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

fix(s5): close remaining missedprint tenant scopes

MissedPrint.tenant_id 已是正式租户列(生成标签写入 + 1.0.442 回填,152 行 0 NULL)。
但 797 的 104 行 Domain='8010'、UAT 的 48 行 Domain='',而 SourceDomainTenantResolver
经 LocationMaster 回落对两者都给出 '8010'。因此凡按 Domain 过滤 MissedPrint 的路径,
对 UAT 而言候选宇宙 100% 由 797 构成、自己的数据一行进不来 —— 方向是反的。
本批把剩余三处消费点一律改为直接消费 tenant_id。

FUNC-S5-009 PendingInspection
- 锚表 m.Domain=@Domain → m.tenant_id=@TenantId。
  按 §3.2 做了 Domain 必要性 dry-run:MissedPrint 中每个租户只有单一 Domain 值
  (797 全 '8010'、UAT 全空串),Domain 对租户内零过滤价值,只可能整租户命中或整租户清零,
  而 UAT 正是被清零的那一个 → Domain 条件移除,不做机械保留。
- 供应商下拉同法收口(MissedPrint 与 SuppMaster 都按租户)。
- 主数据关联由 Domain 改为租户:(tenant_id,ItemNum) 与 (tenant_id,Supp) 均唯一,
  而 (Domain,ItemNum) 有 391 组重复、(Domain,Supp) 有 14 组重复,Domain-only 会一对多放大,
  使分页取回行数与 COUNT 出来的 total 对不上。
- Domain 在本服务已无用途,随之移除 ResolveDomainOrThrowAsync / SourceDomainTenantResolver 注入。

IqcWmsResultPushService
- MissedPrint 取数 IFNULL(Domain,'') IN ('', @Domain) → tenant_id=@TenantId。
  该谓词是本仓唯一一处结构上同时并入两个租户分区的 MissedPrint 查询
  (UAT 的 Domain='' 与 797 的 '8010' 都能命中,实测候选 48+82=130 行)。
- 删除硬编码 var domain = "8010";Domain 降级为仅供 165 报文字段使用,
  改由 SourceDomainTenantResolver 按当前租户解析,解析不出即跳过推送(fail closed)。
- 同一推送决策链上的其余跨租户读一并收口:PurOrdRctDetail 的 QtyReceived 与 ReceiptQty、
  ItemMaster 的默认上架库位(原为 Domain 跨分区 + LIMIT 1,会取到别租户的库位)。
- 推送链租户一致性:取数 tenantId 与入队 outbox 的 TenantId 同源。

InventoryBalanceReader(010 HARDEN)
- Live165 与 STD 两处标签富化子查询 Domain=@Domain → tenant_id=@TenantId。
  JOIN 键 (Location,ItemNum,LotSerial) 在 mdp_std_inventory 已存在跨租户碰撞
  (UAT 与 797 都有 1000|81HC0744|260811013 等组合),今日仅因对应 qty=0 被挡住。
  富化只写 Shelf / LevelChar / Supply 三个展示列,不参与任何数量计算。
- (tenant_id,Location,ItemNum,LotSerial) 实测无重复,加租户后不会放大分页。

验证
- dotnet build --framework net10.0 通过;默认测试套件 1230 passed / 0 failed。
- 新增 15 个用例(9 源码守卫 + 6 数据面,数据面需 AIDOP_IT=1 且全程只读)。
- 运行态(chrome-devtools MCP,隔离浏览器上下文,冒烟槽 2/3):
  009 UAT total=0、供应商下拉 0 项、伪造 ?tenantId=797 无效、页面「暂无数据」无报错;
  009 797 侧 total=0(其唯一 'I' 行 Location 非空,被业务条件排除)、伪造 UAT 无效。
  RUNTIME PUSH NOT EXECUTED:未触发任何 165 推送,IQC 路径以候选集 dry-run 证明。

回归
- FUNC-S5-008:UAT 48 / 797 104,未变。
- FUNC-S5-010 REMAINS CLOSED(本批为 HARDENING,非 REOPEN):
  UAT STD 共 3231 条、合计在库 7895500.821,10/50/100 三档取回数=去重数;
  797 侧 6430 条,两租户互不相等;Shelf/Version/Supplier 仍全空,与本批前一致。
  注:运行态当前恒走 STD(fallbackFromLive=false),LIVE165 富化路径未被实际执行,
  其修复与 STD 侧同构,仅有源码级与谓词级验证。
- P0 无回归:匿名探测 008/009/010/012/013 + SupplierDelivery / SupplierShortage /
  SupplierShipment / PurOrdWmsPush(POST) 共 10 个端点全部 401。

