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

feat(kpi): add S5-S7 KPI configuration governance layer

- add ado_smart_ops_kpi_business_input (KPI-level business source + prep status,
  UNIQUE(TenantId,MetricCode); missing row -> default PENDING_BUSINESS_FIELDS)
- server-validated READY_FOR_CONFIG transition (requires source system/tables/fields/
  sql-source + an existing CONFIG_SQL config with SqlText & datasource)
- gate preview/publish/activate for CONFIG_SQL until business input READY
- add endpoints: GET/POST business-input, GET run-log (paginated, desensitized)
- frontend: KpiBusinessInput + KpiRunLog components + button gating in kpiMaster calc tab
- seed READY for the 9 already-configured KPIs (1.0.281.sql, idempotent INSERT...SELECT,
  inherits TenantId/MetricCode/ModuleCode, no hardcoded tenant)
- does not touch SmartOpsKpiAtomicBuildService; B-pipeline conflict logged as tech debt
- bump version Web 2.4.264 / server 1.0.281
YY968XX 1 неделя назад
Родитель
Сommit
25a0a41eea

+ 1 - 1
Web/package.json

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

+ 15 - 0
Web/src/views/aidop/api/kpiSqlConfigApi.ts

@@ -51,3 +51,18 @@ export function activateConfig(id: number) {
 export function retireConfig(id: number) {
 	return service({ url: `${BASE}/${id}/retire`, method: 'post' });
 }
+
+/** 业务来源与准备状态(无记录返回默认 PENDING_BUSINESS_FIELDS,IsPersisted=false) */
+export function getBusinessInput(metricCode: string) {
+	return service({ url: `${BASE}/business-input/${encodeURIComponent(metricCode)}`, method: 'get' });
+}
+
+/** 保存业务来源登记(upsert;置 READY 时后端强校验缺项) */
+export function saveBusinessInput(data: any) {
+	return service({ url: `${BASE}/business-input`, method: 'post', data });
+}
+
+/** 运行日志(分页、脱敏) */
+export function getRunLog(metricCode: string, params: { page?: number; pageSize?: number; status?: string; startTime?: string; endTime?: string }) {
+	return service({ url: `${BASE}/run-log/${encodeURIComponent(metricCode)}`, method: 'get', params });
+}

+ 188 - 0
Web/src/views/aidop/kanban/components/KpiBusinessInput.vue

@@ -0,0 +1,188 @@
+<template>
+	<div class="kpi-biz-input" v-loading="loading">
+		<div class="kpi-biz-input__head">
+			<span class="kpi-biz-input__title">业务来源登记</span>
+			<el-tag :type="statusMeta.type" size="small">{{ statusMeta.label }}</el-tag>
+			<span v-if="!model.isPersisted" class="kpi-biz-input__default-hint">(默认视图,尚未登记)</span>
+		</div>
+
+		<el-form :model="model" label-width="96px" label-position="right" size="small" class="kpi-biz-input__form">
+			<el-row :gutter="16">
+				<el-col :span="12">
+					<el-form-item label="准备状态">
+						<el-select v-model="model.businessInputStatus" style="width: 100%">
+							<el-option label="待业务字段(PENDING_BUSINESS_FIELDS)" value="PENDING_BUSINESS_FIELDS" />
+							<el-option label="待业务 SQL(PENDING_BUSINESS_SQL)" value="PENDING_BUSINESS_SQL" />
+							<el-option label="就绪可配置(READY_FOR_CONFIG)" value="READY_FOR_CONFIG" />
+						</el-select>
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="来源系统">
+						<el-input v-model="model.sourceSystem" placeholder="如 T8 / DOP-aidopdev / API" />
+					</el-form-item>
+				</el-col>
+			</el-row>
+			<el-form-item label="来源表">
+				<el-input v-model="model.sourceTables" placeholder="逗号分隔,如 mdp_std_xxx, dwd_yyy" />
+			</el-form-item>
+			<el-form-item label="来源字段">
+				<el-input v-model="model.sourceFields" type="textarea" :rows="2" placeholder="参与计算的来源字段" />
+			</el-form-item>
+			<el-row :gutter="16">
+				<el-col :span="12">
+					<el-form-item label="SQL 来源">
+						<el-input v-model="model.businessSqlSource" placeholder="如 方老师提供 / DOP 内部自决" />
+					</el-form-item>
+				</el-col>
+				<el-col :span="12">
+					<el-form-item label="业务负责人">
+						<el-input v-model="model.businessOwner" placeholder="SQL 提供人" />
+					</el-form-item>
+				</el-col>
+			</el-row>
+			<el-row :gutter="16">
+				<el-col :span="12">
+					<el-form-item label="字段负责人">
+						<el-input v-model="model.fieldOwner" placeholder="字段提供人" />
+					</el-form-item>
+				</el-col>
+			</el-row>
+			<el-form-item label="口径说明">
+				<el-input v-model="model.businessSqlRemark" type="textarea" :rows="2" placeholder="SQL 来源 / 业务口径说明" />
+			</el-form-item>
+			<el-form-item label="备注">
+				<el-input v-model="model.remark" type="textarea" :rows="2" />
+			</el-form-item>
+			<el-form-item>
+				<el-button type="primary" size="small" :loading="saving" @click="save">保存业务来源</el-button>
+				<span class="kpi-biz-input__ready-hint">置「就绪可配置」需:来源系统 / 来源表 / 来源字段 / SQL 来源 齐全,且已有含 SQL 与数据源的 CONFIG_SQL 配置。</span>
+			</el-form-item>
+		</el-form>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { reactive, ref, computed, watch } from 'vue';
+import { ElMessage } from 'element-plus';
+import * as cfgApi from '/@/views/aidop/api/kpiSqlConfigApi';
+
+const props = defineProps<{ metricCode: string; moduleCode: string }>();
+const emit = defineEmits<{ (e: 'status-change', status: string): void }>();
+
+const loading = ref(false);
+const saving = ref(false);
+
+const model = reactive({
+	isPersisted: false,
+	businessInputStatus: 'PENDING_BUSINESS_FIELDS',
+	sourceSystem: '',
+	sourceTables: '',
+	sourceFields: '',
+	businessSqlSource: '',
+	businessSqlRemark: '',
+	businessOwner: '',
+	fieldOwner: '',
+	remark: '',
+});
+
+const STATUS_META: Record<string, { label: string; type: 'info' | 'warning' | 'success' }> = {
+	PENDING_BUSINESS_FIELDS: { label: '待业务字段', type: 'info' },
+	PENDING_BUSINESS_SQL: { label: '待业务 SQL', type: 'warning' },
+	READY_FOR_CONFIG: { label: '就绪可配置', type: 'success' },
+};
+const statusMeta = computed(() => STATUS_META[model.businessInputStatus] || STATUS_META.PENDING_BUSINESS_FIELDS);
+
+function unwrap(res: any) {
+	return (res as any)?.data ?? res;
+}
+
+function apply(d: any) {
+	model.isPersisted = !!d?.isPersisted;
+	model.businessInputStatus = d?.businessInputStatus || 'PENDING_BUSINESS_FIELDS';
+	model.sourceSystem = d?.sourceSystem || '';
+	model.sourceTables = d?.sourceTables || '';
+	model.sourceFields = d?.sourceFields || '';
+	model.businessSqlSource = d?.businessSqlSource || '';
+	model.businessSqlRemark = d?.businessSqlRemark || '';
+	model.businessOwner = d?.businessOwner || '';
+	model.fieldOwner = d?.fieldOwner || '';
+	model.remark = d?.remark || '';
+	// 权威状态以「已保存」为准:默认视图(未登记)对上层门禁按 PENDING 处理。
+	emit('status-change', model.isPersisted ? model.businessInputStatus : 'PENDING_BUSINESS_FIELDS');
+}
+
+async function load() {
+	if (!props.metricCode) return;
+	loading.value = true;
+	try {
+		apply(unwrap(await cfgApi.getBusinessInput(props.metricCode)));
+	} catch (e: any) {
+		ElMessage.error('加载业务来源失败: ' + (e.response?.data?.message || e.message || e));
+	} finally {
+		loading.value = false;
+	}
+}
+
+async function save() {
+	if (!props.metricCode) return;
+	saving.value = true;
+	try {
+		const payload = {
+			metricCode: props.metricCode,
+			moduleCode: props.moduleCode,
+			businessInputStatus: model.businessInputStatus,
+			sourceSystem: model.sourceSystem,
+			sourceTables: model.sourceTables,
+			sourceFields: model.sourceFields,
+			businessSqlSource: model.businessSqlSource,
+			businessSqlRemark: model.businessSqlRemark,
+			businessOwner: model.businessOwner,
+			fieldOwner: model.fieldOwner,
+			remark: model.remark,
+		};
+		apply(unwrap(await cfgApi.saveBusinessInput(payload)));
+		ElMessage.success('业务来源已保存');
+	} catch (e: any) {
+		ElMessage.error('保存失败: ' + (e.response?.data?.message || e.message || e));
+	} finally {
+		saving.value = false;
+	}
+}
+
+watch(() => props.metricCode, load, { immediate: true });
+defineExpose({ reload: load });
+</script>
+
+<style scoped lang="scss">
+.kpi-biz-input {
+	border: 1px solid #ebeef5;
+	border-radius: 6px;
+	padding: 12px 16px;
+	margin-bottom: 14px;
+	background: #fafcff;
+	&__head {
+		display: flex;
+		align-items: center;
+		gap: 8px;
+		margin-bottom: 12px;
+	}
+	&__title {
+		font-weight: 600;
+		font-size: 14px;
+		color: #303133;
+	}
+	&__default-hint {
+		font-size: 12px;
+		color: #909399;
+	}
+	&__form {
+		max-width: 860px;
+	}
+	&__ready-hint {
+		margin-left: 12px;
+		font-size: 12px;
+		color: #909399;
+	}
+}
+</style>

+ 136 - 0
Web/src/views/aidop/kanban/components/KpiRunLog.vue

@@ -0,0 +1,136 @@
+<template>
+	<div class="kpi-run-log">
+		<div class="kpi-run-log__head">
+			<span class="kpi-run-log__title">运行历史</span>
+			<el-select v-model="statusFilter" placeholder="全部状态" clearable size="small" style="width: 130px" @change="reload">
+				<el-option label="SUCCESS" value="SUCCESS" />
+				<el-option label="NO_DATA" value="NO_DATA" />
+				<el-option label="FAILED" value="FAILED" />
+			</el-select>
+			<el-button size="small" @click="reload">刷新</el-button>
+		</div>
+
+		<el-table :data="rows" size="small" border v-loading="loading" empty-text="暂无运行记录">
+			<el-table-column prop="startedAt" label="运行时间" width="150" />
+			<el-table-column label="状态" width="96">
+				<template #default="{ row }">
+					<el-tag :type="statusType(row.status)" size="small">{{ row.status }}</el-tag>
+				</template>
+			</el-table-column>
+			<el-table-column prop="engineType" label="引擎" width="110" />
+			<el-table-column prop="versionNo" label="版本" width="64" />
+			<el-table-column prop="dataSourceCode" label="数据源" width="110" />
+			<el-table-column prop="bizDate" label="业务日期" width="104" />
+			<el-table-column prop="durationMs" label="耗时(ms)" width="88" />
+			<el-table-column prop="rowCount" label="行数" width="64" />
+			<el-table-column prop="batchId" label="批次" min-width="150" show-overflow-tooltip />
+			<el-table-column label="错误摘要" min-width="180">
+				<template #default="{ row }">
+					<span v-if="row.errorCode || row.errorMessage" class="kpi-run-log__err">{{ row.errorCode }} {{ row.errorMessage }}</span>
+					<span v-else>—</span>
+				</template>
+			</el-table-column>
+		</el-table>
+
+		<div class="kpi-run-log__pager">
+			<el-pagination
+				v-model:current-page="page"
+				v-model:page-size="pageSize"
+				:total="total"
+				:page-sizes="[20, 50, 100]"
+				layout="total, sizes, prev, pager, next"
+				size="small"
+				background
+				@current-change="load"
+				@size-change="reload"
+			/>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch } from 'vue';
+import { ElMessage } from 'element-plus';
+import * as cfgApi from '/@/views/aidop/api/kpiSqlConfigApi';
+
+const props = defineProps<{ metricCode: string }>();
+
+const loading = ref(false);
+const rows = ref<any[]>([]);
+const total = ref(0);
+const page = ref(1);
+const pageSize = ref(20);
+const statusFilter = ref('');
+
+function unwrap(res: any) {
+	return (res as any)?.data ?? res;
+}
+
+function statusType(s: string): 'success' | 'info' | 'danger' {
+	if (s === 'SUCCESS') return 'success';
+	if (s === 'NO_DATA') return 'info';
+	return 'danger';
+}
+
+async function load() {
+	if (!props.metricCode) return;
+	loading.value = true;
+	try {
+		const res = unwrap(
+			await cfgApi.getRunLog(props.metricCode, {
+				page: page.value,
+				pageSize: pageSize.value,
+				status: statusFilter.value || undefined,
+			})
+		);
+		rows.value = res?.list || [];
+		total.value = res?.total || 0;
+	} catch (e: any) {
+		// 无日志不是接口错误;仅真实请求异常才提示。
+		ElMessage.error('加载运行日志失败: ' + (e.response?.data?.message || e.message || e));
+		rows.value = [];
+		total.value = 0;
+	} finally {
+		loading.value = false;
+	}
+}
+
+function reload() {
+	page.value = 1;
+	load();
+}
+
+watch(() => props.metricCode, reload, { immediate: true });
+defineExpose({ reload });
+</script>
+
+<style scoped lang="scss">
+.kpi-run-log {
+	border: 1px solid #ebeef5;
+	border-radius: 6px;
+	padding: 12px 16px;
+	margin-top: 14px;
+	background: #fff;
+	&__head {
+		display: flex;
+		align-items: center;
+		gap: 10px;
+		margin-bottom: 10px;
+	}
+	&__title {
+		font-weight: 600;
+		font-size: 14px;
+		color: #303133;
+		margin-right: auto;
+	}
+	&__err {
+		color: #f56c6c;
+		font-size: 12px;
+	}
+	&__pager {
+		margin-top: 10px;
+		display: flex;
+		justify-content: flex-end;
+	}
+}
+</style>

