using System.Data; using GB5Shared.DTO.Ice; using GB5Shared.DTO.Framework.Login; using GB5Shared.Telemetry; using IceImportDAL.CustomCode.EntityFieldLookup; using IceImportDAL.CustomCode.GcmCodeLookup; using IceImportDAL.DTO.IceImportPipeline; using Microsoft.Extensions.Logging; namespace IceImportBLL.EntityLookupService; public class EntityLookupService : IEntityLookupService { // MENTITY.ENTITYID for MGCM (POCO side) — see IceMapEntityId in the earlier // IceMap MapAndTransform diagnostics. private const int GcmEntityId = -2147481747; private readonly IGcmCodeLookupDAL _gcmDal; private readonly IEntityFieldMetadataDAL _fieldMetadataDal; private readonly IGenericEntityCodeLookupDAL _genericLookupDal; private readonly ILogger _logger; public EntityLookupService( IGcmCodeLookupDAL gcmDal, IEntityFieldMetadataDAL fieldMetadataDal, IGenericEntityCodeLookupDAL genericLookupDal, ILogger logger) { _gcmDal = gcmDal; _fieldMetadataDal = fieldMetadataDal; _genericLookupDal = genericLookupDal; _logger = logger; } public async Task>> ResolveAllLookupsAsync( ParsedImportDataSetDTO parsedData, IceMapDTO mapDefinition, LoginDTO login, CancellationToken ct) { var result = new Dictionary>(); var lookupRows = mapDefinition.IceMapDetailsArray .Where(d => d.IceMapDetailsValueType is 4 or 5) .ToList(); if (lookupRows.Count == 0) return result; using var section = GB5Trace.BeginSection("entity-lookup-resolve-all"); GB5Trace.Step("entity-lookup-groups", new { RowCount = lookupRows.Count }); // Temporary diagnostic — remove once the BOM parent/detail-array column resolution is // confirmed stable. Shows exactly which parsed tables exist and what columns each one // actually has, so a "column X not found" symptom is diagnosable from the log alone. _logger.LogInformation( "IceImport EntityLookup: ParsedTables=[{Tables}]", string.Join(" | ", parsedData.Tables.Select(kv => $"{kv.Key}:[{string.Join(",", kv.Value.Columns.Cast().Select(c => c.ColumnName))}]"))); // Loop by TABLE first — parsedData holds one table per DestinationMemberEntityId (e.g. // BOMDTO, BOMDETAILDTO), each carrying only that entity's own columns. Only the ValueType // 4/5 rows that actually belong to a given table (DestinationMemberEntityId == that table's // key) are ever resolved against it, so PRODUCTIONITEMCODE (BOMDTO) can never accidentally // be searched against BOMDETAILDTO's table, or vice versa. // // parsedData.Tables is a Dictionary — its enumeration order is an // implementation detail, NOT a guarantee (do not rely on "insertion order" here — it can // and does surface entities in whatever order the un-ordered GET_ICEMAP query happened to // return their MICEMAPDETAILS rows in). Root entity (the parent, e.g. BOMDTO) is therefore // pinned to go first explicitly, every other entity keeping its natural relative order. var orderedEntityIds = mapDefinition.IceMapDetailsArray .Select(d => d.DestinationMemberEntityId) .Distinct() .OrderBy(id => id == mapDefinition.IceMapRootEntityId ? 0 : 1) .ToList(); foreach (var ownerEntityId in orderedEntityIds) { if (!parsedData.Tables.TryGetValue(ownerEntityId.ToString(), out var table)) continue; var tableKey = ownerEntityId.ToString(); var tableLookupRows = lookupRows .Where(d => d.DestinationMemberEntityId == ownerEntityId) .ToList(); if (tableLookupRows.Count == 0) continue; // Group by (EntityId being looked up, Code-vs-Name, GcmTypeId) so codes and names never // collide across ValueTypes, and GCM-scoped rows with different GcmTypeIds under the // same EntityId are resolved independently. foreach (var group in tableLookupRows.GroupBy(d => (d.EntityId, d.IceMapDetailsValueType, d.GcmTypeId))) { var (entityId, valueType, gcmTypeId) = group.Key; var isNameLookup = valueType == 5; var values = CollectDistinctSourceValues(table, group.ToList()); if (values.Count == 0) { _logger.LogWarning( "IceImport EntityLookup: no source values collected for EntityId {EntityId} " + "ValueType {ValueType} in table {TableKey} — SourceFieldName(s)=[{Fields}]. " + "Column missing from that table, or every row's value was blank.", entityId, valueType, tableKey, string.Join(",", group.Select(d => d.IceMapDetailsSourceFieldName))); continue; } Dictionary resolved; try { // GCM entity id — the GcmTypeId-scoped MGCM path only applies to MGCM's own // Code/Name -> Id lookups, never to some other entity that happens to carry a // GcmTypeId. if (gcmTypeId != -1 && entityId == GcmEntityId) { resolved = isNameLookup ? await _gcmDal.ResolveNamesToIdsAsync(gcmTypeId, values, login, ct).ConfigureAwait(false) : await _gcmDal.ResolveCodesToIdsAsync(gcmTypeId, values, login, ct).ConfigureAwait(false); } else { var metadata = await _fieldMetadataDal .ResolveAsync(entityId, isNameLookup, login, ct).ConfigureAwait(false); if (metadata is not null) { resolved = await _genericLookupDal .ResolveValuesToIdsAsync(metadata, values, login, ct).ConfigureAwait(false); } else { // No DBOBJECT metadata resolved and no Menu.GetWebServiceId dispatch to // fall back to (Import only ever saves — see IMenuWebServiceDispatcher) — // every value in this group surfaces as a normal "not found" validation // finding via CodeToIdLookupResolver/NameToIdLookupResolver below. _logger.LogWarning( "IceImport EntityLookup: no DBOBJECT metadata resolved for EntityId {EntityId} — " + "register one, or a dedicated ILookupProvider, to resolve this Code/Name lookup.", entityId); resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); } } } catch (Exception ex) { GB5Trace.MarkFailed("entity-lookup-group-failed", ex); _logger.LogError(ex, "IceImport: lookup resolution failed for EntityId {EntityId}", entityId); resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); } // Deliberately no "resolved.Count == 0 -> fail fast" check here, even though that can // legitimately happen (every value in this batch missing, not just some) — this // method only resolves the lookup dictionary; it never inspects individual rows. An // empty (or partially empty) dictionary flows through to CodeToIdLookupResolver/ // NameToIdLookupResolver exactly as-is, so EVERY row whose value isn't a key gets its // own Error-severity finding with the correct RowNumber — including all N rows when // resolved is fully empty, not just a generic per-EntityId message. Throwing early // here would only cover this one group and would do so before every other column has // even been read; the run must finish reading every column, then // IceImportRunExecutionService groups every column's not-found rows by FieldName into // TICEIMPORTRUNLOG (LogLevel = "Validation") and aborts the commit — see // LogGroupedLookupFindingsAsync. if (!result.TryGetValue(entityId, out var bucket)) { bucket = new Dictionary(StringComparer.OrdinalIgnoreCase); result[entityId] = bucket; } foreach (var kvp in resolved) bucket[kvp.Key] = kvp.Value; } } return result; } // Reads every ValueType 4/5 row's raw source value from ONE table only — the caller has already // established that this table is these rows' own DestinationMemberEntityId owner. private static List CollectDistinctSourceValues(DataTable table, List rows) { var values = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var row in rows) { var fieldName = row.IceMapDetailsSourceFieldName?.Trim().ToUpperInvariant(); if (string.IsNullOrEmpty(fieldName) || !table.Columns.Contains(fieldName)) continue; foreach (DataRow dataRow in table.Rows) { var v = dataRow[fieldName]?.ToString()?.Trim(); if (!string.IsNullOrEmpty(v)) values.Add(v); } } return values.ToList(); } }