Compare commits

..

12 Commits

Author SHA1 Message Date
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
Kalana B. Thilakarathna ac1b2fbc4b loading event 2026-08-24 11:23:32 +05:30
Kalana B. Thilakarathna f381e7a634 Update docs: revise pending tasks and mark step 12 done
- 03_ride_lifecycle.md: Updated pending tasks note to only mention tests for new services, removing reference to database migrations.
- mainPlan.md: Changed status of "Store Management (Beyond CRUD)" (step 12) from "In Progress" to "Done".
2026-08-22 11:16:38 +05:30
DeepnaPooja 6a0322570b Merge pull request 'Feat/promotions & trade' (#7) from feat/promotions-&-trade into dev
Reviewed-on: #7
2026-08-21 08:51:48 +00:00
61 changed files with 7650 additions and 22227 deletions
+102 -3
View File
@@ -39,6 +39,14 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<Scheme> Schemes { get; set; }
public DbSet<SchemeRedemption> SchemeRedemptions { get; set; }
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
public DbSet<LoadingSheet> LoadingSheets { get; set; }
public DbSet<LoadingSheetItem> LoadingSheetItems { get; set; }
public DbSet<PurchaseOrder> PurchaseOrders { get; set; }
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
public DbSet<GoodsReceipt> GoodsReceipts { get; set; }
public DbSet<GoodsReceiptItem> GoodsReceiptItems { get; set; }
public DbSet<PurchaseReturn> PurchaseReturns { get; set; }
public DbSet<PurchaseReturnItem> PurchaseReturnItems { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -76,10 +84,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.IsUnique();
modelBuilder.Entity<Vehicle>()
.HasOne(v => v.DefaultWarehouse)
.HasOne<Warehouse>()
.WithMany()
.HasForeignKey(v => v.FK_DefaultWarehouseId)
.OnDelete(DeleteBehavior.Restrict);
.HasForeignKey(nameof(Vehicle.DefaultWarehouseId))
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<DeliveryRun>()
.HasOne(r => r.SourceWarehouse)
@@ -139,6 +148,96 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(c => new { c.FK_SchemeId, c.FK_ItemId })
.IsUnique();
modelBuilder.Entity<LoadingSheet>()
.HasOne(s => s.SourceWarehouse)
.WithMany()
.HasForeignKey(s => s.FK_SourceWarehouseId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheet>()
.HasOne(s => s.Ride)
.WithMany()
.HasForeignKey(s => s.FK_RideId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<LoadingSheetItem>()
.HasOne(i => i.Item)
.WithMany()
.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);
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
}
}
@@ -14,78 +14,6 @@ public static class RoleAndPermissionSeed
private static readonly Guid RegionalManagerId = new("10000000-0000-0000-0000-000000000005");
private static readonly Guid RegionalAgencyId = new("10000000-0000-0000-0000-000000000006");
private static readonly (string Code, string Description) [] PermissionDefs =
[
("regions.view", "View regions"),
("regions.create", "Create regions"),
("regions.update", "Update regions"),
("regions.delete", "Delete regions"),
("routes.view", "View routes"),
("routes.create", "Create routes"),
("routes.update", "Update routes"),
("routes.delete", "Delete routes"),
("vehicles.view", "View vehicles"),
("vehicles.create", "Create vehicles"),
("vehicles.update", "Update vehicles"),
("vehicles.delete", "Delete vehicles"),
("stores.view", "View stores"),
("stores.create", "Create stores"),
("stores.update", "Update stores"),
("stores.delete", "Delete stores"),
("stores.kyc.verify", "Verify or reject store KYC documents"),
("stores.credit.update", "Update store credit limit and outstanding balance"),
("warehouses.view", "View warehouses"),
("warehouses.create", "Create warehouses"),
("warehouses.update", "Update warehouses"),
("warehouses.delete", "Delete warehouses"),
("orders.view", "View orders"),
("orders.create", "Create orders"),
("orders.update", "Update orders"),
("orders.delete", "Delete orders"),
("users.view", "View users"),
("users.create", "Create users"),
("users.update", "Update users"),
("users.deactivate", "Deactivate users"),
("users.assign_role", "Assign a role to a user"),
("users.assign_scope", "Assign region/route/store scope to a user"),
("items.view", "View items, brands and UOMs"),
("items.create", "Create items, brands and UOMs"),
("items.update", "Update items, brands and UOMs"),
("items.delete", "Delete items, brands and UOMs"),
("categories.view", "View categories"),
("categories.create", "Create categories and subcategories"),
("categories.update", "Update categories and subcategories"),
("categories.delete", "Delete categories and subcategories"),
("driver_profiles.view", "View driver profiles"),
("driver_profiles.create", "Create driver profiles"),
("driver_profiles.update", "Update driver profiles"),
("driver_profiles.deactivate", "Deactivate driver profiles"),
("compliance.manage", "Manage driver compliance configuration"),
("rides.view", "View rides"),
("rides.create", "Create rides"),
("rides.manage", "Manage ride lifecycle"),
("rides.cancel.request", "Request ride cancellation"),
("rides.cancel.approve", "Approve or reject ride cancellations"),
];
private static readonly Dictionary<string, Guid> PermissionIds = PermissionDefs
.Select((p, i) => (p.Code, Id: DeterministicPermissionId(i)))
.ToDictionary(x => x.Code, x => x.Id);
private static Guid DeterministicPermissionId(int index) =>
new($"20000000-0000-0000-0000-{index + 1:D12}");
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Role>().HasData(
@@ -96,76 +24,5 @@ public static class RoleAndPermissionSeed
new Role { Id = RegionalManagerId, Name = "RegionalManager", Level = 50, CreatedAt = SeedDate, IsActive = true },
new Role { Id = RegionalAgencyId, Name = "RegionalAgency", Level = 60, CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Permission>().HasData(
PermissionDefs.Select(p => new Permission
{
Id = PermissionIds[p.Code],
Code = p.Code,
Description = p.Description,
CreatedAt = SeedDate,
IsActive = true
})
);
var itemViewPerms = new[] { "items.view", "categories.view" };
var itemManagePerms = new[] { "items.view", "items.create", "items.update", "categories.view" };
var itemPerms = new[] { "items.view", "items.create", "items.update", "items.delete", "categories.view", "categories.create", "categories.update", "categories.delete" };
var salesRepPerms = new[] { "orders.view", "orders.create", "orders.update", "stores.view", "routes.view" }.Concat(itemViewPerms).ToArray();
var supervisorPerms = salesRepPerms.Concat(["orders.delete", "stores.create", "stores.update"]).ToArray();
var ridePerms = new[]
{
"driver_profiles.view", "driver_profiles.create", "driver_profiles.update", "driver_profiles.deactivate",
"compliance.manage",
"rides.view", "rides.create", "rides.manage", "rides.cancel.request", "rides.cancel.approve"
};
var areaManagerPerms = supervisorPerms.Concat(["vehicles.view", "warehouses.view"]).Concat(itemManagePerms).Concat(ridePerms).Distinct().ToArray();
var areaSupervisorPerms = areaManagerPerms.Concat(["routes.create", "routes.update"]).Concat(itemPerms).Distinct().ToArray();
var regionalManagerPerms = new[]
{
"orders.view", "orders.create", "orders.update", "orders.delete",
"stores.view", "stores.create", "stores.update", "stores.delete", "stores.kyc.verify", "stores.credit.update",
"routes.view", "routes.create", "routes.update", "routes.delete",
"vehicles.view", "vehicles.create", "vehicles.update", "vehicles.delete",
"warehouses.view", "warehouses.create", "warehouses.update", "warehouses.delete",
"regions.view", "users.view",
"driver_profiles.view", "driver_profiles.create", "driver_profiles.update", "driver_profiles.deactivate",
"compliance.manage",
"rides.view", "rides.create", "rides.manage", "rides.cancel.request", "rides.cancel.approve",
"items.view", "items.create", "items.update", "items.delete",
"categories.view", "categories.create", "categories.update", "categories.delete"
};
var regionalAgencyPerms = PermissionDefs.Select(p => p.Code).ToArray();
var rolePermissionMap = new (Guid RoleId, string[] Codes)[]
{
(SalesRepId, salesRepPerms),
(SupervisorId, supervisorPerms),
(AreaManagerId, areaManagerPerms),
(AreaSupervisorId, areaSupervisorPerms),
(RegionalManagerId, regionalManagerPerms),
(RegionalAgencyId, regionalAgencyPerms),
};
modelBuilder.Entity<RolePermission>().HasData(
rolePermissionMap.SelectMany(rp => rp.Codes.Select(code => new RolePermission
{
Id = DeterministicRolePermissionId(rp.RoleId, PermissionIds[code]),
FK_RoleId = rp.RoleId,
FK_PermissionId = PermissionIds[code],
CreatedAt = SeedDate,
IsActive = true
}))
);
}
private static Guid DeterministicRolePermissionId(Guid roleId, Guid permissionId)
{
using var md5 = System.Security.Cryptography.MD5.Create();
var bytes = md5.ComputeHash(roleId.ToByteArray().Concat(permissionId.ToByteArray()).ToArray());
return new Guid(bytes);
}
}
@@ -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);
}
@@ -0,0 +1,16 @@
using Distribution.Models;
namespace Distribution.Features.LoadingEvent;
public interface ILoadingSheetRepository
{
Task<LoadingSheet?> GetByIdAsync(Guid id);
Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId);
Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize);
Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status);
Task<List<(Guid ItemId, decimal TotalQty)>> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId);
Task AddAsync(LoadingSheet sheet);
void Update(LoadingSheet sheet);
void RemoveItems(IEnumerable<LoadingSheetItem> items);
Task AddItemsAsync(IEnumerable<LoadingSheetItem> items);
}
@@ -0,0 +1,113 @@
using Distribution.Models;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.LoadingEvent;
[ApiController]
[Route("api/loading-sheets")]
[Authorize]
public class LoadingSheetController(LoadingSheetService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize(Policy = "loading.view")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? rideId,
[FromQuery] LoadingSheetStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(rideId, status, page, pageSize);
return Ok(result);
}
[HttpGet("{id:guid}")]
[Authorize(Policy = "loading.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize(Policy = "loading.dispatch")]
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}/items")]
[Authorize(Policy = "loading.dispatch")]
public async Task<IActionResult> SetItems(Guid id, SetItemsRequest request, CancellationToken ct)
{
try
{
var result = await service.SetItemsAsync(id, request, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm-dispatch")]
[Authorize(Policy = "loading.dispatch")]
public async Task<IActionResult> ConfirmDispatch(Guid id, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ConfirmDispatchAsync(id, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}/receipt-items")]
[Authorize(Policy = "loading.receive")]
public async Task<IActionResult> SetReceiptItems(Guid id, SetReceiptItemsRequest request, CancellationToken ct)
{
try
{
var result = await service.SetReceiptItemsAsync(id, request, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/confirm-receipt")]
[Authorize(Policy = "loading.receive")]
public async Task<IActionResult> ConfirmReceipt(Guid id, ConfirmReceiptRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ConfirmReceiptAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/resolve")]
[Authorize(Policy = "loading.resolve")]
public async Task<IActionResult> Resolve(Guid id, ResolveDiscrepancyRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
try
{
var result = await service.ResolveAsync(id, request, userId, ct);
return Ok(result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,37 @@
using Distribution.Models;
namespace Distribution.Features.LoadingEvent;
public record LoadingSheetResponse(
Guid Id,
Guid FK_RideId,
Guid FK_SourceWarehouseId,
string WarehouseName,
LoadingSheetStatus Status,
DateTime? DispatchConfirmedAt,
Guid? DispatchConfirmedBy,
DateTime? ReceiptConfirmedAt,
Guid? ReceiptConfirmedBy,
DateTime CreatedAt,
List<LoadingSheetItemResponse> Items);
public record LoadingSheetItemResponse(
Guid Id,
Guid FK_ItemId,
string ItemName,
decimal QuantityDispatched,
decimal? QuantityReceived,
decimal? QuantityResolved);
public record CreateLoadingSheetRequest(Guid FK_RideId);
public record SetItemsRequest(List<SetItemInput> Items);
public record SetItemInput(Guid FK_ItemId, decimal Quantity);
public record SetReceiptItemsRequest(List<ReceiptItemInput> Items);
public record ReceiptItemInput(Guid FK_ItemId, decimal QuantityReceived);
public record ConfirmReceiptRequest(int Meterstart);
public record ResolveDiscrepancyRequest(List<ResolveItemInput> Items, int Meterstart);
public record ResolveItemInput(Guid FK_ItemId, decimal QuantityResolved);
@@ -0,0 +1,60 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.LoadingEvent;
public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetRepository
{
public async Task<LoadingSheet?> GetByIdAsync(Guid id) =>
await context.LoadingSheets
.Include(s => s.SourceWarehouse)
.Include(s => s.Items).ThenInclude(i => i.Item)
.FirstOrDefaultAsync(s => s.Id == id);
public async Task<LoadingSheet?> GetActiveByRideIdAsync(Guid rideId) =>
await context.LoadingSheets
.FirstOrDefaultAsync(s =>
s.FK_RideId == rideId &&
s.Status != LoadingSheetStatus.ReceiptConfirmed &&
s.Status != LoadingSheetStatus.Resolved);
public async Task<List<LoadingSheet>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize) =>
await context.LoadingSheets
.Include(s => s.SourceWarehouse)
.Where(s =>
(rideId == null || s.FK_RideId == rideId) &&
(status == null || s.Status == status))
.OrderByDescending(s => s.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(Guid? rideId, LoadingSheetStatus? status) =>
await context.LoadingSheets
.CountAsync(s =>
(rideId == null || s.FK_RideId == rideId) &&
(status == null || s.Status == status));
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)
.GroupBy(oi => oi.FK_ItemId)
.Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)oi.Quantity) })
.ToListAsync();
return groups.Select(x => (x.ItemId, x.TotalQty)).ToList();
}
public async Task AddAsync(LoadingSheet sheet) =>
await context.LoadingSheets.AddAsync(sheet);
public void Update(LoadingSheet sheet) =>
context.LoadingSheets.Update(sheet);
public void RemoveItems(IEnumerable<LoadingSheetItem> items) =>
context.LoadingSheetItems.RemoveRange(items);
public async Task AddItemsAsync(IEnumerable<LoadingSheetItem> items) =>
await context.LoadingSheetItems.AddRangeAsync(items);
}
@@ -0,0 +1,284 @@
using Distribution.Features.Ride;
using Distribution.Features.Stock;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.LoadingEvent;
public class LoadingSheetService(
ILoadingSheetRepository repo,
IRideRepository rideRepo,
IStockRepository stockRepo,
IUnitOfWork uow)
{
public async Task<PagedResponse<LoadingSheetResponse>> GetAllAsync(Guid? rideId, LoadingSheetStatus? status, int page, int pageSize)
{
var total = await repo.GetCountAsync(rideId, status);
var items = await repo.GetAllAsync(rideId, status, page, pageSize);
return new PagedResponse<LoadingSheetResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
public async Task<LoadingSheetResponse?> GetByIdAsync(Guid id)
{
var sheet = await repo.GetByIdAsync(id);
return sheet is null ? null : ToResponse(sheet);
}
public async Task<LoadingSheetResponse> CreateAsync(CreateLoadingSheetRequest request, CancellationToken ct = default)
{
var ride = await rideRepo.GetByIdAsync(request.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
if (ride.Status != RideStatus.Open)
throw new InvalidOperationException("A loading sheet can only be created for an Open ride");
var existing = await repo.GetActiveByRideIdAsync(request.FK_RideId);
if (existing is not null)
throw new InvalidOperationException("This ride already has an active loading sheet");
var sheet = new LoadingSheet
{
FK_RideId = ride.Id,
FK_SourceWarehouseId = ride.FK_SourceWarehouseId,
Status = LoadingSheetStatus.Pending
};
if (ride.RideType == RideType.Delivery)
{
var orderItems = await repo.GetAggregatedOrderItemsByRouteIdAsync(ride.FK_RouteId);
foreach (var (itemId, qty) in orderItems)
sheet.Items.Add(new LoadingSheetItem { FK_ItemId = itemId, QuantityDispatched = qty });
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(sheet.Id);
return ToResponse(saved!);
}
public async Task<LoadingSheetResponse> SetItemsAsync(Guid id, SetItemsRequest request, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.Pending)
throw new InvalidOperationException("Items can only be changed while the sheet is Pending");
await uow.BeginAsync(ct);
try
{
repo.RemoveItems(sheet.Items);
await repo.AddItemsAsync(request.Items.Select(i => new LoadingSheetItem
{
FK_LoadingSheetId = sheet.Id,
FK_ItemId = i.FK_ItemId,
QuantityDispatched = i.Quantity
}));
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(sheet.Id);
return ToResponse(saved!);
}
public async Task<LoadingSheetResponse> ConfirmDispatchAsync(Guid id, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.Pending)
throw new InvalidOperationException($"Cannot confirm dispatch for a sheet in {sheet.Status} status");
if (!sheet.Items.Any(i => i.QuantityDispatched > 0))
throw new InvalidOperationException("Sheet must have at least one item with quantity greater than zero");
sheet.Status = LoadingSheetStatus.DispatchConfirmed;
sheet.DispatchConfirmedAt = DateTime.Now;
sheet.DispatchConfirmedBy = userId;
await uow.BeginAsync(ct);
try { repo.Update(sheet); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> SetReceiptItemsAsync(Guid id, SetReceiptItemsRequest request, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
throw new InvalidOperationException("Receipt quantities can only be set after dispatch is confirmed");
foreach (var input in request.Items)
{
var item = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.FK_ItemId)
?? throw new InvalidOperationException($"Item {input.FK_ItemId} is not on this loading sheet");
item.QuantityReceived = input.QuantityReceived;
}
await uow.BeginAsync(ct);
try { repo.Update(sheet); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> ConfirmReceiptAsync(Guid id, ConfirmReceiptRequest request, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
throw new InvalidOperationException($"Cannot confirm receipt for a sheet in {sheet.Status} status");
if (sheet.Items.Any(i => !i.QuantityReceived.HasValue))
throw new InvalidOperationException("All items must have a received quantity before confirming receipt");
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
await uow.BeginAsync(ct);
try
{
if (hasDiscrepancy)
{
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
}
else
{
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList();
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = request.Meterstart;
rideRepo.Update(ride);
}
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
public async Task<LoadingSheetResponse> ResolveAsync(Guid id, ResolveDiscrepancyRequest request, Guid userId, CancellationToken ct = default)
{
var sheet = await repo.GetByIdAsync(id)
?? throw new KeyNotFoundException("Loading sheet not found");
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
foreach (var input in request.Items)
{
var item = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.FK_ItemId)
?? throw new InvalidOperationException($"Item {input.FK_ItemId} is not on this loading sheet");
if (input.QuantityResolved < 0)
throw new InvalidOperationException("Resolved quantity cannot be negative");
item.QuantityResolved = input.QuantityResolved;
}
if (sheet.Items.Any(i => !i.QuantityResolved.HasValue))
throw new InvalidOperationException("All items must have a resolved quantity");
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
?? throw new KeyNotFoundException("Ride not found");
sheet.Status = LoadingSheetStatus.Resolved;
sheet.ReceiptConfirmedAt = DateTime.Now;
sheet.ReceiptConfirmedBy = userId;
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
await uow.BeginAsync(ct);
try
{
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
ride.Status = RideStatus.Active;
ride.ActivatedAt = DateTime.Now;
ride.Meterstart = request.Meterstart;
rideRepo.Update(ride);
repo.Update(sheet);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse(sheet);
}
private async Task ApplyLoadMovementsAsync(LoadingSheet sheet, Guid vehicleId, List<(Guid ItemId, decimal Qty)> lines, Guid userId)
{
foreach (var (itemId, qty) in lines.Where(l => l.Qty > 0))
{
var warehouseBalance = await stockRepo.GetBalanceAsync(itemId, sheet.FK_SourceWarehouseId)
?? throw new InvalidOperationException($"No stock found in warehouse for item {itemId}");
if (warehouseBalance.Quantity < qty)
throw new InvalidOperationException($"Insufficient warehouse stock for item {itemId}");
warehouseBalance.Quantity -= qty;
warehouseBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(warehouseBalance);
var vehicleBalance = await stockRepo.GetBalanceAsync(itemId, vehicleId);
if (vehicleBalance is null)
{
await stockRepo.AddBalanceAsync(new StockBalance
{
FK_ItemId = itemId,
LocationId = vehicleId,
LocationType = LocationType.Vehicle,
Quantity = qty
});
}
else
{
vehicleBalance.Quantity += qty;
vehicleBalance.UpdatedAt = DateTime.Now;
stockRepo.UpdateBalance(vehicleBalance);
}
await stockRepo.AddMovementAsync(new StockMovement
{
FK_ItemId = itemId,
FromLocationId = sheet.FK_SourceWarehouseId,
FromLocationType = LocationType.Warehouse,
ToLocationId = vehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = qty,
Type = MovementType.Load,
ReferenceId = sheet.Id,
CreatedBy = userId
});
}
}
private static LoadingSheetResponse ToResponse(LoadingSheet s) =>
new(s.Id, s.FK_RideId, s.FK_SourceWarehouseId,
s.SourceWarehouse?.Name ?? string.Empty,
s.Status,
s.DispatchConfirmedAt, s.DispatchConfirmedBy,
s.ReceiptConfirmedAt, s.ReceiptConfirmedBy,
s.CreatedAt,
s.Items.Select(i => new LoadingSheetItemResponse(
i.Id, i.FK_ItemId, i.Item?.Name ?? string.Empty,
i.QuantityDispatched, i.QuantityReceived, i.QuantityResolved)).ToList());
}
@@ -1,6 +1,7 @@
using Distribution.Features.Item;
using Distribution.Features.PriceList;
using Distribution.Features.Store;
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -12,6 +13,7 @@ public class OrderService(
IOrderRepository repo,
IStoreRepository storeRepo,
IItemRepository itemRepo,
IStockRepository stockRepo,
PriceListService priceListService,
IUnitOfWork uow,
ICurrentUserService currentUser,
@@ -56,6 +58,19 @@ public class OrderService(
return requestedUnitPrice.Value;
}
/// <summary>
/// checkStockAvailability (mainPlan.md §1): confirms the requested quantity does not exceed
/// total on-hand stock for the item, summed across every warehouse/vehicle location. Does not
/// reserve or decrement stock — actual decrement happens later at loading/delivery time
/// (04_loading_event.md / future 05-06 ride execution), not at order placement.
/// </summary>
private async Task<bool> HasSufficientStockAsync(Guid itemId, decimal quantity)
{
var balances = await stockRepo.GetBalancesByItemAsync(itemId);
var totalAvailable = balances.Sum(b => b.Quantity);
return quantity <= totalAvailable;
}
public async Task<List<OrderResponse>> GetAllAsync()
{
var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList();
@@ -116,6 +131,8 @@ public class OrderService(
var orderItems = new List<Models.OrderItem>();
foreach (var i in request.Items)
{
if (!await HasSufficientStockAsync(i.ItemId, i.Quantity)) return null;
var unitPrice = await ResolveUnitPriceAsync(i.ItemId, i.UnitPrice, store, request.OrderDate);
if (unitPrice is null) return null;
@@ -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!);
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ public class RideService(
var vehicle = await vehicleRepo.GetByIdAsync(request.FK_VehicleId)
?? throw new InvalidOperationException("Vehicle not found");
var sourceWarehouseId = vehicle.FK_DefaultWarehouseId ?? request.FK_SourceWarehouseId;
var sourceWarehouseId = vehicle.DefaultWarehouseId ?? request.FK_SourceWarehouseId;
if (!sourceWarehouseId.HasValue)
throw new InvalidOperationException("Source warehouse is required for this vehicle");
@@ -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
File diff suppressed because it is too large Load Diff
@@ -1,716 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddStoreCreditAndKyc : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000005") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000005") });
migrationBuilder.AddColumn<decimal>(
name: "CreditLimit",
table: "Stores",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<int>(
name: "KycStatus",
table: "Stores",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<decimal>(
name: "OutstandingBalance",
table: "Stores",
type: "decimal(65,30)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<string>(
name: "Segment",
table: "Stores",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "StoreKycDocuments",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_StoreId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
DocumentType = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
FileReference = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
UploadedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_StoreKycDocuments", x => x.Id);
table.ForeignKey(
name: "FK_StoreKycDocuments_Stores_FK_StoreId",
column: x => x.FK_StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000017"),
columns: new[] { "Code", "Description" },
values: new object[] { "stores.kyc.verify", "Verify or reject store KYC documents" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000018"),
columns: new[] { "Code", "Description" },
values: new object[] { "stores.credit.update", "Update store credit limit and outstanding balance" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000019"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.view", "View warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000020"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.create", "Create warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000021"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.update", "Update warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000022"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.delete", "Delete warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000023"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.view", "View orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000024"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.create", "Create orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000025"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.update", "Update orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000026"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.delete", "Delete orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000027"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.view", "View users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000028"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.create", "Create users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000029"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.update", "Update users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000030"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.deactivate", "Deactivate users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000031"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.assign_role", "Assign a role to a user" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000032"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.assign_scope", "Assign region/route/store scope to a user" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000033"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.view", "View items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000034"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.create", "Create items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000035"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.update", "Update items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000036"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.delete", "Delete items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000037"),
columns: new[] { "Code", "Description" },
values: new object[] { "categories.view", "View categories" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000038"),
columns: new[] { "Code", "Description" },
values: new object[] { "categories.create", "Create categories and subcategories" });
migrationBuilder.InsertData(
table: "Permissions",
columns: new[] { "Id", "Code", "CreatedAt", "Description", "IsActive" },
values: new object[,]
{
{ new Guid("20000000-0000-0000-0000-000000000039"), "categories.update", new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), "Update categories and subcategories", true },
{ new Guid("20000000-0000-0000-0000-000000000040"), "categories.delete", new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), "Delete categories and subcategories", true }
});
migrationBuilder.InsertData(
table: "RolePermissions",
columns: new[] { "FK_PermissionId", "FK_RoleId", "CreatedAt", "Id", "IsActive" },
values: new object[,]
{
{ new Guid("20000000-0000-0000-0000-000000000024"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("c0739b84-50a2-0971-429b-02bab8354c51"), true },
{ new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("c268a531-44eb-0f29-82ba-18394ac2dcb5"), true },
{ new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("ddb2d04d-19b0-20b1-2639-b157d12a9eec"), true },
{ new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("320ba3fb-666c-ce57-3e61-95c9824c4048"), true },
{ new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("f2c14d55-d557-5cd2-a70a-4a6f58933436"), true },
{ new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("1b126bb0-e582-3e84-4728-a7ab5628fe7b"), true },
{ new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("f50b201c-6c8d-0df8-85ad-08cb137bc213"), true },
{ new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("0f291826-e169-c06a-0735-bd06cbb2062d"), true },
{ new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("10dd0249-ce04-c5d9-8f94-ccb348c1d979"), true },
{ new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("d92d572f-f1bf-9e7d-1d4d-7c7b0d575c8e"), true },
{ new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("f367f6f6-3d26-7a6c-888b-da1a240b59af"), true },
{ new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("929950da-d51b-ad5d-c4e5-55175908fb43"), true },
{ new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("a9a49749-f99d-3166-5d92-b7e380fcabb2"), true },
{ new Guid("20000000-0000-0000-0000-000000000034"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("b17df3df-022c-c903-6ce0-e6423e648dc8"), true },
{ new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("a41f94ba-c24d-93dc-9f25-c462df19785a"), true },
{ new Guid("20000000-0000-0000-0000-000000000016"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("e0b27435-f505-43f5-7ed9-4711f2248495"), true },
{ new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("5e10d61c-2d7a-3388-722f-45232d2cfb71"), true },
{ new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("d8257363-d838-fe7e-57a5-51b626dbfc83"), true },
{ new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("04ece77e-b36f-8ed0-4ef9-2f6962b6f9c6"), true },
{ new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("bf2a2f96-04ed-905d-5a8a-9047d814aa3e"), true },
{ new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("4d287b19-ba40-7d4d-3d39-291be64197f8"), true },
{ new Guid("20000000-0000-0000-0000-000000000027"), new Guid("10000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("43a4c7b4-a50f-aa37-b147-a30636d9d65f"), true },
{ new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("a66f3854-5a8f-95c2-cb1c-b2b2e67ee226"), true },
{ new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("14bcecd1-b1a2-6da3-155a-233611484467"), true },
{ new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("aeb29e0e-e819-1446-6cc6-1fb64b5ecc6b"), true },
{ new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("e18a6eca-1009-bcc6-35e9-ead943c1649f"), true },
{ new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000006"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("3f32cdac-6925-23f2-064e-8226d781d4a3"), true },
{ new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000006"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("dc3dd9ff-b123-a8d4-dcea-b4f5f8ff171e"), true }
});
migrationBuilder.CreateIndex(
name: "IX_StoreKycDocuments_FK_StoreId",
table: "StoreKycDocuments",
column: "FK_StoreId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "StoreKycDocuments");
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000024"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000001") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000033"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000002") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000034"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000037"), new Guid("10000000-0000-0000-0000-000000000003") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000016"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000018"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000019"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000025"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000004") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000026"), new Guid("10000000-0000-0000-0000-000000000005") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000027"), new Guid("10000000-0000-0000-0000-000000000005") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000005") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000005") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000039"), new Guid("10000000-0000-0000-0000-000000000006") });
migrationBuilder.DeleteData(
table: "RolePermissions",
keyColumns: new[] { "FK_PermissionId", "FK_RoleId" },
keyValues: new object[] { new Guid("20000000-0000-0000-0000-000000000040"), new Guid("10000000-0000-0000-0000-000000000006") });
migrationBuilder.DeleteData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000039"));
migrationBuilder.DeleteData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000040"));
migrationBuilder.DropColumn(
name: "CreditLimit",
table: "Stores");
migrationBuilder.DropColumn(
name: "KycStatus",
table: "Stores");
migrationBuilder.DropColumn(
name: "OutstandingBalance",
table: "Stores");
migrationBuilder.DropColumn(
name: "Segment",
table: "Stores");
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000017"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.view", "View warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000018"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.create", "Create warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000019"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.update", "Update warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000020"),
columns: new[] { "Code", "Description" },
values: new object[] { "warehouses.delete", "Delete warehouses" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000021"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.view", "View orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000022"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.create", "Create orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000023"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.update", "Update orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000024"),
columns: new[] { "Code", "Description" },
values: new object[] { "orders.delete", "Delete orders" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000025"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.view", "View users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000026"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.create", "Create users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000027"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.update", "Update users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000028"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.deactivate", "Deactivate users" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000029"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.assign_role", "Assign a role to a user" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000030"),
columns: new[] { "Code", "Description" },
values: new object[] { "users.assign_scope", "Assign region/route/store scope to a user" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000031"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.view", "View items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000032"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.create", "Create items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000033"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.update", "Update items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000034"),
columns: new[] { "Code", "Description" },
values: new object[] { "items.delete", "Delete items, brands and UOMs" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000035"),
columns: new[] { "Code", "Description" },
values: new object[] { "categories.view", "View categories" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000036"),
columns: new[] { "Code", "Description" },
values: new object[] { "categories.create", "Create categories and subcategories" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000037"),
columns: new[] { "Code", "Description" },
values: new object[] { "categories.update", "Update categories and subcategories" });
migrationBuilder.UpdateData(
table: "Permissions",
keyColumn: "Id",
keyValue: new Guid("20000000-0000-0000-0000-000000000038"),
columns: new[] { "Code", "Description" },
values: new object[] { "categories.delete", "Delete categories and subcategories" });
migrationBuilder.InsertData(
table: "RolePermissions",
columns: new[] { "FK_PermissionId", "FK_RoleId", "CreatedAt", "Id", "IsActive" },
values: new object[,]
{
{ new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("85ef4e27-8643-9c43-71f6-da56f66a5c47"), true },
{ new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("1e353909-d682-58f6-df5c-8e57fa2512c9"), true },
{ new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("7596f703-c6ab-316c-7c80-3f4e3daa174e"), true },
{ new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("aa120c36-488e-34ba-e10a-2ac45e46e119"), true },
{ new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("e4648655-7267-141e-51fa-f7d5fe3d2356"), true },
{ new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("d427b9ea-2c9b-d93e-0843-02dfa1ae8887"), true },
{ new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("b7dee5e3-f329-95a5-75c1-d02c3e40e229"), true },
{ new Guid("20000000-0000-0000-0000-000000000035"), new Guid("10000000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("e5506b30-527d-08f9-6caa-8a45d3d02999"), true },
{ new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("0dd5c943-3ace-0560-6fbc-54d8e37ce014"), true },
{ new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("ca97fb3d-132c-d8a5-4ab8-f7b8fc60f5e5"), true },
{ new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("7780d277-9597-84ff-1a1a-f63fc6b4d3cb"), true },
{ new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("8083c659-9506-0bd9-b793-3d6148ce9192"), true },
{ new Guid("20000000-0000-0000-0000-000000000021"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("5c00e972-a697-d71a-6545-1fd8618826cf"), true },
{ new Guid("20000000-0000-0000-0000-000000000022"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("e9b6af8e-7947-7290-9c92-2d9b6239f395"), true },
{ new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("a701da28-aa75-3c1d-abdb-4317404f8df6"), true },
{ new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("2c025310-765f-7d07-ab9a-50524c13d1d3"), true },
{ new Guid("20000000-0000-0000-0000-000000000031"), new Guid("10000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("69f93a29-1df1-7b20-9d5c-94c264f8c4b8"), true },
{ new Guid("20000000-0000-0000-0000-000000000032"), new Guid("10000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), new Guid("070813f7-4053-2889-0d14-d0d5834208c2"), true }
});
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,257 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class RideLifecycle : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "FK_DefaultWarehouseId",
table: "Vehicles",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.AddColumn<DateTime>(
name: "ActivatedAt",
table: "DeliveryRuns",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "ClosedAt",
table: "DeliveryRuns",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "FK_DriverProfileId",
table: "DeliveryRuns",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci");
migrationBuilder.AddColumn<Guid>(
name: "FK_SourceWarehouseId",
table: "DeliveryRuns",
type: "char(36)",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
collation: "ascii_general_ci");
migrationBuilder.AddColumn<int>(
name: "RideType",
table: "DeliveryRuns",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<DateTime>(
name: "StartedAt",
table: "DeliveryRuns",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Status",
table: "DeliveryRuns",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "DriverComplianceConfigs",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
EnforceLicenseExpiry = table.Column<bool>(type: "tinyint(1)", nullable: false),
EnforceMedicalClearance = table.Column<bool>(type: "tinyint(1)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedBy = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci")
},
constraints: table =>
{
table.PrimaryKey("PK_DriverComplianceConfigs", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "DriverProfiles",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_UserId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
LicenseNumber = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
LicenseExpiry = table.Column<DateTime>(type: "datetime(6)", nullable: false),
MedicalClearanceExpiry = 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_DriverProfiles", x => x.Id);
table.ForeignKey(
name: "FK_DriverProfiles_Users_FK_UserId",
column: x => x.FK_UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "RideCancelRequests",
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"),
TypeCode = table.Column<int>(type: "int", nullable: false),
Reason = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
RequestedBy = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
RequestedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ApprovedBy = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
ApprovedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RideCancelRequests", x => x.Id);
table.ForeignKey(
name: "FK_RideCancelRequests_DeliveryRuns_FK_RideId",
column: x => x.FK_RideId,
principalTable: "DeliveryRuns",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Vehicles_FK_DefaultWarehouseId",
table: "Vehicles",
column: "FK_DefaultWarehouseId");
migrationBuilder.CreateIndex(
name: "IX_DeliveryRuns_FK_DriverProfileId",
table: "DeliveryRuns",
column: "FK_DriverProfileId");
migrationBuilder.CreateIndex(
name: "IX_DeliveryRuns_FK_SourceWarehouseId",
table: "DeliveryRuns",
column: "FK_SourceWarehouseId");
migrationBuilder.CreateIndex(
name: "IX_DriverProfiles_FK_UserId",
table: "DriverProfiles",
column: "FK_UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RideCancelRequests_FK_RideId",
table: "RideCancelRequests",
column: "FK_RideId");
migrationBuilder.AddForeignKey(
name: "FK_DeliveryRuns_DriverProfiles_FK_DriverProfileId",
table: "DeliveryRuns",
column: "FK_DriverProfileId",
principalTable: "DriverProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_DeliveryRuns_Warehouses_FK_SourceWarehouseId",
table: "DeliveryRuns",
column: "FK_SourceWarehouseId",
principalTable: "Warehouses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Vehicles_Warehouses_FK_DefaultWarehouseId",
table: "Vehicles",
column: "FK_DefaultWarehouseId",
principalTable: "Warehouses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_DeliveryRuns_DriverProfiles_FK_DriverProfileId",
table: "DeliveryRuns");
migrationBuilder.DropForeignKey(
name: "FK_DeliveryRuns_Warehouses_FK_SourceWarehouseId",
table: "DeliveryRuns");
migrationBuilder.DropForeignKey(
name: "FK_Vehicles_Warehouses_FK_DefaultWarehouseId",
table: "Vehicles");
migrationBuilder.DropTable(
name: "DriverComplianceConfigs");
migrationBuilder.DropTable(
name: "DriverProfiles");
migrationBuilder.DropTable(
name: "RideCancelRequests");
migrationBuilder.DropIndex(
name: "IX_Vehicles_FK_DefaultWarehouseId",
table: "Vehicles");
migrationBuilder.DropIndex(
name: "IX_DeliveryRuns_FK_DriverProfileId",
table: "DeliveryRuns");
migrationBuilder.DropIndex(
name: "IX_DeliveryRuns_FK_SourceWarehouseId",
table: "DeliveryRuns");
migrationBuilder.DropColumn(
name: "FK_DefaultWarehouseId",
table: "Vehicles");
migrationBuilder.DropColumn(
name: "ActivatedAt",
table: "DeliveryRuns");
migrationBuilder.DropColumn(
name: "ClosedAt",
table: "DeliveryRuns");
migrationBuilder.DropColumn(
name: "FK_DriverProfileId",
table: "DeliveryRuns");
migrationBuilder.DropColumn(
name: "FK_SourceWarehouseId",
table: "DeliveryRuns");
migrationBuilder.DropColumn(
name: "RideType",
table: "DeliveryRuns");
migrationBuilder.DropColumn(
name: "StartedAt",
table: "DeliveryRuns");
migrationBuilder.DropColumn(
name: "Status",
table: "DeliveryRuns");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,23 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class ridelife : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,116 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddPricingAndSuppliers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PriceLists",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Name = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
EffectiveFrom = table.Column<DateTime>(type: "datetime(6)", nullable: false),
EffectiveTo = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PriceLists", x => x.Id);
table.ForeignKey(
name: "FK_PriceLists_Regions_FK_RegionId",
column: x => x.FK_RegionId,
principalTable: "Regions",
principalColumn: "Id");
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Suppliers",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Name = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ContactInfo = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
PerformanceScore = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Suppliers", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "PriceListItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_PriceListId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_ItemId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Price = table.Column<decimal>(type: "decimal(65,30)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PriceListItems", x => x.Id);
table.ForeignKey(
name: "FK_PriceListItems_Items_FK_ItemId",
column: x => x.FK_ItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PriceListItems_PriceLists_FK_PriceListId",
column: x => x.FK_PriceListId,
principalTable: "PriceLists",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_PriceListItems_FK_ItemId",
table: "PriceListItems",
column: "FK_ItemId");
migrationBuilder.CreateIndex(
name: "IX_PriceListItems_FK_PriceListId_FK_ItemId",
table: "PriceListItems",
columns: new[] { "FK_PriceListId", "FK_ItemId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PriceLists_FK_RegionId",
table: "PriceLists",
column: "FK_RegionId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PriceListItems");
migrationBuilder.DropTable(
name: "Suppliers");
migrationBuilder.DropTable(
name: "PriceLists");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,28 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddPriceListItemMinPrice : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "MinPrice",
table: "PriceListItems",
type: "decimal(65,30)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MinPrice",
table: "PriceListItems");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,22 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class RidePermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -1,129 +0,0 @@
using System;
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
[DbContext(typeof(AppDBContext))]
[Migration("20260821120000_AddPromotionsSchemes")]
/// <inheritdoc />
public partial class AddPromotionsSchemes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "CostPrice",
table: "Items",
type: "decimal(65,30)",
nullable: true);
migrationBuilder.CreateTable(
name: "Schemes",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Name = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
RuleType = table.Column<int>(type: "int", nullable: false),
RuleConfig = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
FK_RegionId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
EligibleSegment = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
DateRangeFrom = table.Column<DateTime>(type: "datetime(6)", nullable: false),
DateRangeTo = table.Column<DateTime>(type: "datetime(6)", nullable: false),
AgentLimitExempt = table.Column<bool>(type: "tinyint(1)", nullable: false),
BaselineExpectedSales = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
IsActive = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Schemes", x => x.Id);
table.ForeignKey(
name: "FK_Schemes_Regions_FK_RegionId",
column: x => x.FK_RegionId,
principalTable: "Regions",
principalColumn: "Id");
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "SchemeRedemptions",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_SchemeId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_OrderId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
FieldInvoiceId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
DiscountAmount = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
FK_FreeItemId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
FreeQty = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
FreeItemCostValue = table.Column<decimal>(type: "decimal(65,30)", nullable: true),
RedeemedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SchemeRedemptions", x => x.Id);
table.ForeignKey(
name: "FK_SchemeRedemptions_Schemes_FK_SchemeId",
column: x => x.FK_SchemeId,
principalTable: "Schemes",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SchemeRedemptions_Orders_FK_OrderId",
column: x => x.FK_OrderId,
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SchemeRedemptions_Items_FK_FreeItemId",
column: x => x.FK_FreeItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Schemes_FK_RegionId",
table: "Schemes",
column: "FK_RegionId");
migrationBuilder.CreateIndex(
name: "IX_SchemeRedemptions_FK_SchemeId",
table: "SchemeRedemptions",
column: "FK_SchemeId");
migrationBuilder.CreateIndex(
name: "IX_SchemeRedemptions_FK_OrderId",
table: "SchemeRedemptions",
column: "FK_OrderId");
migrationBuilder.CreateIndex(
name: "IX_SchemeRedemptions_FK_FreeItemId",
table: "SchemeRedemptions",
column: "FK_FreeItemId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SchemeRedemptions");
migrationBuilder.DropTable(
name: "Schemes");
migrationBuilder.DropColumn(
name: "CostPrice",
table: "Items");
}
}
}
@@ -1,170 +0,0 @@
using System;
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
[DbContext(typeof(AppDBContext))]
[Migration("20260821130000_AddSchemeFreeGoodsRuleAndBundleComponents")]
/// <inheritdoc />
public partial class AddSchemeFreeGoodsRuleAndBundleComponents : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "RuleConfig",
table: "Schemes",
type: "longtext",
nullable: true,
oldClrType: typeof(string),
oldType: "longtext")
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<Guid>(
name: "FK_TriggerItemId",
table: "Schemes",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.AddColumn<int>(
name: "TriggerQty",
table: "Schemes",
type: "int",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "FK_FreeItemId",
table: "Schemes",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.AddColumn<decimal>(
name: "FreeQty",
table: "Schemes",
type: "decimal(65,30)",
nullable: true);
migrationBuilder.CreateTable(
name: "SchemeBundleComponents",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_SchemeId = 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"),
Qty = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SchemeBundleComponents", x => x.Id);
table.ForeignKey(
name: "FK_SchemeBundleComponents_Schemes_FK_SchemeId",
column: x => x.FK_SchemeId,
principalTable: "Schemes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SchemeBundleComponents_Items_FK_ItemId",
column: x => x.FK_ItemId,
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Schemes_FK_TriggerItemId",
table: "Schemes",
column: "FK_TriggerItemId");
migrationBuilder.CreateIndex(
name: "IX_Schemes_FK_FreeItemId",
table: "Schemes",
column: "FK_FreeItemId");
migrationBuilder.CreateIndex(
name: "IX_SchemeBundleComponents_FK_SchemeId_FK_ItemId",
table: "SchemeBundleComponents",
columns: new[] { "FK_SchemeId", "FK_ItemId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SchemeBundleComponents_FK_ItemId",
table: "SchemeBundleComponents",
column: "FK_ItemId");
migrationBuilder.AddForeignKey(
name: "FK_Schemes_Items_FK_TriggerItemId",
table: "Schemes",
column: "FK_TriggerItemId",
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Schemes_Items_FK_FreeItemId",
table: "Schemes",
column: "FK_FreeItemId",
principalTable: "Items",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SchemeBundleComponents");
migrationBuilder.DropForeignKey(
name: "FK_Schemes_Items_FK_TriggerItemId",
table: "Schemes");
migrationBuilder.DropForeignKey(
name: "FK_Schemes_Items_FK_FreeItemId",
table: "Schemes");
migrationBuilder.DropIndex(
name: "IX_Schemes_FK_TriggerItemId",
table: "Schemes");
migrationBuilder.DropIndex(
name: "IX_Schemes_FK_FreeItemId",
table: "Schemes");
migrationBuilder.DropColumn(
name: "FK_TriggerItemId",
table: "Schemes");
migrationBuilder.DropColumn(
name: "TriggerQty",
table: "Schemes");
migrationBuilder.DropColumn(
name: "FK_FreeItemId",
table: "Schemes");
migrationBuilder.DropColumn(
name: "FreeQty",
table: "Schemes");
migrationBuilder.AlterColumn<string>(
name: "RuleConfig",
table: "Schemes",
type: "longtext",
nullable: false,
oldClrType: typeof(string),
oldType: "longtext",
oldNullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
.OldAnnotation("MySql:CharSet", "utf8mb4");
}
}
}
File diff suppressed because it is too large Load Diff
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
+15 -1
View File
@@ -18,7 +18,19 @@ namespace Distribution.Models
CustomerReturn,
Transfer,
Damage,
Adjustment
Adjustment,
Load,
PurchaseReceipt,
PurchaseReturn
}
public enum LoadingSheetStatus
{
Pending,
DispatchConfirmed,
ReceiptConfirmed,
DiscrepancyFlagged,
Resolved
}
public enum RideType { Delivery, Sale }
@@ -28,4 +40,6 @@ namespace Distribution.Models
public enum CancelRequestType { VehicleBreakdown, DriverUnavailable, RouteIssue, Other }
public enum CancelRequestStatus { Pending, Approved, Rejected }
public enum PurchaseOrderStatus { Issued, PartiallyReceived, Received, Closed, Cancelled }
}
+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;
}
}
+24
View File
@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class LoadingSheet : Template
{
public Guid FK_RideId { get; set; }
[ForeignKey("FK_RideId")]
public DeliveryRun Ride { get; set; }
public Guid FK_SourceWarehouseId { get; set; }
[ForeignKey("FK_SourceWarehouseId")]
public Warehouse SourceWarehouse { get; set; }
public LoadingSheetStatus Status { get; set; } = LoadingSheetStatus.Pending;
public DateTime? DispatchConfirmedAt { get; set; }
public Guid? DispatchConfirmedBy { get; set; }
public DateTime? ReceiptConfirmedAt { get; set; }
public Guid? ReceiptConfirmedBy { get; set; }
public List<LoadingSheetItem> Items { get; set; } = [];
}
}
+21
View File
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class LoadingSheetItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid FK_LoadingSheetId { get; set; }
[ForeignKey("FK_LoadingSheetId")]
public LoadingSheet LoadingSheet { get; set; }
public Guid FK_ItemId { get; set; }
[ForeignKey("FK_ItemId")]
public Item Item { get; set; }
public decimal QuantityDispatched { get; set; }
public decimal? QuantityReceived { get; set; }
public decimal? QuantityResolved { 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; }
}
}
+2 -6
View File
@@ -1,6 +1,4 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
namespace Distribution.Models
{
public class Vehicle : Template
{
@@ -8,9 +6,7 @@ namespace Distribution.Models
public string NumberPlate { get; set; }
public VehicleType Type { get; set; }
public Guid? FK_DefaultWarehouseId { get; set; }
[ForeignKey("FK_DefaultWarehouseId")]
public Warehouse DefaultWarehouse { get; set; }
public Guid? DefaultWarehouseId { get; set; }
}
public enum VehicleType
{
+12
View File
@@ -5,10 +5,14 @@ using Distribution.Features.Brand;
using Distribution.Features.Category;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverProfile;
using Distribution.Features.Grn;
using Distribution.Features.Item;
using Distribution.Features.Order;
using Distribution.Features.PriceList;
using Distribution.Features.Po;
using Distribution.Features.PurchaseReturn;
using Distribution.Features.Region;
using Distribution.Features.LoadingEvent;
using Distribution.Features.Ride;
using Distribution.Features.Role;
using Distribution.Features.Route;
@@ -57,10 +61,14 @@ builder.Services.AddScoped<IDriverProfileRepository, DriverProfileRepository>();
builder.Services.AddScoped<IDriverComplianceConfigRepository, DriverComplianceConfigRepository>();
builder.Services.AddScoped<IRideRepository, RideRepository>();
builder.Services.AddScoped<IRideCancelRequestRepository, RideCancelRequestRepository>();
builder.Services.AddScoped<ILoadingSheetRepository, LoadingSheetRepository>();
builder.Services.AddScoped<IStoreKycDocumentRepository, StoreKycDocumentRepository>();
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<RegionService>();
builder.Services.AddScoped<RouteService>();
@@ -83,7 +91,11 @@ builder.Services.AddScoped<SupplierService>();
builder.Services.AddScoped<DriverProfileService>();
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.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
+1 -1
View File
@@ -248,4 +248,4 @@ Base route: `/api/driver-profiles`
**Feature folders:** `Features/Ride/`, `Features/DriverProfile/`, `Features/DriverComplianceConfig/`
**Pending:** `dotnet ef database update` to apply migrations to DB. Tests for the three new services.
**Pending:** Tests for the three new services.
+149 -34
View File
@@ -1,6 +1,6 @@
# 04 — Loading Event
**Status**: Not Started
**Status**: Planning
**Depends On**: 03 — Ride Lifecycle
---
@@ -8,76 +8,191 @@
## Goal
Move stock from a source warehouse onto an opened ride's vehicle, with two-party confirmation.
Covers the dispatch half of [mainPlan.md §6](./mainPlan.md#6-warehouse-management):
`generatePickList`, `confirmPicking`, `packOrder`, `buildDispatchLoad`, and `calculateLoadPlan`
from §5.
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.
---
## Background / Context
This is the first place the [02_stock_ledger.md](./02_stock_ledger.md) movement model gets
exercised end-to-end: a `Load` movement debits the warehouse and credits the vehicle location
created when the ride opened ([03_ride_lifecycle.md](./03_ride_lifecycle.md)).
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`
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).
---
## Data Model Changes
### Loading Sheet
### New Entity — LoadingSheet
Header record: `RideId`, `SourceWarehouseId`, `Status` (`Pending`, `DispatchConfirmed`,
`ReceiptConfirmed`, `DiscrepancyFlagged`, `Resolved`).
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| 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 |
| ReceiptConfirmedAt | DateTime? | Set when driver confirms receipt |
| ReceiptConfirmedBy | Guid? | UserId of driver |
| CreatedAt | DateTime | |
### Loading Line Item
### New Entity — LoadingSheetItem
`LoadingSheetId`, `ItemId`, `QuantityDispatched` (set by warehouse manager), `QuantityReceived`
(set by driver) — a mismatch between the two is a discrepancy.
| Column | Type | Notes |
|---|---|---|
| Id | Guid | PK |
| 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 |
### Two-Party Confirmation
### New Enum — LoadingSheetStatus
Warehouse manager confirms what was dispatched (`generatePickList` → `confirmPicking` →
`packOrder`); driver separately confirms what was received onto the vehicle. Both confirmations are
required before the ride can move `Open → Active`.
```csharp
public enum LoadingSheetStatus
{
Pending,
DispatchConfirmed,
ReceiptConfirmed,
DiscrepancyFlagged,
Resolved
}
```
No changes to existing entities. Vehicle capacity check is deferred to Step 13 (Fleet Management).
---
## State Machine
```
Pending → DispatchConfirmed → ReceiptConfirmed (all lines match)
↘ 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`. |
---
## Item Population by Ride Type
| 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) |
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.
---
## Stock Movements
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`
`QuantityFinal` = `QuantityReceived` on a clean confirmation, or `QuantityResolved` when a discrepancy was resolved.
Movement `Type` = `Load` (new `StockMovementType` value to add to enums).
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `generatePickList(orderIds[])` (§6) | `POST /api/loading-sheets` |
| `confirmPicking(pickListId, pickedItems[])` (§6) | `POST /api/loading-sheets/{id}/confirm-dispatch` |
| `packOrder(orderId, packageDetails)` (§6) | `POST /api/loading-sheets/{id}/pack` |
| `buildDispatchLoad(vehicleId, orderIds[])` (§6) | Groups multiple orders into one loading sheet |
| `calculateLoadPlan(vehicleId, orderIds[])` (§5) | Validates total volume/weight against vehicle capacity before dispatch confirmation |
| — | `POST /api/loading-sheets/{id}/confirm-receipt` — driver-side confirmation |
Base route: `/api/loading-sheets`
| Method | Route | Who | Description |
|---|---|---|---|
| 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}/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 |
---
## Business Rules
- `QuantityDispatched != QuantityReceived` on any line flags the sheet `DiscrepancyFlagged` and
blocks ride activation until resolved.
- `calculateLoadPlan` must reject a load exceeding vehicle capacity before dispatch is confirmed,
not after.
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.
6. All list endpoints return paginated responses.
7. Vehicle capacity check is not enforced in this step — deferred to Step 13.
---
## Permissions to Seed
| Code | Who |
|---|---|
| `loading.view` | All roles |
| `loading.dispatch` | Warehouse manager and above |
| `loading.receive` | Driver (AreaManager and above for testing) |
| `loading.resolve` | Supervisor and above |
---
## Fraud / Control Considerations
- Discrepancies must be immediately visible, not silently averaged into stock adjustments — see
[09_fraud_controls.md](./09_fraud_controls.md).
- 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.
---
## Open Questions
- Does `Vehicle` need a `Capacity` field added now (volume/weight), or is `calculateLoadPlan`
deferred until that's available?
- ✅ 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.
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
**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.
+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
```
+10 -9
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)
@@ -41,7 +42,7 @@ They must be resolved before new feature steps begin because later steps depend
| 01 | Item Catalog | Done | 00 | [01_item_catalog.md](./01_item_catalog.md) |
| 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 | Not Started | 03 | [04_loading_event.md](./04_loading_event.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) |
| 07 | Ride Close & Reconciliation | Not Started | 05, 06 | [07_ride_reconciliation.md](./07_ride_reconciliation.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