229 lines
9.4 KiB
C#
229 lines
9.4 KiB
C#
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);
|
|
}
|
|
}
|