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:
1 parent
2e0c64849b
commit
4c32a556de
28 files changed
+1458
-76
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user