Pārlūkot izejas kodu

feat(t8-kpi-sched): add auto refresh jobs and failure notices

S5-S6-S7-T8-KPI-AUTO-SCHEDULE-AND-FAILURE-NOTIFICATION-1

* Add S5/S6/S7 MdpRefreshJob with Cron triggers at 02:00 / 07:00 / 12:00 / 17:00 / 22:00 daily.
* Keep 22:01~01:59 as the quiet window before the next 02:00 run.
* Set Concurrent=false and call SxMdpSyncTransformService.RunFullAsync(ct, "AUTO").
* Add failure notification after mdp_transform_run_log is marked FAILED.
* Publish SysNotice to superAdmin.NET and log notification failures without blocking the original run.
* Rely on Furion scheduler preload to persist built-in Cron jobs into SysJobDetail and SysJobTrigger.

Smoke:

* dotnet build 0 Error.
* clean worktree backend started successfully after 1.0.181 migration hash repair.
* 3 jobs appended to the in-memory scheduler.
* 3 SysJobDetail and 3 SysJobTrigger rows verified in DB.
* Cron args verified as ["0 2,7,12,17,22 * * *",0].
* RunOnStart=0 and Concurrent=false verified.
YY968XX 1 dienu atpakaļ
vecāks
revīzija
98393f6f5c

+ 38 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/S7MdpSyncTransformService.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Core.Service;
+using Microsoft.Extensions.Logging;
 using System.Text.Json;
 
 namespace Admin.NET.Plugin.AiDOP.FinishedWarehouse;
@@ -10,14 +12,24 @@ namespace Admin.NET.Plugin.AiDOP.FinishedWarehouse;
 public class S7MdpSyncTransformService : ITransient
 {
     private readonly ISqlSugarClient _db;
+    private readonly SysNoticeService _sysNoticeService;
+    private readonly ILogger<S7MdpSyncTransformService> _logger;
     private const string JobCode = "S7_MDP_SYNC_TRANSFORM";
     private const string JobName = "S7 成品仓储 MDP 同步与转换";
     private const string T8ConfigId = "t8_v5";
     private const string ModuleCode = "S7";
-
-    public S7MdpSyncTransformService(ISqlSugarClient db)
+    // FAILURE-NOTIFICATION-1:超级管理员 superAdmin.NET(AccountType=999)
+    private const long NoticeReceiverUserId = 1300000000101L;
+    private const string NoticeReceiverUserName = "超级管理员";
+
+    public S7MdpSyncTransformService(
+        ISqlSugarClient db,
+        SysNoticeService sysNoticeService,
+        ILogger<S7MdpSyncTransformService> logger)
     {
         _db = db;
+        _sysNoticeService = sysNoticeService;
+        _logger = logger;
     }
 
     public async Task<S7MdpSyncTransformResult> RunFullAsync(
@@ -64,7 +76,7 @@ public class S7MdpSyncTransformService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
+            await MarkTransformRunFailedAsync(runLogId, now, ex.Message, batchId);
             throw;
         }
     }
@@ -385,8 +397,9 @@ WHERE id=@Id",
             new SugarParameter("@Id", runLogId));
     }
 
-    private async Task MarkTransformRunFailedAsync(long runLogId, DateTime startedAt, string message)
+    private async Task MarkTransformRunFailedAsync(long runLogId, DateTime startedAt, string message, string batchId)
     {
+        bool runLogUpdated = false;
         try
         {
             var finishedAt = DateTime.Now;
@@ -399,11 +412,32 @@ WHERE id=@Id",
                 new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
                 new SugarParameter("@ErrorMessage", Truncate(message, 2000)),
                 new SugarParameter("@Id", runLogId));
+            runLogUpdated = true;
         }
         catch (Exception ex)
         {
             Console.Error.WriteLine($"[S7MdpSyncTransform] MarkTransformRunFailed write failed (runLogId={runLogId}): {ex.Message}");
         }
