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:
@@ -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"]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user