Add movement retrieval and update functionality to stock

This commit is contained in:
2026-09-08 13:16:04 +05:30
parent f5872cedaf
commit 7e362be304
5 changed files with 146 additions and 21 deletions
@@ -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);
} }
+16 -2
View File
@@ -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 -3
View File
@@ -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();
+110 -16
View File
@@ -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 ────────────────────────────────────────────────────────────