+
+        // FAILURE-NOTIFICATION-1:写库 FAILED 成功后发通知给超级管理员;通知失败不影响主流程
+        if (!runLogUpdated) return;
+        try
+        {
+            await _sysNoticeService.AddNotice(new AddNoticeInput
+            {
+                Title = "S7 成品仓储 T8 KPI 跑批失败",
+                Content = $"模块:S7 成品仓储\n批次ID:{batchId}\n失败时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}\n错误信息:{Truncate(message, 1000)}\n\n请查看 mdp_transform_run_log 获取完整错误与重试记录。",
+                Type = NoticeTypeEnum.NOTICE,
+                PublicTime = DateTime.Now,
+                Status = NoticeStatusEnum.PUBLIC,
+                PublicUserId = NoticeReceiverUserId,
+                PublicUserName = NoticeReceiverUserName
+            });
+        }
+        catch (Exception notifyEx)
+        {
+            _logger.LogError(notifyEx, "[S7MdpSyncTransform] SysNotice 发送失败 (runLogId={RunLogId}, batchId={BatchId})", runLogId, batchId);
+        }
     }
 
     private static string NormalizeTriggerType(string s) =>

+ 51 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S5MdpRefreshJob.cs

@@ -0,0 +1,51 @@
+using Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+using Furion.Schedule;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using System.Text.Json;
+
+namespace Admin.NET.Plugin.AiDOP.Job;
+
+/// <summary>
+/// S5 物料仓储 T8 KPI 自动跑批任务。
+/// 每日 02:00 / 07:00 / 12:00 / 17:00 / 22:00 共 5 次(间隔 5h;22:00 是当天最后一次跑批,22:01~01:59 安静期)。
+/// 调用 S5MdpSyncTransformService.RunFullAsync(triggerType="AUTO");失败通知由 service 内部 MarkTransformRunFailedAsync 触发。
+/// </summary>
+[JobDetail("job_s5_t8_kpi_refresh",
+    Description = "S5 T8 KPI 自动跑批(5 次/天:02/07/12/17/22)",
+    GroupName = "default",
+    Concurrent = false)]
+[Cron("0 2,7,12,17,22 * * *",
+    TriggerId = "trigger_s5_t8_kpi_refresh",
+    Description = "每日 02:00/07:00/12:00/17:00/22:00 触发(5 字段:分 时 日 月 周,默认 CronStringFormat.Default)")]
+public class S5MdpRefreshJob : IJob
+{
+    private readonly IServiceScopeFactory _scopeFactory;
+    private readonly ILogger _logger;
+
+    public S5MdpRefreshJob(IServiceScopeFactory scopeFactory, ILoggerFactory loggerFactory)
+    {
+        _scopeFactory = scopeFactory;
+        _logger = loggerFactory.CreateLogger(nameof(S5MdpRefreshJob));
+    }
+
+    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
+    {
+        using var scope = _scopeFactory.CreateScope();
+        var service = scope.ServiceProvider.GetRequiredService<S5MdpSyncTransformService>();
+        try
+        {
+            var result = await service.RunFullAsync(stoppingToken, "AUTO");
+            _logger.LogInformation("S5MdpRefreshJob 完成 {Payload}", JsonSerializer.Serialize(result));
+        }
+        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+        {
+            _logger.LogInformation("S5MdpRefreshJob 收到停止信号,结束本轮");
+        }
+        catch (Exception ex)
+        {
+            // 失败通知由 service 内部 MarkTransformRunFailedAsync 触发;此处仅记录 Job 层失败
+            _logger.LogError(ex, "S5MdpRefreshJob 执行失败");
+        }
+    }
+}

+ 51 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S6MdpRefreshJob.cs

