fix(m3): code-review P0 六项——算错钱与不可用

- 经理聚合分成放大 100 倍:参数按百分数存储(12.5=12.5%),parse_fraction
  后调用方又 ×100、函数内部再 ÷100,净效应「绩效×12.5」;去掉调用方
  ×100,rate_text 改用实际参数原文(原硬编码 12.5%+5% 参数调整后失真)
- 审批联动只认终审:财务节点通过(1→2)不再提前 mark_locked/调整单生效,
  否则领导驳回后 locked_at/locked_by 残留、两级审批形同虚设;驳回在任何
  节点即时联动;mark_rejected 补清锁定字段
- 调整单新建不可用:ENABLE_CODE_BY_TYPE 未登记 type=3 → is_enabled 恒 400;
  补登记 approval.adjustment.enabled。另修复叠加缺陷——开关关闭分支手工
  ApprovalStep(order_id=调整单id),而该外键指向 approval_orders,必然
  errno 1452;统一改走 submit_order(自动通过留痕由它负责)
- 重算双重计提:业务级一次性计提的「进度登记」回款无生效台账行,重算会
  凭空落新行;无旧行一律 400
- 前端重算传错 id:台账行主键当 receipt_id 传给重算接口,会作废无关回款
  的台账;改传 row.receipt_id(聚合行防御)
- quantize 显式 ROUND_HALF_UP:9 处金额 quantize 缺省银行家舍入,
  x.xx5 与四舍五入口径差 1 分(docstring 却声称 HALF_UP)

测试:显式控制审批开关(V8 起种子默认开启,用例不再依赖默认值);
新增回归 4 例(经理聚合比例/调整单即生效/进度登记重算 400/财务节点不提前
锁定),test_calc+test_settlements 22 passed

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-19 11:25:21 +08:00
1 parent 35fe37fb0c
commit cb4f38dfd9
6 files changed
+274 -68

No files matched your search

