diff --git a/frontend/e2e/calc_settlement_flow.py b/frontend/e2e/calc_settlement_flow.py new file mode 100644 index 0000000..79be522 --- /dev/null +++ b/frontend/e2e/calc_settlement_flow.py @@ -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())