소스 검색

fix(s0): tenant-scope item pack uniqueness and number rules

ItemPackMaster/NbrControl 唯一约束从全局业务键改为租户内唯一,使不同租户
可复用相同业务编码、同租户重复仍被 DB 拒绝。

- 数据库(1.0.296.sql,幂等+冲突守卫):
  ItemPackMaster 旧全局唯一 uk_domain_itemnum + ix(唯一) → uk_tenant_domain_itemnum(唯一)+ix(改非唯一)
  NbrControl 旧 uk_domain_nbrtype → uk_tenant_domain_nbrtype(唯一)
- 实体 [SugarIndex] 同步租户内唯一,CodeFirst 重启不复活旧全局唯一索引
- 两 Controller dup 校验对齐新唯一索引(DomainCode)
- NumberRuleService 取号规则/日流水改无条件租户硬过滤(AdoS0TenantScope.ResolveRequiredOrThrow)
- SupplierShipmentService WoLot 取号读/写 NbrControl 补 tenant 过滤

server 1.0.296
YY968XX 6 일 전
부모
커밋
c23c9eba6e

+ 6 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET ͨ��Ȩ�޿���ƽ̨</Description>
-    <AssemblyVersion>1.0.295</AssemblyVersion>
-    <FileVersion>1.0.295</FileVersion>
-    <Version>1.0.295</Version>
+    <AssemblyVersion>1.0.296</AssemblyVersion>
+    <FileVersion>1.0.296</FileVersion>
+    <Version>1.0.296</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -274,6 +274,9 @@
     <None Update="UpdateScripts\1.0.292.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.296.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

+ 100 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.296.sql