+ 33 - 3
Web/src/views/aidop/kanban/kpiMaster.vue

@@ -287,6 +287,13 @@
 						<span class="calc-engine-tag">当前生效引擎:{{ currentEngine }}</span>
 					</div>
 
+					<KpiBusinessInput
+						ref="bizInputRef"
+						:metric-code="currentDetail.metricCode"
+						:module-code="currentDetail.moduleCode"
+						@status-change="onBizStatusChange"
+					/>
+
 					<el-table :data="calcVersions" size="small" border style="margin-bottom: 12px">
 						<el-table-column prop="versionNo" label="版本" width="64" />
 						<el-table-column prop="calcEngineType" label="引擎" width="120" />
@@ -299,8 +306,8 @@
 						<el-table-column label="操作" min-width="240">
 							<template #default="{ row }">
 								<el-button link size="small" @click="editDraft(row)">载入</el-button>
-								<el-button v-if="row.publishStatus === 'DRAFT'" link type="primary" size="small" @click="doPublish(row.id)">发布</el-button>
-								<el-button v-if="row.publishStatus === 'PUBLISHED' && !row.isCurrent" link type="warning" size="small" @click="doActivate(row.id)">激活</el-button>
+								<el-button v-if="row.publishStatus === 'DRAFT'" link type="primary" size="small" :disabled="isRowGated(row)" :title="isRowGated(row) ? gateTip : ''" @click="doPublish(row.id)">发布</el-button>
+								<el-button v-if="row.publishStatus === 'PUBLISHED' && !row.isCurrent" link type="warning" size="small" :disabled="isRowGated(row)" :title="isRowGated(row) ? gateTip : ''" @click="doActivate(row.id)">激活</el-button>
 								<el-button v-if="row.publishStatus !== 'RETIRED'" link type="danger" size="small" @click="doRetire(row.id)">停用</el-button>
 							</template>
 						</el-table-column>
