Просмотр исходного кода

fix(s5): close supplier tenant security gaps

三个服务此前是类级 [AllowAnonymous] + AidopTenantHelper.Resolve(App.HttpContext)。
后者解析顺序为 requestTenantId > JWT claim > query 参数 tenantId > DefaultTenantId 兜底,
其自身注释即写明不得用于业务数据读写。叠加后果(实测):匿名 GET
supplier-delivery/list?tenantId=<任意租户> 返回 HTTP 200 与该租户业务数据
(797 → 94 条、UAT → 7 条、不传参数落默认租户),即匿名客户端可自行选择租户。

认证与租户来源
- SupplierDeliveryManagementService:去 [AllowAnonymous],5 处解析改
  AidopTenantScope.ResolveOrThrow(_userManager)(仅认信 JWT、无兜底、失败即拒)。
- SupplierShortageKanbanService:同上,3 处。
- PurOrdWmsPushService:去 [AllowAnonymous];租户改从 JWT 取,请求体 TenantId
  降级为断言(不一致直接拒),不再据其切租户,删掉「无登录态推送」旁路。

Refresh 写链收口(原为匿名可触发的破坏性写)
- DELETE FROM WorkOrdDetailTotal / WorkOrdDetailTotalKB 原无 WHERE,会清空全部租户
  再只重建被指定的那个租户;现补 WHERE tenant_id = @TenantId。
- Refresh 内 7 条 UPDATE 的目标侧全部补 a.tenant_id = @TenantId:原先只靠
  ItemNum / OrdDate 关联,而这些业务键在库中没有不含 tenant_id 的唯一索引。
- 在检数量 zjsl 的 MissedPrint LATERAL 补 m.tenant_id = p0.tenant_id
  (同一语句内的 scm_shdzb / PurOrdRctDetail 本就带此条件,独 MissedPrint 漏)。
- Domain 改走 SourceDomainTenantResolver 按当前租户解析并 fail closed,
  删除 SELECT Domain ... LIMIT 1 的全局第一条写法与硬编码 '1001' 回落。
- 同链顺带收口:mes_morder / ItemMaster JOIN 补租户(ItemMaster 的 ItemNum
  跨租户重名 663 个、Domain 8010 内重复 143 个,不限定会放大透视行数);
  scm_shdzb / scm_jhjh_jq 子查询、ConfigurationItem 配置读取补租户;
  GetList / CreateTodo 去掉「列不存在就读全租户」的运行期探测(改为硬条件);
  GetTodoDetail 由按 Id 裸取改为按租户取;中间表 _tmp_loc_stock 改为按租户分区,
  不再 TRUNCATE / DROP(原写法是全局的,两个租户并发 Refresh 会互相清表)。

PurOrdWmsPush 箱码取数
- SELECT ... FROM MissedPrint WHERE ShipperNbr=@Shddh 补 tenant_id = @TenantId
  (tenantId 本就由 TryEnqueueShipmentChainSafeAsync 传入却未使用),
  保留 Status='U' 与 PurOrd NOT LIKE '作废%' 语义不变。

验证
- dotnet build --framework net10.0 通过;新增 24 个用例全过(18 静态守卫 + 6 数据面)。
- 数据面用例全程零写入:用内联派生表构造双租户样本,把修复后的真实谓词放到真 MySQL 上跑,
  在检数量得 10 而非 110、箱码得 1 条而非 3 条,各带一个旧写法负向对照防止用例空转。
- 默认测试套件 1194 passed / 1 failed,唯一失败为 S6 自有用例,与本批无关;
  AIDOP_IT=1 全量 13 failed,把本批 3 个文件 stash 后重跑同一批仍失败 12 条
  (第 13 条为顺序相关 flaky),且这些用例均未引用本批修改的类 → 非本批引入。
- 运行态冒烟(chrome-devtools MCP,隔离浏览器上下文,冒烟槽 2/3):
  匿名 11 个端点全部 401,含 supplier-shortage-kanban/refresh 与 wms-purord/push;
  UATAdminA 交货管理 7 条、伪造 ?tenantId=797 仍为 7 条;
  AIDOPDemo(797) 94 条、伪造 ?tenantId=UAT 仍为 94 条;页面无报错、控制台无 error。

RUNTIME DESTRUCTIVE PATH NOT EXECUTED:未执行 Refresh 真实跑批,
其修复由静态守卫 + SQL 谓词模拟证明。

回归
- FUNC-S5-008:UAT 48 / 797 104,跨租户 0,未变。
- FUNC-S5-010:UAT STD 共 3231 条,10/50/100 三档取回数=去重数,dataSource=STD;
  Shelf / Version / Supplier 三列仍全空(与本批前一致),REMAINS CLOSED。
  本批未改 InventoryBalanceReader。

未处理(另批)
- P1:FUNC-S5-009 PendingInspection 锚表仍 Domain-only;IqcWmsResultPushService
  的 MissedPrint 取数仍跨 Domain 分区。
- P2:InventoryBalanceReader 两处 MissedPrint 富化子查询仍 Domain-only(HARDEN)。
- Debt:IqcReceiptStateMdpSyncService 未接线;FUNC-S5-012 DEAD。
- 按要求本批不加唯一索引、不动 MissedPrint 数据、不修 GeneralizedCodeMaster。
YY968XX 1 день назад
Родитель
Сommit
9aba53981a

+ 3 - 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.443</AssemblyVersion>
-    <FileVersion>1.0.443</FileVersion>
-    <Version>1.0.443</Version>
+    <AssemblyVersion>1.0.444</AssemblyVersion>
+    <FileVersion>1.0.444</FileVersion>
+    <Version>1.0.444</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 255 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/ProcurementExecution/SupplierTenantSecurityDataTests.cs

