Files
ERP_Distribution/Distribution/Features/Stock/StockService.cs
2026-08-20 09:42:33 +05:30

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);
}
}