- 部门汇总全公司 5% 按每位有台账聚合行的营销经理各计一次, 团队应发小计取聚合额 + 调整净额;与月结 total_payable 对拍 (有调整单时 + adjust_net_total),分列舍入差单列 - 人员归属按行类型拆分(普通/经理个人户/仅调整单),调整单全量并入, 团队与未分配不重复;按经理筛选只汇总展示团队 - 导出改单 sheet 分块签字稿,审计覆盖取数与超限;筛选页眉 ID→名称 - 月份校验抽 core/month,报表/月结/调整单非法月份统一 400; 岗位只读选项供财务/领导筛选 - 前端修复:部门下钻带入筛选、回款多桶独立状态、打印截断判定、 summary 请求序号保护 Co-Authored-By: Claude Code <noreply@anthropic.com>
423 lines
15 KiB
Python
423 lines
15 KiB
Python
"""报表辅助纯函数与 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
|
||
|
||
|
||
def _member(
|
||
personnel_id: int,
|
||
name: str,
|
||
*,
|
||
kind: str = "regular",
|
||
performance: str = "0.00",
|
||
payable: str = "0.00",
|
||
adjust: str = "0.00",
|
||
reason: str | None = None,
|
||
) -> dict:
|
||
return {
|
||
"personnel_id": personnel_id,
|
||
"personnel_name": name,
|
||
"role_code": "salesperson",
|
||
"is_probation": 0,
|
||
"member_kind": kind,
|
||
"unassigned_reason": reason,
|
||
"performance_sum": Decimal(performance),
|
||
"payable_sum": Decimal(payable),
|
||
"adjust_extra": Decimal(adjust),
|
||
"adjust_deduct": Decimal("0"),
|
||
"adjust_net": Decimal(adjust),
|
||
"net_total": Decimal(payable) + Decimal(adjust),
|
||
"ledger_ids": [],
|
||
}
|
||
|
||
|
||
def _department_data(
|
||
team_text: str = "12.5",
|
||
company_text: str = "5",
|
||
*,
|
||
second_has_aggregate: bool = False,
|
||
) -> dict:
|
||
"""一份含成员、零成员团队与未分配人员的部门汇总出参。
|
||
|
||
``second_has_aggregate`` 打开后两位经理都有月结聚合行,用于验证
|
||
全公司 5% 按人数累计(零成员团队默认不发 5%)。
|
||
"""
|
||
second_aggregate = Decimal("600.00") if second_has_aggregate else Decimal("0")
|
||
share_count = 2 if second_has_aggregate else 1
|
||
aggregate_total = Decimal("1850.00") + second_aggregate
|
||
payable_total = Decimal("10000.00") + Decimal("2000.00") + aggregate_total
|
||
return {
|
||
"settlement_month": "2026-10",
|
||
"params": {
|
||
"team_text": team_text,
|
||
"company_text": company_text,
|
||
"manager_personal_included": 0,
|
||
"params_as_of": "2026-10-31",
|
||
},
|
||
"teams": [
|
||
{
|
||
"manager_id": 1,
|
||
"manager_name": "张经理",
|
||
"manager_status": 1,
|
||
"member_count": 1,
|
||
"team_base": Decimal("10000.00"),
|
||
"team_share_amount": Decimal("1250.00"),
|
||
"company_share_amount": Decimal("600.00"),
|
||
"has_aggregate": True,
|
||
"aggregate_amount": Decimal("1850.00"),
|
||
"team_subtotal": Decimal("1850.00"),
|
||
"company_base_ratio": Decimal("0.8333"),
|
||
"adjust_extra": Decimal("0"),
|
||
"adjust_deduct": Decimal("0"),
|
||
"adjust_net": Decimal("0"),
|
||
"net_total": Decimal("1850.00"),
|
||
"frozen_amount": None,
|
||
"aggregate_ledger_id": None,
|
||
"members": [
|
||
_member(
|
||
2,
|
||
"李业务",
|
||
performance="10000.00",
|
||
payable="10000.00",
|
||
)
|
||
],
|
||
},
|
||
{
|
||
"manager_id": 3,
|
||
"manager_name": "王经理",
|
||
"manager_status": 1,
|
||
"member_count": 0,
|
||
"team_base": Decimal("0"),
|
||
"team_share_amount": Decimal("0"),
|
||
"company_share_amount": second_aggregate,
|
||
"has_aggregate": second_has_aggregate,
|
||
"aggregate_amount": second_aggregate,
|
||
"team_subtotal": second_aggregate,
|
||
"company_base_ratio": Decimal("0"),
|
||
"adjust_extra": Decimal("0"),
|
||
"adjust_deduct": Decimal("0"),
|
||
"adjust_net": Decimal("0"),
|
||
"net_total": second_aggregate,
|
||
"frozen_amount": None,
|
||
"aggregate_ledger_id": None,
|
||
"members": [],
|
||
},
|
||
],
|
||
"unassigned": [
|
||
_member(
|
||
4,
|
||
"赵未分配",
|
||
performance="2000.00",
|
||
payable="2000.00",
|
||
reason="无经理或上级非营销经理",
|
||
)
|
||
],
|
||
"company": {
|
||
"team_base_total": Decimal("10000.00"),
|
||
"company_base": Decimal("12000.00"),
|
||
"company_share_pct": company_text,
|
||
"company_share_amount": Decimal("600.00"),
|
||
"company_share_count": share_count,
|
||
"company_share_total": Decimal("600.00") * share_count,
|
||
},
|
||
"reconciliation": {
|
||
"team_member_payable_total": Decimal("10000.00"),
|
||
"unassigned_payable_total": Decimal("2000.00"),
|
||
"team_share_total": Decimal("1250.00"),
|
||
"company_share_total": Decimal("600.00") * share_count,
|
||
"aggregate_payable_total": aggregate_total,
|
||
"aggregate_rounding_diff": Decimal("0"),
|
||
"adjust_net_total": Decimal("0"),
|
||
"payable_total": payable_total,
|
||
"team_base_total": Decimal("10000.00"),
|
||
"unassigned_performance_total": Decimal("2000.00"),
|
||
"other_team_base": Decimal("0"),
|
||
"other_team_member_payable_total": Decimal("0"),
|
||
"other_team_share_total": Decimal("0"),
|
||
"other_team_company_share_total": Decimal("0"),
|
||
"other_team_aggregate_total": Decimal("0"),
|
||
"other_adjust_net_total": Decimal("0"),
|
||
"company_base": Decimal("12000.00"),
|
||
},
|
||
"totals": {},
|
||
}
|
||
|
||
|
||
def _build_department(data: dict) -> bytes:
|
||
return report_export.build_workbook(
|
||
code="department-summary",
|
||
title="部门绩效工资汇总表",
|
||
filters="统计月份 2026-10",
|
||
rows=[],
|
||
summary=None,
|
||
buckets=None,
|
||
department=data,
|
||
template_version="V1.2",
|
||
operator_name="admin",
|
||
company_name="衍水集团",
|
||
)
|
||
|
||
|
||
class Test部门汇总签字稿:
|
||
"""签字稿少了团队分成/5% 块/未分配/零成员团队,合计就会小于应发。"""
|
||
|
||
def test_五块顺序完整(self):
|
||
text = _sheet_text(_build_department(_department_data()))
|
||
titles = [
|
||
"一、团队分成汇总",
|
||
"二、全公司分成(每位有聚合行",
|
||
"三、对账汇总",
|
||
"四、团队成员明细",
|
||
"五、未分配人员明细",
|
||
]
|
||
positions = [text.index(title) for title in titles]
|
||
assert positions == sorted(positions), "签字稿块顺序错乱"
|
||
assert "财务复核" in text and "领导签字" in text
|
||
|
||
def test_零成员团队与未分配行存在(self):
|
||
text = _sheet_text(_build_department(_department_data()))
|
||
assert "王经理" in text
|
||
assert "赵未分配" in text
|
||
assert "无经理或上级非营销经理" in text
|
||
|
||
def test_全公司分成按计入次数累计(self):
|
||
"""两位有聚合行的经理各计一次 5%;零成员团队不产生分成。"""
|
||
one = _department_data()
|
||
text = _sheet_text(_build_department(one))
|
||
assert text.count("全公司分成(每位有聚合行") == 1
|
||
assert "共 1 次" in text
|
||
assert "13850" in text
|
||
|
||
two = _department_data(second_has_aggregate=True)
|
||
text = _sheet_text(_build_department(two))
|
||
assert "共 2 次" in text
|
||
# 团队成员应付 10000 + 未分配 2000 + 聚合 2450
|
||
assert "14450" in text
|
||
|
||
def test_应发合计取对账口径(self):
|
||
text = _sheet_text(_build_department(_department_data()))
|
||
assert "应发合计" in text
|
||
# Excel 数值单元格读回为 int/float,金额断言只看数值本身
|
||
assert "13850" in text
|
||
|
||
def test_聚合行舍入差单列(self):
|
||
data = _department_data()
|
||
data["reconciliation"]["aggregate_rounding_diff"] = Decimal("-0.02")
|
||
text = _sheet_text(_build_department(data))
|
||
assert "聚合行舍入差" in text
|
||
assert "-0.02" in text
|
||
|
||
def test_比例标题参数化(self):
|
||
text = _sheet_text(_build_department(_department_data("20", "6")))
|
||
assert "团队分成(20%)" in text
|
||
assert "6%" in text
|
||
assert "12.5%" not in text
|
||
assert "5%" not in text.replace("6%", "")
|