feat(dashboard): 工作台四区块实时聚合与风险预警参数
- 后端 /api/v1/dashboard 单端点返回四区块:经营总览(绩效/应付/人数/团队数/月结状态/近 6 个月趋势)、待办(待审批 + 回款未指定批次 + 同步显式降级)、风险预警(回款逾期/审批超时,阈值参数化)、最近动态(操作日志) - dashboard_service 实时派生(不落表、不引调度器);参数快照一次解析;总览只做 SQL 聚合,不复用报表的重查询 - V10 迁移新增预警参数三项(warning.enabled / warning.receipt.overdue.days=90 / warning.approval.timeout.days=3),参数注册表 19→22,init-dev 与 V10 SQL 双轨同步 - 测试:test_dashboard 5 例(权限文案/总览对账+非法月份 400/待办三类/预警命中与参数关闭/动态来源);全量 852 passed - 依赖闭包并入:core/month.py 月份校验、api/v1/base/business_roles.py(router 已挂载的岗位只读端点)、test_month_validation - 修复:alembic --sql 在 Windows 按控制台 GBK 输出,V10 SQL 以 UTF-8 重新导出(README 已记) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
b7cfdfa50a
commit
6ff202d637
18 files changed
+1262
-25
No files matched your search
+26
-6
@@ -140,12 +140,24 @@ uv run pytest tests/integration -q # 仅集成
|
||||
|
||||
| 文件 | 守什么 | 用例数 |
|
||||
| --- | --- | --- |
|
||||
| `tests/integration/test_reports.py` | 个人明细与台账逐笔一致 + **pageSize=1 时 summary 仍为全筛选合计**;部门汇总团队基数=成员绩效合计、12.5% 分成按参数、无成员经理出 0 值行;回款统计 8 档桶恒在、空桶为 0、**二次分摊拆行时回款金额只计一次**(防翻倍)、绩效合计=台账;业务统计排除经理聚合行(生成月结后绩效不被整团队聚合额污染);导出 xlsx 可解析且含「财务复核/领导签字」栏 + 操作日志留痕(module=报表统计/action=导出);普通用户 403 且文案「无权访问报表统计」 | 7 |
|
||||
| `tests/unit/test_report_helpers.py` | 区间分桶边界(NULL/负值/0/1/30/…/361+)与 8 档编码唯一;客户类型、产品类别标签覆盖全部常量枚举(防新增枚举后报表少一类);Excel 模板纯构建含页眉/签字栏、空数据提示行、金额为数值单元格且两位格式、合计行=明细之和、文本公式注入前置单引号、回款统计两个维度对比 sheet、业务统计状态转中文 | 11 |
|
||||
| `tests/integration/test_reports.py` | 个人明细与台账逐笔一致 + **pageSize=1 时 summary 仍为全筛选合计**;部门汇总团队基数=成员绩效合计、分成按参数、无成员经理出 0 值行;**无台账经理挂上级不 500、仅有调整单人员并入团队/未分配、经理个人户开关两侧口径、按经理筛选不误判其他团队下属、对账恒等式**;导出页眉 ID→名称/中文标签、回款维度 sheet 透传筛选、**导出超限与取数异常的失败审计**、非法月份查询/导出均 400;回款统计 8 档桶恒在、空桶为 0、**二次分摊拆行时回款金额只计一次**;业务统计排除经理聚合行;普通用户 403 | 24 |
|
||||
| `tests/unit/test_report_helpers.py` | 区间分桶边界与 8 档编码唯一;标签覆盖全部枚举;Excel 模板页眉/签字栏/空数据/金额格式/合计/公式注入/维度 sheet/状态中文;**部门汇总签字稿五块顺序、零成员团队与未分配行、全公司分成只出现一次、应发合计取对账口径、比例标题参数化** | 16 |
|
||||
| `tests/unit/test_month_validation.py` | 非法月份(`2026-13`/`2026-00`/`0000-01` 等)在报表、月结、调整单 schema 入口即拒绝;可选月份空串规范为 None | 17 |
|
||||
|
||||
运行:`uv run pytest tests/integration/test_reports.py -q` → **7 passed**(2026-10-19);`uv run pytest tests/unit/test_report_helpers.py -q` → **11 passed**(2026-10-19);全量回归见运行记录。
|
||||
运行:`uv run pytest tests/integration/test_reports.py -q` → **24 passed**(2026-10-19 修复回归);`uv run pytest tests/unit/test_report_helpers.py tests/unit/test_month_validation.py -q` → **33 passed**;全量 **847 passed**(2026-10-19)。
|
||||
|
||||
**待补**:e2e `frontend/e2e/report_flow.py`(四页筛选/导出下载/打印视图,需 `E2E_ADMIN_PASSWORD`)。
|
||||
**e2e**:`frontend/e2e/report_flow.py` 已补回款多桶同时展开、部门下钻筛选生效、打印截断提示、部门汇总打印五块结构;需 `E2E_ADMIN_PASSWORD` 与前后端在跑。
|
||||
|
||||
### 工作台(M4 ⑧,2026-10-19 完成)
|
||||
|
||||
| 文件 | 守什么 | 用例数 |
|
||||
| --- | --- | --- |
|
||||
| `tests/integration/test_dashboard.py` | 权限 403 且文案「无权访问工作台」;总览绩效合计与台账一致 + 非法月份 400;待办(月结提交→待审批命中、回款未指定批次命中、**同步未建显式降级**);风险预警(审批超时命中、**参数关闭后不产生条目**);最近动态来源操作日志 | 5 |
|
||||
| `tests/unit/test_param_registry.py`(改) | 注册表 19→**22 项**(V10 预警规则三项);编码集合与迁移种子一致 | 改 |
|
||||
|
||||
运行:`uv run pytest tests/integration/test_dashboard.py -q` → **5 passed**(2026-10-19)。
|
||||
|
||||
**实施中发现并修复**:`alembic upgrade --sql` 在 Windows 控制台按 GBK 输出,重定向生成的 `V10__*.sql` 被 `test_schema_sync` 按 UTF-8 读取时抛 `UnicodeDecodeError`——导出命令须加 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1`(已写入 backend/README)。
|
||||
|
||||
## 三、运行记录
|
||||
|
||||
@@ -167,6 +179,8 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 2026-10-19 | M3 定点(calc/settlements) | 34 | ✅ 通过(55s) | **code-review P0+P1 修复中**:经理聚合比例/调整单即生效/进度登记重算 400/财务节点不提前锁定/锁定拦截三路径/回填与聚合落库/月末取参(+新增 `test_settlement_helpers` 5 例、`test_calc_helpers` 4 例锁定 `_q2` HALF_UP) |
|
||||
| 2026-10-19 | 全量回归 | 780 | ✅ 780 passed(8:53) | **M3 code-review 21 项修复后全量回归**(P0 算错钱/不可用 6 项 → P1 锁定不变量与双轨漂移 7 项 → P2 健壮性 6 项 → calc_receipt 重构;新增回归约 20 例,含跨会话并入的 #12 聚合基数开关用例)。type-check 绿;e2e calc_settlement_flow 仍待补 |
|
||||
| 2026-10-19 | 拆行收口批次 | 全量 788 | ✅ 788 passed(9:20) | **2026-10-19 业务答复落实完成**:#14 首日取整 10‰(公式段/锚点不变,用例反转);#12 单笔侧开关消费(1=业务员递减计提);**二次分摊拆行**(参与人份额+负责人余额守恒,uk 三列版);权限分层(params 查看放开/写仅 admin);V8 迁移(审批开关默认开/撤试用期参数/增基数开关)。定点:calc 20 / settlements 13(含 #12 开关两档金标准 6.84→24.34)/ businesses 30(保存侧+3)/ formula 35。e2e calc_settlement_flow 交接并行会话 |
|
||||
| 2026-10-19 | **报表统计修复回归** | 全量 847 | ✅ 847 passed(10:13) | **M4 报表 code-review 15 项修复后全量回归**(后端 8 + 前端 7):归属三集合/调整单全量并入/经理个人户拆分/对账出参、分块签字稿导出、导出审计覆盖取数、筛选页眉 ID→名称、月份校验 400、岗位只读选项。报表集成 24 / 报表单元 16 / 月份校验 17。type-check + build 绿;e2e `report_flow.py` 扩至 12 步,待 `E2E_ADMIN_PASSWORD` 实跑 |
|
||||
| 2026-10-19 | 全部 | 852 | ✅ 852 passed(9:17) | **M4 ⑧ 工作台完成**(四区块实时聚合 + ECharts 5.6 前端 + V10 预警参数三项;新增 5 例;参数注册表 19→22;同步模块显式降级)。期间修复:alembic `--sql` 导出 GBK 导致 schema 同步误报 |
|
||||
| 2026-10-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 |
|
||||
| 2026-10-19 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 |
|
||||
|
||||
@@ -174,7 +188,13 @@ uv run pytest tests/integration -q # 仅集成
|
||||
|
||||
| 日期 | 缺陷 | 影响 | 处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| 2026-10-19 | 分摊负责人份额按余额百分数乘全额导致行合计丢 q2 尾差 | 拆 30%+40% 后负责人 100−70=30% 再乘未 q2 全额(39.1111×0.30=11.7333),行合计 39.10 ≠ 39.11——**每笔分摊账丢 1 分**,守恒破坏(金标准用例 `行合计==业务级全额` 抓住) | 负责人行恒走余额公式(q2 全额 −∑参与人 q2 份额);余额百分数仅作快照展示不参与计算 |
|
||||
| 2026-10-19 | 部门汇总导出/打印只展平成员行 | 团队分成、全公司 5% 块、未分配人员、零成员团队全部缺失——**签字稿合计小于应发**,财务按签字稿复核会漏发经理分成与未分配绩效 | 导出改为单 sheet 分块签字稿(团队/公司/对账/成员/未分配)+ 打印五块;应发公式与对账块由后端 Decimal 计算 |
|
||||
| 2026-10-19 | `_member_rows` 直接 `member_map[pid]` 索引 | 无台账行的营销经理自身挂上级时 KeyError → **部门汇总接口与导出 500**(当月完全无台账也会触发) | 归属只从 member_map/adjustments 生成;`manager_role` 覆盖所有成员引用的上级 |
|
||||
| 2026-10-19 | 生效调整单只并入有台账人员 | 只有调整单、当月无回款的人被静默丢弃——补发/扣减金额不出现,**按报表发放会少发** | 调整单人员补零值成员后参与团队/未分配归属;每人调整净额只计一次 |
|
||||
| 2026-10-19 | 经理个人户成员行取该人全部绩效 | 团队基数只含个人户行,成员行却含普通行——**基数≠成员合计,同一笔业绩在成员行与未分配重复** | 普通行与个人户分行记账;经理成员行只取个人户金额,普通行留在上级团队/未分配 |
|
||||
| 2026-10-19 | 导出取数与超限校验在审计上下文之外 | 超限 400 与取数异常**不留失败日志**,与 M4 计划 T12「导出 400/失败审计」不符 | `_export_payload` 移入 `with audit`;静态标题映射(AuditRecord frozen) |
|
||||
| 2026-10-19 | 月份只校验长度 | `2026-13` 传到 `calendar.monthrange` 抛 ValueError → **500**;导出手工构造模型抛裸 ValidationError 同样 500 | 新增 `core/month.py`(格式+01-12+年份);四类 query、月结、调整单接入;导出 ValidationError 转 400 |
|
||||
| 2026-10-19 | 前端报表五处状态缺陷 | 打印超限判定恒假(静默截断)、部门下钻 query 未读取、回款多桶共用一个 drill 状态(面板串行)、summary 在请求序号守卫前写入(合计串号)、e2e 全页行数选择器必失败 | 分别修正判定/读取 query/按桶独立状态+数组参数/`onData` 回调/限定主桶表;补 e2e 多桶展开、下钻、截断、部门打印用例 |
|
||||
| 2026-10-09 | 日志脱敏在 `logging.Filter` 阶段改写 `record.msg` | 抹掉 `%s` 占位符 → logging 内部 `msg % args` 抛 TypeError → **整条日志丢失**(比不脱敏更危险) | 改为 `SensitiveFormatter` 在格式化**之后**脱敏;补回归用例 |
|
||||
| 2026-10-09 | `PasswordHash.recommended()` 在 pwdlib 0.3.x 推荐 Argon2 而非 BCrypt | 密码算法与规范不符,且随库版本变化——同一批密码可能混用两种算法 | 改为显式 `PasswordHash((BcryptHasher(),))` 钉死算法 |
|
||||
| 2026-10-09 | 模型未设置表级 `comment`,`update_time` 用 `server_onupdate` | 违反规范红线;`server_onupdate` 只是 ORM 标记,**不生成 `ON UPDATE CURRENT_TIMESTAMP` 子句**,更新时间永不刷新 | 补表注释;改用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")` |
|
||||
@@ -229,6 +249,6 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
|
||||
| M3 增量(设置/分配/审批) | ✅ 已完成 | 2026-10-16 完成(44 例,见上表);审批流的**月结审批对象**(order_type=2)随 ④ 月结子块接入时补测 |
|
||||
| 绩效工资核算(M3-core) | ✅ 已完成 | 2026-10-17 完成(核算/月结/调整单,见运行记录);2026-10-19 code-review 21 项修复并补回归约 20 例。**待补**:e2e `calc_settlement_flow`(台账/月结两页真实浏览器链路);业务答复回填批次(88 会话:首日 10‰/聚合基数开关/二次分摊拆行)落地后按同粒度补测 |
|
||||
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
||||
| 报表统计 | ✅ 已完成 | 2026-10-19 完成并补齐回归;e2e 用例已写好,需完整环境用 `E2E_ADMIN_PASSWORD` 实跑 |
|
||||
|
||||
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
|
||||
@@ -190,14 +190,14 @@ def _purge_test_data() -> None:
|
||||
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
|
||||
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
|
||||
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
|
||||
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 25
|
||||
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 28
|
||||
# (V3 十九条 + V6 两条 + V7 调整单一条 + V8 经理基数开关一条;V8 另
|
||||
# 插入两个审批开关的 10-19 生效行 id 23/24,均属种子),删新条目与
|
||||
# 新批次后解析回退到种子值
|
||||
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 25)).scalars())
|
||||
# 插入两个审批开关的 10-19 生效行 id 23/24,V10 插入预警规则参数
|
||||
# id 26/27/28,均属种子),删新条目与新批次后解析回退到种子值
|
||||
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 28)).scalars())
|
||||
if stray_values:
|
||||
stray_batches = {v.batch_id for v in stray_values}
|
||||
session.execute(delete(ParamValue).where(ParamValue.id > 25))
|
||||
session.execute(delete(ParamValue).where(ParamValue.id > 28))
|
||||
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches)))
|
||||
# 系统设置恢复种子单行(test_settings 会改公司名/传 logo——残留会让
|
||||
# 后续 e2e 的品牌断言在污染状态下运行);uploads 下的测试 logo 一并删
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""工作台集成测试(M4 ⑧)。
|
||||
|
||||
守的口径:
|
||||
- 权限:普通用户 403 且文案「无权访问工作台」
|
||||
- 总览:绩效合计/人数与台账一致;月份参数非法 400
|
||||
- 待办:月结提交后出现待审批;回款未指定批次可命中;同步未建时显式降级
|
||||
- 风险预警:审批超时命中、参数关闭后不产生条目、阈值参数化生效
|
||||
- 最近动态:来源操作日志(写操作可见)
|
||||
|
||||
待办与预警为实时派生(不落表),测试只验证查询结果,不清理派生数据。
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_DASHBOARD = "/api/v1/dashboard"
|
||||
_PERSONNEL = "/api/v1/base/personnel"
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_SHIPMENTS = "/api/v1/business/shipments"
|
||||
_RECEIPTS = "/api/v1/business/receipts"
|
||||
_CALC = "/api/v1/calc"
|
||||
_SETTLEMENTS = "/api/v1/settlements"
|
||||
_PARAMS = "/api/v1/system/params"
|
||||
|
||||
|
||||
def _role_id(code: str) -> int:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import BusinessRole
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
return int(
|
||||
session.execute(
|
||||
select(BusinessRole.id).where(
|
||||
BusinessRole.role_code == code, BusinessRole.status == 1
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def _person(client: TestClient, headers: dict) -> dict:
|
||||
resp = client.post(
|
||||
_PERSONNEL,
|
||||
json={
|
||||
"personnel_name": f"t_工作台_{uuid.uuid4().hex[:8]}",
|
||||
"business_role_id": _role_id("salesperson"),
|
||||
"salary_standard": "3000.00",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _chain(client: TestClient, headers: dict, *, assign_shipment: bool = True) -> dict:
|
||||
"""客户/业务(100×100,成本 3000)→ 发货(60 天前)→ 回款 → 核算。"""
|
||||
from tests.integration.factories import business_item, business_payload, customer
|
||||
|
||||
cust = customer(client, headers)
|
||||
person = _person(client, headers)
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=business_payload(
|
||||
cust["id"],
|
||||
owner_personnel_id=person["id"],
|
||||
items=[business_item(cost_amount="3000.00")],
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
business = resp.json()["data"]
|
||||
|
||||
ship_date = (date.today() - timedelta(days=60)).isoformat()
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={
|
||||
"business_id": business["id"],
|
||||
"ship_date": ship_date,
|
||||
"items": [{"product_name": "t_工作台发货", "quantity": "1", "sale_price": "1.00"}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
shipment = resp.json()["data"]
|
||||
|
||||
payload = {
|
||||
"business_id": business["id"],
|
||||
"receipt_date": date.today().isoformat(),
|
||||
"amount": "10000.00",
|
||||
}
|
||||
if assign_shipment:
|
||||
payload["shipment_id"] = shipment["id"]
|
||||
resp = client.post(_RECEIPTS, json=payload, headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
receipt = resp.json()["data"]
|
||||
if assign_shipment:
|
||||
resp = client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return {"business": business, "person": person, "receipt": receipt}
|
||||
|
||||
|
||||
def _month() -> str:
|
||||
return date.today().strftime("%Y-%m")
|
||||
|
||||
|
||||
def _dashboard(client: TestClient, headers: dict, month: str | None = None) -> dict:
|
||||
params = {"month": month} if month else {}
|
||||
resp = client.get(_DASHBOARD, params=params, headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def test_权限_普通用户403文案(client: TestClient, normal: dict):
|
||||
resp = client.get(_DASHBOARD, headers=normal["headers"])
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["message"] == "无权访问工作台"
|
||||
|
||||
|
||||
def test_总览_绩效合计与台账一致且非法月份400(client: TestClient, admin: dict):
|
||||
_chain(client, admin["headers"])
|
||||
data = _dashboard(client, admin["headers"], _month())
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import PerformanceLedger
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
expected = Decimal(
|
||||
str(
|
||||
session.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)
|
||||
).where(
|
||||
PerformanceLedger.settlement_month == _month(),
|
||||
PerformanceLedger.calc_type == 1,
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
)
|
||||
assert Decimal(str(data["overview"]["performance_total"])) == expected
|
||||
assert data["overview"]["person_count"] >= 1
|
||||
assert len(data["overview"]["trend"]) == 6
|
||||
|
||||
resp = client.get(_DASHBOARD, params={"month": "2026-13"}, headers=admin["headers"])
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_待办_待审批与未指定批次与同步降级(client: TestClient, admin: dict):
|
||||
# 先造一笔已核算业务(月结生成要求有可结算记录),再补一笔未指定批次的回款
|
||||
_chain(client, admin["headers"], assign_shipment=True)
|
||||
ctx = _chain(client, admin["headers"], assign_shipment=False)
|
||||
# 月结提交 → 产生待审批单
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
settlement_id = resp.json()["data"]["id"]
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
data = _dashboard(client, admin["headers"])
|
||||
assert data["todos"]["pending_approval_total"] >= 1
|
||||
assert any(
|
||||
item["order_type"] == 2 for item in data["todos"]["pending_approvals"]
|
||||
)
|
||||
assert data["todos"]["unassigned_receipt_total"] >= 1
|
||||
assert data["todos"]["unassigned_receipts"][0]["receipt_id"] == ctx["receipt"]["id"]
|
||||
# ⑥ 未建:显式降级
|
||||
assert data["todos"]["sync_available"] is False
|
||||
|
||||
|
||||
def test_预警_审批超时命中且参数关闭后不产生(client: TestClient, admin: dict):
|
||||
_chain(client, admin["headers"])
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
|
||||
)
|
||||
settlement_id = resp.json()["data"]["id"]
|
||||
client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
|
||||
|
||||
# 把待审批单提交时间改到 4 天前(阈值默认 3 天)
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import ApprovalOrder
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
session.execute(
|
||||
update(ApprovalOrder)
|
||||
.where(ApprovalOrder.status.in_((1, 2)))
|
||||
.values(create_time=datetime.now() - timedelta(days=4))
|
||||
)
|
||||
session.commit()
|
||||
|
||||
data = _dashboard(client, admin["headers"])
|
||||
assert data["warnings"]["enabled"] is True
|
||||
assert any(item["code"] == "approval_timeout" for item in data["warnings"]["items"])
|
||||
|
||||
# 关闭预警开关 → 不产生条目
|
||||
resp = client.post(
|
||||
f"{_PARAMS}/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": "e2e 工作台测试:关闭风险预警",
|
||||
"items": [{"param_code": "warning.enabled", "dimension_value": "", "value": "0"}],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = _dashboard(client, admin["headers"])
|
||||
assert data["warnings"]["enabled"] is False
|
||||
assert data["warnings"]["items"] == []
|
||||
|
||||
|
||||
def test_动态_来源操作日志(client: TestClient, admin: dict):
|
||||
_chain(client, admin["headers"])
|
||||
data = _dashboard(client, admin["headers"])
|
||||
assert data["activities"], "工作台动态为空:写操作应产生操作日志"
|
||||
assert {"module", "action", "summary", "create_time"} <= set(data["activities"][0])
|
||||
@@ -86,10 +86,10 @@ def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of:
|
||||
class TestCatalog:
|
||||
"""参数目录:种子完整性与时点化回溯。"""
|
||||
|
||||
def test_目录共19项且覆盖八个分组(self, client: TestClient, admin: dict) -> None:
|
||||
"""V7 起含调整单审批开关(16 计提项 + 3 BOOL 开关)。"""
|
||||
def test_目录共22项且覆盖九个分组(self, client: TestClient, admin: dict) -> None:
|
||||
"""V10 起含工作台预警规则三项(16 计提项 + 3 BOOL 开关 + 3 预警项)。"""
|
||||
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
|
||||
assert len(items) == 19
|
||||
assert len(items) == 22
|
||||
groups = {item["param_group"] for item in items}
|
||||
assert groups == {
|
||||
"业务员个人户",
|
||||
@@ -100,6 +100,7 @@ class TestCatalog:
|
||||
"产品阈值",
|
||||
"月工资口径",
|
||||
"审批开关",
|
||||
"预警规则",
|
||||
}
|
||||
|
||||
def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None:
|
||||
@@ -444,7 +445,7 @@ class TestPermission:
|
||||
headers = auth_headers(user)
|
||||
resp = client.get(_PARAMS, headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["data"]) == 19
|
||||
assert len(resp.json()["data"]) == 22
|
||||
|
||||
def test_未登录访问目录_403(self, client: TestClient) -> None:
|
||||
# LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""月份校验单元测试。
|
||||
|
||||
守的口径:报表、月结、调整单入口都必须拦住 `2026-13`、`0000-01` 这类
|
||||
非法月份。只校验长度会让 ValueError 一路传到 `calendar.monthrange`,
|
||||
最终变成 500 而不是参数错误 400。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.core.month import parse_month, validate_month
|
||||
from app.schemas.report import DepartmentSummaryQuery, PersonalDetailQuery
|
||||
from app.schemas.settlement import AdjustmentCreateIn, SettlementGenerateIn
|
||||
|
||||
|
||||
class Test月份校验:
|
||||
"""合法月份通过,非法月份在入口即拒绝。"""
|
||||
|
||||
@pytest.mark.parametrize("month", ["2026-01", "2026-12", "2000-06"])
|
||||
def test_合法月份_通过(self, month: str):
|
||||
assert validate_month(month) == month
|
||||
year, mon = parse_month(month)
|
||||
assert (year, mon) == (int(month[:4]), int(month[5:7]))
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"month",
|
||||
["2026-13", "2026-00", "2026-1", "2026-1a", "0000-01", "abc", "2026/10"],
|
||||
)
|
||||
def test_非法月份_抛ValueError(self, month: str):
|
||||
with pytest.raises(ValueError):
|
||||
validate_month(month)
|
||||
|
||||
@pytest.mark.parametrize("month", ["2026-13", "2026-00", "0000-01"])
|
||||
def test_月结与调整单_schema拒绝非法月份(self, month: str):
|
||||
with pytest.raises(ValidationError):
|
||||
SettlementGenerateIn(month=month)
|
||||
with pytest.raises(ValidationError):
|
||||
AdjustmentCreateIn(
|
||||
settlement_month=month,
|
||||
personnel_id=1,
|
||||
direction=1,
|
||||
amount="100.00",
|
||||
reason="测试",
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("month", ["2026-13", "2026-00", "0000-01"])
|
||||
def test_报表查询_schema拒绝非法月份(self, month: str):
|
||||
with pytest.raises(ValidationError):
|
||||
DepartmentSummaryQuery(settlement_month=month)
|
||||
with pytest.raises(ValidationError):
|
||||
PersonalDetailQuery(settlement_month=month)
|
||||
|
||||
def test_报表可选月份_空串规范为None(self):
|
||||
"""axios 未剔除空串时不能误报格式错误。"""
|
||||
assert PersonalDetailQuery(settlement_month="").settlement_month is None
|
||||
@@ -18,8 +18,9 @@ from app.services.param_registry import (
|
||||
validate_and_normalize,
|
||||
)
|
||||
|
||||
# 与 db/migration 迁移种子保持一致的 19 项编码(V3 十六项 + V6 审批开关两项
|
||||
# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12)
|
||||
# 与 db/migration 迁移种子保持一致的 22 项编码(V3 十六项 + V6 审批开关两项
|
||||
# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12;
|
||||
# V10 增工作台预警规则三项 #10)
|
||||
EXPECTED_CODES = {
|
||||
"salesperson.rate.start",
|
||||
"salesperson.rate.slope",
|
||||
@@ -40,14 +41,17 @@ EXPECTED_CODES = {
|
||||
"approval.monthly_settlement.enabled",
|
||||
"approval.adjustment.enabled",
|
||||
"manager.personal.included",
|
||||
"warning.enabled",
|
||||
"warning.receipt.overdue.days",
|
||||
"warning.approval.timeout.days",
|
||||
}
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
"""注册表元数据完整性(与迁移种子同源)。"""
|
||||
|
||||
def test_注册表共19项(self) -> None:
|
||||
assert len(PARAM_REGISTRY) == 19
|
||||
def test_注册表共22项(self) -> None:
|
||||
assert len(PARAM_REGISTRY) == 22
|
||||
|
||||
def test_编码集合与迁移种子一致(self) -> None:
|
||||
assert set(PARAM_REGISTRY) == EXPECTED_CODES
|
||||
|
||||
Reference in new issue
Block a user