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

fix(s5): align inventory query ui and pagination

S5 库存查询页面收口:告警语义 · 分页稳定性 · 库位下拉口径。

1. 数据来源三态,消除误报告警
   原前端 dataSource==='STD' 即显示「165 实时库暂不可用」,但无选择性筛选时
   后端按策略根本不会去查 165,属误报。
   后端 InventoryBalancePage 新增 FallbackFromLive,仅在「尝试实时源失败后降级」
   的两条 catch 分支置 true;StockQueryService 透出该字段。
   前端引入 sourceState:LIVE / STD_NORMAL / STD_FALLBACK,
   仅 STD_FALLBACK 显示「165 实时库存暂不可用」,STD_NORMAL 显示中性快照说明。

2. STD 分页稳定性(total=3231 / fetched=3232 → 3231/3231/3231)
   实测根因有两个:
   a) LEFT JOIN ItemMaster 行放大——Domain 8010 下有 151 个重复 ItemNum,
      total 按 baseQuery 算,分页 SELECT 经 JOIN 后多出行。
      改为与同查询中 MissedPrint 相同的处理:先按 (Domain,ItemNum) 收敛再关联。
   b) ORDER BY location,item_num,lot_serial 非唯一,OFFSET 分页在并列行上
      会跨页重复/漏行。baseQuery 补出 s.id,排序末尾加 n.Id 作唯一 tiebreaker,
      业务排序顺序不变。

3. 库位下拉与查询层同口径
   GetLocationOptions 原先不排除 Supp,用户能选到但查询恒 0 条。
   在 TenantLocationScopeLoader 内把合法库位谓词抽成单一常量 ScopePredicate,
   新增 LoadOptionsAsync 复用同一谓词并去重,GetLocationOptions 改为调用它。
   实测 797 租户下拉由 80 项收敛为 27 项,53 个 Supp 库位全部排除;UAT 保持 18 项。

另含 1.0.435.sql:修复 1.0.433 引入的角色授权回归。
  1.0.433 把 6 个按钮权限挂在「库存查询」下,该菜单原为叶子;
  SysMenuService.cs:468-486 会把「有子节点但未全部持有」的菜单排除出已勾选,
  且其子节点全未勾选也不构成半选,导致管理员下次保存角色时该菜单被
  GrantRoleMenu 全量覆盖删除 —— UATAdminA 打开库存查询页 404。
  修复:新建隐藏管理目录承载这 6 个按钮,「库存查询」恢复为叶子;
  并精确恢复 UAT_FUNCTION_USER 丢失的该菜单授权。不给任何业务角色新增权限。

验证:新增 12 个实库集成测试全通过(分页 3 种 pageSize、重复 3 轮一致、
逐库位稳定、下拉与查询层集合相等且无 Supp、三态语义含 LIVE 故障注入);
非集成 1110 passed / 0 failed;集成模式失败集合与基线一致的 11 个既有失败,零新增。
前端 vite build 通过,vue-tsc 581 ≤ 基线 597 且本批文件 0 错。
UATAdminA 真实会话五步冒烟:STD 中性提示 / 下拉 18 项 / 3231=3231=3231 /
LIVE_165 无告警 / 注入 fallbackFromLive 后正确显示实时库不可用告警。
YY968XX 11 часов назад
Родитель
Сommit
3b0697244e

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.344",
+	"version": "2.4.345",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 6 - 0
Web/src/views/aidop/s5/api/stockQuery.ts

