Quellcode durchsuchen

fix(s8): close dashboard P2 semantics (filter/today/trend/denominator) | server 1.0.565 / web 2.4.408

P2-A 看板筛选参数被前端静默丢弃
  kanban/s8.vue 写的是 fetchS8Alerts(1, 1, extra),而签名只有 (factoryId, extra):
  extra 绑到字面量 1,{factoryId, ...1} 展开数字等于 {},筛选一条都没上行,
  onQuery() 却照样弹「已查询」。改为调用新的汇总端点并正确传参。

P2-B 「今日预警数」实际只是 LIMIT N,没有今日时间过滤
  该卡片数的是 s8-alerts(ORDER BY alert_time DESC LIMIT 6)返回的行数,SQL 里无今日谓词:
  实库仅 2 行、时间是 2026-03-26,却被显示成「今日 2」;今日真超 6 条又会被封顶在 6。
  新增 GET /api/AidopKanban/s8-alert-summary:显式半开窗口 + COUNT(*)、无 LIMIT,
  未传日期 ⇒ [今日00:00, 明日00:00) 且 isToday=true;传了日期 ⇒ 区间窗口、标题切「区间预警数」。
  既有 s8-alerts 列表端点保持不变(home.vue 仍依赖其最近 6 条),由测试钉住。

P2-C last_24h 趋势档恒 0
  S8PeriodHelper 对 last_24h 返回 [Now-24h, Now)(非零点),而日桶按 CreatedAt.Date 分组;
  原实现用 from.AddDays(i)(键上带 17:25:46)去 TryGetValue,永远 miss ⇒ 峰值/均值/今日全 0,
  且只产 1 个桶、标签还是昨天。抽出 S8TrendBucketHelper 统一枚举「窗口跨越的自然日」,
  保证 Σ桶 == 窗口内行数;窗口只收紧不放宽;超 90 桶保留最近 N 天并同步收紧窗口
  (旧实现是砍尾巴、把已查出的行静默丢弃);首尾桶不完整时日环比置 null。
  趋势摘要新增 windowFrom / windowToExclusive / windowTruncated / partialEdgeDays。

P2-D 「每百订单」缺少真实订单分母 ⇒ NO_DATA
  Frequency 的注释写「每百订单异常数」,实际是 Frequency = total(绝对数)。
  算这个比率需要阶段级订单分母,S8 无任何 Authority 可提供:各 stage 异常对象类型不同
  (实库 29 条全是 S4 / PURCHASE_DELIVERY_ABNORMAL,对象是采购单),
  mdp_std_so 只是 S1 的销售订单行,充当全阶段公共分母属于自造口径。
  按「无 Authority 不得发明公式、NO_DATA ≠ 0」改为 double? 恒 null,前端渲染占位符。

验证
  · dotnet build Admin.NET.sln(net8.0 + net10.0 全覆盖)0 Error
  · 新增 43 个测试(后端 31 / 前端 12)全绿;既有失败 5 条均为本批 diff 外的基线
  · vue-tsc 583(基线 584,净 -1);vite build exit 0
  · 运行时 API 与独立 SQL 逐窗口核对 Delta=0(9 个窗口);同请求连打 3 次结果一致
  · 页面冒烟:last_24h 由 1 桶(标昨日) → 2 桶(09/22+09/23)、partialEdgeDays=true、环比 null;
    看板筛选后 wire 上确有 dateStart/dateEnd;order-grid 全部 frequency=null;控制台无错误
YY968XX vor 1 Tag
Ursprung
Commit
d9e10f3ef1

+ 1 - 1
Web/package.json

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

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

@@ -720,6 +720,34 @@ export async function fetchS8Alerts(
 	return (res.data ?? []) as S8AlertRow[];
 }
 
