Przeglądaj źródła

feat(s8): add S9 QDC four-track operational monitoring card

Replace the generic S9 summary card with a dedicated QDC card covering
质量/交付/成本/库存 four tracks. Backend aggregates real exception data
via /api/aidop/s8/monitoring/qdc-summary; frontend renders a 2×2 grid
with per-track totals, avg process hours, on-time close rate, and close rate.
Cost track returns an empty bucket with explanatory remark pending business
definition of cost exception type codes.
YY968XX 5 miesięcy temu
rodzic
commit
684d7fb133

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

@@ -153,6 +153,21 @@ export interface S8DomainTypeMetrics {
 	items: S8DomainTypeMetricItem[];
 }
 
+// TASK-010: S9 QDC 四主线
+export interface S8QdcSummaryItem {
+	code: string;
+	title: string;
+	total: number;
+	avgProcessHours: number | null;
+	onTimeCloseRate: number | null;
+	closeRate: number | null;
+	remark: string | null;
+}
+
+export interface S8QdcSummaryData {
+	items: S8QdcSummaryItem[];
+}
+
 export const s8MonitoringApi = {
 	summary: (params: S8MonitoringSummaryQuery = {}) =>
 		service
@@ -191,4 +206,11 @@ export const s8MonitoringApi = {
 				params: { window: 'LAST_24H', tenantId: 1, factoryId: 1, ...params },
 			})
 			.then(unwrap),
+
+	qdcSummary: (params: { tenantId?: number; factoryId?: number; period?: S8Period } = {}) =>
+		service
+			.get<S8QdcSummaryData>('/api/aidop/s8/monitoring/qdc-summary', {
+				params: { tenantId: 1, factoryId: 1, ...params },
+			})
+			.then(unwrap),
 };

+ 8 - 2
Web/src/views/aidop/s8/monitoring/S8MonitoringOverviewPage.vue

@@ -23,6 +23,7 @@
 					<S8MonitoringModulesGrid
 						:cards="allStageCards"
 						:summary-cards="allSummaryCards"
+						:qdc-items="qdcItems"
 						:layout="draftLayout.modules"
 						:editable="editMode"
 						:row-height="60"
@@ -72,6 +73,7 @@ import {
 	type S8ModuleSummaryItem,
 	type S8MonitoringSummary,
 	type S8OrderGridData,
+	type S8QdcSummaryItem,
 } from '../api/s8MonitoringApi';
 import { deepClone, useS8Layout } from './useS8Layout';
 import type { S8LayoutSchema } from './useS8Layout';
@@ -365,7 +367,8 @@ const s9SummaryCard = computed<SummaryCardData>(() => {
 	};
 });
 
