Просмотр исходного кода

feat(kanban): S5/S6/S7 看板刷新接通真实跑批链路

- 新增 AidopT8KpiManualRefreshService 编排:硬 TTL 锁 → T8 入站(硬门) → 本模块 RunFull(MANUAL) → 逐指标状态
- AidopKanbanController.mdp/refresh/{S5,S6,S7} 接入编排(App.GetRequiredService,不改 ctor)
- S5MdpRefreshJob 与手动刷新共享同一把硬 TTL 锁,手动/Cron 互斥,finally 释放
- QueryT8Async 给 t8_v5 域加 CommandTimeout(60s),防 legacy T8 慢查询挂死持锁
- 前端 registry 补 S5/S6/S7 refreshMdp,请求超时 180s,成功提示聚合(成功/无数据/失败/耗时)
- 不改 S5_L1_004 计算方式;冒烟三模块 happy-path 全通过

chore: bump version Web 2.4.261 / server 1.0.275
YY968XX 2 недель назад
Родитель
Сommit
f05c612fbd

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
   "name": "admin.net",
   "type": "module",
-  "version": "2.4.260",
+  "version": "2.4.261",
   "packageManager": "pnpm@10.32.1",
   "lastBuildTime": "2026.03.15",
   "description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 2 - 1
Web/src/views/aidop/api/kanbanData.ts

@@ -836,7 +836,8 @@ export async function refreshModuleMdp(moduleCode: string): Promise<{
 }> {
 	const mc = String(moduleCode || '').toUpperCase();
 	try {
-		const res = await service.post(`/api/AidopKanban/mdp/refresh/${encodeURIComponent(mc)}`);
+		// 真实跑批(T8 入站 + KPI 转换,含 S5 legacy TVF)可能数十秒,覆盖 axios 默认 50s 超时避免被误判失败。
+		const res = await service.post(`/api/AidopKanban/mdp/refresh/${encodeURIComponent(mc)}`, undefined, { timeout: 180000 });
 		const d = res.data ?? {};
 		return {
 			ok: Boolean(d.ok),

+ 2 - 1
Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue

@@ -429,7 +429,8 @@ async function refreshDashboard() {
 			ElMessage.error(result.message || `${moduleCode.value} MDP 刷新失败`);
 			return;
 		}
-		ElMessage.success(`${moduleCode.value} MDP 刷新完成${result.batchId ? `,批次 ${result.batchId}` : ''}`);
+		// 后端返回聚合文案(成功N/无数据N/失败N/耗时)时优先展示;否则回退默认文案(兼容 S1-S4)。
+		ElMessage.success(result.message || `${moduleCode.value} MDP 刷新完成${result.batchId ? `,批次 ${result.batchId}` : ''}`);
 	} finally {
 		loading.value = false;
 	}

+ 3 - 3
Web/src/views/aidop/kanban/config/moduleDashboardRegistry.ts

@@ -63,9 +63,9 @@ export const moduleDashboardRegistry: Record<string, ModuleDashboardConfig> = {
 		chatSuggestions: commonSuggestions('S4 采购执行'),
 		refreshMdp: () => refreshModuleMdp('S4'),
 	},
-	S5: { moduleCode: 'S5', title: 'S5 物料仓储看板', enableChatBI: true, chatSuggestions: commonSuggestions('S5 物料仓储') },
-	S6: { moduleCode: 'S6', title: 'S6 生产执行看板', enableChatBI: true, chatSuggestions: commonSuggestions('S6 生产执行') },
-	S7: { moduleCode: 'S7', title: 'S7 成品仓储看板', enableChatBI: true, chatSuggestions: commonSuggestions('S7 成品仓储') },
+	S5: { moduleCode: 'S5', title: 'S5 物料仓储看板', enableChatBI: true, chatSuggestions: commonSuggestions('S5 物料仓储'), refreshMdp: () => refreshModuleMdp('S5') },
+	S6: { moduleCode: 'S6', title: 'S6 生产执行看板', enableChatBI: true, chatSuggestions: commonSuggestions('S6 生产执行'), refreshMdp: () => refreshModuleMdp('S6') },
+	S7: { moduleCode: 'S7', title: 'S7 成品仓储看板', enableChatBI: true, chatSuggestions: commonSuggestions('S7 成品仓储'), refreshMdp: () => refreshModuleMdp('S7') },
 	S9: { moduleCode: 'S9', title: 'S9 运营指标看板', enableChatBI: true, chatSuggestions: commonSuggestions('S9 运营指标') },
 };
 

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET ͨ��Ȩ�޿���ƽ̨</Description>
-    <AssemblyVersion>1.0.274</AssemblyVersion>
-    <FileVersion>1.0.274</FileVersion>
-    <Version>1.0.274</Version>
+    <AssemblyVersion>1.0.275</AssemblyVersion>
+    <FileVersion>1.0.275</FileVersion>
+    <Version>1.0.275</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 13 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs

@@ -780,10 +780,23 @@ LIMIT 60
             "S2" => await RefreshS2Mdp(cancellationToken),
             "S3" => await RefreshS3Mdp(cancellationToken),
             "S4" => await RefreshS4Mdp(cancellationToken),
+            "S5" or "S6" or "S7" => await RefreshT8KpiModuleMdp(mc, cancellationToken),
             _ => Ok(new { ok = true, moduleCode = mc, message = "当前模块暂无 MDP 刷新任务,仅刷新动态看板数据。" })
         };
     }
 
