feat(calc): 重算授权留痕与批量核算

- calc_receipt 双模式:recalc_reason 非 None=重算(audit action=重算/
  reason 留痕、作废旧版本行、calc_version 自动递增、CAS 仅首算执行——
  重算状态本就是已核算,误跑 CAS 会误报并发撞车)
- recalc_receipt 薄委托(reason 必填、未核算过 400);重算授权
  require_roles(admin, finance),营销后勤不可重算
- batch_calc:逐笔独立事务不开外层 audit(嵌套禁令),逐笔成败清单
  (同业务第二笔回款走 #25 重复分支 ok=True/repeated=True)
- 测试 +5:重算版本对/原因缺失 400/未核算重算 400/越权 403/批量清单
- 修 next_version 空表 max NULL → int(None) TypeError

Co-Authored-By: Claude Code <noreply@anthropic.com>
这个提交包含在:
liuwenxin和Claude Code 于 2026-10-16 21:49:44 +08:00 提交
1 父节点 df1a3839a1
当前提交 22a2872100
4 个文件已更改
+288 -24

没有与搜索条件相匹配的文件

+51 -1
查看文件
@@ -8,9 +8,10 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel, Field
from app.api.deps import DbSession, RequestMetaDep, require_roles from app.api.deps import DbSession, RequestMetaDep, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_MARKETING_OPS from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_MARKETING_OPS
from app.core.response import ApiResponse, PageResult, ok from app.core.response import ApiResponse, PageResult, ok
from app.models import User from app.models import User
from app.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery from app.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery
@@ -19,6 +20,8 @@ from app.services import calc_service
router = APIRouter(prefix="/calc", tags=["绩效工资 · 绩效核算"]) router = APIRouter(prefix="/calc", tags=["绩效工资 · 绩效核算"])
_CalcWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))] _CalcWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
# 重算是授权动作:管理员/财务(营销后勤不可重算)
_RecalcUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE))]
@router.get("/ledger", response_model=ApiResponse[PageResult[LedgerOut]], summary="绩效台账") @router.get("/ledger", response_model=ApiResponse[PageResult[LedgerOut]], summary="绩效台账")
@@ -55,3 +58,50 @@ def calc_receipt(
calc_service.calc_receipt(session, receipt_id, operator=current_user, meta=meta), calc_service.calc_receipt(session, receipt_id, operator=current_user, meta=meta),
message="核算完成", message="核算完成",
) )
class RecalcIn(BaseModel):
"""重算入参:原因必填(重算授权留痕)。"""
reason: str = Field(min_length=1, max_length=255, description="重算原因")
class BatchIn(BaseModel):
"""批量核算入参。"""
receipt_ids: list[int] = Field(min_length=1, description="回款 ID 列表")
@router.post(
"/receipts/{receipt_id}/recalc",
response_model=ApiResponse[CalcReceiptOut],
summary="重算回款",
)
def recalc_receipt(
receipt_id: Annotated[int, Path(ge=1)],
payload: RecalcIn,
session: DbSession,
current_user: _RecalcUser,
meta: RequestMetaDep,
) -> ApiResponse[CalcReceiptOut]:
"""重算(管理员/财务):作废旧版本、按当前参数落新版本并留痕。"""
return ok(
calc_service.recalc_receipt(
session, receipt_id, payload.reason, operator=current_user, meta=meta
),
message="重算完成",
)
@router.post("/batch", response_model=ApiResponse[list[dict]], summary="批量核算")
def batch_calc(
payload: BatchIn,
session: DbSession,
current_user: _CalcWriter,
meta: RequestMetaDep,
) -> ApiResponse[list[dict]]:
"""批量核算:逐笔独立事务,返回逐笔成败清单(失败不中断其它笔)。"""
return ok(
calc_service.batch_calc(session, payload.receipt_ids, operator=current_user, meta=meta),
message="批量核算完成",
)
@@ -74,13 +74,13 @@ def mark_inactive(session: Session, row: PerformanceLedger) -> None:
def next_version(session: Session, receipt_id: int) -> int: def next_version(session: Session, receipt_id: int) -> int:
"""某回款的下一个核算版本号(max+1;无行则 1)。""" """某回款的下一个核算版本号(max+1;无行时 1)。"""
current = session.execute( current = session.execute(
select(func.max(PerformanceLedger.calc_version)).where( select(func.max(PerformanceLedger.calc_version)).where(
PerformanceLedger.receipt_id == receipt_id PerformanceLedger.receipt_id == receipt_id
) )
).scalar_one() ).scalar_one()
return int(current) + 1 return int(current or 0) + 1
def list_ledger(session: Session, query: LedgerQuery) -> list[PerformanceLedger]: def list_ledger(session: Session, query: LedgerQuery) -> list[PerformanceLedger]:
+93 -16
查看文件
@@ -74,31 +74,42 @@ def calc_receipt(
*, *,
operator: User, operator: User,
meta: RequestMeta | None = None, meta: RequestMeta | None = None,
recalc_reason: str | None = None,
) -> CalcReceiptOut: ) -> CalcReceiptOut:
"""单笔回款核算:回款 → 发货批次 → 业务 → 明细行逐行折算 → 台账固化。 """单笔回款核算 / 重算(recalc_reason 非 None 时为重算模式)。
防重前两层在本函数(FOR UPDATE 锁行 + settlement_status=0 校验), 防重前两层在本函数(FOR UPDATE 锁行 + settlement_status 校验),第三层
第三层是 CAS UPDATE(rowcount=0 即并发撞车 400)。全程一个 audit 是落台账 uk(receipt_id, calc_version)。全程一个 audit 事务,出参在
事务(MODULE_CALC),出参在 commit 前固化。 commit 前固化。
:raises BadRequestError: 已核算 / 未指定发货批次 / 归属未指定 / 首算模式:回款必须未核算;业务级一次性计提(#25)——已有生效台账行时
无明细行 / 并发撞车 仅登记进度(repeated=True)。
重算模式:回款必须已核算过;作废旧版本行(is_active=0),按当前参数
落 calc_version+1 新行,calc_reason 留痕。
""" """
is_recalc = recalc_reason is not None
action = "重算" if is_recalc else "核算"
with audit_service.audit( with audit_service.audit(
session, session,
operator=operator, operator=operator,
record=AuditRecord( record=AuditRecord(
module=MODULE_CALC, module=MODULE_CALC,
action="核算", action=action,
summary=f"核算回款 #{receipt_id}", summary=f"{action}回款 #{receipt_id}",
request_data={"receipt_id": receipt_id}, request_data={
"receipt_id": receipt_id,
**({"reason": recalc_reason} if is_recalc else {}),
},
), ),
meta=meta, meta=meta,
): ):
receipt = session.get(Receipt, receipt_id, with_for_update=True) receipt = session.get(Receipt, receipt_id, with_for_update=True)
if receipt is None: if receipt is None:
raise BadRequestError("回款不存在") raise BadRequestError("回款不存在")
if receipt.settlement_status == SETTLEMENT_DONE: if is_recalc:
if receipt.settlement_status == SETTLEMENT_PENDING:
raise BadRequestError("该回款尚未核算过(无旧版本可重算,走首次核算)")
elif receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("该回款已核算,禁止重复核算(重算请走重算入口)") raise BadRequestError("该回款已核算,禁止重复核算(重算请走重算入口)")
business = session.get(Business, receipt.business_id, with_for_update=True) business = session.get(Business, receipt.business_id, with_for_update=True)
@@ -107,11 +118,19 @@ def calc_receipt(
if business.owner_personnel_id is None: if business.owner_personnel_id is None:
raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)") raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
# 核算粒度 #25(一期口径):业务级一次性计提——已有生效台账行时, # 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算
# 本笔回款仅置已核算状态(支付进度),不重复计提 if is_recalc:
repeated = performance_ledger_repository.has_active_for_business( old = performance_ledger_repository.get_active_by_receipt(session, receipt_id)
session, business.id, calc_type=CALC_TYPE_RECEIPT if old is not None:
) performance_ledger_repository.mark_inactive(session, old)
# 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效
# 台账行时,本笔回款仅置已核算状态(支付进度),不重复计提
repeated = False
if not is_recalc:
repeated = performance_ledger_repository.has_active_for_business(
session, business.id, calc_type=CALC_TYPE_RECEIPT
)
if repeated: if repeated:
_mark_settled(session, receipt) _mark_settled(session, receipt)
row = performance_ledger_repository.get_active_by_receipt_of_business( row = performance_ledger_repository.get_active_by_receipt_of_business(
@@ -232,6 +251,7 @@ def calc_receipt(
session, session,
PerformanceLedger( PerformanceLedger(
receipt_id=receipt.id, receipt_id=receipt.id,
calc_version=performance_ledger_repository.next_version(session, receipt.id),
calc_type=CALC_TYPE_RECEIPT, calc_type=CALC_TYPE_RECEIPT,
business_id=business.id, business_id=business.id,
business_no=business.business_no, business_no=business.business_no,
@@ -254,9 +274,13 @@ def calc_receipt(
is_probation=1 if person.is_probation == 1 else 0, is_probation=1 if person.is_probation == 1 else 0,
missing_cost=1 if missing_cost else 0, missing_cost=1 if missing_cost else 0,
snapshot_json=snapshot_json, snapshot_json=snapshot_json,
calc_reason=recalc_reason,
), ),
) )
_mark_settled(session, receipt) # CAS 仅首算执行:重算模式状态本就是已核算(UPDATE ... WHERE status=0
# 会 rowcount=0 误报并发撞车)
if not is_recalc:
_mark_settled(session, receipt)
session.flush() session.flush()
out = CalcReceiptOut(ledger=_to_out(row), repeated=False) out = CalcReceiptOut(ledger=_to_out(row), repeated=False)
session.commit() session.commit()
@@ -358,6 +382,59 @@ def _row_profit(revenue: Decimal, cost: Decimal) -> Decimal:
return revenue - cost return revenue - cost
def recalc_receipt(
session: Session,
receipt_id: int,
reason: str,
*,
operator: User,
meta: RequestMeta | None = None,
) -> CalcReceiptOut:
"""重算(授权动作,管理员/财务):作废旧版本行、按当前参数重算新版本。
薄委托 calc_receipt(recalc_reason=...)——折算体只有一份;所属月已审定
的拦截在月结(T5)接入后补入本函数。
"""
if not reason or not reason.strip():
raise BadRequestError("重算必须填写原因")
return calc_receipt(
session, receipt_id, operator=operator, meta=meta, recalc_reason=reason.strip()
)
def batch_calc(
session: Session,
receipt_ids: list[int],
*,
operator: User,
meta: RequestMeta | None = None,
) -> list[dict]:
"""批量核算:逐笔独立事务(各笔自带 audit),返回逐笔成败清单。
**不开外层 audit**——外层包 audit 会违反嵌套禁令,且单笔失败不应
回滚其它笔的核算结果。
"""
results: list[dict] = []
for rid in receipt_ids:
try:
out = calc_receipt(session, rid, operator=operator, meta=meta)
results.append(
{
"receipt_id": rid,
"ok": True,
"repeated": out.repeated,
"ledger_id": out.ledger.id,
}
)
except Exception as exc:
results.append(
{"receipt_id": rid, "ok": False, "error": f"{type(exc).__name__}: {exc}"}
)
ok_count = sum(1 for r in results if r["ok"])
logger.info("批量核算 | 总数=%s 成功=%s", len(results), ok_count)
return results
def list_ledger_page(session: Session, query): def list_ledger_page(session: Session, query):
"""分页查询台账(api 层转发)。""" """分页查询台账(api 层转发)。"""
rows = performance_ledger_repository.list_ledger(session, query) rows = performance_ledger_repository.list_ledger(session, query)
+142 -5
查看文件
@@ -119,6 +119,14 @@ def _calc(client: TestClient, headers: dict, receipt_id: int):
return client.post(f"{_CALC}/receipts/{receipt_id}/calc", headers=headers) return client.post(f"{_CALC}/receipts/{receipt_id}/calc", headers=headers)
@pytest.fixture
def finance(make_user, auth_headers) -> dict:
"""财务角色账号(重算授权角色之一)。"""
user = make_user(["finance"])
user["headers"] = auth_headers(user)
return user
_GOLD_ITEMS = [ _GOLD_ITEMS = [
{ {
"product_name": "t_市政_金标", "product_name": "t_市政_金标",
@@ -141,7 +149,7 @@ class TestGoldenStandard:
"""金标准:双类别混合业务复算到分(Decimal 精确断言)。""" """金标准:双类别混合业务复算到分(Decimal 精确断言)。"""
def test_复算到分_全字段固化(self, client: TestClient, admin: dict) -> None: def test_复算到分_全字段固化(self, client: TestClient, admin: dict) -> None:
biz, receipt = _create_full_chain( _biz, receipt = _create_full_chain(
client, client,
admin["headers"], admin["headers"],
items=_GOLD_ITEMS, items=_GOLD_ITEMS,
@@ -185,7 +193,7 @@ class TestGoldenStandard:
assert detail["settlement_status"] == 1 assert detail["settlement_status"] == 1
def test_已核算回款再核算400(self, client: TestClient, admin: dict) -> None: def test_已核算回款再核算400(self, client: TestClient, admin: dict) -> None:
biz, receipt = _create_full_chain( _biz, receipt = _create_full_chain(
client, client,
admin["headers"], admin["headers"],
items=_GOLD_ITEMS, items=_GOLD_ITEMS,
@@ -288,7 +296,7 @@ class TestGuards:
"cost_amount": None, "cost_amount": None,
} }
] ]
biz, receipt = _create_full_chain( _biz, receipt = _create_full_chain(
client, client,
admin["headers"], admin["headers"],
items=items, items=items,
@@ -315,7 +323,7 @@ class TestGuards:
"cost_amount": "9000.00", "cost_amount": "9000.00",
} }
] ]
biz, receipt = _create_full_chain( _biz, receipt = _create_full_chain(
client, client,
admin["headers"], admin["headers"],
items=items, items=items,
@@ -333,7 +341,7 @@ class TestGuards:
def test_准入_normal403_marketing_ops可用( def test_准入_normal403_marketing_ops可用(
self, client: TestClient, admin: dict, normal: dict, make_user, auth_headers self, client: TestClient, admin: dict, normal: dict, make_user, auth_headers
) -> None: ) -> None:
biz, receipt = _create_full_chain( _biz, receipt = _create_full_chain(
client, client,
admin["headers"], admin["headers"],
items=_GOLD_ITEMS, items=_GOLD_ITEMS,
@@ -368,3 +376,132 @@ class TestGuards:
assert resp.status_code == 200 assert resp.status_code == 200
rows = resp.json()["data"]["list"] rows = resp.json()["data"]["list"]
assert rows and rows[0]["business_no"] == biz["business_no"] assert rows and rows[0]["business_no"] == biz["business_no"]
class TestRecalc:
"""重算:授权动作,作废旧版本、落新版本并留痕。"""
def test_重算_新版本生效_旧版本保留(
self, client: TestClient, admin: dict, finance: dict
) -> None:
_biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="20000.00",
)
assert _calc(client, admin["headers"], receipt["id"]).status_code == 200
fin = finance
resp = client.post(
f"{_CALC}/receipts/{receipt['id']}/recalc",
json={"reason": "成本录入修正"},
headers=fin["headers"],
)
assert resp.status_code == 200, resp.text
factory = get_session_factory()
with factory() as session:
rows = (
session.execute(
select(PerformanceLedger)
.where(PerformanceLedger.receipt_id == receipt["id"])
.order_by(PerformanceLedger.calc_version)
)
.scalars()
.all()
)
assert [(r.calc_version, r.is_active) for r in rows] == [(1, 0), (2, 1)]
assert rows[1].calc_reason == "成本录入修正"
# 详情回显的是新版本
assert (
client.get(
f"{_CALC}/ledger",
params={"receipt_id": receipt["id"], "is_active": 1},
headers=admin["headers"],
).json()["data"]["list"][0]["calc_version"]
== 2
)
def test_重算_原因缺失400(self, client: TestClient, admin: dict) -> None:
_biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="100.00",
)
assert _calc(client, admin["headers"], receipt["id"]).status_code == 200
resp = client.post(
f"{_CALC}/receipts/{receipt['id']}/recalc",
json={"reason": " "},
headers=admin["headers"],
)
assert resp.status_code == 400
def test_未核算过_重算400(self, client: TestClient, admin: dict) -> None:
_biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="100.00",
)
resp = client.post(
f"{_CALC}/receipts/{receipt['id']}/recalc",
json={"reason": "无旧版本"},
headers=admin["headers"],
)
assert resp.status_code == 400
assert "尚未核算" in resp.json()["message"]
def test_重算_普通用户与营销后勤403(
self, client: TestClient, normal: dict, make_user, auth_headers
) -> None:
ops = make_user(["marketing_ops"])
ops["headers"] = auth_headers(ops)
for headers in (normal["headers"], ops["headers"]):
resp = client.post(f"{_CALC}/receipts/1/recalc", json={"reason": "x"}, headers=headers)
assert resp.status_code == 403
class TestBatch:
"""批量核算:逐笔独立事务,失败不中断。"""
def test_批量_逐笔成败清单(self, client: TestClient, admin: dict) -> None:
biz, r1 = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="20000.00",
)
# 同业务第二笔回款:核算走 #25 重复分支(ok=True、repeated=True)
resp = client.post(
_RECEIPTS,
json={
"business_id": biz["id"],
"receipt_date": "2026-10-25",
"amount": "100.00",
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
r2 = resp.json()["data"]
resp = client.post(
f"{_CALC}/batch",
json={"receipt_ids": [r1["id"], r2["id"], 999999]},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
results = resp.json()["data"]
by_id = {r["receipt_id"]: r for r in results}
assert by_id[r1["id"]]["ok"] is True and by_id[r1["id"]]["repeated"] is False
assert by_id[r2["id"]]["ok"] is True and by_id[r2["id"]]["repeated"] is True
assert by_id[999999]["ok"] is False