@@ -13,6 +13,12 @@ export interface StockQueryPaged<T> {
 	dataSource?: string;
 	asOf?: string;
 	isRealtime?: boolean;
+	/**
+	 * 是否为「尝试实时库失败后降级到快照」。
+	 * 注意:dataSource==='STD' 有两种语义——无筛选时按策略本就走快照(此值 false),
+	 * 只有本值为 true 才代表实时库真的不可用。
+	 */
+	fallbackFromLive?: boolean;
 }
 
 export interface StockQueryRow {

+ 27 - 3
Web/src/views/aidop/s5/inventory/stockQueryList.vue

@@ -5,13 +5,23 @@
 			<span class="ml16">数据时点:{{ fmtDateTime(meta.asOf) || '-' }}</span>
 			<span v-if="meta.isRealtime" class="ml16 ok">实时</span>
 		</div>
+		<!-- 三态:LIVE(无提示)/ STD_NORMAL(中性说明)/ STD_FALLBACK(真故障告警)。
+		     禁止再用 dataSource==='STD' 直接等价于「165 不可用」——无筛选时后端根本不会去查 165。 -->
 		<el-alert
-			v-if="meta.dataSource === 'STD'"
+			v-if="sourceState === 'STD_FALLBACK'"
 			type="warning"
 			show-icon
 			:closable="false"
 			class="mb12"
-			title="165 实时库暂不可用,当前为非实时快照"
+			title="165 实时库存暂不可用,当前显示非实时快照"
+		/>
+		<el-alert
+			v-else-if="sourceState === 'STD_NORMAL'"
+			type="info"
+			show-icon
+			:closable="false"
+			class="mb12"
+			title="当前显示库存快照;输入物料编码、批次号或库位后可查询实时库存"
 		/>
 		<el-form :inline="true" :model="query" class="mb12" @submit.prevent>
 			<el-form-item label="类型">
@@ -115,7 +125,19 @@ const loading = ref(false);
 const rows = ref<StockQueryRow[]>([]);
 const total = ref(0);
 const grand = reactive({ unrestricted: 0, qc: 0, frozen: 0 });
-const meta = reactive({ dataSource: '', asOf: '', isRealtime: false });
+const meta = reactive({ dataSource: '', asOf: '', isRealtime: false, fallbackFromLive: false });
+
+/**
+ * 数据来源三态:
+ *   LIVE         实时库查询成功
+ *   STD_FALLBACK 尝试实时库失败后降级到快照 —— 只有这种情况才提示「实时库不可用」
+ *   STD_NORMAL   未做选择性筛选,按策略本就走快照(后端根本不会去查实时库)
+ */
+const sourceState = computed<'LIVE' | 'STD_FALLBACK' | 'STD_NORMAL' | ''>(() => {
+	if (!meta.dataSource) return '';
+	if (meta.dataSource !== 'STD') return 'LIVE';
+	return meta.fallbackFromLive ? 'STD_FALLBACK' : 'STD_NORMAL';
+});
 const typeOptions = ref<StockQueryTypeOption[]>([]);
 const locationOptions = ref<StockQueryLocationOption[]>([]);
 
@@ -154,6 +176,7 @@ async function loadList() {
 		meta.dataSource = data.dataSource || '';
 		meta.asOf = data.asOf || '';
 		meta.isRealtime = !!data.isRealtime;
+		meta.fallbackFromLive = !!data.fallbackFromLive;
 	} catch (e: any) {
 		rows.value = [];
 		total.value = 0;
@@ -163,6 +186,7 @@ async function loadList() {
 		meta.dataSource = '';
 		meta.asOf = '';
 		meta.isRealtime = false;
+		meta.fallbackFromLive = false;
 		ElMessage.error(e?.message || '加载库存查询列表失败');
 	} finally {
 		loading.value = false;

+ 9 - 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.434</AssemblyVersion>
-    <FileVersion>1.0.434</FileVersion>
-    <Version>1.0.434</Version>
+    <AssemblyVersion>1.0.435</AssemblyVersion>
+    <FileVersion>1.0.435</FileVersion>
+    <Version>1.0.435</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -607,6 +607,12 @@
     <None Update="UpdateScripts\1.0.434.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.435.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.435.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 100 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.435.sql

@@ -0,0 +1,100 @@
+-- ============================================================================
+-- 1.0.435:修复 1.0.433 引入的角色授权回归 —— 按钮权限挂载点选错导致父菜单被丢
+--
+-- 回归现象(RUNTIME VERIFIED):
+--   UATAdminA 打开 /aidop/s5/inventory/stock-query 返回 404;
+--   其菜单树里「库存查询」消失,同级其余 5 个页面均在。
+--   实测 UAT_FUNCTION_USER(838258976469061) 的 SysRoleMenu 已无 MenuId=1329015030003,
+--   而对照租户 B 的同名角色仍有。
+--
+-- 根因(CODE VERIFIED,SysMenuService.cs:468-486 角色授权树「已勾选」计算):
+--     foreach (var id in menuIds) {
+--         if (是按钮) continue;
+--         var children = menuList.ToChildList(..., id, false);
+--         if (children.Count == 0 || children.All(u => menuIds.Contains(u.Id))) continue;
+--         exceptList.Add(id);      // 有子节点但未全部持有 → 从「已勾选」中排除
+--     }
+--   1.0.433 把 6 个按钮权限挂在「库存查询」(1329015030003) 下。该菜单原本是**叶子**
+--   (children.Count==0,永不排除);挂上按钮后,业务角色一个按钮都不持有
+--   → children.All(...) 为假 → 「库存查询」被排除出已勾选集合。
+--   前端 editRole.vue:118-120 提交的是 checkedKeys ∪ halfCheckedKeys:
+--   它既不在已勾选(被排除),其子节点又全未勾选(不构成半选)
+--   → 管理员下一次保存该角色时它被 GrantRoleMenu 全量覆盖删除。
+--   祖先目录(库存数据 / S5 物料仓储 / Ai-DOP)因仍有其他已勾选子节点而处于**半选**,
+--   得以幸存,所以只丢了这一条。
+--   注:该「已勾选」计算用 GetSugarQueryableAndTenantId(0),**不按租户过滤**,
+--   因此 1.0.433 只授权给 AIDOP 租户的按钮,同样会影响 UAT 租户角色的勾选态。
+--
+-- 修复方案:
+--   ① 新建**隐藏管理目录**「库存冷链入站(管理)」挂在 S5 物料仓储下,
+--      6 个按钮权限改挂到它下面 → 「库存查询」恢复为叶子,永不再被排除。
+--      新目录不授予任何业务角色 → 不在任何业务角色的 menuIds 中 → 排除循环不会遍历到它。
+--      其父节点 S5 物料仓储 仍有多个已授权子节点(实测「持有 S5 目录却不持有其任何子节点」
+--      的角色数 = 0),故各角色对该目录仍为半选,不受影响。
+--   ② 恢复 UAT_FUNCTION_USER 丢失的「库存查询」授权(精确到单租户单角色单菜单)。
+--
+-- 明确不做:不改任何按钮权限的 Permission 名、不改租户级授权范围、
+--   不给任何业务角色新增管理权限、不使用 GrantMenu 全量覆盖、不按 RoleCode 跨租户处理。
+--
+-- 幂等:INSERT 带 NOT EXISTS;UPDATE 带目标状态限定,重复执行影响行数为 0。
+-- ============================================================================
+
+-- ---------------------------------------------------------------------------
+-- 1. 新建隐藏管理目录(按钮权限的专用挂载点)
+-- ---------------------------------------------------------------------------
+INSERT INTO SysMenu (Id, Pid, Type, Name, Title, Icon,
+                     IsIframe, IsHide, IsKeepAlive, IsAffix, OrderNo, Status, Remark, CreateTime)
+SELECT 1329015030100, 1321000006000, 1, 'aidopS5InvInboundAdmin', '库存冷链入站(管理)', 'ele-Menu',
+       0, 1, 1, 0, 900, 1,
+       'S5 冷链入站管理权限点的专用挂载目录(隐藏)。刻意独立成节点:SysMenuService 的角色授权树会把「有子节点但未全部持有」的菜单排除出已勾选,若把按钮挂到业务页面下,会导致该页面在下次角色保存时被删除(见 1.0.433 回归)。',
+       NOW()
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM SysMenu WHERE Id = 1329015030100);
+
+-- ---------------------------------------------------------------------------
+-- 2. 6 个按钮权限改挂到该隐藏目录(原挂在「库存查询」1329015030003 下)
+-- ---------------------------------------------------------------------------
+UPDATE SysMenu
+   SET Pid = 1329015030100, UpdateTime = NOW()
+ WHERE Type = 3
+   AND Pid = 1329015030003
+   AND Permission IN (
+       's5InventoryInbound:bootstrap',
+       's5InventoryInbound:run-once',
+       's5InventoryInbound:reconcile',
+       's5InventoryInbound:transform-std',
+       's5InventoryInbound:transform-inventory-std',
+       's5InventoryInbound:daily-recon');
+
+-- ---------------------------------------------------------------------------
+-- 3. 管理租户(AIDOP)对该隐藏目录的租户级授权(与 1.0.433 的按钮授权保持同一租户)
+-- ---------------------------------------------------------------------------
+INSERT INTO SysTenantMenu (Id, TenantId, MenuId)
+SELECT (SELECT IFNULL(MAX(Id), 0) + 1 FROM SysTenantMenu tm2),
+       t.Id, 1329015030100
+FROM SysTenant t
+JOIN SysOrg o ON o.Id = t.OrgId
+WHERE o.Code = 'AIDOP' AND t.Status = 1
+  AND EXISTS (SELECT 1 FROM SysMenu WHERE Id = 1329015030100)
+  AND NOT EXISTS (
+      SELECT 1 FROM SysTenantMenu tm
+       WHERE tm.TenantId = t.Id AND tm.MenuId = 1329015030100)
+LIMIT 1;
+
+-- ---------------------------------------------------------------------------
+-- 4. 恢复被回归删除的「库存查询」角色授权(精确单租户单角色单菜单)
+--    TenantId 838257186181189 / RoleId 838258976469061 / MenuId 1329015030003
+-- ---------------------------------------------------------------------------
+INSERT INTO SysRoleMenu (Id, RoleId, MenuId)
+SELECT (SELECT IFNULL(MAX(Id), 0) + 1 FROM SysRoleMenu rm2), r.Id, m.Id
+FROM SysRole r
+JOIN SysMenu m ON m.Id = 1329015030003 AND m.Type = 2 AND m.Status = 1
+WHERE r.Id = 838258976469061
+  AND r.TenantId = 838257186181189
+  AND r.Status = 1
+  AND EXISTS (
+      SELECT 1 FROM SysTenantMenu t
+       WHERE t.TenantId = r.TenantId AND t.MenuId = m.Id)
+  AND NOT EXISTS (
+      SELECT 1 FROM SysRoleMenu x
+       WHERE x.RoleId = r.Id AND x.MenuId = m.Id);

+ 65 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.435.verify.sql

@@ -0,0 +1,65 @@
+-- ============================================================================
+-- 1.0.435 verify:按钮权限已移出业务页面、且被回归删除的授权已恢复。
+--
+-- 约定:每条语句取「首行首列」当布尔断言;返回 0 行会判假并中断启动,故一律返回标量。
+-- 口径:全部钉死精确 Id / TenantId / RoleId,不使用跨租户 RoleCode COUNT。
+-- 环境容错:非本项目夹具库上主脚本为空操作,断言同步短路为真。
+-- ============================================================================
+
+-- 1) 「库存查询」必须重新成为叶子(其下不得再挂任何子节点)
+--    这是本次回归的直接触发条件:一旦它有未被持有的子节点,就会在角色保存时被删除。
+SELECT IF(
+  (SELECT COUNT(*) FROM SysMenu WHERE Id = 1329015030003) = 0,
+  1,
+  (SELECT COUNT(*) = 0 FROM SysMenu WHERE Pid = 1329015030003)
+) AS ok_stock_query_is_leaf_again;
+
+-- 2) 6 个按钮权限全部已挂到隐藏管理目录下
+SELECT IF(
+  (SELECT COUNT(*) FROM SysMenu WHERE Id = 1329015030100) = 0,
+  1,
+  (SELECT COUNT(*) = 6 FROM SysMenu
+    WHERE Type = 3 AND Pid = 1329015030100
+      AND Permission LIKE 's5InventoryInbound:%')
+) AS ok_buttons_moved_under_admin_dir;
+
+-- 3) 隐藏管理目录本身:Type=1 / 隐藏 / 启用 / 挂在 S5 物料仓储下
+SELECT IF(
+  (SELECT COUNT(*) FROM SysMenu WHERE Id = 1329015030100) = 0,
+  1,
+  (SELECT COUNT(*) = 1 FROM SysMenu
+    WHERE Id = 1329015030100 AND Type = 1 AND IsHide = 1 AND Status = 1 AND Pid = 1321000006000)
+) AS ok_admin_dir_shape;
+
+-- 4) 该隐藏目录不得授予任何角色(授予业务角色会把管理能力泄漏出去)
+SELECT (
+  SELECT COUNT(*) = 0 FROM SysRoleMenu WHERE MenuId = 1329015030100
+) AS ok_admin_dir_not_granted_to_any_role;
+
+-- 5) UAT_FUNCTION_USER 的「库存查询」授权已恢复,且精确 1 条
+SELECT IF(
+  (SELECT COUNT(*) FROM SysRole WHERE Id = 838258976469061 AND TenantId = 838257186181189) = 0,
+  1,
+  (SELECT COUNT(*) = 1
+     FROM SysRoleMenu rm
+     JOIN SysRole r ON r.Id = rm.RoleId
+    WHERE rm.RoleId = 838258976469061
+      AND rm.MenuId = 1329015030003
+      AND r.TenantId = 838257186181189)
+) AS ok_uat_stock_query_restored;
+
+-- 6) 权限点仍未扩散到任何角色(1.0.433 的安全不变量不得因本次搬迁被破坏)
+SELECT (
+  SELECT COUNT(*) = 0
+    FROM SysRoleMenu rm
+    JOIN SysMenu m ON m.Id = rm.MenuId
+   WHERE m.Type = 3 AND m.Permission LIKE 's5InventoryInbound:%'
+) AS ok_no_role_holds_inbound_permission;
+
+-- 7) 6 个权限点仍然登记且启用(搬迁不得影响 JwtHandler 的命中)
+SELECT IF(
+  (SELECT COUNT(*) FROM SysMenu WHERE Permission LIKE 's5InventoryInbound:%') = 0,
+  1,
+  (SELECT COUNT(*) = 6 FROM SysMenu
+    WHERE Type = 3 AND Status = 1 AND Permission LIKE 's5InventoryInbound:%')
+) AS ok_six_permissions_still_registered;