@@ -332,7 +339,11 @@
 						<SqlEditor ref="sqlEditorRef" v-model="calcDraft.sqlScript" height="240px" />
 						<div class="calc-actions">
 							<el-button size="small" @click="doValidate">校验</el-button>
-							<el-button size="small" :loading="calcBusy" @click="doPreview">试算</el-button>
+							<el-tooltip :disabled="isBizReady" :content="gateTip" placement="top">
+								<span>
+									<el-button size="small" :loading="calcBusy" :disabled="!isBizReady" @click="doPreview">试算</el-button>
+								</span>
+							</el-tooltip>
 							<el-button type="primary" size="small" :loading="calcBusy" @click="saveDraft">保存草稿</el-button>
 						</div>
 						<div v-if="calcValidation" class="calc-line" :class="calcValidation.ok ? 'ok' : 'err'">
@@ -349,6 +360,8 @@
 						LEGACY_CODE:使用该 KPI 的内置代码路径(各模块 BuildSxL1xxx)计算。保存草稿并发布/激活后,看板刷新走 legacy。
 						<div class="calc-actions"><el-button type="primary" size="small" @click="saveDraft">保存草稿</el-button></div>
 					</div>
+
+					<KpiRunLog ref="runLogRef" :metric-code="currentDetail.metricCode" />
 				</div>
 			</el-tab-pane>
 		</el-tabs>
@@ -365,6 +378,8 @@ import * as api from '/@/views/aidop/api/kpiMasterApi';
 import type { KpiTreeNode, KpiDetail } from '/@/views/aidop/api/kpiMasterApi';
 import FormulaEditor from './components/FormulaEditor.vue';
 import SqlEditor from './components/SqlEditor.vue';
+import KpiBusinessInput from './components/KpiBusinessInput.vue';
+import KpiRunLog from './components/KpiRunLog.vue';
 import * as cfgApi from '/@/views/aidop/api/kpiSqlConfigApi';
 
 const router = useRouter();
@@ -529,6 +544,21 @@ const calcDraft = reactive({
 	remark: '',
 });
 const currentEngine = computed(() => calcVersions.value.find((v) => v.isCurrent)?.calcEngineType ?? 'LEGACY_CODE(无配置,默认)');
