diff --git a/Distribution/Features/Stock/IStockRepository.cs b/Distribution/Features/Stock/IStockRepository.cs index a13505e..9b7c755 100644 --- a/Distribution/Features/Stock/IStockRepository.cs +++ b/Distribution/Features/Stock/IStockRepository.cs @@ -15,6 +15,8 @@ public interface IStockRepository // Movements Task AddMovementAsync(StockMovement movement); + Task GetMovementByIdAsync(Guid id); + void UpdateMovement(StockMovement movement); Task> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize); Task GetMovementsCountAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to); } diff --git a/Distribution/Features/Stock/StockController.cs b/Distribution/Features/Stock/StockController.cs index 3f896bb..5680eda 100644 --- a/Distribution/Features/Stock/StockController.cs +++ b/Distribution/Features/Stock/StockController.cs @@ -85,10 +85,24 @@ public class StockController(StockService service, ICurrentUserService currentUs [HttpPost("adjustment")] public async Task 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 { - 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 UpdateMovementReason(Guid id, UpdateMovementReasonRequest request, CancellationToken ct) + { + try + { + var result = await service.UpdateMovementReasonAsync(id, request, ct); return Ok(result); } catch (InvalidOperationException ex) { return BadRequest(ex.Message); } diff --git a/Distribution/Features/Stock/StockDTOs.cs b/Distribution/Features/Stock/StockDTOs.cs index eae6d85..bf57f0c 100644 --- a/Distribution/Features/Stock/StockDTOs.cs +++ b/Distribution/Features/Stock/StockDTOs.cs @@ -12,7 +12,8 @@ public record StockBalanceResponse( Guid LocationId, LocationType LocationType, decimal Quantity, - DateTime UpdatedAt); + DateTime UpdatedAt, + string LocationName); public record StockTotalResponse( Guid ItemId, @@ -34,7 +35,9 @@ public record StockMovementResponse( string? ReasonCode, Guid? ReferenceId, DateTime CreatedAt, - Guid CreatedBy); + Guid CreatedBy, + string? FromLocationName, + string? ToLocationName); // Write requests public record TransferRequest( @@ -64,4 +67,10 @@ public record AdjustmentRequest( Guid LocationId, LocationType LocationType, 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); diff --git a/Distribution/Features/Stock/StockRepository.cs b/Distribution/Features/Stock/StockRepository.cs index 6bc6f34..5f236e6 100644 --- a/Distribution/Features/Stock/StockRepository.cs +++ b/Distribution/Features/Stock/StockRepository.cs @@ -50,6 +50,12 @@ public class StockRepository(AppDBContext db) : IStockRepository public async Task AddMovementAsync(StockMovement movement) => await db.StockMovements.AddAsync(movement); + public Task 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> GetMovementsAsync(Guid? locationId, Guid? itemId, MovementType? type, DateTime? from, DateTime? to, int page, int pageSize) { var query = db.StockMovements.Include(m => m.Item).AsQueryable(); diff --git a/Distribution/Features/Stock/StockService.cs b/Distribution/Features/Stock/StockService.cs index d6a3c5c..7a56062 100644 --- a/Distribution/Features/Stock/StockService.cs +++ b/Distribution/Features/Stock/StockService.cs @@ -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 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 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> 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(); + 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(page, pageSize, total, items.Select(MapBalance).ToList()); + var names = await ResolveLocationNamesAsync(items.Select(b => (b.LocationId, b.LocationType))); + return new PagedResponse(page, pageSize, total, + items.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList()); } public async Task 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> 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(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(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 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 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 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 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 ────────────────────────────────────────────────────────────