Add stock report features r
This commit is contained in:
@@ -13,6 +13,7 @@ public class StockService(
|
||||
ICategoryRepository categoryRepo,
|
||||
IWarehouseRepository warehouseRepo,
|
||||
IVehicleRepository vehicleRepo,
|
||||
StockMover mover,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
private static StockBalanceResponse MapBalance(StockBalance b, string locationName) =>
|
||||
@@ -37,7 +38,9 @@ public class StockService(
|
||||
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.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)
|
||||
{
|
||||
@@ -112,11 +115,16 @@ public class StockService(
|
||||
balances.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList());
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<StockMovementResponse>> GetMovementsAsync(
|
||||
Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize)
|
||||
public async Task<MovementSummary> GetMovementSummaryAsync(Guid itemId, Guid locationId, DateTime from, DateTime to)
|
||||
{
|
||||
var items = await repo.GetMovementsAsync(locationId, itemId, type, from, to, page, pageSize);
|
||||
var total = await repo.GetMovementsCountAsync(locationId, itemId, type, from, 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)
|
||||
@@ -137,60 +145,42 @@ public class StockService(
|
||||
|
||||
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
|
||||
};
|
||||
var item = await itemRepo.GetByIdAsync(request.ItemId)
|
||||
?? throw new InvalidOperationException("Item not found.");
|
||||
|
||||
List<StockMovement> movements;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await ApplyTransferAsync(request.ItemId, request.FromLocationId, request.ToLocationId, request.ToLocationType, request.Quantity);
|
||||
await repo.AddMovementAsync(movement);
|
||||
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 MapMovementAsync(movement);
|
||||
return await MapMovementsAsync(movements, item, request.Quantity);
|
||||
}
|
||||
|
||||
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,
|
||||
ToLocationId = ExternalLocationId,
|
||||
ToLocationType = LocationType.External,
|
||||
Quantity = request.Quantity,
|
||||
Type = MovementType.Damage,
|
||||
ReasonCode = request.ReasonCode,
|
||||
CreatedBy = userId
|
||||
};
|
||||
var item = await itemRepo.GetByIdAsync(request.ItemId)
|
||||
?? throw new InvalidOperationException("Item not found.");
|
||||
|
||||
List<StockMovement> movements;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await DebitBalanceAsync(request.ItemId, request.LocationId, request.Quantity);
|
||||
await repo.AddMovementAsync(movement);
|
||||
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 MapMovementAsync(movement);
|
||||
return await MapMovementsAsync(movements, item, request.Quantity);
|
||||
}
|
||||
|
||||
public async Task<StockMovementResponse> RecordCustomerReturnAsync(CustomerReturnRequest request, Guid userId, CancellationToken ct = default)
|
||||
@@ -204,75 +194,89 @@ public class StockService(
|
||||
if (!category.AllowCustomerReturn)
|
||||
throw new InvalidOperationException($"Customer returns are not allowed for category '{category.Name}'.");
|
||||
|
||||
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
|
||||
};
|
||||
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
|
||||
{
|
||||
await CreditBalanceAsync(request.ItemId, request.VehicleId, LocationType.Vehicle, request.Quantity);
|
||||
await repo.AddMovementAsync(movement);
|
||||
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 MapMovementAsync(movement);
|
||||
return await MapMovementsAsync(movements, item, request.Quantity);
|
||||
}
|
||||
|
||||
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 : ExternalLocationId,
|
||||
FromLocationType = request.Quantity < 0 ? request.LocationType : LocationType.External,
|
||||
ToLocationId = request.Quantity > 0 ? request.LocationId : ExternalLocationId,
|
||||
ToLocationType = request.Quantity > 0 ? request.LocationType : LocationType.External,
|
||||
Quantity = Math.Abs(request.Quantity),
|
||||
Type = MovementType.Adjustment,
|
||||
ReasonCode = request.ReasonCode,
|
||||
CreatedBy = userId
|
||||
};
|
||||
var item = await itemRepo.GetByIdAsync(request.ItemId)
|
||||
?? throw new InvalidOperationException("Item not found.");
|
||||
|
||||
List<StockMovement> movements;
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
if (balance is null)
|
||||
if (request.Quantity < 0)
|
||||
{
|
||||
await repo.AddBalanceAsync(new StockBalance
|
||||
{
|
||||
FK_ItemId = request.ItemId,
|
||||
LocationId = request.LocationId,
|
||||
LocationType = request.LocationType,
|
||||
Quantity = request.Quantity
|
||||
});
|
||||
movements = await mover.MoveAsync(request.ItemId, item.Name,
|
||||
request.LocationId, request.LocationType,
|
||||
ExternalLocationId, LocationType.External,
|
||||
-request.Quantity, MovementType.Adjustment, null, userId, request.ReasonCode);
|
||||
}
|
||||
else
|
||||
{
|
||||
balance.Quantity += request.Quantity;
|
||||
balance.UpdatedAt = DateTime.Now;
|
||||
repo.UpdateBalance(balance);
|
||||
// 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 repo.AddMovementAsync(movement);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return await MapMovementAsync(movement);
|
||||
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.
|
||||
@@ -282,28 +286,29 @@ public class StockService(
|
||||
?? throw new InvalidOperationException("Movement not found.");
|
||||
|
||||
movement.ReasonCode = request.ReasonCode;
|
||||
repo.UpdateMovement(movement);
|
||||
await uow.SaveChangesAsync(ct);
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
repo.UpdateMovement(movement);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return await MapMovementAsync(movement);
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task EnsureStockAsync(Guid itemId, Guid locationId, decimal quantity)
|
||||
/// <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 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);
|
||||
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)
|
||||
@@ -326,10 +331,4 @@ public class StockService(
|
||||
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