feat(admin): add five-identifier ranked equipment search (Phase 6.4 Stream B.5)

Implements the missing Stream B.5 search from the Phase 6.4 plan:
- EquipmentService.SearchAsync scopes to a cluster, scores hits across
  ZTag / MachineCode / SAPID / EquipmentId / EquipmentUuid (decision #117):
  exact = 100, prefix = 50, fuzzy (opt-in) = 20; published generation
  outranks draft on equal scores per spec.
- EquipmentSearchHit record carries Score + MatchedField for badge display.
- EquipmentTab.razor gains a search panel with per-row matched-field chips
  (green exact, amber prefix, grey fuzzy) and fuzzy opt-in checkbox.
- 14 new unit tests in EquipmentSearchTests.cs (Category=Unit) cover exact,
  prefix, fuzzy, case-insensitivity, tie-break, cross-cluster isolation, and
  maxResults cap; all 148 admin unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-18 04:35:02 -04:00
parent 70d7166a39
commit bb1854b2f8
3 changed files with 531 additions and 0 deletions

View File

@@ -12,6 +12,98 @@
</div>
</div>
@* Five-identifier search — decision #117: ZTag / MachineCode / SAPID / EquipmentId / EquipmentUuid *@
<section class="panel rise mb-3" style="animation-delay:.02s">
<div class="panel-head">Search equipment</div>
<div class="p-3">
<div class="row g-2 align-items-end">
<div class="col-md-6">
<label class="form-label small mb-1">
Search by ZTag, MachineCode, SAPID, EquipmentId, or EquipmentUuid
</label>
<input class="form-control form-control-sm"
placeholder="e.g. z-001 or MC-42 or SAP-…"
@bind="_searchQuery"
@bind:event="oninput"
@onkeydown="OnSearchKeyDown"/>
</div>
<div class="col-auto">
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" id="fuzzyCheck" @bind="_searchFuzzy"/>
<label class="form-check-label small" for="fuzzyCheck">Fuzzy (substring)</label>
</div>
</div>
<div class="col-auto">
<button class="btn btn-sm btn-outline-secondary" @onclick="RunSearchAsync" disabled="@_searchBusy">Search</button>
@if (_searchHits is not null)
{
<button class="btn btn-sm btn-link ms-1" @onclick="ClearSearch">Clear</button>
}
</div>
</div>
@if (_searchError is not null)
{
<p class="small text-danger mt-2 mb-0">@_searchError</p>
}
</div>
@if (_searchHits is not null)
{
@if (_searchHits.Count == 0)
{
<p class="p-3 text-muted small mb-0">No matches.</p>
}
else
{
<div class="table-wrap" style="max-height: 340px; overflow-y: auto;">
<table class="data-table">
<thead>
<tr>
<th>EquipmentId</th><th>Name</th><th>MachineCode</th><th>ZTag</th><th>SAPID</th>
<th style="width:110px">Matched</th><th style="width:80px">Gen</th>
</tr>
</thead>
<tbody>
@foreach (var hit in _searchHits)
{
<tr>
<td><span class="mono">@hit.Equipment.EquipmentId</span></td>
<td>@hit.Equipment.Name</td>
<td>@hit.Equipment.MachineCode</td>
<td>@hit.Equipment.ZTag</td>
<td>@hit.Equipment.SAPID</td>
<td>
@if (hit.MatchedField is not null)
{
var chipClass = hit.Score switch
{
100 => "chip chip-ok",
50 => "chip chip-warn",
_ => "chip chip-idle",
};
<span class="@chipClass">@hit.MatchedField</span>
}
</td>
<td>
@if (hit.IsPublished)
{ <span class="chip chip-ok">pub</span> }
else
{ <span class="chip chip-idle">draft</span> }
</td>
</tr>
}
</tbody>
</table>
</div>
<p class="p-2 text-muted small mb-0">
@_searchHits.Count result@(_searchHits.Count == 1 ? "" : "s").
Exact = green, prefix = amber, fuzzy = grey.
Fuzzy matching requires the "Fuzzy" checkbox.
</p>
}
}
</section>
@if (_equipment is null)
{
<p>Loading…</p>
@@ -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<EquipmentSearchHit>? _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,

View File

@@ -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);
/// <summary>
/// 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 <paramref name="maxResults"/> rows.
/// </summary>
/// <param name="query">Search term (trimmed; empty returns empty results, not all rows).</param>
/// <param name="clusterId">Cluster to scope the search to.</param>
/// <param name="maxResults">Cap to prevent full-table dumps (default 50).</param>
/// <param name="allowFuzzy">When true, LIKE-prefix suffix matches score 20 (opt-in per spec).</param>
/// <param name="ct">Cancellation token.</param>
public async Task<IReadOnlyList<EquipmentSearchHit>> 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;
}
/// <summary>Score one equipment row against the search term. Returns (score, matchedFieldName).</summary>
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<Equipment?> 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);
}
}
/// <summary>One hit from <see cref="EquipmentService.SearchAsync"/>.</summary>
/// <param name="Equipment">The matched equipment row.</param>
/// <param name="Score">Match score: 100 = exact, 50 = prefix, 20 = fuzzy.</param>
/// <param name="MatchedField">Which identifier field produced the highest score.</param>
/// <param name="IsPublished">True when the row is from a published generation (aids tie-break display).</param>
public sealed record EquipmentSearchHit(
Equipment Equipment,
int Score,
string? MatchedField,
bool IsPublished);