PayrollManagementSystem/backend/app/services/approval_service.py
liuwenxin 4c32a556de feat(approval): 审批单内容快照与二次分配方案版本化
补齐 code-review 遗留 2 项:审批单此前只固化 ref_id/refer_no 指针,复核者看到
的是业务当前内容;二次分配方案在编辑替换与驳回清空时一律物理删除,方案事实
无处回溯。

- 审批单加 version_no 与 snapshot_json(信封 schema_version/order_type/
  captured_at/data);二次分配、月结、调整单三类提交均固化送审内容,开关关闭
  的自动通过路径同样写快照;详情按 snapshot/live/missing 三态回显,历史单
  只读回退当前内容并显式告警,不回填历史快照
- 二次分配方案行改 append-only 版本化(version_no/is_active,唯一键扩为
  business+version+personnel):编辑替换与驳回清空只置历史,核算与详情默认
  只取生效行,业务详情新增 allocation_history
- 送审内容未变不重提、方案未变不新增版本:判据统一为送审内容,两个
  version_no 语义解耦(方案版本=内容第几版,审批单版本=第几次送审)
- 审批单列表、工作台待办、审批超时预警三处 defer 快照列,避免整列拉取
- 月结提交取行加 for_update,防并发提交算出重复的审批单版本号
- 修复审批类型筛选上界 le=2(调整单无法筛选)、快照金额把空值写成 0.00
- V11 迁移三件套同步(Alembic / db/migration / init-dev.sql)

测试:单元 +16(方案内容比较 10、审批入参 6)、集成 +12;全量 891 passed。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 18:30:51 +08:00

574 lines
22 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.

