PayrollManagementSystem/frontend/e2e/secondary_allocation_flow.py
liuwenxin 694d21d1fd test(e2e): 二次分配流程断言改为取实际方案并补审批内容校验
审批详情的送审内容断言不再硬编码参与人姓名(下拉取人员列表前两项,
未必是脚本刚建的 t_e2e 员):改为经业务详情接口取回实际保存的方案,
逐参与人断言姓名与比例出现在审批抽屉中。顺带修掉原脚本清理环节的
隐性缺陷——开关开启时业务有在途审批单,界面删除被拦截而脚本无断言,
数据从未真正清理;现脚本内显式关闭审批开关并保证结束还原为 1。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 14:25:39 +08:00

342 lines
15 KiB
Python
Raw Permalink 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.

"""二次分配方案录入端到端验证。
覆盖的链路(只有真实浏览器才能验证的组合):
- 登录 → 业务管理 → 新增业务 → 在「二次分配方案」区添加两行参与人
并填比例 → 提交成功
- 详情/编辑回显:再次打开编辑抽屉,二次分配两行仍在(全量替换语义)
- 合计提示:两行比例非满额时展示「余额…(归属待业务确认)」
- 审批内容:审批详情展示**提交时**的送审方案(参与人 + 比例)
- 数据清理:删除本次创建的业务(t_ 前缀客户/业务,走界面删除)
审批开关在脚本内显式关闭(结束还原默认 1):既不依赖种子默认值,也让本次
业务不留在途审批单——业务删除保护在途单,开关开启时删不掉。
运行前置(缺一不可):
docker compose up -d # MySQL + Redis
cd backend && uv run uvicorn app.main:app --port 8000
cd frontend && pnpm dev
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/secondary_allocation_flow.py
"""
import json
import os
import time
import urllib.error
import urllib.request
from urllib.parse import quote
from playwright.sync_api import Page, sync_playwright
from common import (
BASE_URL,
E2EFailure,
collect_console_errors,
confirm_message_box,
current_path,
expect_toast,
fail,
login,
visible_dialog,
)
ADMIN = "admin"
API_BASE = "http://127.0.0.1:8000"
SUFFIX = f"{int(time.time())}"
console_errors: list[str] = []
_ALLOCATION_SWITCH = "approval.secondary_allocation.enabled"
def _api(method: str, path: str, token: str, body: dict | None = None) -> dict:
"""直连后端接口(仅用于开关预置;浏览器交互一律走页面)。"""
request = urllib.request.Request(
f"{API_BASE}{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 _set_allocation_switch(token: str, value: str) -> None:
"""显式控制二次分配审批开关(用例不依赖种子默认值;结束还原默认 1)。"""
_api(
"POST",
"/api/v1/system/params/changes",
token,
{
"change_type": 1,
"remark": f"e2e secondary_allocation_flow 设置二次分配审批开关={value}",
"items": [
{"param_code": _ALLOCATION_SWITCH, "dimension_value": "", "value": value}
],
},
)
def step_navigate_to_business(page: Page, admin_password: str) -> None:
login(page, ADMIN, admin_password)
# 路由直达业务管理:比点菜单更稳(open_menu 展开分组的两段点击可能与
# 弹窗关闭动画竞争,导致后续点击被残留 overlay 拦截)
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
page.wait_for_timeout(600)
if current_path(page) != "/business/businesses":
fail("菜单导航", f"未进入业务管理页,当前 {page.url}")
print("✓ 1. 菜单导航 进入业务管理页")
def step_create_customer_via_ui(page: Page) -> str:
"""经客户管理页建一个 t_ 客户(业务必选引用),返回客户名。"""
page.goto(f"{BASE_URL}/base/customers", wait_until="networkidle")
page.wait_for_timeout(600)
page.click("button:has-text('新增客户')")
dialog = visible_dialog(page)
customer_name = f"t_e2e客户_{SUFFIX}"
dialog.locator('input[placeholder="与负责人构成组合唯一键"]').fill(customer_name)
dialog.locator('input[placeholder="客户公司的负责人姓名"]').fill("张老板")
# 客户分类:个人户(下拉在表单内,需先点开)
dialog.locator(".el-select").first.click()
page.wait_for_timeout(300)
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text="个人户").first.click()
page.wait_for_timeout(300)
dialog.locator("button:has-text('保存')").click()
expect_toast(page, "客户已创建", "新增客户")
page.wait_for_timeout(500)
# 关闭客户弹窗,避免残留 overlay 遮挡后续业务弹窗的菜单点击
close_btn = dialog.locator(".el-dialog__header button").last
if close_btn.count():
close_btn.click()
page.wait_for_timeout(400)
print(f"✓ 2. 准备客户 新建 t_ 客户 {customer_name}")
return customer_name
def step_create_personnel(page: Page) -> None:
"""造 2 名 t_ 在职营销员(二次分配参与人下拉的选项来源)。"""
page.goto(f"{BASE_URL}/base/personnel", wait_until="networkidle")
page.wait_for_timeout(600)
for i in range(2):
page.click("button:has-text('新增人员')")
dialog = visible_dialog(page)
dialog.locator('input[placeholder="营销人员姓名"]').fill(f"t_e2e员_{SUFFIX}_{i}")
# 岗位(表单内第一个 select):选第一项(营销员种子岗位)
dialog.locator(".el-select").first.click()
page.wait_for_timeout(400)
page.locator(".el-select-dropdown:visible .el-select-dropdown__item").first.click()
page.wait_for_timeout(300)
# 月工资标准(选填,给个合法值)
dialog.locator('.el-form-item:has-text("工资标准") input').first.fill("3000.00")
dialog.locator("button:has-text('保存')").click()
expect_toast(page, "已创建", f"新增人员{i+1}")
page.wait_for_timeout(600)
print("✓ 2b. 准备人员 新建 2 名 t_ 在职营销员")
def step_create_business_with_allocation(page: Page, customer_name: str) -> None:
"""新增带二次分配方案的业务(参与人取人员下拉前两项,比例 30% + 40%)。"""
# 客户弹窗已关(上一段已关闭);直接路由直达业务管理,避免菜单点击
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
page.wait_for_timeout(600)
page.click("button:has-text('新增业务')")
dialog = visible_dialog(page)
# 主表:客户 + 一行明细
dialog.locator(".el-select").first.click()
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=customer_name).first.click()
page.wait_for_timeout(300)
# 明细行(至少 1 行):产品名 + 数量 + 单价
# 明细表列序:产品(0)/规格(1)/单位(2)/类别(3)/数量(4)/单价(5)/成本(6)/指导价(7)
first_row = dialog.locator(".items-table tbody tr").first
first_row.locator('input[placeholder="产品名称,可手填"]').fill(f"t_e2e产品_{SUFFIX}")
first_row.locator("td").nth(4).locator("input").fill("10")
first_row.locator("td").nth(5).locator("input").fill("100.00")
# 二次分配方案:添加两行参与人(按钮先滚动进可视区,避免被 overlay 遮挡)
for _ in range(2):
add_btn = page.locator("button:has-text('添加参与人')")
add_btn.scroll_into_view_if_needed()
add_btn.click()
page.wait_for_timeout(300)
# 弹窗里分配表有专属类 alloc-table(与产品明细的 items-table 区分),
# 避免空分配表时 nth(1) 错位到产品明细
alloc_rows = page.locator(".el-dialog:visible .alloc-table tbody tr")
for i in range(2):
row = alloc_rows.nth(i)
row.locator(".el-select").first.click()
page.wait_for_timeout(400)
options = page.locator(".el-select-dropdown:visible .el-select-dropdown__item")
if options.count() < 2:
fail("参与人下拉", f"可分配人员不足 2 人(需先在人员管理录入),当前 {options.count()}")
options.nth(i).click()
page.wait_for_timeout(300)
row.locator("td").nth(1).locator("input").fill("30.00" if i == 0 else "40.00")
page.wait_for_timeout(200)
dialog.locator("button:has-text('保存')").click()
expect_toast(page, "已创建", "新增业务(含二次分配)")
page.wait_for_timeout(800)
print("✓ 3. 录入方案 新增业务并录两行参与人(30% + 40%)")
def step_verify_allocation_echo(page: Page) -> None:
"""编辑抽屉回显:二次分配两行仍在;合计提示展示余额。"""
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
page.wait_for_timeout(600)
page.click("button:has-text('查询')")
page.wait_for_timeout(500)
first_row = page.locator(".el-table__row").first
if first_row.count() == 0:
fail("业务列表", "列表为空,未能找到刚创建的业务")
first_row.locator("button:has-text('编辑')").click()
dialog = visible_dialog(page)
page.wait_for_timeout(600)
# 二次分配表应有 2 行;合计提示:30+40=70,余额 30
alloc_table = page.locator(".el-dialog:visible .alloc-table")
alloc_count = alloc_table.locator("tbody tr").count()
if alloc_count != 2:
fail("方案回显", f"编辑抽屉二次分配应为 2 行,实际 {alloc_count}")
total_bar = page.locator(".el-dialog:visible .alloc-total")
body_text = total_bar.inner_text() if total_bar.count() else ""
if "余额" not in body_text:
fail("合计提示", f"两行非满额应展示余额提示,当前:{body_text or '(无提示)'}")
print(f"✓ 4. 编辑回显 二次分配 2 行保留,合计提示:{body_text.strip()}")
dialog.locator("button:has-text('取消')").click()
page.wait_for_timeout(400)
def _fetch_plan(token: str, customer_name: str) -> tuple[str, list[dict]]:
"""取回刚创建业务的编号与实际方案(接口取数,不从列表 DOM 刮编号)。
断言要以**业务实际存下的方案**为准:参与人下拉取的是人员列表的前两
项,未必是脚本刚建的 t_e2e 员,硬编码姓名会把断言写死成脆弱假设。
"""
rows = _api(
"GET",
f"/api/v1/business/businesses?keyword={quote(customer_name)}&pageSize=20",
token,
)["list"]
target = next((r for r in rows if r.get("customer_name") == customer_name), None)
if target is None:
raise E2EFailure(f"业务列表中未找到客户 {customer_name} 的业务")
detail = _api("GET", f"/api/v1/business/businesses/{target['id']}", token)
return target["business_no"], detail.get("secondary_allocations") or []
def step_verify_approval_snapshot(page: Page, business_no: str, plan: list[dict]) -> None:
"""审批详情:展示提交时的送审方案(参与人 + 比例 + 合计余额)。"""
page.goto(f"{BASE_URL}/system/approvals", wait_until="networkidle")
page.wait_for_timeout(800)
page.locator('.filters:visible input[placeholder="审批单号 / 业务编号"]').fill(business_no)
page.locator('.filters:visible button:has-text("查询")').first.click()
page.wait_for_timeout(800)
rows = page.locator(".el-table__body .el-table__row:visible")
if rows.count() == 0:
fail("审批内容", f"未找到业务 {business_no} 的审批单")
rows.first.locator("button:has-text('详情')").click()
page.wait_for_timeout(1000)
drawer = page.locator(".el-drawer:visible").last
drawer.wait_for(state="visible")
text = drawer.inner_text()
if "审批内容" not in text:
fail("审批内容", "审批详情未展示「审批内容」区块")
for row in plan:
if row["personnel_name"] not in text:
fail("审批内容", f"送审内容缺少参与人 {row['personnel_name']}")
if f"{row['ratio']}%" not in text:
fail("审批内容", f"送审内容缺少参与人 {row['personnel_name']} 的比例 {row['ratio']}%")
print(
f"✓ 5. 审批内容 审批详情展示送审方案({len(plan)} 名参与人及其比例)"
)
def step_cleanup(page: Page) -> None:
"""删除本次创建的业务与其客户(t_ 前缀,走界面删除)。"""
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
page.wait_for_timeout(600)
page.click("button:has-text('查询')")
page.wait_for_timeout(500)
target = page.locator(".el-table__row", has_text=f"t_e2e客户_{SUFFIX}").first
if target.count() == 0:
print(" (清理跳过:未找到本次业务)")
return
target.locator("button:has-text('删除')").click()
# 删除确认框按钮文案为「删除」(useConfirmAction confirmButtonText)
confirm_message_box(page, "删除")
page.wait_for_timeout(600)
print("✓ 6. 数据清理 已删除本次创建的 t_ 业务")
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_navigate_to_business(page, admin_password)
token = page.evaluate("localStorage.getItem('ys_token')")
if not token:
fail("API 预置", "localStorage 中未取到 ys_token")
# 显式关闭二次分配审批开关(结束还原默认 1):自动通过留痕,既不依赖
# 种子默认值,也让本次业务不留“在途审批单”——业务删除保护在途单,
# 开关开启时该业务在界面上删不掉
_set_allocation_switch(token, "0")
try:
customer_name = step_create_customer_via_ui(page)
try:
step_create_personnel(page)
step_create_business_with_allocation(page, customer_name)
business_no, plan = _fetch_plan(token, customer_name)
step_verify_allocation_echo(page)
step_verify_approval_snapshot(page, business_no, plan)
finally:
step_cleanup(page)
finally:
_set_allocation_switch(token, "1")
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)
# 弹窗较宽(业务表单含明细/二次分配两表),窄视口会把右侧操作按钮
# 挤出可视区、被 overlay 遮挡;给到 1600 宽避免遮挡导致点击超时
page = browser.new_page(viewport={"width": 1600, "height": 1000})
page.set_default_timeout(12000)
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())