using Distribution.Features.Ride; using Distribution.Features.Stock; using Distribution.Infra.UoW; using Distribution.Models; namespace Distribution.Features.Reconciliation; public class ReconciliationService( IReconciliationRepository reconciliationRepo, IRideRepository rideRepo, IStockRepository stockRepo, IUnitOfWork uow) { public async Task SubmitCycleCountAsync( Guid rideId, SubmitCycleCountRequest request, Guid userId, CancellationToken ct = default) { var ride = await rideRepo.GetByIdAsync(rideId) ?? throw new KeyNotFoundException("Ride not found"); if (ride.Status != RideStatus.Active) throw new InvalidOperationException($"Cannot submit cycle count for a ride in {ride.Status} status"); var existingCounts = await reconciliationRepo.GetItemCountsByRideAsync(rideId); if (existingCounts.Count > 0) throw new InvalidOperationException("Cycle count has already been submitted for this ride"); // Build submitted counts lookup var submittedCounts = request.Items.ToDictionary(i => i.ItemId, i => i.PhysicalQuantity); // Save all item count records var itemCounts = request.Items.Select(line => new RideItemCount { FK_RideId = rideId, FK_ItemId = line.ItemId, PhysicalQuantity = line.PhysicalQuantity }).ToList(); // Get current vehicle stock balances var vehicleBalances = await stockRepo.GetBalancesByLocationAsync(ride.FK_VehicleId, LocationType.Vehicle); var variances = new List(); // Compare each stock balance against the physical count foreach (var balance in vehicleBalances) { var physicalQty = submittedCounts.TryGetValue(balance.FK_ItemId, out var qty) ? qty : 0m; if (balance.Quantity != physicalQty) { variances.Add(new RideVariance { FK_RideId = rideId, FK_ItemId = balance.FK_ItemId, IsCash = false, ExpectedQuantity = balance.Quantity, ActualQuantity = physicalQty, VarianceQuantity = balance.Quantity - physicalQty, Status = VarianceStatus.Pending }); } } // Cash variance check var expectedCash = await reconciliationRepo.GetCashCollectedByRideAsync(rideId); if (expectedCash != request.CashOnHand) { variances.Add(new RideVariance { FK_RideId = rideId, FK_ItemId = null, IsCash = true, ExpectedQuantity = expectedCash, ActualQuantity = request.CashOnHand, VarianceQuantity = expectedCash - request.CashOnHand, Status = VarianceStatus.Pending }); } ride.Status = RideStatus.PendingClose; await uow.BeginAsync(ct); try { foreach (var count in itemCounts) await reconciliationRepo.AddItemCountAsync(count); foreach (var variance in variances) await reconciliationRepo.AddVarianceAsync(variance); rideRepo.Update(ride); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } var varianceResponses = variances.Select(ToVarianceResponse).ToList(); return new CycleCountResult(varianceResponses.Count > 0, varianceResponses); } public async Task> GetVariancesAsync(Guid rideId) { var variances = await reconciliationRepo.GetVariancesByRideAsync(rideId); return variances.Select(ToVarianceResponse).ToList(); } public async Task ApproveVarianceAsync( Guid varianceId, ResolveVarianceRequest request, Guid userId, CancellationToken ct = default) { var variance = await reconciliationRepo.GetVarianceByIdAsync(varianceId) ?? throw new KeyNotFoundException("Variance not found"); if (variance.Status != VarianceStatus.Pending) throw new InvalidOperationException($"Variance is not in Pending status"); var ride = await rideRepo.GetByIdAsync(variance.FK_RideId) ?? throw new KeyNotFoundException("Ride not found"); if (ride.Status != RideStatus.PendingClose) throw new InvalidOperationException($"Ride is not in PendingClose status"); StockMovement? movement = null; StockBalance? balance = null; if (!variance.IsCash) { // adjustmentQty = -variance.VarianceQuantity // positive variance = short → debit vehicle (reduce stock) // negative variance = surplus → credit vehicle (add stock) var adjustmentQty = -variance.VarianceQuantity; balance = await stockRepo.GetBalanceAsync(variance.FK_ItemId!.Value, ride.FK_VehicleId); if (adjustmentQty < 0) { // Debit vehicle: FromLocationId=vehicleId, Quantity=abs(adjustmentQty) var absQty = Math.Abs(adjustmentQty); movement = new StockMovement { FK_ItemId = variance.FK_ItemId!.Value, FromLocationId = ride.FK_VehicleId, FromLocationType = LocationType.Vehicle, ToLocationId = null, ToLocationType = null, Quantity = absQty, Type = MovementType.Adjustment, ReasonCode = request.ReasonCode, ReferenceId = variance.Id, CreatedAt = DateTime.Now, CreatedBy = userId }; if (balance is not null) { balance.Quantity -= absQty; balance.UpdatedAt = DateTime.Now; } } else if (adjustmentQty > 0) { // Credit vehicle: ToLocationId=vehicleId, Quantity=adjustmentQty movement = new StockMovement { FK_ItemId = variance.FK_ItemId!.Value, FromLocationId = null, FromLocationType = null, ToLocationId = ride.FK_VehicleId, ToLocationType = LocationType.Vehicle, Quantity = adjustmentQty, Type = MovementType.Adjustment, ReasonCode = request.ReasonCode, ReferenceId = variance.Id, CreatedAt = DateTime.Now, CreatedBy = userId }; if (balance is not null) { balance.Quantity += adjustmentQty; balance.UpdatedAt = DateTime.Now; } else { balance = new StockBalance { FK_ItemId = variance.FK_ItemId!.Value, LocationId = ride.FK_VehicleId, LocationType = LocationType.Vehicle, Quantity = adjustmentQty, UpdatedAt = DateTime.Now }; } } } variance.Status = VarianceStatus.Approved; variance.ReasonCode = request.ReasonCode; variance.FK_ResolvedByUserId = userId; variance.ResolvedAt = DateTime.Now; await uow.BeginAsync(ct); try { reconciliationRepo.UpdateVariance(variance); if (movement is not null) await stockRepo.AddMovementAsync(movement); if (balance is not null) stockRepo.UpdateBalance(balance); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return ToVarianceResponse(variance); } public async Task RejectVarianceAsync( Guid varianceId, ResolveVarianceRequest request, Guid userId, CancellationToken ct = default) { var variance = await reconciliationRepo.GetVarianceByIdAsync(varianceId) ?? throw new KeyNotFoundException("Variance not found"); if (variance.Status != VarianceStatus.Pending) throw new InvalidOperationException($"Variance is not in Pending status"); var ride = await rideRepo.GetByIdAsync(variance.FK_RideId) ?? throw new KeyNotFoundException("Ride not found"); if (ride.Status != RideStatus.PendingClose) throw new InvalidOperationException($"Ride is not in PendingClose status"); StockMovement? movement = null; StockBalance? balance = null; if (!variance.IsCash) { var adjustmentQty = -variance.VarianceQuantity; balance = await stockRepo.GetBalanceAsync(variance.FK_ItemId!.Value, ride.FK_VehicleId); if (adjustmentQty < 0) { var absQty = Math.Abs(adjustmentQty); movement = new StockMovement { FK_ItemId = variance.FK_ItemId!.Value, FromLocationId = ride.FK_VehicleId, FromLocationType = LocationType.Vehicle, ToLocationId = null, ToLocationType = null, Quantity = absQty, Type = MovementType.Adjustment, ReasonCode = request.ReasonCode, ReferenceId = variance.Id, CreatedAt = DateTime.Now, CreatedBy = userId }; if (balance is not null) { balance.Quantity -= absQty; balance.UpdatedAt = DateTime.Now; } } else if (adjustmentQty > 0) { movement = new StockMovement { FK_ItemId = variance.FK_ItemId!.Value, FromLocationId = null, FromLocationType = null, ToLocationId = ride.FK_VehicleId, ToLocationType = LocationType.Vehicle, Quantity = adjustmentQty, Type = MovementType.Adjustment, ReasonCode = request.ReasonCode, ReferenceId = variance.Id, CreatedAt = DateTime.Now, CreatedBy = userId }; if (balance is not null) { balance.Quantity += adjustmentQty; balance.UpdatedAt = DateTime.Now; } else { balance = new StockBalance { FK_ItemId = variance.FK_ItemId!.Value, LocationId = ride.FK_VehicleId, LocationType = LocationType.Vehicle, Quantity = adjustmentQty, UpdatedAt = DateTime.Now }; } } } variance.Status = VarianceStatus.Rejected; variance.ReasonCode = request.ReasonCode; variance.FK_ResolvedByUserId = userId; variance.ResolvedAt = DateTime.Now; await uow.BeginAsync(ct); try { reconciliationRepo.UpdateVariance(variance); if (movement is not null) await stockRepo.AddMovementAsync(movement); if (balance is not null) stockRepo.UpdateBalance(balance); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return ToVarianceResponse(variance); } public async Task CloseRideAsync( Guid rideId, Guid userId, CancellationToken ct = default) { var ride = await rideRepo.GetByIdAsync(rideId) ?? throw new KeyNotFoundException("Ride not found"); if (ride.Status != RideStatus.PendingClose) throw new InvalidOperationException($"Cannot close a ride in {ride.Status} status"); if (await reconciliationRepo.HasPendingVariancesAsync(rideId)) throw new InvalidOperationException("All variances must be resolved before the ride can be closed"); var allVariances = await reconciliationRepo.GetVariancesByRideAsync(rideId); var cashVariance = allVariances.FirstOrDefault(v => v.IsCash); var expectedCash = cashVariance?.ExpectedQuantity ?? 0m; var actualCash = cashVariance?.ActualQuantity ?? 0m; var cashVarianceAmount = cashVariance?.VarianceQuantity ?? 0m; var summary = new RideCloseSummary { FK_RideId = rideId, ExpectedCash = expectedCash, ActualCash = actualCash, CashVariance = cashVarianceAmount, FK_ClosedByUserId = userId, ClosedAt = DateTime.Now }; ride.Status = RideStatus.Closed; ride.ClosedAt = DateTime.Now; await uow.BeginAsync(ct); try { await reconciliationRepo.AddCloseSummaryAsync(summary); rideRepo.Update(ride); await uow.CommitAsync(ct); } catch { await uow.RollbackAsync(ct); throw; } return new RideCloseSummaryResponse( summary.Id, summary.FK_RideId, summary.ExpectedCash, summary.ActualCash, summary.CashVariance, summary.FK_ClosedByUserId, summary.ClosedAt); } private static RideVarianceResponse ToVarianceResponse(RideVariance v) => new(v.Id, v.FK_RideId, v.FK_ItemId, v.Item?.Name, v.IsCash, v.ExpectedQuantity, v.ActualQuantity, v.VarianceQuantity, v.ReasonCode, v.Status, v.FK_ResolvedByUserId, v.ResolvedAt); }