Explorar o código

fix(s0): close org scope and remove platform org dependency

P1-1(B-1b):服务端成为组织归属的唯一裁决者,30/30 S0 Controller 边界闭合。
  B-1 已覆盖 13 个,本轮补齐剩余 17 个。按 UI 语义分类而非机械 stamp:
  - A 类 11 个(MfgOrderScheduleCycles / MfgProductionLines / MfgStandardOperations /
    MfgWorkOrderControls / Customers / Materials / OrderReviewCycles / ProductDesignCycles /
    CategoryLeadTimes / MaterialPlanCycles / Suppliers):Create 由
    AdoS0OrgScope.ResolveAsync(JWT tenant) stamp 并忽略 payload,Update pin 既有值,
    唯一性预检同步改用解析值/既有值。
  - B 类 6 个(ContractReviewCycles + Breakdowns + SyncFlags / MfgRoutingOpDetails /
    ProductStructures / SrmPurchases):这些接口的工厂维度由页面自身驱动,或把
    company/factory 用作「物料/组件是否在范围内」的校验作用域,强制 stamp 会与页面
    选择错位、或改变作用域语义(实测租户 797403760988230 的 factoryOptions[0]=Demo工厂
    797403760988231,而 resolver 得一工厂 1329900200002,二者不同)。故改为新增共享
    helper AdoS0OrgScope.ValidateOrgBelongsToTenantAsync:放行本租户组织、拒绝他租户组织。
  该 helper 必须 ClearFilter()——SysOrg 继承 EntityBaseTenant 带全局租户过滤器,
  不清过滤器则他租户组织根本查不出来、越权值会被误判为「非 SysOrg 引用」而放行
  (首轮负控实测即因此漏过,已修正)。判定口径只拒绝「确实解析到 SysOrg 且属他租户」
  的值,解析不到的 legacy 数值域(1000/0/1)放行,不把历史数据编辑一并拒绝。

P1-2:S0 对平台 /api/sysOrg/list 与按钮权限 sysOrg:list 的依赖归零。
  新增 AdoS0OrgsController(GET /api/s0/common/orgs):当前租户只读组织查询,
  内部复用 SysOrgService.GetList,租户与数据权限口径与平台完全一致(含
  GetUserOrgIdList 收窄与 SysOrg 全局租户过滤),统一拍平,只返回
  id/pid/name/code/type 五个字段,无任何写能力;路由未登记为按钮权限故无需授权。
  loadOrgList 的两份实现(s0SalesApi / s0SupplyApi)改指该端点,移除 SysOrgApi 引用,
  单测同步改为 mock 新端点。32 个 S0 页面因此一次性解耦,无需逐页重构。

P1-3(1.0.436):回收 UAT 租户 838257186181189 的 UAT_FUNCTION_USER 对机构管理的权限。
  回收 sysOrg:list 按钮(1300200030201)与机构管理菜单(1300200030101)两条绑定。
  同时撤菜单的原因:1.0.431 补按钮后菜单因祖先补全可见,管理员随后用 UI 重存角色时
  把它直接勾选(该角色 SysRoleMenu 由 290 变 285 且为一整段新雪花块,1.0.431 写入的
  Id=101853272909807688 已被 GrantRoleMenu 全量替换覆盖;对照租户 B 同名角色并无该菜单)。
  只撤按钮会让入口留在侧边栏且点开必 403,等于把最初缺陷换形式留回。
  不改 1.0.431(历史不改写);不动租户 797403760988229、其它角色、SysMenu、SysTenantMenu。

GrantMenu 经审计确认无需修改:其「先删后插」是上游框架的集合语义、撤权依赖它;
SysMenu/SysRoleMenu 无 IsSystem 等系统保留标记;实测 5 个含 INSERT SysRoleMenu 的历史
migration 中,除 1.0.386→租户A 那一条外绑定均仍在,回收后该风险源头消失。

验证:dotnet build 0 Errors;pnpm build 通过;S0 API 单测 16/16;vue-tsc 581<=597 基线
且本批文件净增 0;migration affected_rows=2 / verify Success(文件 SHA256 与执行记录一致);
UAT CROSS-TENANT 保持 0、租户 797403760988229 保持 167;runtime 实测 5 类 S0 场景正常、
无 403、console error 0,机构管理菜单已从菜单树消失,平台 /api/sysOrg/list 对该用户返回 403。
YY968XX hai 11 horas
pai
achega
404c5fda29
Modificáronse 27 ficheiros con 397 adicións e 96 borrados
  1. 1 1
      Web/package.json
  2. 5 11
      Web/src/views/aidop/s0/api/s0SalesApi.spec.ts
  3. 15 11
      Web/src/views/aidop/s0/api/s0SalesApi.ts
  4. 2 6
      Web/src/views/aidop/s0/api/s0SupplyApi.spec.ts
  5. 5 5
      Web/src/views/aidop/s0/api/s0SupplyApi.ts
  6. 10 4
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  7. 44 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.436.sql
  8. 34 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.436.verify.sql
  9. 80 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/AdoS0OrgsController.cs
  10. 10 6
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgOrderScheduleCyclesController.cs
  11. 8 4
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgProductionLinesController.cs
  12. 10 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgRoutingOpDetailsController.cs
  13. 10 6
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgStandardOperationsController.cs
  14. 8 4
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgWorkOrderControlsController.cs
  15. 10 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0ProductStructuresController.cs
  16. 10 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0ContractReviewCycleBreakdownsController.cs
  17. 6 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0ContractReviewCycleSyncFlagsController.cs
  18. 10 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0ContractReviewCyclesController.cs
  19. 10 6
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0CustomersController.cs
  20. 6 4
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0MaterialsController.cs
  21. 10 6
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0OrderReviewCyclesController.cs
  22. 10 6
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0ProductDesignCyclesController.cs
  23. 8 4
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Supply/AdoS0CategoryLeadTimesController.cs
  24. 10 6
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Supply/AdoS0MaterialPlanCyclesController.cs
  25. 10 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Supply/AdoS0SrmPurchasesController.cs
  26. 10 6
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Supply/AdoS0SuppliersController.cs
  27. 45 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AdoS0OrgScope.cs

+ 1 - 1
Web/package.json

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

+ 5 - 11
Web/src/views/aidop/s0/api/s0SalesApi.spec.ts

@@ -7,7 +7,6 @@ const deleteMock = vi.fn();
 const patchMock = vi.fn();
 const patchMock = vi.fn();
 
 
 const dictListMock = vi.fn();
 const dictListMock = vi.fn();
