diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentInput.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentInput.cs new file mode 100644 index 00000000..bccea960 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentInput.cs @@ -0,0 +1,43 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Parameter object carrying the operator-editable fields for an equipment create or update, +/// so and +/// avoid an unwieldy positional signature. +/// The EquipmentId and EquipmentUuid are system-generated (decision #125) and are +/// therefore not part of this input. Optional string fields that arrive whitespace-only are +/// collapsed to null by the service. +/// +/// UNS level-5 segment; matches ^[a-z0-9-]{1,32}$. +/// Operator colloquial id; unique fleet-wide. +/// Logical FK to the owning . +/// Optional driver binding; whitespace/empty means driver-less. +/// Optional ERP equipment id. +/// Optional SAP PM equipment id. +/// Optional OPC 40010 manufacturer name. +/// Optional OPC 40010 model designation. +/// Optional OPC 40010 serial number. +/// Optional OPC 40010 hardware revision. +/// Optional OPC 40010 software revision. +/// Optional OPC 40010 year of construction. +/// Optional OPC 40010 asset location. +/// Optional OPC 40010 manufacturer URI. +/// Optional OPC 40010 device-manual URI. +/// Whether the equipment is surfaced in deployments. +public sealed record EquipmentInput( + string Name, + string MachineCode, + string UnsLineId, + string? DriverInstanceId, + string? ZTag, + string? SAPID, + string? Manufacturer, + string? Model, + string? SerialNumber, + string? HardwareRevision, + string? SoftwareRevision, + short? YearOfConstruction, + string? AssetLocation, + string? ManufacturerUri, + string? DeviceManualUri, + bool Enabled); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs index b3ba077b..3d310b7d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs @@ -100,4 +100,40 @@ public interface IUnsTreeService /// A token to cancel the operation. /// Success, a concurrency failure, or a delete-failed failure. Task DeleteLineAsync(string unsLineId, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Creates a new equipment under a UNS line. The EquipmentId is system-generated + /// (decision #125: EQ- + the first 12 hex chars of a fresh EquipmentUuid). + /// Fails if the line is unset, if the MachineCode is already used fleet-wide, or if the + /// decision-#122 driver-cluster guard trips. Whitespace-only DriverInstanceId/ZTag/SAPID + /// collapse to null. + /// + /// The operator-editable equipment fields. + /// A token to cancel the operation. + /// Success, a missing-line failure, a duplicate-MachineCode failure, or a #122 guard failure. + Task CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default); + + /// + /// Updates an equipment's mutable fields (driver binding, line, name, MachineCode, external + /// ids, and the OPC 40010 identification fields). The decision-#122 driver-cluster guard blocks + /// binding to a driver in a different cluster than the equipment's line. Uses last-write-wins + /// optimistic concurrency on . + /// + /// The equipment to update. + /// The new operator-editable equipment fields. + /// The concurrency token the caller last read. + /// A token to cancel the operation. + /// Success, a missing-row failure, a #122 guard failure, or a concurrency failure. + Task UpdateEquipmentAsync(string equipmentId, EquipmentInput input, byte[] rowVersion, CancellationToken ct = default); + + /// + /// Deletes an equipment. A missing row is treated as success (already gone). Uses last-write-wins + /// optimistic concurrency; a delete that fails because tags or virtual tags still reference the + /// equipment surfaces a guidance message. + /// + /// The equipment to delete. + /// The concurrency token the caller last read. + /// A token to cancel the operation. + /// Success, a concurrency failure, or a delete-failed failure. + Task DeleteEquipmentAsync(string equipmentId, byte[] rowVersion, CancellationToken ct = default); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs index ce1bfc82..54406e2c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -336,4 +336,171 @@ public sealed class UnsTreeService(IDbContextFactory dbF return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because equipment still references this line — remove or re-home it first."); } } + + /// + public async Task CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(input.UnsLineId)) + { + return new UnsMutationResult(false, "Pick a UNS line."); + } + + await using var db = await dbFactory.CreateDbContextAsync(ct); + + if (await db.Equipment.AnyAsync(e => e.MachineCode == input.MachineCode, ct)) + { + return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet."); + } + + var guard = await CheckDriverClusterGuardAsync(db, input, ct); + if (guard is not null) + { + return guard.Value; + } + + var uuid = Guid.NewGuid(); + var equipmentId = $"EQ-{uuid.ToString("N")[..12]}"; + + db.Equipment.Add(new Equipment + { + EquipmentId = equipmentId, + EquipmentUuid = uuid, + DriverInstanceId = string.IsNullOrWhiteSpace(input.DriverInstanceId) ? null : input.DriverInstanceId, + UnsLineId = input.UnsLineId, + Name = input.Name, + MachineCode = input.MachineCode, + ZTag = string.IsNullOrWhiteSpace(input.ZTag) ? null : input.ZTag, + SAPID = string.IsNullOrWhiteSpace(input.SAPID) ? null : input.SAPID, + Manufacturer = input.Manufacturer, + Model = input.Model, + SerialNumber = input.SerialNumber, + HardwareRevision = input.HardwareRevision, + SoftwareRevision = input.SoftwareRevision, + YearOfConstruction = input.YearOfConstruction, + AssetLocation = input.AssetLocation, + ManufacturerUri = input.ManufacturerUri, + DeviceManualUri = input.DeviceManualUri, + Enabled = input.Enabled, + }); + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + + /// + public async Task UpdateEquipmentAsync( + string equipmentId, + EquipmentInput input, + byte[] rowVersion, + CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == equipmentId, ct); + if (entity is null) + { + return new UnsMutationResult(false, "Row no longer exists."); + } + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + + var guard = await CheckDriverClusterGuardAsync(db, input, ct); + if (guard is not null) + { + return guard.Value; + } + + entity.DriverInstanceId = string.IsNullOrWhiteSpace(input.DriverInstanceId) ? null : input.DriverInstanceId; + entity.UnsLineId = input.UnsLineId; + entity.Name = input.Name; + entity.MachineCode = input.MachineCode; + entity.ZTag = string.IsNullOrWhiteSpace(input.ZTag) ? null : input.ZTag; + entity.SAPID = string.IsNullOrWhiteSpace(input.SAPID) ? null : input.SAPID; + entity.Manufacturer = input.Manufacturer; + entity.Model = input.Model; + entity.SerialNumber = input.SerialNumber; + entity.HardwareRevision = input.HardwareRevision; + entity.SoftwareRevision = input.SoftwareRevision; + entity.YearOfConstruction = input.YearOfConstruction; + entity.AssetLocation = input.AssetLocation; + entity.ManufacturerUri = input.ManufacturerUri; + entity.DeviceManualUri = input.DeviceManualUri; + entity.Enabled = input.Enabled; + + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (DbUpdateConcurrencyException) + { + return new UnsMutationResult(false, "Another user changed this equipment while you were editing."); + } + } + + /// + public async Task DeleteEquipmentAsync( + string equipmentId, + byte[] rowVersion, + CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == equipmentId, ct); + if (entity is null) + { + return new UnsMutationResult(true, null); + } + + db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion; + db.Equipment.Remove(entity); + + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (DbUpdateConcurrencyException) + { + return new UnsMutationResult(false, "Another user changed this equipment while you were viewing it."); + } + catch (Exception ex) + { + return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because tags or virtual tags reference this equipment — remove them first."); + } + } + + /// + /// Decision #122: an equipment may only bind to a driver in the same cluster as its line. + /// Only runs when a driver is requested. Resolves the line's cluster (line → area → cluster) + /// and the driver's cluster; when both are known and differ, returns the guard failure. + /// Returns null when the bind is allowed (driver-less, unresolvable cluster, or match). + /// + private static async Task CheckDriverClusterGuardAsync( + OtOpcUaConfigDbContext db, + EquipmentInput input, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(input.DriverInstanceId)) + { + return null; + } + + var line = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == input.UnsLineId, ct); + var area = line is null ? null : await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == line.UnsAreaId, ct); + var lineCluster = area?.ClusterId; + + var driverCluster = await db.DriverInstances + .Where(d => d.DriverInstanceId == input.DriverInstanceId) + .Select(d => d.ClusterId) + .FirstOrDefaultAsync(ct); + + if (driverCluster is not null && lineCluster is not null && driverCluster != lineCluster) + { + return new UnsMutationResult( + false, + $"Driver '{input.DriverInstanceId}' is in cluster '{driverCluster}' but the line is in cluster '{lineCluster}' (decision #122)."); + } + + return null; + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentTests.cs new file mode 100644 index 00000000..bb7ee02f --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceEquipmentTests.cs @@ -0,0 +1,277 @@ +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Verifies the Equipment CRUD mutations on , including the +/// system-generated EQ- id, fleet-wide MachineCode uniqueness, and the decision-#122 +/// driver-cluster guard that blocks binding equipment to a driver in a different cluster than +/// the equipment's line. +/// +/// +/// The EF InMemory provider does not enforce RowVersion concurrency, so the +/// DbUpdateConcurrencyException branches are not exercised here by design. +/// +[Trait("Category", "Unit")] +public sealed class UnsTreeServiceEquipmentTests +{ + private static (UnsTreeService Service, string DbName) Fresh() + { + var dbName = $"uns-equip-{Guid.NewGuid():N}"; + return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName); + } + + /// + /// Seeds a line under an area in , plus an optional driver in + /// . The line id is always LINE-1; the driver (when + /// requested) is always DRV-1. + /// + private static void SeedLineAndDriver(string dbName, string lineCluster, string? driverCluster) + { + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = lineCluster, Name = "a" }); + db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" }); + if (driverCluster is not null) + { + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = "DRV-1", + ClusterId = driverCluster, + NamespaceId = "NS-1", + Name = "drv", + DriverType = "ModbusTcp", + DriverConfig = "{}", + }); + } + db.SaveChanges(); + } + + private static EquipmentInput Input(string name, string machineCode, string unsLineId, string? driverInstanceId) => + new(name, machineCode, unsLineId, driverInstanceId, + ZTag: null, SAPID: null, Manufacturer: null, Model: null, SerialNumber: null, + HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: null, + AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: true); + + // ----- CreateEquipment ----- + + /// A new equipment gets a system-generated EQ- id (EQ- + 12 hex chars) and persists. + [Fact] + public async Task CreateEquipment_generates_EQ_id_and_persists() + { + var (service, dbName) = Fresh(); + SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null); + + var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null)); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + + using var db = UnsTreeTestDb.CreateNamed(dbName); + var eq = db.Equipment.Single(e => e.MachineCode == "machine_001"); + eq.EquipmentId.ShouldStartWith("EQ-"); + eq.EquipmentId.Length.ShouldBe(15); // "EQ-" + 12 hex chars + eq.Name.ShouldBe("machine-1"); + eq.UnsLineId.ShouldBe("LINE-1"); + eq.Enabled.ShouldBeTrue(); + } + + /// Creating equipment with a MachineCode already in the fleet is blocked. + [Fact] + public async Task CreateEquipment_duplicate_machinecode_blocked() + { + var (service, dbName) = Fresh(); + SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null); + await service.CreateEquipmentAsync(Input("machine-1", "machine_dup", "LINE-1", null)); + + var result = await service.CreateEquipmentAsync(Input("machine-2", "machine_dup", "LINE-1", null)); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldBe("MachineCode 'machine_dup' already exists in this fleet."); + } + + /// Creating equipment with no UNS line returns the pick-a-line error. + [Fact] + public async Task CreateEquipment_missing_line_blocked() + { + var (service, _) = Fresh(); + + var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "", null)); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldBe("Pick a UNS line."); + } + + /// The #122 guard blocks binding equipment to a driver in a different cluster than the line. + [Fact] + public async Task CreateEquipment_driver_in_other_cluster_blocked() + { + var (service, dbName) = Fresh(); + SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "SITE-A"); + + var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", "DRV-1")); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldNotBeNull(); + result.Error.ShouldContain("decision #122"); + result.Error.ShouldContain("DRV-1"); + result.Error.ShouldContain("SITE-A"); + result.Error.ShouldContain("MAIN"); + + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.Equipment.Any(e => e.MachineCode == "machine_001").ShouldBeFalse(); + } + + /// Binding equipment to a driver in the same cluster as the line is allowed. + [Fact] + public async Task CreateEquipment_driver_in_same_cluster_allowed() + { + var (service, dbName) = Fresh(); + SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "MAIN"); + + var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", "DRV-1")); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.Equipment.Single(e => e.MachineCode == "machine_001").DriverInstanceId.ShouldBe("DRV-1"); + } + + /// Driver-less equipment is allowed regardless of cluster (no #122 guard applies). + [Fact] + public async Task CreateEquipment_driverless_allowed() + { + var (service, dbName) = Fresh(); + SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null); + + var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null)); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.Equipment.Single(e => e.MachineCode == "machine_001").DriverInstanceId.ShouldBeNull(); + } + + // ----- UpdateEquipment ----- + + /// Updating equipment changes its mutable fields (name, MachineCode, a 40010 field). + [Fact] + public async Task UpdateEquipment_changes_fields() + { + var (service, dbName) = Fresh(); + SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null); + await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null)); + + string equipmentId; + byte[] rv; + using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + var eq = db.Equipment.Single(e => e.MachineCode == "machine_001"); + equipmentId = eq.EquipmentId; + rv = eq.RowVersion; + } + + var updated = new EquipmentInput("machine-renamed", "machine_renamed", "LINE-1", null, + ZTag: null, SAPID: null, Manufacturer: "Acme", Model: null, SerialNumber: null, + HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: (short)2021, + AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: false); + + var result = await service.UpdateEquipmentAsync(equipmentId, updated, rv); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + + using var verify = UnsTreeTestDb.CreateNamed(dbName); + var after = verify.Equipment.Single(e => e.EquipmentId == equipmentId); + after.Name.ShouldBe("machine-renamed"); + after.MachineCode.ShouldBe("machine_renamed"); + after.Manufacturer.ShouldBe("Acme"); + after.YearOfConstruction.ShouldBe((short)2021); + after.Enabled.ShouldBeFalse(); + } + + /// Updating equipment that no longer exists returns the row-gone error. + [Fact] + public async Task UpdateEquipment_missing_row_returns_error() + { + var (service, _) = Fresh(); + + var result = await service.UpdateEquipmentAsync("EQ-nope", Input("n", "mc", "LINE-1", null), []); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldBe("Row no longer exists."); + } + + /// The #122 guard blocks an update that binds equipment to a driver in another cluster. + [Fact] + public async Task UpdateEquipment_driver_in_other_cluster_blocked() + { + var (service, dbName) = Fresh(); + SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "SITE-A"); + await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null)); + + string equipmentId; + byte[] rv; + using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + var eq = db.Equipment.Single(e => e.MachineCode == "machine_001"); + equipmentId = eq.EquipmentId; + rv = eq.RowVersion; + } + + var result = await service.UpdateEquipmentAsync( + equipmentId, Input("machine-1", "machine_001", "LINE-1", "DRV-1"), rv); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldNotBeNull(); + result.Error.ShouldContain("decision #122"); + + using var verify = UnsTreeTestDb.CreateNamed(dbName); + verify.Equipment.Single(e => e.EquipmentId == equipmentId).DriverInstanceId.ShouldBeNull(); + } + + // ----- DeleteEquipment ----- + + /// Deleting equipment removes the row. + [Fact] + public async Task DeleteEquipment_removes_row() + { + var (service, dbName) = Fresh(); + SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null); + await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null)); + + string equipmentId; + byte[] rv; + using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + var eq = db.Equipment.Single(e => e.MachineCode == "machine_001"); + equipmentId = eq.EquipmentId; + rv = eq.RowVersion; + } + + var result = await service.DeleteEquipmentAsync(equipmentId, rv); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + + using var verify = UnsTreeTestDb.CreateNamed(dbName); + verify.Equipment.Any(e => e.EquipmentId == equipmentId).ShouldBeFalse(); + } + + /// Deleting equipment that is already gone is a no-op success. + [Fact] + public async Task DeleteEquipment_already_gone_returns_ok() + { + var (service, _) = Fresh(); + + var result = await service.DeleteEquipmentAsync("EQ-ghost", []); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + } +}