S5MdpSyncTransformService.cs 58 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144
  1. using Admin.NET.Core.Service;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure;
  3. using Admin.NET.Plugin.AiDOP.SmartOps;
  4. using Microsoft.Extensions.Logging;
  5. using System.Text.Json;
  6. namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
  7. /// <summary>
  8. /// S5 物料仓储 — KPI 计算与刷新转换服务。边界:8 迁 1 留。
  9. /// 已中台化(读本地标准层 mdp_std_t8_*,由 T8BaseInboundMdpSyncService 从 T8 贴源→标准):
  10. /// S5_L1_001 物料上线周期 / S5_L1_002 物料上线满足率(依赖 Cj_Bg_Head_Rep + kc_dd_list_cllist,已贴源)/ S5_L1_003 物料仓储人效。
  11. /// 仍 legacy 直连 T8(QueryT8Async,ConfigId=t8_v5):
  12. /// S5_L1_004 品类物料库存周转(依赖 T8 TVF Rep_总账_存货_V3,报表聚合结果无逐行主键,暂不中台化)。
  13. /// 结果统一落 dwd_t8_* 与 ado_s9_kpi_value_l1_day。计算口径沿用方老师 v5.4 KPI J 列。
  14. /// </summary>
  15. public class S5MdpSyncTransformService : ITransient
  16. {
  17. private readonly ISqlSugarClient _db;
  18. private readonly TransformRunLogFinalizer _runLogFinalizer;
  19. private readonly SysNoticeService _sysNoticeService;
  20. private readonly ILogger<S5MdpSyncTransformService> _logger;
  21. private const string JobCode = "S5_MDP_SYNC_TRANSFORM";
  22. private const string JobName = "S5 物料仓储 MDP 同步与转换";
  23. private const string T8ConfigId = "t8_v5";
  24. // 加固:legacy 直连 T8 查询的命令超时上限(秒)。防止 T8 TVF(Rep_总账_存货_V3) 慢或挂起时
  25. // 查询无限期阻塞、进而长时间持有刷新锁(见 AidopT8KpiManualRefreshService)。
  26. // 仅加超时护栏,不改 S5_L1_004 的 SQL / 参数 / 计算口径。(S5_L1_002 已中台化,不再走 QueryT8Async。)
  27. private const int T8CommandTimeoutSeconds = 60;
  28. private const string ModuleCode = "S5";
  29. private const string L2ValueTable = "ado_s9_kpi_value_l2_day";
  30. private const string L3ValueTable = "ado_s9_kpi_value_l3_day";
  31. // FAILURE-NOTIFICATION-1:超级管理员 superAdmin.NET(AccountType=999)
  32. private const long NoticeReceiverUserId = 1300000000101L;
  33. private const string NoticeReceiverUserName = "超级管理员";
  34. private readonly SmartOps.KpiCalcDispatcher _kpiCalcDispatcher;
  35. private readonly SmartOps.KpiDimensionRunService _dimensionRun;
  36. private readonly IKpiTargetResolver _kpiTargetResolver;
  37. private readonly InventoryMdpSyncService _inventoryMdpSync;
  38. private readonly PurchaseReceiptMdpSyncService _purchaseReceiptMdpSync;
  39. public S5MdpSyncTransformService(
  40. ISqlSugarClient db,
  41. SysNoticeService sysNoticeService,
  42. ILogger<S5MdpSyncTransformService> logger,
  43. SmartOps.KpiCalcDispatcher kpiCalcDispatcher,
  44. SmartOps.KpiDimensionRunService dimensionRun,
  45. IKpiTargetResolver kpiTargetResolver,
  46. InventoryMdpSyncService inventoryMdpSync,
  47. PurchaseReceiptMdpSyncService purchaseReceiptMdpSync,
  48. TransformRunLogFinalizer runLogFinalizer)
  49. {
  50. _db = db;
  51. _runLogFinalizer = runLogFinalizer;
  52. _sysNoticeService = sysNoticeService;
  53. _logger = logger;
  54. _kpiCalcDispatcher = kpiCalcDispatcher;
  55. _dimensionRun = dimensionRun;
  56. _kpiTargetResolver = kpiTargetResolver;
  57. _inventoryMdpSync = inventoryMdpSync;
  58. _purchaseReceiptMdpSync = purchaseReceiptMdpSync;
  59. }
  60. public async Task<S5MdpSyncTransformResult> RunFullAsync(
  61. CancellationToken cancellationToken = default,
  62. string triggerType = "AUTO",
  63. S5MdpRefreshOption? option = null)
  64. {
  65. cancellationToken.ThrowIfCancellationRequested();
  66. option ??= S5MdpRefreshOption.Default();
  67. NormalizeOption(option);
  68. var now = DateTime.Now;
  69. var batchId = $"S5_MDP_FULL_{now:yyyyMMddHHmmss}";
  70. var normalizedTrigger = NormalizeTriggerType(triggerType);
  71. var runLogId = await InsertTransformRunLogAsync(batchId, now, normalizedTrigger, option);
  72. var result = new S5MdpSyncTransformResult
  73. {
  74. BatchId = batchId,
  75. RunLogId = runLogId,
  76. TriggerType = normalizedTrigger,
  77. SourceZtid = option.SourceZtid,
  78. TargetTenantId = option.TargetTenantId,
  79. TargetFactoryId = option.TargetFactoryId,
  80. BizDate = option.BizDate,
  81. BizMonth = option.BizMonth,
  82. DailyPeriodStart = option.DailyPeriodStart,
  83. DailyPeriodEnd = option.DailyPeriodEnd,
  84. MonthlyPeriodStart = option.MonthlyPeriodStart,
  85. MonthlyPeriodEnd = option.MonthlyPeriodEnd
  86. };
  87. try
  88. {
  89. var receiptSync = await _purchaseReceiptMdpSync.RunInboundAsync(
  90. option.TargetTenantId, true, cancellationToken);
  91. result.StageRows = receiptSync.RowsWrittenStg;
  92. result.StandardRows = receiptSync.StdRows
  93. + await _inventoryMdpSync.TransformTransStdFromStgAsync(
  94. option.TargetTenantId, cancellationToken);
  95. var sub16 = await BuildS5L1001MaterialOnlineCycleAsync(batchId, now, option, normalizedTrigger, cancellationToken);
  96. result.MergeSub("S5_L1_001", sub16);
  97. var sub17 = await BuildS5L1002MaterialOnlineFulfillmentAsync(batchId, now, option, normalizedTrigger, cancellationToken);
  98. result.MergeSub("S5_L1_002", sub17);
  99. var sub18 = await BuildS5L1003MaterialWarehouseEfficiencyAsync(batchId, now, option, normalizedTrigger, cancellationToken);
  100. result.MergeSub("S5_L1_003", sub18);
  101. var sub19 = await BuildS5L1004MaterialInventoryTurnoverAsync(batchId, now, option, normalizedTrigger, cancellationToken);
  102. result.MergeSub("S5_L1_004", sub19);
  103. var currentBizDate = option.BizDate;
  104. var currentPeriodStart = option.DailyPeriodStart;
  105. var currentPeriodEnd = option.DailyPeriodEnd;
  106. const int backfillDays = 14;
  107. for (var dayOffset = backfillDays - 1; dayOffset >= 0; dayOffset--)
  108. {
  109. option.BizDate = currentBizDate.AddDays(-dayOffset);
  110. option.DailyPeriodStart = option.BizDate.Date;
  111. option.DailyPeriodEnd = option.BizDate.Date.AddDays(1).AddSeconds(-1);
  112. result.MergeSub("S5_L2_001", await BuildS5L2001ReceiptCycleAsync(
  113. batchId, now, option, normalizedTrigger, cancellationToken));
  114. result.MergeSub("S5_L2_002", await BuildS5L2002ReceiptFulfillmentAsync(
  115. batchId, now, option, normalizedTrigger, cancellationToken));
  116. result.MergeSub("S5_L2_003", await BuildS5L2003IqcCycleAsync(
  117. batchId, now, option, normalizedTrigger, cancellationToken));
  118. result.MergeSub("S5_L2_004", await BuildS5L2004IqcFulfillmentAsync(
  119. batchId, now, option, normalizedTrigger, cancellationToken));
  120. foreach (var metricCode in new[]
  121. {
  122. "S5_L2_005", "S5_L2_006", "S5_L2_007", "S5_L2_008", "S5_L2_009",
  123. "S5_L2_010", "S5_L2_011", "S5_L2_012", "S5_L2_013", "S5_L2_014",
  124. "S5_L2_015", "S5_L3_001", "S5_L3_002", "S5_L3_003", "S5_L3_004",
  125. "S5_L3_005"
  126. })
  127. {
  128. var sub = await BuildS5WarehouseStageKpiAsync(
  129. metricCode, batchId, now, option, normalizedTrigger, cancellationToken);
  130. result.MergeSub(metricCode, sub);
  131. }
  132. }
  133. option.BizDate = currentBizDate;
  134. option.DailyPeriodStart = currentPeriodStart;
  135. option.DailyPeriodEnd = currentPeriodEnd;
  136. await MarkTransformRunSuccessAsync(runLogId, now, result);
  137. return result;
  138. }
  139. catch (Exception ex)
  140. {
  141. // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
  142. if (!_runLogFinalizer.IsHostStopping)
  143. await MarkTransformRunFailedAsync(runLogId, now, ex.Message, batchId);
  144. throw;
  145. }
  146. finally
  147. {
  148. await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
  149. }
  150. }
  151. // ─────────────────────────────────────────────────────────────────────────
  152. // KPI 实现(方老师 v5.4 KPI J 列 SQL 原逻辑直发 T8)
  153. // ─────────────────────────────────────────────────────────────────────────
  154. /// <summary>S5_L1_001 物料上线周期 = 配送到产线日期(lbs=生产领料) - 收货日期(lbs=采购入库)。
  155. /// 数据准备(写 dwd 明细)始终执行;最终 KPI 聚合由计算配置分发器接管(LEGACY_CODE/CONFIG_SQL)。</summary>
  156. private async Task<KpiBuildSubResult> BuildS5L1001MaterialOnlineCycleAsync(
  157. string batchId, DateTime now, S5MdpRefreshOption option, string triggerType, CancellationToken ct)
  158. {
  159. var sub = new KpiBuildSubResult();
  160. // 双模式:读本地标准层 mdp_std_t8_*(源 identity Id→src_id),语义等价于原直发 T8 SQL。
  161. const string sqlOnline = @"
  162. select b.code as code, min(a.shtime) as shtime
  163. from mdp_std_t8_kc_tz_head a
  164. inner join mdp_std_t8_kc_tz_list b on a.src_id=b.idid and b.tenant_id=a.tenant_id
  165. where a.tenant_id=@tenantId and a.ztid=@ztid and a.lbs='生产领料' and a.hzyn=0 and a.zfyn=0 and a.shyn=1
  166. group by b.code";
  167. const string sqlReceipt = @"
  168. select b.code as code, min(a.shtime) as shtime
  169. from mdp_std_t8_kc_tz_head a
  170. inner join mdp_std_t8_kc_tz_list b on a.src_id=b.idid and b.tenant_id=a.tenant_id
  171. where a.tenant_id=@tenantId and a.ztid=@ztid and a.lbs='采购入库' and a.hzyn=0 and a.zfyn=0 and a.shyn=1
  172. group by b.code";
  173. var p = new[]
  174. {
  175. new SugarParameter("@tenantId", option.TargetTenantId),
  176. new SugarParameter("@ztid", option.SourceZtid)
  177. };
  178. var onlineRows = await _db.Ado.SqlQueryAsync<S5OnlineCycleRow>(sqlOnline, p);
  179. var receiptRows = await _db.Ado.SqlQueryAsync<S5OnlineCycleRow>(sqlReceipt, p);
  180. sub.T8Rows = onlineRows.Count + receiptRows.Count;
  181. var onlineByCode = onlineRows.Where(r => !string.IsNullOrEmpty(r.code))
  182. .ToDictionary(r => r.code!, r => r.shtime, StringComparer.OrdinalIgnoreCase);
  183. var receiptByCode = receiptRows.Where(r => !string.IsNullOrEmpty(r.code))
  184. .ToDictionary(r => r.code!, r => r.shtime, StringComparer.OrdinalIgnoreCase);
  185. var allCodes = new HashSet<string>(onlineByCode.Keys, StringComparer.OrdinalIgnoreCase);
  186. allCodes.UnionWith(receiptByCode.Keys);
  187. var dwdAffected = 0;
  188. var cycleDaysList = new List<int>();
  189. foreach (var code in allCodes)
  190. {
  191. ct.ThrowIfCancellationRequested();
  192. var online = onlineByCode.GetValueOrDefault(code);
  193. var receipt = receiptByCode.GetValueOrDefault(code);
  194. int? cycleDays = null;
  195. if (online.HasValue && receipt.HasValue)
  196. {
  197. cycleDays = (int)(online.Value.Date - receipt.Value.Date).TotalDays;
  198. cycleDaysList.Add(cycleDays.Value);
  199. }
  200. dwdAffected += await _db.Ado.ExecuteCommandAsync(@"
  201. INSERT INTO dwd_t8_material_online_cycle
  202. (tenant_id, factory_id, biz_date, source_ztid, item_code, online_date, receipt_date, cycle_days, batch_id, create_time)
  203. VALUES
  204. (@tenantId, @factoryId, @bizDate, @ztid, @itemCode, @online, @receipt, @cycleDays, @batchId, @now)
  205. ON DUPLICATE KEY UPDATE
  206. online_date=VALUES(online_date), receipt_date=VALUES(receipt_date),
  207. cycle_days=VALUES(cycle_days), batch_id=VALUES(batch_id), update_time=@now",
  208. new SugarParameter("@tenantId", option.TargetTenantId),
  209. new SugarParameter("@factoryId", option.TargetFactoryId),
  210. new SugarParameter("@bizDate", option.BizDate),
  211. new SugarParameter("@ztid", option.SourceZtid),
  212. new SugarParameter("@itemCode", code),
  213. new SugarParameter("@online", online),
  214. new SugarParameter("@receipt", receipt),
  215. new SugarParameter("@cycleDays", cycleDays),
  216. new SugarParameter("@batchId", batchId),
  217. new SugarParameter("@now", now));
  218. }
  219. sub.DwdRows = dwdAffected;
  220. // 数据准备(dwd 明细)已完成。最终 KPI 聚合交计算配置分发器:
  221. // 无配置/LEGACY_CODE → 用下面 legacy 均值;CONFIG_SQL → 执行已发布只读 SQL;
  222. // CONFIG_SQL 失败不 fallback、不写值、保留上一成功值(ShouldUpsert=false)。
  223. decimal? legacyValue = cycleDaysList.Count > 0 ? (decimal)cycleDaysList.Average() : null;
  224. var legacyDenom = cycleDaysList.Count > 0 ? "OK" : "NO_NUMERATOR";
  225. var dispatch = await _kpiCalcDispatcher.DispatchAsync(
  226. "S5_L1_001", ModuleCode, option.TargetTenantId, option.TargetFactoryId,
  227. option.BizDate, option.DailyPeriodStart, option.DailyPeriodEnd, option.SourceZtid,
  228. batchId, triggerType, legacyValue, legacyDenom, ct);
  229. sub.KpiRows = dispatch.ShouldUpsert
  230. ? await UpsertKpiValueAsync("S5_L1_001", option.BizDate, dispatch.MetricValue, now, option)
  231. : 0;
  232. sub.DenominatorStatus = dispatch.DenominatorStatus;
  233. // 调度:SUMMARY 成功/NO_DATA 后触发对应 DIMENSION 跑批(共享 BatchId;SUMMARY FAILED 不触发)。
  234. if (dispatch.ShouldUpsert)
  235. {
  236. try
  237. {
  238. await _dimensionRun.RunDimensionAsync(
  239. "S5_L1_001", ModuleCode, option.TargetTenantId, option.BizDate, batchId, triggerType, ct);
  240. }
  241. catch (Exception ex)
  242. {
  243. _logger.LogWarning(ex, "S5_L1_001 维度跑批异常(不影响汇总链路)");
  244. }
  245. }
  246. return sub;
  247. }
  248. /// <summary>S5_L1_002 物料上线满足率 = 开工日期前完成上线行数 / 工单物料总行数。</summary>
  249. private async Task<KpiBuildSubResult> BuildS5L1002MaterialOnlineFulfillmentAsync(
  250. string batchId, DateTime now, S5MdpRefreshOption option, string triggerType, CancellationToken ct)
  251. {
  252. var sub = new KpiBuildSubResult();
  253. // 中台化:读本地标准层 mdp_std_t8_*(kc_tz_head.Id→src_id、Cj_Bg_Head_Rep/kc_dd_list_cllist 已贴源),
  254. // 口径与原直发 T8 SQL 逐项等价(分子/分母/JOIN/去重/shtime<=kgdate/count 均不变),不再直连 t8_v5。
  255. const string sqlNumer = @"
  256. select lynoid as lynoid, count(*) as codenum
  257. from (
  258. select h.lynoid as lynoid, l.code as code
  259. from mdp_std_t8_kc_tz_head h
  260. inner join mdp_std_t8_kc_tz_list l on h.src_id=l.idid and l.tenant_id=h.tenant_id
  261. left join (
  262. select noid as noid, min(kgdate) as kgdate
  263. from mdp_std_t8_cj_bg_head_rep
  264. where tenant_id=@tenantId and ztid=@ztid group by noid
  265. ) c on h.lynoid=c.noid
  266. where h.tenant_id=@tenantId and h.ztid=@ztid and h.lbs='生产领料' and h.hzyn=0 and h.zfyn=0 and h.shyn=1 and h.shtime<=c.kgdate
  267. group by h.lynoid, l.code
  268. ) n
  269. group by lynoid";
  270. const string sqlDenom = @"
  271. select h.noid as noid, count(l.src_id) as listnum
  272. from mdp_std_t8_kc_dd_head h
  273. left join mdp_std_t8_kc_dd_list_cllist l on h.src_id=l.idid and l.tenant_id=h.tenant_id
  274. where h.tenant_id=@tenantId and h.ztid=@ztid and h.lbs='生产任务' and h.zf=0 and h.shyn=1
  275. group by h.noid";
  276. var p = new[]
  277. {
  278. new SugarParameter("@tenantId", option.TargetTenantId),
  279. new SugarParameter("@ztid", option.SourceZtid)
  280. };
  281. var numerRows = await _db.Ado.SqlQueryAsync<S5FulfillmentNumerRow>(sqlNumer, p);
  282. var denomRows = await _db.Ado.SqlQueryAsync<S5FulfillmentDenomRow>(sqlDenom, p);
  283. sub.T8Rows = numerRows.Count + denomRows.Count;
  284. var numerByOrder = numerRows.Where(r => !string.IsNullOrEmpty(r.lynoid))
  285. .ToDictionary(r => r.lynoid!, r => r.codenum, StringComparer.OrdinalIgnoreCase);
  286. var dwdAffected = 0;
  287. var rateList = new List<decimal>();
  288. foreach (var d in denomRows)
  289. {
  290. ct.ThrowIfCancellationRequested();
  291. if (string.IsNullOrEmpty(d.noid)) continue;
  292. var beforeKg = numerByOrder.GetValueOrDefault(d.noid, 0);
  293. decimal? rate = d.listnum > 0
  294. ? Math.Round((decimal)beforeKg / d.listnum, 4)
  295. : null; // 分母为 0 时不伪装真实 0
  296. if (rate.HasValue) rateList.Add(rate.Value);
  297. dwdAffected += await _db.Ado.ExecuteCommandAsync(@"
  298. INSERT INTO dwd_t8_material_online_fulfillment
  299. (tenant_id, factory_id, biz_date, source_ztid, work_order_no,
  300. before_kgdate_rows, total_rows, fulfillment_rate, batch_id, create_time)
  301. VALUES
  302. (@tenantId, @factoryId, @bizDate, @ztid, @workOrderNo, @beforeKg, @total, @rate, @batchId, @now)
  303. ON DUPLICATE KEY UPDATE
  304. before_kgdate_rows=VALUES(before_kgdate_rows),
  305. total_rows=VALUES(total_rows),
  306. fulfillment_rate=VALUES(fulfillment_rate),
  307. batch_id=VALUES(batch_id), update_time=@now",
  308. new SugarParameter("@tenantId", option.TargetTenantId),
  309. new SugarParameter("@factoryId", option.TargetFactoryId),
  310. new SugarParameter("@bizDate", option.BizDate),
  311. new SugarParameter("@ztid", option.SourceZtid),
  312. new SugarParameter("@workOrderNo", d.noid),
  313. new SugarParameter("@beforeKg", beforeKg),
  314. new SugarParameter("@total", d.listnum),
  315. new SugarParameter("@rate", rate),
  316. new SugarParameter("@batchId", batchId),
  317. new SugarParameter("@now", now));
  318. }
  319. sub.DwdRows = dwdAffected;
  320. // 数据准备(dwd 逐单明细)已完成。最终 KPI 聚合交计算配置分发器:
  321. // 无配置/LEGACY_CODE → 用下面 legacy 均值-of-比率×100;CONFIG_SQL → 执行已发布只读 SQL;
  322. // CONFIG_SQL 失败不 fallback、不写值、保留上一成功值(ShouldUpsert=false)。
  323. decimal? legacyValue = rateList.Count > 0
  324. ? Math.Round(rateList.Average() * 100m, 4) // 百分号
  325. : null;
  326. var legacyDenom = rateList.Count > 0 ? "OK" : "NO_VALID_ORDER";
  327. var dispatch = await _kpiCalcDispatcher.DispatchAsync(
  328. "S5_L1_002", ModuleCode, option.TargetTenantId, option.TargetFactoryId,
  329. option.BizDate, option.DailyPeriodStart, option.DailyPeriodEnd, option.SourceZtid,
  330. batchId, triggerType, legacyValue, legacyDenom, ct);
  331. sub.KpiRows = dispatch.ShouldUpsert
  332. ? await UpsertKpiValueAsync("S5_L1_002", option.BizDate, dispatch.MetricValue, now, option)
  333. : 0;
  334. sub.DenominatorStatus = dispatch.DenominatorStatus;
  335. // 调度:SUMMARY 成功/NO_DATA 后触发对应 DIMENSION 跑批(共享 BatchId;SUMMARY FAILED 不触发)。
  336. if (dispatch.ShouldUpsert)
  337. {
  338. try
  339. {
  340. await _dimensionRun.RunDimensionAsync(
  341. "S5_L1_002", ModuleCode, option.TargetTenantId, option.BizDate, batchId, triggerType, ct);
  342. }
  343. catch (Exception ex)
  344. {
  345. _logger.LogWarning(ex, "S5_L1_002 维度跑批异常(不影响汇总链路)");
  346. }
  347. }
  348. return sub;
  349. }
  350. /// <summary>S5_L1_003 物料仓储人效 = SUM(slzx where lbs=生产领料) / count(gw=仓管)。</summary>
  351. private async Task<KpiBuildSubResult> BuildS5L1003MaterialWarehouseEfficiencyAsync(
  352. string batchId, DateTime now, S5MdpRefreshOption option, string triggerType, CancellationToken ct)
  353. {
  354. var sub = new KpiBuildSubResult();
  355. const string sqlNumer = @"
  356. select sum(b.slzx) as slzx
  357. from mdp_std_t8_kc_tz_head a
  358. inner join mdp_std_t8_kc_tz_list b on a.src_id=b.idid and b.tenant_id=a.tenant_id
  359. where a.tenant_id=@tenantId and a.ztid=@ztid and a.lbs='生产领料' and a.hzyn=0 and a.zfyn=0 and a.shyn=1
  360. and a.shtime between @startDate and @endDate";
  361. const string sqlDenom = @"
  362. select count(*) as penum
  363. from mdp_std_t8_sys_pelist
  364. where tenant_id=@tenantId and ztid=@ztid and zzzt='在职' and gw='仓管'";
  365. var pNumer = new[]
  366. {
  367. new SugarParameter("@tenantId", option.TargetTenantId),
  368. new SugarParameter("@ztid", option.SourceZtid),
  369. new SugarParameter("@startDate", option.MonthlyPeriodStart),
  370. new SugarParameter("@endDate", option.MonthlyPeriodEnd)
  371. };
  372. var pDenom = new[]
  373. {
  374. new SugarParameter("@tenantId", option.TargetTenantId),
  375. new SugarParameter("@ztid", option.SourceZtid)
  376. };
  377. var numerRows = await _db.Ado.SqlQueryAsync<S5SumQtyRow>(sqlNumer, pNumer);
  378. var denomRows = await _db.Ado.SqlQueryAsync<S5CountRow>(sqlDenom, pDenom);
  379. sub.T8Rows = numerRows.Count + denomRows.Count;
  380. decimal? onlineQty = numerRows.FirstOrDefault()?.slzx;
  381. int? headcount = denomRows.FirstOrDefault()?.penum;
  382. // 分母 = 0 或 NULL:efficiency 写 NULL,并标记 denominator_status;不伪装真实 0
  383. decimal? efficiency = null;
  384. string denomStatus;
  385. if (!headcount.HasValue || headcount.Value <= 0)
  386. {
  387. denomStatus = "NO_HEADCOUNT";
  388. }
  389. else if (!onlineQty.HasValue)
  390. {
  391. denomStatus = "NO_NUMERATOR";
  392. }
  393. else
  394. {
  395. efficiency = Math.Round(onlineQty.Value / headcount.Value, 4);
  396. denomStatus = "OK";
  397. }
  398. sub.DenominatorStatus = denomStatus;
  399. // 月度 KPI 用 biz_month 唯一键,整月 1 行
  400. var dwdAffected = await _db.Ado.ExecuteCommandAsync(@"
  401. INSERT INTO dwd_t8_material_warehouse_efficiency
  402. (tenant_id, factory_id, biz_month, source_ztid, period_start, period_end,
  403. online_qty, warehouse_headcount, efficiency, denominator_status, batch_id, create_time)
  404. VALUES
  405. (@tenantId, @factoryId, @bizMonth, @ztid, @periodStart, @periodEnd,
  406. @onlineQty, @headcount, @efficiency, @denomStatus, @batchId, @now)
  407. ON DUPLICATE KEY UPDATE
  408. period_start=VALUES(period_start), period_end=VALUES(period_end),
  409. online_qty=VALUES(online_qty), warehouse_headcount=VALUES(warehouse_headcount),
  410. efficiency=VALUES(efficiency), denominator_status=VALUES(denominator_status),
  411. batch_id=VALUES(batch_id), update_time=@now",
  412. new SugarParameter("@tenantId", option.TargetTenantId),
  413. new SugarParameter("@factoryId", option.TargetFactoryId),
  414. new SugarParameter("@bizMonth", option.BizMonth),
  415. new SugarParameter("@ztid", option.SourceZtid),
  416. new SugarParameter("@periodStart", option.MonthlyPeriodStart),
  417. new SugarParameter("@periodEnd", option.MonthlyPeriodEnd),
  418. new SugarParameter("@onlineQty", onlineQty),
  419. new SugarParameter("@headcount", headcount),
  420. new SugarParameter("@efficiency", efficiency),
  421. new SugarParameter("@denomStatus", denomStatus),
  422. new SugarParameter("@batchId", batchId),
  423. new SugarParameter("@now", now));
  424. sub.DwdRows = dwdAffected;
  425. // 月度 KPI 最终聚合交分发器;bizDate=月末、period=当月窗口;
  426. // legacyValue=efficiency、legacyDenom 保留 NO_HEADCOUNT/NO_NUMERATOR(CONFIG_SQL 下塌缩为 NO_DATA)。
  427. var dispatch = await _kpiCalcDispatcher.DispatchAsync(
  428. "S5_L1_003", ModuleCode, option.TargetTenantId, option.TargetFactoryId,
  429. option.MonthlyPeriodEnd, option.MonthlyPeriodStart, option.MonthlyPeriodEnd, option.SourceZtid,
  430. batchId, triggerType, efficiency, denomStatus, ct);
  431. sub.KpiRows = dispatch.ShouldUpsert
  432. ? await UpsertKpiValueAsync("S5_L1_003", option.MonthlyPeriodEnd, dispatch.MetricValue, now, option)
  433. : 0;
  434. sub.DenominatorStatus = dispatch.DenominatorStatus;
  435. // 调度:SUMMARY 成功/NO_DATA 后触发人效月度 DIMENSION 跑批(月度:@biz_date=月末派生 biz_month,与 SUMMARY 同月)。
  436. if (dispatch.ShouldUpsert)
  437. {
  438. try
  439. {
  440. await _dimensionRun.RunDimensionAsync(
  441. "S5_L1_003", ModuleCode, option.TargetTenantId, option.MonthlyPeriodEnd, batchId, triggerType, ct);
  442. }
  443. catch (Exception ex)
  444. {
  445. _logger.LogWarning(ex, "S5_L1_003 维度跑批异常(不影响汇总链路)");
  446. }
  447. }
  448. return sub;
  449. }
  450. /// <summary>S5_L1_004 品类物料库存周转 = D1/D2 × 30;D1=je3 月均库存金额,D2=je2 出库成本。</summary>
  451. private async Task<KpiBuildSubResult> BuildS5L1004MaterialInventoryTurnoverAsync(
  452. string batchId, DateTime now, S5MdpRefreshOption option, string triggerType, CancellationToken ct)
  453. {
  454. var sub = new KpiBuildSubResult();
  455. // TVF:Rep_总账_存货_V3(账套, '普通', '正常', 起期 YYYYMM, 止期 YYYYMM)
  456. const string sqlTvf = @"
  457. select ckcode as ckcode, ckname as ckname,
  458. code as code, cname as cname,
  459. pcode as pcode, pname as pname,
  460. je3 as je3, je2 as je2
  461. from dbo.Rep_总账_存货_V3(@ztid, N'普通', N'正常', @startYm, @endYm)";
  462. var p = new[]
  463. {
  464. new SugarParameter("@ztid", option.SourceZtid),
  465. new SugarParameter("@startYm", option.TvfPeriodStartYyyymm),
  466. new SugarParameter("@endYm", option.TvfPeriodEndYyyymm)
  467. };
  468. var tvfRows = await QueryT8Async<S5InventoryTurnoverRow>(sqlTvf, p);
  469. sub.T8Rows = tvfRows.Count;
  470. var dwdAffected = 0;
  471. var turnoverDaysList = new List<decimal>();
  472. foreach (var r in tvfRows)
  473. {
  474. ct.ThrowIfCancellationRequested();
  475. // 周转天数:D2=0 或 NULL 时 NULL,不伪装 0
  476. decimal? turnoverDays = (r.je2.HasValue && r.je2.Value > 0m && r.je3.HasValue)
  477. ? Math.Round(r.je3.Value / r.je2.Value * 30m, 4)
  478. : null;
  479. if (turnoverDays.HasValue) turnoverDaysList.Add(turnoverDays.Value);
  480. dwdAffected += await _db.Ado.ExecuteCommandAsync(@"
  481. INSERT INTO dwd_t8_material_inventory_turnover
  482. (tenant_id, factory_id, biz_month, source_ztid, period_start_yyyymm, period_end_yyyymm,
  483. warehouse_code, warehouse_name, item_code, item_name, category_code, category_name,
  484. avg_inventory_value, monthly_outbound_cost, turnover_days, batch_id, create_time)
  485. VALUES
  486. (@tenantId, @factoryId, @bizMonth, @ztid, @startYm, @endYm,
  487. @ckcode, @ckname, @itemCode, @itemName, @pcode, @pname,
  488. @je3, @je2, @turnoverDays, @batchId, @now)
  489. ON DUPLICATE KEY UPDATE
  490. warehouse_name=VALUES(warehouse_name), item_name=VALUES(item_name),
  491. category_code=VALUES(category_code), category_name=VALUES(category_name),
  492. avg_inventory_value=VALUES(avg_inventory_value),
  493. monthly_outbound_cost=VALUES(monthly_outbound_cost),
  494. turnover_days=VALUES(turnover_days),
  495. period_start_yyyymm=VALUES(period_start_yyyymm),
  496. period_end_yyyymm=VALUES(period_end_yyyymm),
  497. batch_id=VALUES(batch_id), update_time=@now",
  498. new SugarParameter("@tenantId", option.TargetTenantId),
  499. new SugarParameter("@factoryId", option.TargetFactoryId),
  500. new SugarParameter("@bizMonth", option.BizMonth),
  501. new SugarParameter("@ztid", option.SourceZtid),
  502. new SugarParameter("@startYm", option.TvfPeriodStartYyyymm),
  503. new SugarParameter("@endYm", option.TvfPeriodEndYyyymm),
  504. new SugarParameter("@ckcode", r.ckcode ?? ""),
  505. new SugarParameter("@ckname", r.ckname),
  506. new SugarParameter("@itemCode", r.code ?? ""),
  507. new SugarParameter("@itemName", r.cname),
  508. new SugarParameter("@pcode", r.pcode),
  509. new SugarParameter("@pname", r.pname),
  510. new SugarParameter("@je3", r.je3),
  511. new SugarParameter("@je2", r.je2),
  512. new SugarParameter("@turnoverDays", turnoverDays),
  513. new SugarParameter("@batchId", batchId),
  514. new SugarParameter("@now", now));
  515. }
  516. sub.DwdRows = dwdAffected;
  517. // KPI 值:所有品类周转天数算术平均;无任一可计算品类时 NULL
  518. decimal? metricValue = turnoverDaysList.Count > 0
  519. ? Math.Round(turnoverDaysList.Average(), 4)
  520. : null;
  521. if (!metricValue.HasValue)
  522. {
  523. var warehouseFallback = await _db.Ado.SqlQuerySingleAsync<S5StageKpiRow>(
  524. WarehouseTurnoverSql("MAT_RECEIPT", "MAT_RECEIPT"),
  525. new SugarParameter("@TenantId", option.TargetTenantId),
  526. new SugarParameter("@FactoryId", option.TargetFactoryId),
  527. new SugarParameter("@MetricCode", "S5_L1_004"));
  528. metricValue = warehouseFallback?.MetricValue;
  529. }
  530. // S5_L1_004 保留 LEGACY_TVF:分发器 LEGACY_TVF 分支直接回传上面的 TVF 均值,
  531. // 不经 KpiSqlReadOnlyExecutor;仅统一 run-log 记录引擎状态(配置登记为 LEGACY_TVF)。
  532. // TVF(Rep_总账_存货_V3)/参数/口径/CommandTimeout 全不变,不建 CONFIG_SQL 版本。
  533. var legacyDenom = turnoverDaysList.Count > 0 ? "OK" : "NO_VALID_OUTBOUND_COST";
  534. var dispatch = await _kpiCalcDispatcher.DispatchAsync(
  535. "S5_L1_004", ModuleCode, option.TargetTenantId, option.TargetFactoryId,
  536. option.MonthlyPeriodEnd, option.MonthlyPeriodStart, option.MonthlyPeriodEnd, option.SourceZtid,
  537. batchId, triggerType, metricValue, legacyDenom, ct);
  538. sub.KpiRows = dispatch.ShouldUpsert
  539. ? await UpsertKpiValueAsync("S5_L1_004", option.MonthlyPeriodEnd, dispatch.MetricValue, now, option)
  540. : 0;
  541. sub.DenominatorStatus = dispatch.DenominatorStatus;
  542. // 调度:SUMMARY 成功/NO_DATA 后触发对应 DIMENSION 跑批(月度:DIMENSION_SQL 用 @biz_date 派生 biz_month,与 SUMMARY 同月)。
  543. if (dispatch.ShouldUpsert)
  544. {
  545. try
  546. {
  547. await _dimensionRun.RunDimensionAsync(
  548. "S5_L1_004", ModuleCode, option.TargetTenantId, option.MonthlyPeriodEnd, batchId, triggerType, ct);
  549. }
  550. catch (Exception ex)
  551. {
  552. _logger.LogWarning(ex, "S5_L1_004 维度跑批异常(不影响汇总链路)");
  553. }
  554. }
  555. return sub;
  556. }
  557. // ─────────────────────────────────────────────────────────────────────────
  558. // 跨库 / 写入 / 日志 封装
  559. // ─────────────────────────────────────────────────────────────────────────
  560. // legacy 直连 T8:仅 S5_L1_004(Rep_总账_存货_V3 TVF,报表聚合结果无逐行主键)仍用;
  561. // 已中台化的 S5_L1_001/002/003 均读 mdp_std_t8_*,不再走此方法。
  562. private async Task<List<T>> QueryT8Async<T>(string sql, SugarParameter[] parameters)
  563. {
  564. var t8 = _db.AsTenant().GetConnectionScope(T8ConfigId);
  565. t8.Ado.CommandTimeOut = T8CommandTimeoutSeconds;
  566. return await t8.Ado.SqlQueryAsync<T>(sql, parameters);
  567. }
  568. /// <summary>S5_L2_001 物料收货周期 = 实际收货日期 - 约定履约日期。</summary>
  569. private async Task<KpiBuildSubResult> BuildS5L2001ReceiptCycleAsync(
  570. string batchId, DateTime now, S5MdpRefreshOption option, string triggerType, CancellationToken ct)
  571. {
  572. const string sql = """
  573. SELECT ROUND(AVG(TIMESTAMPDIFF(HOUR, perform_date, rct_date) / 24), 4) AS MetricValue,
  574. COUNT(1) AS RowCount
  575. FROM (
  576. SELECT STR_TO_DATE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.PerformDate')), 'null'), '%Y-%m-%d %H:%i:%s.%f') AS perform_date,
  577. STR_TO_DATE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.RctDate')), 'null'), '%Y-%m-%d %H:%i:%s.%f') AS rct_date
  578. FROM mdp_stg_purchase_receipt
  579. WHERE tenant_id=@TenantId
  580. AND source_table='PurOrdRctMaster'
  581. ) t
  582. WHERE perform_date IS NOT NULL AND rct_date IS NOT NULL AND rct_date >= perform_date
  583. """;
  584. return await DispatchAverageAsync("S5_L2_001", sql, "NO_RECEIPT_CYCLE", batchId, now, option, triggerType, ct, L2ValueTable);
  585. }
  586. /// <summary>S5_L2_002 物料收货满足率 = 约定日期内完成收货的收货单占比。</summary>
  587. private async Task<KpiBuildSubResult> BuildS5L2002ReceiptFulfillmentAsync(
  588. string batchId, DateTime now, S5MdpRefreshOption option, string triggerType, CancellationToken ct)
  589. {
  590. const string sql = """
  591. SELECT ROUND(100 * SUM(CASE WHEN rct_date <= perform_date THEN 1 ELSE 0 END) / NULLIF(COUNT(1), 0), 4) AS MetricValue,
  592. COUNT(1) AS RowCount
  593. FROM (
  594. SELECT STR_TO_DATE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.PerformDate')), 'null'), '%Y-%m-%d %H:%i:%s.%f') AS perform_date,
  595. STR_TO_DATE(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.RctDate')), 'null'), '%Y-%m-%d %H:%i:%s.%f') AS rct_date
  596. FROM mdp_stg_purchase_receipt
  597. WHERE tenant_id=@TenantId
  598. AND source_table='PurOrdRctMaster'
  599. ) t
  600. WHERE perform_date IS NOT NULL AND rct_date IS NOT NULL
  601. """;
  602. return await DispatchAverageAsync("S5_L2_002", sql, "NO_RECEIPT_FULFILLMENT", batchId, now, option, triggerType, ct, L2ValueTable);
  603. }
  604. /// <summary>S5_L2_003 物料检验周期 = 检验完成时间 - 检验开始时间。</summary>
  605. private async Task<KpiBuildSubResult> BuildS5L2003IqcCycleAsync(
  606. string batchId, DateTime now, S5MdpRefreshOption option, string triggerType, CancellationToken ct)
  607. {
  608. const string sql = """
  609. SELECT ROUND(AVG(TIMESTAMPDIFF(HOUR, FINSPESTARTDATE, FINSPEENDDATE) / 24), 4) AS MetricValue,
  610. COUNT(1) AS RowCount
  611. FROM qms_qcp_inspbill
  612. WHERE tenant_id=@TenantId
  613. AND FINSPESTARTDATE IS NOT NULL
  614. AND FINSPEENDDATE IS NOT NULL
  615. AND FINSPEENDDATE >= FINSPESTARTDATE
  616. """;
  617. return await DispatchAverageAsync("S5_L2_003", sql, "NO_IQC_CYCLE", batchId, now, option, triggerType, ct, L2ValueTable);
  618. }
  619. /// <summary>S5_L2_004 物料检验满足率 = 报检后 72 小时内完成检验的单据占比。</summary>
  620. private async Task<KpiBuildSubResult> BuildS5L2004IqcFulfillmentAsync(
  621. string batchId, DateTime now, S5MdpRefreshOption option, string triggerType, CancellationToken ct)
  622. {
  623. const string sql = """
  624. SELECT ROUND(100 * SUM(CASE WHEN FINSPEENDDATE IS NOT NULL
  625. AND TIMESTAMPDIFF(HOUR, FCREATETIME, FINSPEENDDATE) <= 72 THEN 1 ELSE 0 END)
  626. / NULLIF(COUNT(1), 0), 4) AS MetricValue,
  627. COUNT(1) AS RowCount
  628. FROM qms_qcp_inspbill
  629. WHERE tenant_id=@TenantId
  630. AND FCREATETIME IS NOT NULL
  631. """;
  632. return await DispatchAverageAsync("S5_L2_004", sql, "NO_IQC_FULFILLMENT", batchId, now, option, triggerType, ct, L2ValueTable);
  633. }
  634. private Task<KpiBuildSubResult> BuildS5WarehouseStageKpiAsync(
  635. string metricCode, string batchId, DateTime now, S5MdpRefreshOption option,
  636. string triggerType, CancellationToken ct)
  637. {
  638. var (sql, emptyDenom, table) = metricCode switch
  639. {
  640. "S5_L2_005" => (WarehouseCycleSql("MAT_IQC_RELEASE", "MAT_PUTAWAY"), "NO_MATERIAL_PUTAWAY_CYCLE", L2ValueTable),
  641. "S5_L2_006" => (WarehouseEfficiencySql("MAT_PUTAWAY"), "NO_MATERIAL_PUTAWAY_OPERATOR", L2ValueTable),
  642. "S5_L2_007" => (WarehouseTurnoverSql("MAT_PUTAWAY", "MAT_RECEIPT"), "NO_MATERIAL_RECEIPT_COST", L2ValueTable),
  643. "S5_L2_008" => (WarehouseCycleSql("MAT_PUTAWAY", "MAT_PICK"), "NO_MATERIAL_PICK_CYCLE", L2ValueTable),
  644. "S5_L2_009" => (WarehouseSatisfactionSql("MAT_PICK"), "NO_MATERIAL_PICK_REQUIRED_DATE", L2ValueTable),
  645. "S5_L2_010" => (WarehouseEfficiencySql("MAT_PICK"), "NO_MATERIAL_PICK_OPERATOR", L2ValueTable),
  646. "S5_L2_011" => (WarehouseTurnoverSql("MAT_PICK", "MAT_ISSUE"), "NO_MATERIAL_ISSUE_COST", L2ValueTable),
  647. "S5_L2_012" => (WarehouseCycleSql("MAT_ISSUE", "MAT_LINE"), "NO_MATERIAL_LINE_CYCLE", L2ValueTable),
  648. "S5_L2_013" => (WarehouseSatisfactionSql("MAT_LINE"), "NO_MATERIAL_LINE_REQUIRED_DATE", L2ValueTable),
  649. "S5_L2_014" => (WarehouseEfficiencySql("MAT_LINE"), "NO_MATERIAL_LINE_OPERATOR", L2ValueTable),
  650. "S5_L2_015" => (WarehouseTurnoverSql("MAT_LINE", "MAT_ISSUE"), "NO_MATERIAL_LINE_COST", L2ValueTable),
  651. "S5_L3_001" => (WarehouseEfficiencySql("MAT_RECEIPT"), "NO_MATERIAL_RECEIPT_OPERATOR", L3ValueTable),
  652. "S5_L3_002" => (WarehouseTurnoverSql("MAT_RECEIPT", "MAT_RECEIPT"), "NO_MATERIAL_RECEIPT_COST", L3ValueTable),
  653. "S5_L3_003" => (WarehouseEfficiencySql("MAT_IQC_RELEASE"), "NO_MATERIAL_IQC_OPERATOR", L3ValueTable),
  654. "S5_L3_004" => (WarehouseTurnoverSql("MAT_IQC_RELEASE", "MAT_RECEIPT"), "NO_MATERIAL_IQC_COST", L3ValueTable),
  655. "S5_L3_005" => (WarehouseSatisfactionSql("MAT_PUTAWAY"), "NO_MATERIAL_PUTAWAY_REQUIRED_DATE", L3ValueTable),
  656. _ => throw new ArgumentOutOfRangeException(nameof(metricCode), metricCode, "不支持的 S5 仓储阶段指标")
  657. };
  658. return DispatchAverageAsync(
  659. metricCode, sql, emptyDenom, batchId, now, option, triggerType, ct, table);
  660. }
  661. private static string WarehouseCycleSql(string fromStage, string toStage) =>
  662. $"""
  663. SELECT ROUND(AVG(TIMESTAMPDIFF(MINUTE,a.trans_time,b.trans_time)/1440),4) AS MetricValue,
  664. COUNT(*) AS RowCount
  665. FROM mdp_std_inv_trans a
  666. INNER JOIN mdp_std_inv_trans b
  667. ON b.tenant_id=a.tenant_id AND b.item_num=a.item_num AND b.lot_serial=a.lot_serial
  668. AND b.trans_type='{toStage}'
  669. WHERE a.tenant_id=@TenantId AND a.trans_type='{fromStage}'
  670. AND a.trans_time IS NOT NULL AND b.trans_time>=a.trans_time
  671. """;
  672. private static string WarehouseSatisfactionSql(string stage) =>
  673. $"""
  674. SELECT ROUND(100 * SUM(CASE WHEN trans_time<=eff_date THEN 1 ELSE 0 END)
  675. / NULLIF(COUNT(*),0),4) AS MetricValue,
  676. COUNT(*) AS RowCount
  677. FROM mdp_std_inv_trans
  678. WHERE tenant_id=@TenantId AND trans_type='{stage}'
  679. AND trans_time IS NOT NULL AND eff_date IS NOT NULL
  680. """;
  681. private static string WarehouseEfficiencySql(string stage) =>
  682. $"""
  683. SELECT ROUND(COUNT(DISTINCT NULLIF(lot_serial,''))
  684. / NULLIF(COUNT(DISTINCT NULLIF(TRIM(create_user),'')),0),4) AS MetricValue,
  685. COUNT(*) AS RowCount
  686. FROM mdp_std_inv_trans
  687. WHERE tenant_id=@TenantId AND trans_type='{stage}'
  688. """;
  689. private static string WarehouseTurnoverSql(string inventoryStage, string flowStage) =>
  690. $"""
  691. SELECT ROUND(
  692. 30 * SUM(CASE WHEN trans_type='{inventoryStage}'
  693. THEN IFNULL(end_balance,0)
  694. * IFNULL(CAST(NULLIF(dimension1,'') AS DECIMAL(18,4)),1)
  695. ELSE 0 END)
  696. / NULLIF(SUM(CASE WHEN trans_type='{flowStage}'
  697. THEN ABS(IFNULL(qty_change,0))
  698. * IFNULL(CAST(NULLIF(dimension1,'') AS DECIMAL(18,4)),1)
  699. ELSE 0 END),0),
  700. 4) AS MetricValue,
  701. SUM(CASE WHEN trans_type='{inventoryStage}' THEN 1 ELSE 0 END) AS RowCount
  702. FROM mdp_std_inv_trans
  703. WHERE tenant_id=@TenantId AND trans_type IN ('{inventoryStage}','{flowStage}')
  704. """;
  705. private async Task<KpiBuildSubResult> DispatchAverageAsync(
  706. string metricCode, string sql, string emptyDenom,
  707. string batchId, DateTime now, S5MdpRefreshOption option, string triggerType, CancellationToken ct,
  708. string valueTable)
  709. {
  710. var row = await _db.Ado.SqlQuerySingleAsync<S5StageKpiRow>(sql,
  711. new SugarParameter("@TenantId", option.TargetTenantId));
  712. decimal? value = row?.RowCount > 0 ? row.MetricValue : null;
  713. var denom = value.HasValue ? "OK" : emptyDenom;
  714. var dispatch = await _kpiCalcDispatcher.DispatchAsync(
  715. metricCode, ModuleCode, option.TargetTenantId, option.TargetFactoryId,
  716. option.BizDate, option.BizDate, option.BizDate, option.SourceZtid,
  717. batchId, triggerType, value, denom, ct);
  718. var sub = new KpiBuildSubResult
  719. {
  720. T8Rows = row?.RowCount ?? 0,
  721. KpiRows = dispatch.ShouldUpsert
  722. ? await UpsertKpiValueAsync(metricCode, option.BizDate, dispatch.MetricValue, now, option, valueTable)
  723. : 0,
  724. DenominatorStatus = dispatch.DenominatorStatus
  725. };
  726. try
  727. {
  728. if (dispatch.ShouldUpsert)
  729. {
  730. await _dimensionRun.RunDimensionAsync(
  731. metricCode, ModuleCode, option.TargetTenantId, option.BizDate, batchId, triggerType, ct);
  732. }
  733. }
  734. catch (Exception ex)
  735. {
  736. _logger.LogWarning(ex, "{MetricCode} 维度跑批异常(不影响汇总链路)", metricCode);
  737. }
  738. return sub;
  739. }
  740. private async Task<int> UpsertKpiValueAsync(string metricCode, DateTime bizDate, decimal? metricValue, DateTime now, S5MdpRefreshOption option, string valueTable = "ado_s9_kpi_value_l1_day")
  741. {
  742. // 沿用 S3 UpsertS3KpiValueAsync 范式:先查现存行 → UPDATE;不存在 → SELECT MAX(id)+1 显式生成 id 后 INSERT。
  743. // ado_s9_kpi_value_l1_day.id 为手工分配主键(无 AUTO_INCREMENT),必须显式 set;
  744. // metric_value 允许 NULL(分母缺失不得伪装真实 0)。
  745. // FIX-2:截断时分秒(月度 KPI 入参可能为 YYYY-MM-DD 23:59:59),保证 SELECT WHERE biz_date=@BizDate 与 DB date 列匹配,避免重复 INSERT。
  746. // FIX-1:tenant_id/factory_id 取自 option,默认仍为 1300000000001/1,不破坏 Demo。
  747. bizDate = bizDate.Date;
  748. var snap = await _kpiTargetResolver.ResolveAsync(option.TargetTenantId, option.TargetFactoryId, metricCode, ModuleCode, bizDate);
  749. var meta = await _db.Ado.SqlQuerySingleAsync<dynamic>(
  750. "SELECT Direction, YellowThreshold, RedThreshold FROM ado_smart_ops_kpi_master WHERE TenantId=@TenantId AND MetricCode=@MetricCode AND IsEnabled=1 LIMIT 1",
  751. new SugarParameter("@TenantId", option.TargetTenantId),
  752. new SugarParameter("@MetricCode", metricCode));
  753. var status = AidopS4KpiMerge.AchievementLevel(
  754. metricValue,
  755. snap.TargetValue,
  756. (string?)meta?.Direction ?? "higher_is_better",
  757. (decimal?)meta?.YellowThreshold,
  758. (decimal?)meta?.RedThreshold);
  759. var existingId = await _db.Ado.GetLongAsync(
  760. $"SELECT IFNULL((SELECT id FROM {valueTable} WHERE tenant_id=@TenantId AND factory_id=@FactoryId " +
  761. "AND module_code=@ModuleCode AND metric_code=@MetricCode AND biz_date=@BizDate AND is_deleted=0 " +
  762. "ORDER BY id LIMIT 1), 0)",
  763. new List<SugarParameter>
  764. {
  765. new("@TenantId", option.TargetTenantId),
  766. new("@FactoryId", option.TargetFactoryId),
  767. new("@ModuleCode", ModuleCode),
  768. new("@MetricCode", metricCode),
  769. new("@BizDate", bizDate)
  770. });
  771. if (existingId > 0)
  772. {
  773. return await _db.Ado.ExecuteCommandAsync(
  774. $"UPDATE {valueTable} SET metric_value=@MetricValue, target_value=@TargetValue, status_color=@StatusColor, " +
  775. "target_config_id=@TargetConfigId, target_source=@TargetSource, target_resolved_at=@TargetResolvedAt, " +
  776. "calc_time=@Now, update_time=@Now, is_deleted=0, is_active=1 WHERE id=@Id",
  777. new SugarParameter("@MetricValue", metricValue),
  778. new SugarParameter("@TargetValue", KpiTargetSnapshotSql.ValueOrDbNull(snap)),
  779. new SugarParameter("@StatusColor", status),
  780. new SugarParameter("@TargetConfigId", KpiTargetSnapshotSql.ConfigIdOrDbNull(snap)),
  781. new SugarParameter("@TargetSource", KpiTargetSnapshotSql.SourceOrDbNull(snap)),
  782. new SugarParameter("@TargetResolvedAt", snap.ResolvedAt),
  783. new SugarParameter("@Now", now),
  784. new SugarParameter("@Id", existingId));
  785. }
  786. var nextId = Yitter.IdGenerator.YitIdHelper.NextId();
  787. return await _db.Ado.ExecuteCommandAsync($@"
  788. INSERT INTO {valueTable}
  789. (id, tenant_id, org_id, company_id, factory_id, status, biz_date,
  790. create_time, update_time, is_deleted, is_active,
  791. module_code, metric_code, metric_value, target_value, status_color, calc_time,
  792. target_config_id, target_source, target_resolved_at)
  793. VALUES
  794. (@Id, @TenantId, NULL, NULL, @FactoryId, NULL, @BizDate,
  795. @Now, @Now, 0, 1,
  796. @ModuleCode, @MetricCode, @MetricValue, @TargetValue, @StatusColor, @Now,
  797. @TargetConfigId, @TargetSource, @TargetResolvedAt)",
  798. new SugarParameter("@Id", nextId),
  799. new SugarParameter("@TenantId", option.TargetTenantId),
  800. new SugarParameter("@FactoryId", option.TargetFactoryId),
  801. new SugarParameter("@BizDate", bizDate),
  802. new SugarParameter("@Now", now),
  803. new SugarParameter("@ModuleCode", ModuleCode),
  804. new SugarParameter("@MetricCode", metricCode),
  805. new SugarParameter("@MetricValue", metricValue),
  806. new SugarParameter("@TargetValue", KpiTargetSnapshotSql.ValueOrDbNull(snap)),
  807. new SugarParameter("@StatusColor", status),
  808. new SugarParameter("@TargetConfigId", KpiTargetSnapshotSql.ConfigIdOrDbNull(snap)),
  809. new SugarParameter("@TargetSource", KpiTargetSnapshotSql.SourceOrDbNull(snap)),
  810. new SugarParameter("@TargetResolvedAt", snap.ResolvedAt));
  811. }
  812. private async Task<long> InsertTransformRunLogAsync(string batchId, DateTime startedAt, string triggerType, S5MdpRefreshOption option)
  813. {
  814. await _db.Ado.ExecuteCommandAsync(@"
  815. INSERT INTO mdp_transform_run_log
  816. (tenant_id, job_code, job_name, trigger_type, batch_id, status, start_time, stage_rows, standard_rows, dwd_rows, create_time, update_time)
  817. VALUES
  818. (@TenantId, @JobCode, @JobName, @TriggerType, @BatchId, 'RUNNING', @StartTime, 0, 0, 0, @StartTime, @StartTime)",
  819. new SugarParameter("@TenantId", option.TargetTenantId),
  820. new SugarParameter("@JobCode", JobCode),
  821. new SugarParameter("@JobName", JobName),
  822. new SugarParameter("@TriggerType", triggerType),
  823. new SugarParameter("@BatchId", batchId),
  824. new SugarParameter("@StartTime", startedAt));
  825. return await _db.Ado.GetLongAsync(
  826. "SELECT id FROM mdp_transform_run_log WHERE batch_id=@BatchId ORDER BY id DESC LIMIT 1",
  827. new List<SugarParameter> { new("@BatchId", batchId) });
  828. }
  829. private async Task MarkTransformRunSuccessAsync(long runLogId, DateTime startedAt, S5MdpSyncTransformResult result)
  830. {
  831. var finishedAt = DateTime.Now;
  832. await _db.Ado.ExecuteCommandAsync(@"
  833. UPDATE mdp_transform_run_log
  834. SET status='SUCCESS', end_time=@EndTime, duration_ms=@DurationMs,
  835. stage_rows=@StageRows, standard_rows=@StandardRows, dwd_rows=@DwdRows,
  836. summary_json=@SummaryJson, update_time=CURRENT_TIMESTAMP
  837. WHERE id=@Id",
  838. new SugarParameter("@EndTime", finishedAt),
  839. new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
  840. new SugarParameter("@StageRows", result.StageRows),
  841. new SugarParameter("@StandardRows", result.StandardRows),
  842. new SugarParameter("@DwdRows", result.DwdRows),
  843. new SugarParameter("@SummaryJson", BuildRunSummaryJson(result)),
  844. new SugarParameter("@Id", runLogId));
  845. }
  846. private async Task MarkTransformRunFailedAsync(long runLogId, DateTime startedAt, string message, string batchId)
  847. {
  848. bool runLogUpdated = false;
  849. try
  850. {
  851. var finishedAt = DateTime.Now;
  852. await _db.Ado.ExecuteCommandAsync(@"
  853. UPDATE mdp_transform_run_log
  854. SET status='FAILED', end_time=@EndTime, duration_ms=@DurationMs,
  855. error_message=@ErrorMessage, update_time=CURRENT_TIMESTAMP
  856. WHERE id=@Id",
  857. new SugarParameter("@EndTime", finishedAt),
  858. new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
  859. new SugarParameter("@ErrorMessage", Truncate(message, 2000)),
  860. new SugarParameter("@Id", runLogId));
  861. runLogUpdated = true;
  862. }
  863. catch (Exception ex)
  864. {
  865. // 写库本身失败兜底:远端 MySQL 瞬断导致 MarkFailed 自身也连不上
  866. Console.Error.WriteLine($"[S5MdpSyncTransform] MarkTransformRunFailed write failed (runLogId={runLogId}): {ex.Message}");
  867. }
  868. // FAILURE-NOTIFICATION-1:写库 FAILED 成功后发通知给超级管理员;通知失败不影响主流程
  869. if (!runLogUpdated) return;
  870. try
  871. {
  872. await _sysNoticeService.AddNotice(new AddNoticeInput
  873. {
  874. Title = "S5 物料仓储 T8 KPI 跑批失败",
  875. Content = $"模块:S5 物料仓储\n批次ID:{batchId}\n失败时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}\n错误信息:{Truncate(message, 1000)}\n\n请查看 mdp_transform_run_log 获取完整错误与重试记录。",
  876. Type = NoticeTypeEnum.NOTICE,
  877. PublicTime = DateTime.Now,
  878. Status = NoticeStatusEnum.PUBLIC,
  879. PublicUserId = NoticeReceiverUserId,
  880. PublicUserName = NoticeReceiverUserName
  881. });
  882. }
  883. catch (Exception notifyEx)
  884. {
  885. _logger.LogError(notifyEx, "[S5MdpSyncTransform] SysNotice 发送失败 (runLogId={RunLogId}, batchId={BatchId})", runLogId, batchId);
  886. }
  887. }
  888. private static string BuildRunSummaryJson(S5MdpSyncTransformResult r)
  889. {
  890. var summary = new
  891. {
  892. batchId = r.BatchId,
  893. sourceZtid = r.SourceZtid,
  894. bizDate = r.BizDate.ToString("yyyy-MM-dd"),
  895. bizMonth = r.BizMonth,
  896. triggerType = r.TriggerType,
  897. dwdRows = r.DwdRows,
  898. kpiRows = r.KpiRows,
  899. perKpiDwdRows = r.PerKpiDwdRows,
  900. perKpiKpiRows = r.PerKpiKpiRows,
  901. denominatorStatus = r.KpiDenominatorStatus,
  902. tvfPeriod = $"{r.MonthlyPeriodStart:yyyy-MM-dd}~{r.MonthlyPeriodEnd:yyyy-MM-dd}"
  903. };
  904. return JsonSerializer.Serialize(summary);
  905. }
  906. private static string NormalizeTriggerType(string s) =>
  907. string.IsNullOrWhiteSpace(s) ? "AUTO" : s.Trim().ToUpperInvariant();
  908. private static void NormalizeOption(S5MdpRefreshOption option)
  909. {
  910. var d = S5MdpRefreshOption.Default();
  911. if (option.TargetFactoryId <= 0) option.TargetFactoryId = d.TargetFactoryId;
  912. if (string.IsNullOrWhiteSpace(option.SourceZtid)) option.SourceZtid = d.SourceZtid;
  913. // 目标租户由 T8 账套(ztid)映射决定,禁止固定默认/兜底
  914. option.TargetTenantId = AidopSourceTenantMap.ResolveTenantId(option.SourceZtid, option.TargetTenantId);
  915. if (option.BizDate == default) option.BizDate = d.BizDate;
  916. if (string.IsNullOrWhiteSpace(option.BizMonth)) option.BizMonth = d.BizMonth;
  917. if (option.DailyPeriodStart == default) option.DailyPeriodStart = d.DailyPeriodStart;
  918. if (option.DailyPeriodEnd == default) option.DailyPeriodEnd = d.DailyPeriodEnd;
  919. if (option.MonthlyPeriodStart == default) option.MonthlyPeriodStart = d.MonthlyPeriodStart;
  920. if (option.MonthlyPeriodEnd == default) option.MonthlyPeriodEnd = d.MonthlyPeriodEnd;
  921. if (string.IsNullOrWhiteSpace(option.TvfPeriodStartYyyymm)) option.TvfPeriodStartYyyymm = d.TvfPeriodStartYyyymm;
  922. if (string.IsNullOrWhiteSpace(option.TvfPeriodEndYyyymm)) option.TvfPeriodEndYyyymm = d.TvfPeriodEndYyyymm;
  923. }
  924. private static string Truncate(string s, int max) =>
  925. string.IsNullOrEmpty(s) ? "" : (s.Length <= max ? s : s.Substring(0, max));
  926. }
  927. // ─────────────────────────────────────────────────────────────────────────────
  928. // Refresh 入参与结果 DTO
  929. // ─────────────────────────────────────────────────────────────────────────────
  930. public sealed class S5MdpRefreshOption
  931. {
  932. /// <summary>T8 账套(kc_tz_head.ztid);实测当前唯一账套为 pbxfxp。</summary>
  933. public string SourceZtid { get; set; } = "pbxfxp";
  934. /// <summary>KPI/DWD 落库目标租户;≤0 时由 <see cref="AidopSourceTenantMap"/> 按 SourceZtid 解析。</summary>
  935. public long TargetTenantId { get; set; }
  936. /// <summary>KPI/DWD 落库目标工厂;默认 1。</summary>
  937. public long TargetFactoryId { get; set; } = 1L;
  938. /// <summary>日 T+1 KPI 的业务日期(默认昨天)。</summary>
  939. public DateTime BizDate { get; set; }
  940. /// <summary>月 M+1 KPI 的业务月 YYYY-MM(默认上月)。</summary>
  941. public string BizMonth { get; set; } = "";
  942. /// <summary>日 T+1 KPI 区间起(含),默认昨天 00:00。</summary>
  943. public DateTime DailyPeriodStart { get; set; }
  944. /// <summary>日 T+1 KPI 区间止(含),默认昨天 23:59:59。</summary>
  945. public DateTime DailyPeriodEnd { get; set; }
  946. /// <summary>月 M+1 KPI 区间起(含),默认上月 1 日。</summary>
  947. public DateTime MonthlyPeriodStart { get; set; }
  948. /// <summary>月 M+1 KPI 区间止(含),默认上月末日。</summary>
  949. public DateTime MonthlyPeriodEnd { get; set; }
  950. /// <summary>TVF Rep_总账_存货_V3 入参起期 YYYYMM。</summary>
  951. public string TvfPeriodStartYyyymm { get; set; } = "";
  952. /// <summary>TVF Rep_总账_存货_V3 入参止期 YYYYMM。</summary>
  953. public string TvfPeriodEndYyyymm { get; set; } = "";
  954. public static S5MdpRefreshOption Default()
  955. {
  956. var today = DateTime.Today;
  957. var yesterday = today.AddDays(-1);
  958. var lastMonth = today.AddMonths(-1);
  959. var monthStart = new DateTime(lastMonth.Year, lastMonth.Month, 1);
  960. var monthEnd = monthStart.AddMonths(1).AddDays(-1);
  961. return new S5MdpRefreshOption
  962. {
  963. SourceZtid = "pbxfxp",
  964. TargetTenantId = 0,
  965. TargetFactoryId = 1L,
  966. BizDate = yesterday,
  967. BizMonth = lastMonth.ToString("yyyy-MM"),
  968. DailyPeriodStart = yesterday,
  969. DailyPeriodEnd = yesterday.AddDays(1).AddSeconds(-1),
  970. MonthlyPeriodStart = monthStart,
  971. MonthlyPeriodEnd = monthEnd.AddDays(1).AddSeconds(-1),
  972. TvfPeriodStartYyyymm = monthStart.ToString("yyyyMM"),
  973. TvfPeriodEndYyyymm = monthEnd.ToString("yyyyMM")
  974. };
  975. }
  976. }
  977. public sealed class S5MdpSyncTransformResult
  978. {
  979. public string BatchId { get; set; } = "";
  980. public long RunLogId { get; set; }
  981. public string TriggerType { get; set; } = "AUTO";
  982. public string SourceZtid { get; set; } = "";
  983. public long TargetTenantId { get; set; }
  984. public long TargetFactoryId { get; set; }
  985. public DateTime BizDate { get; set; }
  986. public string BizMonth { get; set; } = "";
  987. public DateTime DailyPeriodStart { get; set; }
  988. public DateTime DailyPeriodEnd { get; set; }
  989. public DateTime MonthlyPeriodStart { get; set; }
  990. public DateTime MonthlyPeriodEnd { get; set; }
  991. public int StageRows { get; set; }
  992. public int StandardRows { get; set; }
  993. public int DwdRows { get; set; }
  994. public int KpiRows { get; set; }
  995. public Dictionary<string, int> PerKpiDwdRows { get; } = new();
  996. public Dictionary<string, int> PerKpiKpiRows { get; } = new();
  997. public List<string> KpiDenominatorStatus { get; } = new();
  998. public void MergeSub(string kpiCode, KpiBuildSubResult sub)
  999. {
  1000. PerKpiDwdRows[kpiCode] = sub.DwdRows;
  1001. PerKpiKpiRows[kpiCode] = sub.KpiRows;
  1002. DwdRows += sub.DwdRows;
  1003. KpiRows += sub.KpiRows;
  1004. KpiDenominatorStatus.Add($"{kpiCode}:{sub.DenominatorStatus}");
  1005. }
  1006. }
  1007. public sealed class KpiBuildSubResult
  1008. {
  1009. public int T8Rows { get; set; }
  1010. public int DwdRows { get; set; }
  1011. public int KpiRows { get; set; }
  1012. public string DenominatorStatus { get; set; } = "OK";
  1013. }
  1014. // ─────────────────────────────────────────────────────────────────────────────
  1015. // T8 result set 投影类型(与方老师 SQL SELECT 列名严格一致;SqlSugar 映射)
  1016. // ─────────────────────────────────────────────────────────────────────────────
  1017. internal sealed class S5OnlineCycleRow
  1018. {
  1019. public string? code { get; set; }
  1020. public DateTime? shtime { get; set; }
  1021. }
  1022. internal sealed class S5FulfillmentNumerRow
  1023. {
  1024. public string? lynoid { get; set; }
  1025. public int codenum { get; set; }
  1026. }
  1027. internal sealed class S5FulfillmentDenomRow
  1028. {
  1029. public string? noid { get; set; }
  1030. public int listnum { get; set; }
  1031. }
  1032. internal sealed class S5SumQtyRow
  1033. {
  1034. public decimal? slzx { get; set; }
  1035. }
  1036. internal sealed class S5CountRow
  1037. {
  1038. public int penum { get; set; }
  1039. }
  1040. internal sealed class S5StageKpiRow
  1041. {
  1042. public decimal? MetricValue { get; set; }
  1043. public int RowCount { get; set; }
  1044. }
  1045. internal sealed class S5InventoryTurnoverRow
  1046. {
  1047. public string? ckcode { get; set; }
  1048. public string? ckname { get; set; }
  1049. public string? code { get; set; }
  1050. public string? cname { get; set; }
  1051. public string? pcode { get; set; }
  1052. public string? pname { get; set; }
  1053. public decimal? je3 { get; set; }
  1054. public decimal? je2 { get; set; }
  1055. }