using System; using System.Threading; using System.Threading.Tasks; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.Promotion; using GB5Shared.Promotion; using Newtonsoft.Json; using TMSBLL.TrainingProgramme; using TMSDAL.CustomCode.Promotion; using TMSDAL.DTO.TrainingProgramme; namespace TMSBLL.Promotion { // ============================================================ // TrainingProgrammePromotionAdapter — IPromotableEntity for MTRAININGPROGRAMME // ("TRAININGPROGRAMME"). Registered locally inside TMSModule (this module's own process, // HRFinanceHost) — see GB5Shared/DTO/Promotion/PromotionBridgeDTOs.cs for why this can't live // in FrameworkBLL like the earlier adapters. FrameworkBLL's RemoteHttpPromotionAdapter calls // this module's own PromotionBridge endpoints over HTTP to reach it. // // Parent row only — Sessions/ProgrammePrerequisites/SkillMaps/CompetencyMaps are not bundled // (same "children deferred as a follow-up" precedent as ReportView's Fields/Dashboard's Pages). // This is NOT just a scope-reduction shortcut here, though — it is load-bearing for update // safety. TrainingProgrammeBLL.SaveTrainingProgramme's own update path (read directly) snapshots // the current aggregate into a revision-history table and then unconditionally DELETEs every // child row (competency maps, skill maps, prerequisites, session topics, assessment content, // attendance, LMS bridge, sessions) before calling SaveChildRecords again to rebuild from // whatever the current DTO's child collections contain. A promoted DTO with null/empty child // collections would, on every re-promotion, silently wipe out all of the destination // Programme's real Sessions/Prerequisites/SkillMaps/CompetencyMaps. // // Import therefore never constructs a bare destination DTO for an update. When // existingDestinationId resolves to a real row, it first reads that row's OWN current full DTO // (via GetTrainingProgramme — the same aggregate SaveTrainingProgramme itself would snapshot) // and overlays only the promotable parent fields onto it in place, leaving Sessions/ // ProgrammePrerequisites/SkillMaps/CompetencyMaps exactly as they already are at the // destination. A fresh insert has nothing to preserve, so it starts from an empty DTO — // matching the same "children deferred" precedent used elsewhere, safely, because there is // nothing yet to lose. // // TrainingDomainId is the one real cross-reference on the parent row itself (resolved via the // new ITrainingDomainCodeLookupDAL) — every other field is a scalar/enum/decimal/free-text // value with no cross-environment reference at all. // ============================================================ public class TrainingProgrammePromotionAdapter : IPromotableEntity { public string EntityTypeCode => "TRAININGPROGRAMME"; private readonly ITrainingProgrammeBLL _ProgrammeBLL; private readonly ITrainingDomainCodeLookupDAL _DomainCodeLookup; private readonly ITrainingProgrammeCodeLookupDAL _ProgrammeCodeLookup; public TrainingProgrammePromotionAdapter( ITrainingProgrammeBLL programmeBLL, ITrainingDomainCodeLookupDAL domainCodeLookup, ITrainingProgrammeCodeLookupDAL programmeCodeLookup) { _ProgrammeBLL = programmeBLL; _DomainCodeLookup = domainCodeLookup; _ProgrammeCodeLookup = programmeCodeLookup; } private class PortablePayload { public string ProgrammeCode { get; set; } = string.Empty; public string ProgrammeTitle { get; set; } = string.Empty; public string? ShortDescription { get; set; } public string? FullDescription { get; set; } public string? DomainCode { get; set; } public decimal PassMark { get; set; } public byte ProgrammeType { get; set; } public byte DeliveryMode { get; set; } public byte ComplexityLevel { get; set; } public byte DifficultyLevel { get; set; } public byte ProviderType { get; set; } public byte ProgrammeLevel { get; set; } public decimal DurationHours { get; set; } public byte IsRefresher { get; set; } public byte HasAssessment { get; set; } public byte HasPreAssessment { get; set; } public byte CertOnComplete { get; set; } public int CertValidityDays { get; set; } public int RenewalWindowDays { get; set; } public int MinCapacity { get; set; } public int MaxCapacity { get; set; } public decimal CostPerParticipant { get; set; } public string? LearningObjectives { get; set; } public string? TargetAudience { get; set; } public string? Prerequisites { get; set; } public string ProgrammeVersionNo { get; set; } = "1.0"; public string? RevisionRemarks { get; set; } public byte IsActive { get; set; } public byte Status { get; set; } public short SortOrder { get; set; } } public async Task ExportForPromotionAsync( int originEntityId, LoginDTO login, CancellationToken ct) { var json = await _ProgrammeBLL.GetTrainingProgramme(originEntityId, login).ConfigureAwait(false); var dto = JsonConvert.DeserializeObject(json) ?? throw new InvalidOperationException($"TrainingProgramme {originEntityId} not found."); // GetTrainingProgramme already joins DomainCode onto the DTO; fall back to a direct // lookup only if that projection ever comes back empty for a real TrainingDomainId. string? domainCode = dto.DomainCode; if (string.IsNullOrWhiteSpace(domainCode) && dto.TrainingDomainId != 0) { domainCode = await _DomainCodeLookup.GetDomainCodeById(dto.TrainingDomainId, login, ct).ConfigureAwait(false) ?? throw new InvalidOperationException( $"TrainingProgramme {originEntityId} references TrainingDomainId {dto.TrainingDomainId}, which has no DomainCode — cannot promote."); } var payload = new PortablePayload { ProgrammeCode = dto.ProgrammeCode, ProgrammeTitle = dto.ProgrammeTitle, ShortDescription = dto.ShortDescription, FullDescription = dto.FullDescription, DomainCode = domainCode, PassMark = dto.PassMark, ProgrammeType = dto.ProgrammeType, DeliveryMode = dto.DeliveryMode, ComplexityLevel = dto.ComplexityLevel, DifficultyLevel = dto.DifficultyLevel, ProviderType = dto.ProviderType, ProgrammeLevel = dto.ProgrammeLevel, DurationHours = dto.DurationHours, IsRefresher = dto.IsRefresher, HasAssessment = dto.HasAssessment, HasPreAssessment = dto.HasPreAssessment, CertOnComplete = dto.CertOnComplete, CertValidityDays = dto.CertValidityDays, RenewalWindowDays = dto.RenewalWindowDays, MinCapacity = dto.MinCapacity, MaxCapacity = dto.MaxCapacity, CostPerParticipant = dto.CostPerParticipant, LearningObjectives = dto.LearningObjectives, TargetAudience = dto.TargetAudience, Prerequisites = dto.Prerequisites, ProgrammeVersionNo = dto.ProgrammeVersionNo, RevisionRemarks = dto.RevisionRemarks, IsActive = dto.IsActive, Status = dto.Status, SortOrder = dto.SortOrder }; return new PromotionBundleDTO { EntityTypeCode = EntityTypeCode, OriginEntityId = originEntityId, PayloadJson = System.Text.Json.JsonSerializer.Serialize(payload) }; } public Task ValidateForPromotionAsync( PromotionBundleDTO bundle, CancellationToken ct) { var payload = System.Text.Json.JsonSerializer.Deserialize(bundle.PayloadJson); if (payload is null) return Task.FromResult(PromotionValidationResultDTO.Block("TrainingProgramme payload could not be parsed.")); var reasons = new System.Collections.Generic.List(); if (string.IsNullOrWhiteSpace(payload.ProgrammeCode)) reasons.Add("TrainingProgramme.ProgrammeCode is required to promote."); if (string.IsNullOrWhiteSpace(payload.DomainCode)) reasons.Add("TrainingProgramme.TrainingDomainId has no resolvable DomainCode — cannot promote."); if (payload.DurationHours <= 0) reasons.Add("TrainingProgramme.DurationHours must be greater than 0."); if (payload.MinCapacity > payload.MaxCapacity) reasons.Add("TrainingProgramme.MinCapacity cannot exceed MaxCapacity."); var result = reasons.Count == 0 ? PromotionValidationResultDTO.Ok() : new PromotionValidationResultDTO { IsPromotable = false, BlockingReasons = reasons }; return Task.FromResult(result); } public async Task ImportPromotedAsync( PromotionBundleDTO bundle, byte destinationSourceType, int? existingDestinationId, LoginDTO login, CancellationToken ct) { var payload = System.Text.Json.JsonSerializer.Deserialize(bundle.PayloadJson) ?? throw new InvalidOperationException("TrainingProgramme promotion payload could not be parsed."); var domainId = -1; if (!string.IsNullOrWhiteSpace(payload.DomainCode)) { domainId = await _DomainCodeLookup.GetDomainIdByCode(payload.DomainCode, login, ct).ConfigureAwait(false) ?? throw new InvalidOperationException( $"No TrainingDomain with DomainCode '{payload.DomainCode}' exists at this destination."); } var resolvedId = existingDestinationId ?? await _ProgrammeCodeLookup.GetProgrammeIdByCode(payload.ProgrammeCode, login, ct).ConfigureAwait(false) ?? 0; // Update: start from the destination's OWN current full aggregate (its real // Sessions/ProgrammePrerequisites/SkillMaps/CompetencyMaps included) so those child // collections survive SaveTrainingProgramme's delete-and-rebuild-from-DTO update path // untouched — see the class header comment. Insert: nothing exists yet to preserve. TrainingProgrammeDTO dto; if (resolvedId != 0) { var existingJson = await _ProgrammeBLL.GetTrainingProgramme(resolvedId, login).ConfigureAwait(false); dto = JsonConvert.DeserializeObject(existingJson) ?? throw new InvalidOperationException( $"TrainingProgramme {resolvedId} could not be read at the destination for update."); } else { dto = new TrainingProgrammeDTO(); } dto.ProgrammeId = resolvedId; dto.ProgrammeCode = payload.ProgrammeCode; dto.ProgrammeTitle = payload.ProgrammeTitle; dto.ShortDescription = payload.ShortDescription; dto.FullDescription = payload.FullDescription; dto.TrainingDomainId = domainId; dto.PassMark = payload.PassMark; dto.ProgrammeType = payload.ProgrammeType; dto.DeliveryMode = payload.DeliveryMode; dto.ComplexityLevel = payload.ComplexityLevel; dto.DifficultyLevel = payload.DifficultyLevel; dto.ProviderType = payload.ProviderType; dto.ProgrammeLevel = payload.ProgrammeLevel; dto.DurationHours = payload.DurationHours; dto.IsRefresher = payload.IsRefresher; dto.HasAssessment = payload.HasAssessment; dto.HasPreAssessment = payload.HasPreAssessment; dto.CertOnComplete = payload.CertOnComplete; dto.CertValidityDays = payload.CertValidityDays; dto.RenewalWindowDays = payload.RenewalWindowDays; dto.MinCapacity = payload.MinCapacity; dto.MaxCapacity = payload.MaxCapacity; dto.CostPerParticipant = payload.CostPerParticipant; dto.LearningObjectives = payload.LearningObjectives; dto.TargetAudience = payload.TargetAudience; dto.Prerequisites = payload.Prerequisites; dto.ProgrammeVersionNo = payload.ProgrammeVersionNo; dto.RevisionRemarks = payload.RevisionRemarks; dto.IsActive = payload.IsActive; dto.Status = payload.Status; dto.SortOrder = payload.SortOrder; dto.SourceType = destinationSourceType; await _ProgrammeBLL.SaveTrainingProgramme(dto, login).ConfigureAwait(false); return dto.ProgrammeId; // mutated in place by SaveTrainingProgramme when isNew } } }