-const orgListMock = vi.fn();
 
 
 vi.mock('/@/utils/request', () => ({
 vi.mock('/@/utils/request', () => ({
 	default: {
 	default: {
@@ -23,9 +22,6 @@ vi.mock('/@/api-services', () => ({
 	SysDictDataApi: vi.fn().mockImplementation(() => ({
 	SysDictDataApi: vi.fn().mockImplementation(() => ({
 		apiSysDictDataDataListCodeGet: (...args: unknown[]) => dictListMock(...args),
 		apiSysDictDataDataListCodeGet: (...args: unknown[]) => dictListMock(...args),
 	})),
 	})),
-	SysOrgApi: vi.fn().mockImplementation(() => ({
-		apiSysOrgListGet: (...args: unknown[]) => orgListMock(...args),
-	})),
 }));
 }));
 
 
 import { loadDictOptions, loadOrgList } from './s0SalesApi';
 import { loadDictOptions, loadOrgList } from './s0SalesApi';
@@ -38,7 +34,6 @@ describe('s0SalesApi load helpers', () => {
 		deleteMock.mockReset();
 		deleteMock.mockReset();
 		patchMock.mockReset();
 		patchMock.mockReset();
 		dictListMock.mockReset();
 		dictListMock.mockReset();
-		orgListMock.mockReset();
 	});
 	});
 
 
 	it('loadDictOptions maps api result to {label, value}', async () => {
 	it('loadDictOptions maps api result to {label, value}', async () => {
@@ -69,15 +64,14 @@ describe('s0SalesApi load helpers', () => {
 	});
 	});
 
 
 	it('loadOrgList normalizes id/pid to string (snowflake IDs exceed JS number precision)', async () => {
 	it('loadOrgList normalizes id/pid to string (snowflake IDs exceed JS number precision)', async () => {
-		orgListMock.mockResolvedValue({
-			data: {
-				result: [{ id: 12, pid: 1, name: '研发', code: 'DEV', type: 'dept' }, { id: null }],
-			},
+		// P1-2:数据源改为 S0 自有只读端点 /api/s0/common/orgs(不再依赖平台 sysOrg:list)
+		getMock.mockResolvedValue({
+			data: { result: [{ id: 12, pid: 1, name: '研发', code: 'DEV', type: 'dept' }, { id: null }] },
 		});
 		});
 
 
 		const result = await loadOrgList('dept');
 		const result = await loadOrgList('dept');
 
 
-		expect(orgListMock).toHaveBeenCalledWith(0, undefined, undefined, 'dept');
+		expect(getMock).toHaveBeenCalledWith('/api/s0/common/orgs', { params: { type: 'dept' } });
 		expect(result).toEqual([
 		expect(result).toEqual([
 			// id/pid 强制 string,兼容后端历史上偶发 number 返回
 			// id/pid 强制 string,兼容后端历史上偶发 number 返回
 			{ id: '12', pid: '1', name: '研发', code: 'DEV', type: 'dept' },
 			{ id: '12', pid: '1', name: '研发', code: 'DEV', type: 'dept' },
@@ -87,7 +81,7 @@ describe('s0SalesApi load helpers', () => {
 	});
 	});
 
 
 	it('loadOrgList returns empty list when api throws', async () => {
 	it('loadOrgList returns empty list when api throws', async () => {
-		orgListMock.mockRejectedValue(new Error('boom'));
+		getMock.mockRejectedValue(new Error('boom'));
 		const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
 		const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
 
 
 		const result = await loadOrgList();
 		const result = await loadOrgList();

+ 15 - 11
Web/src/views/aidop/s0/api/s0SalesApi.ts

@@ -1,6 +1,6 @@
 import type { AxiosRequestConfig } from 'axios';
 import type { AxiosRequestConfig } from 'axios';
 import service from '/@/utils/request';
 import service from '/@/utils/request';
-import { SysDictDataApi, SysDictTypeApi, SysOrgApi } from '/@/api-services';
+import { SysDictDataApi, SysDictTypeApi } from '/@/api-services';
 import type { SysDictData } from '/@/api-services/models';
 import type { SysDictData } from '/@/api-services/models';
 import { StatusEnum } from '/@/api-services/models';
 import { StatusEnum } from '/@/api-services/models';
 
 
@@ -762,18 +762,22 @@ export const s0KittingExcludeRuleApi = {
 		service.post<S0KittingExcludeRule>('/api/s0/sales/kitting-exclude-rule', body).then(unwrap),
 		service.post<S0KittingExcludeRule>('/api/s0/sales/kitting-exclude-rule', body).then(unwrap),
 };
 };
 
 
+/**
+ * 当前租户组织列表。
+ *
+ * P1-2:数据源由平台 `/api/sysOrg/list` 改为 S0 自有只读端点 `/api/s0/common/orgs`。
+ * 平台路由已登记为按钮权限 `sysOrg:list`,普通 S0 业务角色为了填一个隐藏的公司/工厂归属
+ * 字段而被迫持有平台机构管理权限(并因菜单祖先补全暴露「系统管理/机构管理」入口)。
+ * S0 端点内部复用 SysOrgService.GetList,租户与数据权限口径完全一致,仅去掉权限耦合。
+ *
+ * 端点已统一返回**平铺列表**且 id/pid 以 string 下发;此处保留 string 归一化作为防御
+ * (雪花 ID 超 JS Number 安全范围,禁止朝 number 方向强转)。
+ */
 export async function loadOrgList(type?: string): Promise<OrgOption[]> {
 export async function loadOrgList(type?: string): Promise<OrgOption[]> {
 	try {
 	try {
-		const api = new SysOrgApi(undefined, undefined, service);
-		const res = await api.apiSysOrgListGet(0, undefined, undefined, type);
-		// 后端 SysOrgService.GetList:带 Type 条件时返回**平铺列表**,不带条件时返回**树**
-		// (子节点在 children 内)。此处统一拍平,使调用方拿到的始终是全量平铺组织,
-		// 「本租户只有 1 个组织」这类判断才不会被树形结构误导。
-		const flatten = (nodes: any[]): any[] =>
-			nodes.flatMap((n) => [n, ...(Array.isArray(n?.children) ? flatten(n.children) : [])]);
-		// 统一在进入前端前做 string 归一化,兼容后端历史上偶发 number 返回:
-		// 雪花 ID 超 JS Number 安全范围,禁止朝 number 方向强转,只做朝 string 方向的归一化。
-		return flatten((res.data.result as any[]) ?? []).map((item) => ({
+		const res = await service.get<any[]>('/api/s0/common/orgs', { params: type ? { type } : {} });
+		const list = ((res as any)?.data?.result ?? (res as any)?.data ?? []) as any[];
+		return (Array.isArray(list) ? list : []).map((item) => ({
 			id: item.id == null ? '' : String(item.id),
 			id: item.id == null ? '' : String(item.id),
 			pid: item.pid == null ? null : String(item.pid),
 			pid: item.pid == null ? null : String(item.pid),
 			name: item.name,
 			name: item.name,

+ 2 - 6
Web/src/views/aidop/s0/api/s0SupplyApi.spec.ts

@@ -7,7 +7,6 @@ const deleteMock = vi.fn();
 const patchMock = vi.fn();
 const patchMock = vi.fn();
 
 
 const dictListMock = vi.fn();
 const dictListMock = vi.fn();
-const orgListMock = vi.fn();
 
 
 vi.mock('/@/utils/request', () => ({
 vi.mock('/@/utils/request', () => ({
 	default: {
 	default: {
@@ -23,9 +22,6 @@ vi.mock('/@/api-services', () => ({
 	SysDictDataApi: vi.fn().mockImplementation(() => ({
 	SysDictDataApi: vi.fn().mockImplementation(() => ({
 		apiSysDictDataDataListCodeGet: (...args: unknown[]) => dictListMock(...args),
 		apiSysDictDataDataListCodeGet: (...args: unknown[]) => dictListMock(...args),
 	})),
 	})),
-	SysOrgApi: vi.fn().mockImplementation(() => ({
-		apiSysOrgListGet: (...args: unknown[]) => orgListMock(...args),
-	})),
 }));
 }));
 
 
 import { loadDictOptions, loadOrgList, s0SrmPurchasesApi, s0SuppliersApi } from './s0SupplyApi';
 import { loadDictOptions, loadOrgList, s0SrmPurchasesApi, s0SuppliersApi } from './s0SupplyApi';
@@ -38,7 +34,6 @@ describe('s0SupplyApi', () => {
 		deleteMock.mockReset();
 		deleteMock.mockReset();
 		patchMock.mockReset();
 		patchMock.mockReset();
 		dictListMock.mockReset();
 		dictListMock.mockReset();
-		orgListMock.mockReset();
 	});
 	});
 
 
 	it('suppliers toggleEnabled calls patch endpoint and unwraps', async () => {
 	it('suppliers toggleEnabled calls patch endpoint and unwraps', async () => {
@@ -61,7 +56,8 @@ describe('s0SupplyApi', () => {
 	});
 	});
 
 
 	it('loadOrgList returns [] on error', async () => {
 	it('loadOrgList returns [] on error', async () => {
-		orgListMock.mockRejectedValue(new Error('network'));
+		// P1-2:数据源改为 S0 自有只读端点 /api/s0/common/orgs
+		getMock.mockRejectedValue(new Error('network'));
 		const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
 		const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
 		const result = await loadOrgList('factory');
 		const result = await loadOrgList('factory');
 		expect(result).toEqual([]);
 		expect(result).toEqual([]);

+ 5 - 5
Web/src/views/aidop/s0/api/s0SupplyApi.ts

@@ -1,5 +1,5 @@
 import service from '/@/utils/request';
 import service from '/@/utils/request';
-import { SysDictDataApi, SysOrgApi } from '/@/api-services';
+import { SysDictDataApi } from '/@/api-services';
 
 
 function unwrap<T>(res: { data: T }): T {
 function unwrap<T>(res: { data: T }): T {
 	return res.data;
 	return res.data;
@@ -291,12 +291,12 @@ export async function loadDictOptions(code: string): Promise<OptionItem[]> {
 	}
 	}
 }
 }
 
 
+/** 当前租户组织列表。P1-2:改用 S0 自有只读端点,去掉对平台按钮权限 sysOrg:list 的耦合(见 s0SalesApi.loadOrgList 注释)。 */
 export async function loadOrgList(type?: string): Promise<OrgOption[]> {
 export async function loadOrgList(type?: string): Promise<OrgOption[]> {
 	try {
 	try {
-		const api = new SysOrgApi(undefined, undefined, service);
-		const res = await api.apiSysOrgListGet(0, undefined, undefined, type);
-		// 朝 string 归一化,对抗后端偶发 number 返回(雪花 ID 禁止转 number,否则丢精度)
-		return (res.data.result ?? []).map((item) => ({
+		const res = await service.get<any[]>('/api/s0/common/orgs', { params: type ? { type } : {} });
+		const list = ((res as any)?.data?.result ?? (res as any)?.data ?? []) as any[];
+		return (Array.isArray(list) ? list : []).map((item) => ({
 			id: item.id == null ? '' : String(item.id),
 			id: item.id == null ? '' : String(item.id),
 			pid: item.pid == null ? null : String(item.pid),
 			pid: item.pid == null ? null : String(item.pid),
 			name: item.name,
 			name: item.name,

+ 10 - 4
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk.Web">
+<Project Sdk="Microsoft.NET.Sdk.Web">
 
 
   <PropertyGroup>
   <PropertyGroup>
     <TargetFrameworks>net10.0</TargetFrameworks>
     <TargetFrameworks>net10.0</TargetFrameworks>
@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.435</AssemblyVersion>
-    <FileVersion>1.0.435</FileVersion>
-    <Version>1.0.435</Version>
+    <AssemblyVersion>1.0.436</AssemblyVersion>
+    <FileVersion>1.0.436</FileVersion>
+    <Version>1.0.436</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>
@@ -613,6 +613,12 @@
     <None Update="UpdateScripts\1.0.435.verify.sql">
     <None Update="UpdateScripts\1.0.435.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
     </None>
+    <None Update="UpdateScripts\1.0.436.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.436.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
     </None>

+ 44 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.436.sql

@@ -0,0 +1,44 @@
+-- ============================================================================
+-- 1.0.436:回收 UAT 租户 838257186181189 的 UAT_FUNCTION_USER 对平台机构管理的权限。
+--
+-- 背景(P1-2 已完成):
+--   S0 前端的组织下拉数据源已由平台 /api/sysOrg/list 改为 S0 自有只读端点
+--   /api/s0/common/orgs(AdoS0OrgsController,内部复用 SysOrgService.GetList,
+--   租户与数据权限口径完全一致,路由未登记为按钮权限故无需授权)。
+--   静态扫描确认:Web/src/views/aidop/s0 下已 0 处引用 apiSysOrgListGet / /api/sysOrg/list;
+--   runtime 实测 nbr-control / contract-review-cycle / sampling-scheme / sop-document
+--   四类页面均只发 /api/s0/common/orgs,无 /api/sysOrg/list。
+--   → 该角色不再需要平台按钮权限 sysOrg:list。
+--
+-- 为什么同时撤「机构管理」菜单(1300200030101):
+--   1.0.431 补 sysOrg:list 后,菜单祖先补全使「系统管理 / 机构管理」出现在该角色侧边栏;
+--   其后管理员通过「角色管理 → 授权菜单」重存该角色时把它**直接勾选**了
+--   (实测:该角色 SysRoleMenu 由 290 条变为 285 条且 Id 为一整段新雪花块,
+--     1.0.431 写入的 Id=101853272909807688 已被 GrantRoleMenu 全量替换覆盖;
+--     对照租户 B 的同名角色并未持有该菜单,可见是本租户的连带结果)。
+--   若只撤按钮不撤菜单,入口仍在侧边栏,点开 /system/org/index 会调用
+--   /api/sysOrg/list 而拿到 403 —— 等于把最初的缺陷换一种形式留回去。
+--   故两条绑定一并回收(经 owner 确认)。
+--
+-- 精确范围(只动这一个角色的这两条绑定):
+--   RoleId = 838258976469061(租户 838257186181189 的 UAT_FUNCTION_USER)
+--   MenuId ∈ (1300200030201 sysOrg:list 按钮, 1300200030101 机构管理 菜单)
+--
+-- 明确不做:
+--   - 不动 1.0.431 / 1.0.386 等历史脚本(历史不可改写,撤销以新脚本表达)
+--   - 不动租户 797403760988229 / 797403760988230 及其它任何角色
+--   - 不动 SysMenu、SysTenantMenu(菜单与租户可见性本身保持不变,
+--     仅解除**这一个角色**的绑定;其它角色/租户仍可正常授权使用机构管理)
+--   - 不放开也不回收 sysOrg:add / sysOrg:update / sysOrg:delete(该角色本就未持有)
+--
+-- 幂等:存在则删除,不存在则 0 行;重复执行无副作用。
+--
+-- ⚠️ 缓存:GetOwnBtnPermList 按 sys_user_button:<userId> 缓存 7 天,CacheType=Memory
+--   为进程内缓存,SQL 不会使其失效。AutoVersionUpdate 在应用启动时执行本脚本,
+--   该进程缓存本就为空 → 随发布重启天然生效;若在已运行实例上手工执行,
+--   需用 POST /api/sysCache/deleteByPreKey (prefix=sys_user_button:) 清理。
+-- ============================================================================
+
+DELETE FROM `SysRoleMenu`
+WHERE `RoleId` = 838258976469061
+  AND `MenuId` IN (1300200030201, 1300200030101);

+ 34 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.436.verify.sql

@@ -0,0 +1,34 @@
+-- ============================================================================
+-- 1.0.436 verify:该角色的两条机构管理相关绑定必须已回收,且其它租户/角色/菜单不受影响。
+--
+-- 约定(AutoVersionUpdate.RunVerifyScriptIfExists):每条语句取「首行首列」当布尔断言,
+--   必须为真;返回 0 行会得到 NULL 判假并中断启动。故断言一律返回标量真值,不使用 SET。
+-- 环境容错:目标角色不存在的库上主脚本为空操作,各断言以「本就没有这些绑定」自然为真。
+-- ============================================================================
+
+-- V1:目标角色不再持有 sysOrg:list 按钮
+SELECT (SELECT COUNT(*) FROM `SysRoleMenu`
+         WHERE `RoleId` = 838258976469061 AND `MenuId` = 1300200030201) = 0 AS v1_btn_revoked;
+
+-- V2:目标角色不再持有 机构管理 菜单
+SELECT (SELECT COUNT(*) FROM `SysRoleMenu`
+         WHERE `RoleId` = 838258976469061 AND `MenuId` = 1300200030101) = 0 AS v2_menu_revoked;
+
+-- V3:该角色对 sysOrg 的写权限本就为 0,回收后仍为 0(不应因本脚本产生任何新增)
+SELECT (SELECT COUNT(*) FROM `SysRoleMenu`
+         WHERE `RoleId` = 838258976469061
+           AND `MenuId` IN (1300200030301, 1300200030401, 1300200030501)) = 0 AS v3_no_sysorg_write;
+
+-- V4:租户 B 的同名角色(797403760988229 下 RoleId=838259499909189)**不受影响**,
+--     其 sysOrg:list 绑定仍在(本批只回收租户 A 这一个角色)
+SELECT (SELECT COUNT(*) FROM `SysRoleMenu`
+         WHERE `RoleId` = 838259499909189 AND `MenuId` = 1300200030201) = 1 AS v4_other_tenant_role_untouched;
+
+-- V5:SysMenu 本身未被删改(机构管理菜单与其查询按钮仍存在且启用)
+SELECT (SELECT COUNT(*) FROM `SysMenu`
+         WHERE `Id` IN (1300200030101, 1300200030201) AND `Status` = 1) = 2 AS v5_sysmenu_intact;
+
+-- V6:SysTenantMenu 未被删改(租户 A 对这两个菜单的可见性配置保持原样,
+--     以便将来需要时仍可通过角色授权正常启用)
+SELECT (SELECT COUNT(*) FROM `SysTenantMenu`
+         WHERE `TenantId` = 838257186181189 AND `MenuId` IN (1300200030101, 1300200030201)) = 2 AS v6_systenantmenu_intact;

+ 80 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/AdoS0OrgsController.cs

@@ -0,0 +1,80 @@
+using Admin.NET.Core.Service;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers.S0;
+
+/// <summary>
+/// S0 自有的**当前租户只读组织查询**(P1-2)。
+///
+/// 目的:让 S0 页面不再依赖平台 <c>/api/sysOrg/list</c>。
+///   平台该路由已登记为按钮权限 <c>sysOrg:list</c>(SysMenu Id=1300200030201),
+///   普通 S0 业务角色为了填一个隐藏的公司/工厂归属字段而被迫持有平台机构管理权限,
+///   并因菜单祖先补全在侧边栏暴露「系统管理 / 机构管理」入口。
+///   本路由 <c>s0:common:orgs</c> 未登记为按钮权限,走 JwtHandler「未登记即放行」分支,
+///   普通业务角色无需任何平台机构权限即可读取本租户组织。
+///
+/// 边界(只读 + 只当前租户 + 最小字段):
+///   - 租户来自 <see cref="AdoS0TenantScope.TryResolveRequired"/>(JWT claim),不接受 payload;
+///   - 复用 <see cref="SysOrgService.GetList"/>,与平台口径**完全一致**(含 GetUserOrgIdList
+///     数据权限收窄与 SysOrg 全局租户过滤),不新写一套查询逻辑、不放宽可见范围;
+///   - 只返回 id / pid / name / code / type 五个字段,不暴露机构管理的任何写能力;
+///   - 额外对结果再做一次当前租户断言,作为纵深防御。
+///
+/// 返回一律为**平铺列表**:平台 GetList 在不带 Type 条件时返回树,本接口统一拍平,
+/// 使调用方(loadOrgList)无需再关心树/平铺两种形态。
+/// </summary>
+[ApiController]
+[Route("api/s0/common/orgs")]
+[NonUnify]
+public class AdoS0OrgsController : ControllerBase
+{
+    private readonly SysOrgService _sysOrgService;
+
+    public AdoS0OrgsController(SysOrgService sysOrgService)
+    {
+        _sysOrgService = sysOrgService;
+    }
+
+    /// <summary>
+    /// 查询当前租户的组织列表。<paramref name="type"/> 为 SysOrg.Type(S0 约定 201=公司、501=工厂,
+    /// 501 沿用 S0 legacy 兼容语义,见 AdoS0OrgScope 注释);不传则返回本租户全部组织。
+    /// </summary>
+    [HttpGet]
+    public async Task<IActionResult> GetListAsync([FromQuery] string? type)
+    {
+        if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        var raw = await _sysOrgService.GetList(new OrgInput { Id = 0, Type = type });
+
+        var flat = new List<SysOrg>();
+        Flatten(raw, flat);
+
+        var result = flat
+            .Where(x => x.TenantId == tenantId) // 纵深防御:平台侧已按租户过滤,这里再断言一次
+            .GroupBy(x => x.Id)
+            .Select(g => g.First())
+            .OrderBy(x => x.OrderNo).ThenBy(x => x.Id)
+            .Select(x => new
+            {
+                id = x.Id.ToString(),   // 雪花 Id 超 JS number 精度,一律以 string 下发
+                pid = x.Pid.ToString(),
+                name = x.Name,
+                code = x.Code,
+                type = x.Type,
+            })
+            .ToList();
+
+        return Ok(result);
+    }
+
+    /// <summary>把可能的树形结果拍平(平台 GetList 不带条件时返回树,子节点在 Children 内)。</summary>
+    private static void Flatten(List<SysOrg>? nodes, List<SysOrg> sink)
+    {
+        if (nodes == null) return;
+        foreach (var n in nodes)
+        {
+            sink.Add(n);
+            Flatten(n.Children, sink);
+        }
+    }
+}

+ 10 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgOrderScheduleCyclesController.cs

@@ -52,16 +52,20 @@ public class AdoS0MfgOrderScheduleCyclesController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0OrderScheduleCycleUpsertDto dto)
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0OrderScheduleCycleUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        var (companyId, factoryId) = await AdoS0OrgScope.ResolveAsync(_rep.Context, tenantId);
+        if (companyId <= 0 || factoryId <= 0) return AdoS0OrgScope.NotConfiguredError("订单排程周期");
         var orderType = NormalizeOrderType(dto.OrderType);
         var orderType = NormalizeOrderType(dto.OrderType);
-        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == dto.FactoryRefId && x.OrderType == orderType))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == factoryId && x.OrderType == orderType))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该工厂与订单类型组合已存在订单排程周期标准");
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该工厂与订单类型组合已存在订单排程周期标准");
 
 
         var now = DateTime.Now;
         var now = DateTime.Now;
         var entity = new AdoS0OrderScheduleCycle
         var entity = new AdoS0OrderScheduleCycle
         {
         {
             TenantId = tenantId,
             TenantId = tenantId,
-            CompanyRefId = dto.CompanyRefId,
-            FactoryRefId = dto.FactoryRefId,
+            // 组织字段后端强制 stamp 当前租户内解析出的组织,忽略 payload 组织值(杜绝跨租户组织引用)。
+            CompanyRefId = companyId,
+            FactoryRefId = factoryId,
             DomainCode = dto.DomainCode,
             DomainCode = dto.DomainCode,
             OrderType = orderType,
             OrderType = orderType,
             StdHours = dto.StdHours,
             StdHours = dto.StdHours,
@@ -84,11 +88,11 @@ public class AdoS0MfgOrderScheduleCyclesController : ControllerBase
         if (entity == null) return NotFound();
         if (entity == null) return NotFound();
 
 
         var orderType = NormalizeOrderType(dto.OrderType);
         var orderType = NormalizeOrderType(dto.OrderType);
-        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.OrderType == orderType))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == entity.FactoryRefId && x.OrderType == orderType))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该工厂与订单类型组合已存在其他记录");
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该工厂与订单类型组合已存在其他记录");
 
 
-        entity.CompanyRefId = dto.CompanyRefId;
-        entity.FactoryRefId = dto.FactoryRefId;
+        // 组织字段 pin 到既有原值,忽略 payload 组织值:既杜绝篡改成他租户组织,
+        // 也不因一次普通编辑就顺手迁移历史 CROSS-TENANT 存量。
         entity.DomainCode = dto.DomainCode;
         entity.DomainCode = dto.DomainCode;
         entity.OrderType = orderType;
         entity.OrderType = orderType;
         entity.StdHours = dto.StdHours;
         entity.StdHours = dto.StdHours;

+ 8 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgProductionLinesController.cs

@@ -131,6 +131,9 @@ public class AdoS0MfgProductionLinesController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0LineMasterUpsertDto dto)
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0LineMasterUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        var (companyId, factoryId) = await AdoS0OrgScope.ResolveAsync(_rep.Context, tenantId);
+        if (companyId <= 0 || factoryId <= 0) return AdoS0OrgScope.NotConfiguredError("生产线");
         var locErr = await ValidateLocationReferencesAsync(dto, tenantId);
         var locErr = await ValidateLocationReferencesAsync(dto, tenantId);
         if (locErr != null) return locErr;
         if (locErr != null) return locErr;
 
 
@@ -138,8 +141,9 @@ public class AdoS0MfgProductionLinesController : ControllerBase
         var entity = new AdoS0LineMaster
         var entity = new AdoS0LineMaster
         {
         {
             TenantId = tenantId,
             TenantId = tenantId,
-            CompanyRefId = dto.CompanyRefId,
-            FactoryRefId = dto.FactoryRefId,
+            // 组织字段后端强制 stamp 当前租户内解析出的组织,忽略 payload 组织值(杜绝跨租户组织引用)。
+            CompanyRefId = companyId,
+            FactoryRefId = factoryId,
             Domain = dto.Domain.Trim(),
             Domain = dto.Domain.Trim(),
             Line = dto.Line.Trim(),
             Line = dto.Line.Trim(),
             Describe = string.IsNullOrWhiteSpace(dto.Describe) ? null : dto.Describe.Trim(),
             Describe = string.IsNullOrWhiteSpace(dto.Describe) ? null : dto.Describe.Trim(),
@@ -173,8 +177,8 @@ public class AdoS0MfgProductionLinesController : ControllerBase
         var locErr = await ValidateLocationReferencesAsync(dto, tenantId, entity);
         var locErr = await ValidateLocationReferencesAsync(dto, tenantId, entity);
         if (locErr != null) return locErr;
         if (locErr != null) return locErr;
 
 
-        entity.CompanyRefId = dto.CompanyRefId;
-        entity.FactoryRefId = dto.FactoryRefId;
+        // 组织字段 pin 到既有原值,忽略 payload 组织值:既杜绝篡改成他租户组织,
+        // 也不因一次普通编辑就顺手迁移历史 CROSS-TENANT 存量。
         entity.Domain = dto.Domain.Trim();
         entity.Domain = dto.Domain.Trim();
         entity.Line = dto.Line.Trim();
         entity.Line = dto.Line.Trim();
         entity.Describe = string.IsNullOrWhiteSpace(dto.Describe) ? null : dto.Describe.Trim();
         entity.Describe = string.IsNullOrWhiteSpace(dto.Describe) ? null : dto.Describe.Trim();

+ 10 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgRoutingOpDetailsController.cs

@@ -63,6 +63,11 @@ public class AdoS0MfgRoutingOpDetailsController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0MfgRoutingOpDetailUpsertDto dto)
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0MfgRoutingOpDetailUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        // B-1b:本接口用 payload 的公司/工厂作为「物料/组件是否在范围内」的校验作用域,
+        // 强制 stamp 会改变该作用域语义,故改为放行本租户组织、拒绝他租户组织。
+        var orgError = await AdoS0OrgScope.ValidateOrgBelongsToTenantAsync(_rep.Context, tenantId, dto.CompanyRefId, dto.FactoryRefId);
+        if (orgError != null) return orgError;
         var err = ValidateUpsert(dto);
         var err = ValidateUpsert(dto);
         if (err != null) return BadRequest(new { message = err });
         if (err != null) return BadRequest(new { message = err });
 
 
@@ -94,6 +99,11 @@ public class AdoS0MfgRoutingOpDetailsController : ControllerBase
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0MfgRoutingOpDetailUpsertDto dto)
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0MfgRoutingOpDetailUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        // B-1b:本接口用 payload 的公司/工厂作为「物料/组件是否在范围内」的校验作用域,
+        // 强制 stamp 会改变该作用域语义,故改为放行本租户组织、拒绝他租户组织。
+        var orgError = await AdoS0OrgScope.ValidateOrgBelongsToTenantAsync(_rep.Context, tenantId, dto.CompanyRefId, dto.FactoryRefId);
+        if (orgError != null) return orgError;
         var err = ValidateUpsert(dto);
         var err = ValidateUpsert(dto);
         if (err != null) return BadRequest(new { message = err });
         if (err != null) return BadRequest(new { message = err });
 
 

+ 10 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgStandardOperationsController.cs

@@ -57,16 +57,20 @@ public class AdoS0MfgStandardOperationsController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0StdOpMasterUpsertDto dto)
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0StdOpMasterUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        var (companyId, factoryId) = await AdoS0OrgScope.ResolveAsync(_rep.Context, tenantId);
+        if (companyId <= 0 || factoryId <= 0) return AdoS0OrgScope.NotConfiguredError("标准工序");
         var stdOpCode = dto.StdOpCode.Trim();
         var stdOpCode = dto.StdOpCode.Trim();
-        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.CompanyRefId == dto.CompanyRefId && x.FactoryRefId == dto.FactoryRefId && x.StdOpCode == stdOpCode))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.CompanyRefId == companyId && x.FactoryRefId == factoryId && x.StdOpCode == stdOpCode))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "标准工序编码已存在");
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "标准工序编码已存在");
 
 
         var now = DateTime.Now;
         var now = DateTime.Now;
         var entity = new AdoS0StdOpMaster
         var entity = new AdoS0StdOpMaster
         {
         {
             TenantId = tenantId,
             TenantId = tenantId,
-            CompanyRefId = dto.CompanyRefId,
-            FactoryRefId = dto.FactoryRefId,
+            // 组织字段后端强制 stamp 当前租户内解析出的组织,忽略 payload 组织值(杜绝跨租户组织引用)。
+            CompanyRefId = companyId,
+            FactoryRefId = factoryId,
             Domain = dto.Domain.Trim(),
             Domain = dto.Domain.Trim(),
             StdOp = dto.StdOp.Trim(),
             StdOp = dto.StdOp.Trim(),
             StdOpCode = stdOpCode,
             StdOpCode = stdOpCode,
@@ -88,11 +92,11 @@ public class AdoS0MfgStandardOperationsController : ControllerBase
         if (entity == null) return NotFound();
         if (entity == null) return NotFound();
 
 
         var stdOpCode = dto.StdOpCode.Trim();
         var stdOpCode = dto.StdOpCode.Trim();
-        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.CompanyRefId == dto.CompanyRefId && x.FactoryRefId == dto.FactoryRefId && x.StdOpCode == stdOpCode))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.CompanyRefId == entity.CompanyRefId && x.FactoryRefId == entity.FactoryRefId && x.StdOpCode == stdOpCode))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "标准工序编码已存在");
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "标准工序编码已存在");
 
 
-        entity.CompanyRefId = dto.CompanyRefId;
-        entity.FactoryRefId = dto.FactoryRefId;
+        // 组织字段 pin 到既有原值,忽略 payload 组织值:既杜绝篡改成他租户组织,
+        // 也不因一次普通编辑就顺手迁移历史 CROSS-TENANT 存量。
         entity.Domain = dto.Domain.Trim();
         entity.Domain = dto.Domain.Trim();
         entity.StdOp = dto.StdOp.Trim();
         entity.StdOp = dto.StdOp.Trim();
         entity.StdOpCode = stdOpCode;
         entity.StdOpCode = stdOpCode;

+ 8 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0MfgWorkOrderControlsController.cs

@@ -50,11 +50,15 @@ public class AdoS0MfgWorkOrderControlsController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0MfgWorkOrderControlUpsertDto dto)
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0MfgWorkOrderControlUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        var (companyId, factoryId) = await AdoS0OrgScope.ResolveAsync(_rep.Context, tenantId);
+        if (companyId <= 0 || factoryId <= 0) return AdoS0OrgScope.NotConfiguredError("工单控制");
         var entity = new AdoS0MfgWorkOrderControl
         var entity = new AdoS0MfgWorkOrderControl
         {
         {
             TenantId = tenantId,
             TenantId = tenantId,
-            CompanyRefId = dto.CompanyRefId,
-            FactoryRefId = dto.FactoryRefId,
+            // 组织字段后端强制 stamp 当前租户内解析出的组织,忽略 payload 组织值(杜绝跨租户组织引用)。
+            CompanyRefId = companyId,
+            FactoryRefId = factoryId,
             Code = dto.Code,
             Code = dto.Code,
             Name = dto.Name,
             Name = dto.Name,
             ControlType = dto.ControlType,
             ControlType = dto.ControlType,
@@ -73,8 +77,8 @@ public class AdoS0MfgWorkOrderControlsController : ControllerBase
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         var entity = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         var entity = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         if (entity == null) return NotFound();
         if (entity == null) return NotFound();
-        entity.CompanyRefId = dto.CompanyRefId;
-        entity.FactoryRefId = dto.FactoryRefId;
+        // 组织字段 pin 到既有原值,忽略 payload 组织值:既杜绝篡改成他租户组织,
+        // 也不因一次普通编辑就顺手迁移历史 CROSS-TENANT 存量。
         entity.Code = dto.Code;
         entity.Code = dto.Code;
         entity.Name = dto.Name;
         entity.Name = dto.Name;
         entity.ControlType = dto.ControlType;
         entity.ControlType = dto.ControlType;

+ 10 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Manufacturing/AdoS0ProductStructuresController.cs

@@ -192,6 +192,11 @@ public class AdoS0ProductStructuresController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0ProductStructureUpsertDto dto)
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0ProductStructureUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        // B-1b:本接口用 payload 的公司/工厂作为「物料/组件是否在范围内」的校验作用域,
+        // 强制 stamp 会改变该作用域语义,故改为放行本租户组织、拒绝他租户组织。
+        var orgError = await AdoS0OrgScope.ValidateOrgBelongsToTenantAsync(_masterRep.Context, tenantId, dto.CompanyRefId, dto.FactoryRefId);
+        if (orgError != null) return orgError;
         var err = AdoS0ProductStructureRules.ValidateUpsert(dto);
         var err = AdoS0ProductStructureRules.ValidateUpsert(dto);
         if (err != null) return BadRequest(new { message = err });
         if (err != null) return BadRequest(new { message = err });
 
 
@@ -235,6 +240,11 @@ public class AdoS0ProductStructuresController : ControllerBase
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0ProductStructureUpsertDto dto)
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0ProductStructureUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        // B-1b:本接口用 payload 的公司/工厂作为「物料/组件是否在范围内」的校验作用域,
+        // 强制 stamp 会改变该作用域语义,故改为放行本租户组织、拒绝他租户组织。
+        var orgError = await AdoS0OrgScope.ValidateOrgBelongsToTenantAsync(_masterRep.Context, tenantId, dto.CompanyRefId, dto.FactoryRefId);
+        if (orgError != null) return orgError;
         var err = AdoS0ProductStructureRules.ValidateUpsert(dto);
         var err = AdoS0ProductStructureRules.ValidateUpsert(dto);
         if (err != null) return BadRequest(new { message = err });
         if (err != null) return BadRequest(new { message = err });
 
 

+ 10 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0ContractReviewCycleBreakdownsController.cs

@@ -49,6 +49,11 @@ public class AdoS0ContractReviewCycleBreakdownsController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0ContractReviewCycleBreakdownUpsertDto dto)
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0ContractReviewCycleBreakdownUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        // B-1b:本接口的工厂维度由页面自身驱动(合同评审周期页按所选工厂列出/同步/分解),
+        // 故不强制 stamp 解析值(会与页面选择错位),改为放行本租户组织、拒绝他租户组织。
+        var orgError = await AdoS0OrgScope.ValidateOrgBelongsToTenantAsync(_rep.Context, tenantId, dto.CompanyRefId, dto.FactoryRefId);
+        if (orgError != null) return orgError;
+
         if (await _rep.IsAnyAsync(x =>
         if (await _rep.IsAnyAsync(x =>
                 x.TenantId == tenantId &&
                 x.TenantId == tenantId &&
                 x.FactoryRefId == dto.FactoryRefId &&
                 x.FactoryRefId == dto.FactoryRefId &&
@@ -79,6 +84,11 @@ public class AdoS0ContractReviewCycleBreakdownsController : ControllerBase
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0ContractReviewCycleBreakdownUpsertDto dto)
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0ContractReviewCycleBreakdownUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        // B-1b:本接口的工厂维度由页面自身驱动(合同评审周期页按所选工厂列出/同步/分解),
+        // 故不强制 stamp 解析值(会与页面选择错位),改为放行本租户组织、拒绝他租户组织。
+        var orgError = await AdoS0OrgScope.ValidateOrgBelongsToTenantAsync(_rep.Context, tenantId, dto.CompanyRefId, dto.FactoryRefId);
+        if (orgError != null) return orgError;
+
         var entity = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         var entity = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         if (entity == null) return NotFound();
         if (entity == null) return NotFound();
 
 

+ 6 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0ContractReviewCycleSyncFlagsController.cs

@@ -52,6 +52,12 @@ public class AdoS0ContractReviewCycleSyncFlagsController : ControllerBase
     public async Task<IActionResult> UpsertAsync([FromBody] AdoS0ContractReviewCycleSyncFlagUpsertDto dto)
     public async Task<IActionResult> UpsertAsync([FromBody] AdoS0ContractReviewCycleSyncFlagUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        // B-1b:本接口的工厂维度由页面自身驱动(合同评审周期页按所选工厂同步),
+        // 故不强制 stamp 解析值(会与页面选择错位),改为放行本租户组织、拒绝他租户组织。
+        var orgError = await AdoS0OrgScope.ValidateOrgBelongsToTenantAsync(_rep.Context, tenantId, dto.CompanyRefId, dto.FactoryRefId);
+        if (orgError != null) return orgError;
+
         var now = DateTime.Now;
         var now = DateTime.Now;
         var entity = await _rep.AsQueryable()
         var entity = await _rep.AsQueryable()
             .Where(x => x.TenantId == tenantId)
             .Where(x => x.TenantId == tenantId)

+ 10 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0ContractReviewCyclesController.cs

@@ -53,6 +53,11 @@ public class AdoS0ContractReviewCyclesController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0ContractReviewCycleUpsertDto dto)
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0ContractReviewCycleUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        // B-1b:本接口的工厂维度由页面自身驱动(合同评审周期页按所选工厂列出/同步/分解),
+        // 故不强制 stamp 解析值(会与页面选择错位),改为放行本租户组织、拒绝他租户组织。
+        var orgError = await AdoS0OrgScope.ValidateOrgBelongsToTenantAsync(_rep.Context, tenantId, dto.CompanyRefId, dto.FactoryRefId);
+        if (orgError != null) return orgError;
+
         if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == dto.FactoryRefId && x.StageCode == dto.StageCode))
         if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == dto.FactoryRefId && x.StageCode == dto.StageCode))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该工厂下此阶段已存在");
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该工厂下此阶段已存在");
 
 
@@ -78,6 +83,11 @@ public class AdoS0ContractReviewCyclesController : ControllerBase
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0ContractReviewCycleUpsertDto dto)
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0ContractReviewCycleUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+        // B-1b:本接口的工厂维度由页面自身驱动(合同评审周期页按所选工厂列出/同步/分解),
+        // 故不强制 stamp 解析值(会与页面选择错位),改为放行本租户组织、拒绝他租户组织。
+        var orgError = await AdoS0OrgScope.ValidateOrgBelongsToTenantAsync(_rep.Context, tenantId, dto.CompanyRefId, dto.FactoryRefId);
+        if (orgError != null) return orgError;
+
         var entity = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         var entity = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         if (entity == null) return NotFound();
         if (entity == null) return NotFound();
 
 

+ 10 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0CustomersController.cs

@@ -64,15 +64,19 @@ public class AdoS0CustomersController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0CustMasterUpsertDto dto)
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0CustMasterUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
-        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == dto.FactoryRefId && x.Cust == dto.Cust))
+
+        var (companyId, factoryId) = await AdoS0OrgScope.ResolveAsync(_rep.Context, tenantId);
+        if (companyId <= 0 || factoryId <= 0) return AdoS0OrgScope.NotConfiguredError("客户");
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == factoryId && x.Cust == dto.Cust))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.CustomerCodeExists, "客户编码已存在");
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.CustomerCodeExists, "客户编码已存在");
 
 
         var now = DateTime.Now;
         var now = DateTime.Now;
         var entity = new AdoS0CustMaster
         var entity = new AdoS0CustMaster
         {
         {
             TenantId = tenantId,
             TenantId = tenantId,
-            CompanyRefId = dto.CompanyRefId,
-            FactoryRefId = dto.FactoryRefId,
+            // 组织字段后端强制 stamp 当前租户内解析出的组织,忽略 payload 组织值(杜绝跨租户组织引用)。
+            CompanyRefId = companyId,
+            FactoryRefId = factoryId,
             DomainCode = dto.DomainCode,
             DomainCode = dto.DomainCode,
             Cust = dto.Cust,
             Cust = dto.Cust,
             SortName = dto.SortName,
             SortName = dto.SortName,
@@ -110,11 +114,11 @@ public class AdoS0CustomersController : ControllerBase
         var entity = await _rep.ByIdScopedAsync(id, tenantId);
         var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
         if (entity == null) return NotFound();
 
 
-        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.Cust == dto.Cust))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == entity.FactoryRefId && x.Cust == dto.Cust))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.CustomerCodeExists, "客户编码已存在");
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.CustomerCodeExists, "客户编码已存在");
 
 
-        entity.CompanyRefId = dto.CompanyRefId;
-        entity.FactoryRefId = dto.FactoryRefId;
+        // 组织字段 pin 到既有原值,忽略 payload 组织值:既杜绝篡改成他租户组织,
+        // 也不因一次普通编辑就顺手迁移历史 CROSS-TENANT 存量。
         entity.DomainCode = dto.DomainCode;
         entity.DomainCode = dto.DomainCode;
         entity.Cust = dto.Cust;
         entity.Cust = dto.Cust;
         entity.SortName = dto.SortName;
         entity.SortName = dto.SortName;

+ 6 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0MaterialsController.cs

@@ -71,6 +71,9 @@ public class AdoS0MaterialsController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0ItemMasterUpsertDto dto)
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0ItemMasterUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        var (companyId, factoryId) = await AdoS0OrgScope.ResolveAsync(_rep.Context, tenantId);
+        if (companyId <= 0 || factoryId <= 0) return AdoS0OrgScope.NotConfiguredError("物料");
         if (await _rep.IsAnyAsync(x =>
         if (await _rep.IsAnyAsync(x =>
                 x.TenantId == tenantId &&
                 x.TenantId == tenantId &&
                 x.CompanyRefId == dto.CompanyRefId &&
                 x.CompanyRefId == dto.CompanyRefId &&
@@ -162,8 +165,7 @@ public class AdoS0MaterialsController : ControllerBase
 
 
     private static AdoS0ItemMaster MapDtoToNewEntity(AdoS0ItemMasterUpsertDto dto, DateTime now) => new()
     private static AdoS0ItemMaster MapDtoToNewEntity(AdoS0ItemMasterUpsertDto dto, DateTime now) => new()
     {
     {
-        CompanyRefId = dto.CompanyRefId,
-        FactoryRefId = dto.FactoryRefId,
+        // 组织字段由 CreateAsync stamp 解析值覆盖(不读 payload)。
         DomainCode = dto.DomainCode,
         DomainCode = dto.DomainCode,
         ItemNum = dto.ItemNum,
         ItemNum = dto.ItemNum,
         Descr = dto.Descr,
         Descr = dto.Descr,
@@ -226,8 +228,8 @@ public class AdoS0MaterialsController : ControllerBase
 
 
     private static void ApplyDtoToEntity(AdoS0ItemMaster entity, AdoS0ItemMasterUpsertDto dto)
     private static void ApplyDtoToEntity(AdoS0ItemMaster entity, AdoS0ItemMasterUpsertDto dto)
     {
     {
-        entity.CompanyRefId = dto.CompanyRefId;
-        entity.FactoryRefId = dto.FactoryRefId;
+        // 组织字段 pin 到既有原值,忽略 payload 组织值:既杜绝篡改成他租户组织,
+        // 也不因一次普通编辑就顺手迁移历史 CROSS-TENANT 存量。
         entity.DomainCode = dto.DomainCode;
         entity.DomainCode = dto.DomainCode;
         entity.ItemNum = dto.ItemNum;
         entity.ItemNum = dto.ItemNum;
         entity.Descr = dto.Descr;
         entity.Descr = dto.Descr;

+ 10 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0OrderReviewCyclesController.cs

@@ -47,15 +47,19 @@ public class AdoS0OrderReviewCyclesController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0OrderReviewCycleUpsertDto dto)
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0OrderReviewCycleUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        var (companyId, factoryId) = await AdoS0OrgScope.ResolveAsync(_rep.Context, tenantId);
+        if (companyId <= 0 || factoryId <= 0) return AdoS0OrgScope.NotConfiguredError("订单评审周期");
         var orderType = NormalizeOrderType(dto.OrderType);
         var orderType = NormalizeOrderType(dto.OrderType);
-        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == dto.FactoryRefId && x.OrderType == orderType))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == factoryId && x.OrderType == orderType))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该工厂与订单类型组合已存在订单评审周期配置,请使用编辑功能");
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该工厂与订单类型组合已存在订单评审周期配置,请使用编辑功能");
 
 
         var entity = new AdoS0OrderReviewCycle
         var entity = new AdoS0OrderReviewCycle
         {
         {
             TenantId = tenantId,
             TenantId = tenantId,
-            CompanyRefId = dto.CompanyRefId,
-            FactoryRefId = dto.FactoryRefId,
+            // 组织字段后端强制 stamp 当前租户内解析出的组织,忽略 payload 组织值(杜绝跨租户组织引用)。
+            CompanyRefId = companyId,
+            FactoryRefId = factoryId,
             DomainCode = dto.DomainCode,
             DomainCode = dto.DomainCode,
             OrderType = orderType,
             OrderType = orderType,
             StdHours = dto.StdHours,
             StdHours = dto.StdHours,
@@ -76,11 +80,11 @@ public class AdoS0OrderReviewCyclesController : ControllerBase
         if (entity == null) return NotFound();
         if (entity == null) return NotFound();
 
 
         var orderType = NormalizeOrderType(dto.OrderType);
         var orderType = NormalizeOrderType(dto.OrderType);
-        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.OrderType == orderType))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == entity.FactoryRefId && x.OrderType == orderType))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该工厂与订单类型组合已存在其他记录");
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该工厂与订单类型组合已存在其他记录");
 
 
-        entity.CompanyRefId = dto.CompanyRefId;
-        entity.FactoryRefId = dto.FactoryRefId;
+        // 组织字段 pin 到既有原值,忽略 payload 组织值:既杜绝篡改成他租户组织,
+        // 也不因一次普通编辑就顺手迁移历史 CROSS-TENANT 存量。
         entity.DomainCode = dto.DomainCode;
         entity.DomainCode = dto.DomainCode;
         entity.OrderType = orderType;
         entity.OrderType = orderType;
         entity.StdHours = dto.StdHours;
         entity.StdHours = dto.StdHours;

+ 10 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Sales/AdoS0ProductDesignCyclesController.cs

@@ -126,14 +126,18 @@ public class AdoS0ProductDesignCyclesController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0ProductDesignCycleUpsertDto dto)
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0ProductDesignCycleUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
-        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == dto.FactoryRefId && x.ItemType == dto.ItemType && x.OwnerApplication == dto.OwnerApplication))
+
+        var (companyId, factoryId) = await AdoS0OrgScope.ResolveAsync(_rep.Context, tenantId);
+        if (companyId <= 0 || factoryId <= 0) return AdoS0OrgScope.NotConfiguredError("产品设计周期");
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == factoryId && x.ItemType == dto.ItemType && x.OwnerApplication == dto.OwnerApplication))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该工厂下此物料类型+属性组合已存在");
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该工厂下此物料类型+属性组合已存在");
 
 
         var entity = new AdoS0ProductDesignCycle
         var entity = new AdoS0ProductDesignCycle
         {
         {
             TenantId = tenantId,
             TenantId = tenantId,
-            CompanyRefId = dto.CompanyRefId,
-            FactoryRefId = dto.FactoryRefId,
+            // 组织字段后端强制 stamp 当前租户内解析出的组织,忽略 payload 组织值(杜绝跨租户组织引用)。
+            CompanyRefId = companyId,
+            FactoryRefId = factoryId,
             DomainCode = dto.DomainCode,
             DomainCode = dto.DomainCode,
             ItemType = dto.ItemType,
             ItemType = dto.ItemType,
             OwnerApplication = dto.OwnerApplication,
             OwnerApplication = dto.OwnerApplication,
@@ -153,11 +157,11 @@ public class AdoS0ProductDesignCyclesController : ControllerBase
         var entity = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         var entity = await _rep.AsQueryable().Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
         if (entity == null) return NotFound();
         if (entity == null) return NotFound();
 
 
-        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.ItemType == dto.ItemType && x.OwnerApplication == dto.OwnerApplication))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == entity.FactoryRefId && x.ItemType == dto.ItemType && x.OwnerApplication == dto.OwnerApplication))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该工厂下此物料类型+属性组合已存在");
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该工厂下此物料类型+属性组合已存在");
 
 
-        entity.CompanyRefId = dto.CompanyRefId;
-        entity.FactoryRefId = dto.FactoryRefId;
+        // 组织字段 pin 到既有原值,忽略 payload 组织值:既杜绝篡改成他租户组织,
+        // 也不因一次普通编辑就顺手迁移历史 CROSS-TENANT 存量。
         entity.DomainCode = dto.DomainCode;
         entity.DomainCode = dto.DomainCode;
         entity.ItemType = dto.ItemType;
         entity.ItemType = dto.ItemType;
         entity.OwnerApplication = dto.OwnerApplication;
         entity.OwnerApplication = dto.OwnerApplication;

+ 8 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Supply/AdoS0CategoryLeadTimesController.cs

@@ -61,6 +61,9 @@ public class AdoS0CategoryLeadTimesController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0CategoryLeadTimeUpsertDto dto)
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0CategoryLeadTimeUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        var (companyId, factoryId) = await AdoS0OrgScope.ResolveAsync(_rep.Context, tenantId);
+        if (companyId <= 0 || factoryId <= 0) return AdoS0OrgScope.NotConfiguredError("品类采购前置期");
         var uniqueErr = await EnsureUniqueAsync(dto, null, tenantId);
         var uniqueErr = await EnsureUniqueAsync(dto, null, tenantId);
         if (uniqueErr != null) return uniqueErr;
         if (uniqueErr != null) return uniqueErr;
 
 
