IqcInspectionMdpSyncService.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. using Admin.NET.Plugin.AiDOP.DataPlatform;
  2. using SqlSugar;
  3. namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
  4. /// <summary>
  5. /// S5 来料检验(IQC)标准事实层投影服务。
  6. ///
  7. /// <para>链路:<c>qms_qcp_inspbill</c>(+ 报检申请/分录 + 审批流实例)→ <c>mdp_std_iqc_inspection</c>。
  8. /// 目的是让 S8 Stage-3 不再直读业务表与框架表 —— 那四张表此后只由本 Writer 读。</para>
  9. ///
  10. /// <para><b>Grain = 一张检验单</b>(<c>tenant_id + iqc_bill_id</c>)。先证后建:全库 23 张单实测
  11. /// entries ≤ 1、distinct_po ≤ 1、flow_instances ≤ 1,结构保证来自 <c>inspbill.hid</c> 这把
  12. /// 指向报检分录的单值外键。</para>
  13. ///
  14. /// <para><b>两条正交轴分开存</b>:质量轴取 <c>pd</c>(0 合格 / 1 不合格 / NULL 未判定),
  15. /// 流程轴取审批流 <c>Status</c> 与 <c>EndTime</c>。实测存在「检验完成且 pd=1」的单,
  16. /// 所以绝不可把流程完成当成质量合格,也绝不用 <c>FBILLSTATUS</c> 当质量 Authority。</para>
  17. ///
  18. /// <para><b>租户方向是单向的</b>:<c>ApprovalFlowInstance</c> 没有 TenantId 列,
  19. /// 因此租户只能来自 <c>qms_qcp_inspbill.tenant_id</c>,审批流仅按 (BizType, BizId) 反查,
  20. /// 绝不反向驱动租户。</para>
  21. ///
  22. /// <para><b>刻意不做</b>:不投 PO 行号 / 收货行号(上游 <c>FSRCBILLID</c>、
  23. /// <c>FSRCBILLENTRYSEQ</c> 实测全空,属 SOURCE CONTRACT GAP);不做订单级完成聚合;
  24. /// 不写任何业务源表。</para>
  25. /// </summary>
  26. public class IqcInspectionMdpSyncService : ITransient
  27. {
  28. private const string JobCode = "S5_IQC_INSPECTION_MDP_SYNC";
  29. private const string JobName = "S5来料检验标准事实层投影";
  30. /// <summary>审批流业务类型,与 <c>IqcInspBillFlowConst.BizType</c> 一致。</summary>
  31. private const string FlowBizType = "S5_IQC_INSPBILL";
  32. private readonly ISqlSugarClient _db;
  33. private readonly TransformRunLogFinalizer _runLogFinalizer;
  34. private readonly MdpNeutralSourceGate _neutralGate;
  35. public IqcInspectionMdpSyncService(ISqlSugarClient db, TransformRunLogFinalizer runLogFinalizer, MdpNeutralSourceGate neutralGate)
  36. {
  37. _db = db;
  38. _runLogFinalizer = runLogFinalizer;
  39. _neutralGate = neutralGate;
  40. }
  41. /// <summary>
  42. /// 全量投影(UPSERT)。可按租户收窄;<paramref name="tenantId"/> 为 0 表示全租户。
  43. /// 幂等:同一检验单重复运行只更新不新增,唯一键 (tenant_id, source_row_id)。
  44. /// </summary>
  45. public async Task<IqcInspectionMdpSyncResult> RunFullAsync(
  46. long tenantId = 0,
  47. string triggerType = "AUTO",
  48. CancellationToken cancellationToken = default)
  49. {
  50. cancellationToken.ThrowIfCancellationRequested();
  51. var now = DateTime.Now;
  52. var batchId = $"S5_IQC_STD_{(tenantId > 0 ? tenantId + "_" : "")}{now:yyyyMMddHHmmss}";
  53. var runLogId = await InsertRunLogAsync(batchId, now, triggerType);
  54. var result = new IqcInspectionMdpSyncResult { BatchId = batchId, RunLogId = runLogId };
  55. try
  56. {
  57. result.StdRows = await TransformStandardAsync(batchId, now, tenantId);
  58. await CompleteRunLogAsync(runLogId, result.StdRows, now);
  59. return result;
  60. }
  61. catch (Exception ex)
  62. {
  63. // 宿主关停不是转换失败:交给 finally 收口为 ABORTED,不污染 FAILED 语义。
  64. if (!_runLogFinalizer.IsHostStopping)
  65. await FailRunLogAsync(runLogId, ex.Message);
  66. throw;
  67. }
  68. finally
  69. {
  70. await _runLogFinalizer.FinalizeIfHostStoppingAsync(runLogId, now);
  71. }
  72. }
  73. /// <summary>
  74. /// 标准层投影。
  75. ///
  76. /// <para>报检分录用 <c>LEFT JOIN</c> 且【只走 <c>hid</c> 这把单值外键】:<c>hid</c> 为空的历史单
  77. /// 得到 NULL 的采购/收货/物料身份,而不是去申请单下"挑一条"分录 —— 宁可为空,不猜。</para>
  78. ///
  79. /// <para>审批流同样 <c>LEFT JOIN</c>:尚未发起流程的单落 NULL,不伪造完成时间。</para>
  80. /// </summary>
  81. private async Task<int> TransformStandardAsync(string batchId, DateTime now, long tenantId)
  82. {
  83. if (!await _neutralGate.AllowsAsync(tenantId, "IQC", "AIDOP_NATIVE"))
  84. return 0;
  85. // 租户收窄:>0 时只投该租户,=0 时全投。两处(COUNT 与 INSERT)共用同一谓词,防止口径漂移。
  86. var tenantClause = tenantId > 0 ? " AND b.tenant_id=@TenantId" : "";
  87. var countPars = new List<SugarParameter>();
  88. if (tenantId > 0) countPars.Add(new SugarParameter("@TenantId", tenantId));
  89. var rows = await _db.Ado.GetIntAsync(
  90. $"""
  91. SELECT COUNT(1) FROM qms_qcp_inspbill b
  92. WHERE b.tenant_id > 0{tenantClause}
  93. """, countPars);
  94. var insertSql =
  95. $"""
  96. INSERT INTO mdp_std_iqc_inspection
  97. (tenant_id, factory_id, source_system,
  98. iqc_bill_id, iqc_bill_no, application_id, application_no, application_entry_id,
  99. purchase_order_no, receipt_no,
  100. material_code, material_name, lot_serial, supplier_code, supplier_name,
  101. raw_pd, quality_result,
  102. flow_status, flow_instance_id, completion_time,
  103. source_row_id, source_biz_key, sync_batch_id, sync_time)
  104. SELECT
  105. b.tenant_id, 1, 'AIDOP_NATIVE',
  106. b.id,
  107. NULLIF(b.FBILLNO, ''),
  108. a.id,
  109. NULLIF(b.lydjbh, ''),
  110. e.id,
  111. -- 采购/收货身份只从 hid 解析出的那一条分录取,取不到就是 NULL
  112. NULLIF(e.purchase_order_no, ''),
  113. NULLIF(e.FSRCORDERNUM, ''),
  114. NULLIF(e.FMATERIALCFG, ''),
  115. NULLIF(e.wlmc, ''),
  116. NULLIF(e.FLOTNUMBER, ''),
  117. NULLIF(e.gysbm, ''),
  118. NULLIF(e.gysmc, ''),
  119. b.pd,
  120. -- 质量结论只由 pd 派生。pd 为 NULL 时必须是 PENDING,绝不能滑成 PASS
  121. CASE WHEN b.pd = 0 THEN 'PASS'
  122. WHEN b.pd = 1 THEN 'FAIL'
  123. ELSE 'PENDING' END,
  124. i.Status,
  125. i.Id,
  126. -- 只有流程走到终态(Approved)才认完成时间;Running 状态下不得投影 EndTime
  127. CASE WHEN i.Status = 2 THEN i.EndTime ELSE NULL END,
  128. CAST(b.id AS CHAR),
  129. NULLIF(b.FBILLNO, ''),
  130. @BatchId, @Now
  131. FROM qms_qcp_inspbill b
  132. -- 申请单:租户显式对齐(Domain 不是租户边界,不可用作关联依据)
  133. LEFT JOIN qms_qcp_inspecapplyn a
  134. ON a.tenant_id = b.tenant_id
  135. AND a.FBILLNO = b.lydjbh
  136. AND IFNULL(b.lydjbh, '') <> ''
  137. -- 报检分录:只认 hid 单值外键。不做「申请单下任取一条」的回退 —— 那会把多分录场景
  138. -- 静默变成随机挑一个 PO,是弱关联。
  139. LEFT JOIN qms_qcp_insappnentry e
  140. ON e.tenant_id = b.tenant_id
  141. AND e.id = b.hid
  142. AND b.hid IS NOT NULL
  143. -- 审批流实例:租户方向单向 —— 由已确定租户的检验单去查流程,绝不反向。
  144. -- 实测每个 BizId 至多一个实例,故无需 MAX(EndTime) 之类的挑选规则。
  145. LEFT JOIN ApprovalFlowInstance i
  146. ON i.BizType = @FlowBizType
  147. AND i.BizId = b.id
  148. WHERE b.tenant_id > 0{tenantClause}
  149. ON DUPLICATE KEY UPDATE
  150. factory_id=VALUES(factory_id), source_system=VALUES(source_system),
  151. iqc_bill_no=VALUES(iqc_bill_no),
  152. application_id=VALUES(application_id), application_no=VALUES(application_no),
  153. application_entry_id=VALUES(application_entry_id),
  154. purchase_order_no=VALUES(purchase_order_no), receipt_no=VALUES(receipt_no),
  155. material_code=VALUES(material_code), material_name=VALUES(material_name),
  156. lot_serial=VALUES(lot_serial), supplier_code=VALUES(supplier_code), supplier_name=VALUES(supplier_name),
  157. raw_pd=VALUES(raw_pd), quality_result=VALUES(quality_result),
  158. flow_status=VALUES(flow_status), flow_instance_id=VALUES(flow_instance_id),
  159. completion_time=VALUES(completion_time),
  160. source_biz_key=VALUES(source_biz_key),
  161. sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time),
  162. update_time=CURRENT_TIMESTAMP
  163. """;
  164. var insPars = new List<SugarParameter>
  165. {
  166. new("@BatchId", batchId),
  167. new("@Now", now),
  168. new("@FlowBizType", FlowBizType),
  169. };
  170. if (tenantId > 0) insPars.Add(new SugarParameter("@TenantId", tenantId));
  171. await _db.Ado.ExecuteCommandAsync(insertSql, insPars);
  172. return rows;
  173. }
  174. private async Task<long> InsertRunLogAsync(string batchId, DateTime startedAt, string triggerType)
  175. {
  176. await _db.Ado.ExecuteCommandAsync(
  177. """
  178. INSERT INTO mdp_transform_run_log
  179. (tenant_id, job_code, job_name, trigger_type, batch_id, status, start_time)
  180. VALUES (0, @JobCode, @JobName, @TriggerType, @BatchId, 'RUNNING', @StartTime)
  181. """,
  182. new SugarParameter("@JobCode", JobCode),
  183. new SugarParameter("@JobName", JobName),
  184. new SugarParameter("@TriggerType", NormalizeTriggerType(triggerType)),
  185. new SugarParameter("@BatchId", batchId),
  186. new SugarParameter("@StartTime", startedAt));
  187. return await _db.Ado.GetLongAsync(
  188. "SELECT id FROM mdp_transform_run_log WHERE batch_id=@BatchId ORDER BY id DESC LIMIT 1",
  189. new List<SugarParameter> { new("@BatchId", batchId) });
  190. }
  191. private async Task CompleteRunLogAsync(long runLogId, int stdRows, DateTime startedAt)
  192. {
  193. var endedAt = DateTime.Now;
  194. await _db.Ado.ExecuteCommandAsync(
  195. """
  196. UPDATE mdp_transform_run_log
  197. SET status='SUCCESS', standard_rows=@StdRows, end_time=@EndTime,
  198. duration_ms=@Duration, update_time=CURRENT_TIMESTAMP
  199. WHERE id=@Id
  200. """,
  201. new SugarParameter("@StdRows", stdRows),
  202. new SugarParameter("@EndTime", endedAt),
  203. new SugarParameter("@Duration", (int)(endedAt - startedAt).TotalMilliseconds),
  204. new SugarParameter("@Id", runLogId));
  205. }
  206. private async Task FailRunLogAsync(long runLogId, string message)
  207. {
  208. await _db.Ado.ExecuteCommandAsync(
  209. """
  210. UPDATE mdp_transform_run_log
  211. SET status='FAILED', end_time=@EndTime, error_message=@Msg, update_time=CURRENT_TIMESTAMP
  212. WHERE id=@Id
  213. """,
  214. new SugarParameter("@EndTime", DateTime.Now),
  215. new SugarParameter("@Msg", message.Length > 900 ? message[..900] : message),
  216. new SugarParameter("@Id", runLogId));
  217. }
  218. private static string NormalizeTriggerType(string triggerType)
  219. => string.IsNullOrWhiteSpace(triggerType) ? "AUTO" : triggerType.Trim().ToUpperInvariant();
  220. }
  221. /// <summary>S5 IQC 标准层投影结果。</summary>
  222. public sealed class IqcInspectionMdpSyncResult
  223. {
  224. public string BatchId { get; set; } = string.Empty;
  225. public long RunLogId { get; set; }
  226. /// <summary>本轮投影覆盖的检验单数(去重后的 Grain 行数,非 JOIN 扇出行数)。</summary>
  227. public int StdRows { get; set; }
  228. }