PayrollManagementSystem/backend/tests/integration/test_approval_flow.py

728 lines
31 KiB
Python
Raw Normal View History

"""审批流集成测试(二次分配方案为首个接入对象)。
守的是(计划 T4 验收矩阵):
- 开关关闭(种子默认 0):提单即「自动通过」——status=3 且两级节点
action=3 留痕(留痕不断链,关闭=直接生效的需求口径)
- 开关开启:status=1 → 财务通过 → status=2 → 领导通过 → status=3
- 驳回:整体驳回 status=4,意见留痕;驳回必填意见(空驳 400)
- 节点准入:status=1 仅财务、status=2 仅领导;**admin 不代办**(403);
普通用户/营销后勤进不了模块(403)
- 再编辑撤旧建新:在途单 status=5 + node=0/action=4 撤回留痕,新单新建
- 方案为空不提单
- 开关读一次:同一提交路径内参数变化不影响判定(种子级默认行为锁定)
角色用户经 make_user 造(finance/leader/marketing_ops 三新角色种子在库)。
开关联动走参数变更批次(PUT /system/params 端到端,不直改库)。
"""
import uuid
import pytest
from fastapi.testclient import TestClient
from tests.integration.factories import business_payload, customer
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
_BUSINESSES = "/api/v1/business/businesses"
_APPROVALS = "/api/v1/system/approvals"
_PARAMS = "/api/v1/system/params"
_PERSONNEL = "/api/v1/base/personnel"
def _person(client: TestClient, headers: dict) -> dict:
resp = client.post(
_PERSONNEL,
json={
"personnel_name": f"t_审批_{uuid.uuid4().hex[:8]}",
"business_role_id": _any_role_id(),
"salary_standard": "3000.00",
},
headers=headers,
)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _any_role_id() -> int:
from sqlalchemy import select
from app.core.constants import BUSINESS_ROLE_SALESPERSON
from app.core.db import get_session_factory
from app.models import BusinessRole
factory = get_session_factory()
with factory() as session:
return int(
session.execute(
select(BusinessRole.id).where(
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
BusinessRole.status == 1,
)
).scalar_one()
)
def _create_with_plan(client: TestClient, headers: dict, personnel_id: int) -> dict:
cust = customer(client, headers)
resp = client.post(
_BUSINESSES,
json=business_payload(
cust["id"],
secondary_allocations=[{"personnel_id": personnel_id, "ratio": "50.00"}],
),
headers=headers,
)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _latest_order(client: TestClient, headers: dict, refer_no: str) -> dict:
resp = client.get(_APPROVALS, params={"keyword": refer_no, "pageSize": 1}, headers=headers)
assert resp.status_code == 200, resp.text
rows = resp.json()["data"]["list"]
assert rows, f"未找到审批单:{refer_no}"
detail = client.get(f"{_APPROVALS}/{rows[0]['id']}", headers=headers)
return detail.json()["data"]
def _set_switch(client: TestClient, admin_headers: dict, value: str) -> None:
"""经参数变更批次把二次分配审批开关置值(立即生效)。"""
resp = client.post(
f"{_PARAMS}/changes",
json={
"change_type": 1,
"remark": f"测试切换审批开关={value}",
"items": [
{
"param_code": "approval.secondary_allocation.enabled",
"dimension_value": "",
"value": value,
}
],
},
headers=admin_headers,
)
assert resp.status_code == 200, resp.text
@pytest.fixture
def finance(make_user, auth_headers) -> dict:
"""财务角色账号(审批第二节点)。"""
user = make_user(["finance"])
user["headers"] = auth_headers(user)
return user
@pytest.fixture
def leader(make_user, auth_headers) -> dict:
"""领导角色账号(审批第三节点)。"""
user = make_user(["leader"])
user["headers"] = auth_headers(user)
return user
@pytest.fixture
def switched_on(client: TestClient, admin: dict) -> None:
"""把二次分配审批开关置 1(立即生效;用例结束由 conftest 恢复种子基线)。"""
_set_switch(client, admin["headers"], "1")
class TestSwitchOff:
fix(m3): code-review P1——锁定不变量、入结回填与双轨漂移 - 月结锁定拦截补全:原仅新增回款一处,现覆盖回款编辑(原月+新月,防跨月 搬数)/删除、发货新增(按发货日)/编辑(原月+新月)/删除、核算与重算 (已审定月份金额只走调整单);拦截先于「被引用/已核算」判定 - 台账 settlement_id 回填落地:generate 在 replace_items 后回填纳入行、 重生成前先解绑(clear_ledger_settlement_id)——不回填/不解绑会让 「未入结」判据失效:重生成漏算整月或把已固化行反复计入 - 经理聚合行落库:原只进内存参与 items 汇总,台账表永远无 calc_type=2 行、报表下钻落空;重生成先作废旧聚合行(deactivate_month_aggregates), _confirm_scope 限定计算类型避免聚合行混入单笔池 - 月结取参锚定所属月月末(_month_end):原用 date.today(),补生成历史 月份时分成/月工资口径随生成日漂移;聚合行 receipt_date 同步为月末 - generate/submit 出参固化前置到 commit 之前(commit 后 SELECT 失败会把 已生成的月结记成失败审计,与库中事实相反) - V9 迁移:business_secondary_allocations 唯一键条件式补齐(幂等裸 SQL, 临时库验证「无键→补」「有键→跳过」两分支),修复旧 V6 升级库拿不到 V6 就地加入的约束;V8 迁移 revision 声明补类型注解(原格式漂移使 schema 同步测试的 head 推断静默漏检 V8);init-dev 版本号推进至 V9 - V7 留存 SQL 约束/索引名单数改复数(与 alembic op.f 与 init-dev 对齐, 消除 SHOW CREATE TABLE 双轨漂移) - 测试:显式控制审批开关(不依赖 V8 翻转后的种子默认值);新增回归 8 例 (回填落库/聚合不重复/月末取参/月末纯函数 5 例/锁定拦截回款与发货与 核算);全量 773 passed Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-18 23:59:49 -04:00
"""开关关闭(用例显式设置,不依赖种子默认值):提单即自动通过。"""
def test_创建带方案_自动通过全节点留痕(
self, client: TestClient, admin: dict, finance: dict
) -> None:
fix(m3): code-review P1——锁定不变量、入结回填与双轨漂移 - 月结锁定拦截补全:原仅新增回款一处,现覆盖回款编辑(原月+新月,防跨月 搬数)/删除、发货新增(按发货日)/编辑(原月+新月)/删除、核算与重算 (已审定月份金额只走调整单);拦截先于「被引用/已核算」判定 - 台账 settlement_id 回填落地:generate 在 replace_items 后回填纳入行、 重生成前先解绑(clear_ledger_settlement_id)——不回填/不解绑会让 「未入结」判据失效:重生成漏算整月或把已固化行反复计入 - 经理聚合行落库:原只进内存参与 items 汇总,台账表永远无 calc_type=2 行、报表下钻落空;重生成先作废旧聚合行(deactivate_month_aggregates), _confirm_scope 限定计算类型避免聚合行混入单笔池 - 月结取参锚定所属月月末(_month_end):原用 date.today(),补生成历史 月份时分成/月工资口径随生成日漂移;聚合行 receipt_date 同步为月末 - generate/submit 出参固化前置到 commit 之前(commit 后 SELECT 失败会把 已生成的月结记成失败审计,与库中事实相反) - V9 迁移:business_secondary_allocations 唯一键条件式补齐(幂等裸 SQL, 临时库验证「无键→补」「有键→跳过」两分支),修复旧 V6 升级库拿不到 V6 就地加入的约束;V8 迁移 revision 声明补类型注解(原格式漂移使 schema 同步测试的 head 推断静默漏检 V8);init-dev 版本号推进至 V9 - V7 留存 SQL 约束/索引名单数改复数(与 alembic op.f 与 init-dev 对齐, 消除 SHOW CREATE TABLE 双轨漂移) - 测试:显式控制审批开关(不依赖 V8 翻转后的种子默认值);新增回归 8 例 (回填落库/聚合不重复/月末取参/月末纯函数 5 例/锁定拦截回款与发货与 核算);全量 773 passed Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-18 23:59:49 -04:00
_set_switch(client, admin["headers"], "0") # V8 起种子默认开启
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
assert order["status"] == 3
assert order["order_no"].startswith("AP")
auto_steps = [s for s in order["steps"] if s["action"] == 3]
assert len(auto_steps) == 2
assert {s["node"] for s in auto_steps} == {1, 2}
assert all(s["operator_name"] == "系统" for s in auto_steps)
def test_方案为空不提单(self, client: TestClient, admin: dict, finance: dict) -> None:
cust = customer(client, admin["headers"])
resp = client.post(_BUSINESSES, json=business_payload(cust["id"]), headers=admin["headers"])
assert resp.status_code == 200
rows = client.get(
_APPROVALS,
params={"keyword": resp.json()["data"]["business_no"]},
headers=finance["headers"],
).json()["data"]["list"]
assert rows == []
class TestSwitchOn:
"""开关开启:两节点人工流转。"""
@pytest.fixture
def switched_on(self, client: TestClient, admin: dict) -> None:
_set_switch(client, admin["headers"], "1")
def test_两节点流转至通过(
self, client: TestClient, admin: dict, finance, leader, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
assert order["status"] == 1
# 财务通过 → 待领导
resp = client.post(
f"{_APPROVALS}/{order['id']}/action",
json={"action": 1, "opinion": "同意"},
headers=finance["headers"],
)
assert resp.status_code == 200, resp.text
assert resp.json()["data"]["status"] == 2
# 领导通过 → 已通过
resp = client.post(
f"{_APPROVALS}/{order['id']}/action",
json={"action": 1},
headers=leader["headers"],
)
assert resp.status_code == 200, resp.text
assert resp.json()["data"]["status"] == 3
manual = [s for s in resp.json()["data"]["steps"] if s["action"] == 1]
assert {s["node"] for s in manual} == {1, 2}
def test_财务驳回_整体驳回且意见留痕(
self, client: TestClient, admin: dict, finance, leader, switched_on
) -> None:
_ = leader
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
resp = client.post(
f"{_APPROVALS}/{order['id']}/action",
json={"action": 2, "opinion": "比例分配不合理"},
headers=finance["headers"],
)
assert resp.status_code == 200, resp.text
out = resp.json()["data"]
assert out["status"] == 4
rejected = [s for s in out["steps"] if s["action"] == 2]
assert rejected and rejected[0]["opinion"] == "比例分配不合理"
def test_驳回缺意见被拒(
self, client: TestClient, admin: dict, finance, leader, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
resp = client.post(
f"{_APPROVALS}/{order['id']}/action",
json={"action": 2},
headers=finance["headers"],
)
assert resp.status_code == 400
def test_节点准入_按独立审核权限而非角色名称判定(
self, client: TestClient, admin: dict, finance, leader, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, leader["headers"], data["business_no"])
# 领导模板不含财务复核;管理员固定拥有两项审核权限,但仍遵守节点。
for headers, expected in ((leader["headers"], 403), (admin["headers"], 200)):
resp = client.post(
f"{_APPROVALS}/{order['id']}/action",
json={"action": 1},
headers=headers,
)
assert resp.status_code == expected
response = client.post(
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=finance["headers"]
)
assert response.status_code == 403 # 已进入领导节点,财务模板无领导审核权限。
def test_重复操作已通过单被拒(
self, client: TestClient, admin: dict, finance, leader, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
client.post(
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=finance["headers"]
)
client.post(
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=leader["headers"]
)
resp = client.post(
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=finance["headers"]
)
assert resp.status_code == 400
fix(m3): code-review 15 项修复(13 项落地,2 项依赖分摊语义记待办) - 审批:删 _flush_order 撞键内部重试(rollback 会回掉同事务业务行造成 静默丢数据,异常改由 create 外层重试整体重来/update 路径审计收敛); act() 出参固化前置到 commit 之前(commit 后查询失败会把已通过的审批 记成失败审计);驳回/撤回清空方案生效行(防被否决方案进 M3 快照); keyword 改 contains(autoescape=True) 对齐全库转义口径;撤单抽 withdraw_pending_orders 供编辑清空方案时显式调用 - 业务:删业务遇在途审批单禁删 400、终态审批单随 _hard_delete 清理; 编辑清空方案同步撤回在途单;历史离职参与人编辑放行(冗余姓名固化 语义,仅新增参与人做在职校验) - 迁移:business_secondary_allocations 补 uk(business_id, personnel_id) (模型/迁移 py/导出 SQL/init-dev 四方同步,开发库往返重放验证) - 设置:upload_logo 改同步 def(阻塞操作不卡事件循环)+ file.size 先拦; company_name schema 剥空白防全空白入库;logo 旧文件清理移到 commit 成功后(防库指旧路径而文件已删断链);uploadLogo 走 request() 解包 (原直发 service.post 不解包信封,logoUrl 会被置 undefined) - 备份:restore_drill --drop-exec 先清全部历史 drill 库再演练并即删本轮 (原语义永远删错对象,每轮净增一份整库拷贝) - 测试:conftest 清理补 system_settings 种子行与 uploads/logo 文件; 新增 5 例(驳回清方案/清空撤单/在途禁删终态随删/离职放行/BOOL 边界); 全量回归 701 passed - 遗留 2 项记 AI_CONTEXT(方案版本承载、审批单快照,依赖 ④ 分摊语义) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 02:23:53 -04:00
def test_驳回后方案行清空_不留在生效表(
self, client: TestClient, admin: dict, finance, switched_on
) -> None:
"""被否决的方案不得以「当前生效行」身份留在表里(M3 快照会取到)。"""
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
resp = client.post(
f"{_APPROVALS}/{order['id']}/action",
json={"action": 2, "opinion": "方案不合理"},
headers=finance["headers"],
)
assert resp.status_code == 200
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert detail["secondary_allocations"] == [], "被驳回的方案应从生效表清空"
assert detail["allocation_hint"] is None
class TestResubmit:
fix(m3): code-review 15 项修复(13 项落地,2 项依赖分摊语义记待办) - 审批:删 _flush_order 撞键内部重试(rollback 会回掉同事务业务行造成 静默丢数据,异常改由 create 外层重试整体重来/update 路径审计收敛); act() 出参固化前置到 commit 之前(commit 后查询失败会把已通过的审批 记成失败审计);驳回/撤回清空方案生效行(防被否决方案进 M3 快照); keyword 改 contains(autoescape=True) 对齐全库转义口径;撤单抽 withdraw_pending_orders 供编辑清空方案时显式调用 - 业务:删业务遇在途审批单禁删 400、终态审批单随 _hard_delete 清理; 编辑清空方案同步撤回在途单;历史离职参与人编辑放行(冗余姓名固化 语义,仅新增参与人做在职校验) - 迁移:business_secondary_allocations 补 uk(business_id, personnel_id) (模型/迁移 py/导出 SQL/init-dev 四方同步,开发库往返重放验证) - 设置:upload_logo 改同步 def(阻塞操作不卡事件循环)+ file.size 先拦; company_name schema 剥空白防全空白入库;logo 旧文件清理移到 commit 成功后(防库指旧路径而文件已删断链);uploadLogo 走 request() 解包 (原直发 service.post 不解包信封,logoUrl 会被置 undefined) - 备份:restore_drill --drop-exec 先清全部历史 drill 库再演练并即删本轮 (原语义永远删错对象,每轮净增一份整库拷贝) - 测试:conftest 清理补 system_settings 种子行与 uploads/logo 文件; 新增 5 例(驳回清方案/清空撤单/在途禁删终态随删/离职放行/BOOL 边界); 全量回归 701 passed - 遗留 2 项记 AI_CONTEXT(方案版本承载、审批单快照,依赖 ④ 分摊语义) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 02:23:53 -04:00
"""再编辑撤旧建新 / 清空方案撤在途单。"""
def test_在途单撤回并新建(
self, client: TestClient, admin: dict, finance, leader, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
first = _latest_order(client, finance["headers"], data["business_no"])
assert first["status"] == 1
# 再编辑(仍带方案)→ 旧在途单撤回、新单待审
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = business_payload(
detail["customer_id"],
secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}],
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
rows = client.get(
_APPROVALS,
params={"keyword": data["business_no"], "pageSize": 10},
headers=finance["headers"],
).json()["data"]["list"]
statuses = {row["status"] for row in rows}
assert 5 in statuses and 1 in statuses, f"未见撤回+新建:{statuses}"
old_detail = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()
withdraw_steps = [s for s in old_detail["data"]["steps"] if s["action"] == 4]
assert withdraw_steps and withdraw_steps[0]["node"] == 0
fix(m3): code-review 15 项修复(13 项落地,2 项依赖分摊语义记待办) - 审批:删 _flush_order 撞键内部重试(rollback 会回掉同事务业务行造成 静默丢数据,异常改由 create 外层重试整体重来/update 路径审计收敛); act() 出参固化前置到 commit 之前(commit 后查询失败会把已通过的审批 记成失败审计);驳回/撤回清空方案生效行(防被否决方案进 M3 快照); keyword 改 contains(autoescape=True) 对齐全库转义口径;撤单抽 withdraw_pending_orders 供编辑清空方案时显式调用 - 业务:删业务遇在途审批单禁删 400、终态审批单随 _hard_delete 清理; 编辑清空方案同步撤回在途单;历史离职参与人编辑放行(冗余姓名固化 语义,仅新增参与人做在职校验) - 迁移:business_secondary_allocations 补 uk(business_id, personnel_id) (模型/迁移 py/导出 SQL/init-dev 四方同步,开发库往返重放验证) - 设置:upload_logo 改同步 def(阻塞操作不卡事件循环)+ file.size 先拦; company_name schema 剥空白防全空白入库;logo 旧文件清理移到 commit 成功后(防库指旧路径而文件已删断链);uploadLogo 走 request() 解包 (原直发 service.post 不解包信封,logoUrl 会被置 undefined) - 备份:restore_drill --drop-exec 先清全部历史 drill 库再演练并即删本轮 (原语义永远删错对象,每轮净增一份整库拷贝) - 测试:conftest 清理补 system_settings 种子行与 uploads/logo 文件; 新增 5 例(驳回清方案/清空撤单/在途禁删终态随删/离职放行/BOOL 边界); 全量回归 701 passed - 遗留 2 项记 AI_CONTEXT(方案版本承载、审批单快照,依赖 ④ 分摊语义) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-16 02:23:53 -04:00
def test_编辑清空方案_在途单撤回不再悬空(
self, client: TestClient, admin: dict, finance, switched_on
) -> None:
"""空数组=清空方案:旧在途单必须撤回,否则挂着不存在的方案走审批。"""
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
first = _latest_order(client, finance["headers"], data["business_no"])
assert first["status"] == 1
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = business_payload(detail["customer_id"], secondary_allocations=[])
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
rows = client.get(
_APPROVALS,
params={"keyword": data["business_no"], "pageSize": 10},
headers=finance["headers"],
).json()["data"]["list"]
statuses = {row["status"] for row in rows}
assert statuses == {5}, f"清空方案后旧在途单应撤回且无新单:{statuses}"
assert (
client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"][
"status"
]
== 5
)
def test_删除业务在途单禁删_终态单随删(
self, client: TestClient, admin: dict, finance, switched_on
) -> None:
"""ref_id 固化无外键:在途单禁删(防指向已删业务的活单继续被审批),
终态单随业务删除一并清理(已通过/已驳回的历史事实由留痕日志承载)。"""
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
# 在途:禁删
resp = client.delete(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"])
assert resp.status_code == 400
assert "在途审批单" in resp.json()["message"]
# 驳回(终态)后:可删,且审批单随之清理
order = _latest_order(client, finance["headers"], data["business_no"])
client.post(
f"{_APPROVALS}/{order['id']}/action",
json={"action": 2, "opinion": "驳回后可删"},
headers=finance["headers"],
)
resp = client.delete(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"])
assert resp.status_code == 200, resp.text
rows = client.get(
_APPROVALS,
params={"keyword": data["business_no"]},
headers=finance["headers"],
).json()["data"]["list"]
assert rows == [], "终态审批单应随业务删除一并清理"
class TestModuleAccess:
"""模块准入:普通用户与营销后勤不可见(权限清单待澄清 #6)。"""
def test_普通用户403(self, client: TestClient, normal: dict) -> None:
assert client.get(_APPROVALS, headers=normal["headers"]).status_code == 403
def test_营销后勤403_暂不可见(self, client: TestClient, make_user, auth_headers) -> None:
ops = make_user(["marketing_ops"])
ops["headers"] = auth_headers(ops)
assert client.get(_APPROVALS, headers=ops["headers"]).status_code == 403
def test_财务与领导可见(self, client: TestClient, make_user, auth_headers) -> None:
for code in ("finance", "leader"):
user = make_user([code])
user["headers"] = auth_headers(user)
assert client.get(_APPROVALS, headers=user["headers"]).status_code == 200
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>
2026-10-20 06:30:51 -04:00
class TestContentSnapshot:
"""审批单内容快照与版本号(code-review 遗留②)。
守的是:复核者看到的是**送审时**的方案,而不是审批期间被改过的当前
方案;历史单(升级前无快照)必须显式标注「当前内容回退」,不能把
当前内容冒充送审内容。
"""
def test_提交时快照固化_再改方案不回写旧单(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
first = _latest_order(client, finance["headers"], data["business_no"])
assert first["version_no"] == 1
assert first["content_source"] == "snapshot"
snapshot = first["snapshot"]
assert snapshot["schema_version"] == 1
assert snapshot["order_type"] == 1
assert snapshot["captured_at"], "快照须固化采集时间"
assert snapshot["data"]["business_no"] == data["business_no"]
assert snapshot["data"]["allocations"][0]["ratio"] == "50.00"
assert snapshot["data"]["allocation_total"] == "50.00"
assert snapshot["data"]["allocation_balance"] == "50.00"
# 编辑方案为 80%:旧在途单撤回,新单 V2 快照取新值;旧单快照不变
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = business_payload(
detail["customer_id"],
secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}],
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
second = _latest_order(client, finance["headers"], data["business_no"])
assert second["version_no"] == 2
assert second["snapshot"]["data"]["allocations"][0]["ratio"] == "80.00"
old = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"]
assert old["status"] == 5, "重新提交须撤回旧在途单"
assert old["snapshot"]["data"]["allocations"][0]["ratio"] == "50.00"
def test_历史单无快照_回退当前内容并标注(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
"""升级前的审批单没有快照列:回退读当前内容,但来源必须标 live。"""
from app.core.db import get_session_factory
from app.models import ApprovalOrder
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
factory = get_session_factory()
with factory() as session:
row = session.get(ApprovalOrder, order["id"])
assert row is not None
row.snapshot_json = None
session.commit()
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()[
"data"
]
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>
2026-10-20 06:30:51 -04:00
assert detail["content_source"] == "live"
assert detail["snapshot"]["captured_at"] is None
assert detail["snapshot"]["data"]["business_no"] == data["business_no"]
def test_引用对象缺失且无快照_内容缺失(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
from app.core.db import get_session_factory
from app.models import ApprovalOrder
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
factory = get_session_factory()
with factory() as session:
row = session.get(ApprovalOrder, order["id"])
assert row is not None
row.snapshot_json = None
row.ref_id = 999_999_999
session.commit()
detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()[
"data"
]
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>
2026-10-20 06:30:51 -04:00
assert detail["content_source"] == "missing"
assert detail["snapshot"] is None
def _payload_from_detail(detail: dict, **overrides: object) -> dict:
"""按业务详情原样重建提交载荷(明细行照抄,用于「内容未变」的场景)。"""
items = [
{
"product_name": it["product_name"],
"product_category": it["product_category"],
"quantity": it["quantity"],
"sale_price": it["sale_price"],
}
for it in detail["items"]
]
body = business_payload(
detail["customer_id"],
items=items,
secondary_allocations=detail["secondary_allocations"],
)
body.update(overrides)
return body
class TestNoRedundantResubmit:
"""送审内容未变不重提、方案未变不新增版本(版本与留痕降噪)。
守的是:反复保存同一份业务(只改备注)不应刷出一串方案版本与
「撤回+新建」的审批留痕;而内容真变了(金额或方案)必须重提,
否则审批人看到的是过期内容。
"""
def test_方案与金额均未变_不新增版本也不重提(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
first = _latest_order(client, finance["headers"], data["business_no"])
assert first["status"] == 1
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = _payload_from_detail(detail)
payload["remark"] = "只改了备注"
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
# 方案版本不增
after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [(h["version_no"], h["is_active"]) for h in after["allocation_history"]] == [
(1, 1)
], "方案内容未变不应新增版本行"
# 审批单不重提:仍是原来那一张、仍待财务复核
rows = client.get(
_APPROVALS,
params={"keyword": data["business_no"], "pageSize": 10},
headers=finance["headers"],
).json()["data"]["list"]
assert len(rows) == 1, f"送审内容未变不应重提,实得 {len(rows)} 张单"
assert rows[0]["id"] == first["id"]
assert rows[0]["status"] == 1, "在途单不应被撤回"
assert rows[0]["version_no"] == 1
def test_只改金额不改方案_方案版本不变但审批单重提(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
"""金额变了 → 送审内容变了 → 必须重提;但方案本身没变 → 不新增版本。"""
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
first = _latest_order(client, finance["headers"], data["business_no"])
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = _payload_from_detail(detail)
payload["items"][0]["sale_price"] = "200.00"
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [(h["version_no"], h["is_active"]) for h in after["allocation_history"]] == [
(1, 1)
], "方案未变不应新增版本行"
second = _latest_order(client, finance["headers"], data["business_no"])
assert second["version_no"] == 2, "金额变了必须重提"
assert second["snapshot"]["data"]["sale_amount"] == "20000.00"
old = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"]
assert old["status"] == 5
def test_改方案比例_方案版本与审批单版本同时递增(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
first = _latest_order(client, finance["headers"], data["business_no"])
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = _payload_from_detail(
detail, secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}]
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [(h["version_no"], h["is_active"]) for h in after["allocation_history"]] == [
(1, 0),
(2, 1),
], "方案变了应新增版本并把旧行置历史"
second = _latest_order(client, finance["headers"], data["business_no"])
assert second["version_no"] == 2
old = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"]
assert old["status"] == 5
def test_无在途单时保存仍提新单(
self, client: TestClient, admin: dict, finance: dict, leader: dict, switched_on
) -> None:
"""保守规则:只有「存在在途单且内容一致」才不重提。
方案已审批通过后再保存,仍按原语义重新送审。"""
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
client.post(
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=finance["headers"]
)
client.post(
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=leader["headers"]
)
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
payload = _payload_from_detail(detail)
payload["remark"] = "已通过后再保存"
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
rows = client.get(
_APPROVALS,
params={"keyword": data["business_no"], "pageSize": 10},
headers=finance["headers"],
).json()["data"]["list"]
assert len(rows) == 2, "无在途单时保存应重新送审"
assert max(r["version_no"] for r in rows) == 2
class TestPendingSnapshotMatch:
"""在途单送审内容一致性判定(approval_service.pending_matches_snapshot)。"""
def test_三种情形(self, client: TestClient, admin: dict, finance: dict, switched_on) -> None:
from app.core.db import get_session_factory
from app.services import approval_service
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
snapshot = order["snapshot"]["data"]
factory = get_session_factory()
with factory() as session:
# 1) 内容一致 → True
assert (
approval_service.pending_matches_snapshot(
session,
order_type=1,
ref_id=data["id"],
snapshot=snapshot,
)
is True
)
# 2) 内容不同 → False
assert (
approval_service.pending_matches_snapshot(
session, order_type=1, ref_id=data["id"], snapshot={"allocations": []}
)
is False
)
# 3) 无在途单 → False(引用对象换成别的)
assert (
approval_service.pending_matches_snapshot(
session, order_type=1, ref_id=999_999_999, snapshot=snapshot
)
is False
)
# 4) 快照为 None → False
assert (
approval_service.pending_matches_snapshot(
session, order_type=1, ref_id=data["id"], snapshot=None
)
is False
)
class TestAllocationVersions:
"""二次分配方案版本化(code-review 遗留①)。
守的是:编辑替换与驳回清空只把旧行置历史(is_active=0),不物理删除,
方案参与人与比例可回溯;生效行始终唯一,核算取数不受历史行干扰。
"""
def test_编辑方案产生新版本_旧版本保留(
self, client: TestClient, admin: dict, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert [(h["version_no"], h["is_active"]) for h in detail["allocation_history"]] == [(1, 1)]
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>
2026-10-20 06:30:51 -04:00
payload = business_payload(
detail["customer_id"],
secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}],
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
history = [
(h["version_no"], h["is_active"], h["ratio"]) for h in detail["allocation_history"]
]
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>
2026-10-20 06:30:51 -04:00
assert history == [(1, 0, "50.00"), (2, 1, "80.00")]
assert [a["ratio"] for a in detail["secondary_allocations"]] == ["80.00"]
def test_驳回后生效行为空_历史行保留(
self, client: TestClient, admin: dict, finance: dict, switched_on
) -> None:
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
order = _latest_order(client, finance["headers"], data["business_no"])
resp = client.post(
f"{_APPROVALS}/{order['id']}/action",
json={"action": 2, "opinion": "方案不合理"},
headers=finance["headers"],
)
assert resp.status_code == 200, resp.text
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert detail["secondary_allocations"] == [], "被驳回方案不得留在生效行"
assert detail["allocation_hint"] is None
history = detail["allocation_history"]
assert len(history) == 1
assert history[0]["version_no"] == 1
assert history[0]["is_active"] == 0
assert history[0]["ratio"] == "50.00"