Add Stock Tracking endpoint to Inventory
Adds GET /client/inventory/stock-movements, a unified read-only ledger flattening every GRN line, sale line and return across all products into one shape for the new Stock Tracking tab's table.
This commit is contained in:
@@ -170,5 +170,12 @@ namespace Fitness_core_BE.Controllers
|
||||
notes = request.Notes,
|
||||
returnedAt = request.ReturnedAt?.ToString("o")
|
||||
}, HttpContext));
|
||||
|
||||
[CustomMiddleware]
|
||||
[TenantResolution]
|
||||
[RequiresPermission("inventory")]
|
||||
[HttpGet("stock-movements")]
|
||||
public async Task<ActionResult<ApiResponse<object>>> GetStockMovements()
|
||||
=> Response(await _inventoryService.GetStockMovements(new { }, HttpContext));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,27 @@ namespace Fitness_core_BE.DTOs.Inventory
|
||||
public List<SaleItemDto> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>One row in the Stock Tracking tab's unified ledger -- every GRN line, sale line
|
||||
/// and return across every product, flattened into one shape for a single filterable table.
|
||||
/// Not persisted; built on read from GRNs/Sales/Returns.</summary>
|
||||
public class StockMovementDto
|
||||
{
|
||||
/// <summary>"GRN" | "Sale" | "Return".</summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public Guid ProductId { get; set; }
|
||||
public string ProductName { get; set; } = string.Empty;
|
||||
/// <summary>The GRN/Sale number this line belongs to, or the return's Category label.</summary>
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
/// <summary>Only set for Type == "Return" -- the write-off/return category.</summary>
|
||||
public string? Category { get; set; }
|
||||
/// <summary>Signed: positive for a GRN or a CustomerReturn (stock in), negative for a
|
||||
/// Sale or any other return category (stock out).</summary>
|
||||
public decimal QuantityChange { get; set; }
|
||||
/// <summary>This line's monetary value -- a GRN/Sale line's LineTotal, or a return's Amount.</summary>
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime Date { get; set; }
|
||||
}
|
||||
|
||||
public class StockReturnDto
|
||||
{
|
||||
public Guid StockReturnId { get; set; }
|
||||
|
||||
@@ -22,5 +22,7 @@ namespace Fitness_core_BE.Interfaces.Services
|
||||
Task<object> CreateStockReturn(object payload, HttpContext httpContext);
|
||||
Task<object> GetStockReturns(object payload, HttpContext httpContext);
|
||||
Task<object> GetStockReturnById(object payload, HttpContext httpContext);
|
||||
|
||||
Task<object> GetStockMovements(object payload, HttpContext httpContext);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,6 +576,67 @@ namespace Fitness_core_BE.Services
|
||||
return MapToDto(stockReturn);
|
||||
}
|
||||
|
||||
// --- Stock Tracking (unified ledger, new) ---
|
||||
|
||||
/// <summary>Every GRN line, sale line and return across every product, flattened into one
|
||||
/// shape for the Stock Tracking tab's table. Built from the same three already-tenant-
|
||||
/// scoped reads the GRN/Sales/Returns tabs use -- no new repository queries needed, and
|
||||
/// the dataset is small enough (a single gym's "simple inventory") that filtering happens
|
||||
/// client-side rather than via query params.</summary>
|
||||
public async Task<object> GetStockMovements(object payload, HttpContext httpContext)
|
||||
{
|
||||
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||
|
||||
var grns = await _inventoryRepository.GetGrns(clientId);
|
||||
var sales = await _inventoryRepository.GetSales(clientId);
|
||||
var returns = await _inventoryRepository.GetStockReturns(clientId);
|
||||
|
||||
var movements = new List<StockMovementDto>();
|
||||
|
||||
foreach (var g in grns)
|
||||
foreach (var i in g.Items)
|
||||
movements.Add(new StockMovementDto
|
||||
{
|
||||
Type = "GRN",
|
||||
ProductId = i.FKProductId,
|
||||
ProductName = i.Product?.Name ?? string.Empty,
|
||||
Reference = g.GrnNumber,
|
||||
QuantityChange = i.Quantity,
|
||||
Amount = i.LineTotal,
|
||||
Date = g.ReceivedAt
|
||||
});
|
||||
|
||||
foreach (var s in sales)
|
||||
foreach (var i in s.Items)
|
||||
movements.Add(new StockMovementDto
|
||||
{
|
||||
Type = "Sale",
|
||||
ProductId = i.FKProductId,
|
||||
ProductName = i.Product?.Name ?? string.Empty,
|
||||
Reference = s.SaleNumber,
|
||||
QuantityChange = -i.Quantity,
|
||||
Amount = i.LineTotal,
|
||||
Date = s.SoldAt
|
||||
});
|
||||
|
||||
foreach (var r in returns)
|
||||
movements.Add(new StockMovementDto
|
||||
{
|
||||
Type = "Return",
|
||||
ProductId = r.FKProductId,
|
||||
ProductName = r.Product?.Name ?? string.Empty,
|
||||
Reference = r.Sale?.SaleNumber ?? r.Grn?.GrnNumber ?? r.Category,
|
||||
Category = 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,
|
||||
Amount = r.Amount,
|
||||
Date = r.ReturnedAt
|
||||
});
|
||||
|
||||
return movements.OrderByDescending(m => m.Date).ToList();
|
||||
}
|
||||
|
||||
/// <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>
|
||||
|
||||
Reference in New Issue
Block a user