diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs
index 4ac1e333..481d21ac 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs
@@ -113,6 +113,11 @@ public sealed record EquipmentTagAlarmInfo(string AlarmType, int Severity);
/// that NodeId. = the distinct ctx.GetTag("…") literals in
/// the script source.
///
+/// When true, this VirtualTag's values are historized (carried from the
+/// VirtualTag.Historize entity column). Threaded through the deploy-diff equality below so a
+/// Historize-only toggle is detected as a change. Defaults to false — matching both the entity
+/// default for an unset column and the artifact-decode default when the flag is absent/non-bool —
+/// which keeps existing positional+named ctor call sites compiling and preserves byte-parity.
public sealed record EquipmentVirtualTagPlan(
string VirtualTagId,
string EquipmentId,
@@ -120,12 +125,14 @@ public sealed record EquipmentVirtualTagPlan(
string Name,
string DataType,
string Expression,
- IReadOnlyList DependencyRefs)
+ IReadOnlyList DependencyRefs,
+ bool Historize = false)
{
/// Structural equality: the auto-generated record equality would compare
/// (an interface-typed list) BY REFERENCE, flagging every
/// VirtualTag as "changed" on every parse (fresh list instances). Compare it element-wise
- /// so a no-op redeploy diffs empty.
+ /// so a no-op redeploy diffs empty. is included so a Historize-only
+ /// toggle is detected as a change.
public bool Equals(EquipmentVirtualTagPlan? other) =>
other is not null &&
VirtualTagId == other.VirtualTagId &&
@@ -134,6 +141,7 @@ public sealed record EquipmentVirtualTagPlan(
Name == other.Name &&
DataType == other.DataType &&
Expression == other.Expression &&
+ Historize == other.Historize &&
DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);
public override int GetHashCode()
@@ -141,6 +149,7 @@ public sealed record EquipmentVirtualTagPlan(
var hash = new HashCode();
hash.Add(VirtualTagId); hash.Add(EquipmentId); hash.Add(FolderPath);
hash.Add(Name); hash.Add(DataType); hash.Add(Expression);
+ hash.Add(Historize);
foreach (var r in DependencyRefs) hash.Add(r, StringComparer.Ordinal);
return hash.ToHashCode();
}
@@ -385,7 +394,8 @@ public static class Phase7Composer
Name: v.Name,
DataType: v.DataType,
Expression: expanded,
- DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded));
+ DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded),
+ Historize: v.Historize);
})
.ToList();
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerVirtualTagHistorizeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerVirtualTagHistorizeTests.cs
new file mode 100644
index 00000000..a48499d4
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerVirtualTagHistorizeTests.cs
@@ -0,0 +1,70 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
+
+///
+/// Verifies the live-edit compose seam () carries the
+/// entity column onto the resulting
+/// (H5a). The flag was authored in the UI but
+/// never threaded onto the plan, leaving equipment-namespace runtime historization dead. Both a
+/// true and a false case are asserted so the default (false) and the explicit set both prove out.
+///
+public sealed class Phase7ComposerVirtualTagHistorizeTests
+{
+ [Fact]
+ public void Compose_carries_virtual_tag_historize_flag_onto_plan()
+ {
+ var ns = new Namespace
+ {
+ NamespaceId = "ns-eq",
+ ClusterId = "c1",
+ Kind = NamespaceKind.Equipment,
+ NamespaceUri = "urn:eq",
+ };
+ var driver = new DriverInstance
+ {
+ DriverInstanceId = "drv-1",
+ ClusterId = "c1",
+ NamespaceId = "ns-eq",
+ Name = "Modbus1",
+ DriverType = "Modbus",
+ DriverConfig = "{}",
+ };
+ var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
+ var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
+ var equip = new Equipment { EquipmentId = "eq-1", DriverInstanceId = "drv-1", UnsLineId = "line-1", Name = "TestMachine_001", MachineCode = "TESTMACHINE_001" };
+ var tag = new Tag
+ {
+ TagId = "tag-1",
+ DriverInstanceId = "drv-1",
+ EquipmentId = "eq-1",
+ Name = "Source",
+ DataType = "Int32",
+ AccessLevel = TagAccessLevel.Read,
+ TagConfig = "{\"FullName\":\"TestMachine_001.Source\",\"DataType\":\"Int32\"}",
+ };
+ var script = new Script
+ {
+ ScriptId = "s-1",
+ Name = "passthru",
+ SourceCode = "return ctx.GetTag(\"TestMachine_001.Source\").Value;",
+ SourceHash = "hash-1",
+ };
+ // Two VirtualTags off the same script: one historized, one not.
+ var vtHist = new VirtualTag { VirtualTagId = "vt-hist", EquipmentId = "eq-1", Name = "Historized", DataType = "Int32", ScriptId = "s-1", Historize = true };
+ var vtPlain = new VirtualTag { VirtualTagId = "vt-plain", EquipmentId = "eq-1", Name = "Plain", DataType = "Int32", ScriptId = "s-1", Historize = false };
+
+ var result = Phase7Composer.Compose(
+ new[] { area }, new[] { line }, new[] { equip },
+ new[] { driver }, Array.Empty(),
+ new[] { tag }, new[] { ns },
+ virtualTags: new[] { vtHist, vtPlain },
+ scripts: new[] { script });
+
+ result.EquipmentVirtualTags.Single(p => p.VirtualTagId == "vt-hist").Historize.ShouldBeTrue();
+ result.EquipmentVirtualTags.Single(p => p.VirtualTagId == "vt-plain").Historize.ShouldBeFalse();
+ }
+}