全仓 MissedPrint 消费点复扫后,仍未按 tenant_id 收口的本地运行态消费点为 0。
剩余仅:StocktakeLabelConfirmService(012, DEAD)、IqcReceiptStateMdpSyncService(未接线 DEBT)、
以及 MdpHotWatch / MdpDbPushExecutor 对远端 165 的查询(远端无租户列,N/A)。

本批未处理(新发现,未擅自扩大)
- FUNC-S5-013 StocktakeResultService 仍带 m.Domain=@Domain(与 009 修复前同源,
  当前被 IsTag=1 全库 0 行掩盖),且 LEFT JOIN ItemMaster 仍按 Domain 关联会放大。
- 版本号未递增:csproj 工作区被另一实例未提交的 1.0.445 占用,改动它会连带提交对方内容
  或抢占对方号段;本批无 SQL 迁移,不存在版本依赖。待对方 push 后另行补 bump。
YY968XX 2 дней назад
Родитель
Сommit
01041e276f

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

@@ -0,0 +1,325 @@
+using System.Text.RegularExpressions;
+using SqlSugar;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
+
+/// <summary>
+/// S5 MissedPrint 剩余消费点 P1/P2 收口守卫(FUNC-S5-009 / IqcWmsResultPush / InventoryBalanceReader)。
+/// <para>
+/// 源码断言部分无门禁,普通 CI 也会执行;数据面断言需 AIDOP_IT=1,且**全程只读**,
+/// 不写任何业务表、不触发 165 推送。
+/// </para>
+/// <para>
+/// 背景:MissedPrint.tenant_id 已是正式租户列(生成标签写入 + 1.0.442 回填,152 行 0 NULL)。
+/// 但 797 的 104 行 Domain='8010'、UAT 的 48 行 Domain='',而 SourceDomainTenantResolver
+/// 对两者都解析出 '8010' —— 因此凡是按 Domain 过滤 MissedPrint 的路径,对 UAT 而言候选宇宙
+/// 100% 由 797 构成,方向是反的。本组把「一律直接用 tenant_id」钉死。
+/// </para>
+/// </summary>
+public class MissedPrintConsumerScopeTests
+{
+    private const long TenantUat = 838257186181189L;
+    private const long TenantAidop = 797403760988229L;
+
+    private static bool ItEnabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
+
+    private readonly Xunit.Abstractions.ITestOutputHelper _out;
+    public MissedPrintConsumerScopeTests(Xunit.Abstractions.ITestOutputHelper output) => _out = output;
+
+    private static readonly string PluginRoot =
+        Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+
+    private const string PendingSrc = "MaterialWarehouse/PendingInspectionService.cs";
+    private const string IqcPushSrc = "MaterialWarehouse/IqcWmsResultPushService.cs";
+    private const string BalanceSrc = "MaterialWarehouse/InventoryBalanceReader.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)
+                        && !l.StartsWith("--", StringComparison.Ordinal))
+            .ToList();
+
+    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-mp-consumer-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
+        });
+    }
+
+    /// <summary>取源码中某条 SQL 片段。</summary>
+    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);
+    }
+
+    // ===================== FUNC-S5-009 PendingInspection =====================
+
+    [Fact]
+    public void F009_Anchor_MustFilterByTenantId()
+    {
+        var src = ReadSource(PendingSrc);
+        Assert.Contains("\"m.tenant_id = @TenantId\"", src, StringComparison.Ordinal);
+    }
+
+    /// <summary>
+    /// 锚表不得再带 Domain:它对租户内零过滤价值,却会把整个 UAT 清零。
+    /// </summary>
+    [Fact]
+    public void F009_Anchor_MustNotFilterByDomain()
+    {
+        var offenders = CodeLines(PendingSrc)
+            .Where(l => l.Contains("m.Domain", StringComparison.Ordinal)
+                        || l.Contains("\"@Domain\"", StringComparison.Ordinal))
+            .ToList();
+        Assert.True(offenders.Count == 0, $"009 仍在按 Domain 过滤:{string.Join(" | ", offenders)}");
+    }
+
+    [Fact]
+    public void F009_SupplierOptions_MustBeTenantScoped()
+    {
+        var src = ReadSource(PendingSrc);
+        var stmt = Slice(src, "SELECT DISTINCT m.Supply", "ORDER BY m.Supply");
+        Assert.Contains("m.tenant_id = @TenantId", stmt, StringComparison.Ordinal);
+        Assert.Contains("s.tenant_id = @TenantId", stmt, StringComparison.Ordinal);
+        Assert.DoesNotContain("@Domain", stmt, StringComparison.Ordinal);
+    }
+
+    /// <summary>主数据关联改按租户:(Domain, ItemNum) / (Domain, Supp) 都不唯一,会一对多放大。</summary>
+    [Fact]
+    public void F009_MasterDataJoins_MustBeTenantScoped()
+    {
+        var src = ReadSource(PendingSrc);
+        Assert.Contains("LEFT JOIN ItemMaster i ON i.tenant_id = @TenantId", src, StringComparison.Ordinal);
+        Assert.Contains("LEFT JOIN SuppMaster s ON s.tenant_id = @TenantId", src, StringComparison.Ordinal);
+    }
+
+    // ===================== IqcWmsResultPushService =====================
+
+    [Fact]
+    public void IqcPush_MissedPrint_MustBeTenantScoped()
+    {
+        var src = ReadSource(IqcPushSrc);
+        var stmt = Slice(src, "FROM MissedPrint", "IFNULL(Type,'') <> 'Card'");
+        Assert.Contains("tenant_id = @TenantId", stmt, StringComparison.Ordinal);
+    }
+
+    /// <summary>
+    /// <c>IFNULL(Domain,'') IN ('', @Domain)</c> 会同时命中 UAT(Domain='')与 797(Domain='8010')
+    /// 两个分区,是本仓唯一一处结构性合并两租户的 MissedPrint 查询,必须彻底消失。
+    /// </summary>
+    [Fact]
+    public void IqcPush_MustNotUseCrossPartitionDomainPredicate()
+    {
+        var offenders = CodeLines(IqcPushSrc)
+            .Where(l => l.Contains("IN ('', @Domain)", StringComparison.Ordinal))
+            .ToList();
+        Assert.True(offenders.Count == 0, $"仍存在跨分区 Domain 谓词:{string.Join(" | ", offenders)}");
+    }
+
+    [Fact]
+    public void IqcPush_MustNotHardcodeDomain()
+    {
+        var offenders = CodeLines(IqcPushSrc)
+            .Where(l => l.Contains("\"8010\"", StringComparison.Ordinal))
+            .ToList();
+        Assert.True(offenders.Count == 0, $"仍硬编码 Domain:{string.Join(" | ", offenders)}");
+        // 必须改走统一 resolver 并按当前租户解析
+        Assert.Contains(CodeLines(IqcPushSrc),
+            l => l.Contains("_domainTenant.ResolveDomainAsync(SourceCode, tenantId", StringComparison.Ordinal));
+    }
+
+    /// <summary>推送链租户一致性:入队的 TenantId 必须就是取数用的 tenantId,不得另起来源。</summary>
+    [Fact]
+    public void IqcPush_OutboxTenant_MatchesSourceTenant()
+    {
+        var lines = CodeLines(IqcPushSrc);
+        var outboxTenants = lines.Where(l => l.Contains("TenantId =", StringComparison.Ordinal)).ToList();
+        Assert.NotEmpty(outboxTenants);
+        Assert.All(outboxTenants, l => Assert.Contains("TenantId = tenantId", l, StringComparison.Ordinal));
+    }
+
+    // ===================== InventoryBalanceReader(010 富化) =====================
+
+    /// <summary>Live165 与 STD 两处标签富化都必须按租户,且不得退回 Domain-only。</summary>
+    [Fact]
+    public void Balance_BothEnrichments_MustBeTenantScoped()
+    {
+        var src = ReadSource(BalanceSrc);
+        var occurrences = Regex.Matches(src, @"FROM MissedPrint\s*\r?\n\s*WHERE\s+tenant_id=@TenantId").Count;
+        Assert.Equal(2, occurrences);
+
+        var offenders = CodeLines(BalanceSrc)
+            .Where(l => l.Contains("WHERE Domain=@Domain AND IFNULL(Status", StringComparison.Ordinal))
+            .ToList();
+        Assert.True(offenders.Count == 0, $"仍存在 Domain-only 的标签富化:{string.Join(" | ", offenders)}");
+    }
+
+    /// <summary>富化只能影响展示列,绝不能碰数量列 —— 这是 010 保持 CLOSED 的前提。</summary>
+    [Fact]
+    public void Balance_Enrichment_TouchesOnlyDisplayColumns()
+    {
+        var src = ReadSource(BalanceSrc);
+        foreach (var stmt in new[]
+                 {
+                     Slice(src, "SELECT Location, ItemNum, IFNULL(LotSerial,'') AS LotSerial", "GROUP BY Location"),
+                 })
+        {
+            Assert.Contains("MAX(Shelf)", stmt, StringComparison.Ordinal);
+            Assert.Contains("MAX(LevelChar)", stmt, StringComparison.Ordinal);
+            Assert.Contains("MAX(Supply)", stmt, StringComparison.Ordinal);
+            Assert.DoesNotContain("QtyOnHand", stmt, StringComparison.Ordinal);
+            Assert.DoesNotContain("AvailStatusQty", stmt, StringComparison.Ordinal);
+        }
+    }
+
+    // ===================== 数据面(AIDOP_IT=1,只读) =====================
+
+    /// <summary>
+    /// §3.2 的判据固化:Domain 在 MissedPrint 层对租户内零过滤价值(每租户单一 Domain 值),
+    /// 却会整租户清零 —— UAT 就是被清零的那一个。
+    /// </summary>
+    [Fact]
+    public async Task Data_DomainIsUselessAndHarmfulOnMissedPrint()
+    {
+        if (!ItEnabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var multiDomainTenants = await db!.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM (SELECT tenant_id FROM MissedPrint GROUP BY tenant_id HAVING COUNT(DISTINCT IFNULL(Domain,''))>1) x");
+        var uatUnderDomain8010 = await db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T AND Domain='8010'",
+            new List<SugarParameter> { new("@T", TenantUat) });
+        var uatTotal = await db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T", new List<SugarParameter> { new("@T", TenantUat) });
+
+        _out.WriteLine($"多 Domain 租户数={multiDomainTenants};UAT 总数={uatTotal},其中 Domain='8010' 的={uatUnderDomain8010}");
+        Assert.Equal(0, multiDomainTenants);          // 每租户单一 Domain → Domain 无租户内过滤价值
+        Assert.True(uatTotal > 0);
+        Assert.Equal(0, uatUnderDomain8010);          // 而 resolver 给 UAT 的正是 '8010' → 旧写法整租户清零
+    }
+
+    /// <summary>§4.5:IQC 推送候选集,修复前跨两租户 130 行,修复后按租户切分且互不重叠。</summary>
+    [Fact]
+    public async Task Data_IqcPushCandidates_SplitByTenant()
+    {
+        if (!ItEnabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        const string tail = "IFNULL(Status,'')<>'C' AND IFNULL(Type,'')<>'Card'";
+        var legacy = await db!.Ado.GetIntAsync(
+            $"SELECT COUNT(1) FROM MissedPrint WHERE IFNULL(Domain,'') IN ('', '8010') AND {tail}");
+        var uat = await db.Ado.GetIntAsync(
+            $"SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T AND {tail}",
+            new List<SugarParameter> { new("@T", TenantUat) });
+        var aidop = await db.Ado.GetIntAsync(
+            $"SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T AND {tail}",
+            new List<SugarParameter> { new("@T", TenantAidop) });
+
+        _out.WriteLine($"旧写法候选={legacy}(跨两租户);按租户切分 UAT={uat} / 797={aidop}");
+        Assert.True(legacy > uat && legacy > aidop, "旧写法应当同时并入两个租户,否则用例失去意义");
+        Assert.Equal(legacy, uat + aidop);   // 不重不漏
+    }
+
+    /// <summary>§5.3:富化 JOIN 键在租户内唯一,加 tenant_id 后不会放大分页。</summary>
+    [Fact]
+    public async Task Data_EnrichmentJoinKey_UniqueWithinTenant()
+    {
+        if (!ItEnabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var dupWithinTenant = await db!.Ado.GetIntAsync(
+            """
+            SELECT COUNT(1) FROM (
+              SELECT tenant_id, Location, ItemNum, IFNULL(LotSerial,'') l
+              FROM MissedPrint WHERE IFNULL(Location,'')<>''
+              GROUP BY tenant_id, Location, ItemNum, l HAVING COUNT(*)>1) x
+            """);
+        // 负向对照:不带租户时,(Location,ItemNum,LotSerial) 在 mdp_std_inventory 已跨租户碰撞
+        var stdCollision = await db.Ado.GetIntAsync(
+            """
+            SELECT COUNT(1) FROM (
+              SELECT location, item_num, IFNULL(lot_serial,'') l FROM mdp_std_inventory
+              WHERE (location, item_num, IFNULL(lot_serial,'')) IN (
+                  SELECT Location, ItemNum, IFNULL(LotSerial,'') FROM MissedPrint
+                  WHERE IFNULL(Status,'') NOT IN ('U','P','C') AND IFNULL(Location,'')<>'' AND Qty>0)
+              GROUP BY location, item_num, l HAVING COUNT(DISTINCT tenant_id)>1) x
+            """);
+
+        _out.WriteLine($"MissedPrint 租户内键重复={dupWithinTenant};标签键在 STD 里跨租户碰撞={stdCollision}");
+        Assert.Equal(0, dupWithinTenant);
+        Assert.True(stdCollision > 0, "碰撞若已消失,需重新评估本条 HARDEN 的必要性");
+    }
+
+    /// <summary>§3.4:主数据关联改按租户后是 1:1;按 Domain 则会放大。</summary>
+    [Fact]
+    public async Task Data_MasterDataJoin_TenantScopedIsOneToOne()
+    {
+        if (!ItEnabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var itemByTenant = await db!.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM (SELECT tenant_id,ItemNum FROM ItemMaster GROUP BY tenant_id,ItemNum HAVING COUNT(*)>1) x");
+        var itemByDomain = await db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM (SELECT Domain,ItemNum FROM ItemMaster GROUP BY Domain,ItemNum HAVING COUNT(*)>1) x");
+        var suppByTenant = await db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM (SELECT tenant_id,Supp FROM SuppMaster GROUP BY tenant_id,Supp HAVING COUNT(*)>1) x");
+        var suppByDomain = await db.Ado.GetIntAsync(
+            "SELECT COUNT(1) FROM (SELECT Domain,Supp FROM SuppMaster GROUP BY Domain,Supp HAVING COUNT(*)>1) x");
+
+        _out.WriteLine($"ItemMaster 重复:按租户={itemByTenant} 按Domain={itemByDomain};" +
+                       $"SuppMaster 重复:按租户={suppByTenant} 按Domain={suppByDomain}");
+        Assert.Equal(0, itemByTenant);
+        Assert.Equal(0, suppByTenant);
+        Assert.True(itemByDomain > 0 && suppByDomain > 0, "Domain-only 若已唯一,本条放大论证需重做");
+    }
+
+    /// <summary>回归:MissedPrint 租户归属未被本批破坏。</summary>
+    [Fact]
+    public async Task Data_Regression_TenantSplitUnchanged()
+    {
+        if (!ItEnabled) 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", TenantUat) });
+        var aidop = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T",
+            new List<SugarParameter> { new("@T", TenantAidop) });
+
+        _out.WriteLine($"total={total} null={nullTenant} UAT={uat} 797={aidop}");
+        Assert.Equal(0, nullTenant);
+        Assert.Equal(total, uat + aidop);
+    }
+}

+ 9 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryBalanceReader.cs

@@ -402,16 +402,20 @@ public sealed class Live165InventorySource : ITransient
                     itemMap[it.ItemNum] = (it.Descr, it.UM);
         }
 
