Bladeren bron

feat(s0): S0 公司/工厂前台无感化与后台归属保值治理(物料/货源/BOM)

将 companyRefId/factoryRefId/domainCode 从"用户前台选择字段"降级为"系统后台归属字段":

- 统一 helper(useS0MfgOrgScope)新增 getDefaultS0OrgScope/applyDefaultOrgScopeForCreate/
  preserveOrgScopeForEdit/normalizeOrgScopeForSubmit/shouldClearFactoryOnCompanyChange,
  集中默认组织 code S0-C001/S0-F001(种子 S0OrgSeedData 真实组织)。
- 新增:自动补默认 SysOrg 归属(S0示范公司/一工厂),用户无需选择公司/工厂。
- 编辑:保留接口返回的历史归属(物料/BOM legacy 1000/8010、货源 0/0),不转雪花。
- 提交:normalizeOrgScopeForSubmit 确保 payload 稳定携带 companyRefId/factoryRefId/domainCode。
- watcher 加守卫:隐藏字段/程序化载入不清空工厂,仅用户主动切换且有有效候选时才清空
  (修复标准 BOM 编辑 legacy 记录工厂被静默清空的隐患)。
- 三页对话框隐藏公司/工厂输入控件、移除公司/工厂必填 rules;form 值与 payload 不变。
- 不做 legacy↔SysOrg 域映射、不回填历史数据、不改后端。

chore: bump version Web 2.4.234
YY968XX 5 dagen geleden
bovenliggende
commit
6427d25603

+ 1 - 1
Web/package.json

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

+ 111 - 0
Web/src/views/aidop/s0/composables/useS0MfgOrgScope.ts

@@ -24,3 +24,114 @@ export function useS0MfgOrgScope() {
 
 	return { companyOptions, factoryOptions, loadOrgs, factoriesForCompany };
 }
