using FLSBLL.Instance; using FLSBLL.Respondent; using FLSDAL.DTO.Instance; using FLSDAL.DTO.Respondent; using GB5Shared.DTO.Framework.Login; using GB5Shared.Telemetry; using Microsoft.Extensions.Logging; using TMSDAL.CustomCode.TmsFeedback; using TMSDAL.DTO.TmsFeedback; namespace TMSBLL.TmsFeedback; public class TmsFeedbackBLL : ITmsFeedbackBLL { private readonly ITmsFeedbackDAL _dal; private readonly IFlsInstanceBLL _flsInstance; private readonly IFlsRespondentBLL _flsRespondent; private readonly ILogger _logger; // Reserved placeholder for MFLSRESPONDENT.SOURCEOBJECTTYPEID — this column has no FK to a // real type registry (confirmed live: plain INT, default -1, no MENTITY constraint), and every // TMS-side query joins back to TTRAININGENROLLMENT via SOURCEOBJECTID alone, so the exact // value only needs to be a stable placeholder, not a resolved MENTITY row. private const int EnrollmentSourceObjectTypeId = -1388100310; private static readonly string[] RespondentStatusLabels = { "Not Started", "Opened", "In Progress", "Submitted", "Expired", "Opted Out" }; public TmsFeedbackBLL( ITmsFeedbackDAL dal, IFlsInstanceBLL flsInstance, IFlsRespondentBLL flsRespondent, ILogger logger) { _dal = dal; _flsInstance = flsInstance; _flsRespondent = flsRespondent; _logger = logger; } // "BatchId" on the wire == TTRAININGINSTANCE.InstanceId — kept as BatchId in the public // request/response DTOs for API stability; internally it always meant "which training // instance is this feedback for", never a phantom MBATCH row. public async Task DispatchBatchFeedbackAsync( DispatchBatchFeedbackRequest req, LoginDTO login, CancellationToken ct) { try { GB5Trace.Step("validate-dispatch-feedback", new { req.BatchId }); if (req.BatchId == 0) throw new ArgumentException("BatchId is required."); if (req.InstrumentConfigId == 0) throw new ArgumentException("InstrumentConfigId is required."); if (req.EnrollmentIds is null || req.EnrollmentIds.Count == 0) throw new ArgumentException("At least one enrollment is required."); int instanceId = req.BatchId; var registration = await _dal.ResolveRegistrationAsync(req.InstrumentConfigId, login, ct).ConfigureAwait(false); if (registration is null) throw new InvalidOperationException($"No FLS registration found for InstrumentConfigId {req.InstrumentConfigId}."); bool alreadyDispatched = await _dal.IsAlreadyDispatchedAsync( instanceId, registration.Value.FlsRegistrationId, login, ct).ConfigureAwait(false); if (alreadyDispatched) throw new InvalidOperationException("FEEDBACK_ALREADY_DISPATCHED"); var enrollments = await _dal.GetEnrollmentsForDispatchAsync(req.EnrollmentIds, login, ct).ConfigureAwait(false); if (enrollments.Count == 0) throw new InvalidOperationException("No matching enrollments found for the given InstanceId/EnrollmentIds."); string instanceTitle = enrollments[0].InstanceTitle; string instanceCode = $"TMS-FB-{instanceId}-{DateTime.UtcNow:yyyyMMddHHmmss}"; GB5Trace.Step("fls-create-instance", new { instanceId, req.InstrumentConfigId }); var createReq = new CreateFlsInstanceRequest( FlsInstanceId: 0, FlsRegistrationId: registration.Value.FlsRegistrationId, InstanceCode: instanceCode, InstanceName: $"{instanceTitle} Feedback", EntityObjectId: instanceId, PeriodId: -1, MaxSubmissions: 0, IsAnonymous: false, OpensFrom: null, ClosesOn: null, GlobalDeadlineDt: null, Groups: new List { new FlsGroupRequest( GroupCode: "BATCH", GroupName: instanceTitle, ContextObjectTypeId: -1, ContextObjectId: instanceId, ContextBizTypeId: -1, ContextReference: null, BlockingFlag: req.BlockCertificate, BlockingSpName: null, OpensFrom: null, ClosesOn: null, DeadlineDt: null, StepCount: 1) }, Incharges: new List { new FlsInchargeRequest(RespondentType: 1, InchargeRefId: login.UserId, InchargeRole: 0, GroupCode: null) } ); var createResult = await _flsInstance.CreateInstanceAsync(createReq, login, ct).ConfigureAwait(false); int flsInstanceId = ParseTrailingId(createResult); if (flsInstanceId == 0) throw new InvalidOperationException($"Failed to create FLS instance: {createResult}"); var groups = await _flsInstance.GetInstanceGroupListAsync(flsInstanceId, login, ct).ConfigureAwait(false); var group = groups.FirstOrDefault(g => g.GroupCode == "BATCH"); if (group is null) throw new InvalidOperationException("FLS instance was created but its 'BATCH' group could not be resolved."); var respondents = enrollments.Select(e => new RespondentRequest( RespondentType: 0, // Employee RespondentRefId: e.EmployeeId, SourceObjectTypeId: EnrollmentSourceObjectTypeId, SourceObjectId: e.EnrollmentId, SourceBizTypeId: -1, SourceReference: $"{e.EmployeeName} / {instanceTitle}", RespondentName: e.EmployeeName, RespondentMail: e.EmployeeMail, RespondentMobile: e.EmployeeMobile )).ToList(); GB5Trace.Step("fls-add-respondents", new { flsInstanceId, group.GroupId, Count = respondents.Count }); await _flsRespondent.AddRespondentsAsync( new AddRespondentsRequest(flsInstanceId, group.GroupId, StepNo: 1, respondents), login, ct).ConfigureAwait(false); return new DispatchFeedbackResultDto(flsInstanceId, respondents.Count); } catch (Exception ex) { GB5Trace.MarkFailed("dispatch-feedback-failed", ex); _logger.LogError(ex, "DispatchBatchFeedbackAsync failed for BatchId {BatchId}", req.BatchId); throw; } } public async Task GetBatchFeedbackStatusAsync( int batchId, LoginDTO login, CancellationToken ct) { try { var rows = await _dal.GetBatchFeedbackStatusAsync(batchId, login, ct).ConfigureAwait(false); int total = rows.Count; int submitted = rows.Count(r => r.RespondentSts == 3); int pending = rows.Count(r => r.RespondentSts is 0 or 2); int overdue = rows.Count(r => r.RespondentSts == 4); bool blocksCert = rows.Any(r => r.BlocksCertificate == 1); decimal pct = total > 0 ? Math.Round((decimal)submitted / total * 100, 1) : 0; var learners = rows.Select(r => new TmsLearnerFeedbackDto( EnrollmentId: r.EnrollmentId, EmployeeId: r.EmployeeId, EmployeeName: r.EmployeeName, RespondentStatus: r.RespondentSts, RespondentStatusLabel: RespondentStatusLabels.ElementAtOrDefault(r.RespondentSts) ?? "Unknown", SubmittedAt: r.SubmittedAt, FeedbackCompleted: r.FeedbackCompleted == 1, BlocksCertificate: r.BlocksCertificate == 1 )).ToList(); return new TmsBatchFeedbackStatusDto( BatchId: batchId, BatchName: string.Empty, FlsInstanceId: 0, InstanceStatus: 0, TotalEnrollments: total, TotalSubmitted: submitted, TotalPending: pending, TotalOverdue: overdue, CompletionPct: pct, BlocksCertificate: blocksCert, Learners: learners); } catch (Exception ex) { GB5Trace.MarkFailed("get-batch-feedback-status-failed", ex); _logger.LogError(ex, "GetBatchFeedbackStatusAsync failed for BatchId {BatchId}", batchId); throw; } } public async Task GetTrainerFeedbackAsync( GetTrainerFeedbackRequest req, LoginDTO login, CancellationToken ct) { try { var row = await _dal.GetTrainerFeedbackAsync( req.TrainerId, req.PeriodId, login, ct).ConfigureAwait(false); if (row is null) throw new KeyNotFoundException($"No feedback data for trainer {req.TrainerId}."); decimal pct = row.RespondentCount > 0 ? Math.Round((decimal)row.SubmittedCount / row.RespondentCount * 100, 1) : 0; return new TrainerFeedbackSummaryDto( TrainerId: row.TrainerId, TrainerName: row.TrainerName, SessionCount: row.SessionCount, RespondentCount: row.RespondentCount, SubmittedCount: row.SubmittedCount, CompletionPct: pct, AvgKnowledge: row.AvgKnowledge, AvgClarity: row.AvgClarity, AvgEngagement: row.AvgEngagement, OverallAvg: row.OverallAvg, LastUpdated: row.LastUpdated); } catch (Exception ex) { GB5Trace.MarkFailed("get-trainer-feedback-failed", ex); _logger.LogError(ex, "GetTrainerFeedbackAsync failed for TrainerId {TrainerId}", req.TrainerId); throw; } } public async Task NudgePendingLearnersAsync(int batchId, LoginDTO login, CancellationToken ct) { try { GB5Trace.Step("nudge-pending-learners", new { batchId }); var respondentIds = await _dal .GetNudgePendingRespondentIdsAsync(batchId, login, ct).ConfigureAwait(false); int nudged = 0; foreach (var respondentId in respondentIds) { await _flsRespondent.NudgeRespondentAsync(respondentId, login, ct).ConfigureAwait(false); nudged++; } _logger.LogInformation("Nudged {Count} pending learners for BatchId {BatchId}", nudged, batchId); return nudged; } catch (Exception ex) { GB5Trace.MarkFailed("nudge-pending-learners-failed", ex); _logger.LogError(ex, "NudgePendingLearnersAsync failed for BatchId {BatchId}", batchId); throw; } } public async Task> GetSelectListInstrumentConfigAsync(LoginDTO login, CancellationToken ct) => await _dal.GetSelectListInstrumentConfigAsync(login, ct).ConfigureAwait(false); private static int ParseTrailingId(string message) { var parts = message.Split(' '); return parts.Length > 0 && int.TryParse(parts[^1], out var id) ? id : 0; } }