using AdminBLL.BIZTransactionType; using AdminDAL.DTO.BIZTransactionType; using CRMBLL.Task; using CRMDAL.DTO.Task; using Dapper; using FMBLL.Asset; using FMDAL.CustomCode.AssetActivity; using FMDAL.DTO.Asset; using GB5Shared.Connection; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.Framework.ServerConfig; using GB5Shared.QueryExecutor; using GB5Shared.Telemetry; using Microsoft.Data.SqlClient; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Quartz; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using static GB5Shared.GB5Constant.Constant; namespace FMBLL.Handlers { // Evaluates each MASSETACTIVITY row's own CRONEXPRESSION against LASTGENERATEDTILL and, // when due, creates a TTASK via CRMBLL.ITaskBLL — reusing CRM's own validation/save/number // generation rather than re-implementing it. BizTransactionTypeId is never hardcoded: it is // resolved per-run via AdminBLL's TOP-1 lookup by BizTransactionClass + the asset's OU // (PeriodId wildcarded — MBIZTRANSACTIONTYPE list is not period-scoped for this class). If no // BizTransactionType is configured for that OU, the row is logged and skipped, per the // confirmed "alert and exit" business rule — it never blocks the rest of the batch. [DisallowConcurrentExecution] public class AssetActivitySchedulerJob : IJob { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; // Same MSERVERCONFIG shape/filter as JobEngineSL's OutBoxPollerService — every // active tenant database, excluding the shared ACTIVITI (workflow engine) config row. private const string TenantQuery = @" SELECT SERVERCONFIG1.CLIENTID AS ClientId, SERVERCONFIG1.DATABASENAME AS DatabaseName, SERVERCONFIG1.DATABASETYPE AS DbType, SERVERCONFIG1.CONNECTIONNAME AS ConnectionName FROM MSERVERCONFIG SERVERCONFIG1 JOIN MSERVER SERVER1 ON SERVERCONFIG1.SERVERID = SERVER1.SERVERID WHERE SERVERCONFIG1.STATUS = 1 AND SERVERCONFIG1.CONNECTIONNAME <> 'ACTIVITI'"; public AssetActivitySchedulerJob(IServiceScopeFactory scopeFactory, ILogger logger) { _scopeFactory = scopeFactory; _logger = logger; } public async System.Threading.Tasks.Task Execute(IJobExecutionContext context) { using var activity = GB5Trace.BeginSection("assetactivity-scheduler-job"); var ct = context.CancellationToken; using var loadScope = _scopeFactory.CreateScope(); var appConnection = loadScope.ServiceProvider.GetRequiredService(); List tenants; try { tenants = (await LoadTenantsAsync(appConnection).ConfigureAwait(false)).ToList(); } catch (Exception ex) { _logger.LogError(ex, "AssetActivitySchedulerJob: failed to load tenant list from MSERVERCONFIG"); return; } foreach (var tenant in tenants) { ct.ThrowIfCancellationRequested(); await RunForTenantAsync(tenant, ct).ConfigureAwait(false); } } private async System.Threading.Tasks.Task RunForTenantAsync(ServerConfigDTO tenant, CancellationToken ct) { using var scope = _scopeFactory.CreateScope(); var assetActivityDal = scope.ServiceProvider.GetRequiredService(); var assetBll = scope.ServiceProvider.GetRequiredService(); var bizTransactionTypeBll = scope.ServiceProvider.GetRequiredService(); var taskBll = scope.ServiceProvider.GetRequiredService(); var queryExecutor = scope.ServiceProvider.GetRequiredService(); var login = new LoginDTO { ClientId = tenant.ClientId, UserId = -1, DatabaseName = tenant.DatabaseName, IsSchedulerRun = 1 }; var now = DateTime.UtcNow; IEnumerable dueRows; try { dueRows = await assetActivityDal.GetAssetActivityDueForScheduling(login, ct).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "AssetActivitySchedulerJob: failed to load due activities for tenant {ClientId} ({DatabaseName})", tenant.ClientId, tenant.DatabaseName); return; } foreach (var row in dueRows) { GB5Trace.Step("evaluate-assetactivity-schedule", new { row.AssetActivityId }); try { if (string.IsNullOrWhiteSpace(row.AssetActivityCronExpression)) continue; CronExpression cron; try { cron = new CronExpression(row.AssetActivityCronExpression); } catch (Exception ex) { _logger.LogWarning(ex, "AssetActivitySchedulerJob: invalid CronExpression for AssetActivityId {Id} — skipping", row.AssetActivityId); continue; } var baseline = row.AssetActivityLastGeneratedTill == default ? now.AddDays(-1) : row.AssetActivityLastGeneratedTill; var nextRunOffset = cron.GetNextValidTimeAfter(new DateTimeOffset(baseline, TimeSpan.Zero)); if (nextRunOffset is null || nextRunOffset.Value.UtcDateTime > now) continue; // not due yet var nextRun = nextRunOffset.Value.UtcDateTime; var assetJson = await assetBll.GetAsset(row.AssetId, login).ConfigureAwait(false); var asset = string.IsNullOrWhiteSpace(assetJson) ? null : JsonConvert.DeserializeObject(assetJson); if (asset is null) { _logger.LogWarning("AssetActivitySchedulerJob: AssetId {AssetId} not found for AssetActivityId {Id} — skipping", row.AssetId, row.AssetActivityId); continue; } var criteria = new CriteriaDTO { SectionCriteriaList = new List { new SectionCriteriaDTO { AttributesCriteriaList = new List { new AttributesCriteriaDTO { FieldName = "BIZTransactionTypeClassId", OperationType = CriteriaDTO.OperationType.Equal, FieldValue = BIZTRANSACTIONCLASSCONSTANT.SCHEDULEDMAINTENANCETASK } } } } }; var bizTypeJson = await bizTransactionTypeBll.GetBizTransactionTypeList(criteria, asset.OUId, login).ConfigureAwait(false); var bizTypes = string.IsNullOrWhiteSpace(bizTypeJson) ? new List() : JsonConvert.DeserializeObject>(bizTypeJson) ?? new(); var bizType = bizTypes.FirstOrDefault(); if (bizType is null) { _logger.LogWarning( "AssetActivitySchedulerJob: no MBIZTRANSACTIONTYPE configured for BizTransactionClass {ClassId} / OU {OUId} — " + "skipping AssetActivityId {Id}. Configure a BizTransactionType for this class and OU.", BIZTRANSACTIONCLASSCONSTANT.SCHEDULEDMAINTENANCETASK, asset.OUId, row.AssetActivityId); continue; } var taskDate = DateOnly.FromDateTime(nextRun); var graceDays = row.AssetActivityGraceTime > 0 ? row.AssetActivityGraceTime : 0; var endDate = taskDate.AddDays(graceDays); var taskDto = new TaskDTO { BizTransactionTypeId = bizType.Id, TaskDate = taskDate, TaskDescription = string.IsNullOrWhiteSpace(row.ActivityName) ? $"Scheduled Maintenance: Activity {row.ActivityId} (Asset {row.AssetId})" : $"Scheduled Maintenance: {row.ActivityName} (Asset {row.AssetId})", TaskDetailType = 3, // Work — 0=MileStone,1=RollUp,2=Heading,3=Work per DocumentNumberService's convention AssetId = row.AssetId, ActivityId = row.ActivityId, TaskPlanStartOn = taskDate, TaskPlanEndOn = endDate, TaskDueOn = endDate, TaskExpireOn = endDate, TaskPlannedHours = row.AssetActivityDuration, ObjectTypeId = EntityConstant.OBJECTASSETACTIVITY, ObjectId = row.AssetActivityId }; // TaskAssignedToType: 0=User, 1=UserGroup, 2=Role, 3=Incharge/Employee // (confirmed via legacy GB4Solution/BLL/FMBLL/Task/TaskBLL.cs comments) switch (row.AssetActivityTaskAssignedType) { case 0: taskDto.TaskAssignedToType = 0; taskDto.AssignedToUserId = row.UserId; break; case 1: taskDto.TaskAssignedToType = 1; taskDto.AssignedToUserGroupId = row.TaskUserGroupId; break; case 2: taskDto.TaskAssignedToType = 2; taskDto.AssignedToRoleId = row.RoleId; break; case 3: taskDto.TaskAssignedToType = 3; taskDto.AssignedToEmployeeId = row.EmployeeId; break; } GB5Trace.Step("create-scheduled-maintenance-task", new { row.AssetActivityId, row.AssetId, BizTransactionTypeId = bizType.Id }); await taskBll.SaveTask(new List { taskDto }, login).ConfigureAwait(false); await using var tx = await queryExecutor.BeginTransactionAsync(login).ConfigureAwait(false); try { await assetActivityDal.UpdateAssetActivityLastGeneratedTill(row.AssetActivityId, nextRun, login, tx, ct).ConfigureAwait(false); await tx.CommitAsync(ct).ConfigureAwait(false); } catch { await tx.RollbackAsync(ct).ConfigureAwait(false); throw; } } catch (Exception ex) { GB5Trace.MarkFailed("assetactivity-scheduler-job-item-failed", ex); _logger.LogError(ex, "AssetActivitySchedulerJob failed for AssetActivityId {Id}", row.AssetActivityId); } } } private static async Task> LoadTenantsAsync(IApplicationConnection appConnection) { var systemConn = await appConnection.Gb5SystemConnectionString().ConfigureAwait(false); using var conn = new SqlConnection(systemConn); return await conn.QueryAsync(TenantQuery).ConfigureAwait(false); } } }