diff --git a/backend/app/api/action_plans.py b/backend/app/api/action_plans.py index 0269587e..55423ea4 100644 --- a/backend/app/api/action_plans.py +++ b/backend/app/api/action_plans.py @@ -66,7 +66,9 @@ def _validate_due_date_against_quarter(due_date: datetime, quarter: str): # ────────────────────────────────────────────── # 「已逾期」派生口径(单一来源,2026-09-21 project-20260921-actionplan-overdue-filter) # ────────────────────────────────────────────── -# 口径:overdue = status in (pending, in_progress) AND due_date < now +# 口径:overdue = status in (pending, in_progress) AND due_date < 今天0点 +# (**日期粒度 · 口径 B**,任总 2026-09-22 10:10 拍板;当天到期不算逾期。 +# 2026-09-21 首版为 datetime 粒度 `due_date < now`,本次仅此一处改为日粒度。) # # 为什么要有这个函数(缺陷根因):status 的取值域只有 # pending / in_progress / completed / cancelled —— **没有** overdue 这个值。 @@ -82,10 +84,16 @@ OVERDUE_STATUSES = ("pending", "in_progress") def _apply_overdue(query): - """把「已逾期」派生口径施加到 ActionPlan 查询上(stats / list 共用)。""" + """把「已逾期」派生口径施加到 ActionPlan 查询上(stats / list 共用)。 + + 口径 B(日期粒度,任总 2026-09-22 拍板):due_date < **今天 0 点** —— 当天到期不算逾期。 + 与 cash.py::_plan_dict(应收催收 overdue)及前端行内角标 + ActionPlanLibrary.vue::isOverdue 同粒度(二者本就是日期粒度,本单未动)。 + """ + today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) return query.filter( ActionPlan.status.in_(list(OVERDUE_STATUSES)), - ActionPlan.due_date < datetime.now(), + ActionPlan.due_date < today_start, ) diff --git a/backend/tests/test_actionplan_overdue_filter_20260921.py b/backend/tests/test_actionplan_overdue_filter_20260921.py index 10c28493..e88ee1e7 100644 --- a/backend/tests/test_actionplan_overdue_filter_20260921.py +++ b/backend/tests/test_actionplan_overdue_filter_20260921.py @@ -8,7 +8,9 @@ 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。 + /stats 的 overdue 是派生口径:status in (pending, in_progress) AND due_date < 今天0点 + (**口径 B · 日期粒度**,任总 2026-09-22 拍板;首版为 datetime 粒度 `due_date < now`, + 2026-09-22 改 B:当天到期**不算**逾期,与 cash.py / 前端角标同粒度)。 两处口径分家 ⇒ 同一张卡片「数 7 条 / 列表 0 条」。 修复 @@ -17,12 +19,15 @@ 本文件覆盖 ① 已逾期列表条数 == stats.overdue(HTTP 全链路:鉴权 + 账套 + 查询参数) ② 其余卡片(total/pending/in_progress/completed)列表条数与 stats 逐项相符 - ③ 口径同源:派生集合 ⊆ {pending,in_progress} 且 due_date < now;status=completed/cancelled + ③ 口径同源:派生集合 ⊆ {pending,in_progress} 且 due_date < 今天0点;status=completed/cancelled 即使已过期也不计入 - ④ 边界:刚过期(-60s)计入 / 未到期(+60s)不计入 / due_date 为空不计入 + ④ 边界(口径 B 日粒度):今天0点到期 / 今天此刻到期 / 明天到期 —— 均**不算**逾期; + 昨天 23:59:59 到期 —— 算逾期;due_date 为空不计入 ⑤ 账套隔离:e1 会话只看 e1 KPI 下的计划,e2 逾期计划不出现在 e1 的逾期列表 ⑥ business 角色过滤:只统计/返回自己的计划(列表与统计同时收窄) ⑦ 负对照:同一批夹具下「旧口径」status=='overdue' 确为 0 条(证明夹具真能复现缺陷) + ⑧ 口径锁定(机器可验):_apply_overdue 源码为日粒度(`due_date < 今天0点`), + 且不再出现 datetime 粒度写法 `ActionPlan.due_date < datetime.now()` """ import inspect from datetime import datetime, timedelta @@ -37,14 +42,22 @@ from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user, cr E1_KPI_CODE = "ZZ_APOVD_E1_KPI_001" E2_KPI_CODE = "ZZ_APOVD_E2_KPI_001" +DAY = 86400 # 口径 B 是日粒度,夹具一律用「整天」偏移,避免当天内的小时级偏移歧义 + T_OVERDUE_PENDING = "逾期待处理" T_OVERDUE_INPROG = "逾期进行中" T_FUTURE_PENDING = "未到期待处理" +T_DUE_TODAY_PENDING = "今日到期待处理" # 口径 B 的关键鉴别行:当天到期 → 不算逾期 T_OVERDUE_COMPLETED = "已完成且已过期" T_OVERDUE_CANCELLED = "已取消且已过期" T_NO_DUE_PENDING = "无截止日待处理" +def _today_start() -> datetime: + """今天 0 点 —— 口径 B 的分界(与 _apply_overdue 内部同式)。""" + return datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + + # ────────────────────────────────────────────── # 夹具 # ────────────────────────────────────────────── @@ -64,8 +77,14 @@ def _mk_kpi(db: Session, code: str, eid: int) -> KPIDefinition: 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) + assignee: str = "张三", due_exact=None) -> ActionPlan: + """due_exact 优先(用于日粒度边界:今天0点 / 昨天23:59:59);否则 now + due_delta_sec。""" + if due_exact is not None: + due = due_exact + elif due_delta_sec is None: + due = None + else: + due = datetime.now() + timedelta(seconds=due_delta_sec) plan = ActionPlan( entity_id=1, kpi_id=kpi_id, @@ -94,16 +113,22 @@ def _login(client: TestClient, entity_id: int, username: str = "testadmin") -> s def _seed_e1(db: Session): - """e1 夹具:2 条逾期 + 4 条不计入(未到期 / 已完成 / 已取消 / 无截止日)""" + """e1 夹具:2 条逾期 + 5 条不计入(今日到期 / 未到期 / 已完成 / 已取消 / 无截止日) + + 口径 B:只有「到期日 < 今天0点」才算逾期 —— 故逾期行用 -2天 / -1天, + 并专设「今日到期」(今天0点)一行做鉴别(A 口径下它会被误算逾期)。 + """ _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) # 无截止日 + _mk_plan(db, kpi.id, T_OVERDUE_PENDING, "pending", -2 * DAY) # 逾期(前天) + _mk_plan(db, kpi.id, T_OVERDUE_INPROG, "in_progress", -DAY) # 逾期(昨天) + _mk_plan(db, kpi.id, T_DUE_TODAY_PENDING, "pending", None, + due_exact=_today_start()) # 今日到期→不算逾期 + _mk_plan(db, kpi.id, T_FUTURE_PENDING, "pending", +DAY) # 未到期 + _mk_plan(db, kpi.id, T_OVERDUE_COMPLETED, "completed", -2 * DAY) # 已完成→不算逾期 + _mk_plan(db, kpi.id, T_OVERDUE_CANCELLED, "cancelled", -2 * DAY) # 已取消→不算逾期 + _mk_plan(db, kpi.id, T_NO_DUE_PENDING, "pending", None) # 无截止日 return kpi @@ -132,10 +157,12 @@ def test_overdue_list_count_equals_stats(client: TestClient, db: Session): assert _titles(listed) == {T_OVERDUE_PENDING, T_OVERDUE_INPROG}, ( f"逾期列表内容应为 2 条逾期待办,实际 {sorted(_titles(listed))}" ) - # 口径内容断言:全部落在 status ∈ {pending, in_progress} 且 due_date < now + # 口径内容断言:全部落在 status ∈ {pending, in_progress} 且 due_date < 今天0点(口径 B) + today_start = _today_start() + assert T_DUE_TODAY_PENDING not in _titles(listed), "当天到期不得算作逾期(口径 B)" 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 + assert row["due_date"] and datetime.fromisoformat(row["due_date"]) < today_start, row # ────────────────────────────────────────────── @@ -149,7 +176,7 @@ def test_other_cards_match_stats(client: TestClient, db: Session): h = auth_header(token) stats = client.get("/api/cma/action-plans/stats", headers=h).json() - assert stats == {"total": 6, "pending": 3, "in_progress": 1, + assert stats == {"total": 7, "pending": 4, "in_progress": 1, "completed": 1, "overdue": 2}, stats cards = {"total": "", "pending": "pending", "in_progress": "in_progress", @@ -178,7 +205,7 @@ def test_overdue_excludes_completed_and_cancelled(client: TestClient, db: Sessio # 字面 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 + assert len(client.get("/api/cma/action-plans?status=pending", headers=h).json()["data"]) == 4 def test_overdue_caliber_is_single_source(): @@ -195,21 +222,44 @@ def test_overdue_caliber_is_single_source(): # ④ 边界 # ────────────────────────────────────────────── -def test_overdue_boundary_and_null_due_date(client: TestClient, db: Session): - """边界:-60s 计入、+60s 不计入、due_date 为空不计入""" +def test_overdue_boundary_date_granularity_and_null_due_date(client: TestClient, db: Session): + """边界(口径 B 日粒度):今日到期(0点/此刻)与明天到期 → **不算**逾期; + 昨天 23:59:59 到期 → 算逾期;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) + today_start = _today_start() + _mk_plan(db, kpi.id, "今天0点到期", "pending", None, due_exact=today_start) + _mk_plan(db, kpi.id, "今天此刻到期", "pending", None, due_exact=datetime.now()) + _mk_plan(db, kpi.id, "昨天235959到期", "pending", None, + due_exact=today_start - timedelta(seconds=1)) + _mk_plan(db, kpi.id, "明天到期", "pending", +DAY) _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 + assert titles == {"昨天235959到期"}, ( + "口径 B:仅「到期日 < 今天0点」算逾期 —— 今日0点/今日此刻/明天/无截止日均不得计入" + f",实际 {sorted(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 + assert stats["overdue"] == 1 and stats["pending"] == 5 and stats["total"] == 5, stats + # 今日到期行仍应出现在 pending 卡片与列表里(只是不算逾期) + assert "今天0点到期" in _titles(client.get("/api/cma/action-plans?status=pending", headers=h).json()) + + +def test_overdue_caliber_is_date_granularity(): + """口径锁定(机器可验):_apply_overdue 源码为**日期粒度**,且无 datetime 粒度写法。 + + 口径 B = 任总 2026-09-22 拍板;本用例防"回退到 due_date < datetime.now()"。 + """ + src = inspect.getsource(ap._apply_overdue) + assert "ActionPlan.due_date < today_start" in src, f"未使用日粒度分界 today_start:\n{src}" + assert "replace(hour=0" in src and "microsecond=0" in src, f"今天0点未按日粒度计算:\n{src}" + assert "ActionPlan.due_date < datetime.now()" not in src, ( + f"仍存在 datetime 粒度写法(口径 A):\n{src}" + ) # ────────────────────────────────────────────── @@ -223,8 +273,8 @@ def test_overdue_entity_isolation(client: TestClient, db: Session): 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) + _mk_plan(db, k1.id, "e1逾期", "pending", -2 * DAY) + p2 = _mk_plan(db, k2.id, "e2逾期", "pending", -2 * DAY) p2.entity_id = 2 db.commit() @@ -253,17 +303,20 @@ def test_overdue_business_role_filter(client: TestClient, db: Session): 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="业务小王") + _mk_plan(db, kpi.id, "小王的逾期", "pending", -2 * DAY, assignee="业务小王") + _mk_plan(db, kpi.id, "张三的逾期", "pending", -2 * DAY, assignee="张三") + _mk_plan(db, kpi.id, "小王的未到期", "pending", +DAY, assignee="业务小王") + _mk_plan(db, kpi.id, "小王的今日到期", "pending", None, + due_exact=_today_start(), 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, + assert stats == {"total": 3, "pending": 3, "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 "小王的今日到期" not in {r["title"] for r in listed}, "当天到期不算逾期(口径 B)" assert len(client.get("/api/cma/action-plans", headers=h).json()["data"]) == stats["total"]