using System.Text.Json; using GB5Shared.ValidationCriteria; using GB5Shared.DTO.Framework.Taxonomy; using GB5Shared.DTO.Framework.Login; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using GB5Shared.Validation; using WiBLL.Common; using WiBLL.WiMaster; using WiBLL.WiWorkOrder; using WiDAL.CustomCode.WiAssignment; using WiDAL.CustomCode.WiExecution; using WiDAL.CustomCode.WiMaster; using WiDAL.DTOs; namespace WiBLL.WiExecution; public class WiExecutionBLL : IWiExecutionBLL { private readonly IWiExecutionDAL _dal; private readonly IWiMasterDAL _masterDal; private readonly IWiAssignmentDAL _assignmentDal; private readonly IWiWorkOrderBLL _workOrderBll; private readonly IValidationCriteriaEvaluator _validationEvaluator; private readonly AutoNumber _autoNumber; private readonly IValidation _validation; private readonly IWiHubNotifier _hubNotifier; public WiExecutionBLL( IWiExecutionDAL dal, IWiMasterDAL masterDal, IWiAssignmentDAL assignmentDal, IWiWorkOrderBLL workOrderBll, IValidationCriteriaEvaluator validationEvaluator, AutoNumber autoNumber, IValidation validation, IWiHubNotifier hubNotifier) { _dal = dal; _masterDal = masterDal; _assignmentDal = assignmentDal; _workOrderBll = workOrderBll; _validationEvaluator = validationEvaluator; _autoNumber = autoNumber; _validation = validation; _hubNotifier = hubNotifier; } // ── Resolve ─────────────────────────────────────────────────────────────── public async Task ResolveForWorkstation( int workStationId, TriggerEvent trigger, LoginDTO login, CancellationToken ct, int workCenterId = -1, int routingId = -1, int routingOperationId = -1, int modelId = -1, int itemGroupId = -1) { GB5Trace.Step("resolve-workstation", new { workStationId, trigger, workCenterId, routingId, routingOperationId, modelId, itemGroupId }); var assignments = await _assignmentDal .ResolveForWorkstation(workStationId, (int)trigger, login, ct, workCenterId, routingId, routingOperationId, modelId, itemGroupId) .ConfigureAwait(false); var matches = AnnotateMatches(assignments); if (matches.Count > 0) { GB5Trace.Step("resolve-workstation-matched", new { workStationId, matchCount = matches.Count }); return JsonSerializer.Serialize(new WiResolveResultDTO { Matches = matches }); } var diagnostics = await _assignmentDal .DiagnoseForWorkstation(workStationId, login, ct, workCenterId, routingId, routingOperationId, modelId, itemGroupId) .ConfigureAwait(false); var result = BuildZeroMatchResult(diagnostics); GB5Trace.MarkFailed($"resolve-workstation-no-match:{result.ReasonCode}"); return JsonSerializer.Serialize(result); } public async Task ResolveForEntity( int entityType, int entityId, TriggerEvent trigger, LoginDTO login, CancellationToken ct) { GB5Trace.Step("resolve-entity", new { entityType, entityId, trigger }); var assignments = await _assignmentDal .ResolveForEntity(entityType, entityId, (int)trigger, login, ct) .ConfigureAwait(false); var matches = AnnotateMatches(assignments); if (matches.Count > 0) { GB5Trace.Step("resolve-entity-matched", new { entityType, entityId, matchCount = matches.Count }); return JsonSerializer.Serialize(new WiResolveResultDTO { Matches = matches }); } var diagnostics = await _assignmentDal .DiagnoseForEntity(entityType, entityId, login, ct) .ConfigureAwait(false); var result = BuildZeroMatchResult(diagnostics); GB5Trace.MarkFailed($"resolve-entity-no-match:{result.ReasonCode}"); return JsonSerializer.Serialize(result); } // Zero-match path only — tells apart "nothing matches these dimensions at all" from // "a dimension match exists but is outside its effective window or has no schedule // for this trigger" (the latter is inferred: the diagnostic query has no schedule/date // filter, so any row it returns matched dimensions but was excluded by one of those). private static WiResolveResultDTO BuildZeroMatchResult(IEnumerable diagnostics) { var rows = diagnostics.ToList(); if (rows.Count == 0) { return new WiResolveResultDTO { ReasonCode = WiResolveReasonCode.NoAssignmentExistsForEntity, ReasonMessage = "No WI assignment exists for these dimensions." }; } var today = DateTime.UtcNow.Date; bool anyWithinWindow = rows.Any(r => (r.EffectiveFrom is null || r.EffectiveFrom.Value.Date <= today) && (r.EffectiveTo is null || r.EffectiveTo.Value.Date >= today)); return anyWithinWindow ? new WiResolveResultDTO { ReasonCode = WiResolveReasonCode.NoScheduleForTriggerEvent, ReasonMessage = "A matching WI assignment exists, but has no active schedule for this trigger event." } : new WiResolveResultDTO { ReasonCode = WiResolveReasonCode.OutsideEffectiveDateWindow, ReasonMessage = "A matching WI assignment exists, but is outside its effective date window." }; } // Marks the highest-SpecificityScore candidate(s) as the winner and lists, per candidate, // which dimensions actually matched vs. wildcarded — the "how did it narrow down" view. // Reuses WiMasterBLL.SpecificityDimensions so this never drifts from the save-time scorer. private static List AnnotateMatches(IEnumerable assignments) { var list = assignments.ToList(); if (list.Count == 0) return []; var maxScore = list.Max(a => a.SpecificityScore); return list.Select(a => new WiResolveMatchDTO { Match = a, IsWinner = a.SpecificityScore == maxScore, DimensionMatches = WiMasterBLL.SpecificityDimensions .Select(d => new WiResolveDimensionMatchDTO { Dimension = d.Dimension, IsWildcard = d.Selector(a) == -1, Weight = d.Weight }) .ToList() }).ToList(); } // ── Start execution ─────────────────────────────────────────────────────── public async Task StartExecution( StartExecutionRequestDTO req, LoginDTO login, CancellationToken ct) { // Gate 0 — WorkOrderId, when supplied, must be a real TINDENTDETAIL row (previously // accepted as an opaque int and never checked). -1/0 = no specific work order tied to // this run (e.g. a non-manufacturing WI resolved via EntityType/EntityId) — not an error. if (req.WorkOrderId > 0) { var workOrderExists = await _workOrderBll.ValidateWorkOrderExists(req.WorkOrderId, login, ct) .ConfigureAwait(false); if (!workOrderExists) throw new InvalidOperationException($"WorkOrderId {req.WorkOrderId} does not exist."); } // Gate 1 — Acknowledgement check var ack = await _dal.CheckAcknowledgement(req.WiId, req.EmployeeId, req.WorkOrderId, login, ct) .ConfigureAwait(false); if (ack is null || !ack.IsValid) throw new InvalidOperationException( "WI_ACK_REQUIRED: Acknowledgement required before execution."); // Gate 2 — Skill enforcement (Block-level) var skillReqs = await _dal.GetSkillRequirements(req.WiId, login, ct).ConfigureAwait(false); var empLevels = await _dal.GetEmployeeSkillLevels(req.EmployeeId, login, ct).ConfigureAwait(false); var empLevelMap = empLevels.ToDictionary(e => e.SkillId, e => e.ProficiencyNo); var blockingDeficits = skillReqs .Where(sr => sr.Enforcement == Enforcement.Block) .Where(sr => !empLevelMap.TryGetValue(sr.SkillId, out short empLevel) || empLevel < sr.MinLevelNo) .Select(sr => sr.SkillName ?? sr.SkillId.ToString()) .ToList(); if (blockingDeficits.Count > 0) throw new InvalidOperationException( $"WI_SKILL_BLOCK: Skill requirements not met: {string.Join(", ", blockingDeficits)}."); // Gate 2b — Validation Criteria enforcement (Block-level), e.g. "must pass this quiz first" — // resolved generically via GB5Framework's IValidationCriteriaEvaluator, additive to the // Skill/Competency gates above (see roadmap doc's Phase 1 "loosely-coupled gating" section). var unmetValidationCriteria = await _validationEvaluator .GetUnmetValidationCriteriaAsync(TaxonomyEntityTypeConstant.WORK_INSTRUCTION, req.WiId, -2, req.EmployeeId, login, ct) .ConfigureAwait(false); var blockingValidationDeficits = unmetValidationCriteria .Where(r => r.Enforcement == (byte)Enforcement.Block) .Select(r => r.Description ?? $"ValidationCriteria {r.ValidationCriteriaId}") .ToList(); if (blockingValidationDeficits.Count > 0) throw new InvalidOperationException( $"WI_VALIDATION_BLOCK: Validation criteria not met: {string.Join(", ", blockingValidationDeficits)}."); // Gate 3 — Create execution record var wi = await _masterDal.GetWiById(req.WiId, login, ct).ConfigureAwait(false); var dto = new WiExecutionDTO { WiId = req.WiId, WiActivityScheduleId = req.WiActivityScheduleId, WorkOrderId = req.WorkOrderId, WorkStationId = req.WorkStationId, EmployeeId = req.EmployeeId, ActivityType = req.ActivityType, TriggerEvent = req.TriggerEvent, SerialNo = req.SerialNo, LotNo = req.LotNo, StdCycleSecs = (int)((wi?.StdCycleTimeMins ?? 0m) * 60m), ExecStatus = ExecStatus.InProgress, TenantId = login.ClientId, StartedOn = DateTime.UtcNow, CreatedById = login.UserId, ModifiedById = login.UserId }; GB5Shared.DTO.Framework.AutoNumber.AutoNumberDTO? an = null; GB5Shared.DTO.Framework.AutoNumber.AutoNumberDTO? stepExecAn = null; try { an = await _autoNumber.GetNumberAsync(1, "WIEXECUTION", login).ConfigureAwait(false); dto.WiExecutionId = an.StartNumber; await _dal.InsertExecution(dto, login, ct).ConfigureAwait(false); // Seed one Pending row per active step — without this, CompleteStep has nothing to // update (previously silently affected 0 rows) and no step-execution history exists // for this run at all. var steps = (await _dal.GetActiveStepsForWi(req.WiId, login, ct).ConfigureAwait(false)).ToList(); if (steps.Count > 0) { stepExecAn = await _autoNumber.GetNumberAsync(steps.Count, "WISTEPEXECUTION", login).ConfigureAwait(false); int nextStepExecId = stepExecAn.StartNumber; foreach (var step in steps) { await _dal.InsertStepExecution(new WiStepExecutionDTO { WiStepExecutionId = nextStepExecId++, WiExecutionId = dto.WiExecutionId, WiStepId = step.WiStepId, SlNo = step.WiStepSlNo, StdDurationSecs = step.StdDurationSecs, AdvanceTrigger = StepAdvanceMode.Manual }, login, ct).ConfigureAwait(false); } } await _hubNotifier.NotifyExecutionChangedAsync(dto.WorkStationId, dto.WiExecutionId, (byte)ExecStatus.InProgress, ct) .ConfigureAwait(false); return $"{SuccessResponse.SaveSuccess}"; } catch { if (stepExecAn != null) await _autoNumber.RollbackAutoNumber("WISTEPEXECUTION", stepExecAn.StartNumber, login) .ConfigureAwait(false); if (an != null) await _autoNumber.RollbackAutoNumber("WIEXECUTION", an.StartNumber, login) .ConfigureAwait(false); throw; } } // ── Complete step ───────────────────────────────────────────────────────── public async Task CompleteStep(CompleteStepRequestDTO req, LoginDTO login, CancellationToken ct) { var rows = await _dal.UpdateStepStatus(req, login, ct).ConfigureAwait(false); if (rows == 0) throw new InvalidOperationException($"WiStepExecution {req.WiStepExecutionId} not found."); var workStationId = await _dal.GetWorkStationIdForExecution(req.WiExecutionId, login, ct).ConfigureAwait(false); if (workStationId is not null) await _hubNotifier.NotifyExecutionChangedAsync(workStationId.Value, req.WiExecutionId, (byte)ExecStatus.InProgress, ct) .ConfigureAwait(false); return SuccessResponse.UpdateSuccess; } // ── Step-execution history for a run ────────────────────────────────────── public async Task GetStepExecutions(int wiExecutionId, LoginDTO login, CancellationToken ct) { var steps = await _dal.GetStepExecutionsByExecution(wiExecutionId, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(steps); } // ── Complete execution ──────────────────────────────────────────────────── public async Task CompleteExecution( int wiExecutionId, int totalActualSecs, LoginDTO login, CancellationToken ct) { var workStationId = await _dal.GetWorkStationIdForExecution(wiExecutionId, login, ct).ConfigureAwait(false); await _dal.UpdateExecutionStatus( wiExecutionId, (byte)ExecStatus.Completed, totalActualSecs, null, login.UserId, login, ct) .ConfigureAwait(false); if (workStationId is not null) await _hubNotifier.NotifyExecutionChangedAsync(workStationId.Value, wiExecutionId, (byte)ExecStatus.Completed, ct) .ConfigureAwait(false); return SuccessResponse.UpdateSuccess; } // ── Evaluate eligibility (query-only — display before commit, no throw) ──── public async Task EvaluateEligibility(int wiId, int employeeId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("evaluate-eligibility", new { wiId, employeeId }); var wi = await _masterDal.GetWiById(wiId, login, ct).ConfigureAwait(false); if (wi is null) { GB5Trace.MarkFailed("evaluate-eligibility-wi-not-found"); return JsonSerializer.Serialize(new WiEligibilityResultDTO { WiId = wiId, EmployeeId = employeeId, IsEligible = false, EvaluationStatus = EligibilityEvaluationStatus.WiNotFound }); } var skillReqs = await _dal.GetSkillRequirements(wiId, login, ct).ConfigureAwait(false); var competencyReqs = await _dal.GetCompetencyRequirements(wiId, login, ct).ConfigureAwait(false); var empLevels = await _dal.GetEmployeeSkillLevels(employeeId, login, ct).ConfigureAwait(false); var empLevelMap = empLevels.ToDictionary(e => e.SkillId, e => e.ProficiencyNo); var requirements = new List(); foreach (var sr in skillReqs) { var hasLevel = empLevelMap.TryGetValue(sr.SkillId, out var empLevel); requirements.Add(new WiEligibilityRequirementDTO { RequirementType = EligibilityRequirementType.Skill, RequirementId = sr.WiSkillRequirementId, Name = sr.SkillName, RequiredLevel = sr.MinLevelNo, EmployeeLevel = hasLevel ? empLevel : null, Enforcement = sr.Enforcement, IsMandatory = sr.IsMandatory, Status = (hasLevel && empLevel >= sr.MinLevelNo) ? EligibilityStatus.Met : EligibilityStatus.NotMet }); } // Competency requirements have no employee-competency data anywhere in the system today — // always Unverifiable, never silently Met/NotMet/omitted. foreach (var cr in competencyReqs) { requirements.Add(new WiEligibilityRequirementDTO { RequirementType = EligibilityRequirementType.Competency, RequirementId = cr.WiCompetencyRequirementId, Name = cr.CompetencyName, RequiredLevel = cr.MinLevelNo, EmployeeLevel = null, Enforcement = cr.Enforcement, IsMandatory = cr.IsMandatory, Status = EligibilityStatus.Unverifiable }); } // GB5Framework Validation Criteria (Quiz/ProgrammeAssessment/Skill/Certification, resolved // generically) — additive alongside the WI-native Skill/Competency requirements above. Only // unmet criteria come back from the evaluator, so anything it returns is NotMet or // Unverifiable, never Met. var unmetValidationCriteria = await _validationEvaluator .GetUnmetValidationCriteriaAsync(TaxonomyEntityTypeConstant.WORK_INSTRUCTION, wiId, -2, employeeId, login, ct) .ConfigureAwait(false); foreach (var vc in unmetValidationCriteria) { requirements.Add(new WiEligibilityRequirementDTO { RequirementType = EligibilityRequirementType.ValidationCriteria, RequirementId = vc.ValidationCriteriaId, Name = vc.Description, RequiredLevel = 0, EmployeeLevel = null, Enforcement = (Enforcement)vc.Enforcement, IsMandatory = vc.IsMandatory != 0, Status = vc.Status == ValidationCriteriaStatus.Unverifiable ? EligibilityStatus.Unverifiable : EligibilityStatus.NotMet }); } var isEligible = !requirements.Any(r => (r.RequirementType == EligibilityRequirementType.Skill || r.RequirementType == EligibilityRequirementType.ValidationCriteria) && r.Enforcement == Enforcement.Block && r.Status == EligibilityStatus.NotMet); var result = new WiEligibilityResultDTO { WiId = wiId, EmployeeId = employeeId, IsEligible = isEligible, EvaluationStatus = requirements.Count == 0 ? EligibilityEvaluationStatus.NoRequirements : EligibilityEvaluationStatus.Evaluated, Requirements = requirements }; GB5Trace.Step("evaluate-eligibility-result", new { wiId, employeeId, isEligible, requirementCount = requirements.Count }); return JsonSerializer.Serialize(result); } // ── Employee shop-floor entry scan ────────────────────────────────────────── public async Task GetCurrentAssignmentForEmployee(int employeeId, DateTime? rosterDate, LoginDTO login, CancellationToken ct) { var assignment = await _dal.GetCurrentAssignmentForEmployee(employeeId, rosterDate ?? DateTime.UtcNow, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(assignment); } // ── Acknowledgement ─────────────────────────────────────────────────────── public async Task CheckAcknowledgement( int wiId, int employeeId, int workOrderId, LoginDTO login, CancellationToken ct) { var ack = await _dal.CheckAcknowledgement(wiId, employeeId, workOrderId, login, ct) .ConfigureAwait(false); return JsonSerializer.Serialize(ack); } public async Task SaveAcknowledgement( WiAcknowledgementDTO dto, LoginDTO login, CancellationToken ct) { if (dto.WorkOrderId > 0) { var workOrderExists = await _workOrderBll.ValidateWorkOrderExists(dto.WorkOrderId, login, ct) .ConfigureAwait(false); if (!workOrderExists) throw new InvalidOperationException($"WorkOrderId {dto.WorkOrderId} does not exist."); } dto.TenantId = login.ClientId; dto.AcknowledgedOn = DateTime.UtcNow; dto.IsValid = true; dto.ModifiedById = login.UserId; dto.CreatedById = login.UserId; GB5Shared.DTO.Framework.AutoNumber.AutoNumberDTO? an = null; try { an = await _autoNumber.GetNumberAsync(1, "TWIACKNOWLEDGEMENT", login).ConfigureAwait(false); dto.AcknowledgementId = an.StartNumber; await _dal.InsertAcknowledgement(dto, login, ct).ConfigureAwait(false); return $"{SuccessResponse.SaveSuccess}"; } catch { if (an != null) await _autoNumber.RollbackAutoNumber("TWIACKNOWLEDGEMENT", an.StartNumber, login) .ConfigureAwait(false); throw; } } }