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 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 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(); 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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); } /// 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. private async Task ResolveOptionalCustomerLinkAsync(Dictionary 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 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 }; } }