using AnalyticsBLL.DatasetResolver; using AnalyticsDAL.CustomCode.ApiDataset; using AnalyticsDAL.CustomCode.BIFieldMapping; using AnalyticsDAL.DTO.AnalysisAggregation.V1; using AnalyticsDAL.DTO.BICatalog; using AnalyticsDAL.DTO.BIFieldMapping; using AnalyticsDAL.DTO.Warehouse; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.AutoNumber; using GB5Shared.DTO.Framework.Login; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using GB5Shared.Validation; using Microsoft.Extensions.Logging; using static GB5Shared.GB5Constant.Constant; namespace AnalyticsBLL.BIFieldMapping { public class BIFieldMappingBLL : IBIFieldMappingBLL { private readonly IDatasetResolver _datasetResolver; private readonly IApiDatasetDAL _apiDatasetDAL; private readonly IBIFieldMappingDAL _fieldMappingDAL; private readonly AutoNumber _autoNumber; private readonly IValidation _validation; private readonly KeyInvalidate _keyInvalidate; private readonly ILogger _logger; public BIFieldMappingBLL( IDatasetResolver datasetResolver, IApiDatasetDAL apiDatasetDAL, IBIFieldMappingDAL fieldMappingDAL, AutoNumber autoNumber, IValidation validation, KeyInvalidate keyInvalidate, ILogger logger) { _datasetResolver = datasetResolver; _apiDatasetDAL = apiDatasetDAL; _fieldMappingDAL = fieldMappingDAL; _autoNumber = autoNumber; _validation = validation; _keyInvalidate = keyInvalidate; _logger = logger; } public async Task> SuggestMappings(int biCatalogId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("suggest-biFieldMappings", new { biCatalogId }); var resolution = await _datasetResolver.ResolveAsync(biCatalogId, login, ct).ConfigureAwait(false); if (resolution.Kind != DatasetKind.ApiService) throw new ArgumentException( $"BICatalog {biCatalogId} is {resolution.Kind}-kind, not ApiService — SuggestMappings only applies to ApiService-kind catalogs.", nameof(biCatalogId)); // Sample call: empty Dimensions/Measures/Filters — just hit the report once and see // what comes back. FetchRowsAsync only ever reads definition.Filters (empty here, so // an unfiltered call), never Dimensions/Measures, so this is safe with an otherwise- // bare definition. var sampleDefinition = new AnalysisQueryDefinition { DatasetId = biCatalogId }; var rawRows = await _apiDatasetDAL.FetchRowsAsync(resolution, sampleDefinition, login, ct).ConfigureAwait(false); var firstRow = rawRows.FirstOrDefault(); if (firstRow is null) { _logger.LogInformation("SuggestMappings: BICatalog {BICatalogId}'s report returned zero rows — nothing to suggest.", biCatalogId); return new List(); } var suggestions = new List(); short sortOrder = 0; foreach (var (fieldName, value) in firstRow) { // Nested objects/arrays have no flat Dimension/Measure equivalent — skip rather // than guess something meaningless. if (value is Dictionary || value is System.Collections.IEnumerable and not string) continue; sortOrder++; var isMeasure = value is long or decimal; suggestions.Add(new BIFieldMappingDTO { BIFieldMappingId = 0, // draft — never auto-saved BICatalogId = biCatalogId, SourceFieldName = fieldName, Label = fieldName, MappedAs = isMeasure ? (byte)1 : (byte)0, DataType = InferDataType(value), // Conservative guess for numeric fields: SUM/Additive. Never AVG/NonAdditive // — a wrongly-guessed "safe to sum" is far less dangerous than a wrongly- // guessed "safe to average," and the admin's saved choice (not this guess) // is what AnalysisAggregationService.ValidateAdditivity actually trusts once // this draft is reviewed and saved via SaveMapping. DefaultAggregation = isMeasure ? (byte)1 : null, AdditivityType = isMeasure ? AdditivityType.Additive : null, IsVisible = true, SortOrder = sortOrder }); } return suggestions; } private static byte InferDataType(object? value) => value switch { long => 0, // Int decimal => 6, // Double bool => 4, // Bool string s when DateTime.TryParse(s, out _) => 3, // Date string => 1, // String _ => 1 // unknown -> String (safe fallback) }; public async Task SaveMapping(BIFieldMappingDTO dto, LoginDTO login, CancellationToken ct) { GB5Trace.Step("validate-biFieldMapping", new { dto.BICatalogId, dto.SourceFieldName }); await _validation.NotEmpty(dto.SourceFieldName, nameof(dto.SourceFieldName)); await _validation.NotEmpty(dto.Label, nameof(dto.Label)); if (dto.MappedAs > 2) throw new ArgumentException("MappedAs must be 0 (Dimension), 1 (Measure), or 2 (Computed).", nameof(dto.MappedAs)); if (dto.MappedAs == 2) { await _validation.NotEmpty(dto.ComputeExpression, nameof(dto.ComputeExpression)).ConfigureAwait(false); AnalysisAggregation.ComputedFieldPlanner.ValidateForSave(dto); } else if (!string.IsNullOrEmpty(dto.ComputeExpression)) { throw new ArgumentException("ComputeExpression may only be set when MappedAs = 2 (Computed).", nameof(dto.ComputeExpression)); } dto.ModifiedById = login.UserId; AutoNumberDTO? autoNumberDTO = null; try { if (dto.BIFieldMappingId == 0) { autoNumberDTO = await _autoNumber.GetAutoNumber(1, "BIFIELDMAPPING", login).ConfigureAwait(false); dto.BIFieldMappingId = autoNumberDTO.StartNumber; dto.CreatedById = login.UserId; GB5Trace.Step("save-biFieldMapping", new { dto.BIFieldMappingId, isNew = true }); await _fieldMappingDAL.Insert(dto, login, ct).ConfigureAwait(false); // Matches GetDimensionsForDataset.cs/GetMeasuresForDataset.cs's own // GetCacheKey() exactly ("-dimensions"/"-measures" suffixes) — an ApiService- // kind catalog's picker is built directly from MBIFIELDMAPPING rows, so a // saved mapping must invalidate both or the picker keeps serving its // pre-save cached (possibly empty) field list. var dimensionsKey = new CacheKeyGeneration().KeyGeneration( $"{dto.BICatalogId}-dimensions", EntityConstant.OBJECTBICATALOG, CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.AllInvalidateCache(dimensionsKey).ConfigureAwait(false); var measuresKey = new CacheKeyGeneration().KeyGeneration( $"{dto.BICatalogId}-measures", EntityConstant.OBJECTBICATALOG, CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.AllInvalidateCache(measuresKey).ConfigureAwait(false); var computedKey = new CacheKeyGeneration().KeyGeneration( $"{dto.BICatalogId}-computed", EntityConstant.OBJECTBICATALOG, CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.AllInvalidateCache(computedKey).ConfigureAwait(false); return $"{SuccessResponse.SaveSuccessMessage} {dto.BIFieldMappingId}"; } // No UPDATE path yet — editing an existing suggestion before its first save is // the only flow Phase 2 builds; re-editing an already-saved mapping is a later, // separate concern (the field-mapping review screen, Phase 3). throw new NotImplementedException("Updating an existing BIFieldMapping is not yet implemented — only first-time save from a SuggestMappings draft."); } catch (Exception ex) { GB5Trace.MarkFailed("save-biFieldMapping-failed", ex); _logger.LogError(ex, "SaveMapping failed for BICatalogId {BICatalogId}, Field {Field}", dto.BICatalogId, dto.SourceFieldName); if (autoNumberDTO != null) await _autoNumber.RollbackAutoNumber("BIFIELDMAPPING", dto.BIFieldMappingId, login).ConfigureAwait(false); throw; } } } }