feat(uns): equipment-bound tag CRUD with namespace + cluster guards
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
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.AdminUI.Uns;
|
||||
|
||||
@@ -474,6 +475,249 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadTagDriversForEquipmentAsync(
|
||||
string equipmentId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
|
||||
if (equipmentCluster is null)
|
||||
{
|
||||
return Array.Empty<(string, string)>();
|
||||
}
|
||||
|
||||
// Drivers in the equipment's cluster whose namespace is Equipment-kind (decision #110).
|
||||
var equipmentNamespaceIds = await db.Namespaces
|
||||
.Where(n => n.ClusterId == equipmentCluster && n.Kind == NamespaceKind.Equipment)
|
||||
.Select(n => n.NamespaceId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var drivers = await db.DriverInstances
|
||||
.Where(d => d.ClusterId == equipmentCluster && equipmentNamespaceIds.Contains(d.NamespaceId))
|
||||
.OrderBy(d => d.DriverInstanceId)
|
||||
.Select(d => new { d.DriverInstanceId, d.Name })
|
||||
.ToListAsync(ct);
|
||||
|
||||
return drivers
|
||||
.Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}"))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> CreateTagAsync(
|
||||
string equipmentId,
|
||||
TagInput input,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
if (await db.Tags.AnyAsync(t => t.TagId == input.TagId, ct))
|
||||
{
|
||||
return new UnsMutationResult(false, $"Tag '{input.TagId}' already exists.");
|
||||
}
|
||||
|
||||
if (!IsValidJson(input.TagConfig))
|
||||
{
|
||||
return new UnsMutationResult(false, "TagConfig is not valid JSON.");
|
||||
}
|
||||
|
||||
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
|
||||
var guard = await CheckTagDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct);
|
||||
if (guard is not null)
|
||||
{
|
||||
return guard.Value;
|
||||
}
|
||||
|
||||
if (await db.Tags.AnyAsync(t => t.EquipmentId == equipmentId && t.Name == input.Name, ct))
|
||||
{
|
||||
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists on this equipment.");
|
||||
}
|
||||
|
||||
db.Tags.Add(new Tag
|
||||
{
|
||||
TagId = input.TagId,
|
||||
DriverInstanceId = input.DriverInstanceId,
|
||||
EquipmentId = equipmentId,
|
||||
Name = input.Name,
|
||||
FolderPath = null,
|
||||
DataType = input.DataType,
|
||||
AccessLevel = input.AccessLevel,
|
||||
WriteIdempotent = input.WriteIdempotent,
|
||||
PollGroupId = string.IsNullOrWhiteSpace(input.PollGroupId) ? null : input.PollGroupId,
|
||||
TagConfig = input.TagConfig,
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> UpdateTagAsync(
|
||||
string tagId,
|
||||
TagInput input,
|
||||
byte[] rowVersion,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == tagId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return new UnsMutationResult(false, "Row no longer exists.");
|
||||
}
|
||||
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
|
||||
if (!IsValidJson(input.TagConfig))
|
||||
{
|
||||
return new UnsMutationResult(false, "TagConfig is not valid JSON.");
|
||||
}
|
||||
|
||||
var equipmentCluster = await ResolveEquipmentClusterAsync(db, entity.EquipmentId, ct);
|
||||
var guard = await CheckTagDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct);
|
||||
if (guard is not null)
|
||||
{
|
||||
return guard.Value;
|
||||
}
|
||||
|
||||
if (await db.Tags.AnyAsync(
|
||||
t => t.EquipmentId == entity.EquipmentId && t.Name == input.Name && t.TagId != tagId,
|
||||
ct))
|
||||
{
|
||||
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists on this equipment.");
|
||||
}
|
||||
|
||||
// EquipmentId and FolderPath (null) are preserved — tree tags are always equipment-bound.
|
||||
entity.DriverInstanceId = input.DriverInstanceId;
|
||||
entity.Name = input.Name;
|
||||
entity.DataType = input.DataType;
|
||||
entity.AccessLevel = input.AccessLevel;
|
||||
entity.WriteIdempotent = input.WriteIdempotent;
|
||||
entity.PollGroupId = string.IsNullOrWhiteSpace(input.PollGroupId) ? null : input.PollGroupId;
|
||||
entity.TagConfig = input.TagConfig;
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return new UnsMutationResult(false, "Another user changed this tag while you were editing.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> DeleteTagAsync(
|
||||
string tagId,
|
||||
byte[] rowVersion,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == tagId, ct);
|
||||
if (entity is null)
|
||||
{
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
|
||||
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
||||
db.Tags.Remove(entity);
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
return new UnsMutationResult(true, null);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return new UnsMutationResult(false, "Another user changed this tag while you were viewing it.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UnsMutationResult(false, $"Delete failed: {ex.Message}.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns <c>true</c> if <paramref name="json"/> parses as a well-formed JSON document.</summary>
|
||||
private static bool IsValidJson(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var _ = System.Text.Json.JsonDocument.Parse(json);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an equipment to its cluster via <c>Equipment.UnsLineId → UnsLine.UnsAreaId →
|
||||
/// UnsArea.ClusterId</c>. Returns <c>null</c> when the equipment, its line, or its area cannot be
|
||||
/// resolved.
|
||||
/// </summary>
|
||||
private static async Task<string?> ResolveEquipmentClusterAsync(
|
||||
OtOpcUaConfigDbContext db,
|
||||
string? equipmentId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(equipmentId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var equipment = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == equipmentId, ct);
|
||||
if (equipment is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var line = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == equipment.UnsLineId, ct);
|
||||
if (line is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var area = await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == line.UnsAreaId, ct);
|
||||
return area?.ClusterId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a tree tag's driver binding: the driver must exist, live in an Equipment-kind
|
||||
/// namespace (decision #110), and be in the same cluster as the owning equipment (decision #122).
|
||||
/// Returns <c>null</c> when the binding is allowed, or a populated failure otherwise.
|
||||
/// </summary>
|
||||
private static async Task<UnsMutationResult?> CheckTagDriverGuardAsync(
|
||||
OtOpcUaConfigDbContext db,
|
||||
string driverInstanceId,
|
||||
string? equipmentCluster,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
|
||||
if (driver is null)
|
||||
{
|
||||
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");
|
||||
}
|
||||
|
||||
var ns = await db.Namespaces.FirstOrDefaultAsync(n => n.NamespaceId == driver.NamespaceId, ct);
|
||||
if (ns?.Kind != NamespaceKind.Equipment)
|
||||
{
|
||||
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' is not in an Equipment-kind namespace.");
|
||||
}
|
||||
|
||||
if (driver.ClusterId != equipmentCluster)
|
||||
{
|
||||
return new UnsMutationResult(
|
||||
false,
|
||||
$"Driver '{driverInstanceId}' is in cluster '{driver.ClusterId}' but the equipment is in cluster '{equipmentCluster}' (decision #122).");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decision #122: an equipment may only bind to a driver in the same cluster as its line.
|
||||
/// Policy:
|
||||
|
||||
Reference in New Issue
Block a user