Compare commits
9 Commits
redundancy
...
alarm-invo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
404bfbe7e4 | ||
| 006af636a0 | |||
|
|
c0751fdda5 | ||
| 80e080ecec | |||
|
|
5ee510dc1a | ||
| 543665dedd | |||
|
|
c8a38bc57b | ||
| cecb84fa5d | |||
| d1686ed82d |
@@ -0,0 +1,90 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
|
||||
@* Per-section diff renderer — the base used by DiffViewer for every known TableName. Caps
|
||||
output at RowCap rows so a pathological draft (e.g. 20k tags churned) can't freeze the
|
||||
Blazor render; overflow banner tells operator how many rows were hidden. *@
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>@Title</strong>
|
||||
<small class="text-muted ms-2">@Description</small>
|
||||
</div>
|
||||
<div>
|
||||
@if (_added > 0) { <span class="badge bg-success me-1">+@_added</span> }
|
||||
@if (_removed > 0) { <span class="badge bg-danger me-1">−@_removed</span> }
|
||||
@if (_modified > 0) { <span class="badge bg-warning text-dark me-1">~@_modified</span> }
|
||||
@if (_total == 0) { <span class="badge bg-secondary">no changes</span> }
|
||||
</div>
|
||||
</div>
|
||||
@if (_total == 0)
|
||||
{
|
||||
<div class="card-body text-muted small">No changes in this section.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_total > RowCap)
|
||||
{
|
||||
<div class="alert alert-warning mb-0 small rounded-0">
|
||||
Showing the first @RowCap of @_total rows — cap protects the browser from megabyte-class
|
||||
diffs. Inspect the remainder via the SQL <code>sp_ComputeGenerationDiff</code> directly.
|
||||
</div>
|
||||
}
|
||||
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>LogicalId</th><th style="width: 120px;">Change</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _visibleRows)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@r.LogicalId</code></td>
|
||||
<td>
|
||||
@switch (r.ChangeKind)
|
||||
{
|
||||
case "Added": <span class="badge bg-success">@r.ChangeKind</span> break;
|
||||
case "Removed": <span class="badge bg-danger">@r.ChangeKind</span> break;
|
||||
case "Modified": <span class="badge bg-warning text-dark">@r.ChangeKind</span> break;
|
||||
default: <span class="badge bg-secondary">@r.ChangeKind</span> break;
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Default row-cap per section — matches task #156's acceptance criterion.</summary>
|
||||
public const int DefaultRowCap = 1000;
|
||||
|
||||
[Parameter, EditorRequired] public string Title { get; set; } = string.Empty;
|
||||
[Parameter] public string Description { get; set; } = string.Empty;
|
||||
[Parameter, EditorRequired] public IReadOnlyList<DiffRow> Rows { get; set; } = [];
|
||||
[Parameter] public int RowCap { get; set; } = DefaultRowCap;
|
||||
|
||||
private int _total;
|
||||
private int _added;
|
||||
private int _removed;
|
||||
private int _modified;
|
||||
private List<DiffRow> _visibleRows = [];
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_total = Rows.Count;
|
||||
_added = 0; _removed = 0; _modified = 0;
|
||||
foreach (var r in Rows)
|
||||
{
|
||||
switch (r.ChangeKind)
|
||||
{
|
||||
case "Added": _added++; break;
|
||||
case "Removed": _removed++; break;
|
||||
case "Modified": _modified++; break;
|
||||
}
|
||||
}
|
||||
_visibleRows = _total > RowCap ? Rows.Take(RowCap).ToList() : Rows.ToList();
|
||||
}
|
||||
}
|
||||
@@ -28,36 +28,44 @@ else if (_rows.Count == 0)
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-hover table-sm">
|
||||
<thead><tr><th>Table</th><th>LogicalId</th><th>ChangeKind</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td>@r.TableName</td>
|
||||
<td><code>@r.LogicalId</code></td>
|
||||
<td>
|
||||
@switch (r.ChangeKind)
|
||||
{
|
||||
case "Added": <span class="badge bg-success">@r.ChangeKind</span> break;
|
||||
case "Removed": <span class="badge bg-danger">@r.ChangeKind</span> break;
|
||||
case "Modified": <span class="badge bg-warning text-dark">@r.ChangeKind</span> break;
|
||||
default: <span class="badge bg-secondary">@r.ChangeKind</span> break;
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="small text-muted mb-3">
|
||||
@_rows.Count row@(_rows.Count == 1 ? "" : "s") across @_sectionsWithChanges of @Sections.Count sections.
|
||||
Each section is capped at @DiffSection.DefaultRowCap rows to keep the browser responsive on pathological drafts.
|
||||
</p>
|
||||
|
||||
@foreach (var sec in Sections)
|
||||
{
|
||||
<DiffSection Title="@sec.Title"
|
||||
Description="@sec.Description"
|
||||
Rows="@RowsFor(sec.TableName)"/>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered section definitions — each maps a <c>TableName</c> emitted by
|
||||
/// <c>sp_ComputeGenerationDiff</c> to a human label + description. The proc currently
|
||||
/// emits Namespace/DriverInstance/Equipment/Tag; UnsLine + NodeAcl entries render as
|
||||
/// empty "no changes" cards until the proc is extended (tracked in tasks #196 + #156
|
||||
/// follow-up). Six sections total matches the task #156 target.
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyList<SectionDef> Sections = new[]
|
||||
{
|
||||
new SectionDef("Namespace", "Namespaces", "OPC UA namespace URIs + enablement"),
|
||||
new SectionDef("DriverInstance", "Driver instances","Per-cluster driver configuration rows"),
|
||||
new SectionDef("Equipment", "Equipment", "UNS level-5 rows + identification fields"),
|
||||
new SectionDef("Tag", "Tags", "Per-device tag definitions + poll-group binding"),
|
||||
new SectionDef("UnsLine", "UNS structure", "Site / Area / Line hierarchy (proc-extension pending)"),
|
||||
new SectionDef("NodeAcl", "ACLs", "LDAP-group → node-scope permission grants (proc-extension pending)"),
|
||||
};
|
||||
|
||||
private List<DiffRow>? _rows;
|
||||
private string _fromLabel = "(empty)";
|
||||
private string? _error;
|
||||
private int _sectionsWithChanges;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
@@ -67,7 +75,13 @@ else
|
||||
var from = all.FirstOrDefault(g => g.Status == GenerationStatus.Published);
|
||||
_fromLabel = from is null ? "(empty)" : $"gen {from.GenerationId}";
|
||||
_rows = await GenerationSvc.ComputeDiffAsync(from?.GenerationId ?? 0, GenerationId, CancellationToken.None);
|
||||
_sectionsWithChanges = Sections.Count(s => _rows.Any(r => r.TableName == s.TableName));
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private IReadOnlyList<DiffRow> RowsFor(string tableName) =>
|
||||
_rows?.Where(r => r.TableName == tableName).ToList() ?? [];
|
||||
|
||||
private sealed record SectionDef(string TableName, string Title, string Description);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject UnsService UnsSvc
|
||||
|
||||
<div class="alert alert-info small mb-3">
|
||||
Drag any line in the <strong>UNS Lines</strong> table onto an area row in <strong>UNS Areas</strong>
|
||||
to re-parent it. A preview modal shows the impact (equipment re-home count) + lets you confirm
|
||||
or cancel. If another operator modifies the draft while you're confirming, you'll see a 409
|
||||
refresh-required modal instead of clobbering their work.
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
@@ -14,11 +21,20 @@
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>AreaId</th><th>Name</th></tr></thead>
|
||||
<thead><tr><th>AreaId</th><th>Name</th><th class="small text-muted">(drop target)</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var a in _areas)
|
||||
{
|
||||
<tr><td><code>@a.UnsAreaId</code></td><td>@a.Name</td></tr>
|
||||
<tr class="@(_hoverAreaId == a.UnsAreaId ? "table-primary" : "")"
|
||||
@ondragover="e => OnAreaDragOver(e, a.UnsAreaId)"
|
||||
@ondragover:preventDefault
|
||||
@ondragleave="() => _hoverAreaId = null"
|
||||
@ondrop="() => OnLineDroppedAsync(a.UnsAreaId)"
|
||||
@ondrop:preventDefault>
|
||||
<td><code>@a.UnsAreaId</code></td>
|
||||
<td>@a.Name</td>
|
||||
<td class="small text-muted">drop here</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -35,6 +51,7 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h4>UNS Lines</h4>
|
||||
@@ -50,7 +67,14 @@
|
||||
<tbody>
|
||||
@foreach (var l in _lines)
|
||||
{
|
||||
<tr><td><code>@l.UnsLineId</code></td><td><code>@l.UnsAreaId</code></td><td>@l.Name</td></tr>
|
||||
<tr draggable="true"
|
||||
@ondragstart="() => _dragLineId = l.UnsLineId"
|
||||
@ondragend="() => { _dragLineId = null; _hoverAreaId = null; }"
|
||||
style="cursor: grab;">
|
||||
<td><code>@l.UnsLineId</code></td>
|
||||
<td><code>@l.UnsAreaId</code></td>
|
||||
<td>@l.Name</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -75,6 +99,64 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Preview / confirm modal for a pending drag-drop move *@
|
||||
@if (_pendingPreview is not null)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm UNS move</h5>
|
||||
<button type="button" class="btn-close" @onclick="CancelMove"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>@_pendingPreview.HumanReadableSummary</p>
|
||||
<p class="text-muted small">
|
||||
Equipment re-homed: <strong>@_pendingPreview.AffectedEquipmentCount</strong>.
|
||||
Tags re-parented: <strong>@_pendingPreview.AffectedTagCount</strong>.
|
||||
</p>
|
||||
@if (_pendingPreview.CascadeWarnings.Count > 0)
|
||||
{
|
||||
<div class="alert alert-warning small mb-0">
|
||||
<ul class="mb-0">
|
||||
@foreach (var w in _pendingPreview.CascadeWarnings) { <li>@w</li> }
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @onclick="CancelMove">Cancel</button>
|
||||
<button class="btn btn-primary" @onclick="ConfirmMoveAsync" disabled="@_committing">Confirm move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* 409 concurrent-edit modal — another operator changed the draft between preview + commit *@
|
||||
@if (_conflictMessage is not null)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content border-danger">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title">Draft changed — refresh required</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>@_conflictMessage</p>
|
||||
<p class="small text-muted">
|
||||
Concurrency guard per DraftRevisionToken prevented overwriting the peer
|
||||
operator's edit. Reload the tab + redo the move on the current draft state.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-primary" @onclick="ReloadAfterConflict">Reload draft</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
@@ -87,6 +169,13 @@
|
||||
private string _newLineName = string.Empty;
|
||||
private string _newLineAreaId = string.Empty;
|
||||
|
||||
private string? _dragLineId;
|
||||
private string? _hoverAreaId;
|
||||
private UnsImpactPreview? _pendingPreview;
|
||||
private UnsMoveOperation? _pendingMove;
|
||||
private bool _committing;
|
||||
private string? _conflictMessage;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
@@ -112,4 +201,72 @@
|
||||
_showLineForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
|
||||
private void OnAreaDragOver(DragEventArgs _, string areaId) => _hoverAreaId = areaId;
|
||||
|
||||
private async Task OnLineDroppedAsync(string targetAreaId)
|
||||
{
|
||||
var lineId = _dragLineId;
|
||||
_hoverAreaId = null;
|
||||
_dragLineId = null;
|
||||
if (string.IsNullOrWhiteSpace(lineId)) return;
|
||||
|
||||
var line = _lines?.FirstOrDefault(l => l.UnsLineId == lineId);
|
||||
if (line is null || line.UnsAreaId == targetAreaId) return;
|
||||
|
||||
var snapshot = await UnsSvc.LoadSnapshotAsync(GenerationId, CancellationToken.None);
|
||||
var move = new UnsMoveOperation(
|
||||
Kind: UnsMoveKind.LineMove,
|
||||
SourceClusterId: ClusterId,
|
||||
TargetClusterId: ClusterId,
|
||||
SourceLineId: lineId,
|
||||
TargetAreaId: targetAreaId);
|
||||
try
|
||||
{
|
||||
_pendingPreview = UnsImpactAnalyzer.Analyze(snapshot, move);
|
||||
_pendingMove = move;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_conflictMessage = ex.Message; // CrossCluster or validation failure surfaces here
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelMove()
|
||||
{
|
||||
_pendingPreview = null;
|
||||
_pendingMove = null;
|
||||
}
|
||||
|
||||
private async Task ConfirmMoveAsync()
|
||||
{
|
||||
if (_pendingPreview is null || _pendingMove is null) return;
|
||||
_committing = true;
|
||||
try
|
||||
{
|
||||
await UnsSvc.MoveLineAsync(
|
||||
GenerationId,
|
||||
_pendingPreview.RevisionToken,
|
||||
_pendingMove.SourceLineId!,
|
||||
_pendingMove.TargetAreaId!,
|
||||
CancellationToken.None);
|
||||
|
||||
_pendingPreview = null;
|
||||
_pendingMove = null;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (DraftRevisionConflictException ex)
|
||||
{
|
||||
_pendingPreview = null;
|
||||
_pendingMove = null;
|
||||
_conflictMessage = ex.Message;
|
||||
}
|
||||
finally { _committing = false; }
|
||||
}
|
||||
|
||||
private async Task ReloadAfterConflict()
|
||||
{
|
||||
_conflictMessage = null;
|
||||
await ReloadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
@@ -152,14 +153,37 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var row in batch.Rows.Where(r => r.IsAccepted))
|
||||
// Snapshot active reservations that overlap this batch's ZTag + SAPID set — one
|
||||
// round-trip instead of N. Released rows (ReleasedAt IS NOT NULL) are ignored so
|
||||
// an explicitly-released value can be reused.
|
||||
var accepted = batch.Rows.Where(r => r.IsAccepted).ToList();
|
||||
var zTags = accepted.Where(r => !string.IsNullOrWhiteSpace(r.ZTag))
|
||||
.Select(r => r.ZTag).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var sapIds = accepted.Where(r => !string.IsNullOrWhiteSpace(r.SAPID))
|
||||
.Select(r => r.SAPID).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
var existingReservations = await db.ExternalIdReservations
|
||||
.Where(r => r.ReleasedAt == null &&
|
||||
((r.Kind == ReservationKind.ZTag && zTags.Contains(r.Value)) ||
|
||||
(r.Kind == ReservationKind.SAPID && sapIds.Contains(r.Value))))
|
||||
.ToListAsync(ct).ConfigureAwait(false);
|
||||
var resByKey = existingReservations.ToDictionary(
|
||||
r => (r.Kind, r.Value.ToLowerInvariant()),
|
||||
r => r);
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var firstPublishedBy = batch.CreatedBy;
|
||||
|
||||
foreach (var row in accepted)
|
||||
{
|
||||
var equipmentUuid = Guid.TryParse(row.EquipmentUuid, out var u) ? u : Guid.NewGuid();
|
||||
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(),
|
||||
GenerationId = generationId,
|
||||
EquipmentId = row.EquipmentId,
|
||||
EquipmentUuid = Guid.TryParse(row.EquipmentUuid, out var u) ? u : Guid.NewGuid(),
|
||||
EquipmentUuid = equipmentUuid,
|
||||
DriverInstanceId = driverInstanceIdForRows,
|
||||
UnsLineId = unsLineIdForRows,
|
||||
Name = row.Name,
|
||||
@@ -176,10 +200,25 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
|
||||
ManufacturerUri = row.ManufacturerUri,
|
||||
DeviceManualUri = row.DeviceManualUri,
|
||||
});
|
||||
|
||||
MergeReservation(row.ZTag, ReservationKind.ZTag, equipmentUuid, batch.ClusterId,
|
||||
firstPublishedBy, nowUtc, resByKey);
|
||||
MergeReservation(row.SAPID, ReservationKind.SAPID, equipmentUuid, batch.ClusterId,
|
||||
firstPublishedBy, nowUtc, resByKey);
|
||||
}
|
||||
|
||||
batch.FinalisedAtUtc = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
batch.FinalisedAtUtc = nowUtc;
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsReservationUniquenessViolation(ex))
|
||||
{
|
||||
throw new ExternalIdReservationConflictException(
|
||||
"Finalise rejected: one or more ZTag/SAPID values were reserved by another operator " +
|
||||
"between batch preview and commit. Inspect active reservations + retry after resolving the conflict.",
|
||||
ex);
|
||||
}
|
||||
if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
@@ -193,6 +232,71 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge one external-ID reservation for an equipment row. Three outcomes:
|
||||
/// (1) value is empty → skip; (2) reservation exists for same <paramref name="equipmentUuid"/>
|
||||
/// → bump <c>LastPublishedAt</c>; (3) reservation exists for a different EquipmentUuid
|
||||
/// → throw <see cref="ExternalIdReservationConflictException"/> with the conflicting UUID
|
||||
/// so the caller sees which equipment already owns the value; (4) no reservation → create new.
|
||||
/// </summary>
|
||||
private void MergeReservation(
|
||||
string? value,
|
||||
ReservationKind kind,
|
||||
Guid equipmentUuid,
|
||||
string clusterId,
|
||||
string firstPublishedBy,
|
||||
DateTime nowUtc,
|
||||
Dictionary<(ReservationKind, string), ExternalIdReservation> cache)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return;
|
||||
|
||||
var key = (kind, value.ToLowerInvariant());
|
||||
if (cache.TryGetValue(key, out var existing))
|
||||
{
|
||||
if (existing.EquipmentUuid != equipmentUuid)
|
||||
throw new ExternalIdReservationConflictException(
|
||||
$"{kind} '{value}' is already reserved by EquipmentUuid {existing.EquipmentUuid} " +
|
||||
$"(first published {existing.FirstPublishedAt:u} on cluster '{existing.ClusterId}'). " +
|
||||
$"Refusing to re-assign to {equipmentUuid}.");
|
||||
|
||||
existing.LastPublishedAt = nowUtc;
|
||||
return;
|
||||
}
|
||||
|
||||
var fresh = new ExternalIdReservation
|
||||
{
|
||||
ReservationId = Guid.NewGuid(),
|
||||
Kind = kind,
|
||||
Value = value,
|
||||
EquipmentUuid = equipmentUuid,
|
||||
ClusterId = clusterId,
|
||||
FirstPublishedAt = nowUtc,
|
||||
FirstPublishedBy = firstPublishedBy,
|
||||
LastPublishedAt = nowUtc,
|
||||
};
|
||||
db.ExternalIdReservations.Add(fresh);
|
||||
cache[key] = fresh;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the <see cref="DbUpdateException"/> root-cause was the filtered-unique
|
||||
/// index <c>UX_ExternalIdReservation_KindValue_Active</c> — i.e. another transaction
|
||||
/// won the race between our cache-load + commit. SQL Server surfaces this as 2601 / 2627.
|
||||
/// </summary>
|
||||
private static bool IsReservationUniquenessViolation(DbUpdateException ex)
|
||||
{
|
||||
for (Exception? inner = ex; inner is not null; inner = inner.InnerException)
|
||||
{
|
||||
if (inner is Microsoft.Data.SqlClient.SqlException sql &&
|
||||
(sql.Number == 2601 || sql.Number == 2627) &&
|
||||
sql.Message.Contains("UX_ExternalIdReservation_KindValue_Active", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>List batches created by the given user. Finalised batches are archived; include them on demand.</summary>
|
||||
public async Task<IReadOnlyList<EquipmentImportBatch>> ListByUserAsync(string createdBy, bool includeFinalised, CancellationToken ct)
|
||||
{
|
||||
@@ -205,3 +309,16 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
|
||||
|
||||
public sealed class ImportBatchNotFoundException(string message) : Exception(message);
|
||||
public sealed class ImportBatchAlreadyFinalisedException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a <c>FinaliseBatchAsync</c> call detects that one of its ZTag/SAPID values is
|
||||
/// already reserved by a different EquipmentUuid — either from a prior published generation
|
||||
/// or a concurrent finalise that won the race. The operator sees the message + the conflicting
|
||||
/// equipment ownership so they can resolve the conflict (pick a new ZTag, release the existing
|
||||
/// reservation via <c>sp_ReleaseExternalIdReservation</c>, etc.) and retry the finalise.
|
||||
/// </summary>
|
||||
public sealed class ExternalIdReservationConflictException : Exception
|
||||
{
|
||||
public ExternalIdReservationConflictException(string message) : base(message) { }
|
||||
public ExternalIdReservationConflictException(string message, Exception inner) : base(message, inner) { }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
@@ -47,4 +49,132 @@ public sealed class UnsService(OtOpcUaConfigDbContext db)
|
||||
await db.SaveChangesAsync(ct);
|
||||
return line;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the full UNS tree snapshot for the analyzer. Walks areas + lines in the draft
|
||||
/// and counts equipment + tags per line. Returns the snapshot plus a deterministic
|
||||
/// revision token computed by SHA-256'ing the sorted (kind, id, parent, name) tuples —
|
||||
/// stable across processes + changes whenever any row is added / modified / deleted.
|
||||
/// </summary>
|
||||
public async Task<UnsTreeSnapshot> LoadSnapshotAsync(long generationId, CancellationToken ct)
|
||||
{
|
||||
var areas = await db.UnsAreas.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.UnsAreaId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var lines = await db.UnsLines.AsNoTracking()
|
||||
.Where(l => l.GenerationId == generationId)
|
||||
.OrderBy(l => l.UnsLineId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var equipmentCounts = await db.Equipment.AsNoTracking()
|
||||
.Where(e => e.GenerationId == generationId)
|
||||
.GroupBy(e => e.UnsLineId)
|
||||
.Select(g => new { LineId = g.Key, Count = g.Count() })
|
||||
.ToListAsync(ct);
|
||||
var equipmentByLine = equipmentCounts.ToDictionary(x => x.LineId, x => x.Count, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var lineSummaries = lines.Select(l =>
|
||||
new UnsLineSummary(
|
||||
LineId: l.UnsLineId,
|
||||
Name: l.Name,
|
||||
EquipmentCount: equipmentByLine.GetValueOrDefault(l.UnsLineId),
|
||||
TagCount: 0)).ToList();
|
||||
|
||||
var areaSummaries = areas.Select(a =>
|
||||
new UnsAreaSummary(
|
||||
AreaId: a.UnsAreaId,
|
||||
Name: a.Name,
|
||||
LineIds: lines.Where(l => string.Equals(l.UnsAreaId, a.UnsAreaId, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(l => l.UnsLineId).ToList())).ToList();
|
||||
|
||||
return new UnsTreeSnapshot
|
||||
{
|
||||
DraftGenerationId = generationId,
|
||||
RevisionToken = ComputeRevisionToken(areas, lines),
|
||||
Areas = areaSummaries,
|
||||
Lines = lineSummaries,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomic re-parent of a line to a new area inside the same draft. The caller must pass
|
||||
/// the revision token it observed at preview time — a mismatch raises
|
||||
/// <see cref="DraftRevisionConflictException"/> so the UI can show the 409 concurrent-edit
|
||||
/// modal instead of silently overwriting a peer's work.
|
||||
/// </summary>
|
||||
public async Task MoveLineAsync(
|
||||
long generationId,
|
||||
DraftRevisionToken expected,
|
||||
string lineId,
|
||||
string targetAreaId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expected);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(lineId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(targetAreaId);
|
||||
|
||||
var supportsTx = db.Database.IsRelational();
|
||||
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null;
|
||||
if (supportsTx) tx = await db.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var areas = await db.UnsAreas
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.UnsAreaId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var lines = await db.UnsLines
|
||||
.Where(l => l.GenerationId == generationId)
|
||||
.OrderBy(l => l.UnsLineId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var current = ComputeRevisionToken(areas, lines);
|
||||
if (!current.Matches(expected))
|
||||
throw new DraftRevisionConflictException(
|
||||
$"Draft {generationId} changed since preview. Expected revision {expected.Value}, saw {current.Value}. " +
|
||||
"Refresh + redo the move.");
|
||||
|
||||
var line = lines.FirstOrDefault(l => string.Equals(l.UnsLineId, lineId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidOperationException($"Line '{lineId}' not found in draft {generationId}.");
|
||||
|
||||
if (!areas.Any(a => string.Equals(a.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException($"Target area '{targetAreaId}' not found in draft {generationId}.");
|
||||
|
||||
if (string.Equals(line.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase))
|
||||
return; // no-op drop — same area
|
||||
|
||||
line.UnsAreaId = targetAreaId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (tx is not null) await tx.RollbackAsync(ct).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (tx is not null) await tx.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static DraftRevisionToken ComputeRevisionToken(IReadOnlyList<UnsArea> areas, IReadOnlyList<UnsLine> lines)
|
||||
{
|
||||
var sb = new StringBuilder(capacity: 256 + (areas.Count + lines.Count) * 80);
|
||||
foreach (var a in areas.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal))
|
||||
sb.Append("A:").Append(a.UnsAreaId).Append('|').Append(a.Name).Append('|').Append(a.Notes ?? "").Append(';');
|
||||
foreach (var l in lines.OrderBy(l => l.UnsLineId, StringComparer.Ordinal))
|
||||
sb.Append("L:").Append(l.UnsLineId).Append('|').Append(l.UnsAreaId).Append('|').Append(l.Name).Append('|').Append(l.Notes ?? "").Append(';');
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
return new DraftRevisionToken(Convert.ToHexStringLower(hash)[..16]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Thrown when a UNS move's expected revision token no longer matches the live draft
|
||||
/// — another operator mutated the draft between preview + commit. Caller surfaces a 409-style
|
||||
/// "refresh required" modal in the Admin UI.</summary>
|
||||
public sealed class DraftRevisionConflictException(string message) : Exception(message);
|
||||
|
||||
129
src/ZB.MOM.WW.OtOpcUa.Core/Resilience/AlarmSurfaceInvoker.cs
Normal file
129
src/ZB.MOM.WW.OtOpcUa.Core/Resilience/AlarmSurfaceInvoker.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the three mutating surfaces of <see cref="IAlarmSource"/>
|
||||
/// (<see cref="IAlarmSource.SubscribeAlarmsAsync"/>, <see cref="IAlarmSource.UnsubscribeAlarmsAsync"/>,
|
||||
/// <see cref="IAlarmSource.AcknowledgeAsync"/>) through <see cref="CapabilityInvoker"/> so the
|
||||
/// Phase 6.1 resilience pipeline runs — retry semantics match
|
||||
/// <see cref="DriverCapability.AlarmSubscribe"/> (retries by default) and
|
||||
/// <see cref="DriverCapability.AlarmAcknowledge"/> (does NOT retry per decision #143).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Multi-host dispatch: when the driver implements <see cref="IPerCallHostResolver"/>,
|
||||
/// each source-node-id is resolved individually + grouped by host so a dead PLC inside a
|
||||
/// multi-device driver doesn't poison the sibling hosts' breakers. Drivers with a single
|
||||
/// host fall back to <see cref="IDriver.DriverInstanceId"/> as the single-host key.</para>
|
||||
///
|
||||
/// <para>Why this lives here + not on <see cref="CapabilityInvoker"/>: alarm surfaces have a
|
||||
/// handle-returning shape (SubscribeAlarmsAsync returns <see cref="IAlarmSubscriptionHandle"/>)
|
||||
/// + a per-call fan-out (AcknowledgeAsync gets a batch of
|
||||
/// <see cref="AlarmAcknowledgeRequest"/>s that may span multiple hosts). Keeping the fan-out
|
||||
/// logic here keeps the invoker's execute-overloads narrow.</para>
|
||||
/// </remarks>
|
||||
public sealed class AlarmSurfaceInvoker
|
||||
{
|
||||
private readonly CapabilityInvoker _invoker;
|
||||
private readonly IAlarmSource _alarmSource;
|
||||
private readonly IPerCallHostResolver? _hostResolver;
|
||||
private readonly string _defaultHost;
|
||||
|
||||
public AlarmSurfaceInvoker(
|
||||
CapabilityInvoker invoker,
|
||||
IAlarmSource alarmSource,
|
||||
string defaultHost,
|
||||
IPerCallHostResolver? hostResolver = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(invoker);
|
||||
ArgumentNullException.ThrowIfNull(alarmSource);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(defaultHost);
|
||||
|
||||
_invoker = invoker;
|
||||
_alarmSource = alarmSource;
|
||||
_defaultHost = defaultHost;
|
||||
_hostResolver = hostResolver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to alarm events for a set of source node ids, fanning out by resolved host
|
||||
/// so per-host breakers / bulkheads apply. Returns one handle per host — callers that
|
||||
/// don't care about per-host separation may concatenate them.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<IAlarmSubscriptionHandle>> SubscribeAsync(
|
||||
IReadOnlyList<string> sourceNodeIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sourceNodeIds);
|
||||
if (sourceNodeIds.Count == 0) return [];
|
||||
|
||||
var byHost = GroupByHost(sourceNodeIds);
|
||||
var handles = new List<IAlarmSubscriptionHandle>(byHost.Count);
|
||||
foreach (var (host, ids) in byHost)
|
||||
{
|
||||
var handle = await _invoker.ExecuteAsync(
|
||||
DriverCapability.AlarmSubscribe,
|
||||
host,
|
||||
async ct => await _alarmSource.SubscribeAlarmsAsync(ids, ct).ConfigureAwait(false),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
handles.Add(handle);
|
||||
}
|
||||
return handles;
|
||||
}
|
||||
|
||||
/// <summary>Cancel an alarm subscription. Routes through the AlarmSubscribe pipeline for parity.</summary>
|
||||
public ValueTask UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(handle);
|
||||
return _invoker.ExecuteAsync(
|
||||
DriverCapability.AlarmSubscribe,
|
||||
_defaultHost,
|
||||
async ct => await _alarmSource.UnsubscribeAlarmsAsync(handle, ct).ConfigureAwait(false),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge alarms. Fans out by resolved host; each host's batch runs through the
|
||||
/// AlarmAcknowledge pipeline (no-retry per decision #143 — an alarm-ack is not idempotent
|
||||
/// at the plant-floor acknowledgement level even if the OPC UA spec permits re-issue).
|
||||
/// </summary>
|
||||
public async Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(acknowledgements);
|
||||
if (acknowledgements.Count == 0) return;
|
||||
|
||||
var byHost = _hostResolver is null
|
||||
? new Dictionary<string, List<AlarmAcknowledgeRequest>> { [_defaultHost] = acknowledgements.ToList() }
|
||||
: acknowledgements
|
||||
.GroupBy(a => _hostResolver.ResolveHost(a.SourceNodeId))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
foreach (var (host, batch) in byHost)
|
||||
{
|
||||
var batchSnapshot = batch; // capture for the lambda
|
||||
await _invoker.ExecuteAsync(
|
||||
DriverCapability.AlarmAcknowledge,
|
||||
host,
|
||||
async ct => await _alarmSource.AcknowledgeAsync(batchSnapshot, ct).ConfigureAwait(false),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, List<string>> GroupByHost(IReadOnlyList<string> sourceNodeIds)
|
||||
{
|
||||
if (_hostResolver is null)
|
||||
return new Dictionary<string, List<string>> { [_defaultHost] = sourceNodeIds.ToList() };
|
||||
|
||||
var result = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
foreach (var id in sourceNodeIds)
|
||||
{
|
||||
var host = _hostResolver.ResolveHost(id);
|
||||
if (!result.TryGetValue(host, out var list))
|
||||
result[host] = list = new List<string>();
|
||||
list.Add(id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,13 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
// Unique SAPID per row — FinaliseBatch reserves ZTag + SAPID via filtered-unique index, so
|
||||
// two rows sharing a SAPID under different EquipmentUuids collide as intended.
|
||||
private static EquipmentCsvRow Row(string zTag, string name = "eq-1") => new()
|
||||
{
|
||||
ZTag = zTag,
|
||||
MachineCode = "mc",
|
||||
SAPID = "sap",
|
||||
SAPID = $"sap-{zTag}",
|
||||
EquipmentId = "eq-id",
|
||||
EquipmentUuid = Guid.NewGuid().ToString(),
|
||||
Name = name,
|
||||
@@ -162,4 +164,93 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
||||
await _svc.DropBatchAsync(Guid.NewGuid(), CancellationToken.None);
|
||||
// no throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinaliseBatch_Creates_ExternalIdReservations_ForZTagAndSAPID()
|
||||
{
|
||||
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
await _svc.StageRowsAsync(batch.Id, [Row("z-new-1")], [], CancellationToken.None);
|
||||
|
||||
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
|
||||
|
||||
var active = await _db.ExternalIdReservations.AsNoTracking()
|
||||
.Where(r => r.ReleasedAt == null)
|
||||
.ToListAsync();
|
||||
active.Count.ShouldBe(2);
|
||||
active.ShouldContain(r => r.Kind == ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.ZTag && r.Value == "z-new-1");
|
||||
active.ShouldContain(r => r.Kind == ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.SAPID && r.Value == "sap-z-new-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinaliseBatch_SameEquipmentUuid_ReusesExistingReservation()
|
||||
{
|
||||
var batch1 = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
var sharedUuid = Guid.NewGuid();
|
||||
var row = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-shared", MachineCode = "mc", SAPID = "sap-shared",
|
||||
EquipmentId = "eq-1", EquipmentUuid = sharedUuid.ToString(),
|
||||
Name = "eq-1", UnsAreaName = "a", UnsLineName = "l",
|
||||
};
|
||||
await _svc.StageRowsAsync(batch1.Id, [row], [], CancellationToken.None);
|
||||
await _svc.FinaliseBatchAsync(batch1.Id, 1, "drv", "line", CancellationToken.None);
|
||||
|
||||
var countAfterFirst = _db.ExternalIdReservations.Count(r => r.ReleasedAt == null);
|
||||
|
||||
// Second finalise with same EquipmentUuid + same ZTag — should NOT create a duplicate.
|
||||
var batch2 = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
await _svc.StageRowsAsync(batch2.Id, [row], [], CancellationToken.None);
|
||||
await _svc.FinaliseBatchAsync(batch2.Id, 2, "drv", "line", CancellationToken.None);
|
||||
|
||||
_db.ExternalIdReservations.Count(r => r.ReleasedAt == null).ShouldBe(countAfterFirst);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinaliseBatch_DifferentEquipmentUuid_SameZTag_Throws_Conflict()
|
||||
{
|
||||
var batchA = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
var rowA = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-collide", MachineCode = "mc-a", SAPID = "sap-a",
|
||||
EquipmentId = "eq-a", EquipmentUuid = Guid.NewGuid().ToString(),
|
||||
Name = "a", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
await _svc.StageRowsAsync(batchA.Id, [rowA], [], CancellationToken.None);
|
||||
await _svc.FinaliseBatchAsync(batchA.Id, 1, "drv", "line", CancellationToken.None);
|
||||
|
||||
var batchB = await _svc.CreateBatchAsync("c1", "bob", CancellationToken.None);
|
||||
var rowB = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-collide", MachineCode = "mc-b", SAPID = "sap-b", // same ZTag, different EquipmentUuid
|
||||
EquipmentId = "eq-b", EquipmentUuid = Guid.NewGuid().ToString(),
|
||||
Name = "b", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
await _svc.StageRowsAsync(batchB.Id, [rowB], [], CancellationToken.None);
|
||||
|
||||
var ex = await Should.ThrowAsync<ExternalIdReservationConflictException>(() =>
|
||||
_svc.FinaliseBatchAsync(batchB.Id, 2, "drv", "line", CancellationToken.None));
|
||||
ex.Message.ShouldContain("z-collide");
|
||||
|
||||
// Second finalise must have rolled back — no partial Equipment row for batch B.
|
||||
var equipmentB = await _db.Equipment.AsNoTracking()
|
||||
.Where(e => e.EquipmentId == "eq-b")
|
||||
.ToListAsync();
|
||||
equipmentB.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinaliseBatch_EmptyZTagAndSAPID_SkipsReservation()
|
||||
{
|
||||
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
var row = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "", MachineCode = "mc", SAPID = "",
|
||||
EquipmentId = "eq-nil", EquipmentUuid = Guid.NewGuid().ToString(),
|
||||
Name = "nil", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
await _svc.StageRowsAsync(batch.Id, [row], [], CancellationToken.None);
|
||||
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
|
||||
|
||||
_db.ExternalIdReservations.Count().ShouldBe(0);
|
||||
}
|
||||
}
|
||||
|
||||
130
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsServiceMoveTests.cs
Normal file
130
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsServiceMoveTests.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnsServiceMoveTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LoadSnapshotAsync_ReturnsAllAreasAndLines_WithEquipmentCounts()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" },
|
||||
lines: new[] { ("line-a", "area-1"), ("line-b", "area-1"), ("line-c", "area-2") },
|
||||
equipmentLines: new[] { "line-a", "line-a", "line-b" });
|
||||
var svc = new UnsService(ctx);
|
||||
|
||||
var snap = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||
|
||||
snap.Areas.Count.ShouldBe(2);
|
||||
snap.Lines.Count.ShouldBe(3);
|
||||
snap.FindLine("line-a")!.EquipmentCount.ShouldBe(2);
|
||||
snap.FindLine("line-b")!.EquipmentCount.ShouldBe(1);
|
||||
snap.FindLine("line-c")!.EquipmentCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSnapshotAsync_RevisionToken_IsStable_BetweenTwoReads()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx, draftId: 1, areas: new[] { "area-1" }, lines: new[] { ("line-a", "area-1") });
|
||||
var svc = new UnsService(ctx);
|
||||
|
||||
var first = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||
var second = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||
|
||||
second.RevisionToken.Matches(first.RevisionToken).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSnapshotAsync_RevisionToken_Changes_When_LineAdded()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx, draftId: 1, areas: new[] { "area-1" }, lines: new[] { ("line-a", "area-1") });
|
||||
var svc = new UnsService(ctx);
|
||||
|
||||
var before = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||
await svc.AddLineAsync(1, "area-1", "new-line", null, CancellationToken.None);
|
||||
var after = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||
|
||||
after.RevisionToken.Matches(before.RevisionToken).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveLineAsync_WithMatchingToken_Reparents_Line()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" },
|
||||
lines: new[] { ("line-a", "area-1") });
|
||||
var svc = new UnsService(ctx);
|
||||
|
||||
var snap = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||
await svc.MoveLineAsync(1, snap.RevisionToken, "line-a", "area-2", CancellationToken.None);
|
||||
|
||||
var moved = await ctx.UnsLines.AsNoTracking().FirstAsync(l => l.UnsLineId == "line-a");
|
||||
moved.UnsAreaId.ShouldBe("area-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveLineAsync_WithStaleToken_Throws_DraftRevisionConflict()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" },
|
||||
lines: new[] { ("line-a", "area-1") });
|
||||
var svc = new UnsService(ctx);
|
||||
|
||||
// Simulate a peer operator's concurrent edit between our preview + commit.
|
||||
var stale = new DraftRevisionToken("0000000000000000");
|
||||
|
||||
await Should.ThrowAsync<DraftRevisionConflictException>(() =>
|
||||
svc.MoveLineAsync(1, stale, "line-a", "area-2", CancellationToken.None));
|
||||
|
||||
var row = await ctx.UnsLines.AsNoTracking().FirstAsync(l => l.UnsLineId == "line-a");
|
||||
row.UnsAreaId.ShouldBe("area-1");
|
||||
}
|
||||
|
||||
private static void Seed(OtOpcUaConfigDbContext ctx, long draftId,
|
||||
IEnumerable<string> areas,
|
||||
IEnumerable<(string line, string area)> lines,
|
||||
IEnumerable<string>? equipmentLines = null)
|
||||
{
|
||||
foreach (var a in areas)
|
||||
{
|
||||
ctx.UnsAreas.Add(new UnsArea
|
||||
{
|
||||
GenerationId = draftId, UnsAreaId = a, ClusterId = "c1", Name = a,
|
||||
});
|
||||
}
|
||||
foreach (var (line, area) in lines)
|
||||
{
|
||||
ctx.UnsLines.Add(new UnsLine
|
||||
{
|
||||
GenerationId = draftId, UnsLineId = line, UnsAreaId = area, Name = line,
|
||||
});
|
||||
}
|
||||
foreach (var lineId in equipmentLines ?? [])
|
||||
{
|
||||
ctx.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = draftId,
|
||||
EquipmentId = $"EQ-{Guid.NewGuid():N}"[..15],
|
||||
EquipmentUuid = Guid.NewGuid(), DriverInstanceId = "drv",
|
||||
UnsLineId = lineId, Name = "x", MachineCode = "m",
|
||||
});
|
||||
}
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
private static OtOpcUaConfigDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(opts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AlarmSurfaceInvokerTests
|
||||
{
|
||||
private static readonly DriverResilienceOptions TierAOptions = new() { Tier = DriverTier.A };
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_EmptyList_ReturnsEmpty_WithoutDriverCall()
|
||||
{
|
||||
var driver = new FakeAlarmSource();
|
||||
var surface = NewSurface(driver, defaultHost: "h");
|
||||
|
||||
var handles = await surface.SubscribeAsync([], CancellationToken.None);
|
||||
|
||||
handles.Count.ShouldBe(0);
|
||||
driver.SubscribeCallCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_SingleHost_RoutesThroughDefaultHost()
|
||||
{
|
||||
var driver = new FakeAlarmSource();
|
||||
var surface = NewSurface(driver, defaultHost: "h1");
|
||||
|
||||
var handles = await surface.SubscribeAsync(["src-1", "src-2"], CancellationToken.None);
|
||||
|
||||
handles.Count.ShouldBe(1);
|
||||
driver.SubscribeCallCount.ShouldBe(1);
|
||||
driver.LastSubscribedIds.ShouldBe(["src-1", "src-2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_MultiHost_FansOutByResolvedHost()
|
||||
{
|
||||
var driver = new FakeAlarmSource();
|
||||
var resolver = new StubResolver(new Dictionary<string, string>
|
||||
{
|
||||
["src-1"] = "plc-a",
|
||||
["src-2"] = "plc-b",
|
||||
["src-3"] = "plc-a",
|
||||
});
|
||||
var surface = NewSurface(driver, defaultHost: "default-ignored", resolver: resolver);
|
||||
|
||||
var handles = await surface.SubscribeAsync(["src-1", "src-2", "src-3"], CancellationToken.None);
|
||||
|
||||
handles.Count.ShouldBe(2); // one per distinct host
|
||||
driver.SubscribeCallCount.ShouldBe(2); // one driver call per host
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_DoesNotRetry_OnFailure()
|
||||
{
|
||||
var driver = new FakeAlarmSource { AcknowledgeShouldThrow = true };
|
||||
var surface = NewSurface(driver, defaultHost: "h1");
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
surface.AcknowledgeAsync([new AlarmAcknowledgeRequest("s", "c", null)], CancellationToken.None));
|
||||
|
||||
driver.AcknowledgeCallCount.ShouldBe(1, "AlarmAcknowledge must not retry — decision #143");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_Retries_Transient_Failures()
|
||||
{
|
||||
var driver = new FakeAlarmSource { SubscribeFailuresBeforeSuccess = 2 };
|
||||
var surface = NewSurface(driver, defaultHost: "h1");
|
||||
|
||||
await surface.SubscribeAsync(["src"], CancellationToken.None);
|
||||
|
||||
driver.SubscribeCallCount.ShouldBe(3, "AlarmSubscribe retries by default — decision #143");
|
||||
}
|
||||
|
||||
private static AlarmSurfaceInvoker NewSurface(
|
||||
IAlarmSource driver,
|
||||
string defaultHost,
|
||||
IPerCallHostResolver? resolver = null)
|
||||
{
|
||||
var builder = new DriverResiliencePipelineBuilder();
|
||||
var invoker = new CapabilityInvoker(builder, "drv-1", () => TierAOptions);
|
||||
return new AlarmSurfaceInvoker(invoker, driver, defaultHost, resolver);
|
||||
}
|
||||
|
||||
private sealed class FakeAlarmSource : IAlarmSource
|
||||
{
|
||||
public int SubscribeCallCount { get; private set; }
|
||||
public int AcknowledgeCallCount { get; private set; }
|
||||
public int SubscribeFailuresBeforeSuccess { get; set; }
|
||||
public bool AcknowledgeShouldThrow { get; set; }
|
||||
public IReadOnlyList<string> LastSubscribedIds { get; private set; } = [];
|
||||
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
SubscribeCallCount++;
|
||||
LastSubscribedIds = sourceNodeIds;
|
||||
if (SubscribeCallCount <= SubscribeFailuresBeforeSuccess)
|
||||
throw new InvalidOperationException("transient");
|
||||
return Task.FromResult<IAlarmSubscriptionHandle>(new StubHandle($"h-{SubscribeCallCount}"));
|
||||
}
|
||||
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||
{
|
||||
AcknowledgeCallCount++;
|
||||
if (AcknowledgeShouldThrow) throw new InvalidOperationException("ack boom");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent { add { } remove { } }
|
||||
}
|
||||
|
||||
private sealed record StubHandle(string DiagnosticId) : IAlarmSubscriptionHandle;
|
||||
|
||||
private sealed class StubResolver(Dictionary<string, string> map) : IPerCallHostResolver
|
||||
{
|
||||
public string ResolveHost(string fullReference) => map[fullReference];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user