Compare commits

..

12 Commits

Author SHA1 Message Date
Kalana B. Thilakarathna cefefcbc49 md update 2026-08-24 21:42:13 +05:30
Kalana B. Thilakarathna 6d8b6b3e59 Add DeliveryTask, Payment, and Sale features
Implemented DeliveryTask, Payment, and Sale entities with full CRUD, business logic, and API endpoints. Updated Order/OrderItem for sale linkage and fulfillment tracking. Registered new DbSets and DI services. Revised documentation for delivery, sale, and payment workflows.
2026-08-24 21:34:57 +05:30
DeepnaPooja a833586e83 Merge pull request 'Feat/procurement' (#8) from feat/procurement into dev
Reviewed-on: #8
2026-08-24 11:28:48 +00:00
DeepnaPooja 3bc33ce08f Update procurement and master plan docs to match implementation 2026-08-24 16:52:26 +05:30
DeepnaPooja 870b412a06 Check stock availability when creating an order 2026-08-24 16:52:20 +05:30
DeepnaPooja 095d35e1b8 Add supplier evaluation and block delete when referenced by a purchase order 2026-08-24 16:52:12 +05:30
DeepnaPooja d7b03941fb Wire up procurement services in DI 2026-08-24 16:52:06 +05:30
DeepnaPooja 81a49e1a98 Add purchase return feature (returns received stock to a supplier) 2026-08-24 16:51:59 +05:30
DeepnaPooja 2995ce7012 Add goods receipt feature (creates stock from a purchase order) 2026-08-24 16:51:52 +05:30
DeepnaPooja db0850f1f0 Add purchase order feature (create/update/close/cancel/match-invoice) 2026-08-24 16:51:44 +05:30
DeepnaPooja 702dbeb39f Add procurement tables migration 2026-08-24 16:51:37 +05:30
DeepnaPooja c8d23afe59 Add procurement domain models (PurchaseOrder, GoodsReceipt, PurchaseReturn) 2026-08-24 16:51:22 +05:30
64 changed files with 8177 additions and 588 deletions
+122
View File
@@ -17,6 +17,7 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<UomConversion> UomConversions { get; set; }
public DbSet<Item> Items { get; set; }
public DbSet<SaleRoute> SaleRoutes { get; set; }
public DbSet<Sale> Sales { get; set; }
public DbSet<Order> Orders { get; set; }
public DbSet<OrderItem> OrderItems { get; set; }
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
@@ -41,6 +42,14 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
public DbSet<LoadingSheet> LoadingSheets { get; set; }
public DbSet<LoadingSheetItem> LoadingSheetItems { get; set; }
public DbSet<Payment> Payments { get; set; }
public DbSet<DeliveryTask> DeliveryTasks { get; set; }
public DbSet<PurchaseOrder> PurchaseOrders { get; set; }
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -73,6 +82,10 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId })
.IsUnique();
modelBuilder.Entity<Order>()
.HasIndex(o => o.FK_SaleId)
.IsUnique();
modelBuilder.Entity<DriverProfile>()
.HasIndex(dp => dp.FK_UserId)
.IsUnique();
@@ -96,6 +109,31 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(r => r.FK_DriverProfileId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>()
.HasOne(s => s.Store)
.WithMany()
.HasForeignKey(s => s.FK_StoreId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>()
.HasOne(s => s.CreatedByUser)
.WithMany()
.HasForeignKey(s => s.CreatedByUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Sale>()
.HasOne(s => s.Ride)
.WithMany()
.HasForeignKey(s => s.FK_RideId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<Order>()
.HasOne(o => o.Sale)
.WithMany()
.HasForeignKey(o => o.FK_SaleId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SchemeRedemption>()
.HasOne(r => r.Scheme)
.WithMany(s => s.Redemptions)
@@ -160,6 +198,90 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrder>()
.HasOne(po => po.Supplier)
.WithMany()
.HasForeignKey(po => po.FK_SupplierId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrderItem>()
.HasOne(i => i.PurchaseOrder)
.WithMany(po => po.Items)
.HasForeignKey(i => i.FK_PurchaseOrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrderItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.PurchaseOrder)
.WithMany()
.HasForeignKey(gr => gr.FK_PurchaseOrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceipt>()
.HasOne(gr => gr.Warehouse)
.WithMany()
.HasForeignKey(gr => gr.FK_WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItem>()
.HasOne(i => i.GoodsReceipt)
.WithMany(gr => gr.Items)
.HasForeignKey(i => i.FK_GoodsReceiptId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<GoodsReceiptItem>()
.HasOne(i => i.PurchaseOrderItem)
.WithMany()
.HasForeignKey(i => i.FK_PurchaseOrderItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseReturn>()
.HasOne(pr => pr.PurchaseOrder)
.WithMany()
.HasForeignKey(pr => pr.FK_PurchaseOrderId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseReturnItem>()
.HasOne(i => i.PurchaseReturn)
.WithMany(pr => pr.Items)
.HasForeignKey(i => i.FK_PurchaseReturnId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseReturnItem>()
.HasOne(i => i.Item)
.WithMany()
.HasForeignKey(i => i.FK_ItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrderItem>()
.HasIndex(i => i.FK_PurchaseOrderId);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_GoodsReceiptId);
modelBuilder.Entity<GoodsReceiptItem>()
.HasIndex(i => i.FK_PurchaseOrderItemId);
modelBuilder.Entity<PurchaseReturnItem>()
.HasIndex(i => i.FK_PurchaseReturnId);
modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.Ride)
.WithMany()
.HasForeignKey(t => t.FK_RideId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<DeliveryTask>()
.HasOne(t => t.Order)
.WithMany()
.HasForeignKey(t => t.FK_OrderId)
.OnDelete(DeleteBehavior.Restrict);
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
}
}
@@ -0,0 +1,72 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DeliveryRide;
[ApiController]
[Authorize]
[Route("api")]
public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase
{
[HttpPost("rides/{rideId:guid}/tasks/generate")]
public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct)
{
try
{
var result = await service.GenerateAsync(rideId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("rides/{rideId:guid}/tasks")]
public async Task<IActionResult> GetByRide(Guid rideId)
{
var result = await service.GetByRideIdAsync(rideId);
return Ok(result);
}
[HttpGet("delivery-tasks/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("delivery-tasks/{id:guid}/quantities")]
public async Task<IActionResult> SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct)
{
try
{
var result = await service.SetQuantitiesAsync(id, request, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("delivery-tasks/{id:guid}/confirm")]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
{
try
{
var result = await service.ConfirmAsync(id, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("delivery-tasks/{id:guid}/fail")]
public async Task<IActionResult> Fail(Guid id, FailTaskRequest request, CancellationToken ct)
{
try
{
var result = await service.FailAsync(id, request.Note, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,24 @@
using Distribution.Models;
namespace Distribution.Features.DeliveryRide;
public record DeliveryTaskItemResponse(Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record DeliveryTaskResponse(
Guid Id,
Guid FK_RideId,
Guid FK_OrderId,
Guid StoreId,
string StoreName,
int Sequence,
DeliveryTaskStatus Status,
bool CustomerConfirmed,
DateTime? ConfirmedAt,
string? Note,
List<DeliveryTaskItemResponse> Items,
DateTime CreatedAt);
public record SetQuantitiesRequest(List<DeliveryTaskItemQuantity> Items);
public record DeliveryTaskItemQuantity(Guid ItemId, int QuantityFulfilled);
public record FailTaskRequest(string Note);
@@ -0,0 +1,38 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.DeliveryRide;
public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskRepository
{
public async Task<DeliveryTask?> GetByIdAsync(Guid id) =>
await context.DeliveryTasks
.Include(t => t.Order)
.ThenInclude(o => o.Store)
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item)
.FirstOrDefaultAsync(t => t.Id == id);
public async Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId) =>
await context.DeliveryTasks
.Include(t => t.Order)
.ThenInclude(o => o.Store)
.Include(t => t.Order)
.ThenInclude(o => o.OrderItems)
.ThenInclude(i => i.Item)
.Where(t => t.FK_RideId == rideId)
.OrderBy(t => t.Sequence)
.ToListAsync();
public async Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId) =>
await context.DeliveryTasks
.AnyAsync(t => t.FK_OrderId == orderId && t.FK_RideId == rideId);
public async Task AddAsync(DeliveryTask task) =>
await context.DeliveryTasks.AddAsync(task);
public void Update(DeliveryTask task) =>
context.DeliveryTasks.Update(task);
}
@@ -0,0 +1,224 @@
using Distribution.Features.Order;
using Distribution.Features.Ride;
using Distribution.Features.Sale;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.DeliveryRide;
public class DeliveryTaskService(
IDeliveryTaskRepository repo,
IRideRepository rideRepo,
IOrderRepository orderRepo,
ISaleRepository saleRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
private static DeliveryTaskResponse ToResponse(DeliveryTask t) =>
new(t.Id,
t.FK_RideId,
t.FK_OrderId,
t.Order.FK_StoreId,
t.Order.Store?.StoreName ?? string.Empty,
t.Sequence,
t.Status,
t.CustomerConfirmed,
t.ConfirmedAt,
t.Note,
t.Order.OrderItems.Select(i => new DeliveryTaskItemResponse(
i.FK_ItemId,
i.Item?.Name ?? string.Empty,
i.Quantity,
i.QuantityFulfilled,
i.UnitPrice)).ToList(),
t.CreatedAt);
public async Task<List<DeliveryTaskResponse>> GenerateAsync(Guid rideId, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(rideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Active)
throw new InvalidOperationException("Tasks can only be generated for an Active ride");
if (ride.RideType != RideType.Delivery)
throw new InvalidOperationException("Tasks can only be generated for a Delivery ride");
var orders = await orderRepo.GetPendingByRouteIdAsync(ride.FK_RouteId);
var created = new List<DeliveryTask>();
int sequence = 1;
foreach (var order in orders)
{
if (await repo.ExistsForOrderAndRideAsync(order.Id, rideId))
continue;
var task = new DeliveryTask
{
FK_RideId = rideId,
FK_OrderId = order.Id,
Sequence = sequence++,
Status = DeliveryTaskStatus.Pending
};
created.Add(task);
}
if (created.Count == 0)
return new List<DeliveryTaskResponse>();
await uow.BeginAsync(ct);
try
{
foreach (var task in created)
await repo.AddAsync(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByRideIdAsync(rideId);
return saved.Select(ToResponse).ToList();
}
public async Task<List<DeliveryTaskResponse>> GetByRideIdAsync(Guid rideId)
{
var tasks = await repo.GetByRideIdAsync(rideId);
return tasks.Select(ToResponse).ToList();
}
public async Task<DeliveryTaskResponse?> GetByIdAsync(Guid id)
{
var task = await repo.GetByIdAsync(id);
return task is null ? null : ToResponse(task);
}
public async Task<DeliveryTaskResponse> SetQuantitiesAsync(Guid taskId, SetQuantitiesRequest request, CancellationToken ct = default)
{
var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found");
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Quantities can only be set for a Pending task");
foreach (var input in request.Items)
{
var orderItem = task.Order.OrderItems.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this order");
if (input.QuantityFulfilled < 0)
throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}");
if (input.QuantityFulfilled > orderItem.Quantity)
throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}");
orderItem.QuantityFulfilled = input.QuantityFulfilled;
}
await uow.BeginAsync(ct);
try
{
repo.Update(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(taskId);
return ToResponse(saved!);
}
public async Task<DeliveryTaskResponse> ConfirmAsync(Guid taskId, CancellationToken ct = default)
{
var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found");
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Only a Pending task can be confirmed");
if (!task.Order.OrderItems.Any(i => i.QuantityFulfilled > 0))
throw new InvalidOperationException("Set quantities before confirming — at least one item must have a fulfilled quantity greater than zero");
var ride = await rideRepo.GetByIdAsync(task.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
var vehicleId = ride.FK_VehicleId;
bool fullyDelivered = task.Order.OrderItems.All(i => i.QuantityFulfilled == i.Quantity);
task.CustomerConfirmed = true;
task.ConfirmedAt = DateTime.Now;
task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered;
var order = task.Order;
order.Status = fullyDelivered ? OrderStatus.Fulfilled : OrderStatus.PartiallyFulfilled;
if (fullyDelivered)
order.FulfilledAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
foreach (var item in task.Order.OrderItems.Where(i => i.QuantityFulfilled > 0))
{
var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId)
?? throw new InvalidOperationException($"No vehicle stock found for item {item.FK_ItemId}");
if (vehicleBalance.Quantity < item.QuantityFulfilled)
throw new InvalidOperationException($"Insufficient vehicle stock for item {item.FK_ItemId}");
vehicleBalance.Quantity -= item.QuantityFulfilled;
vehicleBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(vehicleBalance);
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = vehicleId,
FromLocationType = LocationType.Vehicle,
Quantity = item.QuantityFulfilled,
Type = MovementType.Sale,
ReferenceId = task.FK_OrderId
});
}
repo.Update(task);
orderRepo.Update(order);
if (fullyDelivered)
{
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new KeyNotFoundException("Sale not found");
sale.Status = SaleStatus.Completed;
sale.CompletedAt = DateTime.Now;
saleRepo.Update(sale);
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(taskId);
return ToResponse(saved!);
}
public async Task<DeliveryTaskResponse> FailAsync(Guid taskId, string note, CancellationToken ct = default)
{
var task = await repo.GetByIdAsync(taskId)
?? throw new KeyNotFoundException("Delivery task not found");
if (task.Status != DeliveryTaskStatus.Pending)
throw new InvalidOperationException("Only a Pending task can be failed");
task.Status = DeliveryTaskStatus.Failed;
task.Note = note;
await uow.BeginAsync(ct);
try
{
repo.Update(task);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(task);
}
}
@@ -0,0 +1,12 @@
using Distribution.Models;
namespace Distribution.Features.DeliveryRide;
public interface IDeliveryTaskRepository
{
Task<DeliveryTask?> GetByIdAsync(Guid id);
Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId);
Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId);
Task AddAsync(DeliveryTask task);
void Update(DeliveryTask task);
}
@@ -0,0 +1,33 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Grn;
[ApiController]
[Authorize]
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/goods-receipts/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
[HttpPost("api/purchase-orders/{id:guid}/goods-receipts")]
public async Task<IActionResult> Create(Guid id, CreateGrnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CreateAsync(id, request, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
+24
View File
@@ -0,0 +1,24 @@
namespace Distribution.Features.Grn;
public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int RejectedQuantity);
public record CreateGrnRequest(Guid WarehouseId, List<GrnItemRequest> Items);
public record GrnItemResponse(
Guid Id,
Guid PurchaseOrderItemId,
Guid ItemId,
string Sku,
string ItemName,
int AcceptedQuantity,
int RejectedQuantity);
public record GrnResponse(
Guid Id,
Guid PurchaseOrderId,
Guid ReceivedByUserId,
Guid WarehouseId,
string WarehouseName,
DateTime ReceivedAt,
DateTime CreatedAt,
List<GrnItemResponse> Items);
@@ -0,0 +1,35 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Grn;
public class GrnRepository(AppDBContext context) : IGrnRepository
{
public async Task<Models.GoodsReceipt?> GetByIdAsync(Guid id) =>
await context.GoodsReceipts
.Include(gr => gr.Warehouse)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.FirstOrDefaultAsync(gr => gr.Id == id);
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
await context.GoodsReceipts
.Include(gr => gr.Warehouse)
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
.Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive)
.OrderByDescending(gr => gr.ReceivedAt)
.ToListAsync();
public async Task AddAsync(Models.GoodsReceipt entity) =>
await context.GoodsReceipts.AddAsync(entity);
public async Task<bool> HasAnyForPurchaseOrderAsync(Guid purchaseOrderId) =>
await context.GoodsReceipts.AnyAsync(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive);
public async Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId) =>
await context.GoodsReceiptItems
.Include(i => i.PurchaseOrderItem)
.Where(i => i.PurchaseOrderItem.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
.GroupBy(i => i.PurchaseOrderItem.FK_ItemId)
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) })
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
}
+137
View File
@@ -0,0 +1,137 @@
using Distribution.Features.Po;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Grn;
public class GrnService(
IGrnRepository repo,
IPoRepository poRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i) =>
new(i.Id, i.FK_PurchaseOrderItemId, i.PurchaseOrderItem.FK_ItemId, i.PurchaseOrderItem.Item.Sku,
i.PurchaseOrderItem.Item.Name, i.AcceptedQuantity, i.RejectedQuantity);
private static GrnResponse Map(Models.GoodsReceipt gr) =>
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
public async Task<GrnResponse?> GetByIdAsync(Guid id)
{
var gr = await repo.GetByIdAsync(id);
if (gr is null || !gr.IsActive) return null;
return Map(gr);
}
public async Task<List<GrnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
/// <summary>
/// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/
/// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), and
/// transitions the Po to PartiallyReceived/Received. All in one unit of work.
/// </summary>
public async Task<GrnResponse> CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
{
var po = await poRepo.GetByIdAsync(purchaseOrderId)
?? throw new InvalidOperationException("Purchase order not found.");
if (!po.IsActive)
throw new InvalidOperationException("Purchase order not found.");
if (po.Status is not (PurchaseOrderStatus.Issued or PurchaseOrderStatus.PartiallyReceived))
throw new InvalidOperationException(
$"Cannot receive goods against a purchase order in {po.Status} status.");
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A goods receipt must have at least one line item.");
var goodsReceipt = new Models.GoodsReceipt
{
FK_PurchaseOrderId = po.Id,
FK_ReceivedByUserId = receivedByUserId,
FK_WarehouseId = request.WarehouseId,
ReceivedAt = DateTime.Now
};
foreach (var line in request.Items)
{
var poItem = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId && i.IsActive)
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
if (poItem.IsCancelled)
throw new InvalidOperationException($"Line for item {poItem.FK_ItemId} is cancelled and cannot receive stock.");
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0)
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
if (line.AcceptedQuantity + line.RejectedQuantity + poItem.ReceivedQuantity > poItem.Quantity)
throw new InvalidOperationException(
$"Accepted + rejected quantity for item {poItem.FK_ItemId} would exceed the ordered quantity.");
poItem.ReceivedQuantity += line.AcceptedQuantity;
goodsReceipt.Items.Add(new Models.GoodsReceiptItem
{
FK_PurchaseOrderItemId = poItem.Id,
AcceptedQuantity = line.AcceptedQuantity,
RejectedQuantity = line.RejectedQuantity
});
}
var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList();
po.Status = activeLines.Count > 0 && activeLines.All(i => i.ReceivedQuantity >= i.Quantity)
? PurchaseOrderStatus.Received
: PurchaseOrderStatus.PartiallyReceived;
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(goodsReceipt);
foreach (var line in goodsReceipt.Items.Where(i => i.AcceptedQuantity > 0))
{
var poItem = po.Items.First(i => i.Id == line.FK_PurchaseOrderItemId);
var balance = await stockRepo.GetBalanceAsync(poItem.FK_ItemId, request.WarehouseId);
if (balance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = poItem.FK_ItemId,
LocationId = request.WarehouseId,
LocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity
});
}
else
{
balance.Quantity += line.AcceptedQuantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = poItem.FK_ItemId,
ToLocationId = request.WarehouseId,
ToLocationType = LocationType.Warehouse,
Quantity = line.AcceptedQuantity,
Type = MovementType.PurchaseReceipt,
ReferenceId = goodsReceipt.Id,
CreatedBy = receivedByUserId
});
}
poRepo.Update(po);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(goodsReceipt.Id);
return Map(saved!);
}
}
@@ -0,0 +1,14 @@
namespace Distribution.Features.Grn;
public interface IGrnRepository
{
Task<Models.GoodsReceipt?> GetByIdAsync(Guid id);
Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
Task AddAsync(Models.GoodsReceipt entity);
/// <summary>True if at least one GoodsReceipt exists against this Po (createPurchaseReturn guard).</summary>
Task<bool> HasAnyForPurchaseOrderAsync(Guid purchaseOrderId);
/// <summary>Sum of AcceptedQuantity per ItemId across all receipts for a Po.</summary>
Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId);
}
@@ -39,9 +39,12 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit
public async Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId)
{
var groups = await context.OrderItems
.Where(oi => oi.IsActive && oi.Order.IsActive && oi.Order.Store.FK_RouteId == routeId)
.Where(oi => oi.IsActive
&& oi.Order.IsActive
&& oi.Order.Store.FK_RouteId == routeId
&& (oi.Order.Status == OrderStatus.Pending || oi.Order.Status == OrderStatus.PartiallyFulfilled))
.GroupBy(oi => oi.FK_ItemId)
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)oi.Quantity) })
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)(oi.Quantity - oi.QuantityFulfilled)) })
.ToListAsync();
return groups.Select(x => (x.ItemId, x.TotalQty)).ToList();
}
@@ -4,10 +4,12 @@ public interface IOrderRepository
{
Task<Models.Order?> GetByIdAsync(Guid id);
Task<List<Models.Order>> GetAllAsync();
Task<Models.Order?> GetBySaleIdAsync(Guid saleId);
Task AddAsync(Models.Order entity);
void Update(Models.Order entity);
void Remove(Models.Order entity);
Task<List<Models.OrderItem>> GetItemsByOrderIdAsync(Guid orderId);
Task<List<Models.Order>> GetByStoreIdAsync(Guid storeId);
Task AddItemsAsync(IEnumerable<Models.OrderItem> items);
Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId);
}
+12 -4
View File
@@ -33,16 +33,24 @@ public class OrderController(OrderService service) : ControllerBase
[Authorize(Policy = "orders.create")]
public async Task<IActionResult> Create(CreateOrderRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
try
{
var result = await service.CreateAsync(request, ct);
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}")]
[Authorize(Policy = "orders.update")]
public async Task<IActionResult> Update(Guid id, UpdateOrderRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
try
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpDelete("{id:guid}")]
+8 -5
View File
@@ -1,18 +1,21 @@
using Distribution.Models;
namespace Distribution.Features.Order;
public record OrderItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
public record CreateOrderRequest(Guid SaleId);
public record CreateOrderRequest(Guid StoreId, DateTime OrderDate, decimal TotalAmount, List<OrderItemRequest> Items);
public record UpdateOrderRequest(OrderStatus Status);
public record UpdateOrderRequest(DateTime OrderDate, decimal TotalAmount);
public record OrderItemResponse(Guid ItemId, int Quantity, decimal UnitPrice);
public record OrderItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record OrderResponse(
Guid Id,
Guid SaleId,
DateTime OrderDate,
Guid StoreId,
decimal TotalAmount,
OrderStatus Status,
DateTime? FulfilledAt,
List<OrderItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -1,4 +1,5 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Order;
@@ -11,6 +12,9 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public async Task<List<Models.Order>> GetAllAsync() =>
await context.Orders.ToListAsync();
public async Task<Models.Order?> GetBySaleIdAsync(Guid saleId) =>
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
public async Task AddAsync(Models.Order entity) =>
await context.Orders.AddAsync(entity);
@@ -19,6 +23,7 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public void Remove(Models.Order entity)
{
entity.Status = Models.OrderStatus.Cancelled;
entity.IsActive = false;
context.Orders.Update(entity);
}
@@ -31,4 +36,14 @@ public class OrderRepository(AppDBContext context) : IOrderRepository
public async Task AddItemsAsync(IEnumerable<Models.OrderItem> items) =>
await context.OrderItems.AddRangeAsync(items);
public async Task<List<Models.Order>> GetPendingByRouteIdAsync(Guid routeId) =>
await context.Orders
.Include(o => o.Store)
.Include(o => o.OrderItems)
.ThenInclude(i => i.Item)
.Where(o => o.IsActive
&& o.Store.FK_RouteId == routeId
&& (o.Status == OrderStatus.Pending || o.Status == OrderStatus.PartiallyFulfilled))
.ToListAsync();
}
+40 -72
View File
@@ -1,10 +1,11 @@
using Distribution.Features.Sale;
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;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Order;
@@ -12,17 +13,18 @@ public class OrderService(
IOrderRepository repo,
IStoreRepository storeRepo,
IItemRepository itemRepo,
IStockRepository stockRepo,
PriceListService priceListService,
ISaleRepository saleRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess,
IConfiguration configuration)
IScopeAccessService scopeAccess)
{
private const int GlobalScopeRoleLevel = 50;
private static OrderResponse ToResponse(Models.Order o, List<Models.OrderItem> items) =>
new(o.Id, o.OrderDate, o.FK_StoreId, o.TotalAmount,
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.UnitPrice)).ToList(),
new(o.Id, o.FK_SaleId, o.OrderDate, o.FK_StoreId, o.TotalAmount, o.Status, o.FulfilledAt,
items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
o.IsActive, o.CreatedAt);
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
@@ -33,29 +35,6 @@ public class OrderService(
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
/// <summary>
/// applyPricingAndDiscounts (price-list half, 14_pricing_and_suppliers.md): resolves the
/// region-scoped active price list, then global, then Item.SalePrice, and validates any
/// caller-supplied override falls within [MinPrice ?? Price, Price]. Returns null if no price
/// can be resolved, or if the requested price is outside the allowed band.
/// </summary>
private async Task<decimal?> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime orderDate)
{
var item = await itemRepo.GetByIdAsync(itemId);
if (item is null) return null;
var regionId = store.Route?.FK_RegionId;
var resolution = await priceListService.ResolvePriceAsync(itemId, regionId, item.SalePrice, orderDate);
if (resolution is null) return null;
if (!requestedUnitPrice.HasValue) return resolution.Price;
var min = resolution.MinPrice ?? resolution.Price;
var max = resolution.Price;
if (requestedUnitPrice.Value < min || requestedUnitPrice.Value > max) return null;
return requestedUnitPrice.Value;
}
public async Task<List<OrderResponse>> GetAllAsync()
{
var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList();
@@ -97,50 +76,15 @@ public class OrderService(
public async Task<OrderResponse?> CreateAsync(CreateOrderRequest request, CancellationToken ct = default)
{
if (!await HasStoreScopeAsync(request.StoreId)) return null;
var sale = await saleRepo.GetByIdAsync(request.SaleId);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
if (store is null || !store.IsActive) return null;
if (store.KycStatus != KycStatus.Verified)
{
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
if (request.TotalAmount > threshold) return null;
}
var existingOrder = await repo.GetBySaleIdAsync(request.SaleId)
?? throw new InvalidOperationException("Orders are created automatically from sales");
var order = new Models.Order
{
FK_StoreId = request.StoreId,
OrderDate = request.OrderDate,
TotalAmount = request.TotalAmount
};
var orderItems = new List<Models.OrderItem>();
foreach (var i in request.Items)
{
var unitPrice = await ResolveUnitPriceAsync(i.ItemId, i.UnitPrice, store, request.OrderDate);
if (unitPrice is null) return null;
orderItems.Add(new Models.OrderItem
{
FK_OrderId = order.Id,
FK_ItemId = i.ItemId,
Quantity = i.Quantity,
UnitPrice = unitPrice.Value
});
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(order);
await repo.AddItemsAsync(orderItems);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(order, orderItems);
var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id);
return ToResponse(existingOrder, items);
}
public async Task<OrderResponse?> UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default)
@@ -148,12 +92,26 @@ public class OrderService(
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return null;
if (!await HasStoreScopeAsync(order.FK_StoreId)) return null;
order.OrderDate = request.OrderDate;
order.TotalAmount = request.TotalAmount;
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId)
?? throw new InvalidOperationException("Sale not found for this order");
order.Status = request.Status;
order.FulfilledAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
sale.Status = request.Status switch
{
OrderStatus.Fulfilled => SaleStatus.Completed,
OrderStatus.Cancelled => SaleStatus.Cancelled,
_ => SaleStatus.Pending
};
sale.CompletedAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null;
await uow.BeginAsync(ct);
try
{
repo.Update(order);
saleRepo.Update(sale);
await uow.CommitAsync(ct);
}
catch
@@ -161,6 +119,7 @@ public class OrderService(
await uow.RollbackAsync(ct);
throw;
}
var items = await repo.GetItemsByOrderIdAsync(id);
return ToResponse(order, items);
}
@@ -170,10 +129,19 @@ public class OrderService(
var order = await repo.GetByIdAsync(id);
if (order is null || !order.IsActive) return false;
if (!await HasStoreScopeAsync(order.FK_StoreId)) return false;
var sale = await saleRepo.GetByIdAsync(order.FK_SaleId);
await uow.BeginAsync(ct);
try
{
repo.Remove(order);
if (sale is not null)
{
sale.Status = SaleStatus.Cancelled;
sale.CompletedAt = null;
saleRepo.Update(sale);
}
await uow.CommitAsync(ct);
}
catch
@@ -0,0 +1,9 @@
using Distribution.Models;
namespace Distribution.Features.Payment;
public interface IPaymentRepository
{
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId);
Task AddAsync(Models.Payment payment);
}
@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Payment;
[ApiController]
[Authorize]
[Route("api/sales/{saleId:guid}/payments")]
public class PaymentController(PaymentService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetBySale(Guid saleId)
{
var result = await service.GetBySaleIdAsync(saleId);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(Guid saleId, CreatePaymentRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(saleId, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,15 @@
using Distribution.Models;
namespace Distribution.Features.Payment;
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
public record PaymentResponse(
Guid Id,
Guid SaleId,
Guid StoreId,
decimal Amount,
PaymentMethod Method,
DateTime CollectedAt,
Guid CollectedBy,
DateTime CreatedAt);
@@ -0,0 +1,17 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Payment;
public class PaymentRepository(AppDBContext context) : IPaymentRepository
{
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) =>
await context.Payments
.Where(p => p.FK_SaleId == saleId && p.IsActive)
.OrderBy(p => p.CollectedAt)
.ToListAsync();
public async Task AddAsync(Models.Payment payment) =>
await context.Payments.AddAsync(payment);
}
@@ -0,0 +1,67 @@
using Distribution.Features.Sale;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Payment;
public class PaymentService(
IPaymentRepository repo,
ISaleRepository saleRepo,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
private static PaymentResponse ToResponse(Models.Payment p) =>
new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
public async Task<List<PaymentResponse>?> GetBySaleIdAsync(Guid saleId)
{
var sale = await saleRepo.GetByIdAsync(saleId);
if (sale is null || !sale.IsActive) return null;
var payments = await repo.GetBySaleIdAsync(saleId);
return payments.Select(ToResponse).ToList();
}
public async Task<PaymentResponse?> CreateAsync(Guid saleId, CreatePaymentRequest request, CancellationToken ct = default)
{
var sale = await saleRepo.GetByIdAsync(saleId);
if (sale is null || !sale.IsActive) return null;
if (sale.Status == SaleStatus.Cancelled)
throw new InvalidOperationException("Cannot record a payment against a cancelled sale");
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var payment = new Models.Payment
{
FK_SaleId = saleId,
FK_StoreId = sale.FK_StoreId,
Amount = request.Amount,
Method = request.Method,
CollectedAt = DateTime.Now,
CollectedBy = userId
};
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(payment);
var allPayments = await repo.GetBySaleIdAsync(saleId);
var total = allPayments.Sum(p => p.Amount) + request.Amount;
sale.PaymentStatus = total == 0
? PaymentStatus.Pending
: total < sale.TotalAmount
? PaymentStatus.PartiallyPaid
: PaymentStatus.Paid;
saleRepo.Update(sale);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(payment);
}
}
@@ -0,0 +1,19 @@
using Distribution.Models;
namespace Distribution.Features.Po;
public interface IPoRepository
{
Task<Models.PurchaseOrder?> GetByIdAsync(Guid id);
Task<List<Models.PurchaseOrder>> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status);
Task AddAsync(Models.PurchaseOrder entity);
void Update(Models.PurchaseOrder entity);
Task AddItemAsync(PurchaseOrderItem entity);
/// <summary>True if any active (non-cancelled, non-closed-and-void) PurchaseOrder references this supplier.</summary>
Task<bool> HasActiveForSupplierAsync(Guid supplierId);
/// <summary>All active PurchaseOrders for a supplier, with items, used by evaluateSupplier.</summary>
Task<List<Models.PurchaseOrder>> GetBySupplierAsync(Guid supplierId);
}
@@ -0,0 +1,97 @@
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Po;
[ApiController]
[Route("api/purchase-orders")]
[Authorize]
public class PoController(PoService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] Guid? supplierId, [FromQuery] PurchaseOrderStatus? status) =>
Ok(await service.GetAllAsync(supplierId, status));
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreatePoRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CreateAsync(request, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, UpdatePoRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/close")]
public async Task<IActionResult> Close(Guid id, CancellationToken ct)
{
try
{
var result = await service.CloseAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/cancel")]
public async Task<IActionResult> Cancel(Guid id, CancelPoRequest request, CancellationToken ct)
{
try
{
var result = await service.CancelAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/items/{itemId:guid}/cancel")]
public async Task<IActionResult> CancelLine(Guid id, Guid itemId, CancelPoLineRequest request, CancellationToken ct)
{
try
{
var result = await service.CancelLineAsync(id, itemId, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/match-invoice")]
public async Task<IActionResult> MatchInvoice(Guid id, [FromQuery] Guid? invoiceId, [FromQuery] Guid? grnId)
{
var result = await service.MatchInvoiceToPoAsync(id, invoiceId, grnId);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{id:guid}/resolve-variance")]
public async Task<IActionResult> ResolveVariance(Guid id, ResolveInvoiceVarianceRequest request, CancellationToken ct)
{
try
{
var result = await service.ResolveInvoiceVarianceAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
@@ -0,0 +1,54 @@
using Distribution.Models;
namespace Distribution.Features.Po;
public record PoItemRequest(Guid ItemId, int Quantity, decimal UnitCost);
public record CreatePoRequest(Guid SupplierId, List<PoItemRequest> Items);
/// <summary>
/// Update line: PurchaseOrderItemId set = update an existing unreceived line (Quantity/UnitCost),
/// null = add a new line to the Po. Only valid while the Po is Issued.
/// </summary>
public record UpdatePoLineRequest(Guid? PurchaseOrderItemId, Guid ItemId, int Quantity, decimal UnitCost);
public record UpdatePoRequest(List<UpdatePoLineRequest> Items);
public record CancelPoRequest(string Reason);
public record CancelPoLineRequest(string? Reason);
public record PoItemResponse(
Guid Id,
Guid ItemId,
string Sku,
string ItemName,
int Quantity,
decimal UnitCost,
int ReceivedQuantity,
int CancelledQuantity,
bool IsCancelled);
public record PoResponse(
Guid Id,
Guid SupplierId,
string SupplierName,
Guid CreatedByUserId,
PurchaseOrderStatus Status,
string? CancelReason,
bool IsActive,
DateTime CreatedAt,
List<PoItemResponse> Items);
/// <summary>
/// Minimal Po-vs-Grn quantity/cost reconciliation. Full 3-way match (Po + Grn + Invoice) activates
/// once Finance's Invoice entity (step 19, not yet built) exists — until then this compares only
/// PurchaseOrderItem vs GoodsReceiptItem.
/// </summary>
public record InvoiceMatchLineResult(Guid PurchaseOrderItemId, Guid ItemId, int OrderedQuantity, int AcceptedQuantity, decimal UnitCost, bool QuantityMatches);
public record InvoiceMatchResult(Guid PurchaseOrderId, Guid? GoodsReceiptId, Guid? InvoiceId, bool IsMatched, List<InvoiceMatchLineResult> Lines, string Note);
public record ResolveInvoiceVarianceRequest(string Variance, string Resolution, decimal? AdjustedUnitCost, Guid? PurchaseOrderItemId);
public record InvoiceVarianceResolutionResponse(Guid PurchaseOrderId, string Variance, string Resolution, DateTime ResolvedAt);
@@ -0,0 +1,49 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Po;
public class PoRepository(AppDBContext context) : IPoRepository
{
public async Task<Models.PurchaseOrder?> GetByIdAsync(Guid id) =>
await context.PurchaseOrders
.Include(po => po.Supplier)
.Include(po => po.Items).ThenInclude(i => i.Item)
.FirstOrDefaultAsync(po => po.Id == id);
public async Task<List<Models.PurchaseOrder>> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status)
{
var query = context.PurchaseOrders
.Include(po => po.Supplier)
.Include(po => po.Items).ThenInclude(i => i.Item)
.Where(po => po.IsActive)
.AsQueryable();
if (supplierId.HasValue) query = query.Where(po => po.FK_SupplierId == supplierId.Value);
if (status.HasValue) query = query.Where(po => po.Status == status.Value);
return await query.OrderByDescending(po => po.CreatedAt).ToListAsync();
}
public async Task AddAsync(Models.PurchaseOrder entity) =>
await context.PurchaseOrders.AddAsync(entity);
public void Update(Models.PurchaseOrder entity) =>
context.PurchaseOrders.Update(entity);
public async Task AddItemAsync(PurchaseOrderItem entity) =>
await context.PurchaseOrderItems.AddAsync(entity);
public async Task<bool> HasActiveForSupplierAsync(Guid supplierId) =>
await context.PurchaseOrders.AnyAsync(po =>
po.FK_SupplierId == supplierId &&
po.IsActive &&
po.Status != PurchaseOrderStatus.Cancelled);
public async Task<List<Models.PurchaseOrder>> GetBySupplierAsync(Guid supplierId) =>
await context.PurchaseOrders
.Include(po => po.Items)
.Where(po => po.FK_SupplierId == supplierId && po.IsActive)
.ToListAsync();
}
@@ -0,0 +1,245 @@
using Distribution.Features.Grn;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Po;
public class PoService(
IPoRepository repo,
IGrnRepository goodsReceiptRepo,
IUnitOfWork uow)
{
private static PoItemResponse MapItem(PurchaseOrderItem i) =>
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity, i.UnitCost, i.ReceivedQuantity, i.CancelledQuantity, i.IsCancelled);
private static PoResponse Map(Models.PurchaseOrder po) =>
new(po.Id, po.FK_SupplierId, po.Supplier?.Name ?? string.Empty, po.FK_CreatedByUserId,
po.Status, po.CancelReason, po.IsActive, po.CreatedAt,
po.Items.Where(i => i.IsActive).Select(MapItem).ToList());
public async Task<List<PoResponse>> GetAllAsync(Guid? supplierId, PurchaseOrderStatus? status) =>
(await repo.GetAllAsync(supplierId, status)).Select(Map).ToList();
public async Task<PoResponse?> GetByIdAsync(Guid id)
{
var po = await repo.GetByIdAsync(id);
if (po is null || !po.IsActive) return null;
return Map(po);
}
public async Task<PoResponse> CreateAsync(CreatePoRequest request, Guid createdByUserId, CancellationToken ct = default)
{
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A purchase order must have at least one line item.");
var po = new Models.PurchaseOrder
{
FK_SupplierId = request.SupplierId,
FK_CreatedByUserId = createdByUserId,
Status = PurchaseOrderStatus.Issued
};
foreach (var line in request.Items)
{
po.Items.Add(new PurchaseOrderItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
UnitCost = line.UnitCost
});
}
await uow.BeginAsync(ct);
try { await repo.AddAsync(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(po.Id))!;
}
/// <summary>
/// May only change unreceived lines (ReceivedQuantity == 0) and only while the Po is Issued.
/// Lines with a PurchaseOrderItemId update an existing line; lines without one add a new line.
/// </summary>
public async Task<PoResponse?> UpdateAsync(Guid id, UpdatePoRequest request, CancellationToken ct = default)
{
var po = await repo.GetByIdAsync(id);
if (po is null || !po.IsActive) return null;
if (po.Status != PurchaseOrderStatus.Issued)
throw new InvalidOperationException($"Cannot update a purchase order in {po.Status} status; only an Issued Po can be updated.");
await uow.BeginAsync(ct);
try
{
foreach (var line in request.Items)
{
if (line.PurchaseOrderItemId.HasValue)
{
var existing = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId.Value && i.IsActive)
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
if (existing.ReceivedQuantity > 0)
throw new InvalidOperationException(
$"Line for item {existing.FK_ItemId} has already received stock and cannot be edited; use cancelPurchaseOrderLine or createPurchaseReturn instead.");
existing.FK_ItemId = line.ItemId;
existing.Quantity = line.Quantity;
existing.UnitCost = line.UnitCost;
}
else
{
await repo.AddItemAsync(new PurchaseOrderItem
{
FK_PurchaseOrderId = po.Id,
FK_ItemId = line.ItemId,
Quantity = line.Quantity,
UnitCost = line.UnitCost
});
}
}
repo.Update(po);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return (await GetByIdAsync(id))!;
}
/// <summary>
/// Only valid once a Po is Received, or manually closed early (outstanding quantity written off).
/// </summary>
public async Task<PoResponse?> CloseAsync(Guid id, CancellationToken ct = default)
{
var po = await repo.GetByIdAsync(id);
if (po is null || !po.IsActive) return null;
if (po.Status is PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled)
throw new InvalidOperationException($"Cannot close a purchase order already in {po.Status} status.");
po.Status = PurchaseOrderStatus.Closed;
await uow.BeginAsync(ct);
try { repo.Update(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return Map(po);
}
/// <summary>Only valid before any goods receipt exists against the Po.</summary>
public async Task<PoResponse?> CancelAsync(Guid id, CancelPoRequest request, CancellationToken ct = default)
{
var po = await repo.GetByIdAsync(id);
if (po is null || !po.IsActive) return null;
if (po.Status is PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled)
throw new InvalidOperationException($"Cannot cancel a purchase order already in {po.Status} status.");
if (await goodsReceiptRepo.HasAnyForPurchaseOrderAsync(id))
throw new InvalidOperationException("Cannot cancel a purchase order that already has a goods receipt against it.");
po.Status = PurchaseOrderStatus.Cancelled;
po.CancelReason = request.Reason;
await uow.BeginAsync(ct);
try { repo.Update(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return Map(po);
}
/// <summary>
/// Cancels a single line instead of the whole Po. Only valid while ReceivedQuantity &lt; Quantity
/// and the line isn't already fully received or cancelled. Auto-completes the Po to Received once
/// every non-cancelled line is fully received.
/// </summary>
public async Task<PoResponse?> CancelLineAsync(Guid id, Guid poItemId, CancelPoLineRequest request, CancellationToken ct = default)
{
var po = await repo.GetByIdAsync(id);
if (po is null || !po.IsActive) return null;
var line = po.Items.FirstOrDefault(i => i.Id == poItemId && i.IsActive)
?? throw new InvalidOperationException("Purchase order line not found on this Po.");
if (line.IsCancelled)
throw new InvalidOperationException("This line is already cancelled.");
if (line.ReceivedQuantity >= line.Quantity)
throw new InvalidOperationException("This line is already fully received and cannot be cancelled.");
line.IsCancelled = true;
line.CancelledQuantity = line.Quantity - line.ReceivedQuantity;
var activeLines = po.Items.Where(i => i.IsActive).ToList();
if (activeLines.Where(i => !i.IsCancelled).All(i => i.ReceivedQuantity >= i.Quantity)
&& activeLines.Any(i => !i.IsCancelled))
{
po.Status = PurchaseOrderStatus.Received;
}
await uow.BeginAsync(ct);
try { repo.Update(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return Map(po);
}
/// <summary>
/// Minimal Po-vs-Grn reconciliation (matchInvoiceToPo). Full 3-way match against an Invoice
/// entity activates once Finance (step 19) exists; this only detects and reports, never adjusts.
/// </summary>
public async Task<InvoiceMatchResult?> MatchInvoiceToPoAsync(Guid poId, Guid? invoiceId, Guid? grnId)
{
var po = await repo.GetByIdAsync(poId);
if (po is null || !po.IsActive) return null;
var receipts = grnId.HasValue
? (await goodsReceiptRepo.GetByPurchaseOrderIdAsync(poId)).Where(g => g.Id == grnId.Value).ToList()
: await goodsReceiptRepo.GetByPurchaseOrderIdAsync(poId);
var acceptedByItem = receipts
.SelectMany(g => g.Items.Where(i => i.IsActive))
.GroupBy(i => i.PurchaseOrderItem.FK_ItemId)
.ToDictionary(g => g.Key, g => g.Sum(i => i.AcceptedQuantity));
var lines = po.Items.Where(i => i.IsActive).Select(i =>
{
acceptedByItem.TryGetValue(i.FK_ItemId, out var accepted);
return new InvoiceMatchLineResult(i.Id, i.FK_ItemId, i.Quantity, accepted, i.UnitCost, accepted == i.Quantity);
}).ToList();
var isMatched = lines.All(l => l.QuantityMatches);
return new InvoiceMatchResult(
poId, grnId, invoiceId, isMatched, lines,
"Compares PurchaseOrderItem.Quantity against GoodsReceiptItem.AcceptedQuantity only; " +
"invoice line comparison activates once Finance's Invoice entity (step 19) exists.");
}
/// <summary>
/// Records a Po/Grn(/invoice) variance and its resolution. Never silently adjusts anything except
/// when the resolution explicitly requests a retroactive UnitCost adjustment on a named line.
/// </summary>
public async Task<InvoiceVarianceResolutionResponse?> ResolveInvoiceVarianceAsync(Guid poId, ResolveInvoiceVarianceRequest request, CancellationToken ct = default)
{
var po = await repo.GetByIdAsync(poId);
if (po is null || !po.IsActive) return null;
if (request.AdjustedUnitCost.HasValue)
{
if (!request.PurchaseOrderItemId.HasValue)
throw new InvalidOperationException("PurchaseOrderItemId is required when adjusting UnitCost.");
var line = po.Items.FirstOrDefault(i => i.Id == request.PurchaseOrderItemId.Value && i.IsActive)
?? throw new InvalidOperationException("Purchase order line not found on this Po.");
line.UnitCost = request.AdjustedUnitCost.Value;
await uow.BeginAsync(ct);
try { repo.Update(po); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
}
return new InvoiceVarianceResolutionResponse(poId, request.Variance, request.Resolution, DateTime.Now);
}
}
@@ -0,0 +1,11 @@
namespace Distribution.Features.PurchaseReturn;
public interface IPurchaseReturnRepository
{
Task<Models.PurchaseReturn?> GetByIdAsync(Guid id);
Task<List<Models.PurchaseReturn>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
Task AddAsync(Models.PurchaseReturn entity);
/// <summary>Sum of Quantity per ItemId across all prior returns for a Po.</summary>
Task<Dictionary<Guid, int>> GetReturnedTotalsByItemAsync(Guid purchaseOrderId);
}
@@ -0,0 +1,33 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.PurchaseReturn;
[ApiController]
[Authorize]
public class PurchaseReturnController(PurchaseReturnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/purchase-returns/{id:guid}")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("api/purchase-orders/{id:guid}/returns")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
[HttpPost("api/purchase-orders/{id:guid}/returns")]
public async Task<IActionResult> Create(Guid id, CreatePurchaseReturnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.CreateAsync(id, request, userId, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
}
@@ -0,0 +1,15 @@
namespace Distribution.Features.PurchaseReturn;
public record PurchaseReturnItemRequest(Guid ItemId, int Quantity);
public record CreatePurchaseReturnRequest(string Reason, List<PurchaseReturnItemRequest> Items);
public record PurchaseReturnItemResponse(Guid Id, Guid ItemId, string Sku, string ItemName, int Quantity);
public record PurchaseReturnResponse(
Guid Id,
Guid PurchaseOrderId,
string Reason,
bool IsActive,
DateTime CreatedAt,
List<PurchaseReturnItemResponse> Items);
@@ -0,0 +1,30 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.PurchaseReturn;
public class PurchaseReturnRepository(AppDBContext context) : IPurchaseReturnRepository
{
public async Task<Models.PurchaseReturn?> GetByIdAsync(Guid id) =>
await context.PurchaseReturns
.Include(pr => pr.Items).ThenInclude(i => i.Item)
.FirstOrDefaultAsync(pr => pr.Id == id);
public async Task<List<Models.PurchaseReturn>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
await context.PurchaseReturns
.Include(pr => pr.Items).ThenInclude(i => i.Item)
.Where(pr => pr.FK_PurchaseOrderId == purchaseOrderId && pr.IsActive)
.OrderByDescending(pr => pr.CreatedAt)
.ToListAsync();
public async Task AddAsync(Models.PurchaseReturn entity) =>
await context.PurchaseReturns.AddAsync(entity);
public async Task<Dictionary<Guid, int>> GetReturnedTotalsByItemAsync(Guid purchaseOrderId) =>
await context.PurchaseReturnItems
.Include(i => i.PurchaseReturn)
.Where(i => i.PurchaseReturn.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
.GroupBy(i => i.FK_ItemId)
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.Quantity) })
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
}
@@ -0,0 +1,122 @@
using Distribution.Features.Grn;
using Distribution.Features.Po;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.PurchaseReturn;
public class PurchaseReturnService(
IPurchaseReturnRepository repo,
IPoRepository poRepo,
IGrnRepository goodsReceiptRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
private static PurchaseReturnItemResponse MapItem(Models.PurchaseReturnItem i) =>
new(i.Id, i.FK_ItemId, i.Item.Sku, i.Item.Name, i.Quantity);
private static PurchaseReturnResponse Map(Models.PurchaseReturn pr) =>
new(pr.Id, pr.FK_PurchaseOrderId, pr.Reason, pr.IsActive, pr.CreatedAt,
pr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
public async Task<PurchaseReturnResponse?> GetByIdAsync(Guid id)
{
var pr = await repo.GetByIdAsync(id);
if (pr is null || !pr.IsActive) return null;
return Map(pr);
}
public async Task<List<PurchaseReturnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
/// <summary>
/// Requires at least one prior GoodsReceipt against the Po; return quantities per item cannot
/// exceed the received-but-not-already-returned quantity for that item. Decrements StockBalance
/// and writes a StockMovement in the same unit of work.
/// </summary>
public async Task<PurchaseReturnResponse> CreateAsync(Guid purchaseOrderId, CreatePurchaseReturnRequest request, Guid createdByUserId, CancellationToken ct = default)
{
var po = await poRepo.GetByIdAsync(purchaseOrderId);
if (po is null || !po.IsActive)
throw new InvalidOperationException("Purchase order not found.");
if (!await goodsReceiptRepo.HasAnyForPurchaseOrderAsync(purchaseOrderId))
throw new InvalidOperationException("Cannot return items for a purchase order with no goods receipt against it.");
if (request.Items is null || request.Items.Count == 0)
throw new InvalidOperationException("A purchase return must have at least one line item.");
var receivedTotals = await goodsReceiptRepo.GetAcceptedTotalsByItemAsync(purchaseOrderId);
var returnedTotals = await repo.GetReturnedTotalsByItemAsync(purchaseOrderId);
// The receipt(s) for this Po share a single receiving warehouse per the current model
// (each GoodsReceipt carries FK_WarehouseId); use the most recent receipt's warehouse
// as the stock-decrement location for the return.
var receipts = await goodsReceiptRepo.GetByPurchaseOrderIdAsync(purchaseOrderId);
var warehouseId = receipts.First().FK_WarehouseId;
var purchaseReturn = new Models.PurchaseReturn
{
FK_PurchaseOrderId = purchaseOrderId,
Reason = request.Reason
};
foreach (var line in request.Items)
{
if (line.Quantity <= 0)
throw new InvalidOperationException("Return quantity must be greater than zero.");
receivedTotals.TryGetValue(line.ItemId, out var received);
returnedTotals.TryGetValue(line.ItemId, out var alreadyReturned);
var available = received - alreadyReturned;
if (line.Quantity > available)
throw new InvalidOperationException(
$"Return quantity for item {line.ItemId} ({line.Quantity}) exceeds the received-but-not-already-returned quantity ({available}).");
purchaseReturn.Items.Add(new Models.PurchaseReturnItem
{
FK_ItemId = line.ItemId,
Quantity = line.Quantity
});
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(purchaseReturn);
foreach (var line in purchaseReturn.Items)
{
var balance = await stockRepo.GetBalanceAsync(line.FK_ItemId, warehouseId)
?? throw new InvalidOperationException($"No stock balance found for item {line.FK_ItemId} at the receiving warehouse.");
if (balance.Quantity < line.Quantity)
throw new InvalidOperationException($"Insufficient warehouse stock for item {line.FK_ItemId} to return.");
balance.Quantity -= line.Quantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = line.FK_ItemId,
FromLocationId = warehouseId,
FromLocationType = LocationType.Warehouse,
Quantity = line.Quantity,
Type = MovementType.PurchaseReturn,
ReasonCode = request.Reason,
ReferenceId = purchaseReturn.Id,
CreatedBy = createdByUserId
});
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(purchaseReturn.Id);
return Map(saved!);
}
}
@@ -0,0 +1,14 @@
using Distribution.Models;
namespace Distribution.Features.Sale;
public interface ISaleRepository
{
Task<Models.Sale?> GetByIdAsync(Guid id);
Task<List<Models.Sale>> GetAllAsync();
Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId);
Task AddAsync(Models.Sale sale);
void Update(Models.Sale sale);
void Remove(Models.Sale sale);
Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId);
}
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Sale;
[ApiController]
[Route("api/sales")]
[Authorize]
public class SaleController(SaleService service) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("store/{storeId:guid}")]
[Authorize(Policy = "orders.view")]
public async Task<IActionResult> GetByStore(Guid storeId)
{
var result = await service.GetByStoreIdAsync(storeId);
return result is null ? Forbid() : Ok(result);
}
[HttpPost]
[Authorize(Policy = "orders.create")]
public async Task<IActionResult> Create(CreateSaleRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
}
+33
View File
@@ -0,0 +1,33 @@
using Distribution.Models;
namespace Distribution.Features.Sale;
public record SaleItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null);
public record CreateSaleRequest(
Guid StoreId,
DateTime SaleDate,
decimal TotalAmount,
bool FulfillImmediately,
Guid? RideId,
List<SaleItemRequest> Items);
public record SaleItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice);
public record SaleResponse(
Guid Id,
DateTime SaleDate,
Guid StoreId,
Guid CreatedByUserId,
Guid? RideId,
decimal TotalAmount,
SaleStatus Status,
PaymentStatus PaymentStatus,
bool FulfillImmediately,
DateTime? CompletedAt,
Guid OrderId,
OrderStatus OrderStatus,
DateTime? OrderFulfilledAt,
List<SaleItemResponse> Items,
bool IsActive,
DateTime CreatedAt);
@@ -0,0 +1,32 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Sale;
public class SaleRepository(AppDBContext context) : ISaleRepository
{
public async Task<Models.Sale?> GetByIdAsync(Guid id) =>
await context.Sales.FindAsync(id);
public async Task<List<Models.Sale>> GetAllAsync() =>
await context.Sales.ToListAsync();
public async Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId) =>
await context.Sales.Where(s => s.FK_StoreId == storeId && s.IsActive).ToListAsync();
public async Task AddAsync(Models.Sale sale) =>
await context.Sales.AddAsync(sale);
public void Update(Models.Sale sale) =>
context.Sales.Update(sale);
public void Remove(Models.Sale sale)
{
sale.IsActive = false;
context.Sales.Update(sale);
}
public async Task<Models.Order?> GetOrderBySaleIdAsync(Guid saleId) =>
await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive);
}
+233
View File
@@ -0,0 +1,233 @@
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.PriceList;
using Distribution.Features.Ride;
using Distribution.Features.Stock;
using Distribution.Features.Store;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.Extensions.Configuration;
namespace Distribution.Features.Sale;
public class SaleService(
ISaleRepository repo,
IOrderRepository orderRepo,
IStoreRepository storeRepo,
IItemRepository itemRepo,
PriceListService priceListService,
IStockRepository stockRepo,
IRideRepository rideRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
IScopeAccessService scopeAccess,
IConfiguration configuration)
{
private const int GlobalScopeRoleLevel = 50;
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope;
return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId);
}
private async Task<decimal?> ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime saleDate)
{
var item = await itemRepo.GetByIdAsync(itemId);
if (item is null) return null;
var regionId = store.Route?.FK_RegionId;
var resolution = await priceListService.ResolvePriceAsync(itemId, regionId, item.SalePrice, saleDate);
if (resolution is null) return null;
if (!requestedUnitPrice.HasValue) return resolution.Price;
var min = resolution.MinPrice ?? resolution.Price;
var max = resolution.Price;
if (requestedUnitPrice.Value < min || requestedUnitPrice.Value > max) return null;
return requestedUnitPrice.Value;
}
private static SaleResponse ToResponse(Models.Sale sale, Models.Order order, List<Models.OrderItem> items) =>
new(
sale.Id,
sale.SaleDate,
sale.FK_StoreId,
sale.CreatedByUserId,
sale.FK_RideId,
sale.TotalAmount,
sale.Status,
sale.PaymentStatus,
sale.FulfillImmediately,
sale.CompletedAt,
order.Id,
order.Status,
order.FulfilledAt,
items.Select(i => new SaleItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(),
sale.IsActive,
sale.CreatedAt);
public async Task<List<SaleResponse>> GetAllAsync()
{
var sales = (await repo.GetAllAsync()).Where(s => s.IsActive).ToList();
if (!HasGlobalScope && currentUser.UserId is not null)
{
var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet();
sales = sales.Where(s => accessibleStoreIds.Contains(s.FK_StoreId)).ToList();
}
var result = new List<SaleResponse>();
foreach (var sale in sales)
{
var order = await repo.GetOrderBySaleIdAsync(sale.Id);
if (order is null) continue;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(sale, order, items));
}
return result;
}
public async Task<SaleResponse?> GetByIdAsync(Guid id)
{
var sale = await repo.GetByIdAsync(id);
if (sale is null || !sale.IsActive) return null;
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
var order = await repo.GetOrderBySaleIdAsync(id);
if (order is null) return null;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
return ToResponse(sale, order, items);
}
public async Task<List<SaleResponse>?> GetByStoreIdAsync(Guid storeId)
{
if (!await HasStoreScopeAsync(storeId)) return null;
var sales = await repo.GetByStoreIdAsync(storeId);
var result = new List<SaleResponse>();
foreach (var sale in sales)
{
var order = await repo.GetOrderBySaleIdAsync(sale.Id);
if (order is null) continue;
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
result.Add(ToResponse(sale, order, items));
}
return result;
}
public async Task<SaleResponse?> CreateAsync(CreateSaleRequest request, CancellationToken ct = default)
{
if (!await HasStoreScopeAsync(request.StoreId)) return null;
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId);
if (store is null || !store.IsActive) return null;
if (store.KycStatus != KycStatus.Verified)
{
var threshold = configuration.GetValue<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
if (request.TotalAmount > threshold) return null;
}
var saleStatus = request.FulfillImmediately ? SaleStatus.Completed : SaleStatus.Pending;
var orderStatus = request.FulfillImmediately ? OrderStatus.Fulfilled : OrderStatus.Pending;
var fulfilledAt = request.FulfillImmediately ? DateTime.Now : (DateTime?)null;
var sale = new Models.Sale
{
FK_StoreId = request.StoreId,
SaleDate = request.SaleDate,
CreatedByUserId = userId,
FK_RideId = request.RideId,
TotalAmount = request.TotalAmount,
Status = saleStatus,
FulfillImmediately = request.FulfillImmediately,
CompletedAt = request.FulfillImmediately ? DateTime.Now : null
};
var orderItems = new List<Models.OrderItem>();
foreach (var item in request.Items)
{
var unitPrice = await ResolveUnitPriceAsync(item.ItemId, item.UnitPrice, store, request.SaleDate);
if (unitPrice is null) return null;
orderItems.Add(new Models.OrderItem
{
FK_ItemId = item.ItemId,
Quantity = item.Quantity,
QuantityFulfilled = request.FulfillImmediately ? item.Quantity : 0,
UnitPrice = unitPrice.Value
});
}
var order = new Models.Order
{
FK_SaleId = sale.Id,
FK_StoreId = request.StoreId,
OrderDate = request.SaleDate,
TotalAmount = request.TotalAmount,
Status = orderStatus,
FulfilledAt = fulfilledAt
};
foreach (var orderItem in orderItems)
orderItem.FK_OrderId = order.Id;
Guid? vehicleId = null;
if (request.FulfillImmediately && request.RideId.HasValue)
{
var ride = await rideRepo.GetByIdAsync(request.RideId.Value);
if (ride is null) return null;
vehicleId = ride.FK_VehicleId;
foreach (var item in orderItems)
{
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
if (balance is null || balance.Quantity < item.Quantity)
return null;
}
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(sale);
await orderRepo.AddAsync(order);
await orderRepo.AddItemsAsync(orderItems);
if (vehicleId.HasValue)
{
foreach (var item in orderItems)
{
var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value);
balance!.Quantity -= item.Quantity;
balance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(balance);
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = item.FK_ItemId,
FromLocationId = vehicleId.Value,
FromLocationType = LocationType.Vehicle,
Quantity = item.Quantity,
Type = MovementType.Sale,
ReferenceId = order.Id
});
}
}
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(sale, order, orderItems);
}
}
@@ -36,7 +36,18 @@ public class SupplierController(SupplierService service) : ControllerBase
[HttpDelete("{id:guid}")]
public async Task<IActionResult> 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<IActionResult> Evaluate(Guid id, CancellationToken ct)
{
var result = await service.EvaluateAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
}
@@ -1,8 +1,14 @@
using Distribution.Features.Grn;
using Distribution.Features.Po;
using Distribution.Infra.UoW;
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;
}
/// <summary>
/// The only writer of Supplier.PerformanceScore. Computes a simple 0-100 score from this
/// supplier's own purchase-order history:
/// - lineFulfillmentRatio: received (non-cancelled, fully-received) Po lines / all non-active
/// Po lines seen (received + cancelled) - how reliably the supplier fulfils what it commits to.
/// - acceptanceRatio: AcceptedQuantity / (AcceptedQuantity + RejectedQuantity) across every
/// GoodsReceipt against this supplier's Pos - how much of what arrives is usable.
/// score = round(average(lineFulfillmentRatio, acceptanceRatio) * 100, 2).
/// Defaults to 50 (neutral) when there is no history yet, rather than hardcoding a fixed score.
/// </summary>
public async Task<SupplierResponse?> EvaluateAsync(Guid id, CancellationToken ct = default)
{
var supplier = await repo.GetByIdAsync(id);
if (supplier is null || !supplier.IsActive) return null;
var purchaseOrders = await purchaseOrderRepo.GetBySupplierAsync(id);
var receivedLines = 0;
var cancelledLines = 0;
foreach (var po in purchaseOrders)
{
foreach (var item in po.Items.Where(i => i.IsActive))
{
if (item.IsCancelled) cancelledLines++;
else if (item.ReceivedQuantity >= item.Quantity) receivedLines++;
}
}
var totalAccepted = 0;
var totalRejected = 0;
foreach (var po in purchaseOrders)
{
foreach (var receipt in await goodsReceiptRepo.GetByPurchaseOrderIdAsync(po.Id))
{
foreach (var item in receipt.Items.Where(i => i.IsActive))
{
totalAccepted += item.AcceptedQuantity;
totalRejected += item.RejectedQuantity;
}
}
}
var lineFulfillmentRatio = receivedLines + cancelledLines > 0
? (decimal)receivedLines / (receivedLines + cancelledLines)
: (decimal?)null;
var acceptanceRatio = totalAccepted + totalRejected > 0
? (decimal)totalAccepted / (totalAccepted + totalRejected)
: (decimal?)null;
decimal score;
if (lineFulfillmentRatio.HasValue && acceptanceRatio.HasValue)
score = Math.Round((lineFulfillmentRatio.Value + acceptanceRatio.Value) / 2m * 100m, 2);
else if (lineFulfillmentRatio.HasValue)
score = Math.Round(lineFulfillmentRatio.Value * 100m, 2);
else if (acceptanceRatio.HasValue)
score = Math.Round(acceptanceRatio.Value * 100m, 2);
else
score = 50m;
supplier.PerformanceScore = score;
await uow.BeginAsync(ct);
try { repo.Update(supplier); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return Map(supplier);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,257 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddProcurement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PurchaseOrders",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_SupplierId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Status = table.Column<int>(type: "int", nullable: false),
CancelReason = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseOrders", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseOrders_Suppliers_FK_SupplierId",
column: x => x.FK_SupplierId,
principalTable: "Suppliers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "GoodsReceipts",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_PurchaseOrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ReceivedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_WarehouseId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ReceivedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GoodsReceipts", x => x.Id);
table.ForeignKey(
name: "FK_GoodsReceipts_PurchaseOrders_FK_PurchaseOrderId",
column: x => x.FK_PurchaseOrderId,
principalTable: "PurchaseOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_GoodsReceipts_Warehouses_FK_WarehouseId",
column: x => x.FK_WarehouseId,
principalTable: "Warehouses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "PurchaseOrderItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_PurchaseOrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Quantity = table.Column<int>(type: "int", nullable: false),
UnitCost = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
ReceivedQuantity = table.Column<int>(type: "int", nullable: false),
CancelledQuantity = table.Column<int>(type: "int", nullable: false),
IsCancelled = table.Column<bool>(type: "tinyint(1)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseOrderItems", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseOrderItems_Items_FK_ItemId",
column: x => x.FK_ItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_PurchaseOrderItems_PurchaseOrders_FK_PurchaseOrderId",
column: x => x.FK_PurchaseOrderId,
principalTable: "PurchaseOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "PurchaseReturns",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_PurchaseOrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Reason = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseReturns", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseReturns_PurchaseOrders_FK_PurchaseOrderId",
column: x => x.FK_PurchaseOrderId,
principalTable: "PurchaseOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "GoodsReceiptItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_GoodsReceiptId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_PurchaseOrderItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
AcceptedQuantity = table.Column<int>(type: "int", nullable: false),
RejectedQuantity = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GoodsReceiptItems", x => x.Id);
table.ForeignKey(
name: "FK_GoodsReceiptItems_GoodsReceipts_FK_GoodsReceiptId",
column: x => x.FK_GoodsReceiptId,
principalTable: "GoodsReceipts",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_GoodsReceiptItems_PurchaseOrderItems_FK_PurchaseOrderItemId",
column: x => x.FK_PurchaseOrderItemId,
principalTable: "PurchaseOrderItems",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "PurchaseReturnItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_PurchaseReturnId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Quantity = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PurchaseReturnItems", x => x.Id);
table.ForeignKey(
name: "FK_PurchaseReturnItems_Items_FK_ItemId",
column: x => x.FK_ItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_PurchaseReturnItems_PurchaseReturns_FK_PurchaseReturnId",
column: x => x.FK_PurchaseReturnId,
principalTable: "PurchaseReturns",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_GoodsReceiptItems_FK_GoodsReceiptId",
table: "GoodsReceiptItems",
column: "FK_GoodsReceiptId");
migrationBuilder.CreateIndex(
name: "IX_GoodsReceiptItems_FK_PurchaseOrderItemId",
table: "GoodsReceiptItems",
column: "FK_PurchaseOrderItemId");
migrationBuilder.CreateIndex(
name: "IX_GoodsReceipts_FK_PurchaseOrderId",
table: "GoodsReceipts",
column: "FK_PurchaseOrderId");
migrationBuilder.CreateIndex(
name: "IX_GoodsReceipts_FK_WarehouseId",
table: "GoodsReceipts",
column: "FK_WarehouseId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseOrderItems_FK_ItemId",
table: "PurchaseOrderItems",
column: "FK_ItemId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseOrderItems_FK_PurchaseOrderId",
table: "PurchaseOrderItems",
column: "FK_PurchaseOrderId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseOrders_FK_SupplierId",
table: "PurchaseOrders",
column: "FK_SupplierId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseReturnItems_FK_ItemId",
table: "PurchaseReturnItems",
column: "FK_ItemId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseReturnItems_FK_PurchaseReturnId",
table: "PurchaseReturnItems",
column: "FK_PurchaseReturnId");
migrationBuilder.CreateIndex(
name: "IX_PurchaseReturns_FK_PurchaseOrderId",
table: "PurchaseReturns",
column: "FK_PurchaseOrderId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "GoodsReceiptItems");
migrationBuilder.DropTable(
name: "PurchaseReturnItems");
migrationBuilder.DropTable(
name: "GoodsReceipts");
migrationBuilder.DropTable(
name: "PurchaseOrderItems");
migrationBuilder.DropTable(
name: "PurchaseReturns");
migrationBuilder.DropTable(
name: "PurchaseOrders");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,236 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class payment : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "FK_SaleId",
table: "Orders",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci");
migrationBuilder.AddColumn<DateTime>(
name: "FulfilledAt",
table: "Orders",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Status",
table: "Orders",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "QuantityFulfilled",
table: "OrderItems",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "DeliveryTasks",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RideId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_OrderId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Sequence = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
CustomerConfirmed = table.Column<bool>(type: "tinyint(1)", nullable: false),
ConfirmedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
Note = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DeliveryTasks", x => x.Id);
table.ForeignKey(
name: "FK_DeliveryTasks_DeliveryRuns_FK_RideId",
column: x => x.FK_RideId,
principalTable: "DeliveryRuns",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_DeliveryTasks_Orders_FK_OrderId",
column: x => x.FK_OrderId,
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Sales",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
SaleDate = table.Column<DateTime>(type: "datetime(6)", nullable: false),
FK_StoreId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CreatedByUserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RideId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
TotalAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
PaymentStatus = table.Column<int>(type: "int", nullable: false),
FulfillImmediately = table.Column<bool>(type: "tinyint(1)", nullable: false),
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Sales", x => x.Id);
table.ForeignKey(
name: "FK_Sales_DeliveryRuns_FK_RideId",
column: x => x.FK_RideId,
principalTable: "DeliveryRuns",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Sales_Stores_FK_StoreId",
column: x => x.FK_StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Sales_Users_CreatedByUserId",
column: x => x.CreatedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Payments",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_SaleId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_StoreId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Amount = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
Method = table.Column<int>(type: "int", nullable: false),
CollectedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CollectedBy = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Payments", x => x.Id);
table.ForeignKey(
name: "FK_Payments_Sales_FK_SaleId",
column: x => x.FK_SaleId,
principalTable: "Sales",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Payments_Stores_FK_StoreId",
column: x => x.FK_StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Orders_FK_SaleId",
table: "Orders",
column: "FK_SaleId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_DeliveryTasks_FK_OrderId",
table: "DeliveryTasks",
column: "FK_OrderId");
migrationBuilder.CreateIndex(
name: "IX_DeliveryTasks_FK_RideId",
table: "DeliveryTasks",
column: "FK_RideId");
migrationBuilder.CreateIndex(
name: "IX_Payments_FK_SaleId",
table: "Payments",
column: "FK_SaleId");
migrationBuilder.CreateIndex(
name: "IX_Payments_FK_StoreId",
table: "Payments",
column: "FK_StoreId");
migrationBuilder.CreateIndex(
name: "IX_Sales_CreatedByUserId",
table: "Sales",
column: "CreatedByUserId");
migrationBuilder.CreateIndex(
name: "IX_Sales_FK_RideId",
table: "Sales",
column: "FK_RideId");
migrationBuilder.CreateIndex(
name: "IX_Sales_FK_StoreId",
table: "Sales",
column: "FK_StoreId");
migrationBuilder.AddForeignKey(
name: "FK_Orders_Sales_FK_SaleId",
table: "Orders",
column: "FK_SaleId",
principalTable: "Sales",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Orders_Sales_FK_SaleId",
table: "Orders");
migrationBuilder.DropTable(
name: "DeliveryTasks");
migrationBuilder.DropTable(
name: "Payments");
migrationBuilder.DropTable(
name: "Sales");
migrationBuilder.DropIndex(
name: "IX_Orders_FK_SaleId",
table: "Orders");
migrationBuilder.DropColumn(
name: "FK_SaleId",
table: "Orders");
migrationBuilder.DropColumn(
name: "FulfilledAt",
table: "Orders");
migrationBuilder.DropColumn(
name: "Status",
table: "Orders");
migrationBuilder.DropColumn(
name: "QuantityFulfilled",
table: "OrderItems");
}
}
}
@@ -131,6 +131,48 @@ namespace Distribution.Migrations
b.ToTable("DeliveryRuns");
});
modelBuilder.Entity("Distribution.Models.DeliveryTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("ConfirmedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("CustomerConfirmed")
.HasColumnType("tinyint(1)");
b.Property<Guid>("FK_OrderId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_RideId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Note")
.HasColumnType("longtext");
b.Property<int>("Sequence")
.HasColumnType("int");
b.Property<int>("Status")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FK_OrderId");
b.HasIndex("FK_RideId");
b.ToTable("DeliveryTasks");
});
modelBuilder.Entity("Distribution.Models.DriverComplianceConfig", b =>
{
b.Property<Guid>("Id")
@@ -187,6 +229,72 @@ namespace Distribution.Migrations
b.ToTable("DriverProfiles");
});
modelBuilder.Entity("Distribution.Models.GoodsReceipt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_PurchaseOrderId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_ReceivedByUserId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_WarehouseId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("FK_PurchaseOrderId");
b.HasIndex("FK_WarehouseId");
b.ToTable("GoodsReceipts");
});
modelBuilder.Entity("Distribution.Models.GoodsReceiptItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<int>("AcceptedQuantity")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_GoodsReceiptId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_PurchaseOrderItemId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<int>("RejectedQuantity")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FK_GoodsReceiptId");
b.HasIndex("FK_PurchaseOrderItemId");
b.ToTable("GoodsReceiptItems");
});
modelBuilder.Entity("Distribution.Models.Item", b =>
{
b.Property<Guid>("Id")
@@ -331,20 +439,32 @@ namespace Distribution.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_SaleId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_StoreId")
.HasColumnType("char(36)");
b.Property<DateTime?>("FulfilledAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<DateTime>("OrderDate")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<decimal>("TotalAmount")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("FK_SaleId")
.IsUnique();
b.HasIndex("FK_StoreId");
b.ToTable("Orders");
@@ -370,6 +490,9 @@ namespace Distribution.Migrations
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("QuantityFulfilled")
.HasColumnType("int");
b.Property<decimal>("UnitPrice")
.HasColumnType("decimal(65,30)");
@@ -380,6 +503,45 @@ namespace Distribution.Migrations
b.ToTable("OrderItems");
});
modelBuilder.Entity("Distribution.Models.Payment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<decimal>("Amount")
.HasColumnType("decimal(65,30)");
b.Property<DateTime>("CollectedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("CollectedBy")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_SaleId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_StoreId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<int>("Method")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FK_SaleId");
b.HasIndex("FK_StoreId");
b.ToTable("Payments");
});
modelBuilder.Entity("Distribution.Models.Permission", b =>
{
b.Property<Guid>("Id")
@@ -471,6 +633,135 @@ namespace Distribution.Migrations
b.ToTable("PriceListItems");
});
modelBuilder.Entity("Distribution.Models.PurchaseOrder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("CancelReason")
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_CreatedByUserId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_SupplierId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<int>("Status")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FK_SupplierId");
b.ToTable("PurchaseOrders");
});
modelBuilder.Entity("Distribution.Models.PurchaseOrderItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<int>("CancelledQuantity")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_ItemId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_PurchaseOrderId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsCancelled")
.HasColumnType("tinyint(1)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.Property<int>("ReceivedQuantity")
.HasColumnType("int");
b.Property<decimal>("UnitCost")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("FK_ItemId");
b.HasIndex("FK_PurchaseOrderId");
b.ToTable("PurchaseOrderItems");
});
modelBuilder.Entity("Distribution.Models.PurchaseReturn", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_PurchaseOrderId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("FK_PurchaseOrderId");
b.ToTable("PurchaseReturns");
});
modelBuilder.Entity("Distribution.Models.PurchaseReturnItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("FK_ItemId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_PurchaseReturnId")
.HasColumnType("char(36)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<int>("Quantity")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FK_ItemId");
b.HasIndex("FK_PurchaseReturnId");
b.ToTable("PurchaseReturnItems");
});
modelBuilder.Entity("Distribution.Models.Region", b =>
{
b.Property<Guid>("Id")
@@ -665,6 +956,56 @@ namespace Distribution.Migrations
b.ToTable("Routes");
});
modelBuilder.Entity("Distribution.Models.Sale", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("char(36)");
b.Property<Guid?>("FK_RideId")
.HasColumnType("char(36)");
b.Property<Guid>("FK_StoreId")
.HasColumnType("char(36)");
b.Property<bool>("FulfillImmediately")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsActive")
.HasColumnType("tinyint(1)");
b.Property<int>("PaymentStatus")
.HasColumnType("int");
b.Property<DateTime>("SaleDate")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<decimal>("TotalAmount")
.HasColumnType("decimal(65,30)");
b.HasKey("Id");
b.HasIndex("CreatedByUserId");
b.HasIndex("FK_RideId");
b.HasIndex("FK_StoreId");
b.ToTable("Sales");
});
modelBuilder.Entity("Distribution.Models.SaleRoute", b =>
{
b.Property<Guid>("Id")
@@ -1304,6 +1645,25 @@ namespace Distribution.Migrations
b.Navigation("Vehicle");
});
modelBuilder.Entity("Distribution.Models.DeliveryTask", b =>
{
b.HasOne("Distribution.Models.Order", "Order")
.WithMany()
.HasForeignKey("FK_OrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
.WithMany()
.HasForeignKey("FK_RideId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Order");
b.Navigation("Ride");
});
modelBuilder.Entity("Distribution.Models.DriverProfile", b =>
{
b.HasOne("Distribution.Models.User", "User")
@@ -1315,6 +1675,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")
@@ -1386,12 +1784,20 @@ namespace Distribution.Migrations
modelBuilder.Entity("Distribution.Models.Order", b =>
{
b.HasOne("Distribution.Models.Sale", "Sale")
.WithMany()
.HasForeignKey("FK_SaleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.Store", "Store")
.WithMany()
.HasForeignKey("FK_StoreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Sale");
b.Navigation("Store");
});
@@ -1404,7 +1810,7 @@ namespace Distribution.Migrations
.IsRequired();
b.HasOne("Distribution.Models.Order", "Order")
.WithMany()
.WithMany("OrderItems")
.HasForeignKey("FK_OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
@@ -1414,6 +1820,25 @@ namespace Distribution.Migrations
b.Navigation("Order");
});
modelBuilder.Entity("Distribution.Models.Payment", b =>
{
b.HasOne("Distribution.Models.Sale", "Sale")
.WithMany()
.HasForeignKey("FK_SaleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Distribution.Models.Store", "Store")
.WithMany()
.HasForeignKey("FK_StoreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Sale");
b.Navigation("Store");
});
modelBuilder.Entity("Distribution.Models.PriceList", b =>
{
b.HasOne("Distribution.Models.Region", "Region")
@@ -1442,6 +1867,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")
@@ -1483,6 +1968,32 @@ namespace Distribution.Migrations
b.Navigation("Region");
});
modelBuilder.Entity("Distribution.Models.Sale", b =>
{
b.HasOne("Distribution.Models.User", "CreatedByUser")
.WithMany()
.HasForeignKey("CreatedByUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
.WithMany()
.HasForeignKey("FK_RideId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Distribution.Models.Store", "Store")
.WithMany()
.HasForeignKey("FK_StoreId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CreatedByUser");
b.Navigation("Ride");
b.Navigation("Store");
});
modelBuilder.Entity("Distribution.Models.Scheme", b =>
{
b.HasOne("Distribution.Models.Item", "FreeItem")
@@ -1724,6 +2235,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");
@@ -1734,11 +2250,26 @@ namespace Distribution.Migrations
b.Navigation("Items");
});
modelBuilder.Entity("Distribution.Models.Order", b =>
{
b.Navigation("OrderItems");
});
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");
+21
View File
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class DeliveryTask : Template
{
public Guid FK_RideId { get; set; }
[ForeignKey("FK_RideId")]
public DeliveryRun Ride { get; set; }
public Guid FK_OrderId { get; set; }
[ForeignKey("FK_OrderId")]
public Order Order { get; set; }
public int Sequence { get; set; }
public DeliveryTaskStatus Status { get; set; } = DeliveryTaskStatus.Pending;
public bool CustomerConfirmed { get; set; } = false;
public DateTime? ConfirmedAt { get; set; }
public string? Note { get; set; }
}
}
+21 -1
View File
@@ -19,7 +19,9 @@ namespace Distribution.Models
Transfer,
Damage,
Adjustment,
Load
Load,
PurchaseReceipt,
PurchaseReturn
}
public enum LoadingSheetStatus
@@ -38,4 +40,22 @@ namespace Distribution.Models
public enum CancelRequestType { VehicleBreakdown, DriverUnavailable, RouteIssue, Other }
public enum CancelRequestStatus { Pending, Approved, Rejected }
public enum PurchaseOrderStatus { Issued, PartiallyReceived, Received, Closed, Cancelled }
public enum SaleStatus { Pending, Completed, Cancelled }
public enum OrderStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
public enum PaymentStatus { Pending, PartiallyPaid, Paid }
public enum PaymentMethod { Cash, Credit }
public enum DeliveryTaskStatus
{
Pending,
Delivered,
PartiallyDelivered,
Failed
}
}
+26
View File
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class GoodsReceipt : Template
{
public Guid FK_PurchaseOrderId { get; set; }
[ForeignKey("FK_PurchaseOrderId")]
public PurchaseOrder PurchaseOrder { get; set; }
public Guid FK_ReceivedByUserId { get; set; }
/// <summary>
/// Warehouse the accepted quantities are received into. Not listed as a field in
/// docs/15_procurement.md's GoodsReceipt table, but required to write StockBalance/
/// StockMovement rows (both keyed by LocationId+LocationType) for the accepted quantities.
/// </summary>
public Guid FK_WarehouseId { get; set; }
[ForeignKey("FK_WarehouseId")]
public Warehouse Warehouse { get; set; }
public DateTime ReceivedAt { get; set; } = DateTime.Now;
public ICollection<GoodsReceiptItem> Items { get; set; } = [];
}
}
+21
View File
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class GoodsReceiptItem : Template
{
public Guid FK_GoodsReceiptId { get; set; }
[ForeignKey("FK_GoodsReceiptId")]
public GoodsReceipt GoodsReceipt { get; set; }
public Guid FK_PurchaseOrderItemId { get; set; }
[ForeignKey("FK_PurchaseOrderItemId")]
public PurchaseOrderItem PurchaseOrderItem { get; set; }
/// <summary>Added to warehouse stock.</summary>
public int AcceptedQuantity { get; set; }
/// <summary>Damaged/short at receipt, not added to stock.</summary>
public int RejectedQuantity { get; set; } = 0;
}
}
+8
View File
@@ -4,10 +4,18 @@ namespace Distribution.Models
{
public class Order : Template
{
public Guid FK_SaleId { get; set; }
[ForeignKey("FK_SaleId")]
public Sale Sale { get; set; }
public DateTime OrderDate { get; set; }
public Guid FK_StoreId { get; set; }
[ForeignKey("FK_StoreId")]
public Store Store { get; set; }
public decimal TotalAmount { get; set; }
public OrderStatus Status { get; set; } = OrderStatus.Pending;
public DateTime? FulfilledAt { get; set; }
public ICollection<OrderItem> OrderItems { get; set; } = new List<OrderItem>();
}
}
+1
View File
@@ -11,6 +11,7 @@ namespace Distribution.Models
[ForeignKey("FK_ItemId")]
public Item Item { get; set; }
public int Quantity { get; set; }
public int QuantityFulfilled { get; set; } = 0;
public decimal UnitPrice { get; set; }
}
}
+20
View File
@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class Payment : Template
{
public Guid FK_SaleId { get; set; }
[ForeignKey("FK_SaleId")]
public Sale Sale { get; set; }
public Guid FK_StoreId { get; set; }
[ForeignKey("FK_StoreId")]
public Store Store { get; set; }
public decimal Amount { get; set; }
public PaymentMethod Method { get; set; }
public DateTime CollectedAt { get; set; }
public Guid CollectedBy { get; set; }
}
}
+19
View File
@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class PurchaseOrder : Template
{
public Guid FK_SupplierId { get; set; }
[ForeignKey("FK_SupplierId")]
public Supplier Supplier { get; set; }
public Guid FK_CreatedByUserId { get; set; }
public PurchaseOrderStatus Status { get; set; } = PurchaseOrderStatus.Issued;
public string? CancelReason { get; set; }
public ICollection<PurchaseOrderItem> Items { get; set; } = [];
}
}
+32
View File
@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class PurchaseOrderItem : Template
{
public Guid FK_PurchaseOrderId { get; set; }
[ForeignKey("FK_PurchaseOrderId")]
public PurchaseOrder PurchaseOrder { get; set; }
public Guid FK_ItemId { get; set; }
[ForeignKey("FK_ItemId")]
public Item Item { get; set; }
public int Quantity { get; set; }
/// <summary>Agreed cost per unit at the time the PO was issued.</summary>
public decimal UnitCost { get; set; }
/// <summary>Running total, incremented by createGoodsReceipt; drives PurchaseOrder.Status.</summary>
public int ReceivedQuantity { get; set; } = 0;
/// <summary>
/// Set by cancelPurchaseOrderLine. Outstanding qty for receiving purposes is
/// Quantity - ReceivedQuantity - CancelledQuantity.
/// </summary>
public int CancelledQuantity { get; set; } = 0;
/// <summary>True once this line is individually cancelled.</summary>
public bool IsCancelled { get; set; } = false;
}
}
+15
View File
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class PurchaseReturn : Template
{
public Guid FK_PurchaseOrderId { get; set; }
[ForeignKey("FK_PurchaseOrderId")]
public PurchaseOrder PurchaseOrder { get; set; }
public string Reason { get; set; }
public ICollection<PurchaseReturnItem> Items { get; set; } = [];
}
}
+18
View File
@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class PurchaseReturnItem : Template
{
public Guid FK_PurchaseReturnId { get; set; }
[ForeignKey("FK_PurchaseReturnId")]
public PurchaseReturn PurchaseReturn { get; set; }
public Guid FK_ItemId { get; set; }
[ForeignKey("FK_ItemId")]
public Item Item { get; set; }
/// <summary>Returned quantity, decremented from warehouse stock.</summary>
public int Quantity { get; set; }
}
}
+27
View File
@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class Sale : Template
{
public DateTime SaleDate { get; set; }
public Guid FK_StoreId { get; set; }
[ForeignKey("FK_StoreId")]
public Store Store { get; set; }
public Guid CreatedByUserId { get; set; }
[ForeignKey("CreatedByUserId")]
public User CreatedByUser { get; set; }
public Guid? FK_RideId { get; set; }
[ForeignKey("FK_RideId")]
public DeliveryRun? Ride { get; set; }
public decimal TotalAmount { get; set; }
public SaleStatus Status { get; set; } = SaleStatus.Pending;
public PaymentStatus PaymentStatus { get; set; } = PaymentStatus.Pending;
public bool FulfillImmediately { get; set; }
public DateTime? CompletedAt { get; set; }
}
}
+55 -244
View File
@@ -1,267 +1,78 @@
# Phase Plan — Route & Store Management
# Phase Plan - Route and Store Management
## Scope
Build the full vertical-slice stack for: **Region, Route, Vehicle, Store, Warehouse, Order, OrderItem**.
Item model is sourced from ERP Core — excluded from this phase.
SaleRoute shape is TBD — excluded until clarified.
Build the full vertical-slice stack for: **Region, Route, Vehicle, Store, Warehouse, Order,
OrderItem**.
Item model is sourced from ERP Core - excluded from this phase.
Historical note: `SaleRoute` was marked TBD here. For now, rides stay single-route and route
assignment remains simple until further clarification.
---
## Project Structure (target)
```
```text
Distribution/
├── Models/ # All entity POCOs — unchanged
│ ├── Template.cs
│ ├── Region.cs
│ ├── Route.cs
│ ├── Vehicle.cs
│ ├── Store.cs
│ ├── Warehouse.cs
│ ├── SaleRoute.cs # TBD
│ ├── Order.cs
│ ├── OrderItem.cs
│ ├── DeliveryRun.cs
│ └── Item.cs # Sourced from ERP Core
├── Data/
│ └── AppDBContext.cs # DbSets + Fluent API config
├── Infra/
│ ├── UoW/ # Already implemented
│ │ ├── IUnitOfWork.cs
│ │ └── EFUnitOfWork.cs
│ └── Repositories/ # Shared generic base only
│ ├── IGenericRepo.cs
│ └── GenericRepo.cs
└── Features/
├── Region/
│ ├── RegionDTOs.cs
│ ├── IRegionRepository.cs
│ ├── RegionRepository.cs
│ ├── RegionService.cs
│ └── RegionController.cs
├── Route/
│ ├── RouteDTOs.cs
│ ├── IRouteRepository.cs
│ ├── RouteRepository.cs
│ ├── RouteService.cs
│ └── RouteController.cs
├── Vehicle/
│ ├── VehicleDTOs.cs
│ ├── IVehicleRepository.cs
│ ├── VehicleRepository.cs
│ ├── VehicleService.cs
│ └── VehicleController.cs
├── Store/
│ ├── StoreDTOs.cs
│ ├── IStoreRepository.cs
│ ├── StoreRepository.cs
│ ├── StoreService.cs
│ └── StoreController.cs
├── Warehouse/
│ ├── WarehouseDTOs.cs
│ ├── IWarehouseRepository.cs
│ ├── WarehouseRepository.cs
│ ├── WarehouseService.cs
│ └── WarehouseController.cs
└── Order/
├── OrderDTOs.cs
├── IOrderRepository.cs
├── OrderRepository.cs
├── OrderService.cs
└── OrderController.cs
|-- Models/
| |-- Template.cs
| |-- Region.cs
| |-- Route.cs
| |-- Vehicle.cs
| |-- Store.cs
| |-- Warehouse.cs
| |-- SaleRoute.cs
| |-- Order.cs
| |-- OrderItem.cs
| |-- DeliveryRun.cs
| `-- Item.cs
|-- Data/
| `-- AppDBContext.cs
|-- Infra/
| |-- UoW/
| | |-- IUnitOfWork.cs
| | `-- EFUnitOfWork.cs
| `-- Repositories/
| |-- IGenericRepo.cs
| `-- GenericRepo.cs
`-- Features/
|-- Region/
|-- Route/
|-- Vehicle/
|-- Store/
|-- Warehouse/
`-- Order/
```
---
## Hard Rules (from AGENTS.md)
## Hard Rules
- Only `uow.CommitAsync()` in services — never in repositories.
- Use `DateTime.Now` — never `DateTime.UtcNow`.
- Only `uow.CommitAsync()` in services - never in repositories.
- Use `DateTime.Now` - never `DateTime.UtcNow`.
- Never build anything not explicitly requested.
---
## Step 2 — Complete AppDBContext
File: `Data/AppDBContext.cs`
Add DbSets and Fluent API configuration:
```csharp
public DbSet<Region> Regions { get; set; }
public DbSet<Route> Routes { get; set; }
public DbSet<Vehicle> Vehicles { get; set; }
public DbSet<Store> Stores { get; set; }
public DbSet<Warehouse> Warehouses { get; set; }
public DbSet<SaleRoute> SaleRoutes { get; set; }
public DbSet<Order> Orders { get; set; }
public DbSet<OrderItem> OrderItems { get; set; }
public DbSet<DeliveryRun> DeliveryRuns { get; set; }
public DbSet<Item> Items { get; set; }
// OnModelCreating
modelBuilder.Entity<OrderItem>()
.HasKey(oi => new { oi.FK_OrderId, oi.FK_ItemId });
```
---
## Step 3 — Initial Migration
```powershell
dotnet ef migrations add InitialCreate --project Distribution
dotnet ef database update --project Distribution
```
Each developer applies this locally. No migration files are shared.
---
## Step 4 — Generic Repository (Infra/Repositories)
**`IGenericRepo<T>`**
```
GetByIdAsync(Guid id)
GetAllAsync()
AddAsync(T entity)
Update(T entity)
Remove(T entity)
```
**`GenericRepo<T>`** — implements above using DbContext. No `SaveChanges` calls.
---
## Step 5 — Feature Implementation Order
Build each feature in this dependency order:
```
1. Region → no dependencies
2. Route → no dependencies
3. Vehicle → no dependencies
4. Store → no dependencies
5. Warehouse → depends on Vehicle (FK)
6. Order → depends on Store (FK)
7. OrderItem → depends on Order + Item (composite FK)
```
### Per-feature deliverables
#### DTOs (`{Feature}DTOs.cs`)
- `CreateRequest` — fields accepted on POST
- `UpdateRequest` — fields accepted on PUT
- `Response` — shape returned to client (never expose the entity directly)
#### Repository interface (`I{Feature}Repository.cs`)
Extends `IGenericRepo<T>`. Add feature-specific queries (e.g., `GetByStoreId`).
#### Repository implementation (`{Feature}Repository.cs`)
Inherits `GenericRepo<T>`. Implements feature-specific queries. No `CommitAsync` calls.
#### Service (`{Feature}Service.cs`)
- Contains all business logic.
- Calls `_uow.CommitAsync(ct)` after write operations.
- For multi-step writes: `BeginAsync → work → CommitAsync` (catch → `RollbackAsync`).
#### Controller (`{Feature}Controller.cs`)
Thin — maps HTTP → service call → returns result. No logic, no UoW calls.
---
## Step 6 — Endpoints
### Region — `/api/regions`
| Method | Route | Description |
|---|---|---|
| GET | `/` | Get all active regions |
| GET | `/{id}` | Get region by id |
| POST | `/` | Create region |
| PUT | `/{id}` | Update region |
| DELETE | `/{id}` | Soft delete (set IsActive = false) |
### Route — `/api/routes`
| Method | Route | Description |
|---|---|---|
| GET | `/` | Get all active routes |
| GET | `/{id}` | Get route by id |
| POST | `/` | Create route |
| PUT | `/{id}` | Update route |
| DELETE | `/{id}` | Soft delete |
### Vehicle — `/api/vehicles`
| Method | Route | Description |
|---|---|---|
| GET | `/` | Get all active vehicles |
| GET | `/{id}` | Get vehicle by id |
| POST | `/` | Create vehicle |
| PUT | `/{id}` | Update vehicle |
| DELETE | `/{id}` | Soft delete |
### Store — `/api/stores`
| Method | Route | Description |
|---|---|---|
| GET | `/` | Get all active stores |
| GET | `/{id}` | Get store by id |
| POST | `/` | Create store |
| PUT | `/{id}` | Update store |
| DELETE | `/{id}` | Soft delete |
### Warehouse — `/api/warehouses`
| Method | Route | Description |
|---|---|---|
| GET | `/` | Get all active warehouses |
| GET | `/{id}` | Get warehouse by id |
| POST | `/` | Create warehouse |
| PUT | `/{id}` | Update warehouse |
| DELETE | `/{id}` | Soft delete |
### Order — `/api/orders`
| Method | Route | Description |
|---|---|---|
| GET | `/` | Get all active orders |
| GET | `/{id}` | Get order with items |
| GET | `/store/{storeId}` | Get orders by store |
| POST | `/` | Create order with items |
| PUT | `/{id}` | Update order |
| DELETE | `/{id}` | Soft delete |
---
## Step 7 — DI Registration (Program.cs)
Register all feature repositories and services:
```csharp
// Generic base
builder.Services.AddScoped(typeof(IGenericRepo<>), typeof(GenericRepo<>));
// Feature repositories
builder.Services.AddScoped<IRegionRepository, RegionRepository>();
builder.Services.AddScoped<IRouteRepository, RouteRepository>();
builder.Services.AddScoped<IVehicleRepository, VehicleRepository>();
builder.Services.AddScoped<IStoreRepository, StoreRepository>();
builder.Services.AddScoped<IWarehouseRepository, WarehouseRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
// Feature services
builder.Services.AddScoped<RegionService>();
builder.Services.AddScoped<RouteService>();
builder.Services.AddScoped<VehicleService>();
builder.Services.AddScoped<StoreService>();
builder.Services.AddScoped<WarehouseService>();
builder.Services.AddScoped<OrderService>();
```
---
## Deferred
| Item | Reason |
|---|---|
| `SaleRoute` | Shape undefined — implement after clarification |
| `Item` model | Sourced from ERP Core — user handles integration |
| `SaleRoute` | Re-evaluate after ride/route design is clarified |
| `Item` model | Sourced from ERP Core |
| `DeliveryRun` | Not in scope for this phase |
| Driver FK on DeliveryRun | Commented out — depends on HR/Driver module |
| Tests | No test project yet — add after endpoints stabilize |
| Tests | No test project yet |
---
## Note
This file is historical phase planning only. Current business definitions for ride, sale, order,
and fulfillment are maintained in:
- `docs/03_ride_lifecycle.md`
- `docs/04_loading_event.md`
- `docs/05_delivery_ride.md`
- `docs/06_sale_ride.md`
+18
View File
@@ -3,16 +3,22 @@ using Distribution.Data;
using Distribution.Features.Auth;
using Distribution.Features.Brand;
using Distribution.Features.Category;
using Distribution.Features.DeliveryRide;
using Distribution.Features.Payment;
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;
using Distribution.Features.Role;
using Distribution.Features.Route;
using Distribution.Features.Sale;
using Distribution.Features.Scheme;
using Distribution.Features.Store;
using Distribution.Features.Store.Kyc;
@@ -46,6 +52,7 @@ builder.Services.AddScoped<IVehicleRepository, VehicleRepository>();
builder.Services.AddScoped<IStoreRepository, StoreRepository>();
builder.Services.AddScoped<IWarehouseRepository, WarehouseRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<ISaleRepository, SaleRepository>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IRoleRepository, RoleRepository>();
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
@@ -63,6 +70,11 @@ builder.Services.AddScoped<IStoreKycDocumentRepository, StoreKycDocumentReposito
builder.Services.AddScoped<IPriceListRepository, PriceListRepository>();
builder.Services.AddScoped<ISupplierRepository, SupplierRepository>();
builder.Services.AddScoped<ISchemeRepository, SchemeRepository>();
builder.Services.AddScoped<IPoRepository, PoRepository>();
builder.Services.AddScoped<IGrnRepository, GrnRepository>();
builder.Services.AddScoped<IPurchaseReturnRepository, PurchaseReturnRepository>();
builder.Services.AddScoped<IDeliveryTaskRepository, DeliveryTaskRepository>();
builder.Services.AddScoped<IPaymentRepository, PaymentRepository>();
builder.Services.AddScoped<RegionService>();
builder.Services.AddScoped<RouteService>();
@@ -70,6 +82,7 @@ builder.Services.AddScoped<VehicleService>();
builder.Services.AddScoped<StoreService>();
builder.Services.AddScoped<WarehouseService>();
builder.Services.AddScoped<OrderService>();
builder.Services.AddScoped<SaleService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<RoleService>();
builder.Services.AddScoped<AuthService>();
@@ -87,6 +100,11 @@ builder.Services.AddScoped<DriverComplianceConfigService>();
builder.Services.AddScoped<RideService>();
builder.Services.AddScoped<LoadingSheetService>();
builder.Services.AddScoped<SchemeService>();
builder.Services.AddScoped<PoService>();
builder.Services.AddScoped<GrnService>();
builder.Services.AddScoped<PurchaseReturnService>();
builder.Services.AddScoped<DeliveryTaskService>();
builder.Services.AddScoped<PaymentService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
+88 -55
View File
@@ -1,41 +1,59 @@
# 03 — Ride Lifecycle
# 03 - Ride Lifecycle
**Status**: Done
**Depends On**: 02 — Stock Ledger
**Depends On**: 02 - Stock Ledger
---
## Goal
Turn the currently-unused `DeliveryRun` model into a fully working ride — a session that turns a
Turn the currently-unused `DeliveryRun` model into a fully working ride - a session that turns a
vehicle into a temporary warehouse. Covers driver profile management, ride creation, state
transitions, cancellation workflow, and the vehicle breakdown scenario.
---
## Current Business Understanding
- A `Ride` is one operational trip for one vehicle and one driver on one route for now.
- `RideType` distinguishes business behavior:
- `Delivery` ride: used to fulfill existing orders.
- `Sale` ride: used by sales agents in the field to create sales, and every sale creates an
order.
- `Sale` and `Order` are different:
- `Sale` is the commercial transaction created by the sales agent.
- `Order` is the fulfillment document created from every sale.
- Item lines are maintained once in `OrderItem`; `Sale` does not keep a separate item-line table.
- `Sale` statuses use sales language such as `Completed`.
- `Order` statuses use logistics language such as `Fulfilled`.
---
## Data Model Changes
### New Entity — DriverProfile
### New Entity - DriverProfile
Linked one-to-one with `User`. Holds compliance data only — no login or auth fields.
Linked one-to-one with `User`. Holds compliance data only - no login or auth fields.
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| FK_UserId | Guid | FK → User (unique) |
| FK_UserId | Guid | FK -> User (unique) |
| LicenseNumber | string | |
| LicenseExpiry | DateTime | |
| MedicalClearanceExpiry | DateTime | |
| IsActive | bool | Can be suspended without deleting |
| CreatedAt | DateTime | |
No license class tied to vehicle type — not required.
No license class tied to vehicle type - not required.
**Compliance check rule:** Driver is compliant if `IsActive = true`, plus any checks that are enabled in `DriverComplianceConfig`. By default all checks are disabled. When enabled: `LicenseExpiry > today` AND/OR `MedicalClearanceExpiry > today`.
**Compliance check rule:** Driver is compliant if `IsActive = true`, plus any checks that are
enabled in `DriverComplianceConfig`. By default all checks are disabled. When enabled:
`LicenseExpiry > today` and/or `MedicalClearanceExpiry > today`.
---
### Updated Entity — Vehicle
### Updated Entity - Vehicle
Add one nullable field:
@@ -45,7 +63,7 @@ Add one nullable field:
---
### Updated Entity — DeliveryRun (becomes Ride)
### Updated Entity - DeliveryRun (becomes Ride)
Add the following fields to the existing `DeliveryRun`:
@@ -53,34 +71,43 @@ Add the following fields to the existing `DeliveryRun`:
|---|---|---|
| RideType | RideType | Delivery or Sale |
| Status | RideStatus | State machine below |
| FK_DriverProfileId | Guid | FK → DriverProfile |
| FK_DriverProfileId | Guid | FK -> DriverProfile |
| FK_SourceWarehouseId | Guid | Where stock is loaded from. Must be the same warehouse stock returns to on cancel/close. |
| StartedAt | DateTime? | Set when Draft → Open |
| ActivatedAt | DateTime? | Set when Open → Active |
| ClosedAt | DateTime? | Set when Active → Closed |
| StartedAt | DateTime? | Set when Draft -> Open |
| ActivatedAt | DateTime? | Set when Open -> Active |
| ClosedAt | DateTime? | Set when Active -> Closed |
---
### New Enum — RideType
### New Enum - RideType
```csharp
public enum RideType { Delivery, Sale }
```
### New Enum — RideStatus
### New Enum - RideStatus
```csharp
public enum RideStatus { Draft, Open, Active, CancelRequested, Cancelled, Closed }
```
Meaning of statuses:
- `Draft`: ride exists but operational work has not started.
- `Open`: ride is prepared and ready for loading/preparation work.
- `Active`: ride is live in the field.
- `CancelRequested`: active ride is waiting for cancellation approval or rejection.
- `Cancelled`: ride ended through approved cancellation.
- `Closed`: ride ended normally.
---
### New Entity — RideCancelRequest
### New Entity - RideCancelRequest
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| FK_RideId | Guid | FK → DeliveryRun |
| FK_RideId | Guid | FK -> DeliveryRun |
| TypeCode | CancelRequestType | Reason category |
| Reason | string | Free text explanation |
| RequestedBy | Guid | UserId |
@@ -89,7 +116,7 @@ public enum RideStatus { Draft, Open, Active, CancelRequested, Cancelled, Closed
| ApprovedBy | Guid? | UserId of approver |
| ApprovedAt | DateTime? | |
### New Enum — CancelRequestType
### New Enum - CancelRequestType
```csharp
public enum CancelRequestType
@@ -101,7 +128,7 @@ public enum CancelRequestType
}
```
### New Entity — DriverComplianceConfig
### New Entity - DriverComplianceConfig
Single-row configuration table. Controls which compliance checks are enforced at ride open time.
@@ -117,7 +144,7 @@ Managed via a dedicated config endpoint. Only one row ever exists.
---
### New Enum — CancelRequestStatus
### New Enum - CancelRequestStatus
```csharp
public enum CancelRequestStatus { Pending, Approved, Rejected }
@@ -127,21 +154,22 @@ public enum CancelRequestStatus { Pending, Approved, Rejected }
## State Machine
```
Draft → Open → Active → Closed
↘ CancelRequested → Cancelled
```text
Draft -> Open -> Active -> Closed
\-> CancelRequested -> Cancelled
```
| Transition | Trigger | What it does |
|---|---|---|
| Draft → Open | `POST /api/rides/{id}/open` | Validates driver compliance. Checks vehicle has no other Open/Active ride. Sets `StartedAt`. |
| Open → Active | `POST /api/rides/{id}/activate` | Called by loading event confirmation (Step 04). Sets `ActivatedAt`. |
| Active → Closed | `POST /api/rides/{id}/close` | Called after ride reconciliation (Step 07). All stock must be returned to source warehouse. Sets `ClosedAt`. |
| Active → CancelRequested | `POST /api/rides/{id}/cancel-request` | Driver submits cancel request with type code and reason. Ride status changes to `CancelRequested`. |
| CancelRequested → Cancelled | `POST /api/rides/cancel-requests/{id}/approve` | Supervisor approves. Ride status changes to `Cancelled`. All stock must return to source warehouse before cancel completes. |
| CancelRequested → Active | `POST /api/rides/cancel-requests/{id}/reject` | Supervisor rejects. Ride returns to `Active`. |
| Draft -> Open | `POST /api/rides/{id}/open` | Validates driver compliance. Checks vehicle has no other Open/Active ride. Sets `StartedAt`. |
| Open -> Active | `POST /api/rides/{id}/activate` | Called by loading event confirmation (Step 04). Sets `ActivatedAt`. |
| Active -> Closed | `POST /api/rides/{id}/close` | Called after ride reconciliation (Step 07). All stock must be returned to source warehouse. Sets `ClosedAt`. |
| Active -> CancelRequested | `POST /api/rides/{id}/cancel-request` | Driver submits cancel request with type code and reason. Ride status changes to `CancelRequested`. |
| CancelRequested -> Cancelled | `POST /api/rides/cancel-requests/{id}/approve` | Supervisor approves. Ride status changes to `Cancelled`. All stock must return to source warehouse before cancel completes. |
| CancelRequested -> Active | `POST /api/rides/cancel-requests/{id}/reject` | Supervisor rejects. Ride returns to `Active`. |
**Order assignment window:** Orders can be assigned to a Delivery ride from Draft until the ride transitions to Open. No new orders after Open.
**Order assignment window:** Orders can be assigned to a Delivery ride from Draft until the ride
transitions to Open. No new orders after Open.
---
@@ -150,7 +178,7 @@ Draft → Open → Active → Closed
Per vehicle. A vehicle either has `FK_DefaultWarehouseId` set (always loads from that warehouse)
or it is null (warehouse is selected manually at ride creation time).
Loading and unloading warehouse are always the same — stock must return to its source warehouse
Loading and unloading warehouse are always the same - stock must return to its source warehouse
before a ride can close or cancel.
---
@@ -160,12 +188,12 @@ before a ride can close or cancel.
1. Driver submits cancel request with `TypeCode = VehicleBreakdown`.
2. Ride status moves to `CancelRequested`.
3. Supervisor views a breakdown assist endpoint that lists all currently `Active` rides with their
current vehicle stock levels and the stranded ride's open orders — supervisor decides manually
current vehicle stock levels and the stranded ride's open orders. Supervisor decides manually
which ride takes over which orders.
4. Supervisor approves the cancel request.
5. Ride moves to `Cancelled` once all stock is confirmed returned to source warehouse.
The system surfaces the information — it does not automatically reassign orders.
The system surfaces the information - it does not automatically reassign orders.
---
@@ -179,14 +207,14 @@ Base route: `/api/rides`
| GET | `/` | List rides (paginated). Filter by status, type, driverProfileId, vehicleId. |
| GET | `/{id}` | Get ride detail |
| PUT | `/{id}/driver` | Assign or change driver profile |
| POST | `/{id}/open` | Transition Draft → Open |
| POST | `/{id}/activate` | Transition Open → Active (called by loading event) |
| POST | `/{id}/close` | Transition Active → Closed (called by reconciliation) |
| POST | `/{id}/open` | Transition Draft -> Open |
| POST | `/{id}/activate` | Transition Open -> Active (called by loading event) |
| POST | `/{id}/close` | Transition Active -> Closed (called by reconciliation) |
| POST | `/{id}/cancel-request` | Submit cancel request (Active rides only) |
| GET | `/cancel-requests` | List cancel requests (paginated). Filter by status. |
| POST | `/cancel-requests/{id}/approve` | Approve cancel request |
| POST | `/cancel-requests/{id}/reject` | Reject cancel request |
| GET | `/breakdown-assist/{rideId}` | Lists active rides with stock + stranded ride's open orders |
| GET | `/breakdown-assist/{rideId}` | Lists active rides with stock plus stranded ride's open orders |
### DriverComplianceConfig
@@ -215,37 +243,42 @@ Base route: `/api/driver-profiles`
## Business Rules
1. A ride cannot transition Draft → Open if `IsActive = false` on the driver profile. If `EnforceLicenseExpiry = true`, also blocks if license is expired. If `EnforceMedicalClearance = true`, also blocks if medical clearance is expired.
1. A ride cannot transition Draft -> Open if `IsActive = false` on the driver profile. If
`EnforceLicenseExpiry = true`, also block if license is expired. If
`EnforceMedicalClearance = true`, also block if medical clearance is expired.
2. A vehicle cannot be on more than one Open or Active ride at a time.
3. Orders can only be assigned to a Delivery ride while it is in Draft status.
4. Loading and unloading warehouse must be the same — enforced at close/cancel.
4. Loading and unloading warehouse must be the same - enforced at close/cancel.
5. A CancelRequested ride cannot close or activate until the request is resolved.
6. All cancel requests must have a TypeCode — free text reason alone is not sufficient.
6. All cancel requests must have a TypeCode - free text reason alone is not sufficient.
7. All list endpoints return paginated responses.
8. A ride uses a single route for now.
---
## Open Questions
1. **Cancel request type codes** — ✅ Resolved. `VehicleBreakdown`, `DriverUnavailable`, `RouteIssue`, `Other`.
2. **Breakdown assist endpoint** — ✅ Resolved. System shows info only, supervisor decides manually. No auto-reassignment.
3. **Driver compliance** — ✅ Resolved. Checks are configurable via `DriverComplianceConfig`. `IsActive` is always enforced. License and medical checks are optional flags, default off.
1. Cancel request type codes - resolved. `VehicleBreakdown`, `DriverUnavailable`, `RouteIssue`,
`Other`.
2. Breakdown assist endpoint - resolved. System shows info only, supervisor decides manually. No
auto-reassignment.
3. Driver compliance - resolved. Checks are configurable via `DriverComplianceConfig`. `IsActive`
is always enforced. License and medical checks are optional flags, default off.
4. Multi-route ride assignment - deferred for now. Current model stays single-route.
---
## Implementation Notes
**Build order:**
1. ✅ Enums — `RideType`, `RideStatus`, `CancelRequestType`, `CancelRequestStatus` → added to `Models/Enums.cs`
2. ✅ Entities — `DriverProfile`, `DriverComplianceConfig`, `RideCancelRequest` (new); updated `Vehicle` (+`FK_DefaultWarehouseId`), updated `DeliveryRun` (+`RideType`, `Status`, `FK_DriverProfileId`, `FK_SourceWarehouseId`, `StartedAt`, `ActivatedAt`, `ClosedAt`)
3. ✅ `AppDBContext` — new DbSets + Fluent config
4. ✅ Migrations — `RideLifecycle` (schema), `RidePermissions` (10 new permissions seeded across all roles)
5. ✅ DTOs — `DriverProfileDTOs.cs`, `DriverComplianceConfigDTOs.cs`, `RideDTOs.cs`
6. ✅ Repositories — `IDriverProfileRepository`, `IDriverComplianceConfigRepository`, `IRideRepository`, `IRideCancelRequestRepository`
7. ✅ Services — `DriverProfileService`, `DriverComplianceConfigService`, `RideService`
8. ✅ Controllers — `DriverProfileController`, `DriverComplianceConfigController`, `RideController`
9. ✅ DI registration in `Program.cs`
1. Enums - `RideType`, `RideStatus`, `CancelRequestType`, `CancelRequestStatus`
2. Entities - `DriverProfile`, `DriverComplianceConfig`, `RideCancelRequest`
3. `AppDBContext` - new DbSets plus Fluent config
4. Migrations - ride lifecycle schema and permissions
5. DTOs - driver profile, compliance config, ride
6. Repositories - ride and ride cancel request support
7. Services - driver profile, compliance config, ride
8. Controllers - driver profile, compliance config, ride
9. DI registration in `Program.cs`
**Feature folders:** `Features/Ride/`, `Features/DriverProfile/`, `Features/DriverComplianceConfig/`
**Pending:** Tests for the three new services.
+57 -76
View File
@@ -1,7 +1,7 @@
# 04 — Loading Event
# 04 - Loading Event
**Status**: Planning
**Depends On**: 03 — Ride Lifecycle
**Depends On**: 03 - Ride Lifecycle
---
@@ -9,32 +9,36 @@
Move stock from a source warehouse onto an opened ride's vehicle, with two-party confirmation.
When loading is confirmed, the vehicle's stock ledger is credited and the ride transitions
`Open → Active` automatically. This is the first place the stock movement model gets exercised
end-to-end.
`Open -> Active` automatically.
This loading flow serves both ride types:
- `Delivery` rides load stock to fulfill orders already linked to the ride.
- `Sale` rides load stock so the sales agent can sell in the field and create sale-linked orders.
---
## Background / Context
A vehicle is just a location with `LocationType.Vehicle` in the stock ledger — the same math as
a warehouse. When loading is confirmed, two stock movements are created per item:
- **Debit** source warehouse: `-qty` at `LocationType.Warehouse`
- **Credit** vehicle: `+qty` at `LocationType.Vehicle`
A vehicle is just a location with `LocationType.Vehicle` in the stock ledger - the same math as a
warehouse. When loading is confirmed, two stock movements are created per item:
From this point the vehicle carries live inventory that decreases with every sale or delivery
(Steps 05/06) and must return to zero before the ride can close (Step 07).
- Debit source warehouse: `-qty` at `LocationType.Warehouse`
- Credit vehicle: `+qty` at `LocationType.Vehicle`
From this point the vehicle carries live inventory that decreases with every sale or delivery and
must return to zero before the ride can close.
---
## Data Model Changes
### New Entity — LoadingSheet
### New Entity - LoadingSheet
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| FK_RideId | Guid | FK → DeliveryRun |
| FK_SourceWarehouseId | Guid | FK → Warehouse |
| FK_RideId | Guid | FK -> DeliveryRun |
| FK_SourceWarehouseId | Guid | FK -> Warehouse |
| Status | LoadingSheetStatus | State machine below |
| DispatchConfirmedAt | DateTime? | Set when warehouse confirms dispatch |
| DispatchConfirmedBy | Guid? | UserId of warehouse manager |
@@ -42,18 +46,18 @@ From this point the vehicle carries live inventory that decreases with every sal
| ReceiptConfirmedBy | Guid? | UserId of driver |
| CreatedAt | DateTime | |
### New Entity — LoadingSheetItem
### New Entity - LoadingSheetItem
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| FK_LoadingSheetId | Guid | FK → LoadingSheet |
| FK_ItemId | Guid | FK → Item |
| FK_LoadingSheetId | Guid | FK -> LoadingSheet |
| FK_ItemId | Guid | FK -> Item |
| QuantityDispatched | decimal | Set by warehouse manager at confirm-dispatch |
| QuantityReceived | decimal? | Set by driver before confirm-receipt |
| QuantityResolved | decimal? | Set by supervisor when resolving a discrepancy |
### New Enum — LoadingSheetStatus
### New Enum - LoadingSheetStatus
```csharp
public enum LoadingSheetStatus
@@ -66,23 +70,23 @@ public enum LoadingSheetStatus
}
```
No changes to existing entities. Vehicle capacity check is deferred to Step 13 (Fleet Management).
No changes to existing entities. Vehicle capacity check is deferred to Step 13.
---
## State Machine
```
Pending → DispatchConfirmed → ReceiptConfirmed (all lines match)
↘ DiscrepancyFlagged → Resolved
```text
Pending -> DispatchConfirmed -> ReceiptConfirmed
\-> DiscrepancyFlagged -> Resolved
```
| Transition | Trigger | What it does |
|---|---|---|
| Pending → DispatchConfirmed | `POST /{id}/confirm-dispatch` | Warehouse manager locks in `QuantityDispatched` per line. Ride stays `Open`. |
| DispatchConfirmed → ReceiptConfirmed | `POST /{id}/confirm-receipt` | Driver's received quantities all match dispatched. Creates stock movements. Auto-triggers ride `Open → Active`. |
| DispatchConfirmed → DiscrepancyFlagged | `POST /{id}/confirm-receipt` | Any line mismatch. No stock movements created. Ride stays `Open`. |
| DiscrepancyFlagged → Resolved | `POST /{id}/resolve` | Supervisor enters `QuantityResolved` per line. Creates stock movements using resolved quantities. Auto-triggers ride `Open → Active`. |
| Pending -> DispatchConfirmed | `POST /{id}/confirm-dispatch` | Warehouse manager locks in `QuantityDispatched` per line. Ride stays `Open`. |
| DispatchConfirmed -> ReceiptConfirmed | `POST /{id}/confirm-receipt` | Driver's received quantities all match dispatched. Creates stock movements. Auto-triggers ride `Open -> Active`. |
| DispatchConfirmed -> DiscrepancyFlagged | `POST /{id}/confirm-receipt` | Any line mismatch. No stock movements created. Ride stays `Open`. |
| DiscrepancyFlagged -> Resolved | `POST /{id}/resolve` | Supervisor enters `QuantityResolved` per line. Creates stock movements using resolved quantities. Auto-triggers ride `Open -> Active`. |
---
@@ -90,10 +94,11 @@ Pending → DispatchConfirmed → ReceiptConfirmed (all lines match)
| Ride Type | How items are added to the sheet |
|---|---|
| Delivery | Auto-populated on sheet creation from the aggregate of all order lines assigned to the ride |
| Sale | Manually entered by warehouse manager via `PUT /{id}/items` (no orders to derive from) |
| Delivery | Auto-populated on sheet creation from the aggregate of all order lines assigned to the ride's route |
| Sale | Manually entered by warehouse manager via `PUT /{id}/items` based on expected field selling demand |
For Delivery rides the warehouse manager reviews the pre-populated quantities and may adjust before confirming dispatch. `PUT /{id}/items` is still available to override if needed.
For Delivery rides the warehouse manager reviews the pre-populated quantities and may adjust before
confirming dispatch. `PUT /{id}/items` is still available to override if needed.
---
@@ -101,12 +106,13 @@ For Delivery rides the warehouse manager reviews the pre-populated quantities an
Created at `ReceiptConfirmed` or `Resolved`. One pair per line item:
- **Debit** warehouse: `-QuantityFinal` at `LocationType.Warehouse`, `FK_LocationId = SourceWarehouseId`
- **Credit** vehicle: `+QuantityFinal` at `LocationType.Vehicle`, `FK_LocationId = VehicleId`
- Debit warehouse: `-QuantityFinal` at `LocationType.Warehouse`, `FK_LocationId = SourceWarehouseId`
- Credit vehicle: `+QuantityFinal` at `LocationType.Vehicle`, `FK_LocationId = VehicleId`
`QuantityFinal` = `QuantityReceived` on a clean confirmation, or `QuantityResolved` when a discrepancy was resolved.
`QuantityFinal` = `QuantityReceived` on a clean confirmation, or `QuantityResolved` when a
discrepancy was resolved.
Movement `Type` = `Load` (new `StockMovementType` value to add to enums).
Movement `Type` = `Load`.
---
@@ -119,23 +125,25 @@ Base route: `/api/loading-sheets`
| POST | `/` | Warehouse mgr | Create sheet for an Open ride. Delivery rides auto-populate items from orders. |
| GET | `/` | Any | List sheets. Filter by `rideId`, `status`. Paginated. |
| GET | `/{id}` | Any | Get sheet detail with all line items |
| PUT | `/{id}/items` | Warehouse mgr | Set or override item quantities (required for Sale rides, optional for Delivery) |
| POST | `/{id}/confirm-dispatch` | Warehouse mgr | Pending → DispatchConfirmed |
| PUT | `/{id}/items` | Warehouse mgr | Set or override item quantities |
| POST | `/{id}/confirm-dispatch` | Warehouse mgr | Pending -> DispatchConfirmed |
| PUT | `/{id}/receipt-items` | Driver | Set `QuantityReceived` per line |
| POST | `/{id}/confirm-receipt` | Driver | → ReceiptConfirmed or DiscrepancyFlagged |
| POST | `/{id}/resolve` | Supervisor | Enter `QuantityResolved` per line → Resolved + ride activation |
| POST | `/{id}/confirm-receipt` | Driver | -> ReceiptConfirmed or DiscrepancyFlagged |
| POST | `/{id}/resolve` | Supervisor | Enter `QuantityResolved` per line -> Resolved plus ride activation |
---
## Business Rules
1. A loading sheet can only be created for a ride in `Open` status.
2. A ride can only have one active loading sheet (not `Resolved` or `ReceiptConfirmed`) at a time.
3. `QuantityDispatched != QuantityReceived` on any line → `DiscrepancyFlagged`. No partial activation.
4. Stock movements are created only once — at `ReceiptConfirmed` or `Resolved`. Never on dispatch.
5. Discrepancies must be resolved per line item with an explicit `QuantityResolved` value. No bulk "accept driver" or "accept warehouse" shortcut.
2. A ride can only have one active loading sheet at a time.
3. `QuantityDispatched != QuantityReceived` on any line -> `DiscrepancyFlagged`. No partial activation.
4. Stock movements are created only once - at `ReceiptConfirmed` or `Resolved`. Never on dispatch.
5. Discrepancies must be resolved per line item with an explicit `QuantityResolved` value.
6. All list endpoints return paginated responses.
7. Vehicle capacity check is not enforced in this step — deferred to Step 13.
7. Vehicle capacity check is not enforced in this step.
8. Immediate handover from a Sale ride does not remove the need for an `Order`; every sale still
creates an order, and that order is fulfilled immediately when stock is handed over at sale time.
---
@@ -145,54 +153,27 @@ Base route: `/api/loading-sheets`
|---|---|
| `loading.view` | All roles |
| `loading.dispatch` | Warehouse manager and above |
| `loading.receive` | Driver (AreaManager and above for testing) |
| `loading.receive` | Driver |
| `loading.resolve` | Supervisor and above |
---
## Fraud / Control Considerations
- Discrepancies are immediately visible and block activation — they cannot be silently absorbed.
- `QuantityResolved` is permanently stored per line for audit purposes, even after the sheet is resolved.
- See [09_fraud_controls.md](./09_fraud_controls.md) for the audit trail layer that will sit on top of this.
- Discrepancies are immediately visible and block activation.
- `QuantityResolved` is permanently stored per line for audit purposes.
---
## Open Questions
- ✅ Vehicle capacity check — deferred to Step 13 (Fleet Management).
- ✅ Discrepancy resolution — supervisor enters `QuantityResolved` per line item.
- ✅ Ride activation trigger — `confirm-receipt` and `resolve` auto-trigger `Open → Active` internally.
- ✅ Item population — Delivery rides auto-populated from orders; Sale rides manually entered.
1. Vehicle capacity check - deferred to Step 13.
2. Discrepancy resolution - supervisor enters `QuantityResolved` per line item.
3. Ride activation trigger - `confirm-receipt` and `resolve` auto-trigger `Open -> Active`.
4. Delivery item population - from assigned orders on the ride's route.
---
## Implementation Notes
**Build order:**
1. Enum — add `LoadingSheetStatus` to `Models/Enums.cs`; add `Load` to `StockMovementType`
2. Entities — `LoadingSheet`, `LoadingSheetItem` (new)
3. `AppDBContext` — new DbSets + Fluent config
4. Migration — `LoadingEvent` (schema) + `LoadingPermissions` (4 new permissions seeded)
5. DTOs — `LoadingSheetDTOs.cs`
6. Repository — `ILoadingSheetRepository`, `LoadingSheetRepository`
7. Service — `LoadingSheetService`
8. Controller — `LoadingSheetController`
9. DI registration in `Program.cs`
**Feature folder:** `Features/LoadingEvent/`
**What was built:**
1. ✅ Enums — added `LoadingSheetStatus` and `Load` to `MovementType` in `Models/Enums.cs`
2. ✅ Entities — `LoadingSheet`, `LoadingSheetItem` created in `Models/`
3. ✅ `AppDBContext` — `LoadingSheets`, `LoadingSheetItems` DbSets added; Fluent config for FK restrictions and ride/warehouse relationships
4. ✅ DTOs — `LoadingSheetDTOs.cs` in `Features/LoadingEvent/`
5. ✅ Repository — `ILoadingSheetRepository`, `LoadingSheetRepository` in `Features/LoadingEvent/`
6. ✅ Service — `LoadingSheetService` in `Features/LoadingEvent/`
7. ✅ Controller — `LoadingSheetController` in `Features/LoadingEvent/`
8. ✅ DI registration in `Program.cs`
9. ⚠️ Permissions added to seed (`loading.view`, `loading.dispatch`, `loading.receive`, `loading.resolve`) — **authorization layer is being replaced, skip this**
**Pending (run manually):**
- `dotnet ef migrations add LoadingEvent` — blocked by a pre-existing duplicate Permission seed ID in `AppDBContextModelSnapshot.cs` (ID `20000000-0000-0000-0000-000000000040` is assigned to both `categories.delete` and `driver_profiles.create`). This is a snapshot consistency issue from a previous session and needs to be resolved before migration generation will succeed.
- `dotnet ef database update` after migration is generated.
Build after the ride model and before full delivery/sale execution.
+107 -38
View File
@@ -1,79 +1,148 @@
# 05 — Delivery Ride Execution
**Status**: Not Started
**Status**: Done
**Depends On**: 04 — Loading Event
---
## Goal
Execute pre-confirmed orders against a loaded, active delivery ride. Covers the order-status half
of [mainPlan.md §1](./mainPlan.md#1-order-management):
`updateOrderStatus`, `convertOrderToInvoice`, `createBackorder`. Requires `Order.Status` to exist
first — see gap #7 in [00_existing_gaps.md](./00_existing_gaps.md).
Fulfill pending orders against an active Delivery ride. The driver visits each store on the route,
delivers the ordered items, gets customer confirmation, and the system creates the stock movement,
updates the order, and completes the sale.
---
## Background / Context
Today `Order` has no status and no link to a ride. This step is where the order lifecycle
(`pending → approved → picked → dispatched → delivered → invoiced`) actually gets driven, once the
order has been picked/loaded ([04_loading_event.md](./04_loading_event.md)).
- A Delivery ride goes `Active` after the loading event is confirmed.
- Orders are linked to a ride implicitly through route: `Ride → Route → Stores → Pending Orders`.
- `QuantityFulfilled` on `OrderItem` tracks what was actually delivered (added in pre-planning).
- No separate backorder entity — partially fulfilled orders stay `PartiallyFulfilled` and get picked
up by the next delivery ride on the same route.
- Customer confirmation in v1 is a boolean flag (`CustomerConfirmed`). Full signature/OTP deferred
to Step 09 (Fraud Controls).
---
## Data Model Changes
### Delivery Task (per order on ride)
### New Entity — DeliveryTask
`RideId`, `OrderId`, `Sequence` (visit order), `Status` (`Pending`, `Delivered`, `PartiallyDelivered`,
`Failed`).
Extends `Template` (adds `IsActive`, `CreatedAt` — consistent with all other entities).
### Delivery Outcome
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK (from Template) |
| FK_RideId | Guid | FK → DeliveryRun |
| FK_OrderId | Guid | FK → Order |
| Sequence | int | Visit order on the route |
| Status | DeliveryTaskStatus | State machine below |
| CustomerConfirmed | bool | Set to true at confirm time |
| ConfirmedAt | DateTime? | Timestamp of customer confirmation |
| Note | string? | Driver note on failure or partial delivery |
| IsActive | bool | From Template (soft delete) |
| CreatedAt | DateTime | From Template |
Per delivery task: quantities actually delivered per line, vs. ordered — feeds `createBackorder`
for the shortfall.
### New Enum — DeliveryTaskStatus
### Customer Confirmation
```csharp
public enum DeliveryTaskStatus
{
Pending,
Delivered,
PartiallyDelivered,
Failed
}
```
Signature or OTP captured against the delivery task before it can be marked complete.
### No new columns on OrderItem
`QuantityFulfilled` already added in pre-planning. No further model changes needed.
---
## State Machine
```
Pending → Delivered (all lines fully delivered + customer confirmed)
Pending → PartiallyDelivered (some lines short + customer confirmed)
Pending → Failed (driver could not deliver, no stock movement)
```
---
## Stock Movements
Created on `Delivered` or `PartiallyDelivered` after customer confirmation. One movement per item:
- **Debit** vehicle: `-QuantityFulfilled` at `LocationType.Vehicle`, `FK_LocationId = VehicleId`
- `VehicleId` is resolved from `DeliveryTask.FK_RideId → DeliveryRun.FK_VehicleId`
- `MovementType.Sale`
- `ReferenceId = OrderId`
No movement on `Failed`.
---
## Order and Sale Status Transitions (triggered internally)
| Condition | Order | Sale |
|---|---|---|
| All OrderItem lines: QuantityFulfilled == Quantity | → Fulfilled | → Completed |
| Any OrderItem line: QuantityFulfilled < Quantity | → PartiallyFulfilled | stays Pending |
| Task Failed | stays Pending | stays Pending |
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `updateOrderStatus(orderId, status)` (§1) | `PUT /api/rides/{rideId}/tasks/{orderId}/status` |
| `convertOrderToInvoice(orderId)` (§1) | Triggered automatically on `Delivered`/`PartiallyDelivered` — see [19_finance_accounting.md](./19_finance_accounting.md) |
| `createBackorder(orderId, unfulfilledItems[])` (§1) | Triggered automatically on `PartiallyDelivered` |
| — | `POST /api/rides/{rideId}/tasks/{orderId}/confirm` — captures customer signature/OTP |
Base routes: `/api/rides/{rideId}/tasks` and `/api/delivery-tasks`
| Method | Route | Who | Description |
|---|---|---|---|
| POST | `/api/rides/{rideId}/tasks/generate` | Driver/System | Auto-create tasks from all Pending/PartiallyFulfilled orders for stores on ride's route |
| GET | `/api/rides/{rideId}/tasks` | Driver | List all tasks for this ride with current status |
| GET | `/api/delivery-tasks/{id}` | Driver | Task detail with order items and quantities |
| PUT | `/api/delivery-tasks/{id}/quantities` | Driver | Set QuantityFulfilled per order item before confirming |
| POST | `/api/delivery-tasks/{id}/confirm` | Driver | Customer confirmed — creates stock movements, transitions order/sale |
| POST | `/api/delivery-tasks/{id}/fail` | Driver | Mark task as failed with a note |
---
## Business Rules
- A delivery task cannot be marked `Delivered` without a customer confirmation.
- `PartiallyDelivered` always creates a `createBackorder` entry and a credit note for the
undelivered quantity (see [18_returns_claims.md](./18_returns_claims.md) for credit note shape).
- Each confirmed delivery task creates a stock movement (`Sale`/consumption) against the vehicle
location per [02_stock_ledger.md](./02_stock_ledger.md).
1. Delivery tasks can only be created for a ride in `Active` status and of type `Delivery`.
2. One task per order per ride — cannot create a second task for the same order on the same ride.
3. `QuantityFulfilled` must be set on all order items before calling confirm.
4. `QuantityFulfilled` cannot exceed `Quantity` on any line.
5. `CustomerConfirmed` must be true before the task can move to `Delivered` or `PartiallyDelivered`.
6. Stock movement is created only once — at confirm. Never on fail.
7. A failed task does not change the order or sale status — the order stays available for the next ride.
8. Partial delivery: the unfulfilled quantity (`Quantity - QuantityFulfilled`) remains on the same
order. The next delivery ride on the same route will pick it up automatically via `generate`.
---
## Fraud / Control Considerations
## Implementation Build Order
- OTP/signature capture must be tamper-evident (timestamped, tied to the delivery task, not
editable after submission).
1. Enum — add `DeliveryTaskStatus` to `Models/Enums.cs`
2. Entity — `DeliveryTask` in `Models/`
3. `AppDBContext` — new DbSet + Fluent config (Restrict on Ride, Order FKs)
4. Migration
5. DTOs — `DeliveryTaskDTOs.cs` in `Features/DeliveryRide/`
6. Repository — `IDeliveryTaskRepository`, `DeliveryTaskRepository`
7. Service — `DeliveryTaskService`
8. Controller — `DeliveryTaskController`
9. DI registration in `Program.cs`
**Feature folder:** `Features/DeliveryRide/`
---
## Open Questions
## Open Questions — Resolved
- Signature capture (image) or OTP (SMS code) — one, or both, in v1?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
- ✅ Customer confirmation — boolean flag in v1, full signature/OTP in Step 09
- ✅ Backorder — no separate entity; same order stays PartiallyFulfilled for next ride
- ✅ Task generation — on-demand via `generate` endpoint, not automatic on ride activation
- ✅ Failed task — order stays Pending, available for next ride, no stock movement
+95 -43
View File
@@ -1,80 +1,132 @@
# 06 — Sale Ride Execution
**Status**: Not Started
**Status**: Done
**Depends On**: 04 — Loading Event
---
## Goal
Support on-the-spot van sales with no pre-order: driver picks items/quantities, an invoice is
generated immediately, and payment is captured at point of sale. Covers `createInvoice`,
`recordPayment` from [mainPlan.md §13](./mainPlan.md#13-finance--accounting),
and reuses `applyPricingAndDiscounts` (§1) plus `evaluateSchemeEligibility`/`applySchemeDiscount`
(§11) if promotions are live by the time this ships.
Allow a sales agent on an active Sale ride to create sales in the field. Every sale creates an
order. Immediate handover deducts vehicle stock and closes the order immediately. Non-immediate
handover leaves the order pending for a future delivery ride.
---
## Background / Context
Distinct from [05_delivery_ride.md](./05_delivery_ride.md): there is no pre-existing `Order` row —
the field invoice *is* the order, created and settled in one step.
- `SaleService.CreateAsync` already exists and handles both `FulfillImmediately` paths.
- What Step 06 adds: vehicle stock validation and deduction on immediate handover, and the
Payment recording endpoint.
- `Sale.PaymentStatus` and the `Payment` model were added in pre-planning.
- Pricing is already resolved via `PriceListService` inside `SaleService`.
---
## Data Model Changes
### Field Invoice
No new entities. Everything needed was added in pre-planning:
`RideId`, `StoreId`, `CreatedByUserId`, `TotalAmount`, `PaymentStatus` (`Paid`, `PartiallyPaid`,
`OnCredit`).
| What | Where | Status |
|---|---|---|
| `Payment` entity | `Models/Payment.cs` | ✅ Done |
| `PaymentStatus` enum | `Models/Enums.cs` | ✅ Done |
| `PaymentMethod` enum | `Models/Enums.cs` | ✅ Done |
| `Sale.PaymentStatus` | `Models/Sale.cs` | ✅ Done |
| `OrderItem.QuantityFulfilled` | `Models/OrderItem.cs` | ✅ Done |
### Field Invoice Line Item
`FieldInvoiceId`, `ItemId`, `Quantity`, `UnitPrice` (subject to the discount-limit rules in
[08_sales_agent_controls.md](./08_sales_agent_controls.md)).
### Payment Record
`FieldInvoiceId`, `Amount`, `Method` (`Cash`, `Credit`), `CollectedAt`.
Migration needed for: `Payment` table, `Sale.PaymentStatus` column, `OrderItem.QuantityFulfilled` column.
---
## API Endpoints
## Changes to Existing SaleService
| Function reference | Endpoint intent |
|---|---|
| `createInvoice(orderId)` (§13) | `POST /api/rides/{rideId}/field-invoices` — orderId is the field invoice itself here |
| `recordPayment(invoiceId, amount, method)` (§13) | `POST /api/field-invoices/{id}/payments` |
| `applyPricingAndDiscounts(orderId)` (§1) | Applied at line-item entry time, within agent discount limits |
`SaleService.CreateAsync` needs two additions when `FulfillImmediately = true` and `RideId` is set:
1. **Stock check** — for each item, verify vehicle has enough stock (`StockBalance` at
`LocationType.Vehicle` with `LocationId = VehicleId`). Reject the whole sale if any item is short.
2. **Stock deduction** — debit vehicle balance per item, create one `StockMovement` per item:
- `MovementType.Sale`
- `FromLocationId = VehicleId`, `FromLocationType = Vehicle`
- `ReferenceId = OrderId`
`VehicleId` is resolved from `Sale.FK_RideId → DeliveryRun.FK_VehicleId`.
**SaleService constructor additions required:**
- `IStockRepository` — for stock balance check and debit
- `IRideRepository` — to look up the ride and resolve `FK_VehicleId`
When `FulfillImmediately = false`, no stock movement — order stays Pending for a delivery ride.
---
## New Feature — Payment
**Feature folder:** `Features/Payment/`
### API Endpoints
| Method | Route | Who | Description |
|---|---|---|---|
| POST | `/api/sales/{id}/payments` | Driver/Agent | Record a payment against a sale |
| GET | `/api/sales/{id}/payments` | Any | List all payments for a sale |
### Payment Creation Logic
On `POST /api/sales/{id}/payments`:
1. Validate sale exists and is not `Cancelled`.
2. Add `Payment` record (`FK_SaleId`, `FK_StoreId` from sale, `Amount`, `Method`, `CollectedAt`, `CollectedBy`).
3. Sum all payments for this sale. Recalculate `Sale.PaymentStatus`:
- `sum == 0` → `Pending`
- `sum < Sale.TotalAmount` → `PartiallyPaid`
- `sum >= Sale.TotalAmount` → `Paid`
4. Update `Sale.PaymentStatus` in the same transaction.
### DTOs
```csharp
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
public record PaymentResponse(Guid Id, Guid SaleId, Guid StoreId, decimal Amount,
PaymentMethod Method, DateTime CollectedAt, Guid CollectedBy);
```
---
## Business Rules
- Driver cannot sell more than what is loaded on the vehicle — every line item is checked against
the vehicle's current stock balance ([02_stock_ledger.md](./02_stock_ledger.md)) before the
invoice is finalized.
- Each confirmed sale line creates a `Sale` stock movement against the vehicle location,
immediately (not batched at ride close).
- Discount applied per line must not exceed the agent's role-level limit (§8).
1. Driver cannot sell more than vehicle stock for immediate handover — entire sale rejected if any
item is short (no partial sale creation).
2. Every sale creates an order, even when all items are handed over immediately.
3. `FulfillImmediately = true`:
- Vehicle stock debited immediately per item.
- Order → `Fulfilled`, Sale → `Completed` at creation time (already in SaleService).
- `QuantityFulfilled = Quantity` on all order items (already in SaleService).
4. `FulfillImmediately = false`:
- No stock movement at sale time.
- Order → `Pending`, Sale → `Pending` at creation time.
5. Payment can be recorded at any time — sale does not need to be `Completed` first.
6. Overpayment is allowed (`sum > TotalAmount`) — `PaymentStatus` stays `Paid`.
7. A `Cancelled` sale cannot receive new payments.
---
## Fraud / Control Considerations
## Implementation Build Order
- Cash collected must reconcile against invoiced totals at ride close — see
[07_ride_reconciliation.md](./07_ride_reconciliation.md).
1. Migration — `Payment` table, `Sale.PaymentStatus`, `OrderItem.QuantityFulfilled`
2. Extend `SaleService.CreateAsync` — stock check + stock movement on `FulfillImmediately`
3. DTOs — `PaymentDTOs.cs` in `Features/Payment/`
4. Repository — `IPaymentRepository`, `PaymentRepository`
5. Service — `PaymentService`
6. Controller — `PaymentController`
7. DI registration in `Program.cs`
**Feature folder:** `Features/Payment/` (new), `Features/Sale/` (extended)
---
## Open Questions
## Open Questions — Resolved
- Are field invoices numbered/sequenced separately from warehouse-issued invoices, or share one
sequence?
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
- ✅ Payment timing — can be recorded at any time, not locked to sale completion
- ✅ Overpayment — allowed, status stays Paid
- ✅ Stock check scope — entire sale rejected if any item is short, no partial creation
- ✅ Non-immediate handover — no stock movement at sale time, order picked up by delivery ride
+367 -28
View File
@@ -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
```
+25 -16
View File
@@ -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)
@@ -42,8 +43,8 @@ They must be resolved before new feature steps begin because later steps depend
| 02 | Stock Ledger | Done | 01 | [02_stock_ledger.md](./02_stock_ledger.md) |
| 03 | Ride Lifecycle | Done | 02 | [03_ride_lifecycle.md](./03_ride_lifecycle.md) |
| 04 | Loading Event | Done | 03 | [04_loading_event.md](./04_loading_event.md) |
| 05 | Delivery Ride Execution | Not Started | 04 | [05_delivery_ride.md](./05_delivery_ride.md) |
| 06 | Sale Ride Execution | Not Started | 04 | [06_sale_ride.md](./06_sale_ride.md) |
| 05 | Delivery Ride Execution | Done | 04 | [05_delivery_ride.md](./05_delivery_ride.md) |
| 06 | Sale Ride Execution | Done | 04 | [06_sale_ride.md](./06_sale_ride.md) |
| 07 | Ride Close & Reconciliation | Not Started | 05, 06 | [07_ride_reconciliation.md](./07_ride_reconciliation.md) |
| 08 | Sales Agent Controls | Not Started | 07 | [08_sales_agent_controls.md](./08_sales_agent_controls.md) |
| 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) |
@@ -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
@@ -302,13 +303,17 @@ Introduce the concept of stock per location. Every warehouse (fixed or vehicle)
of each item. Stock is never edited directly — all changes happen through movement transactions
(load, sale, return, damage, adjustment). The current balance is always the running sum.
This is the single most important architectural decision for the whole system.
For now, the shop is treated as a business destination, not a tracked stock location in this
system. That means warehouse-to-vehicle and vehicle-to-warehouse movements update tracked stock
balances, while vehicle-to-shop handover reduces vehicle stock and updates sale/order fulfillment
state without creating a shop stock balance.
**Gate**: Stock balance for any item at any location can be queried. Every change is a traceable transaction.
### 03 — Ride Lifecycle
A ride is a session that turns a vehicle into a temporary warehouse. It has a state machine:
Draft → Open → Active → Closed. A vehicle can only be on one active ride at a time.
Two ride types exist: Delivery (pre-orders) and Sale (van sales). The ride tracks who is driving,
which route, which vehicle, and when it started and ended.
which route, which vehicle, and when it started and ended. For now, one ride uses one route.
**Gate**: A ride can be created, opened, and closed. A vehicle's stock ledger is created when a ride opens.
### 04 — Loading Event
@@ -323,14 +328,18 @@ On a delivery ride, the driver fulfills pre-confirmed orders. Each order becomes
The driver marks each as delivered, partially delivered, or failed. Partially delivered orders
generate a credit note for the undelivered quantity. Customer confirmation (signature or OTP)
is required to mark a delivery complete. Stock movements are created per delivery event.
Vehicle-to-shop handover reduces vehicle stock only; the shop does not yet exist as a tracked stock
location in the ledger.
**Gate**: A delivery ride can execute all orders on its route and produce a delivery report.
### 06 — Sale Ride Execution
On a sale ride, the driver visits customers and creates invoices on the spot. No pre-orders exist.
The driver selects items, quantities, and prices (within approved discount limits), and the invoice
is generated immediately. Payment is collected (cash or credit) at the point of sale.
The vehicle's stock ledger decreases with each sale. The driver cannot sell more than what is loaded.
**Gate**: A sale ride can generate invoices in the field. Stock on vehicle decreases with each sale.
On a sale ride, the sales agent visits customers and creates sales in the field. Every sale creates
an order. If items are handed over immediately, the order is fulfilled immediately and the sale is
completed immediately. If items are not handed over immediately, the order stays pending for later
fulfillment. Payment is collected based on the final sale flow. The vehicle's stock ledger decreases
only for quantities actually handed over from the vehicle. The shop is not yet a tracked stock
location in the ledger.
**Gate**: A sale ride can create sales in the field, create linked orders, and reduce vehicle stock for immediate handover quantities.
### 07 — Ride Close & Reconciliation
When a ride ends, the system performs a full reconciliation. The equation must balance: