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

feat(s8): add monitor dictionary options

YY968XX 2 недель назад
Родитель
Сommit
a3f1a031fc

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

@@ -148,6 +148,38 @@ export interface S8WatchRuleSchedulePayload {
 	recoverCountRequired: number;
 }
 
+// CONFIG-MONITOR-DICT-READONLY-SEED-1:监控对象/指标只读字典 DTO。
+export interface S8MonitorOptionsQuery {
+	tenantId: number;
+	factoryId: number;
+}
+
+export interface S8MonitorMetricOption {
+	metricCode: string;
+	metricLabel: string;
+	mechanism: 'DATE' | 'VALUE_RANGE' | 'RATIO' | string;
+	unit?: string | null;
+	dueAtField?: string | null;
+	statusField?: string | null;
+	measuredValueField?: string | null;
+	objectIdField?: string | null;
+	objectCodeField?: string | null;
+	objectNameField?: string | null;
+	isResultKpi: boolean;
+}
+
+export interface S8MonitorObjectOption {
+	objectCode: string;
+	objectType: string;
+	objectLabel: string;
+	mechanisms: string[];
+	metrics: S8MonitorMetricOption[];
+}
+
+export interface S8MonitorOptionsResult {
+	objects: S8MonitorObjectOption[];
+}
+
 // CONFIG-RULE-WIZARD-MVP-1:向导新建规则的最小字段集合,复用 legacy POST /api/aidop/s8/config/watch-rules。
 export interface S8WatchRuleCreatePayload {
 	tenantId: number;
@@ -180,6 +212,9 @@ 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),
+	// CONFIG-MONITOR-DICT-READONLY-SEED-1:监控对象/指标只读字典
+	monitorOptions: (params: S8MonitorOptionsQuery) =>
+		service.get<S8MonitorOptionsResult>('/api/aidop/s8/config/monitor-options', { 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),

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

@@ -8,6 +8,7 @@ import {
 	s8ConfigApi,
 	type S8DimensionNode,
 	type S8ExceptionTypeConfigRow,
+	type S8MonitorObjectOption,
 	type S8WatchRuleConfigRow,
 	type S8WatchRuleType,
 	type S8WizardDraftDetail,
@@ -20,6 +21,8 @@ const loading = ref(false);
 const saving = ref(false);
 const rows = ref<S8WatchRuleConfigRow[]>([]);
 const exceptionTypes = ref<S8ExceptionTypeConfigRow[]>([]);
+// CONFIG-MONITOR-DICT-READONLY-SEED-1:监控对象/指标只读字典;接口失败时 wizard 自动回退 FALLBACK 常量
+const monitorObjects = ref<S8MonitorObjectOption[]>([]);
 
 const drawerOpen = ref(false);
 const editingRow = ref<S8WatchRuleConfigRow | null>(null);
@@ -594,6 +597,17 @@ async function loadExceptionTypes() {
 	}
 }
 
+// CONFIG-MONITOR-DICT-READONLY-SEED-1:加载监控对象/指标字典。失败静默回退(wizard 内部有 FALLBACK 常量兜底)。
+async function loadMonitorOptions() {
+	try {
+		const res = await s8ConfigApi.monitorOptions({ tenantId: TENANT_ID, factoryId: FACTORY_ID });
+		monitorObjects.value = res?.objects ?? [];
+	} catch (e: any) {
+		monitorObjects.value = [];
+		ElMessage.warning('监控对象/指标字典加载失败,已回退本地默认(不影响演示)');
+	}
+}
+
 // TASK-002-RESET-DIMENSION-MODEL-DEV-3:加载 S_STAGE / ORDER_FLOW 维度节点(首版均为顶层叶节点)。
 async function loadDimensionNodes() {
 	try {
@@ -1097,6 +1111,7 @@ onMounted(() => {
 	loadExceptionTypes();
 	loadDimensionNodes();
 	loadDataSources();
+	loadMonitorOptions();
 	loadDraftCount();
 	startAutoRefresh();
 });
@@ -1148,6 +1163,7 @@ onDeactivated(() => stopAutoRefresh());
 			:order-flow-nodes="orderFlowNodes"
 			:exception-types="exceptionTypes"
 			:data-sources="dataSources"
+			:monitor-objects="monitorObjects"
 			:tenant-id="TENANT_ID"
 			:factory-id="FACTORY_ID"
 			:draft-data="editingDraftDetail"

+ 46 - 5
Web/src/views/aidop/s8/config/components/WatchRuleWizardDialog.vue

@@ -10,6 +10,7 @@ import {
 	s8ConfigApi,
 	type S8DimensionNode,
 	type S8ExceptionTypeConfigRow,
+	type S8MonitorObjectOption,
 	type S8WatchRuleCreatePayload,
 	type S8WizardDraftDetail,
 } from '../../api/s8ConfigApi';
@@ -28,6 +29,8 @@ const props = defineProps<{
 	orderFlowNodes: S8DimensionNode[];
 	exceptionTypes: S8ExceptionTypeConfigRow[];
 	dataSources: DataSourceLike[];
+	// CONFIG-MONITOR-DICT-READONLY-SEED-1:监控对象/指标后端字典,空数组时回退 FALLBACK_MONITOR_OPTIONS
+	monitorObjects?: S8MonitorObjectOption[];
 	tenantId?: number;
 	factoryId?: number;
 	draftData?: S8WizardDraftDetail | null;
@@ -79,7 +82,9 @@ interface BizObject {
 	mechanisms: Array<'DATE' | 'VALUE_RANGE' | 'RATIO'>;
 	metrics: BizMetric[];
 }
-const BUSINESS_MONITOR_OPTIONS: BizObject[] = [
+// CONFIG-MONITOR-DICT-READONLY-SEED-1:原 BUSINESS_MONITOR_OPTIONS 重命名为 FALLBACK,
+// 仅在 props.monitorObjects 为空(接口失败 / 未注入)时兜底,确保演示链路不中断。
+const FALLBACK_MONITOR_OPTIONS: BizObject[] = [
 	{
 		objectType: 'ORDER',
 		objectLabel: '订单交付',
@@ -237,9 +242,37 @@ watch(visible, (v) => {
 
 const isManualReport = computed(() => form.mechanism === 'MANUAL_REPORT');
 
+// CONFIG-MONITOR-DICT-READONLY-SEED-1:优先用后端字典,空时回退 FALLBACK
+const activeMonitorOptions = computed<BizObject[]>(() => {
+	const remote = props.monitorObjects ?? [];
+	if (remote.length === 0) return FALLBACK_MONITOR_OPTIONS;
+	return remote.map((o) => ({
+		objectType: o.objectType,
+		objectLabel: o.objectLabel,
+		mechanisms: o.mechanisms as Array<'DATE' | 'VALUE_RANGE' | 'RATIO'>,
+		metrics: o.metrics.map((m) => ({
+			metricCode: m.metricCode,
+			metricLabel: m.metricLabel,
+			mechanism: m.mechanism as 'DATE' | 'VALUE_RANGE' | 'RATIO',
+			dueAtField: m.dueAtField ?? undefined,
+			statusField: m.statusField ?? undefined,
+			measuredValueField: m.measuredValueField ?? undefined,
+			unit: m.unit ?? '',
+		})),
+	}));
+});
+
 const filteredObjects = computed(() => {
 	if (!form.mechanism || isManualReport.value) return [];
-	return BUSINESS_MONITOR_OPTIONS.filter((o) => o.mechanisms.includes(form.mechanism as any));
+	return activeMonitorOptions.value.filter((o) => o.mechanisms.includes(form.mechanism as any));
+});
+
+// CONFIG-MONITOR-DICT-READONLY-SEED-1:当前机制下没有可用对象/指标的空态提示
+const monitorEmptyHint = computed(() => {
+	if (!form.mechanism || isManualReport.value) return '';
+	if (filteredObjects.value.length > 0) return '';
+	if (form.mechanism === 'RATIO') return '暂无可用比例类指标,请在监控指标字典中启用后再配置。';
+	return '当前机制暂无可用监控对象/指标。';
 });
 
 const selectedObject = computed<BizObject | null>(() => {
@@ -512,8 +545,8 @@ function hydrateFromDraft(draft: S8WizardDraftDetail) {
 	if (f.exceptionTypeCode) form.exceptionTypeCode = f.exceptionTypeCode;
 	if (f.severity) form.severity = f.severity;
 
-	// 2) 反查 BUSINESS_MONITOR_OPTIONS:按 (objectType, objectLabel) 在当前机制下定位 objectIndex
-	const candidates = BUSINESS_MONITOR_OPTIONS.filter((o) => o.mechanisms.includes(form.mechanism as any));
+	// 2) 反查 activeMonitorOptions(后端字典优先):按 (objectType, objectLabel) 在当前机制下定位 objectIndex
+	const candidates = activeMonitorOptions.value.filter((o) => o.mechanisms.includes(form.mechanism as any));
 	const idx = candidates.findIndex((o) => o.objectType === f.objectType && o.objectLabel === f.objectLabel);
 	if (idx >= 0) {
 		form.objectIndex = idx;
@@ -763,9 +796,17 @@ function handleInlineExceptionTypeCreated(payload: { typeCode: string; typeName:
 
 		<!-- Step 2: 监控对象/指标 -->
 		<div v-else-if="step === 2">
+			<el-alert
+				v-if="monitorEmptyHint"
+				type="warning"
+				show-icon
+				:closable="false"
+				style="margin-bottom: 12px"
+				:title="monitorEmptyHint"
+			/>
 			<el-form label-position="top" size="small">
 				<el-form-item label="监控对象" required>
-					<el-select v-model="form.objectIndex" placeholder="选择监控对象" style="width: 100%">
+					<el-select v-model="form.objectIndex" placeholder="选择监控对象" style="width: 100%" :disabled="filteredObjects.length === 0">
 						<el-option
 							v-for="(o, idx) in filteredObjects"
 							:key="`${o.objectType}-${o.objectLabel}-${idx}`"

+ 24 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigMonitorDictionaryController.cs

@@ -0,0 +1,24 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers.S8;
+
+[ApiController]
+[Route("api/aidop/s8/config/monitor-options")]
+[NonUnify]
+public class AdoS8ConfigMonitorDictionaryController : ControllerBase
+{
+    private readonly S8MonitorDictionaryService _svc;
+
+    public AdoS8ConfigMonitorDictionaryController(S8MonitorDictionaryService svc) => _svc = svc;
+
+    /// <summary>
+    /// 监控对象/指标只读字典。返回结构与前端 BUSINESS_MONITOR_OPTIONS 同形。
+    /// CONFIG-MONITOR-DICT-READONLY-SEED-1。
+    /// </summary>
+    [HttpGet]
+    public async Task<IActionResult> GetOptionsAsync(
+        [FromQuery] long tenantId = 1,
+        [FromQuery] long factoryId = 1)
+        => Ok(await _svc.GetOptionsAsync(tenantId, factoryId));
+}

+ 39 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8MonitorDictionaryDtos.cs

@@ -0,0 +1,39 @@
+namespace Admin.NET.Plugin.AiDOP.Dto.S8;
+
+/// <summary>
+/// CONFIG-MONITOR-DICT-READONLY-SEED-1:监控对象/指标只读字典查询 + 返回 DTO。
+/// </summary>
+public class AdoS8MonitorOptionsQueryDto
+{
+    public long TenantId { get; set; } = 1;
+    public long FactoryId { get; set; } = 1;
+}
+
+public class AdoS8MonitorMetricOptionDto
+{
+    public string MetricCode { get; set; } = string.Empty;
+    public string MetricLabel { get; set; } = string.Empty;
+    public string Mechanism { get; set; } = string.Empty;
+    public string? Unit { get; set; }
+    public string? DueAtField { get; set; }
+    public string? StatusField { get; set; }
+    public string? MeasuredValueField { get; set; }
+    public string? ObjectIdField { get; set; }
+    public string? ObjectCodeField { get; set; }
+    public string? ObjectNameField { get; set; }
+    public bool IsResultKpi { get; set; }
+}
+
+public class AdoS8MonitorObjectOptionDto
+{
+    public string ObjectCode { get; set; } = string.Empty;
+    public string ObjectType { get; set; } = string.Empty;
+    public string ObjectLabel { get; set; } = string.Empty;
+    public List<string> Mechanisms { get; set; } = new();
+    public List<AdoS8MonitorMetricOptionDto> Metrics { get; set; } = new();
+}
+
+public class AdoS8MonitorOptionsResultDto
+{
+    public List<AdoS8MonitorObjectOptionDto> Objects { get; set; } = new();
+}

+ 93 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8MonitorMetric.cs

@@ -0,0 +1,93 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+/// <summary>
+/// S8 监控指标字典。每条指标绑定一个 object_code + 一个 mechanism(DATE/VALUE_RANGE/RATIO)。
+/// 字段映射:DATE 用 due_at_field/status_field;VALUE_RANGE/RATIO 用 measured_value_field。
+/// is_result_kpi=true 时表示该指标是 S9 结果 KPI 范畴(如交付满足率),本轮 seed 默认 enabled=false。
+/// CONFIG-MONITOR-DICT-READONLY-SEED-1:本轮只读字典。
+/// </summary>
+[SugarTable("ado_s8_monitor_metric", "S8 监控指标字典")]
+[SugarIndex("uk_s8_monitor_metric_tenant_factory_code", nameof(TenantId), OrderByType.Asc, nameof(FactoryId), OrderByType.Asc, nameof(MetricCode), OrderByType.Asc, IsUnique = true)]
+[SugarIndex("idx_s8_monitor_metric_object_mech", nameof(ObjectCode), OrderByType.Asc, nameof(Mechanism), OrderByType.Asc, nameof(Enabled), OrderByType.Asc, nameof(SortNo), OrderByType.Asc)]
+[SugarIndex("idx_s8_monitor_metric_tfe", nameof(TenantId), OrderByType.Asc, nameof(FactoryId), OrderByType.Asc, nameof(Enabled), OrderByType.Asc)]
+public class AdoS8MonitorMetric
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "tenant_id", ColumnDataType = "bigint")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "factory_id", ColumnDataType = "bigint")]
+    public long FactoryId { get; set; }
+
+    /// <summary>所属对象编码(关联 ado_s8_monitor_object.object_code)。</summary>
+    [SugarColumn(ColumnName = "object_code", Length = 64)]
+    public string ObjectCode { get; set; } = string.Empty;
+
+    /// <summary>指标编码(业务唯一),如 ORDER_DUE_AT / IQC_VALUE / WO_COMPLETION_RATE。</summary>
+    [SugarColumn(ColumnName = "metric_code", Length = 64)]
+    public string MetricCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "metric_name", Length = 128)]
+    public string MetricName { get; set; } = string.Empty;
+
+    /// <summary>报警机制:DATE / VALUE_RANGE / RATIO(与 watch_rule.rule_mechanism 一致)。</summary>
+    [SugarColumn(ColumnName = "mechanism", Length = 32)]
+    public string Mechanism { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "unit", Length = 32, IsNullable = true)]
+    public string? Unit { get; set; }
+
+    [SugarColumn(ColumnName = "due_at_field", Length = 64, IsNullable = true)]
+    public string? DueAtField { get; set; }
+
+    [SugarColumn(ColumnName = "status_field", Length = 64, IsNullable = true)]
+    public string? StatusField { get; set; }
+
+    [SugarColumn(ColumnName = "measured_value_field", Length = 64, IsNullable = true)]
+    public string? MeasuredValueField { get; set; }
+
+    [SugarColumn(ColumnName = "object_id_field", Length = 64, IsNullable = true)]
+    public string? ObjectIdField { get; set; }
+
+    [SugarColumn(ColumnName = "object_code_field", Length = 64, IsNullable = true)]
+    public string? ObjectCodeField { get; set; }
+
+    [SugarColumn(ColumnName = "object_name_field", Length = 64, IsNullable = true)]
+    public string? ObjectNameField { get; set; }
+
+    [SugarColumn(ColumnName = "default_grace_minutes", IsNullable = true)]
+    public int? DefaultGraceMinutes { get; set; }
+
+    [SugarColumn(ColumnName = "default_completed_states", Length = 256, IsNullable = true)]
+    public string? DefaultCompletedStates { get; set; }
+
+    [SugarColumn(ColumnName = "default_target_ratio", ColumnDataType = "decimal(10,2)", IsNullable = true)]
+    public decimal? DefaultTargetRatio { get; set; }
+
+    [SugarColumn(ColumnName = "default_lower_bound", ColumnDataType = "decimal(18,6)", IsNullable = true)]
+    public decimal? DefaultLowerBound { get; set; }
+
+    [SugarColumn(ColumnName = "default_upper_bound", ColumnDataType = "decimal(18,6)", IsNullable = true)]
+    public decimal? DefaultUpperBound { get; set; }
+
+    /// <summary>是否为 S9 结果 KPI 类指标(交付满足率/完工率/合格率等);true 时本轮 seed 默认 disabled。</summary>
+    [SugarColumn(ColumnName = "is_result_kpi", ColumnDataType = "boolean")]
+    public bool IsResultKpi { get; set; }
+
+    [SugarColumn(ColumnName = "enabled", ColumnDataType = "boolean")]
+    public bool Enabled { get; set; } = true;
+
+    [SugarColumn(ColumnName = "sort_no")]
+    public int SortNo { get; set; }
+
+    [SugarColumn(ColumnName = "remark", Length = 512, IsNullable = true)]
+    public string? Remark { get; set; }
+
+    [SugarColumn(ColumnName = "created_at")]
+    public DateTime CreatedAt { get; set; } = DateTime.Now;
+
+    [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
+    public DateTime? UpdatedAt { get; set; }
+}

+ 48 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8MonitorObject.cs

@@ -0,0 +1,48 @@
+namespace Admin.NET.Plugin.AiDOP.Entity.S8;
+
+/// <summary>
+/// S8 监控对象字典(订单交付/订单变更/供应商交付/IQC 检验/生产工单/库存等)。
+/// 全局基线 + 工厂覆盖:tenant_id=0/factory_id=0 表示 baseline,可按 (tenant_id,factory_id) 覆盖。
+/// CONFIG-MONITOR-DICT-READONLY-SEED-1:本轮只读字典,前端 wizard 替代 BUSINESS_MONITOR_OPTIONS。
+/// </summary>
+[SugarTable("ado_s8_monitor_object", "S8 监控对象字典")]
+[SugarIndex("uk_s8_monitor_object_tenant_factory_code", nameof(TenantId), OrderByType.Asc, nameof(FactoryId), OrderByType.Asc, nameof(ObjectCode), OrderByType.Asc, IsUnique = true)]
+[SugarIndex("idx_s8_monitor_object_tfe", nameof(TenantId), OrderByType.Asc, nameof(FactoryId), OrderByType.Asc, nameof(Enabled), OrderByType.Asc)]
+public class AdoS8MonitorObject
+{
+    [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "tenant_id", ColumnDataType = "bigint")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "factory_id", ColumnDataType = "bigint")]
+    public long FactoryId { get; set; }
+
+    /// <summary>对象编码(业务唯一),如 ORDER_DELIVERY / ORDER_CHANGE / PURCHASE_DELIVERY。</summary>
+    [SugarColumn(ColumnName = "object_code", Length = 64)]
+    public string ObjectCode { get; set; } = string.Empty;
+
+    /// <summary>对象类型分组:ORDER / PURCHASE_ORDER / WORK_ORDER / IQC / INVENTORY 等。</summary>
+    [SugarColumn(ColumnName = "object_type", Length = 64)]
+    public string ObjectType { get; set; } = string.Empty;
+
+    /// <summary>对象中文名(订单交付/订单变更/供应商交付/...)。</summary>
+    [SugarColumn(ColumnName = "object_name", Length = 128)]
+    public string ObjectName { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "enabled", ColumnDataType = "boolean")]
+    public bool Enabled { get; set; } = true;
+
+    [SugarColumn(ColumnName = "sort_no")]
+    public int SortNo { get; set; }
+
+    [SugarColumn(ColumnName = "remark", Length = 512, IsNullable = true)]
+    public string? Remark { get; set; }
+
+    [SugarColumn(ColumnName = "created_at")]
+    public DateTime CreatedAt { get; set; } = DateTime.Now;
+
+    [SugarColumn(ColumnName = "updated_at", IsNullable = true)]
+    public DateTime? UpdatedAt { get; set; }
+}

+ 150 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopMonitorDictionarySeed.cs

@@ -0,0 +1,150 @@
+using System.Diagnostics;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Infrastructure;
+
+/// <summary>
+/// CONFIG-MONITOR-DICT-READONLY-SEED-1:S8 监控对象/指标 baseline 种子。
+/// 6 个对象 + 10 条指标,与原前端 BUSINESS_MONITOR_OPTIONS 一一对应。
+/// 幂等:按 (tenant_id=0, factory_id=0, object_code/metric_code) Any() 检查后插入。
+/// RATIO 类指标 enabled=false + is_result_kpi=true(S9 结果 KPI 范畴),不进入演示路径。
+/// </summary>
+public static class AidopMonitorDictionarySeed
+{
+    private const string DefaultObjectIdField = "source_object_id";
+    private const string DefaultObjectCodeField = "related_object_code";
+    private const string DefaultObjectNameField = "related_object_name";
+
+    public static void EnsureSeed(ISqlSugarClient db)
+    {
+        try
+        {
+            var ct = DateTime.Parse("2026-05-09 00:00:00");
+
+            // 6 监控对象
+            EnsureObject(db, "ORDER_DELIVERY",         "ORDER",          "订单交付",      10, ct);
+            EnsureObject(db, "ORDER_CHANGE",           "ORDER",          "订单变更",      20, ct);
+            EnsureObject(db, "PURCHASE_DELIVERY",      "PURCHASE_ORDER", "供应商交付",    30, ct);
+            EnsureObject(db, "IQC_INSPECTION",         "IQC",            "IQC 检验",      40, ct);
+            EnsureObject(db, "WORK_ORDER_PRODUCTION",  "WORK_ORDER",     "生产工单",      50, ct);
+            EnsureObject(db, "INVENTORY_STOCK",        "INVENTORY",      "库存",          60, ct);
+
+            // DATE 指标 enabled=true / is_result_kpi=false
+            EnsureMetricDate(db, "ORDER_DUE_AT", "计划交付时间", "ORDER_DELIVERY",        "分钟", "due_at", "status", 110, ct);
+            EnsureMetricDate(db, "PO_DUE_AT",    "计划到货时间", "PURCHASE_DELIVERY",     "分钟", "due_at", "status", 310, ct);
+            EnsureMetricDate(db, "WO_DUE_AT",    "计划完工时间", "WORK_ORDER_PRODUCTION", "分钟", "due_at", "status", 510, ct);
+
+            // VALUE_RANGE 指标 enabled=true / is_result_kpi=false
+            EnsureMetricValueRange(db, "ORDER_CHANGE_COUNT", "订单变更次数", "ORDER_CHANGE",        "次",  "measured_value", 210, ct);
+            EnsureMetricValueRange(db, "IQC_VALUE",          "检验值",       "IQC_INSPECTION",      null, "measured_value", 410, ct);
+            EnsureMetricValueRange(db, "INV_QTY",            "当前库存量",   "INVENTORY_STOCK",     null, "measured_value", 610, ct);
+
+            // RATIO 指标 enabled=false / is_result_kpi=true(S9 结果 KPI 范畴;本轮 seed 入库但默认禁用)
+            EnsureMetricRatio(db, "ORDER_DELIVERY_RATE", "订单交付满足率", "ORDER_DELIVERY",        "%", "measured_value", 120, ct);
+            EnsureMetricRatio(db, "PO_DELIVERY_RATE",    "到货达成率",     "PURCHASE_DELIVERY",     "%", "measured_value", 320, ct);
+            EnsureMetricRatio(db, "IQC_PASS_RATE",       "检验合格率",     "IQC_INSPECTION",        "%", "measured_value", 420, ct);
+            EnsureMetricRatio(db, "WO_COMPLETION_RATE",  "工单完工率",     "WORK_ORDER_PRODUCTION", "%", "measured_value", 520, ct);
+        }
+        catch (Exception ex)
+        {
+            Trace.TraceWarning("AidopMonitorDictionarySeed: " + ex);
+        }
+    }
+
+    private static void EnsureObject(ISqlSugarClient db, string objectCode, string objectType, string objectName, int sortNo, DateTime ct)
+    {
+        var exists = db.Queryable<AdoS8MonitorObject>()
+            .Any(x => x.TenantId == 0 && x.FactoryId == 0 && x.ObjectCode == objectCode);
+        if (exists) return;
+
+        db.Insertable(new AdoS8MonitorObject
+        {
+            TenantId = 0,
+            FactoryId = 0,
+            ObjectCode = objectCode,
+            ObjectType = objectType,
+            ObjectName = objectName,
+            Enabled = true,
+            SortNo = sortNo,
+            CreatedAt = ct,
+        }).ExecuteCommand();
+    }
+
+    private static void EnsureMetricDate(ISqlSugarClient db, string metricCode, string metricName, string objectCode, string? unit, string dueAtField, string statusField, int sortNo, DateTime ct)
+    {
+        if (db.Queryable<AdoS8MonitorMetric>()
+            .Any(x => x.TenantId == 0 && x.FactoryId == 0 && x.MetricCode == metricCode)) return;
+
+        db.Insertable(new AdoS8MonitorMetric
+        {
+            TenantId = 0,
+            FactoryId = 0,
+            ObjectCode = objectCode,
+            MetricCode = metricCode,
+            MetricName = metricName,
+            Mechanism = "DATE",
+            Unit = unit,
+            DueAtField = dueAtField,
+            StatusField = statusField,
+            ObjectIdField = DefaultObjectIdField,
+            ObjectCodeField = DefaultObjectCodeField,
+            ObjectNameField = DefaultObjectNameField,
+            IsResultKpi = false,
+            Enabled = true,
+            SortNo = sortNo,
+            CreatedAt = ct,
+        }).ExecuteCommand();
+    }
+
+    private static void EnsureMetricValueRange(ISqlSugarClient db, string metricCode, string metricName, string objectCode, string? unit, string measuredValueField, int sortNo, DateTime ct)
+    {
+        if (db.Queryable<AdoS8MonitorMetric>()
+            .Any(x => x.TenantId == 0 && x.FactoryId == 0 && x.MetricCode == metricCode)) return;
+
+        db.Insertable(new AdoS8MonitorMetric
+        {
+            TenantId = 0,
+            FactoryId = 0,
+            ObjectCode = objectCode,
+            MetricCode = metricCode,
+            MetricName = metricName,
+            Mechanism = "VALUE_RANGE",
+            Unit = unit,
+            MeasuredValueField = measuredValueField,
+            ObjectIdField = DefaultObjectIdField,
+            ObjectCodeField = DefaultObjectCodeField,
+            ObjectNameField = DefaultObjectNameField,
+            IsResultKpi = false,
+            Enabled = true,
+            SortNo = sortNo,
+            CreatedAt = ct,
+        }).ExecuteCommand();
+    }
+
+    private static void EnsureMetricRatio(ISqlSugarClient db, string metricCode, string metricName, string objectCode, string? unit, string measuredValueField, int sortNo, DateTime ct)
+    {
+        if (db.Queryable<AdoS8MonitorMetric>()
+            .Any(x => x.TenantId == 0 && x.FactoryId == 0 && x.MetricCode == metricCode)) return;
+
+        db.Insertable(new AdoS8MonitorMetric
+        {
+            TenantId = 0,
+            FactoryId = 0,
+            ObjectCode = objectCode,
+            MetricCode = metricCode,
+            MetricName = metricName,
+            Mechanism = "RATIO",
+            Unit = unit,
+            MeasuredValueField = measuredValueField,
+            ObjectIdField = DefaultObjectIdField,
+            ObjectCodeField = DefaultObjectCodeField,
+            ObjectNameField = DefaultObjectNameField,
+            IsResultKpi = true,
+            Enabled = false,
+            SortNo = sortNo,
+            CreatedAt = ct,
+            Remark = "[RESERVED] S9 result KPI; enable explicitly when S8/S9 boundary task lands",
+        }).ExecuteCommand();
+    }
+}

+ 96 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8MonitorDictionaryService.cs

@@ -0,0 +1,96 @@
+using Admin.NET.Plugin.AiDOP.Dto.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
+
+/// <summary>
+/// S8 监控对象/指标只读字典服务。
+/// 合并策略:baseline (tenant_id=0/factory_id=0) + 当前 tenant/factory 覆盖;按 object_code/metric_code 去重。
+/// 仅返回 enabled=true 的对象与指标,且只返回有 enabled metric 的 object。
+/// </summary>
+public class S8MonitorDictionaryService : ITransient
+{
+    private readonly SqlSugarRepository<AdoS8MonitorObject> _objectRep;
+    private readonly SqlSugarRepository<AdoS8MonitorMetric> _metricRep;
+
+    public S8MonitorDictionaryService(
+        SqlSugarRepository<AdoS8MonitorObject> objectRep,
+        SqlSugarRepository<AdoS8MonitorMetric> metricRep)
+    {
+        _objectRep = objectRep;
+        _metricRep = metricRep;
+    }
+
+    public async Task<AdoS8MonitorOptionsResultDto> GetOptionsAsync(long tenantId, long factoryId)
+    {
+        var allObjects = await _objectRep.AsQueryable()
+            .Where(x => (x.TenantId == 0 && x.FactoryId == 0)
+                     || (x.TenantId == tenantId && x.FactoryId == factoryId))
+            .ToListAsync();
+
+        var allMetrics = await _metricRep.AsQueryable()
+            .Where(x => (x.TenantId == 0 && x.FactoryId == 0)
+                     || (x.TenantId == tenantId && x.FactoryId == factoryId))
+            .ToListAsync();
+
+        // tenant/factory 覆盖 baseline:同 object_code 优先取 tenant 行;同 metric_code 优先取 tenant 行。
+        var resolvedObjects = allObjects
+            .GroupBy(x => x.ObjectCode)
+            .Select(g => g.OrderByDescending(x => x.FactoryId).ThenByDescending(x => x.TenantId).First())
+            .Where(x => x.Enabled)
+            .OrderBy(x => x.SortNo)
+            .ThenBy(x => x.ObjectCode)
+            .ToList();
+
+        var resolvedMetrics = allMetrics
+            .GroupBy(x => x.MetricCode)
+            .Select(g => g.OrderByDescending(x => x.FactoryId).ThenByDescending(x => x.TenantId).First())
+            .Where(x => x.Enabled)
+            .OrderBy(x => x.SortNo)
+            .ThenBy(x => x.MetricCode)
+            .ToList();
+
+        var result = new AdoS8MonitorOptionsResultDto();
+
+        foreach (var obj in resolvedObjects)
+        {
+            var metrics = resolvedMetrics
+                .Where(m => m.ObjectCode == obj.ObjectCode)
+                .Select(m => new AdoS8MonitorMetricOptionDto
+                {
+                    MetricCode = m.MetricCode,
+                    MetricLabel = m.MetricName,
+                    Mechanism = m.Mechanism,
+                    Unit = m.Unit,
+                    DueAtField = m.DueAtField,
+                    StatusField = m.StatusField,
+                    MeasuredValueField = m.MeasuredValueField,
+                    ObjectIdField = m.ObjectIdField,
+                    ObjectCodeField = m.ObjectCodeField,
+                    ObjectNameField = m.ObjectNameField,
+                    IsResultKpi = m.IsResultKpi,
+                })
+                .ToList();
+
+            // 仅返回有 enabled metric 的 object
+            if (metrics.Count == 0) continue;
+
+            var mechanisms = metrics
+                .Select(x => x.Mechanism)
+                .Where(s => !string.IsNullOrWhiteSpace(s))
+                .Distinct()
+                .ToList();
+
+            result.Objects.Add(new AdoS8MonitorObjectOptionDto
+            {
+                ObjectCode = obj.ObjectCode,
+                ObjectType = obj.ObjectType,
+                ObjectLabel = obj.ObjectName,
+                Mechanisms = mechanisms,
+                Metrics = metrics,
+            });
+        }
+
+        return result;
+    }
+}

+ 3 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

@@ -140,6 +140,8 @@ public class Startup : AppStartup
                 typeof(AdoS8Dimension),
                 typeof(AdoS8DimensionNode),
                 typeof(AdoS8ConfigDraft),
+                typeof(AdoS8MonitorObject),
+                typeof(AdoS8MonitorMetric),
                 typeof(ContractReview),
                 typeof(ContractReviewFlow),
                 typeof(ProductDesign),
@@ -147,6 +149,7 @@ public class Startup : AppStartup
                 typeof(ProductDesignRouting)
             );
             AidopDimensionSeed.EnsureSeed(db);
+            AidopMonitorDictionarySeed.EnsureSeed(db);
         }
         catch (Exception ex)
         {