+ 272 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/StockQueryUiAndPaginationTests.cs

@@ -0,0 +1,272 @@
+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 库存查询页面收口:分页稳定性 · 库位下拉口径 · 数据来源三态语义。
+/// **门禁**:仅当环境变量 AIDOP_IT=1 时执行。全程只读。
+/// </summary>
+[Trait("Category", "Integration")]
+public class StockQueryUiAndPaginationTests
+{
+    private const long UatTenant = 838257186181189L;
+    private const long AidopTenant = 797403760988229L;
+    private const string SourceCode = "DOPDEMORQ_SQLSERVER";
+
+    private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
+
+    private readonly Xunit.Abstractions.ITestOutputHelper _out;
+    public StockQueryUiAndPaginationTests(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-stockquery-ui-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 Live165InventorySource Live(ISqlSugarClient db, string sourceCode = SourceCode) =>
+        new(new MdpSourceScopeFactory(db), db, Options.Create(Opt(sourceCode)));
+
+    private static InventoryBalanceQuery Q(long tenantId, string domain, int page, int pageSize,
+        string location = null, string materialCode = null) => new()
+    {
+        TenantId = tenantId, Domain = domain, Location = location, MaterialCode = materialCode,
+        Page = page, PageSize = pageSize
+    };
+
+    private static string RowKey(InventoryBalanceRow r) =>
+        $"{r.Location}|{r.MaterialCode}|{r.BatchNo}|{r.UnrestrictedStock}|{r.QcStock}|{r.FrozenStock}|{r.QtyOnHand}";
+
+    /// <summary>按 pageSize 抓完整分页,返回 (total, 抓到的全部行)。</summary>
+    private static async Task<(int Total, List<InventoryBalanceRow> Rows)> FetchAllAsync(
+        StdInventorySource std, InventoryBalanceQuery seed, int pageSize)
+    {
+        var rows = new List<InventoryBalanceRow>();
+        var total = 0;
+        for (var page = 1; page <= 500; page++)
+        {
+            var p = await std.QueryPageAsync(Q(seed.TenantId, seed.Domain, page, pageSize, seed.Location, seed.MaterialCode));
+            total = p.Total;
+            rows.AddRange(p.List);
+            if (page * pageSize >= p.Total) break;
+        }
+        return (total, rows);
+    }
+
+    // ===== 任务 2 · Case 1 / 2:抓取行数 == total,且去重后 == total =====
+
+    [Fact]
+    public async Task Case1_2_StdPagination_FetchedAndUniqueEqualTotal()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+        var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
+
+        var (total, rows) = await FetchAllAsync(Std(db), Q(UatTenant, domain, 1, 50), 50);
+        var unique = rows.Select(RowKey).Distinct().Count();
+
+        _out.WriteLine($"pageSize=50 total={total} fetched={rows.Count} unique={unique}");
+        Assert.True(total > 0, "STD 应有数据,否则本用例无意义");
+        Assert.Equal(total, rows.Count);
+        Assert.Equal(total, unique);
+    }
+
+    [Theory]
+    [InlineData(10)]
+    [InlineData(50)]
+    [InlineData(200)]
+    public async Task Case1_StdPagination_StableAcrossPageSizes(int pageSize)
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+        var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
+
+        var (total, rows) = await FetchAllAsync(Std(db), Q(UatTenant, domain, 1, pageSize), pageSize);
+        _out.WriteLine($"pageSize={pageSize} total={total} fetched={rows.Count}");
+        Assert.Equal(total, rows.Count);
+    }
+
+    // ===== 任务 2 · Case 3:重复 3 次结果集合一致 =====
+
+    [Fact]
+    public async Task Case3_StdPagination_RepeatableAcrossThreeRuns()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+        var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
+
+        List<string> first = null;
+        for (var run = 1; run <= 3; run++)
+        {
+            var (_, rows) = await FetchAllAsync(Std(db), Q(UatTenant, domain, 1, 50), 50);
+            var keys = rows.Select(RowKey).OrderBy(x => x, StringComparer.Ordinal).ToList();
+            if (first is null) first = keys;
+            else Assert.Equal(first, keys);
+        }
+        _out.WriteLine($"3 轮抓取结果集合一致,rows={first!.Count}");
+    }
+
+    // ===== 任务 2 · Case 4:合法 Location 筛选下同样稳定 =====
+
+    [Fact]
+    public async Task Case4_StdPagination_StableUnderLocationFilter()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+        var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
+        var allowed = await TenantLocationScopeLoader.LoadAsync(db, UatTenant, domain);
+
+        var hit = 0;
+        foreach (var loc in allowed)
+        {
+            var (total, rows) = await FetchAllAsync(Std(db), Q(UatTenant, domain, 1, 10, location: loc), 10);
+            Assert.Equal(total, rows.Count);
+            Assert.Equal(total, rows.Select(RowKey).Distinct().Count());
+            if (total > 0) hit++;
+        }
+        _out.WriteLine($"按库位逐个校验分页稳定,有数据的库位数={hit}/{allowed.Count}");
+        Assert.True(hit > 0);
+    }
+
+    // ===== 任务 3:库位下拉必须与查询层同口径(排除 Supp、去重)=====
+
+    [Theory]
+    [InlineData(UatTenant)]
+    [InlineData(AidopTenant)]
+    public async Task LocationOptions_MatchesQueryScope_AndExcludesSupp(long tenantId)
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+        var domain = await Resolver(db).ResolveDomainAsync(SourceCode, tenantId);
+
+        var options = await TenantLocationScopeLoader.LoadOptionsAsync(db, tenantId, domain);
+        var scope = await TenantLocationScopeLoader.LoadAsync(db, tenantId, domain);
+
+        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 optionVals = options.Select(x => x.Val?.Trim()).Where(x => !string.IsNullOrEmpty(x)).ToList();
+
+        _out.WriteLine($"tenant={tenantId} domain={domain} options={optionVals.Count} scope={scope.Count} suppInMaster={supp.Count}");
+
+        // ① 下拉集合 == 查询层白名单集合
+        Assert.Equal(
+            scope.Select(x => x.Trim()).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(),
+            optionVals.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList());
+        // ② 无 Supp
+        Assert.All(optionVals, v => Assert.False(supp.Contains(v!), $"Supp 库位 {v} 不得出现在下拉"));
+        // ③ 无重复
+        Assert.Equal(optionVals.Count, optionVals.Distinct(StringComparer.OrdinalIgnoreCase).Count());
+    }
+
+    [Fact]
+    public async Task LocationOptions_SuppLocationExcluded_NormalLocationPresent()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+        var domain = await Resolver(db).ResolveDomainAsync(SourceCode, AidopTenant);
+
+        var supp = (await db.Ado.SqlQueryAsync<string>(
+            "SELECT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND Typed='Supp' AND TRIM(Location)<>'' ORDER BY Location LIMIT 1",
+            new List<SugarParameter> { new("@T", AidopTenant), new("@D", domain) })).Single();
+        var normal = (await db.Ado.SqlQueryAsync<string>(
+            "SELECT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND IFNULL(Typed,'')<>'Supp' AND TRIM(Location)<>'' ORDER BY Location LIMIT 1",
+            new List<SugarParameter> { new("@T", AidopTenant), new("@D", domain) })).Single();
+
+        var vals = (await TenantLocationScopeLoader.LoadOptionsAsync(db, AidopTenant, domain))
+            .Select(x => x.Val?.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+        _out.WriteLine($"Supp 样例={supp}(应缺席)· 正常样例={normal}(应存在)");
+        Assert.DoesNotContain(supp.Trim(), vals, StringComparer.OrdinalIgnoreCase);
+        Assert.Contains(normal.Trim(), vals, StringComparer.OrdinalIgnoreCase);
+    }
+
+    // ===== 任务 1:数据来源三态语义 =====
+
+    [Fact]
+    public async Task SourceState_NormalStd_IsNotMarkedAsLiveFallback()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+        var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
+
+        var reader = new InventoryBalanceReader(
+            Live(db), Std(db), Resolver(db), Options.Create(Opt()), NullLoggerFactory.Instance);
+
+        // 无选择性筛选 → 按策略走 STD,未尝试实时源 → 不得标记为降级
+        var page = await reader.QueryPageAsync(Q(UatTenant, domain, 1, 10));
+        Assert.Equal(InventoryDataSources.Std, page.DataSource);
+        Assert.False(page.IsRealtime);
+        Assert.False(page.FallbackFromLive);
+        _out.WriteLine($"STD_NORMAL: dataSource={page.DataSource} fallbackFromLive={page.FallbackFromLive}");
+    }
+
+    [Fact]
+    public async Task SourceState_LiveSuccess_IsNotMarkedAsFallback()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+        var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
+        var allowed = await TenantLocationScopeLoader.LoadAsync(db, UatTenant, domain);
+
+        var reader = new InventoryBalanceReader(
+            Live(db), Std(db), Resolver(db), Options.Create(Opt()), NullLoggerFactory.Instance);
+
+        var page = await reader.QueryPageAsync(Q(UatTenant, domain, 1, 10, location: allowed.First()));
+        Assert.Equal(InventoryDataSources.Live165, page.DataSource);
+        Assert.True(page.IsRealtime);
+        Assert.False(page.FallbackFromLive);
+        _out.WriteLine($"LIVE: dataSource={page.DataSource} fallbackFromLive={page.FallbackFromLive}");
+    }
+
+    [Fact]
+    public async Task SourceState_LiveFailure_IsMarkedAsFallback()
+    {
+        if (!Enabled) return;
+        var db = BuildAidopdev();
+        Assert.NotNull(db);
+        var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
+        var allowed = await TenantLocationScopeLoader.LoadAsync(db, UatTenant, domain);
+
+        // 故障注入:给 LIVE 一个 mdp_source 中不存在的源码 → 必然抛错 → 走 fallback
+        var reader = new InventoryBalanceReader(
+            Live(db, "NO_SUCH_SOURCE_FOR_FAULT_INJECTION"), Std(db), Resolver(db),
+            Options.Create(Opt()), NullLoggerFactory.Instance);
+
+        var page = await reader.QueryPageAsync(Q(UatTenant, domain, 1, 10, location: allowed.First()));
+        Assert.Equal(InventoryDataSources.Std, page.DataSource);
+        Assert.False(page.IsRealtime);
+        Assert.True(page.FallbackFromLive);   // 只有这种情况前端才提示「实时库不可用」
+        _out.WriteLine($"STD_FALLBACK: dataSource={page.DataSource} fallbackFromLive={page.FallbackFromLive} total={page.Total}");
+    }
+}

