Bladeren bron

feat(kpi): 公式结构化 v1(@指标/$事实 DSL + 校验 + 预览)+ 业务事实字典 + bump 2.4.88/1.0.55

后端:
- 新表 ado_smart_ops_business_fact(FactCode/FactName + 模块/单位/聚合),两租户各 148 条从历史 Formula 抽取种子
- KpiMaster 新增 FormulaExpr/FormulaPreview/FormulaRefs 三列;保留旧 Formula 不动(Q3-B)
- FormulaParser/Validator/PreviewBuilder 三件套:语法检查、引用存在性校验、中文预览(÷/×)
- Q4-C 策略:启用指标硬校验阻止保存;草稿/禁用仅 warnings
- 新接口 POST /api/AdoSmartOpsKpiMaster/preview-formula(实时预览 + 错误列表)
- AdoSmartOpsBusinessFactController:page/picker/detail/add/update/delete CRUD

前端:
- businessFact.vue 字典管理页(列表 + 筛选 + 新增/编辑 + 启停)
- FormulaEditor.vue 行内编辑器:@ 弹指标候选,$ 弹事实候选,500ms 防抖调用预览接口,分级显示 errors/warnings
- kpiMaster.vue 顶部增「结构化公式」字段;原 Formula 字段降级为兼容展示
- businessFactApi.ts + kpiMasterApi.previewFormula

菜单/种子:
- SysMenuSeedData 增 1320990000313「业务事实字典」叶子;dev 库通过 tools/insert_business_fact_menu.py 幂等入库(含 SysMenu/SysTenantMenu/SysRoleMenu 三端)

工具脚本(ai-dop-platform/tools/):
- apply_formula_structuring_ddl.py  DDL 幂等
- seed_business_facts.py  抽取并写入 148×2 条字典
- insert_business_fact_menu.py  菜单入库幂等
- _probe_formula_samples.py  历史公式分布探查

验收:
- 合法公式 `$F_S1_001 / $F_COMMON_001 * 100` → "规定时间内交期确认的订单数量 ÷ 订单总数 × 100"
- 不存在的 @/$ 引用 → errors 精准定位
- 非法中文 + isEnabled=false → 仅 warnings 不阻塞
- 字典 picker 按模块过滤(S1 返回 S1+COMMON 21 条),page 接口 total=148

Made-with: Cursor
skygu 3 maanden geleden
bovenliggende
commit
8a35c0aa03

+ 1 - 1
Web/package.json

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

+ 55 - 0
Web/src/views/aidop/api/businessFactApi.ts

@@ -0,0 +1,55 @@
+import service from '/@/utils/request';
+
+const BASE = '/api/AdoSmartOpsBusinessFact';
+
+export interface BusinessFact {
+	id: number;
+	factCode: string;
+	factName: string;
+	moduleCode: string | null;
+	unit: string | null;
+	aggType: string | null;
+	dataSource: string | null;
+	description: string | null;
+	isEnabled: boolean;
+	sortNo: number;
+	tenantId: number | null;
+	createdAt?: string;
+	updatedAt?: string | null;
+}
+
+export interface BusinessFactPicker {
+	factCode: string;
+	factName: string;
+	moduleCode: string | null;
+	unit: string | null;
+	aggType: string | null;
+}
+
+export function page(params: { page?: number; size?: number; keyword?: string; moduleCode?: string }) {
+	return service({ url: `${BASE}/page`, method: 'get', params });
+}
+
+export function picker(moduleCode?: string) {
+	return service({
+		url: `${BASE}/picker`,
+		method: 'get',
+		params: moduleCode ? { moduleCode } : {},
+	});
+}
+
+export function detail(id: number) {
+	return service({ url: `${BASE}/${id}`, method: 'get' });
+}
+
+export function add(data: Partial<BusinessFact>) {
+	return service({ url: `${BASE}/add`, method: 'post', data });
+}
+
+export function update(data: Partial<BusinessFact>) {
+	return service({ url: `${BASE}/update`, method: 'post', data });
+}
+
+export function remove(id: number) {
+	return service({ url: `${BASE}/delete`, method: 'post', data: { id } });
+}

+ 20 - 0
Web/src/views/aidop/api/kpiMasterApi.ts