@@ -68,8 +71,9 @@ public class AdoS0CategoryLeadTimesController : ControllerBase
         var entity = new AdoS0CategoryLeadTime
         var entity = new AdoS0CategoryLeadTime
         {
         {
             TenantId = tenantId,
             TenantId = tenantId,
-            CompanyRefId = dto.CompanyRefId,
-            FactoryRefId = dto.FactoryRefId,
+            // 组织字段后端强制 stamp 当前租户内解析出的组织,忽略 payload 组织值(杜绝跨租户组织引用)。
+            CompanyRefId = companyId,
+            FactoryRefId = factoryId,
             DomainCode = dto.DomainCode,
             DomainCode = dto.DomainCode,
             CategoryCode = dto.CategoryCode.Trim(),
             CategoryCode = dto.CategoryCode.Trim(),
             CategoryName = dto.CategoryName,
             CategoryName = dto.CategoryName,
@@ -97,8 +101,8 @@ public class AdoS0CategoryLeadTimesController : ControllerBase
         var uniqueErr = await EnsureUniqueAsync(dto, id, tenantId);
         var uniqueErr = await EnsureUniqueAsync(dto, id, tenantId);
         if (uniqueErr != null) return uniqueErr;
         if (uniqueErr != null) return uniqueErr;
 
 
-        entity.CompanyRefId = dto.CompanyRefId;
-        entity.FactoryRefId = dto.FactoryRefId;
+        // 组织字段 pin 到既有原值,忽略 payload 组织值:既杜绝篡改成他租户组织,
+        // 也不因一次普通编辑就顺手迁移历史 CROSS-TENANT 存量。
         entity.DomainCode = dto.DomainCode;
         entity.DomainCode = dto.DomainCode;
         entity.CategoryCode = dto.CategoryCode.Trim();
         entity.CategoryCode = dto.CategoryCode.Trim();
         entity.CategoryName = dto.CategoryName;
         entity.CategoryName = dto.CategoryName;

+ 10 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Supply/AdoS0MaterialPlanCyclesController.cs

@@ -51,16 +51,20 @@ public class AdoS0MaterialPlanCyclesController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0MaterialPlanCycleUpsertDto dto)
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0MaterialPlanCycleUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        var (companyId, factoryId) = await AdoS0OrgScope.ResolveAsync(_rep.Context, tenantId);
+        if (companyId <= 0 || factoryId <= 0) return AdoS0OrgScope.NotConfiguredError("物料计划周期");
         var orderType = NormalizeOrderType(dto.OrderType);
         var orderType = NormalizeOrderType(dto.OrderType);
-        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == dto.FactoryRefId && x.OrderType == orderType))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == factoryId && x.OrderType == orderType))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该工厂与订单类型组合已存在物料计划周期标准");
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该工厂与订单类型组合已存在物料计划周期标准");
 
 
         var now = DateTime.Now;
         var now = DateTime.Now;
         var entity = new AdoS0MaterialPlanCycle
         var entity = new AdoS0MaterialPlanCycle
         {
         {
             TenantId = tenantId,
             TenantId = tenantId,
-            CompanyRefId = dto.CompanyRefId,
-            FactoryRefId = dto.FactoryRefId,
+            // 组织字段后端强制 stamp 当前租户内解析出的组织,忽略 payload 组织值(杜绝跨租户组织引用)。
+            CompanyRefId = companyId,
+            FactoryRefId = factoryId,
             DomainCode = dto.DomainCode,
             DomainCode = dto.DomainCode,
             OrderType = orderType,
             OrderType = orderType,
             StdHours = dto.StdHours,
             StdHours = dto.StdHours,
@@ -83,11 +87,11 @@ public class AdoS0MaterialPlanCyclesController : ControllerBase
         if (entity == null) return NotFound();
         if (entity == null) return NotFound();
 
 
         var orderType = NormalizeOrderType(dto.OrderType);
         var orderType = NormalizeOrderType(dto.OrderType);
-        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.OrderType == orderType))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == entity.FactoryRefId && x.OrderType == orderType))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该工厂与订单类型组合已存在其他记录");
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.DuplicateCode, "该工厂与订单类型组合已存在其他记录");
 
 
-        entity.CompanyRefId = dto.CompanyRefId;
-        entity.FactoryRefId = dto.FactoryRefId;
+        // 组织字段 pin 到既有原值,忽略 payload 组织值:既杜绝篡改成他租户组织,
+        // 也不因一次普通编辑就顺手迁移历史 CROSS-TENANT 存量。
         entity.DomainCode = dto.DomainCode;
         entity.DomainCode = dto.DomainCode;
         entity.OrderType = orderType;
         entity.OrderType = orderType;
         entity.StdHours = dto.StdHours;
         entity.StdHours = dto.StdHours;

