Преглед на файлове

fix(s5): enforce tenant ownership for shipment labels

MissedPrint(送货标签/箱码)此前无租户归属:生成标签的 INSERT 未写 tenant_id,
库中 152 行全为 NULL;FUNC-S5-008 标签查询无租户过滤,UATAdminA 登录可看到
AIDOP 租户 797 的 104 行(已确认的跨租户读取)。同时 SupplierShipmentService
类级带 [AllowAnonymous] 且用 AidopTenantHelper.Resolve(解析链含 query 参数
tenantId 与默认租户兜底),匿名 GET 传 ?tenantId= 即可读任意租户业务数据。

安全边界
- SupplierShipmentService 去掉 [AllowAnonymous],10 处租户解析统一改为
  AidopTenantScope.ResolveOrThrow(_userManager):只认 JWT,无兜底、失败即拒。

写路径
- INSERT INTO MissedPrint 补写 tenant_id(来自认证上下文,不取请求参数)。
- 同一 INSERT ... SELECT 的取数与关联补租户:scm_shbq / PurOrdDetail / ItemMaster。
  ItemMaster 有 663 个 ItemNum 跨租户重名而 (tenant_id, ItemNum) 唯一,原写法会把
  一行箱码放大成多行标签(实测 797:50→82,UAT:24→48),并可能取到别租户的保质期。
- 按业务键定位的写语句补 tenant_id:DELETE scm_shbq、UPDATE scm_shd、
  作废旧标签的 UPDATE MissedPrint(shdh/shddh 无唯一索引)。
- MdpHotWatchService 的箱码回写补 tenant_id 条件,租户不可解析时跳过并告警,
  不退化为按 Domain 全域改写;送货单状态推进的 MissedPrint 统计同样按租户。
  Domain 不是租户边界:797 与 UAT 的 Domain 同为 8010。

只读列表
- 008 标签查询 / 013 盘点结果查询 增加 m.tenant_id = @TenantId;
  008 的库位下拉同样按租户收敛。

数据回填(1.0.442 + verify)
- 唯一 SET 源 scm_shd.tenant_id(ShipperNbr → shddh),幂等(只补 NULL)。
- 回填前 DRY-RUN 七项守卫全通过:无歧义单号 0、可解析 152/不可解析 0、
  分布 797=104 / UAT=48 / 其它 0、scm_shd 脏租户 0,
  与 CreateUser→SysUser.TenantId 交叉验证一致 152 / 矛盾 0,
  与 scm_shbq 交叉验证一致 130 / 矛盾 0(22 行无对应箱码)。
- 实际应用:affected_rows=152,verify_status=Success。

验证
- dotnet build --framework net10.0 通过;单元测试 1164 通过 / 0 失败;
  AIDOP_IT=1 集成测试 15 通过。
- 页面冒烟(chrome-devtools MCP,冒烟槽 2/3):UATAdminA 看到 48 条(原 152),
  10/50/100 三档 × 3 轮 total=fetched=unique=48 稳定;返回的 48 个 RecID 与库中
  UAT 的 48 行逐一吻合,797 的 104 行零命中。797 侧(隔离浏览器上下文,AIDOPDemo)
  看到 104 条,与 UAT 区间零重叠。两侧伪造 query tenantId 均不生效(返回各自租户)。
  匿名访问 008/012/013 均 401。

未闭环(另批)
- 009 暂收在检、InventoryBalanceReader、SupplierDeliveryManagement 等其余
  MissedPrint 读取点尚未改用 tenant_id,已在注释中标注。
- 012 盘点标签确认无认证上下文可取租户(当前 IsTag=1 恒 0 行),仍为
  Anonymous access CLOSED / tenant isolation 未 CLOSED。
- 按要求本批不加唯一索引,留待独立 schema 批次。
YY968XX преди 2 часа
родител
ревизия
8d889fba7e

+ 9 - 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.441</AssemblyVersion>
-    <FileVersion>1.0.441</FileVersion>
-    <Version>1.0.441</Version>
+    <AssemblyVersion>1.0.442</AssemblyVersion>
+    <FileVersion>1.0.442</FileVersion>
+    <Version>1.0.442</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -625,6 +625,12 @@
     <None Update="UpdateScripts\1.0.438.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.442.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.442.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 53 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.442.sql