+ 12 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryBalanceModels.cs

@@ -53,6 +53,18 @@ public sealed class InventoryBalancePage
     public string DataSource { get; init; } = InventoryDataSources.Std;
     public DateTime AsOf { get; init; }
     public bool IsRealtime { get; init; }
+
+    /// <summary>
+    /// 本次结果是否由「尝试实时源失败后回落标准层」产生。
+    /// <para>
+    /// 用于区分两种同为 <see cref="InventoryDataSources.Std"/> 的语义:
+    /// ① 正常快照(无选择性筛选,按策略本就不查实时源)→ false;
+    /// ② 实时源超时/异常后的降级结果 → true。
+    /// 只有 ② 才应向用户提示「实时库不可用」。
+    /// </para>
+    /// <para>由门面 <c>InventoryBalanceReader</c> 在回落分支设置,故为可写属性。</para>
+    /// </summary>
+    public bool FallbackFromLive { get; set; }
 }
 
 public sealed class InventoryTransactionQuery

+ 23 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/InventoryBalanceReader.cs

@@ -71,6 +71,10 @@ public sealed class InventoryBalanceReader : IInventoryBalanceReader, ITransient
         var mode = (_opt.BalanceMode ?? "LiveThenStd").Trim();
         var stdOnly = string.Equals(mode, "StdOnly", StringComparison.OrdinalIgnoreCase);
 
