Browse Source

fix(s5): repair standard inventory tenant isolation

修复 S5 标准层(mdp_std_inventory)的租户归属与查询隔离。

架构判定:Per-Tenant Snapshot(存储)+ Global-per-Domain Acquisition(获取)。
  依据:uk_std_inventory_biz 与三个二级索引均以 tenant_id 打头;
        MdpStdFullReplace 按 tenant 删除;全部消费者按 tenant_id 查询;
        无任何模块把它当全局快照。
  而 165 一个 Domain 会被多个 DOP 租户按各自库位范围共享,
  且拉取游标持久化在 mdp_entity 上、跨租户共享,故获取侧只能全局拉一次。
  结论:一次贴源 → 按各租户库位范围投影物化,tenant_id 才真正有语义。

1. SourceDomainTenantResolver.ResolveTenantIdAsync
   - 原实现忽略 domain、恒取 SysTenant WHERE Status=1 ORDER BY Id LIMIT 1
     (即默认租户),整份 domain 快照被塞进与业务无关的租户;
     实测默认租户与 AIDOP 租户各持有一份同时间窗的全量快照。
   - 改为 ado_source_domain_tenant_map 的确定性映射,且语义收窄为
     「源落地区归属」;未登记或不唯一一律 fail closed,无任何兜底租户。

2. InventoryMdpSyncService
   - 标准层物化改为:枚举该 domain 下拥有合法库位范围的租户,逐租户物化。
   - stg→std 内联 LocationMaster 投影(tenant + Domain + Typed<>'Supp'),
     保证 ∀ 写入行:tenant_id=目标租户 ∧ location ∈ 该租户合法库位 ∧ domain 正确。
     白名单为空 → JOIN 命中 0 行 → 写 0 条,绝不退回整个 Domain。
   - 新增 TransformInventoryStdFromStgAsync:复用已完整落地的贴源批次补物化,
     不访问源库;UPSERT 语义,不删除既有标准层数据。

3. StdInventorySource.QueryPageAsync
   - 补齐与 LIVE 相同的租户库位边界(参数化 IN + 用户 Location 只能收窄 +
     空边界 fail closed),并显式排除 Supp。
     修复前 STD 会返回 Supp 库位,与 LIVE 口径不一致;
     修复后历史错误归属的脏快照也会被这一层挡住。

4. 库位白名单加载归口为 TenantLocationScopeLoader 单一实现,
   消除 LIVE/STD 三处各写一份 SQL 导致口径漂移的隐患。

验证:新增 11 个实库集成测试(AIDOP_IT=1,只读)全通过,含 Live 故障注入
回落 STD 仍受租户边界约束;非集成 1098 passed / 0 failed;
集成模式失败集合与修复前基线完全一致(11 个既有固定数据依赖失败),零新增回归。
UATAdminA 真实会话验收:无筛选 STD total=3231、17 库位、越权 0、Supp 0;
他租户库位 10000047/10000054/10000092 均 0;LIVE 3058 行 17 库位越权 0 未回归。

历史脏快照(797 的 3144 行 Supp、默认租户 46870 行)按既定边界不在本批清理,
已被查询层挡住(默认租户查询返回 0),记为维护债务。
YY968XX 15 hours ago
parent
commit
d1b5543424

+ 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.431</AssemblyVersion>
-    <FileVersion>1.0.431</FileVersion>
-    <Version>1.0.431</Version>
+    <AssemblyVersion>1.0.432</AssemblyVersion>
+    <FileVersion>1.0.432</FileVersion>
+    <Version>1.0.432</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 342 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/StdInventoryTenantIsolationTests.cs