+ 10 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Supply/AdoS0SrmPurchasesController.cs

@@ -91,6 +91,11 @@ public class AdoS0SrmPurchasesController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0SrmPurchaseUpsertDto dto)
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0SrmPurchaseUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        // B-1b:本接口用 payload 的公司/工厂作为「物料/组件是否在范围内」的校验作用域,
+        // 强制 stamp 会改变该作用域语义,故改为放行本租户组织、拒绝他租户组织。
+        var orgError = await AdoS0OrgScope.ValidateOrgBelongsToTenantAsync(_rep.Context, tenantId, dto.CompanyRefId, dto.FactoryRefId);
+        if (orgError != null) return orgError;
         var dateErr = ValidateDateRange(dto.EffectiveDate, dto.ExpiringDate);
         var dateErr = ValidateDateRange(dto.EffectiveDate, dto.ExpiringDate);
         if (dateErr != null) return AdoS0ApiErrors.InvalidRequest(dateErr);
         if (dateErr != null) return AdoS0ApiErrors.InvalidRequest(dateErr);
 
 
@@ -111,6 +116,11 @@ public class AdoS0SrmPurchasesController : ControllerBase
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0SrmPurchaseUpsertDto dto)
     public async Task<IActionResult> UpdateAsync(long id, [FromBody] AdoS0SrmPurchaseUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
+
+        // B-1b:本接口用 payload 的公司/工厂作为「物料/组件是否在范围内」的校验作用域,
+        // 强制 stamp 会改变该作用域语义,故改为放行本租户组织、拒绝他租户组织。
+        var orgError = await AdoS0OrgScope.ValidateOrgBelongsToTenantAsync(_rep.Context, tenantId, dto.CompanyRefId, dto.FactoryRefId);
+        if (orgError != null) return orgError;
         var entity = await _rep.ByIdScopedAsync(id, tenantId);
         var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
         if (entity == null) return NotFound();
 
 

+ 10 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S0/Supply/AdoS0SuppliersController.cs

@@ -99,15 +99,19 @@ public class AdoS0SuppliersController : ControllerBase
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0SuppMasterUpsertDto dto)
     public async Task<IActionResult> CreateAsync([FromBody] AdoS0SuppMasterUpsertDto dto)
     {
     {
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
         if (!AdoS0TenantScope.TryResolveRequired(out var tenantId, out var tenantError)) return tenantError!;
-        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == dto.FactoryRefId && x.Supp == dto.Supp))
+
+        var (companyId, factoryId) = await AdoS0OrgScope.ResolveAsync(_rep.Context, tenantId);
+        if (companyId <= 0 || factoryId <= 0) return AdoS0OrgScope.NotConfiguredError("供应商");
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.FactoryRefId == factoryId && x.Supp == dto.Supp))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.SupplierCodeExists, "供应商编码已存在");
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.SupplierCodeExists, "供应商编码已存在");
 
 
         var now = DateTime.Now;
         var now = DateTime.Now;
         var entity = new AdoS0SuppMaster
         var entity = new AdoS0SuppMaster
         {
         {
             TenantId = tenantId,
             TenantId = tenantId,
-            CompanyRefId = dto.CompanyRefId,
-            FactoryRefId = dto.FactoryRefId,
+            // 组织字段后端强制 stamp 当前租户内解析出的组织,忽略 payload 组织值(杜绝跨租户组织引用)。
+            CompanyRefId = companyId,
+            FactoryRefId = factoryId,
             DomainCode = dto.DomainCode,
             DomainCode = dto.DomainCode,
             Supp = dto.Supp,
             Supp = dto.Supp,
             SortName = dto.SortName,
             SortName = dto.SortName,
@@ -145,11 +149,11 @@ public class AdoS0SuppliersController : ControllerBase
         var entity = await _rep.ByIdScopedAsync(id, tenantId);
         var entity = await _rep.ByIdScopedAsync(id, tenantId);
         if (entity == null) return NotFound();
         if (entity == null) return NotFound();
 
 
-        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == dto.FactoryRefId && x.Supp == dto.Supp))
+        if (await _rep.IsAnyAsync(x => x.TenantId == tenantId && x.Id != id && x.FactoryRefId == entity.FactoryRefId && x.Supp == dto.Supp))
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.SupplierCodeExists, "供应商编码已存在");
             return AdoS0ApiErrors.Conflict(AdoS0ErrorCodes.SupplierCodeExists, "供应商编码已存在");
 
 