@@ -27,6 +27,9 @@ export interface KpiDetail {
 	description: string | null;
 	formula: string | null;
 	calcRule: string | null;
+	formulaExpr: string | null;
+	formulaPreview: string | null;
+	formulaRefs: string | null;
 	dataSource: string | null;
 	statFrequency: string | null;
 	department: string | null;
@@ -51,6 +54,7 @@ export interface KpiUpsert {
 	description?: string;
 	formula?: string;
 	calcRule?: string;
+	formulaExpr?: string;
 	dataSource?: string;
 	statFrequency?: string;
 	department?: string;
@@ -114,3 +118,19 @@ export function importDemoData(file: File) {
 export function downloadDemoTemplate() {
 	return `${BASE}/demo-template`;
 }
+
+export interface FormulaPreviewResp {
+	preview: string;
+	metrics: string[];
+	facts: string[];
+	errors: string[];
+	warnings: string[];
+}
+
+export function previewFormula(formulaExpr: string, isEnabled = true, excludeKpiId?: number) {
+	return service({
+		url: `${BASE}/preview-formula`,
+		method: 'post',
+		data: { formulaExpr, isEnabled, excludeKpiId },
+	});
+}

+ 201 - 0
Web/src/views/aidop/kanban/businessFact.vue

@@ -0,0 +1,201 @@
+<template>
+	<div class="biz-fact-page">
+		<el-card class="biz-fact-page__header">
+			<div class="biz-fact-page__title">
+				<h2>业务事实字典</h2>
+				<p class="biz-fact-page__subtitle">
+					指标公式中 <code>$FactCode</code> 的引用来源;维护业务短语(如"订单总数"、"销售人数"),供 KpiMaster 公式编辑器使用。
+				</p>
+			</div>
+			<div class="biz-fact-page__filter">
+				<el-input v-model="filter.keyword" placeholder="搜索 FactCode / FactName" clearable style="width: 260px" @keyup.enter="load" />
+				<el-select v-model="filter.moduleCode" placeholder="归属模块" clearable style="width: 160px" @change="load">
+					<el-option v-for="m in modules" :key="m" :label="m" :value="m" />
+				</el-select>
+				<el-button type="primary" @click="load"><el-icon><ele-Search /></el-icon>查询</el-button>
+				<el-button type="success" @click="openEdit(null)"><el-icon><ele-Plus /></el-icon>新增</el-button>
+			</div>
+		</el-card>
+
+		<el-card class="biz-fact-page__body">
+			<el-table :data="rows" border stripe v-loading="loading" max-height="calc(100vh - 280px)">
+				<el-table-column prop="factCode" label="FactCode" width="180" />
+				<el-table-column prop="factName" label="FactName(中文展示)" min-width="240" />
+				<el-table-column prop="moduleCode" label="模块" width="90" align="center" />
+				<el-table-column prop="aggType" label="聚合" width="90" align="center" />
+				<el-table-column prop="unit" label="单位" width="80" align="center" />
+				<el-table-column prop="dataSource" label="数据来源" min-width="160" show-overflow-tooltip />
+				<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
+				<el-table-column prop="isEnabled" label="启用" width="80" align="center">
+					<template #default="{ row }">
+						<el-tag :type="row.isEnabled ? 'success' : 'info'">{{ row.isEnabled ? '启用' : '禁用' }}</el-tag>
+					</template>
+				</el-table-column>
+				<el-table-column label="操作" width="140" align="center">
+					<template #default="{ row }">
+						<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
+						<el-button link type="danger" @click="onDelete(row)">删除</el-button>
+					</template>
+				</el-table-column>
+			</el-table>
+			<div class="biz-fact-page__pager">
+				<el-pagination
+					v-model:current-page="pager.page"
+					v-model:page-size="pager.size"
+					:total="pager.total"
+					:page-sizes="[20, 50, 100, 200]"
+					layout="total, sizes, prev, pager, next, jumper"
+					background
+					@current-change="load"
+					@size-change="load"
+				/>
+			</div>
+		</el-card>
+
+		<el-dialog v-model="editVisible" :title="editForm.id ? '编辑事实' : '新增事实'" width="600px" destroy-on-close>
+			<el-form ref="formRef" :model="editForm" :rules="rules" label-width="110px">
+				<el-form-item label="FactCode" prop="factCode">
+					<el-input v-model="editForm.factCode" :disabled="!!editForm.id" placeholder="F_<MODULE>_<序号>,如 F_S1_001" />
+				</el-form-item>
+				<el-form-item label="FactName" prop="factName">
+					<el-input v-model="editForm.factName" placeholder="中文展示名(如:统计期订单总数)" />
+				</el-form-item>
+				<el-form-item label="归属模块">
+					<el-select v-model="editForm.moduleCode" placeholder="S1~S9 或 COMMON" clearable>
+						<el-option v-for="m in modules" :key="m" :label="m" :value="m" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="聚合类型">
+					<el-select v-model="editForm.aggType" placeholder="COUNT/SUM/AVG/MAX/MIN/LATEST" clearable>
+						<el-option v-for="a in aggTypes" :key="a" :label="a" :value="a" />
+					</el-select>
+				</el-form-item>
+				<el-form-item label="单位">
+					<el-input v-model="editForm.unit" placeholder="如 个 / 天 / %" />
+				</el-form-item>
+				<el-form-item label="数据来源">
+					<el-input v-model="editForm.dataSource" placeholder="文字说明(下一批 ETL 将据此拼 SQL)" />
+				</el-form-item>
+				<el-form-item label="说明">
+					<el-input v-model="editForm.description" type="textarea" :rows="3" />
+				</el-form-item>
+				<el-form-item label="排序号">
+					<el-input-number v-model="editForm.sortNo" :min="0" :step="1" />
+				</el-form-item>
+				<el-form-item label="启用">
+					<el-switch v-model="editForm.isEnabled" />
+				</el-form-item>
+			</el-form>
+			<template #footer>
+				<el-button @click="editVisible = false">取消</el-button>
+				<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
+			</template>
+		</el-dialog>
+	</div>
+</template>
+
+<script setup lang="ts" name="aidopBusinessFact">
+import { ref, reactive, onMounted } from 'vue';
+import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
+import * as api from '/@/views/aidop/api/businessFactApi';
+import type { BusinessFact } from '/@/views/aidop/api/businessFactApi';
+
+const modules = ['COMMON', 'S1', 'S2', 'S3', 'S4', 'S5', 'S6', 'S7', 'S8', 'S9'];
+const aggTypes = ['COUNT', 'SUM', 'AVG', 'MAX', 'MIN', 'LATEST'];
+
+const loading = ref(false);
+const rows = ref<BusinessFact[]>([]);
+const pager = reactive({ page: 1, size: 50, total: 0 });
+const filter = reactive({ keyword: '', moduleCode: '' });
+
+async function load() {
+	loading.value = true;
+	try {
+		const res: any = await api.page({
+			page: pager.page,
+			size: pager.size,
+			keyword: filter.keyword || undefined,
+			moduleCode: filter.moduleCode || undefined,
+		});
+		const data = res.data ?? res;
+		rows.value = data.rows ?? [];
+		pager.total = data.total ?? 0;
+	} catch (e: any) {
+		ElMessage.error('加载失败:' + (e?.message || e));
+	} finally {
+		loading.value = false;
+	}
+}
+
+const editVisible = ref(false);
+const saving = ref(false);
+const formRef = ref<FormInstance>();
+const editForm = reactive<Partial<BusinessFact>>({
+	id: 0, factCode: '', factName: '', moduleCode: '', aggType: '',
+	unit: '', dataSource: '', description: '', isEnabled: true, sortNo: 0,
+});
+const rules: FormRules = {
+	factCode: [
+		{ required: true, message: 'FactCode 必填', trigger: 'blur' },
+		{ pattern: /^[A-Za-z][A-Za-z0-9_]*$/, message: '仅支持字母/数字/下划线,字母开头', trigger: 'blur' },
+	],
+	factName: [{ required: true, message: 'FactName 必填', trigger: 'blur' }],
+};
+
+function openEdit(row: BusinessFact | null) {
+	Object.assign(editForm, {
+		id: row?.id ?? 0,
+		factCode: row?.factCode ?? '',
+		factName: row?.factName ?? '',
+		moduleCode: row?.moduleCode ?? '',
+		aggType: row?.aggType ?? '',
+		unit: row?.unit ?? '',
+		dataSource: row?.dataSource ?? '',
+		description: row?.description ?? '',
+		isEnabled: row?.isEnabled ?? true,
+		sortNo: row?.sortNo ?? 0,
+	});
+	editVisible.value = true;
+}
+
+async function onSave() {
+	if (!formRef.value) return;
+	const valid = await formRef.value.validate().catch(() => false);
+	if (!valid) return;
+	saving.value = true;
+	try {
+		if (editForm.id) await api.update(editForm);
+		else await api.add(editForm);
+		ElMessage.success('保存成功');
+		editVisible.value = false;
+		await load();
+	} catch (e: any) {
+		ElMessage.error('保存失败:' + (e?.response?.data?.message || e?.message || e));
+	} finally {
+		saving.value = false;
+	}
+}
+
+async function onDelete(row: BusinessFact) {
+	try {
+		await ElMessageBox.confirm(`确定删除事实 ${row.factCode} / ${row.factName}?`, '确认', { type: 'warning' });
+		await api.remove(row.id);
+		ElMessage.success('已删除');
+		await load();
+	} catch (e: any) {
+		if (e !== 'cancel') ElMessage.error('删除失败:' + (e?.message || e));
+	}
+}
+
+onMounted(() => load());
+</script>
+
+<style scoped>
+.biz-fact-page { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
+.biz-fact-page__header { }
+.biz-fact-page__title h2 { margin: 0 0 6px; font-size: 18px; }
+.biz-fact-page__subtitle { margin: 0 0 12px; color: #64748b; font-size: 13px; }
+.biz-fact-page__subtitle code { background: rgba(59, 130, 246, 0.1); color: #3b82f6; padding: 0 6px; border-radius: 4px; }
+.biz-fact-page__filter { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
+.biz-fact-page__pager { margin-top: 12px; display: flex; justify-content: flex-end; }
+</style>

+ 315 - 0
Web/src/views/aidop/kanban/components/FormulaEditor.vue

@@ -0,0 +1,315 @@
+<template>
+	<div class="formula-editor">
+		<div class="formula-editor__bar">
+			<span class="formula-editor__hint">
+				<el-tag size="small" type="info">@ 指标</el-tag>
+				<el-tag size="small" type="warning">$ 事实</el-tag>
+				<span class="formula-editor__hint-text">支持 + - × ÷ ( ) 数字 %</span>
+			</span>
+			<el-button link type="primary" size="small" @click="insertSymbol('@')">插入 @指标</el-button>
+			<el-button link type="primary" size="small" @click="insertSymbol('$')">插入 $事实</el-button>
+		</div>
+
+		<el-input
+			ref="inputRef"
+			v-model="localExpr"
+			type="textarea"
+			:rows="2"
+			:placeholder="placeholder"
+			@input="onInput"
+			@blur="onBlur"
+		/>
+
+		<div v-if="showSuggest" class="formula-editor__suggest" :style="suggestStyle">
+			<div class="formula-editor__suggest-header">
+				{{ suggestSymbol === '@' ? '指标列表' : '业务事实列表' }}
+				<span class="formula-editor__suggest-filter">{{ suggestFilter || '(输入后自动过滤)' }}</span>
+			</div>
+			<div class="formula-editor__suggest-list">
+				<div
+					v-for="(it, i) in filteredSuggest"
+					:key="it.code"
+					class="formula-editor__suggest-item"
+					:class="{ active: i === suggestIndex }"
+					@mousedown.prevent="selectSuggest(it)"
+				>
+					<span class="formula-editor__suggest-code">{{ it.code }}</span>
+					<span class="formula-editor__suggest-name">{{ it.name }}</span>
+					<span v-if="it.module" class="formula-editor__suggest-module">[{{ it.module }}]</span>
+				</div>
+				<div v-if="filteredSuggest.length === 0" class="formula-editor__suggest-empty">无匹配项</div>
+			</div>
+		</div>
+
+		<div v-if="preview" class="formula-editor__preview">
+			<span class="formula-editor__preview-label">预览:</span>
+			<span class="formula-editor__preview-text">{{ preview }}</span>
+		</div>
+		<div v-if="errors.length" class="formula-editor__error">
+			<div v-for="(e, i) in errors" :key="'e' + i">✗ {{ e }}</div>
+		</div>
+		<div v-if="warnings.length" class="formula-editor__warning">
+			<div v-for="(w, i) in warnings" :key="'w' + i">⚠ {{ w }}</div>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch, reactive, nextTick } from 'vue';
+import * as kpiApi from '/@/views/aidop/api/kpiMasterApi';
+import * as factApi from '/@/views/aidop/api/businessFactApi';
+
+const props = defineProps<{
+	modelValue: string | null | undefined;
+	moduleCode?: string;
+	isEnabled?: boolean;
+	excludeKpiId?: number;
+	placeholder?: string;
+}>();
+
+const emit = defineEmits<{
+	(e: 'update:modelValue', v: string): void;
+	(e: 'validationChange', v: { errors: string[]; warnings: string[]; preview: string }): void;
+}>();
+
+const inputRef = ref<any>(null);
+const localExpr = ref(props.modelValue ?? '');
+
+watch(
+	() => props.modelValue,
+	(v) => {
+		if ((v ?? '') !== localExpr.value) localExpr.value = v ?? '';
+	}
+);
+
+// ── 候选 ──
+
+interface SuggestItem { code: string; name: string; module?: string }
+const metricList = ref<SuggestItem[]>([]);
+const factList = ref<SuggestItem[]>([]);
+
+async function loadCandidates() {
+	try {
+		const [tree, facts] = await Promise.all([
+			kpiApi.getTree({ moduleCode: undefined as any }),
+			factApi.picker(props.moduleCode),
+		]);
+		const nodes: any[] = (tree as any).data ?? tree ?? [];
+		const flat: SuggestItem[] = [];
+		const walk = (arr: any[]) => {
+			for (const n of arr) {
+				flat.push({ code: n.metricCode, name: n.metricName, module: n.moduleCode });
+				if (n.children?.length) walk(n.children);
+			}
+		};
+		walk(nodes);
+		metricList.value = flat;
+
+		const fdata: any[] = (facts as any).data ?? facts ?? [];
+		factList.value = fdata.map((f) => ({ code: f.factCode, name: f.factName, module: f.moduleCode }));
+	} catch (err) {
+		// 静默失败,候选为空不阻塞编辑
+	}
+}
+
+loadCandidates();
+watch(
+	() => props.moduleCode,
+	() => loadCandidates()
+);
+
+// ── 输入监听 + 候选弹出 ──
+
+const showSuggest = ref(false);
+const suggestSymbol = ref<'@' | '$'>('@');
+const suggestFilter = ref('');
+const suggestIndex = ref(0);
+const suggestStyle = reactive({ top: '0px', left: '0px' });
+
+const filteredSuggest = computed<SuggestItem[]>(() => {
+	const src = suggestSymbol.value === '@' ? metricList.value : factList.value;
+	const kw = suggestFilter.value.toLowerCase();
+	if (!kw) return src.slice(0, 50);
+	return src
+		.filter((x) => x.code.toLowerCase().includes(kw) || x.name.toLowerCase().includes(kw))
+		.slice(0, 50);
+});
+
+function getCursorPos(): number {
+	const el = inputRef.value?.textarea ?? inputRef.value?.$refs?.textarea ?? null;
+	if (el && 'selectionStart' in el) return el.selectionStart as number;
+	return localExpr.value.length;
+}
+
+function onInput() {
+	emit('update:modelValue', localExpr.value);
+	const pos = getCursorPos();
+	const before = localExpr.value.substring(0, pos);
+	const match = before.match(/([@$])([A-Za-z0-9_]*)$/);
+	if (match) {
+		suggestSymbol.value = match[1] as '@' | '$';
+		suggestFilter.value = match[2];
+		suggestIndex.value = 0;
+		showSuggest.value = true;
+	} else {
+		showSuggest.value = false;
+	}
+	schedulePreview();
+}
+
+function onBlur() {
+	setTimeout(() => {
+		showSuggest.value = false;
+	}, 150);
+	fetchPreview();
+}
+
+function insertSymbol(sym: '@' | '$') {
+	const pos = getCursorPos();
+	localExpr.value = localExpr.value.slice(0, pos) + sym + localExpr.value.slice(pos);
+	emit('update:modelValue', localExpr.value);
+	nextTick(() => {
+		const el = inputRef.value?.textarea;
+		if (el) {
+			el.focus();
+			el.setSelectionRange(pos + 1, pos + 1);
+		}
+		onInput();
+	});
+}
+
+function selectSuggest(it: SuggestItem) {
+	const pos = getCursorPos();
+	const before = localExpr.value.substring(0, pos);
+	const after = localExpr.value.substring(pos);
+	const newBefore = before.replace(/([@$])([A-Za-z0-9_]*)$/, `$1${it.code}`);
+	localExpr.value = newBefore + after;
+	emit('update:modelValue', localExpr.value);
+	showSuggest.value = false;
+	nextTick(() => {
+		const el = inputRef.value?.textarea;
+		if (el) {
+			const cp = newBefore.length;
+			el.focus();
+			el.setSelectionRange(cp, cp);
+		}
+		fetchPreview();
+	});
+}
+
+// ── 预览 ──
+
+const preview = ref('');
+const errors = ref<string[]>([]);
+const warnings = ref<string[]>([]);
+let previewTimer: number | null = null;
+
+function schedulePreview() {
+	if (previewTimer) window.clearTimeout(previewTimer);
+	previewTimer = window.setTimeout(() => fetchPreview(), 500);
+}
+
+async function fetchPreview() {
+	if (!localExpr.value?.trim()) {
+		preview.value = '';
+		errors.value = [];
+		warnings.value = [];
+		emit('validationChange', { errors: [], warnings: [], preview: '' });
+		return;
+	}
+	try {
+		const res: any = await kpiApi.previewFormula(
+			localExpr.value,
+			props.isEnabled ?? true,
+			props.excludeKpiId
+		);
+		const data = res.data ?? res;
+		preview.value = data.preview ?? '';
+		errors.value = data.errors ?? [];
+		warnings.value = data.warnings ?? [];
+		emit('validationChange', { errors: errors.value, warnings: warnings.value, preview: preview.value });
+	} catch {
+		// 静默失败:保留上次预览
+	}
+}
+
+defineExpose({ fetchPreview });
+</script>
+
+<style scoped>
+.formula-editor { position: relative; }
+.formula-editor__bar {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	margin-bottom: 4px;
+	font-size: 12px;
+}
+.formula-editor__hint { display: flex; gap: 6px; align-items: center; }
+.formula-editor__hint-text { color: #64748b; }
+.formula-editor__suggest {
+	position: absolute;
+	left: 0;
+	top: calc(100% + 4px);
+	width: 420px;
+	max-height: 280px;
+	overflow-y: auto;
+	background: #ffffff;
+	border: 1px solid #cbd5e1;
+	border-radius: 6px;
+	box-shadow: 0 8px 20px rgba(15, 23, 42, 0.15);
+	z-index: 10;
+}
+.formula-editor__suggest-header {
+	padding: 8px 12px;
+	border-bottom: 1px solid #e2e8f0;
+	font-size: 12px;
+	font-weight: 600;
+	color: #334155;
+	display: flex;
+	justify-content: space-between;
+}
+.formula-editor__suggest-filter { color: #94a3b8; font-weight: normal; }
+.formula-editor__suggest-item {
+	padding: 6px 12px;
+	cursor: pointer;
+	display: flex;
+	gap: 10px;
+	align-items: center;
+	font-size: 12px;
+}
+.formula-editor__suggest-item:hover,
+.formula-editor__suggest-item.active { background: #eef2ff; }
+.formula-editor__suggest-code { font-family: 'JetBrains Mono', Consolas, monospace; color: #2563eb; min-width: 120px; }
+.formula-editor__suggest-name { flex: 1; color: #1f2937; }
+.formula-editor__suggest-module { color: #64748b; font-size: 11px; }
+.formula-editor__suggest-empty { padding: 16px; text-align: center; color: #94a3b8; font-size: 12px; }
+.formula-editor__preview {
+	margin-top: 6px;
+	padding: 8px 12px;
+	background: #f0f9ff;
+	border-left: 3px solid #3b82f6;
+	font-size: 13px;
+	color: #1e40af;
+	border-radius: 4px;
+}
+.formula-editor__preview-label { font-weight: 600; margin-right: 6px; }
+.formula-editor__error {
+	margin-top: 4px;
+	padding: 6px 10px;
+	background: #fef2f2;
+	border-left: 3px solid #ef4444;
+	color: #b91c1c;
+	font-size: 12px;
+	border-radius: 4px;
+}
+.formula-editor__warning {
+	margin-top: 4px;
+	padding: 6px 10px;
+	background: #fffbeb;
+	border-left: 3px solid #f59e0b;
+	color: #92400e;
+	font-size: 12px;
+	border-radius: 4px;
+}
+</style>

+ 42 - 5
Web/src/views/aidop/kanban/kpiMaster.vue

@@ -171,8 +171,18 @@
 						<el-form-item label="KPI 描述">
 							<el-input v-model="editForm.description" type="textarea" :rows="2" />
 						</el-form-item>
-						<el-form-item label="计算公式">
-							<el-input v-model="editForm.formula" type="textarea" :rows="2" />
+						<el-form-item label="结构化公式" class="kpi-detail__formula-expr">
+							<FormulaEditor
+								v-model="editForm.formulaExpr"
+								:module-code="editForm.moduleCode"
+								:is-enabled="editForm.isEnabled"
+								:exclude-kpi-id="editForm.id ?? undefined"
+								placeholder="示例:$F_S1_001 / $F_COMMON_001  或  @S1_L1_001"
+								@validation-change="onFormulaValidation"
+							/>
+						</el-form-item>
+						<el-form-item label="原始公式(兼容)">
+							<el-input v-model="editForm.formula" type="textarea" :rows="2" placeholder="自由文本公式描述(旧字段,新指标建议使用上方结构化公式)" />
 						</el-form-item>
 						<el-form-item label="计算规则">
 							<el-input v-model="editForm.calcRule" type="textarea" :rows="3" />
@@ -278,6 +288,7 @@ import type { NodeDropType } from 'element-plus/es/components/tree/src/tree.type
 import type { UploadFile, UploadInstance } from 'element-plus';
 import * as api from '/@/views/aidop/api/kpiMasterApi';
 import type { KpiTreeNode, KpiDetail } from '/@/views/aidop/api/kpiMasterApi';
+import FormulaEditor from './components/FormulaEditor.vue';
 
 const router = useRouter();
 
@@ -298,6 +309,7 @@ const treeRef = ref<any>(null);
 const currentDetail = ref<KpiDetail | null>(null);
 
 const editForm = reactive({
+	id: 0 as number,
 	moduleCode: '',
 	metricLevel: 1,
 	metricName: '',
@@ -313,11 +325,23 @@ const editForm = reactive({
 	description: '',
 	formula: '',
 	calcRule: '',
+	formulaExpr: '',
 	dopFields: '',
 	remark: '',
 	isEnabled: true,
 });
 
+const formulaValidation = reactive({
+	errors: [] as string[],
+	warnings: [] as string[],
+	preview: '',
+});
+function onFormulaValidation(v: { errors: string[]; warnings: string[]; preview: string }) {
+	formulaValidation.errors = v.errors;
+	formulaValidation.warnings = v.warnings;
+	formulaValidation.preview = v.preview;
+}
+
 const ctxMenu = reactive({ visible: false, x: 0, y: 0, node: null as KpiTreeNode | null });
 
 function wrapWithModuleGroups(flatRoots: KpiTreeNode[]): KpiTreeNode[] {
@@ -384,6 +408,7 @@ async function onNodeClick(data: KpiTreeNode) {
 		const d: KpiDetail = (res as any).data || res;
 		currentDetail.value = d;
 		Object.assign(editForm, {
+			id: d.id,
 			moduleCode: d.moduleCode,
 			metricLevel: d.metricLevel,
 			metricName: d.metricName,
@@ -399,6 +424,7 @@ async function onNodeClick(data: KpiTreeNode) {
 			description: d.description || '',
 			formula: d.formula || '',
 			calcRule: d.calcRule || '',
+			formulaExpr: d.formulaExpr || '',
 			dopFields: d.dopFields || '',
 			remark: d.remark || '',
 			isEnabled: d.isEnabled,
@@ -414,15 +440,26 @@ async function onSave() {
 	if (!currentDetail.value) return;
 	saving.value = true;
 	try {
-		await api.update(currentDetail.value.id, {
+		const resp: any = await api.update(currentDetail.value.id, {
 			...editForm,
 			parentId: currentDetail.value.parentId,
 		});
-		ElMessage.success('保存成功');
+		const warns: string[] = resp?.data?.formulaWarnings ?? [];
+		if (warns.length > 0) {
+			ElMessage.warning('保存成功(公式提示:' + warns.join(';') + ')');
+		} else {
+			ElMessage.success('保存成功');
+		}
 		await loadTree();
 		await onNodeClick({ id: currentDetail.value.id } as any);
 	} catch (e: any) {
-		ElMessage.error('保存失败: ' + (e.message || e));
+		const body = e?.response?.data;
+		const errs: string[] = body?.errors;
+		if (Array.isArray(errs) && errs.length > 0) {
+			ElMessage.error(`${body?.message || '保存失败'}:${errs.join(';')}`);
+		} else {
+			ElMessage.error('保存失败: ' + (body?.message || e.message || e));
+		}
 	} finally {
 		saving.value = false;
 	}

+ 66 - 0
ai-dop-platform/tools/_probe_formula_samples.py

@@ -0,0 +1,66 @@
+# -*- coding: utf-8 -*-
+"""抽样现有 KpiMaster 的 Formula / CalcRule / DopFields,评估公式结构化改造的真实样本形态。"""
+from __future__ import annotations
+import sys
+import pymysql
+
+sys.stdout.reconfigure(encoding='utf-8')
+
+CONN = dict(
+    host='123.60.180.165', port=3306,
+    user='aidopremote', password='1234567890aiDOP#',
+    database='aidopdev', charset='utf8mb4', autocommit=True,
+)
+
+
+def main() -> None:
+    conn = pymysql.connect(**CONN)
+    with conn.cursor(pymysql.cursors.DictCursor) as cur:
+        cur.execute(
+            "SELECT COUNT(*) AS c, "
+            "       SUM(CASE WHEN Formula IS NOT NULL AND Formula<>'' THEN 1 ELSE 0 END) AS fc, "
+            "       SUM(CASE WHEN CalcRule IS NOT NULL AND CalcRule<>'' THEN 1 ELSE 0 END) AS cc, "
+            "       SUM(CASE WHEN DopFields IS NOT NULL AND DopFields<>'' THEN 1 ELSE 0 END) AS dc "
+            "FROM ado_smart_ops_kpi_master WHERE IsEnabled=1"
+        )
+        s = cur.fetchone()
+        print(f"总启用指标: {s['c']}  有 Formula: {s['fc']}  有 CalcRule: {s['cc']}  有 DopFields: {s['dc']}")
+
+        print("\n=== Formula 长度分布 ===")
+        cur.execute(
+            "SELECT LENGTH(Formula) AS L, COUNT(*) AS c FROM ado_smart_ops_kpi_master "
+            "WHERE IsEnabled=1 AND Formula IS NOT NULL AND Formula<>'' GROUP BY L ORDER BY L"
+        )
+        bins = {"0-40": 0, "41-80": 0, "81-150": 0, "151-400": 0, "400+": 0}
+        for r in cur.fetchall():
+            L = r['L']
+            n = r['c']
+            if L <= 40: bins["0-40"] += n
+            elif L <= 80: bins["41-80"] += n
+            elif L <= 150: bins["81-150"] += n
+            elif L <= 400: bins["151-400"] += n
+            else: bins["400+"] += n
+        for k, v in bins.items():
+            print(f"  {k:>8} 字符 : {v}")
+
+        print("\n=== 随机抽样 20 条(不同模块/层级) ===")
+        cur.execute(
+            "SELECT ModuleCode, MetricLevel, MetricCode, MetricName, Formula, CalcRule, DopFields "
+            "FROM ado_smart_ops_kpi_master WHERE IsEnabled=1 AND TenantId=1300000000001 "
+            "  AND Formula IS NOT NULL AND Formula<>'' "
+            "ORDER BY ModuleCode, MetricLevel, SortNo LIMIT 20"
+        )
+        for r in cur.fetchall():
+            print(f"\n[{r['ModuleCode']} L{r['MetricLevel']}] {r['MetricCode']} {r['MetricName']}")
+            print(f"  Formula  : {r['Formula']}")
+            if r['CalcRule']:
+                cr = r['CalcRule'].replace('\n', ' ')
+                print(f"  CalcRule : {cr[:120]}{'…' if len(cr)>120 else ''}")
+            if r['DopFields']:
+                print(f"  DopFields: {r['DopFields']}")
+
+    conn.close()
+
+
+if __name__ == "__main__":
+    main()

+ 88 - 0
ai-dop-platform/tools/apply_formula_structuring_ddl.py

@@ -0,0 +1,88 @@
+# -*- coding: utf-8 -*-
+"""第三批:公式结构化 DDL — 幂等可重跑。
+
+1) 新建 ado_smart_ops_business_fact 业务事实字典表
+2) 为 ado_smart_ops_kpi_master 增加 3 列:FormulaExpr / FormulaPreview / FormulaRefs
+
+保留旧 Formula / CalcRule / DopFields 字段不动(Q3-B 向下兼容)。
+"""
+from __future__ import annotations
+import sys
+import pymysql
+
+sys.stdout.reconfigure(encoding='utf-8')
+
+CONN = dict(
+    host='123.60.180.165', port=3306,
+    user='aidopremote', password='1234567890aiDOP#',
+    database='aidopdev', charset='utf8mb4', autocommit=True,
+)
+
+FACT_TABLE_DDL = """
+CREATE TABLE IF NOT EXISTS `ado_smart_ops_business_fact` (
+  `Id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `FactCode` varchar(64) NOT NULL COMMENT '事实编码(英文,稳定)',
+  `FactName` varchar(200) NOT NULL COMMENT '事实名称(中文,展示)',
+  `ModuleCode` varchar(20) NULL COMMENT '归属模块 S1~S9 或 COMMON',
+  `Unit` varchar(50) NULL COMMENT '单位',
+  `AggType` varchar(20) NULL COMMENT '聚合类型 COUNT/SUM/AVG/MAX/MIN/LATEST',
+  `DataSource` varchar(200) NULL COMMENT '预期数据来源(文字说明)',
+  `Description` text NULL COMMENT '说明/备注',
+  `IsEnabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
+  `SortNo` int NOT NULL DEFAULT 0 COMMENT '排序号',
+  `TenantId` bigint NOT NULL COMMENT '租户 ID',
+  `CreatedAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `UpdatedAt` datetime NULL COMMENT '更新时间',
+  PRIMARY KEY (`Id`),
+  UNIQUE KEY `uk_fact_code_tenant` (`FactCode`, `TenantId`),
+  KEY `idx_fact_module_tenant` (`ModuleCode`, `TenantId`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事实字典(指标公式引用的分子/分母来源)';
+"""
+
+KPIM_COLS = [
+    ("FormulaExpr", "ALTER TABLE `ado_smart_ops_kpi_master` ADD COLUMN `FormulaExpr` varchar(500) NULL COMMENT '结构化表达式 @MetricCode/$FactCode 及算符常量'"),
+    ("FormulaPreview", "ALTER TABLE `ado_smart_ops_kpi_master` ADD COLUMN `FormulaPreview` varchar(1000) NULL COMMENT '人读预览(引用替换为中文名)'"),
+    ("FormulaRefs", "ALTER TABLE `ado_smart_ops_kpi_master` ADD COLUMN `FormulaRefs` varchar(500) NULL COMMENT '引用清单 JSON {metrics:[],facts:[]}'"),
+]
+
+
+def table_exists(cur, table: str) -> bool:
+    cur.execute("SELECT COUNT(*) AS c FROM information_schema.tables WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s", (table,))
+    return cur.fetchone()[0] > 0
+
+
+def col_exists(cur, table: str, col: str) -> bool:
+    cur.execute("SELECT COUNT(*) AS c FROM information_schema.columns WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s AND COLUMN_NAME=%s", (table, col))
+    return cur.fetchone()[0] > 0
+
+
+def main() -> None:
+    conn = pymysql.connect(**CONN)
+    with conn.cursor() as cur:
+        # 1) business fact
+        if table_exists(cur, 'ado_smart_ops_business_fact'):
+            print("[SKIP] ado_smart_ops_business_fact 已存在")
+        else:
+            cur.execute(FACT_TABLE_DDL)
+            print("[OK]   创建 ado_smart_ops_business_fact")
+
+        # 2) kpi master new columns
+        for col, ddl in KPIM_COLS:
+            if col_exists(cur, 'ado_smart_ops_kpi_master', col):
+                print(f"[SKIP] kpi_master.{col} 已存在")
+            else:
+                cur.execute(ddl)
+                print(f"[OK]   新增 kpi_master.{col}")
+
+        # 验证
+        cur.execute("SHOW COLUMNS FROM `ado_smart_ops_kpi_master`")
+        cols = {r[0] for r in cur.fetchall()}
+        missing = [c for c, _ in KPIM_COLS if c not in cols]
+        if missing:
+            raise RuntimeError(f"新增列缺失:{missing}")
+        print("\n[DONE] DDL 全部就绪。")
+    conn.close()
+
+
+if __name__ == "__main__":
+    main()

+ 134 - 0
ai-dop-platform/tools/insert_business_fact_menu.py

@@ -0,0 +1,134 @@
+# -*- coding: utf-8 -*-
+"""幂等插入「业务事实字典」菜单到 dev 库。
+
+首次启动时 SysMenuSeedData 不会自动补新菜单(IgnoreUpdateSeed),
+AidopMenuLinkSync 仅补租户/角色链接而不会建菜单本身。
+故 dev 环境需一次性手工插。重跑无副作用。
+"""
+from __future__ import annotations
+import sys
+from datetime import datetime
+
+import pymysql
+
+sys.stdout.reconfigure(encoding='utf-8')
+
+CONN = dict(
+    host='123.60.180.165', port=3306,
+    user='aidopremote', password='1234567890aiDOP#',
+    database='aidopdev', charset='utf8mb4', autocommit=False,
+)
+
+MENU_ID = 1320990000313
+PARENT_ID = 1320990000300  # Ai-DOP 智慧运营目录(从 kpi-master 菜单推得,同父级)
+ROUTE_PATH = '/aidop/smart-ops/business-fact'
+ROUTE_NAME = 'aidopSmartOpsBusinessFact'
+COMPONENT = '/aidop/kanban/businessFact'
+TITLE = '业务事实字典'
+
+
+def main() -> None:
+    conn = pymysql.connect(**CONN)
+    cur = conn.cursor(pymysql.cursors.DictCursor)
+    try:
+        # 先找出 kpi-master 菜单的 Pid 作为父级
+        cur.execute("SELECT Pid, OrderNo FROM SysMenu WHERE Id=%s", (1320990000312,))
+        parent_row = cur.fetchone()
+        if not parent_row:
+            raise RuntimeError("未找到 kpi-master 菜单(1320990000312),无法推断父级目录")
+        pid = parent_row['Pid']
+        order_no = int(parent_row['OrderNo']) + 1
+        print(f"父级目录 Pid={pid}, 排序={order_no}")
+
+        # 探测 SysMenu 列集
+        cur.execute("SHOW COLUMNS FROM SysMenu")
+        cols = {r['Field'] for r in cur.fetchall()}
+        print(f"SysMenu 列: {sorted(cols)[:12]}... (共 {len(cols)})")
+
+        # 1) 菜单:从 kpi-master 克隆后覆盖关键字段
+        cur.execute("SELECT COUNT(*) AS c FROM SysMenu WHERE Id=%s", (MENU_ID,))
+        if cur.fetchone()['c'] > 0:
+            print(f"[SKIP] SysMenu.Id={MENU_ID} 已存在")
+        else:
+            cur.execute("SELECT * FROM SysMenu WHERE Id=%s", (1320990000312,))
+            tmpl = cur.fetchone()
+            if not tmpl:
+                raise RuntimeError("模板菜单 1320990000312 不存在")
+            tmpl['Id'] = MENU_ID
+            tmpl['Title'] = TITLE
+            tmpl['Path'] = ROUTE_PATH
+            tmpl['Name'] = ROUTE_NAME
+            tmpl['Component'] = COMPONENT
+            tmpl['Icon'] = 'ele-Collection'
+            tmpl['OrderNo'] = order_no
+            tmpl['Remark'] = TITLE
+            tmpl['CreateTime'] = datetime.now()
+            if 'UpdateTime' in tmpl:
+                tmpl['UpdateTime'] = None
+            ks = ','.join(f'`{k}`' for k in tmpl)
+            ph = ','.join(['%s'] * len(tmpl))
+            cur.execute(f"INSERT INTO SysMenu ({ks}) VALUES ({ph})", tuple(tmpl.values()))
+            print(f"[OK]   新增 SysMenu.Id={MENU_ID}(按 kpi-master 菜单克隆)")
+
+        # 2) 租户菜单 - 给所有租户都挂上
+        cur.execute("SELECT Id FROM SysTenant")
+        tenants = [r['Id'] for r in cur.fetchall()]
+        cur.execute("SHOW COLUMNS FROM SysTenantMenu")
+        tm_cols = {r['Field'] for r in cur.fetchall()}
+        tm_added = 0
+        for tid in tenants:
+            cur.execute(
+                "SELECT COUNT(*) AS c FROM SysTenantMenu WHERE TenantId=%s AND MenuId=%s",
+                (tid, MENU_ID),
+            )
+            if cur.fetchone()['c'] > 0:
+                continue
+            cur.execute("SELECT IFNULL(MAX(Id),0)+1 AS nx FROM SysTenantMenu")
+            link_id = cur.fetchone()['nx']
+            tm_row = {'Id': link_id, 'TenantId': tid, 'MenuId': MENU_ID}
+            tm_row = {k: v for k, v in tm_row.items() if k in tm_cols}
+            ks = ','.join(f'`{k}`' for k in tm_row)
+            ph = ','.join(['%s'] * len(tm_row))
+            cur.execute(f"INSERT INTO SysTenantMenu ({ks}) VALUES ({ph})", tuple(tm_row.values()))
+            tm_added += 1
+        print(f"[OK]   补 SysTenantMenu x {tm_added} 条(共 {len(tenants)} 租户)")
+
+        # 3) 角色菜单 - 给系统管理员 + 已授权 kpi-master 的角色
+        cur.execute("SELECT DISTINCT RoleId FROM SysRoleMenu WHERE MenuId=%s", (1320990000312,))
+        roles = {r['RoleId'] for r in cur.fetchall()}
+        roles.add(1300000000101)  # SysAdmin
+        cur.execute("SHOW COLUMNS FROM SysRoleMenu")
+        rm_cols = {r['Field'] for r in cur.fetchall()}
+        inserted = 0
+        for rid in roles:
+            cur.execute(
+                "SELECT COUNT(*) AS c FROM SysRoleMenu WHERE RoleId=%s AND MenuId=%s",
+                (rid, MENU_ID),
+            )
+            if cur.fetchone()['c'] > 0:
+                continue
+            # 避免 Id 撞号:用 MAX+1 策略
+            cur.execute("SELECT IFNULL(MAX(Id),0)+1 AS nx FROM SysRoleMenu")
+            nx = cur.fetchone()['nx']
+            rm_row = {'Id': nx, 'RoleId': rid, 'MenuId': MENU_ID}
+            rm_row = {k: v for k, v in rm_row.items() if k in rm_cols}
+            ks = ','.join(f'`{k}`' for k in rm_row)
+            ph = ','.join(['%s'] * len(rm_row))
+            cur.execute(f"INSERT INTO SysRoleMenu ({ks}) VALUES ({ph})", tuple(rm_row.values()))
+            inserted += 1
+        print(f"[OK]   补 SysRoleMenu x {inserted} 角色(共候选 {len(roles)})")
+
+        conn.commit()
+        print("\n[DONE] 菜单入库完成。请在前端 ctrl-shift-R 刷新或重新登录以加载。")
+
+    except Exception as ex:
+        conn.rollback()
+        print(f"回滚:{ex}")
+        raise
+    finally:
+        cur.close()
+        conn.close()
+
+
+if __name__ == "__main__":
+    main()

+ 200 - 0
ai-dop-platform/tools/seed_business_facts.py

@@ -0,0 +1,200 @@
+# -*- coding: utf-8 -*-
+"""从 230 条 Formula 自动抽取高频短语,规范化为 Business Fact 入库。
+
+策略:
+  1) 取所有 IsEnabled=1 的 Formula 文本
+  2) 按 / * + - ( ) 空格 等号 切分,去噪
+  3) 统计每个短语在各模块出现次数
+  4) 命中次数 >=2 的短语 → 生成 FactCode(F_<MODULE>_<PINYIN_SHORT>)
+  5) 幂等写入 ado_smart_ops_business_fact,两个租户各一份
+
+FactCode 命名约定:
+  F_<MODULE>_<序号3位>   纯英文稳定编码,不依赖中文(避免乱码/改名断裂)
+  同一短语在多模块出现 → 归到 COMMON_ 前缀
+"""
+from __future__ import annotations
+import re
+import sys
+from collections import Counter, defaultdict
+from datetime import datetime
+
+import pymysql
+
+sys.stdout.reconfigure(encoding='utf-8')
+
+CONN = dict(
+    host='123.60.180.165', port=3306,
+    user='aidopremote', password='1234567890aiDOP#',
+    database='aidopdev', charset='utf8mb4', autocommit=False,
+)
+
+TENANTS = [1300000000001, 1300000000888]
+
+# 分隔符:支持全角/半角
+SPLIT_RE = re.compile(r'[\s/÷\*×\+\-\(\)()==,,、。]+')
+# 排除噪声:纯数字、过短、含日期占位等
+NOISE_RE = re.compile(
+    r'^\d+%?$|^D\d+$|^[A-Z]\d+$|^\d+天$|^[×÷\+\-\*/\(\)]+$|^$'
+)
+# 仅保留有实际业务含义的中文短语(2-25 字)
+VALID_RE = re.compile(r'^[\u4e00-\u9fa5A-Za-z0-9%]{2,30}$')
+
+
+def normalize(phrase: str) -> str:
+    """去除尾部'=', 空格, 中文全角括号注释。"""
+    s = phrase.strip().rstrip('==::')
+    # 去除公式中嵌入的'举例''等'等尾缀
+    for trail in ('举例', '示例'):
+        if s.endswith(trail):
+            s = s[:-len(trail)].rstrip()
+    return s
+
+
+def extract_phrases(formula: str) -> list[str]:
+    """从一条 Formula 抽取候选业务事实短语。"""
+    if not formula:
+        return []
+    # 去除常见的"说明"部分
+    cleaned = formula.split('(备注')[0].split('(说明')[0]
+    parts = SPLIT_RE.split(cleaned)
+    out = []
+    for p in parts:
+        p = normalize(p)
+        if not p or NOISE_RE.match(p):
+            continue
+        if not VALID_RE.match(p):
+            continue
+        # 排除纯算符中文描述("等于"等)
+        if p in {'等于', '之', '或', '与', '除以', '乘以', '减去', '加上', '的', '其中', '以及', '即', '则', '每个'}:
+            continue
+        out.append(p)
+    return out
+
+
+def make_code(module: str, idx: int) -> str:
+    return f"F_{module}_{idx:03d}"
+
+
+def guess_unit(name: str) -> str | None:
+    if name.endswith('数') or name.endswith('个数') or name.endswith('行数') or name.endswith('人数') or name.endswith('数量'):
+        return '个'
+    if name.endswith('时长') or name.endswith('天数') or name.endswith('周期'):
+        return '天'
+    if name.endswith('率'):
+        return '%'
+    if name.endswith('金额') or name.endswith('成本'):
+        return '元'
+    return None
+
+
+def guess_agg(name: str) -> str:
+    if '合计' in name or '总' in name:
+        return 'SUM'
+    if '平均' in name or '均' in name:
+        return 'AVG'
+    if '最大' in name:
+        return 'MAX'
+    if '最小' in name:
+        return 'MIN'
+    if '数量' in name or '数' in name or '人数' in name or '行数' in name:
+        return 'COUNT'
+    return 'LATEST'
+
+
+def main() -> None:
+    conn = pymysql.connect(**CONN)
+    cur = conn.cursor(pymysql.cursors.DictCursor)
+    try:
+        cur.execute(
+            "SELECT ModuleCode, Formula FROM ado_smart_ops_kpi_master "
+            "WHERE IsEnabled=1 AND TenantId=%s AND Formula IS NOT NULL AND Formula<>''",
+            (TENANTS[0],),
+        )
+        rows = cur.fetchall()
+
+        # 短语 -> {(module): count}
+        phrase_to_modules: dict[str, Counter] = defaultdict(Counter)
+        for r in rows:
+            mc = r['ModuleCode']
+            for p in extract_phrases(r['Formula']):
+                phrase_to_modules[p][mc] += 1
+
+        # 筛选:命中 >=2 次,或特定高价值关键词
+        HIGH_VALUE_KWS = ('人数', '总数', '订单', '合计', '金额', '库存', '周期', '行数', '数量')
+        facts: list[tuple[str, str, int]] = []  # (phrase, module, total_count)
+        for p, mod_cnt in phrase_to_modules.items():
+            total = sum(mod_cnt.values())
+            if total < 2 and not any(k in p for k in HIGH_VALUE_KWS):
+                continue
+            top_mod, top_cnt = mod_cnt.most_common(1)[0]
+            # 跨多个模块 → COMMON
+            mod = top_mod if len(mod_cnt) == 1 else 'COMMON'
+            facts.append((p, mod, total))
+
+        # 排序:COMMON 在前,其次按模块、命中数
+        facts.sort(key=lambda x: (x[1] != 'COMMON', x[1], -x[2]))
+
+        # 每模块生成连续编号
+        mod_seq: Counter = Counter()
+        inserts = []
+        for name, mod, cnt in facts:
+            mod_seq[mod] += 1
+            code = make_code(mod, mod_seq[mod])
+            inserts.append(dict(
+                code=code, name=name, module=mod,
+                unit=guess_unit(name), agg=guess_agg(name),
+                count=cnt,
+            ))
+
+        print(f"抽取短语 {len(phrase_to_modules)} 个,入库候选 {len(inserts)} 条(命中≥2 或高价值关键词)")
+        print("\n预览前 20 条:")
+        print(f"{'FactCode':<18}{'Module':<10}{'FactName':<30}{'Unit':<6}{'Agg':<8}{'Hits':<6}")
+        print("-" * 82)
+        for i, f in enumerate(inserts[:20]):
+            print(f"{f['code']:<18}{f['module']:<10}{f['name']:<30}{(f['unit'] or ''):<6}{f['agg']:<8}{f['count']:<6}")
+
+        # 入库(两个租户幂等)
+        now = datetime.now()
+        sort_no = 0
+        for t in TENANTS:
+            print(f"\n>>> 写入租户 {t}")
+            inserted = 0
+            skipped = 0
+            for f in inserts:
+                sort_no += 1
+                cur.execute(
+                    "SELECT COUNT(*) AS c FROM ado_smart_ops_business_fact "
+                    "WHERE FactCode=%s AND TenantId=%s",
+                    (f['code'], t),
+                )
+                exists = cur.fetchone()['c'] > 0
+                if exists:
+                    skipped += 1
+                    continue
+                cur.execute(
+                    """INSERT INTO ado_smart_ops_business_fact
+                    (FactCode, FactName, ModuleCode, Unit, AggType, DataSource, Description,
+                     IsEnabled, SortNo, TenantId, CreatedAt, UpdatedAt)
+                    VALUES (%s, %s, %s, %s, %s, %s, %s, 1, %s, %s, %s, %s)""",
+                    (f['code'], f['name'], f['module'], f['unit'], f['agg'],
+                     None, f'从历史 Formula 抽取,命中 {f["count"]} 次',
+                     sort_no, t, now, now),
+                )
+                inserted += 1
+            print(f"    插入 {inserted} 条,跳过 {skipped} 条")
+
+        conn.commit()
+        cur.execute("SELECT COUNT(*) AS c FROM ado_smart_ops_business_fact")
+        print(f"\n当前字典总记录数: {cur.fetchone()['c']}")
+
+    except Exception as ex:
+        conn.rollback()
+        print(f"回滚:{ex}")
+        raise
+    finally:
+        cur.close()
+        conn.close()
+
+
+if __name__ == "__main__":
+    main()

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

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.54</AssemblyVersion>
-    <FileVersion>1.0.54</FileVersion>
-    <Version>1.0.54</Version>
+    <AssemblyVersion>1.0.55</AssemblyVersion>
+    <FileVersion>1.0.55</FileVersion>
+    <Version>1.0.55</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 123 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AdoSmartOpsBusinessFactController.cs

@@ -0,0 +1,123 @@
+using Admin.NET.Plugin.AiDOP.Entity;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers;
+
+/// <summary>
+/// 业务事实字典:KpiMaster 公式中 $FactCode 的引用来源。
+/// </summary>
+[ApiController]
+[Route("api/[controller]")]
+[AllowAnonymous]
+[NonUnify]
+public class AdoSmartOpsBusinessFactController : ControllerBase
+{
+    private readonly ISqlSugarClient _db;
+
+    public AdoSmartOpsBusinessFactController(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    /// <summary>分页 / 按条件查询</summary>
+    [HttpGet("page")]
+    public async Task<IActionResult> Page(
+        [FromQuery] int page = 1,
+        [FromQuery] int size = 50,
+        [FromQuery] string? keyword = null,
+        [FromQuery] string? moduleCode = null)
+    {
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        var q = _db.Queryable<AdoSmartOpsBusinessFact>().Where(x => x.TenantId == tenantId);
+        if (!string.IsNullOrWhiteSpace(moduleCode))
+            q = q.Where(x => x.ModuleCode == moduleCode);
+        if (!string.IsNullOrWhiteSpace(keyword))
+            q = q.Where(x => x.FactCode.Contains(keyword) || x.FactName.Contains(keyword));
+
+        RefAsync<int> total = 0;
+        var rows = await q.OrderBy(x => x.ModuleCode).OrderBy(x => x.SortNo).ToPageListAsync(page, size, total);
+        return Ok(new { total = total.Value, rows });
+    }
+
+    /// <summary>下拉选择器精简列表(公式编辑器 $ 补全用)</summary>
+    [HttpGet("picker")]
+    public async Task<IActionResult> Picker([FromQuery] string? moduleCode = null)
+    {
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        var q = _db.Queryable<AdoSmartOpsBusinessFact>()
+            .Where(x => x.TenantId == tenantId && x.IsEnabled);
+        if (!string.IsNullOrWhiteSpace(moduleCode))
+            q = q.Where(x => x.ModuleCode == moduleCode || x.ModuleCode == "COMMON" || x.ModuleCode == null);
+        var list = await q.OrderBy(x => x.ModuleCode).OrderBy(x => x.SortNo)
+            .Select(x => new { x.FactCode, x.FactName, x.ModuleCode, x.Unit, x.AggType }).ToListAsync();
+        return Ok(list);
+    }
+
+    /// <summary>单条详情</summary>
+    [HttpGet("{id:long}")]
+    public async Task<IActionResult> Detail(long id)
+    {
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        var row = await _db.Queryable<AdoSmartOpsBusinessFact>()
+            .Where(x => x.Id == id && x.TenantId == tenantId).FirstAsync();
+        if (row == null) return NotFound(new { message = "事实不存在" });
+        return Ok(row);
+    }
+
+    /// <summary>新增</summary>
+    [HttpPost("add")]
+    public async Task<IActionResult> Add([FromBody] AdoSmartOpsBusinessFact input)
+    {
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        if (string.IsNullOrWhiteSpace(input.FactCode) || string.IsNullOrWhiteSpace(input.FactName))
+            return BadRequest(new { message = "FactCode / FactName 必填" });
+
+        var dup = await _db.Queryable<AdoSmartOpsBusinessFact>()
+            .Where(x => x.TenantId == tenantId && x.FactCode == input.FactCode).AnyAsync();
+        if (dup) return BadRequest(new { message = $"FactCode '{input.FactCode}' 已存在" });
+
+        input.TenantId = tenantId;
+        input.CreatedAt = DateTime.Now;
+        input.UpdatedAt = DateTime.Now;
+        await _db.Insertable(input).ExecuteCommandAsync();
+        return Ok(new { id = input.Id });
+    }
+
+    /// <summary>更新</summary>
+    [HttpPost("update")]
+    public async Task<IActionResult> Update([FromBody] AdoSmartOpsBusinessFact input)
+    {
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        var row = await _db.Queryable<AdoSmartOpsBusinessFact>()
+            .Where(x => x.Id == input.Id && x.TenantId == tenantId).FirstAsync();
+        if (row == null) return NotFound(new { message = "事实不存在" });
+
+        row.FactName = input.FactName ?? row.FactName;
+        row.ModuleCode = input.ModuleCode;
+        row.Unit = input.Unit;
+        row.AggType = input.AggType;
+        row.DataSource = input.DataSource;
+        row.Description = input.Description;
+        row.IsEnabled = input.IsEnabled;
+        row.SortNo = input.SortNo;
+        row.UpdatedAt = DateTime.Now;
+        await _db.Updateable(row).ExecuteCommandAsync();
+        return Ok(new { ok = true });
+    }
+
+    /// <summary>删除(软删?此处做硬删;如有引用将在下批检查)</summary>
+    [HttpPost("delete")]
+    public async Task<IActionResult> Delete([FromBody] DeleteIn input)
+    {
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        await _db.Deleteable<AdoSmartOpsBusinessFact>()
+            .Where(x => x.Id == input.Id && x.TenantId == tenantId).ExecuteCommandAsync();
+        return Ok(new { ok = true });
+    }
+
+    public sealed class DeleteIn
+    {
+        public long Id { get; set; }
+    }
+}

+ 115 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AdoSmartOpsKpiMasterController.cs

@@ -1,7 +1,9 @@
+using System.Text.Json;
 using Admin.NET.Core;
 using Admin.NET.Plugin.AiDOP.Dto.SmartOps;
 using Admin.NET.Plugin.AiDOP.Entity;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Infrastructure.FormulaExpr;
 using Microsoft.AspNetCore.Http;
 using MiniExcelLibs;
 using SqlSugar;
@@ -93,7 +95,9 @@ public class AdoSmartOpsKpiMasterController : ControllerBase
             Id = e.Id, MetricCode = e.MetricCode, ModuleCode = e.ModuleCode,
             MetricLevel = e.MetricLevel, ParentId = e.ParentId, ParentName = parentName,
             MetricName = e.MetricName, Description = e.Description, Formula = e.Formula,
-            CalcRule = e.CalcRule, DataSource = e.DataSource, StatFrequency = e.StatFrequency,
+            CalcRule = e.CalcRule,
+            FormulaExpr = e.FormulaExpr, FormulaPreview = e.FormulaPreview, FormulaRefs = e.FormulaRefs,
+            DataSource = e.DataSource, StatFrequency = e.StatFrequency,
             Department = e.Department, DopFields = e.DopFields, Unit = e.Unit,
             Direction = e.Direction,
             YellowThreshold = e.YellowThreshold, RedThreshold = e.RedThreshold,
@@ -115,6 +119,12 @@ public class AdoSmartOpsKpiMasterController : ControllerBase
             return BadRequest(new { message = "L2/L3 必须指定父指标" });
 
         var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+
+        // Q4-C:对 FormulaExpr 做解析 + 校验;启用指标硬校验,否则仅警告
+        var fxResult = await ProcessFormulaAsync(tenantId, dto.FormulaExpr, dto.IsEnabled);
+        if (fxResult.BlockSave)
+            return BadRequest(new { message = "公式校验失败", errors = fxResult.Errors });
+
         var code = await GenerateMetricCodeAsync(dto.ModuleCode, dto.MetricLevel, tenantId);
 
         var entity = new AdoSmartOpsKpiMaster
@@ -127,6 +137,9 @@ public class AdoSmartOpsKpiMasterController : ControllerBase
             Description = dto.Description,
             Formula = dto.Formula,
             CalcRule = dto.CalcRule,
+            FormulaExpr = dto.FormulaExpr,
+            FormulaPreview = fxResult.Preview,
+            FormulaRefs = fxResult.RefsJson,
             DataSource = dto.DataSource,
             StatFrequency = dto.StatFrequency,
             Department = dto.Department,
@@ -145,7 +158,13 @@ public class AdoSmartOpsKpiMasterController : ControllerBase
 
         var newId = await _db.Insertable(entity).ExecuteReturnBigIdentityAsync();
         entity.Id = newId;
-        return Ok(new { id = newId, metricCode = code });
+        return Ok(new
+        {
+            id = newId,
+            metricCode = code,
+            formulaPreview = fxResult.Preview,
+            formulaWarnings = fxResult.Warnings
+        });
     }
 
     /// <summary>编辑指标</summary>
@@ -155,6 +174,13 @@ public class AdoSmartOpsKpiMasterController : ControllerBase
         var entity = await _db.Queryable<AdoSmartOpsKpiMaster>().FirstAsync(x => x.Id == id);
         if (entity == null) return NotFound();
 
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+
+        // Q4-C:公式解析 + 校验
+        var fxResult = await ProcessFormulaAsync(tenantId, dto.FormulaExpr, dto.IsEnabled, excludeKpiId: id);
+        if (fxResult.BlockSave)
+            return BadRequest(new { message = "公式校验失败", errors = fxResult.Errors });
+
         entity.ModuleCode = dto.ModuleCode;
         entity.MetricLevel = dto.MetricLevel;
         entity.ParentId = dto.ParentId;
@@ -162,6 +188,9 @@ public class AdoSmartOpsKpiMasterController : ControllerBase
         entity.Description = dto.Description;
         entity.Formula = dto.Formula;
         entity.CalcRule = dto.CalcRule;
+        entity.FormulaExpr = dto.FormulaExpr;
+        entity.FormulaPreview = fxResult.Preview;
+        entity.FormulaRefs = fxResult.RefsJson;
         entity.DataSource = dto.DataSource;
         entity.StatFrequency = dto.StatFrequency;
         entity.Department = dto.Department;
@@ -177,7 +206,12 @@ public class AdoSmartOpsKpiMasterController : ControllerBase
         entity.UpdatedAt = DateTime.Now;
 
         await _db.Updateable(entity).ExecuteCommandAsync();
-        return Ok(new { ok = true });
+        return Ok(new
+        {
+            ok = true,
+            formulaPreview = fxResult.Preview,
+            formulaWarnings = fxResult.Warnings
+        });
     }
 
     /// <summary>删除指标(含子节点级联删除)</summary>
@@ -559,4 +593,82 @@ public class AdoSmartOpsKpiMasterController : ControllerBase
                 SortChildren(n.Children);
         }
     }
+
+    // ── 公式解析 / 校验 / 预览:第三批新增 ──
+
+    private sealed class FormulaProcessResult
+    {
+        public string? Preview { get; init; }
+        public string? RefsJson { get; init; }
+        public List<string> Errors { get; init; } = new();
+        public List<string> Warnings { get; init; } = new();
+        public bool BlockSave { get; init; }
+    }
+
+    private async Task<FormulaProcessResult> ProcessFormulaAsync(
+        long tenantId, string? expr, bool isEnabled, long? excludeKpiId = null)
+    {
+        var parsed = FormulaParser.Parse(expr);
+        if (parsed.IsEmpty)
+            return new FormulaProcessResult();
+
+        var validation = await FormulaValidator.ValidateAsync(_db, tenantId, parsed, isEnabled, excludeKpiId);
+
+        // 启用指标 → 硬校验;否则仅警告
+        if (isEnabled && validation.HasErrors)
+        {
+            return new FormulaProcessResult
+            {
+                Errors = validation.Errors,
+                Warnings = validation.Warnings,
+                BlockSave = true,
+            };
+        }
+
+        var preview = await FormulaPreviewBuilder.BuildAsync(_db, tenantId, expr, parsed);
+        var refsJson = JsonSerializer.Serialize(new
+        {
+            metrics = parsed.MetricRefs,
+            facts = parsed.FactRefs,
+        });
+
+        return new FormulaProcessResult
+        {
+            Preview = preview,
+            RefsJson = refsJson,
+            Errors = validation.Errors,
+            Warnings = validation.Warnings,
+            BlockSave = false,
+        };
+    }
+
+    /// <summary>实时预览公式(不落库)。前端编辑器失焦/定时调用。</summary>
+    [HttpPost("preview-formula")]
+    public async Task<IActionResult> PreviewFormula([FromBody] PreviewFormulaIn input)
+    {
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        var parsed = FormulaParser.Parse(input?.FormulaExpr);
+        if (parsed.IsEmpty)
+            return Ok(new { preview = "", metrics = new List<string>(), facts = new List<string>(), errors = Array.Empty<string>(), warnings = Array.Empty<string>() });
+
+        var validation = await FormulaValidator.ValidateAsync(
+            _db, tenantId, parsed, isEnabled: input?.IsEnabled ?? true, excludeKpiId: input?.ExcludeKpiId);
+        var preview = await FormulaPreviewBuilder.BuildAsync(_db, tenantId, input!.FormulaExpr, parsed);
+
+        return Ok(new
+        {
+            preview,
+            metrics = parsed.MetricRefs,
+            facts = parsed.FactRefs,
+            errors = validation.Errors,
+            warnings = validation.Warnings,
+        });
+    }
+
+    public sealed class PreviewFormulaIn
+    {
+        public string? FormulaExpr { get; set; }
+        public bool IsEnabled { get; set; } = true;
+        public long? ExcludeKpiId { get; set; }
+    }
 }

+ 4 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/SmartOps/AdoSmartOpsKpiMasterDtos.cs

@@ -20,6 +20,7 @@ public class KpiMasterUpsertDto
     public string? Description { get; set; }
     public string? Formula { get; set; }
     public string? CalcRule { get; set; }
+    public string? FormulaExpr { get; set; }
     public string? DataSource { get; set; }
     public string? StatFrequency { get; set; }
     public string? Department { get; set; }
@@ -63,6 +64,9 @@ public class KpiMasterDetailDto
     public string? Description { get; set; }
     public string? Formula { get; set; }
     public string? CalcRule { get; set; }
+    public string? FormulaExpr { get; set; }
+    public string? FormulaPreview { get; set; }
+    public string? FormulaRefs { get; set; }
     public string? DataSource { get; set; }
     public string? StatFrequency { get; set; }
     public string? Department { get; set; }

+ 51 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsBusinessFact.cs

@@ -0,0 +1,51 @@
+using Admin.NET.Core;
+
+namespace Admin.NET.Plugin.AiDOP.Entity;
+
+/// <summary>
+/// 业务事实字典:供 KpiMaster.FormulaExpr 以 $FactCode 形式引用的分子/分母来源。
+/// 下一批做 ETL 规则时将把 FactCode 映射到具体 SQL 模板。
+/// </summary>
+[SugarTable("ado_smart_ops_business_fact", "业务事实字典")]
+[SugarIndex("uk_fact_code_tenant", nameof(FactCode), OrderByType.Asc, nameof(TenantId), OrderByType.Asc, true)]
+public class AdoSmartOpsBusinessFact : ITenantIdFilter
+{
+    [SugarColumn(ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnDescription = "事实编码(英文稳定码)", Length = 64)]
+    public string FactCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "事实名称(中文展示)", Length = 200)]
+    public string FactName { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "归属模块 S1~S9 或 COMMON", Length = 20, IsNullable = true)]
+    public string? ModuleCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "单位", Length = 50, IsNullable = true)]
+    public string? Unit { get; set; }
+
+    [SugarColumn(ColumnDescription = "聚合类型 COUNT/SUM/AVG/MAX/MIN/LATEST", Length = 20, IsNullable = true)]
+    public string? AggType { get; set; }
+
+    [SugarColumn(ColumnDescription = "预期数据来源", Length = 200, IsNullable = true)]
+    public string? DataSource { get; set; }
+
+    [SugarColumn(ColumnDescription = "说明", ColumnDataType = "text", IsNullable = true)]
+    public string? Description { get; set; }
+
+    [SugarColumn(ColumnDescription = "启用")]
+    public bool IsEnabled { get; set; } = true;
+
+    [SugarColumn(ColumnDescription = "排序号")]
+    public int SortNo { get; set; }
+
+    [SugarColumn(ColumnDescription = "租户 ID", ColumnDataType = "bigint")]
+    public long? TenantId { get; set; }
+
+    [SugarColumn(ColumnDescription = "创建时间")]
+    public DateTime CreatedAt { get; set; } = DateTime.Now;
+
+    [SugarColumn(ColumnDescription = "更新时间", IsNullable = true)]
+    public DateTime? UpdatedAt { get; set; }
+}

+ 9 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiMaster.cs

@@ -36,6 +36,15 @@ public class AdoSmartOpsKpiMaster : ITenantIdFilter
     [SugarColumn(ColumnDescription = "计算规则(含举例)", ColumnDataType = "text", IsNullable = true)]
     public string? CalcRule { get; set; }
 
+    [SugarColumn(ColumnDescription = "结构化表达式 @MetricCode / $FactCode 及算符常量", Length = 500, IsNullable = true)]
+    public string? FormulaExpr { get; set; }
+
+    [SugarColumn(ColumnDescription = "人读预览(引用替换为中文名)", Length = 1000, IsNullable = true)]
+    public string? FormulaPreview { get; set; }
+
+    [SugarColumn(ColumnDescription = "引用清单 JSON {metrics:[],facts:[]}", Length = 500, IsNullable = true)]
+    public string? FormulaRefs { get; set; }
+
     [SugarColumn(ColumnDescription = "数据来源模块", Length = 200, IsNullable = true)]
     public string? DataSource { get; set; }
 

+ 89 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/FormulaExpr/FormulaParser.cs

@@ -0,0 +1,89 @@
+using System.Text.RegularExpressions;
+
+namespace Admin.NET.Plugin.AiDOP.Infrastructure.FormulaExpr;
+
+/// <summary>
+/// 第三批公式结构化 — 最小 DSL v1 解析器。
+///
+/// 语法:
+///   FormulaExpr ::= Token (Operator Token)*
+///   Token       ::= MetricRef | FactRef | Number | '(' FormulaExpr ')'
+///   MetricRef   ::= '@' MetricCode
+///   FactRef     ::= '$' FactCode
+///   Operator    ::= '+' | '-' | '*' | '/'
+///   Number      ::= 数字(支持小数、百分号)
+///
+/// 仅做语法 / 引用抽取,不做执行求值。
+/// </summary>
+public static class FormulaParser
+{
+    // MetricCode 形如 S1_L2_003 / S4_L1_001,允许字母数字下划线
+    private static readonly Regex MetricRefRe = new(@"@([A-Za-z][A-Za-z0-9_]*)", RegexOptions.Compiled);
+    // FactCode 形如 F_S1_001 / F_COMMON_001
+    private static readonly Regex FactRefRe = new(@"\$([A-Za-z][A-Za-z0-9_]*)", RegexOptions.Compiled);
+
+    public sealed class ParseResult
+    {
+        public List<string> MetricRefs { get; } = new();
+        public List<string> FactRefs { get; } = new();
+        public List<string> Errors { get; } = new();
+        public bool IsEmpty { get; set; }
+    }
+
+    public static ParseResult Parse(string? expr)
+    {
+        var result = new ParseResult();
+        if (string.IsNullOrWhiteSpace(expr))
+        {
+            result.IsEmpty = true;
+            return result;
+        }
+
+        var text = expr.Trim();
+
+        // 1) 抽取 @ / $ 引用
+        foreach (Match m in MetricRefRe.Matches(text))
+            if (!result.MetricRefs.Contains(m.Groups[1].Value))
+                result.MetricRefs.Add(m.Groups[1].Value);
+        foreach (Match m in FactRefRe.Matches(text))
+            if (!result.FactRefs.Contains(m.Groups[1].Value))
+                result.FactRefs.Add(m.Groups[1].Value);
+
+        // 2) 括号配对
+        var stack = 0;
+        foreach (var ch in text)
+        {
+            if (ch == '(') stack++;
+            else if (ch == ')')
+            {
+                stack--;
+                if (stack < 0) { result.Errors.Add("括号不匹配:右括号多余"); break; }
+            }
+        }
+        if (stack > 0) result.Errors.Add("括号不匹配:左括号多余");
+
+        // 3) 语法白名单:去掉 @xxx / $xxx 及合法符号后,不应剩下其它字符
+        var residue = MetricRefRe.Replace(text, "");
+        residue = FactRefRe.Replace(residue, "");
+        // 允许:数字、小数点、百分号、算符 + - * / ( ) 、空白、中文"天"(兼容 (D2/D1)*30天)
+        // 纯 ASCII 校验:保留 + 剔除后若出现其它字符标记为警告(不作为硬错)
+        var allowedRe = new Regex(@"^[\s\d\.\+\-\*/\(\)%]*$", RegexOptions.Compiled);
+        if (!allowedRe.IsMatch(residue))
+        {
+            // 找出不合法字符样本(最多 20 字)
+            var bad = new string(residue.Where(c => !char.IsWhiteSpace(c)
+                && !char.IsDigit(c) && "+-*/().%".IndexOf(c) < 0).Distinct().Take(20).ToArray());
+            if (bad.Length > 0)
+                result.Errors.Add($"公式包含未识别字符:'{bad}'。仅支持 @指标 $事实 数字及 + - * / ( ) % 算符");
+        }
+
+        // 4) 至少含一个 token
+        if (result.MetricRefs.Count == 0 && result.FactRefs.Count == 0
+            && !Regex.IsMatch(text, @"\d"))
+        {
+            result.Errors.Add("公式至少应包含一个引用(@指标 / $事实)或数字");
+        }
+
+        return result;
+    }
+}

+ 56 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/FormulaExpr/FormulaPreviewBuilder.cs

@@ -0,0 +1,56 @@
+using System.Text.RegularExpressions;
+using Admin.NET.Plugin.AiDOP.Entity;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Infrastructure.FormulaExpr;
+
+/// <summary>
+/// 把结构化公式替换为人读预览:
+///   $F_S1_001 → 统计期订单总数
+///   @S1_L1_001 → 订单评审周期(天)
+///   / → ÷, * → ×
+/// </summary>
+public static class FormulaPreviewBuilder
+{
+    private static readonly Regex RefRe = new(@"([@$])([A-Za-z][A-Za-z0-9_]*)", RegexOptions.Compiled);
+
+    public static async Task<string> BuildAsync(
+        ISqlSugarClient db,
+        long tenantId,
+        string? expr,
+        FormulaParser.ParseResult? parsed = null)
+    {
+        if (string.IsNullOrWhiteSpace(expr)) return string.Empty;
+        parsed ??= FormulaParser.Parse(expr);
+
+        var metricMap = parsed.MetricRefs.Count == 0
+            ? new Dictionary<string, string>()
+            : (await db.Queryable<AdoSmartOpsKpiMaster>()
+                .Where(x => x.TenantId == tenantId && parsed.MetricRefs.Contains(x.MetricCode))
+                .Select(x => new { x.MetricCode, x.MetricName })
+                .ToListAsync())
+                .ToDictionary(x => x.MetricCode, x => x.MetricName, StringComparer.OrdinalIgnoreCase);
+
+        var factMap = parsed.FactRefs.Count == 0
+            ? new Dictionary<string, string>()
+            : (await db.Queryable<AdoSmartOpsBusinessFact>()
+                .Where(x => x.TenantId == tenantId && parsed.FactRefs.Contains(x.FactCode))
+                .Select(x => new { x.FactCode, x.FactName })
+                .ToListAsync())
+                .ToDictionary(x => x.FactCode, x => x.FactName, StringComparer.OrdinalIgnoreCase);
+
+        var preview = RefRe.Replace(expr, m =>
+        {
+            var sym = m.Groups[1].Value;
+            var code = m.Groups[2].Value;
+            if (sym == "@" && metricMap.TryGetValue(code, out var mn)) return mn ?? $"@{code}";
+            if (sym == "$" && factMap.TryGetValue(code, out var fn)) return fn ?? $"${code}";
+            return m.Value;
+        });
+
+        preview = preview.Replace('/', '÷').Replace('*', '×');
+        // 连续空白压缩
+        preview = Regex.Replace(preview, @"\s+", " ").Trim();
+        return preview;
+    }
+}

+ 74 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/FormulaExpr/FormulaValidator.cs

@@ -0,0 +1,74 @@
+using Admin.NET.Plugin.AiDOP.Entity;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Infrastructure.FormulaExpr;
+
+/// <summary>
+/// 基于 ParseResult 做引用有效性校验。
+/// Q4-C 策略:
+///   - 当 KpiMaster.IsEnabled=true → Errors(硬校验,阻止保存)
+///   - 当 IsEnabled=false(草稿/禁用) → Warnings(软警告,允许保存)
+/// </summary>
+public static class FormulaValidator
+{
+    public sealed class ValidationResult
+    {
+        public List<string> Errors { get; } = new();
+        public List<string> Warnings { get; } = new();
+        public bool HasErrors => Errors.Count > 0;
+    }
+
+    public static async Task<ValidationResult> ValidateAsync(
+        ISqlSugarClient db,
+        long tenantId,
+        FormulaParser.ParseResult parsed,
+        bool isEnabled,
+        long? excludeKpiId = null)
+    {
+        var result = new ValidationResult();
+
+        // 1) Parser 阶段错误
+        foreach (var e in parsed.Errors)
+        {
+            if (isEnabled) result.Errors.Add(e);
+            else result.Warnings.Add(e);
+        }
+
+        if (parsed.IsEmpty) return result;
+
+        // 2) MetricRefs 有效性:必须存在于本租户的 KpiMaster
+        if (parsed.MetricRefs.Count > 0)
+        {
+            var existMetrics = await db.Queryable<AdoSmartOpsKpiMaster>()
+                .Where(x => x.TenantId == tenantId && parsed.MetricRefs.Contains(x.MetricCode))
+                .WhereIF(excludeKpiId.HasValue, x => x.Id != excludeKpiId!.Value)
+                .Select(x => x.MetricCode)
+                .ToListAsync();
+            var missing = parsed.MetricRefs.Except(existMetrics, StringComparer.OrdinalIgnoreCase).ToList();
+            foreach (var m in missing)
+            {
+                var msg = $"引用的指标 @{m} 不存在于本租户 KpiMaster";
+                if (isEnabled) result.Errors.Add(msg);
+                else result.Warnings.Add(msg);
+            }
+        }
+
+        // 3) FactRefs 有效性:必须存在于本租户的 BusinessFact
+        if (parsed.FactRefs.Count > 0)
+        {
+            var existFacts = await db.Queryable<AdoSmartOpsBusinessFact>()
+                .Where(x => x.TenantId == tenantId && parsed.FactRefs.Contains(x.FactCode))
+                .Select(x => x.FactCode)
+                .ToListAsync();
+            var missing = parsed.FactRefs.Except(existFacts, StringComparer.OrdinalIgnoreCase).ToList();
+            foreach (var f in missing)
+            {
+                var msg = $"引用的事实 ${f} 不存在于业务事实字典";
+                if (isEnabled) result.Errors.Add(msg);
+                else result.Warnings.Add(msg);
+            }
+        }
+
+        return result;
+    }
+}

+ 1 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs

@@ -163,6 +163,7 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             (1320990000301L, "/aidop/smart-ops/grid", "aidopSmartOpsGrid", "九宫格智慧运营看板", "/dashboard/home", 100),
             (1320990000311L, "/aidop/smart-ops/modeling", "aidopSmartOpsModeling", "运营指标建模", "/aidop/kanban/s0", 105),
             (1320990000312L, "/aidop/smart-ops/kpi-master", "aidopSmartOpsKpiMaster", "运营指标主数据", "/aidop/kanban/kpiMaster", 106),
+            (1320990000313L, "/aidop/smart-ops/business-fact", "aidopSmartOpsBusinessFact", "业务事实字典", "/aidop/kanban/businessFact", 107),
             (1320990000302L, "/aidop/smart-ops/s1", "aidopSmartOpsS1", "S1产销协同看板", "/aidop/kanban/s1", 110),
             (1320990000303L, "/aidop/smart-ops/s2", "aidopSmartOpsS2", "S2制造协同看板", "/aidop/kanban/s2", 120),
             (1320990000304L, "/aidop/smart-ops/s3", "aidopSmartOpsS3", "S3供应协同看板", "/aidop/kanban/s3", 130),