@@ -0,0 +1,342 @@
+using System.Text.RegularExpressions;
+using Admin.NET.Plugin.AiDOP.DataPlatform;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using SqlSugar;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
+
+/// <summary>
+/// S5 标准层(mdp_std_inventory)多租户隔离实库测试。
+/// **门禁**:仅当环境变量 AIDOP_IT=1 时执行。全程只读,不写 aidopdev、不写 165。
+/// </summary>
+[Trait("Category", "Integration")]
+public class StdInventoryTenantIsolationTests
+{
+    private const long UatTenant = 838257186181189L;
+    private const long AidopTenant = 797403760988229L;
+    private const long SecondDomainTenant = 824585161322565L;
+    private const string SourceCode = "DOPDEMORQ_SQLSERVER";
+
+    private static readonly string[] KnownForeignLocations = { "10000047", "10000054", "10000092" };
+
+    private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
+
+    private readonly Xunit.Abstractions.ITestOutputHelper _out;
+    public StdInventoryTenantIsolationTests(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-std-iso-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
+        });
+    }
+
+    private static AidopInventoryOptions Opt(string sourceCode = SourceCode) =>
+        new() { SourceCode = sourceCode };
+
+    private static SourceDomainTenantResolver Resolver(ISqlSugarClient db) => new(db);
+    private static StdInventorySource Std(ISqlSugarClient db) => new(db, Options.Create(Opt()));
+
+    private static InventoryBalanceQuery Q(long tenantId, string domain, string location = null,
+        string materialCode = null, int pageSize = 200, int page = 1) => new()
+    {
+        TenantId = tenantId, Domain = domain, Location = location, MaterialCode = materialCode,
+        Page = page, PageSize = pageSize
+    };
+
+    private static async Task<List<string>> AllowedAsync(ISqlSugarClient db, long tenantId, string domain) =>
+        await TenantLocationScopeLoader.LoadAsync(db, tenantId, domain);
+
+    private static async Task<string> DomainOf(ISqlSugarClient db, long tenantId) =>
+        await Resolver(db).ResolveDomainAsync(SourceCode, tenantId);
+
+    // ===== Case 1:Tenant 归属确定性,不再是「全局第一个启用租户」 =====
+
+    [Fact]
+    public async Task Case1_SourceOwnerTenant_IsDeterministic_NotGlobalFirstEnabledTenant()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var globalFirst = (await db.Ado.SqlQueryAsync<long>(
+            "SELECT Id FROM SysTenant WHERE Status=1 ORDER BY Id LIMIT 1")).Single();
+
+        var mapped = (await db.Ado.SqlQueryAsync<long>(
+            "SELECT tenant_id FROM ado_source_domain_tenant_map WHERE source_code=@S AND domain=@D AND status=1",
+            new List<SugarParameter> { new("@S", SourceCode), new("@D", "8010") })).Single();
+
+        var resolved = await Resolver(db).ResolveTenantIdAsync(SourceCode, "8010");
+
+        Assert.Equal(mapped, resolved);
+        Assert.NotEqual(globalFirst, resolved);   // 核心:不再落到默认租户
+        _out.WriteLine($"globalFirstEnabledTenant={globalFirst} mapped={mapped} resolved={resolved}");
+    }
+
+    [Fact]
+    public async Task Case1b_UnregisteredSourceDomain_FailsClosed()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
+            () => Resolver(db).ResolveTenantIdAsync(SourceCode, "NO_SUCH_DOMAIN_9999"));
+        Assert.Contains("未登记", ex.Message);
+    }
+
+    // ===== Case 2 / 3:Domain 按租户解析(STD 侧复核)=====
+
+    [Fact]
+    public async Task Case2_3_DomainPerTenant()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var uat = await DomainOf(db, UatTenant);
+        var aidop = await DomainOf(db, AidopTenant);
+        var second = await DomainOf(db, SecondDomainTenant);
+
+        Assert.Equal("8010", uat);
+        Assert.Equal("8010", aidop);
+        Assert.Equal("2410", second);           // Case 3:824 不得被写进 8010
+        Assert.NotEqual(uat, second);
+    }
+
+    // ===== Case 4 / 5 / 6 / 8:STD 查询结果必须落在租户库位边界内 =====
+
+    [Theory]
+    [InlineData(UatTenant)]
+    [InlineData(AidopTenant)]
+    public async Task Case4_6_StdResultAlwaysInsideTenantScopeAndNeverSupp(long tenantId)
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        var domain = await DomainOf(db, tenantId);
+        var scope = TenantLocationScope.FromWhitelist(await AllowedAsync(db, tenantId, domain));
+        var std = Std(db);
+
+        var supp = (await db.Ado.SqlQueryAsync<string>(
+            "SELECT DISTINCT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND Typed='Supp' AND TRIM(Location)<>''",
+            new List<SugarParameter> { new("@T", tenantId), new("@D", domain) }))
+            .Select(x => x.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+        var seen = new List<string>();
+        for (var page = 1; page <= 50; page++)
+        {
+            var p = await std.QueryPageAsync(Q(tenantId, domain, page: page));
+            Assert.Equal(InventoryDataSources.Std, p.DataSource);
+            Assert.False(p.IsRealtime);
+            seen.AddRange(p.List.Select(x => x.Location));
+            if (page * 200 >= p.Total) break;
+        }
+
+        var locs = seen.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim())
+            .Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+
+        var violations = locs.Where(x => !scope.Contains(x)).ToList();
+        var suppHits = locs.Where(supp.Contains).ToList();
+
+        _out.WriteLine($"tenant={tenantId} domain={domain} allowed={scope.Count} stdLocs={locs.Count} " +
+                       $"violations={violations.Count} suppHits={suppHits.Count}");
+
+        Assert.Empty(violations);   // Case 4
+        Assert.Empty(suppHits);     // Case 6
+    }
+
+    [Fact]
+    public async Task Case5_8_Uat_ForeignLocation_ReturnsZero()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+        var domain = await DomainOf(db, UatTenant);
+        var std = Std(db);
+
+        foreach (var foreign in KnownForeignLocations)
+        {
+            var p = await std.QueryPageAsync(Q(UatTenant, domain, location: foreign));
+            Assert.Equal(0, p.Total);
+            Assert.Empty(p.List);
+        }
+    }
+
+    [Fact]
+    public async Task Case6b_ExplicitSuppLocation_ReturnsZero()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+        var domain = await DomainOf(db, AidopTenant);
+
+        var supp = (await db.Ado.SqlQueryAsync<string>(
+            "SELECT DISTINCT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND Typed='Supp' AND TRIM(Location)<>'' ORDER BY Location LIMIT 5",
+            new List<SugarParameter> { new("@T", AidopTenant), new("@D", domain) })).ToList();
+        Assert.NotEmpty(supp);
+
+        foreach (var loc in supp)
+        {
+            var p = await Std(db).QueryPageAsync(Q(AidopTenant, domain, location: loc));
+            Assert.Equal(0, p.Total);
+        }
+    }
+
+    // ===== Case 9:空白名单 fail closed =====
+
+    [Fact]
+    public async Task Case9_EmptyWhitelist_ReturnsZero_NeverFullDomain()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        // 默认租户在 LocationMaster 无任何库位,但库里可能仍留有历史错误归属的快照
+        var stale = (await db.Ado.SqlQueryAsync<int>(
+            "SELECT COUNT(1) FROM mdp_std_inventory WHERE tenant_id=@T",
+            new List<SugarParameter> { new("@T", 1300000000001L) })).Single();
+
+        var p = await Std(db).QueryPageAsync(Q(1300000000001L, "8010"));
+
+        _out.WriteLine($"默认租户库中残留 std 行={stale},查询返回={p.Total}");
+        Assert.Equal(0, p.Total);          // 脏快照必须被查询层挡住
+        Assert.Empty(p.List);
+    }
+
+    // ===== Case 10:SourceSystem 严格匹配 =====
+
+    [Fact]
+    public async Task Case10_SourceSystemStillStrictlyMatched()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+        var domain = await DomainOf(db, UatTenant);
+
+        // 库里若存在非正式来源(如 fixture source_system='UAT')的行,正式查询一律不得返回
+        var foreignSourceRows = (await db.Ado.SqlQueryAsync<int>(
+            "SELECT COUNT(1) FROM mdp_std_inventory WHERE tenant_id=@T AND source_system<>@S",
+            new List<SugarParameter> { new("@T", UatTenant), new("@S", SourceCode) })).Single();
+
+        var byOfficial = await Std(db).QueryPageAsync(Q(UatTenant, domain));
+        var officialRows = (await db.Ado.SqlQueryAsync<int>(
+            "SELECT COUNT(1) FROM mdp_std_inventory WHERE tenant_id=@T AND source_system=@S",
+            new List<SugarParameter> { new("@T", UatTenant), new("@S", SourceCode) })).Single();
+
+        _out.WriteLine($"UAT 非正式来源行={foreignSourceRows} 正式来源行={officialRows} 查询返回={byOfficial.Total}");
+        Assert.True(byOfficial.Total <= officialRows,
+            "STD 查询返回的行数不得超过正式 source_system 的行数(说明 source 过滤被放宽)");
+    }
+
+    // ===== 第十九节:Live 故障注入 → fallback STD,且 fallback 结果仍受租户边界约束 =====
+
+    [Fact]
+    public async Task LiveFailure_FallsBackToStd_AndStillTenantScoped()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+        var domain = await DomainOf(db, AidopTenant);
+        var scope = TenantLocationScope.FromWhitelist(await AllowedAsync(db, AidopTenant, domain));
+
+        // 故障注入:给 LIVE 一个 mdp_source 中不存在的源码 → GetScopeAsync 抛错 → 触发 fallback。
+        // 生产代码零改动;STD 侧仍用正确源码。
+        var brokenLive = new Live165InventorySource(
+            new MdpSourceScopeFactory(db), db, Options.Create(Opt("NO_SUCH_SOURCE_FOR_FAULT_INJECTION")));
+        var reader = new InventoryBalanceReader(
+            brokenLive, Std(db), Resolver(db), Options.Create(Opt()), NullLoggerFactory.Instance);
+
+        // 带筛选 → 本应走 LIVE;LIVE 失败后必须回落 STD
+        var legal = scope.Locations.First();
+        var page = await reader.QueryPageAsync(Q(AidopTenant, domain, location: legal));
+
+        Assert.Equal(InventoryDataSources.Std, page.DataSource);
+        Assert.False(page.IsRealtime);
+        Assert.All(page.List, r => Assert.True(scope.Contains(r.Location),
+            $"fallback 结果越界:{r.Location}"));
+        _out.WriteLine($"fallback dataSource={page.DataSource} total={page.Total} location={legal}");
+    }
+
+    // ===== 第二十节:写入层安全断言(对当前已物化快照做全量校验)=====
+
+    /// <summary>
+    /// 写入层安全断言。
+    /// <para>
+    /// **断言范围**:只对经修复后物化路径重建过的租户强制成立(当前为 UAT)。
+    /// 其余租户(797 / 默认租户)库中仍留有修复前写入的历史脏快照,
+    /// 按本批既定边界不做清理,只在输出中记录为维护债务 —— 它们已被查询层的
+    /// 租户库位边界挡住(见 Case4/Case9),不构成越权可见性。
+    /// </para>
+    /// </summary>
+    [Fact]
+    public async Task WriteSideInvariant_EveryStdRowInsideItsTenantScope()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+
+        // 对每个「有合法库位范围」的租户,校验其正式来源快照的每一行都落在范围内
+        var tenants = await db.Ado.SqlQueryAsync<long>(
+            """
+            SELECT DISTINCT lm.tenant_id FROM LocationMaster lm
+            JOIN SysTenant t ON t.Id=lm.tenant_id AND t.Status=1
+            WHERE IFNULL(lm.typed,'')<>'Supp' AND TRIM(lm.location)<>''
+            """);
+
+        var legacyDebt = new List<string>();
+        foreach (var tenantId in tenants)
+        {
+            var domain = await DomainOf(db, tenantId);
+            var bad = (await db.Ado.SqlQueryAsync<int>(
+                """
+                SELECT COUNT(1) FROM mdp_std_inventory s
+                WHERE s.tenant_id=@T AND s.source_system=@S
+                  AND NOT EXISTS (
+                    SELECT 1 FROM LocationMaster lm
+                     WHERE lm.tenant_id=@T AND lm.Domain=s.domain
+                       AND lm.location=s.location AND IFNULL(lm.typed,'')<>'Supp')
+                """,
+                new List<SugarParameter> { new("@T", tenantId), new("@S", SourceCode) })).Single();
+
+            var wrongDomain = (await db.Ado.SqlQueryAsync<int>(
+                "SELECT COUNT(1) FROM mdp_std_inventory WHERE tenant_id=@T AND source_system=@S AND domain<>@D",
+                new List<SugarParameter> { new("@T", tenantId), new("@S", SourceCode), new("@D", domain) })).Single();
+
+            var total = (await db.Ado.SqlQueryAsync<int>(
+                "SELECT COUNT(1) FROM mdp_std_inventory WHERE tenant_id=@T AND source_system=@S",
+                new List<SugarParameter> { new("@T", tenantId), new("@S", SourceCode) })).Single();
+
+            _out.WriteLine($"tenant={tenantId} domain={domain} stdRows={total} outOfScope={bad} wrongDomain={wrongDomain}");
+
+            if (tenantId == UatTenant)
+            {
+                // 已由修复后的物化路径重建 → 必须严格成立
+                Assert.Equal(0, bad);
+                Assert.Equal(0, wrongDomain);
+            }
+            else if (bad > 0 || wrongDomain > 0)
+            {
+                legacyDebt.Add($"tenant={tenantId} outOfScope={bad} wrongDomain={wrongDomain}");
+            }
+        }
+
+        if (legacyDebt.Count > 0)
+            _out.WriteLine("【维护债务·本批不清理】修复前写入的历史脏快照(已被查询层挡住):\n  "
+                           + string.Join("\n  ", legacyDebt));
+    }
+}

