fix(cma): my-dashboard KPI period caliber (monthly period priority) + as-of/stale marks

my_dashboard picked the row with max calculated_at, so batch backfills made stale periods win (revenue/net-profit showed 2026-07, rebate rate 2026-04). Now prefer the latest monthly period, fall back to calculated_at. Frontend adds data-as-of label and stale tag; not deployed yet (push gated by another pending commit).
This commit is contained in:
Hermes CI Fix
2026-09-18 15:39:12 +08:00
parent df2be301b3
commit d33b5e1ced
2 changed files with 60 additions and 5 deletions
+30 -3
View File
@@ -565,6 +565,35 @@ def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_ent
# ── 个人工作台 ──────────────────────────────
# KPIValue 取数口径(2026-09-18 工作台「我的KPI」期间缺口修正)
# 原实现(my_dashboard 内联)按 calculated_at 倒序取「最近写入」的一条:批量补录/复采会把
# **旧期间**的行写得更晚 → 卡片显示过期期间。实测 entity1:F_REVENUE/F_NET_PROFIT 的
# 2026-07 行写入时刻(2026-09-14 17:01:22, id=2128/2130)晚于 2026-08 行(17:00:30, id=2125/2127)
# → 取到 7 月;C_REBATE_RATE 1–4 月补录行 calculated_at 并列(2026-08-24 17:17:38) → 取到 4 月。
# 新口径:优先取「月期间 YYYY-MM」中最新的一期(月期是采集/录入的常规粒度);若该 KPI 只有
# H1/H2/FY 等汇总期行,则回退到原按 calculated_at 的取法(不改变无月期 KPI 的行为)。
# 注意:**不能**只按 period 字典序排 —— '2026-H1'/'2026-H2' 字典序大于 '2026-08',会把
# 季度汇总行/半年度预估值(forecast)当成"最新"。
_MONTH_PERIOD_REGEX = r"^[0-9]{4}-(0[1-9]|1[0-2])$"
def latest_kpi_value(db: Session, kpi_id: int) -> Optional[KPIValue]:
"""该 KPI 的最新可用值:月期间优先,无月期间行时回退 calculated_at 倒序。"""
monthly = (
db.query(KPIValue)
.filter(KPIValue.kpi_id == kpi_id, KPIValue.period.op("REGEXP")(_MONTH_PERIOD_REGEX))
.order_by(KPIValue.period.desc(), KPIValue.calculated_at.desc(), KPIValue.id.desc())
.first()
)
if monthly is not None:
return monthly
return (
db.query(KPIValue)
.filter(KPIValue.kpi_id == kpi_id)
.order_by(KPIValue.calculated_at.desc(), KPIValue.id.desc())
.first()
)
@router.get("/my-dashboard")
def my_dashboard(
@@ -613,9 +642,7 @@ def my_dashboard(
kpi_list = []
for k in all_kpis:
latest_v = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id
).order_by(KPIValue.calculated_at.desc()).first()
latest_v = latest_kpi_value(db, k.id)
actual = latest_v.actual_value if latest_v else None
target = kpi_target_by_frequency(k)
+30 -2
View File
@@ -56,6 +56,7 @@
<div class="section-header">
<span class="section-icon">📊</span>
<span class="section-title">我的KPI</span>
<span v-if="dataAsOf" class="section-asof">数据截至 {{ dataAsOf }}</span>
<span class="section-action" @click="$router.push('/kpis')">查看全部 →</span>
</div>
<div v-if="kpis.length === 0" class="empty-card">
@@ -70,10 +71,12 @@
<span class="kpi-tag" :class="'tag-' + k.level">{{ k.level === 'green' ? '达标' : k.level === 'yellow' ? '预警' : k.level === 'red' ? '危险' : '未评' }}</span>
</div>
<div class="kpi-value-row">
<div class="kpi-actual" :class="{ 'kpi-actual-empty': k.actual_value == null }">
<div class="kpi-actual" :class="{ 'kpi-actual-empty': k.actual_value == null, 'kpi-actual-stale': isStale(k) }">
{{ k.actual_value == null ? '暂无数据' : periodActual(k) }}
</div>
<div class="kpi-target">{{ targetLabel(k) }} {{ k.target_value ?? '-' }}</div>
<div class="kpi-target">
<span v-if="isStale(k)" class="kpi-stale-tag">非当期</span>{{ targetLabel(k) }} {{ k.target_value ?? '-' }}
</div>
</div>
<el-progress
:percentage="k.target_value ? Math.min(100, Math.round((k.actual_value || 0) / k.target_value * 100)) : 0"
@@ -174,6 +177,23 @@ function targetLabel(k: any) {
return '目标(月)'
}
// 2026-09-18 口径小改(工作台期间缺口):月期间正则 + 页面最新可得月
const MONTH_PERIOD_RE = /^\d{4}-(0[1-9]|1[0-2])$/
const latestMonth = computed(() => {
const months = kpis.value
.map((k: any) => String(k.period || ''))
.filter((p: string) => MONTH_PERIOD_RE.test(p))
.sort()
return months.length ? months[months.length - 1] : ''
})
// 页头「数据截至 X 月」= 本页各卡中最新的月期间(无月期间时不出该文案,不编造时点)
const dataAsOf = computed(() => (latestMonth.value ? fmtPeriod(latestMonth.value) : ''))
// 非当期 = 该卡的值期间早于页面最新可得月(仍保留原期间标签,只加灰标提示,不隐藏)
function isStale(k: any): boolean {
const p = String(k.period || '')
return MONTH_PERIOD_RE.test(p) && !!latestMonth.value && p < latestMonth.value
}
function planIcon(p: any) {
if (p.status === 'completed') return '✅'
if (p.overdue) return '🔴'
@@ -262,6 +282,8 @@ onMounted(async () => {
}
.section-action { font-size: 13px; color: #409eff; cursor: pointer; }
.section-action:hover { text-decoration: underline; }
/* 数据截至 X 月(2026-09-18):说明本页各卡的最新可得期间,不改渲染结构 */
.section-asof { font-size: 12px; color: #999; margin-right: 10px; }
.loading-wrap { padding: 40px; }
/* ── 空状态 ── */
@@ -310,6 +332,12 @@ onMounted(async () => {
.kpi-value-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 8px; }
.kpi-actual { font-size: 24px; font-weight: 700; }
.kpi-actual-empty { font-size: 15px; font-weight: 500; color: #999; }
/* 非当期灰标(2026-09-18):值期间落后于页面最新可得月时置灰,期间标签照旧显示 */
.kpi-actual-stale { color: #999; }
.kpi-stale-tag {
font-size: 11px; color: #999; background: #f0f0f0;
padding: 0 6px; border-radius: 4px; margin-right: 6px;
}
.kpi-target { font-size: 12px; color: #999; }
/* ── 改善行动 ── */