+
+// ── S0 公司/工厂后台归属治理(前台无感化 + 归属保值)────────────────────────────
+//
+// 现状:S0 组织归属存在两套域——历史 legacy 数值域(物料/BOM=1000/8010、货源=0/0)
+// 与 SysOrg 雪花域(种子 S0OrgSeedData:S0示范公司 S0-C001 / 一工厂 S0-F001)。
+// 本模块把公司/工厂从"用户前台选择字段"降级为"系统后台归属字段":
+//   - 新增:自动补默认 SysOrg 归属;
+//   - 编辑:保留接口返回的原归属(legacy 不转雪花);
+//   - 提交:统一确保 payload 有稳定归属;
+//   - 隐藏字段场景/编辑 legacy 记录:watcher 不得清空已载入的工厂值。
+// 不做 legacy↔雪花映射、不回填历史数据。
+
+/** 默认公司/工厂的种子 Code(S0OrgSeedData:S0示范公司 / 一工厂)。按 code 匹配真实组织,不硬编码 id。 */
+export const S0_DEFAULT_COMPANY_CODE = 'S0-C001';
+export const S0_DEFAULT_FACTORY_CODE = 'S0-F001';
+
+/** 组织归属三件套(string 语义;未定为 undefined)。 */
+export interface S0OrgScope {
+	companyRefId?: string;
+	factoryRefId?: string;
+	domainCode?: string;
+}
+
+/** 承载归属字段的表单/记录最小形状(页面 form 可携带更多字段,结构化兼容)。 */
+export interface S0OrgScopeCarrier {
+	companyRefId?: string;
+	factoryRefId?: string;
+	domainCode?: string;
+}
+
+function isEmptyScopeValue(v: unknown): boolean {
+	return v === undefined || v === null || v === '';
+}
+
+/**
+ * 从已加载组织列表按 code 求默认归属;取不到默认组织时返回空对象(不脑补 id/domainCode)。
+ * domainCode 口径沿用页面既有约定:新记录取工厂 code。
+ */
+export function getDefaultS0OrgScope(companyOptions: OrgOption[], factoryOptions: OrgOption[]): S0OrgScope {
+	const company = companyOptions.find((item) => item.code === S0_DEFAULT_COMPANY_CODE);
+	if (!company) return {};
+	const factory = factoryOptions.find((item) => item.code === S0_DEFAULT_FACTORY_CODE && item.pid === company.id);
+	return {
+		companyRefId: company.id,
+		factoryRefId: factory?.id,
+		domainCode: factory?.code ?? undefined,
+	};
+}
+
+/**
+ * 新增:仅当归属字段为空时补默认,已有值不覆盖。就地写回 form。
+ */
+export function applyDefaultOrgScopeForCreate(
+	form: S0OrgScopeCarrier,
+	companyOptions: OrgOption[],
+	factoryOptions: OrgOption[]
+): void {
+	const d = getDefaultS0OrgScope(companyOptions, factoryOptions);
+	if (isEmptyScopeValue(form.companyRefId)) form.companyRefId = d.companyRefId;
+	if (isEmptyScopeValue(form.factoryRefId)) form.factoryRefId = d.factoryRefId;
+	if (isEmptyScopeValue(form.domainCode) && d.domainCode != null) form.domainCode = d.domainCode;
+}
+
+/** 接口返回记录里归属字段的只读形状(domainCode 可能为 null)。 */
+export interface S0OrgScopeSource {
+	companyRefId?: string | null;
+	factoryRefId?: string | null;
+	domainCode?: string | null;
+}
+
+/**
+ * 编辑:保留接口返回记录的原归属(legacy 1000/8010/2410 或货源 0/0/null 一律原样保留,不转雪花)。
+ * 就地写回 form;null 归一为 undefined('0' 等有效值原样保留)。
+ */
+export function preserveOrgScopeForEdit(form: S0OrgScopeCarrier, sourceRecord: S0OrgScopeSource): void {
+	form.companyRefId = sourceRecord.companyRefId ?? undefined;
+	form.factoryRefId = sourceRecord.factoryRefId ?? undefined;
+	if (sourceRecord.domainCode != null) form.domainCode = sourceRecord.domainCode;
+}
+
+/**
+ * 提交前统一归属:缺归属则补默认,有归属(含 legacy、含货源 0/0)则保留,不做 legacy↔雪花映射。
+ * create 与 edit 语义一致——"缺则补默认、有则保留";edit 下已有 legacy 值天然不被覆盖。
+ */
+export function normalizeOrgScopeForSubmit(
+	form: S0OrgScopeCarrier,
+	mode: 'create' | 'edit',
+	companyOptions: OrgOption[],
+	factoryOptions: OrgOption[]
+): void {
+	void mode;
+	const d = getDefaultS0OrgScope(companyOptions, factoryOptions);
+	if (isEmptyScopeValue(form.companyRefId)) form.companyRefId = d.companyRefId;
+	if (isEmptyScopeValue(form.factoryRefId)) form.factoryRefId = d.factoryRefId;
+	if (isEmptyScopeValue(form.domainCode) && d.domainCode != null) form.domainCode = d.domainCode;
+}
+
+/**
+ * 是否应在"公司变化"时清空工厂:
+ * 只有用户主动切换公司(userInitiated)、且该公司确有有效工厂候选、且当前工厂不在候选中时才清空。
+ * 隐藏字段场景(userInitiated=false)或编辑 legacy 记录(候选为空)一律不清空,保护已载入的工厂值。
+ */
+export function shouldClearFactoryOnCompanyChange(
+	factoriesForCompany: OrgOption[],
+	currentFactoryId: string | undefined | null,
+	userInitiated: boolean
+): boolean {
+	if (!userInitiated) return false;
+	if (factoriesForCompany.length === 0) return false;
+	return !factoriesForCompany.some((item) => item.id === currentFactoryId);
+}

+ 22 - 22
Web/src/views/aidop/s0/manufacturing/StandardBomManagement.vue

@@ -62,14 +62,15 @@
 		<el-dialog v-model="dialogVisible" :title="dialogTitle" width="720px" destroy-on-close @closed="resetForm">
 			<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
 				<el-row :gutter="12">
