feat(template-engine): resolve TemplateEngine-002 — per-slot alarm override for derived templates
Adds IsInherited/LockedInDerived to the TemplateAlarm entity (mirroring the attribute/script override model), an EF migration, base-alarm copy-on-derive, inherited-alarm flattening skip, and LockedInDerived override-rejection validation.
This commit is contained in:
@@ -14,6 +14,21 @@ public class TemplateAlarm
|
||||
public string? TriggerConfiguration { get; set; }
|
||||
public int? OnTriggerScriptId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when this row was copied from the base template and has not been
|
||||
/// overridden on the derived template. Changes to the base flow downward
|
||||
/// for inherited rows; an explicit override flips this to false.
|
||||
/// Always false on base (non-derived) templates.
|
||||
/// </summary>
|
||||
public bool IsInherited { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Set on a base alarm. When true, derived templates may not override the
|
||||
/// alarm — the row is rendered readonly with a 🔒 in the derived UI, and
|
||||
/// any attempt to update it through the API is rejected.
|
||||
/// </summary>
|
||||
public bool LockedInDerived { get; set; }
|
||||
|
||||
public TemplateAlarm(string name)
|
||||
{
|
||||
Name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
|
||||
1348
src/ScadaLink.ConfigurationDatabase/Migrations/20260517000628_AddDerivedAlarmFields.Designer.cs
generated
Normal file
1348
src/ScadaLink.ConfigurationDatabase/Migrations/20260517000628_AddDerivedAlarmFields.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDerivedAlarmFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsInherited",
|
||||
table: "TemplateAlarms",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "LockedInDerived",
|
||||
table: "TemplateAlarms",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsInherited",
|
||||
table: "TemplateAlarms");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LockedInDerived",
|
||||
table: "TemplateAlarms");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -917,9 +917,15 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
|
||||
b.Property<bool>("IsInherited")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsLocked")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("LockedInDerived")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
|
||||
@@ -180,13 +180,14 @@ public class FlatteningService
|
||||
|
||||
/// <summary>
|
||||
/// Reports any LockedInDerived violations across the chain — i.e., a base
|
||||
/// attribute/script marked LockedInDerived that a downstream derived
|
||||
/// attribute/alarm/script marked LockedInDerived that a downstream derived
|
||||
/// template overrides (IsInherited=false). Returns null on success or an
|
||||
/// error message describing the first offending entries.
|
||||
/// </summary>
|
||||
private static string? ValidateLockedInDerived(IReadOnlyList<Template> templateChain)
|
||||
{
|
||||
var attrLocks = new Dictionary<string, Template>(StringComparer.Ordinal);
|
||||
var alarmLocks = new Dictionary<string, Template>(StringComparer.Ordinal);
|
||||
var scriptLocks = new Dictionary<string, Template>(StringComparer.Ordinal);
|
||||
var errors = new List<string>();
|
||||
|
||||
@@ -202,6 +203,14 @@ public class FlatteningService
|
||||
errors.Add($"Attribute '{attr.Name}' is LockedInDerived by base template '{lockingTemplate.Name}' and cannot be overridden by '{template.Name}'.");
|
||||
}
|
||||
|
||||
foreach (var alarm in template.Alarms)
|
||||
{
|
||||
if (alarm.LockedInDerived)
|
||||
alarmLocks[alarm.Name] = template;
|
||||
else if (!alarm.IsInherited && alarmLocks.TryGetValue(alarm.Name, out var lockingTemplate) && lockingTemplate.Id != template.Id)
|
||||
errors.Add($"Alarm '{alarm.Name}' is LockedInDerived by base template '{lockingTemplate.Name}' and cannot be overridden by '{template.Name}'.");
|
||||
}
|
||||
|
||||
foreach (var script in template.Scripts)
|
||||
{
|
||||
if (script.LockedInDerived)
|
||||
@@ -385,8 +394,16 @@ public class FlatteningService
|
||||
|
||||
foreach (var alarm in template.Alarms)
|
||||
{
|
||||
if (result.TryGetValue(alarm.Name, out var existing) && existing.IsLocked)
|
||||
continue;
|
||||
if (result.TryGetValue(alarm.Name, out var existing))
|
||||
{
|
||||
if (existing.IsLocked)
|
||||
continue;
|
||||
// IsInherited rows on a derived template are placeholders
|
||||
// that must not shadow the live base alarm; they only
|
||||
// contribute a row when the base lacks one.
|
||||
if (alarm.IsInherited)
|
||||
continue;
|
||||
}
|
||||
|
||||
// HiLo per-setpoint override: derived templates can supply a
|
||||
// partial TriggerConfiguration (e.g., just `hi`) and have the
|
||||
|
||||
@@ -398,6 +398,16 @@ public class TemplateService
|
||||
if (parentMember != null && parentMember.IsLocked)
|
||||
return Result<TemplateAlarm>.Failure(
|
||||
$"Alarm '{existing.Name}' is locked in parent and cannot be overridden.");
|
||||
|
||||
// Derived templates may not override alarms the base marked LockedInDerived.
|
||||
if (template.IsDerived)
|
||||
{
|
||||
var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
|
||||
var baseAlarm = baseTemplate?.Alarms.FirstOrDefault(a => a.Name == existing.Name);
|
||||
if (baseAlarm != null && baseAlarm.LockedInDerived)
|
||||
return Result<TemplateAlarm>.Failure(
|
||||
$"Alarm '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate fixed fields
|
||||
@@ -411,6 +421,10 @@ public class TemplateService
|
||||
existing.Description = proposed.Description;
|
||||
existing.OnTriggerScriptId = proposed.OnTriggerScriptId;
|
||||
existing.IsLocked = proposed.IsLocked;
|
||||
if (template?.IsDerived == true)
|
||||
existing.IsInherited = proposed.IsInherited;
|
||||
else
|
||||
existing.LockedInDerived = proposed.LockedInDerived;
|
||||
// Name and TriggerType are NOT updated (fixed)
|
||||
|
||||
await _repository.UpdateTemplateAlarmAsync(existing, cancellationToken);
|
||||
@@ -818,6 +832,21 @@ public class TemplateService
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var alarm in baseTemplate.Alarms)
|
||||
{
|
||||
derived.Alarms.Add(new TemplateAlarm(alarm.Name)
|
||||
{
|
||||
Description = alarm.Description,
|
||||
PriorityLevel = alarm.PriorityLevel,
|
||||
IsLocked = alarm.IsLocked,
|
||||
TriggerType = alarm.TriggerType,
|
||||
TriggerConfiguration = alarm.TriggerConfiguration,
|
||||
OnTriggerScriptId = alarm.OnTriggerScriptId,
|
||||
IsInherited = true,
|
||||
LockedInDerived = false,
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var script in baseTemplate.Scripts)
|
||||
{
|
||||
derived.Scripts.Add(new TemplateScript(script.Name, script.Code)
|
||||
|
||||
Reference in New Issue
Block a user