"""月度结算服务(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 ]