2026-10-15 06:50:38 -04:00
|
|
|
|
"""审批流业务编排(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
|
2026-10-19 21:45:03 -04:00
|
|
|
|
from collections.abc import Iterable
|
2026-10-20 06:30:51 -04:00
|
|
|
|
from datetime import date, datetime
|
|
|
|
|
|
from decimal import Decimal
|
2026-10-15 06:50:38 -04:00
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import func, select
|
2026-10-20 06:30:51 -04:00
|
|
|
|
from sqlalchemy.orm import Session, defer
|
2026-10-15 06:50:38 -04:00
|
|
|
|
|
|
|
|
|
|
from app.core.constants import (
|
|
|
|
|
|
ACTION_APPROVE,
|
|
|
|
|
|
ACTION_REJECT,
|
|
|
|
|
|
MODULE_APPROVAL,
|
|
|
|
|
|
NO_PREFIX_APPROVAL,
|
2026-10-19 21:45:03 -04:00
|
|
|
|
ROLE_ADMIN,
|
2026-10-15 06:50:38 -04:00
|
|
|
|
ROLE_FINANCE,
|
|
|
|
|
|
ROLE_LEADER,
|
|
|
|
|
|
)
|
2026-11-02 22:20:55 -05:00
|
|
|
|
from app.core.exceptions import BadRequestError, NotFoundError
|
2026-10-15 06:50:38 -04:00
|
|
|
|
from app.core.response import PageResult
|
2026-10-20 06:30:51 -04:00
|
|
|
|
from app.models import ApprovalOrder, ApprovalStep, Business, User
|
2026-10-15 06:50:38 -04:00
|
|
|
|
from app.models.approval import (
|
|
|
|
|
|
APPROVAL_ACTION_APPROVE,
|
|
|
|
|
|
APPROVAL_ACTION_AUTO_APPROVE,
|
2026-10-16 02:23:53 -04:00
|
|
|
|
APPROVAL_ACTION_REJECT,
|
2026-10-15 06:50:38 -04:00
|
|
|
|
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.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__)
|
|
|
|
|
|
|
2026-10-18 23:25:21 -04:00
|
|
|
|
# 开关参数编码(与 param_registry 种子同源;新增审批对象类型必须在此登记,
|
|
|
|
|
|
# 否则 is_enabled 走 BadRequestError 兜底、对应业务接口整体不可用)
|
2026-10-15 06:50:38 -04:00
|
|
|
|
ENABLE_CODE_BY_TYPE: dict[int, str] = {
|
|
|
|
|
|
1: "approval.secondary_allocation.enabled",
|
|
|
|
|
|
2: "approval.monthly_settlement.enabled",
|
2026-10-18 23:25:21 -04:00
|
|
|
|
3: "approval.adjustment.enabled",
|
2026-10-15 06:50:38 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 在途状态集合(同一引用对象至多一张在途单)
|
|
|
|
|
|
_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: "领导"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 21:45:03 -04:00
|
|
|
|
def pending_statuses_for_roles(role_codes: Iterable[str]) -> tuple[int, ...]:
|
|
|
|
|
|
"""工作台「待我审批」:按当前用户角色映射在途审批状态。
|
|
|
|
|
|
|
|
|
|
|
|
admin 不代办审批,但作为管理视角可查看全部在途单(与审批管理列表
|
|
|
|
|
|
的可见性一致);财务/领导只看到本节点可操作的条目。
|
|
|
|
|
|
"""
|
2026-11-01 20:42:53 -05:00
|
|
|
|
from app.core.config import get_settings
|
|
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
not get_settings().payroll_allow_legacy_calculation
|
|
|
|
|
|
and not get_settings().payroll_approval_enabled
|
|
|
|
|
|
):
|
|
|
|
|
|
return ()
|
2026-10-19 21:45:03 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-15 06:50:38 -04:00
|
|
|
|
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,
|
2026-10-20 06:30:51 -04:00
|
|
|
|
snapshot: dict | None = None,
|
2026-10-15 06:50:38 -04:00
|
|
|
|
) -> ApprovalOrder:
|
|
|
|
|
|
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。
|
|
|
|
|
|
|
|
|
|
|
|
同一引用对象的在途单先撤回留痕再建新单;开关关闭时生成即自动通过
|
2026-10-16 02:23:53 -04:00
|
|
|
|
(全节点 action=3 留痕)。AP 编号撞唯一键时 IntegrityError 直接穿透:
|
|
|
|
|
|
create 路径由外层编号重试循环整体重来(rollback 后业务/方案/审批单
|
|
|
|
|
|
一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400——
|
|
|
|
|
|
**任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务
|
|
|
|
|
|
的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。
|
2026-10-20 06:30:51 -04:00
|
|
|
|
|
|
|
|
|
|
``snapshot`` 是「提交时」的业务内容(由调用方组装,Decimal 已转 str);
|
|
|
|
|
|
落库时包一层信封并固化采集时间——审批期间业务再改,复核者看到的仍是
|
|
|
|
|
|
送审内容。开关关闭的自动通过路径同样写快照,留痕不走样。
|
2026-10-15 06:50:38 -04:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
from app.core.config import get_settings
|
|
|
|
|
|
|
|
|
|
|
|
if not get_settings().payroll_allow_legacy_calculation and (
|
|
|
|
|
|
not get_settings().payroll_approval_enabled or not is_enabled(session, order_type)
|
|
|
|
|
|
):
|
|
|
|
|
|
raise BadRequestError("审核功能未开启,不创建审批单或自动通过痕迹")
|
|
|
|
|
|
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
|
2026-10-15 06:50:38 -04:00
|
|
|
|
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,
|
2026-10-20 06:30:51 -04:00
|
|
|
|
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
|
|
|
|
|
|
),
|
2026-10-15 06:50:38 -04:00
|
|
|
|
submitter_id=submitter.id,
|
|
|
|
|
|
submitter_name=submitter.username,
|
|
|
|
|
|
)
|
|
|
|
|
|
session.add(order)
|
|
|
|
|
|
|
|
|
|
|
|
if is_enabled(session, order_type):
|
|
|
|
|
|
order.status = APPROVAL_STATUS_PENDING_FINANCE
|
2026-10-16 02:23:53 -04:00
|
|
|
|
session.flush()
|
2026-10-15 06:50:38 -04:00
|
|
|
|
logger.info("审批单待审 | order=%s", order.order_no)
|
|
|
|
|
|
return order
|
|
|
|
|
|
|
|
|
|
|
|
# 开关关闭=直接生效:两级节点各记一条「自动通过」,留痕不断链
|
|
|
|
|
|
order.status = APPROVAL_STATUS_APPROVED
|
2026-10-16 02:23:53 -04:00
|
|
|
|
session.flush()
|
2026-10-15 06:50:38 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-20 06:30:51 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-16 02:23:53 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-15 06:50:38 -04:00
|
|
|
|
def act(
|
|
|
|
|
|
session: Session,
|
|
|
|
|
|
order_id: int,
|
|
|
|
|
|
payload: ApprovalActionIn,
|
|
|
|
|
|
*,
|
|
|
|
|
|
operator: User,
|
|
|
|
|
|
meta: RequestMeta | None = None,
|
|
|
|
|
|
) -> ApprovalOrderDetailOut:
|
|
|
|
|
|
"""节点动作(财务复核/领导审核):状态机流转 + 节点留痕(审计上下文)。
|
|
|
|
|
|
|
|
|
|
|
|
通过:status=1 → 2(下一步领导);status=2 → 3(终态已通过)。
|
|
|
|
|
|
驳回:任一在途状态 → 4(整体驳回,一期不退回重走)。
|
|
|
|
|
|
节点留痕的 node 取**动作发生时的当前节点**(通过/驳回都记在操作节点上)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
verb = ACTION_APPROVE if payload.action == APPROVAL_ACTION_APPROVE else ACTION_REJECT
|
2026-11-01 20:42:53 -05:00
|
|
|
|
from app.core.config import get_settings
|
|
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
|
not get_settings().payroll_allow_legacy_calculation
|
|
|
|
|
|
and not get_settings().payroll_approval_enabled
|
|
|
|
|
|
):
|
|
|
|
|
|
raise BadRequestError("当前工资流程无需系统审批,历史记录仅供查看")
|
2026-10-15 06:50:38 -04:00
|
|
|
|
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,
|
|
|
|
|
|
):
|
2026-11-02 22:20:55 -05:00
|
|
|
|
# 与工资生成保持相同人员锁顺序,审核驳回释放年度余额不得绕过结清锁。
|
|
|
|
|
|
hint = session.get(ApprovalOrder, order_id)
|
|
|
|
|
|
if hint is not None and hint.order_type in (2, 3):
|
|
|
|
|
|
from app.models import AdjustmentOrder, Personnel, SettlementItem
|
|
|
|
|
|
|
|
|
|
|
|
person_ids = []
|
|
|
|
|
|
if hint.order_type == 2:
|
|
|
|
|
|
person_ids = list(session.scalars(select(SettlementItem.personnel_id).where(SettlementItem.settlement_id == hint.ref_id)))
|
|
|
|
|
|
else:
|
|
|
|
|
|
target_adjustment = session.get(AdjustmentOrder, hint.ref_id)
|
|
|
|
|
|
if target_adjustment:
|
|
|
|
|
|
person_ids = [target_adjustment.personnel_id]
|
|
|
|
|
|
list(session.scalars(select(Personnel).where(Personnel.id.in_(person_ids)).order_by(Personnel.id).with_for_update()))
|
|
|
|
|
|
order = session.get(ApprovalOrder, order_id, with_for_update=True, populate_existing=True)
|
2026-10-15 06:50:38 -04:00
|
|
|
|
if order is None:
|
|
|
|
|
|
raise NotFoundError("审批单不存在")
|
|
|
|
|
|
if order.status not in _PENDING_STATUSES:
|
|
|
|
|
|
raise BadRequestError("审批单已结束,不可重复操作")
|
|
|
|
|
|
|
|
|
|
|
|
required_role = _NODE_ROLE_BY_STATUS[order.status]
|
2026-11-02 22:20:55 -05:00
|
|
|
|
from app.services.authorization_service import require_permission
|
|
|
|
|
|
|
|
|
|
|
|
require_permission(session, operator, "approvals:" + required_role)
|
2026-10-15 06:50:38 -04:00
|
|
|
|
|
|
|
|
|
|
node_now = APPROVAL_NODES[order.status]
|
2026-10-16 02:23:53 -04:00
|
|
|
|
rejected = payload.action == APPROVAL_ACTION_REJECT
|
|
|
|
|
|
if rejected:
|
|
|
|
|
|
order.status = APPROVAL_STATUS_REJECTED
|
|
|
|
|
|
else:
|
2026-10-15 06:50:38 -04:00
|
|
|
|
order.status = (
|
|
|
|
|
|
APPROVAL_STATUS_PENDING_LEADER
|
|
|
|
|
|
if order.status == APPROVAL_STATUS_PENDING_FINANCE
|
|
|
|
|
|
else APPROVAL_STATUS_APPROVED
|
|
|
|
|
|
)
|
2026-10-18 23:25:21 -04:00
|
|
|
|
# 生效类联动只认**终审**:财务节点通过(1→2)只是流转,不得提前
|
|
|
|
|
|
# 锁定月结/生效调整单——否则领导驳回后锁定字段残留、审批流形同虚设。
|
|
|
|
|
|
# 驳回在任何节点都是终态,即时联动。
|
|
|
|
|
|
final_approved = (not rejected) and order.status == APPROVAL_STATUS_APPROVED
|
2026-10-15 06:50:38 -04:00
|
|
|
|
|
|
|
|
|
|
session.add(
|
|
|
|
|
|
ApprovalStep(
|
|
|
|
|
|
order_id=order.id,
|
|
|
|
|
|
node=node_now,
|
|
|
|
|
|
action=payload.action,
|
|
|
|
|
|
opinion=payload.opinion,
|
|
|
|
|
|
operator_id=operator.id,
|
|
|
|
|
|
operator_name=operator.username,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-10-22 01:31:01 -04:00
|
|
|
|
# 驳回=方案未获批准:把该业务的生效方案行置历史(is_active=0,
|
|
|
|
|
|
# **不删行**——V11 起 append-only 版本化),防止被否决的方案以
|
|
|
|
|
|
# 「当前生效行」身份被 M3 核算快照算进工资;历史方案仍可在业务
|
|
|
|
|
|
# 详情 allocation_history 回溯。委托 business_repository 完成
|
|
|
|
|
|
# —— service 层互调、不触 commit。
|
2026-10-16 02:23:53 -04:00
|
|
|
|
if rejected and order.order_type == 1:
|
|
|
|
|
|
from app.repositories import business_repository
|
|
|
|
|
|
|
2026-10-20 06:30:51 -04:00
|
|
|
|
business_repository.deactivate_allocations(session, order.ref_id)
|
2026-10-16 02:23:53 -04:00
|
|
|
|
|
2026-10-16 12:33:31 -04:00
|
|
|
|
# 审批结果联动(服务层回调,不 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)
|
2026-10-18 23:25:21 -04:00
|
|
|
|
elif final_approved:
|
2026-10-16 12:33:31 -04:00
|
|
|
|
settlement_service.mark_locked(session, target, operator=operator)
|
|
|
|
|
|
else:
|
|
|
|
|
|
adj = session.get(AdjustmentOrder, order.ref_id)
|
|
|
|
|
|
if adj is not None:
|
2026-10-18 23:25:21 -04:00
|
|
|
|
if rejected:
|
|
|
|
|
|
adj.status = ADJ_REJECTED
|
|
|
|
|
|
elif final_approved:
|
|
|
|
|
|
adj.status = ADJUSTMENT_STATUS_EFFECTIVE
|
2026-10-16 12:33:31 -04:00
|
|
|
|
|
2026-10-16 02:23:53 -04:00
|
|
|
|
# 出参(含 steps)在 **commit 之前** 固化:audit 禁止 commit 后再碰
|
|
|
|
|
|
# ORM/查询——commit 后的任何 SELECT 失败都会让「已通过的审批」被记
|
|
|
|
|
|
# 成失败审计,与库中事实相反(全 services 层唯一例外不许再犯)
|
|
|
|
|
|
session.flush()
|
|
|
|
|
|
detail = _detail_of(session, order)
|
2026-10-15 06:50:38 -04:00
|
|
|
|
session.commit()
|
2026-10-16 02:23:53 -04:00
|
|
|
|
return detail
|
2026-10-15 06:50:38 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]:
|
2026-10-20 06:30:51 -04:00
|
|
|
|
"""分页查询审批单(准入由 api 层依赖判定)。
|
|
|
|
|
|
|
|
|
|
|
|
排除 snapshot_json 列(月结快照含按人明细,可达数十~上百 KB):列表出参
|
|
|
|
|
|
本就不含快照,整列加载会让翻页负担随快照体积线性放大;详情走
|
|
|
|
|
|
`get_order` 按需读取(与操作日志列表排除 request_data 同一理由,
|
|
|
|
|
|
见 operation_log_repository.list_logs)。
|
|
|
|
|
|
"""
|
2026-10-15 06:50:38 -04:00
|
|
|
|
filters = _order_filters(query)
|
|
|
|
|
|
rows = (
|
|
|
|
|
|
session.execute(
|
|
|
|
|
|
select(ApprovalOrder)
|
2026-10-20 06:30:51 -04:00
|
|
|
|
.options(defer(ApprovalOrder.snapshot_json))
|
2026-10-15 06:50:38 -04:00
|
|
|
|
.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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-20 06:30:51 -04:00
|
|
|
|
def _next_version(session: Session, order_type: int, ref_id: int) -> int:
|
2026-10-22 01:31:01 -04:00
|
|
|
|
"""同一对象类型+引用对象的下一个提交版本号(从 1 递增)。
|
|
|
|
|
|
|
|
|
|
|
|
**必须用锁读(当前读),不能用普通 MAX 查询**:普通 SELECT 在
|
|
|
|
|
|
REPEATABLE READ 下是快照读,读到的是本事务首次一致读的快照;请求级
|
|
|
|
|
|
鉴权(deps.get_current_user 与业务共用同一 DbSession)那次 SELECT 已把
|
|
|
|
|
|
快照锚定在请求开始时刻,此后即使父行锁等到并发事务提交,MAX 仍停在
|
|
|
|
|
|
旧快照上——两个请求会算出同一个 version_no。锁读是当前读,永远读最新
|
|
|
|
|
|
已提交数据。
|
|
|
|
|
|
|
|
|
|
|
|
**不能写成 select(func.max(...)).with_for_update()**:MySQL 官方明确
|
|
|
|
|
|
「Locking clauses are not permitted in queries with aggregate functions
|
|
|
|
|
|
or GROUP BY」,聚合结果上的 FOR UPDATE 语义无保证。改锁「该 ref 的现有
|
|
|
|
|
|
版本行」,最大值在 Python 侧算——同 no_generator 的既有套路。只取
|
|
|
|
|
|
version_no 单列,不把 snapshot_json 拉进内存。
|
|
|
|
|
|
"""
|
|
|
|
|
|
versions = (
|
|
|
|
|
|
session.execute(
|
|
|
|
|
|
select(ApprovalOrder.version_no)
|
|
|
|
|
|
.where(
|
|
|
|
|
|
ApprovalOrder.order_type == order_type,
|
|
|
|
|
|
ApprovalOrder.ref_id == ref_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
.with_for_update()
|
2026-10-20 06:30:51 -04:00
|
|
|
|
)
|
2026-10-22 01:31:01 -04:00
|
|
|
|
.scalars()
|
|
|
|
|
|
.all()
|
2026-10-20 06:30:51 -04:00
|
|
|
|
)
|
2026-10-22 01:31:01 -04:00
|
|
|
|
return (max(versions) if versions else 0) + 1
|
2026-10-20 06:30:51 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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),
|
2026-11-02 22:20:55 -05:00
|
|
|
|
"normal_payable_sum": str(item.normal_payable_sum),
|
|
|
|
|
|
"deferred_sum": str(item.deferred_sum),
|
|
|
|
|
|
"probation_payment_sum": str(item.probation_payment_sum),
|
2026-10-20 06:30:51 -04:00
|
|
|
|
"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"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-15 06:50:38 -04:00
|
|
|
|
def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
|
2026-10-20 06:30:51 -04:00
|
|
|
|
"""出参组装(含 steps 时间线与审批内容)。调用方须保证处于 commit 之前。"""
|
2026-10-15 06:50:38 -04:00
|
|
|
|
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]
|
2026-10-20 06:30:51 -04:00
|
|
|
|
detail.snapshot, detail.content_source = _content_of(session, order)
|
2026-10-15 06:50:38 -04:00
|
|
|
|
return detail
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _order_filters(query: ApprovalQuery) -> tuple:
|
|
|
|
|
|
filters = []
|
|
|
|
|
|
if query.keyword:
|
2026-10-16 02:23:53 -04:00
|
|
|
|
# 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)
|
|
|
|
|
|
)
|
2026-10-15 06:50:38 -04:00
|
|
|
|
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)
|