@@ -0,0 +1,100 @@
+-- 1.0.296.sql  S0 Warehouse P1:ItemPackMaster / NbrControl 唯一索引租户化(幂等 + 冲突前置守卫)。
+--   目的:唯一约束从「全局业务键」改为「租户内业务键」,使不同租户可复用相同业务编码,
+--         同一租户内重复业务键仍被数据库拒绝。
+--   不改字段类型、不改 tenant_id 可空性、不插入/更新/删除任何业务数据、不动其它表索引。
+--   业务键依据(information_schema 实证 + 消费链路审计):
+--     ItemPackMaster:历史唯一键 (Domain, ItemNum);Domain 与 factory_ref_id 在数据中 1:1;
+--                     tenant 内唯一键 = (tenant_id, Domain, ItemNum)。
+--     NbrControl    :历史唯一键 (domain_code, NbrType);domain_code NOT NULL(现值全为空串,作安全唯一锚),
+--                     NbrType 为真实单号类型码;tenant 内唯一键 = (tenant_id, domain_code, NbrType)。
+-- ============================================================================
+
+-- ========== 一、冲突前置检测(同租户内重复则跳过创建,绝不自动清理历史数据) ==========
+SET @ipk_dup := (SELECT COUNT(*) FROM (
+    SELECT tenant_id, `Domain`, ItemNum
+    FROM ItemPackMaster
+    GROUP BY tenant_id, `Domain`, ItemNum
+    HAVING COUNT(*) > 1) t);
+
+SET @nbr_dup := (SELECT COUNT(*) FROM (
+    SELECT tenant_id, domain_code, NbrType
+    FROM NbrControl
+    GROUP BY tenant_id, domain_code, NbrType
+    HAVING COUNT(*) > 1) t);
+
+SELECT @ipk_dup AS itempack_intratenant_dup, @nbr_dup AS nbrcontrol_intratenant_dup;
+-- 期望均为 0;若非 0,本脚本不会创建新唯一索引(下方 ADD 被守卫跳过),需人工治理后重跑。
+
+-- ========== 二、ItemPackMaster:旧全局唯一索引 -> 租户内唯一索引 ==========
+-- 2.1 删除旧唯一索引 uk_ItemPackMaster_domain_itemnum(存在才删)
+SET @has_old_ipk := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ItemPackMaster'
+      AND INDEX_NAME = 'uk_ItemPackMaster_domain_itemnum');
+SET @sql := IF(@has_old_ipk > 0,
+    'ALTER TABLE ItemPackMaster DROP INDEX uk_ItemPackMaster_domain_itemnum', 'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- 2.2 创建新租户内唯一索引(不存在且无同租户冲突才建)
+SET @has_new_ipk := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ItemPackMaster'
+      AND INDEX_NAME = 'uk_ItemPackMaster_tenant_domain_itemnum');
+SET @sql := IF(@has_new_ipk = 0 AND @ipk_dup = 0,
+    'ALTER TABLE ItemPackMaster ADD UNIQUE INDEX uk_ItemPackMaster_tenant_domain_itemnum (tenant_id, `Domain`, ItemNum)',
+    'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- 2.3 处理隐藏的第二个全局唯一索引 ix_ItemPackMaster (Domain, ItemNum)。
+--     该索引 NON_UNIQUE=0(唯一),与被删的 uk 同样强制全局唯一,会继续阻断跨租户相同 (Domain, ItemNum),必须一并处理。
+--     方案:删除其“唯一”版本,重建为“非唯一”同名索引(保留 (Domain, ItemNum) 查询性能,但不再强制全局唯一)。
+SET @ix_unique := (SELECT COUNT(*) FROM information_schema.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ItemPackMaster'
+      AND INDEX_NAME = 'ix_ItemPackMaster' AND NON_UNIQUE = 0);
+SET @sql := IF(@ix_unique > 0,
+    'ALTER TABLE ItemPackMaster DROP INDEX ix_ItemPackMaster', 'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @has_ix := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ItemPackMaster'
+      AND INDEX_NAME = 'ix_ItemPackMaster');
+SET @sql := IF(@has_ix = 0,
+    'ALTER TABLE ItemPackMaster ADD INDEX ix_ItemPackMaster (`Domain`, ItemNum)', 'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ========== 三、NbrControl:旧全局唯一索引 -> 租户内唯一索引 ==========
+-- 3.1 删除旧唯一索引 uk_NbrControl_domain_nbrtype(存在才删)
+SET @has_old_nbr := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'NbrControl'
+      AND INDEX_NAME = 'uk_NbrControl_domain_nbrtype');
+SET @sql := IF(@has_old_nbr > 0,
+    'ALTER TABLE NbrControl DROP INDEX uk_NbrControl_domain_nbrtype', 'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- 3.2 创建新租户内唯一索引(不存在且无同租户冲突才建)
+SET @has_new_nbr := (SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'NbrControl'
+      AND INDEX_NAME = 'uk_NbrControl_tenant_domain_nbrtype');
+SET @sql := IF(@has_new_nbr = 0 AND @nbr_dup = 0,
+    'ALTER TABLE NbrControl ADD UNIQUE INDEX uk_NbrControl_tenant_domain_nbrtype (tenant_id, domain_code, NbrType)',
+    'DO 0');
+PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ========== 四、执行后验证 ==========
+SELECT TABLE_NAME, INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME IN ('ItemPackMaster', 'NbrControl')
+  AND INDEX_NAME <> 'PRIMARY'
+ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX;
+-- 期望:ItemPackMaster 仅 uk_ItemPackMaster_tenant_domain_itemnum(唯一) + ix_ItemPackMaster(NON_UNIQUE=1);
+--       NbrControl 仅 uk_NbrControl_tenant_domain_nbrtype(唯一)。均无全局唯一 (Domain,ItemNum)/(domain_code,NbrType)。
+
+-- ============================================================================
+-- 回滚 SQL(如需恢复到全局唯一索引,历史数据无跨租户同键冲突时可执行):
+--   ALTER TABLE ItemPackMaster DROP INDEX uk_ItemPackMaster_tenant_domain_itemnum;
+--   ALTER TABLE ItemPackMaster DROP INDEX ix_ItemPackMaster;
+--   ALTER TABLE ItemPackMaster ADD UNIQUE INDEX uk_ItemPackMaster_domain_itemnum (`Domain`, ItemNum);
+--   ALTER TABLE ItemPackMaster ADD UNIQUE INDEX ix_ItemPackMaster (`Domain`, ItemNum);
+--   ALTER TABLE NbrControl     DROP INDEX uk_NbrControl_tenant_domain_nbrtype;
+--   ALTER TABLE NbrControl     ADD UNIQUE INDEX uk_NbrControl_domain_nbrtype (domain_code, NbrType);
+-- 注意:回滚前必须确认不存在跨租户相同业务键,否则回滚 ADD 会因重复而失败(这正是本次改造要放开的场景)。
+-- ============================================================================

+ 3 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Warehouse/AdoS0ItemPacksController.cs

@@ -59,7 +59,8 @@ public class AdoS0ItemPacksController : ControllerBase
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
 
-        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == dto.FactoryRefId && x.ItemNum == dto.ItemNum))
+        // 唯一口径对齐数据库 uk_ItemPackMaster_tenant_domain_itemnum (tenant_id, Domain, ItemNum)
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.DomainCode == (dto.DomainCode ?? string.Empty) && x.ItemNum == dto.ItemNum))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "物料包装规格已存在");
 
         var entity = new AdoS0ItemPackMaster