-        entity.CompanyRefId = dto.CompanyRefId;
-        entity.FactoryRefId = dto.FactoryRefId;
+        // 组织字段 pin 到既有原值,忽略 payload 组织值:既杜绝篡改成他租户组织,
+        // 也不因一次普通编辑就顺手迁移历史 CROSS-TENANT 存量。
         entity.DomainCode = dto.DomainCode;
         entity.DomainCode = dto.DomainCode;
         entity.Supp = dto.Supp;
         entity.Supp = dto.Supp;
         entity.SortName = dto.SortName;
         entity.SortName = dto.SortName;

+ 45 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AdoS0OrgScope.cs

@@ -93,4 +93,49 @@ public static class AdoS0OrgScope
     /// </summary>
     /// </summary>
     public static IActionResult NotConfiguredError(string entityLabel)
     public static IActionResult NotConfiguredError(string entityLabel)
         => AdoS0ApiErrors.InvalidRequest($"当前租户未配置机构,无法确定{entityLabel}的公司/工厂归属,请先在机构管理中维护");
         => AdoS0ApiErrors.InvalidRequest($"当前租户未配置机构,无法确定{entityLabel}的公司/工厂归属,请先在机构管理中维护");
+
+    /// <summary>
+    /// 校验 payload 带来的组织引用未越出当前租户(B-1b:用于「页面自己驱动工厂维度」的接口,
+    /// 这类接口不能强制 stamp 成解析值,否则会与页面的工厂选择错位,故改为放行 + 越权拒绝)。
+    ///
+    /// 判定口径——**只拒绝「确实解析到 SysOrg 且属于他租户」的值**:
+    ///   - 0 / 负数:视为未设置,放行(由各 Controller 自身的必填校验处理);
+    ///   - 在 SysOrg 中查不到(如 legacy 数值域 1000 / 1 等历史值):**不属于跨租户引用**,放行;
+    ///     legacy 数值域的治理是独立批次,本校验不得把历史数据编辑一并拒绝掉。
+    ///   - 能查到且 TenantId != 当前租户:**拒绝**——这正是要堵的越权写入。
+    ///
+    /// 返回 null 表示校验通过;否则返回可直接 return 的 400 业务错误。
+    /// </summary>
+    /// <param name="db">SqlSugar 上下文(调用方一般传 <c>_rep.Context</c>)。</param>
+    /// <param name="tenantId">AdoS0TenantScope 解析出的可信租户(JWT claim)。</param>
+    /// <param name="companyRefId">payload 的公司引用。</param>
+    /// <param name="factoryRefId">payload 的工厂引用。</param>
+    public static async Task<IActionResult?> ValidateOrgBelongsToTenantAsync(
+        ISqlSugarClient db, long tenantId, long companyRefId, long factoryRefId)
+    {
+        var candidates = new List<long>();
+        if (companyRefId > 0) candidates.Add(companyRefId);
+        if (factoryRefId > 0 && factoryRefId != companyRefId) candidates.Add(factoryRefId);
+        if (candidates.Count == 0) return null;
+
+        // 一次取回候选组织的归属;查不到的 id 不在结果里 → 按「非 SysOrg 引用」放行。
+        //
+        // ⚠️ 必须 ClearFilter():SysOrg 继承 EntityBaseTenant,带 SqlSugar 全局租户过滤器。
+        //    不清过滤器时,他租户的组织**根本查不出来**,owners 恒为空 → 越权值会被误判为
+        //    「非 SysOrg 引用」而放行,本校验形同虚设(B-1b 首轮负控实测即因此漏过)。
+        //    本查询只读取 SysOrg 的 Id/TenantId 用于归属判定,不返回任何他租户业务数据。
+        var owners = await db.Queryable<SysOrg>()
+            .ClearFilter()
+            .Where(x => candidates.Contains(x.Id))
+            .Select(x => new { x.Id, x.TenantId })
+            .ToListAsync();
+
+        var offending = owners.FirstOrDefault(o => o.TenantId != tenantId);
+        if (offending != null)
+        {
+            return AdoS0ApiErrors.InvalidRequest(
+                $"公司/工厂归属非法:组织 {offending.Id} 不属于当前租户,拒绝写入");
+        }
+        return null;
+    }
 }
 }