Эх сурвалжийг харах

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 өдөр өмнө
parent
commit
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);
                     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>(
         var mp = await _db.Ado.SqlQueryAsync<MpRow>(
             """
             """
             SELECT Location, ItemNum, IFNULL(LotSerial,'') AS LotSerial,
             SELECT Location, ItemNum, IFNULL(LotSerial,'') AS LotSerial,
                    MAX(Shelf) AS Shelf, MAX(LevelChar) AS LevelChar, MAX(Supply) AS Supply
                    MAX(Shelf) AS Shelf, MAX(LevelChar) AS LevelChar, MAX(Supply) AS Supply
             FROM MissedPrint
             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
               AND IFNULL(Location,'') <> '' AND Qty > 0
             GROUP BY Location, ItemNum, IFNULL(LotSerial,'')
             GROUP BY Location, ItemNum, IFNULL(LotSerial,'')
             """,
             """,
-            new List<SugarParameter> { new("@Domain", domain) });
+            new List<SugarParameter> { new("@TenantId", tenantId) });
         var mpMap = mp.ToDictionary(
         var mpMap = mp.ToDictionary(
             x => $"{x.Location}|{x.ItemNum}|{x.LotSerial}",
             x => $"{x.Location}|{x.ItemNum}|{x.LotSerial}",
             x => x,
             x => x,
@@ -649,11 +653,13 @@ public sealed class StdInventorySource : ITransient
                  WHERE Domain=@Domain
                  WHERE Domain=@Domain
                  GROUP BY Domain, ItemNum
                  GROUP BY Domain, ItemNum
              ) i ON i.Domain=@Domain AND n.ItemNum=i.ItemNum
              ) i ON i.Domain=@Domain AND n.ItemNum=i.ItemNum
+             -- 同上:标签富化按租户取。(tenant_id, Location, ItemNum, LotSerial) 实测无重复,
+             -- 外层 GROUP BY + MAX() 亦已把多行收敛成一行,不会放大分页。
              LEFT JOIN (
              LEFT JOIN (
                  SELECT Location, ItemNum, IFNULL(LotSerial,'') AS LotSerial,
                  SELECT Location, ItemNum, IFNULL(LotSerial,'') AS LotSerial,
                         MAX(Shelf) AS Shelf, MAX(LevelChar) AS LevelChar, MAX(Supply) AS Supply
                         MAX(Shelf) AS Shelf, MAX(LevelChar) AS LevelChar, MAX(Supply) AS Supply
                  FROM MissedPrint
                  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
                    AND IFNULL(Location,'') <> '' AND Qty > 0
                  GROUP BY Location, ItemNum, IFNULL(LotSerial,'')
                  GROUP BY Location, ItemNum, IFNULL(LotSerial,'')
              ) mp ON mp.Location=n.Location AND mp.ItemNum=n.ItemNum AND mp.LotSerial=n.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 ActionUpShelf = "IQC_WMS_UPSHELF";
     public const string QmsApiAction = "S5_IQC_RESULT_PUSH";
     public const string QmsApiAction = "S5_IQC_RESULT_PUSH";
 
 
+    /// <summary>贴源源码:与 S5 其余库存页保持一致,供 Domain 解析使用。</summary>
+    private const string SourceCode = "DOPDEMORQ_SQLSERVER";
+
     private readonly ISqlSugarClient _db;
     private readonly ISqlSugarClient _db;
     private readonly MdpOutboxEnqueueService _enqueue;
     private readonly MdpOutboxEnqueueService _enqueue;
     private readonly MdpOutboxWakeSignal _wake;
     private readonly MdpOutboxWakeSignal _wake;
+    private readonly SourceDomainTenantResolver _domainTenant;
     private readonly ILogger<IqcWmsResultPushService> _logger;
     private readonly ILogger<IqcWmsResultPushService> _logger;
 
 
     public IqcWmsResultPushService(
     public IqcWmsResultPushService(
         ISqlSugarClient db,
         ISqlSugarClient db,
         MdpOutboxEnqueueService enqueue,
         MdpOutboxEnqueueService enqueue,
         MdpOutboxWakeSignal wake,
         MdpOutboxWakeSignal wake,
+        SourceDomainTenantResolver domainTenant,
         ILogger<IqcWmsResultPushService> logger)
         ILogger<IqcWmsResultPushService> logger)
     {
     {
         _db = db;
         _db = db;
         _enqueue = enqueue;
         _enqueue = enqueue;
         _wake = wake;
         _wake = wake;
+        _domainTenant = domainTenant;
         _logger = logger;
         _logger = logger;
     }
     }
 
 
@@ -91,7 +97,19 @@ public sealed class IqcWmsResultPushService : ITransient
             return;
             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 qcNbr = string.IsNullOrWhiteSpace(bill.ApplyBillNo) ? bill.BillNo : bill.ApplyBillNo!;
         var userNo = string.IsNullOrWhiteSpace(bill.Inspector) ? "AIDOP" : bill.Inspector!;
         var userNo = string.IsNullOrWhiteSpace(bill.Inspector) ? "AIDOP" : bill.Inspector!;
         var now = DateTime.Now;
         var now = DateTime.Now;
@@ -102,14 +120,17 @@ public sealed class IqcWmsResultPushService : ITransient
         // 入队前预检:本库箱码(已与 165 同步)
         // 入队前预检:本库箱码(已与 165 同步)
         var barcodes = await _db.Ado.SqlQueryAsync<BarcodeRow>(
         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
             FROM MissedPrint
-            WHERE IFNULL(Domain,'') IN ('', @Domain)
+            WHERE tenant_id = @TenantId
               AND RctNbr = @Receiver AND ItemNum = @ItemNum
               AND RctNbr = @Receiver AND ItemNum = @ItemNum
               AND IFNULL(Status,'') <> 'C'
               AND IFNULL(Status,'') <> 'C'
               AND IFNULL(Type,'') <> 'Card'
               AND IFNULL(Type,'') <> 'Card'
             """,
             """,
-            new SugarParameter("@Domain", domain),
+            new SugarParameter("@TenantId", tenantId),
             new SugarParameter("@Receiver", entry.Receiver),
             new SugarParameter("@Receiver", entry.Receiver),
             new SugarParameter("@ItemNum", entry.ItemNum));
             new SugarParameter("@ItemNum", entry.ItemNum));
 
 