@@ -0,0 +1,51 @@
+using Admin.NET.Plugin.AiDOP.Manufacturing;
+using Furion.Schedule;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using System.Text.Json;
+
+namespace Admin.NET.Plugin.AiDOP.Job;
+
+/// <summary>
+/// S6 生产执行 T8 KPI 自动跑批任务。
+/// 每日 02:00 / 07:00 / 12:00 / 17:00 / 22:00 共 5 次(间隔 5h;22:00 是当天最后一次跑批,22:01~01:59 安静期)。
+/// 调用 S6MdpSyncTransformService.RunFullAsync(triggerType="AUTO");失败通知由 service 内部 MarkTransformRunFailedAsync 触发。
+/// </summary>
+[JobDetail("job_s6_t8_kpi_refresh",
+    Description = "S6 T8 KPI 自动跑批(5 次/天:02/07/12/17/22)",
+    GroupName = "default",
+    Concurrent = false)]
+[Cron("0 2,7,12,17,22 * * *",
+    TriggerId = "trigger_s6_t8_kpi_refresh",
+    Description = "每日 02:00/07:00/12:00/17:00/22:00 触发(5 字段:分 时 日 月 周,默认 CronStringFormat.Default)")]
+public class S6MdpRefreshJob : IJob
+{
+    private readonly IServiceScopeFactory _scopeFactory;
+    private readonly ILogger _logger;
+
+    public S6MdpRefreshJob(IServiceScopeFactory scopeFactory, ILoggerFactory loggerFactory)
+    {
+        _scopeFactory = scopeFactory;
+        _logger = loggerFactory.CreateLogger(nameof(S6MdpRefreshJob));
+    }
+
+    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
+    {
+        using var scope = _scopeFactory.CreateScope();
+        var service = scope.ServiceProvider.GetRequiredService<S6MdpSyncTransformService>();
+        try
+        {
+            var result = await service.RunFullAsync(stoppingToken, "AUTO");
+            _logger.LogInformation("S6MdpRefreshJob 完成 {Payload}", JsonSerializer.Serialize(result));
+        }
+        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+        {
+            _logger.LogInformation("S6MdpRefreshJob 收到停止信号,结束本轮");
+        }
+        catch (Exception ex)
+        {
+            // 失败通知由 service 内部 MarkTransformRunFailedAsync 触发;此处仅记录 Job 层失败
+            _logger.LogError(ex, "S6MdpRefreshJob 执行失败");
+        }
+    }
+}

+ 51 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S7MdpRefreshJob.cs

@@ -0,0 +1,51 @@
+using Admin.NET.Plugin.AiDOP.FinishedWarehouse;
+using Furion.Schedule;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using System.Text.Json;
+
+namespace Admin.NET.Plugin.AiDOP.Job;
+
+/// <summary>
+/// S7 成品仓储 T8 KPI 自动跑批任务。
+/// 每日 02:00 / 07:00 / 12:00 / 17:00 / 22:00 共 5 次(间隔 5h;22:00 是当天最后一次跑批,22:01~01:59 安静期)。
+/// 调用 S7MdpSyncTransformService.RunFullAsync(triggerType="AUTO");失败通知由 service 内部 MarkTransformRunFailedAsync 触发。
+/// </summary>
+[JobDetail("job_s7_t8_kpi_refresh",
+    Description = "S7 T8 KPI 自动跑批(5 次/天:02/07/12/17/22)",
+    GroupName = "default",
+    Concurrent = false)]
+[Cron("0 2,7,12,17,22 * * *",
+    TriggerId = "trigger_s7_t8_kpi_refresh",
+    Description = "每日 02:00/07:00/12:00/17:00/22:00 触发(5 字段:分 时 日 月 周,默认 CronStringFormat.Default)")]
+public class S7MdpRefreshJob : IJob
+{
+    private readonly IServiceScopeFactory _scopeFactory;
+    private readonly ILogger _logger;
+
+    public S7MdpRefreshJob(IServiceScopeFactory scopeFactory, ILoggerFactory loggerFactory)
+    {
+        _scopeFactory = scopeFactory;
+        _logger = loggerFactory.CreateLogger(nameof(S7MdpRefreshJob));
+    }
+
+    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
+    {
+        using var scope = _scopeFactory.CreateScope();
+        var service = scope.ServiceProvider.GetRequiredService<S7MdpSyncTransformService>();
+        try
+        {
+            var result = await service.RunFullAsync(stoppingToken, "AUTO");
+            _logger.LogInformation("S7MdpRefreshJob 完成 {Payload}", JsonSerializer.Serialize(result));
+        }
+        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+        {
+            _logger.LogInformation("S7MdpRefreshJob 收到停止信号,结束本轮");
+        }
+        catch (Exception ex)
+        {
+            // 失败通知由 service 内部 MarkTransformRunFailedAsync 触发;此处仅记录 Job 层失败
+            _logger.LogError(ex, "S7MdpRefreshJob 执行失败");
+        }
+    }
+}