@@ -0,0 +1,53 @@
+-- =============================================================================
+-- 1.0.442  S5 MissedPrint 租户归属回填(一次性数据修复)
+--
+-- 背景
+--   MissedPrint(送货标签/箱码)由 SupplierShipmentService「生成标签」写入,
+--   历史实现的 INSERT 未写 tenant_id,导致库中 152 行全部为 NULL,
+--   下游 FUNC-S5-008 标签查询 / FUNC-S5-013 盘点结果无法按租户过滤,
+--   UATAdminA 登录可看到 797 租户的 104 行(已确认的跨租户读取)。
+--   代码侧已在同批修复:INSERT 补 tenant_id、MdpHotWatch 的 UPDATE 补租户条件、
+--   008/013 查询补 m.tenant_id = @TenantId。本脚本负责历史行回填。
+--
+-- 归属推导(唯一 SET 源:scm_shd.tenant_id)
+--   MissedPrint.ShipperNbr = scm_shd.shddh → scm_shd.tenant_id
+--   * 不用 Domain 推导:797 与 UAT 的 Domain 同为 8010,Domain 不是租户边界。
+--   * 不用 scm_shbq 作 SET 源:它只是标签明细的旁证,覆盖不全(22 行无对应箱码行)。
+--
+-- 回填前实测(2026-09-01,aidopdev)
+--   G1 MissedPrint 152 行,tenant_id 全部为 NULL
+--   G2 scm_shd 中 shddh 解析出多个 tenant_id 的:0(映射无歧义)
+--   G3 152 行全部可解析,不可解析 0
+--   G4 分布:797403760988229 = 104 / 838257186181189 = 48 / 其它 0 / 合计 152
+--   G5 scm_shd.tenant_id 为 NULL 或 0 的行:0
+--   G6 交叉验证 CreateUser → SysUser.TenantId:一致 152,矛盾 0
+--   G7 交叉验证 scm_shbq.xh = MissedPrint.BarCode:一致 130,矛盾 0,无对应 22
+--
+-- 幂等
+--   仅更新 tenant_id IS NULL 的行;重复执行影响 0 行。
+-- =============================================================================
+
+-- 幂等前置:列不存在时补列(正常情况下列已存在,此处只做保险)
+SET @col_exists := (
+    SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MissedPrint' AND COLUMN_NAME = 'tenant_id');
+SET @sql := IF(@col_exists = 0,
+    'ALTER TABLE MissedPrint ADD COLUMN tenant_id BIGINT NULL COMMENT ''租户Id''',
+    'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 回填:唯一 SET 源 = scm_shd.tenant_id
+UPDATE MissedPrint m
+JOIN (
+    SELECT shddh, MIN(tenant_id) AS tenant_id
+    FROM scm_shd
+    WHERE tenant_id IS NOT NULL AND tenant_id > 0
+    GROUP BY shddh
+    HAVING COUNT(DISTINCT tenant_id) = 1        -- 有歧义的单号一律跳过,不猜
+) d ON d.shddh = m.ShipperNbr
+SET m.tenant_id = d.tenant_id
+WHERE m.tenant_id IS NULL;                      -- 幂等:只补空,绝不覆盖已有归属
+
+-- 结果门禁在 1.0.442.verify.sql:still_null=0 / 797=104 / UAT=48 / 合计=152 / 与两路独立推导零矛盾。
+-- 若 verify 未过(例如回填前又新增了标签),AutoVersionUpdate 会抛错阻断启动 —— 这是有意的:
+-- 应先核对新行的归属再决定是否放宽 verify 中的数量断言,而不是让归属不明的行悄悄进库。

+ 45 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.442.verify.sql

@@ -0,0 +1,45 @@
+-- ===========================================================================
+-- 1.0.442 · verify  S5 MissedPrint 租户归属回填
+--
+-- 契约(AutoVersionUpdate.RunVerifyScriptIfExists):逐条执行、取首行首列、
+-- 交 IsTruthy 判定,不真即抛。故:禁止 SET @var;每条语句自包含;
+-- 每条返回「通过为真」的标量(用 = 期望值 得 1/0),不得返回错误条数;
+-- 一条语句一个断言;文件以最后一条 SQL 的分号结束。
+-- ===========================================================================
+
+-- 01 · 全部标签都有租户归属(写路径 + 回填共同保证)
+SELECT COUNT(*) = 0
+FROM MissedPrint
+WHERE tenant_id IS NULL OR tenant_id = 0;
+
+-- 02 · 归属与唯一 SET 源 scm_shd.tenant_id 零矛盾
+SELECT COUNT(*) = 0
+FROM MissedPrint m
+JOIN (SELECT shddh, MIN(tenant_id) AS tenant_id
+      FROM scm_shd
+      GROUP BY shddh
+      HAVING COUNT(DISTINCT tenant_id) = 1) d ON d.shddh = m.ShipperNbr
+WHERE IFNULL(m.tenant_id, 0) <> d.tenant_id;
+
+-- 03 · 与独立推导 CreateUser → SysUser.TenantId 零矛盾(交叉验证,非 SET 源)
+SELECT COUNT(*) = 0
+FROM MissedPrint m
+JOIN (SELECT Account, MIN(TenantId) AS TenantId
+      FROM SysUser
+      GROUP BY Account
+      HAVING COUNT(DISTINCT TenantId) = 1) u ON u.Account = m.CreateUser
+WHERE IFNULL(m.tenant_id, 0) <> u.TenantId;
+
+-- 04 · 回填基线快照(2026-09-01 实测 DRY-RUN):合计 152
+SELECT (SELECT COUNT(*) FROM MissedPrint) = 152;
+
+-- 05 · 回填基线快照:AIDOP 租户 797403760988229 = 104
+SELECT (SELECT COUNT(*) FROM MissedPrint WHERE tenant_id = 797403760988229) = 104;
+
+-- 06 · 回填基线快照:UAT 租户 838257186181189 = 48
+SELECT (SELECT COUNT(*) FROM MissedPrint WHERE tenant_id = 838257186181189) = 48;
+
+-- 07 · 两个租户之外不得出现第三方归属
+SELECT COUNT(*) = 0
+FROM MissedPrint
+WHERE tenant_id NOT IN (797403760988229, 838257186181189);

+ 104 - 7
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/InventoryDataPagesScopeTests.cs

@@ -144,22 +144,119 @@ public class InventoryDataPagesScopeTests
     }
 
     /// <summary>
-    /// 数据模型债的实证守卫:一旦 MissedPrint 开始有非 NULL 的 tenant_id,
-    /// 说明可以建立真正的租户边界,此用例会失败以提醒复核 008 的 Scope 决策
+    /// 原「数据模型债守卫」的反转版:1.0.442 回填后 MissedPrint.tenant_id 必须全部有值。
+    /// 一旦又出现 NULL,说明有写路径漏写 tenant_id(008/013 的租户过滤会把这些行整体查空)
     /// </summary>
     [Fact]
-    public async Task F008_MissedPrintTenantColumn_StillUnusable_DataModelDebtGuard()
+    public async Task F008_MissedPrintTenantColumn_MustBeFullyPopulated()
     {
         if (!Enabled) return;
         var db = BuildAidopdev();
         Assert.NotNull(db);
 
         var total = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint");
-        var withTenant = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint WHERE tenant_id IS NOT NULL");
-        var withLocation = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint WHERE IFNULL(Location,'')<>''");
+        var nullTenant = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint WHERE tenant_id IS NULL");
 
-        _out.WriteLine($"MissedPrint rows={total} tenant_id 非空={withTenant} Location 非空={withLocation}");
-        Assert.Equal(0, withTenant);   // 仍全为 NULL → 无法建立租户边界,008 的 DATA-MODEL RISK 依然成立
+        _out.WriteLine($"MissedPrint rows={total} tenant_id 为空={nullTenant}");
+        Assert.Equal(0, nullTenant);
+    }
+
+    /// <summary>
+    /// 回填正确性:归属只能来自 scm_shd.tenant_id(ShipperNbr → shddh),
+    /// 且必须与 CreateUser → SysUser.TenantId 的独立推导完全一致。
+    /// </summary>
+    [Fact]
+    public async Task F008_MissedPrintTenant_MatchesShipperAndCreateUser()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var vsShipper = await db.Ado.GetIntAsync(
+            """
+            SELECT COUNT(1) FROM MissedPrint m
+            JOIN (SELECT shddh, MIN(tenant_id) tenant_id FROM scm_shd
+                  GROUP BY shddh HAVING COUNT(DISTINCT tenant_id)=1) d ON d.shddh = m.ShipperNbr
+            WHERE IFNULL(m.tenant_id,0) <> d.tenant_id
+            """);
+
+        var vsCreateUser = await db.Ado.GetIntAsync(
+            """
+            SELECT COUNT(1) FROM MissedPrint m
+            JOIN (SELECT Account, MIN(TenantId) TenantId FROM SysUser
+                  GROUP BY Account HAVING COUNT(DISTINCT TenantId)=1) u ON u.Account = m.CreateUser
+            WHERE IFNULL(m.tenant_id,0) <> u.TenantId
+            """);
+
+        _out.WriteLine($"与 scm_shd 不一致={vsShipper};与 CreateUser→SysUser 不一致={vsCreateUser}");
+        Assert.Equal(0, vsShipper);
+        Assert.Equal(0, vsCreateUser);
+    }
+
+    /// <summary>
+    /// 双向不可见:任一租户按 tenant_id 过滤后,不得看到其它租户的标签;
+    /// 且各租户可见行数之和必须等于全表行数(不重不漏)。
+    /// </summary>
+    [Fact]
+    public async Task F008_F013_TenantScopedLabels_AreMutuallyInvisible()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var total = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint");
+        var uat = await db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T", new List<SugarParameter> { new("@T", UatTenant) });
+        var aidop = await db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T", new List<SugarParameter> { new("@T", AidopTenant) });
+        var leaked = await db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T AND tenant_id<>@T2",
+            new List<SugarParameter> { new("@T", UatTenant), new("@T2", UatTenant) });
+
+        _out.WriteLine($"total={total} UAT可见={uat} 797可见={aidop} 越界={leaked}");
+        Assert.Equal(0, leaked);
+        Assert.True(uat > 0 && aidop > 0, "两个租户都应各有标签,否则本用例失去意义");
+        Assert.Equal(total, uat + aidop);
+    }
+
+    /// <summary>
+    /// 生成标签 INSERT ... SELECT 的 ItemMaster 关联必须限定租户:
+    /// ItemNum 单独并不唯一(跨租户重名),不限定会把一行箱码放大成多行 MissedPrint,
+    /// 并可能取到别的租户的保质期主数据。
+    /// </summary>
+    [Fact]
+    public async Task LabelInsert_ItemMasterJoin_MustBeTenantScoped()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        // (tenant_id, ItemNum) 唯一 —— 这是加租户条件之后不再放大的前提
+        var dupWithinTenant = await db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM (SELECT tenant_id,ItemNum FROM ItemMaster GROUP BY tenant_id,ItemNum HAVING COUNT(*)>1) x");
+        // 单独 ItemNum 不唯一 —— 这是缺陷成立的前提(负向对照)
+        var dupAcrossTenant = await db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM (SELECT ItemNum FROM ItemMaster GROUP BY ItemNum HAVING COUNT(DISTINCT tenant_id)>1) x");
+
+        var amplified = await db.Ado.GetIntAsync(
+            """
+            SELECT IFNULL(SUM((SELECT COUNT(*) FROM ItemMaster i WHERE i.ItemNum=s.sh_material_code)),0)
+            FROM scm_shbq s
+            """);
+        var scoped = await db.Ado.GetIntAsync(
+            """
+            SELECT IFNULL(SUM((SELECT COUNT(*) FROM ItemMaster i
+                               WHERE i.ItemNum=s.sh_material_code AND i.tenant_id=s.tenant_id)),0)
+            FROM scm_shbq s
+            """);
+        var shbq = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM scm_shbq");
+
+        _out.WriteLine($"shbq={shbq} 不限租户JOIN={amplified} 限租户JOIN={scoped} " +
+                       $"(tenant,ItemNum)重复={dupWithinTenant} ItemNum跨租户重名={dupAcrossTenant}");
+        Assert.Equal(0, dupWithinTenant);
+        Assert.True(dupAcrossTenant > 0, "ItemNum 若已全局唯一,本缺陷不成立,用例失去意义");
+        Assert.True(amplified > shbq, "旧写法应当放大,否则本用例失去意义");
+        Assert.Equal(shbq, scoped);
     }
 
     // ===================== FUNC-S5-009 / 013 Domain 解析 =====================

