ride reconcilation with driver profile update
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
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<CycleCountResult> 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<RideVariance>();
|
||||
|
||||
// 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<List<RideVarianceResponse>> GetVariancesAsync(Guid rideId)
|
||||
{
|
||||
var variances = await reconciliationRepo.GetVariancesByRideAsync(rideId);
|
||||
return variances.Select(ToVarianceResponse).ToList();
|
||||
}
|
||||
|
||||
public async Task<RideVarianceResponse> 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<RideVarianceResponse> 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<RideCloseSummaryResponse> 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);
|
||||
}
|
||||
Reference in New Issue
Block a user