@@ -125,10 +146,10 @@ public sealed class IqcWmsResultPushService : ITransient
             """
             """
             SELECT SUM(IFNULL(QtyReceived,0))
             SELECT SUM(IFNULL(QtyReceived,0))
             FROM PurOrdRctDetail
             FROM PurOrdRctDetail
-            WHERE IFNULL(Domain,'') IN ('', @Domain)
+            WHERE tenant_id = @TenantId
               AND Receiver = @Receiver AND ItemNum = @ItemNum
               AND Receiver = @Receiver AND ItemNum = @ItemNum
             """,
             """,
-            new SugarParameter("@Domain", domain),
+            new SugarParameter("@TenantId", tenantId),
             new SugarParameter("@Receiver", entry.Receiver),
             new SugarParameter("@Receiver", entry.Receiver),
             new SugarParameter("@ItemNum", entry.ItemNum)) ?? 0m;
             new SugarParameter("@ItemNum", entry.ItemNum)) ?? 0m;
 
 
@@ -328,12 +349,12 @@ public sealed class IqcWmsResultPushService : ITransient
             SELECT SUM(IFNULL(d.ReceiptQty, 0))
             SELECT SUM(IFNULL(d.ReceiptQty, 0))
             FROM PurOrdRctDetail r
             FROM PurOrdRctDetail r
             INNER JOIN PurOrdDetail d
             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
              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
               AND r.Receiver = @Receiver AND r.ItemNum = @ItemNum
             """,
             """,
-            new SugarParameter("@Domain", domain),
+            new SugarParameter("@TenantId", tenantId),
             new SugarParameter("@Receiver", entry.Receiver!),
             new SugarParameter("@Receiver", entry.Receiver!),
             new SugarParameter("@ItemNum", entry.ItemNum!)) ?? 0m;
             new SugarParameter("@ItemNum", entry.ItemNum!)) ?? 0m;
         if (receiptQty > 0)
         if (receiptQty > 0)
