Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
T
Joseph Doherty 77bc010ba9 v3 Batch 3 WP1: UNS Equipment Tags tab → reference list + raw-tag picker
Replace the Batch-1-stubbed authored-tag flow with a UnsTagReference list on the
equipment page's Tags tab (v3 reference-only equipment): columns are effective name,
computed raw path, inherited datatype/access (read-only), display-name override
(editable), and remove. The retired equipment-authored-tag editor (TagModal.razor)
is deleted; the VirtualTag and ScriptedAlarm flows are untouched.

- "+ Add reference" opens a new AddReferenceModal that reuses RawTree in a new opt-in
  PickerMode (Tag-leaf checkboxes + Device/TagGroup "select all tags below"), scoped
  structurally to the equipment's cluster via LoadReferencePickerRootAsync (a single
  cluster root — cross-cluster tags are unreachable, not merely hidden). Default /raw
  usage is byte-unchanged (PickerMode defaults false).
- UnsTreeService (+ IUnsTreeService) gain the reference mutations:
  LoadReferencesForEquipmentAsync (computes each RawPath via the shared RawPathResolver),
  AddReferencesAsync (cluster-checked, all-or-nothing), RemoveReferenceAsync,
  SetReferenceOverrideAsync, plus the picker helpers LoadReferencePickerRootAsync /
  LoadDescendantTagIdsAsync.
- Consume IEffectiveNameGuard (constructor-injected, no-op fallback when unregistered):
  AddReferences, SetReferenceOverride, and the VirtualTag + ScriptedAlarm create/update
  mutations now call CheckAsync before persisting and surface a collision as the failure.
  WP2 supplies the implementation + DI registration.
- Drop the retired equipment↔driver binding: remove DriverInstanceId from EquipmentInput,
  the ImportEquipmentModal CSV column, the EquipmentPage Details driver select, and the
  dead decision-#122 driver-cluster guard.

Tests: new UnsTreeServiceReferenceTests (add/remove/override, cross-cluster + duplicate
rejection, guard-consumed rejection via a fake guard, RawPath projection, picker helpers);
Equipment/Import test suites rewritten to drop the retired driver-guard cases. AdminUI
suite green (639 passed, 3 skipped); full solution builds 0 errors.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 06:15:51 -04:00

633 lines
43 KiB
C#