+/**
+ * S8 预警计数(S8-DASHBOARD-P2-SEMANTICS-1 · P2-B)。
+ *
+ * `fetchS8Alerts` 是「最近 6 条」列表,SQL 里没有今日谓词、还带 LIMIT 6,
+ * 拿它的 length 当「今日预警数」两个方向都会错(旧行被算成今日 / 今日超 6 条被封顶)。
+ * 计数一律走本接口:窗口由服务端显式给出,并在 `isToday` 里声明是不是自然日今日。
+ */
+export interface S8AlertSummary {
+	/** 统计窗口起始日(含),yyyy-MM-dd */
+	windowStart: string;
+	/** 统计窗口结束日(含),yyyy-MM-dd */
+	windowEnd: string;
+	/** 窗口是否 = 服务器自然日「今日」;false 表示用户选了日期区间 */
+	isToday: boolean;
+	total: number;
+	critical: number;
+}
+
+export async function fetchS8AlertSummary(
+	factoryId = 1,
+	extra: KanbanExtraQuery = {}
+): Promise<S8AlertSummary> {
+	const res = await service.get('/api/AidopKanban/s8-alert-summary', {
+		params: { factoryId, ...extra },
+	});
+	return res.data as S8AlertSummary;
+}
+
 export interface S8HomeTrendPoint {
 	date: string;
 	count: number;

+ 153 - 0
Web/src/views/aidop/kanban/dashboardSemantics.spec.ts

@@ -0,0 +1,153 @@
+// S8-DASHBOARD-P2-SEMANTICS-1:看板语义守卫(P2-A 筛选 / P2-B 今日 / P2-D 分母)。
+//
+// 背景(2026-09-22 取证):
+//   · P2-A  kanban/s8.vue 写的是 `fetchS8Alerts(1, 1, extra)`,而该函数签名只有
+//     (factoryId, extra) 两个形参 —— extra 绑到了字面量 1,`{ factoryId, ...1 }`
+//     展开数字等于 {},筛选条件一个都没上行,但 onQuery() 照样弹「已查询」。
+//   · P2-B  「今日预警数」= 那条 `LIMIT 6` 列表的 length,SQL 里根本没有今日谓词:
+//     实库 ado_s8_alert_record 仅有 2 行、时间是 2026-03-26,却会被显示成「今日 2 条」。
+//   · P2-D  frequency 的 DTO 注释写着「每百订单异常数」,实际 `Frequency = total`,
+//     没有任何订单分母;S8 异常对象(采购单)与 mdp_std_so(销售订单)也不是同一种对象。
+//
+// 源码契约类断言一律作用于**去注释后的源码**,否则本文件/被测文件的注释会把守卫变成噪音。
+
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const getMock = vi.fn();
+
+vi.mock('/@/utils/request', () => ({
+	default: {
+		get: (...args: unknown[]) => getMock(...args),
+		post: vi.fn(),
+		put: vi.fn(),
+		delete: vi.fn(),
+		patch: vi.fn(),
+	},
+}));
+
+vi.mock('/@/stores/userInfo', () => ({
+	useUserInfo: () => ({ userInfos: { currentTenantId: 9, tenantId: 9 } }),
+}));
+
+import { fetchS8AlertSummary, fetchS8Alerts } from '../api/kanbanData';
+
+const SRC = resolve(__dirname, '../../..');
+
+function readCode(relPath: string): string {
+	const raw = readFileSync(resolve(SRC, relPath), 'utf-8');
+	const noBlock = raw.replace(/\/\*[\s\S]*?\*\//g, '');
+	const noLine = noBlock.replace(/^\s*\/\/.*$/gm, '');
+	return noLine.replace(/<!--[\s\S]*?-->/g, '');
+}
+
+const kanbanS8 = () => readCode('views/aidop/kanban/s8.vue');
+const kanbanApi = () => readCode('views/aidop/api/kanbanData.ts');
+const monitoringApi = () => readCode('views/aidop/s8/api/s8MonitoringApi.ts');
+const orderGridCell = () => readCode('views/aidop/s8/monitoring/components/OrderGridCell.vue');
+
+describe('S8 看板:筛选 / 今日 / 分母 语义', () => {
+	beforeEach(() => getMock.mockReset());
+
+	// ── P2-A 筛选参数必须真的上行 ──
+
+	it('㉕ AlertSummary_ForwardsFilterParams:汇总端点把全部筛选维度带到 query', async () => {
+		getMock.mockResolvedValue({ data: { total: 0, critical: 0, isToday: true } });
+
+		await fetchS8AlertSummary(1, {
+			dateStart: '2026-09-01',
+			dateEnd: '2026-09-22',
+			product: 'P-1',
+			orderNo: 'SO-9',
+			productionLine: 'L3',
+		});
+
+		const [url, cfg] = getMock.mock.calls[0] as [string, { params: Record<string, unknown> }];
+		expect(url).toBe('/api/AidopKanban/s8-alert-summary');
+		expect(cfg.params).toMatchObject({
+			factoryId: 1,
+			dateStart: '2026-09-01',
+			dateEnd: '2026-09-22',
+			product: 'P-1',
+			orderNo: 'SO-9',
+			productionLine: 'L3',
+		});
+	});
+
+	it('㉖ AlertList_ForwardsFilterParams:列表端点同样不丢参(home.vue 依赖)', async () => {
+		getMock.mockResolvedValue({ data: [] });
+		await fetchS8Alerts(1, { product: 'P-1' });
+
+		const [, cfg] = getMock.mock.calls[0] as [string, { params: Record<string, unknown> }];
+		expect(cfg.params).toMatchObject({ factoryId: 1, product: 'P-1' });
+	});
+
+	it('㉗ KanbanS8_DoesNotDropFilters:页面不得再用错元数的调用', () => {
+		const code = kanbanS8();
+		// 原缺陷:fetchS8Alerts(1, 1, extra) —— 第三个实参被丢弃
+		expect(code).not.toMatch(/fetchS8Alerts\s*\(\s*1\s*,\s*1\s*,/);
+		expect(code).toContain('fetchS8AlertSummary');
+	});
+
+	it('㉘ NoCallSite_PassesThreeArgsToFetchS8Alerts:全仓无超元数调用', () => {
+		for (const f of ['views/aidop/kanban/s8.vue', 'views/dashboard/home.vue']) {
+			expect(readCode(f)).not.toMatch(/fetchS8Alerts\s*\([^)]*,[^)]*,[^)]*\)/);
+		}
+	});
+
+	// ── P2-B 今日必须是真的今日 ──
+
+	it('㉙ TodayCount_NotDerivedFromListLength:计数不得来自 LIMIT 列表长度', () => {
+		const code = kanbanS8();
+		expect(code).not.toMatch(/value:\s*String\(list\.length/);
+		expect(code).not.toContain('filteredAlerts');
+	});
+
+	it('㉚ TodayCard_ReflectsServerWindow:卡片跟随服务端返回的窗口口径', () => {
+		const code = kanbanS8();
+		expect(code).toContain('isToday');
+		expect(code).toContain('windowStart');
+		expect(code).toContain('windowEnd');
+	});
+
+	it('㉛ TodayCard_NoDataIsNotZero:拿不到汇总时显示占位而非 0', () => {
+		const code = kanbanS8();
+		expect(code).not.toMatch(/String\(\s*\w+\s*\.length\s*\|\|\s*0\s*\)/);
+		expect(code).toContain("'—'");
+	});
+
+	it('㉜ AlertSummary_TypeExposesWindow:前端类型必须带窗口字段', () => {
+		const code = kanbanApi();
+		expect(code).toContain('export interface S8AlertSummary');
+		for (const f of ['windowStart', 'windowEnd', 'isToday', 'total', 'critical']) {
+			expect(code).toContain(f);
+		}
+	});
+
+	// ── P2-D 每百订单:无分母就 NO_DATA ──
+
+	it('㉝ Frequency_IsNullable:前端类型允许 NO_DATA', () => {
+		expect(monitoringApi()).toMatch(/frequency:\s*number\s*\|\s*null/);
+	});
+
+	it('㉞ FrequencyCell_RendersNoData:无分母时不得渲染 0.0/百单', () => {
+		const code = orderGridCell();
+		expect(code).not.toMatch(/module\.frequency\.toFixed/);
+		expect(code).toContain('frequencyText');
+	});
+
+	it('㉟ FrequencyCell_DoesNotClaimPerHundred:模板不得裸挂「/百单」单位', () => {
+		const code = orderGridCell();
+		expect(code).toContain('frequencyText');
+		// 单位只允许出现在带 null 判定的格式化函数里;模板不得无条件写死单位,
+		// 否则 NO_DATA 会被渲染成「— /百单」这种仍在断言口径的样子。
+		const template = code.slice(0, code.indexOf('<script'));
+		expect(template).not.toContain('/百单');
+	});
+
+	it('㊱ Frequency_NoDataNotCoercedToZero:页面侧不得把 null 频率吞成 0', () => {
+		const code = readCode('views/aidop/s8/monitoring/S8MonitoringOverviewPage.vue');
+		expect(code).not.toMatch(/module\?\.frequency\s*\?\?\s*0/);
+	});
+});

+ 24 - 18
Web/src/views/aidop/kanban/s8.vue

@@ -23,13 +23,12 @@
 import { onMounted, ref, computed } from 'vue';
 import { ElMessage } from 'element-plus';
 import AidopDemoShell from '../components/AidopDemoShell.vue';
-import { fetchS8Alerts, type S8AlertRow } from '../api/kanbanData';
+import { fetchS8AlertSummary, type S8AlertSummary } from '../api/kanbanData';
 import DetailQueryBar from './components/DetailQueryBar.vue';
 import SmartOpsBaseQueryFields from './components/SmartOpsBaseQueryFields.vue';
 import DashboardFullscreenButton from './components/DashboardFullscreenButton.vue';
 import {
 	emptySmartOpsBaseQuery,
-	blobMatchesSmartOpsBase,
 	baseQueryToApiParams,
 	summarizeSmartOpsBaseQuery,
 	type SmartOpsBaseQuery,
@@ -37,24 +36,31 @@ import {
 
 const baseQuery = ref<SmartOpsBaseQuery>(emptySmartOpsBaseQuery());
 
-const rawList = ref<S8AlertRow[]>([]);
-
-const filteredAlerts = computed(() =>
-	rawList.value.filter((x) =>
-		blobMatchesSmartOpsBase(baseQuery.value, `${x.message ?? ''} ${x.module ?? ''} ${x.time ?? ''}`)
-	)
-);
+// S8-DASHBOARD-P2-SEMANTICS-1(P2-A + P2-B):
+//  · P2-A 原为 `fetchS8Alerts(1, 1, extra)` —— 签名只有 (factoryId, extra) 两个形参,
+//    extra 绑到了字面量 1,`{ factoryId, ...1 }` 展开数字等于 {},筛选条件一条都没上行;
+//    第三个实参被静默丢弃,onQuery() 却照样弹「已查询」。
+//  · P2-B 两张计数卡原本数的是那条 `LIMIT 6` 列表的长度,SQL 里没有任何今日谓词 ——
+//    表里存着几个月前的旧行也会被显示成「今日 N 条」,今日真超 6 条又会被封顶在 6。
+// 现在计数一律取服务端汇总:窗口由后端显式给出,并自报是不是自然日「今日」。
+const summary = ref<S8AlertSummary | null>(null);
 
 const alarms = computed(() => {
-	const list = filteredAlerts.value;
-	const critical = list.filter((x) => x.level === 'critical').length;
+	const s = summary.value;
+	// 用户选了日期区间时,这张卡数的就不是「今日」了,标题必须跟着改口径。
+	const countTitle = s && !s.isToday ? '区间预警数' : '今日预警数';
+	const windowDesc = s
+		? s.isToday
+			? `统计窗口 ${s.windowStart}(服务器自然日)`
+			: `统计窗口 ${s.windowStart} ~ ${s.windowEnd}`
+		: '暂无数据(未取到预警汇总)';
 	return [
-		{ title: '今日预警数', value: String(list.length || 0), desc: '来源数据库告警表', level: 'warn' },
+		{ title: countTitle, value: s ? String(s.total) : '—', desc: windowDesc, level: 'warn' },
 		{
 			title: '严重告警',
-			value: String(critical),
-			desc: '需 1h 内响应',
-			level: critical > 0 ? 'danger' : 'ok',
+			value: s ? String(s.critical) : '—',
+			desc: s ? '需 1h 内响应' : '暂无数据(未取到预警汇总)',
+			level: s && s.critical > 0 ? 'danger' : 'ok',
 		},
 		// S8-P1-FACTUAL-DISPLAY-1:原为写死的 `list.length ? '86%' : '100%'`(副标题还写着「目标 95%」),
 		// 没有任何接口或表支撑,纯字面量冒充业务指标。
@@ -73,10 +79,10 @@ const alarms = computed(() => {
 
 async function loadAlerts() {
 	try {
-		const extra = baseQueryToApiParams(baseQuery.value);
-		rawList.value = await fetchS8Alerts(1, 1, extra);
+		summary.value = await fetchS8AlertSummary(1, baseQueryToApiParams(baseQuery.value));
 	} catch {
-		rawList.value = [];
+		// 取不到汇总 = NO_DATA,卡片走占位符;绝不回落成 0 冒充「今日 0 条」。
+		summary.value = null;
 	}
 }
 

+ 9 - 1
Web/src/views/aidop/s8/api/s8MonitoringApi.ts

@@ -58,7 +58,15 @@ export interface S8ModuleOrderSummary {
 	yellow: number;
 	red: number;
 	total: number;
-	frequency: number;
+	/**
+	 * 每百订单异常数 —— S8-DASHBOARD-P2-SEMANTICS-1(P2-D)起恒为 null(NO_DATA)。
+	 *
+	 * 后端曾把「异常绝对数」直接赋给本字段冒充比率。算每百订单需要「该阶段的订单分母」,
+	 * 而 S8 没有 Authority 能提供:各 stage 的异常对象类型不同(S4 的对象是采购单),
+	 * mdp_std_so 只是 S1 的销售订单行,当全阶段公共分母属于自造口径。
+	 * 待业务确认分母口径后再接入;在此之前一律渲染占位符,不得回落成 0。
+	 */
+	frequency: number | null;
 	avgProcessHours: number;
 	closeRate: number;
 }

+ 7 - 3
Web/src/views/aidop/s8/monitoring/S8MonitoringOverviewPage.vue

@@ -479,7 +479,9 @@ function buildStageCard(
 	const seriousTimeout = Math.max(summaryItem?.seriousTimeout ?? 0, 0);
 	const followTimeout = Math.max(summaryItem?.followTimeout ?? 0, 0);
 	const closeRate = clampPercent(module?.closeRate ?? 0);
-	const frequency = module?.frequency ?? 0;
+	// S8-DASHBOARD-P2-SEMANTICS-1(P2-D):频率无分母可算 ⇒ 后端返回 null。
+	// 这里保持 null,交给 formatStageValue 渲染占位符;不得 `?? 0` 把 NO_DATA 吞成 0。
+	const frequency = module?.frequency ?? null;
 	const avgProcessHours = module?.avgProcessHours ?? 0;
 	const tone: 'green' | 'yellow' | 'red' = red > 0 ? 'red' : yellow > 0 ? 'yellow' : 'green';
 	const title = stripModulePrefix(module?.moduleLabel || summaryItem?.moduleLabel || meta.title, meta.code) || meta.title;
@@ -536,11 +538,13 @@ function stripModulePrefix(label: string, code: string) {
 
 function formatStageValue(
 	mode: StageMeta['valueMode'],
-	data: { total: number; frequency: number; avgProcessHours: number; closeRate: number }
+	data: { total: number; frequency: number | null; avgProcessHours: number; closeRate: number }
 ) {
 	switch (mode) {
 		case 'frequency':
-			return data.frequency.toFixed(1);
+			return typeof data.frequency === 'number' && Number.isFinite(data.frequency)
+				? data.frequency.toFixed(1)
+				: '—';
 		case 'avgHours':
 			return formatHours(data.avgProcessHours);
 		case 'closeRate':

+ 13 - 2
Web/src/views/aidop/s8/monitoring/components/OrderGridCell.vue

@@ -31,8 +31,7 @@
 		<div class="ogc__meta">
 			<span class="ogc__meta-item">
 				<span class="ogc__meta-label">频率</span>
-				<span class="ogc__meta-val">{{ module.frequency.toFixed(1) }}</span>
-				<span class="ogc__meta-unit">/百单</span>
+				<span class="ogc__meta-val" :title="frequencyTitle">{{ frequencyText }}</span>
 			</span>
 			<span class="ogc__meta-sep" />
 			<span class="ogc__meta-item">
@@ -67,6 +66,18 @@ const moduleName = computed(() => {
 
 const hasAnomaly = computed(() => props.module.red > 0 || props.module.yellow > 0);
 
+// S8-DASHBOARD-P2-SEMANTICS-1(P2-D):原为 `module.frequency.toFixed(1)` + 写死单位「/百单」。
+// 后端没有任何阶段级订单分母可用,该比率算不出来 ⇒ 返回 null。此处只渲染占位符,
+// 并把「为什么没有」放进 title,绝不回落成 0.0 冒充一个真实存在的比率。
+const frequencyText = computed(() => {
+	const f = props.module.frequency;
+	return typeof f === 'number' && Number.isFinite(f) ? `${f.toFixed(1)} /百单` : '—';
+});
+
+const frequencyTitle = computed(() =>
+	frequencyText.value === '—' ? '暂无数据(缺少阶段级订单分母,每百订单异常数不可算)' : '每百订单异常数'
+);
+
 // red > yellow > green
 const statusKey = computed(() => {
 	if (props.module.red > 0) return 'red';

+ 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.564</AssemblyVersion>
-    <FileVersion>1.0.564</FileVersion>
-    <Version>1.0.564</Version>
+    <AssemblyVersion>1.0.565</AssemblyVersion>
+    <FileVersion>1.0.565</FileVersion>
+    <Version>1.0.565</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 369 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8DashboardSemanticsTests.cs

@@ -0,0 +1,369 @@
+using Admin.NET.Plugin.AiDOP.Controllers.S8;
+using Admin.NET.Plugin.AiDOP.Dto.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using System.Text.RegularExpressions;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-DASHBOARD-P2-SEMANTICS-1:看板语义守卫(P2-B 今日 / P2-C 趋势桶 / P2-D 分母)。
+///
+/// <para><b>P2-C 的缺陷是一个纯函数缺陷</b>:<c>last_24h</c> 的窗口是
+/// <c>[Now-24h, Now)</c>(非零点),而日桶是按 <c>CreatedAt.Date</c>(零点)分组的,
+/// 原实现用 <c>from.AddDays(i)</c> 直接拿非零点的 <c>from</c> 当桶键去 <c>TryGetValue</c>,
+/// 永远 miss ⇒ 峰值 / 均值 / 今日 恒为 0。因此桶枚举被抽成
+/// <see cref="S8TrendBucketHelper"/> 后可以确定性验证,不依赖实库时钟。</para>
+///
+/// <para>服务层方法需要 SqlSugar 实库、进程内无法构造,故其余判据沿用本仓既有范式
+/// (反射元数据 + 去注释源码契约),与 <c>S8OrderArchiveAuthorityTests</c> 一致。</para>
+/// </summary>
+public class S8DashboardSemanticsTests
+{
+    private static string PluginRoot => Path.GetFullPath(
+        Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+
+    private static string ReadCode(string relativePath)
+    {
+        var raw = File.ReadAllText(Path.Combine(PluginRoot, relativePath));
+        var noBlock = Regex.Replace(raw, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline);
+        return Regex.Replace(noBlock, @"^\s*//.*$", string.Empty, RegexOptions.Multiline);
+    }
+
+    private static string MonitoringCode() => ReadCode("Service/S8/S8MonitoringService.cs");
+    private static string KanbanCode() => ReadCode("Controllers/AidopKanbanController.cs");
+
+    /// <summary>
+    /// 取某个方法体:从签名起,到下一个「类成员级(4 空格缩进)声明」之前。
+    /// 必须把 private / internal 也算进终止符,否则方法体会吃进后面的私有辅助方法,
+    /// 让 <c>DoesNotContain</c> 类断言变成假失败。
+    /// </summary>
+    private static string MethodBody(string code, string signature)
+    {
+        var start = code.IndexOf(signature, StringComparison.Ordinal);
+        Assert.True(start > 0, $"未找到方法 {signature}");
+
+        var from = start + signature.Length;
+        var end = code.Length;
+        foreach (var kw in new[] { "\n    public ", "\n    private ", "\n    internal ", "\n    protected " })
+        {
+            var i = code.IndexOf(kw, from, StringComparison.Ordinal);
+            if (i > 0 && i < end) end = i;
+        }
+        return code[start..end];
+    }
+
+    // ══════════════ P2-C · 日桶枚举(纯函数,确定性) ══════════════
+
+    /// <summary>① today:窗口本就零点对齐,1 个桶,行为与历史一致。</summary>
+    [Fact]
+    public void T01_DayBuckets_Today_SingleMidnightBucket()
+    {
+        var today = new DateTime(2026, 9, 22);
+        var plan = S8TrendBucketHelper.Resolve(today, today.AddDays(1));
+
+        Assert.Single(plan.Buckets);
+        Assert.Equal(today, plan.Buckets[0]);
+        Assert.False(plan.HasPartialEdge);
+        Assert.False(plan.IsTruncated);
+    }
+
+    /// <summary>② last_24h:跨两个自然日,必须给出 2 个桶(原实现只给 1 个)。</summary>
+    [Fact]
+    public void T02_DayBuckets_Last24h_SpansTwoCalendarDays()
+    {
+        var now = new DateTime(2026, 9, 22, 17, 25, 46);
+        var plan = S8TrendBucketHelper.Resolve(now.AddHours(-24), now);
+
+        Assert.Equal(2, plan.Buckets.Count);
+        Assert.Equal(new DateTime(2026, 9, 21), plan.Buckets[0]);
+        Assert.Equal(new DateTime(2026, 9, 22), plan.Buckets[1]);
+    }
+
+    /// <summary>③ 桶键必须零点对齐——否则与 <c>GroupBy(r =&gt; r.CreatedAt.Date)</c> 永远对不上。</summary>
+    [Fact]
+    public void T03_DayBuckets_KeysAreAlwaysMidnightAligned()
+    {
+        var now = new DateTime(2026, 9, 22, 17, 25, 46);
+        var plan = S8TrendBucketHelper.Resolve(now.AddHours(-24), now);
+
+        Assert.All(plan.Buckets, b => Assert.Equal(b.Date, b));
+    }
+
+    /// <summary>④ 桶必须覆盖整个查询窗口:Σ桶 == 窗口内行数。这是本次修复的核心不变量。</summary>
+    [Fact]
+    public void T04_DayBuckets_CoverEveryRowInWindow()
+    {
+        var now = new DateTime(2026, 9, 22, 17, 25, 46);
+        var plan = S8TrendBucketHelper.Resolve(now.AddHours(-24), now);
+
+        // 构造分布在窗口内不同时刻的行(含昨日下午、今日凌晨、今日上午)
+        var rows = new[]
+        {
+            new DateTime(2026, 9, 21, 18, 0, 0),
+            new DateTime(2026, 9, 21, 23, 59, 59),
+            new DateTime(2026, 9, 22, 0, 0, 1),
+            new DateTime(2026, 9, 22, 12, 6, 44),
+        };
+        var inWindow = rows.Where(r => r >= plan.QueryFrom && r < plan.QueryToExclusive).ToList();
+        var byDate = inWindow.GroupBy(r => r.Date).ToDictionary(g => g.Key, g => g.Count());
+
+        var bucketed = plan.Buckets.Sum(b => byDate.TryGetValue(b, out var c) ? c : 0);
+
+        Assert.Equal(4, inWindow.Count);
+        Assert.Equal(inWindow.Count, bucketed);
+    }
+
+    /// <summary>⑤ 原实现的反例固定下来:用非零点 <c>from</c> 当桶键,一行都命中不了。</summary>
+    [Fact]
+    public void T05_DayBuckets_RegressionGuard_NonMidnightKeyMatchesNothing()
+    {
+        var now = new DateTime(2026, 9, 22, 17, 25, 46);
+        var from = now.AddHours(-24);
+        var byDate = new[] { new DateTime(2026, 9, 21, 18, 0, 0), new DateTime(2026, 9, 22, 9, 0, 0) }
+            .GroupBy(r => r.Date).ToDictionary(g => g.Key, g => g.Count());
+
+        // 旧写法:d = from.AddDays(i),from 带 17:25:46 → TryGetValue 必 miss
+        Assert.False(byDate.ContainsKey(from));
+        // 新写法:桶键取自 Resolve,必命中
+        var plan = S8TrendBucketHelper.Resolve(from, now);
+        Assert.Contains(plan.Buckets, b => byDate.ContainsKey(b));
+    }
+
+    /// <summary>⑥ last_7d / this_week 等零点对齐窗口行为不变(回归保护)。</summary>
+    [Fact]
+    public void T06_DayBuckets_MidnightAlignedPeriods_Unchanged()
+    {
+        var today = new DateTime(2026, 9, 22);
+        var plan = S8TrendBucketHelper.Resolve(today.AddDays(-6), today.AddDays(1));
+
+        Assert.Equal(7, plan.Buckets.Count);
+        Assert.Equal(today.AddDays(-6), plan.Buckets[0]);
+        Assert.Equal(today, plan.Buckets[^1]);
+        Assert.False(plan.HasPartialEdge);
+        Assert.Equal(today.AddDays(-6), plan.QueryFrom);
+        Assert.Equal(today.AddDays(1), plan.QueryToExclusive);
+    }
+
+    /// <summary>⑦ 超上限(如 92 天的季度)时保留最近 N 天,且查询窗口同步收紧,Σ桶仍等于行数。</summary>
+    [Fact]
+    public void T07_DayBuckets_Truncation_KeepsLatestDaysAndNarrowsWindow()
+    {
+        var qStart = new DateTime(2026, 7, 1);
+        var qEnd = new DateTime(2026, 10, 1);
+        var plan = S8TrendBucketHelper.Resolve(qStart, qEnd);
+
+        Assert.Equal(S8TrendBucketHelper.MaxBuckets, plan.Buckets.Count);
+        Assert.True(plan.IsTruncated);
+        // 末桶必须是窗口最后一个自然日(而不是被砍掉尾巴)
+        Assert.Equal(new DateTime(2026, 9, 30), plan.Buckets[^1]);
+        // 查询窗口与桶范围一致,绝不查出桶外的行
+        Assert.Equal(plan.Buckets[0], plan.QueryFrom);
+        Assert.Equal(plan.Buckets[^1].AddDays(1), plan.QueryToExclusive);
+    }
+
+    /// <summary>⑧ 查询窗口只能收紧、不得放宽(防止桶对齐把窗口撑大而多统计行)。</summary>
+    [Fact]
+    public void T08_DayBuckets_QueryWindow_NeverWidensBeyondInput()
+    {
+        var now = new DateTime(2026, 9, 22, 17, 25, 46);
+        var from = now.AddHours(-24);
+        var plan = S8TrendBucketHelper.Resolve(from, now);
+
+        Assert.True(plan.QueryFrom >= from);
+        Assert.True(plan.QueryToExclusive <= now);
+    }
+
+    /// <summary>⑨ 边界不完整时必须自报(供上层抑制「今日 vs 昨日」环比)。</summary>
+    [Fact]
+    public void T09_DayBuckets_PartialEdge_IsReported()
+    {
+        var now = new DateTime(2026, 9, 22, 17, 25, 46);
+        Assert.True(S8TrendBucketHelper.Resolve(now.AddHours(-24), now).HasPartialEdge);
+    }
+
+    /// <summary>⑩ 空窗 / 倒挂窗不得抛异常,至少给 1 个桶。</summary>
+    [Fact]
+    public void T10_DayBuckets_DegenerateWindow_IsSafe()
+    {
+        var t = new DateTime(2026, 9, 22, 10, 0, 0);
+        var plan = S8TrendBucketHelper.Resolve(t, t);
+
+        Assert.Single(plan.Buckets);
+        Assert.Equal(t.Date, plan.Buckets[0]);
+    }
+
+    // ══════════════ P2-C · 三条趋势必须走同一枚举 ══════════════
+
+    /// <summary>⑪ 三条趋势方法都必须改用共享桶枚举。</summary>
+    [Theory]
+    [InlineData("GetDeliveryTrendAsync")]
+    [InlineData("GetProductionTrendAsync")]
+    [InlineData("GetSupplyTrendAsync")]
+    public void T11_TrendMethods_UseSharedBucketPlan(string method)
+    {
+        var body = MethodBody(MonitoringCode(), $"public async Task<Ado{(method.Contains("Delivery") ? "S8Delivery" : method.Contains("Production") ? "S8Production" : "S8Supply")}TrendDto> {method}");
+
+        Assert.Contains("ResolveTrendWindow", body);
+        Assert.Contains("plan.Buckets", body);
+    }
+
+    /// <summary>⑫ 不得再出现「用 from 自增当桶键」的旧写法。</summary>
+    [Theory]
+    [InlineData("GetDeliveryTrendAsync")]
+    [InlineData("GetProductionTrendAsync")]
+    [InlineData("GetSupplyTrendAsync")]
+    public void T12_TrendMethods_DropRawFromAddDaysBucketKey(string method)
+    {
+        var body = MethodBody(MonitoringCode(), $"public async Task<Ado{(method.Contains("Delivery") ? "S8Delivery" : method.Contains("Production") ? "S8Production" : "S8Supply")}TrendDto> {method}");
+
+        Assert.DoesNotContain("var d = from.AddDays(i)", body);
+        Assert.DoesNotContain("Math.Min(90, (int)(toExclusive - from).TotalDays)", body);
+    }
+
+    /// <summary>⑬ 三条趋势的摘要必须走同一个构建器(口径不得各写各的)。</summary>
+    [Theory]
+    [InlineData("GetDeliveryTrendAsync")]
+    [InlineData("GetProductionTrendAsync")]
+    [InlineData("GetSupplyTrendAsync")]
+    public void T13_TrendMethods_ShareSummaryBuilder(string method)
+    {
+        var body = MethodBody(MonitoringCode(), $"public async Task<Ado{(method.Contains("Delivery") ? "S8Delivery" : method.Contains("Production") ? "S8Production" : "S8Supply")}TrendDto> {method}");
+
+        Assert.Contains("BuildTrendSummary(plan,", body);
+        // 不得再各自手搓环比
+        Assert.DoesNotContain("changeRate", body);
+    }
+
+    /// <summary>⑬b 边界桶不完整时,日环比必须置 null(不得拿半天比全天)。</summary>
+    [Fact]
+    public void T13b_TrendSummary_SuppressesChangeRateOnPartialEdge()
+    {
+        var code = MonitoringCode();
+        var start = code.IndexOf("private static AdoS8DeliveryTrendSummaryDto BuildTrendSummary", StringComparison.Ordinal);
+        Assert.True(start > 0, "未找到 BuildTrendSummary");
+        var body = code[start..];
+
+        Assert.Contains("plan.HasPartialEdge", body);
+        Assert.Contains("PartialEdgeDays   = plan.HasPartialEdge", body);
+        Assert.Contains("WindowTruncated   = plan.IsTruncated", body);
+    }
+
+    /// <summary>⑭ 趋势出参必须自带统计窗口,口径可被前端与审计核对。</summary>
+    [Fact]
+    public void T14_TrendSummaryDto_ExposesWindow()
+    {
+        var t = typeof(AdoS8DeliveryTrendSummaryDto);
+
+        Assert.NotNull(t.GetProperty("WindowFrom"));
+        Assert.NotNull(t.GetProperty("WindowToExclusive"));
+        Assert.NotNull(t.GetProperty("WindowTruncated"));
+        Assert.NotNull(t.GetProperty("PartialEdgeDays"));
+    }
+
+    // ══════════════ P2-D · 每百订单分母 ══════════════
+
+    /// <summary>⑮ 无分母 Authority ⇒ Frequency 必须可空,用 null 表达 NO_DATA。</summary>
+    [Fact]
+    public void T15_Frequency_IsNullable_ForNoData()
+    {
+        var p = typeof(AdoS8ModuleOrderSummary).GetProperty("Frequency");
+        Assert.NotNull(p);
+        Assert.Equal(typeof(double?), p!.PropertyType);
+    }
+
+    /// <summary>⑯ 绝不能再把「异常绝对数」当成「每百订单异常数」发出去。</summary>
+    [Fact]
+    public void T16_Frequency_IsNotAssignedRawTotal()
+    {
+        var code = MonitoringCode();
+
+        Assert.DoesNotContain("Frequency       = total,", code);
+        Assert.DoesNotContain("Frequency = total,", code);
+        Assert.Contains("Frequency       = null,", code);
+    }
+
+    /// <summary>⑰ 不得为了让卡片活着而自造分母(拿销售订单数去除以采购异常数等)。</summary>
+    [Fact]
+    public void T17_Frequency_DoesNotInventDenominator()
+    {
+        var body = MethodBody(MonitoringCode(), "public async Task<AdoS8OrderGridDto> GetOrderGridAsync");
+
+        Assert.DoesNotContain("mdp_std_so", body);
+        Assert.DoesNotContain("* 100.0 / orderCount", body);
+    }
+
+    // ══════════════ P2-B · 今日预警数 ══════════════
+
+    /// <summary>⑱ 必须存在一个真正做计数的汇总端点(列表端点的 LIMIT 不能当计数)。</summary>
+    [Fact]
+    public void T18_AlertSummary_EndpointExists()
+    {
+        Assert.Contains("[HttpGet(\"s8-alert-summary\")]", KanbanCode());
+    }
+
+    /// <summary>⑲ 汇总端点必须 COUNT,且不得带 LIMIT。</summary>
+    [Fact]
+    public void T19_AlertSummary_CountsWithoutLimit()
+    {
+        var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
+
+        Assert.Contains("COUNT(*)", body);
+        Assert.DoesNotContain("LIMIT", body);
+    }
+
+    /// <summary>⑳ 「今日」必须是显式的服务端自然日窗口,不是「最近 N 条」的副作用。</summary>
+    [Fact]
+    public void T20_AlertSummary_HasExplicitTodayWindow()
+    {
+        var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
+
+        Assert.Contains("DateTime.Today", body);
+        Assert.Contains("windowStart", body);
+        Assert.Contains("windowEnd", body);
+        Assert.Contains("isToday", body);
+    }
+
+    /// <summary>㉑ 汇总端点必须租户 + 工厂作用域,且窗口是半开区间。</summary>
+    [Fact]
+    public void T21_AlertSummary_IsTenantScoped()
+    {
+        var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
+
+        Assert.Contains("AidopTenantHelper.GetTenantId(HttpContext)", body);
+        Assert.Contains("tenant_id=@tenantId", body);
+        Assert.Contains("factory_id=@factoryId", body);
+        Assert.Contains("alert_time >= @ws", body);
+        Assert.Contains("alert_time < @we", body);
+    }
+
+    /// <summary>㉒ 汇总端点必须复用同一套维度过滤,不得另起一套口径。</summary>
+    [Fact]
+    public void T22_AlertSummary_ReusesSharedDimensionFilter()
+    {
+        var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
+
+        Assert.Contains("BuildS8AlertTextFilterSql()", body);
+        Assert.Contains("SmartOpsDashboardFilter.FromQuery", body);
+    }
+
+    /// <summary>㉓ 既有 s8-alerts 列表端点不得被本批改动(home.vue 仍依赖它返回最近 6 条)。</summary>
+    [Fact]
+    public void T23_LegacyAlertsEndpoint_StillReturnsRecentList()
+    {
+        var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8Alerts");
+
+        Assert.Contains("LIMIT 6", body);
+        Assert.Contains("ORDER BY alert_time DESC", body);
+    }
+
+    /// <summary>㉔ 汇总端点必须同时给出严重级计数,避免前端再拿 6 条列表推严重数。</summary>
+    [Fact]
+    public void T24_AlertSummary_ReturnsCriticalCount()
+    {
+        var body = MethodBody(KanbanCode(), "public async Task<IActionResult> GetS8AlertSummary");
+
+        Assert.Contains("critical", body);
+        Assert.Contains("level_code", body);
+    }
+}

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

@@ -172,6 +172,76 @@ LIMIT 6
         }));
     }
 
+    /// <summary>
+    /// S8-DASHBOARD-P2-SEMANTICS-1(P2-B):S8 看板预警计数。
+    ///
+    /// <para><b>为什么要单开一个端点</b>:<c>s8-alerts</c> 是「最近 6 条」列表,带 <c>LIMIT 6</c>
+    /// 且 SQL 里<b>没有任何今日谓词</b>。前端拿它的 <c>length</c> 当「今日预警数」,于是
+    /// ① 真实今日 0 条、表里存着几个月前的旧行时,卡片照样显示非 0;
+    /// ② 今日真有 7 条以上时,卡片永远封顶在 6。两个方向都错。</para>
+    ///
+    /// <para><b>窗口口径</b>:未传日期 ⇒ <c>[今日 00:00, 明日 00:00)</c>(服务器自然日,
+    /// <c>isToday=true</c>);传了日期 ⇒ <c>[dateStart, dateEnd + 1 天)</c>,<c>isToday=false</c>,
+    /// 由前端把标题从「今日预警数」切成「区间预警数」。一律半开区间,与 <c>s8-alerts</c> 同构。</para>
+    ///
+    /// <para>维度过滤复用 <see cref="BuildS8AlertTextFilterSql"/>,不另起口径。</para>
+    /// </summary>
+    [HttpGet("s8-alert-summary")]
+    public async Task<IActionResult> GetS8AlertSummary(
+        [FromQuery] long factoryId = 1,
+        [FromQuery] string? dateStart = null,
+        [FromQuery] string? dateEnd = null,
+        [FromQuery] string? product = null,
+        [FromQuery] string? orderNo = null,
+        [FromQuery] string? productionLine = null)
+    {
+        var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
+        var filter = SmartOps.SmartOpsDashboardFilter.FromQuery(
+            dateStart, dateEnd, product, orderNo, productionLine, null, null, null, null, null, null, null, null);
+
+        var isToday = !filter.DateStart.HasValue && !filter.DateEnd.HasValue;
+        var windowStart = (filter.DateStart ?? filter.DateEnd ?? DateTime.Today).Date;
+        var windowEnd = (filter.DateEnd ?? filter.DateStart ?? DateTime.Today).Date.AddDays(1);
+        if (windowEnd <= windowStart) windowEnd = windowStart.AddDays(1);
+
+        var dimSql = BuildS8AlertTextFilterSql();
+        var sql = $"""
+SELECT COUNT(*) AS Total,
+       IFNULL(SUM(CASE WHEN level_code = 'critical' THEN 1 ELSE 0 END), 0) AS Critical
+FROM ado_s8_alert_record
+WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0
+  AND alert_time >= @ws
+  AND alert_time < @we
+  {dimSql}
+""";
+        var rows = await _db.Ado.SqlQueryAsync<S8AlertSummaryRow>(sql, new
+        {
+            tenantId,
+            factoryId,
+            ws = windowStart,
+            we = windowEnd,
+            product = filter.Product,
+            orderNo = filter.OrderNo,
+            productionLine = filter.ProductionLine
+        });
+
+        var row = rows.FirstOrDefault();
+        return Ok(new
+        {
+            windowStart = windowStart.ToString("yyyy-MM-dd"),
+            windowEnd = windowEnd.AddDays(-1).ToString("yyyy-MM-dd"),
+            isToday,
+            total = row?.Total ?? 0,
+            critical = row?.Critical ?? 0
+        });
+    }
+
+    private sealed class S8AlertSummaryRow
+    {
+        public int Total { get; set; }
+        public int Critical { get; set; }
+    }
+
     [HttpGet("s8-home-trend")]
     public async Task<IActionResult> GetS8HomeTrend(
         [FromQuery] long factoryId = 1,

+ 26 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/S8/AdoS8Dtos.cs

@@ -304,8 +304,18 @@ public class AdoS8ModuleOrderSummary
     /// <summary>红色订单数(SERIOUS 严重,或黄色超时)。</summary>
     public int Red { get; set; }
     public int Total { get; set; }
-    /// <summary>异常发生频率(每百订单异常数)</summary>
-    public double Frequency { get; set; }
+    /// <summary>
+    /// 异常发生频率(每百订单异常数)—— <b>当前恒为 null(NO_DATA)</b>。
+    ///
+    /// <para>S8-DASHBOARD-P2-SEMANTICS-1:本字段历史上被赋成 <c>Total</c>(异常绝对数),
+    /// 与「每百订单」的注释口径不符。要算这个比率需要「该阶段的订单分母」,而 S8 没有任何
+    /// Authority 能提供它:异常对象按 stage 各不相同(S4 的异常对象是采购单,不是销售订单),
+    /// <c>mdp_std_so</c> 只是 S1 的销售订单行,拿它当所有阶段的公共分母属于自造口径。</para>
+    ///
+    /// <para>按「无 Authority 不得发明公式、NO_DATA ≠ 0」的既定纪律,此处返回 null,
+    /// 由前端渲染为占位符。待业务确认各阶段订单分母口径后再接入。</para>
+    /// </summary>
+    public double? Frequency { get; set; }
     /// <summary>平均处理时效(小时)</summary>
     public double AvgProcessHours { get; set; }
     /// <summary>异常关闭百分比</summary>
@@ -412,8 +422,21 @@ public class AdoS8DeliveryTrendSummaryDto
     public string? PeakDate { get; set; }
     public double AvgValue { get; set; }
     public int TodayValue { get; set; }
-    /// <summary>今日相对昨日变化率(%);昨日为 0 时返回 null。</summary>
+    /// <summary>
+    /// 今日相对昨日变化率(%);昨日为 0、或首尾桶不是完整自然日(<see cref="PartialEdgeDays"/>)时返回 null。
+    /// </summary>
     public double? TodayChangeRate { get; set; }
+
+    // ── S8-DASHBOARD-P2-SEMANTICS-1:统计窗口随出参一起给出,口径可被前端与独立 SQL 核对 ──
+
+    /// <summary>实际统计窗口起点(含),ISO 8601。</summary>
+    public string? WindowFrom { get; set; }
+    /// <summary>实际统计窗口终点(不含),ISO 8601。</summary>
+    public string? WindowToExclusive { get; set; }
+    /// <summary>原时间范围跨越的自然日超过 90 天上限,已保留最近 90 天。</summary>
+    public bool WindowTruncated { get; set; }
+    /// <summary>首桶或末桶只覆盖该自然日的一部分(如「过去 24h」),此时日环比不可比。</summary>
+    public bool PartialEdgeDays { get; set; }
 }
 
 /// <summary>S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:Production 页近 N 日生产异常趋势出参。</summary>

+ 72 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8TrendBucketHelper.cs

@@ -0,0 +1,72 @@
+namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+
+/// <summary>
+/// S8-DASHBOARD-P2-SEMANTICS-1:日粒度趋势的「桶枚举」。
+///
+/// <para><b>要解决的问题</b>:趋势按 <c>CreatedAt.Date</c>(零点)分组,而
+/// <see cref="S8PeriodHelper"/> 的 <c>last_24h</c> 返回的是 <c>[Now-24h, Now)</c>
+/// 这种<b>非零点</b>窗口。原实现直接拿 <c>from.AddDays(i)</c> 当桶键去查分组字典,
+/// 键上带着 <c>17:25:46</c>,与零点键永远对不上 ⇒ 窗口内明明有行,峰值 / 均值 / 今日
+/// 却全是 0。</para>
+///
+/// <para><b>本类确立的口径</b>:桶 = 查询窗口所<b>跨越的自然日</b>。
+/// 窗口非零点对齐时,首尾桶承载的是「该自然日落在窗口内的那一段」,
+/// 因此 <c>Σ桶 == 窗口内行数</c> 恒成立——这是趋势图与任何独立 SQL 核对的唯一不变量。</para>
+///
+/// <para><b>窗口只收紧、不放宽</b>:桶对齐绝不把查询范围撑大,否则会把窗口外的行算进来。
+/// 桶数超过上限时保留<b>最近</b>的 N 天并同步收紧查询窗口(而不是砍掉尾巴、把已查出的行
+/// 静默丢弃),并通过 <see cref="DayBucketPlan.IsTruncated"/> 自报。</para>
+/// </summary>
+public static class S8TrendBucketHelper
+{
+    /// <summary>日桶上限。与既有 period 分支一致(季度最长 92 天,超出部分保留最近 90 天)。</summary>
+    public const int MaxBuckets = 90;
+
+    /// <summary>
+    /// 桶枚举结果。
+    /// </summary>
+    /// <param name="QueryFrom">实际应下发到 SQL 的窗口起点(含)。</param>
+    /// <param name="QueryToExclusive">实际应下发到 SQL 的窗口终点(不含)。</param>
+    /// <param name="Buckets">零点对齐的桶键,升序,与分组字典的 <c>DateTime.Date</c> 键同构。</param>
+    /// <param name="HasPartialEdge">首桶或末桶只承载了该自然日的一部分(如 last_24h)。
+    /// 为 true 时「今日 vs 昨日」环比不可比,调用方应置 null。</param>
+    /// <param name="IsTruncated">原窗口跨越的自然日超过上限,已保留最近 N 天。</param>
+    public sealed record DayBucketPlan(
+        DateTime QueryFrom,
+        DateTime QueryToExclusive,
+        IReadOnlyList<DateTime> Buckets,
+        bool HasPartialEdge,
+        bool IsTruncated);
+
+    /// <summary>
+    /// 把任意半开窗口 <c>[from, toExclusive)</c> 映射为日桶计划。
+    /// </summary>
+    public static DayBucketPlan Resolve(DateTime from, DateTime toExclusive, int maxBuckets = MaxBuckets)
+    {
+        if (maxBuckets < 1) maxBuckets = 1;
+        // 退化窗口(倒挂或零长)兜底为「from 所在自然日」的 1 个桶,不抛异常。
+        if (toExclusive <= from) toExclusive = from.AddTicks(1);
+
+        var firstDay = from.Date;
+        var lastDay = toExclusive.AddTicks(-1).Date;   // 半开区间右端所属的最后一个自然日
+
+        var span = (int)(lastDay - firstDay).TotalDays + 1;
+        var truncated = span > maxBuckets;
+        if (truncated)
+        {
+            firstDay = lastDay.AddDays(-(maxBuckets - 1));
+            span = maxBuckets;
+        }
+
+        var bucketEnd = lastDay.AddDays(1);
+        var queryFrom = from > firstDay ? from : firstDay;
+        var queryTo = toExclusive < bucketEnd ? toExclusive : bucketEnd;
+
+        var buckets = new List<DateTime>(span);
+        for (var i = 0; i < span; i++) buckets.Add(firstDay.AddDays(i));
+
+        var hasPartialEdge = queryFrom != firstDay || queryTo != bucketEnd;
+
+        return new DayBucketPlan(queryFrom, queryTo, buckets, hasPartialEdge, truncated);
+    }
+}

+ 85 - 102
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8MonitoringService.cs

@@ -83,7 +83,12 @@ public class S8MonitoringService : ITransient
                 Yellow          = yellow,
                 Red             = red,
                 Total           = total,
-                Frequency       = total,
+                // S8-DASHBOARD-P2-SEMANTICS-1(P2-D):原为 `Frequency = total`,
+                // 把「异常绝对数」冒充成 DTO 注释所写的「每百订单异常数」。
+                // 每百订单需要「该阶段的订单分母」,而 S8 没有任何 Authority 能提供:
+                // 各 stage 的异常对象类型不同(S4 的对象是采购单),mdp_std_so 只是 S1 的
+                // 销售订单行,充当全阶段公共分母属于自造口径。按 NO_DATA ≠ 0 返回 null。
+                Frequency       = null,
                 AvgProcessHours = AvgHours(rows.Select(r => (r.CreatedAt, r.ClosedAt))),
                 CloseRate       = total == 0 ? 0 : Math.Round(closed * 100.0 / total, 1),
             };
@@ -390,22 +395,10 @@ public class S8MonitoringService : ITransient
     /// </summary>
     public async Task<AdoS8DeliveryTrendDto> GetDeliveryTrendAsync(long tenantId, int days = 7, string? period = null)
     {
-        DateTime from, toExclusive;
-        int effectiveDays;
-        var (periodFrom, periodTo) = S8PeriodHelper.Resolve(period);
-        if (periodFrom.HasValue && periodTo.HasValue)
-        {
-            from = periodFrom.Value;
-            toExclusive = periodTo.Value;
-            effectiveDays = Math.Max(1, Math.Min(90, (int)(toExclusive - from).TotalDays));
-        }
-        else
-        {
-            effectiveDays = Math.Clamp(days, 1, 30);
-            var today = DateTime.Today;
-            from = today.AddDays(-(effectiveDays - 1));
-            toExclusive = today.AddDays(1);
-        }
+        var plan = ResolveTrendWindow(period, days);
+        var from = plan.QueryFrom;
+        var toExclusive = plan.QueryToExclusive;
+        var effectiveDays = plan.Buckets.Count;
 
         var deliveryModules = new[] { "S1", "S7" };
         var deliveryTypes = new[] { "ORDER_DUE_DATE_DELAY", "PRODUCT_DESIGN_DELAY", "DELIVERY_DELAY_WARNING", "SHIPMENT_ABNORMAL" };
@@ -423,9 +416,8 @@ public class S8MonitoringService : ITransient
             .ToDictionary(g => g.Key, g => g.ToList());
 
         var dayList = new List<AdoS8DeliveryTrendDayDto>(effectiveDays);
-        for (var i = 0; i < effectiveDays; i++)
+        foreach (var d in plan.Buckets)
         {
-            var d = from.AddDays(i);
             var bucket = byDate.TryGetValue(d, out var list) ? list : new();
             var orderDueDateDelay    = bucket.Count(r => r.ExceptionTypeCode == "ORDER_DUE_DATE_DELAY");
             var productDesignDelay   = bucket.Count(r => r.ExceptionTypeCode == "PRODUCT_DESIGN_DELAY");
@@ -443,24 +435,11 @@ public class S8MonitoringService : ITransient
             });
         }
 
-        var totalSum = dayList.Sum(d => d.Total);
-        var peak = dayList.OrderByDescending(d => d.Total).FirstOrDefault();
-        var todayDay = dayList.Last();
-        var yesterday = dayList.Count >= 2 ? dayList[^2] : null;
-        double? changeRate = (yesterday is null || yesterday.Total == 0)
-            ? (double?)null
-            : Math.Round((todayDay.Total - yesterday.Total) * 100.0 / yesterday.Total, 1);
-
-        var summary = new AdoS8DeliveryTrendSummaryDto
+        return new AdoS8DeliveryTrendDto
         {
-            PeakValue       = peak?.Total ?? 0,
-            PeakDate        = (peak is null || peak.Total == 0) ? null : peak.RawDate,
-            AvgValue        = Math.Round(totalSum / (double)effectiveDays, 2),
-            TodayValue      = todayDay.Total,
-            TodayChangeRate = changeRate,
+            Days = dayList,
+            Summary = BuildTrendSummary(plan, dayList.Select(d => (d.Total, d.RawDate)).ToList()),
         };
-
-        return new AdoS8DeliveryTrendDto { Days = dayList, Summary = summary };
     }
 
     /// <summary>
@@ -472,22 +451,10 @@ public class S8MonitoringService : ITransient
     /// </summary>
     public async Task<AdoS8ProductionTrendDto> GetProductionTrendAsync(long tenantId, int days = 7, string? period = null)
     {
-        DateTime from, toExclusive;
-        int effectiveDays;
-        var (periodFrom, periodTo) = S8PeriodHelper.Resolve(period);
-        if (periodFrom.HasValue && periodTo.HasValue)
-        {
-            from = periodFrom.Value;
-            toExclusive = periodTo.Value;
-            effectiveDays = Math.Max(1, Math.Min(90, (int)(toExclusive - from).TotalDays));
-        }
-        else
-        {
-            effectiveDays = Math.Clamp(days, 1, 30);
-            var today = DateTime.Today;
-            from = today.AddDays(-(effectiveDays - 1));
-            toExclusive = today.AddDays(1);
-        }
+        var plan = ResolveTrendWindow(period, days);
+        var from = plan.QueryFrom;
+        var toExclusive = plan.QueryToExclusive;
+        var effectiveDays = plan.Buckets.Count;
 
         var prodModules = new[] { "S2", "S6" };
         var prodTypes = new[]
@@ -507,9 +474,8 @@ public class S8MonitoringService : ITransient
         var byDate = rows.GroupBy(r => r.CreatedAt.Date).ToDictionary(g => g.Key, g => g.ToList());
 
         var dayList = new List<AdoS8ProductionTrendDayDto>(effectiveDays);
-        for (var i = 0; i < effectiveDays; i++)
+        foreach (var d in plan.Buckets)
         {
-            var d = from.AddDays(i);
             var bucket = byDate.TryGetValue(d, out var list) ? list : new();
             var ef  = bucket.Count(r => r.ExceptionTypeCode == "EQUIP_FAULT");
             var mf  = bucket.Count(r => r.ExceptionTypeCode == "MFG_MATERIAL_ABNORMAL");
@@ -529,24 +495,11 @@ public class S8MonitoringService : ITransient
             });
         }
 
-        var totalSum = dayList.Sum(d => d.Total);
-        var peak = dayList.OrderByDescending(d => d.Total).FirstOrDefault();
-        var todayDay = dayList.Last();
-        var yesterday = dayList.Count >= 2 ? dayList[^2] : null;
-        double? changeRate = (yesterday is null || yesterday.Total == 0)
-            ? (double?)null
-            : Math.Round((todayDay.Total - yesterday.Total) * 100.0 / yesterday.Total, 1);
-
-        var summary = new AdoS8DeliveryTrendSummaryDto
+        return new AdoS8ProductionTrendDto
         {
-            PeakValue       = peak?.Total ?? 0,
-            PeakDate        = (peak is null || peak.Total == 0) ? null : peak.RawDate,
-            AvgValue        = Math.Round(totalSum / (double)effectiveDays, 2),
-            TodayValue      = todayDay.Total,
-            TodayChangeRate = changeRate,
+            Days = dayList,
+            Summary = BuildTrendSummary(plan, dayList.Select(d => (d.Total, d.RawDate)).ToList()),
         };
-
-        return new AdoS8ProductionTrendDto { Days = dayList, Summary = summary };
     }
 
     /// <summary>
@@ -561,22 +514,10 @@ public class S8MonitoringService : ITransient
     /// </summary>
     public async Task<AdoS8SupplyTrendDto> GetSupplyTrendAsync(long tenantId, int days = 7, string? period = null)
     {
-        DateTime from, toExclusive;
-        int effectiveDays;
-        var (periodFrom, periodTo) = S8PeriodHelper.Resolve(period);
-        if (periodFrom.HasValue && periodTo.HasValue)
-        {
-            from = periodFrom.Value;
-            toExclusive = periodTo.Value;
-            effectiveDays = Math.Max(1, Math.Min(90, (int)(toExclusive - from).TotalDays));
-        }
-        else
-        {
-            effectiveDays = Math.Clamp(days, 1, 30);
-            var today = DateTime.Today;
-            from = today.AddDays(-(effectiveDays - 1));
-            toExclusive = today.AddDays(1);
-        }
+        var plan = ResolveTrendWindow(period, days);
+        var from = plan.QueryFrom;
+        var toExclusive = plan.QueryToExclusive;
+        var effectiveDays = plan.Buckets.Count;
 
         var supplyModules = new[] { "S3", "S4", "S5" };
         var supplyTypes = new[]
@@ -599,9 +540,8 @@ public class S8MonitoringService : ITransient
         var byDate = rows.GroupBy(r => r.CreatedAt.Date).ToDictionary(g => g.Key, g => g.ToList());
 
         var dayList = new List<AdoS8SupplyTrendDayDto>(effectiveDays);
-        for (var i = 0; i < effectiveDays; i++)
+        foreach (var d in plan.Buckets)
         {
-            var d = from.AddDays(i);
             var bucket = byDate.TryGetValue(d, out var list) ? list : new();
             var sEta  = bucket.Count(r => r.ExceptionTypeCode == "SUPPLIER_ETA_ISSUE");
             var sShip = bucket.Count(r => r.ExceptionTypeCode == "SUPPLIER_SHIP_ISSUE");
@@ -635,24 +575,67 @@ public class S8MonitoringService : ITransient
             });
         }
 
-        var totalSum = dayList.Sum(d => d.Total);
-        var peak = dayList.OrderByDescending(d => d.Total).FirstOrDefault();
-        var todayDay = dayList.Last();
-        var yesterday = dayList.Count >= 2 ? dayList[^2] : null;
-        double? changeRate = (yesterday is null || yesterday.Total == 0)
-            ? (double?)null
-            : Math.Round((todayDay.Total - yesterday.Total) * 100.0 / yesterday.Total, 1);
-
-        var summary = new AdoS8DeliveryTrendSummaryDto
+        return new AdoS8SupplyTrendDto
         {
-            PeakValue       = peak?.Total ?? 0,
-            PeakDate        = (peak is null || peak.Total == 0) ? null : peak.RawDate,
-            AvgValue        = Math.Round(totalSum / (double)effectiveDays, 2),
-            TodayValue      = todayDay.Total,
-            TodayChangeRate = changeRate,
+            Days = dayList,
+            Summary = BuildTrendSummary(plan, dayList.Select(d => (d.Total, d.RawDate)).ToList()),
         };
+    }
+
+    // ══════════════ S8-DASHBOARD-P2-SEMANTICS-1(P2-C):趋势时间语义 ══════════════
+
+    /// <summary>
+    /// 解析日粒度趋势的统计窗口与日桶。
+    ///
+    /// <para>显式 period 优先;未给 period 时回落「最近 <paramref name="days"/> 天」
+    /// (零点对齐,上限 30 天,与历史行为一致)。</para>
+    ///
+    /// <para>关键:<c>last_24h</c> 这类<b>非零点</b>窗口过去会被直接当成桶键,
+    /// 与按 <c>CreatedAt.Date</c> 分组的字典永远对不上 ⇒ 趋势恒 0。桶枚举统一交给
+    /// <see cref="S8TrendBucketHelper"/>,保证 Σ桶 == 窗口内行数。</para>
+    /// </summary>
+    private static S8TrendBucketHelper.DayBucketPlan ResolveTrendWindow(string? period, int days)
+    {
+        var (periodFrom, periodTo) = S8PeriodHelper.Resolve(period);
+        if (periodFrom.HasValue && periodTo.HasValue)
+            return S8TrendBucketHelper.Resolve(periodFrom.Value, periodTo.Value);
+
+        var effectiveDays = Math.Clamp(days, 1, 30);
+        var today = DateTime.Today;
+        return S8TrendBucketHelper.Resolve(today.AddDays(-(effectiveDays - 1)), today.AddDays(1), 30);
+    }
 
-        return new AdoS8SupplyTrendDto { Days = dayList, Summary = summary };
+    /// <summary>
+    /// 由日桶汇总出趋势摘要,并把统计窗口一并回传,供前端与独立 SQL 核对口径。
+    ///
+    /// <para>首尾桶不是完整自然日时(<c>HasPartialEdge</c>,如「过去 24h」),
+    /// 「今日 vs 昨日」是半天比全天,<b>不可比</b> ⇒ 环比置 null,不给误导性的百分比。</para>
+    /// </summary>
+    private static AdoS8DeliveryTrendSummaryDto BuildTrendSummary(
+        S8TrendBucketHelper.DayBucketPlan plan,
+        IReadOnlyList<(int Total, string RawDate)> dayList)
+    {
+        var totalSum = dayList.Sum(d => d.Total);
+        var peak = dayList.Count == 0 ? default : dayList.OrderByDescending(d => d.Total).First();
+        var todayDay = dayList.Count == 0 ? default : dayList[^1];
+        var yesterday = dayList.Count >= 2 ? dayList[^2] : default((int, string)?);
+
+        double? changeRate = (plan.HasPartialEdge || yesterday is null || yesterday.Value.Item1 == 0)
+            ? null
+            : Math.Round((todayDay.Total - yesterday.Value.Item1) * 100.0 / yesterday.Value.Item1, 1);
+
+        return new AdoS8DeliveryTrendSummaryDto
+        {
+            PeakValue         = peak.Total,
+            PeakDate          = peak.Total == 0 ? null : peak.RawDate,
+            AvgValue          = dayList.Count == 0 ? 0 : Math.Round(totalSum / (double)dayList.Count, 2),
+            TodayValue        = todayDay.Total,
+            TodayChangeRate   = changeRate,
+            WindowFrom        = plan.QueryFrom.ToString("yyyy-MM-ddTHH:mm:ss"),
+            WindowToExclusive = plan.QueryToExclusive.ToString("yyyy-MM-ddTHH:mm:ss"),
+            WindowTruncated   = plan.IsTruncated,
+            PartialEdgeDays   = plan.HasPartialEdge,
+        };
     }
 
     /// <summary>