2026-10-19 06:44:15 -04:00
|
|
|
|
"""工作台集成测试(M4 ⑧)。
|
|
|
|
|
|
|
|
|
|
|
|
守的口径:
|
|
|
|
|
|
- 权限:普通用户 403 且文案「无权访问工作台」
|
|
|
|
|
|
- 总览:绩效合计/人数与台账一致;月份参数非法 400
|
2026-11-02 22:20:55 -05:00
|
|
|
|
- 待办:月结提交后出现待审批;回款未指定批次可命中;同步状态取真实记录
|
2026-10-19 06:44:15 -04:00
|
|
|
|
- 风险预警:审批超时命中、参数关闭后不产生条目、阈值参数化生效
|
|
|
|
|
|
- 最近动态:来源操作日志(写操作可见)
|
|
|
|
|
|
|
|
|
|
|
|
待办与预警为实时派生(不落表),测试只验证查询结果,不清理派生数据。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
from datetime import date, datetime, timedelta
|
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
|
2026-10-19 06:44:15 -04:00
|
|
|
|
|
|
|
|
|
|
_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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-23 04:15:09 -04:00
|
|
|
|
def _activities(client: TestClient, headers: dict, **params) -> dict:
|
|
|
|
|
|
"""最近动态分页端点。"""
|
|
|
|
|
|
resp = client.get(f"{_DASHBOARD}/activities", params=params, headers=headers)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
return resp.json()["data"]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 06:44:15 -04:00
|
|
|
|
def test_权限_普通用户403文案(client: TestClient, normal: dict):
|
|
|
|
|
|
resp = client.get(_DASHBOARD, headers=normal["headers"])
|
|
|
|
|
|
assert resp.status_code == 403
|
2026-11-02 22:20:55 -05:00
|
|
|
|
assert resp.json()["message"] == "当前账号没有「首页:查看」权限,请联系管理员授权"
|
2026-10-23 04:15:09 -04:00
|
|
|
|
# 最近动态分页端点同属工作台,权限一致
|
|
|
|
|
|
resp = client.get(f"{_DASHBOARD}/activities", headers=normal["headers"])
|
|
|
|
|
|
assert resp.status_code == 403
|
2026-10-19 06:44:15 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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(
|
2026-10-22 07:43:20 -04:00
|
|
|
|
select(func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)).where(
|
2026-10-19 06:44:15 -04:00
|
|
|
|
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"]
|
2026-10-22 07:43:20 -04:00
|
|
|
|
resp = client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
|
2026-10-19 06:44:15 -04:00
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
|
|
|
|
|
|
data = _dashboard(client, admin["headers"])
|
|
|
|
|
|
assert data["todos"]["pending_approval_total"] >= 1
|
2026-10-22 07:43:20 -04:00
|
|
|
|
assert any(item["order_type"] == 2 for item in data["todos"]["pending_approvals"])
|
2026-10-19 06:44:15 -04:00
|
|
|
|
assert data["todos"]["unassigned_receipt_total"] >= 1
|
|
|
|
|
|
assert data["todos"]["unassigned_receipts"][0]["receipt_id"] == ctx["receipt"]["id"]
|
2026-11-02 22:20:55 -05:00
|
|
|
|
assert data["todos"]["sync_available"] is True
|
|
|
|
|
|
assert data["sync"]["available"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_首页关闭读取仍展示真实采集成功记录(client: TestClient, admin: dict, monkeypatch):
|
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import delete, func, select
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.db import get_session_factory
|
|
|
|
|
|
from app.models import TPlusSyncBatch
|
|
|
|
|
|
from app.services import dashboard_service
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(dashboard_service, "get_settings", lambda: SimpleNamespace(
|
|
|
|
|
|
t_plus_enabled=False, t_plus_automation_enabled=False
|
|
|
|
|
|
))
|
|
|
|
|
|
prefix = "dashboard-" + uuid.uuid4().hex[:12]
|
|
|
|
|
|
finished = datetime(2026, 10, 30, 16, 52, 3)
|
|
|
|
|
|
factory = get_session_factory()
|
|
|
|
|
|
with factory() as session:
|
|
|
|
|
|
baseline = session.scalar(select(func.count()).select_from(TPlusSyncBatch).where(
|
|
|
|
|
|
TPlusSyncBatch.sync_mode.in_(("FULL", "INCREMENTAL")),
|
|
|
|
|
|
TPlusSyncBatch.status.in_(("FAILED", "PARTIAL")),
|
|
|
|
|
|
))
|
|
|
|
|
|
for index, (mode, status) in enumerate([
|
|
|
|
|
|
("FULL", "FAILED"), ("FULL", "SUCCEEDED"), ("AUDIT", "FAILED")
|
|
|
|
|
|
]):
|
|
|
|
|
|
session.add(TPlusSyncBatch(
|
|
|
|
|
|
batch_no=f"{prefix}-{index}", sync_mode=mode, status=status,
|
|
|
|
|
|
source_database="dashboard-test", finished_at=finished,
|
|
|
|
|
|
))
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = _dashboard(client, admin["headers"])
|
|
|
|
|
|
assert data["sync"]["latest_status"] == "SUCCEEDED"
|
|
|
|
|
|
assert data["sync"]["last_success_at"] is not None
|
|
|
|
|
|
assert data["sync"]["enabled"] is False
|
|
|
|
|
|
assert data["sync"]["automation_enabled"] is False
|
|
|
|
|
|
assert data["todos"]["sync_available"] is True
|
|
|
|
|
|
assert data["warnings"]["sync_available"] is True
|
|
|
|
|
|
assert data["todos"]["sync_failure_total"] == baseline + 1
|
|
|
|
|
|
finally:
|
|
|
|
|
|
with factory() as session:
|
|
|
|
|
|
session.execute(delete(TPlusSyncBatch).where(
|
|
|
|
|
|
TPlusSyncBatch.batch_no.in_([f"{prefix}-{index}" for index in range(3)])
|
|
|
|
|
|
))
|
|
|
|
|
|
session.commit()
|
2026-10-19 06:44:15 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 天)
|
2026-10-19 21:45:03 -04:00
|
|
|
|
from sqlalchemy import update
|
2026-10-19 06:44:15 -04:00
|
|
|
|
|
|
|
|
|
|
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"])
|
2026-10-23 04:15:09 -04:00
|
|
|
|
data = _activities(client, admin["headers"])
|
|
|
|
|
|
assert data["list"], "工作台动态为空:写操作应产生操作日志"
|
|
|
|
|
|
assert {"module", "action", "summary", "create_time"} <= set(data["list"][0])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_动态_分页翻页不重不漏(client: TestClient, admin: dict):
|
|
|
|
|
|
_chain(client, admin["headers"])
|
|
|
|
|
|
first = _activities(client, admin["headers"], pageNum=1, pageSize=2)
|
|
|
|
|
|
assert first["pageNum"] == 1
|
|
|
|
|
|
assert first["pageSize"] == 2
|
|
|
|
|
|
assert len(first["list"]) == 2
|
|
|
|
|
|
assert first["total"] >= 2
|
|
|
|
|
|
|
|
|
|
|
|
second = _activities(client, admin["headers"], pageNum=2, pageSize=2)
|
|
|
|
|
|
assert len(second["list"]) == 2
|
|
|
|
|
|
# 两页不重不漏(倒序取,按 id 倒排)
|
|
|
|
|
|
first_ids = {item["id"] for item in first["list"]}
|
|
|
|
|
|
second_ids = {item["id"] for item in second["list"]}
|
|
|
|
|
|
assert first_ids.isdisjoint(second_ids)
|
|
|
|
|
|
assert first["list"][0]["id"] > second["list"][0]["id"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_动态_模块筛选与筛选项(client: TestClient, admin: dict):
|
|
|
|
|
|
_chain(client, admin["headers"])
|
|
|
|
|
|
page = _activities(client, admin["headers"], pageSize=50)
|
|
|
|
|
|
assert page["modules"], "模块筛选项不应为空"
|
|
|
|
|
|
module = page["modules"][0]
|
|
|
|
|
|
|
|
|
|
|
|
filtered = _activities(client, admin["headers"], module=module, pageSize=50)
|
|
|
|
|
|
assert filtered["total"] >= 1
|
|
|
|
|
|
assert filtered["list"], "按模块筛选后应有数据"
|
|
|
|
|
|
assert all(item["module"] == module for item in filtered["list"])
|
|
|
|
|
|
# 筛选项不随筛选条件变化(下拉里始终是全量模块)
|
|
|
|
|
|
assert filtered["modules"] == page["modules"]
|
2026-10-19 21:45:03 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def finance(make_user, auth_headers) -> dict:
|
|
|
|
|
|
user = make_user(["finance"])
|
|
|
|
|
|
user["headers"] = auth_headers(user)
|
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def leader(make_user, auth_headers) -> dict:
|
|
|
|
|
|
user = make_user(["leader"])
|
|
|
|
|
|
user["headers"] = auth_headers(user)
|
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _insert_approval(status: int) -> None:
|
2026-10-22 01:31:01 -04:00
|
|
|
|
"""直插在途审批单(避免依赖两次流转,专注工作台过滤口径)。
|
|
|
|
|
|
|
|
|
|
|
|
``ref_id`` 仅作对象分组锚点、本用例不校验:**每张单必须给不同的
|
|
|
|
|
|
ref_id**——V12 的 ``uk_approval_orders_order_type_ref_version`` 要求
|
|
|
|
|
|
(order_type, ref_id, version_no) 唯一,同一假对象连插两张 version_no=1
|
|
|
|
|
|
会撞 1062。
|
|
|
|
|
|
"""
|
2026-10-19 21:45:03 -04:00
|
|
|
|
from app.core.db import get_session_factory
|
|
|
|
|
|
from app.models import ApprovalOrder
|
|
|
|
|
|
|
|
|
|
|
|
factory = get_session_factory()
|
|
|
|
|
|
with factory() as session:
|
|
|
|
|
|
session.add(
|
|
|
|
|
|
ApprovalOrder(
|
|
|
|
|
|
order_no=f"AP{uuid.uuid4().hex[:12]}",
|
|
|
|
|
|
order_type=2,
|
2026-10-22 01:31:01 -04:00
|
|
|
|
ref_id=status,
|
2026-10-19 21:45:03 -04:00
|
|
|
|
refer_no="t_工作台",
|
|
|
|
|
|
status=status,
|
|
|
|
|
|
submitter_name="t_admin",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _set_param(client: TestClient, headers: dict, code: str, value: str) -> None:
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
f"{_PARAMS}/changes",
|
|
|
|
|
|
json={
|
|
|
|
|
|
"change_type": 1,
|
|
|
|
|
|
"remark": f"t_工作台:{code}",
|
|
|
|
|
|
"items": [{"param_code": code, "dimension_value": "", "value": value}],
|
|
|
|
|
|
},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _shipment_scene(client: TestClient, headers: dict, *, days_ago: int) -> tuple[dict, dict]:
|
|
|
|
|
|
"""造一笔无回款的发货(用于回款逾期预警)。"""
|
|
|
|
|
|
from tests.integration.factories import business_item, business_payload, customer
|
|
|
|
|
|
|
|
|
|
|
|
person = _person(client, headers)
|
|
|
|
|
|
cust = customer(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=days_ago)).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
|
|
|
|
|
|
return business, resp.json()["data"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_待办_按角色仅显示可操作节点(
|
|
|
|
|
|
client: TestClient, admin: dict, finance: dict, leader: dict, make_user, auth_headers
|
|
|
|
|
|
):
|
|
|
|
|
|
"""财务只看到待财务复核、领导只看到待领导审核;admin 与多角色看并集。"""
|
|
|
|
|
|
_insert_approval(1)
|
|
|
|
|
|
_insert_approval(2)
|
|
|
|
|
|
|
|
|
|
|
|
assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 1
|
|
|
|
|
|
assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 1
|
|
|
|
|
|
assert _dashboard(client, admin["headers"])["todos"]["pending_approval_total"] == 2
|
|
|
|
|
|
|
|
|
|
|
|
both = make_user(["finance", "leader"])
|
|
|
|
|
|
both["headers"] = auth_headers(both)
|
|
|
|
|
|
assert _dashboard(client, both["headers"])["todos"]["pending_approval_total"] == 2
|
|
|
|
|
|
|
|
|
|
|
|
finance_orders = {
|
2026-10-22 07:43:20 -04:00
|
|
|
|
item["order_no"]
|
|
|
|
|
|
for item in _dashboard(client, finance["headers"])["todos"]["pending_approvals"]
|
2026-10-19 21:45:03 -04:00
|
|
|
|
}
|
|
|
|
|
|
leader_orders = {
|
2026-10-22 07:43:20 -04:00
|
|
|
|
item["order_no"]
|
|
|
|
|
|
for item in _dashboard(client, leader["headers"])["todos"]["pending_approvals"]
|
2026-10-19 21:45:03 -04:00
|
|
|
|
}
|
|
|
|
|
|
assert finance_orders.isdisjoint(leader_orders)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 07:43:20 -04:00
|
|
|
|
def test_待办_财务通过后领导出现待办(client: TestClient, admin: dict, finance: dict, leader: dict):
|
2026-10-19 21:45:03 -04:00
|
|
|
|
"""真实流转:财务节点通过后,条目从财务待办消失并进入领导待办。"""
|
|
|
|
|
|
_chain(client, admin["headers"])
|
|
|
|
|
|
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"]
|
2026-10-22 07:43:20 -04:00
|
|
|
|
resp = client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"])
|
2026-10-19 21:45:03 -04:00
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
|
|
|
|
|
|
order = _dashboard(client, admin["headers"])["todos"]["pending_approvals"][0]
|
|
|
|
|
|
assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 1
|
|
|
|
|
|
assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
f"/api/v1/system/approvals/{order['order_id']}/action",
|
|
|
|
|
|
json={"action": 1},
|
|
|
|
|
|
headers=finance["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 0
|
|
|
|
|
|
assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_预警_回款逾期命中且阈值参数生效(client: TestClient, admin: dict):
|
|
|
|
|
|
"""发货超阈值且无任何回款 → 命中;调大阈值后不再命中。"""
|
|
|
|
|
|
_, shipment = _shipment_scene(client, admin["headers"], days_ago=120)
|
|
|
|
|
|
|
|
|
|
|
|
data = _dashboard(client, admin["headers"])
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
item["code"] == "receipt_overdue" and item["object_id"] == shipment["id"]
|
|
|
|
|
|
for item in data["warnings"]["items"]
|
|
|
|
|
|
), "120 天未回款的发货批次未命中预警"
|
|
|
|
|
|
|
|
|
|
|
|
_set_param(client, admin["headers"], "warning.receipt.overdue.days", "200")
|
|
|
|
|
|
data = _dashboard(client, admin["headers"])
|
|
|
|
|
|
assert not any(
|
|
|
|
|
|
item["code"] == "receipt_overdue" and item["object_id"] == shipment["id"]
|
|
|
|
|
|
for item in data["warnings"]["items"]
|
|
|
|
|
|
), "阈值调大后仍命中,说明参数未生效"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_预警_终止业务不再产生回款逾期(client: TestClient, admin: dict):
|
|
|
|
|
|
"""已终止业务不再催收,其未回款发货单不得留在预警列表。"""
|
|
|
|
|
|
business, shipment = _shipment_scene(client, admin["headers"], days_ago=120)
|
|
|
|
|
|
resp = client.put(
|
|
|
|
|
|
f"{_BUSINESSES}/{business['id']}/status",
|
|
|
|
|
|
json={"status": 3, "reason": "t_工作台终止"},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
|
|
|
|
|
|
data = _dashboard(client, admin["headers"])
|
|
|
|
|
|
assert not any(item["object_id"] == shipment["id"] for item in data["warnings"]["items"])
|
|
|
|
|
|
assert data["warnings"]["receipt_overdue_total"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_预警_总数不受展示上限截断(client: TestClient, admin: dict, monkeypatch):
|
|
|
|
|
|
"""items 只展示前 N 条,徽标 total 必须仍是全量命中数。"""
|
|
|
|
|
|
from app.services import dashboard_service
|
|
|
|
|
|
|
|
|
|
|
|
_shipment_scene(client, admin["headers"], days_ago=120)
|
|
|
|
|
|
_shipment_scene(client, admin["headers"], days_ago=120)
|
|
|
|
|
|
monkeypatch.setattr(dashboard_service, "_WARNING_LIMIT", 1)
|
|
|
|
|
|
|
|
|
|
|
|
data = _dashboard(client, admin["headers"])
|
|
|
|
|
|
assert data["warnings"]["total"] == 2
|
|
|
|
|
|
assert len(data["warnings"]["items"]) == 1
|
|
|
|
|
|
assert data["warnings"]["total"] == data["warnings"]["receipt_overdue_total"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_总览_回款趋势按到账月跨月归属(client: TestClient, admin: dict):
|
|
|
|
|
|
"""趋势窗口按 receipt_date 范围过滤,月初/月末不得串月。"""
|
|
|
|
|
|
from tests.integration.factories import business_item, business_payload, customer
|
|
|
|
|
|
|
|
|
|
|
|
person = _person(client, admin["headers"])
|
|
|
|
|
|
cust = customer(client, admin["headers"])
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
_BUSINESSES,
|
|
|
|
|
|
json=business_payload(
|
|
|
|
|
|
cust["id"],
|
|
|
|
|
|
owner_personnel_id=person["id"],
|
|
|
|
|
|
items=[business_item(cost_amount="3000.00")],
|
|
|
|
|
|
),
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
business = resp.json()["data"]
|
|
|
|
|
|
|
|
|
|
|
|
first_of_month = date.today().replace(day=1)
|
|
|
|
|
|
previous_last = first_of_month - timedelta(days=1)
|
|
|
|
|
|
for receipt_date in (previous_last, first_of_month):
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
_RECEIPTS,
|
|
|
|
|
|
json={
|
|
|
|
|
|
"business_id": business["id"],
|
|
|
|
|
|
"receipt_date": receipt_date.isoformat(),
|
|
|
|
|
|
"amount": "1000.00",
|
|
|
|
|
|
},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
|
|
|
|
|
|
trend = {
|
|
|
|
|
|
item["month"]: Decimal(str(item["receipt_total"] or 0))
|
|
|
|
|
|
for item in _dashboard(client, admin["headers"])["overview"]["trend"]
|
|
|
|
|
|
}
|
|
|
|
|
|
assert trend[previous_last.strftime("%Y-%m")] == Decimal("1000.00")
|
|
|
|
|
|
assert trend[first_of_month.strftime("%Y-%m")] == Decimal("1000.00")
|