Ver Fonte

fix(s5): finalize stocktake result tenant scope

FUNC-S5-013 StocktakeResultService 是 MissedPrint 主线最后一个残余点。
它的租户边界虽已有 m.tenant_id=@TenantId,但仍叠加 m.Domain=@Domain,
且 ItemMaster 按 Domain 关联。当前 IsTag=1 全库 0 行,两处缺陷被数据状态掩盖,
故本批判据取自放宽 IsTag 后的候选集模拟(不造数据、不改 IsTag 业务语义)。

MissedPrint 去 Domain
- 实测:UAT tenant-only 得 48 行,tenant+Domain('8010') 得 0 行;
  797 则 104 → 104 无影响 —— 是单向误杀本租户数据。
  成因是两套 Domain 口径不一致:写路径(SupplierShipmentService)只查
  GeneralizedCodeMaster,UAT 无该行故写入空串;读路径 SourceDomainTenantResolver
  会回落 LocationMaster,给出 '8010'。
- 且 MissedPrint 中每个租户只有单一 Domain 值,Domain 对租户内零过滤价值,
  要么整租户命中要么整租户清零,不构成任何业务筛选。
- 结论:Domain 既无过滤价值又会误杀,删除,由 tenant_id 完全取代。

ItemMaster JOIN 改 tenant-aware
- m.Domain=i.Domain AND m.ItemNum=i.ItemNum
  → i.tenant_id=m.tenant_id AND i.ItemNum=m.ItemNum
- 基数:(tenant_id, ItemNum) 重复组 0 → JOIN 恒 1:1,SELECT 行数不超过 COUNT;
  (Domain, ItemNum) 重复组 391(8010 内 143 / 2410 内 248,全部是跨租户重名)。
- 更正一处此前的推断:**今日这 152 行上旧 JOIN 并未真的放大**(单行最大命中=1),
  放大是潜在风险而非已发生事实;当前 9 个 ItemNum 中已有 2 个落在重复组内,
  只是承载它们的行 Domain 对不上才未触发。
- 今日**已经发生**的错误是另一件:UAT 的 48 行 Domain 为空串,按 Domain 关联命中 0 条,
  物料名称/规格/单位整列为空;按租户关联 152/152 行都能取到(实测 old 104 → new 152)。

Domain Resolver
- 移除后 @Domain 在本服务已无任何用途,随之删除 ResolveDomainOrThrowAsync 与
  SourceDomainTenantResolver 注入。未引入 global-first / 硬编码 / 8010 回落。

验证
- dotnet build --framework net10.0 通过;默认测试套件 1264 passed / 0 failed。
- 新增 7 个用例(5 源码守卫 + 2 数据面):租户过滤存在、不得再用 Domain、
  ItemMaster 关联 tenant-aware、COUNT 与 SELECT 共用同一 whereSql、
  保持只读且 IsTag=1 语义不变;数据面固化「Domain 单向清零 UAT」与
  「租户内 1:1 且覆盖更全」两条判据,全程只读。
- 运行态(chrome-devtools MCP,隔离浏览器上下文,冒烟槽 3/3):
  产物校验 —— 运行中的 DLL 含新 JOIN 文本 1 处、旧 Domain JOIN 与旧 where 各 0 处;
  UATAdminA 013 → 200/total=0,10/100 两档一致,伪造 ?tenantId=797 无效,页面「暂无数据」无报错;
  AIDOPDemo(797) 013 → 200/total=0,伪造 UAT 无效;匿名 013 → 401。

回归
- 008:UAT 48 / 797 104,未变。
- 009:UAT 0 / 797 0,口径未变。
- 010 REMAINS CLOSED:UAT 3231 / 797 6430,未变。
- 全仓复扫:ACTIVE + LOCAL 且未按 tenant_id 收口的 MissedPrint 消费点 = 0。
  仅剩 StocktakeLabelConfirmService(012, DEAD)、IqcReceiptStateMdpSyncService(未接线 DEBT)、
  以及 MdpHotWatch / MdpDbPushExecutor 对远端 165 的查询(远端无租户列,N/A)。
