InventoryMdpSyncService.cs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  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. private async Task<InventorySyncResult> RunAsync(bool bootstrap, bool reconcile, CancellationToken cancellationToken)
  93. {
  94. cancellationToken.ThrowIfCancellationRequested();
  95. var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? SourceCodeDefault : _opt.SourceCode.Trim();
  96. var domain = string.IsNullOrWhiteSpace(_opt.DefaultDomain) ? "8010" : _opt.DefaultDomain.Trim();
  97. var tenantId = await _domainTenant.ResolveTenantIdAsync(sourceCode, domain, cancellationToken);
  98. var asOf = DateTime.Now;
  99. var batchId = $"S5_INV_{(bootstrap ? "BOOT" : reconcile ? "RECON" : "INCR")}_{asOf:yyyyMMddHHmmss}";
  100. var months = _opt.TransBootstrapMonths <= 0 ? 12 : _opt.TransBootstrapMonths;
  101. var historyFrom = asOf.Date.AddMonths(-months);
  102. var locked = await TryAcquireLockAsync();
  103. if (!locked)
  104. {
  105. _logger.LogWarning("[InventoryMdpSync] 跨实例锁占用,本轮跳过 batch={Batch}", batchId);
  106. return new InventorySyncResult
  107. {
  108. BatchId = batchId,
  109. TenantId = tenantId,
  110. Domain = domain,
  111. Bootstrap = bootstrap,
  112. AsOf = asOf,
  113. HistoryFrom = historyFrom,
  114. Skipped = true,
  115. Message = "lock busy"
  116. };
  117. }
  118. try
  119. {
  120. var upperLoc = await CaptureUpperBoundAsync(sourceCode, "LocationDetail", "UpdateTime", cancellationToken);
  121. var upperTrans = await CaptureUpperBoundAsync(sourceCode, "InvTransHist", "CreateTime", cancellationToken);
  122. MdpPullResult locPull;
  123. if (bootstrap || reconcile)
  124. {
  125. var locBatch = $"{batchId}_LOC";
  126. // NULL UpdateTime 段与非 NULL 段共用同一 batchId,保证 Replace 不漏 NULL 行
  127. var nullCtx = BuildKeysetCtx(tenantId, locBatch, asOf, historyFrom, upperLoc,
  128. cursorColumn: "UpdateTime", nullPhase: true, bootstrapFull: true);
  129. nullCtx.CursorValue = null;
  130. nullCtx.TieBreakerValue = null;
  131. await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, nullCtx, cancellationToken, maxPages: 50);
  132. var fullCtx = BuildKeysetCtx(tenantId, locBatch, asOf, historyFrom, upperLoc,
  133. cursorColumn: "UpdateTime", nullPhase: false, bootstrapFull: true);
  134. fullCtx.CursorValue = null;
  135. fullCtx.TieBreakerValue = null;
  136. fullCtx.BootstrapFrom = null; // LocationDetail 首刷不截时间窗
  137. locPull = await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, fullCtx, cancellationToken, maxPages: 200);
  138. }
  139. else
  140. {
  141. var incrCtx = BuildKeysetCtx(tenantId, $"{batchId}_LOC", asOf, historyFrom, upperLoc,
  142. cursorColumn: "UpdateTime", nullPhase: false, bootstrapFull: false);
  143. // 重叠窗口:从上次游标时间向前回退 LocationOverlapMinutes
  144. if (!string.IsNullOrWhiteSpace(incrCtx.CursorValue)
  145. && DateTime.TryParse(incrCtx.CursorValue, out var lastDt))
  146. {
  147. var overlap = Math.Max(0, _opt.LocationOverlapMinutes);
  148. incrCtx.CursorValue = lastDt.AddMinutes(-overlap)
  149. .ToString("yyyy-MM-dd HH:mm:ss.fff");
  150. incrCtx.TieBreakerValue = "0";
  151. }
  152. locPull = await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, incrCtx, cancellationToken, maxPages: 200);
  153. }
  154. int inventoryStdRows;
  155. if (bootstrap || reconcile)
  156. {
  157. inventoryStdRows = await MdpStdFullReplace.ReplaceAsync(
  158. _db,
  159. "mdp_std_inventory",
  160. tenantId,
  161. "source_system='DOPDEMORQ_SQLSERVER'",
  162. () => InsertInventoryStdAsync(tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: true),
  163. cancellationToken);
  164. }
  165. else
  166. {
  167. inventoryStdRows = await InsertInventoryStdAsync(tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: false);
  168. }
  169. var transCtx = BuildKeysetCtx(tenantId, $"{batchId}_TRN", asOf, historyFrom, upperTrans,
  170. cursorColumn: "CreateTime", nullPhase: false, bootstrapFull: bootstrap || reconcile);
  171. if (bootstrap || reconcile)
  172. {
  173. transCtx.CursorValue = null;
  174. transCtx.TieBreakerValue = null;
  175. transCtx.BootstrapFrom = historyFrom;
  176. }
  177. var transPull = await _pullDispatcher.PullAllByEntityCodeAsync(TransEntity, transCtx, cancellationToken, maxPages: 500);
  178. var transStdRows = await UpsertInvTransStdAsync(tenantId, $"{batchId}_TRN", asOf, historyFrom, sourceCode);
  179. _logger.LogInformation(
  180. "[InventoryMdpSync] done batch={Batch} boot={Boot} recon={Recon} locPulled={LocP} invStd={Inv} trnPulled={TrnP} trnStd={Trn}",
  181. batchId, bootstrap, reconcile, locPull.RowsPulled, inventoryStdRows, transPull.RowsPulled, transStdRows);
  182. return new InventorySyncResult
  183. {
  184. BatchId = batchId,
  185. TenantId = tenantId,
  186. Domain = domain,
  187. Bootstrap = bootstrap,
  188. LocationPulled = locPull.RowsPulled,
  189. LocationWritten = locPull.RowsWritten,
  190. InventoryStdRows = inventoryStdRows,
  191. TransPulled = transPull.RowsPulled,
  192. TransWritten = transPull.RowsWritten,
  193. TransStdRows = transStdRows,
  194. AsOf = asOf,
  195. HistoryFrom = historyFrom,
  196. Message = "OK"
  197. };
  198. }
  199. finally
  200. {
  201. await ReleaseLockAsync();
  202. }
  203. }
  204. private MdpPullContext BuildKeysetCtx(
  205. long tenantId,
  206. string batchId,
  207. DateTime asOf,
  208. DateTime historyFrom,
  209. (string? Cursor, string? Tie) upper,
  210. string cursorColumn,
  211. bool nullPhase,
  212. bool bootstrapFull)
  213. {
  214. return new MdpPullContext
  215. {
  216. TenantId = tenantId,
  217. BatchId = batchId,
  218. FullRefresh = false,
  219. UseKeysetCursor = true,
  220. CursorColumn = cursorColumn,
  221. TieBreakerColumn = "RecID",
  222. UpperCursorValue = upper.Cursor,
  223. UpperTieBreakerValue = upper.Tie,
  224. BootstrapFrom = bootstrapFull && cursorColumn == "CreateTime" ? historyFrom : null,
  225. DeferCursorPersist = false,
  226. NullTimePhase = nullPhase,
  227. // 首刷/校准不得继承实体脏水位,否则只会抽到「游标之后」的尾巴
  228. SkipPersistedKeysetCursor = bootstrapFull || nullPhase
  229. };
  230. }
  231. private async Task<(string? Cursor, string? Tie)> CaptureUpperBoundAsync(
  232. string sourceCode,
  233. string table,
  234. string cursorColumn,
  235. CancellationToken ct)
  236. {
  237. if (!System.Text.RegularExpressions.Regex.IsMatch(table, @"^[A-Za-z0-9_]+$")
  238. || !System.Text.RegularExpressions.Regex.IsMatch(cursorColumn, @"^[A-Za-z0-9_]+$"))
  239. throw new InvalidOperationException("非法上界查询标识符");
  240. var remote = await _scopeFactory.GetScopeAsync(sourceCode, ct);
  241. var rows = await remote.Ado.SqlQueryAsync<UpperRow>(
  242. $"""
  243. SELECT TOP 1
  244. CONVERT(varchar(30), {cursorColumn}, 121) AS CursorText,
  245. CAST(RecID AS varchar(30)) AS TieText
  246. FROM {table}
  247. WHERE {cursorColumn} IS NOT NULL
  248. ORDER BY {cursorColumn} DESC, RecID DESC
  249. """);
  250. var hit = rows.FirstOrDefault();
  251. return (hit?.CursorText, hit?.TieText);
  252. }
  253. private async Task<int> InsertInventoryStdAsync(
  254. long tenantId, string batchId, DateTime asOf, string sourceSystem, bool replaceMode)
  255. {
  256. // replaceMode:MdpStdFullReplace 已 DELETE,直接 INSERT;
  257. // 增量:UPSERT 本批变化。
  258. var sTenant = MdpJsonSql.TenantFromStg("s", "@TenantId");
  259. var sql = replaceMode
  260. ? $"""
  261. INSERT INTO mdp_std_inventory
  262. (tenant_id, source_system, domain, location, lot_serial, item_num,
  263. dimension1, dimension2, refs, site, inv_status,
  264. qty_on_hand, qty_unrestricted, qty_inspection, qty_frozen, qty_available,
  265. src_rec_id, source_update_time, as_of, sync_batch_id)
  266. SELECT
  267. {sTenant},
  268. @SourceSystem,
  269. IFNULL({MdpJsonSql.Str("s", "Domain")}, ''),
  270. IFNULL({MdpJsonSql.Str("s", "Location")}, ''),
  271. IFNULL({MdpJsonSql.Str("s", "LotSerial")}, ''),
  272. IFNULL({MdpJsonSql.Str("s", "ItemNum")}, ''),
  273. IFNULL({MdpJsonSql.Str("s", "Dimension1")}, ''),
  274. IFNULL({MdpJsonSql.Str("s", "Dimension2")}, ''),
  275. IFNULL({MdpJsonSql.Str("s", "Refs")}, ''),
  276. IFNULL({MdpJsonSql.Str("s", "Site")}, ''),
  277. {MdpJsonSql.Str("s", "InvStatus")},
  278. IFNULL({MdpJsonSql.Dec("s", "QtyOnHand", 18, 5)}, 0),
  279. IFNULL({MdpJsonSql.Dec("s", "AvailStatusQty", 18, 5)}, 0),
  280. IFNULL({MdpJsonSql.Dec("s", "Assay", 18, 5)}, 0),
  281. IFNULL({MdpJsonSql.Dec("s", "FreezeQty", 18, 5)}, 0),
  282. IFNULL({MdpJsonSql.Dec("s", "AvailStatusQty", 18, 5)}, 0),
  283. s.source_row_id,
  284. {MdpJsonSql.DateTimeSec("s", "UpdateTime")},
  285. @AsOf,
  286. @BatchId
  287. FROM mdp_stg_inventory s
  288. WHERE s.tenant_id=@TenantId
  289. AND s.source_system=@SourceSystem
  290. AND s.source_table='LocationDetail'
  291. AND s.sync_batch_id=@BatchId
  292. AND {MdpJsonSql.TenantGuard(sTenant)}
  293. """
  294. : $"""
  295. INSERT INTO mdp_std_inventory
  296. (tenant_id, source_system, domain, location, lot_serial, item_num,
  297. dimension1, dimension2, refs, site, inv_status,
  298. qty_on_hand, qty_unrestricted, qty_inspection, qty_frozen, qty_available,
  299. src_rec_id, source_update_time, as_of, sync_batch_id)
  300. SELECT
  301. {sTenant},
  302. @SourceSystem,
  303. IFNULL({MdpJsonSql.Str("s", "Domain")}, ''),
  304. IFNULL({MdpJsonSql.Str("s", "Location")}, ''),
  305. IFNULL({MdpJsonSql.Str("s", "LotSerial")}, ''),
  306. IFNULL({MdpJsonSql.Str("s", "ItemNum")}, ''),
  307. IFNULL({MdpJsonSql.Str("s", "Dimension1")}, ''),
  308. IFNULL({MdpJsonSql.Str("s", "Dimension2")}, ''),
  309. IFNULL({MdpJsonSql.Str("s", "Refs")}, ''),
  310. IFNULL({MdpJsonSql.Str("s", "Site")}, ''),
  311. {MdpJsonSql.Str("s", "InvStatus")},
  312. IFNULL({MdpJsonSql.Dec("s", "QtyOnHand", 18, 5)}, 0),
  313. IFNULL({MdpJsonSql.Dec("s", "AvailStatusQty", 18, 5)}, 0),
  314. IFNULL({MdpJsonSql.Dec("s", "Assay", 18, 5)}, 0),
  315. IFNULL({MdpJsonSql.Dec("s", "FreezeQty", 18, 5)}, 0),
  316. IFNULL({MdpJsonSql.Dec("s", "AvailStatusQty", 18, 5)}, 0),
  317. s.source_row_id,
  318. {MdpJsonSql.DateTimeSec("s", "UpdateTime")},
  319. @AsOf,
  320. @BatchId
  321. FROM mdp_stg_inventory s
  322. WHERE s.tenant_id=@TenantId
  323. AND s.source_system=@SourceSystem
  324. AND s.source_table='LocationDetail'
  325. AND s.sync_batch_id=@BatchId
  326. AND {MdpJsonSql.TenantGuard(sTenant)}
  327. ON DUPLICATE KEY UPDATE
  328. inv_status=VALUES(inv_status),
  329. qty_on_hand=VALUES(qty_on_hand),
  330. qty_unrestricted=VALUES(qty_unrestricted),
  331. qty_inspection=VALUES(qty_inspection),
  332. qty_frozen=VALUES(qty_frozen),
  333. qty_available=VALUES(qty_available),
  334. src_rec_id=VALUES(src_rec_id),
  335. source_update_time=VALUES(source_update_time),
  336. as_of=VALUES(as_of),
  337. sync_batch_id=VALUES(sync_batch_id),
  338. update_time=CURRENT_TIMESTAMP
  339. """;
  340. return await _db.Ado.ExecuteCommandAsync(sql,
  341. new SugarParameter("@TenantId", tenantId),
  342. new SugarParameter("@SourceSystem", sourceSystem),
  343. new SugarParameter("@BatchId", batchId),
  344. new SugarParameter("@AsOf", asOf));
  345. }
  346. private async Task<int> UpsertInvTransStdAsync(
  347. long tenantId, string? batchId, DateTime asOf, DateTime historyFrom, string sourceSystem)
  348. {
  349. // batchId 为空:转换该租户下全部已贴源 InvTransHist(中断恢复用)
  350. var batchPred = string.IsNullOrWhiteSpace(batchId)
  351. ? "1=1"
  352. : "s.sync_batch_id=@BatchId";
  353. var syncBatchExpr = string.IsNullOrWhiteSpace(batchId)
  354. ? "s.sync_batch_id"
  355. : "@BatchId";
  356. var sTenant = MdpJsonSql.TenantFromStg("s", "@TenantId");
  357. var sql =
  358. $"""
  359. INSERT INTO mdp_std_inv_trans
  360. (tenant_id, source_system, domain, src_rec_id, trans_type, item_num, lot_serial, location,
  361. dimension1, dimension2, refs, site, qty_change, begin_balance, end_balance,
  362. eff_date, trans_time, ord_nbr, work_ord, shipper_num, ship_type, reason, remark, create_user,
  363. history_from, as_of, sync_batch_id)
  364. SELECT
  365. {sTenant},
  366. @SourceSystem,
  367. IFNULL({MdpJsonSql.Str("s", "Domain")}, ''),
  368. s.source_row_id,
  369. {MdpJsonSql.Str("s", "TransType")},
  370. {MdpJsonSql.Str("s", "ItemNum")},
  371. {MdpJsonSql.Str("s", "LotSerial")},
  372. {MdpJsonSql.Str("s", "Loc")},
  373. IFNULL({MdpJsonSql.Str("s", "Dimension1")}, ''),
  374. IFNULL({MdpJsonSql.Str("s", "Dimension2")}, ''),
  375. {MdpJsonSql.Str("s", "Refs")},
  376. {MdpJsonSql.Str("s", "Site")},
  377. IFNULL({MdpJsonSql.Dec("s", "QtyChange", 18, 5)}, 0),
  378. IFNULL({MdpJsonSql.Dec("s", "BeginBalance", 18, 5)}, 0),
  379. IFNULL({MdpJsonSql.Dec("s", "BeginBalance", 18, 5)}, 0) + IFNULL({MdpJsonSql.Dec("s", "QtyChange", 18, 5)}, 0),
  380. {MdpJsonSql.DateTimeSec("s", "EffDate")},
  381. {MdpJsonSql.DateTimeSec("s", "CreateTime")},
  382. {MdpJsonSql.Str("s", "OrdNbr")},
  383. {MdpJsonSql.Str("s", "WorkOrd")},
  384. {MdpJsonSql.Str("s", "ShipperNum")},
  385. {MdpJsonSql.Str("s", "ShipType")},
  386. {MdpJsonSql.Str("s", "Reason")},
  387. {MdpJsonSql.Str("s", "Remark")},
  388. {MdpJsonSql.Str("s", "CreateUser")},
  389. @HistoryFrom,
  390. @AsOf,
  391. {syncBatchExpr}
  392. FROM mdp_stg_inv_trans s
  393. WHERE s.tenant_id=@TenantId
  394. AND s.source_system=@SourceSystem
  395. AND s.source_table='InvTransHist'
  396. AND {batchPred}
  397. AND {MdpJsonSql.TenantGuard(sTenant)}
  398. ON DUPLICATE KEY UPDATE
  399. trans_type=VALUES(trans_type),
  400. item_num=VALUES(item_num),
  401. lot_serial=VALUES(lot_serial),
  402. location=VALUES(location),
  403. qty_change=VALUES(qty_change),
  404. begin_balance=VALUES(begin_balance),
  405. end_balance=VALUES(end_balance),
  406. eff_date=VALUES(eff_date),
  407. trans_time=VALUES(trans_time),
  408. ord_nbr=VALUES(ord_nbr),
  409. work_ord=VALUES(work_ord),
  410. shipper_num=VALUES(shipper_num),
  411. ship_type=VALUES(ship_type),
  412. reason=VALUES(reason),
  413. remark=VALUES(remark),
  414. create_user=VALUES(create_user),
  415. as_of=VALUES(as_of),
  416. sync_batch_id=VALUES(sync_batch_id),
  417. update_time=CURRENT_TIMESTAMP
  418. """;
  419. var ps = new List<SugarParameter>
  420. {
  421. new("@TenantId", tenantId),
  422. new("@SourceSystem", sourceSystem),
  423. new("@AsOf", asOf),
  424. new("@HistoryFrom", historyFrom)
  425. };
  426. if (!string.IsNullOrWhiteSpace(batchId))
  427. ps.Add(new SugarParameter("@BatchId", batchId));
  428. return await _db.Ado.ExecuteCommandAsync(sql, ps);
  429. }
  430. private async Task<bool> TryAcquireLockAsync()
  431. {
  432. var ok = await _db.Ado.GetIntAsync(
  433. "SELECT GET_LOCK(@k, 0)",
  434. new List<SugarParameter> { new("@k", LockKey) });
  435. return ok == 1;
  436. }
  437. private Task ReleaseLockAsync()
  438. => _db.Ado.ExecuteCommandAsync(
  439. "SELECT RELEASE_LOCK(@k)",
  440. new List<SugarParameter> { new("@k", LockKey) });
  441. private sealed class UpperRow
  442. {
  443. public string? CursorText { get; set; }
  444. public string? TieText { get; set; }
  445. }
  446. }
  447. public sealed class InventorySyncResult
  448. {
  449. public string BatchId { get; init; } = "";
  450. public long TenantId { get; init; }
  451. public string Domain { get; init; } = "";
  452. public bool Bootstrap { get; init; }
  453. public int LocationPulled { get; init; }
  454. public int LocationWritten { get; init; }
  455. public int InventoryStdRows { get; init; }
  456. public int TransPulled { get; init; }
  457. public int TransWritten { get; init; }
  458. public int TransStdRows { get; init; }
  459. public DateTime AsOf { get; init; }
  460. public DateTime? HistoryFrom { get; init; }
  461. public bool Skipped { get; init; }
  462. public string? Message { get; init; }
  463. }