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>
This commit is contained in:
1 parent
22a2872100
commit
e7e47d88bc
8 files changed
+1177
No files matched your search
@@ -0,0 +1,523 @@
|
||||
"""月度结算服务(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
|
||||
]
|
||||
Reference in new issue
Block a user