feat(approval): 审批单内容快照与二次分配方案版本化

补齐 code-review 遗留 2 项:审批单此前只固化 ref_id/refer_no 指针,复核者看到
的是业务当前内容;二次分配方案在编辑替换与驳回清空时一律物理删除,方案事实
无处回溯。

- 审批单加 version_no 与 snapshot_json(信封 schema_version/order_type/
  captured_at/data);二次分配、月结、调整单三类提交均固化送审内容,开关关闭
  的自动通过路径同样写快照;详情按 snapshot/live/missing 三态回显,历史单
  只读回退当前内容并显式告警,不回填历史快照
- 二次分配方案行改 append-only 版本化(version_no/is_active,唯一键扩为
  business+version+personnel):编辑替换与驳回清空只置历史,核算与详情默认
  只取生效行,业务详情新增 allocation_history
- 送审内容未变不重提、方案未变不新增版本:判据统一为送审内容,两个
  version_no 语义解耦(方案版本=内容第几版,审批单版本=第几次送审)
- 审批单列表、工作台待办、审批超时预警三处 defer 快照列,避免整列拉取
- 月结提交取行加 for_update,防并发提交算出重复的审批单版本号
- 修复审批类型筛选上界 le=2(调整单无法筛选)、快照金额把空值写成 0.00
- V11 迁移三件套同步(Alembic / db/migration / init-dev.sql)

测试:单元 +16(方案内容比较 10、审批入参 6)、集成 +12;全量 891 passed。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-20 18:30:51 +08:00
1 parent 2e0c64849b
commit 4c32a556de
28 files changed
+1458 -76

No files matched your search

+104 -6
View File
@@ -5,8 +5,12 @@
并填比例 → 提交成功
- 详情/编辑回显:再次打开编辑抽屉,二次分配两行仍在(全量替换语义)
- 合计提示:两行比例非满额时展示「余额…(归属待业务确认)」
- 审批内容:审批详情展示**提交时**的送审方案(参与人 + 比例)
- 数据清理:删除本次创建的业务(t_ 前缀客户/业务,走界面删除)
审批开关在脚本内显式关闭(结束还原默认 1):既不依赖种子默认值,也让本次
业务不留在途审批单——业务删除保护在途单,开关开启时删不掉。
运行前置(缺一不可):
docker compose up -d # MySQL + Redis
cd backend && uv run uvicorn app.main:app --port 8000
@@ -14,8 +18,11 @@
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/secondary_allocation_flow.py
"""
import json
import os
import time
import urllib.error
import urllib.request
from playwright.sync_api import Page, sync_playwright
@@ -32,9 +39,50 @@ from common import (
)
ADMIN = "admin"
API_BASE = "http://127.0.0.1:8000"
SUFFIX = f"{int(time.time())}"
console_errors: list[str] = []
_ALLOCATION_SWITCH = "approval.secondary_allocation.enabled"
def _api(method: str, path: str, token: str, body: dict | None = None) -> dict:
"""直连后端接口(仅用于开关预置;浏览器交互一律走页面)。"""
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_allocation_switch(token: str, value: str) -> None:
"""显式控制二次分配审批开关(用例不依赖种子默认值;结束还原默认 1)。"""
_api(
"POST",
"/api/v1/system/params/changes",
token,
{
"change_type": 1,
"remark": f"e2e secondary_allocation_flow 设置二次分配审批开关={value}",
"items": [
{"param_code": _ALLOCATION_SWITCH, "dimension_value": "", "value": value}
],
},
)
def step_navigate_to_business(page: Page, admin_password: str) -> None:
login(page, ADMIN, admin_password)
@@ -170,6 +218,44 @@ def step_verify_allocation_echo(page: Page) -> None:
page.wait_for_timeout(400)
def _current_business_no(page: Page, customer_name: str) -> str:
"""取本次创建业务的业务编号(列表首列),用于审批单检索。"""
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
page.wait_for_timeout(600)
page.click("button:has-text('查询')")
page.wait_for_timeout(500)
row = page.locator(".el-table__row", has_text=customer_name).first
if row.count() == 0:
fail("取业务编号", f"未找到本次业务(客户 {customer_name})")
return row.locator("td").first.inner_text().strip()
def step_verify_approval_snapshot(page: Page, business_no: str) -> None:
"""审批详情:展示提交时的送审方案(参与人 + 比例 + 合计余额)。"""
page.goto(f"{BASE_URL}/system/approvals", wait_until="networkidle")
page.wait_for_timeout(800)
page.locator('.filters:visible input[placeholder="审批单号 / 业务编号"]').fill(business_no)
page.locator('.filters:visible button:has-text("查询")').first.click()
page.wait_for_timeout(800)
rows = page.locator(".el-table__body .el-table__row:visible")
if rows.count() == 0:
fail("审批内容", f"未找到业务 {business_no} 的审批单")
rows.first.locator("button:has-text('详情')").click()
page.wait_for_timeout(1000)
drawer = page.locator(".el-drawer:visible").last
drawer.wait_for(state="visible")
text = drawer.inner_text()
if "审批内容" not in text:
fail("审批内容", "审批详情未展示「审批内容」区块")
for i in (0, 1):
name = f"t_e2e员_{SUFFIX}_{i}"
if name not in text:
fail("审批内容", f"送审内容缺少参与人 {name}")
if "30.00%" not in text or "40.00%" not in text:
fail("审批内容", f"送审内容缺少分配比例:{text[:200]}")
print("✓ 5. 审批内容 审批详情展示送审方案(两名参与人与 30%/40% 比例)")
def step_cleanup(page: Page) -> None:
"""删除本次创建的业务与其客户(t_ 前缀,走界面删除)。"""
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
@@ -184,7 +270,7 @@ def step_cleanup(page: Page) -> None:
# 删除确认框按钮文案为「删除」(useConfirmAction confirmButtonText)
confirm_message_box(page, "删除")
page.wait_for_timeout(600)
print("✓ 5. 数据清理 已删除本次创建的 t_ 业务")
print("✓ 6. 数据清理 已删除本次创建的 t_ 业务")
def run(page: Page) -> None:
@@ -194,13 +280,25 @@ def run(page: Page) -> None:
fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)")
step_navigate_to_business(page, admin_password)
customer_name = step_create_customer_via_ui(page)
token = page.evaluate("localStorage.getItem('ys_token')")
if not token:
fail("API 预置", "localStorage 中未取到 ys_token")
# 显式关闭二次分配审批开关(结束还原默认 1):自动通过留痕,既不依赖
# 种子默认值,也让本次业务不留“在途审批单”——业务删除保护在途单,
# 开关开启时该业务在界面上删不掉
_set_allocation_switch(token, "0")
try:
step_create_personnel(page)
step_create_business_with_allocation(page, customer_name)
step_verify_allocation_echo(page)
customer_name = step_create_customer_via_ui(page)
try:
step_create_personnel(page)
step_create_business_with_allocation(page, customer_name)
business_no = _current_business_no(page, customer_name)
step_verify_allocation_echo(page)
step_verify_approval_snapshot(page, business_no)
finally:
step_cleanup(page)
finally:
step_cleanup(page)
_set_allocation_switch(token, "1")
if console_errors:
print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})")