PayrollManagementSystem/frontend/e2e/report_flow.py

433 lines
16 KiB
Python
Raw Normal View History

"""报表统计端到端验证(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())