+
+// ── 业务来源准备状态门禁(与后端一致:仅 READY_FOR_CONFIG 放行 CONFIG_SQL 的 试算/发布/激活)──
+const bizInputRef = ref<any>(null);
+const runLogRef = ref<any>(null);
+const bizStatus = ref('PENDING_BUSINESS_FIELDS');
+const isBizReady = computed(() => bizStatus.value === 'READY_FOR_CONFIG');
+const gateTip = computed(
+	() => `业务来源未就绪(当前 ${bizStatus.value}):需先在「业务来源登记」补齐来源系统/表/字段/SQL来源并置为 READY_FOR_CONFIG`
+);
+function onBizStatusChange(s: string) {
+	bizStatus.value = s || 'PENDING_BUSINESS_FIELDS';
+}
+function isRowGated(row: any): boolean {
+	return row?.calcEngineType === 'CONFIG_SQL' && !isBizReady.value;
+}
 // 通用骨架(非某一 KPI 专用):新建 CONFIG_SQL 草稿的初始占位,管理员按目标 KPI 改表名/列名。
 const NEW_SQL_TEMPLATE = `-- 只读 SELECT,返回 0/1 行;必须含列 metric_value(可选 numerator_value / denominator_value / result_status / result_message)
 -- 可用参数:@tenant_id @factory_id @biz_date @ztid @period_start @period_end @module_code @metric_code

+ 6 - 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.280</AssemblyVersion>
-    <FileVersion>1.0.280</FileVersion>
-    <Version>1.0.280</Version>
+    <AssemblyVersion>1.0.281</AssemblyVersion>
+    <FileVersion>1.0.281</FileVersion>
+    <Version>1.0.281</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -256,6 +256,9 @@
     <None Update="UpdateScripts\1.0.280.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.281.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

+ 45 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.281.sql

@@ -0,0 +1,45 @@
+-- 1.0.281(AIDOP-S567-KPI-CONFIGURATION-PLATFORM-1,治理层)。
+-- 提交那刻 rename 为正式 1.0.<next>.sql、注册 csproj Copy 条目(见 §二 版本取号)。
+-- 表结构由 CodeFirst(EnableInitTable=true)从实体 AdoSmartOpsKpiBusinessInput 创建;EnableUnderLine=false → 列名 PascalCase。
+-- 下方 CREATE TABLE IF NOT EXISTS 为兜底(CodeFirst 已建则跳过),列名/类型对齐实体。
+-- 种子:不给 49 个 PENDING KPI 落库(缺行 = PENDING_BUSINESS_FIELDS 由后端默认返回);
+--       仅把当前 IsCurrent=1 的 9 个已配置 KPI(7 CONFIG_SQL + S5_L1_001 LEGACY_CODE + S5_L1_004 LEGACY_TVF)
+--       INSERT...SELECT 继承 TenantId/MetricCode/ModuleCode 落 READY_FOR_CONFIG,避免迁移后阻断当前功能。
+--       ModuleCode 直接取自 calc_config(已含 S5,S5_L1_004 无需 Master 行即可覆盖);不手写固定 TenantId;NOT EXISTS 幂等、不覆盖人工记录。
+
+CREATE TABLE IF NOT EXISTS ado_smart_ops_kpi_business_input (
+  `Id` BIGINT NOT NULL AUTO_INCREMENT,
+  `TenantId` BIGINT DEFAULT NULL,
+  `MetricCode` VARCHAR(50) NOT NULL,
+  `ModuleCode` VARCHAR(20) NOT NULL,
+  `BusinessInputStatus` VARCHAR(30) NOT NULL DEFAULT 'PENDING_BUSINESS_FIELDS',
+  `SourceSystem` VARCHAR(200) DEFAULT NULL,
+  `SourceTables` VARCHAR(500) DEFAULT NULL,
+  `SourceFields` TEXT DEFAULT NULL,
+  `BusinessSqlSource` VARCHAR(200) DEFAULT NULL,
+  `BusinessSqlRemark` TEXT DEFAULT NULL,
+  `BusinessOwner` VARCHAR(100) DEFAULT NULL,
+  `FieldOwner` VARCHAR(100) DEFAULT NULL,
+  `Remark` TEXT DEFAULT NULL,
+  `CreatedBy` VARCHAR(100) DEFAULT NULL,
+  `CreatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `UpdatedBy` VARCHAR(100) DEFAULT NULL,
+  `UpdatedAt` DATETIME DEFAULT NULL,
+  PRIMARY KEY (`Id`),
+  UNIQUE KEY `uk_kpi_biz_input_tenant_metric` (`TenantId`, `MetricCode`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='KPI 业务来源与准备状态';
+
+-- 9 个已配置 KPI 种 READY(继承 calc_config 的 TenantId/MetricCode/ModuleCode,不硬写租户;逐 KPI NOT EXISTS 幂等)。
+INSERT INTO ado_smart_ops_kpi_business_input
+  (`TenantId`, `MetricCode`, `ModuleCode`, `BusinessInputStatus`, `Remark`, `CreatedBy`, `CreatedAt`)
+SELECT c.`TenantId`, c.`MetricCode`, c.`ModuleCode`, 'READY_FOR_CONFIG',
+       '迁移基线:已有生效计算配置,业务来源就绪', 'system', NOW()
+FROM ado_smart_ops_kpi_calc_config c
+WHERE c.`IsCurrent` = 1
+  AND c.`MetricCode` IN ('S5_L1_001','S5_L1_002','S5_L1_003','S5_L1_004',
+                         'S6_L2_002','S6_L2_003',
+                         'S7_L1_001','S7_L1_002','S7_L1_003')
+  AND NOT EXISTS (
+    SELECT 1 FROM ado_smart_ops_kpi_business_input b
+    WHERE b.`TenantId` = c.`TenantId` AND b.`MetricCode` = c.`MetricCode`
+  );

+ 52 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AdoSmartOpsKpiCalcConfigController.cs

@@ -16,10 +16,17 @@ namespace Admin.NET.Plugin.AiDOP.Controllers;
 public class AdoSmartOpsKpiCalcConfigController : ControllerBase
 {
     private readonly AdoSmartOpsKpiCalcConfigService _service;
+    private readonly AdoSmartOpsKpiBusinessInputService _businessInput;
+    private readonly AdoSmartOpsKpiRunLogQueryService _runLog;
 
-    public AdoSmartOpsKpiCalcConfigController(AdoSmartOpsKpiCalcConfigService service)
+    public AdoSmartOpsKpiCalcConfigController(
+        AdoSmartOpsKpiCalcConfigService service,
+        AdoSmartOpsKpiBusinessInputService businessInput,
+        AdoSmartOpsKpiRunLogQueryService runLog)
     {
         _service = service;
+        _businessInput = businessInput;
+        _runLog = runLog;
     }
 
     private string Operator() =>
@@ -102,4 +109,48 @@ public class AdoSmartOpsKpiCalcConfigController : ControllerBase
         await _service.RetireAsync(id, Operator());
         return Ok(new { ok = true });
     }
+
+    /// <summary>某 KPI 的业务来源与准备状态;无记录返回默认 PENDING_BUSINESS_FIELDS(IsPersisted=false)。</summary>
+    [HttpGet("business-input/{metricCode}")]
+    public async Task<IActionResult> GetBusinessInput(string metricCode)
+    {
+        if (string.IsNullOrWhiteSpace(metricCode))
+            return BadRequest(new { message = "metricCode 必填" });
+        var dto = await _businessInput.GetAsync(metricCode);
+        return Ok(dto);
+    }
+
+    /// <summary>保存业务来源登记(upsert);置 READY_FOR_CONFIG 时服务端强校验缺项。</summary>
+    [HttpPost("business-input")]
+    public async Task<IActionResult> SaveBusinessInput([FromBody] KpiBusinessInputUpsertDto dto)
+    {
+        if (string.IsNullOrWhiteSpace(dto.MetricCode))
+            return BadRequest(new { message = "metricCode 必填" });
+        var saved = await _businessInput.UpsertAsync(dto, Operator());
+        return Ok(saved);
+    }
+
+    /// <summary>某 KPI 的运行日志(分页、脱敏;租户后端解析)。</summary>
+    [HttpGet("run-log/{metricCode}")]
+    public async Task<IActionResult> RunLog(
+        string metricCode,
+        [FromQuery] int page = 1,
+        [FromQuery] int pageSize = 20,
+        [FromQuery] string? status = null,
+        [FromQuery] DateTime? startTime = null,
+        [FromQuery] DateTime? endTime = null)
+    {
+        if (string.IsNullOrWhiteSpace(metricCode))
+            return BadRequest(new { message = "metricCode 必填" });
+        var res = await _runLog.QueryAsync(new KpiRunLogQueryDto
+        {
+            MetricCode = metricCode,
+            Page = page,
+            PageSize = pageSize,
+            Status = status,
+            StartTime = startTime,
+            EndTime = endTime,
+        });
+        return Ok(res);
+    }
 }

