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:
parent
df1a3839a1
commit
22a2872100
@ -8,9 +8,10 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
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.models import User
|
||||
from app.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery
|
||||
@ -19,6 +20,8 @@ from app.services import calc_service
|
||||
router = APIRouter(prefix="/calc", tags=["绩效工资 · 绩效核算"])
|
||||
|
||||
_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="绩效台账")
|
||||
@ -55,3 +58,50 @@ def calc_receipt(
|
||||
calc_service.calc_receipt(session, receipt_id, operator=current_user, meta=meta),
|
||||
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:
|
||||
"""某回款的下一个核算版本号(max+1;无行则 1)。"""
|
||||
"""某回款的下一个核算版本号(max+1;无行时 1)。"""
|
||||
current = session.execute(
|
||||
select(func.max(PerformanceLedger.calc_version)).where(
|
||||
PerformanceLedger.receipt_id == receipt_id
|
||||
)
|
||||
).scalar_one()
|
||||
return int(current) + 1
|
||||
return int(current or 0) + 1
|
||||
|
||||
|
||||
def list_ledger(session: Session, query: LedgerQuery) -> list[PerformanceLedger]:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -119,6 +119,14 @@ def _calc(client: TestClient, headers: dict, receipt_id: int):
|
||||
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 = [
|
||||
{
|
||||
"product_name": "t_市政_金标",
|
||||
@ -141,7 +149,7 @@ class TestGoldenStandard:
|
||||
"""金标准:双类别混合业务复算到分(Decimal 精确断言)。"""
|
||||
|
||||
def test_复算到分_全字段固化(self, client: TestClient, admin: dict) -> None:
|
||||
biz, receipt = _create_full_chain(
|
||||
_biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
@ -185,7 +193,7 @@ class TestGoldenStandard:
|
||||
assert detail["settlement_status"] == 1
|
||||
|
||||
def test_已核算回款再核算400(self, client: TestClient, admin: dict) -> None:
|
||||
biz, receipt = _create_full_chain(
|
||||
_biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
@ -288,7 +296,7 @@ class TestGuards:
|
||||
"cost_amount": None,
|
||||
}
|
||||
]
|
||||
biz, receipt = _create_full_chain(
|
||||
_biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=items,
|
||||
@ -315,7 +323,7 @@ class TestGuards:
|
||||
"cost_amount": "9000.00",
|
||||
}
|
||||
]
|
||||
biz, receipt = _create_full_chain(
|
||||
_biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=items,
|
||||
@ -333,7 +341,7 @@ class TestGuards:
|
||||
def test_准入_normal403_marketing_ops可用(
|
||||
self, client: TestClient, admin: dict, normal: dict, make_user, auth_headers
|
||||
) -> None:
|
||||
biz, receipt = _create_full_chain(
|
||||
_biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
@ -368,3 +376,132 @@ class TestGuards:
|
||||
assert resp.status_code == 200
|
||||
rows = resp.json()["data"]["list"]
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user