瀏覽代碼

feat(s0): decouple domainCode from factory and restore contract review menu

STEP 1 结构性阻塞清理,为 S0 全模块演示覆盖铺路。SysOrg 零改动。

1) 单-org 租户的公司/工厂选择器兼容
   UATTEST_CHL 只有一个 Type=NULL 的根组织,loadOrgList('201'/'501') 恒返回空,
   客户/雇员/供应商三页的公司、工厂下拉全空,雇员页工厂框还因
   :disabled="!companyRefId" 永久锁死。
   新增 applySingleOrgFallback:typed 列表非空时行为不变;typed 为空且本租户
   仅 1 个组织时,该组织同时作为公司与工厂候选;多组织租户仍返回空并暴露歧义,
   不臆造 Company/Factory。filterFactoriesForCompany 的 self-match 仅在
   singleOrgMode 下生效,不做全局放宽。
   loadOrgList 无 type 时后端返回树,此处统一拍平,避免把多组织租户误判成单组织。

2) domainCode 与 Factory.Code 解耦
   旧口径 domain = 选中工厂的 Code,并在取不到工厂时回落硬编码 '2410'。
   新契约:组织归属与业务数据域互不推导。
     CREATE  A 目标表唯一有效域 → 用该域;B 多域 / C 空表 → 租户域锚
             DepartmentMaster.Domain;D 都取不到 → 留空由后端 required 暴露
     EDIT    永远保留记录自身的 Domain
   删除 S0_FALLBACK_DOMAIN_CODE,移除三页 syncDomainCodeFromFactory 的域改写
   (供应商页保留按当前域重载库位的副作用)。刻意不取 rows[0]:EmployeeMaster
   为 8010×23 + WB8A×3,首行会随排序漂移。

   实测:Customer=2410(唯一域) · Supplier=8010(唯一域) · Employee=8010(多域→锚)
        Material=8010(多域→锚) · 编辑 WB8-QLA 仍为 WB8A,未被改写。

3) 合同评审周期菜单归位(1.0.406)
   Vue/API/表齐备但 SysMenu 缺行,前端路由由菜单驱动故不可达。
   Id 1329002000004 与 OrderNo 4 恰为产销建模下的历史空缺槽位,按三件套幂等补齐
   SysMenu + SysTenantMenu + SysRoleMenu(UAT_FUNCTION_USER)。

边界:SysOrg = 0(S8 trusted factory 仍为 838257186320453,未分裂);
后端业务代码 = 0;DDL = 0;DELETE = 0;未补任何业务主数据。
回归:19 个 org-scope helper 消费页首屏 total 全部无变化,console/API 错误 0。

