PayrollManagementSystem/frontend/e2e/secondary_allocation_flow.py
liuwenxin fc8a42bcd9 test(m3): 集成/e2e 增补三模块用例并更新测试记录
- 新增 e2e:settings_flow(改名→登出→登录页即时生效→finally 还原防污染)、
  secondary_allocation_flow(建客户/员→录两行 30%+40%→编辑回显与合计条
  余额提示→删除清理;宽视口防弹窗遮挡、alloc-table 专属类防选择器错位)
- 修复 e2e 首跑抓出的两处缺陷:① public 端点 300s 响应缓存让登录页拿旧
  公司名(登录页改时间戳 fetch + cache:no-store,手动解包信封)② logo 同
  扩展名覆盖时先写后删(清理移到写入前且仅异扩展名执行)
- 分配表加 alloc-table 专属类(与产品明细 items-table 区分)
- 全量回归 697 passed(基线 625 + 72);e2e 七脚本全过(既有五脚本回归 +
  两个新增);pnpm type-check 零错误
- tests/README.md 增 M3 节(44 例明细)/运行记录/缺陷 3 行;frontend/
  README.md 增 M3 e2e 节

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 12:01:52 +08:00

235 lines
10 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.

"""二次分配方案录入端到端验证。
覆盖的链路(只有真实浏览器才能验证的组合):
- 登录 → 业务管理 → 新增业务 → 在「二次分配方案」区添加两行参与人
并填比例 → 提交成功
- 详情/编辑回显:再次打开编辑抽屉,二次分配两行仍在(全量替换语义)
- 合计提示:两行比例非满额时展示「余额…(归属待业务确认)」
- 数据清理:删除本次创建的业务(t_ 前缀客户/业务,走界面删除)
运行前置(缺一不可):
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 os
import time
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"
SUFFIX = f"{int(time.time())}"
console_errors: list[str] = []
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:
# 客户弹窗已关(上一段已关闭);直接路由直达业务管理,避免菜单点击
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 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("✓ 5. 数据清理 已删除本次创建的 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)
customer_name = step_create_customer_via_ui(page)
try:
step_create_personnel(page)
step_create_business_with_allocation(page, customer_name)
step_verify_allocation_echo(page)
finally:
step_cleanup(page)
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())