fix(m3): code-review 15 项修复(13 项落地,2 项依赖分摊语义记待办)

- 审批:删 _flush_order 撞键内部重试(rollback 会回掉同事务业务行造成
  静默丢数据,异常改由 create 外层重试整体重来/update 路径审计收敛);
  act() 出参固化前置到 commit 之前(commit 后查询失败会把已通过的审批
  记成失败审计);驳回/撤回清空方案生效行(防被否决方案进 M3 快照);
  keyword 改 contains(autoescape=True) 对齐全库转义口径;撤单抽
  withdraw_pending_orders 供编辑清空方案时显式调用
- 业务:删业务遇在途审批单禁删 400、终态审批单随 _hard_delete 清理;
  编辑清空方案同步撤回在途单;历史离职参与人编辑放行(冗余姓名固化
  语义,仅新增参与人做在职校验)
- 迁移:business_secondary_allocations 补 uk(business_id, personnel_id)
  (模型/迁移 py/导出 SQL/init-dev 四方同步,开发库往返重放验证)
- 设置:upload_logo 改同步 def(阻塞操作不卡事件循环)+ file.size 先拦;
  company_name schema 剥空白防全空白入库;logo 旧文件清理移到 commit
  成功后(防库指旧路径而文件已删断链);uploadLogo 走 request() 解包
  (原直发 service.post 不解包信封,logoUrl 会被置 undefined)
- 备份:restore_drill --drop-exec 先清全部历史 drill 库再演练并即删本轮
  (原语义永远删错对象,每轮净增一份整库拷贝)
- 测试:conftest 清理补 system_settings 种子行与 uploads/logo 文件;
  新增 5 例(驳回清方案/清空撤单/在途禁删终态随删/离职放行/BOOL 边界);
  全量回归 701 passed
- 遗留 2 项记 AI_CONTEXT(方案版本承载、审批单快照,依赖 ④ 分摊语义)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxin 2026-10-16 14:23:53 +08:00
parent fc8a42bcd9
commit 1c93ea0a4b
14 changed files with 338 additions and 79 deletions

View File

@ -255,6 +255,11 @@ def upgrade() -> None:
ondelete="RESTRICT", ondelete="RESTRICT",
), ),
sa.PrimaryKeyConstraint("id", name=op.f("pk_business_secondary_allocations")), sa.PrimaryKeyConstraint("id", name=op.f("pk_business_secondary_allocations")),
sa.UniqueConstraint(
"business_id",
"personnel_id",
name="uk_business_secondary_allocations_business_personnel",
),
comment="业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)", comment="业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
mysql_charset="utf8mb4", mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci", mysql_collate="utf8mb4_general_ci",

View File

