Import: reject duplicate reading targets in the CSV wizard
ci / build-test (push) Successful in 1m15s
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:
@@ -292,6 +292,14 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The mapping can change after a dry run — re-check before writing.
|
||||||
|
_validationErrors = Validate();
|
||||||
|
if (_validationErrors.Count > 0)
|
||||||
|
{
|
||||||
|
_staged = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_committing = true;
|
_committing = true;
|
||||||
try
|
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;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public sealed class ImportService(MeterVaultDbContext db, NormalizationService n
|
|||||||
StagedImport staged, string? sourceName, string? mappingJson, CancellationToken cancellationToken = default)
|
StagedImport staged, string? sourceName, string? mappingJson, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(staged);
|
ArgumentNullException.ThrowIfNull(staged);
|
||||||
|
GuardDuplicateReadings(staged);
|
||||||
|
|
||||||
await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
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);
|
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) =>
|
private static IEnumerable<int> AffectedMeters(StagedImport staged) =>
|
||||||
staged.Readings.Select(r => r.MeterId)
|
staged.Readings.Select(r => r.MeterId)
|
||||||
.Concat(staged.Events.Select(e => e.MeterId))
|
.Concat(staged.Events.Select(e => e.MeterId))
|
||||||
|
|||||||
Reference in New Issue
Block a user