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>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-19 11:59:49 +08:00
1 parent 7a460cb682
commit 76ded6d5ec
15 files changed
+495 -48

No files matched your search

@@ -129,11 +129,12 @@ def switched_on(client: TestClient, admin: dict) -> None:
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"])
@@ -69,6 +69,26 @@ def _create_with_allocations(client: TestClient, headers: dict, allocations: lis
return resp.json()["data"]
def _set_switch(client: TestClient, headers: dict, value: str) -> None:
"""显式设置二次分配审批开关(V8 起默认开启,用例不得依赖种子默认值)。"""
resp = client.post(
"/api/v1/system/params/changes",
json={
"change_type": 1,
"remark": f"用例设置二次分配审批开关={value}",
"items": [
{
"param_code": "approval.secondary_allocation.enabled",
"dimension_value": "",
"value": value,
}
],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
def _detail(client: TestClient, headers: dict, business_id: int) -> dict:
resp = client.get(f"{_BUSINESSES}/{business_id}", headers=headers)
assert resp.status_code == 200, resp.text
@@ -239,6 +259,8 @@ class TestDeleteProtection:
assert detail["secondary_allocations"][0]["personnel_name"] == p["personnel_name"]
def test_删除业务后可删人员(self, client: TestClient, admin: dict) -> None:
# 显式关开关:开启时审批单在途、删业务被 400 拦截(产品行为,另有用例覆盖)
_set_switch(client, admin["headers"], "0")
p = _person(client, admin["headers"])
data = _create_with_allocations(
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}]
+228 -12
View File
@@ -16,7 +16,7 @@ from fastapi.testclient import TestClient
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import MonthlySettlement
from app.models import MonthlySettlement, PerformanceLedger
from tests.integration.factories import business_payload, customer
pytestmark = pytest.mark.integration
@@ -167,6 +167,15 @@ class TestGenerate:
select(MonthlySettlement).where(MonthlySettlement.settlement_month == "2026-10")
).scalar_one()
assert settled.person_count >= 1
backfilled = (
session.execute(
select(PerformanceLedger).where(PerformanceLedger.settlement_id == settled.id)
)
.scalars()
.all()
)
assert backfilled, "台账行 settlement_id 未回填(「未入结」判据失效)"
assert all(r.settlement_month == "2026-10" for r in backfilled)
def test_无核算记录生成400(self, client: TestClient, admin: dict) -> None:
resp = client.post(
@@ -369,11 +378,12 @@ class TestSwitchOn:
class TestManagerAggregate:
"""经理团队聚合:比例按参数实际值计算(守「百分数再乘 100」放大缺陷)。"""
def test_聚合金额按实际比例计入(self, client: TestClient, admin: dict) -> None:
manager = _person(client, admin["headers"], role_code="sales_manager")
sales = _person(client, admin["headers"], manager_id=manager["id"])
def _scene(self, client: TestClient, headers: dict) -> tuple[dict, dict]:
"""经理(带一名业务员)→ 业务 → 发货 → 已核算回款,返回 (经理, 回款)。"""
manager = _person(client, headers, role_code="sales_manager")
sales = _person(client, headers, manager_id=manager["id"])
cust = customer(client, admin["headers"])
cust = customer(client, headers)
resp = client.post(
_BUSINESSES,
json=business_payload(
@@ -381,7 +391,7 @@ class TestManagerAggregate:
owner_personnel_id=sales["id"],
items=[dict(_GOLD_ITEMS[0], product_name=f"t_聚合_{uuid.uuid4().hex[:6]}")],
),
headers=admin["headers"],
headers=headers,
)
assert resp.status_code == 200, resp.text
biz = resp.json()["data"]
@@ -392,7 +402,7 @@ class TestManagerAggregate:
"ship_date": "2026-10-01",
"items": [{"product_name": "t_发", "quantity": "1", "sale_price": "1.00"}],
},
headers=admin["headers"],
headers=headers,
)
assert resp.status_code == 200, resp.text
resp = client.post(
@@ -403,22 +413,24 @@ class TestManagerAggregate:
"amount": "20000.00",
"shipment_id": resp.json()["data"]["id"],
},
headers=admin["headers"],
headers=headers,
)
assert resp.status_code == 200, resp.text
receipt = resp.json()["data"]
assert (
client.post(
f"{_CALC}/receipts/{receipt['id']}/calc", headers=admin["headers"]
).status_code
client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers).status_code
== 200
)
return manager, receipt
def test_聚合金额按实际比例计入且落库回填(self, client: TestClient, admin: dict) -> None:
manager, _receipt = self._scene(client, admin["headers"])
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
items = resp.json()["data"]["items"]
out = resp.json()["data"]
items = out["items"]
manager_items = [i for i in items if i["calc_type"] == 2]
assert len(manager_items) == 1
# 业务员绩效 39.11;经理 = 39.11×12.5% + 39.11×5% = 6.84425 → 6.84
@@ -427,6 +439,81 @@ class TestManagerAggregate:
assert manager_items[0]["performance_sum"] == "6.84"
assert manager_items[0]["payable_sum"] == "6.84"
# 聚合行必须落台账(报告下钻的数据源)且被回填 settlement_id
factory = get_session_factory()
with factory() as session:
rows = (
session.execute(
select(PerformanceLedger).where(
PerformanceLedger.settlement_month == "2026-10",
PerformanceLedger.calc_type == 2,
)
)
.scalars()
.all()
)
assert len(rows) == 1, "聚合行必须落库(修复前只在内存参与汇总)"
assert rows[0].is_active == 1
assert rows[0].receipt_id is None
assert rows[0].settlement_id == out["id"], "聚合行未回填 settlement_id"
def test_草稿态重生成_聚合不重复计入(self, client: TestClient, admin: dict) -> None:
self._scene(client, admin["headers"])
first = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
).json()["data"]
second = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
)
assert second.status_code == 200, second.text
out = second.json()["data"]
# 重生成金额不翻倍(聚合行不做废旧行会 45.95→52.79)也不漏行
assert out["total_payable"] == first["total_payable"] == "45.95"
assert len([i for i in out["items"] if i["calc_type"] == 2]) == 1
assert len([i for i in out["items"] if i["calc_type"] == 1]) == 1
def test_月末取参_预约变更影响本月聚合(self, client: TestClient, admin: dict) -> None:
"""取参锚点=所属月月末:10-25 生效的预约变更应影响 2026-10 月结。"""
resp = client.post(
"/api/v1/system/params/changes",
json={
"change_type": 2,
"effective_date": "2026-10-25",
"remark": "用例:月末取参锚点",
"items": [
{
"param_code": "manager.share.team",
"dimension_value": "",
"value": "20",
}
],
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
self._scene(client, admin["headers"])
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
manager_items = [i for i in resp.json()["data"]["items"] if i["calc_type"] == 2]
# 39.11×20% + 39.11×5% = 9.7775 → 9.78(按生成日取参会得 6.84)
assert manager_items[0]["performance_sum"] == "9.78"
# 台账快照固化月末取参日与实际比例原文
factory = get_session_factory()
with factory() as session:
row = session.execute(
select(PerformanceLedger).where(
PerformanceLedger.settlement_month == "2026-10",
PerformanceLedger.calc_type == 2,
)
).scalar_one()
assert row.snapshot_json["params_as_of"] == "2026-10-31"
assert row.rate_text == "聚合:20%+5%"
assert str(row.receipt_date) == "2026-10-31"
class TestAdjustment:
"""调整单:已锁定月份补发/扣减的唯一通道。"""
@@ -450,3 +537,132 @@ class TestAdjustment:
assert resp.status_code == 200, resp.text
out = resp.json()["data"]
assert out["status"] == 2 # 已生效
class TestLockInterception:
"""锁定后源数据写路径全量拦截(回款编辑/删除、发货增改删、核算/重算)。"""
def _lock_scene(self, client: TestClient, admin: dict) -> tuple[dict, dict, dict, dict]:
"""已核算+未核算各一笔回款、两张 10 月发货单,然后锁定 2026-10。
返回 (月结, 业务, 未核算回款, 无回款引用的发货单)。
"""
biz = _calc_one(client, admin["headers"])
resp = client.post(
_RECEIPTS,
json={"business_id": biz["id"], "receipt_date": "2026-10-22", "amount": "100.00"},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
unsettled = resp.json()["data"]
resp = client.post(
_SHIPMENTS,
json={
"business_id": biz["id"],
"ship_date": "2026-10-05",
"items": [{"product_name": "t_发2", "quantity": "1", "sale_price": "1.00"}],
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
free_shipment = resp.json()["data"]
_set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0")
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
settlement = resp.json()["data"]
resp = client.post(f"{_SETTLEMENTS}/{settlement['id']}/submit", headers=admin["headers"])
assert resp.status_code == 200, resp.text
assert resp.json()["data"]["status"] == 3
return settlement, biz, unsettled, free_shipment
def test_锁定后_未核算回款禁改禁删(self, client: TestClient, admin: dict) -> None:
_settlement, biz, unsettled, _ship = self._lock_scene(client, admin)
resp = client.put(
f"{_RECEIPTS}/{unsettled['id']}",
json={
"business_id": biz["id"],
"receipt_date": "2026-10-23",
"amount": "88.00",
},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
resp = client.delete(f"{_RECEIPTS}/{unsettled['id']}", headers=admin["headers"])
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
def test_锁定后_发货增改删400_非锁定月放行(self, client: TestClient, admin: dict) -> None:
_settlement, biz, _unsettled, free_shipment = self._lock_scene(client, admin)
items = [{"product_name": "t_发x", "quantity": "1", "sale_price": "1.00"}]
# 新增:落锁定月 400
resp = client.post(
_SHIPMENTS,
json={"business_id": biz["id"], "ship_date": "2026-10-08", "items": items},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
# 新增:非锁定月放行(拦截不误伤其它月份)
resp = client.post(
_SHIPMENTS,
json={"business_id": biz["id"], "ship_date": "2026-11-02", "items": items},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
nov = resp.json()["data"]
# 编辑:把非锁定月的发货单改入锁定月 400
resp = client.put(
f"{_SHIPMENTS}/{nov['id']}",
json={"ship_date": "2026-10-15", "items": items},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
# 编辑:原月即锁定月 400
resp = client.put(
f"{_SHIPMENTS}/{free_shipment['id']}",
json={"ship_date": "2026-10-06", "items": items},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
# 删除:锁定月的发货单 400(拦截先于「被回款引用」判定)
resp = client.delete(f"{_SHIPMENTS}/{free_shipment['id']}", headers=admin["headers"])
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
def test_锁定后_首算与重算400(self, client: TestClient, admin: dict) -> None:
_settlement, biz, unsettled, _ship = self._lock_scene(client, admin)
from app.models import Receipt
with get_session_factory()() as session:
settled_id = int(
session.execute(
select(Receipt.id).where(
Receipt.business_id == biz["id"],
Receipt.settlement_status == 1,
)
).scalar_one()
)
resp = client.post(
f"{_CALC}/receipts/{settled_id}/recalc",
json={"reason": "锁定月重算"},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
resp = client.post(f"{_CALC}/receipts/{unsettled['id']}/calc", headers=admin["headers"])
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]