+        // 是否真的尝试过实时源。未尝试(无选择性筛选 / StdOnly)时回落标准层属正常策略,
+        // 不能对用户提示「实时库不可用」——只有尝试过且失败才算降级。
+        var liveFailed = false;
+
         if (!stdOnly && selective)
         {
             try
@@ -82,15 +86,19 @@ public sealed class InventoryBalanceReader : IInventoryBalanceReader, ITransient
             }
             catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
             {
+                liveFailed = true;
                 _logger.LogWarning("[InventoryBalanceReader] LIVE_165 超时,回落 STD tenant={Tenant}", q.TenantId);
             }
             catch (Exception ex)
             {
+                liveFailed = true;
                 _logger.LogWarning(ex, "[InventoryBalanceReader] LIVE_165 失败,回落 STD tenant={Tenant}", q.TenantId);
             }
         }
 
-        return await _std.QueryPageAsync(q, cancellationToken);
+        var std = await _std.QueryPageAsync(q, cancellationToken);
+        std.FallbackFromLive = liveFailed;
+        return std;
     }
 
     public async Task<IReadOnlyDictionary<string, decimal>> LoadAvailableQtyAsync(
@@ -591,7 +599,8 @@ public sealed class StdInventorySource : ITransient
         var whereSql = string.Join(" AND ", where);
         var baseQuery =
             $"""
-            SELECT s.location AS Location, s.item_num AS ItemNum, IFNULL(s.lot_serial,'') AS LotSerial,
+            SELECT s.id AS Id,
+                   s.location AS Location, s.item_num AS ItemNum, IFNULL(s.lot_serial,'') AS LotSerial,
                    s.qty_on_hand AS QtyOnHand, s.qty_unrestricted AS AvailStatusQty,
                    s.qty_inspection AS Assay, s.qty_frozen AS FreezeQty,
                    {locTypeExpr} AS LocType, t.descr AS LocationName, s.as_of AS AsOf
@@ -631,7 +640,15 @@ public sealed class StdInventorySource : ITransient
                  i.UM AS Unit,
                  IFNULL(mp.Supply,'') AS Supplier
              FROM ({baseQuery}) n
-             LEFT JOIN ItemMaster i ON i.Domain=@Domain AND n.ItemNum=i.ItemNum
+             -- ItemMaster 同 Domain 下存在重复 ItemNum(实测 8010 有 151 个),
+             -- 直接 LEFT JOIN 会放大行数,使分页取回行数大于 COUNT 出来的 total。
+             -- 与下方 MissedPrint 同款处理:先按业务键收敛成一行再关联。
+             LEFT JOIN (
+                 SELECT Domain, ItemNum, MAX(Descr) AS Descr, MAX(UM) AS UM
+                 FROM ItemMaster
+                 WHERE Domain=@Domain
+                 GROUP BY Domain, ItemNum
+             ) i ON i.Domain=@Domain AND n.ItemNum=i.ItemNum
              LEFT JOIN (
                  SELECT Location, ItemNum, IFNULL(LotSerial,'') AS LotSerial,
                         MAX(Shelf) AS Shelf, MAX(LevelChar) AS LevelChar, MAX(Supply) AS Supply
@@ -640,7 +657,9 @@ public sealed class StdInventorySource : ITransient
                    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
-             ORDER BY n.Location, n.ItemNum, n.LotSerial
+             -- 业务排序保持 库位/物料/批次 不变,末尾补主键作唯一 tiebreaker:
+             -- 三者组合非唯一,OFFSET 分页在并列行上会跨页重复/漏行。
+             ORDER BY n.Location, n.ItemNum, n.LotSerial, n.Id
              LIMIT {query.PageSize} OFFSET {offset}
              """, pars);
 