+ 82 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/SmartOps/AdoSmartOpsKpiBusinessInputDtos.cs

@@ -0,0 +1,82 @@
+namespace Admin.NET.Plugin.AiDOP.Dto.SmartOps;
+
+/// <summary>业务来源登记 —— 保存入参。ModuleCode / TenantId 一律后端解析,不接受前端指定。</summary>
+public sealed class KpiBusinessInputUpsertDto
+{
+    public string MetricCode { get; set; } = string.Empty;
+    /// <summary>仅用于容错展示;实际存储的 ModuleCode 由后端从 MetricCode 解析。</summary>
+    public string? ModuleCode { get; set; }
+    /// <summary>目标状态 PENDING_BUSINESS_FIELDS / PENDING_BUSINESS_SQL / READY_FOR_CONFIG。置 READY 时后端强校验。</summary>
+    public string BusinessInputStatus { get; set; } = "PENDING_BUSINESS_FIELDS";
+    public string? SourceSystem { get; set; }
+    public string? SourceTables { get; set; }
+    public string? SourceFields { get; set; }
+    public string? BusinessSqlSource { get; set; }
+    public string? BusinessSqlRemark { get; set; }
+    public string? BusinessOwner { get; set; }
+    public string? FieldOwner { get; set; }
+    public string? Remark { get; set; }
+}
+
+/// <summary>业务来源登记 —— 输出。查询不到时后端返回 IsPersisted=false 的默认 PENDING_BUSINESS_FIELDS 视图。</summary>
+public sealed class KpiBusinessInputDto
+{
+    /// <summary>false=默认视图(库中无记录);true=已保存记录。前端据此区分默认与已维护。</summary>
+    public bool IsPersisted { get; set; }
+    public long? TenantId { get; set; }
+    public string MetricCode { get; set; } = string.Empty;
+    public string ModuleCode { get; set; } = string.Empty;
+    public string BusinessInputStatus { get; set; } = "PENDING_BUSINESS_FIELDS";
+    public string? SourceSystem { get; set; }
+    public string? SourceTables { get; set; }
+    public string? SourceFields { get; set; }
+    public string? BusinessSqlSource { get; set; }
+    public string? BusinessSqlRemark { get; set; }
+    public string? BusinessOwner { get; set; }
+    public string? FieldOwner { get; set; }
+    public string? Remark { get; set; }
+    public string? UpdatedBy { get; set; }
+    public DateTime? UpdatedAt { get; set; }
+}
+
+/// <summary>运行日志查询入参。TenantId 不接受前端,由 MetricCode 解析。</summary>
+public sealed class KpiRunLogQueryDto
+{
+    public string MetricCode { get; set; } = string.Empty;
+    public string? ModuleCode { get; set; }
+    public int Page { get; set; } = 1;
+    public int PageSize { get; set; } = 20;
+    /// <summary>可选状态过滤 SUCCESS / NO_DATA / FAILED。</summary>
+    public string? Status { get; set; }
+    public DateTime? StartTime { get; set; }
+    public DateTime? EndTime { get; set; }
+}
+
+/// <summary>运行日志单条(脱敏:不含 SQL 明文 / 参数快照 / 连接串;错误摘要截断)。</summary>
+public sealed class KpiRunLogItemDto
+{
+    public long Id { get; set; }
+    public string BatchId { get; set; } = string.Empty;
+    public string MetricCode { get; set; } = string.Empty;
+    public string EngineType { get; set; } = string.Empty;
+    public int? VersionNo { get; set; }
+    public string? DataSourceCode { get; set; }
+    public string BizDate { get; set; } = string.Empty;
+    public string StartedAt { get; set; } = string.Empty;
+    public long DurationMs { get; set; }
+    public string Status { get; set; } = string.Empty;
+    public int RowCount { get; set; }
+    public decimal? MetricValue { get; set; }
+    public string? ErrorCode { get; set; }
+    public string? ErrorMessage { get; set; }
+    public string TriggerType { get; set; } = string.Empty;
+}
+
+/// <summary>运行日志分页结果。</summary>
+public sealed class KpiRunLogPageDto
+{
+    public int Total { get; set; }
+    public int Page { get; set; }
+    public int PageSize { get; set; }
+    public List<KpiRunLogItemDto> List { get; set; } = new();
+}

+ 66 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiBusinessInput.cs

