Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac1b2fbc4b | |||
| f381e7a634 | |||
| 6a0322570b | |||
| dc4b1934e1 | |||
| aabace1bba | |||
| f7101800cd |
@@ -36,6 +36,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
public DbSet<DriverProfile> DriverProfiles { get; set; }
|
||||
public DbSet<DriverComplianceConfig> DriverComplianceConfigs { get; set; }
|
||||
public DbSet<RideCancelRequest> RideCancelRequests { get; set; }
|
||||
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; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -73,10 +78,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)
|
||||
@@ -90,6 +96,70 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
.HasForeignKey(r => r.FK_DriverProfileId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SchemeRedemption>()
|
||||
.HasOne(r => r.Scheme)
|
||||
.WithMany(s => s.Redemptions)
|
||||
.HasForeignKey(r => r.FK_SchemeId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SchemeRedemption>()
|
||||
.HasOne(r => r.Order)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.FK_OrderId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SchemeRedemption>()
|
||||
.HasOne(r => r.FreeItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.FK_FreeItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<Scheme>()
|
||||
.HasOne(s => s.TriggerItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.FK_TriggerItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<Scheme>()
|
||||
.HasOne(s => s.FreeItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.FK_FreeItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SchemeBundleComponent>()
|
||||
.HasOne(c => c.Scheme)
|
||||
.WithMany(s => s.BundleComponents)
|
||||
.HasForeignKey(c => c.FK_SchemeId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<SchemeBundleComponent>()
|
||||
.HasOne(c => c.Item)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.FK_ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<SchemeBundleComponent>()
|
||||
.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);
|
||||
|
||||
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,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());
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Distribution.Features.Scheme;
|
||||
|
||||
public interface ISchemeRepository
|
||||
{
|
||||
Task<Models.Scheme?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.Scheme>> GetAllAsync(Guid? regionId = null);
|
||||
Task AddAsync(Models.Scheme entity);
|
||||
void Update(Models.Scheme entity);
|
||||
void Remove(Models.Scheme entity);
|
||||
|
||||
Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId);
|
||||
Task AddRedemptionAsync(Models.SchemeRedemption entity);
|
||||
Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId);
|
||||
|
||||
Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId);
|
||||
Task AddBundleComponentAsync(Models.SchemeBundleComponent entity);
|
||||
void UpdateBundleComponent(Models.SchemeBundleComponent entity);
|
||||
void RemoveBundleComponent(Models.SchemeBundleComponent entity);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Scheme;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/schemes")]
|
||||
[Authorize]
|
||||
public class SchemeController(SchemeService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId) =>
|
||||
Ok(await service.GetAllAsync(regionId));
|
||||
|
||||
[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(CreateSchemeRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateSchemeRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/free-goods-rule")]
|
||||
public async Task<IActionResult> SetFreeGoodsRule(Guid id, SetFreeGoodsRuleRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.SetFreeGoodsRuleAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Conflict(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}/free-goods-rule")]
|
||||
public async Task<IActionResult> ClearFreeGoodsRule(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await service.ClearFreeGoodsRuleAsync(id, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/bundle-components/{itemId:guid}")]
|
||||
public async Task<IActionResult> UpsertBundleComponent(Guid id, Guid itemId, UpsertBundleComponentRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.UpsertBundleComponentAsync(id, itemId, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Conflict(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}/bundle-components/{itemId:guid}")]
|
||||
public async Task<IActionResult> RemoveBundleComponent(Guid id, Guid itemId, CancellationToken ct)
|
||||
{
|
||||
var result = await service.RemoveBundleComponentAsync(id, itemId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("eligible")]
|
||||
public async Task<IActionResult> GetEligible([FromQuery] Guid storeId, [FromQuery] Guid orderId)
|
||||
{
|
||||
var result = await service.EvaluateEligibilityAsync(storeId, orderId);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/spend")]
|
||||
public async Task<IActionResult> GetSpend(Guid id)
|
||||
{
|
||||
var result = await service.GetSpendAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/roi")]
|
||||
public async Task<IActionResult> GetROI(Guid id)
|
||||
{
|
||||
var result = await service.GetROIAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Route("api/orders")]
|
||||
[Authorize]
|
||||
public class SchemeOrderController(SchemeService service) : ControllerBase
|
||||
{
|
||||
[HttpPost("{id:guid}/apply-scheme")]
|
||||
public async Task<IActionResult> ApplyScheme(Guid id, ApplySchemeRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.ApplySchemeAsync(id, request.SchemeId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Conflict(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Scheme;
|
||||
|
||||
// ── Rule config payloads for percentage-based rules (serialized into Scheme.RuleConfig) ──
|
||||
|
||||
public record SlabDiscountConfig(List<SlabTier> Tiers);
|
||||
|
||||
public record SlabTier(decimal MinOrderAmount, decimal DiscountPercent);
|
||||
|
||||
public record FlatPercentConfig(decimal DiscountPercent);
|
||||
|
||||
// ── Requests ────────────────────────────────────────────────────────────
|
||||
|
||||
public record BundleComponentRequest(Guid ItemId, int Qty);
|
||||
|
||||
/// <summary>
|
||||
/// TriggerItemId/TriggerQty/FreeItemId/FreeQty/BundleComponents are optional so plain discount
|
||||
/// schemes (SlabDiscount/FlatPercent) can omit them entirely. When RuleType is BuyXGetY, supplying
|
||||
/// TriggerItemId+TriggerQty+FreeItemId+FreeQty here creates the scheme fully populated in one call
|
||||
/// instead of a separate PUT .../free-goods-rule. When RuleType is FreeBundle, supplying
|
||||
/// FreeItemId+FreeQty+BundleComponents does the same. These can still be added/edited later via
|
||||
/// PUT .../free-goods-rule and PUT .../bundle-components/{itemId}.
|
||||
/// </summary>
|
||||
public record CreateSchemeRequest(
|
||||
string Name,
|
||||
SchemeRuleType RuleType,
|
||||
string? RuleConfig,
|
||||
Guid? RegionId,
|
||||
string? EligibleSegment,
|
||||
DateTime DateRangeFrom,
|
||||
DateTime DateRangeTo,
|
||||
bool AgentLimitExempt,
|
||||
decimal? BaselineExpectedSales,
|
||||
Guid? TriggerItemId = null,
|
||||
int? TriggerQty = null,
|
||||
Guid? FreeItemId = null,
|
||||
decimal? FreeQty = null,
|
||||
List<BundleComponentRequest>? BundleComponents = null);
|
||||
|
||||
public record UpdateSchemeRequest(
|
||||
string Name,
|
||||
string? RuleConfig,
|
||||
Guid? RegionId,
|
||||
string? EligibleSegment,
|
||||
DateTime DateRangeFrom,
|
||||
DateTime DateRangeTo,
|
||||
bool AgentLimitExempt,
|
||||
decimal? BaselineExpectedSales);
|
||||
|
||||
public record ApplySchemeRequest(Guid SchemeId);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the free-goods payout for a scheme. For BuyXGetY, TriggerItemId/TriggerQty are required
|
||||
/// (what the store must buy); for FreeBundle they are ignored — the required items instead come
|
||||
/// from the scheme's bundle-components CRUD. FreeItemId/FreeQty is the payout in both cases.
|
||||
/// </summary>
|
||||
public record SetFreeGoodsRuleRequest(Guid? TriggerItemId, int? TriggerQty, Guid FreeItemId, decimal FreeQty);
|
||||
|
||||
public record UpsertBundleComponentRequest(int Qty);
|
||||
|
||||
// ── Responses ───────────────────────────────────────────────────────────
|
||||
|
||||
public record BundleComponentResponse(Guid ItemId, string Sku, string ItemName, int Qty);
|
||||
|
||||
public record SchemeResponse(
|
||||
Guid Id,
|
||||
string Name,
|
||||
SchemeRuleType RuleType,
|
||||
string? RuleConfig,
|
||||
Guid? TriggerItemId,
|
||||
string? TriggerItemName,
|
||||
int? TriggerQty,
|
||||
Guid? FreeItemId,
|
||||
string? FreeItemName,
|
||||
decimal? FreeQty,
|
||||
List<BundleComponentResponse> BundleComponents,
|
||||
Guid? RegionId,
|
||||
string? RegionName,
|
||||
string? EligibleSegment,
|
||||
DateTime DateRangeFrom,
|
||||
DateTime DateRangeTo,
|
||||
bool AgentLimitExempt,
|
||||
decimal? BaselineExpectedSales,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record SchemeEligibilityResponse(
|
||||
Guid SchemeId,
|
||||
string SchemeName,
|
||||
SchemeRuleType RuleType,
|
||||
bool Eligible,
|
||||
string? Reason,
|
||||
decimal? DiscountAmount,
|
||||
Guid? FreeItemId,
|
||||
decimal? FreeQty);
|
||||
|
||||
public record SchemeRedemptionResponse(
|
||||
Guid Id,
|
||||
Guid SchemeId,
|
||||
Guid? OrderId,
|
||||
decimal? DiscountAmount,
|
||||
Guid? FreeItemId,
|
||||
string? FreeItemName,
|
||||
decimal? FreeQty,
|
||||
decimal? FreeItemCostValue,
|
||||
DateTime RedeemedAt);
|
||||
|
||||
public record SchemeSpendResponse(Guid SchemeId, decimal TotalDiscountSpend, decimal TotalFreeGoodsCostSpend, decimal TotalSpend, int RedemptionCount);
|
||||
|
||||
public record SchemeROIResponse(Guid SchemeId, decimal BaselineExpectedSales, decimal ActualSales, decimal IncrementalSales, decimal TotalSpend, decimal? ROI);
|
||||
@@ -0,0 +1,67 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Scheme;
|
||||
|
||||
public class SchemeRepository(AppDBContext context) : ISchemeRepository
|
||||
{
|
||||
public async Task<Models.Scheme?> GetByIdAsync(Guid id) =>
|
||||
await context.Schemes
|
||||
.Include(s => s.Region)
|
||||
.Include(s => s.TriggerItem)
|
||||
.Include(s => s.FreeItem)
|
||||
.Include(s => s.BundleComponents).ThenInclude(c => c.Item)
|
||||
.FirstOrDefaultAsync(s => s.Id == id);
|
||||
|
||||
public async Task<List<Models.Scheme>> GetAllAsync(Guid? regionId = null)
|
||||
{
|
||||
var query = context.Schemes
|
||||
.Include(s => s.Region)
|
||||
.Include(s => s.TriggerItem)
|
||||
.Include(s => s.FreeItem)
|
||||
.Include(s => s.BundleComponents).ThenInclude(c => c.Item)
|
||||
.Where(s => s.IsActive);
|
||||
|
||||
if (regionId.HasValue) query = query.Where(s => s.FK_RegionId == regionId);
|
||||
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task AddAsync(Models.Scheme entity) =>
|
||||
await context.Schemes.AddAsync(entity);
|
||||
|
||||
public void Update(Models.Scheme entity) =>
|
||||
context.Schemes.Update(entity);
|
||||
|
||||
public void Remove(Models.Scheme entity)
|
||||
{
|
||||
entity.IsActive = false;
|
||||
context.Schemes.Update(entity);
|
||||
}
|
||||
|
||||
public async Task<List<Models.SchemeRedemption>> GetRedemptionsAsync(Guid schemeId) =>
|
||||
await context.SchemeRedemptions
|
||||
.Include(r => r.FreeItem)
|
||||
.Include(r => r.Order)
|
||||
.Where(r => r.FK_SchemeId == schemeId)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task AddRedemptionAsync(Models.SchemeRedemption entity) =>
|
||||
await context.SchemeRedemptions.AddAsync(entity);
|
||||
|
||||
public async Task<bool> HasRedemptionAsync(Guid schemeId, Guid orderId) =>
|
||||
await context.SchemeRedemptions.AnyAsync(r => r.FK_SchemeId == schemeId && r.FK_OrderId == orderId);
|
||||
|
||||
public async Task<Models.SchemeBundleComponent?> GetBundleComponentAsync(Guid schemeId, Guid itemId) =>
|
||||
await context.SchemeBundleComponents
|
||||
.FirstOrDefaultAsync(c => c.FK_SchemeId == schemeId && c.FK_ItemId == itemId);
|
||||
|
||||
public async Task AddBundleComponentAsync(Models.SchemeBundleComponent entity) =>
|
||||
await context.SchemeBundleComponents.AddAsync(entity);
|
||||
|
||||
public void UpdateBundleComponent(Models.SchemeBundleComponent entity) =>
|
||||
context.SchemeBundleComponents.Update(entity);
|
||||
|
||||
public void RemoveBundleComponent(Models.SchemeBundleComponent entity) =>
|
||||
context.SchemeBundleComponents.Remove(entity);
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
using System.Text.Json;
|
||||
using Distribution.Features.Item;
|
||||
using Distribution.Features.Order;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.Store;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Scheme;
|
||||
|
||||
public class SchemeService(
|
||||
ISchemeRepository repo,
|
||||
IOrderRepository orderRepo,
|
||||
IItemRepository itemRepo,
|
||||
IStoreRepository storeRepo,
|
||||
IStockRepository stockRepo,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private static SchemeResponse ToResponse(Models.Scheme s) =>
|
||||
new(s.Id, s.Name, s.RuleType, s.RuleConfig,
|
||||
s.FK_TriggerItemId, s.TriggerItem?.Name, s.TriggerQty,
|
||||
s.FK_FreeItemId, s.FreeItem?.Name, s.FreeQty,
|
||||
s.BundleComponents.Select(c => new BundleComponentResponse(c.FK_ItemId, c.Item.Sku, c.Item.Name, c.Qty)).ToList(),
|
||||
s.FK_RegionId, s.Region?.Name, s.EligibleSegment,
|
||||
s.DateRangeFrom, s.DateRangeTo, s.AgentLimitExempt, s.BaselineExpectedSales, s.IsActive, s.CreatedAt);
|
||||
|
||||
private static SchemeRedemptionResponse ToRedemptionResponse(SchemeRedemption r) =>
|
||||
new(r.Id, r.FK_SchemeId, r.FK_OrderId, r.DiscountAmount, r.FK_FreeItemId, r.FreeItem?.Name,
|
||||
r.FreeQty, r.FreeItemCostValue, r.RedeemedAt);
|
||||
|
||||
// ── CRUD ───────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<SchemeResponse>> GetAllAsync(Guid? regionId = null)
|
||||
{
|
||||
var schemes = await repo.GetAllAsync(regionId);
|
||||
return schemes.Select(ToResponse).ToList();
|
||||
}
|
||||
|
||||
public async Task<SchemeResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(id);
|
||||
return scheme is null || !scheme.IsActive ? null : ToResponse(scheme);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// createScheme(schemeRules, eligibility, dateRange). RuleConfig is only meaningful for
|
||||
/// SlabDiscount/FlatPercent. For BuyXGetY/FreeBundle, the free-goods payout (and bundle
|
||||
/// components) can be supplied inline here to create a fully-populated scheme in one call, or
|
||||
/// added afterwards via SetFreeGoodsRuleAsync / UpsertBundleComponentAsync.
|
||||
/// </summary>
|
||||
public async Task<SchemeResponse> CreateAsync(CreateSchemeRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ValidatePercentRuleConfig(request.RuleType, request.RuleConfig);
|
||||
if (request.DateRangeFrom > request.DateRangeTo)
|
||||
throw new InvalidOperationException("DateRangeFrom must not be after DateRangeTo.");
|
||||
|
||||
var hasFreeGoodsRule = request.FreeItemId.HasValue || request.FreeQty.HasValue ||
|
||||
request.TriggerItemId.HasValue || request.TriggerQty.HasValue;
|
||||
if (hasFreeGoodsRule)
|
||||
await ValidateFreeGoodsRuleAsync(request.RuleType, request.TriggerItemId, request.TriggerQty, request.FreeItemId, request.FreeQty);
|
||||
|
||||
if (request.BundleComponents is { Count: > 0 })
|
||||
{
|
||||
if (request.RuleType != SchemeRuleType.FreeBundle)
|
||||
throw new InvalidOperationException("BundleComponents only applies to FreeBundle schemes.");
|
||||
foreach (var c in request.BundleComponents)
|
||||
{
|
||||
if (c.Qty <= 0)
|
||||
throw new InvalidOperationException("Bundle component Qty must be positive.");
|
||||
if (await itemRepo.GetByIdAsync(c.ItemId) is null)
|
||||
throw new InvalidOperationException($"Bundle component item '{c.ItemId}' not found.");
|
||||
}
|
||||
}
|
||||
|
||||
var scheme = new Models.Scheme
|
||||
{
|
||||
Name = request.Name,
|
||||
RuleType = request.RuleType,
|
||||
RuleConfig = request.RuleConfig,
|
||||
FK_RegionId = request.RegionId,
|
||||
EligibleSegment = request.EligibleSegment,
|
||||
DateRangeFrom = request.DateRangeFrom,
|
||||
DateRangeTo = request.DateRangeTo,
|
||||
AgentLimitExempt = request.AgentLimitExempt,
|
||||
BaselineExpectedSales = request.BaselineExpectedSales
|
||||
};
|
||||
|
||||
if (hasFreeGoodsRule)
|
||||
{
|
||||
scheme.FK_TriggerItemId = request.RuleType == SchemeRuleType.BuyXGetY ? request.TriggerItemId : null;
|
||||
scheme.TriggerQty = request.RuleType == SchemeRuleType.BuyXGetY ? request.TriggerQty : null;
|
||||
scheme.FK_FreeItemId = request.FreeItemId;
|
||||
scheme.FreeQty = request.FreeQty;
|
||||
}
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(scheme);
|
||||
if (request.BundleComponents is { Count: > 0 })
|
||||
{
|
||||
foreach (var c in request.BundleComponents)
|
||||
await repo.AddBundleComponentAsync(new SchemeBundleComponent { FK_SchemeId = scheme.Id, FK_ItemId = c.ItemId, Qty = c.Qty });
|
||||
}
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return (await GetByIdAsync(scheme.Id))!;
|
||||
}
|
||||
|
||||
public async Task<SchemeResponse?> UpdateAsync(Guid id, UpdateSchemeRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(id);
|
||||
if (scheme is null || !scheme.IsActive) return null;
|
||||
|
||||
ValidatePercentRuleConfig(scheme.RuleType, request.RuleConfig);
|
||||
if (request.DateRangeFrom > request.DateRangeTo)
|
||||
throw new InvalidOperationException("DateRangeFrom must not be after DateRangeTo.");
|
||||
|
||||
scheme.Name = request.Name;
|
||||
scheme.RuleConfig = request.RuleConfig;
|
||||
scheme.FK_RegionId = request.RegionId;
|
||||
scheme.EligibleSegment = request.EligibleSegment;
|
||||
scheme.DateRangeFrom = request.DateRangeFrom;
|
||||
scheme.DateRangeTo = request.DateRangeTo;
|
||||
scheme.AgentLimitExempt = request.AgentLimitExempt;
|
||||
scheme.BaselineExpectedSales = request.BaselineExpectedSales;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(scheme); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(scheme);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(id);
|
||||
if (scheme is null || !scheme.IsActive) return false;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Remove(scheme); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Free-goods rule CRUD (BuyXGetY trigger + shared payout) ──────────────
|
||||
|
||||
public async Task<SchemeResponse?> SetFreeGoodsRuleAsync(Guid schemeId, SetFreeGoodsRuleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(schemeId);
|
||||
if (scheme is null || !scheme.IsActive) return null;
|
||||
|
||||
await ValidateFreeGoodsRuleAsync(scheme.RuleType, request.TriggerItemId, request.TriggerQty, request.FreeItemId, request.FreeQty);
|
||||
|
||||
scheme.FK_TriggerItemId = scheme.RuleType == SchemeRuleType.BuyXGetY ? request.TriggerItemId : null;
|
||||
scheme.TriggerQty = scheme.RuleType == SchemeRuleType.BuyXGetY ? request.TriggerQty : null;
|
||||
scheme.FK_FreeItemId = request.FreeItemId;
|
||||
scheme.FreeQty = request.FreeQty;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(scheme); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return (await GetByIdAsync(schemeId))!;
|
||||
}
|
||||
|
||||
public async Task<SchemeResponse?> ClearFreeGoodsRuleAsync(Guid schemeId, CancellationToken ct = default)
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(schemeId);
|
||||
if (scheme is null || !scheme.IsActive) return null;
|
||||
|
||||
scheme.FK_TriggerItemId = null;
|
||||
scheme.TriggerQty = null;
|
||||
scheme.FK_FreeItemId = null;
|
||||
scheme.FreeQty = null;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(scheme); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return (await GetByIdAsync(schemeId))!;
|
||||
}
|
||||
|
||||
// ── Bundle component CRUD (FreeBundle required items) ────────────────────
|
||||
|
||||
public async Task<SchemeResponse?> UpsertBundleComponentAsync(Guid schemeId, Guid itemId, UpsertBundleComponentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(schemeId);
|
||||
if (scheme is null || !scheme.IsActive) return null;
|
||||
|
||||
if (scheme.RuleType != SchemeRuleType.FreeBundle)
|
||||
throw new InvalidOperationException("Bundle components only apply to FreeBundle schemes.");
|
||||
if (request.Qty <= 0)
|
||||
throw new InvalidOperationException("Qty must be positive.");
|
||||
|
||||
var item = await itemRepo.GetByIdAsync(itemId)
|
||||
?? throw new InvalidOperationException("Item not found.");
|
||||
|
||||
var existing = await repo.GetBundleComponentAsync(schemeId, itemId);
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
if (existing is null)
|
||||
{
|
||||
await repo.AddBundleComponentAsync(new SchemeBundleComponent
|
||||
{
|
||||
FK_SchemeId = schemeId,
|
||||
FK_ItemId = item.Id,
|
||||
Qty = request.Qty
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Qty = request.Qty;
|
||||
repo.UpdateBundleComponent(existing);
|
||||
}
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return (await GetByIdAsync(schemeId))!;
|
||||
}
|
||||
|
||||
public async Task<SchemeResponse?> RemoveBundleComponentAsync(Guid schemeId, Guid itemId, CancellationToken ct = default)
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(schemeId);
|
||||
if (scheme is null || !scheme.IsActive) return null;
|
||||
|
||||
var existing = await repo.GetBundleComponentAsync(schemeId, itemId);
|
||||
if (existing is null) return null;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.RemoveBundleComponent(existing); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return (await GetByIdAsync(schemeId))!;
|
||||
}
|
||||
|
||||
// ── evaluateSchemeEligibility(storeId, orderId) ──────────────────────────
|
||||
|
||||
public async Task<List<SchemeEligibilityResponse>?> EvaluateEligibilityAsync(Guid storeId, Guid orderId)
|
||||
{
|
||||
var store = await storeRepo.GetByIdWithRegionAsync(storeId);
|
||||
if (store is null || !store.IsActive) return null;
|
||||
|
||||
var order = await orderRepo.GetByIdAsync(orderId);
|
||||
if (order is null || !order.IsActive) return null;
|
||||
|
||||
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
|
||||
var regionId = store.Route?.FK_RegionId;
|
||||
|
||||
var candidates = await repo.GetAllAsync();
|
||||
var result = new List<SchemeEligibilityResponse>();
|
||||
|
||||
foreach (var scheme in candidates)
|
||||
{
|
||||
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != regionId)
|
||||
continue;
|
||||
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
|
||||
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
if (order.OrderDate < scheme.DateRangeFrom || order.OrderDate > scheme.DateRangeTo)
|
||||
continue;
|
||||
|
||||
result.Add(EvaluateRule(scheme, orderItems));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scope-only check (region, segment, date range as of today) with no order to evaluate line
|
||||
/// items against — used by Store Management's `checkStoreSchemeEligibility(storeId, schemeId)`
|
||||
/// (12_store_management.md), which has no order context. Returns null if the store or scheme
|
||||
/// doesn't exist; otherwise (Eligible, Reason) — Eligible here means "in scope for this store",
|
||||
/// not "this order's items satisfy the rule" (use EvaluateEligibilityAsync for that).
|
||||
/// </summary>
|
||||
public async Task<(bool Eligible, string? Reason)?> CheckStoreScopeEligibilityAsync(Guid storeId, Guid schemeId)
|
||||
{
|
||||
var store = await storeRepo.GetByIdWithRegionAsync(storeId);
|
||||
if (store is null || !store.IsActive) return null;
|
||||
|
||||
var scheme = await repo.GetByIdAsync(schemeId);
|
||||
if (scheme is null || !scheme.IsActive) return null;
|
||||
|
||||
var regionId = store.Route?.FK_RegionId;
|
||||
if (scheme.FK_RegionId.HasValue && scheme.FK_RegionId != regionId)
|
||||
return (false, "Scheme is not available in the store's region.");
|
||||
|
||||
if (!string.IsNullOrEmpty(scheme.EligibleSegment) &&
|
||||
!string.Equals(scheme.EligibleSegment, store.Segment, StringComparison.OrdinalIgnoreCase))
|
||||
return (false, $"Scheme is only available to the '{scheme.EligibleSegment}' segment.");
|
||||
|
||||
var today = DateTime.Now;
|
||||
if (today < scheme.DateRangeFrom || today > scheme.DateRangeTo)
|
||||
return (false, "Scheme is not currently active (outside its date range).");
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
private static SchemeEligibilityResponse EvaluateRule(Models.Scheme scheme, List<OrderItem> orderItems)
|
||||
{
|
||||
switch (scheme.RuleType)
|
||||
{
|
||||
case SchemeRuleType.BuyXGetY:
|
||||
{
|
||||
if (scheme.FK_TriggerItemId is null || scheme.TriggerQty is null or <= 0 ||
|
||||
scheme.FK_FreeItemId is null || scheme.FreeQty is null or <= 0)
|
||||
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
|
||||
"Scheme has no free-goods rule configured.", null, null, null);
|
||||
|
||||
var boughtQty = orderItems.Where(i => i.FK_ItemId == scheme.FK_TriggerItemId).Sum(i => i.Quantity);
|
||||
if (boughtQty < scheme.TriggerQty)
|
||||
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
|
||||
$"Requires {scheme.TriggerQty} of the trigger item; order has {boughtQty}.", null, null, null);
|
||||
|
||||
var multiplier = boughtQty / scheme.TriggerQty.Value;
|
||||
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
|
||||
null, scheme.FK_FreeItemId, scheme.FreeQty * multiplier);
|
||||
}
|
||||
case SchemeRuleType.FreeBundle:
|
||||
{
|
||||
if (scheme.BundleComponents.Count == 0 || scheme.FK_FreeItemId is null || scheme.FreeQty is null or <= 0)
|
||||
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
|
||||
"Scheme has no bundle components or free-goods payout configured.", null, null, null);
|
||||
|
||||
var missing = scheme.BundleComponents
|
||||
.Where(c => orderItems.Where(i => i.FK_ItemId == c.FK_ItemId).Sum(i => i.Quantity) < c.Qty)
|
||||
.ToList();
|
||||
if (missing.Count > 0)
|
||||
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
|
||||
"Order is missing required bundle components.", null, null, null);
|
||||
|
||||
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
|
||||
null, scheme.FK_FreeItemId, scheme.FreeQty);
|
||||
}
|
||||
case SchemeRuleType.FlatPercent:
|
||||
{
|
||||
if (string.IsNullOrEmpty(scheme.RuleConfig))
|
||||
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Scheme has no RuleConfig.", null, null, null);
|
||||
var cfg = JsonSerializer.Deserialize<FlatPercentConfig>(scheme.RuleConfig, JsonOptions)!;
|
||||
var orderTotal = orderItems.Sum(i => i.Quantity * i.UnitPrice);
|
||||
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
|
||||
Math.Round(orderTotal * cfg.DiscountPercent / 100m, 2), null, null);
|
||||
}
|
||||
case SchemeRuleType.SlabDiscount:
|
||||
{
|
||||
if (string.IsNullOrEmpty(scheme.RuleConfig))
|
||||
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Scheme has no RuleConfig.", null, null, null);
|
||||
var cfg = JsonSerializer.Deserialize<SlabDiscountConfig>(scheme.RuleConfig, JsonOptions)!;
|
||||
var orderTotal = orderItems.Sum(i => i.Quantity * i.UnitPrice);
|
||||
var tier = cfg.Tiers.Where(t => orderTotal >= t.MinOrderAmount)
|
||||
.OrderByDescending(t => t.MinOrderAmount).FirstOrDefault();
|
||||
if (tier is null)
|
||||
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false,
|
||||
"Order total does not meet any slab threshold.", null, null, null);
|
||||
|
||||
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, true, null,
|
||||
Math.Round(orderTotal * tier.DiscountPercent / 100m, 2), null, null);
|
||||
}
|
||||
default:
|
||||
return new SchemeEligibilityResponse(scheme.Id, scheme.Name, scheme.RuleType, false, "Unknown rule type.", null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
// ── applySchemeDiscount(orderId, schemeId) ───────────────────────────────
|
||||
|
||||
public async Task<SchemeRedemptionResponse?> ApplySchemeAsync(Guid orderId, Guid schemeId, CancellationToken ct = default)
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(schemeId);
|
||||
if (scheme is null || !scheme.IsActive) return null;
|
||||
|
||||
var order = await orderRepo.GetByIdAsync(orderId);
|
||||
if (order is null || !order.IsActive) return null;
|
||||
|
||||
if (await repo.HasRedemptionAsync(schemeId, orderId))
|
||||
throw new InvalidOperationException("This scheme has already been redeemed against this order.");
|
||||
|
||||
var orderItems = await orderRepo.GetItemsByOrderIdAsync(orderId);
|
||||
var eligibility = EvaluateRule(scheme, orderItems);
|
||||
if (!eligibility.Eligible)
|
||||
throw new InvalidOperationException(eligibility.Reason ?? "Order is not eligible for this scheme.");
|
||||
|
||||
var redemption = new SchemeRedemption
|
||||
{
|
||||
FK_SchemeId = schemeId,
|
||||
FK_OrderId = orderId,
|
||||
DiscountAmount = eligibility.DiscountAmount
|
||||
};
|
||||
|
||||
var newOrderItems = new List<OrderItem>();
|
||||
|
||||
if (eligibility.FreeItemId.HasValue && eligibility.FreeQty is > 0)
|
||||
{
|
||||
var freeItem = await itemRepo.GetByIdAsync(eligibility.FreeItemId.Value)
|
||||
?? throw new InvalidOperationException("Free item not found.");
|
||||
|
||||
var availableQty = (await stockRepo.GetBalancesByItemAsync(freeItem.Id)).Sum(b => b.Quantity);
|
||||
if (availableQty < eligibility.FreeQty.Value)
|
||||
throw new InvalidOperationException($"Insufficient stock of '{freeItem.Sku}' to fulfil the free-goods scheme.");
|
||||
|
||||
newOrderItems.Add(new OrderItem
|
||||
{
|
||||
FK_OrderId = orderId,
|
||||
FK_ItemId = freeItem.Id,
|
||||
Quantity = (int)eligibility.FreeQty.Value,
|
||||
UnitPrice = 0m
|
||||
});
|
||||
|
||||
redemption.FK_FreeItemId = freeItem.Id;
|
||||
redemption.FreeQty = eligibility.FreeQty;
|
||||
redemption.FreeItemCostValue = freeItem.CostPrice ?? 0m;
|
||||
}
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
if (newOrderItems.Count > 0) await orderRepo.AddItemsAsync(newOrderItems);
|
||||
await repo.AddRedemptionAsync(redemption);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var reloaded = (await repo.GetRedemptionsAsync(schemeId)).First(r => r.Id == redemption.Id);
|
||||
return ToRedemptionResponse(reloaded);
|
||||
}
|
||||
|
||||
// ── trackSchemeSpend(schemeId) ───────────────────────────────────────────
|
||||
|
||||
public async Task<SchemeSpendResponse?> GetSpendAsync(Guid schemeId)
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(schemeId);
|
||||
if (scheme is null) return null;
|
||||
|
||||
var redemptions = await repo.GetRedemptionsAsync(schemeId);
|
||||
var discountSpend = redemptions.Sum(r => r.DiscountAmount ?? 0m);
|
||||
var freeGoodsSpend = redemptions.Sum(r => (r.FreeItemCostValue ?? 0m) * (r.FreeQty ?? 0m));
|
||||
|
||||
return new SchemeSpendResponse(schemeId, discountSpend, freeGoodsSpend, discountSpend + freeGoodsSpend, redemptions.Count);
|
||||
}
|
||||
|
||||
// ── getSchemeROI(schemeId) ───────────────────────────────────────────────
|
||||
|
||||
public async Task<SchemeROIResponse?> GetROIAsync(Guid schemeId)
|
||||
{
|
||||
var scheme = await repo.GetByIdAsync(schemeId);
|
||||
if (scheme is null) return null;
|
||||
|
||||
var redemptions = await repo.GetRedemptionsAsync(schemeId);
|
||||
var spend = await GetSpendAsync(schemeId);
|
||||
|
||||
var actualSales = redemptions
|
||||
.Where(r => r.Order is not null)
|
||||
.Select(r => r.Order!)
|
||||
.Distinct()
|
||||
.Sum(o => o.TotalAmount);
|
||||
|
||||
var baseline = scheme.BaselineExpectedSales ?? 0m;
|
||||
var incremental = actualSales - baseline;
|
||||
decimal? roi = spend!.TotalSpend > 0 ? incremental / spend.TotalSpend : null;
|
||||
|
||||
return new SchemeROIResponse(schemeId, baseline, actualSales, incremental, spend.TotalSpend, roi);
|
||||
}
|
||||
|
||||
private async Task ValidateFreeGoodsRuleAsync(SchemeRuleType ruleType, Guid? triggerItemId, int? triggerQty, Guid? freeItemId, decimal? freeQty)
|
||||
{
|
||||
if (ruleType is not (SchemeRuleType.BuyXGetY or SchemeRuleType.FreeBundle))
|
||||
throw new InvalidOperationException($"Free-goods rule only applies to BuyXGetY or FreeBundle schemes, not '{ruleType}'.");
|
||||
|
||||
if (ruleType == SchemeRuleType.BuyXGetY)
|
||||
{
|
||||
if (triggerItemId is null || triggerQty is null or <= 0)
|
||||
throw new InvalidOperationException("BuyXGetY requires TriggerItemId and a positive TriggerQty.");
|
||||
if (await itemRepo.GetByIdAsync(triggerItemId.Value) is null)
|
||||
throw new InvalidOperationException("Trigger item not found.");
|
||||
}
|
||||
|
||||
if (freeItemId is null || freeQty is null or <= 0)
|
||||
throw new InvalidOperationException("FreeItemId and a positive FreeQty are required.");
|
||||
if (await itemRepo.GetByIdAsync(freeItemId.Value) is null)
|
||||
throw new InvalidOperationException("Free item not found.");
|
||||
}
|
||||
|
||||
private static void ValidatePercentRuleConfig(SchemeRuleType ruleType, string? ruleConfig)
|
||||
{
|
||||
if (ruleType is not (SchemeRuleType.SlabDiscount or SchemeRuleType.FlatPercent)) return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ruleConfig))
|
||||
throw new InvalidOperationException($"RuleConfig is required for RuleType '{ruleType}'.");
|
||||
|
||||
try
|
||||
{
|
||||
if (ruleType == SchemeRuleType.SlabDiscount)
|
||||
{
|
||||
var slab = JsonSerializer.Deserialize<SlabDiscountConfig>(ruleConfig, JsonOptions) ?? throw new JsonException();
|
||||
if (slab.Tiers.Count == 0)
|
||||
throw new InvalidOperationException("SlabDiscount scheme requires at least one tier.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = JsonSerializer.Deserialize<FlatPercentConfig>(ruleConfig, JsonOptions) ?? throw new JsonException();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new InvalidOperationException($"RuleConfig is not valid JSON for RuleType '{ruleType}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
using Distribution.Features.Scheme;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Store;
|
||||
|
||||
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess)
|
||||
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService)
|
||||
{
|
||||
private const int GlobalScopeRoleLevel = 50;
|
||||
private const int CreditApproverRoleLevel = 20;
|
||||
@@ -217,8 +218,10 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
||||
var store = await repo.GetByIdAsync(id);
|
||||
if (store is null || !store.IsActive) return null;
|
||||
if (!await HasStoreScopeAsync(id)) return null;
|
||||
// Wired to Step 17 (Promotions & Schemes) once scheme rules exist.
|
||||
return new SchemeEligibilityResponse(true, null);
|
||||
|
||||
var result = await schemeService.CheckStoreScopeEligibilityAsync(id, schemeId);
|
||||
if (result is null) return null;
|
||||
return new SchemeEligibilityResponse(result.Value.Eligible, result.Value.Reason);
|
||||
}
|
||||
|
||||
public async Task<StoreDashboardResponse?> GetDashboardAsync(Guid id, string period)
|
||||
|
||||
-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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
+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
@@ -18,7 +18,17 @@ namespace Distribution.Models
|
||||
CustomerReturn,
|
||||
Transfer,
|
||||
Damage,
|
||||
Adjustment
|
||||
Adjustment,
|
||||
Load
|
||||
}
|
||||
|
||||
public enum LoadingSheetStatus
|
||||
{
|
||||
Pending,
|
||||
DispatchConfirmed,
|
||||
ReceiptConfirmed,
|
||||
DiscrepancyFlagged,
|
||||
Resolved
|
||||
}
|
||||
|
||||
public enum RideType { Delivery, Sale }
|
||||
|
||||
@@ -26,6 +26,9 @@ namespace Distribution.Models
|
||||
|
||||
public decimal? SalePrice { get; set; }
|
||||
|
||||
/// <summary>Cost/purchase price, used to value free-goods scheme redemptions for getSchemeROI.</summary>
|
||||
public decimal? CostPrice { get; set; }
|
||||
|
||||
public SyncSource SyncSource { get; set; } = SyncSource.Manual;
|
||||
public int? ExternalErpItemId { get; set; }
|
||||
public DateTime? LastSyncedAt { get; set; }
|
||||
|
||||
@@ -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,62 @@
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public enum SchemeRuleType
|
||||
{
|
||||
BuyXGetY,
|
||||
SlabDiscount,
|
||||
FlatPercent,
|
||||
FreeBundle
|
||||
}
|
||||
|
||||
public class Scheme : Template
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public SchemeRuleType RuleType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON payload for percentage-based rules only (SlabDiscount tiers, FlatPercent). Free-goods
|
||||
/// rules (BuyXGetY / FreeBundle) are structured fields/child rows below, not JSON, so they can
|
||||
/// be managed through their own CRUD endpoints instead of a hand-edited blob.
|
||||
/// </summary>
|
||||
public string? RuleConfig { get; set; }
|
||||
|
||||
// ── BuyXGetY: trigger item/qty; shares FK_FreeItemId/FreeQty below as the payout. ──────────
|
||||
|
||||
public Guid? FK_TriggerItemId { get; set; }
|
||||
public Item? TriggerItem { get; set; }
|
||||
public int? TriggerQty { get; set; }
|
||||
|
||||
// ── Free-goods payout, used by both BuyXGetY and FreeBundle. ─────────────────────────────
|
||||
|
||||
public Guid? FK_FreeItemId { get; set; }
|
||||
public Item? FreeItem { get; set; }
|
||||
public decimal? FreeQty { get; set; }
|
||||
|
||||
/// <summary>Required component items for a FreeBundle scheme — managed via its own CRUD.</summary>
|
||||
public ICollection<SchemeBundleComponent> BundleComponents { get; set; } = [];
|
||||
|
||||
public Guid? FK_RegionId { get; set; }
|
||||
public Region? Region { get; set; }
|
||||
|
||||
/// <summary>Store segment this scheme applies to (Gold/Silver/Key Account/...); null = all segments.</summary>
|
||||
public string? EligibleSegment { get; set; }
|
||||
|
||||
public DateTime DateRangeFrom { get; set; }
|
||||
public DateTime DateRangeTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, this scheme's discount is exempt from the per-role discount ceiling in
|
||||
/// 08_sales_agent_controls.md and stacks on top of it unconditionally.
|
||||
/// </summary>
|
||||
public bool AgentLimitExempt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Manual baseline (expected sales with no scheme running) used by getSchemeROI. Per the
|
||||
/// open question in docs/17_promotions_schemes.md, this is entered at scheme creation
|
||||
/// rather than computed from a trailing-period average.
|
||||
/// </summary>
|
||||
public decimal? BaselineExpectedSales { get; set; }
|
||||
|
||||
public ICollection<SchemeRedemption> Redemptions { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
/// <summary>A required item+qty line of a FreeBundle scheme (e.g. "buy A + B, get C free").</summary>
|
||||
public class SchemeBundleComponent
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid FK_SchemeId { get; set; }
|
||||
[ForeignKey("FK_SchemeId")]
|
||||
public Scheme Scheme { get; set; }
|
||||
|
||||
public Guid FK_ItemId { get; set; }
|
||||
[ForeignKey("FK_ItemId")]
|
||||
public Item Item { get; set; }
|
||||
|
||||
public int Qty { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class SchemeRedemption
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid FK_SchemeId { get; set; }
|
||||
[ForeignKey("FK_SchemeId")]
|
||||
public Scheme Scheme { get; set; }
|
||||
|
||||
public Guid? FK_OrderId { get; set; }
|
||||
[ForeignKey("FK_OrderId")]
|
||||
public Order? Order { get; set; }
|
||||
|
||||
/// <summary>Placeholder for the future field-invoice module — no FieldInvoice entity exists yet.</summary>
|
||||
public Guid? FieldInvoiceId { get; set; }
|
||||
|
||||
public decimal? DiscountAmount { get; set; }
|
||||
|
||||
public Guid? FK_FreeItemId { get; set; }
|
||||
[ForeignKey("FK_FreeItemId")]
|
||||
public Item? FreeItem { get; set; }
|
||||
|
||||
public decimal? FreeQty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cost price of the free item snapshotted at redemption time, so getSchemeROI stays
|
||||
/// accurate even after the item's cost price later changes.
|
||||
/// </summary>
|
||||
public decimal? FreeItemCostValue { get; set; }
|
||||
|
||||
public DateTime RedeemedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -9,9 +9,11 @@ using Distribution.Features.Item;
|
||||
using Distribution.Features.Order;
|
||||
using Distribution.Features.PriceList;
|
||||
using Distribution.Features.Region;
|
||||
using Distribution.Features.LoadingEvent;
|
||||
using Distribution.Features.Ride;
|
||||
using Distribution.Features.Role;
|
||||
using Distribution.Features.Route;
|
||||
using Distribution.Features.Scheme;
|
||||
using Distribution.Features.Store;
|
||||
using Distribution.Features.Store.Kyc;
|
||||
using Distribution.Features.Supplier;
|
||||
@@ -56,9 +58,11 @@ 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<RegionService>();
|
||||
builder.Services.AddScoped<RouteService>();
|
||||
@@ -81,6 +85,8 @@ 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.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.
|
||||
|
||||
@@ -21,12 +21,18 @@ gap #8 in [00_existing_gaps.md](./00_existing_gaps.md). Credit fields exist info
|
||||
`OneTimeAmount` / `CreditAmount`, which stay as-is; this step adds the real credit-limit +
|
||||
outstanding-balance + KYC-gated-ordering flow on top.
|
||||
|
||||
`getStoreVisitHistory` and `checkStoreSchemeEligibility` are read-only integration points into
|
||||
modules that don't exist yet ([11_route_sales_management.md](./11_route_sales_management.md) for
|
||||
visits, [17_promotions_schemes.md](./17_promotions_schemes.md) for schemes). Implement them now
|
||||
with the query shape defined below; wire the actual join once those modules land — return an empty
|
||||
list / `eligible: true` (no active schemes to check) until then, guarded by a `NotImplementedYet`
|
||||
style short-circuit, not a broken query.
|
||||
`getStoreVisitHistory` and `checkStoreSchemeEligibility` were originally read-only integration
|
||||
points into modules that didn't exist yet ([11_route_sales_management.md](./11_route_sales_management.md)
|
||||
for visits, [17_promotions_schemes.md](./17_promotions_schemes.md) for schemes) — both shipped as
|
||||
placeholders (empty list / `eligible: true`) guarded by a `NotImplementedYet`-style short-circuit.
|
||||
|
||||
**Update (2026-08-21, after 17_promotions_schemes.md landed):** `checkStoreSchemeEligibility` is now
|
||||
wired for real. `StoreService.CheckSchemeEligibilityAsync` calls
|
||||
`SchemeService.CheckStoreScopeEligibilityAsync(storeId, schemeId)`, which checks the scheme's
|
||||
region/segment/date-range scope against the store (no order context, since this endpoint takes no
|
||||
`orderId` — that's a *scope* eligibility check, not the full order-line rule evaluation that
|
||||
`GET /api/schemes/eligible?storeId=...&orderId=...` does). `getStoreVisitHistory` is still an empty
|
||||
list — [11_route_sales_management.md](./11_route_sales_management.md) remains Not Started.
|
||||
|
||||
---
|
||||
|
||||
@@ -102,7 +108,7 @@ One migration needed:
|
||||
| GET | /api/stores/{id}/outstanding-balance | stores.view | Returns cached value + CreditLimit + available credit |
|
||||
| PUT | /api/stores/{id}/segment | stores.update | Body: `{ "segment": "Gold" }` |
|
||||
| GET | /api/stores/{id}/visit-history | stores.view | Paginated. Empty list until Step 11 wiring lands |
|
||||
| GET | /api/stores/{id}/schemes/{schemeId}/eligibility | stores.view | Returns `{ eligible: true, reason: null }` until Step 17 wiring lands |
|
||||
| GET | /api/stores/{id}/schemes/{schemeId}/eligibility | stores.view | Wired to Step 17 — returns real region/segment/date-range eligibility for the store against that scheme |
|
||||
| GET | /api/stores/{id}/dashboard?period=... | stores.view | Aggregates order count/value for the period from existing Order tables |
|
||||
|
||||
---
|
||||
@@ -206,7 +212,9 @@ Features/
|
||||
StoreController.cs (extended — credit-limit, balance, kyc/verify, segment,
|
||||
visit-history, scheme-eligibility, dashboard endpoints)
|
||||
StoreDTOs.cs (extended — new request/response records)
|
||||
StoreService.cs (extended — new methods, RoleLevel >= 20 credit-approver check)
|
||||
StoreService.cs (extended — new methods, RoleLevel >= 20 credit-approver check;
|
||||
CheckSchemeEligibilityAsync now depends on Features/Scheme's
|
||||
SchemeService for the real region/segment/date-range check)
|
||||
IStoreRepository.cs (extended — GetOrdersForDashboardAsync)
|
||||
StoreRepository.cs (extended)
|
||||
Kyc/
|
||||
|
||||
@@ -1,70 +1,378 @@
|
||||
# 17 — Promotions & Trade Scheme Management
|
||||
|
||||
**Status**: Not Started
|
||||
**Depends On**: 01 — Item Catalog, 14 — Pricing & Supplier Master Data
|
||||
**Status**: Done — migrations applied to the database
|
||||
**Depends On**: 01 — Item Catalog (Done), 14 — Pricing & Supplier Master Data (Done), 02 — Stock Ledger (Done)
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Covers [mainPlan.md §11](./mainPlan.md#11-promotions--trade-scheme-management)
|
||||
in full — an entirely new subsystem. Once built, `applyPricingAndDiscounts` (§1) and
|
||||
in full — an entirely new subsystem. `applyPricingAndDiscounts` (§1) and
|
||||
`checkStoreSchemeEligibility` (§4) both read from it.
|
||||
|
||||
---
|
||||
|
||||
## Background / Context
|
||||
|
||||
No scheme/promotion entity exists today. Every reference to "eligible discount" elsewhere in this
|
||||
doc set (order pricing, field invoices, agent discount limits) currently has no scheme data source
|
||||
— it only enforces the flat per-role discount ceiling from
|
||||
No scheme/promotion entity existed before this step. Every reference to "eligible discount"
|
||||
elsewhere in this doc set (order pricing, field invoices, agent discount limits) had no scheme data
|
||||
source — it only enforced the flat per-role discount ceiling from
|
||||
[08_sales_agent_controls.md](./08_sales_agent_controls.md).
|
||||
|
||||
For an enterprise distributor, free-goods/bundle promotions ("buy 10 get 1 free", "buy A + B get C
|
||||
free") are operationally different from discount schemes because they move physical stock at zero
|
||||
revenue, not just reduce a price. That distinction shaped the data model below: `BuyXGetY` and
|
||||
`FreeBundle` rules are structured columns/child rows (with their own CRUD), not a JSON blob, so a
|
||||
free-goods rule can be validated (item exists, positive qty, stock available) the same way any other
|
||||
order line is. `SlabDiscount`/`FlatPercent` stay JSON in `RuleConfig` since they're pure
|
||||
percentage-off rules with no inventory or fulfilment side effects.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Scheme
|
||||
```
|
||||
Scheme
|
||||
├── Id (Guid) — PK, from Template
|
||||
├── Name (string)
|
||||
├── RuleType (enum) — BuyXGetY, SlabDiscount, FlatPercent, FreeBundle
|
||||
├── RuleConfig (string?) — JSON, only used by SlabDiscount/FlatPercent (see below); null
|
||||
│ for BuyXGetY/FreeBundle
|
||||
├── FK_TriggerItemId (Guid?) — BuyXGetY only: the item the store must buy
|
||||
├── TriggerQty (int?) — BuyXGetY only: qty of TriggerItem required to earn one payout
|
||||
├── FK_FreeItemId (Guid?) — shared payout for BuyXGetY and FreeBundle: item given free
|
||||
├── FreeQty (decimal?) — shared payout: qty of FreeItem given free
|
||||
├── FK_RegionId (Guid?) — nullable, null = all regions
|
||||
├── EligibleSegment (string?) — Store.Segment match (Gold/Silver/Key Account/...); null = all segments
|
||||
├── DateRangeFrom (DateTime)
|
||||
├── DateRangeTo (DateTime)
|
||||
├── AgentLimitExempt (bool) — if true, this scheme's discount stacks on top of the per-role
|
||||
│ discount ceiling (08_sales_agent_controls.md) unconditionally
|
||||
├── BaselineExpectedSales (decimal?) — manual baseline used by getSchemeROI (see Open Questions)
|
||||
├── IsActive, CreatedAt — from Template
|
||||
```
|
||||
|
||||
`Name`, `RuleType` (`BuyXGetY`, `SlabDiscount`, `FlatPercent`), `RuleConfig` (JSON), `Eligibility`
|
||||
(store segment / region scope), `DateRangeFrom`, `DateRangeTo`.
|
||||
`RuleConfig` JSON shape (SlabDiscount/FlatPercent only):
|
||||
- `FlatPercent` — `{ "discountPercent": 10 }`
|
||||
- `SlabDiscount` — `{ "tiers": [{ "minOrderAmount": 5000, "discountPercent": 5 }, ...] }`, the
|
||||
highest tier whose `minOrderAmount` the order total meets or exceeds wins.
|
||||
|
||||
### Scheme Redemption
|
||||
### SchemeBundleComponent
|
||||
```
|
||||
SchemeBundleComponent
|
||||
├── Id (Guid) — PK
|
||||
├── FK_SchemeId (Guid) — required, cascade delete with parent Scheme
|
||||
├── FK_ItemId (Guid) — required
|
||||
├── Qty (int) — required qty of this item for the FreeBundle rule to trigger
|
||||
```
|
||||
Unique constraint on `(SchemeId, ItemId)`. Only meaningful when `Scheme.RuleType = FreeBundle` — the
|
||||
order must contain every component in at least its listed `Qty` before the scheme's
|
||||
`FK_FreeItemId`/`FreeQty` payout is granted.
|
||||
|
||||
`SchemeId`, `OrderId` (or `FieldInvoiceId`), `DiscountAmount`, `RedeemedAt` — feeds
|
||||
`trackSchemeSpend` and `getSchemeROI` without re-scanning every order.
|
||||
### SchemeRedemption
|
||||
```
|
||||
SchemeRedemption
|
||||
├── Id (Guid) — PK
|
||||
├── FK_SchemeId (Guid) — required
|
||||
├── FK_OrderId (Guid?) — nullable (see FieldInvoiceId below)
|
||||
├── FieldInvoiceId (Guid?) — placeholder for the future field-invoice module; no FieldInvoice
|
||||
│ entity exists yet, so this is an unconstrained Guid for now
|
||||
├── DiscountAmount (decimal?) — set for SlabDiscount/FlatPercent redemptions
|
||||
├── FK_FreeItemId (Guid?) — set for BuyXGetY/FreeBundle redemptions
|
||||
├── FreeQty (decimal?)
|
||||
├── FreeItemCostValue (decimal?) — free item's Item.CostPrice snapshotted at redemption time, so
|
||||
│ getSchemeROI/trackSchemeSpend stay accurate after CostPrice
|
||||
│ later changes
|
||||
├── RedeemedAt (DateTime)
|
||||
```
|
||||
At least one of `DiscountAmount` or `FreeQty` is set per redemption; `Scheme`/`Order` restrict-delete
|
||||
(a scheme or order with redemptions can't be hard-deleted, and soft-delete leaves history intact).
|
||||
|
||||
### Item (extended)
|
||||
```
|
||||
Item
|
||||
├── ...
|
||||
├── CostPrice (decimal?) — new: cost/purchase price, used to value free-goods redemptions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migrations
|
||||
|
||||
Two migrations:
|
||||
1. **AddPromotionsSchemes** (applied) — creates `Schemes`, `SchemeRedemptions`; adds `Item.CostPrice`.
|
||||
2. **AddSchemeFreeGoodsRuleAndBundleComponents** (applied) — makes `Scheme.RuleConfig` nullable, adds
|
||||
`Scheme.FK_TriggerItemId`/`TriggerQty`/`FK_FreeItemId`/`FreeQty`, creates `SchemeBundleComponents`.
|
||||
|
||||
(Split into two because the free-goods columns were promoted from a JSON blob to structured
|
||||
columns/child table after the first migration had already been applied to the dev database.)
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Function reference | Endpoint intent |
|
||||
|---|---|
|
||||
| `createScheme(schemeRules, eligibility, dateRange)` | `POST /api/schemes` |
|
||||
| `evaluateSchemeEligibility(storeId, orderId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` |
|
||||
| `applySchemeDiscount(orderId, schemeId)` | `POST /api/orders/{id}/apply-scheme` |
|
||||
| `trackSchemeSpend(schemeId)` | `GET /api/schemes/{id}/spend` |
|
||||
| `getSchemeROI(schemeId)` | `GET /api/schemes/{id}/roi` |
|
||||
| Function reference | Endpoint | Notes |
|
||||
|---|---|---|
|
||||
| `createScheme(schemeRules, eligibility, dateRange)` | `POST /api/schemes` | `RuleConfig` required only for SlabDiscount/FlatPercent; `TriggerItemId`/`TriggerQty`/`FreeItemId`/`FreeQty`/`BundleComponents` can be supplied inline to fully create a BuyXGetY/FreeBundle scheme in one call |
|
||||
| — | `GET /api/schemes` | Filterable by region |
|
||||
| — | `GET /api/schemes/{id}` | Includes trigger/free item, bundle components |
|
||||
| — | `PUT /api/schemes/{id}` | Updates name/eligibility/date range/`RuleConfig` |
|
||||
| — | `DELETE /api/schemes/{id}` | Soft delete |
|
||||
| — | `PUT /api/schemes/{id}/free-goods-rule` | Sets the BuyXGetY trigger + shared free-goods payout (also the payout for FreeBundle) |
|
||||
| — | `DELETE /api/schemes/{id}/free-goods-rule` | Clears it |
|
||||
| — | `PUT /api/schemes/{id}/bundle-components/{itemId}` | Upserts one FreeBundle required-item row |
|
||||
| — | `DELETE /api/schemes/{id}/bundle-components/{itemId}` | Removes one FreeBundle required-item row |
|
||||
| `evaluateSchemeEligibility(storeId, orderId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` | Returns eligibility for every in-scope scheme, not just one |
|
||||
| `applySchemeDiscount(orderId, schemeId)` | `POST /api/orders/{id}/apply-scheme` | Adds a zero-price order line for free-goods payouts |
|
||||
| `trackSchemeSpend(schemeId)` | `GET /api/schemes/{id}/spend` | Discount spend + free-goods cost spend |
|
||||
| `getSchemeROI(schemeId)` | `GET /api/schemes/{id}/roi` | Uses `BaselineExpectedSales` |
|
||||
|
||||
---
|
||||
|
||||
## Request / Response Shapes
|
||||
|
||||
### POST `/api/schemes` (BuyXGetY — created fully populated in one call)
|
||||
```json
|
||||
{
|
||||
"name": "Soap Buy10Get1",
|
||||
"ruleType": "BuyXGetY",
|
||||
"ruleConfig": null,
|
||||
"regionId": null,
|
||||
"eligibleSegment": "Gold",
|
||||
"dateRangeFrom": "2026-08-01T00:00:00",
|
||||
"dateRangeTo": "2026-08-31T23:59:59",
|
||||
"agentLimitExempt": false,
|
||||
"baselineExpectedSales": 50000,
|
||||
"triggerItemId": "<soapItemId>",
|
||||
"triggerQty": 10,
|
||||
"freeItemId": "<soapItemId>",
|
||||
"freeQty": 1
|
||||
}
|
||||
```
|
||||
`triggerItemId`/`triggerQty`/`freeItemId`/`freeQty` are optional on create — omit them for a plain
|
||||
SlabDiscount/FlatPercent scheme, or omit and set them later via `PUT .../free-goods-rule` if you want
|
||||
to create the shell first and configure the payout afterwards.
|
||||
|
||||
### POST `/api/schemes` (FreeBundle — created fully populated in one call)
|
||||
```json
|
||||
{
|
||||
"name": "Soap+Shampoo -> Free Toothbrush",
|
||||
"ruleType": "FreeBundle",
|
||||
"ruleConfig": null,
|
||||
"regionId": null,
|
||||
"eligibleSegment": null,
|
||||
"dateRangeFrom": "2026-08-01T00:00:00",
|
||||
"dateRangeTo": "2026-08-31T23:59:59",
|
||||
"agentLimitExempt": false,
|
||||
"baselineExpectedSales": null,
|
||||
"freeItemId": "<toothbrushId>",
|
||||
"freeQty": 1,
|
||||
"bundleComponents": [ { "itemId": "<soapId>", "qty": 1 }, { "itemId": "<shampooId>", "qty": 1 } ]
|
||||
}
|
||||
```
|
||||
`triggerItemId`/`triggerQty` are ignored for FreeBundle — the required items come from
|
||||
`bundleComponents` instead.
|
||||
|
||||
### PUT `/api/schemes/{id}/free-goods-rule`
|
||||
```json
|
||||
{ "triggerItemId": "<soapItemId>", "triggerQty": 10, "freeItemId": "<soapItemId>", "freeQty": 1 }
|
||||
```
|
||||
Same shape as the inline create fields — use this to add or change the free-goods payout on a
|
||||
scheme that was created without one.
|
||||
|
||||
### PUT `/api/schemes/{id}/bundle-components/{itemId}` (FreeBundle)
|
||||
```json
|
||||
{ "qty": 1 }
|
||||
```
|
||||
Use this to add/edit one bundle component after creation.
|
||||
|
||||
### POST `/api/schemes` (SlabDiscount)
|
||||
```json
|
||||
{
|
||||
"name": "Volume Discount",
|
||||
"ruleType": "SlabDiscount",
|
||||
"ruleConfig": "{\"tiers\":[{\"minOrderAmount\":5000,\"discountPercent\":5},{\"minOrderAmount\":10000,\"discountPercent\":8}]}",
|
||||
"regionId": null,
|
||||
"eligibleSegment": null,
|
||||
"dateRangeFrom": "2026-08-01T00:00:00",
|
||||
"dateRangeTo": "2026-08-31T23:59:59",
|
||||
"agentLimitExempt": false,
|
||||
"baselineExpectedSales": null
|
||||
}
|
||||
```
|
||||
|
||||
### POST `/api/orders/{id}/apply-scheme`
|
||||
```json
|
||||
{ "schemeId": "..." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Business Rules
|
||||
|
||||
- A scheme discount stacks with, but does not bypass, the per-role discount ceiling in
|
||||
[08_sales_agent_controls.md](./08_sales_agent_controls.md) unless explicitly flagged as an
|
||||
agent-limit-exempt scheme.
|
||||
- `getSchemeROI` requires a baseline (pre-scheme expected sales) to compare against — confirm data
|
||||
source before implementing (see Open Questions).
|
||||
1. A scheme discount stacks with, but does not bypass, the per-role discount ceiling in
|
||||
[08_sales_agent_controls.md](./08_sales_agent_controls.md) unless `AgentLimitExempt = true`.
|
||||
(Enforcement against the actual ceiling is deferred — 08_sales_agent_controls.md's discount
|
||||
ceiling isn't implemented in code yet; this is recorded as the target behavior.)
|
||||
2. `evaluateSchemeEligibility` matches a scheme to a store/order only if: the scheme's `RegionId` is
|
||||
null or matches the store's region; `EligibleSegment` is null or matches `Store.Segment`; and the
|
||||
order's date falls within `[DateRangeFrom, DateRangeTo]`.
|
||||
3. `BuyXGetY` eligibility: the order must contain at least `TriggerQty` of `TriggerItem`; the payout
|
||||
scales with how many multiples of `TriggerQty` were bought (`FreeQty × floor(boughtQty / TriggerQty)`).
|
||||
4. `FreeBundle` eligibility: the order must contain every `SchemeBundleComponent` in at least its
|
||||
listed `Qty`; payout is the flat `FreeQty` of `FreeItem` (no multiplier).
|
||||
5. `applySchemeDiscount` adds a zero-price `OrderItem` line for the free-goods payout (`UnitPrice = 0`,
|
||||
`Quantity = FreeQty`), rather than only recording a discount amount — the free line still needs to
|
||||
be picked, delivered, and stock-tracked like any other sold line.
|
||||
6. A free-goods redemption is blocked if the free item's available `StockBalance` (summed across all
|
||||
locations) is less than the payout `FreeQty` — same out-of-stock guard as a normal sold line.
|
||||
7. A scheme can only be redeemed once per order (`HasRedemptionAsync` check in `applySchemeDiscount`).
|
||||
8. `SchemeRedemption.FreeItemCostValue` snapshots `Item.CostPrice` at redemption time, so
|
||||
`trackSchemeSpend`/`getSchemeROI` stay accurate even after the item's cost price later changes.
|
||||
9. Free goods are typically still a taxable "deemed supply" at zero net value under GST/VAT-style
|
||||
regimes — not yet wired into a tax calculation, since no tax subsystem exists in this codebase
|
||||
yet. Flagged for whoever builds tax/invoicing.
|
||||
10. `trackSchemeSpend` = sum(`DiscountAmount`) + sum(`FreeItemCostValue × FreeQty`) across all
|
||||
redemptions. `getSchemeROI` = `(actualSales − BaselineExpectedSales) / totalSpend`, where
|
||||
`actualSales` is the sum of `Order.TotalAmount` for every distinct order that redeemed the scheme.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- What counts as "incremental sales" for `getSchemeROI` — a manual baseline entered when the scheme
|
||||
is created, or a trailing-period average computed automatically?
|
||||
- What counts as "incremental sales" for `getSchemeROI` — **resolved for v1**: a manual
|
||||
`BaselineExpectedSales` entered at scheme creation, not a trailing-period average. Revisit if
|
||||
historical order data volume later makes an automatic trailing average preferable.
|
||||
- Per-role discount ceiling stacking (`AgentLimitExempt`) is unenforced pending
|
||||
[08_sales_agent_controls.md](./08_sales_agent_controls.md) having an actual ceiling implementation
|
||||
to stack against.
|
||||
- Deemed-supply tax treatment for free goods — deferred until a tax/invoicing subsystem exists.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
> Fill this section in during planning discussion before writing any code.
|
||||
### File structure created / touched
|
||||
```
|
||||
Features/
|
||||
Scheme/
|
||||
SchemeController.cs (also hosts SchemeOrderController for POST /api/orders/{id}/apply-scheme)
|
||||
SchemeDTOs.cs
|
||||
SchemeService.cs (EvaluateRule — the core eligibility/payout calculator; ApplySchemeAsync
|
||||
— the free-goods order-line + stock-check + redemption write path)
|
||||
ISchemeRepository.cs
|
||||
SchemeRepository.cs
|
||||
|
||||
Models/
|
||||
Scheme.cs (new — includes SchemeRuleType enum)
|
||||
SchemeBundleComponent.cs (new)
|
||||
SchemeRedemption.cs (new)
|
||||
Item.cs (extended — CostPrice)
|
||||
|
||||
Data/AppDBContext.cs
|
||||
add Schemes, SchemeRedemptions, SchemeBundleComponents DbSets; relationships (Scheme→TriggerItem/
|
||||
FreeItem restrict-delete, SchemeBundleComponent→Scheme cascade-delete, →Item restrict-delete,
|
||||
SchemeRedemption→Scheme/Order/FreeItem restrict-delete); unique index on
|
||||
SchemeBundleComponent(FK_SchemeId, FK_ItemId)
|
||||
|
||||
Migrations/
|
||||
AddPromotionsSchemes (applied)
|
||||
AddSchemeFreeGoodsRuleAndBundleComponents (applied)
|
||||
|
||||
Program.cs
|
||||
register ISchemeRepository/SchemeRepository, SchemeService
|
||||
```
|
||||
|
||||
### Eligibility + payout logic (pseudo)
|
||||
```
|
||||
EvaluateRule(scheme, orderItems):
|
||||
switch scheme.RuleType:
|
||||
BuyXGetY:
|
||||
boughtQty = sum(orderItems where ItemId = scheme.TriggerItemId).Quantity
|
||||
if boughtQty < scheme.TriggerQty: not eligible
|
||||
multiplier = boughtQty / scheme.TriggerQty // integer division
|
||||
→ eligible, FreeItemId = scheme.FreeItemId, FreeQty = scheme.FreeQty * multiplier
|
||||
|
||||
FreeBundle:
|
||||
missing = scheme.BundleComponents where sum(orderItems qty for that item) < component.Qty
|
||||
if missing.any(): not eligible
|
||||
→ eligible, FreeItemId = scheme.FreeItemId, FreeQty = scheme.FreeQty
|
||||
|
||||
FlatPercent / SlabDiscount:
|
||||
parse RuleConfig JSON, compute DiscountAmount off orderItems total
|
||||
```
|
||||
|
||||
### applySchemeDiscount flow (pseudo)
|
||||
```
|
||||
ApplySchemeAsync(orderId, schemeId):
|
||||
reject if scheme already redeemed against this order
|
||||
eligibility = EvaluateRule(scheme, order's items)
|
||||
reject if not eligible
|
||||
|
||||
if eligibility has a free-goods payout:
|
||||
check StockBalance across all locations >= FreeQty, else reject
|
||||
add OrderItem { ItemId = FreeItemId, Quantity = FreeQty, UnitPrice = 0 }
|
||||
redemption.FreeItemCostValue = freeItem.CostPrice ?? 0
|
||||
|
||||
save SchemeRedemption { DiscountAmount, FreeItemId, FreeQty, FreeItemCostValue }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Worked Example
|
||||
|
||||
Item `Soap-500ml` has `CostPrice = 12`. Store is in the "Gold" segment.
|
||||
|
||||
**Create the scheme (one call, fully populated):**
|
||||
```
|
||||
POST /api/schemes
|
||||
{ "name": "Soap Buy10Get1", "ruleType": "BuyXGetY", "ruleConfig": null,
|
||||
"regionId": null, "eligibleSegment": "Gold",
|
||||
"dateRangeFrom": "2026-08-01", "dateRangeTo": "2026-08-31",
|
||||
"agentLimitExempt": false, "baselineExpectedSales": 50000,
|
||||
"triggerItemId": "<soapId>", "triggerQty": 10, "freeItemId": "<soapId>", "freeQty": 1 }
|
||||
→ Scheme { Id, ... }
|
||||
```
|
||||
|
||||
**Store places an order** for 20 units of `Soap-500ml` → `Order { Id, TotalAmount }`.
|
||||
|
||||
**Check eligibility:**
|
||||
```
|
||||
GET /api/schemes/eligible?storeId=...&orderId=...
|
||||
→ [{ "schemeId": "...", "eligible": true, "freeItemId": "<soapId>", "freeQty": 2 }]
|
||||
```
|
||||
20 ÷ 10 = 2 multiples → 2 free units.
|
||||
|
||||
**Apply it:**
|
||||
```
|
||||
POST /api/orders/{orderId}/apply-scheme
|
||||
{ "schemeId": "..." }
|
||||
```
|
||||
Adds an `OrderItem` line (`Soap-500ml`, qty 2, `UnitPrice = 0`) and a `SchemeRedemption` with
|
||||
`FreeQty = 2`, `FreeItemCostValue = 12`.
|
||||
|
||||
**Spend/ROI:**
|
||||
```
|
||||
GET /api/schemes/{id}/spend
|
||||
→ { "totalDiscountSpend": 0, "totalFreeGoodsCostSpend": 24, "totalSpend": 24, "redemptionCount": 1 }
|
||||
|
||||
GET /api/schemes/{id}/roi
|
||||
→ { "baselineExpectedSales": 50000, "actualSales": 62000, "incrementalSales": 12000,
|
||||
"totalSpend": 24, "roi": 500.0 }
|
||||
```
|
||||
|
||||
**FreeBundle variant** ("buy Soap + Shampoo, get a free Toothbrush") — also one call:
|
||||
```
|
||||
POST /api/schemes
|
||||
{ "name": "Soap+Shampoo -> Free Toothbrush", "ruleType": "FreeBundle", "ruleConfig": null,
|
||||
"regionId": null, "eligibleSegment": null,
|
||||
"dateRangeFrom": "2026-08-01", "dateRangeTo": "2026-08-31",
|
||||
"agentLimitExempt": false, "baselineExpectedSales": null,
|
||||
"freeItemId": "<toothbrushId>", "freeQty": 1,
|
||||
"bundleComponents": [ { "itemId": "<soapId>", "qty": 1 }, { "itemId": "<shampooId>", "qty": 1 } ] }
|
||||
```
|
||||
Eligibility now requires both Soap ≥1 and Shampoo ≥1 in the order; payout is 1 free toothbrush,
|
||||
flat (no multiplier). Components/payout can still be edited afterwards via
|
||||
`PUT .../bundle-components/{itemId}` and `PUT .../free-goods-rule`.
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
| Role Management | Done (read-only) | 6 roles seeded, no create/update via API |
|
||||
| Region / Route / Vehicle CRUD | Done | Full API surface with permission checks |
|
||||
| Store / Warehouse CRUD | Done | Full API surface with permission checks |
|
||||
| Store Management (Beyond CRUD) | Done | Credit limit, KYC, segment, dashboard, scheme eligibility; see [12_store_management.md](./12_store_management.md) |
|
||||
| Order CRUD | Done | Scope-aware (user only sees their assigned stores) |
|
||||
| Unit of Work + Repositories | Done | Transaction support throughout |
|
||||
| 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) |
|
||||
|
||||
## Known Gaps in Existing System (Pre-Work)
|
||||
|
||||
@@ -39,7 +41,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) |
|
||||
@@ -47,12 +49,12 @@ They must be resolved before new feature steps begin because later steps depend
|
||||
| 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) |
|
||||
| 10 | Region Management (Beyond CRUD) | Not Started | 00 | [10_region_management.md](./10_region_management.md) |
|
||||
| 11 | Route & Sales Visit Management | Not Started | 00 | [11_route_sales_management.md](./11_route_sales_management.md) |
|
||||
| 12 | Store Management (Beyond CRUD) | In Progress | 00 | [12_store_management.md](./12_store_management.md) |
|
||||
| 12 | Store Management (Beyond CRUD) | Done | 00 | [12_store_management.md](./12_store_management.md) |
|
||||
| 13 | Fleet Management (Beyond CRUD) | Not Started | 00 | [13_fleet_management.md](./13_fleet_management.md) |
|
||||
| 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) |
|
||||
| 16 | Sales Force Automation / CRM | Not Started | 12 | [16_sfa_crm.md](./16_sfa_crm.md) |
|
||||
| 17 | Promotions & Trade Scheme Management | Not Started | 01, 14 | [17_promotions_schemes.md](./17_promotions_schemes.md) |
|
||||
| 17 | Promotions & Trade Scheme Management | Done | 01, 14 | [17_promotions_schemes.md](./17_promotions_schemes.md) |
|
||||
| 18 | Returns & Claims Management | Not Started | 05, 06 | [18_returns_claims.md](./18_returns_claims.md) |
|
||||
| 19 | Finance & Accounting | Not Started | 05, 06, 18 | [19_finance_accounting.md](./19_finance_accounting.md) |
|
||||
| 20 | Reporting, Analytics & BI | Not Started | all prior | [20_reporting_analytics.md](./20_reporting_analytics.md) |
|
||||
@@ -83,7 +85,7 @@ here — see `SRS_User_Management_Module.pdf`.
|
||||
| `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. |
|
||||
| `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). |
|
||||
| `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 still depends on §11 Promotions below. |
|
||||
| `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. |
|
||||
| `updateOrderStatus(orderId, status)` | Transitions order through `pending → approved → picked → dispatched → delivered → invoiced`. | Not implemented — `Order` has no status field or state machine today. |
|
||||
@@ -132,7 +134,7 @@ here — see `SRS_User_Management_Module.pdf`.
|
||||
| `classifyStore(storeId, segment)` | Sets/updates the store's segment (key account, general trade, etc.). | Implemented - `PUT /api/stores/{id}/segment` updates the free-text `Store.Segment` field. |
|
||||
| `deactivateStore(storeId, reason)` | Soft-deletes a store, blocking new orders against it. | Partially implemented — `DELETE /api/stores/{id}` soft-deletes via `IsActive`, no reason code captured. |
|
||||
| `getStoreVisitHistory(storeId)` | Returns visit compliance history from Route Management. | Placeholder only - endpoint exists (`GET /api/stores/{id}/visit-history`) but currently returns an empty list until Route/Sales Visit Management lands. |
|
||||
| `checkStoreSchemeEligibility(storeId, schemeId)` | Evaluates whether a store qualifies for a given promotion. | Placeholder only - endpoint exists (`GET /api/stores/{id}/schemes/{schemeId}/eligibility`) but currently returns a default eligible response until Promotions is implemented. |
|
||||
| `checkStoreSchemeEligibility(storeId, schemeId)` | Evaluates whether a store qualifies for a given promotion. | Implemented — `GET /api/stores/{id}/schemes/{schemeId}/eligibility` now calls `SchemeService.CheckStoreScopeEligibilityAsync` (region/segment/date-range scope check, no order context). For full order-line rule evaluation against a specific order, use `evaluateSchemeEligibility(storeId, orderId)` instead (`GET /api/schemes/eligible?storeId=...&orderId=...`, §11). |
|
||||
| `getStoreSalesDashboard(storeId, period)` | Store-level sales and outstanding summary. | Partially implemented - `GET /api/stores/{id}/dashboard` aggregates order count, total order value, and last order date for the selected month. It does not yet include full sales/outstanding analytics from Finance. |
|
||||
|
||||
### 5. Vehicle / Fleet Management
|
||||
@@ -212,11 +214,11 @@ here — see `SRS_User_Management_Module.pdf`.
|
||||
|
||||
| Function | What it does | Status |
|
||||
|---|---|---|
|
||||
| `createScheme(schemeRules, eligibility, dateRange)` | Defines a promotion (buy-X-get-Y, slab discount, etc.). | Not implemented — no scheme entity exists. |
|
||||
| `evaluateSchemeEligibility(storeId, orderId)` | Checks whether an order qualifies for a scheme. | Not implemented. |
|
||||
| `applySchemeDiscount(orderId, schemeId)` | Applies the scheme's discount to order pricing. | Not implemented. |
|
||||
| `trackSchemeSpend(schemeId)` | Aggregates total discount value given under a scheme. | Not implemented. |
|
||||
| `getSchemeROI(schemeId)` | Compares scheme spend against incremental sales generated. | Not implemented. |
|
||||
| `createScheme(schemeRules, eligibility, dateRange)` | Defines a promotion (buy-X-get-Y, slab discount, etc.). | Implemented — `POST /api/schemes`. Supports `BuyXGetY`, `FreeBundle`, `SlabDiscount`, `FlatPercent`; free-goods trigger/payout and bundle components can be supplied inline in the same call. See [17_promotions_schemes.md](./17_promotions_schemes.md). |
|
||||
| `evaluateSchemeEligibility(storeId, orderId)` | Checks whether an order qualifies for a scheme. | Implemented — `GET /api/schemes/eligible?storeId=...&orderId=...`, returns eligibility (and computed discount/free-goods payout) for every in-scope scheme, not just one. |
|
||||
| `applySchemeDiscount(orderId, schemeId)` | Applies the scheme's discount to order pricing. | Implemented — `POST /api/orders/{id}/apply-scheme`. For free-goods schemes this adds a zero-price `OrderItem` line and checks `StockBalance` availability first; not yet auto-invoked from `createOrder`/`applyPricingAndDiscounts` (§1) — caller applies it explicitly after order creation. |
|
||||
| `trackSchemeSpend(schemeId)` | Aggregates total discount value given under a scheme. | Implemented — `GET /api/schemes/{id}/spend`, sums `DiscountAmount` plus free-goods cost (`FreeItemCostValue × FreeQty`) across redemptions. |
|
||||
| `getSchemeROI(schemeId)` | Compares scheme spend against incremental sales generated. | Implemented — `GET /api/schemes/{id}/roi`, using a manually-entered `BaselineExpectedSales` per scheme (no trailing-period auto-baseline yet). |
|
||||
|
||||
### 12. Returns & Claims Management
|
||||
|
||||
|
||||
Reference in New Issue
Block a user