feat(adminui): reject {{equip}} virtual tags whose equipment has no derivable base

This commit is contained in:
Joseph Doherty
2026-06-10 07:58:38 -04:00
parent 66ea9c56f6
commit cadd6c60b7
3 changed files with 252 additions and 0 deletions
@@ -1,6 +1,7 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
@@ -889,6 +890,34 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
.ToList();
}
/// <summary>
/// When the bound script uses the reserved <c>{{equip}}</c> token, the owning equipment must have
/// a derivable tag base (≥1 driver tag, all sharing one object prefix). Returns a rejection result
/// when the token is present but no base can be derived; <c>null</c> otherwise (token absent, or a
/// base exists). The script source is fetched from the supplied (in-scope) context.
/// </summary>
private static async Task<UnsMutationResult?> ValidateEquipTokenAsync(
OtOpcUaConfigDbContext db, string equipmentId, string scriptId, CancellationToken ct)
{
var src = await db.Scripts.Where(s => s.ScriptId == scriptId)
.Select(s => s.SourceCode).FirstOrDefaultAsync(ct);
if (!EquipmentScriptPaths.ContainsEquipToken(src)) return null;
var configs = await db.Tags.Where(t => t.EquipmentId == equipmentId)
.Select(t => t.TagConfig).ToListAsync(ct);
var fullNames = configs.Select(TagConfigFullName.Extract);
if (EquipmentScriptPaths.DeriveEquipmentBase(fullNames) is null)
{
// NOTE: literal {{equip}} must survive — in a C# interpolated string `{{`/`}}`
// collapse to single braces, so keep the token text in a NON-interpolated segment.
return new UnsMutationResult(false,
$"Equipment '{equipmentId}' has no single tag base, so the "
+ "{{equip}} token can't be resolved. Add at least one driver tag under this "
+ "equipment (all sharing one object prefix), or remove {{equip}} from the script.");
}
return null;
}
/// <inheritdoc />
public async Task<UnsMutationResult> CreateVirtualTagAsync(
string equipmentId,
@@ -918,6 +947,9 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
}
var equipGuard = await ValidateEquipTokenAsync(db, equipmentId, input.ScriptId, ct);
if (equipGuard is not null) return equipGuard.Value;
db.VirtualTags.Add(new VirtualTag
{
VirtualTagId = input.VirtualTagId,
@@ -964,6 +996,9 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
}
var equipGuard = await ValidateEquipTokenAsync(db, entity.EquipmentId, input.ScriptId, ct);
if (equipGuard is not null) return equipGuard.Value;
// EquipmentId is preserved — virtual tags are always equipment-bound (plan decision #2).
entity.Name = input.Name;
entity.DataType = input.DataType;