@ -12,6 +12,7 @@ from fastapi import APIRouter, Response, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from app.api.deps import AdminUser, DbSession, RequestMetaDep from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.exceptions import BadRequestError
from app.core.response import ApiResponse, ok from app.core.response import ApiResponse, ok
from app.schemas.setting import SettingOut, SettingPublicOut, SettingUpdateIn from app.schemas.setting import SettingOut, SettingPublicOut, SettingUpdateIn
from app.services import system_setting_service as service from app.services import system_setting_service as service
@ -42,14 +43,21 @@ def update_settings(
@router.post("/logo", response_model=ApiResponse[SettingOut], summary="上传 logo") @router.post("/logo", response_model=ApiResponse[SettingOut], summary="上传 logo")
async def upload_logo( def upload_logo(
file: UploadFile, file: UploadFile,
session: DbSession, session: DbSession,
current_user: AdminUser, current_user: AdminUser,
meta: RequestMetaDep, meta: RequestMetaDep,
) -> ApiResponse[SettingOut]: ) -> ApiResponse[SettingOut]:
"""上传公司 logo(png/jpg ≤2MB;固定名覆盖,旧扩展名文件清理)。""" """上传公司 logo(png/jpg ≤2MB;固定名覆盖,旧扩展名文件清理)。
content = await file.read()
同步 `def`:读文件、磁盘写与审计提交都是阻塞操作,async 路由里会让
它们卡死事件循环(项目红线:全栈同步路由,FastAPI 自动进线程池)。
大小先用 `file.size` 拦截,避免超限文件整体读进内存。
"""
if file.size is not None and file.size > service.LOGO_MAX_BYTES:
raise BadRequestError("logo 文件不能超过 2MB")
content = file.file.read()
ext = (file.filename or "").rsplit(".", 1)[-1] ext = (file.filename or "").rsplit(".", 1)[-1]
return ok( return ok(
service.save_logo(session, content, ext, operator=current_user, meta=meta), service.save_logo(session, content, ext, operator=current_user, meta=meta),

View File

@ -15,7 +15,7 @@
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, text from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import ( from app.models.base import (
@ -56,6 +56,11 @@ class SecondaryAllocation(Base, TimestampMixin):
# 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」, # 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」,
# 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例) # 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例)
"业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)", "业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)",
UniqueConstraint(
"business_id",
"personnel_id",
name="uk_business_secondary_allocations_business_personnel",
),
Index("idx_business_secondary_allocations_business_id", "business_id"), Index("idx_business_secondary_allocations_business_id", "business_id"),
Index("idx_business_secondary_allocations_personnel_id", "personnel_id"), Index("idx_business_secondary_allocations_personnel_id", "personnel_id"),
) )

View File

@ -228,6 +228,18 @@ def replace_allocations(
session.flush() session.flush()
def delete_allocations(session: Session, business_id: int) -> None:
"""清空业务的二次分配方案(审批驳回/撤回时由 approval_service 调用)。
被否决的方案不得以「当前生效行」身份留在表里(M3 核算快照会取到);
方案事实由审批单留痕与操作日志 request_data 承载。
"""
session.execute(
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
)
session.flush()
def add_status_log(session: Session, log: BusinessStatusLog) -> None: def add_status_log(session: Session, log: BusinessStatusLog) -> None:
"""追加状态留痕(不 commit)。""" """追加状态留痕(不 commit)。"""
session.add(log) session.add(log)

View File

@ -5,7 +5,7 @@
顺着复用链泄漏到匿名端点。 顺着复用链泄漏到匿名端点。
""" """
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
class SettingOut(BaseModel): class SettingOut(BaseModel):
@ -23,6 +23,12 @@ class SettingPublicOut(BaseModel):
class SettingUpdateIn(BaseModel): class SettingUpdateIn(BaseModel):
"""公司信息更新。""" """公司信息更新。
str_strip_whitespace:全空白名称先剥再校验——否则 `" "` 过 min_length=1
后由服务层 strip 成空串入库(200 成功但 public 端点返回空公司名)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
company_name: str = Field(min_length=1, max_length=64, description="公司名称") company_name: str = Field(min_length=1, max_length=64, description="公司名称")

View File

@ -22,7 +22,6 @@ import logging
from datetime import date from datetime import date
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.constants import ( from app.core.constants import (
@ -33,13 +32,13 @@ from app.core.constants import (
ROLE_FINANCE, ROLE_FINANCE,
ROLE_LEADER, ROLE_LEADER,
) )
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult from app.core.response import PageResult
from app.models import ApprovalOrder, ApprovalStep, User from app.models import ApprovalOrder, ApprovalStep, User
from app.models.approval import ( from app.models.approval import (
APPROVAL_ACTION_APPROVE, APPROVAL_ACTION_APPROVE,
APPROVAL_ACTION_AUTO_APPROVE, APPROVAL_ACTION_AUTO_APPROVE,
APPROVAL_ACTION_REJECT,
APPROVAL_ACTION_WITHDRAW, APPROVAL_ACTION_WITHDRAW,
APPROVAL_NODE_FINANCE, APPROVAL_NODE_FINANCE,
APPROVAL_NODE_LEADER, APPROVAL_NODE_LEADER,
@ -71,8 +70,6 @@ ENABLE_CODE_BY_TYPE: dict[int, str] = {
2: "approval.monthly_settlement.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) _PENDING_STATUSES = (APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER)
@ -100,19 +97,15 @@ def submit_order(
ref_id: int, ref_id: int,
refer_no: str, refer_no: str,
submitter: User, submitter: User,
retry_on_conflict: bool,
) -> ApprovalOrder: ) -> ApprovalOrder:
"""提单(业务写路径内部调用;不 commit、不嵌套 audit)。 """提单(业务写路径内部调用;不 commit、不嵌套 audit)。
同一引用对象的在途单先撤回留痕再建新单;开关关闭时生成即自动通过 同一引用对象的在途单先撤回留痕再建新单;开关关闭时生成即自动通过
(全节点 action=3 留痕)。 (全节点 action=3 留痕)。AP 编号撞唯一键时 IntegrityError 直接穿透:
create 路径由外层编号重试循环整体重来(rollback 后业务/方案/审批单
:param retry_on_conflict: AP 编号撞唯一键时是否允许本函数内部 一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400——
rollback 后重取。**仅 create 传 True**——它外层有编号重试循环, **任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务
rollback 后整体重来;update 路径必须传 False:内部 rollback 会把 的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。
同事务的业务行修改一并回滚,静默继续会「返回 200 库里没改」。
False 时 IntegrityError 直接抛出 → 审计上下文回滚并记失败 →
全局 handler 收敛 400,用户重试即可。
""" """
_withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter) _withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter)
@ -133,13 +126,13 @@ def submit_order(
if is_enabled(session, order_type): if is_enabled(session, order_type):
order.status = APPROVAL_STATUS_PENDING_FINANCE order.status = APPROVAL_STATUS_PENDING_FINANCE
_flush_order(session, order, retry_on_conflict=retry_on_conflict) session.flush()
logger.info("审批单待审 | order=%s", order.order_no) logger.info("审批单待审 | order=%s", order.order_no)
return order return order
# 开关关闭=直接生效:两级节点各记一条「自动通过」,留痕不断链 # 开关关闭=直接生效:两级节点各记一条「自动通过」,留痕不断链
order.status = APPROVAL_STATUS_APPROVED order.status = APPROVAL_STATUS_APPROVED
_flush_order(session, order, retry_on_conflict=retry_on_conflict) session.flush()
session.add_all( session.add_all(
[ [
ApprovalStep( ApprovalStep(
@ -156,6 +149,29 @@ def submit_order(
return order return order
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( def act(
session: Session, session: Session,
order_id: int, order_id: int,
@ -194,14 +210,15 @@ def act(
raise ForbiddenError(f"当前节点须{_ROLE_LABEL[required_role]}角色操作") raise ForbiddenError(f"当前节点须{_ROLE_LABEL[required_role]}角色操作")
node_now = APPROVAL_NODES[order.status] node_now = APPROVAL_NODES[order.status]
if payload.action == APPROVAL_ACTION_APPROVE: rejected = payload.action == APPROVAL_ACTION_REJECT
if rejected:
order.status = APPROVAL_STATUS_REJECTED
else:
order.status = ( order.status = (
APPROVAL_STATUS_PENDING_LEADER APPROVAL_STATUS_PENDING_LEADER
if order.status == APPROVAL_STATUS_PENDING_FINANCE if order.status == APPROVAL_STATUS_PENDING_FINANCE
else APPROVAL_STATUS_APPROVED else APPROVAL_STATUS_APPROVED
) )
else:
order.status = APPROVAL_STATUS_REJECTED
session.add( session.add(
ApprovalStep( ApprovalStep(
@ -213,8 +230,23 @@ def act(
operator_name=operator.username, 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.delete_allocations(session, order.ref_id)
# 出参(含 steps)在 **commit 之前** 固化:audit 禁止 commit 后再碰
# ORM/查询——commit 后的任何 SELECT 失败都会让「已通过的审批」被记
# 成失败审计,与库中事实相反(全 services 层唯一例外不许再犯)
session.flush()
detail = _detail_of(session, order)
session.commit() session.commit()
return _detail_of(session, order) return detail
def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]: def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]:
@ -279,33 +311,8 @@ def _withdraw_pending(session: Session, *, order_type: int, ref_id: int, operato
logger.info("撤回在途审批单 %s 张 | ref=%s", len(pending), ref_id) 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: def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
"""出参组装(含 steps 时间线)。""" """出参组装(含 steps 时间线)。调用方须保证处于 commit 之前。"""
steps = ( steps = (
session.execute( session.execute(
select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id) select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id)
@ -321,8 +328,13 @@ def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut
def _order_filters(query: ApprovalQuery) -> tuple: def _order_filters(query: ApprovalQuery) -> tuple:
filters = [] filters = []
if query.keyword: if query.keyword:
like = f"%{query.keyword}%" # autoescape:用户输入里的 %/_ 按 URL 片段字面匹配(全库统一口径,
filters.append(ApprovalOrder.order_no.like(like) | ApprovalOrder.refer_no.like(like)) # 见 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: if query.status is not None:
filters.append(ApprovalOrder.status == query.status) filters.append(ApprovalOrder.status == query.status)
if query.order_type is not None: if query.order_type is not None:

View File

@ -34,6 +34,7 @@ from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageQuery, PageResult from app.core.response import PageQuery, PageResult
from app.models import ( from app.models import (
ApprovalOrder,
Business, Business,
BusinessItem, BusinessItem,
BusinessStatusLog, BusinessStatusLog,
@ -51,7 +52,7 @@ from app.models.business import (
BUSINESS_STATUS_DONE, BUSINESS_STATUS_DONE,
) )
from app.models.customer import OWNER_TYPE_BUSINESS from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import business_repository, customer_repository from app.repositories import business_repository, customer_repository, personnel_repository
from app.schemas.business import ( from app.schemas.business import (
BusinessCreateIn, BusinessCreateIn,
BusinessDetailOut, BusinessDetailOut,
@ -284,14 +285,28 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
def _build_allocation_rows( def _build_allocation_rows(
session: Session, business_id: int, items: list[SecondaryAllocationIn] session: Session,
business_id: int,
items: list[SecondaryAllocationIn],
*,
old_ids: set[int],
) -> list[SecondaryAllocation]: ) -> list[SecondaryAllocation]:
"""构造二次分配明细行:人员在职校验 + 姓名冗余固化 + 比例转 Decimal。 """构造二次分配明细行:人员校验 + 姓名冗余固化 + 比例转 Decimal。
:raises BadRequestError: 参与人员不存在或已离职 :param old_ids: 编辑前已参与的人员 ID 集合(create 传空集)。这些人的
行是**历史事实**——personnel_name 冗余固化的设计本就允许离职后回显,
编辑业务(哪怕只改备注)不应被历史参与人的离职状态卡死;仅对
**新加入**的参与人做在职校验。
:raises BadRequestError: 新增参与人员不存在或已离职
""" """
rows: list[SecondaryAllocation] = [] rows: list[SecondaryAllocation] = []
for item in items: for item in items:
if item.personnel_id in old_ids:
person = personnel_repository.get_by_id(session, item.personnel_id)
if person is None:
raise BadRequestError("分摊参与人员不存在")
else:
person = _ensure_personnel_active(session, item.personnel_id, "分摊参与") person = _ensure_personnel_active(session, item.personnel_id, "分摊参与")
rows.append( rows.append(
SecondaryAllocation( SecondaryAllocation(
@ -388,11 +403,13 @@ def create_business(
business_repository.replace_allocations( business_repository.replace_allocations(
session, session,
business.id, business.id,
_build_allocation_rows(session, business.id, payload.secondary_allocations), _build_allocation_rows(
session, business.id, payload.secondary_allocations, old_ids=set()
),
) )
# 方案非空才提单:开关关闭=自动通过留痕,开启=待财务复核。 # 方案非空才提单:开关关闭=自动通过留痕,开启=待财务复核。
# retry_on_conflict=True:AP 编号撞键可内部重取(外层编号 # AP 编号撞键时 IntegrityError 穿透 → 外层编号重试循环把
# 重试循环兜底整体重来) # 业务/方案/审批单整体重来
if payload.secondary_allocations: if payload.secondary_allocations:
approval_service.submit_order( approval_service.submit_order(
session, session,
@ -400,7 +417,6 @@ def create_business(
ref_id=business.id, ref_id=business.id,
refer_no=business.business_no, refer_no=business.business_no,
submitter=operator, 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))
@ -477,13 +493,18 @@ def update_business(
) )
# 方案全量替换(空数组=清空);终态门槛已挡「修改影响已结算月份」的 # 方案全量替换(空数组=清空);终态门槛已挡「修改影响已结算月份」的
# 前置场景(月结在 ④,快照固化后按同一哲学隔离) # 前置场景(月结在 ④,快照固化后按同一哲学隔离)
old_ids = {
a.personnel_id for a in business_repository.list_allocations(session, business_id)
}
business_repository.replace_allocations( business_repository.replace_allocations(
session, session,
business_id, business_id,
_build_allocation_rows(session, business_id, payload.secondary_allocations), _build_allocation_rows(
session, business_id, payload.secondary_allocations, old_ids=old_ids
),
) )
# 方案非空提单(自动撤回旧在途单);retry_on_conflict=False: # 提单/撤单与方案同步生命周期:非空=提单(内含撤旧建新);空=仅撤回
# 本路径无外层重试,内部 rollback 会回掉业务行修改造成静默丢失 # 在途单 —— 否则旧在途单挂着已不存在的方案继续走审批
if payload.secondary_allocations: if payload.secondary_allocations:
approval_service.submit_order( approval_service.submit_order(
session, session,
@ -491,7 +512,13 @@ def update_business(
ref_id=business_id, ref_id=business_id,
refer_no=business.business_no, refer_no=business.business_no,
submitter=operator, submitter=operator,
retry_on_conflict=False, )
else:
approval_service.withdraw_pending_orders(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business_id,
operator=operator,
) )
_recalc_totals(session, business) _recalc_totals(session, business)
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段 # 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
@ -515,7 +542,7 @@ def delete_business(
与业务维度的归属留痕(无外键固化表,须手动清)。 与业务维度的归属留痕(无外键固化表,须手动清)。
:raises NotFoundError: 业务不存在 :raises NotFoundError: 业务不存在
:raises BadRequestError: 存在发货或回款记录 :raises BadRequestError: 存在发货或回款记录 / 存在在途审批单
""" """
business = business_repository.get_by_id(session, business_id, for_update=True) business = business_repository.get_by_id(session, business_id, for_update=True)
with audit_service.audit( with audit_service.audit(
@ -532,6 +559,10 @@ def delete_business(
raise NotFoundError("业务不存在") raise NotFoundError("业务不存在")
if business_repository.has_docs(session, business_id): if business_repository.has_docs(session, business_id):
raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除") raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除")
# 在途审批单(ref_id 固化无外键,业务删了单还挂着且可被继续审批):
# 禁删引导先完成/撤回审批,而不是静默留下指向已删业务的活单
if _count_pending_approvals(session, business_id):
raise BadRequestError("该业务有在途审批单,请先完成或撤回审批后再删除")
business_no = business.business_no business_no = business.business_no
_hard_delete(session, business_id) _hard_delete(session, business_id)
@ -539,11 +570,46 @@ def delete_business(
logger.info("删除业务 | business_id=%s | no=%s", business_id, business_no) logger.info("删除业务 | business_id=%s | no=%s", business_id, business_no)
def _count_pending_approvals(session: Session, business_id: int) -> int:
"""该业务的在途审批单数(status 1/2;终态单随业务删除一并清理)。"""
from sqlalchemy import func, select
from app.models import ApprovalOrder
from app.models.approval import (
APPROVAL_STATUS_PENDING_FINANCE,
APPROVAL_STATUS_PENDING_LEADER,
)
return int(
session.execute(
select(func.count())
.select_from(ApprovalOrder)
.where(
ApprovalOrder.order_type == APPROVAL_TYPE_SECONDARY_ALLOCATION,
ApprovalOrder.ref_id == business_id,
ApprovalOrder.status.in_(
(APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER)
),
)
).scalar_one()
)
def _hard_delete(session: Session, business_id: int) -> None: def _hard_delete(session: Session, business_id: int) -> None:
"""按依赖序删除:明细 → 状态留痕 → 业务维度归属留痕 → 主表。""" """按依赖序删除:明细 → 二次分配方案 → 审批单(steps 随 CASCADE)→
状态留痕 → 业务维度归属留痕 → 主表。"""
from sqlalchemy import delete from sqlalchemy import delete
session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id)) session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id))
session.execute(
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
)
session.execute(
delete(ApprovalOrder).where(
ApprovalOrder.order_type == APPROVAL_TYPE_SECONDARY_ALLOCATION,
ApprovalOrder.ref_id == business_id,
)
)
session.execute(delete(BusinessStatusLog).where(BusinessStatusLog.business_id == business_id)) session.execute(delete(BusinessStatusLog).where(BusinessStatusLog.business_id == business_id))
session.execute( session.execute(
delete(OwnershipChangeLog).where( delete(OwnershipChangeLog).where(

View File

@ -126,17 +126,18 @@ def save_logo(
setting = get_setting(session) setting = get_setting(session)
old_path = setting.logo_path old_path = setting.logo_path
# 旧扩展名清理必须在写新文件**之前**:同扩展名覆盖时先写后删会把 # 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前,
# 刚落盘的新文件一并删掉(回读 404) # commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None # 由写入覆盖,无需也不会误删新文件
if old_ext and old_ext != ext:
_cleanup_old_logo(old_ext)
target = _logo_dir() / f"logo.{ext}" target = _logo_dir() / f"logo.{ext}"
target.write_bytes(content) target.write_bytes(content)
setting.logo_path = f"logo/logo.{ext}" setting.logo_path = f"logo/logo.{ext}"
session.commit() session.commit()
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
if old_ext and old_ext != ext:
_cleanup_old_logo(old_ext)
logger.info("更新公司 logo | size=%s ext=%s", len(content), ext) logger.info("更新公司 logo | size=%s ext=%s", len(content), ext)
return to_out(setting) return to_out(setting)

View File

@ -21,6 +21,7 @@
import uuid import uuid
from decimal import Decimal from decimal import Decimal
from pathlib import Path
import pytest import pytest
from sqlalchemy import delete, select, update from sqlalchemy import delete, select, update
@ -48,6 +49,7 @@ from app.models import (
Role, Role,
Shipment, Shipment,
ShipmentItem, ShipmentItem,
SystemSetting,
User, User,
UserRole, UserRole,
) )
@ -172,7 +174,7 @@ def _purge_test_data() -> None:
# 人员已先删(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:审批单 / 二次分配 / 参数开关基线 ---- # ---- M3:审批单 / 二次分配 / 参数开关 / 系统设置基线 ----
# 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删; # 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删;
# ref_id 固化无外键,业务已在上一步删除,无悬挂影响) # ref_id 固化无外键,业务已在上一步删除,无悬挂影响)
session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%"))) session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%")))
@ -183,7 +185,18 @@ def _purge_test_data() -> None:
stray_batches = {v.batch_id for v in stray_values} stray_batches = {v.batch_id for v in stray_values}
session.execute(delete(ParamValue).where(ParamValue.id > 21)) session.execute(delete(ParamValue).where(ParamValue.id > 21))
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches))) session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches)))
# 系统设置恢复种子单行(test_settings 会改公司名/传 logo——残留会让
# 后续 e2e 的品牌断言在污染状态下运行);uploads 下的测试 logo 一并删
session.execute(
update(SystemSetting)
.where(SystemSetting.id == 1)
.values(company_name="衍水集团", logo_path=None)
)
session.commit() session.commit()
logo_dir = Path(__file__).resolve().parents[2] / "uploads" / "logo"
if logo_dir.is_dir():
for f in logo_dir.iterdir():
f.unlink(missing_ok=True)
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)

View File

@ -261,9 +261,28 @@ class TestSwitchOn:
) )
assert resp.status_code == 400 assert resp.status_code == 400
def test_驳回后方案行清空_不留在生效表(
self, client: TestClient, admin: dict, finance, switched_on
) -> None:
"""被否决的方案不得以「当前生效行」身份留在表里(M3 快照会取到)。"""
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
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"]
assert detail["secondary_allocations"] == [], "被驳回的方案应从生效表清空"
assert detail["allocation_hint"] is None
class TestResubmit: class TestResubmit:
"""再编辑撤旧建新。""" """再编辑撤旧建新 / 清空方案撤在途单。"""
def test_在途单撤回并新建( def test_在途单撤回并新建(
self, client: TestClient, admin: dict, finance, leader, switched_on self, client: TestClient, admin: dict, finance, leader, switched_on
@ -293,6 +312,64 @@ class TestResubmit:
withdraw_steps = [s for s in old_detail["data"]["steps"] if s["action"] == 4] withdraw_steps = [s for s in old_detail["data"]["steps"] if s["action"] == 4]
assert withdraw_steps and withdraw_steps[0]["node"] == 0 assert withdraw_steps and withdraw_steps[0]["node"] == 0
def test_编辑清空方案_在途单撤回不再悬空(
self, client: TestClient, admin: dict, finance, 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=[])
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 statuses == {5}, f"清空方案后旧在途单应撤回且无新单:{statuses}"
assert (
client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"][
"status"
]
== 5
)
def test_删除业务在途单禁删_终态单随删(
self, client: TestClient, admin: dict, finance, switched_on
) -> None:
"""ref_id 固化无外键:在途单禁删(防指向已删业务的活单继续被审批),
终态单随业务删除一并清理(已通过/已驳回的历史事实由留痕日志承载)。"""
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])
# 在途:禁删
resp = client.delete(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"])
assert resp.status_code == 400
assert "在途审批单" in resp.json()["message"]
# 驳回(终态)后:可删,且审批单随之清理
order = _latest_order(client, finance["headers"], data["business_no"])
client.post(
f"{_APPROVALS}/{order['id']}/action",
json={"action": 2, "opinion": "驳回后可删"},
headers=finance["headers"],
)
resp = client.delete(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"])
assert resp.status_code == 200, resp.text
rows = client.get(
_APPROVALS,
params={"keyword": data["business_no"]},
headers=finance["headers"],
).json()["data"]["list"]
assert rows == [], "终态审批单应随业务删除一并清理"
class TestModuleAccess: class TestModuleAccess:
"""模块准入:普通用户与营销后勤不可见(权限清单待澄清 #6)。""" """模块准入:普通用户与营销后勤不可见(权限清单待澄清 #6)。"""

View File

@ -142,6 +142,43 @@ class TestGuards:
assert resp.status_code == 400 assert resp.status_code == 400
assert "分摊参与" in resp.json()["message"] assert "分摊参与" in resp.json()["message"]
def test_离职历史参与人_编辑其余字段放行(self, client: TestClient, admin: dict) -> None:
"""历史参与人离职后:编辑业务(保留该行)不应被 400 卡死——
personnel_name 冗余固化的设计本就允许离职后回显;仅新增参与人
才做在职校验。"""
p = _person(client, admin["headers"])
data = _create_with_allocations(
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}]
)
resp = client.put(
f"{_PERSONNEL}/{p['id']}/status", json={"status": 0}, headers=admin["headers"]
)
assert resp.status_code == 200
detail = _detail(client, admin["headers"], data["id"])
payload = business_payload(
detail["customer_id"],
secondary_allocations=[{"personnel_id": p["id"], "ratio": "50.00"}],
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
after = _detail(client, admin["headers"], data["id"])
assert after["secondary_allocations"][0]["personnel_name"] == p["personnel_name"]
# 新增一名离职人员仍要拦(新增才做在职校验)
p2 = _person(client, admin["headers"])
client.put(f"{_PERSONNEL}/{p2['id']}/status", json={"status": 0}, headers=admin["headers"])
payload = business_payload(
detail["customer_id"],
secondary_allocations=[
{"personnel_id": p["id"], "ratio": "30.00"},
{"personnel_id": p2["id"], "ratio": "20.00"},
],
)
resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"])
assert resp.status_code == 400
assert "分摊参与" in resp.json()["message"]
def test_终态业务禁改方案(self, client: TestClient, admin: dict) -> None: def test_终态业务禁改方案(self, client: TestClient, admin: dict) -> None:
p = _person(client, admin["headers"]) p = _person(client, admin["headers"])
data = _create_with_allocations( data = _create_with_allocations(

View File

@ -734,6 +734,7 @@ CREATE TABLE business_secondary_allocations (
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_business_secondary_allocations PRIMARY KEY (id), CONSTRAINT pk_business_secondary_allocations PRIMARY KEY (id),
CONSTRAINT uk_business_secondary_allocations_business_personnel UNIQUE (business_id, personnel_id),
CONSTRAINT fk_business_secondary_allocations_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE CASCADE, CONSTRAINT fk_business_secondary_allocations_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT fk_business_secondary_allocations_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT CONSTRAINT fk_business_secondary_allocations_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci; )CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci;

View File

@ -53,7 +53,8 @@ CREATE TABLE business_secondary_allocations (
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_business_secondary_allocations PRIMARY KEY (id), CONSTRAINT pk_business_secondary_allocations PRIMARY KEY (id),
CONSTRAINT fk_business_secondary_allocations_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE CASCADE, CONSTRAINT fk_business_secondary_allocations_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT fk_business_secondary_allocations_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT CONSTRAINT fk_business_secondary_allocations_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
CONSTRAINT uk_business_secondary_allocations_business_personnel UNIQUE (business_id, personnel_id)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci; )CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_business_secondary_allocations_business_id ON business_secondary_allocations (business_id); CREATE INDEX idx_business_secondary_allocations_business_id ON business_secondary_allocations (business_id);

View File

@ -37,6 +37,20 @@ if [[ -z "$LATEST" ]]; then
fi fi
DRILL_DB="payroll_drill_$(date +%Y%m%d_%H%M%S)" DRILL_DB="payroll_drill_$(date +%Y%m%d_%H%M%S)"
# 0. --drop-exec:演练前先清掉**全部**历史遗留 drill 库(含上一轮「保留供
# 核对」的那份——否则每演练一轮净增一份整库拷贝,磁盘被无人认领的
# drill 库吃满),演练完成后本轮临时库也随之删除
if [[ "${1:-}" == "--drop-exec" ]]; then
OLD_DBS=$(docker exec "$CONTAINER" sh -c \
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -B -e \"SHOW DATABASES LIKE 'payroll_drill_%';\"" 2>/dev/null || true)
for OLD in $OLD_DBS; do
docker exec "$CONTAINER" sh -c \
"mysqladmin -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -f drop \"$OLD\""
echo "已清理历史演练库 $OLD"
done
fi
echo "演练恢复:$(basename "$LATEST") → 临时库 $DRILL_DB" echo "演练恢复:$(basename "$LATEST") → 临时库 $DRILL_DB"
# 1. 创建临时库(mysqladmin create 无反引号转义问题) # 1. 创建临时库(mysqladmin create 无反引号转义问题)
@ -75,9 +89,10 @@ if [[ "$ALL_OK" == "1" ]]; then
if [[ "${1:-}" == "--drop-exec" ]]; then if [[ "${1:-}" == "--drop-exec" ]]; then
docker exec "$CONTAINER" sh -c \ docker exec "$CONTAINER" sh -c \
"mysqladmin -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -f drop \"$DRILL_DB\"" "mysqladmin -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -f drop \"$DRILL_DB\""
echo "已删除临时库 $DRILL_DB" echo "本轮临时库 $DRILL_DB 已删除(历史遗留亦在演练前清理)。"
else else
echo "临时库 $DRILL_DB 保留(核对用);确认无误后重跑本脚本加 --drop-exec 删除。" echo "临时库 $DRILL_DB 保留(核对用);确认无误后重跑本脚本加 --drop-exec,"
echo "会先清掉包括它在内的全部演练库再演练,本轮完成后同样即删。"
fi fi
exit 0 exit 0
else else