@@ -97,7 +98,7 @@ public class AdoS0ItemPacksController : ControllerBase
         var entity = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         if (entity == null) return NotFound();
 
-        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.ItemNum == dto.ItemNum))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.DomainCode == (dto.DomainCode ?? string.Empty) && x.ItemNum == dto.ItemNum))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "物料包装规格已存在");
 
         // entity.TenantId 保持不变(== tenantId);不接受前端修改 tenant

+ 3 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Warehouse/AdoS0NbrControlsController.cs

@@ -57,7 +57,8 @@ public class AdoS0NbrControlsController : ControllerBase
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
 
-        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == dto.FactoryRefId && x.NbrType == dto.NbrType))
+        // 唯一口径对齐数据库 uk_NbrControl_tenant_domain_nbrtype (tenant_id, domain_code, NbrType)
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.DomainCode == (dto.DomainCode ?? string.Empty) && x.NbrType == dto.NbrType))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "单号类型编码已存在");
 
         var entity = new AdoS0NbrControl
@@ -95,7 +96,7 @@ public class AdoS0NbrControlsController : ControllerBase
         var entity = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         if (entity == null) return NotFound();
 
-        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.NbrType == dto.NbrType))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.DomainCode == (dto.DomainCode ?? string.Empty) && x.NbrType == dto.NbrType))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "单号类型编码已存在");
 
         // entity.TenantId 保持不变(== tenantId);不接受前端修改 tenant

+ 3 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Warehouse/AdoS0ItemPackMaster.cs

@@ -4,7 +4,9 @@ namespace Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
 /// 零件包装规格主数据(复刻 ItemPackMaster)
 /// </summary>
 [SugarTable("ItemPackMaster", "零件包装规格主数据(复刻 ItemPackMaster)")]