"""审批流业务编排(2026-10-15 复核审批补充需求)。
三级链路:营销后勤提交 → 财务复核 → 领导审核。行为由参数开关驱动
(`approval.secondary_allocation.enabled` / `approval.monthly_settlement.enabled`):
- **关闭(默认)**:提交即生成审批单并「自动通过」全节点留痕(action=3,
operator 记「系统」)——留痕不断链,与「关闭=直接生效」的需求口径一致;
- **开启**:status=1 待财务复核 → 2 待领导审核 → 3 已通过;任一节点驳回
即整体驳回(status=4),一期不做退回重走(简单状态机)。
红线:
- `submit_order` **不 commit、不嵌套 audit** —— 由业务写路径(business_service
的 create/update)既有 audit 上下文统一收口,违反嵌套禁令会让失败路径
回滚不完整(见 audit_service 模块 docstring);
- 节点准入**严格按角色编码**:status=1 仅财务、status=2 仅领导,admin 不
代办 —— 放行 admin 会让双人复核流退化成管理员单点审批;
- 同一引用对象的在途单(status 1/2)在重新提交前先撤回(status=5 +
node=0/action=4 留痕),保证「一个对象至多一张在途单」。
"""
import logging
from collections.abc import Iterable
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import func, select
from sqlalchemy.orm import Session, defer
from app.core.constants import (
ACTION_APPROVE,
ACTION_REJECT,
MODULE_APPROVAL,
NO_PREFIX_APPROVAL,
ROLE_ADMIN,
ROLE_FINANCE,
ROLE_LEADER,
)
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.models import ApprovalOrder, ApprovalStep, Business, User
from app.models.approval import (
APPROVAL_ACTION_APPROVE,
APPROVAL_ACTION_AUTO_APPROVE,
APPROVAL_ACTION_REJECT,
APPROVAL_ACTION_WITHDRAW,
APPROVAL_NODE_FINANCE,
APPROVAL_NODE_LEADER,
APPROVAL_NODE_WITHDRAW,
APPROVAL_NODES,
APPROVAL_STATUS_APPROVED,
APPROVAL_STATUS_PENDING_FINANCE,
APPROVAL_STATUS_PENDING_LEADER,
APPROVAL_STATUS_REJECTED,
APPROVAL_STATUS_WITHDRAWN,
)
from app.repositories import user_repository
from app.schemas.approval import (
ApprovalActionIn,
ApprovalOrderDetailOut,
ApprovalOrderOut,
ApprovalQuery,
ApprovalStepOut,
)
from app.services import audit_service, param_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.no_generator import next_no
logger = logging.getLogger(__name__)
# 开关参数编码(与 param_registry 种子同源;新增审批对象类型必须在此登记,
# 否则 is_enabled 走 BadRequestError 兜底、对应业务接口整体不可用)
ENABLE_CODE_BY_TYPE: dict[int, str] = {
1: "approval.secondary_allocation.enabled",
2: "approval.monthly_settlement.enabled",
3: "approval.adjustment.enabled",
}
# 在途状态集合(同一引用对象至多一张在途单)
_PENDING_STATUSES = (APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER)
# 在途状态 → 当前节点应操作的角色(admin 不代办:单人审批绕过双人复核)
_NODE_ROLE_BY_STATUS = {
APPROVAL_STATUS_PENDING_FINANCE: ROLE_FINANCE,
APPROVAL_STATUS_PENDING_LEADER: ROLE_LEADER,
}
_ROLE_LABEL = {ROLE_FINANCE: "财务", ROLE_LEADER: "领导"}
def pending_statuses_for_roles(role_codes: Iterable[str]) -> tuple[int, ...]:
"""工作台「待我审批」:按当前用户角色映射在途审批状态。
admin 不代办审批,但作为管理视角可查看全部在途单(与审批管理列表
的可见性一致);财务/领导只看到本节点可操作的条目。
"""
codes = set(role_codes)
if ROLE_ADMIN in codes:
return _PENDING_STATUSES
return tuple(status for status, role in _NODE_ROLE_BY_STATUS.items() if role in codes)
def is_enabled(session: Session, order_type: int) -> bool:
"""读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。"""
code = ENABLE_CODE_BY_TYPE.get(order_type)
if code is None:
raise BadRequestError("未知的审批对象类型")
return param_service.resolve_snapshot(session, date.today()).boolean(code)
def submit_order(
session: Session,
*,
order_type: int,
ref_id: int,
refer_no: str,
submitter: User,
snapshot: dict | None = None,
) -> ApprovalOrder:
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。
同一引用对象的在途单先撤回留痕再建新单;开关关闭时生成即自动通过
(全节点 action=3 留痕)。AP 编号撞唯一键时 IntegrityError 直接穿透:
create 路径由外层编号重试循环整体重来(rollback 后业务/方案/审批单
一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400——
**任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务
的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。
``snapshot`` 是「提交时」的业务内容(由调用方组装,Decimal 已转 str);
落库时包一层信封并固化采集时间——审批期间业务再改,复核者看到的仍是
送审内容。开关关闭的自动通过路径同样写快照,留痕不走样。
"""
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
order = ApprovalOrder(
order_no=next_no(
session,
model=ApprovalOrder,
column_name="order_no",
prefix=NO_PREFIX_APPROVAL,
),
order_type=order_type,
ref_id=ref_id,
refer_no=refer_no,
version_no=_next_version(session, order_type, ref_id),
snapshot_json=(
{
"schema_version": 1,
"order_type": order_type,
"captured_at": datetime.now().isoformat(timespec="seconds"),
"data": snapshot,
}
if snapshot is not None
else None
),
submitter_id=submitter.id,
submitter_name=submitter.username,
)
session.add(order)
if is_enabled(session, order_type):
order.status = APPROVAL_STATUS_PENDING_FINANCE
session.flush()
logger.info("审批单待审 | order=%s", order.order_no)
return order
# 开关关闭=直接生效:两级节点各记一条「自动通过」,留痕不断链
order.status = APPROVAL_STATUS_APPROVED
session.flush()
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()
logger.info("审批单自动通过 | order=%s(开关关闭)", order.order_no)
return order
def pending_matches_snapshot(
session: Session, *, order_type: int, ref_id: int, snapshot: dict | None
) -> bool:
"""在途审批单的送审内容是否与给定快照一致(用于「内容未变不重提」)。
只比较快照信封里的 ``data`` 部分——``captured_at`` 每次采集都不同,
参与比较会让判断恒为不一致。V11 之前的历史单没有快照,一律视为不一致
(保守重提:宁可多提一次,不能漏审)。
"""
if snapshot is None:
return False
pending = (
session.execute(
select(ApprovalOrder).where(
ApprovalOrder.order_type == order_type,
ApprovalOrder.ref_id == ref_id,
ApprovalOrder.status.in_(_PENDING_STATUSES),
)
)
.scalars()
.all()
)
for order in pending:
stored = order.snapshot_json or {}
if stored.get("data") == snapshot:
return True
return False
def withdraw_pending_orders(
session: Session, *, order_type: int, ref_id: int, operator: User
) -> int:
"""撤回引用对象的全部在途单(供业务清空方案等场景显式调用;不 commit)。
返回撤回张数(0 = 本就无在途单)。
"""
before = len(
session.execute(
select(ApprovalOrder.id).where(
ApprovalOrder.order_type == order_type,
ApprovalOrder.ref_id == ref_id,
ApprovalOrder.status.in_(_PENDING_STATUSES),
)
)
.scalars()
.all()
)
if before:
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=operator)
return before
def act(
session: Session,
order_id: int,
payload: ApprovalActionIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> ApprovalOrderDetailOut:
"""节点动作(财务复核/领导审核):状态机流转 + 节点留痕(审计上下文)。
通过:status=1 → 2(下一步领导);status=2 → 3(终态已通过)。
驳回:任一在途状态 → 4(整体驳回,一期不退回重走)。
节点留痕的 node 取**动作发生时的当前节点**(通过/驳回都记在操作节点上)。
"""
codes = set(user_repository.get_role_codes(operator))
verb = ACTION_APPROVE if payload.action == APPROVAL_ACTION_APPROVE else ACTION_REJECT
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_APPROVAL,
action=verb,
summary=f"审批单 #{order_id} {verb}",
request_data=payload.model_dump(),
),
meta=meta,
):
order = session.get(ApprovalOrder, order_id, with_for_update=True)
if order is None:
raise NotFoundError("审批单不存在")
if order.status not in _PENDING_STATUSES:
raise BadRequestError("审批单已结束,不可重复操作")
required_role = _NODE_ROLE_BY_STATUS[order.status]
if required_role not in codes:
raise ForbiddenError(f"当前节点须{_ROLE_LABEL[required_role]}角色操作")
node_now = APPROVAL_NODES[order.status]
rejected = payload.action == APPROVAL_ACTION_REJECT
if rejected:
order.status = APPROVAL_STATUS_REJECTED
else:
order.status = (
APPROVAL_STATUS_PENDING_LEADER
if order.status == APPROVAL_STATUS_PENDING_FINANCE
else APPROVAL_STATUS_APPROVED
)
# 生效类联动只认**终审**:财务节点通过(1→2)只是流转,不得提前
# 锁定月结/生效调整单——否则领导驳回后锁定字段残留、审批流形同虚设。
# 驳回在任何节点都是终态,即时联动。
final_approved = (not rejected) and order.status == APPROVAL_STATUS_APPROVED
session.add(
ApprovalStep(
order_id=order.id,
node=node_now,
action=payload.action,
opinion=payload.opinion,
operator_id=operator.id,
operator_name=operator.username,
)
)
# 驳回=方案未获批准:清空该业务的二次分配行,防止被否决的方案以
# 「当前生效行」身份留在表里、被 M3 核算快照算进工资;方案事实由
# 审批单留痕与操作日志 request_data 承载(分摊语义落定后如需保留
# 历史版本,在 ④ 设计时演进)。审批单的清理由调用方(act 内联)
# 委托 business_repository 完成 —— service 层互调、不触 commit。
if rejected and order.order_type == 1:
from app.repositories import business_repository
business_repository.deactivate_allocations(session, order.ref_id)
# 审批结果联动(服务层回调,不 commit):月结 通过=锁定/驳回=回草稿;
# 调整单 通过=生效/驳回=作废。函数内延迟导入防循环依赖。
if order.order_type == 2 or order.order_type == 3:
from app.models import AdjustmentOrder
from app.models.settlement import (
ADJUSTMENT_STATUS_EFFECTIVE,
)
from app.models.settlement import (
ADJUSTMENT_STATUS_REJECTED as ADJ_REJECTED,
)
from app.services import settlement_service
if order.order_type == 2:
from app.models.settlement import MonthlySettlement
target = session.get(MonthlySettlement, order.ref_id)
if target is not None:
if rejected:
settlement_service.mark_rejected(session, target, operator=operator)
elif final_approved:
settlement_service.mark_locked(session, target, operator=operator)
else:
adj = session.get(AdjustmentOrder, order.ref_id)
if adj is not None:
if rejected:
adj.status = ADJ_REJECTED
elif final_approved:
adj.status = ADJUSTMENT_STATUS_EFFECTIVE
# 出参(含 steps)在 **commit 之前** 固化:audit 禁止 commit 后再碰
# ORM/查询——commit 后的任何 SELECT 失败都会让「已通过的审批」被记
# 成失败审计,与库中事实相反(全 services 层唯一例外不许再犯)
session.flush()
detail = _detail_of(session, order)
session.commit()
return detail
def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]:
"""分页查询审批单(准入由 api 层依赖判定)。
排除 snapshot_json 列(月结快照含按人明细,可达数十~上百 KB):列表出参
本就不含快照,整列加载会让翻页负担随快照体积线性放大;详情走
`get_order` 按需读取(与操作日志列表排除 request_data 同一理由,
见 operation_log_repository.list_logs)。
"""
filters = _order_filters(query)
rows = (
session.execute(
select(ApprovalOrder)
.options(defer(ApprovalOrder.snapshot_json))
.where(*filters)
.order_by(ApprovalOrder.id.desc())
.offset(query.offset)
.limit(query.page_size)
)
.scalars()
.all()
)
total = session.execute(
select(func.count()).select_from(ApprovalOrder).where(*filters)
).scalar_one()
return PageResult[ApprovalOrderOut](
records=[ApprovalOrderOut.model_validate(r) for r in rows],
total=int(total),
page_num=query.page_num,
page_size=query.page_size,
)
def get_order(session: Session, order_id: int) -> ApprovalOrderDetailOut:
"""审批单详情(附节点时间线)。"""
order = session.get(ApprovalOrder, order_id)
if order is None:
raise NotFoundError("审批单不存在")
return _detail_of(session, order)
def _withdraw_pending(session: Session, *, order_type: int, ref_id: int, operator: User) -> None:
"""撤回引用对象的在途单(重新提交前调用;不 commit)。"""
pending = (
session.execute(
select(ApprovalOrder).where(
ApprovalOrder.order_type == order_type,
ApprovalOrder.ref_id == ref_id,
ApprovalOrder.status.in_(_PENDING_STATUSES),
)
)
.scalars()
.all()
)
for order in pending:
order.status = APPROVAL_STATUS_WITHDRAWN
session.add(
ApprovalStep(
order_id=order.id,
node=APPROVAL_NODE_WITHDRAW,
action=APPROVAL_ACTION_WITHDRAW,
operator_id=operator.id,
operator_name=operator.username,
)
)
if pending:
session.flush()
logger.info("撤回在途审批单 %s 张 | ref=%s", len(pending), ref_id)
def _next_version(session: Session, order_type: int, ref_id: int) -> int:
"""同一对象类型+引用对象的下一个提交版本号(从 1 递增)。"""
return (
int(
session.execute(
select(func.coalesce(func.max(ApprovalOrder.version_no), 0)).where(
ApprovalOrder.order_type == order_type,
ApprovalOrder.ref_id == ref_id,
)
).scalar_one()
)
+ 1
)
def allocation_content(session: Session, business: Business) -> dict:
"""二次分配方案的审批内容(提单快照与历史单回退共用同一结构)。
业务主行已存在但客户联查落空(客户被删等异常)时返回空字典——调用方
以 ``or None`` 归一,避免生成「有快照但内容为空」的信封。
"""
from app.repositories import business_repository
row = business_repository.get_with_names(session, business.id)
if row is None:
return {}
_, customer_name, owner_name, assistant_name = row
allocations = business_repository.list_allocations(session, business.id)
total = sum((a.ratio for a in allocations), Decimal("0"))
return {
"business_no": business.business_no,
"business_date": business.business_date.isoformat() if business.business_date else None,
"customer_name": customer_name,
"owner_personnel_name": owner_name,
"assistant_personnel_name": assistant_name,
"sale_amount": _amount_text(business.total_sale_amount),
"cost_amount": _amount_text(business.total_cost_amount),
"allocations": [
{
"personnel_id": a.personnel_id,
"personnel_name": a.personnel_name,
"ratio": str(a.ratio),
}
for a in allocations
],
"allocation_total": str(total.quantize(Decimal("0.01"))),
"allocation_balance": str((Decimal("100") - total).quantize(Decimal("0.01"))),
}
def _amount_text(value: Decimal | None) -> str | None:
"""金额文本化(Decimal 全程不转 float;None 保留为空,不伪造 0.00)。"""
return str(value) if value is not None else None
def settlement_content(session: Session, settlement_id: int) -> dict | None:
"""月结审批内容(汇总 + 按人明细;提单快照与历史单回退共用)。"""
from app.models import MonthlySettlement
from app.repositories import settlement_repository
settlement = session.get(MonthlySettlement, settlement_id)
if settlement is None:
return None
items = settlement_repository.list_items(session, settlement_id)
return {
"settlement_month": settlement.settlement_month,
"total_performance": _amount_text(settlement.total_performance),
"total_payable": _amount_text(settlement.total_payable),
"person_count": settlement.person_count,
"items": [
{
"personnel_name": item.personnel_name,
"role_code": item.role_code,
"calc_type": item.calc_type,
"performance_sum": str(item.performance_sum),
"payable_sum": str(item.payable_sum),
"basic_amount": _amount_text(item.basic_amount),
"salary_mode": item.salary_mode,
"net_amount": _amount_text(item.net_amount),
"is_probation": item.is_probation,
}
for item in items
],
}
def adjustment_content(session: Session, adjustment_id: int) -> dict | None:
"""调整单审批内容(提单快照与历史单回退共用)。"""
from app.models import AdjustmentOrder
adjustment = session.get(AdjustmentOrder, adjustment_id)
if adjustment is None:
return None
return {
"order_no": adjustment.order_no,
"settlement_month": adjustment.settlement_month,
"personnel_name": adjustment.personnel_name,
"direction": adjustment.direction,
"amount": str(adjustment.amount),
"reason": adjustment.reason,
}
def _content_of(session: Session, order: ApprovalOrder) -> tuple[dict | None, str]:
"""审批内容出参:优先提交时快照,历史单回退当前内容并显式标注来源。"""
if order.snapshot_json:
return order.snapshot_json, "snapshot"
if order.order_type == 1:
business = session.get(Business, order.ref_id)
data = allocation_content(session, business) if business is not None else None
elif order.order_type == 2:
data = settlement_content(session, order.ref_id)
elif order.order_type == 3:
data = adjustment_content(session, order.ref_id)
else:
data = None
if not data:
return None, "missing"
return {
"schema_version": 1,
"order_type": order.order_type,
"captured_at": None,
"data": data,
}, "live"
def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
"""出参组装(含 steps 时间线与审批内容)。调用方须保证处于 commit 之前。"""
steps = (
session.execute(
select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id)
)
.scalars()
.all()
)
detail = ApprovalOrderDetailOut.model_validate(order)
detail.steps = [ApprovalStepOut.model_validate(s) for s in steps]
detail.snapshot, detail.content_source = _content_of(session, order)
return detail
def _order_filters(query: ApprovalQuery) -> tuple:
filters = []
if query.keyword:
# autoescape:用户输入里的 %/_ 按 URL 片段字面匹配(全库统一口径,
# 见 customer_repository.contains 用法)——否则 "AP2026_10" 会把
# 下划线当单字符通配,搜出无关单据
filters.append(
ApprovalOrder.order_no.contains(query.keyword, autoescape=True)
| ApprovalOrder.refer_no.contains(query.keyword, autoescape=True)
)
if query.status is not None:
filters.append(ApprovalOrder.status == query.status)
if query.order_type is not None:
filters.append(ApprovalOrder.order_type == query.order_type)
return tuple(filters)