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))