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
@@ -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;
}