stock_ledger completed need to test

This commit is contained in:
Kalana B. Thilakarathna
2026-08-20 09:42:33 +05:30
parent aa7ea37ab6
commit f95fda3806
37 changed files with 1976 additions and 5805 deletions
@@ -0,0 +1,19 @@
using Distribution.Models;
namespace Distribution.Features.Stock;
public interface IStockRepository
{
// Balance
Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId);
Task<List<StockBalance>> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize);
Task<int> GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId);
Task<List<StockBalance>> GetBalancesByItemAsync(Guid itemId);
Task AddBalanceAsync(StockBalance balance);
void UpdateBalance(StockBalance balance);
// Movements
Task AddMovementAsync(StockMovement movement);
Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize);
Task<int> GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to);
}
@@ -0,0 +1,98 @@
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Stock;
[ApiController]
[Route("api/stock")]
[Authorize]
public class StockController(StockService service, ICurrentUserService currentUser) : ControllerBase
{
// ── Query ──────────────────────────────────────────────────────────────
[HttpGet("balances")]
public async Task<IActionResult> GetBalances(
[FromQuery] Guid? locationId,
[FromQuery] LocationType? locationType,
[FromQuery] Guid? itemId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetBalancesAsync(locationId, locationType, itemId, page, pageSize);
return Ok(result);
}
[HttpGet("balances/{itemId}/total")]
public async Task<IActionResult> GetItemTotal(Guid itemId)
{
var result = await service.GetItemTotalAsync(itemId);
if (result is null) return NotFound();
return Ok(result);
}
[HttpGet("movements")]
public async Task<IActionResult> GetMovements(
[FromQuery] Guid? locationId,
[FromQuery] Guid? itemId,
[FromQuery] MovementType? type,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetMovementsAsync(locationId, itemId, type, from, to, page, pageSize);
return Ok(result);
}
// ── Write ──────────────────────────────────────────────────────────────
[HttpPost("transfer")]
public async Task<IActionResult> Transfer(TransferRequest request, CancellationToken ct)
{
if (currentUser.UserId is null) return Unauthorized();
try
{
var result = await service.TransferAsync(request, currentUser.UserId.Value, ct);
return Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(ex.Message); }
}
[HttpPost("damage")]
public async Task<IActionResult> RecordDamage(DamageRequest request, CancellationToken ct)
{
if (currentUser.UserId is null) return Unauthorized();
try
{
var result = await service.RecordDamageAsync(request, currentUser.UserId.Value, ct);
return Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(ex.Message); }
}
[HttpPost("customer-return")]
public async Task<IActionResult> RecordCustomerReturn(CustomerReturnRequest request, CancellationToken ct)
{
if (currentUser.UserId is null) return Unauthorized();
try
{
var result = await service.RecordCustomerReturnAsync(request, currentUser.UserId.Value, ct);
return Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(ex.Message); }
}
[HttpPost("adjustment")]
public async Task<IActionResult> Adjust(AdjustmentRequest request, CancellationToken ct)
{
if (currentUser.UserId is null) return Unauthorized();
try
{
var result = await service.AdjustAsync(request, currentUser.UserId.Value, ct);
return Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(ex.Message); }
}
}
+67
View File
@@ -0,0 +1,67 @@
using Distribution.Models;
namespace Distribution.Features.Stock;
// Shared pagination wrapper
public record PagedResponse<T>(int Page, int PageSize, int Total, List<T> Items);
// Balance
public record StockBalanceResponse(
Guid ItemId,
string ItemName,
Guid LocationId,
LocationType LocationType,
decimal Quantity,
DateTime UpdatedAt);
public record StockTotalResponse(
Guid ItemId,
string ItemName,
decimal TotalQuantity,
List<StockBalanceResponse> Breakdown);
// Movement
public record StockMovementResponse(
Guid Id,
Guid ItemId,
string ItemName,
Guid? FromLocationId,
LocationType? FromLocationType,
Guid? ToLocationId,
LocationType? ToLocationType,
decimal Quantity,
MovementType Type,
string? ReasonCode,
Guid? ReferenceId,
DateTime CreatedAt,
Guid CreatedBy);
// Write requests
public record TransferRequest(
Guid ItemId,
Guid FromLocationId,
LocationType FromLocationType,
Guid ToLocationId,
LocationType ToLocationType,
decimal Quantity,
Guid? ReferenceId);
public record DamageRequest(
Guid ItemId,
Guid LocationId,
LocationType LocationType,
decimal Quantity,
string ReasonCode);
public record CustomerReturnRequest(
Guid ItemId,
Guid VehicleId,
decimal Quantity,
Guid? ReferenceId);
public record AdjustmentRequest(
Guid ItemId,
Guid LocationId,
LocationType LocationType,
decimal Quantity,
string ReasonCode);
@@ -0,0 +1,70 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Stock;
public class StockRepository(AppDBContext db) : IStockRepository
{
public Task<StockBalance?> GetBalanceAsync(Guid itemId, Guid locationId) =>
db.StockBalances
.Include(b => b.Item)
.FirstOrDefaultAsync(b => b.FK_ItemId == itemId && b.LocationId == locationId);
public Task<List<StockBalance>> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize)
{
var query = db.StockBalances.Include(b => b.Item).AsQueryable();
if (locationId.HasValue) query = query.Where(b => b.LocationId == locationId.Value);
if (locationType.HasValue) query = query.Where(b => b.LocationType == locationType.Value);
if (itemId.HasValue) query = query.Where(b => b.FK_ItemId == itemId.Value);
return query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
}
public Task<int> GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId)
{
var query = db.StockBalances.AsQueryable();
if (locationId.HasValue) query = query.Where(b => b.LocationId == locationId.Value);
if (locationType.HasValue) query = query.Where(b => b.LocationType == locationType.Value);
if (itemId.HasValue) query = query.Where(b => b.FK_ItemId == itemId.Value);
return query.CountAsync();
}
public Task<List<StockBalance>> GetBalancesByItemAsync(Guid itemId) =>
db.StockBalances
.Include(b => b.Item)
.Where(b => b.FK_ItemId == itemId)
.ToListAsync();
public async Task AddBalanceAsync(StockBalance balance) =>
await db.StockBalances.AddAsync(balance);
public void UpdateBalance(StockBalance balance) =>
db.StockBalances.Update(balance);
public async Task AddMovementAsync(StockMovement movement) =>
await db.StockMovements.AddAsync(movement);
public Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize)
{
var query = db.StockMovements.Include(m => m.Item).AsQueryable();
if (locationId.HasValue)
query = query.Where(m => m.FromLocationId == locationId.Value || m.ToLocationId == locationId.Value);
if (itemId.HasValue) query = query.Where(m => m.FK_ItemId == itemId.Value);
if (type.HasValue) query = query.Where(m => m.Type == type.Value);
if (from.HasValue) query = query.Where(m => m.CreatedAt >= from.Value);
if (to.HasValue) query = query.Where(m => m.CreatedAt <= to.Value);
return query.OrderByDescending(m => m.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
}
public Task<int> GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to)
{
var query = db.StockMovements.AsQueryable();
if (locationId.HasValue)
query = query.Where(m => m.FromLocationId == locationId.Value || m.ToLocationId == locationId.Value);
if (itemId.HasValue) query = query.Where(m => m.FK_ItemId == itemId.Value);
if (type.HasValue) query = query.Where(m => m.Type == type.Value);
if (from.HasValue) query = query.Where(m => m.CreatedAt >= from.Value);
if (to.HasValue) query = query.Where(m => m.CreatedAt <= to.Value);
return query.CountAsync();
}
}
+228
View File
@@ -0,0 +1,228 @@
using Distribution.Features.Category;
using Distribution.Features.Item;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Stock;
public class StockService(IStockRepository repo, IItemRepository itemRepo, ICategoryRepository categoryRepo, IUnitOfWork uow)
{
private static StockBalanceResponse MapBalance(StockBalance b) =>
new(b.FK_ItemId, b.Item.Name, b.LocationId, b.LocationType, b.Quantity, b.UpdatedAt);
private static StockMovementResponse MapMovement(StockMovement m) =>
new(m.Id, m.FK_ItemId, m.Item.Name, m.FromLocationId, m.FromLocationType,
m.ToLocationId, m.ToLocationType, m.Quantity, m.Type, m.ReasonCode,
m.ReferenceId, m.CreatedAt, m.CreatedBy);
// ── Queries ────────────────────────────────────────────────────────────
public async Task<PagedResponse<StockBalanceResponse>> GetBalancesAsync(
Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize)
{
var items = await repo.GetBalancesAsync(locationId, locationType, itemId, page, pageSize);
var total = await repo.GetBalancesCountAsync(locationId, locationType, itemId);
return new PagedResponse<StockBalanceResponse>(page, pageSize, total, items.Select(MapBalance).ToList());
}
public async Task<StockTotalResponse?> GetItemTotalAsync(Guid itemId)
{
var item = await itemRepo.GetByIdAsync(itemId);
if (item is null || !item.IsActive) return null;
var balances = await repo.GetBalancesByItemAsync(itemId);
var total = balances.Sum(b => b.Quantity);
return new StockTotalResponse(itemId, item.Name, total, balances.Select(MapBalance).ToList());
}
public async Task<PagedResponse<StockMovementResponse>> GetMovementsAsync(
Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize)
{
var items = await repo.GetMovementsAsync(locationId, itemId, type, from, to, page, pageSize);
var total = await repo.GetMovementsCountAsync(locationId, itemId, type, from, to);
return new PagedResponse<StockMovementResponse>(page, pageSize, total, items.Select(MapMovement).ToList());
}
// ── Writes ─────────────────────────────────────────────────────────────
public async Task<StockMovementResponse> TransferAsync(TransferRequest request, Guid userId, CancellationToken ct = default)
{
await EnsureStockAsync(request.ItemId, request.FromLocationId, request.Quantity);
var movement = new StockMovement
{
FK_ItemId = request.ItemId,
FromLocationId = request.FromLocationId,
FromLocationType = request.FromLocationType,
ToLocationId = request.ToLocationId,
ToLocationType = request.ToLocationType,
Quantity = request.Quantity,
Type = MovementType.Transfer,
ReferenceId = request.ReferenceId,
CreatedBy = userId
};
await uow.BeginAsync(ct);
try
{
await ApplyTransferAsync(request.ItemId, request.FromLocationId, request.ToLocationId, request.ToLocationType, request.Quantity);
await repo.AddMovementAsync(movement);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return MapMovement(movement);
}
public async Task<StockMovementResponse> RecordDamageAsync(DamageRequest request, Guid userId, CancellationToken ct = default)
{
await EnsureStockAsync(request.ItemId, request.LocationId, request.Quantity);
var movement = new StockMovement
{
FK_ItemId = request.ItemId,
FromLocationId = request.LocationId,
FromLocationType = request.LocationType,
Quantity = request.Quantity,
Type = MovementType.Damage,
ReasonCode = request.ReasonCode,
CreatedBy = userId
};
await uow.BeginAsync(ct);
try
{
await DebitBalanceAsync(request.ItemId, request.LocationId, request.Quantity);
await repo.AddMovementAsync(movement);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return MapMovement(movement);
}
public async Task<StockMovementResponse> RecordCustomerReturnAsync(CustomerReturnRequest request, Guid userId, CancellationToken ct = default)
{
var item = await itemRepo.GetByIdAsync(request.ItemId)
?? throw new InvalidOperationException("Item not found.");
var category = await categoryRepo.GetByIdAsync(item.FK_CategoryId)
?? throw new InvalidOperationException("Category not found.");
if (!category.AllowCustomerReturn)
throw new InvalidOperationException($"Customer returns are not allowed for category '{category.Name}'.");
var movement = new StockMovement
{
FK_ItemId = request.ItemId,
ToLocationId = request.VehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = request.Quantity,
Type = MovementType.CustomerReturn,
ReferenceId = request.ReferenceId,
CreatedBy = userId
};
await uow.BeginAsync(ct);
try
{
await CreditBalanceAsync(request.ItemId, request.VehicleId, LocationType.Vehicle, request.Quantity);
await repo.AddMovementAsync(movement);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return MapMovement(movement);
}
public async Task<StockMovementResponse> AdjustAsync(AdjustmentRequest request, Guid userId, CancellationToken ct = default)
{
var balance = await repo.GetBalanceAsync(request.ItemId, request.LocationId);
if (balance is not null && balance.Quantity + request.Quantity < 0)
throw new InvalidOperationException("Adjustment would result in negative stock.");
var movement = new StockMovement
{
FK_ItemId = request.ItemId,
FromLocationId = request.Quantity < 0 ? request.LocationId : null,
FromLocationType = request.Quantity < 0 ? request.LocationType : null,
ToLocationId = request.Quantity > 0 ? request.LocationId : null,
ToLocationType = request.Quantity > 0 ? request.LocationType : null,
Quantity = Math.Abs(request.Quantity),
Type = MovementType.Adjustment,
ReasonCode = request.ReasonCode,
CreatedBy = userId
};
await uow.BeginAsync(ct);
try
{
if (balance is null)
{
await repo.AddBalanceAsync(new StockBalance
{
FK_ItemId = request.ItemId,
LocationId = request.LocationId,
LocationType = request.LocationType,
Quantity = request.Quantity
});
}
else
{
balance.Quantity += request.Quantity;
balance.UpdatedAt = DateTime.Now;
repo.UpdateBalance(balance);
}
await repo.AddMovementAsync(movement);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return MapMovement(movement);
}
// ── Helpers ────────────────────────────────────────────────────────────
private async Task EnsureStockAsync(Guid itemId, Guid locationId, decimal quantity)
{
var balance = await repo.GetBalanceAsync(itemId, locationId);
if (balance is null || balance.Quantity < quantity)
throw new InvalidOperationException("Insufficient stock at the specified location.");
}
private async Task DebitBalanceAsync(Guid itemId, Guid locationId, decimal quantity)
{
var balance = await repo.GetBalanceAsync(itemId, locationId)
?? throw new InvalidOperationException("No stock balance found.");
balance.Quantity -= quantity;
balance.UpdatedAt = DateTime.Now;
repo.UpdateBalance(balance);
}
private async Task CreditBalanceAsync(Guid itemId, Guid locationId, LocationType locationType, decimal quantity)
{
var balance = await repo.GetBalanceAsync(itemId, locationId);
if (balance is null)
{
await repo.AddBalanceAsync(new StockBalance
{
FK_ItemId = itemId,
LocationId = locationId,
LocationType = locationType,
Quantity = quantity
});
}
else
{
balance.Quantity += quantity;
balance.UpdatedAt = DateTime.Now;
repo.UpdateBalance(balance);
}
}
private async Task ApplyTransferAsync(Guid itemId, Guid fromLocationId, Guid toLocationId, LocationType toLocationType, decimal quantity)
{
await DebitBalanceAsync(itemId, fromLocationId, quantity);
await CreditBalanceAsync(itemId, toLocationId, toLocationType, quantity);
}
}