diff --git a/src/App/Components/Pages/ImportWizard.razor b/src/App/Components/Pages/ImportWizard.razor index 5879bff..99e519b 100644 --- a/src/App/Components/Pages/ImportWizard.razor +++ b/src/App/Components/Pages/ImportWizard.razor @@ -292,6 +292,14 @@ return; } + // The mapping can change after a dry run — re-check before writing. + _validationErrors = Validate(); + if (_validationErrors.Count > 0) + { + _staged = null; + return; + } + _committing = true; try { @@ -341,6 +349,21 @@ } } + // Two Reading columns on one meter would stage two readings per row at the same timestamp, + // and (meter_id, time) is the reading key — reject it here rather than at the DB. + var duplicateTargets = _columns + .Select((c, i) => (Column: c, Index: i)) + .Where(x => x.Column.Role == MappingRole.Reading && x.Column.MeterId is not null) + .GroupBy(x => x.Column.MeterId!.Value) + .Where(g => g.Count() > 1); + + foreach (var group in duplicateTargets) + { + var meterName = _meters.FirstOrDefault(m => m.Id == group.Key)?.Name ?? $"meter {group.Key}"; + var cols = string.Join(", ", group.Select(x => $"Col {x.Index}")); + errors.Add($"{cols} all read into '{meterName}'. Each Reading column needs its own meter."); + } + return errors; } diff --git a/src/Infrastructure/Import/ImportService.cs b/src/Infrastructure/Import/ImportService.cs index 223d998..08a7a64 100644 --- a/src/Infrastructure/Import/ImportService.cs +++ b/src/Infrastructure/Import/ImportService.cs @@ -18,6 +18,7 @@ public sealed class ImportService(MeterVaultDbContext db, NormalizationService n StagedImport staged, string? sourceName, string? mappingJson, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(staged); + GuardDuplicateReadings(staged); await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); @@ -89,6 +90,31 @@ public sealed class ImportService(MeterVaultDbContext db, NormalizationService n await tx.CommitAsync(cancellationToken).ConfigureAwait(false); } + /// + /// A reading is keyed by (meter, time), so a staged set holding two rows for one meter at one + /// timestamp cannot be written. Usually a mapping that points two source columns at one meter — + /// report it in those terms instead of letting EF surface a change-tracker error. + /// + private static void GuardDuplicateReadings(StagedImport staged) + { + var duplicates = staged.Readings + .GroupBy(r => (r.MeterId, r.Time)) + .Where(g => g.Count() > 1) + .ToList(); + + if (duplicates.Count == 0) + { + return; + } + + var sample = string.Join("; ", duplicates.Take(3) + .Select(g => $"meter {g.Key.MeterId} at {g.Key.Time:yyyy-MM-dd}")); + + throw new InvalidOperationException( + $"{duplicates.Count} duplicate reading(s): the same meter is written twice at the same " + + $"timestamp ({sample}). Check that no two mapped columns target the same meter."); + } + private static IEnumerable AffectedMeters(StagedImport staged) => staged.Readings.Select(r => r.MeterId) .Concat(staged.Events.Select(e => e.MeterId))