feat(approval): 审批单/节点状态机与二次分配接入(开关自动通过)
- approval_orders/approval_steps 服务层:submit_order(不 commit 不嵌套 audit,业务写路径统一收口)+ act 节点动作 + 分页/详情 - 开关关闭=提单即两级「自动通过」留痕(action=3,operator 记系统); 开启=财务→领导流转,一驳全驳,驳回必填意见 - 节点准入严格按角色(status=1 仅财务、status=2 仅领导),admin 不代办; 模块准入 require_roles 依赖工厂(admin/finance/leader,营销后勤待 澄清 #6 后放开) - 业务接入:创建/编辑带方案即提单(空方案不提单);再编辑自动撤回旧 在途单(node=0/action=4 留痕);AP 编号冲突 create 内可重取(外层 重试兜底)、update 直接抛出防静默丢失 - conftest 兜底清理增审批单(AP 前缀)与开关参数种子基线恢复 - 集成 11 例:自动通过/两节点流转/驳回留痕/缺意见 400/节点准入 403/ admin 不代办/重复操作 400/撤旧建新/模块准入 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
680661a80b
commit
8ef26e22fb
@ -113,3 +113,22 @@ def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> U
|
|||||||
|
|
||||||
|
|
||||||
AdminUser = Annotated[User, Depends(require_admin)]
|
AdminUser = Annotated[User, Depends(require_admin)]
|
||||||
|
|
||||||
|
|
||||||
|
def require_roles(*role_codes: str):
|
||||||
|
"""要求当前用户具备任一指定角色(依赖工厂)。
|
||||||
|
|
||||||
|
与 require_admin 的差别:admin 不是天然放行——传入集合里显式含
|
||||||
|
ROLE_ADMIN 才可见。用于审批等模块的粗粒度准入;节点级准入仍由
|
||||||
|
service 按单据状态二次校验(双层:模块可见 ≠ 可操作)。
|
||||||
|
|
||||||
|
:raises ForbiddenError: 已登录但不具备任一指定角色(403)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||||
|
codes = set(user_repository.get_role_codes(current_user))
|
||||||
|
if not codes & set(role_codes):
|
||||||
|
raise ForbiddenError("无权访问审批管理")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
return _checker
|
||||||
|
|||||||
@ -15,6 +15,7 @@ from app.api.v1.business import businesses as business_businesses
|
|||||||
from app.api.v1.business import ratings as business_ratings
|
from app.api.v1.business import ratings as business_ratings
|
||||||
from app.api.v1.business import receipts as business_receipts
|
from app.api.v1.business import receipts as business_receipts
|
||||||
from app.api.v1.business import shipments as business_shipments
|
from app.api.v1.business import shipments as business_shipments
|
||||||
|
from app.api.v1.system import approvals as system_approvals
|
||||||
from app.api.v1.system import business_roles as system_business_roles
|
from app.api.v1.system import business_roles as system_business_roles
|
||||||
from app.api.v1.system import logs as system_logs
|
from app.api.v1.system import logs as system_logs
|
||||||
from app.api.v1.system import params as system_params
|
from app.api.v1.system import params as system_params
|
||||||
@ -38,6 +39,7 @@ system_router.include_router(system_roles.router)
|
|||||||
system_router.include_router(system_business_roles.router)
|
system_router.include_router(system_business_roles.router)
|
||||||
system_router.include_router(system_params.router)
|
system_router.include_router(system_params.router)
|
||||||
system_router.include_router(system_settings.router)
|
system_router.include_router(system_settings.router)
|
||||||
|
system_router.include_router(system_approvals.router)
|
||||||
system_router.include_router(system_logs.operation_router)
|
system_router.include_router(system_logs.operation_router)
|
||||||
system_router.include_router(system_logs.login_router)
|
system_router.include_router(system_logs.login_router)
|
||||||
v1_router.include_router(system_router)
|
v1_router.include_router(system_router)
|
||||||
|
|||||||
67
backend/app/api/v1/system/approvals.py
Normal file
67
backend/app/api/v1/system/approvals.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"""审批管理接口(2026-10-15 复核审批补充需求)。
|
||||||
|
|
||||||
|
准入:admin/财务/领导可见(营销后勤暂不可见——权限清单待澄清 #6,
|
||||||
|
答复后放开为「提交人可见本人单据」)。节点级准入由 service 按单据
|
||||||
|
状态二次校验(status=1 仅财务、status=2 仅领导,admin 不代办)。
|
||||||
|
提单不经本模块:由业务写路径(创建/编辑带二次分配方案)自动触发。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, require_roles
|
||||||
|
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
|
||||||
|
from app.core.response import ApiResponse, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.approval import (
|
||||||
|
ApprovalActionIn,
|
||||||
|
ApprovalOrderDetailOut,
|
||||||
|
ApprovalOrderOut,
|
||||||
|
ApprovalQuery,
|
||||||
|
)
|
||||||
|
from app.services import approval_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/approvals", tags=["系统管理 · 审批管理"])
|
||||||
|
|
||||||
|
# 模块准入:可见集合(节点准入在 service 层按状态二次判定)
|
||||||
|
_ApprovalUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER))]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[ApprovalOrderOut]], summary="审批单列表")
|
||||||
|
def list_orders(
|
||||||
|
session: DbSession,
|
||||||
|
_: _ApprovalUser,
|
||||||
|
query: Annotated[ApprovalQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[ApprovalOrderOut]]:
|
||||||
|
"""分页查询审批单。"""
|
||||||
|
return ok(approval_service.list_orders(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{order_id}", response_model=ApiResponse[ApprovalOrderDetailOut], summary="审批详情")
|
||||||
|
def get_order(
|
||||||
|
session: DbSession,
|
||||||
|
_: _ApprovalUser,
|
||||||
|
order_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[ApprovalOrderDetailOut]:
|
||||||
|
"""审批单详情(含节点时间线)。"""
|
||||||
|
return ok(approval_service.get_order(session, order_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{order_id}/action",
|
||||||
|
response_model=ApiResponse[ApprovalOrderDetailOut],
|
||||||
|
summary="节点动作(通过/驳回)",
|
||||||
|
)
|
||||||
|
def act_on_order(
|
||||||
|
payload: ApprovalActionIn,
|
||||||
|
session: DbSession,
|
||||||
|
current_user: _ApprovalUser,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
order_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[ApprovalOrderDetailOut]:
|
||||||
|
"""财务复核 / 领导审核;一驳全驳,驳回须填意见。"""
|
||||||
|
return ok(
|
||||||
|
approval_service.act(session, order_id, payload, operator=current_user, meta=meta),
|
||||||
|
message="已提交",
|
||||||
|
)
|
||||||
@ -100,3 +100,5 @@ ACTION_DELETE = "删除"
|
|||||||
ACTION_RESET_PASSWORD = "重置密码"
|
ACTION_RESET_PASSWORD = "重置密码"
|
||||||
ACTION_ENABLE = "启用"
|
ACTION_ENABLE = "启用"
|
||||||
ACTION_DISABLE = "禁用"
|
ACTION_DISABLE = "禁用"
|
||||||
|
ACTION_APPROVE = "审批通过"
|
||||||
|
ACTION_REJECT = "审批驳回"
|
||||||
|
|||||||
@ -42,6 +42,10 @@ APPROVAL_ACTION_REJECT = 2 # 人工驳回
|
|||||||
APPROVAL_ACTION_AUTO_APPROVE = 3 # 系统自动通过(开关关闭时逐节点留痕)
|
APPROVAL_ACTION_AUTO_APPROVE = 3 # 系统自动通过(开关关闭时逐节点留痕)
|
||||||
APPROVAL_ACTION_WITHDRAW = 4 # 撤回(业务重新提交时旧在途单注销)
|
APPROVAL_ACTION_WITHDRAW = 4 # 撤回(业务重新提交时旧在途单注销)
|
||||||
|
|
||||||
|
# 审批对象类型(approval_orders.order_type;2 为 ④ 月结预留)
|
||||||
|
APPROVAL_TYPE_SECONDARY_ALLOCATION = 1
|
||||||
|
APPROVAL_TYPE_MONTHLY_SETTLEMENT = 2
|
||||||
|
|
||||||
|
|
||||||
class ApprovalOrder(Base, TimestampMixin):
|
class ApprovalOrder(Base, TimestampMixin):
|
||||||
"""审批单:一张待审对象一条审批流(状态可流转,含 update_time)。"""
|
"""审批单:一张待审对象一条审批流(状态可流转,含 update_time)。"""
|
||||||
|
|||||||
66
backend/app/schemas/approval.py
Normal file
66
backend/app/schemas/approval.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"""审批管理的请求/响应模型(2026-10-15 复核审批补充需求)。"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
from app.core.response import PageQuery
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalStepOut(BaseModel):
|
||||||
|
"""节点记录(时间线;action=3 自动通过 operator 记「系统」)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
node: int
|
||||||
|
action: int
|
||||||
|
opinion: str | None = None
|
||||||
|
operator_id: int | None = None
|
||||||
|
operator_name: str
|
||||||
|
create_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalOrderOut(BaseModel):
|
||||||
|
"""审批单列表行。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
order_no: str
|
||||||
|
order_type: int
|
||||||
|
ref_id: int
|
||||||
|
refer_no: str
|
||||||
|
status: int
|
||||||
|
submitter_id: int | None = None
|
||||||
|
submitter_name: str
|
||||||
|
create_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalOrderDetailOut(ApprovalOrderOut):
|
||||||
|
"""审批单详情:附节点时间线。"""
|
||||||
|
|
||||||
|
steps: list[ApprovalStepOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalQuery(PageQuery):
|
||||||
|
"""审批单列表查询条件。"""
|
||||||
|
|
||||||
|
keyword: str | None = Field(default=None, max_length=32, description="审批单号/引用单号,模糊")
|
||||||
|
status: int | None = Field(default=None, ge=1, le=5, description="状态筛选")
|
||||||
|
order_type: int | None = Field(default=None, ge=1, le=2, description="对象类型筛选")
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalActionIn(BaseModel):
|
||||||
|
"""节点动作:1 通过 / 2 驳回(驳回须填意见——空驳无从追溯)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
action: int = Field(ge=1, le=2, description="1通过 2驳回")
|
||||||
|
opinion: str | None = Field(default=None, max_length=255, description="审批意见")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_opinion(self) -> "ApprovalActionIn":
|
||||||
|
if self.action == 2 and not self.opinion:
|
||||||
|
raise ValueError("驳回必须填写审批意见")
|
||||||
|
return self
|
||||||
330
backend/app/services/approval_service.py
Normal file
330
backend/app/services/approval_service.py
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
"""审批流业务编排(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 datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_APPROVE,
|
||||||
|
ACTION_REJECT,
|
||||||
|
MODULE_APPROVAL,
|
||||||
|
NO_PREFIX_APPROVAL,
|
||||||
|
ROLE_FINANCE,
|
||||||
|
ROLE_LEADER,
|
||||||
|
)
|
||||||
|
from app.core.db_errors import is_duplicate_entry
|
||||||
|
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
|
||||||
|
from app.core.response import PageResult
|
||||||
|
from app.models import ApprovalOrder, ApprovalStep, User
|
||||||
|
from app.models.approval import (
|
||||||
|
APPROVAL_ACTION_APPROVE,
|
||||||
|
APPROVAL_ACTION_AUTO_APPROVE,
|
||||||
|
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 种子同源)
|
||||||
|
ENABLE_CODE_BY_TYPE: dict[int, str] = {
|
||||||
|
1: "approval.secondary_allocation.enabled",
|
||||||
|
2: "approval.monthly_settlement.enabled",
|
||||||
|
}
|
||||||
|
|
||||||
|
_NO_RETRY = 1 # AP 单号唯一键冲突重试(next_no 的 MAX+FOR UPDATE 之外的兜底)
|
||||||
|
|
||||||
|
# 在途状态集合(同一引用对象至多一张在途单)
|
||||||
|
_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 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,
|
||||||
|
retry_on_conflict: bool,
|
||||||
|
) -> ApprovalOrder:
|
||||||
|
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。
|
||||||
|
|
||||||
|
同一引用对象的在途单先撤回留痕再建新单;开关关闭时生成即自动通过
|
||||||
|
(全节点 action=3 留痕)。
|
||||||
|
|
||||||
|
:param retry_on_conflict: AP 编号撞唯一键时是否允许本函数内部
|
||||||
|
rollback 后重取。**仅 create 传 True**——它外层有编号重试循环,
|
||||||
|
rollback 后整体重来;update 路径必须传 False:内部 rollback 会把
|
||||||
|
同事务的业务行修改一并回滚,静默继续会「返回 200 库里没改」。
|
||||||
|
False 时 IntegrityError 直接抛出 → 审计上下文回滚并记失败 →
|
||||||
|
全局 handler 收敛 400,用户重试即可。
|
||||||
|
"""
|
||||||
|
_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,
|
||||||
|
submitter_id=submitter.id,
|
||||||
|
submitter_name=submitter.username,
|
||||||
|
)
|
||||||
|
session.add(order)
|
||||||
|
|
||||||
|
if is_enabled(session, order_type):
|
||||||
|
order.status = APPROVAL_STATUS_PENDING_FINANCE
|
||||||
|
_flush_order(session, order, retry_on_conflict=retry_on_conflict)
|
||||||
|
logger.info("审批单待审 | order=%s", order.order_no)
|
||||||
|
return order
|
||||||
|
|
||||||
|
# 开关关闭=直接生效:两级节点各记一条「自动通过」,留痕不断链
|
||||||
|
order.status = APPROVAL_STATUS_APPROVED
|
||||||
|
_flush_order(session, order, retry_on_conflict=retry_on_conflict)
|
||||||
|
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 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]
|
||||||
|
if payload.action == APPROVAL_ACTION_APPROVE:
|
||||||
|
order.status = (
|
||||||
|
APPROVAL_STATUS_PENDING_LEADER
|
||||||
|
if order.status == APPROVAL_STATUS_PENDING_FINANCE
|
||||||
|
else APPROVAL_STATUS_APPROVED
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
order.status = APPROVAL_STATUS_REJECTED
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
ApprovalStep(
|
||||||
|
order_id=order.id,
|
||||||
|
node=node_now,
|
||||||
|
action=payload.action,
|
||||||
|
opinion=payload.opinion,
|
||||||
|
operator_id=operator.id,
|
||||||
|
operator_name=operator.username,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return _detail_of(session, order)
|
||||||
|
|
||||||
|
|
||||||
|
def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]:
|
||||||
|
"""分页查询审批单(准入由 api 层依赖判定)。"""
|
||||||
|
filters = _order_filters(query)
|
||||||
|
rows = (
|
||||||
|
session.execute(
|
||||||
|
select(ApprovalOrder)
|
||||||
|
.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 _flush_order(session: Session, order: ApprovalOrder, *, retry_on_conflict: bool) -> None:
|
||||||
|
"""flush 提单;撞 AP 单号唯一键时按 retry_on_conflict 决定重取或抛出。
|
||||||
|
|
||||||
|
rollback 会连业务写路径的未提交变更一并回滚 —— 见 submit_order 的
|
||||||
|
retry_on_conflict 说明(create 才允许内部重取)。
|
||||||
|
"""
|
||||||
|
for attempt in range(_NO_RETRY + 1):
|
||||||
|
try:
|
||||||
|
session.flush()
|
||||||
|
return
|
||||||
|
except IntegrityError as exc:
|
||||||
|
session.rollback()
|
||||||
|
if is_duplicate_entry(exc) and retry_on_conflict and attempt < _NO_RETRY:
|
||||||
|
order.id = None # rollback 后实例回 transient,主键重新分配
|
||||||
|
order.order_no = next_no(
|
||||||
|
session,
|
||||||
|
model=ApprovalOrder,
|
||||||
|
column_name="order_no",
|
||||||
|
prefix=NO_PREFIX_APPROVAL,
|
||||||
|
)
|
||||||
|
session.add(order)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
|
||||||
|
"""出参组装(含 steps 时间线)。"""
|
||||||
|
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]
|
||||||
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
def _order_filters(query: ApprovalQuery) -> tuple:
|
||||||
|
filters = []
|
||||||
|
if query.keyword:
|
||||||
|
like = f"%{query.keyword}%"
|
||||||
|
filters.append(ApprovalOrder.order_no.like(like) | ApprovalOrder.refer_no.like(like))
|
||||||
|
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)
|
||||||
@ -45,6 +45,7 @@ from app.models import (
|
|||||||
Shipment,
|
Shipment,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
from app.models.approval import APPROVAL_TYPE_SECONDARY_ALLOCATION
|
||||||
from app.models.business import (
|
from app.models.business import (
|
||||||
BUSINESS_STATUS_ACTIVE,
|
BUSINESS_STATUS_ACTIVE,
|
||||||
BUSINESS_STATUS_DONE,
|
BUSINESS_STATUS_DONE,
|
||||||
@ -69,7 +70,7 @@ from app.schemas.business import (
|
|||||||
SecondaryAllocationIn,
|
SecondaryAllocationIn,
|
||||||
SecondaryAllocationOut,
|
SecondaryAllocationOut,
|
||||||
)
|
)
|
||||||
from app.services import audit_service, param_service, personnel_service
|
from app.services import approval_service, audit_service, param_service, personnel_service
|
||||||
from app.services.audit_service import AuditRecord, RequestMeta
|
from app.services.audit_service import AuditRecord, RequestMeta
|
||||||
from app.services.formula import converted_profit, profit_rate
|
from app.services.formula import converted_profit, profit_rate
|
||||||
from app.services.no_generator import next_no
|
from app.services.no_generator import next_no
|
||||||
@ -389,6 +390,18 @@ def create_business(
|
|||||||
business.id,
|
business.id,
|
||||||
_build_allocation_rows(session, business.id, payload.secondary_allocations),
|
_build_allocation_rows(session, business.id, payload.secondary_allocations),
|
||||||
)
|
)
|
||||||
|
# 方案非空才提单:开关关闭=自动通过留痕,开启=待财务复核。
|
||||||
|
# retry_on_conflict=True:AP 编号撞键可内部重取(外层编号
|
||||||
|
# 重试循环兜底整体重来)
|
||||||
|
if payload.secondary_allocations:
|
||||||
|
approval_service.submit_order(
|
||||||
|
session,
|
||||||
|
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||||
|
ref_id=business.id,
|
||||||
|
refer_no=business.business_no,
|
||||||
|
submitter=operator,
|
||||||
|
retry_on_conflict=True,
|
||||||
|
)
|
||||||
customer = customer_repository.get_by_id(session, business.customer_id)
|
customer = customer_repository.get_by_id(session, business.customer_id)
|
||||||
out = _to_out((business, customer.company_name if customer else None, None, None))
|
out = _to_out((business, customer.company_name if customer else None, None, None))
|
||||||
new_id, new_no = business.id, business.business_no # 固化:commit 后不碰 ORM
|
new_id, new_no = business.id, business.business_no # 固化:commit 后不碰 ORM
|
||||||
@ -469,6 +482,17 @@ def update_business(
|
|||||||
business_id,
|
business_id,
|
||||||
_build_allocation_rows(session, business_id, payload.secondary_allocations),
|
_build_allocation_rows(session, business_id, payload.secondary_allocations),
|
||||||
)
|
)
|
||||||
|
# 方案非空提单(自动撤回旧在途单);retry_on_conflict=False:
|
||||||
|
# 本路径无外层重试,内部 rollback 会回掉业务行修改造成静默丢失
|
||||||
|
if payload.secondary_allocations:
|
||||||
|
approval_service.submit_order(
|
||||||
|
session,
|
||||||
|
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||||
|
ref_id=business_id,
|
||||||
|
refer_no=business.business_no,
|
||||||
|
submitter=operator,
|
||||||
|
retry_on_conflict=False,
|
||||||
|
)
|
||||||
_recalc_totals(session, business)
|
_recalc_totals(session, business)
|
||||||
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
|
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
|
||||||
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
|
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
|
||||||
|
|||||||
@ -29,6 +29,7 @@ from app.core.constants import ROLE_ADMIN, ROLE_USER
|
|||||||
from app.core.db import get_session_factory
|
from app.core.db import get_session_factory
|
||||||
from app.core.security import create_access_token, hash_password
|
from app.core.security import create_access_token, hash_password
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
ApprovalOrder,
|
||||||
Business,
|
Business,
|
||||||
BusinessItem,
|
BusinessItem,
|
||||||
BusinessRole,
|
BusinessRole,
|
||||||
@ -37,6 +38,8 @@ from app.models import (
|
|||||||
CustomerRatingGrade,
|
CustomerRatingGrade,
|
||||||
OperationLog,
|
OperationLog,
|
||||||
OwnershipChangeLog,
|
OwnershipChangeLog,
|
||||||
|
ParamChangeBatch,
|
||||||
|
ParamValue,
|
||||||
Personnel,
|
Personnel,
|
||||||
PersonnelPlan,
|
PersonnelPlan,
|
||||||
PersonnelRegion,
|
PersonnelRegion,
|
||||||
@ -168,6 +171,18 @@ def _purge_test_data() -> None:
|
|||||||
session.execute(delete(Product).where(Product.product_name.like("t\\_%")))
|
session.execute(delete(Product).where(Product.product_name.like("t\\_%")))
|
||||||
# 人员已先删(business_role_id 外键 RESTRICT),直建的测试岗位此时可安全删
|
# 人员已先删(business_role_id 外键 RESTRICT),直建的测试岗位此时可安全删
|
||||||
session.execute(delete(BusinessRole).where(BusinessRole.role_code.like("t\\_%")))
|
session.execute(delete(BusinessRole).where(BusinessRole.role_code.like("t\\_%")))
|
||||||
|
|
||||||
|
# ---- M3:审批单 / 二次分配 / 参数开关基线 ----
|
||||||
|
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
|
||||||
|
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
|
||||||
|
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
|
||||||
|
# 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 21
|
||||||
|
# (V3 十九条 + V6 两条),删新条目与新批次后解析回退到种子值(开关=0)
|
||||||
|
stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 21)).scalars())
|
||||||
|
if stray_values:
|
||||||
|
stray_batches = {v.batch_id for v in stray_values}
|
||||||
|
session.execute(delete(ParamValue).where(ParamValue.id > 21))
|
||||||
|
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches)))
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
312
backend/tests/integration/test_approval_flow.py
Normal file
312
backend/tests/integration/test_approval_flow.py
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
"""审批流集成测试(二次分配方案为首个接入对象)。
|
||||||
|
|
||||||
|
守的是(计划 T4 验收矩阵):
|
||||||
|
- 开关关闭(种子默认 0):提单即「自动通过」——status=3 且两级节点
|
||||||
|
action=3 留痕(留痕不断链,关闭=直接生效的需求口径)
|
||||||
|
- 开关开启:status=1 → 财务通过 → status=2 → 领导通过 → status=3
|
||||||
|
- 驳回:整体驳回 status=4,意见留痕;驳回必填意见(空驳 400)
|
||||||
|
- 节点准入:status=1 仅财务、status=2 仅领导;**admin 不代办**(403);
|
||||||
|
普通用户/营销后勤进不了模块(403)
|
||||||
|
- 再编辑撤旧建新:在途单 status=5 + node=0/action=4 撤回留痕,新单新建
|
||||||
|
- 方案为空不提单
|
||||||
|
- 开关读一次:同一提交路径内参数变化不影响判定(种子级默认行为锁定)
|
||||||
|
|
||||||
|
角色用户经 make_user 造(finance/leader/marketing_ops 三新角色种子在库)。
|
||||||
|
开关联动走参数变更批次(PUT /system/params 端到端,不直改库)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.integration.factories import business_payload, customer
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_BUSINESSES = "/api/v1/business/businesses"
|
||||||
|
_APPROVALS = "/api/v1/system/approvals"
|
||||||
|
_PARAMS = "/api/v1/system/params"
|
||||||
|
_PERSONNEL = "/api/v1/base/personnel"
|
||||||
|
|
||||||
|
|
||||||
|
def _person(client: TestClient, headers: dict) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
_PERSONNEL,
|
||||||
|
json={
|
||||||
|
"personnel_name": f"t_审批_{uuid.uuid4().hex[:8]}",
|
||||||
|
"business_role_id": _any_role_id(),
|
||||||
|
"salary_standard": "3000.00",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _any_role_id() -> int:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.constants import BUSINESS_ROLE_SALESPERSON
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import BusinessRole
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
return int(
|
||||||
|
session.execute(
|
||||||
|
select(BusinessRole.id).where(
|
||||||
|
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
|
||||||
|
BusinessRole.status == 1,
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_with_plan(client: TestClient, headers: dict, personnel_id: int) -> dict:
|
||||||
|
cust = customer(client, headers)
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json=business_payload(
|
||||||
|
cust["id"],
|
||||||
|
secondary_allocations=[{"personnel_id": personnel_id, "ratio": "50.00"}],
|
||||||
|
),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_order(client: TestClient, headers: dict, refer_no: str) -> dict:
|
||||||
|
resp = client.get(_APPROVALS, params={"keyword": refer_no, "pageSize": 1}, headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
rows = resp.json()["data"]["list"]
|
||||||
|
assert rows, f"未找到审批单:{refer_no}"
|
||||||
|
detail = client.get(f"{_APPROVALS}/{rows[0]['id']}", headers=headers)
|
||||||
|
return detail.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _set_switch(client: TestClient, admin_headers: dict, value: str) -> None:
|
||||||
|
"""经参数变更批次把二次分配审批开关置值(立即生效)。"""
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"remark": f"测试切换审批开关={value}",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"param_code": "approval.secondary_allocation.enabled",
|
||||||
|
"dimension_value": "",
|
||||||
|
"value": value,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=admin_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def finance(make_user, auth_headers) -> dict:
|
||||||
|
"""财务角色账号(审批第二节点)。"""
|
||||||
|
user = make_user(["finance"])
|
||||||
|
user["headers"] = auth_headers(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def leader(make_user, auth_headers) -> dict:
|
||||||
|
"""领导角色账号(审批第三节点)。"""
|
||||||
|
user = make_user(["leader"])
|
||||||
|
user["headers"] = auth_headers(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def switched_on(client: TestClient, admin: dict) -> None:
|
||||||
|
"""把二次分配审批开关置 1(立即生效;用例结束由 conftest 恢复种子基线)。"""
|
||||||
|
_set_switch(client, admin["headers"], "1")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSwitchOff:
|
||||||
|
"""开关关闭(种子默认):提单即自动通过。"""
|
||||||
|
|
||||||
|
def test_创建带方案_自动通过全节点留痕(
|
||||||
|
self, client: TestClient, admin: dict, finance: dict
|
||||||
|
) -> None:
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
|
||||||
|
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
assert order["status"] == 3
|
||||||
|
assert order["order_no"].startswith("AP")
|
||||||
|
auto_steps = [s for s in order["steps"] if s["action"] == 3]
|
||||||
|
assert len(auto_steps) == 2
|
||||||
|
assert {s["node"] for s in auto_steps} == {1, 2}
|
||||||
|
assert all(s["operator_name"] == "系统" for s in auto_steps)
|
||||||
|
|
||||||
|
def test_方案为空不提单(self, client: TestClient, admin: dict, finance: dict) -> None:
|
||||||
|
cust = customer(client, admin["headers"])
|
||||||
|
resp = client.post(_BUSINESSES, json=business_payload(cust["id"]), headers=admin["headers"])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
rows = client.get(
|
||||||
|
_APPROVALS,
|
||||||
|
params={"keyword": resp.json()["data"]["business_no"]},
|
||||||
|
headers=finance["headers"],
|
||||||
|
).json()["data"]["list"]
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestSwitchOn:
|
||||||
|
"""开关开启:两节点人工流转。"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def switched_on(self, client: TestClient, admin: dict) -> None:
|
||||||
|
_set_switch(client, admin["headers"], "1")
|
||||||
|
|
||||||
|
def test_两节点流转至通过(
|
||||||
|
self, client: TestClient, admin: dict, finance, leader, switched_on
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
|
||||||
|
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
assert order["status"] == 1
|
||||||
|
|
||||||
|
# 财务通过 → 待领导
|
||||||
|
resp = client.post(
|
||||||
|
f"{_APPROVALS}/{order['id']}/action",
|
||||||
|
json={"action": 1, "opinion": "同意"},
|
||||||
|
headers=finance["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["data"]["status"] == 2
|
||||||
|
|
||||||
|
# 领导通过 → 已通过
|
||||||
|
resp = client.post(
|
||||||
|
f"{_APPROVALS}/{order['id']}/action",
|
||||||
|
json={"action": 1},
|
||||||
|
headers=leader["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["data"]["status"] == 3
|
||||||
|
manual = [s for s in resp.json()["data"]["steps"] if s["action"] == 1]
|
||||||
|
assert {s["node"] for s in manual} == {1, 2}
|
||||||
|
|
||||||
|
def test_财务驳回_整体驳回且意见留痕(
|
||||||
|
self, client: TestClient, admin: dict, finance, leader, switched_on
|
||||||
|
) -> None:
|
||||||
|
_ = leader
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"{_APPROVALS}/{order['id']}/action",
|
||||||
|
json={"action": 2, "opinion": "比例分配不合理"},
|
||||||
|
headers=finance["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
out = resp.json()["data"]
|
||||||
|
assert out["status"] == 4
|
||||||
|
rejected = [s for s in out["steps"] if s["action"] == 2]
|
||||||
|
assert rejected and rejected[0]["opinion"] == "比例分配不合理"
|
||||||
|
|
||||||
|
def test_驳回缺意见被拒(
|
||||||
|
self, client: TestClient, admin: dict, finance, leader, switched_on
|
||||||
|
) -> None:
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"{_APPROVALS}/{order['id']}/action",
|
||||||
|
json={"action": 2},
|
||||||
|
headers=finance["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_节点准入_领导不能审财务节点_admin不代办(
|
||||||
|
self, client: TestClient, admin: dict, finance, leader, switched_on
|
||||||
|
) -> None:
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
order = _latest_order(client, leader["headers"], data["business_no"])
|
||||||
|
|
||||||
|
# status=1 是财务节点:领导不行,admin 也不代办
|
||||||
|
for headers in (leader["headers"], admin["headers"]):
|
||||||
|
resp = client.post(
|
||||||
|
f"{_APPROVALS}/{order['id']}/action",
|
||||||
|
json={"action": 1},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_重复操作已通过单被拒(
|
||||||
|
self, client: TestClient, admin: dict, finance, leader, switched_on
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
order = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
client.post(
|
||||||
|
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=finance["headers"]
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=leader["headers"]
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=finance["headers"]
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestResubmit:
|
||||||
|
"""再编辑撤旧建新。"""
|
||||||
|
|
||||||
|
def test_在途单撤回并新建(
|
||||||
|
self, client: TestClient, admin: dict, finance, leader, switched_on
|
||||||
|
) -> None:
|
||||||
|
p = _person(client, admin["headers"])
|
||||||
|
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||||
|
first = _latest_order(client, finance["headers"], data["business_no"])
|
||||||
|
assert first["status"] == 1
|
||||||
|
|
||||||
|
# 再编辑(仍带方案)→ 旧在途单撤回、新单待审
|
||||||
|
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
|
||||||
|
payload = business_payload(
|
||||||
|
detail["customer_id"],
|
||||||
|
secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}],
|
||||||
|
)
|
||||||
|
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
rows = client.get(
|
||||||
|
_APPROVALS,
|
||||||
|
params={"keyword": data["business_no"], "pageSize": 10},
|
||||||
|
headers=finance["headers"],
|
||||||
|
).json()["data"]["list"]
|
||||||
|
statuses = {row["status"] for row in rows}
|
||||||
|
assert 5 in statuses and 1 in statuses, f"未见撤回+新建:{statuses}"
|
||||||
|
old_detail = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()
|
||||||
|
withdraw_steps = [s for s in old_detail["data"]["steps"] if s["action"] == 4]
|
||||||
|
assert withdraw_steps and withdraw_steps[0]["node"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestModuleAccess:
|
||||||
|
"""模块准入:普通用户与营销后勤不可见(权限清单待澄清 #6)。"""
|
||||||
|
|
||||||
|
def test_普通用户403(self, client: TestClient, normal: dict) -> None:
|
||||||
|
assert client.get(_APPROVALS, headers=normal["headers"]).status_code == 403
|
||||||
|
|
||||||
|
def test_营销后勤403_暂不可见(self, client: TestClient, make_user, auth_headers) -> None:
|
||||||
|
ops = make_user(["marketing_ops"])
|
||||||
|
ops["headers"] = auth_headers(ops)
|
||||||
|
assert client.get(_APPROVALS, headers=ops["headers"]).status_code == 403
|
||||||
|
|
||||||
|
def test_财务与领导可见(self, client: TestClient, make_user, auth_headers) -> None:
|
||||||
|
for code in ("finance", "leader"):
|
||||||
|
user = make_user([code])
|
||||||
|
user["headers"] = auth_headers(user)
|
||||||
|
assert client.get(_APPROVALS, headers=user["headers"]).status_code == 200
|
||||||
Loading…
Reference in New Issue
Block a user