@@ -0,0 +1,66 @@
+using Admin.NET.Core;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Entity;
+
+/// <summary>
+/// KPI 业务来源与准备状态(KPI 级,一 KPI 一行)。独立于 ado_smart_ops_kpi_calc_config(版本模型)——
+/// 业务来源/准备是"指标级"元信息,无配置版本的 PENDING KPI 也要能登记,故不塞进版本表。
+/// 唯一键 (TenantId, MetricCode):MetricCode 已含模块身份(Sx_Ly_nnn),ModuleCode 仅冗余展示、由后端解析生成。
+/// BusinessInputStatus 决定 CONFIG_SQL 的试算/发布/激活门禁:仅 READY_FOR_CONFIG 放行。
+/// </summary>
+[SugarTable("ado_smart_ops_kpi_business_input", "KPI 业务来源与准备状态")]
+[SugarIndex("uk_kpi_biz_input_tenant_metric", nameof(TenantId), OrderByType.Asc, nameof(MetricCode), OrderByType.Asc, true)]
+public class AdoSmartOpsKpiBusinessInput : ITenantIdFilter
+{
+    [SugarColumn(ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnDescription = "租户 ID(KPI 数据租户,后端解析)", ColumnDataType = "bigint", IsNullable = true)]
+    public long? TenantId { get; set; }
+
+    [SugarColumn(ColumnDescription = "指标编码 如 S5_L2_007", Length = 50)]
+    public string MetricCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "模块 S1~S9(由 MetricCode 解析,不信任前端)", Length = 20)]
+    public string ModuleCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "业务准备状态 PENDING_BUSINESS_FIELDS / PENDING_BUSINESS_SQL / READY_FOR_CONFIG", Length = 30)]
+    public string BusinessInputStatus { get; set; } = "PENDING_BUSINESS_FIELDS";
+
+    [SugarColumn(ColumnDescription = "来源系统 如 T8 / DOP-aidopdev / API", Length = 200, IsNullable = true)]
+    public string? SourceSystem { get; set; }
+
+    [SugarColumn(ColumnDescription = "来源表(逗号分隔)", Length = 500, IsNullable = true)]
+    public string? SourceTables { get; set; }
+
+    [SugarColumn(ColumnDescription = "来源字段", ColumnDataType = "text", IsNullable = true)]
+    public string? SourceFields { get; set; }
+
+    [SugarColumn(ColumnDescription = "SQL 来源 如 方老师提供 / DOP 内部自决", Length = 200, IsNullable = true)]
+    public string? BusinessSqlSource { get; set; }
+
+    [SugarColumn(ColumnDescription = "SQL 来源/业务口径说明", ColumnDataType = "text", IsNullable = true)]
+    public string? BusinessSqlRemark { get; set; }
+
+    [SugarColumn(ColumnDescription = "业务负责人(SQL 提供人)", Length = 100, IsNullable = true)]
+    public string? BusinessOwner { get; set; }
+
+    [SugarColumn(ColumnDescription = "字段负责人(字段提供人)", Length = 100, IsNullable = true)]
+    public string? FieldOwner { get; set; }
+
+    [SugarColumn(ColumnDescription = "备注", ColumnDataType = "text", IsNullable = true)]
+    public string? Remark { get; set; }
+
+    [SugarColumn(ColumnDescription = "创建人", Length = 100, IsNullable = true)]
+    public string? CreatedBy { get; set; }
+
+    [SugarColumn(ColumnDescription = "创建时间")]
+    public DateTime CreatedAt { get; set; }
+
+    [SugarColumn(ColumnDescription = "更新人", Length = 100, IsNullable = true)]
+    public string? UpdatedBy { get; set; }
+
+    [SugarColumn(ColumnDescription = "更新时间", IsNullable = true)]
+    public DateTime? UpdatedAt { get; set; }
+}

+ 162 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/AdoSmartOpsKpiBusinessInputService.cs

