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:
parent
fc8a42bcd9
commit
1c93ea0a4b
@ -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",
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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"),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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="公司名称")
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)。"""
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user