+ 7 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/StockQueryService.cs

@@ -110,7 +110,9 @@ public class StockQueryService : IDynamicApiController, ITransient
             grandFrozen = page.GrandFrozen,
             dataSource = page.DataSource,
             asOf = page.AsOf,
-            isRealtime = page.IsRealtime
+            isRealtime = page.IsRealtime,
+            // true 仅表示「尝试实时源失败后降级」;无筛选时按策略本就走标准层,此处为 false
+            fallbackFromLive = page.FallbackFromLive
         };
     }
 
@@ -137,14 +139,9 @@ public class StockQueryService : IDynamicApiController, ITransient
     {
         var tenantId = ResolveTenantOrThrow();
         var domain = await ResolveDomainOrThrowAsync(tenantId, ct);
-        var list = await _db.Ado.SqlQueryAsync<StockQueryLocationOption>(
-            """
-            SELECT location AS Val, descr AS Label
-            FROM LocationMaster
-            WHERE Domain = @Domain AND tenant_id = @TenantId AND IFNULL(location,'') <> ''
-            ORDER BY location
-            """,
-            new List<SugarParameter> { new("@Domain", domain), new("@TenantId", tenantId) });
-        return list;
+        // 与查询层白名单共用同一谓词(含 Typed<>'Supp'),
+        // 避免出现「下拉能选到某库位、查询却永远 0 条」的口径不一致。
+        var list = await TenantLocationScopeLoader.LoadOptionsAsync(_db, tenantId, domain, ct);
+        return list.Select(x => new StockQueryLocationOption { Val = x.Val, Label = x.Label }).ToList();
     }
 }