+        // 标签富化(Shelf / LevelChar / Supply)必须按租户取:JOIN 键 (Location, ItemNum, LotSerial)
+        // 在 mdp_std_inventory 里已存在跨租户碰撞(UAT 与 797 都有 1000|81HC0744|260811013 等组合),
+        // 只按 Domain 会把别租户的货架/版本/供应商贴到本租户的库存行上。
+        // Domain 保留为业务约束,但不再单独承担租户边界。
         var mp = await _db.Ado.SqlQueryAsync<MpRow>(
             """
             SELECT Location, ItemNum, IFNULL(LotSerial,'') AS LotSerial,
                    MAX(Shelf) AS Shelf, MAX(LevelChar) AS LevelChar, MAX(Supply) AS Supply
             FROM MissedPrint
-            WHERE Domain=@Domain AND IFNULL(Status,'') NOT IN ('U','P','C')
+            WHERE tenant_id=@TenantId AND IFNULL(Status,'') NOT IN ('U','P','C')
               AND IFNULL(Location,'') <> '' AND Qty > 0
             GROUP BY Location, ItemNum, IFNULL(LotSerial,'')
             """,
-            new List<SugarParameter> { new("@Domain", domain) });
+            new List<SugarParameter> { new("@TenantId", tenantId) });
         var mpMap = mp.ToDictionary(
             x => $"{x.Location}|{x.ItemNum}|{x.LotSerial}",
             x => x,
@@ -649,11 +653,13 @@ public sealed class StdInventorySource : ITransient
                  WHERE Domain=@Domain
                  GROUP BY Domain, ItemNum
              ) i ON i.Domain=@Domain AND n.ItemNum=i.ItemNum
