feat: KPI多粒度目标智能派生 — 基准值+按类型派生+手动覆盖

- 后端: 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
This commit is contained in:
Hermes CI Fix
2026-08-21 10:59:43 +08:00
parent d5edcefb48
commit b06821a426
4 changed files with 214 additions and 5 deletions
+107
View File
@@ -488,6 +488,103 @@ def _validate_kpi_data(data: dict, db: Session, current_kpi_id: Optional[int] =
return kpi_issues_message(issues) 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=Falseupdate场景):仅当用户显式传了空值时推断,未传则保留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("") @router.post("")
def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES): 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) errs = _validate_kpi_data(data, db=db, is_update=False)
if errs: if errs:
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs}) raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
data = apply_calc_type_inference(data)
kpi = KPIDefinition(**data) kpi = KPIDefinition(**data)
db.add(kpi) db.add(kpi)
db.commit() 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) errs = _validate_kpi_data(data, db=db, current_kpi_id=kpi_id, is_update=True)
if errs: if errs:
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs}) raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
data = apply_calc_type_inference(data, infer_missing=False)
for k, v in data.items(): for k, v in data.items():
if hasattr(kpi, k) and v is not None: if hasattr(kpi, k) and v is not None:
setattr(kpi, k, v) 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): def kpi_to_dict(k):
d = {c.name: getattr(k, c.name) for c in k.__table__.columns} 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: if k.map_id:
from app.database import get_session_local from app.database import get_session_local
+1
View File
@@ -79,6 +79,7 @@ class KPIDefinition(Base):
target_monthly = Column(Float, nullable=True, comment="月度目标值") target_monthly = Column(Float, nullable=True, comment="月度目标值")
target_quarterly = Column(Float, nullable=True, comment="季度目标值") target_quarterly = Column(Float, nullable=True, comment="季度目标值")
target_yearly = 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_green = Column(String(100), nullable=True, comment="绿灯阈值")
threshold_yellow = Column(String(100), nullable=True, comment="黄灯阈值") threshold_yellow = Column(String(100), nullable=True, comment="黄灯阈值")
threshold_red = Column(String(100), nullable=True, comment="红灯阈值") threshold_red = Column(String(100), nullable=True, comment="红灯阈值")
+57 -3
View File
@@ -35,9 +35,21 @@
<el-tag v-else type="warning" size="small">{{ kpi.data_owner || '缺失' }}</el-tag> <el-tag v-else type="warning" size="small">{{ kpi.data_owner || '缺失' }}</el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="单位">{{ kpi.unit || '-' }}</el-descriptions-item> <el-descriptions-item label="单位">{{ kpi.unit || '-' }}</el-descriptions-item>
<el-descriptions-item label="目标值(月)">{{ kpi.target_monthly ?? '-' }}</el-descriptions-item> <el-descriptions-item label="目标值(月)">
<el-descriptions-item label="目标值(季)">{{ kpi.target_quarterly ?? '-' }}</el-descriptions-item> {{ fmtDerived(kpi, 'monthly') }}
<el-descriptions-item label="目标值(年)">{{ kpi.target_yearly ?? '-' }}</el-descriptions-item> <el-tag v-if="isDerived(kpi, 'monthly')" size="small" type="info" effect="plain">自动</el-tag>
</el-descriptions-item>
<el-descriptions-item label="目标值(季)">
{{ fmtDerived(kpi, 'quarterly') }}
<el-tag v-if="isDerived(kpi, 'quarterly')" size="small" type="info" effect="plain">自动</el-tag>
</el-descriptions-item>
<el-descriptions-item label="目标值(年)">
{{ fmtDerived(kpi, 'yearly') }}
<el-tag v-if="isDerived(kpi, 'yearly')" size="small" type="info" effect="plain">自动</el-tag>
</el-descriptions-item>
<el-descriptions-item label="指标类型">
{{ kpi.target_calc_type === 'accumulate' ? '累计型' : kpi.target_calc_type === 'ratio' ? '比率型' : (kpi.target_calc_type || '-') }}
</el-descriptions-item>
<el-descriptions-item label="数据源类型">{{ kpi.data_source_type || '-' }}</el-descriptions-item> <el-descriptions-item label="数据源类型">{{ kpi.data_source_type || '-' }}</el-descriptions-item>
<el-descriptions-item label="维度">{{ dimLabel(kpi.dimension) }}</el-descriptions-item> <el-descriptions-item label="维度">{{ dimLabel(kpi.dimension) }}</el-descriptions-item>
<el-descriptions-item label="频率">{{ kpi.frequency || '-' }}</el-descriptions-item> <el-descriptions-item label="频率">{{ kpi.frequency || '-' }}</el-descriptions-item>
@@ -68,6 +80,12 @@
<el-form-item label="目标值(月度)"><el-input-number v-model="kpi.target_monthly" :min="0" style="width:100%" /></el-form-item> <el-form-item label="目标值(月度)"><el-input-number v-model="kpi.target_monthly" :min="0" style="width:100%" /></el-form-item>
<el-form-item label="目标值(季度)"><el-input-number v-model="kpi.target_quarterly" :min="0" style="width:100%" /></el-form-item> <el-form-item label="目标值(季度)"><el-input-number v-model="kpi.target_quarterly" :min="0" style="width:100%" /></el-form-item>
<el-form-item label="目标值(年度)"><el-input-number v-model="kpi.target_yearly" :min="0" style="width:100%" /></el-form-item> <el-form-item label="目标值(年度)"><el-input-number v-model="kpi.target_yearly" :min="0" style="width:100%" /></el-form-item>
<el-form-item label="指标类型">
<el-select v-model="kpi.target_calc_type" style="width:100%">
<el-option label="累计型(月×3=季,可乘)" value="accumulate" />
<el-option label="比率型(季沿用月,不可乘)" value="ratio" />
</el-select>
</el-form-item>
<el-form-item label="单位"><el-input v-model="kpi.unit" /></el-form-item> <el-form-item label="单位"><el-input v-model="kpi.unit" /></el-form-item>
<el-form-item label="频率"> <el-form-item label="频率">
<el-select v-model="kpi.frequency" style="width:100%"> <el-select v-model="kpi.frequency" style="width:100%">
@@ -385,6 +403,42 @@ const userOptions = computed(() => {
function dimLabel(d: string) { return ({ finance: '财务', customer: '客户', process: '流程', learning: '学习' } as any)[d] || d } function dimLabel(d: string) { return ({ finance: '财务', customer: '客户', process: '流程', learning: '学习' } as any)[d] || d }
function catLabel(c: string) { return CAT_MAP[c] || c } 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] || '' } function dimTagType(d: string) { return ({ finance: '', customer: 'success', process: 'warning', learning: 'info' } as any)[d] || '' }
const metadataComplete = computed(() => { const metadataComplete = computed(() => {
+49 -2
View File
@@ -231,12 +231,18 @@
<el-form-item label="目标值()"><el-input-number v-model="form.target_monthly" :min="0" style="width:100%" /></el-form-item> <el-form-item label="目标值()"><el-input-number v-model="form.target_monthly" :min="0" style="width:100%" /></el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="目标值()"><el-input-number v-model="form.target_quarterly" :min="0" style="width:100%" /></el-form-item> <el-form-item label="目标值()">
<el-input-number v-model="form.target_quarterly" :min="0" style="width:100%" />
<div v-if="derivedPreview.quarterly !== null" class="derive-hint">↳ 自动={{ derivedPreview.quarterly }}</div>
</el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="目标值()"><el-input-number v-model="form.target_yearly" :min="0" style="width:100%" /></el-form-item> <el-form-item label="目标值()">
<el-input-number v-model="form.target_yearly" :min="0" style="width:100%" />
<div v-if="derivedPreview.yearly !== null" class="derive-hint">↳ 自动={{ derivedPreview.yearly }}</div>
</el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="单位"><el-input v-model="form.unit" placeholder="%, , , ..." /></el-form-item> <el-form-item label="单位"><el-input v-model="form.unit" placeholder="%, , , ..." /></el-form-item>
@@ -254,6 +260,16 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12">
<el-form-item label="指标类型">
<el-select v-model="form.target_calc_type" style="width:100%">
<el-option label="累计型×3=可乘" value="accumulate" />
<el-option label="比率型季沿用月不可乘" value="ratio" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="数据源"> <el-form-item label="数据源">
<el-select v-model="form.data_source_type" style="width:100%"> <el-select v-model="form.data_source_type" style="width:100%">
@@ -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) { function openEdit(row: any) {
editMode.value = true editMode.value = true
@@ -518,6 +558,7 @@ function openEdit(row: any) {
threshold_yellow: row.threshold_yellow, threshold_yellow: row.threshold_yellow,
threshold_red: row.threshold_red, threshold_red: row.threshold_red,
epic: row.epic, epic: row.epic,
target_calc_type: row.target_calc_type,
} }
showForm.value = true showForm.value = true
} }
@@ -952,6 +993,12 @@ onMounted(() => {
color: #E6A23C; color: #E6A23C;
font-size: 12px; font-size: 12px;
} }
.derive-hint {
font-size: 12px;
color: #409EFF;
margin-top: 2px;
line-height: 1.4;
}
.page-header { .page-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;