PayrollManagementSystem/frontend/e2e/report_flow.py
liuwenxin 980628bb8f test(e2e): 回款统计改按本次业务编号下钻断言
历史 e2e 数据会累计到同一回款区间桶,断言桶聚合值在重复运行时必然失败;改为展开目标桶后按本次业务编号定位下钻行并校验金额,脚本可无副作用重跑。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 10:04:42 +08:00

433 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.

"""报表统计端到端验证(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_id": person["id"],
"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("✓ 7. 业务统计 关键字筛选命中")
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('[data-testid="payment-bucket-table"] .el-table__row')
if rows.count() != 8:
fail("回款统计", f"区间表应为 8 行,实际 {rows.count()}")
bucket = page.locator(
'[data-testid="payment-bucket-table"] .el-table__row', has_text="61-90 天"
).first
if bucket.count() == 0:
fail("回款统计", "未找到 61-90 天区间行")
# 按本次业务编号在下钻明细中定位,不依赖桶聚合值(历史 e2e 数据会累计到同桶)
bucket.locator(".el-table__expand-icon").click()
page.wait_for_timeout(1000)
drill_row = page.locator(
".drill-wrap .el-table__row", has_text=ctx["business_no"]
).first
if drill_row.count() == 0:
fail("回款统计", f"61-90 天桶下钻未找到本次业务 {ctx['business_no']}")
drill_text = drill_row.inner_text()
if "10000.00" not in drill_text:
fail("回款统计", f"本次业务下钻行金额不符:{drill_text[:120]}")
print("✓ 8. 回款统计 8 档区间与本次业务下钻金额正确")
def step_payment_multi_expand(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)
for label in ("61-90 天", "1-30 天"):
row = page.locator(
'[data-testid="payment-bucket-table"] .el-table__row', has_text=label
).first
row.locator(".el-table__expand-icon").click()
page.wait_for_timeout(700)
drills = page.locator(".drill-wrap")
if drills.count() != 2:
fail("回款多桶展开", f"应展开 2 个下钻面板,实际 {drills.count()}")
texts = [drills.nth(index).inner_text() for index in range(2)]
# 面板是明细行(每笔 10000),不是桶合计;串行时两个面板文本会完全相同
if texts[0] == texts[1] or "10000.00" not in "".join(texts):
fail("回款多桶展开", "两个桶面板显示了同一批明细(drill 状态串行)")
print("✓ 9. 回款多桶展开 两个下钻面板互不串行")
def step_department_drill(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
row.locator(".el-table__expand-icon").click()
page.wait_for_timeout(500)
page.locator("button:has-text('查看明细')").first.click()
page.wait_for_timeout(900)
if f"personnel_id={ctx['personnel_id']}" not in page.url:
fail("部门下钻", f"URL 未携带人员筛选:{page.url}")
if ctx["personnel_name"] not in page.locator(".filters").inner_text():
fail("部门下钻", "个人明细页未应用人员筛选")
print("✓ 6. 部门下钻 人员与月份筛选已生效")
def step_print_truncated(page: Page, ctx: dict) -> None:
"""超过打印上限时必须给出截断提示(修复前提示永不出现)。"""
def handler(route) -> None:
rows = [
{
"ledger_id": index,
"business_no": f"E2E-{index}",
"ship_date": None,
"receipt_date": f"{ctx['month']}-01",
"payment_days": 10,
"product_category": "municipal_water",
"customer_type": "personal",
"role_name": "业务员",
"rate_text": "1",
"weight_r": 1,
"performance_amount": "1.00",
"payable_amount": "1.00",
}
for index in range(200)
]
route.fulfill(
json={
"code": 200,
"message": "ok",
"data": {
"list": rows,
"total": 2500,
"pageNum": 1,
"pageSize": 200,
"summary": {"row_count": 2500},
},
}
)
page.route("**/api/v1/reports/personal-detail*", handler)
try:
page.goto(
f"{BASE_URL}/reports/print/personal-detail?settlement_month={ctx['month']}",
wait_until="networkidle",
)
page.wait_for_timeout(1200)
if page.locator(".truncated").count() == 0:
fail("打印截断", "超过 2000 行未显示截断提示")
if "2000" not in page.locator(".truncated").inner_text():
fail("打印截断", "截断提示未说明上限行数")
finally:
page.unroute("**/api/v1/reports/personal-detail*", handler)
print("✓ 11. 打印截断 超限提示可见")
def step_print_department(page: Page, ctx: dict) -> None:
"""部门汇总打印稿必须含团队分成、5% 块、对账、成员与未分配五块。"""
page.goto(
f"{BASE_URL}/reports/print/department-summary?settlement_month={ctx['month']}",
wait_until="networkidle",
)
page.wait_for_timeout(1200)
for testid in (
"print-department-teams",
"print-company-block",
"print-reconciliation",
"print-department-members",
"print-unassigned",
):
if page.locator(f'[data-testid="{testid}"]').count() == 0:
fail("部门打印", f"缺少区块 {testid}")
if "财务复核" not in page.locator(".print-page").inner_text():
fail("部门打印", "签字栏缺失")
print("✓ 12. 部门打印 五块结构与签字栏完整")
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("✓ 10. 打印视图 表格/签字栏/打印调用均正常")
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_department_drill(page, ctx)
step_business_stats(page, ctx)
step_payment_stats(page, ctx)
step_payment_multi_expand(page, ctx)
step_print_view(page, ctx)
step_print_truncated(page, ctx)
step_print_department(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())