using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using GB5Shared.DTO.Qualifier; using GB5Shared.GOP.Qualifier; namespace GB5Shared.GB5Library.Qualifier { // ============================================================ // QualifierFacade — compatibility shim over the relocated GOP // qualifier engine (IQualifierFacadeImpl, GB5Shared.GOP.Qualifier). // // BaseEntityAppService and 64+ BLL callers across GB5Solution // keep calling this 5-method interface unchanged; internally every // call now translates into one IQualifierFacadeImpl.ExecuteStageAsync // call against the versioned/DAG-scheduled engine instead of the // old QualifierEngine/DbEnrichExecutor stack. // // Stage mapping (old QualifierStage enum -> new Stage byte, by NAME — // the numeric values do not line up, see GOP_DDL.sql's MQUALIFIERSCOPE.STAGE // comment: 1=PreValidate,2=PreSave,3=PreWorkflow,4=PreEventPublish, // 5=PreNotification,6=PostSave,7=PreExport,8=PrePrint): // PreValidate -> 1 (PreValidate) // PreWorkflow -> 3 (PreWorkflow) // PrePersist -> 2 (PreSave) // PostPersist -> 6 (PostSave) // PreEventPublish -> 4 (PreEventPublish) // // Scope mapping: only EnrichForWorkflowAsync has a dedicated Scope value // (1=Workflow, confirmed by real Phase 1 data for QUALIFIERID 2/1002/1003). // The other four reuse Scope=2 (Save) — no dedicated value exists for // Validate/PrePersist/PostPersist/Event in the current convention. // ============================================================ public class QualifierFacade : IQualifierFacade { private const byte ScopeWorkflow = 1; private const byte ScopeSave = 2; private const string FactBagCacheKey = "__GopFacts"; private readonly IQualifierFacadeImpl _engine; private readonly IEntityCodeResolver _entityCodeResolver; public QualifierFacade(IQualifierFacadeImpl engine, IEntityCodeResolver entityCodeResolver) { _engine = engine; _entityCodeResolver = entityCodeResolver; } public Task ValidateAsync(ExecuteContext ctx) { ctx.QualifierType = QualifierType.Validate; return RunAsync(ctx, MapStage(QualifierStage.PreValidate), ScopeSave); } public Task EnrichForWorkflowAsync(ExecuteContext ctx) { ctx.QualifierType = QualifierType.Workflow; return RunAsync(ctx, MapStage(QualifierStage.PreWorkflow), ScopeWorkflow); } public Task EnrichForEventAsync(ExecuteContext ctx) { ctx.QualifierType = QualifierType.Event; return RunAsync(ctx, MapStage(QualifierStage.PreEventPublish), ScopeSave); } public Task ExecuteStageAsync(QualifierStage stage, ExecuteContext ctx) { ctx.QualifierType = QualifierType.BusinessRule; return RunAsync(ctx, MapStage(stage), ScopeSave); } public async Task PreviewAsync( string entityName, QualifierStage stage, object data, Dictionary? initialContext = null) { if (string.IsNullOrWhiteSpace(entityName)) throw new ArgumentException("Entity name is required", nameof(entityName)); var ctx = new ExecuteContext { EntityName = entityName, Data = data!, EntityId = 0, QualifierType = QualifierType.BusinessRule }; if (initialContext != null) foreach (var kv in initialContext) ctx.Set(kv.Key, kv.Value); try { await RunAsync(ctx, MapStage(stage), ScopeSave); return QualifierInvocationResult.Success(ctx.ExportBag()); } catch (ValidationException ex) { return QualifierInvocationResult.Failure(ctx.ExportBag(), ex.Errors); } catch (Exception ex) { return QualifierInvocationResult.Failure(ctx.ExportBag(), new List { new() { Field = "System", Message = ex.Message } }); } } // ═════════════════════════════════════════════════════════ // PRIVATE ADAPTER // ═════════════════════════════════════════════════════════ private async Task RunAsync(ExecuteContext ctx, byte stage, byte scope) { var login = ctx.Login ?? throw new InvalidOperationException( "QualifierFacade requires ExecuteContext.Login to be set before calling the qualifier engine."); var entityCode = await _entityCodeResolver.ResolveAsync(ctx.EntityId, login) ?? string.Empty; // Reuse one FactBag across the whole ExecuteSaveAsync pipeline (cached on ctx // itself), so a fact EnrichForWorkflowAsync writes is still visible to the // later ExecuteStageAsync(PrePersist) call — matching the old engine's single // shared ExecuteContext semantics. if (!ctx.TryGetCached(FactBagCacheKey, out var factBag)) { factBag = new QualifierFactBag(); ctx.SetCached(FactBagCacheKey, factBag); } // Seed/refresh Document facts from everything the old flat bag has accumulated // so far (caller-supplied Facts, the WIP flag, prior stages' outputs) — visible // to the new engine's NCalcEvaluator/DataSourceExecutor via ctx.Facts.ExportAll(). // "Transaction"/"Login" are excluded — same reason EventHandler.cs's own // BuildContextJson excludes them from its bag export: they hold a live // DbTransaction/LoginDTO, not serializable data, and DataSourceExecutor forwards // every Document fact as a query parameter — a raw DbTransaction there crashes // GBQueryExecutor's parameter/log serialization. foreach (var kv in ctx.ExportBag()) { if (kv.Key is "Transaction" or "Login") continue; factBag.SetDocumentFact(kv.Key, kv.Value); } // DTO fields bound as Document root properties — same source the old // DbEnrichExecutor read directly via ctx.Data. var document = JsonDocument.Parse(JsonSerializer.Serialize(ctx.Data)); var qCtx = new QualifierExecutionContext { ClientId = login.ClientId, EntityId = ctx.EntityId, EntityCode = entityCode, Stage = stage, Scope = scope, Document = document, Facts = factBag, CorrelationId = ctx.ObjectId != 0 ? ctx.ObjectId.ToString() : Guid.NewGuid().ToString() }; var result = await _engine.ExecuteStageAsync(qCtx, login, CancellationToken.None); // Flatten Document facts back into the OLD flat bag — WorkflowStartRequest.Facts // (built from ctx.ExportBag() in EventHandler.cs) and WorkFlowEngine's // StrategyType=2 case both read this. Missing this step silently reintroduces // the exact bug Phase 0 fixed, from a new root cause. if (result.Facts.TryGetValue("Document", out var docObj) && docObj is IReadOnlyDictionary docFacts) { foreach (var (k, v) in docFacts) if (v is not null) ctx.Set(k, v); } if (result.HasErrors) { var errors = result.Findings .Where(f => f.Severity == FindingSeverity.Error) .Select(f => new ValidationError { Field = f.DocumentPath, Message = f.Message }) .ToList(); throw new ValidationException(errors); } return result; } private static byte MapStage(QualifierStage stage) => stage switch { QualifierStage.PreValidate => 1, // PreValidate QualifierStage.PreWorkflow => 3, // PreWorkflow QualifierStage.PrePersist => 2, // PreSave QualifierStage.PostPersist => 6, // PostSave QualifierStage.PreEventPublish => 4, // PreEventPublish _ => 2 // PreSave — unreachable, enum has no other members }; } public sealed class ValidationException : Exception { public IReadOnlyList Errors { get; } public ValidationException(IEnumerable errors) : base("Validation failed") => Errors = errors.ToList(); } public sealed class QualifierInvocationResult { public bool IsSuccess { get; } public IReadOnlyDictionary Context { get; } public IReadOnlyList? Errors { get; } private QualifierInvocationResult(bool isSuccess, IReadOnlyDictionary context, IReadOnlyList? errors = null) { IsSuccess = isSuccess; Context = context; Errors = errors; } public static QualifierInvocationResult Success(IReadOnlyDictionary context) => new(true, context); public static QualifierInvocationResult Failure(IReadOnlyDictionary context, IReadOnlyList errors) => new(false, context, errors); } }