@@ -0,0 +1,162 @@
+using System.Text.RegularExpressions;
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Dto.SmartOps;
+using Admin.NET.Plugin.AiDOP.Entity;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>
+/// KPI 业务来源与准备状态服务(KPI 级)。缺行时返回 IsPersisted=false 的默认 PENDING_BUSINESS_FIELDS,
+/// 不隐式建行;置 READY_FOR_CONFIG 时服务端强校验(来源系统/表/字段/SQL来源 + 对应 CONFIG_SQL 草稿含 SqlText/数据源)。
+/// ModuleCode / TenantId 一律由 MetricCode 解析,不信任前端(避免错误 ModuleCode 造成重复口径)。
+/// </summary>
+public sealed class AdoSmartOpsKpiBusinessInputService : ITransient
+{
+    public const string StatusPendingFields = "PENDING_BUSINESS_FIELDS";
+    public const string StatusPendingSql = "PENDING_BUSINESS_SQL";
+    public const string StatusReady = "READY_FOR_CONFIG";
+    private const string EngineConfigSql = "CONFIG_SQL";
+    private static readonly string[] AllStatuses = { StatusPendingFields, StatusPendingSql, StatusReady };
+    private static readonly Regex ModuleRe = new(@"^S[1-9]$", RegexOptions.Compiled);
+
+    private readonly ISqlSugarClient _db;
+
+    public AdoSmartOpsKpiBusinessInputService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    /// <summary>从 MetricCode 解析模块(Sx_Ly_nnn → Sx);非法前缀回落 metricCode 首段大写。</summary>
+    public static string ResolveModuleCode(string metricCode)
+    {
+        var prefix = (metricCode ?? string.Empty).Split('_')[0].Trim().ToUpperInvariant();
+        return ModuleRe.IsMatch(prefix) ? prefix : prefix;
+    }
+
+    private ISugarQueryable<AdoSmartOpsKpiBusinessInput> Query() =>
+        _db.Queryable<AdoSmartOpsKpiBusinessInput>().ClearFilter<ITenantIdFilter>();
+
+    /// <summary>取业务来源登记;库中无记录时返回默认 PENDING_BUSINESS_FIELDS(IsPersisted=false),不建行。</summary>
+    public async Task<KpiBusinessInputDto> GetAsync(string metricCode)
+    {
+        var moduleCode = ResolveModuleCode(metricCode);
+        var tenantId = AdoSmartOpsKpiCalcConfigService.ResolveKpiTenantId(moduleCode);
+        var e = await Query().Where(x => x.MetricCode == metricCode && x.TenantId == tenantId).FirstAsync();
+        if (e == null)
+            return new KpiBusinessInputDto
+            {
+                IsPersisted = false,
+                TenantId = tenantId,
+                MetricCode = metricCode,
+                ModuleCode = moduleCode,
+                BusinessInputStatus = StatusPendingFields,
+            };
+        return ToDto(e);
+    }
+
+    /// <summary>门禁用:取业务准备状态;无记录返回 PENDING_BUSINESS_FIELDS。</summary>
+    public async Task<string> GetStatusAsync(long tenantId, string metricCode)
+    {
+        var e = await Query().Where(x => x.MetricCode == metricCode && x.TenantId == tenantId).FirstAsync();
+        return string.IsNullOrWhiteSpace(e?.BusinessInputStatus) ? StatusPendingFields : e!.BusinessInputStatus;
+    }
+
+    /// <summary>保存业务来源登记(upsert)。置 READY 前强校验;ModuleCode/TenantId 后端解析。</summary>
+    public async Task<KpiBusinessInputDto> UpsertAsync(KpiBusinessInputUpsertDto dto, string operatorName)
+    {
+        if (string.IsNullOrWhiteSpace(dto.MetricCode))
+            throw Oops.Bah("metricCode 必填");
+        var status = (dto.BusinessInputStatus ?? string.Empty).Trim().ToUpperInvariant();
+        if (!AllStatuses.Contains(status))
+            throw Oops.Bah($"非法业务准备状态:{dto.BusinessInputStatus}(PENDING_BUSINESS_FIELDS/PENDING_BUSINESS_SQL/READY_FOR_CONFIG)");
+
+        var moduleCode = ResolveModuleCode(dto.MetricCode);
+        var tenantId = AdoSmartOpsKpiCalcConfigService.ResolveKpiTenantId(moduleCode);
+
+        if (status == StatusReady)
+            await EnsureReadyRequirementsAsync(tenantId, dto);
+
+        var existing = await Query().Where(x => x.MetricCode == dto.MetricCode && x.TenantId == tenantId).FirstAsync();
+        var now = DateTime.Now;
+        if (existing == null)
+        {
+            var entity = new AdoSmartOpsKpiBusinessInput
+            {
+                TenantId = tenantId,
+                MetricCode = dto.MetricCode,
+                ModuleCode = moduleCode,
+                BusinessInputStatus = status,
+                SourceSystem = Trim(dto.SourceSystem),
+                SourceTables = Trim(dto.SourceTables),
+                SourceFields = Trim(dto.SourceFields),
+                BusinessSqlSource = Trim(dto.BusinessSqlSource),
+                BusinessSqlRemark = Trim(dto.BusinessSqlRemark),
+                BusinessOwner = Trim(dto.BusinessOwner),
+                FieldOwner = Trim(dto.FieldOwner),
+                Remark = Trim(dto.Remark),
+                CreatedBy = operatorName,
+                CreatedAt = now,
+            };
+            entity.Id = await _db.Insertable(entity).ExecuteReturnBigIdentityAsync();
+            return ToDto(entity);
+        }
+
+        existing.ModuleCode = moduleCode;
+        existing.BusinessInputStatus = status;
+        existing.SourceSystem = Trim(dto.SourceSystem);
+        existing.SourceTables = Trim(dto.SourceTables);
+        existing.SourceFields = Trim(dto.SourceFields);
+        existing.BusinessSqlSource = Trim(dto.BusinessSqlSource);
+        existing.BusinessSqlRemark = Trim(dto.BusinessSqlRemark);
+        existing.BusinessOwner = Trim(dto.BusinessOwner);
+        existing.FieldOwner = Trim(dto.FieldOwner);
+        existing.Remark = Trim(dto.Remark);
+        existing.UpdatedBy = operatorName;
+        existing.UpdatedAt = now;
+        await _db.Updateable(existing).ExecuteCommandAsync();
+        return ToDto(existing);
+    }
+
+    /// <summary>READY_FOR_CONFIG 前置:业务字段齐 + 存在含 SqlText/数据源的 CONFIG_SQL 版本。缺项明确报出。</summary>
+    private async Task EnsureReadyRequirementsAsync(long tenantId, KpiBusinessInputUpsertDto dto)
+    {
+        var missing = new List<string>();
+        if (string.IsNullOrWhiteSpace(dto.SourceSystem)) missing.Add("来源系统");
+        if (string.IsNullOrWhiteSpace(dto.SourceTables)) missing.Add("来源表");
+        if (string.IsNullOrWhiteSpace(dto.SourceFields)) missing.Add("来源字段");
+        if (string.IsNullOrWhiteSpace(dto.BusinessSqlSource)) missing.Add("SQL 来源");
+
+        var hasConfigSql = await _db.Queryable<AdoSmartOpsKpiCalcConfig>().ClearFilter<ITenantIdFilter>()
+            .Where(x => x.MetricCode == dto.MetricCode && x.TenantId == tenantId
+                        && x.CalcEngineType == EngineConfigSql
+                        && x.SqlScript != null && x.SqlScript != ""
+                        && x.DataSourceCode != null && x.DataSourceCode != "")
+            .AnyAsync();
+        if (!hasConfigSql) missing.Add("对应 CONFIG_SQL 配置(含 SqlText + 数据源)");
+
+        if (missing.Count > 0)
+            throw Oops.Bah($"不能置为 READY_FOR_CONFIG,尚缺:{string.Join("、", missing)}");
+    }
+
+    private static string? Trim(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
+
+    private static KpiBusinessInputDto ToDto(AdoSmartOpsKpiBusinessInput e) => new()
+    {
+        IsPersisted = true,
+        TenantId = e.TenantId,
+        MetricCode = e.MetricCode,
+        ModuleCode = e.ModuleCode,
+        BusinessInputStatus = e.BusinessInputStatus,
+        SourceSystem = e.SourceSystem,
+        SourceTables = e.SourceTables,
+        SourceFields = e.SourceFields,
+        BusinessSqlSource = e.BusinessSqlSource,
+        BusinessSqlRemark = e.BusinessSqlRemark,
+        BusinessOwner = e.BusinessOwner,
+        FieldOwner = e.FieldOwner,
+        Remark = e.Remark,
+        UpdatedBy = e.UpdatedBy,
+        UpdatedAt = e.UpdatedAt,
+    };
+}

+ 19 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/AdoSmartOpsKpiCalcConfigService.cs

@@ -23,11 +23,24 @@ public sealed class AdoSmartOpsKpiCalcConfigService : ITransient
 
     private readonly ISqlSugarClient _db;
     private readonly KpiSqlReadOnlyExecutor _executor;
+    private readonly AdoSmartOpsKpiBusinessInputService _businessInput;
 
-    public AdoSmartOpsKpiCalcConfigService(ISqlSugarClient db, KpiSqlReadOnlyExecutor executor)
+    public AdoSmartOpsKpiCalcConfigService(ISqlSugarClient db, KpiSqlReadOnlyExecutor executor, AdoSmartOpsKpiBusinessInputService businessInput)
     {
         _db = db;
         _executor = executor;
+        _businessInput = businessInput;
+    }
+
+    /// <summary>
+    /// CONFIG_SQL 业务门禁:仅 BusinessInputStatus=READY_FOR_CONFIG 放行 试算/发布/激活。
+    /// 未就绪则明确拒绝——不执行 SQL、不写运行日志、不改版本状态/IsCurrent。
+    /// </summary>
+    private async Task EnsureBusinessReadyAsync(long tenantId, string metricCode, string action)
+    {
+        var status = await _businessInput.GetStatusAsync(tenantId, metricCode);
+        if (status != AdoSmartOpsKpiBusinessInputService.StatusReady)
+            throw Oops.Bah($"业务来源未就绪(当前 {status}):{action} 需先在「业务来源登记」补齐来源系统/表/字段/SQL来源并置为 READY_FOR_CONFIG");
     }
 
     /// <summary>解析配置应归属的租户(= 运行时 KPI 落库租户)。S5/S6/S7 走 T8 账套映射(pbxfxp→AIDOP)。</summary>
@@ -158,6 +171,8 @@ public sealed class AdoSmartOpsKpiCalcConfigService : ITransient
         }
 
         var tenantId = ResolveKpiTenantId(dto.ModuleCode);
