Add inventory management features including Sales, Stock Returns, and Suppliers
- Implement Sale and SaleItem models for tracking sales transactions. - Create StockReturn model to manage stock adjustments for various reasons. - Introduce Supplier model to represent suppliers of products. - Develop InventoryRepository for CRUD operations on products, suppliers, sales, and stock returns. - Implement InventoryService to handle business logic for inventory management. - Update TenantDbContext to include new models and relationships. - Enhance ProfitLossRepository to account for sales, purchases, and stock returns in profit and loss calculations. - Modify Program.cs to register new inventory-related services.
This commit is contained in:
@@ -152,6 +152,7 @@ namespace Fitness_core_BE
|
|||||||
new NavItem { Key = "memberships", Label = "Memberships", Href = "/dashboard/memberships", Icon = "CreditCard", SortOrder = 3, IsOwnerOnly = false },
|
new NavItem { Key = "memberships", Label = "Memberships", Href = "/dashboard/memberships", Icon = "CreditCard", SortOrder = 3, IsOwnerOnly = false },
|
||||||
new NavItem { Key = "bookings", Label = "Bookings", Href = "/dashboard/bookings", Icon = "CalendarClock", SortOrder = 4, IsOwnerOnly = false },
|
new NavItem { Key = "bookings", Label = "Bookings", Href = "/dashboard/bookings", Icon = "CalendarClock", SortOrder = 4, IsOwnerOnly = false },
|
||||||
new NavItem { Key = "customer-payments", Label = "Customer Payments", ShortLabel = "Payments", Href = "/dashboard/customer-payments", Icon = "Receipt", SortOrder = 5, IsOwnerOnly = false },
|
new NavItem { Key = "customer-payments", Label = "Customer Payments", ShortLabel = "Payments", Href = "/dashboard/customer-payments", Icon = "Receipt", SortOrder = 5, IsOwnerOnly = false },
|
||||||
|
new NavItem { Key = "inventory", Label = "Inventory", Href = "/dashboard/inventory", Icon = "Package", SortOrder = 6, IsOwnerOnly = false },
|
||||||
new NavItem { Key = "workout-plans", Label = "Workout Plans", Href = "/dashboard/workout-plans", Icon = "Activity", SortOrder = 7, IsOwnerOnly = false },
|
new NavItem { Key = "workout-plans", Label = "Workout Plans", Href = "/dashboard/workout-plans", Icon = "Activity", SortOrder = 7, IsOwnerOnly = false },
|
||||||
new NavItem { Key = "meal-plans", Label = "Meal Plans", Href = "/dashboard/meal-plans", Icon = "Utensils", SortOrder = 8, IsOwnerOnly = false },
|
new NavItem { Key = "meal-plans", Label = "Meal Plans", Href = "/dashboard/meal-plans", Icon = "Utensils", SortOrder = 8, IsOwnerOnly = false },
|
||||||
new NavItem { Key = "staff", Label = "Staff", Href = "/dashboard/staff", Icon = "UsersRound", SortOrder = 9, IsOwnerOnly = false },
|
new NavItem { Key = "staff", Label = "Staff", Href = "/dashboard/staff", Icon = "UsersRound", SortOrder = 9, IsOwnerOnly = false },
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
using Fitness_core_BE.DTOs.Inventory;
|
||||||
|
using Fitness_core_BE.Helpers;
|
||||||
|
using Fitness_core_BE.Interfaces.Services;
|
||||||
|
using Fitness_core_BE.Models.IOs;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using static Fitness_core_BE.Helpers.JwtMiddleware;
|
||||||
|
using static Fitness_core_BE.Helpers.ApiResponseHelper;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.Controllers
|
||||||
|
{
|
||||||
|
// Simple inventory: Products, Suppliers, GRN (stock in), Sales (stock out) and Returns
|
||||||
|
// (stock write-offs). All gated on the "inventory" nav permission.
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/client/inventory")]
|
||||||
|
public class InventoryController(IInventoryService inventoryService) : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IInventoryService _inventoryService = inventoryService;
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("inventory")]
|
||||||
|
[HttpGet("products")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> GetProducts([FromQuery] bool includeInactive = false)
|
||||||
|
=> Response(await _inventoryService.GetProducts(new { includeInactive }, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("inventory")]
|
||||||
|
[HttpGet("products/{id:guid}")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> GetProductById(Guid id)
|
||||||
|
=> Response(await _inventoryService.GetProductById(new { productId = id }, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("inventory")]
|
||||||
|
[HttpPost("products")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> CreateProduct([FromBody] CreateProductRequest request)
|
||||||
|
=> Response(await _inventoryService.CreateProduct(new
|
||||||
|
{
|
||||||
|
name = request.Name,
|
||||||
|
unitOfMeasure = request.UnitOfMeasure,
|
||||||
|
sellingPrice = request.SellingPrice
|
||||||
|
}, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("inventory")]
|
||||||
|
[HttpPut("products/{id:guid}")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> UpdateProduct(Guid id, [FromBody] UpdateProductRequest request)
|
||||||
|
{
|
||||||
|
var payload = new Dictionary<string, object?> { ["productId"] = id };
|
||||||
|
if (request.Name != null) payload["name"] = request.Name;
|
||||||
|
if (request.UnitOfMeasure != null) payload["unitOfMeasure"] = request.UnitOfMeasure;
|
||||||
|
if (request.SellingPrice.HasValue) payload["sellingPrice"] = request.SellingPrice.Value;
|
||||||
|
if (request.IsActive.HasValue) payload["isActive"] = request.IsActive.Value;
|
||||||
|
|
||||||
|
return Response(await _inventoryService.UpdateProduct(payload, HttpContext));
|
||||||
|
}
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("inventory")]
|
||||||
|
[HttpPost("products/{id:guid}/image")]
|
||||||
|
[RequestSizeLimit(6_000_000)]
|
||||||
|
[Consumes("multipart/form-data")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> UploadProductImage(Guid id, [FromForm] IFormFile image)
|
||||||
|
=> Response(await _inventoryService.UploadProductImage(id, image, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("inventory")]
|
||||||
|
[HttpGet("suppliers")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> GetSuppliers([FromQuery] bool includeInactive = false)
|
||||||
|
=> Response(await _inventoryService.GetSuppliers(new { includeInactive }, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("inventory")]
|
||||||
|
[HttpPost("suppliers")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> CreateSupplier([FromBody] CreateSupplierRequest request)
|
||||||
|
=> Response(await _inventoryService.CreateSupplier(new
|
||||||
|
{
|
||||||
|
name = request.Name,
|
||||||
|
phone = request.Phone,
|
||||||
|
email = request.Email,
|
||||||
|
address = request.Address
|
||||||
|
}, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("inventory")]
|
||||||
|
[HttpGet("grn")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> GetGrns()
|
||||||
|
=> Response(await _inventoryService.GetGrns(new { }, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("inventory")]
|
||||||
|
[HttpGet("grn/{id:guid}")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> GetGrnById(Guid id)
|
||||||
|
=> Response(await _inventoryService.GetGrnById(new { grnId = id }, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("inventory")]
|
||||||
|
[HttpPost("grn")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> CreateGrn([FromBody] CreateGrnRequest request)
|
||||||
|
=> Response(await _inventoryService.CreateGrn(new
|
||||||
|
{
|
||||||
|
supplierId = request.SupplierId,
|
||||||
|
receivedAt = request.ReceivedAt?.ToString("o"),
|
||||||
|
notes = request.Notes,
|
||||||
|
items = request.Items.Select(i => new { productId = i.ProductId, quantity = i.Quantity, unitCost = i.UnitCost })
|
||||||
|
}, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("inventory")]
|
||||||
|
[HttpGet("sales")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> GetSales()
|
||||||
|
=> Response(await _inventoryService.GetSales(new { }, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("inventory")]
|
||||||
|
[HttpGet("sales/{id:guid}")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> GetSaleById(Guid id)
|
||||||
|
=> Response(await _inventoryService.GetSaleById(new { saleId = id }, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("inventory")]
|
||||||
|
[HttpPost("sales")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> CreateSale([FromBody] CreateSaleRequest request)
|
||||||
|
=> Response(await _inventoryService.CreateSale(new
|
||||||
|
{
|
||||||
|
soldAt = request.SoldAt?.ToString("o"),
|
||||||
|
paymentMethod = request.PaymentMethod,
|
||||||
|
customerId = request.CustomerId,
|
||||||
|
customerName = request.CustomerName,
|
||||||
|
items = request.Items.Select(i => new { productId = i.ProductId, quantity = i.Quantity, unitPrice = i.UnitPrice })
|
||||||
|
}, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("inventory")]
|
||||||
|
[HttpGet("returns")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> GetStockReturns()
|
||||||
|
=> Response(await _inventoryService.GetStockReturns(new { }, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("inventory")]
|
||||||
|
[HttpGet("returns/{id:guid}")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> GetStockReturnById(Guid id)
|
||||||
|
=> Response(await _inventoryService.GetStockReturnById(new { stockReturnId = id }, HttpContext));
|
||||||
|
|
||||||
|
[CustomMiddleware]
|
||||||
|
[TenantResolution]
|
||||||
|
[RequiresPermission("inventory")]
|
||||||
|
[HttpPost("returns")]
|
||||||
|
public async Task<ActionResult<ApiResponse<object>>> CreateStockReturn([FromBody] CreateStockReturnRequest request)
|
||||||
|
=> Response(await _inventoryService.CreateStockReturn(new
|
||||||
|
{
|
||||||
|
productId = request.ProductId,
|
||||||
|
quantity = request.Quantity,
|
||||||
|
category = request.Category,
|
||||||
|
saleId = request.SaleId,
|
||||||
|
grnId = request.GrnId,
|
||||||
|
notes = request.Notes,
|
||||||
|
returnedAt = request.ReturnedAt?.ToString("o")
|
||||||
|
}, HttpContext));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
namespace Fitness_core_BE.DTOs.Inventory
|
||||||
|
{
|
||||||
|
public class ProductDto
|
||||||
|
{
|
||||||
|
public Guid ProductId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Sku { get; set; }
|
||||||
|
/// <summary>Base64 data URL, computed on read from ImageId -- null when no image is set.</summary>
|
||||||
|
public string? ImageUrl { get; set; }
|
||||||
|
public string UnitOfMeasure { get; set; } = string.Empty;
|
||||||
|
public decimal CostPrice { get; set; }
|
||||||
|
public decimal SellingPrice { get; set; }
|
||||||
|
public decimal QuantityOnHand { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One row in a Product's detail-page stock-movement history.</summary>
|
||||||
|
public class ProductMovementDto
|
||||||
|
{
|
||||||
|
/// <summary>"GRN" | "Sale" | "Return".</summary>
|
||||||
|
public string Type { get; set; } = string.Empty;
|
||||||
|
/// <summary>The GRN/Sale number, or the return's Category.</summary>
|
||||||
|
public string Reference { get; set; } = string.Empty;
|
||||||
|
/// <summary>Signed: positive for a GRN (stock in), negative for a Sale or Return (stock out).</summary>
|
||||||
|
public decimal QuantityChange { get; set; }
|
||||||
|
public DateTime Date { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ProductDetailDto : ProductDto
|
||||||
|
{
|
||||||
|
public List<ProductMovementDto> Movements { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SupplierDto
|
||||||
|
{
|
||||||
|
public Guid SupplierId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GoodsReceivedNoteItemDto
|
||||||
|
{
|
||||||
|
public Guid ProductId { get; set; }
|
||||||
|
public string ProductName { get; set; } = string.Empty;
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public decimal UnitCost { get; set; }
|
||||||
|
public decimal LineTotal { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GoodsReceivedNoteDto
|
||||||
|
{
|
||||||
|
public Guid GrnId { get; set; }
|
||||||
|
public string GrnNumber { get; set; } = string.Empty;
|
||||||
|
/// <summary>Null when this GRN was recorded without a supplier.</summary>
|
||||||
|
public Guid? SupplierId { get; set; }
|
||||||
|
public string? SupplierName { get; set; }
|
||||||
|
public DateTime ReceivedAt { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
public List<GoodsReceivedNoteItemDto> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SaleItemDto
|
||||||
|
{
|
||||||
|
public Guid ProductId { get; set; }
|
||||||
|
public string ProductName { get; set; } = string.Empty;
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
public decimal LineTotal { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SaleDto
|
||||||
|
{
|
||||||
|
public Guid SaleId { get; set; }
|
||||||
|
public string SaleNumber { get; set; } = string.Empty;
|
||||||
|
public DateTime SoldAt { get; set; }
|
||||||
|
public string PaymentMethod { get; set; } = string.Empty;
|
||||||
|
public Guid? CustomerId { get; set; }
|
||||||
|
public string? CustomerName { get; set; }
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
public List<SaleItemDto> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StockReturnDto
|
||||||
|
{
|
||||||
|
public Guid StockReturnId { get; set; }
|
||||||
|
public Guid ProductId { get; set; }
|
||||||
|
public string ProductName { get; set; } = string.Empty;
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public string Category { get; set; } = string.Empty;
|
||||||
|
/// <summary>Optional traceability link -- which sale this return came from, if any.</summary>
|
||||||
|
public Guid? SaleId { get; set; }
|
||||||
|
public string? SaleNumber { get; set; }
|
||||||
|
/// <summary>Optional traceability link -- which GRN this return relates to, if any.</summary>
|
||||||
|
public Guid? GrnId { get; set; }
|
||||||
|
public string? GrnNumber { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public DateTime ReturnedAt { get; set; }
|
||||||
|
/// <summary>Quantity * Product.CostPrice at the moment of the return -- auto-computed.</summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.DTOs.Inventory
|
||||||
|
{
|
||||||
|
public class CreateProductRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string UnitOfMeasure { get; set; } = "pcs";
|
||||||
|
|
||||||
|
[Range(typeof(decimal), "0", "79228162514264337593543950335", ErrorMessage = "SellingPrice cannot be negative")]
|
||||||
|
public decimal SellingPrice { get; set; } = 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sku is system-generated at creation and never included here -- it can't be edited.</summary>
|
||||||
|
public class UpdateProductRequest
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? UnitOfMeasure { get; set; }
|
||||||
|
public decimal? SellingPrice { get; set; }
|
||||||
|
public bool? IsActive { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateSupplierRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Address { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GrnItemRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public Guid ProductId { get; set; }
|
||||||
|
|
||||||
|
[Range(0.0001, double.MaxValue, ErrorMessage = "Quantity must be greater than 0")]
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
|
||||||
|
[Range(typeof(decimal), "0", "79228162514264337593543950335", ErrorMessage = "UnitCost cannot be negative")]
|
||||||
|
public decimal UnitCost { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateGrnRequest
|
||||||
|
{
|
||||||
|
/// <summary>Optional -- some GRNs don't have a supplier on file.</summary>
|
||||||
|
public Guid? SupplierId { get; set; }
|
||||||
|
public DateTime? ReceivedAt { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
[Required, MinLength(1, ErrorMessage = "At least one item is required")]
|
||||||
|
public List<GrnItemRequest> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SaleItemRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public Guid ProductId { get; set; }
|
||||||
|
|
||||||
|
[Range(0.0001, double.MaxValue, ErrorMessage = "Quantity must be greater than 0")]
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
|
||||||
|
[Range(typeof(decimal), "0", "79228162514264337593543950335", ErrorMessage = "UnitPrice cannot be negative")]
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateSaleRequest
|
||||||
|
{
|
||||||
|
public DateTime? SoldAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>"Cash" | "Bank".</summary>
|
||||||
|
[Required]
|
||||||
|
public string PaymentMethod { get; set; } = "Cash";
|
||||||
|
|
||||||
|
public Guid? CustomerId { get; set; }
|
||||||
|
public string? CustomerName { get; set; }
|
||||||
|
|
||||||
|
[Required, MinLength(1, ErrorMessage = "At least one item is required")]
|
||||||
|
public List<SaleItemRequest> Items { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateStockReturnRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public Guid ProductId { get; set; }
|
||||||
|
|
||||||
|
[Range(0.0001, double.MaxValue, ErrorMessage = "Quantity must be greater than 0")]
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>"Expired" | "Damaged" | "Lost" | "SupplierReturn" | "CustomerReturn" | "Other".
|
||||||
|
/// Only "CustomerReturn" adds back to stock; every other category removes from it.</summary>
|
||||||
|
[Required]
|
||||||
|
public string Category { get; set; } = "Other";
|
||||||
|
|
||||||
|
/// <summary>Optional traceability link -- e.g. a customer return that came from a
|
||||||
|
/// specific sale. Many returns (damaged shelf stock, etc.) don't have one.</summary>
|
||||||
|
public Guid? SaleId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional traceability link -- e.g. stock sent back to the supplier from a
|
||||||
|
/// specific GRN. Many returns don't have one.</summary>
|
||||||
|
public Guid? GrnId { get; set; }
|
||||||
|
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public DateTime? ReturnedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Fitness_core_BE.Models;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.Interfaces
|
||||||
|
{
|
||||||
|
public interface IInventoryRepository
|
||||||
|
{
|
||||||
|
// Products
|
||||||
|
Task<List<Product>> GetProducts(Guid clientId, bool includeInactive = false);
|
||||||
|
Task<Product?> GetProductById(Guid productId);
|
||||||
|
Task<Product> CreateProduct(Product product);
|
||||||
|
Task<Product> UpdateProduct(Product product);
|
||||||
|
/// <summary>Next sequential number for this client, e.g. 3 -> "PRD-0004" -- Sku is
|
||||||
|
/// system-generated, never user-entered.</summary>
|
||||||
|
Task<int> GetNextProductSequence(Guid clientId);
|
||||||
|
|
||||||
|
// Product detail page: stock-movement history (GRN-in, Sale-out, Return-out).
|
||||||
|
Task<List<GoodsReceivedNoteItem>> GetGrnItemsForProduct(Guid productId);
|
||||||
|
Task<List<SaleItem>> GetSaleItemsForProduct(Guid productId);
|
||||||
|
Task<List<StockReturn>> GetStockReturnsForProduct(Guid productId);
|
||||||
|
|
||||||
|
// Suppliers
|
||||||
|
Task<List<Supplier>> GetSuppliers(Guid clientId, bool includeInactive = false);
|
||||||
|
Task<Supplier?> GetSupplierById(Guid supplierId);
|
||||||
|
Task<Supplier> CreateSupplier(Supplier supplier);
|
||||||
|
|
||||||
|
// GRN
|
||||||
|
Task<GoodsReceivedNote> CreateGrn(GoodsReceivedNote grn);
|
||||||
|
Task<List<GoodsReceivedNote>> GetGrns(Guid clientId);
|
||||||
|
Task<GoodsReceivedNote?> GetGrnById(Guid grnId);
|
||||||
|
/// <summary>Next sequential number for this client, e.g. 3 -> "GRN-0004".</summary>
|
||||||
|
Task<int> GetNextGrnSequence(Guid clientId);
|
||||||
|
|
||||||
|
// Sales
|
||||||
|
Task<Sale> CreateSale(Sale sale);
|
||||||
|
Task<List<Sale>> GetSales(Guid clientId);
|
||||||
|
Task<Sale?> GetSaleById(Guid saleId);
|
||||||
|
Task<int> GetNextSaleSequence(Guid clientId);
|
||||||
|
|
||||||
|
// Returns
|
||||||
|
Task<StockReturn> CreateStockReturn(StockReturn stockReturn);
|
||||||
|
Task<List<StockReturn>> GetStockReturns(Guid clientId);
|
||||||
|
Task<StockReturn?> GetStockReturnById(Guid stockReturnId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace Fitness_core_BE.Interfaces.Services
|
||||||
|
{
|
||||||
|
public interface IInventoryService
|
||||||
|
{
|
||||||
|
Task<object> GetProducts(object payload, HttpContext httpContext);
|
||||||
|
Task<object> GetProductById(object payload, HttpContext httpContext);
|
||||||
|
Task<object> CreateProduct(object payload, HttpContext httpContext);
|
||||||
|
Task<object> UpdateProduct(object payload, HttpContext httpContext);
|
||||||
|
Task<object> UploadProductImage(Guid productId, IFormFile image, HttpContext httpContext);
|
||||||
|
|
||||||
|
Task<object> GetSuppliers(object payload, HttpContext httpContext);
|
||||||
|
Task<object> CreateSupplier(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
|
Task<object> CreateGrn(object payload, HttpContext httpContext);
|
||||||
|
Task<object> GetGrns(object payload, HttpContext httpContext);
|
||||||
|
Task<object> GetGrnById(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
|
Task<object> CreateSale(object payload, HttpContext httpContext);
|
||||||
|
Task<object> GetSales(object payload, HttpContext httpContext);
|
||||||
|
Task<object> GetSaleById(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
|
Task<object> CreateStockReturn(object payload, HttpContext httpContext);
|
||||||
|
Task<object> GetStockReturns(object payload, HttpContext httpContext);
|
||||||
|
Task<object> GetStockReturnById(object payload, HttpContext httpContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.Models
|
||||||
|
{
|
||||||
|
/// <summary>Stock coming in, optionally from a Supplier -- some GRNs don't have one (e.g.
|
||||||
|
/// self-produced stock, or the supplier just isn't on file). Creating one increments each
|
||||||
|
/// line's Product.QuantityOnHand and overwrites Product.CostPrice with that line's UnitCost
|
||||||
|
/// (a simple latest-cost approach, not a weighted average).</summary>
|
||||||
|
public class GoodsReceivedNote
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid GrnId { get; set; }
|
||||||
|
|
||||||
|
public Guid FKClientId { get; set; }
|
||||||
|
[ForeignKey(nameof(FKClientId))]
|
||||||
|
public Client Client { get; set; }
|
||||||
|
|
||||||
|
public Guid? FKSupplierId { get; set; }
|
||||||
|
[ForeignKey(nameof(FKSupplierId))]
|
||||||
|
public Supplier? Supplier { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string GrnNumber { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime ReceivedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
[MaxLength(300)]
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public decimal TotalAmount { get; set; } = 0m;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public virtual ICollection<GoodsReceivedNoteItem> Items { get; set; } = new List<GoodsReceivedNoteItem>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GoodsReceivedNoteItem
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid GrnItemId { get; set; }
|
||||||
|
|
||||||
|
public Guid FKGrnId { get; set; }
|
||||||
|
[ForeignKey(nameof(FKGrnId))]
|
||||||
|
public GoodsReceivedNote? Grn { get; set; }
|
||||||
|
|
||||||
|
public Guid FKProductId { get; set; }
|
||||||
|
[ForeignKey(nameof(FKProductId))]
|
||||||
|
public Product? Product { get; set; }
|
||||||
|
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public decimal UnitCost { get; set; }
|
||||||
|
public decimal LineTotal { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.Models
|
||||||
|
{
|
||||||
|
/// <summary>A stocked item sold at the gym (snacks, supplements, merchandise). QuantityOnHand
|
||||||
|
/// is a maintained running balance -- adjusted by GoodsReceivedNoteItem (+), SaleItem (-) and
|
||||||
|
/// StockReturn (-) -- not computed from those tables on every read.</summary>
|
||||||
|
public class Product
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid ProductId { get; set; }
|
||||||
|
|
||||||
|
public Guid FKClientId { get; set; }
|
||||||
|
[ForeignKey(nameof(FKClientId))]
|
||||||
|
public Client Client { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string? Sku { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Relative /Uploads path to the product's photo (same convention as
|
||||||
|
/// ClientBusiness.BusinessLogoId) -- null when no image has been uploaded.</summary>
|
||||||
|
[MaxLength(300)]
|
||||||
|
public string? ImageId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string UnitOfMeasure { get; set; } = "pcs";
|
||||||
|
|
||||||
|
/// <summary>Set from the most recent GRN line's UnitCost -- a simple latest-cost
|
||||||
|
/// approach, not a weighted average.</summary>
|
||||||
|
public decimal CostPrice { get; set; } = 0m;
|
||||||
|
|
||||||
|
public decimal SellingPrice { get; set; } = 0m;
|
||||||
|
|
||||||
|
public decimal QuantityOnHand { get; set; } = 0m;
|
||||||
|
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.Models
|
||||||
|
{
|
||||||
|
/// <summary>Stock going out to a customer. Creating one decrements each line's
|
||||||
|
/// Product.QuantityOnHand -- the whole sale is rejected up front if any line would go
|
||||||
|
/// negative (no backorders).</summary>
|
||||||
|
public class Sale
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid SaleId { get; set; }
|
||||||
|
|
||||||
|
public Guid FKClientId { get; set; }
|
||||||
|
[ForeignKey(nameof(FKClientId))]
|
||||||
|
public Client Client { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string SaleNumber { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime SoldAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>"Cash" | "Bank" -- same convention as Booking.PaymentMethod.</summary>
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string PaymentMethod { get; set; } = "Cash";
|
||||||
|
|
||||||
|
/// <summary>Optional link to a registered member, purely for identification -- mirrors
|
||||||
|
/// Booking.FKCustomerId (see BookingService.ResolveOptionalCustomerLinkAsync). A walk-in
|
||||||
|
/// buyer leaves this null and uses CustomerName instead.</summary>
|
||||||
|
public Guid? FKCustomerId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string? CustomerName { get; set; }
|
||||||
|
|
||||||
|
public decimal TotalAmount { get; set; } = 0m;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public virtual ICollection<SaleItem> Items { get; set; } = new List<SaleItem>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SaleItem
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid SaleItemId { get; set; }
|
||||||
|
|
||||||
|
public Guid FKSaleId { get; set; }
|
||||||
|
[ForeignKey(nameof(FKSaleId))]
|
||||||
|
public Sale? Sale { get; set; }
|
||||||
|
|
||||||
|
public Guid FKProductId { get; set; }
|
||||||
|
[ForeignKey(nameof(FKProductId))]
|
||||||
|
public Product? Product { get; set; }
|
||||||
|
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
public decimal LineTotal { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.Models
|
||||||
|
{
|
||||||
|
/// <summary>A stock adjustment -- expired, damaged, lost, sent back to the supplier, handed
|
||||||
|
/// back by a customer, or anything else (Category == "Other"). Direction depends on the
|
||||||
|
/// category: "CustomerReturn" is the one case that ADDS back to Product.QuantityOnHand (the
|
||||||
|
/// item physically comes back after an earlier sale already removed it); every other
|
||||||
|
/// category REMOVES from it (stock we currently hold being written off or sent away). Amount
|
||||||
|
/// is auto-computed (Quantity * Product.CostPrice at the moment of the return) for the
|
||||||
|
/// Profit & Loss impact. FKSaleId/FKGrnId are both optional, independent traceability
|
||||||
|
/// links to the specific Sale or GRN this return relates to -- many returns (e.g. damaged
|
||||||
|
/// stock sitting on the shelf) don't trace back to either.</summary>
|
||||||
|
public class StockReturn
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid StockReturnId { get; set; }
|
||||||
|
|
||||||
|
public Guid FKClientId { get; set; }
|
||||||
|
[ForeignKey(nameof(FKClientId))]
|
||||||
|
public Client Client { get; set; }
|
||||||
|
|
||||||
|
public Guid FKProductId { get; set; }
|
||||||
|
[ForeignKey(nameof(FKProductId))]
|
||||||
|
public Product? Product { get; set; }
|
||||||
|
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>"Expired" | "Damaged" | "Lost" | "SupplierReturn" | "CustomerReturn" | "Other".
|
||||||
|
/// Only "CustomerReturn" adds back to stock -- see class doc comment.</summary>
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string Category { get; set; } = "Other";
|
||||||
|
|
||||||
|
/// <summary>Optional -- e.g. a customer return that came from a specific sale.</summary>
|
||||||
|
public Guid? FKSaleId { get; set; }
|
||||||
|
[ForeignKey(nameof(FKSaleId))]
|
||||||
|
public Sale? Sale { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional -- e.g. stock sent back to the supplier from a specific GRN.</summary>
|
||||||
|
public Guid? FKGrnId { get; set; }
|
||||||
|
[ForeignKey(nameof(FKGrnId))]
|
||||||
|
public GoodsReceivedNote? Grn { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(300)]
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public DateTime ReturnedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Quantity * Product.CostPrice at the moment of the return -- snapshotted so
|
||||||
|
/// it doesn't drift later if CostPrice changes.</summary>
|
||||||
|
public decimal Amount { get; set; } = 0m;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.Models
|
||||||
|
{
|
||||||
|
/// <summary>Who stock is received from on a GoodsReceivedNote.</summary>
|
||||||
|
public class Supplier
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid SupplierId { get; set; }
|
||||||
|
|
||||||
|
public Guid FKClientId { get; set; }
|
||||||
|
[ForeignKey(nameof(FKClientId))]
|
||||||
|
public Client Client { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(300)]
|
||||||
|
public string? Address { get; set; }
|
||||||
|
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,6 +142,7 @@ builder.Services.AddScoped<IPlatformBillingRepository, PlatformBillingRepository
|
|||||||
builder.Services.AddScoped<ICustomerNotificationRepository, CustomerNotificationRepository>();
|
builder.Services.AddScoped<ICustomerNotificationRepository, CustomerNotificationRepository>();
|
||||||
builder.Services.AddScoped<IFacilityRepository, FacilityRepository>();
|
builder.Services.AddScoped<IFacilityRepository, FacilityRepository>();
|
||||||
builder.Services.AddScoped<IBookingRepository, BookingRepository>();
|
builder.Services.AddScoped<IBookingRepository, BookingRepository>();
|
||||||
|
builder.Services.AddScoped<IInventoryRepository, InventoryRepository>();
|
||||||
builder.Services.AddScoped<ITimeBankCardRepository, TimeBankCardRepository>();
|
builder.Services.AddScoped<ITimeBankCardRepository, TimeBankCardRepository>();
|
||||||
builder.Services.AddScoped<IRfidTagRepository, RfidTagRepository>();
|
builder.Services.AddScoped<IRfidTagRepository, RfidTagRepository>();
|
||||||
|
|
||||||
@@ -163,6 +164,7 @@ builder.Services.AddScoped<ICustomerInvoiceService, CustomerInvoiceService>();
|
|||||||
builder.Services.AddScoped<ICustomerSubscriptionService, CustomerSubscriptionService>();
|
builder.Services.AddScoped<ICustomerSubscriptionService, CustomerSubscriptionService>();
|
||||||
builder.Services.AddScoped<IFacilityService, FacilityService>();
|
builder.Services.AddScoped<IFacilityService, FacilityService>();
|
||||||
builder.Services.AddScoped<IBookingService, BookingService>();
|
builder.Services.AddScoped<IBookingService, BookingService>();
|
||||||
|
builder.Services.AddScoped<IInventoryService, InventoryService>();
|
||||||
builder.Services.AddScoped<IRfidTagService, RfidTagService>();
|
builder.Services.AddScoped<IRfidTagService, RfidTagService>();
|
||||||
builder.Services.AddScoped<ITimeBankCardService, TimeBankCardService>();
|
builder.Services.AddScoped<ITimeBankCardService, TimeBankCardService>();
|
||||||
builder.Services.AddScoped<IBootstrapService, BootstrapService>();
|
builder.Services.AddScoped<IBootstrapService, BootstrapService>();
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
using Fitness_core_BE.Interfaces;
|
||||||
|
using Fitness_core_BE.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.Repos
|
||||||
|
{
|
||||||
|
public class InventoryRepository : IInventoryRepository
|
||||||
|
{
|
||||||
|
private readonly TenantDbContext _context;
|
||||||
|
|
||||||
|
public InventoryRepository(TenantDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Products ---
|
||||||
|
|
||||||
|
public async Task<List<Product>> GetProducts(Guid clientId, bool includeInactive = false)
|
||||||
|
{
|
||||||
|
var query = _context.Products.Where(p => p.FKClientId == clientId);
|
||||||
|
if (!includeInactive)
|
||||||
|
query = query.Where(p => p.IsActive);
|
||||||
|
|
||||||
|
return await query.OrderBy(p => p.Name).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Product?> GetProductById(Guid productId)
|
||||||
|
=> _context.Products.FirstOrDefaultAsync(p => p.ProductId == productId);
|
||||||
|
|
||||||
|
public async Task<Product> CreateProduct(Product product)
|
||||||
|
{
|
||||||
|
product.ProductId = Guid.NewGuid();
|
||||||
|
product.CreatedAt = DateTime.UtcNow;
|
||||||
|
product.IsActive = true;
|
||||||
|
product.CostPrice = 0m;
|
||||||
|
product.QuantityOnHand = 0m;
|
||||||
|
|
||||||
|
_context.Products.Add(product);
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Product> UpdateProduct(Product product)
|
||||||
|
{
|
||||||
|
var existing = await _context.Products.FindAsync(product.ProductId)
|
||||||
|
?? throw new KeyNotFoundException("Product not found");
|
||||||
|
|
||||||
|
// Sku is system-generated at creation and never edited -- not copied here.
|
||||||
|
existing.Name = product.Name;
|
||||||
|
existing.UnitOfMeasure = product.UnitOfMeasure;
|
||||||
|
existing.SellingPrice = product.SellingPrice;
|
||||||
|
existing.IsActive = product.IsActive;
|
||||||
|
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetNextProductSequence(Guid clientId)
|
||||||
|
=> await _context.Products.CountAsync(p => p.FKClientId == clientId) + 1;
|
||||||
|
|
||||||
|
public Task<List<GoodsReceivedNoteItem>> GetGrnItemsForProduct(Guid productId)
|
||||||
|
=> _context.GoodsReceivedNoteItems
|
||||||
|
.Include(i => i.Grn)
|
||||||
|
.Where(i => i.FKProductId == productId)
|
||||||
|
.OrderByDescending(i => i.Grn!.ReceivedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public Task<List<SaleItem>> GetSaleItemsForProduct(Guid productId)
|
||||||
|
=> _context.SaleItems
|
||||||
|
.Include(i => i.Sale)
|
||||||
|
.Where(i => i.FKProductId == productId)
|
||||||
|
.OrderByDescending(i => i.Sale!.SoldAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public Task<List<StockReturn>> GetStockReturnsForProduct(Guid productId)
|
||||||
|
=> _context.StockReturns
|
||||||
|
.Where(r => r.FKProductId == productId)
|
||||||
|
.OrderByDescending(r => r.ReturnedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// --- Suppliers ---
|
||||||
|
|
||||||
|
public async Task<List<Supplier>> GetSuppliers(Guid clientId, bool includeInactive = false)
|
||||||
|
{
|
||||||
|
var query = _context.Suppliers.Where(s => s.FKClientId == clientId);
|
||||||
|
if (!includeInactive)
|
||||||
|
query = query.Where(s => s.IsActive);
|
||||||
|
|
||||||
|
return await query.OrderBy(s => s.Name).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Supplier?> GetSupplierById(Guid supplierId)
|
||||||
|
=> _context.Suppliers.FirstOrDefaultAsync(s => s.SupplierId == supplierId);
|
||||||
|
|
||||||
|
public async Task<Supplier> CreateSupplier(Supplier supplier)
|
||||||
|
{
|
||||||
|
supplier.SupplierId = Guid.NewGuid();
|
||||||
|
supplier.CreatedAt = DateTime.UtcNow;
|
||||||
|
supplier.IsActive = true;
|
||||||
|
|
||||||
|
_context.Suppliers.Add(supplier);
|
||||||
|
return supplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GRN ---
|
||||||
|
|
||||||
|
public async Task<GoodsReceivedNote> CreateGrn(GoodsReceivedNote grn)
|
||||||
|
{
|
||||||
|
grn.GrnId = Guid.NewGuid();
|
||||||
|
grn.CreatedAt = DateTime.UtcNow;
|
||||||
|
foreach (var item in grn.Items)
|
||||||
|
item.GrnItemId = Guid.NewGuid();
|
||||||
|
|
||||||
|
_context.GoodsReceivedNotes.Add(grn);
|
||||||
|
return grn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<GoodsReceivedNote>> GetGrns(Guid clientId)
|
||||||
|
=> _context.GoodsReceivedNotes
|
||||||
|
.Include(g => g.Supplier)
|
||||||
|
.Include(g => g.Items).ThenInclude(i => i.Product)
|
||||||
|
.Where(g => g.FKClientId == clientId)
|
||||||
|
.OrderByDescending(g => g.ReceivedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public Task<GoodsReceivedNote?> GetGrnById(Guid grnId)
|
||||||
|
=> _context.GoodsReceivedNotes
|
||||||
|
.Include(g => g.Supplier)
|
||||||
|
.Include(g => g.Items).ThenInclude(i => i.Product)
|
||||||
|
.FirstOrDefaultAsync(g => g.GrnId == grnId);
|
||||||
|
|
||||||
|
public async Task<int> GetNextGrnSequence(Guid clientId)
|
||||||
|
=> await _context.GoodsReceivedNotes.CountAsync(g => g.FKClientId == clientId) + 1;
|
||||||
|
|
||||||
|
// --- Sales ---
|
||||||
|
|
||||||
|
public async Task<Sale> CreateSale(Sale sale)
|
||||||
|
{
|
||||||
|
sale.SaleId = Guid.NewGuid();
|
||||||
|
sale.CreatedAt = DateTime.UtcNow;
|
||||||
|
foreach (var item in sale.Items)
|
||||||
|
item.SaleItemId = Guid.NewGuid();
|
||||||
|
|
||||||
|
_context.Sales.Add(sale);
|
||||||
|
return sale;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<Sale>> GetSales(Guid clientId)
|
||||||
|
=> _context.Sales
|
||||||
|
.Include(s => s.Items).ThenInclude(i => i.Product)
|
||||||
|
.Where(s => s.FKClientId == clientId)
|
||||||
|
.OrderByDescending(s => s.SoldAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public Task<Sale?> GetSaleById(Guid saleId)
|
||||||
|
=> _context.Sales
|
||||||
|
.Include(s => s.Items).ThenInclude(i => i.Product)
|
||||||
|
.FirstOrDefaultAsync(s => s.SaleId == saleId);
|
||||||
|
|
||||||
|
public async Task<int> GetNextSaleSequence(Guid clientId)
|
||||||
|
=> await _context.Sales.CountAsync(s => s.FKClientId == clientId) + 1;
|
||||||
|
|
||||||
|
// --- Returns ---
|
||||||
|
|
||||||
|
public async Task<StockReturn> CreateStockReturn(StockReturn stockReturn)
|
||||||
|
{
|
||||||
|
stockReturn.StockReturnId = Guid.NewGuid();
|
||||||
|
stockReturn.CreatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
_context.StockReturns.Add(stockReturn);
|
||||||
|
return stockReturn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<StockReturn>> GetStockReturns(Guid clientId)
|
||||||
|
=> _context.StockReturns
|
||||||
|
.Include(r => r.Product)
|
||||||
|
.Include(r => r.Sale)
|
||||||
|
.Include(r => r.Grn)
|
||||||
|
.Where(r => r.FKClientId == clientId)
|
||||||
|
.OrderByDescending(r => r.ReturnedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public Task<StockReturn?> GetStockReturnById(Guid stockReturnId)
|
||||||
|
=> _context.StockReturns
|
||||||
|
.Include(r => r.Product)
|
||||||
|
.Include(r => r.Sale)
|
||||||
|
.Include(r => r.Grn)
|
||||||
|
.FirstOrDefaultAsync(r => r.StockReturnId == stockReturnId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,9 +105,68 @@ namespace Fitness_core_BE.Repos
|
|||||||
})
|
})
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// Inventory: a Sale's revenue, a GRN's cost, and a StockReturn's Amount (staff-
|
||||||
|
// entered -- see StockReturn's doc comment). Every return Category still posts the
|
||||||
|
// same way here (one Expense category, reducing profit) even though CustomerReturn
|
||||||
|
// is the one category that adds the stock back -- the P&L side stays uniform while
|
||||||
|
// the stock-direction side doesn't.
|
||||||
|
var productSales = await _context.Sales
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(sale =>
|
||||||
|
sale.FKClientId == clientId &&
|
||||||
|
sale.SoldAt >= fromUtc &&
|
||||||
|
sale.SoldAt < toUtc)
|
||||||
|
.Select(sale => new LedgerEntryDto
|
||||||
|
{
|
||||||
|
EntryType = "Income",
|
||||||
|
Category = "Product Sales",
|
||||||
|
Description = sale.SaleNumber,
|
||||||
|
Amount = sale.TotalAmount,
|
||||||
|
EntryDate = sale.SoldAt,
|
||||||
|
IsManual = false
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var inventoryPurchases = await _context.GoodsReceivedNotes
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(grn =>
|
||||||
|
grn.FKClientId == clientId &&
|
||||||
|
grn.ReceivedAt >= fromUtc &&
|
||||||
|
grn.ReceivedAt < toUtc)
|
||||||
|
.Select(grn => new LedgerEntryDto
|
||||||
|
{
|
||||||
|
EntryType = "Expense",
|
||||||
|
Category = "Inventory Purchases",
|
||||||
|
Description = grn.GrnNumber,
|
||||||
|
Amount = grn.TotalAmount,
|
||||||
|
EntryDate = grn.ReceivedAt,
|
||||||
|
IsManual = false
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var inventoryWriteOffs = await _context.StockReturns
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(stockReturn =>
|
||||||
|
stockReturn.FKClientId == clientId &&
|
||||||
|
stockReturn.ReturnedAt >= fromUtc &&
|
||||||
|
stockReturn.ReturnedAt < toUtc)
|
||||||
|
.Select(stockReturn => new LedgerEntryDto
|
||||||
|
{
|
||||||
|
EntryType = "Expense",
|
||||||
|
Category = "Inventory Write-offs",
|
||||||
|
Description = stockReturn.Category + " — " + stockReturn.Product!.Name,
|
||||||
|
Amount = stockReturn.Amount,
|
||||||
|
EntryDate = stockReturn.ReturnedAt,
|
||||||
|
IsManual = false
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
memberPayments.AddRange(platformInvoices);
|
memberPayments.AddRange(platformInvoices);
|
||||||
memberPayments.AddRange(staffPayments);
|
memberPayments.AddRange(staffPayments);
|
||||||
memberPayments.AddRange(bookingPayments);
|
memberPayments.AddRange(bookingPayments);
|
||||||
|
memberPayments.AddRange(productSales);
|
||||||
|
memberPayments.AddRange(inventoryPurchases);
|
||||||
|
memberPayments.AddRange(inventoryWriteOffs);
|
||||||
return memberPayments;
|
return memberPayments;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,673 @@
|
|||||||
|
using Fitness_core_BE.DTOs.Inventory;
|
||||||
|
using Fitness_core_BE.Exceptions;
|
||||||
|
using Fitness_core_BE.Helpers;
|
||||||
|
using Fitness_core_BE.Infra.UoW;
|
||||||
|
using Fitness_core_BE.Interfaces;
|
||||||
|
using Fitness_core_BE.Interfaces.Services;
|
||||||
|
using Fitness_core_BE.Models;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Fitness_core_BE.Services
|
||||||
|
{
|
||||||
|
public class InventoryService : IInventoryService
|
||||||
|
{
|
||||||
|
private static readonly string[] ValidReturnCategories =
|
||||||
|
["Expired", "Damaged", "Lost", "SupplierReturn", "CustomerReturn", "Other"];
|
||||||
|
|
||||||
|
private readonly IInventoryRepository _inventoryRepository;
|
||||||
|
private readonly ICustomerRepository _customerRepository;
|
||||||
|
private readonly IUnitOfWork _uow;
|
||||||
|
private readonly UserRoleHelper _userRoleHelper;
|
||||||
|
|
||||||
|
public InventoryService(
|
||||||
|
IInventoryRepository inventoryRepository,
|
||||||
|
ICustomerRepository customerRepository,
|
||||||
|
IUnitOfWork uow,
|
||||||
|
UserRoleHelper userRoleHelper)
|
||||||
|
{
|
||||||
|
_inventoryRepository = inventoryRepository;
|
||||||
|
_customerRepository = customerRepository;
|
||||||
|
_uow = uow;
|
||||||
|
_userRoleHelper = userRoleHelper;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Products ---
|
||||||
|
|
||||||
|
public async Task<object> GetProducts(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
bool includeInactive = data.ContainsKey("includeInactive") && data["includeInactive"].GetBoolean();
|
||||||
|
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
var products = await _inventoryRepository.GetProducts(clientId, includeInactive);
|
||||||
|
|
||||||
|
return (await Task.WhenAll(products.Select(MapToDtoAsync))).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> GetProductById(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
var productId = Guid.Parse(data["productId"].GetString()!);
|
||||||
|
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
var product = await _inventoryRepository.GetProductById(productId);
|
||||||
|
if (product == null || product.FKClientId != clientId)
|
||||||
|
throw new KeyNotFoundException("Product not found");
|
||||||
|
|
||||||
|
var grnItems = await _inventoryRepository.GetGrnItemsForProduct(productId);
|
||||||
|
var saleItems = await _inventoryRepository.GetSaleItemsForProduct(productId);
|
||||||
|
var returns = await _inventoryRepository.GetStockReturnsForProduct(productId);
|
||||||
|
|
||||||
|
var movements = new List<ProductMovementDto>();
|
||||||
|
movements.AddRange(grnItems.Select(i => new ProductMovementDto
|
||||||
|
{
|
||||||
|
Type = "GRN",
|
||||||
|
Reference = i.Grn?.GrnNumber ?? string.Empty,
|
||||||
|
QuantityChange = i.Quantity,
|
||||||
|
Date = i.Grn?.ReceivedAt ?? DateTime.UtcNow
|
||||||
|
}));
|
||||||
|
movements.AddRange(saleItems.Select(i => new ProductMovementDto
|
||||||
|
{
|
||||||
|
Type = "Sale",
|
||||||
|
Reference = i.Sale?.SaleNumber ?? string.Empty,
|
||||||
|
QuantityChange = -i.Quantity,
|
||||||
|
Date = i.Sale?.SoldAt ?? DateTime.UtcNow
|
||||||
|
}));
|
||||||
|
movements.AddRange(returns.Select(r => new ProductMovementDto
|
||||||
|
{
|
||||||
|
Type = "Return",
|
||||||
|
Reference = r.Category,
|
||||||
|
// CustomerReturn is the one category that adds back to stock -- see
|
||||||
|
// StockReturn's doc comment.
|
||||||
|
QuantityChange = r.Category == "CustomerReturn" ? r.Quantity : -r.Quantity,
|
||||||
|
Date = r.ReturnedAt
|
||||||
|
}));
|
||||||
|
movements = movements.OrderByDescending(m => m.Date).ToList();
|
||||||
|
|
||||||
|
var dto = await MapToDtoAsync(product);
|
||||||
|
return new ProductDetailDto
|
||||||
|
{
|
||||||
|
ProductId = dto.ProductId,
|
||||||
|
Name = dto.Name,
|
||||||
|
Sku = dto.Sku,
|
||||||
|
ImageUrl = dto.ImageUrl,
|
||||||
|
UnitOfMeasure = dto.UnitOfMeasure,
|
||||||
|
CostPrice = dto.CostPrice,
|
||||||
|
SellingPrice = dto.SellingPrice,
|
||||||
|
QuantityOnHand = dto.QuantityOnHand,
|
||||||
|
IsActive = dto.IsActive,
|
||||||
|
CreatedAt = dto.CreatedAt,
|
||||||
|
Movements = movements
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> CreateProduct(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
await _uow.BeginAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required");
|
||||||
|
|
||||||
|
var sellingPrice = data.ContainsKey("sellingPrice") ? data["sellingPrice"].GetDecimal() : 0m;
|
||||||
|
if (sellingPrice < 0) throw new ArgumentException("sellingPrice cannot be negative");
|
||||||
|
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
|
||||||
|
var product = new Product
|
||||||
|
{
|
||||||
|
FKClientId = clientId,
|
||||||
|
Name = data["name"].GetString() ?? "",
|
||||||
|
UnitOfMeasure = data.ContainsKey("unitOfMeasure") ? data["unitOfMeasure"].GetString() ?? "pcs" : "pcs",
|
||||||
|
SellingPrice = sellingPrice
|
||||||
|
};
|
||||||
|
|
||||||
|
var sequence = await _inventoryRepository.GetNextProductSequence(clientId);
|
||||||
|
product.Sku = $"PRD-{sequence:D4}";
|
||||||
|
|
||||||
|
var result = await _inventoryRepository.CreateProduct(product);
|
||||||
|
await _uow.CommitAsync();
|
||||||
|
|
||||||
|
return await MapToDtoAsync(result);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await _uow.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> UpdateProduct(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
await _uow.BeginAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
var productId = Guid.Parse(data["productId"].GetString()!);
|
||||||
|
|
||||||
|
var existing = await _inventoryRepository.GetProductById(productId)
|
||||||
|
?? throw new KeyNotFoundException("Product not found");
|
||||||
|
|
||||||
|
// Sku is system-generated at creation and never editable here.
|
||||||
|
if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name;
|
||||||
|
if (data.ContainsKey("unitOfMeasure")) existing.UnitOfMeasure = data["unitOfMeasure"].GetString() ?? existing.UnitOfMeasure;
|
||||||
|
if (data.ContainsKey("sellingPrice"))
|
||||||
|
{
|
||||||
|
var price = data["sellingPrice"].GetDecimal();
|
||||||
|
if (price < 0) throw new ArgumentException("sellingPrice cannot be negative");
|
||||||
|
existing.SellingPrice = price;
|
||||||
|
}
|
||||||
|
if (data.ContainsKey("isActive")) existing.IsActive = data["isActive"].GetBoolean();
|
||||||
|
|
||||||
|
var result = await _inventoryRepository.UpdateProduct(existing);
|
||||||
|
await _uow.CommitAsync();
|
||||||
|
|
||||||
|
return await MapToDtoAsync(result);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await _uow.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> UploadProductImage(Guid productId, IFormFile image, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
var product = await _inventoryRepository.GetProductById(productId);
|
||||||
|
if (product == null || product.FKClientId != clientId)
|
||||||
|
throw new KeyNotFoundException("Product not found");
|
||||||
|
|
||||||
|
if (image == null || image.Length == 0)
|
||||||
|
throw new BadRequestException("No image file provided.");
|
||||||
|
|
||||||
|
string extension = Path.GetExtension(image.FileName).ToLowerInvariant();
|
||||||
|
string[] allowed = { ".jpg", ".jpeg", ".png", ".webp" };
|
||||||
|
if (!allowed.Contains(extension))
|
||||||
|
throw new BadRequestException("Image must be a jpg, png or webp file.");
|
||||||
|
if (image.Length > 5 * 1024 * 1024)
|
||||||
|
throw new BadRequestException("Image too large (max 5MB).");
|
||||||
|
|
||||||
|
string uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "Uploads", "product-images", clientId.ToString()));
|
||||||
|
if (!Directory.Exists(uploadsRoot))
|
||||||
|
Directory.CreateDirectory(uploadsRoot);
|
||||||
|
|
||||||
|
string filename = $"{productId}_{DateTime.UtcNow:yyyyMMdd_HHmmss}{extension}";
|
||||||
|
string filePath = Path.Combine(uploadsRoot, filename);
|
||||||
|
using (var stream = new FileStream(filePath, FileMode.Create))
|
||||||
|
{
|
||||||
|
await image.CopyToAsync(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _uow.BeginAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
product.ImageId = $"/Uploads/product-images/{clientId}/{Uri.EscapeDataString(filename)}";
|
||||||
|
await _uow.CommitAsync();
|
||||||
|
return await MapToDtoAsync(product);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await _uow.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Suppliers ---
|
||||||
|
|
||||||
|
public async Task<object> GetSuppliers(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
bool includeInactive = data.ContainsKey("includeInactive") && data["includeInactive"].GetBoolean();
|
||||||
|
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
var suppliers = await _inventoryRepository.GetSuppliers(clientId, includeInactive);
|
||||||
|
|
||||||
|
return suppliers.Select(MapToDto).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> CreateSupplier(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
await _uow.BeginAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required");
|
||||||
|
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
|
||||||
|
var supplier = new Supplier
|
||||||
|
{
|
||||||
|
FKClientId = clientId,
|
||||||
|
Name = data["name"].GetString() ?? "",
|
||||||
|
Phone = data.ContainsKey("phone") && data["phone"].ValueKind != JsonValueKind.Null ? data["phone"].GetString() : null,
|
||||||
|
Email = data.ContainsKey("email") && data["email"].ValueKind != JsonValueKind.Null ? data["email"].GetString() : null,
|
||||||
|
Address = data.ContainsKey("address") && data["address"].ValueKind != JsonValueKind.Null ? data["address"].GetString() : null
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _inventoryRepository.CreateSupplier(supplier);
|
||||||
|
await _uow.CommitAsync();
|
||||||
|
|
||||||
|
return MapToDto(result);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await _uow.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- GRN ---
|
||||||
|
|
||||||
|
public async Task<object> CreateGrn(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
await _uow.BeginAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
|
||||||
|
// Optional -- some GRNs don't have a supplier on file.
|
||||||
|
Supplier? supplier = null;
|
||||||
|
Guid? supplierId = data.ContainsKey("supplierId") && data["supplierId"].ValueKind != JsonValueKind.Null
|
||||||
|
? Guid.Parse(data["supplierId"].GetString()!)
|
||||||
|
: null;
|
||||||
|
if (supplierId.HasValue)
|
||||||
|
{
|
||||||
|
supplier = await _inventoryRepository.GetSupplierById(supplierId.Value);
|
||||||
|
if (supplier == null || supplier.FKClientId != clientId)
|
||||||
|
throw new KeyNotFoundException("Supplier not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.ContainsKey("items") || data["items"].ValueKind != JsonValueKind.Array)
|
||||||
|
throw new ArgumentException("At least one item is required");
|
||||||
|
|
||||||
|
var receivedAt = data.ContainsKey("receivedAt") && data["receivedAt"].ValueKind != JsonValueKind.Null
|
||||||
|
? DateTime.Parse(data["receivedAt"].GetString()!).ToUniversalTime()
|
||||||
|
: DateTime.UtcNow;
|
||||||
|
|
||||||
|
var grn = new GoodsReceivedNote
|
||||||
|
{
|
||||||
|
FKClientId = clientId,
|
||||||
|
FKSupplierId = supplierId,
|
||||||
|
ReceivedAt = receivedAt,
|
||||||
|
Notes = data.ContainsKey("notes") && data["notes"].ValueKind != JsonValueKind.Null ? data["notes"].GetString() : null
|
||||||
|
};
|
||||||
|
|
||||||
|
decimal total = 0m;
|
||||||
|
var itemsArray = data["items"].EnumerateArray().ToList();
|
||||||
|
if (itemsArray.Count == 0) throw new ArgumentException("At least one item is required");
|
||||||
|
|
||||||
|
foreach (var itemEl in itemsArray)
|
||||||
|
{
|
||||||
|
var productId = Guid.Parse(itemEl.GetProperty("productId").GetString()!);
|
||||||
|
var quantity = itemEl.GetProperty("quantity").GetDecimal();
|
||||||
|
var unitCost = itemEl.GetProperty("unitCost").GetDecimal();
|
||||||
|
|
||||||
|
if (quantity <= 0) throw new ArgumentException("Quantity must be greater than 0");
|
||||||
|
if (unitCost < 0) throw new ArgumentException("UnitCost cannot be negative");
|
||||||
|
|
||||||
|
var product = await _inventoryRepository.GetProductById(productId);
|
||||||
|
if (product == null || product.FKClientId != clientId)
|
||||||
|
throw new KeyNotFoundException("Product not found");
|
||||||
|
|
||||||
|
var lineTotal = quantity * unitCost;
|
||||||
|
grn.Items.Add(new GoodsReceivedNoteItem
|
||||||
|
{
|
||||||
|
FKProductId = productId,
|
||||||
|
Quantity = quantity,
|
||||||
|
UnitCost = unitCost,
|
||||||
|
LineTotal = lineTotal
|
||||||
|
});
|
||||||
|
|
||||||
|
product.QuantityOnHand += quantity;
|
||||||
|
product.CostPrice = unitCost;
|
||||||
|
total += lineTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
grn.TotalAmount = total;
|
||||||
|
var sequence = await _inventoryRepository.GetNextGrnSequence(clientId);
|
||||||
|
grn.GrnNumber = $"GRN-{sequence:D4}";
|
||||||
|
|
||||||
|
var result = await _inventoryRepository.CreateGrn(grn);
|
||||||
|
await _uow.CommitAsync();
|
||||||
|
|
||||||
|
result.Supplier = supplier;
|
||||||
|
return MapToDto(result);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await _uow.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> GetGrns(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
var grns = await _inventoryRepository.GetGrns(clientId);
|
||||||
|
return grns.Select(MapToDto).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> GetGrnById(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
var grnId = Guid.Parse(data["grnId"].GetString()!);
|
||||||
|
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
var grn = await _inventoryRepository.GetGrnById(grnId);
|
||||||
|
if (grn == null || grn.FKClientId != clientId)
|
||||||
|
throw new KeyNotFoundException("GRN not found");
|
||||||
|
|
||||||
|
return MapToDto(grn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sales ---
|
||||||
|
|
||||||
|
public async Task<object> CreateSale(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
await _uow.BeginAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
|
||||||
|
var paymentMethod = data.ContainsKey("paymentMethod") && data["paymentMethod"].ValueKind != JsonValueKind.Null
|
||||||
|
? data["paymentMethod"].GetString() ?? "Cash" : "Cash";
|
||||||
|
|
||||||
|
var customerId = await ResolveOptionalCustomerLinkAsync(data);
|
||||||
|
|
||||||
|
if (!data.ContainsKey("items") || data["items"].ValueKind != JsonValueKind.Array)
|
||||||
|
throw new ArgumentException("At least one item is required");
|
||||||
|
|
||||||
|
var soldAt = data.ContainsKey("soldAt") && data["soldAt"].ValueKind != JsonValueKind.Null
|
||||||
|
? DateTime.Parse(data["soldAt"].GetString()!).ToUniversalTime()
|
||||||
|
: DateTime.UtcNow;
|
||||||
|
|
||||||
|
var sale = new Sale
|
||||||
|
{
|
||||||
|
FKClientId = clientId,
|
||||||
|
SoldAt = soldAt,
|
||||||
|
PaymentMethod = paymentMethod,
|
||||||
|
FKCustomerId = customerId,
|
||||||
|
CustomerName = data.ContainsKey("customerName") && data["customerName"].ValueKind != JsonValueKind.Null
|
||||||
|
? data["customerName"].GetString() : null
|
||||||
|
};
|
||||||
|
|
||||||
|
decimal total = 0m;
|
||||||
|
var itemsArray = data["items"].EnumerateArray().ToList();
|
||||||
|
if (itemsArray.Count == 0) throw new ArgumentException("At least one item is required");
|
||||||
|
|
||||||
|
// Load + validate every line before mutating stock, so a short line rejects the
|
||||||
|
// whole sale rather than partially decrementing earlier lines.
|
||||||
|
var lines = new List<(Product Product, decimal Quantity, decimal UnitPrice)>();
|
||||||
|
foreach (var itemEl in itemsArray)
|
||||||
|
{
|
||||||
|
var productId = Guid.Parse(itemEl.GetProperty("productId").GetString()!);
|
||||||
|
var quantity = itemEl.GetProperty("quantity").GetDecimal();
|
||||||
|
var unitPrice = itemEl.GetProperty("unitPrice").GetDecimal();
|
||||||
|
|
||||||
|
if (quantity <= 0) throw new ArgumentException("Quantity must be greater than 0");
|
||||||
|
if (unitPrice < 0) throw new ArgumentException("UnitPrice cannot be negative");
|
||||||
|
|
||||||
|
var product = await _inventoryRepository.GetProductById(productId);
|
||||||
|
if (product == null || product.FKClientId != clientId)
|
||||||
|
throw new KeyNotFoundException("Product not found");
|
||||||
|
if (product.QuantityOnHand < quantity)
|
||||||
|
throw new ArgumentException($"Not enough stock for \"{product.Name}\" (have {product.QuantityOnHand}, need {quantity})");
|
||||||
|
|
||||||
|
lines.Add((product, quantity, unitPrice));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (product, quantity, unitPrice) in lines)
|
||||||
|
{
|
||||||
|
var lineTotal = quantity * unitPrice;
|
||||||
|
sale.Items.Add(new SaleItem
|
||||||
|
{
|
||||||
|
FKProductId = product.ProductId,
|
||||||
|
Quantity = quantity,
|
||||||
|
UnitPrice = unitPrice,
|
||||||
|
LineTotal = lineTotal
|
||||||
|
});
|
||||||
|
|
||||||
|
product.QuantityOnHand -= quantity;
|
||||||
|
total += lineTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
sale.TotalAmount = total;
|
||||||
|
var sequence = await _inventoryRepository.GetNextSaleSequence(clientId);
|
||||||
|
sale.SaleNumber = $"SALE-{sequence:D4}";
|
||||||
|
|
||||||
|
var result = await _inventoryRepository.CreateSale(sale);
|
||||||
|
await _uow.CommitAsync();
|
||||||
|
|
||||||
|
return MapToDto(result);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await _uow.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> GetSales(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
var sales = await _inventoryRepository.GetSales(clientId);
|
||||||
|
return sales.Select(MapToDto).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> GetSaleById(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
var saleId = Guid.Parse(data["saleId"].GetString()!);
|
||||||
|
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
var sale = await _inventoryRepository.GetSaleById(saleId);
|
||||||
|
if (sale == null || sale.FKClientId != clientId)
|
||||||
|
throw new KeyNotFoundException("Sale not found");
|
||||||
|
|
||||||
|
return MapToDto(sale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Returns ---
|
||||||
|
|
||||||
|
public async Task<object> CreateStockReturn(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
await _uow.BeginAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
|
||||||
|
var productId = Guid.Parse(data["productId"].GetString()!);
|
||||||
|
var quantity = data["quantity"].GetDecimal();
|
||||||
|
if (quantity <= 0) throw new ArgumentException("Quantity must be greater than 0");
|
||||||
|
|
||||||
|
var category = data["category"].GetString() ?? "Other";
|
||||||
|
if (!ValidReturnCategories.Contains(category))
|
||||||
|
throw new ArgumentException($"category must be one of: {string.Join(", ", ValidReturnCategories)}");
|
||||||
|
|
||||||
|
var product = await _inventoryRepository.GetProductById(productId);
|
||||||
|
if (product == null || product.FKClientId != clientId)
|
||||||
|
throw new KeyNotFoundException("Product not found");
|
||||||
|
|
||||||
|
// CustomerReturn is the one category that adds back to stock -- the item
|
||||||
|
// physically comes back after an earlier sale already removed it, so there's no
|
||||||
|
// "enough stock on hand" requirement (it may well be at 0). Every other category
|
||||||
|
// removes from stock we currently hold, so it does require enough on hand.
|
||||||
|
var addsBackToStock = category == "CustomerReturn";
|
||||||
|
if (!addsBackToStock && product.QuantityOnHand < quantity)
|
||||||
|
throw new ArgumentException($"Not enough stock for \"{product.Name}\" (have {product.QuantityOnHand}, need {quantity})");
|
||||||
|
|
||||||
|
// Optional traceability -- most returns have neither. Only validated for
|
||||||
|
// existence/ownership, not cross-checked against the product/quantity.
|
||||||
|
Guid? saleId = data.ContainsKey("saleId") && data["saleId"].ValueKind != JsonValueKind.Null
|
||||||
|
? Guid.Parse(data["saleId"].GetString()!) : null;
|
||||||
|
Sale? sale = null;
|
||||||
|
if (saleId.HasValue)
|
||||||
|
{
|
||||||
|
sale = await _inventoryRepository.GetSaleById(saleId.Value);
|
||||||
|
if (sale == null || sale.FKClientId != clientId)
|
||||||
|
throw new KeyNotFoundException("Sale not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid? grnId = data.ContainsKey("grnId") && data["grnId"].ValueKind != JsonValueKind.Null
|
||||||
|
? Guid.Parse(data["grnId"].GetString()!) : null;
|
||||||
|
GoodsReceivedNote? grn = null;
|
||||||
|
if (grnId.HasValue)
|
||||||
|
{
|
||||||
|
grn = await _inventoryRepository.GetGrnById(grnId.Value);
|
||||||
|
if (grn == null || grn.FKClientId != clientId)
|
||||||
|
throw new KeyNotFoundException("GRN not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
var returnedAt = data.ContainsKey("returnedAt") && data["returnedAt"].ValueKind != JsonValueKind.Null
|
||||||
|
? DateTime.Parse(data["returnedAt"].GetString()!).ToUniversalTime()
|
||||||
|
: DateTime.UtcNow;
|
||||||
|
|
||||||
|
var stockReturn = new StockReturn
|
||||||
|
{
|
||||||
|
FKClientId = clientId,
|
||||||
|
FKProductId = productId,
|
||||||
|
Quantity = quantity,
|
||||||
|
Category = category,
|
||||||
|
FKSaleId = saleId,
|
||||||
|
FKGrnId = grnId,
|
||||||
|
Notes = data.ContainsKey("notes") && data["notes"].ValueKind != JsonValueKind.Null ? data["notes"].GetString() : null,
|
||||||
|
ReturnedAt = returnedAt,
|
||||||
|
Amount = quantity * product.CostPrice
|
||||||
|
};
|
||||||
|
|
||||||
|
product.QuantityOnHand += addsBackToStock ? quantity : -quantity;
|
||||||
|
|
||||||
|
var result = await _inventoryRepository.CreateStockReturn(stockReturn);
|
||||||
|
await _uow.CommitAsync();
|
||||||
|
|
||||||
|
result.Product = product;
|
||||||
|
result.Sale = sale;
|
||||||
|
result.Grn = grn;
|
||||||
|
return MapToDto(result);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await _uow.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> GetStockReturns(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
var returns = await _inventoryRepository.GetStockReturns(clientId);
|
||||||
|
return returns.Select(MapToDto).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object> GetStockReturnById(object payload, HttpContext httpContext)
|
||||||
|
{
|
||||||
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
|
var stockReturnId = Guid.Parse(data["stockReturnId"].GetString()!);
|
||||||
|
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
var stockReturn = await _inventoryRepository.GetStockReturnById(stockReturnId);
|
||||||
|
if (stockReturn == null || stockReturn.FKClientId != clientId)
|
||||||
|
throw new KeyNotFoundException("Return not found");
|
||||||
|
|
||||||
|
return MapToDto(stockReturn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Staff optionally picking an existing registered member to attach to a sale
|
||||||
|
/// (search-and-pick, not free-typed) -- purely for identification, mirrors
|
||||||
|
/// BookingService.ResolveOptionalCustomerLinkAsync exactly.</summary>
|
||||||
|
private async Task<Guid?> ResolveOptionalCustomerLinkAsync(Dictionary<string, JsonElement> data)
|
||||||
|
{
|
||||||
|
if (!data.ContainsKey("customerId") || data["customerId"].ValueKind == JsonValueKind.Null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var customerId = Guid.Parse(data["customerId"].GetString()!);
|
||||||
|
_ = await _customerRepository.GetTenantCustomerLinkById(customerId)
|
||||||
|
?? throw new KeyNotFoundException("Customer not found");
|
||||||
|
return customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageId is a relative /Uploads path (same convention as ClientBusiness.BusinessLogoId)
|
||||||
|
// with no static-file serving configured for it, so it's inlined as a base64 data URL.
|
||||||
|
private static async Task<ProductDto> MapToDtoAsync(Product p) => new()
|
||||||
|
{
|
||||||
|
ProductId = p.ProductId,
|
||||||
|
Name = p.Name,
|
||||||
|
Sku = p.Sku,
|
||||||
|
ImageUrl = await BusinessLogoHelper.ReadAsDataUrlAsync(p.ImageId),
|
||||||
|
UnitOfMeasure = p.UnitOfMeasure,
|
||||||
|
CostPrice = p.CostPrice,
|
||||||
|
SellingPrice = p.SellingPrice,
|
||||||
|
QuantityOnHand = p.QuantityOnHand,
|
||||||
|
IsActive = p.IsActive,
|
||||||
|
CreatedAt = p.CreatedAt
|
||||||
|
};
|
||||||
|
|
||||||
|
private static SupplierDto MapToDto(Supplier s) => new()
|
||||||
|
{
|
||||||
|
SupplierId = s.SupplierId,
|
||||||
|
Name = s.Name,
|
||||||
|
Phone = s.Phone,
|
||||||
|
Email = s.Email,
|
||||||
|
Address = s.Address,
|
||||||
|
IsActive = s.IsActive
|
||||||
|
};
|
||||||
|
|
||||||
|
private static GoodsReceivedNoteDto MapToDto(GoodsReceivedNote g) => new()
|
||||||
|
{
|
||||||
|
GrnId = g.GrnId,
|
||||||
|
GrnNumber = g.GrnNumber,
|
||||||
|
SupplierId = g.FKSupplierId,
|
||||||
|
SupplierName = g.Supplier?.Name,
|
||||||
|
ReceivedAt = g.ReceivedAt,
|
||||||
|
Notes = g.Notes,
|
||||||
|
TotalAmount = g.TotalAmount,
|
||||||
|
Items = g.Items.Select(i => new GoodsReceivedNoteItemDto
|
||||||
|
{
|
||||||
|
ProductId = i.FKProductId,
|
||||||
|
ProductName = i.Product?.Name ?? string.Empty,
|
||||||
|
Quantity = i.Quantity,
|
||||||
|
UnitCost = i.UnitCost,
|
||||||
|
LineTotal = i.LineTotal
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
private static SaleDto MapToDto(Sale s) => new()
|
||||||
|
{
|
||||||
|
SaleId = s.SaleId,
|
||||||
|
SaleNumber = s.SaleNumber,
|
||||||
|
SoldAt = s.SoldAt,
|
||||||
|
PaymentMethod = s.PaymentMethod,
|
||||||
|
CustomerId = s.FKCustomerId,
|
||||||
|
CustomerName = s.CustomerName,
|
||||||
|
TotalAmount = s.TotalAmount,
|
||||||
|
Items = s.Items.Select(i => new SaleItemDto
|
||||||
|
{
|
||||||
|
ProductId = i.FKProductId,
|
||||||
|
ProductName = i.Product?.Name ?? string.Empty,
|
||||||
|
Quantity = i.Quantity,
|
||||||
|
UnitPrice = i.UnitPrice,
|
||||||
|
LineTotal = i.LineTotal
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
private static StockReturnDto MapToDto(StockReturn r) => new()
|
||||||
|
{
|
||||||
|
StockReturnId = r.StockReturnId,
|
||||||
|
ProductId = r.FKProductId,
|
||||||
|
ProductName = r.Product?.Name ?? string.Empty,
|
||||||
|
Quantity = r.Quantity,
|
||||||
|
Category = r.Category,
|
||||||
|
SaleId = r.FKSaleId,
|
||||||
|
SaleNumber = r.Sale?.SaleNumber,
|
||||||
|
GrnId = r.FKGrnId,
|
||||||
|
GrnNumber = r.Grn?.GrnNumber,
|
||||||
|
Notes = r.Notes,
|
||||||
|
ReturnedAt = r.ReturnedAt,
|
||||||
|
Amount = r.Amount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,16 @@ namespace Fitness_core_BE
|
|||||||
// Models/BookingInvoice.cs. Deliberately separate from CustomerInvoice (membership-only).
|
// Models/BookingInvoice.cs. Deliberately separate from CustomerInvoice (membership-only).
|
||||||
public DbSet<BookingInvoice> BookingInvoices => Set<BookingInvoice>();
|
public DbSet<BookingInvoice> BookingInvoices => Set<BookingInvoice>();
|
||||||
|
|
||||||
|
// Simple inventory: Products, Suppliers, GRN (stock in), Sales (stock out), Returns
|
||||||
|
// (stock write-offs) -- see each model's own doc comment.
|
||||||
|
public DbSet<Product> Products => Set<Product>();
|
||||||
|
public DbSet<Supplier> Suppliers => Set<Supplier>();
|
||||||
|
public DbSet<GoodsReceivedNote> GoodsReceivedNotes => Set<GoodsReceivedNote>();
|
||||||
|
public DbSet<GoodsReceivedNoteItem> GoodsReceivedNoteItems => Set<GoodsReceivedNoteItem>();
|
||||||
|
public DbSet<Sale> Sales => Set<Sale>();
|
||||||
|
public DbSet<SaleItem> SaleItems => Set<SaleItem>();
|
||||||
|
public DbSet<StockReturn> StockReturns => Set<StockReturn>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
@@ -191,6 +201,11 @@ namespace Fitness_core_BE
|
|||||||
modelBuilder.Entity<BookingInvoice>().Ignore(i => i.Client);
|
modelBuilder.Entity<BookingInvoice>().Ignore(i => i.Client);
|
||||||
modelBuilder.Entity<TimeBankCard>().Ignore(t => t.Client);
|
modelBuilder.Entity<TimeBankCard>().Ignore(t => t.Client);
|
||||||
modelBuilder.Entity<TimeBankCardUsageLog>().Ignore(l => l.Client);
|
modelBuilder.Entity<TimeBankCardUsageLog>().Ignore(l => l.Client);
|
||||||
|
modelBuilder.Entity<Product>().Ignore(p => p.Client);
|
||||||
|
modelBuilder.Entity<Supplier>().Ignore(s => s.Client);
|
||||||
|
modelBuilder.Entity<GoodsReceivedNote>().Ignore(g => g.Client);
|
||||||
|
modelBuilder.Entity<Sale>().Ignore(s => s.Client);
|
||||||
|
modelBuilder.Entity<StockReturn>().Ignore(r => r.Client);
|
||||||
|
|
||||||
// A session's capacity is enforced by counting non-cancelled bookings against it at
|
// A session's capacity is enforced by counting non-cancelled bookings against it at
|
||||||
// booking time, not by a unique index -- multiple guests/members can share one slot
|
// booking time, not by a unique index -- multiple guests/members can share one slot
|
||||||
@@ -286,6 +301,45 @@ namespace Fitness_core_BE
|
|||||||
modelBuilder.Entity<BookingInvoice>()
|
modelBuilder.Entity<BookingInvoice>()
|
||||||
.HasIndex(i => new { i.FKClientId, i.IssuedAt });
|
.HasIndex(i => new { i.FKClientId, i.IssuedAt });
|
||||||
|
|
||||||
|
// Inventory: GRN/Sale items cascade with their header; Return/GRN/Sale restrict
|
||||||
|
// deleting a Product/Supplier that already has history (there's no delete endpoint
|
||||||
|
// for either, but Restrict keeps the data model honest either way).
|
||||||
|
modelBuilder.Entity<Product>()
|
||||||
|
.HasIndex(p => new { p.FKClientId, p.Name });
|
||||||
|
modelBuilder.Entity<Supplier>()
|
||||||
|
.HasIndex(s => new { s.FKClientId, s.Name });
|
||||||
|
modelBuilder.Entity<GoodsReceivedNote>()
|
||||||
|
.HasIndex(g => new { g.FKClientId, g.ReceivedAt });
|
||||||
|
modelBuilder.Entity<GoodsReceivedNote>()
|
||||||
|
.HasOne(g => g.Supplier).WithMany()
|
||||||
|
.HasForeignKey(g => g.FKSupplierId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
modelBuilder.Entity<GoodsReceivedNoteItem>()
|
||||||
|
.HasOne(i => i.Grn).WithMany(g => g.Items)
|
||||||
|
.HasForeignKey(i => i.FKGrnId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
modelBuilder.Entity<GoodsReceivedNoteItem>()
|
||||||
|
.HasOne(i => i.Product).WithMany()
|
||||||
|
.HasForeignKey(i => i.FKProductId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
modelBuilder.Entity<Sale>()
|
||||||
|
.HasIndex(s => new { s.FKClientId, s.SoldAt });
|
||||||
|
modelBuilder.Entity<SaleItem>()
|
||||||
|
.HasOne(i => i.Sale).WithMany(s => s.Items)
|
||||||
|
.HasForeignKey(i => i.FKSaleId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
modelBuilder.Entity<SaleItem>()
|
||||||
|
.HasOne(i => i.Product).WithMany()
|
||||||
|
.HasForeignKey(i => i.FKProductId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
modelBuilder.Entity<StockReturn>()
|
||||||
|
.HasIndex(r => new { r.FKClientId, r.ReturnedAt });
|
||||||
|
modelBuilder.Entity<StockReturn>()
|
||||||
|
.HasOne(r => r.Product).WithMany()
|
||||||
|
.HasForeignKey(r => r.FKProductId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
// Optional traceability links -- most returns have neither.
|
||||||
|
modelBuilder.Entity<StockReturn>()
|
||||||
|
.HasOne(r => r.Sale).WithMany()
|
||||||
|
.HasForeignKey(r => r.FKSaleId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
modelBuilder.Entity<StockReturn>()
|
||||||
|
.HasOne(r => r.Grn).WithMany()
|
||||||
|
.HasForeignKey(r => r.FKGrnId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<UnlinkedDeviceEnrollment>()
|
modelBuilder.Entity<UnlinkedDeviceEnrollment>()
|
||||||
.HasIndex(e => new { e.FKDeviceId, e.DeviceEmployeeNo, e.LinkedAt });
|
.HasIndex(e => new { e.FKDeviceId, e.DeviceEmployeeNo, e.LinkedAt });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user