using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// A UNS area projected for editing: its operator-editable fields plus the owning cluster and
/// the concurrency token the edit modal must echo back on save.
/// </summary>
/// <param name="UnsAreaId">The area's stable id (read-only on edit).</param>
/// <param name="Name">The area name.</param>
/// <param name="Notes">Optional notes; <c>null</c> when unset.</param>
/// <param name="ClusterId">The owning cluster id (the served-by selection).</param>
/// <param name="RowVersion">The optimistic-concurrency token last read.</param>
public sealed record AreaEditDto(string UnsAreaId, string Name, string? Notes, string ClusterId, byte[] RowVersion);
/// <summary>
/// A UNS line projected for editing: its operator-editable fields plus the parent area and the
/// concurrency token the edit modal must echo back on save.
/// </summary>
/// <param name="UnsLineId">The line's stable id (read-only on edit).</param>
/// <param name="UnsAreaId">The owning area id (the parent-area selection).</param>
/// <param name="Name">The line name.</param>
/// <param name="Notes">Optional notes; <c>null</c> when unset.</param>
/// <param name="RowVersion">The optimistic-concurrency token last read.</param>
public sealed record LineEditDto(string UnsLineId, string UnsAreaId, string Name, string? Notes, byte[] RowVersion);
/// <summary>
/// An equipment projected for editing: its system-generated id, the operator-editable identity and
/// OPC 40010 identification fields, plus the concurrency token the edit modal must echo back on save.
/// </summary>
/// <param name="EquipmentId">The system-generated stable id (read-only — never operator-edited).</param>
/// <param name="Name">UNS level-5 segment name.</param>
/// <param name="MachineCode">Operator colloquial id; unique fleet-wide.</param>
/// <param name="UnsLineId">The owning line id (the UNS-line selection).</param>
/// <param name="DriverInstanceId">Optional driver binding; <c>null</c> when driver-less.</param>
/// <param name="ZTag">Optional ERP equipment id.</param>
/// <param name="SAPID">Optional SAP PM equipment id.</param>
/// <param name="Manufacturer">Optional OPC 40010 manufacturer name.</param>
/// <param name="Model">Optional OPC 40010 model designation.</param>
/// <param name="SerialNumber">Optional OPC 40010 serial number.</param>
/// <param name="HardwareRevision">Optional OPC 40010 hardware revision.</param>
/// <param name="SoftwareRevision">Optional OPC 40010 software revision.</param>
/// <param name="YearOfConstruction">Optional OPC 40010 year of construction.</param>
/// <param name="AssetLocation">Optional OPC 40010 asset location.</param>
/// <param name="ManufacturerUri">Optional OPC 40010 manufacturer URI.</param>
/// <param name="DeviceManualUri">Optional OPC 40010 device-manual URI.</param>
/// <param name="Enabled">Whether the equipment is surfaced in deployments.</param>
/// <param name="RowVersion">The optimistic-concurrency token last read.</param>
public sealed record EquipmentEditDto(string EquipmentId, string Name, string MachineCode, string UnsLineId,
string? DriverInstanceId, string? ZTag, string? SAPID, string? Manufacturer, string? Model, string? SerialNumber,
string? HardwareRevision, string? SoftwareRevision, short? YearOfConstruction, string? AssetLocation,
string? ManufacturerUri, string? DeviceManualUri, bool Enabled, byte[] RowVersion);
/// <summary>
/// An equipment-bound tag projected for editing: its operator-editable fields, the owning equipment
/// (so the host can scope the candidate-driver list and refresh the right node), plus the concurrency
/// token the edit modal must echo back on save. Tree tags are always equipment-bound,
/// so <c>FolderPath</c> never surfaces here.
/// </summary>
/// <param name="TagId">The tag's stable id (read-only on edit).</param>
/// <param name="EquipmentId">The owning equipment id.</param>
/// <param name="Name">The tag name.</param>
/// <param name="DriverInstanceId">The bound driver id.</param>
/// <param name="DataType">The OPC UA built-in type name.</param>
/// <param name="AccessLevel">The tag-level access baseline.</param>
/// <param name="WriteIdempotent">Whether writes are safe to retry.</param>
/// <param name="PollGroupId">Optional poll-group key; <c>null</c> when unset.</param>
/// <param name="TagConfig">The schemaless per-driver-type JSON config.</param>
/// <param name="RowVersion">The optimistic-concurrency token last read.</param>
public sealed record TagEditDto(string TagId, string EquipmentId, string Name, string DriverInstanceId, string DataType,
TagAccessLevel AccessLevel, bool WriteIdempotent, string? PollGroupId, string TagConfig, byte[] RowVersion);
/// <summary>
/// An equipment-bound virtual tag projected for editing: its operator-editable fields, the owning
/// equipment (so the host can refresh the right node), plus the concurrency token the edit modal must
/// echo back on save. Virtual tags are always scoped to an equipment, so the modal
/// never offers an equipment-change control.
/// </summary>
/// <param name="VirtualTagId">The virtual tag's stable id (read-only on edit).</param>
/// <param name="EquipmentId">The owning equipment id.</param>
/// <param name="Name">The virtual-tag name.</param>
/// <param name="DataType">The OPC UA built-in type name.</param>
/// <param name="ScriptId">The bound script id.</param>
/// <param name="ChangeTriggered">Whether the tag re-evaluates on dependency change.</param>
/// <param name="TimerIntervalMs">Optional periodic re-evaluation cadence in ms; <c>null</c> when unset.</param>
/// <param name="Historize">Whether the tag's values are historized.</param>
/// <param name="Enabled">Whether the tag is spawned in deployments.</param>
/// <param name="RowVersion">The optimistic-concurrency token last read.</param>
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 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.
/// Equipment is a leaf in the tree; its tags and virtual tags are accessed via the
/// equipment detail page, not via lazy tree expansion.
/// </summary>
public interface IUnsTreeService
{
/// <summary>
/// Loads the full structural tree. Empty clusters are retained so they remain
/// visible and editable. The returned nodes are detached view-models, safe to
/// hold and mutate UI state on after the underlying context is disposed.
/// </summary>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The enterprise root nodes, each populated down to equipment.</returns>
Task<IReadOnlyList<UnsNode>> LoadStructureAsync(CancellationToken ct = default);
/// <summary>
/// Loads the driver tags bound to a single equipment as flat row projections for the equipment
/// page's Tags tab table, ordered by Name. Each row carries the display columns plus the
/// <c>TagId</c> the table uses to open the edit modal. Reads untracked. Returns an empty list when
/// the equipment has no tags.
/// </summary>
/// <param name="equipmentId">The equipment whose tags to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The equipment's tag rows ordered by Name; empty if it has none.</returns>
Task<IReadOnlyList<EquipmentTagRow>> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Loads the virtual tags scoped to a single equipment as flat row projections for the equipment
/// page's Virtual Tags tab table, ordered by Name. Each row carries the display columns plus the
/// <c>VirtualTagId</c> the table uses to open the edit modal. Reads untracked. Returns an empty list
/// when the equipment has no virtual tags.
/// </summary>
/// <param name="equipmentId">The equipment whose virtual tags to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The equipment's virtual-tag rows ordered by Name; empty if it has none.</returns>
Task<IReadOnlyList<EquipmentVirtualTagRow>> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default);
// ---------------------------------------------------------------------------------------------
// v3 UNS reference-only equipment: the Tags tab lists UnsTagReference rows pointing at raw tags.
// ---------------------------------------------------------------------------------------------
/// <summary>
/// Loads the <see cref="Configuration.Entities.UnsTagReference"/> rows for a single equipment as flat
/// projections for the equipment page's Tags tab, ordered by <c>SortOrder</c> then effective name.
/// Each row carries the effective name (override else the backing raw tag's <c>Name</c>), the backing
/// tag's computed RawPath, its inherited (read-only) datatype/access, the optional display-name
/// override, and the reference's concurrency token. Reads untracked. Returns an empty list when the
/// equipment has no references.
/// </summary>
/// <param name="equipmentId">The equipment whose references to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The equipment's reference rows; empty if it has none.</returns>
Task<IReadOnlyList<EquipmentReferenceRow>> LoadReferencesForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Adds one <see cref="Configuration.Entities.UnsTagReference"/> per supplied raw <c>TagId</c> to an
/// equipment. Every backing tag MUST live in the same cluster as the equipment (cross-cluster
/// references are rejected). Each new reference's effective name (the raw tag's <c>Name</c> — no
/// override on add) must be unique within the equipment across references, VirtualTags, and
/// ScriptedAlarms (enforced via <see cref="IEffectiveNameGuard"/>), and a tag already referenced by
/// the equipment is rejected. The batch is all-or-nothing.
/// </summary>
/// <param name="equipmentId">The referencing equipment.</param>
/// <param name="tagIds">The raw tag ids to reference.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or the first guard/cluster/duplicate failure (nothing is added).</returns>
Task<UnsMutationResult> AddReferencesAsync(string equipmentId, IReadOnlyList<string> tagIds, CancellationToken ct = default);
/// <summary>
/// Removes a UNS tag reference. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.UnsTagReference.RowVersion"/>.
/// </summary>
/// <param name="unsTagReferenceId">The reference to remove.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> RemoveReferenceAsync(string unsTagReferenceId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Sets (or clears) a reference's <c>DisplayNameOverride</c>. A whitespace-only override collapses to
/// <c>null</c> (use the raw name). The resulting effective name (override else the backing raw tag's
/// <c>Name</c>) must be unique within the equipment across references, VirtualTags, and ScriptedAlarms
/// (enforced via <see cref="IEffectiveNameGuard"/>, excluding this reference). Uses last-write-wins
/// optimistic concurrency.
/// </summary>
/// <param name="unsTagReferenceId">The reference to update.</param>
/// <param name="displayNameOverride">The new override, or <c>null</c>/blank to clear it.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a name-collision failure, or a concurrency failure.</returns>
Task<UnsMutationResult> SetReferenceOverrideAsync(string unsTagReferenceId, string? displayNameOverride, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Builds the single cluster-rooted <see cref="RawNode"/> that seeds the "+ Add reference" raw-tag
/// picker for an equipment, structurally scoped to the equipment's own cluster (raw tags in any other
/// cluster are unreachable through this root — the scope is enforced by the query, not merely hidden).
/// The picker's <c>RawTree</c> lazily expands it via <see cref="IRawTreeService.LoadChildrenAsync"/>.
/// Returns <c>null</c> when the equipment cannot be resolved to a cluster.
/// </summary>
/// <param name="equipmentId">The equipment whose cluster scopes the picker.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The cluster root node, or <c>null</c> when the equipment/cluster can't be resolved.</returns>
Task<RawNode?> LoadReferencePickerRootAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Enumerates every raw <c>TagId</c> beneath a Device or TagGroup picker node (the whole subtree), for
/// the picker's "select all tags below" affordance. Returns an empty list for non-container node kinds.
/// </summary>
/// <param name="node">The picker node (Device or TagGroup) to enumerate tags under.</param>
/// <param name="ct">A token to cancel the query.</param>
/// <returns>The tag ids in the node's subtree; empty for unsupported kinds.</returns>
Task<IReadOnlyList<string>> LoadDescendantTagIdsAsync(RawNode node, CancellationToken ct = default);
/// <summary>
/// Loads a single UNS area projected for editing, or <c>null</c> if it no longer exists.
/// Reads untracked and captures the current concurrency token for last-write-wins saves.
/// </summary>
/// <param name="unsAreaId">The area to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The area's edit projection, or <c>null</c> when missing.</returns>
Task<AreaEditDto?> LoadAreaAsync(string unsAreaId, CancellationToken ct = default);
/// <summary>
/// Loads a single UNS line projected for editing, or <c>null</c> if it no longer exists.
/// Reads untracked and captures the current concurrency token for last-write-wins saves.
/// </summary>
/// <param name="unsLineId">The line to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The line's edit projection, or <c>null</c> when missing.</returns>
Task<LineEditDto?> LoadLineAsync(string unsLineId, CancellationToken ct = default);
/// <summary>
/// Loads a single equipment projected for editing, or <c>null</c> if it no longer exists.
/// Reads untracked and captures the current concurrency token for last-write-wins saves.
/// </summary>
/// <param name="equipmentId">The equipment to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The equipment's edit projection, or <c>null</c> when missing.</returns>
Task<EquipmentEditDto?> LoadEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Loads a single equipment-bound tag projected for editing, or <c>null</c> if it no longer exists.
/// Reads untracked and captures the current concurrency token for last-write-wins saves.
/// </summary>
/// <param name="tagId">The tag to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The tag's edit projection, or <c>null</c> when missing.</returns>
Task<TagEditDto?> LoadTagAsync(string tagId, CancellationToken ct = default);
/// <summary>
/// Loads a single equipment-bound virtual tag projected for editing, or <c>null</c> if it no longer
/// exists. Reads untracked and captures the current concurrency token for last-write-wins saves.
/// </summary>
/// <param name="virtualTagId">The virtual tag to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The virtual tag's edit projection, or <c>null</c> when missing.</returns>
Task<VirtualTagEditDto?> LoadVirtualTagAsync(string virtualTagId, CancellationToken ct = default);
/// <summary>
/// Loads every driver instance in a cluster (regardless of namespace kind) so the equipment modal
/// can offer the full cluster driver list for binding. Ordered by <c>DriverInstanceId</c>. Each is
/// projected to a <c>(DriverInstanceId, Display)</c> pair where <c>Display</c> is
/// <c>"{DriverInstanceId} — {Name} ({DriverType})"</c>.
/// </summary>
/// <param name="clusterId">The cluster whose drivers to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The cluster's drivers projected to <c>(DriverInstanceId, Display)</c> pairs.</returns>
Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadDriversForClusterAsync(string clusterId, CancellationToken ct = default);
/// <summary>
/// Loads the UNS-line and driver <c>(Id, Display)</c> option lists the equipment page's Details tab
/// offers, scoped to the cluster that owns the supplied line: every line in that cluster (for the
/// line picker) and that cluster's drivers (reusing <see cref="LoadDriversForClusterAsync"/>).
/// Centralizes the resolution the page would otherwise need the loaded tree to perform. Returns
/// empty lists when <paramref name="lineId"/> is null/empty or cannot be resolved to a cluster.
/// </summary>
/// <param name="lineId">The line whose owning cluster scopes the option lists; null/empty yields empties.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The cluster-scoped line and driver options, or empty lists when the line can't be resolved.</returns>
Task<EquipmentPickContext> LoadEquipmentPickContextAsync(string? lineId, CancellationToken ct = default);
/// <summary>
/// Creates a new UNS area under a cluster. Fails if an area with the same id already exists.
/// Whitespace-only notes are stored as <c>null</c>.
/// </summary>
/// <param name="clusterId">The owning cluster.</param>
/// <param name="unsAreaId">The unique area id to create.</param>
/// <param name="name">The area name.</param>
/// <param name="notes">Optional notes; whitespace collapses to <c>null</c>.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or a duplicate-id failure.</returns>
Task<UnsMutationResult> CreateAreaAsync(string clusterId, string unsAreaId, string name, string? notes, CancellationToken ct = default);
/// <summary>
/// Updates a UNS area's name, notes, and owning cluster. When the cluster changes, the
/// reassignment guard blocks the move if any driver-bound equipment under the
/// area is bound to a driver in a different cluster than the target. Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.UnsArea.RowVersion"/>.
/// </summary>
/// <param name="unsAreaId">The area to update.</param>
/// <param name="name">The new name.</param>
/// <param name="notes">The new notes; whitespace collapses to <c>null</c>.</param>
/// <param name="newClusterId">The target cluster (may equal the current one).</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a cluster-guard failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateAreaAsync(string unsAreaId, string name, string? notes, string newClusterId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes a UNS area. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency; a delete that fails because lines still reference the area surfaces a
/// guidance message.
/// </summary>
/// <param name="unsAreaId">The area to delete.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteAreaAsync(string unsAreaId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Creates a new UNS line under an area. Fails if a line with the same id already exists.
/// Whitespace-only notes are stored as <c>null</c>.
/// </summary>
/// <param name="unsAreaId">The owning area.</param>
/// <param name="unsLineId">The unique line id to create.</param>
/// <param name="name">The line name.</param>
/// <param name="notes">Optional notes; whitespace collapses to <c>null</c>.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or a duplicate-id failure.</returns>
Task<UnsMutationResult> CreateLineAsync(string unsAreaId, string unsLineId, string name, string? notes, CancellationToken ct = default);
/// <summary>
/// Updates a UNS line's owning area, name, and notes. Uses last-write-wins optimistic
/// concurrency on <see cref="Configuration.Entities.UnsLine.RowVersion"/>.
/// </summary>
/// <param name="unsLineId">The line to update.</param>
/// <param name="name">The new name.</param>
/// <param name="notes">The new notes; whitespace collapses to <c>null</c>.</param>
/// <param name="newUnsAreaId">The target parent area.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateLineAsync(string unsLineId, string name, string? notes, string newUnsAreaId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes a UNS line. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency; a delete that fails because equipment still references the line
/// surfaces a guidance message.
/// </summary>
/// <param name="unsLineId">The line to delete.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteLineAsync(string unsLineId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Creates a new equipment under a UNS line. The <c>EquipmentId</c> is system-generated
/// (<c>EQ-</c> + the first 12 hex chars of a fresh <c>EquipmentUuid</c>).
/// Fails if the line is unset or if the MachineCode is already used fleet-wide. Whitespace-only
/// ZTag/SAPID collapse to <c>null</c>. (v3: equipment no longer binds a driver — the reference-only
/// Tags tab points at raw tags instead — so no driver-cluster guard applies.)
/// </summary>
/// <param name="input">The operator-editable equipment fields.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-line failure, or a duplicate-MachineCode 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; 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. (v3: equipment no longer binds a driver, so there is no per-row driver-cluster guard.)
/// </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 (line, name, MachineCode, external ids, and the OPC 40010
/// identification fields). Fails on a MachineCode already used by another row. Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.Equipment.RowVersion"/>. (v3: equipment
/// no longer binds a driver, so there is no driver-cluster guard.)
/// </summary>
/// <param name="equipmentId">The equipment to update.</param>
/// <param name="input">The new operator-editable equipment fields.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a duplicate-MachineCode failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateEquipmentAsync(string equipmentId, EquipmentInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes an equipment. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency; a delete that fails because tags or virtual tags still reference the
/// equipment surfaces a guidance message.
/// </summary>
/// <param name="equipmentId">The equipment to delete.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteEquipmentAsync(string equipmentId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes a server cluster. A missing row is treated as success (already gone). The delete is
/// refuse-if-children: a cluster that still owns nodes, namespaces, UNS areas, or driver
/// instances is left intact and a guidance message is returned (the same convention as
/// Area/Line/Equipment, and matching the existing cluster-settings "Delete cluster" behaviour).
/// The <see cref="Configuration.Entities.ServerCluster"/> entity carries no concurrency token,
/// so — unlike the Area/Line/Equipment deletes — this signature takes no <c>rowVersion</c>.
/// </summary>
/// <param name="clusterId">The cluster to delete.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or a refuse-if-children failure naming what still references the cluster.</returns>
Task<UnsMutationResult> DeleteClusterAsync(string clusterId, CancellationToken ct = default);
/// <summary>
/// Deletes every cluster carrying the given <c>Enterprise</c> label. The enterprise is a tree
/// label (a string value on cluster rows), not a DB entity, so deleting it means deleting all of
/// its clusters. The operation is all-or-nothing: it first checks every matching cluster for
/// children; if <em>any</em> cluster still owns nodes, namespaces, UNS areas, or driver instances
/// the whole delete refuses (naming the first blocking cluster) and nothing is removed — there is
/// never a partial enterprise deletion. An enterprise with no matching clusters is a no-op success.
/// </summary>
/// <param name="enterpriseName">The <c>Enterprise</c> label whose clusters to delete.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or a refuse-if-children failure naming the blocking cluster.</returns>
Task<UnsMutationResult> DeleteEnterpriseAsync(string enterpriseName, CancellationToken ct = default);
/// <summary>
/// Loads the drivers eligible to back a tag on the given equipment: drivers in the equipment's
/// cluster (<c>Equipment.UnsLine → UnsArea.ClusterId</c>) whose namespace is Equipment-kind
/// (tree tags are equipment-bound). Ordered by <c>DriverInstanceId</c>. Returns
/// an empty list when the equipment cannot be resolved to a cluster.
/// </summary>
/// <param name="equipmentId">The equipment whose candidate drivers to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The eligible drivers projected to <c>(DriverInstanceId, Display, DriverType, DriverConfig)</c>
/// tuples, where <c>DriverType</c> lets the TagModal dispatch to a per-driver-type typed config editor
/// and <c>DriverConfig</c> feeds the live-browse address picker (e.g. the Galaxy gateway picker opens its
/// browse session against the selected GalaxyMxGateway's config).</returns>
Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType, string DriverConfig)>> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Creates a new equipment-bound tag. <c>FolderPath</c> is always <c>null</c> (the tree
/// only edits equipment-bound tags). Fails on a duplicate <c>TagId</c>, invalid
/// <c>TagConfig</c> JSON, an unknown driver, a driver whose namespace is not Equipment-kind, a
/// driver in a different cluster than the equipment, or a name already used on
/// the equipment. Whitespace-only <c>PollGroupId</c> collapses to <c>null</c>.
/// </summary>
/// <param name="equipmentId">The owning equipment.</param>
/// <param name="input">The operator-editable tag fields.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or one of the guard failures.</returns>
Task<UnsMutationResult> CreateTagAsync(string equipmentId, TagInput input, CancellationToken ct = default);
/// <summary>
/// Updates an equipment-bound tag's driver binding, name, data type, access level, write-retry
/// flag, poll group, and config. The owning <c>EquipmentId</c> and the <c>null</c>
/// <c>FolderPath</c> are preserved. Re-runs the JSON-validity, namespace-kind, and cluster
/// guards against the tag's existing equipment, and enforces name uniqueness on that
/// equipment excluding this tag. Uses last-write-wins optimistic concurrency on
/// <see cref="Configuration.Entities.Tag.RowVersion"/>.
/// </summary>
/// <param name="tagId">The tag to update.</param>
/// <param name="input">The new operator-editable tag fields.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a guard failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateTagAsync(string tagId, TagInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes a tag. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.Tag.RowVersion"/>.
/// </summary>
/// <param name="tagId">The tag to delete.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Loads the scripts eligible to back a virtual tag, ordered by name. Each is projected to a
/// <c>(ScriptId, Display)</c> pair where <c>Display</c> is <c>"{Name} ({Language})"</c>.
/// </summary>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The scripts projected to <c>(ScriptId, Display)</c> pairs.</returns>
Task<IReadOnlyList<(string ScriptId, string Display)>> LoadScriptsAsync(CancellationToken ct = default);
/// <summary>
/// Creates a new, empty C# script row so the inline virtual-tag panel can bind a brand-new script
/// without leaving the modal. The <c>ScriptId</c> is system-generated (<c>SC-</c> + the first 12 hex
/// chars of a fresh <see cref="Guid"/>, mirroring the Script-edit page's convention); the source is
/// blank, the <c>SourceHash</c> is the SHA-256 of that blank source (so it stays consistent with the
/// inline save), and the language is <c>CSharp</c>. A whitespace-only name falls back to a default.
/// The new id is returned via <see cref="UnsMutationResult.CreatedId"/> so the caller can bind it.
/// </summary>
/// <param name="name">The operator-friendly script name; whitespace falls back to a default.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success carrying the generated <c>SC-…</c> id in <see cref="UnsMutationResult.CreatedId"/>.</returns>
Task<UnsMutationResult> CreateScriptAsync(string name, CancellationToken ct = default);
/// <summary>
/// Creates a new equipment-bound virtual tag (virtual tags are always scoped
/// to an equipment). Fails if the equipment does not exist, if no script is chosen, if neither a
/// change trigger nor a timer is set, if the timer is below the 50 ms minimum, on a duplicate
/// <c>VirtualTagId</c>, or on a name already used on the equipment.
/// </summary>
/// <param name="equipmentId">The owning equipment.</param>
/// <param name="input">The operator-editable virtual-tag fields.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or one of the guard failures; or if the chosen script uses the <c>{{equip}}</c>
/// token but the equipment has no derivable single tag base.</returns>
Task<UnsMutationResult> CreateVirtualTagAsync(string equipmentId, VirtualTagInput input, CancellationToken ct = default);
/// <summary>
/// Updates an equipment-bound virtual tag's name, data type, script binding, triggers, historize,
/// and enabled flags. The owning <c>EquipmentId</c> is preserved. Re-runs the script-chosen,
/// change-or-timer, and 50 ms timer-minimum guards, and enforces name uniqueness on the tag's
/// existing equipment excluding this virtual tag. Uses last-write-wins optimistic concurrency on
/// <see cref="Configuration.Entities.VirtualTag.RowVersion"/>.
/// </summary>
/// <param name="virtualTagId">The virtual tag to update.</param>
/// <param name="input">The new operator-editable virtual-tag fields.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a guard failure, or a concurrency failure; or if the
/// chosen script uses the <c>{{equip}}</c> token but the equipment has no derivable single tag base.</returns>
Task<UnsMutationResult> UpdateVirtualTagAsync(string virtualTagId, VirtualTagInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes a virtual tag. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.VirtualTag.RowVersion"/>.
/// </summary>
/// <param name="virtualTagId">The virtual tag to delete.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteVirtualTagAsync(string virtualTagId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Loads a script's editable source for the inline script-source panel in the virtual-tag modal,
/// along with the concurrency token the panel must echo back on save and the script's display name.
/// Reads untracked. Returns <c>null</c> when the script no longer exists.
/// </summary>
/// <param name="scriptId">The script whose source to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The <c>(SourceCode, RowVersion, Name)</c> triple, or <c>null</c> when missing.</returns>
Task<(string SourceCode, byte[] RowVersion, string Name)?> GetScriptSourceAsync(string scriptId, CancellationToken ct = default);
/// <summary>
/// Counts how many virtual tags bind the given script, so the inline editor can warn the operator
/// that an edit to a shared script affects every virtual tag using it.
/// </summary>
/// <param name="scriptId">The script to count usages of.</param>
/// <param name="ct">A token to cancel the query.</param>
/// <returns>The number of virtual tags whose <c>ScriptId</c> matches.</returns>
Task<int> CountVirtualTagsUsingScriptAsync(string scriptId, CancellationToken ct = default);
/// <summary>
/// Saves an edited script body from the inline panel: updates <c>SourceCode</c> and recomputes the
/// SHA-256 <c>SourceHash</c> (lower-case hex, matching the Script-edit page). This save is separate
/// from the virtual-tag save and is guarded by its own last-write-wins optimistic concurrency on
/// <see cref="Configuration.Entities.Script.RowVersion"/>.
/// </summary>
/// <param name="scriptId">The script to update.</param>
/// <param name="sourceCode">The new source body.</param>
/// <param name="rowVersion">The concurrency token the panel last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, or a concurrency failure.</returns>
Task<(bool Ok, string? Error)> UpdateScriptSourceAsync(string scriptId, string sourceCode, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Loads the scripted alarms owned by a single equipment as flat row projections for the equipment
/// page's Alarms tab table, ordered by Name. Each row carries the display columns plus the
/// <c>ScriptedAlarmId</c> the table uses to open the edit modal. Reads untracked. Returns an empty
/// list when the equipment has no scripted alarms.
/// </summary>
/// <param name="equipmentId">The equipment whose scripted alarms to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The equipment's scripted-alarm rows ordered by Name; empty if it has none.</returns>
Task<IReadOnlyList<EquipmentAlarmRow>> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Loads a single scripted alarm projected for editing, or <c>null</c> if it no longer exists.
/// Reads untracked and captures the current concurrency token for last-write-wins saves.
/// </summary>
/// <param name="scriptedAlarmId">The scripted alarm to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The scripted alarm's edit projection, or <c>null</c> when missing.</returns>
Task<ScriptedAlarmEditDto?> LoadScriptedAlarmAsync(string scriptedAlarmId, CancellationToken ct = default);
/// <summary>
/// Creates a new scripted alarm under an equipment. The <c>ScriptedAlarmId</c> is operator-typed
/// (not system-generated). Fails on a duplicate <c>ScriptedAlarmId</c>. Field validation (severity
/// range, required fields) is enforced client-side in the modal. On success the operator-typed id is
/// echoed back via <see cref="UnsMutationResult.CreatedId"/>.
/// </summary>
/// <param name="equipmentId">The owning equipment.</param>
/// <param name="input">The operator-editable scripted-alarm fields.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success carrying the created id, or a duplicate-id failure.</returns>
Task<UnsMutationResult> CreateScriptedAlarmAsync(string equipmentId, ScriptedAlarmInput input, CancellationToken ct = default);
/// <summary>
/// Updates a scripted alarm's mutable fields (name, alarm type, severity, message template,
/// predicate script, and the historize/retain/enabled flags). The owning <c>EquipmentId</c> is
/// preserved (the Alarms tab fixes it). Uses last-write-wins optimistic concurrency on
/// <see cref="Configuration.Entities.ScriptedAlarm.RowVersion"/>.
/// </summary>
/// <param name="scriptedAlarmId">The scripted alarm to update.</param>
/// <param name="input">The new operator-editable scripted-alarm fields.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateScriptedAlarmAsync(string scriptedAlarmId, ScriptedAlarmInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes a scripted alarm. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.ScriptedAlarm.RowVersion"/>.
/// </summary>
/// <param name="scriptedAlarmId">The scripted alarm to delete.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteScriptedAlarmAsync(string scriptedAlarmId, byte[] rowVersion, CancellationToken ct = default);
}