"""业务数据全链路端到端验证:业务 / 发货 / 回款 / 状态机 / 评级计算器。 覆盖的链路(只有真实浏览器才能验证的组合): - 基础数据准备(复用 `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())