@@ -0,0 +1,255 @@
+using System.Text.RegularExpressions;
+using SqlSugar;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S5.ProcurementExecution;
+
+/// <summary>
+/// S5 供应商链 P0 租户安全 · 谓词语义验证(§14 C / D / E / F 的数据面部分)。
+/// <para>
+/// <b>门禁</b>:仅当环境变量 AIDOP_IT=1 时执行。
+/// <b>全程零写入</b>:不 INSERT / UPDATE / DELETE 任何业务表,也不执行 Refresh。
+/// 双租户样本用内联派生表(<c>SELECT ... UNION ALL</c>)构造,把**修复后的真实谓词**
+/// 放到真实 MySQL 引擎上跑,验证「本租户算式只吃本租户行」。
+/// </para>
+/// <para>
+/// 边界声明:本组验证的是 <b>SQL 谓词语义</b>,不是端到端接线。
+/// 「谁调用、传的哪个 tenantId、是否匿名」由 <see cref="SupplierTenantSecurityGuardTests"/> 的
+/// 反射 / 源码断言覆盖。Refresh 属破坏性写路径,按批次纪律
+/// <b>RUNTIME DESTRUCTIVE PATH NOT EXECUTED</b>。
+/// </para>
+/// </summary>
+[Trait("Category", "Integration")]
+public class SupplierTenantSecurityDataTests
+{
+    private const long TenantA = 838257186181189L;   // UAT
+    private const long TenantB = 797403760988229L;   // AIDOP
+
+    private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
+
+    private readonly Xunit.Abstractions.ITestOutputHelper _out;
+    public SupplierTenantSecurityDataTests(Xunit.Abstractions.ITestOutputHelper output) => _out = output;
+
+    private static ISqlSugarClient? BuildAidopdev()
+    {
+        const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json";
+        if (!File.Exists(path)) return null;
+        var cs = File.ReadLines(path)
+            .Select(l => l.Trim())
+            .Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"")
+                        && l.Contains("Database=aidopdev") && l.Contains("123.60.180.165"))
+            .Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value)
+            .FirstOrDefault(v => !string.IsNullOrEmpty(v));
+        if (string.IsNullOrEmpty(cs)) return null;
+        return new SqlSugarScope(new ConnectionConfig
+        {
+            ConfigId = "aidopdev-supplier-sec-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
+        });
+    }
+
+    /// <summary>双租户 MissedPrint 样本:同一 (PurOrd, PurLine),A=10 / B=99。</summary>
+    private const string MissedPrintFixture = """
+        SELECT @A AS tenant_id, 'PO-DUP-1' AS PurOrd, 1 AS PurLine, 'I' AS Status, 10 AS Qty, 'SHD-DUP-1' AS ShipperNbr
+        UNION ALL SELECT @B, 'PO-DUP-1', 1, 'I', 99, 'SHD-DUP-1'
+        UNION ALL SELECT @B, 'PO-DUP-1', 1, 'I',  1, 'SHD-DUP-1'
+        """;
+
+    /// <summary>双租户欠料中间表样本:同一 ItemNum,各 1 行。</summary>
+    private const string TotalFixture = """
+        SELECT @A AS tenant_id, 'ITEM-DUP' AS ItemNum, 0 AS zjsl, 0 AS locStock
+        UNION ALL SELECT @B, 'ITEM-DUP', 0, 0
+        """;
+
+    private static List<SugarParameter> Pars() => new()
+    {
+        new("@A", TenantA), new("@B", TenantB), new("@TenantId", TenantA),
+    };
+
+    // ===================== E:MissedPrint 在检数量聚合 =====================
+
+    /// <summary>
+    /// §14 E:Tenant A 的 zjsl 必须只得到 10,不得得到 110(A 的 10 + B 的 99+1)。
+    /// 修复后的谓词是 <c>m.tenant_id = p0.tenant_id</c>。
+    /// </summary>
+    [Fact]
+    public async Task E_MissedPrintAggregate_OnlySumsOwnTenant()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var scoped = await db!.Ado.SqlQuerySingleAsync<decimal?>(
+            $"""
+            SELECT (SELECT SUM(m.Qty) FROM ({MissedPrintFixture}) m
+                    WHERE m.Status='I' AND m.PurOrd=p0.PurOrd AND m.PurLine=p0.Line
+                      AND m.tenant_id = p0.tenant_id)
+            FROM (SELECT @TenantId AS tenant_id, 'PO-DUP-1' AS PurOrd, 1 AS Line) p0
+            """, Pars());
+
+        // 负向对照:旧写法(无 tenant 条件)会把 B 的 100 一起算进来
+        var unscoped = await db.Ado.SqlQuerySingleAsync<decimal?>(
+            $"""
+            SELECT (SELECT SUM(m.Qty) FROM ({MissedPrintFixture}) m
+                    WHERE m.Status='I' AND m.PurOrd=p0.PurOrd AND m.PurLine=p0.Line)
+            FROM (SELECT @TenantId AS tenant_id, 'PO-DUP-1' AS PurOrd, 1 AS Line) p0
+            """, Pars());
+
+        _out.WriteLine($"修复后 zjsl={scoped};旧写法 zjsl={unscoped}");
+        Assert.Equal(110m, unscoped);   // 负向对照成立,用例才有意义
+        Assert.Equal(10m, scoped);
+    }
+
+    // ===================== F:PurOrdWmsPush 箱码取数 =====================
+
+    /// <summary>
+    /// §14 F:同一 ShipperNbr 双租户时,Tenant A 入队只能读到 A 自己的箱码。
+    /// </summary>
+    [Fact]
+    public async Task F_BarcodeSelect_OnlyReadsOwnTenant()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var scoped = await db!.Ado.GetIntAsync(
+            $"""
+            SELECT COUNT(1) FROM ({MissedPrintFixture}) m
+            WHERE m.ShipperNbr='SHD-DUP-1' AND m.tenant_id = @TenantId
+            """, Pars());
+
+        var unscoped = await db.Ado.GetIntAsync(
+            $"""
+            SELECT COUNT(1) FROM ({MissedPrintFixture}) m
+            WHERE m.ShipperNbr='SHD-DUP-1'
+            """, Pars());
+
+        _out.WriteLine($"修复后箱码数={scoped};旧写法={unscoped}");
+        Assert.Equal(3, unscoped);
+        Assert.Equal(1, scoped);
+    }
+
+    // ===================== C / D:DELETE / UPDATE 影响面 =====================
+
+    /// <summary>
+    /// §14 C:Tenant A 的清表只能命中 A。用 SELECT 复刻 DELETE 的 WHERE 求影响面,不执行 DELETE。
+    /// </summary>
+    [Fact]
+    public async Task C_DeleteScope_TouchesOnlyOwnTenant()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var affected = await db!.Ado.SqlQueryAsync<long>(
+            $"SELECT tenant_id FROM ({TotalFixture}) a WHERE a.tenant_id = @TenantId", Pars());
+        var affectedAll = await db.Ado.GetIntAsync(
+            $"SELECT COUNT(1) FROM ({TotalFixture}) a", Pars());
+
+        _out.WriteLine($"本租户命中={affected.Count},样本总行={affectedAll}");
+        Assert.Equal(2, affectedAll);                 // 样本确实含两个租户,用例才有意义
+        Assert.Single(affected);
+        Assert.Equal(TenantA, affected[0]);
+        Assert.DoesNotContain(TenantB, affected);
+    }
+
+    /// <summary>
+    /// §14 D:同 ItemNum 的双租户数据,Tenant A 的 UPDATE 不得命中 B。
+    /// 复刻 Step 3 的 `JOIN ... ON a.ItemNum=b.ItemNum WHERE a.tenant_id=@TenantId` 影响面。
+    /// </summary>
+    [Fact]
+    public async Task D_UpdateScope_TouchesOnlyOwnTenant()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        const string joinSrc = "SELECT 'ITEM-DUP' AS ItemNum, 7 AS zjsl";
+
+        var scoped = await db!.Ado.SqlQueryAsync<long>(
+            $"""
+            SELECT a.tenant_id FROM ({TotalFixture}) a
+            JOIN ({joinSrc}) b ON a.ItemNum = b.ItemNum
+            WHERE a.tenant_id = @TenantId
+            """, Pars());
+
+        // 负向对照:旧写法只按 ItemNum 关联,会同时改到两个租户
+        var unscoped = await db.Ado.SqlQueryAsync<long>(
+            $"""
+            SELECT a.tenant_id FROM ({TotalFixture}) a
+            JOIN ({joinSrc}) b ON a.ItemNum = b.ItemNum
+            """, Pars());
+
+        _out.WriteLine($"修复后命中租户={string.Join(",", scoped)};旧写法命中={string.Join(",", unscoped)}");
+        Assert.Equal(2, unscoped.Count);
+        Assert.Single(scoped);
+        Assert.Equal(TenantA, scoped[0]);
+    }
+
+    // ===================== 现网键碰撞现状(§12) =====================
+
+    /// <summary>
+    /// §12:即使当前无跨租户重复也不能省 tenant 条件(业务键无数据库级唯一约束)。
+    /// 本用例只记录现状,不因为「今天不重复」而放松要求;若将来出现重复,日志里能直接看到。
+    /// </summary>
+    [Fact]
+    public async Task BusinessKeys_HaveNoGlobalUniqueConstraint()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var dupMp = await db!.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM (SELECT PurOrd,PurLine FROM MissedPrint WHERE IFNULL(PurOrd,'')<>'' GROUP BY PurOrd,PurLine HAVING COUNT(DISTINCT tenant_id)>1) x");
+        var dupShipper = await db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM (SELECT ShipperNbr FROM MissedPrint WHERE IFNULL(ShipperNbr,'')<>'' GROUP BY ShipperNbr HAVING COUNT(DISTINCT tenant_id)>1) x");
+        var dupItem = await db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM (SELECT ItemNum FROM ItemMaster GROUP BY ItemNum HAVING COUNT(DISTINCT tenant_id)>1) x");
+
+        _out.WriteLine($"跨租户重复:MissedPrint(PurOrd,PurLine)={dupMp} ShipperNbr={dupShipper} ItemMaster.ItemNum={dupItem}");
+
+        // 这些业务键没有任何**不含 tenant_id 的**唯一索引 —— 即它们全局不唯一,
+        // 所以只按业务键关联必然可能跨租户串数,必须显式带 tenant 条件。
+        // (ItemMaster 有 uk_ItemMaster_factory_item = (tenant_id, factory_ref_id, ItemNum),
+        //   恰恰说明 ItemNum 只在租户内唯一。)
+        var globallyUniqueIdx = await db.Ado.SqlQueryAsync<string>(
+            """
+            SELECT CONCAT(s.TABLE_NAME, '.', s.INDEX_NAME)
+            FROM information_schema.STATISTICS s
+            WHERE s.TABLE_SCHEMA = DATABASE() AND s.NON_UNIQUE = 0
+              AND ((s.TABLE_NAME='MissedPrint'   AND s.COLUMN_NAME IN ('ShipperNbr','PurOrd'))
+                OR (s.TABLE_NAME='ItemMaster'    AND s.COLUMN_NAME = 'ItemNum')
+                OR (s.TABLE_NAME='WorkOrdMaster' AND s.COLUMN_NAME = 'WorkOrd'))
+              AND NOT EXISTS (
+                    SELECT 1 FROM information_schema.STATISTICS t
+                    WHERE t.TABLE_SCHEMA = s.TABLE_SCHEMA AND t.TABLE_NAME = s.TABLE_NAME
+                      AND t.INDEX_NAME = s.INDEX_NAME AND t.COLUMN_NAME = 'tenant_id')
+            GROUP BY s.TABLE_NAME, s.INDEX_NAME
+            """);
+        _out.WriteLine($"不含 tenant_id 的唯一索引:{(globallyUniqueIdx.Count == 0 ? "无" : string.Join(",", globallyUniqueIdx))}");
+        Assert.Empty(globallyUniqueIdx);
+
+        // ItemNum 已经跨租户重名 —— 这条是「不能只按业务键关联」的现网证据
+        Assert.True(dupItem > 0, "ItemMaster.ItemNum 若已全局唯一,需重新评估本组结论");
+    }
+
+    // ===================== §17 回归:008 主链未被本批破坏 =====================
+
+    [Fact]
+    public async Task Regression_LabelQueryTenantSplit_Unchanged()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var total = await db!.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint");
+        var nullTenant = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint WHERE tenant_id IS NULL");
+        var uat = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T",
+            new List<SugarParameter> { new("@T", TenantA) });
+        var aidop = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T",
+            new List<SugarParameter> { new("@T", TenantB) });
+
+        _out.WriteLine($"MissedPrint total={total} null={nullTenant} UAT={uat} 797={aidop}");
+        Assert.Equal(0, nullTenant);
+        Assert.Equal(total, uat + aidop);
+    }
+}