+12 -3
View File
@@ -64,10 +64,12 @@ from app.services.no_generator import next_no
logger = logging.getLogger(__name__)
# 开关参数编码(与 param_registry 种子同源)
# 开关参数编码(与 param_registry 种子同源;新增审批对象类型必须在此登记,
# 否则 is_enabled 走 BadRequestError 兜底、对应业务接口整体不可用)
ENABLE_CODE_BY_TYPE: dict[int, str] = {
1: "approval.secondary_allocation.enabled",
2: "approval.monthly_settlement.enabled",
3: "approval.adjustment.enabled",
}
# 在途状态集合(同一引用对象至多一张在途单)
@@ -219,6 +221,10 @@ def act(
if order.status == APPROVAL_STATUS_PENDING_FINANCE
else APPROVAL_STATUS_APPROVED
)
# 生效类联动只认**终审**:财务节点通过(1→2)只是流转,不得提前
# 锁定月结/生效调整单——否则领导驳回后锁定字段残留、审批流形同虚设。
# 驳回在任何节点都是终态,即时联动。
final_approved = (not rejected) and order.status == APPROVAL_STATUS_APPROVED
session.add(
ApprovalStep(
@@ -259,12 +265,15 @@ def act(
if target is not None:
if rejected:
settlement_service.mark_rejected(session, target, operator=operator)
else:
elif final_approved:
settlement_service.mark_locked(session, target, operator=operator)
else:
adj = session.get(AdjustmentOrder, order.ref_id)
if adj is not None:
adj.status = ADJ_REJECTED if rejected else ADJUSTMENT_STATUS_EFFECTIVE
if rejected:
adj.status = ADJ_REJECTED
elif final_approved:
adj.status = ADJUSTMENT_STATUS_EFFECTIVE
# 出参(含 steps)在 **commit 之前** 固化:audit 禁止 commit 后再碰
# ORM/查询——commit 后的任何 SELECT 失败都会让「已通过的审批」被记
+23 -8
View File
@@ -15,7 +15,7 @@
import logging
from dataclasses import dataclass
from decimal import Decimal
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from typing import Any
@@ -118,11 +118,17 @@ def calc_receipt(
if business.owner_personnel_id is None:
raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
# 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算
# 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算。
# 无生效行 = 该回款是「进度登记」(业务级一次性计提 #25 的第二笔及
# 以后)——本无独立计提,继续重算会凭空落一行生效行,同业务出现
# 两行生效行导致双重计提 → 一律 400
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)
if old is None:
raise BadRequestError(
"该回款为进度登记(业务已按一次性计提核算),无独立台账行可重算"
)
performance_ledger_repository.mark_inactive(session, old)
# 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效
# 台账行时,本笔回款仅置已核算状态(支付进度),不重复计提
@@ -195,8 +201,11 @@ def calc_receipt(
"revenue": str(row_revenue),
"profit": str(row_profit),
# 行级折算同样固化为两位(to_decimal 对整数值无小数位,
# 快照/台账口径需与列一致)
"converted": str(row_converted.quantize(Decimal("0.01"))),
# 快照/台账口径需与列一致;显式 HALF_UP——Python 默认
# 银行家舍入会让 x.xx5 少 1 分)
"converted": str(
row_converted.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
),
}
)
@@ -244,8 +253,14 @@ def calc_receipt(
snapshot_json["strategy_note"] = _NOTE_MANAGER_PENDING
def q2(value: Decimal | None) -> Decimal | None:
"""金额统一两位小数(HALF_UP):内存/DB/序列化三方一致。"""
return value.quantize(Decimal("0.01")) if value is not None else None
"""金额统一两位小数(HALF_UP):内存/DB/序列化三方一致。
显式传 rounding——quantize 缺省是上下文 ROUND_HALF_EVEN(银行家
舍入),x.xx5 会舍成偶数值,与业务四舍五入口径差 1 分。
"""
if value is None:
return None
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
row = performance_ledger_repository.add(
session,
+52 -45
View File
@@ -9,7 +9,7 @@
"""
import logging
from decimal import Decimal
from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -146,11 +146,19 @@ def generate(
personnel_name=bucket["personnel_name"],
role_code=bucket["role_code"],
calc_type=bucket["calc_type"],
performance_sum=bucket["performance_sum"].quantize(Decimal("0.01")),
payable_sum=bucket["payable_sum"].quantize(Decimal("0.01")),
performance_sum=bucket["performance_sum"].quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
),
payable_sum=bucket["payable_sum"].quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
),
basic_amount=basic,
salary_mode=mode,
net_amount=net.quantize(Decimal("0.01")) if net is not None else None,
net_amount=(
net.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if net is not None
else None
),
is_probation=bucket["is_probation"],
probation_note=note,
snapshot_json={"ledger_ids": bucket["ledger_ids"]},
@@ -160,9 +168,9 @@ def generate(
settlement_repository.replace_items(session, settlement.id, items)
settlement.total_performance = sum(
(i.performance_sum for i in items), Decimal("0")
).quantize(Decimal("0.01"))
).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
settlement.total_payable = sum((i.payable_sum for i in items), Decimal("0")).quantize(
Decimal("0.01")
Decimal("0.01"), rounding=ROUND_HALF_UP
)
settlement.person_count = len({i.personnel_id for i in items})
settlement.snapshot_json = {"note": _SETTLEMENT_NOTE}
@@ -225,8 +233,14 @@ def mark_locked(session: Session, settlement: MonthlySettlement, *, operator: Us
def mark_rejected(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
"""审批驳回钩子:回草稿(可修改后重提)。"""
"""审批驳回钩子:回草稿(可修改后重提)。
锁定信息一并清空:草稿态残留 locked_at/locked_by 会让详情页展示
「已锁定」的假象(历史缺陷路径下驳回前可能已被提前锁定)。
"""
settlement.status = SETTLEMENT_STATUS_DRAFT
settlement.locked_at = None
settlement.locked_by = None
def ensure_month_not_locked(session: Session, month: str) -> None:
@@ -315,8 +329,12 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
aggregates: list = []
snapshot = resolve_snapshot(session, date.today())
team_pct = parse_fraction(snapshot.get("manager.share.team"))
company_pct = parse_fraction(snapshot.get("manager.share.company"))
# 参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5,
# manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍)
team_text = snapshot.get("manager.share.team")
company_text = snapshot.get("manager.share.company")
team_pct = parse_fraction(team_text)
company_pct = parse_fraction(company_text)
for manager_id, bucket in managers.items():
manager = session.get(Personnel, manager_id)
if manager is None:
@@ -340,8 +358,8 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
share = manager_team_share(
bucket["performance"],
company_base,
team_pct=team_pct * 100,
company_pct=company_pct * 100,
team_pct=team_pct,
company_pct=company_pct,
)
member_names = "、".join(sorted(bucket["names"])) or "(无成员)"
aggregates.append(
@@ -356,11 +374,12 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
customer_type="personal",
product_category="mixed",
receipt_date=date.today(),
rate_text="聚合:12.5%+5%",
# 比例原文取实际参数(勿硬编码默认值——参数调整后台账说明会失真)
rate_text=f"聚合:{team_text}%+{company_text}%",
converted_profit=None,
performance_amount=share.quantize(Decimal("0.01")),
performance_amount=share.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
barter_applied=0,
payable_amount=share.quantize(Decimal("0.01")),
payable_amount=share.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
settlement_month=month,
is_probation=1 if manager.is_probation == 1 else 0,
snapshot_json={
@@ -412,13 +431,8 @@ def create_adjustment(
):
"""新建调整单:开关分流(关=提交即生效留痕;开=待审批)。"""
from app.core.constants import NO_PREFIX_ADJUSTMENT
from app.models import AdjustmentOrder, ApprovalStep
from app.models.approval import (
APPROVAL_ACTION_AUTO_APPROVE,
APPROVAL_NODE_FINANCE,
APPROVAL_NODE_LEADER,
APPROVAL_TYPE_ADJUSTMENT,
)
from app.models import AdjustmentOrder
from app.models.approval import APPROVAL_STATUS_APPROVED, APPROVAL_TYPE_ADJUSTMENT
from app.models.settlement import (
ADJUSTMENT_STATUS_EFFECTIVE,
ADJUSTMENT_STATUS_PENDING,
@@ -459,30 +473,23 @@ def create_adjustment(
session.add(order)
session.flush()
if approval_service.is_enabled(session, APPROVAL_TYPE_ADJUSTMENT):
order.status = ADJUSTMENT_STATUS_PENDING
session.flush()
approval_service.submit_order(
session,
order_type=APPROVAL_TYPE_ADJUSTMENT,
ref_id=order.id,
refer_no=order.order_no,
submitter=operator,
)
else:
order.status = ADJUSTMENT_STATUS_EFFECTIVE
session.add_all(
[
ApprovalStep(
order_id=order.id,
node=node,
action=APPROVAL_ACTION_AUTO_APPROVE,
operator_name="系统",
)
for node in (APPROVAL_NODE_FINANCE, APPROVAL_NODE_LEADER)
]
)
session.flush()
# 开关分流与留痕统一交给 submit_order:开关关闭时它自动通过并写两条
# 「系统」留痕。**不得手工插 ApprovalStep** —— approval_steps.order_id
# 外键指向 approval_orders(不是 adjustment_orders),用调整单 id 落步
# 必然外键失败(历史缺陷:关开关时新建调整单不可用)
approval_order = approval_service.submit_order(
session,
order_type=APPROVAL_TYPE_ADJUSTMENT,
ref_id=order.id,
refer_no=order.order_no,
submitter=operator,
)
order.status = (
ADJUSTMENT_STATUS_EFFECTIVE
if approval_order.status == APPROVAL_STATUS_APPROVED
else ADJUSTMENT_STATUS_PENDING
)
session.flush()
session.commit()
logger.info("调整单创建 | order=%s | status=%s", order.order_no, order.status)
return {