From 6b479bfe7d258b1b98f5733d314c1907df157a5b Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Thu, 27 Aug 2026 12:50:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(KR):=20=E8=A1=8C=E5=8A=A8=E6=8C=82KR?= =?UTF-8?q?=E5=AE=8C=E6=88=90=20=E2=80=94=20action=5Fplans.kr=5Fid?= =?UTF-8?q?=E5=AD=97=E6=AE=B5+API+=E5=89=8D=E7=AB=AF=E7=9B=AE=E6=A0=87/KR?= =?UTF-8?q?=E9=80=89=E6=8B=A9+KR=E4=B8=AD=E6=96=87=E5=90=8D=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=A1=AB+=E5=9C=B0=E5=9B=BEkrs=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - action_plans表加kr_id字段(关联krs.id),create/update/list支持kr_id读写 - 创建行动选KR时自动继承objective_id;列表附kr_title - 前端行动方案库加'所属目标/KR'两级联动选择,选中KR自动带出KPI - OKR详情页KR卡片下显示达成行动列表(O→KR→行动三层) - MapCanvasDialogs onKpiChange选中KPI自动填kr.name=kpi.kpi_name(中文) - 编辑回填英文code KR名转中文显示兜底 - maps.py 新增GET /{map_id}/krs端点(MapReview战略回顾会KR进度) - 数据修复: krs表9条英文code title→中文名(按kpi_definitions映射) - 数据迁移: 高置信行动挂KR(费用率→kr10,应收→kr14); 地图32空KR清理 - okr list的kr_summary补metric_kpi_id(前端联动用) - 新增6个kr_id测试(test_action_plans),全量547通过 --- backend/app/api/action_plans.py | 42 +++++- backend/app/api/maps.py | 65 ++++++++++ backend/app/api/okr.py | 3 +- backend/app/models/__init__.py | 1 + backend/tests/test_action_plans.py | 121 +++++++++++++++++- docs/cma-toyota-landing-check-20260826.md | 42 ++++++ frontend/components.d.ts | 1 + .../map-canvas/MapCanvasDialogs.vue | 26 +++- frontend/src/views/ActionPlanLibrary.vue | 53 +++++++- frontend/src/views/OkrDetail.vue | 62 ++++++++- 10 files changed, 402 insertions(+), 14 deletions(-) create mode 100644 docs/cma-toyota-landing-check-20260826.md diff --git a/backend/app/api/action_plans.py b/backend/app/api/action_plans.py index ef5eae4d..22113df0 100644 --- a/backend/app/api/action_plans.py +++ b/backend/app/api/action_plans.py @@ -9,7 +9,7 @@ from calendar import monthrange from app.database import get_db from app.deps import get_entity_id from app.auth_middleware import require_role, require_auth -from app.models import ActionPlan, KPIAlert, KPIDefinition, User, Objective +from app.models import ActionPlan, KPIAlert, KPIDefinition, User, Objective, KR logger = logging.getLogger("cma.action_plans") @@ -64,6 +64,7 @@ def plan_to_dict(p: ActionPlan) -> dict: "alert_id": p.alert_id, "kpi_id": p.kpi_id, "objective_id": p.objective_id, + "kr_id": p.kr_id, "title": p.title, "description": p.description, "assignee": p.assignee, @@ -87,6 +88,7 @@ def list_plans( status: Optional[str] = None, kpi_id: Optional[int] = None, alert_id: Optional[int] = None, + keyword: Optional[str] = None, db: Session = Depends(get_db), current_user: User = Depends(require_auth), entity_id: int = Depends(get_entity_id), @@ -100,6 +102,8 @@ def list_plans( query = query.filter(ActionPlan.kpi_id == kpi_id) if alert_id: query = query.filter(ActionPlan.alert_id == alert_id) + if keyword: + query = query.filter(ActionPlan.title.like(f"%{keyword}%")) # business角色只看自己的 if current_user.role == "business": @@ -115,6 +119,16 @@ def list_plans( # 附带KPI名称 kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first() item["kpi_name"] = kpi.kpi_name if kpi else "未知KPI" + item["kpi_code"] = kpi.kpi_code if kpi else None + item["kpi_dimension"] = kpi.dimension if kpi else None + # 附带KR信息(行动挂KR 2026-08-27) + if p.kr_id is not None: + kr = db.query(KR).filter(KR.id == p.kr_id).first() + item["kr_title"] = kr.title if kr else None + item["kr_metric_kpi_id"] = kr.metric_kpi_id if kr else None + else: + item["kr_title"] = None + item["kr_metric_kpi_id"] = None result.append(item) return {"data": result} @@ -137,6 +151,16 @@ def create_plan( if not kpi_ent or kpi_ent.entity_id != entity_id: raise HTTPException(404, "关联KPI不存在") + # 行动挂KR (2026-08-27): kr_id 需存在且属于当前企业 + kr_id = data.get("kr_id") + if kr_id: + kr_ent = db.query(KR).filter(KR.id == kr_id, KR.entity_id == entity_id).first() + if not kr_ent: + raise HTTPException(404, "关联KR不存在") + # 未显式传objective_id时从KR继承目标 + if not data.get("objective_id"): + data["objective_id"] = kr_ent.objective_id + due_date = datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None # 校验截止日期与关联Objective的季度匹配 @@ -150,6 +174,7 @@ def create_plan( alert_id=data.get("alert_id"), kpi_id=data["kpi_id"], objective_id=objective_id, + kr_id=kr_id, title=data["title"], description=data.get("description"), assignee=data.get("assignee"), @@ -214,6 +239,21 @@ def update_plan( plan.assignee = data["assignee"] if "priority" in data: plan.priority = data["priority"] + if "kr_id" in data: + # 行动挂KR: 支持置空(null)或改挂 + if data["kr_id"] is None: + plan.kr_id = None + else: + kr_ent = db.query(KR).filter(KR.id == data["kr_id"]).first() + if not kr_ent: + raise HTTPException(404, "关联KR不存在") + plan.kr_id = kr_ent.id + if data.get("objective_id") is not None: + plan.objective_id = data["objective_id"] + elif plan.objective_id is None: + plan.objective_id = kr_ent.objective_id + if "objective_id" in data: + plan.objective_id = data["objective_id"] if "due_date" in data: plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None if "status" in data: diff --git a/backend/app/api/maps.py b/backend/app/api/maps.py index 46ecd128..2e7586a7 100644 --- a/backend/app/api/maps.py +++ b/backend/app/api/maps.py @@ -533,6 +533,71 @@ def _parse_kr_date(v): # ── 战略回顾会 聚合接口 ────────────────────── +@router.get("/{map_id}/krs") +def get_map_krs(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): + """战略回顾会 KR进度: 从krs表读取地图各目标的KR (KR修复 2026-08-27) + + 地图dimensions JSON中每个带krs的目标节点已同步到objectives+krs表, + 此处按 节点._objective_id(或 entity+name 回查)取KR。 + """ + from app.models import KPIDefinition, KR, Objective + m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first() + if not m: + raise HTTPException(404, "战略地图不存在") + + dims = m.dimensions + if isinstance(dims, str): + try: + dims = json.loads(dims) + except Exception: + dims = [] + dims = dims or [] + + obj_ids = [] + for dim in dims: + for obj in dim.get("objectives", []): + oid = obj.get("_objective_id") + if oid: + obj_ids.append(int(oid)) + continue + # 兼容无回写字段的旧节点: 按 entity+name 回查 + name = (obj.get("name") or "").strip() + if name: + o = db.query(Objective).filter( + Objective.entity_id == entity_id, + Objective.title == name, + ).first() + if o: + obj_ids.append(o.id) + + result = [] + if obj_ids: + krs = db.query(KR).filter( + KR.entity_id == entity_id, + KR.objective_id.in_(obj_ids), + ).order_by(KR.objective_id, KR.sort_order, KR.id).all() + for kr in krs: + mkpi = None + if kr.metric_kpi_id: + mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first() + result.append({ + "id": kr.id, + "objective_id": kr.objective_id, + "title": kr.title, + "metric_kpi_id": kr.metric_kpi_id, + "metric_kpi_code": mkpi.kpi_code if mkpi else None, + "metric_kpi_name": mkpi.kpi_name if mkpi else None, + "operator": _parse_kr_operator_target(kr.operator)[0] if hasattr(kr, "operator") and kr.operator else ">=", + "weight": float(kr.weight) if kr.weight is not None else None, + "target_value": float(kr.target_value) if kr.target_value is not None else None, + "current_value": float(kr.current_value) if kr.current_value is not None else None, + "progress": kr.progress or 0, + "status": kr.status, + "due_date": kr.due_date.isoformat() if kr.due_date else None, + }) + return {"data": result} + + @router.get("/{map_id}/review") def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): """战略回顾会:返回目标状态、KPI值、改善行动 diff --git a/backend/app/api/okr.py b/backend/app/api/okr.py index cb897e55..3c2d01d1 100644 --- a/backend/app/api/okr.py +++ b/backend/app/api/okr.py @@ -192,7 +192,8 @@ def list_objectives( krs = db.query(KR).filter(KR.objective_id == o.id).order_by(KR.id).all() kr_summary = [ {"id": kr.id, "title": kr.title, "status": kr.status, "progress": kr.progress, - "operator": _parse_operator(kr.operator), "weight": _num(kr.weight)} + "operator": _parse_operator(kr.operator), "weight": _num(kr.weight), + "metric_kpi_id": kr.metric_kpi_id} for kr in krs ] results.append({ diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9be9886c..0686eae3 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -220,6 +220,7 @@ class ActionPlan(Base): alert_id = Column(Integer, ForeignKey("kpi_alerts.id"), nullable=True, comment="关联预警") kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI") objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=True, comment="关联OKR目标") + kr_id = Column(Integer, ForeignKey("krs.id"), nullable=True, comment="关联KR (行动挂KR 2026-08-27)") title = Column(String(200), nullable=False, comment="计划标题") description = Column(Text, nullable=True, comment="详细描述") assignee = Column(String(100), nullable=True, comment="负责人") diff --git a/backend/tests/test_action_plans.py b/backend/tests/test_action_plans.py index 3e6da4dc..e7545474 100644 --- a/backend/tests/test_action_plans.py +++ b/backend/tests/test_action_plans.py @@ -6,7 +6,32 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session from datetime import datetime, timedelta from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi -from app.models import ActionPlan +from app.models import ActionPlan, Objective, KR + + +def create_test_objective_kr(db: Session, **kwargs): + """创建测试目标+KR(行动挂KR 2026-08-27)""" + obj = Objective( + entity_id=kwargs.get("entity_id", 1), + title=kwargs.get("objective_title", "测试目标"), + quarter="2026Q3", + status="active", + ) + db.add(obj) + db.commit() + db.refresh(obj) + kr = KR( + entity_id=kwargs.get("entity_id", 1), + objective_id=obj.id, + title=kwargs.get("kr_title", "测试KR"), + operator=">=", + weight=50, + target_value=100, + ) + db.add(kr) + db.commit() + db.refresh(kr) + return obj, kr def create_test_plan(db: Session, **kwargs) -> ActionPlan: @@ -109,7 +134,7 @@ class TestActionPlans: assert data["data"][0]["title"] == "进行中" def test_filter_by_keyword(self, client: TestClient, db: Session): - """关键词搜索""" + """关键词搜索:keyword 应过滤 title(期望行为,2026-08-27 TDD示范)""" user = create_test_user(db) token = get_token_for_user(client) kpi = create_test_kpi(db) @@ -119,8 +144,9 @@ class TestActionPlans: resp = client.get("/api/cma/action-plans?keyword=营收", headers=auth_header(token)) assert resp.status_code == 200 data = resp.json() - # API当前未实现keyword过滤,返回全部2条 - assert len(data["data"]) == 2 + # 期望行为:keyword 过滤生效,只返回标题含"营收"的1条 + assert len(data["data"]) == 1 + assert data["data"][0]["title"] == "提升营收计划" def test_update_plan(self, client: TestClient, db: Session): """更新行动计划""" @@ -226,3 +252,90 @@ class TestActionPlans: assert data["pending"] == 1 assert data["in_progress"] == 1 assert data["completed"] == 1 + + # ── 行动挂KR (2026-08-27) ── + + def test_create_plan_with_kr_id(self, client: TestClient, db: Session): + """创建行动时挂KR:返回kr_id,objective_id从KR自动继承""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db) + obj, kr = create_test_objective_kr(db) + + resp = client.post( + "/api/cma/action-plans", + headers=auth_header(token), + json={ + "title": "降低费用率行动", + "kpi_id": kpi.id, + "kr_id": kr.id, + "assignee": "张三", + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["kr_id"] == kr.id + # 未显式传objective_id时从KR继承 + assert data["objective_id"] == obj.id + + def test_create_plan_kr_not_found(self, client: TestClient, db: Session): + """kr_id不存在 → 404""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db) + + resp = client.post( + "/api/cma/action-plans", + headers=auth_header(token), + json={"title": "行动", "kpi_id": kpi.id, "kr_id": 99999}, + ) + assert resp.status_code == 404 + + def test_list_includes_kr_title(self, client: TestClient, db: Session): + """列表返回kr_title""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db) + obj, kr = create_test_objective_kr(db) + create_test_plan(db, kpi_id=kpi.id, kr_id=kr.id, title="挂KR的行动") + + resp = client.get("/api/cma/action-plans", headers=auth_header(token)) + assert resp.status_code == 200 + data = resp.json()["data"] + item = data[0] + assert item["kr_id"] == kr.id + assert item["kr_title"] == "测试KR" + + def test_update_plan_kr_id(self, client: TestClient, db: Session): + """更新行动可改挂KR""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db) + obj1, kr1 = create_test_objective_kr(db, kr_title="KR一") + obj2, kr2 = create_test_objective_kr(db, kr_title="KR二") + plan = create_test_plan(db, kpi_id=kpi.id, kr_id=kr1.id, title="行动") + + resp = client.put( + f"/api/cma/action-plans/{plan.id}", + headers=auth_header(token), + json={"kr_id": kr2.id}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["kr_id"] == kr2.id + + def test_update_plan_kr_clear(self, client: TestClient, db: Session): + """更新行动可置空kr_id""" + create_test_user(db) + token = get_token_for_user(client) + kpi = create_test_kpi(db) + obj, kr = create_test_objective_kr(db) + plan = create_test_plan(db, kpi_id=kpi.id, kr_id=kr.id, title="行动") + + resp = client.put( + f"/api/cma/action-plans/{plan.id}", + headers=auth_header(token), + json={"kr_id": None}, + ) + assert resp.status_code == 200 + assert resp.json()["kr_id"] is None diff --git a/docs/cma-toyota-landing-check-20260826.md b/docs/cma-toyota-landing-check-20260826.md new file mode 100644 index 00000000..f235f88c --- /dev/null +++ b/docs/cma-toyota-landing-check-20260826.md @@ -0,0 +1,42 @@ +# CMA 丰田式体检报告(2026-08-26)— 真落地 vs 伪上线 + +> 方法:丰田五原则(看板/5Why/Andon/过量生产/先流程后系统)对照 CMA 各模块真实数据量 +> 参考:~/WB_WS/_wisdom/reference/b2b-toyota-style-landing-check.md + +## 一、真落地(有真实业务流支撑 → 聚焦) + +| 模块 | 数据量 | 判断 | +|:--|:--|:--| +| KPI字典 + 实际值 | 94 KPI / 1674 值 | ✅ 核心引擎,持续写入 | +| 预警 + 规则 | 191 预警 / 78 规则 | ✅ Andon 机制已落地(异常即暴露) | +| 预算计划 | 134 | ✅ 真在用 | +| 成本(标准+实际) | 163 | ✅ 真在用 | +| 战略地图 | 2(酣客/博海各1) | ✅ 双账套真实使用 | +| 改善行动 + 因果链 | 18 + 50 | ✅ 与预警/OKR联动 | +| 操作日志 | 163 | ✅ 审计流 | +| 知识文章 | 36 | ✅ 在用 | +| 数据源配置 | 12 | ✅ 数据管道 | + +## 二、疑似伪需求(丰田"过量生产"候选 → 不做清单) + +| 模块 | 数据量 | 诊断 | 建议 | +|:--|:--|:--|:--| +| **OKR模板库** | 32个模板,**仅1个被用** | 🔴 典型过量生产:做了 32 个"以后可能用得上"的模板,实际只有1个被应用 | **冻结归档**:保留 Top5 真实模板(按 use_count+行业),其余 is_active=0;或做模板应用引导提升复用 | +| **BI报表** | **0 份报表** | 🔴 功能完整(列表/模板/导出)但零使用 | **冻结**:无真实需求,等客户要求再启用 | +| **通知渠道** | 0 渠道 / 0 日志 | 🔴 通知模块(渠道配置/测试/日志)零使用——预警走前端展示即可 | **冻结**:不做推送通知,预警保持在页面内 | +| 费用报销 | 13 条 | 🟡 demo 级 | 观察:真实报销流程启动后再评估 | +| 导入日志 | 0 | 🟡 导入功能存在但无记录(可能走bot_bridge) | 确认数据入口后决定 | + +## 三、丰田五原则对照结论 + +1. **看板**:核心引擎(KPI/预算/成本/预警)都是"业务痛"驱动 ✅;OKR模板/BI/通知是"想要"非"需要" ❌ +2. **5Why**:CMA 开发已用 Grill-Me/6段式任务书 ✅ +3. **Andon**:预警红黄绿 + 预测偏差告警 + Bot健康看板 ✅ 已超标 +4. **过量生产**:**32模板/BI/通知 = 最大的浪费**(开发了没人用的功能,还占回归测试面)🔴 +5. **先流程后系统**:协作铁律/流程纪律 ✅ + +## 四、建议动作(待确认后执行) + +- [ ] OKR模板库冻结:is_active=0 除 Top5 外全部(可随时恢复,不删数据) +- [ ] BI报表/通知渠道:前端菜单隐藏(模块保留,后端不动) +- [ ] 后续新需求过"三问":是否真实痛点?数据基础就绪?有人会用? diff --git a/frontend/components.d.ts b/frontend/components.d.ts index f850d29b..85312a58 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -59,6 +59,7 @@ declare module 'vue' { ElTabPane: typeof import('element-plus/es')['ElTabPane'] ElTabs: typeof import('element-plus/es')['ElTabs'] ElTag: typeof import('element-plus/es')['ElTag'] + ElText: typeof import('element-plus/es')['ElText'] ElTimeline: typeof import('element-plus/es')['ElTimeline'] ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] diff --git a/frontend/src/components/map-canvas/MapCanvasDialogs.vue b/frontend/src/components/map-canvas/MapCanvasDialogs.vue index 50f81c8b..3cfdb6f0 100644 --- a/frontend/src/components/map-canvas/MapCanvasDialogs.vue +++ b/frontend/src/components/map-canvas/MapCanvasDialogs.vue @@ -649,19 +649,28 @@ function autoDivideWeights() { ElMessage.success(`已自动平分: ${form.value.krs.map(k => k.weight).join('/')}`) } -// ── 关联KPI变化 → 自动继承方向(从KPI阈值解析) ── +// ── 关联KPI变化 → 自动填中文名 + 继承方向(KR英文名修复 2026-08-27) ── function onKpiChange(ki: number) { const kr = form.value.krs[ki] if (!kr) return const kpi = kpiOptions.value.find((k: any) => k.kpi_code === kr.kpi_code) if (!kpi) return + const kpiName = kpi.kpi_name || kpi.kpi_code + // 选中KPI自动填中文名:名称空 / 还是英文code时替换(用户已手填中文则保留) + const curName = (kr.name || "").trim() + if (!curName || curName === kpi.kpi_code || (curName === curName.toUpperCase() && curName.includes("_"))) { + kr.name = kpiName + } + // 从KPI阈值解析方向/目标值并继承(仅当用户未手动设置时) const threshold = kpi.threshold_green || kpi.threshold_yellow || "" - if (!threshold) return + if (!threshold) { + ElMessage.success(`已关联KPI: ${kpiName}`) + return + } const parsed = parseKrTarget(threshold) - // 仅当用户未手动设置方向/目标值时继承 if (!kr.operator || kr.operator === ">=") kr.operator = parsed.operator if (kr.target_value == null) kr.target_value = parsed.value - ElMessage.success(`已继承KPI方向: ${kr.operator} ${kr.target_value}`) + ElMessage.success(`已关联KPI: ${kpiName}(方向 ${kr.operator} ${kr.target_value})`) } function formatTargetValue(idx: number) { @@ -747,8 +756,15 @@ watch( const obj = props.editingObj const existingKrs = (obj.krs || []).map((kr: any) => { const parsed = parseKrTarget(kr.target_value) + // 显示兜底: 旧数据KR名是英文code → 转中文(KR英文名修复 2026-08-27) + let krName = kr.name || "" + const curName = String(krName).trim() + if (curName && curName === curName.toUpperCase() && curName.includes("_")) { + const hit = kpiOptions.value.find((k: any) => k.kpi_code === curName) + if (hit) krName = hit.kpi_name || hit.kpi_code + } return { - name: kr.name || "", + name: krName || "", operator: kr.operator || parsed.operator, target_value: kr.target_value != null && typeof kr.target_value === "number" ? kr.target_value : parsed.value, diff --git a/frontend/src/views/ActionPlanLibrary.vue b/frontend/src/views/ActionPlanLibrary.vue index 7bf82958..fe1209a5 100644 --- a/frontend/src/views/ActionPlanLibrary.vue +++ b/frontend/src/views/ActionPlanLibrary.vue @@ -71,6 +71,12 @@ + + +