diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
index a93d34bc..f05852d9 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
@@ -1040,7 +1040,7 @@ public sealed class BundleImporter : IBundleImporter
{
t.Attributes.Add(new TemplateAttribute(a.Name)
{
- Value = a.Value,
+ Value = ImportValueNormalizer.NormalizeListValue(a.Value, a.DataType, a.ElementDataType),
DataType = a.DataType,
IsLocked = a.IsLocked,
Description = a.Description,
@@ -1115,11 +1115,17 @@ public sealed class BundleImporter : IBundleImporter
// Adds + Updates.
foreach (var attrDto in dto.Attributes)
{
+ // Normalise List values to the native-typed JSON form on import so the
+ // comparison (and the persisted value) match what the target already
+ // stores natively — otherwise an idempotent re-import of an old-form
+ // bundle would spuriously report a Value change.
+ var normalizedValue = ImportValueNormalizer.NormalizeListValue(
+ attrDto.Value, attrDto.DataType, attrDto.ElementDataType);
if (existingByName.TryGetValue(attrDto.Name, out var current))
{
// Update only if any field actually changed.
bool changed =
- !string.Equals(current.Value, attrDto.Value, StringComparison.Ordinal) ||
+ !string.Equals(current.Value, normalizedValue, StringComparison.Ordinal) ||
current.DataType != attrDto.DataType ||
current.IsLocked != attrDto.IsLocked ||
!string.Equals(current.Description, attrDto.Description, StringComparison.Ordinal) ||
@@ -1127,7 +1133,7 @@ public sealed class BundleImporter : IBundleImporter
current.ElementDataType != attrDto.ElementDataType;
if (!changed) continue;
- current.Value = attrDto.Value;
+ current.Value = normalizedValue;
current.DataType = attrDto.DataType;
current.IsLocked = attrDto.IsLocked;
current.Description = attrDto.Description;
@@ -1157,7 +1163,7 @@ public sealed class BundleImporter : IBundleImporter
{
var newAttr = new TemplateAttribute(attrDto.Name)
{
- Value = attrDto.Value,
+ Value = normalizedValue,
DataType = attrDto.DataType,
IsLocked = attrDto.IsLocked,
Description = attrDto.Description,
diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs
index 22c7c31b..88b66573 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs
@@ -199,7 +199,7 @@ public sealed class EntitySerializer
t.Attributes.Add(new TemplateAttribute(a.Name)
{
TemplateId = t.Id,
- Value = a.Value,
+ Value = ImportValueNormalizer.NormalizeListValue(a.Value, a.DataType, a.ElementDataType),
DataType = a.DataType,
IsLocked = a.IsLocked,
Description = a.Description,
diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/ImportValueNormalizer.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/ImportValueNormalizer.cs
new file mode 100644
index 00000000..e4211ae1
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/ImportValueNormalizer.cs
@@ -0,0 +1,51 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Types;
+using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
+
+namespace ZB.MOM.WW.ScadaBridge.Transport.Serialization;
+
+///
+/// Import-time normalization of attribute values to the native-typed JSON form.
+///
+/// Bundles exported before the native-typed-JSON change carry List attribute
+/// values in the old quoted-element form (e.g. ["10","20"] for an
+/// list). Already-exported bundle files can't be
+/// rewritten, so List values are normalised on import: every DTO→entity write
+/// site routes the value through so imported
+/// data lands native ([10,20]). The central DB normalizer remains the
+/// backstop for anything that slips through.
+///
+///
+/// Non-List attributes and null/empty values pass through unchanged. A value
+/// that fails to decode (malformed JSON / un-parseable element) is left exactly
+/// as-is so the import still succeeds — the DB normalizer is the backstop.
+///
+///
+internal static class ImportValueNormalizer
+{
+ ///
+ /// Returns the native-typed JSON form of a List attribute value, or the
+ /// value unchanged for non-List / null / empty / malformed inputs.
+ ///
+ /// The attribute value as carried by the bundle DTO.
+ /// The attribute's declared data type.
+ /// The List element type (null for scalars).
+ public static string? NormalizeListValue(string? value, DataType dataType, DataType? elementType)
+ {
+ if (dataType != DataType.List || string.IsNullOrEmpty(value))
+ {
+ return value;
+ }
+
+ try
+ {
+ return AttributeValueCodec.Encode(
+ AttributeValueCodec.Decode(value, DataType.List, elementType));
+ }
+ catch (FormatException)
+ {
+ // Leave malformed values exactly as imported; the DB normalizer is
+ // the backstop. Never abort the import for a single bad value.
+ return value;
+ }
+ }
+}
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 b4fefbb0..51f8ab0c 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Serialization/EntitySerializerTests.cs
@@ -259,6 +259,122 @@ public sealed class EntitySerializerTests
Assert.Equal("[\"a\",\"b\"]", rtAttr.Value);
}
+ private static BundleContentDto MakeContentWithListAttribute(
+ string value, DataType elementType)
+ {
+ var template = new TemplateDto(
+ Name: "Pump",
+ FolderName: null,
+ BaseTemplateName: null,
+ Description: null,
+ Attributes: new[]
+ {
+ new TemplateAttributeDto(
+ Name: "Tags",
+ Value: value,
+ DataType: DataType.List,
+ IsLocked: false,
+ Description: null,
+ DataSourceReference: null,
+ ElementDataType: elementType),
+ },
+ Alarms: Array.Empty(),
+ Scripts: Array.Empty(),
+ Compositions: Array.Empty());
+
+ return new BundleContentDto(
+ TemplateFolders: Array.Empty(),
+ Templates: new[] { template },
+ SharedScripts: Array.Empty(),
+ ExternalSystems: Array.Empty(),
+ DatabaseConnections: Array.Empty(),
+ NotificationLists: Array.Empty(),
+ SmtpConfigs: Array.Empty(),
+ ApiMethods: Array.Empty());
+ }
+
+ [Fact]
+ public void Import_normalizes_old_form_Int32_list_value_to_native_json()
+ {
+ // Pre-native bundle: quoted Int32 list elements.
+ var dto = MakeContentWithListAttribute("[\"10\",\"20\"]", DataType.Int32);
+
+ var aggregate = new EntitySerializer().FromBundleContent(dto);
+
+ var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
+ Assert.Equal(DataType.List, attr.DataType);
+ Assert.Equal(DataType.Int32, attr.ElementDataType);
+ // Imported native: numbers unquoted.
+ Assert.Equal("[10,20]", attr.Value);
+ }
+
+ [Fact]
+ public void Import_leaves_string_list_value_quoted()
+ {
+ var dto = MakeContentWithListAttribute("[\"a\",\"b\"]", DataType.String);
+
+ var aggregate = new EntitySerializer().FromBundleContent(dto);
+
+ var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
+ Assert.Equal(DataType.List, attr.DataType);
+ Assert.Equal(DataType.String, attr.ElementDataType);
+ // Strings stay quoted in native form.
+ Assert.Equal("[\"a\",\"b\"]", attr.Value);
+ }
+
+ [Fact]
+ public void Import_leaves_malformed_list_value_unchanged_without_throwing()
+ {
+ // Truncated JSON array — Decode throws FormatException; the import must
+ // still succeed and carry the value through verbatim (DB normalizer is
+ // the backstop).
+ var dto = MakeContentWithListAttribute("[\"a\"", DataType.String);
+
+ var aggregate = new EntitySerializer().FromBundleContent(dto);
+
+ var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
+ Assert.Equal("[\"a\"", attr.Value);
+ }
+
+ [Fact]
+ public void Import_leaves_scalar_attribute_value_unchanged()
+ {
+ var template = new TemplateDto(
+ Name: "Sensor",
+ FolderName: null,
+ BaseTemplateName: null,
+ Description: null,
+ Attributes: new[]
+ {
+ new TemplateAttributeDto(
+ Name: "Pressure",
+ Value: "42.0",
+ DataType: DataType.Double,
+ IsLocked: false,
+ Description: null,
+ DataSourceReference: null,
+ ElementDataType: null),
+ },
+ Alarms: Array.Empty(),
+ Scripts: Array.Empty(),
+ Compositions: Array.Empty());
+ var dto = new BundleContentDto(
+ TemplateFolders: Array.Empty(),
+ Templates: new[] { template },
+ SharedScripts: Array.Empty(),
+ ExternalSystems: Array.Empty(),
+ DatabaseConnections: Array.Empty(),
+ NotificationLists: Array.Empty(),
+ SmtpConfigs: Array.Empty(),
+ ApiMethods: Array.Empty());
+
+ var aggregate = new EntitySerializer().FromBundleContent(dto);
+
+ var attr = Assert.Single(Assert.Single(aggregate.Templates).Attributes);
+ Assert.Equal(DataType.Double, attr.DataType);
+ Assert.Equal("42.0", attr.Value);
+ }
+
[Fact]
public void Roundtrip_scalar_attribute_with_null_ElementDataType_remains_null()
{