feat(uns): equipment CSV import folded into the tree toolbar
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
@bind="_filter" @bind:event="oninput" />
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="ExpandAll">Expand all</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CollapseAll">Collapse all</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" disabled>Import equipment CSV</button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenImportModal">Import equipment CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,6 +89,10 @@
|
||||
OnSaved="OnEquipmentChildModalSavedAsync"
|
||||
OnCancel="CloseModals" />
|
||||
|
||||
<ImportEquipmentModal Visible="_importModalVisible"
|
||||
OnImported="OnImportedAsync"
|
||||
OnCancel="CloseModals" />
|
||||
|
||||
@if (_confirmNode is not null)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
@@ -158,6 +162,9 @@
|
||||
private VirtualTagEditDto? _vtagModalExisting;
|
||||
private IReadOnlyList<(string Id, string Display)> _vtagModalScriptOptions = Array.Empty<(string, string)>();
|
||||
|
||||
// --- Import-equipment-CSV modal state ---
|
||||
private bool _importModalVisible;
|
||||
|
||||
// --- Owning equipment to refresh in place after a tag/virtual-tag mutation ---
|
||||
private string? _childRefreshEquipmentId;
|
||||
|
||||
@@ -357,6 +364,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Opens the bulk equipment-CSV import modal.</summary>
|
||||
private void OpenImportModal()
|
||||
{
|
||||
CloseModals();
|
||||
_importModalVisible = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the import modal's close after a run. A bulk import can add equipment across many
|
||||
/// lines and clusters, so the whole structural tree is reloaded rather than refreshed in place.
|
||||
/// </summary>
|
||||
private async Task OnImportedAsync()
|
||||
{
|
||||
_roots = await Svc.LoadStructureAsync();
|
||||
CloseModals();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>Opens the delete-confirm modal for a node, stashing it as the pending target.</summary>
|
||||
private void HandleDelete(UnsNode node)
|
||||
{
|
||||
@@ -549,6 +574,7 @@
|
||||
_vtagModalExisting = null;
|
||||
_vtagModalEquipmentId = null;
|
||||
_vtagModalScriptOptions = Array.Empty<(string, string)>();
|
||||
_importModalVisible = false;
|
||||
_childRefreshEquipmentId = null;
|
||||
_confirmNode = null;
|
||||
_confirmError = null;
|
||||
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
@* Bulk equipment import modal wired straight into IUnsTreeService. Paste a CSV (header + rows),
|
||||
the modal parses it into EquipmentInput rows and calls ImportEquipmentAsync, then shows the
|
||||
Inserted / Skipped / Errors summary in place. The host owns visibility; "Close" raises OnImported
|
||||
so the page can reload the whole tree (an import can add equipment across many lines/clusters).
|
||||
Required header columns (in order): Name, MachineCode, UnsLineId, DriverInstanceId.
|
||||
Optional: ZTag, SAPID, Manufacturer, Model. Existing rows are detected by MachineCode and
|
||||
skipped (additive-only — no updates), matching the retired /clusters/{id}/equipment/import page. *@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@inject IUnsTreeService Svc
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
<div class="modal-backdrop fade show" style="display:block"></div>
|
||||
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
|
||||
<div class="modal-dialog modal-lg" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Import equipment CSV</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small mb-2">
|
||||
Paste CSV below. Required header columns (in order):
|
||||
<span class="mono">Name, MachineCode, UnsLineId, DriverInstanceId</span>.
|
||||
Optional: <span class="mono">ZTag, SAPID, Manufacturer, Model</span>.
|
||||
Each row inserts one equipment with a freshly-generated EquipmentId. Existing rows
|
||||
are detected by MachineCode and skipped (additive-only — no updates).
|
||||
</p>
|
||||
|
||||
<textarea class="form-control form-control-sm mono" rows="12"
|
||||
@bind="_csv" @bind:event="oninput" disabled="@_busy"
|
||||
placeholder="Name,MachineCode,UnsLineId,DriverInstanceId,ZTag,SAPID,Manufacturer,Model mixer-01,MX_001,LINE-1,drv-modbus-01,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_parseError))
|
||||
{
|
||||
<div class="text-danger small mt-2">@_parseError</div>
|
||||
}
|
||||
|
||||
@if (_result is not null)
|
||||
{
|
||||
<div class="alert alert-info mt-3 mb-0" role="alert">
|
||||
<div>
|
||||
<strong>Inserted:</strong> @_result.Inserted
|
||||
· <strong>Skipped (existing MachineCode):</strong> @_result.Skipped
|
||||
· <strong>Errors:</strong> @_result.Errors.Count
|
||||
</div>
|
||||
@if (_result.Errors.Count > 0)
|
||||
{
|
||||
<ul class="small mb-0 mt-2">
|
||||
@foreach (var err in _result.Errors)
|
||||
{
|
||||
<li>@err</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">
|
||||
@(_result is null ? "Cancel" : "Close")
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="ImportAsync" disabled="@_busy">
|
||||
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
// Required, in order; DriverInstanceId may be left blank per row (driver-less equipment).
|
||||
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId", "DriverInstanceId"];
|
||||
|
||||
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user closes the modal after an import so the host can reload the tree and
|
||||
/// dismiss the modal. Fired on close regardless of whether anything was inserted — closing always
|
||||
/// returns control to the host.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnImported { get; set; }
|
||||
|
||||
/// <summary>Raised when the user cancels before importing so the host can close.</summary>
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
|
||||
private string _csv = "";
|
||||
private bool _busy;
|
||||
private string? _parseError;
|
||||
private EquipmentImportResult? _result;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Reset the working state each time the host (re)opens the modal.
|
||||
if (!Visible)
|
||||
{
|
||||
_csv = "";
|
||||
_parseError = null;
|
||||
_result = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ImportAsync()
|
||||
{
|
||||
_busy = true;
|
||||
_parseError = null;
|
||||
_result = null;
|
||||
try
|
||||
{
|
||||
var rows = ParseCsv(_csv);
|
||||
if (rows is null) { return; }
|
||||
|
||||
_result = await Svc.ImportEquipmentAsync(rows);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the modal. Once an import has run (the user has seen the summary), closing raises
|
||||
/// OnImported so the host reloads the tree; before any import it raises OnCancel.
|
||||
/// </summary>
|
||||
private Task CloseAsync() =>
|
||||
_result is not null ? OnImported.InvokeAsync() : OnCancel.InvokeAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Parses the pasted CSV into <see cref="EquipmentInput"/> rows. Requires a header row whose first
|
||||
/// four columns are Name, MachineCode, UnsLineId, DriverInstanceId (case-insensitive); the optional
|
||||
/// ZTag/SAPID/Manufacturer/Model follow. Blank optional cells parse as <c>null</c>. Returns
|
||||
/// <c>null</c> (and sets <see cref="_parseError"/>) when the CSV is empty or the header is wrong.
|
||||
/// </summary>
|
||||
private List<EquipmentInput>? ParseCsv(string csv)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(csv)) { _parseError = "CSV is empty."; return null; }
|
||||
|
||||
var lines = csv.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (lines.Length < 2) { _parseError = "Need a header row and at least one data row."; return null; }
|
||||
|
||||
var header = lines[0].Split(',').Select(c => c.Trim()).ToArray();
|
||||
for (var i = 0; i < RequiredColumns.Length; i++)
|
||||
{
|
||||
if (i >= header.Length || !string.Equals(header[i], RequiredColumns[i], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_parseError = $"Header column #{i + 1} must be '{RequiredColumns[i]}' (got '{(i < header.Length ? header[i] : "")}').";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var rows = new List<EquipmentInput>();
|
||||
for (var lineIdx = 1; lineIdx < lines.Length; lineIdx++)
|
||||
{
|
||||
var parts = lines[lineIdx].Split(',').Select(c => c.Trim()).ToArray();
|
||||
if (parts.Length < RequiredColumns.Length)
|
||||
{
|
||||
_parseError = $"Row {lineIdx}: too few columns (got {parts.Length}, need {RequiredColumns.Length}).";
|
||||
return null;
|
||||
}
|
||||
|
||||
rows.Add(new EquipmentInput(
|
||||
Name: parts[0],
|
||||
MachineCode: parts[1],
|
||||
UnsLineId: parts[2],
|
||||
DriverInstanceId: NullIfEmpty(parts, 3),
|
||||
ZTag: NullIfEmpty(parts, 4),
|
||||
SAPID: NullIfEmpty(parts, 5),
|
||||
Manufacturer: NullIfEmpty(parts, 6),
|
||||
Model: NullIfEmpty(parts, 7),
|
||||
SerialNumber: null,
|
||||
HardwareRevision: null,
|
||||
SoftwareRevision: null,
|
||||
YearOfConstruction: null,
|
||||
AssetLocation: null,
|
||||
ManufacturerUri: null,
|
||||
DeviceManualUri: null,
|
||||
Enabled: true));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string[] parts, int idx) =>
|
||||
idx < parts.Length && !string.IsNullOrWhiteSpace(parts[idx]) ? parts[idx] : null;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the bulk <see cref="UnsTreeService.ImportEquipmentAsync"/> path: valid rows insert,
|
||||
/// rows whose MachineCode already exists (in the DB or earlier in the same batch) are skipped,
|
||||
/// rows referencing an unknown UNS line or unknown driver are reported as errors, and the
|
||||
/// decision-#122 driver-cluster guard rejects a driver in a different cluster than the line.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnsTreeServiceImportTests
|
||||
{
|
||||
private static (UnsTreeService Service, string DbName) Fresh()
|
||||
{
|
||||
var dbName = $"uns-import-{Guid.NewGuid():N}";
|
||||
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a line under an area in <paramref name="lineCluster"/>, plus an optional driver in
|
||||
/// <paramref name="driverCluster"/>. The line id is always <c>LINE-1</c>; the driver (when
|
||||
/// requested) is always <c>DRV-1</c>.
|
||||
/// </summary>
|
||||
private static void SeedLineAndDriver(string dbName, string lineCluster, string? driverCluster)
|
||||
{
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = lineCluster, Name = "a" });
|
||||
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
|
||||
if (driverCluster is not null)
|
||||
{
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "DRV-1",
|
||||
ClusterId = driverCluster,
|
||||
NamespaceId = "NS-1",
|
||||
Name = "drv",
|
||||
DriverType = "ModbusTcp",
|
||||
DriverConfig = "{}",
|
||||
});
|
||||
}
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static EquipmentInput Input(string name, string machineCode, string unsLineId, string? driverInstanceId) =>
|
||||
new(name, machineCode, unsLineId, driverInstanceId,
|
||||
ZTag: null, SAPID: null, Manufacturer: null, Model: null, SerialNumber: null,
|
||||
HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: null,
|
||||
AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: true);
|
||||
|
||||
/// <summary>Valid rows insert with system-generated EQ- ids and are counted in <c>Inserted</c>.</summary>
|
||||
[Fact]
|
||||
public async Task Import_inserts_valid_rows()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
|
||||
|
||||
var result = await service.ImportEquipmentAsync(
|
||||
[
|
||||
Input("machine-1", "mc_1", "LINE-1", null),
|
||||
Input("machine-2", "mc_2", "LINE-1", null),
|
||||
]);
|
||||
|
||||
result.Inserted.ShouldBe(2);
|
||||
result.Skipped.ShouldBe(0);
|
||||
result.Errors.ShouldBeEmpty();
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Equipment.Count().ShouldBe(2);
|
||||
var eq = db.Equipment.Single(e => e.MachineCode == "mc_1");
|
||||
eq.EquipmentId.ShouldStartWith("EQ-");
|
||||
eq.EquipmentId.Length.ShouldBe(15); // "EQ-" + 12 hex chars
|
||||
eq.Name.ShouldBe("machine-1");
|
||||
eq.UnsLineId.ShouldBe("LINE-1");
|
||||
eq.Enabled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A row whose MachineCode already exists in the DB is skipped (not an error), and a second row
|
||||
/// later in the batch that reuses an earlier row's MachineCode is also skipped.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Import_skips_duplicate_machinecode()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
|
||||
// Pre-existing equipment with MachineCode "mc_existing".
|
||||
await service.CreateEquipmentAsync(Input("machine-x", "mc_existing", "LINE-1", null));
|
||||
|
||||
var result = await service.ImportEquipmentAsync(
|
||||
[
|
||||
Input("machine-1", "mc_existing", "LINE-1", null), // dup of DB row → skip
|
||||
Input("machine-2", "mc_new", "LINE-1", null), // inserts
|
||||
Input("machine-3", "mc_new", "LINE-1", null), // dup of in-batch row → skip
|
||||
]);
|
||||
|
||||
result.Inserted.ShouldBe(1);
|
||||
result.Skipped.ShouldBe(2);
|
||||
result.Errors.ShouldBeEmpty();
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Equipment.Count(e => e.MachineCode == "mc_new").ShouldBe(1);
|
||||
db.Equipment.Count(e => e.MachineCode == "mc_existing").ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>A row whose UnsLineId does not exist is reported as an error and not inserted.</summary>
|
||||
[Fact]
|
||||
public async Task Import_reports_unknown_line()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
|
||||
|
||||
var result = await service.ImportEquipmentAsync(
|
||||
[
|
||||
Input("machine-1", "mc_1", "LINE-BOGUS", null),
|
||||
Input("machine-2", "mc_2", "LINE-1", null),
|
||||
]);
|
||||
|
||||
result.Inserted.ShouldBe(1);
|
||||
result.Skipped.ShouldBe(0);
|
||||
result.Errors.Count.ShouldBe(1);
|
||||
result.Errors[0].ShouldContain("mc_1");
|
||||
result.Errors[0].ShouldContain("LINE-BOGUS");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Equipment.Any(e => e.MachineCode == "mc_1").ShouldBeFalse();
|
||||
db.Equipment.Any(e => e.MachineCode == "mc_2").ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>A driver-bound row whose DriverInstanceId does not resolve is reported as an error.</summary>
|
||||
[Fact]
|
||||
public async Task Import_reports_unknown_driver()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null); // no driver seeded
|
||||
|
||||
var result = await service.ImportEquipmentAsync(
|
||||
[
|
||||
Input("machine-1", "mc_1", "LINE-1", "DRV-GHOST"),
|
||||
]);
|
||||
|
||||
result.Inserted.ShouldBe(0);
|
||||
result.Skipped.ShouldBe(0);
|
||||
result.Errors.Count.ShouldBe(1);
|
||||
result.Errors[0].ShouldContain("mc_1");
|
||||
result.Errors[0].ShouldContain("DRV-GHOST");
|
||||
result.Errors[0].ShouldContain("not found");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Equipment.Any(e => e.MachineCode == "mc_1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The decision-#122 guard rejects a row that binds a driver living in a different cluster than
|
||||
/// the row's UNS line.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Import_enforces_122_cluster()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "SITE-A");
|
||||
|
||||
var result = await service.ImportEquipmentAsync(
|
||||
[
|
||||
Input("machine-1", "mc_1", "LINE-1", "DRV-1"),
|
||||
]);
|
||||
|
||||
result.Inserted.ShouldBe(0);
|
||||
result.Skipped.ShouldBe(0);
|
||||
result.Errors.Count.ShouldBe(1);
|
||||
result.Errors[0].ShouldContain("mc_1");
|
||||
result.Errors[0].ShouldContain("decision #122");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Equipment.Any(e => e.MachineCode == "mc_1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A driver in the same cluster as the line imports cleanly.</summary>
|
||||
[Fact]
|
||||
public async Task Import_allows_driver_in_same_cluster()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "MAIN");
|
||||
|
||||
var result = await service.ImportEquipmentAsync(
|
||||
[
|
||||
Input("machine-1", "mc_1", "LINE-1", "DRV-1"),
|
||||
]);
|
||||
|
||||
result.Inserted.ShouldBe(1);
|
||||
result.Skipped.ShouldBe(0);
|
||||
result.Errors.ShouldBeEmpty();
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.Equipment.Single(e => e.MachineCode == "mc_1").DriverInstanceId.ShouldBe("DRV-1");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user