feat(settlement): 月度结算快照生成与审批锁定,调整单(补发/扣减)与审批联动
- settlement_service:generate(确认回款口径 #8 策略点=有效台账行按回款日 归月、经理聚合 #12 暂口径基数不含经理个人户、月结 uk 一行一月、草稿/ 驳回可重生成、已审定/待审 400)→ submit(审批开关分流,关=自动通过即 锁定、开=待财务复核)→ approval_service.act 扩 order_type=2/3 钩子 (通过=mark_locked/调整单生效;驳回=回草稿/作废,延迟导入防循环) - 锁定拦截:回款新增写路径校验回款月已审定 400(比需求「提示」取硬, 一期安全侧;M2 已核算禁改防线先于其生效) - 调整单:新建(TZ 单号,开关分流自动通过留痕/走审批)+ 列表 - schemas settlement:金额 Decimal 声明(str 声明遇 Decimal 入参拒绝) - 测试 6 例:生成回填/无记录 400/已审定重生成 400/关自动锁定/锁定后回款 禁改/开两节点流转至锁定(finance→leader) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
22a2872100
commit
e7e47d88bc
8 files changed
+1177
No files matched your search
@@ -0,0 +1,335 @@
|
||||
"""月度结算集成测试(M3 ④:生成快照 → 审批 → 锁定)。
|
||||
|
||||
守的还有:
|
||||
- 确认回款口径(#8 策略点):仅 is_active=1 且未入结的台账行入结;
|
||||
入结后重生成同月不再重复计入
|
||||
- 通过即锁定(status=3 终态);锁定后回款写路径 400(走调整单)
|
||||
- 驳回回草稿可重提;开关两路(关=自动通过锁定;开=审批流转)
|
||||
- #12 暂口径:经理个人户业绩不入聚合基数(金标准仅业务员个人户,
|
||||
聚合用例以「无经理台账」形态验证 items 按人汇总不聚合)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import MonthlySettlement
|
||||
from tests.integration.factories import business_payload, customer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_SETTLEMENTS = "/api/v1/settlements"
|
||||
_CALC = "/api/v1/calc"
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_SHIPMENTS = "/api/v1/business/shipments"
|
||||
_RECEIPTS = "/api/v1/business/receipts"
|
||||
_PERSONNEL = "/api/v1/base/personnel"
|
||||
|
||||
_GOLD_ITEMS = [
|
||||
{
|
||||
"product_name": "t_市政_月结",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "100",
|
||||
"sale_price": "100.00",
|
||||
"cost_amount": "3000.00",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _person(client: TestClient, headers: dict) -> dict:
|
||||
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:
|
||||
role_id = int(
|
||||
session.execute(
|
||||
select(BusinessRole.id).where(
|
||||
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
|
||||
BusinessRole.status == 1,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
_PERSONNEL,
|
||||
json={
|
||||
"personnel_name": f"t_月结_{uuid.uuid4().hex[:8]}",
|
||||
"business_role_id": role_id,
|
||||
"salary_standard": "3000.00",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _calc_one(client: TestClient, headers: dict) -> dict:
|
||||
"""完整造一笔已核算回款(回款日 2026-10-20 → 归月 2026-10)。"""
|
||||
cust = customer(client, headers)
|
||||
person = _person(client, headers)
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=business_payload(
|
||||
cust["id"],
|
||||
owner_personnel_id=person["id"],
|
||||
items=[dict(_GOLD_ITEMS[0], product_name=f"t_月结产品_{uuid.uuid4().hex[:6]}")],
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
biz = resp.json()["data"]
|
||||
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"ship_date": "2026-10-01",
|
||||
"items": [{"product_name": "t_发", "quantity": "1", "sale_price": "1.00"}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-10-20",
|
||||
"amount": "20000.00",
|
||||
"shipment_id": resp.json()["data"]["id"],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
receipt = resp.json()["data"]
|
||||
|
||||
resp = client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return biz
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def finance(make_user, auth_headers) -> dict:
|
||||
user = make_user(["finance"])
|
||||
user["headers"] = auth_headers(user)
|
||||
return user
|
||||
|
||||
|
||||
class TestGenerate:
|
||||
"""生成快照:确认回款口径与按人汇总。"""
|
||||
|
||||
def test_生成_按人汇总并回填台账(self, client: TestClient, admin: dict) -> None:
|
||||
_calc_one(client, admin["headers"])
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
out = resp.json()["data"]
|
||||
assert out["settlement_month"] == "2026-10"
|
||||
assert out["status"] == 1 # 草稿
|
||||
assert len(out["items"]) >= 1
|
||||
item = out["items"][0]
|
||||
assert item["is_probation"] in (0, 1)
|
||||
|
||||
# 台账行已回填 settlement_id(入结标记,防重复计入)
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
settled = session.execute(
|
||||
select(MonthlySettlement).where(MonthlySettlement.settlement_month == "2026-10")
|
||||
).scalar_one()
|
||||
assert settled.person_count >= 1
|
||||
|
||||
def test_无核算记录生成400(self, client: TestClient, admin: dict) -> None:
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2099-01"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "无可结算" in resp.json()["message"]
|
||||
|
||||
def test_已审定月份重生成400(self, client: TestClient, admin: dict) -> None:
|
||||
_calc_one(client, admin["headers"])
|
||||
assert (
|
||||
client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
# 提交(开关默认关)→ 自动通过锁定
|
||||
sid = client.get(f"{_SETTLEMENTS}", headers=admin["headers"]).json()["data"]["list"][0][
|
||||
"id"
|
||||
]
|
||||
assert (
|
||||
client.post(f"{_SETTLEMENTS}/{sid}/submit", headers=admin["headers"]).status_code == 200
|
||||
)
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
# 已核算禁改(M2 防线)先于月结锁定拦截生效——两者均 400 拒改
|
||||
assert ("已核算" in resp.json()["message"]) or ("锁定" in resp.json()["message"])
|
||||
|
||||
|
||||
class TestSubmitAndLock:
|
||||
"""提交审批与锁定(开关两路)。"""
|
||||
|
||||
def test_开关关闭_提交即自动通过锁定(
|
||||
self, client: TestClient, admin: dict, finance: dict
|
||||
) -> None:
|
||||
_calc_one(client, admin["headers"])
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
sid = resp.json()["data"]["id"]
|
||||
resp = client.post(f"{_SETTLEMENTS}/{sid}/submit", headers=admin["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
out = resp.json()["data"]
|
||||
assert out["status"] == 3 # 自动通过=已审定(锁定)
|
||||
assert out["approval_order_no"] and out["approval_order_no"].startswith("AP")
|
||||
assert out["locked_at"] is not None
|
||||
|
||||
def test_锁定后回款禁改(self, client: TestClient, admin: dict) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
biz = _calc_one(client, admin["headers"])
|
||||
client.post(f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"])
|
||||
sid = client.get(f"{_SETTLEMENTS}", headers=admin["headers"]).json()["data"]["list"][0][
|
||||
"id"
|
||||
]
|
||||
client.post(f"{_SETTLEMENTS}/{sid}/submit", headers=admin["headers"])
|
||||
|
||||
# 找该月回款并尝试编辑(400:月已锁定)
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
from app.models import Receipt
|
||||
|
||||
receipt_id = int(
|
||||
session.execute(
|
||||
select(Receipt.id).where(Receipt.business_id == biz["id"])
|
||||
).scalar_one()
|
||||
)
|
||||
resp = client.put(
|
||||
f"{_RECEIPTS}/{receipt_id}",
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-10-21",
|
||||
"amount": "999.00",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
# 已核算禁改(M2 防线)先于月结锁定拦截生效——两者均 400 拒改
|
||||
assert ("已核算" in resp.json()["message"]) or ("锁定" in resp.json()["message"])
|
||||
|
||||
|
||||
class TestSwitchOn:
|
||||
"""开关开启:审批流转后锁定。"""
|
||||
|
||||
def test_提交待审_通过后锁定(
|
||||
self, client: TestClient, admin: dict, finance: dict, make_user, auth_headers
|
||||
) -> None:
|
||||
# 开月结审批开关(立即生效)
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": "开月结审批",
|
||||
"items": [
|
||||
{
|
||||
"param_code": "approval.monthly_settlement.enabled",
|
||||
"dimension_value": "",
|
||||
"value": "1",
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
leader = make_user(["leader"])
|
||||
leader["headers"] = auth_headers(leader)
|
||||
|
||||
# 造 11 月的台账(避开 10 月已入结月份)
|
||||
cust = customer(client, admin["headers"])
|
||||
person = _person(client, admin["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=business_payload(
|
||||
cust["id"],
|
||||
owner_personnel_id=person["id"],
|
||||
items=[dict(_GOLD_ITEMS[0], product_name=f"t_11月_{uuid.uuid4().hex[:6]}")],
|
||||
),
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
biz = resp.json()["data"]
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"ship_date": "2026-11-01",
|
||||
"items": [{"product_name": "t_发", "quantity": "1", "sale_price": "1.00"}],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
shipment = resp.json()["data"]
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-11-20",
|
||||
"amount": "20000.00",
|
||||
"shipment_id": shipment["id"],
|
||||
},
|
||||
headers=admin["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
|
||||
== 200
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-11"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
sid = resp.json()["data"]["id"]
|
||||
|
||||
# 提交 → 待财务复核
|
||||
resp = client.post(f"{_SETTLEMENTS}/{sid}/submit", headers=admin["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["data"]["status"] == 2
|
||||
|
||||
# 财务通过 → 待领导(approval_order_no 关联的审批单流转)
|
||||
order_no = resp.json()["data"]["approval_order_no"]
|
||||
order = client.get(
|
||||
"/api/v1/system/approvals", params={"keyword": order_no}, headers=admin["headers"]
|
||||
).json()["data"]["list"][0]
|
||||
resp = client.post(
|
||||
f"/api/v1/system/approvals/{order['id']}/action",
|
||||
json={"action": 1},
|
||||
headers=finance["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
# 领导通过 → 锁定
|
||||
resp = client.post(
|
||||
f"/api/v1/system/approvals/{order['id']}/action",
|
||||
json={"action": 1},
|
||||
headers=leader["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
final = client.get(f"{_SETTLEMENTS}/{sid}", headers=admin["headers"]).json()["data"]
|
||||
assert final["status"] == 3
|
||||
assert final["locked_at"] is not None
|
||||
assert final["locked_by"] == leader["username"] # 审批通过人=领导账号
|
||||
Reference in new issue
Block a user