+ 37 - 15
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/SourceDomainTenantResolver.cs

@@ -16,14 +16,21 @@ public sealed class SourceDomainTenantResolver : ITransient
     public SourceDomainTenantResolver(ISqlSugarClient db) => _db = db;
 
     /// <summary>
-    /// 根据 sourceCode + domain 解析 tenant_id。
-    /// 直接查询 sys_tenant:返回第一个启用租户的 Id。
+    /// 根据 sourceCode + domain 解析**源归属租户**(source owner)。
+    /// <para>
+    /// 语义:一个 (source, domain) 组合物理上属于哪一个 DOP 租户的贴源落地区(stg)。
+    /// 唯一依据是显式登记表 <c>ado_source_domain_tenant_map</c>;未登记即 fail closed。
+    /// </para>
+    /// <para>
+    /// ⚠️ 本方法**不回答**「某条库存数据业务上属于哪个租户」——同一个 domain 可被多个租户
+    /// 按各自库位范围共享(如 8010 同时被 AIDOP 与 UATTEST_CHL 使用),
+    /// 业务归属由标准层物化时按各租户 LocationMaster 库位范围投影决定。
+    /// </para>
+    /// <para>
+    /// 历史缺陷:本方法曾忽略 domain、恒取 <c>SysTenant WHERE Status=1 ORDER BY Id LIMIT 1</c>
+    /// (即默认租户),导致整份 domain 快照被塞进与业务无关的租户。已删除该行为。
+    /// </para>
     /// </summary>
