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; /// /// 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 /// ). /// public sealed class EquipmentService(OtOpcUaConfigDbContext db) { public Task> ListAsync(long generationId, CancellationToken ct) => db.Equipment.AsNoTracking() .Where(e => e.GenerationId == generationId) .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); /// /// 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. /// public async Task 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); } } /// 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);