PayrollManagementSystem/backend/tests/unit/test_report_helpers.py
liuwenxin b3de35c2ec fix(核算): 对齐抵顶口径并完善界面细节
回款确认时间收敛到秒级;抵顶回款不再自动折算并保留标记。补齐公式模板角色标签与提示、指导成本登记入口及基础布局、分页、设置等响应式细节。同步忽略本地排查产物。
2026-11-06 16:27:45 +08:00

426 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""报表辅助纯函数与 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 test_业务销售额未知时导出明细和合计不显示零(self):
rows = [{"business_no": "YW001", "status": 1, "total_sale_amount": None}]
workbook = load_workbook(io.BytesIO(_build("business-stats", rows)))
values = [cell.value for row in workbook.active.iter_rows() for cell in row]
assert values.count("待核对") >= 2
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%", "")