-    /// <remarks>
-    /// TODO(S5 STD Tenant Attribution Repair):本方法忽略 domain、恒取第一个启用租户,
-    /// 会把 STD 同步(InventoryMdpSyncService / InventoryReconService)的数据错误归属到默认租户。
-    /// 该缺陷与 Live 隔离无关,单独批次修复,本批不改,避免混入 STD 数据归属变更。
-    /// </remarks>
     public async Task<long> ResolveTenantIdAsync(
         string sourceCode,
         string domain,
@@ -32,19 +39,34 @@ public sealed class SourceDomainTenantResolver : ITransient
         if (string.IsNullOrWhiteSpace(sourceCode) || string.IsNullOrWhiteSpace(domain))
             throw new InvalidOperationException("sourceCode/domain 不能为空");
 
+        var code = sourceCode.Trim();
+        var dom = domain.Trim();
+
         var rows = await _db.Ado.SqlQueryAsync<MapRow>(
             """
-            SELECT Id AS TenantId
-            FROM SysTenant
-            WHERE Status = 1
-            ORDER BY Id
-            LIMIT 1
-            """);
+            SELECT DISTINCT m.tenant_id AS TenantId
+            FROM ado_source_domain_tenant_map m
+            JOIN SysTenant t ON t.Id = m.tenant_id AND t.Status = 1
+            WHERE m.source_code = @SourceCode
+              AND m.domain = @Domain
+              AND m.status = 1
+            """,
+            new List<SugarParameter>
+            {
+                new("@SourceCode", code),
+                new("@Domain", dom)
+            });
 
+        // fail closed:既不猜第一个租户,也不回落默认租户
         if (rows.Count == 0)
-            throw new InvalidOperationException("未找到启用的系统租户");
+            throw new InvalidOperationException(
+                $"源归属租户未登记:source={code}, domain={dom}(请在 ado_source_domain_tenant_map 显式登记)");
+        if (rows.Count > 1)
+            throw new InvalidOperationException(
+                $"源归属租户不唯一:source={code}, domain={dom}, tenants=[{string.Join(",", rows.Select(x => x.TenantId))}]");
         if (rows[0].TenantId <= 0)
-            throw new InvalidOperationException("系统租户 Id 非法");
+            throw new InvalidOperationException($"源归属租户 Id 非法:source={code}, domain={dom}");
+
         return rows[0].TenantId;
     }
 

+ 45 - 36
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryBalanceReader.cs

@@ -345,23 +345,10 @@ public sealed class Live165InventorySource : ITransient
         return result;
     }
 
-    private async Task<List<string>> LoadWhitelistLocationsAsync(
+    /// <summary>与 STD 共用同一口径(<see cref="TenantLocationScopeLoader"/>),禁止两处各写一份 SQL。</summary>
+    private Task<List<string>> LoadWhitelistLocationsAsync(
         long tenantId, string domain, CancellationToken ct)
-    {
-        return await _db.Ado.SqlQueryAsync<string>(
-            """
-            SELECT DISTINCT Location
-            FROM LocationMaster
-            WHERE tenant_id=@TenantId AND Domain=@Domain
-              AND IFNULL(Typed,'')<>'Supp'
-              AND TRIM(Location)<>''
-            """,
-            new List<SugarParameter>
-            {
-                new("@TenantId", tenantId),
-                new("@Domain", domain)
-            });
-    }
+        => TenantLocationScopeLoader.LoadAsync(_db, tenantId, domain, ct);
 
     private async Task<List<InventoryBalanceRow>> EnrichLocalAsync(
         long tenantId, string domain, List<RawBalanceRow> raw, CancellationToken ct)
@@ -493,7 +480,11 @@ public sealed class Live165InventorySource : ITransient
     }
 }
 
-/// <summary>标准层余额(mdp_std_inventory),查询页展示全部库位。</summary>
+/// <summary>
+/// 标准层余额(mdp_std_inventory)。
+/// **只返回当前租户 LocationMaster 白名单(Typed &lt;&gt; 'Supp')内的库位**,口径与 LIVE 一致;
+/// 白名单为空则 fail closed 返回 0 行。历史错误归属的脏快照会被这一层挡住。
+/// </summary>
 public sealed class StdInventorySource : ITransient
 {
     private readonly ISqlSugarClient _db;
@@ -505,18 +496,52 @@ public sealed class StdInventorySource : ITransient
         _opt = opt.Value;
     }
 
+    /// <summary>与 LIVE 共用同一口径(<see cref="TenantLocationScopeLoader"/>)。</summary>
+    private Task<List<string>> LoadWhitelistLocationsAsync(
+        long tenantId, string domain, CancellationToken ct)
+        => TenantLocationScopeLoader.LoadAsync(_db, tenantId, domain, ct);
+
+    /// <summary>租户库位边界为空时的 fail-closed 结果:0 行。</summary>
+    private static InventoryBalancePage EmptyStdPage(InventoryBalanceQuery query) => new()
+    {
+        Total = 0,
+        Page = query.Page,
+        PageSize = query.PageSize,
+        List = Array.Empty<InventoryBalanceRow>(),
+        GrandTotalQtyOnHand = 0m,
+        GrandUnrestricted = 0m,
+        GrandQc = 0m,
+        GrandFrozen = 0m,
+        DataSource = InventoryDataSources.Std,
+        AsOf = DateTime.Now,
+        IsRealtime = false
+    };
+
     public async Task<InventoryBalancePage> QueryPageAsync(
         InventoryBalanceQuery query,
         CancellationToken cancellationToken = default)
     {
+        // —— 安全第一层:与 LIVE 同一口径的租户库位边界(Typed<>'Supp'),
+        // 用户传入的 Location 只能在其内部收窄。历史脏快照即使还留在表里也会被这一层挡住。
+        var scope = TenantLocationScope
+            .FromWhitelist(await LoadWhitelistLocationsAsync(query.TenantId, query.Domain, cancellationToken))
+            .Intersect(query.Location);
+
+        // fail closed:空边界绝不等于整个 domain
+        if (scope.IsEmpty) return EmptyStdPage(query);
+
         const string locTypeExpr = "CASE WHEN t.typed='VMI' THEN 'VMI' WHEN t.typed='Supp' THEN 'O库' ELSE '' END";
+        var (locationClause, locationPars) = scope.BuildInClause("s.location", "loc");
         var where = new List<string>
         {
             "s.tenant_id = @TenantId",
             "s.domain = @Domain",
             "s.source_system = @SourceSystem",
+            locationClause,
             "(IFNULL(s.qty_unrestricted,0)+IFNULL(s.qty_inspection,0)+IFNULL(s.qty_frozen,0)) > 0",
-            "t.tenant_id = @TenantId"
+            "t.tenant_id = @TenantId",
+            // 第二层:JOIN 到的库位主数据本身也不得是供应商库存
+            "IFNULL(t.typed,'') <> 'Supp'"
         };
         var sourceSystem = string.IsNullOrWhiteSpace(_opt.SourceCode) ? "DOPDEMORQ_SQLSERVER" : _opt.SourceCode.Trim();
         var pars = new List<SugarParameter>
@@ -525,17 +550,13 @@ public sealed class StdInventorySource : ITransient
             new("@Domain", query.Domain),
             new("@SourceSystem", sourceSystem)
         };
+        pars.AddRange(locationPars);
 
         if (!string.IsNullOrWhiteSpace(query.Type))
         {
             where.Add($"({locTypeExpr}) = @Type");
             pars.Add(new SugarParameter("@Type", query.Type.Trim()));
         }
-        if (!string.IsNullOrWhiteSpace(query.Location))
-        {
-            where.Add("s.location = @Location");
-            pars.Add(new SugarParameter("@Location", query.Location.Trim()));
-        }
         if (!string.IsNullOrWhiteSpace(query.MaterialCode))
         {
             where.Add("s.item_num LIKE @MaterialCode");
@@ -645,19 +666,7 @@ public sealed class StdInventorySource : ITransient
         IReadOnlyList<string> itemNumbers,
         CancellationToken cancellationToken = default)
     {
-        var scopes = await _db.Ado.SqlQueryAsync<string>(
-            """
-            SELECT DISTINCT Location
-            FROM LocationMaster
-            WHERE tenant_id=@TenantId AND Domain=@Domain
-              AND IFNULL(Typed,'')<>'Supp'
-              AND TRIM(Location)<>''
-            """,
-            new List<SugarParameter>
-            {
-                new("@TenantId", tenantId),
-                new("@Domain", domain)
-            });
+        var scopes = await LoadWhitelistLocationsAsync(tenantId, domain, cancellationToken);
         if (scopes.Count == 0)
             throw new InvalidOperationException($"库存库位白名单为空:tenant={tenantId}, domain={domain}");
 

+ 13 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryInboundAdminService.cs

@@ -59,6 +59,19 @@ public sealed class InventoryInboundAdminService : IDynamicApiController, ITrans
         return await _sync.TransformTransStdFromStgAsync(ct);
     }
 
+    /// <summary>
+    /// 库存余额 stg→std 补物化:复用已完整落地的贴源批次,不访问源库。
+    /// UPSERT 语义,不删除既有标准层数据。
+    /// </summary>
+    [DisplayName("库存余额 stg→std 补物化")]
+    [HttpPost("transform-inventory-std")]
+    [ApiDescriptionSettings(Name = "S5InventoryTransformInventoryStd")]
+    public async Task<InventorySyncResult> TransformInventoryStd([FromQuery] string batchId, CancellationToken ct)
+    {
+        _logger.LogInformation("[S5InventoryInbound] transform-inventory-std requested batch={Batch}", batchId);
+        return await _sync.TransformInventoryStdFromStgAsync(batchId, ct);
+    }
+
     [DisplayName("库存日终对账(手工)")]
     [HttpPost("daily-recon")]
     [ApiDescriptionSettings(Name = "S5InventoryDailyRecon")]

+ 156 - 20
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryMdpSyncService.cs

@@ -100,11 +100,79 @@ public sealed class InventoryMdpSyncService : ITransient
         }
     }
 
+    /// <summary>
+    /// 仅 stg→std(库存余额):**不访问源库**,从一个已完整落地的贴源批次重新物化标准层。
+    /// <para>
+    /// 用途:同步链路修复后,复用既有完整 stg 批次让各租户按新的库位范围口径重新物化,
+    /// 避免为此重新全量拉取源库。
+    /// </para>
+    /// <para>
+    /// 语义为 <b>UPSERT,不做 FULL REPLACE</b>:只按业务键写入/更新本批次覆盖到的行,
+    /// 不删除任何既有标准层数据 —— 单个增量批次不代表全量,replace 会造成数据丢失。
+    /// 因此本入口<b>不负责</b>清理历史脏快照,那属于全量校准(reconcile)的职责。
+    /// </para>
+    /// </summary>
+    /// <param name="stgBatchId">贴源批次号(sync_batch_id),必须是已完整落地的批次。</param>
+    public async Task<InventorySyncResult> TransformInventoryStdFromStgAsync(
+        string stgBatchId, CancellationToken cancellationToken = default)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        if (string.IsNullOrWhiteSpace(stgBatchId)
+            || !System.Text.RegularExpressions.Regex.IsMatch(stgBatchId, @"^[A-Za-z0-9_]+$"))
+            throw new InvalidOperationException("非法贴源批次号");
+
+        var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? SourceCodeDefault : _opt.SourceCode.Trim();
+        var domain = string.IsNullOrWhiteSpace(_opt.DefaultDomain) ? "8010" : _opt.DefaultDomain.Trim();
+        var sourceTenantId = await _domainTenant.ResolveTenantIdAsync(sourceCode, domain, cancellationToken);
+        var asOf = DateTime.Now;
+
+        var stgRows = await _db.Ado.GetIntAsync(
+            """
+            SELECT COUNT(1) FROM mdp_stg_inventory
+            WHERE tenant_id=@TenantId AND source_system=@SourceSystem
+              AND source_table='LocationDetail' AND sync_batch_id=@BatchId
+            """,
+            new List<SugarParameter>
+            {
+                new("@TenantId", sourceTenantId),
+                new("@SourceSystem", sourceCode),
+                new("@BatchId", stgBatchId)
+            });
+        if (stgRows == 0)
+            throw new InvalidOperationException(
+                $"贴源批次为空或不属于源归属租户:batch={stgBatchId}, sourceTenant={sourceTenantId}");
+
+        var targetTenants = await ListInventoryScopedTenantsAsync(domain, cancellationToken);
+        var total = 0;
+        foreach (var targetTenantId in targetTenants)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            var rows = await InsertInventoryStdAsync(
+                targetTenantId, sourceTenantId, stgBatchId, asOf, sourceCode, replaceMode: false);
+            total += rows;
+            _logger.LogInformation(
+                "[InventoryMdpSync] std re-materialized from stg tenant={Tenant} domain={Domain} batch={Batch} rows={Rows}",
+                targetTenantId, domain, stgBatchId, rows);
+        }
+
+        return new InventorySyncResult
+        {
+            BatchId = stgBatchId,
+            TenantId = sourceTenantId,
+            Domain = domain,
+            InventoryStdRows = total,
+            AsOf = asOf,
+            Message = $"OK transform-inventory-std from stg (stgRows={stgRows}, tenants={targetTenants.Count})"
+        };
+    }
+
     private async Task<InventorySyncResult> RunAsync(bool bootstrap, bool reconcile, CancellationToken cancellationToken)
     {
         cancellationToken.ThrowIfCancellationRequested();
         var sourceCode = string.IsNullOrWhiteSpace(_opt.SourceCode) ? SourceCodeDefault : _opt.SourceCode.Trim();
         var domain = string.IsNullOrWhiteSpace(_opt.DefaultDomain) ? "8010" : _opt.DefaultDomain.Trim();
+        // 源归属租户:只决定贴源层(stg)落在谁名下,**不代表业务归属**;
+        // 业务归属在标准层物化时按各租户 LocationMaster 库位范围投影决定。
         var tenantId = await _domainTenant.ResolveTenantIdAsync(sourceCode, domain, cancellationToken);
         var asOf = DateTime.Now;
         var batchId = $"S5_INV_{(bootstrap ? "BOOT" : reconcile ? "RECON" : "INCR")}_{asOf:yyyyMMddHHmmss}";
@@ -167,20 +235,37 @@ public sealed class InventoryMdpSyncService : ITransient
                 locPull = await _pullDispatcher.PullAllByEntityCodeAsync(LocationEntity, incrCtx, cancellationToken, maxPages: 200);
             }
 
-            int inventoryStdRows;
-            if (bootstrap || reconcile)
-            {
-                inventoryStdRows = await MdpStdFullReplace.ReplaceAsync(
-                    _db,
-                    "mdp_std_inventory",
-                    tenantId,
-                    "source_system='DOPDEMORQ_SQLSERVER'",
-                    () => InsertInventoryStdAsync(tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: true),
-                    cancellationToken);
-            }
-            else
+            // —— 标准层按租户物化:一次贴源,逐租户按各自库位范围投影 ——
+            // 贴源层是「源+domain」维度(归属 sourceTenantId),标准层是「租户」维度。
+            // 拉取游标持久化在 mdp_entity 上、跨租户共享,故绝不能为每个租户各拉一次。
+            var targetTenants = await ListInventoryScopedTenantsAsync(domain, cancellationToken);
+            if (targetTenants.Count == 0)
+                _logger.LogWarning(
+                    "[InventoryMdpSync] domain={Domain} 无任何配置了合法库位的租户,标准层本轮不写入", domain);
+
+            var inventoryStdRows = 0;
+            foreach (var targetTenantId in targetTenants)
             {
-                inventoryStdRows = await InsertInventoryStdAsync(tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: false);
+                cancellationToken.ThrowIfCancellationRequested();
+                int rows;
+                if (bootstrap || reconcile)
+                {
+                    rows = await MdpStdFullReplace.ReplaceAsync(
+                        _db,
+                        "mdp_std_inventory",
+                        targetTenantId,
+                        "source_system='DOPDEMORQ_SQLSERVER'",
+                        () => InsertInventoryStdAsync(targetTenantId, tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: true),
+                        cancellationToken);
+                }
+                else
+                {
+                    rows = await InsertInventoryStdAsync(targetTenantId, tenantId, $"{batchId}_LOC", asOf, sourceCode, replaceMode: false);
+                }
+                inventoryStdRows += rows;
+                _logger.LogInformation(
+                    "[InventoryMdpSync] std materialized tenant={Tenant} domain={Domain} rows={Rows}",
+                    targetTenantId, domain, rows);
             }
 
             var transCtx = BuildKeysetCtx(tenantId, $"{batchId}_TRN", asOf, historyFrom, upperTrans,
@@ -273,12 +358,29 @@ public sealed class InventoryMdpSyncService : ITransient
         return (hit?.CursorText, hit?.TieText);
     }
 
+    /// <summary>
+    /// stg → std 物化:**按目标租户的合法库位范围投影**。
+    /// <para>
+    /// 贴源层(stg)是「源 + domain」维度的全量落地区,归属 <paramref name="sourceTenantId"/>;
+    /// 标准层(std)是「租户」维度的可见快照,因此这里必须内联 LocationMaster 做投影:
+    /// 只有落在目标租户自己 LocationMaster(同 Domain 且 Typed &lt;&gt; 'Supp')内的库位才写入。
+    /// </para>
+    /// <para>
+    /// 写入不变量:∀ 写入行 → tenant_id = targetTenantId
+    /// ∧ location ∈ AllowedLocations(targetTenantId) ∧ domain = 该租户 LocationMaster 的 Domain。
+    /// 租户白名单为空 → JOIN 命中 0 行 → 写 0 条(fail closed,绝不退回整个 Domain)。
+    /// </para>
+    /// <para>
+    /// tenant_id 直接取 <paramref name="targetTenantId"/> 而非 MdpJsonSql.TenantFromStg:
+    /// 贴源行的 tenant 是「源落地区归属」,不是业务归属,不能顺着传下来。
+    /// </para>
+    /// </summary>
     private async Task<int> InsertInventoryStdAsync(
-        long tenantId, string batchId, DateTime asOf, string sourceSystem, bool replaceMode)
+        long targetTenantId, long sourceTenantId, string batchId, DateTime asOf, string sourceSystem, bool replaceMode)
     {
         // replaceMode:MdpStdFullReplace 已 DELETE,直接 INSERT;
         // 增量:UPSERT 本批变化。
-        var sTenant = MdpJsonSql.TenantFromStg("s", "@TenantId");
+        var sTenant = "@TargetTenantId";
         var sql = replaceMode
             ? $"""
               INSERT INTO mdp_std_inventory
@@ -308,11 +410,11 @@ public sealed class InventoryMdpSyncService : ITransient
                 @AsOf,
                 @BatchId
               FROM mdp_stg_inventory s
-              WHERE s.tenant_id=@TenantId
+              {TenantScopeJoin}
+              WHERE s.tenant_id=@SourceTenantId
                 AND s.source_system=@SourceSystem
                 AND s.source_table='LocationDetail'
                 AND s.sync_batch_id=@BatchId
-                AND {MdpJsonSql.TenantGuard(sTenant)}
               """
             : $"""
               INSERT INTO mdp_std_inventory
@@ -342,11 +444,11 @@ public sealed class InventoryMdpSyncService : ITransient
                 @AsOf,
                 @BatchId
               FROM mdp_stg_inventory s
-              WHERE s.tenant_id=@TenantId
+              {TenantScopeJoin}
+              WHERE s.tenant_id=@SourceTenantId
                 AND s.source_system=@SourceSystem
                 AND s.source_table='LocationDetail'
                 AND s.sync_batch_id=@BatchId
-                AND {MdpJsonSql.TenantGuard(sTenant)}
               ON DUPLICATE KEY UPDATE
                 inv_status=VALUES(inv_status),
                 qty_on_hand=VALUES(qty_on_hand),
@@ -362,12 +464,46 @@ public sealed class InventoryMdpSyncService : ITransient
               """;
 
         return await _db.Ado.ExecuteCommandAsync(sql,
-            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@TargetTenantId", targetTenantId),
+            new SugarParameter("@SourceTenantId", sourceTenantId),
             new SugarParameter("@SourceSystem", sourceSystem),
             new SugarParameter("@BatchId", batchId),
             new SugarParameter("@AsOf", asOf));
     }
 
+    /// <summary>
+    /// 租户库位范围内联投影:贴源行只有落在目标租户自己的合法库位(同 Domain、Typed &lt;&gt; 'Supp')
+    /// 才允许进入标准层。这是标准层写入侧的租户安全边界。
+    /// </summary>
+    private static readonly string TenantScopeJoin =
+        $"""
+         INNER JOIN LocationMaster lm
+                 ON lm.tenant_id = @TargetTenantId
+                AND lm.Domain    = IFNULL({MdpJsonSql.Str("s", "Domain")}, '')
+                AND lm.location  = IFNULL({MdpJsonSql.Str("s", "Location")}, '')
+                AND IFNULL(lm.typed, '') <> 'Supp'
+                AND TRIM(lm.location) <> ''
+         """;
+
+    /// <summary>
+    /// 枚举该 domain 下**拥有合法库存范围**的租户:即在 LocationMaster 里配了非 Supp 库位的启用租户。
+    /// 没有库位范围的租户(如默认租户)不会被物化,标准层里不会出现它的快照。
+    /// </summary>
+    private async Task<List<long>> ListInventoryScopedTenantsAsync(string domain, CancellationToken ct)
+    {
+        return await _db.Ado.SqlQueryAsync<long>(
+            """
+            SELECT DISTINCT lm.tenant_id
+            FROM LocationMaster lm
+            JOIN SysTenant t ON t.Id = lm.tenant_id AND t.Status = 1
+            WHERE lm.Domain = @Domain
+              AND IFNULL(lm.typed,'') <> 'Supp'
+              AND TRIM(lm.location) <> ''
+            ORDER BY lm.tenant_id
+            """,
+            new List<SugarParameter> { new("@Domain", domain) });
+    }
+
     private async Task<int> UpsertInvTransStdAsync(
         long tenantId, string? batchId, DateTime asOf, DateTime historyFrom, string sourceSystem)
     {

+ 34 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/TenantLocationScope.cs

@@ -2,6 +2,40 @@ using SqlSugar;
 
 namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 
+/// <summary>
+/// 租户合法库位白名单的**唯一加载口径**。LIVE 与 STD 必须共用本方法,
+/// 否则两条链路的安全边界会各自漂移(曾出现 LIVE 排除 Supp、STD 未排除)。
+/// </summary>
+public static class TenantLocationScopeLoader
+{
+    /// <summary>
+    /// 读取指定租户在指定 Domain 下的合法库位(<c>Typed &lt;&gt; 'Supp'</c>、非空库位)。
+    /// 供应商/寄存库存(Supp)不属于本租户自有库存,一律排除。
+    /// </summary>
+    public static async Task<List<string>> LoadAsync(
+        ISqlSugarClient db, long tenantId, string domain, CancellationToken cancellationToken = default)
+    {
+        return await db.Ado.SqlQueryAsync<string>(
+            """
+            SELECT DISTINCT Location
+            FROM LocationMaster
+            WHERE tenant_id=@TenantId AND Domain=@Domain
+              AND IFNULL(Typed,'')<>'Supp'
+              AND TRIM(Location)<>''
+            """,
+            new List<SugarParameter>
+            {
+                new("@TenantId", tenantId),
+                new("@Domain", domain)
+            });
+    }
+
+    /// <summary>读取并直接构造安全边界。</summary>
+    public static async Task<TenantLocationScope> LoadScopeAsync(
+        ISqlSugarClient db, long tenantId, string domain, CancellationToken cancellationToken = default)
+        => TenantLocationScope.FromWhitelist(await LoadAsync(db, tenantId, domain, cancellationToken));
+}
+
 /// <summary>
 /// 租户库位安全边界(源库直读专用)。
 /// <para>