+ 45 - 12
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/TenantLocationScope.cs

@@ -8,6 +8,20 @@ namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 /// </summary>
 public static class TenantLocationScopeLoader
 {
+    /// <summary>
+    /// 合法库位的**唯一谓词**。查询层白名单与页面库位下拉必须共用它,
+    /// 否则会出现「下拉能选到、查询永远 0 条」的口径不一致。
+    /// </summary>
+    private const string ScopePredicate =
+        """
+        tenant_id=@TenantId AND Domain=@Domain
+          AND IFNULL(Typed,'')<>'Supp'
+          AND TRIM(Location)<>''
+        """;
+
+    private static List<SugarParameter> ScopeParameters(long tenantId, string domain) =>
+        new() { new SugarParameter("@TenantId", tenantId), new SugarParameter("@Domain", domain) };
+
     /// <summary>
     /// 读取指定租户在指定 Domain 下的合法库位(<c>Typed &lt;&gt; 'Supp'</c>、非空库位)。
     /// 供应商/寄存库存(Supp)不属于本租户自有库存,一律排除。
@@ -16,18 +30,30 @@ public static class TenantLocationScopeLoader
         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)
-            });
+            $"""
+             SELECT DISTINCT Location
+             FROM LocationMaster
+             WHERE {ScopePredicate}
+             """,
+            ScopeParameters(tenantId, domain));
+    }
+
+    /// <summary>
+    /// 页面库位下拉选项:与 <see cref="LoadAsync"/> 使用**同一谓词**,
+    /// 保证「下拉里能选到的」恒等于「查询层允许查的」。同库位多行时取一个描述。
+    /// </summary>
+    public static async Task<List<TenantLocationOption>> LoadOptionsAsync(
+        ISqlSugarClient db, long tenantId, string domain, CancellationToken cancellationToken = default)
+    {
+        return await db.Ado.SqlQueryAsync<TenantLocationOption>(
+            $"""
+             SELECT Location AS Val, MAX(Descr) AS Label
+             FROM LocationMaster
+             WHERE {ScopePredicate}
+             GROUP BY Location
+             ORDER BY Location
+             """,
+            ScopeParameters(tenantId, domain));
     }
 
     /// <summary>读取并直接构造安全边界。</summary>
@@ -36,6 +62,13 @@ public static class TenantLocationScopeLoader
         => TenantLocationScope.FromWhitelist(await LoadAsync(db, tenantId, domain, cancellationToken));
 }
 
+/// <summary>库位下拉选项(编码 + 名称)。</summary>
+public sealed class TenantLocationOption
+{
+    public string? Val { get; set; }
+    public string? Label { get; set; }
+}
+
 /// <summary>
 /// 租户库位安全边界(源库直读专用)。
 /// <para>