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>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-16 21:49:44 +08:00
1 parent df1a3839a1
commit 22a2872100
4 files changed
+288 -24

No files matched your search

+93 -16
View File
@@ -74,31 +74,42 @@ def calc_receipt(
*,
operator: User,
meta: RequestMeta | None = None,
recalc_reason: str | None = None,
) -> CalcReceiptOut:
"""单笔回款核算:回款 → 发货批次 → 业务 → 明细行逐行折算 → 台账固化。
"""单笔回款核算 / 重算(recalc_reason 非 None 时为重算模式)。
防重前两层在本函数(FOR UPDATE 锁行 + settlement_status=0 校验),
第三层是 CAS UPDATE(rowcount=0 即并发撞车 400)。全程一个 audit
事务(MODULE_CALC),出参在 commit 前固化。
防重前两层在本函数(FOR UPDATE 锁行 + settlement_status 校验),第三层
是落台账 uk(receipt_id, calc_version)。全程一个 audit 事务,出参在
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(
session,
operator=operator,
record=AuditRecord(
module=MODULE_CALC,
action="核算",
summary=f"核算回款 #{receipt_id}",
request_data={"receipt_id": receipt_id},
action=action,
summary=f"{action}回款 #{receipt_id}",
request_data={
"receipt_id": receipt_id,
**({"reason": recalc_reason} if is_recalc else {}),
},
),
meta=meta,
):
receipt = session.get(Receipt, receipt_id, with_for_update=True)
if receipt is None:
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("该回款已核算,禁止重复核算(重算请走重算入口)")
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:
raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
# 核算粒度 #25(一期口径):业务级一次性计提——已有生效台账行时,
# 本笔回款仅置已核算状态(支付进度),不重复计提
repeated = performance_ledger_repository.has_active_for_business(
session, business.id, calc_type=CALC_TYPE_RECEIPT
)
# 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算
if is_recalc:
old = performance_ledger_repository.get_active_by_receipt(session, receipt_id)
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:
_mark_settled(session, receipt)
row = performance_ledger_repository.get_active_by_receipt_of_business(
@@ -232,6 +251,7 @@ def calc_receipt(
session,
PerformanceLedger(
receipt_id=receipt.id,
calc_version=performance_ledger_repository.next_version(session, receipt.id),
calc_type=CALC_TYPE_RECEIPT,
business_id=business.id,
business_no=business.business_no,
@@ -254,9 +274,13 @@ def calc_receipt(
is_probation=1 if person.is_probation == 1 else 0,
missing_cost=1 if missing_cost else 0,
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()
out = CalcReceiptOut(ledger=_to_out(row), repeated=False)
session.commit()
@@ -358,6 +382,59 @@ def _row_profit(revenue: Decimal, cost: Decimal) -> Decimal:
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):
"""分页查询台账(api 层转发)。"""
rows = performance_ledger_repository.list_ledger(session, query)