+             -- 同上:标签富化按租户取。(tenant_id, Location, ItemNum, LotSerial) 实测无重复,
+             -- 外层 GROUP BY + MAX() 亦已把多行收敛成一行,不会放大分页。
              LEFT JOIN (
                  SELECT Location, ItemNum, IFNULL(LotSerial,'') AS LotSerial,
                         MAX(Shelf) AS Shelf, MAX(LevelChar) AS LevelChar, MAX(Supply) AS Supply
                  FROM MissedPrint
-                 WHERE Domain=@Domain AND IFNULL(Status,'') NOT IN ('U','P','C')
+                 WHERE tenant_id=@TenantId AND IFNULL(Status,'') NOT IN ('U','P','C')
                    AND IFNULL(Location,'') <> '' AND Qty > 0
                  GROUP BY Location, ItemNum, IFNULL(LotSerial,'')
              ) mp ON mp.Location=n.Location AND mp.ItemNum=n.ItemNum AND mp.LotSerial=n.LotSerial

+ 33 - 12
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcWmsResultPushService.cs

@@ -21,20 +21,26 @@ public sealed class IqcWmsResultPushService : ITransient
     public const string ActionUpShelf = "IQC_WMS_UPSHELF";
     public const string QmsApiAction = "S5_IQC_RESULT_PUSH";
 
