feat(report): 报表四页与打印视图
- 个人绩效明细/部门绩效汇总/业务统计/回款统计四页,路由组「报表统计」order19-22(allowedRoles admin/finance/leader) - 明细分页页脚全筛选合计;部门汇总成员内联与跨页下钻;业务统计展开回款/台账;回款统计 8 档区间(档位由后端下发)与桶下钻 - 打印视图独立顶层路由(不挂 BasicLayout):公司页眉走 public 端点、循环翻页取全量(2000 行截断提示)、@media print A4 横版每页表头、签字栏 - AppPagination 只派发 page-change/size-change,四页按事件绑定并补初始加载(@change/v-model 不生效的同类缺陷) - e2e report_flow.py:四页筛选与导出下载、打印视图 window.print 桩断言 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
2a1939d2e7
commit
04a1a0d307
12 files changed
+2763
-1
No files matched your search
@@ -136,6 +136,17 @@ uv run pytest tests/integration -q # 仅集成
|
||||
|
||||
运行:`uv run pytest tests/integration/test_{settings,secondary_allocation,approval_flow}.py tests/unit/test_secondary_allocation_schema.py -q` → **44 passed**(2026-10-16);全量回归 `uv run pytest -q` → **697 passed**(基线 625 + 新增 72,5:10)。
|
||||
|
||||
### 报表统计(M4 ⑤,2026-10-19 后端完成)
|
||||
|
||||
| 文件 | 守什么 | 用例数 |
|
||||
| --- | --- | --- |
|
||||
| `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 |
|
||||
|
||||
运行:`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);全量回归见运行记录。
|
||||
|
||||
**待补**:e2e `frontend/e2e/report_flow.py`(四页筛选/导出下载/打印视图,需 `E2E_ADMIN_PASSWORD`)。
|
||||
|
||||
## 三、运行记录
|
||||
|
||||
| 日期 | 范围 | 用例数 | 结果 | 备注 |
|
||||
@@ -156,6 +167,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 | 单元全量 + 报表定点 | 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` 运行 |
|
||||
|
||||
## 四、测试发现并修复的缺陷
|
||||
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""报表统计集成测试(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()
|
||||
)
|
||||
|
||||
|
||||
def _person(client: TestClient, headers: dict, *, role: str = "salesperson", manager_id=None) -> dict:
|
||||
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
|
||||
team = next(
|
||||
(t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]), None
|
||||
)
|
||||
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"]] == [
|
||||
"le0", "1-30", "31-60", "61-90", "91-180", "181-360", "361+", "none",
|
||||
]
|
||||
# 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:
|
||||
log = session.execute(
|
||||
select(OperationLog)
|
||||
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
|
||||
.order_by(OperationLog.id.desc())
|
||||
).scalars().first()
|
||||
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"] == "无权访问报表统计"
|
||||
@@ -0,0 +1,211 @@
|
||||
"""报表辅助纯函数与 Excel 模板构建单元测试(M4 ⑤)。
|
||||
|
||||
这组用例守的是「不测会静默出错」的三类点:
|
||||
- 区间分桶边界错一档 → 金额进错桶,但报表仍然「能打开」,没人会发现
|
||||
- 标签映射漏一个枚举值 → 报表少一类数据或显示英文编码
|
||||
- 导出模板缺签字栏 / 金额被转 float / 文本公式注入 → 交给财务的签字稿失真
|
||||
"""
|
||||
|
||||
import io
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from app.core import report_meta
|
||||
from app.core.constants import CUSTOMER_TYPES, PRODUCT_CATEGORIES
|
||||
from app.services import report_export
|
||||
|
||||
|
||||
class Test区间分桶:
|
||||
"""回款天数 → 8 档区间(含 ≤0 与无天数两个兜底桶)。"""
|
||||
|
||||
def test_边界值全部落入正确区间(self):
|
||||
cases = [
|
||||
(None, "none"),
|
||||
(-5, "le0"),
|
||||
(0, "le0"),
|
||||
(1, "1-30"),
|
||||
(30, "1-30"),
|
||||
(31, "31-60"),
|
||||
(60, "31-60"),
|
||||
(61, "61-90"),
|
||||
(90, "61-90"),
|
||||
(91, "91-180"),
|
||||
(180, "91-180"),
|
||||
(181, "181-360"),
|
||||
(360, "181-360"),
|
||||
(361, "361+"),
|
||||
(1000, "361+"),
|
||||
]
|
||||
for days, code in cases:
|
||||
assert report_meta.bucket_of(days).code == code, f"days={days}"
|
||||
|
||||
def test_八档且编码唯一(self):
|
||||
buckets = report_meta.get_payment_day_buckets()
|
||||
assert len(buckets) == 8
|
||||
assert len({b.code for b in buckets}) == 8
|
||||
|
||||
|
||||
class Test标签覆盖:
|
||||
"""标签键集合必须覆盖业务常量——防常量新增后报表静默少算/显示编码。"""
|
||||
|
||||
def test_客户类型标签覆盖全部类型(self):
|
||||
assert set(report_meta.CUSTOMER_TYPE_LABELS) >= CUSTOMER_TYPES
|
||||
|
||||
def test_产品类别标签覆盖常量与混合业务(self):
|
||||
assert set(report_meta.PRODUCT_CATEGORY_LABELS) >= PRODUCT_CATEGORIES
|
||||
assert "mixed" in report_meta.PRODUCT_CATEGORY_LABELS
|
||||
|
||||
|
||||
def _build(code: str, rows: list[dict], buckets: dict | None = None) -> bytes:
|
||||
return report_export.build_workbook(
|
||||
code=code,
|
||||
title="测试报表",
|
||||
filters="统计月份 2026-10",
|
||||
rows=rows,
|
||||
summary=None,
|
||||
buckets=buckets,
|
||||
template_version="V1.0",
|
||||
operator_name="admin",
|
||||
company_name="衍水集团",
|
||||
)
|
||||
|
||||
|
||||
def _sheet_text(content: bytes) -> str:
|
||||
workbook = load_workbook(io.BytesIO(content))
|
||||
return "\n".join(
|
||||
str(cell.value)
|
||||
for row in workbook.active.iter_rows()
|
||||
for cell in row
|
||||
if cell.value is not None
|
||||
)
|
||||
|
||||
|
||||
def _personal_row(**overrides) -> dict:
|
||||
row = {
|
||||
"business_no": "YW001",
|
||||
"ship_date": date(2026, 10, 1),
|
||||
"receipt_date": date(2026, 10, 20),
|
||||
"payment_days": 20,
|
||||
"product_category": "municipal_water",
|
||||
"customer_type": "personal",
|
||||
"role_name": "营销员",
|
||||
"rate_text": "88/9000",
|
||||
"weight_r": 100,
|
||||
"performance_amount": Decimal("39.11"),
|
||||
"payable_amount": Decimal("39.11"),
|
||||
}
|
||||
row.update(overrides)
|
||||
return row
|
||||
|
||||
|
||||
class TestExcel模板:
|
||||
"""导出稿是交给财务复核与领导签字的,结构缺失等同于没有模板。"""
|
||||
|
||||
def test_页眉标题签字栏齐全(self):
|
||||
text = _sheet_text(_build("personal-detail", [_personal_row()]))
|
||||
assert "衍水集团" in text
|
||||
assert "测试报表" in text
|
||||
assert "统计月份 2026-10" in text
|
||||
assert "模板版本:V1.0" in text
|
||||
assert "财务复核" in text
|
||||
assert "领导签字" in text
|
||||
|
||||
def test_空数据不炸且给提示行(self):
|
||||
text = _sheet_text(_build("personal-detail", []))
|
||||
assert "本筛选条件无数据" in text
|
||||
assert "财务复核" in text
|
||||
|
||||
def test_金额为数值单元格且两位格式(self):
|
||||
# openpyxl 写入 Decimal 后读回为 int/float(Excel 单元格本身是数值),
|
||||
# 这里断言「是数值而非文本」+ 显示格式;计算精度在服务层 Decimal 侧保证
|
||||
content = _build("personal-detail", [_personal_row()])
|
||||
workbook = load_workbook(io.BytesIO(content))
|
||||
sheet = workbook.active
|
||||
amount_cells = [
|
||||
cell
|
||||
for row in sheet.iter_rows()
|
||||
for cell in row
|
||||
if isinstance(cell.value, (int, float))
|
||||
and Decimal(str(cell.value)) == Decimal("39.11")
|
||||
]
|
||||
assert amount_cells, "未找到金额数值单元格(Decimal 被转成了文本?)"
|
||||
assert amount_cells[0].number_format == "#,##0.00"
|
||||
|
||||
def test_合计行等于明细之和且标签只出现一次(self):
|
||||
rows = [
|
||||
_personal_row(performance_amount=Decimal("10.00"), payable_amount=Decimal("10.00")),
|
||||
_personal_row(performance_amount=Decimal("5.50"), payable_amount=Decimal("5.50")),
|
||||
]
|
||||
content = _build("personal-detail", rows)
|
||||
workbook = load_workbook(io.BytesIO(content))
|
||||
cells = [cell for row in workbook.active.iter_rows() for cell in row]
|
||||
values = [
|
||||
Decimal(str(cell.value))
|
||||
for cell in cells
|
||||
if isinstance(cell.value, (int, float))
|
||||
]
|
||||
assert Decimal("15.50") in values
|
||||
# 「合计」标签只写一次(写满每个文本列是回归缺陷)
|
||||
assert sum(1 for cell in cells if cell.value == "合计") == 1
|
||||
|
||||
def test_文本公式注入被前置单引号(self):
|
||||
evil = "=HYPERLINK(\"http://evil\")"
|
||||
content = _build("personal-detail", [_personal_row(business_no=evil)])
|
||||
workbook = load_workbook(io.BytesIO(content))
|
||||
values = [
|
||||
cell.value
|
||||
for row in workbook.active.iter_rows()
|
||||
for cell in row
|
||||
if isinstance(cell.value, str)
|
||||
]
|
||||
assert any(value.startswith("'=") for value in values), "公式注入未转义"
|
||||
|
||||
def test_回款统计附两个维度对比sheet(self):
|
||||
buckets = {
|
||||
"by_customer_type": [
|
||||
{
|
||||
"label": "个人户",
|
||||
"receipt_amount": Decimal("100.00"),
|
||||
"performance_amount": Decimal("1.00"),
|
||||
"payable_amount": Decimal("1.00"),
|
||||
"ledger_count": 1,
|
||||
}
|
||||
],
|
||||
"by_product_category": [],
|
||||
}
|
||||
content = _build(
|
||||
"payment-stats",
|
||||
[
|
||||
{
|
||||
"label": "1-30 天",
|
||||
"receipt_amount": Decimal("100.00"),
|
||||
"receipt_count": 1,
|
||||
"performance_amount": Decimal("1.00"),
|
||||
"payable_amount": Decimal("1.00"),
|
||||
"ledger_count": 1,
|
||||
}
|
||||
],
|
||||
buckets=buckets,
|
||||
)
|
||||
workbook = load_workbook(io.BytesIO(content))
|
||||
assert workbook.sheetnames == ["回款统计", "客户类型对比", "产品类别对比"]
|
||||
|
||||
def test_业务统计状态转中文(self):
|
||||
rows = [
|
||||
{
|
||||
"business_no": "YW001",
|
||||
"business_date": date(2026, 10, 1),
|
||||
"customer_name": "测试客户",
|
||||
"owner_personnel_name": "张三",
|
||||
"status": 2,
|
||||
"total_sale_amount": Decimal("100.00"),
|
||||
"total_ship_amount": Decimal("100.00"),
|
||||
"total_received_amount": Decimal("100.00"),
|
||||
"performance_amount": Decimal("1.00"),
|
||||
"payable_amount": Decimal("1.00"),
|
||||
}
|
||||
]
|
||||
text = _sheet_text(_build("business-stats", rows))
|
||||
assert "已完成" in text
|
||||
Reference in new issue
Block a user