+ 38 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/S6MdpSyncTransformService.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Core.Service;
+using Microsoft.Extensions.Logging;
 using System.Text.Json;
 
 namespace Admin.NET.Plugin.AiDOP.Manufacturing;
@@ -10,14 +12,24 @@ namespace Admin.NET.Plugin.AiDOP.Manufacturing;
 public class S6MdpSyncTransformService : ITransient
 {
     private readonly ISqlSugarClient _db;
+    private readonly SysNoticeService _sysNoticeService;
+    private readonly ILogger<S6MdpSyncTransformService> _logger;
     private const string JobCode = "S6_MDP_SYNC_TRANSFORM";
     private const string JobName = "S6 生产执行 MDP 同步与转换";
     private const string T8ConfigId = "t8_v5";
     private const string ModuleCode = "S6";
-
-    public S6MdpSyncTransformService(ISqlSugarClient db)
+    // FAILURE-NOTIFICATION-1:超级管理员 superAdmin.NET(AccountType=999)
+    private const long NoticeReceiverUserId = 1300000000101L;
+    private const string NoticeReceiverUserName = "超级管理员";
+
+    public S6MdpSyncTransformService(
+        ISqlSugarClient db,
+        SysNoticeService sysNoticeService,
+        ILogger<S6MdpSyncTransformService> logger)
     {
         _db = db;
+        _sysNoticeService = sysNoticeService;
+        _logger = logger;
     }
 
     public async Task<S6MdpSyncTransformResult> RunFullAsync(
@@ -61,7 +73,7 @@ public class S6MdpSyncTransformService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
+            await MarkTransformRunFailedAsync(runLogId, now, ex.Message, batchId);
             throw;
         }
     }
@@ -309,8 +321,9 @@ WHERE id=@Id",
             new SugarParameter("@Id", runLogId));
     }
 
-    private async Task MarkTransformRunFailedAsync(long runLogId, DateTime startedAt, string message)
+    private async Task MarkTransformRunFailedAsync(long runLogId, DateTime startedAt, string message, string batchId)
     {
+        bool runLogUpdated = false;
         try
         {
             var finishedAt = DateTime.Now;
@@ -323,11 +336,32 @@ WHERE id=@Id",
                 new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
                 new SugarParameter("@ErrorMessage", Truncate(message, 2000)),
                 new SugarParameter("@Id", runLogId));
+            runLogUpdated = true;
         }
         catch (Exception ex)
         {
             Console.Error.WriteLine($"[S6MdpSyncTransform] MarkTransformRunFailed write failed (runLogId={runLogId}): {ex.Message}");
         }
+
+        // FAILURE-NOTIFICATION-1:写库 FAILED 成功后发通知给超级管理员;通知失败不影响主流程
+        if (!runLogUpdated) return;
+        try
+        {
+            await _sysNoticeService.AddNotice(new AddNoticeInput
+            {
+                Title = "S6 生产执行 T8 KPI 跑批失败",
+                Content = $"模块:S6 生产执行\n批次ID:{batchId}\n失败时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}\n错误信息:{Truncate(message, 1000)}\n\n请查看 mdp_transform_run_log 获取完整错误与重试记录。",
+                Type = NoticeTypeEnum.NOTICE,
+                PublicTime = DateTime.Now,
+                Status = NoticeStatusEnum.PUBLIC,
+                PublicUserId = NoticeReceiverUserId,
+                PublicUserName = NoticeReceiverUserName
+            });
+        }
+        catch (Exception notifyEx)
+        {
+            _logger.LogError(notifyEx, "[S6MdpSyncTransform] SysNotice 发送失败 (runLogId={RunLogId}, batchId={BatchId})", runLogId, batchId);
+        }
     }
 
     private static string NormalizeTriggerType(string s) =>