+ 167 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/MissedPrintTenantOwnershipGuardTests.cs

@@ -0,0 +1,167 @@
+using System.Reflection;
+using Microsoft.AspNetCore.Authorization;
+using Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+using Admin.NET.Plugin.AiDOP.ProcurementExecution;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
+
+/// <summary>
+/// S5 MissedPrint 租户归属 · 源码级守卫。
+/// <para>
+/// 纯反射 + 源码文本断言,不连库、不起服务,普通 CI 也会执行(无 AIDOP_IT 门禁)。
+/// 数据面断言在 <see cref="InventoryDataPagesScopeTests"/>(需 AIDOP_IT=1)。
+/// </para>
+/// <para>
+/// 背景:MissedPrint 由 SupplierShipmentService「生成标签」写入,历史 INSERT 未写 tenant_id,
+/// 库中 152 行全为 NULL;同时该服务类级带 <c>[AllowAnonymous]</c> 且用
+/// <c>AidopTenantHelper.Resolve</c>(会读 query 参数 tenantId 并有默认租户兜底),
+/// 匿名 GET 传 <c>?tenantId=</c> 即可读任意租户数据。本组把修复后的边界固化下来。
+/// </para>
+/// </summary>
+public class MissedPrintTenantOwnershipGuardTests
+{
+    private static readonly string PluginRoot =
+        Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+
+    private static string ReadSource(string relativePath)
+    {
+        var full = Path.Combine(PluginRoot, relativePath);
+        Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
+        return File.ReadAllText(full);
+    }
+
+    // ---------- A:发货单服务的认证与租户解析边界 ----------
+
+    [Theory]
+    [InlineData(typeof(SupplierShipmentService))]
+    [InlineData(typeof(LabelQueryService))]
+    [InlineData(typeof(StocktakeResultService))]
+    public void ShipmentAndLabelServices_MustNotBeAnonymous(Type serviceType)
+    {
+        Assert.Null(serviceType.GetCustomAttribute<AllowAnonymousAttribute>(inherit: true));
+
+        var offenders = serviceType
+            .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
+            .Where(m => m.GetCustomAttribute<AllowAnonymousAttribute>(inherit: true) != null)
+            .Select(m => m.Name)
+            .ToList();
+        Assert.True(offenders.Count == 0, $"以下端点仍标注了 AllowAnonymous:{string.Join(", ", offenders)}");
+    }
+
+    /// <summary>
+    /// AidopTenantHelper.Resolve 会读 query 参数并有默认租户兜底(其自身注释即写明不得用于业务读写),
+    /// 发货单服务必须一律走 AidopTenantScope.ResolveOrThrow(仅认信 JWT、无兜底)。
+    /// </summary>
+    [Fact]
+    public void SupplierShipmentService_MustNotUseUnsafeTenantHelper()
+    {
+        var src = ReadSource("ProcurementExecution/SupplierShipmentService.cs");
+
+        // 只看可执行代码:类注释里为了留档会提到旧写法的名字,不应算违规
+        var codeLines = src.Split('\n')
+            .Select(l => l.Trim())
+            .Where(l => !l.StartsWith("///", StringComparison.Ordinal) && !l.StartsWith("//", StringComparison.Ordinal))
+            .ToList();
+
+        var offenders = codeLines.Where(l => l.Contains("AidopTenantHelper.Resolve", StringComparison.Ordinal)).ToList();
+        Assert.True(offenders.Count == 0, $"仍在用不安全的租户解析:{string.Join(" | ", offenders)}");
+        Assert.Contains(codeLines, l => l.Contains("AidopTenantScope.ResolveOrThrow(_userManager)", StringComparison.Ordinal));
+    }
+
+    // ---------- B:写路径必须写 / 限定 tenant_id ----------
+
+    [Fact]
+    public void MissedPrintInsert_MustWriteTenantId()
+    {
+        var src = ReadSource("ProcurementExecution/SupplierShipmentService.cs");
+        var insert = Slice(src, "INSERT INTO MissedPrint", "FROM scm_shbq s");
+        Assert.Contains("tenant_id", insert, StringComparison.Ordinal);
+        Assert.Contains("@TenantId AS tenant_id", insert, StringComparison.Ordinal);
+    }
+
+    /// <summary>
+    /// 生成标签的取数与关联必须限定租户:scm_shbq 的行、以及 PurOrdDetail / ItemMaster 的富化关联。
+    /// ItemMaster 的 ItemNum 跨租户重名,不限定会放大插入行数。
+    /// </summary>
+    [Fact]
+    public void MissedPrintInsertSource_MustBeTenantScoped()
+    {
+        var src = ReadSource("ProcurementExecution/SupplierShipmentService.cs");
+        var select = Slice(src, "FROM scm_shbq s", "\"\"\"");
+        Assert.Contains("i.tenant_id = @TenantId", select, StringComparison.Ordinal);
+        Assert.Contains("p.tenant_id = @TenantId", select, StringComparison.Ordinal);
+        Assert.Contains("s.tenant_id = @TenantId", select, StringComparison.Ordinal);
+    }
+
+    /// <summary>
+    /// 按业务键定位的写语句必须叠加租户:shdh / shddh 在库中无唯一索引。
+    /// </summary>
+    [Theory]
+    [InlineData("DELETE FROM scm_shbq WHERE shdh=@shdh AND tenant_id=@TenantId")]
+    [InlineData("UPDATE scm_shd SET shzt='待收', state=2, dycs=0 WHERE shddh=@shddh AND tenant_id=@TenantId")]
+    public void BusinessKeyWrites_MustCarryTenantPredicate(string expected)
+    {
+        var src = ReadSource("ProcurementExecution/SupplierShipmentService.cs");
+        Assert.Contains(expected, src, StringComparison.Ordinal);
+    }
+
+    /// <summary>
+    /// 作废旧标签的 UPDATE 必须按租户,而不是仅按 Domain —— 797 与 UAT 的 Domain 同为 8010。
+    /// </summary>
+    [Fact]
+    public void MissedPrintVoidUpdate_MustBeTenantScoped()
+    {
+        var src = ReadSource("ProcurementExecution/SupplierShipmentService.cs");
+        var stmt = Slice(src, "UPDATE MissedPrint SET", "Status = 'U';");
+        Assert.Contains("tenant_id = @TenantId", stmt, StringComparison.Ordinal);
+    }
+
+    // ---------- C:热监听回写路径 ----------
+
+    [Fact]
+    public void HotWatch_MissedPrintUpdate_MustBeTenantScoped()
+    {
+        var src = ReadSource("DataPlatform/HotWatch/MdpHotWatchService.cs");
+        var stmt = Slice(src, "UPDATE MissedPrint", "\"\"\"");
+        Assert.Contains("IFNULL(tenant_id, 0) = @TenantId", stmt, StringComparison.Ordinal);
+        // 租户不可解析时必须跳过,而不是退化成按 Domain 全域改写
+        Assert.Contains("跳过 MissedPrint 箱码回写", src, StringComparison.Ordinal);
+    }
+
+    // ---------- D:只读列表的租户过滤 ----------
+
+    [Fact]
+    public void LabelQuery_008_MustFilterByTenant()
+    {
+        var src = ReadSource("MaterialWarehouse/LabelQueryService.cs");
+        Assert.Contains("\"m.tenant_id = @TenantId\"", src, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void StocktakeResult_013_MustFilterByTenant()
+    {
+        var src = ReadSource("MaterialWarehouse/StocktakeResultService.cs");
+        Assert.Contains("\"m.tenant_id = @TenantId\"", src, StringComparison.Ordinal);
+    }
+
+    /// <summary>
+    /// 下拉选项同样是数据出口,不得跨租户抖出别人的库位。
+    /// </summary>
+    [Fact]
+    public void LabelQuery_LocationOptions_MustFilterByTenant()
+    {
+        var src = ReadSource("MaterialWarehouse/LabelQueryService.cs");
+        var stmt = Slice(src, "SELECT DISTINCT Location AS Val", "ORDER BY Location");
+        Assert.Contains("tenant_id = @TenantId", stmt, StringComparison.Ordinal);
+    }
+
+    private static string Slice(string src, string from, string to)
+    {
+        var i = src.IndexOf(from, StringComparison.Ordinal);
+        Assert.True(i >= 0, $"源码中找不到锚点:{from}");
+        var j = src.IndexOf(to, i + from.Length, StringComparison.Ordinal);
+        Assert.True(j >= 0, $"源码中找不到结束锚点:{to}");
+        return src.Substring(i, j - i);
+    }
+}

+ 15 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/HotWatch/MdpHotWatchService.cs

@@ -889,9 +889,16 @@ public sealed class MdpHotWatchService : ITransient
             }
         }
 
