Files
ERP_Distribution/Distribution/Features/Stock/StockService.cs
T

335 lines
16 KiB
C#

using Distribution.Features.Category;
using Distribution.Features.Item;
using Distribution.Features.Vehicle;
using Distribution.Features.Warehouse;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Stock;
public class StockService(
IStockRepository repo,
IItemRepository itemRepo,
ICategoryRepository categoryRepo,
IWarehouseRepository warehouseRepo,
IVehicleRepository vehicleRepo,
StockMover mover,
IUnitOfWork uow)
{
private static StockBalanceResponse MapBalance(StockBalance b, string locationName) =>
new(b.FK_ItemId, b.Item.Name, b.LocationId, b.LocationType, b.Quantity, b.UpdatedAt, locationName);
// Sentinel id used for the "External" side of a movement (customer, supplier, write-off, adjustment
// source/sink) that has no real Warehouse/Vehicle record — every movement must always show both a
// From and a To, even when one side is outside the tracked system.
public static readonly Guid ExternalLocationId = Guid.Empty;
private static string ExternalLocationLabel(MovementType type, bool isFrom) => type switch
{
MovementType.Sale => "Customer",
MovementType.CustomerReturn => "Customer",
MovementType.PurchaseReceipt => "Supplier",
MovementType.PurchaseReturn => "Supplier",
MovementType.Damage => "Written Off",
MovementType.Adjustment => "Stock Adjustment",
_ => isFrom ? "External Source" : "External Destination",
};
private static StockMovementResponse MapMovement(StockMovement m, string? fromLocationName, string? toLocationName) =>
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, fromLocationName, toLocationName,
m.FK_ItemBatchId, m.ItemBatch?.BatchNumber, m.ItemBatch?.ExpiryDate,
m.FK_ItemSerialNumberId, m.ItemSerialNumber?.SerialNumber);
private async Task<StockMovementResponse> MapMovementAsync(StockMovement m)
{
var locations = new List<(Guid, LocationType)>();
if (m.FromLocationId is Guid fromId && m.FromLocationType is LocationType fromType && fromType != LocationType.External) locations.Add((fromId, fromType));
if (m.ToLocationId is Guid toId && m.ToLocationType is LocationType toType && toType != LocationType.External) locations.Add((toId, toType));
var names = await ResolveLocationNamesAsync(locations);
return MapMovement(m, ResolveMovementSideName(m, names, isFrom: true), ResolveMovementSideName(m, names, isFrom: false));
}
private static string? ResolveMovementSideName(StockMovement m, Dictionary<Guid, string> names, bool isFrom)
{
var id = isFrom ? m.FromLocationId : m.ToLocationId;
var type = isFrom ? m.FromLocationType : m.ToLocationType;
if (id is not Guid locId || type is not LocationType locType) return null;
return locType == LocationType.External ? ExternalLocationLabel(m.Type, isFrom) : names.GetValueOrDefault(locId, "—");
}
private async Task<Dictionary<Guid, string>> ResolveLocationNamesAsync(IEnumerable<(Guid Id, LocationType Type)> locations)
{
var warehouseIds = locations.Where(l => l.Type == LocationType.Warehouse).Select(l => l.Id).Distinct().ToList();
var vehicleIds = locations.Where(l => l.Type == LocationType.Vehicle).Select(l => l.Id).Distinct().ToList();
var names = new Dictionary<Guid, string>();
foreach (var id in warehouseIds)
{
var warehouse = await warehouseRepo.GetByIdAsync(id);
names[id] = warehouse?.Name ?? "Unknown Warehouse";
}
foreach (var id in vehicleIds)
{
var vehicle = await vehicleRepo.GetByIdAsync(id);
names[id] = vehicle?.RegNumber ?? "Unknown Vehicle";
}
return names;
}
// ── 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);
var names = await ResolveLocationNamesAsync(items.Select(b => (b.LocationId, b.LocationType)));
return new PagedResponse<StockBalanceResponse>(page, pageSize, total,
items.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList());
}
public async Task<List<LowStockItemResponse>> GetLowStockAsync(Guid? locationId)
{
var balances = await repo.GetLowStockAsync(locationId);
var names = await ResolveLocationNamesAsync(balances.Select(b => (b.LocationId, b.LocationType)));
return balances.Select(b => new LowStockItemResponse(
b.FK_ItemId, b.Item.Name, b.LocationId, names.GetValueOrDefault(b.LocationId, "—"),
b.Quantity, b.Item.ReorderLevel!.Value)).ToList();
}
/// <summary>The next unused sequence number for generating serials like "&lt;prefix&gt;0001" for an item.</summary>
public Task<int> GetNextSerialSequenceAsync(Guid itemId, string prefix) =>
repo.GetNextSerialSequenceAsync(itemId, prefix ?? string.Empty);
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);
var names = await ResolveLocationNamesAsync(balances.Select(b => (b.LocationId, b.LocationType)));
return new StockTotalResponse(itemId, item.Name, total,
balances.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList());
}
public async Task<MovementSummary> GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime to)
{
var toExclusive = to.TimeOfDay == TimeSpan.Zero ? to.AddDays(1) : to;
return await repo.GetMovementSummaryAsync(itemId, locationId, from, toExclusive);
}
public async Task<PagedResponse<StockMovementResponse>> GetMovementsAsync(MovementFilter filter, int page, int pageSize)
{
var items = await repo.GetMovementsAsync(filter, page, pageSize);
var total = await repo.GetMovementsCountAsync(filter);
var locations = new List<(Guid, LocationType)>();
foreach (var m in items)
{
if (m.FromLocationId is Guid fromId && m.FromLocationType is LocationType fromType && fromType != LocationType.External) locations.Add((fromId, fromType));
if (m.ToLocationId is Guid toId && m.ToLocationType is LocationType toType && toType != LocationType.External) locations.Add((toId, toType));
}
var names = await ResolveLocationNamesAsync(locations);
return new PagedResponse<StockMovementResponse>(page, pageSize, total,
items.Select(m => MapMovement(
m,
ResolveMovementSideName(m, names, isFrom: true),
ResolveMovementSideName(m, names, isFrom: false))).ToList());
}
// ── Writes ─────────────────────────────────────────────────────────────
public async Task<StockMovementResponse> TransferAsync(TransferRequest request, Guid userId, CancellationToken ct = default)
{
var item = await itemRepo.GetByIdAsync(request.ItemId)
?? throw new InvalidOperationException("Item not found.");
List<StockMovement> movements;
await uow.BeginAsync(ct);
try
{
movements = await mover.MoveAsync(request.ItemId, item.Name,
request.FromLocationId, request.FromLocationType,
request.ToLocationId, request.ToLocationType,
request.Quantity, MovementType.Transfer, request.ReferenceId, userId);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementsAsync(movements, item, request.Quantity);
}
public async Task<StockMovementResponse> RecordDamageAsync(DamageRequest request, Guid userId, CancellationToken ct = default)
{
var item = await itemRepo.GetByIdAsync(request.ItemId)
?? throw new InvalidOperationException("Item not found.");
List<StockMovement> movements;
await uow.BeginAsync(ct);
try
{
movements = await mover.MoveAsync(request.ItemId, item.Name,
request.LocationId, request.LocationType,
ExternalLocationId, LocationType.External,
request.Quantity, MovementType.Damage, null, userId, request.ReasonCode);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementsAsync(movements, item, request.Quantity);
}
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 tracked = item.TracksSerialNumbers || item.TracksBatches || item.TracksExpiryDate;
if (tracked && request.ReferenceId is null)
throw new InvalidOperationException(
$"'{item.Name}' is batch/serial tracked — a customer return must reference the ShopPO it was delivered on.");
List<StockMovement> movements;
await uow.BeginAsync(ct);
try
{
if (tracked)
{
movements = await mover.ReturnToVehicleAsync(request.ItemId, item.Name, request.Quantity,
request.ReferenceId!.Value, userId);
}
else
{
var movement = new StockMovement
{
FK_ItemId = request.ItemId,
FromLocationId = ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = request.VehicleId,
ToLocationType = LocationType.Vehicle,
Quantity = request.Quantity,
Type = MovementType.CustomerReturn,
ReferenceId = request.ReferenceId,
CreatedBy = userId
};
await CreditBalanceAsync(request.ItemId, request.VehicleId, LocationType.Vehicle, request.Quantity);
await repo.AddMovementAsync(movement);
movements = [movement];
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementsAsync(movements, item, request.Quantity);
}
public async Task<StockMovementResponse> AdjustAsync(AdjustmentRequest request, Guid userId, CancellationToken ct = default)
{
var item = await itemRepo.GetByIdAsync(request.ItemId)
?? throw new InvalidOperationException("Item not found.");
List<StockMovement> movements;
await uow.BeginAsync(ct);
try
{
if (request.Quantity < 0)
{
movements = await mover.MoveAsync(request.ItemId, item.Name,
request.LocationId, request.LocationType,
ExternalLocationId, LocationType.External,
-request.Quantity, MovementType.Adjustment, null, userId, request.ReasonCode);
}
else
{
// A positive adjustment can't say which batch/serial it adds — those enter stock through a GRN.
if (item.TracksSerialNumbers || item.TracksBatches || item.TracksExpiryDate)
throw new InvalidOperationException(
$"'{item.Name}' is batch/serial tracked — increase its stock by receiving a GRN, not by adjustment.");
var movement = new StockMovement
{
FK_ItemId = request.ItemId,
FromLocationId = ExternalLocationId,
FromLocationType = LocationType.External,
ToLocationId = request.LocationId,
ToLocationType = request.LocationType,
Quantity = request.Quantity,
Type = MovementType.Adjustment,
ReasonCode = request.ReasonCode,
CreatedBy = userId
};
await CreditBalanceAsync(request.ItemId, request.LocationId, request.LocationType, request.Quantity);
await repo.AddMovementAsync(movement);
movements = [movement];
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementsAsync(movements, item, Math.Abs(request.Quantity));
}
// Movements are an immutable ledger — only the reason/note can be edited, never quantity/locations/type.
public async Task<StockMovementResponse> UpdateMovementReasonAsync(Guid movementId, UpdateMovementReasonRequest request, CancellationToken ct = default)
{
var movement = await repo.GetMovementByIdAsync(movementId)
?? throw new InvalidOperationException("Movement not found.");
movement.ReasonCode = request.ReasonCode;
await uow.BeginAsync(ct);
try
{
repo.UpdateMovement(movement);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return await MapMovementAsync(movement);
}
// ── Helpers ────────────────────────────────────────────────────────────
/// <summary>
/// One logical operation can produce several movements (one per batch/serial slice). The caller gets a
/// single response for the operation: the first slice's locations, with the total quantity moved.
/// </summary>
private async Task<StockMovementResponse> MapMovementsAsync(List<StockMovement> movements, Models.Item item, decimal totalQuantity)
{
var first = movements[0];
first.Item ??= item;
return (await MapMovementAsync(first)) with { Quantity = totalQuantity };
}
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);
}
}
}