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:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user