PayrollManagementSystem/backend/app/services/settlement_service.py
liuwenxin e7e47d88bc feat(settlement): 月度结算快照生成与审批锁定,调整单(补发/扣减)与审批联动
- settlement_service:generate(确认回款口径 #8 策略点=有效台账行按回款日
  归月、经理聚合 #12 暂口径基数不含经理个人户、月结 uk 一行一月、草稿/
  驳回可重生成、已审定/待审 400)→ submit(审批开关分流,关=自动通过即
  锁定、开=待财务复核)→ approval_service.act 扩 order_type=2/3 钩子
  (通过=mark_locked/调整单生效;驳回=回草稿/作废,延迟导入防循环)
- 锁定拦截:回款新增写路径校验回款月已审定 400(比需求「提示」取硬,
  一期安全侧;M2 已核算禁改防线先于其生效)
- 调整单:新建(TZ 单号,开关分流自动通过留痕/走审批)+ 列表
- schemas settlement:金额 Decimal 声明(str 声明遇 Decimal 入参拒绝)
- 测试 6 例:生成回填/无记录 400/已审定重生成 400/关自动锁定/锁定后回款
  禁改/开两节点流转至锁定(finance→leader)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-17 00:33:31 +08:00

524 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 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")),
payable_sum=bucket["payable_sum"].quantize(Decimal("0.01")),
basic_amount=basic,
salary_mode=mode,
net_amount=net.quantize(Decimal("0.01")) 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"))
settlement.total_payable = sum((i.payable_sum for i in items), Decimal("0")).quantize(
Decimal("0.01")
)
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:
"""审批驳回钩子:回草稿(可修改后重提)。"""
settlement.status = SETTLEMENT_STATUS_DRAFT
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())
team_pct = parse_fraction(snapshot.get("manager.share.team"))
company_pct = parse_fraction(snapshot.get("manager.share.company"))
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 * 100,
company_pct=company_pct * 100,
)
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="聚合:12.5%+5%",
converted_profit=None,
performance_amount=share.quantize(Decimal("0.01")),
barter_applied=0,
payable_amount=share.quantize(Decimal("0.01")),
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, ApprovalStep
from app.models.approval import (
APPROVAL_ACTION_AUTO_APPROVE,
APPROVAL_NODE_FINANCE,
APPROVAL_NODE_LEADER,
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()
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()
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
]