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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user