fix(action-plans): 「已逾期」改日期粒度(口径 B,当天到期不算逾期)

- _apply_overdue():`ActionPlan.due_date < datetime.now()` → `... < 今天0点`
  (唯一实现,plan_stats 与 list_plans 共用,故一处改即两端点同改)
- 口径同步注释(口径 A→B 的沿革与分界式);行为改动仅此一处
- 测试夹具改到日粒度(整天偏移),新增「今日到期不算逾期」鉴别行与
  `test_overdue_caliber_is_date_granularity` 口径锁定用例(防回退 A)
- 依据:任总 2026-09-22 10:10 拍板口径 B;前端角标 ActionPlanLibrary.vue::isOverdue
  与 cash.py::_plan_dict 本就同粒度,本单未动
- 只改码:未 push / 未部署 / 未重启(发布须放行)

(cherry picked from commit 2da5b6da018013f860430913b5b0e0436f966685)
This commit is contained in:
Hermes CI Fix
2026-10-18 16:31:52 +08:00
parent 5fa83e2532
commit 57f9dbae15
2 changed files with 92 additions and 31 deletions
+11 -3
View File
@@ -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,
)
@@ -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"]