瀏覽代碼

feat(smart-ops): KPI atomic layer, global filter, and AutoVersionUpdate fix

Add ado_smart_ops_kpi_atomic_day build/query pipeline with S1-S9 aggregate rules, wire dashboard/diagnosis APIs and kanban scope labels, and rebuild AutoVersionUpdate with sys_db_migration_log, SHA256 checks, and same-session SQL execution for PREPARE/SET @ scripts.

chore: bump version Web 2.4.188 / server 1.0.162
Co-authored-by: Cursor <cursoragent@cursor.com>
skygu 3 月之前
父節點
當前提交
3e22265615
共有 29 個文件被更改,包括 4672 次插入 和 162 次删除
  1. 1 1
      Web/package.json
  2. 7 0
      Web/src/views/aidop/api/kanbanData.ts
  3. 34 1
      Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue
  4. 1 0
      Web/src/views/aidop/kanban/config/moduleDashboardRegistry.ts
  5. 45 0
      doc/db/mdp/06_kpi_atomic_agg_layer.sql
  6. 1556 0
      doc/plan/AutoVersionUpdate假成功彻底修复任务书.md
  7. 776 0
      doc/plan/KPI原子聚合层与全局筛选适配执行任务书.md
  8. 641 136
      server/Admin.NET.Core/Update/AutoVersionUpdate.cs
  9. 3 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  10. 44 11
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.DashboardPage.cs
  11. 23 2
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Diagnosis.cs
  12. 85 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.SmartOpsFilter.cs
  13. 15 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.SmartOpsFilterSearch.cs
  14. 26 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs
  15. 92 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiAtomicDay.cs
  16. 122 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopTenantMigration.cs
  17. 1 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Job/S1MdpSyncTransformJob.cs
  18. 1 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Job/S2MdpSyncTransformJob.cs
  19. 1 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Job/S3MdpSyncTransformJob.cs
  20. 64 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Job/SmartOpsKpiAtomicWarmupJob.cs
  21. 92 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Job/SmartOpsKpiMdpBootstrapJob.cs
  22. 7 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Order/S1MdpSyncTransformService.cs
  23. 7 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Production/S2MdpSyncTransformService.cs
  24. 103 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/SmartOpsDashboardFilter.cs
  25. 183 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/SmartOpsKpiAggregateRuleRegistry.cs
  26. 507 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/SmartOpsKpiAtomicBuildService.cs
  27. 187 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/SmartOpsKpiAtomicQueryService.cs
  28. 40 0
      server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/SmartOpsKpiQueryModels.cs
  29. 8 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Supply/S3MdpSyncTransformService.cs

+ 1 - 1
Web/package.json

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

+ 7 - 0
Web/src/views/aidop/api/kanbanData.ts

