Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
T

461 lines
30 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, decision #125).</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 (decision #110),
/// 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 (plan decision #2), 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 decision-#122 cluster
/// mismatch). Skipped rows never appear in <see cref="Errors"/>.
/// </summary>
/// <param name="Inserted">The count of new Equipment rows added.</param>
/// <param name="Skipped">The count of rows skipped because their MachineCode already exists.</param>
/// <param name="Errors">The human-readable error strings for rows that failed validation.</param>
public sealed record EquipmentImportResult(int Inserted, int Skipped, IReadOnlyList<string> Errors);
/// <summary>
/// Loads the structural portion of the unified-namespace (UNS) browse tree —
/// Enterprise → Cluster → Area → Line → Equipment — from the config database.
/// Equipment children (tags/virtual tags) are summarised by count only and loaded
/// lazily by the renderer via <see cref="LoadEquipmentChildrenAsync"/>.
/// </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>
/// Lazily loads the Tag and VirtualTag leaf nodes for a single equipment node.
/// Tags are returned first (ordered by Name), followed by VirtualTags (ordered by Name).
/// Leaf nodes carry <c>ChildCount = 0</c> and <c>HasLazyChildren = false</c>.
/// </summary>
/// <param name="equipmentId">The equipment whose children to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>Tag nodes followed by VirtualTag nodes; empty if the equipment has none.</returns>
Task<IReadOnlyList<UnsNode>> LoadEquipmentChildrenAsync(string equipmentId, 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);
/// <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>
/// 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
/// decision-#122 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 #122 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
/// (decision #125: <c>EQ-</c> + the first 12 hex chars of a fresh <c>EquipmentUuid</c>).
/// Fails if the line is unset, if the MachineCode is already used fleet-wide, or if the
/// decision-#122 driver-cluster guard trips. Whitespace-only DriverInstanceId/ZTag/SAPID
/// collapse to <c>null</c>.
/// </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, a duplicate-MachineCode failure, or a #122 guard failure.</returns>
Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default);
/// <summary>
/// Bulk-imports equipment from a parsed set of <see cref="EquipmentInput"/> rows in a single
/// context, applying the same rules as the single-add path: a row whose <c>UnsLineId</c> does not
/// exist is an error; a row whose <c>DriverInstanceId</c> is set but does not resolve is an error;
/// a driver-bound row whose driver is in a different cluster than its line fails the decision-#122
/// guard; and a row whose <c>MachineCode</c> already exists in the DB <em>or</em> earlier in the
/// same batch is silently skipped (additive-only — never an update). Inserted rows get a
/// system-generated <c>EQ-</c> id and a fresh <c>EquipmentUuid</c>. All inserts are saved once at
/// the end.
/// </summary>
/// <param name="rows">The parsed equipment rows to import.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The insert/skip counts and the per-row error list.</returns>
Task<EquipmentImportResult> ImportEquipmentAsync(IReadOnlyList<EquipmentInput> rows, CancellationToken ct = default);
/// <summary>
/// Updates an equipment's mutable fields (driver binding, line, name, MachineCode, external
/// ids, and the OPC 40010 identification fields). The decision-#122 driver-cluster guard blocks
/// binding to a driver in a different cluster than the equipment's line. Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.Equipment.RowVersion"/>.
/// </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 #122 guard 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>
/// 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
/// (decision #110 — 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)</c> triples,
/// where <c>DriverType</c> lets the TagModal dispatch to a per-driver-type typed config editor.</returns>
Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType)>> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Creates a new equipment-bound tag. <c>FolderPath</c> is always <c>null</c> (decision #110 —
/// 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 (decision #122), 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 decision-#122
/// 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 equipment-bound virtual tag (plan decision #2 — 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);
}