Add movement retrieval and update functionality to stock
This commit is contained in:
@@ -15,6 +15,8 @@ public interface IStockRepository
|
|||||||
|
|
||||||
// Movements
|
// Movements
|
||||||
Task AddMovementAsync(StockMovement movement);
|
Task AddMovementAsync(StockMovement movement);
|
||||||
|
Task<StockMovement?> GetMovementByIdAsync(Guid id);
|
||||||
|
void UpdateMovement(StockMovement movement);
|
||||||
Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize);
|
Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize);
|
||||||
Task<int> GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to);
|
Task<int> GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,10 +85,24 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
|||||||
[HttpPost("adjustment")]
|
[HttpPost("adjustment")]
|
||||||
public async Task<IActionResult> Adjust(AdjustmentRequest request, CancellationToken ct)
|
public async Task<IActionResult> Adjust(AdjustmentRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (currentUser.UserId is null) return Unauthorized();
|
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
|
||||||
|
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
|
||||||
|
var userId = currentUser.UserId ?? request.AdjustedByUserId;
|
||||||
|
if (userId is null) return BadRequest(new { error = "AdjustedByUserId is required while auth is unavailable" });
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await service.AdjustAsync(request, currentUser.UserId.Value, ct);
|
var result = await service.AdjustAsync(request, userId.Value, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(ex.Message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("movements/{id}/reason")]
|
||||||
|
public async Task<IActionResult> UpdateMovementReason(Guid id, UpdateMovementReasonRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.UpdateMovementReasonAsync(id, request, ct);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex) { return BadRequest(ex.Message); }
|
catch (InvalidOperationException ex) { return BadRequest(ex.Message); }
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ public record StockBalanceResponse(
|
|||||||
Guid LocationId,
|
Guid LocationId,
|
||||||
LocationType LocationType,
|
LocationType LocationType,
|
||||||
decimal Quantity,
|
decimal Quantity,
|
||||||
DateTime UpdatedAt);
|
DateTime UpdatedAt,
|
||||||
|
string LocationName);
|
||||||
|
|
||||||
public record StockTotalResponse(
|
public record StockTotalResponse(
|
||||||
Guid ItemId,
|
Guid ItemId,
|
||||||
@@ -34,7 +35,9 @@ public record StockMovementResponse(
|
|||||||
string? ReasonCode,
|
string? ReasonCode,
|
||||||
Guid? ReferenceId,
|
Guid? ReferenceId,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
Guid CreatedBy);
|
Guid CreatedBy,
|
||||||
|
string? FromLocationName,
|
||||||
|
string? ToLocationName);
|
||||||
|
|
||||||
// Write requests
|
// Write requests
|
||||||
public record TransferRequest(
|
public record TransferRequest(
|
||||||
@@ -64,4 +67,10 @@ public record AdjustmentRequest(
|
|||||||
Guid LocationId,
|
Guid LocationId,
|
||||||
LocationType LocationType,
|
LocationType LocationType,
|
||||||
decimal Quantity,
|
decimal Quantity,
|
||||||
string ReasonCode);
|
string ReasonCode,
|
||||||
|
// TEMPORARY: required while auth doesn't resolve a logged-in user server-side — see testbug.md #28.
|
||||||
|
Guid? AdjustedByUserId = null);
|
||||||
|
|
||||||
|
// Movements are an immutable ledger — quantity, locations, and type can never change once recorded
|
||||||
|
// (the running stock balance is the sum of all movements). Only the free-text reason/note is editable.
|
||||||
|
public record UpdateMovementReasonRequest(string ReasonCode);
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ public class StockRepository(AppDBContext db) : IStockRepository
|
|||||||
public async Task AddMovementAsync(StockMovement movement) =>
|
public async Task AddMovementAsync(StockMovement movement) =>
|
||||||
await db.StockMovements.AddAsync(movement);
|
await db.StockMovements.AddAsync(movement);
|
||||||
|
|
||||||
|
public Task<StockMovement?> GetMovementByIdAsync(Guid id) =>
|
||||||
|
db.StockMovements.Include(m => m.Item).FirstOrDefaultAsync(m => m.Id == id);
|
||||||
|
|
||||||
|
public void UpdateMovement(StockMovement movement) =>
|
||||||
|
db.StockMovements.Update(movement);
|
||||||
|
|
||||||
public Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize)
|
public Task<List<StockMovement>> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize)
|
||||||
{
|
{
|
||||||
var query = db.StockMovements.Include(m => m.Item).AsQueryable();
|
var query = db.StockMovements.Include(m => m.Item).AsQueryable();
|
||||||
|
|||||||
@@ -1,19 +1,79 @@
|
|||||||
using Distribution.Features.Category;
|
using Distribution.Features.Category;
|
||||||
using Distribution.Features.Item;
|
using Distribution.Features.Item;
|
||||||
|
using Distribution.Features.Vehicle;
|
||||||
|
using Distribution.Features.Warehouse;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
|
|
||||||
namespace Distribution.Features.Stock;
|
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) =>
|
private static StockBalanceResponse MapBalance(StockBalance b, string locationName) =>
|
||||||
new(b.FK_ItemId, b.Item.Name, b.LocationId, b.LocationType, b.Quantity, b.UpdatedAt);
|
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,
|
new(m.Id, m.FK_ItemId, m.Item.Name, m.FromLocationId, m.FromLocationType,
|
||||||
m.ToLocationId, m.ToLocationType, m.Quantity, m.Type, m.ReasonCode,
|
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 ────────────────────────────────────────────────────────────
|
// ── Queries ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -22,7 +82,9 @@ public class StockService(IStockRepository repo, IItemRepository itemRepo, ICate
|
|||||||
{
|
{
|
||||||
var items = await repo.GetBalancesAsync(locationId, locationType, itemId, page, pageSize);
|
var items = await repo.GetBalancesAsync(locationId, locationType, itemId, page, pageSize);
|
||||||
var total = await repo.GetBalancesCountAsync(locationId, locationType, itemId);
|
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)
|
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 balances = await repo.GetBalancesByItemAsync(itemId);
|
||||||
var total = balances.Sum(b => b.Quantity);
|
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(
|
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 items = await repo.GetMovementsAsync(locationId, itemId, type, from, to, page, pageSize);
|
||||||
var total = await repo.GetMovementsCountAsync(locationId, itemId, type, from, to);
|
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 ─────────────────────────────────────────────────────────────
|
// ── Writes ─────────────────────────────────────────────────────────────
|
||||||
@@ -71,7 +148,7 @@ public class StockService(IStockRepository repo, IItemRepository itemRepo, ICate
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
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)
|
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,
|
FK_ItemId = request.ItemId,
|
||||||
FromLocationId = request.LocationId,
|
FromLocationId = request.LocationId,
|
||||||
FromLocationType = request.LocationType,
|
FromLocationType = request.LocationType,
|
||||||
|
ToLocationId = ExternalLocationId,
|
||||||
|
ToLocationType = LocationType.External,
|
||||||
Quantity = request.Quantity,
|
Quantity = request.Quantity,
|
||||||
Type = MovementType.Damage,
|
Type = MovementType.Damage,
|
||||||
ReasonCode = request.ReasonCode,
|
ReasonCode = request.ReasonCode,
|
||||||
@@ -98,7 +177,7 @@ public class StockService(IStockRepository repo, IItemRepository itemRepo, ICate
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
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)
|
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
|
var movement = new StockMovement
|
||||||
{
|
{
|
||||||
FK_ItemId = request.ItemId,
|
FK_ItemId = request.ItemId,
|
||||||
|
FromLocationId = ExternalLocationId,
|
||||||
|
FromLocationType = LocationType.External,
|
||||||
ToLocationId = request.VehicleId,
|
ToLocationId = request.VehicleId,
|
||||||
ToLocationType = LocationType.Vehicle,
|
ToLocationType = LocationType.Vehicle,
|
||||||
Quantity = request.Quantity,
|
Quantity = request.Quantity,
|
||||||
@@ -132,7 +213,7 @@ public class StockService(IStockRepository repo, IItemRepository itemRepo, ICate
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
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)
|
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
|
var movement = new StockMovement
|
||||||
{
|
{
|
||||||
FK_ItemId = request.ItemId,
|
FK_ItemId = request.ItemId,
|
||||||
FromLocationId = request.Quantity < 0 ? request.LocationId : null,
|
FromLocationId = request.Quantity < 0 ? request.LocationId : ExternalLocationId,
|
||||||
FromLocationType = request.Quantity < 0 ? request.LocationType : null,
|
FromLocationType = request.Quantity < 0 ? request.LocationType : LocationType.External,
|
||||||
ToLocationId = request.Quantity > 0 ? request.LocationId : null,
|
ToLocationId = request.Quantity > 0 ? request.LocationId : ExternalLocationId,
|
||||||
ToLocationType = request.Quantity > 0 ? request.LocationType : null,
|
ToLocationType = request.Quantity > 0 ? request.LocationType : LocationType.External,
|
||||||
Quantity = Math.Abs(request.Quantity),
|
Quantity = Math.Abs(request.Quantity),
|
||||||
Type = MovementType.Adjustment,
|
Type = MovementType.Adjustment,
|
||||||
ReasonCode = request.ReasonCode,
|
ReasonCode = request.ReasonCode,
|
||||||
@@ -178,7 +259,20 @@ public class StockService(IStockRepository repo, IItemRepository itemRepo, ICate
|
|||||||
}
|
}
|
||||||
catch { await uow.RollbackAsync(ct); throw; }
|
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 ────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user