diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/EquipmentTab.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/EquipmentTab.razor
index f185419..550b801 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/EquipmentTab.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/EquipmentTab.razor
@@ -12,6 +12,98 @@
+@* Five-identifier search — decision #117: ZTag / MachineCode / SAPID / EquipmentId / EquipmentUuid *@
+
+ Search equipment
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @if (_searchHits is not null)
+ {
+
+ }
+
+
+ @if (_searchError is not null)
+ {
+
@_searchError
+ }
+
+
+ @if (_searchHits is not null)
+ {
+ @if (_searchHits.Count == 0)
+ {
+ No matches.
+ }
+ else
+ {
+
+
+
+
+ | EquipmentId | Name | MachineCode | ZTag | SAPID |
+ Matched | Gen |
+
+
+
+ @foreach (var hit in _searchHits)
+ {
+
+ | @hit.Equipment.EquipmentId |
+ @hit.Equipment.Name |
+ @hit.Equipment.MachineCode |
+ @hit.Equipment.ZTag |
+ @hit.Equipment.SAPID |
+
+ @if (hit.MatchedField is not null)
+ {
+ var chipClass = hit.Score switch
+ {
+ 100 => "chip chip-ok",
+ 50 => "chip chip-warn",
+ _ => "chip chip-idle",
+ };
+ @hit.MatchedField
+ }
+ |
+
+ @if (hit.IsPublished)
+ { pub }
+ else
+ { draft }
+ |
+
+ }
+
+
+
+
+ @_searchHits.Count result@(_searchHits.Count == 1 ? "" : "s").
+ Exact = green, prefix = amber, fuzzy = grey.
+ Fuzzy matching requires the "Fuzzy" checkbox.
+
+ }
+ }
+
+
@if (_equipment is null)
{
Loading…
@@ -114,6 +206,41 @@ else if (_equipment.Count > 0)
private Equipment _draft = NewBlankDraft();
private string? _error;
+ // ── Five-identifier search ──────────────────────────────────────────
+ private string _searchQuery = string.Empty;
+ private bool _searchFuzzy;
+ private IReadOnlyList? _searchHits;
+ private bool _searchBusy;
+ private string? _searchError;
+
+ private async Task RunSearchAsync()
+ {
+ _searchError = null;
+ if (string.IsNullOrWhiteSpace(_searchQuery)) { _searchHits = null; return; }
+ _searchBusy = true;
+ try
+ {
+ _searchHits = await EquipmentSvc.SearchAsync(
+ _searchQuery, ClusterId, CancellationToken.None,
+ maxResults: 50, allowFuzzy: _searchFuzzy);
+ }
+ catch (Exception ex) { _searchError = ex.Message; }
+ finally { _searchBusy = false; }
+ }
+
+ private void ClearSearch()
+ {
+ _searchQuery = string.Empty;
+ _searchHits = null;
+ _searchError = null;
+ }
+
+ private async Task OnSearchKeyDown(KeyboardEventArgs e)
+ {
+ if (e.Key == "Enter") await RunSearchAsync();
+ }
+ // ───────────────────────────────────────────────────────────────────
+
private static Equipment NewBlankDraft() => new()
{
EquipmentId = string.Empty, DriverInstanceId = string.Empty,
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentService.cs
index ee93822..2d52e18 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentService.cs
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
@@ -18,6 +19,118 @@ public sealed class EquipmentService(OtOpcUaConfigDbContext db)
.OrderBy(e => e.Name)
.ToListAsync(ct);
+ ///
+ /// Five-identifier ranked search across a cluster (all draft + published generations).
+ /// Identifiers: ZTag, MachineCode, SAPID, EquipmentId, EquipmentUuid (decision #117).
+ /// Scoring: exact match on any identifier = 100, prefix match = 50, fuzzy (opt-in) = 20.
+ /// Tie-break: Published generation outranks Draft; within same status by Name ascending.
+ /// Returns at most rows.
+ ///
+ /// Search term (trimmed; empty returns empty results, not all rows).
+ /// Cluster to scope the search to.
+ /// Cap to prevent full-table dumps (default 50).
+ /// When true, LIKE-prefix suffix matches score 20 (opt-in per spec).
+ /// Cancellation token.
+ public async Task> SearchAsync(
+ string query,
+ string clusterId,
+ CancellationToken ct,
+ int maxResults = 50,
+ bool allowFuzzy = false)
+ {
+ ArgumentNullException.ThrowIfNull(clusterId);
+ query = query?.Trim() ?? string.Empty;
+ if (string.IsNullOrEmpty(query))
+ return [];
+
+ // Load candidates from DB — we filter generation to this cluster via the Join.
+ // The scoring is pure-LINQ post-load because EF InMemory doesn't support CASE WHEN scoring
+ // and the SQL-provider translation for this small set is acceptable (bounded by cluster).
+ var candidates = await db.Equipment.AsNoTracking()
+ .Join(db.ConfigGenerations.AsNoTracking(),
+ e => e.GenerationId,
+ g => g.GenerationId,
+ (e, g) => new { Equipment = e, Generation = g })
+ .Where(x => x.Generation.ClusterId == clusterId)
+ .Select(x => new
+ {
+ x.Equipment,
+ IsPublished = x.Generation.Status == GenerationStatus.Published,
+ })
+ .ToListAsync(ct)
+ .ConfigureAwait(false);
+
+ var lower = query.ToLowerInvariant();
+
+ var scored = candidates
+ .Select(c =>
+ {
+ var (score, matchedField) = ScoreEquipment(c.Equipment, lower, allowFuzzy);
+ return new
+ {
+ c.Equipment,
+ c.IsPublished,
+ Score = score,
+ MatchedField = matchedField,
+ };
+ })
+ .Where(x => x.Score > 0)
+ // Tie-break: highest score → published before draft → name
+ .OrderByDescending(x => x.Score)
+ .ThenByDescending(x => x.IsPublished ? 1 : 0)
+ .ThenBy(x => x.Equipment.Name)
+ .Take(maxResults)
+ .Select(x => new EquipmentSearchHit(x.Equipment, x.Score, x.MatchedField, x.IsPublished))
+ .ToList();
+
+ return scored;
+ }
+
+ /// Score one equipment row against the search term. Returns (score, matchedFieldName).
+ private static (int Score, string? MatchedField) ScoreEquipment(Equipment e, string lower, bool allowFuzzy)
+ {
+ // Evaluate each identifier in priority order — first exact match wins with score 100.
+ var identifiers = new (string FieldName, string? Value)[]
+ {
+ ("ZTag", e.ZTag),
+ ("MachineCode", e.MachineCode),
+ ("SAPID", e.SAPID),
+ ("EquipmentId", e.EquipmentId),
+ ("EquipmentUuid", e.EquipmentUuid == Guid.Empty ? null : e.EquipmentUuid.ToString()),
+ };
+
+ foreach (var (fieldName, value) in identifiers)
+ {
+ if (string.IsNullOrEmpty(value)) continue;
+ var v = value.ToLowerInvariant();
+ if (v == lower)
+ return (100, fieldName);
+ }
+
+ // Prefix match — score 50
+ foreach (var (fieldName, value) in identifiers)
+ {
+ if (string.IsNullOrEmpty(value)) continue;
+ var v = value.ToLowerInvariant();
+ if (v.StartsWith(lower, StringComparison.Ordinal))
+ return (50, fieldName);
+ }
+
+ // Fuzzy (substring) match — score 20, opt-in only
+ if (allowFuzzy)
+ {
+ foreach (var (fieldName, value) in identifiers)
+ {
+ if (string.IsNullOrEmpty(value)) continue;
+ var v = value.ToLowerInvariant();
+ if (v.Contains(lower, StringComparison.Ordinal))
+ return (20, fieldName);
+ }
+ }
+
+ return (0, null);
+ }
+
public Task FindAsync(long generationId, string equipmentId, CancellationToken ct) =>
db.Equipment.AsNoTracking()
.FirstOrDefaultAsync(e => e.GenerationId == generationId && e.EquipmentId == equipmentId, ct);
@@ -73,3 +186,14 @@ public sealed class EquipmentService(OtOpcUaConfigDbContext db)
await db.SaveChangesAsync(ct);
}
}
+
+/// One hit from .
+/// The matched equipment row.
+/// Match score: 100 = exact, 50 = prefix, 20 = fuzzy.
+/// Which identifier field produced the highest score.
+/// True when the row is from a published generation (aids tie-break display).
+public sealed record EquipmentSearchHit(
+ Equipment Equipment,
+ int Score,
+ string? MatchedField,
+ bool IsPublished);
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentSearchTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentSearchTests.cs
new file mode 100644
index 0000000..c20be68
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentSearchTests.cs
@@ -0,0 +1,280 @@
+using Microsoft.EntityFrameworkCore;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Admin.Services;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+
+namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
+
+///
+/// Unit tests for the Phase 6.4 Stream B.5 five-identifier ranked search.
+/// Decision #117 identifiers: ZTag / MachineCode / SAPID / EquipmentId / EquipmentUuid.
+/// Scoring: exact match = 100, prefix = 50, fuzzy (opt-in) = 20.
+/// Tie-break: published generation outranks draft.
+///
+[Trait("Category", "Unit")]
+public sealed class EquipmentSearchTests : IDisposable
+{
+ private readonly OtOpcUaConfigDbContext _db;
+ private readonly EquipmentService _svc;
+
+ private const string ClusterId = "cluster-1";
+ private const long DraftGenId = 1L;
+ private const long PublishedGenId = 2L;
+
+ public EquipmentSearchTests()
+ {
+ var opts = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase($"eq-search-{Guid.NewGuid():N}")
+ .Options;
+ _db = new OtOpcUaConfigDbContext(opts);
+
+ // Seed two generations — draft + published — for the same cluster.
+ _db.ConfigGenerations.AddRange(
+ new ConfigGeneration
+ {
+ GenerationId = DraftGenId,
+ ClusterId = ClusterId,
+ Status = GenerationStatus.Draft,
+ CreatedBy = "test",
+ },
+ new ConfigGeneration
+ {
+ GenerationId = PublishedGenId,
+ ClusterId = ClusterId,
+ Status = GenerationStatus.Published,
+ CreatedBy = "test",
+ PublishedAt = DateTime.UtcNow,
+ PublishedBy = "test",
+ });
+ _db.SaveChanges();
+
+ _svc = new EquipmentService(_db);
+ }
+
+ public void Dispose() => _db.Dispose();
+
+ // ── Helpers ──────────────────────────────────────────────────────────
+
+ private Equipment AddEquipment(
+ long generationId,
+ string name,
+ string ztag,
+ string machineCode = "MC",
+ string sapid = "",
+ Guid? uuid = null,
+ string equipmentId = "")
+ {
+ var uu = uuid ?? Guid.NewGuid();
+ var eq = new Equipment
+ {
+ EquipmentRowId = Guid.NewGuid(),
+ GenerationId = generationId,
+ EquipmentId = string.IsNullOrEmpty(equipmentId) ? $"EQ-{uu:N}"[..14] : equipmentId,
+ EquipmentUuid = uu,
+ DriverInstanceId = "drv",
+ UnsLineId = "line-1",
+ Name = name,
+ MachineCode = machineCode,
+ ZTag = ztag,
+ SAPID = string.IsNullOrEmpty(sapid) ? null : sapid,
+ };
+ _db.Equipment.Add(eq);
+ _db.SaveChanges();
+ return eq;
+ }
+
+ // ── Exact-match tests (score 100) ────────────────────────────────────
+
+ [Fact]
+ public async Task ExactMatch_ZTag_Returns_Score100()
+ {
+ AddEquipment(DraftGenId, "Oven-A", ztag: "z-001");
+
+ var hits = await _svc.SearchAsync("z-001", ClusterId, TestContext.Current.CancellationToken);
+
+ hits.Count.ShouldBe(1);
+ hits[0].Score.ShouldBe(100);
+ hits[0].MatchedField.ShouldBe("ZTag");
+ hits[0].Equipment.Name.ShouldBe("Oven-A");
+ }
+
+ [Fact]
+ public async Task ExactMatch_IsCaseInsensitive()
+ {
+ AddEquipment(DraftGenId, "Welder-1", ztag: "Z-ABC");
+
+ var hits = await _svc.SearchAsync("z-abc", ClusterId, TestContext.Current.CancellationToken);
+
+ hits.Count.ShouldBe(1);
+ hits[0].Score.ShouldBe(100);
+ }
+
+ [Fact]
+ public async Task ExactMatch_MachineCode_Returns_Score100()
+ {
+ AddEquipment(DraftGenId, "Wrapper", ztag: "z-2", machineCode: "MC-42");
+
+ var hits = await _svc.SearchAsync("MC-42", ClusterId, TestContext.Current.CancellationToken);
+
+ hits.Count.ShouldBe(1);
+ hits[0].Score.ShouldBe(100);
+ hits[0].MatchedField.ShouldBe("MachineCode");
+ }
+
+ [Fact]
+ public async Task ExactMatch_SAPID_Returns_Score100()
+ {
+ AddEquipment(DraftGenId, "Conveyor", ztag: "z-3", sapid: "SAP-999");
+
+ var hits = await _svc.SearchAsync("SAP-999", ClusterId, TestContext.Current.CancellationToken);
+
+ hits.Count.ShouldBe(1);
+ hits[0].Score.ShouldBe(100);
+ hits[0].MatchedField.ShouldBe("SAPID");
+ }
+
+ [Fact]
+ public async Task ExactMatch_EquipmentUuid_Returns_Score100()
+ {
+ var uu = Guid.NewGuid();
+ AddEquipment(DraftGenId, "Robot-A", ztag: "z-4", uuid: uu);
+
+ var hits = await _svc.SearchAsync(uu.ToString(), ClusterId, TestContext.Current.CancellationToken);
+
+ hits.Count.ShouldBe(1);
+ hits[0].Score.ShouldBe(100);
+ hits[0].MatchedField.ShouldBe("EquipmentUuid");
+ }
+
+ // ── Prefix-match tests (score 50) ────────────────────────────────────
+
+ [Fact]
+ public async Task PrefixMatch_ZTag_Returns_Score50()
+ {
+ AddEquipment(DraftGenId, "Press-1", ztag: "z-alpha-001");
+
+ var hits = await _svc.SearchAsync("z-alpha", ClusterId, TestContext.Current.CancellationToken);
+
+ hits.Count.ShouldBe(1);
+ hits[0].Score.ShouldBe(50);
+ hits[0].MatchedField.ShouldBe("ZTag");
+ }
+
+ [Fact]
+ public async Task ExactOutranks_Prefix_InResults()
+ {
+ // exact: z-001 == "z-001" → score 100
+ // prefix: z-001x startsWith "z-001" → score 50
+ AddEquipment(DraftGenId, "Exact-Hit", ztag: "z-001");
+ AddEquipment(DraftGenId, "Prefix-Hit", ztag: "z-001x");
+
+ var hits = await _svc.SearchAsync("z-001", ClusterId, TestContext.Current.CancellationToken);
+
+ hits.Count.ShouldBe(2);
+ hits[0].Score.ShouldBe(100);
+ hits[0].Equipment.Name.ShouldBe("Exact-Hit");
+ hits[1].Score.ShouldBe(50);
+ hits[1].Equipment.Name.ShouldBe("Prefix-Hit");
+ }
+
+ // ── Fuzzy-match tests (score 20, opt-in) ─────────────────────────────
+
+ [Fact]
+ public async Task FuzzyMatch_Disabled_DoesNotReturn_SubstringOnly_Hit()
+ {
+ AddEquipment(DraftGenId, "SubstrEq", ztag: "prefix-INFIX-suffix");
+
+ var hits = await _svc.SearchAsync("INFIX", ClusterId, TestContext.Current.CancellationToken, allowFuzzy: false);
+
+ hits.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task FuzzyMatch_Enabled_Returns_Score20()
+ {
+ AddEquipment(DraftGenId, "SubstrEq", ztag: "prefix-infix-suffix");
+
+ var hits = await _svc.SearchAsync("infix", ClusterId, TestContext.Current.CancellationToken, allowFuzzy: true);
+
+ hits.Count.ShouldBe(1);
+ hits[0].Score.ShouldBe(20);
+ hits[0].MatchedField.ShouldBe("ZTag");
+ }
+
+ // ── Tie-break: published outranks draft ───────────────────────────────
+
+ [Fact]
+ public async Task PublishedGeneration_Outranks_Draft_ForEqualScore()
+ {
+ // Same ZTag prefix "mc-" in both draft + published generation.
+ AddEquipment(DraftGenId, "Draft-Eq", ztag: "mc-001");
+ AddEquipment(PublishedGenId, "Published-Eq", ztag: "mc-002");
+
+ // Both hit prefix match on "mc-" (score 50).
+ var hits = await _svc.SearchAsync("mc-", ClusterId, TestContext.Current.CancellationToken);
+
+ hits.Count.ShouldBe(2);
+ // Published generation should come first.
+ hits[0].IsPublished.ShouldBeTrue();
+ hits[1].IsPublished.ShouldBeFalse();
+ }
+
+ // ── Empty / no-match ─────────────────────────────────────────────────
+
+ [Fact]
+ public async Task EmptyQuery_Returns_EmptyList()
+ {
+ AddEquipment(DraftGenId, "Irrelevant", ztag: "z-999");
+
+ var hits = await _svc.SearchAsync(" ", ClusterId, TestContext.Current.CancellationToken);
+
+ hits.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task NoMatch_Returns_EmptyList()
+ {
+ AddEquipment(DraftGenId, "Irrelevant", ztag: "z-999");
+
+ var hits = await _svc.SearchAsync("xyzzy-unknown", ClusterId, TestContext.Current.CancellationToken);
+
+ hits.ShouldBeEmpty();
+ }
+
+ // ── Cross-cluster isolation ───────────────────────────────────────────
+
+ [Fact]
+ public async Task Equipment_In_DifferentCluster_NotReturned()
+ {
+ // Seed a generation in a different cluster.
+ _db.ConfigGenerations.Add(new ConfigGeneration
+ {
+ GenerationId = 99L,
+ ClusterId = "cluster-other",
+ Status = GenerationStatus.Draft,
+ CreatedBy = "test",
+ });
+ _db.SaveChanges();
+ AddEquipment(99L, "OtherEq", ztag: "z-001");
+
+ var hits = await _svc.SearchAsync("z-001", ClusterId, TestContext.Current.CancellationToken);
+
+ hits.ShouldBeEmpty("equipment from another cluster must be invisible");
+ }
+
+ // ── MaxResults cap ────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task MaxResults_Limits_Output()
+ {
+ for (var i = 0; i < 10; i++)
+ AddEquipment(DraftGenId, $"Eq-{i}", ztag: $"zprefix-{i:D3}");
+
+ var hits = await _svc.SearchAsync("zprefix-", ClusterId, TestContext.Current.CancellationToken, maxResults: 3);
+
+ hits.Count.ShouldBe(3);
+ }
+}