From 6d8b6b3e596a013c80dac379569181a43af87770 Mon Sep 17 00:00:00 2001 From: "Kalana B. Thilakarathna" Date: Mon, 24 Aug 2026 21:34:57 +0530 Subject: [PATCH] Add DeliveryTask, Payment, and Sale features Implemented DeliveryTask, Payment, and Sale entities with full CRUD, business logic, and API endpoints. Updated Order/OrderItem for sale linkage and fulfillment tracking. Registered new DbSets and DI services. Revised documentation for delivery, sale, and payment workflows. --- Distribution/Data/AppDBContext.cs | 44 + .../DeliveryRide/DeliveryTaskController.cs | 72 + .../Features/DeliveryRide/DeliveryTaskDTOs.cs | 24 + .../DeliveryRide/DeliveryTaskRepository.cs | 38 + .../DeliveryRide/DeliveryTaskService.cs | 224 ++ .../DeliveryRide/IDeliveryTaskRepository.cs | 12 + .../LoadingEvent/LoadingSheetRepository.cs | 7 +- .../Features/Order/IOrderRepository.cs | 2 + .../Features/Order/OrderController.cs | 16 +- Distribution/Features/Order/OrderDTOs.cs | 13 +- .../Features/Order/OrderRepository.cs | 15 + Distribution/Features/Order/OrderService.cs | 125 +- .../Features/Payment/IPaymentRepository.cs | 9 + .../Features/Payment/PaymentController.cs | 28 + Distribution/Features/Payment/PaymentDTOs.cs | 15 + .../Features/Payment/PaymentRepository.cs | 17 + .../Features/Payment/PaymentService.cs | 67 + Distribution/Features/Sale/ISaleRepository.cs | 14 + Distribution/Features/Sale/SaleController.cs | 39 + Distribution/Features/Sale/SaleDTOs.cs | 33 + Distribution/Features/Sale/SaleRepository.cs | 32 + Distribution/Features/Sale/SaleService.cs | 233 ++ .../20260824155602_payment.Designer.cs | 1977 +++++++++++++++++ .../Migrations/20260824155602_payment.cs | 236 ++ .../Migrations/AppDBContextModelSnapshot.cs | 225 +- Distribution/Models/DeliveryTask.cs | 21 + Distribution/Models/Enums.cs | 16 + Distribution/Models/Order.cs | 8 + Distribution/Models/OrderItem.cs | 1 + Distribution/Models/Payment.cs | 20 + Distribution/Models/Sale.cs | 27 + Distribution/PLAN.md | 299 +-- Distribution/Program.cs | 9 + Distribution/docs/03_ride_lifecycle.md | 143 +- Distribution/docs/04_loading_event.md | 133 +- Distribution/docs/05_delivery_ride.md | 145 +- Distribution/docs/06_sale_ride.md | 138 +- Distribution/docs/mainPlan.md | 24 +- 38 files changed, 3938 insertions(+), 563 deletions(-) create mode 100644 Distribution/Features/DeliveryRide/DeliveryTaskController.cs create mode 100644 Distribution/Features/DeliveryRide/DeliveryTaskDTOs.cs create mode 100644 Distribution/Features/DeliveryRide/DeliveryTaskRepository.cs create mode 100644 Distribution/Features/DeliveryRide/DeliveryTaskService.cs create mode 100644 Distribution/Features/DeliveryRide/IDeliveryTaskRepository.cs create mode 100644 Distribution/Features/Payment/IPaymentRepository.cs create mode 100644 Distribution/Features/Payment/PaymentController.cs create mode 100644 Distribution/Features/Payment/PaymentDTOs.cs create mode 100644 Distribution/Features/Payment/PaymentRepository.cs create mode 100644 Distribution/Features/Payment/PaymentService.cs create mode 100644 Distribution/Features/Sale/ISaleRepository.cs create mode 100644 Distribution/Features/Sale/SaleController.cs create mode 100644 Distribution/Features/Sale/SaleDTOs.cs create mode 100644 Distribution/Features/Sale/SaleRepository.cs create mode 100644 Distribution/Features/Sale/SaleService.cs create mode 100644 Distribution/Migrations/20260824155602_payment.Designer.cs create mode 100644 Distribution/Migrations/20260824155602_payment.cs create mode 100644 Distribution/Models/DeliveryTask.cs create mode 100644 Distribution/Models/Payment.cs create mode 100644 Distribution/Models/Sale.cs diff --git a/Distribution/Data/AppDBContext.cs b/Distribution/Data/AppDBContext.cs index 62d8bd3..603d3ec 100644 --- a/Distribution/Data/AppDBContext.cs +++ b/Distribution/Data/AppDBContext.cs @@ -17,6 +17,7 @@ public class AppDBContext(DbContextOptions options) : DbContext(op public DbSet UomConversions { get; set; } public DbSet Items { get; set; } public DbSet SaleRoutes { get; set; } + public DbSet Sales { get; set; } public DbSet Orders { get; set; } public DbSet OrderItems { get; set; } public DbSet DeliveryRuns { get; set; } @@ -41,6 +42,8 @@ public class AppDBContext(DbContextOptions options) : DbContext(op public DbSet SchemeBundleComponents { get; set; } public DbSet LoadingSheets { get; set; } public DbSet LoadingSheetItems { get; set; } + public DbSet Payments { get; set; } + public DbSet DeliveryTasks { get; set; } public DbSet PurchaseOrders { get; set; } public DbSet PurchaseOrderItems { get; set; } public DbSet GoodsReceipts { get; set; } @@ -79,6 +82,10 @@ public class AppDBContext(DbContextOptions options) : DbContext(op .HasIndex(pli => new { pli.FK_PriceListId, pli.FK_ItemId }) .IsUnique(); + modelBuilder.Entity() + .HasIndex(o => o.FK_SaleId) + .IsUnique(); + modelBuilder.Entity() .HasIndex(dp => dp.FK_UserId) .IsUnique(); @@ -102,6 +109,31 @@ public class AppDBContext(DbContextOptions options) : DbContext(op .HasForeignKey(r => r.FK_DriverProfileId) .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(s => s.Store) + .WithMany() + .HasForeignKey(s => s.FK_StoreId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(s => s.CreatedByUser) + .WithMany() + .HasForeignKey(s => s.CreatedByUserId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(s => s.Ride) + .WithMany() + .HasForeignKey(s => s.FK_RideId) + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(false); + + modelBuilder.Entity() + .HasOne(o => o.Sale) + .WithMany() + .HasForeignKey(o => o.FK_SaleId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() .HasOne(r => r.Scheme) .WithMany(s => s.Redemptions) @@ -238,6 +270,18 @@ public class AppDBContext(DbContextOptions options) : DbContext(op modelBuilder.Entity() .HasIndex(i => i.FK_PurchaseReturnId); + modelBuilder.Entity() + .HasOne(t => t.Ride) + .WithMany() + .HasForeignKey(t => t.FK_RideId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(t => t.Order) + .WithMany() + .HasForeignKey(t => t.FK_OrderId) + .OnDelete(DeleteBehavior.Restrict); + Seed.RoleAndPermissionSeed.Apply(modelBuilder); } } diff --git a/Distribution/Features/DeliveryRide/DeliveryTaskController.cs b/Distribution/Features/DeliveryRide/DeliveryTaskController.cs new file mode 100644 index 0000000..92b851a --- /dev/null +++ b/Distribution/Features/DeliveryRide/DeliveryTaskController.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Distribution.Features.DeliveryRide; + +[ApiController] +[Authorize] +[Route("api")] +public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase +{ + [HttpPost("rides/{rideId:guid}/tasks/generate")] + public async Task Generate(Guid rideId, CancellationToken ct) + { + try + { + var result = await service.GenerateAsync(rideId, ct); + return Ok(result); + } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } + } + + [HttpGet("rides/{rideId:guid}/tasks")] + public async Task GetByRide(Guid rideId) + { + var result = await service.GetByRideIdAsync(rideId); + return Ok(result); + } + + [HttpGet("delivery-tasks/{id:guid}")] + public async Task GetById(Guid id) + { + var result = await service.GetByIdAsync(id); + return result is null ? NotFound() : Ok(result); + } + + [HttpPut("delivery-tasks/{id:guid}/quantities")] + public async Task SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct) + { + try + { + var result = await service.SetQuantitiesAsync(id, request, ct); + return Ok(result); + } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } + } + + [HttpPost("delivery-tasks/{id:guid}/confirm")] + public async Task Confirm(Guid id, CancellationToken ct) + { + try + { + var result = await service.ConfirmAsync(id, ct); + return Ok(result); + } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } + } + + [HttpPost("delivery-tasks/{id:guid}/fail")] + public async Task Fail(Guid id, FailTaskRequest request, CancellationToken ct) + { + try + { + var result = await service.FailAsync(id, request.Note, ct); + return Ok(result); + } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } + } +} diff --git a/Distribution/Features/DeliveryRide/DeliveryTaskDTOs.cs b/Distribution/Features/DeliveryRide/DeliveryTaskDTOs.cs new file mode 100644 index 0000000..fd8a1ab --- /dev/null +++ b/Distribution/Features/DeliveryRide/DeliveryTaskDTOs.cs @@ -0,0 +1,24 @@ +using Distribution.Models; + +namespace Distribution.Features.DeliveryRide; + +public record DeliveryTaskItemResponse(Guid ItemId, string ItemName, int Quantity, int QuantityFulfilled, decimal UnitPrice); + +public record DeliveryTaskResponse( + Guid Id, + Guid FK_RideId, + Guid FK_OrderId, + Guid StoreId, + string StoreName, + int Sequence, + DeliveryTaskStatus Status, + bool CustomerConfirmed, + DateTime? ConfirmedAt, + string? Note, + List Items, + DateTime CreatedAt); + +public record SetQuantitiesRequest(List Items); +public record DeliveryTaskItemQuantity(Guid ItemId, int QuantityFulfilled); + +public record FailTaskRequest(string Note); diff --git a/Distribution/Features/DeliveryRide/DeliveryTaskRepository.cs b/Distribution/Features/DeliveryRide/DeliveryTaskRepository.cs new file mode 100644 index 0000000..3e7aea7 --- /dev/null +++ b/Distribution/Features/DeliveryRide/DeliveryTaskRepository.cs @@ -0,0 +1,38 @@ +using Distribution.Data; +using Distribution.Models; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Features.DeliveryRide; + +public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskRepository +{ + public async Task GetByIdAsync(Guid id) => + await context.DeliveryTasks + .Include(t => t.Order) + .ThenInclude(o => o.Store) + .Include(t => t.Order) + .ThenInclude(o => o.OrderItems) + .ThenInclude(i => i.Item) + .FirstOrDefaultAsync(t => t.Id == id); + + public async Task> GetByRideIdAsync(Guid rideId) => + await context.DeliveryTasks + .Include(t => t.Order) + .ThenInclude(o => o.Store) + .Include(t => t.Order) + .ThenInclude(o => o.OrderItems) + .ThenInclude(i => i.Item) + .Where(t => t.FK_RideId == rideId) + .OrderBy(t => t.Sequence) + .ToListAsync(); + + public async Task ExistsForOrderAndRideAsync(Guid orderId, Guid rideId) => + await context.DeliveryTasks + .AnyAsync(t => t.FK_OrderId == orderId && t.FK_RideId == rideId); + + public async Task AddAsync(DeliveryTask task) => + await context.DeliveryTasks.AddAsync(task); + + public void Update(DeliveryTask task) => + context.DeliveryTasks.Update(task); +} diff --git a/Distribution/Features/DeliveryRide/DeliveryTaskService.cs b/Distribution/Features/DeliveryRide/DeliveryTaskService.cs new file mode 100644 index 0000000..f31ae5a --- /dev/null +++ b/Distribution/Features/DeliveryRide/DeliveryTaskService.cs @@ -0,0 +1,224 @@ +using Distribution.Features.Order; +using Distribution.Features.Ride; +using Distribution.Features.Sale; +using Distribution.Features.Stock; +using Distribution.Infra.UoW; +using Distribution.Models; + +namespace Distribution.Features.DeliveryRide; + +public class DeliveryTaskService( + IDeliveryTaskRepository repo, + IRideRepository rideRepo, + IOrderRepository orderRepo, + ISaleRepository saleRepo, + IStockRepository stockRepo, + IUnitOfWork uow) +{ + private static DeliveryTaskResponse ToResponse(DeliveryTask t) => + new(t.Id, + t.FK_RideId, + t.FK_OrderId, + t.Order.FK_StoreId, + t.Order.Store?.StoreName ?? string.Empty, + t.Sequence, + t.Status, + t.CustomerConfirmed, + t.ConfirmedAt, + t.Note, + t.Order.OrderItems.Select(i => new DeliveryTaskItemResponse( + i.FK_ItemId, + i.Item?.Name ?? string.Empty, + i.Quantity, + i.QuantityFulfilled, + i.UnitPrice)).ToList(), + t.CreatedAt); + + public async Task> GenerateAsync(Guid rideId, CancellationToken ct = default) + { + var ride = await rideRepo.GetByIdAsync(rideId) + ?? throw new KeyNotFoundException("Ride not found"); + + if (ride.Status != RideStatus.Active) + throw new InvalidOperationException("Tasks can only be generated for an Active ride"); + + if (ride.RideType != RideType.Delivery) + throw new InvalidOperationException("Tasks can only be generated for a Delivery ride"); + + var orders = await orderRepo.GetPendingByRouteIdAsync(ride.FK_RouteId); + + var created = new List(); + int sequence = 1; + + foreach (var order in orders) + { + if (await repo.ExistsForOrderAndRideAsync(order.Id, rideId)) + continue; + + var task = new DeliveryTask + { + FK_RideId = rideId, + FK_OrderId = order.Id, + Sequence = sequence++, + Status = DeliveryTaskStatus.Pending + }; + created.Add(task); + } + + if (created.Count == 0) + return new List(); + + await uow.BeginAsync(ct); + try + { + foreach (var task in created) + await repo.AddAsync(task); + await uow.CommitAsync(ct); + } + catch { await uow.RollbackAsync(ct); throw; } + + var saved = await repo.GetByRideIdAsync(rideId); + return saved.Select(ToResponse).ToList(); + } + + public async Task> GetByRideIdAsync(Guid rideId) + { + var tasks = await repo.GetByRideIdAsync(rideId); + return tasks.Select(ToResponse).ToList(); + } + + public async Task GetByIdAsync(Guid id) + { + var task = await repo.GetByIdAsync(id); + return task is null ? null : ToResponse(task); + } + + public async Task SetQuantitiesAsync(Guid taskId, SetQuantitiesRequest request, CancellationToken ct = default) + { + var task = await repo.GetByIdAsync(taskId) + ?? throw new KeyNotFoundException("Delivery task not found"); + + if (task.Status != DeliveryTaskStatus.Pending) + throw new InvalidOperationException("Quantities can only be set for a Pending task"); + + foreach (var input in request.Items) + { + var orderItem = task.Order.OrderItems.FirstOrDefault(i => i.FK_ItemId == input.ItemId) + ?? throw new InvalidOperationException($"Item {input.ItemId} is not on this order"); + + if (input.QuantityFulfilled < 0) + throw new InvalidOperationException($"QuantityFulfilled cannot be negative for item {input.ItemId}"); + + if (input.QuantityFulfilled > orderItem.Quantity) + throw new InvalidOperationException($"QuantityFulfilled exceeds ordered quantity for item {input.ItemId}"); + + orderItem.QuantityFulfilled = input.QuantityFulfilled; + } + + await uow.BeginAsync(ct); + try + { + repo.Update(task); + await uow.CommitAsync(ct); + } + catch { await uow.RollbackAsync(ct); throw; } + + var saved = await repo.GetByIdAsync(taskId); + return ToResponse(saved!); + } + + public async Task ConfirmAsync(Guid taskId, CancellationToken ct = default) + { + var task = await repo.GetByIdAsync(taskId) + ?? throw new KeyNotFoundException("Delivery task not found"); + + if (task.Status != DeliveryTaskStatus.Pending) + throw new InvalidOperationException("Only a Pending task can be confirmed"); + + if (!task.Order.OrderItems.Any(i => i.QuantityFulfilled > 0)) + throw new InvalidOperationException("Set quantities before confirming — at least one item must have a fulfilled quantity greater than zero"); + + var ride = await rideRepo.GetByIdAsync(task.FK_RideId) + ?? throw new KeyNotFoundException("Ride not found"); + + var vehicleId = ride.FK_VehicleId; + + bool fullyDelivered = task.Order.OrderItems.All(i => i.QuantityFulfilled == i.Quantity); + + task.CustomerConfirmed = true; + task.ConfirmedAt = DateTime.Now; + task.Status = fullyDelivered ? DeliveryTaskStatus.Delivered : DeliveryTaskStatus.PartiallyDelivered; + + var order = task.Order; + order.Status = fullyDelivered ? OrderStatus.Fulfilled : OrderStatus.PartiallyFulfilled; + if (fullyDelivered) + order.FulfilledAt = DateTime.Now; + + await uow.BeginAsync(ct); + try + { + foreach (var item in task.Order.OrderItems.Where(i => i.QuantityFulfilled > 0)) + { + var vehicleBalance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId) + ?? throw new InvalidOperationException($"No vehicle stock found for item {item.FK_ItemId}"); + + if (vehicleBalance.Quantity < item.QuantityFulfilled) + throw new InvalidOperationException($"Insufficient vehicle stock for item {item.FK_ItemId}"); + + vehicleBalance.Quantity -= item.QuantityFulfilled; + vehicleBalance.UpdatedAt = DateTime.Now; + stockRepo.UpdateBalance(vehicleBalance); + + await stockRepo.AddMovementAsync(new StockMovement + { + FK_ItemId = item.FK_ItemId, + FromLocationId = vehicleId, + FromLocationType = LocationType.Vehicle, + Quantity = item.QuantityFulfilled, + Type = MovementType.Sale, + ReferenceId = task.FK_OrderId + }); + } + + repo.Update(task); + orderRepo.Update(order); + + if (fullyDelivered) + { + var sale = await saleRepo.GetByIdAsync(order.FK_SaleId) + ?? throw new KeyNotFoundException("Sale not found"); + sale.Status = SaleStatus.Completed; + sale.CompletedAt = DateTime.Now; + saleRepo.Update(sale); + } + + await uow.CommitAsync(ct); + } + catch { await uow.RollbackAsync(ct); throw; } + + var saved = await repo.GetByIdAsync(taskId); + return ToResponse(saved!); + } + + public async Task FailAsync(Guid taskId, string note, CancellationToken ct = default) + { + var task = await repo.GetByIdAsync(taskId) + ?? throw new KeyNotFoundException("Delivery task not found"); + + if (task.Status != DeliveryTaskStatus.Pending) + throw new InvalidOperationException("Only a Pending task can be failed"); + + task.Status = DeliveryTaskStatus.Failed; + task.Note = note; + + await uow.BeginAsync(ct); + try + { + repo.Update(task); + await uow.CommitAsync(ct); + } + catch { await uow.RollbackAsync(ct); throw; } + + return ToResponse(task); + } +} diff --git a/Distribution/Features/DeliveryRide/IDeliveryTaskRepository.cs b/Distribution/Features/DeliveryRide/IDeliveryTaskRepository.cs new file mode 100644 index 0000000..e96be2b --- /dev/null +++ b/Distribution/Features/DeliveryRide/IDeliveryTaskRepository.cs @@ -0,0 +1,12 @@ +using Distribution.Models; + +namespace Distribution.Features.DeliveryRide; + +public interface IDeliveryTaskRepository +{ + Task GetByIdAsync(Guid id); + Task> GetByRideIdAsync(Guid rideId); + Task ExistsForOrderAndRideAsync(Guid orderId, Guid rideId); + Task AddAsync(DeliveryTask task); + void Update(DeliveryTask task); +} diff --git a/Distribution/Features/LoadingEvent/LoadingSheetRepository.cs b/Distribution/Features/LoadingEvent/LoadingSheetRepository.cs index 7a60b53..bb6c404 100644 --- a/Distribution/Features/LoadingEvent/LoadingSheetRepository.cs +++ b/Distribution/Features/LoadingEvent/LoadingSheetRepository.cs @@ -39,9 +39,12 @@ public class LoadingSheetRepository(AppDBContext context) : ILoadingSheetReposit public async Task> GetAggregatedOrderItemsByRouteIdAsync(Guid routeId) { var groups = await context.OrderItems - .Where(oi => oi.IsActive && oi.Order.IsActive && oi.Order.Store.FK_RouteId == routeId) + .Where(oi => oi.IsActive + && oi.Order.IsActive + && oi.Order.Store.FK_RouteId == routeId + && (oi.Order.Status == OrderStatus.Pending || oi.Order.Status == OrderStatus.PartiallyFulfilled)) .GroupBy(oi => oi.FK_ItemId) - .Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)oi.Quantity) }) + .Select(g => new { ItemId = g.Key, TotalQty = g.Sum(oi => (decimal)(oi.Quantity - oi.QuantityFulfilled)) }) .ToListAsync(); return groups.Select(x => (x.ItemId, x.TotalQty)).ToList(); } diff --git a/Distribution/Features/Order/IOrderRepository.cs b/Distribution/Features/Order/IOrderRepository.cs index 6bdcde3..856f12c 100644 --- a/Distribution/Features/Order/IOrderRepository.cs +++ b/Distribution/Features/Order/IOrderRepository.cs @@ -4,10 +4,12 @@ public interface IOrderRepository { Task GetByIdAsync(Guid id); Task> GetAllAsync(); + Task GetBySaleIdAsync(Guid saleId); Task AddAsync(Models.Order entity); void Update(Models.Order entity); void Remove(Models.Order entity); Task> GetItemsByOrderIdAsync(Guid orderId); Task> GetByStoreIdAsync(Guid storeId); Task AddItemsAsync(IEnumerable items); + Task> GetPendingByRouteIdAsync(Guid routeId); } diff --git a/Distribution/Features/Order/OrderController.cs b/Distribution/Features/Order/OrderController.cs index 4aacb79..9b75b04 100644 --- a/Distribution/Features/Order/OrderController.cs +++ b/Distribution/Features/Order/OrderController.cs @@ -33,16 +33,24 @@ public class OrderController(OrderService service) : ControllerBase [Authorize(Policy = "orders.create")] public async Task Create(CreateOrderRequest request, CancellationToken ct) { - var result = await service.CreateAsync(request, ct); - return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result); + try + { + var result = await service.CreateAsync(request, ct); + return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result); + } + catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } } [HttpPut("{id:guid}")] [Authorize(Policy = "orders.update")] public async Task Update(Guid id, UpdateOrderRequest request, CancellationToken ct) { - var result = await service.UpdateAsync(id, request, ct); - return result is null ? NotFound() : Ok(result); + try + { + var result = await service.UpdateAsync(id, request, ct); + return result is null ? NotFound() : Ok(result); + } + catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } } [HttpDelete("{id:guid}")] diff --git a/Distribution/Features/Order/OrderDTOs.cs b/Distribution/Features/Order/OrderDTOs.cs index 4f22a00..247eea4 100644 --- a/Distribution/Features/Order/OrderDTOs.cs +++ b/Distribution/Features/Order/OrderDTOs.cs @@ -1,18 +1,21 @@ +using Distribution.Models; + namespace Distribution.Features.Order; -public record OrderItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null); +public record CreateOrderRequest(Guid SaleId); -public record CreateOrderRequest(Guid StoreId, DateTime OrderDate, decimal TotalAmount, List Items); +public record UpdateOrderRequest(OrderStatus Status); -public record UpdateOrderRequest(DateTime OrderDate, decimal TotalAmount); - -public record OrderItemResponse(Guid ItemId, int Quantity, decimal UnitPrice); +public record OrderItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice); public record OrderResponse( Guid Id, + Guid SaleId, DateTime OrderDate, Guid StoreId, decimal TotalAmount, + OrderStatus Status, + DateTime? FulfilledAt, List Items, bool IsActive, DateTime CreatedAt); diff --git a/Distribution/Features/Order/OrderRepository.cs b/Distribution/Features/Order/OrderRepository.cs index eba8ed6..20fe8bb 100644 --- a/Distribution/Features/Order/OrderRepository.cs +++ b/Distribution/Features/Order/OrderRepository.cs @@ -1,4 +1,5 @@ using Distribution.Data; +using Distribution.Models; using Microsoft.EntityFrameworkCore; namespace Distribution.Features.Order; @@ -11,6 +12,9 @@ public class OrderRepository(AppDBContext context) : IOrderRepository public async Task> GetAllAsync() => await context.Orders.ToListAsync(); + public async Task GetBySaleIdAsync(Guid saleId) => + await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive); + public async Task AddAsync(Models.Order entity) => await context.Orders.AddAsync(entity); @@ -19,6 +23,7 @@ public class OrderRepository(AppDBContext context) : IOrderRepository public void Remove(Models.Order entity) { + entity.Status = Models.OrderStatus.Cancelled; entity.IsActive = false; context.Orders.Update(entity); } @@ -31,4 +36,14 @@ public class OrderRepository(AppDBContext context) : IOrderRepository public async Task AddItemsAsync(IEnumerable items) => await context.OrderItems.AddRangeAsync(items); + + public async Task> GetPendingByRouteIdAsync(Guid routeId) => + await context.Orders + .Include(o => o.Store) + .Include(o => o.OrderItems) + .ThenInclude(i => i.Item) + .Where(o => o.IsActive + && o.Store.FK_RouteId == routeId + && (o.Status == OrderStatus.Pending || o.Status == OrderStatus.PartiallyFulfilled)) + .ToListAsync(); } diff --git a/Distribution/Features/Order/OrderService.cs b/Distribution/Features/Order/OrderService.cs index aa00a6c..e14e5dd 100644 --- a/Distribution/Features/Order/OrderService.cs +++ b/Distribution/Features/Order/OrderService.cs @@ -1,3 +1,4 @@ +using Distribution.Features.Sale; using Distribution.Features.Item; using Distribution.Features.PriceList; using Distribution.Features.Store; @@ -5,7 +6,6 @@ using Distribution.Features.Stock; using Distribution.Infra.Authorization; using Distribution.Infra.UoW; using Distribution.Models; -using Microsoft.Extensions.Configuration; namespace Distribution.Features.Order; @@ -15,16 +15,16 @@ public class OrderService( IItemRepository itemRepo, IStockRepository stockRepo, PriceListService priceListService, + ISaleRepository saleRepo, IUnitOfWork uow, ICurrentUserService currentUser, - IScopeAccessService scopeAccess, - IConfiguration configuration) + IScopeAccessService scopeAccess) { private const int GlobalScopeRoleLevel = 50; private static OrderResponse ToResponse(Models.Order o, List items) => - new(o.Id, o.OrderDate, o.FK_StoreId, o.TotalAmount, - items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.UnitPrice)).ToList(), + new(o.Id, o.FK_SaleId, o.OrderDate, o.FK_StoreId, o.TotalAmount, o.Status, o.FulfilledAt, + items.Select(i => new OrderItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(), o.IsActive, o.CreatedAt); private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel; @@ -35,42 +35,6 @@ public class OrderService( return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId); } - /// - /// applyPricingAndDiscounts (price-list half, 14_pricing_and_suppliers.md): resolves the - /// region-scoped active price list, then global, then Item.SalePrice, and validates any - /// caller-supplied override falls within [MinPrice ?? Price, Price]. Returns null if no price - /// can be resolved, or if the requested price is outside the allowed band. - /// - private async Task ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime orderDate) - { - var item = await itemRepo.GetByIdAsync(itemId); - if (item is null) return null; - var regionId = store.Route?.FK_RegionId; - var resolution = await priceListService.ResolvePriceAsync(itemId, regionId, item.SalePrice, orderDate); - if (resolution is null) return null; - - if (!requestedUnitPrice.HasValue) return resolution.Price; - - var min = resolution.MinPrice ?? resolution.Price; - var max = resolution.Price; - if (requestedUnitPrice.Value < min || requestedUnitPrice.Value > max) return null; - - return requestedUnitPrice.Value; - } - - /// - /// checkStockAvailability (mainPlan.md §1): confirms the requested quantity does not exceed - /// total on-hand stock for the item, summed across every warehouse/vehicle location. Does not - /// reserve or decrement stock — actual decrement happens later at loading/delivery time - /// (04_loading_event.md / future 05-06 ride execution), not at order placement. - /// - private async Task HasSufficientStockAsync(Guid itemId, decimal quantity) - { - var balances = await stockRepo.GetBalancesByItemAsync(itemId); - var totalAvailable = balances.Sum(b => b.Quantity); - return quantity <= totalAvailable; - } - public async Task> GetAllAsync() { var orders = (await repo.GetAllAsync()).Where(o => o.IsActive).ToList(); @@ -112,52 +76,15 @@ public class OrderService( public async Task CreateAsync(CreateOrderRequest request, CancellationToken ct = default) { - if (!await HasStoreScopeAsync(request.StoreId)) return null; + var sale = await saleRepo.GetByIdAsync(request.SaleId); + if (sale is null || !sale.IsActive) return null; + if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null; - var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId); - if (store is null || !store.IsActive) return null; - if (store.KycStatus != KycStatus.Verified) - { - var threshold = configuration.GetValue("StoreManagement:UnverifiedKycOrderThreshold"); - if (request.TotalAmount > threshold) return null; - } + var existingOrder = await repo.GetBySaleIdAsync(request.SaleId) + ?? throw new InvalidOperationException("Orders are created automatically from sales"); - var order = new Models.Order - { - FK_StoreId = request.StoreId, - OrderDate = request.OrderDate, - TotalAmount = request.TotalAmount - }; - var orderItems = new List(); - foreach (var i in request.Items) - { - if (!await HasSufficientStockAsync(i.ItemId, i.Quantity)) return null; - - var unitPrice = await ResolveUnitPriceAsync(i.ItemId, i.UnitPrice, store, request.OrderDate); - if (unitPrice is null) return null; - - orderItems.Add(new Models.OrderItem - { - FK_OrderId = order.Id, - FK_ItemId = i.ItemId, - Quantity = i.Quantity, - UnitPrice = unitPrice.Value - }); - } - - await uow.BeginAsync(ct); - try - { - await repo.AddAsync(order); - await repo.AddItemsAsync(orderItems); - await uow.CommitAsync(ct); - } - catch - { - await uow.RollbackAsync(ct); - throw; - } - return ToResponse(order, orderItems); + var items = await repo.GetItemsByOrderIdAsync(existingOrder.Id); + return ToResponse(existingOrder, items); } public async Task UpdateAsync(Guid id, UpdateOrderRequest request, CancellationToken ct = default) @@ -165,12 +92,26 @@ public class OrderService( var order = await repo.GetByIdAsync(id); if (order is null || !order.IsActive) return null; if (!await HasStoreScopeAsync(order.FK_StoreId)) return null; - order.OrderDate = request.OrderDate; - order.TotalAmount = request.TotalAmount; + + var sale = await saleRepo.GetByIdAsync(order.FK_SaleId) + ?? throw new InvalidOperationException("Sale not found for this order"); + + order.Status = request.Status; + order.FulfilledAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null; + + sale.Status = request.Status switch + { + OrderStatus.Fulfilled => SaleStatus.Completed, + OrderStatus.Cancelled => SaleStatus.Cancelled, + _ => SaleStatus.Pending + }; + sale.CompletedAt = request.Status == OrderStatus.Fulfilled ? DateTime.Now : null; + await uow.BeginAsync(ct); try { repo.Update(order); + saleRepo.Update(sale); await uow.CommitAsync(ct); } catch @@ -178,6 +119,7 @@ public class OrderService( await uow.RollbackAsync(ct); throw; } + var items = await repo.GetItemsByOrderIdAsync(id); return ToResponse(order, items); } @@ -187,10 +129,19 @@ public class OrderService( var order = await repo.GetByIdAsync(id); if (order is null || !order.IsActive) return false; if (!await HasStoreScopeAsync(order.FK_StoreId)) return false; + + var sale = await saleRepo.GetByIdAsync(order.FK_SaleId); + await uow.BeginAsync(ct); try { repo.Remove(order); + if (sale is not null) + { + sale.Status = SaleStatus.Cancelled; + sale.CompletedAt = null; + saleRepo.Update(sale); + } await uow.CommitAsync(ct); } catch diff --git a/Distribution/Features/Payment/IPaymentRepository.cs b/Distribution/Features/Payment/IPaymentRepository.cs new file mode 100644 index 0000000..6ee40af --- /dev/null +++ b/Distribution/Features/Payment/IPaymentRepository.cs @@ -0,0 +1,9 @@ +using Distribution.Models; + +namespace Distribution.Features.Payment; + +public interface IPaymentRepository +{ + Task> GetBySaleIdAsync(Guid saleId); + Task AddAsync(Models.Payment payment); +} diff --git a/Distribution/Features/Payment/PaymentController.cs b/Distribution/Features/Payment/PaymentController.cs new file mode 100644 index 0000000..9fef526 --- /dev/null +++ b/Distribution/Features/Payment/PaymentController.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Distribution.Features.Payment; + +[ApiController] +[Authorize] +[Route("api/sales/{saleId:guid}/payments")] +public class PaymentController(PaymentService service) : ControllerBase +{ + [HttpGet] + public async Task GetBySale(Guid saleId) + { + var result = await service.GetBySaleIdAsync(saleId); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost] + public async Task Create(Guid saleId, CreatePaymentRequest request, CancellationToken ct) + { + try + { + var result = await service.CreateAsync(saleId, request, ct); + return result is null ? NotFound() : Ok(result); + } + catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } + } +} diff --git a/Distribution/Features/Payment/PaymentDTOs.cs b/Distribution/Features/Payment/PaymentDTOs.cs new file mode 100644 index 0000000..cedd6bf --- /dev/null +++ b/Distribution/Features/Payment/PaymentDTOs.cs @@ -0,0 +1,15 @@ +using Distribution.Models; + +namespace Distribution.Features.Payment; + +public record CreatePaymentRequest(decimal Amount, PaymentMethod Method); + +public record PaymentResponse( + Guid Id, + Guid SaleId, + Guid StoreId, + decimal Amount, + PaymentMethod Method, + DateTime CollectedAt, + Guid CollectedBy, + DateTime CreatedAt); diff --git a/Distribution/Features/Payment/PaymentRepository.cs b/Distribution/Features/Payment/PaymentRepository.cs new file mode 100644 index 0000000..e4fb6ce --- /dev/null +++ b/Distribution/Features/Payment/PaymentRepository.cs @@ -0,0 +1,17 @@ +using Distribution.Data; +using Distribution.Models; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Features.Payment; + +public class PaymentRepository(AppDBContext context) : IPaymentRepository +{ + public async Task> GetBySaleIdAsync(Guid saleId) => + await context.Payments + .Where(p => p.FK_SaleId == saleId && p.IsActive) + .OrderBy(p => p.CollectedAt) + .ToListAsync(); + + public async Task AddAsync(Models.Payment payment) => + await context.Payments.AddAsync(payment); +} diff --git a/Distribution/Features/Payment/PaymentService.cs b/Distribution/Features/Payment/PaymentService.cs new file mode 100644 index 0000000..22f8819 --- /dev/null +++ b/Distribution/Features/Payment/PaymentService.cs @@ -0,0 +1,67 @@ +using Distribution.Features.Sale; +using Distribution.Infra.Authorization; +using Distribution.Infra.UoW; +using Distribution.Models; + +namespace Distribution.Features.Payment; + +public class PaymentService( + IPaymentRepository repo, + ISaleRepository saleRepo, + IUnitOfWork uow, + ICurrentUserService currentUser) +{ + private static PaymentResponse ToResponse(Models.Payment p) => + new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt); + + public async Task?> GetBySaleIdAsync(Guid saleId) + { + var sale = await saleRepo.GetByIdAsync(saleId); + if (sale is null || !sale.IsActive) return null; + + var payments = await repo.GetBySaleIdAsync(saleId); + return payments.Select(ToResponse).ToList(); + } + + public async Task CreateAsync(Guid saleId, CreatePaymentRequest request, CancellationToken ct = default) + { + var sale = await saleRepo.GetByIdAsync(saleId); + if (sale is null || !sale.IsActive) return null; + if (sale.Status == SaleStatus.Cancelled) + throw new InvalidOperationException("Cannot record a payment against a cancelled sale"); + + var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); + + var payment = new Models.Payment + { + FK_SaleId = saleId, + FK_StoreId = sale.FK_StoreId, + Amount = request.Amount, + Method = request.Method, + CollectedAt = DateTime.Now, + CollectedBy = userId + }; + + await uow.BeginAsync(ct); + try + { + await repo.AddAsync(payment); + + var allPayments = await repo.GetBySaleIdAsync(saleId); + var total = allPayments.Sum(p => p.Amount) + request.Amount; + + sale.PaymentStatus = total == 0 + ? PaymentStatus.Pending + : total < sale.TotalAmount + ? PaymentStatus.PartiallyPaid + : PaymentStatus.Paid; + + saleRepo.Update(sale); + + await uow.CommitAsync(ct); + } + catch { await uow.RollbackAsync(ct); throw; } + + return ToResponse(payment); + } +} diff --git a/Distribution/Features/Sale/ISaleRepository.cs b/Distribution/Features/Sale/ISaleRepository.cs new file mode 100644 index 0000000..b664c08 --- /dev/null +++ b/Distribution/Features/Sale/ISaleRepository.cs @@ -0,0 +1,14 @@ +using Distribution.Models; + +namespace Distribution.Features.Sale; + +public interface ISaleRepository +{ + Task GetByIdAsync(Guid id); + Task> GetAllAsync(); + Task> GetByStoreIdAsync(Guid storeId); + Task AddAsync(Models.Sale sale); + void Update(Models.Sale sale); + void Remove(Models.Sale sale); + Task GetOrderBySaleIdAsync(Guid saleId); +} diff --git a/Distribution/Features/Sale/SaleController.cs b/Distribution/Features/Sale/SaleController.cs new file mode 100644 index 0000000..792e6b3 --- /dev/null +++ b/Distribution/Features/Sale/SaleController.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Distribution.Features.Sale; + +[ApiController] +[Route("api/sales")] +[Authorize] +public class SaleController(SaleService service) : ControllerBase +{ + [HttpGet] + [Authorize(Policy = "orders.view")] + public async Task GetAll() => + Ok(await service.GetAllAsync()); + + [HttpGet("{id:guid}")] + [Authorize(Policy = "orders.view")] + public async Task GetById(Guid id) + { + var result = await service.GetByIdAsync(id); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("store/{storeId:guid}")] + [Authorize(Policy = "orders.view")] + public async Task GetByStore(Guid storeId) + { + var result = await service.GetByStoreIdAsync(storeId); + return result is null ? Forbid() : Ok(result); + } + + [HttpPost] + [Authorize(Policy = "orders.create")] + public async Task Create(CreateSaleRequest request, CancellationToken ct) + { + var result = await service.CreateAsync(request, ct); + return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result); + } +} diff --git a/Distribution/Features/Sale/SaleDTOs.cs b/Distribution/Features/Sale/SaleDTOs.cs new file mode 100644 index 0000000..c6db510 --- /dev/null +++ b/Distribution/Features/Sale/SaleDTOs.cs @@ -0,0 +1,33 @@ +using Distribution.Models; + +namespace Distribution.Features.Sale; + +public record SaleItemRequest(Guid ItemId, int Quantity, decimal? UnitPrice = null); + +public record CreateSaleRequest( + Guid StoreId, + DateTime SaleDate, + decimal TotalAmount, + bool FulfillImmediately, + Guid? RideId, + List Items); + +public record SaleItemResponse(Guid ItemId, int Quantity, int QuantityFulfilled, decimal UnitPrice); + +public record SaleResponse( + Guid Id, + DateTime SaleDate, + Guid StoreId, + Guid CreatedByUserId, + Guid? RideId, + decimal TotalAmount, + SaleStatus Status, + PaymentStatus PaymentStatus, + bool FulfillImmediately, + DateTime? CompletedAt, + Guid OrderId, + OrderStatus OrderStatus, + DateTime? OrderFulfilledAt, + List Items, + bool IsActive, + DateTime CreatedAt); diff --git a/Distribution/Features/Sale/SaleRepository.cs b/Distribution/Features/Sale/SaleRepository.cs new file mode 100644 index 0000000..af9e5a5 --- /dev/null +++ b/Distribution/Features/Sale/SaleRepository.cs @@ -0,0 +1,32 @@ +using Distribution.Data; +using Distribution.Models; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Features.Sale; + +public class SaleRepository(AppDBContext context) : ISaleRepository +{ + public async Task GetByIdAsync(Guid id) => + await context.Sales.FindAsync(id); + + public async Task> GetAllAsync() => + await context.Sales.ToListAsync(); + + public async Task> GetByStoreIdAsync(Guid storeId) => + await context.Sales.Where(s => s.FK_StoreId == storeId && s.IsActive).ToListAsync(); + + public async Task AddAsync(Models.Sale sale) => + await context.Sales.AddAsync(sale); + + public void Update(Models.Sale sale) => + context.Sales.Update(sale); + + public void Remove(Models.Sale sale) + { + sale.IsActive = false; + context.Sales.Update(sale); + } + + public async Task GetOrderBySaleIdAsync(Guid saleId) => + await context.Orders.FirstOrDefaultAsync(o => o.FK_SaleId == saleId && o.IsActive); +} diff --git a/Distribution/Features/Sale/SaleService.cs b/Distribution/Features/Sale/SaleService.cs new file mode 100644 index 0000000..e7d3ecf --- /dev/null +++ b/Distribution/Features/Sale/SaleService.cs @@ -0,0 +1,233 @@ +using Distribution.Features.Item; +using Distribution.Features.Order; +using Distribution.Features.PriceList; +using Distribution.Features.Ride; +using Distribution.Features.Stock; +using Distribution.Features.Store; +using Distribution.Infra.Authorization; +using Distribution.Infra.UoW; +using Distribution.Models; +using Microsoft.Extensions.Configuration; + +namespace Distribution.Features.Sale; + +public class SaleService( + ISaleRepository repo, + IOrderRepository orderRepo, + IStoreRepository storeRepo, + IItemRepository itemRepo, + PriceListService priceListService, + IStockRepository stockRepo, + IRideRepository rideRepo, + IUnitOfWork uow, + ICurrentUserService currentUser, + IScopeAccessService scopeAccess, + IConfiguration configuration) +{ + private const int GlobalScopeRoleLevel = 50; + + private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel; + + private async Task HasStoreScopeAsync(Guid storeId) + { + if (HasGlobalScope || currentUser.UserId is null) return HasGlobalScope; + return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId); + } + + private async Task ResolveUnitPriceAsync(Guid itemId, decimal? requestedUnitPrice, Models.Store store, DateTime saleDate) + { + var item = await itemRepo.GetByIdAsync(itemId); + if (item is null) return null; + + var regionId = store.Route?.FK_RegionId; + var resolution = await priceListService.ResolvePriceAsync(itemId, regionId, item.SalePrice, saleDate); + if (resolution is null) return null; + + if (!requestedUnitPrice.HasValue) return resolution.Price; + + var min = resolution.MinPrice ?? resolution.Price; + var max = resolution.Price; + if (requestedUnitPrice.Value < min || requestedUnitPrice.Value > max) return null; + + return requestedUnitPrice.Value; + } + + private static SaleResponse ToResponse(Models.Sale sale, Models.Order order, List items) => + new( + sale.Id, + sale.SaleDate, + sale.FK_StoreId, + sale.CreatedByUserId, + sale.FK_RideId, + sale.TotalAmount, + sale.Status, + sale.PaymentStatus, + sale.FulfillImmediately, + sale.CompletedAt, + order.Id, + order.Status, + order.FulfilledAt, + items.Select(i => new SaleItemResponse(i.FK_ItemId, i.Quantity, i.QuantityFulfilled, i.UnitPrice)).ToList(), + sale.IsActive, + sale.CreatedAt); + + public async Task> GetAllAsync() + { + var sales = (await repo.GetAllAsync()).Where(s => s.IsActive).ToList(); + if (!HasGlobalScope && currentUser.UserId is not null) + { + var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet(); + sales = sales.Where(s => accessibleStoreIds.Contains(s.FK_StoreId)).ToList(); + } + + var result = new List(); + foreach (var sale in sales) + { + var order = await repo.GetOrderBySaleIdAsync(sale.Id); + if (order is null) continue; + var items = await orderRepo.GetItemsByOrderIdAsync(order.Id); + result.Add(ToResponse(sale, order, items)); + } + return result; + } + + public async Task GetByIdAsync(Guid id) + { + var sale = await repo.GetByIdAsync(id); + if (sale is null || !sale.IsActive) return null; + if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null; + + var order = await repo.GetOrderBySaleIdAsync(id); + if (order is null) return null; + var items = await orderRepo.GetItemsByOrderIdAsync(order.Id); + return ToResponse(sale, order, items); + } + + public async Task?> GetByStoreIdAsync(Guid storeId) + { + if (!await HasStoreScopeAsync(storeId)) return null; + + var sales = await repo.GetByStoreIdAsync(storeId); + var result = new List(); + foreach (var sale in sales) + { + var order = await repo.GetOrderBySaleIdAsync(sale.Id); + if (order is null) continue; + var items = await orderRepo.GetItemsByOrderIdAsync(order.Id); + result.Add(ToResponse(sale, order, items)); + } + return result; + } + + public async Task CreateAsync(CreateSaleRequest request, CancellationToken ct = default) + { + if (!await HasStoreScopeAsync(request.StoreId)) return null; + + var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); + var store = await storeRepo.GetByIdWithRegionAsync(request.StoreId); + if (store is null || !store.IsActive) return null; + + if (store.KycStatus != KycStatus.Verified) + { + var threshold = configuration.GetValue("StoreManagement:UnverifiedKycOrderThreshold"); + if (request.TotalAmount > threshold) return null; + } + + var saleStatus = request.FulfillImmediately ? SaleStatus.Completed : SaleStatus.Pending; + var orderStatus = request.FulfillImmediately ? OrderStatus.Fulfilled : OrderStatus.Pending; + var fulfilledAt = request.FulfillImmediately ? DateTime.Now : (DateTime?)null; + + var sale = new Models.Sale + { + FK_StoreId = request.StoreId, + SaleDate = request.SaleDate, + CreatedByUserId = userId, + FK_RideId = request.RideId, + TotalAmount = request.TotalAmount, + Status = saleStatus, + FulfillImmediately = request.FulfillImmediately, + CompletedAt = request.FulfillImmediately ? DateTime.Now : null + }; + + var orderItems = new List(); + foreach (var item in request.Items) + { + var unitPrice = await ResolveUnitPriceAsync(item.ItemId, item.UnitPrice, store, request.SaleDate); + if (unitPrice is null) return null; + + orderItems.Add(new Models.OrderItem + { + FK_ItemId = item.ItemId, + Quantity = item.Quantity, + QuantityFulfilled = request.FulfillImmediately ? item.Quantity : 0, + UnitPrice = unitPrice.Value + }); + } + + var order = new Models.Order + { + FK_SaleId = sale.Id, + FK_StoreId = request.StoreId, + OrderDate = request.SaleDate, + TotalAmount = request.TotalAmount, + Status = orderStatus, + FulfilledAt = fulfilledAt + }; + + foreach (var orderItem in orderItems) + orderItem.FK_OrderId = order.Id; + + Guid? vehicleId = null; + if (request.FulfillImmediately && request.RideId.HasValue) + { + var ride = await rideRepo.GetByIdAsync(request.RideId.Value); + if (ride is null) return null; + vehicleId = ride.FK_VehicleId; + + foreach (var item in orderItems) + { + var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value); + if (balance is null || balance.Quantity < item.Quantity) + return null; + } + } + + await uow.BeginAsync(ct); + try + { + await repo.AddAsync(sale); + await orderRepo.AddAsync(order); + await orderRepo.AddItemsAsync(orderItems); + + if (vehicleId.HasValue) + { + foreach (var item in orderItems) + { + var balance = await stockRepo.GetBalanceAsync(item.FK_ItemId, vehicleId.Value); + balance!.Quantity -= item.Quantity; + balance.UpdatedAt = DateTime.Now; + stockRepo.UpdateBalance(balance); + + await stockRepo.AddMovementAsync(new StockMovement + { + FK_ItemId = item.FK_ItemId, + FromLocationId = vehicleId.Value, + FromLocationType = LocationType.Vehicle, + Quantity = item.Quantity, + Type = MovementType.Sale, + ReferenceId = order.Id + }); + } + } + + await uow.CommitAsync(ct); + } + catch + { + await uow.RollbackAsync(ct); + throw; + } + + return ToResponse(sale, order, orderItems); + } +} diff --git a/Distribution/Migrations/20260824155602_payment.Designer.cs b/Distribution/Migrations/20260824155602_payment.Designer.cs new file mode 100644 index 0000000..39a8aa8 --- /dev/null +++ b/Distribution/Migrations/20260824155602_payment.Designer.cs @@ -0,0 +1,1977 @@ +// +using System; +using Distribution.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Distribution.Migrations +{ + [DbContext(typeof(AppDBContext))] + [Migration("20260824155602_payment")] + partial class payment + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Distribution.Models.Brand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExternalErpId") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Brands"); + }); + + modelBuilder.Entity("Distribution.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AllowCustomerReturn") + .HasColumnType("tinyint(1)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExternalErpId") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Distribution.Models.DeliveryRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ActivatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_DriverProfileId") + .HasColumnType("char(36)"); + + b.Property("FK_RouteId") + .HasColumnType("char(36)"); + + b.Property("FK_SourceWarehouseId") + .HasColumnType("char(36)"); + + b.Property("FK_VehicleId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Meterend") + .HasColumnType("int"); + + b.Property("Meterstart") + .HasColumnType("int"); + + b.Property("RideType") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_DriverProfileId"); + + b.HasIndex("FK_RouteId"); + + b.HasIndex("FK_SourceWarehouseId"); + + b.HasIndex("FK_VehicleId"); + + b.ToTable("DeliveryRuns"); + }); + + modelBuilder.Entity("Distribution.Models.DeliveryTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConfirmedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CustomerConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("FK_OrderId") + .HasColumnType("char(36)"); + + b.Property("FK_RideId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("Sequence") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_OrderId"); + + b.HasIndex("FK_RideId"); + + b.ToTable("DeliveryTasks"); + }); + + modelBuilder.Entity("Distribution.Models.DriverComplianceConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("EnforceLicenseExpiry") + .HasColumnType("tinyint(1)"); + + b.Property("EnforceMedicalClearance") + .HasColumnType("tinyint(1)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.ToTable("DriverComplianceConfigs"); + }); + + modelBuilder.Entity("Distribution.Models.DriverProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_UserId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("LicenseExpiry") + .HasColumnType("datetime(6)"); + + b.Property("LicenseNumber") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("MedicalClearanceExpiry") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_UserId") + .IsUnique(); + + b.ToTable("DriverProfiles"); + }); + + modelBuilder.Entity("Distribution.Models.Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CostPrice") + .HasColumnType("decimal(65,30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("ExternalErpItemId") + .HasColumnType("int"); + + b.Property("FK_BaseUomId") + .HasColumnType("char(36)"); + + b.Property("FK_BrandId") + .HasColumnType("char(36)"); + + b.Property("FK_CategoryId") + .HasColumnType("char(36)"); + + b.Property("FK_SubCategoryId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("LastSyncedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SalePrice") + .HasColumnType("decimal(65,30)"); + + b.Property("Sku") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SyncSource") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_BaseUomId"); + + b.HasIndex("FK_BrandId"); + + b.HasIndex("FK_CategoryId"); + + b.HasIndex("FK_SubCategoryId"); + + b.ToTable("Items"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DispatchConfirmedAt") + .HasColumnType("datetime(6)"); + + b.Property("DispatchConfirmedBy") + .HasColumnType("char(36)"); + + b.Property("FK_RideId") + .HasColumnType("char(36)"); + + b.Property("FK_SourceWarehouseId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("ReceiptConfirmedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReceiptConfirmedBy") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_RideId"); + + b.HasIndex("FK_SourceWarehouseId"); + + b.ToTable("LoadingSheets"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheetItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_LoadingSheetId") + .HasColumnType("char(36)"); + + b.Property("QuantityDispatched") + .HasColumnType("decimal(65,30)"); + + b.Property("QuantityReceived") + .HasColumnType("decimal(65,30)"); + + b.Property("QuantityResolved") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_LoadingSheetId"); + + b.ToTable("LoadingSheetItems"); + }); + + modelBuilder.Entity("Distribution.Models.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_SaleId") + .HasColumnType("char(36)"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("FulfilledAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("OrderDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalAmount") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_SaleId") + .IsUnique(); + + b.HasIndex("FK_StoreId"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("Distribution.Models.OrderItem", b => + { + b.Property("FK_OrderId") + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("QuantityFulfilled") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasColumnType("decimal(65,30)"); + + b.HasKey("FK_OrderId", "FK_ItemId"); + + b.HasIndex("FK_ItemId"); + + b.ToTable("OrderItems"); + }); + + modelBuilder.Entity("Distribution.Models.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CollectedAt") + .HasColumnType("datetime(6)"); + + b.Property("CollectedBy") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_SaleId") + .HasColumnType("char(36)"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Method") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_SaleId"); + + b.HasIndex("FK_StoreId"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("Distribution.Models.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("Permissions"); + }); + + modelBuilder.Entity("Distribution.Models.PriceList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveFrom") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveTo") + .HasColumnType("datetime(6)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_RegionId"); + + b.ToTable("PriceLists"); + }); + + modelBuilder.Entity("Distribution.Models.PriceListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_PriceListId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("MinPrice") + .HasColumnType("decimal(65,30)"); + + b.Property("Price") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_PriceListId", "FK_ItemId") + .IsUnique(); + + b.ToTable("PriceListItems"); + }); + + modelBuilder.Entity("Distribution.Models.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Distribution.Models.RideCancelRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApprovedBy") + .HasColumnType("char(36)"); + + b.Property("FK_RideId") + .HasColumnType("char(36)"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RequestedAt") + .HasColumnType("datetime(6)"); + + b.Property("RequestedBy") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TypeCode") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_RideId"); + + b.ToTable("RideCancelRequests"); + }); + + modelBuilder.Entity("Distribution.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Level") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + + b.HasData( + new + { + Id = new Guid("10000000-0000-0000-0000-000000000001"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 10, + Name = "SalesRep" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000002"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 20, + Name = "Supervisor" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000003"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 30, + Name = "AreaManager" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000004"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 40, + Name = "AreaSupervisor" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000005"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 50, + Name = "RegionalManager" + }, + new + { + Id = new Guid("10000000-0000-0000-0000-000000000006"), + CreatedAt = new DateTime(2026, 8, 18, 0, 0, 0, 0, DateTimeKind.Unspecified), + IsActive = true, + Level = 60, + Name = "RegionalAgency" + }); + }); + + modelBuilder.Entity("Distribution.Models.RolePermission", b => + { + b.Property("FK_RoleId") + .HasColumnType("char(36)"); + + b.Property("FK_PermissionId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("FK_RoleId", "FK_PermissionId"); + + b.HasIndex("FK_PermissionId"); + + b.ToTable("RolePermissions"); + }); + + modelBuilder.Entity("Distribution.Models.Route", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndPoint") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Length") + .HasColumnType("decimal(65,30)"); + + b.Property("RouteName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("StartPoint") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_RegionId"); + + b.ToTable("Routes"); + }); + + modelBuilder.Entity("Distribution.Models.Sale", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedByUserId") + .HasColumnType("char(36)"); + + b.Property("FK_RideId") + .HasColumnType("char(36)"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("FulfillImmediately") + .HasColumnType("tinyint(1)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("PaymentStatus") + .HasColumnType("int"); + + b.Property("SaleDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalAmount") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FK_RideId"); + + b.HasIndex("FK_StoreId"); + + b.ToTable("Sales"); + }); + + modelBuilder.Entity("Distribution.Models.SaleRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("SaleRoutes"); + }); + + modelBuilder.Entity("Distribution.Models.Scheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AgentLimitExempt") + .HasColumnType("tinyint(1)"); + + b.Property("BaselineExpectedSales") + .HasColumnType("decimal(65,30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DateRangeFrom") + .HasColumnType("datetime(6)"); + + b.Property("DateRangeTo") + .HasColumnType("datetime(6)"); + + b.Property("EligibleSegment") + .HasColumnType("longtext"); + + b.Property("FK_FreeItemId") + .HasColumnType("char(36)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("FK_TriggerItemId") + .HasColumnType("char(36)"); + + b.Property("FreeQty") + .HasColumnType("decimal(65,30)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RegionId") + .HasColumnType("char(36)"); + + b.Property("RuleConfig") + .HasColumnType("longtext"); + + b.Property("RuleType") + .HasColumnType("int"); + + b.Property("TriggerQty") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_FreeItemId"); + + b.HasIndex("FK_TriggerItemId"); + + b.HasIndex("RegionId"); + + b.ToTable("Schemes"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeBundleComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_SchemeId") + .HasColumnType("char(36)"); + + b.Property("Qty") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_SchemeId", "FK_ItemId") + .IsUnique(); + + b.ToTable("SchemeBundleComponents"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeRedemption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("DiscountAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("FK_FreeItemId") + .HasColumnType("char(36)"); + + b.Property("FK_OrderId") + .HasColumnType("char(36)"); + + b.Property("FK_SchemeId") + .HasColumnType("char(36)"); + + b.Property("FieldInvoiceId") + .HasColumnType("char(36)"); + + b.Property("FreeItemCostValue") + .HasColumnType("decimal(65,30)"); + + b.Property("FreeQty") + .HasColumnType("decimal(65,30)"); + + b.Property("RedeemedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_FreeItemId"); + + b.HasIndex("FK_OrderId"); + + b.HasIndex("FK_SchemeId"); + + b.ToTable("SchemeRedemptions"); + }); + + modelBuilder.Entity("Distribution.Models.StockBalance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("LocationId") + .HasColumnType("char(36)"); + + b.Property("LocationType") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("decimal(65,30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId", "LocationId") + .IsUnique(); + + b.ToTable("StockBalances"); + }); + + modelBuilder.Entity("Distribution.Models.StockMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FromLocationId") + .HasColumnType("char(36)"); + + b.Property("FromLocationType") + .HasColumnType("int"); + + b.Property("Quantity") + .HasColumnType("decimal(65,30)"); + + b.Property("ReasonCode") + .HasColumnType("longtext"); + + b.Property("ReferenceId") + .HasColumnType("char(36)"); + + b.Property("ToLocationId") + .HasColumnType("char(36)"); + + b.Property("ToLocationType") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_ItemId"); + + b.ToTable("StockMovements"); + }); + + modelBuilder.Entity("Distribution.Models.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreditAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreditLimit") + .HasColumnType("decimal(65,30)"); + + b.Property("FK_RouteId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("KycStatus") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("decimal(65,30)"); + + b.Property("Longitude") + .HasColumnType("decimal(65,30)"); + + b.Property("OneTimeAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("OutstandingBalance") + .HasColumnType("decimal(65,30)"); + + b.Property("OwnerName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Segment") + .HasColumnType("longtext"); + + b.Property("StoreAddress") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("StoreName") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_RouteId"); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("Distribution.Models.StoreKycDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DocumentType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("FileReference") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("UploadedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("FK_StoreId"); + + b.ToTable("StoreKycDocuments"); + }); + + modelBuilder.Entity("Distribution.Models.SubCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExternalErpId") + .HasColumnType("int"); + + b.Property("FK_CategoryId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_CategoryId"); + + b.ToTable("SubCategories"); + }); + + modelBuilder.Entity("Distribution.Models.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ContactInfo") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("PerformanceScore") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.ToTable("Suppliers"); + }); + + modelBuilder.Entity("Distribution.Models.Uom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExternalErpId") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Uoms"); + }); + + modelBuilder.Entity("Distribution.Models.UomConversion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("FK_FromUomId") + .HasColumnType("char(36)"); + + b.Property("FK_ItemId") + .HasColumnType("char(36)"); + + b.Property("FK_ToUomId") + .HasColumnType("char(36)"); + + b.Property("Factor") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("FK_FromUomId"); + + b.HasIndex("FK_ItemId"); + + b.HasIndex("FK_ToUomId"); + + b.ToTable("UomConversions"); + }); + + modelBuilder.Entity("Distribution.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_ReportsToUserId") + .HasColumnType("char(36)"); + + b.Property("FK_RoleId") + .HasColumnType("char(36)"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Username") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("FK_ReportsToUserId"); + + b.HasIndex("FK_RoleId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Distribution.Models.UserRegionScope", b => + { + b.Property("FK_UserId") + .HasColumnType("char(36)"); + + b.Property("FK_RegionId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("FK_UserId", "FK_RegionId"); + + b.HasIndex("FK_RegionId"); + + b.ToTable("UserRegionScopes"); + }); + + modelBuilder.Entity("Distribution.Models.UserRouteScope", b => + { + b.Property("FK_UserId") + .HasColumnType("char(36)"); + + b.Property("FK_RouteId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("FK_UserId", "FK_RouteId"); + + b.HasIndex("FK_RouteId"); + + b.ToTable("UserRouteScopes"); + }); + + modelBuilder.Entity("Distribution.Models.UserStoreScope", b => + { + b.Property("FK_UserId") + .HasColumnType("char(36)"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.HasKey("FK_UserId", "FK_StoreId"); + + b.HasIndex("FK_StoreId"); + + b.ToTable("UserStoreScopes"); + }); + + modelBuilder.Entity("Distribution.Models.Vehicle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DefaultWarehouseId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("NumberPlate") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RegNumber") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DefaultWarehouseId"); + + b.ToTable("Vehicles"); + }); + + modelBuilder.Entity("Distribution.Models.Warehouse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Location") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("VehicleId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("VehicleId"); + + b.ToTable("Warehouses"); + }); + + modelBuilder.Entity("Distribution.Models.DeliveryRun", b => + { + b.HasOne("Distribution.Models.DriverProfile", "DriverProfile") + .WithMany() + .HasForeignKey("FK_DriverProfileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Route", "Route") + .WithMany() + .HasForeignKey("FK_RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Warehouse", "SourceWarehouse") + .WithMany() + .HasForeignKey("FK_SourceWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("FK_VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DriverProfile"); + + b.Navigation("Route"); + + b.Navigation("SourceWarehouse"); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("Distribution.Models.DeliveryTask", b => + { + b.HasOne("Distribution.Models.Order", "Order") + .WithMany() + .HasForeignKey("FK_OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.DeliveryRun", "Ride") + .WithMany() + .HasForeignKey("FK_RideId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("Ride"); + }); + + modelBuilder.Entity("Distribution.Models.DriverProfile", b => + { + b.HasOne("Distribution.Models.User", "User") + .WithMany() + .HasForeignKey("FK_UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Distribution.Models.Item", b => + { + b.HasOne("Distribution.Models.Uom", "BaseUom") + .WithMany() + .HasForeignKey("FK_BaseUomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Brand", "Brand") + .WithMany() + .HasForeignKey("FK_BrandId"); + + b.HasOne("Distribution.Models.Category", "Category") + .WithMany() + .HasForeignKey("FK_CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.SubCategory", "SubCategory") + .WithMany() + .HasForeignKey("FK_SubCategoryId"); + + b.Navigation("BaseUom"); + + b.Navigation("Brand"); + + b.Navigation("Category"); + + b.Navigation("SubCategory"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheet", b => + { + b.HasOne("Distribution.Models.DeliveryRun", "Ride") + .WithMany() + .HasForeignKey("FK_RideId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Warehouse", "SourceWarehouse") + .WithMany() + .HasForeignKey("FK_SourceWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ride"); + + b.Navigation("SourceWarehouse"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheetItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.LoadingSheet", "LoadingSheet") + .WithMany("Items") + .HasForeignKey("FK_LoadingSheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("LoadingSheet"); + }); + + modelBuilder.Entity("Distribution.Models.Order", b => + { + b.HasOne("Distribution.Models.Sale", "Sale") + .WithMany() + .HasForeignKey("FK_SaleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sale"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("Distribution.Models.OrderItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Order", "Order") + .WithMany("OrderItems") + .HasForeignKey("FK_OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("Distribution.Models.Payment", b => + { + b.HasOne("Distribution.Models.Sale", "Sale") + .WithMany() + .HasForeignKey("FK_SaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sale"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("Distribution.Models.PriceList", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Distribution.Models.PriceListItem", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.PriceList", "PriceList") + .WithMany("Items") + .HasForeignKey("FK_PriceListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PriceList"); + }); + + modelBuilder.Entity("Distribution.Models.RideCancelRequest", b => + { + b.HasOne("Distribution.Models.DeliveryRun", "Ride") + .WithMany() + .HasForeignKey("FK_RideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ride"); + }); + + modelBuilder.Entity("Distribution.Models.RolePermission", b => + { + b.HasOne("Distribution.Models.Permission", "Permission") + .WithMany() + .HasForeignKey("FK_PermissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Role", "Role") + .WithMany() + .HasForeignKey("FK_RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Permission"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Distribution.Models.Route", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Distribution.Models.Sale", b => + { + b.HasOne("Distribution.Models.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.DeliveryRun", "Ride") + .WithMany() + .HasForeignKey("FK_RideId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Ride"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("Distribution.Models.Scheme", b => + { + b.HasOne("Distribution.Models.Item", "FreeItem") + .WithMany() + .HasForeignKey("FK_FreeItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Item", "TriggerItem") + .WithMany() + .HasForeignKey("FK_TriggerItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("RegionId"); + + b.Navigation("FreeItem"); + + b.Navigation("Region"); + + b.Navigation("TriggerItem"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeBundleComponent", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.Scheme", "Scheme") + .WithMany("BundleComponents") + .HasForeignKey("FK_SchemeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Scheme"); + }); + + modelBuilder.Entity("Distribution.Models.SchemeRedemption", b => + { + b.HasOne("Distribution.Models.Item", "FreeItem") + .WithMany() + .HasForeignKey("FK_FreeItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Order", "Order") + .WithMany() + .HasForeignKey("FK_OrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Scheme", "Scheme") + .WithMany("Redemptions") + .HasForeignKey("FK_SchemeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FreeItem"); + + b.Navigation("Order"); + + b.Navigation("Scheme"); + }); + + modelBuilder.Entity("Distribution.Models.StockBalance", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Distribution.Models.StockMovement", b => + { + b.HasOne("Distribution.Models.Item", "Item") + .WithMany() + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Distribution.Models.Store", b => + { + b.HasOne("Distribution.Models.Route", "Route") + .WithMany() + .HasForeignKey("FK_RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("Distribution.Models.StoreKycDocument", b => + { + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("Distribution.Models.SubCategory", b => + { + b.HasOne("Distribution.Models.Category", "Category") + .WithMany() + .HasForeignKey("FK_CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Distribution.Models.UomConversion", b => + { + b.HasOne("Distribution.Models.Uom", "FromUom") + .WithMany() + .HasForeignKey("FK_FromUomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Item", "Item") + .WithMany("Conversions") + .HasForeignKey("FK_ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Uom", "ToUom") + .WithMany() + .HasForeignKey("FK_ToUomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FromUom"); + + b.Navigation("Item"); + + b.Navigation("ToUom"); + }); + + modelBuilder.Entity("Distribution.Models.User", b => + { + b.HasOne("Distribution.Models.User", "ReportsTo") + .WithMany() + .HasForeignKey("FK_ReportsToUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Role", "Role") + .WithMany() + .HasForeignKey("FK_RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReportsTo"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Distribution.Models.UserRegionScope", b => + { + b.HasOne("Distribution.Models.Region", "Region") + .WithMany() + .HasForeignKey("FK_RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "User") + .WithMany() + .HasForeignKey("FK_UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Distribution.Models.UserRouteScope", b => + { + b.HasOne("Distribution.Models.Route", "Route") + .WithMany() + .HasForeignKey("FK_RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "User") + .WithMany() + .HasForeignKey("FK_UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Distribution.Models.UserStoreScope", b => + { + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.User", "User") + .WithMany() + .HasForeignKey("FK_UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Store"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Distribution.Models.Vehicle", b => + { + b.HasOne("Distribution.Models.Warehouse", null) + .WithMany() + .HasForeignKey("DefaultWarehouseId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("Distribution.Models.Warehouse", b => + { + b.HasOne("Distribution.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId"); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("Distribution.Models.Item", b => + { + b.Navigation("Conversions"); + }); + + modelBuilder.Entity("Distribution.Models.LoadingSheet", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Distribution.Models.Order", b => + { + b.Navigation("OrderItems"); + }); + + modelBuilder.Entity("Distribution.Models.PriceList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Distribution.Models.Scheme", b => + { + b.Navigation("BundleComponents"); + + b.Navigation("Redemptions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Distribution/Migrations/20260824155602_payment.cs b/Distribution/Migrations/20260824155602_payment.cs new file mode 100644 index 0000000..5531493 --- /dev/null +++ b/Distribution/Migrations/20260824155602_payment.cs @@ -0,0 +1,236 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Distribution.Migrations +{ + /// + public partial class payment : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FK_SaleId", + table: "Orders", + type: "char(36)", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + collation: "ascii_general_ci"); + + migrationBuilder.AddColumn( + name: "FulfilledAt", + table: "Orders", + type: "datetime(6)", + nullable: true); + + migrationBuilder.AddColumn( + name: "Status", + table: "Orders", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "QuantityFulfilled", + table: "OrderItems", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "DeliveryTasks", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_RideId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_OrderId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Sequence = table.Column(type: "int", nullable: false), + Status = table.Column(type: "int", nullable: false), + CustomerConfirmed = table.Column(type: "tinyint(1)", nullable: false), + ConfirmedAt = table.Column(type: "datetime(6)", nullable: true), + Note = table.Column(type: "longtext", nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + IsActive = table.Column(type: "tinyint(1)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DeliveryTasks", x => x.Id); + table.ForeignKey( + name: "FK_DeliveryTasks_DeliveryRuns_FK_RideId", + column: x => x.FK_RideId, + principalTable: "DeliveryRuns", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_DeliveryTasks_Orders_FK_OrderId", + column: x => x.FK_OrderId, + principalTable: "Orders", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Sales", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + SaleDate = table.Column(type: "datetime(6)", nullable: false), + FK_StoreId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + CreatedByUserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_RideId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"), + TotalAmount = table.Column(type: "decimal(65,30)", nullable: false), + Status = table.Column(type: "int", nullable: false), + PaymentStatus = table.Column(type: "int", nullable: false), + FulfillImmediately = table.Column(type: "tinyint(1)", nullable: false), + CompletedAt = table.Column(type: "datetime(6)", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + IsActive = table.Column(type: "tinyint(1)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Sales", x => x.Id); + table.ForeignKey( + name: "FK_Sales_DeliveryRuns_FK_RideId", + column: x => x.FK_RideId, + principalTable: "DeliveryRuns", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Sales_Stores_FK_StoreId", + column: x => x.FK_StoreId, + principalTable: "Stores", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Sales_Users_CreatedByUserId", + column: x => x.CreatedByUserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Payments", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_SaleId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + FK_StoreId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + Amount = table.Column(type: "decimal(65,30)", nullable: false), + Method = table.Column(type: "int", nullable: false), + CollectedAt = table.Column(type: "datetime(6)", nullable: false), + CollectedBy = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + IsActive = table.Column(type: "tinyint(1)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Payments", x => x.Id); + table.ForeignKey( + name: "FK_Payments_Sales_FK_SaleId", + column: x => x.FK_SaleId, + principalTable: "Sales", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Payments_Stores_FK_StoreId", + column: x => x.FK_StoreId, + principalTable: "Stores", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_Orders_FK_SaleId", + table: "Orders", + column: "FK_SaleId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_DeliveryTasks_FK_OrderId", + table: "DeliveryTasks", + column: "FK_OrderId"); + + migrationBuilder.CreateIndex( + name: "IX_DeliveryTasks_FK_RideId", + table: "DeliveryTasks", + column: "FK_RideId"); + + migrationBuilder.CreateIndex( + name: "IX_Payments_FK_SaleId", + table: "Payments", + column: "FK_SaleId"); + + migrationBuilder.CreateIndex( + name: "IX_Payments_FK_StoreId", + table: "Payments", + column: "FK_StoreId"); + + migrationBuilder.CreateIndex( + name: "IX_Sales_CreatedByUserId", + table: "Sales", + column: "CreatedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_Sales_FK_RideId", + table: "Sales", + column: "FK_RideId"); + + migrationBuilder.CreateIndex( + name: "IX_Sales_FK_StoreId", + table: "Sales", + column: "FK_StoreId"); + + migrationBuilder.AddForeignKey( + name: "FK_Orders_Sales_FK_SaleId", + table: "Orders", + column: "FK_SaleId", + principalTable: "Sales", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Orders_Sales_FK_SaleId", + table: "Orders"); + + migrationBuilder.DropTable( + name: "DeliveryTasks"); + + migrationBuilder.DropTable( + name: "Payments"); + + migrationBuilder.DropTable( + name: "Sales"); + + migrationBuilder.DropIndex( + name: "IX_Orders_FK_SaleId", + table: "Orders"); + + migrationBuilder.DropColumn( + name: "FK_SaleId", + table: "Orders"); + + migrationBuilder.DropColumn( + name: "FulfilledAt", + table: "Orders"); + + migrationBuilder.DropColumn( + name: "Status", + table: "Orders"); + + migrationBuilder.DropColumn( + name: "QuantityFulfilled", + table: "OrderItems"); + } + } +} diff --git a/Distribution/Migrations/AppDBContextModelSnapshot.cs b/Distribution/Migrations/AppDBContextModelSnapshot.cs index 45137df..9a18c9b 100644 --- a/Distribution/Migrations/AppDBContextModelSnapshot.cs +++ b/Distribution/Migrations/AppDBContextModelSnapshot.cs @@ -131,6 +131,48 @@ namespace Distribution.Migrations b.ToTable("DeliveryRuns"); }); + modelBuilder.Entity("Distribution.Models.DeliveryTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConfirmedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CustomerConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("FK_OrderId") + .HasColumnType("char(36)"); + + b.Property("FK_RideId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("Sequence") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_OrderId"); + + b.HasIndex("FK_RideId"); + + b.ToTable("DeliveryTasks"); + }); + modelBuilder.Entity("Distribution.Models.DriverComplianceConfig", b => { b.Property("Id") @@ -397,20 +439,32 @@ namespace Distribution.Migrations b.Property("CreatedAt") .HasColumnType("datetime(6)"); + b.Property("FK_SaleId") + .HasColumnType("char(36)"); + b.Property("FK_StoreId") .HasColumnType("char(36)"); + b.Property("FulfilledAt") + .HasColumnType("datetime(6)"); + b.Property("IsActive") .HasColumnType("tinyint(1)"); b.Property("OrderDate") .HasColumnType("datetime(6)"); + b.Property("Status") + .HasColumnType("int"); + b.Property("TotalAmount") .HasColumnType("decimal(65,30)"); b.HasKey("Id"); + b.HasIndex("FK_SaleId") + .IsUnique(); + b.HasIndex("FK_StoreId"); b.ToTable("Orders"); @@ -436,6 +490,9 @@ namespace Distribution.Migrations b.Property("Quantity") .HasColumnType("int"); + b.Property("QuantityFulfilled") + .HasColumnType("int"); + b.Property("UnitPrice") .HasColumnType("decimal(65,30)"); @@ -446,6 +503,45 @@ namespace Distribution.Migrations b.ToTable("OrderItems"); }); + modelBuilder.Entity("Distribution.Models.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CollectedAt") + .HasColumnType("datetime(6)"); + + b.Property("CollectedBy") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FK_SaleId") + .HasColumnType("char(36)"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("Method") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FK_SaleId"); + + b.HasIndex("FK_StoreId"); + + b.ToTable("Payments"); + }); + modelBuilder.Entity("Distribution.Models.Permission", b => { b.Property("Id") @@ -860,6 +956,56 @@ namespace Distribution.Migrations b.ToTable("Routes"); }); + modelBuilder.Entity("Distribution.Models.Sale", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedByUserId") + .HasColumnType("char(36)"); + + b.Property("FK_RideId") + .HasColumnType("char(36)"); + + b.Property("FK_StoreId") + .HasColumnType("char(36)"); + + b.Property("FulfillImmediately") + .HasColumnType("tinyint(1)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("PaymentStatus") + .HasColumnType("int"); + + b.Property("SaleDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalAmount") + .HasColumnType("decimal(65,30)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FK_RideId"); + + b.HasIndex("FK_StoreId"); + + b.ToTable("Sales"); + }); + modelBuilder.Entity("Distribution.Models.SaleRoute", b => { b.Property("Id") @@ -1499,6 +1645,25 @@ namespace Distribution.Migrations b.Navigation("Vehicle"); }); + modelBuilder.Entity("Distribution.Models.DeliveryTask", b => + { + b.HasOne("Distribution.Models.Order", "Order") + .WithMany() + .HasForeignKey("FK_OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.DeliveryRun", "Ride") + .WithMany() + .HasForeignKey("FK_RideId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("Ride"); + }); + modelBuilder.Entity("Distribution.Models.DriverProfile", b => { b.HasOne("Distribution.Models.User", "User") @@ -1619,12 +1784,20 @@ namespace Distribution.Migrations modelBuilder.Entity("Distribution.Models.Order", b => { + b.HasOne("Distribution.Models.Sale", "Sale") + .WithMany() + .HasForeignKey("FK_SaleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + b.HasOne("Distribution.Models.Store", "Store") .WithMany() .HasForeignKey("FK_StoreId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.Navigation("Sale"); + b.Navigation("Store"); }); @@ -1637,7 +1810,7 @@ namespace Distribution.Migrations .IsRequired(); b.HasOne("Distribution.Models.Order", "Order") - .WithMany() + .WithMany("OrderItems") .HasForeignKey("FK_OrderId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -1647,6 +1820,25 @@ namespace Distribution.Migrations b.Navigation("Order"); }); + modelBuilder.Entity("Distribution.Models.Payment", b => + { + b.HasOne("Distribution.Models.Sale", "Sale") + .WithMany() + .HasForeignKey("FK_SaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sale"); + + b.Navigation("Store"); + }); + modelBuilder.Entity("Distribution.Models.PriceList", b => { b.HasOne("Distribution.Models.Region", "Region") @@ -1776,6 +1968,32 @@ namespace Distribution.Migrations b.Navigation("Region"); }); + modelBuilder.Entity("Distribution.Models.Sale", b => + { + b.HasOne("Distribution.Models.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Distribution.Models.DeliveryRun", "Ride") + .WithMany() + .HasForeignKey("FK_RideId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Distribution.Models.Store", "Store") + .WithMany() + .HasForeignKey("FK_StoreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Ride"); + + b.Navigation("Store"); + }); + modelBuilder.Entity("Distribution.Models.Scheme", b => { b.HasOne("Distribution.Models.Item", "FreeItem") @@ -2032,6 +2250,11 @@ namespace Distribution.Migrations b.Navigation("Items"); }); + modelBuilder.Entity("Distribution.Models.Order", b => + { + b.Navigation("OrderItems"); + }); + modelBuilder.Entity("Distribution.Models.PriceList", b => { b.Navigation("Items"); diff --git a/Distribution/Models/DeliveryTask.cs b/Distribution/Models/DeliveryTask.cs new file mode 100644 index 0000000..b28bdc1 --- /dev/null +++ b/Distribution/Models/DeliveryTask.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Distribution.Models +{ + public class DeliveryTask : Template + { + public Guid FK_RideId { get; set; } + [ForeignKey("FK_RideId")] + public DeliveryRun Ride { get; set; } + + public Guid FK_OrderId { get; set; } + [ForeignKey("FK_OrderId")] + public Order Order { get; set; } + + public int Sequence { get; set; } + public DeliveryTaskStatus Status { get; set; } = DeliveryTaskStatus.Pending; + public bool CustomerConfirmed { get; set; } = false; + public DateTime? ConfirmedAt { get; set; } + public string? Note { get; set; } + } +} diff --git a/Distribution/Models/Enums.cs b/Distribution/Models/Enums.cs index 311200e..90f0801 100644 --- a/Distribution/Models/Enums.cs +++ b/Distribution/Models/Enums.cs @@ -42,4 +42,20 @@ namespace Distribution.Models public enum CancelRequestStatus { Pending, Approved, Rejected } public enum PurchaseOrderStatus { Issued, PartiallyReceived, Received, Closed, Cancelled } + + public enum SaleStatus { Pending, Completed, Cancelled } + + public enum OrderStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled } + + public enum PaymentStatus { Pending, PartiallyPaid, Paid } + + public enum PaymentMethod { Cash, Credit } + + public enum DeliveryTaskStatus + { + Pending, + Delivered, + PartiallyDelivered, + Failed + } } diff --git a/Distribution/Models/Order.cs b/Distribution/Models/Order.cs index f71dbe1..ef7448f 100644 --- a/Distribution/Models/Order.cs +++ b/Distribution/Models/Order.cs @@ -4,10 +4,18 @@ namespace Distribution.Models { public class Order : Template { + public Guid FK_SaleId { get; set; } + [ForeignKey("FK_SaleId")] + public Sale Sale { get; set; } + public DateTime OrderDate { get; set; } public Guid FK_StoreId { get; set; } [ForeignKey("FK_StoreId")] public Store Store { get; set; } public decimal TotalAmount { get; set; } + public OrderStatus Status { get; set; } = OrderStatus.Pending; + public DateTime? FulfilledAt { get; set; } + + public ICollection OrderItems { get; set; } = new List(); } } diff --git a/Distribution/Models/OrderItem.cs b/Distribution/Models/OrderItem.cs index b8e5829..07dfa15 100644 --- a/Distribution/Models/OrderItem.cs +++ b/Distribution/Models/OrderItem.cs @@ -11,6 +11,7 @@ namespace Distribution.Models [ForeignKey("FK_ItemId")] public Item Item { get; set; } public int Quantity { get; set; } + public int QuantityFulfilled { get; set; } = 0; public decimal UnitPrice { get; set; } } } diff --git a/Distribution/Models/Payment.cs b/Distribution/Models/Payment.cs new file mode 100644 index 0000000..79a7bac --- /dev/null +++ b/Distribution/Models/Payment.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Distribution.Models +{ + public class Payment : Template + { + public Guid FK_SaleId { get; set; } + [ForeignKey("FK_SaleId")] + public Sale Sale { get; set; } + + public Guid FK_StoreId { get; set; } + [ForeignKey("FK_StoreId")] + public Store Store { get; set; } + + public decimal Amount { get; set; } + public PaymentMethod Method { get; set; } + public DateTime CollectedAt { get; set; } + public Guid CollectedBy { get; set; } + } +} diff --git a/Distribution/Models/Sale.cs b/Distribution/Models/Sale.cs new file mode 100644 index 0000000..68eaae8 --- /dev/null +++ b/Distribution/Models/Sale.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Distribution.Models +{ + public class Sale : Template + { + public DateTime SaleDate { get; set; } + + public Guid FK_StoreId { get; set; } + [ForeignKey("FK_StoreId")] + public Store Store { get; set; } + + public Guid CreatedByUserId { get; set; } + [ForeignKey("CreatedByUserId")] + public User CreatedByUser { get; set; } + + public Guid? FK_RideId { get; set; } + [ForeignKey("FK_RideId")] + public DeliveryRun? Ride { get; set; } + + public decimal TotalAmount { get; set; } + public SaleStatus Status { get; set; } = SaleStatus.Pending; + public PaymentStatus PaymentStatus { get; set; } = PaymentStatus.Pending; + public bool FulfillImmediately { get; set; } + public DateTime? CompletedAt { get; set; } + } +} diff --git a/Distribution/PLAN.md b/Distribution/PLAN.md index 8383a01..6db314a 100644 --- a/Distribution/PLAN.md +++ b/Distribution/PLAN.md @@ -1,267 +1,78 @@ -# Phase Plan — Route & Store Management +# Phase Plan - Route and Store Management ## Scope -Build the full vertical-slice stack for: **Region, Route, Vehicle, Store, Warehouse, Order, OrderItem**. -Item model is sourced from ERP Core — excluded from this phase. -SaleRoute shape is TBD — excluded until clarified. +Build the full vertical-slice stack for: **Region, Route, Vehicle, Store, Warehouse, Order, +OrderItem**. + +Item model is sourced from ERP Core - excluded from this phase. + +Historical note: `SaleRoute` was marked TBD here. For now, rides stay single-route and route +assignment remains simple until further clarification. --- ## Project Structure (target) -``` +```text Distribution/ -├── Models/ # All entity POCOs — unchanged -│ ├── Template.cs -│ ├── Region.cs -│ ├── Route.cs -│ ├── Vehicle.cs -│ ├── Store.cs -│ ├── Warehouse.cs -│ ├── SaleRoute.cs # TBD -│ ├── Order.cs -│ ├── OrderItem.cs -│ ├── DeliveryRun.cs -│ └── Item.cs # Sourced from ERP Core -├── Data/ -│ └── AppDBContext.cs # DbSets + Fluent API config -├── Infra/ -│ ├── UoW/ # Already implemented -│ │ ├── IUnitOfWork.cs -│ │ └── EFUnitOfWork.cs -│ └── Repositories/ # Shared generic base only -│ ├── IGenericRepo.cs -│ └── GenericRepo.cs -└── Features/ - ├── Region/ - │ ├── RegionDTOs.cs - │ ├── IRegionRepository.cs - │ ├── RegionRepository.cs - │ ├── RegionService.cs - │ └── RegionController.cs - ├── Route/ - │ ├── RouteDTOs.cs - │ ├── IRouteRepository.cs - │ ├── RouteRepository.cs - │ ├── RouteService.cs - │ └── RouteController.cs - ├── Vehicle/ - │ ├── VehicleDTOs.cs - │ ├── IVehicleRepository.cs - │ ├── VehicleRepository.cs - │ ├── VehicleService.cs - │ └── VehicleController.cs - ├── Store/ - │ ├── StoreDTOs.cs - │ ├── IStoreRepository.cs - │ ├── StoreRepository.cs - │ ├── StoreService.cs - │ └── StoreController.cs - ├── Warehouse/ - │ ├── WarehouseDTOs.cs - │ ├── IWarehouseRepository.cs - │ ├── WarehouseRepository.cs - │ ├── WarehouseService.cs - │ └── WarehouseController.cs - └── Order/ - ├── OrderDTOs.cs - ├── IOrderRepository.cs - ├── OrderRepository.cs - ├── OrderService.cs - └── OrderController.cs +|-- Models/ +| |-- Template.cs +| |-- Region.cs +| |-- Route.cs +| |-- Vehicle.cs +| |-- Store.cs +| |-- Warehouse.cs +| |-- SaleRoute.cs +| |-- Order.cs +| |-- OrderItem.cs +| |-- DeliveryRun.cs +| `-- Item.cs +|-- Data/ +| `-- AppDBContext.cs +|-- Infra/ +| |-- UoW/ +| | |-- IUnitOfWork.cs +| | `-- EFUnitOfWork.cs +| `-- Repositories/ +| |-- IGenericRepo.cs +| `-- GenericRepo.cs +`-- Features/ + |-- Region/ + |-- Route/ + |-- Vehicle/ + |-- Store/ + |-- Warehouse/ + `-- Order/ ``` --- -## Hard Rules (from AGENTS.md) +## Hard Rules -- Only `uow.CommitAsync()` in services — never in repositories. -- Use `DateTime.Now` — never `DateTime.UtcNow`. +- Only `uow.CommitAsync()` in services - never in repositories. +- Use `DateTime.Now` - never `DateTime.UtcNow`. - Never build anything not explicitly requested. --- -## Step 2 — Complete AppDBContext - -File: `Data/AppDBContext.cs` - -Add DbSets and Fluent API configuration: - -```csharp -public DbSet Regions { get; set; } -public DbSet Routes { get; set; } -public DbSet Vehicles { get; set; } -public DbSet Stores { get; set; } -public DbSet Warehouses { get; set; } -public DbSet SaleRoutes { get; set; } -public DbSet Orders { get; set; } -public DbSet OrderItems { get; set; } -public DbSet DeliveryRuns { get; set; } -public DbSet Items { get; set; } - -// OnModelCreating -modelBuilder.Entity() - .HasKey(oi => new { oi.FK_OrderId, oi.FK_ItemId }); -``` - ---- - -## Step 3 — Initial Migration - -```powershell -dotnet ef migrations add InitialCreate --project Distribution -dotnet ef database update --project Distribution -``` - -Each developer applies this locally. No migration files are shared. - ---- - -## Step 4 — Generic Repository (Infra/Repositories) - -**`IGenericRepo`** -``` -GetByIdAsync(Guid id) -GetAllAsync() -AddAsync(T entity) -Update(T entity) -Remove(T entity) -``` - -**`GenericRepo`** — implements above using DbContext. No `SaveChanges` calls. - ---- - -## Step 5 — Feature Implementation Order - -Build each feature in this dependency order: - -``` -1. Region → no dependencies -2. Route → no dependencies -3. Vehicle → no dependencies -4. Store → no dependencies -5. Warehouse → depends on Vehicle (FK) -6. Order → depends on Store (FK) -7. OrderItem → depends on Order + Item (composite FK) -``` - -### Per-feature deliverables - -#### DTOs (`{Feature}DTOs.cs`) -- `CreateRequest` — fields accepted on POST -- `UpdateRequest` — fields accepted on PUT -- `Response` — shape returned to client (never expose the entity directly) - -#### Repository interface (`I{Feature}Repository.cs`) -Extends `IGenericRepo`. Add feature-specific queries (e.g., `GetByStoreId`). - -#### Repository implementation (`{Feature}Repository.cs`) -Inherits `GenericRepo`. Implements feature-specific queries. No `CommitAsync` calls. - -#### Service (`{Feature}Service.cs`) -- Contains all business logic. -- Calls `_uow.CommitAsync(ct)` after write operations. -- For multi-step writes: `BeginAsync → work → CommitAsync` (catch → `RollbackAsync`). - -#### Controller (`{Feature}Controller.cs`) -Thin — maps HTTP → service call → returns result. No logic, no UoW calls. - ---- - -## Step 6 — Endpoints - -### Region — `/api/regions` -| Method | Route | Description | -|---|---|---| -| GET | `/` | Get all active regions | -| GET | `/{id}` | Get region by id | -| POST | `/` | Create region | -| PUT | `/{id}` | Update region | -| DELETE | `/{id}` | Soft delete (set IsActive = false) | - -### Route — `/api/routes` -| Method | Route | Description | -|---|---|---| -| GET | `/` | Get all active routes | -| GET | `/{id}` | Get route by id | -| POST | `/` | Create route | -| PUT | `/{id}` | Update route | -| DELETE | `/{id}` | Soft delete | - -### Vehicle — `/api/vehicles` -| Method | Route | Description | -|---|---|---| -| GET | `/` | Get all active vehicles | -| GET | `/{id}` | Get vehicle by id | -| POST | `/` | Create vehicle | -| PUT | `/{id}` | Update vehicle | -| DELETE | `/{id}` | Soft delete | - -### Store — `/api/stores` -| Method | Route | Description | -|---|---|---| -| GET | `/` | Get all active stores | -| GET | `/{id}` | Get store by id | -| POST | `/` | Create store | -| PUT | `/{id}` | Update store | -| DELETE | `/{id}` | Soft delete | - -### Warehouse — `/api/warehouses` -| Method | Route | Description | -|---|---|---| -| GET | `/` | Get all active warehouses | -| GET | `/{id}` | Get warehouse by id | -| POST | `/` | Create warehouse | -| PUT | `/{id}` | Update warehouse | -| DELETE | `/{id}` | Soft delete | - -### Order — `/api/orders` -| Method | Route | Description | -|---|---|---| -| GET | `/` | Get all active orders | -| GET | `/{id}` | Get order with items | -| GET | `/store/{storeId}` | Get orders by store | -| POST | `/` | Create order with items | -| PUT | `/{id}` | Update order | -| DELETE | `/{id}` | Soft delete | - ---- - -## Step 7 — DI Registration (Program.cs) - -Register all feature repositories and services: - -```csharp -// Generic base -builder.Services.AddScoped(typeof(IGenericRepo<>), typeof(GenericRepo<>)); - -// Feature repositories -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// Feature services -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -``` - ---- - ## Deferred | Item | Reason | |---|---| -| `SaleRoute` | Shape undefined — implement after clarification | -| `Item` model | Sourced from ERP Core — user handles integration | +| `SaleRoute` | Re-evaluate after ride/route design is clarified | +| `Item` model | Sourced from ERP Core | | `DeliveryRun` | Not in scope for this phase | -| Driver FK on DeliveryRun | Commented out — depends on HR/Driver module | -| Tests | No test project yet — add after endpoints stabilize | +| Tests | No test project yet | + +--- + +## Note + +This file is historical phase planning only. Current business definitions for ride, sale, order, +and fulfillment are maintained in: + +- `docs/03_ride_lifecycle.md` +- `docs/04_loading_event.md` +- `docs/05_delivery_ride.md` +- `docs/06_sale_ride.md` diff --git a/Distribution/Program.cs b/Distribution/Program.cs index c741a26..32b4c37 100644 --- a/Distribution/Program.cs +++ b/Distribution/Program.cs @@ -3,6 +3,8 @@ using Distribution.Data; using Distribution.Features.Auth; using Distribution.Features.Brand; using Distribution.Features.Category; +using Distribution.Features.DeliveryRide; +using Distribution.Features.Payment; using Distribution.Features.DriverComplianceConfig; using Distribution.Features.DriverProfile; using Distribution.Features.Grn; @@ -16,6 +18,7 @@ using Distribution.Features.LoadingEvent; using Distribution.Features.Ride; using Distribution.Features.Role; using Distribution.Features.Route; +using Distribution.Features.Sale; using Distribution.Features.Scheme; using Distribution.Features.Store; using Distribution.Features.Store.Kyc; @@ -49,6 +52,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -69,6 +73,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -76,6 +82,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -96,6 +103,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); diff --git a/Distribution/docs/03_ride_lifecycle.md b/Distribution/docs/03_ride_lifecycle.md index 4236e97..e425887 100644 --- a/Distribution/docs/03_ride_lifecycle.md +++ b/Distribution/docs/03_ride_lifecycle.md @@ -1,41 +1,59 @@ -# 03 — Ride Lifecycle +# 03 - Ride Lifecycle **Status**: Done -**Depends On**: 02 — Stock Ledger +**Depends On**: 02 - Stock Ledger --- ## Goal -Turn the currently-unused `DeliveryRun` model into a fully working ride — a session that turns a +Turn the currently-unused `DeliveryRun` model into a fully working ride - a session that turns a vehicle into a temporary warehouse. Covers driver profile management, ride creation, state transitions, cancellation workflow, and the vehicle breakdown scenario. --- +## Current Business Understanding + +- A `Ride` is one operational trip for one vehicle and one driver on one route for now. +- `RideType` distinguishes business behavior: + - `Delivery` ride: used to fulfill existing orders. + - `Sale` ride: used by sales agents in the field to create sales, and every sale creates an + order. +- `Sale` and `Order` are different: + - `Sale` is the commercial transaction created by the sales agent. + - `Order` is the fulfillment document created from every sale. + - Item lines are maintained once in `OrderItem`; `Sale` does not keep a separate item-line table. +- `Sale` statuses use sales language such as `Completed`. +- `Order` statuses use logistics language such as `Fulfilled`. + +--- + ## Data Model Changes -### New Entity — DriverProfile +### New Entity - DriverProfile -Linked one-to-one with `User`. Holds compliance data only — no login or auth fields. +Linked one-to-one with `User`. Holds compliance data only - no login or auth fields. | Column | Type | Notes | |---|---|---| | Id | Guid | PK | -| FK_UserId | Guid | FK → User (unique) | +| FK_UserId | Guid | FK -> User (unique) | | LicenseNumber | string | | | LicenseExpiry | DateTime | | | MedicalClearanceExpiry | DateTime | | | IsActive | bool | Can be suspended without deleting | | CreatedAt | DateTime | | -No license class tied to vehicle type — not required. +No license class tied to vehicle type - not required. -**Compliance check rule:** Driver is compliant if `IsActive = true`, plus any checks that are enabled in `DriverComplianceConfig`. By default all checks are disabled. When enabled: `LicenseExpiry > today` AND/OR `MedicalClearanceExpiry > today`. +**Compliance check rule:** Driver is compliant if `IsActive = true`, plus any checks that are +enabled in `DriverComplianceConfig`. By default all checks are disabled. When enabled: +`LicenseExpiry > today` and/or `MedicalClearanceExpiry > today`. --- -### Updated Entity — Vehicle +### Updated Entity - Vehicle Add one nullable field: @@ -45,7 +63,7 @@ Add one nullable field: --- -### Updated Entity — DeliveryRun (becomes Ride) +### Updated Entity - DeliveryRun (becomes Ride) Add the following fields to the existing `DeliveryRun`: @@ -53,34 +71,43 @@ Add the following fields to the existing `DeliveryRun`: |---|---|---| | RideType | RideType | Delivery or Sale | | Status | RideStatus | State machine below | -| FK_DriverProfileId | Guid | FK → DriverProfile | +| FK_DriverProfileId | Guid | FK -> DriverProfile | | FK_SourceWarehouseId | Guid | Where stock is loaded from. Must be the same warehouse stock returns to on cancel/close. | -| StartedAt | DateTime? | Set when Draft → Open | -| ActivatedAt | DateTime? | Set when Open → Active | -| ClosedAt | DateTime? | Set when Active → Closed | +| StartedAt | DateTime? | Set when Draft -> Open | +| ActivatedAt | DateTime? | Set when Open -> Active | +| ClosedAt | DateTime? | Set when Active -> Closed | --- -### New Enum — RideType +### New Enum - RideType ```csharp public enum RideType { Delivery, Sale } ``` -### New Enum — RideStatus +### New Enum - RideStatus ```csharp public enum RideStatus { Draft, Open, Active, CancelRequested, Cancelled, Closed } ``` +Meaning of statuses: + +- `Draft`: ride exists but operational work has not started. +- `Open`: ride is prepared and ready for loading/preparation work. +- `Active`: ride is live in the field. +- `CancelRequested`: active ride is waiting for cancellation approval or rejection. +- `Cancelled`: ride ended through approved cancellation. +- `Closed`: ride ended normally. + --- -### New Entity — RideCancelRequest +### New Entity - RideCancelRequest | Column | Type | Notes | |---|---|---| | Id | Guid | PK | -| FK_RideId | Guid | FK → DeliveryRun | +| FK_RideId | Guid | FK -> DeliveryRun | | TypeCode | CancelRequestType | Reason category | | Reason | string | Free text explanation | | RequestedBy | Guid | UserId | @@ -89,7 +116,7 @@ public enum RideStatus { Draft, Open, Active, CancelRequested, Cancelled, Closed | ApprovedBy | Guid? | UserId of approver | | ApprovedAt | DateTime? | | -### New Enum — CancelRequestType +### New Enum - CancelRequestType ```csharp public enum CancelRequestType @@ -101,7 +128,7 @@ public enum CancelRequestType } ``` -### New Entity — DriverComplianceConfig +### New Entity - DriverComplianceConfig Single-row configuration table. Controls which compliance checks are enforced at ride open time. @@ -117,7 +144,7 @@ Managed via a dedicated config endpoint. Only one row ever exists. --- -### New Enum — CancelRequestStatus +### New Enum - CancelRequestStatus ```csharp public enum CancelRequestStatus { Pending, Approved, Rejected } @@ -127,21 +154,22 @@ public enum CancelRequestStatus { Pending, Approved, Rejected } ## State Machine -``` -Draft → Open → Active → Closed - ↘ CancelRequested → Cancelled +```text +Draft -> Open -> Active -> Closed + \-> CancelRequested -> Cancelled ``` | Transition | Trigger | What it does | |---|---|---| -| Draft → Open | `POST /api/rides/{id}/open` | Validates driver compliance. Checks vehicle has no other Open/Active ride. Sets `StartedAt`. | -| Open → Active | `POST /api/rides/{id}/activate` | Called by loading event confirmation (Step 04). Sets `ActivatedAt`. | -| Active → Closed | `POST /api/rides/{id}/close` | Called after ride reconciliation (Step 07). All stock must be returned to source warehouse. Sets `ClosedAt`. | -| Active → CancelRequested | `POST /api/rides/{id}/cancel-request` | Driver submits cancel request with type code and reason. Ride status changes to `CancelRequested`. | -| CancelRequested → Cancelled | `POST /api/rides/cancel-requests/{id}/approve` | Supervisor approves. Ride status changes to `Cancelled`. All stock must return to source warehouse before cancel completes. | -| CancelRequested → Active | `POST /api/rides/cancel-requests/{id}/reject` | Supervisor rejects. Ride returns to `Active`. | +| Draft -> Open | `POST /api/rides/{id}/open` | Validates driver compliance. Checks vehicle has no other Open/Active ride. Sets `StartedAt`. | +| Open -> Active | `POST /api/rides/{id}/activate` | Called by loading event confirmation (Step 04). Sets `ActivatedAt`. | +| Active -> Closed | `POST /api/rides/{id}/close` | Called after ride reconciliation (Step 07). All stock must be returned to source warehouse. Sets `ClosedAt`. | +| Active -> CancelRequested | `POST /api/rides/{id}/cancel-request` | Driver submits cancel request with type code and reason. Ride status changes to `CancelRequested`. | +| CancelRequested -> Cancelled | `POST /api/rides/cancel-requests/{id}/approve` | Supervisor approves. Ride status changes to `Cancelled`. All stock must return to source warehouse before cancel completes. | +| CancelRequested -> Active | `POST /api/rides/cancel-requests/{id}/reject` | Supervisor rejects. Ride returns to `Active`. | -**Order assignment window:** Orders can be assigned to a Delivery ride from Draft until the ride transitions to Open. No new orders after Open. +**Order assignment window:** Orders can be assigned to a Delivery ride from Draft until the ride +transitions to Open. No new orders after Open. --- @@ -150,7 +178,7 @@ Draft → Open → Active → Closed Per vehicle. A vehicle either has `FK_DefaultWarehouseId` set (always loads from that warehouse) or it is null (warehouse is selected manually at ride creation time). -Loading and unloading warehouse are always the same — stock must return to its source warehouse +Loading and unloading warehouse are always the same - stock must return to its source warehouse before a ride can close or cancel. --- @@ -160,12 +188,12 @@ before a ride can close or cancel. 1. Driver submits cancel request with `TypeCode = VehicleBreakdown`. 2. Ride status moves to `CancelRequested`. 3. Supervisor views a breakdown assist endpoint that lists all currently `Active` rides with their - current vehicle stock levels and the stranded ride's open orders — supervisor decides manually + current vehicle stock levels and the stranded ride's open orders. Supervisor decides manually which ride takes over which orders. 4. Supervisor approves the cancel request. 5. Ride moves to `Cancelled` once all stock is confirmed returned to source warehouse. -The system surfaces the information — it does not automatically reassign orders. +The system surfaces the information - it does not automatically reassign orders. --- @@ -179,14 +207,14 @@ Base route: `/api/rides` | GET | `/` | List rides (paginated). Filter by status, type, driverProfileId, vehicleId. | | GET | `/{id}` | Get ride detail | | PUT | `/{id}/driver` | Assign or change driver profile | -| POST | `/{id}/open` | Transition Draft → Open | -| POST | `/{id}/activate` | Transition Open → Active (called by loading event) | -| POST | `/{id}/close` | Transition Active → Closed (called by reconciliation) | +| POST | `/{id}/open` | Transition Draft -> Open | +| POST | `/{id}/activate` | Transition Open -> Active (called by loading event) | +| POST | `/{id}/close` | Transition Active -> Closed (called by reconciliation) | | POST | `/{id}/cancel-request` | Submit cancel request (Active rides only) | | GET | `/cancel-requests` | List cancel requests (paginated). Filter by status. | | POST | `/cancel-requests/{id}/approve` | Approve cancel request | | POST | `/cancel-requests/{id}/reject` | Reject cancel request | -| GET | `/breakdown-assist/{rideId}` | Lists active rides with stock + stranded ride's open orders | +| GET | `/breakdown-assist/{rideId}` | Lists active rides with stock plus stranded ride's open orders | ### DriverComplianceConfig @@ -215,37 +243,42 @@ Base route: `/api/driver-profiles` ## Business Rules -1. A ride cannot transition Draft → Open if `IsActive = false` on the driver profile. If `EnforceLicenseExpiry = true`, also blocks if license is expired. If `EnforceMedicalClearance = true`, also blocks if medical clearance is expired. +1. A ride cannot transition Draft -> Open if `IsActive = false` on the driver profile. If + `EnforceLicenseExpiry = true`, also block if license is expired. If + `EnforceMedicalClearance = true`, also block if medical clearance is expired. 2. A vehicle cannot be on more than one Open or Active ride at a time. 3. Orders can only be assigned to a Delivery ride while it is in Draft status. -4. Loading and unloading warehouse must be the same — enforced at close/cancel. +4. Loading and unloading warehouse must be the same - enforced at close/cancel. 5. A CancelRequested ride cannot close or activate until the request is resolved. -6. All cancel requests must have a TypeCode — free text reason alone is not sufficient. +6. All cancel requests must have a TypeCode - free text reason alone is not sufficient. 7. All list endpoints return paginated responses. +8. A ride uses a single route for now. --- ## Open Questions -1. **Cancel request type codes** — ✅ Resolved. `VehicleBreakdown`, `DriverUnavailable`, `RouteIssue`, `Other`. -2. **Breakdown assist endpoint** — ✅ Resolved. System shows info only, supervisor decides manually. No auto-reassignment. -3. **Driver compliance** — ✅ Resolved. Checks are configurable via `DriverComplianceConfig`. `IsActive` is always enforced. License and medical checks are optional flags, default off. +1. Cancel request type codes - resolved. `VehicleBreakdown`, `DriverUnavailable`, `RouteIssue`, + `Other`. +2. Breakdown assist endpoint - resolved. System shows info only, supervisor decides manually. No + auto-reassignment. +3. Driver compliance - resolved. Checks are configurable via `DriverComplianceConfig`. `IsActive` + is always enforced. License and medical checks are optional flags, default off. +4. Multi-route ride assignment - deferred for now. Current model stays single-route. --- ## Implementation Notes **Build order:** -1. ✅ Enums — `RideType`, `RideStatus`, `CancelRequestType`, `CancelRequestStatus` → added to `Models/Enums.cs` -2. ✅ Entities — `DriverProfile`, `DriverComplianceConfig`, `RideCancelRequest` (new); updated `Vehicle` (+`FK_DefaultWarehouseId`), updated `DeliveryRun` (+`RideType`, `Status`, `FK_DriverProfileId`, `FK_SourceWarehouseId`, `StartedAt`, `ActivatedAt`, `ClosedAt`) -3. ✅ `AppDBContext` — new DbSets + Fluent config -4. ✅ Migrations — `RideLifecycle` (schema), `RidePermissions` (10 new permissions seeded across all roles) -5. ✅ DTOs — `DriverProfileDTOs.cs`, `DriverComplianceConfigDTOs.cs`, `RideDTOs.cs` -6. ✅ Repositories — `IDriverProfileRepository`, `IDriverComplianceConfigRepository`, `IRideRepository`, `IRideCancelRequestRepository` -7. ✅ Services — `DriverProfileService`, `DriverComplianceConfigService`, `RideService` -8. ✅ Controllers — `DriverProfileController`, `DriverComplianceConfigController`, `RideController` -9. ✅ DI registration in `Program.cs` +1. Enums - `RideType`, `RideStatus`, `CancelRequestType`, `CancelRequestStatus` +2. Entities - `DriverProfile`, `DriverComplianceConfig`, `RideCancelRequest` +3. `AppDBContext` - new DbSets plus Fluent config +4. Migrations - ride lifecycle schema and permissions +5. DTOs - driver profile, compliance config, ride +6. Repositories - ride and ride cancel request support +7. Services - driver profile, compliance config, ride +8. Controllers - driver profile, compliance config, ride +9. DI registration in `Program.cs` **Feature folders:** `Features/Ride/`, `Features/DriverProfile/`, `Features/DriverComplianceConfig/` - -**Pending:** Tests for the three new services. diff --git a/Distribution/docs/04_loading_event.md b/Distribution/docs/04_loading_event.md index 87af1f0..d936cdc 100644 --- a/Distribution/docs/04_loading_event.md +++ b/Distribution/docs/04_loading_event.md @@ -1,7 +1,7 @@ -# 04 — Loading Event +# 04 - Loading Event **Status**: Planning -**Depends On**: 03 — Ride Lifecycle +**Depends On**: 03 - Ride Lifecycle --- @@ -9,32 +9,36 @@ Move stock from a source warehouse onto an opened ride's vehicle, with two-party confirmation. When loading is confirmed, the vehicle's stock ledger is credited and the ride transitions -`Open → Active` automatically. This is the first place the stock movement model gets exercised -end-to-end. +`Open -> Active` automatically. + +This loading flow serves both ride types: +- `Delivery` rides load stock to fulfill orders already linked to the ride. +- `Sale` rides load stock so the sales agent can sell in the field and create sale-linked orders. --- ## Background / Context -A vehicle is just a location with `LocationType.Vehicle` in the stock ledger — the same math as -a warehouse. When loading is confirmed, two stock movements are created per item: -- **Debit** source warehouse: `-qty` at `LocationType.Warehouse` -- **Credit** vehicle: `+qty` at `LocationType.Vehicle` +A vehicle is just a location with `LocationType.Vehicle` in the stock ledger - the same math as a +warehouse. When loading is confirmed, two stock movements are created per item: -From this point the vehicle carries live inventory that decreases with every sale or delivery -(Steps 05/06) and must return to zero before the ride can close (Step 07). +- Debit source warehouse: `-qty` at `LocationType.Warehouse` +- Credit vehicle: `+qty` at `LocationType.Vehicle` + +From this point the vehicle carries live inventory that decreases with every sale or delivery and +must return to zero before the ride can close. --- ## Data Model Changes -### New Entity — LoadingSheet +### New Entity - LoadingSheet | Column | Type | Notes | |---|---|---| | Id | Guid | PK | -| FK_RideId | Guid | FK → DeliveryRun | -| FK_SourceWarehouseId | Guid | FK → Warehouse | +| FK_RideId | Guid | FK -> DeliveryRun | +| FK_SourceWarehouseId | Guid | FK -> Warehouse | | Status | LoadingSheetStatus | State machine below | | DispatchConfirmedAt | DateTime? | Set when warehouse confirms dispatch | | DispatchConfirmedBy | Guid? | UserId of warehouse manager | @@ -42,18 +46,18 @@ From this point the vehicle carries live inventory that decreases with every sal | ReceiptConfirmedBy | Guid? | UserId of driver | | CreatedAt | DateTime | | -### New Entity — LoadingSheetItem +### New Entity - LoadingSheetItem | Column | Type | Notes | |---|---|---| | Id | Guid | PK | -| FK_LoadingSheetId | Guid | FK → LoadingSheet | -| FK_ItemId | Guid | FK → Item | +| FK_LoadingSheetId | Guid | FK -> LoadingSheet | +| FK_ItemId | Guid | FK -> Item | | QuantityDispatched | decimal | Set by warehouse manager at confirm-dispatch | | QuantityReceived | decimal? | Set by driver before confirm-receipt | | QuantityResolved | decimal? | Set by supervisor when resolving a discrepancy | -### New Enum — LoadingSheetStatus +### New Enum - LoadingSheetStatus ```csharp public enum LoadingSheetStatus @@ -66,23 +70,23 @@ public enum LoadingSheetStatus } ``` -No changes to existing entities. Vehicle capacity check is deferred to Step 13 (Fleet Management). +No changes to existing entities. Vehicle capacity check is deferred to Step 13. --- ## State Machine -``` -Pending → DispatchConfirmed → ReceiptConfirmed (all lines match) - ↘ DiscrepancyFlagged → Resolved +```text +Pending -> DispatchConfirmed -> ReceiptConfirmed + \-> DiscrepancyFlagged -> Resolved ``` | Transition | Trigger | What it does | |---|---|---| -| Pending → DispatchConfirmed | `POST /{id}/confirm-dispatch` | Warehouse manager locks in `QuantityDispatched` per line. Ride stays `Open`. | -| DispatchConfirmed → ReceiptConfirmed | `POST /{id}/confirm-receipt` | Driver's received quantities all match dispatched. Creates stock movements. Auto-triggers ride `Open → Active`. | -| DispatchConfirmed → DiscrepancyFlagged | `POST /{id}/confirm-receipt` | Any line mismatch. No stock movements created. Ride stays `Open`. | -| DiscrepancyFlagged → Resolved | `POST /{id}/resolve` | Supervisor enters `QuantityResolved` per line. Creates stock movements using resolved quantities. Auto-triggers ride `Open → Active`. | +| Pending -> DispatchConfirmed | `POST /{id}/confirm-dispatch` | Warehouse manager locks in `QuantityDispatched` per line. Ride stays `Open`. | +| DispatchConfirmed -> ReceiptConfirmed | `POST /{id}/confirm-receipt` | Driver's received quantities all match dispatched. Creates stock movements. Auto-triggers ride `Open -> Active`. | +| DispatchConfirmed -> DiscrepancyFlagged | `POST /{id}/confirm-receipt` | Any line mismatch. No stock movements created. Ride stays `Open`. | +| DiscrepancyFlagged -> Resolved | `POST /{id}/resolve` | Supervisor enters `QuantityResolved` per line. Creates stock movements using resolved quantities. Auto-triggers ride `Open -> Active`. | --- @@ -90,10 +94,11 @@ Pending → DispatchConfirmed → ReceiptConfirmed (all lines match) | Ride Type | How items are added to the sheet | |---|---| -| Delivery | Auto-populated on sheet creation from the aggregate of all order lines assigned to the ride | -| Sale | Manually entered by warehouse manager via `PUT /{id}/items` (no orders to derive from) | +| Delivery | Auto-populated on sheet creation from the aggregate of all order lines assigned to the ride's route | +| Sale | Manually entered by warehouse manager via `PUT /{id}/items` based on expected field selling demand | -For Delivery rides the warehouse manager reviews the pre-populated quantities and may adjust before confirming dispatch. `PUT /{id}/items` is still available to override if needed. +For Delivery rides the warehouse manager reviews the pre-populated quantities and may adjust before +confirming dispatch. `PUT /{id}/items` is still available to override if needed. --- @@ -101,12 +106,13 @@ For Delivery rides the warehouse manager reviews the pre-populated quantities an Created at `ReceiptConfirmed` or `Resolved`. One pair per line item: -- **Debit** warehouse: `-QuantityFinal` at `LocationType.Warehouse`, `FK_LocationId = SourceWarehouseId` -- **Credit** vehicle: `+QuantityFinal` at `LocationType.Vehicle`, `FK_LocationId = VehicleId` +- Debit warehouse: `-QuantityFinal` at `LocationType.Warehouse`, `FK_LocationId = SourceWarehouseId` +- Credit vehicle: `+QuantityFinal` at `LocationType.Vehicle`, `FK_LocationId = VehicleId` -`QuantityFinal` = `QuantityReceived` on a clean confirmation, or `QuantityResolved` when a discrepancy was resolved. +`QuantityFinal` = `QuantityReceived` on a clean confirmation, or `QuantityResolved` when a +discrepancy was resolved. -Movement `Type` = `Load` (new `StockMovementType` value to add to enums). +Movement `Type` = `Load`. --- @@ -119,23 +125,25 @@ Base route: `/api/loading-sheets` | POST | `/` | Warehouse mgr | Create sheet for an Open ride. Delivery rides auto-populate items from orders. | | GET | `/` | Any | List sheets. Filter by `rideId`, `status`. Paginated. | | GET | `/{id}` | Any | Get sheet detail with all line items | -| PUT | `/{id}/items` | Warehouse mgr | Set or override item quantities (required for Sale rides, optional for Delivery) | -| POST | `/{id}/confirm-dispatch` | Warehouse mgr | Pending → DispatchConfirmed | +| PUT | `/{id}/items` | Warehouse mgr | Set or override item quantities | +| POST | `/{id}/confirm-dispatch` | Warehouse mgr | Pending -> DispatchConfirmed | | PUT | `/{id}/receipt-items` | Driver | Set `QuantityReceived` per line | -| POST | `/{id}/confirm-receipt` | Driver | → ReceiptConfirmed or DiscrepancyFlagged | -| POST | `/{id}/resolve` | Supervisor | Enter `QuantityResolved` per line → Resolved + ride activation | +| POST | `/{id}/confirm-receipt` | Driver | -> ReceiptConfirmed or DiscrepancyFlagged | +| POST | `/{id}/resolve` | Supervisor | Enter `QuantityResolved` per line -> Resolved plus ride activation | --- ## Business Rules 1. A loading sheet can only be created for a ride in `Open` status. -2. A ride can only have one active loading sheet (not `Resolved` or `ReceiptConfirmed`) at a time. -3. `QuantityDispatched != QuantityReceived` on any line → `DiscrepancyFlagged`. No partial activation. -4. Stock movements are created only once — at `ReceiptConfirmed` or `Resolved`. Never on dispatch. -5. Discrepancies must be resolved per line item with an explicit `QuantityResolved` value. No bulk "accept driver" or "accept warehouse" shortcut. +2. A ride can only have one active loading sheet at a time. +3. `QuantityDispatched != QuantityReceived` on any line -> `DiscrepancyFlagged`. No partial activation. +4. Stock movements are created only once - at `ReceiptConfirmed` or `Resolved`. Never on dispatch. +5. Discrepancies must be resolved per line item with an explicit `QuantityResolved` value. 6. All list endpoints return paginated responses. -7. Vehicle capacity check is not enforced in this step — deferred to Step 13. +7. Vehicle capacity check is not enforced in this step. +8. Immediate handover from a Sale ride does not remove the need for an `Order`; every sale still + creates an order, and that order is fulfilled immediately when stock is handed over at sale time. --- @@ -145,54 +153,27 @@ Base route: `/api/loading-sheets` |---|---| | `loading.view` | All roles | | `loading.dispatch` | Warehouse manager and above | -| `loading.receive` | Driver (AreaManager and above for testing) | +| `loading.receive` | Driver | | `loading.resolve` | Supervisor and above | --- ## Fraud / Control Considerations -- Discrepancies are immediately visible and block activation — they cannot be silently absorbed. -- `QuantityResolved` is permanently stored per line for audit purposes, even after the sheet is resolved. -- See [09_fraud_controls.md](./09_fraud_controls.md) for the audit trail layer that will sit on top of this. +- Discrepancies are immediately visible and block activation. +- `QuantityResolved` is permanently stored per line for audit purposes. --- ## Open Questions -- ✅ Vehicle capacity check — deferred to Step 13 (Fleet Management). -- ✅ Discrepancy resolution — supervisor enters `QuantityResolved` per line item. -- ✅ Ride activation trigger — `confirm-receipt` and `resolve` auto-trigger `Open → Active` internally. -- ✅ Item population — Delivery rides auto-populated from orders; Sale rides manually entered. +1. Vehicle capacity check - deferred to Step 13. +2. Discrepancy resolution - supervisor enters `QuantityResolved` per line item. +3. Ride activation trigger - `confirm-receipt` and `resolve` auto-trigger `Open -> Active`. +4. Delivery item population - from assigned orders on the ride's route. --- ## Implementation Notes -**Build order:** -1. Enum — add `LoadingSheetStatus` to `Models/Enums.cs`; add `Load` to `StockMovementType` -2. Entities — `LoadingSheet`, `LoadingSheetItem` (new) -3. `AppDBContext` — new DbSets + Fluent config -4. Migration — `LoadingEvent` (schema) + `LoadingPermissions` (4 new permissions seeded) -5. DTOs — `LoadingSheetDTOs.cs` -6. Repository — `ILoadingSheetRepository`, `LoadingSheetRepository` -7. Service — `LoadingSheetService` -8. Controller — `LoadingSheetController` -9. DI registration in `Program.cs` - -**Feature folder:** `Features/LoadingEvent/` - -**What was built:** -1. ✅ Enums — added `LoadingSheetStatus` and `Load` to `MovementType` in `Models/Enums.cs` -2. ✅ Entities — `LoadingSheet`, `LoadingSheetItem` created in `Models/` -3. ✅ `AppDBContext` — `LoadingSheets`, `LoadingSheetItems` DbSets added; Fluent config for FK restrictions and ride/warehouse relationships -4. ✅ DTOs — `LoadingSheetDTOs.cs` in `Features/LoadingEvent/` -5. ✅ Repository — `ILoadingSheetRepository`, `LoadingSheetRepository` in `Features/LoadingEvent/` -6. ✅ Service — `LoadingSheetService` in `Features/LoadingEvent/` -7. ✅ Controller — `LoadingSheetController` in `Features/LoadingEvent/` -8. ✅ DI registration in `Program.cs` -9. ⚠️ Permissions added to seed (`loading.view`, `loading.dispatch`, `loading.receive`, `loading.resolve`) — **authorization layer is being replaced, skip this** - -**Pending (run manually):** -- `dotnet ef migrations add LoadingEvent` — blocked by a pre-existing duplicate Permission seed ID in `AppDBContextModelSnapshot.cs` (ID `20000000-0000-0000-0000-000000000040` is assigned to both `categories.delete` and `driver_profiles.create`). This is a snapshot consistency issue from a previous session and needs to be resolved before migration generation will succeed. -- `dotnet ef database update` after migration is generated. +Build after the ride model and before full delivery/sale execution. diff --git a/Distribution/docs/05_delivery_ride.md b/Distribution/docs/05_delivery_ride.md index 656dffd..9992a77 100644 --- a/Distribution/docs/05_delivery_ride.md +++ b/Distribution/docs/05_delivery_ride.md @@ -1,79 +1,148 @@ # 05 — Delivery Ride Execution -**Status**: Not Started +**Status**: Planning **Depends On**: 04 — Loading Event --- ## Goal -Execute pre-confirmed orders against a loaded, active delivery ride. Covers the order-status half -of [mainPlan.md §1](./mainPlan.md#1-order-management): -`updateOrderStatus`, `convertOrderToInvoice`, `createBackorder`. Requires `Order.Status` to exist -first — see gap #7 in [00_existing_gaps.md](./00_existing_gaps.md). +Fulfill pending orders against an active Delivery ride. The driver visits each store on the route, +delivers the ordered items, gets customer confirmation, and the system creates the stock movement, +updates the order, and completes the sale. --- ## Background / Context -Today `Order` has no status and no link to a ride. This step is where the order lifecycle -(`pending → approved → picked → dispatched → delivered → invoiced`) actually gets driven, once the -order has been picked/loaded ([04_loading_event.md](./04_loading_event.md)). +- A Delivery ride goes `Active` after the loading event is confirmed. +- Orders are linked to a ride implicitly through route: `Ride → Route → Stores → Pending Orders`. +- `QuantityFulfilled` on `OrderItem` tracks what was actually delivered (added in pre-planning). +- No separate backorder entity — partially fulfilled orders stay `PartiallyFulfilled` and get picked + up by the next delivery ride on the same route. +- Customer confirmation in v1 is a boolean flag (`CustomerConfirmed`). Full signature/OTP deferred + to Step 09 (Fraud Controls). --- ## Data Model Changes -### Delivery Task (per order on ride) +### New Entity — DeliveryTask -`RideId`, `OrderId`, `Sequence` (visit order), `Status` (`Pending`, `Delivered`, `PartiallyDelivered`, -`Failed`). +Extends `Template` (adds `IsActive`, `CreatedAt` — consistent with all other entities). -### Delivery Outcome +| Column | Type | Notes | +|---|---|---| +| Id | Guid | PK (from Template) | +| FK_RideId | Guid | FK → DeliveryRun | +| FK_OrderId | Guid | FK → Order | +| Sequence | int | Visit order on the route | +| Status | DeliveryTaskStatus | State machine below | +| CustomerConfirmed | bool | Set to true at confirm time | +| ConfirmedAt | DateTime? | Timestamp of customer confirmation | +| Note | string? | Driver note on failure or partial delivery | +| IsActive | bool | From Template (soft delete) | +| CreatedAt | DateTime | From Template | -Per delivery task: quantities actually delivered per line, vs. ordered — feeds `createBackorder` -for the shortfall. +### New Enum — DeliveryTaskStatus -### Customer Confirmation +```csharp +public enum DeliveryTaskStatus +{ + Pending, + Delivered, + PartiallyDelivered, + Failed +} +``` -Signature or OTP captured against the delivery task before it can be marked complete. +### No new columns on OrderItem + +`QuantityFulfilled` already added in pre-planning. No further model changes needed. + +--- + +## State Machine + +``` +Pending → Delivered (all lines fully delivered + customer confirmed) +Pending → PartiallyDelivered (some lines short + customer confirmed) +Pending → Failed (driver could not deliver, no stock movement) +``` + +--- + +## Stock Movements + +Created on `Delivered` or `PartiallyDelivered` after customer confirmation. One movement per item: + +- **Debit** vehicle: `-QuantityFulfilled` at `LocationType.Vehicle`, `FK_LocationId = VehicleId` +- `VehicleId` is resolved from `DeliveryTask.FK_RideId → DeliveryRun.FK_VehicleId` +- `MovementType.Sale` +- `ReferenceId = OrderId` + +No movement on `Failed`. + +--- + +## Order and Sale Status Transitions (triggered internally) + +| Condition | Order | Sale | +|---|---|---| +| All OrderItem lines: QuantityFulfilled == Quantity | → Fulfilled | → Completed | +| Any OrderItem line: QuantityFulfilled < Quantity | → PartiallyFulfilled | stays Pending | +| Task Failed | stays Pending | stays Pending | --- ## API Endpoints -| Function reference | Endpoint intent | -|---|---| -| `updateOrderStatus(orderId, status)` (§1) | `PUT /api/rides/{rideId}/tasks/{orderId}/status` | -| `convertOrderToInvoice(orderId)` (§1) | Triggered automatically on `Delivered`/`PartiallyDelivered` — see [19_finance_accounting.md](./19_finance_accounting.md) | -| `createBackorder(orderId, unfulfilledItems[])` (§1) | Triggered automatically on `PartiallyDelivered` | -| — | `POST /api/rides/{rideId}/tasks/{orderId}/confirm` — captures customer signature/OTP | +Base routes: `/api/rides/{rideId}/tasks` and `/api/delivery-tasks` + +| Method | Route | Who | Description | +|---|---|---|---| +| POST | `/api/rides/{rideId}/tasks/generate` | Driver/System | Auto-create tasks from all Pending/PartiallyFulfilled orders for stores on ride's route | +| GET | `/api/rides/{rideId}/tasks` | Driver | List all tasks for this ride with current status | +| GET | `/api/delivery-tasks/{id}` | Driver | Task detail with order items and quantities | +| PUT | `/api/delivery-tasks/{id}/quantities` | Driver | Set QuantityFulfilled per order item before confirming | +| POST | `/api/delivery-tasks/{id}/confirm` | Driver | Customer confirmed — creates stock movements, transitions order/sale | +| POST | `/api/delivery-tasks/{id}/fail` | Driver | Mark task as failed with a note | --- ## Business Rules -- A delivery task cannot be marked `Delivered` without a customer confirmation. -- `PartiallyDelivered` always creates a `createBackorder` entry and a credit note for the - undelivered quantity (see [18_returns_claims.md](./18_returns_claims.md) for credit note shape). -- Each confirmed delivery task creates a stock movement (`Sale`/consumption) against the vehicle - location per [02_stock_ledger.md](./02_stock_ledger.md). +1. Delivery tasks can only be created for a ride in `Active` status and of type `Delivery`. +2. One task per order per ride — cannot create a second task for the same order on the same ride. +3. `QuantityFulfilled` must be set on all order items before calling confirm. +4. `QuantityFulfilled` cannot exceed `Quantity` on any line. +5. `CustomerConfirmed` must be true before the task can move to `Delivered` or `PartiallyDelivered`. +6. Stock movement is created only once — at confirm. Never on fail. +7. A failed task does not change the order or sale status — the order stays available for the next ride. +8. Partial delivery: the unfulfilled quantity (`Quantity - QuantityFulfilled`) remains on the same + order. The next delivery ride on the same route will pick it up automatically via `generate`. --- -## Fraud / Control Considerations +## Implementation Build Order -- OTP/signature capture must be tamper-evident (timestamped, tied to the delivery task, not - editable after submission). +1. Enum — add `DeliveryTaskStatus` to `Models/Enums.cs` +2. Entity — `DeliveryTask` in `Models/` +3. `AppDBContext` — new DbSet + Fluent config (Restrict on Ride, Order FKs) +4. Migration +5. DTOs — `DeliveryTaskDTOs.cs` in `Features/DeliveryRide/` +6. Repository — `IDeliveryTaskRepository`, `DeliveryTaskRepository` +7. Service — `DeliveryTaskService` +8. Controller — `DeliveryTaskController` +9. DI registration in `Program.cs` + +**Feature folder:** `Features/DeliveryRide/` --- -## Open Questions +## Open Questions — Resolved -- Signature capture (image) or OTP (SMS code) — one, or both, in v1? - ---- - -## Implementation Notes - -> Fill this section in during planning discussion before writing any code. +- ✅ Customer confirmation — boolean flag in v1, full signature/OTP in Step 09 +- ✅ Backorder — no separate entity; same order stays PartiallyFulfilled for next ride +- ✅ Task generation — on-demand via `generate` endpoint, not automatic on ride activation +- ✅ Failed task — order stays Pending, available for next ride, no stock movement diff --git a/Distribution/docs/06_sale_ride.md b/Distribution/docs/06_sale_ride.md index e162525..21c3044 100644 --- a/Distribution/docs/06_sale_ride.md +++ b/Distribution/docs/06_sale_ride.md @@ -1,80 +1,132 @@ # 06 — Sale Ride Execution -**Status**: Not Started +**Status**: Planning **Depends On**: 04 — Loading Event --- ## Goal -Support on-the-spot van sales with no pre-order: driver picks items/quantities, an invoice is -generated immediately, and payment is captured at point of sale. Covers `createInvoice`, -`recordPayment` from [mainPlan.md §13](./mainPlan.md#13-finance--accounting), -and reuses `applyPricingAndDiscounts` (§1) plus `evaluateSchemeEligibility`/`applySchemeDiscount` -(§11) if promotions are live by the time this ships. +Allow a sales agent on an active Sale ride to create sales in the field. Every sale creates an +order. Immediate handover deducts vehicle stock and closes the order immediately. Non-immediate +handover leaves the order pending for a future delivery ride. --- ## Background / Context -Distinct from [05_delivery_ride.md](./05_delivery_ride.md): there is no pre-existing `Order` row — -the field invoice *is* the order, created and settled in one step. +- `SaleService.CreateAsync` already exists and handles both `FulfillImmediately` paths. +- What Step 06 adds: vehicle stock validation and deduction on immediate handover, and the + Payment recording endpoint. +- `Sale.PaymentStatus` and the `Payment` model were added in pre-planning. +- Pricing is already resolved via `PriceListService` inside `SaleService`. --- ## Data Model Changes -### Field Invoice +No new entities. Everything needed was added in pre-planning: -`RideId`, `StoreId`, `CreatedByUserId`, `TotalAmount`, `PaymentStatus` (`Paid`, `PartiallyPaid`, -`OnCredit`). +| What | Where | Status | +|---|---|---| +| `Payment` entity | `Models/Payment.cs` | ✅ Done | +| `PaymentStatus` enum | `Models/Enums.cs` | ✅ Done | +| `PaymentMethod` enum | `Models/Enums.cs` | ✅ Done | +| `Sale.PaymentStatus` | `Models/Sale.cs` | ✅ Done | +| `OrderItem.QuantityFulfilled` | `Models/OrderItem.cs` | ✅ Done | -### Field Invoice Line Item - -`FieldInvoiceId`, `ItemId`, `Quantity`, `UnitPrice` (subject to the discount-limit rules in -[08_sales_agent_controls.md](./08_sales_agent_controls.md)). - -### Payment Record - -`FieldInvoiceId`, `Amount`, `Method` (`Cash`, `Credit`), `CollectedAt`. +Migration needed for: `Payment` table, `Sale.PaymentStatus` column, `OrderItem.QuantityFulfilled` column. --- -## API Endpoints +## Changes to Existing SaleService -| Function reference | Endpoint intent | -|---|---| -| `createInvoice(orderId)` (§13) | `POST /api/rides/{rideId}/field-invoices` — orderId is the field invoice itself here | -| `recordPayment(invoiceId, amount, method)` (§13) | `POST /api/field-invoices/{id}/payments` | -| `applyPricingAndDiscounts(orderId)` (§1) | Applied at line-item entry time, within agent discount limits | +`SaleService.CreateAsync` needs two additions when `FulfillImmediately = true` and `RideId` is set: + +1. **Stock check** — for each item, verify vehicle has enough stock (`StockBalance` at + `LocationType.Vehicle` with `LocationId = VehicleId`). Reject the whole sale if any item is short. +2. **Stock deduction** — debit vehicle balance per item, create one `StockMovement` per item: + - `MovementType.Sale` + - `FromLocationId = VehicleId`, `FromLocationType = Vehicle` + - `ReferenceId = OrderId` + +`VehicleId` is resolved from `Sale.FK_RideId → DeliveryRun.FK_VehicleId`. + +**SaleService constructor additions required:** +- `IStockRepository` — for stock balance check and debit +- `IRideRepository` — to look up the ride and resolve `FK_VehicleId` + +When `FulfillImmediately = false`, no stock movement — order stays Pending for a delivery ride. + +--- + +## New Feature — Payment + +**Feature folder:** `Features/Payment/` + +### API Endpoints + +| Method | Route | Who | Description | +|---|---|---|---| +| POST | `/api/sales/{id}/payments` | Driver/Agent | Record a payment against a sale | +| GET | `/api/sales/{id}/payments` | Any | List all payments for a sale | + +### Payment Creation Logic + +On `POST /api/sales/{id}/payments`: +1. Validate sale exists and is not `Cancelled`. +2. Add `Payment` record (`FK_SaleId`, `FK_StoreId` from sale, `Amount`, `Method`, `CollectedAt`, `CollectedBy`). +3. Sum all payments for this sale. Recalculate `Sale.PaymentStatus`: + - `sum == 0` → `Pending` + - `sum < Sale.TotalAmount` → `PartiallyPaid` + - `sum >= Sale.TotalAmount` → `Paid` +4. Update `Sale.PaymentStatus` in the same transaction. + +### DTOs + +```csharp +public record CreatePaymentRequest(decimal Amount, PaymentMethod Method); +public record PaymentResponse(Guid Id, Guid SaleId, Guid StoreId, decimal Amount, + PaymentMethod Method, DateTime CollectedAt, Guid CollectedBy); +``` --- ## Business Rules -- Driver cannot sell more than what is loaded on the vehicle — every line item is checked against - the vehicle's current stock balance ([02_stock_ledger.md](./02_stock_ledger.md)) before the - invoice is finalized. -- Each confirmed sale line creates a `Sale` stock movement against the vehicle location, - immediately (not batched at ride close). -- Discount applied per line must not exceed the agent's role-level limit (§8). +1. Driver cannot sell more than vehicle stock for immediate handover — entire sale rejected if any + item is short (no partial sale creation). +2. Every sale creates an order, even when all items are handed over immediately. +3. `FulfillImmediately = true`: + - Vehicle stock debited immediately per item. + - Order → `Fulfilled`, Sale → `Completed` at creation time (already in SaleService). + - `QuantityFulfilled = Quantity` on all order items (already in SaleService). +4. `FulfillImmediately = false`: + - No stock movement at sale time. + - Order → `Pending`, Sale → `Pending` at creation time. +5. Payment can be recorded at any time — sale does not need to be `Completed` first. +6. Overpayment is allowed (`sum > TotalAmount`) — `PaymentStatus` stays `Paid`. +7. A `Cancelled` sale cannot receive new payments. --- -## Fraud / Control Considerations +## Implementation Build Order -- Cash collected must reconcile against invoiced totals at ride close — see - [07_ride_reconciliation.md](./07_ride_reconciliation.md). +1. Migration — `Payment` table, `Sale.PaymentStatus`, `OrderItem.QuantityFulfilled` +2. Extend `SaleService.CreateAsync` — stock check + stock movement on `FulfillImmediately` +3. DTOs — `PaymentDTOs.cs` in `Features/Payment/` +4. Repository — `IPaymentRepository`, `PaymentRepository` +5. Service — `PaymentService` +6. Controller — `PaymentController` +7. DI registration in `Program.cs` + +**Feature folder:** `Features/Payment/` (new), `Features/Sale/` (extended) --- -## Open Questions +## Open Questions — Resolved -- Are field invoices numbered/sequenced separately from warehouse-issued invoices, or share one - sequence? - ---- - -## Implementation Notes - -> Fill this section in during planning discussion before writing any code. +- ✅ Payment timing — can be recorded at any time, not locked to sale completion +- ✅ Overpayment — allowed, status stays Paid +- ✅ Stock check scope — entire sale rejected if any item is short, no partial creation +- ✅ Non-immediate handover — no stock movement at sale time, order picked up by delivery ride diff --git a/Distribution/docs/mainPlan.md b/Distribution/docs/mainPlan.md index b719286..1907682 100644 --- a/Distribution/docs/mainPlan.md +++ b/Distribution/docs/mainPlan.md @@ -43,8 +43,8 @@ They must be resolved before new feature steps begin because later steps depend | 02 | Stock Ledger | Done | 01 | [02_stock_ledger.md](./02_stock_ledger.md) | | 03 | Ride Lifecycle | Done | 02 | [03_ride_lifecycle.md](./03_ride_lifecycle.md) | | 04 | Loading Event | Done | 03 | [04_loading_event.md](./04_loading_event.md) | -| 05 | Delivery Ride Execution | Not Started | 04 | [05_delivery_ride.md](./05_delivery_ride.md) | -| 06 | Sale Ride Execution | Not Started | 04 | [06_sale_ride.md](./06_sale_ride.md) | +| 05 | Delivery Ride Execution | Planning | 04 | [05_delivery_ride.md](./05_delivery_ride.md) | +| 06 | Sale Ride Execution | Planning | 04 | [06_sale_ride.md](./06_sale_ride.md) | | 07 | Ride Close & Reconciliation | Not Started | 05, 06 | [07_ride_reconciliation.md](./07_ride_reconciliation.md) | | 08 | Sales Agent Controls | Not Started | 07 | [08_sales_agent_controls.md](./08_sales_agent_controls.md) | | 09 | Fraud Controls & Audit Trail | Not Started | 08 | [09_fraud_controls.md](./09_fraud_controls.md) | @@ -303,13 +303,17 @@ Introduce the concept of stock per location. Every warehouse (fixed or vehicle) of each item. Stock is never edited directly — all changes happen through movement transactions (load, sale, return, damage, adjustment). The current balance is always the running sum. This is the single most important architectural decision for the whole system. +For now, the shop is treated as a business destination, not a tracked stock location in this +system. That means warehouse-to-vehicle and vehicle-to-warehouse movements update tracked stock +balances, while vehicle-to-shop handover reduces vehicle stock and updates sale/order fulfillment +state without creating a shop stock balance. **Gate**: Stock balance for any item at any location can be queried. Every change is a traceable transaction. ### 03 — Ride Lifecycle A ride is a session that turns a vehicle into a temporary warehouse. It has a state machine: Draft → Open → Active → Closed. A vehicle can only be on one active ride at a time. Two ride types exist: Delivery (pre-orders) and Sale (van sales). The ride tracks who is driving, -which route, which vehicle, and when it started and ended. +which route, which vehicle, and when it started and ended. For now, one ride uses one route. **Gate**: A ride can be created, opened, and closed. A vehicle's stock ledger is created when a ride opens. ### 04 — Loading Event @@ -324,14 +328,18 @@ On a delivery ride, the driver fulfills pre-confirmed orders. Each order becomes The driver marks each as delivered, partially delivered, or failed. Partially delivered orders generate a credit note for the undelivered quantity. Customer confirmation (signature or OTP) is required to mark a delivery complete. Stock movements are created per delivery event. +Vehicle-to-shop handover reduces vehicle stock only; the shop does not yet exist as a tracked stock +location in the ledger. **Gate**: A delivery ride can execute all orders on its route and produce a delivery report. ### 06 — Sale Ride Execution -On a sale ride, the driver visits customers and creates invoices on the spot. No pre-orders exist. -The driver selects items, quantities, and prices (within approved discount limits), and the invoice -is generated immediately. Payment is collected (cash or credit) at the point of sale. -The vehicle's stock ledger decreases with each sale. The driver cannot sell more than what is loaded. -**Gate**: A sale ride can generate invoices in the field. Stock on vehicle decreases with each sale. +On a sale ride, the sales agent visits customers and creates sales in the field. Every sale creates +an order. If items are handed over immediately, the order is fulfilled immediately and the sale is +completed immediately. If items are not handed over immediately, the order stays pending for later +fulfillment. Payment is collected based on the final sale flow. The vehicle's stock ledger decreases +only for quantities actually handed over from the vehicle. The shop is not yet a tracked stock +location in the ledger. +**Gate**: A sale ride can create sales in the field, create linked orders, and reduce vehicle stock for immediate handover quantities. ### 07 — Ride Close & Reconciliation When a ride ends, the system performs a full reconciliation. The equation must balance: