2eb88b058e
- 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.
674 lines
29 KiB
C#
674 lines
29 KiB
C#
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
|
|
};
|
|
}
|
|
}
|