"""绩效核算与月度结算端到端验证(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="选择结算月份"]').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())