PayrollManagementSystem/frontend/e2e/business_data.py
liuwenxin 299b72483c test(business): M2 基础数据与业务数据两个 e2e 链路脚本(13+16 步)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 14:17:28 +08:00

613 lines
28 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.

"""业务数据全链路端到端验证:业务 / 发货 / 回款 / 状态机 / 评级计算器。
覆盖的链路(只有真实浏览器才能验证的组合):
- 基础数据准备(复用 `e2e/base_data.py` 建数):经理 / 业务员(挂经理)/
产品(轨道交通)/ 客户(企业户,归属=业务员)
- 业务:创建(两行明细:市政+轨交,数量/单价/成本)→ YW 业务编号与销售额
显示;编辑明细数量 → 汇总重算(销售额变化)
- 详情抽屉:产品明细回显、状态时间线「业务创建」首条、毛利润类别汇总卡片
与「不含运费」口径注释
- 归属调整(业务员 → 经理):二次确认含「不回溯历史」(对照表 #19)、
详情留痕表可见旧 → 新
- 发货管理:挂执行中业务创建发货单(日期/运费/明细行)→ SH 单号与发货总额
- 回款管理:选业务后级联加载其发货单(断言下拉出现刚建发货单号)→ 指定
批次 → 回款天数派生显示「6 天」(发货日计第 1 天);第二笔不指定批次 →
显示「未指定批次」
- 状态机:执行中 → 已完成(详情时间线新增记录)→ 终态业务新发货被 400
拦截(「禁止新发货」)
- 客户评级计算器:五项各 20 分 → 总分 100 → 等级 A;等级映射抽屉 A/B/C/D
- 控制台无 JS 错误;清理按 回款 → 发货 → 业务 → 客户 → 产品 → 人员 依赖序
**运行前置**(缺一不可)::
docker compose -f docker/docker-compose.dev.yml up -d # MySQL + Redis
cd backend && uv run uvicorn app.main:app --reload --port 8000 # 后端
cd frontend && pnpm dev # 前端
管理员密码经环境变量传入(不写死在源码里)::
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/business_data.py
脚本自建的测试数据(`e2e_` 前缀)在流程内清理;删除保护拦截属预期行为,
被拦截时打印留档说明。页面辅助与基础数据建数复用 `e2e/base_data.py`。
"""
import os
import re
import sys
import time
sys.path.insert(0, os.path.dirname(__file__))
from playwright.sync_api import Page, sync_playwright
from common import (
E2EFailure,
collect_console_errors,
current_path,
expect_toast,
fail,
login,
wait_toast,
)
import base_data as base
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
ADMIN = "admin"
# 明细两行:市政 10×5000(成本 3000)+ 轨交 5×3000(成本 2000)
ITEM_NAME_MUNICIPAL = f"e2e_市政产品_{base.SUFFIX}"
BUSINESS_DATE = "2026-01-05"
SHIP_DATE = "2026-01-10"
RECEIPT_DATE = "2026-01-15"
INIT_SALE_AMOUNT = "65000" # 10×5000 + 5×3000
EDITED_SALE_AMOUNT = "115000" # 编辑后 20×5000 + 5×3000
SHIP_FREIGHT = "100"
SHIP_ITEM_QUANTITY = "6"
SHIP_ITEM_PRICE = "4800"
SHIP_TOTAL = "28800" # 6×4800
RECEIPT_AMOUNT_1 = "20000"
RECEIPT_AMOUNT_2 = "5000"
RECEIPT_DAYS = "6 天" # 回款日相对发货日 2026-01-10(发货日计第 1 天)
# 控制台错误收集(失败诊断):模块级列表,main() 的失败路径也要读取
console_errors: list[str] = []
# 运行时提取的单据编号(新增业务/发货单后从列表文本提取,级联与清理用)
business_no = ""
shipment_no = ""
# ==================== 流程步骤 ====================
def step_setup_base(page: Page, admin_password: str) -> None:
"""登录并复用基础数据建数:经理 / 业务员(挂经理)/ 产品 / 客户(归属=业务员)。"""
login(page, ADMIN, admin_password)
base.create_manager_via_ui(page, base.MANAGER_NAME)
print(f"✓ 1. 建经理 {base.MANAGER_NAME}(岗位=营销经理)")
base.create_salesman_via_ui(page, base.SALESMAN_NAME, base.MANAGER_NAME)
print(f"✓ 2. 建业务员 {base.SALESMAN_NAME}(岗位=营销员,挂经理)")
base.create_product_via_ui(page, base.PRODUCT_NAME, base.PRODUCT_SPEC)
print(f"✓ 3. 建产品 {base.PRODUCT_NAME}(类别=轨道交通)")
base.create_customer_via_ui(
page, base.CUSTOMER_COMPANY, base.CUSTOMER_OWNER, base.SALESMAN_NAME
)
print(f"✓ 4. 建客户 {base.CUSTOMER_COMPANY}(企业户,归属=业务员)")
def fill_item_row(row, name: str, quantity: str, price: str, cost: str) -> None:
"""按 placeholder 填写业务明细行:名称 / 数量 / 销售单价 / 成本额。
「元,选填」的 placeholder 在单价/成本/指导价三列重复,按列序 nth 取
(单价在前、成本居次),指导价不填。
"""
row.locator('input[placeholder="产品名称,可手填"]').fill(name)
row.locator('input[placeholder=">0"]').fill(quantity)
row.locator('input[placeholder="元,选填"]').nth(0).fill(price)
row.locator('input[placeholder="元,选填"]').nth(1).fill(cost)
def select_row_option(page: Page, row, option: str) -> None:
"""点开表格行内的下拉(明细行「类别」列,非 form-item 结构)并选择选项。"""
row.locator(".el-select").first.click()
option_item = page.locator(
".el-select-dropdown:visible .el-select-dropdown__item", has_text=option
)
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if option_item.count():
break
page.wait_for_timeout(200)
if option_item.count() == 0:
fail("下拉选择", f"行内下拉未出现选项「{option}」")
option_item.first.click()
page.wait_for_timeout(200)
def step_business_create(page: Page) -> None:
"""创建业务:选客户、两行明细(市政+轨交)、负责人员=业务员。"""
global business_no
base.open_menu(page, "业务管理", "业务数据")
if current_path(page) != "/business/businesses":
fail("菜单导航", f"未进入业务管理页,当前 {page.url}")
page.click("button:has-text('新增业务')")
page.wait_for_timeout(500)
dialog = base.visible_dialog(page)
base.select_form_option(page, dialog, "客户", base.CUSTOMER_COMPANY)
base.fill_date(page, dialog, "业务日期", BUSINESS_DATE)
base.select_form_option(page, dialog, "负责人员", base.SALESMAN_NAME)
item_rows = dialog.locator(".items-table .el-table__row")
fill_item_row(item_rows.nth(0), ITEM_NAME_MUNICIPAL, "10", "5000", "3000")
dialog.locator("button:has-text('添加一行')").click()
page.wait_for_timeout(300)
item_rows = dialog.locator(".items-table .el-table__row")
select_row_option(page, item_rows.nth(1), "轨道交通")
fill_item_row(item_rows.nth(1), base.PRODUCT_NAME, "5", "3000", "2000")
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "业务已创建", "新增业务")
row = base.find_row(page, "关键词", base.CUSTOMER_COMPANY)
if row.count() == 0:
fail("新增业务", f"列表中未找到新建业务(客户 {base.CUSTOMER_COMPANY})")
text = row.inner_text()
no_match = re.search(r"YW\d{8}-\d+", text)
if not no_match:
fail("新增业务", f"列表未显示 YW 前缀业务编号:{text[:120]}")
business_no = no_match.group(0)
if INIT_SALE_AMOUNT not in text:
fail("新增业务", f"列表销售额未显示 {INIT_SALE_AMOUNT}:{text[:160]}")
print(f"✓ 5. 新增业务 两行明细创建成功,业务编号 {business_no},销售额 {INIT_SALE_AMOUNT}")
def step_business_edit(page: Page) -> None:
"""编辑业务:第一行数量 10 → 20 → 保存 → 列表销售额汇总重算。"""
row = base.find_row(page, "关键词", business_no)
row.locator("button:has-text('编辑')").click()
# openEdit 异步拉详情后全量替换明细草稿(此时预置草稿仍显示旧值),
# 固定等待让「回显替换」先完成,避免填到将被丢弃的草稿上
page.wait_for_timeout(1500)
dialog = base.visible_dialog(page)
item_rows = dialog.locator(".items-table .el-table__row")
if item_rows.count() < 2:
fail("编辑业务", "编辑弹窗未回显两行明细")
item_rows.nth(0).locator('input[placeholder=">0"]').fill("20")
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "已保存", "编辑业务")
row = base.find_row(page, "关键词", business_no)
if EDITED_SALE_AMOUNT not in row.inner_text():
fail("编辑汇总重算", f"保存后销售额未更新为 {EDITED_SALE_AMOUNT}:{row.inner_text()[:160]}")
print(f"✓ 6. 编辑重算 明细数量 10→20,销售额 {INIT_SALE_AMOUNT} → {EDITED_SALE_AMOUNT}")
def step_business_detail(page: Page) -> None:
"""详情抽屉:明细表回显、时间线「业务创建」首条、毛利润类别汇总与口径注释。"""
row = base.find_row(page, "关键词", business_no)
row.locator("button:has-text('详情')").click()
page.wait_for_timeout(1000)
drawer = page.locator(".el-drawer:visible").last
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if drawer.locator(".detail-desc").count():
break
page.wait_for_timeout(200)
detail_text = drawer.inner_text()
if ITEM_NAME_MUNICIPAL not in detail_text or base.PRODUCT_NAME not in detail_text:
fail("业务详情", "详情抽屉产品明细未回显两行产品")
timeline = drawer.locator(".status-timeline .el-timeline-item")
if timeline.count() == 0:
fail("业务详情", "状态时间线为空")
if "业务创建" not in (timeline.first.inner_text() or ""):
fail("业务详情", "时间线首条不是「业务创建」")
if (
drawer.locator(".gp-card", has_text="市政水利").count() == 0
or drawer.locator(".gp-card", has_text="轨道交通").count() == 0
):
fail("毛利润视图", "类别汇总卡片未展示市政水利/轨道交通")
note = drawer.locator(".gp-note").inner_text()
if "不含运费" not in note:
fail("毛利润注释", f"口径注释缺少「不含运费」:{note[:120]}")
page.keyboard.press("Escape")
page.wait_for_timeout(600)
print("✓ 7. 详情抽屉 明细回显、时间线「业务创建」首条、毛利润汇总与口径注释")
def step_business_ownership(page: Page) -> None:
"""归属调整(业务员 → 经理):确认含「不回溯历史」→ 详情留痕表可见。"""
row = base.find_row(page, "关键词", business_no)
row.locator("button:has-text('归属')").click()
page.wait_for_timeout(500)
dialog = base.visible_dialog(page)
if business_no not in dialog.inner_text():
fail("归属调整", "归属调整弹窗未回显业务编号")
base.select_form_option(page, dialog, "新负责人员", base.MANAGER_NAME)
dialog.locator("button:has-text('提交调整')").click()
page.wait_for_timeout(500)
box = base.confirm_text(page)
if "不回溯历史" not in box:
fail("归属调整确认", f"二次确认文案缺少「不回溯历史」:{box[:120]}")
base.confirm_message_box(page, "确认调整")
expect_toast(page, "归属已调整", "业务归属调整")
row = base.find_row(page, "关键词", business_no)
if row.count() == 0:
fail("业务归属调整", "按关键词查询未找到业务行")
if base.MANAGER_NAME not in row.inner_text():
fail("业务归属调整", "列表「负责人员」列未更新为经理")
row.locator("button:has-text('详情')").click()
page.wait_for_timeout(1000)
drawer = page.locator(".el-drawer:visible").last
transition = f"负责 {base.SALESMAN_NAME} → {base.MANAGER_NAME}"
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if drawer.locator(".el-table__row", has_text=transition).count():
break
page.wait_for_timeout(200)
if drawer.locator(".el-table__row", has_text=transition).count() == 0:
fail("归属留痕", f"详情抽屉归属变更记录未看到「{transition}」")
page.keyboard.press("Escape")
page.wait_for_timeout(600)
print("✓ 8. 归属调整 业务员→经理确认含「不回溯历史」,详情留痕表可见")
def step_shipment_create(page: Page) -> None:
"""发货管理:挂执行中业务创建发货单(日期/运费/明细行)→ SH 单号。"""
global shipment_no
base.open_menu(page, "发货管理", "业务数据")
if current_path(page) != "/business/shipments":
fail("菜单导航", f"未进入发货管理页,当前 {page.url}")
page.click("button:has-text('新增发货单')")
page.wait_for_timeout(500)
dialog = base.visible_dialog(page)
base.select_form_option(page, dialog, "业务", business_no)
base.fill_date(page, dialog, "发货日期", SHIP_DATE)
base.fill_form_item(page, dialog, "运费(元)", SHIP_FREIGHT)
item_row = dialog.locator(".items-table .el-table__row").first
item_row.locator('input[placeholder="产品名称,可手填"]').fill(base.PRODUCT_NAME)
item_row.locator('input[placeholder=">0"]').fill(SHIP_ITEM_QUANTITY)
item_row.locator('input[placeholder="可空=待人工补价"]').fill(SHIP_ITEM_PRICE)
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
page.wait_for_timeout(500)
base.confirm_message_box(page, "确认创建")
expect_toast(page, "发货单已创建", "新增发货单")
base.select_filter_option(page, "业务", business_no)
base.click_query(page)
ship_row = base.table_rows(page).filter(has_text=business_no).first
if ship_row.count() == 0:
fail("新增发货单", "发货列表未显示该业务的发货单")
ship_text = ship_row.inner_text()
no_match = re.search(r"SH\d{8}-\d+", ship_text)
if not no_match:
fail("新增发货单", f"发货单号未显示 SH 前缀:{ship_text[:120]}")
shipment_no = no_match.group(0)
if SHIP_TOTAL not in ship_text:
fail("新增发货单", f"发货总额未显示 {SHIP_TOTAL}:{ship_text[:160]}")
print(f"✓ 9. 发货单 挂业务创建成功,发货单号 {shipment_no},发货总额 {SHIP_TOTAL}")
def step_receipt_with_shipment(page: Page) -> None:
"""第一笔回款:选业务 → 级联加载发货单并断言下拉出现刚建单号 → 天数派生。"""
base.open_menu(page, "回款管理", "业务数据")
if current_path(page) != "/business/receipts":
fail("菜单导航", f"未进入回款管理页,当前 {page.url}")
page.click("button:has-text('新增回款')")
page.wait_for_timeout(500)
dialog = base.visible_dialog(page)
base.select_form_option(page, dialog, "业务", business_no)
page.wait_for_timeout(600) # 等发货批次级联加载完成
# 级联断言:发货批次下拉必须出现刚建的发货单号
batch_item = dialog.locator('.el-form-item:has(.el-form-item__label:text-is("发货批次"))')
batch_item.locator(".el-select").first.click()
batch_option = page.locator(
".el-select-dropdown:visible .el-select-dropdown__item", has_text=shipment_no
)
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if batch_option.count():
break
page.wait_for_timeout(200)
if batch_option.count() == 0:
fail("回款级联", f"发货批次下拉未级联出刚建的发货单 {shipment_no}")
batch_option.first.click()
page.wait_for_timeout(200)
base.fill_date(page, dialog, "回款到账日", RECEIPT_DATE)
base.fill_form_item(page, dialog, "金额(元)", RECEIPT_AMOUNT_1)
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "回款已登记", "新增回款")
base.select_filter_option(page, "业务", business_no)
base.click_query(page)
rows_text = " ".join(base.table_rows(page).all_inner_texts())
if RECEIPT_DAYS not in rows_text:
fail("回款天数派生", f"指定批次后回款天数未显示「{RECEIPT_DAYS}」:{rows_text[:200]}")
if "未指定批次" in rows_text:
fail("回款天数派生", "指定批次后不应显示「未指定批次」")
print(f"✓ 10. 回款第一笔 级联出 {shipment_no} 并指定,回款天数显示「{RECEIPT_DAYS}」")
def step_receipt_without_shipment(page: Page) -> None:
"""第二笔回款:不指定批次 → 列表显示「未指定批次」,第一笔天数不消失。"""
page.click("button:has-text('新增回款')")
page.wait_for_timeout(500)
dialog = base.visible_dialog(page)
base.select_form_option(page, dialog, "业务", business_no)
page.wait_for_timeout(600)
base.fill_date(page, dialog, "回款到账日", RECEIPT_DATE)
base.fill_form_item(page, dialog, "金额(元)", RECEIPT_AMOUNT_2)
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "回款已登记", "新增第二笔回款")
base.click_query(page)
rows_text = " ".join(base.table_rows(page).all_inner_texts())
if "未指定批次" not in rows_text:
fail("未指定批次", "第二笔回款(未指定批次)未显示「未指定批次」")
if RECEIPT_DAYS not in rows_text:
fail("未指定批次", f"第一笔回款的「{RECEIPT_DAYS}」不应消失")
print("✓ 11. 回款第二笔 不指定批次显示「未指定批次」,第一笔天数保留")
def step_status_flow(page: Page) -> None:
"""状态机:执行中 → 已完成(详情时间线新增记录)。"""
base.open_menu(page, "业务管理", "业务数据")
row = base.find_row(page, "关键词", business_no)
if "执行中" not in row.inner_text():
fail("状态流转", f"业务当前状态不是执行中:{row.inner_text()[:120]}")
row.locator("button:has-text('状态')").click()
page.wait_for_timeout(500)
dialog = base.visible_dialog(page)
# 目标状态默认「已完成」(openStatus 预设 status=2),直接确认
dialog.locator("button:has-text('确认变更')").click()
page.wait_for_timeout(500)
box = base.confirm_text(page)
if "终态不可再变更" not in box:
fail("状态流转确认", f"二次确认文案缺少「终态不可再变更」:{box[:120]}")
base.confirm_message_box(page, "已完成")
expect_toast(page, "状态已变更", "状态流转")
row = base.find_row(page, "关键词", business_no)
if row.count() == 0:
fail("状态流转", "按关键词查询未找到业务行")
if "已完成" not in row.inner_text():
fail("状态流转", "列表状态未更新为已完成")
row.locator("button:has-text('详情')").click()
page.wait_for_timeout(1000)
drawer = page.locator(".el-drawer:visible").last
timeline = drawer.locator(".status-timeline .el-timeline-item")
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if timeline.count() >= 2:
break
page.wait_for_timeout(200)
if timeline.count() < 2:
fail("状态时间线", f"流转后时间线未新增记录({timeline.count()} 条)")
if "已完成" not in (timeline.last.inner_text() or ""):
fail("状态时间线", "时间线末条未显示已完成状态")
if "业务创建" not in (timeline.first.inner_text() or ""):
fail("状态时间线", "时间线首条「业务创建」不应消失")
page.keyboard.press("Escape")
page.wait_for_timeout(600)
print("✓ 12. 状态流转 执行中→已完成,详情时间线新增一条记录")
def step_terminal_shipment_blocked(page: Page) -> None:
"""终态业务(已完成)再建发货单被 400 拦截(「禁止新发货」)。"""
base.open_menu(page, "发货管理", "业务数据")
page.click("button:has-text('新增发货单')")
page.wait_for_timeout(500)
dialog = base.visible_dialog(page)
base.select_form_option(page, dialog, "业务", business_no)
base.fill_date(page, dialog, "发货日期", SHIP_DATE)
item_row = dialog.locator(".items-table .el-table__row").first
item_row.locator('input[placeholder="产品名称,可手填"]').fill(base.PRODUCT_NAME)
item_row.locator('input[placeholder=">0"]').fill("1")
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
page.wait_for_timeout(500)
base.confirm_message_box(page, "确认创建")
expect_toast(page, "禁止新发货", "终态业务发货拦截")
base.visible_dialog(page).locator(".el-dialog__footer button:has-text('取消')").click()
page.wait_for_timeout(500)
print("✓ 13. 终态拦截 已完成业务新发货被 400 拒绝(禁止新发货)")
def step_rating(page: Page) -> None:
"""客户评级计算器:五项各 20 → 总分 100 → 等级 A;映射抽屉 A/B/C/D。"""
base.open_menu(page, "客户评级计算器", "业务数据")
if current_path(page) != "/business/rating":
fail("菜单导航", f"未进入客户评级计算器页,当前 {page.url}")
for label in ("发货额", "回款率", "毛利率", "合同履约配合能力", "现场规范施工能力"):
score_row = page.locator(".calc-form .score-row", has_text=label).first
score_row.locator(".el-input-number input").fill("20")
score_row.locator(".el-input-number input").press("Enter")
page.wait_for_timeout(200)
total = (page.locator(".total-num").first.text_content() or "").strip()
if total != "100":
fail("评级总分", f"五项各 20 分后总分未显示 100:{total or '(无)'}")
grade = (page.locator(".grade-name").first.text_content() or "").strip()
if grade != "A":
fail("评级等级", f"总分 100 未命中等级 A:{grade or '(无)'}")
page.click("button:has-text('等级映射设置')")
page.wait_for_timeout(600)
drawer = page.locator(".el-drawer").filter(has_text="等级映射设置").last
table_text = drawer.inner_text()
for letter in ("A", "B", "C", "D"):
if letter not in table_text:
fail("等级映射抽屉", f"映射抽屉表格缺少等级 {letter}")
page.keyboard.press("Escape")
page.wait_for_timeout(500)
print("✓ 14. 评级计算器 五项满 20 → 总分 100 → 等级 A;映射抽屉 A/B/C/D")
def step_cleanup(page: Page) -> None:
"""清理:按 回款 → 发货 → 业务 → 客户 → 产品 → 人员 依赖序删除。
删除保护拦截属预期行为:被拦截时打印留档说明,不做假绿也不误伤主流程。
"""
# 回款(未核算可删;删除后业务回款合计自动回退)
base.open_menu(page, "回款管理", "业务数据")
base.select_filter_option(page, "业务", business_no)
base.click_query(page)
while base.table_rows(page).count() > 0:
base.table_rows(page).first.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
base.confirm_message_box(page, "删除")
expect_toast(page, "回款已删除", "数据清理")
page.wait_for_timeout(600)
base.click_query(page)
# 发货单(回款已删,无批次指定;删除连带明细并回写业务汇总)
base.open_menu(page, "发货管理", "业务数据")
base.select_filter_option(page, "业务", business_no)
base.click_query(page)
while base.table_rows(page).count() > 0:
base.table_rows(page).first.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
base.confirm_message_box(page, "删除")
text = wait_toast(page, "已删除")
if "发货单已删除" not in text:
print(" 数据清理 发货单删除被拦(属预期保护),留档说明:发货单保留在开发库")
break
page.wait_for_timeout(600)
base.click_query(page)
# 业务(发货/回款均已删则可删;仍被拦时留档)
base.open_menu(page, "业务管理", "业务数据")
row = base.find_row(page, "关键词", business_no)
if row.count() > 0:
row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
base.confirm_message_box(page, "删除")
text = wait_toast(page, "已删除")
if "业务已删除" in text:
print(f" 数据清理 业务 {business_no} 已删除")
else:
print(
f" 数据清理 业务删除被拦(属预期保护),留档说明:业务 {business_no} 保留在开发库"
)
# 基础数据:客户 → 产品 → 业务员 → 经理
base.open_menu(page, "客户管理", "基础数据")
row = base.find_row(page, "关键词", base.CUSTOMER_COMPANY)
if row.count() > 0:
base.delete_row(page, row, "客户已删除")
base.open_menu(page, "产品管理", "基础数据")
row = base.find_row(page, "关键词", base.PRODUCT_NAME)
if row.count() > 0:
base.delete_row(page, row, "产品已删除")
base.open_menu(page, "人员管理", "基础数据")
row = base.find_row(page, "姓名", base.SALESMAN_NAME)
if row.count() > 0:
base.delete_row(page, row, "人员已删除")
row = base.find_row(page, "姓名", base.MANAGER_NAME)
if row.count() > 0:
row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
base.confirm_message_box(page, "删除")
text = wait_toast(page, "已删除")
if "人员已删除" in text:
print(" 数据清理 经理已删除")
elif "请改为离职" in text or "存在团队成员" in text:
print(" 数据清理 经理仍被引用(属预期保护),留档说明:保留经理")
else:
fail("数据清理", f"清理经理出现未预期提示:{text or '(无)'}")
print("✓ 15. 数据清理 回款/发货/业务与基础数据按依赖序清理,被保护项留档")
def step_console_assertion() -> None:
"""断言无 JS 控制台错误(口径同 base_data.py:只拦脚本错误)。"""
real_errors = [e for e in console_errors if "Failed to load resource" not in e]
if real_errors:
fail("控制台检查", f"发现 {len(real_errors)} 条 JS 错误:{real_errors[:3]}")
print("✓ 16. 控制台检查 无 JS 控制台错误")
# ==================== 入口 ====================
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 密码),"
"示例:E2E_ADMIN_PASSWORD=xxx python e2e/business_data.py",
)
step_setup_base(page, admin_password)
step_business_create(page)
step_business_edit(page)
step_business_detail(page)
step_business_ownership(page)
step_shipment_create(page)
step_receipt_with_shipment(page)
step_receipt_without_shipment(page)
step_status_flow(page)
step_terminal_shipment_blocked(page)
step_rating(page)
step_cleanup(page)
step_console_assertion()
if console_errors:
print(f" (网络资源日志 {len(console_errors)} 条,属 4xx 请求的预期噪声)")
def main() -> int:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1440, "height": 860})
page.set_default_timeout(10_000)
try:
run(page)
except E2EFailure as exc:
page.screenshot(path="e2e/failure.png", full_page=True)
print(f"\n ✗ {exc}")
if console_errors:
print(f" 控制台错误:{console_errors[-5:]}")
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}")
if console_errors:
print(f" 控制台错误:{console_errors[-5:]}")
print(" 已保存截图 e2e/failure.png")
return 1
finally:
browser.close()
print("\n全部通过。")
return 0
if __name__ == "__main__":
raise SystemExit(main())