-const allSummaryCards = computed<SummaryCardData[]>(() => [s8SummaryCard.value, s9SummaryCard.value]);
+// S9 由 S9QdcSummaryCard 特判渲染,不再注入 summaryCards
+const allSummaryCards = computed<SummaryCardData[]>(() => [s8SummaryCard.value]);
 
 const deptCards = computed<DeptDisplay[]>(() => {
 	const source = gridData.byDept.length
@@ -585,14 +588,16 @@ const { period } = useS8PeriodFilter();
 
 const loadState = shallowRef<'idle' | 'loading' | 'ok' | 'error'>('idle');
 const loadError = shallowRef<string>('');
+const qdcItems = shallowRef<S8QdcSummaryItem[]>([]);
 
 async function loadData() {
 	loadState.value = 'loading';
 	loadError.value = '';
 	try {
-		const [summaryData, gridRaw] = await Promise.all([
+		const [summaryData, gridRaw, qdcRaw] = await Promise.all([
 			s8MonitoringApi.summary({ tenantId: 1, factoryId: 1, period: period.value }),
 			s8MonitoringApi.orderGrid({ period: period.value }),
+			s8MonitoringApi.qdcSummary({ period: period.value }),
 		]);
 		Object.assign(summary, summaryData);
 		Object.assign(gridData, {
@@ -600,6 +605,7 @@ async function loadData() {
 			byCategory: gridRaw.byCategory,
 			byDept: gridRaw.byDept,
 		});
+		qdcItems.value = qdcRaw.items;
 		loadState.value = 'ok';
 	} catch (err) {
 		console.error('[S8MonitoringOverviewPage] loadData failed:', err);

+ 10 - 1
Web/src/views/aidop/s8/monitoring/components/S8MonitoringModulesGrid.vue

@@ -26,8 +26,14 @@
         :min-h="2"
         :static="!editable"
       >
+        <S9QdcSummaryCard
+          v-if="item.i === 'S9' && qdcItems && qdcItems.length > 0"
+          :items="qdcItems"
+          :min-height="item.h * rowHeight - gap * 2"
+          style="width:100%;height:100%"
+        />
         <S8MonitoringStageCard
-          v-if="stageCardMap.get(item.i)"
+          v-else-if="stageCardMap.get(item.i)"
           v-bind="stageCardMap.get(item.i)!"
           :card-span="item.w"
           :min-height="item.h * rowHeight - gap * 2"
@@ -51,8 +57,10 @@ import { computed, nextTick, onActivated, onMounted, ref, watch } from 'vue'
 import { useResizeObserver } from '@vueuse/core'
 import S8MonitoringStageCard from './S8MonitoringStageCard.vue'
 import S8MonitoringSummaryCard, { type SummaryMetric } from './S8MonitoringSummaryCard.vue'
+import S9QdcSummaryCard from './S9QdcSummaryCard.vue'
 import type { ModuleGridItem } from '../useS8Layout'
 import type { Component } from 'vue'
+import type { S8QdcSummaryItem } from '../../api/s8MonitoringApi'
 
 interface StageCardData {
   code: string
@@ -98,6 +106,7 @@ export interface SummaryCardData {
 const props = defineProps<{
   cards: StageCardData[]
   summaryCards?: SummaryCardData[]
+  qdcItems?: S8QdcSummaryItem[]
   layout: ModuleGridItem[]
   editable: boolean
   rowHeight?: number

+ 154 - 0
Web/src/views/aidop/s8/monitoring/components/S9QdcSummaryCard.vue

@@ -0,0 +1,154 @@
+<script setup lang="ts">
+import { computed } from 'vue';
+import type { S8QdcSummaryItem } from '../../api/s8MonitoringApi';
+
+const props = defineProps<{
+	items: S8QdcSummaryItem[];
+	minHeight?: number;
+}>();
+
+const cardStyle = computed(() => ({
+	minHeight: `${Math.max(120, props.minHeight ?? 160)}px`,
+}));
+
+function fmtHours(val: number | null | undefined): string {
+	if (val === null || val === undefined) return '--';
+	return `${val.toFixed(1)}h`;
+}
+
+function fmtRate(val: number | null | undefined): string {
+	if (val === null || val === undefined) return '--';
+	return `${val.toFixed(0)}%`;
+}
+</script>
+
+<template>
+  <article class="s9-qdc-card" :style="cardStyle">
+    <div class="s9-qdc-card__header">
+      <span class="s9-qdc-card__code">S9</span>
+      <span class="s9-qdc-card__title">QDC 运营指标</span>
+      <span class="s9-qdc-card__sub">质量 / 交付 / 成本 / 库存</span>
+    </div>
+    <div class="s9-qdc-card__tracks">
+      <div v-for="item in items" :key="item.code" class="s9-qdc-track">
+        <div class="s9-qdc-track__name">{{ item.title }}</div>
+        <div class="s9-qdc-track__metrics">
+          <div class="s9-qdc-track__metric">
+            <span class="s9-qdc-track__metric-val">{{ item.total }}</span>
+            <span class="s9-qdc-track__metric-lbl">异常总数</span>
+          </div>
+          <div class="s9-qdc-track__metric">
+            <span class="s9-qdc-track__metric-val">{{ fmtHours(item.avgProcessHours) }}</span>
+            <span class="s9-qdc-track__metric-lbl">平均处理</span>
+          </div>
+          <div class="s9-qdc-track__metric">
+            <span class="s9-qdc-track__metric-val">{{ fmtRate(item.onTimeCloseRate) }}</span>
+            <span class="s9-qdc-track__metric-lbl">及时关闭率</span>
+          </div>
+          <div class="s9-qdc-track__metric">
+            <span class="s9-qdc-track__metric-val">{{ fmtRate(item.closeRate) }}</span>
+            <span class="s9-qdc-track__metric-lbl">总关闭率</span>
+          </div>
+        </div>
+        <div v-if="item.remark" class="s9-qdc-track__remark">{{ item.remark }}</div>
+      </div>
+    </div>
+  </article>
+</template>
+
+<style scoped>
+.s9-qdc-card {
+  position: relative;
+  overflow: hidden;
+  border-radius: 18px;
+  width: 100%;
+  height: 100%;
+  box-sizing: border-box;
+  padding: 14px 16px 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  background: rgba(22, 26, 36, 0.85);
+  border: 1px solid rgba(123, 208, 255, 0.14);
+}
+
+.s9-qdc-card__header {
+  display: flex;
+  align-items: baseline;
+  gap: 6px;
+  flex-shrink: 0;
+}
+
+.s9-qdc-card__code {
+  font-size: 11px;
+  font-weight: 700;
+  color: rgba(123, 208, 255, 0.7);
+  letter-spacing: 0.08em;
+}
+
+.s9-qdc-card__title {
+  font-size: 13px;
+  font-weight: 700;
+  color: #e1e2eb;
+}
+
+.s9-qdc-card__sub {
+  font-size: 10px;
+  color: #909097;
+  margin-left: 2px;
+}
+
+.s9-qdc-card__tracks {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 6px;
+  flex: 1;
+  min-height: 0;
+}
+
+.s9-qdc-track {
+  background: rgba(255, 255, 255, 0.03);
+  border: 1px solid rgba(255, 255, 255, 0.06);
+  border-radius: 10px;
+  padding: 7px 8px 6px;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.s9-qdc-track__name {
+  font-size: 11px;
+  font-weight: 600;
+  color: rgba(123, 208, 255, 0.75);
+}
+
+.s9-qdc-track__metrics {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 3px 6px;
+}
+
+.s9-qdc-track__metric {
+  display: flex;
+  flex-direction: column;
+  gap: 1px;
+}
+
+.s9-qdc-track__metric-val {
+  font-size: 13px;
+  font-weight: 700;
+  color: #c6c6cd;
+  line-height: 1.2;
+}
+
+.s9-qdc-track__metric-lbl {
+  font-size: 9px;
+  color: #909097;
+}
+
+.s9-qdc-track__remark {
+  font-size: 9px;
+  color: rgba(144, 144, 151, 0.55);
+  margin-top: 2px;
+}
+</style>

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

@@ -61,4 +61,11 @@ public class AdoS8MonitoringController : ControllerBase
     [HttpGet("domain-type-metrics")]
     public async Task<IActionResult> GetDomainTypeMetricsAsync(string domain, string window = "LAST_24H", string? period = null, long tenantId = 1, long factoryId = 1)
         => Ok(await _svc.GetDomainTypeMetricsAsync(domain, window, tenantId, factoryId, period));
+
+    /// <summary>
+    /// TASK-010:S9 卡片 QDC 四主线运营聚合(质量 / 交付 / 成本 / 库存)。
+    /// </summary>
+    [HttpGet("qdc-summary")]
+    public async Task<IActionResult> GetQdcSummaryAsync(long tenantId = 1, long factoryId = 1, string? period = null)
+        => Ok(await _svc.GetQdcSummaryAsync(tenantId, factoryId, period));
 }

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

@@ -255,6 +255,25 @@ public class AdoS8OrderGridDto
     public List<AdoS8DeptSummary> ByDept { get; set; } = new();
 }
 
+/// <summary>S9 QDC 四主线聚合单项</summary>
+public class AdoS8QdcSummaryItemDto
+{
+    /// <summary>主线码:QUALITY / DELIVERY / COST / INVENTORY</summary>
+    public string Code { get; set; } = string.Empty;
+    public string Title { get; set; } = string.Empty;
+    public int Total { get; set; }
+    public double? AvgProcessHours { get; set; }
+    public double? OnTimeCloseRate { get; set; }
+    public double? CloseRate { get; set; }
+    public string? Remark { get; set; }
+}
+
+/// <summary>S9 QDC 四主线聚合出参</summary>
+public class AdoS8QdcSummaryDto
+{
+    public List<AdoS8QdcSummaryItemDto> Items { get; set; } = new();
+}
+
 /// <summary>S8-DELIVERY-TREND-CHART-REPLACE-DUPLICATE-SECTION-1:Delivery 页近 N 日交付异常趋势出参。</summary>
 public class AdoS8DeliveryTrendDto
 {

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

@@ -207,6 +207,84 @@ public class S8MonitoringService : ITransient
         _         => 99,
     };
 
+    // ── QDC 常量映射(不建表,后端常量驱动) ──────────────────────────────────────
+    private static readonly (string Code, string Title, string[] Types, string? Remark)[] QdcGroups =
+    {
+        ("QUALITY",   "质量异常", new[] { "PURCHASE_QUALITY_ABNORMAL", "MFG_QUALITY_ABNORMAL", "PRODUCTION_QUALITY_ABNORMAL" }, null),
+        ("DELIVERY",  "交付异常", new[] { "ORDER_DELIVERY_DELAY_WARNING", "SUPPLIER_DELIVERY_DELAY_WARNING", "ORDER_CHANGE", "DELIVERY_DELAY", "PENDING_SHIPMENT" }, null),
+        ("INVENTORY", "库存异常", new[] { "MATERIAL_STOCK_ABNORMAL", "INVENTORY_TURNOVER_ABNORMAL", "INVENTORY_AMOUNT_LEVEL_ABNORMAL" }, null),
+        ("COST",      "成本异常", Array.Empty<string>(), "待定义成本异常口径"),
+    };
+
+    /// <summary>
+    /// S9 QDC 四主线运营聚合:质量 / 交付 / 成本 / 库存。
+    /// 成本异常当前无 enabled exception_type,返回空桶。
+    /// 及时关闭率依赖 sla_deadline,无 SLA 数据时返回 null。
+    /// </summary>
+    public async Task<AdoS8QdcSummaryDto> GetQdcSummaryAsync(long tenantId = 1, long factoryId = 1, string? period = null)
+    {
+        var (periodFrom, periodTo) = S8PeriodHelper.Resolve(period);
+
+        var events = await _rep.AsQueryable()
+            .Where(e => e.TenantId == tenantId && e.FactoryId == factoryId && !e.IsDeleted)
+            .WhereIF(periodFrom.HasValue, e => e.CreatedAt >= periodFrom!.Value)
+            .WhereIF(periodTo.HasValue,   e => e.CreatedAt < periodTo!.Value)
+            .Select(e => new
+            {
+                e.ExceptionTypeCode,
+                e.Status,
+                e.CreatedAt,
+                e.ClosedAt,
+                e.SlaDeadline,
+            })
+            .ToListAsync();
+
+        var items = new List<AdoS8QdcSummaryItemDto>();
+        foreach (var (code, title, types, remark) in QdcGroups)
+        {
+            if (types.Length == 0)
+            {
+                items.Add(new AdoS8QdcSummaryItemDto { Code = code, Title = title, Total = 0, Remark = remark });
+                continue;
+            }
+
+            var rows = events
+                .Where(e => e.ExceptionTypeCode != null && types.Contains(e.ExceptionTypeCode))
+                .ToList();
+            var total      = rows.Count;
+            var closedRows = rows.Where(e => e.Status == "CLOSED" && e.ClosedAt.HasValue).ToList();
+
+            double? avgHours = closedRows.Count == 0
+                ? null
+                : Math.Round(closedRows.Average(e => (e.ClosedAt!.Value - e.CreatedAt).TotalHours), 1);
+
+            double? closeRate = total == 0
+                ? null
+                : Math.Round(closedRows.Count * 100.0 / total, 1);
+
+            double? onTimeCloseRate = null;
+            if (closedRows.Count > 0)
+            {
+                var closedWithSla = closedRows.Where(e => e.SlaDeadline.HasValue).ToList();
+                if (closedWithSla.Count > 0)
+                    onTimeCloseRate = Math.Round(
+                        closedWithSla.Count(e => e.ClosedAt!.Value <= e.SlaDeadline!.Value) * 100.0 / closedRows.Count, 1);
+            }
+
+            items.Add(new AdoS8QdcSummaryItemDto
+            {
+                Code            = code,
+                Title           = title,
+                Total           = total,
+                AvgProcessHours = avgHours,
+                OnTimeCloseRate = onTimeCloseRate,
+                CloseRate       = closeRate,
+                Remark          = remark,
+            });
+        }
+        return new AdoS8QdcSummaryDto { Items = items };
+    }
+
     /// <summary>
     /// 异常监控汇总:按 module_code 分组统计红/黄/绿/超时数。
     /// 供综合全景页顶部徽标和模块汇总表使用。