"""报表辅助纯函数与 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%", "")