Browse Source

feat(s8): add delivery trend chart replacing duplicate analysis section

YY968XX 1 month ago
parent
commit
fa2dead9fe

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

@@ -66,6 +66,29 @@ export interface S8OrderGridData {
 	byDept: S8DeptSummary[];
 }
 
+// S8-DELIVERY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1
+export interface S8DeliveryTrendDay {
+	date: string;
+	rawDate: string;
+	orderChange: number;
+	deliveryDelay: number;
+	pendingShipment: number;
+	total: number;
+}
+
+export interface S8DeliveryTrendSummary {
+	peakValue: number;
+	peakDate: string | null;
+	avgValue: number;
+	todayValue: number;
+	todayChangeRate: number | null;
+}
+
+export interface S8DeliveryTrendData {
+	days: S8DeliveryTrendDay[];
+	summary: S8DeliveryTrendSummary;
+}
+
 export const s8MonitoringApi = {
 	summary: (params: S8MonitoringSummaryQuery = {}) =>
 		service
@@ -76,4 +99,9 @@ export const s8MonitoringApi = {
 		service
 			.get<S8OrderGridData>('/api/aidop/s8/monitoring/order-grid')
 			.then(unwrap),
+
+	deliveryTrend: (params: { tenantId?: number; factoryId?: number; days?: number } = {}) =>
+		service
+			.get<S8DeliveryTrendData>('/api/aidop/s8/monitoring/delivery-trend', { params })
+			.then(unwrap),
 };

+ 27 - 52
Web/src/views/aidop/s8/monitoring/S8MonitoringDeliveryPage.vue

@@ -68,25 +68,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-DELIVERY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:原「交付异常多维分析」三连卡与右侧「交付异常类型」重复,
+					     替换为近 7 日交付异常趋势图(接口 /monitoring/delivery-trend)。 -->
+					<S8DeliveryTrendChart :data="deliveryTrendData" />
 				</section>
 			</template>
 		</S8MonitoringResizableShell>
@@ -98,7 +82,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"
 		/>
@@ -111,12 +95,12 @@ import { Checked, Van } from '@element-plus/icons-vue';
 import { ElMessage, ElMessageBox } from 'element-plus';
 import { deepClone, createPageLayout, LAYOUT_VERSION, type S8LayoutSchema, type GridItem } from './useS8Layout';
 import { s8DeliveryMonitoringApi, type DeliveryAnomalyType } from '../api/s8DeliveryMonitoringApi';
-import type { S8MonitoringSummary, S8ModuleOrderSummary } from '../api/s8MonitoringApi';
+import { s8MonitoringApi, type S8MonitoringSummary, type S8ModuleOrderSummary, type S8DeliveryTrendData } 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 S8DeliveryTrendChart from './components/S8DeliveryTrendChart.vue';
 import { useS8UnsavedLayoutEditGuard } from './useS8UnsavedLayoutEditGuard';
 import { useS8StageConfig, buildModuleTriColorStats, DEFAULT_NORMAL_WORK_ORDER_COUNT } from './useS8StageConfig';
 import { useS8CategoryConfig } from './useS8CategoryConfig';
@@ -144,7 +128,9 @@ const { layout: persistedLayout, save, resetToDefault, restoreDemo } = useDelive
 // ─── 卡片配置 ──────────────────────────────────────────────────────────────
 const configDrawerVisible = shallowRef(false);
 const { stageConfigState, initializeFromCards, applyConfig, reset: resetStageConfigState } = useS8StageConfig();
-const { categoryConfigState, initializeFromCategories, applyConfig: applyCategoryConfig, reset: resetCategoryConfigState } = useS8CategoryConfig();
+// S8-DELIVERY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:保留 categoryConfigState 与 reset 给抽屉状态绑定,
+// 数据绑定 categoryCards 已废弃;initializeFromCategories / applyCategoryConfig 不再使用。
+const { categoryConfigState, reset: resetCategoryConfigState } = useS8CategoryConfig();
 
 // ─── 布局状态 ──────────────────────────────────────────────────────────────
 const editMode = shallowRef(false);
@@ -226,14 +212,10 @@ const ANOMALY_TYPES_FALLBACK: ReadonlyArray<{ key: string; label: string }> = [
 	{ key: 'stock-pending',  label: '入库待发' },
 ];
 
-const CATEGORY_DEFS_FALLBACK: ReadonlyArray<{ key: string; title: string; icon: Component }> = [
-	{ key: 'order-change',   title: '订单变更', icon: Checked },
-	{ key: 'delivery-delay', title: '交期延迟', icon: Van },
-	{ key: 'stock-pending',  title: '入库待发', icon: Van },
-];
+// S8-DELIVERY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:原 CATEGORY_DEFS_FALLBACK 仅服务 analysis 区块,
+// 该区块已被趋势图替换,常量与 effectiveCategoryDefs / categoryCards 一并移除。
 
 const effectiveStageMeta = computed(() => pageConfig.effectiveStageMeta(STAGE_META_FALLBACK));
-const effectiveCategoryDefs = computed(() => pageConfig.effectiveCategoryDefs(CATEGORY_DEFS_FALLBACK));
 
 const anomalyTypes = reactive<DeliveryAnomalyType[]>(
 	ANOMALY_TYPES_FALLBACK.map((d) => ({ ...d, total: 0, avgProcessHours: 0, closeRate: 0 })),
@@ -305,26 +287,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-DELIVERY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:原 categoryCards / categoryKeys 仅服务被替换的多维分析区,已移除。
 const totalAnomalies = computed(() => anomalyTypes.reduce((s, t) => s + t.total, 0));
+
+// S8-DELIVERY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:近 7 日交付异常趋势数据。
+const deliveryTrendData = shallowRef<S8DeliveryTrendData | null>(null);
+async function loadDeliveryTrend() {
+	try {
+		deliveryTrendData.value = await s8MonitoringApi.deliveryTrend({ tenantId: 1, factoryId: 1, days: 7 });
+	} catch (err) {
+		console.error('[S8MonitoringDeliveryPage] loadDeliveryTrend failed:', err);
+		deliveryTrendData.value = null;
+	}
+}
 const layoutVars = computed<CSSProperties>(() => ({ '--grid-gap': '24px' }));
 
 // ─── 工具函数 ──────────────────────────────────────────────────────────────
@@ -376,16 +351,16 @@ async function loadData() {
 }
 
 function resetStageConfig() { resetStageConfigState(stageCards.value); }
-function resetCategoryConfig() { resetCategoryConfigState(categoryCards.value); }
+function resetCategoryConfig() { resetCategoryConfigState([]); }
 
 onMounted(async () => {
 	// 1. 先拉 page-config(决定卡片结构)
 	await loadPageConfig();
-	// 2. 用最终的 stageCards / categoryCards 初始化 localStorage 覆盖层
+	// 2. 用最终的 stageCards 初始化 stage 配置覆盖层(applyConfig 已 passthrough,仅维护抽屉状态)
 	initializeFromCards(stageCards.value);
-	initializeFromCategories(categoryCards.value);
-	// 3. 拉业务数据(loadData 按 key 合入 anomalyTypes,与 page-config 顺序解耦)
+	// 3. 拉业务数据 + 趋势
 	void loadData();
+	void loadDeliveryTrend();
 });
 </script>
 

+ 232 - 0
Web/src/views/aidop/s8/monitoring/components/S8DeliveryTrendChart.vue

@@ -0,0 +1,232 @@
+<template>
+	<div class="s8-delivery-trend">
+		<div class="s8-delivery-trend__header">
+			<span class="s8-delivery-trend__title">近7日交付异常趋势</span>
+		</div>
+
+		<div v-if="isEmpty" class="s8-delivery-trend__empty">
+			<div class="s8-delivery-trend__empty-title">暂无趋势数据</div>
+			<div class="s8-delivery-trend__empty-desc">最近7天未统计到交付异常记录</div>
+		</div>
+		<scEcharts
+			v-else
+			ref="chartRef"
+			height="240px"
+			width="100%"
+			:option="chartOption"
+		/>
+
+		<div class="s8-delivery-trend__summary">
+			<div class="s8-delivery-trend__summary-card">
+				<div class="s8-delivery-trend__summary-label">7日峰值</div>
+				<div class="s8-delivery-trend__summary-value">{{ peakText }}</div>
+			</div>
+			<div class="s8-delivery-trend__summary-card">
+				<div class="s8-delivery-trend__summary-label">7日均值</div>
+				<div class="s8-delivery-trend__summary-value">{{ avgText }}</div>
+			</div>
+			<div class="s8-delivery-trend__summary-card">
+				<div class="s8-delivery-trend__summary-label">今日异常</div>
+				<div class="s8-delivery-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 { S8DeliveryTrendData } from '../../api/s8MonitoringApi';
+
+const props = defineProps<{
+	data: S8DeliveryTrendData | 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: 'orderChange', name: '订单变更', color: '#7bd0ff' },
+	{ key: 'deliveryDelay', name: '交期延迟', color: '#ffc107' },
+	{ key: 'pendingShipment', 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(123,208,255,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 oc = day.orderChange;
+				const dd = day.deliveryDelay;
+				const ps = day.pendingShipment;
+				return `<div style="font-weight:600;margin-bottom:4px;">${day.rawDate}</div>`
+					+ `<div>· 订单变更:${oc}</div>`
+					+ `<div>· 交期延迟:${dd}</div>`
+					+ `<div>· 入库待发:${ps}</div>`
+					+ `<div style="margin-top:4px;color:#7bd0ff;">合计:${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-delivery-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(123, 208, 255, 0.12);
+	border-radius: 14px;
+}
+
+.s8-delivery-trend__header {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+}
+
+.s8-delivery-trend__title {
+	font-size: 14px;
+	font-weight: 600;
+	letter-spacing: 0.06em;
+	color: #e1e6ee;
+}
+
+.s8-delivery-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-delivery-trend__empty-title {
+	font-size: 13px;
+	color: #c6c6cd;
+}
+
+.s8-delivery-trend__empty-desc {
+	font-size: 11px;
+	color: #6b7280;
+}
+
+.s8-delivery-trend__summary {
+	display: grid;
+	grid-template-columns: repeat(3, minmax(0, 1fr));
+	gap: 10px;
+}
+
+.s8-delivery-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-delivery-trend__summary-label {
+	font-size: 11px;
+	color: #9ca3af;
+}
+
+.s8-delivery-trend__summary-value {
+	font-size: 20px;
+	font-weight: 600;
+	color: #e1e6ee;
+	font-family: 'Roboto Mono', monospace;
+}
+</style>

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

@@ -29,4 +29,12 @@ public class AdoS8MonitoringController : ControllerBase
     [HttpGet("order-grid")]
     public async Task<IActionResult> GetOrderGridAsync()
         => Ok(await _svc.GetOrderGridAsync());
+
+    /// <summary>
+    /// S8-DELIVERY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:Delivery 页近 N 日交付异常趋势。
+    /// 口径 module_code IN (S1,S7) AND exception_type_code IN (ORDER_CHANGE / DELIVERY_DELAY / PENDING_SHIPMENT)。
+    /// </summary>
+    [HttpGet("delivery-trend")]
+    public async Task<IActionResult> GetDeliveryTrendAsync(long tenantId = 1, long factoryId = 1, int days = 7)
+        => Ok(await _svc.GetDeliveryTrendAsync(tenantId, factoryId, days));
 }

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

@@ -248,6 +248,35 @@ public class AdoS8OrderGridDto
     public List<AdoS8DeptSummary> ByDept { get; set; } = new();
 }
 
+/// <summary>S8-DELIVERY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:Delivery 页近 N 日交付异常趋势出参。</summary>
+public class AdoS8DeliveryTrendDto
+{
+    public List<AdoS8DeliveryTrendDayDto> Days { get; set; } = new();
+    public AdoS8DeliveryTrendSummaryDto Summary { get; set; } = new();
+}
+
+public class AdoS8DeliveryTrendDayDto
+{
+    /// <summary>日期短格式 MM/dd(图表 X 轴)</summary>
+    public string Date { get; set; } = string.Empty;
+    /// <summary>原始日期 yyyy-MM-dd</summary>
+    public string RawDate { get; set; } = string.Empty;
+    public int OrderChange { get; set; }
+    public int DeliveryDelay { get; set; }
+    public int PendingShipment { get; set; }
+    public int Total { get; set; }
+}
+
+public class AdoS8DeliveryTrendSummaryDto
+{
+    public int PeakValue { get; set; }
+    public string? PeakDate { get; set; }
+    public double AvgValue { get; set; }
+    public int TodayValue { get; set; }
+    /// <summary>今日相对昨日变化率(%);昨日为 0 时返回 null。</summary>
+    public double? TodayChangeRate { get; set; }
+}
+
 public class AdoS8SubmitVerificationDto
 {
     public long VerifierId { get; set; }

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

@@ -256,4 +256,70 @@ public class S8MonitoringService : ITransient
             ByModule = byModule
         };
     }
+
+    /// <summary>
+    /// S8-DELIVERY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:Delivery 页近 N 日交付异常趋势。
+    /// 口径:module_code IN (S1,S7) AND exception_type_code IN (ORDER_CHANGE / DELIVERY_DELAY / PENDING_SHIPMENT);
+    /// 时间字段 created_at;不排除 CLOSED;按日聚合,缺失日期补 0;days 限 1-30。
+    /// </summary>
+    public async Task<AdoS8DeliveryTrendDto> GetDeliveryTrendAsync(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 deliveryModules = new[] { "S1", "S7" };
+        var deliveryTypes = new[] { "ORDER_CHANGE", "DELIVERY_DELAY", "PENDING_SHIPMENT" };
+
+        var rows = await _rep.AsQueryable()
+            .Where(e => e.TenantId == tenantId && e.FactoryId == factoryId && !e.IsDeleted)
+            .Where(e => deliveryModules.Contains(e.ModuleCode))
+            .Where(e => e.ExceptionTypeCode != null && deliveryTypes.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<AdoS8DeliveryTrendDayDto>(days);
+        for (var i = 0; i < days; i++)
+        {
+            var d = from.AddDays(i);
+            var bucket = byDate.TryGetValue(d, out var list) ? list : new();
+            var oc = bucket.Count(r => r.ExceptionTypeCode == "ORDER_CHANGE");
+            var dd = bucket.Count(r => r.ExceptionTypeCode == "DELIVERY_DELAY");
+            var ps = bucket.Count(r => r.ExceptionTypeCode == "PENDING_SHIPMENT");
+            dayList.Add(new AdoS8DeliveryTrendDayDto
+            {
+                Date           = d.ToString("MM/dd"),
+                RawDate        = d.ToString("yyyy-MM-dd"),
+                OrderChange    = oc,
+                DeliveryDelay  = dd,
+                PendingShipment = ps,
+                Total          = oc + dd + ps,
+            });
+        }
+
+        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 AdoS8DeliveryTrendDto { Days = dayList, Summary = summary };
+    }
 }