InventoryMdpSyncService.cs 48 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007
  1. using Admin.NET.Plugin.AiDOP.DataPlatform;
  2. using Admin.NET.Plugin.AiDOP.DataPlatform.Schema;
  3. using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
  4. using Admin.NET.Plugin.AiDOP.Infrastructure;
  5. using Microsoft.Extensions.Logging;
  6. using Microsoft.Extensions.Options;
  7. using SqlSugar;
  8. namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
  9. /// <summary>
  10. /// S5 库存冷链:165 LocationDetail / InvTransHist → stg → std。
  11. /// 余额事实源仅 LocationDetail;InvMaster 不入标准层。
  12. /// </summary>
  13. public sealed class InventoryMdpSyncService : ITransient
  14. {
  15. /// <summary>跨实例互斥锁键;真正的取/放锁生命周期见 <see cref="InventoryInboundLockGuard"/>。</summary>
  16. public const string LockKey = InventoryInboundLockGuard.LockKey;
  17. private const string LocationEntity = "S5_LOCATION_DETAIL_SQLSERVER";
  18. private const string TransEntity = "S5_INV_TRANS_HIST_SQLSERVER";
  19. private const string SourceCodeDefault = "DOPDEMORQ_SQLSERVER";
  20. private readonly ISqlSugarClient _db;
  21. private readonly MdpSourcePullDispatcher _pullDispatcher;
  22. private readonly MdpSourceScopeFactory _scopeFactory;
  23. private readonly SourceDomainTenantResolver _domainTenant;
  24. private readonly AidopInventoryOptions _opt;
  25. private readonly ILogger _logger;
  26. public InventoryMdpSyncService(
  27. ISqlSugarClient db,
  28. MdpSourcePullDispatcher pullDispatcher,
  29. MdpSourceScopeFactory scopeFactory,
  30. SourceDomainTenantResolver domainTenant,
  31. IOptions<AidopInventoryOptions> opt,
  32. ILoggerFactory loggerFactory)
  33. {
  34. _db = db;
  35. _pullDispatcher = pullDispatcher;
  36. _scopeFactory = scopeFactory;
  37. _domainTenant = domainTenant;
  38. _opt = opt.Value;
  39. _logger = loggerFactory.CreateLogger(nameof(InventoryMdpSyncService));
  40. }
  41. public Task<InventorySyncResult> RunBootstrapAsync(CancellationToken cancellationToken = default)
  42. => RunAsync(bootstrap: true, reconcile: false, cancellationToken);
  43. public Task<InventorySyncResult> RunIncrementalAsync(CancellationToken cancellationToken = default)
  44. => RunAsync(bootstrap: false, reconcile: false, cancellationToken);
  45. public Task<InventorySyncResult> RunReconcileFullAsync(CancellationToken cancellationToken = default)
  46. => RunAsync(bootstrap: false, reconcile: true, cancellationToken);
  47. /// <summary>
  48. /// 仅 stg→std:**不访问源库**。用于链路修复后,复用已完整落地的贴源层让各业务租户
  49. /// 按当前库位范围口径重新物化标准层。
  50. /// <para>
  51. /// 语义为 <b>FULL REPLACE</b>(逐租户、逐正式切片):贴源层是完整历史,
  52. /// 只跑 UPSERT 既清不掉旧口径残留、也补不齐从未物化过的租户。
  53. /// 删除范围严格限定「该租户 + 当前正式 source_system + 当前 domain」,
  54. /// 不触碰 UAT_GENERATOR 等非正式切片。
  55. /// </para>
  56. /// </summary>
  57. public async Task<InventorySyncResult> TransformTransStdFromStgAsync(CancellationToken cancellationToken = default)
  58. {
  59. cancellationToken.ThrowIfCancellationRequested();
  60. var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? SourceCodeDefault : _opt.SourceCode.Trim();
  61. var domain = string.IsNullOrWhiteSpace(_opt.DefaultDomain) ? "8010" : _opt.DefaultDomain.Trim();
  62. var tenantId = await _domainTenant.ResolveTenantIdAsync(sourceCode, domain, cancellationToken);
  63. var asOf = DateTime.Now;
  64. var months = _opt.TransBootstrapMonths <= 0 ? 12 : _opt.TransBootstrapMonths;
  65. var historyFrom = asOf.Date.AddMonths(-months);
  66. var batchId = $"S5_INV_XFORM_{asOf:yyyyMMddHHmmss}";
  67. // await using:异常路径也一定走到释放(DisposeAsync 内先 RELEASE_LOCK 再关专用连接)
  68. await using var guard = await InventoryInboundLockGuard.TryAcquireAsync(_db, _logger, cancellationToken: cancellationToken);
  69. if (!guard.Acquired)
  70. {
  71. _logger.LogWarning("[InventoryMdpSync] 互斥锁占用,transform-std 本轮跳过 batch={Batch} reason={Reason}",
  72. batchId, guard.BusyReason);
  73. return new InventorySyncResult
  74. {
  75. BatchId = batchId,
  76. TenantId = tenantId,
  77. Domain = domain,
  78. AsOf = asOf,
  79. HistoryFrom = historyFrom,
  80. Skipped = true,
  81. Message = "lock busy"
  82. };
  83. }
  84. var targetTenants = await ListInventoryScopedTenantsAsync(domain, cancellationToken);
  85. if (targetTenants.Count == 0)
  86. {
  87. // fail closed:没有任何配了合法库位的租户时不写标准层,绝不回落成「按源归属租户写一份」
  88. _logger.LogWarning(
  89. "[InventoryMdpSync] domain={Domain} 无任何配置了合法库位的租户,transform-std 本轮不写入", domain);
  90. return new InventorySyncResult
  91. {
  92. BatchId = batchId,
  93. TenantId = tenantId,
  94. Domain = domain,
  95. TransStdRows = 0,
  96. AsOf = asOf,
  97. HistoryFrom = historyFrom,
  98. Skipped = true,
  99. Message = "no scoped tenant"
  100. };
  101. }
  102. using var xformTimeout = WithLongCommandTimeout();
  103. var rows = 0;
  104. foreach (var targetTenantId in targetTenants)
  105. {
  106. cancellationToken.ThrowIfCancellationRequested();
  107. var n = await MdpStdFullReplace.ReplaceAsync(
  108. _db,
  109. "mdp_std_inv_trans",
  110. targetTenantId,
  111. FormalSliceWhere(sourceCode, domain),
  112. () => MaterializeInvTransStdAsync(
  113. tenantId, targetTenantId, batchId: null, asOf, historyFrom, sourceCode),
  114. cancellationToken);
  115. rows += n;
  116. _logger.LogInformation(
  117. "[InventoryMdpSync] transform-std materialized tenant={Tenant} domain={Domain} rows={Rows}",
  118. targetTenantId, domain, n);
  119. }
  120. _logger.LogInformation(
  121. "[InventoryMdpSync] transform-std done source={Source} tenants={Cnt} rows={Rows}",
  122. tenantId, targetTenants.Count, rows);
  123. return new InventorySyncResult
  124. {
  125. BatchId = batchId,
  126. TenantId = tenantId,
  127. Domain = domain,
  128. TransStdRows = rows,
  129. AsOf = asOf,
  130. HistoryFrom = historyFrom,
  131. Message = "OK transform-std"
  132. };
  133. }
  134. /// <summary>
  135. /// 仅 stg→std(库存余额):**不访问源库**,从一个已完整落地的贴源批次重新物化标准层。
  136. /// <para>
  137. /// 用途:同步链路修复后,复用既有完整 stg 批次让各租户按新的库位范围口径重新物化,
  138. /// 避免为此重新全量拉取源库。
  139. /// </para>
  140. /// <para>
  141. /// 语义为 <b>UPSERT,不做 FULL REPLACE</b>:只按业务键写入/更新本批次覆盖到的行,
  142. /// 不删除任何既有标准层数据 —— 单个增量批次不代表全量,replace 会造成数据丢失。
  143. /// 因此本入口<b>不负责</b>清理历史脏快照,那属于全量校准(reconcile)的职责。
  144. /// </para>
  145. /// </summary>
  146. /// <param name="stgBatchId">贴源批次号(sync_batch_id),必须是已完整落地的批次。</param>
  147. public async Task<InventorySyncResult> TransformInventoryStdFromStgAsync(
  148. string stgBatchId, CancellationToken cancellationToken = default)
  149. {
  150. cancellationToken.ThrowIfCancellationRequested();
  151. if (string.IsNullOrWhiteSpace(stgBatchId)
  152. || !System.Text.RegularExpressions.Regex.IsMatch(stgBatchId, @"^[A-Za-z0-9_]+$"))
  153. throw new InvalidOperationException("非法贴源批次号");
  154. var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? SourceCodeDefault : _opt.SourceCode.Trim();
  155. var domain = string.IsNullOrWhiteSpace(_opt.DefaultDomain) ? "8010" : _opt.DefaultDomain.Trim();
  156. var sourceTenantId = await _domainTenant.ResolveTenantIdAsync(sourceCode, domain, cancellationToken);
  157. var asOf = DateTime.Now;
  158. // 本入口同样写 mdp_std_inventory,必须与 bootstrap/reconcile/incremental 互斥(原实现漏取锁)
  159. await using var guard = await InventoryInboundLockGuard.TryAcquireAsync(_db, _logger, cancellationToken: cancellationToken);
  160. if (!guard.Acquired)
  161. {
  162. _logger.LogWarning("[InventoryMdpSync] 互斥锁占用,transform-inventory-std 本轮跳过 batch={Batch} reason={Reason}",
  163. stgBatchId, guard.BusyReason);
  164. return new InventorySyncResult
  165. {
  166. BatchId = stgBatchId,
  167. TenantId = sourceTenantId,
  168. Domain = domain,
  169. AsOf = asOf,
  170. Skipped = true,
  171. Message = "lock busy"
  172. };
  173. }
  174. var stgRows = await _db.Ado.GetIntAsync(
  175. """
  176. SELECT COUNT(1) FROM mdp_stg_inventory
  177. WHERE tenant_id=@TenantId AND source_system=@SourceSystem
  178. AND source_table='LocationDetail' AND sync_batch_id=@BatchId
  179. """,
  180. new List<SugarParameter>
  181. {
  182. new("@TenantId", sourceTenantId),
  183. new("@SourceSystem", sourceCode),
  184. new("@BatchId", stgBatchId)
  185. });
  186. if (stgRows == 0)
  187. throw new InvalidOperationException(
  188. $"贴源批次为空或不属于源归属租户:batch={stgBatchId}, sourceTenant={sourceTenantId}");
  189. var targetTenants = await ListInventoryScopedTenantsAsync(domain, cancellationToken);
  190. var total = 0;
  191. foreach (var targetTenantId in targetTenants)
  192. {
  193. cancellationToken.ThrowIfCancellationRequested();
  194. var rows = await InsertInventoryStdAsync(
  195. targetTenantId, sourceTenantId, stgBatchId, asOf, sourceCode, replaceMode: false);
  196. total += rows;
  197. _logger.LogInformation(
  198. "[InventoryMdpSync] std re-materialized from stg tenant={Tenant} domain={Domain} batch={Batch} rows={Rows}",
  199. targetTenantId, domain, stgBatchId, rows);
  200. }
  201. return new InventorySyncResult
  202. {
  203. BatchId = stgBatchId,
  204. TenantId = sourceTenantId,
  205. Domain = domain,
  206. InventoryStdRows = total,
  207. AsOf = asOf,
  208. Message = $"OK transform-inventory-std from stg (stgRows={stgRows}, tenants={targetTenants.Count})"
  209. };
  210. }
  211. /// <summary>
  212. /// 将指定租户已存在的全部库存交易贴源批次转换到标准层。
  213. /// 供租户级模块重算使用,不拉外部源,也不依赖默认 Domain→Tenant 映射。
  214. /// </summary>
  215. public async Task<int> TransformTransStdFromStgAsync(
  216. long tenantId, CancellationToken cancellationToken = default)
  217. {
  218. if (tenantId <= 0) throw new ArgumentOutOfRangeException(nameof(tenantId));
  219. cancellationToken.ThrowIfCancellationRequested();
  220. var sources = await _db.Ado.SqlQueryAsync<InventoryStageSourceRow>(
  221. """
  222. SELECT DISTINCT source_system AS SourceSystem
  223. FROM mdp_stg_inv_trans
  224. WHERE tenant_id=@TenantId
  225. AND source_table='InvTransHist'
  226. AND NULLIF(TRIM(source_system),'') IS NOT NULL
  227. """,
  228. new SugarParameter("@TenantId", tenantId));
  229. var asOf = DateTime.Now;
  230. var months = _opt.TransBootstrapMonths <= 0 ? 12 : _opt.TransBootstrapMonths;
  231. var historyFrom = asOf.Date.AddMonths(-months);
  232. var affected = 0;
  233. foreach (var source in sources)
  234. {
  235. cancellationToken.ThrowIfCancellationRequested();
  236. if (string.IsNullOrWhiteSpace(source.SourceSystem)) continue;
  237. affected += await MaterializeInvTransStdAsync(
  238. tenantId, tenantId, batchId: null, asOf, historyFrom, source.SourceSystem);
  239. }
  240. return affected;
  241. }
  242. private async Task<InventorySyncResult> RunAsync(bool bootstrap, bool reconcile, CancellationToken cancellationToken)
  243. {
  244. cancellationToken.ThrowIfCancellationRequested();
  245. var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? SourceCodeDefault : _opt.SourceCode.Trim();
  246. var domain = string.IsNullOrWhiteSpace(_opt.DefaultDomain) ? "8010" : _opt.DefaultDomain.Trim();
  247. // 源归属租户:只决定贴源层(stg)落在谁名下,**不代表业务归属**;
  248. // 业务归属在标准层物化时按各租户 LocationMaster 库位范围投影决定。
  249. var tenantId = await _domainTenant.ResolveTenantIdAsync(sourceCode, domain, cancellationToken);
  250. var asOf = DateTime.Now;
  251. var batchId = $"S5_INV_{(bootstrap ? "BOOT" : reconcile ? "RECON" : "INCR")}_{asOf:yyyyMMddHHmmss}";
  252. var months = _opt.TransBootstrapMonths <= 0 ? 12 : _opt.TransBootstrapMonths;
  253. var historyFrom = asOf.Date.AddMonths(-months);
  254. // await using:异常路径也一定走到释放(DisposeAsync 内先 RELEASE_LOCK 再关专用连接)
  255. await using var guard = await InventoryInboundLockGuard.TryAcquireAsync(_db, _logger, cancellationToken: cancellationToken);
  256. if (!guard.Acquired)
  257. {
  258. _logger.LogWarning("[InventoryMdpSync] 跨实例锁占用,本轮跳过 batch={Batch} reason={Reason}", batchId, guard.BusyReason);
  259. return new InventorySyncResult
  260. {
  261. BatchId = batchId,
  262. TenantId = tenantId,
  263. Domain = domain,
  264. Bootstrap = bootstrap,
  265. AsOf = asOf,
  266. HistoryFrom = historyFrom,
  267. Skipped = true,
  268. Message = "lock busy"
  269. };
  270. }
  271. var upperLoc = await CaptureUpperBoundAsync(sourceCode, "LocationDetail", "UpdateTime", cancellationToken);
  272. var upperTrans = await CaptureUpperBoundAsync(sourceCode, "InvTransHist", "CreateTime", cancellationToken);
  273. MdpPullResult locPull;
  274. if (bootstrap || reconcile)
  275. {
  276. var locBatch = $"{batchId}_LOC";
  277. // NULL UpdateTime 段与非 NULL 段共用同一 batchId,保证 Replace 不漏 NULL 行
  278. var nullCtx = BuildKeysetCtx(tenantId, locBatch, asOf, historyFrom, upperLoc,
  279. cursorColumn: "UpdateTime", nullPhase: true, bootstrapFull: true);
  280. nullCtx.CursorValue = null;
  281. nullCtx.TieBreakerValue = null;
  282. await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, nullCtx, cancellationToken, maxPages: 50);
  283. var fullCtx = BuildKeysetCtx(tenantId, locBatch, asOf, historyFrom, upperLoc,
  284. cursorColumn: "UpdateTime", nullPhase: false, bootstrapFull: true);
  285. fullCtx.CursorValue = null;
  286. fullCtx.TieBreakerValue = null;
  287. fullCtx.BootstrapFrom = null; // LocationDetail 首刷不截时间窗
  288. locPull = await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, fullCtx, cancellationToken, maxPages: 200);
  289. }
  290. else
  291. {
  292. var incrCtx = BuildKeysetCtx(tenantId, $"{batchId}_LOC", asOf, historyFrom, upperLoc,
  293. cursorColumn: "UpdateTime", nullPhase: false, bootstrapFull: false);
  294. // 重叠窗口:从上次游标时间向前回退 LocationOverlapMinutes
  295. if (!string.IsNullOrWhiteSpace(incrCtx.CursorValue)
  296. && DateTime.TryParse(incrCtx.CursorValue, out var lastDt))
  297. {
  298. var overlap = Math.Max(0, _opt.LocationOverlapMinutes);
  299. incrCtx.CursorValue = lastDt.AddMinutes(-overlap)
  300. .ToString("yyyy-MM-dd HH:mm:ss.fff");
  301. incrCtx.TieBreakerValue = "0";
  302. }
  303. locPull = await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, incrCtx, cancellationToken, maxPages: 200);
  304. }
  305. // —— 标准层按租户物化:一次贴源,逐租户按各自库位范围投影 ——
  306. // 贴源层是「源+domain」维度(归属 sourceTenantId),标准层是「租户」维度。
  307. // 拉取游标持久化在 mdp_entity 上、跨租户共享,故绝不能为每个租户各拉一次。
  308. var targetTenants = await ListInventoryScopedTenantsAsync(domain, cancellationToken);
  309. if (targetTenants.Count == 0)
  310. _logger.LogWarning(
  311. "[InventoryMdpSync] domain={Domain} 无任何配置了合法库位的租户,标准层本轮不写入", domain);
  312. var inventoryStdRows = 0;
  313. foreach (var targetTenantId in targetTenants)
  314. {
  315. cancellationToken.ThrowIfCancellationRequested();
  316. int rows;
  317. if (bootstrap || reconcile)
  318. {
  319. rows = await MdpStdFullReplace.ReplaceAsync(
  320. _db,
  321. "mdp_std_inventory",
  322. targetTenantId,
  323. "source_system='DOPDEMORQ_SQLSERVER'",
  324. () => InsertInventoryStdAsync(targetTenantId, tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: true),
  325. cancellationToken);
  326. }
  327. else
  328. {
  329. rows = await InsertInventoryStdAsync(targetTenantId, tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: false);
  330. }
  331. inventoryStdRows += rows;
  332. _logger.LogInformation(
  333. "[InventoryMdpSync] std materialized tenant={Tenant} domain={Domain} rows={Rows}",
  334. targetTenantId, domain, rows);
  335. }
  336. var transCtx = BuildKeysetCtx(tenantId, $"{batchId}_TRN", asOf, historyFrom, upperTrans,
  337. cursorColumn: "CreateTime", nullPhase: false, bootstrapFull: bootstrap || reconcile);
  338. if (bootstrap || reconcile)
  339. {
  340. transCtx.CursorValue = null;
  341. transCtx.TieBreakerValue = null;
  342. transCtx.BootstrapFrom = historyFrom;
  343. }
  344. var transPull = await _pullDispatcher.PullAllByEntityCodeAsync(TransEntity, transCtx, cancellationToken, maxPages: 500);
  345. // —— 流水腿与余额腿同构:一次贴源,逐业务租户按各自库位范围投影 ——
  346. using var transTimeout = WithLongCommandTimeout();
  347. var transStdRows = 0;
  348. foreach (var targetTenantId in targetTenants)
  349. {
  350. cancellationToken.ThrowIfCancellationRequested();
  351. int rows;
  352. if (bootstrap || reconcile)
  353. {
  354. rows = await MdpStdFullReplace.ReplaceAsync(
  355. _db,
  356. "mdp_std_inv_trans",
  357. targetTenantId,
  358. FormalSliceWhere(sourceCode, domain),
  359. () => MaterializeInvTransStdAsync(
  360. tenantId, targetTenantId, batchId: null, asOf, historyFrom, sourceCode),
  361. cancellationToken);
  362. }
  363. else
  364. {
  365. rows = await MaterializeInvTransStdAsync(
  366. tenantId, targetTenantId, $"{batchId}_TRN", asOf, historyFrom, sourceCode);
  367. }
  368. transStdRows += rows;
  369. _logger.LogInformation(
  370. "[InventoryMdpSync] trans std materialized tenant={Tenant} domain={Domain} rows={Rows}",
  371. targetTenantId, domain, rows);
  372. }
  373. _logger.LogInformation(
  374. "[InventoryMdpSync] done batch={Batch} boot={Boot} recon={Recon} locPulled={LocP} invStd={Inv} trnPulled={TrnP} trnStd={Trn}",
  375. batchId, bootstrap, reconcile, locPull.RowsPulled, inventoryStdRows, transPull.RowsPulled, transStdRows);
  376. return new InventorySyncResult
  377. {
  378. BatchId = batchId,
  379. TenantId = tenantId,
  380. Domain = domain,
  381. Bootstrap = bootstrap,
  382. LocationPulled = locPull.RowsPulled,
  383. LocationWritten = locPull.RowsWritten,
  384. InventoryStdRows = inventoryStdRows,
  385. TransPulled = transPull.RowsPulled,
  386. TransWritten = transPull.RowsWritten,
  387. TransStdRows = transStdRows,
  388. AsOf = asOf,
  389. HistoryFrom = historyFrom,
  390. Message = "OK"
  391. };
  392. }
  393. private MdpPullContext BuildKeysetCtx(
  394. long tenantId,
  395. string batchId,
  396. DateTime asOf,
  397. DateTime historyFrom,
  398. (string? Cursor, string? Tie) upper,
  399. string cursorColumn,
  400. bool nullPhase,
  401. bool bootstrapFull)
  402. {
  403. return new MdpPullContext
  404. {
  405. TenantId = tenantId,
  406. BatchId = batchId,
  407. FullRefresh = false,
  408. UseKeysetCursor = true,
  409. CursorColumn = cursorColumn,
  410. TieBreakerColumn = "RecID",
  411. UpperCursorValue = upper.Cursor,
  412. UpperTieBreakerValue = upper.Tie,
  413. BootstrapFrom = bootstrapFull && cursorColumn == "CreateTime" ? historyFrom : null,
  414. DeferCursorPersist = false,
  415. NullTimePhase = nullPhase,
  416. // 首刷/校准不得继承实体脏水位,否则只会抽到「游标之后」的尾巴
  417. SkipPersistedKeysetCursor = bootstrapFull || nullPhase
  418. };
  419. }
  420. private async Task<(string? Cursor, string? Tie)> CaptureUpperBoundAsync(
  421. string sourceCode,
  422. string table,
  423. string cursorColumn,
  424. CancellationToken ct)
  425. {
  426. if (!System.Text.RegularExpressions.Regex.IsMatch(table, @"^[A-Za-z0-9_]+$")
  427. || !System.Text.RegularExpressions.Regex.IsMatch(cursorColumn, @"^[A-Za-z0-9_]+$"))
  428. throw new InvalidOperationException("非法上界查询标识符");
  429. var remote = await _scopeFactory.GetScopeAsync(sourceCode, ct);
  430. var rows = await remote.Ado.SqlQueryAsync<UpperRow>(
  431. $"""
  432. SELECT TOP 1
  433. CONVERT(varchar(30), {cursorColumn}, 121) AS CursorText,
  434. CAST(RecID AS varchar(30)) AS TieText
  435. FROM {table}
  436. WHERE {cursorColumn} IS NOT NULL
  437. ORDER BY {cursorColumn} DESC, RecID DESC
  438. """);
  439. var hit = rows.FirstOrDefault();
  440. return (hit?.CursorText, hit?.TieText);
  441. }
  442. /// <summary>
  443. /// stg → std 物化:**按目标租户的合法库位范围投影**。
  444. /// <para>
  445. /// 贴源层(stg)是「源 + domain」维度的全量落地区,归属 <paramref name="sourceTenantId"/>;
  446. /// 标准层(std)是「租户」维度的可见快照,因此这里必须内联 LocationMaster 做投影:
  447. /// 只有落在目标租户自己 LocationMaster(同 Domain 且 Typed &lt;&gt; 'Supp')内的库位才写入。
  448. /// </para>
  449. /// <para>
  450. /// 写入不变量:∀ 写入行 → tenant_id = targetTenantId
  451. /// ∧ location ∈ AllowedLocations(targetTenantId) ∧ domain = 该租户 LocationMaster 的 Domain。
  452. /// 租户白名单为空 → JOIN 命中 0 行 → 写 0 条(fail closed,绝不退回整个 Domain)。
  453. /// </para>
  454. /// <para>
  455. /// tenant_id 直接取 <paramref name="targetTenantId"/> 而非 MdpJsonSql.TenantFromStg:
  456. /// 贴源行的 tenant 是「源落地区归属」,不是业务归属,不能顺着传下来。
  457. /// </para>
  458. /// </summary>
  459. private async Task<int> InsertInventoryStdAsync(
  460. long targetTenantId, long sourceTenantId, string batchId, DateTime asOf, string sourceSystem, bool replaceMode)
  461. {
  462. // replaceMode:MdpStdFullReplace 已 DELETE,直接 INSERT;
  463. // 增量:UPSERT 本批变化。
  464. var sTenant = "@TargetTenantId";
  465. var sql = replaceMode
  466. ? $"""
  467. INSERT INTO mdp_std_inventory
  468. (tenant_id, source_system, domain, location, lot_serial, item_num,
  469. dimension1, dimension2, refs, site, inv_status,
  470. qty_on_hand, qty_unrestricted, qty_inspection, qty_frozen, qty_available,
  471. src_rec_id, source_update_time, as_of, sync_batch_id)
  472. SELECT
  473. {sTenant},
  474. @SourceSystem,
  475. IFNULL({MdpJsonSql.Str("s", "Domain")}, ''),
  476. IFNULL({MdpJsonSql.Str("s", "Location")}, ''),
  477. IFNULL({MdpJsonSql.Str("s", "LotSerial")}, ''),
  478. IFNULL({MdpJsonSql.Str("s", "ItemNum")}, ''),
  479. IFNULL({MdpJsonSql.Str("s", "Dimension1")}, ''),
  480. IFNULL({MdpJsonSql.Str("s", "Dimension2")}, ''),
  481. IFNULL({MdpJsonSql.Str("s", "Refs")}, ''),
  482. IFNULL({MdpJsonSql.Str("s", "Site")}, ''),
  483. {MdpJsonSql.Str("s", "InvStatus")},
  484. IFNULL({MdpJsonSql.Dec("s", "QtyOnHand", 18, 5)}, 0),
  485. IFNULL({MdpJsonSql.Dec("s", "AvailStatusQty", 18, 5)}, 0),
  486. IFNULL({MdpJsonSql.Dec("s", "Assay", 18, 5)}, 0),
  487. IFNULL({MdpJsonSql.Dec("s", "FreezeQty", 18, 5)}, 0),
  488. IFNULL({MdpJsonSql.Dec("s", "AvailStatusQty", 18, 5)}, 0),
  489. s.source_row_id,
  490. {MdpJsonSql.DateTimeSec("s", "UpdateTime")},
  491. @AsOf,
  492. @BatchId
  493. FROM mdp_stg_inventory s
  494. {TenantScopeJoin}
  495. WHERE s.tenant_id=@SourceTenantId
  496. AND s.source_system=@SourceSystem
  497. AND s.source_table='LocationDetail'
  498. AND s.sync_batch_id=@BatchId
  499. """
  500. : $"""
  501. INSERT INTO mdp_std_inventory
  502. (tenant_id, source_system, domain, location, lot_serial, item_num,
  503. dimension1, dimension2, refs, site, inv_status,
  504. qty_on_hand, qty_unrestricted, qty_inspection, qty_frozen, qty_available,
  505. src_rec_id, source_update_time, as_of, sync_batch_id)
  506. SELECT
  507. {sTenant},
  508. @SourceSystem,
  509. IFNULL({MdpJsonSql.Str("s", "Domain")}, ''),
  510. IFNULL({MdpJsonSql.Str("s", "Location")}, ''),
  511. IFNULL({MdpJsonSql.Str("s", "LotSerial")}, ''),
  512. IFNULL({MdpJsonSql.Str("s", "ItemNum")}, ''),
  513. IFNULL({MdpJsonSql.Str("s", "Dimension1")}, ''),
  514. IFNULL({MdpJsonSql.Str("s", "Dimension2")}, ''),
  515. IFNULL({MdpJsonSql.Str("s", "Refs")}, ''),
  516. IFNULL({MdpJsonSql.Str("s", "Site")}, ''),
  517. {MdpJsonSql.Str("s", "InvStatus")},
  518. IFNULL({MdpJsonSql.Dec("s", "QtyOnHand", 18, 5)}, 0),
  519. IFNULL({MdpJsonSql.Dec("s", "AvailStatusQty", 18, 5)}, 0),
  520. IFNULL({MdpJsonSql.Dec("s", "Assay", 18, 5)}, 0),
  521. IFNULL({MdpJsonSql.Dec("s", "FreezeQty", 18, 5)}, 0),
  522. IFNULL({MdpJsonSql.Dec("s", "AvailStatusQty", 18, 5)}, 0),
  523. s.source_row_id,
  524. {MdpJsonSql.DateTimeSec("s", "UpdateTime")},
  525. @AsOf,
  526. @BatchId
  527. FROM mdp_stg_inventory s
  528. {TenantScopeJoin}
  529. WHERE s.tenant_id=@SourceTenantId
  530. AND s.source_system=@SourceSystem
  531. AND s.source_table='LocationDetail'
  532. AND s.sync_batch_id=@BatchId
  533. ON DUPLICATE KEY UPDATE
  534. inv_status=VALUES(inv_status),
  535. qty_on_hand=VALUES(qty_on_hand),
  536. qty_unrestricted=VALUES(qty_unrestricted),
  537. qty_inspection=VALUES(qty_inspection),
  538. qty_frozen=VALUES(qty_frozen),
  539. qty_available=VALUES(qty_available),
  540. src_rec_id=VALUES(src_rec_id),
  541. source_update_time=VALUES(source_update_time),
  542. as_of=VALUES(as_of),
  543. sync_batch_id=VALUES(sync_batch_id),
  544. update_time=CURRENT_TIMESTAMP
  545. """;
  546. return await _db.Ado.ExecuteCommandAsync(sql,
  547. new SugarParameter("@TargetTenantId", targetTenantId),
  548. new SugarParameter("@SourceTenantId", sourceTenantId),
  549. new SugarParameter("@SourceSystem", sourceSystem),
  550. new SugarParameter("@BatchId", batchId),
  551. new SugarParameter("@AsOf", asOf));
  552. }
  553. /// <summary>
  554. /// 租户库位范围内联投影:贴源行只有落在目标租户自己的合法库位(同 Domain、Typed &lt;&gt; 'Supp')
  555. /// 才允许进入标准层。这是标准层写入侧的租户安全边界。
  556. /// <para>
  557. /// **余额腿与流水腿共用本实现**,只有贴源 JSON 里的库位字段名不同
  558. /// (LocationDetail 用 <c>Location</c>,InvTransHist 用 <c>Loc</c>)。
  559. /// 禁止再手写第二套语义略有差异的 LocationMaster JOIN。
  560. /// </para>
  561. /// </summary>
  562. /// <param name="locationJsonKey">贴源 raw_data 中的库位字段名。</param>
  563. private static string TenantScopeJoinSql(string locationJsonKey) =>
  564. $"""
  565. INNER JOIN LocationMaster lm
  566. ON lm.tenant_id = @TargetTenantId
  567. AND lm.Domain = IFNULL({MdpJsonSql.Str("s", "Domain")}, '')
  568. AND lm.location = IFNULL({MdpJsonSql.Str("s", locationJsonKey)}, '')
  569. AND IFNULL(lm.typed, '') <> 'Supp'
  570. AND TRIM(lm.location) <> ''
  571. """;
  572. /// <summary>库存余额贴源的库位字段名。</summary>
  573. private static readonly string TenantScopeJoin = TenantScopeJoinSql("Location");
  574. /// <summary>进出存流水贴源的库位字段名(InvTransHist 用 Loc)。</summary>
  575. private static readonly string TransTenantScopeJoin = TenantScopeJoinSql("Loc");
  576. /// <summary>
  577. /// 库位角色左联:165 的事务码(iss-tr / rct-tr / rct-wo)单看码判不出阶段,
  578. /// 必须结合库位角色与数量方向,见 <see cref="NeutralTransTypeCodes.LocationRules"/>。
  579. /// 角色配置缺失时取 UNKNOWN —— 规则一律不成立,流水按「库位角色未配置」隔离,不猜。
  580. /// </summary>
  581. private static readonly string LocationRoleJoin =
  582. $"""
  583. LEFT JOIN mdp_location_role lr
  584. ON lr.tenant_id = @TargetTenantId
  585. AND lr.domain = IFNULL({MdpJsonSql.Str("s", "Domain")}, '')
  586. AND lr.location = IFNULL({MdpJsonSql.Str("s", "Loc")}, '')
  587. """;
  588. /// <summary>库位角色表达式(未配置 → UNKNOWN)。</summary>
  589. private static readonly string LocationRoleExpr =
  590. $"IFNULL(lr.location_role, '{NeutralTransTypeCodes.UnknownRole}')";
  591. /// <summary>
  592. /// FULL Replace 的删除范围:**只替换「当前租户 + 当前正式源 + 当前 domain」这一份物化投影**。
  593. /// 绝不能只按 tenant_id 删 —— 那会连带删掉其它来源切片。
  594. /// UAT 名下的 UAT_GENERATOR 演示行由 1.0.565 清理,不在这次物化删除范围里。
  595. /// sourceSystem/domain 均来自服务端配置(<c>AidopInventoryOptions</c>),非用户输入;
  596. /// 仍做标识符白名单校验,杜绝任何拼接注入。
  597. /// </summary>
  598. /// <summary>
  599. /// 标准层物化的命令超时(秒)。默认 30s 不够:正式切片 FULL REPLACE 单次要
  600. /// DELETE 40 万+ 行再 INSERT ... SELECT 40 万+ 行,实测 DELETE 一步就超时,
  601. /// 且超时后连接已断、连 ROLLBACK 都会抛 "Connection must be Open" 掩盖真正的超时异常。
  602. /// 用 <see cref="WithLongCommandTimeout"/> 在物化区间内临时放宽、退出时还原。
  603. /// </summary>
  604. private const int MaterializeCommandTimeoutSeconds = 900;
  605. /// <summary>物化区间内临时放宽命令超时,Dispose 时还原原值(异常路径同样还原)。</summary>
  606. private sealed class LongCommandTimeoutScope : IDisposable
  607. {
  608. private readonly ISqlSugarClient _db;
  609. private readonly int _original;
  610. public LongCommandTimeoutScope(ISqlSugarClient db, int seconds)
  611. {
  612. _db = db;
  613. _original = db.Ado.CommandTimeOut;
  614. db.Ado.CommandTimeOut = seconds;
  615. }
  616. public void Dispose() => _db.Ado.CommandTimeOut = _original;
  617. }
  618. private LongCommandTimeoutScope WithLongCommandTimeout()
  619. => new(_db, MaterializeCommandTimeoutSeconds);
  620. private static string FormalSliceWhere(string sourceSystem, string domain)
  621. {
  622. if (!System.Text.RegularExpressions.Regex.IsMatch(sourceSystem, "^[A-Za-z0-9_]+$"))
  623. throw new InvalidOperationException($"非法 source_system:{sourceSystem}");
  624. if (!System.Text.RegularExpressions.Regex.IsMatch(domain, "^[A-Za-z0-9_]+$"))
  625. throw new InvalidOperationException($"非法 domain:{domain}");
  626. return $"source_system='{sourceSystem}' AND domain='{domain}'";
  627. }
  628. /// <summary>
  629. /// 枚举该 domain 下**拥有合法库存范围**的租户:即在 LocationMaster 里配了非 Supp 库位的启用租户。
  630. /// 没有库位范围的租户(如默认租户)不会被物化,标准层里不会出现它的快照。
  631. /// </summary>
  632. private async Task<List<long>> ListInventoryScopedTenantsAsync(string domain, CancellationToken ct)
  633. {
  634. return await _db.Ado.SqlQueryAsync<long>(
  635. """
  636. SELECT DISTINCT lm.tenant_id
  637. FROM LocationMaster lm
  638. JOIN SysTenant t ON t.Id = lm.tenant_id AND t.Status = 1
  639. WHERE lm.Domain = @Domain
  640. AND IFNULL(lm.typed,'') <> 'Supp'
  641. AND TRIM(lm.location) <> ''
  642. ORDER BY lm.tenant_id
  643. """,
  644. new List<SugarParameter> { new("@Domain", domain) });
  645. }
  646. /// <summary>
  647. /// 进出存流水 stg→std **按业务租户物化**:一次贴源、逐目标租户按各自库位范围投影。
  648. /// <para>
  649. /// 修复前本方法只有一个 <c>tenantId</c> 参数,把「源归属租户」当成了「业务租户」:
  650. /// <c>ado_source_domain_tenant_map</c> 把 DOPDEMORQ_SQLSERVER/8010 登记在 797 名下,
  651. /// 于是全部流水标准层都写 797,UAT(838257186181189) 名下恒为 0 —— 而正式贴源层里
  652. /// 落在 UAT 18 个合法库位上的流水实测有 188,419 行。余额腿(InsertInventoryStdAsync)
  653. /// 早已是「逐租户 + TenantScopeJoin」,本方法此前漏了这一步,本次对齐。
  654. /// </para>
  655. /// </summary>
  656. /// <param name="sourceTenantId">贴源层归属租户(决定读哪批 stg),非业务归属。</param>
  657. /// <param name="targetTenantId">业务租户(决定 std.tenant_id 与库位投影范围)。</param>
  658. /// <param name="replaceMode">true=已由 MdpStdFullReplace 删除正式切片,直接 INSERT;false=增量 UPSERT。</param>
  659. private async Task<int> MaterializeInvTransStdAsync(
  660. long sourceTenantId, long targetTenantId, string? batchId,
  661. DateTime asOf, DateTime historyFrom, string sourceSystem)
  662. {
  663. // 物化 SQL 要 LEFT JOIN 库位角色;未跑到 1.0.564 的环境也不能因缺表而整批失败
  664. await EnsureLocationRoleTableAsync();
  665. await MdpSchemaAligner.EnsureWrittenByColumnAsync(_db, "mdp_std_inv_trans");
  666. // batchId 为空:转换该源租户下全部已贴源 InvTransHist(中断恢复 / 补物化用)
  667. var batchPred = string.IsNullOrWhiteSpace(batchId)
  668. ? "1=1"
  669. : "s.sync_batch_id=@BatchId";
  670. var syncBatchExpr = string.IsNullOrWhiteSpace(batchId)
  671. ? "s.sync_batch_id"
  672. : "@BatchId";
  673. // 业务归属 = 目标租户,不再从 stg 反推
  674. var sTenant = "@TargetTenantId";
  675. var sql =
  676. $"""
  677. INSERT INTO mdp_std_inv_trans
  678. (tenant_id, source_system, written_by, domain, src_rec_id, trans_type, src_trans_type_raw, biz_doc_type,
  679. approved_flag, void_flag, summary_flag, approved_time,
  680. item_num, lot_serial, location,
  681. dimension1, dimension2, refs, site, qty_change, begin_balance, end_balance,
  682. eff_date, trans_time, ord_nbr, work_ord, ref_task_no, doc_qty, shipper_num, ship_type, reason, remark, create_user,
  683. history_from, as_of, sync_batch_id)
  684. SELECT
  685. {sTenant},
  686. @SourceSystem,
  687. 'DB_SYNC',
  688. IFNULL({MdpJsonSql.Str("s", "Domain")}, ''),
  689. s.source_row_id,
  690. {NeutralTransTypeCodes.TransTypeCase(
  691. MdpJsonSql.Str("s", "TransType"),
  692. LocationRoleExpr,
  693. $"IFNULL({MdpJsonSql.Dec("s", "QtyChange", 18, 5)}, 0)")},
  694. {MdpJsonSql.Str("s", "TransType")},
  695. {NeutralTransTypeCodes.BizDocCase(MdpJsonSql.Str("s", "TransType"), "'OTHER'")},
  696. 1, 0, 0,
  697. {MdpJsonSql.DateTimeSec("s", "CreateTime")},
  698. {MdpJsonSql.Str("s", "ItemNum")},
  699. {MdpJsonSql.Str("s", "LotSerial")},
  700. {MdpJsonSql.Str("s", "Loc")},
  701. IFNULL({MdpJsonSql.Str("s", "Dimension1")}, ''),
  702. IFNULL({MdpJsonSql.Str("s", "Dimension2")}, ''),
  703. {MdpJsonSql.Str("s", "Refs")},
  704. {MdpJsonSql.Str("s", "Site")},
  705. IFNULL({MdpJsonSql.Dec("s", "QtyChange", 18, 5)}, 0),
  706. IFNULL({MdpJsonSql.Dec("s", "BeginBalance", 18, 5)}, 0),
  707. IFNULL({MdpJsonSql.Dec("s", "BeginBalance", 18, 5)}, 0) + IFNULL({MdpJsonSql.Dec("s", "QtyChange", 18, 5)}, 0),
  708. {MdpJsonSql.DateTimeSec("s", "EffDate")},
  709. {MdpJsonSql.DateTimeSec("s", "CreateTime")},
  710. {MdpJsonSql.Str("s", "OrdNbr")},
  711. {MdpJsonSql.Str("s", "WorkOrd")},
  712. {MdpJsonSql.Str("s", "WorkOrd")},
  713. NULLIF({MdpJsonSql.Dec("s", "QtyRequired", 18, 5)}, 0),
  714. {MdpJsonSql.Str("s", "ShipperNum")},
  715. {MdpJsonSql.Str("s", "ShipType")},
  716. {MdpJsonSql.Str("s", "Reason")},
  717. {MdpJsonSql.Str("s", "Remark")},
  718. {MdpJsonSql.Str("s", "CreateUser")},
  719. @HistoryFrom,
  720. @AsOf,
  721. {syncBatchExpr}
  722. FROM mdp_stg_inv_trans s
  723. {TransTenantScopeJoin}
  724. {LocationRoleJoin}
  725. WHERE s.tenant_id=@SourceTenantId
  726. AND s.source_system=@SourceSystem
  727. AND s.source_table='InvTransHist'
  728. AND {batchPred}
  729. AND {ProjectionGateSql($"IFNULL({MdpJsonSql.Str("s", "Domain")}, '')")}
  730. ON DUPLICATE KEY UPDATE
  731. trans_type=VALUES(trans_type),
  732. src_trans_type_raw=VALUES(src_trans_type_raw),
  733. biz_doc_type=VALUES(biz_doc_type),
  734. approved_flag=VALUES(approved_flag),
  735. approved_time=VALUES(approved_time),
  736. item_num=VALUES(item_num),
  737. lot_serial=VALUES(lot_serial),
  738. location=VALUES(location),
  739. qty_change=VALUES(qty_change),
  740. begin_balance=VALUES(begin_balance),
  741. end_balance=VALUES(end_balance),
  742. eff_date=VALUES(eff_date),
  743. trans_time=VALUES(trans_time),
  744. ord_nbr=VALUES(ord_nbr),
  745. work_ord=VALUES(work_ord),
  746. ref_task_no=VALUES(ref_task_no),
  747. doc_qty=VALUES(doc_qty),
  748. shipper_num=VALUES(shipper_num),
  749. ship_type=VALUES(ship_type),
  750. reason=VALUES(reason),
  751. remark=VALUES(remark),
  752. create_user=VALUES(create_user),
  753. as_of=VALUES(as_of),
  754. sync_batch_id=VALUES(sync_batch_id),
  755. update_time=CURRENT_TIMESTAMP
  756. """;
  757. var ps = new List<SugarParameter>
  758. {
  759. new("@SourceTenantId", sourceTenantId),
  760. new("@TargetTenantId", targetTenantId),
  761. new("@SourceSystem", sourceSystem),
  762. new("@AsOf", asOf),
  763. new("@HistoryFrom", historyFrom)
  764. };
  765. if (!string.IsNullOrWhiteSpace(batchId))
  766. ps.Add(new SugarParameter("@BatchId", batchId));
  767. var affected = await _db.Ado.ExecuteCommandAsync(sql, ps);
  768. await RegisterUnmappedAsync(targetTenantId, sourceSystem, batchId);
  769. return affected;
  770. }
  771. /// <summary>
  772. /// 库位角色配置表兜底建表。权威定义与初值生成在 1.0.564.sql;
  773. /// 这里只保证「表存在」,不写初值——角色缺失时阶段码留空并进隔离,不猜。
  774. /// </summary>
  775. private async Task EnsureLocationRoleTableAsync() =>
  776. await MdpSchemaAligner.ExecuteAsync(_db,
  777. """
  778. CREATE TABLE IF NOT EXISTS mdp_location_role (
  779. id BIGINT AUTO_INCREMENT PRIMARY KEY,
  780. tenant_id BIGINT NOT NULL DEFAULT 0,
  781. domain VARCHAR(50) NOT NULL DEFAULT '',
  782. location VARCHAR(100) NOT NULL,
  783. location_role VARCHAR(24) NOT NULL DEFAULT 'UNKNOWN',
  784. role_source VARCHAR(24) NOT NULL DEFAULT 'AUTO_DESCR',
  785. remark VARCHAR(255) NULL,
  786. create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  787. update_time DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
  788. UNIQUE KEY uk_location_role (tenant_id, domain, location),
  789. KEY idx_location_role_role (tenant_id, location_role)
  790. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='库位中立角色(阶段码判定用)'
  791. """);
  792. /// <summary>
  793. /// 未能映射到中立阶段码的流水登记隔离表,按原因分级记日志。
  794. /// <para>
  795. /// 三类语义不同,禁止一律 Warning:
  796. /// <list type="bullet">
  797. /// <item><b>非阶段事件</b>(<see cref="NeutralTransTypeCodes.NonStageCodes"/>,如冻结/解冻):
  798. /// 不登记、不记日志 —— 它们永远不该出现在待办里。</item>
  799. /// <item><b>待核验</b>(PENDING_VERIFY):登记,只记 Information。</item>
  800. /// <item><b>库位角色未配置</b>(LOCATION_ROLE_UNCONFIGURED)/ <b>真未知</b>(UNKNOWN_CODE):
  801. /// 登记 + Warning,这两类是真有待办动作的。</item>
  802. /// </list>
  803. /// </para>
  804. /// 隔离表尚未建出时只记 Warning,不打断库存物化。
  805. /// </summary>
  806. /// <summary>
  807. /// 业务投影收口。映射表 uk(source_code, domain) 只登记源归属租户,不能再插一行代表 UAT,
  808. /// 否则会覆盖 797 并让 SourceDomainTenantResolver 报「不唯一」。
  809. /// 允许投影的目标:映射表上的源归属租户,或该 domain 下自有非 Supp 库位的启用租户。
  810. /// 默认租户 1300000000001 不是源归属、也没有库位主数据,两条都不成立,历史残留不再增长。
  811. /// </summary>
  812. private static string ProjectionGateSql(string domainExpr) =>
  813. $"""
  814. (
  815. EXISTS (
  816. SELECT 1 FROM ado_source_domain_tenant_map m
  817. WHERE m.source_code = @SourceSystem
  818. AND m.domain = {domainExpr}
  819. AND m.tenant_id = @TargetTenantId
  820. AND m.status = 1
  821. )
  822. OR EXISTS (
  823. SELECT 1 FROM LocationMaster lm
  824. JOIN SysTenant st ON st.Id = lm.tenant_id AND st.Status = 1
  825. WHERE lm.tenant_id = @TargetTenantId
  826. AND IFNULL(lm.Domain,'') = {domainExpr}
  827. AND IFNULL(lm.typed,'') <> 'Supp'
  828. AND TRIM(lm.location) <> ''
  829. )
  830. )
  831. """;
  832. private async Task RegisterUnmappedAsync(long tenantId, string sourceSystem, string? batchId)
  833. {
  834. try
  835. {
  836. var batchPred = string.IsNullOrWhiteSpace(batchId) ? "1=1" : "t.sync_batch_id=@BatchId";
  837. var ps = new List<SugarParameter>
  838. {
  839. new("@TenantId", tenantId),
  840. new("@SourceSystem", sourceSystem)
  841. };
  842. if (!string.IsNullOrWhiteSpace(batchId))
  843. ps.Add(new SugarParameter("@BatchId", batchId));
  844. var pendingScope =
  845. $"""
  846. t.trans_type IS NULL AND t.src_trans_type_raw IS NOT NULL
  847. AND t.src_trans_type_raw NOT IN ({NeutralTransTypeCodes.NonStageInList})
  848. AND t.src_trans_type_raw NOT IN ({NeutralTransTypeCodes.ExcludedInList})
  849. """;
  850. var roleExpr = $"IFNULL(lr.location_role, '{NeutralTransTypeCodes.UnknownRole}')";
  851. var reasonCase = NeutralTransTypeCodes.UnmappedReasonCase("t.src_trans_type_raw", roleExpr);
  852. await _db.Ado.ExecuteCommandAsync(
  853. $"""
  854. INSERT INTO mdp_std_inv_trans_unmapped
  855. (tenant_id, source_system, src_rec_id, src_trans_type_raw, unmapped_reason, location, row_count, first_seen, last_seen)
  856. SELECT t.tenant_id, t.source_system, t.src_rec_id, t.src_trans_type_raw, {reasonCase}, IFNULL(t.location,''), 1, NOW(), NOW()
  857. FROM mdp_std_inv_trans t
  858. LEFT JOIN mdp_location_role lr
  859. ON lr.tenant_id = t.tenant_id
  860. AND lr.domain = IFNULL(t.domain,'')
  861. AND lr.location = IFNULL(t.location,'')
  862. WHERE t.tenant_id=@TenantId AND t.source_system=@SourceSystem
  863. AND {pendingScope}
  864. AND {batchPred}
  865. AND NOT (t.tenant_id = 1300000000001)
  866. ON DUPLICATE KEY UPDATE last_seen=NOW(), row_count=row_count+1,
  867. unmapped_reason=VALUES(unmapped_reason), location=VALUES(location)
  868. """,
  869. ps);
  870. var groups = await _db.Ado.SqlQueryAsync<UnmappedTransGroup>(
  871. $"""
  872. SELECT t.src_trans_type_raw AS Code, {reasonCase} AS Reason, COUNT(*) AS Cnt
  873. FROM mdp_std_inv_trans t
  874. LEFT JOIN mdp_location_role lr
  875. ON lr.tenant_id = t.tenant_id
  876. AND lr.domain = IFNULL(t.domain,'')
  877. AND lr.location = IFNULL(t.location,'')
  878. WHERE t.tenant_id=@TenantId AND t.source_system=@SourceSystem
  879. AND {pendingScope}
  880. AND {batchPred}
  881. AND NOT (t.tenant_id = 1300000000001)
  882. GROUP BY t.src_trans_type_raw, {reasonCase}
  883. """,
  884. ps);
  885. foreach (var g in groups)
  886. {
  887. if (g.Reason == "PENDING_VERIFY")
  888. {
  889. _logger.LogInformation(
  890. "库存流水有 {Count} 行源事务码 {Code} 语义待核验,已进隔离表暂不计入指标。tenant={TenantId} source={Source}",
  891. g.Cnt, g.Code, tenantId, sourceSystem);
  892. continue;
  893. }
  894. var action = g.Reason switch
  895. {
  896. "LOCATION_ROLE_UNCONFIGURED" => "请在 mdp_location_role 配置该库位角色",
  897. "OUT_OF_SCOPE_ROLE" => "该库位角色不在此事务码的阶段规则内,不计入指标",
  898. _ => "请确认该码语义后补入 NeutralTransTypeCodes"
  899. };
  900. _logger.LogWarning(
  901. "库存流水有 {Count} 行源事务码 {Code} 未映射到中立阶段码({Reason}),已进隔离表,指标不计入。{Action}。tenant={TenantId} source={Source}",
  902. g.Cnt, g.Code, g.Reason, action, tenantId, sourceSystem);
  903. }
  904. }
  905. catch (Exception ex)
  906. {
  907. _logger.LogWarning(ex, "未映射流水隔离登记跳过(隔离表未就绪)。tenant={TenantId} source={Source}", tenantId, sourceSystem);
  908. }
  909. }
  910. private sealed class UnmappedTransGroup
  911. {
  912. public string Code { get; set; } = "";
  913. public string Reason { get; set; } = "";
  914. public int Cnt { get; set; }
  915. }
  916. // 取/放锁已迁至 InventoryInboundLockGuard:
  917. // 原实现在共享 _db(IsAutoCloseConnection=true)上跑 GET_LOCK/RELEASE_LOCK,
  918. // 命令执行完连接即回池并被驱动 reset,MySQL 当场释放咨询锁 → 跨实例互斥失效。
  919. private sealed class UpperRow
  920. {
  921. public string? CursorText { get; set; }
  922. public string? TieText { get; set; }
  923. }
  924. private sealed class InventoryStageSourceRow
  925. {
  926. public string? SourceSystem { get; set; }
  927. }
  928. }
  929. public sealed class InventorySyncResult
  930. {
  931. public string BatchId { get; init; } = "";
  932. public long TenantId { get; init; }
  933. public string Domain { get; init; } = "";
  934. public bool Bootstrap { get; init; }
  935. public int LocationPulled { get; init; }
  936. public int LocationWritten { get; init; }
  937. public int InventoryStdRows { get; init; }
  938. public int TransPulled { get; init; }
  939. public int TransWritten { get; init; }
  940. public int TransStdRows { get; init; }
  941. public DateTime AsOf { get; init; }
  942. public DateTime? HistoryFrom { get; init; }
  943. public bool Skipped { get; init; }
  944. public string? Message { get; init; }
  945. }