InventoryMdpSyncService.cs 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773
  1. using Admin.NET.Plugin.AiDOP.DataPlatform;
  2. using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure;
  4. using Microsoft.Extensions.Logging;
  5. using Microsoft.Extensions.Options;
  6. using SqlSugar;
  7. namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
  8. /// <summary>
  9. /// S5 库存冷链:165 LocationDetail / InvTransHist → stg → std。
  10. /// 余额事实源仅 LocationDetail;InvMaster 不入标准层。
  11. /// </summary>
  12. public sealed class InventoryMdpSyncService : ITransient
  13. {
  14. /// <summary>跨实例互斥锁键;真正的取/放锁生命周期见 <see cref="InventoryInboundLockGuard"/>。</summary>
  15. public const string LockKey = InventoryInboundLockGuard.LockKey;
  16. private const string LocationEntity = "S5_LOCATION_DETAIL_SQLSERVER";
  17. private const string TransEntity = "S5_INV_TRANS_HIST_SQLSERVER";
  18. private const string SourceCodeDefault = "DOPDEMORQ_SQLSERVER";
  19. private readonly ISqlSugarClient _db;
  20. private readonly MdpSourcePullDispatcher _pullDispatcher;
  21. private readonly MdpSourceScopeFactory _scopeFactory;
  22. private readonly SourceDomainTenantResolver _domainTenant;
  23. private readonly AidopInventoryOptions _opt;
  24. private readonly ILogger _logger;
  25. public InventoryMdpSyncService(
  26. ISqlSugarClient db,
  27. MdpSourcePullDispatcher pullDispatcher,
  28. MdpSourceScopeFactory scopeFactory,
  29. SourceDomainTenantResolver domainTenant,
  30. IOptions<AidopInventoryOptions> opt,
  31. ILoggerFactory loggerFactory)
  32. {
  33. _db = db;
  34. _pullDispatcher = pullDispatcher;
  35. _scopeFactory = scopeFactory;
  36. _domainTenant = domainTenant;
  37. _opt = opt.Value;
  38. _logger = loggerFactory.CreateLogger(nameof(InventoryMdpSyncService));
  39. }
  40. public Task<InventorySyncResult> RunBootstrapAsync(CancellationToken cancellationToken = default)
  41. => RunAsync(bootstrap: true, reconcile: false, cancellationToken);
  42. public Task<InventorySyncResult> RunIncrementalAsync(CancellationToken cancellationToken = default)
  43. => RunAsync(bootstrap: false, reconcile: false, cancellationToken);
  44. public Task<InventorySyncResult> RunReconcileFullAsync(CancellationToken cancellationToken = default)
  45. => RunAsync(bootstrap: false, reconcile: true, cancellationToken);
  46. /// <summary>
  47. /// 仅 stg→std:**不访问源库**。用于链路修复后,复用已完整落地的贴源层让各业务租户
  48. /// 按当前库位范围口径重新物化标准层。
  49. /// <para>
  50. /// 语义为 <b>FULL REPLACE</b>(逐租户、逐正式切片):贴源层是完整历史,
  51. /// 只跑 UPSERT 既清不掉旧口径残留、也补不齐从未物化过的租户。
  52. /// 删除范围严格限定「该租户 + 当前正式 source_system + 当前 domain」,
  53. /// 不触碰 UAT_GENERATOR 等非正式切片。
  54. /// </para>
  55. /// </summary>
  56. public async Task<InventorySyncResult> TransformTransStdFromStgAsync(CancellationToken cancellationToken = default)
  57. {
  58. cancellationToken.ThrowIfCancellationRequested();
  59. var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? SourceCodeDefault : _opt.SourceCode.Trim();
  60. var domain = string.IsNullOrWhiteSpace(_opt.DefaultDomain) ? "8010" : _opt.DefaultDomain.Trim();
  61. var tenantId = await _domainTenant.ResolveTenantIdAsync(sourceCode, domain, cancellationToken);
  62. var asOf = DateTime.Now;
  63. var months = _opt.TransBootstrapMonths <= 0 ? 12 : _opt.TransBootstrapMonths;
  64. var historyFrom = asOf.Date.AddMonths(-months);
  65. var batchId = $"S5_INV_XFORM_{asOf:yyyyMMddHHmmss}";
  66. // await using:异常路径也一定走到释放(DisposeAsync 内先 RELEASE_LOCK 再关专用连接)
  67. await using var guard = await InventoryInboundLockGuard.TryAcquireAsync(_db, _logger, cancellationToken: cancellationToken);
  68. if (!guard.Acquired)
  69. {
  70. _logger.LogWarning("[InventoryMdpSync] 互斥锁占用,transform-std 本轮跳过 batch={Batch} reason={Reason}",
  71. batchId, guard.BusyReason);
  72. return new InventorySyncResult
  73. {
  74. BatchId = batchId,
  75. TenantId = tenantId,
  76. Domain = domain,
  77. AsOf = asOf,
  78. HistoryFrom = historyFrom,
  79. Skipped = true,
  80. Message = "lock busy"
  81. };
  82. }
  83. var targetTenants = await ListInventoryScopedTenantsAsync(domain, cancellationToken);
  84. if (targetTenants.Count == 0)
  85. {
  86. // fail closed:没有任何配了合法库位的租户时不写标准层,绝不回落成「按源归属租户写一份」
  87. _logger.LogWarning(
  88. "[InventoryMdpSync] domain={Domain} 无任何配置了合法库位的租户,transform-std 本轮不写入", domain);
  89. return new InventorySyncResult
  90. {
  91. BatchId = batchId,
  92. TenantId = tenantId,
  93. Domain = domain,
  94. TransStdRows = 0,
  95. AsOf = asOf,
  96. HistoryFrom = historyFrom,
  97. Skipped = true,
  98. Message = "no scoped tenant"
  99. };
  100. }
  101. using var xformTimeout = WithLongCommandTimeout();
  102. var rows = 0;
  103. foreach (var targetTenantId in targetTenants)
  104. {
  105. cancellationToken.ThrowIfCancellationRequested();
  106. var n = await MdpStdFullReplace.ReplaceAsync(
  107. _db,
  108. "mdp_std_inv_trans",
  109. targetTenantId,
  110. FormalSliceWhere(sourceCode, domain),
  111. () => MaterializeInvTransStdAsync(
  112. tenantId, targetTenantId, batchId: null, asOf, historyFrom, sourceCode),
  113. cancellationToken);
  114. rows += n;
  115. _logger.LogInformation(
  116. "[InventoryMdpSync] transform-std materialized tenant={Tenant} domain={Domain} rows={Rows}",
  117. targetTenantId, domain, n);
  118. }
  119. _logger.LogInformation(
  120. "[InventoryMdpSync] transform-std done source={Source} tenants={Cnt} rows={Rows}",
  121. tenantId, targetTenants.Count, rows);
  122. return new InventorySyncResult
  123. {
  124. BatchId = batchId,
  125. TenantId = tenantId,
  126. Domain = domain,
  127. TransStdRows = rows,
  128. AsOf = asOf,
  129. HistoryFrom = historyFrom,
  130. Message = "OK transform-std"
  131. };
  132. }
  133. /// <summary>
  134. /// 仅 stg→std(库存余额):**不访问源库**,从一个已完整落地的贴源批次重新物化标准层。
  135. /// <para>
  136. /// 用途:同步链路修复后,复用既有完整 stg 批次让各租户按新的库位范围口径重新物化,
  137. /// 避免为此重新全量拉取源库。
  138. /// </para>
  139. /// <para>
  140. /// 语义为 <b>UPSERT,不做 FULL REPLACE</b>:只按业务键写入/更新本批次覆盖到的行,
  141. /// 不删除任何既有标准层数据 —— 单个增量批次不代表全量,replace 会造成数据丢失。
  142. /// 因此本入口<b>不负责</b>清理历史脏快照,那属于全量校准(reconcile)的职责。
  143. /// </para>
  144. /// </summary>
  145. /// <param name="stgBatchId">贴源批次号(sync_batch_id),必须是已完整落地的批次。</param>
  146. public async Task<InventorySyncResult> TransformInventoryStdFromStgAsync(
  147. string stgBatchId, CancellationToken cancellationToken = default)
  148. {
  149. cancellationToken.ThrowIfCancellationRequested();
  150. if (string.IsNullOrWhiteSpace(stgBatchId)
  151. || !System.Text.RegularExpressions.Regex.IsMatch(stgBatchId, @"^[A-Za-z0-9_]+$"))
  152. throw new InvalidOperationException("非法贴源批次号");
  153. var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? SourceCodeDefault : _opt.SourceCode.Trim();
  154. var domain = string.IsNullOrWhiteSpace(_opt.DefaultDomain) ? "8010" : _opt.DefaultDomain.Trim();
  155. var sourceTenantId = await _domainTenant.ResolveTenantIdAsync(sourceCode, domain, cancellationToken);
  156. var asOf = DateTime.Now;
  157. // 本入口同样写 mdp_std_inventory,必须与 bootstrap/reconcile/incremental 互斥(原实现漏取锁)
  158. await using var guard = await InventoryInboundLockGuard.TryAcquireAsync(_db, _logger, cancellationToken: cancellationToken);
  159. if (!guard.Acquired)
  160. {
  161. _logger.LogWarning("[InventoryMdpSync] 互斥锁占用,transform-inventory-std 本轮跳过 batch={Batch} reason={Reason}",
  162. stgBatchId, guard.BusyReason);
  163. return new InventorySyncResult
  164. {
  165. BatchId = stgBatchId,
  166. TenantId = sourceTenantId,
  167. Domain = domain,
  168. AsOf = asOf,
  169. Skipped = true,
  170. Message = "lock busy"
  171. };
  172. }
  173. var stgRows = await _db.Ado.GetIntAsync(
  174. """
  175. SELECT COUNT(1) FROM mdp_stg_inventory
  176. WHERE tenant_id=@TenantId AND source_system=@SourceSystem
  177. AND source_table='LocationDetail' AND sync_batch_id=@BatchId
  178. """,
  179. new List<SugarParameter>
  180. {
  181. new("@TenantId", sourceTenantId),
  182. new("@SourceSystem", sourceCode),
  183. new("@BatchId", stgBatchId)
  184. });
  185. if (stgRows == 0)
  186. throw new InvalidOperationException(
  187. $"贴源批次为空或不属于源归属租户:batch={stgBatchId}, sourceTenant={sourceTenantId}");
  188. var targetTenants = await ListInventoryScopedTenantsAsync(domain, cancellationToken);
  189. var total = 0;
  190. foreach (var targetTenantId in targetTenants)
  191. {
  192. cancellationToken.ThrowIfCancellationRequested();
  193. var rows = await InsertInventoryStdAsync(
  194. targetTenantId, sourceTenantId, stgBatchId, asOf, sourceCode, replaceMode: false);
  195. total += rows;
  196. _logger.LogInformation(
  197. "[InventoryMdpSync] std re-materialized from stg tenant={Tenant} domain={Domain} batch={Batch} rows={Rows}",
  198. targetTenantId, domain, stgBatchId, rows);
  199. }
  200. return new InventorySyncResult
  201. {
  202. BatchId = stgBatchId,
  203. TenantId = sourceTenantId,
  204. Domain = domain,
  205. InventoryStdRows = total,
  206. AsOf = asOf,
  207. Message = $"OK transform-inventory-std from stg (stgRows={stgRows}, tenants={targetTenants.Count})"
  208. };
  209. }
  210. private async Task<InventorySyncResult> RunAsync(bool bootstrap, bool reconcile, CancellationToken cancellationToken)
  211. {
  212. cancellationToken.ThrowIfCancellationRequested();
  213. var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? SourceCodeDefault : _opt.SourceCode.Trim();
  214. var domain = string.IsNullOrWhiteSpace(_opt.DefaultDomain) ? "8010" : _opt.DefaultDomain.Trim();
  215. // 源归属租户:只决定贴源层(stg)落在谁名下,**不代表业务归属**;
  216. // 业务归属在标准层物化时按各租户 LocationMaster 库位范围投影决定。
  217. var tenantId = await _domainTenant.ResolveTenantIdAsync(sourceCode, domain, cancellationToken);
  218. var asOf = DateTime.Now;
  219. var batchId = $"S5_INV_{(bootstrap ? "BOOT" : reconcile ? "RECON" : "INCR")}_{asOf:yyyyMMddHHmmss}";
  220. var months = _opt.TransBootstrapMonths <= 0 ? 12 : _opt.TransBootstrapMonths;
  221. var historyFrom = asOf.Date.AddMonths(-months);
  222. // await using:异常路径也一定走到释放(DisposeAsync 内先 RELEASE_LOCK 再关专用连接)
  223. await using var guard = await InventoryInboundLockGuard.TryAcquireAsync(_db, _logger, cancellationToken: cancellationToken);
  224. if (!guard.Acquired)
  225. {
  226. _logger.LogWarning("[InventoryMdpSync] 跨实例锁占用,本轮跳过 batch={Batch} reason={Reason}", batchId, guard.BusyReason);
  227. return new InventorySyncResult
  228. {
  229. BatchId = batchId,
  230. TenantId = tenantId,
  231. Domain = domain,
  232. Bootstrap = bootstrap,
  233. AsOf = asOf,
  234. HistoryFrom = historyFrom,
  235. Skipped = true,
  236. Message = "lock busy"
  237. };
  238. }
  239. var upperLoc = await CaptureUpperBoundAsync(sourceCode, "LocationDetail", "UpdateTime", cancellationToken);
  240. var upperTrans = await CaptureUpperBoundAsync(sourceCode, "InvTransHist", "CreateTime", cancellationToken);
  241. MdpPullResult locPull;
  242. if (bootstrap || reconcile)
  243. {
  244. var locBatch = $"{batchId}_LOC";
  245. // NULL UpdateTime 段与非 NULL 段共用同一 batchId,保证 Replace 不漏 NULL 行
  246. var nullCtx = BuildKeysetCtx(tenantId, locBatch, asOf, historyFrom, upperLoc,
  247. cursorColumn: "UpdateTime", nullPhase: true, bootstrapFull: true);
  248. nullCtx.CursorValue = null;
  249. nullCtx.TieBreakerValue = null;
  250. await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, nullCtx, cancellationToken, maxPages: 50);
  251. var fullCtx = BuildKeysetCtx(tenantId, locBatch, asOf, historyFrom, upperLoc,
  252. cursorColumn: "UpdateTime", nullPhase: false, bootstrapFull: true);
  253. fullCtx.CursorValue = null;
  254. fullCtx.TieBreakerValue = null;
  255. fullCtx.BootstrapFrom = null; // LocationDetail 首刷不截时间窗
  256. locPull = await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, fullCtx, cancellationToken, maxPages: 200);
  257. }
  258. else
  259. {
  260. var incrCtx = BuildKeysetCtx(tenantId, $"{batchId}_LOC", asOf, historyFrom, upperLoc,
  261. cursorColumn: "UpdateTime", nullPhase: false, bootstrapFull: false);
  262. // 重叠窗口:从上次游标时间向前回退 LocationOverlapMinutes
  263. if (!string.IsNullOrWhiteSpace(incrCtx.CursorValue)
  264. && DateTime.TryParse(incrCtx.CursorValue, out var lastDt))
  265. {
  266. var overlap = Math.Max(0, _opt.LocationOverlapMinutes);
  267. incrCtx.CursorValue = lastDt.AddMinutes(-overlap)
  268. .ToString("yyyy-MM-dd HH:mm:ss.fff");
  269. incrCtx.TieBreakerValue = "0";
  270. }
  271. locPull = await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, incrCtx, cancellationToken, maxPages: 200);
  272. }
  273. // —— 标准层按租户物化:一次贴源,逐租户按各自库位范围投影 ——
  274. // 贴源层是「源+domain」维度(归属 sourceTenantId),标准层是「租户」维度。
  275. // 拉取游标持久化在 mdp_entity 上、跨租户共享,故绝不能为每个租户各拉一次。
  276. var targetTenants = await ListInventoryScopedTenantsAsync(domain, cancellationToken);
  277. if (targetTenants.Count == 0)
  278. _logger.LogWarning(
  279. "[InventoryMdpSync] domain={Domain} 无任何配置了合法库位的租户,标准层本轮不写入", domain);
  280. var inventoryStdRows = 0;
  281. foreach (var targetTenantId in targetTenants)
  282. {
  283. cancellationToken.ThrowIfCancellationRequested();
  284. int rows;
  285. if (bootstrap || reconcile)
  286. {
  287. rows = await MdpStdFullReplace.ReplaceAsync(
  288. _db,
  289. "mdp_std_inventory",
  290. targetTenantId,
  291. "source_system='DOPDEMORQ_SQLSERVER'",
  292. () => InsertInventoryStdAsync(targetTenantId, tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: true),
  293. cancellationToken);
  294. }
  295. else
  296. {
  297. rows = await InsertInventoryStdAsync(targetTenantId, tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: false);
  298. }
  299. inventoryStdRows += rows;
  300. _logger.LogInformation(
  301. "[InventoryMdpSync] std materialized tenant={Tenant} domain={Domain} rows={Rows}",
  302. targetTenantId, domain, rows);
  303. }
  304. var transCtx = BuildKeysetCtx(tenantId, $"{batchId}_TRN", asOf, historyFrom, upperTrans,
  305. cursorColumn: "CreateTime", nullPhase: false, bootstrapFull: bootstrap || reconcile);
  306. if (bootstrap || reconcile)
  307. {
  308. transCtx.CursorValue = null;
  309. transCtx.TieBreakerValue = null;
  310. transCtx.BootstrapFrom = historyFrom;
  311. }
  312. var transPull = await _pullDispatcher.PullAllByEntityCodeAsync(TransEntity, transCtx, cancellationToken, maxPages: 500);
  313. // —— 流水腿与余额腿同构:一次贴源,逐业务租户按各自库位范围投影 ——
  314. using var transTimeout = WithLongCommandTimeout();
  315. var transStdRows = 0;
  316. foreach (var targetTenantId in targetTenants)
  317. {
  318. cancellationToken.ThrowIfCancellationRequested();
  319. int rows;
  320. if (bootstrap || reconcile)
  321. {
  322. rows = await MdpStdFullReplace.ReplaceAsync(
  323. _db,
  324. "mdp_std_inv_trans",
  325. targetTenantId,
  326. FormalSliceWhere(sourceCode, domain),
  327. () => MaterializeInvTransStdAsync(
  328. tenantId, targetTenantId, batchId: null, asOf, historyFrom, sourceCode),
  329. cancellationToken);
  330. }
  331. else
  332. {
  333. rows = await MaterializeInvTransStdAsync(
  334. tenantId, targetTenantId, $"{batchId}_TRN", asOf, historyFrom, sourceCode);
  335. }
  336. transStdRows += rows;
  337. _logger.LogInformation(
  338. "[InventoryMdpSync] trans std materialized tenant={Tenant} domain={Domain} rows={Rows}",
  339. targetTenantId, domain, rows);
  340. }
  341. _logger.LogInformation(
  342. "[InventoryMdpSync] done batch={Batch} boot={Boot} recon={Recon} locPulled={LocP} invStd={Inv} trnPulled={TrnP} trnStd={Trn}",
  343. batchId, bootstrap, reconcile, locPull.RowsPulled, inventoryStdRows, transPull.RowsPulled, transStdRows);
  344. return new InventorySyncResult
  345. {
  346. BatchId = batchId,
  347. TenantId = tenantId,
  348. Domain = domain,
  349. Bootstrap = bootstrap,
  350. LocationPulled = locPull.RowsPulled,
  351. LocationWritten = locPull.RowsWritten,
  352. InventoryStdRows = inventoryStdRows,
  353. TransPulled = transPull.RowsPulled,
  354. TransWritten = transPull.RowsWritten,
  355. TransStdRows = transStdRows,
  356. AsOf = asOf,
  357. HistoryFrom = historyFrom,
  358. Message = "OK"
  359. };
  360. }
  361. private MdpPullContext BuildKeysetCtx(
  362. long tenantId,
  363. string batchId,
  364. DateTime asOf,
  365. DateTime historyFrom,
  366. (string? Cursor, string? Tie) upper,
  367. string cursorColumn,
  368. bool nullPhase,
  369. bool bootstrapFull)
  370. {
  371. return new MdpPullContext
  372. {
  373. TenantId = tenantId,
  374. BatchId = batchId,
  375. FullRefresh = false,
  376. UseKeysetCursor = true,
  377. CursorColumn = cursorColumn,
  378. TieBreakerColumn = "RecID",
  379. UpperCursorValue = upper.Cursor,
  380. UpperTieBreakerValue = upper.Tie,
  381. BootstrapFrom = bootstrapFull && cursorColumn == "CreateTime" ? historyFrom : null,
  382. DeferCursorPersist = false,
  383. NullTimePhase = nullPhase,
  384. // 首刷/校准不得继承实体脏水位,否则只会抽到「游标之后」的尾巴
  385. SkipPersistedKeysetCursor = bootstrapFull || nullPhase
  386. };
  387. }
  388. private async Task<(string? Cursor, string? Tie)> CaptureUpperBoundAsync(
  389. string sourceCode,
  390. string table,
  391. string cursorColumn,
  392. CancellationToken ct)
  393. {
  394. if (!System.Text.RegularExpressions.Regex.IsMatch(table, @"^[A-Za-z0-9_]+$")
  395. || !System.Text.RegularExpressions.Regex.IsMatch(cursorColumn, @"^[A-Za-z0-9_]+$"))
  396. throw new InvalidOperationException("非法上界查询标识符");
  397. var remote = await _scopeFactory.GetScopeAsync(sourceCode, ct);
  398. var rows = await remote.Ado.SqlQueryAsync<UpperRow>(
  399. $"""
  400. SELECT TOP 1
  401. CONVERT(varchar(30), {cursorColumn}, 121) AS CursorText,
  402. CAST(RecID AS varchar(30)) AS TieText
  403. FROM {table}
  404. WHERE {cursorColumn} IS NOT NULL
  405. ORDER BY {cursorColumn} DESC, RecID DESC
  406. """);
  407. var hit = rows.FirstOrDefault();
  408. return (hit?.CursorText, hit?.TieText);
  409. }
  410. /// <summary>
  411. /// stg → std 物化:**按目标租户的合法库位范围投影**。
  412. /// <para>
  413. /// 贴源层(stg)是「源 + domain」维度的全量落地区,归属 <paramref name="sourceTenantId"/>;
  414. /// 标准层(std)是「租户」维度的可见快照,因此这里必须内联 LocationMaster 做投影:
  415. /// 只有落在目标租户自己 LocationMaster(同 Domain 且 Typed &lt;&gt; 'Supp')内的库位才写入。
  416. /// </para>
  417. /// <para>
  418. /// 写入不变量:∀ 写入行 → tenant_id = targetTenantId
  419. /// ∧ location ∈ AllowedLocations(targetTenantId) ∧ domain = 该租户 LocationMaster 的 Domain。
  420. /// 租户白名单为空 → JOIN 命中 0 行 → 写 0 条(fail closed,绝不退回整个 Domain)。
  421. /// </para>
  422. /// <para>
  423. /// tenant_id 直接取 <paramref name="targetTenantId"/> 而非 MdpJsonSql.TenantFromStg:
  424. /// 贴源行的 tenant 是「源落地区归属」,不是业务归属,不能顺着传下来。
  425. /// </para>
  426. /// </summary>
  427. private async Task<int> InsertInventoryStdAsync(
  428. long targetTenantId, long sourceTenantId, string batchId, DateTime asOf, string sourceSystem, bool replaceMode)
  429. {
  430. // replaceMode:MdpStdFullReplace 已 DELETE,直接 INSERT;
  431. // 增量:UPSERT 本批变化。
  432. var sTenant = "@TargetTenantId";
  433. var sql = replaceMode
  434. ? $"""
  435. INSERT INTO mdp_std_inventory
  436. (tenant_id, source_system, domain, location, lot_serial, item_num,
  437. dimension1, dimension2, refs, site, inv_status,
  438. qty_on_hand, qty_unrestricted, qty_inspection, qty_frozen, qty_available,
  439. src_rec_id, source_update_time, as_of, sync_batch_id)
  440. SELECT
  441. {sTenant},
  442. @SourceSystem,
  443. IFNULL({MdpJsonSql.Str("s", "Domain")}, ''),
  444. IFNULL({MdpJsonSql.Str("s", "Location")}, ''),
  445. IFNULL({MdpJsonSql.Str("s", "LotSerial")}, ''),
  446. IFNULL({MdpJsonSql.Str("s", "ItemNum")}, ''),
  447. IFNULL({MdpJsonSql.Str("s", "Dimension1")}, ''),
  448. IFNULL({MdpJsonSql.Str("s", "Dimension2")}, ''),
  449. IFNULL({MdpJsonSql.Str("s", "Refs")}, ''),
  450. IFNULL({MdpJsonSql.Str("s", "Site")}, ''),
  451. {MdpJsonSql.Str("s", "InvStatus")},
  452. IFNULL({MdpJsonSql.Dec("s", "QtyOnHand", 18, 5)}, 0),
  453. IFNULL({MdpJsonSql.Dec("s", "AvailStatusQty", 18, 5)}, 0),
  454. IFNULL({MdpJsonSql.Dec("s", "Assay", 18, 5)}, 0),
  455. IFNULL({MdpJsonSql.Dec("s", "FreezeQty", 18, 5)}, 0),
  456. IFNULL({MdpJsonSql.Dec("s", "AvailStatusQty", 18, 5)}, 0),
  457. s.source_row_id,
  458. {MdpJsonSql.DateTimeSec("s", "UpdateTime")},
  459. @AsOf,
  460. @BatchId
  461. FROM mdp_stg_inventory s
  462. {TenantScopeJoin}
  463. WHERE s.tenant_id=@SourceTenantId
  464. AND s.source_system=@SourceSystem
  465. AND s.source_table='LocationDetail'
  466. AND s.sync_batch_id=@BatchId
  467. """
  468. : $"""
  469. INSERT INTO mdp_std_inventory
  470. (tenant_id, source_system, domain, location, lot_serial, item_num,
  471. dimension1, dimension2, refs, site, inv_status,
  472. qty_on_hand, qty_unrestricted, qty_inspection, qty_frozen, qty_available,
  473. src_rec_id, source_update_time, as_of, sync_batch_id)
  474. SELECT
  475. {sTenant},
  476. @SourceSystem,
  477. IFNULL({MdpJsonSql.Str("s", "Domain")}, ''),
  478. IFNULL({MdpJsonSql.Str("s", "Location")}, ''),
  479. IFNULL({MdpJsonSql.Str("s", "LotSerial")}, ''),
  480. IFNULL({MdpJsonSql.Str("s", "ItemNum")}, ''),
  481. IFNULL({MdpJsonSql.Str("s", "Dimension1")}, ''),
  482. IFNULL({MdpJsonSql.Str("s", "Dimension2")}, ''),
  483. IFNULL({MdpJsonSql.Str("s", "Refs")}, ''),
  484. IFNULL({MdpJsonSql.Str("s", "Site")}, ''),
  485. {MdpJsonSql.Str("s", "InvStatus")},
  486. IFNULL({MdpJsonSql.Dec("s", "QtyOnHand", 18, 5)}, 0),
  487. IFNULL({MdpJsonSql.Dec("s", "AvailStatusQty", 18, 5)}, 0),
  488. IFNULL({MdpJsonSql.Dec("s", "Assay", 18, 5)}, 0),
  489. IFNULL({MdpJsonSql.Dec("s", "FreezeQty", 18, 5)}, 0),
  490. IFNULL({MdpJsonSql.Dec("s", "AvailStatusQty", 18, 5)}, 0),
  491. s.source_row_id,
  492. {MdpJsonSql.DateTimeSec("s", "UpdateTime")},
  493. @AsOf,
  494. @BatchId
  495. FROM mdp_stg_inventory s
  496. {TenantScopeJoin}
  497. WHERE s.tenant_id=@SourceTenantId
  498. AND s.source_system=@SourceSystem
  499. AND s.source_table='LocationDetail'
  500. AND s.sync_batch_id=@BatchId
  501. ON DUPLICATE KEY UPDATE
  502. inv_status=VALUES(inv_status),
  503. qty_on_hand=VALUES(qty_on_hand),
  504. qty_unrestricted=VALUES(qty_unrestricted),
  505. qty_inspection=VALUES(qty_inspection),
  506. qty_frozen=VALUES(qty_frozen),
  507. qty_available=VALUES(qty_available),
  508. src_rec_id=VALUES(src_rec_id),
  509. source_update_time=VALUES(source_update_time),
  510. as_of=VALUES(as_of),
  511. sync_batch_id=VALUES(sync_batch_id),
  512. update_time=CURRENT_TIMESTAMP
  513. """;
  514. return await _db.Ado.ExecuteCommandAsync(sql,
  515. new SugarParameter("@TargetTenantId", targetTenantId),
  516. new SugarParameter("@SourceTenantId", sourceTenantId),
  517. new SugarParameter("@SourceSystem", sourceSystem),
  518. new SugarParameter("@BatchId", batchId),
  519. new SugarParameter("@AsOf", asOf));
  520. }
  521. /// <summary>
  522. /// 租户库位范围内联投影:贴源行只有落在目标租户自己的合法库位(同 Domain、Typed &lt;&gt; 'Supp')
  523. /// 才允许进入标准层。这是标准层写入侧的租户安全边界。
  524. /// <para>
  525. /// **余额腿与流水腿共用本实现**,只有贴源 JSON 里的库位字段名不同
  526. /// (LocationDetail 用 <c>Location</c>,InvTransHist 用 <c>Loc</c>)。
  527. /// 禁止再手写第二套语义略有差异的 LocationMaster JOIN。
  528. /// </para>
  529. /// </summary>
  530. /// <param name="locationJsonKey">贴源 raw_data 中的库位字段名。</param>
  531. private static string TenantScopeJoinSql(string locationJsonKey) =>
  532. $"""
  533. INNER JOIN LocationMaster lm
  534. ON lm.tenant_id = @TargetTenantId
  535. AND lm.Domain = IFNULL({MdpJsonSql.Str("s", "Domain")}, '')
  536. AND lm.location = IFNULL({MdpJsonSql.Str("s", locationJsonKey)}, '')
  537. AND IFNULL(lm.typed, '') <> 'Supp'
  538. AND TRIM(lm.location) <> ''
  539. """;
  540. /// <summary>库存余额贴源的库位字段名。</summary>
  541. private static readonly string TenantScopeJoin = TenantScopeJoinSql("Location");
  542. /// <summary>进出存流水贴源的库位字段名(InvTransHist 用 Loc)。</summary>
  543. private static readonly string TransTenantScopeJoin = TenantScopeJoinSql("Loc");
  544. /// <summary>
  545. /// FULL Replace 的删除范围:**只替换「当前租户 + 当前正式源 + 当前 domain」这一份物化投影**。
  546. /// 绝不能只按 tenant_id 删 —— 那会连带删掉 <c>source_system='UAT_GENERATOR'</c> 之类的
  547. /// 非正式切片(UAT 名下现有 168 行 fixture,本批不做 fixture 治理)。
  548. /// sourceSystem/domain 均来自服务端配置(<c>AidopInventoryOptions</c>),非用户输入;
  549. /// 仍做标识符白名单校验,杜绝任何拼接注入。
  550. /// </summary>
  551. /// <summary>
  552. /// 标准层物化的命令超时(秒)。默认 30s 不够:正式切片 FULL REPLACE 单次要
  553. /// DELETE 40 万+ 行再 INSERT ... SELECT 40 万+ 行,实测 DELETE 一步就超时,
  554. /// 且超时后连接已断、连 ROLLBACK 都会抛 "Connection must be Open" 掩盖真正的超时异常。
  555. /// 用 <see cref="WithLongCommandTimeout"/> 在物化区间内临时放宽、退出时还原。
  556. /// </summary>
  557. private const int MaterializeCommandTimeoutSeconds = 900;
  558. /// <summary>物化区间内临时放宽命令超时,Dispose 时还原原值(异常路径同样还原)。</summary>
  559. private sealed class LongCommandTimeoutScope : IDisposable
  560. {
  561. private readonly ISqlSugarClient _db;
  562. private readonly int _original;
  563. public LongCommandTimeoutScope(ISqlSugarClient db, int seconds)
  564. {
  565. _db = db;
  566. _original = db.Ado.CommandTimeOut;
  567. db.Ado.CommandTimeOut = seconds;
  568. }
  569. public void Dispose() => _db.Ado.CommandTimeOut = _original;
  570. }
  571. private LongCommandTimeoutScope WithLongCommandTimeout()
  572. => new(_db, MaterializeCommandTimeoutSeconds);
  573. private static string FormalSliceWhere(string sourceSystem, string domain)
  574. {
  575. if (!System.Text.RegularExpressions.Regex.IsMatch(sourceSystem, "^[A-Za-z0-9_]+$"))
  576. throw new InvalidOperationException($"非法 source_system:{sourceSystem}");
  577. if (!System.Text.RegularExpressions.Regex.IsMatch(domain, "^[A-Za-z0-9_]+$"))
  578. throw new InvalidOperationException($"非法 domain:{domain}");
  579. return $"source_system='{sourceSystem}' AND domain='{domain}'";
  580. }
  581. /// <summary>
  582. /// 枚举该 domain 下**拥有合法库存范围**的租户:即在 LocationMaster 里配了非 Supp 库位的启用租户。
  583. /// 没有库位范围的租户(如默认租户)不会被物化,标准层里不会出现它的快照。
  584. /// </summary>
  585. private async Task<List<long>> ListInventoryScopedTenantsAsync(string domain, CancellationToken ct)
  586. {
  587. return await _db.Ado.SqlQueryAsync<long>(
  588. """
  589. SELECT DISTINCT lm.tenant_id
  590. FROM LocationMaster lm
  591. JOIN SysTenant t ON t.Id = lm.tenant_id AND t.Status = 1
  592. WHERE lm.Domain = @Domain
  593. AND IFNULL(lm.typed,'') <> 'Supp'
  594. AND TRIM(lm.location) <> ''
  595. ORDER BY lm.tenant_id
  596. """,
  597. new List<SugarParameter> { new("@Domain", domain) });
  598. }
  599. /// <summary>
  600. /// 进出存流水 stg→std **按业务租户物化**:一次贴源、逐目标租户按各自库位范围投影。
  601. /// <para>
  602. /// 修复前本方法只有一个 <c>tenantId</c> 参数,把「源归属租户」当成了「业务租户」:
  603. /// <c>ado_source_domain_tenant_map</c> 把 DOPDEMORQ_SQLSERVER/8010 登记在 797 名下,
  604. /// 于是全部流水标准层都写 797,UAT(838257186181189) 名下恒为 0 —— 而正式贴源层里
  605. /// 落在 UAT 18 个合法库位上的流水实测有 188,419 行。余额腿(InsertInventoryStdAsync)
  606. /// 早已是「逐租户 + TenantScopeJoin」,本方法此前漏了这一步,本次对齐。
  607. /// </para>
  608. /// </summary>
  609. /// <param name="sourceTenantId">贴源层归属租户(决定读哪批 stg),非业务归属。</param>
  610. /// <param name="targetTenantId">业务租户(决定 std.tenant_id 与库位投影范围)。</param>
  611. /// <param name="replaceMode">true=已由 MdpStdFullReplace 删除正式切片,直接 INSERT;false=增量 UPSERT。</param>
  612. private async Task<int> MaterializeInvTransStdAsync(
  613. long sourceTenantId, long targetTenantId, string? batchId,
  614. DateTime asOf, DateTime historyFrom, string sourceSystem)
  615. {
  616. // batchId 为空:转换该源租户下全部已贴源 InvTransHist(中断恢复 / 补物化用)
  617. var batchPred = string.IsNullOrWhiteSpace(batchId)
  618. ? "1=1"
  619. : "s.sync_batch_id=@BatchId";
  620. var syncBatchExpr = string.IsNullOrWhiteSpace(batchId)
  621. ? "s.sync_batch_id"
  622. : "@BatchId";
  623. // 业务归属 = 目标租户,不再从 stg 反推
  624. var sTenant = "@TargetTenantId";
  625. var sql =
  626. $"""
  627. INSERT INTO mdp_std_inv_trans
  628. (tenant_id, source_system, domain, src_rec_id, trans_type, item_num, lot_serial, location,
  629. dimension1, dimension2, refs, site, qty_change, begin_balance, end_balance,
  630. eff_date, trans_time, ord_nbr, work_ord, shipper_num, ship_type, reason, remark, create_user,
  631. history_from, as_of, sync_batch_id)
  632. SELECT
  633. {sTenant},
  634. @SourceSystem,
  635. IFNULL({MdpJsonSql.Str("s", "Domain")}, ''),
  636. s.source_row_id,
  637. {MdpJsonSql.Str("s", "TransType")},
  638. {MdpJsonSql.Str("s", "ItemNum")},
  639. {MdpJsonSql.Str("s", "LotSerial")},
  640. {MdpJsonSql.Str("s", "Loc")},
  641. IFNULL({MdpJsonSql.Str("s", "Dimension1")}, ''),
  642. IFNULL({MdpJsonSql.Str("s", "Dimension2")}, ''),
  643. {MdpJsonSql.Str("s", "Refs")},
  644. {MdpJsonSql.Str("s", "Site")},
  645. IFNULL({MdpJsonSql.Dec("s", "QtyChange", 18, 5)}, 0),
  646. IFNULL({MdpJsonSql.Dec("s", "BeginBalance", 18, 5)}, 0),
  647. IFNULL({MdpJsonSql.Dec("s", "BeginBalance", 18, 5)}, 0) + IFNULL({MdpJsonSql.Dec("s", "QtyChange", 18, 5)}, 0),
  648. {MdpJsonSql.DateTimeSec("s", "EffDate")},
  649. {MdpJsonSql.DateTimeSec("s", "CreateTime")},
  650. {MdpJsonSql.Str("s", "OrdNbr")},
  651. {MdpJsonSql.Str("s", "WorkOrd")},
  652. {MdpJsonSql.Str("s", "ShipperNum")},
  653. {MdpJsonSql.Str("s", "ShipType")},
  654. {MdpJsonSql.Str("s", "Reason")},
  655. {MdpJsonSql.Str("s", "Remark")},
  656. {MdpJsonSql.Str("s", "CreateUser")},
  657. @HistoryFrom,
  658. @AsOf,
  659. {syncBatchExpr}
  660. FROM mdp_stg_inv_trans s
  661. {TransTenantScopeJoin}
  662. WHERE s.tenant_id=@SourceTenantId
  663. AND s.source_system=@SourceSystem
  664. AND s.source_table='InvTransHist'
  665. AND {batchPred}
  666. ON DUPLICATE KEY UPDATE
  667. trans_type=VALUES(trans_type),
  668. item_num=VALUES(item_num),
  669. lot_serial=VALUES(lot_serial),
  670. location=VALUES(location),
  671. qty_change=VALUES(qty_change),
  672. begin_balance=VALUES(begin_balance),
  673. end_balance=VALUES(end_balance),
  674. eff_date=VALUES(eff_date),
  675. trans_time=VALUES(trans_time),
  676. ord_nbr=VALUES(ord_nbr),
  677. work_ord=VALUES(work_ord),
  678. shipper_num=VALUES(shipper_num),
  679. ship_type=VALUES(ship_type),
  680. reason=VALUES(reason),
  681. remark=VALUES(remark),
  682. create_user=VALUES(create_user),
  683. as_of=VALUES(as_of),
  684. sync_batch_id=VALUES(sync_batch_id),
  685. update_time=CURRENT_TIMESTAMP
  686. """;
  687. var ps = new List<SugarParameter>
  688. {
  689. new("@SourceTenantId", sourceTenantId),
  690. new("@TargetTenantId", targetTenantId),
  691. new("@SourceSystem", sourceSystem),
  692. new("@AsOf", asOf),
  693. new("@HistoryFrom", historyFrom)
  694. };
  695. if (!string.IsNullOrWhiteSpace(batchId))
  696. ps.Add(new SugarParameter("@BatchId", batchId));
  697. return await _db.Ado.ExecuteCommandAsync(sql, ps);
  698. }
  699. // 取/放锁已迁至 InventoryInboundLockGuard:
  700. // 原实现在共享 _db(IsAutoCloseConnection=true)上跑 GET_LOCK/RELEASE_LOCK,
  701. // 命令执行完连接即回池并被驱动 reset,MySQL 当场释放咨询锁 → 跨实例互斥失效。
  702. private sealed class UpperRow
  703. {
  704. public string? CursorText { get; set; }
  705. public string? TieText { get; set; }
  706. }
  707. }
  708. public sealed class InventorySyncResult
  709. {
  710. public string BatchId { get; init; } = "";
  711. public long TenantId { get; init; }
  712. public string Domain { get; init; } = "";
  713. public bool Bootstrap { get; init; }
  714. public int LocationPulled { get; init; }
  715. public int LocationWritten { get; init; }
  716. public int InventoryStdRows { get; init; }
  717. public int TransPulled { get; init; }
  718. public int TransWritten { get; init; }
  719. public int TransStdRows { get; init; }
  720. public DateTime AsOf { get; init; }
  721. public DateTime? HistoryFrom { get; init; }
  722. public bool Skipped { get; init; }
  723. public string? Message { get; init; }
  724. }