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>
200 lines
8.5 KiB
C#
200 lines
8.5 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Equipment CRUD scoped to a generation. The Admin app writes against Draft generations only;
|
|
/// Published generations are read-only (to create changes, clone to a new draft via
|
|
/// <see cref="GenerationService.CreateDraftAsync"/>).
|
|
/// </summary>
|
|
public sealed class EquipmentService(OtOpcUaConfigDbContext db)
|
|
{
|
|
public Task<List<Equipment>> ListAsync(long generationId, CancellationToken ct) =>
|
|
db.Equipment.AsNoTracking()
|
|
.Where(e => e.GenerationId == generationId)
|
|
.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);
|
|
|
|
/// <summary>
|
|
/// Creates a new equipment row in the given draft. The EquipmentId is auto-derived from
|
|
/// a fresh EquipmentUuid per decision #125; operator-supplied IDs are rejected upstream.
|
|
/// </summary>
|
|
public async Task<Equipment> CreateAsync(long draftId, Equipment input, CancellationToken ct)
|
|
{
|
|
input.GenerationId = draftId;
|
|
input.EquipmentUuid = input.EquipmentUuid == Guid.Empty ? Guid.NewGuid() : input.EquipmentUuid;
|
|
input.EquipmentId = DraftValidator.DeriveEquipmentId(input.EquipmentUuid);
|
|
db.Equipment.Add(input);
|
|
await db.SaveChangesAsync(ct);
|
|
return input;
|
|
}
|
|
|
|
public async Task UpdateAsync(Equipment updated, CancellationToken ct)
|
|
{
|
|
// Only editable fields are persisted; EquipmentId + EquipmentUuid are immutable once set.
|
|
var existing = await db.Equipment
|
|
.FirstOrDefaultAsync(e => e.EquipmentRowId == updated.EquipmentRowId, ct)
|
|
?? throw new InvalidOperationException($"Equipment row {updated.EquipmentRowId} not found");
|
|
|
|
existing.Name = updated.Name;
|
|
existing.MachineCode = updated.MachineCode;
|
|
existing.ZTag = updated.ZTag;
|
|
existing.SAPID = updated.SAPID;
|
|
existing.Manufacturer = updated.Manufacturer;
|
|
existing.Model = updated.Model;
|
|
existing.SerialNumber = updated.SerialNumber;
|
|
existing.HardwareRevision = updated.HardwareRevision;
|
|
existing.SoftwareRevision = updated.SoftwareRevision;
|
|
existing.YearOfConstruction = updated.YearOfConstruction;
|
|
existing.AssetLocation = updated.AssetLocation;
|
|
existing.ManufacturerUri = updated.ManufacturerUri;
|
|
existing.DeviceManualUri = updated.DeviceManualUri;
|
|
existing.DriverInstanceId = updated.DriverInstanceId;
|
|
existing.DeviceId = updated.DeviceId;
|
|
existing.UnsLineId = updated.UnsLineId;
|
|
existing.EquipmentClassRef = updated.EquipmentClassRef;
|
|
existing.Enabled = updated.Enabled;
|
|
|
|
await db.SaveChangesAsync(ct);
|
|
}
|
|
|
|
public async Task DeleteAsync(Guid equipmentRowId, CancellationToken ct)
|
|
{
|
|
var row = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentRowId == equipmentRowId, ct);
|
|
if (row is null) return;
|
|
db.Equipment.Remove(row);
|
|
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);
|