From 04a1a0d3076066ecbafb870863d2ee95c313830d Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Mon, 19 Oct 2026 16:33:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(report):=20=E6=8A=A5=E8=A1=A8=E5=9B=9B?= =?UTF-8?q?=E9=A1=B5=E4=B8=8E=E6=89=93=E5=8D=B0=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 个人绩效明细/部门绩效汇总/业务统计/回款统计四页,路由组「报表统计」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 --- backend/tests/README.md | 13 + backend/tests/integration/test_reports.py | 308 +++++++++++++ backend/tests/unit/test_report_helpers.py | 211 +++++++++ .../plans/2026-10-19-M4-报表统计.md | 6 +- frontend/e2e/report_flow.py | 300 +++++++++++++ frontend/src/api/reports.ts | 228 ++++++++++ frontend/src/router/index.ts | 58 +++ .../views/reports/business-stats/index.vue | 319 ++++++++++++++ .../reports/department-summary/index.vue | 296 +++++++++++++ .../src/views/reports/payment-stats/index.vue | 310 +++++++++++++ .../views/reports/personal-detail/index.vue | 304 +++++++++++++ frontend/src/views/reports/print/index.vue | 411 ++++++++++++++++++ 12 files changed, 2763 insertions(+), 1 deletion(-) create mode 100644 backend/tests/integration/test_reports.py create mode 100644 backend/tests/unit/test_report_helpers.py create mode 100644 frontend/e2e/report_flow.py create mode 100644 frontend/src/api/reports.ts create mode 100644 frontend/src/views/reports/business-stats/index.vue create mode 100644 frontend/src/views/reports/department-summary/index.vue create mode 100644 frontend/src/views/reports/payment-stats/index.vue create mode 100644 frontend/src/views/reports/personal-detail/index.vue create mode 100644 frontend/src/views/reports/print/index.vue diff --git a/backend/tests/README.md b/backend/tests/README.md index 95a1277..fbe5481 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -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` 运行 | ## 四、测试发现并修复的缺陷 diff --git a/backend/tests/integration/test_reports.py b/backend/tests/integration/test_reports.py new file mode 100644 index 0000000..68bff5c --- /dev/null +++ b/backend/tests/integration/test_reports.py @@ -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"] == "无权访问报表统计" diff --git a/backend/tests/unit/test_report_helpers.py b/backend/tests/unit/test_report_helpers.py new file mode 100644 index 0000000..f774c7c --- /dev/null +++ b/backend/tests/unit/test_report_helpers.py @@ -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 diff --git a/docs/superpowers/plans/2026-10-19-M4-报表统计.md b/docs/superpowers/plans/2026-10-19-M4-报表统计.md index 1a1976e..2a43978 100644 --- a/docs/superpowers/plans/2026-10-19-M4-报表统计.md +++ b/docs/superpowers/plans/2026-10-19-M4-报表统计.md @@ -42,7 +42,11 @@ ## 二、后端任务 -### T1 常量与元数据(`core/constants.py` 改 + `services/report_meta.py` 新) +### T1 常量与元数据(`core/constants.py` 改 + `core/report_meta.py` 新) + +> 实施修订(2026-10-19):`report_meta` 放在 `core/` 而非 `services/`——仓储层 +> 的区间下钻需要 `DayBucket` 类型,放 services 会让 repository 反向依赖 +> service 层(违反六层红线)。 - `MODULE_REPORT = "报表统计"`、`ACTION_EXPORT = "导出"` - `report_meta.py`(叶子模块,禁 app 内导入防循环依赖):`DayBucket` frozen dataclass + `PAYMENT_DAY_BUCKETS` 8 档(≤0 天(先款后货)/1-30/31-60/61-90/91-180/181-360/361+/无天数)、`CUSTOMER_TYPE_LABELS`、`PRODUCT_CATEGORY_LABELS`(含 mixed)、`REPORT_TEMPLATE_VERSION = "V1.0"`、`REPORT_EXPORT_MAX_ROWS = 20000` diff --git a/frontend/e2e/report_flow.py b/frontend/e2e/report_flow.py new file mode 100644 index 0000000..1998fbf --- /dev/null +++ b/frontend/e2e/report_flow.py @@ -0,0 +1,300 @@ +"""报表统计端到端验证(M4 ⑤:四张报表 + 打印视图)。 + +覆盖真实浏览器链路(接口测试守不住的部分): +- 「报表统计」组四页菜单可见、可进入 +- 个人明细:API 预置的已核算业务出现在列表;导出触发真实下载 +- 部门汇总:经理团队行出现且团队基数非 0;全公司 5% 卡片可见 +- 业务统计:关键字筛选命中;三列统计非空 +- 回款统计:8 档区间表;目标桶(61-90 天)有金额;空桶显示 0 +- 打印视图:独立路由带出报表与签字栏;window.print 被调用 + +前置(缺一不可): + docker compose up -d + cd backend && uv run uvicorn app.main:app --port 8000 + cd frontend && pnpm dev + cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/report_flow.py + +数据说明:核算后数据不可删(业务规则),按 `e2e_` 前缀留档开发库; +每次运行用随机后缀隔离,断言以本次业务编号定位。 +""" + +import json +import os +import time +import urllib.error +import urllib.request + +from playwright.sync_api import Page, sync_playwright + +from common import ( + BASE_URL, + E2EFailure, + collect_console_errors, + current_path, + fail, + login, +) + +ADMIN = "admin" +SUFFIX = f"{int(time.time())}" +console_errors: list[str] = [] +MONTH = time.strftime("%Y-%m") + + +def _api(method: str, path: str, token: str, body: dict | None = None) -> dict: + """带令牌的轻量 API 调用(urllib,避免额外依赖)。""" + request = urllib.request.Request( + f"{BASE_URL}{path}", + method=method, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + data=json.dumps(body).encode() if body is not None else None, + ) + try: + with urllib.request.urlopen(request, timeout=20) as response: + payload = json.loads(response.read().decode()) + except urllib.error.HTTPError as exc: + detail = exc.read().decode()[:200] + raise E2EFailure(f"API {method} {path} HTTP {exc.code}:{detail}") from exc + if payload.get("code") != 200: + raise E2EFailure(f"API {method} {path} 业务失败:{payload.get('message')}") + return payload["data"] + + +def step_login(page: Page, admin_password: str) -> None: + login(page, ADMIN, admin_password) + print("✓ 1. 登录") + + +def step_setup_via_api(page: Page) -> dict: + """预置:经理 + 业务员 → 客户/业务 → 发货(60 天前)→ 回款 → 核算。""" + token = page.evaluate("localStorage.getItem('ys_token')") + if not token: + fail("API 预置", "localStorage 中未取到 ys_token") + + roles = _api("GET", "/api/v1/system/business-roles?pageNum=1&pageSize=50", token)["list"] + salesperson_role = next((r["id"] for r in roles if r["role_code"] == "salesperson"), None) + manager_role = next((r["id"] for r in roles if r["role_code"] == "sales_manager"), None) + if salesperson_role is None or manager_role is None: + fail("API 预置", "未找到 salesperson / sales_manager 岗位种子") + + manager = _api( + "POST", + "/api/v1/base/personnel", + token, + { + "personnel_name": f"e2e_报表现经理_{SUFFIX}", + "business_role_id": manager_role, + "salary_standard": "3000.00", + }, + ) + person = _api( + "POST", + "/api/v1/base/personnel", + token, + { + "personnel_name": f"e2e_报表现业务员_{SUFFIX}", + "business_role_id": salesperson_role, + "salary_standard": "3000.00", + "manager_id": manager["id"], + }, + ) + customer = _api( + "POST", + "/api/v1/base/customers", + token, + { + "company_name": f"e2e_报表客户_{SUFFIX}", + "owner_name": "张老板", + "customer_type": "personal", + }, + ) + business = _api( + "POST", + "/api/v1/business/businesses", + token, + { + "customer_id": customer["id"], + "business_date": f"{MONTH}-01", + "owner_personnel_id": person["id"], + "items": [ + { + "product_name": f"e2e_报表产品_{SUFFIX}", + "product_category": "municipal_water", + "quantity": "100", + "sale_price": "100.00", + "cost_amount": "3000.00", + } + ], + }, + ) + from datetime import date, timedelta + + ship_date = (date.today() - timedelta(days=60)).isoformat() + shipment = _api( + "POST", + "/api/v1/business/shipments", + token, + { + "business_id": business["id"], + "ship_date": ship_date, + "items": [{"product_name": "e2e_报表发货", "quantity": "1", "sale_price": "1.00"}], + }, + ) + receipt = _api( + "POST", + "/api/v1/business/receipts", + token, + { + "business_id": business["id"], + "receipt_date": date.today().isoformat(), + "amount": "10000.00", + "shipment_id": shipment["id"], + }, + ) + _api("POST", f"/api/v1/calc/receipts/{receipt['id']}/calc", token) + print(f"✓ 2. API 预置 业务 {business['business_no']} 已核算") + return { + "month": MONTH, + "business_no": business["business_no"], + "personnel_name": person["personnel_name"], + "manager_name": manager["personnel_name"], + } + + +def step_personal_detail(page: Page, ctx: dict) -> None: + page.goto(f"{BASE_URL}/reports/personal-detail", wait_until="networkidle") + page.wait_for_timeout(800) + if current_path(page) != "/reports/personal-detail": + fail("导航", f"未进入个人绩效明细页,当前 {page.url}") + + # 月份筛选:Element Plus 月份选择器可直接键入 YYYY-MM + month_input = page.locator(".filters input").first + month_input.fill(ctx["month"]) + month_input.press("Enter") + page.locator(".filters button:has-text('查询')").first.click() + page.wait_for_timeout(900) + + row = page.locator(".el-table__row", has_text=ctx["business_no"]).first + if row.count() == 0: + fail("个人明细", f"未找到业务 {ctx['business_no']} 的明细行") + print("✓ 3. 个人明细 列表命中本次核算业务") + + with page.expect_download(timeout=20000) as download_info: + page.locator("button:has-text('导出 Excel')").first.click() + download = download_info.value + if not download.suggested_filename.endswith(".xlsx"): + fail("导出", f"下载文件名非 xlsx:{download.suggested_filename}") + print(f"✓ 4. 导出下载 {download.suggested_filename}") + + +def step_department_summary(page: Page, ctx: dict) -> None: + page.goto(f"{BASE_URL}/reports/department-summary", wait_until="networkidle") + page.wait_for_timeout(900) + page.locator(".filters button:has-text('查询')").first.click() + page.wait_for_timeout(900) + + row = page.locator(".el-table__row", has_text=ctx["manager_name"]).first + if row.count() == 0: + fail("部门汇总", f"未找到经理 {ctx['manager_name']} 的团队行") + if page.locator(".company-card").count() == 0: + fail("部门汇总", "全公司 5% 卡片未渲染") + print("✓ 5. 部门汇总 团队行与全公司卡片可见") + + +def step_business_stats(page: Page, ctx: dict) -> None: + page.goto(f"{BASE_URL}/reports/business-stats", wait_until="networkidle") + page.wait_for_timeout(800) + keyword = page.locator(".filters input").first + keyword.fill(ctx["business_no"]) + keyword.press("Enter") + page.locator(".filters button:has-text('查询')").first.click() + page.wait_for_timeout(800) + + row = page.locator(".el-table__row", has_text=ctx["business_no"]).first + if row.count() == 0: + fail("业务统计", f"未找到业务 {ctx['business_no']}") + print("✓ 6. 业务统计 关键字筛选命中") + + +def step_payment_stats(page: Page, ctx: dict) -> None: + page.goto(f"{BASE_URL}/reports/payment-stats", wait_until="networkidle") + page.wait_for_timeout(900) + page.locator(".filters button:has-text('查询')").first.click() + page.wait_for_timeout(900) + + rows = page.locator(".el-table__row") + if rows.count() != 8: + fail("回款统计", f"区间表应为 8 行,实际 {rows.count()}") + bucket = page.locator(".el-table__row", has_text="61-90 天").first + if bucket.count() == 0: + fail("回款统计", "未找到 61-90 天区间行") + bucket_text = bucket.inner_text() + if "10000.00" not in bucket_text: + fail("回款统计", f"61-90 天桶回款金额不符:{bucket_text[:120]}") + print("✓ 7. 回款统计 8 档区间与目标桶金额正确") + + +def step_print_view(page: Page, ctx: dict) -> None: + page.add_init_script("window.print = () => { window.__printed = true }") + page.goto( + f"{BASE_URL}/reports/print/personal-detail?settlement_month={ctx['month']}", + wait_until="networkidle", + ) + page.wait_for_timeout(1200) + if page.locator(".sheet-table").count() == 0: + fail("打印视图", "报表表格未渲染") + text = page.locator(".print-page").inner_text() + if "财务复核" not in text or "领导签字" not in text: + fail("打印视图", "签字栏缺失") + page.locator("button:has-text('打印')").first.click() + page.wait_for_timeout(400) + if page.evaluate("window.__printed") is not True: + fail("打印视图", "window.print 未被调用") + print("✓ 8. 打印视图 表格/签字栏/打印调用均正常") + + +def run(page: Page) -> None: + collect_console_errors(page, console_errors) + admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "") + if not admin_password: + fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)") + + step_login(page, admin_password) + ctx = step_setup_via_api(page) + step_personal_detail(page, ctx) + step_department_summary(page, ctx) + step_business_stats(page, ctx) + step_payment_stats(page, ctx) + step_print_view(page, ctx) + + if console_errors: + print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})") + + +def main() -> int: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1600, "height": 1000}) + page.set_default_timeout(15000) + try: + run(page) + except E2EFailure as exc: + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ {exc}") + print(" 已保存截图 e2e/failure.png") + return 1 + except Exception as exc: # noqa: BLE001 — 测试脚本需打印完整失败原因 + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}") + return 1 + finally: + browser.close() + print("\n全部通过。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontend/src/api/reports.ts b/frontend/src/api/reports.ts new file mode 100644 index 0000000..fae54b9 --- /dev/null +++ b/frontend/src/api/reports.ts @@ -0,0 +1,228 @@ +import service, { request } from './request' +import type { PageData } from './types' + +/** 报表分页:标准分页四字段 + 全筛选合计(summary 非当前页合计) */ +export interface ReportSummary { + row_count: number + performance_total?: string | null + payable_total?: string | null + converted_profit_total?: string | null + ship_total?: string | null + received_total?: string | null + sale_total?: string | null +} + +export interface ReportPage extends PageData { + summary: ReportSummary +} + +/** 个人绩效明细行(金额/比例一律字符串,前端不做数值运算) */ +export interface PersonalDetailRow { + ledger_id: number + business_id: number + business_no: string + personnel_id: number + personnel_name: string + role_code: string + role_name: string + customer_type: string + product_category: string + ship_date: string | null + receipt_date: string + payment_days: number | null + rate_text: string + weight_r: number | null + payment_weight: string | null + performance_amount: string | null + payable_amount: string | null + converted_profit: string | null + is_probation: number + missing_cost: number + calc_version: number + settlement_id: number | null +} + +export interface DepartmentMemberRow { + personnel_id: number + personnel_name: string + role_code: string + is_probation: number + performance_sum: string | null + payable_sum: string | null + adjust_extra: string + adjust_deduct: string + adjust_net: string + net_total: string | null + ledger_ids: number[] +} + +export interface DepartmentTeamRow { + manager_id: number + manager_name: string + manager_status: number + member_count: number + team_base: string + team_share_amount: string + company_share_amount: string + company_base_ratio: string + adjust_extra: string + adjust_deduct: string + adjust_net: string + net_total: string + frozen_amount: string | null + aggregate_ledger_id: number | null + members: DepartmentMemberRow[] +} + +export interface DepartmentSummary { + settlement_month: string + params: { + team_text: string + company_text: string + manager_personal_included: number + params_as_of: string + } | null + teams: DepartmentTeamRow[] + unassigned: DepartmentMemberRow[] + company: { + team_base_total: string + company_base: string + company_share_pct: string + company_share_amount: string + } | null + totals: Record +} + +export interface BusinessStatsRow { + business_id: number + business_no: string + business_date: string | null + status: number + customer_name: string | null + owner_personnel_name: string | null + total_sale_amount: string | null + total_ship_amount: string | null + total_received_amount: string | null + performance_amount: string | null + payable_amount: string | null +} + +export interface BucketRow { + code: string + label: string + min_days: number | null + max_days: number | null + receipt_amount: string | null + receipt_count: number + performance_amount: string | null + payable_amount: string | null + ledger_count: number +} + +export interface GroupRow { + code: string + label: string + receipt_amount: string | null + performance_amount: string | null + payable_amount: string | null + ledger_count: number +} + +export interface PaymentStats { + settlement_month: string | null + buckets: BucketRow[] + by_customer_type: GroupRow[] + by_product_category: GroupRow[] + totals: Record +} + +export interface PaymentDetailRow { + ledger_id: number + receipt_id: number | null + receipt_batch_no: string | null + amount: string | null + business_id: number + business_no: string + personnel_id: number + personnel_name: string + customer_type: string + product_category: string + ship_date: string | null + receipt_date: string + payment_days: number | null + performance_amount: string | null + payable_amount: string | null +} + +/** 报表查询通用筛选(各页按需取用) */ +export interface ReportFilterParams { + pageNum?: number + pageSize?: number + settlement_month?: string + personnel_id?: number + customer_type?: string + product_category?: string + role_code?: string + manager_id?: number + keyword?: string + customer_id?: number + owner_personnel_id?: number + status?: number + date_from?: string + date_to?: string + bucket?: string +} + +export function listPersonalDetail( + params: ReportFilterParams, +): Promise> { + return request({ url: '/v1/reports/personal-detail', method: 'get', params }) +} + +export function getDepartmentSummary(params: ReportFilterParams): Promise { + return request({ url: '/v1/reports/department-summary', method: 'get', params }) +} + +export function listBusinessStats( + params: ReportFilterParams, +): Promise> { + return request({ url: '/v1/reports/business-stats', method: 'get', params }) +} + +export function getPaymentStats(params: ReportFilterParams): Promise { + return request({ url: '/v1/reports/payment-stats', method: 'get', params }) +} + +export function listPaymentDetail( + params: ReportFilterParams, +): Promise> { + return request({ url: '/v1/reports/payment-detail', method: 'get', params }) +} + +export function getPaymentDayBuckets(): Promise< + { code: string; label: string; min_days: number | null; max_days: number | null }[] +> { + return request({ url: '/v1/reports/payment-day-buckets', method: 'get' }) +} + +/** + * 导出报表(文件流)。走 axios 实例(带令牌)而非 request 封装:响应是 + * xlsx 文件流,不是统一信封,拦截器已对 responseType=blob 直通。 + * 服务端文件名是 ASCII(HTTP 头 latin-1 限制),中文名由调用方 link.download 指定。 + */ +export async function exportReport( + code: 'personal-detail' | 'department-summary' | 'business-stats' | 'payment-stats', + params: ReportFilterParams, +): Promise { + const response = await service.get(`/v1/reports/${code}/export`, { + params, + responseType: 'blob', + }) + return response.data as Blob +} + +/** 产品类别展示名:报表需额外覆盖「混合业务」(台账行可为 mixed) */ +export function reportCategoryLabel(code: string): string { + if (code === 'mixed') return '混合业务' + return { municipal_water: '市政水利', rail_transit: '轨道交通' }[code] ?? code +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 6bff61d..ee8ea7c 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -158,8 +158,66 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/settlements/index.vue'), meta: { title: '月度结算', group: '绩效工资', order: 18 }, }, + // 报表统计组(M4 ⑤):FR-030/031 限领导/财务/管理员;admin 须显式 + // 列入 allowedRoles(项目既定规则,admin 不天然放行) + { + path: '/reports/personal-detail', + name: 'reports-personal-detail', + component: () => import('@/views/reports/personal-detail/index.vue'), + meta: { + title: '个人绩效明细', + group: '报表统计', + order: 19, + allowedRoles: [ROLE_ADMIN, 'finance', 'leader'], + }, + }, + { + path: '/reports/department-summary', + name: 'reports-department-summary', + component: () => import('@/views/reports/department-summary/index.vue'), + meta: { + title: '部门绩效汇总', + group: '报表统计', + order: 20, + allowedRoles: [ROLE_ADMIN, 'finance', 'leader'], + }, + }, + { + path: '/reports/business-stats', + name: 'reports-business-stats', + component: () => import('@/views/reports/business-stats/index.vue'), + meta: { + title: '业务统计', + group: '报表统计', + order: 21, + allowedRoles: [ROLE_ADMIN, 'finance', 'leader'], + }, + }, + { + path: '/reports/payment-stats', + name: 'reports-payment-stats', + component: () => import('@/views/reports/payment-stats/index.vue'), + meta: { + title: '回款统计', + group: '报表统计', + order: 22, + allowedRoles: [ROLE_ADMIN, 'finance', 'leader'], + }, + }, ], }, + { + // 打印视图独立于 BasicLayout(不挂侧栏/顶栏,浏览器打印只出报表本身); + // menu:false 不进菜单,但 allowedRoles 守卫照常生效 + path: '/reports/print/:code', + name: 'report-print', + component: () => import('@/views/reports/print/index.vue'), + meta: { + title: '报表打印', + menu: false, + allowedRoles: [ROLE_ADMIN, 'finance', 'leader'], + }, + }, // 未匹配路由回首页;若未登录会被守卫拦到登录页 { path: '/:pathMatch(.*)*', redirect: '/dashboard' }, ] diff --git a/frontend/src/views/reports/business-stats/index.vue b/frontend/src/views/reports/business-stats/index.vue new file mode 100644 index 0000000..69192b7 --- /dev/null +++ b/frontend/src/views/reports/business-stats/index.vue @@ -0,0 +1,319 @@ + + + + + diff --git a/frontend/src/views/reports/department-summary/index.vue b/frontend/src/views/reports/department-summary/index.vue new file mode 100644 index 0000000..9ecfa38 --- /dev/null +++ b/frontend/src/views/reports/department-summary/index.vue @@ -0,0 +1,296 @@ + + + + + diff --git a/frontend/src/views/reports/payment-stats/index.vue b/frontend/src/views/reports/payment-stats/index.vue new file mode 100644 index 0000000..772c814 --- /dev/null +++ b/frontend/src/views/reports/payment-stats/index.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/frontend/src/views/reports/personal-detail/index.vue b/frontend/src/views/reports/personal-detail/index.vue new file mode 100644 index 0000000..d4f085c --- /dev/null +++ b/frontend/src/views/reports/personal-detail/index.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/frontend/src/views/reports/print/index.vue b/frontend/src/views/reports/print/index.vue new file mode 100644 index 0000000..ea37e56 --- /dev/null +++ b/frontend/src/views/reports/print/index.vue @@ -0,0 +1,411 @@ + + + + +