|
|
@@ -13,7 +13,8 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
|
|
|
/// </summary>
|
|
|
public sealed class InventoryMdpSyncService : ITransient
|
|
|
{
|
|
|
- public const string LockKey = "aidop:s5:inventory-inbound";
|
|
|
+ /// <summary>跨实例互斥锁键;真正的取/放锁生命周期见 <see cref="InventoryInboundLockGuard"/>。</summary>
|
|
|
+ public const string LockKey = InventoryInboundLockGuard.LockKey;
|
|
|
private const string LocationEntity = "S5_LOCATION_DETAIL_SQLSERVER";
|
|
|
private const string TransEntity = "S5_INV_TRANS_HIST_SQLSERVER";
|
|
|
private const string SourceCodeDefault = "DOPDEMORQ_SQLSERVER";
|
|
|
@@ -64,9 +65,12 @@ public sealed class InventoryMdpSyncService : ITransient
|
|
|
var historyFrom = asOf.Date.AddMonths(-months);
|
|
|
var batchId = $"S5_INV_XFORM_{asOf:yyyyMMddHHmmss}";
|
|
|
|
|
|
- var locked = await TryAcquireLockAsync();
|
|
|
- if (!locked)
|
|
|
+ // await using:异常路径也一定走到释放(DisposeAsync 内先 RELEASE_LOCK 再关专用连接)
|
|
|
+ await using var guard = await InventoryInboundLockGuard.TryAcquireAsync(_db, _logger, cancellationToken: cancellationToken);
|
|
|
+ if (!guard.Acquired)
|
|
|
{
|
|
|
+ _logger.LogWarning("[InventoryMdpSync] 互斥锁占用,transform-std 本轮跳过 batch={Batch} reason={Reason}",
|
|
|
+ batchId, guard.BusyReason);
|
|
|
return new InventorySyncResult
|
|
|
{
|
|
|
BatchId = batchId,
|
|
|
@@ -79,25 +83,18 @@ public sealed class InventoryMdpSyncService : ITransient
|
|
|
};
|
|
|
}
|
|
|
|
|
|
- try
|
|
|
- {
|
|
|
- var rows = await UpsertInvTransStdAsync(tenantId, batchId: null, asOf, historyFrom, sourceCode);
|
|
|
- _logger.LogInformation("[InventoryMdpSync] transform-std done tenant={Tenant} rows={Rows}", tenantId, rows);
|
|
|
- return new InventorySyncResult
|
|
|
- {
|
|
|
- BatchId = batchId,
|
|
|
- TenantId = tenantId,
|
|
|
- Domain = domain,
|
|
|
- TransStdRows = rows,
|
|
|
- AsOf = asOf,
|
|
|
- HistoryFrom = historyFrom,
|
|
|
- Message = "OK transform-std"
|
|
|
- };
|
|
|
- }
|
|
|
- finally
|
|
|
+ var rows = await UpsertInvTransStdAsync(tenantId, batchId: null, asOf, historyFrom, sourceCode);
|
|
|
+ _logger.LogInformation("[InventoryMdpSync] transform-std done tenant={Tenant} rows={Rows}", tenantId, rows);
|
|
|
+ return new InventorySyncResult
|
|
|
{
|
|
|
- await ReleaseLockAsync();
|
|
|
- }
|
|
|
+ BatchId = batchId,
|
|
|
+ TenantId = tenantId,
|
|
|
+ Domain = domain,
|
|
|
+ TransStdRows = rows,
|
|
|
+ AsOf = asOf,
|
|
|
+ HistoryFrom = historyFrom,
|
|
|
+ Message = "OK transform-std"
|
|
|
+ };
|
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
|
@@ -126,6 +123,23 @@ public sealed class InventoryMdpSyncService : ITransient
|
|
|
var sourceTenantId = await _domainTenant.ResolveTenantIdAsync(sourceCode, domain, cancellationToken);
|
|
|
var asOf = DateTime.Now;
|
|
|
|
|
|
+ // 本入口同样写 mdp_std_inventory,必须与 bootstrap/reconcile/incremental 互斥(原实现漏取锁)
|
|
|
+ await using var guard = await InventoryInboundLockGuard.TryAcquireAsync(_db, _logger, cancellationToken: cancellationToken);
|
|
|
+ if (!guard.Acquired)
|
|
|
+ {
|
|
|
+ _logger.LogWarning("[InventoryMdpSync] 互斥锁占用,transform-inventory-std 本轮跳过 batch={Batch} reason={Reason}",
|
|
|
+ stgBatchId, guard.BusyReason);
|
|
|
+ return new InventorySyncResult
|
|
|
+ {
|
|
|
+ BatchId = stgBatchId,
|
|
|
+ TenantId = sourceTenantId,
|
|
|
+ Domain = domain,
|
|
|
+ AsOf = asOf,
|
|
|
+ Skipped = true,
|
|
|
+ Message = "lock busy"
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
var stgRows = await _db.Ado.GetIntAsync(
|
|
|
"""
|
|
|
SELECT COUNT(1) FROM mdp_stg_inventory
|
|
|
@@ -179,10 +193,11 @@ public sealed class InventoryMdpSyncService : ITransient
|
|
|
var months = _opt.TransBootstrapMonths <= 0 ? 12 : _opt.TransBootstrapMonths;
|
|
|
var historyFrom = asOf.Date.AddMonths(-months);
|
|
|
|
|
|
- var locked = await TryAcquireLockAsync();
|
|
|
- if (!locked)
|
|
|
+ // await using:异常路径也一定走到释放(DisposeAsync 内先 RELEASE_LOCK 再关专用连接)
|
|
|
+ await using var guard = await InventoryInboundLockGuard.TryAcquireAsync(_db, _logger, cancellationToken: cancellationToken);
|
|
|
+ if (!guard.Acquired)
|
|
|
{
|
|
|
- _logger.LogWarning("[InventoryMdpSync] 跨实例锁占用,本轮跳过 batch={Batch}", batchId);
|
|
|
+ _logger.LogWarning("[InventoryMdpSync] 跨实例锁占用,本轮跳过 batch={Batch} reason={Reason}", batchId, guard.BusyReason);
|
|
|
return new InventorySyncResult
|
|
|
{
|
|
|
BatchId = batchId,
|
|
|
@@ -196,114 +211,107 @@ public sealed class InventoryMdpSyncService : ITransient
|
|
|
};
|
|
|
}
|
|
|
|
|
|
- try
|
|
|
- {
|
|
|
- var upperLoc = await CaptureUpperBoundAsync(sourceCode, "LocationDetail", "UpdateTime", cancellationToken);
|
|
|
- var upperTrans = await CaptureUpperBoundAsync(sourceCode, "InvTransHist", "CreateTime", cancellationToken);
|
|
|
+ var upperLoc = await CaptureUpperBoundAsync(sourceCode, "LocationDetail", "UpdateTime", cancellationToken);
|
|
|
+ var upperTrans = await CaptureUpperBoundAsync(sourceCode, "InvTransHist", "CreateTime", cancellationToken);
|
|
|
|
|
|
- MdpPullResult locPull;
|
|
|
- if (bootstrap || reconcile)
|
|
|
- {
|
|
|
- var locBatch = $"{batchId}_LOC";
|
|
|
- // NULL UpdateTime 段与非 NULL 段共用同一 batchId,保证 Replace 不漏 NULL 行
|
|
|
- var nullCtx = BuildKeysetCtx(tenantId, locBatch, asOf, historyFrom, upperLoc,
|
|
|
- cursorColumn: "UpdateTime", nullPhase: true, bootstrapFull: true);
|
|
|
- nullCtx.CursorValue = null;
|
|
|
- nullCtx.TieBreakerValue = null;
|
|
|
- await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, nullCtx, cancellationToken, maxPages: 50);
|
|
|
-
|
|
|
- var fullCtx = BuildKeysetCtx(tenantId, locBatch, asOf, historyFrom, upperLoc,
|
|
|
- cursorColumn: "UpdateTime", nullPhase: false, bootstrapFull: true);
|
|
|
- fullCtx.CursorValue = null;
|
|
|
- fullCtx.TieBreakerValue = null;
|
|
|
- fullCtx.BootstrapFrom = null; // LocationDetail 首刷不截时间窗
|
|
|
- locPull = await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, fullCtx, cancellationToken, maxPages: 200);
|
|
|
- }
|
|
|
- else
|
|
|
+ MdpPullResult locPull;
|
|
|
+ if (bootstrap || reconcile)
|
|
|
+ {
|
|
|
+ var locBatch = $"{batchId}_LOC";
|
|
|
+ // NULL UpdateTime 段与非 NULL 段共用同一 batchId,保证 Replace 不漏 NULL 行
|
|
|
+ var nullCtx = BuildKeysetCtx(tenantId, locBatch, asOf, historyFrom, upperLoc,
|
|
|
+ cursorColumn: "UpdateTime", nullPhase: true, bootstrapFull: true);
|
|
|
+ nullCtx.CursorValue = null;
|
|
|
+ nullCtx.TieBreakerValue = null;
|
|
|
+ await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, nullCtx, cancellationToken, maxPages: 50);
|
|
|
+
|
|
|
+ var fullCtx = BuildKeysetCtx(tenantId, locBatch, asOf, historyFrom, upperLoc,
|
|
|
+ cursorColumn: "UpdateTime", nullPhase: false, bootstrapFull: true);
|
|
|
+ fullCtx.CursorValue = null;
|
|
|
+ fullCtx.TieBreakerValue = null;
|
|
|
+ fullCtx.BootstrapFrom = null; // LocationDetail 首刷不截时间窗
|
|
|
+ locPull = await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, fullCtx, cancellationToken, maxPages: 200);
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ var incrCtx = BuildKeysetCtx(tenantId, $"{batchId}_LOC", asOf, historyFrom, upperLoc,
|
|
|
+ cursorColumn: "UpdateTime", nullPhase: false, bootstrapFull: false);
|
|
|
+ // 重叠窗口:从上次游标时间向前回退 LocationOverlapMinutes
|
|
|
+ if (!string.IsNullOrWhiteSpace(incrCtx.CursorValue)
|
|
|
+ && DateTime.TryParse(incrCtx.CursorValue, out var lastDt))
|
|
|
{
|
|
|
- var incrCtx = BuildKeysetCtx(tenantId, $"{batchId}_LOC", asOf, historyFrom, upperLoc,
|
|
|
- cursorColumn: "UpdateTime", nullPhase: false, bootstrapFull: false);
|
|
|
- // 重叠窗口:从上次游标时间向前回退 LocationOverlapMinutes
|
|
|
- if (!string.IsNullOrWhiteSpace(incrCtx.CursorValue)
|
|
|
- && DateTime.TryParse(incrCtx.CursorValue, out var lastDt))
|
|
|
- {
|
|
|
- var overlap = Math.Max(0, _opt.LocationOverlapMinutes);
|
|
|
- incrCtx.CursorValue = lastDt.AddMinutes(-overlap)
|
|
|
- .ToString("yyyy-MM-dd HH:mm:ss.fff");
|
|
|
- incrCtx.TieBreakerValue = "0";
|
|
|
- }
|
|
|
- locPull = await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, incrCtx, cancellationToken, maxPages: 200);
|
|
|
+ var overlap = Math.Max(0, _opt.LocationOverlapMinutes);
|
|
|
+ incrCtx.CursorValue = lastDt.AddMinutes(-overlap)
|
|
|
+ .ToString("yyyy-MM-dd HH:mm:ss.fff");
|
|
|
+ incrCtx.TieBreakerValue = "0";
|
|
|
}
|
|
|
+ locPull = await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, incrCtx, cancellationToken, maxPages: 200);
|
|
|
+ }
|
|
|
|
|
|
- // —— 标准层按租户物化:一次贴源,逐租户按各自库位范围投影 ——
|
|
|
- // 贴源层是「源+domain」维度(归属 sourceTenantId),标准层是「租户」维度。
|
|
|
- // 拉取游标持久化在 mdp_entity 上、跨租户共享,故绝不能为每个租户各拉一次。
|
|
|
- var targetTenants = await ListInventoryScopedTenantsAsync(domain, cancellationToken);
|
|
|
- if (targetTenants.Count == 0)
|
|
|
- _logger.LogWarning(
|
|
|
- "[InventoryMdpSync] domain={Domain} 无任何配置了合法库位的租户,标准层本轮不写入", domain);
|
|
|
+ // —— 标准层按租户物化:一次贴源,逐租户按各自库位范围投影 ——
|
|
|
+ // 贴源层是「源+domain」维度(归属 sourceTenantId),标准层是「租户」维度。
|
|
|
+ // 拉取游标持久化在 mdp_entity 上、跨租户共享,故绝不能为每个租户各拉一次。
|
|
|
+ var targetTenants = await ListInventoryScopedTenantsAsync(domain, cancellationToken);
|
|
|
+ if (targetTenants.Count == 0)
|
|
|
+ _logger.LogWarning(
|
|
|
+ "[InventoryMdpSync] domain={Domain} 无任何配置了合法库位的租户,标准层本轮不写入", domain);
|
|
|
|
|
|
- var inventoryStdRows = 0;
|
|
|
- foreach (var targetTenantId in targetTenants)
|
|
|
+ var inventoryStdRows = 0;
|
|
|
+ foreach (var targetTenantId in targetTenants)
|
|
|
+ {
|
|
|
+ cancellationToken.ThrowIfCancellationRequested();
|
|
|
+ int rows;
|
|
|
+ if (bootstrap || reconcile)
|
|
|
{
|
|
|
- cancellationToken.ThrowIfCancellationRequested();
|
|
|
- int rows;
|
|
|
- if (bootstrap || reconcile)
|
|
|
- {
|
|
|
- rows = await MdpStdFullReplace.ReplaceAsync(
|
|
|
- _db,
|
|
|
- "mdp_std_inventory",
|
|
|
- targetTenantId,
|
|
|
- "source_system='DOPDEMORQ_SQLSERVER'",
|
|
|
- () => InsertInventoryStdAsync(targetTenantId, tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: true),
|
|
|
- cancellationToken);
|
|
|
- }
|
|
|
- else
|
|
|
- {
|
|
|
- rows = await InsertInventoryStdAsync(targetTenantId, tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: false);
|
|
|
- }
|
|
|
- inventoryStdRows += rows;
|
|
|
- _logger.LogInformation(
|
|
|
- "[InventoryMdpSync] std materialized tenant={Tenant} domain={Domain} rows={Rows}",
|
|
|
- targetTenantId, domain, rows);
|
|
|
+ rows = await MdpStdFullReplace.ReplaceAsync(
|
|
|
+ _db,
|
|
|
+ "mdp_std_inventory",
|
|
|
+ targetTenantId,
|
|
|
+ "source_system='DOPDEMORQ_SQLSERVER'",
|
|
|
+ () => InsertInventoryStdAsync(targetTenantId, tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: true),
|
|
|
+ cancellationToken);
|
|
|
}
|
|
|
-
|
|
|
- var transCtx = BuildKeysetCtx(tenantId, $"{batchId}_TRN", asOf, historyFrom, upperTrans,
|
|
|
- cursorColumn: "CreateTime", nullPhase: false, bootstrapFull: bootstrap || reconcile);
|
|
|
- if (bootstrap || reconcile)
|
|
|
+ else
|
|
|
{
|
|
|
- transCtx.CursorValue = null;
|
|
|
- transCtx.TieBreakerValue = null;
|
|
|
- transCtx.BootstrapFrom = historyFrom;
|
|
|
+ rows = await InsertInventoryStdAsync(targetTenantId, tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: false);
|
|
|
}
|
|
|
- var transPull = await _pullDispatcher.PullAllByEntityCodeAsync(TransEntity, transCtx, cancellationToken, maxPages: 500);
|
|
|
- var transStdRows = await UpsertInvTransStdAsync(tenantId, $"{batchId}_TRN", asOf, historyFrom, sourceCode);
|
|
|
-
|
|
|
+ inventoryStdRows += rows;
|
|
|
_logger.LogInformation(
|
|
|
- "[InventoryMdpSync] done batch={Batch} boot={Boot} recon={Recon} locPulled={LocP} invStd={Inv} trnPulled={TrnP} trnStd={Trn}",
|
|
|
- batchId, bootstrap, reconcile, locPull.RowsPulled, inventoryStdRows, transPull.RowsPulled, transStdRows);
|
|
|
-
|
|
|
- return new InventorySyncResult
|
|
|
- {
|
|
|
- BatchId = batchId,
|
|
|
- TenantId = tenantId,
|
|
|
- Domain = domain,
|
|
|
- Bootstrap = bootstrap,
|
|
|
- LocationPulled = locPull.RowsPulled,
|
|
|
- LocationWritten = locPull.RowsWritten,
|
|
|
- InventoryStdRows = inventoryStdRows,
|
|
|
- TransPulled = transPull.RowsPulled,
|
|
|
- TransWritten = transPull.RowsWritten,
|
|
|
- TransStdRows = transStdRows,
|
|
|
- AsOf = asOf,
|
|
|
- HistoryFrom = historyFrom,
|
|
|
- Message = "OK"
|
|
|
- };
|
|
|
+ "[InventoryMdpSync] std materialized tenant={Tenant} domain={Domain} rows={Rows}",
|
|
|
+ targetTenantId, domain, rows);
|
|
|
}
|
|
|
- finally
|
|
|
+
|
|
|
+ var transCtx = BuildKeysetCtx(tenantId, $"{batchId}_TRN", asOf, historyFrom, upperTrans,
|
|
|
+ cursorColumn: "CreateTime", nullPhase: false, bootstrapFull: bootstrap || reconcile);
|
|
|
+ if (bootstrap || reconcile)
|
|
|
{
|
|
|
- await ReleaseLockAsync();
|
|
|
+ transCtx.CursorValue = null;
|
|
|
+ transCtx.TieBreakerValue = null;
|
|
|
+ transCtx.BootstrapFrom = historyFrom;
|
|
|
}
|
|
|
+ var transPull = await _pullDispatcher.PullAllByEntityCodeAsync(TransEntity, transCtx, cancellationToken, maxPages: 500);
|
|
|
+ var transStdRows = await UpsertInvTransStdAsync(tenantId, $"{batchId}_TRN", asOf, historyFrom, sourceCode);
|
|
|
+
|
|
|
+ _logger.LogInformation(
|
|
|
+ "[InventoryMdpSync] done batch={Batch} boot={Boot} recon={Recon} locPulled={LocP} invStd={Inv} trnPulled={TrnP} trnStd={Trn}",
|
|
|
+ batchId, bootstrap, reconcile, locPull.RowsPulled, inventoryStdRows, transPull.RowsPulled, transStdRows);
|
|
|
+
|
|
|
+ return new InventorySyncResult
|
|
|
+ {
|
|
|
+ BatchId = batchId,
|
|
|
+ TenantId = tenantId,
|
|
|
+ Domain = domain,
|
|
|
+ Bootstrap = bootstrap,
|
|
|
+ LocationPulled = locPull.RowsPulled,
|
|
|
+ LocationWritten = locPull.RowsWritten,
|
|
|
+ InventoryStdRows = inventoryStdRows,
|
|
|
+ TransPulled = transPull.RowsPulled,
|
|
|
+ TransWritten = transPull.RowsWritten,
|
|
|
+ TransStdRows = transStdRows,
|
|
|
+ AsOf = asOf,
|
|
|
+ HistoryFrom = historyFrom,
|
|
|
+ Message = "OK"
|
|
|
+ };
|
|
|
}
|
|
|
|
|
|
private MdpPullContext BuildKeysetCtx(
|
|
|
@@ -592,18 +600,9 @@ public sealed class InventoryMdpSyncService : ITransient
|
|
|
return await _db.Ado.ExecuteCommandAsync(sql, ps);
|
|
|
}
|
|
|
|
|
|
- private async Task<bool> TryAcquireLockAsync()
|
|
|
- {
|
|
|
- var ok = await _db.Ado.GetIntAsync(
|
|
|
- "SELECT GET_LOCK(@k, 0)",
|
|
|
- new List<SugarParameter> { new("@k", LockKey) });
|
|
|
- return ok == 1;
|
|
|
- }
|
|
|
-
|
|
|
- private Task ReleaseLockAsync()
|
|
|
- => _db.Ado.ExecuteCommandAsync(
|
|
|
- "SELECT RELEASE_LOCK(@k)",
|
|
|
- new List<SugarParameter> { new("@k", LockKey) });
|
|
|
+ // 取/放锁已迁至 InventoryInboundLockGuard:
|
|
|
+ // 原实现在共享 _db(IsAutoCloseConnection=true)上跑 GET_LOCK/RELEASE_LOCK,
|
|
|
+ // 命令执行完连接即回池并被驱动 reset,MySQL 当场释放咨询锁 → 跨实例互斥失效。
|
|
|
|
|
|
private sealed class UpperRow
|
|
|
{
|