-        // 本库 MissedPrint 无 tenant_id,按 Domain + BarCode 定位;作废行 BarCode 带 RecID 前缀不会误匹配
+        // MissedPrint 已补 tenant_id(生成标签写入 + 一次性回填),故按 租户 + Domain + BarCode 定位。
+        // Domain 不是租户边界(797 与 UAT 同为 8010),必须叠加 tenant_id,否则会跨租户改写标签。
+        // 作废行 BarCode 带 RecID 前缀不会误匹配。
         var shippers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
-        if (barcodes != null)
+        if (barcodes != null && tenantId <= 0)
+        {
+            _logger.LogWarning(
+                "[MdpHotWatch] 无法确定租户,跳过 MissedPrint 箱码回写 po={PurOrd} domain={Domain}", purOrd, domain);
+        }
+        else if (barcodes != null)
         {
             foreach (var row in barcodes)
             {
@@ -910,6 +917,7 @@ public sealed class MdpHotWatchService : ITransient
                         UpdateUser = 'MDP_HOT',
                         UpdateTime = @Now
                     WHERE BarCode = @BarCode
+                      AND IFNULL(tenant_id, 0) = @TenantId
                       AND IFNULL(Domain, '') IN ('', @Domain)
                     """,
                     new SugarParameter("@Status", Trunc(GetString(row, "Status"), 4) ?? ""),
@@ -919,7 +927,8 @@ public sealed class MdpHotWatchService : ITransient
                     new SugarParameter("@InvStatus", Trunc(GetString(row, "InvStatus"), 24) ?? ""),
                     new SugarParameter("@Now", now),
                     new SugarParameter("@BarCode", barCode),
-                    new SugarParameter("@Domain", domain));
+                    new SugarParameter("@Domain", domain),
+                    new SugarParameter("@TenantId", tenantId));
 
                 var shipper = GetString(row, "ShipperNbr")?.Trim();
                 if (!string.IsNullOrWhiteSpace(shipper))
@@ -950,11 +959,13 @@ public sealed class MdpHotWatchService : ITransient
                 SUM(CASE WHEN IFNULL(Status, '') = 'U' THEN 1 ELSE 0 END) AS Pending
             FROM MissedPrint
             WHERE ShipperNbr = @Shddh
+              AND (@TenantId = 0 OR IFNULL(tenant_id, 0) = @TenantId)
               AND IFNULL(Domain, '') = @Domain
               AND IFNULL(PurOrd, '') NOT LIKE '作废%'
             """,
             new SugarParameter("@Shddh", shddh),
-            new SugarParameter("@Domain", domain));
+            new SugarParameter("@Domain", domain),
+            new SugarParameter("@TenantId", tenantId));
 
         var stat = stats?.FirstOrDefault();
         if (stat == null || stat.Total <= 0)

