Import: reject duplicate reading targets in the CSV wizard
ci / build-test (push) Successful in 1m15s

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
This commit is contained in:
2026-07-18 09:35:36 +02:00
parent 400d349d6a
commit a3af4838e8
2 changed files with 49 additions and 0 deletions
@@ -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);
}
/// <summary>
/// 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.
/// </summary>
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<int> AffectedMeters(StagedImport staged) =>
staged.Readings.Select(r => r.MeterId)
.Concat(staged.Events.Select(e => e.MeterId))