From b06821a42674c801046d633c51c01eb2686b92dc Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Fri, 21 Aug 2026 10:59:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20KPI=E5=A4=9A=E7=B2=92=E5=BA=A6=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E6=99=BA=E8=83=BD=E6=B4=BE=E7=94=9F=20=E2=80=94=20?= =?UTF-8?q?=E5=9F=BA=E5=87=86=E5=80=BC+=E6=8C=89=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E6=B4=BE=E7=94=9F+=E6=89=8B=E5=8A=A8=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: target_calc_type字段(accumulate累计/ratio比率) + infer_calc_type名称/单位推断 - 派生规则: 累计型 月×3=季×12=年(季×4=年); 比率型 季/年沿用基准不可乘 - 虚拟派生不落库: kpi_to_dict返回derived_targets+derived_flags(自动标记) - DB: 302个KPI回填类型(226累计/76比率) - 前端KPIList: 指标类型选择 + 季/年自动派生预览(↳自动=N) - 前端KPIDetail: 元数据卡片自动标记 + 编辑表单指标类型 - 回归: pytest 451 passed --- backend/app/api/kpis.py | 107 +++++++++++++++++++++++++++++++ backend/app/models/__init__.py | 1 + frontend/src/views/KPIDetail.vue | 60 ++++++++++++++++- frontend/src/views/KPIList.vue | 51 ++++++++++++++- 4 files changed, 214 insertions(+), 5 deletions(-) diff --git a/backend/app/api/kpis.py b/backend/app/api/kpis.py index 0bb6b3ed..2df7ad24 100644 --- a/backend/app/api/kpis.py +++ b/backend/app/api/kpis.py @@ -488,6 +488,103 @@ def _validate_kpi_data(data: dict, db: Session, current_kpi_id: Optional[int] = return kpi_issues_message(issues) +# ════════════════════════════════════════════════════════════ +# KPI多粒度目标:指标类型推断 + 周期目标派生(docs/kpi-design-rule.md 落地) +# 规则:累计型 月×3=季、月×12=年(季×4=年);比率型 季/年沿用基准(可手调) +# 派生为"虚拟展示值":DB只存用户手填真值,API返回时补派生值+derived标记 +# ════════════════════════════════════════════════════════════ +RATIO_NAME_HINTS = ['率', '比', '满意度', '周转', '时长', '周期', '天数', '指数', 'NPS', 'LTV', 'CAC', + '份额', '集中度', '响应', '完成', '达成', '人均', '单价', '净推荐', '覆盖', '保留', + '复购', '转介绍', '投诉', '合规', '认证', '掌握', '胜任', '认知', '采纳', '引用', + '复用', '一致性', '准确', '间隙', '时效', '及时'] +ACCUM_NAME_HINTS = ['营收', '收入', '利润', '净利', '销售', '客户数', '新客', '新增', '产量', '销量', + '金额', '现金流', '回款', '毛利额', '产值', '储备', '数量', '篇数', '报告产出', + '提案', '发现数', '知识沉淀', '招待费'] +RATIO_UNIT_HINTS = ['%', '倍', '天', '分', '小时', '分钟'] +ACCUM_UNIT_HINTS = ['万元', '元', '个', '件', '人', '篇', '份', '万'] + + +def infer_calc_type(kpi_code: str = "", kpi_name: str = "", unit: str = "") -> str: + """推断指标类型: accumulate累计(可乘) / ratio比率(不可乘)。名称关键词优先于单位""" + n = (kpi_name or "") + " " + (kpi_code or "") + u = unit or "" + if any(k in n for k in RATIO_NAME_HINTS): + return "ratio" + if any(k in n for k in ACCUM_NAME_HINTS): + return "accumulate" + if u in RATIO_UNIT_HINTS or u.startswith("小时"): + return "ratio" + if u in ACCUM_UNIT_HINTS: + return "accumulate" + return "ratio" # 兜底比率(率值不能乘,更安全) + + +def derive_cycle_targets(kpi) -> dict: + """按指标类型派生月/季/年目标(虚拟值,不落库)。 + 返回: {"derived": {monthly/quarterly/yearly: 显示值}, "flags": {monthly/quarterly/yearly: 是否派生}} + """ + calc_type = (getattr(kpi, "target_calc_type", None) or infer_calc_type( + kpi.kpi_code or "", kpi.kpi_name or "", kpi.unit or "")).lower() + m = kpi.target_monthly + q = kpi.target_quarterly + y = kpi.target_yearly + freq = (kpi.frequency or "monthly").lower() + + # 基准值(考核周期优先,回退 target_value) + base = None + if freq == "yearly": + base = y + elif freq in ("quarterly", "half_year"): + base = q + elif freq in ("monthly", "weekly"): + base = m + if base is None: + base = kpi.target_value + # 无基准值则不派生 + if base is None: + return {"derived": {"monthly": m, "quarterly": q, "yearly": y}, + "flags": {"monthly": False, "quarterly": False, "yearly": False}} + + dm, dq, dy = m, q, y + fm, fq, fy = False, False, False + if calc_type == "accumulate": + # 锚点月值:手填月目标优先;月基准且手填月空时用 target_value 回退 + anchor_m = dm + if anchor_m is None and base is not None and freq in ("monthly", "weekly"): + anchor_m = base + if anchor_m is not None: + if dm is None: + dm = anchor_m # target_value 回退显示为月基准 + if dq is None: + dq, fq = anchor_m * 3, True + if dy is None: + dy, fy = anchor_m * 12, True + elif dq is not None: + # 季基准(累计型):年=季×4;月不反推(避免小数噪声) + if dy is None: + dy, fy = dq * 4, True + else: # ratio:季/年沿用基准,不乘 + if dq is None: + dq, fq = base, True + if dy is None: + dy, fy = base, True + return {"derived": {"monthly": dm, "quarterly": dq, "yearly": dy}, + "flags": {"monthly": fm, "quarterly": fq, "yearly": fy}} + + +def apply_calc_type_inference(data: dict, infer_missing: bool = True) -> dict: + """create/update 前:未显式传 target_calc_type 时按名称/单位推断。 + infer_missing=False(update场景):仅当用户显式传了空值时推断,未传则保留DB原值""" + if "target_calc_type" in data: + if not data.get("target_calc_type"): + data["target_calc_type"] = infer_calc_type( + data.get("kpi_code", ""), data.get("kpi_name", ""), data.get("unit", "")) + elif infer_missing: + data["target_calc_type"] = infer_calc_type( + data.get("kpi_code", ""), data.get("kpi_name", ""), data.get("unit", "")) + return data + + @router.post("") def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES): # 检查编码唯一性 @@ -498,6 +595,7 @@ def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES): errs = _validate_kpi_data(data, db=db, is_update=False) if errs: raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs}) + data = apply_calc_type_inference(data) kpi = KPIDefinition(**data) db.add(kpi) db.commit() @@ -515,6 +613,7 @@ def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRIT errs = _validate_kpi_data(data, db=db, current_kpi_id=kpi_id, is_update=True) if errs: raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs}) + data = apply_calc_type_inference(data, infer_missing=False) for k, v in data.items(): if hasattr(kpi, k) and v is not None: setattr(kpi, k, v) @@ -543,6 +642,14 @@ def restore_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES): def kpi_to_dict(k): d = {c.name: getattr(k, c.name) for c in k.__table__.columns} + # 多粒度目标派生:月/季/年显示值 + derived标记(虚拟,不落库) + try: + der = derive_cycle_targets(k) + d["derived_targets"] = der["derived"] + d["derived_flags"] = der["flags"] + except Exception: + d["derived_targets"] = {"monthly": k.target_monthly, "quarterly": k.target_quarterly, "yearly": k.target_yearly} + d["derived_flags"] = {"monthly": False, "quarterly": False, "yearly": False} # 附加战略地图信息 if k.map_id: from app.database import get_session_local diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 4314369b..81447ba8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -79,6 +79,7 @@ class KPIDefinition(Base): target_monthly = Column(Float, nullable=True, comment="月度目标值") target_quarterly = Column(Float, nullable=True, comment="季度目标值") target_yearly = Column(Float, nullable=True, comment="年度目标值") + target_calc_type = Column(String(20), nullable=True, comment="指标类型: accumulate累计(月×3=季,×12=年) / ratio比率(季/年沿用基准,可手调)") threshold_green = Column(String(100), nullable=True, comment="绿灯阈值") threshold_yellow = Column(String(100), nullable=True, comment="黄灯阈值") threshold_red = Column(String(100), nullable=True, comment="红灯阈值") diff --git a/frontend/src/views/KPIDetail.vue b/frontend/src/views/KPIDetail.vue index 224f1368..24387383 100644 --- a/frontend/src/views/KPIDetail.vue +++ b/frontend/src/views/KPIDetail.vue @@ -35,9 +35,21 @@ {{ kpi.data_owner || '缺失' }} {{ kpi.unit || '-' }} - {{ kpi.target_monthly ?? '-' }} - {{ kpi.target_quarterly ?? '-' }} - {{ kpi.target_yearly ?? '-' }} + + {{ fmtDerived(kpi, 'monthly') }} + 自动 + + + {{ fmtDerived(kpi, 'quarterly') }} + 自动 + + + {{ fmtDerived(kpi, 'yearly') }} + 自动 + + + {{ kpi.target_calc_type === 'accumulate' ? '累计型' : kpi.target_calc_type === 'ratio' ? '比率型' : (kpi.target_calc_type || '-') }} + {{ kpi.data_source_type || '-' }} {{ dimLabel(kpi.dimension) }} {{ kpi.frequency || '-' }} @@ -68,6 +80,12 @@ + + + + + + @@ -385,6 +403,42 @@ const userOptions = computed(() => { function dimLabel(d: string) { return ({ finance: '财务', customer: '客户', process: '流程', learning: '学习' } as any)[d] || d } function catLabel(c: string) { return CAT_MAP[c] || c } + +// ── 多粒度目标派生显示(与后端 derive_cycle_targets 一致)── +function derivedFor(k: any): { monthly: number | null; quarterly: number | null; yearly: number | null; flags: any } { + if (k?.derived_targets) return { ...k.derived_targets, flags: k.derived_flags || {} } + // 本地兜底(旧数据无 derived_targets 时) + const f = k || {} + const type = f.target_calc_type || 'ratio' + const freq = f.frequency || 'monthly' + let base: number | null = null + if (freq === 'yearly') base = f.target_yearly + else if (freq === 'quarterly' || freq === 'half_year') base = f.target_quarterly + else base = f.target_monthly + if (base == null) base = f.target_value + const flags = { monthly: false, quarterly: false, yearly: false } + let q: number | null = f.target_quarterly + let y: number | null = f.target_yearly + if (base != null) { + if (type === 'accumulate') { + const m: number = f.target_monthly ?? base + if (q == null) { q = m * 3; flags.quarterly = true } + if (y == null) { y = m * 12; flags.yearly = true } + } else { + if (q == null) { q = base; flags.quarterly = true } + if (y == null) { y = base; flags.yearly = true } + } + } + return { monthly: f.target_monthly, quarterly: q, yearly: y, flags } +} +function fmtDerived(k: any, cycle: 'monthly' | 'quarterly' | 'yearly') { + const d = derivedFor(k) + const v = d[cycle] + return v == null ? '-' : String(v) +} +function isDerived(k: any, cycle: 'monthly' | 'quarterly' | 'yearly') { + return !!derivedFor(k).flags?.[cycle] +} function dimTagType(d: string) { return ({ finance: '', customer: 'success', process: 'warning', learning: 'info' } as any)[d] || '' } const metadataComplete = computed(() => { diff --git a/frontend/src/views/KPIList.vue b/frontend/src/views/KPIList.vue index 23f928bd..1b38bd3c 100644 --- a/frontend/src/views/KPIList.vue +++ b/frontend/src/views/KPIList.vue @@ -231,12 +231,18 @@ - + + +
↳ 自动={{ derivedPreview.quarterly }}
+
- + + +
↳ 自动={{ derivedPreview.yearly }}
+
@@ -254,6 +260,16 @@
+ + + + + + + + + + @@ -495,6 +511,30 @@ const designChecks = computed(() => { ] }) +// ── 多粒度目标派生预览(填基准值后季/年自动提示,与后端 derive_cycle_targets 一致)── +const derivedPreview = computed(() => { + const f = form.value || {} + const type = f.target_calc_type || 'ratio' + const freq = f.frequency || 'monthly' + let base: number | null = null + if (freq === 'yearly') base = f.target_yearly + else if (freq === 'quarterly' || freq === 'half_year') base = f.target_quarterly + else base = f.target_monthly + if (base == null) base = f.target_value + if (base == null) return { quarterly: null, yearly: null } + let q: number | null = f.target_quarterly + let y: number | null = f.target_yearly + if (type === 'accumulate') { + const m: number = f.target_monthly ?? base + if (q == null) q = m * 3 + if (y == null) y = m * 12 + } else { + if (q == null) q = base + if (y == null) y = base + } + return { quarterly: f.target_quarterly == null ? q : null, yearly: f.target_yearly == null ? y : null } +}) + // ── 编辑入口:列表行 → 弹窗编辑(与新建一致)── function openEdit(row: any) { editMode.value = true @@ -518,6 +558,7 @@ function openEdit(row: any) { threshold_yellow: row.threshold_yellow, threshold_red: row.threshold_red, epic: row.epic, + target_calc_type: row.target_calc_type, } showForm.value = true } @@ -952,6 +993,12 @@ onMounted(() => { color: #E6A23C; font-size: 12px; } +.derive-hint { + font-size: 12px; + color: #409EFF; + margin-top: 2px; + line-height: 1.4; +} .page-header { display: flex; justify-content: space-between;