fix(cma): KR 名称污染存量清理 + 透传点护栏 (cma-kr-name-pollution-cleanup-20260916)
ci/woodpecker/push/main Pipeline was canceled
ci/woodpecker/push/main Pipeline was canceled
- kr_name.py/krName.ts: sanitize_kr_name 判据(尾部≥/≤数字≠目标值→截断;==目标值→保留) - maps.py/okr.py/okr_templates.py: 模板名/JSON→krs.title 透传点护栏 - test: 28 passed(独立复核) - 原会话因迭代上限停手,本提交代收口(工作已复核完成)
This commit is contained in:
@@ -7,7 +7,11 @@ from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_auth, require_role
|
||||
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition, ReviewRecord, User
|
||||
from app.utils.kr_name import sanitize_kr_name
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
|
||||
dependencies=[Depends(require_role("ceo", "finance"))],
|
||||
@@ -481,6 +485,16 @@ def _sync_objectives_krs(m, db):
|
||||
if kpi and kpi.threshold_green:
|
||||
op2, tgt2 = _parse_kr_operator_target(kpi.threshold_green)
|
||||
op, tgt = op2, (tgt2 if tgt is None else tgt)
|
||||
# 护栏(2026-09-16):名称尾部「≥/≤+数字」与目标值不一致 = 历史污染截断值,
|
||||
# 在此透传点(JSON/模板名 → krs.title)规整,防止污染继续传染(判据见 app/utils/kr_name.py)
|
||||
clean_title, _polluted = sanitize_kr_name(title, kr_item.get("target_value"), tgt)
|
||||
if _polluted:
|
||||
logger.warning(
|
||||
"KR名称污染已规整: map=%s objective=%s %r -> %r (target=%s)",
|
||||
m.id, objective.id, title, clean_title, tgt,
|
||||
)
|
||||
title = clean_title
|
||||
kr_item["name"] = clean_title # 同步回写 JSON,保持展示口径=落库口径
|
||||
kr = existing_krs.get(title)
|
||||
if kr is None:
|
||||
kr = KR(
|
||||
|
||||
@@ -11,6 +11,10 @@ from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import Objective, ActionPlan, KR, ObjectiveKPI, KPIDefinition
|
||||
from app.utils.kr_name import sanitize_kr_name
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/cma/okr", tags=["OKR目标管理"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
@@ -329,6 +333,14 @@ def sync_krs(objective_id: int, data: dict, db: Session = Depends(get_db),
|
||||
kpi_id = kpi.id if kpi else None
|
||||
operator, target_value = _inherit_kpi_direction(db, kpi_id, item.get("operator"), item.get("target_value"))
|
||||
|
||||
# 护栏(2026-09-16):画布节点/接口传来的 KR 名若带历史污染尾巴(尾部「≥/≤+被截断的数字」)
|
||||
# 在此规整,防污染经 krs.title 继续传染(判据见 app/utils/kr_name.py)
|
||||
_tv = item.get("target_value")
|
||||
title, _polluted = sanitize_kr_name(title, _tv if _tv is not None else target_value)
|
||||
if _polluted:
|
||||
logger.warning("KR名称污染已规整: objective=%s %r -> %r (target=%s)",
|
||||
objective_id, item.get("title") or item.get("name"), title, _tv)
|
||||
|
||||
kr = None
|
||||
if item.get("id") and int(item["id"]) in existing:
|
||||
kr = existing[int(item["id"])]
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role
|
||||
from app.models import OKRTemplate, StrategicMap
|
||||
from app.utils.kr_name import sanitize_kr_name
|
||||
|
||||
router = APIRouter(prefix="/api/cma/okr-templates", tags=["OKR模板库"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "it"))],
|
||||
@@ -273,7 +274,8 @@ def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_d
|
||||
"kpis": [],
|
||||
"krs": [
|
||||
{
|
||||
"name": kr.get("name", ""),
|
||||
# 护栏(2026-09-16):模板名 → 新地图 KR 名 透传点,污染名(尾部符号+截断数字)在此规整
|
||||
"name": sanitize_kr_name(kr.get("name", ""), kr.get("target_value"))[0],
|
||||
"target_value": kr.get("target_value", ""),
|
||||
"weight": kr.get("weight", 33),
|
||||
"metric_kpi_id": kr.get("metric_kpi_id"),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""KR 名称规整(历史名称污染防护)。
|
||||
|
||||
背景:2026-09-11 前 `MapCanvasDialogs.vue` 的逐键写入点会把「方向符号 + 已输入的首位数字」
|
||||
拼进 KR 名称(输入 780 → 名称变 `营业收入≥7`)。写入口已删,但**存量数据仍在传染**:
|
||||
对话框用模板 `preset_krs` 预填 KR 名 → 沿用到新目标/新地图。
|
||||
|
||||
判据(勿一刀切):
|
||||
- 名称尾部「≥/≤ + 数字」且该数字 **≠** 目标值 → 截断值(污染)→ 去掉尾部片段;
|
||||
- 数字 **==** 目标值 → 有意命名(如 `渠道留存率≥90` / `新客户数≥10` / `A级渠道占比≥30%`)→ 原样保留;
|
||||
- 目标值里取不到数字(如 `为正` / `TOP3` / 空值)→ 无法判定 → 原样保留(不擅改)。
|
||||
|
||||
用法(护栏放在「JSON/模板名 → krs.title」的透传点,而不是用户输入点):
|
||||
from app.utils.kr_name import sanitize_kr_name
|
||||
clean, changed = sanitize_kr_name(name, target_value)
|
||||
"""
|
||||
import re
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
_NUM = re.compile(r"[-+]?\d*\.?\d+")
|
||||
_TAIL = re.compile(r"^(?P<base>.*?)\s*(?P<sym>[≥≤])\s*(?P<num>\d+(?:\.\d+)?)\s*$")
|
||||
|
||||
|
||||
def target_number(target_value: Any) -> Optional[float]:
|
||||
"""从目标值里取数字(兼容 780 / '780' / '≥780' / '≤45天' / '≥20%');取不到返回 None。"""
|
||||
if target_value is None:
|
||||
return None
|
||||
if isinstance(target_value, bool):
|
||||
return None
|
||||
if isinstance(target_value, (int, float)):
|
||||
return float(target_value)
|
||||
m = _NUM.search(str(target_value))
|
||||
return float(m.group(0)) if m else None
|
||||
|
||||
|
||||
def sanitize_kr_name(
|
||||
name: Any,
|
||||
target_value: Any = None,
|
||||
target_number_value: Optional[float] = None,
|
||||
) -> Tuple[str, bool]:
|
||||
"""返回 (规整后名称, 是否发生变更)。target_number_value 可显式传入已解析的目标数(优先)。"""
|
||||
if not isinstance(name, str):
|
||||
return ("" if name is None else str(name)), False
|
||||
m = _TAIL.match(name)
|
||||
if not m:
|
||||
return name, False
|
||||
tn = target_number_value if target_number_value is not None else target_number(target_value)
|
||||
if tn is None:
|
||||
return name, False
|
||||
if abs(float(m.group("num")) - tn) < 1e-9:
|
||||
return name, False
|
||||
clean = m.group("base").strip()
|
||||
if not clean:
|
||||
return name, False
|
||||
return clean, True
|
||||
@@ -0,0 +1,178 @@
|
||||
"""KR 名称污染(历史截断值)护栏测试 — 任务 cma-kr-name-pollution-cleanup-20260916
|
||||
|
||||
覆盖:
|
||||
1. 判据单测(sanitize_kr_name / target_number):污染截断、有意命名、无法判定三类
|
||||
2. 「JSON/模板名 → krs.title」透传点护栏:
|
||||
- PUT /api/cma/maps/{id}(_sync_objectives_krs)
|
||||
- PUT /api/cma/okr/{objective_id}/krs/sync(画布节点同步)
|
||||
- POST /api/cma/okr-templates/{id}/apply(模板 → 新地图)
|
||||
3. 阴性对照:合法命名(渠道留存率≥90 / 新客户数≥10)经同样路径不得被改
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import KR, Objective, OKRTemplate, StrategicMap
|
||||
from app.utils.kr_name import sanitize_kr_name, target_number
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||
|
||||
|
||||
# ────────────────────────── 1. 判据单测 ──────────────────────────
|
||||
|
||||
class TestSanitizeKrName:
|
||||
@pytest.mark.parametrize("name,target,expect", [
|
||||
("营业收入≥2", 2500, "营业收入"), # 输入 2500 时截断成首位数字
|
||||
("新客户数≥1", 5, "新客户数"),
|
||||
("营业收入≥7", 780, "营业收入"),
|
||||
("净利润≥6", 60, "净利润"),
|
||||
("费用率≤3", 30, "费用率"),
|
||||
("经营性现金流≥5", 50, "经营性现金流"),
|
||||
("营业收入≥2", "2500", "营业收入"), # 目标值为字符串数字
|
||||
("应收账款天数≤4", "≤45天", "应收账款天数"), # 目标值带单位文本
|
||||
])
|
||||
def test_polluted_tail_stripped(self, name, target, expect):
|
||||
clean, changed = sanitize_kr_name(name, target)
|
||||
assert changed is True
|
||||
assert clean == expect
|
||||
|
||||
@pytest.mark.parametrize("name,target", [
|
||||
("渠道留存率≥90", 90), # 数字==目标值 = 有意命名
|
||||
("新客户数≥10", 10),
|
||||
("A级渠道占比≥30%", 30),
|
||||
("营业收入≥780", 780),
|
||||
("费用率≤30", 30),
|
||||
("现金折扣收益≤收入20%", 20), # 尾部不是「符号+数字」
|
||||
("库存周转天数≤45天", "≤45天"),
|
||||
("数据质量评分≥90", 90.0),
|
||||
("剔除现金折扣后经营性净利≥0", 0),
|
||||
])
|
||||
def test_legal_names_untouched(self, name, target):
|
||||
clean, changed = sanitize_kr_name(name, target)
|
||||
assert changed is False
|
||||
assert clean == name
|
||||
|
||||
@pytest.mark.parametrize("name,target", [
|
||||
("NPS净推荐值≥60", "为正"), # 目标值无数字 → 无法判定,原样保留
|
||||
("总资产周转率≥1.0", None),
|
||||
("自由现金流≥0", ""),
|
||||
("经营现金流为正", "为正"), # 无尾部符号+数字
|
||||
])
|
||||
def test_unknown_target_untouched(self, name, target):
|
||||
clean, changed = sanitize_kr_name(name, target)
|
||||
assert changed is False
|
||||
assert clean == name
|
||||
|
||||
def test_empty_base_kept(self):
|
||||
"""名称主体为空(只剩符号+数字)时不动,避免产出空标题"""
|
||||
clean, changed = sanitize_kr_name("≥2", 2500)
|
||||
assert changed is False
|
||||
assert clean == "≥2"
|
||||
|
||||
def test_non_string_input(self):
|
||||
clean, changed = sanitize_kr_name(None, 100)
|
||||
assert changed is False and clean == ""
|
||||
|
||||
def test_target_number_parsing(self):
|
||||
assert target_number("≥45天") == 45
|
||||
assert target_number("≥20%") == 20
|
||||
assert target_number(780) == 780
|
||||
assert target_number("为正") is None
|
||||
assert target_number(None) is None
|
||||
|
||||
|
||||
# ────────────────────────── 2. 透传点护栏 ──────────────────────────
|
||||
|
||||
def _dims_with_krs(name, target_value):
|
||||
return [
|
||||
{"key": "finance", "name": "财务层", "icon": "💰", "color": "#F56C6C",
|
||||
"objectives": [{"name": "营收目标", "kpis": [],
|
||||
"krs": [{"name": name, "target_value": target_value,
|
||||
"weight": 70, "operator": ">="}]}]},
|
||||
{"key": "customer", "name": "客户层", "icon": "🤝", "color": "#67c23a", "objectives": []},
|
||||
]
|
||||
|
||||
|
||||
class TestPassThroughGuards:
|
||||
def _setup(self, db: Session):
|
||||
user = create_test_user(db)
|
||||
return user
|
||||
|
||||
def test_map_save_strips_polluted_kr_title(self, client: TestClient, db: Session):
|
||||
"""PUT /maps/{id} 的 JSON→krs.title 同步点:污染名被规整(库 + JSON 双清)"""
|
||||
self._setup(db)
|
||||
token = get_token_for_user(client)
|
||||
m = StrategicMap(title="测试地图", status="draft", entity_id=1,
|
||||
dimensions=_dims_with_krs("营业收入≥2", 25000),
|
||||
canvas_data={"connections": []})
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
|
||||
resp = client.put(f"/api/cma/maps/{m.id}", headers=auth_header(token),
|
||||
json={"dimensions": _dims_with_krs("营业收入≥2", 25000)})
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
kr = db.query(KR).filter(KR.objective_id.isnot(None)).first()
|
||||
assert kr is not None and kr.title == "营业收入"
|
||||
assert float(kr.target_value) == 25000.0
|
||||
# JSON 回写:展示口径 = 落库口径(回查须先失效身份映射,防读到旧快照)
|
||||
db.expire_all()
|
||||
m2 = db.query(StrategicMap).filter(StrategicMap.id == m.id).first()
|
||||
dims = m2.dimensions
|
||||
dims = json.loads(dims) if isinstance(dims, str) else dims
|
||||
assert dims[0]["objectives"][0]["krs"][0]["name"] == "营业收入"
|
||||
|
||||
def test_map_save_keeps_legal_kr_title(self, client: TestClient, db: Session):
|
||||
"""阴性对照:合法命名(数字==目标值)不得被改"""
|
||||
self._setup(db)
|
||||
token = get_token_for_user(client)
|
||||
m = StrategicMap(title="测试地图", status="draft", entity_id=1,
|
||||
dimensions=_dims_with_krs("渠道留存率≥90", 90),
|
||||
canvas_data={"connections": []})
|
||||
db.add(m)
|
||||
db.commit()
|
||||
db.refresh(m)
|
||||
|
||||
resp = client.put(f"/api/cma/maps/{m.id}", headers=auth_header(token),
|
||||
json={"dimensions": _dims_with_krs("渠道留存率≥90", 90)})
|
||||
assert resp.status_code == 200, resp.text
|
||||
kr = db.query(KR).filter(KR.objective_id.isnot(None)).first()
|
||||
assert kr is not None and kr.title == "渠道留存率≥90"
|
||||
|
||||
def test_kr_sync_endpoint_strips_polluted_title(self, client: TestClient, db: Session):
|
||||
"""PUT /okr/{oid}/krs/sync(画布节点同步)同样被护栏覆盖"""
|
||||
self._setup(db)
|
||||
token = get_token_for_user(client)
|
||||
obj = Objective(entity_id=1, title="营收目标", dimension="finance", status="active",
|
||||
quarter="2026Q3")
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
|
||||
resp = client.put(f"/api/cma/okr/{obj.id}/krs/sync", headers=auth_header(token),
|
||||
json={"krs": [{"title": "新客户数≥1", "operator": ">=", "target_value": 100,
|
||||
"weight": 30}]})
|
||||
assert resp.status_code == 200, resp.text
|
||||
krs = db.query(KR).filter(KR.objective_id == obj.id).all()
|
||||
assert [k.title for k in krs] == ["新客户数"]
|
||||
|
||||
def test_apply_template_strips_polluted_kr_name(self, client: TestClient, db: Session):
|
||||
"""POST /okr-templates/{id}/apply:存量污染模板 → 新地图 KR 名干净"""
|
||||
self._setup(db)
|
||||
token = get_token_for_user(client)
|
||||
t = OKRTemplate(name="营收目标", description="", dimension="finance", layer="level3",
|
||||
industry_tag="general", source="system",
|
||||
preset_krs=[{"name": "营业收入≥2", "target_value": 2500, "weight": 70},
|
||||
{"name": "渠道留存率≥90", "target_value": 90, "weight": 30}])
|
||||
db.add(t)
|
||||
db.commit()
|
||||
db.refresh(t)
|
||||
|
||||
resp = client.post(f"/api/cma/okr-templates/{t.id}/apply", headers=auth_header(token),
|
||||
json={"title": "套用模板-护栏验证"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
m = db.query(StrategicMap).filter(StrategicMap.id == resp.json()["map_id"]).first()
|
||||
names = [k["name"] for k in m.dimensions[0]["objectives"][0]["krs"]]
|
||||
assert names == ["营业收入", "渠道留存率≥90"]
|
||||
Reference in New Issue
Block a user