fix(business): e2e 链路验证与两处修复

- wait_toast 超时返回最后见到的非空提示(错误 toast 一闪而过时诊断信息全丢)
- 业务/发货明细行单价类字段放行 4 位小数(与后端 DECIMAL(14,4) 对齐)
- 清理步骤适配删除保护(被终态业务/引用拦截时留档而非失败)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxin 2026-10-13 17:06:46 +08:00
parent fba8cfc215
commit 8c4acb82fe
5 changed files with 67 additions and 41 deletions

View File

@ -370,24 +370,18 @@ def step_person_edit_regions(page: Page) -> None:
def step_person_delete_protection(page: Page) -> None:
"""删除被引用的经理被 400 拦截并引导「改为离职」,改为离职成功。"""
"""删除被引用的经理被 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 拦截,改为离职成功")
print("✓ 5. 删除保护 删除被引人员被 400 拦截,引导改为离职")
def step_products(page: Page) -> None:
@ -460,8 +454,8 @@ def step_ownership_change(page: Page) -> None:
def step_cleanup(page: Page) -> None:
"""清理测试数据:按 客户 → 产品 → 业务员 → 经理 依赖序删除。
经理在删除保护步骤已改为离职;仍被引用拦截时打印留档说明(属预期
保护行为,不做假绿)。
经理此刻仍在职(删除保护步骤刻意未改);删除被拦时先「设为离职」
再删——届时客户/业务员均已删除,经理应无引用、可正常删除。
"""
open_menu(page, "客户管理", "基础数据")
row = find_row(page, "关键词", CUSTOMER_COMPANY)
@ -486,8 +480,18 @@ def step_cleanup(page: Page) -> None:
text = wait_toast(page, "已删除")
if "人员已删除" in text:
pass
elif "请改为离职" in text or "存在团队成员" in text:
print(" 数据清理 经理仍被引用(属预期保护),留档说明:保留离职态经理")
elif "请改为离职" in text or "存在团队成员" in text or "业务归属" in text:
# 引导路径照做:设为离职后再删(引用已随客户/业务员删除解除)
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, "人员已删除", "清理·经理删除")
else:
fail("数据清理", f"清理经理出现未预期提示:{text or '(无)'}")
print("✓ 12. 数据清理 测试数据按依赖序清理,被保护项按留档说明处理")

View File

@ -466,8 +466,10 @@ def step_cleanup(page: Page) -> None:
"""清理:按 回款 → 发货 → 业务 → 客户 → 产品 → 人员 依赖序删除。
删除保护拦截属预期行为:被拦截时打印留档说明,不做假绿也不误伤主流程。
特别地,步骤 12 已把业务流转为「已完成」——终态业务禁删回款/发货是
设计保护(单据金额参与核算口径),此处删除被拦即留档,不视为失败。
"""
# 回款(未核算可删;删除后业务回款合计自动回退)
# 回款(业务此刻已是终态:删除必然被拦,留档说明)
base.open_menu(page, "回款管理", "业务数据")
base.select_filter_option(page, "业务", business_no)
base.click_query(page)
@ -475,7 +477,10 @@ def step_cleanup(page: Page) -> None:
base.table_rows(page).first.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
base.confirm_message_box(page, "删除")
expect_toast(page, "回款已删除", "数据清理")
text = wait_toast(page, "已删除")
if "回款已删除" not in text:
print(" 数据清理 回款删除被拦(业务已终态属预期保护),留档说明:回款保留在开发库")
break
page.wait_for_timeout(600)
base.click_query(page)
@ -510,10 +515,18 @@ def step_cleanup(page: Page) -> None:
)
# 基础数据:客户 → 产品 → 业务员 → 经理
# 客户被留档的业务引用时删除被拦(属预期保护),宽清处理不误伤主流程
base.open_menu(page, "客户管理", "基础数据")
row = base.find_row(page, "关键词", base.CUSTOMER_COMPANY)
if row.count() > 0:
base.delete_row(page, row, "客户已删除")
row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
base.confirm_message_box(page, "删除")
text = wait_toast(page, "已删除")
if "客户已删除" in text:
pass
else:
print(" 数据清理 客户删除被拦(被留档业务引用属预期保护),留档说明:客户保留在开发库")
base.open_menu(page, "产品管理", "基础数据")
row = base.find_row(page, "关键词", base.PRODUCT_NAME)
@ -521,22 +534,20 @@ def step_cleanup(page: Page) -> None:
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:
for person_name in (base.SALESMAN_NAME, base.MANAGER_NAME):
row = base.find_row(page, "姓名", person_name)
if row.count() == 0:
continue
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 '(无)'}")
continue
if "请改为离职" in text or "存在团队成员" in text or "业务归属" in text:
print(f" 数据清理 {person_name} 仍被引用(属预期保护),留档说明:保留人员")
continue
fail("数据清理", f"清理人员出现未预期提示:{text or '(无)'}")
print("✓ 15. 数据清理 回款/发货/业务与基础数据按依赖序清理,被保护项留档")

View File

@ -85,19 +85,24 @@ def toast_text(page: Page) -> str:
def wait_toast(page: Page, expected: str, timeout_ms: int = 5000) -> str:
"""等待包含 expected 的全局提示出现,返回其文本(超时返回当前文本)。
"""等待包含 expected 的全局提示出现,返回其文本(超时返回最后见到的非空提示)。
直接读 `toast_text` 会在「上一条提示尚未消失、新提示尚未出现」的窗口里
读到旧文本,产生误判;轮询等待把这类时序问题挡在断言之外。
返回值语义:匹配成功返回该条;超时返回**见过的最后一条非空提示** ——
toast 只有 3 秒生命周期,等到超时才回来断言的场景(如「请求已 400、
错误 toast 一闪而过」)若返回最后一次的空串,诊断信息会全部丢失。
"""
deadline = time.monotonic() + timeout_ms / 1000
text = ""
last_seen = ""
while time.monotonic() < deadline:
text = toast_text(page)
if text:
last_seen = text
if expected in text:
return text
page.wait_for_timeout(200)
return text
return last_seen
def expect_toast(page: Page, expected: str, step: str, timeout_ms: int = 5000) -> None:

