S6MdpSyncTransformService.cs 49 KB

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