+    /// <summary>
+    /// S5/S6/S7 T8 KPI 统一手动刷新:共享 6 表入站(硬门)→ 本模块 RunFull(MANUAL) → 逐指标状态。
+    /// 编排与并发锁在 AidopT8KpiManualRefreshService(与 Cron job_s5_t8_kpi_refresh 共享刷新锁)。
+    /// 经 App.GetRequiredService 解析,避免改动共享控制器构造函数。
+    /// </summary>
+    private async Task<IActionResult> RefreshT8KpiModuleMdp(string moduleCode, CancellationToken cancellationToken)
+    {
+        var orchestrator = App.GetRequiredService<Admin.NET.Plugin.AiDOP.DataPlatform.AidopT8KpiManualRefreshService>();
+        var result = await orchestrator.RunModuleRefreshAsync(moduleCode, cancellationToken);
+        return Ok(result);
+    }
+
     private async Task<S2SyncStatusDto?> GetS2SyncStatusAsync()
     {
         return await GetMdpSyncStatusAsync("S2_MDP_SYNC_TRANSFORM");

+ 274 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/AidopT8KpiManualRefreshService.cs

@@ -0,0 +1,274 @@
+using Admin.NET.Core.Service;
+using Admin.NET.Plugin.AiDOP.FinishedWarehouse;
+using Admin.NET.Plugin.AiDOP.Manufacturing;
+using Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.DataPlatform;
+
+/// <summary>
+/// S5/S6/S7 T8 KPI 手动刷新统一编排(看板"刷新"按钮真实跑批入口)。
+/// 单次刷新语义:抢全局刷新锁 → T8 基表入站(硬门,共享 6 表)→ 本模块 RunFull(MANUAL) → 汇总逐指标状态 → 释放锁。
+/// 与 Cron job_s5_t8_kpi_refresh 共享同一把锁(<see cref="RefreshLockKey"/>),保证手动刷新与定时跑批互斥、并发手动刷新排他。
+/// 入站为 S5/S6/S7 共用(T8BaseInboundMdpSyncService 6 张基表无法按模块拆),故刷任一模块均触发一次全量入站。
+/// S5_L1_002 / S5_L1_004 仍 legacy 直连 T8(本轮不改),随 S5 RunFull 一并执行,逐指标标 LEGACY_SUCCESS。
+///
+/// 锁设计(加固):用持有者无关的硬 TTL 键(SysCacheService.Set 带过期),而非 BeginCacheLock。
+/// 原因:BeginCacheLock 在持有者线程存活期间会续租,一旦某次运行意外挂死(如源库慢查询无超时)会永久占锁、
+/// 阻塞后续所有刷新。改为硬 TTL 后:正常路径 finally 主动 Remove;即使挂死,键到 <see cref="RefreshLockTtlSeconds"/> 也自动过期。
+/// 配合 QueryT8Async 的 T8 命令超时(60s)与主库命令超时(30s),单次运行时长恒 &lt; TTL,正常不会误过期。
+/// </summary>
+public sealed class AidopT8KpiManualRefreshService : ITransient
+{
+    /// <summary>手动刷新 + Cron 共享的全局互斥锁 key(覆盖 inbound + transform 整段)。</summary>
+    public const string RefreshLockKey = "aidop:t8kpi:refresh:lock";
+
+    /// <summary>刷新锁硬 TTL(秒,持有者无关):到点自动过期,即使某次运行意外挂死也不会永久阻塞后续刷新。
+    /// 取值须大于加固后最坏运行时长(T8 查询各 ≤60s、主库命令 ≤30s,全程数分钟内),10 分钟为安全上限。</summary>
+    public const int RefreshLockTtlSeconds = 600;
+
+    // legacy 直连 T8 的 KPI(本轮保留),逐指标状态标 LEGACY_SUCCESS 以示区分。
+    private static readonly HashSet<string> LegacyKpiCodes = new(StringComparer.OrdinalIgnoreCase) { "S5_L1_002", "S5_L1_004" };
+
+    private readonly T8BaseInboundMdpSyncService _inbound;
+    private readonly S5MdpSyncTransformService _s5;
+    private readonly S6MdpSyncTransformService _s6;
+    private readonly S7MdpSyncTransformService _s7;
+    private readonly SysCacheService _cache;
+    private readonly ILogger<AidopT8KpiManualRefreshService> _logger;
+
+    public AidopT8KpiManualRefreshService(
+        T8BaseInboundMdpSyncService inbound,
+        S5MdpSyncTransformService s5,
+        S6MdpSyncTransformService s6,
+        S7MdpSyncTransformService s7,
+        SysCacheService cache,
+        ILogger<AidopT8KpiManualRefreshService> logger)
+    {
+        _inbound = inbound;
+        _s5 = s5;
+        _s6 = s6;
+        _s7 = s7;
+        _cache = cache;
+        _logger = logger;
+    }
+
+    /// <summary>执行一次模块级手动刷新(S5/S6/S7)。返回统一契约,绝不抛业务异常(取消除外)。</summary>
+    public async Task<AidopKpiRefreshResult> RunModuleRefreshAsync(string moduleCode, CancellationToken cancellationToken)
+    {
+        var mc = string.IsNullOrWhiteSpace(moduleCode) ? string.Empty : moduleCode.Trim().ToUpperInvariant();
+        var startedAt = DateTime.Now;
+        var result = new AidopKpiRefreshResult
+        {
+            ModuleCode = mc,
+            StartedAt = startedAt.ToString("yyyy-MM-dd HH:mm:ss")
+        };
+
+        if (mc is not ("S5" or "S6" or "S7"))
+        {
+            result.Ok = false;
+            result.OverallStatus = "SKIPPED";
+            result.Message = $"模块 {mc} 不支持 T8 KPI 手动刷新";
+            Finish(result, startedAt);
+            return result;
+        }
+
+        // 硬 TTL 锁:ExistKey 命中即判定已有刷新在跑(手动或定时);否则 Set(带 TTL) 占锁。
+        // 单实例 + 人工点击/每日 5 次定时下,ExistKey→Set 的微小竞态概率可忽略;即便未主动释放,TTL 到点自动过期。
+        if (_cache.ExistKey(RefreshLockKey))
+        {
+            result.Ok = false;
+            result.OverallStatus = "REFRESHING";
+            result.Message = "T8 KPI 刷新正在进行中(手动或定时任务占用),请稍后重试";
+            Finish(result, startedAt);
+            return result;
+        }
+        _cache.Set(RefreshLockKey, $"MANUAL:{mc}:{startedAt:yyyyMMddHHmmss}", TimeSpan.FromSeconds(RefreshLockTtlSeconds));
+
+        try
+        {
+            // ① 入站硬门:共享 6 张 T8 基表全量入站;失败即中断,不用旧 std 伪装成功。
+            try
+            {
+                var inbound = await _inbound.RunInboundAsync(0, true, null, cancellationToken);
+                result.Inbound.Ok = true;
+                result.Inbound.StageRows = inbound.Tables.Sum(t => t.StgRows);
+                result.Inbound.StandardRows = inbound.Tables.Sum(t => t.StdRows);
+            }
+            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+            {
+                throw;
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "[手动刷新] {Module} T8 基表入站失败", mc);
+                result.Ok = false;
+                result.OverallStatus = "FAILED";
+                result.Inbound.Ok = false;
+                result.Inbound.Message = ex.Message;
+                result.Message = "源数据入站失败,未更新 KPI(保留原有看板数据)";
+                Finish(result, startedAt);
+                return result;
+            }
+
+            // ② 本模块 KPI 转换(RunFull 为全有全无:任一 KPI 异常即整体抛错)。
+            string batchId;
+            int dwdRows;
+            int kpiRows;
+            Dictionary<string, int> perKpiRows;
+            List<string> denomStatus;
+            try
+            {
+                switch (mc)
+                {
+                    case "S5":
+                        var r5 = await _s5.RunFullAsync(cancellationToken, "MANUAL");
+                        batchId = r5.BatchId;
+                        dwdRows = r5.DwdRows;
+                        kpiRows = r5.KpiRows;
+                        perKpiRows = r5.PerKpiKpiRows;
+                        denomStatus = r5.KpiDenominatorStatus;
+                        break;
+                    case "S6":
+                        var r6 = await _s6.RunFullAsync(cancellationToken, "MANUAL");
+                        batchId = r6.BatchId;
+                        dwdRows = r6.DwdRows;
+                        kpiRows = r6.KpiRows;
+                        perKpiRows = r6.PerKpiKpiRows;
+                        denomStatus = r6.KpiDenominatorStatus;
+                        break;
+                    default:
+                        var r7 = await _s7.RunFullAsync(cancellationToken, "MANUAL");
+                        batchId = r7.BatchId;
+                        dwdRows = r7.DwdRows;
+                        kpiRows = r7.KpiRows;
+                        perKpiRows = r7.PerKpiKpiRows;
+                        denomStatus = r7.KpiDenominatorStatus;
+                        break;
+                }
+            }
+            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+            {
+                throw;
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "[手动刷新] {Module} KPI 转换失败", mc);
+                result.Ok = false;
+                result.OverallStatus = "FAILED";
+                result.Transform.Ok = false;
+                result.Transform.Message = ex.Message;
+                result.Message = "KPI 计算失败(源数据已入站,看板保留原有数据)";
+                Finish(result, startedAt);
+                return result;
+            }
+
+            result.BatchId = batchId;
+            result.Transform.Ok = true;
+            result.Transform.DwdRows = dwdRows;
+            result.Transform.KpiRows = kpiRows;
+
+            // ③ 逐指标状态:denom=OK → SUCCESS(legacy→LEGACY_SUCCESS);denom=NO_* → NO_DATA(真实无数据,非伪造 0)。
+            var denomMap = ParseDenominator(denomStatus);
+            foreach (var kv in perKpiRows.OrderBy(k => k.Key, StringComparer.Ordinal))
+            {
+                var code = kv.Key;
+                var denom = denomMap.TryGetValue(code, out var d) ? d : "OK";
+                var ok = string.Equals(denom, "OK", StringComparison.OrdinalIgnoreCase);
+                var status = ok
+                    ? (LegacyKpiCodes.Contains(code) ? "LEGACY_SUCCESS" : "SUCCESS")
+                    : "NO_DATA";
+                result.PerKpi.Add(new AidopKpiRefreshPerKpi
+                {
+                    MetricCode = code,
+                    Status = status,
+                    Rows = kv.Value,
+                    Message = ok ? null : denom
+                });
+            }
+
+            result.SuccessCount = result.PerKpi.Count(p => p.Status is "SUCCESS" or "LEGACY_SUCCESS");
+            result.NoDataCount = result.PerKpi.Count(p => p.Status == "NO_DATA");
+            result.FailedCount = result.PerKpi.Count(p => p.Status is "FAILED" or "LEGACY_FAILED");
+
+            result.Ok = true;
+            // RunFull 为全有全无,成功路径下 FailedCount 恒为 0;PARTIAL_SUCCESS 仅为契约保留态。
+            result.OverallStatus = result.FailedCount > 0 ? "PARTIAL_SUCCESS" : "SUCCESS";
+            Finish(result, startedAt);
+            result.Message = $"刷新完成:成功 {result.SuccessCount},无数据 {result.NoDataCount},失败 {result.FailedCount},耗时 {result.DurationMs / 1000.0:0.0} 秒";
+            return result;
+        }
+        finally
+        {
+            // 无论成功/失败/取消,主动释放锁(硬 TTL 是兜底,正常路径这里即时释放)。
+            _cache.Remove(RefreshLockKey);
+        }
+    }
+
+    /// <summary>把 "S5_L1_001:OK" 形式的分母状态列表解析为 metricCode → status 字典。</summary>
+    private static Dictionary<string, string> ParseDenominator(List<string> denom)
+    {
+        var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+        foreach (var item in denom)
+        {
+            if (string.IsNullOrWhiteSpace(item)) continue;
+            var idx = item.IndexOf(':');
+            if (idx <= 0) continue;
+            map[item.Substring(0, idx).Trim()] = item.Substring(idx + 1).Trim();
+        }
+        return map;
+    }
+
+    private static void Finish(AidopKpiRefreshResult result, DateTime startedAt)
+    {
+        var finishedAt = DateTime.Now;
+        result.FinishedAt = finishedAt.ToString("yyyy-MM-dd HH:mm:ss");
+        result.DurationMs = (long)(finishedAt - startedAt).TotalMilliseconds;
+    }
+}
+
+/// <summary>手动刷新统一返回契约(PascalCase 属性经框架序列化为 camelCase:Ok→ok、BatchId→batchId)。</summary>
+public sealed class AidopKpiRefreshResult
+{
+    public bool Ok { get; set; }
+    public string ModuleCode { get; set; } = string.Empty;
+
+    /// <summary>整体状态:SUCCESS / PARTIAL_SUCCESS / FAILED / REFRESHING / SKIPPED。</summary>
+    public string OverallStatus { get; set; } = string.Empty;
+    public string? BatchId { get; set; }
+    public string StartedAt { get; set; } = string.Empty;
+    public string FinishedAt { get; set; } = string.Empty;
+    public long DurationMs { get; set; }
+    public AidopKpiRefreshInbound Inbound { get; set; } = new();
+    public AidopKpiRefreshTransform Transform { get; set; } = new();
+    public int SuccessCount { get; set; }
+    public int NoDataCount { get; set; }
+    public int FailedCount { get; set; }
+    public List<AidopKpiRefreshPerKpi> PerKpi { get; set; } = new();
+    public string Message { get; set; } = string.Empty;
+}
+
+public sealed class AidopKpiRefreshInbound
+{
+    public bool Ok { get; set; }
+    public int StageRows { get; set; }
+    public int StandardRows { get; set; }
+    public string? Message { get; set; }
+}
+
+public sealed class AidopKpiRefreshTransform
+{
+    public bool Ok { get; set; }
+    public int DwdRows { get; set; }
+    public int KpiRows { get; set; }
+    public string? Message { get; set; }
+}
+
+/// <summary>单指标刷新状态:SUCCESS / LEGACY_SUCCESS / NO_DATA / FAILED / LEGACY_FAILED。</summary>
+public sealed class AidopKpiRefreshPerKpi
+{
+    public string MetricCode { get; set; } = string.Empty;
+    public string Status { get; set; } = string.Empty;
+    public int Rows { get; set; }
+    public string? Message { get; set; }
+}

