S6MdpSyncTransformService.cs 49 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022
  1. using Admin.NET.Core.Service;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure;
  3. using Microsoft.Extensions.Logging;
  4. using System.Text.Json;
  5. namespace Admin.NET.Plugin.AiDOP.Manufacturing;
  6. /// <summary>
  7. /// S6 生产执行 — KPI 计算与刷新转换服务。
  8. /// 双模式:读本地标准层 mdp_std_t8_kc_*(由 T8BaseInboundMdpSyncService 从 T8 贴源→标准),不再直连 T8。
  9. /// 计算逻辑沿用方老师 v5.4 KPI J 列口径(等价改写为 MySQL 读 std)。
  10. /// 口径归位(S6-L1-KPI-CONTRACT-RESOLUTION-1,方案 B):L1=订单级,L2=工单级。
  11. /// L1:订单制造周期/满足率/人效写 l1_day;L2:工单制造满足率/人效写 l2_day。
  12. /// 最终聚合经 KpiCalcDispatcher(LEGACY_CODE/CONFIG_SQL),数据准备(dwd)不变。
  13. /// </summary>
  14. public class S6MdpSyncTransformService : ITransient
  15. {
  16. private readonly ISqlSugarClient _db;
  17. private readonly TransformRunLogFinalizer _runLogFinalizer;
  18. private readonly SysNoticeService _sysNoticeService;
  19. private readonly ILogger<S6MdpSyncTransformService> _logger;
  20. private readonly SmartOps.KpiCalcDispatcher _kpiCalcDispatcher;
  21. private readonly SmartOps.KpiDimensionRunService _dimensionRun;
  22. private readonly SmartOps.IKpiTargetResolver _kpiTargetResolver;
  23. private const string L2ValueTable = "ado_s9_kpi_value_l2_day";
  24. private const string L3ValueTable = "ado_s9_kpi_value_l3_day";
  25. private const string JobCode = "S6_MDP_SYNC_TRANSFORM";
  26. private const string JobName = "S6 生产执行 MDP 同步与转换";
  27. private const string ModuleCode = "S6";
  28. // FAILURE-NOTIFICATION-1:超级管理员 superAdmin.NET(AccountType=999)
  29. private const long NoticeReceiverUserId = 1300000000101L;
  30. private const string NoticeReceiverUserName = "超级管理员";
  31. public S6MdpSyncTransformService(
  32. ISqlSugarClient db,
  33. SysNoticeService sysNoticeService,
  34. ILogger<S6MdpSyncTransformService> logger,
  35. SmartOps.KpiCalcDispatcher kpiCalcDispatcher,
  36. SmartOps.KpiDimensionRunService dimensionRun,
  37. SmartOps.IKpiTargetResolver kpiTargetResolver,
  38. TransformRunLogFinalizer runLogFinalizer)
  39. {
  40. _db = db;
  41. _runLogFinalizer = runLogFinalizer;
  42. _sysNoticeService = sysNoticeService;
  43. _logger = logger;
  44. _kpiCalcDispatcher = kpiCalcDispatcher;
  45. _dimensionRun = dimensionRun;
  46. _kpiTargetResolver = kpiTargetResolver;
  47. }
  48. public async Task<S6MdpSyncTransformResult> RunFullAsync(
  49. CancellationToken cancellationToken = default,
  50. string triggerType = "AUTO",
  51. S6MdpRefreshOption? option = null)
  52. {
  53. cancellationToken.ThrowIfCancellationRequested();
  54. option ??= S6MdpRefreshOption.Default();
  55. NormalizeOption(option);
  56. var now = DateTime.Now;
  57. var batchId = $"S6_MDP_FULL_{now:yyyyMMddHHmmss}";
  58. var normalizedTrigger = NormalizeTriggerType(triggerType);
  59. var runLogId = await InsertTransformRunLogAsync(batchId, now, normalizedTrigger, option);
  60. var result = new S6MdpSyncTransformResult
  61. {
  62. BatchId = batchId,
  63. RunLogId = runLogId,
  64. TriggerType = normalizedTrigger,
  65. SourceZtid = option.SourceZtid,
  66. TargetTenantId = option.TargetTenantId,
  67. TargetFactoryId = option.TargetFactoryId,
  68. BizDate = option.BizDate,
  69. BizMonth = option.BizMonth,
  70. MonthlyPeriodStart = option.MonthlyPeriodStart,
  71. MonthlyPeriodEnd = option.MonthlyPeriodEnd
  72. };
  73. try
  74. {
  75. result.StageRows = 0;
  76. result.StandardRows = 0;
  77. var currentBizDate = option.BizDate;
  78. const int backfillDays = 14;
  79. for (var dayOffset = backfillDays - 1; dayOffset >= 0; dayOffset--)
  80. {
  81. option.BizDate = currentBizDate.AddDays(-dayOffset);
  82. result.MergeSub("S6_L2_001", await BuildS6L2001WorkOrderMfgCycleAsync(
  83. batchId, now, option, normalizedTrigger, cancellationToken));
  84. result.MergeSub("S6_L2_002", await BuildS6L2002WorkOrderMfgFulfillmentAsync(
  85. batchId, now, option, normalizedTrigger, cancellationToken));
  86. result.MergeSub("S6_L2_003", await BuildS6L2003WorkOrderMfgEfficiencyAsync(
  87. batchId, now, option, normalizedTrigger, cancellationToken));
  88. result.MergeSub("S6_L2_004", await BuildS6L2004WorkOrderWipTurnoverAsync(
  89. batchId, now, option, normalizedTrigger, cancellationToken));
  90. foreach (var metricCode in new[]
  91. {
  92. "S6_L3_001", "S6_L3_002", "S6_L3_003", "S6_L3_004",
  93. "S6_L3_005", "S6_L3_006", "S6_L3_007", "S6_L3_008"
  94. })
  95. {
  96. var sub = await BuildS6ExecutionDetailKpiAsync(
  97. metricCode, batchId, now, option, normalizedTrigger, cancellationToken);
  98. result.MergeSub(metricCode, sub);
  99. }
  100. }
  101. option.BizDate = currentBizDate;
  102. var sub11 = await BuildS6L1001OrderMfgCycleAsync(batchId, now, option, normalizedTrigger, cancellationToken);
  103. result.MergeSub("S6_L1_001", sub11);
  104. var sub12 = await BuildS6L1002OrderMfgFulfillmentAsync(batchId, now, option, normalizedTrigger, cancellationToken);
  105. result.MergeSub("S6_L1_002", sub12);
  106. var sub13 = await BuildS6L1003OrderMfgEfficiencyAsync(batchId, now, option, normalizedTrigger, cancellationToken);
  107. result.MergeSub("S6_L1_003", sub13);
  108. await MarkTransformRunSuccessAsync(runLogId, now, result);
  109. return result;
  110. }
  111. catch (Exception ex)
  112. {
  113. // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
  114. if (!_runLogFinalizer.IsHostStopping)
  115. await MarkTransformRunFailedAsync(runLogId, now, ex.Message, batchId);
  116. throw;
  117. }
  118. finally
  119. {
  120. await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
  121. }
  122. }
  123. // ─────────────────────────────────────────────────────────────────────────
  124. /// <summary>工单制造周期 = 每个完成工单最晚完工时间 - 最早投产时间,再取工单平均。归位 L2:写 S6_L2_001。</summary>
  125. private async Task<KpiBuildSubResult> BuildS6L2001WorkOrderMfgCycleAsync(
  126. string batchId, DateTime now, S6MdpRefreshOption option, string triggerType, CancellationToken ct)
  127. {
  128. const string sql = @"
  129. select rwnoid, datediff(max(completion_time), min(start_time)) as cycle_days
  130. from (
  131. select a.noid, b.rwnoid, b.code, b.addtime as start_time,
  132. case when b.gdyn=1 and b.gdtime is not null then b.gdtime else max(d.completion_time) end as completion_time,
  133. case when b.gdyn=1 or sum(ifnull(d.slzx,0))>=b.sl then 1 else 0 end as completed
  134. from mdp_std_t8_kc_dd_head a
  135. inner join mdp_std_t8_kc_dd_list b on a.tenant_id=b.tenant_id and a.src_id=b.idid
  136. left join (
  137. select l.lynoid, l.code, l.slzx, ifnull(l.gdtime,h.shtime) as completion_time
  138. from mdp_std_t8_kc_tz_head h
  139. inner join mdp_std_t8_kc_tz_list l on h.tenant_id=l.tenant_id and h.src_id=l.idid
  140. where h.tenant_id=@tenantId and l.tenant_id=@tenantId and h.ztid=@ztid
  141. and h.lbs='生产入库' and h.hzyn=0 and h.zfyn=0 and h.shyn=1
  142. ) d on b.rwnoid=d.lynoid and b.code=d.code
  143. where a.tenant_id=@tenantId and b.tenant_id=@tenantId and a.ztid=@ztid
  144. and a.lbs='生产任务' and a.zf=0 and a.shyn=1 and b.rwnoid is not null
  145. group by a.noid, b.rwnoid, b.code, b.sl, b.addtime, b.gdyn, b.gdtime
  146. ) n
  147. group by rwnoid
  148. having min(completed)=1 and min(start_time) is not null and max(completion_time) is not null";
  149. var rows = await _db.Ado.SqlQueryAsync<S6WorkOrderCycleRow>(sql, new[]
  150. {
  151. new SugarParameter("@tenantId", option.TargetTenantId),
  152. new SugarParameter("@ztid", option.SourceZtid)
  153. });
  154. var cycles = rows.Where(r => r.cycle_days.HasValue && r.cycle_days.Value >= 0)
  155. .Select(r => (decimal)r.cycle_days!.Value).ToList();
  156. decimal? value = cycles.Count > 0 ? Math.Round(cycles.Average(), 4) : null;
  157. var denom = cycles.Count > 0 ? "OK" : "NO_COMPLETED_WORK_ORDER";
  158. var dispatch = await _kpiCalcDispatcher.DispatchAsync(
  159. "S6_L2_001", ModuleCode, option.TargetTenantId, option.TargetFactoryId,
  160. option.BizDate, option.BizDate, option.BizDate, option.SourceZtid,
  161. batchId, triggerType, value, denom, ct);
  162. var sub = new KpiBuildSubResult
  163. {
  164. T8Rows = rows.Count,
  165. KpiRows = dispatch.ShouldUpsert
  166. ? await UpsertKpiValueAsync("S6_L2_001", option.BizDate, dispatch.MetricValue, now, option, L2ValueTable)
  167. : 0,
  168. DenominatorStatus = dispatch.DenominatorStatus
  169. };
  170. await RunDimensionSafelyAsync("S6_L2_001", option.BizDate, batchId, triggerType, option, dispatch.ShouldUpsert, ct);
  171. return sub;
  172. }
  173. /// <summary>工单制造满足率 = 计划完工时间内累计报工 / 工单计划生产数量。归位 L2:写 S6_L2_002。</summary>
  174. private async Task<KpiBuildSubResult> BuildS6L2002WorkOrderMfgFulfillmentAsync(
  175. string batchId, DateTime now, S6MdpRefreshOption option, string triggerType, CancellationToken ct)
  176. {
  177. var sub = new KpiBuildSubResult();
  178. // 双模式:读本地标准层 mdp_std_t8_*(源 identity Id→src_id),语义等价于原直发 T8 SQL。
  179. const string sql = @"
  180. select a.noid as noid, b.rwnoid as rwnoid, b.code as code, b.sl as sl, sum(d.slzx) as slzx
  181. from mdp_std_t8_kc_dd_head a
  182. left join mdp_std_t8_kc_dd_list b on a.tenant_id=b.tenant_id and a.src_id=b.idid
  183. left join (
  184. select b.lynoid as lynoid, b.code as code,
  185. date(a.shtime) as shtime, b.slzx as slzx
  186. from mdp_std_t8_kc_tz_head a
  187. inner join mdp_std_t8_kc_tz_list b on a.tenant_id=b.tenant_id and a.src_id=b.idid
  188. where a.tenant_id=@tenantId and b.tenant_id=@tenantId and a.ztid=@ztid
  189. and a.lbs='生产入库' and a.hzyn=0 and a.zfyn=0 and a.shyn=1
  190. ) d on b.rwnoid=d.lynoid and b.code=d.code
  191. where a.tenant_id=@tenantId and b.tenant_id=@tenantId and a.ztid=@ztid
  192. and a.lbs='生产任务' and a.zf=0 and a.shyn=1 and d.shtime<=b.jhdate
  193. group by a.noid, b.rwnoid, b.code, b.sl";
  194. var rows = await _db.Ado.SqlQueryAsync<S6MfgFulfillmentRow>(sql, new[]
  195. {
  196. new SugarParameter("@tenantId", option.TargetTenantId),
  197. new SugarParameter("@ztid", option.SourceZtid)
  198. });
  199. sub.T8Rows = rows.Count;
  200. var dwdAffected = 0;
  201. var rateList = new List<decimal>();
  202. foreach (var r in rows)
  203. {
  204. ct.ThrowIfCancellationRequested();
  205. if (string.IsNullOrEmpty(r.noid)) continue;
  206. decimal? rate = (r.sl.HasValue && r.sl.Value > 0m && r.slzx.HasValue)
  207. ? Math.Round(Math.Clamp(r.slzx.Value / r.sl.Value, 0m, 1m), 4)
  208. : null;
  209. if (rate.HasValue) rateList.Add(rate.Value);
  210. dwdAffected += await _db.Ado.ExecuteCommandAsync(@"
  211. INSERT INTO dwd_t8_work_order_mfg_fulfillment
  212. (tenant_id, factory_id, biz_date, source_ztid, order_no, task_no, item_code,
  213. plan_qty, done_qty_in_window, fulfillment_rate, batch_id, create_time)
  214. VALUES
  215. (@tenantId, @factoryId, @bizDate, @ztid, @orderNo, @taskNo, @itemCode,
  216. @planQty, @doneQty, @rate, @batchId, @now)
  217. ON DUPLICATE KEY UPDATE
  218. plan_qty=VALUES(plan_qty), done_qty_in_window=VALUES(done_qty_in_window),
  219. fulfillment_rate=VALUES(fulfillment_rate),
  220. batch_id=VALUES(batch_id), update_time=@now",
  221. new SugarParameter("@tenantId", option.TargetTenantId),
  222. new SugarParameter("@factoryId", option.TargetFactoryId),
  223. new SugarParameter("@bizDate", option.BizDate),
  224. new SugarParameter("@ztid", option.SourceZtid),
  225. new SugarParameter("@orderNo", r.noid),
  226. new SugarParameter("@taskNo", r.rwnoid ?? ""),
  227. new SugarParameter("@itemCode", r.code ?? ""),
  228. new SugarParameter("@planQty", r.sl),
  229. new SugarParameter("@doneQty", r.slzx),
  230. new SugarParameter("@rate", rate),
  231. new SugarParameter("@batchId", batchId),
  232. new SugarParameter("@now", now));
  233. }
  234. sub.DwdRows = dwdAffected;
  235. // 数据准备(dwd)已完成。最终聚合交分发器(日 KPI:period 复用 BizDate,SQL 按 biz_date 圈选)。
  236. decimal? legacyValue = rateList.Count > 0
  237. ? Math.Round(rateList.Average() * 100m, 4) // 百分号
  238. : null;
  239. var legacyDenom = rateList.Count > 0 ? "OK" : "NO_VALID_WORK_ORDER";
  240. var dispatch = await _kpiCalcDispatcher.DispatchAsync(
  241. "S6_L2_002", ModuleCode, option.TargetTenantId, option.TargetFactoryId,
  242. option.BizDate, option.BizDate, option.BizDate, option.SourceZtid,
  243. batchId, triggerType, legacyValue, legacyDenom, ct);
  244. sub.KpiRows = dispatch.ShouldUpsert
  245. ? await UpsertKpiValueAsync("S6_L2_002", option.BizDate, dispatch.MetricValue, now, option, L2ValueTable)
  246. : 0;
  247. sub.DenominatorStatus = dispatch.DenominatorStatus;
  248. // 调度:SUMMARY 成功/NO_DATA 后触发对应 DIMENSION 跑批(共享 BatchId;SUMMARY FAILED 不触发)。
  249. if (dispatch.ShouldUpsert)
  250. {
  251. try
  252. {
  253. await _dimensionRun.RunDimensionAsync(
  254. "S6_L2_002", ModuleCode, option.TargetTenantId, option.BizDate, batchId, triggerType, ct);
  255. }
  256. catch (Exception ex)
  257. {
  258. _logger.LogWarning(ex, "S6_L2_002 维度跑批异常(不影响汇总链路)");
  259. }
  260. }
  261. return sub;
  262. }
  263. /// <summary>工单制造人效 = 完成制造工单数(lbs=生产入库 AND (slzx>=sl OR gdyn=1)) / count(gw=生产)。归位 L2:写 S6_L2_003。</summary>
  264. private async Task<KpiBuildSubResult> BuildS6L2003WorkOrderMfgEfficiencyAsync(
  265. string batchId, DateTime now, S6MdpRefreshOption option, string triggerType, CancellationToken ct)
  266. {
  267. var sub = new KpiBuildSubResult();
  268. const string sqlNumer = @"
  269. select count(*) as ddnum
  270. from mdp_std_t8_kc_tz_head a
  271. inner join mdp_std_t8_kc_tz_list b on a.tenant_id=b.tenant_id and a.src_id=b.idid
  272. where a.tenant_id=@tenantId and b.tenant_id=@tenantId and a.ztid=@ztid
  273. and a.lbs='生产入库' and a.hzyn=0 and a.zfyn=0 and a.shyn=1
  274. and a.date0 between @startDate and @endDate
  275. and b.slzx>0 and (b.slzx>=b.sl or b.gdyn=1)";
  276. const string sqlDenom = @"
  277. select count(*) as penum
  278. from mdp_std_t8_sys_pelist
  279. where tenant_id=@tenantId and ztid=@ztid and zzzt='在职' and gw='生产'";
  280. var pNumer = new[]
  281. {
  282. new SugarParameter("@tenantId", option.TargetTenantId),
  283. new SugarParameter("@ztid", option.SourceZtid),
  284. new SugarParameter("@startDate", option.MonthlyPeriodStart),
  285. new SugarParameter("@endDate", option.MonthlyPeriodEnd)
  286. };
  287. var pDenom = new[]
  288. {
  289. new SugarParameter("@tenantId", option.TargetTenantId),
  290. new SugarParameter("@ztid", option.SourceZtid)
  291. };
  292. var numerRows = await _db.Ado.SqlQueryAsync<S6CountRow>(sqlNumer, pNumer);
  293. var denomRows = await _db.Ado.SqlQueryAsync<S6PeNumRow>(sqlDenom, pDenom);
  294. sub.T8Rows = numerRows.Count + denomRows.Count;
  295. int? doneCount = numerRows.FirstOrDefault()?.ddnum;
  296. int? headcount = denomRows.FirstOrDefault()?.penum;
  297. decimal? efficiency = null;
  298. string denomStatus;
  299. if (!headcount.HasValue || headcount.Value <= 0)
  300. denomStatus = "NO_HEADCOUNT";
  301. else if (!doneCount.HasValue)
  302. denomStatus = "NO_NUMERATOR";
  303. else
  304. {
  305. efficiency = Math.Round((decimal)doneCount.Value / headcount.Value, 4);
  306. denomStatus = "OK";
  307. }
  308. sub.DenominatorStatus = denomStatus;
  309. var dwdAffected = await _db.Ado.ExecuteCommandAsync(@"
  310. INSERT INTO dwd_t8_work_order_mfg_efficiency
  311. (tenant_id, factory_id, biz_month, source_ztid, period_start, period_end,
  312. done_count, production_headcount, efficiency, denominator_status, batch_id, create_time)
  313. VALUES
  314. (@tenantId, @factoryId, @bizMonth, @ztid, @periodStart, @periodEnd,
  315. @doneCount, @headcount, @efficiency, @denomStatus, @batchId, @now)
  316. ON DUPLICATE KEY UPDATE
  317. period_start=VALUES(period_start), period_end=VALUES(period_end),
  318. done_count=VALUES(done_count), production_headcount=VALUES(production_headcount),
  319. efficiency=VALUES(efficiency), denominator_status=VALUES(denominator_status),
  320. batch_id=VALUES(batch_id), update_time=@now",
  321. new SugarParameter("@tenantId", option.TargetTenantId),
  322. new SugarParameter("@factoryId", option.TargetFactoryId),
  323. new SugarParameter("@bizMonth", option.BizMonth),
  324. new SugarParameter("@ztid", option.SourceZtid),
  325. new SugarParameter("@periodStart", option.MonthlyPeriodStart),
  326. new SugarParameter("@periodEnd", option.MonthlyPeriodEnd),
  327. new SugarParameter("@doneCount", doneCount),
  328. new SugarParameter("@headcount", headcount),
  329. new SugarParameter("@efficiency", efficiency),
  330. new SugarParameter("@denomStatus", denomStatus),
  331. new SugarParameter("@batchId", batchId),
  332. new SugarParameter("@now", now));
  333. sub.DwdRows = dwdAffected;
  334. // 月度 KPI 最终聚合交分发器;bizDate=月末、period=当月窗口;legacyValue=efficiency、legacyDenom 保留 NO_HEADCOUNT/NO_NUMERATOR。
  335. var dispatch = await _kpiCalcDispatcher.DispatchAsync(
  336. "S6_L2_003", ModuleCode, option.TargetTenantId, option.TargetFactoryId,
  337. option.MonthlyPeriodEnd, option.MonthlyPeriodStart, option.MonthlyPeriodEnd, option.SourceZtid,
  338. batchId, triggerType, efficiency, denomStatus, ct);
  339. sub.KpiRows = dispatch.ShouldUpsert
  340. ? await UpsertKpiValueAsync("S6_L2_003", option.MonthlyPeriodEnd, dispatch.MetricValue, now, option, L2ValueTable)
  341. : 0;
  342. sub.DenominatorStatus = dispatch.DenominatorStatus;
  343. // 调度:SUMMARY 成功/NO_DATA 后触发人效月度 DIMENSION 跑批(月度:@biz_date=月末派生 biz_month)。
  344. if (dispatch.ShouldUpsert)
  345. {
  346. try
  347. {
  348. await _dimensionRun.RunDimensionAsync(
  349. "S6_L2_003", ModuleCode, option.TargetTenantId, option.MonthlyPeriodEnd, batchId, triggerType, ct);
  350. }
  351. catch (Exception ex)
  352. {
  353. _logger.LogWarning(ex, "S6_L2_003 维度跑批异常(不影响汇总链路)");
  354. }
  355. }
  356. return sub;
  357. }
  358. /// <summary>工单在制周转 = 在制工单成本 / 当期工单入库成本 × 30。</summary>
  359. private Task<KpiBuildSubResult> BuildS6L2004WorkOrderWipTurnoverAsync(
  360. string batchId, DateTime now, S6MdpRefreshOption option, string triggerType, CancellationToken ct)
  361. => DispatchExecutionKpiAsync(
  362. "S6_L2_004",
  363. """
  364. SELECT ROUND(
  365. 30 * SUM(IFNULL(QtyWIP,0) * IFNULL(NULLIF(PerAmt,0),1))
  366. / NULLIF(SUM(IFNULL(QtyComplete,0) * IFNULL(NULLIF(PerAmt,0),1)),0),
  367. 4) AS MetricValue,
  368. SUM(CASE WHEN IFNULL(QtyComplete,0)>0 THEN 1 ELSE 0 END) AS RowCount
  369. FROM WorkOrdRouting
  370. WHERE tenant_id=@TenantId
  371. """,
  372. "NO_WORK_ORDER_COMPLETION",
  373. L2ValueTable,
  374. batchId, now, option, triggerType, ct);
  375. private Task<KpiBuildSubResult> BuildS6ExecutionDetailKpiAsync(
  376. string metricCode, string batchId, DateTime now, S6MdpRefreshOption option,
  377. string triggerType, CancellationToken ct)
  378. {
  379. var (sql, emptyDenom) = metricCode switch
  380. {
  381. "S6_L3_001" => (
  382. """
  383. SELECT ROUND(AVG(TIMESTAMPDIFF(MINUTE,StartDate,EndDate)/1440),4) AS MetricValue,
  384. COUNT(*) AS RowCount
  385. FROM WorkOrdRouting
  386. WHERE tenant_id=@TenantId AND StartDate IS NOT NULL AND EndDate>=StartDate
  387. """,
  388. "NO_COMPLETED_OPERATION"),
  389. "S6_L3_002" => (
  390. """
  391. SELECT ROUND(
  392. 100 * SUM(CASE WHEN EndDate IS NOT NULL AND DueDate IS NOT NULL AND EndDate<=DueDate
  393. THEN LEAST(IFNULL(QtyComplete,0),IFNULL(QtyOrded,0)) ELSE 0 END)
  394. / NULLIF(SUM(IFNULL(QtyOrded,0)),0),
  395. 4) AS MetricValue,
  396. SUM(CASE WHEN DueDate IS NOT NULL AND IFNULL(QtyOrded,0)>0 THEN 1 ELSE 0 END) AS RowCount
  397. FROM WorkOrdRouting
  398. WHERE tenant_id=@TenantId
  399. """,
  400. "NO_PLANNED_OPERATION"),
  401. "S6_L3_003" => (
  402. """
  403. SELECT ROUND(
  404. (SELECT COUNT(*) FROM WorkOrdRouting r
  405. WHERE r.tenant_id=@TenantId AND r.EndDate IS NOT NULL AND IFNULL(r.QtyComplete,0)>0)
  406. / NULLIF((
  407. SELECT COUNT(DISTINCT NULLIF(TRIM(e.Employee),''))
  408. FROM OpTransEmployee e
  409. WHERE EXISTS (
  410. SELECT 1 FROM WorkOrdRouting r2
  411. WHERE r2.tenant_id=@TenantId AND r2.WorkOrd=e.WorkOrd
  412. )
  413. ),0),
  414. 4) AS MetricValue,
  415. (SELECT COUNT(*) FROM WorkOrdRouting r
  416. WHERE r.tenant_id=@TenantId AND r.EndDate IS NOT NULL AND IFNULL(r.QtyComplete,0)>0) AS RowCount
  417. """,
  418. "NO_OPERATION_OPERATOR"),
  419. "S6_L3_004" => (
  420. """
  421. SELECT ROUND(
  422. 30 * SUM(IFNULL(QtyWIP,0) * IFNULL(NULLIF(PerAmt,0),1))
  423. / NULLIF(SUM(IFNULL(QtyComplete,0) * IFNULL(NULLIF(PerAmt,0),1)),0),
  424. 4) AS MetricValue,
  425. SUM(CASE WHEN IFNULL(QtyComplete,0)>0 THEN 1 ELSE 0 END) AS RowCount
  426. FROM WorkOrdRouting
  427. WHERE tenant_id=@TenantId
  428. """,
  429. "NO_OPERATION_COMPLETION"),
  430. "S6_L3_005" => (
  431. """
  432. SELECT ROUND(AVG(cycle_days),4) AS MetricValue, COUNT(*) AS RowCount
  433. FROM (
  434. SELECT Machine,
  435. TIMESTAMPDIFF(MINUTE,MIN(StartDate),MAX(EndDate))/1440 AS cycle_days
  436. FROM WorkOrdRouting
  437. WHERE tenant_id=@TenantId AND NULLIF(TRIM(Machine),'') IS NOT NULL
  438. AND StartDate IS NOT NULL AND EndDate>=StartDate
  439. GROUP BY Machine
  440. ) x
  441. """,
  442. "NO_COMPLETED_EQUIPMENT_TASK"),
  443. "S6_L3_006" => (
  444. """
  445. SELECT ROUND(
  446. 100 * SUM(CASE WHEN EndDate IS NOT NULL AND DueDate IS NOT NULL AND EndDate<=DueDate
  447. THEN LEAST(IFNULL(QtyComplete,0),IFNULL(QtyOrded,0)) ELSE 0 END)
  448. / NULLIF(SUM(IFNULL(QtyOrded,0)),0),
  449. 4) AS MetricValue,
  450. COUNT(DISTINCT NULLIF(TRIM(Machine),'')) AS RowCount
  451. FROM WorkOrdRouting
  452. WHERE tenant_id=@TenantId AND NULLIF(TRIM(Machine),'') IS NOT NULL
  453. """,
  454. "NO_PLANNED_EQUIPMENT_TASK"),
  455. "S6_L3_007" => (
  456. """
  457. SELECT ROUND(
  458. 100 * SUM(IFNULL(StdRunTime,0)
  459. * GREATEST(IFNULL(QtyComplete,0)-IFNULL(CumRejected,0)-IFNULL(QtyScrap,0),0))
  460. / NULLIF(SUM(IFNULL(ActRunTime,0)),0),
  461. 4) AS MetricValue,
  462. SUM(CASE WHEN IFNULL(ActRunTime,0)>0 AND IFNULL(StdRunTime,0)>0
  463. THEN 1 ELSE 0 END) AS RowCount
  464. FROM WorkOrdRouting
  465. WHERE tenant_id=@TenantId AND NULLIF(TRIM(Machine),'') IS NOT NULL
  466. """,
  467. "NO_OEE_RUNTIME"),
  468. "S6_L3_008" => (
  469. """
  470. SELECT ROUND(
  471. SUM(IFNULL(QtyWIP,0)) / NULLIF(SUM(IFNULL(QtyComplete,0)),0),
  472. 4) AS MetricValue,
  473. COUNT(DISTINCT NULLIF(TRIM(Machine),'')) AS RowCount
  474. FROM WorkOrdRouting
  475. WHERE tenant_id=@TenantId AND NULLIF(TRIM(Machine),'') IS NOT NULL
  476. """,
  477. "NO_EQUIPMENT_COMPLETION"),
  478. _ => throw new ArgumentOutOfRangeException(nameof(metricCode), metricCode, "不支持的 S6 明细指标")
  479. };
  480. return DispatchExecutionKpiAsync(
  481. metricCode, sql, emptyDenom, L3ValueTable,
  482. batchId, now, option, triggerType, ct);
  483. }
  484. private async Task<KpiBuildSubResult> DispatchExecutionKpiAsync(
  485. string metricCode, string sql, string emptyDenom, string valueTable,
  486. string batchId, DateTime now, S6MdpRefreshOption option, string triggerType, CancellationToken ct)
  487. {
  488. var row = await _db.Ado.SqlQuerySingleAsync<S6ExecutionKpiRow>(
  489. sql, new SugarParameter("@TenantId", option.TargetTenantId));
  490. var value = row?.RowCount > 0 ? row.MetricValue : null;
  491. var dispatch = await _kpiCalcDispatcher.DispatchAsync(
  492. metricCode, ModuleCode, option.TargetTenantId, option.TargetFactoryId,
  493. option.BizDate, option.BizDate, option.BizDate, option.SourceZtid,
  494. batchId, triggerType, value, value.HasValue ? "OK" : emptyDenom, ct);
  495. var sub = new KpiBuildSubResult
  496. {
  497. T8Rows = row?.RowCount ?? 0,
  498. KpiRows = dispatch.ShouldUpsert
  499. ? await UpsertKpiValueAsync(metricCode, option.BizDate, dispatch.MetricValue, now, option, valueTable)
  500. : 0,
  501. DenominatorStatus = dispatch.DenominatorStatus
  502. };
  503. await RunDimensionSafelyAsync(
  504. metricCode, option.BizDate, batchId, triggerType, option, dispatch.ShouldUpsert, ct);
  505. return sub;
  506. }
  507. /// <summary>订单制造周期 = 每个完成订单最晚完工时间 - 最早投产时间,再取订单平均。</summary>
  508. private async Task<KpiBuildSubResult> BuildS6L1001OrderMfgCycleAsync(
  509. string batchId, DateTime now, S6MdpRefreshOption option, string triggerType, CancellationToken ct)
  510. {
  511. const string sql = @"
  512. select noid, datediff(max(completion_time), min(start_time)) as cycle_days
  513. from (
  514. select a.noid, b.rwnoid, b.code, b.addtime as start_time,
  515. case when b.gdyn=1 and b.gdtime is not null then b.gdtime else max(d.completion_time) end as completion_time,
  516. case when b.gdyn=1 or sum(ifnull(d.slzx,0))>=b.sl then 1 else 0 end as completed
  517. from mdp_std_t8_kc_dd_head a
  518. inner join mdp_std_t8_kc_dd_list b on a.tenant_id=b.tenant_id and a.src_id=b.idid
  519. left join (
  520. select l.lynoid, l.code, l.slzx, ifnull(l.gdtime,h.shtime) as completion_time
  521. from mdp_std_t8_kc_tz_head h
  522. inner join mdp_std_t8_kc_tz_list l on h.tenant_id=l.tenant_id and h.src_id=l.idid
  523. where h.tenant_id=@tenantId and l.tenant_id=@tenantId and h.ztid=@ztid
  524. and h.lbs='生产入库' and h.hzyn=0 and h.zfyn=0 and h.shyn=1
  525. ) d on b.rwnoid=d.lynoid and b.code=d.code
  526. where a.tenant_id=@tenantId and b.tenant_id=@tenantId and a.ztid=@ztid
  527. and a.lbs='生产任务' and a.zf=0 and a.shyn=1 and a.noid is not null
  528. group by a.noid, b.rwnoid, b.code, b.sl, b.addtime, b.gdyn, b.gdtime
  529. ) n
  530. group by noid
  531. having min(completed)=1 and min(start_time) is not null and max(completion_time) is not null";
  532. var rows = await _db.Ado.SqlQueryAsync<S6OrderCycleRow>(sql, new[]
  533. {
  534. new SugarParameter("@tenantId", option.TargetTenantId),
  535. new SugarParameter("@ztid", option.SourceZtid)
  536. });
  537. var cycles = rows.Where(r => r.cycle_days.HasValue && r.cycle_days.Value >= 0)
  538. .Select(r => (decimal)r.cycle_days!.Value).ToList();
  539. decimal? value = cycles.Count > 0 ? Math.Round(cycles.Average(), 4) : null;
  540. var denom = cycles.Count > 0 ? "OK" : "NO_COMPLETED_ORDER";
  541. var dispatch = await _kpiCalcDispatcher.DispatchAsync(
  542. "S6_L1_001", ModuleCode, option.TargetTenantId, option.TargetFactoryId,
  543. option.BizDate, option.BizDate, option.BizDate, option.SourceZtid,
  544. batchId, triggerType, value, denom, ct);
  545. var sub = new KpiBuildSubResult
  546. {
  547. T8Rows = rows.Count,
  548. KpiRows = dispatch.ShouldUpsert
  549. ? await UpsertKpiValueAsync("S6_L1_001", option.BizDate, dispatch.MetricValue, now, option)
  550. : 0,
  551. DenominatorStatus = dispatch.DenominatorStatus
  552. };
  553. await RunDimensionSafelyAsync("S6_L1_001", option.BizDate, batchId, triggerType, option, dispatch.ShouldUpsert, ct);
  554. return sub;
  555. }
  556. /// <summary>订单制造满足率 = AVG(clamp(SUM(交期内完成量)/SUM(计划量), 0..1)) × 100。</summary>
  557. private async Task<KpiBuildSubResult> BuildS6L1002OrderMfgFulfillmentAsync(
  558. string batchId, DateTime now, S6MdpRefreshOption option, string triggerType, CancellationToken ct)
  559. {
  560. const string sql = @"
  561. select order_no, sum(plan_qty) as plan_qty, sum(done_qty_in_window) as done_qty_in_window
  562. from dwd_t8_work_order_mfg_fulfillment
  563. where tenant_id=@tenantId and factory_id=@factoryId and biz_date=@bizDate and source_ztid=@ztid
  564. group by order_no";
  565. var rows = await _db.Ado.SqlQueryAsync<S6OrderFulfillmentRow>(sql,
  566. new SugarParameter("@tenantId", option.TargetTenantId),
  567. new SugarParameter("@factoryId", option.TargetFactoryId),
  568. new SugarParameter("@bizDate", option.BizDate),
  569. new SugarParameter("@ztid", option.SourceZtid));
  570. decimal? value = CalculateOrderFulfillmentPercent(
  571. rows.Select(r => (r.plan_qty, r.done_qty_in_window)));
  572. var denom = value.HasValue ? "OK" : "NO_VALID_ORDER";
  573. var dispatch = await _kpiCalcDispatcher.DispatchAsync(
  574. "S6_L1_002", ModuleCode, option.TargetTenantId, option.TargetFactoryId,
  575. option.BizDate, option.BizDate, option.BizDate, option.SourceZtid,
  576. batchId, triggerType, value, denom, ct);
  577. var sub = new KpiBuildSubResult
  578. {
  579. T8Rows = rows.Count,
  580. KpiRows = dispatch.ShouldUpsert
  581. ? await UpsertKpiValueAsync("S6_L1_002", option.BizDate, dispatch.MetricValue, now, option)
  582. : 0,
  583. DenominatorStatus = dispatch.DenominatorStatus
  584. };
  585. await RunDimensionSafelyAsync("S6_L1_002", option.BizDate, batchId, triggerType, option, dispatch.ShouldUpsert, ct);
  586. return sub;
  587. }
  588. internal static decimal? CalculateOrderFulfillmentPercent(
  589. IEnumerable<(decimal? PlanQty, decimal? DoneQty)> orders)
  590. {
  591. var rates = orders
  592. .Where(r => r.PlanQty.HasValue && r.PlanQty.Value > 0m && r.DoneQty.HasValue)
  593. .Select(r => Math.Clamp(r.DoneQty!.Value / r.PlanQty!.Value, 0m, 1m))
  594. .ToList();
  595. return rates.Count > 0 ? Math.Round(rates.Average() * 100m, 4) : null;
  596. }
  597. /// <summary>订单制造人效 = 统计期内完成制造的订单数 / 在职生产人数。</summary>
  598. private async Task<KpiBuildSubResult> BuildS6L1003OrderMfgEfficiencyAsync(
  599. string batchId, DateTime now, S6MdpRefreshOption option, string triggerType, CancellationToken ct)
  600. {
  601. const string sqlNumer = @"
  602. select count(*) as ddnum
  603. from (
  604. select noid
  605. from (
  606. select a.noid, b.rwnoid, b.code,
  607. case when b.gdyn=1 and b.gdtime is not null then b.gdtime else max(d.completion_time) end as completion_time,
  608. case when b.gdyn=1 or sum(ifnull(d.slzx,0))>=b.sl then 1 else 0 end as completed
  609. from mdp_std_t8_kc_dd_head a
  610. inner join mdp_std_t8_kc_dd_list b on a.tenant_id=b.tenant_id and a.src_id=b.idid
  611. left join (
  612. select l.lynoid, l.code, l.slzx, ifnull(l.gdtime,h.shtime) as completion_time
  613. from mdp_std_t8_kc_tz_head h
  614. inner join mdp_std_t8_kc_tz_list l on h.tenant_id=l.tenant_id and h.src_id=l.idid
  615. where h.tenant_id=@tenantId and l.tenant_id=@tenantId and h.ztid=@ztid
  616. and h.lbs='生产入库' and h.hzyn=0 and h.zfyn=0 and h.shyn=1
  617. ) d on b.rwnoid=d.lynoid and b.code=d.code
  618. where a.tenant_id=@tenantId and b.tenant_id=@tenantId and a.ztid=@ztid
  619. and a.lbs='生产任务' and a.zf=0 and a.shyn=1 and a.noid is not null
  620. group by a.noid, b.rwnoid, b.code, b.sl, b.gdyn, b.gdtime
  621. ) order_lines
  622. group by noid
  623. having min(completed)=1 and max(completion_time) between @startDate and @endDate
  624. ) orders_done";
  625. const string sqlDenom = @"
  626. select count(*) as penum
  627. from mdp_std_t8_sys_pelist
  628. where tenant_id=@tenantId and ztid=@ztid and zzzt='在职' and gw='生产'";
  629. var numerRows = await _db.Ado.SqlQueryAsync<S6CountRow>(sqlNumer,
  630. new SugarParameter("@tenantId", option.TargetTenantId),
  631. new SugarParameter("@ztid", option.SourceZtid),
  632. new SugarParameter("@startDate", option.MonthlyPeriodStart),
  633. new SugarParameter("@endDate", option.MonthlyPeriodEnd));
  634. var denomRows = await _db.Ado.SqlQueryAsync<S6PeNumRow>(sqlDenom,
  635. new SugarParameter("@tenantId", option.TargetTenantId),
  636. new SugarParameter("@ztid", option.SourceZtid));
  637. int? doneCount = numerRows.FirstOrDefault()?.ddnum;
  638. int? headcount = denomRows.FirstOrDefault()?.penum;
  639. decimal? value = null;
  640. string denom;
  641. if (!headcount.HasValue || headcount.Value <= 0)
  642. denom = "NO_HEADCOUNT";
  643. else if (!doneCount.HasValue)
  644. denom = "NO_NUMERATOR";
  645. else
  646. {
  647. value = Math.Round((decimal)doneCount.Value / headcount.Value, 4);
  648. denom = "OK";
  649. }
  650. var dispatch = await _kpiCalcDispatcher.DispatchAsync(
  651. "S6_L1_003", ModuleCode, option.TargetTenantId, option.TargetFactoryId,
  652. option.MonthlyPeriodEnd, option.MonthlyPeriodStart, option.MonthlyPeriodEnd, option.SourceZtid,
  653. batchId, triggerType, value, denom, ct);
  654. var sub = new KpiBuildSubResult
  655. {
  656. T8Rows = numerRows.Count + denomRows.Count,
  657. KpiRows = dispatch.ShouldUpsert
  658. ? await UpsertKpiValueAsync("S6_L1_003", option.MonthlyPeriodEnd, dispatch.MetricValue, now, option)
  659. : 0,
  660. DenominatorStatus = dispatch.DenominatorStatus
  661. };
  662. await RunDimensionSafelyAsync("S6_L1_003", option.MonthlyPeriodEnd, batchId, triggerType, option, dispatch.ShouldUpsert, ct);
  663. return sub;
  664. }
  665. private async Task RunDimensionSafelyAsync(
  666. string metricCode, DateTime bizDate, string batchId, string triggerType,
  667. S6MdpRefreshOption option, bool shouldRun, CancellationToken ct)
  668. {
  669. if (!shouldRun) return;
  670. try
  671. {
  672. await _dimensionRun.RunDimensionAsync(
  673. metricCode, ModuleCode, option.TargetTenantId, bizDate, batchId, triggerType, ct);
  674. }
  675. catch (Exception ex)
  676. {
  677. _logger.LogWarning(ex, "{MetricCode} 维度跑批异常(不影响汇总链路)", metricCode);
  678. }
  679. }
  680. // ─────────────────────────────────────────────────────────────────────────
  681. private async Task<int> UpsertKpiValueAsync(string metricCode, DateTime bizDate, decimal? metricValue, DateTime now, S6MdpRefreshOption option, string valueTable = "ado_s9_kpi_value_l1_day")
  682. {
  683. // 沿用 S3 UpsertS3KpiValueAsync 范式:先查现存行 → UPDATE;不存在 → SELECT MAX(id)+1 显式生成 id 后 INSERT。
  684. // 值表 id 为手工分配主键(无 AUTO_INCREMENT),必须显式 set;metric_value 允许 NULL(分母缺失不得伪装真实 0)。
  685. // valueTable:L1 KPI 写 ado_s9_kpi_value_l1_day;L2(本服务工单指标 S6_L2_002/003)写 ado_s9_kpi_value_l2_day。
  686. // 表名为受控常量(非用户输入),可安全内插;各表 id 序列独立,MAX(id) 取目标表。
  687. // FIX-2:截断时分秒(月度 KPI 入参可能为 YYYY-MM-DD 23:59:59),保证 SELECT WHERE biz_date=@BizDate 与 DB date 列匹配。
  688. bizDate = bizDate.Date;
  689. var snap = await _kpiTargetResolver.ResolveAsync(option.TargetTenantId, option.TargetFactoryId, metricCode, ModuleCode, bizDate);
  690. var existingId = await _db.Ado.GetLongAsync(
  691. $"SELECT IFNULL((SELECT id FROM {valueTable} WHERE tenant_id=@TenantId AND factory_id=@FactoryId " +
  692. "AND module_code=@ModuleCode AND metric_code=@MetricCode AND biz_date=@BizDate AND is_deleted=0 " +
  693. "ORDER BY id LIMIT 1), 0)",
  694. new List<SugarParameter>
  695. {
  696. new("@TenantId", option.TargetTenantId),
  697. new("@FactoryId", option.TargetFactoryId),
  698. new("@ModuleCode", ModuleCode),
  699. new("@MetricCode", metricCode),
  700. new("@BizDate", bizDate)
  701. });
  702. if (existingId > 0)
  703. {
  704. return await _db.Ado.ExecuteCommandAsync(
  705. $"UPDATE {valueTable} SET metric_value=@MetricValue, target_value=@TargetValue, " +
  706. "target_config_id=@TargetConfigId, target_source=@TargetSource, target_resolved_at=@TargetResolvedAt, " +
  707. "calc_time=@Now, update_time=@Now, is_deleted=0, is_active=1 WHERE id=@Id",
  708. new SugarParameter("@MetricValue", metricValue),
  709. new SugarParameter("@TargetValue", SmartOps.KpiTargetSnapshotSql.ValueOrDbNull(snap)),
  710. new SugarParameter("@TargetConfigId", SmartOps.KpiTargetSnapshotSql.ConfigIdOrDbNull(snap)),
  711. new SugarParameter("@TargetSource", SmartOps.KpiTargetSnapshotSql.SourceOrDbNull(snap)),
  712. new SugarParameter("@TargetResolvedAt", snap.ResolvedAt),
  713. new SugarParameter("@Now", now),
  714. new SugarParameter("@Id", existingId));
  715. }
  716. var nextId = Yitter.IdGenerator.YitIdHelper.NextId();
  717. return await _db.Ado.ExecuteCommandAsync($@"
  718. INSERT INTO {valueTable}
  719. (id, tenant_id, org_id, company_id, factory_id, status, biz_date,
  720. create_time, update_time, is_deleted, is_active,
  721. module_code, metric_code, metric_value, target_value, calc_time,
  722. target_config_id, target_source, target_resolved_at)
  723. VALUES
  724. (@Id, @TenantId, NULL, NULL, @FactoryId, NULL, @BizDate,
  725. @Now, @Now, 0, 1,
  726. @ModuleCode, @MetricCode, @MetricValue, @TargetValue, @Now,
  727. @TargetConfigId, @TargetSource, @TargetResolvedAt)",
  728. new SugarParameter("@Id", nextId),
  729. new SugarParameter("@TenantId", option.TargetTenantId),
  730. new SugarParameter("@FactoryId", option.TargetFactoryId),
  731. new SugarParameter("@BizDate", bizDate),
  732. new SugarParameter("@Now", now),
  733. new SugarParameter("@ModuleCode", ModuleCode),
  734. new SugarParameter("@MetricCode", metricCode),
  735. new SugarParameter("@MetricValue", metricValue),
  736. new SugarParameter("@TargetValue", SmartOps.KpiTargetSnapshotSql.ValueOrDbNull(snap)),
  737. new SugarParameter("@TargetConfigId", SmartOps.KpiTargetSnapshotSql.ConfigIdOrDbNull(snap)),
  738. new SugarParameter("@TargetSource", SmartOps.KpiTargetSnapshotSql.SourceOrDbNull(snap)),
  739. new SugarParameter("@TargetResolvedAt", snap.ResolvedAt));
  740. }
  741. private async Task<long> InsertTransformRunLogAsync(string batchId, DateTime startedAt, string triggerType, S6MdpRefreshOption option)
  742. {
  743. await _db.Ado.ExecuteCommandAsync(@"
  744. INSERT INTO mdp_transform_run_log
  745. (tenant_id, job_code, job_name, trigger_type, batch_id, status, start_time, stage_rows, standard_rows, dwd_rows, create_time, update_time)
  746. VALUES
  747. (@TenantId, @JobCode, @JobName, @TriggerType, @BatchId, 'RUNNING', @StartTime, 0, 0, 0, @StartTime, @StartTime)",
  748. new SugarParameter("@TenantId", option.TargetTenantId),
  749. new SugarParameter("@JobCode", JobCode),
  750. new SugarParameter("@JobName", JobName),
  751. new SugarParameter("@TriggerType", triggerType),
  752. new SugarParameter("@BatchId", batchId),
  753. new SugarParameter("@StartTime", startedAt));
  754. return await _db.Ado.GetLongAsync(
  755. "SELECT id FROM mdp_transform_run_log WHERE batch_id=@BatchId ORDER BY id DESC LIMIT 1",
  756. new List<SugarParameter> { new("@BatchId", batchId) });
  757. }
  758. private async Task MarkTransformRunSuccessAsync(long runLogId, DateTime startedAt, S6MdpSyncTransformResult result)
  759. {
  760. var finishedAt = DateTime.Now;
  761. await _db.Ado.ExecuteCommandAsync(@"
  762. UPDATE mdp_transform_run_log
  763. SET status='SUCCESS', end_time=@EndTime, duration_ms=@DurationMs,
  764. stage_rows=@StageRows, standard_rows=@StandardRows, dwd_rows=@DwdRows,
  765. summary_json=@SummaryJson, update_time=CURRENT_TIMESTAMP
  766. WHERE id=@Id",
  767. new SugarParameter("@EndTime", finishedAt),
  768. new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
  769. new SugarParameter("@StageRows", result.StageRows),
  770. new SugarParameter("@StandardRows", result.StandardRows),
  771. new SugarParameter("@DwdRows", result.DwdRows),
  772. new SugarParameter("@SummaryJson", JsonSerializer.Serialize(new
  773. {
  774. batchId = result.BatchId,
  775. sourceZtid = result.SourceZtid,
  776. bizDate = result.BizDate.ToString("yyyy-MM-dd"),
  777. bizMonth = result.BizMonth,
  778. dwdRows = result.DwdRows,
  779. kpiRows = result.KpiRows,
  780. perKpiDwdRows = result.PerKpiDwdRows,
  781. perKpiKpiRows = result.PerKpiKpiRows,
  782. denominatorStatus = result.KpiDenominatorStatus
  783. })),
  784. new SugarParameter("@Id", runLogId));
  785. }
  786. private async Task MarkTransformRunFailedAsync(long runLogId, DateTime startedAt, string message, string batchId)
  787. {
  788. bool runLogUpdated = false;
  789. try
  790. {
  791. var finishedAt = DateTime.Now;
  792. await _db.Ado.ExecuteCommandAsync(@"
  793. UPDATE mdp_transform_run_log
  794. SET status='FAILED', end_time=@EndTime, duration_ms=@DurationMs,
  795. error_message=@ErrorMessage, update_time=CURRENT_TIMESTAMP
  796. WHERE id=@Id",
  797. new SugarParameter("@EndTime", finishedAt),
  798. new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
  799. new SugarParameter("@ErrorMessage", Truncate(message, 2000)),
  800. new SugarParameter("@Id", runLogId));
  801. runLogUpdated = true;
  802. }
  803. catch (Exception ex)
  804. {
  805. Console.Error.WriteLine($"[S6MdpSyncTransform] MarkTransformRunFailed write failed (runLogId={runLogId}): {ex.Message}");
  806. }
  807. // FAILURE-NOTIFICATION-1:写库 FAILED 成功后发通知给超级管理员;通知失败不影响主流程
  808. if (!runLogUpdated) return;
  809. try
  810. {
  811. await _sysNoticeService.AddNotice(new AddNoticeInput
  812. {
  813. Title = "S6 生产执行 T8 KPI 跑批失败",
  814. Content = $"模块:S6 生产执行\n批次ID:{batchId}\n失败时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}\n错误信息:{Truncate(message, 1000)}\n\n请查看 mdp_transform_run_log 获取完整错误与重试记录。",
  815. Type = NoticeTypeEnum.NOTICE,
  816. PublicTime = DateTime.Now,
  817. Status = NoticeStatusEnum.PUBLIC,
  818. PublicUserId = NoticeReceiverUserId,
  819. PublicUserName = NoticeReceiverUserName
  820. });
  821. }
  822. catch (Exception notifyEx)
  823. {
  824. _logger.LogError(notifyEx, "[S6MdpSyncTransform] SysNotice 发送失败 (runLogId={RunLogId}, batchId={BatchId})", runLogId, batchId);
  825. }
  826. }
  827. private static string NormalizeTriggerType(string s) =>
  828. string.IsNullOrWhiteSpace(s) ? "AUTO" : s.Trim().ToUpperInvariant();
  829. private static void NormalizeOption(S6MdpRefreshOption option)
  830. {
  831. var d = S6MdpRefreshOption.Default();
  832. if (option.TargetFactoryId <= 0) option.TargetFactoryId = d.TargetFactoryId;
  833. if (string.IsNullOrWhiteSpace(option.SourceZtid)) option.SourceZtid = d.SourceZtid;
  834. option.TargetTenantId = AidopSourceTenantMap.ResolveTenantId(option.SourceZtid, option.TargetTenantId);
  835. if (option.BizDate == default) option.BizDate = d.BizDate;
  836. if (string.IsNullOrWhiteSpace(option.BizMonth)) option.BizMonth = d.BizMonth;
  837. if (option.MonthlyPeriodStart == default) option.MonthlyPeriodStart = d.MonthlyPeriodStart;
  838. if (option.MonthlyPeriodEnd == default) option.MonthlyPeriodEnd = d.MonthlyPeriodEnd;
  839. }
  840. private static string Truncate(string s, int max) =>
  841. string.IsNullOrEmpty(s) ? "" : (s.Length <= max ? s : s.Substring(0, max));
  842. }
  843. // DTO ────────────────────────────────────────────────────────────────────────
  844. public sealed class S6MdpRefreshOption
  845. {
  846. public string SourceZtid { get; set; } = "pbxfxp";
  847. /// <summary>KPI/DWD 落库目标租户;≤0 时由 <see cref="AidopSourceTenantMap"/> 按 SourceZtid 解析。</summary>
  848. public long TargetTenantId { get; set; }
  849. /// <summary>KPI/DWD 落库目标工厂;默认 1。</summary>
  850. public long TargetFactoryId { get; set; } = 1L;
  851. public DateTime BizDate { get; set; }
  852. public string BizMonth { get; set; } = "";
  853. public DateTime MonthlyPeriodStart { get; set; }
  854. public DateTime MonthlyPeriodEnd { get; set; }
  855. public static S6MdpRefreshOption Default()
  856. {
  857. var today = DateTime.Today;
  858. var yesterday = today.AddDays(-1);
  859. var lastMonth = today.AddMonths(-1);
  860. var monthStart = new DateTime(lastMonth.Year, lastMonth.Month, 1);
  861. var monthEnd = monthStart.AddMonths(1).AddDays(-1);
  862. return new S6MdpRefreshOption
  863. {
  864. SourceZtid = "pbxfxp",
  865. TargetTenantId = 0,
  866. TargetFactoryId = 1L,
  867. BizDate = yesterday,
  868. BizMonth = lastMonth.ToString("yyyy-MM"),
  869. MonthlyPeriodStart = monthStart,
  870. MonthlyPeriodEnd = monthEnd.AddDays(1).AddSeconds(-1)
  871. };
  872. }
  873. }
  874. public sealed class S6MdpSyncTransformResult
  875. {
  876. public string BatchId { get; set; } = "";
  877. public long RunLogId { get; set; }
  878. public string TriggerType { get; set; } = "AUTO";
  879. public string SourceZtid { get; set; } = "";
  880. public long TargetTenantId { get; set; }
  881. public long TargetFactoryId { get; set; }
  882. public DateTime BizDate { get; set; }
  883. public string BizMonth { get; set; } = "";
  884. public DateTime MonthlyPeriodStart { get; set; }
  885. public DateTime MonthlyPeriodEnd { get; set; }
  886. public int StageRows { get; set; }
  887. public int StandardRows { get; set; }
  888. public int DwdRows { get; set; }
  889. public int KpiRows { get; set; }
  890. public Dictionary<string, int> PerKpiDwdRows { get; } = new();
  891. public Dictionary<string, int> PerKpiKpiRows { get; } = new();
  892. public List<string> KpiDenominatorStatus { get; } = new();
  893. public void MergeSub(string kpiCode, KpiBuildSubResult sub)
  894. {
  895. PerKpiDwdRows[kpiCode] = sub.DwdRows;
  896. PerKpiKpiRows[kpiCode] = sub.KpiRows;
  897. DwdRows += sub.DwdRows;
  898. KpiRows += sub.KpiRows;
  899. KpiDenominatorStatus.Add($"{kpiCode}:{sub.DenominatorStatus}");
  900. }
  901. }
  902. public sealed class KpiBuildSubResult
  903. {
  904. public int T8Rows { get; set; }
  905. public int DwdRows { get; set; }
  906. public int KpiRows { get; set; }
  907. public string DenominatorStatus { get; set; } = "OK";
  908. }
  909. // T8 result set 投影类型 ──────────────────────────────────────────────────────
  910. internal sealed class S6MfgFulfillmentRow
  911. {
  912. public string? noid { get; set; }
  913. public string? rwnoid { get; set; }
  914. public string? code { get; set; }
  915. public decimal? sl { get; set; }
  916. public decimal? slzx { get; set; }
  917. }
  918. internal sealed class S6CountRow
  919. {
  920. public int ddnum { get; set; }
  921. }
  922. internal sealed class S6PeNumRow
  923. {
  924. public int penum { get; set; }
  925. }
  926. internal sealed class S6OrderCycleRow
  927. {
  928. public string? noid { get; set; }
  929. public int? cycle_days { get; set; }
  930. }
  931. internal sealed class S6WorkOrderCycleRow
  932. {
  933. public string? rwnoid { get; set; }
  934. public int? cycle_days { get; set; }
  935. }
  936. internal sealed class S6OrderFulfillmentRow
  937. {
  938. public string? order_no { get; set; }
  939. public decimal? plan_qty { get; set; }
  940. public decimal? done_qty_in_window { get; set; }
  941. }
  942. internal sealed class S6ExecutionKpiRow
  943. {
  944. public decimal? MetricValue { get; set; }
  945. public int RowCount { get; set; }
  946. }