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

feat(s8): wire dimension fields into rule configuration

YY968XX 1 неделя назад
Родитель
Сommit
91afaba44e

+ 38 - 0
Web/src/views/aidop/s8/api/s8ConfigApi.ts

@@ -102,11 +102,44 @@ export interface S8WatchRuleConfigRow {
 	consecutiveFailureCount?: number | null;
 	pausedUntil?: string | null;
 	pauseReason?: string | null;
+	// TASK-002-RESET-DIMENSION-MODEL-DEV-3:业务维度归属 + 报警机制
+	stageCode?: string | null;
+	orderFlowCode?: string | null;
+	ruleMechanism?: string | null;
 }
 
 export interface S8WatchRuleParamsPayload {
 	paramsJson?: string | null;
 	enabled: boolean;
+	// TASK-002-RESET-DIMENSION-MODEL-DEV-3:保存时透传维度三字段(null 清空)。
+	stageCode?: string | null;
+	orderFlowCode?: string | null;
+	ruleMechanism?: string | null;
+}
+
+// TASK-002-RESET-DIMENSION-MODEL-DEV-3:业务维度元数据 DTO。
+export interface S8Dimension {
+	dimensionCode: string;
+	dimensionName: string;
+	enabled: boolean;
+	sortNo: number;
+	remark?: string | null;
+}
+
+export interface S8DimensionNode {
+	id: number | string;
+	dimensionCode: string;
+	nodeCode: string;
+	nodeName: string;
+	parentId?: number | string | null;
+	level: number;
+	path: string;
+	isSelectable: boolean;
+	disabled: boolean;
+	enabled: boolean;
+	sortNo: number;
+	remark?: string | null;
+	children: S8DimensionNode[];
 }
 
 export interface S8WatchRuleSchedulePayload {
@@ -123,6 +156,11 @@ export const s8ConfigApi = {
 	test: (endpoint: string, id: number) => service.post(`${endpoint}/${id}/test`).then(unwrap),
 	scenes: (params?: { tenantId?: number; factoryId?: number }) =>
 		service.get('/api/aidop/s8/config/scenes', { params }).then(unwrap),
+	// TASK-002-RESET-DIMENSION-MODEL-DEV-3:业务维度元数据接口
+	dimensions: (params?: { tenantId?: number; factoryId?: number }) =>
+		service.get<S8Dimension[]>('/api/aidop/s8/config/dimensions', { params }).then(unwrap),
+	dimensionNodes: (params: { tenantId?: number; factoryId?: number; dimensionCode: string }) =>
+		service.get<S8DimensionNode[]>('/api/aidop/s8/config/dimension-nodes', { params }).then(unwrap),
 	severities: () => service.get('/api/aidop/s8/dictionaries/S8_EXCEPTION_SEVERITY').then(unwrap),
 	dataSources: (params?: { tenantId?: number; factoryId?: number }) =>
 		service.get('/api/aidop/s8/config/data-sources', { params }).then(unwrap),

+ 126 - 0
Web/src/views/aidop/s8/config/S8WatchRuleConfigPage.vue

@@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox } from 'element-plus';
 import AidopDemoShell from '../../components/AidopDemoShell.vue';
 import {
 	s8ConfigApi,
+	type S8DimensionNode,
 	type S8ExceptionTypeConfigRow,
 	type S8WatchRuleConfigRow,
 	type S8WatchRuleType,
@@ -92,6 +93,38 @@ const outOfRangeForm = reactive<OutOfRangeForm>({
 const enabledForm = ref(true);
 const rawParamsJson = ref<string>('');
 
+// TASK-002-RESET-DIMENSION-MODEL-DEV-3:业务维度(S_STAGE / ORDER_FLOW)+ 报警机制。
+interface DimensionForm {
+	stageCode: string;
+	orderFlowCode: string;
+	ruleMechanism: string;
+}
+const dimensionForm = reactive<DimensionForm>({
+	stageCode: '',
+	orderFlowCode: '',
+	ruleMechanism: '',
+});
+const stageNodes = ref<S8DimensionNode[]>([]);
+const orderFlowNodes = ref<S8DimensionNode[]>([]);
+const RULE_MECHANISM_OPTIONS = [
+	{ value: 'DATE', label: '日期类' },
+	{ value: 'VALUE_RANGE', label: '数值超差类' },
+	{ value: 'RATIO', label: '比例类' },
+	{ value: 'MANUAL_REPORT', label: '主动提报' },
+];
+function ruleMechanismLabel(code?: string | null): string {
+	const found = RULE_MECHANISM_OPTIONS.find(o => o.value === code);
+	return found?.label ?? code ?? '—';
+}
+function stageNodeLabel(code?: string | null): string {
+	if (!code) return '—';
+	return stageNodes.value.find(n => n.nodeCode === code)?.nodeName ?? code;
+}
+function orderFlowNodeLabel(code?: string | null): string {
+	if (!code) return '—';
+	return orderFlowNodes.value.find(n => n.nodeCode === code)?.nodeName ?? code;
+}
+
 // S8-SCHED-FRONTEND-1:调度参数编辑表单。
 interface ScheduleForm {
 	pollIntervalSeconds: number;
@@ -122,6 +155,13 @@ interface ScheduleSnapshot {
 const originalScheduleSnapshot = ref<ScheduleSnapshot | null>(null);
 const originalParamsSnapshot = ref<string | null>(null);
 const originalEnabled = ref<boolean>(true);
+// TASK-002-RESET-DIMENSION-MODEL-DEV-3:维度三字段快照
+interface DimensionSnapshot {
+	stageCode: string;
+	orderFlowCode: string;
+	ruleMechanism: string;
+}
+const originalDimensionSnapshot = ref<DimensionSnapshot | null>(null);
 
 // S8-SCHED-FRONTEND-1:30s 自动刷新;onMounted/onActivated 启动;onUnmounted/onDeactivated 清理。
 const REFRESH_INTERVAL_MS = 30000;
@@ -456,6 +496,20 @@ async function loadExceptionTypes() {
 	}
 }
 
+// TASK-002-RESET-DIMENSION-MODEL-DEV-3:加载 S_STAGE / ORDER_FLOW 维度节点(首版均为顶层叶节点)。
+async function loadDimensionNodes() {
+	try {
+		const [stage, flow] = await Promise.all([
+			s8ConfigApi.dimensionNodes({ tenantId: TENANT_ID, factoryId: FACTORY_ID, dimensionCode: 'S_STAGE' }),
+			s8ConfigApi.dimensionNodes({ tenantId: TENANT_ID, factoryId: FACTORY_ID, dimensionCode: 'ORDER_FLOW' }),
+		]);
+		stageNodes.value = stage;
+		orderFlowNodes.value = flow;
+	} catch (e: any) {
+		ElMessage.error(e?.message ?? '加载业务维度节点失败');
+	}
+}
+
 function resetForms() {
 	Object.assign(timeoutForm, {
 		dueAtField: '', statusField: '', completedStates: '',
@@ -482,6 +536,15 @@ function loadFormFromRow(row: S8WatchRuleConfigRow) {
 	scheduleForm.pollIntervalSeconds = clampInt(row.pollIntervalSeconds ?? 300, 60, 86400, 300);
 	scheduleForm.triggerCountRequired = clampInt(row.triggerCountRequired ?? 1, 1, 10, 1);
 	scheduleForm.recoverCountRequired = clampInt(row.recoverCountRequired ?? 1, 1, 10, 1);
+	// TASK-002-RESET-DIMENSION-MODEL-DEV-3:维度三字段。机制缺省按 rule_type 推荐。
+	dimensionForm.stageCode = row.stageCode ?? '';
+	dimensionForm.orderFlowCode = row.orderFlowCode ?? '';
+	if (row.ruleMechanism) {
+		dimensionForm.ruleMechanism = row.ruleMechanism;
+	} else {
+		const t = (row.ruleType ?? '').toUpperCase();
+		dimensionForm.ruleMechanism = t === 'TIMEOUT' ? 'DATE' : t === 'OUT_OF_RANGE' ? 'VALUE_RANGE' : '';
+	}
 
 	if (!row.paramsJson) return;
 	let parsed: Record<string, any>;
@@ -552,9 +615,24 @@ function openEdit(row: S8WatchRuleConfigRow) {
 	};
 	originalParamsSnapshot.value = buildPayloadParamsJson();
 	originalEnabled.value = enabledForm.value;
+	originalDimensionSnapshot.value = {
+		stageCode: dimensionForm.stageCode,
+		orderFlowCode: dimensionForm.orderFlowCode,
+		ruleMechanism: dimensionForm.ruleMechanism,
+	};
 	drawerOpen.value = true;
 }
 
+function hasDimensionChanged(): boolean {
+	const o = originalDimensionSnapshot.value;
+	if (!o) return false;
+	return (
+		dimensionForm.stageCode !== o.stageCode
+		|| dimensionForm.orderFlowCode !== o.orderFlowCode
+		|| dimensionForm.ruleMechanism !== o.ruleMechanism
+	);
+}
+
 function hasScheduleChanged(): boolean {
 	const o = originalScheduleSnapshot.value;
 	if (!o) return false;
@@ -568,6 +646,8 @@ function hasScheduleChanged(): boolean {
 function hasParamsChanged(): boolean {
 	// enabled 在后端走 /params 端点,因此 enabled 切换视为 params 变更。
 	if (enabledForm.value !== originalEnabled.value) return true;
+	// TASK-002-RESET-DIMENSION-MODEL-DEV-3:维度三字段也走 /params 端点保存。
+	if (hasDimensionChanged()) return true;
 	const next = buildPayloadParamsJson();
 	return next !== originalParamsSnapshot.value;
 }
@@ -673,6 +753,10 @@ async function save() {
 				await s8ConfigApi.watchRules.updateParams(editingRow.value.id, {
 					paramsJson: newParamsJson,
 					enabled: newEnabled,
+					// TASK-002-RESET-DIMENSION-MODEL-DEV-3:维度三字段随 params 一起保存。
+					stageCode: dimensionForm.stageCode || null,
+					orderFlowCode: dimensionForm.orderFlowCode || null,
+					ruleMechanism: dimensionForm.ruleMechanism || null,
 				});
 				paramsSaved = true;
 			} catch (e) {
@@ -687,6 +771,11 @@ async function save() {
 		if (paramsSaved) {
 			originalParamsSnapshot.value = newParamsJson;
 			originalEnabled.value = newEnabled;
+			originalDimensionSnapshot.value = {
+				stageCode: dimensionForm.stageCode,
+				orderFlowCode: dimensionForm.orderFlowCode,
+				ruleMechanism: dimensionForm.ruleMechanism,
+			};
 		}
 
 		if (scheduleChanged && paramsChanged) {
@@ -812,6 +901,10 @@ async function toggleEnabled(row: S8WatchRuleConfigRow) {
 		await s8ConfigApi.watchRules.updateParams(row.id, {
 			paramsJson: row.paramsJson ?? null,
 			enabled: next,
+			// TASK-002-RESET-DIMENSION-MODEL-DEV-3:列表行启停切换时保留维度三字段,避免被服务端清空。
+			stageCode: row.stageCode ?? null,
+			orderFlowCode: row.orderFlowCode ?? null,
+			ruleMechanism: row.ruleMechanism ?? null,
 		});
 		ElMessage.success(`${action}成功`);
 		await loadRows();
@@ -823,6 +916,7 @@ async function toggleEnabled(row: S8WatchRuleConfigRow) {
 onMounted(() => {
 	loadRows();
 	loadExceptionTypes();
+	loadDimensionNodes();
 	startAutoRefresh();
 });
 onUnmounted(() => stopAutoRefresh());
@@ -859,6 +953,18 @@ onDeactivated(() => stopAutoRefresh());
 				</template>
 			</el-table-column>
 			<el-table-column prop="sceneCode" label="场景" width="120" show-overflow-tooltip />
+			<el-table-column label="机制" width="100">
+				<template #default="{ row }">
+					<el-tag v-if="row.ruleMechanism" size="small" type="info">{{ ruleMechanismLabel(row.ruleMechanism) }}</el-tag>
+					<span v-else>—</span>
+				</template>
+			</el-table-column>
+			<el-table-column label="阶段维度" width="140" show-overflow-tooltip>
+				<template #default="{ row }">{{ stageNodeLabel(row.stageCode) }}</template>
+			</el-table-column>
+			<el-table-column label="订单流程" width="160" show-overflow-tooltip>
+				<template #default="{ row }">{{ orderFlowNodeLabel(row.orderFlowCode) }}</template>
+			</el-table-column>
 			<el-table-column label="严重度" width="80">
 				<template #default="{ row }">
 					<el-tag v-if="row.severity" :type="severityTagType(row.severity)" size="small">
@@ -958,6 +1064,26 @@ onDeactivated(() => stopAutoRefresh());
 					<el-descriptions-item label="源对象">{{ editingRow.sourceObjectType ?? editingRow.watchObjectType ?? '—' }}</el-descriptions-item>
 				</el-descriptions>
 
+				<el-divider content-position="left">维度归属与机制</el-divider>
+				<el-form label-position="top" size="small">
+					<el-form-item label="报警机制">
+						<el-select v-model="dimensionForm.ruleMechanism" clearable placeholder="选择报警机制" style="width: 100%">
+							<el-option v-for="o in RULE_MECHANISM_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
+						</el-select>
+						<span class="rule-hint">建议:日期类→DATE / 数值超差→VALUE_RANGE / 比例类→RATIO;缺料类待业务方确认</span>
+					</el-form-item>
+					<el-form-item label="S_STAGE 阶段(S1-S7)">
+						<el-select v-model="dimensionForm.stageCode" clearable filterable placeholder="选择阶段维度" style="width: 100%">
+							<el-option v-for="n in stageNodes" :key="n.nodeCode" :label="n.nodeName" :value="n.nodeCode" :disabled="n.disabled" />
+						</el-select>
+					</el-form-item>
+					<el-form-item label="ORDER_FLOW 订单全流程(可选)">
+						<el-select v-model="dimensionForm.orderFlowCode" clearable filterable placeholder="选择订单流程节点(可不选)" style="width: 100%">
+							<el-option v-for="n in orderFlowNodes" :key="n.nodeCode" :label="n.nodeName" :value="n.nodeCode" :disabled="n.disabled" />
+						</el-select>
+					</el-form-item>
+				</el-form>
+
 				<el-divider content-position="left">触发条件</el-divider>
 
 				<!-- TIMEOUT form -->

+ 4 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8Dtos.cs

@@ -101,6 +101,10 @@ public class AdoS8ManualReportCreateDto
     public long? ReporterId { get; set; }
     /// <summary>关联对象编码:当前按订单项 / 类订单项对象理解,允许重复,可为空。最长 64 字符(与 ado_s8_exception.related_object_code 一致)。</summary>
     public string? RelatedObjectCode { get; set; }
+
+    // TASK-002-RESET-DIMENSION-MODEL-DEV-2B:手工提报维度选择(前端首版可不传,按 module_code 兜底;rule_mechanism 固定 MANUAL_REPORT)。
+    public string? StageCode { get; set; }
+    public string? OrderFlowCode { get; set; }
 }
 
 public class AdoS8ManualReportResultDto

+ 67 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8ManualReportService.cs

@@ -327,6 +327,54 @@ public class S8ManualReportService : ITransient
         return (d.Id, string.IsNullOrWhiteSpace(d.Descr) ? d.Department : d.Descr);
     }
 
+    // TASK-002-RESET-DIMENSION-MODEL-DEV-2B:维度字段辅助。
+    private static string? NormalizeOrNull(string? value)
+    {
+        if (string.IsNullOrWhiteSpace(value)) return null;
+        var t = value.Trim();
+        return t.Length == 0 ? null : t;
+    }
+
+    private static string? NormalizeStage(string? value)
+    {
+        var t = NormalizeOrNull(value);
+        if (t == null) return null;
+        return S8ModuleCode.All.Contains(t) ? t : null;
+    }
+
+    /// <summary>
+    /// TASK-002-RESET-DIMENSION-MODEL-DEV-2B:自动建单时从规则复制维度三件套;规则缺失则按 rule_type 推断 rule_mechanism。
+    /// </summary>
+    private async Task<(string? StageCode, string? OrderFlowCode, string? RuleMechanism)> ResolveRuleDimensionsAsync(
+        long ruleId, string? fallbackModuleCode, string? ruleType)
+    {
+        string? stageCode = null;
+        string? orderFlowCode = null;
+        string? ruleMechanism = null;
+        if (ruleId > 0)
+        {
+            var row = await _ruleRep.AsQueryable()
+                .Where(x => x.Id == ruleId)
+                .Select(x => new { x.StageCode, x.OrderFlowCode, x.RuleMechanism, x.RuleType })
+                .FirstAsync();
+            if (row != null)
+            {
+                stageCode = NormalizeStage(row.StageCode);
+                orderFlowCode = NormalizeOrNull(row.OrderFlowCode);
+                ruleMechanism = NormalizeOrNull(row.RuleMechanism);
+                ruleType ??= row.RuleType;
+            }
+        }
+        stageCode ??= NormalizeStage(fallbackModuleCode);
+        ruleMechanism ??= ruleType switch
+        {
+            "TIMEOUT" => "DATE",
+            "OUT_OF_RANGE" => "VALUE_RANGE",
+            _ => null,
+        };
+        return (stageCode, orderFlowCode, ruleMechanism);
+    }
+
     /// <summary>
     /// 主动提报推断 ExceptionTypeCode:场景下取启用且 SortNo 最小的一条。
     /// baseline 异常类型当前 tenant_id=0/factory_id=0(全局基线),所以匹配条件为
@@ -497,7 +545,11 @@ public class S8ManualReportService : ITransient
             CreatedAt = now,
             // S8-SLA-TIMEOUT-RUNTIME-1:sla_deadline 由 exception_type.sla_minutes 决定;缺配置 → null。
             SlaDeadline = slaDeadline,
-            IsDeleted = false
+            IsDeleted = false,
+            // TASK-002-RESET-DIMENSION-MODEL-DEV-2B:维度归属优先 DTO,缺省回退 module_code;rule_mechanism 固定 MANUAL_REPORT。
+            StageCode = NormalizeStage(dto.StageCode) ?? resolvedModule,
+            OrderFlowCode = NormalizeOrNull(dto.OrderFlowCode),
+            RuleMechanism = "MANUAL_REPORT",
         };
 
         await _rep.AsTenant().UseTranAsync(async () =>
@@ -618,9 +670,15 @@ public class S8ManualReportService : ITransient
             SourceRuleId = hit.SourceRuleId,
             SourceDataSourceId = hit.DataSourceId,
             SourcePayload = hit.SourcePayload,
-            RelatedObjectCode = hit.RelatedObjectCode
+            RelatedObjectCode = hit.RelatedObjectCode,
         };
 
+        // TASK-002-RESET-DIMENSION-MODEL-DEV-2B:从规则复制维度三件套。
+        var (gWatchStage, gWatchFlow, gWatchMech) = await ResolveRuleDimensionsAsync(hit.SourceRuleId, resolvedModule, ruleType: null);
+        entity.StageCode = gWatchStage;
+        entity.OrderFlowCode = gWatchFlow;
+        entity.RuleMechanism = gWatchMech;
+
         _logger.LogInformation(
             "s8_auto_watch_dept_resolved path={Path} ruleId={RuleId} ruleCode={RuleCode} factoryId={FactoryId} occDeptId={OccDeptId} occSource={OccSource} respDeptId={RespDeptId} respSource={RespSource}",
             nameof(CreateFromWatchAsync), hit.SourceRuleId, hit.SourceRuleCode, fixedFactoryId,
@@ -744,9 +802,15 @@ public class S8ManualReportService : ITransient
             RecoveredAt = null,
             SourceRuleCode = hit.SourceRuleCode,
             SourceObjectType = hit.SourceObjectType,
-            SourceObjectId = hit.SourceObjectId
+            SourceObjectId = hit.SourceObjectId,
         };
 
+        // TASK-002-RESET-DIMENSION-MODEL-DEV-2B:R2 自动建单同样从规则复制维度三件套。
+        var (gHitStage, gHitFlow, gHitMech) = await ResolveRuleDimensionsAsync(hit.SourceRuleId, resolvedModule, ruleType: null);
+        entity.StageCode = gHitStage;
+        entity.OrderFlowCode = gHitFlow;
+        entity.RuleMechanism = gHitMech;
+
         _logger.LogInformation(
             "s8_auto_watch_dept_resolved path={Path} ruleId={RuleId} ruleCode={RuleCode} factoryId={FactoryId} occDeptId={OccDeptId} occSource={OccSource} respDeptId={RespDeptId} respSource={RespSource} sourceObjectType={SourceObjectType} sourceObjectId={SourceObjectId} dedupKey={DedupKey}",
             nameof(CreateFromHitAsync), hit.SourceRuleId, hit.SourceRuleCode, fixedFactoryId,

+ 12 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleParamsPayload.cs

@@ -12,6 +12,18 @@ public sealed class S8WatchRuleParamsPayload
 
     /// <summary>启用 / 停用。</summary>
     public bool Enabled { get; set; }
+
+    // TASK-002-RESET-DIMENSION-MODEL-DEV-2B:维度归属(S_STAGE / ORDER_FLOW)+ 报警机制。
+    // 这里允许手选改写;保存时按字段原样落库(含 null 清空),前端每次保存需带回当前快照。
+
+    /// <summary>S_STAGE 维度节点(S1-S7)。</summary>
+    public string? StageCode { get; set; }
+
+    /// <summary>ORDER_FLOW 维度节点;可空。</summary>
+    public string? OrderFlowCode { get; set; }
+
+    /// <summary>报警机制:MANUAL_REPORT / DATE / RATIO / VALUE_RANGE。</summary>
+    public string? RuleMechanism { get; set; }
 }
 
 /// <summary>

+ 11 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs

@@ -64,11 +64,22 @@ public class S8WatchRuleService : ITransient
 
         entity.ParamsJson = string.IsNullOrEmpty(paramsJson) ? null : paramsJson;
         entity.Enabled = payload.Enabled;
+        // TASK-002-RESET-DIMENSION-MODEL-DEV-2B:维度归属 + 报警机制按 payload 原样落库(含 null 清空)。
+        entity.StageCode = NormalizeOrNull(payload.StageCode);
+        entity.OrderFlowCode = NormalizeOrNull(payload.OrderFlowCode);
+        entity.RuleMechanism = NormalizeOrNull(payload.RuleMechanism);
         entity.UpdatedAt = DateTime.Now;
         await _rep.UpdateAsync(entity);
         return entity;
     }
 
+    private static string? NormalizeOrNull(string? value)
+    {
+        if (string.IsNullOrWhiteSpace(value)) return null;
+        var trimmed = value.Trim();
+        return trimmed.Length == 0 ? null : trimmed;
+    }
+
     private static void ValidateParamsJsonByRuleType(string? ruleType, string paramsJson)
     {
         try