using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.Login; using GB5Shared.EntityHandler; using GB5Shared.QueryExecutor; using GB5Shared.Resource.Response; using PIEDAL.CustomCode.Profile; using PIEDAL.DTO.Profile; using static GB5Shared.GB5Constant.Constant; namespace PIEBLL.Profile; public class PIEProfileBLL : IPIEProfileBLL { private readonly IPIEProfileDAL _PIEProfileDAL; private readonly IQueryExecutor _QueryExecutor; private readonly KeyInvalidate _KeyInvalidate; private readonly BaseEntityAppService _BaseEntityAppService; public PIEProfileBLL( IPIEProfileDAL pieProfileDAL, IQueryExecutor queryExecutor, KeyInvalidate keyInvalidate, BaseEntityAppService baseEntityAppService) { _PIEProfileDAL = pieProfileDAL; _QueryExecutor = queryExecutor; _KeyInvalidate = keyInvalidate; _BaseEntityAppService = baseEntityAppService; } // ── Reads ───────────────────────────────────────────────────────────────── public async Task GetPIEProfileAsync( Guid profileId, LoginDTO login, CancellationToken ct) { var profile = await _PIEProfileDAL.GetPIEProfileAsync(profileId, login, ct).ConfigureAwait(false); if (profile is null) return null; var modules = await _PIEProfileDAL.GetProfileModulesAsync(profileId, login, ct).ConfigureAwait(false); var variants = await _PIEProfileDAL.GetVariantDefaultsAsync(profileId, login, ct).ConfigureAwait(false); var kpis = await _PIEProfileDAL.GetProfileKpisAsync(profileId, login, ct).ConfigureAwait(false); var questions = await _PIEProfileDAL.GetDeviationQuestionsAsync(profileId, login, ct).ConfigureAwait(false); // Load implications for each question var questionList = questions.ToList(); if (questionList.Count > 0) { var questionIds = questionList.Select(q => q.QuestionId).ToArray(); var implications = await _PIEProfileDAL.GetDeviationImplicationsAsync(questionIds, login, ct).ConfigureAwait(false); var impByQuestion = implications .GroupBy(i => i.QuestionId) .ToDictionary(g => g.Key, g => g.ToList()); foreach (var q in questionList) { q.Implications = impByQuestion.TryGetValue(q.QuestionId, out var list) ? list : new List(); } } profile.Modules = modules.ToList(); profile.VariantDefaults = variants.ToList(); profile.KpiItems = kpis.ToList(); profile.DeviationQuestions = questionList; return profile; } public async Task> GetProfileSelectListAsync( LoginDTO login, CancellationToken ct) => await _PIEProfileDAL.GetProfileSelectListAsync(login, ct).ConfigureAwait(false); /// /// QuickStart profile matching — <500ms SLA. /// Returns matched profiles with their active deviation questions. /// Confidence score is set to 100 for exact matches; caller can refine with keyword matching. /// public async Task> MatchProfilesAsync( PIEProfileMatchRequestDTO req, LoginDTO login, CancellationToken ct) { var profileList = (await _PIEProfileDAL.MatchProfilesAsync(req, login, ct).ConfigureAwait(false)).ToList(); if (profileList.Count == 0) return Enumerable.Empty(); // Single batch query replaces N per-profile calls var profileIds = profileList.Select(p => p.ProfileId).ToArray(); var allQuestions = (await _PIEProfileDAL.GetDeviationQuestionsByProfileIdsAsync( profileIds, login, ct).ConfigureAwait(false)).ToList(); var questionsByProfile = allQuestions.GroupBy(q => q.ProfileId) .ToDictionary(g => g.Key, g => g.ToList()); // Fetch implications for all questions in a second batch (already 2 trips total) var allQuestionIds = allQuestions.Select(q => q.QuestionId).ToArray(); var impByQuestion = allQuestionIds.Length > 0 ? (await _PIEProfileDAL.GetDeviationImplicationsAsync(allQuestionIds, login, ct).ConfigureAwait(false)) .GroupBy(i => i.QuestionId) .ToDictionary(g => g.Key, g => g.ToList()) : new Dictionary>(); var results = new List(profileList.Count); foreach (var p in profileList) { var questionList = questionsByProfile.TryGetValue(p.ProfileId, out var qs) ? qs : new(); foreach (var q in questionList) q.Implications = impByQuestion.TryGetValue(q.QuestionId, out var l) ? l : new(); results.Add(new PIEProfileMatchResultDTO { ProfileId = p.ProfileId, ProfileCode = p.ProfileCode, ProfileName = p.ProfileName, ConfidenceScore = CalculateConfidenceScore(req, p), DeviationQuestions = questionList }); } return results.OrderByDescending(r => r.ConfidenceScore); } // ── Saves ───────────────────────────────────────────────────────────────── public async Task SavePIEProfileAsync( PIEProfileDTO dto, LoginDTO login, CancellationToken ct) { if (dto is null) throw new ArgumentNullException(nameof(dto)); ValidateProfileDto(dto); bool isNew = dto.ProfileId == Guid.Empty; if (isNew) dto.ProfileId = Guid.NewGuid(); var Trans = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { await _BaseEntityAppService.ExecuteSaveAsync( EntityConstant.OBJECTPIEPROFILE, isNew ? EventTypeConstant.SAVEPIEPROFILEEVENTTYPEID : EventTypeConstant.UPDATEPIEPROFILEEVENTTYPEID, dto, login, async tx => { if (isNew) await _PIEProfileDAL.SavePIEProfileAsync(dto, login, tx, ct); else await _PIEProfileDAL.UpdatePIEProfileAsync(dto, login, tx, ct); // Replace modules await _PIEProfileDAL.DeleteProfileModulesAsync(dto.ProfileId, login, tx, ct); foreach (var m in dto.Modules) { m.ProfileModuleId = m.ProfileModuleId == Guid.Empty ? Guid.NewGuid() : m.ProfileModuleId; m.ProfileId = dto.ProfileId; m.TenantId = dto.TenantId; await _PIEProfileDAL.InsertProfileModuleAsync(m, login, tx, ct); } // Replace variant defaults await _PIEProfileDAL.DeleteVariantDefaultsAsync(dto.ProfileId, login, tx, ct); foreach (var v in dto.VariantDefaults) { v.PvdId = v.PvdId == Guid.Empty ? Guid.NewGuid() : v.PvdId; v.ProfileId = dto.ProfileId; v.TenantId = dto.TenantId; await _PIEProfileDAL.InsertVariantDefaultAsync(v, login, tx, ct); } // Replace profile KPIs await _PIEProfileDAL.DeleteProfileKpisAsync(dto.ProfileId, login, tx, ct); foreach (var k in dto.KpiItems) { k.ProfileKpiId = k.ProfileKpiId == Guid.Empty ? Guid.NewGuid() : k.ProfileKpiId; k.ProfileId = dto.ProfileId; k.TenantId = dto.TenantId; await _PIEProfileDAL.InsertProfileKpiAsync(k, login, tx, ct); } // Replace deviation questions + implications await _PIEProfileDAL.DeleteDeviationQuestionsAsync(dto.ProfileId, login, tx, ct); foreach (var q in dto.DeviationQuestions) { q.QuestionId = q.QuestionId == Guid.Empty ? Guid.NewGuid() : q.QuestionId; q.ProfileId = dto.ProfileId; q.TenantId = dto.TenantId; await _PIEProfileDAL.InsertDeviationQuestionAsync(q, login, tx, ct); foreach (var imp in q.Implications) { imp.ImplicationId = imp.ImplicationId == Guid.Empty ? Guid.NewGuid() : imp.ImplicationId; imp.QuestionId = q.QuestionId; imp.TenantId = dto.TenantId; await _PIEProfileDAL.InsertDeviationImplicationAsync(imp, login, tx, ct); } } return 0; // PIEProfile uses a Guid PK — no int object ID to report back }, null, BIZTRANSACTIONCLASSCONSTANT.PIEPROFILE, BizTransactionConstant.PIEPROFILE, Trans).ConfigureAwait(false); await _QueryExecutor.CommitAsync(Trans).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( dto.ProfileId, EntityConstant.OBJECTPIEPROFILE, CacheKeyLevel.CLIENT_LEVEL, login); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); return isNew ? SuccessResponse.SaveSuccess : SuccessResponse.UpdateSuccess; } catch (Exception ex) { GB5Shared.Telemetry.GB5Trace.MarkFailed("save-pie-profile-failed", ex); await _QueryExecutor.RollbackAsync(Trans).ConfigureAwait(false); throw; } } public async Task PublishPIEProfileAsync( Guid profileId, LoginDTO login, CancellationToken ct) { var Trans = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { await _BaseEntityAppService.ExecuteSaveAsync( EntityConstant.OBJECTPIEPROFILE, EventTypeConstant.UPDATEPIEPROFILEEVENTTYPEID, new PIEProfileDTO { ProfileId = profileId }, login, async tx => { await _PIEProfileDAL.PublishPIEProfileAsync(profileId, login, tx, ct); return 0; }, null, BIZTRANSACTIONCLASSCONSTANT.PIEPROFILE, BizTransactionConstant.PIEPROFILE, Trans).ConfigureAwait(false); await _QueryExecutor.CommitAsync(Trans).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( profileId, EntityConstant.OBJECTPIEPROFILE, CacheKeyLevel.CLIENT_LEVEL, login); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); return SuccessResponse.UpdateSuccess; } catch (Exception ex) { GB5Shared.Telemetry.GB5Trace.MarkFailed("publish-pie-profile-failed", ex); await _QueryExecutor.RollbackAsync(Trans).ConfigureAwait(false); throw; } } public async Task DeprecatePIEProfileAsync( Guid profileId, LoginDTO login, CancellationToken ct) { var Trans = await _QueryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { await _BaseEntityAppService.ExecuteSaveAsync( EntityConstant.OBJECTPIEPROFILE, EventTypeConstant.DELETEPIEPROFILEEVENTTYPEID, new PIEProfileDTO { ProfileId = profileId }, login, async tx => { await _PIEProfileDAL.DeprecatePIEProfileAsync(profileId, login, tx, ct); return 0; }, null, BIZTRANSACTIONCLASSCONSTANT.PIEPROFILE, BizTransactionConstant.PIEPROFILE, Trans).ConfigureAwait(false); await _QueryExecutor.CommitAsync(Trans).ConfigureAwait(false); var cacheKey = new CacheKeyGeneration().KeyGeneration( profileId, EntityConstant.OBJECTPIEPROFILE, CacheKeyLevel.CLIENT_LEVEL, login); await _KeyInvalidate.AllInvalidateCache(cacheKey).ConfigureAwait(false); return SuccessResponse.UpdateSuccess; } catch (Exception ex) { GB5Shared.Telemetry.GB5Trace.MarkFailed("deprecate-pie-profile-failed", ex); await _QueryExecutor.RollbackAsync(Trans).ConfigureAwait(false); throw; } } // ── Private helpers ─────────────────────────────────────────────────────── private static void ValidateProfileDto(PIEProfileDTO dto) { if (string.IsNullOrWhiteSpace(dto.ProfileCode)) throw new ArgumentException("Profile Code is required."); if (string.IsNullOrWhiteSpace(dto.ProfileName)) throw new ArgumentException("Profile Name is required."); if (string.IsNullOrWhiteSpace(dto.Industry)) throw new ArgumentException("Industry is required."); if (string.IsNullOrWhiteSpace(dto.Geography)) throw new ArgumentException("Geography is required."); if (dto.CompanySizeMin < 0) throw new ArgumentException("CompanySizeMin must be >= 0."); if (dto.CompanySizeMax.HasValue && dto.CompanySizeMax < dto.CompanySizeMin) throw new ArgumentException("CompanySizeMax must be >= CompanySizeMin."); if (!dto.Modules.Any()) throw new ArgumentException("At least one module must be assigned to the profile."); } /// /// Simple keyword-based confidence score. /// Exact match = 100. Keyword overlap raises the score for partial matches. /// private static decimal CalculateConfidenceScore(PIEProfileMatchRequestDTO req, PIEProfileDTO p) { if (string.IsNullOrWhiteSpace(p.MatchKeywords)) return 90m; var keywords = p.MatchKeywords.Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(k => k.Trim().ToLowerInvariant()) .ToHashSet(); int matched = req.ModuleIds.Count(id => keywords.Contains(id.ToString().ToLowerInvariant())); if (keywords.Count == 0) return 90m; decimal overlap = (decimal)matched / keywords.Count; return Math.Round(70m + (overlap * 30m), 2); } }