stock_ledger completed need to test
This commit is contained in:
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user