View File

@ -103,6 +103,10 @@ function personnelLabel(option: PersonnelOption): string {
/** 金额/数量形态:非负数字、最多两位小数(纯形态校验,不涉及 float 运算) */
const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
// 单价类列(sale_price/guided_price)建表为 Numeric(14,4):后端 str 直出
// 会带最多 4 位小数(如 "5000.0000"),前端必须放行到 4 位——否则任何
// 带小数精度的历史记录在编辑时都会被前端永远拦下
const PRICE_PATTERN = /^(\d+)(\.\d{1,4})?$/
/** 全零形态:数量用它排除「>0」语义,不走 float 比较 */
const ZERO_PATTERN = /^0+(\.0+)?$/
@ -234,13 +238,13 @@ function validateItemRows(rows: ItemDraft[]): string | null {
return `${no}的数量须为大于 0 的数字,最多两位小数`
}
const prices = [
['销售单价', row.sale_price],
['成本额', row.cost_amount],
['指导价', row.guided_price],
['销售单价', row.sale_price, PRICE_PATTERN, '最多四位小数'],
['成本额', row.cost_amount, AMOUNT_PATTERN, '最多两位小数'],
['指导价', row.guided_price, PRICE_PATTERN, '最多四位小数'],
] as const
for (const [label, value] of prices) {
if (value.trim() && !AMOUNT_PATTERN.test(value.trim())) {
return `${no}的${label}须为非负数字,最多两位小数`
for (const [label, value, pattern, hint] of prices) {
if (value.trim() && !pattern.test(value.trim())) {
return `${no}的${label}须为非负数字,${hint}`
}
}
}

View File

@ -82,6 +82,8 @@ function businessLabel(option: BusinessOption): string {
/** 金额/数量形态:非负数字、最多两位小数(与后端 `_check_amount` 同口径) */
const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
// 发货单价列建表为 Numeric(14,4):后端 str 直出带最多 4 位小数,前端放行到 4 位
const PRICE_PATTERN = /^(\d+)(\.\d{1,4})?$/
/** 全零形态:数量排除「>0」语义,不走 float 比较 */
const ZERO_PATTERN = /^0+(\.0+)?$/
@ -183,8 +185,8 @@ function validateItemRows(rows: ItemDraft[]): string | null {
if (!AMOUNT_PATTERN.test(row.quantity) || ZERO_PATTERN.test(row.quantity)) {
return `${no}的数量须为大于 0 的数字,最多两位小数`
}
if (row.sale_price.trim() && !AMOUNT_PATTERN.test(row.sale_price.trim())) {
return `${no}的发货单价须为非负数字,最多两位小数`
if (row.sale_price.trim() && !PRICE_PATTERN.test(row.sale_price.trim())) {
return `${no}的发货单价须为非负数字,最多四位小数`
}
}
return null