+ 44 - 21
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S5MdpRefreshJob.cs

@@ -1,3 +1,4 @@
+using Admin.NET.Core.Service;
 using Admin.NET.Plugin.AiDOP.DataPlatform;
 using Admin.NET.Plugin.AiDOP.FinishedWarehouse;
 using Admin.NET.Plugin.AiDOP.Manufacturing;
@@ -22,6 +23,10 @@ namespace Admin.NET.Plugin.AiDOP.Job;
 ///   ② inbound 成功后,S5→S6→S7 采用步级失败隔离(某模块失败记错误、继续下一模块);
 ///   ③ OperationCanceledException 保持取消语义直接上抛,不吞。
 /// 手工补偿入口 /t8-base-mdp/inbound 仍独立保留。
+///
+/// 并发:与手动刷新(AidopKanbanController → AidopT8KpiManualRefreshService)共享同一把硬 TTL 刷新锁
+///   (RefreshLockKey)。占用时本轮定时跳过(5 次/天,下一 tick 自愈),避免共享入站/std 与手动刷新并发写;
+///   本轮结束在 finally 主动释放,即使异常/挂死也由 TTL 到点自动过期,不永久阻塞后续刷新。
 /// </summary>
 [JobDetail("job_s5_t8_kpi_refresh",
     Description = "T8 基础数据 + S5/S6/S7 KPI 统一刷新编排(inbound 硬门 → S5→S6→S7;5 次/天:02/07/12/17/22)",
@@ -43,33 +48,51 @@ public class S5MdpRefreshJob : IJob
 
     public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
     {
-        // ① 硬门:T8 基表入站(源→stg→std)必须先成功;失败则本轮结束,不继续下游、不降级用旧 std。
-        try
+        // 与手动刷新共享同一把硬 TTL 刷新锁:占用时本轮跳过;本轮结束 finally 释放,异常/挂死由 TTL 自动过期兜底。
+        using var lockScope = _scopeFactory.CreateScope();
+        var cache = lockScope.ServiceProvider.GetRequiredService<SysCacheService>();
+        if (cache.ExistKey(AidopT8KpiManualRefreshService.RefreshLockKey))
         {
-            using var inboundScope = _scopeFactory.CreateScope();
-            var inbound = inboundScope.ServiceProvider.GetRequiredService<T8BaseInboundMdpSyncService>();
-            var inboundResult = await inbound.RunInboundAsync(
-                tenantId: 0, fullRefresh: true, entityCode: null, cancellationToken: stoppingToken);
-            _logger.LogInformation("S5MdpRefreshJob T8 inbound 完成 {Payload}", JsonSerializer.Serialize(inboundResult));
+            _logger.LogWarning("S5MdpRefreshJob 检测到刷新锁被占用(手动刷新或上轮未释放),本轮跳过");
+            return;
         }
-        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+        cache.Set(AidopT8KpiManualRefreshService.RefreshLockKey, $"AUTO:{DateTime.Now:yyyyMMddHHmmss}",
+            TimeSpan.FromSeconds(AidopT8KpiManualRefreshService.RefreshLockTtlSeconds));
+
+        try
         {
-            _logger.LogInformation("S5MdpRefreshJob T8 inbound 收到停止信号,本轮结束");
-            throw;
+            // ① 硬门:T8 基表入站(源→stg→std)必须先成功;失败则本轮结束,不继续下游、不降级用旧 std。
+            try
+            {
+                using var inboundScope = _scopeFactory.CreateScope();
+                var inbound = inboundScope.ServiceProvider.GetRequiredService<T8BaseInboundMdpSyncService>();
+                var inboundResult = await inbound.RunInboundAsync(
+                    tenantId: 0, fullRefresh: true, entityCode: null, cancellationToken: stoppingToken);
+                _logger.LogInformation("S5MdpRefreshJob T8 inbound 完成 {Payload}", JsonSerializer.Serialize(inboundResult));
+            }
+            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+            {
+                _logger.LogInformation("S5MdpRefreshJob T8 inbound 收到停止信号,本轮结束");
+                throw;
+            }
+            catch (Exception ex)
+            {
+                _logger.LogError(ex, "S5MdpRefreshJob T8 inbound 失败,本轮跳过 S5/S6/S7 KPI 刷新(不使用上一周期 std 继续计算)");
+                return;
+            }
+
+            // ② 下游:inbound 成功后,S5→S6→S7 顺序执行,步级失败隔离(各自独立 DI scope)。
+            await RunStepAsync("S5", stoppingToken, async sp =>
+                (object)await sp.GetRequiredService<S5MdpSyncTransformService>().RunFullAsync(stoppingToken, "AUTO"));
+            await RunStepAsync("S6", stoppingToken, async sp =>
+                (object)await sp.GetRequiredService<S6MdpSyncTransformService>().RunFullAsync(stoppingToken, "AUTO"));
+            await RunStepAsync("S7", stoppingToken, async sp =>
+                (object)await sp.GetRequiredService<S7MdpSyncTransformService>().RunFullAsync(stoppingToken, "AUTO"));
         }
-        catch (Exception ex)
+        finally
         {
-            _logger.LogError(ex, "S5MdpRefreshJob T8 inbound 失败,本轮跳过 S5/S6/S7 KPI 刷新(不使用上一周期 std 继续计算)");
-            return;
+            cache.Remove(AidopT8KpiManualRefreshService.RefreshLockKey);
         }
-
-        // ② 下游:inbound 成功后,S5→S6→S7 顺序执行,步级失败隔离(各自独立 DI scope)。
-        await RunStepAsync("S5", stoppingToken, async sp =>
-            (object)await sp.GetRequiredService<S5MdpSyncTransformService>().RunFullAsync(stoppingToken, "AUTO"));
-        await RunStepAsync("S6", stoppingToken, async sp =>
-            (object)await sp.GetRequiredService<S6MdpSyncTransformService>().RunFullAsync(stoppingToken, "AUTO"));
-        await RunStepAsync("S7", stoppingToken, async sp =>
-            (object)await sp.GetRequiredService<S7MdpSyncTransformService>().RunFullAsync(stoppingToken, "AUTO"));
     }
 
     /// <summary>

+ 5 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/S5MdpSyncTransformService.cs

@@ -21,6 +21,10 @@ public class S5MdpSyncTransformService : ITransient
     private const string JobCode = "S5_MDP_SYNC_TRANSFORM";
     private const string JobName = "S5 物料仓储 MDP 同步与转换";
     private const string T8ConfigId = "t8_v5";
+    // 加固:legacy 直连 T8 查询的命令超时上限(秒)。防止 T8 报表视图(Cj_Bg_Head_Rep)/TVF(Rep_总账_存货_V3)
+    // 慢或挂起时查询无限期阻塞、进而长时间持有刷新锁(见 AidopT8KpiManualRefreshService)。
+    // 仅加超时护栏,不改 S5_L1_002 / S5_L1_004 的 SQL / 参数 / 计算口径。
+    private const int T8CommandTimeoutSeconds = 60;
     private const string ModuleCode = "S5";
     // FAILURE-NOTIFICATION-1:超级管理员 superAdmin.NET(AccountType=999)
     private const long NoticeReceiverUserId = 1300000000101L;
@@ -427,6 +431,7 @@ ON DUPLICATE KEY UPDATE
     private async Task<List<T>> QueryT8Async<T>(string sql, SugarParameter[] parameters)
     {
         var t8 = _db.AsTenant().GetConnectionScope(T8ConfigId);
+        t8.Ado.CommandTimeOut = T8CommandTimeoutSeconds;
         return await t8.Ado.SqlQueryAsync<T>(sql, parameters);
     }