+ 7 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/LabelQueryService.cs

@@ -59,7 +59,9 @@ public class LabelQueryService : IDynamicApiController, ITransient
         // 仅用于状态码表 JOIN 的租户限定;业务行本身的租户边界见类注释(数据模型债,本批不伪造)
         var tenantId = ResolveTenantOrThrow();
 
-        var where = new List<string> { "1=1" };
+        // 租户边界:MissedPrint.tenant_id 由生成标签写入(SupplierShipmentService)并已一次性回填历史行。
+        // 不得退回按 Domain 过滤 —— 797 与 UAT 的 Domain 同为 8010,Domain 不是租户边界。
+        var where = new List<string> { "m.tenant_id = @TenantId" };
         var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
 
         if (!string.IsNullOrWhiteSpace(input.ItemNum))
@@ -191,9 +193,11 @@ public class LabelQueryService : IDynamicApiController, ITransient
             """
             SELECT DISTINCT Location AS Val
             FROM MissedPrint
-            WHERE Location IS NOT NULL AND Location <> ''
+            WHERE tenant_id = @TenantId
+              AND Location IS NOT NULL AND Location <> ''
             ORDER BY Location
-            """);
+            """,
+            new List<SugarParameter> { new("@TenantId", ResolveTenantOrThrow()) });
         return list;
     }
 

+ 4 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/PendingInspectionService.cs

@@ -91,10 +91,11 @@ public class PendingInspectionService : IDynamicApiController, ITransient
             "m.Qty > 0",
             "IFNULL(m.Location,'') = ''",
         };
-        // 同库分租户:主表 MissedPrint 无 tenant_id(只能靠 Domain 公司域隔离,无法做租户行级过滤);
+        // 同库分租户:主表 MissedPrint 的 tenant_id 已由生成标签写入 + 1.0.442 回填补齐(跟踪项:本服务尚未改用它);
         // LEFT JOIN 的 qms 申请/明细表已补 tenant_id,租户过滤置于 JOIN ON(避免退化为 INNER JOIN 丢失 MissedPrint 锚行)。
         // 租户改用严格可信解析(仅认证后 JWT,忽略前端 tenantId);配合类级已去 [AllowAnonymous],杜绝匿名跨租户读。
-        // 残留边界:MissedPrint 锚行本身仍按 Domain(非租户)可见,此为旧系统 pr_WMS_GetMissedTempQty 无租户列的既有约束。
+        // 残留边界:MissedPrint 锚行本身仍按 Domain(非租户)可见。Domain 不是租户边界(797 与 UAT 同为 8010),
+        // 待与 008/013 同法改用 m.tenant_id = @TenantId 收口(本批未纳入,见回执「未闭环」)。
         var tenantId = listTenantId;
         var pars = new List<SugarParameter> { new("@Domain", domain), new("@TenantId", tenantId) };
 
@@ -205,7 +206,7 @@ public class PendingInspectionService : IDynamicApiController, ITransient
     [HttpGet("supplier-options")]
     public async Task<object> GetSupplierOptions()
     {
-        // 残留边界(注明):本选项来自 MissedPrint(tenant_id)+ SuppMaster(无 tenant_id),无 tenant-safe 父键可挂载租户过滤,
+        // 残留边界(注明):本选项来自 MissedPrint(tenant_id 已补齐但本服务尚未启用)+ SuppMaster(无 tenant_id),
         // 只能按 Domain 公司域收敛,无法做租户行级过滤(既有约束)。类级已去 [AllowAnonymous] → 需认证后方可访问,杜绝匿名读。
         var domain = await ResolveDomainOrThrowAsync(ResolveTenantOrThrow());
         var list = await _db.Ado.SqlQueryAsync<PendingInspectionSupplierOption>(

+ 6 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/StocktakeLabelConfirmService.cs

@@ -16,10 +16,12 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 /// 当时 IsTag=1 恰好 0 行故未造成实际泄漏,但端点是公开的,一旦出现盘点标签数据即形成匿名读取。
 /// 现收敛为需认证访问。
 ///
-/// 【仍未解决 · DATA-MODEL DEBT】租户边界。查询仍是 WHERE IsTag=1,无 Tenant/Domain 条件:
-/// MissedPrint 的 tenant_id 列存在但实测 152 行全为 NULL,且 150 行 Location 为空,
-/// 既无法按 tenant_id 过滤(会把页面整体查空),也无法拿 Location 当租户边界(会误删绝大多数行)。
-/// 本批只关闭匿名访问,**不代表 tenant isolation 已 CLOSED**。
+/// 【仍未解决 · 跟踪项】租户边界。查询仍是 WHERE IsTag=1,无 Tenant/Domain 条件。
+/// 原因已从「无法过滤」变成「本服务无认证上下文可取租户」:MissedPrint.tenant_id 曾实测 152 行全为 NULL,
+/// 现已由生成标签写入 + 1.0.442 一次性回填补齐,008/013 已改按 m.tenant_id 过滤;
+/// 本服务未一并改造,是因为它当前 IsTag=1 恒为 0 行(DEAD FUNCTION),
+/// 改造留待其恢复业务时与 UserManager 注入一起做。
+/// 现状:**Anonymous access CLOSED,tenant isolation 未 CLOSED**。
 /// </summary>
 [ApiDescriptionSettings(Order = 308, Description = "盘点标签确认")]
 [Route("api/S5StocktakeLabelConfirm")]

+ 5 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/StocktakeResultService.cs

@@ -81,10 +81,12 @@ public class StocktakeResultService : IDynamicApiController, ITransient
         var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
         var offset = (page - 1) * pageSize;
 
-        var domain = await ResolveDomainOrThrowAsync(ResolveTenantOrThrow());
+        var tenantId = ResolveTenantOrThrow();
+        var domain = await ResolveDomainOrThrowAsync(tenantId);
 
-        var where = new List<string> { "m.Domain = @Domain", "m.IsTag = 1" };
-        var pars = new List<SugarParameter> { new("@Domain", domain) };
+        // 租户边界取 tenant_id;Domain 仅作同租户内的站点过滤,不足以隔离租户(797 与 UAT 同为 8010)
+        var where = new List<string> { "m.tenant_id = @TenantId", "m.Domain = @Domain", "m.IsTag = 1" };
+        var pars = new List<SugarParameter> { new("@Domain", domain), new("@TenantId", tenantId) };
 
         if (!string.IsNullOrWhiteSpace(input.MaterialCode))
         {

+ 54 - 21
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/SupplierShipmentService.cs

@@ -6,10 +6,32 @@ namespace Admin.NET.Plugin.AiDOP.ProcurementExecution;
 
 /// <summary>
 /// S4 供应商发货单(列表 + 表单)
+///
+/// 【租户安全边界】租户一律经 <see cref="AidopTenantScope.ResolveOrThrow"/> 从认证后 JWT 解析,
+/// 无有效租户即拒绝,不读前端 tenantId、无默认回退。
+/// <para>
+/// 原实现:类级 <c>[AllowAnonymous]</c> + 全类使用 <c>AidopTenantHelper.Resolve(App.HttpContext)</c>。
+/// 后者的解析顺序为 requestTenantId &gt; JWT claim &gt; <b>query 参数 tenantId</b> &gt; <b>DefaultTenantId 兜底</b>,
+/// 其自身注释即写明「允许前端指定租户且有默认回退,<b>不得用于业务数据的查询与写入</b>」。
+/// 两者叠加的实测后果:匿名(无 Token、无 Cookie)GET
+/// <c>/api/ProcurementExecution/supplier-shipment/list?tenantId=&lt;任意租户&gt;</c> 可读回该租户业务数据
+/// (797 返回 13 条、UAT 返回 2 条);同类的 HttpPost 写接口走同一条解析链。
+/// </para>
+/// <para>
+/// 本类的写 SQL 一律带 <c>tenant_id = @TenantId</c>:业务键 shddh/shdh 在库中无唯一索引,
+/// 一旦将来出现跨租户重复单号,仅按业务键定位会造成越权改写。
+/// </para>
+/// <para>
+/// 生成标签的 <c>INSERT INTO MissedPrint ... SELECT ... FROM scm_shbq</c> 另有两处缺陷一并修复:
+/// ① 未写 <c>tenant_id</c>,导致库中 152 行标签全部为 NULL、下游 008/013 无法按租户过滤;
+/// ② <c>LEFT JOIN ItemMaster ON ItemNum</c> 未限定租户,而 ItemMaster 有 663 个 ItemNum 跨租户重名
+/// (<c>(tenant_id, ItemNum)</c> 唯一、单独 ItemNum 不唯一),实测按现网 scm_shbq 数据:
+/// 797 的 50 行会放大成 82 行、UAT 的 24 行会放大成 48 行,且 ExpireDate 可能取自其它租户的物料主数据。
+/// 现补 <c>i.tenant_id / p.tenant_id / s.tenant_id = @TenantId</c>。
+/// </para>
 /// </summary>
 [ApiDescriptionSettings(Order = 321, Description = "S4供应商发货单")]
 [Route("api/ProcurementExecution")]
-[AllowAnonymous]
 [NonUnify]
 public class SupplierShipmentService : IDynamicApiController, ITransient
 {
@@ -39,7 +61,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
     [HttpGet("supplier-shipment/list")]
     public async Task<object> GetList([FromQuery] SupplierShipmentListInput input)
     {
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
         var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
         var conditions = new List<string> { "IFNULL(m.state, 1) <> 0" };
         if (!string.IsNullOrWhiteSpace(input.JhshrqFrom))
@@ -99,7 +121,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
     [HttpGet("supplier-shipment/{id:long}")]
     public async Task<object> GetDetail(long id)
     {
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
         var master = await _masterRep.GetFirstAsync(x => x.Id == id && x.TenantId == tenantId) ?? throw Oops.Oh("发货单不存在");
         var details = await _detailRep.AsQueryable().Where(x => x.Glid == id.ToString() && x.TenantId == tenantId).OrderBy(x => x.Hh).ToListAsync();
         return new
@@ -144,7 +166,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
     [HttpGet("supplier-shipment/create-draft")]
     public async Task<object> GetCreateDraft([FromQuery] string ids)
     {
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
         var pars = new List<SugarParameter> { new("@ids", ids), new("@TenantId", tenantId) };
         var rows = await _db.Ado.SqlQueryAsync<SupplierShipmentDraftRow>(
             """
