"""二次分配方案录入端到端验证。 覆盖的链路(只有真实浏览器才能验证的组合): - 登录 → 业务管理 → 新增业务 → 在「二次分配方案」区添加两行参与人 并填比例 → 提交成功 - 详情/编辑回显:再次打开编辑抽屉,二次分配两行仍在(全量替换语义) - 合计提示:两行比例非满额时展示「余额…(归属待业务确认)」 - 数据清理:删除本次创建的业务(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())