审批详情的送审内容断言不再硬编码参与人姓名(下拉取人员列表前两项, 未必是脚本刚建的 t_e2e 员):改为经业务详情接口取回实际保存的方案, 逐参与人断言姓名与比例出现在审批抽屉中。顺带修掉原脚本清理环节的 隐性缺陷——开关开启时业务有在途审批单,界面删除被拦截而脚本无断言, 数据从未真正清理;现脚本内显式关闭审批开关并保证结束还原为 1。 Co-Authored-By: Claude Code <noreply@anthropic.com>
342 lines
15 KiB
Python
342 lines
15 KiB
Python
"""二次分配方案录入端到端验证。
|
||
|
||
覆盖的链路(只有真实浏览器才能验证的组合):
|
||
- 登录 → 业务管理 → 新增业务 → 在「二次分配方案」区添加两行参与人
|
||
并填比例 → 提交成功
|
||
- 详情/编辑回显:再次打开编辑抽屉,二次分配两行仍在(全量替换语义)
|
||
- 合计提示:两行比例非满额时展示「余额…(归属待业务确认)」
|
||
- 审批内容:审批详情展示**提交时**的送审方案(参与人 + 比例)
|
||
- 数据清理:删除本次创建的业务(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())
|