+    /// <summary>贴源源码:与 S5 其余库存页保持一致,供 Domain 解析使用。</summary>
+    private const string SourceCode = "DOPDEMORQ_SQLSERVER";
+
     private readonly ISqlSugarClient _db;
     private readonly MdpOutboxEnqueueService _enqueue;
     private readonly MdpOutboxWakeSignal _wake;
+    private readonly SourceDomainTenantResolver _domainTenant;
     private readonly ILogger<IqcWmsResultPushService> _logger;
 
     public IqcWmsResultPushService(
         ISqlSugarClient db,
         MdpOutboxEnqueueService enqueue,
         MdpOutboxWakeSignal wake,
+        SourceDomainTenantResolver domainTenant,
         ILogger<IqcWmsResultPushService> logger)
     {
         _db = db;
         _enqueue = enqueue;
         _wake = wake;
+        _domainTenant = domainTenant;
         _logger = logger;
     }
 
@@ -91,7 +97,19 @@ public sealed class IqcWmsResultPushService : ITransient
             return;
         }
 
-        var domain = "8010";
+        // Domain 只用于 165 报文字段(旧系统按公司域组织),**不再充当任何过滤/安全条件**。
+        // 原来是硬编码 "8010";现按当前租户解析,解析不出即 fail closed,绝不回落固定值。
+        string domain;
+        try
+        {
+            domain = await _domainTenant.ResolveDomainAsync(SourceCode, tenantId, ct);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex, "[IqcWmsPush] 无法解析租户 Domain,跳过推送 tenant={Tenant} bill={BillNo}",
+                tenantId, bill.BillNo);
+            return;
+        }
         var qcNbr = string.IsNullOrWhiteSpace(bill.ApplyBillNo) ? bill.BillNo : bill.ApplyBillNo!;
         var userNo = string.IsNullOrWhiteSpace(bill.Inspector) ? "AIDOP" : bill.Inspector!;
         var now = DateTime.Now;
