|
|
|
@@ -1,19 +1,79 @@
|
|
|
|
|
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, IUnitOfWork uow)
|
|
|
|
|
public class StockService(
|
|
|
|
|
IStockRepository repo,
|
|
|
|
|
IItemRepository itemRepo,
|
|
|
|
|
ICategoryRepository categoryRepo,
|
|
|
|
|
IWarehouseRepository warehouseRepo,
|
|
|
|
|
IVehicleRepository vehicleRepo,
|
|
|
|
|
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 StockBalanceResponse MapBalance(StockBalance b, string locationName) =>
|
|
|
|
|
new(b.FK_ItemId, b.Item.Name, b.LocationId, b.LocationType, b.Quantity, b.UpdatedAt, locationName);
|
|
|
|
|
|
|
|
|
|
private static StockMovementResponse MapMovement(StockMovement m) =>
|
|
|
|
|
// 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);
|
|
|
|
|
m.ReferenceId, m.CreatedAt, m.CreatedBy, fromLocationName, toLocationName);
|
|
|
|
|
|
|
|
|
|
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 ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
@@ -22,7 +82,9 @@ public class StockService(IStockRepository repo, IItemRepository itemRepo, ICate
|
|
|
|
|
{
|
|
|
|
|
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());
|
|
|
|
|
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<StockTotalResponse?> GetItemTotalAsync(Guid itemId)
|
|
|
|
@@ -32,7 +94,9 @@ public class StockService(IStockRepository repo, IItemRepository itemRepo, ICate
|
|
|
|
|
|
|
|
|
|
var balances = await repo.GetBalancesByItemAsync(itemId);
|
|
|
|
|
var total = balances.Sum(b => b.Quantity);
|
|
|
|
|
return new StockTotalResponse(itemId, item.Name, total, balances.Select(MapBalance).ToList());
|
|
|
|
|
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<PagedResponse<StockMovementResponse>> GetMovementsAsync(
|
|
|
|
@@ -40,7 +104,20 @@ public class StockService(IStockRepository repo, IItemRepository itemRepo, ICate
|
|
|
|
|
{
|
|
|
|
|
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());
|
|
|
|
|
|
|
|
|
|
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 ─────────────────────────────────────────────────────────────
|
|
|
|
@@ -71,7 +148,7 @@ public class StockService(IStockRepository repo, IItemRepository itemRepo, ICate
|
|
|
|
|
}
|
|
|
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
|
|
|
|
|
|
return MapMovement(movement);
|
|
|
|
|
return await MapMovementAsync(movement);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<StockMovementResponse> RecordDamageAsync(DamageRequest request, Guid userId, CancellationToken ct = default)
|
|
|
|
@@ -83,6 +160,8 @@ public class StockService(IStockRepository repo, IItemRepository itemRepo, ICate
|
|
|
|
|
FK_ItemId = request.ItemId,
|
|
|
|
|
FromLocationId = request.LocationId,
|
|
|
|
|
FromLocationType = request.LocationType,
|
|
|
|
|
ToLocationId = ExternalLocationId,
|
|
|
|
|
ToLocationType = LocationType.External,
|
|
|
|
|
Quantity = request.Quantity,
|
|
|
|
|
Type = MovementType.Damage,
|
|
|
|
|
ReasonCode = request.ReasonCode,
|
|
|
|
@@ -98,7 +177,7 @@ public class StockService(IStockRepository repo, IItemRepository itemRepo, ICate
|
|
|
|
|
}
|
|
|
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
|
|
|
|
|
|
return MapMovement(movement);
|
|
|
|
|
return await MapMovementAsync(movement);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<StockMovementResponse> RecordCustomerReturnAsync(CustomerReturnRequest request, Guid userId, CancellationToken ct = default)
|
|
|
|
@@ -115,6 +194,8 @@ public class StockService(IStockRepository repo, IItemRepository itemRepo, ICate
|
|
|
|
|
var movement = new StockMovement
|
|
|
|
|
{
|
|
|
|
|
FK_ItemId = request.ItemId,
|
|
|
|
|
FromLocationId = ExternalLocationId,
|
|
|
|
|
FromLocationType = LocationType.External,
|
|
|
|
|
ToLocationId = request.VehicleId,
|
|
|
|
|
ToLocationType = LocationType.Vehicle,
|
|
|
|
|
Quantity = request.Quantity,
|
|
|
|
@@ -132,7 +213,7 @@ public class StockService(IStockRepository repo, IItemRepository itemRepo, ICate
|
|
|
|
|
}
|
|
|
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
|
|
|
|
|
|
return MapMovement(movement);
|
|
|
|
|
return await MapMovementAsync(movement);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<StockMovementResponse> AdjustAsync(AdjustmentRequest request, Guid userId, CancellationToken ct = default)
|
|
|
|
@@ -144,10 +225,10 @@ public class StockService(IStockRepository repo, IItemRepository itemRepo, ICate
|
|
|
|
|
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,
|
|
|
|
|
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,
|
|
|
|
@@ -178,7 +259,20 @@ public class StockService(IStockRepository repo, IItemRepository itemRepo, ICate
|
|
|
|
|
}
|
|
|
|
|
catch { await uow.RollbackAsync(ct); throw; }
|
|
|
|
|
|
|
|
|
|
return MapMovement(movement);
|
|
|
|
|
return await MapMovementAsync(movement);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
repo.UpdateMovement(movement);
|
|
|
|
|
await uow.SaveChangesAsync(ct);
|
|
|
|
|
|
|
|
|
|
return await MapMovementAsync(movement);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
|
|
|