@@ -220,7 +242,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
     public async Task<object> Save([FromBody] SupplierShipmentSaveInput input)
     {
         var now = DateTime.Now;
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
         var userId = _userManager.UserId.ToString();
         var userName = _userManager.Account ?? "system";
         var shipDate = string.IsNullOrWhiteSpace(input.Jhshrq) ? now.ToString("yyyy-MM-dd") : input.Jhshrq!.Trim();
@@ -272,7 +294,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
     [ApiDescriptionSettings(Name = "DeleteSupplierShipment"), HttpPost("supplier-shipment/delete")]
     public async Task<object> Delete([FromBody] SupplierShipmentDeleteInput input)
     {
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
         var master = await _masterRep.GetFirstAsync(x => x.Id == input.Id && x.TenantId == tenantId) ?? throw Oops.Oh("发货单不存在");
         master.State = 0;
         await _masterRep.UpdateAsync(master);
@@ -286,7 +308,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
         if (input.Id <= 0)
             throw Oops.Oh("缺少发货单ID(id)");
 
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
         var userName = _userManager.Account ?? "system";
 
         var master = await _masterRep.GetFirstAsync(x => x.Id == input.Id && x.TenantId == tenantId) ?? throw Oops.Oh("发货单不存在");
@@ -323,9 +345,10 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                     """,
                     new List<SugarParameter> { new("@shdh", shddh) });
 
+                // 业务键 shdh 无唯一索引,必须叠加租户条件,否则跨租户重号时会删掉别人的标签
                 await _db.Ado.ExecuteCommandAsync(
-                    "DELETE FROM scm_shbq WHERE shdh=@shdh",
-                    new List<SugarParameter> { new("@shdh", shddh) });
+                    "DELETE FROM scm_shbq WHERE shdh=@shdh AND tenant_id=@TenantId",
+                    new List<SugarParameter> { new("@shdh", shddh), new("@TenantId", tenantId) });
 
                 // 2) 需要补 shpc 的明细(按物料+供应商批号去重)
                 var needShpc = await _db.Ado.SqlQueryAsync<NeedShpcRow>(
@@ -513,8 +536,8 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
 
                 // 5) 更新发货单状态
                 await _db.Ado.ExecuteCommandAsync(
-                    "UPDATE scm_shd SET shzt='待收', state=2, dycs=0 WHERE shddh=@shddh",
-                    new List<SugarParameter> { new("@shddh", shddh) });
+                    "UPDATE scm_shd SET shzt='待收', state=2, dycs=0 WHERE shddh=@shddh AND tenant_id=@TenantId",
+                    new List<SugarParameter> { new("@shddh", shddh), new("@TenantId", tenantId) });
 
                 // 6) MissedPrint:作废旧未打印(U)
                 await _db.Ado.ExecuteCommandAsync(
@@ -526,7 +549,8 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                         RelatedBarCode = '',
                         UpdateTime = NOW(),
                         UpdateUser = @u
-                    WHERE Domain = @domain
+                    WHERE tenant_id = @TenantId
+                      AND Domain = @domain
                       AND ShipperNbr = @shddh
                       AND Status = 'U';
                     """,
@@ -535,6 +559,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                         new("@u", userName),
                         new("@domain", domain),
                         new("@shddh", shddh),
+                        new("@TenantId", tenantId),
                     });
 
                 // 7) MissedPrint:插入待打印(U)