@@ -102,14 +120,17 @@ public sealed class IqcWmsResultPushService : ITransient
         // 入队前预检:本库箱码(已与 165 同步)
         var barcodes = await _db.Ado.SqlQueryAsync<BarcodeRow>(
             """
-            SELECT BarCode, Qty, Status, IFNULL(Domain,'8010') AS Domain
+            -- 租户边界取 tenant_id。原写法 IFNULL(Domain,'') IN ('', @Domain) 是本仓唯一一处
+            -- **结构上同时并入两个租户分区**的 MissedPrint 查询:797 的行 Domain='8010'、
+            -- UAT 的行 Domain='',两者都能命中(实测候选 48 + 82 = 130 行)。
+            SELECT BarCode, Qty, Status, IFNULL(Domain,'') AS Domain
             FROM MissedPrint
-            WHERE IFNULL(Domain,'') IN ('', @Domain)
+            WHERE tenant_id = @TenantId
               AND RctNbr = @Receiver AND ItemNum = @ItemNum
               AND IFNULL(Status,'') <> 'C'
               AND IFNULL(Type,'') <> 'Card'
             """,
-            new SugarParameter("@Domain", domain),
+            new SugarParameter("@TenantId", tenantId),
             new SugarParameter("@Receiver", entry.Receiver),
             new SugarParameter("@ItemNum", entry.ItemNum));
 
@@ -125,10 +146,10 @@ public sealed class IqcWmsResultPushService : ITransient
             """
             SELECT SUM(IFNULL(QtyReceived,0))
             FROM PurOrdRctDetail
-            WHERE IFNULL(Domain,'') IN ('', @Domain)
+            WHERE tenant_id = @TenantId
               AND Receiver = @Receiver AND ItemNum = @ItemNum
             """,
-            new SugarParameter("@Domain", domain),
+            new SugarParameter("@TenantId", tenantId),
             new SugarParameter("@Receiver", entry.Receiver),
             new SugarParameter("@ItemNum", entry.ItemNum)) ?? 0m;
 
@@ -328,12 +349,12 @@ public sealed class IqcWmsResultPushService : ITransient
             SELECT SUM(IFNULL(d.ReceiptQty, 0))
             FROM PurOrdRctDetail r
             INNER JOIN PurOrdDetail d
-              ON IFNULL(d.Domain,'') IN ('', @Domain)
+              ON d.tenant_id = r.tenant_id
              AND d.PurOrd = r.OrdNbr AND d.Line = r.OrdLine
