S2MdpSyncTransformService.cs 56 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993
  1. using Admin.NET.Plugin.AiDOP.DataPlatform;
  2. using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
  3. using Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild;
  4. using Admin.NET.Plugin.AiDOP.SmartOps;
  5. namespace Admin.NET.Plugin.AiDOP.Production;
  6. /// <summary>
  7. /// S2 生产排程 MDP 同步、标准化、DWD 与 KPI 计算服务。
  8. /// Phase2:源→stg 经统一执行器;std/DWD/KPI 仍读 stg。
  9. /// </summary>
  10. public class S2MdpSyncTransformService : ITransient
  11. {
  12. private const string JobCode = "S2_MDP_SYNC_TRANSFORM";
  13. private readonly ISqlSugarClient _db;
  14. private readonly TransformRunLogFinalizer _runLogFinalizer;
  15. private readonly SmartOpsKpiAtomicBuildService _atomicBuild;
  16. private readonly MdpModuleStagingPuller _stagingPuller;
  17. private readonly IKpiTargetResolver _kpiTargetResolver;
  18. private readonly S9CompositeKpiWriter _s9Composite;
  19. private readonly MdpNeutralSourceGate _neutralGate;
  20. private readonly MdpTenantStdSourceResolver _stdSource;
  21. private readonly NativeNeutralProjectionService _nativeProjection;
  22. private MdpRebuildScope _runScope = null!;
  23. public S2MdpSyncTransformService(
  24. ISqlSugarClient db,
  25. SmartOpsKpiAtomicBuildService atomicBuild,
  26. MdpModuleStagingPuller stagingPuller,
  27. IKpiTargetResolver kpiTargetResolver,
  28. TransformRunLogFinalizer runLogFinalizer,
  29. S9CompositeKpiWriter s9Composite,
  30. MdpNeutralSourceGate neutralGate,
  31. MdpTenantStdSourceResolver stdSource,
  32. NativeNeutralProjectionService nativeProjection)
  33. {
  34. _db = db;
  35. _runLogFinalizer = runLogFinalizer;
  36. _atomicBuild = atomicBuild;
  37. _stagingPuller = stagingPuller;
  38. _kpiTargetResolver = kpiTargetResolver;
  39. _s9Composite = s9Composite;
  40. _neutralGate = neutralGate;
  41. _stdSource = stdSource;
  42. _nativeProjection = nativeProjection;
  43. }
  44. public Task<S2MdpSyncTransformResult> RunFullAsync(CancellationToken cancellationToken = default, string triggerType = "AUTO") =>
  45. throw new InvalidOperationException("S2 全量重算必须指定租户/工厂作用域,请走 ModuleRebuildService.Enqueue");
  46. public async Task<S2MdpSyncTransformResult> RunFullAsync(
  47. MdpRebuildScope scope,
  48. CancellationToken cancellationToken = default,
  49. string triggerType = "AUTO",
  50. long? rebuildJobId = null,
  51. Func<ModuleProgressUpdate, Task>? reportProgress = null)
  52. {
  53. _ = rebuildJobId;
  54. _runScope = MdpRebuildScope.Create("S2", scope.TenantId, scope.FactoryId);
  55. cancellationToken.ThrowIfCancellationRequested();
  56. var now = DateTime.Now;
  57. var batchId = $"S2_MDP_FULL_{_runScope.TenantId}_{_runScope.FactoryId}_{now:yyyyMMddHHmmss}";
  58. var runLogId = await InsertTransformRunLogAsync(batchId, now, triggerType);
  59. var result = new S2MdpSyncTransformResult { BatchId = batchId, RunLogId = runLogId };
  60. try
  61. {
  62. await ReportAsync(reportProgress, new ModuleProgressUpdate(ModuleRebuildStages.Preparing, 0, 5, "准备运行环境"));
  63. await EnsureS2RuntimeObjectsAsync();
  64. await ReportAsync(reportProgress, new ModuleProgressUpdate(ModuleRebuildStages.Staging, 1, 10, "正在拉取源数据"));
  65. result.StageRows = await SyncStagingAsync(batchId, now, cancellationToken, triggerType);
  66. await ReportAsync(reportProgress, new ModuleProgressUpdate(ModuleRebuildStages.Staging, 1, 35, "拉取源数据完成", result.StageRows, ModuleRebuildStages.Staging));
  67. await ReportAsync(reportProgress, new ModuleProgressUpdate(ModuleRebuildStages.Standard, 2, 35, "正在标准化数据"));
  68. result.StandardRows = await TransformStandardAsync(batchId, now, cancellationToken);
  69. await ReportAsync(reportProgress, new ModuleProgressUpdate(ModuleRebuildStages.Standard, 2, 50, "标准化数据完成", result.StandardRows, ModuleRebuildStages.Standard));
  70. await ReportAsync(reportProgress, new ModuleProgressUpdate(ModuleRebuildStages.Dwd, 3, 50, "正在生成 DWD 明细"));
  71. result.DwdRows = await BuildDwdAsync(batchId, now, cancellationToken);
  72. await ReportAsync(reportProgress, new ModuleProgressUpdate(ModuleRebuildStages.Dwd, 3, 65, "生成 DWD 明细完成", result.DwdRows, ModuleRebuildStages.Dwd));
  73. await ReportAsync(reportProgress, new ModuleProgressUpdate(ModuleRebuildStages.Kpi, 4, 65, "正在重算 KPI"));
  74. result.KpiRows = await BuildS2KpiValuesAsync(batchId, now, cancellationToken);
  75. await ReportAsync(reportProgress, new ModuleProgressUpdate(ModuleRebuildStages.Kpi, 4, 80, "重算 KPI 完成", result.KpiRows, ModuleRebuildStages.Kpi));
  76. await ReportAsync(reportProgress, new ModuleProgressUpdate(ModuleRebuildStages.Atomic, 5, 82, "正在重建原子数据"));
  77. result.AtomicRows = await _atomicBuild.BuildWorkScheduleDomainForAllDatesAsync(
  78. _runScope.TenantId, _runScope.FactoryId, batchId, cancellationToken);
  79. await ReportAsync(reportProgress, new ModuleProgressUpdate(ModuleRebuildStages.Atomic, 5, 98, "重建原子数据完成", result.AtomicRows, ModuleRebuildStages.Atomic));
  80. await ReportAsync(reportProgress, new ModuleProgressUpdate(ModuleRebuildStages.Finalizing, 5, 99, "正在收尾"));
  81. await _nativeProjection.ProjectAsync(_runScope.TenantId, batchId, cancellationToken);
  82. result.KpiRows += await _s9Composite.WriteRecentAsync(_runScope.TenantId, _runScope.FactoryId, now, cancellationToken);
  83. await MarkTransformRunSuccessAsync(runLogId, now, result);
  84. return result;
  85. }
  86. catch (Exception ex)
  87. {
  88. if (ex is ModuleRebuildCancelledException)
  89. throw;
  90. // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
  91. if (!_runLogFinalizer.IsHostStopping)
  92. await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
  93. throw;
  94. }
  95. finally
  96. {
  97. await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
  98. }
  99. }
  100. private static async Task ReportAsync(Func<ModuleProgressUpdate, Task>? report, ModuleProgressUpdate update)
  101. {
  102. if (report == null) return;
  103. await report(update);
  104. }
  105. private async Task EnsureS2RuntimeObjectsAsync()
  106. {
  107. foreach (var table in new[]
  108. {
  109. "mdp_stg_schedule",
  110. "mdp_std_work_order_schedule",
  111. "mdp_std_operation_schedule",
  112. "dwd_order_schedule_trans"
  113. })
  114. await MdpSchemaAligner.ExecuteAsync(_db, MdpSchemaDefinition.Ddl(table));
  115. foreach (var sql in S2MdpDdl.SqlBlocks)
  116. {
  117. await MdpSchemaAligner.ExecuteAsync(_db, sql);
  118. }
  119. }
  120. private async Task<int> SyncStagingAsync(string batchId, DateTime now, CancellationToken cancellationToken, string triggerType = "AUTO")
  121. {
  122. _ = now;
  123. EnsureRunScope();
  124. return await _stagingPuller.PullEntitiesAsync(
  125. S2MdpEntityConfig.All.Select(x => x.EntityCode),
  126. batchId, _runScope.TenantId, fullRefresh: ModuleRebuildTriggerType.ShouldPullFull(triggerType), taskCode: "S2_MDP_INBOUND", cancellationToken,
  127. factoryId: _runScope.FactoryId, requireMatchingSourceTenant: true);
  128. }
  129. public async Task<S2MdpSyncTransformResult> RunInboundAsync(
  130. IEnumerable<string>? entityCodes = null,
  131. long tenantId = 0,
  132. bool fullRefresh = false,
  133. CancellationToken cancellationToken = default)
  134. {
  135. cancellationToken.ThrowIfCancellationRequested();
  136. if (tenantId <= 0)
  137. throw new InvalidOperationException("S2 inbound 必须指定有效 tenantId");
  138. _runScope = MdpRebuildScope.Create("S2", tenantId, 1);
  139. var now = DateTime.Now;
  140. var batchId = $"S2_MDP_IN_{_runScope.TenantId}_{now:yyyyMMddHHmmss}";
  141. var runLogId = await InsertTransformRunLogAsync(batchId, now, "INBOUND");
  142. var result = new S2MdpSyncTransformResult { BatchId = batchId, RunLogId = runLogId };
  143. try
  144. {
  145. await EnsureS2RuntimeObjectsAsync();
  146. var codes = entityCodes?.ToList() ?? S2MdpEntityConfig.All.Select(x => x.EntityCode).ToList();
  147. result.StageRows = await _stagingPuller.PullEntitiesAsync(
  148. codes, batchId, tenantId, fullRefresh, "S2_MDP_INBOUND", cancellationToken);
  149. result.StandardRows = await TransformStandardAsync(batchId, now, cancellationToken);
  150. result.DwdRows = await BuildDwdAsync(batchId, now, cancellationToken);
  151. result.KpiRows = await BuildS2KpiValuesAsync(batchId, now, cancellationToken);
  152. result.AtomicRows = await _atomicBuild.BuildWorkScheduleDomainForAllDatesAsync(
  153. _runScope.TenantId, _runScope.FactoryId, batchId, cancellationToken);
  154. await _nativeProjection.ProjectAsync(_runScope.TenantId, batchId, cancellationToken);
  155. result.KpiRows += await _s9Composite.WriteRecentAsync(_runScope.TenantId, _runScope.FactoryId, now, cancellationToken);
  156. await MarkTransformRunSuccessAsync(runLogId, now, result);
  157. return result;
  158. }
  159. catch (Exception ex)
  160. {
  161. if (ex is ModuleRebuildCancelledException)
  162. throw;
  163. // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
  164. if (!_runLogFinalizer.IsHostStopping)
  165. await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
  166. throw;
  167. }
  168. finally
  169. {
  170. await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
  171. }
  172. }
  173. [Obsolete("Phase3: use MdpModuleStagingPuller / executors")]
  174. private async Task<int> SyncOneEntityAsync(S2MdpEntityConfig entity, string batchId, DateTime now)
  175. {
  176. var entityRow = await _db.Ado.SqlQuerySingleAsync<S2MdpEntityRow>(
  177. "SELECT id AS Id, entity_name AS EntityName FROM mdp_entity WHERE tenant_id=0 AND entity_code=@EntityCode LIMIT 1",
  178. new SugarParameter("@EntityCode", entity.EntityCode));
  179. if (entityRow == null) throw Oops.Oh($"未找到 MDP 实体配置:{entity.EntityCode}");
  180. var columns = await _db.Ado.SqlQueryAsync<S2ColumnRow>(
  181. """
  182. SELECT COLUMN_NAME AS ColumnName
  183. FROM information_schema.COLUMNS
  184. WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=@TableName
  185. ORDER BY ORDINAL_POSITION
  186. """,
  187. new SugarParameter("@TableName", entity.SourceTable));
  188. if (columns.Count == 0) throw Oops.Oh($"未找到源表:{entity.SourceTable}");
  189. var names = columns.Select(u => u.ColumnName).ToList();
  190. var tenantExpr = names.Any(u => string.Equals(u, "tenant_id", StringComparison.OrdinalIgnoreCase))
  191. ? $"IFNULL(s.`{FindColumn(names, "tenant_id")}`,0)"
  192. : "0";
  193. var factoryExpr = names.Any(u => string.Equals(u, "factory_id", StringComparison.OrdinalIgnoreCase))
  194. ? $"s.`{FindColumn(names, "factory_id")}`"
  195. : "NULL";
  196. var domainExpr = names.Any(u => string.Equals(u, "Domain", StringComparison.OrdinalIgnoreCase))
  197. ? $"s.`{FindColumn(names, "Domain")}`"
  198. : "NULL";
  199. var sourceRowExpr = names.Any(u => string.Equals(u, entity.SourceRowIdExpression, StringComparison.OrdinalIgnoreCase))
  200. ? $"s.`{FindColumn(names, entity.SourceRowIdExpression)}`"
  201. : entity.SourceRowIdExpression;
  202. var rawDataExpr = BuildJsonObjectExpression(names);
  203. var rowsRead = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM `{entity.SourceTable}`");
  204. var logId = await InsertSyncLogAsync(entityRow.Id, entityRow.EntityName, batchId, rowsRead);
  205. var started = DateTime.Now;
  206. try
  207. {
  208. var affected = await MdpSchemaAligner.ExecuteAsync(_db,
  209. $"""
  210. INSERT INTO `{entity.TargetTable}`
  211. (tenant_id, factory_id, source_system, source_table, source_row_id, source_biz_key, sync_batch_id, sync_time, process_status, raw_data)
  212. SELECT
  213. {tenantExpr},
  214. COALESCE({factoryExpr}, {domainExpr}),
  215. COALESCE((SELECT r.source_system FROM mdp_source_table_registry r WHERE r.source_table = @SourceTable LIMIT 1), NULLIF('',''), ''),
  216. @SourceTable,
  217. CAST({sourceRowExpr} AS CHAR),
  218. CAST(COALESCE({entity.SourceBizKeyExpression}, CAST({sourceRowExpr} AS CHAR)) AS CHAR),
  219. @BatchId,
  220. @Now,
  221. 'PENDING',
  222. {rawDataExpr}
  223. FROM `{entity.SourceTable}` s
  224. ON DUPLICATE KEY UPDATE
  225. tenant_id=VALUES(tenant_id),
  226. factory_id=VALUES(factory_id),
  227. sync_batch_id=VALUES(sync_batch_id),
  228. sync_time=VALUES(sync_time),
  229. process_status=VALUES(process_status),
  230. raw_data=VALUES(raw_data),
  231. update_time=CURRENT_TIMESTAMP
  232. """,
  233. new SugarParameter("@SourceTable", entity.SourceTable),
  234. new SugarParameter("@BatchId", batchId),
  235. new SugarParameter("@Now", now));
  236. await MarkSyncLogSuccessAsync(logId, started, affected);
  237. return rowsRead;
  238. }
  239. catch (Exception ex)
  240. {
  241. await MarkSyncLogFailedAsync(logId, started, ex.Message);
  242. throw;
  243. }
  244. }
  245. private async Task<int> TransformStandardAsync(string batchId, DateTime now, CancellationToken cancellationToken)
  246. {
  247. var registeredSource = (await _stdSource.GetSourceAsync(
  248. _runScope.TenantId, "WO_SCHEDULE", cancellationToken))?.SourceSystem;
  249. var gateSource = string.IsNullOrWhiteSpace(registeredSource)
  250. ? "DOPDEMORQ_SQLSERVER"
  251. : registeredSource;
  252. if (!await _neutralGate.AllowsAsync(_runScope.TenantId, "WO_SCHEDULE", gateSource))
  253. return 0;
  254. var total = 0;
  255. foreach (var command in BuildStandardCommands(batchId, now))
  256. {
  257. cancellationToken.ThrowIfCancellationRequested();
  258. total += await MdpSchemaAligner.ExecuteAsync(_db, command.Sql, command.Parameters);
  259. }
  260. return total;
  261. }
  262. private async Task<int> BuildDwdAsync(string batchId, DateTime now, CancellationToken cancellationToken)
  263. {
  264. var total = 0;
  265. foreach (var command in BuildDwdCommands(batchId, now))
  266. {
  267. cancellationToken.ThrowIfCancellationRequested();
  268. total += await MdpSchemaAligner.ExecuteAsync(_db, command.Sql, command.Parameters);
  269. }
  270. return total;
  271. }
  272. private async Task<int> BuildS2KpiValuesAsync(string batchId, DateTime now, CancellationToken cancellationToken)
  273. {
  274. var affected = 0;
  275. for (var dayOffset = 13; dayOffset >= 0; dayOffset--)
  276. {
  277. var statDate = now.Date.AddDays(-dayOffset);
  278. var rows = await CalculateS2KpiValuesAsync(batchId, statDate);
  279. foreach (var row in rows)
  280. {
  281. cancellationToken.ThrowIfCancellationRequested();
  282. affected += await UpsertS2KpiValueAsync(row, statDate, now);
  283. }
  284. }
  285. return affected;
  286. }
  287. private IEnumerable<S2MdpSqlCommand> BuildStandardCommands(string batchId, DateTime now)
  288. {
  289. yield return Cmd(
  290. """
  291. INSERT INTO mdp_std_work_order_schedule
  292. (tenant_id, factory_id, source_system, doc_type, work_order, sales_order_no, sales_order_entry_id, item_code, item_name, site_code, status,
  293. priority, urgent_flag, qty_ordered, qty_completed, order_date, due_date, release_date, prod_line,
  294. specification, lot_serial, drawing_no, project, work_order_type, labor_variance,
  295. source_biz_key, sync_batch_id, sync_time)
  296. SELECT s.tenant_id,
  297. CASE WHEN s.factory_id REGEXP '^[0-9]+$' THEN CAST(s.factory_id AS UNSIGNED) ELSE 1 END,
  298. COALESCE(NULLIF(s.source_system,''), (SELECT r.source_system FROM mdp_source_table_registry r WHERE r.source_table = s.source_table LIMIT 1), ''),
  299. 'PROD_TASK',
  300. JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.WorkOrd')),
  301. JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.SalesJob')),
  302. -- 订单行 ID 桥:WorkOrdMaster.BusinessID,>0 时恒等于 crm_seorderentry.Id
  303. -- (全库实测 28/28 命中同租户订单行、0 孤儿)。源值 0 表示「无关联订单行」,
  304. -- 必须归一为 NULL —— 0 不是合法订单行 Id,落 0 会在桥上造出假关联。
  305. -- sales_order_no(=SalesJob) 只是文本,禁止用它做归因。
  306. NULLIF(CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.BusinessID')) REGEXP '^[0-9]+$'
  307. THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.BusinessID')) AS SIGNED) END, 0),
  308. JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ItemNum')),
  309. JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ItemName')),
  310. JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Site')),
  311. JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Status')),
  312. CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Priority')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Priority')) AS DECIMAL(18,6)) END,
  313. CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Urgent')) IN ('1','true','True') THEN 1 ELSE 0 END,
  314. CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.QtyOrded')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.QtyOrded')) AS DECIMAL(18,6)) ELSE 0 END,
  315. CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.QtyCompleted')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.QtyCompleted')) AS DECIMAL(18,6)) ELSE 0 END,
  316. NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.OrdDate')), 'null'), ''),
  317. NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.DueDate')), 'null'), ''),
  318. NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ReleaseDate')), 'null'), ''),
  319. JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ProdLine')),
  320. -- Specification(型号)= ItemMaster.Descr1 中台 enrich((Domain,ItemNum) 去重取一,实测同键 Descr1 无冲突;
  321. -- 严格按 Domain+ItemNum 关联,不做 ItemNum-only 去重,避免跨工厂串型号):
  322. im.Descr1,
  323. NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.LotSerial')), 'null'), ''),
  324. NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Drawing')), 'null'), ''),
  325. NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Project')), 'null'), ''),
  326. NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.WoTyped')), 'null'), ''),
  327. CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.LbrVar')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.LbrVar')) AS DECIMAL(18,6)) END,
  328. s.source_biz_key, @BatchId, @Now
  329. FROM mdp_stg_schedule s
  330. LEFT JOIN (
  331. SELECT `Domain`, ItemNum, MIN(Descr1) AS Descr1
  332. FROM ItemMaster
  333. GROUP BY `Domain`, ItemNum
  334. ) im ON im.`Domain` = JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Domain'))
  335. AND im.ItemNum = JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ItemNum'))
  336. WHERE s.source_table='WorkOrdMaster' AND IFNULL(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.WorkOrd')), '') <> ''
  337. ON DUPLICATE KEY UPDATE
  338. sales_order_no=VALUES(sales_order_no), sales_order_entry_id=VALUES(sales_order_entry_id),
  339. item_code=VALUES(item_code), item_name=VALUES(item_name),
  340. site_code=VALUES(site_code), status=VALUES(status), priority=VALUES(priority), urgent_flag=VALUES(urgent_flag),
  341. qty_ordered=VALUES(qty_ordered), qty_completed=VALUES(qty_completed), order_date=VALUES(order_date),
  342. due_date=VALUES(due_date), release_date=VALUES(release_date), prod_line=VALUES(prod_line),
  343. specification=VALUES(specification), lot_serial=VALUES(lot_serial), drawing_no=VALUES(drawing_no),
  344. project=VALUES(project), work_order_type=VALUES(work_order_type), labor_variance=VALUES(labor_variance),
  345. sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
  346. """, batchId, now);
  347. yield return Cmd(
  348. """
  349. INSERT INTO mdp_std_operation_schedule
  350. (tenant_id, factory_id, source_system, work_order, op_no, work_center, line_code, item_code,
  351. plan_date, prod_date, start_time, end_time, ord_qty, comp_qty, run_crew, employee, status,
  352. source_biz_key, sync_batch_id, sync_time)
  353. SELECT tenant_id,
  354. CASE WHEN factory_id REGEXP '^[0-9]+$' THEN CAST(factory_id AS UNSIGNED) ELSE 1 END,
  355. COALESCE(NULLIF(mdp_stg_schedule.source_system,''), (SELECT r.source_system FROM mdp_source_table_registry r WHERE r.source_table = mdp_stg_schedule.source_table LIMIT 1), ''),
  356. JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkOrds')),
  357. JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Op')),
  358. JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkCtr')),
  359. JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Line')),
  360. JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ItemNum')),
  361. NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.PlanDate')), 'null'), ''),
  362. NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ProdDate')), 'null'), ''),
  363. NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.StartTime')), 'null'), ''),
  364. NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.EndTime')), 'null'), ''),
  365. CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.OrdQty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.OrdQty')) AS DECIMAL(18,6)) ELSE 0 END,
  366. CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.CompQty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.CompQty')) AS DECIMAL(18,6)) ELSE 0 END,
  367. CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.RunCrew')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.RunCrew')) AS DECIMAL(18,6)) END,
  368. JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Employee')),
  369. -- 日计划下达状态:'Y'=已下达(可执行),空=未下达。原样投影 PeriodSequenceDet.Status。
  370. -- 它只是【状态佐证】—— Stage-2 End 的时间 Authority 一律取
  371. -- S2_DAILY_PLAN_RELEASE SUCCESS 的 end_time,绝不能用 update_time 当下达时间
  372. -- (update_time 会被后续任意一次 UPDATE 覆盖)。
  373. JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Status')),
  374. source_biz_key, @BatchId, @Now
  375. FROM mdp_stg_schedule
  376. WHERE source_table='PeriodSequenceDet' AND IFNULL(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkOrds')), '') <> ''
  377. ON DUPLICATE KEY UPDATE
  378. work_center=VALUES(work_center), line_code=VALUES(line_code), item_code=VALUES(item_code),
  379. plan_date=VALUES(plan_date), prod_date=VALUES(prod_date), start_time=VALUES(start_time), end_time=VALUES(end_time),
  380. ord_qty=VALUES(ord_qty), comp_qty=VALUES(comp_qty), run_crew=VALUES(run_crew), employee=VALUES(employee),
  381. status=VALUES(status),
  382. sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
  383. """, batchId, now);
  384. // ⚠️ 同表的另一个来源:ScheduleResultOpMaster —— 它没有「日计划下达」这个概念。
  385. // 因此既不投影 status,ODKU 里也【刻意不列】status,
  386. // 否则会把 PeriodSequenceDet 分支刚投影进来的下达状态覆盖成 NULL。
  387. // (两来源 source_biz_key 格式不同,uk=(tenant_id,source_biz_key) 实测不碰撞;此为纵深防御。)
  388. yield return Cmd(
  389. """
  390. INSERT INTO mdp_std_operation_schedule
  391. (tenant_id, factory_id, source_system, work_order, op_no, work_center, line_code, item_code,
  392. plan_date, prod_date, start_time, end_time, ord_qty, comp_qty, run_crew, employee,
  393. source_biz_key, sync_batch_id, sync_time)
  394. SELECT tenant_id,
  395. CASE WHEN factory_id REGEXP '^[0-9]+$' THEN CAST(factory_id AS UNSIGNED) ELSE 1 END,
  396. COALESCE(NULLIF(mdp_stg_schedule.source_system,''), (SELECT r.source_system FROM mdp_source_table_registry r WHERE r.source_table = mdp_stg_schedule.source_table LIMIT 1), ''),
  397. JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkOrd')),
  398. JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Op')),
  399. JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkCtr')),
  400. JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Line')),
  401. JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ItemNum')),
  402. NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkDate')), 'null'), ''),
  403. NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkDate')), 'null'), ''),
  404. NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkStartTime')), 'null'), ''),
  405. NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkEndTime')), 'null'), ''),
  406. CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkQty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkQty')) AS DECIMAL(18,6)) ELSE 0 END,
  407. CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkQty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkQty')) AS DECIMAL(18,6)) ELSE 0 END,
  408. CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.AssignedPersonnelCount')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.AssignedPersonnelCount')) AS DECIMAL(18,6)) END,
  409. JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.AssignedEmployeeID')),
  410. source_biz_key, @BatchId, @Now
  411. FROM mdp_stg_schedule
  412. WHERE source_table='ScheduleResultOpMaster' AND IFNULL(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkOrd')), '') <> ''
  413. ON DUPLICATE KEY UPDATE
  414. work_center=VALUES(work_center), line_code=VALUES(line_code), item_code=VALUES(item_code),
  415. plan_date=VALUES(plan_date), prod_date=VALUES(prod_date), start_time=VALUES(start_time), end_time=VALUES(end_time),
  416. ord_qty=VALUES(ord_qty), comp_qty=VALUES(comp_qty), run_crew=VALUES(run_crew), employee=VALUES(employee),
  417. sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
  418. """, batchId, now);
  419. }
  420. private IEnumerable<S2MdpSqlCommand> BuildDwdCommands(string batchId, DateTime now)
  421. {
  422. yield return Cmd(
  423. """
  424. INSERT INTO dwd_order_schedule_trans
  425. (tenant_id, factory_id, stat_date, doc_type, work_order, sales_order_no, item_code, item_name, site_code, prod_line,
  426. status, urgent_flag, qty_ordered, qty_completed, order_date, due_date, release_date, first_plan_date,
  427. last_plan_date, first_start_time, last_end_time, operation_count, scheduled_qty, completed_op_qty,
  428. schedule_cycle_days, schedule_satisfaction_flag, wip_qty, resource_person_count, calc_batch_id, calc_time)
  429. SELECT w.tenant_id, COALESCE(w.factory_id, 1), @StatDate, w.doc_type,
  430. w.work_order, w.sales_order_no, w.item_code, w.item_name, w.site_code, w.prod_line,
  431. w.status, w.urgent_flag, w.qty_ordered, w.qty_completed, w.order_date, w.due_date, w.release_date,
  432. MIN(o.plan_date), MAX(o.plan_date), MIN(o.start_time), MAX(o.end_time),
  433. COUNT(o.id), SUM(IFNULL(o.ord_qty, 0)), SUM(IFNULL(o.comp_qty, 0)),
  434. CASE
  435. WHEN COALESCE(MIN(o.plan_date), MAX(o.plan_date)) IS NOT NULL
  436. AND COALESCE(w.release_date, w.order_date) IS NOT NULL
  437. THEN TIMESTAMPDIFF(HOUR, COALESCE(w.release_date, w.order_date), COALESCE(MAX(o.plan_date), MIN(o.plan_date))) / 24
  438. END,
  439. CASE
  440. WHEN w.due_date IS NOT NULL AND COALESCE(MAX(o.plan_date), MIN(o.plan_date)) IS NOT NULL
  441. AND DATE(COALESCE(MAX(o.plan_date), MIN(o.plan_date))) <= DATE(w.due_date) THEN 1
  442. ELSE 0
  443. END,
  444. GREATEST(IFNULL(w.qty_ordered, 0) - IFNULL(w.qty_completed, 0), 0),
  445. SUM(IFNULL(o.run_crew, 0)),
  446. @BatchId, @Now
  447. FROM mdp_std_work_order_schedule w
  448. LEFT JOIN mdp_std_operation_schedule o
  449. ON o.tenant_id=w.tenant_id AND IFNULL(o.work_order,'')=IFNULL(w.work_order,'')
  450. WHERE IFNULL(w.work_order, '') <> ''
  451. GROUP BY w.tenant_id, COALESCE(w.factory_id, 1), w.doc_type, w.work_order, w.sales_order_no, w.item_code, w.item_name,
  452. w.site_code, w.prod_line, w.status, w.urgent_flag, w.qty_ordered, w.qty_completed,
  453. w.order_date, w.due_date, w.release_date
  454. ON DUPLICATE KEY UPDATE
  455. sales_order_no=VALUES(sales_order_no), item_code=VALUES(item_code), item_name=VALUES(item_name),
  456. site_code=VALUES(site_code), prod_line=VALUES(prod_line), status=VALUES(status), urgent_flag=VALUES(urgent_flag),
  457. qty_ordered=VALUES(qty_ordered), qty_completed=VALUES(qty_completed), order_date=VALUES(order_date),
  458. due_date=VALUES(due_date), release_date=VALUES(release_date), first_plan_date=VALUES(first_plan_date),
  459. last_plan_date=VALUES(last_plan_date), first_start_time=VALUES(first_start_time), last_end_time=VALUES(last_end_time),
  460. operation_count=VALUES(operation_count), scheduled_qty=VALUES(scheduled_qty), completed_op_qty=VALUES(completed_op_qty),
  461. schedule_cycle_days=VALUES(schedule_cycle_days), schedule_satisfaction_flag=VALUES(schedule_satisfaction_flag),
  462. wip_qty=VALUES(wip_qty), resource_person_count=VALUES(resource_person_count), calc_time=VALUES(calc_time),
  463. update_time=CURRENT_TIMESTAMP
  464. """, batchId, now);
  465. }
  466. private async Task<List<S2KpiCalcRow>> CalculateS2KpiValuesAsync(string batchId, DateTime statDate)
  467. {
  468. var scope = RequireScope();
  469. return await _db.Ado.SqlQueryAsync<S2KpiCalcRow>(MdpSqlScope.InjectTenantFactory(
  470. """
  471. SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L1_001' AS MetricCode,
  472. ROUND(AVG(schedule_cycle_days), 4) AS MetricValue
  473. FROM dwd_order_schedule_trans
  474. WHERE calc_batch_id=@BatchId AND doc_type='PROD_TASK' AND schedule_cycle_days IS NOT NULL AND schedule_cycle_days >= 0
  475. GROUP BY tenant_id, factory_id
  476. UNION ALL
  477. SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L1_002' AS MetricCode,
  478. ROUND(100 * SUM(schedule_satisfaction_flag) / NULLIF(COUNT(1), 0), 4) AS MetricValue
  479. FROM dwd_order_schedule_trans
  480. WHERE calc_batch_id=@BatchId AND doc_type='PROD_TASK'
  481. GROUP BY tenant_id, factory_id
  482. UNION ALL
  483. SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L1_003' AS MetricCode,
  484. ROUND(COUNT(1) / NULLIF(SUM(CASE WHEN IFNULL(resource_person_count, 0) > 0 THEN resource_person_count ELSE 1 END), 0), 4) AS MetricValue
  485. FROM dwd_order_schedule_trans
  486. WHERE calc_batch_id=@BatchId AND doc_type='PROD_TASK'
  487. GROUP BY tenant_id, factory_id
  488. UNION ALL
  489. SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L1_004' AS MetricCode,
  490. ROUND(SUM(wip_qty) / NULLIF(SUM(CASE WHEN qty_completed > 0 THEN qty_completed ELSE completed_op_qty END), 0) * 30, 4) AS MetricValue
  491. FROM dwd_order_schedule_trans
  492. WHERE calc_batch_id=@BatchId AND doc_type='PROD_TASK'
  493. GROUP BY tenant_id, factory_id
  494. UNION ALL
  495. SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L2_001' AS MetricCode,
  496. ROUND(AVG(schedule_cycle_days), 4) AS MetricValue
  497. FROM dwd_order_schedule_trans
  498. WHERE calc_batch_id=@BatchId AND doc_type='PROD_TASK' AND schedule_cycle_days IS NOT NULL AND schedule_cycle_days >= 0
  499. GROUP BY tenant_id, factory_id
  500. UNION ALL
  501. SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L2_002' AS MetricCode,
  502. ROUND(100 * SUM(schedule_satisfaction_flag) / NULLIF(COUNT(1), 0), 4) AS MetricValue
  503. FROM dwd_order_schedule_trans
  504. WHERE calc_batch_id=@BatchId AND doc_type='PROD_TASK'
  505. GROUP BY tenant_id, factory_id
  506. UNION ALL
  507. SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L2_003' AS MetricCode,
  508. ROUND(COUNT(1) / NULLIF(SUM(CASE WHEN IFNULL(resource_person_count, 0) > 0 THEN resource_person_count ELSE 1 END), 0), 4) AS MetricValue
  509. FROM dwd_order_schedule_trans
  510. WHERE calc_batch_id=@BatchId AND doc_type='PROD_TASK'
  511. GROUP BY tenant_id, factory_id
  512. UNION ALL
  513. SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L3_001' AS MetricCode,
  514. ROUND(AVG(
  515. CASE
  516. WHEN start_time IS NOT NULL AND end_time IS NOT NULL AND end_time >= start_time
  517. THEN TIMESTAMPDIFF(HOUR, start_time, end_time) / 24
  518. WHEN plan_date IS NOT NULL AND prod_date IS NOT NULL
  519. THEN ABS(TIMESTAMPDIFF(DAY, plan_date, prod_date))
  520. END
  521. ), 4) AS MetricValue
  522. FROM mdp_std_operation_schedule
  523. WHERE IFNULL(work_order, '') <> ''
  524. GROUP BY tenant_id, factory_id
  525. UNION ALL
  526. SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L3_002' AS MetricCode,
  527. ROUND(100 * SUM(CASE WHEN plan_date IS NOT NULL AND (prod_date IS NULL OR prod_date >= plan_date) THEN 1 ELSE 0 END) / NULLIF(COUNT(1), 0), 4) AS MetricValue
  528. FROM mdp_std_operation_schedule
  529. WHERE IFNULL(work_order, '') <> ''
  530. GROUP BY tenant_id, factory_id
  531. UNION ALL
  532. SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L3_003' AS MetricCode,
  533. ROUND(COUNT(1) / NULLIF(SUM(CASE WHEN IFNULL(run_crew, 0) > 0 THEN run_crew ELSE 1 END), 0), 4) AS MetricValue
  534. FROM mdp_std_operation_schedule
  535. WHERE IFNULL(work_order, '') <> ''
  536. GROUP BY tenant_id, factory_id
  537. UNION ALL
  538. SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L3_004' AS MetricCode,
  539. ROUND(AVG(
  540. CASE
  541. WHEN start_time IS NOT NULL AND end_time IS NOT NULL AND end_time >= start_time
  542. THEN TIMESTAMPDIFF(HOUR, start_time, end_time) / 24
  543. WHEN plan_date IS NOT NULL AND prod_date IS NOT NULL
  544. THEN ABS(TIMESTAMPDIFF(DAY, plan_date, prod_date))
  545. END
  546. ), 4) AS MetricValue
  547. FROM mdp_std_operation_schedule
  548. WHERE IFNULL(COALESCE(NULLIF(work_center,''), NULLIF(line_code,'')), '') <> ''
  549. GROUP BY tenant_id, factory_id
  550. UNION ALL
  551. SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L3_005' AS MetricCode,
  552. ROUND(100 * SUM(CASE WHEN plan_date IS NOT NULL AND (prod_date IS NULL OR prod_date >= plan_date) THEN 1 ELSE 0 END) / NULLIF(COUNT(1), 0), 4) AS MetricValue
  553. FROM mdp_std_operation_schedule
  554. WHERE IFNULL(COALESCE(NULLIF(work_center,''), NULLIF(line_code,'')), '') <> ''
  555. GROUP BY tenant_id, factory_id
  556. UNION ALL
  557. SELECT tenant_id AS TenantId, factory_id AS FactoryId, 'S2_L3_006' AS MetricCode,
  558. ROUND(COUNT(DISTINCT COALESCE(NULLIF(work_center,''), NULLIF(line_code,''), CONCAT(work_order, ':', op_no)))
  559. / NULLIF(SUM(CASE WHEN IFNULL(run_crew, 0) > 0 THEN run_crew ELSE 1 END), 0), 4) AS MetricValue
  560. FROM mdp_std_operation_schedule
  561. WHERE IFNULL(COALESCE(NULLIF(work_center,''), NULLIF(line_code,'')), '') <> ''
  562. GROUP BY tenant_id, factory_id
  563. """),
  564. new SugarParameter("@BatchId", batchId),
  565. new SugarParameter("@StatDate", statDate),
  566. new SugarParameter("@TenantId", scope.TenantId),
  567. new SugarParameter("@FactoryId", scope.FactoryId));
  568. }
  569. private async Task<int> UpsertS2KpiValueAsync(S2KpiCalcRow row, DateTime statDate, DateTime now)
  570. {
  571. if (row.MetricValue == null) return 0;
  572. var meta = await _db.Ado.SqlQuerySingleAsync<S2KpiMetaRow>(
  573. """
  574. SELECT MetricLevel, Direction, YellowThreshold, RedThreshold
  575. FROM ado_smart_ops_kpi_master
  576. WHERE TenantId=@TenantId AND ModuleCode='S2' AND MetricCode=@MetricCode AND IsEnabled=1
  577. LIMIT 1
  578. """,
  579. new SugarParameter("@TenantId", row.TenantId),
  580. new SugarParameter("@MetricCode", row.MetricCode));
  581. if (meta == null) return 0;
  582. var table = ResolveKpiValueTable(meta.MetricLevel);
  583. var current = await _db.Ado.SqlQuerySingleAsync<S2KpiValueRow>(
  584. $"""
  585. SELECT id AS Id, metric_value AS MetricValue, target_value AS TargetValue
  586. FROM {table}
  587. WHERE tenant_id=@TenantId AND factory_id=@FactoryId AND module_code='S2'
  588. AND metric_code=@MetricCode AND biz_date=@BizDate AND is_deleted=0
  589. ORDER BY id
  590. LIMIT 1
  591. """,
  592. new SugarParameter("@TenantId", row.TenantId),
  593. new SugarParameter("@FactoryId", row.FactoryId),
  594. new SugarParameter("@MetricCode", row.MetricCode),
  595. new SugarParameter("@BizDate", statDate));
  596. var prior = await _db.Ado.SqlQuerySingleAsync<S2KpiValueRow>(
  597. $"""
  598. SELECT id AS Id, metric_value AS MetricValue, target_value AS TargetValue
  599. FROM {table}
  600. WHERE tenant_id=@TenantId AND factory_id=@FactoryId AND module_code='S2'
  601. AND metric_code=@MetricCode AND biz_date<@BizDate AND is_deleted=0
  602. ORDER BY biz_date DESC, id DESC
  603. LIMIT 1
  604. """,
  605. new SugarParameter("@TenantId", row.TenantId),
  606. new SugarParameter("@FactoryId", row.FactoryId),
  607. new SugarParameter("@MetricCode", row.MetricCode),
  608. new SugarParameter("@BizDate", statDate));
  609. var actual = Math.Round(row.MetricValue.Value, 4);
  610. var snap = await _kpiTargetResolver.ResolveAsync(row.TenantId, row.FactoryId, row.MetricCode, "S2", statDate);
  611. var target = snap.TargetValue;
  612. var status = AidopS4KpiMerge.AchievementLevel(actual, target, meta.Direction, meta.YellowThreshold, meta.RedThreshold);
  613. var trend = ResolveTrendFlag(actual, prior?.MetricValue);
  614. if (current != null)
  615. {
  616. return await MdpSchemaAligner.ExecuteAsync(_db,
  617. $"""
  618. UPDATE {table}
  619. SET {Admin.NET.Plugin.AiDOP.SmartOps.KpiResultWrite.FromMetricSet}metric_value=@MetricValue, target_value=@TargetValue, status_color=@StatusColor, trend_flag=@TrendFlag,
  620. target_config_id=@TargetConfigId, target_source=@TargetSource, target_resolved_at=@TargetResolvedAt,
  621. is_active=1, status='ACTIVE', calc_time=@CalcTime, update_time=@CalcTime
  622. WHERE tenant_id=@TenantId AND factory_id=@FactoryId AND module_code='S2'
  623. AND metric_code=@MetricCode AND biz_date=@BizDate AND is_deleted=0
  624. """,
  625. new SugarParameter("@MetricValue", actual),
  626. new SugarParameter("@TargetValue", KpiTargetSnapshotSql.ValueOrDbNull(snap)),
  627. new SugarParameter("@StatusColor", status),
  628. new SugarParameter("@TrendFlag", trend),
  629. new SugarParameter("@TargetConfigId", KpiTargetSnapshotSql.ConfigIdOrDbNull(snap)),
  630. new SugarParameter("@TargetSource", KpiTargetSnapshotSql.SourceOrDbNull(snap)),
  631. new SugarParameter("@TargetResolvedAt", snap.ResolvedAt),
  632. new SugarParameter("@CalcTime", now),
  633. new SugarParameter("@TenantId", row.TenantId),
  634. new SugarParameter("@FactoryId", row.FactoryId),
  635. new SugarParameter("@MetricCode", row.MetricCode),
  636. new SugarParameter("@BizDate", statDate));
  637. }
  638. var nextId = Yitter.IdGenerator.YitIdHelper.NextId();
  639. return await MdpSchemaAligner.ExecuteAsync(_db,
  640. $"""
  641. INSERT INTO {table}
  642. (id, tenant_id, factory_id, status, biz_date, create_time, update_time, is_deleted, is_active,
  643. module_code, metric_code, result_status, result_reason, metric_value, target_value, status_color, trend_flag, calc_time,
  644. target_config_id, target_source, target_resolved_at)
  645. VALUES
  646. (@Id, @TenantId, @FactoryId, 'ACTIVE', @BizDate, @CalcTime, @CalcTime, 0, 1,
  647. 'S2', @MetricCode, {Admin.NET.Plugin.AiDOP.SmartOps.KpiResultWrite.FromMetricVals}@MetricValue, @TargetValue, @StatusColor, @TrendFlag, @CalcTime,
  648. @TargetConfigId, @TargetSource, @TargetResolvedAt)
  649. """,
  650. new SugarParameter("@Id", nextId),
  651. new SugarParameter("@TenantId", row.TenantId),
  652. new SugarParameter("@FactoryId", row.FactoryId),
  653. new SugarParameter("@BizDate", statDate),
  654. new SugarParameter("@CalcTime", now),
  655. new SugarParameter("@MetricCode", row.MetricCode),
  656. new SugarParameter("@MetricValue", actual),
  657. new SugarParameter("@TargetValue", KpiTargetSnapshotSql.ValueOrDbNull(snap)),
  658. new SugarParameter("@StatusColor", status),
  659. new SugarParameter("@TrendFlag", trend),
  660. new SugarParameter("@TargetConfigId", KpiTargetSnapshotSql.ConfigIdOrDbNull(snap)),
  661. new SugarParameter("@TargetSource", KpiTargetSnapshotSql.SourceOrDbNull(snap)),
  662. new SugarParameter("@TargetResolvedAt", snap.ResolvedAt));
  663. }
  664. private async Task<long> InsertSyncLogAsync(long entityId, string entityName, string batchId, int rowsRead)
  665. {
  666. EnsureRunScope();
  667. await MdpSchemaAligner.ExecuteAsync(_db,
  668. """
  669. INSERT INTO mdp_sync_log
  670. (tenant_id, entity_id, source_code, entity_name, sync_batch_id, sync_type, trigger_type, sync_start, rows_read, status)
  671. VALUES (@TenantId, @EntityId, 'AIDOPDEV_MYSQL', @EntityName, @BatchId, 'FULL', 'AUTO', NOW(), @RowsRead, 'RUNNING')
  672. """,
  673. new SugarParameter("@TenantId", _runScope.TenantId),
  674. new SugarParameter("@EntityId", entityId),
  675. new SugarParameter("@EntityName", entityName),
  676. new SugarParameter("@BatchId", batchId),
  677. new SugarParameter("@RowsRead", rowsRead));
  678. return await _db.Ado.GetLongAsync(
  679. "SELECT id FROM mdp_sync_log WHERE sync_batch_id=@BatchId AND entity_id=@EntityId ORDER BY id DESC LIMIT 1",
  680. new List<SugarParameter> { new("@BatchId", batchId), new("@EntityId", entityId) });
  681. }
  682. private async Task MarkSyncLogSuccessAsync(long logId, DateTime started, int affected)
  683. {
  684. await MdpSchemaAligner.ExecuteAsync(_db,
  685. """
  686. UPDATE mdp_sync_log
  687. SET sync_end=NOW(), duration_ms=@DurationMs, rows_insert=@RowsInsert, rows_update=0, rows_skip=0, rows_error=0, status='SUCCESS'
  688. WHERE id=@Id
  689. """,
  690. new SugarParameter("@DurationMs", (int)(DateTime.Now - started).TotalMilliseconds),
  691. new SugarParameter("@RowsInsert", affected),
  692. new SugarParameter("@Id", logId));
  693. }
  694. private async Task MarkSyncLogFailedAsync(long logId, DateTime started, string message)
  695. {
  696. try
  697. {
  698. await MdpSchemaAligner.ExecuteAsync(_db,
  699. """
  700. UPDATE mdp_sync_log
  701. SET sync_end=NOW(), duration_ms=@DurationMs, rows_error=1, status='FAILED', error_msg=@ErrorMsg
  702. WHERE id=@Id
  703. """,
  704. new SugarParameter("@DurationMs", (int)(DateTime.Now - started).TotalMilliseconds),
  705. new SugarParameter("@ErrorMsg", Truncate(message, 1000)),
  706. new SugarParameter("@Id", logId));
  707. }
  708. catch (Exception ex)
  709. {
  710. Console.Error.WriteLine($"[S2MdpSyncTransform] MarkSyncLogFailed write failed (syncLogId={logId}): {ex.Message}");
  711. }
  712. }
  713. private async Task<long> InsertTransformRunLogAsync(string batchId, DateTime startedAt, string triggerType)
  714. {
  715. await MdpSchemaAligner.ExecuteAsync(_db,
  716. """
  717. INSERT INTO mdp_transform_run_log
  718. (tenant_id, job_code, job_name, trigger_type, batch_id, status, start_time)
  719. VALUES (@TenantId, 'S2_MDP_SYNC_TRANSFORM', 'S2 MDP同步与KPI计算', @TriggerType, @BatchId, 'RUNNING', @StartTime)
  720. """,
  721. new SugarParameter("@TenantId", RequireScope().TenantId),
  722. new SugarParameter("@TriggerType", NormalizeTriggerType(triggerType)),
  723. new SugarParameter("@BatchId", batchId),
  724. new SugarParameter("@StartTime", startedAt));
  725. return await _db.Ado.GetLongAsync(
  726. "SELECT id FROM mdp_transform_run_log WHERE batch_id=@BatchId ORDER BY id DESC LIMIT 1",
  727. new List<SugarParameter> { new("@BatchId", batchId) });
  728. }
  729. private async Task MarkTransformRunSuccessAsync(long runLogId, DateTime startedAt, S2MdpSyncTransformResult result)
  730. {
  731. var finishedAt = DateTime.Now;
  732. await MdpSchemaAligner.ExecuteAsync(_db,
  733. """
  734. UPDATE mdp_transform_run_log
  735. SET status='SUCCESS', end_time=@EndTime, duration_ms=@DurationMs,
  736. stage_rows=@StageRows, standard_rows=@StandardRows, dwd_rows=@DwdRows,
  737. summary_json=@SummaryJson, update_time=CURRENT_TIMESTAMP
  738. WHERE id=@Id
  739. """,
  740. new SugarParameter("@EndTime", finishedAt),
  741. new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
  742. new SugarParameter("@StageRows", result.StageRows),
  743. new SugarParameter("@StandardRows", result.StandardRows),
  744. new SugarParameter("@DwdRows", result.DwdRows),
  745. new SugarParameter("@SummaryJson", BuildRunSummaryJson(result)),
  746. new SugarParameter("@Id", runLogId));
  747. }
  748. private async Task MarkTransformRunFailedAsync(long runLogId, DateTime startedAt, string message)
  749. {
  750. try
  751. {
  752. var finishedAt = DateTime.Now;
  753. await MdpSchemaAligner.ExecuteAsync(_db,
  754. """
  755. UPDATE mdp_transform_run_log
  756. SET status='FAILED', end_time=@EndTime, duration_ms=@DurationMs,
  757. error_message=@ErrorMessage, update_time=CURRENT_TIMESTAMP
  758. WHERE id=@Id
  759. """,
  760. new SugarParameter("@EndTime", finishedAt),
  761. new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
  762. new SugarParameter("@ErrorMessage", Truncate(message, 2000)),
  763. new SugarParameter("@Id", runLogId));
  764. }
  765. catch (Exception ex)
  766. {
  767. Console.Error.WriteLine($"[S2MdpSyncTransform] MarkTransformRunFailed write failed (runLogId={runLogId}): {ex.Message}");
  768. }
  769. }
  770. private S2MdpSqlCommand Cmd(string sql, string batchId, DateTime now)
  771. {
  772. var scope = RequireScope();
  773. return new S2MdpSqlCommand(MdpSqlScope.InjectTenantFactory(sql), new[]
  774. {
  775. new SugarParameter("@BatchId", batchId),
  776. new SugarParameter("@Now", now),
  777. new SugarParameter("@StatDate", now.Date),
  778. new SugarParameter("@TenantId", scope.TenantId),
  779. new SugarParameter("@FactoryId", scope.FactoryId)
  780. });
  781. }
  782. private MdpRebuildScope RequireScope()
  783. {
  784. EnsureRunScope();
  785. return _runScope;
  786. }
  787. private void EnsureRunScope()
  788. {
  789. if (_runScope == null)
  790. throw new InvalidOperationException("S2 全量重算必须指定有效 tenantId/factoryId");
  791. }
  792. private static string BuildJsonObjectExpression(IEnumerable<string> columns)
  793. {
  794. var parts = columns.SelectMany(c => new[] { $"'{c.Replace("'", "''")}'", $"s.`{c}`" });
  795. return $"JSON_OBJECT({string.Join(",", parts)})";
  796. }
  797. private static string FindColumn(IEnumerable<string> columns, string expected)
  798. {
  799. return columns.First(u => string.Equals(u, expected, StringComparison.OrdinalIgnoreCase));
  800. }
  801. private static string NormalizeTriggerType(string? triggerType)
  802. {
  803. return string.IsNullOrWhiteSpace(triggerType) ? "AUTO" : triggerType.Trim().ToUpperInvariant();
  804. }
  805. private static string BuildRunSummaryJson(S2MdpSyncTransformResult result)
  806. {
  807. return $$"""{"batchId":"{{result.BatchId}}","stageRows":{{result.StageRows}},"standardRows":{{result.StandardRows}},"dwdRows":{{result.DwdRows}},"kpiRows":{{result.KpiRows}}}""";
  808. }
  809. private static string ResolveKpiValueTable(int metricLevel)
  810. {
  811. return metricLevel switch
  812. {
  813. 1 => "ado_s9_kpi_value_l1_day",
  814. 2 => "ado_s9_kpi_value_l2_day",
  815. 3 => "ado_s9_kpi_value_l3_day",
  816. 4 => "ado_s9_kpi_value_l4_day",
  817. _ => "ado_s9_kpi_value_l2_day"
  818. };
  819. }
  820. private static decimal DefaultS2Target(string metricCode) => LegacyKpiCodeTargets.GetOrZero(metricCode);
  821. private static string ResolveKpiStatus(decimal actual, decimal target, string? direction, decimal? yellowThreshold, decimal? redThreshold)
  822. {
  823. if (target <= 0) return "gray";
  824. var ratio = actual / target * 100m;
  825. if (string.Equals(direction, "lower_is_better", StringComparison.OrdinalIgnoreCase))
  826. {
  827. if (actual <= target) return "green";
  828. if (ratio <= (yellowThreshold ?? 110m)) return "yellow";
  829. return ratio >= (redThreshold ?? 120m) ? "red" : "yellow";
  830. }
  831. if (actual >= target) return "green";
  832. if (ratio >= (yellowThreshold ?? 95m)) return "yellow";
  833. return ratio <= (redThreshold ?? 80m) ? "red" : "yellow";
  834. }
  835. private static string ResolveTrendFlag(decimal actual, decimal? previous)
  836. {
  837. if (previous == null) return "flat";
  838. if (actual > previous.Value) return "up";
  839. if (actual < previous.Value) return "down";
  840. return "flat";
  841. }
  842. private static string Truncate(string? raw, int maxLength)
  843. {
  844. if (string.IsNullOrEmpty(raw)) return string.Empty;
  845. return raw.Length <= maxLength ? raw : raw[..maxLength];
  846. }
  847. private sealed class S2ColumnRow
  848. {
  849. public string ColumnName { get; set; } = string.Empty;
  850. }
  851. private sealed class S2MdpEntityRow
  852. {
  853. public long Id { get; set; }
  854. public string EntityName { get; set; } = string.Empty;
  855. }
  856. private sealed class S2KpiCalcRow
  857. {
  858. public long TenantId { get; set; }
  859. public long FactoryId { get; set; }
  860. public string MetricCode { get; set; } = string.Empty;
  861. public decimal? MetricValue { get; set; }
  862. }
  863. private sealed class S2KpiMetaRow
  864. {
  865. public int MetricLevel { get; set; }
  866. public string Direction { get; set; } = "higher_is_better";
  867. public decimal? YellowThreshold { get; set; }
  868. public decimal? RedThreshold { get; set; }
  869. }
  870. private sealed class S2KpiValueRow
  871. {
  872. public long Id { get; set; }
  873. public decimal? MetricValue { get; set; }
  874. public decimal? TargetValue { get; set; }
  875. }
  876. }
  877. public sealed class S2MdpSyncTransformResult
  878. {
  879. public long RunLogId { get; set; }
  880. public string BatchId { get; set; } = string.Empty;
  881. public int StageRows { get; set; }
  882. public int StandardRows { get; set; }
  883. public int DwdRows { get; set; }
  884. public int KpiRows { get; set; }
  885. public int AtomicRows { get; set; }
  886. }
  887. internal sealed record S2MdpSqlCommand(string Sql, SugarParameter[] Parameters);
  888. internal sealed record S2MdpEntityConfig(
  889. string EntityCode,
  890. string SourceTable,
  891. string TargetTable,
  892. string SourceRowIdExpression,
  893. string SourceBizKeyExpression)
  894. {
  895. public static readonly IReadOnlyList<S2MdpEntityConfig> All = new List<S2MdpEntityConfig>
  896. {
  897. new("S2_WORK_ORDER_MASTER", "WorkOrdMaster", "mdp_stg_schedule", "RecID", "CONCAT(IFNULL(s.`Domain`,''), ':', IFNULL(s.`WorkOrd`,''))"),
  898. new("S2_WORK_ORDER_ROUTING", "WorkOrdRouting", "mdp_stg_schedule", "RecID", "CONCAT(IFNULL(s.`Domain`,''), ':', IFNULL(s.`WorkOrd`,''), ':', IFNULL(s.`OP`,''))"),
  899. new("S2_WORK_ORDER_DETAIL", "WorkOrdDetail", "mdp_stg_schedule", "RecID", "CONCAT(IFNULL(s.`Domain`,''), ':', IFNULL(s.`WorkOrd`,''), ':', IFNULL(s.`Op`,''), ':', IFNULL(s.`ItemNum`,''))"),
  900. new("S2_PERIOD_SEQUENCE_DET", "PeriodSequenceDet", "mdp_stg_schedule", "RecID", "CONCAT(IFNULL(s.`Domain`,''), ':', IFNULL(s.`WorkOrds`,''), ':', IFNULL(s.`Op`,''), ':', IFNULL(s.`Sequence`,''))"),
  901. new("S2_SCHEDULE_RESULT_OP", "ScheduleResultOpMaster", "mdp_stg_schedule", "RecID", "CONCAT(IFNULL(s.`Domain`,''), ':', IFNULL(s.`WorkOrd`,''), ':', IFNULL(s.`Op`,''), ':', IFNULL(s.`WorkDate`,''))")
  902. };
  903. }
  904. internal static class S2MdpDdl
  905. {
  906. public static readonly IReadOnlyList<string> SqlBlocks = new[]
  907. {
  908. """
  909. INSERT INTO mdp_entity
  910. (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name, sync_mode, batch_size, status, remark, create_time, update_time)
  911. SELECT 0, s.id, v.entity_code, v.entity_name, 'TABLE', v.source_table_name, 'mdp_stg_schedule', 'FULL', 5000, 1, v.remark, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
  912. FROM mdp_source s
  913. JOIN (
  914. SELECT 'S2_WORK_ORDER_MASTER' AS entity_code, 'S2工单主数据' AS entity_name, 'WorkOrdMaster' AS source_table_name, '工单主数据进入 S2 贴源层' AS remark
  915. UNION ALL SELECT 'S2_WORK_ORDER_ROUTING', 'S2工单工艺路线', 'WorkOrdRouting', '工单工艺路线进入 S2 贴源层'
  916. UNION ALL SELECT 'S2_WORK_ORDER_DETAIL', 'S2工单物料明细', 'WorkOrdDetail', '工单物料需求进入 S2 贴源层'
  917. UNION ALL SELECT 'S2_PERIOD_SEQUENCE_DET', 'S2工序排程计划', 'PeriodSequenceDet', '工序间衔接与排程计划进入 S2 贴源层'
  918. UNION ALL SELECT 'S2_SCHEDULE_RESULT_OP', 'S2工序排产结果', 'ScheduleResultOpMaster', '工序排产结果进入 S2 贴源层'
  919. ) v
  920. WHERE s.tenant_id=0 AND s.source_code='AIDOPDEV_MYSQL'
  921. ON DUPLICATE KEY UPDATE
  922. source_id=VALUES(source_id), entity_name=VALUES(entity_name), entity_type=VALUES(entity_type),
  923. source_table_name=VALUES(source_table_name), target_table_name=VALUES(target_table_name),
  924. sync_mode=VALUES(sync_mode), batch_size=VALUES(batch_size), status=VALUES(status),
  925. remark=VALUES(remark), update_time=CURRENT_TIMESTAMP;
  926. """
  927. };
  928. }