2026-10-16 12:33:31 -04:00
|
|
|
|
"""月度结算服务(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
|
2026-10-18 23:59:49 -04:00
|
|
|
|
from datetime import date
|
2026-10-18 23:25:21 -04:00
|
|
|
|
from decimal import ROUND_HALF_UP, Decimal
|
2026-10-16 12:33:31 -04:00
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import select
|
2026-10-22 01:31:01 -04:00
|
|
|
|
from sqlalchemy.exc import IntegrityError
|
2026-10-16 12:33:31 -04:00
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.constants import (
|
|
|
|
|
|
MODULE_SETTLEMENT,
|
|
|
|
|
|
)
|
2026-10-22 01:31:01 -04:00
|
|
|
|
from app.core.db_errors import is_duplicate_entry
|
2026-10-16 12:33:31 -04:00
|
|
|
|
from app.core.exceptions import BadRequestError, NotFoundError
|
|
|
|
|
|
from app.models import (
|
|
|
|
|
|
MonthlySettlement,
|
|
|
|
|
|
Personnel,
|
|
|
|
|
|
SettlementItem,
|
|
|
|
|
|
User,
|
|
|
|
|
|
)
|
|
|
|
|
|
from app.models.approval import APPROVAL_TYPE_MONTHLY_SETTLEMENT
|
2026-10-18 23:59:49 -04:00
|
|
|
|
from app.models.performance_ledger import (
|
|
|
|
|
|
CALC_TYPE_MANAGER_AGGREGATE,
|
|
|
|
|
|
CALC_TYPE_RECEIPT,
|
|
|
|
|
|
)
|
2026-10-16 12:33:31 -04:00
|
|
|
|
from app.models.settlement import (
|
|
|
|
|
|
SETTLEMENT_STATUS_APPROVED,
|
|
|
|
|
|
SETTLEMENT_STATUS_DRAFT,
|
|
|
|
|
|
SETTLEMENT_STATUS_PENDING,
|
|
|
|
|
|
SETTLEMENT_STATUS_REJECTED,
|
|
|
|
|
|
)
|
2026-10-18 23:59:49 -04:00
|
|
|
|
from app.repositories import performance_ledger_repository, settlement_repository
|
2026-10-16 12:33:31 -04:00
|
|
|
|
from app.schemas.settlement import (
|
|
|
|
|
|
SettlementDetailOut,
|
|
|
|
|
|
SettlementOut,
|
|
|
|
|
|
SettlementQuery,
|
|
|
|
|
|
)
|
2026-10-19 04:33:31 -04:00
|
|
|
|
from app.services import approval_service, audit_service, manager_share
|
2026-10-16 12:33:31 -04:00
|
|
|
|
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——一行一月,重生成走「驳回/草稿态
|
2026-10-19 00:52:36 -04:00
|
|
|
|
重新生成」(_release_previous_round 解绑回填后清明细重算)
|
|
|
|
|
|
- 经理聚合(calc_type=2)在生成时计算并落库(#12 参数化见
|
|
|
|
|
|
``_build_manager_aggregates``)
|
|
|
|
|
|
|
|
|
|
|
|
本函数只做编排,各段独立私有函数(口径修改只动对应段)。
|
2026-10-16 12:33:31 -04:00
|
|
|
|
"""
|
|
|
|
|
|
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)
|
2026-10-19 00:52:36 -04:00
|
|
|
|
_guard_regeneration(existing, month)
|
|
|
|
|
|
_release_previous_round(session, existing, month)
|
2026-10-18 23:59:49 -04:00
|
|
|
|
|
2026-10-16 12:33:31 -04:00
|
|
|
|
ledger_rows = _confirm_scope(session, month)
|
|
|
|
|
|
if not ledger_rows:
|
|
|
|
|
|
raise BadRequestError(f"{month} 无可结算的核算记录(先在台账完成核算)")
|
|
|
|
|
|
|
|
|
|
|
|
aggregate_rows = _build_manager_aggregates(session, ledger_rows, month)
|
2026-10-19 00:52:36 -04:00
|
|
|
|
settlement = _upsert_draft(session, existing, operator, month)
|
|
|
|
|
|
items, ledger_ids = _build_items(session, ledger_rows, aggregate_rows, month)
|
2026-10-16 12:33:31 -04:00
|
|
|
|
|
|
|
|
|
|
settlement_repository.replace_items(session, settlement.id, items)
|
2026-10-19 00:52:36 -04:00
|
|
|
|
_apply_totals(settlement, items)
|
2026-10-16 12:33:31 -04:00
|
|
|
|
settlement.snapshot_json = {"note": _SETTLEMENT_NOTE}
|
2026-10-18 23:59:49 -04:00
|
|
|
|
# 入结回填:台账行 settlement_id 是「未入结」防重判据(_confirm_scope),
|
|
|
|
|
|
# 不回填会让重生成把已固化行反复纳入;聚合行此刻已落库、id 可用
|
|
|
|
|
|
settlement_repository.update_ledger_settlement_id(session, ledger_ids, settlement.id)
|
|
|
|
|
|
|
|
|
|
|
|
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已生成的月结
|
|
|
|
|
|
# 记成失败审计,与库中事实相反)
|
|
|
|
|
|
session.flush()
|
|
|
|
|
|
detail = _detail_of(session, settlement)
|
2026-10-16 12:33:31 -04:00
|
|
|
|
session.commit()
|
|
|
|
|
|
logger.info("月结生成 | month=%s | items=%s", month, len(items))
|
2026-10-18 23:59:49 -04:00
|
|
|
|
return detail
|
2026-10-16 12:33:31 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 00:52:36 -04:00
|
|
|
|
def _guard_regeneration(existing: MonthlySettlement | None, month: str) -> None:
|
|
|
|
|
|
"""重生成准入:已审定 / 待审批不可重生成(草稿、已驳回放行)。"""
|
|
|
|
|
|
if existing is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
if existing.status == SETTLEMENT_STATUS_APPROVED:
|
|
|
|
|
|
raise BadRequestError(f"{month} 已审定锁定,不可重新生成(调整走调整单)")
|
|
|
|
|
|
if existing.status == SETTLEMENT_STATUS_PENDING:
|
|
|
|
|
|
raise BadRequestError(f"{month} 有待审批的月结,不可重新生成")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _release_previous_round(
|
|
|
|
|
|
session: Session, existing: MonthlySettlement | None, month: str
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""释放上一轮固化标记(必须早于 _confirm_scope)。
|
|
|
|
|
|
|
|
|
|
|
|
台账行 settlement_id 回填后,「未入结」过滤会把它们永久排除在候选集外;
|
|
|
|
|
|
旧聚合行也要作废,否则本轮重建的聚合行与旧行重复计入。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if existing is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
settlement_repository.clear_ledger_settlement_id(session, existing.id)
|
|
|
|
|
|
performance_ledger_repository.deactivate_month_aggregates(session, month)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _upsert_draft(
|
|
|
|
|
|
session: Session, existing: MonthlySettlement | None, operator: User, month: str
|
|
|
|
|
|
) -> MonthlySettlement:
|
|
|
|
|
|
"""新建或重置草稿头表(重生成时刷新提交人与状态)。"""
|
|
|
|
|
|
if existing is None:
|
|
|
|
|
|
settlement = MonthlySettlement(
|
|
|
|
|
|
settlement_month=month,
|
|
|
|
|
|
submitter_id=operator.id,
|
|
|
|
|
|
submitter_name=operator.username,
|
|
|
|
|
|
status=SETTLEMENT_STATUS_DRAFT,
|
|
|
|
|
|
)
|
|
|
|
|
|
return settlement_repository.add(session, settlement)
|
|
|
|
|
|
existing.submitter_id = operator.id
|
|
|
|
|
|
existing.submitter_name = operator.username
|
|
|
|
|
|
existing.status = SETTLEMENT_STATUS_DRAFT
|
|
|
|
|
|
return existing
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_items(
|
|
|
|
|
|
session: Session, ledger_rows: list, aggregate_rows: list, month: str
|
|
|
|
|
|
) -> tuple[list[SettlementItem], list[int]]:
|
|
|
|
|
|
"""按人汇总生成明细行,返回 (明细, 本次入结的台账行 id 列表)。"""
|
|
|
|
|
|
by_person = _bucket_rows(ledger_rows + aggregate_rows)
|
|
|
|
|
|
items = [_item_of(session, bucket, month) for _, bucket in sorted(by_person.items())]
|
|
|
|
|
|
ledger_ids = [lid for bucket in by_person.values() for lid in bucket["ledger_ids"]]
|
|
|
|
|
|
return items, ledger_ids
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _bucket_rows(rows: list) -> dict[tuple[int, int], dict]:
|
|
|
|
|
|
"""台账行按 (人员, 核算类型) 维度分桶(冗余人读字段取首行)。"""
|
|
|
|
|
|
by_person: dict[tuple[int, int], dict] = {}
|
|
|
|
|
|
for row in 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)
|
|
|
|
|
|
return by_person
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _item_of(session: Session, bucket: dict, month: str) -> SettlementItem:
|
|
|
|
|
|
"""单条结算明细:月工资三口径净额、试用期说明与台账行引用列表。"""
|
|
|
|
|
|
person = session.get(Personnel, bucket["personnel_id"])
|
|
|
|
|
|
basic = person.salary_standard if person else None
|
|
|
|
|
|
mode = _salary_mode_of(session, bucket["role_code"], month)
|
|
|
|
|
|
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, month)} 元部分"
|
|
|
|
|
|
"年底一次性结算"
|
|
|
|
|
|
)
|
|
|
|
|
|
return 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"]},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _apply_totals(settlement: MonthlySettlement, items: list[SettlementItem]) -> None:
|
|
|
|
|
|
"""头表三合计固化(明细已两位小数,合计再 HALF_UP 收口)。"""
|
|
|
|
|
|
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})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 12:33:31 -04:00
|
|
|
|
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,
|
|
|
|
|
|
):
|
2026-10-20 06:30:51 -04:00
|
|
|
|
settlement = settlement_repository.get_by_id(session, settlement_id, for_update=True)
|
2026-10-16 12:33:31 -04:00
|
|
|
|
if settlement is None:
|
|
|
|
|
|
raise NotFoundError("月结不存在")
|
|
|
|
|
|
if settlement.status not in (SETTLEMENT_STATUS_DRAFT, SETTLEMENT_STATUS_REJECTED):
|
|
|
|
|
|
raise BadRequestError("仅草稿或已驳回状态可提交")
|
|
|
|
|
|
|
2026-10-22 01:31:01 -04:00
|
|
|
|
try:
|
|
|
|
|
|
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,
|
|
|
|
|
|
# 月结明细在生成时已固化;提交时快照让复核者看到的是送审版本,
|
|
|
|
|
|
# 而不是审批期间若发生重生成后的内容
|
|
|
|
|
|
snapshot=approval_service.settlement_content(session, settlement.id),
|
|
|
|
|
|
)
|
|
|
|
|
|
except IntegrityError as exc:
|
|
|
|
|
|
# version_no 唯一键的并发冲突(提交前已锁月结行,此项理论不可达,
|
|
|
|
|
|
# 留作 DB 兜底):审计上下文会统一回滚,这里只把 1062 翻译成
|
|
|
|
|
|
# 可重试的业务提示,其余完整性错误原样上抛
|
|
|
|
|
|
if is_duplicate_entry(exc):
|
|
|
|
|
|
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
|
|
|
|
|
|
raise
|
2026-10-16 12:33:31 -04:00
|
|
|
|
settlement.approval_order_no = order.order_no
|
|
|
|
|
|
if order.status == SETTLEMENT_STATUS_APPROVED:
|
|
|
|
|
|
mark_locked(session, settlement, operator=operator)
|
|
|
|
|
|
logger.info("月结自动通过并锁定 | %s", settlement.settlement_month)
|
2026-10-18 23:59:49 -04:00
|
|
|
|
else:
|
|
|
|
|
|
settlement.status = SETTLEMENT_STATUS_PENDING
|
2026-10-16 12:33:31 -04:00
|
|
|
|
|
2026-10-18 23:59:49 -04:00
|
|
|
|
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已提交的
|
|
|
|
|
|
# 月结记成失败审计,与库中事实相反)
|
|
|
|
|
|
session.flush()
|
|
|
|
|
|
detail = _detail_of(session, settlement)
|
2026-10-16 12:33:31 -04:00
|
|
|
|
session.commit()
|
2026-10-18 23:59:49 -04:00
|
|
|
|
return detail
|
2026-10-16 12:33:31 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-10-18 23:25:21 -04:00
|
|
|
|
"""审批驳回钩子:回草稿(可修改后重提)。
|
|
|
|
|
|
|
|
|
|
|
|
锁定信息一并清空:草稿态残留 locked_at/locked_by 会让详情页展示
|
|
|
|
|
|
「已锁定」的假象(历史缺陷路径下驳回前可能已被提前锁定)。
|
|
|
|
|
|
"""
|
2026-10-16 12:33:31 -04:00
|
|
|
|
settlement.status = SETTLEMENT_STATUS_DRAFT
|
2026-10-18 23:25:21 -04:00
|
|
|
|
settlement.locked_at = None
|
|
|
|
|
|
settlement.locked_by = None
|
2026-10-16 12:33:31 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-10-18 23:59:49 -04:00
|
|
|
|
"""确认回款口径(#8 策略点):本月**单笔回款**的有效台账行(未入结)。
|
|
|
|
|
|
|
|
|
|
|
|
calc_type 必须限定单笔(1):聚合行(2)也落同月台账、也会被回填
|
|
|
|
|
|
settlement_id,不排除会在重生成时与新聚合行重复计入。
|
|
|
|
|
|
"""
|
2026-10-16 12:33:31 -04:00
|
|
|
|
from app.models import PerformanceLedger
|
|
|
|
|
|
|
|
|
|
|
|
return list(
|
|
|
|
|
|
session.execute(
|
|
|
|
|
|
select(PerformanceLedger)
|
|
|
|
|
|
.where(
|
|
|
|
|
|
PerformanceLedger.settlement_month == month,
|
2026-10-18 23:59:49 -04:00
|
|
|
|
PerformanceLedger.calc_type == CALC_TYPE_RECEIPT,
|
2026-10-16 12:33:31 -04:00
|
|
|
|
PerformanceLedger.is_active == 1,
|
|
|
|
|
|
PerformanceLedger.settlement_id.is_(None),
|
|
|
|
|
|
)
|
|
|
|
|
|
.order_by(PerformanceLedger.id)
|
|
|
|
|
|
).scalars()
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 04:33:31 -04:00
|
|
|
|
def _manager_share_params(session: Session, month: str) -> manager_share.ManagerShareParams:
|
|
|
|
|
|
"""薄委托 manager_share 共享模块:月结聚合与报表部门汇总同源取参。
|
2026-10-16 12:33:31 -04:00
|
|
|
|
|
2026-10-19 04:33:31 -04:00
|
|
|
|
迁移说明(2026-10-19):实现原样迁至 manager_share,行为零变化;
|
|
|
|
|
|
参数按百分数存储(12.5 表示 12.5%),调用方不得再乘 100。
|
2026-10-16 12:33:31 -04:00
|
|
|
|
"""
|
2026-10-19 04:33:31 -04:00
|
|
|
|
return manager_share.manager_share_params(session, month)
|
2026-10-19 00:52:36 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _collect_team_buckets(
|
|
|
|
|
|
session: Session, ledger_rows: list, include_manager_personal: bool
|
|
|
|
|
|
) -> dict[int, dict]:
|
2026-10-19 04:33:31 -04:00
|
|
|
|
"""薄委托 manager_share 共享模块:按经理采集团队业绩(行为零变化)。"""
|
|
|
|
|
|
return manager_share.collect_team_buckets(session, ledger_rows, include_manager_personal)
|
2026-10-19 00:52:36 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _aggregate_row(
|
2026-10-19 04:33:31 -04:00
|
|
|
|
session: Session,
|
|
|
|
|
|
manager,
|
|
|
|
|
|
bucket: dict,
|
|
|
|
|
|
company_base: Decimal,
|
|
|
|
|
|
share: manager_share.ManagerShareParams,
|
2026-10-19 00:52:36 -04:00
|
|
|
|
):
|
|
|
|
|
|
"""单经理聚合台账行(必须落库:月结 ledger_ids 与报表下钻的锚点)。"""
|
|
|
|
|
|
from app.models import PerformanceLedger
|
|
|
|
|
|
|
|
|
|
|
|
amount = manager_team_share(
|
|
|
|
|
|
bucket["performance"],
|
|
|
|
|
|
company_base,
|
|
|
|
|
|
team_pct=share.team_pct,
|
|
|
|
|
|
company_pct=share.company_pct,
|
|
|
|
|
|
)
|
|
|
|
|
|
member_names = "、".join(sorted(bucket["names"])) or "(无成员)"
|
|
|
|
|
|
return performance_ledger_repository.add(
|
|
|
|
|
|
session,
|
|
|
|
|
|
PerformanceLedger(
|
|
|
|
|
|
receipt_id=None,
|
|
|
|
|
|
calc_version=1, # 聚合行无回款维度,版本恒 1(uk 含 NULL 不去重)
|
|
|
|
|
|
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=share.param_date,
|
|
|
|
|
|
# 比例原文取实际参数(勿硬编码默认值——参数调整后台账说明会失真)
|
|
|
|
|
|
rate_text=f"聚合:{share.team_text}%+{share.company_text}%",
|
|
|
|
|
|
converted_profit=None,
|
|
|
|
|
|
performance_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
|
|
|
|
|
barter_applied=0,
|
|
|
|
|
|
payable_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
|
|
|
|
|
settlement_month=share.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,
|
|
|
|
|
|
"params_as_of": str(share.param_date),
|
|
|
|
|
|
"manager_personal_included": 1 if share.include_manager_personal else 0,
|
2026-10-23 04:20:05 -04:00
|
|
|
|
# 说明写给业务人员看:说清这一笔的基数是怎么凑出来的
|
2026-10-19 00:52:36 -04:00
|
|
|
|
"note": (
|
2026-10-23 04:20:05 -04:00
|
|
|
|
f"团队分成:基数{'含' if share.include_manager_personal else '不含'}"
|
|
|
|
|
|
"经理本人名下业绩,按本团队与全公司两级比例合计计提"
|
2026-10-19 00:52:36 -04:00
|
|
|
|
),
|
2026-10-16 12:33:31 -04:00
|
|
|
|
},
|
2026-10-19 00:52:36 -04:00
|
|
|
|
),
|
|
|
|
|
|
)
|
2026-10-16 12:33:31 -04:00
|
|
|
|
|
2026-10-19 00:52:36 -04:00
|
|
|
|
|
|
|
|
|
|
def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -> list:
|
|
|
|
|
|
"""按经理生成团队聚合行(calc_type=2)并落库。
|
|
|
|
|
|
|
|
|
|
|
|
#12 答复(2026-10-19)参数化:经理个人户业绩是否计入聚合基数由参数
|
|
|
|
|
|
``manager.personal.included`` 决定(0=不计入默认,1=计入);
|
|
|
|
|
|
经理 = 台账行归属人员经 manager_id 关联的 sales_manager 岗人员。
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app.repositories import business_role_repository
|
|
|
|
|
|
|
|
|
|
|
|
share = _manager_share_params(session, month)
|
|
|
|
|
|
managers = _collect_team_buckets(session, ledger_rows, share.include_manager_personal)
|
2026-10-19 04:33:31 -04:00
|
|
|
|
# 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤);共享口径单点
|
|
|
|
|
|
company_base = manager_share.company_base_of(ledger_rows, share.include_manager_personal)
|
2026-10-19 00:10:29 -04:00
|
|
|
|
aggregates: list = []
|
2026-10-16 12:33:31 -04:00
|
|
|
|
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 # 非营销经理的上级不参与聚合
|
2026-10-19 00:52:36 -04:00
|
|
|
|
aggregates.append(_aggregate_row(session, manager, bucket, company_base, share))
|
2026-10-16 12:33:31 -04:00
|
|
|
|
return aggregates
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-18 23:59:49 -04:00
|
|
|
|
def _month_end(month: str) -> date:
|
2026-10-19 04:33:31 -04:00
|
|
|
|
"""薄委托 manager_share 共享模块:YYYY-MM → 所属月月末(取参锚点)。
|
2026-10-16 12:33:31 -04:00
|
|
|
|
|
2026-10-19 04:33:31 -04:00
|
|
|
|
保留包装:tests/unit/test_settlement_helpers.py 直接 import 本名字;
|
|
|
|
|
|
「补生成历史月份结果不随操作日漂移」的原因说明见 manager_share.month_end。
|
2026-10-18 23:59:49 -04:00
|
|
|
|
"""
|
2026-10-19 04:33:31 -04:00
|
|
|
|
return manager_share.month_end(month)
|
2026-10-18 23:59:49 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _salary_mode_of(session: Session, role_code: str, month: str) -> str | None:
|
2026-10-16 12:33:31 -04:00
|
|
|
|
from app.services.param_service import resolve_snapshot
|
|
|
|
|
|
|
2026-10-18 23:59:49 -04:00
|
|
|
|
snapshot = resolve_snapshot(session, _month_end(month))
|
2026-10-16 12:33:31 -04:00
|
|
|
|
return snapshot.get("salary.basic.mode", role_code=role_code) or None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-18 23:59:49 -04:00
|
|
|
|
def _probation_threshold(session: Session, month: str) -> Decimal:
|
2026-10-16 12:33:31 -04:00
|
|
|
|
from app.services.param_service import parse_fraction, resolve_snapshot
|
|
|
|
|
|
|
2026-10-18 23:59:49 -04:00
|
|
|
|
snapshot = resolve_snapshot(session, _month_end(month))
|
2026-10-16 12:33:31 -04:00
|
|
|
|
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
|
2026-10-18 23:25:21 -04:00
|
|
|
|
from app.models import AdjustmentOrder
|
2026-10-16 12:33:31 -04:00
|
|
|
|
from app.services.no_generator import next_no
|
|
|
|
|
|
|
2026-10-19 00:52:36 -04:00
|
|
|
|
amount = _adjustment_amount(payload)
|
2026-10-19 00:10:29 -04:00
|
|
|
|
|
2026-10-16 12:33:31 -04:00
|
|
|
|
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,
|
2026-10-19 00:10:29 -04:00
|
|
|
|
amount=amount,
|
2026-10-16 12:33:31 -04:00
|
|
|
|
reason=payload.reason.strip(),
|
|
|
|
|
|
submitter_id=operator.id,
|
|
|
|
|
|
submitter_name=operator.username,
|
|
|
|
|
|
)
|
|
|
|
|
|
session.add(order)
|
|
|
|
|
|
session.flush()
|
|
|
|
|
|
|
2026-10-19 00:52:36 -04:00
|
|
|
|
_wire_adjustment_approval(session, order, operator)
|
|
|
|
|
|
out = _adjustment_out(order) # 出参在 commit 前固化(audit 约定)
|
2026-10-16 12:33:31 -04:00
|
|
|
|
session.commit()
|
2026-10-19 00:52:36 -04:00
|
|
|
|
logger.info("调整单创建 | order=%s | status=%s", out["order_no"], out["status"])
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _adjustment_amount(payload) -> Decimal:
|
|
|
|
|
|
"""金额恒正校验(方向表达补发/扣减)。
|
|
|
|
|
|
|
|
|
|
|
|
列注释即「恒正」约定,负数会让方向语义反转、审批/报表按 direction
|
|
|
|
|
|
汇总出错;格式已由 schema 正则拦截,此处兜底转换 + 语义判定。
|
|
|
|
|
|
"""
|
|
|
|
|
|
from decimal import InvalidOperation
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
amount = Decimal(payload.amount)
|
|
|
|
|
|
except InvalidOperation as exc:
|
|
|
|
|
|
raise BadRequestError("调整金额格式非法(非负两位小数)") from exc
|
|
|
|
|
|
if amount <= 0:
|
|
|
|
|
|
raise BadRequestError("调整金额须大于 0(方向表达补发/扣减)")
|
|
|
|
|
|
return amount
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _wire_adjustment_approval(session: Session, order, operator: User) -> None:
|
|
|
|
|
|
"""开关分流与留痕统一交给 submit_order(关=自动通过留痕;开=待财务复核)。
|
|
|
|
|
|
|
|
|
|
|
|
**不得手工插 ApprovalStep**——``approval_steps.order_id`` 外键指向
|
|
|
|
|
|
approval_orders(不是 adjustment_orders),用调整单 id 落步必然外键
|
|
|
|
|
|
失败(历史缺陷:关开关时新建调整单不可用)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
from app.models.approval import APPROVAL_STATUS_APPROVED, APPROVAL_TYPE_ADJUSTMENT
|
|
|
|
|
|
from app.models.settlement import (
|
|
|
|
|
|
ADJUSTMENT_STATUS_EFFECTIVE,
|
|
|
|
|
|
ADJUSTMENT_STATUS_PENDING,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
approval_order = approval_service.submit_order(
|
|
|
|
|
|
session,
|
|
|
|
|
|
order_type=APPROVAL_TYPE_ADJUSTMENT,
|
|
|
|
|
|
ref_id=order.id,
|
|
|
|
|
|
refer_no=order.order_no,
|
|
|
|
|
|
submitter=operator,
|
2026-10-20 06:30:51 -04:00
|
|
|
|
snapshot=approval_service.adjustment_content(session, order.id),
|
2026-10-19 00:52:36 -04:00
|
|
|
|
)
|
|
|
|
|
|
order.status = (
|
|
|
|
|
|
ADJUSTMENT_STATUS_EFFECTIVE
|
|
|
|
|
|
if approval_order.status == APPROVAL_STATUS_APPROVED
|
|
|
|
|
|
else ADJUSTMENT_STATUS_PENDING
|
|
|
|
|
|
)
|
|
|
|
|
|
session.flush()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _adjustment_out(order) -> dict:
|
|
|
|
|
|
"""调整单出参(commit 前固化;expire_on_commit 关闭下也不依赖后置读取)。"""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": order.id,
|
|
|
|
|
|
"order_no": order.order_no,
|
|
|
|
|
|
"status": order.status,
|
|
|
|
|
|
"personnel_name": order.personnel_name,
|
|
|
|
|
|
"amount": str(order.amount),
|
|
|
|
|
|
"direction": order.direction,
|
|
|
|
|
|
}
|
2026-10-16 12:33:31 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
]
|