-            WHERE IFNULL(r.Domain,'') IN ('', @Domain)
+            WHERE r.tenant_id = @TenantId
               AND r.Receiver = @Receiver AND r.ItemNum = @ItemNum
             """,
-            new SugarParameter("@Domain", domain),
+            new SugarParameter("@TenantId", tenantId),
             new SugarParameter("@Receiver", entry.Receiver!),
             new SugarParameter("@ItemNum", entry.ItemNum!)) ?? 0m;
         if (receiptQty > 0)
@@ -349,14 +370,14 @@ public sealed class IqcWmsResultPushService : ITransient
         {
             passShelf = await _db.Ado.SqlQuerySingleAsync<string>(
                 """
+                -- (tenant_id, ItemNum) 唯一;原写法跨租户命中后 LIMIT 1 会取到别的租户的默认库位
                 SELECT Location
                 FROM ItemMaster
-                WHERE ItemNum = @ItemNum
-                  AND IFNULL(Domain,'') IN ('', @Domain)
+                WHERE tenant_id = @TenantId AND ItemNum = @ItemNum
                 LIMIT 1
                 """,
                 new SugarParameter("@ItemNum", entry.ItemNum!),
-                new SugarParameter("@Domain", domain));
+                new SugarParameter("@TenantId", tenantId));
             if (string.IsNullOrWhiteSpace(passShelf))
             {
                 _logger.LogWarning(

+ 28 - 49
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/PendingInspectionService.cs

@@ -9,7 +9,8 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 /// pr_WMS_GetMissedTempQty:LEFT JOIN qms_qcp_inspecapplyn/qms_qcp_insappnentry 取检验状态,按
 /// Supply/PurOrd/PurLine/ShipperNbr/ItemNum/LotSerial 聚合(MAX Shelf/SUM Qty/MAX CheckNo/MAX FINSPECTSTATUS),
 /// 再 LEFT JOIN ItemMaster(描述/单位)、SuppMaster(简称),clfs='3' 追加“-挑选使用”。
-/// Domain 由 GeneralizedCodeMaster(SystemConfig/CompanyCode) 解析,默认 8010。
+/// 租户边界取 MissedPrint.tenant_id(正式租户列,已由生成标签写入 + 1.0.442 回填补齐)。
+/// Domain **不再**参与 MissedPrint 及其主数据关联的过滤,理由见 GetList 内注释。
 ///
 /// 本服务仅 SELECT:无新增/编辑/删除/库存事务/状态流转;MissedPrint 无 tenant_id,不做租户过滤。
 /// </summary>
@@ -21,19 +22,13 @@ public class PendingInspectionService : 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 PendingInspectionService(
-        ISqlSugarClient db, UserManager userManager, SourceDomainTenantResolver domainTenant)
+    public PendingInspectionService(ISqlSugarClient db, UserManager userManager)
     {
         _db = db;
         _userManager = userManager;
-        _domainTenant = domainTenant;
     }
 
     /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token(&lt;=0) 或超管未选主租户 → 拒绝;不读前端 tenantId、无默认回退。</summary>
@@ -47,28 +42,6 @@ public class PendingInspectionService : 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 条件**,取的是全库第一条,
-    /// 与 resolver 修复前的缺陷同源:不同租户会被解析到同一个(别人的)Domain,
-    /// 且解析不到时硬回落 "8010"。本项目的 Domain 解析只允许有一个正式实现。
-    /// </para>
-    /// </summary>
-    private async Task<string> ResolveDomainOrThrowAsync(long tenantId, CancellationToken ct = default)
-    {
-        try
-        {
-            return await _domainTenant.ResolveDomainAsync(SourceCode, tenantId, ct);
-        }
-        catch (Exception ex)
-        {
-            // fail closed:解析不出租户 Domain 就不查,绝不回落固定值
-            throw Oops.Oh($"无法解析当前租户 Domain 映射:{ex.Message}");
-        }
-    }
-
     /// <summary>
     /// 暂收在检列表(只读分页查询)。
     /// </summary>
@@ -80,24 +53,27 @@ public class PendingInspectionService : IDynamicApiController, ITransient
         var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
         var offset = (page - 1) * pageSize;
 
-        // 租户先解析,Domain 由租户决定(不再全局取第一条)
-        var listTenantId = ResolveTenantOrThrow();
-        var domain = await ResolveDomainOrThrowAsync(listTenantId);
+        var tenantId = ResolveTenantOrThrow();
 
+        // 租户边界 = MissedPrint.tenant_id。
+        //
+        // 这里**刻意不再带 m.Domain = @Domain**,依据(2026-09-02 实测 aidopdev):
+        // ① Domain 不是租户边界 —— 797 与 UAT 的 Domain 解析结果同为 '8010';
+        // ② MissedPrint 里每个租户只有单一 Domain 值(797 全为 '8010'、UAT 全为空串),
+        //    所以叠加 Domain 对**租户内**没有任何过滤作用,只可能整租户命中或整租户清零;
+        // ③ 而 UAT 恰好就是被整租户清零的那一种:SourceDomainTenantResolver 经 LocationMaster
+        //    回落给出 '8010',UAT 自己的 48 行 Domain 却是空串 —— 旧写法下 UAT 的候选宇宙
+        //    100% 由 797 的 104 行构成,自己的数据一行都进不来,方向是反的。
+        // 结论:Domain 在本查询里既无过滤价值又会误杀,由 tenant_id 完全取代。
         var where = new List<string>
         {
-            "m.Domain = @Domain",
+            "m.tenant_id = @TenantId",
             "m.Status = 'I'",
             "m.Qty > 0",
             "IFNULL(m.Location,'') = ''",
         };
-        // 同库分租户:主表 MissedPrint 的 tenant_id 已由生成标签写入 + 1.0.442 回填补齐(跟踪项:本服务尚未改用它);
-        // LEFT JOIN 的 qms 申请/明细表已补 tenant_id,租户过滤置于 JOIN ON(避免退化为 INNER JOIN 丢失 MissedPrint 锚行)。
-        // 租户改用严格可信解析(仅认证后 JWT,忽略前端 tenantId);配合类级已去 [AllowAnonymous],杜绝匿名跨租户读。
-        // 残留边界: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) };
+        // qms 申请/明细的租户过滤置于 JOIN ON(避免 LEFT JOIN 退化为 INNER JOIN 丢失 MissedPrint 锚行)
+        var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
 
         if (!string.IsNullOrWhiteSpace(input.DemandNo))
         {
@@ -169,8 +145,11 @@ public class PendingInspectionService : IDynamicApiController, ITransient
                 t.Supply     AS Supplier,
                 s.SortName   AS SupplierShortName
             FROM ({groupedInner}) t
-            LEFT JOIN ItemMaster i ON i.Domain = @Domain AND t.ItemNum = i.ItemNum
-            LEFT JOIN SuppMaster s ON s.Domain = @Domain AND t.Supply = s.Supp
+            -- 主数据按租户关联,不按 Domain:(tenant_id, ItemNum) 与 (tenant_id, Supp) 都唯一,
+            -- 而 (Domain, ItemNum) 有 391 组重复、(Domain, Supp) 有 14 组重复,
+            -- Domain-only 关联会一对多放大,使分页取回行数与 COUNT 出来的 total 对不上。
+            LEFT JOIN ItemMaster i ON i.tenant_id = @TenantId AND t.ItemNum = i.ItemNum
+            LEFT JOIN SuppMaster s ON s.tenant_id = @TenantId AND t.Supply = s.Supp
             ORDER BY t.Supply, t.PurOrd, t.PurLine
             LIMIT {pageSize} OFFSET {offset}
             """,