-[SugarIndex("uk_ItemPackMaster_domain_itemnum", nameof(DomainCode), OrderByType.Asc, nameof(ItemNum), OrderByType.Asc, IsUnique = true)]
+// P1 租户化:唯一约束改为租户内唯一(tenant_id, Domain, ItemNum),使不同租户可复用相同业务编码。
+// 索引名/列须与 UpdateScripts 版本 SQL 完全一致,CodeFirst 重启方能幂等、不再复活旧全局唯一索引。
+[SugarIndex("uk_ItemPackMaster_tenant_domain_itemnum", nameof(TenantId), OrderByType.Asc, nameof(DomainCode), OrderByType.Asc, nameof(ItemNum), OrderByType.Asc, IsUnique = true)]
 public class AdoS0ItemPackMaster : ITenantIdFilter
 {
     [SugarColumn(ColumnName = "RecID", ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]

+ 3 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S0/Warehouse/AdoS0NbrControl.cs

@@ -4,7 +4,9 @@ namespace Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
 /// 单号规则主数据(复刻 NbrControl)
 /// </summary>
 [SugarTable("NbrControl", "单号规则主数据(复刻 NbrControl)")]
-[SugarIndex("uk_NbrControl_domain_nbrtype", nameof(DomainCode), OrderByType.Asc, nameof(NbrType), OrderByType.Asc, IsUnique = true)]
+// P1 租户化:唯一约束改为租户内唯一(tenant_id, domain_code, NbrType),使不同租户可复用相同单号规则码。
+// 索引名/列须与 UpdateScripts 版本 SQL 完全一致,CodeFirst 重启方能幂等、不再复活旧全局唯一索引。
+[SugarIndex("uk_NbrControl_tenant_domain_nbrtype", nameof(TenantId), OrderByType.Asc, nameof(DomainCode), OrderByType.Asc, nameof(NbrType), OrderByType.Asc, IsUnique = true)]
 public class AdoS0NbrControl : ITenantIdFilter
 {
     [SugarColumn(ColumnName = "RecID", ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "int")]

+ 19 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AdoS0TenantScope.cs

@@ -45,4 +45,23 @@ public static class AdoS0TenantScope
         tenantId = tid;
         return true;
     }
+
+    /// <summary>
+    /// Service 层(无 IActionResult 上下文,如取号/编号服务)统一的可信租户解析。
+    /// 语义与 <see cref="TryResolveRequired"/> 完全一致(同一 JWT 来源、同样拒绝无租户/超管未选),
+    /// 但解析失败时抛出业务异常而非返回错误结果。绝不回退固定默认租户、绝不读前端 tenantId。
+    /// </summary>
+    public static long ResolveRequiredOrThrow()
+    {
+        var um = App.GetRequiredService<UserManager>();
+        var tid = um.TenantId;
+
+        if (tid <= 0)
+            throw Oops.Oh("无法确定当前租户,请重新登录或选择目标租户");
+
+        if (um.SuperAdmin && tid == MainTenantId)
+            throw Oops.Oh("超级管理员操作前必须选择目标租户");
+
+        return tid;
+    }
 }

+ 9 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/SupplierShipmentService.cs

@@ -346,7 +346,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                     lockKey = $"scm_shpc_seq_{prefix}";
                     await AcquireMySqlLockAsync(lockKey, 10);
 
-                    var nextSerial = await GetNextDailySerialAsync(prefix, domain);
+                    var nextSerial = await GetNextDailySerialAsync(prefix, domain, tenantId);
                     var pairs = needShpc
                         .OrderBy(x => x.Wlbm ?? string.Empty)
                         .ThenBy(x => x.Scph ?? string.Empty)
@@ -411,7 +411,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                     // 推进 NbrControl.NextValue,确保续号(仅当没有历史 shpc 且使用了 NbrControl 作为起点时也能正确续)
                     // 这里直接推进到“本批次最后一个流水号”,下次取号将从 NextValue+1 开始。
                     var lastSerial = nextSerial + pairs.Count - 1;
-                    await BumpNbrControlNextValueAsync(domain, lastSerial);
+                    await BumpNbrControlNextValueAsync(domain, lastSerial, tenantId);
                 }
 
                 // 3) 补 jhdhh(同一发货单:为空的明细按 id 递增,起始 max(jhdhh) or 1000)
@@ -748,7 +748,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
         }
     }
 