-					<el-col :span="12">
+					<!-- 公司/工厂已降级为系统后台归属字段:前台隐藏输入控件,form 值与 payload 仍由 openCreate 补默认 / openEdit 保值维护。 -->
+					<el-col v-if="false" :span="12">
 						<el-form-item label="公司" prop="companyRefId">
 							<el-select v-model="form.companyRefId" filterable style="width: 100%" @change="onFormOrgChange">
 								<el-option v-for="item in companyOptions" :key="item.id" :label="item.name || item.code || `${item.id}`" :value="item.id" />
 							</el-select>
 						</el-form-item>
 					</el-col>
-					<el-col :span="12">
+					<el-col v-if="false" :span="12">
 						<el-form-item label="工厂" prop="factoryRefId">
 							<el-select v-model="form.factoryRefId" filterable style="width: 100%" @change="onFormOrgChange">
 								<el-option v-for="item in formFactories" :key="item.id" :label="item.name || item.code || `${item.id}`" :value="item.id" />
@@ -137,7 +138,13 @@ import { computed, onMounted, reactive, ref, watch } from 'vue';
 import { useRoute } from 'vue-router';
 import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
 import AidopDemoShell from '../../components/AidopDemoShell.vue';
-import { useS0MfgOrgScope } from '../composables/useS0MfgOrgScope';
+import {
+	applyDefaultOrgScopeForCreate,
+	normalizeOrgScopeForSubmit,
+	preserveOrgScopeForEdit,
+	shouldClearFactoryOnCompanyChange,
+	useS0MfgOrgScope,
+} from '../composables/useS0MfgOrgScope';
 import {
 	searchMaterialsForMfg,
 	s0MfgStandardBomLinesApi,
@@ -204,13 +211,16 @@ watch(
 watch(
 	() => form.companyRefId,
 	() => {
-		if (!formFactories.value.some((x) => x.id === form.factoryRefId)) form.factoryRefId = undefined;
+		// 公司/工厂为隐藏的后台归属字段,此变化均为程序化载入(openCreate 补默认 / openEdit 回填),
+		// 非用户主动切换,故 userInitiated=false,永不清空已载入的工厂值(保护编辑 legacy 1000/8010 归属)。
+		if (shouldClearFactoryOnCompanyChange(formFactories.value, form.factoryRefId, false)) {
+			form.factoryRefId = undefined;
+		}
 	},
 );
 
+// 公司/工厂已降级为系统后台归属字段(新增自动补默认、编辑保留原值),不再作为用户前台必填项。
 const rules: FormRules = {
-	companyRefId: [{ required: true, message: '请选择公司', trigger: 'change' }],
-	factoryRefId: [{ required: true, message: '请选择工厂', trigger: 'change' }],
 	parentMaterialId: [{ required: true, message: '请选择父项物料', trigger: 'change' }],
 	componentMaterialId: [{ required: true, message: '请选择子项物料', trigger: 'change' }],
 	qty: [{ required: true, message: '请填写用量', trigger: 'change' }],
@@ -300,24 +310,10 @@ function resetForm() {
 	formRef.value?.clearValidate();
 }
 
-// 私有云单组织场景:新增 BOM 行时自动带出 S0 默认公司/工厂(种子 S0OrgSeedData:S0示范公司 S0-C001 / 一工厂 S0-F001)。
-// 字段仍可见可改;未匹配到默认组织时留空、由用户手选,不报错。编辑走 openEdit 回填、不受影响。
-const S0_DEFAULT_COMPANY_CODE = 'S0-C001';
-const S0_DEFAULT_FACTORY_CODE = 'S0-F001';
-
-function applyDefaultOrg() {
-	const company = companyOptions.value.find((item) => item.code === S0_DEFAULT_COMPANY_CODE);
-	if (!company) return;
-	const factory = factoryOptions.value.find((item) => item.code === S0_DEFAULT_FACTORY_CODE && item.pid === company.id);
-	Object.assign(form, {
-		companyRefId: company.id,
-		factoryRefId: factory?.id,
-	});
-}
-
 function openCreate() {
 	resetForm();
-	applyDefaultOrg();
+	// 新增:自动补默认 SysOrg 归属(公司/工厂),用户无需选择公司/工厂。
+	applyDefaultOrgScopeForCreate(form, companyOptions.value, factoryOptions.value);
 	dialogTitle.value = '新增标准 BOM 行';
 	dialogVisible.value = true;
 }
@@ -350,12 +346,16 @@ async function openEdit(row: S0MfgStandardBomLineRow) {
 		endEff: m.endEff ?? undefined,
 		isEnabled: m.isEnabled,
 	});
+	// 编辑:保留接口返回的原归属(legacy 1000/8010 不转雪花)。
+	preserveOrgScopeForEdit(form, m);
 	dialogVisible.value = true;
 }
 
 async function submitForm() {
 	await formRef.value?.validate();
 	if (!form.parentMaterialId || !form.componentMaterialId) return;
+	// 提交前统一归属:缺则补默认、有则保留(含 legacy 1000/8010),不做 legacy↔雪花映射。
+	normalizeOrgScopeForSubmit(form, editingId.value ? 'edit' : 'create', companyOptions.value, factoryOptions.value);
 	const payload = {
 		companyRefId: form.companyRefId,
 		factoryRefId: form.factoryRefId,

+ 20 - 10
Web/src/views/aidop/s0/sales/MaterialList.vue

@@ -98,7 +98,8 @@
 		<el-dialog v-model="dialogVisible" :title="dialogTitle" width="920px" destroy-on-close @closed="resetForm">
 			<el-form ref="formRef" :model="form" :rules="rules" label-width="130px">
 				<el-tabs v-model="activeTab">
-					<el-tab-pane label="归属" name="owner">
+					<!-- 公司/工厂已降级为系统后台归属字段:前台隐藏输入控件,form 值与 payload 仍由 openCreate 补默认 / openEdit 保值维护。 -->
+					<el-tab-pane v-if="false" label="归属" name="owner">
 						<el-row :gutter="16">
 							<el-col :span="12">
 								<el-form-item label="公司" prop="companyRefId">
@@ -241,11 +242,17 @@ import {
 	type S0ItemMasterRow,
 	type S0ItemMasterUpsert,
 } from '../api/s0SalesApi';
+import {
+	applyDefaultOrgScopeForCreate,
+	normalizeOrgScopeForSubmit,
+	preserveOrgScopeForEdit,
+	shouldClearFactoryOnCompanyChange,
+} from '../composables/useS0MfgOrgScope';
 
 const route = useRoute();
 const pageTitle = computed(() => (route.meta?.title as string) || '物料维护列表');
 
-const activeTab = ref('owner');
+const activeTab = ref('main');
 const query = reactive({
 	keyword: '',
 	domainCode: '',
@@ -344,9 +351,8 @@ function emptyForm(): S0ItemMasterUpsert {
 
 const form = reactive<S0ItemMasterUpsert>(emptyForm());
 
+// 公司/工厂已降级为系统后台归属字段(新增自动补默认、编辑保留原值),不再作为用户前台必填项。
 const rules: FormRules = {
-	companyRefId: [{ required: true, message: '请选择公司', trigger: 'change' }],
-	factoryRefId: [{ required: true, message: '请选择工厂', trigger: 'change' }],
 	itemNum: [{ required: true, message: '请填写物料编码', trigger: 'blur' }],
 	descr: [{ required: true, message: '请填写物料名称', trigger: 'blur' }],
 };
@@ -369,11 +375,9 @@ function syncDomainCodeFromFactory() {
 watch(
 	() => form.companyRefId,
 	() => {
-		// 仅当所选公司在雪花组织下拉中确有可选工厂、且当前工厂不在其中时才清空(用户切换公司场景);
-		// 编辑既有 legacy 组织域物料(如 companyRefId=1000)时下拉无匹配子工厂,此时保留已载入的 factoryRefId,
-		// 避免静默清空导致保存被工厂必填校验误拦截。
-		const opts = formFactoryOptions.value;
-		if (opts.length > 0 && !opts.some((item) => item.id === form.factoryRefId)) {
+		// 公司/工厂为隐藏的后台归属字段,此变化均为程序化载入(openCreate 补默认 / openEdit 回填),
+		// 非用户主动切换,故 userInitiated=false,永不清空已载入的工厂值(保护 legacy 归属)。
+		if (shouldClearFactoryOnCompanyChange(formFactoryOptions.value, form.factoryRefId, false)) {
 			form.factoryRefId = undefined;
 		}
 	}
@@ -459,7 +463,7 @@ function resetQuery() {
 
 function resetForm() {
 	editingId.value = null;
-	activeTab.value = 'owner';
+	activeTab.value = 'main';
 	Object.assign(form, emptyForm());
 	formRef.value?.clearValidate();
 }
@@ -529,6 +533,8 @@ function rowToForm(row: S0ItemMasterRow): void {
 
 function openCreate() {
 	resetForm();
+	// 新增:自动补默认 SysOrg 归属(公司/工厂/domainCode),用户无需选择公司/工厂。
+	applyDefaultOrgScopeForCreate(form, companyOptions.value, factoryOptions.value);
 	dialogTitle.value = '新增物料';
 	dialogVisible.value = true;
 }
@@ -538,6 +544,8 @@ function openEdit(row: S0ItemMasterRow) {
 	editingId.value = row.id;
 	dialogTitle.value = `编辑物料 ${row.itemNum}`;
 	rowToForm(row);
+	// 编辑:保留接口返回的原归属(legacy 1000/8010/2410 不转雪花)。
+	preserveOrgScopeForEdit(form, row);
 	dialogVisible.value = true;
 }
 
@@ -547,6 +555,8 @@ async function submitForm() {
 	// 避免整段 submit 静默中断(既不成功提示、也不进入 catch 错误提示)。
 	const valid = await formRef.value.validate().catch(() => false);
 	if (!valid) return;
+	// 提交前统一归属:缺则补默认、有则保留(含 legacy),不做 legacy↔雪花映射。
+	normalizeOrgScopeForSubmit(form, editingId.value ? 'edit' : 'create', companyOptions.value, factoryOptions.value);
 	saving.value = true;
 	try {
 		const payload: S0ItemMasterUpsert = { ...form };

+ 18 - 22
Web/src/views/aidop/s0/supply/SourcingList.vue

@@ -77,14 +77,15 @@
 		<el-dialog v-model="dialogVisible" :title="dialogTitle" width="920px" destroy-on-close @closed="resetForm">
 			<el-form ref="formRef" :model="form" :rules="rules" label-width="132px">
 				<el-row :gutter="12">
-					<el-col :span="12">
+					<!-- 公司/工厂已降级为系统后台归属字段:前台隐藏输入控件,form 值与 payload 仍由 openCreate 补默认 / openEdit 保值维护。 -->
+					<el-col v-if="false" :span="12">
 						<el-form-item label="公司" prop="companyRefId">
 							<el-select v-model="form.companyRefId" filterable style="width: 100%" @change="onFormCompanyChange">
 								<el-option v-for="item in companyOptions" :key="item.id" :label="item.name || item.code || `${item.id}`" :value="item.id" />
 							</el-select>
 						</el-form-item>
 					</el-col>
-					<el-col :span="12">
+					<el-col v-if="false" :span="12">
 						<el-form-item label="工厂" prop="factoryRefId">
 							<el-select v-model="form.factoryRefId" filterable style="width: 100%" @change="syncDomainFromFactory">
 								<el-option v-for="item in formFactoryOptions" :key="item.id" :label="item.name || item.code || `${item.id}`" :value="item.id" />
@@ -266,6 +267,12 @@ import {
 	type S0SrmPurchaseUpsert,
 	type S0SuppMasterRow,
 } from '../api/s0SupplyApi';
+import {
+	applyDefaultOrgScopeForCreate,
+	normalizeOrgScopeForSubmit,
+	preserveOrgScopeForEdit,
+	shouldClearFactoryOnCompanyChange,
+} from '../composables/useS0MfgOrgScope';
 
 const route = useRoute();
 const pageTitle = computed(() => (route.meta?.title as string) || '货源清单');
@@ -334,9 +341,8 @@ const form = reactive<S0SrmPurchaseUpsert & { icitemId: number; supplierId: numb
 	updateUser: '',
 });
 
+// 公司/工厂已降级为系统后台归属字段(新增自动补默认、编辑保留原值),不再作为用户前台必填项。
 const rules: FormRules = {
-	companyRefId: [{ required: true, message: '请选择公司', trigger: 'change' }],
-	factoryRefId: [{ required: true, message: '请选择工厂', trigger: 'change' }],
 	icitemId: [{ required: true, message: '请选择物料', trigger: 'change' }],
 	supplierId: [{ required: true, message: '请选择供应商', trigger: 'change' }],
 };
@@ -357,7 +363,8 @@ function syncDomainFromFactory() {
 }
 
 function onFormCompanyChange() {
-	if (!formFactoryOptions.value.some((item) => item.id === form.factoryRefId)) {
+	// 用户主动切换公司时才在存在有效工厂候选且当前工厂不在候选中的情况下清空工厂(保护编辑 legacy 归属)。
+	if (shouldClearFactoryOnCompanyChange(formFactoryOptions.value, form.factoryRefId, true)) {
 		form.factoryRefId = undefined;
 	}
 }
@@ -516,25 +523,10 @@ function resetForm() {
 	formRef.value?.clearValidate();
 }
 
-// 私有云单组织场景:新增货源时自动带出 S0 默认公司/工厂(种子 S0OrgSeedData:S0示范公司 S0-C001 / 一工厂 S0-F001)。
-// 字段仍可见可改;未匹配到默认组织时留空、由用户手选,不报错。编辑走 openEdit 回填、不受影响。
-const S0_DEFAULT_COMPANY_CODE = 'S0-C001';
-const S0_DEFAULT_FACTORY_CODE = 'S0-F001';
-
-function applyDefaultOrg() {
-	const company = companyOptions.value.find((item) => item.code === S0_DEFAULT_COMPANY_CODE);
-	if (!company) return;
-	const factory = factoryOptions.value.find((item) => item.code === S0_DEFAULT_FACTORY_CODE && item.pid === company.id);
-	Object.assign(form, {
-		companyRefId: company.id,
-		factoryRefId: factory?.id,
-		domainCode: factory?.code ?? '',
-	});
-}
-
 async function openCreate() {
 	resetForm();
-	applyDefaultOrg();
+	// 新增:自动补默认 SysOrg 归属(公司/工厂/domainCode),用户无需选择公司/工厂。
+	applyDefaultOrgScopeForCreate(form, companyOptions.value, factoryOptions.value);
 	dialogTitle.value = '新增货源';
 	dialogVisible.value = true;
 }
@@ -572,6 +564,8 @@ async function openEdit(row: S0SrmPurchaseRow) {
 		createUser: row.createUser ?? '',
 		updateUser: row.updateUser ?? '',
 	});
+	// 编辑:保留接口返回的原归属(货源历史 0/0/null 原样保留,不转雪花)。
+	preserveOrgScopeForEdit(form, row);
 	dialogVisible.value = true;
 	await remoteMaterials('');
 	await remoteSuppliers('');
@@ -600,6 +594,8 @@ function normalizeCurrencyType(v: string | number | null | undefined): number |
 
 async function submitForm() {
 	await formRef.value?.validate();
+	// 提交前统一归属:缺则补默认、有则保留(含货源历史 0/0),不做 legacy↔雪花映射。
+	normalizeOrgScopeForSubmit(form, editingId.value ? 'edit' : 'create', companyOptions.value, factoryOptions.value);
 	saving.value = true;
 	try {
 		const payload: S0SrmPurchaseUpsert = { ...form, currencyType: normalizeCurrencyType(form.currencyType) };