@@ -206,19 +185,19 @@ public class PendingInspectionService : IDynamicApiController, ITransient
     [HttpGet("supplier-options")]
     public async Task<object> GetSupplierOptions()
     {
-        // 残留边界(注明):本选项来自 MissedPrint(tenant_id 已补齐但本服务尚未启用)+ SuppMaster(无 tenant_id)
-        // 只能按 Domain 公司域收敛,无法做租户行级过滤(既有约束)。类级已去 [AllowAnonymous] → 需认证后方可访问,杜绝匿名读
-        var domain = await ResolveDomainOrThrowAsync(ResolveTenantOrThrow());
+        // 与主查询同口径:租户边界取 tenant_id,MissedPrint 与 SuppMaster 都按租户收敛
+        // 下拉集合不会再出现别的租户的供应商(Supply 编码跨租户重名不影响,集合本身已按租户切分)
+        var tenantId = ResolveTenantOrThrow();
         var list = await _db.Ado.SqlQueryAsync<PendingInspectionSupplierOption>(
             """
             SELECT DISTINCT m.Supply AS Val, s.SortName AS Label
             FROM MissedPrint m
-            LEFT JOIN SuppMaster s ON s.Domain = @Domain AND m.Supply = s.Supp
-            WHERE m.Domain = @Domain AND m.Status = 'I' AND m.Qty > 0 AND IFNULL(m.Location,'') = ''
+            LEFT JOIN SuppMaster s ON s.tenant_id = @TenantId AND m.Supply = s.Supp
+            WHERE m.tenant_id = @TenantId AND m.Status = 'I' AND m.Qty > 0 AND IFNULL(m.Location,'') = ''
               AND IFNULL(m.Supply,'') <> ''
             ORDER BY m.Supply
             """,
-            new List<SugarParameter> { new("@Domain", domain) });
+            new List<SugarParameter> { new("@TenantId", tenantId) });
         return list;
     }
 }