PayrollManagementSystem/backend/app/services/settlement_service.py
liuwenxin cb4f38dfd9 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>
2026-10-19 11:25:21 +08:00

531 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""月度结算服务(M3 ④):生成快照 → 提交审批 → 通过即锁定。
确认回款口径(澄清 #8 策略点,2026-10-16 暂定):settlement_status=1 的
**有效台账行**(is_active=1、settlement_id IS NULL=未被任何月结固化),
按 settlement_month(回款日所在月)归集。答复后只改 ``_confirm_scope``。
通过即锁定(status=3 终态):锁定后源数据(回款/发货)写路径由
``ensure_month_not_locked`` 拦截;调整走调整单(T6)。
"""
import logging
from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.constants import (
MODULE_SETTLEMENT,
)
from app.core.exceptions import BadRequestError, NotFoundError
from app.models import (
MonthlySettlement,
Personnel,
SettlementItem,
User,
)
from app.models.approval import APPROVAL_TYPE_MONTHLY_SETTLEMENT
from app.models.performance_ledger import CALC_TYPE_MANAGER_AGGREGATE
from app.models.settlement import (
SETTLEMENT_STATUS_APPROVED,
SETTLEMENT_STATUS_DRAFT,
SETTLEMENT_STATUS_PENDING,
SETTLEMENT_STATUS_REJECTED,
)
from app.repositories import settlement_repository
from app.schemas.settlement import (
SettlementDetailOut,
SettlementOut,
SettlementQuery,
)
from app.services import approval_service, audit_service
from app.services.approval_service import AuditRecord, RequestMeta
from app.services.formula import manager_team_share, monthly_salary
logger = logging.getLogger(__name__)
# 审批对象类型引用(order_type=2 月结、3 调整单)
_SETTLEMENT_ORDER_TYPE = APPROVAL_TYPE_MONTHLY_SETTLEMENT
_SETTLEMENT_NOTE = "确认回款口径(#8 策略点):settlement_status=1 的有效台账行按回款日归月"
def generate(
session: Session,
month: str,
*,
operator: User,
meta: RequestMeta | None = None,
) -> SettlementOut:
"""生成月度结算快照(草稿):按确认回款口径取有效台账行,按人汇总。
- 同月已有月结行(任意状态)时 400——一行一月,重生成走「驳回/草稿态
重新生成」(先清 items 重算头表)
- 经理聚合(calc_type=2)在生成时计算(#12 暂口径:基数不含经理个人户)
"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SETTLEMENT,
action="生成月结",
summary=f"生成 {month} 月度结算快照",
request_data={"month": month},
),
meta=meta,
):
existing = settlement_repository.get_by_month(session, month)
if existing is not None:
if existing.status == SETTLEMENT_STATUS_APPROVED:
raise BadRequestError(f"{month} 已审定锁定,不可重新生成(调整走调整单)")
if existing.status == SETTLEMENT_STATUS_PENDING:
raise BadRequestError(f"{month} 有待审批的月结,不可重新生成")
# 草稿/已驳回:允许重新生成(清明细重算)
ledger_rows = _confirm_scope(session, month)
if not ledger_rows:
raise BadRequestError(f"{month} 无可结算的核算记录(先在台账完成核算)")
# 经理团队聚合(#12 策略点:基数不含经理个人户业绩)
aggregate_rows = _build_manager_aggregates(session, ledger_rows, month)
settlement = settlement_repository.get_by_month(session, month)
is_new = settlement is None
if is_new:
settlement = MonthlySettlement(
settlement_month=month,
submitter_id=operator.id,
submitter_name=operator.username,
status=SETTLEMENT_STATUS_DRAFT,
)
settlement_repository.add(session, settlement)
else:
settlement.submitter_id = operator.id
settlement.submitter_name = operator.username
settlement.status = SETTLEMENT_STATUS_DRAFT
# 按人汇总(台账行:personnel+calc_type 维度)
by_person: dict[tuple[int, int], dict] = {}
for row in ledger_rows + aggregate_rows:
key = (row.personnel_id, row.calc_type)
bucket = by_person.setdefault(
key,
{
"personnel_id": row.personnel_id,
"personnel_name": row.personnel_name,
"role_code": row.role_code,
"calc_type": row.calc_type,
"performance_sum": Decimal("0"),
"payable_sum": Decimal("0"),
"ledger_ids": [],
"is_probation": row.is_probation,
},
)
bucket["performance_sum"] += row.performance_amount or Decimal("0")
bucket["payable_sum"] += row.payable_amount or Decimal("0")
bucket["ledger_ids"].append(row.id)
items: list[SettlementItem] = []
for (_, _), bucket in sorted(by_person.items()):
person = session.get(Personnel, bucket["personnel_id"])
basic = person.salary_standard if person else None
mode = _salary_mode_of(session, bucket["role_code"])
net = (
monthly_salary(bucket["payable_sum"], basic, mode)
if person and person.is_probation != 1
else None
)
note = None
if person and person.is_probation == 1:
note = (
f"试用期绩效照算不发放;年累计超 {_probation_threshold(session)} 元部分"
"年底一次性结算"
)
items.append(
SettlementItem(
personnel_id=bucket["personnel_id"],
personnel_name=bucket["personnel_name"],
role_code=bucket["role_code"],
calc_type=bucket["calc_type"],
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"), 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"]},
)
)
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"), rounding=ROUND_HALF_UP)
settlement.total_payable = sum((i.payable_sum for i in items), Decimal("0")).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
settlement.person_count = len({i.personnel_id for i in items})
settlement.snapshot_json = {"note": _SETTLEMENT_NOTE}
session.commit()
logger.info("月结生成 | month=%s | items=%s", month, len(items))
return _detail_of(session, settlement)
def submit(
session: Session,
settlement_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> SettlementDetailOut:
"""提交审批:开关分流(关=自动通过并锁定;开=待财务复核)。"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SETTLEMENT,
action="提交月结",
summary=f"提交月结 #{settlement_id}",
request_data={"settlement_id": settlement_id},
),
meta=meta,
):
settlement = settlement_repository.get_by_id(session, settlement_id)
if settlement is None:
raise NotFoundError("月结不存在")
if settlement.status not in (SETTLEMENT_STATUS_DRAFT, SETTLEMENT_STATUS_REJECTED):
raise BadRequestError("仅草稿或已驳回状态可提交")
order = approval_service.submit_order(
session,
order_type=_SETTLEMENT_ORDER_TYPE,
ref_id=settlement.id,
refer_no=f"JS{settlement.settlement_month.replace('-', '')}",
submitter=operator,
)
settlement.approval_order_no = order.order_no
if order.status == SETTLEMENT_STATUS_APPROVED:
mark_locked(session, settlement, operator=operator)
session.commit()
logger.info("月结自动通过并锁定 | %s", settlement.settlement_month)
return _detail_of(session, settlement)
settlement.status = SETTLEMENT_STATUS_PENDING
session.commit()
return _detail_of(session, settlement)
def mark_locked(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
"""审批通过钩子(由 approval_service.act 回调):置已审定+锁定信息。"""
from datetime import datetime
settlement.status = SETTLEMENT_STATUS_APPROVED
settlement.locked_at = datetime.now()
settlement.locked_by = operator.username
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:
"""锁定拦截(回款/发货写路径调用):单据日期所在月已审定 → 400。"""
settlement = settlement_repository.get_by_month(session, month)
if settlement is not None and settlement.status == SETTLEMENT_STATUS_APPROVED:
raise BadRequestError(f"{month} 已月结锁定,源数据不可修改(调整走调整单)")
def get_detail(session: Session, settlement_id: int) -> SettlementDetailOut:
settlement = settlement_repository.get_by_id(session, settlement_id)
if settlement is None:
raise NotFoundError("月结不存在")
return _detail_of(session, settlement)
def list_page(session: Session, query: SettlementQuery):
from app.core.response import PageResult
from app.schemas.settlement import SettlementOut
rows = settlement_repository.list_settlements(session, query)
total = settlement_repository.count_settlements(session, query)
return PageResult[SettlementOut](
records=[SettlementOut.model_validate(r) for r in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
# ---------- 内部 ----------
def _confirm_scope(session: Session, month: str) -> list:
"""确认回款口径(#8 策略点):本月有效台账行(未入结)。"""
from app.models import PerformanceLedger
return list(
session.execute(
select(PerformanceLedger)
.where(
PerformanceLedger.settlement_month == month,
PerformanceLedger.is_active == 1,
PerformanceLedger.settlement_id.is_(None),
)
.order_by(PerformanceLedger.id)
).scalars()
)
def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -> list:
"""按经理生成团队聚合行(calc_type=2)。
#12 策略点:聚合基数**不含**经理个人户业绩(customer_type=
manager_personal 的行按经理个人户单笔处理,已落台账但此处不入基数)。
经理 = 本月台账行里 role_code=sales_manager 且 customer_type=personal
的人员(本人带领的业务员经 manager_id 关联)。
"""
from app.models import PerformanceLedger
managers: dict[int, dict] = {}
for row in ledger_rows:
if row.customer_type == "manager_personal":
continue # 经理个人户业绩不入聚合基数(#12 暂口径)
# 找到该行业务归属人员的经理(manager_id)
person = session.get(Personnel, row.personnel_id)
manager_id = person.manager_id if person else None
if manager_id is None:
continue
bucket = managers.setdefault(
manager_id,
{
"performance": Decimal("0"),
"business_id": row.business_id,
"business_no": row.business_no,
"names": set(),
},
)
bucket["performance"] += row.performance_amount or Decimal("0")
bucket["names"].add(row.personnel_name)
from datetime import date
from app.repositories import business_role_repository
from app.services.param_service import parse_fraction, resolve_snapshot
aggregates: list = []
snapshot = resolve_snapshot(session, date.today())
# 参数按百分数存储(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:
continue
role = (
business_role_repository.get_by_id(session, manager.business_role_id)
if manager.business_role_id
else None
)
if role is None or role.role_code != "sales_manager":
continue # 非营销经理的上级不参与聚合
# 全公司基数:全部非经理个人户的有效行
company_base = sum(
(
r.performance_amount or Decimal("0")
for r in ledger_rows
if r.customer_type != "manager_personal"
),
Decimal("0"),
)
share = manager_team_share(
bucket["performance"],
company_base,
team_pct=team_pct,
company_pct=company_pct,
)
member_names = "、".join(sorted(bucket["names"])) or "(无成员)"
aggregates.append(
PerformanceLedger(
receipt_id=None,
calc_type=CALC_TYPE_MANAGER_AGGREGATE,
business_id=bucket["business_id"],
business_no=bucket["business_no"],
personnel_id=manager_id,
personnel_name=manager.personnel_name,
role_code="sales_manager",
customer_type="personal",
product_category="mixed",
receipt_date=date.today(),
# 比例原文取实际参数(勿硬编码默认值——参数调整后台账说明会失真)
rate_text=f"聚合:{team_text}%+{company_text}%",
converted_profit=None,
performance_amount=share.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
barter_applied=0,
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={
"team_base": str(bucket["performance"]),
"company_base": str(company_base),
"members": member_names,
"note": "#12 暂口径:基数不含经理个人户业绩;9.8 方案公式",
},
)
)
return aggregates
def _salary_mode_of(session: Session, role_code: str) -> str | None:
from datetime import date
from app.services.param_service import resolve_snapshot
snapshot = resolve_snapshot(session, date.today())
return snapshot.get("salary.basic.mode", role_code=role_code) or None
def _probation_threshold(session: Session) -> Decimal:
from datetime import date
from app.services.param_service import parse_fraction, resolve_snapshot
snapshot = resolve_snapshot(session, date.today())
from app.services.formula import to_decimal
return to_decimal(parse_fraction(snapshot.get("probation.annual.threshold")))
def _detail_of(session: Session, settlement: MonthlySettlement) -> SettlementDetailOut:
items = settlement_repository.list_items(session, settlement.id)
detail = SettlementDetailOut.model_validate(settlement)
from app.schemas.settlement import SettlementItemOut
detail.items = [SettlementItemOut.model_validate(i) for i in items]
return detail
def create_adjustment(
session: Session,
payload,
*,
operator: User,
meta: RequestMeta | None = None,
):
"""新建调整单:开关分流(关=提交即生效留痕;开=待审批)。"""
from app.core.constants import NO_PREFIX_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,
)
from app.services.no_generator import next_no
person = session.get(Personnel, payload.personnel_id)
if person is None:
raise BadRequestError("调整人员不存在")
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SETTLEMENT,
action="新建调整单",
summary=f"调整单 {payload.direction} {payload.amount} 元({person.personnel_name})",
request_data=payload.model_dump(),
),
meta=meta,
):
order = AdjustmentOrder(
order_no=next_no(
session,
model=AdjustmentOrder,
column_name="order_no",
prefix=NO_PREFIX_ADJUSTMENT,
),
settlement_month=payload.settlement_month,
personnel_id=payload.personnel_id,
personnel_name=person.personnel_name,
direction=payload.direction,
amount=payload.amount,
reason=payload.reason.strip(),
submitter_id=operator.id,
submitter_name=operator.username,
)
session.add(order)
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 {
"id": order.id,
"order_no": order.order_no,
"status": order.status,
"personnel_name": order.personnel_name,
"amount": str(order.amount),
"direction": order.direction,
}
def list_adjustments(session: Session) -> list[dict]:
"""调整单列表(月份倒序)。"""
from sqlalchemy import select
from app.models import AdjustmentOrder
rows = (
session.execute(select(AdjustmentOrder).order_by(AdjustmentOrder.id.desc()).limit(200))
.scalars()
.all()
)
from app.models.settlement import adjustment_label # noqa: F401
return [
{
"id": r.id,
"order_no": r.order_no,
"settlement_month": r.settlement_month,
"personnel_name": r.personnel_name,
"direction": r.direction,
"amount": str(r.amount),
"reason": r.reason,
"status": r.status,
}
for r in rows
]