diff --git a/.gitignore b/.gitignore index bee5538..56c0217 100644 --- a/.gitignore +++ b/.gitignore @@ -45,8 +45,9 @@ htmlcov/ .mypy_cache/ .ruff_cache/ .tox/ -# 端到端测试产出的截图 -frontend/e2e/*.png +# 端到端测试产出的截图(脚本以相对路径 e2e/failure.png 落盘,在不同 CWD +# 下会落到 frontend/e2e/ 或 frontend/e2e/e2e/,用 ** 一并覆盖) +frontend/e2e/**/*.png # 临时文件 *.tmp @@ -70,3 +71,6 @@ docs/**/*.rar docs/T_12.3数据字典/ # macOS 压缩包残留元数据 __MACOSX/ + +# AI 辅助生成文档的临时工作目录(脚本、预览截图),不入版本库 +.tmpdocx/ diff --git a/backend/README.md b/backend/README.md index 09cba71..e647574 100644 --- a/backend/README.md +++ b/backend/README.md @@ -221,6 +221,22 @@ 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`,送审次序失真。 +- **审批单 `version_no` 有唯一键兜底(V12)**:`uk_approval_orders_order_type_ref_version (order_type, ref_id, version_no)`——同一对象的第 N 次送审在库里有唯一一行。ORM 侧约束名必须与迁移 DDL 严格一致,否则 autogenerate 误判。 +- **取版本号必须是「当前读」(`approval_service._next_version`)**,两个禁忌:(a) 不能用普通 `MAX` 查询——请求级鉴权与业务共用同一 DbSession,鉴权的普通 SELECT 已把 REPEATABLE READ 快照锚定在请求开始时刻,父行锁只串行化写入时刻、**不刷新快照**,MAX 会读到旧值;(b) 不能写成 `select(func.max(...)).with_for_update()`——MySQL 官方明确「Locking clauses are not permitted in queries with aggregate functions or GROUP BY」,聚合上的 FOR UPDATE 语义无保证。正解:锁读该 ref 的现有版本行(`select(version_no)...with_for_update()`),最大值 Python 侧算(同 `no_generator` 套路)。 +- **版本号冲突的收敛口径**:`submit_order` 内不 catch、不 rollback(红线)。create 路径由外层编号重试循环整体重来(`business_service._NO_RETRY`);编辑/月结提交路径由审计上下文统一回滚,再把 1062 翻译为 400「提交冲突(版本号生成重复),请重试」(同 `param_service` 版本号冲突模板)。唯一键是最后一道防线,不是取号正确性的前提。 +- **迁移前先修存量**:V11 把存量行 `version_no` 一律置 1,而 V11 前每次重提都新建行——同一 `(order_type, ref_id)` 存在多行 version_no=1,直接加唯一键必报 1062。V12 先重排「组内有重复」的分组(按 id 升序 1..n),其余分组不动(全量重排会改掉前端已展示过的历史版本号)。 +- **新增审批对象类型**须同时提供快照组装函数并接入 `_content_of` 的 live 回退分支,不要在各业务服务内联 JSON 结构。 + ### 工作台口径(M4 ⑧,改动前先读) - **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。 diff --git a/backend/alembic/versions/7f4f7b918bc4_v12_审批单版本号唯一键.py b/backend/alembic/versions/7f4f7b918bc4_v12_审批单版本号唯一键.py new file mode 100644 index 0000000..8c2f920 --- /dev/null +++ b/backend/alembic/versions/7f4f7b918bc4_v12_审批单版本号唯一键.py @@ -0,0 +1,71 @@ +"""v12 审批单版本号唯一键 + +Revision ID: 7f4f7b918bc4 +Revises: f3a8c2d61b47 +Create Date: 2026-10-21 10:15:00.000000 + +给 ``approval_orders`` 加 ``(order_type, ref_id, version_no)`` 唯一键,兜住 +「同一引用对象的第几次送审」在并发下算重。 + +**加键前必须先重排存量**:V11 迁移用 ``ADD COLUMN version_no INT NOT NULL +DEFAULT 1`` 把存量行一律置 1,而 V11 之前的每次重提都是**新建行**(撤回 +只改旧单 status,行不删)。于是任何有 V11 前审批历史的库里,同一 +``(order_type, ref_id)`` 存在多行且 ``version_no`` 全为 1——直接 +``ADD CONSTRAINT`` 必报 1062,迁移会停在半途。 + +重排只针对「组内有重复」的分组(``COUNT(*) > COUNT(DISTINCT version_no)``), +按 id 升序重排为 1..n;其余分组一个字节都不动——全量重排会把前端已展示 +过的历史版本号(``V{{ version_no }}``)改掉,属于用户可见的历史数据篡改。 +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "7f4f7b918bc4" +down_revision: str | Sequence[str] | None = "f3a8c2d61b47" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_UK = "uk_approval_orders_order_type_ref_version" + + +def upgrade() -> None: + """先重排存量重复的版本号,再加唯一键。""" + # 重排:只动「组内有重复版本号」的分组,按 id 升序编 1..n。 + # 用派生表内联而非临时表——同一张临时表被 JOIN 两侧引用会报 1137 + # "Can't reopen table"。 + op.execute( + "UPDATE approval_orders ao " + "JOIN (SELECT id, ROW_NUMBER() OVER (" + " PARTITION BY order_type, ref_id ORDER BY id) AS rn " + " FROM approval_orders " + " WHERE (order_type, ref_id) IN (" + " SELECT order_type, ref_id FROM approval_orders " + " GROUP BY order_type, ref_id " + " HAVING COUNT(*) > COUNT(DISTINCT version_no))) t " + "ON ao.id = t.id " + "SET ao.version_no = t.rn" + ) + # 幂等 guard:MySQL DDL 隐式提交,若加键成功而版本号未落盘, + # 重跑会报 1091(键已存在)。写法沿用 V9 先例。 + op.execute( + "SET @uk_exists := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS " + "WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'approval_orders' " + f"AND CONSTRAINT_NAME = '{_UK}')" + ) + op.execute( + "SET @ddl := IF(@uk_exists = 0, " + f"'ALTER TABLE approval_orders ADD CONSTRAINT {_UK} " + "UNIQUE (order_type, ref_id, version_no)', 'DO 0')" + ) + op.execute("PREPARE stmt FROM @ddl") + op.execute("EXECUTE stmt") + op.execute("DEALLOCATE PREPARE stmt") + + +def downgrade() -> None: + """删唯一键。唯一键本质是索引,DROP INDEX 不校验数据,存量是否已有 + 重复 version_no 与删索引无关;重排结果也不回滚(版本号保持 1..n 连续)。""" + op.execute(f"ALTER TABLE approval_orders DROP INDEX {_UK}") 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..cf68105 100644 --- a/backend/app/models/approval.py +++ b/backend/app/models/approval.py @@ -15,7 +15,16 @@ 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, + UniqueConstraint, + text, +) from sqlalchemy.orm import Mapped, mapped_column from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args @@ -54,7 +63,17 @@ class ApprovalOrder(Base, TimestampMixin): __tablename__ = "approval_orders" __table_args__ = mysql_table_args( "审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)", + # 同一引用对象的第 N 次送审在库里有唯一一行:并发提单算重版本号时 + # 由 DB 兜住(应用层取号走锁读,见 approval_service._next_version) + UniqueConstraint( + "order_type", + "ref_id", + "version_no", + name="uk_approval_orders_order_type_ref_version", + ), 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 +95,17 @@ 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递增;重提产生新版本,旧单快照不覆盖;" + "同一对象内唯一,由uk_approval_orders_order_type_ref_version强制)", + ) + 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..08b60a3 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: @@ -250,15 +296,15 @@ def act( operator_name=operator.username, ) ) - # 驳回=方案未获批准:清空该业务的二次分配行,防止被否决的方案以 - # 「当前生效行」身份留在表里、被 M3 核算快照算进工资;方案事实由 - # 审批单留痕与操作日志 request_data 承载(分摊语义落定后如需保留 - # 历史版本,在 ④ 设计时演进)。审批单的清理由调用方(act 内联) - # 委托 business_repository 完成 —— service 层互调、不触 commit。 + # 驳回=方案未获批准:把该业务的生效方案行置历史(is_active=0, + # **不删行**——V11 起 append-only 版本化),防止被否决的方案以 + # 「当前生效行」身份被 M3 核算快照算进工资;历史方案仍可在业务 + # 详情 allocation_history 回溯。委托 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) + 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,152 @@ 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 递增)。 + + **必须用锁读(当前读),不能用普通 MAX 查询**:普通 SELECT 在 + REPEATABLE READ 下是快照读,读到的是本事务首次一致读的快照;请求级 + 鉴权(deps.get_current_user 与业务共用同一 DbSession)那次 SELECT 已把 + 快照锚定在请求开始时刻,此后即使父行锁等到并发事务提交,MAX 仍停在 + 旧快照上——两个请求会算出同一个 version_no。锁读是当前读,永远读最新 + 已提交数据。 + + **不能写成 select(func.max(...)).with_for_update()**:MySQL 官方明确 + 「Locking clauses are not permitted in queries with aggregate functions + or GROUP BY」,聚合结果上的 FOR UPDATE 语义无保证。改锁「该 ref 的现有 + 版本行」,最大值在 Python 侧算——同 no_generator 的既有套路。只取 + version_no 单列,不把 snapshot_json 拉进内存。 + """ + versions = ( + session.execute( + select(ApprovalOrder.version_no) + .where( + ApprovalOrder.order_type == order_type, + ApprovalOrder.ref_id == ref_id, + ) + .with_for_update() + ) + .scalars() + .all() + ) + return (max(versions) if versions else 0) + 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 +568,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..a13b2fa 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,20 @@ 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 +459,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 +534,51 @@ 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, + ): + try: + approval_service.submit_order( + session, + order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION, + ref_id=business_id, + refer_no=business.business_no, + submitter=operator, + snapshot=snapshot, + ) + except IntegrityError as exc: + # version_no 唯一键的并发冲突(取号是锁读后此项理论不可达, + # 留作 DB 兜底):审计上下文会统一回滚,这里只把 1062 + # 翻译成可重试的业务提示,其余完整性错误原样上抛 + if _is_duplicate_entry(exc): + raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc + raise else: approval_service.withdraw_pending_orders( session, @@ -536,7 +586,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..8a531ed 100644 --- a/backend/app/services/settlement_service.py +++ b/backend/app/services/settlement_service.py @@ -13,11 +13,13 @@ from datetime import date from decimal import ROUND_HALF_UP, Decimal from sqlalchemy import select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core.constants import ( MODULE_SETTLEMENT, ) +from app.core.db_errors import is_duplicate_entry from app.core.exceptions import BadRequestError, NotFoundError from app.models import ( MonthlySettlement, @@ -248,19 +250,30 @@ 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): raise BadRequestError("仅草稿或已驳回状态可提交") - order = approval_service.submit_order( - session, - order_type=_SETTLEMENT_ORDER_TYPE, - ref_id=settlement.id, - refer_no=f"JS{settlement.settlement_month.replace('-', '')}", - submitter=operator, - ) + try: + order = approval_service.submit_order( + session, + order_type=_SETTLEMENT_ORDER_TYPE, + ref_id=settlement.id, + refer_no=f"JS{settlement.settlement_month.replace('-', '')}", + submitter=operator, + # 月结明细在生成时已固化;提交时快照让复核者看到的是送审版本, + # 而不是审批期间若发生重生成后的内容 + snapshot=approval_service.settlement_content(session, settlement.id), + ) + except IntegrityError as exc: + # version_no 唯一键的并发冲突(提交前已锁月结行,此项理论不可达, + # 留作 DB 兜底):审计上下文会统一回滚,这里只把 1062 翻译成 + # 可重试的业务提示,其余完整性错误原样上抛 + if is_duplicate_entry(exc): + raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc + raise settlement.approval_order_no = order.order_no if order.status == SETTLEMENT_STATUS_APPROVED: mark_locked(session, settlement, operator=operator) @@ -573,6 +586,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..7b96360 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -159,6 +159,25 @@ 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 | + +### 审批单版本号并发防线(2026-10-21 完成:V12 唯一键 + 锁读取号) + +| 文件 | 守什么 | 用例数 | +| --- | --- | --- | +| `tests/integration/test_approval_version.py`(新) | **取号读最新已提交值**(两会话法:s1 锚定快照 + s2 提交 version_no=2 → s1 必须取到 3,守住「快照读 vs 当前读」根因);**取号查询带 FOR UPDATE 且不含聚合**(事件钩子抓真实 SQL——MySQL 官方不允许聚合函数携带锁子句);**唯一键生效**(重复 (order_type, ref_id, version_no) 直插撞 1062);**编辑路径冲突收敛为 400** 且业务数据不写坏(monkeypatch `_next_version` 恒返回 1);**正常路径版本号仍递增**(1→2→3) | +5 | +| `tests/unit/test_models.py`(扩) | 审批单唯一键覆盖 (order_type, ref_id, version_no) 三列且约束名与迁移 DDL 一致 | +1 | + +运行(2026-10-22,容器恢复后):`tests/integration/test_approval_version.py` + `test_dashboard.py` → **16 passed**;全量 `uv run pytest -q` → **897 passed(12:02)**;`uv run ruff check .` 绿、前端 `pnpm type-check` 绿。首次全量跑出两处**测试代码**适配(非产品缺陷):① 工作台 `_insert_approval` 用同一 `(order_type=2, ref_id=0)` 连插两张单,撞 V12 新唯一键 → 每张单改给不同 `ref_id`;② 新用例跨模块引用 `switched_on` 夹具(pytest 夹具只在定义模块与 conftest 可见,报 fixture not found)→ 用 `_set_switch` 本地重建。 + ## 三、运行记录 | 日期 | 范围 | 用例数 | 结果 | 备注 | @@ -184,6 +203,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 +218,15 @@ 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-21 | 审批单 `version_no` 无唯一约束 | 应用层取号即使正确,并发下 DB 层无兜底;且唯一键是「一个对象至多一张在途单」红线之外版本语义的最后防线 | V12 迁移加 `uk_approval_orders_order_type_ref_version (order_type, ref_id, version_no)`;加键前先重排存量重复分组(V11 把存量一律置 1 而 V11 前重提是新建行,直接加键必报 1062) | +| 2026-10-21 | `_next_version` 是快照读 | 请求级鉴权与业务共用同一 DbSession,鉴权的普通 SELECT 已锚定 REPEATABLE READ 快照——父行锁只串行化写入时刻、**不刷新快照**,并发提单的 MAX 停在请求开始时刻 → 版本号重复 | 取号改锁读(`select(version_no)...with_for_update()`,当前读),最大值 Python 侧算;**不能用聚合 + FOR UPDATE**——MySQL 官方不允许聚合函数携带锁子句 | +| 2026-10-21 | `act()` 驳回清空方案的注释与行为相反 | 注释写「清空该业务的二次分配行…如需保留历史版本,在 ④ 设计时演进」,而 V11 已实现 append-only(只置 `is_active=0` 行不删)——误导后来者按旧语义改代码 | 注释改为「置历史不删行,历史方案可回溯」 | | 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_approval_version.py b/backend/tests/integration/test_approval_version.py new file mode 100644 index 0000000..b5281d4 --- /dev/null +++ b/backend/tests/integration/test_approval_version.py @@ -0,0 +1,183 @@ +"""审批单版本号并发防线(V12 唯一键 + 锁读取号)。 + +守的是: +- **取号必须读最新已提交值**:请求级鉴权与业务共用同一 DbSession,鉴权 + 的普通 SELECT 已把 REPEATABLE READ 快照锚定在请求开始时刻;父行锁只 + 串行化写入时刻、不刷新快照——取号若是快照读,并发提单会算出同一个 + version_no(两会话法做行为级回归锁,不依赖真多线程) +- **取号查询带 FOR UPDATE 且不用聚合**:MySQL 官方不允许聚合函数携带 + 锁子句(Locking clauses are not permitted in queries with aggregate + functions or GROUP BY),聚合上的 FOR UPDATE 语义无保证 +- **DB 层唯一键生效**:即使应用层取号缺陷被绕过,重复 + (order_type, ref_id, version_no) 也必须在库上被拒(1062) +- **冲突收敛为 400**:编辑路径撞唯一键 → 审计上下文回滚 → 业务提示 + 可重试,业务数据不得写坏 +- **正常路径版本号仍递增**:锁读改写不破坏既有 1→2→3 行为 +""" + +import uuid + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import event, text + +from app.core.db import get_engine, get_session_factory +from app.core.db_errors import is_duplicate_entry +from app.models import ApprovalOrder +from tests.integration.factories import business_payload +from tests.integration.test_approval_flow import ( + _BUSINESSES, + _create_with_plan, + _latest_order, + _person, + _set_switch, +) + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def switched_on(client: TestClient, admin: dict) -> None: + """把二次分配审批开关置 1(用例结束由 conftest 恢复种子基线)。 + + pytest 夹具只在定义它的模块与 conftest 中可见,跨模块直接引用会报 + ``fixture 'switched_on' not found``;这里用 ``_set_switch`` 本地重建。 + """ + _set_switch(client, admin["headers"], "1") + + +class TestNextVersionLockRead: + """取号查询的锁读口径(真实 SQL 形态 + 行为级快照回归)。""" + + def test_取号查询带FOR_UPDATE_不含聚合(self, client: TestClient, admin: dict) -> None: + """抓 _next_version 真实发出的 SQL(比本地重造语句再编译更贴真): + 必须含 FOR UPDATE、不含 MAX( —— 聚合上的锁子句 MySQL 不保证语义。""" + + captured: list[str] = [] + + def _spy(_conn, _cursor, statement, *_rest): + if "approval_orders" in statement and "version_no" in statement: + captured.append(statement) + + engine = get_engine() + event.listen(engine, "before_cursor_execute", _spy) + try: + p = _person(client, admin["headers"]) + _create_with_plan(client, admin["headers"], p["id"]) # 触发一次真实提单 + finally: + event.remove(engine, "before_cursor_execute", _spy) + + assert captured, "未捕获到取号查询" + assert any("FOR UPDATE" in sql.upper() for sql in captured), captured + assert not any("MAX(" in sql.upper() for sql in captured), captured + + def test_版本号读最新已提交值_不受事务旧快照影响(self) -> None: + """两会话法(无需线程):s1 先锚定快照 → s2 提交新版本 → + s1 取号必须读到 3 而非旧快照里的 2——守住「快照读 vs 当前读」根因。""" + from app.services import approval_service + + factory = get_session_factory() + with factory() as s1: + # 锚定快照(模拟请求级鉴权读先于业务写发生) + s1.execute(text("SELECT order_no FROM approval_orders LIMIT 1")).all() + + with factory() as s2: + order = ApprovalOrder( + order_no=f"AP-{uuid.uuid4().hex[:12]}", + order_type=1, + ref_id=987654321, + refer_no="YW-SNAPSHOT-TEST", + version_no=2, + status=5, + submitter_name="t_snapshot_probe", + ) + s2.add(order) + s2.commit() + + # s1 的普通读仍停在旧快照(该分组在快照里不存在) + stale = s1.execute( + text( + "SELECT COALESCE(MAX(version_no),0) FROM approval_orders " + "WHERE order_type=1 AND ref_id=987654321" + ) + ).scalar_one() + assert stale == 0, "前置失效:快照读应停在锚定时刻,先修隔离级别再跑本用例" + + assert approval_service._next_version(s1, 1, 987654321) == 3, ( + "取号必须读到最新已提交值(2+1=3),读到 2 说明退化为快照读" + ) + s1.rollback() + + def test_唯一键生效_重复版本号被拒(self) -> None: + """重复 (order_type, ref_id, version_no) 直插必须撞 1062—— + 应用层取号即使被绕过,DB 也是最后一道防线。""" + from sqlalchemy.exc import IntegrityError + + factory = get_session_factory() + with factory() as s: + common = { + "order_type": 1, + "ref_id": 987654322, + "refer_no": "YW-UK-TEST", + "status": 5, + "submitter_name": "t_uk_probe", + } + s.add(ApprovalOrder(order_no=f"AP-{uuid.uuid4().hex[:12]}", version_no=1, **common)) + s.flush() + s.add(ApprovalOrder(order_no=f"AP-{uuid.uuid4().hex[:12]}", version_no=1, **common)) + with pytest.raises(IntegrityError) as exc_info: + s.flush() + assert is_duplicate_entry(exc_info.value), "应为唯一键冲突(1062)而非其他完整性错误" + s.rollback() + + def test_正常路径版本号仍递增(self, client: TestClient, admin: dict, switched_on) -> None: + """常规编辑两次:送审版本号 1→2→3——锁读改写不得破坏既有行为。""" + p = _person(client, admin["headers"]) + data = _create_with_plan(client, admin["headers"], p["id"]) + + first = _latest_order(client, admin["headers"], data["business_no"]) + assert first["version_no"] == 1 + + for ratio in ("60.00", "70.00"): + 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": ratio}], + ) + resp = client.put(f"{_BUSINESSES}/{data['id']}", json=payload, headers=admin["headers"]) + assert resp.status_code == 200, resp.text + + third = _latest_order(client, admin["headers"], data["business_no"]) + assert third["version_no"] == 3 + + +class TestConflictConvergence: + """编辑路径撞版本号唯一键 → 收敛为可重试的 400,业务数据不写坏。""" + + def test_编辑路径版本号冲突_收敛为400( + self, client: TestClient, admin: dict, switched_on, monkeypatch + ) -> None: + from app.services import approval_service + + p = _person(client, admin["headers"]) + data = _create_with_plan(client, admin["headers"], p["id"]) + before = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"] + + # 模拟取号读到过期快照:恒返回 1(库中 V1 已存在)→ 撞唯一键 + monkeypatch.setattr(approval_service, "_next_version", lambda *a, **k: 1) + + payload = business_payload( + before["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 == 400, resp.text + assert "版本号生成重复" in resp.json()["message"] + + monkeypatch.undo() + # 业务数据不得写坏:整个事务回滚,方案仍是 50.00(V1 生效行) + after = client.get(f"{_BUSINESSES}/{data['id']}", headers=admin["headers"]).json()["data"] + assert [a["ratio"] for a in after["secondary_allocations"]] == ["50.00"] + assert [h["version_no"] for h in after["allocation_history"]] == [1] diff --git a/backend/tests/integration/test_dashboard.py b/backend/tests/integration/test_dashboard.py index 67e6d47..eb7b509 100644 --- a/backend/tests/integration/test_dashboard.py +++ b/backend/tests/integration/test_dashboard.py @@ -250,7 +250,13 @@ def leader(make_user, auth_headers) -> dict: def _insert_approval(status: int) -> None: - """直插在途审批单(避免依赖两次流转,专注工作台过滤口径)。""" + """直插在途审批单(避免依赖两次流转,专注工作台过滤口径)。 + + ``ref_id`` 仅作对象分组锚点、本用例不校验:**每张单必须给不同的 + ref_id**——V12 的 ``uk_approval_orders_order_type_ref_version`` 要求 + (order_type, ref_id, version_no) 唯一,同一假对象连插两张 version_no=1 + 会撞 1062。 + """ from app.core.db import get_session_factory from app.models import ApprovalOrder @@ -260,7 +266,7 @@ def _insert_approval(status: int) -> None: ApprovalOrder( order_no=f"AP{uuid.uuid4().hex[:12]}", order_type=2, - ref_id=0, + ref_id=status, refer_no="t_工作台", status=status, submitter_name="t_admin", 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/backend/tests/unit/test_models.py b/backend/tests/unit/test_models.py index 51f06d9..056e06f 100644 --- a/backend/tests/unit/test_models.py +++ b/backend/tests/unit/test_models.py @@ -96,6 +96,20 @@ class TestNamingConvention: for fk in table.foreign_keys: assert fk.constraint.name.startswith(f"fk_{table.name}_"), fk.constraint.name + def test_审批单版本号唯一键覆盖三列(self) -> None: + """并发提单算重版本号时由 DB 兜底(应用层取号走锁读,见 + approval_service._next_version);约束名必须与迁移 DDL 严格一致, + 否则 alembic autogenerate 会误判成「约束缺失/多余」。""" + table = Base.metadata.tables["approval_orders"] + uks = [c for c in table.constraints if c.__class__.__name__ == "UniqueConstraint"] + hit = [ + c + for c in uks + if {col.name for col in c.columns} == {"order_type", "ref_id", "version_no"} + ] + assert hit, "审批单缺 (order_type, ref_id, version_no) 唯一键" + assert hit[0].name == "uk_approval_orders_order_type_ref_version" + class TestComments: """规范红线要求中文 COMMENT。""" diff --git a/db/init/init-dev.sql b/db/init/init-dev.sql index d6ddf1e..40dde34 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 '提交人用户名(冗余固化)', @@ -720,11 +722,14 @@ CREATE TABLE approval_orders ( update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT pk_approval_orders PRIMARY KEY (id), CONSTRAINT uk_approval_orders_order_no UNIQUE (order_no), + CONSTRAINT uk_approval_orders_order_type_ref_version UNIQUE (order_type, ref_id, version_no), CONSTRAINT fk_approval_orders_submitter_id FOREIGN KEY(submitter_id) REFERENCES users (id) ON DELETE SET NULL )CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)' COLLATE utf8mb4_general_ci; 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 +749,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 +891,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 ('7f4f7b918bc4'); 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/db/migration/V12__approval_order_version_unique.sql b/db/migration/V12__approval_order_version_unique.sql new file mode 100644 index 0000000..a745fa9 --- /dev/null +++ b/db/migration/V12__approval_order_version_unique.sql @@ -0,0 +1,16 @@ +-- Running upgrade f3a8c2d61b47 -> 7f4f7b918bc4 + +UPDATE approval_orders ao JOIN (SELECT id, ROW_NUMBER() OVER ( PARTITION BY order_type, ref_id ORDER BY id) AS rn FROM approval_orders WHERE (order_type, ref_id) IN ( SELECT order_type, ref_id FROM approval_orders GROUP BY order_type, ref_id HAVING COUNT(*) > COUNT(DISTINCT version_no))) t ON ao.id = t.id SET ao.version_no = t.rn; + +SET @uk_exists := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'approval_orders' AND CONSTRAINT_NAME = 'uk_approval_orders_order_type_ref_version'); + +SET @ddl := IF(@uk_exists = 0, 'ALTER TABLE approval_orders ADD CONSTRAINT uk_approval_orders_order_type_ref_version UNIQUE (order_type, ref_id, version_no)', 'DO 0'); + +PREPARE stmt FROM @ddl; + +EXECUTE stmt; + +DEALLOCATE PREPARE stmt; + +UPDATE alembic_version SET version_num='7f4f7b918bc4' WHERE alembic_version.version_num = 'f3a8c2d61b47'; + diff --git a/docs/T+对接数据需求清单_2026-10-15.xlsx b/docs/T+对接数据需求清单_2026-10-15.xlsx deleted file mode 100644 index 52fafbf..0000000 Binary files a/docs/T+对接数据需求清单_2026-10-15.xlsx and /dev/null differ diff --git a/docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx b/docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx deleted file mode 100644 index f3337d8..0000000 Binary files a/docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx and /dev/null differ diff --git a/docs/T+对接数据需求清单_v1.5_2026-10-20.xlsx b/docs/T+对接数据需求清单_v1.5_2026-10-20.xlsx deleted file mode 100644 index dddc231..0000000 Binary files a/docs/T+对接数据需求清单_v1.5_2026-10-20.xlsx and /dev/null differ diff --git a/docs/T+对接数据需求清单_v1.6_2026-10-20.xlsx b/docs/T+对接数据需求清单_v1.6_2026-10-20.xlsx new file mode 100644 index 0000000..d4419fe Binary files /dev/null and b/docs/T+对接数据需求清单_v1.6_2026-10-20.xlsx differ diff --git a/docs/T+对接数据需求清单_v1.7_2026-10-20.xlsx b/docs/T+对接数据需求清单_v1.7_2026-10-20.xlsx new file mode 100644 index 0000000..106ed5c Binary files /dev/null and b/docs/T+对接数据需求清单_v1.7_2026-10-20.xlsx differ diff --git a/docs/待澄清问题清单_2026-10-15(1)(2).xlsx b/docs/待澄清问题清单_2026-10-15(1)(2).xlsx deleted file mode 100644 index dd2e2c8..0000000 Binary files a/docs/待澄清问题清单_2026-10-15(1)(2).xlsx and /dev/null differ diff --git a/docs/待澄清问题清单_2026-10-15.xlsx b/docs/待澄清问题清单_2026-10-15.xlsx deleted file mode 100644 index c2215b5..0000000 Binary files a/docs/待澄清问题清单_2026-10-15.xlsx and /dev/null differ diff --git a/docs/待确认事项排序表_2026-10-16.xlsx b/docs/待确认事项排序表_2026-10-16.xlsx deleted file mode 100644 index 1595f8a..0000000 Binary files a/docs/待确认事项排序表_2026-10-16.xlsx and /dev/null differ diff --git a/docs/待确认事项排序表_2026-10-16_new.xlsx b/docs/待确认事项排序表_2026-10-16_new.xlsx deleted file mode 100644 index 327d07b..0000000 Binary files a/docs/待确认事项排序表_2026-10-16_new.xlsx and /dev/null differ 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/部署硬件需求.txt b/docs/部署硬件需求.txt new file mode 100644 index 0000000..b28e5fb --- /dev/null +++ b/docs/部署硬件需求.txt @@ -0,0 +1,241 @@ +衍水集团营销人员工资管理系统 +部署硬件需求说明 +================================================================ +版本:v1.0 +日期:2026-10-21 +适用:内部系统部署(数十人并发,无公网访问) +依据:仓库实际运行架构(docker/docker-compose.dev.yml、backend/pyproject.toml、 + backend/app/core/db.py、docs/备份恢复手册.md、ai-aux/AI_CONTEXT.md) + + +================================================================ +一、结论摘要(先看这一节) +================================================================ + +推荐配置:2 核 CPU / 8 GB 内存 / 100 GB SSD(Linux 单机) +最低配置:2 核 CPU / 4 GB 内存 / 100 GB SSD +演示配置:1 核 CPU / 2 GB 内存 / 40 GB SSD(仅限试用,不适合正式使用) + +一句话:内部系统的瓶颈不在 CPU,而在「内存余量」和「磁盘容量」。 + 内存从 4 GB 升到 8 GB 的成本极低,却能一次性消除以后所有内存不足的排查; + 磁盘不要按当前数据量买,要按「备份 + 恢复演练」的双倍空间买。 + + +================================================================ +二、负载前提(为什么是这个配置) +================================================================ + +以下事实全部来自本仓库,不是通用模板: + +1) 内部系统、数十人并发 + 出处:ai-aux/AI_CONTEXT.md:101「并发极低(内部系统数十人)」 + 含义:CPU 核心数不是瓶颈,2 核足以支撑全部并发请求。 + +2) 全栈同步架构 + 单 worker,重活走线程池 + 出处:CLAUDE.md(分层约定)、backend/app/core/db.py:29 + 含义:一个请求占用一个线程;绩效批量核算、Excel 导出会短暂吃满一个核, + 因此要留出至少 1 个空闲核给后端,避免请求排队。 + +3) MySQL 8.0 + Redis 7 双容器,Redis 开启 AOF + 出处:docker/docker-compose.dev.yml:11、:44 + 含义:MySQL 是内存消耗大户(默认配置下性能很差,必须调参); + Redis 只存验证码、Token 黑名单、登录失败计数,用量极小(<50 MB)。 + +4) 备份策略:每日全量 gz + binlog 增量,各自保留 30 天 + 出处:docs/备份恢复手册.md:11 + 含义:磁盘需按「业务数据 + 索引 + 30 天 binlog + 30 份全量备份」计算。 + +5) 恢复演练需恢复到独立临时库 payroll_drill_* + 出处:docs/备份恢复手册.md:45 + 含义:演练瞬间需要约「2 倍数据库体积」的可用空间。 + 这是最容易被低估的一项,磁盘卡着下限买会在第一次演练时爆盘。 + +6) 报表导出用 openpyxl 全量载入内存 + 出处:backend/app/services/report_export.py:18 + 含义:导出大报表时后端进程内存峰值会翻倍,必须在内存预算里留余量。 + + +================================================================ +三、正式生产推荐配置(内部系统,数十人) +================================================================ + +项目 规格 说明 +---------------------------------------------------------------- +CPU 2 核(推荐 4 核) 2 核给容器与 MySQL, + 1 核给 uvicorn(workers=2 上限), + 1 核余量给系统与备份任务。 + +内存 8 GB(最低 4 GB) 8 GB 才有余量承受 + 「导出 + 核算 + 备份」同时发生。 + +磁盘 100 GB SSD(推荐 200 GB) 必须是 SSD。HDD 上 MySQL 的 + ON UPDATE CURRENT_TIMESTAMP + 高频写入会明显拖慢。 + +网络 内网千兆 无需公网 IP、域名、SSL 证书、CDN。 + +操作系统 Linux 推荐 Ubuntu 22.04 LTS / Rocky 9。 + (Ubuntu 22.04 / Rocky 9) 备份、恢复、巡检脚本全部是 .sh + (scripts/ 目录),Linux 为原生路径。 + 若必须用 Windows Server,需 2022 + + WSL2 或 Git Bash 运行脚本。 + + +================================================================ +四、内存分配明细(8 GB 的具体切法) +================================================================ + +组件 建议占用 关键设置与说明 +---------------------------------------------------------------- +MySQL 8.0 3.5 GB innodb_buffer_pool_size = 2G + ★ 默认仅 128 MB,不调会大量走磁盘, + MySQL 会成为整机最慢的一环。 + +后端 uvicorn 1.5 GB workers = 2 + 报表导出时单 worker 峰值可达 600 MB ~ 1 GB + (openpyxl 全量载入内存)。 + +Redis 7 256 MB maxmemory 256mb + 实际用量 < 50 MB,设置上限只为兜底防失控。 + +Nginx + 系统 1 GB 静态资源(前端 dist)+ 反向代理 /api。 + +余量 1.75 GB mysqldump 备份进程、binlog 压缩、系统缓存。 + +---------------------------------------------------------------- +4 GB 内存版(最低配置)的分配: + MySQL 1.5~2 GB(innodb_buffer_pool_size = 1G) + 后端 0.8~1 GB + Redis 128 MB(maxmemory 128mb) + Docker + Nginx + 系统 0.6 GB + 余量 ~0.6 GB + ★ 这是「刚好够用」,一旦「导出报表」与「每日备份」撞在同一时刻, + OOM Killer 会优先杀 MySQL。这就是推荐 8 GB 的原因。 + + +================================================================ +五、磁盘空间算式(100 GB 的来历) +================================================================ + +项目 估算占用 +---------------------------------------------------------------- +业务数据(数十人,5 年) ≈ 1 GB +索引 + binlog 30 天(ROW 格式) ≈ 5 GB +全量备份 30 份(gz,约数据量 20%) ≈ 6 GB +恢复演练临时库(峰值 = 2 倍数据量) ≈ 2 GB +系统 + Docker 镜像 + 应用日志 ≈ 25 GB +---------------------------------------------------------------- +合计 ≈ 39 GB + +结论:100 GB 可支撑 2~3 年不需扩容。 + 按 docs/备份恢复手册.md:87 的 80% 磁盘水位告警,100 GB 有充足缓冲。 + + +================================================================ +六、内部部署必须调整的四项(现有配置存在隐患) +================================================================ + +1) 数据库端口必须收敛,不能监听所有网卡 + 现状:docker/docker-compose.dev.yml:27 为 "3306:3306", + :48 为 "6379:6379",等价于绑定 0.0.0.0。 + 风险:内网任何一台办公电脑都能直连 MySQL 试 root 密码。 + 改法(二选一): + A. 只允许宿主机访问 + ports: + - "127.0.0.1:3306:3306" + - "127.0.0.1:6379:6379" + B. 后端与数据库在同一 compose 网络内时,直接删掉这两行 ports, + 容器之间通过服务名互访,宿主机只暴露 80/443。 + +2) 前端必须构建成静态文件 + Nginx 反向代理 + 禁止把 vite dev 服务器当正式环境用: + 开发服务器会暴露全部源码,且不提供任何访问控制。 + 正式做法:pnpm build 产出 frontend/dist,由 Nginx 托管, + /api 反向代理到后端 8000 端口。 + +3) 备份必须异地留一份 + 内部系统最常见的「数据全丢」场景就是单机故障。 + 在 scripts/backup_full.sh 之后追加一步 rsync 到 NAS 或公司文件服务器。 + +4) 提前预留反向代理层 + 不接公网就不需要 WAF / CDN / SSL。 + 但如果公司后续有统一域名单点登录(SSO)规划, + Nginx 反代层要提前留好,避免到时大改架构。 + + +================================================================ +七、三档速查表 +================================================================ + +场景 CPU 内存 磁盘 备注 +-------------------------------------------------------------------------------- +试用 / 演示(< 5 人) 1 核 2 GB 40 GB SSD 仅跑小数据量; + 导出大报表会 OOM + +正式内部(数十人) 2 核 4 GB(建议 8 GB)100 GB SSD 推荐方案 +【推荐】 + +内部 + 冗余 4 核 ×2 8 GB ×2 200 GB SSD ×2 含从库与监控; +(从库 + 监控) 工资系统若需 + 审计级可靠性 + 值得投入 +-------------------------------------------------------------------------------- + + +================================================================ +八、可选加速项(非必须) +================================================================ + +1) 图表库已本地化 + ECharts 静态文件已放在 docs/design/echarts.min.js, + 前端自托管即可,不依赖外网 CDN,符合「内网加载不可靠」的既定决策。 + +2) 日志盘分离 + 若开启审计日志长期留存,把 backend/logs/ 挂载到独立磁盘, + 避免日志写满系统盘导致 MySQL 无法写入。 + +3) 备份异地化 + 本机备份 + 定时 rsync 到 NAS 或对象存储,构成「本地 + 异地」两份。 + + +================================================================ +九、生产环境仍需完成的事项(本文件之外) +================================================================ + +1) 生成 docker/docker-compose.prod.yml: + - 端口收敛到 127.0.0.1 + - MySQL innodb_buffer_pool_size 等参数调优 + - 容器 mem_limit / cpus 资源限制 + - Nginx 反向代理静态前端 + /api + +2) 后端启动参数由开发模式改为生产模式: + - 去掉 --reload(生产环境必须去掉) + - 设置 workers=2 + +3) backend/.env 中 APP_SECRET_KEY 必须换成强随机值, + MYSQL_PASSWORD / REDIS_PASSWORD 两处(docker/.env 与 backend/.env)保持一致。 + +4) 首次部署执行 alembic upgrade head,并创建管理员账号: + uv run python -m app.cli create-admin --username admin + +5) 配置每日定时任务:全量备份 + 增量归档(见 docs/备份恢复手册.md) + + +================================================================ +十、附:数据来源索引 +================================================================ + +ai-aux/AI_CONTEXT.md:101 并发规模判断(数十人) +docker/docker-compose.dev.yml:11 MySQL 8.0 容器 +docker/docker-compose.dev.yml:27 MySQL 端口映射(需收敛) +docker/docker-compose.dev.yml:44 Redis 7 容器与 AOF +docker/docker-compose.dev.yml:48 Redis 端口映射(需收敛) +backend/app/core/db.py:29 同步引擎与连接池配置 +backend/app/services/report_export.py:18 报表导出(内存峰值来源) +docs/备份恢复手册.md:11 备份策略 +docs/备份恢复手册.md:45 恢复演练临时库 +docs/备份恢复手册.md:87 磁盘水位 80% 告警阈值 +scripts/ 备份 / 恢复 / 巡检脚本(.sh) + +================================================================ +(文件结束) diff --git a/docs/部署硬件需求说明.docx b/docs/部署硬件需求说明.docx new file mode 100644 index 0000000..2faf0de Binary files /dev/null and b/docs/部署硬件需求说明.docx differ diff --git a/docs/项目阶段报告_2026-10-13.md b/docs/项目阶段报告_2026-10-13.md deleted file mode 100644 index 16f459b..0000000 --- a/docs/项目阶段报告_2026-10-13.md +++ /dev/null @@ -1,154 +0,0 @@ -# 衍水集团营销人员工资管理系统项目阶段报告 - -- **报告日期**:2026-10-13 -- **报告范围**:需求澄清、技术底座、系统管理、M1 参数体系、M2 当前进展与后续计划 -- **项目分支**:`feat/base-business-data` -- **阶段结论**:项目已从“需求与底座建设”进入 **M2 基础数据管理开发期**;M1 已形成可验收闭环,M2 数据模型与迁移初稿已落地,但接口、页面与模块级测试尚未完成。 - -## 一、3P 阶段摘要 - -### Progress(进展) - -已完成需求裁决、开发环境、数据库基础设计、登录鉴权、系统管理、前端框架与 M1 系统设置参数体系。M1 覆盖岗位角色、公式模板、16 项参数种子、时点化取值、版本留痕、参数预览与 Excel 导出;最近一次全量回归为后端 406 个用例与 28 项端到端检查通过。当前 M2 已完成人员、区域、计划、产品、客户、归属留痕六类表的模型与 V4 迁移初稿。 - -### Plans(计划) - -短期优先完成 M2:在既有六表基础上补齐仓储、服务、接口、前端页面、审计与模块测试,并完成双轨迁移一致性验证。随后进入业务数据管理,为绩效核算提供业务单据与回款数据输入。报表与 T+ 同步继续后置,待业务数据模型稳定和 IT 字段映射确认后实施。 - -### Problems(问题) - -当前无业务需求阻塞,但 M2 代码尚未提交,需防止结构设计与后续接口实现长期分叉。T+ 字段映射、幂等唯一键与失败重试策略仍待 IT 确认;经理个人户业绩进入 12.5%+5% 的衔接口径需在核算模块开工前确认。原需求文档存在已裁决作废条款,对外引用前需修订或明确以对照表 v2 为准。 - -## 二、阶段定位与完成度 - -| 维度 | 当前状态 | 说明 | -| --- | --- | --- | -| 项目阶段 | 开发期 | M1 已交付,M2 进行中 | -| 整体完成度 | 14% | 管理口径;M2 尚未达到可交付节点,仅按结构设计进展小幅上调 | -| 交付主线 | M2 基础数据管理 | 六表结构与 T+ 预留已形成初稿 | -| 质量状态 | M1 回归通过 | 后端 406 用例、e2e 登录 8 项 / 系统管理 13 项 / 参数 7 项通过 | -| 主要风险 | 外部对接与口径追溯 | T+ 映射未定;需规含作废条款 | - -> 说明:整体完成度用于管理沟通,不等同于代码行占比。M2 仅完成数据结构层,接口、页面和模块级测试未完成,因此不能标记为模块交付。 - -## 三、已完成成果 - -### 1. 需求与执行口径 - -- 业务方已回执《问题与解决方案对照表_v2.xlsx》,28 项争议全部落定。 -- 2026-10-12 后,需求以对照表 v2 与《2026.9.8修改方案.docx》的裁决口径为准。 -- 关键口径已固化:回款天数自然日累计且发货日计第 1 天;毛利润率不含运费;业务员计提按 1~360 天线性递减、361 天及以上固定 4‰;企业户 3‰;信息员按销售额 1‰;参数按回款到账日时点取值。 -- 支付环节的起付比例、支付权重、预付款、以物抵顶均按可配置参数预留。 - -### 2. 技术与运行底座 - -- 后端六层结构建立:`api → services → repositories → models`,`schemas` 承载请求响应,`core` 处理横切能力。 -- 统一响应、全局异常、结构化日志、审计脱敏、配置安全校验已落地。 -- 本地环境基于 Docker Compose 编排 MySQL 8.0 与 Redis 7,使用命名卷持久化、业务账号最小权限、utf8mb4 字符集。 -- 迁移采用 Alembic 生成并导出 `db/migration/V{n}__*.sql` 的双轨方式,兼顾开发迭代与生产脚本留存。 - -### 3. 登录鉴权与系统管理 - -- 登录链路包含图形验证码、JWT、登录失败限流、Token 黑名单与登录日志。 -- 系统管理包含用户、角色、操作日志与日志查询,前后端闭环。 -- 审计机制记录成功与失败操作,敏感字段按键名结构化脱敏,日志列表与详情分段加载。 - -### 4. M1 系统设置与参数体系 - -- 建立岗位角色、参数定义、变更批次、参数值四表。 -- 公式模板以代码内有限集合注册,参数按生效日期时点化存储,避免用当前值重算历史。 -- 提供 16 个管理端点,支持参数分组、版本管理、批次撤回、两日期对比、计算预览与 xlsx 导出。 -- 前端交付岗位角色配置页、计提参数配置页、Bento 仪表盘与统一视觉体系。 -- Code review 发现的 15 项问题已全部修复,并完成回归。 - -## 四、当前进行中工作 - -当前分支 `feat/base-business-data` 正在推进 M2 基础数据管理。已形成的结构设计包括: - -| 数据对象 | 设计要点 | 后续消费方 | -| --- | --- | --- | -| `personnel` | 人员档案、岗位角色、直属经理、试用期、工资标准与状态 | 基础数据、业务归属、绩效核算 | -| `personnel_regions` | 人员负责的产品辐射区域,1:N 子表 | 基础数据维护 | -| `personnel_plans` | 市场拓展计划与指标说明,先做自由条目容器 | T+ 字段映射确认后细化 | -| `products` | 产品名称与规格唯一,类别锚定折算阈值参数 | 业务数据与绩效核算 | -| `customers` | 公司名称加负责人唯一,客户分类作为计提规则锚点 | 业务数据与绩效核算 | -| `ownership_change_logs` | 客户与业务归属变更留痕,append-only,不回溯历史 | 审计与核算追溯 | - -设计上已为 T+ 同步预留 `source_no`、`t_plus_id`、`sync_batch_no`、`synced_at`。由于 IT 尚未确认字段映射与幂等键,这些列暂为可空并仅对 `source_no` 建普通索引,待键语义确定后再以小迁移升级唯一约束。 - -**当前边界**:以上属于模型、Alembic 迁移、生产 SQL 与开发初始化脚本层面的初稿;M2 的 CRUD API、服务事务、前端页面、权限审计和完整测试仍待实现。相关文件当前未提交,不能视为已交付功能。 - -## 五、下一步计划 - -| 优先级 | 工作项 | 验收标准 | -| --- | --- | --- | -| P0 | 完成 M2 六表接口与服务 | 列表、详情、创建、更新、停用 / 删除保护可用;事务与审计完整 | -| P0 | 完成 M2 前端页面 | 人员、产品、客户管理页面复用现有分页与确认交互,类型检查通过 | -| P0 | 补齐 M2 测试与迁移一致性检查 | 单元测试、集成测试、schema 同步测试通过 | -| P1 | 提交并评审 M2 | 提交信息符合规范,不直推共享主干 | -| P1 | 启动业务数据管理设计 | 明确业务、发货、回款、批次匹配与核销状态模型 | -| P1 | 确认核算前置口径 | 经理个人户业绩是否计入 12.5% 团队基数 | -| P2 | T+ 对接专项 | 字段映射、唯一键、去重、同步日志与失败重试策略由 IT 确认 | - -## 六、风险与应对 - -| 风险 | 影响 | 当前应对 | -| --- | --- | --- | -| M2 结构初稿长期未提交 | 后续接口实现与表结构评审结论容易分叉 | 尽快完成最小可测闭环后提交,评审后再扩展 | -| T+ 字段映射未定 | 同步模块无法最终定幂等键与字段清洗规则 | 目标表预留同步字段;同步模块后置;不覆盖人工维护字段 | -| 需求文档存在作废条款 | 新成员或外部方按旧需规理解会产生口径冲突 | 以对照表 v2 与 9.8 修改方案为准;后续修订需规或加显著说明 | -| 经理个人户衔接口径待确认 | 影响绩效台账归属字段与经理绩效基数 | 在核算模块开工前作为必须裁决项跟踪 | -| 绩效金额精度风险 | 浮点误差会直接造成算错钱 | 金额使用 `Decimal`,比例斜率使用 `Fraction`;关键边界已有纯函数测试 | - -## 七、技术选型理由 - -### 1. 后端:Python 3.12 + FastAPI - -| 选型 | 理由 | 放弃的替代方案 | -| --- | --- | --- | -| Python 3.12 | `Decimal` 与 `Fraction` 能精确处理金额、千分比和 `4/360` 斜率;数据处理、Excel、后续 T+ 清洗生态成熟;版本锁定 3.12 避免新版本第三方轮子覆盖不稳 | Python 3.14:生态轮子覆盖尚不完备,兼容风险高 | -| FastAPI | 自动 OpenAPI、Pydantic 参数校验、依赖注入和统一异常模型适合内部管理系统;显式路由便于把算钱逻辑限定在 service 层 | Django:Admin 对 T+ 来源的数据 CRUD 复用价值有限,隐式 ORM 行为不利于工资核算追溯 | -| 同步 SQLAlchemy 2.0 | 项目并发压力低;绩效批量核算、Excel 导出和数据清洗是同步阻塞任务,同步路由由 FastAPI 放入线程池,避免阻塞事件循环 | 异步 SQLAlchemy:增加复杂度但没有对应吞吐收益 | - -### 2. 数据与缓存:MySQL 8.0 + Redis 7 - -| 选型 | 理由 | 放弃的替代方案 | -| --- | --- | --- | -| MySQL 8.0 + InnoDB + utf8mb4 | 事务、外键、约束与审计留痕可靠;团队与运维熟悉度高;`DECIMAL` 支撑金额精度;utf8mb4 覆盖中文与特殊字符 | Excel / 文件库:无法承载并发、事务、权限与审计;NoSQL:缺少关系约束,不利薪酬追溯 | -| Alembic 双轨迁移 | 开发期可自动生成演进,生产保留按序 SQL 脚本,符合审计与发布要求 | 仅手写 SQL:模型与库结构易漂移;仅 Alembic:不满足生产脚本留存约定 | -| Redis 7 | 验证码、登录限流、Token 黑名单都需要带 TTL 的短生命周期状态;`GETDEL` 支撑验证码一次性消费 | 仅 MySQL:一次性状态表会增加清理与原子性复杂度 | - -### 3. 前端:Vue 3 + TypeScript + Vite + Element Plus + Pinia - -| 选型 | 理由 | 放弃的替代方案 | -| --- | --- | --- | -| Vue 3 + TypeScript | 组件生态成熟,类型约束能降低表单和接口字段错误;项目已沉淀路由、状态、分页与确认交互 | 新换框架:无业务收益,徒增迁移成本 | -| Vite | 启动与热更新快,开发体验好;生产构建链路简单 | Webpack:配置与维护成本更高 | -| Element Plus | 表格、表单、抽屉、分页、日期选择等管理端组件齐全,可快速交付后台页面 | 自研组件库:一期交付效率低 | -| Pinia | 轻量状态管理,适合登录态、用户信息与模块状态 | 大型全局状态方案:当前规模过度设计 | - -### 4. 工程与质量:uv + pnpm + Docker Compose + pytest + Playwright - -| 选型 | 理由 | -| --- | --- | -| uv | 锁定 Python 与依赖版本,安装速度快,避免“本机能跑、别处失败” | -| pnpm | 依赖硬链接节省空间,lockfile 保证前端可重复安装 | -| Docker Compose | 一条命令拉起 MySQL 与 Redis,版本固定、环境一致,不依赖开发者本机散装数据库 | -| pytest + fakeredis + Playwright | 单元、集成、端到端分层验证;fakeredis 让验证码和限流测试无需容器,浏览器测试覆盖真实登录与管理链路 | - -### 5. 业务计算架构:公式模板 + 参数版本,而非规则引擎 - -一期公式骨架固定,变化的是比例、阈值、生效日期和岗位绑定,因此使用“代码内有限公式模板 + 参数时点化版本”足够,并且可测试、可审计、排错直观。引入规则引擎会增加公式调试、权限、留痕与回归成本,却不能消除业务口径澄清本身。若未来业务要求公式可由管理员自由编写,再评估规则引擎或表达式引擎,并保持计算入口兼容。 - -## 八、报告结论 - -项目基础能力和 M1 参数地基已经稳定,当前最短路径是完成 M2 基础数据管理的可测闭环,再进入业务数据管理。技术上应继续坚持“关系数据库约束 + 精确数值类型 + 时点化参数 + 审计留痕”的路线,不在 T+ 映射未定时提前锁死同步唯一键。管理上需重点跟踪 M2 提交、核算前口径确认和 T+ 对接三项。 - -## 附录:信息来源 - -- 项目入口与运行说明:[README.md](../README.md) -- 剩余开发路线:[2026-10-12-剩余开发计划.md](superpowers/plans/2026-10-12-剩余开发计划.md) -- 需求裁决:`docs/问题与解决方案对照表_v2.xlsx` -- 修改方案:`docs/2026.9.8修改方案.docx` -- 后端测试记录:[backend/tests/README.md](../backend/tests/README.md) -- 前端约定与测试:[frontend/README.md](../frontend/README.md) 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..c472562 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,12 @@ 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 urllib.parse import quote from playwright.sync_api import Page, sync_playwright @@ -32,9 +40,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) @@ -95,6 +144,7 @@ def step_create_personnel(page: Page) -> None: def step_create_business_with_allocation(page: Page, customer_name: str) -> None: + """新增带二次分配方案的业务(参与人取人员下拉前两项,比例 30% + 40%)。""" # 客户弹窗已关(上一段已关闭);直接路由直达业务管理,避免菜单点击 page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle") page.wait_for_timeout(600) @@ -170,6 +220,51 @@ def step_verify_allocation_echo(page: Page) -> None: page.wait_for_timeout(400) +def _fetch_plan(token: str, customer_name: str) -> tuple[str, list[dict]]: + """取回刚创建业务的编号与实际方案(接口取数,不从列表 DOM 刮编号)。 + + 断言要以**业务实际存下的方案**为准:参与人下拉取的是人员列表的前两 + 项,未必是脚本刚建的 t_e2e 员,硬编码姓名会把断言写死成脆弱假设。 + """ + rows = _api( + "GET", + f"/api/v1/business/businesses?keyword={quote(customer_name)}&pageSize=20", + token, + )["list"] + target = next((r for r in rows if r.get("customer_name") == customer_name), None) + if target is None: + raise E2EFailure(f"业务列表中未找到客户 {customer_name} 的业务") + detail = _api("GET", f"/api/v1/business/businesses/{target['id']}", token) + return target["business_no"], detail.get("secondary_allocations") or [] + + +def step_verify_approval_snapshot(page: Page, business_no: str, plan: list[dict]) -> 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 row in plan: + if row["personnel_name"] not in text: + fail("审批内容", f"送审内容缺少参与人 {row['personnel_name']}") + if f"{row['ratio']}%" not in text: + fail("审批内容", f"送审内容缺少参与人 {row['personnel_name']} 的比例 {row['ratio']}%") + print( + f"✓ 5. 审批内容 审批详情展示送审方案({len(plan)} 名参与人及其比例)" + ) + + def step_cleanup(page: Page) -> None: """删除本次创建的业务与其客户(t_ 前缀,走界面删除)。""" page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle") @@ -184,7 +279,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 +289,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, plan = _fetch_plan(token, customer_name) + step_verify_allocation_echo(page) + step_verify_approval_snapshot(page, business_no, plan) + 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..d6ea8ef 100644 --- a/frontend/src/views/system/approvals/index.vue +++ b/frontend/src/views/system/approvals/index.vue @@ -1,5 +1,5 @@