@@ -349,14 +370,14 @@ public sealed class IqcWmsResultPushService : ITransient
         {
         {
             passShelf = await _db.Ado.SqlQuerySingleAsync<string>(
             passShelf = await _db.Ado.SqlQuerySingleAsync<string>(
                 """
                 """
+                -- (tenant_id, ItemNum) 唯一;原写法跨租户命中后 LIMIT 1 会取到别的租户的默认库位
                 SELECT Location
                 SELECT Location
                 FROM ItemMaster
                 FROM ItemMaster
-                WHERE ItemNum = @ItemNum
-                  AND IFNULL(Domain,'') IN ('', @Domain)
+                WHERE tenant_id = @TenantId AND ItemNum = @ItemNum
                 LIMIT 1
                 LIMIT 1
                 """,
                 """,
                 new SugarParameter("@ItemNum", entry.ItemNum!),
                 new SugarParameter("@ItemNum", entry.ItemNum!),
-                new SugarParameter("@Domain", domain));
+                new SugarParameter("@TenantId", tenantId));
             if (string.IsNullOrWhiteSpace(passShelf))
             if (string.IsNullOrWhiteSpace(passShelf))
             {
             {
                 _logger.LogWarning(
                 _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 取检验状态,按
 /// 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),
 /// Supply/PurOrd/PurLine/ShipperNbr/ItemNum/LotSerial 聚合(MAX Shelf/SUM Qty/MAX CheckNo/MAX FINSPECTSTATUS),
 /// 再 LEFT JOIN ItemMaster(描述/单位)、SuppMaster(简称),clfs='3' 追加“-挑选使用”。
 /// 再 LEFT JOIN ItemMaster(描述/单位)、SuppMaster(简称),clfs='3' 追加“-挑选使用”。
-/// Domain 由 GeneralizedCodeMaster(SystemConfig/CompanyCode) 解析,默认 8010。
+/// 租户边界取 MissedPrint.tenant_id(正式租户列,已由生成标签写入 + 1.0.442 回填补齐)。
+/// Domain **不再**参与 MissedPrint 及其主数据关联的过滤,理由见 GetList 内注释。
 ///
 ///
 /// 本服务仅 SELECT:无新增/编辑/删除/库存事务/状态流转;MissedPrint 无 tenant_id,不做租户过滤。
 /// 本服务仅 SELECT:无新增/编辑/删除/库存事务/状态流转;MissedPrint 无 tenant_id,不做租户过滤。
 /// </summary>
 /// </summary>
@@ -21,19 +22,13 @@ public class PendingInspectionService : IDynamicApiController, ITransient
     /// <summary>主/系统租户哨兵:超管未选择目标租户时其 JWT TenantId 即此值,拒绝作为业务租户。</summary>
     /// <summary>主/系统租户哨兵:超管未选择目标租户时其 JWT TenantId 即此值,拒绝作为业务租户。</summary>
     private const long MainTenantId = 1300000000001L;
     private const long MainTenantId = 1300000000001L;
 
 
-    /// <summary>贴源源码:与 S5 其余库存页保持一致,供 Domain 解析使用。</summary>
-    private const string SourceCode = "DOPDEMORQ_SQLSERVER";
-
     private readonly ISqlSugarClient _db;
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
     private readonly UserManager _userManager;
-    private readonly SourceDomainTenantResolver _domainTenant;
 
 
-    public PendingInspectionService(
-        ISqlSugarClient db, UserManager userManager, SourceDomainTenantResolver domainTenant)
+    public PendingInspectionService(ISqlSugarClient db, UserManager userManager)
     {
     {
         _db = db;
         _db = db;
         _userManager = userManager;
         _userManager = userManager;
-        _domainTenant = domainTenant;
     }
     }
 
 
     /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token(&lt;=0) 或超管未选主租户 → 拒绝;不读前端 tenantId、无默认回退。</summary>
     /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token(&lt;=0) 或超管未选主租户 → 拒绝;不读前端 tenantId、无默认回退。</summary>
@@ -47,28 +42,6 @@ public class PendingInspectionService : IDynamicApiController, ITransient
         return tid;
         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>
     /// 暂收在检列表(只读分页查询)。
     /// 暂收在检列表(只读分页查询)。
     /// </summary>
     /// </summary>
@@ -80,24 +53,27 @@ public class PendingInspectionService : IDynamicApiController, ITransient
         var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
         var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
         var offset = (page - 1) * 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>
         var where = new List<string>
         {
         {
-            "m.Domain = @Domain",
+            "m.tenant_id = @TenantId",
             "m.Status = 'I'",
             "m.Status = 'I'",
             "m.Qty > 0",
             "m.Qty > 0",
             "IFNULL(m.Location,'') = ''",
             "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))
         if (!string.IsNullOrWhiteSpace(input.DemandNo))
         {
         {
@@ -169,8 +145,11 @@ public class PendingInspectionService : IDynamicApiController, ITransient
                 t.Supply     AS Supplier,
                 t.Supply     AS Supplier,
                 s.SortName   AS SupplierShortName
                 s.SortName   AS SupplierShortName
             FROM ({groupedInner}) t
             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
             ORDER BY t.Supply, t.PurOrd, t.PurLine
             LIMIT {pageSize} OFFSET {offset}
             LIMIT {pageSize} OFFSET {offset}
             """,
             """,
@@ -206,19 +185,19 @@ public class PendingInspectionService : IDynamicApiController, ITransient
     [HttpGet("supplier-options")]
     [HttpGet("supplier-options")]
     public async Task<object> GetSupplierOptions()
     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>(
         var list = await _db.Ado.SqlQueryAsync<PendingInspectionSupplierOption>(
             """
             """
             SELECT DISTINCT m.Supply AS Val, s.SortName AS Label
             SELECT DISTINCT m.Supply AS Val, s.SortName AS Label
             FROM MissedPrint m
             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,'') <> ''
               AND IFNULL(m.Supply,'') <> ''
             ORDER BY m.Supply
             ORDER BY m.Supply
             """,
             """,
-            new List<SugarParameter> { new("@Domain", domain) });
+            new List<SugarParameter> { new("@TenantId", tenantId) });
         return list;
         return list;
     }
     }
 }
 }