chore: bump version server 1.0.406 / Web 2.4.329
YY968XX 2 天之前
父節點
當前提交
36e078725d

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 {
 	"name": "admin.net",
 	"name": "admin.net",
 	"type": "module",
 	"type": "module",
-	"version": "2.4.328",
+	"version": "2.4.329",
 	"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 通用权限开发框架",

+ 6 - 1
Web/src/views/aidop/s0/api/s0SalesApi.ts

@@ -766,9 +766,14 @@ export async function loadOrgList(type?: string): Promise<OrgOption[]> {
 	try {
 	try {
 		const api = new SysOrgApi(undefined, undefined, service);
 		const api = new SysOrgApi(undefined, undefined, service);
 		const res = await api.apiSysOrgListGet(0, undefined, undefined, type);
 		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 返回:
 		// 统一在进入前端前做 string 归一化,兼容后端历史上偶发 number 返回:
 		// 雪花 ID 超 JS Number 安全范围,禁止朝 number 方向强转,只做朝 string 方向的归一化。
 		// 雪花 ID 超 JS Number 安全范围,禁止朝 number 方向强转,只做朝 string 方向的归一化。
-		return (res.data.result ?? []).map((item) => ({
+		return flatten((res.data.result as any[]) ?? []).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,

+ 136 - 28
Web/src/views/aidop/s0/composables/useS0MfgOrgScope.ts

@@ -1,6 +1,7 @@
 import { ref } from 'vue';
 import { ref } from 'vue';
 import { useUserInfo } from '/@/stores/userInfo';
 import { useUserInfo } from '/@/stores/userInfo';
 import { loadOrgList, type OrgOption } from '../api/s0SalesApi';
 import { loadOrgList, type OrgOption } from '../api/s0SalesApi';
+import { s0DepartmentsApi } from '../api/s0WarehouseApi';
 
 
 /**
 /**
  * S0 制造页共用:公司(201) / 工厂(501) 下拉
  * S0 制造页共用:公司(201) / 工厂(501) 下拉
@@ -8,22 +9,91 @@ import { loadOrgList, type OrgOption } from '../api/s0SalesApi';
  * 组织 ID 全链路为 string 语义(雪花 ID 超 JS number 精度,禁止转 number)。
  * 组织 ID 全链路为 string 语义(雪花 ID 超 JS number 精度,禁止转 number)。
  * 未选状态统一为 undefined / null / '',不使用 0。
  * 未选状态统一为 undefined / null / '',不使用 0。
  */
  */
+/**
+ * 公司(201)/工厂(501) 选项的统一加载器(含单-org 兼容回落)。
+ *
+ * 契约:
+ *   typed 列表非空                → 直接返回 typed 列表(多组织租户,行为与历史一致)
+ *   typed 为空 且 本租户仅 1 个 org → 该 org 同时作为公司与工厂候选(singleOrgMode=true)
+ *   typed 为空 且 本租户多个 org   → 返回空列表,暴露歧义,**不臆造** Company/Factory
+ *
+ * 回落**只**适用于单-org 租户;绝不把多-org 租户的任意 org 冒充公司/工厂。
+ */
+export interface S0OrgSelectorOptions {
+	companies: OrgOption[];
+	factories: OrgOption[];
+	singleOrgMode: boolean;
+}
+
+/**
+ * 对**已加载**的 typed 组织列表施加单-org 兼容回落。
+ * 页面可保留自己的 Promise.all 批量取数,取完后调用本函数做后处理,避免重复请求。
+ */
+export async function applySingleOrgFallback(c: OrgOption[], f: OrgOption[]): Promise<S0OrgSelectorOptions> {
+	const companies = c.filter((x) => x.id && x.id !== '0');
+	const factories = f.filter((x) => x.id && x.id !== '0');
+	if (companies.length > 0 || factories.length > 0) {
+		return { companies, factories, singleOrgMode: false };
+	}
+	const all = (await loadOrgList()).filter((x) => x.id && x.id !== '0');
+	if (all.length === 1) {
+		return { companies: all, factories: all, singleOrgMode: true };
+	}
+	// 多-org 租户却取不到 201/501:暴露歧义,不臆造。
+	return { companies: [], factories: [], singleOrgMode: false };
+}
+
+export async function loadCompanyFactoryOptions(): Promise<S0OrgSelectorOptions> {
+	const [c, f] = await Promise.all([loadOrgList('201'), loadOrgList('501')]);
+	return applySingleOrgFallback(c, f);
+}
+
+/**
+ * 表单工厂候选:正常按 pid 归属过滤;单-org 兼容模式下额外允许 self-match。
+ * self-match 只在 singleOrgMode 为真时生效,不做全局放宽。
+ */
+export function filterFactoriesForCompany(
+	factories: OrgOption[],
+	companyId: string | undefined | null,
+	singleOrgMode: boolean
+): OrgOption[] {
+	if (companyId == null || companyId === '') return factories;
+	return factories.filter((x) => x.pid === companyId || (singleOrgMode && x.id === companyId));
+}
+
 export function useS0MfgOrgScope() {
 export function useS0MfgOrgScope() {
 	const companyOptions = ref<OrgOption[]>([]);
 	const companyOptions = ref<OrgOption[]>([]);
 	const factoryOptions = ref<OrgOption[]>([]);
 	const factoryOptions = ref<OrgOption[]>([]);
+	/** 单-org 兼容模式:本租户 SysOrg 只有一个节点,它同时充当公司与工厂 selector 值。 */
+	const singleOrgMode = ref(false);
 
 
+	/**
+	 * 公司(201)/工厂(501) 下拉。
+	 *
+	 * 单-org 兼容回落(S0-WORKCENTER 之后的 org selector 契约):
+	 *   typed 列表非空          → 直接使用 typed 列表(正常多组织租户,行为不变)
+	 *   typed 为空 且 本租户仅 1 个 org → 该 org 同时作为公司与工厂候选(单-org 兼容模式)
+	 *   typed 为空 且 本租户多个 org   → 不臆造,返回空列表并暴露歧义(沿用既有 UI 契约)
+	 *
+	 * 边界:回落**只**适用于单-org 租户;绝不允许把多-org 租户的任意 org 冒充公司/工厂。
+	 */
 	async function loadOrgs() {
 	async function loadOrgs() {
-		const [c, f] = await Promise.all([loadOrgList('201'), loadOrgList('501')]);
-		companyOptions.value = c;
-		factoryOptions.value = f;
+		const r = await loadCompanyFactoryOptions();
+		companyOptions.value = r.companies;
+		factoryOptions.value = r.factories;
+		singleOrgMode.value = r.singleOrgMode;
 	}
 	}
 
 
+	/**
+	 * 正常模式:工厂必须挂在所选公司下(factory.pid === companyId)。
+	 * 单-org 兼容模式:同一个 org 既是公司又是工厂,额外允许 self-match(factory.id === companyId)。
+	 * self-match 规则仅在 singleOrgMode 下生效,不做全局放宽。
+	 */
 	function factoriesForCompany(cid: string | undefined | null) {
 	function factoriesForCompany(cid: string | undefined | null) {
-		if (cid == null || cid === '') return factoryOptions.value;
-		return factoryOptions.value.filter((x) => x.pid === cid);
+		return filterFactoriesForCompany(factoryOptions.value, cid, singleOrgMode.value);
 	}
 	}
 
 
-	return { companyOptions, factoryOptions, loadOrgs, factoriesForCompany };
+	return { companyOptions, factoryOptions, singleOrgMode, loadOrgs, factoriesForCompany };
 }
 }
 
 
 // ── S0 公司/工厂后台归属治理(前台无感化 + 归属保值)────────────────────────────
 // ── S0 公司/工厂后台归属治理(前台无感化 + 归属保值)────────────────────────────
@@ -41,19 +111,19 @@ export function useS0MfgOrgScope() {
 export const S0_DEFAULT_COMPANY_CODE = 'S0-C001';
 export const S0_DEFAULT_COMPANY_CODE = 'S0-C001';
 export const S0_DEFAULT_FACTORY_CODE = 'S0-F001';
 export const S0_DEFAULT_FACTORY_CODE = 'S0-F001';
 
 
-/**
- * 兜底工厂域编码(当前版本临时口径)。
- *
- * 未铺 S0-C001 / S0-F001 种子组织的租户取不到工厂,domainCode 便无来源;而部分后端
- * DTO(AdoS0LineMasterUpsertDto / AdoS0StdOpMasterUpsertDto 的 Domain)为 [Required]
- * 非空,域编码为空会被直接拒绝,页面无法保存。
- *
- * ⚠️ 已知不一致:这类租户的历史数据域编码并不统一(实测 UATTEST_CHL:
- * LineMaster / StdOpMaster 为 '8010',CustMaster 为 '2410'),前端无可靠依据按页面区分,
- * 故当前版本统一取 '2410'。新记录与部分历史记录的域编码会不同,属已知取舍。
- * 后续若为租户补齐真实工厂组织,本兜底会自动让位于工厂 code(见 getDefaultS0OrgScope)。
- */
-export const S0_FALLBACK_DOMAIN_CODE = '2410';
+// ── domainCode 与 Factory.Code 解耦(S0-FULL-DEMO-COVERAGE-CLOSURE-1 STEP 1)──────
+//
+// 旧口径:domainCode = 选中工厂的 Code(getDefaultS0OrgScope / syncDomainCodeFromFactory),
+// 并在取不到工厂时回落到硬编码常量 '2410'。该耦合导致:
+//   ① 组织选择会改写业务数据域;② 硬编码常量与各表既有域(2410/8010/8011/WB8A)冲突。
+//
+// 新契约:Company/Factory 只负责**组织归属**,Domain 只负责**业务数据域**,两者不再互相推导。
+//   CREATE:A 目标表有效业务数据只有一个非空 Domain → 用该 Domain
+//           B 目标表存在多个 Domain            → 用租户域锚 DepartmentMaster.Domain
+//           C 目标表为空                       → 用租户域锚
+//           D 两者都解析不到                   → 保持空,由后端 required 校验暴露配置缺失
+//   EDIT  :永远保留记录自身的 Domain,不重算(见 preserveOrgScopeForEdit)
+// 禁止:Factory.Code → Domain;禁止全局硬编码域;禁止取首行(rows[0])。
 
 
 /** 组织归属三件套(string 语义;未定为 undefined)。 */
 /** 组织归属三件套(string 语义;未定为 undefined)。 */
 export interface S0OrgScope {
 export interface S0OrgScope {
@@ -95,26 +165,64 @@ function getCurrentTenantOrgId(): string | undefined {
 }
 }
 
 
 /**
 /**
- * 从已加载组织列表按 code 求默认归属;取不到种子组织时回落到当前租户自身组织。
- * 两者都取不到才返回空对象(不脑补 id/domainCode)。
- * domainCode 口径:正常路径取工厂 code;兜底路径无工厂可取,用 S0_FALLBACK_DOMAIN_CODE。
+ * 从已加载组织列表按 code 求默认**组织归属**;取不到种子组织时回落到当前租户自身组织。
+ * 两者都取不到才返回空对象(不脑补 id)。
+ *
+ * ⚠️ 本函数**不再返回 domainCode** —— Domain 由 resolveCreateDomain 独立解析(见上方契约)。
  */
  */
 export function getDefaultS0OrgScope(companyOptions: OrgOption[], factoryOptions: OrgOption[]): S0OrgScope {
 export function getDefaultS0OrgScope(companyOptions: OrgOption[], factoryOptions: OrgOption[]): S0OrgScope {
 	const company = companyOptions.find((item) => item.code === S0_DEFAULT_COMPANY_CODE);
 	const company = companyOptions.find((item) => item.code === S0_DEFAULT_COMPANY_CODE);
 	if (!company) {
 	if (!company) {
 		const tenantOrgId = getCurrentTenantOrgId();
 		const tenantOrgId = getCurrentTenantOrgId();
-		return tenantOrgId
-			? { companyRefId: tenantOrgId, factoryRefId: tenantOrgId, domainCode: S0_FALLBACK_DOMAIN_CODE }
-			: {};
+		return tenantOrgId ? { companyRefId: tenantOrgId, factoryRefId: tenantOrgId } : {};
 	}
 	}
 	const factory = factoryOptions.find((item) => item.code === S0_DEFAULT_FACTORY_CODE && item.pid === company.id);
 	const factory = factoryOptions.find((item) => item.code === S0_DEFAULT_FACTORY_CODE && item.pid === company.id);
 	return {
 	return {
 		companyRefId: company.id,
 		companyRefId: company.id,
 		factoryRefId: factory?.id,
 		factoryRefId: factory?.id,
-		domainCode: factory?.code ?? undefined,
 	};
 	};
 }
 }
 
 
+/**
+ * 租户域锚:本租户 DepartmentMaster 的 Domain。
+ *
+ * 与服务端 CLOSURE-1 迁移脚本的 `@dom := (SELECT Domain FROM DepartmentMaster WHERE tenant_id=@dst LIMIT 1)`
+ * 同源,是本租户已确立的业务域锚点;数据派生,非硬编码。
+ */
+export async function loadTenantDomainAnchor(): Promise<string | undefined> {
+	try {
+		const data = await s0DepartmentsApi.list({ page: 1, pageSize: 1 });
+		const d = (data?.list ?? [])[0]?.domainCode;
+		const v = d == null ? '' : String(d).trim();
+		return v === '' ? undefined : v;
+	} catch (e) {
+		console.warn('[s0] loadTenantDomainAnchor failed', e);
+		return undefined;
+	}
+}
+
+/**
+ * CREATE 时的 Domain 解析(契约 A/B/C/D)。
+ *
+ * @param domains        目标表**已完整观测到**的 domainCode 集合(见 seenWholeTable)
+ * @param seenWholeTable 是否已看到该表全部业务行(total <= 已加载行数)。
+ *                       只有看全了才能断言「唯一域」,否则分页可能只覆盖到其中一个域,
+ *                       会把多域表误判成单域表(例:EmployeeMaster 首页 20 行全是 8010,
+ *                       但整表还有 3 行 WB8A)——这正是禁止使用 rows[0] 的同一类风险。
+ * @param tenantAnchor   租户域锚(DepartmentMaster.Domain)
+ */
+export function resolveCreateDomain(
+	domains: Array<string | null | undefined>,
+	seenWholeTable: boolean,
+	tenantAnchor?: string
+): string | undefined {
+	if (seenWholeTable) {
+		const distinct = Array.from(new Set(domains.map((d) => (d == null ? '' : String(d).trim())).filter((d) => d !== '')));
+		if (distinct.length === 1) return distinct[0]; // A:目标表唯一有效域
+	}
+	return tenantAnchor || undefined; // B / C:多域或空表 → 租户域锚;D:锚也没有 → undefined
+}
+
 /**
 /**
  * 新增:仅当归属字段为空时补默认,已有值不覆盖。就地写回 form。
  * 新增:仅当归属字段为空时补默认,已有值不覆盖。就地写回 form。
  */
  */
@@ -126,7 +234,7 @@ export function applyDefaultOrgScopeForCreate(
 	const d = getDefaultS0OrgScope(companyOptions, factoryOptions);
 	const d = getDefaultS0OrgScope(companyOptions, factoryOptions);
 	if (isEmptyScopeValue(form.companyRefId)) form.companyRefId = d.companyRefId;
 	if (isEmptyScopeValue(form.companyRefId)) form.companyRefId = d.companyRefId;
 	if (isEmptyScopeValue(form.factoryRefId)) form.factoryRefId = d.factoryRefId;
 	if (isEmptyScopeValue(form.factoryRefId)) form.factoryRefId = d.factoryRefId;
-	if (isEmptyScopeValue(form.domainCode) && d.domainCode != null) form.domainCode = d.domainCode;
+	// domainCode 不再由组织归属推导(去 Factory.Code 耦合);由各页 resolveCreateDomain 独立解析。
 }
 }
 
 
 /** 接口返回记录里归属字段的只读形状(domainCode 可能为 null)。 */
 /** 接口返回记录里归属字段的只读形状(domainCode 可能为 null)。 */
@@ -160,7 +268,7 @@ export function normalizeOrgScopeForSubmit(
 	const d = getDefaultS0OrgScope(companyOptions, factoryOptions);
 	const d = getDefaultS0OrgScope(companyOptions, factoryOptions);
 	if (isEmptyScopeValue(form.companyRefId)) form.companyRefId = d.companyRefId;
 	if (isEmptyScopeValue(form.companyRefId)) form.companyRefId = d.companyRefId;
 	if (isEmptyScopeValue(form.factoryRefId)) form.factoryRefId = d.factoryRefId;
 	if (isEmptyScopeValue(form.factoryRefId)) form.factoryRefId = d.factoryRefId;
-	if (isEmptyScopeValue(form.domainCode) && d.domainCode != null) form.domainCode = d.domainCode;
+	// domainCode 不再由组织归属推导(去 Factory.Code 耦合);由各页 resolveCreateDomain 独立解析。
 }
 }
 
 
 /**
 /**

+ 18 - 11
Web/src/views/aidop/s0/sales/CustomerList.vue

@@ -95,7 +95,6 @@
 								placeholder="请选择工厂"
 								placeholder="请选择工厂"
 								style="width: 100%"
 								style="width: 100%"
 								:disabled="!form.companyRefId || selectedCompanyHasNoFactories"
 								:disabled="!form.companyRefId || selectedCompanyHasNoFactories"
-								@change="syncDomainCodeFromFactory"
 							>
 							>
 								<el-option v-for="item in formFactoryOptions" :key="item.id" :label="item.name || item.code || `${item.id}`" :value="item.id" />
 								<el-option v-for="item in formFactoryOptions" :key="item.id" :label="item.name || item.code || `${item.id}`" :value="item.id" />
 							</el-select>
 							</el-select>
@@ -217,6 +216,10 @@ import {
 } from '../api/s0SalesApi';
 } from '../api/s0SalesApi';
 import {
 import {
 	applyDefaultOrgScopeForCreate,
 	applyDefaultOrgScopeForCreate,
+	applySingleOrgFallback,
+	filterFactoriesForCompany,
+	loadTenantDomainAnchor,
+	resolveCreateDomain,
 	normalizeOrgScopeForSubmit,
 	normalizeOrgScopeForSubmit,
 	preserveOrgScopeForEdit,
 	preserveOrgScopeForEdit,
 	shouldClearFactoryOnCompanyChange,
 	shouldClearFactoryOnCompanyChange,
@@ -293,17 +296,16 @@ const filteredFactoryOptions = computed(() => {
 	return factoryOptions.value.filter((item) => item.pid === query.companyRefId);
 	return factoryOptions.value.filter((item) => item.pid === query.companyRefId);
 });
 });
 
 
-const formFactoryOptions = computed(() => {
-	if (!form.companyRefId) return factoryOptions.value;
-	return factoryOptions.value.filter((item) => item.pid === form.companyRefId);
-});
+const singleOrgMode = ref(false);
+const tenantDomainAnchor = ref<string | undefined>(undefined);
+
+const formFactoryOptions = computed(() =>
+	filterFactoriesForCompany(factoryOptions.value, form.companyRefId, singleOrgMode.value)
+);
 
 
 const selectedCompanyHasNoFactories = computed(() => Boolean(form.companyRefId) && formFactoryOptions.value.length === 0);
 const selectedCompanyHasNoFactories = computed(() => Boolean(form.companyRefId) && formFactoryOptions.value.length === 0);
 
 
-function syncDomainCodeFromFactory() {
-	const f = factoryOptions.value.find((item) => item.id === form.factoryRefId);
-	form.domainCode = f?.code ?? '';
-}
+// 去 Factory.Code 耦合:选择工厂不再改写 domainCode(domain 由 resolveCreateDomain 独立解析)。
 
 
 function stringifyBusinessError(value: unknown): string | undefined {
 function stringifyBusinessError(value: unknown): string | undefined {
 	if (!value) return undefined;
 	if (!value) return undefined;
@@ -388,8 +390,11 @@ async function loadOptions() {
 		loadDictOptions('s0_currency'),
 		loadDictOptions('s0_currency'),
 	]);
 	]);
 	// 组织 ID 为 string 雪花 ID,过滤掉空/伪 0 项
 	// 组织 ID 为 string 雪花 ID,过滤掉空/伪 0 项
-	companyOptions.value = companies.filter((item) => item.id && item.id !== '0');
-	factoryOptions.value = factories.filter((item) => item.id && item.id !== '0');
+	const orgScope = await applySingleOrgFallback(companies, factories);
+	companyOptions.value = orgScope.companies;
+	factoryOptions.value = orgScope.factories;
+	singleOrgMode.value = orgScope.singleOrgMode;
+	tenantDomainAnchor.value = await loadTenantDomainAnchor();
 	currencyOptions.value = currencies;
 	currencyOptions.value = currencies;
 
 
 	// 公司/工厂已降级为后台归属字段(前台隐藏、新增由 applyDefaultOrgScopeForCreate 兜底补当前租户组织),
 	// 公司/工厂已降级为后台归属字段(前台隐藏、新增由 applyDefaultOrgScopeForCreate 兜底补当前租户组织),
@@ -472,6 +477,8 @@ function openCreate() {
 	resetForm();
 	resetForm();
 	// 新增:自动补默认 SysOrg 归属(公司/工厂/domainCode),用户无需选择公司/工厂。
 	// 新增:自动补默认 SysOrg 归属(公司/工厂/domainCode),用户无需选择公司/工厂。
 	applyDefaultOrgScopeForCreate(form, companyOptions.value, factoryOptions.value);
 	applyDefaultOrgScopeForCreate(form, companyOptions.value, factoryOptions.value);
+	form.domainCode =
+		resolveCreateDomain(rows.value.map((r) => r.domainCode), total.value <= rows.value.length, tenantDomainAnchor.value) ?? '';
 	dialogTitle.value = '新增客户';
 	dialogTitle.value = '新增客户';
 	dialogVisible.value = true;
 	dialogVisible.value = true;
 }
 }

+ 19 - 11
Web/src/views/aidop/s0/sales/MaterialList.vue

@@ -110,7 +110,7 @@
 							</el-col>
 							</el-col>
 							<el-col :span="12">
 							<el-col :span="12">
 								<el-form-item label="工厂" prop="factoryRefId">
 								<el-form-item label="工厂" prop="factoryRefId">
-									<el-select v-model="form.factoryRefId" filterable style="width: 100%" @change="syncDomainCodeFromFactory">
+									<el-select v-model="form.factoryRefId" filterable style="width: 100%">
 										<el-option v-for="item in formFactoryOptions" :key="item.id" :label="item.name || item.code || `${item.id}`" :value="item.id" />
 										<el-option v-for="item in formFactoryOptions" :key="item.id" :label="item.name || item.code || `${item.id}`" :value="item.id" />
 									</el-select>
 									</el-select>
 								</el-form-item>
 								</el-form-item>
@@ -245,6 +245,10 @@ import {
 } from '../api/s0SalesApi';
 } from '../api/s0SalesApi';
 import {
 import {
 	applyDefaultOrgScopeForCreate,
 	applyDefaultOrgScopeForCreate,
+	applySingleOrgFallback,
+	filterFactoriesForCompany,
+	loadTenantDomainAnchor,
+	resolveCreateDomain,
 	normalizeOrgScopeForSubmit,
 	normalizeOrgScopeForSubmit,
 	preserveOrgScopeForEdit,
 	preserveOrgScopeForEdit,
 	shouldClearFactoryOnCompanyChange,
 	shouldClearFactoryOnCompanyChange,
@@ -364,15 +368,14 @@ const filteredFactoryOptions = computed(() => {
 	return factoryOptions.value.filter((item) => item.pid === query.companyRefId);
 	return factoryOptions.value.filter((item) => item.pid === query.companyRefId);
 });
 });
 
 
-const formFactoryOptions = computed(() => {
-	if (!form.companyRefId) return factoryOptions.value;
-	return factoryOptions.value.filter((item) => item.pid === form.companyRefId);
-});
+const singleOrgMode = ref(false);
+const tenantDomainAnchor = ref<string | undefined>(undefined);
 
 
-function syncDomainCodeFromFactory() {
-	const f = factoryOptions.value.find((item) => item.id === form.factoryRefId);
-	form.domainCode = f?.code ?? '';
-}
+const formFactoryOptions = computed(() =>
+	filterFactoriesForCompany(factoryOptions.value, form.companyRefId, singleOrgMode.value)
+);
+
+// 去 Factory.Code 耦合:选择工厂不再改写 domainCode(domain 由 resolveCreateDomain 独立解析)。
 
 
 watch(
 watch(
 	() => form.companyRefId,
 	() => form.companyRefId,
@@ -403,8 +406,11 @@ async function loadOptions() {
 		loadDictOptions('s0_stock_type'),
 		loadDictOptions('s0_stock_type'),
 		loadDictOptions('s0_owner_application'),
 		loadDictOptions('s0_owner_application'),
 	]);
 	]);
-	companyOptions.value = companies;
-	factoryOptions.value = factories;
+	const orgScope = await applySingleOrgFallback(companies, factories);
+	companyOptions.value = orgScope.companies;
+	factoryOptions.value = orgScope.factories;
+	singleOrgMode.value = orgScope.singleOrgMode;
+	tenantDomainAnchor.value = await loadTenantDomainAnchor();
 	materialTypeOptions.value = materialTypes;
 	materialTypeOptions.value = materialTypes;
 	plCategoryOptions.value = plCategories;
 	plCategoryOptions.value = plCategories;
 	stockTypeOptions.value = stockTypes;
 	stockTypeOptions.value = stockTypes;
@@ -543,6 +549,8 @@ function openCreate() {
 	resetForm();
 	resetForm();
 	// 新增:自动补默认 SysOrg 归属(公司/工厂/domainCode),用户无需选择公司/工厂。
 	// 新增:自动补默认 SysOrg 归属(公司/工厂/domainCode),用户无需选择公司/工厂。
 	applyDefaultOrgScopeForCreate(form, companyOptions.value, factoryOptions.value);
 	applyDefaultOrgScopeForCreate(form, companyOptions.value, factoryOptions.value);
+	form.domainCode =
+		resolveCreateDomain(rows.value.map((r) => r.domainCode), total.value <= rows.value.length, tenantDomainAnchor.value) ?? '';
 	dialogTitle.value = '新增物料';
 	dialogTitle.value = '新增物料';
 	dialogVisible.value = true;
 	dialogVisible.value = true;
 }
 }

+ 15 - 9
Web/src/views/aidop/s0/supply/SupplierList.vue

@@ -270,7 +270,7 @@ import {
 	type S0SuppMasterUpsert,
 	type S0SuppMasterUpsert,
 } from '../api/s0SupplyApi';
 } from '../api/s0SupplyApi';
 import { s0LocationsApi, type S0LocationOptionRow } from '../api/s0WarehouseApi';
 import { s0LocationsApi, type S0LocationOptionRow } from '../api/s0WarehouseApi';
-import { applyDefaultOrgScopeForCreate, normalizeOrgScopeForSubmit, preserveOrgScopeForEdit, shouldClearFactoryOnCompanyChange } from '../composables/useS0MfgOrgScope';
+import { applyDefaultOrgScopeForCreate, applySingleOrgFallback, filterFactoriesForCompany, loadTenantDomainAnchor, normalizeOrgScopeForSubmit, preserveOrgScopeForEdit, resolveCreateDomain, shouldClearFactoryOnCompanyChange } from '../composables/useS0MfgOrgScope';
 
 
 type ApCyclePresetKey = 'none' | 'fullMonth' | 'firstHalf' | 'secondHalf' | 'dualHalf' | 'crossMonth' | 'custom';
 type ApCyclePresetKey = 'none' | 'fullMonth' | 'firstHalf' | 'secondHalf' | 'dualHalf' | 'crossMonth' | 'custom';
 
 
@@ -397,14 +397,15 @@ const filteredFactoryOptions = computed(() => {
 	return factoryOptions.value.filter((item) => item.pid === query.companyRefId);
 	return factoryOptions.value.filter((item) => item.pid === query.companyRefId);
 });
 });
 
 
-const formFactoryOptions = computed(() => {
-	if (!form.companyRefId) return factoryOptions.value;
-	return factoryOptions.value.filter((item) => item.pid === form.companyRefId);
-});
+const singleOrgMode = ref(false);
+const tenantDomainAnchor = ref<string | undefined>(undefined);
+
+const formFactoryOptions = computed(() =>
+	filterFactoriesForCompany(factoryOptions.value, form.companyRefId, singleOrgMode.value)
+);
 
 
+// 去 Factory.Code 耦合:选择工厂不再改写 domainCode,仅按当前 domain 重载库位候选。
 function syncDomainCodeFromFactory() {
 function syncDomainCodeFromFactory() {
-	const f = factoryOptions.value.find((item) => item.id === form.factoryRefId);
-	form.domainCode = f?.code ?? '';
 	void loadLocationOptions(form.domainCode);
 	void loadLocationOptions(form.domainCode);
 }
 }
 
 
@@ -434,8 +435,11 @@ async function loadOptions() {
 		loadDictOptions('s0_cr_terms'),
 		loadDictOptions('s0_cr_terms'),
 		loadDictOptions('s0_tax_class'),
 		loadDictOptions('s0_tax_class'),
 	]);
 	]);
-	companyOptions.value = companies;
-	factoryOptions.value = factories;
+	const orgScope = await applySingleOrgFallback(companies, factories);
+	companyOptions.value = orgScope.companies;
+	factoryOptions.value = orgScope.factories;
+	singleOrgMode.value = orgScope.singleOrgMode;
+	tenantDomainAnchor.value = await loadTenantDomainAnchor();
 	currencyOptions.value = currencies;
 	currencyOptions.value = currencies;
 	crTermsOptions.value = crTerms;
 	crTermsOptions.value = crTerms;
 	taxClassOptions.value = taxClass;
 	taxClassOptions.value = taxClass;
@@ -523,6 +527,8 @@ function openCreate() {
 	resetForm();
 	resetForm();
 	// 新增:自动补默认 SysOrg 归属,用户无需选择公司/工厂。
 	// 新增:自动补默认 SysOrg 归属,用户无需选择公司/工厂。
 	applyDefaultOrgScopeForCreate(form, companyOptions.value, factoryOptions.value);
 	applyDefaultOrgScopeForCreate(form, companyOptions.value, factoryOptions.value);
+	form.domainCode =
+		resolveCreateDomain(rows.value.map((r) => r.domainCode), total.value <= rows.value.length, tenantDomainAnchor.value) ?? '';
 	apCyclePreset.value = 'none';
 	apCyclePreset.value = 'none';
 	dialogTitle.value = '新增供应商';
 	dialogTitle.value = '新增供应商';
 	dialogVisible.value = true;
 	dialogVisible.value = true;

+ 26 - 8
Web/src/views/aidop/s0/warehouse/EmployeeList.vue

@@ -143,6 +143,13 @@
 
 
 <script setup lang="ts" name="aidopS0WhEmployee">
 <script setup lang="ts" name="aidopS0WhEmployee">
 import { computed, onMounted, reactive, ref, watch } from 'vue';
 import { computed, onMounted, reactive, ref, watch } from 'vue';
+import {
+	applyDefaultOrgScopeForCreate,
+	filterFactoriesForCompany,
+	loadCompanyFactoryOptions,
+	loadTenantDomainAnchor,
+	resolveCreateDomain,
+} from '../composables/useS0MfgOrgScope';
 import { useRoute } from 'vue-router';
 import { useRoute } from 'vue-router';
 import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
 import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
 import AidopDemoShell from '../../components/AidopDemoShell.vue';
 import AidopDemoShell from '../../components/AidopDemoShell.vue';
@@ -199,10 +206,12 @@ function applyDefaultOrg() {
 }
 }
 const companyOptions = ref<OrgOption[]>([]);
 const companyOptions = ref<OrgOption[]>([]);
 const factoryOptions = ref<OrgOption[]>([]);
 const factoryOptions = ref<OrgOption[]>([]);
-const formFactoryOptions = computed(() => {
-	if (!form.companyRefId) return factoryOptions.value;
-	return factoryOptions.value.filter((item) => item.pid === form.companyRefId);
-});
+const singleOrgMode = ref(false);
+const tenantDomainAnchor = ref<string | undefined>(undefined);
+
+const formFactoryOptions = computed(() =>
+	filterFactoriesForCompany(factoryOptions.value, form.companyRefId, singleOrgMode.value)
+);
 
 
 const rules: FormRules = {
 const rules: FormRules = {
 	employee: [{ required: true, message: '请填写员工编码', trigger: 'blur' }],
 	employee: [{ required: true, message: '请填写员工编码', trigger: 'blur' }],
@@ -237,7 +246,14 @@ function resetForm() {
 	Object.assign(form, emptyForm());
 	Object.assign(form, emptyForm());
 	formRef.value?.clearValidate();
 	formRef.value?.clearValidate();
 }
 }
-function openCreate() { resetForm(); dialogTitle.value = '新增雇员'; dialogVisible.value = true; }
+function openCreate() {
+	resetForm();
+	applyDefaultOrgScopeForCreate(form, companyOptions.value, factoryOptions.value);
+	form.domainCode =
+		resolveCreateDomain(rows.value.map((r) => r.domainCode), total.value <= rows.value.length, tenantDomainAnchor.value) ?? '';
+	dialogTitle.value = '新增雇员';
+	dialogVisible.value = true;
+}
 function openEdit(row: S0EmployeeRow) {
 function openEdit(row: S0EmployeeRow) {
 	resetForm(); editingId.value = row.id; dialogTitle.value = `编辑雇员 ${row.employee}`;
 	resetForm(); editingId.value = row.id; dialogTitle.value = `编辑雇员 ${row.employee}`;
 	Object.assign(form, {
 	Object.assign(form, {
@@ -270,9 +286,11 @@ function onDelete(row: S0EmployeeRow) {
 }
 }
 
 
 async function loadOrgOptions() {
 async function loadOrgOptions() {
-	const [companies, factories] = await Promise.all([loadOrgList('201'), loadOrgList('501')]);
-	companyOptions.value = companies.filter((item) => item.id && item.id !== '0');
-	factoryOptions.value = factories.filter((item) => item.id && item.id !== '0');
+	const orgScope = await loadCompanyFactoryOptions();
+	companyOptions.value = orgScope.companies;
+	factoryOptions.value = orgScope.factories;
+	singleOrgMode.value = orgScope.singleOrgMode;
+	tenantDomainAnchor.value = await loadTenantDomainAnchor();
 }
 }
 
 
 // 加载真实下拉源:部门 / 生产线 / 工作中心 / 岗位
 // 加载真实下拉源:部门 / 生产线 / 工作中心 / 岗位

+ 9 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -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.405</AssemblyVersion>
-    <FileVersion>1.0.405</FileVersion>
-    <Version>1.0.405</Version>
+    <AssemblyVersion>1.0.406</AssemblyVersion>
+    <FileVersion>1.0.406</FileVersion>
+    <Version>1.0.406</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>
@@ -553,6 +553,12 @@
     <None Update="UpdateScripts\1.0.402.verify.sql">
     <None Update="UpdateScripts\1.0.402.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
     </None>
+    <None Update="UpdateScripts\1.0.406.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.406.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
   </ItemGroup>
 
 
   <ItemGroup>
   <ItemGroup>

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

@@ -0,0 +1,65 @@
+-- ============================================================================
+-- S0-FULL-DEMO-COVERAGE-CLOSURE-1 · STEP 1:合同评审周期菜单归位(三件套)
+--
+-- 背景:Web/src/views/aidop/s0/sales/ContractReviewCycleList.vue(681 行)与
+--   /api/s0/sales/contract-review-cycles 等 3 个接口、S0ContractReviewCycle 等 2 张表
+--   均已存在,唯独 SysMenu 缺行;前端 isRequestRoutes=true(路由由菜单驱动),
+--   故该页在任何租户都不可达(BLOCKED_BY_MENU)。
+--
+-- 归位而非新造:产销建模子菜单占用 Id …001/002/003/005/006/007 与 OrderNo 1/2/3/5/6/7,
+--   Id 1329002000004 与 OrderNo 4 恰好双双空缺,与历史「菜单 Id 被复用删除」的记录吻合。
+--
+-- 三件套(缺一不可,只插 SysMenu 会导致租户/角色不可达):
+--   ① SysMenu           菜单本身
+--   ② SysTenantMenu     租户可见(UATTEST_CHL)
+--   ③ SysRoleMenu       角色可授权(UAT_FUNCTION_USER)
+--
+-- 幂等:三处均以 NOT EXISTS 保护;重复执行 affected rows = 0。
+-- 边界:不改 SysOrg、不动业务数据、无 DDL、无 DELETE。
+--
+-- ⚠️ WIP:不占正式版本号,AutoVersionUpdate 不会自动应用;提交时 rename 为
+--    UpdateScripts/1.0.<next>.sql 并登记 csproj Copy 条目。
+-- ============================================================================
+
+-- ---------------------------------------------------------------------------
+-- ① SysMenu:合同评审周期
+--    字段口径对齐同级兄弟菜单 1329002000006 / 1329002000007
+--    (Type=2 菜单 / Status=1 启用 / IsHide=0 / IsKeepAlive=1 / IsAffix=0 / IsIframe=0)
+-- ---------------------------------------------------------------------------
+INSERT INTO SysMenu (Id, Pid, Type, Title, Path, Component, OrderNo, Status, IsHide, IsKeepAlive, IsAffix, IsIframe)
+SELECT 1329002000004, 1329002000000, 2, '合同评审周期',
+       '/aidop/s0/sales/contract-review-cycle', '/aidop/s0/sales/ContractReviewCycleList',
+       4, 1, 0, 1, 0, 0
+FROM DUAL
+WHERE NOT EXISTS (SELECT 1 FROM SysMenu m WHERE m.Id = 1329002000004)
+  AND NOT EXISTS (SELECT 1 FROM SysMenu m WHERE m.Path = '/aidop/s0/sales/contract-review-cycle')
+  AND EXISTS (SELECT 1 FROM SysMenu p WHERE p.Id = 1329002000000);
+
+-- ---------------------------------------------------------------------------
+-- ② SysTenantMenu:租户可见性(按 SysOrg.Code 解析租户,不硬编码 TenantId)
+--    Id 取当前最大值 +1(该表无 auto_increment)
+-- ---------------------------------------------------------------------------
+SET @crc_menu_id := (SELECT Id FROM SysMenu WHERE Path = '/aidop/s0/sales/contract-review-cycle' LIMIT 1);
+SET @crc_tenant  := (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id = t.OrgId
+                     WHERE o.Code = 'UATTEST_CHL' AND t.Status = 1 LIMIT 1);
+SET @crc_tm_base := (SELECT IFNULL(MAX(Id), 0) FROM SysTenantMenu);
+
+INSERT INTO SysTenantMenu (Id, TenantId, MenuId)
+SELECT @crc_tm_base + 1, @crc_tenant, @crc_menu_id
+FROM DUAL
+WHERE @crc_menu_id IS NOT NULL AND @crc_tenant IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM SysTenantMenu x WHERE x.TenantId = @crc_tenant AND x.MenuId = @crc_menu_id);
+
+-- ---------------------------------------------------------------------------
+-- ③ SysRoleMenu:角色授权 UAT_FUNCTION_USER(UATAdminA 的菜单来源角色)
+--    其余 SMART_OPS_* 角色在 SysRoleMenu 中本就 0 条菜单授权,本批不改其语义。
+-- ---------------------------------------------------------------------------
+SET @crc_role := (SELECT r.Id FROM SysRole r
+                  WHERE r.TenantId = @crc_tenant AND r.Code = 'UAT_FUNCTION_USER' AND r.Status = 1 LIMIT 1);
+SET @crc_rm_base := (SELECT IFNULL(MAX(Id), 0) FROM SysRoleMenu);
+
+INSERT INTO SysRoleMenu (Id, RoleId, MenuId)
+SELECT @crc_rm_base + 1, @crc_role, @crc_menu_id
+FROM DUAL
+WHERE @crc_menu_id IS NOT NULL AND @crc_role IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM SysRoleMenu x WHERE x.RoleId = @crc_role AND x.MenuId = @crc_menu_id);

+ 32 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.406.verify.sql

@@ -0,0 +1,32 @@
+-- ============================================================================
+-- STEP 1 合同评审周期菜单 · VERIFY
+--
+-- 执行契约(AutoVersionUpdate.RunVerifyScriptIfExists):每条语句经
+-- SqlQuerySingle<object> 取首行首列并要求 IsTruthy == true。故:
+--   ① 禁止 SET @var(返回 null 即判失败)—— 租户/角色一律内联子查询;
+--   ② 每条语句返回「通过为真」的标量;
+--   ③ 文件必须以最后一条 SQL 的 ; 结束,末尾不得残留 comment-only 块
+--      (SqlScriptSplitter 会把它切成一条纯注释语句 → null → false Failed)。
+-- ============================================================================
+
+-- 菜单本身存在且字段口径正确
+SELECT COUNT(*) = 1 FROM SysMenu WHERE Id = 1329002000004 AND Pid = 1329002000000 AND Type = 2 AND Status = 1 AND OrderNo = 4 AND Path = '/aidop/s0/sales/contract-review-cycle' AND Component = '/aidop/s0/sales/ContractReviewCycleList';
+
+-- 路由唯一(未产生重复菜单)
+SELECT COUNT(*) = 1 FROM SysMenu WHERE Path = '/aidop/s0/sales/contract-review-cycle';
+
+-- 租户可见性
+SELECT COUNT(*) = 1 FROM SysTenantMenu tm JOIN SysMenu m ON m.Id = tm.MenuId WHERE m.Path = '/aidop/s0/sales/contract-review-cycle' AND tm.TenantId = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id = t.OrgId WHERE o.Code = 'UATTEST_CHL' AND t.Status = 1 LIMIT 1);
+
+-- 角色授权(UAT_FUNCTION_USER)
+SELECT COUNT(*) = 1 FROM SysRoleMenu rm JOIN SysMenu m ON m.Id = rm.MenuId JOIN SysRole r ON r.Id = rm.RoleId WHERE m.Path = '/aidop/s0/sales/contract-review-cycle' AND r.Code = 'UAT_FUNCTION_USER' AND r.TenantId = (SELECT t.Id FROM SysTenant t JOIN SysOrg o ON o.Id = t.OrgId WHERE o.Code = 'UATTEST_CHL' AND t.Status = 1 LIMIT 1);
+
+-- UATAdminA 经其角色可达该菜单(端到端可达性)
+SELECT COUNT(*) = 1 FROM SysUser u JOIN SysUserRole ur ON ur.UserId = u.Id JOIN SysRoleMenu rm ON rm.RoleId = ur.RoleId JOIN SysMenu m ON m.Id = rm.MenuId WHERE u.Account = 'UATAdminA' AND m.Path = '/aidop/s0/sales/contract-review-cycle';
+
+-- 产销建模子菜单总数由 6 增至 7,且 OrderNo 无重复
+SELECT COUNT(*) = 7 FROM SysMenu WHERE Pid = 1329002000000 AND Type = 2;
+SELECT COUNT(DISTINCT OrderNo) = COUNT(*) FROM SysMenu WHERE Pid = 1329002000000 AND Type = 2;
+
+-- 回归:不得误伤其它 S0 菜单(S0 Type=2 菜单总数 58 → 59)
+SELECT COUNT(*) = 59 FROM SysMenu WHERE Path LIKE '/aidop/s0%' AND Type = 2;