feat(uns): equipment CSV import folded into the tree toolbar

This commit is contained in:
Joseph Doherty
2026-06-08 13:56:01 -04:00
parent c0346f14ce
commit 7db9a24403
5 changed files with 515 additions and 1 deletions
@@ -89,6 +89,17 @@ public sealed record TagEditDto(string TagId, string EquipmentId, string Name, s
public sealed record VirtualTagEditDto(string VirtualTagId, string EquipmentId, string Name, string DataType, string ScriptId,
bool ChangeTriggered, int? TimerIntervalMs, bool Historize, bool Enabled, byte[] RowVersion);
/// <summary>
/// The outcome of a bulk equipment CSV import: how many rows were inserted, how many were skipped
/// (existing MachineCode — the importer is additive-only, never an update), and a per-row error list
/// for rows that could not be inserted (unknown line, unknown driver, or a decision-#122 cluster
/// mismatch). Skipped rows never appear in <see cref="Errors"/>.
/// </summary>
/// <param name="Inserted">The count of new Equipment rows added.</param>
/// <param name="Skipped">The count of rows skipped because their MachineCode already exists.</param>
/// <param name="Errors">The human-readable error strings for rows that failed validation.</param>
public sealed record EquipmentImportResult(int Inserted, int Skipped, IReadOnlyList<string> Errors);
/// <summary>
/// Loads the structural portion of the unified-namespace (UNS) browse tree —
/// Enterprise → Cluster → Area → Line → Equipment — from the config database.
@@ -258,6 +269,21 @@ public interface IUnsTreeService
/// <returns>Success, a missing-line failure, a duplicate-MachineCode failure, or a #122 guard failure.</returns>
Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default);
/// <summary>
/// Bulk-imports equipment from a parsed set of <see cref="EquipmentInput"/> rows in a single
/// context, applying the same rules as the single-add path: a row whose <c>UnsLineId</c> does not
/// exist is an error; a row whose <c>DriverInstanceId</c> is set but does not resolve is an error;
/// a driver-bound row whose driver is in a different cluster than its line fails the decision-#122
/// guard; and a row whose <c>MachineCode</c> already exists in the DB <em>or</em> earlier in the
/// same batch is silently skipped (additive-only — never an update). Inserted rows get a
/// system-generated <c>EQ-</c> id and a fresh <c>EquipmentUuid</c>. All inserts are saved once at
/// the end.
/// </summary>
/// <param name="rows">The parsed equipment rows to import.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The insert/skip counts and the per-row error list.</returns>
Task<EquipmentImportResult> ImportEquipmentAsync(IReadOnlyList<EquipmentInput> rows, CancellationToken ct = default);
/// <summary>
/// Updates an equipment's mutable fields (driver binding, line, name, MachineCode, external
/// ids, and the OPC 40010 identification fields). The decision-#122 driver-cluster guard blocks
@@ -504,6 +504,81 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return new UnsMutationResult(true, null);
}
/// <inheritdoc />
public async Task<EquipmentImportResult> ImportEquipmentAsync(
IReadOnlyList<EquipmentInput> rows,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
// Known lines and the MachineCodes already in the fleet, read once up front. The seen-set
// tracks MachineCodes inserted earlier in this same batch so two CSV rows that share a
// MachineCode skip the duplicate the same way the original ImportEquipment.razor did.
var lineSet = (await db.UnsLines.AsNoTracking().Select(l => l.UnsLineId).ToListAsync(ct))
.ToHashSet(StringComparer.Ordinal);
var existing = (await db.Equipment.AsNoTracking().Select(e => e.MachineCode).ToListAsync(ct))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var inserted = 0;
var skipped = 0;
var errors = new List<string>();
foreach (var row in rows)
{
// Existing MachineCode (in DB or earlier in this batch) → skip, never an error.
if (existing.Contains(row.MachineCode))
{
skipped++;
continue;
}
if (!lineSet.Contains(row.UnsLineId))
{
errors.Add($"Row '{row.MachineCode}': UNS line '{row.UnsLineId}' not found.");
continue;
}
// Reuse the #122 guard: it reports an unknown DriverInstanceId and a driver/line cluster
// mismatch, and is a no-op for driver-less rows.
var guard = await CheckDriverClusterGuardAsync(db, row, ct);
if (guard is not null)
{
errors.Add($"Row '{row.MachineCode}': {guard.Value.Error}");
continue;
}
var uuid = Guid.NewGuid();
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
db.Equipment.Add(new Equipment
{
EquipmentId = equipmentId,
EquipmentUuid = uuid,
DriverInstanceId = string.IsNullOrWhiteSpace(row.DriverInstanceId) ? null : row.DriverInstanceId,
UnsLineId = row.UnsLineId,
Name = row.Name,
MachineCode = row.MachineCode,
ZTag = string.IsNullOrWhiteSpace(row.ZTag) ? null : row.ZTag,
SAPID = string.IsNullOrWhiteSpace(row.SAPID) ? null : row.SAPID,
Manufacturer = row.Manufacturer,
Model = row.Model,
SerialNumber = row.SerialNumber,
HardwareRevision = row.HardwareRevision,
SoftwareRevision = row.SoftwareRevision,
YearOfConstruction = row.YearOfConstruction,
AssetLocation = row.AssetLocation,
ManufacturerUri = row.ManufacturerUri,
DeviceManualUri = row.DeviceManualUri,
Enabled = row.Enabled,
});
existing.Add(row.MachineCode);
inserted++;
}
await db.SaveChangesAsync(ct);
return new EquipmentImportResult(inserted, skipped, errors);
}
/// <inheritdoc />
public async Task<UnsMutationResult> UpdateEquipmentAsync(
string equipmentId,