-    private async Task<int> GetNextDailySerialAsync(string prefix, string domain)
+    private async Task<int> GetNextDailySerialAsync(string prefix, string domain, long tenantId)
     {
         // 从已存在的批次号中取当天最大流水:yyMMdd + 3位
         // 优先 scm_shdzb,其次 scm_shbq(兼容历史数据来源)
@@ -769,6 +769,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                 SELECT IFNULL(NextValue, 0)
                 FROM NbrControl
                 WHERE NbrType='WoLot'
+                  AND tenant_id = @TenantId
                   AND (
                         domain_code = @d
                         OR Domain = @d
@@ -777,7 +778,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                 ORDER BY RecID
                 LIMIT 1
                 """,
-                new List<SugarParameter> { new("@d", domain ?? string.Empty) });
+                new List<SugarParameter> { new("@d", domain ?? string.Empty), new("@TenantId", tenantId) });
             // 用户要求:NextValue+1 为下一流水号起点
             return Math.Max(1, nv + 1);
         }
@@ -786,9 +787,10 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
         return int.TryParse(suffix, out var n) ? n + 1 : 1;
     }
 
-    private async Task BumpNbrControlNextValueAsync(string domain, int lastSerial)
+    private async Task BumpNbrControlNextValueAsync(string domain, int lastSerial, long tenantId)
     {
         // 保守推进:仅当新值更大时更新,避免其它流程并发推进导致回退。
+        // 租户隔离:仅推进当前租户的 WoLot 规则,绝不写他租户序号。
         await _db.Ado.ExecuteCommandAsync(
             """
             UPDATE NbrControl
@@ -798,6 +800,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
             END,
                 UpdateTime = NOW()
             WHERE NbrType = 'WoLot'
+              AND tenant_id = @TenantId
               AND (
                     domain_code = @d
                     OR Domain = @d
@@ -808,6 +811,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
             {
                 new("@d", domain ?? string.Empty),
                 new("@v", lastSerial),
+                new("@TenantId", tenantId),
             });
     }
 

+ 16 - 11
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/NumberRuleService.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+
 namespace Admin.NET.Plugin.AiDOP.Supply;
 
 /// <summary>
@@ -6,12 +8,10 @@ namespace Admin.NET.Plugin.AiDOP.Supply;
 public class NumberRuleService : ITransient
 {
     private readonly ISqlSugarClient _db;
-    private readonly UserManager _userManager;
 
-    public NumberRuleService(ISqlSugarClient db, UserManager userManager)
+    public NumberRuleService(ISqlSugarClient db)
     {
         _db = db;
-        _userManager = userManager;
     }
 
     public async Task<IReadOnlyList<string>> NextBatchAsync(string ruleCode, string domain, int count, string operatorNo)
@@ -39,13 +39,17 @@ public class NumberRuleService : ITransient
         var normalizedDomain = domain?.Trim() ?? string.Empty;
         var now = DateTime.Now;
 
-        var tenantWhere = _userManager.TenantId > 0 ? "\n              AND tenant_id = @TenantId" : string.Empty;
+        // 可信租户硬解析:无 token / 超管未选租户一律抛错;绝不回退固定默认租户、绝不读前端 tenantId。
+        // 租户过滤在取号规则定位上强制无条件生效,杜绝跨租户读到他租户规则或落到默认规则。
+        var tenantId = AdoS0TenantScope.ResolveRequiredOrThrow();
+
+        const string tenantWhere = "\n              AND tenant_id = @TenantId";
         var rulePars = new List<SugarParameter>
         {
             new("@RuleCode", normalizedRuleCode),
-            new("@Domain", normalizedDomain)
+            new("@Domain", normalizedDomain),
+            new("@TenantId", tenantId)
         };
-        if (_userManager.TenantId > 0) rulePars.Add(new SugarParameter("@TenantId", _userManager.TenantId));
 
         var rule = (await _db.Ado.SqlQueryAsync<NbrControlRuleRow>(
             $"""
@@ -94,7 +98,7 @@ public class NumberRuleService : ITransient
             throw Oops.Oh($"未找到编号规则:{normalizedRuleCode}");
 
         var sequenceDate = NumberRuleFormatter.ResolveSequenceDate(rule.DateType, rule.IsDateType, now);
-        var dayInfo = await GetDayInfoForUpdateAsync(rule, normalizedRuleCode, normalizedDomain, sequenceDate);
+        var dayInfo = await GetDayInfoForUpdateAsync(rule, normalizedRuleCode, normalizedDomain, sequenceDate, tenantId);
 
         var firstSerial = ResolveFirstSerial(rule, dayInfo);
         var lastSerial = firstSerial + count - 1;
@@ -130,17 +134,18 @@ public class NumberRuleService : ITransient
         return numbers;
     }
 
-    private async Task<NbrDayInfoRow?> GetDayInfoForUpdateAsync(NbrControlRuleRow rule, string ruleCode, string domain, DateTime? sequenceDate)
+    private async Task<NbrDayInfoRow?> GetDayInfoForUpdateAsync(NbrControlRuleRow rule, string ruleCode, string domain, DateTime? sequenceDate, long tenantId)
     {
         var dateFilter = sequenceDate.HasValue ? "AND DATE(Today) = DATE(@Today)" : string.Empty;
-        var tenantFilter = _userManager.TenantId > 0 ? "AND tenant_id = @TenantId2" : string.Empty;
+        // 日流水行同样按当前租户无条件过滤,序号推进不跨租户。
+        const string tenantFilter = "AND tenant_id = @TenantId2";
         var dayPars = new List<SugarParameter>
         {
             new("@RuleCode", rule.NbrType ?? ruleCode),
             new("@Domain", rule.Domain ?? domain),
-            new("@Today", sequenceDate ?? DateTime.Today)
+            new("@Today", sequenceDate ?? DateTime.Today),
+            new("@TenantId2", tenantId)
         };
-        if (_userManager.TenantId > 0) dayPars.Add(new SugarParameter("@TenantId2", _userManager.TenantId));
         return (await _db.Ado.SqlQueryAsync<NbrDayInfoRow>(
             $"""
             SELECT