test(e2e): 绩效核算与月度结算端到端用例——台账重算与月结审批链路

覆盖台账页按回款 ID 重算、版本筛选三态、月结生成/提交审批/明细回显,
用随机远未来月份造数,重复运行互不碰撞。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxin 2026-10-19 13:50:13 +08:00
parent c5f219423b
commit fb398c3835

View File

@ -0,0 +1,353 @@
"""绩效核算与月度结算端到端验证(M3 ④:台账/月结两页)。
覆盖真实浏览器链路(重点守 code-review 修复过的两处前端缺陷):
- 台账页「重算」对话框按**回款 ID** 发起重算(修复前传台账行主键,会
作废并重算无关回款的台账);重算后新版本生效、旧版本翻「已替代」
- 版本筛选三态:仅当前生效(1)/ 仅历史版本(0)/ 清空=全部(不过滤)
(修复前「含历史版本」传 0 只回历史行)
- 月结页:生成快照(草稿)→ 提交审批(自动通过并锁定)→ 详情抽屉展示
按人明细(净工资/试用期),锁定人回显
前置(API 预置;前端无「核算」入口——核算为批量动作走接口):
客户/人员/业务/发货/回款经 HTTP API 直建(与集成测试同形入参),核算经
API 触发;浏览器只负责台账/月结两页的真实交互。
数据说明:核算后的回款与已生成月结按设计不可删(变更只走调整单),
本流程按 `e2e_` 前缀 + **随机远未来月份(209x)**造数——每次运行独占
一个月份,重复运行互不碰撞;行数据留档开发库(与 business_data.py 同)。
运行前置(缺一不可):
docker compose up -d
cd backend && uv run uvicorn app.main:app --port 8000
cd frontend && pnpm dev
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/calc_settlement_flow.py
"""
import json
import os
import random
import time
import urllib.error
import urllib.request
from playwright.sync_api import Page, sync_playwright
from common import (
BASE_URL,
E2EFailure,
collect_console_errors,
current_path,
expect_toast,
fail,
login,
)
ADMIN = "admin"
API_BASE = "http://127.0.0.1:8000"
SUFFIX = f"{int(time.time())}"
console_errors: list[str] = []
_SETTLEMENT_SWITCH = "approval.monthly_settlement.enabled"
def _api(method: str, path: str, token: str, body: dict | None = None) -> dict:
"""直连后端接口(仅用于 API 预置;浏览器交互一律走页面)。"""
request = urllib.request.Request(
f"{API_BASE}{path}",
method=method,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
data=json.dumps(body).encode() if body is not None else None,
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
payload = json.loads(response.read().decode())
except urllib.error.HTTPError as exc:
detail = exc.read().decode()[:200]
raise E2EFailure(f"API {method} {path} HTTP {exc.code}:{detail}") from exc
if payload.get("code") != 200:
raise E2EFailure(f"API {method} {path} 业务失败:{payload.get('message')}")
return payload["data"]
def _set_settlement_switch(token: str, value: str) -> None:
"""显式控制月结审批开关(用例不依赖种子默认值;结束还原默认 1)。"""
_api(
"POST",
"/api/v1/system/params/changes",
token,
{
"change_type": 1,
"remark": f"e2e calc_settlement_flow 设置月结审批开关={value}",
"items": [
{"param_code": _SETTLEMENT_SWITCH, "dimension_value": "", "value": value}
],
},
)
def _filter_item(page: Page, label: str):
"""可见筛选表单中指定标签的表单项(select 带 :visible 避开隐藏实例)。"""
return page.locator(
f'.filters:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
).first
def _query(page: Page) -> None:
page.locator(".filters:visible button:has-text('查询')").first.click()
page.wait_for_timeout(800)
def _select_filter(page: Page, label: str, option: str) -> None:
item = _filter_item(page, 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 step_login_and_open_ledger(page: Page, admin_password: str) -> None:
login(page, ADMIN, admin_password)
page.goto(f"{BASE_URL}/calc/ledger", wait_until="networkidle")
page.wait_for_timeout(600)
if current_path(page) != "/calc/ledger":
fail("导航", f"未进入绩效台账页,当前 {page.url}")
print("✓ 1. 登录与导航 进入绩效台账页")
def step_setup_via_api(page: Page, month: str) -> dict:
"""API 预置完整链路:角色 → 客户/人员 → 业务 → 发货 → 回款 → 核算。"""
token = page.evaluate("localStorage.getItem('ys_token')")
if not token:
fail("API 预置", "localStorage 中未取到 ys_token")
roles = _api("GET", "/api/v1/system/business-roles?pageNum=1&pageSize=50", token)["list"]
role_id = next((r["id"] for r in roles if r["role_code"] == "salesperson"), None)
if role_id is None:
fail("API 预置", "未找到 salesperson 岗位种子")
customer = _api(
"POST",
"/api/v1/base/customers",
token,
{
"company_name": f"e2e_月结客户_{SUFFIX}",
"owner_name": "张老板",
"customer_type": "personal",
},
)
person = _api(
"POST",
"/api/v1/base/personnel",
token,
{
"personnel_name": f"e2e_月结人员_{SUFFIX}",
"business_role_id": role_id,
"salary_standard": "3000.00",
},
)
business = _api(
"POST",
"/api/v1/business/businesses",
token,
{
"customer_id": customer["id"],
"business_date": f"{month}-01",
"owner_personnel_id": person["id"],
"items": [
{
"product_name": f"e2e_月结产品_{SUFFIX}",
"product_category": "municipal_water",
"quantity": "100",
"sale_price": "100.00",
"cost_amount": "3000.00",
}
],
},
)
shipment = _api(
"POST",
"/api/v1/business/shipments",
token,
{
"business_id": business["id"],
"ship_date": f"{month}-02",
"items": [{"product_name": "e2e_月结发货", "quantity": "1", "sale_price": "1.00"}],
},
)
receipt = _api(
"POST",
"/api/v1/business/receipts",
token,
{
"business_id": business["id"],
"receipt_date": f"{month}-15",
"amount": "20000.00",
"shipment_id": shipment["id"],
},
)
calc = _api("POST", f"/api/v1/calc/receipts/{receipt['id']}/calc", token)
if calc.get("repeated") is not False:
fail("API 预置", f"核算应为首算,实际 repeated={calc.get('repeated')}")
print(f"✓ 2. API 预置 业务 {business['business_no']} 已核算({month})")
return {
"token": token,
"month": month,
"business_no": business["business_no"],
"personnel_name": person["personnel_name"],
}
def step_ledger_row_visible(page: Page, ctx: dict) -> None:
_filter_item(page, "归属月").locator("input").first.fill(ctx["month"])
_query(page)
row = page.locator(".el-table__row", has_text=ctx["business_no"]).first
if row.count() == 0:
fail("台账列表", f"筛选 {ctx['month']} 未见业务 {ctx['business_no']} 的台账行")
text = row.text_content()
if "v1" not in text or "已替代" in text:
fail("台账列表", f"首算行应为当前生效 v1,实际:「{text.strip()[:80]}」")
print("✓ 3. 台账筛选 首算 v1 行可见且为当前生效")
def step_recalc_via_dialog(page: Page, ctx: dict) -> None:
"""重算对话框:修复前按台账行主键发起,会作废无关回款的台账。"""
row = page.locator(".el-table__row", has_text=ctx["business_no"]).first
row.locator("button:has-text('重算')").click()
dialog = page.locator(".el-dialog:visible").last
dialog.wait_for(state="visible")
dialog.locator("textarea").fill(f"e2e 重算验证 {SUFFIX}")
dialog.locator("button:has-text('确认重算')").click()
expect_toast(page, "重算完成", "重算")
page.wait_for_timeout(600)
row = page.locator(".el-table__row", has_text=ctx["business_no"]).first
text = row.text_content()
if "v2" not in text:
fail("重算", f"重算后应显示 v2,实际:「{text.strip()[:80]}」")
print("✓ 4. 重算对话框 按回款 ID 重算成功,v2 生效")
def step_version_filter(page: Page, ctx: dict) -> None:
# 仅历史版本:0 必须精确命中历史行(修复前「含历史版本」即传 0,语义相反)
_select_filter(page, "版本", "仅历史版本")
_query(page)
rows = page.locator(".el-table__row", has_text=ctx["business_no"])
if rows.count() != 1 or "已替代" not in rows.first.text_content():
fail("版本筛选", "「仅历史版本」应只剩一条带「已替代」的 v1 行")
# 清空 = 不过滤(全部版本):v1+v2 双版本可见
item = _filter_item(page, "版本")
item.hover()
clear = item.locator(".el-select__clear").first
if clear.count() == 0:
fail("版本筛选", "未找到下拉清空按钮")
clear.click()
page.wait_for_timeout(200)
_query(page)
rows = page.locator(".el-table__row", has_text=ctx["business_no"])
texts = [rows.nth(i).text_content() for i in range(rows.count())]
if rows.count() != 2 or not any("已替代" in t for t in texts):
fail("版本筛选", f"清空后应见 v1+v2 两行,实际 {rows.count()} 行:{texts}")
print("✓ 5. 版本筛选 仅历史/全部三态正确(v1 已替代 + v2 生效)")
def step_generate_settlement(page: Page, ctx: dict) -> None:
page.goto(f"{BASE_URL}/settlements", wait_until="networkidle")
page.wait_for_timeout(600)
page.locator('input[placeholder="结算月份 YYYY-MM"]').fill(ctx["month"])
page.click("button:has-text('生成月结快照')")
expect_toast(page, "月结已生成", "生成月结")
row = page.locator(".el-table__row", has_text=ctx["month"]).first
row.wait_for(state="visible")
text = row.text_content()
if "草稿" not in text:
fail("月结生成", f"生成后应为草稿状态,实际:「{text.strip()[:80]}」")
print("✓ 6. 月结生成 快照生成,草稿状态")
def step_submit_and_locked(page: Page, ctx: dict) -> None:
row = page.locator(".el-table__row", has_text=ctx["month"]).first
row.locator("button:has-text('提交审批')").click()
# 开关已显式关闭(见 run 的 finally 还原)→ 自动通过并锁定
expect_toast(page, "月结已锁定", "提交审批")
page.wait_for_timeout(600)
row = page.locator(".el-table__row", has_text=ctx["month"]).first
if "已审定" not in row.text_content():
fail("提交审批", "提交后应显示已审定(锁定)")
print("✓ 7. 提交审批 自动通过并锁定(已审定)")
def step_detail_drawer(page: Page, ctx: dict) -> None:
row = page.locator(".el-table__row", has_text=ctx["month"]).first
row.locator("button:has-text('详情')").click()
drawer = page.locator(".el-drawer:visible").last
drawer.wait_for(state="visible")
page.wait_for_timeout(500)
text = drawer.text_content()
if ctx["month"] not in text or "已审定" not in text:
fail("月结详情", f"抽屉应含月份与已审定状态,实际:「{text.strip()[:120]}」")
if ctx["personnel_name"] not in text:
fail("月结详情", f"明细应含本次人员 {ctx['personnel_name']}")
if "净工资" not in text:
fail("月结详情", "明细应含净工资列")
print("✓ 8. 月结详情 抽屉明细含人员/净工资/锁定状态")
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 密码)")
# 随机远未来月份:每次运行独占一月,重复运行互不碰撞(数据留档)
month = f"20{random.randint(90, 99)}-{random.randint(1, 12):02d}"
step_login_and_open_ledger(page, admin_password)
ctx = step_setup_via_api(page, month)
_set_settlement_switch(ctx["token"], "0")
try:
step_ledger_row_visible(page, ctx)
step_recalc_via_dialog(page, ctx)
step_version_filter(page, ctx)
step_generate_settlement(page, ctx)
step_submit_and_locked(page, ctx)
step_detail_drawer(page, ctx)
finally:
# 还原审批开关默认(V8 起默认开启),中断也不遗留
_set_settlement_switch(ctx["token"], "1")
print(f" (本次数据留档:{month} 已锁月结 + 客户/人员/业务 e2e_ 前缀,不可删)")
if console_errors:
fail("控制台", f"存在 {len(console_errors)} 条 JS 错误:{console_errors[:2]}")
def main() -> int:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1600, "height": 1000})
page.set_default_timeout(15000)
try:
run(page)
except E2EFailure as exc:
page.screenshot(path="e2e/failure.png", full_page=True)
print(f"\n ✗ {exc}")
print(" 已保存截图 e2e/failure.png")
return 1
except Exception as exc: # noqa: BLE001 — 测试脚本需打印完整失败原因
page.screenshot(path="e2e/failure.png", full_page=True)
print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}")
return 1
finally:
browser.close()
print("\n全部通过。")
return 0
if __name__ == "__main__":
raise SystemExit(main())