diff --git a/backend/alembic/versions/79bcc5aa98d4_v6_系统设置_二次分配_审批表与角色参数种子.py b/backend/alembic/versions/79bcc5aa98d4_v6_系统设置_二次分配_审批表与角色参数种子.py index a66cc12..f42752d 100644 --- a/backend/alembic/versions/79bcc5aa98d4_v6_系统设置_二次分配_审批表与角色参数种子.py +++ b/backend/alembic/versions/79bcc5aa98d4_v6_系统设置_二次分配_审批表与角色参数种子.py @@ -255,6 +255,11 @@ def upgrade() -> None: ondelete="RESTRICT", ), sa.PrimaryKeyConstraint("id", name=op.f("pk_business_secondary_allocations")), + sa.UniqueConstraint( + "business_id", + "personnel_id", + name="uk_business_secondary_allocations_business_personnel", + ), comment="业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)", mysql_charset="utf8mb4", mysql_collate="utf8mb4_general_ci", diff --git a/backend/app/api/v1/system/settings.py b/backend/app/api/v1/system/settings.py index d21a677..ba71442 100644 --- a/backend/app/api/v1/system/settings.py +++ b/backend/app/api/v1/system/settings.py @@ -12,6 +12,7 @@ from fastapi import APIRouter, Response, UploadFile from fastapi.responses import FileResponse from app.api.deps import AdminUser, DbSession, RequestMetaDep +from app.core.exceptions import BadRequestError from app.core.response import ApiResponse, ok from app.schemas.setting import SettingOut, SettingPublicOut, SettingUpdateIn 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") -async def upload_logo( +def upload_logo( file: UploadFile, session: DbSession, current_user: AdminUser, meta: RequestMetaDep, ) -> ApiResponse[SettingOut]: - """上传公司 logo(png/jpg ≤2MB;固定名覆盖,旧扩展名文件清理)。""" - content = await file.read() + """上传公司 logo(png/jpg ≤2MB;固定名覆盖,旧扩展名文件清理)。 + + 同步 `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] return ok( service.save_logo(session, content, ext, operator=current_user, meta=meta), diff --git a/backend/app/models/business.py b/backend/app/models/business.py index 3d3a891..8b54d4f 100644 --- a/backend/app/models/business.py +++ b/backend/app/models/business.py @@ -15,7 +15,7 @@ from datetime import date, datetime 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 app.models.base import ( @@ -56,6 +56,11 @@ class SecondaryAllocation(Base, TimestampMixin): # 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」, # 会与 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_personnel_id", "personnel_id"), ) diff --git a/backend/app/repositories/business_repository.py b/backend/app/repositories/business_repository.py index f73c7f3..acc358d 100644 --- a/backend/app/repositories/business_repository.py +++ b/backend/app/repositories/business_repository.py @@ -228,6 +228,18 @@ def replace_allocations( 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: """追加状态留痕(不 commit)。""" session.add(log) diff --git a/backend/app/schemas/setting.py b/backend/app/schemas/setting.py index 7cd8e2f..62ba45d 100644 --- a/backend/app/schemas/setting.py +++ b/backend/app/schemas/setting.py @@ -5,7 +5,7 @@ 顺着复用链泄漏到匿名端点。 """ -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class SettingOut(BaseModel): @@ -23,6 +23,12 @@ class SettingPublicOut(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="公司名称") diff --git a/backend/app/services/approval_service.py b/backend/app/services/approval_service.py index e00fcba..a4b71fc 100644 --- a/backend/app/services/approval_service.py +++ b/backend/app/services/approval_service.py @@ -22,7 +22,6 @@ 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 ( @@ -33,13 +32,13 @@ from app.core.constants import ( 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_REJECT, APPROVAL_ACTION_WITHDRAW, APPROVAL_NODE_FINANCE, APPROVAL_NODE_LEADER, @@ -71,8 +70,6 @@ ENABLE_CODE_BY_TYPE: dict[int, str] = { 2: "approval.monthly_settlement.enabled", } -_NO_RETRY = 1 # AP 单号唯一键冲突重试(next_no 的 MAX+FOR UPDATE 之外的兜底) - # 在途状态集合(同一引用对象至多一张在途单) _PENDING_STATUSES = (APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER) @@ -100,19 +97,15 @@ def submit_order( 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,用户重试即可。 + (全节点 action=3 留痕)。AP 编号撞唯一键时 IntegrityError 直接穿透: + create 路径由外层编号重试循环整体重来(rollback 后业务/方案/审批单 + 一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400—— + **任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务 + 的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。 """ _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): 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) return order # 开关关闭=直接生效:两级节点各记一条「自动通过」,留痕不断链 order.status = APPROVAL_STATUS_APPROVED - _flush_order(session, order, retry_on_conflict=retry_on_conflict) + session.flush() session.add_all( [ ApprovalStep( @@ -156,6 +149,29 @@ def submit_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( session: Session, order_id: int, @@ -194,14 +210,15 @@ def act( raise ForbiddenError(f"当前节点须{_ROLE_LABEL[required_role]}角色操作") 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 = ( APPROVAL_STATUS_PENDING_LEADER if order.status == APPROVAL_STATUS_PENDING_FINANCE else APPROVAL_STATUS_APPROVED ) - else: - order.status = APPROVAL_STATUS_REJECTED session.add( ApprovalStep( @@ -213,8 +230,23 @@ def act( 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() - return _detail_of(session, order) + return detail 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) -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 时间线)。调用方须保证处于 commit 之前。""" steps = ( session.execute( 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: filters = [] if query.keyword: - like = f"%{query.keyword}%" - filters.append(ApprovalOrder.order_no.like(like) | ApprovalOrder.refer_no.like(like)) + # autoescape:用户输入里的 %/_ 按 URL 片段字面匹配(全库统一口径, + # 见 customer_repository.contains 用法)——否则 "AP2026_10" 会把 + # 下划线当单字符通配,搜出无关单据 + filters.append( + ApprovalOrder.order_no.contains(query.keyword, autoescape=True) + | ApprovalOrder.refer_no.contains(query.keyword, autoescape=True) + ) if query.status is not None: filters.append(ApprovalOrder.status == query.status) if query.order_type is not None: diff --git a/backend/app/services/business_service.py b/backend/app/services/business_service.py index 3568383..48c7bfa 100644 --- a/backend/app/services/business_service.py +++ b/backend/app/services/business_service.py @@ -34,6 +34,7 @@ from app.core.db_errors import is_duplicate_entry from app.core.exceptions import BadRequestError, NotFoundError from app.core.response import PageQuery, PageResult from app.models import ( + ApprovalOrder, Business, BusinessItem, BusinessStatusLog, @@ -51,7 +52,7 @@ from app.models.business import ( BUSINESS_STATUS_DONE, ) 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 ( BusinessCreateIn, BusinessDetailOut, @@ -284,15 +285,29 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut: 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]: - """构造二次分配明细行:人员在职校验 + 姓名冗余固化 + 比例转 Decimal。 + """构造二次分配明细行:人员校验 + 姓名冗余固化 + 比例转 Decimal。 - :raises BadRequestError: 参与人员不存在或已离职 + :param old_ids: 编辑前已参与的人员 ID 集合(create 传空集)。这些人的 + 行是**历史事实**——personnel_name 冗余固化的设计本就允许离职后回显, + 编辑业务(哪怕只改备注)不应被历史参与人的离职状态卡死;仅对 + **新加入**的参与人做在职校验。 + + :raises BadRequestError: 新增参与人员不存在或已离职 """ rows: list[SecondaryAllocation] = [] for item in items: - person = _ensure_personnel_active(session, item.personnel_id, "分摊参与") + 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, "分摊参与") rows.append( SecondaryAllocation( business_id=business_id, @@ -388,11 +403,13 @@ def create_business( business_repository.replace_allocations( session, 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: approval_service.submit_order( session, @@ -400,7 +417,6 @@ def create_business( 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) 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( session, 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: approval_service.submit_order( session, @@ -491,7 +512,13 @@ def update_business( ref_id=business_id, refer_no=business.business_no, 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) # 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段 @@ -515,7 +542,7 @@ def delete_business( 与业务维度的归属留痕(无外键固化表,须手动清)。 :raises NotFoundError: 业务不存在 - :raises BadRequestError: 存在发货或回款记录 + :raises BadRequestError: 存在发货或回款记录 / 存在在途审批单 """ business = business_repository.get_by_id(session, business_id, for_update=True) with audit_service.audit( @@ -532,6 +559,10 @@ def delete_business( raise NotFoundError("业务不存在") if business_repository.has_docs(session, business_id): raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除") + # 在途审批单(ref_id 固化无外键,业务删了单还挂着且可被继续审批): + # 禁删引导先完成/撤回审批,而不是静默留下指向已删业务的活单 + if _count_pending_approvals(session, business_id): + raise BadRequestError("该业务有在途审批单,请先完成或撤回审批后再删除") business_no = business.business_no _hard_delete(session, business_id) @@ -539,11 +570,46 @@ def delete_business( 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: - """按依赖序删除:明细 → 状态留痕 → 业务维度归属留痕 → 主表。""" + """按依赖序删除:明细 → 二次分配方案 → 审批单(steps 随 CASCADE)→ + 状态留痕 → 业务维度归属留痕 → 主表。""" from sqlalchemy import delete 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(OwnershipChangeLog).where( diff --git a/backend/app/services/system_setting_service.py b/backend/app/services/system_setting_service.py index 5bb6665..bfeac7c 100644 --- a/backend/app/services/system_setting_service.py +++ b/backend/app/services/system_setting_service.py @@ -126,17 +126,18 @@ def save_logo( setting = get_setting(session) old_path = setting.logo_path - # 旧扩展名清理必须在写新文件**之前**:同扩展名覆盖时先写后删会把 - # 刚落盘的新文件一并删掉(回读 404) - old_ext = Path(old_path).suffix.lstrip(".") if old_path else None - if old_ext and old_ext != ext: - _cleanup_old_logo(old_ext) - + # 先写新文件、commit 成功后再清旧扩展名(若把清理放在 commit 之前, + # commit 失败会让库指旧路径而旧文件已删——断链无补偿);同扩展名 + # 由写入覆盖,无需也不会误删新文件 target = _logo_dir() / f"logo.{ext}" target.write_bytes(content) setting.logo_path = f"logo/logo.{ext}" 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) return to_out(setting) diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py index 13c4601..395e3d0 100644 --- a/backend/tests/integration/conftest.py +++ b/backend/tests/integration/conftest.py @@ -21,6 +21,7 @@ import uuid from decimal import Decimal +from pathlib import Path import pytest from sqlalchemy import delete, select, update @@ -48,6 +49,7 @@ from app.models import ( Role, Shipment, ShipmentItem, + SystemSetting, User, UserRole, ) @@ -172,7 +174,7 @@ def _purge_test_data() -> None: # 人员已先删(business_role_id 外键 RESTRICT),直建的测试岗位此时可安全删 session.execute(delete(BusinessRole).where(BusinessRole.role_code.like("t\\_%"))) - # ---- M3:审批单 / 二次分配 / 参数开关基线 ---- + # ---- M3:审批单 / 二次分配 / 参数开关 / 系统设置基线 ---- # 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删; # ref_id 固化无外键,业务已在上一步删除,无悬挂影响) 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} session.execute(delete(ParamValue).where(ParamValue.id > 21)) 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() + 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) diff --git a/backend/tests/integration/test_approval_flow.py b/backend/tests/integration/test_approval_flow.py index 30866a0..428da1a 100644 --- a/backend/tests/integration/test_approval_flow.py +++ b/backend/tests/integration/test_approval_flow.py @@ -261,9 +261,28 @@ class TestSwitchOn: ) 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: - """再编辑撤旧建新。""" + """再编辑撤旧建新 / 清空方案撤在途单。""" def test_在途单撤回并新建( 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] 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: """模块准入:普通用户与营销后勤不可见(权限清单待澄清 #6)。""" diff --git a/backend/tests/integration/test_secondary_allocation.py b/backend/tests/integration/test_secondary_allocation.py index 476781d..5cb384f 100644 --- a/backend/tests/integration/test_secondary_allocation.py +++ b/backend/tests/integration/test_secondary_allocation.py @@ -142,6 +142,43 @@ class TestGuards: assert resp.status_code == 400 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: p = _person(client, admin["headers"]) data = _create_with_allocations( diff --git a/db/init/init-dev.sql b/db/init/init-dev.sql index e5b6076..eb1838b 100644 --- a/db/init/init-dev.sql +++ b/db/init/init-dev.sql @@ -734,6 +734,7 @@ CREATE TABLE business_secondary_allocations ( create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 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_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT )CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci; diff --git a/db/migration/V6__add_settings_allocations_approvals.sql b/db/migration/V6__add_settings_allocations_approvals.sql index f07d227..26b98a6 100644 --- a/db/migration/V6__add_settings_allocations_approvals.sql +++ b/db/migration/V6__add_settings_allocations_approvals.sql @@ -53,7 +53,8 @@ CREATE TABLE business_secondary_allocations ( update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 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_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; CREATE INDEX idx_business_secondary_allocations_business_id ON business_secondary_allocations (business_id); diff --git a/scripts/restore_drill.sh b/scripts/restore_drill.sh index 1d8b676..98dc0dc 100644 --- a/scripts/restore_drill.sh +++ b/scripts/restore_drill.sh @@ -37,6 +37,20 @@ if [[ -z "$LATEST" ]]; then fi 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" # 1. 创建临时库(mysqladmin create 无反引号转义问题) @@ -75,9 +89,10 @@ if [[ "$ALL_OK" == "1" ]]; then if [[ "${1:-}" == "--drop-exec" ]]; then docker exec "$CONTAINER" sh -c \ "mysqladmin -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -f drop \"$DRILL_DB\"" - echo "已删除临时库 $DRILL_DB" + echo "本轮临时库 $DRILL_DB 已删除(历史遗留亦在演练前清理)。" else - echo "临时库 $DRILL_DB 保留(核对用);确认无误后重跑本脚本加 --drop-exec 删除。" + echo "临时库 $DRILL_DB 保留(核对用);确认无误后重跑本脚本加 --drop-exec," + echo "会先清掉包括它在内的全部演练库再演练,本轮完成后同样即删。" fi exit 0 else