InventoryMdpSyncService.cs 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  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. /// </summary>
  49. public async Task<InventorySyncResult> TransformTransStdFromStgAsync(CancellationToken cancellationToken = default)
  50. {
  51. cancellationToken.ThrowIfCancellationRequested();
  52. var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? SourceCodeDefault : _opt.SourceCode.Trim();
  53. var domain = string.IsNullOrWhiteSpace(_opt.DefaultDomain) ? "8010" : _opt.DefaultDomain.Trim();
  54. var tenantId = await _domainTenant.ResolveTenantIdAsync(sourceCode, domain, cancellationToken);
  55. var asOf = DateTime.Now;
  56. var months = _opt.TransBootstrapMonths <= 0 ? 12 : _opt.TransBootstrapMonths;
  57. var historyFrom = asOf.Date.AddMonths(-months);
  58. var batchId = $"S5_INV_XFORM_{asOf:yyyyMMddHHmmss}";
  59. // await using:异常路径也一定走到释放(DisposeAsync 内先 RELEASE_LOCK 再关专用连接)
  60. await using var guard = await InventoryInboundLockGuard.TryAcquireAsync(_db, _logger, cancellationToken: cancellationToken);
  61. if (!guard.Acquired)
  62. {
  63. _logger.LogWarning("[InventoryMdpSync] 互斥锁占用,transform-std 本轮跳过 batch={Batch} reason={Reason}",
  64. batchId, guard.BusyReason);
  65. return new InventorySyncResult
  66. {
  67. BatchId = batchId,
  68. TenantId = tenantId,
  69. Domain = domain,
  70. AsOf = asOf,
  71. HistoryFrom = historyFrom,
  72. Skipped = true,
  73. Message = "lock busy"
  74. };
  75. }
  76. var rows = await UpsertInvTransStdAsync(tenantId, batchId: null, asOf, historyFrom, sourceCode);
  77. _logger.LogInformation("[InventoryMdpSync] transform-std done tenant={Tenant} rows={Rows}", tenantId, rows);
  78. return new InventorySyncResult
  79. {
  80. BatchId = batchId,
  81. TenantId = tenantId,
  82. Domain = domain,
  83. TransStdRows = rows,
  84. AsOf = asOf,
  85. HistoryFrom = historyFrom,
  86. Message = "OK transform-std"
  87. };
  88. }
  89. /// <summary>
  90. /// 仅 stg→std(库存余额):**不访问源库**,从一个已完整落地的贴源批次重新物化标准层。
  91. /// <para>
  92. /// 用途:同步链路修复后,复用既有完整 stg 批次让各租户按新的库位范围口径重新物化,
  93. /// 避免为此重新全量拉取源库。
  94. /// </para>
  95. /// <para>
  96. /// 语义为 <b>UPSERT,不做 FULL REPLACE</b>:只按业务键写入/更新本批次覆盖到的行,
  97. /// 不删除任何既有标准层数据 —— 单个增量批次不代表全量,replace 会造成数据丢失。
  98. /// 因此本入口<b>不负责</b>清理历史脏快照,那属于全量校准(reconcile)的职责。
  99. /// </para>
  100. /// </summary>
  101. /// <param name="stgBatchId">贴源批次号(sync_batch_id),必须是已完整落地的批次。</param>
  102. public async Task<InventorySyncResult> TransformInventoryStdFromStgAsync(
  103. string stgBatchId, CancellationToken cancellationToken = default)
  104. {
  105. cancellationToken.ThrowIfCancellationRequested();
  106. if (string.IsNullOrWhiteSpace(stgBatchId)
  107. || !System.Text.RegularExpressions.Regex.IsMatch(stgBatchId, @"^[A-Za-z0-9_]+$"))
  108. throw new InvalidOperationException("非法贴源批次号");
  109. var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? SourceCodeDefault : _opt.SourceCode.Trim();
  110. var domain = string.IsNullOrWhiteSpace(_opt.DefaultDomain) ? "8010" : _opt.DefaultDomain.Trim();
  111. var sourceTenantId = await _domainTenant.ResolveTenantIdAsync(sourceCode, domain, cancellationToken);
  112. var asOf = DateTime.Now;
  113. // 本入口同样写 mdp_std_inventory,必须与 bootstrap/reconcile/incremental 互斥(原实现漏取锁)
  114. await using var guard = await InventoryInboundLockGuard.TryAcquireAsync(_db, _logger, cancellationToken: cancellationToken);
  115. if (!guard.Acquired)
  116. {
  117. _logger.LogWarning("[InventoryMdpSync] 互斥锁占用,transform-inventory-std 本轮跳过 batch={Batch} reason={Reason}",
  118. stgBatchId, guard.BusyReason);
  119. return new InventorySyncResult
  120. {
  121. BatchId = stgBatchId,
  122. TenantId = sourceTenantId,
  123. Domain = domain,
  124. AsOf = asOf,
  125. Skipped = true,
  126. Message = "lock busy"
  127. };
  128. }
  129. var stgRows = await _db.Ado.GetIntAsync(
  130. """
  131. SELECT COUNT(1) FROM mdp_stg_inventory
  132. WHERE tenant_id=@TenantId AND source_system=@SourceSystem
  133. AND source_table='LocationDetail' AND sync_batch_id=@BatchId
  134. """,
  135. new List<SugarParameter>
  136. {
  137. new("@TenantId", sourceTenantId),
  138. new("@SourceSystem", sourceCode),
  139. new("@BatchId", stgBatchId)
  140. });
  141. if (stgRows == 0)
  142. throw new InvalidOperationException(
  143. $"贴源批次为空或不属于源归属租户:batch={stgBatchId}, sourceTenant={sourceTenantId}");
  144. var targetTenants = await ListInventoryScopedTenantsAsync(domain, cancellationToken);
  145. var total = 0;
  146. foreach (var targetTenantId in targetTenants)
  147. {
  148. cancellationToken.ThrowIfCancellationRequested();
  149. var rows = await InsertInventoryStdAsync(
  150. targetTenantId, sourceTenantId, stgBatchId, asOf, sourceCode, replaceMode: false);
  151. total += rows;
  152. _logger.LogInformation(
  153. "[InventoryMdpSync] std re-materialized from stg tenant={Tenant} domain={Domain} batch={Batch} rows={Rows}",
  154. targetTenantId, domain, stgBatchId, rows);
  155. }
  156. return new InventorySyncResult
  157. {
  158. BatchId = stgBatchId,
  159. TenantId = sourceTenantId,
  160. Domain = domain,
  161. InventoryStdRows = total,
  162. AsOf = asOf,
  163. Message = $"OK transform-inventory-std from stg (stgRows={stgRows}, tenants={targetTenants.Count})"
  164. };
  165. }
  166. private async Task<InventorySyncResult> RunAsync(bool bootstrap, bool reconcile, CancellationToken cancellationToken)
  167. {
  168. cancellationToken.ThrowIfCancellationRequested();
  169. var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? SourceCodeDefault : _opt.SourceCode.Trim();
  170. var domain = string.IsNullOrWhiteSpace(_opt.DefaultDomain) ? "8010" : _opt.DefaultDomain.Trim();
  171. // 源归属租户:只决定贴源层(stg)落在谁名下,**不代表业务归属**;
  172. // 业务归属在标准层物化时按各租户 LocationMaster 库位范围投影决定。
  173. var tenantId = await _domainTenant.ResolveTenantIdAsync(sourceCode, domain, cancellationToken);
  174. var asOf = DateTime.Now;
  175. var batchId = $"S5_INV_{(bootstrap ? "BOOT" : reconcile ? "RECON" : "INCR")}_{asOf:yyyyMMddHHmmss}";
  176. var months = _opt.TransBootstrapMonths <= 0 ? 12 : _opt.TransBootstrapMonths;
  177. var historyFrom = asOf.Date.AddMonths(-months);
  178. // await using:异常路径也一定走到释放(DisposeAsync 内先 RELEASE_LOCK 再关专用连接)
  179. await using var guard = await InventoryInboundLockGuard.TryAcquireAsync(_db, _logger, cancellationToken: cancellationToken);
  180. if (!guard.Acquired)
  181. {
  182. _logger.LogWarning("[InventoryMdpSync] 跨实例锁占用,本轮跳过 batch={Batch} reason={Reason}", batchId, guard.BusyReason);
  183. return new InventorySyncResult
  184. {
  185. BatchId = batchId,
  186. TenantId = tenantId,
  187. Domain = domain,
  188. Bootstrap = bootstrap,
  189. AsOf = asOf,
  190. HistoryFrom = historyFrom,
  191. Skipped = true,
  192. Message = "lock busy"
  193. };
  194. }
  195. var upperLoc = await CaptureUpperBoundAsync(sourceCode, "LocationDetail", "UpdateTime", cancellationToken);
  196. var upperTrans = await CaptureUpperBoundAsync(sourceCode, "InvTransHist", "CreateTime", cancellationToken);
  197. MdpPullResult locPull;
  198. if (bootstrap || reconcile)
  199. {
  200. var locBatch = $"{batchId}_LOC";
  201. // NULL UpdateTime 段与非 NULL 段共用同一 batchId,保证 Replace 不漏 NULL 行
  202. var nullCtx = BuildKeysetCtx(tenantId, locBatch, asOf, historyFrom, upperLoc,
  203. cursorColumn: "UpdateTime", nullPhase: true, bootstrapFull: true);
  204. nullCtx.CursorValue = null;
  205. nullCtx.TieBreakerValue = null;
  206. await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, nullCtx, cancellationToken, maxPages: 50);
  207. var fullCtx = BuildKeysetCtx(tenantId, locBatch, asOf, historyFrom, upperLoc,
  208. cursorColumn: "UpdateTime", nullPhase: false, bootstrapFull: true);
  209. fullCtx.CursorValue = null;
  210. fullCtx.TieBreakerValue = null;
  211. fullCtx.BootstrapFrom = null; // LocationDetail 首刷不截时间窗
  212. locPull = await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, fullCtx, cancellationToken, maxPages: 200);
  213. }
  214. else
  215. {
  216. var incrCtx = BuildKeysetCtx(tenantId, $"{batchId}_LOC", asOf, historyFrom, upperLoc,
  217. cursorColumn: "UpdateTime", nullPhase: false, bootstrapFull: false);
  218. // 重叠窗口:从上次游标时间向前回退 LocationOverlapMinutes
  219. if (!string.IsNullOrWhiteSpace(incrCtx.CursorValue)
  220. && DateTime.TryParse(incrCtx.CursorValue, out var lastDt))
  221. {
  222. var overlap = Math.Max(0, _opt.LocationOverlapMinutes);
  223. incrCtx.CursorValue = lastDt.AddMinutes(-overlap)
  224. .ToString("yyyy-MM-dd HH:mm:ss.fff");
  225. incrCtx.TieBreakerValue = "0";
  226. }
  227. locPull = await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, incrCtx, cancellationToken, maxPages: 200);
  228. }
  229. // —— 标准层按租户物化:一次贴源,逐租户按各自库位范围投影 ——
  230. // 贴源层是「源+domain」维度(归属 sourceTenantId),标准层是「租户」维度。
  231. // 拉取游标持久化在 mdp_entity 上、跨租户共享,故绝不能为每个租户各拉一次。
  232. var targetTenants = await ListInventoryScopedTenantsAsync(domain, cancellationToken);
  233. if (targetTenants.Count == 0)
  234. _logger.LogWarning(
  235. "[InventoryMdpSync] domain={Domain} 无任何配置了合法库位的租户,标准层本轮不写入", domain);
  236. var inventoryStdRows = 0;
  237. foreach (var targetTenantId in targetTenants)
  238. {
  239. cancellationToken.ThrowIfCancellationRequested();
  240. int rows;
  241. if (bootstrap || reconcile)
  242. {
  243. rows = await MdpStdFullReplace.ReplaceAsync(
  244. _db,
  245. "mdp_std_inventory",
  246. targetTenantId,
  247. "source_system='DOPDEMORQ_SQLSERVER'",
  248. () => InsertInventoryStdAsync(targetTenantId, tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: true),
  249. cancellationToken);
  250. }
  251. else
  252. {
  253. rows = await InsertInventoryStdAsync(targetTenantId, tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: false);
  254. }
  255. inventoryStdRows += rows;
  256. _logger.LogInformation(
  257. "[InventoryMdpSync] std materialized tenant={Tenant} domain={Domain} rows={Rows}",
  258. targetTenantId, domain, rows);
  259. }
  260. var transCtx = BuildKeysetCtx(tenantId, $"{batchId}_TRN", asOf, historyFrom, upperTrans,
  261. cursorColumn: "CreateTime", nullPhase: false, bootstrapFull: bootstrap || reconcile);
  262. if (bootstrap || reconcile)
  263. {
  264. transCtx.CursorValue = null;
  265. transCtx.TieBreakerValue = null;
  266. transCtx.BootstrapFrom = historyFrom;
  267. }
  268. var transPull = await _pullDispatcher.PullAllByEntityCodeAsync(TransEntity, transCtx, cancellationToken, maxPages: 500);
  269. var transStdRows = await UpsertInvTransStdAsync(tenantId, $"{batchId}_TRN", asOf, historyFrom, sourceCode);
  270. _logger.LogInformation(
  271. "[InventoryMdpSync] done batch={Batch} boot={Boot} recon={Recon} locPulled={LocP} invStd={Inv} trnPulled={TrnP} trnStd={Trn}",
  272. batchId, bootstrap, reconcile, locPull.RowsPulled, inventoryStdRows, transPull.RowsPulled, transStdRows);
  273. return new InventorySyncResult
  274. {
  275. BatchId = batchId,
  276. TenantId = tenantId,
  277. Domain = domain,
  278. Bootstrap = bootstrap,
  279. LocationPulled = locPull.RowsPulled,
  280. LocationWritten = locPull.RowsWritten,
  281. InventoryStdRows = inventoryStdRows,
  282. TransPulled = transPull.RowsPulled,
  283. TransWritten = transPull.RowsWritten,
  284. TransStdRows = transStdRows,
  285. AsOf = asOf,
  286. HistoryFrom = historyFrom,
  287. Message = "OK"
  288. };
  289. }
  290. private MdpPullContext BuildKeysetCtx(
  291. long tenantId,
  292. string batchId,
  293. DateTime asOf,
  294. DateTime historyFrom,
  295. (string? Cursor, string? Tie) upper,
  296. string cursorColumn,
  297. bool nullPhase,
  298. bool bootstrapFull)
  299. {
  300. return new MdpPullContext
  301. {
  302. TenantId = tenantId,
  303. BatchId = batchId,
  304. FullRefresh = false,
  305. UseKeysetCursor = true,
  306. CursorColumn = cursorColumn,
  307. TieBreakerColumn = "RecID",
  308. UpperCursorValue = upper.Cursor,
  309. UpperTieBreakerValue = upper.Tie,
  310. BootstrapFrom = bootstrapFull && cursorColumn == "CreateTime" ? historyFrom : null,
  311. DeferCursorPersist = false,
  312. NullTimePhase = nullPhase,
  313. // 首刷/校准不得继承实体脏水位,否则只会抽到「游标之后」的尾巴
  314. SkipPersistedKeysetCursor = bootstrapFull || nullPhase
  315. };
  316. }
  317. private async Task<(string? Cursor, string? Tie)> CaptureUpperBoundAsync(
  318. string sourceCode,
  319. string table,
  320. string cursorColumn,
  321. CancellationToken ct)
  322. {
  323. if (!System.Text.RegularExpressions.Regex.IsMatch(table, @"^[A-Za-z0-9_]+$")
  324. || !System.Text.RegularExpressions.Regex.IsMatch(cursorColumn, @"^[A-Za-z0-9_]+$"))
  325. throw new InvalidOperationException("非法上界查询标识符");
  326. var remote = await _scopeFactory.GetScopeAsync(sourceCode, ct);
  327. var rows = await remote.Ado.SqlQueryAsync<UpperRow>(
  328. $"""
  329. SELECT TOP 1
  330. CONVERT(varchar(30), {cursorColumn}, 121) AS CursorText,
  331. CAST(RecID AS varchar(30)) AS TieText
  332. FROM {table}
  333. WHERE {cursorColumn} IS NOT NULL
  334. ORDER BY {cursorColumn} DESC, RecID DESC
  335. """);
  336. var hit = rows.FirstOrDefault();
  337. return (hit?.CursorText, hit?.TieText);
  338. }
  339. /// <summary>
  340. /// stg → std 物化:**按目标租户的合法库位范围投影**。
  341. /// <para>
  342. /// 贴源层(stg)是「源 + domain」维度的全量落地区,归属 <paramref name="sourceTenantId"/>;
  343. /// 标准层(std)是「租户」维度的可见快照,因此这里必须内联 LocationMaster 做投影:
  344. /// 只有落在目标租户自己 LocationMaster(同 Domain 且 Typed &lt;&gt; 'Supp')内的库位才写入。
  345. /// </para>
  346. /// <para>
  347. /// 写入不变量:∀ 写入行 → tenant_id = targetTenantId
  348. /// ∧ location ∈ AllowedLocations(targetTenantId) ∧ domain = 该租户 LocationMaster 的 Domain。
  349. /// 租户白名单为空 → JOIN 命中 0 行 → 写 0 条(fail closed,绝不退回整个 Domain)。
  350. /// </para>
  351. /// <para>
  352. /// tenant_id 直接取 <paramref name="targetTenantId"/> 而非 MdpJsonSql.TenantFromStg:
  353. /// 贴源行的 tenant 是「源落地区归属」,不是业务归属,不能顺着传下来。
  354. /// </para>
  355. /// </summary>
  356. private async Task<int> InsertInventoryStdAsync(
  357. long targetTenantId, long sourceTenantId, string batchId, DateTime asOf, string sourceSystem, bool replaceMode)
  358. {
  359. // replaceMode:MdpStdFullReplace 已 DELETE,直接 INSERT;
  360. // 增量:UPSERT 本批变化。
  361. var sTenant = "@TargetTenantId";
  362. var sql = replaceMode
  363. ? $"""
  364. INSERT INTO mdp_std_inventory
  365. (tenant_id, source_system, domain, location, lot_serial, item_num,
  366. dimension1, dimension2, refs, site, inv_status,
  367. qty_on_hand, qty_unrestricted, qty_inspection, qty_frozen, qty_available,
  368. src_rec_id, source_update_time, as_of, sync_batch_id)
  369. SELECT
  370. {sTenant},
  371. @SourceSystem,
  372. IFNULL({MdpJsonSql.Str("s", "Domain")}, ''),
  373. IFNULL({MdpJsonSql.Str("s", "Location")}, ''),
  374. IFNULL({MdpJsonSql.Str("s", "LotSerial")}, ''),
  375. IFNULL({MdpJsonSql.Str("s", "ItemNum")}, ''),
  376. IFNULL({MdpJsonSql.Str("s", "Dimension1")}, ''),
  377. IFNULL({MdpJsonSql.Str("s", "Dimension2")}, ''),
  378. IFNULL({MdpJsonSql.Str("s", "Refs")}, ''),
  379. IFNULL({MdpJsonSql.Str("s", "Site")}, ''),
  380. {MdpJsonSql.Str("s", "InvStatus")},
  381. IFNULL({MdpJsonSql.Dec("s", "QtyOnHand", 18, 5)}, 0),
  382. IFNULL({MdpJsonSql.Dec("s", "AvailStatusQty", 18, 5)}, 0),
  383. IFNULL({MdpJsonSql.Dec("s", "Assay", 18, 5)}, 0),
  384. IFNULL({MdpJsonSql.Dec("s", "FreezeQty", 18, 5)}, 0),
  385. IFNULL({MdpJsonSql.Dec("s", "AvailStatusQty", 18, 5)}, 0),
  386. s.source_row_id,
  387. {MdpJsonSql.DateTimeSec("s", "UpdateTime")},
  388. @AsOf,
  389. @BatchId
  390. FROM mdp_stg_inventory s
  391. {TenantScopeJoin}
  392. WHERE s.tenant_id=@SourceTenantId
  393. AND s.source_system=@SourceSystem
  394. AND s.source_table='LocationDetail'
  395. AND s.sync_batch_id=@BatchId
  396. """
  397. : $"""
  398. INSERT INTO mdp_std_inventory
  399. (tenant_id, source_system, domain, location, lot_serial, item_num,
  400. dimension1, dimension2, refs, site, inv_status,
  401. qty_on_hand, qty_unrestricted, qty_inspection, qty_frozen, qty_available,
  402. src_rec_id, source_update_time, as_of, sync_batch_id)
  403. SELECT
  404. {sTenant},
  405. @SourceSystem,
  406. IFNULL({MdpJsonSql.Str("s", "Domain")}, ''),
  407. IFNULL({MdpJsonSql.Str("s", "Location")}, ''),
  408. IFNULL({MdpJsonSql.Str("s", "LotSerial")}, ''),
  409. IFNULL({MdpJsonSql.Str("s", "ItemNum")}, ''),
  410. IFNULL({MdpJsonSql.Str("s", "Dimension1")}, ''),
  411. IFNULL({MdpJsonSql.Str("s", "Dimension2")}, ''),
  412. IFNULL({MdpJsonSql.Str("s", "Refs")}, ''),
  413. IFNULL({MdpJsonSql.Str("s", "Site")}, ''),
  414. {MdpJsonSql.Str("s", "InvStatus")},
  415. IFNULL({MdpJsonSql.Dec("s", "QtyOnHand", 18, 5)}, 0),
  416. IFNULL({MdpJsonSql.Dec("s", "AvailStatusQty", 18, 5)}, 0),
  417. IFNULL({MdpJsonSql.Dec("s", "Assay", 18, 5)}, 0),
  418. IFNULL({MdpJsonSql.Dec("s", "FreezeQty", 18, 5)}, 0),
  419. IFNULL({MdpJsonSql.Dec("s", "AvailStatusQty", 18, 5)}, 0),
  420. s.source_row_id,
  421. {MdpJsonSql.DateTimeSec("s", "UpdateTime")},
  422. @AsOf,
  423. @BatchId
  424. FROM mdp_stg_inventory s
  425. {TenantScopeJoin}
  426. WHERE s.tenant_id=@SourceTenantId
  427. AND s.source_system=@SourceSystem
  428. AND s.source_table='LocationDetail'
  429. AND s.sync_batch_id=@BatchId
  430. ON DUPLICATE KEY UPDATE
  431. inv_status=VALUES(inv_status),
  432. qty_on_hand=VALUES(qty_on_hand),
  433. qty_unrestricted=VALUES(qty_unrestricted),
  434. qty_inspection=VALUES(qty_inspection),
  435. qty_frozen=VALUES(qty_frozen),
  436. qty_available=VALUES(qty_available),
  437. src_rec_id=VALUES(src_rec_id),
  438. source_update_time=VALUES(source_update_time),
  439. as_of=VALUES(as_of),
  440. sync_batch_id=VALUES(sync_batch_id),
  441. update_time=CURRENT_TIMESTAMP
  442. """;
  443. return await _db.Ado.ExecuteCommandAsync(sql,
  444. new SugarParameter("@TargetTenantId", targetTenantId),
  445. new SugarParameter("@SourceTenantId", sourceTenantId),
  446. new SugarParameter("@SourceSystem", sourceSystem),
  447. new SugarParameter("@BatchId", batchId),
  448. new SugarParameter("@AsOf", asOf));
  449. }
  450. /// <summary>
  451. /// 租户库位范围内联投影:贴源行只有落在目标租户自己的合法库位(同 Domain、Typed &lt;&gt; 'Supp')
  452. /// 才允许进入标准层。这是标准层写入侧的租户安全边界。
  453. /// </summary>
  454. private static readonly string TenantScopeJoin =
  455. $"""
  456. INNER JOIN LocationMaster lm
  457. ON lm.tenant_id = @TargetTenantId
  458. AND lm.Domain = IFNULL({MdpJsonSql.Str("s", "Domain")}, '')
  459. AND lm.location = IFNULL({MdpJsonSql.Str("s", "Location")}, '')
  460. AND IFNULL(lm.typed, '') <> 'Supp'
  461. AND TRIM(lm.location) <> ''
  462. """;
  463. /// <summary>
  464. /// 枚举该 domain 下**拥有合法库存范围**的租户:即在 LocationMaster 里配了非 Supp 库位的启用租户。
  465. /// 没有库位范围的租户(如默认租户)不会被物化,标准层里不会出现它的快照。
  466. /// </summary>
  467. private async Task<List<long>> ListInventoryScopedTenantsAsync(string domain, CancellationToken ct)
  468. {
  469. return await _db.Ado.SqlQueryAsync<long>(
  470. """
  471. SELECT DISTINCT lm.tenant_id
  472. FROM LocationMaster lm
  473. JOIN SysTenant t ON t.Id = lm.tenant_id AND t.Status = 1
  474. WHERE lm.Domain = @Domain
  475. AND IFNULL(lm.typed,'') <> 'Supp'
  476. AND TRIM(lm.location) <> ''
  477. ORDER BY lm.tenant_id
  478. """,
  479. new List<SugarParameter> { new("@Domain", domain) });
  480. }
  481. private async Task<int> UpsertInvTransStdAsync(
  482. long tenantId, string? batchId, DateTime asOf, DateTime historyFrom, string sourceSystem)
  483. {
  484. // batchId 为空:转换该租户下全部已贴源 InvTransHist(中断恢复用)
  485. var batchPred = string.IsNullOrWhiteSpace(batchId)
  486. ? "1=1"
  487. : "s.sync_batch_id=@BatchId";
  488. var syncBatchExpr = string.IsNullOrWhiteSpace(batchId)
  489. ? "s.sync_batch_id"
  490. : "@BatchId";
  491. var sTenant = MdpJsonSql.TenantFromStg("s", "@TenantId");
  492. var sql =
  493. $"""
  494. INSERT INTO mdp_std_inv_trans
  495. (tenant_id, source_system, domain, src_rec_id, trans_type, item_num, lot_serial, location,
  496. dimension1, dimension2, refs, site, qty_change, begin_balance, end_balance,
  497. eff_date, trans_time, ord_nbr, work_ord, shipper_num, ship_type, reason, remark, create_user,
  498. history_from, as_of, sync_batch_id)
  499. SELECT
  500. {sTenant},
  501. @SourceSystem,
  502. IFNULL({MdpJsonSql.Str("s", "Domain")}, ''),
  503. s.source_row_id,
  504. {MdpJsonSql.Str("s", "TransType")},
  505. {MdpJsonSql.Str("s", "ItemNum")},
  506. {MdpJsonSql.Str("s", "LotSerial")},
  507. {MdpJsonSql.Str("s", "Loc")},
  508. IFNULL({MdpJsonSql.Str("s", "Dimension1")}, ''),
  509. IFNULL({MdpJsonSql.Str("s", "Dimension2")}, ''),
  510. {MdpJsonSql.Str("s", "Refs")},
  511. {MdpJsonSql.Str("s", "Site")},
  512. IFNULL({MdpJsonSql.Dec("s", "QtyChange", 18, 5)}, 0),
  513. IFNULL({MdpJsonSql.Dec("s", "BeginBalance", 18, 5)}, 0),
  514. IFNULL({MdpJsonSql.Dec("s", "BeginBalance", 18, 5)}, 0) + IFNULL({MdpJsonSql.Dec("s", "QtyChange", 18, 5)}, 0),
  515. {MdpJsonSql.DateTimeSec("s", "EffDate")},
  516. {MdpJsonSql.DateTimeSec("s", "CreateTime")},
  517. {MdpJsonSql.Str("s", "OrdNbr")},
  518. {MdpJsonSql.Str("s", "WorkOrd")},
  519. {MdpJsonSql.Str("s", "ShipperNum")},
  520. {MdpJsonSql.Str("s", "ShipType")},
  521. {MdpJsonSql.Str("s", "Reason")},
  522. {MdpJsonSql.Str("s", "Remark")},
  523. {MdpJsonSql.Str("s", "CreateUser")},
  524. @HistoryFrom,
  525. @AsOf,
  526. {syncBatchExpr}
  527. FROM mdp_stg_inv_trans s
  528. WHERE s.tenant_id=@TenantId
  529. AND s.source_system=@SourceSystem
  530. AND s.source_table='InvTransHist'
  531. AND {batchPred}
  532. AND {MdpJsonSql.TenantGuard(sTenant)}
  533. ON DUPLICATE KEY UPDATE
  534. trans_type=VALUES(trans_type),
  535. item_num=VALUES(item_num),
  536. lot_serial=VALUES(lot_serial),
  537. location=VALUES(location),
  538. qty_change=VALUES(qty_change),
  539. begin_balance=VALUES(begin_balance),
  540. end_balance=VALUES(end_balance),
  541. eff_date=VALUES(eff_date),
  542. trans_time=VALUES(trans_time),
  543. ord_nbr=VALUES(ord_nbr),
  544. work_ord=VALUES(work_ord),
  545. shipper_num=VALUES(shipper_num),
  546. ship_type=VALUES(ship_type),
  547. reason=VALUES(reason),
  548. remark=VALUES(remark),
  549. create_user=VALUES(create_user),
  550. as_of=VALUES(as_of),
  551. sync_batch_id=VALUES(sync_batch_id),
  552. update_time=CURRENT_TIMESTAMP
  553. """;
  554. var ps = new List<SugarParameter>
  555. {
  556. new("@TenantId", tenantId),
  557. new("@SourceSystem", sourceSystem),
  558. new("@AsOf", asOf),
  559. new("@HistoryFrom", historyFrom)
  560. };
  561. if (!string.IsNullOrWhiteSpace(batchId))
  562. ps.Add(new SugarParameter("@BatchId", batchId));
  563. return await _db.Ado.ExecuteCommandAsync(sql, ps);
  564. }
  565. // 取/放锁已迁至 InventoryInboundLockGuard:
  566. // 原实现在共享 _db(IsAutoCloseConnection=true)上跑 GET_LOCK/RELEASE_LOCK,
  567. // 命令执行完连接即回池并被驱动 reset,MySQL 当场释放咨询锁 → 跨实例互斥失效。
  568. private sealed class UpperRow
  569. {
  570. public string? CursorText { get; set; }
  571. public string? TieText { get; set; }
  572. }
  573. }
  574. public sealed class InventorySyncResult
  575. {
  576. public string BatchId { get; init; } = "";
  577. public long TenantId { get; init; }
  578. public string Domain { get; init; } = "";
  579. public bool Bootstrap { get; init; }
  580. public int LocationPulled { get; init; }
  581. public int LocationWritten { get; init; }
  582. public int InventoryStdRows { get; init; }
  583. public int TransPulled { get; init; }
  584. public int TransWritten { get; init; }
  585. public int TransStdRows { get; init; }
  586. public DateTime AsOf { get; init; }
  587. public DateTime? HistoryFrom { get; init; }
  588. public bool Skipped { get; init; }
  589. public string? Message { get; init; }
  590. }