fix(action-plans): derive overdue filter in list endpoint (single source with stats)
ci/woodpecker/push/main Pipeline failed

Frontend sends card key status=overdue to GET /api/cma/action-plans, but the

list endpoint compared ActionPlan.status == 'overdue' which is not in the

status domain -> always 0 rows while /stats showed 7 (derived caliber).

Extract _apply_overdue() and use it in both plan_stats and list_plans.

Refs: project-20260921-actionplan-overdue-filter
This commit is contained in:
Hermes CI Fix
2026-09-21 15:13:27 +08:00
parent 27ea7e7f53
commit f88ed94c15
2 changed files with 330 additions and 3 deletions
@@ -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