merge: 合并 V12 审批版本号唯一键与 T+ 清单 v1.7 至 main
- V12:审批单 (order_type, ref_id, version_no) 唯一键 + 取号改锁读 - 审批详情展示快照采集时间 - T+ 对接清单升至 v1.7,清理过期版本文件;补充部署硬件需求 - 全量回归 897 passed
This commit is contained in:
commit
c48d24ab6d
8
.gitignore
vendored
8
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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)`——二次分摊拆行会让行数虚高。
|
||||
|
||||
71
backend/alembic/versions/7f4f7b918bc4_v12_审批单版本号唯一键.py
Normal file
71
backend/alembic/versions/7f4f7b918bc4_v12_审批单版本号唯一键.py
Normal file
@ -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}")
|
||||
95
backend/alembic/versions/f3a8c2d61b47_v11_审批快照与方案版本化.py
Normal file
95
backend/alembic/versions/f3a8c2d61b47_v11_审批快照与方案版本化.py
Normal file
@ -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")
|
||||
@ -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,
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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` 对拍」用例 |
|
||||
|
||||
@ -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"
|
||||
|
||||
183
backend/tests/integration/test_approval_version.py
Normal file
183
backend/tests/integration/test_approval_version.py
Normal file
@ -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]
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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")
|
||||
|
||||
72
backend/tests/unit/test_approval_helpers.py
Normal file
72
backend/tests/unit/test_approval_helpers.py
Normal file
@ -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
|
||||
38
backend/tests/unit/test_approval_schema.py
Normal file
38
backend/tests/unit/test_approval_schema.py
Normal file
@ -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
|
||||
@ -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。"""
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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';
|
||||
16
db/migration/V12__approval_order_version_unique.sql
Normal file
16
db/migration/V12__approval_order_version_unique.sql
Normal file
@ -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';
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
docs/T+对接数据需求清单_v1.6_2026-10-20.xlsx
Normal file
BIN
docs/T+对接数据需求清单_v1.6_2026-10-20.xlsx
Normal file
Binary file not shown.
BIN
docs/T+对接数据需求清单_v1.7_2026-10-20.xlsx
Normal file
BIN
docs/T+对接数据需求清单_v1.7_2026-10-20.xlsx
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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 余两页 | 触碰时顺手 |
|
||||
|
||||
## 五、文档待办(低优)
|
||||
|
||||
@ -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 负绩效照算不归零。
|
||||
|
||||
241
docs/部署硬件需求.txt
Normal file
241
docs/部署硬件需求.txt
Normal file
@ -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)
|
||||
|
||||
================================================================
|
||||
(文件结束)
|
||||
BIN
docs/部署硬件需求说明.docx
Normal file
BIN
docs/部署硬件需求说明.docx
Normal file
Binary file not shown.
@ -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)
|
||||
@ -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 项答复。
|
||||
|
||||
@ -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]})")
|
||||
|
||||
@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1141,6 +1141,43 @@ onMounted(() => {
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="sub-section">
|
||||
<div class="sub-section-title">二次分配方案(含历史版本)</div>
|
||||
<el-table
|
||||
v-if="detail && (detail.allocation_history?.length ?? 0) > 0"
|
||||
:data="detail.allocation_history"
|
||||
stripe
|
||||
size="small"
|
||||
>
|
||||
<el-table-column label="版本" width="70" align="center">
|
||||
<template #default="{ row }">V{{ row.version_no }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="personnel_name" label="参与业务员" min-width="140" />
|
||||
<el-table-column label="分配比例" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-amount">{{ row.ratio }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
v-if="row.is_active === 1"
|
||||
type="success"
|
||||
size="small"
|
||||
disable-transitions
|
||||
>
|
||||
生效
|
||||
</el-tag>
|
||||
<el-tag v-else type="info" size="small" disable-transitions>历史</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="录入时间" min-width="150">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="cell-muted">未设置二次分配 —— 全部绩效归负责营销人员</div>
|
||||
</div>
|
||||
|
||||
<div class="sub-section">
|
||||
<div class="sub-section-title">状态流转</div>
|
||||
<el-timeline v-if="statusLogs.length > 0" class="status-timeline">
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import {
|
||||
@ -8,10 +8,14 @@ import {
|
||||
approvalStatusTagType,
|
||||
getApproval,
|
||||
listApprovals,
|
||||
orderTypeLabel,
|
||||
stepActionLabel,
|
||||
stepNodeLabel,
|
||||
type ApprovalAdjustmentData,
|
||||
type ApprovalAllocationData,
|
||||
type ApprovalOrder,
|
||||
type ApprovalOrderDetail,
|
||||
type ApprovalSettlementData,
|
||||
} from '@/api/approvals'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
@ -78,6 +82,26 @@ async function openDetail(row: ApprovalOrder): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 审批内容(按对象类型解释快照 data) ====================
|
||||
|
||||
const allocationData = computed<ApprovalAllocationData | null>(() =>
|
||||
detail.value?.order_type === 1 && detail.value.snapshot
|
||||
? (detail.value.snapshot.data as unknown as ApprovalAllocationData)
|
||||
: null,
|
||||
)
|
||||
|
||||
const settlementData = computed<ApprovalSettlementData | null>(() =>
|
||||
detail.value?.order_type === 2 && detail.value.snapshot
|
||||
? (detail.value.snapshot.data as unknown as ApprovalSettlementData)
|
||||
: null,
|
||||
)
|
||||
|
||||
const adjustmentData = computed<ApprovalAdjustmentData | null>(() =>
|
||||
detail.value?.order_type === 3 && detail.value.snapshot
|
||||
? (detail.value.snapshot.data as unknown as ApprovalAdjustmentData)
|
||||
: null,
|
||||
)
|
||||
|
||||
// ==================== 节点动作 ====================
|
||||
|
||||
const actingId = ref<number | null>(null)
|
||||
@ -154,6 +178,8 @@ async function submitAction(): Promise<void> {
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="filters.order_type" placeholder="全部" clearable style="width: 150px">
|
||||
<el-option label="二次分配方案" :value="1" />
|
||||
<el-option label="月度结算" :value="2" />
|
||||
<el-option label="调整单" :value="3" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
@ -165,11 +191,12 @@ async function submitAction(): Promise<void> {
|
||||
<el-table v-loading="loading" :data="records" stripe>
|
||||
<el-table-column prop="order_no" label="审批单号" min-width="150" />
|
||||
<el-table-column label="类型" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.order_type === 1 ? '二次分配方案' : `类型${row.order_type}` }}
|
||||
</template>
|
||||
<template #default="{ row }">{{ orderTypeLabel(row.order_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="refer_no" label="业务编号" min-width="140" />
|
||||
<el-table-column label="版本" width="70" align="center">
|
||||
<template #default="{ row }">V{{ row.version_no }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="refer_no" label="引用单号" min-width="140" />
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="approvalStatusTagType(row.status)" size="small" disable-transitions>
|
||||
@ -196,7 +223,7 @@ async function submitAction(): Promise<void> {
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="审批详情" size="480px">
|
||||
<el-drawer v-model="detailVisible" title="审批详情" size="720px">
|
||||
<div v-loading="detailLoading">
|
||||
<template v-if="detail">
|
||||
<div class="detail-head">
|
||||
@ -210,11 +237,129 @@ async function submitAction(): Promise<void> {
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="detail-meta">
|
||||
<span>业务编号:{{ detail.refer_no }}</span>
|
||||
<span>类型:{{ orderTypeLabel(detail.order_type) }} · 版本 V{{ detail.version_no }}</span>
|
||||
<span>引用单号:{{ detail.refer_no }}</span>
|
||||
<span>提交人:{{ detail.submitter_name }}</span>
|
||||
<span>提交时间:{{ formatDateTime(detail.create_time) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 审批内容:优先提交时快照;历史单缺快照时读当前并显式标注 -->
|
||||
<div class="content-section">
|
||||
<div class="content-title">审批内容</div>
|
||||
<!-- 采集时间让复核者明确「看到的是哪个时点的内容」:快照=送审时刻;
|
||||
live=打开详情时的当前内容(不做时刻展示——渲染函数里的
|
||||
new Date() 非响应式且 toISOString 是 UTC,会差 8 小时) -->
|
||||
<div v-if="detail.content_source === 'snapshot'" class="content-captured">
|
||||
内容采集时间:{{ formatDateTime(detail.snapshot?.captured_at) }}(送审版本)
|
||||
</div>
|
||||
<div v-else-if="detail.content_source === 'live'" class="content-captured">
|
||||
内容读取时间:打开详情时实时读取(非送审版本)
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="detail.content_source === 'live'"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="该单为历史单,未留提交时快照;以下为当前业务内容,仅供参考,不等同送审版本。"
|
||||
/>
|
||||
<el-alert
|
||||
v-else-if="detail.content_source === 'missing'"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="送审内容缺失:引用对象已不存在且未留快照,仅可查看节点留痕。"
|
||||
/>
|
||||
|
||||
<template v-if="allocationData">
|
||||
<el-descriptions :column="2" border size="small" class="content-desc">
|
||||
<el-descriptions-item label="业务编号">
|
||||
{{ allocationData.business_no }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="业务日期">
|
||||
{{ allocationData.business_date ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="客户名称">
|
||||
{{ allocationData.customer_name ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="负责人员">
|
||||
{{ allocationData.owner_personnel_name ?? '(待指定)' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="销售额(元)">
|
||||
{{ allocationData.sale_amount ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="成本(元)">
|
||||
{{ allocationData.cost_amount ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-table :data="allocationData.allocations" stripe size="small" class="content-table">
|
||||
<el-table-column prop="personnel_name" label="参与业务员" min-width="160" />
|
||||
<el-table-column label="分配比例" width="120" align="right">
|
||||
<template #default="{ row }">{{ row.ratio }}%</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<span class="content-empty">未设置二次分配 —— 全部绩效归负责营销人员</span>
|
||||
</template>
|
||||
</el-table>
|
||||
<div class="content-total">
|
||||
合计 {{ allocationData.allocation_total }}%,余额
|
||||
{{ allocationData.allocation_balance }}%(归合同负责营销人员)
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="settlementData">
|
||||
<el-descriptions :column="2" border size="small" class="content-desc">
|
||||
<el-descriptions-item label="结算月份">
|
||||
{{ settlementData.settlement_month }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="结算人数">
|
||||
{{ settlementData.person_count }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="绩效合计(元)">
|
||||
{{ settlementData.total_performance ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="应付合计(元)">
|
||||
{{ settlementData.total_payable ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-table :data="settlementData.items" stripe size="small" class="content-table">
|
||||
<el-table-column prop="personnel_name" label="人员" min-width="110" />
|
||||
<el-table-column prop="role_code" label="岗位" width="110" />
|
||||
<el-table-column label="绩效(元)" width="110" align="right">
|
||||
<template #default="{ row }">{{ row.performance_sum }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="应付(元)" width="110" align="right">
|
||||
<template #default="{ row }">{{ row.payable_sum }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="净工资(元)" width="120" align="right">
|
||||
<template #default="{ row }">{{ row.net_amount ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<template v-else-if="adjustmentData">
|
||||
<el-descriptions :column="2" border size="small" class="content-desc">
|
||||
<el-descriptions-item label="调整单号">
|
||||
{{ adjustmentData.order_no }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="调整月份">
|
||||
{{ adjustmentData.settlement_month }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="调整人员">
|
||||
{{ adjustmentData.personnel_name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="方向">
|
||||
{{ adjustmentData.direction === 1 ? '补发' : '扣减' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="金额(元)">
|
||||
{{ adjustmentData.amount }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="扣补说明">
|
||||
{{ adjustmentData.reason }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 签名元素:竖排节点时间线(财务复核 → 领导审核的处理链) -->
|
||||
<div class="timeline">
|
||||
<div
|
||||
@ -315,6 +460,46 @@ async function submitAction(): Promise<void> {
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.content-section {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: #f7f8fa;
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.content-title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.content-captured {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-2);
|
||||
}
|
||||
|
||||
.content-desc {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.content-table {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.content-total {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-2);
|
||||
}
|
||||
|
||||
.content-empty {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
/* 竖排时间线:左侧轨道 + 节点圆点,动作色区分人工/自动/驳回 */
|
||||
.timeline {
|
||||
margin-top: 14px;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user