@@ -551,7 +576,8 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                         LabelFormat, StandItem, EffSize, GP12CheckedQty, NetWeight,
                         Remark, CreateTime, UpdateTime, CreateUser, UpdateUser,
                         LevelChar, PurOrdDetBatchNbr, FirmString5, ExpireDate,
-                        Printer, Company, Checker, Position
+                        Printer, Company, Checker, Position,
+                        tenant_id
                     )
                     SELECT
                         @domain AS Domain,
@@ -605,17 +631,24 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                         '' AS Printer,
                         '' AS Company,
                         '' AS Checker,
-                        '' AS Position
+                        '' AS Position,
+                        @TenantId AS tenant_id
                     FROM scm_shbq s
-                    LEFT JOIN PurOrdDetail p ON p.PurOrd = s.po_billno AND p.Line = s.po_billline
-                    LEFT JOIN ItemMaster i ON i.ItemNum = s.sh_material_code
-                    WHERE s.shdh = @shddh;
+                    LEFT JOIN PurOrdDetail p
+                           ON p.PurOrd = s.po_billno AND p.Line = s.po_billline
+                          AND p.tenant_id = @TenantId
+                    LEFT JOIN ItemMaster i
+                           ON i.ItemNum = s.sh_material_code
+                          AND i.tenant_id = @TenantId
+                    WHERE s.shdh = @shddh
+                      AND s.tenant_id = @TenantId;
                     """,
                     new List<SugarParameter>
                     {
                         new("@domain", domain),
                         new("@u", userName),
                         new("@shddh", shddh),
+                        new("@TenantId", tenantId),
                     });
             });
 
@@ -715,7 +748,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
     /// </summary>
     private async Task<string> AllocateShddhAsync(string supplierCode)
     {
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
         var code = (supplierCode ?? string.Empty).Trim().Replace("-", string.Empty);
         if (string.IsNullOrEmpty(code))
             throw Oops.Oh("请先填写供应商编号,保存时将用于生成发货单号");
@@ -877,7 +910,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
         if (string.IsNullOrWhiteSpace(shddh))
             throw Oops.Oh("缺少发货单编号(shddh)");
 
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
         var pars = new List<SugarParameter> { new("@shddh", shddh.Trim()), new("@TenantId", tenantId) };
 
         const string masterSql = """
@@ -1009,7 +1042,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
         if (string.IsNullOrWhiteSpace(shddh))
             throw Oops.Oh("缺少发货单编号(shddh)");
 
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
         var pars = new List<SugarParameter> { new("@shddh", shddh.Trim()), new("@TenantId", tenantId) };
         const string sql = """
                            SELECT DISTINCT
@@ -1045,7 +1078,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
 
     private async Task SaveDetailsAsync(long masterId, List<SupplierShipmentDetailInput> inputDetails)
     {
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
         var dbDetails = await _detailRep.AsQueryable().Where(x => x.Glid == masterId.ToString() && x.TenantId == tenantId).ToListAsync();
         var dbById = dbDetails.ToDictionary(x => x.Id);
         var inputIds = new HashSet<long>(inputDetails.Where(d => d.Id is > 0).Select(d => d.Id!.Value));