diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs index b035ce0b..a62817af 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs @@ -491,7 +491,8 @@ public sealed class ArtifactDiff e => e.AlarmCanonicalName, i => i.AlarmCanonicalName, (e, i) => e.TriggerConfigurationOverride == i.TriggerConfigurationOverride - && e.PriorityLevelOverride == i.PriorityLevelOverride, + && e.PriorityLevelOverride == i.PriorityLevelOverride + && e.IsMesReportableOverride == i.IsMesReportableOverride, "AlarmOverrides", changes); diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index f70103a0..ebefc4ae 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -2172,6 +2172,7 @@ public sealed class BundleImporter : IBundleImporter TriggerConfiguration = al.TriggerConfiguration, IsLocked = al.IsLocked, LockedInDerived = al.LockedInDerived, + IsMesReportable = al.IsMesReportable, }); } foreach (var s in dto.Scripts) @@ -2395,6 +2396,7 @@ public sealed class BundleImporter : IBundleImporter current.TriggerConfiguration = alarmDto.TriggerConfiguration; current.IsLocked = alarmDto.IsLocked; current.LockedInDerived = alarmDto.LockedInDerived; + current.IsMesReportable = alarmDto.IsMesReportable; current.OnTriggerScriptId = null; // re-resolved post-flush. await _templateRepo.UpdateTemplateAlarmAsync(current, ct).ConfigureAwait(false); await _auditService.LogAsync( @@ -2413,6 +2415,7 @@ public sealed class BundleImporter : IBundleImporter current.TriggerConfiguration, current.IsLocked, current.LockedInDerived, + current.IsMesReportable, OnTriggerScriptName = alarmDto.OnTriggerScriptName, }, ct).ConfigureAwait(false); @@ -2427,6 +2430,7 @@ public sealed class BundleImporter : IBundleImporter TriggerConfiguration = alarmDto.TriggerConfiguration, IsLocked = alarmDto.IsLocked, LockedInDerived = alarmDto.LockedInDerived, + IsMesReportable = alarmDto.IsMesReportable, }; ex.Alarms.Add(newAlarm); await _auditService.LogAsync( @@ -2445,6 +2449,7 @@ public sealed class BundleImporter : IBundleImporter newAlarm.TriggerConfiguration, newAlarm.IsLocked, newAlarm.LockedInDerived, + newAlarm.IsMesReportable, OnTriggerScriptName = alarmDto.OnTriggerScriptName, }, ct).ConfigureAwait(false); @@ -4360,6 +4365,7 @@ public sealed class BundleImporter : IBundleImporter { TriggerConfigurationOverride = o.TriggerConfigurationOverride, PriorityLevelOverride = o.PriorityLevelOverride, + IsMesReportableOverride = o.IsMesReportableOverride, }); } foreach (var o in dto.NativeAlarmSourceOverrides) @@ -4913,6 +4919,7 @@ public sealed class BundleImporter : IBundleImporter // at this single-template-scope validation step the bare name // IS the canonical name, so just pass it through. OnTriggerScriptCanonicalName = string.IsNullOrEmpty(al.OnTriggerScriptName) ? null : al.OnTriggerScriptName, + IsMesReportable = al.IsMesReportable, Source = "Template", }); } diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/TemplateChildEquality.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/TemplateChildEquality.cs index 2dd6a68f..9812fa54 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/TemplateChildEquality.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/TemplateChildEquality.cs @@ -61,6 +61,7 @@ internal static class TemplateChildEquality && e.TriggerConfiguration == i.TriggerConfiguration && e.IsLocked == i.IsLocked && e.LockedInDerived == i.LockedInDerived + && e.IsMesReportable == i.IsMesReportable && scriptNameById(e.OnTriggerScriptId) == i.OnTriggerScriptName; /// diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs index 8f99d3e6..9f308a00 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntityDtos.cs @@ -218,7 +218,10 @@ public sealed record TemplateAlarmDto( bool IsLocked, string? OnTriggerScriptName, // Derived-lock flag; additive trailing field (see TemplateAttributeDto). - bool LockedInDerived = false); + bool LockedInDerived = false, + // Whether this alarm is reported to MES; additive trailing field, false on + // bundles written before it existed (see TemplateAttributeDto). + bool IsMesReportable = false); public sealed record TemplateScriptDto( string Name, @@ -395,7 +398,10 @@ public sealed record InstanceAttributeOverrideDto( public sealed record InstanceAlarmOverrideDto( string AlarmCanonicalName, string? TriggerConfigurationOverride, - int? PriorityLevelOverride); + int? PriorityLevelOverride, + // Per-instance override of the alarm's MES-reportable flag; additive trailing + // field, null ("keep inherited") on bundles written before it existed. + bool? IsMesReportableOverride = null); public sealed record InstanceNativeAlarmSourceOverrideDto( string SourceCanonicalName, diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs index dc6ec230..2128250c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs @@ -77,7 +77,8 @@ public sealed class EntitySerializer // name comes through as null and the importer leaves the // FK null on the imported alarm. OnTriggerScriptName: a.OnTriggerScriptId is { } sid && scriptNameById.TryGetValue(sid, out var sn) ? sn : null, - LockedInDerived: a.LockedInDerived)).ToList(), + LockedInDerived: a.LockedInDerived, + IsMesReportable: a.IsMesReportable)).ToList(), Scripts: t.Scripts.Select(s => new TemplateScriptDto( Name: s.Name, Code: s.Code, @@ -285,7 +286,8 @@ public sealed class EntitySerializer AlarmOverrides: inst.AlarmOverrides.Select(o => new InstanceAlarmOverrideDto( AlarmCanonicalName: o.AlarmCanonicalName, TriggerConfigurationOverride: o.TriggerConfigurationOverride, - PriorityLevelOverride: o.PriorityLevelOverride)).ToList(), + PriorityLevelOverride: o.PriorityLevelOverride, + IsMesReportableOverride: o.IsMesReportableOverride)).ToList(), NativeAlarmSourceOverrides: inst.NativeAlarmSourceOverrides.Select(o => new InstanceNativeAlarmSourceOverrideDto( SourceCanonicalName: o.SourceCanonicalName, ConnectionNameOverride: o.ConnectionNameOverride, @@ -354,6 +356,7 @@ public sealed class EntitySerializer TriggerConfiguration = al.TriggerConfiguration, IsLocked = al.IsLocked, LockedInDerived = al.LockedInDerived, + IsMesReportable = al.IsMesReportable, }); } foreach (var s in dto.Scripts) @@ -607,6 +610,7 @@ public sealed class EntitySerializer InstanceId = inst.Id, TriggerConfigurationOverride = o.TriggerConfigurationOverride, PriorityLevelOverride = o.PriorityLevelOverride, + IsMesReportableOverride = o.IsMesReportableOverride, }); } foreach (var o in dto.NativeAlarmSourceOverrides) diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripEquivalenceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripEquivalenceTests.cs index b753e285..33979eb6 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripEquivalenceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/RoundTripEquivalenceTests.cs @@ -238,6 +238,7 @@ public sealed class RoundTripEquivalenceTests : IDisposable TriggerType = AlarmTriggerType.RangeViolation, TriggerConfiguration = "{\"threshold\":100}", LockedInDerived = true, + IsMesReportable = true, }); tpl.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Boiler") { @@ -809,6 +810,9 @@ public sealed class RoundTripEquivalenceTests : IDisposable { TriggerConfigurationOverride = "{\"hi\":90}", PriorityLevelOverride = 7, + // Non-default for a bool? (whose default is null), so the reflection + // sweep fails loudly if the DTO drops the field. + IsMesReportableOverride = false, }); instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("NativeSrc") { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/ArtifactDiffTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/ArtifactDiffTests.cs index c04874f5..1a21e3bc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/ArtifactDiffTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/ArtifactDiffTests.cs @@ -396,6 +396,53 @@ public sealed class ArtifactDiffTests ChangeFor(item, "Alarms.A"); // OnTriggerScriptName diverged — previously missed. } + [Fact] + public void CompareTemplate_AlarmIsMesReportableChange_IsModified() + { + var existing = MakeTemplate("T1"); + existing.Alarms.Add(new TemplateAlarm("A") + { + TriggerType = AlarmTriggerType.RangeViolation, + IsMesReportable = false, + }); + var incoming = MakeTemplateDto("T1", alarms: + [ + // every other alarm field identical; only IsMesReportable differs. + new TemplateAlarmDto("A", null, 0, AlarmTriggerType.RangeViolation, null, false, null, + IsMesReportable: true), + ]); + + var item = _diff.CompareTemplate(incoming, existing); + + Assert.Equal(ConflictKind.Modified, item.Kind); + ChangeFor(item, "Alarms.A"); // IsMesReportable diverged. + } + + [Fact] + public void CompareInstance_AlarmOverrideIsMesReportableChange_IsModified() + { + var existing = new Instance("inst-1") { State = InstanceState.Enabled }; + existing.AlarmOverrides.Add(new InstanceAlarmOverride("HiAlarm") + { + TriggerConfigurationOverride = "{\"hi\":90}", + PriorityLevelOverride = 7, + IsMesReportableOverride = null, + }); + + var dto = MakeInstanceDto("inst-1", state: InstanceState.Enabled, alarmOverrides: + [ + // identical apart from the MES-reportable override. + new InstanceAlarmOverrideDto("HiAlarm", "{\"hi\":90}", 7, IsMesReportableOverride: false), + ]); + + var item = _diff.CompareInstance(dto, existing); + + Assert.Equal(ConflictKind.Modified, item.Kind); + var modified = ChangeFor(item, "AlarmOverrides.HiAlarm"); + Assert.Equal("", modified.GetProperty("oldValue").GetString()); + Assert.Equal("", modified.GetProperty("newValue").GetString()); + } + // ============ M8: CompareSite ============ [Fact] @@ -699,7 +746,8 @@ public sealed class ArtifactDiffTests string template = "Tmpl", string site = "site-a", InstanceState state = InstanceState.Enabled, - IReadOnlyList? attributeOverrides = null) => + IReadOnlyList? attributeOverrides = null, + IReadOnlyList? alarmOverrides = null) => new( UniqueName: uniqueName, TemplateName: template, @@ -707,7 +755,7 @@ public sealed class ArtifactDiffTests AreaName: null, State: state, AttributeOverrides: attributeOverrides ?? Array.Empty(), - AlarmOverrides: Array.Empty(), + AlarmOverrides: alarmOverrides ?? Array.Empty(), NativeAlarmSourceOverrides: Array.Empty(), ConnectionBindings: Array.Empty()); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs index 7e79c92b..da36cda3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs @@ -294,6 +294,106 @@ public sealed class EntitySerializerTests Assert.False(Assert.Single(template.Scripts).LockedInDerived); } + [Fact] + public void Roundtrip_preserves_IsMesReportable_on_alarm_and_instance_override() + { + var basic = new Template("Basic") { Id = 1 }; + basic.Alarms.Add(new TemplateAlarm("High") + { + Id = 1, + TemplateId = 1, + TriggerType = AlarmTriggerType.RangeViolation, + IsMesReportable = true, + }); + + var instance = new Instance("Pump-01") { Id = 1, TemplateId = 1, SiteId = 1 }; + instance.AlarmOverrides.Add(new InstanceAlarmOverride("High") + { + InstanceId = 1, + PriorityLevelOverride = 7, + IsMesReportableOverride = false, + }); + + var aggregate = MakeEmptyAggregate() with + { + Templates = new[] { basic }, + Sites = new[] { new Site("Site A", "site-a") { Id = 1 } }, + Instances = new[] { instance }, + }; + + var sut = new EntitySerializer(); + var dto = sut.ToBundleContent(aggregate); + + Assert.True(Assert.Single(Assert.Single(dto.Templates).Alarms).IsMesReportable); + Assert.False(Assert.Single(Assert.Single(dto.Instances).AlarmOverrides).IsMesReportableOverride); + + var restored = sut.FromBundleContent(dto); + Assert.True(Assert.Single(Assert.Single(restored.Templates).Alarms).IsMesReportable); + Assert.False(Assert.Single(Assert.Single(restored.Instances).AlarmOverrides).IsMesReportableOverride); + } + + [Fact] + public void OldForm_bundle_json_without_IsMesReportable_deserializes_to_false_and_null() + { + // A bundle written before the MES-reportable flag existed: the property is + // absent from the template alarm and from the instance alarm override. The + // DTO fields are additive trailing parameters, so the alarm flag must land + // on false and the override on null (= "keep inherited"), never throw. + const string json = """ + { + "TemplateFolders": [], + "Templates": [ + { + "Name": "Legacy", + "FolderName": null, + "BaseTemplateName": null, + "Description": null, + "Attributes": [], + "Alarms": [ + { "Name": "Alm", "Description": null, "PriorityLevel": 1, + "TriggerType": "RangeViolation", "TriggerConfiguration": null, "IsLocked": false, + "OnTriggerScriptName": null, "LockedInDerived": true } + ], + "Scripts": [], + "Compositions": [] + } + ], + "SharedScripts": [], + "ExternalSystems": [], + "DatabaseConnections": [], + "NotificationLists": [], + "SmtpConfigs": [], + "ApiMethods": [], + "Instances": [ + { + "UniqueName": "Pump-01", + "TemplateName": "Legacy", + "SiteIdentifier": "site-a", + "AreaName": null, + "State": "Enabled", + "AttributeOverrides": [], + "AlarmOverrides": [ + { "AlarmCanonicalName": "Alm", "TriggerConfigurationOverride": null, + "PriorityLevelOverride": 3 } + ], + "NativeAlarmSourceOverrides": [], + "ConnectionBindings": [] + } + ] + } + """; + + var content = JsonSerializer.Deserialize(json, BundleJsonOptions.Default)!; + + var alarm = Assert.Single(Assert.Single(content.Templates).Alarms); + Assert.True(alarm.LockedInDerived); // pre-existing trailing field still read + Assert.False(alarm.IsMesReportable); // new trailing field defaults + + var over = Assert.Single(Assert.Single(content.Instances).AlarmOverrides); + Assert.Equal(3, over.PriorityLevelOverride); + Assert.Null(over.IsMesReportableOverride); + } + [Fact] public void Roundtrip_template_folder_preserves_hierarchy() {