PayrollManagementSystem/frontend/e2e/calc_settlement_flow.py
liuwenxin fb398c3835 test(e2e): 绩效核算与月度结算端到端用例——台账重算与月结审批链路
覆盖台账页按回款 ID 重算、版本筛选三态、月结生成/提交审批/明细回显,
用随机远未来月份造数,重复运行互不碰撞。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 13:50:13 +08:00

354 lines
14 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.

"""绩效核算与月度结算端到端验证(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())