InventoryMdpSyncService.cs 29 KB

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