+ 243 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/ProcurementExecution/SupplierTenantSecurityGuardTests.cs

@@ -0,0 +1,243 @@
+using System.Reflection;
+using System.Text.RegularExpressions;
+using Microsoft.AspNetCore.Authorization;
+using Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
+using Admin.NET.Plugin.AiDOP.ProcurementExecution;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S5.ProcurementExecution;
+
+/// <summary>
+/// S5 供应商链 P0 租户安全守卫(§14 A/B/C/D/E/F 的静态部分)。
+/// <para>
+/// 纯反射 + 源码文本断言,不连库、不起服务、不执行任何写路径,普通 CI 也会执行(无 AIDOP_IT 门禁)。
+/// 数据面断言见 <c>SupplierTenantSecurityDataTests</c>(需 AIDOP_IT=1,全程只读)。
+/// </para>
+/// <para>
+/// 背景(实测):<c>SupplierDeliveryManagementService</c> 与 <c>SupplierShortageKanbanService</c>
+/// 原为类级 <c>[AllowAnonymous]</c> + <c>AidopTenantHelper.Resolve(App.HttpContext)</c>
+/// (解析链含 query 参数 tenantId 与 DefaultTenantId 兜底)。匿名 GET
+/// <c>supplier-delivery/list?tenantId=&lt;任意租户&gt;</c> 返回该租户业务数据(797→94 条、UAT→7 条);
+/// 而 <c>supplier-shortage-kanban/refresh</c> 的两条 DELETE 当时不带 WHERE,会清空全部租户的欠料汇总。
+/// </para>
+/// </summary>
+public class SupplierTenantSecurityGuardTests
+{
+    private static readonly string PluginRoot =
+        Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+
+    private const string DeliverySrc = "ProcurementExecution/SupplierDeliveryManagementService.cs";
+    private const string ShortageSrc = "ProcurementExecution/SupplierShortageKanbanService.cs";
+    private const string PushSrc = "DataPlatform/Wms/PurOrdWmsPushService.cs";
+
+    private static string ReadSource(string relativePath)
+    {
+        var full = Path.Combine(PluginRoot, relativePath);
+        Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
+        return File.ReadAllText(full);
+    }
+
+    /// <summary>只取可执行代码行:类注释里为留档会提到旧写法的名字,不应算违规。</summary>
+    private static List<string> CodeLines(string relativePath) =>
+        ReadSource(relativePath)
+            .Split('\n')
+            .Select(l => l.Trim())
+            .Where(l => !l.StartsWith("///", StringComparison.Ordinal) && !l.StartsWith("//", StringComparison.Ordinal))
+            .ToList();
+
+    // ===================== A / B:认证与 Tenant 来源 =====================
+
+    [Theory]
+    [InlineData(typeof(SupplierDeliveryManagementService))]
+    [InlineData(typeof(SupplierShortageKanbanService))]
+    [InlineData(typeof(PurOrdWmsPushService))]
+    public void Service_MustNotBeMarkedAllowAnonymous(Type serviceType)
+    {
+        Assert.Null(serviceType.GetCustomAttribute<AllowAnonymousAttribute>(inherit: true));
+    }
+
+    [Theory]
+    [InlineData(typeof(SupplierDeliveryManagementService))]
+    [InlineData(typeof(SupplierShortageKanbanService))]
+    [InlineData(typeof(PurOrdWmsPushService))]
+    public void NoEndpoint_MayBeMarkedAllowAnonymous(Type serviceType)
+    {
+        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 参数 tenantId 且有 DefaultTenantId 兜底
+    /// (其自身注释即写明不得用于业务读写),三个服务必须一律走 AidopTenantScope.ResolveOrThrow。
+    /// </summary>
+    [Theory]
+    [InlineData(DeliverySrc)]
+    [InlineData(ShortageSrc)]
+    [InlineData(PushSrc)]
+    public void Service_MustNotUseUnsafeTenantHelper(string relativePath)
+    {
+        var offenders = CodeLines(relativePath)
+            .Where(l => l.Contains("AidopTenantHelper", StringComparison.Ordinal))
+            .ToList();
+        Assert.True(offenders.Count == 0, $"{relativePath} 仍在用不安全的租户解析:{string.Join(" | ", offenders)}");
+    }
+
+    /// <summary>每个公开端点方法体内都必须出现可信租户解析,不允许有"忘了加"的端点。</summary>
+    [Theory]
+    [InlineData(DeliverySrc, 5)]
+    [InlineData(ShortageSrc, 4)]
+    public void EveryEndpoint_ResolvesTenantFromTrustedContext(string relativePath, int expectedCount)
+    {
+        var count = CodeLines(relativePath)
+            .Count(l => l.Contains("AidopTenantScope.ResolveOrThrow(_userManager)", StringComparison.Ordinal));
+        Assert.Equal(expectedCount, count);
+    }
+
+    /// <summary>
+    /// 请求体里的 TenantId 只能当断言用,不得据其切租户。
+    /// </summary>
+    [Fact]
+    public void PurOrdWmsPush_MustDeriveTenantFromJwt_NotFromRequestBody()
+    {
+        var lines = CodeLines(PushSrc);
+        Assert.Contains(lines, l => l.Contains("var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);", StringComparison.Ordinal));
+        Assert.Contains(lines, l => l.Contains("input.TenantId != tenantId", StringComparison.Ordinal));
+        // 不得再把请求体的 TenantId 直接传进业务链
+        Assert.DoesNotContain(lines, l => l.Contains("EnqueueShipmentChainAsync(input.TenantId", StringComparison.Ordinal));
+        Assert.DoesNotContain(lines, l => l.Contains("EnqueuePurchaseOrderAsync(input.TenantId", StringComparison.Ordinal));
+    }
+
+    // ===================== C:DELETE tenant scope =====================
+
+    /// <summary>
+    /// Refresh 里绝不允许出现无 WHERE 的整表 DELETE / TRUNCATE:
+    /// 原写法 `DELETE FROM WorkOrdDetailTotal` 会清空全部租户,随后只重建被指定的那个租户。
+    /// </summary>
+    [Fact]
+    public void ShortageRefresh_MustNotContainUnscopedDeleteOrTruncate()
+    {
+        var src = ReadSource(ShortageSrc);
+
+        foreach (var table in new[] { "WorkOrdDetailTotal", "WorkOrdDetailTotalKB" })
+        {
+            Assert.Contains($"DELETE FROM {table} WHERE tenant_id = @TenantId", src, StringComparison.Ordinal);
+        }
+
+        // 注释里会留档旧写法的关键词,因此只扫可执行代码
+        var code = string.Join("\n", CodeLines(ShortageSrc));
+
+        // 任何 "DELETE FROM <表>" 后面若没跟 WHERE 就是整表删
+        // \b 必不可少:没有它,\w+ 会回溯成更短的前缀让负向先行断言假性通过
+        var unscoped = Regex.Matches(code, @"DELETE\s+FROM\s+\w+\b(?!\s+WHERE)", RegexOptions.IgnoreCase)
+            .Select(m => m.Value.Trim())
+            .ToList();
+        Assert.True(unscoped.Count == 0, $"存在无 WHERE 的整表 DELETE:{string.Join(" | ", unscoped)}");
+
+        // TRUNCATE 天然无法带条件,一律不允许
+        Assert.DoesNotContain("TRUNCATE", code, StringComparison.OrdinalIgnoreCase);
+        // DROP 会打断并发的另一个租户
+        Assert.DoesNotContain("DROP TABLE", code, StringComparison.OrdinalIgnoreCase);
+    }
+
+    // ===================== D:UPDATE tenant scope =====================
+
+    /// <summary>
+    /// 每条 `UPDATE WorkOrdDetailTotal*` 的目标侧都必须带 tenant_id;
+    /// 只靠 ItemNum / WorkOrd / OrdDate 关联是不够的(这些业务键无全局唯一约束)。
+    /// </summary>
+    [Fact]
+    public void ShortageRefresh_EveryUpdate_TargetsOwnTenantOnly()
+    {
+        var src = ReadSource(ShortageSrc);
+        var offenders = new List<string>();
+
+        foreach (Match m in Regex.Matches(src, @"UPDATE\s+WorkOrdDetailTotal(KB)?\b", RegexOptions.IgnoreCase))
+        {
+            // 取该 UPDATE 语句块:到下一个 `"""` 结束符为止
+            var end = src.IndexOf("\"\"\"", m.Index, StringComparison.Ordinal);
+            var stmt = end > m.Index ? src[m.Index..end] : src[m.Index..];
+            var hasTenant = stmt.Contains("tenant_id = @TenantId", StringComparison.Ordinal)
+                            || stmt.Contains("tenant_id = p0.tenant_id", StringComparison.Ordinal);
+            if (!hasTenant)
+                offenders.Add(stmt.Split('\n')[0].Trim() + " …");
+        }
+
+        Assert.True(offenders.Count == 0, $"以下 UPDATE 目标侧缺租户条件:{string.Join(" | ", offenders)}");
+    }
+
+    // ===================== E:MissedPrint 聚合 tenant scope =====================
+
+    /// <summary>
+    /// 在检数量 zjsl 只能统计当前租户的 MissedPrint。
+    /// 同一语句内的 s0(scm_shdzb)/ Do0(PurOrdRctDetail)本就带 tenant_id = p0.tenant_id,
+    /// 独 i0(MissedPrint)漏掉 —— 本用例把这条口径钉死。
+    /// </summary>
+    [Fact]
+    public void ShortageRefresh_MissedPrintAggregate_MustBeTenantScoped()
+    {
+        var src = ReadSource(ShortageSrc);
+        var i = src.IndexOf("FROM MissedPrint m", StringComparison.Ordinal);
+        Assert.True(i >= 0, "找不到 MissedPrint 聚合锚点");
+        var end = src.IndexOf(") i0 ON TRUE", i, StringComparison.Ordinal);
+        Assert.True(end > i, "找不到 i0 LATERAL 结束锚点");
+        var stmt = src[i..end];
+
+        Assert.Contains("m.tenant_id = p0.tenant_id", stmt, StringComparison.Ordinal);
+        // 且 p0 侧必须被钉到当前租户,否则上面那条等式没有意义
+        Assert.Contains("p0.tenant_id = @TenantId", src, StringComparison.Ordinal);
+    }
+
+    // ===================== F:PurOrdWmsPush 的 MissedPrint 取数 =====================
+
+    /// <summary>
+    /// 送货单号 ShipperNbr 在库中无唯一索引,不能只靠它收敛箱码;
+    /// 该方法本就有可信的 tenantId 传入(TryEnqueueShipmentChainSafeAsync(tenantId, shddh)),必须用上。
+    /// </summary>
+    [Fact]
+    public void PurOrdWmsPush_BarcodeSelect_MustBeTenantScoped()
+    {
+        var src = ReadSource(PushSrc);
+        var i = src.IndexOf("FROM MissedPrint", StringComparison.Ordinal);
+        Assert.True(i >= 0, "找不到 MissedPrint 取数锚点");
+        var end = src.IndexOf("ORDER BY ShipperLine", i, StringComparison.Ordinal);
+        Assert.True(end > i, "找不到结束锚点");
+        var stmt = src[i..end];
+
+        Assert.Contains("tenant_id = @TenantId", stmt, StringComparison.Ordinal);
+        // 原有业务语义不得被削弱
+        Assert.Contains("IFNULL(Status, '') = 'U'", stmt, StringComparison.Ordinal);
+        Assert.Contains("NOT LIKE '作废%'", stmt, StringComparison.Ordinal);
+    }
+
+    // ===================== §9 Domain resolver 归口 =====================
+
+    /// <summary>
+    /// 欠料看板不得再用「全局取第一条 CompanyCode」+ 硬编码回落,必须按当前租户解析且 fail closed。
+    /// </summary>
+    [Fact]
+    public void ShortageRefresh_DomainResolver_MustBeTenantScopedAndFailClosed()
+    {
+        var lines = CodeLines(ShortageSrc);
+        Assert.Contains(lines, l => l.Contains("_domainTenant.ResolveDomainAsync(", StringComparison.Ordinal));
+
+        var offenders = lines
+            .Where(l => l.Contains("FldName='SystemConfig'", StringComparison.Ordinal)
+                        || l.Contains("\"1001\"", StringComparison.Ordinal)
+                        || l.Contains("\"8010\"", StringComparison.Ordinal))
+            .ToList();
+        Assert.True(offenders.Count == 0, $"仍存在 global-first / 硬编码 Domain 回落:{string.Join(" | ", offenders)}");
+    }
+
+    // ===================== 回归:上一批已收口的邻居 =====================
+
+    [Theory]
+    [InlineData(typeof(SupplierShipmentService))]
+    public void PreviouslyClosedSiblings_MustStayClosed(Type serviceType)
+    {
+        Assert.Null(serviceType.GetCustomAttribute<AllowAnonymousAttribute>(inherit: true));
+    }
+}

+ 13 - 15
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/Wms/PurOrdWmsPushService.cs

@@ -15,7 +15,6 @@ namespace Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
 /// </summary>
 [ApiDescriptionSettings(Order = 327, Description = "采购链推送 WMS")]
 [Route("api/aidop/wms-purord")]
-[AllowAnonymous]
 [NonUnify]
 public class PurOrdWmsPushService : IDynamicApiController, ITransient
 {
@@ -87,24 +86,21 @@ public class PurOrdWmsPushService : IDynamicApiController, ITransient
         if (input == null) throw Oops.Oh("请求体不能为空");
         if (string.IsNullOrWhiteSpace(input.PurOrd) && string.IsNullOrWhiteSpace(input.Shddh))
             throw Oops.Oh("采购单号与送货单号至少给一个");
-        if (input.TenantId <= 0) throw Oops.Oh("租户号无效,请指定有效的 TenantId");
-
-        var jwtTenantId = _userManager.TenantId;
-        if (jwtTenantId > 0 && input.TenantId != jwtTenantId)
+        // 租户只认认证后 JWT;请求体里的 TenantId 降级为断言,不一致直接拒绝,绝不据其切租户
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
+        if (input.TenantId > 0 && input.TenantId != tenantId)
             throw Oops.Oh("请求租户与当前登录租户不一致");
-        if (jwtTenantId <= 0)
-        {
-            var clientIp = _httpContextAccessor.HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "unknown";
-            _logger.LogInformation(
-                "[PurOrdWmsPush] 无登录态推送 audit ip={ClientIp} purOrd={PurOrd} shddh={Shddh} tenantId={TenantId}",
-                clientIp, input.PurOrd, input.Shddh, input.TenantId);
-        }
+
+        var clientIp = _httpContextAccessor.HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "unknown";
+        _logger.LogInformation(
+            "[PurOrdWmsPush] 推送 audit ip={ClientIp} account={Account} tenantId={TenantId} purOrd={PurOrd} shddh={Shddh}",
+            clientIp, _userManager.Account, tenantId, input.PurOrd, input.Shddh);
 
         var result = new PurOrdPushResult { Ok = true };
         if (!string.IsNullOrWhiteSpace(input.Shddh))
-            await EnqueueShipmentChainAsync(input.TenantId, input.Shddh.Trim(), result, ct);
+            await EnqueueShipmentChainAsync(tenantId, input.Shddh.Trim(), result, ct);
         else
-            await EnqueuePurchaseOrderAsync(input.TenantId, input.PurOrd!.Trim(), result, ct);
+            await EnqueuePurchaseOrderAsync(tenantId, input.PurOrd!.Trim(), result, ct);
 
         _wake.Pulse();
         result.Message = $"已入队 {result.Enqueued} 条(采购单 {result.PurOrdCount}/明细 {result.DetailCount}/"
@@ -760,11 +756,13 @@ public class PurOrdWmsPushService : IDynamicApiController, ITransient
                 CreateUser, CreateTime, UpdateUser, UpdateTime
             FROM MissedPrint
             WHERE ShipperNbr = @Shddh
+              AND tenant_id = @TenantId
               AND IFNULL(Status, '') = 'U'
               AND IFNULL(PurOrd, '') NOT LIKE '作废%'
             ORDER BY ShipperLine, Carton
             """,
-            new SugarParameter("@Shddh", shddh));
+            new SugarParameter("@Shddh", shddh),
+            new SugarParameter("@TenantId", tenantId));
 
         var now = DateTime.Now;
         var watchKeys = new HashSet<(string Domain, string PurOrd)>();

+ 15 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/SupplierDeliveryManagementService.cs

@@ -6,10 +6,19 @@ 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>,
+/// 其自身注释即写明「不得用于业务数据的查询与写入」。两者叠加的实测后果:匿名(无 Token、无 Cookie)
+/// <c>GET /api/ProcurementExecution/supplier-delivery/list?tenantId=&lt;任意租户&gt;</c> 返回该租户业务数据
+/// (797 返回 94 条、UAT 返回 7 条、不传参数则落默认租户);同类的 4 个 HttpPost 写接口走同一条解析链。
+/// </para>
 /// </summary>
 [ApiDescriptionSettings(Order = 320, Description = "S4供应商交货管理")]
 [Route("api/ProcurementExecution")]
-[AllowAnonymous]
 [NonUnify]
 public class SupplierDeliveryManagementService : IDynamicApiController, ITransient
 {
@@ -26,7 +35,7 @@ public class SupplierDeliveryManagementService : IDynamicApiController, ITransie
     [HttpGet("supplier-delivery/list")]
     public async Task<object> GetList([FromQuery] SupplierDeliveryListInput 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>();
         if (!string.IsNullOrWhiteSpace(input.Cgdd))
@@ -66,7 +75,7 @@ public class SupplierDeliveryManagementService : IDynamicApiController, ITransie
     [HttpPost("supplier-delivery/publish")]
     public async Task<object> Publish([FromBody] SupplierDeliveryBatchInput input)
     {
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
         var userId = _userManager.UserId.ToString();
         var userName = _userManager.Account ?? "system";
         var sql = """
@@ -139,7 +148,7 @@ public class SupplierDeliveryManagementService : IDynamicApiController, ITransie
         if (string.IsNullOrWhiteSpace(input.Ids))
             throw Oops.Oh("缺少勾选行ID(ids)");
 
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
         var userId = _userManager.UserId.ToString();
         var userName = _userManager.Account ?? "system";
 
@@ -216,7 +225,7 @@ public class SupplierDeliveryManagementService : IDynamicApiController, ITransie
         if (string.IsNullOrWhiteSpace(input.Jqhf))
             throw Oops.Oh("缺少交期回复日期(jqhf)");
 
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
         var userId = _userManager.UserId.ToString();
         var userName = _userManager.Account ?? "system";
 
@@ -291,7 +300,7 @@ public class SupplierDeliveryManagementService : IDynamicApiController, ITransie
     [HttpPost("supplier-delivery/close")]
     public async Task<object> CloseDelivery([FromBody] DeliveryCloseInput input)
     {
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
 
         // 检查是否已有关联的发货单明细(scm_shdzb.jhdbh = 交货单号)
         var shipmentCount = await _db.Ado.GetIntAsync(

+ 94 - 63
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/SupplierShortageKanbanService.cs

@@ -9,10 +9,25 @@ namespace Admin.NET.Plugin.AiDOP.ProcurementExecution;
 /// S4 供应商欠料看板(只读列表 + 刷新)
 /// 数据表:WorkOrdDetailTotalKB(结果)/ WorkOrdDetailTotal(中间)
 /// 路由:/api/ProcurementExecution/supplier-shortage-kanban/...
+///
+/// 【租户安全边界】租户一律经 <see cref="AidopTenantScope.ResolveOrThrow"/> 从认证后 JWT 解析,
+/// 无有效租户即拒绝,不读前端 tenantId、无默认回退;公司域经
+/// <see cref="SourceDomainTenantResolver"/> 按当前租户解析,解析不出即 fail closed。
+/// <para>
+/// 原实现:类级 <c>[AllowAnonymous]</c> + <c>AidopTenantHelper.Resolve(App.HttpContext)</c>
+/// (解析链含 query 参数 tenantId 与 DefaultTenantId 兜底)+ <c>SELECT Domain ... LIMIT 1</c> 取全局第一条、
+/// 取不到则硬编码回落 <c>'1001'</c>。叠加后果:匿名 POST <c>supplier-shortage-kanban/refresh</c>
+/// 可指定任意租户,而 <c>Refresh</c> 的两条 <c>DELETE</c> 当时不带任何 WHERE,会清空**全部租户**的
+/// WorkOrdDetailTotal / WorkOrdDetailTotalKB,随后只重建被指定的那个租户。
+/// </para>
+/// <para>
+/// <b>Refresh 的写 SQL 一律按租户收口</b>:DELETE / UPDATE 的目标侧都带 <c>tenant_id = @TenantId</c>,
+/// 派生量的来源侧(MissedPrint 在检数、ItemMaster 物料主数据等)同样按租户限定 ——
+/// 业务键 ItemNum / WorkOrd / PurOrd 在库中都没有全局唯一约束,仅按业务键关联会跨租户串数。
+/// </para>
 /// </summary>
 [ApiDescriptionSettings(Order = 323, Description = "S4供应商欠料看板")]
 [Route("api/ProcurementExecution")]
-[AllowAnonymous]
 [NonUnify]
 public class SupplierShortageKanbanService : IDynamicApiController, ITransient
 {
@@ -21,15 +36,18 @@ public class SupplierShortageKanbanService : IDynamicApiController, ITransient
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
     private readonly MdpSourceScopeFactory _scopeFactory;
+    private readonly SourceDomainTenantResolver _domainTenant;
 
     public SupplierShortageKanbanService(
         ISqlSugarClient db,
         UserManager userManager,
-        MdpSourceScopeFactory scopeFactory)
+        MdpSourceScopeFactory scopeFactory,
+        SourceDomainTenantResolver domainTenant)
     {
         _db = db;
         _userManager = userManager;
         _scopeFactory = scopeFactory;
+        _domainTenant = domainTenant;
     }
 
     private static string NormalizeOrderBy(string field, string order)
@@ -73,22 +91,10 @@ public class SupplierShortageKanbanService : IDynamicApiController, ITransient
         var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
         var offset = (page - 1) * pageSize;
 
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
-        var cond = new List<string> { "1=1" };
-        var pars = new List<SugarParameter>();
-
-        var hasTenantColumn = await _db.Ado.GetIntAsync("""
-            SELECT COUNT(1)
-            FROM information_schema.COLUMNS
-            WHERE TABLE_SCHEMA = DATABASE()
-              AND TABLE_NAME = 'WorkOrdDetailTotalKB'
-              AND COLUMN_NAME = 'tenant_id'
-            """) > 0;
-        if (hasTenantColumn)
-        {
-            cond.Add("t.tenant_id = @TenantId");
-            pars.Add(new("@TenantId", tenantId));
-        }
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
+        // 租户过滤是硬条件,不做运行期列探测:列若缺失应当报错,而不是静默退化成读全部租户
+        var cond = new List<string> { "t.tenant_id = @TenantId" };
+        var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
 
         if (!string.IsNullOrWhiteSpace(input.ItemNum))
         {
@@ -146,16 +152,17 @@ public class SupplierShortageKanbanService : IDynamicApiController, ITransient
         var sw = System.Diagnostics.Stopwatch.StartNew();
         try
         {
-            var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+            var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
 
-            // ── Step 0: 取公司域 ──
-            var domain = await _db.Ado.GetStringAsync(
-                "SELECT Domain FROM GeneralizedCodeMaster WHERE FldName='SystemConfig' AND Val='CompanyCode' LIMIT 1");
-            domain = string.IsNullOrWhiteSpace(domain) ? "1001" : domain.Trim();
+            // ── Step 0: 取公司域(按当前租户解析,取不到即 fail closed,不回落 1001/8010)──
+            var domain = await _domainTenant.ResolveDomainAsync(SqlServerSourceCode, tenantId);
+            var tenantPar = () => new List<SugarParameter> { new("@TenantId", tenantId) };
 
-            // ── 清空中间表与结果表 ──
-            await _db.Ado.ExecuteCommandAsync("DELETE FROM WorkOrdDetailTotal");
-            await _db.Ado.ExecuteCommandAsync("DELETE FROM WorkOrdDetailTotalKB");
+            // ── 清空中间表与结果表(**仅本租户**:原写法无 WHERE,会清空全部租户)──
+            await _db.Ado.ExecuteCommandAsync(
+                "DELETE FROM WorkOrdDetailTotal WHERE tenant_id = @TenantId", tenantPar());
+            await _db.Ado.ExecuteCommandAsync(
+                "DELETE FROM WorkOrdDetailTotalKB WHERE tenant_id = @TenantId", tenantPar());
 
             // ── Step 1: 基础数据写入 WorkOrdDetailTotal ──
             await _db.Ado.ExecuteCommandAsync("""
@@ -203,8 +210,8 @@ public class SupplierShortageKanbanService : IDynamicApiController, ITransient
                               OR Status = 'p'
                           )
                     ) a
-                    LEFT JOIN mes_morder b ON a.workord = b.morder_no
-                    LEFT JOIN ItemMaster c ON b.product_code = c.ItemNum AND (c.tenant_id = @TenantId OR @TenantId IS NULL)
+                    LEFT JOIN mes_morder b ON a.workord = b.morder_no AND b.tenant_id = @TenantId
+                    LEFT JOIN ItemMaster c ON b.product_code = c.ItemNum AND c.tenant_id = @TenantId
                     LEFT JOIN WorkOrdDetail d ON a.WorkOrd = d.WorkOrd AND d.tenant_id = @TenantId
                     LEFT JOIN (
                         SELECT a0.number,
@@ -243,10 +250,13 @@ public class SupplierShortageKanbanService : IDynamicApiController, ITransient
                                 WHERE po_bill = p0.PurOrd AND po_billline = p0.Line
                                   AND tenant_id = p0.tenant_id
                             ) s0 ON TRUE
+                            -- 在检数量只能统计本租户的标签:(PurOrd, PurLine) 无全局唯一约束,
+                            -- 缺 tenant 条件会把别租户的 MissedPrint 加进本租户的 zjsl(与 s0/Do0 口径对齐)
                             LEFT JOIN LATERAL (
                                 SELECT SUM(Qty) AS zjsl
                                 FROM MissedPrint m
                                 WHERE m.Status = 'I' AND m.PurOrd = p0.PurOrd AND m.PurLine = p0.Line
+                                  AND m.tenant_id = p0.tenant_id
                             ) i0 ON TRUE
                             LEFT JOIN LATERAL (
                                 SELECT SUM(t.QtyReturn) AS thsl
@@ -262,7 +272,8 @@ public class SupplierShortageKanbanService : IDynamicApiController, ITransient
                 ) b ON a.ItemNum = b.ItemNum
                 SET a.ztsl = IFNULL(b.ztsl, 0),
                     a.zjsl = IFNULL(b.zjsl, 0)
-                """, new List<SugarParameter> { new("@TenantId", tenantId) });
+                WHERE a.tenant_id = @TenantId
+                """, tenantPar());
 
             // ── Step 4: 承诺 jqsl ──
             await _db.Ado.ExecuteCommandAsync("""
@@ -278,9 +289,11 @@ public class SupplierShortageKanbanService : IDynamicApiController, ITransient
                         LEFT JOIN (
                             SELECT SUM(sh_delivery_quantity) AS sh_delivery_quantity, jhdbh
                             FROM scm_shdzb
+                            WHERE tenant_id = @TenantId
                             GROUP BY jhdbh
                         ) c ON ds.dsnum = c.jhdbh
                         WHERE jq.flag = 0
+                          AND jq.tenant_id = @TenantId
                           AND IFNULL(c.sh_delivery_quantity, 0) < ds.schedqty
                           AND ds.status = 'P'
                           AND ds.schedqty - ds.sentqty > 0
@@ -289,34 +302,39 @@ public class SupplierShortageKanbanService : IDynamicApiController, ITransient
                     ) jqview
                 ) b ON a.ItemNum = b.ItemNum AND DATE(a.OrdDate) = b.jqhf
                 SET a.jqsl = CAST(IFNULL(b.jqsl, 0) AS DECIMAL(18,6))
-                """);
+                WHERE a.tenant_id = @TenantId
+                """, tenantPar());
 
             // ── Step 5: 可用 / 欠料(按天数偏移累加)──
             await _db.Ado.ExecuteCommandAsync("""
                 UPDATE WorkOrdDetailTotal
                 SET available = IFNULL(locStock, 0),
                     shortage  = IFNULL(locStock, 0) - IFNULL(ljsl, 0)
-                WHERE DATE(OrdDate) = DATE(NOW()) OR DATE(OrdDate) = DATE(DATE_ADD(NOW(), INTERVAL 1 DAY))
-                """);
+                WHERE tenant_id = @TenantId
+                  AND (DATE(OrdDate) = DATE(NOW()) OR DATE(OrdDate) = DATE(DATE_ADD(NOW(), INTERVAL 1 DAY)))
+                """, tenantPar());
             await _db.Ado.ExecuteCommandAsync("""
                 UPDATE WorkOrdDetailTotal
                 SET available = IFNULL(locStock, 0) + IFNULL(zjsl, 0),
                     shortage  = IFNULL(locStock, 0) + IFNULL(zjsl, 0) - IFNULL(ljsl, 0)
-                WHERE DATE(OrdDate) = DATE(DATE_ADD(NOW(), INTERVAL 2 DAY))
-                """);
+                WHERE tenant_id = @TenantId
+                  AND DATE(OrdDate) = DATE(DATE_ADD(NOW(), INTERVAL 2 DAY))
+                """, tenantPar());
             await _db.Ado.ExecuteCommandAsync("""
                 UPDATE WorkOrdDetailTotal
                 SET available = IFNULL(locStock, 0) + IFNULL(zjsl, 0) + IFNULL(ztsl, 0),
                     shortage  = IFNULL(locStock, 0) + IFNULL(zjsl, 0) + IFNULL(ztsl, 0) - IFNULL(ljsl, 0)
-                WHERE DATE(OrdDate) = DATE(DATE_ADD(NOW(), INTERVAL 3 DAY))
-                """);
+                WHERE tenant_id = @TenantId
+                  AND DATE(OrdDate) = DATE(DATE_ADD(NOW(), INTERVAL 3 DAY))
+                """, tenantPar());
             await _db.Ado.ExecuteCommandAsync("""
                 UPDATE WorkOrdDetailTotal
                 SET available = IFNULL(locStock, 0) + IFNULL(zjsl, 0) + IFNULL(ztsl, 0) + IFNULL(jqsl, 0),
                     shortage  = IFNULL(locStock, 0) + IFNULL(zjsl, 0) + IFNULL(ztsl, 0) + IFNULL(jqsl, 0) - IFNULL(ljsl, 0)
-                WHERE DATE(OrdDate) >= DATE(DATE_ADD(NOW(), INTERVAL 4 DAY))
+                WHERE tenant_id = @TenantId
+                  AND DATE(OrdDate) >= DATE(DATE_ADD(NOW(), INTERVAL 4 DAY))
                   AND DATE(OrdDate) <= DATE(DATE_ADD(NOW(), INTERVAL 14 DAY))
-                """);
+                """, tenantPar());
 
             // ── Step 6: 透视写入 KB 表 ──
             await _db.Ado.ExecuteCommandAsync("""
@@ -349,11 +367,12 @@ public class SupplierShortageKanbanService : IDynamicApiController, ITransient
                            a.supplier_number, a.supplier_name, a.ItemNum,
                            b.Descr, b.Descr1, a.shortage
                     FROM WorkOrdDetailTotal a
-                    LEFT JOIN ItemMaster b ON a.ItemNum = b.ItemNum
-                    WHERE a.shortage < 0
+                    -- ItemMaster 的 ItemNum 跨租户重名、同 Domain 内亦有重复,不限租户会放大透视行数
+                    LEFT JOIN ItemMaster b ON a.ItemNum = b.ItemNum AND b.tenant_id = @TenantId
+                    WHERE a.tenant_id = @TenantId AND a.shortage < 0
                 ) src
                 GROUP BY supplier_number, supplier_name, ItemNum, Descr, Descr1
-                """, new List<SugarParameter> { new("@TenantId", tenantId) });
+                """, tenantPar());
 
             return new { ok = true, elapsedMs = sw.ElapsedMilliseconds };
         }
@@ -370,7 +389,8 @@ public class SupplierShortageKanbanService : IDynamicApiController, ITransient
     {
         // 读取 MRPLocationRange 配置(逗号分隔的库位列表)
         var locRange = await _db.Ado.GetStringAsync(
-            "SELECT Val FROM ConfigurationItem WHERE FldName='MRPLocationRange' LIMIT 1");
+            "SELECT Val FROM ConfigurationItem WHERE FldName='MRPLocationRange' AND tenant_id = @TenantId LIMIT 1",
+            new List<SugarParameter> { new("@TenantId", tenantId) });
         if (string.IsNullOrWhiteSpace(locRange))
             return;
 
@@ -403,10 +423,20 @@ public class SupplierShortageKanbanService : IDynamicApiController, ITransient
         if (rows.Count == 0)
             return;
 
-        // 批量写入临时表,再单次 JOIN UPDATE(比逐行 UPDATE 快得多)
+        // 批量写入中间表,再单次 JOIN UPDATE(比逐行 UPDATE 快得多)。
+        // 中间表按租户分区:原写法 TRUNCATE + DROP 是全局的,两个租户并发 Refresh 会互相清表。
         await _db.Ado.ExecuteCommandAsync(
-            "CREATE TABLE IF NOT EXISTS _tmp_loc_stock (ItemNum VARCHAR(50) PRIMARY KEY, locStock DECIMAL(18,6))");
-        await _db.Ado.ExecuteCommandAsync("TRUNCATE TABLE _tmp_loc_stock");
+            """
+            CREATE TABLE IF NOT EXISTS _tmp_loc_stock (
+                tenant_id BIGINT NOT NULL,
+                ItemNum VARCHAR(50) NOT NULL,
+                locStock DECIMAL(18,6),
+                PRIMARY KEY (tenant_id, ItemNum)
+            )
+            """);
+        await _db.Ado.ExecuteCommandAsync(
+            "DELETE FROM _tmp_loc_stock WHERE tenant_id = @TenantId",
+            new List<SugarParameter> { new("@TenantId", tenantId) });
 
         var insertPars = new List<SugarParameter>();
         var valuesClauses = new List<string>();
@@ -414,7 +444,7 @@ public class SupplierShortageKanbanService : IDynamicApiController, ITransient
         {
             var r = rows[i];
             if (string.IsNullOrWhiteSpace(r.ItemNum)) continue;
-            valuesClauses.Add($"(@item{i}, @stock{i})");
+            valuesClauses.Add($"(@TenantId, @item{i}, @stock{i})");
             insertPars.Add(new SugarParameter($"@item{i}", r.ItemNum));
             insertPars.Add(new SugarParameter($"@stock{i}", r.LocStock));
         }
@@ -430,21 +460,26 @@ public class SupplierShortageKanbanService : IDynamicApiController, ITransient
             var batchPars = insertPars.GetRange(offset, batchEnd - offset);
             var batchValues = new List<string>();
             for (var j = offset; j < batchEnd; j += 2)
-                batchValues.Add($"({insertPars[j].ParameterName}, {insertPars[j + 1].ParameterName})");
+                batchValues.Add($"(@TenantId, {insertPars[j].ParameterName}, {insertPars[j + 1].ParameterName})");
+            batchPars.Add(new SugarParameter("@TenantId", tenantId));
 
             await _db.Ado.ExecuteCommandAsync(
-                $"INSERT INTO _tmp_loc_stock (ItemNum, locStock) VALUES {string.Join(",", batchValues)}",
+                $"INSERT INTO _tmp_loc_stock (tenant_id, ItemNum, locStock) VALUES {string.Join(",", batchValues)}",
                 batchPars);
         }
 
         // 单次 JOIN UPDATE
         await _db.Ado.ExecuteCommandAsync("""
             UPDATE WorkOrdDetailTotal a
-            INNER JOIN _tmp_loc_stock t ON a.ItemNum = t.ItemNum
+            INNER JOIN _tmp_loc_stock t ON a.ItemNum = t.ItemNum AND t.tenant_id = a.tenant_id
             SET a.locStock = t.locStock
-            """);
+            WHERE a.tenant_id = @TenantId
+            """, new List<SugarParameter> { new("@TenantId", tenantId) });
 
