From a3af4838e855a3ac260902ffffea3884c29c53b5 Mon Sep 17 00:00:00 2001 From: Florian Schmidt Date: Sat, 18 Jul 2026 09:35:36 +0200 Subject: [PATCH] Import: reject duplicate reading targets in the CSV wizard Mapping two Reading columns onto one meter staged two readings per row at the same timestamp. (meter_id, time) is the reading key, so the commit failed with a raw EF change-tracker error that named neither the column nor the meter. - Wizard Validate() rejects duplicate Reading targets, naming the columns and the meter. Reading-only: Delivery/TankLevel stage events, not readings. - CommitAsync() re-validates. Previously only Preview did, so a mapping edited after a dry run reached the database unchecked. - ImportService.GuardDuplicateReadings() backstops the same case for the API and any other non-wizard caller, reporting meter + date. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd --- src/App/Components/Pages/ImportWizard.razor | 23 ++++++++++++++++++ src/Infrastructure/Import/ImportService.cs | 26 +++++++++++++++++++++ 2 files changed, 49 insertions(+) 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))