YY968XX há 2 dias atrás
pai
commit
e3c712206f

+ 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.446</AssemblyVersion>
-    <FileVersion>1.0.446</FileVersion>
-    <Version>1.0.446</Version>
+    <AssemblyVersion>1.0.447</AssemblyVersion>
+    <FileVersion>1.0.447</FileVersion>
+    <Version>1.0.447</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 131 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/MissedPrintConsumerScopeTests.cs

@@ -33,6 +33,7 @@ public class MissedPrintConsumerScopeTests
     private const string PendingSrc = "MaterialWarehouse/PendingInspectionService.cs";
     private const string IqcPushSrc = "MaterialWarehouse/IqcWmsResultPushService.cs";
     private const string BalanceSrc = "MaterialWarehouse/InventoryBalanceReader.cs";
+    private const string StocktakeSrc = "MaterialWarehouse/StocktakeResultService.cs";
 
     private static string ReadSource(string relativePath)
     {
@@ -198,6 +199,65 @@ public class MissedPrintConsumerScopeTests
         }
     }
 
+    // ===================== FUNC-S5-013 StocktakeResult =====================
+
+    [Fact]
+    public void F013_MissedPrint_MustFilterByTenantId()
+    {
+        var src = ReadSource(StocktakeSrc);
+        Assert.Contains("\"m.tenant_id = @TenantId\"", src, StringComparison.Ordinal);
+    }
+
+    /// <summary>
+    /// 013 不得再把 Domain 当租户边界:UAT 的 48 行 Domain 为空串,
+    /// 而 resolver 给出 '8010',叠加后会把本租户数据整体清零(单向误杀)。
+    /// </summary>
+    [Fact]
+    public void F013_MustNotUseDomainAsTenantBoundary()
+    {
+        var offenders = CodeLines(StocktakeSrc)
+            .Where(l => l.Contains("m.Domain", StringComparison.Ordinal)
+                        || l.Contains("\"@Domain\"", StringComparison.Ordinal)
+                        || l.Contains("ResolveDomainAsync", StringComparison.Ordinal))
+            .ToList();
+        Assert.True(offenders.Count == 0, $"013 仍在按 Domain 过滤/解析:{string.Join(" | ", offenders)}");
+    }
+
+    /// <summary>013 的 ItemMaster 关联必须 tenant-aware,且不得回落到 Domain-only。</summary>
+    [Fact]
+    public void F013_ItemMasterJoin_MustBeTenantAware()
+    {
+        var src = ReadSource(StocktakeSrc);
+        Assert.Contains("LEFT JOIN ItemMaster i ON i.tenant_id = m.tenant_id AND i.ItemNum = m.ItemNum",
+            src, StringComparison.Ordinal);
+        Assert.DoesNotContain(CodeLines(StocktakeSrc),
+            l => l.Contains("ON m.Domain = i.Domain", StringComparison.Ordinal));
+    }
+
+    /// <summary>
+    /// COUNT 与 SELECT 必须建立在同一业务集合上:COUNT 只查 MissedPrint,
+    /// SELECT 多一个 LEFT JOIN —— 该 JOIN 必须恒为 1:1,否则分页取回行数会大于 total。
+    /// </summary>
+    [Fact]
+    public void F013_CountAndSelect_ShareSameWhereClause()
+    {
+        var src = ReadSource(StocktakeSrc);
+        Assert.Contains("SELECT COUNT(1) FROM MissedPrint m WHERE {whereSql}", src, StringComparison.Ordinal);
+        // SELECT 侧复用同一个 whereSql,不得另起条件
+        var select = Slice(src, "FROM MissedPrint m", "ORDER BY m.UpdateTime");
+        Assert.Contains("WHERE {whereSql}", select, StringComparison.Ordinal);
+    }
+
+    /// <summary>013 仍须保持只读,且 IsTag=1 业务语义不得被改动。</summary>
+    [Fact]
+    public void F013_MustRemainReadOnly_AndKeepIsTagSemantics()
+    {
+        var src = ReadSource(StocktakeSrc);
+        Assert.Contains("\"m.IsTag = 1\"", src, StringComparison.Ordinal);
+        foreach (var verb in new[] { "HttpPost", "HttpPut", "HttpDelete", "HttpPatch" })
+            Assert.DoesNotContain(verb, src, StringComparison.Ordinal);
+    }
+
     // ===================== 数据面(AIDOP_IT=1,只读) =====================
 
     /// <summary>
