PayrollManagementSystem/backend/tests/integration/test_approval_flow.py
liuwenxin 4c32a556de 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 18:30:51 +08:00

720 lines
30 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.

"""审批流集成测试(二次分配方案为首个接入对象)。
守的是(计划 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
_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:
"""开关关闭(用例显式设置,不依赖种子默认值):提单即自动通过。"""
def test_创建带方案_自动通过全节点留痕(
self, client: TestClient, admin: dict, finance: dict
) -> None:
_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_节点准入_领导不能审财务节点_admin不代办(
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"])
# status=1 是财务节点:领导不行,admin 也不代办
for headers in (leader["headers"], admin["headers"]):
resp = client.post(
f"{_APPROVALS}/{order['id']}/action",
json={"action": 1},
headers=headers,
)
assert resp.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
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:
"""再编辑撤旧建新 / 清空方案撤在途单。"""
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
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
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"]
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"]
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)
]
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"]]
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"