feat(h2-forecast): 实测区/预估区口径分离 —— 对外读路径默认只取实测区
ci/woodpecker/push/main Pipeline failed

- 新增 app/utils/kpi_value_scope.py(口径单一事实源:actual_only_clause / forecast_only_clause / is_forecast_row)
- dashboard.py: /kpis /my-kpis /finance-analysis /predict /my-dashboard / latest_kpi_value 默认剔预估,include_forecast=true 单列返回
- alert_rules.py: 动态阈值白名单去 estimated + 4 处读路径加实测区判据
- 前端 ForecastTag.vue 新增;Dashboard.vue / MyDashboard.vue 预估单列 + 标签(不与实测相加)

Task: finance-20261002-h2-forecast-separation-deploy-fullstack
This commit is contained in:
Hermes CI Fix
2026-10-02 23:33:15 +08:00
parent f32f6e7430
commit 5d7b3cc728
6 changed files with 399 additions and 29 deletions
+33
View File
@@ -0,0 +1,33 @@
<template>
<span class="forecast-tag" :title="title">{{ label }}</span>
</template>
<script setup lang="ts">
/**
* 预估标签(predefined「预估」标记)
*
* 口径:财务裁定 2026-10-02 —— 预估行(data_status='estimated' 或 source_type='forecast')
* 单列展示、带本标签;不得与实测相加、不得同线绘制、不得作预警依据。
* 无 emoji(前端护栏)。
*/
withDefaults(defineProps<{ label?: string; title?: string }>(), {
label: '预估',
title: '预估值:来源 data_status=estimated / source_type=forecast,单列展示,不计入实测汇总、不进趋势与预警',
})
</script>
<style scoped>
.forecast-tag {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border-radius: 3px;
font-size: 12px;
line-height: 18px;
white-space: nowrap;
color: #b88230;
background: #fdf6ec;
border: 1px solid #f5dab1;
}
</style>
+63 -3
View File
@@ -93,6 +93,11 @@
<span class="kpi-row-val">{{ fmtValue(k.actual_value, k.kpi_name) }}</span>
<span class="kpi-row-target">/ {{ k.target_value ?? '-' }}</span>
</div>
<!-- 预估区(单列,不与上面的实测值相加) -->
<div v-if="k.forecast_value != null" class="kpi-row-forecast">
<ForecastTag />
<span class="kpi-row-fc-val">{{ fmtValue(k.forecast_value, k.kpi_name) }}</span>
</div>
</div>
</div>
</div>
@@ -124,6 +129,18 @@
</div>
</div>
</div>
<!-- 预估区(单列展示:不与实测相加、不进趋势序列、不作预警依据) -->
<div v-if="forecastList.length > 0" class="right-card forecast-card">
<div class="right-card-title">预估区(不计入实测)</div>
<div class="forecast-note">以下为预估值(data_status=estimated / source_type=forecast),单列展示</div>
<div v-for="f in forecastList" :key="f.kpi_code + '|' + f.period" class="forecast-row">
<span class="forecast-name">{{ f.kpi_name }}</span>
<span class="forecast-period">{{ f.period }}</span>
<span class="forecast-val">{{ fmtValue(f.value, f.kpi_code) }}</span>
<ForecastTag />
</div>
</div>
</div>
</div>
@@ -149,6 +166,11 @@
<span class="item-unit">{{ k.unit }}</span>
</div>
<div class="card-item-target">目标 {{ k.target_value ?? '-' }}</div>
<!-- 预估区(单列,不与上面的实测值相加) -->
<div v-if="k.forecast_value != null" class="card-item-forecast">
<ForecastTag />
<span class="card-item-fc-val">{{ fmtValue(k.forecast_value, k.kpi_name) }}</span>
</div>
<div class="card-item-trend" v-if="k.trend && k.trend.length >= 2">
<span v-for="(t, i) in k.trend.slice(-3)" :key="i" class="tdot" :style="{ background: trendColor(t.value, k) }"></span>
<span class="trend-hint">近3月</span>
@@ -185,6 +207,21 @@
<div class="tbar-label">{{ t.period.slice(-2) }}</div>
</div>
</div>
<!-- 预估区(单列;不进上方趋势条,趋势序列仅实测区) -->
<div v-if="k.forecast_value != null" class="card-item-forecast">
<ForecastTag />
<span class="card-item-fc-val">{{ fmtValue(k.forecast_value, k.kpi_code) }}</span>
</div>
</div>
</div>
<!-- 预估区(单列展示:不与实测相加、不进趋势序列) -->
<div v-if="forecastList.length > 0" class="forecast-block">
<div class="forecast-block-title">预估区(不计入实测)</div>
<div v-for="f in forecastList" :key="f.kpi_code + '|' + f.period" class="forecast-row">
<span class="forecast-name">{{ f.kpi_name }}</span>
<span class="forecast-period">{{ f.period }}</span>
<span class="forecast-val">{{ fmtValue(f.value, f.kpi_code) }}</span>
<ForecastTag />
</div>
</div>
</template>
@@ -277,6 +314,8 @@ import CountUp from '../components/CountUp.vue'
import GrowthQuality from '../components/GrowthQuality.vue'
import BEPBreakEvenCard from '../components/BEPBreakEvenCard.vue'
import MyDialog from '../components/MyDialog.vue'
// 预估区标签(财务裁定 2026-10-02:预估行单列展示,不与实测相加/同线)
import ForecastTag from '../components/ForecastTag.vue'
const router = useRouter()
@@ -327,6 +366,8 @@ const yellowKpis = computed(() => kpis.value.filter((k: any) => k.alert_level ==
const myKpis = ref<any[]>([])
const finKpis = ref<any[]>([])
const finSummary = ref<any>({})
// 预估区(单列,不计入实测汇总;财务裁定 2026-10-02)
const forecastList = ref<any[]>([])
const enabledChannels = ref(0)
const aiAnalysis = ref('')
const aiLoading = ref(false)
@@ -456,25 +497,29 @@ async function loadData() {
try {
const entityId = Number(localStorage.getItem('cma_entity_id') || 1)
const params = { ...getPeriodParams(), entity_id: entityId }
// include_forecast=true:预估行**单列返回**(forecast 字段),实测取值不受影响
if (userRole.value === 'ceo') {
const [kpiRes, alertRes, sumRes, predRes] = await Promise.all([
dashboardApi.kpis({ role: 'ceo', ...params }),
dashboardApi.kpis({ role: 'ceo', ...params, include_forecast: true }),
alertApi.list({ limit: 10, entity_id: entityId }),
dashboardApi.summary({ role: 'ceo', ...params }),
dashboardApi.predict({ entity_id: entityId }).catch(() => ({ data: [] })),
])
kpis.value = (kpiRes as any).data || []
forecastList.value = (kpiRes as any).forecast || []
alerts.value = (alertRes as any).data?.filter((a: any) => a.status === 'pending') || []
summary.value = { kpi_total: (sumRes as any).kpi_total || 0, alert_count: (sumRes as any).alert_count || 0 }
predictions.value = (predRes as any)?.predictions || []
dataRange.value = (kpiRes as any).range?.start + ' ~ ' + (kpiRes as any).range?.end
} else if (userRole.value === 'business') {
const r: any = await dashboardApi.myKpis?.({ ...params })
const r: any = await dashboardApi.myKpis?.({ ...params, include_forecast: true })
myKpis.value = (r as any)?.data || []
forecastList.value = (r as any)?.forecast || []
dataRange.value = (r as any)?.period || ''
} else if (userRole.value === 'finance') {
const r: any = await dashboardApi.financeAnalysis?.({ ...params })
const r: any = await dashboardApi.financeAnalysis?.({ ...params, include_forecast: true })
finKpis.value = (r as any)?.kpis || []
forecastList.value = (r as any)?.forecast || []
finSummary.value = (r as any)?.summary || {}
dataRange.value = (r as any)?.period || ''
} else if (userRole.value === 'it') {
@@ -546,6 +591,9 @@ onMounted(() => { loadData() })
.kpi-row-btm { display:flex; align-items:baseline; gap:4px; }
.kpi-row-val { font-size:18px; font-weight:700; color:#1a1a2e; }
.kpi-row-target { font-size:12px; color:#aaa; }
/* 预估区(单列,不与实测值相加;财务裁定 2026-10-02) */
.kpi-row-forecast { display:flex; align-items:center; gap:4px; margin-top:2px; }
.kpi-row-fc-val { font-size:12px; color:#b88230; }
/* 右侧预测 */
.right-card { background:#fff; border-radius:10px; padding:16px; box-shadow:0 1px 3px rgba(0,0,0,0.05); border:1px solid #f0f0f0; }
@@ -581,6 +629,18 @@ onMounted(() => { loadData() })
.card-item-val { font-size:26px; font-weight:700; margin-bottom:4px; }
.item-unit { font-size:13px; color:#999; font-weight:400; margin-left:4px; }
.card-item-target { font-size:12px; color:#aaa; margin-bottom:6px; }
/* 预估区(单列:卡片内独立一行 + 「预估」标签,不与实测相加/同线) */
.card-item-forecast { display:flex; align-items:center; gap:4px; margin-top:2px; }
.card-item-fc-val { font-size:12px; color:#b88230; }
.forecast-card { margin-top:16px; }
.forecast-note { font-size:12px; color:#999; margin:-8px 0 10px; }
.forecast-block { margin-top:16px; background:#fff; border:1px solid #f0f0f0; border-radius:10px; padding:16px; box-shadow:0 1px 3px rgba(0,0,0,0.05); }
.forecast-block-title { font-size:15px; font-weight:600; margin-bottom:10px; }
.forecast-row { display:flex; align-items:center; gap:8px; padding:6px 0; border-top:1px dashed #f2f2f2; }
.forecast-row:first-of-type { border-top:none; }
.forecast-name { flex:1; font-size:13px; color:#333; }
.forecast-period { font-size:12px; color:#999; }
.forecast-val { font-size:13px; font-weight:600; color:#b88230; }
.card-item-trend { display:flex; align-items:center; gap:4px; }
.tdot { width:8px; height:8px; border-radius:50%; display:inline-block; }
.trend-hint { font-size:10px; color:#ccc; margin-left:2px; }
+15 -1
View File
@@ -87,6 +87,11 @@
:show-text="false"
/>
<div v-else style="height:6px;line-height:6px;color:#c0c4cc;font-size:11px">—</div>
<!-- 预估区(单列,不与上方实测值相加;财务裁定 2026-10-02) -->
<div v-if="k.forecast_value != null" class="kpi-forecast-row">
<ForecastTag />
<span class="kpi-forecast-val">{{ k.forecast_value }}</span>
</div>
</div>
</div>
</div>
@@ -128,6 +133,8 @@
import { ref, computed, onMounted } from "vue"
import { useRouter } from "vue-router"
import api from "../api/index"
// 预估区标签(财务裁定 2026-10-02:预估行单列展示,不与实测相加/同线)
import ForecastTag from "../components/ForecastTag.vue"
// 排序固化: danger(逾期行动/红色预警) 在前, warning(黄色预警) 在后;
// 同 severity 保持后端生成顺序 (overdue→red→yellow), 不改变现有展示次序
@@ -146,6 +153,8 @@ const loading = ref(true)
const kpis = ref<any[]>([])
const actionPlans = ref<any[]>([])
const reminders = ref<any[]>([])
// 预估区(单列,不计入卡片实测值;财务裁定 2026-10-02)
const forecastList = ref<any[]>([])
const todayStr = new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })
function fmtValue(val: any, code: string) {
@@ -224,8 +233,10 @@ function goToDetail(r: any) {
onMounted(async () => {
try {
const r: any = await api.get('/dashboard/my-dashboard')
// include_forecast=true:预估行**单列返回**(forecast 字段),卡片实测值不受影响
const r: any = await api.get('/dashboard/my-dashboard', { params: { include_forecast: true } })
kpis.value = r.kpis || []
forecastList.value = r.forecast || []
actionPlans.value = r.action_plans || []
reminders.value = r.reminders || []
} catch (e: any) {
@@ -342,6 +353,9 @@ onMounted(async () => {
padding: 0 6px; border-radius: 4px; margin-right: 6px;
}
.kpi-target { font-size: 12px; color: #999; }
/* 预估区(单列一行 + 「预估」标签,不与卡片实测值相加) */
.kpi-forecast-row { display: flex; align-items: center; gap: 4px; margin-top: 6px; }
.kpi-forecast-val { font-size: 12px; color: #b88230; }
/* ── 改善行动 ── */
.plans-section { margin-bottom: 28px; }