Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bc33ce08f | |||
| 870b412a06 | |||
| 095d35e1b8 | |||
| d7b03941fb | |||
| 81a49e1a98 | |||
| 2995ce7012 | |||
| db0850f1f0 | |||
| 702dbeb39f | |||
| c8d23afe59 | |||
| ac1b2fbc4b | |||
| f381e7a634 | |||
| 6a0322570b |
@@ -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 }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Distribution.Features.Grn;
|
||||
|
||||
public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int RejectedQuantity);
|
||||
|
||||
public record CreateGrnRequest(Guid WarehouseId, List<GrnItemRequest> Items);
|
||||
|
||||
public record GrnItemResponse(
|
||||
Guid Id,
|
||||
Guid PurchaseOrderItemId,
|
||||
Guid ItemId,
|
||||
string Sku,
|
||||
string ItemName,
|
||||
int AcceptedQuantity,
|
||||
int RejectedQuantity);
|
||||
|
||||
public record GrnResponse(
|
||||
Guid Id,
|
||||
Guid PurchaseOrderId,
|
||||
Guid ReceivedByUserId,
|
||||
Guid WarehouseId,
|
||||
string WarehouseName,
|
||||
DateTime ReceivedAt,
|
||||
DateTime CreatedAt,
|
||||
List<GrnItemResponse> Items);
|
||||
@@ -0,0 +1,35 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Grn;
|
||||
|
||||
public class GrnRepository(AppDBContext context) : IGrnRepository
|
||||
{
|
||||
public async Task<Models.GoodsReceipt?> GetByIdAsync(Guid id) =>
|
||||
await context.GoodsReceipts
|
||||
.Include(gr => gr.Warehouse)
|
||||
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
||||
.FirstOrDefaultAsync(gr => gr.Id == id);
|
||||
|
||||
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||
await context.GoodsReceipts
|
||||
.Include(gr => gr.Warehouse)
|
||||
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
||||
.Where(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive)
|
||||
.OrderByDescending(gr => gr.ReceivedAt)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task AddAsync(Models.GoodsReceipt entity) =>
|
||||
await context.GoodsReceipts.AddAsync(entity);
|
||||
|
||||
public async Task<bool> HasAnyForPurchaseOrderAsync(Guid purchaseOrderId) =>
|
||||
await context.GoodsReceipts.AnyAsync(gr => gr.FK_PurchaseOrderId == purchaseOrderId && gr.IsActive);
|
||||
|
||||
public async Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId) =>
|
||||
await context.GoodsReceiptItems
|
||||
.Include(i => i.PurchaseOrderItem)
|
||||
.Where(i => i.PurchaseOrderItem.FK_PurchaseOrderId == purchaseOrderId && i.IsActive)
|
||||
.GroupBy(i => i.PurchaseOrderItem.FK_ItemId)
|
||||
.Select(g => new { ItemId = g.Key, Total = g.Sum(i => i.AcceptedQuantity) })
|
||||
.ToDictionaryAsync(x => x.ItemId, x => x.Total);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using Distribution.Features.Po;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Grn;
|
||||
|
||||
public class GrnService(
|
||||
IGrnRepository repo,
|
||||
IPoRepository poRepo,
|
||||
IStockRepository stockRepo,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i) =>
|
||||
new(i.Id, i.FK_PurchaseOrderItemId, i.PurchaseOrderItem.FK_ItemId, i.PurchaseOrderItem.Item.Sku,
|
||||
i.PurchaseOrderItem.Item.Name, i.AcceptedQuantity, i.RejectedQuantity);
|
||||
|
||||
private static GrnResponse Map(Models.GoodsReceipt gr) =>
|
||||
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
|
||||
gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||
|
||||
public async Task<GrnResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var gr = await repo.GetByIdAsync(id);
|
||||
if (gr is null || !gr.IsActive) return null;
|
||||
return Map(gr);
|
||||
}
|
||||
|
||||
public async Task<List<GrnResponse>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||
(await repo.GetByPurchaseOrderIdAsync(purchaseOrderId)).Select(Map).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Validates the Po status, increments PurchaseOrderItem.ReceivedQuantity, writes StockBalance/
|
||||
/// StockMovement for AcceptedQuantity only (RejectedQuantity never touches stock), and
|
||||
/// transitions the Po to PartiallyReceived/Received. All in one unit of work.
|
||||
/// </summary>
|
||||
public async Task<GrnResponse> CreateAsync(Guid purchaseOrderId, CreateGrnRequest request, Guid receivedByUserId, CancellationToken ct = default)
|
||||
{
|
||||
var po = await poRepo.GetByIdAsync(purchaseOrderId)
|
||||
?? throw new InvalidOperationException("Purchase order not found.");
|
||||
|
||||
if (!po.IsActive)
|
||||
throw new InvalidOperationException("Purchase order not found.");
|
||||
|
||||
if (po.Status is not (PurchaseOrderStatus.Issued or PurchaseOrderStatus.PartiallyReceived))
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot receive goods against a purchase order in {po.Status} status.");
|
||||
|
||||
if (request.Items is null || request.Items.Count == 0)
|
||||
throw new InvalidOperationException("A goods receipt must have at least one line item.");
|
||||
|
||||
var goodsReceipt = new Models.GoodsReceipt
|
||||
{
|
||||
FK_PurchaseOrderId = po.Id,
|
||||
FK_ReceivedByUserId = receivedByUserId,
|
||||
FK_WarehouseId = request.WarehouseId,
|
||||
ReceivedAt = DateTime.Now
|
||||
};
|
||||
|
||||
foreach (var line in request.Items)
|
||||
{
|
||||
var poItem = po.Items.FirstOrDefault(i => i.Id == line.PurchaseOrderItemId && i.IsActive)
|
||||
?? throw new InvalidOperationException($"Purchase order line {line.PurchaseOrderItemId} not found on this Po.");
|
||||
|
||||
if (poItem.IsCancelled)
|
||||
throw new InvalidOperationException($"Line for item {poItem.FK_ItemId} is cancelled and cannot receive stock.");
|
||||
|
||||
if (line.AcceptedQuantity < 0 || line.RejectedQuantity < 0)
|
||||
throw new InvalidOperationException("Accepted/rejected quantities cannot be negative.");
|
||||
|
||||
if (line.AcceptedQuantity + line.RejectedQuantity + poItem.ReceivedQuantity > poItem.Quantity)
|
||||
throw new InvalidOperationException(
|
||||
$"Accepted + rejected quantity for item {poItem.FK_ItemId} would exceed the ordered quantity.");
|
||||
|
||||
poItem.ReceivedQuantity += line.AcceptedQuantity;
|
||||
|
||||
goodsReceipt.Items.Add(new Models.GoodsReceiptItem
|
||||
{
|
||||
FK_PurchaseOrderItemId = poItem.Id,
|
||||
AcceptedQuantity = line.AcceptedQuantity,
|
||||
RejectedQuantity = line.RejectedQuantity
|
||||
});
|
||||
}
|
||||
|
||||
var activeLines = po.Items.Where(i => i.IsActive && !i.IsCancelled).ToList();
|
||||
po.Status = activeLines.Count > 0 && activeLines.All(i => i.ReceivedQuantity >= i.Quantity)
|
||||
? PurchaseOrderStatus.Received
|
||||
: PurchaseOrderStatus.PartiallyReceived;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(goodsReceipt);
|
||||
|
||||
foreach (var line in goodsReceipt.Items.Where(i => i.AcceptedQuantity > 0))
|
||||
{
|
||||
var poItem = po.Items.First(i => i.Id == line.FK_PurchaseOrderItemId);
|
||||
|
||||
var balance = await stockRepo.GetBalanceAsync(poItem.FK_ItemId, request.WarehouseId);
|
||||
if (balance is null)
|
||||
{
|
||||
await stockRepo.AddBalanceAsync(new StockBalance
|
||||
{
|
||||
FK_ItemId = poItem.FK_ItemId,
|
||||
LocationId = request.WarehouseId,
|
||||
LocationType = LocationType.Warehouse,
|
||||
Quantity = line.AcceptedQuantity
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
balance.Quantity += line.AcceptedQuantity;
|
||||
balance.UpdatedAt = DateTime.Now;
|
||||
stockRepo.UpdateBalance(balance);
|
||||
}
|
||||
|
||||
await stockRepo.AddMovementAsync(new StockMovement
|
||||
{
|
||||
FK_ItemId = poItem.FK_ItemId,
|
||||
ToLocationId = request.WarehouseId,
|
||||
ToLocationType = LocationType.Warehouse,
|
||||
Quantity = line.AcceptedQuantity,
|
||||
Type = MovementType.PurchaseReceipt,
|
||||
ReferenceId = goodsReceipt.Id,
|
||||
CreatedBy = receivedByUserId
|
||||
});
|
||||
}
|
||||
|
||||
poRepo.Update(po);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var saved = await repo.GetByIdAsync(goodsReceipt.Id);
|
||||
return Map(saved!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Distribution.Features.Grn;
|
||||
|
||||
public interface IGrnRepository
|
||||
{
|
||||
Task<Models.GoodsReceipt?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
|
||||
Task AddAsync(Models.GoodsReceipt entity);
|
||||
|
||||
/// <summary>True if at least one GoodsReceipt exists against this Po (createPurchaseReturn guard).</summary>
|
||||
Task<bool> HasAnyForPurchaseOrderAsync(Guid purchaseOrderId);
|
||||
|
||||
/// <summary>Sum of AcceptedQuantity per ItemId across all receipts for a Po.</summary>
|
||||
Task<Dictionary<Guid, int>> GetAcceptedTotalsByItemAsync(Guid purchaseOrderId);
|
||||
}
|
||||
@@ -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 < 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!);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
-2338
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-2910
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-2616
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1754
File diff suppressed because it is too large
Load Diff
+612
-251
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
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class GoodsReceipt : Template
|
||||
{
|
||||
public Guid FK_PurchaseOrderId { get; set; }
|
||||
[ForeignKey("FK_PurchaseOrderId")]
|
||||
public PurchaseOrder PurchaseOrder { get; set; }
|
||||
|
||||
public Guid FK_ReceivedByUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Warehouse the accepted quantities are received into. Not listed as a field in
|
||||
/// docs/15_procurement.md's GoodsReceipt table, but required to write StockBalance/
|
||||
/// StockMovement rows (both keyed by LocationId+LocationType) for the accepted quantities.
|
||||
/// </summary>
|
||||
public Guid FK_WarehouseId { get; set; }
|
||||
[ForeignKey("FK_WarehouseId")]
|
||||
public Warehouse Warehouse { get; set; }
|
||||
|
||||
public DateTime ReceivedAt { get; set; } = DateTime.Now;
|
||||
|
||||
public ICollection<GoodsReceiptItem> Items { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class GoodsReceiptItem : Template
|
||||
{
|
||||
public Guid FK_GoodsReceiptId { get; set; }
|
||||
[ForeignKey("FK_GoodsReceiptId")]
|
||||
public GoodsReceipt GoodsReceipt { get; set; }
|
||||
|
||||
public Guid FK_PurchaseOrderItemId { get; set; }
|
||||
[ForeignKey("FK_PurchaseOrderItemId")]
|
||||
public PurchaseOrderItem PurchaseOrderItem { get; set; }
|
||||
|
||||
/// <summary>Added to warehouse stock.</summary>
|
||||
public int AcceptedQuantity { get; set; }
|
||||
|
||||
/// <summary>Damaged/short at receipt, not added to stock.</summary>
|
||||
public int RejectedQuantity { get; set; } = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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; } = [];
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class PurchaseOrder : Template
|
||||
{
|
||||
public Guid FK_SupplierId { get; set; }
|
||||
[ForeignKey("FK_SupplierId")]
|
||||
public Supplier Supplier { get; set; }
|
||||
|
||||
public Guid FK_CreatedByUserId { get; set; }
|
||||
|
||||
public PurchaseOrderStatus Status { get; set; } = PurchaseOrderStatus.Issued;
|
||||
|
||||
public string? CancelReason { get; set; }
|
||||
|
||||
public ICollection<PurchaseOrderItem> Items { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class PurchaseOrderItem : Template
|
||||
{
|
||||
public Guid FK_PurchaseOrderId { get; set; }
|
||||
[ForeignKey("FK_PurchaseOrderId")]
|
||||
public PurchaseOrder PurchaseOrder { get; set; }
|
||||
|
||||
public Guid FK_ItemId { get; set; }
|
||||
[ForeignKey("FK_ItemId")]
|
||||
public Item Item { get; set; }
|
||||
|
||||
public int Quantity { get; set; }
|
||||
|
||||
/// <summary>Agreed cost per unit at the time the PO was issued.</summary>
|
||||
public decimal UnitCost { get; set; }
|
||||
|
||||
/// <summary>Running total, incremented by createGoodsReceipt; drives PurchaseOrder.Status.</summary>
|
||||
public int ReceivedQuantity { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Set by cancelPurchaseOrderLine. Outstanding qty for receiving purposes is
|
||||
/// Quantity - ReceivedQuantity - CancelledQuantity.
|
||||
/// </summary>
|
||||
public int CancelledQuantity { get; set; } = 0;
|
||||
|
||||
/// <summary>True once this line is individually cancelled.</summary>
|
||||
public bool IsCancelled { get; set; } = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class PurchaseReturn : Template
|
||||
{
|
||||
public Guid FK_PurchaseOrderId { get; set; }
|
||||
[ForeignKey("FK_PurchaseOrderId")]
|
||||
public PurchaseOrder PurchaseOrder { get; set; }
|
||||
|
||||
public string Reason { get; set; }
|
||||
|
||||
public ICollection<PurchaseReturnItem> Items { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class PurchaseReturnItem : Template
|
||||
{
|
||||
public Guid FK_PurchaseReturnId { get; set; }
|
||||
[ForeignKey("FK_PurchaseReturnId")]
|
||||
public PurchaseReturn PurchaseReturn { get; set; }
|
||||
|
||||
public Guid FK_ItemId { get; set; }
|
||||
[ForeignKey("FK_ItemId")]
|
||||
public Item Item { get; set; }
|
||||
|
||||
/// <summary>Returned quantity, decremented from warehouse stock.</summary>
|
||||
public int Quantity { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user