fix(action-plans): derive overdue filter in list endpoint (single source with stats)
ci/woodpecker/push/main Pipeline failed
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user