2026-10-19 04:33:37 -04:00
|
|
|
|
"""报表统计集成测试(M4 ⑤)。
|
|
|
|
|
|
|
|
|
|
|
|
守的口径(破坏了就是报表数字与台账对不上):
|
|
|
|
|
|
- 个人明细与台账逐笔一致;summary 为**全筛选**合计而非当前页合计
|
|
|
|
|
|
- 部门汇总团队基数 = 成员绩效合计;12.5%/5% 分成按参数;无成员经理出 0 行
|
|
|
|
|
|
- 回款统计 8 个区间桶恒在(空桶为 0);**二次分摊拆行时回款金额只计一次**
|
|
|
|
|
|
- 业务统计排除经理聚合行(calc_type=2),绩效不被整团队聚合额污染
|
|
|
|
|
|
- 导出 xlsx 可解析且含签字栏;导出行为入操作日志
|
|
|
|
|
|
- 权限:普通用户 403 且文案为「无权访问报表统计」
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
|
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
|
|
|
|
|
|
_REPORTS = "/api/v1/reports"
|
|
|
|
|
|
_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"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _role_id(code: str) -> int:
|
|
|
|
|
|
"""取内置业务岗位 id(salesperson / sales_manager)。"""
|
|
|
|
|
|
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()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 07:43:20 -04:00
|
|
|
|
def _person(
|
|
|
|
|
|
client: TestClient, headers: dict, *, role: str = "salesperson", manager_id=None
|
|
|
|
|
|
) -> dict:
|
2026-10-19 04:33:37 -04:00
|
|
|
|
body = {
|
|
|
|
|
|
"personnel_name": f"t_报表_{uuid.uuid4().hex[:8]}",
|
|
|
|
|
|
"business_role_id": _role_id(role),
|
|
|
|
|
|
"salary_standard": "3000.00",
|
|
|
|
|
|
}
|
|
|
|
|
|
if manager_id is not None:
|
|
|
|
|
|
body["manager_id"] = manager_id
|
|
|
|
|
|
resp = client.post(_PERSONNEL, json=body, headers=headers)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
return resp.json()["data"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _full_chain(
|
|
|
|
|
|
client: TestClient,
|
|
|
|
|
|
headers: dict,
|
|
|
|
|
|
*,
|
|
|
|
|
|
owner_id: int,
|
|
|
|
|
|
customer_id: int | None = None,
|
|
|
|
|
|
allocations: list[dict] | None = None,
|
|
|
|
|
|
) -> tuple[dict, dict]:
|
|
|
|
|
|
"""业务(单行 100×100,成本 3000)→ 发货(60 天前)→ 回款 → 核算。"""
|
|
|
|
|
|
from tests.integration.factories import business_item, business_payload, customer
|
|
|
|
|
|
|
|
|
|
|
|
cust = customer(client, headers) if customer_id is None else {"id": customer_id}
|
|
|
|
|
|
payload = business_payload(
|
|
|
|
|
|
cust["id"],
|
|
|
|
|
|
owner_personnel_id=owner_id,
|
|
|
|
|
|
items=[business_item(cost_amount="3000.00")],
|
|
|
|
|
|
)
|
|
|
|
|
|
if allocations:
|
|
|
|
|
|
payload["secondary_allocations"] = allocations
|
|
|
|
|
|
resp = client.post(_BUSINESSES, json=payload, headers=headers)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
business = resp.json()["data"]
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import date, timedelta
|
|
|
|
|
|
|
|
|
|
|
|
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"]
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
_RECEIPTS,
|
|
|
|
|
|
json={
|
|
|
|
|
|
"business_id": business["id"],
|
|
|
|
|
|
"receipt_date": date.today().isoformat(),
|
|
|
|
|
|
"amount": "10000.00",
|
|
|
|
|
|
"shipment_id": shipment["id"],
|
|
|
|
|
|
},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
receipt = resp.json()["data"]
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
return business, receipt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _month() -> str:
|
|
|
|
|
|
from datetime import date
|
|
|
|
|
|
|
|
|
|
|
|
return date.today().strftime("%Y-%m")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ledger_sum(headers: dict, month: str) -> Decimal:
|
|
|
|
|
|
"""台账(单笔生效行)同月绩效合计——报表 summary 的对照基准。"""
|
|
|
|
|
|
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:
|
|
|
|
|
|
return 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()
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_个人明细_与台账一致且summary为全量合计(client: TestClient, admin: dict):
|
|
|
|
|
|
"""明细行金额必须等于台账;pageSize=1 时 summary 仍是全部筛选结果之和。"""
|
|
|
|
|
|
person = _person(client, admin["headers"])
|
|
|
|
|
|
_full_chain(client, admin["headers"], owner_id=person["id"])
|
|
|
|
|
|
month = _month()
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/personal-detail",
|
|
|
|
|
|
params={"settlement_month": month, "pageSize": 1},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
data = resp.json()["data"]
|
|
|
|
|
|
assert data["total"] == 1
|
|
|
|
|
|
expected = _ledger_sum(admin["headers"], month)
|
|
|
|
|
|
assert Decimal(str(data["list"][0]["performance_amount"])) == expected
|
|
|
|
|
|
assert Decimal(str(data["summary"]["performance_total"])) == expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_部门汇总_团队基数等于成员绩效且分成按参数(admin, client: TestClient):
|
|
|
|
|
|
"""团队基数=成员绩效合计;12.5% 团队分成与 5% 全公司分成分别可核。"""
|
|
|
|
|
|
manager = _person(client, admin["headers"], role="sales_manager")
|
|
|
|
|
|
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
|
|
|
|
|
|
_full_chain(client, admin["headers"], owner_id=salesperson["id"])
|
|
|
|
|
|
month = _month()
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/department-summary",
|
|
|
|
|
|
params={"settlement_month": month},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
data = resp.json()["data"]
|
|
|
|
|
|
|
|
|
|
|
|
team = next(t for t in data["teams"] if t["manager_id"] == manager["id"])
|
|
|
|
|
|
ledger_total = _ledger_sum(admin["headers"], month)
|
|
|
|
|
|
assert Decimal(str(team["team_base"])) == ledger_total
|
|
|
|
|
|
expected_share = (ledger_total * Decimal("0.125")).quantize(Decimal("0.01"))
|
|
|
|
|
|
assert Decimal(str(team["team_share_amount"])) == expected_share
|
|
|
|
|
|
assert team["member_count"] == 1
|
|
|
|
|
|
assert data["company"]["company_base"] == team["team_base"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_部门汇总_无成员经理出零值行(admin, client: TestClient):
|
|
|
|
|
|
"""无团队成员 → 0 行展示(FR-030 异常处理)。"""
|
|
|
|
|
|
manager = _person(client, admin["headers"], role="sales_manager")
|
|
|
|
|
|
month = _month()
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/department-summary",
|
|
|
|
|
|
params={"settlement_month": month},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
2026-10-22 07:43:20 -04:00
|
|
|
|
team = next((t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]), None)
|
2026-10-19 04:33:37 -04:00
|
|
|
|
assert team is not None
|
|
|
|
|
|
assert Decimal(str(team["team_base"])) == Decimal("0")
|
|
|
|
|
|
assert team["member_count"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_回款统计_空桶恒在且分摊拆行金额只计一次(admin, client: TestClient):
|
|
|
|
|
|
"""8 个区间桶恒在;二次分配拆行使台账多行但回款金额不得翻倍。"""
|
|
|
|
|
|
owner = _person(client, admin["headers"])
|
|
|
|
|
|
participant = _person(client, admin["headers"])
|
|
|
|
|
|
_full_chain(
|
|
|
|
|
|
client,
|
|
|
|
|
|
admin["headers"],
|
|
|
|
|
|
owner_id=owner["id"],
|
|
|
|
|
|
allocations=[{"personnel_id": participant["id"], "ratio": "30.00"}],
|
|
|
|
|
|
)
|
|
|
|
|
|
month = _month()
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/payment-stats",
|
|
|
|
|
|
params={"settlement_month": month},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
data = resp.json()["data"]
|
|
|
|
|
|
|
|
|
|
|
|
assert len(data["buckets"]) == 8
|
|
|
|
|
|
assert [b["code"] for b in data["buckets"]] == [
|
2026-10-22 07:43:20 -04:00
|
|
|
|
"le0",
|
|
|
|
|
|
"1-30",
|
|
|
|
|
|
"31-60",
|
|
|
|
|
|
"61-90",
|
|
|
|
|
|
"91-180",
|
|
|
|
|
|
"181-360",
|
|
|
|
|
|
"361+",
|
|
|
|
|
|
"none",
|
2026-10-19 04:33:37 -04:00
|
|
|
|
]
|
|
|
|
|
|
# 60 天前发货 → 落在 61-90 桶(发货日首日计第 1 天)
|
|
|
|
|
|
bucket = next(b for b in data["buckets"] if b["code"] == "61-90")
|
|
|
|
|
|
assert Decimal(str(bucket["receipt_amount"])) == Decimal("10000.00")
|
|
|
|
|
|
assert bucket["receipt_count"] == 1
|
|
|
|
|
|
# 拆行后台账两行,但绩效合计仍等于台账总和
|
|
|
|
|
|
assert bucket["ledger_count"] == 2
|
|
|
|
|
|
empty = next(b for b in data["buckets"] if b["code"] == "361+")
|
|
|
|
|
|
assert Decimal(str(empty["performance_amount"])) == Decimal("0")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_业务统计_绩效排除经理聚合行(admin, client: TestClient):
|
|
|
|
|
|
"""生成月结后出现 calc_type=2 聚合行,业务统计不得把聚合额算进单业务。"""
|
|
|
|
|
|
manager = _person(client, admin["headers"], role="sales_manager")
|
|
|
|
|
|
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
|
|
|
|
|
|
business, _ = _full_chain(client, admin["headers"], owner_id=salesperson["id"])
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/business-stats",
|
|
|
|
|
|
params={"keyword": business["business_no"]},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
row = resp.json()["data"]["list"][0]
|
|
|
|
|
|
assert Decimal(str(row["performance_amount"])) == _ledger_sum(admin["headers"], _month())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_导出_excel含签字栏且入操作日志(admin, client: TestClient):
|
|
|
|
|
|
"""导出是文件流(非统一信封);模板含复核/签字栏;行为入操作日志。"""
|
|
|
|
|
|
from io import BytesIO
|
|
|
|
|
|
|
|
|
|
|
|
from openpyxl import load_workbook
|
|
|
|
|
|
|
|
|
|
|
|
person = _person(client, admin["headers"])
|
|
|
|
|
|
_full_chain(client, admin["headers"], owner_id=person["id"])
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/personal-detail/export",
|
|
|
|
|
|
params={"settlement_month": _month()},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
assert resp.headers["content-type"].startswith(
|
|
|
|
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
|
|
|
|
)
|
|
|
|
|
|
workbook = load_workbook(BytesIO(resp.content))
|
|
|
|
|
|
text = "\n".join(
|
|
|
|
|
|
str(cell.value)
|
|
|
|
|
|
for row in workbook.active.iter_rows()
|
|
|
|
|
|
for cell in row
|
|
|
|
|
|
if cell.value is not None
|
|
|
|
|
|
)
|
|
|
|
|
|
assert "个人绩效工资明细表" in text
|
|
|
|
|
|
assert "财务复核" in text
|
|
|
|
|
|
assert "领导签字" in text
|
|
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.db import get_session_factory
|
|
|
|
|
|
from app.models import OperationLog
|
|
|
|
|
|
|
|
|
|
|
|
factory = get_session_factory()
|
|
|
|
|
|
with factory() as session:
|
2026-10-22 07:43:20 -04:00
|
|
|
|
log = (
|
|
|
|
|
|
session.execute(
|
|
|
|
|
|
select(OperationLog)
|
|
|
|
|
|
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
|
|
|
|
|
|
.order_by(OperationLog.id.desc())
|
|
|
|
|
|
)
|
|
|
|
|
|
.scalars()
|
|
|
|
|
|
.first()
|
|
|
|
|
|
)
|
2026-10-19 04:33:37 -04:00
|
|
|
|
assert log is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_权限_普通用户403文案(admin, normal, client: TestClient):
|
|
|
|
|
|
"""报表限管理员/财务/领导;普通用户被后端拦截且文案正确。"""
|
|
|
|
|
|
resp = client.get(f"{_REPORTS}/personal-detail", headers=normal["headers"])
|
|
|
|
|
|
assert resp.status_code == 403
|
|
|
|
|
|
assert resp.json()["message"] == "无权访问报表统计"
|
2026-10-19 21:44:43 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ==== 审查回归:归属、调整单、经理个人户、导出审计 ====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _set_param(client: TestClient, headers: dict, code: str, value: str) -> None:
|
|
|
|
|
|
"""立即生效的参数变更(用例不依赖种子默认值,结束显式恢复)。"""
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
"/api/v1/system/params/changes",
|
|
|
|
|
|
json={
|
|
|
|
|
|
"change_type": 1,
|
|
|
|
|
|
"remark": f"用例设置 {code}={value}",
|
|
|
|
|
|
"items": [{"param_code": code, "dimension_value": "", "value": value}],
|
|
|
|
|
|
},
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _insert_adjustment(person: dict, month: str, amount: str, direction: int = 1) -> None:
|
|
|
|
|
|
"""直插生效调整单(status=2):绕开审批链,聚焦报表归属口径。"""
|
|
|
|
|
|
from app.core.db import get_session_factory
|
|
|
|
|
|
from app.models import AdjustmentOrder
|
|
|
|
|
|
|
|
|
|
|
|
factory = get_session_factory()
|
|
|
|
|
|
with factory() as session:
|
|
|
|
|
|
session.add(
|
|
|
|
|
|
AdjustmentOrder(
|
|
|
|
|
|
order_no=f"TZ{uuid.uuid4().hex[:12]}",
|
|
|
|
|
|
settlement_month=month,
|
|
|
|
|
|
personnel_id=person["id"],
|
|
|
|
|
|
personnel_name=person["personnel_name"],
|
|
|
|
|
|
direction=direction,
|
|
|
|
|
|
amount=Decimal(amount),
|
|
|
|
|
|
reason="t_报表用例",
|
|
|
|
|
|
status=2,
|
|
|
|
|
|
submitter_name="t_admin",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _insert_manager_personal_ledger(manager: dict, month: str, amount: str) -> None:
|
|
|
|
|
|
"""直插一条经理个人户台账行(模拟单笔侧已计提的结果)。"""
|
|
|
|
|
|
from datetime import date
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.db import get_session_factory
|
|
|
|
|
|
from app.models import PerformanceLedger
|
|
|
|
|
|
|
|
|
|
|
|
factory = get_session_factory()
|
|
|
|
|
|
with factory() as session:
|
|
|
|
|
|
session.add(
|
|
|
|
|
|
PerformanceLedger(
|
|
|
|
|
|
receipt_id=None,
|
|
|
|
|
|
calc_version=1,
|
|
|
|
|
|
calc_type=1,
|
|
|
|
|
|
business_id=0,
|
|
|
|
|
|
business_no="t_个人户",
|
|
|
|
|
|
personnel_id=manager["id"],
|
|
|
|
|
|
personnel_name=manager["personnel_name"],
|
|
|
|
|
|
role_code="sales_manager",
|
|
|
|
|
|
customer_type="manager_personal",
|
|
|
|
|
|
product_category="mixed",
|
|
|
|
|
|
receipt_date=date.today(),
|
|
|
|
|
|
rate_text="0",
|
|
|
|
|
|
performance_amount=Decimal(amount),
|
|
|
|
|
|
barter_applied=0,
|
|
|
|
|
|
payable_amount=Decimal(amount),
|
|
|
|
|
|
settlement_month=month,
|
|
|
|
|
|
is_probation=0,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _latest_export_log():
|
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.db import get_session_factory
|
|
|
|
|
|
from app.models import OperationLog
|
|
|
|
|
|
|
|
|
|
|
|
factory = get_session_factory()
|
|
|
|
|
|
with factory() as session:
|
2026-10-22 07:43:20 -04:00
|
|
|
|
return (
|
|
|
|
|
|
session.execute(
|
|
|
|
|
|
select(OperationLog)
|
|
|
|
|
|
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
|
|
|
|
|
|
.order_by(OperationLog.id.desc())
|
|
|
|
|
|
)
|
|
|
|
|
|
.scalars()
|
|
|
|
|
|
.first()
|
|
|
|
|
|
)
|
2026-10-19 21:44:43 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_部门汇总_无台账经理挂上级不报500(admin, client: TestClient):
|
|
|
|
|
|
"""经理 A 无台账且上级是经理 B:不能因 member_map 直接索引而 KeyError。"""
|
|
|
|
|
|
manager_b = _person(client, admin["headers"], role="sales_manager")
|
2026-10-22 07:43:20 -04:00
|
|
|
|
manager_a = _person(client, admin["headers"], role="sales_manager", manager_id=manager_b["id"])
|
2026-10-19 21:44:43 -04:00
|
|
|
|
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/department-summary",
|
|
|
|
|
|
params={"settlement_month": _month()},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
data = resp.json()["data"]
|
|
|
|
|
|
team_a = next(t for t in data["teams"] if t["manager_id"] == manager_a["id"])
|
|
|
|
|
|
assert team_a["member_count"] == 0
|
|
|
|
|
|
team_b = next(t for t in data["teams"] if t["manager_id"] == manager_b["id"])
|
|
|
|
|
|
assert all(m["personnel_id"] != manager_a["id"] for m in team_b["members"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_部门汇总_仅有调整单人员并入对应团队(admin, client: TestClient):
|
|
|
|
|
|
"""只有调整单、当月无台账行的人也要出现在团队明细中。"""
|
|
|
|
|
|
manager = _person(client, admin["headers"], role="sales_manager")
|
|
|
|
|
|
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
|
|
|
|
|
|
_insert_adjustment(salesperson, _month(), "500.00")
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/department-summary",
|
|
|
|
|
|
params={"settlement_month": _month()},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
data = resp.json()["data"]
|
|
|
|
|
|
team = next(t for t in data["teams"] if t["manager_id"] == manager["id"])
|
|
|
|
|
|
member = next(m for m in team["members"] if m["personnel_id"] == salesperson["id"])
|
|
|
|
|
|
assert member["member_kind"] == "adjustment"
|
|
|
|
|
|
assert Decimal(str(member["performance_sum"])) == Decimal("0")
|
|
|
|
|
|
assert Decimal(str(member["adjust_net"])) == Decimal("500.00")
|
|
|
|
|
|
assert Decimal(str(team["adjust_net"])) == Decimal("500.00")
|
|
|
|
|
|
assert Decimal(str(data["reconciliation"]["adjust_net_total"])) == Decimal("500.00")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_部门汇总_仅调整单无经理人员进入未分配(admin, client: TestClient):
|
|
|
|
|
|
"""无经理人员的调整单不能被静默丢弃。"""
|
|
|
|
|
|
salesperson = _person(client, admin["headers"])
|
|
|
|
|
|
_insert_adjustment(salesperson, _month(), "300.00")
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/department-summary",
|
|
|
|
|
|
params={"settlement_month": _month()},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
data = resp.json()["data"]
|
|
|
|
|
|
row = next(u for u in data["unassigned"] if u["personnel_id"] == salesperson["id"])
|
|
|
|
|
|
assert row["member_kind"] == "adjustment"
|
|
|
|
|
|
assert Decimal(str(row["adjust_net"])) == Decimal("300.00")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_部门汇总_经理个人户计入时基数等于成员合计(admin, client: TestClient):
|
|
|
|
|
|
"""开关 1:经理成员行只取个人户金额,团队基数 == 成员绩效合计。"""
|
|
|
|
|
|
manager = _person(client, admin["headers"], role="sales_manager")
|
|
|
|
|
|
_insert_manager_personal_ledger(manager, _month(), "100.00")
|
|
|
|
|
|
_set_param(client, admin["headers"], "manager.personal.included", "1")
|
|
|
|
|
|
try:
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/department-summary",
|
|
|
|
|
|
params={"settlement_month": _month()},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
2026-10-22 07:43:20 -04:00
|
|
|
|
team = next(t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"])
|
2026-10-19 21:44:43 -04:00
|
|
|
|
assert Decimal(str(team["team_base"])) == Decimal("100.00")
|
|
|
|
|
|
assert sum(
|
|
|
|
|
|
(Decimal(str(m["performance_sum"])) for m in team["members"]), Decimal("0")
|
|
|
|
|
|
) == Decimal("100.00")
|
|
|
|
|
|
assert all(m["member_kind"] == "manager_personal" for m in team["members"])
|
|
|
|
|
|
finally:
|
|
|
|
|
|
_set_param(client, admin["headers"], "manager.personal.included", "0")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_部门汇总_经理个人户关闭时不展示个人户金额(admin, client: TestClient):
|
|
|
|
|
|
"""开关 0:个人户金额既不计入团队基数,也不进未分配(与全公司口径一致)。"""
|
|
|
|
|
|
manager = _person(client, admin["headers"], role="sales_manager")
|
|
|
|
|
|
_insert_manager_personal_ledger(manager, _month(), "100.00")
|
|
|
|
|
|
_set_param(client, admin["headers"], "manager.personal.included", "0")
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/department-summary",
|
|
|
|
|
|
params={"settlement_month": _month()},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
data = resp.json()["data"]
|
|
|
|
|
|
team = next(t for t in data["teams"] if t["manager_id"] == manager["id"])
|
|
|
|
|
|
assert team["member_count"] == 0
|
|
|
|
|
|
assert all(u["personnel_id"] != manager["id"] for u in data["unassigned"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_部门汇总_按经理筛选不误判其他团队下属(admin, client: TestClient):
|
|
|
|
|
|
"""筛选只影响展示的团队;其他经理的下属不能进入未分配。"""
|
|
|
|
|
|
manager_1 = _person(client, admin["headers"], role="sales_manager")
|
|
|
|
|
|
manager_2 = _person(client, admin["headers"], role="sales_manager")
|
|
|
|
|
|
salesperson_1 = _person(client, admin["headers"], manager_id=manager_1["id"])
|
|
|
|
|
|
salesperson_2 = _person(client, admin["headers"], manager_id=manager_2["id"])
|
|
|
|
|
|
_full_chain(client, admin["headers"], owner_id=salesperson_1["id"])
|
|
|
|
|
|
_full_chain(client, admin["headers"], owner_id=salesperson_2["id"])
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/department-summary",
|
|
|
|
|
|
params={"settlement_month": _month(), "manager_id": manager_1["id"]},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
data = resp.json()["data"]
|
|
|
|
|
|
assert [t["manager_id"] for t in data["teams"]] == [manager_1["id"]]
|
|
|
|
|
|
assert data["unassigned"] == []
|
|
|
|
|
|
assert all(
|
2026-10-22 07:43:20 -04:00
|
|
|
|
m["personnel_id"] != salesperson_2["id"] for team in data["teams"] for m in team["members"]
|
2026-10-19 21:44:43 -04:00
|
|
|
|
)
|
|
|
|
|
|
assert Decimal(str(data["reconciliation"]["other_team_base"])) > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_部门汇总_对账恒等式(admin, client: TestClient):
|
|
|
|
|
|
"""未筛选:团队基数 + 未分配绩效 = 全公司基数;应发公式成立。"""
|
|
|
|
|
|
manager = _person(client, admin["headers"], role="sales_manager")
|
|
|
|
|
|
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
|
|
|
|
|
|
unassigned = _person(client, admin["headers"])
|
|
|
|
|
|
_full_chain(client, admin["headers"], owner_id=salesperson["id"])
|
|
|
|
|
|
_full_chain(client, admin["headers"], owner_id=unassigned["id"])
|
|
|
|
|
|
_insert_adjustment(salesperson, _month(), "200.00")
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/department-summary",
|
|
|
|
|
|
params={"settlement_month": _month()},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
reconciliation = resp.json()["data"]["reconciliation"]
|
|
|
|
|
|
assert Decimal(str(reconciliation["team_base_total"])) + Decimal(
|
|
|
|
|
|
str(reconciliation["unassigned_performance_total"])
|
|
|
|
|
|
) == Decimal(str(reconciliation["company_base"]))
|
|
|
|
|
|
assert Decimal(str(reconciliation["other_team_base"])) == Decimal("0")
|
|
|
|
|
|
assert Decimal(str(reconciliation["payable_total"])) == (
|
|
|
|
|
|
Decimal(str(reconciliation["team_member_payable_total"]))
|
|
|
|
|
|
+ Decimal(str(reconciliation["unassigned_payable_total"]))
|
|
|
|
|
|
+ Decimal(str(reconciliation["aggregate_payable_total"]))
|
|
|
|
|
|
+ Decimal(str(reconciliation["adjust_net_total"]))
|
|
|
|
|
|
)
|
|
|
|
|
|
# 分列合计 + 舍入差 = 聚合额(月结一次舍入口径)
|
|
|
|
|
|
assert Decimal(str(reconciliation["aggregate_rounding_diff"])) == (
|
|
|
|
|
|
Decimal(str(reconciliation["aggregate_payable_total"]))
|
|
|
|
|
|
- Decimal(str(reconciliation["team_share_total"]))
|
|
|
|
|
|
- Decimal(str(reconciliation["company_share_total"]))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 07:43:20 -04:00
|
|
|
|
def _two_manager_scene(client: TestClient, headers: dict) -> tuple[dict, dict, dict, dict, dict]:
|
2026-10-19 21:44:43 -04:00
|
|
|
|
"""两位经理各有下属与业务 + 一位零成员经理(无聚合行)。"""
|
|
|
|
|
|
manager_1 = _person(client, headers, role="sales_manager")
|
|
|
|
|
|
manager_2 = _person(client, headers, role="sales_manager")
|
|
|
|
|
|
empty_manager = _person(client, headers, role="sales_manager")
|
|
|
|
|
|
salesperson_1 = _person(client, headers, manager_id=manager_1["id"])
|
|
|
|
|
|
salesperson_2 = _person(client, headers, manager_id=manager_2["id"])
|
|
|
|
|
|
_full_chain(client, headers, owner_id=salesperson_1["id"])
|
|
|
|
|
|
_full_chain(client, headers, owner_id=salesperson_2["id"])
|
|
|
|
|
|
return manager_1, manager_2, empty_manager, salesperson_1, salesperson_2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_部门汇总_应发合计与月结对拍_两位经理(admin, client: TestClient):
|
|
|
|
|
|
"""月结每位有聚合行的经理各计一次 5%;签字稿应发必须等于 total_payable。"""
|
|
|
|
|
|
_two_manager_scene(client, admin["headers"])
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/department-summary",
|
|
|
|
|
|
params={"settlement_month": _month()},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
before = resp.json()["data"]
|
|
|
|
|
|
assert before["company"]["company_share_count"] == 2
|
|
|
|
|
|
assert Decimal(str(before["company"]["company_share_total"])) == (
|
|
|
|
|
|
Decimal(str(before["company"]["company_share_amount"])) * 2
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
settlement = resp.json()["data"]
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/department-summary",
|
|
|
|
|
|
params={"settlement_month": _month()},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
data = resp.json()["data"]
|
|
|
|
|
|
teams = {team["manager_id"]: team for team in data["teams"]}
|
|
|
|
|
|
frozen_total = sum(
|
|
|
|
|
|
(
|
|
|
|
|
|
Decimal(str(team["frozen_amount"]))
|
|
|
|
|
|
for team in teams.values()
|
|
|
|
|
|
if team["frozen_amount"] is not None
|
|
|
|
|
|
),
|
|
|
|
|
|
Decimal("0"),
|
|
|
|
|
|
)
|
|
|
|
|
|
reconciliation = data["reconciliation"]
|
|
|
|
|
|
assert Decimal(str(reconciliation["aggregate_payable_total"])) == frozen_total
|
|
|
|
|
|
assert Decimal(str(reconciliation["payable_total"])) == Decimal(
|
|
|
|
|
|
str(settlement["total_payable"])
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_部门汇总_有调整单时应发等于月结加调整(admin, client: TestClient):
|
|
|
|
|
|
"""调整单不进月结 items;报表应发 = 月结总额 + 调整净额。"""
|
|
|
|
|
|
_, _, _, salesperson, _ = _two_manager_scene(client, admin["headers"])
|
|
|
|
|
|
_insert_adjustment(salesperson, _month(), "500.00")
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
settlement = resp.json()["data"]
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/department-summary",
|
|
|
|
|
|
params={"settlement_month": _month()},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
reconciliation = resp.json()["data"]["reconciliation"]
|
|
|
|
|
|
assert Decimal(str(reconciliation["adjust_net_total"])) == Decimal("500.00")
|
|
|
|
|
|
assert Decimal(str(reconciliation["payable_total"])) == (
|
|
|
|
|
|
Decimal(str(settlement["total_payable"])) + Decimal("500.00")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_部门汇总_按经理筛选时5百分比只计该团队(admin, client: TestClient):
|
|
|
|
|
|
"""筛选态只汇总展示团队;隐藏团队的金额进 other_*,不参与应发。"""
|
|
|
|
|
|
manager_1, _, _, _, _ = _two_manager_scene(client, admin["headers"])
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/department-summary",
|
|
|
|
|
|
params={"settlement_month": _month(), "manager_id": manager_1["id"]},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
data = resp.json()["data"]
|
|
|
|
|
|
assert data["company"]["company_share_count"] == 1
|
|
|
|
|
|
reconciliation = data["reconciliation"]
|
|
|
|
|
|
assert Decimal(str(reconciliation["other_team_aggregate_total"])) > 0
|
|
|
|
|
|
assert Decimal(str(reconciliation["other_team_company_share_total"])) > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_导出_筛选页眉ID转名称与中文标签(admin, client: TestClient):
|
|
|
|
|
|
"""页眉是签字稿追溯筛选范围的唯一凭据,ID 必须转名称。"""
|
|
|
|
|
|
from io import BytesIO
|
|
|
|
|
|
|
|
|
|
|
|
from openpyxl import load_workbook
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.db import get_session_factory
|
|
|
|
|
|
from app.models import BusinessRole
|
|
|
|
|
|
|
|
|
|
|
|
person = _person(client, admin["headers"])
|
|
|
|
|
|
_full_chain(client, admin["headers"], owner_id=person["id"])
|
|
|
|
|
|
factory = get_session_factory()
|
|
|
|
|
|
with factory() as session:
|
|
|
|
|
|
role_name = (
|
|
|
|
|
|
session.query(BusinessRole.role_name)
|
|
|
|
|
|
.filter(BusinessRole.role_code == "salesperson")
|
|
|
|
|
|
.scalar()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/personal-detail/export",
|
|
|
|
|
|
params={
|
|
|
|
|
|
"settlement_month": _month(),
|
|
|
|
|
|
"personnel_id": person["id"],
|
|
|
|
|
|
"role_code": "salesperson",
|
|
|
|
|
|
"customer_type": "personal",
|
|
|
|
|
|
"product_category": "municipal_water",
|
|
|
|
|
|
},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
workbook = load_workbook(BytesIO(resp.content))
|
|
|
|
|
|
text = "\n".join(
|
|
|
|
|
|
str(cell.value)
|
|
|
|
|
|
for row in workbook.active.iter_rows()
|
|
|
|
|
|
for cell in row
|
|
|
|
|
|
if cell.value is not None
|
|
|
|
|
|
)
|
|
|
|
|
|
assert person["personnel_name"] in text
|
|
|
|
|
|
assert f"人员 {person['personnel_name']}(#{person['id']})" in text
|
|
|
|
|
|
assert "个人户" in text
|
|
|
|
|
|
assert "市政水利" in text
|
|
|
|
|
|
assert role_name in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_导出_回款维度sheet透传筛选条件(admin, client: TestClient):
|
|
|
|
|
|
"""两个维度 sheet 的页眉不能固定写「全部」。"""
|
|
|
|
|
|
from io import BytesIO
|
|
|
|
|
|
|
|
|
|
|
|
from openpyxl import load_workbook
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/payment-stats/export",
|
|
|
|
|
|
params={"customer_type": "personal"},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
workbook = load_workbook(BytesIO(resp.content))
|
|
|
|
|
|
for sheet_name in ("客户类型对比", "产品类别对比"):
|
|
|
|
|
|
text = "\n".join(
|
|
|
|
|
|
str(cell.value)
|
|
|
|
|
|
for row in workbook[sheet_name].iter_rows()
|
|
|
|
|
|
for cell in row
|
|
|
|
|
|
if cell.value is not None
|
|
|
|
|
|
)
|
|
|
|
|
|
assert "客户类型 个人户" in text
|
|
|
|
|
|
assert "筛选条件:全部" not in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_导出超限_失败审计留痕(admin, client: TestClient, monkeypatch):
|
|
|
|
|
|
"""超限 400 发生在审计上下文内,必须留下失败记录。"""
|
|
|
|
|
|
from app.services import report_service
|
|
|
|
|
|
|
|
|
|
|
|
person = _person(client, admin["headers"])
|
|
|
|
|
|
_full_chain(client, admin["headers"], owner_id=person["id"])
|
|
|
|
|
|
monkeypatch.setattr(report_service, "REPORT_EXPORT_MAX_ROWS", 0)
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/personal-detail/export",
|
|
|
|
|
|
params={"settlement_month": _month()},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 400, resp.text
|
|
|
|
|
|
log = _latest_export_log()
|
|
|
|
|
|
assert log is not None
|
|
|
|
|
|
assert log.success == 0
|
|
|
|
|
|
assert "超过上限" in (log.error_message or "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_导出取数异常_失败审计留痕(admin, client: TestClient, monkeypatch):
|
|
|
|
|
|
"""取数阶段的异常同样要留下失败记录。
|
|
|
|
|
|
|
|
|
|
|
|
TestClient 默认重抛服务端异常,这里直接调 service 层,聚焦审计落库。
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app.core.db import get_session_factory
|
|
|
|
|
|
from app.models import User
|
|
|
|
|
|
from app.schemas.report import PersonalDetailQuery
|
|
|
|
|
|
from app.services import report_service
|
|
|
|
|
|
|
|
|
|
|
|
def _boom(*args, **kwargs):
|
|
|
|
|
|
raise RuntimeError("t_取数异常")
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(report_service, "_export_payload", _boom)
|
|
|
|
|
|
factory = get_session_factory()
|
|
|
|
|
|
with factory() as session:
|
|
|
|
|
|
operator = session.get(User, admin["id"])
|
|
|
|
|
|
with pytest.raises(RuntimeError, match="t_取数异常"):
|
|
|
|
|
|
report_service.export_report(
|
|
|
|
|
|
session,
|
|
|
|
|
|
"personal-detail",
|
|
|
|
|
|
PersonalDetailQuery(settlement_month=_month()),
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
)
|
|
|
|
|
|
log = _latest_export_log()
|
|
|
|
|
|
assert log is not None
|
|
|
|
|
|
assert log.success == 0
|
|
|
|
|
|
# 未预期异常按安全口径收敛为通用文案,摘要仍能定位到报表
|
|
|
|
|
|
assert "导出个人绩效工资明细表" in (log.summary or "")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 07:43:20 -04:00
|
|
|
|
@pytest.mark.parametrize("path", ["/personal-detail", "/department-summary", "/payment-stats"])
|
2026-10-19 21:44:43 -04:00
|
|
|
|
def test_非法月份_查询返回400(admin, client: TestClient, path: str):
|
|
|
|
|
|
"""非法月份必须是参数错误 400,不能落 500。"""
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}{path}",
|
|
|
|
|
|
params={"settlement_month": "2026-13"},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 400, resp.text
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 07:43:20 -04:00
|
|
|
|
@pytest.mark.parametrize("code", ["personal-detail", "department-summary", "payment-stats"])
|
2026-10-19 21:44:43 -04:00
|
|
|
|
def test_非法月份_导出返回400(admin, client: TestClient, code: str):
|
|
|
|
|
|
"""导出手工构造查询模型同样要转 400。"""
|
|
|
|
|
|
resp = client.get(
|
|
|
|
|
|
f"{_REPORTS}/{code}/export",
|
|
|
|
|
|
params={"settlement_month": "2026-13"},
|
|
|
|
|
|
headers=admin["headers"],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 400, resp.text
|