diff --git a/backend/app/api/action_plans.py b/backend/app/api/action_plans.py index 43237e36..46393f2b 100644 --- a/backend/app/api/action_plans.py +++ b/backend/app/api/action_plans.py @@ -62,6 +62,32 @@ def _validate_due_date_against_quarter(due_date: datetime, quarter: str): f"KR截止日期({due.date()})超出本季度范围({q_end.date()}),最大截止为{q_end.date()}") +# ────────────────────────────────────────────── +# 「已逾期」派生口径(单一来源,2026-09-21 project-20260921-actionplan-overdue-filter) +# ────────────────────────────────────────────── +# 口径:overdue = status in (pending, in_progress) AND due_date < now +# +# 为什么要有这个函数(缺陷根因):status 的取值域只有 +# pending / in_progress / completed / cancelled —— **没有** overdue 这个值。 +# 前端把统计卡片的 key('overdue')直接当 status 传给列表端点 +# (frontend/src/views/ActionPlanLibrary.vue:414 卡片 key、:26 filterStatus、:422 params.status), +# 而 list_plans 原实现按 `ActionPlan.status == 'overdue'` 直查 → 恒 0 条, +# 于是出现「卡片 7 条 ≠ 列表 0 条」。 +# 统计端点 plan_stats 早就用的是派生口径 —— 两处口径分家正是本次不一致的来源。 +# 故把派生口径抽成本函数,plan_stats 与 list_plans **共用同一实现**(同源), +# 今后口径只此一处,不再出现「卡片 ≠ 列表」。 +OVERDUE_KEY = "overdue" +OVERDUE_STATUSES = ("pending", "in_progress") + + +def _apply_overdue(query): + """把「已逾期」派生口径施加到 ActionPlan 查询上(stats / list 共用)。""" + return query.filter( + ActionPlan.status.in_(list(OVERDUE_STATUSES)), + ActionPlan.due_date < datetime.now(), + ) + + def plan_to_dict(p: ActionPlan) -> dict: return { "id": p.id, @@ -100,7 +126,12 @@ def list_plans( """获取行动计划列表(账套隔离: join KPI按企业过滤, 2026-08-23 P1b)""" query = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(KPIDefinition.entity_id == entity_id).order_by(ActionPlan.created_at.desc()) - if status: + if status == OVERDUE_KEY: + # 「已逾期」是**派生条件**而非 status 值(口径与 /stats 同源,2026-09-21) + # 前端统计卡片直接把 key='overdue' 当 status 传来,故此处按派生口径过滤, + # 与 plan_stats 共用 _apply_overdue,保证「卡片条数 = 列表条数」。 + query = _apply_overdue(query) + elif status: query = query.filter(ActionPlan.status == status) if kpi_id: query = query.filter(ActionPlan.kpi_id == kpi_id) @@ -352,8 +383,7 @@ def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(requi pending = query.filter(ActionPlan.status == "pending").count() in_progress = query.filter(ActionPlan.status == "in_progress").count() completed = query.filter(ActionPlan.status == "completed").count() - from datetime import datetime - overdue = query.filter(ActionPlan.status.in_(["pending", "in_progress"]), ActionPlan.due_date < datetime.now()).count() + overdue = _apply_overdue(query).count() return { "total": total, "pending": pending, diff --git a/backend/tests/test_actionplan_overdue_filter_20260921.py b/backend/tests/test_actionplan_overdue_filter_20260921.py new file mode 100644 index 00000000..10c28493 --- /dev/null +++ b/backend/tests/test_actionplan_overdue_filter_20260921.py @@ -0,0 +1,297 @@ +"""行动方案库「已逾期」口径一致性测试 + +任务:project-20260921-actionplan-overdue-filter +(卡片 7 条 → 列表 0 条) + +缺陷 + 前端统计卡片把 key='overdue' 直接当 status 传给 + GET /api/cma/action-plans(ActionPlanLibrary.vue:414 卡片 key / :26 filterStatus / :422 params.status), + 而 list_plans 原实现按 `ActionPlan.status == 'overdue'` 直查 —— status 取值域只有 + pending / in_progress / completed / cancelled,**没有 overdue** → 恒 0 条。 + /stats 的 overdue 是派生口径:status in (pending, in_progress) AND due_date < now。 + 两处口径分家 ⇒ 同一张卡片「数 7 条 / 列表 0 条」。 + +修复 + list_plans 在 status == 'overdue' 时走 _apply_overdue(),与 plan_stats **共用同一实现**。 + +本文件覆盖 + ① 已逾期列表条数 == stats.overdue(HTTP 全链路:鉴权 + 账套 + 查询参数) + ② 其余卡片(total/pending/in_progress/completed)列表条数与 stats 逐项相符 + ③ 口径同源:派生集合 ⊆ {pending,in_progress} 且 due_date < now;status=completed/cancelled + 即使已过期也不计入 + ④ 边界:刚过期(-60s)计入 / 未到期(+60s)不计入 / due_date 为空不计入 + ⑤ 账套隔离:e1 会话只看 e1 KPI 下的计划,e2 逾期计划不出现在 e1 的逾期列表 + ⑥ business 角色过滤:只统计/返回自己的计划(列表与统计同时收窄) + ⑦ 负对照:同一批夹具下「旧口径」status=='overdue' 确为 0 条(证明夹具真能复现缺陷) +""" +import inspect +from datetime import datetime, timedelta + +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.api import action_plans as ap +from app.models import ActionPlan, Entity, KPIDefinition +from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user, create_test_kpi + +E1_KPI_CODE = "ZZ_APOVD_E1_KPI_001" +E2_KPI_CODE = "ZZ_APOVD_E2_KPI_001" + +T_OVERDUE_PENDING = "逾期待处理" +T_OVERDUE_INPROG = "逾期进行中" +T_FUTURE_PENDING = "未到期待处理" +T_OVERDUE_COMPLETED = "已完成且已过期" +T_OVERDUE_CANCELLED = "已取消且已过期" +T_NO_DUE_PENDING = "无截止日待处理" + + +# ────────────────────────────────────────────── +# 夹具 +# ────────────────────────────────────────────── + +def _ensure_entity(db: Session, eid: int, name: str) -> Entity: + ent = db.query(Entity).filter(Entity.id == eid).first() + if not ent: + ent = Entity(id=eid, name=name, short_name=name, status="active") + db.add(ent) + db.commit() + db.refresh(ent) + return ent + + +def _mk_kpi(db: Session, code: str, eid: int) -> KPIDefinition: + return create_test_kpi(db, kpi_code=code, kpi_name=f"{code}-名称", entity_id=eid) + + +def _mk_plan(db: Session, kpi_id: int, title: str, status: str, due_delta_sec, + assignee: str = "张三") -> ActionPlan: + due = None if due_delta_sec is None else datetime.now() + timedelta(seconds=due_delta_sec) + plan = ActionPlan( + entity_id=1, + kpi_id=kpi_id, + title=title, + status=status, + due_date=due, + assignee=assignee, + priority="medium", + progress=0, + created_by="testadmin", + ) + db.add(plan) + db.commit() + db.refresh(plan) + return plan + + +def _login(client: TestClient, entity_id: int, username: str = "testadmin") -> str: + resp = client.post("/api/cma/auth/login", json={ + "username": username, + "password": TEST_USER_PASSWORD, + "entity_id": entity_id, + }) + assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}" + return resp.json()["token"] + + +def _seed_e1(db: Session): + """e1 夹具:2 条逾期 + 4 条不计入(未到期 / 已完成 / 已取消 / 无截止日)""" + _ensure_entity(db, 1, "酣客") + create_test_user(db) + kpi = _mk_kpi(db, E1_KPI_CODE, 1) + _mk_plan(db, kpi.id, T_OVERDUE_PENDING, "pending", -86400) # 逾期 + _mk_plan(db, kpi.id, T_OVERDUE_INPROG, "in_progress", -3600) # 逾期 + _mk_plan(db, kpi.id, T_FUTURE_PENDING, "pending", +86400) # 未到期 + _mk_plan(db, kpi.id, T_OVERDUE_COMPLETED, "completed", -86400) # 已完成→不算逾期 + _mk_plan(db, kpi.id, T_OVERDUE_CANCELLED, "cancelled", -86400) # 已取消→不算逾期 + _mk_plan(db, kpi.id, T_NO_DUE_PENDING, "pending", None) # 无截止日 + return kpi + + +def _titles(resp_json) -> set: + return {row["title"] for row in resp_json["data"]} + + +# ────────────────────────────────────────────── +# ① 已逾期列表条数 == stats.overdue(HTTP 全链路) +# ────────────────────────────────────────────── + +def test_overdue_list_count_equals_stats(client: TestClient, db: Session): + """核心验收:?status=overdue 的条数 == /stats 的 overdue,且只含派生口径内的行""" + _seed_e1(db) + token = _login(client, 1) + h = auth_header(token) + + stats = client.get("/api/cma/action-plans/stats", headers=h).json() + listed = client.get("/api/cma/action-plans?status=overdue", headers=h).json() + + assert stats["overdue"] == 2, f"夹具期望 overdue=2,实际 {stats['overdue']}" + assert len(listed["data"]) == stats["overdue"], ( + f"卡片 overdue={stats['overdue']} 与列表 {len(listed['data'])} 条不一致" + "(缺陷复现:卡片数≠列表数)" + ) + assert _titles(listed) == {T_OVERDUE_PENDING, T_OVERDUE_INPROG}, ( + f"逾期列表内容应为 2 条逾期待办,实际 {sorted(_titles(listed))}" + ) + # 口径内容断言:全部落在 status ∈ {pending, in_progress} 且 due_date < now + for row in listed["data"]: + assert row["status"] in ("pending", "in_progress"), row + assert row["due_date"] and datetime.fromisoformat(row["due_date"]) < datetime.now(), row + + +# ────────────────────────────────────────────── +# ② 其余卡片逐项相符 +# ────────────────────────────────────────────── + +def test_other_cards_match_stats(client: TestClient, db: Session): + """回归:total/pending/in_progress/completed 列表条数与 stats 逐项相符""" + _seed_e1(db) + token = _login(client, 1) + h = auth_header(token) + + stats = client.get("/api/cma/action-plans/stats", headers=h).json() + assert stats == {"total": 6, "pending": 3, "in_progress": 1, + "completed": 1, "overdue": 2}, stats + + cards = {"total": "", "pending": "pending", "in_progress": "in_progress", + "completed": "completed", "overdue": "overdue"} + for key, param in cards.items(): + url = "/api/cma/action-plans" + (f"?status={param}" if param else "") + rows = client.get(url, headers=h).json()["data"] + assert len(rows) == stats[key], f"卡片 {key}: stats={stats[key]} 列表={len(rows)}" + + +# ────────────────────────────────────────────── +# ③ 口径同源(派生集合 + 源码单一来源) +# ────────────────────────────────────────────── + +def test_overdue_excludes_completed_and_cancelled(client: TestClient, db: Session): + """已完成/已取消即使早已过期也不计入逾期(口径 = status ∈ {pending,in_progress})""" + _seed_e1(db) + token = _login(client, 1) + h = auth_header(token) + + titles = _titles(client.get("/api/cma/action-plans?status=overdue", headers=h).json()) + assert T_OVERDUE_COMPLETED not in titles + assert T_OVERDUE_CANCELLED not in titles + assert T_NO_DUE_PENDING not in titles + + # 字面 status 过滤未被改动(仍按 status 直查) + assert _titles(client.get("/api/cma/action-plans?status=cancelled", headers=h).json()) == { + T_OVERDUE_CANCELLED} + assert len(client.get("/api/cma/action-plans?status=pending", headers=h).json()["data"]) == 3 + + +def test_overdue_caliber_is_single_source(): + """统计与列表共用同一实现:plan_stats 与 list_plans 都调用 _apply_overdue""" + stats_src = inspect.getsource(ap.plan_stats) + list_src = inspect.getsource(ap.list_plans) + assert "_apply_overdue(query)" in stats_src, "plan_stats 未走 _apply_overdue(口径分家)" + assert "_apply_overdue(query)" in list_src, "list_plans 未走 _apply_overdue(口径分家)" + assert ap.OVERDUE_STATUSES == ("pending", "in_progress") + assert ap.OVERDUE_KEY == "overdue" + + +# ────────────────────────────────────────────── +# ④ 边界 +# ────────────────────────────────────────────── + +def test_overdue_boundary_and_null_due_date(client: TestClient, db: Session): + """边界:-60s 计入、+60s 不计入、due_date 为空不计入""" + _ensure_entity(db, 1, "酣客") + create_test_user(db) + kpi = _mk_kpi(db, E1_KPI_CODE, 1) + _mk_plan(db, kpi.id, "刚过期60秒", "pending", -60) + _mk_plan(db, kpi.id, "还有60秒到期", "pending", +60) + _mk_plan(db, kpi.id, "无截止日", "pending", None) + + token = _login(client, 1) + h = auth_header(token) + titles = _titles(client.get("/api/cma/action-plans?status=overdue", headers=h).json()) + assert titles == {"刚过期60秒"}, titles + stats = client.get("/api/cma/action-plans/stats", headers=h).json() + assert stats["overdue"] == 1 and stats["pending"] == 3 and stats["total"] == 3, stats + + +# ────────────────────────────────────────────── +# ⑤ 账套隔离 +# ────────────────────────────────────────────── + +def test_overdue_entity_isolation(client: TestClient, db: Session): + """e1/e2 各自只看到本账套 KPI 下的计划;逾期口径同样按账套收窄""" + _ensure_entity(db, 1, "酣客") + _ensure_entity(db, 2, "博海") + create_test_user(db) + k1 = _mk_kpi(db, E1_KPI_CODE, 1) + k2 = _mk_kpi(db, E2_KPI_CODE, 2) + _mk_plan(db, k1.id, "e1逾期", "pending", -3600) + p2 = _mk_plan(db, k2.id, "e2逾期", "pending", -3600) + p2.entity_id = 2 + db.commit() + + h1 = auth_header(_login(client, 1)) + h2 = auth_header(_login(client, 2)) + + s1 = client.get("/api/cma/action-plans/stats", headers=h1).json() + s2 = client.get("/api/cma/action-plans/stats", headers=h2).json() + l1 = _titles(client.get("/api/cma/action-plans?status=overdue", headers=h1).json()) + l2 = _titles(client.get("/api/cma/action-plans?status=overdue", headers=h2).json()) + + assert s1["overdue"] == 1 and s2["overdue"] == 1, (s1, s2) + assert l1 == {"e1逾期"}, l1 + assert l2 == {"e2逾期"}, l2 + assert len(client.get("/api/cma/action-plans", headers=h1).json()["data"]) == s1["total"] == 1 + assert len(client.get("/api/cma/action-plans", headers=h2).json()["data"]) == s2["total"] == 1 + + +# ────────────────────────────────────────────── +# ⑥ business 角色过滤 +# ────────────────────────────────────────────── + +def test_overdue_business_role_filter(client: TestClient, db: Session): + """business 角色:列表与统计同时只收窄到自己名下(逾期口径也随之下沉)""" + _ensure_entity(db, 1, "酣客") + create_test_user(db) + create_test_user(db, username="业务小王", name="业务小王", role="business") + kpi = _mk_kpi(db, E1_KPI_CODE, 1) + _mk_plan(db, kpi.id, "小王的逾期", "pending", -3600, assignee="业务小王") + _mk_plan(db, kpi.id, "张三的逾期", "pending", -3600, assignee="张三") + _mk_plan(db, kpi.id, "小王的未到期", "pending", +3600, assignee="业务小王") + + h = auth_header(_login(client, 1, username="业务小王")) + stats = client.get("/api/cma/action-plans/stats", headers=h).json() + assert stats == {"total": 2, "pending": 2, "in_progress": 0, + "completed": 0, "overdue": 1}, stats + listed = client.get("/api/cma/action-plans?status=overdue", headers=h).json()["data"] + assert len(listed) == stats["overdue"] == 1 + assert listed[0]["title"] == "小王的逾期" + assert len(client.get("/api/cma/action-plans", headers=h).json()["data"]) == stats["total"] + + +def test_overdue_with_other_filters_compose(client: TestClient, db: Session): + """派生口径与 kpi_id / keyword 组合时仍成立(不覆盖既有筛选)""" + kpi = _seed_e1(db) + token = _login(client, 1) + h = auth_header(token) + + hit = client.get(f"/api/cma/action-plans?status=overdue&kpi_id={kpi.id}", headers=h).json() + assert len(hit["data"]) == 2 + miss = client.get(f"/api/cma/action-plans?status=overdue&kpi_id={kpi.id + 999}", headers=h).json() + assert miss["data"] == [] + kw = client.get("/api/cma/action-plans?status=overdue&keyword=逾期", headers=h).json() + assert _titles(kw) == {T_OVERDUE_PENDING, T_OVERDUE_INPROG} + + +# ────────────────────────────────────────────── +# ⑦ 负对照:旧口径在同一夹具上确为 0 +# ────────────────────────────────────────────── + +def test_negative_control_legacy_caliber_returns_zero(db: Session): + """负对照:同一批夹具下 `status=='overdue'` 的行数 = 0、派生口径 = 2 + + 证明夹具真的能复现该缺陷(若这里两边相等,用例就没有鉴别力)。 + """ + _seed_e1(db) + literal = db.query(ActionPlan).filter(ActionPlan.status == "overdue").count() + derived = ap._apply_overdue(db.query(ActionPlan)).count() + assert literal == 0, "status 域不应存在 'overdue' 值" + assert derived == 2, derived