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

feat(s8): replace prod/supply analysis with trends and label delivery window

- Production / Supply 页 analysis 区改用近 7 日趋势折线图,与右侧近 24h 类型卡区分时间窗口
- 新增 GET /api/aidop/s8/monitoring/production-trend、/supply-trend,按 module_code + exception_type_code + created_at 聚合
- Production:S2/S6 模块 × 设备/物料/质量 3 类
- Supply:S3/S4/S5 模块 × 7 类供应异常,前端按近 7 日总量选 Top5 非零类型展示
- Production / Supply 右侧类型卡标题增加近 24h 徽标,与 Delivery 口径统一
YY968XX 4 недель назад
Родитель
Сommit
7dc3efd09a

+ 43 - 0
Web/src/views/aidop/s8/api/s8MonitoringApi.ts

@@ -89,6 +89,39 @@ export interface S8DeliveryTrendData {
 	summary: S8DeliveryTrendSummary;
 }
 
+// S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1
+export interface S8ProductionTrendDay {
+	date: string;
+	rawDate: string;
+	equipmentFault: number;
+	materialFault: number;
+	qualityFault: number;
+	total: number;
+}
+
+export interface S8ProductionTrendData {
+	days: S8ProductionTrendDay[];
+	summary: S8DeliveryTrendSummary;
+}
+
+export interface S8SupplyTrendDay {
+	date: string;
+	rawDate: string;
+	supplierEtaIssue: number;
+	supplierShipIssue: number;
+	warehouseReceiptAbnormal: number;
+	iqcIssue: number;
+	warehousePutawayIssue: number;
+	workOrderKittingAbnormal: number;
+	workOrderIssueAbnormal: number;
+	total: number;
+}
+
+export interface S8SupplyTrendData {
+	days: S8SupplyTrendDay[];
+	summary: S8DeliveryTrendSummary;
+}
+
 export const s8MonitoringApi = {
 	summary: (params: S8MonitoringSummaryQuery = {}) =>
 		service
@@ -104,4 +137,14 @@ export const s8MonitoringApi = {
 		service
 			.get<S8DeliveryTrendData>('/api/aidop/s8/monitoring/delivery-trend', { params })
 			.then(unwrap),
+
+	productionTrend: (params: { tenantId?: number; factoryId?: number; days?: number } = {}) =>
+		service
+			.get<S8ProductionTrendData>('/api/aidop/s8/monitoring/production-trend', { params })
+			.then(unwrap),
+
+	supplyTrend: (params: { tenantId?: number; factoryId?: number; days?: number } = {}) =>
+		service
+			.get<S8SupplyTrendData>('/api/aidop/s8/monitoring/supply-trend', { params })
+			.then(unwrap),
 };

+ 42 - 51
Web/src/views/aidop/s8/monitoring/S8MonitoringProductionPage.vue

@@ -33,6 +33,8 @@
 					<div class="section-title">
 						<div class="section-title__bar" />
 						<h2 class="section-title__text">生产异常类型</h2>
+						<!-- S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:与下方「近7日生产异常趋势」做时间窗口区分。 -->
+						<span class="anomaly-monitor__time-badge">近24h</span>
 					</div>
 
 					<div class="anomaly-monitor__type-list">
@@ -68,25 +70,9 @@
 
 			<template #analysis>
 				<section class="anomaly-monitor__analysis-panel glass-panel">
-					<div class="anomaly-monitor__analysis-head">
-						<div class="section-title">
-							<div class="section-title__bar section-title__bar--accent" />
-							<h2 class="section-title__text">生产异常多维分析</h2>
-						</div>
-					</div>
-
-					<div class="anomaly-monitor__analysis-grid">
-						<S8MonitoringCategoryGrid
-							:cards="categoryCards"
-							:layout="draftLayout.analysis"
-							:editable="editMode"
-							:row-height="56"
-							:gap="12"
-							@update:layout="onAnalysisLayoutUpdate"
-						/>
-					</div>
-
-					<!-- S8-MONITORING-TOP-STATUS-STRIP-REMOVE-1:移除底部全局状态条。 -->
+					<!-- S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:原「生产异常多维分析」三连卡与右侧「生产异常类型」重复,
+					     替换为近 7 日生产异常趋势图(接口 /monitoring/production-trend)。 -->
+					<S8ProductionTrendChart :data="productionTrendData" />
 				</section>
 			</template>
 		</S8MonitoringResizableShell>
@@ -98,7 +84,7 @@
 			:stage-keys="stageKeys"
 			v-model:selected-cat-key="categoryConfigState.selectedKey"
 			v-model:cat-card="categoryConfigState.items[categoryConfigState.selectedKey]"
-			:category-keys="categoryKeys"
+			:category-keys="[]"
 			@reset="resetStageConfig"
 			@reset-cat="resetCategoryConfig"
 		/>
@@ -107,16 +93,16 @@
 
 <script setup lang="ts" name="aidopS8MonitoringProduction">
 import { computed, onMounted, reactive, shallowRef, type CSSProperties, type Component } from 'vue';
-import { TrendCharts, Box, Tools } from '@element-plus/icons-vue';
+import { TrendCharts, Box } from '@element-plus/icons-vue';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import { deepClone, createPageLayout, LAYOUT_VERSION, type S8LayoutSchema, type GridItem } from './useS8Layout';
 import { s8ProductionMonitoringApi, type ProductionAnomalyType } from '../api/s8ProductionMonitoringApi';
-import type { S8MonitoringSummary, S8ModuleOrderSummary } from '../api/s8MonitoringApi';
+import { s8MonitoringApi, type S8MonitoringSummary, type S8ModuleOrderSummary, type S8ProductionTrendData } from '../api/s8MonitoringApi';
 import S8MonitoringResizableShell from './components/S8MonitoringResizableShell.vue';
 import S8MonitoringModulesGrid from './components/S8MonitoringModulesGrid.vue';
-import S8MonitoringCategoryGrid, { type CategoryGridCardData } from './components/S8MonitoringCategoryGrid.vue';
 import S8MonitoringEditToolbar from './components/S8MonitoringEditToolbar.vue';
 import S8MonitoringStageConfigDrawer from './components/S8MonitoringStageConfigDrawer.vue';
+import S8ProductionTrendChart from './components/S8ProductionTrendChart.vue';
 import { useS8UnsavedLayoutEditGuard } from './useS8UnsavedLayoutEditGuard';
 import { useS8StageConfig, buildModuleTriColorStats, DEFAULT_NORMAL_WORK_ORDER_COUNT } from './useS8StageConfig';
 import { useS8CategoryConfig } from './useS8CategoryConfig';
@@ -144,7 +130,9 @@ const { layout: persistedLayout, save, resetToDefault, restoreDemo } = useProduc
 // ─── 卡片配置 ──────────────────────────────────────────────────────────────
 const configDrawerVisible = shallowRef(false);
 const { stageConfigState, initializeFromCards, applyConfig, reset: resetStageConfigState } = useS8StageConfig();
-const { categoryConfigState, initializeFromCategories, applyConfig: applyCategoryConfig, reset: resetCategoryConfigState } = useS8CategoryConfig();
+// S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:保留 categoryConfigState 与 reset 给抽屉状态绑定,
+// 数据绑定 categoryCards 已废弃;initializeFromCategories / applyCategoryConfig 不再使用。
+const { categoryConfigState, reset: resetCategoryConfigState } = useS8CategoryConfig();
 
 // ─── 布局状态 ──────────────────────────────────────────────────────────────
 const editMode = shallowRef(false);
@@ -224,14 +212,9 @@ const ANOMALY_TYPES_FALLBACK: ReadonlyArray<{ key: string; label: string }> = [
 	{ key: 'quality-fault',   label: '质量异常' },
 ];
 
-const CATEGORY_DEFS_FALLBACK: ReadonlyArray<{ key: string; title: string; icon: Component }> = [
-	{ key: 'equipment-fault', title: '设备异常', icon: Tools },
-	{ key: 'material-fault',  title: '物料异常', icon: Box },
-	{ key: 'quality-fault',   title: '质量异常', icon: TrendCharts },
-];
+// S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:原 CATEGORY_DEFS_FALLBACK 仅服务被替换的多维分析区,已移除。
 
 const effectiveStageMeta = computed(() => pageConfig.effectiveStageMeta(STAGE_META_FALLBACK));
-const effectiveCategoryDefs = computed(() => pageConfig.effectiveCategoryDefs(CATEGORY_DEFS_FALLBACK));
 
 const anomalyTypes = reactive<ProductionAnomalyType[]>(
 	ANOMALY_TYPES_FALLBACK.map((d) => ({ ...d, total: 0, avgProcessHours: 0, closeRate: 0 })),
@@ -300,26 +283,19 @@ const stageCards = computed(() =>
 
 const stageKeys = computed(() => stageCards.value.map((c) => c.code));
 
-const categoryCards = computed<CategoryGridCardData[]>(() => {
-	const typeMap = new Map(anomalyTypes.map((t) => [t.key, t]));
-	const raw = effectiveCategoryDefs.value.map((def) => {
-		const t = typeMap.get(def.key);
-		return {
-			key: def.key,
-			title: def.title,
-			icon: def.icon,
-			totalText: formatInteger(t?.total ?? 0),
-			avgHoursText: formatHours(t?.avgProcessHours ?? 0),
-			closeRateText: formatPercent(t?.closeRate ?? 0),
-			tone: resolveTone(t?.closeRate ?? 0),
-		};
-	});
-	return raw.map((c) => applyCategoryConfig(c));
-});
-
-const categoryKeys = computed(() => categoryCards.value.map((c) => c.key));
-
+// S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:原 categoryCards / categoryKeys 仅服务被替换的多维分析区,已移除。
 const totalAnomalies = computed(() => anomalyTypes.reduce((s, t) => s + t.total, 0));
+
+// S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:近 7 日生产异常趋势数据。
+const productionTrendData = shallowRef<S8ProductionTrendData | null>(null);
+async function loadProductionTrend() {
+	try {
+		productionTrendData.value = await s8MonitoringApi.productionTrend({ tenantId: 1, factoryId: 1, days: 7 });
+	} catch (err) {
+		console.error('[S8MonitoringProductionPage] loadProductionTrend failed:', err);
+		productionTrendData.value = null;
+	}
+}
 const layoutVars = computed<CSSProperties>(() => ({ '--grid-gap': '24px' }));
 
 // ─── 工具函数 ──────────────────────────────────────────────────────────────
@@ -371,13 +347,13 @@ async function loadData() {
 }
 
 function resetStageConfig() { resetStageConfigState(stageCards.value); }
-function resetCategoryConfig() { resetCategoryConfigState(categoryCards.value); }
+function resetCategoryConfig() { resetCategoryConfigState([]); }
 
 onMounted(async () => {
 	await loadPageConfig();
 	initializeFromCards(stageCards.value);
-	initializeFromCategories(categoryCards.value);
 	void loadData();
+	void loadProductionTrend();
 });
 </script>
 
@@ -574,6 +550,21 @@ onMounted(async () => {
 
 .section-title { display: flex; align-items: center; gap: 12px; }
 
+/* S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:右侧「生产异常类型」时间窗口徽标。 */
+.anomaly-monitor__time-badge {
+	display: inline-flex;
+	align-items: center;
+	height: 20px;
+	padding: 0 8px;
+	font-size: 11px;
+	font-weight: 500;
+	color: #ffc107;
+	background: rgba(255, 193, 7, 0.12);
+	border: 1px solid rgba(255, 193, 7, 0.28);
+	border-radius: 999px;
+	letter-spacing: 0.04em;
+}
+
 .section-title__bar {
 	width: 6px;
 	height: 24px;

+ 42 - 54
Web/src/views/aidop/s8/monitoring/S8MonitoringSupplyPage.vue

@@ -33,6 +33,8 @@
 					<div class="section-title">
 						<div class="section-title__bar" />
 						<h2 class="section-title__text">供应异常类型</h2>
+						<!-- S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:与下方「近7日供应异常趋势」做时间窗口区分。 -->
+						<span class="anomaly-monitor__time-badge">近24h</span>
 					</div>
 
 					<div class="anomaly-monitor__type-list">
@@ -67,26 +69,9 @@
 
 			<template #analysis>
 				<section class="anomaly-monitor__analysis-panel glass-panel">
-					<div class="anomaly-monitor__analysis-head">
-						<div class="section-title">
-							<div class="section-title__bar section-title__bar--accent" />
-							<h2 class="section-title__text">供应异常多维分析</h2>
-						</div>
-					</div>
-
-					<div class="anomaly-monitor__analysis-grid">
-						<S8MonitoringCategoryGrid
-							:cards="categoryCards"
-							:layout="draftLayout.analysis"
-							:editable="editMode"
-							:row-height="56"
-							:gap="12"
-							:min-w="3"
-							@update:layout="onAnalysisLayoutUpdate"
-						/>
-					</div>
-
-					<!-- S8-MONITORING-TOP-STATUS-STRIP-REMOVE-1:移除底部全局状态条。 -->
+					<!-- S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:原「供应异常多维分析」7 张卡与右侧「供应异常类型」重复,
+					     替换为近 7 日供应异常趋势图(接口 /monitoring/supply-trend,前端按近7日总量选 Top5 非零类型展示)。 -->
+					<S8SupplyTrendChart :data="supplyTrendData" />
 				</section>
 			</template>
 		</S8MonitoringResizableShell>
@@ -98,7 +83,7 @@
 			:stage-keys="stageKeys"
 			v-model:selected-cat-key="categoryConfigState.selectedKey"
 			v-model:cat-card="categoryConfigState.items[categoryConfigState.selectedKey]"
-			:category-keys="categoryKeys"
+			:category-keys="[]"
 			@reset="resetStageConfig"
 			@reset-cat="resetCategoryConfig"
 		/>
@@ -107,16 +92,16 @@
 
 <script setup lang="ts" name="aidopS8MonitoringSupply">
 import { computed, onMounted, reactive, shallowRef, type CSSProperties, type Component } from 'vue';
-import { ShoppingBag, Tools, DataAnalysis, Box, Van, Document, Promotion } from '@element-plus/icons-vue';
+import { ShoppingBag, Tools, DataAnalysis } from '@element-plus/icons-vue';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import { deepClone, createPageLayout, LAYOUT_VERSION, type S8LayoutSchema, type GridItem } from './useS8Layout';
 import { s8SupplyMonitoringApi, type SupplyAnomalyType } from '../api/s8SupplyMonitoringApi';
-import type { S8MonitoringSummary, S8ModuleOrderSummary } from '../api/s8MonitoringApi';
+import { s8MonitoringApi, type S8MonitoringSummary, type S8ModuleOrderSummary, type S8SupplyTrendData } from '../api/s8MonitoringApi';
 import S8MonitoringResizableShell from './components/S8MonitoringResizableShell.vue';
 import S8MonitoringModulesGrid from './components/S8MonitoringModulesGrid.vue';
-import S8MonitoringCategoryGrid, { type CategoryGridCardData } from './components/S8MonitoringCategoryGrid.vue';
 import S8MonitoringEditToolbar from './components/S8MonitoringEditToolbar.vue';
 import S8MonitoringStageConfigDrawer from './components/S8MonitoringStageConfigDrawer.vue';
+import S8SupplyTrendChart from './components/S8SupplyTrendChart.vue';
 import { useS8UnsavedLayoutEditGuard } from './useS8UnsavedLayoutEditGuard';
 import { useS8StageConfig, buildModuleTriColorStats, DEFAULT_NORMAL_WORK_ORDER_COUNT } from './useS8StageConfig';
 import { useS8CategoryConfig } from './useS8CategoryConfig';
@@ -150,7 +135,9 @@ const { layout: persistedLayout, save, resetToDefault, restoreDemo } = useSupply
 // ─── 卡片配置 ──────────────────────────────────────────────────────────────
 const configDrawerVisible = shallowRef(false);
 const { stageConfigState, initializeFromCards, applyConfig, reset: resetStageConfigState } = useS8StageConfig();
-const { categoryConfigState, initializeFromCategories, applyConfig: applyCategoryConfig, reset: resetCategoryConfigState } = useS8CategoryConfig();
+// S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:保留 categoryConfigState 与 reset 给抽屉状态绑定,
+// 数据绑定 categoryCards 已废弃;initializeFromCategories / applyCategoryConfig 不再使用。
+const { categoryConfigState, reset: resetCategoryConfigState } = useS8CategoryConfig();
 
 // ─── 布局状态 ──────────────────────────────────────────────────────────────
 const editMode = shallowRef(false);
@@ -236,16 +223,9 @@ const ANOMALY_TYPES_FALLBACK: ReadonlyArray<{ key: string; label: string }> = [
 	{ key: 'work-order-issue',     label: '仓库工单发料异常' },
 ];
 
-const SUPPLY_CATEGORY_ICONS: ReadonlyArray<Component> = [ShoppingBag, Van, Box, DataAnalysis, Document, Promotion, Tools];
-
-const CATEGORY_DEFS_FALLBACK: ReadonlyArray<{ key: string; title: string; icon: Component }> = ANOMALY_TYPES_FALLBACK.map((t, idx) => ({
-	key: t.key,
-	title: t.label,
-	icon: SUPPLY_CATEGORY_ICONS[idx % SUPPLY_CATEGORY_ICONS.length],
-}));
+// S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:原 SUPPLY_CATEGORY_ICONS / CATEGORY_DEFS_FALLBACK 仅服务被替换的多维分析区,已移除。
 
 const effectiveStageMeta = computed(() => pageConfig.effectiveStageMeta(STAGE_META_FALLBACK));
-const effectiveCategoryDefs = computed(() => pageConfig.effectiveCategoryDefs(CATEGORY_DEFS_FALLBACK));
 
 const anomalyTypes = reactive<SupplyAnomalyType[]>(
 	ANOMALY_TYPES_FALLBACK.map((d) => ({ ...d, total: 0, avgProcessHours: 0, closeRate: 0 })),
@@ -315,26 +295,19 @@ const stageCards = computed(() =>
 
 const stageKeys = computed(() => stageCards.value.map((c) => c.code));
 
-const categoryCards = computed<CategoryGridCardData[]>(() => {
-	const typeMap = new Map(anomalyTypes.map((t) => [t.key, t]));
-	const raw = effectiveCategoryDefs.value.map((def) => {
-		const t = typeMap.get(def.key);
-		return {
-			key: def.key,
-			title: def.title,
-			icon: def.icon,
-			totalText: formatInteger(t?.total ?? 0),
-			avgHoursText: formatHours(t?.avgProcessHours ?? 0),
-			closeRateText: formatPercent(t?.closeRate ?? 0),
-			tone: resolveTone(t?.closeRate ?? 0),
-		};
-	});
-	return raw.map((c) => applyCategoryConfig(c));
-});
-
-const categoryKeys = computed(() => categoryCards.value.map((c) => c.key));
-
+// S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:原 categoryCards / categoryKeys 仅服务被替换的多维分析区,已移除。
 const totalAnomalies = computed(() => anomalyTypes.reduce((s, t) => s + t.total, 0));
+
+// S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:近 7 日供应异常趋势数据。
+const supplyTrendData = shallowRef<S8SupplyTrendData | null>(null);
+async function loadSupplyTrend() {
+	try {
+		supplyTrendData.value = await s8MonitoringApi.supplyTrend({ tenantId: 1, factoryId: 1, days: 7 });
+	} catch (err) {
+		console.error('[S8MonitoringSupplyPage] loadSupplyTrend failed:', err);
+		supplyTrendData.value = null;
+	}
+}
 const layoutVars = computed<CSSProperties>(() => ({ '--grid-gap': '24px' }));
 
 // ─── 工具函数 ──────────────────────────────────────────────────────────────
@@ -385,13 +358,13 @@ async function loadData() {
 }
 
 function resetStageConfig() { resetStageConfigState(stageCards.value); }
-function resetCategoryConfig() { resetCategoryConfigState(categoryCards.value); }
+function resetCategoryConfig() { resetCategoryConfigState([]); }
 
 onMounted(async () => {
 	await loadPageConfig();
 	initializeFromCards(stageCards.value);
-	initializeFromCategories(categoryCards.value);
 	void loadData();
+	void loadSupplyTrend();
 });
 </script>
 
@@ -604,6 +577,21 @@ onMounted(async () => {
 
 .section-title { display: flex; align-items: center; gap: 12px; }
 
+/* S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:右侧「供应异常类型」时间窗口徽标。 */
+.anomaly-monitor__time-badge {
+	display: inline-flex;
+	align-items: center;
+	height: 20px;
+	padding: 0 8px;
+	font-size: 11px;
+	font-weight: 500;
+	color: #6de039;
+	background: rgba(109, 224, 57, 0.12);
+	border: 1px solid rgba(109, 224, 57, 0.28);
+	border-radius: 999px;
+	letter-spacing: 0.04em;
+}
+
 .section-title__bar {
 	width: 6px;
 	height: 24px;

+ 235 - 0
Web/src/views/aidop/s8/monitoring/components/S8ProductionTrendChart.vue

@@ -0,0 +1,235 @@
+<template>
+	<div class="s8-prod-trend">
+		<div class="s8-prod-trend__header">
+			<span class="s8-prod-trend__title">近7日生产异常趋势</span>
+			<span class="s8-prod-trend__subtitle">统计近7天生产相关异常数量变化,辅助识别生产过程风险波动。</span>
+		</div>
+
+		<div v-if="isEmpty" class="s8-prod-trend__empty">
+			<div class="s8-prod-trend__empty-title">暂无趋势数据</div>
+			<div class="s8-prod-trend__empty-desc">最近7天未统计到生产异常记录</div>
+		</div>
+		<scEcharts
+			v-else
+			ref="chartRef"
+			height="240px"
+			width="100%"
+			:option="chartOption"
+		/>
+
+		<div class="s8-prod-trend__summary">
+			<div class="s8-prod-trend__summary-card">
+				<div class="s8-prod-trend__summary-label">7日峰值</div>
+				<div class="s8-prod-trend__summary-value">{{ peakText }}</div>
+			</div>
+			<div class="s8-prod-trend__summary-card">
+				<div class="s8-prod-trend__summary-label">7日均值</div>
+				<div class="s8-prod-trend__summary-value">{{ avgText }}</div>
+			</div>
+			<div class="s8-prod-trend__summary-card">
+				<div class="s8-prod-trend__summary-label">今日异常</div>
+				<div class="s8-prod-trend__summary-value">{{ todayText }}</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import scEcharts from '/@/components/scEcharts/index.vue';
+import type { S8ProductionTrendData } from '../../api/s8MonitoringApi';
+
+const props = defineProps<{
+	data: S8ProductionTrendData | null;
+}>();
+
+const chartRef = ref();
+
+const days = computed(() => props.data?.days ?? []);
+const summary = computed(() => props.data?.summary ?? null);
+const isEmpty = computed(() => days.value.length === 0 || days.value.every((d) => d.total === 0));
+
+const peakText = computed(() => {
+	const s = summary.value;
+	if (!s || s.peakValue <= 0) return '--';
+	return String(s.peakValue);
+});
+const avgText = computed(() => {
+	const s = summary.value;
+	if (!s || s.avgValue <= 0) return '--';
+	return s.avgValue.toFixed(1);
+});
+const todayText = computed(() => String(summary.value?.todayValue ?? 0));
+
+const SERIES_META = [
+	{ key: 'equipmentFault', name: '设备异常', color: '#ffc107' },
+	{ key: 'materialFault',  name: '物料异常', color: '#7bd0ff' },
+	{ key: 'qualityFault',   name: '质量异常', color: '#ff9d6c' },
+] as const;
+
+const chartOption = computed(() => {
+	const list = days.value;
+	const labels = list.map((d) => d.date);
+
+	return {
+		backgroundColor: 'transparent',
+		animation: true,
+		animationDuration: 600,
+		animationEasing: 'cubicOut',
+		legend: {
+			show: true,
+			top: 0,
+			right: 8,
+			textStyle: { color: '#c6c6cd', fontSize: 11 },
+			itemWidth: 14,
+			itemHeight: 8,
+			data: SERIES_META.map((m) => m.name),
+		},
+		grid: { left: 12, right: 18, top: 32, bottom: 8, containLabel: true },
+		tooltip: {
+			trigger: 'axis',
+			backgroundColor: 'rgba(15,23,42,0.92)',
+			borderColor: 'rgba(255,193,7,0.24)',
+			borderWidth: 1,
+			textStyle: { color: '#dbe5f1', fontSize: 12 },
+			formatter: (params: any[]) => {
+				if (!params?.length) return '';
+				const idx = params[0].dataIndex;
+				const day = list[idx];
+				if (!day) return '';
+				return `<div style="font-weight:600;margin-bottom:4px;">${day.rawDate}</div>`
+					+ `<div>· 设备异常:${day.equipmentFault}</div>`
+					+ `<div>· 物料异常:${day.materialFault}</div>`
+					+ `<div>· 质量异常:${day.qualityFault}</div>`
+					+ `<div style="margin-top:4px;color:#ffc107;">合计:${day.total}</div>`;
+			},
+		},
+		xAxis: {
+			type: 'category',
+			data: labels,
+			boundaryGap: false,
+			axisLine: { lineStyle: { color: 'rgba(255,255,255,0.12)' } },
+			axisTick: { show: false },
+			axisLabel: { color: '#9ca3af', fontSize: 11 },
+		},
+		yAxis: {
+			type: 'value',
+			minInterval: 1,
+			axisLine: { show: false },
+			axisTick: { show: false },
+			axisLabel: { color: '#9ca3af', fontSize: 11 },
+			splitLine: { lineStyle: { color: 'rgba(255,255,255,0.06)' } },
+		},
+		series: SERIES_META.map((meta) => ({
+			name: meta.name,
+			type: 'line',
+			smooth: true,
+			symbol: 'circle',
+			symbolSize: 6,
+			lineStyle: { width: 2, color: meta.color },
+			itemStyle: { color: meta.color, borderColor: meta.color, borderWidth: 2 },
+			areaStyle: {
+				color: {
+					type: 'linear',
+					x: 0, y: 0, x2: 0, y2: 1,
+					colorStops: [
+						{ offset: 0, color: hexToRgba(meta.color, 0.32) },
+						{ offset: 1, color: hexToRgba(meta.color, 0.02) },
+					],
+				},
+			},
+			data: list.map((d) => d[meta.key as keyof typeof d] as number),
+		})),
+	};
+});
+
+function hexToRgba(hex: string, alpha: number) {
+	const h = hex.replace('#', '');
+	const r = parseInt(h.slice(0, 2), 16);
+	const g = parseInt(h.slice(2, 4), 16);
+	const b = parseInt(h.slice(4, 6), 16);
+	return `rgba(${r},${g},${b},${alpha})`;
+}
+</script>
+
+<style scoped>
+.s8-prod-trend {
+	display: flex;
+	flex-direction: column;
+	height: 100%;
+	padding: 18px 18px 16px;
+	gap: 12px;
+	background: rgba(15, 19, 26, 0.55);
+	border: 1px solid rgba(255, 193, 7, 0.12);
+	border-radius: 14px;
+}
+
+.s8-prod-trend__header {
+	display: flex;
+	flex-direction: column;
+	gap: 4px;
+}
+
+.s8-prod-trend__title {
+	font-size: 14px;
+	font-weight: 600;
+	letter-spacing: 0.06em;
+	color: #e1e6ee;
+}
+
+.s8-prod-trend__subtitle {
+	font-size: 11px;
+	color: #9ca3af;
+}
+
+.s8-prod-trend__empty {
+	flex: 1;
+	min-height: 200px;
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	justify-content: center;
+	gap: 6px;
+	border: 1px dashed rgba(255, 255, 255, 0.08);
+	border-radius: 10px;
+	background: rgba(11, 14, 20, 0.4);
+}
+
+.s8-prod-trend__empty-title {
+	font-size: 13px;
+	color: #c6c6cd;
+}
+
+.s8-prod-trend__empty-desc {
+	font-size: 11px;
+	color: #6b7280;
+}
+
+.s8-prod-trend__summary {
+	display: grid;
+	grid-template-columns: repeat(3, minmax(0, 1fr));
+	gap: 10px;
+}
+
+.s8-prod-trend__summary-card {
+	display: flex;
+	flex-direction: column;
+	gap: 4px;
+	padding: 10px 12px;
+	border-radius: 10px;
+	background: rgba(11, 14, 20, 0.55);
+	border: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.s8-prod-trend__summary-label {
+	font-size: 11px;
+	color: #9ca3af;
+}
+
+.s8-prod-trend__summary-value {
+	font-size: 20px;
+	font-weight: 600;
+	color: #e1e6ee;
+	font-family: 'Roboto Mono', monospace;
+}
+</style>

+ 253 - 0
Web/src/views/aidop/s8/monitoring/components/S8SupplyTrendChart.vue

@@ -0,0 +1,253 @@
+<template>
+	<div class="s8-supply-trend">
+		<div class="s8-supply-trend__header">
+			<span class="s8-supply-trend__title">近7日供应异常趋势</span>
+			<span class="s8-supply-trend__subtitle">统计近7天供应相关异常数量变化,辅助识别供应风险波动。</span>
+		</div>
+
+		<div v-if="isEmpty" class="s8-supply-trend__empty">
+			<div class="s8-supply-trend__empty-title">暂无趋势数据</div>
+			<div class="s8-supply-trend__empty-desc">最近7天未统计到供应异常记录</div>
+		</div>
+		<scEcharts
+			v-else
+			ref="chartRef"
+			height="240px"
+			width="100%"
+			:option="chartOption"
+		/>
+
+		<div class="s8-supply-trend__summary">
+			<div class="s8-supply-trend__summary-card">
+				<div class="s8-supply-trend__summary-label">7日峰值</div>
+				<div class="s8-supply-trend__summary-value">{{ peakText }}</div>
+			</div>
+			<div class="s8-supply-trend__summary-card">
+				<div class="s8-supply-trend__summary-label">7日均值</div>
+				<div class="s8-supply-trend__summary-value">{{ avgText }}</div>
+			</div>
+			<div class="s8-supply-trend__summary-card">
+				<div class="s8-supply-trend__summary-label">今日异常</div>
+				<div class="s8-supply-trend__summary-value">{{ todayText }}</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import scEcharts from '/@/components/scEcharts/index.vue';
+import type { S8SupplyTrendData, S8SupplyTrendDay } from '../../api/s8MonitoringApi';
+
+const props = defineProps<{
+	data: S8SupplyTrendData | null;
+}>();
+
+const chartRef = ref();
+
+const days = computed<S8SupplyTrendDay[]>(() => props.data?.days ?? []);
+const summary = computed(() => props.data?.summary ?? null);
+const isEmpty = computed(() => days.value.length === 0 || days.value.every((d) => d.total === 0));
+
+const peakText = computed(() => {
+	const s = summary.value;
+	if (!s || s.peakValue <= 0) return '--';
+	return String(s.peakValue);
+});
+const avgText = computed(() => {
+	const s = summary.value;
+	if (!s || s.avgValue <= 0) return '--';
+	return s.avgValue.toFixed(1);
+});
+const todayText = computed(() => String(summary.value?.todayValue ?? 0));
+
+// 7 类完整元数据;按近 7 日总量选 Top5 非零展示,避免视觉拥挤。
+const ALL_SERIES_META = [
+	{ key: 'supplierEtaIssue',         name: '供应商交期异常', color: '#7bd0ff' },
+	{ key: 'supplierShipIssue',        name: '供应商发货异常', color: '#ffc107' },
+	{ key: 'warehouseReceiptAbnormal', name: '仓库收货异常',   color: '#ff9d6c' },
+	{ key: 'iqcIssue',                 name: 'IQC 检验异常',   color: '#88fd54' },
+	{ key: 'warehousePutawayIssue',    name: '仓库上架异常',   color: '#c084fc' },
+	{ key: 'workOrderKittingAbnormal', name: '工单备料异常',   color: '#f87171' },
+	{ key: 'workOrderIssueAbnormal',   name: '工单发料异常',   color: '#34d399' },
+] as const;
+
+type SeriesMeta = typeof ALL_SERIES_META[number];
+
+const visibleSeries = computed<SeriesMeta[]>(() => {
+	const list = days.value;
+	if (list.length === 0) return [];
+	const totals = ALL_SERIES_META.map((m) => ({
+		meta: m,
+		sum: list.reduce((s, d) => s + (d[m.key as keyof S8SupplyTrendDay] as number), 0),
+	}));
+	const nonZero = totals.filter((t) => t.sum > 0).sort((a, b) => b.sum - a.sum);
+	return nonZero.slice(0, 5).map((t) => t.meta);
+});
+
+const chartOption = computed(() => {
+	const list = days.value;
+	const labels = list.map((d) => d.date);
+	const series = visibleSeries.value;
+
+	return {
+		backgroundColor: 'transparent',
+		animation: true,
+		animationDuration: 600,
+		animationEasing: 'cubicOut',
+		legend: {
+			show: true,
+			top: 0,
+			right: 8,
+			textStyle: { color: '#c6c6cd', fontSize: 11 },
+			itemWidth: 14,
+			itemHeight: 8,
+			data: series.map((m) => m.name),
+		},
+		grid: { left: 12, right: 18, top: 32, bottom: 8, containLabel: true },
+		tooltip: {
+			trigger: 'axis',
+			backgroundColor: 'rgba(15,23,42,0.92)',
+			borderColor: 'rgba(109,224,57,0.24)',
+			borderWidth: 1,
+			textStyle: { color: '#dbe5f1', fontSize: 12 },
+			formatter: (params: any[]) => {
+				if (!params?.length) return '';
+				const idx = params[0].dataIndex;
+				const day = list[idx];
+				if (!day) return '';
+				const lines = series.map((m) => `<div>· ${m.name}:${day[m.key as keyof S8SupplyTrendDay]}</div>`).join('');
+				return `<div style="font-weight:600;margin-bottom:4px;">${day.rawDate}</div>`
+					+ lines
+					+ `<div style="margin-top:4px;color:#88fd54;">合计:${day.total}</div>`;
+			},
+		},
+		xAxis: {
+			type: 'category',
+			data: labels,
+			boundaryGap: false,
+			axisLine: { lineStyle: { color: 'rgba(255,255,255,0.12)' } },
+			axisTick: { show: false },
+			axisLabel: { color: '#9ca3af', fontSize: 11 },
+		},
+		yAxis: {
+			type: 'value',
+			minInterval: 1,
+			axisLine: { show: false },
+			axisTick: { show: false },
+			axisLabel: { color: '#9ca3af', fontSize: 11 },
+			splitLine: { lineStyle: { color: 'rgba(255,255,255,0.06)' } },
+		},
+		series: series.map((meta) => ({
+			name: meta.name,
+			type: 'line',
+			smooth: true,
+			symbol: 'circle',
+			symbolSize: 6,
+			lineStyle: { width: 2, color: meta.color },
+			itemStyle: { color: meta.color, borderColor: meta.color, borderWidth: 2 },
+			areaStyle: {
+				color: {
+					type: 'linear',
+					x: 0, y: 0, x2: 0, y2: 1,
+					colorStops: [
+						{ offset: 0, color: hexToRgba(meta.color, 0.32) },
+						{ offset: 1, color: hexToRgba(meta.color, 0.02) },
+					],
+				},
+			},
+			data: list.map((d) => d[meta.key as keyof S8SupplyTrendDay] as number),
+		})),
+	};
+});
+
+function hexToRgba(hex: string, alpha: number) {
+	const h = hex.replace('#', '');
+	const r = parseInt(h.slice(0, 2), 16);
+	const g = parseInt(h.slice(2, 4), 16);
+	const b = parseInt(h.slice(4, 6), 16);
+	return `rgba(${r},${g},${b},${alpha})`;
+}
+</script>
+
+<style scoped>
+.s8-supply-trend {
+	display: flex;
+	flex-direction: column;
+	height: 100%;
+	padding: 18px 18px 16px;
+	gap: 12px;
+	background: rgba(15, 19, 26, 0.55);
+	border: 1px solid rgba(109, 224, 57, 0.12);
+	border-radius: 14px;
+}
+
+.s8-supply-trend__header {
+	display: flex;
+	flex-direction: column;
+	gap: 4px;
+}
+
+.s8-supply-trend__title {
+	font-size: 14px;
+	font-weight: 600;
+	letter-spacing: 0.06em;
+	color: #e1e6ee;
+}
+
+.s8-supply-trend__subtitle {
+	font-size: 11px;
+	color: #9ca3af;
+}
+
+.s8-supply-trend__empty {
+	flex: 1;
+	min-height: 200px;
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	justify-content: center;
+	gap: 6px;
+	border: 1px dashed rgba(255, 255, 255, 0.08);
+	border-radius: 10px;
+	background: rgba(11, 14, 20, 0.4);
+}
+
+.s8-supply-trend__empty-title {
+	font-size: 13px;
+	color: #c6c6cd;
+}
+
+.s8-supply-trend__empty-desc {
+	font-size: 11px;
+	color: #6b7280;
+}
+
+.s8-supply-trend__summary {
+	display: grid;
+	grid-template-columns: repeat(3, minmax(0, 1fr));
+	gap: 10px;
+}
+
+.s8-supply-trend__summary-card {
+	display: flex;
+	flex-direction: column;
+	gap: 4px;
+	padding: 10px 12px;
+	border-radius: 10px;
+	background: rgba(11, 14, 20, 0.55);
+	border: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.s8-supply-trend__summary-label {
+	font-size: 11px;
+	color: #9ca3af;
+}
+
+.s8-supply-trend__summary-value {
+	font-size: 20px;
+	font-weight: 600;
+	color: #e1e6ee;
+	font-family: 'Roboto Mono', monospace;
+}
+</style>

+ 16 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8MonitoringController.cs

@@ -37,4 +37,20 @@ public class AdoS8MonitoringController : ControllerBase
     [HttpGet("delivery-trend")]
     public async Task<IActionResult> GetDeliveryTrendAsync(long tenantId = 1, long factoryId = 1, int days = 7)
         => Ok(await _svc.GetDeliveryTrendAsync(tenantId, factoryId, days));
+
+    /// <summary>
+    /// S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:Production 页近 N 日生产异常趋势。
+    /// 口径 module_code IN (S2,S6) AND exception_type_code IN (EQUIP_FAULT / MFG_MATERIAL_ABNORMAL / MFG_QUALITY_ABNORMAL)。
+    /// </summary>
+    [HttpGet("production-trend")]
+    public async Task<IActionResult> GetProductionTrendAsync(long tenantId = 1, long factoryId = 1, int days = 7)
+        => Ok(await _svc.GetProductionTrendAsync(tenantId, factoryId, days));
+
+    /// <summary>
+    /// S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:Supply 页近 N 日供应异常趋势。
+    /// 口径 module_code IN (S3,S4,S5) AND exception_type_code IN 7 类供应异常。
+    /// </summary>
+    [HttpGet("supply-trend")]
+    public async Task<IActionResult> GetSupplyTrendAsync(long tenantId = 1, long factoryId = 1, int days = 7)
+        => Ok(await _svc.GetSupplyTrendAsync(tenantId, factoryId, days));
 }

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

@@ -277,6 +277,44 @@ public class AdoS8DeliveryTrendSummaryDto
     public double? TodayChangeRate { get; set; }
 }
 
+/// <summary>S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:Production 页近 N 日生产异常趋势出参。</summary>
+public class AdoS8ProductionTrendDto
+{
+    public List<AdoS8ProductionTrendDayDto> Days { get; set; } = new();
+    public AdoS8DeliveryTrendSummaryDto Summary { get; set; } = new();
+}
+
+public class AdoS8ProductionTrendDayDto
+{
+    public string Date { get; set; } = string.Empty;
+    public string RawDate { get; set; } = string.Empty;
+    public int EquipmentFault { get; set; }
+    public int MaterialFault { get; set; }
+    public int QualityFault { get; set; }
+    public int Total { get; set; }
+}
+
+/// <summary>S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:Supply 页近 N 日供应异常趋势出参。</summary>
+public class AdoS8SupplyTrendDto
+{
+    public List<AdoS8SupplyTrendDayDto> Days { get; set; } = new();
+    public AdoS8DeliveryTrendSummaryDto Summary { get; set; } = new();
+}
+
+public class AdoS8SupplyTrendDayDto
+{
+    public string Date { get; set; } = string.Empty;
+    public string RawDate { get; set; } = string.Empty;
+    public int SupplierEtaIssue { get; set; }
+    public int SupplierShipIssue { get; set; }
+    public int WarehouseReceiptAbnormal { get; set; }
+    public int IqcIssue { get; set; }
+    public int WarehousePutawayIssue { get; set; }
+    public int WorkOrderKittingAbnormal { get; set; }
+    public int WorkOrderIssueAbnormal { get; set; }
+    public int Total { get; set; }
+}
+
 public class AdoS8SubmitVerificationDto
 {
     public long VerifierId { get; set; }

+ 141 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8MonitoringService.cs

@@ -322,4 +322,145 @@ public class S8MonitoringService : ITransient
 
         return new AdoS8DeliveryTrendDto { Days = dayList, Summary = summary };
     }
+
+    /// <summary>
+    /// S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:Production 页近 N 日生产异常趋势。
+    /// 口径:module_code IN (S2,S6) AND exception_type_code IN (EQUIP_FAULT / MFG_MATERIAL_ABNORMAL / MFG_QUALITY_ABNORMAL);
+    /// 时间字段 created_at;不排除 CLOSED;按日聚合,缺失日期补 0;days 限 1-30。
+    /// </summary>
+    public async Task<AdoS8ProductionTrendDto> GetProductionTrendAsync(long tenantId = 1, long factoryId = 1, int days = 7)
+    {
+        days = Math.Clamp(days, 1, 30);
+        var today = DateTime.Today;
+        var from = today.AddDays(-(days - 1));
+        var toExclusive = today.AddDays(1);
+
+        var prodModules = new[] { "S2", "S6" };
+        var prodTypes = new[] { "EQUIP_FAULT", "MFG_MATERIAL_ABNORMAL", "MFG_QUALITY_ABNORMAL" };
+
+        var rows = await _rep.AsQueryable()
+            .Where(e => e.TenantId == tenantId && e.FactoryId == factoryId && !e.IsDeleted)
+            .Where(e => prodModules.Contains(e.ModuleCode))
+            .Where(e => e.ExceptionTypeCode != null && prodTypes.Contains(e.ExceptionTypeCode))
+            .Where(e => e.CreatedAt >= from && e.CreatedAt < toExclusive)
+            .Select(e => new { e.ExceptionTypeCode, e.CreatedAt })
+            .ToListAsync();
+
+        var byDate = rows.GroupBy(r => r.CreatedAt.Date).ToDictionary(g => g.Key, g => g.ToList());
+
+        var dayList = new List<AdoS8ProductionTrendDayDto>(days);
+        for (var i = 0; i < days; i++)
+        {
+            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");
+            var qf = bucket.Count(r => r.ExceptionTypeCode == "MFG_QUALITY_ABNORMAL");
+            dayList.Add(new AdoS8ProductionTrendDayDto
+            {
+                Date           = d.ToString("MM/dd"),
+                RawDate        = d.ToString("yyyy-MM-dd"),
+                EquipmentFault = ef,
+                MaterialFault  = mf,
+                QualityFault   = qf,
+                Total          = ef + mf + qf,
+            });
+        }
+
+        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
+        {
+            PeakValue       = peak?.Total ?? 0,
+            PeakDate        = (peak is null || peak.Total == 0) ? null : peak.RawDate,
+            AvgValue        = Math.Round(totalSum / (double)days, 2),
+            TodayValue      = todayDay.Total,
+            TodayChangeRate = changeRate,
+        };
+
+        return new AdoS8ProductionTrendDto { Days = dayList, Summary = summary };
+    }
+
+    /// <summary>
+    /// S8-PROD-SUPPLY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:Supply 页近 N 日供应异常趋势。
+    /// 口径:module_code IN (S3,S4,S5) AND exception_type_code IN 7 类(SUPPLIER_ETA_ISSUE / SUPPLIER_SHIP_ISSUE
+    /// / WAREHOUSE_RECEIPT_ABNORMAL / IQC_ISSUE / WH_PUTAWAY_ISSUE / WORK_ORDER_KITTING_ABNORMAL / WORK_ORDER_ISSUE_ABNORMAL);
+    /// 时间字段 created_at;不排除 CLOSED;按日聚合,缺失日期补 0;days 限 1-30。
+    /// </summary>
+    public async Task<AdoS8SupplyTrendDto> GetSupplyTrendAsync(long tenantId = 1, long factoryId = 1, int days = 7)
+    {
+        days = Math.Clamp(days, 1, 30);
+        var today = DateTime.Today;
+        var from = today.AddDays(-(days - 1));
+        var toExclusive = today.AddDays(1);
+
+        var supplyModules = new[] { "S3", "S4", "S5" };
+        var supplyTypes = new[]
+        {
+            "SUPPLIER_ETA_ISSUE", "SUPPLIER_SHIP_ISSUE", "WAREHOUSE_RECEIPT_ABNORMAL",
+            "IQC_ISSUE", "WH_PUTAWAY_ISSUE", "WORK_ORDER_KITTING_ABNORMAL", "WORK_ORDER_ISSUE_ABNORMAL"
+        };
+
+        var rows = await _rep.AsQueryable()
+            .Where(e => e.TenantId == tenantId && e.FactoryId == factoryId && !e.IsDeleted)
+            .Where(e => supplyModules.Contains(e.ModuleCode))
+            .Where(e => e.ExceptionTypeCode != null && supplyTypes.Contains(e.ExceptionTypeCode))
+            .Where(e => e.CreatedAt >= from && e.CreatedAt < toExclusive)
+            .Select(e => new { e.ExceptionTypeCode, e.CreatedAt })
+            .ToListAsync();
+
+        var byDate = rows.GroupBy(r => r.CreatedAt.Date).ToDictionary(g => g.Key, g => g.ToList());
+
+        var dayList = new List<AdoS8SupplyTrendDayDto>(days);
+        for (var i = 0; i < days; i++)
+        {
+            var d = from.AddDays(i);
+            var bucket = byDate.TryGetValue(d, out var list) ? list : new();
+            var s1 = bucket.Count(r => r.ExceptionTypeCode == "SUPPLIER_ETA_ISSUE");
+            var s2 = bucket.Count(r => r.ExceptionTypeCode == "SUPPLIER_SHIP_ISSUE");
+            var s3 = bucket.Count(r => r.ExceptionTypeCode == "WAREHOUSE_RECEIPT_ABNORMAL");
+            var s4 = bucket.Count(r => r.ExceptionTypeCode == "IQC_ISSUE");
+            var s5 = bucket.Count(r => r.ExceptionTypeCode == "WH_PUTAWAY_ISSUE");
+            var s6 = bucket.Count(r => r.ExceptionTypeCode == "WORK_ORDER_KITTING_ABNORMAL");
+            var s7 = bucket.Count(r => r.ExceptionTypeCode == "WORK_ORDER_ISSUE_ABNORMAL");
+            dayList.Add(new AdoS8SupplyTrendDayDto
+            {
+                Date                       = d.ToString("MM/dd"),
+                RawDate                    = d.ToString("yyyy-MM-dd"),
+                SupplierEtaIssue           = s1,
+                SupplierShipIssue          = s2,
+                WarehouseReceiptAbnormal   = s3,
+                IqcIssue                   = s4,
+                WarehousePutawayIssue      = s5,
+                WorkOrderKittingAbnormal   = s6,
+                WorkOrderIssueAbnormal     = s7,
+                Total                      = s1 + s2 + s3 + s4 + s5 + s6 + s7,
+            });
+        }
+
+        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
+        {
+            PeakValue       = peak?.Total ?? 0,
+            PeakDate        = (peak is null || peak.Total == 0) ? null : peak.RawDate,
+            AvgValue        = Math.Round(totalSum / (double)days, 2),
+            TodayValue      = todayDay.Total,
+            TodayChangeRate = changeRate,
+        };
+
+        return new AdoS8SupplyTrendDto { Days = dayList, Summary = summary };
+    }
 }