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:
1 parent
2e0c64849b
commit
4c32a556de
28 files changed
+1458
-76
No files matched your search
@@ -388,3 +388,332 @@ class TestModuleAccess:
|
||||
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"
|
||||
@@ -247,6 +247,36 @@ class TestGuards:
|
||||
class TestDeleteProtection:
|
||||
"""RESTRICT 外键:被方案引用的人员禁删,方案随业务删除而清。"""
|
||||
|
||||
def test_同一会话内旧方案行同步为历史态(self, client: TestClient, admin: dict) -> None:
|
||||
"""守「置历史后同一会话内的旧行必须同步刷新」这一行为:Core UPDATE
|
||||
走的是 SQLAlchemy 的会话同步策略,条件复杂化时可能退化为不刷新,
|
||||
届时行对象会停留在 is_active=1 的过期内存值(库中实际已置历史),
|
||||
被序列化或参与判断就会呈现「生效态」的假象。"""
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import SecondaryAllocation
|
||||
from app.repositories import business_repository
|
||||
|
||||
p = _person(client, admin["headers"])
|
||||
data = _create_with_allocations(
|
||||
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}]
|
||||
)
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
loaded = business_repository.list_allocations(session, data["id"])
|
||||
assert loaded and all(a.is_active == 1 for a in loaded)
|
||||
business_repository.deactivate_allocations(session, data["id"])
|
||||
# 同一 session 内的行对象必须已是历史态,不能停留在过期内存值
|
||||
assert all(a.is_active == 0 for a in loaded), "旧行未随置历史同步刷新"
|
||||
# 库中同样已置历史
|
||||
rows = (
|
||||
session.query(SecondaryAllocation)
|
||||
.filter(SecondaryAllocation.business_id == data["id"])
|
||||
.all()
|
||||
)
|
||||
assert rows and all(r.is_active == 0 for r in rows)
|
||||
session.rollback()
|
||||
|
||||
def test_被方案引用的人员禁删(self, client: TestClient, admin: dict) -> None:
|
||||
p = _person(client, admin["headers"])
|
||||
data = _create_with_allocations(
|
||||
|
||||
@@ -229,6 +229,19 @@ class TestSubmitAndLock:
|
||||
assert out["approval_order_no"] and out["approval_order_no"].startswith("AP")
|
||||
assert out["locked_at"] is not None
|
||||
|
||||
# 自动通过路径同样固化送审快照(月结头 + 按人明细)
|
||||
order = client.get(
|
||||
"/api/v1/system/approvals",
|
||||
params={"keyword": out["approval_order_no"], "pageSize": 1},
|
||||
headers=admin["headers"],
|
||||
).json()["data"]["list"][0]
|
||||
detail = client.get(
|
||||
f"/api/v1/system/approvals/{order['id']}", headers=admin["headers"]
|
||||
).json()["data"]
|
||||
assert detail["content_source"] == "snapshot"
|
||||
assert detail["snapshot"]["data"]["settlement_month"] == "2026-10"
|
||||
assert detail["snapshot"]["data"]["items"], "月结快照须含按人明细"
|
||||
|
||||
def test_锁定后回款禁改(self, client: TestClient, admin: dict) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -603,6 +616,21 @@ class TestAdjustment:
|
||||
out = resp.json()["data"]
|
||||
assert out["status"] == 2 # 已生效
|
||||
|
||||
# 调整单送审快照:复核者能看到金额/方向/说明,而非只有调整单号
|
||||
order = client.get(
|
||||
"/api/v1/system/approvals",
|
||||
params={"keyword": out["order_no"], "pageSize": 1},
|
||||
headers=admin["headers"],
|
||||
).json()["data"]["list"][0]
|
||||
assert order["order_type"] == 3
|
||||
detail = client.get(
|
||||
f"/api/v1/system/approvals/{order['id']}", headers=admin["headers"]
|
||||
).json()["data"]
|
||||
assert detail["content_source"] == "snapshot"
|
||||
assert detail["snapshot"]["data"]["amount"] == "500.00"
|
||||
assert detail["snapshot"]["data"]["direction"] == 1
|
||||
assert detail["snapshot"]["data"]["reason"] == "补发差额"
|
||||
|
||||
def test_金额恒正校验(self, client: TestClient, admin: dict) -> None:
|
||||
"""守金额无校验缺陷:负数会反转补发/扣减语义,非数值直落库 500。"""
|
||||
_set_switch(client, admin["headers"], "approval.adjustment.enabled", "0")
|
||||
|
||||
Reference in new issue
Block a user