diff --git a/Distribution/Data/AppDBContext.cs b/Distribution/Data/AppDBContext.cs index cf82530..62d8bd3 100644 --- a/Distribution/Data/AppDBContext.cs +++ b/Distribution/Data/AppDBContext.cs @@ -41,6 +41,12 @@ public class AppDBContext(DbContextOptions options) : DbContext(op public DbSet SchemeBundleComponents { get; set; } public DbSet LoadingSheets { get; set; } public DbSet LoadingSheetItems { get; set; } + public DbSet PurchaseOrders { get; set; } + public DbSet PurchaseOrderItems { get; set; } + public DbSet GoodsReceipts { get; set; } + public DbSet GoodsReceiptItems { get; set; } + public DbSet PurchaseReturns { get; set; } + public DbSet PurchaseReturnItems { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -160,6 +166,78 @@ public class AppDBContext(DbContextOptions options) : DbContext(op .HasForeignKey(i => i.FK_ItemId) .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(po => po.Supplier) + .WithMany() + .HasForeignKey(po => po.FK_SupplierId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(i => i.PurchaseOrder) + .WithMany(po => po.Items) + .HasForeignKey(i => i.FK_PurchaseOrderId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(i => i.Item) + .WithMany() + .HasForeignKey(i => i.FK_ItemId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(gr => gr.PurchaseOrder) + .WithMany() + .HasForeignKey(gr => gr.FK_PurchaseOrderId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(gr => gr.Warehouse) + .WithMany() + .HasForeignKey(gr => gr.FK_WarehouseId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(i => i.GoodsReceipt) + .WithMany(gr => gr.Items) + .HasForeignKey(i => i.FK_GoodsReceiptId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(i => i.PurchaseOrderItem) + .WithMany() + .HasForeignKey(i => i.FK_PurchaseOrderItemId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(pr => pr.PurchaseOrder) + .WithMany() + .HasForeignKey(pr => pr.FK_PurchaseOrderId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(i => i.PurchaseReturn) + .WithMany(pr => pr.Items) + .HasForeignKey(i => i.FK_PurchaseReturnId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(i => i.Item) + .WithMany() + .HasForeignKey(i => i.FK_ItemId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasIndex(i => i.FK_PurchaseOrderId); + + modelBuilder.Entity() + .HasIndex(i => i.FK_GoodsReceiptId); + + modelBuilder.Entity() + .HasIndex(i => i.FK_PurchaseOrderItemId); + + modelBuilder.Entity() + .HasIndex(i => i.FK_PurchaseReturnId); + Seed.RoleAndPermissionSeed.Apply(modelBuilder); } } diff --git a/Distribution/Features/Grn/GrnController.cs b/Distribution/Features/Grn/GrnController.cs new file mode 100644 index 0000000..05bc233 --- /dev/null +++ b/Distribution/Features/Grn/GrnController.cs @@ -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 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 GetByPurchaseOrder(Guid id) => + Ok(await service.GetByPurchaseOrderIdAsync(id)); + + [HttpPost("api/purchase-orders/{id:guid}/goods-receipts")] + public async Task 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 }); } + } +} diff --git a/Distribution/Features/Grn/GrnDTOs.cs b/Distribution/Features/Grn/GrnDTOs.cs new file mode 100644 index 0000000..1fa768d --- /dev/null +++ b/Distribution/Features/Grn/GrnDTOs.cs @@ -0,0 +1,24 @@ +namespace Distribution.Features.Grn; + +public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int RejectedQuantity); + +public record CreateGrnRequest(Guid WarehouseId, List 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 Items); diff --git a/Distribution/Features/Grn/GrnRepository.cs b/Distribution/Features/Grn/GrnRepository.cs new file mode 100644 index 0000000..40b6572 --- /dev/null +++ b/Distribution/Features/Grn/GrnRepository.cs @@ -0,0 +1,35 @@ +using Distribution.Data; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Features.Grn; + +public class GrnRepository(AppDBContext context) : IGrnRepository +{ + public async Task 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> 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 HasAnyForPurchaseOrderAsync(Guid purchaseOrderId) => + await context.GoodsReceipts.AnyAsync(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive); + + public async Task> 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); +} diff --git a/Distribution/Features/Grn/GrnService.cs b/Distribution/Features/Grn/GrnService.cs new file mode 100644 index 0000000..349cc1f --- /dev/null +++ b/Distribution/Features/Grn/GrnService.cs @@ -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 GetByIdAsync(Guid id) + { + var gr = await repo.GetByIdAsync(id); + if (gr is null || !gr.IsActive) return null; + return Map(gr); + } + + public async Task> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) => + (await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList(); + + /// + /// 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. + /// + public async Task 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!); + } +} diff --git a/Distribution/Features/Grn/IGrnRepository.cs b/Distribution/Features/Grn/IGrnRepository.cs new file mode 100644 index 0000000..2a30abf --- /dev/null +++ b/Distribution/Features/Grn/IGrnRepository.cs @@ -0,0 +1,14 @@ +namespace Distribution.Features.Grn; + +public interface IGrnRepository +{ + Task GetByIdAsync(Guid id); + Task> GetByPurchaseOrderIdAsync(Guid purchaseOrderId); + Task AddAsync(Models.GoodsReceipt entity); + + /// True if at least one GoodsReceipt exists against this Po (createPurchaseReturn guard). + Task HasAnyForPurchaseOrderAsync(Guid purchaseOrderId); + + /// Sum of AcceptedQuantity per ItemId across all receipts for a Po. + Task> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId); +} diff --git a/Distribution/Features/Order/OrderService.cs b/Distribution/Features/Order/OrderService.cs index 21e1527..aa00a6c 100644 --- a/Distribution/Features/Order/OrderService.cs +++ b/Distribution/Features/Order/OrderService.cs @@ -1,6 +1,7 @@ using Distribution.Features.Item; using Distribution.Features.PriceList; using Distribution.Features.Store; +using Distribution.Features.Stock; using Distribution.Infra.Authorization; using Distribution.Infra.UoW; using Distribution.Models; @@ -12,6 +13,7 @@ public class OrderService( IOrderRepository repo, IStoreRepository storeRepo, IItemRepository itemRepo, + IStockRepository stockRepo, PriceListService priceListService, IUnitOfWork uow, ICurrentUserService currentUser, @@ -56,6 +58,19 @@ public class OrderService( return requestedUnitPrice.Value; } + /// + /// 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. + /// + private async Task HasSufficientStockAsync(Guid itemId, decimal quantity) + { + var balances = await stockRepo.GetBalancesByItemAsync(itemId); + var totalAvailable = balances.Sum(b => b.Quantity); + return quantity <= totalAvailable; + } + public async Task> GetAllAsync() { var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList(); @@ -116,6 +131,8 @@ public class OrderService( var orderItems = new List(); 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); if (unitPrice is null) return null; diff --git a/Distribution/Features/PurchaseOrder/IPoRepository.cs b/Distribution/Features/PurchaseOrder/IPoRepository.cs new file mode 100644 index 0000000..9844faf --- /dev/null +++ b/Distribution/Features/PurchaseOrder/IPoRepository.cs @@ -0,0 +1,19 @@ +using Distribution.Models; + +namespace Distribution.Features.Po; + +public interface IPoRepository +{ + Task GetByIdAsync(Guid id); + Task> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status); + Task AddAsync(Models.PurchaseOrder entity); + void Update(Models.PurchaseOrder entity); + + Task AddItemAsync(PurchaseOrderItem entity); + + /// True if any active (non-cancelled, non-closed-and-void) PurchaseOrder references this supplier. + Task HasActiveForSupplierAsync(Guid supplierId); + + /// All active PurchaseOrders for a supplier, with items, used by evaluateSupplier. + Task> GetBySupplierAsync(Guid supplierId); +} diff --git a/Distribution/Features/PurchaseOrder/PoController.cs b/Distribution/Features/PurchaseOrder/PoController.cs new file mode 100644 index 0000000..c1fa3dc --- /dev/null +++ b/Distribution/Features/PurchaseOrder/PoController.cs @@ -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 GetAll([FromQuery] Guid? supplierId, [FromQuery] PurchaseOrderStatus? status) => + Ok(await service.GetAllAsync(supplierId, status)); + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id) + { + var result = await service.GetByIdAsync(id); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost] + public async Task 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 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 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 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 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 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 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 }); } + } +} diff --git a/Distribution/Features/PurchaseOrder/PoDTOs.cs b/Distribution/Features/PurchaseOrder/PoDTOs.cs new file mode 100644 index 0000000..7c9868b --- /dev/null +++ b/Distribution/Features/PurchaseOrder/PoDTOs.cs @@ -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 Items); + +/// +/// 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. +/// +public record UpdatePoLineRequest(Guid? PurchaseOrderItemId, Guid ItemId, int Quantity, decimal UnitCost); + +public record UpdatePoRequest(List 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 Items); + +/// +/// 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. +/// +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 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); diff --git a/Distribution/Features/PurchaseOrder/PoRepository.cs b/Distribution/Features/PurchaseOrder/PoRepository.cs new file mode 100644 index 0000000..334dc6e --- /dev/null +++ b/Distribution/Features/PurchaseOrder/PoRepository.cs @@ -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 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> 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 HasActiveForSupplierAsync(Guid supplierId) => + await context.PurchaseOrders.AnyAsync(po => + po.FK_SupplierId == supplierId && + po.IsActive && + po.Status != PurchaseOrderStatus.Cancelled); + + public async Task> GetBySupplierAsync(Guid supplierId) => + await context.PurchaseOrders + .Include(po => po.Items) + .Where(po => po.FK_SupplierId == supplierId && po.IsActive) + .ToListAsync(); +} diff --git a/Distribution/Features/PurchaseOrder/PoService.cs b/Distribution/Features/PurchaseOrder/PoService.cs new file mode 100644 index 0000000..11184d0 --- /dev/null +++ b/Distribution/Features/PurchaseOrder/PoService.cs @@ -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> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status) => + (await repo.GetAllAsync(supplierId, status)).Select(Map).ToList(); + + public async Task GetByIdAsync(Guid id) + { + var po = await repo.GetByIdAsync(id); + if (po is null || !po.IsActive) return null; + return Map(po); + } + + public async Task 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))!; + } + + /// + /// 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. + /// + public async Task 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))!; + } + + /// + /// Only valid once a Po is Received, or manually closed early (outstanding quantity written off). + /// + public async Task 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); + } + + /// Only valid before any goods receipt exists against the Po. + public async Task 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); + } + + /// + /// 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. + /// + public async Task 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); + } + + /// + /// 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. + /// + public async Task 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."); + } + + /// + /// 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. + /// + public async Task 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); + } +} diff --git a/Distribution/Features/PurchaseReturn/IPurchaseReturnRepository.cs b/Distribution/Features/PurchaseReturn/IPurchaseReturnRepository.cs new file mode 100644 index 0000000..3449ff2 --- /dev/null +++ b/Distribution/Features/PurchaseReturn/IPurchaseReturnRepository.cs @@ -0,0 +1,11 @@ +namespace Distribution.Features.PurchaseReturn; + +public interface IPurchaseReturnRepository +{ + Task GetByIdAsync(Guid id); + Task> GetByPurchaseOrderIdAsync(Guid purchaseOrderId); + Task AddAsync(Models.PurchaseReturn entity); + + /// Sum of Quantity per ItemId across all prior returns for a Po. + Task> GetReturnedTotalsByItemAsync(Guid purchaseOrderId); +} diff --git a/Distribution/Features/PurchaseReturn/PurchaseReturnController.cs b/Distribution/Features/PurchaseReturn/PurchaseReturnController.cs new file mode 100644 index 0000000..f125024 --- /dev/null +++ b/Distribution/Features/PurchaseReturn/PurchaseReturnController.cs @@ -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 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 GetByPurchaseOrder(Guid id) => + Ok(await service.GetByPurchaseOrderIdAsync(id)); + + [HttpPost("api/purchase-orders/{id:guid}/returns")] + public async Task 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 }); } + } +} diff --git a/Distribution/Features/PurchaseReturn/PurchaseReturnDTOs.cs b/Distribution/Features/PurchaseReturn/PurchaseReturnDTOs.cs new file mode 100644 index 0000000..cc5ed88 --- /dev/null +++ b/Distribution/Features/PurchaseReturn/PurchaseReturnDTOs.cs @@ -0,0 +1,15 @@ +namespace Distribution.Features.PurchaseReturn; + +public record PurchaseReturnItemRequest(Guid ItemId, int Quantity); + +public record CreatePurchaseReturnRequest(string Reason, List 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 Items); diff --git a/Distribution/Features/PurchaseReturn/PurchaseReturnRepository.cs b/Distribution/Features/PurchaseReturn/PurchaseReturnRepository.cs new file mode 100644 index 0000000..f082aa6 --- /dev/null +++ b/Distribution/Features/PurchaseReturn/PurchaseReturnRepository.cs @@ -0,0 +1,30 @@ +using Distribution.Data; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Features.PurchaseReturn; + +public class PurchaseReturnRepository(AppDBContext context) : IPurchaseReturnRepository +{ + public async Task GetByIdAsync(Guid id) => + await context.PurchaseReturns + .Include(pr => pr.Items).ThenInclude(i => i.Item) + .FirstOrDefaultAsync(pr => pr.Id == id); + + public async Task> 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> 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); +} diff --git a/Distribution/Features/PurchaseReturn/PurchaseReturnService.cs b/Distribution/Features/PurchaseReturn/PurchaseReturnService.cs new file mode 100644 index 0000000..1e08795 --- /dev/null +++ b/Distribution/Features/PurchaseReturn/PurchaseReturnService.cs @@ -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 GetByIdAsync(Guid id) + { + var pr = await repo.GetByIdAsync(id); + if (pr is null || !pr.IsActive) return null; + return Map(pr); + } + + public async Task> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) => + (await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList(); + + /// + /// 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. + /// + public async Task 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!); + } +} diff --git a/Distribution/Features/Supplier/SupplierController.cs b/Distribution/Features/Supplier/SupplierController.cs index f1c83f9..ad950ea 100644 --- a/Distribution/Features/Supplier/SupplierController.cs +++ b/Distribution/Features/Supplier/SupplierController.cs @@ -36,7 +36,18 @@ public class SupplierController(SupplierService service) : ControllerBase [HttpDelete("{id:guid}")] public async Task Delete(Guid id, CancellationToken ct) { - var deleted = await service.DeleteAsync(id, ct); - return deleted ? NoContent() : NotFound(); + try + { + 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 Evaluate(Guid id, CancellationToken ct) + { + var result = await service.EvaluateAsync(id, ct); + return result is null ? NotFound() : Ok(result); } } diff --git a/Distribution/Features/Supplier/SupplierService.cs b/Distribution/Features/Supplier/SupplierService.cs index 27013ec..4b4ce43 100644 --- a/Distribution/Features/Supplier/SupplierService.cs +++ b/Distribution/Features/Supplier/SupplierService.cs @@ -1,8 +1,14 @@ +using Distribution.Features.Grn; +using Distribution.Features.Po; using Distribution.Infra.UoW; 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) => 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); 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); try { repo.Remove(supplier); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return true; } + + /// + /// 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. + /// + public async Task 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); + } } diff --git a/Distribution/Migrations/20260824110925_AddProcurement.Designer.cs b/Distribution/Migrations/20260824110925_AddProcurement.Designer.cs new file mode 100644 index 0000000..529485a --- /dev/null +++ b/Distribution/Migrations/20260824110925_AddProcurement.Designer.cs @@ -0,0 +1,2062 @@ +// +using System; +using Distribution.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Distribution.Migrations +{ + [DbContext(typeof(AppDBContext))] + [Migration("20260824110925_AddProcurement")] + partial class AddProcurement + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Distribution.Models.Brand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExternalErpId") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Brands"); + }); + + modelBuilder.Entity("Distribution.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AllowCustomerReturn") + .HasColumnType("tinyint(1)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExternalErpId") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Distribution.Models.DeliveryRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ActivatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_DriverProfileId") + .HasColumnType("char(36)"); + + b.Property("FK_RouteId") + .HasColumnType("char(36)"); + + b.Property("FK_SourceWarehouseId") + .HasColumnType("char(36)"); + + b.Property("FK_VehicleId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Meterend") + .HasColumnType("int"); + + b.Property("Meterstart") + .HasColumnType("int"); + + b.Property("RideType") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_DriverProfileId"); + + b.HasIndex("FK_RouteId"); + + b.HasIndex("FK_SourceWarehouseId"); + + b.HasIndex("FK_VehicleId"); + + b.ToTable("DeliveryRuns"); + }); + + modelBuilder.Entity("Distribution.Models.DriverComplianceConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("EnforceLicenseExpiry") + .HasColumnType("tinyint(1)"); + + b.Property("EnforceMedicalClearance") + .HasColumnType("tinyint(1)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.ToTable("DriverComplianceConfigs"); + }); + + modelBuilder.Entity("Distribution.Models.DriverProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_UserId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("LicenseExpiry") + .HasColumnType("datetime(6)"); + + b.Property("LicenseNumber") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("MedicalClearanceExpiry") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_UserId") + .IsUnique(); + + b.ToTable("DriverProfiles"); + }); + + modelBuilder.Entity("Distribution.Models.GoodsReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_PurchaseOrderId") + .HasColumnType("char(36)"); + + b.Property("FK_ReceivedByUserId") + .HasColumnType("char(36)"); + + b.Property("FK_WarehouseId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcceptedQuantity") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_GoodsReceiptId") + .HasColumnType("char(36)"); + + b.Property("FK_PurchaseOrderItemId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("RejectedQuantity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_GoodsReceiptId"); + + b.HasIndex("FK_PurchaseOrderItemId"); + + b.ToTable("GoodsReceiptItems"); + }); + + modelBuilder.Entity("Distribution.Models.Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CostPrice") + .HasColumnType("decimal(65,30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("ExternalErpItemId") + .HasColumnType("int"); + + b.Property("FK_BaseUomId") + .HasColumnType("char(36)"); + + b.Property("FK_BrandId") + .HasColumnType("char(36)"); + + b.Property("FK_CategoryId") + .HasColumnType("char(36)"); + + b.Property("FK_SubCategoryId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SalePrice") + .HasColumnType("decimal(65,30)"); + + b.Property("Sku") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SyncSource") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_BaseUomId"); + + b.HasIndex("FK_BrandId"); + + b.HasIndex("FK_CategoryId"); + + b.HasIndex("FK_SubCategoryId"); + + b.ToTable("Items"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DispatchConfirmedAt") + .HasColumnType("datetime(6)"); + + b.Property("DispatchConfirmedBy") + .HasColumnType("char(36)"); + + b.Property("FK_RideId") + .HasColumnType("char(36)"); + + b.Property("FK_SourceWarehouseId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("ReceiptConfirmedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReceiptConfirmedBy") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_RideId"); + + b.HasIndex("FK_SourceWarehouseId"); + + b.ToTable("LoadingSheets"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheetItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_LoadingSheetId") + .HasColumnType("char(36)"); + + b.Property("QuantityDispatched") + .HasColumnType("decimal(65,30)"); + + b.Property("QuantityReceived") + .HasColumnType("decimal(65,30)"); + + b.Property("QuantityResolved") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_LoadingSheetId"); + + b.ToTable("LoadingSheetItems"); + }); + + modelBuilder.Entity("Distribution.Models.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("OrderDate") + .HasColumnType("datetime(6)"); + + b.Property("TotalAmount") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_StoreId"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("Distribution.Models.OrderItem", b => + { + b.Property("FK_OrderId") + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasColumnType("decimal(65,30)"); + + b.HasKey("FK_OrderId", "FK_ItemId"); + + b.HasIndex("FK_ItemId"); + + b.ToTable("OrderItems"); + }); + + modelBuilder.Entity("Distribution.Models.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Distribution.Models.PriceList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveFrom") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveTo") + .HasColumnType("datetime(6)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_RegionId"); + + b.ToTable("PriceLists"); + }); + + modelBuilder.Entity("Distribution.Models.PriceListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_PriceListId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("MinPrice") + .HasColumnType("decimal(65,30)"); + + b.Property("Price") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_PriceListId", "FK_ItemId") + .IsUnique(); + + b.ToTable("PriceListItems"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CancelReason") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_CreatedByUserId") + .HasColumnType("char(36)"); + + b.Property("FK_SupplierId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_SupplierId"); + + b.ToTable("PurchaseOrders"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseOrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CancelledQuantity") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_PurchaseOrderId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsCancelled") + .HasColumnType("tinyint(1)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("ReceivedQuantity") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_PurchaseOrderId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_PurchaseOrderId"); + + b.ToTable("PurchaseReturns"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseReturnItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_PurchaseReturnId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_PurchaseReturnId"); + + b.ToTable("PurchaseReturnItems"); + }); + + modelBuilder.Entity("Distribution.Models.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Distribution.Models.RideCancelRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApprovedBy") + .HasColumnType("char(36)"); + + b.Property("FK_RideId") + .HasColumnType("char(36)"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RequestedAt") + .HasColumnType("datetime(6)"); + + b.Property("RequestedBy") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TypeCode") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_RideId"); + + b.ToTable("RideCancelRequests"); + }); + + modelBuilder.Entity("Distribution.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Level") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + + b.HasData( + new + { + Id = new Guid("10000000-0000-0000-0000-000000000001"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 10, + Name = "SalesRep" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000002"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 20, + Name = "Supervisor" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000003"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 30, + Name = "AreaManager" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000004"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 40, + Name = "AreaSupervisor" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000005"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 50, + Name = "RegionalManager" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000006"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 60, + Name = "RegionalAgency" + }); + }); + + modelBuilder.Entity("Distribution.Models.RolePermission", b => + { + b.Property("FK_RoleId") + .HasColumnType("char(36)"); + + b.Property("FK_PermissionId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("FK_RoleId", "FK_PermissionId"); + + b.HasIndex("FK_PermissionId"); + + b.ToTable("RolePermissions"); + }); + + modelBuilder.Entity("Distribution.Models.Route", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndPoint") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Length") + .HasColumnType("decimal(65,30)"); + + b.Property("RouteName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("StartPoint") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_RegionId"); + + b.ToTable("Routes"); + }); + + modelBuilder.Entity("Distribution.Models.SaleRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("SaleRoutes"); + }); + + modelBuilder.Entity("Distribution.Models.Scheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AgentLimitExempt") + .HasColumnType("tinyint(1)"); + + b.Property("BaselineExpectedSales") + .HasColumnType("decimal(65,30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DateRangeFrom") + .HasColumnType("datetime(6)"); + + b.Property("DateRangeTo") + .HasColumnType("datetime(6)"); + + b.Property("EligibleSegment") + .HasColumnType("longtext"); + + b.Property("FK_FreeItemId") + .HasColumnType("char(36)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("FK_TriggerItemId") + .HasColumnType("char(36)"); + + b.Property("FreeQty") + .HasColumnType("decimal(65,30)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RegionId") + .HasColumnType("char(36)"); + + b.Property("RuleConfig") + .HasColumnType("longtext"); + + b.Property("RuleType") + .HasColumnType("int"); + + b.Property("TriggerQty") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_FreeItemId"); + + b.HasIndex("FK_TriggerItemId"); + + b.HasIndex("RegionId"); + + b.ToTable("Schemes"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeBundleComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_SchemeId") + .HasColumnType("char(36)"); + + b.Property("Qty") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_SchemeId", "FK_ItemId") + .IsUnique(); + + b.ToTable("SchemeBundleComponents"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeRedemption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("DiscountAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("FK_FreeItemId") + .HasColumnType("char(36)"); + + b.Property("FK_OrderId") + .HasColumnType("char(36)"); + + b.Property("FK_SchemeId") + .HasColumnType("char(36)"); + + b.Property("FieldInvoiceId") + .HasColumnType("char(36)"); + + b.Property("FreeItemCostValue") + .HasColumnType("decimal(65,30)"); + + b.Property("FreeQty") + .HasColumnType("decimal(65,30)"); + + b.Property("RedeemedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_FreeItemId"); + + b.HasIndex("FK_OrderId"); + + b.HasIndex("FK_SchemeId"); + + b.ToTable("SchemeRedemptions"); + }); + + modelBuilder.Entity("Distribution.Models.StockBalance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("LocationId") + .HasColumnType("char(36)"); + + b.Property("LocationType") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("decimal(65,30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId", "LocationId") + .IsUnique(); + + b.ToTable("StockBalances"); + }); + + modelBuilder.Entity("Distribution.Models.StockMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FromLocationId") + .HasColumnType("char(36)"); + + b.Property("FromLocationType") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("decimal(65,30)"); + + b.Property("ReasonCode") + .HasColumnType("longtext"); + + b.Property("ReferenceId") + .HasColumnType("char(36)"); + + b.Property("ToLocationId") + .HasColumnType("char(36)"); + + b.Property("ToLocationType") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.ToTable("StockMovements"); + }); + + modelBuilder.Entity("Distribution.Models.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreditAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreditLimit") + .HasColumnType("decimal(65,30)"); + + b.Property("FK_RouteId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("KycStatus") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("decimal(65,30)"); + + b.Property("Longitude") + .HasColumnType("decimal(65,30)"); + + b.Property("OneTimeAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("OutstandingBalance") + .HasColumnType("decimal(65,30)"); + + b.Property("OwnerName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Segment") + .HasColumnType("longtext"); + + b.Property("StoreAddress") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_RouteId"); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("Distribution.Models.StoreKycDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DocumentType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("FileReference") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("UploadedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_StoreId"); + + b.ToTable("StoreKycDocuments"); + }); + + modelBuilder.Entity("Distribution.Models.SubCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExternalErpId") + .HasColumnType("int"); + + b.Property("FK_CategoryId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_CategoryId"); + + b.ToTable("SubCategories"); + }); + + modelBuilder.Entity("Distribution.Models.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ContactInfo") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PerformanceScore") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.ToTable("Suppliers"); + }); + + modelBuilder.Entity("Distribution.Models.Uom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExternalErpId") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Uoms"); + }); + + modelBuilder.Entity("Distribution.Models.UomConversion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("FK_FromUomId") + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_ToUomId") + .HasColumnType("char(36)"); + + b.Property("Factor") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_FromUomId"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_ToUomId"); + + b.ToTable("UomConversions"); + }); + + modelBuilder.Entity("Distribution.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ReportsToUserId") + .HasColumnType("char(36)"); + + b.Property("FK_RoleId") + .HasColumnType("char(36)"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Username") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_ReportsToUserId"); + + b.HasIndex("FK_RoleId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Distribution.Models.UserRegionScope", b => + { + b.Property("FK_UserId") + .HasColumnType("char(36)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("FK_UserId", "FK_RegionId"); + + b.HasIndex("FK_RegionId"); + + b.ToTable("UserRegionScopes"); + }); + + modelBuilder.Entity("Distribution.Models.UserRouteScope", b => + { + b.Property("FK_UserId") + .HasColumnType("char(36)"); + + b.Property("FK_RouteId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("FK_UserId", "FK_RouteId"); + + b.HasIndex("FK_RouteId"); + + b.ToTable("UserRouteScopes"); + }); + + modelBuilder.Entity("Distribution.Models.UserStoreScope", b => + { + b.Property("FK_UserId") + .HasColumnType("char(36)"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("FK_UserId", "FK_StoreId"); + + b.HasIndex("FK_StoreId"); + + b.ToTable("UserStoreScopes"); + }); + + modelBuilder.Entity("Distribution.Models.Vehicle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DefaultWarehouseId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("NumberPlate") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RegNumber") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DefaultWarehouseId"); + + b.ToTable("Vehicles"); + }); + + modelBuilder.Entity("Distribution.Models.Warehouse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Location") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("VehicleId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId"); + + b.ToTable("Warehouses"); + }); + + modelBuilder.Entity("Distribution.Models.DeliveryRun", b => + { + b.HasOne("Distribution.Models.DriverProfile", "DriverProfile") + .WithMany() + .HasForeignKey("FK_DriverProfileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Route", "Route") + .WithMany() + .HasForeignKey("FK_RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Warehouse", "SourceWarehouse") + .WithMany() + .HasForeignKey("FK_SourceWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("FK_VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DriverProfile"); + + b.Navigation("Route"); + + b.Navigation("SourceWarehouse"); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("Distribution.Models.DriverProfile", b => + { + b.HasOne("Distribution.Models.User", "User") + .WithMany() + .HasForeignKey("FK_UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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 => + { + b.HasOne("Distribution.Models.Uom", "BaseUom") + .WithMany() + .HasForeignKey("FK_BaseUomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Brand", "Brand") + .WithMany() + .HasForeignKey("FK_BrandId"); + + b.HasOne("Distribution.Models.Category", "Category") + .WithMany() + .HasForeignKey("FK_CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.SubCategory", "SubCategory") + .WithMany() + .HasForeignKey("FK_SubCategoryId"); + + b.Navigation("BaseUom"); + + b.Navigation("Brand"); + + b.Navigation("Category"); + + b.Navigation("SubCategory"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheet", b => + { + b.HasOne("Distribution.Models.DeliveryRun", "Ride") + .WithMany() + .HasForeignKey("FK_RideId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Warehouse", "SourceWarehouse") + .WithMany() + .HasForeignKey("FK_SourceWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ride"); + + b.Navigation("SourceWarehouse"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheetItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.LoadingSheet", "LoadingSheet") + .WithMany("Items") + .HasForeignKey("FK_LoadingSheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("LoadingSheet"); + }); + + modelBuilder.Entity("Distribution.Models.Order", b => + { + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("Distribution.Models.OrderItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Order", "Order") + .WithMany() + .HasForeignKey("FK_OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("Distribution.Models.PriceList", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Distribution.Models.PriceListItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.PriceList", "PriceList") + .WithMany("Items") + .HasForeignKey("FK_PriceListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PriceList"); + }); + + modelBuilder.Entity("Distribution.Models.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 => + { + b.HasOne("Distribution.Models.DeliveryRun", "Ride") + .WithMany() + .HasForeignKey("FK_RideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ride"); + }); + + modelBuilder.Entity("Distribution.Models.RolePermission", b => + { + b.HasOne("Distribution.Models.Permission", "Permission") + .WithMany() + .HasForeignKey("FK_PermissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Role", "Role") + .WithMany() + .HasForeignKey("FK_RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Permission"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Distribution.Models.Route", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Distribution.Models.Scheme", b => + { + b.HasOne("Distribution.Models.Item", "FreeItem") + .WithMany() + .HasForeignKey("FK_FreeItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Item", "TriggerItem") + .WithMany() + .HasForeignKey("FK_TriggerItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("RegionId"); + + b.Navigation("FreeItem"); + + b.Navigation("Region"); + + b.Navigation("TriggerItem"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeBundleComponent", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Scheme", "Scheme") + .WithMany("BundleComponents") + .HasForeignKey("FK_SchemeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Scheme"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeRedemption", b => + { + b.HasOne("Distribution.Models.Item", "FreeItem") + .WithMany() + .HasForeignKey("FK_FreeItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Order", "Order") + .WithMany() + .HasForeignKey("FK_OrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Scheme", "Scheme") + .WithMany("Redemptions") + .HasForeignKey("FK_SchemeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FreeItem"); + + b.Navigation("Order"); + + b.Navigation("Scheme"); + }); + + modelBuilder.Entity("Distribution.Models.StockBalance", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Distribution.Models.StockMovement", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Distribution.Models.Store", b => + { + b.HasOne("Distribution.Models.Route", "Route") + .WithMany() + .HasForeignKey("FK_RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("Distribution.Models.StoreKycDocument", b => + { + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("Distribution.Models.SubCategory", b => + { + b.HasOne("Distribution.Models.Category", "Category") + .WithMany() + .HasForeignKey("FK_CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Distribution.Models.UomConversion", b => + { + b.HasOne("Distribution.Models.Uom", "FromUom") + .WithMany() + .HasForeignKey("FK_FromUomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Item", "Item") + .WithMany("Conversions") + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Uom", "ToUom") + .WithMany() + .HasForeignKey("FK_ToUomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FromUom"); + + b.Navigation("Item"); + + b.Navigation("ToUom"); + }); + + modelBuilder.Entity("Distribution.Models.User", b => + { + b.HasOne("Distribution.Models.User", "ReportsTo") + .WithMany() + .HasForeignKey("FK_ReportsToUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Role", "Role") + .WithMany() + .HasForeignKey("FK_RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReportsTo"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Distribution.Models.UserRegionScope", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "User") + .WithMany() + .HasForeignKey("FK_UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Distribution.Models.UserRouteScope", b => + { + b.HasOne("Distribution.Models.Route", "Route") + .WithMany() + .HasForeignKey("FK_RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "User") + .WithMany() + .HasForeignKey("FK_UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Distribution.Models.UserStoreScope", b => + { + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "User") + .WithMany() + .HasForeignKey("FK_UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Store"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Distribution.Models.Vehicle", b => + { + b.HasOne("Distribution.Models.Warehouse", null) + .WithMany() + .HasForeignKey("DefaultWarehouseId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("Distribution.Models.Warehouse", b => + { + b.HasOne("Distribution.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId"); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("Distribution.Models.GoodsReceipt", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Distribution.Models.Item", b => + { + b.Navigation("Conversions"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheet", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Distribution.Models.PriceList", b => + { + 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 => + { + b.Navigation("BundleComponents"); + + b.Navigation("Redemptions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Distribution/Migrations/20260824110925_AddProcurement.cs b/Distribution/Migrations/20260824110925_AddProcurement.cs new file mode 100644 index 0000000..75c0682 --- /dev/null +++ b/Distribution/Migrations/20260824110925_AddProcurement.cs @@ -0,0 +1,257 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Distribution.Migrations +{ + /// + public partial class AddProcurement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PurchaseOrders", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_SupplierId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_CreatedByUserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Status = table.Column(type: "int", nullable: false), + CancelReason = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + IsActive = table.Column(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(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_PurchaseOrderId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_ReceivedByUserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_WarehouseId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + ReceivedAt = table.Column(type: "datetime(6)", nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + IsActive = table.Column(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(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_PurchaseOrderId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_ItemId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Quantity = table.Column(type: "int", nullable: false), + UnitCost = table.Column(type: "decimal(65,30)", nullable: false), + ReceivedQuantity = table.Column(type: "int", nullable: false), + CancelledQuantity = table.Column(type: "int", nullable: false), + IsCancelled = table.Column(type: "tinyint(1)", nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + IsActive = table.Column(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(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_PurchaseOrderId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Reason = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + IsActive = table.Column(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(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_GoodsReceiptId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_PurchaseOrderItemId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + AcceptedQuantity = table.Column(type: "int", nullable: false), + RejectedQuantity = table.Column(type: "int", nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + IsActive = table.Column(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(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_PurchaseReturnId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_ItemId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Quantity = table.Column(type: "int", nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + IsActive = table.Column(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"); + } + + /// + 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"); + } + } +} diff --git a/Distribution/Migrations/AppDBContextModelSnapshot.cs b/Distribution/Migrations/AppDBContextModelSnapshot.cs index d1ac609..45137df 100644 --- a/Distribution/Migrations/AppDBContextModelSnapshot.cs +++ b/Distribution/Migrations/AppDBContextModelSnapshot.cs @@ -187,6 +187,72 @@ namespace Distribution.Migrations b.ToTable("DriverProfiles"); }); + modelBuilder.Entity("Distribution.Models.GoodsReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_PurchaseOrderId") + .HasColumnType("char(36)"); + + b.Property("FK_ReceivedByUserId") + .HasColumnType("char(36)"); + + b.Property("FK_WarehouseId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcceptedQuantity") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_GoodsReceiptId") + .HasColumnType("char(36)"); + + b.Property("FK_PurchaseOrderItemId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("RejectedQuantity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_GoodsReceiptId"); + + b.HasIndex("FK_PurchaseOrderItemId"); + + b.ToTable("GoodsReceiptItems"); + }); + modelBuilder.Entity("Distribution.Models.Item", b => { b.Property("Id") @@ -471,6 +537,135 @@ namespace Distribution.Migrations b.ToTable("PriceListItems"); }); + modelBuilder.Entity("Distribution.Models.PurchaseOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CancelReason") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_CreatedByUserId") + .HasColumnType("char(36)"); + + b.Property("FK_SupplierId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_SupplierId"); + + b.ToTable("PurchaseOrders"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseOrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CancelledQuantity") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_PurchaseOrderId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsCancelled") + .HasColumnType("tinyint(1)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("ReceivedQuantity") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_PurchaseOrderId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_PurchaseOrderId"); + + b.ToTable("PurchaseReturns"); + }); + + modelBuilder.Entity("Distribution.Models.PurchaseReturnItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_PurchaseReturnId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_PurchaseReturnId"); + + b.ToTable("PurchaseReturnItems"); + }); + modelBuilder.Entity("Distribution.Models.Region", b => { b.Property("Id") @@ -1315,6 +1510,44 @@ namespace Distribution.Migrations 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 => { b.HasOne("Distribution.Models.Uom", "BaseUom") @@ -1442,6 +1675,66 @@ namespace Distribution.Migrations 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 => { b.HasOne("Distribution.Models.DeliveryRun", "Ride") @@ -1724,6 +2017,11 @@ namespace Distribution.Migrations b.Navigation("Vehicle"); }); + modelBuilder.Entity("Distribution.Models.GoodsReceipt", b => + { + b.Navigation("Items"); + }); + modelBuilder.Entity("Distribution.Models.Item", b => { b.Navigation("Conversions"); @@ -1739,6 +2037,16 @@ namespace Distribution.Migrations 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 => { b.Navigation("BundleComponents"); diff --git a/Distribution/Models/Enums.cs b/Distribution/Models/Enums.cs index cb588a3..311200e 100644 --- a/Distribution/Models/Enums.cs +++ b/Distribution/Models/Enums.cs @@ -19,7 +19,9 @@ namespace Distribution.Models Transfer, Damage, Adjustment, - Load + Load, + PurchaseReceipt, + PurchaseReturn } public enum LoadingSheetStatus @@ -38,4 +40,6 @@ namespace Distribution.Models public enum CancelRequestType { VehicleBreakdown, DriverUnavailable, RouteIssue, Other } public enum CancelRequestStatus { Pending, Approved, Rejected } + + public enum PurchaseOrderStatus { Issued, PartiallyReceived, Received, Closed, Cancelled } } diff --git a/Distribution/Models/GoodsReceipt.cs b/Distribution/Models/GoodsReceipt.cs new file mode 100644 index 0000000..4ce1368 --- /dev/null +++ b/Distribution/Models/GoodsReceipt.cs @@ -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; } + + /// + /// 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. + /// + public Guid FK_WarehouseId { get; set; } + [ForeignKey("FK_WarehouseId")] + public Warehouse Warehouse { get; set; } + + public DateTime ReceivedAt { get; set; } = DateTime.Now; + + public ICollection Items { get; set; } = []; + } +} diff --git a/Distribution/Models/GoodsReceiptItem.cs b/Distribution/Models/GoodsReceiptItem.cs new file mode 100644 index 0000000..7a04ebb --- /dev/null +++ b/Distribution/Models/GoodsReceiptItem.cs @@ -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; } + + /// Added to warehouse stock. + public int AcceptedQuantity { get; set; } + + /// Damaged/short at receipt, not added to stock. + public int RejectedQuantity { get; set; } = 0; + } +} diff --git a/Distribution/Models/PurchaseOrder.cs b/Distribution/Models/PurchaseOrder.cs new file mode 100644 index 0000000..e785d6a --- /dev/null +++ b/Distribution/Models/PurchaseOrder.cs @@ -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 Items { get; set; } = []; + } +} diff --git a/Distribution/Models/PurchaseOrderItem.cs b/Distribution/Models/PurchaseOrderItem.cs new file mode 100644 index 0000000..5ad9954 --- /dev/null +++ b/Distribution/Models/PurchaseOrderItem.cs @@ -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; } + + /// Agreed cost per unit at the time the PO was issued. + public decimal UnitCost { get; set; } + + /// Running total, incremented by createGoodsReceipt; drives PurchaseOrder.Status. + public int ReceivedQuantity { get; set; } = 0; + + /// + /// Set by cancelPurchaseOrderLine. Outstanding qty for receiving purposes is + /// Quantity - ReceivedQuantity - CancelledQuantity. + /// + public int CancelledQuantity { get; set; } = 0; + + /// True once this line is individually cancelled. + public bool IsCancelled { get; set; } = false; + } +} diff --git a/Distribution/Models/PurchaseReturn.cs b/Distribution/Models/PurchaseReturn.cs new file mode 100644 index 0000000..4f487bd --- /dev/null +++ b/Distribution/Models/PurchaseReturn.cs @@ -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 Items { get; set; } = []; + } +} diff --git a/Distribution/Models/PurchaseReturnItem.cs b/Distribution/Models/PurchaseReturnItem.cs new file mode 100644 index 0000000..54a4620 --- /dev/null +++ b/Distribution/Models/PurchaseReturnItem.cs @@ -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; } + + /// Returned quantity, decremented from warehouse stock. + public int Quantity { get; set; } + } +} diff --git a/Distribution/Program.cs b/Distribution/Program.cs index a4282e9..c741a26 100644 --- a/Distribution/Program.cs +++ b/Distribution/Program.cs @@ -5,9 +5,12 @@ using Distribution.Features.Brand; using Distribution.Features.Category; using Distribution.Features.DriverComplianceConfig; using Distribution.Features.DriverProfile; +using Distribution.Features.Grn; using Distribution.Features.Item; using Distribution.Features.Order; using Distribution.Features.PriceList; +using Distribution.Features.Po; +using Distribution.Features.PurchaseReturn; using Distribution.Features.Region; using Distribution.Features.LoadingEvent; using Distribution.Features.Ride; @@ -63,6 +66,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -87,6 +93,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); diff --git a/Distribution/docs/15_procurement.md b/Distribution/docs/15_procurement.md index a4d36c5..aa2f165 100644 --- a/Distribution/docs/15_procurement.md +++ b/Distribution/docs/15_procurement.md @@ -1,74 +1,413 @@ # 15 — Procurement / Purchasing -**Status**: Not Started -**Depends On**: 01 — Item Catalog, 14 — Pricing & Supplier Master Data +**Status**: Done — migration applied to the database +**Depends On**: 01 — Item Catalog (Done), 14 — Pricing & Supplier Master Data (Done) --- ## Goal -Covers [mainPlan.md §9](./mainPlan.md#9-procurement--purchasing) in full -— an entirely new subsystem, nothing in this area exists in the current schema. +Covers [mainPlan.md §9](./mainPlan.md#9-procurement--purchasing) — inbound stock from suppliers, +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 Inbound stock (`createGoodsReceipt` in [04_loading_event.md](./04_loading_event.md)/ -[mainPlan.md §6](./mainPlan.md#6-warehouse-management)) currently has no -purchase order to receive against — this step is what creates that PO in the first place. +[mainPlan.md §6](./mainPlan.md#6-warehouse-management)) had no purchase order to receive against — +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 -### Purchase Requisition +Six new tables — three header/line-item pairs (mirrors the `PriceList`/`PriceListItem` split). -`RequestedByUserId`, `Status` (`Draft`, `Submitted`, `Approved`, `Rejected`), line items -(`ItemId`, `Quantity`). +### PurchaseOrder +``` +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` -(`Issued`, `PartiallyReceived`, `Received`, `Closed`), line items (`ItemId`, `Quantity`, `UnitCost`). +### GoodsReceipt +``` +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 -| 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` | -| `matchInvoiceToPO(invoiceId, poId, grnId)` | `POST /api/purchase-orders/{id}/match-invoice` | -| `evaluateSupplier(supplierId, criteria)` | `POST /api/suppliers/{id}/evaluate` | +| `getPurchaseOrder(poId)` | `GET /api/purchase-orders/{id}` | +| `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` | +| `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 -- `createGoodsReceipt` (§6, [04_loading_event.md](./04_loading_event.md)'s warehouse-inbound - counterpart) must reference an `Issued`/`PartiallyReceived` Purchase Order — no receipts against - a closed or nonexistent PO. -- `matchInvoiceToPO` (3-way match) must block payment posting in - [19_finance_accounting.md](./19_finance_accounting.md) until PO, GRN, and invoice quantities/ - amounts reconcile within a configurable tolerance. +1. `createPurchaseOrder` is the entry point to procurement — no prior approval step. A PO starts + in `Issued`. +2. `createGoodsReceipt` must reference an `Issued`/`PartiallyReceived` PurchaseOrder — no receipts + against a `Closed`/`Cancelled`/nonexistent PO. Each receipt increments + `PurchaseOrderItem.ReceivedQuantity` by `AcceptedQuantity`, credits `StockBalance` at the + 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 -- Is procurement in scope for this project at all right now, or is inbound stock assumed to be - seeded/manual for the foreseeable future? Confirm before investing in this step — it's the - largest net-new subsystem in the reference doc. +- Where should cost-vs-sale-price margin reporting (`PurchaseOrderItem.UnitCost` vs + `PriceListItem.Price`/`Item.SalePrice`) live — a read-only endpoint/report in this step, or a + 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 -> 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 +``` diff --git a/Distribution/docs/mainPlan.md b/Distribution/docs/mainPlan.md index 1898a00..b719286 100644 --- a/Distribution/docs/mainPlan.md +++ b/Distribution/docs/mainPlan.md @@ -16,6 +16,7 @@ | 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) | | 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) @@ -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) | | 13 | Fleet Management (Beyond CRUD) | Not Started | 00 | [13_fleet_management.md](./13_fleet_management.md) | | 14 | Pricing & Supplier Master Data | Done | 01 | [14_pricing_and_suppliers.md](./14_pricing_and_suppliers.md) | -| 15 | Procurement / Purchasing | Not Started | 01, 14 | [15_procurement.md](./15_procurement.md) | +| 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) | | 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) | @@ -82,9 +83,9 @@ here — see `SRS_User_Management_Module.pdf`. | 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. | -| `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. | | `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. | @@ -156,7 +157,7 @@ here — see `SRS_User_Management_Module.pdf`. | 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. | | `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). | @@ -195,10 +196,10 @@ here — see `SRS_User_Management_Module.pdf`. | Function | What it does | Status | |---|---|---| | `createPurchaseRequisition(items[], requestedBy)` | Raises an internal request to purchase stock. | Not implemented — no procurement entities exist. | -| `createPurchaseOrder(supplierId, items[])` | Issues a PO to a supplier. | Not implemented. | -| `matchInvoiceToPO(invoiceId, poId, grnId)` | Performs the 3-way match (PO–GRN–Invoice) before payment. | Not implemented. | -| `evaluateSupplier(supplierId, criteria)` | Scores a supplier on delivery/quality/price performance. | Not implemented. | -| `createPurchaseReturn(poId, items[], reason)` | Initiates a return of received goods to the supplier. | Not implemented. | +| `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. | 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. | 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. | 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