-        await _db.Ado.ExecuteCommandAsync("DROP TABLE IF EXISTS _tmp_loc_stock");
+        // 只清本租户的中间数据;不再 DROP,避免打断并发的另一个租户
+        await _db.Ado.ExecuteCommandAsync(
+            "DELETE FROM _tmp_loc_stock WHERE tenant_id = @TenantId",
+            new List<SugarParameter> { new("@TenantId", tenantId) });
     }
 
     private sealed class LocStockRow
@@ -460,19 +495,13 @@ public class SupplierShortageKanbanService : IDynamicApiController, ITransient
     [HttpPost("supplier-shortage-kanban/create-todo")]
     public async Task<object> CreateTodo()
     {
-        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
         var userId = _userManager?.UserId ?? 0;
 
-        // 查询当前全量欠料数据作为快照
-        var hasTenantColumn = await _db.Ado.GetIntAsync("""
-            SELECT COUNT(1) FROM information_schema.COLUMNS
-            WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'WorkOrdDetailTotalKB' AND COLUMN_NAME = 'tenant_id'
-            """) > 0;
-
-        var where = hasTenantColumn ? "WHERE tenant_id = @TenantId" : "";
-        var pars = hasTenantColumn ? new[] { new SugarParameter("@TenantId", tenantId) } : Array.Empty<SugarParameter>();
+        // 查询本租户欠料数据作为快照(租户过滤是硬条件,不做运行期列探测)
+        var pars = new[] { new SugarParameter("@TenantId", tenantId) };
 
-        var snapshotSql = $"""
+        var snapshotSql = """
             SELECT
               IFNULL(supplier_number, '') AS supplier_number,
               IFNULL(supplier_name, '')   AS supplier_name,
@@ -484,7 +513,8 @@ public class SupplierShortageKanbanService : IDynamicApiController, ITransient
               IFNULL(D6, 0) AS D6, IFNULL(D7, 0) AS D7, IFNULL(D8, 0) AS D8,
               IFNULL(D9, 0) AS D9, IFNULL(D10, 0) AS D10, IFNULL(D11, 0) AS D11,
               IFNULL(D12, 0) AS D12, IFNULL(D13, 0) AS D13, IFNULL(D14, 0) AS D14
-            FROM WorkOrdDetailTotalKB {where}
+            FROM WorkOrdDetailTotalKB
+            WHERE tenant_id = @TenantId
             ORDER BY IFNULL(D0, 0) DESC
             """;
 
@@ -512,8 +542,9 @@ public class SupplierShortageKanbanService : IDynamicApiController, ITransient
     [HttpGet("supplier-shortage-kanban/todo-detail/{id:long}")]
     public async Task<object> GetTodoDetail(long id)
     {
+        var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
         var todo = await _db.Queryable<AdoMaterialShortageTodo>()
-            .Where(t => t.Id == id)
+            .Where(t => t.Id == id && t.TenantId == tenantId)
             .FirstAsync();
 
         if (todo == null) return new { code = 404, message = "待办不存在" };