+        // 业务门禁:试算是真实数据只读执行,未 READY 直接拒绝(不落只读事务、不占连接)。
+        await EnsureBusinessReadyAsync(tenantId, dto.MetricCode, "试算");
         var pars = new KpiSqlRunParams
         {
             TenantId = tenantId,
@@ -202,6 +217,7 @@ public sealed class AdoSmartOpsKpiCalcConfigService : ITransient
             if (!val.Ok) throw Oops.Bah($"SQL 安全校验未通过:{val.ErrorCode} {val.ErrorMessage}");
             if (string.IsNullOrWhiteSpace(entity.DataSourceCode))
                 throw Oops.Bah("CONFIG_SQL 必须指定数据源");
+            await EnsureBusinessReadyAsync(entity.TenantId ?? 0, entity.MetricCode, "发布");
         }
 
         var tenantId = entity.TenantId;
@@ -230,6 +246,8 @@ public sealed class AdoSmartOpsKpiCalcConfigService : ITransient
             ?? throw Oops.Bah("配置版本不存在");
         if (entity.PublishStatus != StatusPublished)
             throw Oops.Bah("只能激活已发布(PUBLISHED)版本;回滚是激活历史已发布版本,不是复制重发");
+        if (entity.CalcEngineType == EngineConfigSql)
+            await EnsureBusinessReadyAsync(entity.TenantId ?? 0, entity.MetricCode, "激活");
 
         var tenantId = entity.TenantId;
         var tran = await _db.AsTenant().UseTranAsync(async () =>

+ 76 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/AdoSmartOpsKpiRunLogQueryService.cs

@@ -0,0 +1,76 @@
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Dto.SmartOps;
+using Admin.NET.Plugin.AiDOP.Entity;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>
+/// KPI 计算运行日志查询(只读、分页、脱敏)。租户由 MetricCode 解析,不信任前端;
+/// 输出仅可展示字段,不返回 SQL 明文 / 参数快照 / 连接串;错误摘要截断。
+/// </summary>
+public sealed class AdoSmartOpsKpiRunLogQueryService : ITransient
+{
+    private const int DefaultPageSize = 20;
+    private const int MaxPageSize = 100;
+    private const int ErrorMessageMaxLen = 300;
+
+    private readonly ISqlSugarClient _db;
+
+    public AdoSmartOpsKpiRunLogQueryService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    public async Task<KpiRunLogPageDto> QueryAsync(KpiRunLogQueryDto dto)
+    {
+        if (string.IsNullOrWhiteSpace(dto.MetricCode))
+            throw Oops.Bah("metricCode 必填");
+
+        var moduleCode = AdoSmartOpsKpiBusinessInputService.ResolveModuleCode(dto.MetricCode);
+        var tenantId = AdoSmartOpsKpiCalcConfigService.ResolveKpiTenantId(moduleCode);
+
+        var page = dto.Page <= 0 ? 1 : dto.Page;
+        var pageSize = dto.PageSize <= 0 ? DefaultPageSize : Math.Min(dto.PageSize, MaxPageSize);
+        var status = string.IsNullOrWhiteSpace(dto.Status) ? null : dto.Status.Trim().ToUpperInvariant();
+
+        RefAsync<int> total = 0;
+        var rows = await _db.Queryable<AdoSmartOpsKpiCalcRunLog>().ClearFilter<ITenantIdFilter>()
+            .Where(x => x.MetricCode == dto.MetricCode && x.TenantId == tenantId)
+            .WhereIF(status != null, x => x.Status == status)
+            .WhereIF(dto.StartTime != null, x => x.StartedAt >= dto.StartTime!.Value)
+            .WhereIF(dto.EndTime != null, x => x.StartedAt <= dto.EndTime!.Value)
+            .OrderBy(x => x.StartedAt, OrderByType.Desc)
+            .ToPageListAsync(page, pageSize, total);
+
+        return new KpiRunLogPageDto
+        {
+            Total = total.Value,
+            Page = page,
+            PageSize = pageSize,
+            List = rows.Select(ToItem).ToList(),
+        };
+    }
+
+    private static KpiRunLogItemDto ToItem(AdoSmartOpsKpiCalcRunLog e) => new()
+    {
+        Id = e.Id,
+        BatchId = e.BatchId,
+        MetricCode = e.MetricCode,
+        EngineType = e.EngineType,
+        VersionNo = e.VersionNo,
+        DataSourceCode = e.DataSourceCode,
+        BizDate = e.BizDate.ToString("yyyy-MM-dd"),
+        StartedAt = e.StartedAt.ToString("yyyy-MM-dd HH:mm:ss"),
+        DurationMs = e.DurationMs,
+        Status = e.Status,
+        RowCount = e.RowCount,
+        MetricValue = e.MetricValue,
+        ErrorCode = e.ErrorCode,
+        ErrorMessage = Truncate(e.ErrorMessage, ErrorMessageMaxLen),
+        TriggerType = e.TriggerType,
+    };
+
+    private static string? Truncate(string? s, int max) =>
+        string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s.Substring(0, max) + "…");
+}