+ 38 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/S5MdpSyncTransformService.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Core.Service;
+using Microsoft.Extensions.Logging;
 using System.Text.Json;
 
 namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
@@ -12,14 +14,24 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 public class S5MdpSyncTransformService : ITransient
 {
     private readonly ISqlSugarClient _db;
+    private readonly SysNoticeService _sysNoticeService;
+    private readonly ILogger<S5MdpSyncTransformService> _logger;
     private const string JobCode = "S5_MDP_SYNC_TRANSFORM";
     private const string JobName = "S5 物料仓储 MDP 同步与转换";
     private const string T8ConfigId = "t8_v5";
     private const string ModuleCode = "S5";
-
-    public S5MdpSyncTransformService(ISqlSugarClient db)
+    // FAILURE-NOTIFICATION-1:超级管理员 superAdmin.NET(AccountType=999)
+    private const long NoticeReceiverUserId = 1300000000101L;
+    private const string NoticeReceiverUserName = "超级管理员";
+
+    public S5MdpSyncTransformService(
+        ISqlSugarClient db,
+        SysNoticeService sysNoticeService,
+        ILogger<S5MdpSyncTransformService> logger)
     {
         _db = db;
+        _sysNoticeService = sysNoticeService;
+        _logger = logger;
     }
 
     public async Task<S5MdpSyncTransformResult> RunFullAsync(
@@ -72,7 +84,7 @@ public class S5MdpSyncTransformService : ITransient
         }
         catch (Exception ex)
         {
-            await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
+            await MarkTransformRunFailedAsync(runLogId, now, ex.Message, batchId);
             throw;
         }
     }
@@ -484,8 +496,9 @@ WHERE id=@Id",
             new SugarParameter("@Id", runLogId));
     }
 
-    private async Task MarkTransformRunFailedAsync(long runLogId, DateTime startedAt, string message)
+    private async Task MarkTransformRunFailedAsync(long runLogId, DateTime startedAt, string message, string batchId)
     {
+        bool runLogUpdated = false;
         try
         {
             var finishedAt = DateTime.Now;
@@ -498,12 +511,33 @@ WHERE id=@Id",
                 new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
                 new SugarParameter("@ErrorMessage", Truncate(message, 2000)),
                 new SugarParameter("@Id", runLogId));
+            runLogUpdated = true;
         }
         catch (Exception ex)
         {
             // 写库本身失败兜底:远端 MySQL 瞬断导致 MarkFailed 自身也连不上
             Console.Error.WriteLine($"[S5MdpSyncTransform] MarkTransformRunFailed write failed (runLogId={runLogId}): {ex.Message}");
         }
+
+        // FAILURE-NOTIFICATION-1:写库 FAILED 成功后发通知给超级管理员;通知失败不影响主流程
+        if (!runLogUpdated) return;
+        try
+        {
+            await _sysNoticeService.AddNotice(new AddNoticeInput
+            {
+                Title = "S5 物料仓储 T8 KPI 跑批失败",
+                Content = $"模块:S5 物料仓储\n批次ID:{batchId}\n失败时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}\n错误信息:{Truncate(message, 1000)}\n\n请查看 mdp_transform_run_log 获取完整错误与重试记录。",
+                Type = NoticeTypeEnum.NOTICE,
+                PublicTime = DateTime.Now,
+                Status = NoticeStatusEnum.PUBLIC,
+                PublicUserId = NoticeReceiverUserId,
+                PublicUserName = NoticeReceiverUserName
+            });
+        }
+        catch (Exception notifyEx)
+        {
+            _logger.LogError(notifyEx, "[S5MdpSyncTransform] SysNotice 发送失败 (runLogId={RunLogId}, batchId={BatchId})", runLogId, batchId);
+        }
     }
 
     private static string BuildRunSummaryJson(S5MdpSyncTransformResult r)