feat/m3-increments #1
567
frontend/e2e/base_data.py
Normal file
567
frontend/e2e/base_data.py
Normal file
@ -0,0 +1,567 @@
|
||||
"""基础数据端到端验证:人员 / 产品 / 客户。
|
||||
|
||||
覆盖的链路(只有真实浏览器才能验证的组合):
|
||||
|
||||
- 侧边菜单「基础数据」分组展开与三页导航
|
||||
- 人员:新增(营销经理 / 挂经理的营销员)、直属经理名回显、辐射区域
|
||||
编辑抽屉行内添加保存并重开回显
|
||||
- 人员删除保护:被团队成员引用 → 400 引导「改为离职」,改为离职成功
|
||||
- 产品:新增(轨道交通类别)、同名称同规格重复 → 400
|
||||
- 客户:新增(企业户 + 初始归属业务员)、归属调整二次确认文案含
|
||||
「不回溯历史」(对照表 #19 业务口径)、变更留痕展示旧 → 新归属
|
||||
- 控制台无 JS 错误(4xx 请求的网络资源日志属预期,见 step_console_assertion)
|
||||
|
||||
**运行前置**(缺一不可)::
|
||||
|
||||
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/base_data.py
|
||||
|
||||
脚本自建的测试数据(`e2e_` 前缀)在流程内清理;被引用保护拦截的清理项
|
||||
打印留档说明而非失败(删除保护本身是预期行为)。公共工具见 `e2e/common.py`。
|
||||
"""
|
||||
|
||||
import os
|
||||
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,
|
||||
toast_text,
|
||||
wait_toast,
|
||||
)
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
ADMIN = "admin"
|
||||
|
||||
SUFFIX = str(int(time.time()))
|
||||
MANAGER_NAME = f"e2e_经理_{SUFFIX}"
|
||||
SALESMAN_NAME = f"e2e_业务员_{SUFFIX}"
|
||||
MANAGER_PHONE = "13800000001"
|
||||
MANAGER_SALARY = "5000"
|
||||
SALESMAN_SALARY = "4000"
|
||||
PRODUCT_NAME = f"e2e_产品_{SUFFIX}"
|
||||
PRODUCT_SPEC = f"e2e_规格_{SUFFIX}"
|
||||
CUSTOMER_COMPANY = f"e2e_企业_{SUFFIX}"
|
||||
CUSTOMER_OWNER = "e2e_客户负责人"
|
||||
REGION_NAME = "沈阳市铁西区"
|
||||
|
||||
# 控制台错误收集(失败诊断):模块级列表,main() 的失败路径也要读取
|
||||
console_errors: list[str] = []
|
||||
|
||||
|
||||
# ==================== 页面操作辅助 ====================
|
||||
|
||||
|
||||
def open_menu(page: Page, title: str, group: str) -> None:
|
||||
"""点击侧边菜单项,必要时先展开指定分组。
|
||||
|
||||
分组(el-sub-menu)已展开时再次点击会收拢,因此先判断菜单项是否可见。
|
||||
"""
|
||||
menu = page.locator(".app-menu")
|
||||
item = menu.locator(".el-menu-item", has_text=title).first
|
||||
if not item.is_visible():
|
||||
menu.locator(".el-sub-menu__title", has_text=group).click()
|
||||
page.wait_for_timeout(400)
|
||||
item.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def fill_filter(page: Page, label: str, value: str) -> None:
|
||||
"""在**当前可见**的筛选表单中按标签填写输入框。
|
||||
|
||||
选择器一律带 `:visible` —— 同页存在多个表单实例(隐藏未销毁)时,
|
||||
不带可见性过滤会命中隐藏元素。
|
||||
"""
|
||||
form_item = page.locator(
|
||||
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
|
||||
).first
|
||||
form_item.locator("input").first.fill(value)
|
||||
|
||||
|
||||
def select_filter_option(page: Page, label: str, option: str) -> None:
|
||||
"""展开可见筛选表单中指定标签的下拉并选择选项(发货/回款页按业务筛选)。"""
|
||||
item = page.locator(
|
||||
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
|
||||
)
|
||||
item.locator(".el-select").click()
|
||||
page.wait_for_timeout(300)
|
||||
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=option).first.click()
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
|
||||
def click_query(page: Page) -> None:
|
||||
"""点击可见筛选表单中的查询按钮。"""
|
||||
page.locator(".filter-form:visible button:has-text('查询')").first.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def table_rows(page: Page):
|
||||
"""当前可见的表格行(选择器带 :visible,避开隐藏实例)。"""
|
||||
return page.locator(".el-table__body .el-table__row:visible")
|
||||
|
||||
|
||||
def visible_dialog(page: Page):
|
||||
"""当前可见的对话框(同页可能存在多个 dialog 实例,按可见性取最后一个)。"""
|
||||
return page.locator(".el-dialog:visible").last
|
||||
|
||||
|
||||
def confirm_message_box(page: Page, button_text: str) -> None:
|
||||
"""点击确认框(ElMessageBox)中的指定按钮。"""
|
||||
page.locator(".el-message-box:visible").locator(f"button:has-text('{button_text}')").click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def confirm_text(page: Page) -> str:
|
||||
"""当前确认框的完整文本(业务口径文案断言用),轮询等待出现。"""
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
if page.locator(".el-message-box:visible").count():
|
||||
break
|
||||
page.wait_for_timeout(200)
|
||||
return page.locator(".el-message-box:visible").last.inner_text()
|
||||
|
||||
|
||||
def fill_form_item(page: Page, scope, label: str, value: str) -> None:
|
||||
"""在表单(dialog / drawer)范围内按 form-item 标签填写输入框。
|
||||
|
||||
标签用 `:text-is` 精确匹配(子串会让「姓名」命中「真实姓名」一类歧义);
|
||||
只用于真实 el-input 的表单项(el-select 的表单项走 select_form_option)。
|
||||
"""
|
||||
item = scope.locator(f'.el-form-item:has(.el-form-item__label:text-is("{label}"))')
|
||||
item.locator("input").first.fill(value)
|
||||
|
||||
|
||||
def fill_date(page: Page, scope, label: str, value: str) -> None:
|
||||
"""el-date-picker(type=date)按 form-item 标签填日期:点输入框 → 键入 → 回车。"""
|
||||
item = scope.locator(f'.el-form-item:has(.el-form-item__label:text-is("{label}"))')
|
||||
item.locator("input").first.click()
|
||||
page.keyboard.type(value)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
|
||||
def select_form_option(page: Page, scope, label: str, option: str) -> None:
|
||||
"""在表单范围内展开指定标签的下拉并选择选项。
|
||||
|
||||
下拉面板 teleport 到 body,展开与选项均按 `:visible` 过滤;选项渲染
|
||||
(filterable / 页面挂载时拉取的数据源)可能滞后,轮询等待再点击。
|
||||
"""
|
||||
item = scope.locator(f'.el-form-item:has(.el-form-item__label:text-is("{label}"))')
|
||||
item.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"下拉「{label}」中未出现选项「{option}」")
|
||||
option_item.first.click()
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
|
||||
def refresh_page_data(page: Page) -> None:
|
||||
"""刷新当前页面(保持路径):重新挂载页面并重拉挂载时的一次性数据源。"""
|
||||
page.reload(wait_until="networkidle")
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
|
||||
def find_row(page: Page, filter_label: str, keyword: str):
|
||||
"""按筛选条件查询并返回含 keyword 的第一行(找不到时 count() 为 0)。"""
|
||||
fill_filter(page, filter_label, keyword)
|
||||
click_query(page)
|
||||
return table_rows(page).filter(has_text=keyword).first
|
||||
|
||||
|
||||
def delete_row(page: Page, row, success_toast: str) -> None:
|
||||
"""删除行:点「删除」→ 确认「删除」→ 断言成功提示(各模块成功文案不同)。"""
|
||||
row.locator("button:has-text('删除')").click()
|
||||
page.wait_for_timeout(400)
|
||||
confirm_message_box(page, "删除")
|
||||
expect_toast(page, success_toast, "数据清理")
|
||||
page.wait_for_timeout(600)
|
||||
|
||||
|
||||
# ==================== 基础数据创建(本脚本与 business_data 复用) ====================
|
||||
|
||||
|
||||
def create_manager_via_ui(page: Page, name: str) -> None:
|
||||
"""导航到人员管理并经界面新增营销经理 → 列表岗位回显。"""
|
||||
open_menu(page, "人员管理", "基础数据")
|
||||
if current_path(page) != "/base/personnel":
|
||||
fail("菜单导航", f"未进入人员管理页,当前 {page.url}")
|
||||
|
||||
page.click("button:has-text('新增人员')")
|
||||
page.wait_for_timeout(500)
|
||||
dialog = visible_dialog(page)
|
||||
fill_form_item(page, dialog, "姓名", name)
|
||||
select_form_option(page, dialog, "岗位", "营销经理")
|
||||
fill_form_item(page, dialog, "联系电话", MANAGER_PHONE)
|
||||
fill_form_item(page, dialog, "工资标准", MANAGER_SALARY)
|
||||
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
|
||||
expect_toast(page, "人员已创建", "新增人员")
|
||||
|
||||
row = find_row(page, "姓名", name)
|
||||
if row.count() == 0:
|
||||
fail("新增人员", f"列表中未找到新建的 {name}")
|
||||
if "营销经理" not in row.inner_text():
|
||||
fail("新增人员", "列表岗位列未回显「营销经理」")
|
||||
page.wait_for_timeout(400)
|
||||
|
||||
|
||||
def create_salesman_via_ui(page: Page, name: str, manager_name: str) -> None:
|
||||
"""在人员管理页新增营销员并挂直属经理(先刷新页面重拉经理下拉)。
|
||||
|
||||
经理下拉数据源只在页面挂载时拉取一次 —— 同一页面连续「新建经理再
|
||||
新建业务员」时下拉不含新经理,必须先刷新页面。
|
||||
"""
|
||||
open_menu(page, "人员管理", "基础数据")
|
||||
refresh_page_data(page)
|
||||
if current_path(page) != "/base/personnel":
|
||||
fail("菜单导航", f"刷新后未回到人员管理页,当前 {page.url}")
|
||||
|
||||
page.click("button:has-text('新增人员')")
|
||||
page.wait_for_timeout(500)
|
||||
dialog = visible_dialog(page)
|
||||
fill_form_item(page, dialog, "姓名", name)
|
||||
select_form_option(page, dialog, "岗位", "营销员")
|
||||
select_form_option(page, dialog, "直属经理", manager_name)
|
||||
fill_form_item(page, dialog, "工资标准", SALESMAN_SALARY)
|
||||
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
|
||||
expect_toast(page, "人员已创建", "新增业务员")
|
||||
|
||||
row = find_row(page, "姓名", name)
|
||||
if row.count() == 0:
|
||||
fail("新增业务员", f"列表中未找到新建的 {name}")
|
||||
if manager_name not in row.inner_text():
|
||||
fail("新增业务员", "列表「直属经理」列未回显经理姓名")
|
||||
page.wait_for_timeout(400)
|
||||
|
||||
|
||||
def create_product_via_ui(page: Page, product_name: str, spec: str) -> None:
|
||||
"""导航到产品管理并经界面新增产品(类别=轨道交通)→ 列表回显。"""
|
||||
open_menu(page, "产品管理", "基础数据")
|
||||
if current_path(page) != "/base/products":
|
||||
fail("菜单导航", f"未进入产品管理页,当前 {page.url}")
|
||||
|
||||
page.click("button:has-text('新增产品')")
|
||||
page.wait_for_timeout(500)
|
||||
dialog = visible_dialog(page)
|
||||
fill_form_item(page, dialog, "产品名称", product_name)
|
||||
fill_form_item(page, dialog, "规格型号", spec)
|
||||
fill_form_item(page, dialog, "数量", "100")
|
||||
fill_form_item(page, dialog, "单价(元)", "3000")
|
||||
select_form_option(page, dialog, "产品类别", "轨道交通")
|
||||
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
|
||||
expect_toast(page, "产品已创建", "新增产品")
|
||||
|
||||
row = find_row(page, "关键词", product_name)
|
||||
if row.count() == 0:
|
||||
fail("新增产品", f"列表中未找到新建的 {product_name}")
|
||||
if "轨道交通" not in row.inner_text():
|
||||
fail("新增产品", "列表类别列未回显「轨道交通」")
|
||||
page.wait_for_timeout(400)
|
||||
|
||||
|
||||
def create_customer_via_ui(page: Page, company: str, owner: str, initial_owner: str) -> None:
|
||||
"""导航到客户管理并经界面新增客户(企业户 + 初始归属)→ 列表回显。"""
|
||||
open_menu(page, "客户管理", "基础数据")
|
||||
if current_path(page) != "/base/customers":
|
||||
fail("菜单导航", f"未进入客户管理页,当前 {page.url}")
|
||||
|
||||
page.click("button:has-text('新增客户')")
|
||||
page.wait_for_timeout(500)
|
||||
dialog = visible_dialog(page)
|
||||
fill_form_item(page, dialog, "公司名称", company)
|
||||
fill_form_item(page, dialog, "负责人", owner)
|
||||
fill_form_item(page, dialog, "行业", "轨道交通")
|
||||
select_form_option(page, dialog, "客户分类", "企业户")
|
||||
select_form_option(page, dialog, "初始归属", initial_owner)
|
||||
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
|
||||
expect_toast(page, "客户已创建", "新增客户")
|
||||
|
||||
row = find_row(page, "关键词", company)
|
||||
if row.count() == 0:
|
||||
fail("新增客户", f"列表中未找到新建的 {company}")
|
||||
if initial_owner not in row.inner_text():
|
||||
fail("新增客户", "列表「归属人员」列未回显初始归属")
|
||||
if "企业户" not in row.inner_text():
|
||||
fail("新增客户", "列表分类列未回显「企业户」")
|
||||
page.wait_for_timeout(400)
|
||||
|
||||
|
||||
# ==================== 流程步骤 ====================
|
||||
|
||||
|
||||
def step_open_group_and_personnel(page: Page, admin_password: str) -> None:
|
||||
"""登录、「基础数据」分组出现且含三页,点开「人员管理」。"""
|
||||
login(page, ADMIN, admin_password)
|
||||
menu = page.locator(".app-menu")
|
||||
if menu.locator(".el-sub-menu__title", has_text="基础数据").count() == 0:
|
||||
fail("菜单分组", "侧边菜单未出现「基础数据」分组")
|
||||
for title in ("人员管理", "产品管理", "客户管理"):
|
||||
if menu.locator(".el-menu-item", has_text=title).count() == 0:
|
||||
fail("菜单分组", f"「基础数据」分组内未找到「{title}」")
|
||||
|
||||
open_menu(page, "人员管理", "基础数据")
|
||||
if current_path(page) != "/base/personnel":
|
||||
fail("菜单导航", f"未进入人员管理页,当前 {page.url}")
|
||||
print("✓ 1. 菜单导航 「基础数据」分组出现、三页可点开,已进入人员管理")
|
||||
|
||||
|
||||
def step_person_create_manager(page: Page) -> None:
|
||||
create_manager_via_ui(page, MANAGER_NAME)
|
||||
print(f"✓ 2. 新增经理 创建 {MANAGER_NAME}(岗位=营销经理),列表可见")
|
||||
|
||||
|
||||
def step_person_create_salesman(page: Page) -> None:
|
||||
create_salesman_via_ui(page, SALESMAN_NAME, MANAGER_NAME)
|
||||
print(f"✓ 3. 新增业务员 创建 {SALESMAN_NAME}(岗位=营销员),经理名回显")
|
||||
|
||||
|
||||
def step_person_edit_regions(page: Page) -> None:
|
||||
"""编辑抽屉给经理添加一条辐射区域行 → 保存 → 重开回显。"""
|
||||
row = find_row(page, "姓名", MANAGER_NAME)
|
||||
row.locator("button:has-text('区域与计划')").click()
|
||||
page.wait_for_timeout(600)
|
||||
drawer = page.locator(".el-drawer:visible").last
|
||||
drawer.locator("button:has-text('添加区域')").click()
|
||||
page.wait_for_timeout(300)
|
||||
drawer.locator('input[placeholder="如 沈阳市铁西区"]').last.fill(REGION_NAME)
|
||||
drawer.locator("button:has-text('保存区域')").click()
|
||||
expect_toast(page, "辐射区域已保存", "编辑辐射区域")
|
||||
|
||||
# 重开抽屉验证回显(保存走全量替换接口,详情须持久化)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(600)
|
||||
row = find_row(page, "姓名", MANAGER_NAME)
|
||||
row.locator("button:has-text('区域与计划')").click()
|
||||
page.wait_for_timeout(600)
|
||||
drawer = page.locator(".el-drawer:visible").last
|
||||
region_input = drawer.locator('input[placeholder="如 沈阳市铁西区"]').first
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
if region_input.count() and region_input.input_value() == REGION_NAME:
|
||||
break
|
||||
page.wait_for_timeout(200)
|
||||
if region_input.count() == 0 or region_input.input_value() != REGION_NAME:
|
||||
fail("辐射区域回显", "重新打开抽屉未回显已保存的区域名称")
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(500)
|
||||
print("✓ 4. 辐射区域 编辑抽屉添加区域保存,重开回显正确")
|
||||
|
||||
|
||||
def step_person_delete_protection(page: Page) -> None:
|
||||
"""删除被引用的经理被 400 拦截并引导「改为离职」,改为离职成功。"""
|
||||
row = find_row(page, "姓名", MANAGER_NAME)
|
||||
row.locator("button:has-text('删除')").click()
|
||||
page.wait_for_timeout(400)
|
||||
# 确认框文案本身已含「此时应改为离职」引导;确认后走后端校验
|
||||
confirm_message_box(page, "删除")
|
||||
expect_toast(page, "请改为离职", "人员删除保护")
|
||||
|
||||
row = find_row(page, "姓名", MANAGER_NAME)
|
||||
row.locator("button:has-text('设为离职')").click()
|
||||
page.wait_for_timeout(400)
|
||||
confirm_message_box(page, "设为离职")
|
||||
expect_toast(page, "已设为离职", "人员离职")
|
||||
|
||||
row = find_row(page, "姓名", MANAGER_NAME)
|
||||
if "离职" not in row.inner_text():
|
||||
fail("人员离职", "列表状态未更新为离职")
|
||||
print("✓ 5. 删除保护 删除被引人员被 400 拦截,改为离职成功")
|
||||
|
||||
|
||||
def step_products(page: Page) -> None:
|
||||
"""新增产品(轨道交通)与同名称同规格重复负例。"""
|
||||
create_product_via_ui(page, PRODUCT_NAME, PRODUCT_SPEC)
|
||||
print(f"✓ 6. 新增产品 创建 {PRODUCT_NAME}(类别=轨道交通),列表可见")
|
||||
|
||||
# 同名称同规格重复 → 400(唯一键 = 名称 + 规格)
|
||||
page.click("button:has-text('新增产品')")
|
||||
page.wait_for_timeout(500)
|
||||
dialog = visible_dialog(page)
|
||||
fill_form_item(page, dialog, "产品名称", PRODUCT_NAME)
|
||||
fill_form_item(page, dialog, "规格型号", PRODUCT_SPEC)
|
||||
select_form_option(page, dialog, "产品类别", "轨道交通")
|
||||
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
|
||||
if "已存在同名称同规格的产品" not in wait_toast(page, "已存在"):
|
||||
fail("产品重复负例", f"重复产品未提示已存在,当前提示:{toast_text(page) or '(无)'}")
|
||||
visible_dialog(page).locator(".el-dialog__footer button:has-text('取消')").click()
|
||||
page.wait_for_timeout(500)
|
||||
print("✓ 7. 产品重复负例 同名称同规格被 400 拒绝并给出中文文案")
|
||||
|
||||
|
||||
def step_customers(page: Page) -> None:
|
||||
create_customer_via_ui(page, CUSTOMER_COMPANY, CUSTOMER_OWNER, SALESMAN_NAME)
|
||||
print(f"✓ 8. 新增客户 创建 {CUSTOMER_COMPANY}(企业户,初始归属=业务员)")
|
||||
|
||||
|
||||
def step_ownership_change(page: Page) -> None:
|
||||
"""归属调整到经理:二次确认含「不回溯历史」,留痕展示旧 → 新。"""
|
||||
row = find_row(page, "关键词", CUSTOMER_COMPANY)
|
||||
row.locator("button:has-text('归属变更')").click()
|
||||
page.wait_for_timeout(500)
|
||||
dialog = visible_dialog(page)
|
||||
if SALESMAN_NAME not in dialog.inner_text():
|
||||
fail("归属变更", "变更弹窗未回显当前归属业务员")
|
||||
select_form_option(page, dialog, "新归属人员", MANAGER_NAME)
|
||||
dialog.locator("button:has-text('提交变更')").click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# 业务口径(对照表 #19):二次确认文案必须含「不回溯历史」
|
||||
box = confirm_text(page)
|
||||
if "不回溯历史" not in box:
|
||||
fail("归属变更确认", f"二次确认文案缺少「不回溯历史」:{box[:120]}")
|
||||
confirm_message_box(page, "确认变更")
|
||||
expect_toast(page, "归属已调整", "归属调整")
|
||||
|
||||
row = find_row(page, "关键词", CUSTOMER_COMPANY)
|
||||
if row.count() == 0:
|
||||
fail("归属调整", "按关键词查询未找到客户行")
|
||||
if MANAGER_NAME not in row.inner_text():
|
||||
fail("归属调整", "列表「归属人员」列未更新为经理")
|
||||
print("✓ 9. 归属调整 二次确认含「不回溯历史」,归属已改到经理")
|
||||
|
||||
row.locator("button:has-text('变更记录')").click()
|
||||
page.wait_for_timeout(800)
|
||||
drawer = page.locator(".el-drawer:visible").last
|
||||
transition = f"{SALESMAN_NAME} → {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(500)
|
||||
print("✓ 10. 归属留痕 留痕抽屉展示旧 → 新归属一条记录")
|
||||
|
||||
|
||||
def step_cleanup(page: Page) -> None:
|
||||
"""清理测试数据:按 客户 → 产品 → 业务员 → 经理 依赖序删除。
|
||||
|
||||
经理在删除保护步骤已改为离职;仍被引用拦截时打印留档说明(属预期
|
||||
保护行为,不做假绿)。
|
||||
"""
|
||||
open_menu(page, "客户管理", "基础数据")
|
||||
row = find_row(page, "关键词", CUSTOMER_COMPANY)
|
||||
if row.count() > 0:
|
||||
delete_row(page, row, "客户已删除")
|
||||
|
||||
open_menu(page, "产品管理", "基础数据")
|
||||
row = find_row(page, "关键词", PRODUCT_NAME)
|
||||
if row.count() > 0:
|
||||
delete_row(page, row, "产品已删除")
|
||||
|
||||
open_menu(page, "人员管理", "基础数据")
|
||||
row = find_row(page, "姓名", SALESMAN_NAME)
|
||||
if row.count() > 0:
|
||||
delete_row(page, row, "人员已删除")
|
||||
|
||||
row = find_row(page, "姓名", MANAGER_NAME)
|
||||
if row.count() > 0:
|
||||
row.locator("button:has-text('删除')").click()
|
||||
page.wait_for_timeout(400)
|
||||
confirm_message_box(page, "删除")
|
||||
text = wait_toast(page, "已删除")
|
||||
if "人员已删除" in text:
|
||||
pass
|
||||
elif "请改为离职" in text or "存在团队成员" in text:
|
||||
print(" 数据清理 经理仍被引用(属预期保护),留档说明:保留离职态经理")
|
||||
else:
|
||||
fail("数据清理", f"清理经理出现未预期提示:{text or '(无)'}")
|
||||
print("✓ 12. 数据清理 测试数据按依赖序清理,被保护项按留档说明处理")
|
||||
|
||||
|
||||
def step_console_assertion() -> None:
|
||||
"""断言无 JS 控制台错误。
|
||||
|
||||
口径:4xx 请求触发的浏览器网络层日志(Failed to load resource)在
|
||||
负例步骤属预期,不算 JS 错误;只拦脚本错误(console.error / pageerror)。
|
||||
网络资源日志仍收集在 console_errors 供失败诊断。
|
||||
"""
|
||||
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("✓ 13. 控制台检查 无 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/base_data.py",
|
||||
)
|
||||
|
||||
step_open_group_and_personnel(page, admin_password)
|
||||
step_person_create_manager(page)
|
||||
step_person_create_salesman(page)
|
||||
step_person_edit_regions(page)
|
||||
step_person_delete_protection(page)
|
||||
step_products(page)
|
||||
step_customers(page)
|
||||
step_ownership_change(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())
|
||||
612
frontend/e2e/business_data.py
Normal file
612
frontend/e2e/business_data.py
Normal file
@ -0,0 +1,612 @@
|
||||
"""业务数据全链路端到端验证:业务 / 发货 / 回款 / 状态机 / 评级计算器。
|
||||
|
||||
覆盖的链路(只有真实浏览器才能验证的组合):
|
||||
|
||||
- 基础数据准备(复用 `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())
|
||||
Loading…
Reference in New Issue
Block a user