@@ -56,6 +56,10 @@ export interface DynamicDashboardMetric {
 	gapValue?: number | null;
 	gapLabel?: string | null;
 	trend: DynamicDashboardTrendPoint[];
+	filterMode?: string | null;
+	scopeLabel?: string | null;
+	unsupportedFilters?: string[];
+	isFiltered?: boolean;
 }
 
 export interface DynamicDashboardWidget {
@@ -90,7 +94,10 @@ export interface DynamicDashboardPagePayload {
 	dataSource?: {
 		metricDefinition?: string;
 		valueTables?: string[];
+		atomicTable?: string;
 		formulaStatus?: string;
+		scope?: string;
+		filterContext?: Record<string, string | null>;
 	};
 	metrics?: DynamicDashboardMetric[];
 	sections: DynamicDashboardSection[];

+ 34 - 1
Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue

@@ -20,6 +20,15 @@
 			<slot name="extra-fields" :query="extraQuery" />
 		</DetailQueryBar>
 
+		<el-alert
+			v-if="scopeLabel"
+			:type="scopeAlertType"
+			:closable="false"
+			show-icon
+			class="dynamic-dashboard__scope"
+			:title="scopeLabel"
+		/>
+
 		<el-alert
 			v-if="!loading && !page"
 			type="warning"
@@ -311,6 +320,26 @@ const activeL2MetricCode = ref('');
 const activeL3MetricCode = ref('');
 const pageTabs = computed(() => page.value?.tabs || []);
 const chatBIFilters = computed(() => buildExtraParams());
+const scopeLabel = computed(() => {
+	const scope = page.value?.dataSource?.scope;
+	if (!scope || scope === 'module_summary') return '';
+	switch (scope) {
+		case 'filtered_atomic':
+			return '当前 KPI 已按查询条件从原子聚合层计算';
+		case 'partial_filtered':
+			return '部分 KPI 已按查询条件过滤,其余仍为模块汇总口径';
+		case 'summary_only_fallback':
+			return '当前查询条件下暂无可过滤 KPI,展示模块汇总口径';
+		default:
+			return '';
+	}
+});
+const scopeAlertType = computed(() => {
+	const scope = page.value?.dataSource?.scope;
+	if (scope === 'filtered_atomic') return 'success';
+	if (scope === 'partial_filtered') return 'warning';
+	return 'info';
+});
 const chatBISuggestions = computed(() => moduleConfig.value.chatSuggestions || [`${moduleCode.value} 当前最严重的指标是什么?`]);
 const useTabs = computed(() => page.value?.navigationMode === 'tabs_by_l1' && pageTabs.value.length > 0);
 const useCardTabs = computed(() => page.value?.navigationMode === 'l1_cards' && pageTabs.value.length > 0);
@@ -409,7 +438,8 @@ async function refreshDashboard() {
 
 function onQuery() {
 	reload();
-	ElMessage.info(`已记录基础查询(${summarizeSmartOpsBaseQuery(baseQuery.value)});当前 KPI 卡片仍为模块汇总口径`);
+	const summary = summarizeSmartOpsBaseQuery(baseQuery.value);
+	ElMessage.info(summary ? `已应用查询条件(${summary})` : '已刷新看板数据');
 }
 
 function onReset() {
@@ -759,6 +789,9 @@ watch(page, () => renderChartsSoon());
 	overflow: auto;
 }
 
+.dynamic-dashboard__scope {
+	margin: 0 0 12px;
+}
 .dynamic-dashboard__header {
 	display: flex;
 	justify-content: space-between;

+ 1 - 0
Web/src/views/aidop/kanban/config/moduleDashboardRegistry.ts

@@ -34,6 +34,7 @@ export const moduleDashboardRegistry: Record<string, ModuleDashboardConfig> = {
 		enableChatBI: true,
 		chatTitle: 'S1 产销协同智能问答',
 		chatSuggestions: ['S1 当前最严重的指标是什么?', '订单评审周期为什么红了?', '下一步应该创建什么改善计划?'],
+		refreshMdp: () => refreshModuleMdp('S1'),
 	},
 	S2: {
 		moduleCode: 'S2',

+ 45 - 0
doc/db/mdp/06_kpi_atomic_agg_layer.sql

@@ -0,0 +1,45 @@
+-- 全局业务对象域 · KPI 最小颗粒度原子聚合层(日粒度)
+-- 只存可二次聚合的基础量,不存模块/客户/产品/时间范围等二次汇总结果。
+-- API 根据前端查询条件动态 SUM/GROUP BY 计算最终 KPI。
+
+SET NAMES utf8mb4;
+
+CREATE TABLE IF NOT EXISTS ado_smart_ops_kpi_atomic_day (
+    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+    tenant_id BIGINT NOT NULL COMMENT '租户ID',
+    factory_id BIGINT NOT NULL DEFAULT 1 COMMENT '工厂ID',
+    domain_code VARCHAR(50) NOT NULL COMMENT '业务对象域 order_delivery/supply_purchase/work_schedule/inventory',
+    metric_code VARCHAR(50) NOT NULL COMMENT '指标编码',
+    metric_level TINYINT NOT NULL DEFAULT 1 COMMENT '指标层级 1-4',
+    stat_date DATE NOT NULL COMMENT '统计日期',
+    customer_code VARCHAR(100) NOT NULL DEFAULT '' COMMENT '客户编码',
+    product_code VARCHAR(100) NOT NULL DEFAULT '' COMMENT '产品/物料编码',
+    order_no VARCHAR(100) NOT NULL DEFAULT '' COMMENT '订单号',
+    production_line VARCHAR(100) NOT NULL DEFAULT '' COMMENT '产线',
+    supplier_code VARCHAR(100) NOT NULL DEFAULT '' COMMENT '供应商编码',
+    material_code VARCHAR(100) NOT NULL DEFAULT '' COMMENT '物料编码',
+    po_no VARCHAR(100) NOT NULL DEFAULT '' COMMENT '采购单号',
+    warehouse_code VARCHAR(100) NOT NULL DEFAULT '' COMMENT '仓库编码',
+    work_order_no VARCHAR(100) NOT NULL DEFAULT '' COMMENT '工单号',
+    equipment_code VARCHAR(100) NOT NULL DEFAULT '' COMMENT '设备编码',
+    outbound_no VARCHAR(100) NOT NULL DEFAULT '' COMMENT '出库单号',
+    sum_value DECIMAL(18, 6) NULL COMMENT '可累加值',
+    numerator_value DECIMAL(18, 6) NULL COMMENT '比率分子',
+    denominator_value DECIMAL(18, 6) NULL COMMENT '比率分母',
+    sample_count INT NULL COMMENT '样本数(平均类)',
+    min_value DECIMAL(18, 6) NULL COMMENT '最小值',
+    max_value DECIMAL(18, 6) NULL COMMENT '最大值',
+    batch_id VARCHAR(100) NULL COMMENT 'MDP批次',
+    calc_time DATETIME(3) NULL COMMENT '计算时间',
+    grain VARCHAR(20) NOT NULL DEFAULT 'day' COMMENT '统计粒度 day/hour',
+    is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '删除标识',
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_atomic_grain (
+        tenant_id, factory_id, domain_code, metric_code, stat_date,
+        customer_code, product_code, order_no, production_line,
+        supplier_code, material_code, po_no, warehouse_code,
+        work_order_no, equipment_code, outbound_no
+    ),
+    KEY idx_tenant_domain_metric_date (tenant_id, domain_code, metric_code, stat_date),
+    KEY idx_tenant_module_lookup (tenant_id, factory_id, metric_code, stat_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KPI原子聚合层(日)';

+ 1556 - 0
doc/plan/AutoVersionUpdate假成功彻底修复任务书.md

@@ -0,0 +1,1556 @@
+# AutoVersionUpdate 假成功彻底修复任务书
+
+> 适用范围:`server/Admin.NET.Core/Update/AutoVersionUpdate.cs` 与入口项目 `server/Admin.NET.Web.Entry/UpdateScripts/` 版本脚本执行链路  
+> 目标读者:后端开发、大语言模型执行代理、测试/发布负责人  
+> 编写日期:2026-06-11  
+> 任务类型:后端基础设施修复 / 数据库迁移执行可靠性治理
+
+---
+
+## 1. 背景与问题定义
+
+当前 Admin.NET 启动阶段通过 `UseAutoVersionUpdate()` 自动执行入口项目运行目录下的 `UpdateScripts/*.sql`。该机制负责把版本 SQL 脚本落到数据库中,项目内 S1/S2/S3/S4/S8 等多处表结构、种子数据、MDP 实体登记均依赖此链路。
+
+现状存在“假成功”风险:
+
+- SQL 文件被读取,日志显示“执行版本 xxx 脚本”。
+- 启动日志最终显示 `AutoVersionUpdate 中间件结束`。
+- 运行目录 `version.txt` 被写为 `{version}^{datetime}^True`。
+- 但数据库中对应表、字段、数据、实体登记并未真实落库,或只有部分语句落库。
+
+本任务要求彻底修复执行与成功判定逻辑,使版本脚本执行链路满足:
+
+- 未执行的脚本不会被跳过。
+- 脚本失败不会被标记成功。
+- 可追踪每个脚本的执行状态、错误、校验结果。
+- 可从数据库真实状态判断迁移是否完成。
+- 兼容现有 `version.txt`,但不继续依赖它作为唯一事实来源。
+
+---
+
+## 2. 当前源码位置与关键风险
+
+### 2.1 入口位置
+
+- `server/Admin.NET.Web.Core/Startup.cs`
+  - `Configure()` 中调用 `app.UseAutoVersionUpdate();`
+
+- `server/Admin.NET.Core/Update/AutoVersionUpdate.cs`
+  - 当前所有自动版本脚本逻辑位于此文件。
+
+### 2.2 当前核心逻辑
+
+现有逻辑大致为:
+
+1. 仅 `SnowId:WorkerId == 1` 的主节点执行。
+2. 读取入口程序集版本:`ReflectionHelper.GetEntryAssemblyVersion().ToString(3)`。
+3. 读取运行目录 `version.txt`。
+4. 若 `version.txt` 不存在或格式不对,视为历史版本为空。
+5. 从运行目录 `UpdateScripts/*.sql` 读取脚本。
+6. 对每个脚本执行:
+   - `dbContext.Ado.BeginTran();`
+   - `dbContext.Ado.ExecuteCommand(sql);`
+   - `dbContext.Ado.CommitTran();`
+   - 未抛异常则写 `version.txt = {sqlVersion}^{time}^True`。
+
+### 2.3 已确认风险点
+
+#### 风险 1:首次启动跳过所有历史脚本
+
+当前逻辑中,当 `version.txt` 不存在时:
+
+```csharp
+if (historyVersion == string.Empty)
+{
+    LogHelper.Handle("历史版本为空,默认为最新版本,不执行脚本");
+    SetEntryAssemblyCurrentVersion(currentVersion, true);
+    return app;
+}
+```
+
+这会导致新环境首次启动直接标记当前版本已执行,但 `UpdateScripts` 里的历史脚本全部未跑。
+
+#### 风险 2:当前版本高于最新脚本时跳过未执行脚本
+
+当前逻辑:
+
+```csharp
+if (scriptSqlVersions.All(s => s.Version.CompareTo(currentVersion) < 0))
+{
+    LogHelper.Handle("不存在当前版本的脚本,只保存当前版本信息,不执行脚本");
+    SetEntryAssemblyCurrentVersion(currentVersion, false);
+    return app;
+}
+```
+
+如果入口项目版本为 `1.0.161`,而 `UpdateScripts` 最新是 `1.0.158`,即使历史版本是 `1.0.154`,`1.0.155` 到 `1.0.158` 也可能被整体跳过。
+
+#### 风险 3:成功判定只看 `ExecuteCommand` 是否抛异常
+
+没有验证:
+
+- 表是否存在。
+- 字段是否存在。
+- 种子数据是否写入。
+- UPDATE 是否命中目标行。
+- 脚本中多条语句是否全部执行。
+- 执行库是否为预期库。
+
+#### 风险 4:使用字符串比较版本号
+
+当前使用 `string.CompareTo`。这对语义版本不安全,例如:
+
+- `1.0.9` 与 `1.0.10`
+- `1.0.158` 与 `1.0.99`
+
+应改为 `System.Version` 或等价 SemVer 比较。
+
+#### 风险 5:`version.txt` 是单点状态,且不在数据库内
+
+`version.txt` 只在本机运行目录中,无法反映:
+
+- 多节点真实执行状态。
+- 当前连接数据库是否已迁移。
+- 脚本 checksum 是否变化。
+- 某个版本脚本是否失败后重跑。
+
+#### 风险 6:MySQL DDL 与事务边界不可靠
+
+MySQL `ALTER TABLE` / `CREATE TABLE` 等 DDL 通常会隐式提交,不能依赖 `BeginTran/CommitTran` 保证整文件原子性。
+
+---
+
+## 3. 修复目标
+
+### 3.1 必须达成
+
+1. `version.txt` 不存在时,不得跳过历史脚本。
+2. 当前版本高于最新脚本时,仍应执行所有历史版本之后、当前版本之前或等于当前版本的未执行脚本。
+3. 每个脚本必须有数据库内迁移记录。
+4. 脚本失败必须记录失败状态,不得写 `version.txt=True`。
+5. 成功状态必须基于:
+   - SQL 执行无异常;
+   - 迁移记录写入成功;
+   - 若存在校验规则,校验通过。
+6. 版本比较必须使用语义版本比较。
+7. 日志必须能定位:
+   - 执行的脚本文件;
+   - 当前数据库;
+   - 脚本 checksum;
+   - 执行耗时;
+   - 失败语句或失败校验。
+
+### 3.2 建议达成
+
+1. 支持脚本校验文件或脚本内校验注释。
+2. 支持空脚本、注释脚本跳过但记录。
+3. 支持失败后下次启动自动重试。
+4. 支持重复启动幂等跳过已成功且 checksum 未变化的脚本。
+5. 支持发现同版本脚本 checksum 变化时报错,避免已执行脚本被静默篡改。
+
+---
+
+## 4. 设计方案
+
+### 4.1 新增数据库迁移记录表
+
+建议表名:`sys_db_migration_log`
+
+DDL 由代码启动时幂等创建,不依赖 `UpdateScripts` 自己创建。
+
+```sql
+CREATE TABLE IF NOT EXISTS sys_db_migration_log (
+    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+    version VARCHAR(50) NOT NULL COMMENT '脚本版本',
+    file_name VARCHAR(255) NOT NULL COMMENT '脚本文件名',
+    file_hash VARCHAR(128) NOT NULL COMMENT '脚本 SHA256',
+    status VARCHAR(20) NOT NULL COMMENT 'Running/Success/Failed/Skipped',
+    started_at DATETIME(3) NOT NULL COMMENT '开始时间',
+    finished_at DATETIME(3) NULL COMMENT '结束时间',
+    elapsed_ms BIGINT NULL COMMENT '耗时毫秒',
+    statement_count INT NOT NULL DEFAULT 0 COMMENT '执行语句数',
+    affected_rows BIGINT NULL COMMENT '影响行数合计',
+    verify_status VARCHAR(20) NULL COMMENT '校验状态:NotConfigured/Success/Failed',
+    error_message LONGTEXT NULL COMMENT '错误信息',
+    database_name VARCHAR(128) NULL COMMENT '执行数据库',
+    created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+    updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_sys_db_migration_log_version (version),
+    KEY idx_sys_db_migration_log_status (status),
+    KEY idx_sys_db_migration_log_started_at (started_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库版本脚本执行记录';
+```
+
+说明:
+
+- `version` 唯一,保持每个版本脚本只成功一次。
+- 如果同版本文件 hash 与历史成功记录不一致,应停止启动并报错。
+- 若历史状态为 `Failed`,允许重试并覆盖状态或新增 retry 字段。为简化,建议更新同一行。
+
+### 4.2 版本状态事实来源
+
+优先级:
+
+1. 数据库表 `sys_db_migration_log` 中 `status='Success'` 的版本记录。
+2. 兼容读取 `version.txt`,仅作为迁移表为空时的旧状态参考。
+
+不得再只依赖 `version.txt` 判断脚本是否执行。
+
+### 4.3 脚本选择逻辑
+
+输入:
+
+- `currentVersion`:入口程序集版本。
+- `scriptVersions`:`UpdateScripts/*.sql` 中所有版本脚本。
+- `successVersions`:迁移记录表中成功版本。
+- `historyVersionFromTxt`:旧 `version.txt` 中记录的版本。
+
+应执行脚本集合:
+
+1. 脚本版本 `<= currentVersion`。
+2. 数据库迁移表中不存在 `Success` 记录。
+3. 若迁移表为空但 `version.txt` 存在且 `isRunScript=True`,可将 `<= historyVersionFromTxt` 的脚本视为 legacy 已成功,但建议只作为兼容策略,并打印警告。
+4. 不要求存在与 `currentVersion` 同名的 SQL 文件。
+
+伪代码:
+
+```csharp
+var current = Version.Parse(currentVersion);
+var scripts = GetScriptSqlVersions()
+    .Where(x => Version.Parse(x.Version) <= current)
+    .OrderBy(x => Version.Parse(x.Version))
+    .ToList();
+
+foreach (var script in scripts)
+{
+    if (MigrationAlreadySucceeded(script))
+        continue;
+
+    ExecuteOneScript(script);
+}
+```
+
+### 4.4 版本比较
+
+必须新增统一比较方法:
+
+```csharp
+private static Version ParseVersion(string value)
+{
+    if (!Version.TryParse(value, out var version))
+        throw new InvalidOperationException($"非法版本号:{value}");
+    return version;
+}
+```
+
+若项目后续出现 `1.0.158-alpha`,再引入 SemVer 库;当前脚本命名均为 `x.y.z`,`System.Version` 足够。
+
+### 4.5 SQL 执行方式
+
+#### 基线方案
+
+保留整文件执行能力,但必须增强结果判断与日志。
+
+```csharp
+var affected = db.Ado.ExecuteCommand(sql);
+```
+
+注意:这仍无法精确定位多语句中的哪条失败,但能先消除“无记录、无校验”的问题。
+
+#### 推荐方案
+
+实现 SQL 脚本分割器,按语句执行:
+
+- 支持 `--` 单行注释。
+- 支持 `#` 单行注释。
+- 支持 `/* ... */` 块注释。
+- 支持字符串中的分号不切分。
+- 支持普通 MySQL `SET / PREPARE / EXECUTE / DEALLOCATE / CREATE / ALTER / INSERT / UPDATE`。
+- 暂不要求支持 `DELIMITER` 与存储过程;若发现脚本含 `DELIMITER`,应报错并提示手工执行或扩展执行器。
+
+执行策略:
+
+- 普通 DML 可在事务中执行。
+- DDL 不依赖事务回滚,失败即停。
+- 每条语句执行前后记录序号。
+- 任一语句失败,则该脚本 `Failed`,不继续后续脚本。
+
+### 4.6 成功判定
+
+脚本成功必须满足:
+
+1. 所有待执行 SQL 语句无异常。
+2. 迁移记录表该版本写入 `Success`。
+3. 若存在校验 SQL,全部校验通过。
+
+`version.txt` 只在上述三项成功后更新。
+
+### 4.7 校验机制
+
+建议支持两种方式,至少实现一种。
+
+#### 方式 A:旁路校验文件
+
+同版本脚本旁新增可选文件:
+
+```text
+UpdateScripts/1.0.158.sql
+UpdateScripts/1.0.158.verify.sql
+```
+
+`*.verify.sql` 可包含一条或多条 SELECT,每条必须返回第一行第一列为 truthy:
+
+- `1`
+- `true`
+- 大于 0 的数字
+
+示例:
+
+```sql
+SELECT COUNT(*) > 0
+FROM information_schema.TABLES
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME = 'dwd_po_trans';
+```
+
+#### 方式 B:脚本内校验注释
+
+在 SQL 文件末尾约定:
+
+```sql
+-- VERIFY:
+-- SELECT COUNT(*) > 0 FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='xxx';
+```
+
+实现复杂度高于方式 A。建议本轮优先实现方式 A。
+
+### 4.8 日志要求
+
+启动日志至少包含:
+
+- 当前程序集版本。
+- 当前数据库名:`SELECT DATABASE()`。
+- 发现脚本数量。
+- 已成功脚本数量。
+- 待执行脚本数量。
+- 每个脚本:
+  - 版本;
+  - 文件名;
+  - SHA256;
+  - 语句数;
+  - affected rows;
+  - verify 状态;
+  - elapsed ms。
+
+失败时日志必须包含:
+
+- 脚本版本;
+- 语句序号;
+- SQL 片段前 300 字符;
+- 异常 message;
+- 内部异常 message。
+
+### 4.9 启动失败策略
+
+推荐:版本脚本失败应阻止应用继续启动。
+
+理由:
+
+- 数据库结构与代码版本不匹配时继续启动更危险。
+- 当前“继续启动”导致隐藏问题。
+
+实现:
+
+- `HandleSqlScript` 失败后抛出异常。
+- `UseAutoVersionUpdate()` 不吞异常。
+
+如果项目负责人要求不中断启动,可加配置:
+
+```json
+"AutoVersionUpdate": {
+  "StopApplicationOnFailure": true
+}
+```
+
+默认必须为 `true`。
+
+---
+
+## 5. 具体执行步骤
+
+### Step 1:备份当前文件
+
+阅读并理解:
+
+- `server/Admin.NET.Core/Update/AutoVersionUpdate.cs`
+- `server/Admin.NET.Web.Core/Startup.cs`
+- `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj`
+- `server/Admin.NET.Web.Entry/UpdateScripts/*.sql`
+
+不要改动业务插件代码。
+
+### Step 2:新增内部模型
+
+在 `AutoVersionUpdate.cs` 中新增私有模型:
+
+```csharp
+private sealed record MigrationScript(
+    string Version,
+    Version ParsedVersion,
+    string FilePath,
+    string FileName,
+    string Hash);
+
+private sealed class MigrationLogRow
+{
+    public long Id { get; set; }
+    public string Version { get; set; } = string.Empty;
+    public string FileHash { get; set; } = string.Empty;
+    public string Status { get; set; } = string.Empty;
+}
+```
+
+也可独立文件,但为减少影响范围,建议本轮集中在 `AutoVersionUpdate.cs`。
+
+### Step 3:确保迁移记录表
+
+在执行脚本前调用:
+
+```csharp
+EnsureMigrationLogTable(dbContext);
+```
+
+该方法必须执行 `CREATE TABLE IF NOT EXISTS sys_db_migration_log (...)`。
+
+### Step 4:替换版本比较与脚本筛选
+
+废弃:
+
+```csharp
+string.CompareTo(...)
+```
+
+改为:
+
+```csharp
+Version.Parse(...)
+```
+
+修复以下逻辑:
+
+- `version.txt` 为空不再直接返回。
+- 当前版本没有同名脚本不再直接返回。
+- 执行所有 `scriptVersion <= currentVersion && !success` 的脚本。
+
+### Step 5:实现 hash
+
+使用 SHA256:
+
+```csharp
+private static string ComputeSha256(string filePath)
+{
+    using var sha = SHA256.Create();
+    using var stream = File.OpenRead(filePath);
+    return Convert.ToHexString(sha.ComputeHash(stream));
+}
+```
+
+需要添加:
+
+```csharp
+using System.Security.Cryptography;
+```
+
+### Step 6:实现执行前记录 Running
+
+执行脚本前 upsert:
+
+- 若无记录:插入 `Running`。
+- 若 `Failed`:更新为 `Running`,清空错误。
+- 若 `Success` 且 hash 相同:跳过。
+- 若 `Success` 且 hash 不同:抛异常,阻止启动。
+
+### Step 7:执行 SQL
+
+优先实现分语句执行。若时间不足,可第一版保留整文件执行,但必须:
+
+- 写迁移记录;
+- 失败抛异常;
+- 支持 verify;
+- 不再跳过未执行脚本。
+
+推荐接口:
+
+```csharp
+private static ScriptExecuteResult ExecuteSqlScript(ISqlSugarClient db, string sql)
+```
+
+返回:
+
+```csharp
+private sealed record ScriptExecuteResult(int StatementCount, long AffectedRows);
+```
+
+### Step 8:执行校验 SQL
+
+寻找同目录:
+
+```csharp
+var verifyPath = Path.ChangeExtension(script.FilePath, ".verify.sql");
+```
+
+注意 `Path.ChangeExtension("1.0.158.sql", ".verify.sql")` 会得到 `1.0.158.verify.sql`。
+
+若存在,逐条执行 SELECT 校验。
+
+校验失败:
+
+- 记录 `verify_status='Failed'`;
+- 记录错误;
+- 抛异常;
+- 不写 `version.txt=True`。
+
+若不存在:
+
+- `verify_status='NotConfigured'`。
+
+### Step 9:执行成功后更新迁移记录
+
+成功后更新:
+
+- `status='Success'`
+- `finished_at`
+- `elapsed_ms`
+- `statement_count`
+- `affected_rows`
+- `verify_status`
+- `error_message=NULL`
+- `database_name=SELECT DATABASE()`
+
+### Step 10:更新 `version.txt`
+
+只有当本轮所有应执行脚本成功后,写:
+
+```text
+{currentVersion}^{now}^{True}
+```
+
+或者在每个脚本成功后写脚本版本也可,但推荐最终写当前程序集版本,数据库真实状态以 `sys_db_migration_log` 为准。
+
+### Step 11:失败处理
+
+任一脚本失败:
+
+1. 更新迁移记录 `Failed`。
+2. 写入错误信息。
+3. 不更新 `version.txt=True`。
+4. 抛出异常,使启动失败。
+
+### Step 12:兼容旧 `version.txt`
+
+如果迁移表为空但 `version.txt` 存在:
+
+- 读取 `version.txt`。
+- 若 `isRunScript=True`,可将 `<= historyVersion` 的脚本视为 legacy 已成功,但不要自动批量插入 `Success`,避免伪造历史。
+- 日志提示:
+
+```text
+检测到 sys_db_migration_log 为空但 version.txt 存在,将 version.txt 作为 legacy 跳过边界:{historyVersion}
+```
+
+更严格方案:
+
+- 不信任 `version.txt`,全部重新跑幂等脚本。
+- 但历史脚本不一定全部幂等,风险较大。
+
+本任务建议采用兼容边界方案。
+
+---
+
+## 6. 测试方案
+
+### 6.1 单元/伪单元测试(可手动)
+
+至少覆盖:
+
+1. 版本排序:
+   - `1.0.9 < 1.0.10`
+   - `1.0.158 < 1.0.161`
+2. 当前版本 `1.0.161`,脚本最新 `1.0.158`,历史成功到 `1.0.154`:
+   - 应执行 `1.0.155`、`1.0.157`、`1.0.158` 等未成功脚本。
+3. `version.txt` 不存在:
+   - 不得直接跳过所有脚本。
+4. 同版本 hash 变化:
+   - 必须报错。
+
+### 6.2 本地集成测试
+
+#### 测试 A:首次启动不跳过
+
+1. 备份并删除运行目录 `version.txt`。
+2. 清空或新建测试库的 `sys_db_migration_log`。
+3. 放一个测试脚本:
+
+```sql
+CREATE TABLE IF NOT EXISTS auto_version_update_test (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT,
+    code VARCHAR(50) NOT NULL
+);
+INSERT INTO auto_version_update_test(code)
+SELECT 'OK'
+WHERE NOT EXISTS (SELECT 1 FROM auto_version_update_test WHERE code='OK');
+```
+
+4. 启动后验证:
+
+```sql
+SELECT COUNT(*) FROM auto_version_update_test WHERE code='OK';
+SELECT version, status FROM sys_db_migration_log ORDER BY version;
+```
+
+#### 测试 B:失败不标成功
+
+添加一个故意失败脚本:
+
+```sql
+ALTER TABLE not_exists_table ADD COLUMN x INT;
+```
+
+预期:
+
+- 启动失败。
+- `sys_db_migration_log.status='Failed'`。
+- `version.txt` 不写 `True` 到该版本。
+
+#### 测试 C:verify 失败不标成功
+
+脚本成功但 `.verify.sql` 返回 false:
+
+```sql
+SELECT 0;
+```
+
+预期同失败。
+
+#### 测试 D:当前版本高于最新脚本
+
+入口项目版本保持 `1.0.161`,脚本最新 `1.0.158`。
+
+构造历史成功到 `1.0.154`。
+
+预期:
+
+- `1.0.155` 到 `1.0.158` 中未成功脚本会执行。
+- 不因“没有 1.0.161.sql”而直接返回。
+
+### 6.3 生产/预发验证 SQL
+
+执行后检查:
+
+```sql
+SELECT version, file_name, status, verify_status, started_at, finished_at, elapsed_ms
+FROM sys_db_migration_log
+ORDER BY version;
+```
+
+针对关键版本脚本补充业务校验,例如:
+
+```sql
+-- 表是否存在
+SELECT COUNT(*)
+FROM information_schema.TABLES
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME = '目标表名';
+
+-- 字段是否存在
+SELECT COUNT(*)
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME = '目标表名'
+  AND COLUMN_NAME = '目标字段';
+
+-- 种子数据是否存在
+SELECT COUNT(*)
+FROM 目标表
+WHERE 业务唯一条件;
+```
+
+---
+
+## 7. 验收标准
+
+### 7.1 功能验收
+
+- [ ] 无 `version.txt` 的新环境启动时,不再跳过历史脚本。
+- [ ] 当前程序集版本高于最新脚本时,仍执行所有未成功脚本。
+- [ ] 任一脚本执行失败时,启动失败或至少明确失败,不写成功状态。
+- [ ] `sys_db_migration_log` 有每个脚本的状态记录。
+- [ ] 成功脚本重启后不重复执行。
+- [ ] 已成功脚本被修改 hash 后,启动阻断并提示。
+- [ ] 可选 `.verify.sql` 失败时,脚本不标成功。
+- [ ] 日志能定位失败脚本和失败语句。
+
+### 7.2 回归验收
+
+- [ ] `dotnet build server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 通过。
+- [ ] 后端可正常启动。
+- [ ] 旧的 `UpdateScripts` 不因新执行器误拆分而报错。
+- [ ] WorkerId 非 1 的节点仍不执行脚本。
+- [ ] 现有 `version.txt` 兼容读取。
+
+---
+
+## 8. 风险与注意事项
+
+1. 历史脚本不一定全部幂等,不要贸然在生产全量重跑所有历史脚本。
+2. 首次引入 `sys_db_migration_log` 时,要明确如何处理已有环境:
+   - 推荐以 `version.txt` 作为 legacy 边界;
+   - 对边界之后的脚本按新机制执行。
+3. MySQL `DELIMITER` / 存储过程脚本若存在,普通分号拆分器会失败。当前脚本初查主要为普通 DDL/DML 与 `PREPARE/EXECUTE`,但执行前仍需扫描。
+4. 不要在本任务中顺手改业务脚本内容,除非脚本本身语法导致新执行器无法执行。
+5. 失败后不要手动把 `version.txt` 改成 True 掩盖问题,应修复脚本或校验。
+
+---
+
+## 9. 建议文件改动清单
+
+### 必改
+
+- `server/Admin.NET.Core/Update/AutoVersionUpdate.cs`
+  - 重写脚本选择、版本比较、执行记录、成功判定、失败处理。
+
+### 可选新增
+
+- `server/Admin.NET.Web.Entry/UpdateScripts/*.verify.sql`
+  - 为关键脚本增加验证。
+
+### 不建议本任务改
+
+- 业务插件实体/服务。
+- 前端代码。
+- 现有 SQL 脚本业务内容。
+
+---
+
+## 10. 给执行模型的最终指令
+
+请严格按以下顺序执行:
+
+1. 读取并理解 `AutoVersionUpdate.cs` 当前实现。
+2. 不修改业务逻辑,先重构版本脚本执行器。
+3. 新增 `sys_db_migration_log` 幂等建表逻辑。
+4. 改掉 `version.txt` 不存在时跳过脚本的逻辑。
+5. 改掉“当前版本无同名脚本则跳过全部”的逻辑。
+6. 改用 `System.Version` 比较版本。
+7. 增加 SHA256 hash 与已成功脚本 hash 校验。
+8. 增加 Running/Success/Failed 迁移记录。
+9. 增加可选 `.verify.sql` 校验。
+10. 失败时抛异常并阻止启动,不写成功状态。
+11. 构建验证。
+12. 用临时测试脚本或测试库验证首次启动、失败脚本、verify 失败、当前版本高于最新脚本四类场景。
+
+执行完成后输出:
+
+- 改动文件列表。
+- 核心逻辑说明。
+- 已执行测试。
+- 尚未覆盖的风险。
+- 是否需要为关键历史脚本补 `.verify.sql`。
+
+---
+
+## 11. 推荐后续治理
+
+1. 对所有重要 `UpdateScripts` 补充 `.verify.sql`。
+2. 禁止只依赖 `version.txt` 作为发布验收依据。
+3. 发布验收模板增加:
+
+```sql
+SELECT version, status, verify_status, finished_at
+FROM sys_db_migration_log
+ORDER BY version DESC
+LIMIT 20;
+```
+
+4. 对 DDL 类脚本要求幂等。
+5. 对数据种子脚本要求唯一键或 `INSERT ... ON DUPLICATE KEY UPDATE`。
+6. 对非幂等脚本必须在脚本头注明风险与回滚方式。
+# AutoVersionUpdate 假成功彻底修复任务书
+
+> 适用范围:`server/Admin.NET.Core/Update/AutoVersionUpdate.cs` 与入口项目 `server/Admin.NET.Web.Entry/UpdateScripts/` 版本脚本执行链路  
+> 目标读者:后端开发、大语言模型执行代理、测试/发布负责人  
+> 编写日期:2026-06-11  
+> 任务类型:后端基础设施修复 / 数据库迁移执行可靠性治理
+
+---
+
+## 1. 背景与问题定义
+
+当前 Admin.NET 启动阶段通过 `UseAutoVersionUpdate()` 自动执行入口项目运行目录下的 `UpdateScripts/*.sql`。该机制负责把版本 SQL 脚本落到数据库中,项目内 S1/S2/S3/S4/S8 等多处表结构、种子数据、MDP 实体登记均依赖此链路。
+
+现状存在“假成功”风险:
+
+- SQL 文件被读取,日志显示“执行版本 xxx 脚本”。
+- 启动日志最终显示 `AutoVersionUpdate 中间件结束`。
+- 运行目录 `version.txt` 被写为 `{version}^{datetime}^True`。
+- 但数据库中对应表、字段、数据、实体登记并未真实落库,或只有部分语句落库。
+
+本任务要求彻底修复执行与成功判定逻辑,使版本脚本执行链路满足:
+
+- 未执行的脚本不会被跳过。
+- 脚本失败不会被标记成功。
+- 可追踪每个脚本的执行状态、错误、校验结果。
+- 可从数据库真实状态判断迁移是否完成。
+- 兼容现有 `version.txt`,但不继续依赖它作为唯一事实来源。
+
+---
+
+## 2. 当前源码位置与关键风险
+
+### 2.1 入口位置
+
+- `server/Admin.NET.Web.Core/Startup.cs`
+  - `Configure()` 中调用 `app.UseAutoVersionUpdate();`
+
+- `server/Admin.NET.Core/Update/AutoVersionUpdate.cs`
+  - 当前所有自动版本脚本逻辑位于此文件。
+
+### 2.2 当前核心逻辑
+
+现有逻辑大致为:
+
+1. 仅 `SnowId:WorkerId == 1` 的主节点执行。
+2. 读取入口程序集版本:`ReflectionHelper.GetEntryAssemblyVersion().ToString(3)`。
+3. 读取运行目录 `version.txt`。
+4. 若 `version.txt` 不存在或格式不对,视为历史版本为空。
+5. 从运行目录 `UpdateScripts/*.sql` 读取脚本。
+6. 对每个脚本执行:
+   - `dbContext.Ado.BeginTran();`
+   - `dbContext.Ado.ExecuteCommand(sql);`
+   - `dbContext.Ado.CommitTran();`
+   - 未抛异常则写 `version.txt = {sqlVersion}^{time}^True`。
+
+### 2.3 已确认风险点
+
+#### 风险 1:首次启动跳过所有历史脚本
+
+当前逻辑中,当 `version.txt` 不存在时:
+
+```csharp
+if (historyVersion == string.Empty)
+{
+    LogHelper.Handle("历史版本为空,默认为最新版本,不执行脚本");
+    SetEntryAssemblyCurrentVersion(currentVersion, true);
+    return app;
+}
+```
+
+这会导致新环境首次启动直接标记当前版本已执行,但 `UpdateScripts` 里的历史脚本全部未跑。
+
+#### 风险 2:当前版本高于最新脚本时跳过未执行脚本
+
+当前逻辑:
+
+```csharp
+if (scriptSqlVersions.All(s => s.Version.CompareTo(currentVersion) < 0))
+{
+    LogHelper.Handle("不存在当前版本的脚本,只保存当前版本信息,不执行脚本");
+    SetEntryAssemblyCurrentVersion(currentVersion, false);
+    return app;
+}
+```
+
+如果入口项目版本为 `1.0.161`,而 `UpdateScripts` 最新是 `1.0.158`,即使历史版本是 `1.0.154`,`1.0.155` 到 `1.0.158` 也可能被整体跳过。
+
+#### 风险 3:成功判定只看 `ExecuteCommand` 是否抛异常
+
+没有验证:
+
+- 表是否存在。
+- 字段是否存在。
+- 种子数据是否写入。
+- UPDATE 是否命中目标行。
+- 脚本中多条语句是否全部执行。
+- 执行库是否为预期库。
+
+#### 风险 4:使用字符串比较版本号
+
+当前使用 `string.CompareTo`。这对语义版本不安全,例如:
+
+- `1.0.9` 与 `1.0.10`
+- `1.0.158` 与 `1.0.99`
+
+应改为 `System.Version` 或等价 SemVer 比较。
+
+#### 风险 5:`version.txt` 是单点状态,且不在数据库内
+
+`version.txt` 只在本机运行目录中,无法反映:
+
+- 多节点真实执行状态。
+- 当前连接数据库是否已迁移。
+- 脚本 checksum 是否变化。
+- 某个版本脚本是否失败后重跑。
+
+#### 风险 6:MySQL DDL 与事务边界不可靠
+
+MySQL `ALTER TABLE` / `CREATE TABLE` 等 DDL 通常会隐式提交,不能依赖 `BeginTran/CommitTran` 保证整文件原子性。
+
+---
+
+## 3. 修复目标
+
+### 3.1 必须达成
+
+1. `version.txt` 不存在时,不得跳过历史脚本。
+2. 当前版本高于最新脚本时,仍应执行所有历史版本之后、当前版本之前或等于当前版本的未执行脚本。
+3. 每个脚本必须有数据库内迁移记录。
+4. 脚本失败必须记录失败状态,不得写 `version.txt=True`。
+5. 成功状态必须基于:
+   - SQL 执行无异常;
+   - 迁移记录写入成功;
+   - 若存在校验规则,校验通过。
+6. 版本比较必须使用语义版本比较。
+7. 日志必须能定位:
+   - 执行的脚本文件;
+   - 当前数据库;
+   - 脚本 checksum;
+   - 执行耗时;
+   - 失败语句或失败校验。
+
+### 3.2 建议达成
+
+1. 支持脚本校验文件或脚本内校验注释。
+2. 支持空脚本、注释脚本跳过但记录。
+3. 支持失败后下次启动自动重试。
+4. 支持重复启动幂等跳过已成功且 checksum 未变化的脚本。
+5. 支持发现同版本脚本 checksum 变化时报错,避免已执行脚本被静默篡改。
+
+---
+
+## 4. 设计方案
+
+### 4.1 新增数据库迁移记录表
+
+建议表名:`sys_db_migration_log`
+
+DDL 由代码启动时幂等创建,不依赖 `UpdateScripts` 自己创建。
+
+```sql
+CREATE TABLE IF NOT EXISTS sys_db_migration_log (
+    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+    version VARCHAR(50) NOT NULL COMMENT '脚本版本',
+    file_name VARCHAR(255) NOT NULL COMMENT '脚本文件名',
+    file_hash VARCHAR(128) NOT NULL COMMENT '脚本 SHA256',
+    status VARCHAR(20) NOT NULL COMMENT 'Running/Success/Failed/Skipped',
+    started_at DATETIME(3) NOT NULL COMMENT '开始时间',
+    finished_at DATETIME(3) NULL COMMENT '结束时间',
+    elapsed_ms BIGINT NULL COMMENT '耗时毫秒',
+    statement_count INT NOT NULL DEFAULT 0 COMMENT '执行语句数',
+    affected_rows BIGINT NULL COMMENT '影响行数合计',
+    verify_status VARCHAR(20) NULL COMMENT '校验状态:NotConfigured/Success/Failed',
+    error_message LONGTEXT NULL COMMENT '错误信息',
+    database_name VARCHAR(128) NULL COMMENT '执行数据库',
+    created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+    updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_sys_db_migration_log_version (version),
+    KEY idx_sys_db_migration_log_status (status),
+    KEY idx_sys_db_migration_log_started_at (started_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库版本脚本执行记录';
+```
+
+说明:
+
+- `version` 唯一,保持每个版本脚本只成功一次。
+- 如果同版本文件 hash 与历史成功记录不一致,应停止启动并报错。
+- 若历史状态为 `Failed`,允许重试并覆盖状态或新增 retry 字段。为简化,建议更新同一行。
+
+### 4.2 版本状态事实来源
+
+优先级:
+
+1. 数据库表 `sys_db_migration_log` 中 `status='Success'` 的版本记录。
+2. 兼容读取 `version.txt`,仅作为迁移表为空时的旧状态参考。
+
+不得再只依赖 `version.txt` 判断脚本是否执行。
+
+### 4.3 脚本选择逻辑
+
+输入:
+
+- `currentVersion`:入口程序集版本。
+- `scriptVersions`:`UpdateScripts/*.sql` 中所有版本脚本。
+- `successVersions`:迁移记录表中成功版本。
+- `historyVersionFromTxt`:旧 `version.txt` 中记录的版本。
+
+应执行脚本集合:
+
+1. 脚本版本 `<= currentVersion`。
+2. 数据库迁移表中不存在 `Success` 记录。
+3. 若迁移表为空但 `version.txt` 存在且 `isRunScript=True`,可将 `<= historyVersionFromTxt` 的脚本视为 legacy 已成功,但建议只作为兼容策略,并打印警告。
+4. 不要求存在与 `currentVersion` 同名的 SQL 文件。
+
+伪代码:
+
+```csharp
+var current = Version.Parse(currentVersion);
+var scripts = GetScriptSqlVersions()
+    .Where(x => Version.Parse(x.Version) <= current)
+    .OrderBy(x => Version.Parse(x.Version))
+    .ToList();
+
+foreach (var script in scripts)
+{
+    if (MigrationAlreadySucceeded(script))
+        continue;
+
+    ExecuteOneScript(script);
+}
+```
+
+### 4.4 版本比较
+
+必须新增统一比较方法:
+
+```csharp
+private static Version ParseVersion(string value)
+{
+    if (!Version.TryParse(value, out var version))
+        throw new InvalidOperationException($"非法版本号:{value}");
+    return version;
+}
+```
+
+若项目后续出现 `1.0.158-alpha`,再引入 SemVer 库;当前脚本命名均为 `x.y.z`,`System.Version` 足够。
+
+### 4.5 SQL 执行方式
+
+#### 基线方案
+
+保留整文件执行能力,但必须增强结果判断与日志。
+
+```csharp
+var affected = db.Ado.ExecuteCommand(sql);
+```
+
+注意:这仍无法精确定位多语句中的哪条失败,但能先消除“无记录、无校验”的问题。
+
+#### 推荐方案
+
+实现 SQL 脚本分割器,按语句执行:
+
+- 支持 `--` 单行注释。
+- 支持 `#` 单行注释。
+- 支持 `/* ... */` 块注释。
+- 支持字符串中的分号不切分。
+- 支持普通 MySQL `SET / PREPARE / EXECUTE / DEALLOCATE / CREATE / ALTER / INSERT / UPDATE`。
+- 暂不要求支持 `DELIMITER` 与存储过程;若发现脚本含 `DELIMITER`,应报错并提示手工执行或扩展执行器。
+
+执行策略:
+
+- 普通 DML 可在事务中执行。
+- DDL 不依赖事务回滚,失败即停。
+- 每条语句执行前后记录序号。
+- 任一语句失败,则该脚本 `Failed`,不继续后续脚本。
+
+### 4.6 成功判定
+
+脚本成功必须满足:
+
+1. 所有待执行 SQL 语句无异常。
+2. 迁移记录表该版本写入 `Success`。
+3. 若存在校验 SQL,全部校验通过。
+
+`version.txt` 只在上述三项成功后更新。
+
+### 4.7 校验机制
+
+建议支持两种方式,至少实现一种。
+
+#### 方式 A:旁路校验文件
+
+同版本脚本旁新增可选文件:
+
+```text
+UpdateScripts/1.0.158.sql
+UpdateScripts/1.0.158.verify.sql
+```
+
+`*.verify.sql` 可包含一条或多条 SELECT,每条必须返回第一行第一列为 truthy:
+
+- `1`
+- `true`
+- 大于 0 的数字
+
+示例:
+
+```sql
+SELECT COUNT(*) > 0
+FROM information_schema.TABLES
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME = 'dwd_po_trans';
+```
+
+#### 方式 B:脚本内校验注释
+
+在 SQL 文件末尾约定:
+
+```sql
+-- VERIFY:
+-- SELECT COUNT(*) > 0 FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='xxx';
+```
+
+实现复杂度高于方式 A。建议本轮优先实现方式 A。
+
+### 4.8 日志要求
+
+启动日志至少包含:
+
+- 当前程序集版本。
+- 当前数据库名:`SELECT DATABASE()`。
+- 发现脚本数量。
+- 已成功脚本数量。
+- 待执行脚本数量。
+- 每个脚本:
+  - 版本;
+  - 文件名;
+  - SHA256;
+  - 语句数;
+  - affected rows;
+  - verify 状态;
+  - elapsed ms。
+
+失败时日志必须包含:
+
+- 脚本版本;
+- 语句序号;
+- SQL 片段前 300 字符;
+- 异常 message;
+- 内部异常 message。
+
+### 4.9 启动失败策略
+
+推荐:版本脚本失败应阻止应用继续启动。
+
+理由:
+
+- 数据库结构与代码版本不匹配时继续启动更危险。
+- 当前“继续启动”导致隐藏问题。
+
+实现:
+
+- `HandleSqlScript` 失败后抛出异常。
+- `UseAutoVersionUpdate()` 不吞异常。
+
+如果项目负责人要求不中断启动,可加配置:
+
+```json
+"AutoVersionUpdate": {
+  "StopApplicationOnFailure": true
+}
+```
+
+默认必须为 `true`。
+
+---
+
+## 5. 具体执行步骤
+
+### Step 1:备份当前文件
+
+阅读并理解:
+
+- `server/Admin.NET.Core/Update/AutoVersionUpdate.cs`
+- `server/Admin.NET.Web.Core/Startup.cs`
+- `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj`
+- `server/Admin.NET.Web.Entry/UpdateScripts/*.sql`
+
+不要改动业务插件代码。
+
+### Step 2:新增内部模型
+
+在 `AutoVersionUpdate.cs` 中新增私有模型:
+
+```csharp
+private sealed record MigrationScript(
+    string Version,
+    Version ParsedVersion,
+    string FilePath,
+    string FileName,
+    string Hash);
+
+private sealed class MigrationLogRow
+{
+    public long Id { get; set; }
+    public string Version { get; set; } = string.Empty;
+    public string FileHash { get; set; } = string.Empty;
+    public string Status { get; set; } = string.Empty;
+}
+```
+
+也可独立文件,但为减少影响范围,建议本轮集中在 `AutoVersionUpdate.cs`。
+
+### Step 3:确保迁移记录表
+
+在执行脚本前调用:
+
+```csharp
+EnsureMigrationLogTable(dbContext);
+```
+
+该方法必须执行 `CREATE TABLE IF NOT EXISTS sys_db_migration_log (...)`。
+
+### Step 4:替换版本比较与脚本筛选
+
+废弃:
+
+```csharp
+string.CompareTo(...)
+```
+
+改为:
+
+```csharp
+Version.Parse(...)
+```
+
+修复以下逻辑:
+
+- `version.txt` 为空不再直接返回。
+- 当前版本没有同名脚本不再直接返回。
+- 执行所有 `scriptVersion <= currentVersion && !success` 的脚本。
+
+### Step 5:实现 hash
+
+使用 SHA256:
+
+```csharp
+private static string ComputeSha256(string filePath)
+{
+    using var sha = SHA256.Create();
+    using var stream = File.OpenRead(filePath);
+    return Convert.ToHexString(sha.ComputeHash(stream));
+}
+```
+
+需要添加:
+
+```csharp
+using System.Security.Cryptography;
+```
+
+### Step 6:实现执行前记录 Running
+
+执行脚本前 upsert:
+
+- 若无记录:插入 `Running`。
+- 若 `Failed`:更新为 `Running`,清空错误。
+- 若 `Success` 且 hash 相同:跳过。
+- 若 `Success` 且 hash 不同:抛异常,阻止启动。
+
+### Step 7:执行 SQL
+
+优先实现分语句执行。若时间不足,可第一版保留整文件执行,但必须:
+
+- 写迁移记录;
+- 失败抛异常;
+- 支持 verify;
+- 不再跳过未执行脚本。
+
+推荐接口:
+
+```csharp
+private static ScriptExecuteResult ExecuteSqlScript(ISqlSugarClient db, string sql)
+```
+
+返回:
+
+```csharp
+private sealed record ScriptExecuteResult(int StatementCount, long AffectedRows);
+```
+
+### Step 8:执行校验 SQL
+
+寻找同目录:
+
+```csharp
+var verifyPath = Path.ChangeExtension(script.FilePath, ".verify.sql");
+```
+
+注意 `Path.ChangeExtension("1.0.158.sql", ".verify.sql")` 会得到 `1.0.158.verify.sql`。
+
+若存在,逐条执行 SELECT 校验。
+
+校验失败:
+
+- 记录 `verify_status='Failed'`;
+- 记录错误;
+- 抛异常;
+- 不写 `version.txt=True`。
+
+若不存在:
+
+- `verify_status='NotConfigured'`。
+
+### Step 9:执行成功后更新迁移记录
+
+成功后更新:
+
+- `status='Success'`
+- `finished_at`
+- `elapsed_ms`
+- `statement_count`
+- `affected_rows`
+- `verify_status`
+- `error_message=NULL`
+- `database_name=SELECT DATABASE()`
+
+### Step 10:更新 `version.txt`
+
+只有当本轮所有应执行脚本成功后,写:
+
+```text
+{currentVersion}^{now}^{True}
+```
+
+或者在每个脚本成功后写脚本版本也可,但推荐最终写当前程序集版本,数据库真实状态以 `sys_db_migration_log` 为准。
+
+### Step 11:失败处理
+
+任一脚本失败:
+
+1. 更新迁移记录 `Failed`。
+2. 写入错误信息。
+3. 不更新 `version.txt=True`。
+4. 抛出异常,使启动失败。
+
+### Step 12:兼容旧 `version.txt`
+
+如果迁移表为空但 `version.txt` 存在:
+
+- 读取 `version.txt`。
+- 若 `isRunScript=True`,可将 `<= historyVersion` 的脚本视为 legacy 成功,但不要自动批量插入 `Success`,避免伪造历史。
+- 日志提示:
+
+```text
+检测到 sys_db_migration_log 为空但 version.txt 存在,将 version.txt 作为 legacy 跳过边界:{historyVersion}
+```
+
+更严格方案:
+
+- 不信任 `version.txt`,全部重新跑幂等脚本。
+- 但历史脚本不一定全部幂等,风险较大。
+
+本任务建议采用兼容边界方案。
+
+---
+
+## 6. 测试方案
+
+### 6.1 单元/伪单元测试(可手动)
+
+至少覆盖:
+
+1. 版本排序:
+   - `1.0.9 < 1.0.10`
+   - `1.0.158 < 1.0.161`
+2. 当前版本 `1.0.161`,脚本最新 `1.0.158`,历史成功到 `1.0.154`:
+   - 应执行 `1.0.155`、`1.0.157`、`1.0.158` 等未成功脚本。
+3. `version.txt` 不存在:
+   - 不得直接跳过所有脚本。
+4. 同版本 hash 变化:
+   - 必须报错。
+
+### 6.2 本地集成测试
+
+#### 测试 A:首次启动不跳过
+
+1. 备份并删除运行目录 `version.txt`。
+2. 清空或新建测试库的 `sys_db_migration_log`。
+3. 放一个测试脚本:
+
+```sql
+CREATE TABLE IF NOT EXISTS auto_version_update_test (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT,
+    code VARCHAR(50) NOT NULL
+);
+INSERT INTO auto_version_update_test(code)
+SELECT 'OK'
+WHERE NOT EXISTS (SELECT 1 FROM auto_version_update_test WHERE code='OK');
+```
+
+4. 启动后验证:
+
+```sql
+SELECT COUNT(*) FROM auto_version_update_test WHERE code='OK';
+SELECT version, status FROM sys_db_migration_log ORDER BY version;
+```
+
+#### 测试 B:失败不标成功
+
+添加一个故意失败脚本:
+
+```sql
+ALTER TABLE not_exists_table ADD COLUMN x INT;
+```
+
+预期:
+
+- 启动失败。
+- `sys_db_migration_log.status='Failed'`。
+- `version.txt` 不写 `True` 到该版本。
+
+#### 测试 C:verify 失败不标成功
+
+脚本成功但 `.verify.sql` 返回 false:
+
+```sql
+SELECT 0;
+```
+
+预期同失败。
+
+#### 测试 D:当前版本高于最新脚本
+
+入口项目版本保持 `1.0.161`,脚本最新 `1.0.158`。
+
+构造历史成功到 `1.0.154`。
+
+预期:
+
+- `1.0.155` 到 `1.0.158` 中未成功脚本会执行。
+- 不因“没有 1.0.161.sql”而直接返回。
+
+### 6.3 生产/预发验证 SQL
+
+执行后检查:
+
+```sql
+SELECT version, file_name, status, verify_status, started_at, finished_at, elapsed_ms
+FROM sys_db_migration_log
+ORDER BY version;
+```
+
+针对关键版本脚本补充业务校验,例如:
+
+```sql
+-- 表是否存在
+SELECT COUNT(*)
+FROM information_schema.TABLES
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME = '目标表名';
+
+-- 字段是否存在
+SELECT COUNT(*)
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE()
+  AND TABLE_NAME = '目标表名'
+  AND COLUMN_NAME = '目标字段';
+
+-- 种子数据是否存在
+SELECT COUNT(*)
+FROM 目标表
+WHERE 业务唯一条件;
+```
+
+---
+
+## 7. 验收标准
+
+### 7.1 功能验收
+
+- [ ] 无 `version.txt` 的新环境启动时,不再跳过历史脚本。
+- [ ] 当前程序集版本高于最新脚本时,仍执行所有未成功脚本。
+- [ ] 任一脚本执行失败时,启动失败或至少明确失败,不写成功状态。
+- [ ] `sys_db_migration_log` 有每个脚本的状态记录。
+- [ ] 成功脚本重启后不重复执行。
+- [ ] 已成功脚本被修改 hash 后,启动阻断并提示。
+- [ ] 可选 `.verify.sql` 失败时,脚本不标成功。
+- [ ] 日志能定位失败脚本和失败语句。
+
+### 7.2 回归验收
+
+- [ ] `dotnet build server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 通过。
+- [ ] 后端可正常启动。
+- [ ] 旧的 `UpdateScripts` 不因新执行器误拆分而报错。
+- [ ] WorkerId 非 1 的节点仍不执行脚本。
+- [ ] 现有 `version.txt` 兼容读取。
+
+---
+
+## 8. 风险与注意事项
+
+1. 历史脚本不一定全部幂等,不要贸然在生产全量重跑所有历史脚本。
+2. 首次引入 `sys_db_migration_log` 时,要明确如何处理已有环境:
+   - 推荐以 `version.txt` 作为 legacy 边界;
+   - 对边界之后的脚本按新机制执行。
+3. MySQL `DELIMITER` / 存储过程脚本若存在,普通分号拆分器会失败。当前脚本初查主要为普通 DDL/DML 与 `PREPARE/EXECUTE`,但执行前仍需扫描。
+4. 不要在本任务中顺手改业务脚本内容,除非脚本本身语法导致新执行器无法执行。
+5. 失败后不要手动把 `version.txt` 改成 True 掩盖问题,应修复脚本或校验。
+
+---
+
+## 9. 建议文件改动清单
+
+### 必改
+
+- `server/Admin.NET.Core/Update/AutoVersionUpdate.cs`
+  - 重写脚本选择、版本比较、执行记录、成功判定、失败处理。
+
+### 可选新增
+
+- `server/Admin.NET.Web.Entry/UpdateScripts/*.verify.sql`
+  - 为关键脚本增加验证。
+
+### 不建议本任务改
+
+- 业务插件实体/服务。
+- 前端代码。
+- 现有 SQL 脚本业务内容。
+
+---
+
+## 10. 给执行模型的最终指令
+
+请严格按以下顺序执行:
+
+1. 读取并理解 `AutoVersionUpdate.cs` 当前实现。
+2. 不修改业务逻辑,先重构版本脚本执行器。
+3. 新增 `sys_db_migration_log` 幂等建表逻辑。
+4. 改掉 `version.txt` 不存在时跳过脚本的逻辑。
+5. 改掉“当前版本无同名脚本则跳过全部”的逻辑。
+6. 改用 `System.Version` 比较版本。
+7. 增加 SHA256 hash 与已成功脚本 hash 校验。
+8. 增加 Running/Success/Failed 迁移记录。
+9. 增加可选 `.verify.sql` 校验。
+10. 失败时抛异常并阻止启动,不写成功状态。
+11. 构建验证。
+12. 用临时测试脚本或测试库验证首次启动、失败脚本、verify 失败、当前版本高于最新脚本四类场景。
+
+执行完成后输出:
+
+- 改动文件列表。
+- 核心逻辑说明。
+- 已执行测试。
+- 尚未覆盖的风险。
+- 是否需要为关键历史脚本补 `.verify.sql`。
+
+---
+
+## 11. 推荐后续治理
+
+1. 对所有重要 `UpdateScripts` 补充 `.verify.sql`。
+2. 禁止只依赖 `version.txt` 作为发布验收依据。
+3. 发布验收模板增加:
+
+```sql
+SELECT version, status, verify_status, finished_at
+FROM sys_db_migration_log
+ORDER BY version DESC
+LIMIT 20;
+```
+
+4. 对 DDL 类脚本要求幂等。
+5. 对数据种子脚本要求唯一键或 `INSERT ... ON DUPLICATE KEY UPDATE`。
+6. 对非幂等脚本必须在脚本头注明风险与回滚方式。

+ 776 - 0
doc/plan/KPI原子聚合层与全局筛选适配执行任务书.md

@@ -0,0 +1,776 @@
+# KPI 原子聚合层与全局筛选适配执行任务书
+
+## 1. 执行声明
+
+本任务书用于指导其他大模型或协作者直接在 Ai-DOP 仓库中实施“智慧运营 KPI 按查询条件全局筛选呈现”的改造。
+
+本轮不是 S1/S3/S4 等个别模块试点,而是全局适配:
+
+- 覆盖智慧运营动态看板 `S1` 到 `S9`。
+- 覆盖智慧诊断页,与动态看板使用同一套筛选口径。
+- 覆盖四个全局业务对象域:`order_delivery`、`work_schedule`、`supply_purchase`、`inventory`。
+- 数据中台落库只做到最小颗粒度原子聚合层。
+- 模块汇总、日期范围汇总、维度筛选汇总和最终 KPI 值计算由 API 动态完成。
+- 暂无原子事实或暂无规则的 KPI 必须显式标记为 `summary_only`,不得伪装为已按条件过滤。
+
+## 2. 背景与现状
+
+当前动态运营看板的前端已经能够传递基础查询条件,例如日期、产品、订单号、产线、客户、供应商、物料、采购单、仓库、工单、设备等。
+
+现有链路的主要问题:
+
+- 前端 `DynamicModuleDashboard.vue` 会通过 `buildExtraParams()` 将查询条件传给 `fetchDynamicDashboardPage()`。
+- `GET /api/AidopKanban/dashboard-page/{moduleCode}` 当前主要按 `tenant_id + factory_id + module_code + 最新 biz_date` 从 `ado_s9_kpi_value_l1/l2/l3/l4_day` 读取模块级最终汇总。
+- 后端 KPI 读数没有按查询条件进入可二次聚合的明细或原子层。
+- 如果直接把筛选条件套到最终汇总表,会造成“看似过滤,实际仍是模块汇总”的错误口径。
+
+因此需要新增全局 KPI 原子聚合层,让 API 根据前端筛选条件从原子层动态聚合并计算最终 KPI。
+
+## 3. 必读文件
+
+执行前必须先阅读以下文件,并以当前仓库实际代码为准:
+
+- `doc/plan/看板查询条件现状分析.md`
+- `doc/plan/动态运营详情看板与指标改善闭环方案.md`
+- `doc/plan/数据中台模块扩展指南-S1至S3经验版.md`
+- `doc/db/mdp/06_kpi_atomic_agg_layer.sql`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.DashboardPage.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Diagnosis.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiMaster.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopKpiMasterSeed.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopTenantMigration.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Order/S1MdpSyncTransformService.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Production/S2MdpSyncTransformService.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Supply/S3MdpSyncTransformService.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/S4MdpSyncTransformService.cs`
+- `Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue`
+- `Web/src/views/aidop/kanban/utils/smartOpsBaseQuery.ts`
+- `Web/src/views/aidop/api/kanbanData.ts`
+- `Web/src/views/aidop/kanban/s1.vue` 到 `Web/src/views/aidop/kanban/s9.vue`
+
+## 4. 核心原则
+
+- 不按 S1/S2/S3/S4/S5/S6/S7/S8/S9 模块边界重复聚合同一批业务数据。
+- 先按全局业务对象域设计聚合层,再由模块消费。
+- 模块是展示和消费边界,不是数据重复聚合边界。
+- 本轮只落库到最小颗粒度原子聚合层,不落按模块、客户、产品、产线、7 天、10 天、30 天等二次汇总表。
+- 原子聚合层不只存最终 `metric_value`,必须存可二次聚合的基础量。
+- 前端不承担业务 KPI 计算,只传查询条件并展示后端返回的口径。
+- 无查询条件时继续保留现有 `ado_s9_kpi_value_l1/l2/l3/l4_day` 作为兼容默认视图。
+- 有查询条件时统一走原子层动态计算。若某 KPI 无原子规则或无可追溯事实,返回汇总值时必须标明 `summary_only`。
+
+## 5. 禁止事项
+
+- 禁止只针对个别 KPI 写死 `if metricCode == ...` 的业务分支。
+- 禁止在 Controller 中按模块大量硬编码 SQL。
+- 禁止把前端查询条件直接套到 `ado_s9_kpi_value_l1/l2/l3/l4_day` 上假装过滤。
+- 禁止让前端计算业务 KPI 公式。
+- 禁止新增客户汇总表、产品汇总表、模块汇总表等二次派生结果表作为主模型。
+- 禁止为 S3 和 S4 分别重复建设同一套供应采购聚合链路。
+- 禁止无提示地回退到模块汇总口径。
+- 禁止提交 Office 临时文件、敏感配置、运行产物或无关改动。
+
+## 6. 目标数据分层
+
+```mermaid
+flowchart TD
+  sourceData["Source 源系统数据"] --> stgLayer["STG 贴源层"]
+  stgLayer --> stdLayer["STD 标准层"]
+  stdLayer --> dwdLayer["DWD 明细事实层"]
+  dwdLayer --> atomicAgg["AGG 原子聚合层"]
+  atomicAgg --> apiCalc["API 动态计算层"]
+  apiCalc --> dashboard["动态看板与智慧诊断"]
+```
+
+各层职责:
+
+- Source:ERP、MES、CRM、SRM 等源系统原始数据。
+- STG:按批次贴源,保留源字段、原始结构和追溯信息,不做 KPI。
+- STD:标准化租户、工厂、客户、供应商、物料、产品、产线、仓库、日期、状态。
+- DWD:沉淀全局业务对象域明细事实,一行代表一条可分析业务事实。
+- AGG 原子聚合层:按最小统计颗粒度预计算可二次聚合基础量。
+- API 动态计算层:根据查询条件从 AGG 层筛选、聚合、执行 KPI 公式。
+- 前端展示层:传条件、展示 KPI、展示口径,不计算业务 KPI。
+
+## 7. 全局业务对象域
+
+本轮必须覆盖以下四个对象域。实现时可以按域分阶段提交,但最终代码路径必须是全局框架,而不是试点特例。
+
+| 对象域 | domain_code | 主要事实 | 主要消费模块 | 支持筛选 |
+|---|---|---|---|---|
+| 订单交付域 | `order_delivery` | 销售订单、客户、产品、交期评审、发货计划、发货事实 | S1、S7、S9 部分 | `dateStart`、`dateEnd`、`customer`、`product`、`orderNo`、`productionLine` |
+| 工单排程域 | `work_schedule` | 工单、产品、产线、工序、设备、排程、制造执行 | S2、S6、S9 部分 | `dateStart`、`dateEnd`、`product`、`orderNo`、`productionLine`、`workOrder`、`equipment` |
+| 供应采购域 | `supply_purchase` | 供应商、物料、采购申请、采购单、交货计划、收货、采购执行 | S3、S4、S5 部分、S9 部分 | `dateStart`、`dateEnd`、`supplier`、`material`、`poNo` |
+| 仓储库存域 | `inventory` | 仓库、库区、物料、批次、收发存、出入库单、工单配送 | S5、S7 部分、S9 部分 | `dateStart`、`dateEnd`、`warehouse`、`material`、`workOrder`、`outboundNo` |
+
+模块只定义“消费哪些 KPI、支持哪些筛选字段、从哪个对象域取数、如何展示”,不得因为模块边界重复建设聚合链路。
+
+## 8. 原子聚合层数据模型
+
+优先使用统一表:
+
+- `ado_smart_ops_kpi_atomic_day`
+
+当前已有或待完善文件:
+
+- `doc/db/mdp/06_kpi_atomic_agg_layer.sql`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiAtomicDay.cs`
+
+建议字段:
+
+| 类别 | 字段 |
+|---|---|
+| 公共键 | `tenant_id`、`factory_id`、`domain_code`、`metric_code`、`metric_level`、`stat_date` |
+| 订单与产品维度 | `customer_code`、`product_code`、`order_no`、`production_line` |
+| 供应采购维度 | `supplier_code`、`material_code`、`po_no` |
+| 仓储与制造维度 | `warehouse_code`、`work_order_no`、`equipment_code`、`outbound_no` |
+| 可聚合基础量 | `sum_value`、`numerator_value`、`denominator_value`、`sample_count`、`min_value`、`max_value` |
+| 追溯与元数据 | `batch_id`、`calc_time`、`grain`、`is_deleted` |
+
+索引要求:
+
+- 必须支持 `tenant_id + factory_id + domain_code + metric_code + stat_date` 查询。
+- 必须支持按常用维度过滤,例如客户、产品、订单、供应商、物料、采购单、仓库、工单、设备。
+- 唯一键必须保证同一租户、工厂、对象域、指标、日期、维度组合幂等写入。
+
+执行要求:
+
+- 原子层存基础量,不存最终 KPI 值。
+- 比率类指标存分子和分母。
+- 平均类指标存总量和样本数。
+- 数量类指标存可累加值。
+- 最大值、最小值类指标分别存 `max_value`、`min_value`。
+
+## 9. KPI 规则治理
+
+当前 `ado_smart_ops_kpi_master` 已有以下公式相关字段:
+
+- `Formula`
+- `CalcRule`
+- `FormulaExpr`
+- `FormulaRefs`
+- `FormulaPreview`
+- `Direction`
+- `YellowThreshold`
+- `RedThreshold`
+
+本轮可以先通过代码 Registry 维护规则,后续再迁入 KPI Master 或独立配置表。
+
+目标文件:
+
+- `server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/SmartOpsKpiAggregateRuleRegistry.cs`
+
+Registry 必须提供:
+
+- `metricCode -> domain_code`
+- `metricCode -> aggregation_type`
+- `metricCode -> supported_filter_fields`
+- `metricCode -> filter_mode`
+- `moduleCode -> domains`
+- 无规则 KPI 的 `summary_only` 判定
+
+聚合类型:
+
+| aggregation_type | API 计算方式 |
+|---|---|
+| `sum` | `SUM(sum_value)` |
+| `ratio` | `100 * SUM(numerator_value) / NULLIF(SUM(denominator_value), 0)` |
+| `avg` | `SUM(sum_value) / NULLIF(SUM(sample_count), 0)` |
+| `min` | `MIN(min_value)` |
+| `max` | `MAX(max_value)` |
+| `expr` | 根据 `FormulaExpr` 和 `FormulaRefs` 执行组合计算 |
+
+筛选模式:
+
+| filter_mode | 含义 |
+|---|---|
+| `filterable_full` | 当前 KPI 支持所有当前查询条件 |
+| `filterable_partial` | 当前 KPI 支持部分查询条件,存在不支持维度 |
+| `summary_only` | 当前 KPI 暂无原子层口径,只能返回模块汇总或空值 |
+
+## 10. 后端服务设计
+
+### 10.1 筛选上下文
+
+目标文件:
+
+- `server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/SmartOpsDashboardFilter.cs`
+
+必须包含字段:
+
+```csharp
+public sealed class SmartOpsDashboardFilter
+{
+    public DateTime? DateStart { get; set; }
+    public DateTime? DateEnd { get; set; }
+    public string? Product { get; set; }
+    public string? OrderNo { get; set; }
+    public string? ProductionLine { get; set; }
+    public string? Customer { get; set; }
+    public string? Supplier { get; set; }
+    public string? Material { get; set; }
+    public string? PoNo { get; set; }
+    public string? Warehouse { get; set; }
+    public string? WorkOrder { get; set; }
+    public string? Equipment { get; set; }
+    public string? OutboundNo { get; set; }
+}
+```
+
+必须提供:
+
+- `IsEmpty`
+- `FromQuery(...)`
+- `ToContextDictionary()`
+
+### 10.2 原子层构建服务
+
+目标文件:
+
+- `server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/SmartOpsKpiAtomicBuildService.cs`
+
+该服务必须实现 `ITransient`,由 MDP 同步服务注入并调用。
+
+必须提供四个对象域构建方法:
+
+```csharp
+Task<int> BuildOrderDeliveryDomainAsync(DateTime statDate, string? batchId, CancellationToken cancellationToken = default);
+Task<int> BuildWorkScheduleDomainAsync(DateTime statDate, string? batchId, CancellationToken cancellationToken = default);
+Task<int> BuildSupplyPurchaseDomainAsync(DateTime statDate, string? batchId, CancellationToken cancellationToken = default);
+Task<int> BuildInventoryDomainAsync(DateTime statDate, string? batchId, CancellationToken cancellationToken = default);
+```
+
+构建要求:
+
+- 每个方法先删除同一对象域、日期、指标范围内旧数据,再插入新原子数据。
+- 写入必须幂等。
+- SQL 必须参数化。
+- 不在服务内生成模块级派生汇总表。
+- 不同模块共享同一对象域事实。例如 S3 和 S4 共用 `supply_purchase`,不得重复构建。
+
+对象域来源建议:
+
+| domain_code | 建议来源 |
+|---|---|
+| `order_delivery` | `mdp_std_so`、`dwd_ship_trans` 等 S1/S7 相关事实 |
+| `work_schedule` | S2 工单排程、S6 制造执行相关 STD/DWD 事实 |
+| `supply_purchase` | `dwd_supplier_delivery`、`dwd_s4_purchase_execution`、采购计划与收货事实 |
+| `inventory` | S5 仓储、收发存、上架、备货、发货、库存周转相关事实 |
+
+如果某对象域缺少完整 DWD 事实,执行模型必须:
+
+1. 优先复用现有 STD/DWD 表。
+2. 不临时拼接前端口径。
+3. 在文档和代码注释中标明当前原子事实覆盖范围。
+4. 对未覆盖 KPI 返回 `summary_only`。
+
+### 10.3 原子层查询服务
+
+目标文件:
+
+- `server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/SmartOpsKpiAtomicQueryService.cs`
+
+该服务必须实现 `ITransient`。
+
+建议接口:
+
+```csharp
+public sealed class SmartOpsKpiAtomicQueryService : ITransient
+{
+    public Task<FilteredKpiQueryResult> QueryAsync(
+        long tenantId,
+        long factoryId,
+        string moduleCode,
+        SmartOpsDashboardFilter filter,
+        IReadOnlyList<AdoSmartOpsKpiMaster> kpis,
+        CancellationToken cancellationToken = default);
+}
+```
+
+建议返回模型:
+
+```csharp
+public sealed class FilteredKpiQueryResult
+{
+    public string Scope { get; init; } = "module_summary";
+    public Dictionary<string, DashboardMetricValueModel> Values { get; init; } = new(StringComparer.OrdinalIgnoreCase);
+    public Dictionary<string, string> MetricFilterModes { get; init; } = new(StringComparer.OrdinalIgnoreCase);
+    public Dictionary<string, string[]> UnsupportedFiltersByMetric { get; init; } = new(StringComparer.OrdinalIgnoreCase);
+    public Dictionary<string, string?> FilterContext { get; init; } = new(StringComparer.OrdinalIgnoreCase);
+}
+```
+
+查询算法:
+
+1. 输入当前模块所有 KPI。
+2. 对每个 KPI 从 Registry 获取规则。
+3. 无规则或规则为 `summary_only`:记录 `summary_only`,不从原子层计算。
+4. 有规则但当前筛选条件包含不支持维度:记录 `filterable_partial` 和 `unsupportedFilters`,不得伪装成全量过滤。
+5. 有规则且支持当前筛选:从 `ado_smart_ops_kpi_atomic_day` 按租户、工厂、对象域、指标、日期和维度过滤聚合。
+6. 按 `aggregation_type` 动态计算最终值。
+7. 按 `stat_date` 生成趋势数据。
+8. 返回 `scope`:
+   - `module_summary`:无筛选或无原子计算结果。
+   - `filtered_atomic`:当前返回值全部可按筛选条件从原子层计算。
+   - `partial_filtered`:部分 KPI 按条件过滤,部分 KPI 仍为汇总。
+   - `summary_only_fallback`:有筛选但本模块暂时全部只能回退汇总。
+
+WHERE 条件要求:
+
+- 必须包含 `tenant_id`、`factory_id`、`domain_code`、`metric_code`、`is_deleted = 0`。
+- 日期条件使用 `stat_date`,支持仅开始日期、仅结束日期、起止日期。
+- 维度条件精确匹配,前端下拉值与后端字段保持一致。
+- 不要使用字符串拼接用户输入,必须参数化。
+
+## 11. 后端接口接入
+
+### 11.1 动态看板 API
+
+目标文件:
+
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs`
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.DashboardPage.cs`
+
+改造要求:
+
+- Controller 构造函数注入 `SmartOpsKpiAtomicQueryService`。
+- `GetDashboardPage` 接收以下查询参数:
+  - `factoryId`
+  - `dateStart`
+  - `dateEnd`
+  - `product`
+  - `orderNo`
+  - `productionLine`
+  - `customer`
+  - `supplier`
+  - `material`
+  - `poNo`
+  - `warehouse`
+  - `workOrder`
+  - `equipment`
+  - `outboundNo`
+- 使用 `SmartOpsDashboardFilter.FromQuery(...)` 生成筛选上下文。
+- 无筛选时继续使用 `LoadDashboardValuesAsync(...)`。
+- 有筛选时调用 `SmartOpsKpiAtomicQueryService.QueryAsync(...)`。
+- 原子层返回值覆盖对应 KPI 的模块汇总值,无法过滤的 KPI 保留汇总值并标明 `filterMode`。
+
+响应必须增加:
+
+```json
+{
+  "dataSource": {
+    "metricDefinition": "ado_smart_ops_kpi_master",
+    "valueTables": ["ado_s9_kpi_value_l1_day", "ado_s9_kpi_value_l2_day", "ado_s9_kpi_value_l3_day", "ado_s9_kpi_value_l4_day"],
+    "atomicTable": "ado_smart_ops_kpi_atomic_day",
+    "scope": "partial_filtered",
+    "filterContext": {
+      "dateStart": "2026-06-01",
+      "dateEnd": "2026-06-10",
+      "customer": "C001"
+    }
+  },
+  "metrics": [
+    {
+      "metricCode": "S1_L1_002",
+      "currentValue": 95.2,
+      "filterMode": "filterable_full",
+      "unsupportedFilters": []
+    }
+  ]
+}
+```
+
+`BuildDashboardMetricPayload(...)` 必须能携带 metric 级口径字段,例如:
+
+- `filterMode`
+- `scopeLabel`
+- `unsupportedFilters`
+- `isFiltered`
+
+### 11.2 智慧诊断 API
+
+目标文件:
+
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Diagnosis.cs`
+
+改造要求:
+
+- `GetSmartDiagnosis` 接收与 `GetDashboardPage` 相同的筛选参数。
+- 使用同一 `SmartOpsDashboardFilter`。
+- 使用同一 `SmartOpsKpiAtomicQueryService`。
+- 诊断矩阵中 L1/L2/L3/L4 的值必须与看板筛选口径一致。
+- 无筛选时保持现有行为。
+- 有筛选但无法过滤的指标必须返回 `summary_only` 或等价口径字段。
+
+## 12. MDP 构建挂接
+
+按对象域挂接,不按消费模块重复挂接。
+
+| 对象域 | 挂接服务 | 说明 |
+|---|---|---|
+| `order_delivery` | `S1MdpSyncTransformService` | S1 产生订单、评审、交付相关 STD/DWD 后构建,S7/S9 复用 |
+| `work_schedule` | `S2MdpSyncTransformService` | S2 产生排程和工单相关事实后构建,S6/S9 复用 |
+| `supply_purchase` | `S3MdpSyncTransformService` | S3 产生供应交付事实后构建,S4/S5/S9 复用 |
+| `inventory` | S5 相关仓储服务或后续统一 MDP 服务 | S5 产生仓储库存事实后构建,S7/S9 复用 |
+
+执行要求:
+
+- S4 不重复构建 `supply_purchase`,只消费供应采购域。
+- S6 不重复构建 `work_schedule`,优先消费工单排程域。
+- S7 不重复构建 `order_delivery` 或 `inventory`,按 KPI 所属对象域读取。
+- S9 总览只消费各对象域,不新建 S9 专属聚合链路。
+- 如果当前仓库尚无 S5 独立 MDP 服务,执行模型必须先定位现有仓储库存事实来源,再决定挂接点。不得临时用 Controller 查询明细替代原子层。
+
+历史日期要求:
+
+- 不能只构建 `now.Date`。
+- 应从源事实表取 `DISTINCT stat_date` 或业务日期,按日期循环构建。
+- 如数据量较大,可先限制近 90 天,并在文档中说明后续全量回刷策略。
+
+## 13. 前端接入
+
+目标文件:
+
+- `Web/src/views/aidop/api/kanbanData.ts`
+- `Web/src/views/aidop/kanban/components/DynamicModuleDashboard.vue`
+- `Web/src/views/aidop/kanban/utils/smartOpsBaseQuery.ts`
+- `Web/src/views/aidop/kanban/s1.vue` 到 `Web/src/views/aidop/kanban/s9.vue`
+
+现有各模块额外查询字段:
+
+| 页面 | 已有额外查询 |
+|---|---|
+| S1 | `customer` |
+| S2 | 基础字段 |
+| S3 | `supplier`、`material` |
+| S4 | `supplier`、`material`、`poNo` |
+| S5 | `warehouse`、`material`、`workOrder` |
+| S6 | `equipment` |
+| S7 | `customer`、`outboundNo` |
+| S8 | 按当前页面实际字段为准 |
+| S9 | 基础字段 |
+
+前端要求:
+
+- 保持现有 `baseQueryToApiParams(...)` 和 `extraQueryMapper` 链路。
+- 不在前端计算 KPI。
+- 删除或替换固定提示“当前 KPI 卡片仍为模块汇总口径”。
+- 根据后端 `dataSource.scope` 展示全局口径:
+  - `module_summary`:模块汇总口径。
+  - `filtered_atomic`:已按当前条件从原子层计算。
+  - `partial_filtered`:部分指标已过滤,其余仍为模块汇总。
+  - `summary_only_fallback`:当前筛选条件下暂无可过滤 KPI,展示汇总口径。
+- 指标卡可展示 `filterMode` 和 `unsupportedFilters`。
+- 查询后提示应来自后端口径,不得写死“已过滤”。
+
+`kanbanData.ts` 类型建议:
+
+```ts
+export interface DynamicDashboardPagePayload {
+  dataSource?: {
+    metricDefinition?: string;
+    valueTables?: string[];
+    atomicTable?: string;
+    formulaStatus?: string;
+    scope?: string;
+    filterContext?: Record<string, string | null>;
+  };
+}
+```
+
+指标类型建议增加:
+
+```ts
+filterMode?: 'filterable_full' | 'filterable_partial' | 'summary_only' | string;
+scopeLabel?: string;
+unsupportedFilters?: string[];
+isFiltered?: boolean;
+```
+
+## 14. 迁移与初始化
+
+目标文件:
+
+- `server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopTenantMigration.cs`
+
+必须新增:
+
+```csharp
+public static void EnsureKpiAtomicTable(ISqlSugarClient db)
+```
+
+要求:
+
+- 创建 `ado_smart_ops_kpi_atomic_day`。
+- 与 `EnsureKpiValueL4Table(...)` 同级。
+- `MigrateOldTenantId(...)` 中调用。
+- 租户迁移 `Tables` 数组增加 `("ado_smart_ops_kpi_atomic_day", "tenant_id")`。
+- 捕获异常并记录 Trace warning,不影响主启动。
+
+## 15. 分阶段执行任务
+
+### 阶段 0:只读审计
+
+输出内容:
+
+- S1 到 S9 的 KPI 清单。
+- 每个 KPI 的对象域归属。
+- 每个 KPI 的聚合类型:`sum`、`ratio`、`avg`、`min`、`max`、`expr`、`summary_only`。
+- 每个对象域现有 STD/DWD 来源表。
+- 当前缺口清单。
+
+此阶段不改代码。
+
+### 阶段 1:DDL、实体与迁移
+
+交付:
+
+- 完善 `06_kpi_atomic_agg_layer.sql`。
+- 完善 `AdoSmartOpsKpiAtomicDay.cs`。
+- 新增 `EnsureKpiAtomicTable(...)`。
+- 保证表、实体、索引字段一致。
+
+### 阶段 2:全局规则 Registry
+
+交付:
+
+- `SmartOpsKpiAggregateRuleRegistry.cs` 覆盖 S1 到 S9。
+- 四个对象域完整定义。
+- 支持 `GetDomainsForModule(...)`。
+- 支持 `SupportsFilter(...)`。
+- 支持 `GetUnsupportedFilters(...)`。
+- 无规则 KPI 返回 `summary_only`。
+
+### 阶段 3:原子构建服务
+
+交付:
+
+- `BuildOrderDeliveryDomainAsync`
+- `BuildWorkScheduleDomainAsync`
+- `BuildSupplyPurchaseDomainAsync`
+- `BuildInventoryDomainAsync`
+
+要求:
+
+- 每个域至少覆盖 L1 核心 KPI。
+- L2/L3/L4 如短期无法完整覆盖,必须在 Registry 中标注 `summary_only` 或 `filterable_partial`。
+- 构建 SQL 必须按日期幂等。
+
+### 阶段 4:MDP 挂接
+
+交付:
+
+- S1 挂接 `order_delivery`。
+- S2 挂接 `work_schedule`。
+- S3 挂接 `supply_purchase`。
+- S5 或仓储库存事实生成处挂接 `inventory`。
+
+要求:
+
+- 按日期回刷,不仅构建当天。
+- S4/S6/S7/S9 只消费共享域,不重复构建。
+
+### 阶段 5:原子查询服务
+
+交付:
+
+- `SmartOpsKpiAtomicQueryService.cs`
+- `FilteredKpiQueryResult` 等模型
+- 动态 WHERE
+- 动态聚合公式
+- 趋势查询
+- `scope` 和 metric 级 `filterMode`
+
+### 阶段 6:动态看板 API
+
+交付:
+
+- `GetDashboardPage` 接入全局筛选。
+- 响应增加 `dataSource.scope`、`filterContext`、`atomicTable`。
+- `metrics[]` 增加口径字段。
+- 无筛选保持现有汇总行为。
+
+### 阶段 7:智慧诊断 API
+
+交付:
+
+- `GetSmartDiagnosis` 接收同一组筛选参数。
+- 诊断值使用同一查询服务。
+- 与看板口径一致。
+
+### 阶段 8:前端展示
+
+交付:
+
+- `kanbanData.ts` 类型扩展。
+- `DynamicModuleDashboard.vue` 展示 scope。
+- 指标卡展示 filterMode 或说明。
+- 移除固定错误提示。
+
+### 阶段 9:文档、验收与提交
+
+交付:
+
+- 对账 SQL。
+- 接口样例。
+- 页面验收步骤。
+- 后端 build。
+- 前端 build。
+- 按规则递增版本号并提交。
+
+## 16. 验收标准
+
+必须满足:
+
+- 无查询条件时,S1 到 S9 动态看板数值与现有模块汇总一致,或差异有明确解释。
+- 有日期范围查询时,可过滤 KPI 按 `stat_date` 从原子层动态计算。
+- S1/S7 选择客户或订单号后,订单交付域 KPI 数值变化。
+- S2/S6 选择产线、工单或设备后,工单排程域 KPI 数值变化。
+- S3/S4 选择供应商、物料或采购单后,供应采购域 KPI 数值变化。
+- S5 选择仓库、物料或工单后,仓储库存域 KPI 数值变化。
+- S9 总览不新建专属聚合链路,而是消费全局对象域。
+- 智慧诊断页与动态看板使用同一筛选口径。
+- 不支持筛选的 KPI 有明确 `summary_only` 或 `filterable_partial` 标识。
+- 前端没有业务 KPI 计算逻辑。
+- 不新增二次派生汇总表作为主模型。
+
+## 17. 对账 SQL 示例
+
+执行模型应根据最终字段和实际数据调整 SQL。以下仅为模板。
+
+比率类 KPI 对账:
+
+```sql
+SELECT
+    metric_code,
+    ROUND(100 * SUM(numerator_value) / NULLIF(SUM(denominator_value), 0), 4) AS atomic_value
+FROM ado_smart_ops_kpi_atomic_day
+WHERE tenant_id = @tenantId
+  AND factory_id = @factoryId
+  AND domain_code = 'order_delivery'
+  AND metric_code = 'S1_L1_002'
+  AND is_deleted = 0
+  AND stat_date = @bizDate
+GROUP BY metric_code;
+```
+
+平均类 KPI 对账:
+
+```sql
+SELECT
+    metric_code,
+    ROUND(SUM(sum_value) / NULLIF(SUM(sample_count), 0), 4) AS atomic_value
+FROM ado_smart_ops_kpi_atomic_day
+WHERE tenant_id = @tenantId
+  AND factory_id = @factoryId
+  AND domain_code = 'order_delivery'
+  AND metric_code = 'S1_L1_001'
+  AND is_deleted = 0
+  AND stat_date BETWEEN @dateStart AND @dateEnd
+GROUP BY metric_code;
+```
+
+维度筛选对账:
+
+```sql
+SELECT
+    metric_code,
+    customer_code,
+    product_code,
+    order_no,
+    ROUND(SUM(sum_value) / NULLIF(SUM(sample_count), 0), 4) AS filtered_value
+FROM ado_smart_ops_kpi_atomic_day
+WHERE tenant_id = @tenantId
+  AND factory_id = @factoryId
+  AND domain_code = 'order_delivery'
+  AND metric_code = 'S1_L1_001'
+  AND customer_code = @customer
+  AND stat_date BETWEEN @dateStart AND @dateEnd
+  AND is_deleted = 0
+GROUP BY metric_code, customer_code, product_code, order_no;
+```
+
+## 18. 构建验证
+
+后端:
+
+```powershell
+dotnet build server/Admin.NET.sln
+```
+
+如果仓库实际 solution 路径不同,先使用已有构建方式。
+
+前端:
+
+```powershell
+cd Web
+npm run build
+```
+
+若前端包管理器为 pnpm 或 yarn,以仓库现有脚本为准,不要擅自更换包管理器。
+
+## 19. 版本号与提交规则
+
+本任务涉及后端代码时,提交前必须递增:
+
+- `server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj` 中的 `<Version>`
+- `<AssemblyVersion>`
+- `<FileVersion>`
+
+三处保持同号,patch +1。
+
+本任务涉及前端代码时,提交前必须递增:
+
+- `Web/package.json` 的 `version`
+
+patch +1。
+
+如果同一提交同时包含前端与后端代码,前后端版本号都必须递增。
+
+纯文档提交不递增前后端版本号。
+
+## 20. 给执行模型的固定 Prompt
+
+将下面 Prompt 交给其他大模型时,可以直接执行。
+
+```text
+请基于 Ai-DOP 当前仓库,实现“智慧运营 KPI 原子聚合层与全局筛选适配”。
+
+执行范围是全局,不是试点:
+1. 覆盖 S1 到 S9 动态看板。
+2. 覆盖智慧诊断页。
+3. 覆盖四个全局业务对象域:order_delivery、work_schedule、supply_purchase、inventory。
+4. 无查询条件时继续兼容现有 ado_s9_kpi_value_l1/l2/l3/l4_day 模块汇总。
+5. 有查询条件时,可过滤 KPI 必须从 ado_smart_ops_kpi_atomic_day 动态聚合计算。
+6. 无原子规则或无事实支撑的 KPI 必须显式标记 summary_only 或 filterable_partial,不得假装已过滤。
+
+必须遵守:
+- 不按模块重复建设聚合链路。
+- 不新增二次派生汇总表作为主模型。
+- 不让前端计算 KPI。
+- 不把查询条件套到最终汇总表上假装过滤。
+- 不在 Controller 中按模块硬编码大量业务 SQL。
+
+实施步骤:
+1. 只读审计 S1-S9 KPI、现有 STD/DWD 来源、对象域归属和缺口。
+2. 完善 ado_smart_ops_kpi_atomic_day DDL、实体和 EnsureKpiAtomicTable 迁移。
+3. 完善 SmartOpsDashboardFilter。
+4. 实现全局 SmartOpsKpiAggregateRuleRegistry。
+5. 实现 SmartOpsKpiAtomicBuildService 四个对象域构建方法。
+6. 将对象域构建按域挂接到 S1/S2/S3/S5 或对应事实生成服务,不在 S4/S6/S7/S9 重复构建。
+7. 实现 SmartOpsKpiAtomicQueryService,支持动态筛选、聚合公式、趋势、scope 和 metric filterMode。
+8. 改造 dashboard-page API。
+9. 改造 smart-diagnosis API。
+10. 改造前端 DynamicModuleDashboard 和 kanbanData 类型,展示 scope 和 filterMode。
+11. 输出对账 SQL、接口样例和页面验收步骤。
+12. 执行 dotnet build 和 npm run build。
+13. 如提交代码,按仓库规则递增前后端版本号。
+
+交付时必须说明:
+- 修改文件清单。
+- 每个对象域的事实来源。
+- 已支持过滤的 KPI 清单。
+- summary_only 或 filterable_partial 的 KPI 清单。
+- 验证命令和结果。
+```

+ 641 - 136
server/Admin.NET.Core/Update/AutoVersionUpdate.cs

@@ -6,6 +6,11 @@
 
 #if NET10_0_OR_GREATER
 
+using System.Data;
+using System.Diagnostics;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.RegularExpressions;
 using Microsoft.AspNetCore.Builder;
 using XiHan.Framework.Utils.Logging;
 using XiHan.Framework.Utils.Reflections;
@@ -15,27 +20,21 @@ namespace Admin.NET.Core.Update;
 /// <summary>
 /// 自动版本更新中间件拓展
 /// </summary>
-/// <remarks>
-/// 使用方法
-/// 1.在 Admin.NET.Web.Core 的 Startup.cs 中的 Configure 方法中调用 app.UseAutoVersionUpdate()。
-/// 2.在入口项目 Admin.NET.Web.Entry 的根目录下创建一个名为 UpdateScripts 的文件夹,并在其中放置 .sql 后缀的脚本文件
-/// 3.脚本文件命名格式为版本号,例如 1.0.0.sql、1.0.1.sql 等,版本号应符合语义化版本规范。
-/// 4.脚本的属性:复制到输出目录,设置为:始终复制。
-/// 5.设置主节点的 Admin.NET.Application 的 Configuration/App.json 的 WorkerId 为 1。
-/// 6.设置入口项目 Admin.NET.Web.Entry.csproj 的 Version。
-/// ==================================================
-/// 更新新版本时
-/// 1.需在 UpdateScripts 文件夹中添加新的脚本文件,脚本文件名应为新版本号。
-/// 2.设置入口项目 Admin.NET.Web.Entry.csproj 的 Version
-/// </remarks>
 [SuppressSniffer]
 public static class AutoVersionUpdate
 {
+    private const string MigrationLogTable = "sys_db_migration_log";
+    private const string StatusRunning = "Running";
+    private const string StatusSuccess = "Success";
+    private const string StatusFailed = "Failed";
+    private const string StatusSkipped = "Skipped";
+    private const string VerifyNotConfigured = "NotConfigured";
+    private const string VerifySuccess = "Success";
+    private const string VerifyFailed = "Failed";
+
     /// <summary>
     /// 使用自动版本更新中间件
     /// </summary>
-    /// <param name="app"></param>
-    /// <returns></returns>
     public static IApplicationBuilder UseAutoVersionUpdate(this IApplicationBuilder app)
     {
         LogHelper.Info("AutoVersionUpdate 中间件运行");
@@ -47,102 +46,376 @@ public static class AutoVersionUpdate
             return app;
         }
 
-        var currentVersion = GetEntryAssemblyCurrentVersion();
-        LogHelper.Handle($"当前版本:{currentVersion}");
+        var stopOnFailure = App.GetConfig<bool?>("AutoVersionUpdate:StopApplicationOnFailure") ?? true;
 
-        var historyVersionInfo = GetEntryAssemblyHistoryVersionInfo();
-        var historyVersion = historyVersionInfo.Version;
-        var historyDate = historyVersionInfo.Date;
-        var historyIsRunScript = historyVersionInfo.IsRunScript;
+        try
+        {
+            RunPendingMigrations(app);
+            LogHelper.Success("AutoVersionUpdate 中间件结束");
+        }
+        catch (Exception ex)
+        {
+            LogHelper.Error($"AutoVersionUpdate 执行失败:{ex.Message}");
+            if (stopOnFailure)
+                throw;
+        }
+
+        return app;
+    }
+
+    private static void RunPendingMigrations(IApplicationBuilder app)
+    {
+        using var scope = App.GetRequiredService<IServiceScopeFactory>().CreateScope();
+        var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
 
-        LogHelper.Handle($"历史版本:{historyVersion},更新时间:{historyDate},是否已执行{historyIsRunScript}");
+        EnsureMigrationLogTable(db);
 
-        // 历史版本为空、版本号相同,不执行脚本
-        if (historyVersion == string.Empty)
+        var currentVersionText = GetEntryAssemblyCurrentVersion();
+        var currentVersion = ParseVersion(currentVersionText);
+        var historyFromTxt = GetEntryAssemblyHistoryVersionInfo();
+        var databaseName = GetDatabaseName(db);
+
+        LogHelper.Handle($"当前版本:{currentVersionText},目标数据库:{databaseName ?? "(unknown)"}");
+
+        var scripts = LoadMigrationScripts();
+        var migrationRows = LoadMigrationLogs(db);
+        var successVersions = migrationRows
+            .Where(x => string.Equals(x.Status, StatusSuccess, StringComparison.OrdinalIgnoreCase))
+            .Select(x => ParseVersion(x.Version))
+            .ToHashSet();
+
+        Version? legacyBoundary = null;
+        if (migrationRows.Count == 0 &&
+            !string.IsNullOrWhiteSpace(historyFromTxt.Version) &&
+            historyFromTxt.IsRunScript &&
+            Version.TryParse(historyFromTxt.Version, out var legacyVersion))
         {
-            LogHelper.Handle("历史版本为空,默认为最新版本,不执行脚本");
+            legacyBoundary = legacyVersion;
+            LogHelper.Handle(
+                $"检测到 {MigrationLogTable} 为空但 version.txt 存在,legacy 跳过边界:{legacyBoundary}");
+        }
 
-            // 保存当前版本信息
-            SetEntryAssemblyCurrentVersion(currentVersion, true);
+        var pending = scripts
+            .Where(s => s.ParsedVersion <= currentVersion)
+            .Where(s => !ShouldSkipScript(s, migrationRows, legacyBoundary))
+            .OrderBy(s => s.ParsedVersion)
+            .ToList();
 
-            return app;
+        LogHelper.Handle($"发现脚本 {scripts.Count} 个,已成功 {successVersions.Count} 个,待执行 {pending.Count} 个");
+
+        if (pending.Count == 0)
+        {
+            SetEntryAssemblyCurrentVersion(currentVersionText, true);
+            return;
         }
-        else if (currentVersion.CompareTo(historyVersion) <= 0 && historyIsRunScript)
+
+        foreach (var script in pending)
         {
-            LogHelper.Handle("当前版本号与历史版本号相同,且已执行过脚本,不再执行");
+            ExecuteOneMigrationScript(db, script, databaseName);
+        }
 
-            // 保存当前版本信息
-            SetEntryAssemblyCurrentVersion(currentVersion, false);
+        SetEntryAssemblyCurrentVersion(currentVersionText, true);
+    }
 
-            return app;
+    private static bool ShouldSkipScript(
+        MigrationScript script,
+        List<MigrationLogRow> migrationRows,
+        Version? legacyBoundary)
+    {
+        var row = migrationRows.FirstOrDefault(x =>
+            string.Equals(x.Version, script.Version, StringComparison.OrdinalIgnoreCase));
+
+        if (row != null)
+        {
+            if (string.Equals(row.Status, StatusSuccess, StringComparison.OrdinalIgnoreCase))
+            {
+                if (!string.Equals(row.FileHash, script.Hash, StringComparison.OrdinalIgnoreCase))
+                {
+                    throw new InvalidOperationException(
+                        $"版本脚本 {script.FileName} 的 SHA256 与已成功记录不一致,禁止静默覆盖。请新建更高版本脚本。");
+                }
+
+                LogHelper.Handle($"版本 {script.Version} 已成功且 hash 未变,跳过");
+                return true;
+            }
+
+            return false;
         }
-        else
+
+        if (legacyBoundary != null && script.ParsedVersion <= legacyBoundary)
         {
-            LogHelper.Handle("当前版本号与历史版本号不同,或版本号相同但未执行过脚本,开始执行脚本");
+            LogHelper.Handle($"版本 {script.Version} 处于 legacy 边界内,跳过");
+            return true;
+        }
 
-            var scriptSqlVersions = GetScriptSqlVersions();
+        return false;
+    }
 
-            // 若不存在当前版本的脚本,则只保存当前版本信息,不执行脚本
-            if (scriptSqlVersions.All(s => s.Version.CompareTo(currentVersion) < 0))
-            {
-                LogHelper.Handle("不存在当前版本的脚本,只保存当前版本信息,不执行脚本");
+    private static void ExecuteOneMigrationScript(ISqlSugarClient db, MigrationScript script, string? databaseName)
+    {
+        var sql = File.ReadAllText(script.FilePath);
+        if (string.IsNullOrWhiteSpace(sql))
+        {
+            LogHelper.Handle($"版本 {script.Version} 脚本为空,记录 Skipped");
+            UpsertMigrationLog(db, script, databaseName, StatusSkipped, 0, 0, VerifyNotConfigured, null, 0);
+            return;
+        }
 
-                // 保存当前版本信息
-                SetEntryAssemblyCurrentVersion(currentVersion, false);
+        if (SqlScriptSplitter.ContainsDelimiterDirective(sql))
+        {
+            throw new InvalidOperationException(
+                $"版本脚本 {script.FileName} 包含 DELIMITER 指令,当前执行器不支持,请改为普通 SQL 或手工执行。");
+        }
 
-                return app;
-            }
+        var startedAt = DateTime.Now;
+        var sw = Stopwatch.StartNew();
+        UpsertMigrationLog(db, script, databaseName, StatusRunning, 0, 0, VerifyNotConfigured, null, 0);
 
-            // 执行脚本
-            foreach (var sqlFileInfo in scriptSqlVersions)
-            {
-                var sqlVersion = sqlFileInfo.Version;
+        try
+        {
+            LogHelper.Handle($"执行版本 {script.Version} 脚本 {script.FileName},SHA256={script.Hash}");
+
+            var executeResult = SqlScriptSplitter.Execute(db, sql);
+            var verifyStatus = RunVerifyScriptIfExists(db, script);
+
+            sw.Stop();
+            UpsertMigrationLog(
+                db,
+                script,
+                databaseName,
+                StatusSuccess,
+                executeResult.StatementCount,
+                executeResult.AffectedRows,
+                verifyStatus,
+                null,
+                sw.ElapsedMilliseconds);
+
+            LogHelper.Handle(
+                $"版本 {script.Version} 成功:语句 {executeResult.StatementCount} 条,影响行 {executeResult.AffectedRows},校验 {verifyStatus},耗时 {sw.ElapsedMilliseconds}ms");
+        }
+        catch (Exception ex)
+        {
+            sw.Stop();
+            var message = BuildExceptionMessage(ex);
+            UpsertMigrationLog(
+                db,
+                script,
+                databaseName,
+                StatusFailed,
+                0,
+                0,
+                VerifyFailed,
+                message,
+                sw.ElapsedMilliseconds);
+
+            LogHelper.Error($"AutoVersionUpdate 版本 {script.Version} 失败:{message}");
+            throw new InvalidOperationException($"AutoVersionUpdate 版本 {script.Version} 执行失败:{message}", ex);
+        }
+    }
 
-                // 只执行大于历史版本的脚本,或者当前版本但未执行过
-                if (sqlVersion.CompareTo(historyVersion) < 0)
-                {
-                    LogHelper.Handle($"版本{sqlVersion}低于历史版本,跳过");
-                    continue;
-                }
-                if (sqlVersion == historyVersion && historyIsRunScript)
-                {
-                    LogHelper.Handle($"版本{sqlVersion}等于历史版本,且已执行过脚本,跳过");
-                    continue;
-                }
+    private static string RunVerifyScriptIfExists(ISqlSugarClient db, MigrationScript script)
+    {
+        var verifyPath = Path.ChangeExtension(script.FilePath, ".verify.sql");
+        if (!File.Exists(verifyPath))
+            return VerifyNotConfigured;
 
-                // 执行脚本
-                var sql = File.ReadAllText(sqlFileInfo.FilePath);
-                if (sql != null)
-                {
-                    LogHelper.Handle($"执行版本{sqlVersion}脚本");
+        var verifySql = File.ReadAllText(verifyPath);
+        var statements = SqlScriptSplitter.Split(verifySql);
+        if (statements.Count == 0)
+            return VerifyNotConfigured;
 
-                    HandleSqlScript(app, sql, sqlVersion);
-                }
+        for (var i = 0; i < statements.Count; i++)
+        {
+            var statement = statements[i];
+            var result = db.Ado.SqlQuerySingle<object>(statement);
+            if (!IsTruthy(result))
+            {
+                throw new InvalidOperationException(
+                    $"校验 SQL 第 {i + 1} 条未通过:{TrimForLog(statement)}");
             }
         }
 
-        LogHelper.Success("AutoVersionUpdate 中间件结束");
+        return VerifySuccess;
+    }
 
-        return app;
+    private static bool IsTruthy(object? value)
+    {
+        if (value == null || value is DBNull)
+            return false;
+
+        return value switch
+        {
+            bool b => b,
+            byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal =>
+                Convert.ToDecimal(value) != 0,
+            string s => !string.IsNullOrWhiteSpace(s) &&
+                          !string.Equals(s, "0", StringComparison.OrdinalIgnoreCase) &&
+                          !string.Equals(s, "false", StringComparison.OrdinalIgnoreCase),
+            _ => true
+        };
     }
 
-    #region 辅助方法
+    private static void EnsureMigrationLogTable(ISqlSugarClient db)
+    {
+        db.Ado.ExecuteCommand(
+            """
+            CREATE TABLE IF NOT EXISTS sys_db_migration_log (
+                id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+                version VARCHAR(50) NOT NULL COMMENT '脚本版本',
+                file_name VARCHAR(255) NOT NULL COMMENT '脚本文件名',
+                file_hash VARCHAR(128) NOT NULL COMMENT '脚本 SHA256',
+                status VARCHAR(20) NOT NULL COMMENT 'Running/Success/Failed/Skipped',
+                started_at DATETIME(3) NOT NULL COMMENT '开始时间',
+                finished_at DATETIME(3) NULL COMMENT '结束时间',
+                elapsed_ms BIGINT NULL COMMENT '耗时毫秒',
+                statement_count INT NOT NULL DEFAULT 0 COMMENT '执行语句数',
+                affected_rows BIGINT NULL COMMENT '影响行数合计',
+                verify_status VARCHAR(20) NULL COMMENT '校验状态',
+                error_message LONGTEXT NULL COMMENT '错误信息',
+                database_name VARCHAR(128) NULL COMMENT '执行数据库',
+                created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+                updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+                PRIMARY KEY (id),
+                UNIQUE KEY uk_sys_db_migration_log_version (version),
+                KEY idx_sys_db_migration_log_status (status),
+                KEY idx_sys_db_migration_log_started_at (started_at)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库版本脚本执行记录'
+            """);
+    }
+
+    private static List<MigrationLogRow> LoadMigrationLogs(ISqlSugarClient db)
+    {
+        try
+        {
+            return db.Ado.SqlQuery<MigrationLogRow>(
+                $"""
+                 SELECT version AS Version, file_hash AS FileHash, status AS Status
+                 FROM {MigrationLogTable}
+                 """);
+        }
+        catch
+        {
+            return [];
+        }
+    }
+
+    private static void UpsertMigrationLog(
+        ISqlSugarClient db,
+        MigrationScript script,
+        string? databaseName,
+        string status,
+        int statementCount,
+        long affectedRows,
+        string verifyStatus,
+        string? errorMessage,
+        long elapsedMs)
+    {
+        var now = DateTime.Now;
+        var existing = db.Ado.SqlQuerySingle<int?>(
+            $"SELECT id FROM {MigrationLogTable} WHERE version = @version LIMIT 1",
+            new { version = script.Version });
+
+        if (existing is > 0)
+        {
+            db.Ado.ExecuteCommand(
+                $"""
+                 UPDATE {MigrationLogTable}
+                 SET file_name = @fileName,
+                     file_hash = @fileHash,
+                     status = @status,
+                     started_at = CASE WHEN @status = '{StatusRunning}' THEN @startedAt ELSE started_at END,
+                     finished_at = @finishedAt,
+                     elapsed_ms = @elapsedMs,
+                     statement_count = @statementCount,
+                     affected_rows = @affectedRows,
+                     verify_status = @verifyStatus,
+                     error_message = @errorMessage,
+                     database_name = @databaseName,
+                     updated_at = @updatedAt
+                 WHERE version = @version
+                 """,
+                new
+                {
+                    version = script.Version,
+                    fileName = script.FileName,
+                    fileHash = script.Hash,
+                    status,
+                    startedAt = now,
+                    finishedAt = status is StatusRunning ? (DateTime?)null : now,
+                    elapsedMs = status is StatusRunning ? (long?)null : elapsedMs,
+                    statementCount,
+                    affectedRows,
+                    verifyStatus,
+                    errorMessage,
+                    databaseName,
+                    updatedAt = now
+                });
+            return;
+        }
+
+        db.Ado.ExecuteCommand(
+            $"""
+             INSERT INTO {MigrationLogTable}
+             (version, file_name, file_hash, status, started_at, finished_at, elapsed_ms,
+              statement_count, affected_rows, verify_status, error_message, database_name)
+             VALUES
+             (@version, @fileName, @fileHash, @status, @startedAt, @finishedAt, @elapsedMs,
+              @statementCount, @affectedRows, @verifyStatus, @errorMessage, @databaseName)
+             """,
+            new
+            {
+                version = script.Version,
+                fileName = script.FileName,
+                fileHash = script.Hash,
+                status,
+                startedAt = now,
+                finishedAt = status is StatusRunning ? (DateTime?)null : now,
+                elapsedMs = status is StatusRunning ? (long?)null : elapsedMs,
+                statementCount,
+                affectedRows,
+                verifyStatus,
+                errorMessage,
+                databaseName
+            });
+    }
+
+    private static List<MigrationScript> LoadMigrationScripts()
+    {
+        var path = Path.Combine(AppContext.BaseDirectory, "UpdateScripts");
+        if (!Directory.Exists(path))
+            return [];
+
+        return Directory.GetFiles(path, "*.sql", SearchOption.TopDirectoryOnly)
+            .Where(file =>
+            {
+                var name = Path.GetFileName(file);
+                return !name.EndsWith(".verify.sql", StringComparison.OrdinalIgnoreCase);
+            })
+            .Select(file =>
+            {
+                var versionText = Path.GetFileNameWithoutExtension(file);
+                if (!Version.TryParse(versionText, out var parsedVersion))
+                    return null;
+
+                return new MigrationScript(
+                    versionText,
+                    parsedVersion,
+                    file,
+                    Path.GetFileName(file),
+                    ComputeSha256(file));
+            })
+            .Where(x => x != null)
+            .Cast<MigrationScript>()
+            .OrderBy(x => x.ParsedVersion)
+            .ToList();
+    }
 
-    /// <summary>
-    /// 获取入口程序集当前版本信息
-    /// </summary>
-    /// <returns></returns>
     private static string GetEntryAssemblyCurrentVersion()
     {
         var entryAssemblyVersion = ReflectionHelper.GetEntryAssemblyVersion();
         return entryAssemblyVersion.ToString(3);
     }
 
-    /// <summary>
-    /// 设置入口程序集当前版本信息
-    /// </summary>
-    /// <param name="version"></param>
-    /// <param name="isRunScript"></param>
     private static void SetEntryAssemblyCurrentVersion(string version, bool isRunScript)
     {
         var path = Path.Combine(AppContext.BaseDirectory, "version.txt");
@@ -150,91 +423,323 @@ public static class AutoVersionUpdate
         File.WriteAllText(path, $"{version}^{now:yyyy-MM-dd HH:mm:ss}^{isRunScript}");
     }
 
-    /// <summary>
-    /// 获取入口程序集上一次运行版本信息
-    /// </summary>
-    /// <returns></returns>
     private static HistoryVersionInfo GetEntryAssemblyHistoryVersionInfo()
     {
         var path = Path.Combine(AppContext.BaseDirectory, "version.txt");
+        if (!File.Exists(path))
+            return new HistoryVersionInfo(string.Empty, string.Empty, false);
+
+        var info = File.ReadAllText(path);
+        if (!info.Contains('^'))
+            return new HistoryVersionInfo(string.Empty, string.Empty, false);
+
+        var parts = info.Split('^');
+        var version = parts.Length > 0 ? parts[0] : string.Empty;
+        var date = parts.Length > 1 ? parts[1] : string.Empty;
+        var isRunScript = parts.Length > 2 && parts[2].ToBoolean();
+        return new HistoryVersionInfo(version, date, isRunScript);
+    }
 
-        // 检查文件是否存在
-        if (File.Exists(path))
-        {
-            // 文件存在时读取内容
-            var info = File.ReadAllText(path);
+    private static Version ParseVersion(string value)
+    {
+        if (!Version.TryParse(value, out var version))
+            throw new InvalidOperationException($"非法版本号:{value}");
+        return version;
+    }
 
-            if (info.Contains('^'))
-            {
-                var parts = info.Split('^');
-                var version = parts.Length > 0 ? parts[0].ToString() : string.Empty;
-                var date = parts.Length > 1 ? parts[1] : string.Empty;
-                var isRunScript = parts.Length > 2 ? parts[2].ToBoolean() : false;
+    private static string ComputeSha256(string filePath)
+    {
+        using var sha = SHA256.Create();
+        using var stream = File.OpenRead(filePath);
+        return Convert.ToHexString(sha.ComputeHash(stream));
+    }
 
-                return new HistoryVersionInfo(version, date, isRunScript);
-            }
+    private static string? GetDatabaseName(ISqlSugarClient db)
+    {
+        try
+        {
+            return db.Ado.GetString("SELECT DATABASE()");
+        }
+        catch
+        {
+            return null;
         }
+    }
 
-        // 文件不存在或内容格式不正确时返回默认值
-        return new HistoryVersionInfo(string.Empty, string.Empty, false);
+    private static string BuildExceptionMessage(Exception ex)
+    {
+        var messages = new List<string>();
+        for (var current = ex; current != null; current = current.InnerException)
+            messages.Add(current.Message);
+        return string.Join(" | ", messages);
     }
 
-    /// <summary>
-    /// 获取程序目录下的脚本 SQL 文件版本
-    /// </summary>
-    /// <returns></returns>
-    private static List<SqlFileInfo> GetScriptSqlVersions()
+    private static string TrimForLog(string sql, int maxLength = 300)
     {
-        // 获取所有脚本文件
-        var path = Path.Combine(AppContext.BaseDirectory, "UpdateScripts");
-        var scriptFiles = Directory.GetFiles(path, "*.sql").ToList();
+        var normalized = sql.Replace('\r', ' ').Replace('\n', ' ').Trim();
+        return normalized.Length <= maxLength ? normalized : normalized[..maxLength] + "...";
+    }
 
-        var sqlVersions = scriptFiles
-            .Select(s => new SqlFileInfo(Path.GetFileNameWithoutExtension(s), s))
-            .OrderBy(s => s.Version).ToList();
-        return sqlVersions;
+    private sealed record MigrationScript(
+        string Version,
+        Version ParsedVersion,
+        string FilePath,
+        string FileName,
+        string Hash);
+
+    private sealed class MigrationLogRow
+    {
+        public string Version { get; set; } = string.Empty;
+        public string FileHash { get; set; } = string.Empty;
+        public string Status { get; set; } = string.Empty;
     }
 
-    /// <summary>
-    /// 保存当前版本信息
-    /// </summary>
-    /// <param name="app"></param>
-    /// <param name="sql"></param>
-    /// <param name="sqlVersion"></param>
-    private static void HandleSqlScript(IApplicationBuilder app, string sql, string sqlVersion)
+    private sealed record ScriptExecuteResult(int StatementCount, long AffectedRows);
+
+    private static class SqlScriptSplitter
     {
-        using var scope = App.GetRequiredService<IServiceScopeFactory>().CreateScope();
-        var dbContext = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
+        public static bool ContainsDelimiterDirective(string sql) =>
+            sql.Contains("DELIMITER", StringComparison.OrdinalIgnoreCase);
 
-        var isSuccess = false;
+        public static ScriptExecuteResult Execute(ISqlSugarClient db, string sql)
+        {
+            if (RequiresSameSessionExecution(sql))
+                return ExecuteAsBatch(db, sql);
 
-        try
+            return ExecuteSplitWithSharedConnection(db, sql);
+        }
+
+        private static bool RequiresSameSessionExecution(string sql) =>
+            sql.Contains("PREPARE", StringComparison.OrdinalIgnoreCase) ||
+            Regex.IsMatch(sql, @"\bSET\s+@", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant) ||
+            Regex.IsMatch(sql, @"\bSELECT\b.+\bINTO\s+@", RegexOptions.IgnoreCase | RegexOptions.Singleline);
+
+        private static ScriptExecuteResult ExecuteAsBatch(ISqlSugarClient db, string sql)
         {
-            // 开启事务
-            dbContext.Ado.BeginTran();
-            dbContext.Ado.ExecuteCommand(sql);
-            dbContext.Ado.CommitTran();
-            isSuccess = true;
+            var wasOpen = EnsureConnectionOpen(db);
+            try
+            {
+                var affected = db.Ado.ExecuteCommand(sql);
+                var statementCount = Math.Max(1, Split(sql).Count);
+                return new ScriptExecuteResult(statementCount, affected);
+            }
+            finally
+            {
+                RestoreConnection(db, wasOpen);
+            }
         }
-        catch (Exception ex)
+
+        private static ScriptExecuteResult ExecuteSplitWithSharedConnection(ISqlSugarClient db, string sql)
+        {
+            var statements = Split(sql);
+            long affectedRows = 0;
+            var wasOpen = EnsureConnectionOpen(db);
+
+            try
+            {
+                for (var i = 0; i < statements.Count; i++)
+                {
+                    var statement = statements[i];
+                    try
+                    {
+                        affectedRows += db.Ado.ExecuteCommand(statement);
+                    }
+                    catch (Exception ex)
+                    {
+                        throw new InvalidOperationException(
+                            $"第 {i + 1}/{statements.Count} 条语句失败:{TrimForLog(statement)} | {ex.Message}", ex);
+                    }
+                }
+
+                return new ScriptExecuteResult(statements.Count, affectedRows);
+            }
+            finally
+            {
+                RestoreConnection(db, wasOpen);
+            }
+        }
+
+        private static bool EnsureConnectionOpen(ISqlSugarClient db)
+        {
+            var connection = db.Ado.Connection;
+            if (connection.State == ConnectionState.Open)
+                return true;
+
+            connection.Open();
+            return false;
+        }
+
+        private static void RestoreConnection(ISqlSugarClient db, bool wasOpen)
         {
-            dbContext.Ado.RollbackTran();
-            LogHelper.Error($"AutoVersionUpdate 执行 SQL 脚本出错,版本:{sqlVersion},错误:{ex.Message}");
+            if (wasOpen)
+                return;
+
+            var connection = db.Ado.Connection;
+            if (connection.State == ConnectionState.Open)
+                connection.Close();
         }
-        finally
+
+        public static List<string> Split(string sql)
         {
-            if (isSuccess)
+            var statements = new List<string>();
+            if (string.IsNullOrWhiteSpace(sql))
+                return statements;
+
+            var current = new StringBuilder();
+            var inSingleQuote = false;
+            var inDoubleQuote = false;
+            var inBacktick = false;
+            var inLineComment = false;
+            var inBlockComment = false;
+
+            for (var i = 0; i < sql.Length; i++)
             {
-                // 保存当前版本信息
-                SetEntryAssemblyCurrentVersion(sqlVersion, true);
+                var ch = sql[i];
+                var next = i + 1 < sql.Length ? sql[i + 1] : '\0';
+
+                if (inLineComment)
+                {
+                    current.Append(ch);
+                    if (ch is '\n' or '\r')
+                        inLineComment = false;
+                    continue;
+                }
+
+                if (inBlockComment)
+                {
+                    current.Append(ch);
+                    if (ch == '*' && next == '/')
+                    {
+                        current.Append(next);
+                        i++;
+                        inBlockComment = false;
+                    }
+
+                    continue;
+                }
+
+                if (!inSingleQuote && !inDoubleQuote && !inBacktick)
+                {
+                    if (ch == '-' && next == '-')
+                    {
+                        inLineComment = true;
+                        current.Append(ch);
+                        continue;
+                    }
+
+                    if (ch == '#')
+                    {
+                        inLineComment = true;
+                        current.Append(ch);
+                        continue;
+                    }
+
+                    if (ch == '/' && next == '*')
+                    {
+                        inBlockComment = true;
+                        current.Append(ch);
+                        continue;
+                    }
+                }
+
+                if (!inDoubleQuote && !inBacktick && ch == '\'' && !inSingleQuote)
+                {
+                    inSingleQuote = true;
+                    current.Append(ch);
+                    continue;
+                }
+
+                if (inSingleQuote)
+                {
+                    current.Append(ch);
+                    if (ch == '\'' && next == '\'')
+                    {
+                        current.Append(next);
+                        i++;
+                        continue;
+                    }
+
+                    if (ch == '\\' && next != '\0')
+                    {
+                        current.Append(next);
+                        i++;
+                        continue;
+                    }
+
+                    if (ch == '\'')
+                        inSingleQuote = false;
+                    continue;
+                }
+
+                if (!inSingleQuote && !inBacktick && ch == '"' && !inDoubleQuote)
+                {
+                    inDoubleQuote = true;
+                    current.Append(ch);
+                    continue;
+                }
+
+                if (inDoubleQuote)
+                {
+                    current.Append(ch);
+                    if (ch == '"' && next == '"')
+                    {
+                        current.Append(next);
+                        i++;
+                        continue;
+                    }
+
+                    if (ch == '\\' && next != '\0')
+                    {
+                        current.Append(next);
+                        i++;
+                        continue;
+                    }
+
+                    if (ch == '"')
+                        inDoubleQuote = false;
+                    continue;
+                }
+
+                if (!inSingleQuote && !inDoubleQuote && ch == '`' && !inBacktick)
+                {
+                    inBacktick = true;
+                    current.Append(ch);
+                    continue;
+                }
+
+                if (inBacktick)
+                {
+                    current.Append(ch);
+                    if (ch == '`')
+                        inBacktick = false;
+                    continue;
+                }
+
+                if (ch == ';')
+                {
+                    AppendStatement(statements, current);
+                    continue;
+                }
+
+                current.Append(ch);
             }
+
+            AppendStatement(statements, current);
+            return statements;
         }
-    }
 
-    #endregion 辅助方法
+        private static void AppendStatement(List<string> statements, StringBuilder current)
+        {
+            var text = current.ToString().Trim();
+            current.Clear();
+            if (string.IsNullOrWhiteSpace(text))
+                return;
+
+            statements.Add(text);
+        }
+    }
 }
 
-public record SqlFileInfo(string Version, string FilePath);
 public record HistoryVersionInfo(string Version, string Date, bool IsRunScript);
 
-#endif // NET10_0_OR_GREATER
+#endif // NET10_0_OR_GREATER

+ 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.167</AssemblyVersion>
-    <FileVersion>1.0.167</FileVersion>
-    <Version>1.0.167</Version>
+    <AssemblyVersion>1.0.168</AssemblyVersion>
+    <FileVersion>1.0.168</FileVersion>
+    <Version>1.0.168</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 44 - 11
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.DashboardPage.cs

@@ -1,6 +1,7 @@
 using System.Text.Json;
 using Admin.NET.Plugin.AiDOP.Entity;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.SmartOps;
 using SqlSugar;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers;
@@ -16,11 +17,29 @@ public partial class AidopKanbanController
     /// 通用动态详情看板:返回页面区域、组件配置、指标定义、当前值和趋势。
     /// </summary>
     [HttpGet("dashboard-page/{moduleCode}")]
-    public async Task<IActionResult> GetDashboardPage(string moduleCode, [FromQuery] long factoryId = 1)
+    public async Task<IActionResult> GetDashboardPage(
+        string moduleCode,
+        [FromQuery] long factoryId = 1,
+        [FromQuery] string? dateStart = null,
+        [FromQuery] string? dateEnd = null,
+        [FromQuery] string? product = null,
+        [FromQuery] string? orderNo = null,
+        [FromQuery] string? productionLine = null,
+        [FromQuery] string? customer = null,
+        [FromQuery] string? supplier = null,
+        [FromQuery] string? material = null,
+        [FromQuery] string? poNo = null,
+        [FromQuery] string? warehouse = null,
+        [FromQuery] string? workOrder = null,
+        [FromQuery] string? equipment = null,
+        [FromQuery] string? outboundNo = null)
     {
         var mc = NormalizeDynamicDashboardModule(moduleCode);
         if (mc == null) return BadRequest(new { message = "moduleCode 仅支持 S1/S2/S3/S4/S5/S6/S7/S9" });
         var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        var filter = ParseDashboardFilter(
+            dateStart, dateEnd, product, orderNo, productionLine,
+            customer, supplier, material, poNo, warehouse, workOrder, equipment, outboundNo);
 
         var kpis = await _db.Queryable<AdoSmartOpsKpiMaster>()
             .Where(x => x.TenantId == tenantId && x.ModuleCode == mc && x.IsEnabled)
@@ -40,7 +59,8 @@ public partial class AidopKanbanController
             ? persistedWidgets.Select(x => MapPersistedWidget(x, kpiByCode)).Where(x => x.MetricCodes.Count > 0).ToList()
             : BuildDefaultDashboardWidgets(mc, kpis);
 
-        var values = await LoadDashboardValuesAsync(tenantId, factoryId, mc);
+        var valueBundle = await ResolveDashboardValuesBundleAsync(tenantId, factoryId, mc, filter, kpis);
+        var values = valueBundle.Values;
         var sections = BuildDashboardSections(widgets, values, kpiByCode);
         var tabs = BuildDashboardTabs(kpis);
         var navigationMode = ResolveNavigationMode(pageConfig?.NavigationMode, tabs.Count);
@@ -55,13 +75,9 @@ public partial class AidopKanbanController
             tabSource = pageConfig?.TabSource ?? "l1_metrics",
             defaultTabMetricCode = pageConfig?.DefaultTabMetricCode ?? tabs.FirstOrDefault()?.MetricCode,
             tabs,
-            dataSource = new
-            {
-                metricDefinition = "ado_smart_ops_kpi_master",
-                valueTables = new[] { "ado_s9_kpi_value_l1_day", "ado_s9_kpi_value_l2_day", "ado_s9_kpi_value_l3_day", "ado_s9_kpi_value_l4_day" },
-                formulaStatus = "formula_expr_validated_pending_runtime_engine"
-            },
-            metrics = kpis.Select(x => BuildDashboardMetricPayload(x, values)).ToList(),
+            dataSource = BuildDataSourcePayload(valueBundle),
+            metrics = kpis.Select(x => BuildDashboardMetricPayload(
+                x, values, valueBundle.MetricFilterModes, valueBundle.UnsupportedFiltersByMetric)).ToList(),
             sections
         });
     }
@@ -453,7 +469,9 @@ ORDER BY metric_code, biz_date
 
     private static object BuildDashboardMetricPayload(
         AdoSmartOpsKpiMaster kpi,
-        Dictionary<string, DashboardMetricValueModel> values)
+        Dictionary<string, DashboardMetricValueModel> values,
+        Dictionary<string, string>? filterModes = null,
+        Dictionary<string, string[]>? unsupportedFilters = null)
     {
         values.TryGetValue(kpi.MetricCode, out var value);
         var level = value?.Level ?? kpi.MetricLevel;
@@ -464,6 +482,10 @@ ORDER BY metric_code, biz_date
             kpi.YellowThreshold,
             kpi.RedThreshold);
         var gap = AidopS4KpiMerge.GapValue(value?.MetricValue, value?.TargetValue);
+        var resolvedFilterMode = filterModes?.GetValueOrDefault(kpi.MetricCode)
+            ?? SmartOpsKpiAggregateRuleRegistry.FilterSummaryOnly;
+        var unsupported = unsupportedFilters?.GetValueOrDefault(kpi.MetricCode) ?? Array.Empty<string>();
+        var isFiltered = string.Equals(resolvedFilterMode, SmartOpsKpiAggregateRuleRegistry.FilterFull, StringComparison.OrdinalIgnoreCase);
         return new
         {
             id = kpi.Id,
@@ -486,10 +508,21 @@ ORDER BY metric_code, biz_date
             trendFlag = value?.TrendFlag,
             gapValue = gap,
             gapLabel = FormatGapLabelGeneric(gap, kpi.Unit),
-            trend = value?.Trend ?? new List<DashboardTrendPointModel>()
+            trend = value?.Trend ?? new List<DashboardTrendPointModel>(),
+            filterMode = resolvedFilterMode,
+            scopeLabel = ResolveScopeLabel(resolvedFilterMode),
+            unsupportedFilters = unsupported,
+            isFiltered
         };
     }
 
+    private static string ResolveScopeLabel(string? filterMode) => filterMode switch
+    {
+        SmartOpsKpiAggregateRuleRegistry.FilterFull => "已按当前条件过滤",
+        SmartOpsKpiAggregateRuleRegistry.FilterPartial => "部分维度已过滤",
+        _ => "模块汇总口径"
+    };
+
     private static List<string> ParseMetricCodes(string? json)
     {
         if (string.IsNullOrWhiteSpace(json)) return new List<string>();

+ 23 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.Diagnosis.cs

@@ -9,18 +9,38 @@ public partial class AidopKanbanController
     /// 智慧诊断:按模块和当前 L1 指标返回 L2/L3/L4 诊断矩阵。
     /// </summary>
     [HttpGet("smart-diagnosis/{moduleCode}")]
-    public async Task<IActionResult> GetSmartDiagnosis(string moduleCode, [FromQuery] string? metricCode = null, [FromQuery] long factoryId = 1)
+    public async Task<IActionResult> GetSmartDiagnosis(
+        string moduleCode,
+        [FromQuery] string? metricCode = null,
+        [FromQuery] long factoryId = 1,
+        [FromQuery] string? dateStart = null,
+        [FromQuery] string? dateEnd = null,
+        [FromQuery] string? product = null,
+        [FromQuery] string? orderNo = null,
+        [FromQuery] string? productionLine = null,
+        [FromQuery] string? customer = null,
+        [FromQuery] string? supplier = null,
+        [FromQuery] string? material = null,
+        [FromQuery] string? poNo = null,
+        [FromQuery] string? warehouse = null,
+        [FromQuery] string? workOrder = null,
+        [FromQuery] string? equipment = null,
+        [FromQuery] string? outboundNo = null)
     {
         var mc = NormalizeDynamicDashboardModule(moduleCode);
         if (mc == null) return BadRequest(new { message = "moduleCode 仅支持 S1/S2/S3/S4/S5/S6/S7/S9" });
 
         var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        var filter = ParseDashboardFilter(
+            dateStart, dateEnd, product, orderNo, productionLine,
+            customer, supplier, material, poNo, warehouse, workOrder, equipment, outboundNo);
         var kpis = await _db.Queryable<AdoSmartOpsKpiMaster>()
             .Where(x => x.TenantId == tenantId && x.ModuleCode == mc && x.IsEnabled)
             .OrderBy(x => x.MetricLevel)
             .OrderBy(x => x.SortNo)
             .ToListAsync();
-        var values = await LoadDashboardValuesAsync(tenantId, factoryId, mc);
+        var valueBundle = await ResolveDashboardValuesBundleAsync(tenantId, factoryId, mc, filter, kpis);
+        var values = valueBundle.Values;
         var l1Metrics = kpis.Where(x => x.MetricLevel == 1).OrderBy(x => x.SortNo).ToList();
         var root = ResolveDiagnosisRoot(l1Metrics, values, metricCode);
         if (root == null) return Ok(new { moduleCode = mc, metricCode, title = $"{mc} 智慧诊断", stages = Array.Empty<object>() });
@@ -80,6 +100,7 @@ public partial class AidopKanbanController
             metricName = root.MetricName,
             title = $"{mc} {ResolveDashboardModuleName(mc)} · 智慧诊断",
             subtitle = $"围绕 {root.MetricName} 自动定位下层问题指标",
+            dataSource = BuildDataSourcePayload(valueBundle),
             root = new
             {
                 metricCode = root.MetricCode,

+ 85 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.SmartOpsFilter.cs

@@ -0,0 +1,85 @@
+using Admin.NET.Plugin.AiDOP.Entity;
+using Admin.NET.Plugin.AiDOP.SmartOps;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers;
+
+public partial class AidopKanbanController
+{
+    private sealed record DashboardValuesBundle(
+        Dictionary<string, DashboardMetricValueModel> Values,
+        string Scope,
+        Dictionary<string, string?> FilterContext,
+        Dictionary<string, string> MetricFilterModes,
+        Dictionary<string, string[]> UnsupportedFiltersByMetric);
+
+    private static SmartOpsDashboardFilter ParseDashboardFilter(
+        string? dateStart,
+        string? dateEnd,
+        string? product,
+        string? orderNo,
+        string? productionLine,
+        string? customer,
+        string? supplier,
+        string? material,
+        string? poNo,
+        string? warehouse,
+        string? workOrder,
+        string? equipment,
+        string? outboundNo) =>
+        SmartOpsDashboardFilter.FromQuery(
+            dateStart, dateEnd, product, orderNo, productionLine,
+            customer, supplier, material, poNo, warehouse, workOrder, equipment, outboundNo);
+
+    private async Task<DashboardValuesBundle> ResolveDashboardValuesBundleAsync(
+        long tenantId,
+        long factoryId,
+        string moduleCode,
+        SmartOpsDashboardFilter filter,
+        List<AdoSmartOpsKpiMaster> kpis)
+    {
+        var summaryValues = await LoadDashboardValuesAsync(tenantId, factoryId, moduleCode);
+        if (filter.IsEmpty)
+        {
+            return new DashboardValuesBundle(
+                summaryValues,
+                "module_summary",
+                new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
+                new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
+                new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase));
+        }
+
+        var filtered = await _atomicQuery.QueryAsync(tenantId, factoryId, moduleCode, filter, kpis);
+        var merged = new Dictionary<string, DashboardMetricValueModel>(summaryValues, StringComparer.OrdinalIgnoreCase);
+
+        foreach (var (metricCode, atomicValue) in filtered.Values)
+        {
+            summaryValues.TryGetValue(metricCode, out var summaryValue);
+            merged[metricCode] = new DashboardMetricValueModel(
+                atomicValue.Level,
+                atomicValue.MetricValue,
+                summaryValue?.TargetValue,
+                summaryValue?.StatusColor,
+                summaryValue?.TrendFlag,
+                atomicValue.Trend
+                    .Select(x => new DashboardTrendPointModel(x.Date, x.Value))
+                    .ToList());
+        }
+
+        return new DashboardValuesBundle(
+            merged,
+            filtered.Scope,
+            filtered.FilterContext,
+            filtered.MetricFilterModes,
+            filtered.UnsupportedFiltersByMetric);
+    }
+
+    private static object BuildDataSourcePayload(DashboardValuesBundle bundle) => new
+    {
+        metricDefinition = "ado_smart_ops_kpi_master",
+        valueTables = new[] { "ado_s9_kpi_value_l1_day", "ado_s9_kpi_value_l2_day", "ado_s9_kpi_value_l3_day", "ado_s9_kpi_value_l4_day" },
+        atomicTable = "ado_smart_ops_kpi_atomic_day",
+        formulaStatus = "formula_expr_validated_pending_runtime_engine",
+        scope = bundle.Scope,
+        filterContext = bundle.FilterContext
+    };
+}

+ 15 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.SmartOpsFilterSearch.cs

@@ -257,6 +257,21 @@ public partial class AidopKanbanController
             LIMIT @Limit
             """, tenantId, keyword, limit - rows.Count), limit);
 
+        if (rows.Count >= limit) return rows;
+
+        AppendCodeNameRows(rows, seen, await QueryCodeNameRowsAsync(
+            """
+            SELECT TRIM(IFNULL(so.customer_no, '')) AS Code,
+                   TRIM(IFNULL(so.customer_name, '')) AS Name
+            FROM mdp_std_so so
+            WHERE so.tenant_id = @TenantId
+              AND TRIM(IFNULL(so.customer_no, '')) <> ''
+              AND (@Kw IS NULL OR so.customer_name LIKE @Kw OR so.customer_no LIKE @Kw)
+            GROUP BY TRIM(IFNULL(so.customer_no, '')), TRIM(IFNULL(so.customer_name, ''))
+            ORDER BY Code
+            LIMIT @Limit
+            """, tenantId, keyword, limit - rows.Count), limit);
+
         return rows;
     }
 

+ 26 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs

@@ -1,9 +1,11 @@
 using Admin.NET.Core;
 using Admin.NET.Plugin.AiDOP.Entity;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Order;
 using Admin.NET.Plugin.AiDOP.ProcurementExecution;
 using Admin.NET.Plugin.AiDOP.Production;
 using Admin.NET.Plugin.AiDOP.Supply;
+using Admin.NET.Plugin.AiDOP.SmartOps;
 using SqlSugar;
 
 namespace Admin.NET.Plugin.AiDOP.Controllers;
@@ -15,20 +17,26 @@ namespace Admin.NET.Plugin.AiDOP.Controllers;
 public partial class AidopKanbanController : ControllerBase
 {
     private readonly ISqlSugarClient _db;
+    private readonly S1MdpSyncTransformService _s1MdpSyncTransformService;
     private readonly S2MdpSyncTransformService _s2MdpSyncTransformService;
     private readonly S3MdpSyncTransformService _s3MdpSyncTransformService;
     private readonly S4MdpSyncTransformService _s4MdpSyncTransformService;
+    private readonly SmartOpsKpiAtomicQueryService _atomicQuery;
 
     public AidopKanbanController(
         ISqlSugarClient db,
+        S1MdpSyncTransformService s1MdpSyncTransformService,
         S2MdpSyncTransformService s2MdpSyncTransformService,
         S3MdpSyncTransformService s3MdpSyncTransformService,
-        S4MdpSyncTransformService s4MdpSyncTransformService)
+        S4MdpSyncTransformService s4MdpSyncTransformService,
+        SmartOpsKpiAtomicQueryService atomicQuery)
     {
         _db = db;
+        _s1MdpSyncTransformService = s1MdpSyncTransformService;
         _s2MdpSyncTransformService = s2MdpSyncTransformService;
         _s3MdpSyncTransformService = s3MdpSyncTransformService;
         _s4MdpSyncTransformService = s4MdpSyncTransformService;
+        _atomicQuery = atomicQuery;
     }
 
     [HttpGet("home-l1")]
@@ -325,6 +333,22 @@ LIMIT 60
         return decomposition;
     }
 
+    [HttpPost("s1-mdp/refresh")]
+    public async Task<IActionResult> RefreshS1Mdp(CancellationToken cancellationToken)
+    {
+        var result = await _s1MdpSyncTransformService.RunFullAsync(cancellationToken, "MANUAL");
+        return Ok(new
+        {
+            ok = true,
+            result.BatchId,
+            result.StageRows,
+            result.StandardRows,
+            result.DwdRows,
+            result.KpiRows,
+            result.AtomicRows
+        });
+    }
+
     [HttpPost("s2-mdp/refresh")]
     public async Task<IActionResult> RefreshS2Mdp(CancellationToken cancellationToken)
     {
@@ -376,6 +400,7 @@ LIMIT 60
         var mc = string.IsNullOrWhiteSpace(moduleCode) ? string.Empty : moduleCode.Trim().ToUpperInvariant();
         return mc switch
         {
+            "S1" => await RefreshS1Mdp(cancellationToken),
             "S2" => await RefreshS2Mdp(cancellationToken),
             "S3" => await RefreshS3Mdp(cancellationToken),
             "S4" => await RefreshS4Mdp(cancellationToken),

+ 92 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiAtomicDay.cs

@@ -0,0 +1,92 @@
+namespace Admin.NET.Plugin.AiDOP.Entity;
+
+/// <summary>
+/// KPI 最小颗粒度原子聚合层(日粒度)。存可二次聚合基础量,不存模块级最终汇总。
+/// </summary>
+[SugarTable("ado_smart_ops_kpi_atomic_day", "KPI原子聚合层(日)")]
+public class AdoSmartOpsKpiAtomicDay
+{
+    [SugarColumn(ColumnName = "id", ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnName = "tenant_id", ColumnDescription = "租户ID", ColumnDataType = "bigint")]
+    public long TenantId { get; set; }
+
+    [SugarColumn(ColumnName = "factory_id", ColumnDescription = "工厂ID", ColumnDataType = "bigint")]
+    public long FactoryId { get; set; } = 1;
+
+    [SugarColumn(ColumnName = "domain_code", ColumnDescription = "业务对象域", Length = 50)]
+    public string DomainCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "metric_code", ColumnDescription = "指标编码", Length = 50)]
+    public string MetricCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "metric_level", ColumnDescription = "指标层级")]
+    public int MetricLevel { get; set; } = 1;
+
+    [SugarColumn(ColumnName = "stat_date", ColumnDescription = "统计日期", ColumnDataType = "date")]
+    public DateTime StatDate { get; set; }
+
+    [SugarColumn(ColumnName = "customer_code", ColumnDescription = "客户编码", Length = 100)]
+    public string CustomerCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "product_code", ColumnDescription = "产品编码", Length = 100)]
+    public string ProductCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "order_no", ColumnDescription = "订单号", Length = 100)]
+    public string OrderNo { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "production_line", ColumnDescription = "产线", Length = 100)]
+    public string ProductionLine { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "supplier_code", ColumnDescription = "供应商编码", Length = 100)]
+    public string SupplierCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "material_code", ColumnDescription = "物料编码", Length = 100)]
+    public string MaterialCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "po_no", ColumnDescription = "采购单号", Length = 100)]
+    public string PoNo { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "warehouse_code", ColumnDescription = "仓库编码", Length = 100)]
+    public string WarehouseCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "work_order_no", ColumnDescription = "工单号", Length = 100)]
+    public string WorkOrderNo { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "equipment_code", ColumnDescription = "设备编码", Length = 100)]
+    public string EquipmentCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "outbound_no", ColumnDescription = "出库单号", Length = 100)]
+    public string OutboundNo { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnName = "sum_value", ColumnDescription = "可累加值", Length = 18, DecimalDigits = 6, IsNullable = true)]
+    public decimal? SumValue { get; set; }
+
+    [SugarColumn(ColumnName = "numerator_value", ColumnDescription = "比率分子", Length = 18, DecimalDigits = 6, IsNullable = true)]
+    public decimal? NumeratorValue { get; set; }
+
+    [SugarColumn(ColumnName = "denominator_value", ColumnDescription = "比率分母", Length = 18, DecimalDigits = 6, IsNullable = true)]
+    public decimal? DenominatorValue { get; set; }
+
+    [SugarColumn(ColumnName = "sample_count", ColumnDescription = "样本数", IsNullable = true)]
+    public int? SampleCount { get; set; }
+
+    [SugarColumn(ColumnName = "min_value", ColumnDescription = "最小值", Length = 18, DecimalDigits = 6, IsNullable = true)]
+    public decimal? MinValue { get; set; }
+
+    [SugarColumn(ColumnName = "max_value", ColumnDescription = "最大值", Length = 18, DecimalDigits = 6, IsNullable = true)]
+    public decimal? MaxValue { get; set; }
+
+    [SugarColumn(ColumnName = "batch_id", ColumnDescription = "MDP批次", Length = 100, IsNullable = true)]
+    public string? BatchId { get; set; }
+
+    [SugarColumn(ColumnName = "calc_time", ColumnDescription = "计算时间", IsNullable = true)]
+    public DateTime? CalcTime { get; set; }
+
+    [SugarColumn(ColumnName = "grain", ColumnDescription = "统计粒度", Length = 20)]
+    public string Grain { get; set; } = "day";
+
+    [SugarColumn(ColumnName = "is_deleted", ColumnDescription = "删除标识")]
+    public int IsDeleted { get; set; }
+}

+ 122 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopTenantMigration.cs

@@ -18,12 +18,14 @@ public static class AidopTenantMigration
         ("ado_s9_kpi_value_l1_day", "tenant_id"),
         ("ado_s9_kpi_value_l2_day", "tenant_id"),
         ("ado_s9_kpi_value_l3_day", "tenant_id"),
-        ("ado_s9_kpi_value_l4_day", "tenant_id")
+        ("ado_s9_kpi_value_l4_day", "tenant_id"),
+        ("ado_smart_ops_kpi_atomic_day", "tenant_id")
     };
 
     public static void MigrateOldTenantId(ISqlSugarClient db)
     {
         EnsureKpiValueL4Table(db);
+        EnsureKpiAtomicTable(db);
 
         var target = SqlSugarConst.DefaultTenantId;
         if (target == 1) return;
@@ -59,6 +61,125 @@ public static class AidopTenantMigration
         }
     }
 
+    public static void EnsureKpiAtomicTable(ISqlSugarClient db)
+    {
+        RepairKpiAtomicTableSchemaIfNeeded(db);
+
+        try
+        {
+            db.Ado.ExecuteCommand(
+                """
+                CREATE TABLE IF NOT EXISTS ado_smart_ops_kpi_atomic_day (
+                    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+                    tenant_id BIGINT NOT NULL COMMENT '租户ID',
+                    factory_id BIGINT NOT NULL DEFAULT 1 COMMENT '工厂ID',
+                    domain_code VARCHAR(50) NOT NULL COMMENT '业务对象域',
+                    metric_code VARCHAR(50) NOT NULL COMMENT '指标编码',
+                    metric_level TINYINT NOT NULL DEFAULT 1 COMMENT '指标层级',
+                    stat_date DATE NOT NULL COMMENT '统计日期',
+                    customer_code VARCHAR(100) NOT NULL DEFAULT '' COMMENT '客户编码',
+                    product_code VARCHAR(100) NOT NULL DEFAULT '' COMMENT '产品编码',
+                    order_no VARCHAR(100) NOT NULL DEFAULT '' COMMENT '订单号',
+                    production_line VARCHAR(100) NOT NULL DEFAULT '' COMMENT '产线',
+                    supplier_code VARCHAR(100) NOT NULL DEFAULT '' COMMENT '供应商编码',
+                    material_code VARCHAR(100) NOT NULL DEFAULT '' COMMENT '物料编码',
+                    po_no VARCHAR(100) NOT NULL DEFAULT '' COMMENT '采购单号',
+                    warehouse_code VARCHAR(100) NOT NULL DEFAULT '' COMMENT '仓库编码',
+                    work_order_no VARCHAR(100) NOT NULL DEFAULT '' COMMENT '工单号',
+                    equipment_code VARCHAR(100) NOT NULL DEFAULT '' COMMENT '设备编码',
+                    outbound_no VARCHAR(100) NOT NULL DEFAULT '' COMMENT '出库单号',
+                    sum_value DECIMAL(18, 6) NULL COMMENT '可累加值',
+                    numerator_value DECIMAL(18, 6) NULL COMMENT '比率分子',
+                    denominator_value DECIMAL(18, 6) NULL COMMENT '比率分母',
+                    sample_count INT NULL COMMENT '样本数',
+                    min_value DECIMAL(18, 6) NULL COMMENT '最小值',
+                    max_value DECIMAL(18, 6) NULL COMMENT '最大值',
+                    batch_id VARCHAR(100) NULL COMMENT 'MDP批次',
+                    calc_time DATETIME(3) NULL COMMENT '计算时间',
+                    grain VARCHAR(20) NOT NULL DEFAULT 'day' COMMENT '统计粒度',
+                    is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '删除标识',
+                    PRIMARY KEY (id),
+                    UNIQUE KEY uk_atomic_grain (
+                        tenant_id, factory_id, domain_code, metric_code, stat_date,
+                        customer_code, product_code, order_no, production_line,
+                        supplier_code, material_code, po_no, warehouse_code,
+                        work_order_no, equipment_code, outbound_no
+                    ),
+                    KEY idx_tenant_domain_metric_date (tenant_id, domain_code, metric_code, stat_date),
+                    KEY idx_tenant_module_lookup (tenant_id, factory_id, metric_code, stat_date)
+                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KPI原子聚合层(日)'
+                """);
+            EnsureKpiAtomicColumnDefaults(db);
+        }
+        catch (Exception ex)
+        {
+            Trace.TraceWarning($"AidopTenantMigration ensure KPI atomic table: {ex.Message}");
+        }
+    }
+
+    /// <summary>
+    /// 补齐维度列 DEFAULT '',避免 CodeFirst 误建表后 INSERT 缺列报错。
+    /// </summary>
+    private static void EnsureKpiAtomicColumnDefaults(ISqlSugarClient db)
+    {
+        try
+        {
+            db.Ado.ExecuteCommand(
+                """
+                ALTER TABLE ado_smart_ops_kpi_atomic_day
+                    MODIFY customer_code VARCHAR(100) NOT NULL DEFAULT '' COMMENT '客户编码',
+                    MODIFY product_code VARCHAR(100) NOT NULL DEFAULT '' COMMENT '产品编码',
+                    MODIFY order_no VARCHAR(100) NOT NULL DEFAULT '' COMMENT '订单号',
+                    MODIFY production_line VARCHAR(100) NOT NULL DEFAULT '' COMMENT '产线',
+                    MODIFY supplier_code VARCHAR(100) NOT NULL DEFAULT '' COMMENT '供应商编码',
+                    MODIFY material_code VARCHAR(100) NOT NULL DEFAULT '' COMMENT '物料编码',
+                    MODIFY po_no VARCHAR(100) NOT NULL DEFAULT '' COMMENT '采购单号',
+                    MODIFY warehouse_code VARCHAR(100) NOT NULL DEFAULT '' COMMENT '仓库编码',
+                    MODIFY work_order_no VARCHAR(100) NOT NULL DEFAULT '' COMMENT '工单号',
+                    MODIFY equipment_code VARCHAR(100) NOT NULL DEFAULT '' COMMENT '设备编码',
+                    MODIFY outbound_no VARCHAR(100) NOT NULL DEFAULT '' COMMENT '出库单号',
+                    MODIFY grain VARCHAR(20) NOT NULL DEFAULT 'day' COMMENT '统计粒度',
+                    MODIFY is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '删除标识'
+                """);
+        }
+        catch (Exception ex)
+        {
+            Trace.TraceWarning($"AidopTenantMigration ensure KPI atomic column defaults: {ex.Message}");
+        }
+    }
+
+    /// <summary>
+    /// 早期 SqlSugar 误建 PascalCase 列名时,DROP 后由 EnsureKpiAtomicTable 按 snake_case 重建。
+    /// </summary>
+    private static void RepairKpiAtomicTableSchemaIfNeeded(ISqlSugarClient db)
+    {
+        try
+        {
+            var columns = db.Ado.SqlQuery<string>(
+                """
+                SELECT COLUMN_NAME
+                FROM information_schema.COLUMNS
+                WHERE TABLE_SCHEMA = DATABASE()
+                  AND TABLE_NAME = 'ado_smart_ops_kpi_atomic_day'
+                """);
+
+            if (columns == null || columns.Count == 0)
+                return;
+
+            var hasSnakeDomain = columns.Any(c => string.Equals(c, "domain_code", StringComparison.OrdinalIgnoreCase));
+            var hasPascalDomain = columns.Any(c => string.Equals(c, "DomainCode", StringComparison.OrdinalIgnoreCase));
+            if (hasSnakeDomain || !hasPascalDomain)
+                return;
+
+            Trace.TraceWarning("AidopTenantMigration: ado_smart_ops_kpi_atomic_day 检测到 PascalCase 列名,将 DROP 后重建为 snake_case。");
+            db.Ado.ExecuteCommand("DROP TABLE ado_smart_ops_kpi_atomic_day");
+        }
+        catch (Exception ex)
+        {
+            Trace.TraceWarning($"AidopTenantMigration repair KPI atomic schema: {ex.Message}");
+        }
+    }
+
     private static void BackfillThresholds(ISqlSugarClient db)
     {
         try

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S1MdpSyncTransformJob.cs

@@ -9,7 +9,7 @@ namespace Admin.NET.Plugin.AiDOP.Job;
 /// <summary>
 /// S1 首批 MDP 同步与标准化转换定时任务。
 /// </summary>
-[JobDetail("job_s1_mdp_sync_transform", Description = "S1 MDP同步与标准化转换", GroupName = "default", Concurrent = false)]
+[JobDetail("job_s1_mdp_sync_transform", Description = "S1 MDP同步与标准化转换(含订单交付域原子聚合层)", GroupName = "default", Concurrent = false)]
 [Period(3600000, TriggerId = "trigger_s1_mdp_sync_transform", Description = "每60分钟执行")]
 public class S1MdpSyncTransformJob : IJob
 {

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S2MdpSyncTransformJob.cs

@@ -9,7 +9,7 @@ namespace Admin.NET.Plugin.AiDOP.Job;
 /// <summary>
 /// S2 MDP 同步、标准化、DWD 与 KPI 计算定时任务。
 /// </summary>
-[JobDetail("job_s2_mdp_sync_transform", Description = "S2 MDP同步与KPI计算", GroupName = "default", Concurrent = false)]
+[JobDetail("job_s2_mdp_sync_transform", Description = "S2 MDP同步与KPI计算(含工单排程域原子聚合层)", GroupName = "default", Concurrent = false)]
 [Period(3600000, TriggerId = "trigger_s2_mdp_sync_transform", Description = "每60分钟执行")]
 public class S2MdpSyncTransformJob : IJob
 {

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S3MdpSyncTransformJob.cs

@@ -9,7 +9,7 @@ namespace Admin.NET.Plugin.AiDOP.Job;
 /// <summary>
 /// S3 首批 MDP 同步与标准化转换定时任务。
 /// </summary>
-[JobDetail("job_s3_mdp_sync_transform", Description = "S3 MDP同步与标准化转换", GroupName = "default", Concurrent = false)]
+[JobDetail("job_s3_mdp_sync_transform", Description = "S3 MDP同步与标准化转换(含供应采购/仓储库存域原子聚合层)", GroupName = "default", Concurrent = false)]
 [Period(3600000, TriggerId = "trigger_s3_mdp_sync_transform", Description = "每60分钟执行")]
 public class S3MdpSyncTransformJob : IJob
 {

+ 64 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Job/SmartOpsKpiAtomicWarmupJob.cs

@@ -0,0 +1,64 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.SmartOps;
+using Furion.Schedule;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Job;
+
+/// <summary>
+/// 原子聚合表为空时(含 schema 修复后)自动构建四域全量历史原子 KPI,无需等待 hourly MDP。
+/// </summary>
+[JobDetail("job_smart_ops_kpi_atomic_warmup", Description = "智慧运营KPI:原子层为空时自动预热(四域全量历史)", GroupName = "default", Concurrent = false)]
+[PeriodSeconds(2, TriggerId = "trigger_smart_ops_kpi_atomic_warmup", Description = "启动后检测原子层并预热", MaxNumberOfRuns = 1, RunOnStart = true)]
+public class SmartOpsKpiAtomicWarmupJob : IJob
+{
+    private readonly IServiceScopeFactory _scopeFactory;
+    private readonly ILogger _logger;
+
+    public SmartOpsKpiAtomicWarmupJob(IServiceScopeFactory scopeFactory, ILoggerFactory loggerFactory)
+    {
+        _scopeFactory = scopeFactory;
+        _logger = loggerFactory.CreateLogger(nameof(SmartOpsKpiAtomicWarmupJob));
+    }
+
+    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
+    {
+        using var scope = _scopeFactory.CreateScope();
+        var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
+        var build = scope.ServiceProvider.GetRequiredService<SmartOpsKpiAtomicBuildService>();
+
+        try
+        {
+            AidopTenantMigration.EnsureKpiAtomicTable(db);
+            var rowCount = await db.Ado.GetIntAsync(
+                "SELECT COUNT(1) FROM ado_smart_ops_kpi_atomic_day WHERE is_deleted = 0");
+
+            if (rowCount > 0)
+            {
+                _logger.LogInformation("SmartOpsKpiAtomicWarmupJob: 原子层已有 {RowCount} 行,跳过预热", rowCount);
+                return;
+            }
+
+            const string batchId = "ATOMIC_WARMUP";
+            var orderRows = await build.BuildOrderDeliveryDomainForAllDatesAsync(batchId, cancellationToken: stoppingToken);
+            var scheduleRows = await build.BuildWorkScheduleDomainForAllDatesAsync(batchId, cancellationToken: stoppingToken);
+            var supplyRows = await build.BuildSupplyPurchaseDomainForAllDatesAsync(batchId, cancellationToken: stoppingToken);
+            var inventoryRows = await build.BuildInventoryDomainForAllDatesAsync(batchId, cancellationToken: stoppingToken);
+
+            _logger.LogInformation(
+                "SmartOpsKpiAtomicWarmupJob: 预热完成 order={OrderRows} schedule={ScheduleRows} supply={SupplyRows} inventory={InventoryRows}",
+                orderRows, scheduleRows, supplyRows, inventoryRows);
+        }
+        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+        {
+            _logger.LogInformation("SmartOpsKpiAtomicWarmupJob: 收到停止信号");
+            throw;
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "SmartOpsKpiAtomicWarmupJob: 原子层预热失败");
+        }
+    }
+}

+ 92 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Job/SmartOpsKpiMdpBootstrapJob.cs

@@ -0,0 +1,92 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Order;
+using Admin.NET.Plugin.AiDOP.ProcurementExecution;
+using Admin.NET.Plugin.AiDOP.Production;
+using Admin.NET.Plugin.AiDOP.Supply;
+using Furion.Schedule;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using SqlSugar;
+using System.Text.Json;
+
+namespace Admin.NET.Plugin.AiDOP.Job;
+
+/// <summary>
+/// 应用启动后按 S1→S2→S3→S4 顺序执行 MDP 全量同步,构建模块 KPI 汇总与 KPI 原子聚合层。
+/// 新环境发布后无需等待 hourly 任务即可在看板筛选中看到数据。
+/// </summary>
+[JobDetail("job_smart_ops_kpi_mdp_bootstrap", Description = "智慧运营KPI:启动时按序执行S1→S2→S3→S4 MDP同步(含原子聚合层)", GroupName = "default", Concurrent = false)]
+[PeriodSeconds(1, TriggerId = "trigger_smart_ops_kpi_mdp_bootstrap", Description = "应用启动后立即执行一次", MaxNumberOfRuns = 1, RunOnStart = true)]
+public class SmartOpsKpiMdpBootstrapJob : IJob
+{
+    private readonly IServiceScopeFactory _scopeFactory;
+    private readonly ILogger _logger;
+
+    public SmartOpsKpiMdpBootstrapJob(IServiceScopeFactory scopeFactory, ILoggerFactory loggerFactory)
+    {
+        _scopeFactory = scopeFactory;
+        _logger = loggerFactory.CreateLogger(nameof(SmartOpsKpiMdpBootstrapJob));
+    }
+
+    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
+    {
+        using var scope = _scopeFactory.CreateScope();
+        var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
+
+        try
+        {
+            AidopTenantMigration.EnsureKpiAtomicTable(db);
+            _logger.LogInformation("SmartOpsKpiMdpBootstrapJob: ado_smart_ops_kpi_atomic_day 已就绪");
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "SmartOpsKpiMdpBootstrapJob: 原子聚合表初始化失败");
+        }
+
+        await RunStepAsync("S1", stoppingToken, async sp =>
+        {
+            var result = await sp.GetRequiredService<S1MdpSyncTransformService>().RunFullAsync(stoppingToken, "BOOTSTRAP");
+            return new { result.StageRows, result.StandardRows, result.DwdRows, result.KpiRows, result.AtomicRows };
+        });
+
+        await RunStepAsync("S2", stoppingToken, async sp =>
+        {
+            var result = await sp.GetRequiredService<S2MdpSyncTransformService>().RunFullAsync(stoppingToken, "BOOTSTRAP");
+            return new { result.StageRows, result.StandardRows, result.DwdRows, result.KpiRows, result.AtomicRows };
+        });
+
+        await RunStepAsync("S3", stoppingToken, async sp =>
+        {
+            var result = await sp.GetRequiredService<S3MdpSyncTransformService>().RunFullAsync(stoppingToken, "BOOTSTRAP");
+            return new { result.StageRows, result.StandardRows, result.DwdRows, result.KpiRows, result.AtomicRows };
+        });
+
+        await RunStepAsync("S4", stoppingToken, async sp =>
+        {
+            var result = await sp.GetRequiredService<S4MdpSyncTransformService>().RunFullAsync(stoppingToken, "BOOTSTRAP");
+            return new { result.StageRows, result.StandardRows, result.DwdRows, result.KpiRows };
+        });
+
+        _logger.LogInformation("SmartOpsKpiMdpBootstrapJob: 全部 MDP 引导同步已完成");
+    }
+
+    private async Task RunStepAsync(string step, CancellationToken stoppingToken, Func<IServiceProvider, Task<object>> action)
+    {
+        using var scope = _scopeFactory.CreateScope();
+        try
+        {
+            stoppingToken.ThrowIfCancellationRequested();
+            var payload = await action(scope.ServiceProvider);
+            _logger.LogInformation("SmartOpsKpiMdpBootstrapJob {Step} 完成 {Payload}", step, JsonSerializer.Serialize(payload));
+        }
+        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+        {
+            _logger.LogInformation("SmartOpsKpiMdpBootstrapJob {Step} 收到停止信号", step);
+            throw;
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "SmartOpsKpiMdpBootstrapJob {Step} 执行失败,继续后续步骤", step);
+        }
+    }
+}

+ 7 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Order/S1MdpSyncTransformService.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.SmartOps;
+
 namespace Admin.NET.Plugin.AiDOP.Order;
 
 /// <summary>
@@ -7,10 +9,12 @@ public class S1MdpSyncTransformService : ITransient
 {
     private const string JobCode = "S1_MDP_SYNC_TRANSFORM";
     private readonly ISqlSugarClient _db;
+    private readonly SmartOpsKpiAtomicBuildService _atomicBuild;
 
-    public S1MdpSyncTransformService(ISqlSugarClient db)
+    public S1MdpSyncTransformService(ISqlSugarClient db, SmartOpsKpiAtomicBuildService atomicBuild)
     {
         _db = db;
+        _atomicBuild = atomicBuild;
     }
 
     public async Task<S1MdpSyncTransformResult> RunFullAsync(CancellationToken cancellationToken = default, string triggerType = "AUTO")
@@ -28,6 +32,7 @@ public class S1MdpSyncTransformService : ITransient
             result.StandardRows = await TransformStandardAsync(batchId, now, cancellationToken);
             result.DwdRows = await BuildDwdAsync(batchId, now, cancellationToken);
             result.KpiRows = await BuildS1KpiValuesAsync(batchId, now, cancellationToken);
+            result.AtomicRows = await _atomicBuild.BuildOrderDeliveryDomainForAllDatesAsync(batchId, cancellationToken: cancellationToken);
             await MarkTransformRunSuccessAsync(runLogId, now, result);
             return result;
         }
@@ -1322,6 +1327,7 @@ public sealed class S1MdpSyncTransformResult
     public int StandardRows { get; set; }
     public int DwdRows { get; set; }
     public int KpiRows { get; set; }
+    public int AtomicRows { get; set; }
 }
 
 internal sealed record S1MdpSqlCommand(string Sql, SugarParameter[] Parameters);

+ 7 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Production/S2MdpSyncTransformService.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.SmartOps;
+
 namespace Admin.NET.Plugin.AiDOP.Production;
 
 /// <summary>
@@ -7,10 +9,12 @@ public class S2MdpSyncTransformService : ITransient
 {
     private const string JobCode = "S2_MDP_SYNC_TRANSFORM";
     private readonly ISqlSugarClient _db;
+    private readonly SmartOpsKpiAtomicBuildService _atomicBuild;
 
-    public S2MdpSyncTransformService(ISqlSugarClient db)
+    public S2MdpSyncTransformService(ISqlSugarClient db, SmartOpsKpiAtomicBuildService atomicBuild)
     {
         _db = db;
+        _atomicBuild = atomicBuild;
     }
 
     public async Task<S2MdpSyncTransformResult> RunFullAsync(CancellationToken cancellationToken = default, string triggerType = "AUTO")
@@ -28,6 +32,7 @@ public class S2MdpSyncTransformService : ITransient
             result.StandardRows = await TransformStandardAsync(batchId, now, cancellationToken);
             result.DwdRows = await BuildDwdAsync(batchId, now, cancellationToken);
             result.KpiRows = await BuildS2KpiValuesAsync(batchId, now, cancellationToken);
+            result.AtomicRows = await _atomicBuild.BuildWorkScheduleDomainForAllDatesAsync(batchId, cancellationToken: cancellationToken);
             await MarkTransformRunSuccessAsync(runLogId, now, result);
             return result;
         }
@@ -754,6 +759,7 @@ public sealed class S2MdpSyncTransformResult
     public int StandardRows { get; set; }
     public int DwdRows { get; set; }
     public int KpiRows { get; set; }
+    public int AtomicRows { get; set; }
 }
 
 internal sealed record S2MdpSqlCommand(string Sql, SugarParameter[] Parameters);

+ 103 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/SmartOpsDashboardFilter.cs

@@ -0,0 +1,103 @@
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>
+/// 智慧运营看板统一筛选上下文(前端 query 与 API 动态 KPI 计算共用)。
+/// </summary>
+public sealed class SmartOpsDashboardFilter
+{
+    public DateTime? DateStart { get; set; }
+    public DateTime? DateEnd { get; set; }
+    public string? Product { get; set; }
+    public string? OrderNo { get; set; }
+    public string? ProductionLine { get; set; }
+    public string? Customer { get; set; }
+    public string? Supplier { get; set; }
+    public string? Material { get; set; }
+    public string? PoNo { get; set; }
+    public string? Warehouse { get; set; }
+    public string? WorkOrder { get; set; }
+    public string? Equipment { get; set; }
+    public string? OutboundNo { get; set; }
+
+    public bool IsEmpty =>
+        !DateStart.HasValue &&
+        !DateEnd.HasValue &&
+        IsBlank(Product) &&
+        IsBlank(OrderNo) &&
+        IsBlank(ProductionLine) &&
+        IsBlank(Customer) &&
+        IsBlank(Supplier) &&
+        IsBlank(Material) &&
+        IsBlank(PoNo) &&
+        IsBlank(Warehouse) &&
+        IsBlank(WorkOrder) &&
+        IsBlank(Equipment) &&
+        IsBlank(OutboundNo);
+
+    public static SmartOpsDashboardFilter FromQuery(
+        string? dateStart,
+        string? dateEnd,
+        string? product,
+        string? orderNo,
+        string? productionLine,
+        string? customer,
+        string? supplier,
+        string? material,
+        string? poNo,
+        string? warehouse,
+        string? workOrder,
+        string? equipment,
+        string? outboundNo)
+    {
+        return new SmartOpsDashboardFilter
+        {
+            DateStart = ParseDate(dateStart),
+            DateEnd = ParseDate(dateEnd),
+            Product = TrimOrNull(product),
+            OrderNo = TrimOrNull(orderNo),
+            ProductionLine = TrimOrNull(productionLine),
+            Customer = TrimOrNull(customer),
+            Supplier = TrimOrNull(supplier),
+            Material = TrimOrNull(material),
+            PoNo = TrimOrNull(poNo),
+            Warehouse = TrimOrNull(warehouse),
+            WorkOrder = TrimOrNull(workOrder),
+            Equipment = TrimOrNull(equipment),
+            OutboundNo = TrimOrNull(outboundNo)
+        };
+    }
+
+    public Dictionary<string, string?> ToContextDictionary()
+    {
+        return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
+        {
+            ["dateStart"] = DateStart?.ToString("yyyy-MM-dd"),
+            ["dateEnd"] = DateEnd?.ToString("yyyy-MM-dd"),
+            ["product"] = Product,
+            ["orderNo"] = OrderNo,
+            ["productionLine"] = ProductionLine,
+            ["customer"] = Customer,
+            ["supplier"] = Supplier,
+            ["material"] = Material,
+            ["poNo"] = PoNo,
+            ["warehouse"] = Warehouse,
+            ["workOrder"] = WorkOrder,
+            ["equipment"] = Equipment,
+            ["outboundNo"] = OutboundNo
+        };
+    }
+
+    private static bool IsBlank(string? value) => string.IsNullOrWhiteSpace(value);
+
+    private static string? TrimOrNull(string? value)
+    {
+        var s = value?.Trim();
+        return string.IsNullOrEmpty(s) ? null : s;
+    }
+
+    private static DateTime? ParseDate(string? value)
+    {
+        if (string.IsNullOrWhiteSpace(value)) return null;
+        return DateTime.TryParse(value.Trim(), out var d) ? d.Date : null;
+    }
+}

+ 183 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/SmartOpsKpiAggregateRuleRegistry.cs

@@ -0,0 +1,183 @@
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>
+/// KPI 原子聚合规则注册表(全局 S1-S9):指标如何从原子层二次聚合,以及支持哪些筛选字段。
+/// </summary>
+public static class SmartOpsKpiAggregateRuleRegistry
+{
+    public const string DomainOrderDelivery = "order_delivery";
+    public const string DomainWorkSchedule = "work_schedule";
+    public const string DomainSupplyPurchase = "supply_purchase";
+    public const string DomainInventory = "inventory";
+
+    public const string AggSum = "sum";
+    public const string AggRatio = "ratio";
+    public const string AggAvg = "avg";
+    public const string AggMin = "min";
+    public const string AggMax = "max";
+
+    public const string FilterFull = "filterable_full";
+    public const string FilterPartial = "filterable_partial";
+    public const string FilterSummaryOnly = "summary_only";
+
+    private static readonly string[] OrderDeliveryFilters =
+        ["dateStart", "dateEnd", "customer", "product", "orderNo", "productionLine"];
+
+    private static readonly string[] WorkScheduleFilters =
+        ["dateStart", "dateEnd", "product", "orderNo", "productionLine", "workOrder", "equipment"];
+
+    private static readonly string[] SupplyPurchaseFilters =
+        ["dateStart", "dateEnd", "supplier", "material", "poNo"];
+
+    private static readonly string[] InventoryFilters =
+        ["dateStart", "dateEnd", "warehouse", "material", "workOrder", "outboundNo"];
+
+    public static SmartOpsKpiAggregateRule? TryGet(string? metricCode)
+    {
+        if (string.IsNullOrWhiteSpace(metricCode)) return null;
+        return ResolveByPattern(metricCode.Trim());
+    }
+
+    public static bool SupportsFilter(string metricCode, SmartOpsDashboardFilter filter)
+    {
+        var rule = TryGet(metricCode);
+        if (rule == null || rule.FilterMode == FilterSummaryOnly) return false;
+        if (filter.IsEmpty) return true;
+
+        var active = GetActiveFilterKeys(filter);
+        if (active.Count == 0) return true;
+        return active.All(k => rule.SupportedFilters.Contains(k, StringComparer.OrdinalIgnoreCase));
+    }
+
+    public static IReadOnlyList<string> GetUnsupportedFilters(string metricCode, SmartOpsDashboardFilter filter)
+    {
+        var rule = TryGet(metricCode);
+        if (rule == null || filter.IsEmpty) return Array.Empty<string>();
+
+        return GetActiveFilterKeys(filter)
+            .Where(k => !rule.SupportedFilters.Contains(k, StringComparer.OrdinalIgnoreCase))
+            .ToList();
+    }
+
+    public static IEnumerable<string> GetDomainsForModule(string moduleCode)
+    {
+        return moduleCode.ToUpperInvariant() switch
+        {
+            "S1" or "S7" => new[] { DomainOrderDelivery, DomainInventory },
+            "S2" or "S6" => new[] { DomainWorkSchedule },
+            "S3" or "S4" => new[] { DomainSupplyPurchase },
+            "S5" => new[] { DomainInventory, DomainSupplyPurchase },
+            "S9" => new[] { DomainOrderDelivery, DomainWorkSchedule, DomainSupplyPurchase, DomainInventory },
+            _ => Array.Empty<string>()
+        };
+    }
+
+    public static string ResolveFilterMode(string metricCode, SmartOpsDashboardFilter filter)
+    {
+        var rule = TryGet(metricCode);
+        if (rule == null || rule.FilterMode == FilterSummaryOnly) return FilterSummaryOnly;
+        if (filter.IsEmpty) return FilterSummaryOnly;
+        if (SupportsFilter(metricCode, filter)) return FilterFull;
+        return FilterPartial;
+    }
+
+    private static SmartOpsKpiAggregateRule? ResolveByPattern(string metricCode)
+    {
+        var parts = metricCode.Split('_', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+        if (parts.Length < 3) return null;
+
+        var module = parts[0].ToUpperInvariant();
+        if (module == "S8") return SummaryRule(metricCode);
+
+        var domain = ResolveDomain(module, metricCode);
+        if (domain == null) return SummaryRule(metricCode);
+
+        var suffix = parts[^1];
+        var aggregation = ResolveAggregation(suffix, metricCode);
+        var filters = ResolveFilters(domain);
+        return new SmartOpsKpiAggregateRule(metricCode, domain, aggregation, FilterPartial, filters);
+    }
+
+    private static string? ResolveDomain(string module, string metricCode)
+    {
+        if (module == "S9")
+        {
+            return metricCode.ToUpperInvariant() switch
+            {
+                var c when c.StartsWith("S9_L1_001", StringComparison.OrdinalIgnoreCase) => null,
+                var c when c.StartsWith("S9_L1_002", StringComparison.OrdinalIgnoreCase) ||
+                           c.StartsWith("S9_L1_003", StringComparison.OrdinalIgnoreCase) => DomainOrderDelivery,
+                var c when c.StartsWith("S9_L1_004", StringComparison.OrdinalIgnoreCase) => DomainWorkSchedule,
+                var c when c.StartsWith("S9_L1_005", StringComparison.OrdinalIgnoreCase) => DomainInventory,
+                _ => ResolveDomainByModule(module)
+            };
+        }
+
+        return ResolveDomainByModule(module);
+    }
+
+    private static string? ResolveDomainByModule(string module) => module switch
+    {
+        "S1" or "S7" => DomainOrderDelivery,
+        "S2" or "S6" => DomainWorkSchedule,
+        "S3" or "S4" => DomainSupplyPurchase,
+        "S5" => DomainInventory,
+        _ => null
+    };
+
+    private static string ResolveAggregation(string suffix, string metricCode)
+    {
+        if (metricCode.Contains("数量", StringComparison.Ordinal) ||
+            metricCode.EndsWith("_001", StringComparison.Ordinal) && metricCode.StartsWith("S8", StringComparison.OrdinalIgnoreCase))
+        {
+            return AggSum;
+        }
+
+        return suffix switch
+        {
+            "001" => AggAvg,
+            "002" => AggRatio,
+            "003" => AggAvg,
+            "004" => AggAvg,
+            "005" => AggAvg,
+            _ => AggAvg
+        };
+    }
+
+    private static string[] ResolveFilters(string domain) => domain switch
+    {
+        DomainOrderDelivery => OrderDeliveryFilters,
+        DomainWorkSchedule => WorkScheduleFilters,
+        DomainSupplyPurchase => SupplyPurchaseFilters,
+        DomainInventory => InventoryFilters,
+        _ => Array.Empty<string>()
+    };
+
+    private static SmartOpsKpiAggregateRule SummaryRule(string metricCode) =>
+        new(metricCode, string.Empty, AggAvg, FilterSummaryOnly, Array.Empty<string>());
+
+    private static List<string> GetActiveFilterKeys(SmartOpsDashboardFilter filter)
+    {
+        var keys = new List<string>();
+        if (filter.DateStart.HasValue || filter.DateEnd.HasValue) keys.Add("dateStart");
+        if (!string.IsNullOrWhiteSpace(filter.Product)) keys.Add("product");
+        if (!string.IsNullOrWhiteSpace(filter.OrderNo)) keys.Add("orderNo");
+        if (!string.IsNullOrWhiteSpace(filter.ProductionLine)) keys.Add("productionLine");
+        if (!string.IsNullOrWhiteSpace(filter.Customer)) keys.Add("customer");
+        if (!string.IsNullOrWhiteSpace(filter.Supplier)) keys.Add("supplier");
+        if (!string.IsNullOrWhiteSpace(filter.Material)) keys.Add("material");
+        if (!string.IsNullOrWhiteSpace(filter.PoNo)) keys.Add("poNo");
+        if (!string.IsNullOrWhiteSpace(filter.Warehouse)) keys.Add("warehouse");
+        if (!string.IsNullOrWhiteSpace(filter.WorkOrder)) keys.Add("workOrder");
+        if (!string.IsNullOrWhiteSpace(filter.Equipment)) keys.Add("equipment");
+        if (!string.IsNullOrWhiteSpace(filter.OutboundNo)) keys.Add("outboundNo");
+        return keys;
+    }
+}
+
+public sealed record SmartOpsKpiAggregateRule(
+    string MetricCode,
+    string DomainCode,
+    string AggregationType,
+    string FilterMode,
+    string[] SupportedFilters);

+ 507 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/SmartOpsKpiAtomicBuildService.cs

@@ -0,0 +1,507 @@
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>
+/// 从 DWD/STD 事实写入 KPI 原子聚合层(最小颗粒度,不做二次汇总)。
+/// </summary>
+public class SmartOpsKpiAtomicBuildService : ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public SmartOpsKpiAtomicBuildService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    public async Task<int> BuildOrderDeliveryDomainForAllDatesAsync(string? batchId, CancellationToken cancellationToken = default)
+    {
+        AidopTenantMigration.EnsureKpiAtomicTable(_db);
+        var dates = await _db.Ado.SqlQueryAsync<DateTime>(
+            """
+            SELECT DISTINCT DATE(order_date) AS D
+            FROM mdp_std_so
+            WHERE order_date IS NOT NULL
+            ORDER BY D
+            """);
+
+        var total = 0;
+        foreach (var date in dates)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            total += await BuildOrderDeliveryDomainAsync(date, batchId, cancellationToken);
+        }
+
+        return total;
+    }
+
+    public async Task<int> BuildWorkScheduleDomainForAllDatesAsync(string? batchId, CancellationToken cancellationToken = default)
+    {
+        AidopTenantMigration.EnsureKpiAtomicTable(_db);
+        var dates = await _db.Ado.SqlQueryAsync<DateTime>(
+            """
+            SELECT DISTINCT stat_date AS D
+            FROM dwd_order_schedule_trans
+            WHERE stat_date IS NOT NULL
+            ORDER BY D
+            """);
+
+        var total = 0;
+        foreach (var date in dates)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            total += await BuildWorkScheduleDomainAsync(date, batchId, cancellationToken);
+        }
+
+        return total;
+    }
+
+    public async Task<int> BuildSupplyPurchaseDomainForAllDatesAsync(string? batchId, CancellationToken cancellationToken = default)
+    {
+        AidopTenantMigration.EnsureKpiAtomicTable(_db);
+        var dates = await _db.Ado.SqlQueryAsync<DateTime>(
+            """
+            SELECT DISTINCT stat_date AS D
+            FROM dwd_supplier_delivery
+            WHERE stat_date IS NOT NULL
+            ORDER BY D
+            """);
+
+        var total = 0;
+        foreach (var date in dates)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            total += await BuildSupplyPurchaseDomainAsync(date, batchId, cancellationToken);
+        }
+
+        return total;
+    }
+
+    public async Task<int> BuildInventoryDomainForAllDatesAsync(string? batchId, CancellationToken cancellationToken = default)
+    {
+        AidopTenantMigration.EnsureKpiAtomicTable(_db);
+        var dates = await _db.Ado.SqlQueryAsync<DateTime>(
+            """
+            SELECT DISTINCT stat_date AS D
+            FROM dwd_material_readiness
+            WHERE stat_date IS NOT NULL
+            ORDER BY D
+            """);
+
+        var total = 0;
+        foreach (var date in dates)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            total += await BuildInventoryDomainAsync(date, batchId, cancellationToken);
+        }
+
+        return total;
+    }
+
+    public async Task<int> BuildOrderDeliveryDomainAsync(DateTime statDate, string? batchId, CancellationToken cancellationToken = default)
+    {
+        AidopTenantMigration.EnsureKpiAtomicTable(_db);
+        var date = statDate.Date;
+        var now = DateTime.Now;
+        var affected = 0;
+
+        affected += await _db.Ado.ExecuteCommandAsync(
+            """
+            DELETE FROM ado_smart_ops_kpi_atomic_day
+            WHERE domain_code = @Domain
+              AND stat_date = @StatDate
+            """,
+            new { Domain = SmartOpsKpiAggregateRuleRegistry.DomainOrderDelivery, StatDate = date });
+
+        foreach (var metricCode in new[] { "S1_L1_001", "S7_L1_001", "S9_L1_002" })
+        {
+            affected += await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO ado_smart_ops_kpi_atomic_day
+                (tenant_id, factory_id, domain_code, metric_code, metric_level, stat_date,
+                 customer_code, product_code, order_no, production_line,
+                 sum_value, sample_count, batch_id, calc_time, grain, is_deleted)
+                SELECT
+                    so.tenant_id,
+                    COALESCE(NULLIF(so.factory_id, 0), 1),
+                    @Domain,
+                    @MetricCode,
+                    1,
+                    DATE(so.order_date),
+                    IFNULL(so.customer_no, ''),
+                    IFNULL(so.item_code, ''),
+                    IFNULL(so.order_no, ''),
+                    '',
+                    ROUND(SUM(TIMESTAMPDIFF(HOUR, so.order_date,
+                        COALESCE(so.promised_delivery_date, so.capacity_date, so.material_ready_date, so.plan_delivery_date)) / 24), 6),
+                    COUNT(1),
+                    @BatchId,
+                    @Now,
+                    'day',
+                    0
+                FROM mdp_std_so so
+                WHERE so.order_date IS NOT NULL
+                  AND COALESCE(so.promised_delivery_date, so.capacity_date, so.material_ready_date, so.plan_delivery_date) IS NOT NULL
+                  AND COALESCE(so.promised_delivery_date, so.capacity_date, so.material_ready_date, so.plan_delivery_date) >= so.order_date
+                  AND DATE(so.order_date) = @StatDate
+                GROUP BY so.tenant_id, COALESCE(NULLIF(so.factory_id, 0), 1),
+                         DATE(so.order_date), IFNULL(so.customer_no, ''), IFNULL(so.item_code, ''), IFNULL(so.order_no, '')
+                """,
+                new
+                {
+                    Domain = SmartOpsKpiAggregateRuleRegistry.DomainOrderDelivery,
+                    MetricCode = metricCode,
+                    StatDate = date,
+                    BatchId = batchId,
+                    Now = now
+                });
+        }
+
+        foreach (var metricCode in new[] { "S1_L1_002", "S7_L1_002", "S9_L1_003" })
+        {
+            affected += await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO ado_smart_ops_kpi_atomic_day
+                (tenant_id, factory_id, domain_code, metric_code, metric_level, stat_date,
+                 customer_code, product_code, order_no, production_line,
+                 numerator_value, denominator_value, batch_id, calc_time, grain, is_deleted)
+                SELECT
+                    so.tenant_id,
+                    COALESCE(NULLIF(so.factory_id, 0), 1),
+                    @Domain,
+                    @MetricCode,
+                    1,
+                    DATE(so.order_date),
+                    IFNULL(so.customer_no, ''),
+                    IFNULL(so.item_code, ''),
+                    IFNULL(so.order_no, ''),
+                    '',
+                    SUM(CASE WHEN TIMESTAMPDIFF(HOUR, so.order_date,
+                        COALESCE(so.promised_delivery_date, so.capacity_date, so.material_ready_date, so.plan_delivery_date)) <= 72 THEN 1 ELSE 0 END),
+                    COUNT(1),
+                    @BatchId,
+                    @Now,
+                    'day',
+                    0
+                FROM mdp_std_so so
+                WHERE so.order_date IS NOT NULL
+                  AND COALESCE(so.promised_delivery_date, so.capacity_date, so.material_ready_date, so.plan_delivery_date) IS NOT NULL
+                  AND DATE(so.order_date) = @StatDate
+                GROUP BY so.tenant_id, COALESCE(NULLIF(so.factory_id, 0), 1),
+                         DATE(so.order_date), IFNULL(so.customer_no, ''), IFNULL(so.item_code, ''), IFNULL(so.order_no, '')
+                """,
+                new
+                {
+                    Domain = SmartOpsKpiAggregateRuleRegistry.DomainOrderDelivery,
+                    MetricCode = metricCode,
+                    StatDate = date,
+                    BatchId = batchId,
+                    Now = now
+                });
+        }
+
+        foreach (var metricCode in new[] { "S1_L1_003", "S7_L1_003" })
+        {
+            affected += await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO ado_smart_ops_kpi_atomic_day
+                (tenant_id, factory_id, domain_code, metric_code, metric_level, stat_date,
+                 customer_code, product_code, order_no, production_line,
+                 numerator_value, denominator_value, batch_id, calc_time, grain, is_deleted)
+                SELECT
+                    so.tenant_id,
+                    COALESCE(NULLIF(so.factory_id, 0), 1),
+                    @Domain,
+                    @MetricCode,
+                    1,
+                    DATE(so.order_date),
+                    IFNULL(so.customer_no, ''),
+                    IFNULL(so.item_code, ''),
+                    IFNULL(so.order_no, ''),
+                    '',
+                    COUNT(1),
+                    GREATEST(COUNT(DISTINCT NULLIF(so.planner_no, '')), 1),
+                    @BatchId,
+                    @Now,
+                    'day',
+                    0
+                FROM mdp_std_so so
+                WHERE so.order_date IS NOT NULL
+                  AND DATE(so.order_date) = @StatDate
+                GROUP BY so.tenant_id, COALESCE(NULLIF(so.factory_id, 0), 1),
+                         DATE(so.order_date), IFNULL(so.customer_no, ''), IFNULL(so.item_code, ''), IFNULL(so.order_no, '')
+                """,
+                new
+                {
+                    Domain = SmartOpsKpiAggregateRuleRegistry.DomainOrderDelivery,
+                    MetricCode = metricCode,
+                    StatDate = date,
+                    BatchId = batchId,
+                    Now = now
+                });
+        }
+
+        return affected;
+    }
+
+    public async Task<int> BuildSupplyPurchaseDomainAsync(DateTime statDate, string? batchId, CancellationToken cancellationToken = default)
+    {
+        AidopTenantMigration.EnsureKpiAtomicTable(_db);
+        var date = statDate.Date;
+        var now = DateTime.Now;
+        var affected = 0;
+
+        affected += await _db.Ado.ExecuteCommandAsync(
+            """
+            DELETE FROM ado_smart_ops_kpi_atomic_day
+            WHERE domain_code = @Domain
+              AND stat_date = @StatDate
+            """,
+            new { Domain = SmartOpsKpiAggregateRuleRegistry.DomainSupplyPurchase, StatDate = date });
+
+        foreach (var metricCode in new[] { "S3_L1_001", "S4_L1_001" })
+        {
+            affected += await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO ado_smart_ops_kpi_atomic_day
+                (tenant_id, factory_id, domain_code, metric_code, metric_level, stat_date,
+                 supplier_code, material_code, po_no,
+                 sum_value, sample_count, batch_id, calc_time, grain, is_deleted)
+                SELECT tenant_id, 1, @Domain, @MetricCode, 1, DATE(request_date),
+                       IFNULL(supplier_code, ''), IFNULL(item_code, ''), IFNULL(po_no, ''),
+                       ROUND(SUM(TIMESTAMPDIFF(HOUR, request_date, submit_date) / 24), 6),
+                       COUNT(1), @BatchId, @Now, 'day', 0
+                FROM mdp_std_delivery_schedule
+                WHERE request_date IS NOT NULL AND submit_date IS NOT NULL AND submit_date >= request_date
+                  AND DATE(request_date) = @StatDate
+                GROUP BY tenant_id, DATE(request_date), IFNULL(supplier_code, ''), IFNULL(item_code, ''), IFNULL(po_no, '')
+                """,
+                new
+                {
+                    Domain = SmartOpsKpiAggregateRuleRegistry.DomainSupplyPurchase,
+                    MetricCode = metricCode,
+                    StatDate = date,
+                    BatchId = batchId,
+                    Now = now
+                });
+        }
+
+        foreach (var metricCode in new[] { "S4_L1_002", "S3_L1_002" })
+        {
+            affected += await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO ado_smart_ops_kpi_atomic_day
+                (tenant_id, factory_id, domain_code, metric_code, metric_level, stat_date,
+                 supplier_code, material_code, po_no,
+                 numerator_value, denominator_value, batch_id, calc_time, grain, is_deleted)
+                SELECT tenant_id, 1, @Domain, @MetricCode, 1, d.stat_date,
+                       IFNULL(d.supplier_code, ''), IFNULL(d.item_code, ''), IFNULL(d.po_no, ''),
+                       SUM(CASE WHEN IFNULL(d.receipt_qty, 0) >= IFNULL(d.order_qty, 0) AND IFNULL(d.order_qty, 0) > 0 THEN 1
+                                WHEN d.delivery_status = 'COMPLETED' THEN 1 ELSE 0 END),
+                       SUM(CASE WHEN IFNULL(d.order_qty, 0) > 0 THEN 1 ELSE 0 END),
+                       @BatchId, @Now, 'day', 0
+                FROM dwd_supplier_delivery d
+                WHERE d.stat_date = @StatDate
+                GROUP BY d.tenant_id, d.stat_date, IFNULL(d.supplier_code, ''), IFNULL(d.item_code, ''), IFNULL(d.po_no, '')
+                HAVING SUM(CASE WHEN IFNULL(d.order_qty, 0) > 0 THEN 1 ELSE 0 END) > 0
+                """,
+                new
+                {
+                    Domain = SmartOpsKpiAggregateRuleRegistry.DomainSupplyPurchase,
+                    MetricCode = metricCode,
+                    StatDate = date,
+                    BatchId = batchId,
+                    Now = now
+                });
+        }
+
+        foreach (var metricCode in new[] { "S4_L1_003", "S3_L1_003" })
+        {
+            affected += await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO ado_smart_ops_kpi_atomic_day
+                (tenant_id, factory_id, domain_code, metric_code, metric_level, stat_date,
+                 supplier_code, material_code, po_no,
+                 numerator_value, denominator_value, batch_id, calc_time, grain, is_deleted)
+                SELECT tenant_id, 1, @Domain, @MetricCode, 1, d.stat_date,
+                       IFNULL(d.supplier_code, ''), IFNULL(d.item_code, ''), IFNULL(d.po_no, ''),
+                       SUM(IFNULL(d.receipt_qty, 0)),
+                       GREATEST(COUNT(DISTINCT NULLIF(d.supplier_code, '')), 1),
+                       @BatchId, @Now, 'day', 0
+                FROM dwd_supplier_delivery d
+                WHERE d.stat_date = @StatDate
+                GROUP BY d.tenant_id, d.stat_date, IFNULL(d.supplier_code, ''), IFNULL(d.item_code, ''), IFNULL(d.po_no, '')
+                """,
+                new
+                {
+                    Domain = SmartOpsKpiAggregateRuleRegistry.DomainSupplyPurchase,
+                    MetricCode = metricCode,
+                    StatDate = date,
+                    BatchId = batchId,
+                    Now = now
+                });
+        }
+
+        return affected;
+    }
+
+    public async Task<int> BuildWorkScheduleDomainAsync(DateTime statDate, string? batchId, CancellationToken cancellationToken = default)
+    {
+        AidopTenantMigration.EnsureKpiAtomicTable(_db);
+        var date = statDate.Date;
+        var now = DateTime.Now;
+        var affected = 0;
+        var domain = SmartOpsKpiAggregateRuleRegistry.DomainWorkSchedule;
+
+        affected += await _db.Ado.ExecuteCommandAsync(
+            """
+            DELETE FROM ado_smart_ops_kpi_atomic_day
+            WHERE domain_code = @Domain AND stat_date = @StatDate
+            """,
+            new { Domain = domain, StatDate = date });
+
+        foreach (var metricCode in new[] { "S2_L1_001", "S6_L1_001" })
+        {
+            affected += await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO ado_smart_ops_kpi_atomic_day
+                (tenant_id, factory_id, domain_code, metric_code, metric_level, stat_date,
+                 product_code, order_no, production_line, work_order_no,
+                 sum_value, sample_count, batch_id, calc_time, grain, is_deleted)
+                SELECT tenant_id, factory_id, @Domain, @MetricCode, 1, stat_date,
+                       IFNULL(item_code, ''), IFNULL(sales_order_no, ''), IFNULL(prod_line, ''), IFNULL(work_order, ''),
+                       ROUND(SUM(schedule_cycle_days), 6), COUNT(1), @BatchId, @Now, 'day', 0
+                FROM dwd_order_schedule_trans
+                WHERE stat_date = @StatDate AND schedule_cycle_days IS NOT NULL AND schedule_cycle_days >= 0
+                GROUP BY tenant_id, factory_id, stat_date, IFNULL(item_code, ''), IFNULL(sales_order_no, ''),
+                         IFNULL(prod_line, ''), IFNULL(work_order, '')
+                """,
+                new { Domain = domain, MetricCode = metricCode, StatDate = date, BatchId = batchId, Now = now });
+        }
+
+        foreach (var metricCode in new[] { "S2_L1_002", "S6_L1_002" })
+        {
+            affected += await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO ado_smart_ops_kpi_atomic_day
+                (tenant_id, factory_id, domain_code, metric_code, metric_level, stat_date,
+                 product_code, order_no, production_line, work_order_no,
+                 numerator_value, denominator_value, batch_id, calc_time, grain, is_deleted)
+                SELECT tenant_id, factory_id, @Domain, @MetricCode, 1, stat_date,
+                       IFNULL(item_code, ''), IFNULL(sales_order_no, ''), IFNULL(prod_line, ''), IFNULL(work_order, ''),
+                       SUM(IFNULL(schedule_satisfaction_flag, 0)), COUNT(1), @BatchId, @Now, 'day', 0
+                FROM dwd_order_schedule_trans
+                WHERE stat_date = @StatDate
+                GROUP BY tenant_id, factory_id, stat_date, IFNULL(item_code, ''), IFNULL(sales_order_no, ''),
+                         IFNULL(prod_line, ''), IFNULL(work_order, '')
+                """,
+                new { Domain = domain, MetricCode = metricCode, StatDate = date, BatchId = batchId, Now = now });
+        }
+
+        foreach (var metricCode in new[] { "S2_L1_003", "S6_L1_003", "S9_L1_004" })
+        {
+            affected += await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO ado_smart_ops_kpi_atomic_day
+                (tenant_id, factory_id, domain_code, metric_code, metric_level, stat_date,
+                 product_code, order_no, production_line, work_order_no,
+                 numerator_value, denominator_value, batch_id, calc_time, grain, is_deleted)
+                SELECT tenant_id, factory_id, @Domain, @MetricCode, 1, stat_date,
+                       IFNULL(item_code, ''), IFNULL(sales_order_no, ''), IFNULL(prod_line, ''), IFNULL(work_order, ''),
+                       COUNT(1), GREATEST(SUM(CASE WHEN IFNULL(resource_person_count, 0) > 0 THEN resource_person_count ELSE 1 END), 1),
+                       @BatchId, @Now, 'day', 0
+                FROM dwd_order_schedule_trans
+                WHERE stat_date = @StatDate
+                GROUP BY tenant_id, factory_id, stat_date, IFNULL(item_code, ''), IFNULL(sales_order_no, ''),
+                         IFNULL(prod_line, ''), IFNULL(work_order, '')
+                """,
+                new { Domain = domain, MetricCode = metricCode, StatDate = date, BatchId = batchId, Now = now });
+        }
+
+        return affected;
+    }
+
+    public async Task<int> BuildInventoryDomainAsync(DateTime statDate, string? batchId, CancellationToken cancellationToken = default)
+    {
+        AidopTenantMigration.EnsureKpiAtomicTable(_db);
+        var date = statDate.Date;
+        var now = DateTime.Now;
+        var affected = 0;
+        var domain = SmartOpsKpiAggregateRuleRegistry.DomainInventory;
+
+        affected += await _db.Ado.ExecuteCommandAsync(
+            """
+            DELETE FROM ado_smart_ops_kpi_atomic_day
+            WHERE domain_code = @Domain AND stat_date = @StatDate
+            """,
+            new { Domain = domain, StatDate = date });
+
+        affected += await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO ado_smart_ops_kpi_atomic_day
+            (tenant_id, factory_id, domain_code, metric_code, metric_level, stat_date,
+             warehouse_code, material_code, work_order_no,
+             sum_value, sample_count, batch_id, calc_time, grain, is_deleted)
+            SELECT tenant_id, 1, @Domain, 'S5_L1_001', 1, stat_date,
+                   '', IFNULL(component_item_code, ''), IFNULL(work_order, ''),
+                   ROUND(SUM(GREATEST(TIMESTAMPDIFF(DAY, stat_date, IFNULL(need_date, stat_date)), 0)), 6),
+                   COUNT(1), @BatchId, @Now, 'day', 0
+            FROM dwd_material_readiness
+            WHERE stat_date = @StatDate
+            GROUP BY tenant_id, stat_date, IFNULL(component_item_code, ''), IFNULL(work_order, '')
+            """,
+            new { Domain = domain, StatDate = date, BatchId = batchId, Now = now });
+
+        affected += await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO ado_smart_ops_kpi_atomic_day
+            (tenant_id, factory_id, domain_code, metric_code, metric_level, stat_date,
+             warehouse_code, material_code, work_order_no,
+             numerator_value, denominator_value, batch_id, calc_time, grain, is_deleted)
+            SELECT tenant_id, 1, @Domain, 'S5_L1_002', 1, stat_date,
+                   '', IFNULL(component_item_code, ''), IFNULL(work_order, ''),
+                   SUM(CASE WHEN IFNULL(shortage_qty, 0) <= 0 OR UPPER(IFNULL(ready_status, '')) IN ('READY', 'SUFFICIENT', 'OK') THEN 1 ELSE 0 END),
+                   COUNT(1), @BatchId, @Now, 'day', 0
+            FROM dwd_material_readiness
+            WHERE stat_date = @StatDate
+            GROUP BY tenant_id, stat_date, IFNULL(component_item_code, ''), IFNULL(work_order, '')
+            """,
+            new { Domain = domain, StatDate = date, BatchId = batchId, Now = now });
+
+        affected += await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO ado_smart_ops_kpi_atomic_day
+            (tenant_id, factory_id, domain_code, metric_code, metric_level, stat_date,
+             warehouse_code, material_code, work_order_no,
+             numerator_value, denominator_value, batch_id, calc_time, grain, is_deleted)
+            SELECT tenant_id, 1, @Domain, 'S5_L1_003', 1, stat_date,
+                   '', IFNULL(component_item_code, ''), IFNULL(work_order, ''),
+                   COUNT(1), GREATEST(COUNT(DISTINCT NULLIF(supplier_code, '')), 1),
+                   @BatchId, @Now, 'day', 0
+            FROM dwd_material_readiness
+            WHERE stat_date = @StatDate
+            GROUP BY tenant_id, stat_date, IFNULL(component_item_code, ''), IFNULL(work_order, '')
+            """,
+            new { Domain = domain, StatDate = date, BatchId = batchId, Now = now });
+
+        affected += await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO ado_smart_ops_kpi_atomic_day
+            (tenant_id, factory_id, domain_code, metric_code, metric_level, stat_date,
+             warehouse_code, material_code, work_order_no,
+             sum_value, sample_count, batch_id, calc_time, grain, is_deleted)
+            SELECT tenant_id, 1, @Domain, 'S9_L1_005', 1, stat_date,
+                   '', IFNULL(component_item_code, ''), IFNULL(work_order, ''),
+                   ROUND(SUM(GREATEST(IFNULL(shortage_qty, 0), 0)), 6),
+                   COUNT(1), @BatchId, @Now, 'day', 0
+            FROM dwd_material_readiness
+            WHERE stat_date = @StatDate
+            GROUP BY tenant_id, stat_date, IFNULL(component_item_code, ''), IFNULL(work_order, '')
+            """,
+            new { Domain = domain, StatDate = date, BatchId = batchId, Now = now });
+
+        return affected;
+    }
+}

+ 187 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/SmartOpsKpiAtomicQueryService.cs

@@ -0,0 +1,187 @@
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Entity;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>
+/// 从 KPI 原子聚合层按筛选条件动态聚合并计算最终 KPI。
+/// </summary>
+public class SmartOpsKpiAtomicQueryService : ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public SmartOpsKpiAtomicQueryService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    public async Task<FilteredKpiQueryResult> QueryAsync(
+        long tenantId,
+        long factoryId,
+        string moduleCode,
+        SmartOpsDashboardFilter filter,
+        IReadOnlyList<AdoSmartOpsKpiMaster> kpis,
+        CancellationToken cancellationToken = default)
+    {
+        AidopTenantMigration.EnsureKpiAtomicTable(_db);
+
+        var result = new FilteredKpiQueryResult
+        {
+            FilterContext = filter.ToContextDictionary()
+                .Where(x => !string.IsNullOrWhiteSpace(x.Value))
+                .ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase)
+        };
+
+        if (filter.IsEmpty || kpis.Count == 0)
+        {
+            result.Scope = "module_summary";
+            return result;
+        }
+
+        var filteredCount = 0;
+        var summaryCount = 0;
+        var partialCount = 0;
+
+        foreach (var kpi in kpis)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+            var metricCode = kpi.MetricCode;
+            var rule = SmartOpsKpiAggregateRuleRegistry.TryGet(metricCode);
+            var unsupported = SmartOpsKpiAggregateRuleRegistry.GetUnsupportedFilters(metricCode, filter).ToArray();
+            var filterMode = SmartOpsKpiAggregateRuleRegistry.ResolveFilterMode(metricCode, filter);
+            result.MetricFilterModes[metricCode] = filterMode;
+            if (unsupported.Length > 0)
+                result.UnsupportedFiltersByMetric[metricCode] = unsupported;
+
+            if (rule == null || rule.FilterMode == SmartOpsKpiAggregateRuleRegistry.FilterSummaryOnly ||
+                !SmartOpsKpiAggregateRuleRegistry.SupportsFilter(metricCode, filter))
+            {
+                if (filterMode == SmartOpsKpiAggregateRuleRegistry.FilterPartial)
+                    partialCount++;
+                else
+                    summaryCount++;
+                continue;
+            }
+
+            var valueSql = BuildAggregateSql(rule.AggregationType, groupByDate: false);
+            var whereSql = BuildWhereSql(filter);
+            var sql = $"""
+                SELECT {valueSql} AS MetricValue
+                FROM ado_smart_ops_kpi_atomic_day
+                WHERE tenant_id = @tenantId
+                  AND factory_id = @factoryId
+                  AND domain_code = @domainCode
+                  AND metric_code = @metricCode
+                  AND is_deleted = 0
+                  {whereSql}
+                """;
+
+            var parameters = BuildParameters(tenantId, factoryId, rule.DomainCode, metricCode, filter);
+            var current = await _db.Ado.SqlQuerySingleAsync<AtomicAggregateRow>(sql, parameters);
+            if (current?.MetricValue == null)
+            {
+                summaryCount++;
+                result.MetricFilterModes[metricCode] = SmartOpsKpiAggregateRuleRegistry.FilterSummaryOnly;
+                continue;
+            }
+
+            var trendSql = $"""
+                SELECT stat_date AS StatDate, {BuildAggregateSql(rule.AggregationType, groupByDate: true)} AS MetricValue
+                FROM ado_smart_ops_kpi_atomic_day
+                WHERE tenant_id = @tenantId
+                  AND factory_id = @factoryId
+                  AND domain_code = @domainCode
+                  AND metric_code = @metricCode
+                  AND is_deleted = 0
+                  {whereSql}
+                GROUP BY stat_date
+                ORDER BY stat_date
+                """;
+            var trendRows = await _db.Ado.SqlQueryAsync<AtomicTrendRow>(trendSql, parameters);
+
+            result.Values[metricCode] = new SmartOpsKpiMetricValue
+            {
+                Level = kpi.MetricLevel,
+                MetricValue = current.MetricValue,
+                Trend = trendRows
+                    .Select(x => new SmartOpsKpiTrendPoint(x.StatDate.ToString("yyyy-MM-dd"), x.MetricValue))
+                    .ToList()
+            };
+            filteredCount++;
+        }
+
+        result.Scope = filteredCount switch
+        {
+            0 when summaryCount > 0 && partialCount == 0 => "summary_only_fallback",
+            0 => "summary_only_fallback",
+            var n when n == kpis.Count => "filtered_atomic",
+            var n when n > 0 => "partial_filtered",
+            _ => "summary_only_fallback"
+        };
+
+        return result;
+    }
+
+    private static string BuildAggregateSql(string aggregationType, bool groupByDate)
+    {
+        return aggregationType switch
+        {
+            SmartOpsKpiAggregateRuleRegistry.AggSum => "ROUND(SUM(sum_value), 4)",
+            SmartOpsKpiAggregateRuleRegistry.AggRatio =>
+                "ROUND(100 * SUM(numerator_value) / NULLIF(SUM(denominator_value), 0), 4)",
+            SmartOpsKpiAggregateRuleRegistry.AggMin => "ROUND(MIN(min_value), 4)",
+            SmartOpsKpiAggregateRuleRegistry.AggMax => "ROUND(MAX(max_value), 4)",
+            _ => "ROUND(SUM(sum_value) / NULLIF(SUM(sample_count), 0), 4)"
+        };
+    }
+
+    private static string BuildWhereSql(SmartOpsDashboardFilter filter)
+    {
+        var clauses = new List<string>();
+        if (filter.DateStart.HasValue) clauses.Add("AND stat_date >= @dateStart");
+        if (filter.DateEnd.HasValue) clauses.Add("AND stat_date <= @dateEnd");
+        if (!string.IsNullOrWhiteSpace(filter.Customer)) clauses.Add("AND customer_code = @customer");
+        if (!string.IsNullOrWhiteSpace(filter.Product)) clauses.Add("AND product_code = @product");
+        if (!string.IsNullOrWhiteSpace(filter.OrderNo)) clauses.Add("AND order_no = @orderNo");
+        if (!string.IsNullOrWhiteSpace(filter.ProductionLine)) clauses.Add("AND production_line = @productionLine");
+        if (!string.IsNullOrWhiteSpace(filter.Supplier)) clauses.Add("AND supplier_code = @supplier");
+        if (!string.IsNullOrWhiteSpace(filter.Material)) clauses.Add("AND material_code = @material");
+        if (!string.IsNullOrWhiteSpace(filter.PoNo)) clauses.Add("AND po_no = @poNo");
+        if (!string.IsNullOrWhiteSpace(filter.Warehouse)) clauses.Add("AND warehouse_code = @warehouse");
+        if (!string.IsNullOrWhiteSpace(filter.WorkOrder)) clauses.Add("AND work_order_no = @workOrder");
+        if (!string.IsNullOrWhiteSpace(filter.Equipment)) clauses.Add("AND equipment_code = @equipment");
+        if (!string.IsNullOrWhiteSpace(filter.OutboundNo)) clauses.Add("AND outbound_no = @outboundNo");
+        return string.Join(' ', clauses);
+    }
+
+    private static object BuildParameters(
+        long tenantId,
+        long factoryId,
+        string domainCode,
+        string metricCode,
+        SmartOpsDashboardFilter filter)
+    {
+        return new
+        {
+            tenantId,
+            factoryId,
+            domainCode,
+            metricCode,
+            dateStart = filter.DateStart?.Date,
+            dateEnd = filter.DateEnd?.Date,
+            customer = filter.Customer?.Trim(),
+            product = filter.Product?.Trim(),
+            orderNo = filter.OrderNo?.Trim(),
+            productionLine = filter.ProductionLine?.Trim(),
+            supplier = filter.Supplier?.Trim(),
+            material = filter.Material?.Trim(),
+            poNo = filter.PoNo?.Trim(),
+            warehouse = filter.Warehouse?.Trim(),
+            workOrder = filter.WorkOrder?.Trim(),
+            equipment = filter.Equipment?.Trim(),
+            outboundNo = filter.OutboundNo?.Trim()
+        };
+    }
+}

+ 40 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/SmartOpsKpiQueryModels.cs

@@ -0,0 +1,40 @@
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+public sealed class FilteredKpiQueryResult
+{
+    public string Scope { get; set; } = "module_summary";
+    public Dictionary<string, SmartOpsKpiMetricValue> Values { get; init; } = new(StringComparer.OrdinalIgnoreCase);
+    public Dictionary<string, string> MetricFilterModes { get; init; } = new(StringComparer.OrdinalIgnoreCase);
+    public Dictionary<string, string[]> UnsupportedFiltersByMetric { get; init; } = new(StringComparer.OrdinalIgnoreCase);
+    public Dictionary<string, string?> FilterContext { get; init; } = new(StringComparer.OrdinalIgnoreCase);
+}
+
+public sealed class SmartOpsKpiMetricValue
+{
+    public int Level { get; init; }
+    public decimal? MetricValue { get; init; }
+    public List<SmartOpsKpiTrendPoint> Trend { get; init; } = new();
+}
+
+public sealed class SmartOpsKpiTrendPoint
+{
+    public SmartOpsKpiTrendPoint(string date, decimal? value)
+    {
+        Date = date;
+        Value = value;
+    }
+
+    public string Date { get; }
+    public decimal? Value { get; }
+}
+
+public sealed class AtomicAggregateRow
+{
+    public decimal? MetricValue { get; set; }
+}
+
+public sealed class AtomicTrendRow
+{
+    public DateTime StatDate { get; set; }
+    public decimal? MetricValue { get; set; }
+}

+ 8 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/S3MdpSyncTransformService.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.SmartOps;
+
 namespace Admin.NET.Plugin.AiDOP.Supply;
 
 /// <summary>
@@ -6,10 +8,12 @@ namespace Admin.NET.Plugin.AiDOP.Supply;
 public class S3MdpSyncTransformService : ITransient
 {
     private readonly ISqlSugarClient _db;
+    private readonly SmartOpsKpiAtomicBuildService _atomicBuild;
 
-    public S3MdpSyncTransformService(ISqlSugarClient db)
+    public S3MdpSyncTransformService(ISqlSugarClient db, SmartOpsKpiAtomicBuildService atomicBuild)
     {
         _db = db;
+        _atomicBuild = atomicBuild;
     }
 
     public async Task<S3MdpSyncTransformResult> RunFullAsync(CancellationToken cancellationToken = default, string triggerType = "AUTO")
@@ -26,6 +30,8 @@ public class S3MdpSyncTransformService : ITransient
             result.StandardRows = await TransformStandardAsync(batchId, now, cancellationToken);
             result.DwdRows = await BuildDwdAsync(batchId, now, cancellationToken);
             result.KpiRows = await BuildS3KpiValuesAsync(now, cancellationToken);
+            result.AtomicRows = await _atomicBuild.BuildSupplyPurchaseDomainForAllDatesAsync(batchId, cancellationToken: cancellationToken);
+            result.AtomicRows += await _atomicBuild.BuildInventoryDomainForAllDatesAsync(batchId, cancellationToken: cancellationToken);
             await MarkTransformRunSuccessAsync(runLogId, now, result);
             return result;
         }
@@ -1023,6 +1029,7 @@ public sealed class S3MdpSyncTransformResult
     public int StandardRows { get; set; }
     public int DwdRows { get; set; }
     public int KpiRows { get; set; }
+    public int AtomicRows { get; set; }
 }
 
 public sealed class S3MaterialRefreshResult