diff --git a/backend/README.md b/backend/README.md index 09cba71..16a2af4 100644 --- a/backend/README.md +++ b/backend/README.md @@ -221,6 +221,18 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是 - **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。 - **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。 +### 审批快照与方案版本化(2026-10-20 定稿,改动前先读) + +- **审批单带版本号与内容快照**:`approval_orders.version_no` 按 `(order_type, ref_id)` 从 1 递增,重提先撤回旧在途单、但**旧单快照不覆盖**;`snapshot_json` 统一信封 `{schema_version, order_type, captured_at, data}`,`data` 由 `approval_service.{allocation,settlement,adjustment}_content` 组装(Decimal 转 str、日期转 ISO),开关关闭的自动通过路径同样写快照。审批期间业务再改,复核者看到的仍是送审版本。 +- **两个 `version_no` 语义不同,别混用**:`business_secondary_allocations.version_no` = 方案内容第几版,**仅方案内容(参与人+比例)变化时 +1**;`approval_orders.version_no` = 同一对象第几次送审,**仅送审内容变化时 +1**。改备注不动金额 → 两者都不动;改金额 → 只有审批单版本号动;改方案比例 → 两者都动。 +- **送审内容未变不重提**:编辑业务时若**存在在途审批单**且其快照 `data` 与本次送审内容逐键相等(`approval_service.pending_matches_snapshot`,只比 `data` 不比 `captured_at`),则保留原单不撤回不重建;否则撤旧提新。方案内容未变时**不新增方案版本行**(`business_service._plan_changed` 按「人员+比例」集合比较,与行序无关)。 +- **快照的覆盖范围是「方案 + 钱 + 归属」**(业务编号/日期、客户、负责与协助人、销售额、成本额、方案参与人与比例、合计与余额),**不含产品明细行**。因此只改产品构成而金额不变时不会重提——分摊依据(谁分、分多少、基于多少钱)未变;产品明细由复核者按业务编号回业务页查看。 +- **列表不加载快照列**:快照可达上百 KB,审批单列表、工作台待办、工作台审批超时预警三处查询一律 `defer(ApprovalOrder.snapshot_json)`(与操作日志列表排除 `request_data` 同一理由,参考 `operation_log_repository.list_logs`);详情走 `get_order` 按需读取。 +- **详情内容来源三态**:`content_source=snapshot`(送审快照)/ `live`(V11 前的历史单体无快照,只读回退当前内容并显式告警)/ `missing`(引用对象不存在且无快照)。**禁止回填历史单快照**——回填等于伪造提交时内容。 +- **二次分配方案 append-only**:`business_secondary_allocations` 行不物理删除,编辑全量替换与驳回清空只把旧行 `is_active=0`;唯一键为 `(business_id, version_no, personnel_id)`,同一版本内参与人唯一、跨版本可重复。`business_repository.list_allocations(active_only=True)` 是核算与详情回显的唯一取数口径;业务详情 `allocation_history` 返回全部版本供回溯。 +- **月结提交要锁行**:`settlement_service.submit` 取月结行必须 `for_update=True`——并发提交会让两次 `submit_order` 算出同一个审批单 `version_no`,送审次序失真。 +- **新增审批对象类型**须同时提供快照组装函数并接入 `_content_of` 的 live 回退分支,不要在各业务服务内联 JSON 结构。 + ### 工作台口径(M4 ⑧,改动前先读) - **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。 diff --git a/backend/alembic/versions/f3a8c2d61b47_v11_审批快照与方案版本化.py b/backend/alembic/versions/f3a8c2d61b47_v11_审批快照与方案版本化.py new file mode 100644 index 0000000..1dbe38e --- /dev/null +++ b/backend/alembic/versions/f3a8c2d61b47_v11_审批快照与方案版本化.py @@ -0,0 +1,95 @@ +"""v11 审批快照与方案版本化 + +Revision ID: f3a8c2d61b47 +Revises: 90653814a421 +Create Date: 2026-10-20 10:30:00.000000 + +补齐 code-review 遗留 2 项: + +- 审批单加 ``version_no`` 与 ``snapshot_json``:重提产生新版本,送审内容 + 在提交时固化,审批期间业务再改不影响复核依据; +- 二次分配方案行改 append-only 版本化:加 ``version_no`` / ``is_active``, + 唯一键由 (business_id, personnel_id) 扩为 + (business_id, version_no, personnel_id),编辑替换与驳回清空只置历史、 + 不再物理删除。 + +存量数据均落 ``version_no=1`` / ``is_active=1``:升级前的行就是当前生效 +版本,语义不变。审批单存量快照为空,详情接口按「当前内容回退 + 显式 +标注」处理,不做历史回填(回填会伪造提交时内容)。 +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "f3a8c2d61b47" +down_revision: str | Sequence[str] | None = "90653814a421" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """加审批快照列/索引与方案版本列,替换唯一键。""" + op.execute( + "ALTER TABLE approval_orders " + "ADD COLUMN version_no INT NOT NULL DEFAULT 1 " + "COMMENT '提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)' " + "AFTER refer_no" + ) + op.execute( + "ALTER TABLE approval_orders " + "ADD COLUMN snapshot_json JSON NULL " + "COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)' " + "AFTER version_no" + ) + op.execute( + "CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id)" + ) + + op.execute( + "ALTER TABLE business_secondary_allocations " + "ADD COLUMN version_no INT NOT NULL DEFAULT 1 " + "COMMENT '方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)' " + "AFTER business_id" + ) + op.execute( + "ALTER TABLE business_secondary_allocations " + "ADD COLUMN is_active TINYINT NOT NULL DEFAULT 1 " + "COMMENT '是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)' " + "AFTER ratio" + ) + op.execute( + "ALTER TABLE business_secondary_allocations " + "DROP INDEX uk_business_secondary_allocations_business_personnel, " + "ADD CONSTRAINT uk_business_secondary_allocations_business_version_personnel " + "UNIQUE (business_id, version_no, personnel_id)" + ) + op.execute( + "CREATE INDEX idx_business_secondary_allocations_business_active " + "ON business_secondary_allocations (business_id, is_active)" + ) + op.execute( + "ALTER TABLE business_secondary_allocations COMMENT = " + "'业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)'" + ) + + +def downgrade() -> None: + """回退结构(降级前须确认同业务同一参与人只存在于一个版本)。""" + op.execute( + "DROP INDEX idx_business_secondary_allocations_business_active " + "ON business_secondary_allocations" + ) + op.execute( + "ALTER TABLE business_secondary_allocations " + "DROP INDEX uk_business_secondary_allocations_business_version_personnel, " + "ADD CONSTRAINT uk_business_secondary_allocations_business_personnel " + "UNIQUE (business_id, personnel_id)" + ) + op.execute("ALTER TABLE business_secondary_allocations DROP COLUMN is_active") + op.execute("ALTER TABLE business_secondary_allocations DROP COLUMN version_no") + + op.execute("DROP INDEX idx_approval_orders_type_ref ON approval_orders") + op.execute("ALTER TABLE approval_orders DROP COLUMN snapshot_json") + op.execute("ALTER TABLE approval_orders DROP COLUMN version_no") diff --git a/backend/app/models/approval.py b/backend/app/models/approval.py index 0a1d142..40261d4 100644 --- a/backend/app/models/approval.py +++ b/backend/app/models/approval.py @@ -15,7 +15,7 @@ ref_id 为**固化无外键**(同 business_status_logs 先例):order_type= from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Index, String, text +from sqlalchemy import JSON, DateTime, ForeignKey, Index, Integer, String, text from sqlalchemy.orm import Mapped, mapped_column from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args @@ -55,6 +55,8 @@ class ApprovalOrder(Base, TimestampMixin): __table_args__ = mysql_table_args( "审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)", Index("idx_approval_orders_type_status", "order_type", "status"), + # 版本号按 (order_type, ref_id) 递增,须有对应索引避免全表 max 扫描 + Index("idx_approval_orders_type_ref", "order_type", "ref_id"), ) id: Mapped[int] = mapped_column( @@ -76,6 +78,16 @@ class ApprovalOrder(Base, TimestampMixin): refer_no: Mapped[str] = mapped_column( String(32), nullable=False, comment="引用对象单号(冗余固化,列表页免联查)" ) + version_no: Mapped[int] = mapped_column( + Integer, + nullable=False, + server_default="1", + comment="提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)", + ) + snapshot_json: Mapped[dict | None] = mapped_column( + JSON, + comment="提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)", + ) status: Mapped[int] = mapped_column( TINYINT_TYPE, nullable=False, diff --git a/backend/app/models/business.py b/backend/app/models/business.py index 8b54d4f..18e9947 100644 --- a/backend/app/models/business.py +++ b/backend/app/models/business.py @@ -15,7 +15,17 @@ from datetime import date, datetime from decimal import Decimal -from sqlalchemy import Date, DateTime, ForeignKey, Index, Numeric, String, UniqueConstraint, text +from sqlalchemy import ( + Date, + DateTime, + ForeignKey, + Index, + Integer, + Numeric, + String, + UniqueConstraint, + text, +) from sqlalchemy.orm import Mapped, mapped_column from app.models.base import ( @@ -42,27 +52,36 @@ class SecondaryAllocation(Base, TimestampMixin): - 分配比例**不要求合计 100%**:余额归属未定(澄清 #2),存储只落参与人 与比例,响应以 hint 提示合计/余额,不做强制校验 - T+ 无二次分配数据(2026-10-15 回馈):参与人全为系统内人工维护 - - **分摊计算不在本期**(澄清 #1 分配标的三种口径结果不同):④ 核算时按 - 核算时刻读「当前生效行」做快照,天然隔离历史版本,故无版本表 + - **版本行 append-only**:编辑全量替换与驳回清空都把旧行置 + ``is_active=0`` 而非物理删除,历史方案可回溯;当前生效行由 + ``is_active=1`` 唯一标识。核算仍按「当前生效行」取快照 + (``performance_ledgers.snapshot_json.alloc_plan`` 固化),版本化只 + 补齐「方案事实留存」,不改变核算口径 personnel 外键 **RESTRICT**:被方案引用的人员禁删——分摊金额待定的记录 不允许静默失联(silently deleted)导致回档无法人工查验;personnel_name - 冗余固化,人离职后方案回显仍有名。uk 锁 (business_id, personnel_id), - 同一合同的同一参与人仅一行,重复提交在服务层聚合。 + 冗余固化,人离职后方案回显仍有名。uk 锁 + (business_id, version_no, personnel_id),同一版本内同一参与人仅一行, + 重复提交在服务层聚合;跨版本允许同一人再次出现。 """ __tablename__ = "business_secondary_allocations" __table_args__ = mysql_table_args( # 表/列注释避开「%」字符:SHOW CREATE TABLE 经驱动参数化转义为「%%」, # 会与 ORM 侧 comment 字面断言失配(全库既有注释同此惯例) - "业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)", + "业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)", UniqueConstraint( "business_id", + "version_no", "personnel_id", - name="uk_business_secondary_allocations_business_personnel", + name="uk_business_secondary_allocations_business_version_personnel", ), Index("idx_business_secondary_allocations_business_id", "business_id"), Index("idx_business_secondary_allocations_personnel_id", "personnel_id"), + # 取当前生效行的唯一查询形态:WHERE business_id=? AND is_active=1 + Index( + "idx_business_secondary_allocations_business_active", "business_id", "is_active" + ), ) id: Mapped[int] = mapped_column( @@ -74,6 +93,12 @@ class SecondaryAllocation(Base, TimestampMixin): nullable=False, comment="业务ID(businesses.id;业务删除随删)", ) + version_no: Mapped[int] = mapped_column( + Integer, + nullable=False, + server_default="1", + comment="方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)", + ) personnel_id: Mapped[int] = mapped_column( BIGINT_UNSIGNED, ForeignKey("personnel.id", ondelete="RESTRICT"), @@ -88,6 +113,12 @@ class SecondaryAllocation(Base, TimestampMixin): nullable=False, comment="分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)", ) + is_active: Mapped[int] = mapped_column( + TINYINT_TYPE, + nullable=False, + server_default="1", + comment="是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)", + ) class Business(Base, TimestampMixin, TPlusSyncMixin): diff --git a/backend/app/repositories/business_repository.py b/backend/app/repositories/business_repository.py index acc358d..9fa9456 100644 --- a/backend/app/repositories/business_repository.py +++ b/backend/app/repositories/business_repository.py @@ -7,7 +7,7 @@ models/customer.py 定义)。 from datetime import date -from sqlalchemy import Row, Select, delete, exists, func, or_, select +from sqlalchemy import Row, Select, delete, exists, func, or_, select, update from sqlalchemy.orm import Session, aliased from app.models import ( @@ -199,43 +199,75 @@ def replace_items(session: Session, business_id: int, rows: list[BusinessItem]) session.flush() -def list_allocations(session: Session, business_id: int) -> list[SecondaryAllocation]: - """二次分配方案(详情回显;空列表=未设置方案)。""" +def list_allocations( + session: Session, business_id: int, *, active_only: bool = True +) -> list[SecondaryAllocation]: + """二次分配方案(详情回显与核算取数;默认仅当前生效版本)。""" + stmt = select(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id) + if active_only: + stmt = stmt.where(SecondaryAllocation.is_active == 1) return list( session.execute( - select(SecondaryAllocation) - .where(SecondaryAllocation.business_id == business_id) - .order_by(SecondaryAllocation.id) + stmt.order_by(SecondaryAllocation.version_no, SecondaryAllocation.id) ).scalars() ) +def list_allocation_versions(session: Session, business_id: int) -> list[SecondaryAllocation]: + """方案全部版本行(含历史;业务详情「方案历史」用,按版本正序)。""" + return list_allocations(session, business_id, active_only=False) + + def replace_allocations( session: Session, business_id: int, rows: list[SecondaryAllocation] ) -> None: - """全量替换二次分配方案(不 commit,事务边界在服务层)。 + """全量替换二次分配方案:旧生效行置历史,新行按下一版本号插入。 - 本表有 uk(business_id, personnel_id),**必须**沿用先删后 flush 再插的 - 防御写法:同 flush 内 INSERT 先于 DELETE 处理,直接删加插会撞唯一键。 + 不 commit(事务边界在服务层)。版本行 append-only——编辑与清空都不 + 物理删除,驳回后仍可回溯被否决方案的参与人与比例。空 ``rows`` 表示 + 只清空生效态(不产生新版本)。 """ - session.execute( - delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id) + deactivate_allocations(session, business_id) + if not rows: + return + next_version = ( + session.execute( + select(func.coalesce(func.max(SecondaryAllocation.version_no), 0)).where( + SecondaryAllocation.business_id == business_id + ) + ).scalar_one() + + 1 ) - session.flush() for row in rows: row.business_id = business_id + row.version_no = next_version + row.is_active = 1 session.add(row) session.flush() -def delete_allocations(session: Session, business_id: int) -> None: - """清空业务的二次分配方案(审批驳回/撤回时由 approval_service 调用)。 +def deactivate_allocations(session: Session, business_id: int) -> None: + """把业务的当前生效方案置为历史(审批驳回/编辑替换时调用;行保留)。 - 被否决的方案不得以「当前生效行」身份留在表里(M3 核算快照会取到); - 方案事实由审批单留痕与操作日志 request_data 承载。 + 被否决的方案不得以「当前生效行」身份继续被核算取到;``is_active=0`` + 后核算侧自然读不到,而历史版本仍可在业务详情回溯。不 commit。 + + 显式声明 ``synchronize_session="fetch"``:Core UPDATE 默认走 auto 策略 + (优先 evaluate,本例的条件恰好能在 Python 侧求值,故实测也会刷新在途 + 对象)。显式写 fetch 是为了不把「在途对象被刷新」这件事押在 evaluate + 能否解析条件上——条件一旦复杂化,auto 会退化为不刷新,同一会话里已 + 加载的方案行就会停留在 ``is_active=1`` 的过期内存值(查询虽有 + is_active 过滤,但行对象本身仍是「生效态」,被序列化或参与判断就是 + 错的)。 """ session.execute( - delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id) + update(SecondaryAllocation) + .where( + SecondaryAllocation.business_id == business_id, + SecondaryAllocation.is_active == 1, + ) + .values(is_active=0) + .execution_options(synchronize_session="fetch") ) session.flush() diff --git a/backend/app/repositories/dashboard_repository.py b/backend/app/repositories/dashboard_repository.py index 179a03c..4c0e46c 100644 --- a/backend/app/repositories/dashboard_repository.py +++ b/backend/app/repositories/dashboard_repository.py @@ -8,7 +8,7 @@ from datetime import date, datetime from sqlalchemy import exists, func, or_, select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, defer from app.models import ( ApprovalOrder, @@ -113,12 +113,15 @@ def list_pending_approvals( """待处理审批单(按提交时间升序——最久的排前面)。 ``statuses`` 由调用方按当前用户角色解析(待我审批),空元组返回空列表。 + + 排除 snapshot_json 列(快照可达上百 KB,待办只取单号/类型/提交人/时间)。 """ if not statuses: return [] return list( session.execute( select(ApprovalOrder) + .options(defer(ApprovalOrder.snapshot_json)) .where(ApprovalOrder.status.in_(statuses)) .order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc()) .limit(limit) @@ -210,10 +213,14 @@ def count_overdue_shipments(session: Session, *, cutoff: date) -> int: def list_overdue_approvals( session: Session, *, cutoff: datetime, limit: int ) -> list[ApprovalOrder]: - """提交后超过阈值仍未处理的审批单(审批超时规则)。""" + """提交后超过阈值仍未处理的审批单(审批超时规则)。 + + 排除 snapshot_json 列(同待办:预警只用单号/类型/提交人/时间)。 + """ return list( session.execute( select(ApprovalOrder) + .options(defer(ApprovalOrder.snapshot_json)) .where( ApprovalOrder.status.in_(_PENDING_STATUSES), ApprovalOrder.create_time < cutoff, diff --git a/backend/app/repositories/settlement_repository.py b/backend/app/repositories/settlement_repository.py index 8eb01e0..cc8abeb 100644 --- a/backend/app/repositories/settlement_repository.py +++ b/backend/app/repositories/settlement_repository.py @@ -7,7 +7,16 @@ from app.models import MonthlySettlement, SettlementItem from app.schemas.settlement import SettlementQuery -def get_by_id(session: Session, settlement_id: int) -> MonthlySettlement | None: +def get_by_id( + session: Session, settlement_id: int, *, for_update: bool = False +) -> MonthlySettlement | None: + """月结行;``for_update`` 供提交审批路径加行锁用。 + + 提交要算审批单版本号(同一月结的第几次送审),并发提交会让两次 + 算出同一个版本号;锁住月结行使并发提交串行化。 + """ + if for_update: + return session.get(MonthlySettlement, settlement_id, with_for_update=True) return session.get(MonthlySettlement, settlement_id) diff --git a/backend/app/schemas/approval.py b/backend/app/schemas/approval.py index 75b17c0..7a3eac9 100644 --- a/backend/app/schemas/approval.py +++ b/backend/app/schemas/approval.py @@ -1,6 +1,7 @@ """审批管理的请求/响应模型(2026-10-15 复核审批补充需求)。""" from datetime import datetime +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -31,6 +32,7 @@ class ApprovalOrderOut(BaseModel): order_type: int ref_id: int refer_no: str + version_no: int status: int submitter_id: int | None = None submitter_name: str @@ -38,9 +40,20 @@ class ApprovalOrderOut(BaseModel): class ApprovalOrderDetailOut(ApprovalOrderOut): - """审批单详情:附节点时间线。""" + """审批单详情:附节点时间线与审批内容。 + + ``content_source`` 语义:``snapshot``=提交时快照(可信); + ``live``=历史单无快照、回退读当前业务内容(仅供参考); + ``missing``=引用对象已不存在且无快照。 + """ steps: list[ApprovalStepOut] = Field(default_factory=list) + snapshot: dict[str, Any] | None = Field( + default=None, description="提交时内容快照(信封:schema_version/order_type/captured_at/data)" + ) + content_source: Literal["snapshot", "live", "missing"] = Field( + default="missing", description="审批内容来源:提交时快照/当前内容回退/缺失" + ) class ApprovalQuery(PageQuery): @@ -48,7 +61,7 @@ class ApprovalQuery(PageQuery): keyword: str | None = Field(default=None, max_length=32, description="审批单号/引用单号,模糊") status: int | None = Field(default=None, ge=1, le=5, description="状态筛选") - order_type: int | None = Field(default=None, ge=1, le=2, description="对象类型筛选") + order_type: int | None = Field(default=None, ge=1, le=3, description="对象类型筛选") class ApprovalActionIn(BaseModel): diff --git a/backend/app/schemas/business.py b/backend/app/schemas/business.py index da08797..4d4d398 100644 --- a/backend/app/schemas/business.py +++ b/backend/app/schemas/business.py @@ -180,6 +180,14 @@ class SecondaryAllocationOut(BaseModel): ratio: str +class AllocationVersionOut(SecondaryAllocationOut): + """方案版本行(含历史;业务详情抽屉回溯用)。""" + + version_no: int + is_active: int = Field(description="1当前生效 0历史版本") + create_time: datetime + + def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None: """同一业务内参与人去重:uk(business_id, personnel_id) 的前置友好提示 (让 400 文案说「重复参与人」而不是裸唯一键冲突)。""" @@ -191,13 +199,16 @@ def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None: class BusinessDetailOut(BusinessOut): - """业务详情:附产品明细行与二次分配方案。""" + """业务详情:附产品明细行、当前方案与方案版本历史。""" items: list[BusinessItemOut] = Field(default_factory=list) secondary_allocations: list[SecondaryAllocationOut] = Field(default_factory=list) allocation_hint: str | None = Field( default=None, description="方案合计与余额提示(余额归属待澄清#2,仅展示)" ) + allocation_history: list[AllocationVersionOut] = Field( + default_factory=list, description="方案全部版本(含历史;被驳回方案亦保留)" + ) class BusinessCreateIn(BaseModel): diff --git a/backend/app/services/approval_service.py b/backend/app/services/approval_service.py index e0cc6dd..b8f3e29 100644 --- a/backend/app/services/approval_service.py +++ b/backend/app/services/approval_service.py @@ -20,10 +20,11 @@ import logging from collections.abc import Iterable -from datetime import date +from datetime import date, datetime +from decimal import Decimal from sqlalchemy import func, select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, defer from app.core.constants import ( ACTION_APPROVE, @@ -36,7 +37,7 @@ from app.core.constants import ( ) from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError from app.core.response import PageResult -from app.models import ApprovalOrder, ApprovalStep, User +from app.models import ApprovalOrder, ApprovalStep, Business, User from app.models.approval import ( APPROVAL_ACTION_APPROVE, APPROVAL_ACTION_AUTO_APPROVE, @@ -113,6 +114,7 @@ def submit_order( ref_id: int, refer_no: str, submitter: User, + snapshot: dict | None = None, ) -> ApprovalOrder: """提单(业务写路径内部调用;不 commit、不嵌套 audit)。 @@ -122,6 +124,10 @@ def submit_order( 一并重做),update 路径由审计上下文回滚并经全局 handler 收敛 400—— **任何情况下都不允许本函数内部 rollback 后部分重试**,那会把同事务 的业务主行/明细/方案回滚掉而审批单单表重插,静默丢业务数据。 + + ``snapshot`` 是「提交时」的业务内容(由调用方组装,Decimal 已转 str); + 落库时包一层信封并固化采集时间——审批期间业务再改,复核者看到的仍是 + 送审内容。开关关闭的自动通过路径同样写快照,留痕不走样。 """ _withdraw_pending(session, order_type=order_type, ref_id=ref_id, operator=submitter) @@ -135,6 +141,17 @@ def submit_order( order_type=order_type, ref_id=ref_id, refer_no=refer_no, + version_no=_next_version(session, order_type, ref_id), + snapshot_json=( + { + "schema_version": 1, + "order_type": order_type, + "captured_at": datetime.now().isoformat(timespec="seconds"), + "data": snapshot, + } + if snapshot is not None + else None + ), submitter_id=submitter.id, submitter_name=submitter.username, ) @@ -165,6 +182,35 @@ def submit_order( return order +def pending_matches_snapshot( + session: Session, *, order_type: int, ref_id: int, snapshot: dict | None +) -> bool: + """在途审批单的送审内容是否与给定快照一致(用于「内容未变不重提」)。 + + 只比较快照信封里的 ``data`` 部分——``captured_at`` 每次采集都不同, + 参与比较会让判断恒为不一致。V11 之前的历史单没有快照,一律视为不一致 + (保守重提:宁可多提一次,不能漏审)。 + """ + if snapshot is None: + return False + pending = ( + session.execute( + select(ApprovalOrder).where( + ApprovalOrder.order_type == order_type, + ApprovalOrder.ref_id == ref_id, + ApprovalOrder.status.in_(_PENDING_STATUSES), + ) + ) + .scalars() + .all() + ) + for order in pending: + stored = order.snapshot_json or {} + if stored.get("data") == snapshot: + return True + return False + + def withdraw_pending_orders( session: Session, *, order_type: int, ref_id: int, operator: User ) -> int: @@ -258,7 +304,7 @@ def act( if rejected and order.order_type == 1: from app.repositories import business_repository - business_repository.delete_allocations(session, order.ref_id) + business_repository.deactivate_allocations(session, order.ref_id) # 审批结果联动(服务层回调,不 commit):月结 通过=锁定/驳回=回草稿; # 调整单 通过=生效/驳回=作废。函数内延迟导入防循环依赖。 @@ -299,11 +345,18 @@ def act( def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]: - """分页查询审批单(准入由 api 层依赖判定)。""" + """分页查询审批单(准入由 api 层依赖判定)。 + + 排除 snapshot_json 列(月结快照含按人明细,可达数十~上百 KB):列表出参 + 本就不含快照,整列加载会让翻页负担随快照体积线性放大;详情走 + `get_order` 按需读取(与操作日志列表排除 request_data 同一理由, + 见 operation_log_repository.list_logs)。 + """ filters = _order_filters(query) rows = ( session.execute( select(ApprovalOrder) + .options(defer(ApprovalOrder.snapshot_json)) .where(*filters) .order_by(ApprovalOrder.id.desc()) .offset(query.offset) @@ -360,8 +413,136 @@ def _withdraw_pending(session: Session, *, order_type: int, ref_id: int, operato logger.info("撤回在途审批单 %s 张 | ref=%s", len(pending), ref_id) +def _next_version(session: Session, order_type: int, ref_id: int) -> int: + """同一对象类型+引用对象的下一个提交版本号(从 1 递增)。""" + return ( + int( + session.execute( + select(func.coalesce(func.max(ApprovalOrder.version_no), 0)).where( + ApprovalOrder.order_type == order_type, + ApprovalOrder.ref_id == ref_id, + ) + ).scalar_one() + ) + + 1 + ) + + +def allocation_content(session: Session, business: Business) -> dict: + """二次分配方案的审批内容(提单快照与历史单回退共用同一结构)。 + + 业务主行已存在但客户联查落空(客户被删等异常)时返回空字典——调用方 + 以 ``or None`` 归一,避免生成「有快照但内容为空」的信封。 + """ + from app.repositories import business_repository + + row = business_repository.get_with_names(session, business.id) + if row is None: + return {} + _, customer_name, owner_name, assistant_name = row + allocations = business_repository.list_allocations(session, business.id) + total = sum((a.ratio for a in allocations), Decimal("0")) + return { + "business_no": business.business_no, + "business_date": business.business_date.isoformat() if business.business_date else None, + "customer_name": customer_name, + "owner_personnel_name": owner_name, + "assistant_personnel_name": assistant_name, + "sale_amount": _amount_text(business.total_sale_amount), + "cost_amount": _amount_text(business.total_cost_amount), + "allocations": [ + { + "personnel_id": a.personnel_id, + "personnel_name": a.personnel_name, + "ratio": str(a.ratio), + } + for a in allocations + ], + "allocation_total": str(total.quantize(Decimal("0.01"))), + "allocation_balance": str((Decimal("100") - total).quantize(Decimal("0.01"))), + } + + +def _amount_text(value: Decimal | None) -> str | None: + """金额文本化(Decimal 全程不转 float;None 保留为空,不伪造 0.00)。""" + return str(value) if value is not None else None + + +def settlement_content(session: Session, settlement_id: int) -> dict | None: + """月结审批内容(汇总 + 按人明细;提单快照与历史单回退共用)。""" + from app.models import MonthlySettlement + from app.repositories import settlement_repository + + settlement = session.get(MonthlySettlement, settlement_id) + if settlement is None: + return None + items = settlement_repository.list_items(session, settlement_id) + return { + "settlement_month": settlement.settlement_month, + "total_performance": _amount_text(settlement.total_performance), + "total_payable": _amount_text(settlement.total_payable), + "person_count": settlement.person_count, + "items": [ + { + "personnel_name": item.personnel_name, + "role_code": item.role_code, + "calc_type": item.calc_type, + "performance_sum": str(item.performance_sum), + "payable_sum": str(item.payable_sum), + "basic_amount": _amount_text(item.basic_amount), + "salary_mode": item.salary_mode, + "net_amount": _amount_text(item.net_amount), + "is_probation": item.is_probation, + } + for item in items + ], + } + + +def adjustment_content(session: Session, adjustment_id: int) -> dict | None: + """调整单审批内容(提单快照与历史单回退共用)。""" + from app.models import AdjustmentOrder + + adjustment = session.get(AdjustmentOrder, adjustment_id) + if adjustment is None: + return None + return { + "order_no": adjustment.order_no, + "settlement_month": adjustment.settlement_month, + "personnel_name": adjustment.personnel_name, + "direction": adjustment.direction, + "amount": str(adjustment.amount), + "reason": adjustment.reason, + } + + +def _content_of(session: Session, order: ApprovalOrder) -> tuple[dict | None, str]: + """审批内容出参:优先提交时快照,历史单回退当前内容并显式标注来源。""" + if order.snapshot_json: + return order.snapshot_json, "snapshot" + + if order.order_type == 1: + business = session.get(Business, order.ref_id) + data = allocation_content(session, business) if business is not None else None + elif order.order_type == 2: + data = settlement_content(session, order.ref_id) + elif order.order_type == 3: + data = adjustment_content(session, order.ref_id) + else: + data = None + + if not data: + return None, "missing" + return { + "schema_version": 1, + "order_type": order.order_type, + "captured_at": None, + "data": data, + }, "live" + + def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut: - """出参组装(含 steps 时间线)。调用方须保证处于 commit 之前。""" + """出参组装(含 steps 时间线与审批内容)。调用方须保证处于 commit 之前。""" steps = ( session.execute( select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id) @@ -371,6 +552,7 @@ def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut ) detail = ApprovalOrderDetailOut.model_validate(order) detail.steps = [ApprovalStepOut.model_validate(s) for s in steps] + detail.snapshot, detail.content_source = _content_of(session, order) return detail diff --git a/backend/app/services/business_service.py b/backend/app/services/business_service.py index 40029bb..edaa614 100644 --- a/backend/app/services/business_service.py +++ b/backend/app/services/business_service.py @@ -54,6 +54,7 @@ from app.models.business import ( from app.models.customer import OWNER_TYPE_BUSINESS from app.repositories import business_repository, customer_repository, personnel_repository from app.schemas.business import ( + AllocationVersionOut, BusinessCreateIn, BusinessDetailOut, BusinessItemIn, @@ -281,6 +282,17 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut: for a in allocations ] out.allocation_hint = _allocation_hint(allocations) + out.allocation_history = [ + AllocationVersionOut( + personnel_id=a.personnel_id, + personnel_name=a.personnel_name, + ratio=str(a.ratio), + version_no=a.version_no, + is_active=a.is_active, + create_time=a.create_time, + ) + for a in business_repository.list_allocation_versions(session, business_id) + ] return out @@ -335,6 +347,21 @@ def _build_allocation_rows( return rows +def _plan_changed( + old_rows: list[SecondaryAllocation], new_rows: list[SecondaryAllocation] +) -> bool: + """方案内容(参与人 + 比例)是否真的变了。 + + 比较口径是**集合**(人员 ID 与其比例的组合),与提交顺序无关——送审 + 快照同样按库内生效行的顺序生成,若按顺序比较,用户只是把行拖了个 + 序就会被判成「变了」,进而无谓地新增版本与重提审批单。 + """ + def _key(row: SecondaryAllocation) -> tuple[int, Decimal]: + return (row.personnel_id, Decimal(row.ratio).quantize(Decimal("0.01"))) + + return {_key(r) for r in old_rows} != {_key(r) for r in new_rows} + + def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None: """方案合计/余额提示(不要求合计满额是需求原文;余额归属 = 澄清 #2 答复)。 @@ -433,6 +460,7 @@ def create_business( ref_id=business.id, refer_no=business.business_no, submitter=operator, + snapshot=approval_service.allocation_content(session, business) or None, ) customer = customer_repository.get_by_id(session, business.customer_id) out = _to_out((business, customer.company_name if customer else None, None, None)) @@ -507,28 +535,43 @@ def update_business( business_repository.replace_items( session, business_id, _build_item_rows(business_id, payload.items) ) - # 方案全量替换(空数组=清空);终态门槛已挡「修改影响已结算月份」的 - # 前置场景(月结在 ④,快照固化后按同一哲学隔离) - old_ids = { - a.personnel_id for a in business_repository.list_allocations(session, business_id) - } - business_repository.replace_allocations( + # 方案:内容真的变了才动版本行(内容未变不新增版本,避免「只改备注」 + # 也堆出一个方案版本);空数组=清空=把生效行置历史 + old_allocations = business_repository.list_allocations(session, business_id) + new_rows = _build_allocation_rows( session, business_id, - _build_allocation_rows( - session, business_id, payload.secondary_allocations, old_ids=old_ids - ), + payload.secondary_allocations, + old_ids={a.personnel_id for a in old_allocations}, ) - # 提单/撤单与方案同步生命周期:非空=提单(内含撤旧建新);空=仅撤回 - # 在途单 —— 否则旧在途单挂着已不存在的方案继续走审批 + if not payload.secondary_allocations: + business_repository.replace_allocations(session, business_id, []) + elif _plan_changed(old_allocations, new_rows): + business_repository.replace_allocations(session, business_id, new_rows) + # 汇总先重算:送审快照里的销售额/成本额必须反映本次编辑后的值, + # 放到提单之后会固化成上一版本的金额 + _recalc_totals(session, business) + # 提单/撤单与方案同步生命周期: + # - 方案为空 → 仅撤回在途单(旧在途单挂着已不存在的方案继续走审批) + # - 送审内容与在途单一致 → 保留原单,不撤回不重建(避免反复保存 + # 刷出「撤回+新建」的审批留痕噪音) + # - 其余 → 提新单(内含撤旧建新) if payload.secondary_allocations: - approval_service.submit_order( + snapshot = approval_service.allocation_content(session, business) or None + if not approval_service.pending_matches_snapshot( session, order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION, ref_id=business_id, - refer_no=business.business_no, - submitter=operator, - ) + snapshot=snapshot, + ): + approval_service.submit_order( + session, + order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION, + ref_id=business_id, + refer_no=business.business_no, + submitter=operator, + snapshot=snapshot, + ) else: approval_service.withdraw_pending_orders( session, @@ -536,7 +579,6 @@ def update_business( ref_id=business_id, operator=operator, ) - _recalc_totals(session, business) # 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段 # 从首查 row 取(换客户时 _ensure_customer 已给出新公司名) out = _to_out((business, customer.company_name, owner_name, assistant_name)) diff --git a/backend/app/services/settlement_service.py b/backend/app/services/settlement_service.py index 3763e45..50fed9a 100644 --- a/backend/app/services/settlement_service.py +++ b/backend/app/services/settlement_service.py @@ -248,7 +248,7 @@ def submit( ), meta=meta, ): - settlement = settlement_repository.get_by_id(session, settlement_id) + settlement = settlement_repository.get_by_id(session, settlement_id, for_update=True) if settlement is None: raise NotFoundError("月结不存在") if settlement.status not in (SETTLEMENT_STATUS_DRAFT, SETTLEMENT_STATUS_REJECTED): @@ -260,6 +260,9 @@ def submit( ref_id=settlement.id, refer_no=f"JS{settlement.settlement_month.replace('-', '')}", submitter=operator, + # 月结明细在生成时已固化;提交时快照让复核者看到的是送审版本, + # 而不是审批期间若发生重生成后的内容 + snapshot=approval_service.settlement_content(session, settlement.id), ) settlement.approval_order_no = order.order_no if order.status == SETTLEMENT_STATUS_APPROVED: @@ -573,6 +576,7 @@ def _wire_adjustment_approval(session: Session, order, operator: User) -> None: ref_id=order.id, refer_no=order.order_no, submitter=operator, + snapshot=approval_service.adjustment_content(session, order.id), ) order.status = ( ADJUSTMENT_STATUS_EFFECTIVE diff --git a/backend/tests/README.md b/backend/tests/README.md index b802319..d66bf22 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -159,6 +159,18 @@ uv run pytest tests/integration -q # 仅集成 **实施中发现并修复**:`alembic upgrade --sql` 在 Windows 控制台按 GBK 输出,重定向生成的 `V10__*.sql` 被 `test_schema_sync` 按 UTF-8 读取时抛 `UnicodeDecodeError`——导出命令须加 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1`(已写入 backend/README)。 +### code-review 遗留 2 项(2026-10-20 完成:方案版本化 / 审批内容快照) + +| 文件 | 守什么 | 用例数 | +| --- | --- | --- | +| `tests/integration/test_approval_flow.py`(扩) | **提交时快照固化**(version_no=1、content_source=snapshot、方案比例/合计/余额与业务编号);编辑方案为 80% 后新单 V2、旧单快照仍 50.00 且旧在途单撤回;**历史单无快照回退当前内容并标 live**(captured_at=null);引用对象缺失且无快照 → missing;**方案版本化**:编辑后 history=[(1,0,50.00),(2,1,80.00)]、生效行仅 80.00;驳回后生效行为空但历史行保留(is_active=0) | +5 | +| `tests/unit/test_approval_schema.py` | 对象类型筛选 1/2/3 均可、0/4 拒绝(修复原上界 `le=2` 导致调整单审批单无法筛选);不传类型表示全部;驳回必须填意见、通过可不填 | 6 | +| `tests/unit/test_approval_helpers.py` | **方案内容比较**(`business_service._plan_changed`):完全相同/行序不同判未变、比例变/人员增减/同比例换人判已变、比例写法 `50` 与 `50.00` 等价、空方案对照 | 10 | +| `tests/integration/test_secondary_allocation.py`(扩) | **同一会话内旧方案行随置历史同步刷新**(Core UPDATE 的会话同步策略退化为不刷新时,行对象会停留在 is_active=1 的过期内存值) | +1 | +| `tests/integration/test_approval_flow.py`(扩) | **送审内容未变不重提**:方案与金额都没动 → 方案不新增版本、审批单不重提(旧单仍待财务复核);**只改金额** → 方案版本不变但审批单 version_no+1 且新快照金额为改后值;**改方案比例** → 方案版本+1、审批单 version_no+1 且旧单撤回;**无在途单时保存仍提新单**(保守规则);`pending_matches_snapshot` 四态(一致/不一致/无在途单/快照为 None) | +5 | + +运行:`uv run pytest tests/integration/test_approval_flow.py tests/integration/test_secondary_allocation.py tests/integration/test_schema_sync.py -q` → **37 passed**(2026-10-20);`tests/integration/test_settlements.py` → **16 passed**;`tests/integration/test_calc.py` → **20 passed**;`tests/unit` → **525 passed**(含新增 `test_approval_helpers.py` 10 例);**全量 `uv run pytest -q` → 891 passed(10:23)**。ruff、前端 `pnpm type-check` 与 `pnpm build` 均绿。 + ## 三、运行记录 | 日期 | 范围 | 用例数 | 结果 | 备注 | @@ -184,6 +196,8 @@ uv run pytest tests/integration -q # 仅集成 | 2026-10-19 | **code-review 修复回归** | 全量 862 | ✅ 862 passed(9:36) | **M4 工作台 + 报表 3 必须 + 8 建议修复**:普通用户落地页/守卫(报表集成 27、工作台集成 11、报表单元 17,新增 10 例);部门汇总 5% 按计入次数累计并与月结 `total_payable` 对拍、聚合行舍入差;工作台待办按角色过滤、逾期命中与阈值生效、终止业务排除、`warnings.total` 截断、趋势跨月归属。ruff 绿;type-check + build 绿 | | 2026-10-20 | e2e 全链路 | — | ✅ login 8 / system 13 / role_home 4 / report 12 / calc_settlement 8 | 重启后端加载新代码后实跑:普通用户落地页与受限页回退、报表四页/导出/打印、核算与月结链路全过。`report_flow` 回款统计改为展开目标桶后按本次业务编号定位明细,不依赖桶累计值,可重复运行 | | 2026-10-20 | 全量回归 | 862 | ✅ 862 passed(11:32) | **工作台/报表修复提交后的全量复核**;e2e 三脚本复跑:role_home 4/4、report 12/12、calc_settlement 8/8;calc 审批开关已还原为 1,无 `e2e_role_*` 残留 | +| 2026-10-20 | code-review 遗留 2 项 | 全量 875 | ✅ 875 passed(12:13) | **方案版本化 + 审批内容快照**(V11 迁移):审批单 `version_no`/`snapshot_json`,方案行 `is_active` 版本化;编辑/驳回不再物理删方案;历史单无快照时 live 回退并显式标注。新增集成 7 + 单元 6;定点审批/月结/核算/业务/报表全绿;ruff 与 type-check/build 绿 | +| 2026-10-20 | 遗留项自评审收口 | 全量 891 | ✅ 891 passed(10:23) | **列表 defer 快照列**(审批列表/工作台待办/超时预警)+ **送审内容未变不重提、方案未变不新增版本**(两个 version_no 语义解耦)+ **月结提交加行锁**。新增单元 10 + 集成 5;单元 525、集成定点 96 全绿 | | 2026-10-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 | | 2026-10-19 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 | @@ -197,6 +211,12 @@ uv run pytest tests/integration -q # 仅集成 | 2026-10-19 | 经理个人户成员行取该人全部绩效 | 团队基数只含个人户行,成员行却含普通行——**基数≠成员合计,同一笔业绩在成员行与未分配重复** | 普通行与个人户分行记账;经理成员行只取个人户金额,普通行留在上级团队/未分配 | | 2026-10-19 | 导出取数与超限校验在审计上下文之外 | 超限 400 与取数异常**不留失败日志**,与 M4 计划 T12「导出 400/失败审计」不符 | `_export_payload` 移入 `with audit`;静态标题映射(AuditRecord frozen) | | 2026-10-19 | 月份只校验长度 | `2026-13` 传到 `calendar.monthrange` 抛 ValueError → **500**;导出手工构造模型抛裸 ValidationError 同样 500 | 新增 `core/month.py`(格式+01-12+年份);四类 query、月结、调整单接入;导出 ValidationError 转 400 | +| 2026-10-20 | 审批类型筛选上界写死 2 | `ApprovalQuery.order_type` 为 `le=2`:调整单(order_type=3)审批单在列表无法筛选,只能翻全量 | 上界改 3;单元用例锁定 1/2/3 与越界拒绝 | +| 2026-10-20 | 编辑业务时送审快照取旧汇总 | `update_business` 中 `_recalc_totals` 在提单之后,快照里的销售额/成本额会固化上一版本金额 | 汇总重算移到提单之前;快照用例断言送审内容与本次编辑后方案一致 | +| 2026-10-20 | 快照金额把「空」写成 0.00 | 业务尚无成本时 `total_cost_amount=None` 被文本化成 `0.00`,复核者会把「无成本」误读成「成本为零」 | `_amount_text(None)` 保留为空,前端显示「—」 | +| 2026-10-20 | 每次保存带方案的业务都刷出版本与审批留痕 | 只改备注、方案一字未动,也会「置历史+新增版本+撤回旧单+提新单」,方案历史与审批留痕被噪音淹没,两个 `version_no` 语义混同 | 判据统一为**送审内容**:方案未变不新增版本行;在途单送审内容一致则不重提(`pending_matches_snapshot`,只比 `data`) | +| 2026-10-20 | 三处列表查询整列拉快照 | 审批单列表、工作台待办、审批超时预警都用 `select(ApprovalOrder)` 取全列,快照 JSON 可达上百 KB,翻页与工作台负担随快照体积线性放大 | 三处加 `defer(ApprovalOrder.snapshot_json)`(沿用操作日志列表排除 `request_data` 的既有模式) | +| 2026-10-20 | 月结提交未锁行 | 并发/双击提交同一月结时两次 `_next_version` 可能算出同一 `version_no`,送审次序失真 | `settlement_repository.get_by_id` 加 `for_update` 参数,提交路径锁行 | | 2026-10-19 | 前端报表五处状态缺陷 | 打印超限判定恒假(静默截断)、部门下钻 query 未读取、回款多桶共用一个 drill 状态(面板串行)、summary 在请求序号守卫前写入(合计串号)、e2e 全页行数选择器必失败 | 分别修正判定/读取 query/按桶独立状态+数组参数/`onData` 回调/限定主桶表;补 e2e 多桶展开、下钻、截断、部门打印用例 | | 2026-10-19 | 工作台权限收紧后守卫仍回退 `/dashboard` | 普通用户登录/直连受限页被反复重定向到同一受限页 → **无法进入系统**(导航被判定无限重定向) | `router/access.ts` 统一准入与落地页解析(工作台 → 业务数据 → 回款);登录回跳经 `canAccessPath` 校验;菜单/面包屑复用同一判定 | | 2026-10-19 | 部门汇总应发只计一次全公司 5% | 月结每位有聚合行的经理各计一次 5%,签字稿少计 N-1 份;**导出/打印金额小于月结应发** | 团队行增 `has_aggregate/aggregate_amount`(优先冻结聚合行);对账改按聚合额求和,新增「聚合行舍入差」吸收分列 q2 尾差;补「与 `total_payable` 对拍」用例 | diff --git a/backend/tests/integration/test_approval_flow.py b/backend/tests/integration/test_approval_flow.py index 37ccbfe..b0be800 100644 --- a/backend/tests/integration/test_approval_flow.py +++ b/backend/tests/integration/test_approval_flow.py @@ -388,3 +388,332 @@ class TestModuleAccess: user = make_user([code]) user["headers"] = auth_headers(user) assert client.get(_APPROVALS, headers=user["headers"]).status_code == 200 + + +class TestContentSnapshot: + """审批单内容快照与版本号(code-review 遗留②)。 + + 守的是:复核者看到的是**送审时**的方案,而不是审批期间被改过的当前 + 方案;历史单(升级前无快照)必须显式标注「当前内容回退」,不能把 + 当前内容冒充送审内容。 + """ + + def test_提交时快照固化_再改方案不回写旧单( + self, client: TestClient, admin: dict, finance: dict, 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["version_no"] == 1 + assert first["content_source"] == "snapshot" + snapshot = first["snapshot"] + assert snapshot["schema_version"] == 1 + assert snapshot["order_type"] == 1 + assert snapshot["captured_at"], "快照须固化采集时间" + assert snapshot["data"]["business_no"] == data["business_no"] + assert snapshot["data"]["allocations"][0]["ratio"] == "50.00" + assert snapshot["data"]["allocation_total"] == "50.00" + assert snapshot["data"]["allocation_balance"] == "50.00" + + # 编辑方案为 80%:旧在途单撤回,新单 V2 快照取新值;旧单快照不变 + detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"] + payload = business_payload( + detail["customer_id"], + secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}], + ) + resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"]) + assert resp.status_code == 200, resp.text + + second = _latest_order(client, finance["headers"], data["business_no"]) + assert second["version_no"] == 2 + assert second["snapshot"]["data"]["allocations"][0]["ratio"] == "80.00" + + old = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"] + assert old["status"] == 5, "重新提交须撤回旧在途单" + assert old["snapshot"]["data"]["allocations"][0]["ratio"] == "50.00" + + def test_历史单无快照_回退当前内容并标注( + self, client: TestClient, admin: dict, finance: dict, switched_on + ) -> None: + """升级前的审批单没有快照列:回退读当前内容,但来源必须标 live。""" + from app.core.db import get_session_factory + from app.models import ApprovalOrder + + p = _person(client, admin["headers"]) + data = _create_with_plan(client, admin["headers"], p["id"]) + order = _latest_order(client, finance["headers"], data["business_no"]) + + factory = get_session_factory() + with factory() as session: + row = session.get(ApprovalOrder, order["id"]) + assert row is not None + row.snapshot_json = None + session.commit() + + detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()["data"] + assert detail["content_source"] == "live" + assert detail["snapshot"]["captured_at"] is None + assert detail["snapshot"]["data"]["business_no"] == data["business_no"] + + def test_引用对象缺失且无快照_内容缺失( + self, client: TestClient, admin: dict, finance: dict, switched_on + ) -> None: + from app.core.db import get_session_factory + from app.models import ApprovalOrder + + p = _person(client, admin["headers"]) + data = _create_with_plan(client, admin["headers"], p["id"]) + order = _latest_order(client, finance["headers"], data["business_no"]) + + factory = get_session_factory() + with factory() as session: + row = session.get(ApprovalOrder, order["id"]) + assert row is not None + row.snapshot_json = None + row.ref_id = 999_999_999 + session.commit() + + detail = client.get(f"{_APPROVALS}/{order['id']}", headers=finance["headers"]).json()["data"] + assert detail["content_source"] == "missing" + assert detail["snapshot"] is None + + +def _payload_from_detail(detail: dict, **overrides: object) -> dict: + """按业务详情原样重建提交载荷(明细行照抄,用于「内容未变」的场景)。""" + items = [ + { + "product_name": it["product_name"], + "product_category": it["product_category"], + "quantity": it["quantity"], + "sale_price": it["sale_price"], + } + for it in detail["items"] + ] + body = business_payload( + detail["customer_id"], + items=items, + secondary_allocations=detail["secondary_allocations"], + ) + body.update(overrides) + return body + + +class TestNoRedundantResubmit: + """送审内容未变不重提、方案未变不新增版本(版本与留痕降噪)。 + + 守的是:反复保存同一份业务(只改备注)不应刷出一串方案版本与 + 「撤回+新建」的审批留痕;而内容真变了(金额或方案)必须重提, + 否则审批人看到的是过期内容。 + """ + + def test_方案与金额均未变_不新增版本也不重提( + self, client: TestClient, admin: dict, finance: dict, 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 = _payload_from_detail(detail) + payload["remark"] = "只改了备注" + resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"]) + assert resp.status_code == 200, resp.text + + # 方案版本不增 + after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"] + assert [(h["version_no"], h["is_active"]) for h in after["allocation_history"]] == [ + (1, 1) + ], "方案内容未变不应新增版本行" + + # 审批单不重提:仍是原来那一张、仍待财务复核 + rows = client.get( + _APPROVALS, + params={"keyword": data["business_no"], "pageSize": 10}, + headers=finance["headers"], + ).json()["data"]["list"] + assert len(rows) == 1, f"送审内容未变不应重提,实得 {len(rows)} 张单" + assert rows[0]["id"] == first["id"] + assert rows[0]["status"] == 1, "在途单不应被撤回" + assert rows[0]["version_no"] == 1 + + def test_只改金额不改方案_方案版本不变但审批单重提( + self, client: TestClient, admin: dict, finance: dict, 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"]) + + detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"] + payload = _payload_from_detail(detail) + payload["items"][0]["sale_price"] = "200.00" + resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"]) + assert resp.status_code == 200, resp.text + + after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"] + assert [(h["version_no"], h["is_active"]) for h in after["allocation_history"]] == [ + (1, 1) + ], "方案未变不应新增版本行" + + second = _latest_order(client, finance["headers"], data["business_no"]) + assert second["version_no"] == 2, "金额变了必须重提" + assert second["snapshot"]["data"]["sale_amount"] == "20000.00" + old = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"] + assert old["status"] == 5 + + def test_改方案比例_方案版本与审批单版本同时递增( + self, client: TestClient, admin: dict, finance: dict, 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"]) + + detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"] + payload = _payload_from_detail( + detail, secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}] + ) + resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"]) + assert resp.status_code == 200, resp.text + + after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"] + assert [(h["version_no"], h["is_active"]) for h in after["allocation_history"]] == [ + (1, 0), + (2, 1), + ], "方案变了应新增版本并把旧行置历史" + + second = _latest_order(client, finance["headers"], data["business_no"]) + assert second["version_no"] == 2 + old = client.get(f"{_APPROVALS}/{first['id']}", headers=finance["headers"]).json()["data"] + assert old["status"] == 5 + + def test_无在途单时保存仍提新单( + self, client: TestClient, admin: dict, finance: dict, leader: dict, switched_on + ) -> None: + """保守规则:只有「存在在途单且内容一致」才不重提。 + 方案已审批通过后再保存,仍按原语义重新送审。""" + p = _person(client, admin["headers"]) + data = _create_with_plan(client, admin["headers"], p["id"]) + order = _latest_order(client, finance["headers"], data["business_no"]) + client.post( + f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=finance["headers"] + ) + client.post( + f"{_APPROVALS}/{order['id']}/action", json={"action": 1}, headers=leader["headers"] + ) + + detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"] + payload = _payload_from_detail(detail) + payload["remark"] = "已通过后再保存" + 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"] + assert len(rows) == 2, "无在途单时保存应重新送审" + assert max(r["version_no"] for r in rows) == 2 + + +class TestPendingSnapshotMatch: + """在途单送审内容一致性判定(approval_service.pending_matches_snapshot)。""" + + def test_三种情形(self, client: TestClient, admin: dict, finance: dict, switched_on) -> None: + from app.core.db import get_session_factory + from app.services import approval_service + + p = _person(client, admin["headers"]) + data = _create_with_plan(client, admin["headers"], p["id"]) + order = _latest_order(client, finance["headers"], data["business_no"]) + snapshot = order["snapshot"]["data"] + + factory = get_session_factory() + with factory() as session: + # 1) 内容一致 → True + assert ( + approval_service.pending_matches_snapshot( + session, + order_type=1, + ref_id=data["id"], + snapshot=snapshot, + ) + is True + ) + # 2) 内容不同 → False + assert ( + approval_service.pending_matches_snapshot( + session, order_type=1, ref_id=data["id"], snapshot={"allocations": []} + ) + is False + ) + # 3) 无在途单 → False(引用对象换成别的) + assert ( + approval_service.pending_matches_snapshot( + session, order_type=1, ref_id=999_999_999, snapshot=snapshot + ) + is False + ) + # 4) 快照为 None → False + assert ( + approval_service.pending_matches_snapshot( + session, order_type=1, ref_id=data["id"], snapshot=None + ) + is False + ) + + +class TestAllocationVersions: + """二次分配方案版本化(code-review 遗留①)。 + + 守的是:编辑替换与驳回清空只把旧行置历史(is_active=0),不物理删除, + 方案参与人与比例可回溯;生效行始终唯一,核算取数不受历史行干扰。 + """ + + def test_编辑方案产生新版本_旧版本保留( + self, client: TestClient, admin: dict, switched_on + ) -> None: + p = _person(client, admin["headers"]) + data = _create_with_plan(client, admin["headers"], p["id"]) + + detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"] + assert [(h["version_no"], h["is_active"]) for h in detail["allocation_history"]] == [ + (1, 1) + ] + + payload = business_payload( + detail["customer_id"], + secondary_allocations=[{"personnel_id": p["id"], "ratio": "80.00"}], + ) + resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"]) + assert resp.status_code == 200, resp.text + + detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"] + history = [(h["version_no"], h["is_active"], h["ratio"]) for h in detail["allocation_history"]] + assert history == [(1, 0, "50.00"), (2, 1, "80.00")] + assert [a["ratio"] for a in detail["secondary_allocations"]] == ["80.00"] + + def test_驳回后生效行为空_历史行保留( + self, client: TestClient, admin: dict, finance: dict, switched_on + ) -> None: + p = _person(client, admin["headers"]) + data = _create_with_plan(client, admin["headers"], p["id"]) + order = _latest_order(client, finance["headers"], data["business_no"]) + + resp = client.post( + f"{_APPROVALS}/{order['id']}/action", + json={"action": 2, "opinion": "方案不合理"}, + headers=finance["headers"], + ) + assert resp.status_code == 200, resp.text + + detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"] + assert detail["secondary_allocations"] == [], "被驳回方案不得留在生效行" + assert detail["allocation_hint"] is None + history = detail["allocation_history"] + assert len(history) == 1 + assert history[0]["version_no"] == 1 + assert history[0]["is_active"] == 0 + assert history[0]["ratio"] == "50.00" diff --git a/backend/tests/integration/test_secondary_allocation.py b/backend/tests/integration/test_secondary_allocation.py index 176e9ac..9323fa9 100644 --- a/backend/tests/integration/test_secondary_allocation.py +++ b/backend/tests/integration/test_secondary_allocation.py @@ -247,6 +247,36 @@ class TestGuards: class TestDeleteProtection: """RESTRICT 外键:被方案引用的人员禁删,方案随业务删除而清。""" + def test_同一会话内旧方案行同步为历史态(self, client: TestClient, admin: dict) -> None: + """守「置历史后同一会话内的旧行必须同步刷新」这一行为:Core UPDATE + 走的是 SQLAlchemy 的会话同步策略,条件复杂化时可能退化为不刷新, + 届时行对象会停留在 is_active=1 的过期内存值(库中实际已置历史), + 被序列化或参与判断就会呈现「生效态」的假象。""" + from app.core.db import get_session_factory + from app.models import SecondaryAllocation + from app.repositories import business_repository + + p = _person(client, admin["headers"]) + data = _create_with_allocations( + client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}] + ) + + factory = get_session_factory() + with factory() as session: + loaded = business_repository.list_allocations(session, data["id"]) + assert loaded and all(a.is_active == 1 for a in loaded) + business_repository.deactivate_allocations(session, data["id"]) + # 同一 session 内的行对象必须已是历史态,不能停留在过期内存值 + assert all(a.is_active == 0 for a in loaded), "旧行未随置历史同步刷新" + # 库中同样已置历史 + rows = ( + session.query(SecondaryAllocation) + .filter(SecondaryAllocation.business_id == data["id"]) + .all() + ) + assert rows and all(r.is_active == 0 for r in rows) + session.rollback() + def test_被方案引用的人员禁删(self, client: TestClient, admin: dict) -> None: p = _person(client, admin["headers"]) data = _create_with_allocations( diff --git a/backend/tests/integration/test_settlements.py b/backend/tests/integration/test_settlements.py index 6c398c7..1a828bf 100644 --- a/backend/tests/integration/test_settlements.py +++ b/backend/tests/integration/test_settlements.py @@ -229,6 +229,19 @@ class TestSubmitAndLock: assert out["approval_order_no"] and out["approval_order_no"].startswith("AP") assert out["locked_at"] is not None + # 自动通过路径同样固化送审快照(月结头 + 按人明细) + order = client.get( + "/api/v1/system/approvals", + params={"keyword": out["approval_order_no"], "pageSize": 1}, + headers=admin["headers"], + ).json()["data"]["list"][0] + detail = client.get( + f"/api/v1/system/approvals/{order['id']}", headers=admin["headers"] + ).json()["data"] + assert detail["content_source"] == "snapshot" + assert detail["snapshot"]["data"]["settlement_month"] == "2026-10" + assert detail["snapshot"]["data"]["items"], "月结快照须含按人明细" + def test_锁定后回款禁改(self, client: TestClient, admin: dict) -> None: from sqlalchemy import select @@ -603,6 +616,21 @@ class TestAdjustment: out = resp.json()["data"] assert out["status"] == 2 # 已生效 + # 调整单送审快照:复核者能看到金额/方向/说明,而非只有调整单号 + order = client.get( + "/api/v1/system/approvals", + params={"keyword": out["order_no"], "pageSize": 1}, + headers=admin["headers"], + ).json()["data"]["list"][0] + assert order["order_type"] == 3 + detail = client.get( + f"/api/v1/system/approvals/{order['id']}", headers=admin["headers"] + ).json()["data"] + assert detail["content_source"] == "snapshot" + assert detail["snapshot"]["data"]["amount"] == "500.00" + assert detail["snapshot"]["data"]["direction"] == 1 + assert detail["snapshot"]["data"]["reason"] == "补发差额" + def test_金额恒正校验(self, client: TestClient, admin: dict) -> None: """守金额无校验缺陷:负数会反转补发/扣减语义,非数值直落库 500。""" _set_switch(client, admin["headers"], "approval.adjustment.enabled", "0") diff --git a/backend/tests/unit/test_approval_helpers.py b/backend/tests/unit/test_approval_helpers.py new file mode 100644 index 0000000..7513d17 --- /dev/null +++ b/backend/tests/unit/test_approval_helpers.py @@ -0,0 +1,72 @@ +"""审批与方案版本化的纯函数单元测试(秒级,无需容器)。 + +守的是「方案内容是否真的变了」这一判定(``business_service._plan_changed``): +它决定要不要新增一个方案版本行、要不要重提审批单。判错的后果是双向的—— +判成「变了」会刷出版本与审批留痕噪音,判成「没变」会让改过的方案 +沿用旧版本号,版本语义失真。 + +比较口径是**集合**(人员 ID + 其比例),与提交顺序无关:用户只是把行 +拖了个序,不该被算成方案变了。 +""" + +from decimal import Decimal + +import pytest + +from app.models.business import SecondaryAllocation +from app.services.business_service import _plan_changed + + +def _row(personnel_id: int, ratio: str) -> SecondaryAllocation: + """构造一个未入库的方案行(ORM 实例化不触数据库)。""" + return SecondaryAllocation( + business_id=1, + version_no=1, + personnel_id=personnel_id, + personnel_name=f"t_人员{personnel_id}", + ratio=Decimal(ratio), + is_active=1, + ) + + +class TestPlanChanged: + """方案内容比较。""" + + def test_完全相同_判未变(self) -> None: + old = [_row(1, "50.00"), _row(2, "30.00")] + new = [_row(1, "50.00"), _row(2, "30.00")] + assert _plan_changed(old, new) is False + + def test_顺序不同_判未变(self) -> None: + """用户只是把行拖了个序 —— 送审快照按库内生效行顺序生成, + 按顺序比较会把这种编辑误判成「方案变了」。""" + old = [_row(1, "50.00"), _row(2, "30.00")] + new = [_row(2, "30.00"), _row(1, "50.00")] + assert _plan_changed(old, new) is False + + def test_比例变化_判已变(self) -> None: + old = [_row(1, "50.00")] + new = [_row(1, "80.00")] + assert _plan_changed(old, new) is True + + def test_参与人增减_判已变(self) -> None: + old = [_row(1, "50.00")] + assert _plan_changed(old, [_row(1, "50.00"), _row(2, "30.00")]) is True + assert _plan_changed([_row(1, "50.00"), _row(2, "30.00")], [_row(1, "50.00")]) is True + + def test_同一人的比例从一人换成另一人_判已变(self) -> None: + """参与人不同但比例相同:人员 ID 是比较键的一部分,不能只看比例。""" + old = [_row(1, "50.00")] + new = [_row(2, "50.00")] + assert _plan_changed(old, new) is True + + @pytest.mark.parametrize("written", ["50", "50.0", "50.00"]) + def test_比例写法不同_判未变(self, written: str) -> None: + """比例入参是字符串('50' 与 '50.00' 都合法),数值相同即未变。""" + assert _plan_changed([_row(1, "50.00")], [_row(1, written)]) is False + + def test_空方案与空方案_判未变(self) -> None: + assert _plan_changed([], []) is False + + def test_空方案与有方案_判已变(self) -> None: + assert _plan_changed([], [_row(1, "50.00")]) is True diff --git a/backend/tests/unit/test_approval_schema.py b/backend/tests/unit/test_approval_schema.py new file mode 100644 index 0000000..1c1e28f --- /dev/null +++ b/backend/tests/unit/test_approval_schema.py @@ -0,0 +1,38 @@ +"""审批管理入参校验(schema 层,秒级)。 + +守的是:对象类型筛选必须覆盖三类审批对象(1 二次分配方案 / 2 月度结算 / +3 调整单)——早期上界写死在 2,调整单审批单在列表里无法筛选;越界值在 +参数绑定阶段即拒绝。驳回必须带意见(空驳无从追溯)。 +""" + +import pytest +from pydantic import ValidationError + +from app.schemas.approval import ApprovalActionIn, ApprovalQuery + + +class TestQueryOrderType: + """对象类型筛选边界。""" + + @pytest.mark.parametrize("value", [1, 2, 3]) + def test_三类对象均可筛选(self, value: int) -> None: + assert ApprovalQuery(order_type=value).order_type == value + + @pytest.mark.parametrize("value", [0, 4]) + def test_越界类型被拒(self, value: int) -> None: + with pytest.raises(ValidationError): + ApprovalQuery(order_type=value) + + def test_不传类型表示全部(self) -> None: + assert ApprovalQuery().order_type is None + + +class TestActionOpinion: + """节点动作的驳回意见要求。""" + + def test_驳回必须填意见(self) -> None: + with pytest.raises(ValidationError): + ApprovalActionIn(action=2) + + def test_通过可不填意见(self) -> None: + assert ApprovalActionIn(action=1).opinion is None diff --git a/db/init/init-dev.sql b/db/init/init-dev.sql index d6ddf1e..66ab2c8 100644 --- a/db/init/init-dev.sql +++ b/db/init/init-dev.sql @@ -713,6 +713,8 @@ CREATE TABLE approval_orders ( order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算(预留)', ref_id BIGINT UNSIGNED NOT NULL COMMENT '引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)', refer_no VARCHAR(32) NOT NULL COMMENT '引用对象单号(冗余固化,列表页免联查)', + version_no INT NOT NULL COMMENT '提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)' DEFAULT '1', + snapshot_json JSON COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)', status TINYINT NOT NULL COMMENT '审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回' DEFAULT '1', submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)', submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)', @@ -725,6 +727,8 @@ CREATE TABLE approval_orders ( CREATE INDEX idx_approval_orders_type_status ON approval_orders (order_type, status); +CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id); + CREATE TABLE approval_steps ( id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, order_id BIGINT UNSIGNED NOT NULL COMMENT '审批单ID(approval_orders.id;单删记录随删)', @@ -744,21 +748,25 @@ CREATE INDEX idx_approval_steps_order_id ON approval_steps (order_id); CREATE TABLE business_secondary_allocations ( id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT, business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;业务删除随删)', + version_no INT NOT NULL COMMENT '方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)' DEFAULT '1', personnel_id BIGINT UNSIGNED NOT NULL COMMENT '参与业务员ID(personnel.id;被方案引用禁删)', personnel_name VARCHAR(64) NOT NULL COMMENT '参与业务员姓名(冗余固化,离职后回显有名)', ratio DECIMAL(5, 2) NOT NULL COMMENT '分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)', + is_active TINYINT NOT NULL COMMENT '是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)' DEFAULT '1', 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 uk_business_secondary_allocations_business_version_personnel UNIQUE (business_id, version_no, 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; +)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci; CREATE INDEX idx_business_secondary_allocations_business_id ON business_secondary_allocations (business_id); CREATE INDEX idx_business_secondary_allocations_personnel_id ON business_secondary_allocations (personnel_id); +CREATE INDEX idx_business_secondary_allocations_business_active ON business_secondary_allocations (business_id, is_active); + -- --------------------------------------------------------------------------- -- 绩效核算台账/月结/调整单(V7 迁移;append-only 版本行;通过即锁定走调整单) -- --------------------------------------------------------------------------- @@ -882,4 +890,4 @@ CREATE TABLE alembic_version ( CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) )CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci; -INSERT INTO alembic_version (version_num) VALUES ('90653814a421'); +INSERT INTO alembic_version (version_num) VALUES ('f3a8c2d61b47'); diff --git a/db/migration/V11__approval_snapshot_and_allocation_versions.sql b/db/migration/V11__approval_snapshot_and_allocation_versions.sql new file mode 100644 index 0000000..26788cf --- /dev/null +++ b/db/migration/V11__approval_snapshot_and_allocation_versions.sql @@ -0,0 +1,19 @@ +-- Running upgrade 90653814a421 -> f3a8c2d61b47 + +ALTER TABLE approval_orders ADD COLUMN version_no INT NOT NULL DEFAULT 1 COMMENT '提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)' AFTER refer_no; + +ALTER TABLE approval_orders ADD COLUMN snapshot_json JSON NULL COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)' AFTER version_no; + +CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id); + +ALTER TABLE business_secondary_allocations ADD COLUMN version_no INT NOT NULL DEFAULT 1 COMMENT '方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)' AFTER business_id; + +ALTER TABLE business_secondary_allocations ADD COLUMN is_active TINYINT NOT NULL DEFAULT 1 COMMENT '是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)' AFTER ratio; + +ALTER TABLE business_secondary_allocations DROP INDEX uk_business_secondary_allocations_business_personnel, ADD CONSTRAINT uk_business_secondary_allocations_business_version_personnel UNIQUE (business_id, version_no, personnel_id); + +CREATE INDEX idx_business_secondary_allocations_business_active ON business_secondary_allocations (business_id, is_active); + +ALTER TABLE business_secondary_allocations COMMENT = '业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)'; + +UPDATE alembic_version SET version_num='f3a8c2d61b47' WHERE alembic_version.version_num = '90653814a421'; diff --git a/docs/待确认事项汇总_2026-10-16.md b/docs/待确认事项汇总_2026-10-16.md index c0168c5..7dab348 100644 --- a/docs/待确认事项汇总_2026-10-16.md +++ b/docs/待确认事项汇总_2026-10-16.md @@ -4,7 +4,7 @@ > 来源:《待澄清问题清单_2026-10-15.xlsx》(业务方 24 项)、《T+对接数据需求清单》v1.2.1(IT 18 项)、AI_CONTEXT 待办。 > 答复到齐前,各模块按清单内「建议默认」执行;答复与默认不同时以答复为准。 > **2026-10-19 更新**:业务答复落实批次与 M3 code-review 修复已落地(见各节标注);T+ 清单已升 v1.4。 -> **2026-10-20 更新**:开发计划 3.3 已回填(3.3.1),SRS V2.1 已交付;二次分摊已按开发侧暂定口径实现(#1–#3 答复仍空白)。 +> **2026-10-20 更新**:开发计划 3.3 已回填(3.3.1),SRS V2.1 已交付;二次分摊已按开发侧暂定口径实现(#1–#3 答复仍空白);code-review 遗留 2 项(方案版本承载、审批单内容快照)已随 V11 迁移收口。 ## 一、立即待办(需项目内处理) @@ -88,7 +88,7 @@ | M3 剩余 | ✅ ④ 核算引擎 + 月结/调整单 + ⑦ 月结审批对象已完成(2026-10-17 M3-core);**2026-10-19** 业务答复落实批次(首日 10‰/审批开关默认/试用期参数/增量备份/参数查看放开/经理基数开关)与 code-review 21 项修复已落地;二次分摊已按开发侧暂定口径实现(ccfd206),业务答复回填已完成(计划 3.3.1,2026-10-20);**余**:#1–#3/#20 答复与 #4/#6/#12 补值 | #20(含税口径)、#1–#3 答复后按同粒度推进 | | M4 | ⑤ 报表(签字导出模板)+ ⑧ 工作台 | ④ 已交付;报表口径确认后启动 | | M4(并行) | ⑥ T+ 数据同步 | IT 答复字段映射后 | -| code-review 遗留 2 项 | 方案生效版本承载、审批单内容快照 | ④ 分摊语义落定时一并设计 | +| code-review 遗留 2 项 | ✅ **已完成(2026-10-20,V11)**:方案行 append-only 版本化(is_active/version_no);审批单加版本号与 JSON 内容快照,详情按 snapshot/live/missing 展示 | — | | 杂项收口(低优) | 超长函数收敛:calc_receipt 与 settlement_service(生成/聚合/调整单)已拆(2026-10-19);剩 preview/change_ownership/create_receipt/update_receipt 等;散参数 66 处封装、useDropdownOptions 余两页 | 触碰时顺手 | ## 五、文档待办(低优) diff --git a/docs/待答复催办清单_2026-10-19.md b/docs/待答复催办清单_2026-10-19.md index 03ae4ec..36d9028 100644 --- a/docs/待答复催办清单_2026-10-19.md +++ b/docs/待答复催办清单_2026-10-19.md @@ -33,6 +33,15 @@ | 人员识别 | 「负责营销人员以姓名为唯一识别」 | 改为「以人员编码(工号)为唯一识别、姓名辅助核对」——原表述与「编码为业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误,请确认 | | 回款↔发货批次 | 「由 T+ 收款分配关系指定,人工辅助」 | 改为「**系统人工指定**(对照表 #18 定稿),T+ 收款分配关系仅作辅助」——主次相反疑漏改,请确认 | +### D. 2026-10-20 新增:需业务定口径(2 项) + +> 随「审批单内容快照 + 二次分配方案版本化」收口带出,开发侧已按现行默认实现,答复后按答复调整。 + +| # | 事项 | 当前默认(开发侧已实现) | 需确认 | +| --- | --- | --- | --- | +| 25 | **业务硬删除时,方案版本历史是否保留?** | 随业务一并删除(与既有「终态审批单随业务删除一并清理」规则一致)——业务被删后,方案参与人与比例的版本痕迹也不复存在 | 是否需要保留(如留归档表/只留审批单快照),还是维持一并删除 | +| 26 | **离职的历史参与人重新加入方案,是否允许?** | 按新参与人处理:曾参与过但当前不在生效方案里的人,若已离职,重新加入会被「离职人员禁入」拦截 | 是否应放行(把「历史参与过」视为豁免),还是维持按现任职校验 | + ### 已按默认执行、无需回复的项(供核阅) \#7 月结审批链默认开启、#8 人工确认回款可手动关闭、#9 调整单同链且跨月需授权、#10 风险预警参数化、#11 待办范围(待审批+同步失败+回款未指定批次)、#13 支付环节 5 默认值、#15 一期仅 FULL 模式、#17 参数查看放开/修改限管理员、#18 企业户 2‰ 回溯、#21 退货当期冲减已结算走调整单、#22 预收/保证金/代收款暂不计入、#23 按到账金额、#24 负绩效照算不归零。 diff --git a/docs/项目阶段报告_2026-10-20.md b/docs/项目阶段报告_2026-10-20.md index f64516b..2575ba0 100644 --- a/docs/项目阶段报告_2026-10-20.md +++ b/docs/项目阶段报告_2026-10-20.md @@ -13,7 +13,7 @@ M4 两块(⑤ 报表统计、⑧ 工作台)已完成并通过 code-review ### Plans(计划) -下一步优先解析 T+12.3 数据字典,形成六类单据字段映射草案并自答 IT 清单中可自答部分;其次补齐 code-review 遗留的方案版本承载与审批单快照;需规 V2.2 修订与对照表回填择期进行。 +下一步优先解析 T+12.3 数据字典,形成六类单据字段映射草案并自答 IT 清单中可自答部分;~~其次补齐 code-review 遗留的方案版本承载与审批单快照~~(已于 2026-10-20 完成,V11);需规 V2.2 修订与对照表回填择期进行。 ### Problems(问题) @@ -61,7 +61,7 @@ M4 两块(⑤ 报表统计、⑧ 工作台)已完成并通过 code-review ## 五、下一步计划 1. **T+ 同步前置**:解析 `docs/T_12.3数据字典/`,产出人员/产品/业务/发货/回款/客户六类字段映射草案,自答 IT 18 项中可从数据字典确认的部分。 -2. **code-review 遗留 2 项**:方案生效版本承载、审批单内容快照(orders 加方案 JSON 快照列 + 前端详情展示)。 +2. ~~**code-review 遗留 2 项**:方案生效版本承载、审批单内容快照(orders 加方案 JSON 快照列 + 前端详情展示)。~~ ✅ 已完成(2026-10-20,V11 迁移):方案行版本化 + 审批单版本号/内容快照 + 前端展示。 3. **需规 V2.2**:修订 26 天示例、含运费公式、协助 25% 等过时条款,回填 2026-10-15 补充需求;对照表 v2 收编 24 项答复。 4. **清理类**:5 处超长函数、散参数 DTO、`useDropdownOptions` 余两页、前端菜单图标。 5. **待外部输入**:业务方催办、审批权限清单、IT 18 项答复。 diff --git a/frontend/e2e/secondary_allocation_flow.py b/frontend/e2e/secondary_allocation_flow.py index 0276cdc..1bd6c82 100644 --- a/frontend/e2e/secondary_allocation_flow.py +++ b/frontend/e2e/secondary_allocation_flow.py @@ -5,8 +5,12 @@ 并填比例 → 提交成功 - 详情/编辑回显:再次打开编辑抽屉,二次分配两行仍在(全量替换语义) - 合计提示:两行比例非满额时展示「余额…(归属待业务确认)」 +- 审批内容:审批详情展示**提交时**的送审方案(参与人 + 比例) - 数据清理:删除本次创建的业务(t_ 前缀客户/业务,走界面删除) +审批开关在脚本内显式关闭(结束还原默认 1):既不依赖种子默认值,也让本次 +业务不留在途审批单——业务删除保护在途单,开关开启时删不掉。 + 运行前置(缺一不可): docker compose up -d # MySQL + Redis cd backend && uv run uvicorn app.main:app --port 8000 @@ -14,8 +18,11 @@ cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/secondary_allocation_flow.py """ +import json import os import time +import urllib.error +import urllib.request from playwright.sync_api import Page, sync_playwright @@ -32,9 +39,50 @@ from common import ( ) ADMIN = "admin" +API_BASE = "http://127.0.0.1:8000" SUFFIX = f"{int(time.time())}" console_errors: list[str] = [] +_ALLOCATION_SWITCH = "approval.secondary_allocation.enabled" + + +def _api(method: str, path: str, token: str, body: dict | None = None) -> dict: + """直连后端接口(仅用于开关预置;浏览器交互一律走页面)。""" + request = urllib.request.Request( + f"{API_BASE}{path}", + method=method, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + data=json.dumps(body).encode() if body is not None else None, + ) + try: + with urllib.request.urlopen(request, timeout=20) as response: + payload = json.loads(response.read().decode()) + except urllib.error.HTTPError as exc: + detail = exc.read().decode()[:200] + raise E2EFailure(f"API {method} {path} HTTP {exc.code}:{detail}") from exc + if payload.get("code") != 200: + raise E2EFailure(f"API {method} {path} 业务失败:{payload.get('message')}") + return payload["data"] + + +def _set_allocation_switch(token: str, value: str) -> None: + """显式控制二次分配审批开关(用例不依赖种子默认值;结束还原默认 1)。""" + _api( + "POST", + "/api/v1/system/params/changes", + token, + { + "change_type": 1, + "remark": f"e2e secondary_allocation_flow 设置二次分配审批开关={value}", + "items": [ + {"param_code": _ALLOCATION_SWITCH, "dimension_value": "", "value": value} + ], + }, + ) + def step_navigate_to_business(page: Page, admin_password: str) -> None: login(page, ADMIN, admin_password) @@ -170,6 +218,44 @@ def step_verify_allocation_echo(page: Page) -> None: page.wait_for_timeout(400) +def _current_business_no(page: Page, customer_name: str) -> str: + """取本次创建业务的业务编号(列表首列),用于审批单检索。""" + page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle") + page.wait_for_timeout(600) + page.click("button:has-text('查询')") + page.wait_for_timeout(500) + row = page.locator(".el-table__row", has_text=customer_name).first + if row.count() == 0: + fail("取业务编号", f"未找到本次业务(客户 {customer_name})") + return row.locator("td").first.inner_text().strip() + + +def step_verify_approval_snapshot(page: Page, business_no: str) -> None: + """审批详情:展示提交时的送审方案(参与人 + 比例 + 合计余额)。""" + page.goto(f"{BASE_URL}/system/approvals", wait_until="networkidle") + page.wait_for_timeout(800) + page.locator('.filters:visible input[placeholder="审批单号 / 业务编号"]').fill(business_no) + page.locator('.filters:visible button:has-text("查询")').first.click() + page.wait_for_timeout(800) + rows = page.locator(".el-table__body .el-table__row:visible") + if rows.count() == 0: + fail("审批内容", f"未找到业务 {business_no} 的审批单") + rows.first.locator("button:has-text('详情')").click() + page.wait_for_timeout(1000) + drawer = page.locator(".el-drawer:visible").last + drawer.wait_for(state="visible") + text = drawer.inner_text() + if "审批内容" not in text: + fail("审批内容", "审批详情未展示「审批内容」区块") + for i in (0, 1): + name = f"t_e2e员_{SUFFIX}_{i}" + if name not in text: + fail("审批内容", f"送审内容缺少参与人 {name}") + if "30.00%" not in text or "40.00%" not in text: + fail("审批内容", f"送审内容缺少分配比例:{text[:200]}") + print("✓ 5. 审批内容 审批详情展示送审方案(两名参与人与 30%/40% 比例)") + + def step_cleanup(page: Page) -> None: """删除本次创建的业务与其客户(t_ 前缀,走界面删除)。""" page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle") @@ -184,7 +270,7 @@ def step_cleanup(page: Page) -> None: # 删除确认框按钮文案为「删除」(useConfirmAction confirmButtonText) confirm_message_box(page, "删除") page.wait_for_timeout(600) - print("✓ 5. 数据清理 已删除本次创建的 t_ 业务") + print("✓ 6. 数据清理 已删除本次创建的 t_ 业务") def run(page: Page) -> None: @@ -194,13 +280,25 @@ def run(page: Page) -> None: fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)") step_navigate_to_business(page, admin_password) - customer_name = step_create_customer_via_ui(page) + token = page.evaluate("localStorage.getItem('ys_token')") + if not token: + fail("API 预置", "localStorage 中未取到 ys_token") + # 显式关闭二次分配审批开关(结束还原默认 1):自动通过留痕,既不依赖 + # 种子默认值,也让本次业务不留“在途审批单”——业务删除保护在途单, + # 开关开启时该业务在界面上删不掉 + _set_allocation_switch(token, "0") try: - step_create_personnel(page) - step_create_business_with_allocation(page, customer_name) - step_verify_allocation_echo(page) + customer_name = step_create_customer_via_ui(page) + try: + step_create_personnel(page) + step_create_business_with_allocation(page, customer_name) + business_no = _current_business_no(page, customer_name) + step_verify_allocation_echo(page) + step_verify_approval_snapshot(page, business_no) + finally: + step_cleanup(page) finally: - step_cleanup(page) + _set_allocation_switch(token, "1") if console_errors: print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})") diff --git a/frontend/src/api/approvals.ts b/frontend/src/api/approvals.ts index 4a157be..8123758 100644 --- a/frontend/src/api/approvals.ts +++ b/frontend/src/api/approvals.ts @@ -39,6 +39,65 @@ export function stepNodeLabel(node: number): string { return ({ 0: '撤回', 1: '财务复核', 2: '领导审核' } as const)[node] ?? `节点${node}` } +/** 审批对象类型:1二次分配方案 2月度结算 3调整单 */ +export function orderTypeLabel(orderType: number): string { + return ( + ({ 1: '二次分配方案', 2: '月度结算', 3: '调整单' } as const)[orderType] ?? + `类型${orderType}` + ) +} + +/** 二次分配方案送审内容(snapshot.data 按 order_type=1 解释) */ +export interface ApprovalAllocationData { + business_no: string + business_date: string | null + customer_name: string | null + owner_personnel_name: string | null + assistant_personnel_name: string | null + sale_amount: string | null + cost_amount: string | null + allocations: { personnel_id: number; personnel_name: string; ratio: string }[] + allocation_total: string + allocation_balance: string +} + +/** 月结送审内容(snapshot.data 按 order_type=2 解释) */ +export interface ApprovalSettlementData { + settlement_month: string + total_performance: string | null + total_payable: string | null + person_count: number + items: { + personnel_name: string + role_code: string + calc_type: number + performance_sum: string + payable_sum: string + basic_amount: string | null + salary_mode: string | null + net_amount: string | null + is_probation: number + }[] +} + +/** 调整单送审内容(snapshot.data 按 order_type=3 解释) */ +export interface ApprovalAdjustmentData { + order_no: string + settlement_month: string + personnel_name: string + direction: number + amount: string + reason: string +} + +/** 审批内容信封:snapshot=提交时快照;live=历史单回退当前内容;missing=缺失 */ +export interface ApprovalSnapshot { + schema_version: number + order_type: number + captured_at: string | null + data: Record +} + export interface ApprovalStep { id: number node: number @@ -52,10 +111,12 @@ export interface ApprovalStep { export interface ApprovalOrder { id: number order_no: string - /** 1二次分配方案 2月度结算(预留) */ + /** 1二次分配方案 2月度结算 3调整单 */ order_type: number ref_id: number refer_no: string + /** 同一引用对象的第几次提交(重提产生新版本) */ + version_no: number status: number submitter_id: number | null submitter_name: string @@ -64,6 +125,8 @@ export interface ApprovalOrder { export interface ApprovalOrderDetail extends ApprovalOrder { steps: ApprovalStep[] + snapshot: ApprovalSnapshot | null + content_source: 'snapshot' | 'live' | 'missing' } export interface ApprovalQueryParams { diff --git a/frontend/src/api/businesses.ts b/frontend/src/api/businesses.ts index 89d10df..7eca379 100644 --- a/frontend/src/api/businesses.ts +++ b/frontend/src/api/businesses.ts @@ -58,12 +58,23 @@ export interface SecondaryAllocationRow { ratio: string } -/** 业务详情:附产品明细行与二次分配方案(编辑弹窗一次回显) */ +/** 方案版本行(append-only;编辑替换/驳回清空后旧行仍保留为历史) */ +export interface AllocationVersionRow extends SecondaryAllocationRow { + /** 方案版本号(同一业务从 1 递增) */ + version_no: number + /** 1 当前生效 0 历史版本 */ + is_active: number + create_time: string +} + +/** 业务详情:附产品明细行、当前方案与方案版本历史(编辑弹窗一次回显) */ export interface BusinessDetail extends BusinessItem { items: BusinessItemRow[] secondary_allocations: SecondaryAllocationRow[] /** 方案合计/余额提示(余额归属待澄清 #2,仅展示) */ allocation_hint: string | null + /** 方案全部版本(含历史;被驳回方案亦保留) */ + allocation_history: AllocationVersionRow[] } export interface BusinessQueryParams { diff --git a/frontend/src/views/business/businesses/index.vue b/frontend/src/views/business/businesses/index.vue index ad2eeab..17ac6e7 100644 --- a/frontend/src/views/business/businesses/index.vue +++ b/frontend/src/views/business/businesses/index.vue @@ -1141,6 +1141,43 @@ onMounted(() => { +
+
二次分配方案(含历史版本)
+ + + + + + + + + + + + + + + +
未设置二次分配 —— 全部绩效归负责营销人员
+
+
状态流转
diff --git a/frontend/src/views/system/approvals/index.vue b/frontend/src/views/system/approvals/index.vue index 28d226d..de2b73d 100644 --- a/frontend/src/views/system/approvals/index.vue +++ b/frontend/src/views/system/approvals/index.vue @@ -1,5 +1,5 @@