397 lines
14 KiB
C#
397 lines
14 KiB
C#
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);
|
|
}
|