@@ -303,6 +363,77 @@ public class MissedPrintConsumerScopeTests
         Assert.True(itemByDomain > 0 && suppByDomain > 0, "Domain-only 若已唯一,本条放大论证需重做");
     }
 
+    /// <summary>
+    /// §三 dry-run 固化:013 的 Domain 条件会单向清零 UAT。
+    /// 当前 IsTag=1 全库 0,故放宽 IsTag 做候选集模拟(不造数据)。
+    /// </summary>
+    [Fact]
+    public async Task Data_F013_DomainCondition_WipesOutUatOnly()
+    {
+        if (!ItEnabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        async Task<int> Cnt(long t, bool withDomain) => await db!.Ado.GetIntAsync(
+            withDomain
+                ? "SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T AND Domain='8010'"
+                : "SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T",
+            new List<SugarParameter> { new("@T", t) });
+
+        var uatOnly = await Cnt(TenantUat, false);
+        var uatWithDomain = await Cnt(TenantUat, true);
+        var aidopOnly = await Cnt(TenantAidop, false);
+        var aidopWithDomain = await Cnt(TenantAidop, true);
+
+        _out.WriteLine($"UAT tenant-only={uatOnly} / tenant+Domain={uatWithDomain};" +
+                       $"797 tenant-only={aidopOnly} / tenant+Domain={aidopWithDomain}");
+        Assert.True(uatOnly > 0);
+        Assert.Equal(0, uatWithDomain);           // Domain 把 UAT 自己的数据整体清零
+        Assert.Equal(aidopOnly, aidopWithDomain); // 对 797 无影响 —— 单向误杀
+    }
+
+    /// <summary>
+    /// §六/§七 JOIN 基数:租户内 ItemNum 唯一 → 1:1 不放大;
+    /// 且按租户关联能取到的物料主数据严格多于按 Domain(今日 104 → 152)。
+    /// </summary>
+    [Fact]
+    public async Task Data_F013_ItemMasterJoin_OneToOne_AndMoreComplete()
+    {
+        if (!ItEnabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var dupByTenant = await db!.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM (SELECT tenant_id,ItemNum FROM ItemMaster GROUP BY tenant_id,ItemNum HAVING COUNT(*)>1) x");
+        var dupByDomain = await db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM (SELECT Domain,ItemNum FROM ItemMaster GROUP BY Domain,ItemNum HAVING COUNT(*)>1) x");
+
+        var baseRows = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint");
+        var newJoinRows = await db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM MissedPrint m LEFT JOIN ItemMaster i ON i.tenant_id=m.tenant_id AND i.ItemNum=m.ItemNum");
+        var newWorst = await db.Ado.GetIntAsync(
+            """
+            SELECT IFNULL(MAX(c),0) FROM (
+              SELECT m.RecID, COUNT(i.ItemNum) c FROM MissedPrint m
+              LEFT JOIN ItemMaster i ON i.tenant_id=m.tenant_id AND i.ItemNum=m.ItemNum
+              GROUP BY m.RecID) x
+            """);
+        var oldMatched = await db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM MissedPrint m JOIN ItemMaster i ON m.Domain=i.Domain AND m.ItemNum=i.ItemNum");
+        var newMatched = await db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM MissedPrint m JOIN ItemMaster i ON i.tenant_id=m.tenant_id AND i.ItemNum=m.ItemNum");
+
+        _out.WriteLine($"重复组:按租户={dupByTenant} 按Domain={dupByDomain};" +
+                       $"base={baseRows} newJoin={newJoinRows} 单行最大命中={newWorst};" +
+                       $"能取到物料名:old={oldMatched} new={newMatched}");
+
+        Assert.Equal(0, dupByTenant);              // 租户内唯一 → 可直接收口
+        Assert.True(dupByDomain > 0, "Domain-only 若已唯一,放大论证需重做");
+        Assert.Equal(baseRows, newJoinRows);       // 不放大:SELECT 行数 == COUNT 基集
+        Assert.True(newWorst <= 1);
+        Assert.True(newMatched > oldMatched, "按租户关联应当严格覆盖更多行(UAT 原本一条都取不到)");
+    }
+
     /// <summary>回归:MissedPrint 租户归属未被本批破坏。</summary>
     [Fact]
     public async Task Data_Regression_TenantSplitUnchanged()

+ 21 - 36
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/StocktakeResultService.cs

@@ -5,15 +5,15 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 /// <summary>
 /// S5 盘点结果查询 只读 list 服务。
 ///
-/// 数据源:aidopdev.MissedPrint(盘点标签 IsTag=1),忠实复刻证据 sql.txt:
-/// LEFT JOIN ItemMaster(名称/规格/单位),过滤 m.Domain=@Domain AND m.IsTag=1
+/// 数据源:aidopdev.MissedPrint(盘点标签 IsTag=1),LEFT JOIN ItemMaster(名称/规格/单位)。
+/// 证据 sql.txt 原始口径是 <c>m.Domain=@Domain AND m.IsTag=1</c>,本实现改按租户,理由见 GetList 内注释
 /// 盘点口径由 SQL 内定义(非脑补):
 ///   账面(标签数量)=Qty;实盘数量=CompQty;差异数量=CompQty-Qty;
 ///   盘点状态=CompQty≠Qty?'有差异':'正常';状态=Status='Q'?'冻结':'';实际库位=FirmString2;实际货架=FirmString4。
-/// Domain 按当前租户经 SourceDomainTenantResolver 解析(不再全局取第一条、无 8010 回落)。排序 UpdateTime DESC, RecID DESC。
+/// 排序 UpdateTime DESC, RecID DESC。
 ///
 /// 本服务仅 SELECT:无新增/编辑/删除/库存事务/状态流转。
-/// MissedPrint 的 tenant_id 列存在但实测全为 NULL,无法做租户行级过滤,仅能按 Domain 公司域收敛(既有数据模型约束)。
+/// 租户边界 = <c>MissedPrint.tenant_id</c>(正式租户列,已由生成标签写入 + 1.0.442 回填,152 行 0 NULL)。
 /// 类级已去 [AllowAnonymous]:原先该接口可被**完全未认证**调用(实测匿名 GET 返回 200),已收敛为需认证。
 /// </summary>
 [ApiDescriptionSettings(Order = 304, Description = "盘点结果查询")]
@@ -24,19 +24,13 @@ public class StocktakeResultService : IDynamicApiController, ITransient
     /// <summary>主/系统租户哨兵:超管未选择目标租户时其 JWT TenantId 即此值,拒绝作为业务租户。</summary>
     private const long MainTenantId = 1300000000001L;
 
-    /// <summary>贴源源码:与 S5 其余库存页保持一致,供 Domain 解析使用。</summary>
-    private const string SourceCode = "DOPDEMORQ_SQLSERVER";
-
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
-    private readonly SourceDomainTenantResolver _domainTenant;
 
-    public StocktakeResultService(
-        ISqlSugarClient db, UserManager userManager, SourceDomainTenantResolver domainTenant)
+    public StocktakeResultService(ISqlSugarClient db, UserManager userManager)
     {
         _db = db;
         _userManager = userManager;
-        _domainTenant = domainTenant;
     }
 
     /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token 或超管未选目标租户 → 拒绝,无默认回退。</summary>
@@ -50,26 +44,6 @@ public class StocktakeResultService : IDynamicApiController, ITransient
         return tid;
     }
 
-    /// <summary>
-    /// 按当前租户解析公司域,统一走 <see cref="SourceDomainTenantResolver"/>。
-    /// <para>
-    /// 原实现是 <c>SELECT Domain FROM GeneralizedCodeMaster WHERE FldName='SystemConfig'
-    /// AND Val='CompanyCode' ORDER BY RecID LIMIT 1</c> —— **无 tenant 条件**,取全库第一条并硬回落 "8010",
-    /// 与 resolver 修复前的缺陷同源。本项目的 Domain 解析只允许有一个正式实现。
-    /// </para>
-    /// </summary>
-    private async Task<string> ResolveDomainOrThrowAsync(long tenantId, CancellationToken ct = default)
-    {
-        try
-        {
-            return await _domainTenant.ResolveDomainAsync(SourceCode, tenantId, ct);
-        }
-        catch (Exception ex)
-        {
-            throw Oops.Oh($"无法解析当前租户 Domain 映射:{ex.Message}");
-        }
-    }
-
     /// <summary>
     /// 盘点结果查询列表(只读分页查询)。
     /// </summary>
@@ -82,11 +56,17 @@ public class StocktakeResultService : IDynamicApiController, ITransient
         var offset = (page - 1) * pageSize;
 
         var tenantId = ResolveTenantOrThrow();
-        var domain = await ResolveDomainOrThrowAsync(tenantId);
 
-        // 租户边界取 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) };
+        // 租户边界 = m.tenant_id,**刻意不再叠加 m.Domain = @Domain**(2026-09-02 实测 aidopdev):
+        // ① MissedPrint 里每个租户只有单一 Domain 值,Domain 对租户内零过滤价值 ——
+        //    要么整租户命中、要么整租户清零,不构成任何业务筛选;
+        // ② 而 UAT 恰好是被清零的那一种:SourceDomainTenantResolver 经 LocationMaster 回落给出 '8010',
+        //    UAT 自己的 48 行 Domain 却是空串 —— tenant-only 得 48 行,tenant+Domain 得 0 行;
+        //    797 则 104 → 104 无影响,是单向误杀本租户数据。
+        // 结论:Domain 在本查询里既无过滤价值又会误杀,由 tenant_id 完全取代。
+        // 当前 IsTag=1 全库 0 行,缺陷被数据状态掩盖,故上述判据取自放宽 IsTag 后的候选集模拟。
+        var where = new List<string> { "m.tenant_id = @TenantId", "m.IsTag = 1" };
+        var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
 
         if (!string.IsNullOrWhiteSpace(input.MaterialCode))
         {
@@ -126,7 +106,12 @@ public class StocktakeResultService : IDynamicApiController, ITransient
                 m.UpdateUser   AS CountUser,
                 m.UpdateTime   AS CountTime
             FROM MissedPrint m
-            LEFT JOIN ItemMaster i ON m.Domain = i.Domain AND m.ItemNum = i.ItemNum
+            -- 物料主数据按租户关联:(tenant_id, ItemNum) 唯一(0 重复组),JOIN 恒为 1:1,
+            -- SELECT 行数不会超过 COUNT。而 (Domain, ItemNum) 有 391 个重复组(全部是跨租户重名,
+            -- 8010 内 143 组 / 2410 内 248 组),Domain-only 关联在重名 ItemNum 上会一对多放大。
+            -- 另有一项今日就已发生的错误:UAT 的 48 行 Domain 为空串,按 Domain 关联命中 0 条,
+            -- 物料名称/规格/单位整列为空;按租户关联则 152/152 行都能取到(实测 old 104 → new 152)。
+            LEFT JOIN ItemMaster i ON i.tenant_id = m.tenant_id AND i.ItemNum = m.ItemNum
             WHERE {whereSql}
             ORDER BY m.UpdateTime DESC, m.RecID DESC
             LIMIT {pageSize} OFFSET {offset}