feat(approval): 审批单内容快照与二次分配方案版本化
补齐 code-review 遗留 2 项:审批单此前只固化 ref_id/refer_no 指针,复核者看到 的是业务当前内容;二次分配方案在编辑替换与驳回清空时一律物理删除,方案事实 无处回溯。 - 审批单加 version_no 与 snapshot_json(信封 schema_version/order_type/ captured_at/data);二次分配、月结、调整单三类提交均固化送审内容,开关关闭 的自动通过路径同样写快照;详情按 snapshot/live/missing 三态回显,历史单 只读回退当前内容并显式告警,不回填历史快照 - 二次分配方案行改 append-only 版本化(version_no/is_active,唯一键扩为 business+version+personnel):编辑替换与驳回清空只置历史,核算与详情默认 只取生效行,业务详情新增 allocation_history - 送审内容未变不重提、方案未变不新增版本:判据统一为送审内容,两个 version_no 语义解耦(方案版本=内容第几版,审批单版本=第几次送审) - 审批单列表、工作台待办、审批超时预警三处 defer 快照列,避免整列拉取 - 月结提交取行加 for_update,防并发提交算出重复的审批单版本号 - 修复审批类型筛选上界 le=2(调整单无法筛选)、快照金额把空值写成 0.00 - V11 迁移三件套同步(Alembic / db/migration / init-dev.sql) 测试:单元 +16(方案内容比较 10、审批入参 6)、集成 +12;全量 891 passed。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
2e0c64849b
commit
4c32a556de
@ -221,6 +221,18 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
|
||||
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
|
||||
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
|
||||
|
||||
### 审批快照与方案版本化(2026-10-20 定稿,改动前先读)
|
||||
|
||||
- **审批单带版本号与内容快照**:`approval_orders.version_no` 按 `(order_type, ref_id)` 从 1 递增,重提先撤回旧在途单、但**旧单快照不覆盖**;`snapshot_json` 统一信封 `{schema_version, order_type, captured_at, data}`,`data` 由 `approval_service.{allocation,settlement,adjustment}_content` 组装(Decimal 转 str、日期转 ISO),开关关闭的自动通过路径同样写快照。审批期间业务再改,复核者看到的仍是送审版本。
|
||||
- **两个 `version_no` 语义不同,别混用**:`business_secondary_allocations.version_no` = 方案内容第几版,**仅方案内容(参与人+比例)变化时 +1**;`approval_orders.version_no` = 同一对象第几次送审,**仅送审内容变化时 +1**。改备注不动金额 → 两者都不动;改金额 → 只有审批单版本号动;改方案比例 → 两者都动。
|
||||
- **送审内容未变不重提**:编辑业务时若**存在在途审批单**且其快照 `data` 与本次送审内容逐键相等(`approval_service.pending_matches_snapshot`,只比 `data` 不比 `captured_at`),则保留原单不撤回不重建;否则撤旧提新。方案内容未变时**不新增方案版本行**(`business_service._plan_changed` 按「人员+比例」集合比较,与行序无关)。
|
||||
- **快照的覆盖范围是「方案 + 钱 + 归属」**(业务编号/日期、客户、负责与协助人、销售额、成本额、方案参与人与比例、合计与余额),**不含产品明细行**。因此只改产品构成而金额不变时不会重提——分摊依据(谁分、分多少、基于多少钱)未变;产品明细由复核者按业务编号回业务页查看。
|
||||
- **列表不加载快照列**:快照可达上百 KB,审批单列表、工作台待办、工作台审批超时预警三处查询一律 `defer(ApprovalOrder.snapshot_json)`(与操作日志列表排除 `request_data` 同一理由,参考 `operation_log_repository.list_logs`);详情走 `get_order` 按需读取。
|
||||
- **详情内容来源三态**:`content_source=snapshot`(送审快照)/ `live`(V11 前的历史单体无快照,只读回退当前内容并显式告警)/ `missing`(引用对象不存在且无快照)。**禁止回填历史单快照**——回填等于伪造提交时内容。
|
||||
- **二次分配方案 append-only**:`business_secondary_allocations` 行不物理删除,编辑全量替换与驳回清空只把旧行 `is_active=0`;唯一键为 `(business_id, version_no, personnel_id)`,同一版本内参与人唯一、跨版本可重复。`business_repository.list_allocations(active_only=True)` 是核算与详情回显的唯一取数口径;业务详情 `allocation_history` 返回全部版本供回溯。
|
||||
- **月结提交要锁行**:`settlement_service.submit` 取月结行必须 `for_update=True`——并发提交会让两次 `submit_order` 算出同一个审批单 `version_no`,送审次序失真。
|
||||
- **新增审批对象类型**须同时提供快照组装函数并接入 `_content_of` 的 live 回退分支,不要在各业务服务内联 JSON 结构。
|
||||
|
||||
### 工作台口径(M4 ⑧,改动前先读)
|
||||
|
||||
- **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。
|
||||
|
||||
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,7 @@ ref_id 为**固化无外键**(同 business_status_logs 先例):order_type=
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, text
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Index, Integer, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||
@ -55,6 +55,8 @@ class ApprovalOrder(Base, TimestampMixin):
|
||||
__table_args__ = mysql_table_args(
|
||||
"审批单表(营销后勤提交→财务复核→领导审核;开关关闭时自动通过留痕)",
|
||||
Index("idx_approval_orders_type_status", "order_type", "status"),
|
||||
# 版本号按 (order_type, ref_id) 递增,须有对应索引避免全表 max 扫描
|
||||
Index("idx_approval_orders_type_ref", "order_type", "ref_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
@ -76,6 +78,16 @@ class ApprovalOrder(Base, TimestampMixin):
|
||||
refer_no: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, comment="引用对象单号(冗余固化,列表页免联查)"
|
||||
)
|
||||
version_no: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
comment="提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)",
|
||||
)
|
||||
snapshot_json: Mapped[dict | None] = mapped_column(
|
||||
JSON,
|
||||
comment="提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)",
|
||||
)
|
||||
status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE,
|
||||
nullable=False,
|
||||
|
||||
@ -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:
|
||||
@ -258,7 +304,7 @@ def act(
|
||||
if rejected and order.order_type == 1:
|
||||
from app.repositories import business_repository
|
||||
|
||||
business_repository.delete_allocations(session, order.ref_id)
|
||||
business_repository.deactivate_allocations(session, order.ref_id)
|
||||
|
||||
# 审批结果联动(服务层回调,不 commit):月结 通过=锁定/驳回=回草稿;
|
||||
# 调整单 通过=生效/驳回=作废。函数内延迟导入防循环依赖。
|
||||
@ -299,11 +345,18 @@ def act(
|
||||
|
||||
|
||||
def list_orders(session: Session, query: ApprovalQuery) -> PageResult[ApprovalOrderOut]:
|
||||
"""分页查询审批单(准入由 api 层依赖判定)。"""
|
||||
"""分页查询审批单(准入由 api 层依赖判定)。
|
||||
|
||||
排除 snapshot_json 列(月结快照含按人明细,可达数十~上百 KB):列表出参
|
||||
本就不含快照,整列加载会让翻页负担随快照体积线性放大;详情走
|
||||
`get_order` 按需读取(与操作日志列表排除 request_data 同一理由,
|
||||
见 operation_log_repository.list_logs)。
|
||||
"""
|
||||
filters = _order_filters(query)
|
||||
rows = (
|
||||
session.execute(
|
||||
select(ApprovalOrder)
|
||||
.options(defer(ApprovalOrder.snapshot_json))
|
||||
.where(*filters)
|
||||
.order_by(ApprovalOrder.id.desc())
|
||||
.offset(query.offset)
|
||||
@ -360,8 +413,136 @@ def _withdraw_pending(session: Session, *, order_type: int, ref_id: int, operato
|
||||
logger.info("撤回在途审批单 %s 张 | ref=%s", len(pending), ref_id)
|
||||
|
||||
|
||||
def _next_version(session: Session, order_type: int, ref_id: int) -> int:
|
||||
"""同一对象类型+引用对象的下一个提交版本号(从 1 递增)。"""
|
||||
return (
|
||||
int(
|
||||
session.execute(
|
||||
select(func.coalesce(func.max(ApprovalOrder.version_no), 0)).where(
|
||||
ApprovalOrder.order_type == order_type,
|
||||
ApprovalOrder.ref_id == ref_id,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
+ 1
|
||||
)
|
||||
|
||||
|
||||
def allocation_content(session: Session, business: Business) -> dict:
|
||||
"""二次分配方案的审批内容(提单快照与历史单回退共用同一结构)。
|
||||
|
||||
业务主行已存在但客户联查落空(客户被删等异常)时返回空字典——调用方
|
||||
以 ``or None`` 归一,避免生成「有快照但内容为空」的信封。
|
||||
"""
|
||||
from app.repositories import business_repository
|
||||
|
||||
row = business_repository.get_with_names(session, business.id)
|
||||
if row is None:
|
||||
return {}
|
||||
_, customer_name, owner_name, assistant_name = row
|
||||
allocations = business_repository.list_allocations(session, business.id)
|
||||
total = sum((a.ratio for a in allocations), Decimal("0"))
|
||||
return {
|
||||
"business_no": business.business_no,
|
||||
"business_date": business.business_date.isoformat() if business.business_date else None,
|
||||
"customer_name": customer_name,
|
||||
"owner_personnel_name": owner_name,
|
||||
"assistant_personnel_name": assistant_name,
|
||||
"sale_amount": _amount_text(business.total_sale_amount),
|
||||
"cost_amount": _amount_text(business.total_cost_amount),
|
||||
"allocations": [
|
||||
{
|
||||
"personnel_id": a.personnel_id,
|
||||
"personnel_name": a.personnel_name,
|
||||
"ratio": str(a.ratio),
|
||||
}
|
||||
for a in allocations
|
||||
],
|
||||
"allocation_total": str(total.quantize(Decimal("0.01"))),
|
||||
"allocation_balance": str((Decimal("100") - total).quantize(Decimal("0.01"))),
|
||||
}
|
||||
|
||||
|
||||
def _amount_text(value: Decimal | None) -> str | None:
|
||||
"""金额文本化(Decimal 全程不转 float;None 保留为空,不伪造 0.00)。"""
|
||||
return str(value) if value is not None else None
|
||||
|
||||
|
||||
def settlement_content(session: Session, settlement_id: int) -> dict | None:
|
||||
"""月结审批内容(汇总 + 按人明细;提单快照与历史单回退共用)。"""
|
||||
from app.models import MonthlySettlement
|
||||
from app.repositories import settlement_repository
|
||||
|
||||
settlement = session.get(MonthlySettlement, settlement_id)
|
||||
if settlement is None:
|
||||
return None
|
||||
items = settlement_repository.list_items(session, settlement_id)
|
||||
return {
|
||||
"settlement_month": settlement.settlement_month,
|
||||
"total_performance": _amount_text(settlement.total_performance),
|
||||
"total_payable": _amount_text(settlement.total_payable),
|
||||
"person_count": settlement.person_count,
|
||||
"items": [
|
||||
{
|
||||
"personnel_name": item.personnel_name,
|
||||
"role_code": item.role_code,
|
||||
"calc_type": item.calc_type,
|
||||
"performance_sum": str(item.performance_sum),
|
||||
"payable_sum": str(item.payable_sum),
|
||||
"basic_amount": _amount_text(item.basic_amount),
|
||||
"salary_mode": item.salary_mode,
|
||||
"net_amount": _amount_text(item.net_amount),
|
||||
"is_probation": item.is_probation,
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def adjustment_content(session: Session, adjustment_id: int) -> dict | None:
|
||||
"""调整单审批内容(提单快照与历史单回退共用)。"""
|
||||
from app.models import AdjustmentOrder
|
||||
|
||||
adjustment = session.get(AdjustmentOrder, adjustment_id)
|
||||
if adjustment is None:
|
||||
return None
|
||||
return {
|
||||
"order_no": adjustment.order_no,
|
||||
"settlement_month": adjustment.settlement_month,
|
||||
"personnel_name": adjustment.personnel_name,
|
||||
"direction": adjustment.direction,
|
||||
"amount": str(adjustment.amount),
|
||||
"reason": adjustment.reason,
|
||||
}
|
||||
|
||||
|
||||
def _content_of(session: Session, order: ApprovalOrder) -> tuple[dict | None, str]:
|
||||
"""审批内容出参:优先提交时快照,历史单回退当前内容并显式标注来源。"""
|
||||
if order.snapshot_json:
|
||||
return order.snapshot_json, "snapshot"
|
||||
|
||||
if order.order_type == 1:
|
||||
business = session.get(Business, order.ref_id)
|
||||
data = allocation_content(session, business) if business is not None else None
|
||||
elif order.order_type == 2:
|
||||
data = settlement_content(session, order.ref_id)
|
||||
elif order.order_type == 3:
|
||||
data = adjustment_content(session, order.ref_id)
|
||||
else:
|
||||
data = None
|
||||
|
||||
if not data:
|
||||
return None, "missing"
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"order_type": order.order_type,
|
||||
"captured_at": None,
|
||||
"data": data,
|
||||
}, "live"
|
||||
|
||||
|
||||
def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut:
|
||||
"""出参组装(含 steps 时间线)。调用方须保证处于 commit 之前。"""
|
||||
"""出参组装(含 steps 时间线与审批内容)。调用方须保证处于 commit 之前。"""
|
||||
steps = (
|
||||
session.execute(
|
||||
select(ApprovalStep).where(ApprovalStep.order_id == order.id).order_by(ApprovalStep.id)
|
||||
@ -371,6 +552,7 @@ def _detail_of(session: Session, order: ApprovalOrder) -> ApprovalOrderDetailOut
|
||||
)
|
||||
detail = ApprovalOrderDetailOut.model_validate(order)
|
||||
detail.steps = [ApprovalStepOut.model_validate(s) for s in steps]
|
||||
detail.snapshot, detail.content_source = _content_of(session, order)
|
||||
return detail
|
||||
|
||||
|
||||
|
||||
@ -54,6 +54,7 @@ from app.models.business import (
|
||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||
from app.repositories import business_repository, customer_repository, personnel_repository
|
||||
from app.schemas.business import (
|
||||
AllocationVersionOut,
|
||||
BusinessCreateIn,
|
||||
BusinessDetailOut,
|
||||
BusinessItemIn,
|
||||
@ -281,6 +282,17 @@ def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
|
||||
for a in allocations
|
||||
]
|
||||
out.allocation_hint = _allocation_hint(allocations)
|
||||
out.allocation_history = [
|
||||
AllocationVersionOut(
|
||||
personnel_id=a.personnel_id,
|
||||
personnel_name=a.personnel_name,
|
||||
ratio=str(a.ratio),
|
||||
version_no=a.version_no,
|
||||
is_active=a.is_active,
|
||||
create_time=a.create_time,
|
||||
)
|
||||
for a in business_repository.list_allocation_versions(session, business_id)
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
@ -335,6 +347,21 @@ def _build_allocation_rows(
|
||||
return rows
|
||||
|
||||
|
||||
def _plan_changed(
|
||||
old_rows: list[SecondaryAllocation], new_rows: list[SecondaryAllocation]
|
||||
) -> bool:
|
||||
"""方案内容(参与人 + 比例)是否真的变了。
|
||||
|
||||
比较口径是**集合**(人员 ID 与其比例的组合),与提交顺序无关——送审
|
||||
快照同样按库内生效行的顺序生成,若按顺序比较,用户只是把行拖了个
|
||||
序就会被判成「变了」,进而无谓地新增版本与重提审批单。
|
||||
"""
|
||||
def _key(row: SecondaryAllocation) -> tuple[int, Decimal]:
|
||||
return (row.personnel_id, Decimal(row.ratio).quantize(Decimal("0.01")))
|
||||
|
||||
return {_key(r) for r in old_rows} != {_key(r) for r in new_rows}
|
||||
|
||||
|
||||
def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
|
||||
"""方案合计/余额提示(不要求合计满额是需求原文;余额归属 = 澄清 #2 答复)。
|
||||
|
||||
@ -433,6 +460,7 @@ def create_business(
|
||||
ref_id=business.id,
|
||||
refer_no=business.business_no,
|
||||
submitter=operator,
|
||||
snapshot=approval_service.allocation_content(session, business) or None,
|
||||
)
|
||||
customer = customer_repository.get_by_id(session, business.customer_id)
|
||||
out = _to_out((business, customer.company_name if customer else None, None, None))
|
||||
@ -507,28 +535,43 @@ def update_business(
|
||||
business_repository.replace_items(
|
||||
session, business_id, _build_item_rows(business_id, payload.items)
|
||||
)
|
||||
# 方案全量替换(空数组=清空);终态门槛已挡「修改影响已结算月份」的
|
||||
# 前置场景(月结在 ④,快照固化后按同一哲学隔离)
|
||||
old_ids = {
|
||||
a.personnel_id for a in business_repository.list_allocations(session, business_id)
|
||||
}
|
||||
business_repository.replace_allocations(
|
||||
# 方案:内容真的变了才动版本行(内容未变不新增版本,避免「只改备注」
|
||||
# 也堆出一个方案版本);空数组=清空=把生效行置历史
|
||||
old_allocations = business_repository.list_allocations(session, business_id)
|
||||
new_rows = _build_allocation_rows(
|
||||
session,
|
||||
business_id,
|
||||
_build_allocation_rows(
|
||||
session, business_id, payload.secondary_allocations, old_ids=old_ids
|
||||
),
|
||||
payload.secondary_allocations,
|
||||
old_ids={a.personnel_id for a in old_allocations},
|
||||
)
|
||||
# 提单/撤单与方案同步生命周期:非空=提单(内含撤旧建新);空=仅撤回
|
||||
# 在途单 —— 否则旧在途单挂着已不存在的方案继续走审批
|
||||
if not payload.secondary_allocations:
|
||||
business_repository.replace_allocations(session, business_id, [])
|
||||
elif _plan_changed(old_allocations, new_rows):
|
||||
business_repository.replace_allocations(session, business_id, new_rows)
|
||||
# 汇总先重算:送审快照里的销售额/成本额必须反映本次编辑后的值,
|
||||
# 放到提单之后会固化成上一版本的金额
|
||||
_recalc_totals(session, business)
|
||||
# 提单/撤单与方案同步生命周期:
|
||||
# - 方案为空 → 仅撤回在途单(旧在途单挂着已不存在的方案继续走审批)
|
||||
# - 送审内容与在途单一致 → 保留原单,不撤回不重建(避免反复保存
|
||||
# 刷出「撤回+新建」的审批留痕噪音)
|
||||
# - 其余 → 提新单(内含撤旧建新)
|
||||
if payload.secondary_allocations:
|
||||
approval_service.submit_order(
|
||||
snapshot = approval_service.allocation_content(session, business) or None
|
||||
if not approval_service.pending_matches_snapshot(
|
||||
session,
|
||||
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||
ref_id=business_id,
|
||||
refer_no=business.business_no,
|
||||
submitter=operator,
|
||||
)
|
||||
snapshot=snapshot,
|
||||
):
|
||||
approval_service.submit_order(
|
||||
session,
|
||||
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
|
||||
ref_id=business_id,
|
||||
refer_no=business.business_no,
|
||||
submitter=operator,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
else:
|
||||
approval_service.withdraw_pending_orders(
|
||||
session,
|
||||
@ -536,7 +579,6 @@ def update_business(
|
||||
ref_id=business_id,
|
||||
operator=operator,
|
||||
)
|
||||
_recalc_totals(session, business)
|
||||
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
|
||||
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
|
||||
out = _to_out((business, customer.company_name, owner_name, assistant_name))
|
||||
|
||||
@ -248,7 +248,7 @@ def submit(
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
settlement = settlement_repository.get_by_id(session, settlement_id)
|
||||
settlement = settlement_repository.get_by_id(session, settlement_id, for_update=True)
|
||||
if settlement is None:
|
||||
raise NotFoundError("月结不存在")
|
||||
if settlement.status not in (SETTLEMENT_STATUS_DRAFT, SETTLEMENT_STATUS_REJECTED):
|
||||
@ -260,6 +260,9 @@ def submit(
|
||||
ref_id=settlement.id,
|
||||
refer_no=f"JS{settlement.settlement_month.replace('-', '')}",
|
||||
submitter=operator,
|
||||
# 月结明细在生成时已固化;提交时快照让复核者看到的是送审版本,
|
||||
# 而不是审批期间若发生重生成后的内容
|
||||
snapshot=approval_service.settlement_content(session, settlement.id),
|
||||
)
|
||||
settlement.approval_order_no = order.order_no
|
||||
if order.status == SETTLEMENT_STATUS_APPROVED:
|
||||
@ -573,6 +576,7 @@ def _wire_adjustment_approval(session: Session, order, operator: User) -> None:
|
||||
ref_id=order.id,
|
||||
refer_no=order.order_no,
|
||||
submitter=operator,
|
||||
snapshot=approval_service.adjustment_content(session, order.id),
|
||||
)
|
||||
order.status = (
|
||||
ADJUSTMENT_STATUS_EFFECTIVE
|
||||
|
||||
@ -159,6 +159,18 @@ uv run pytest tests/integration -q # 仅集成
|
||||
|
||||
**实施中发现并修复**:`alembic upgrade --sql` 在 Windows 控制台按 GBK 输出,重定向生成的 `V10__*.sql` 被 `test_schema_sync` 按 UTF-8 读取时抛 `UnicodeDecodeError`——导出命令须加 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1`(已写入 backend/README)。
|
||||
|
||||
### code-review 遗留 2 项(2026-10-20 完成:方案版本化 / 审批内容快照)
|
||||
|
||||
| 文件 | 守什么 | 用例数 |
|
||||
| --- | --- | --- |
|
||||
| `tests/integration/test_approval_flow.py`(扩) | **提交时快照固化**(version_no=1、content_source=snapshot、方案比例/合计/余额与业务编号);编辑方案为 80% 后新单 V2、旧单快照仍 50.00 且旧在途单撤回;**历史单无快照回退当前内容并标 live**(captured_at=null);引用对象缺失且无快照 → missing;**方案版本化**:编辑后 history=[(1,0,50.00),(2,1,80.00)]、生效行仅 80.00;驳回后生效行为空但历史行保留(is_active=0) | +5 |
|
||||
| `tests/unit/test_approval_schema.py` | 对象类型筛选 1/2/3 均可、0/4 拒绝(修复原上界 `le=2` 导致调整单审批单无法筛选);不传类型表示全部;驳回必须填意见、通过可不填 | 6 |
|
||||
| `tests/unit/test_approval_helpers.py` | **方案内容比较**(`business_service._plan_changed`):完全相同/行序不同判未变、比例变/人员增减/同比例换人判已变、比例写法 `50` 与 `50.00` 等价、空方案对照 | 10 |
|
||||
| `tests/integration/test_secondary_allocation.py`(扩) | **同一会话内旧方案行随置历史同步刷新**(Core UPDATE 的会话同步策略退化为不刷新时,行对象会停留在 is_active=1 的过期内存值) | +1 |
|
||||
| `tests/integration/test_approval_flow.py`(扩) | **送审内容未变不重提**:方案与金额都没动 → 方案不新增版本、审批单不重提(旧单仍待财务复核);**只改金额** → 方案版本不变但审批单 version_no+1 且新快照金额为改后值;**改方案比例** → 方案版本+1、审批单 version_no+1 且旧单撤回;**无在途单时保存仍提新单**(保守规则);`pending_matches_snapshot` 四态(一致/不一致/无在途单/快照为 None) | +5 |
|
||||
|
||||
运行:`uv run pytest tests/integration/test_approval_flow.py tests/integration/test_secondary_allocation.py tests/integration/test_schema_sync.py -q` → **37 passed**(2026-10-20);`tests/integration/test_settlements.py` → **16 passed**;`tests/integration/test_calc.py` → **20 passed**;`tests/unit` → **525 passed**(含新增 `test_approval_helpers.py` 10 例);**全量 `uv run pytest -q` → 891 passed(10:23)**。ruff、前端 `pnpm type-check` 与 `pnpm build` 均绿。
|
||||
|
||||
## 三、运行记录
|
||||
|
||||
| 日期 | 范围 | 用例数 | 结果 | 备注 |
|
||||
@ -184,6 +196,8 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 2026-10-19 | **code-review 修复回归** | 全量 862 | ✅ 862 passed(9:36) | **M4 工作台 + 报表 3 必须 + 8 建议修复**:普通用户落地页/守卫(报表集成 27、工作台集成 11、报表单元 17,新增 10 例);部门汇总 5% 按计入次数累计并与月结 `total_payable` 对拍、聚合行舍入差;工作台待办按角色过滤、逾期命中与阈值生效、终止业务排除、`warnings.total` 截断、趋势跨月归属。ruff 绿;type-check + build 绿 |
|
||||
| 2026-10-20 | e2e 全链路 | — | ✅ login 8 / system 13 / role_home 4 / report 12 / calc_settlement 8 | 重启后端加载新代码后实跑:普通用户落地页与受限页回退、报表四页/导出/打印、核算与月结链路全过。`report_flow` 回款统计改为展开目标桶后按本次业务编号定位明细,不依赖桶累计值,可重复运行 |
|
||||
| 2026-10-20 | 全量回归 | 862 | ✅ 862 passed(11:32) | **工作台/报表修复提交后的全量复核**;e2e 三脚本复跑:role_home 4/4、report 12/12、calc_settlement 8/8;calc 审批开关已还原为 1,无 `e2e_role_*` 残留 |
|
||||
| 2026-10-20 | code-review 遗留 2 项 | 全量 875 | ✅ 875 passed(12:13) | **方案版本化 + 审批内容快照**(V11 迁移):审批单 `version_no`/`snapshot_json`,方案行 `is_active` 版本化;编辑/驳回不再物理删方案;历史单无快照时 live 回退并显式标注。新增集成 7 + 单元 6;定点审批/月结/核算/业务/报表全绿;ruff 与 type-check/build 绿 |
|
||||
| 2026-10-20 | 遗留项自评审收口 | 全量 891 | ✅ 891 passed(10:23) | **列表 defer 快照列**(审批列表/工作台待办/超时预警)+ **送审内容未变不重提、方案未变不新增版本**(两个 version_no 语义解耦)+ **月结提交加行锁**。新增单元 10 + 集成 5;单元 525、集成定点 96 全绿 |
|
||||
| 2026-10-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 |
|
||||
| 2026-10-19 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 |
|
||||
|
||||
@ -197,6 +211,12 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 2026-10-19 | 经理个人户成员行取该人全部绩效 | 团队基数只含个人户行,成员行却含普通行——**基数≠成员合计,同一笔业绩在成员行与未分配重复** | 普通行与个人户分行记账;经理成员行只取个人户金额,普通行留在上级团队/未分配 |
|
||||
| 2026-10-19 | 导出取数与超限校验在审计上下文之外 | 超限 400 与取数异常**不留失败日志**,与 M4 计划 T12「导出 400/失败审计」不符 | `_export_payload` 移入 `with audit`;静态标题映射(AuditRecord frozen) |
|
||||
| 2026-10-19 | 月份只校验长度 | `2026-13` 传到 `calendar.monthrange` 抛 ValueError → **500**;导出手工构造模型抛裸 ValidationError 同样 500 | 新增 `core/month.py`(格式+01-12+年份);四类 query、月结、调整单接入;导出 ValidationError 转 400 |
|
||||
| 2026-10-20 | 审批类型筛选上界写死 2 | `ApprovalQuery.order_type` 为 `le=2`:调整单(order_type=3)审批单在列表无法筛选,只能翻全量 | 上界改 3;单元用例锁定 1/2/3 与越界拒绝 |
|
||||
| 2026-10-20 | 编辑业务时送审快照取旧汇总 | `update_business` 中 `_recalc_totals` 在提单之后,快照里的销售额/成本额会固化上一版本金额 | 汇总重算移到提单之前;快照用例断言送审内容与本次编辑后方案一致 |
|
||||
| 2026-10-20 | 快照金额把「空」写成 0.00 | 业务尚无成本时 `total_cost_amount=None` 被文本化成 `0.00`,复核者会把「无成本」误读成「成本为零」 | `_amount_text(None)` 保留为空,前端显示「—」 |
|
||||
| 2026-10-20 | 每次保存带方案的业务都刷出版本与审批留痕 | 只改备注、方案一字未动,也会「置历史+新增版本+撤回旧单+提新单」,方案历史与审批留痕被噪音淹没,两个 `version_no` 语义混同 | 判据统一为**送审内容**:方案未变不新增版本行;在途单送审内容一致则不重提(`pending_matches_snapshot`,只比 `data`) |
|
||||
| 2026-10-20 | 三处列表查询整列拉快照 | 审批单列表、工作台待办、审批超时预警都用 `select(ApprovalOrder)` 取全列,快照 JSON 可达上百 KB,翻页与工作台负担随快照体积线性放大 | 三处加 `defer(ApprovalOrder.snapshot_json)`(沿用操作日志列表排除 `request_data` 的既有模式) |
|
||||
| 2026-10-20 | 月结提交未锁行 | 并发/双击提交同一月结时两次 `_next_version` 可能算出同一 `version_no`,送审次序失真 | `settlement_repository.get_by_id` 加 `for_update` 参数,提交路径锁行 |
|
||||
| 2026-10-19 | 前端报表五处状态缺陷 | 打印超限判定恒假(静默截断)、部门下钻 query 未读取、回款多桶共用一个 drill 状态(面板串行)、summary 在请求序号守卫前写入(合计串号)、e2e 全页行数选择器必失败 | 分别修正判定/读取 query/按桶独立状态+数组参数/`onData` 回调/限定主桶表;补 e2e 多桶展开、下钻、截断、部门打印用例 |
|
||||
| 2026-10-19 | 工作台权限收紧后守卫仍回退 `/dashboard` | 普通用户登录/直连受限页被反复重定向到同一受限页 → **无法进入系统**(导航被判定无限重定向) | `router/access.ts` 统一准入与落地页解析(工作台 → 业务数据 → 回款);登录回跳经 `canAccessPath` 校验;菜单/面包屑复用同一判定 |
|
||||
| 2026-10-19 | 部门汇总应发只计一次全公司 5% | 月结每位有聚合行的经理各计一次 5%,签字稿少计 N-1 份;**导出/打印金额小于月结应发** | 团队行增 `has_aggregate/aggregate_amount`(优先冻结聚合行);对账改按聚合额求和,新增「聚合行舍入差」吸收分列 q2 尾差;补「与 `total_payable` 对拍」用例 |
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
@ -713,6 +713,8 @@ CREATE TABLE approval_orders (
|
||||
order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算(预留)',
|
||||
ref_id BIGINT UNSIGNED NOT NULL COMMENT '引用对象ID(order_type=1 时为 businesses.id;固化无外键,删除保护由服务层承担)',
|
||||
refer_no VARCHAR(32) NOT NULL COMMENT '引用对象单号(冗余固化,列表页免联查)',
|
||||
version_no INT NOT NULL COMMENT '提交版本号(同一对象类型+引用对象从1递增;重提产生新版本,旧单快照不覆盖)' DEFAULT '1',
|
||||
snapshot_json JSON COMMENT '提交时内容快照(信封:schema_version/order_type/captured_at/data;历史单可空)',
|
||||
status TINYINT NOT NULL COMMENT '审批状态:1待财务复核 2待领导审核 3已通过 4已驳回 5已撤回' DEFAULT '1',
|
||||
submitter_id BIGINT UNSIGNED COMMENT '提交人ID(账号删除后置空)',
|
||||
submitter_name VARCHAR(64) NOT NULL COMMENT '提交人用户名(冗余固化)',
|
||||
@ -725,6 +727,8 @@ CREATE TABLE approval_orders (
|
||||
|
||||
CREATE INDEX idx_approval_orders_type_status ON approval_orders (order_type, status);
|
||||
|
||||
CREATE INDEX idx_approval_orders_type_ref ON approval_orders (order_type, ref_id);
|
||||
|
||||
CREATE TABLE approval_steps (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
order_id BIGINT UNSIGNED NOT NULL COMMENT '审批单ID(approval_orders.id;单删记录随删)',
|
||||
@ -744,21 +748,25 @@ CREATE INDEX idx_approval_steps_order_id ON approval_steps (order_id);
|
||||
CREATE TABLE business_secondary_allocations (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
business_id BIGINT UNSIGNED NOT NULL COMMENT '业务ID(businesses.id;业务删除随删)',
|
||||
version_no INT NOT NULL COMMENT '方案版本号(同一业务从1递增;编辑/重提产生新版本,旧版本仅置历史)' DEFAULT '1',
|
||||
personnel_id BIGINT UNSIGNED NOT NULL COMMENT '参与业务员ID(personnel.id;被方案引用禁删)',
|
||||
personnel_name VARCHAR(64) NOT NULL COMMENT '参与业务员姓名(冗余固化,离职后回显有名)',
|
||||
ratio DECIMAL(5, 2) NOT NULL COMMENT '分摊比例(0-100 两位小数;不要求合计满额,余额归属待澄清#2)',
|
||||
is_active TINYINT NOT NULL COMMENT '是否当前生效版本:1生效 0历史(编辑替换/驳回清空后置0,行不删除)' DEFAULT '1',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_business_secondary_allocations PRIMARY KEY (id),
|
||||
CONSTRAINT uk_business_secondary_allocations_business_personnel UNIQUE (business_id, personnel_id),
|
||||
CONSTRAINT uk_business_secondary_allocations_business_version_personnel UNIQUE (business_id, version_no, personnel_id),
|
||||
CONSTRAINT fk_business_secondary_allocations_business_id FOREIGN KEY(business_id) REFERENCES businesses (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_business_secondary_allocations_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案明细表(合同建立时录入;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci;
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='业务二次分配方案版本表(append-only;比例不要求合计满额,余额归属待澄清)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_business_secondary_allocations_business_id ON business_secondary_allocations (business_id);
|
||||
|
||||
CREATE INDEX idx_business_secondary_allocations_personnel_id ON business_secondary_allocations (personnel_id);
|
||||
|
||||
CREATE INDEX idx_business_secondary_allocations_business_active ON business_secondary_allocations (business_id, is_active);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 绩效核算台账/月结/调整单(V7 迁移;append-only 版本行;通过即锁定走调整单)
|
||||
-- ---------------------------------------------------------------------------
|
||||
@ -882,4 +890,4 @@ CREATE TABLE alembic_version (
|
||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||
|
||||
INSERT INTO alembic_version (version_num) VALUES ('90653814a421');
|
||||
INSERT INTO alembic_version (version_num) VALUES ('f3a8c2d61b47');
|
||||
|
||||
@ -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';
|
||||
@ -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 负绩效照算不归零。
|
||||
|
||||
@ -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,11 @@
|
||||
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/secondary_allocation_flow.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
@ -32,9 +39,50 @@ from common import (
|
||||
)
|
||||
|
||||
ADMIN = "admin"
|
||||
API_BASE = "http://127.0.0.1:8000"
|
||||
SUFFIX = f"{int(time.time())}"
|
||||
console_errors: list[str] = []
|
||||
|
||||
_ALLOCATION_SWITCH = "approval.secondary_allocation.enabled"
|
||||
|
||||
|
||||
def _api(method: str, path: str, token: str, body: dict | None = None) -> dict:
|
||||
"""直连后端接口(仅用于开关预置;浏览器交互一律走页面)。"""
|
||||
request = urllib.request.Request(
|
||||
f"{API_BASE}{path}",
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data=json.dumps(body).encode() if body is not None else None,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
payload = json.loads(response.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode()[:200]
|
||||
raise E2EFailure(f"API {method} {path} HTTP {exc.code}:{detail}") from exc
|
||||
if payload.get("code") != 200:
|
||||
raise E2EFailure(f"API {method} {path} 业务失败:{payload.get('message')}")
|
||||
return payload["data"]
|
||||
|
||||
|
||||
def _set_allocation_switch(token: str, value: str) -> None:
|
||||
"""显式控制二次分配审批开关(用例不依赖种子默认值;结束还原默认 1)。"""
|
||||
_api(
|
||||
"POST",
|
||||
"/api/v1/system/params/changes",
|
||||
token,
|
||||
{
|
||||
"change_type": 1,
|
||||
"remark": f"e2e secondary_allocation_flow 设置二次分配审批开关={value}",
|
||||
"items": [
|
||||
{"param_code": _ALLOCATION_SWITCH, "dimension_value": "", "value": value}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def step_navigate_to_business(page: Page, admin_password: str) -> None:
|
||||
login(page, ADMIN, admin_password)
|
||||
@ -170,6 +218,44 @@ def step_verify_allocation_echo(page: Page) -> None:
|
||||
page.wait_for_timeout(400)
|
||||
|
||||
|
||||
def _current_business_no(page: Page, customer_name: str) -> str:
|
||||
"""取本次创建业务的业务编号(列表首列),用于审批单检索。"""
|
||||
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
|
||||
page.wait_for_timeout(600)
|
||||
page.click("button:has-text('查询')")
|
||||
page.wait_for_timeout(500)
|
||||
row = page.locator(".el-table__row", has_text=customer_name).first
|
||||
if row.count() == 0:
|
||||
fail("取业务编号", f"未找到本次业务(客户 {customer_name})")
|
||||
return row.locator("td").first.inner_text().strip()
|
||||
|
||||
|
||||
def step_verify_approval_snapshot(page: Page, business_no: str) -> None:
|
||||
"""审批详情:展示提交时的送审方案(参与人 + 比例 + 合计余额)。"""
|
||||
page.goto(f"{BASE_URL}/system/approvals", wait_until="networkidle")
|
||||
page.wait_for_timeout(800)
|
||||
page.locator('.filters:visible input[placeholder="审批单号 / 业务编号"]').fill(business_no)
|
||||
page.locator('.filters:visible button:has-text("查询")').first.click()
|
||||
page.wait_for_timeout(800)
|
||||
rows = page.locator(".el-table__body .el-table__row:visible")
|
||||
if rows.count() == 0:
|
||||
fail("审批内容", f"未找到业务 {business_no} 的审批单")
|
||||
rows.first.locator("button:has-text('详情')").click()
|
||||
page.wait_for_timeout(1000)
|
||||
drawer = page.locator(".el-drawer:visible").last
|
||||
drawer.wait_for(state="visible")
|
||||
text = drawer.inner_text()
|
||||
if "审批内容" not in text:
|
||||
fail("审批内容", "审批详情未展示「审批内容」区块")
|
||||
for i in (0, 1):
|
||||
name = f"t_e2e员_{SUFFIX}_{i}"
|
||||
if name not in text:
|
||||
fail("审批内容", f"送审内容缺少参与人 {name}")
|
||||
if "30.00%" not in text or "40.00%" not in text:
|
||||
fail("审批内容", f"送审内容缺少分配比例:{text[:200]}")
|
||||
print("✓ 5. 审批内容 审批详情展示送审方案(两名参与人与 30%/40% 比例)")
|
||||
|
||||
|
||||
def step_cleanup(page: Page) -> None:
|
||||
"""删除本次创建的业务与其客户(t_ 前缀,走界面删除)。"""
|
||||
page.goto(f"{BASE_URL}/business/businesses", wait_until="networkidle")
|
||||
@ -184,7 +270,7 @@ def step_cleanup(page: Page) -> None:
|
||||
# 删除确认框按钮文案为「删除」(useConfirmAction confirmButtonText)
|
||||
confirm_message_box(page, "删除")
|
||||
page.wait_for_timeout(600)
|
||||
print("✓ 5. 数据清理 已删除本次创建的 t_ 业务")
|
||||
print("✓ 6. 数据清理 已删除本次创建的 t_ 业务")
|
||||
|
||||
|
||||
def run(page: Page) -> None:
|
||||
@ -194,13 +280,25 @@ def run(page: Page) -> None:
|
||||
fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)")
|
||||
|
||||
step_navigate_to_business(page, admin_password)
|
||||
customer_name = step_create_customer_via_ui(page)
|
||||
token = page.evaluate("localStorage.getItem('ys_token')")
|
||||
if not token:
|
||||
fail("API 预置", "localStorage 中未取到 ys_token")
|
||||
# 显式关闭二次分配审批开关(结束还原默认 1):自动通过留痕,既不依赖
|
||||
# 种子默认值,也让本次业务不留“在途审批单”——业务删除保护在途单,
|
||||
# 开关开启时该业务在界面上删不掉
|
||||
_set_allocation_switch(token, "0")
|
||||
try:
|
||||
step_create_personnel(page)
|
||||
step_create_business_with_allocation(page, customer_name)
|
||||
step_verify_allocation_echo(page)
|
||||
customer_name = step_create_customer_via_ui(page)
|
||||
try:
|
||||
step_create_personnel(page)
|
||||
step_create_business_with_allocation(page, customer_name)
|
||||
business_no = _current_business_no(page, customer_name)
|
||||
step_verify_allocation_echo(page)
|
||||
step_verify_approval_snapshot(page, business_no)
|
||||
finally:
|
||||
step_cleanup(page)
|
||||
finally:
|
||||
step_cleanup(page)
|
||||
_set_allocation_switch(token, "1")
|
||||
|
||||
if console_errors:
|
||||
print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})")
|
||||
|
||||
@ -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,120 @@ 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>
|
||||
<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 +451,40 @@ 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-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