feat(claim): 重构报销审批流并收敛风险标记

- 直属领导兼任部门 P8 预算审批人时合并预算审批,直接流转至财务审批
- 预算超过警戒值时强制要求预算管理者填写审批意见
- 新增风险标记去重工具,消除各审核阶段重复风险卡片
- 新增工作流修复 Mixin,纠正重复预算审批阶段的历史数据
- 收紧单据删除权限至 admin,放宽预算分析可见范围至当前审核人
- 提交校验放宽已上传票据条目的 OCR 字段缺失并忽略尾部占位条目
This commit is contained in:
caoxiaozhu
2026-06-17 14:38:07 +08:00
parent 09a66c72cb
commit 1f4681f486
11 changed files with 372 additions and 27 deletions

View File

@@ -49,6 +49,7 @@ from app.services.expense_claim_attachment_document import ExpenseClaimAttachmen
from app.services.expense_claim_attachment_operations import ExpenseClaimAttachmentOperationsMixin
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
from app.services.expense_claim_workflow_constants import DIRECT_MANAGER_APPROVAL_STAGE
from app.services.expense_claim_workflow_repair import ExpenseClaimWorkflowRepairMixin
from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin
from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin
from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin
@@ -58,6 +59,7 @@ from app.services.expense_claim_pagination import ExpenseClaimPaginationMixin
from app.services.expense_claim_pre_review import ExpenseClaimPreReviewMixin
from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
from app.services.receipt_folder import ReceiptFolderService
@@ -156,6 +158,7 @@ class ExpenseClaimService(
ExpenseClaimAttachmentAnalysisMixin,
ExpenseClaimReadModelMixin,
ExpenseClaimRiskReviewMixin,
ExpenseClaimWorkflowRepairMixin,
):
def __init__(self, db: Session) -> None:
self.db = db
@@ -210,7 +213,9 @@ class ExpenseClaimService(
.order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc())
)
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
return self._access_policy.attach_budget_approval_snapshots(list(self.db.scalars(stmt).all()))
claims = list(self.db.scalars(stmt).all())
self._repair_duplicate_budget_approval_stages(claims)
return self._access_policy.attach_budget_approval_snapshots(claims)
def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
stmt = (
@@ -224,7 +229,9 @@ class ExpenseClaimService(
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
)
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
return self._access_policy.attach_budget_approval_snapshots(list(self.db.scalars(stmt).all()))
claims = list(self.db.scalars(stmt).all())
self._repair_duplicate_budget_approval_stages(claims)
return self._access_policy.attach_budget_approval_snapshots(claims)
def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
stmt = (
@@ -252,7 +259,10 @@ class ExpenseClaimService(
.where(ExpenseClaim.id == claim_id)
)
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
return self._access_policy.attach_approval_snapshot(self.db.scalar(stmt))
claim = self.db.scalar(stmt)
if claim is not None:
self._repair_duplicate_budget_approval_stages([claim])
return self._access_policy.attach_approval_snapshot(claim)
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
if claim is None:
@@ -262,6 +272,13 @@ class ExpenseClaimService(
role_codes = self._access_policy.normalize_role_codes(current_user)
if "executive" in role_codes:
return True
if (
self._access_policy.has_privileged_claim_access(current_user)
and not self._access_policy.is_claim_owned_by_current_user(claim, current_user)
):
return True
if self._access_policy.can_approve_claim(current_user, claim):
return True
if self._access_policy.is_claim_owned_by_current_user(claim, current_user):
return False
return self._access_policy.is_department_p8_budget_monitor(current_user, claim)
@@ -545,7 +562,7 @@ class ExpenseClaimService(
and str(flag.get("source") or "").strip() == STANDARD_ADJUSTMENT_RISK_SOURCE
)
]
claim.risk_flags_json = [*preserved_flags, *adjustment_flags]
claim.risk_flags_json = dedupe_claim_risk_flags([*preserved_flags, *adjustment_flags])
self._sync_claim_from_items(claim)
self.db.commit()
@@ -805,6 +822,7 @@ class ExpenseClaimService(
claim.approval_stage = DIRECT_MANAGER_APPROVAL_STAGE
claim.submitted_at = datetime.now(UTC)
claim.risk_flags_json = dedupe_claim_risk_flags(claim.risk_flags_json)
self.db.commit()
self.db.refresh(claim)
@@ -829,9 +847,7 @@ class ExpenseClaimService(
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None and (
current_user.is_admin or self._access_policy.has_archive_center_access(current_user)
):
if claim is None and current_user.is_admin:
candidate_claim = self.db.scalar(
select(ExpenseClaim)
.options(
@@ -841,13 +857,14 @@ class ExpenseClaimService(
)
.where(ExpenseClaim.id == claim_id)
)
if candidate_claim is not None and (
current_user.is_admin or self._access_policy.is_archived_claim(candidate_claim)
):
if candidate_claim is not None:
claim = candidate_claim
if claim is None:
return None
if not self._access_policy.has_claim_delete_access(current_user):
raise ValueError("只有 admin 管理员可以删除单据。")
if self._access_policy.is_archived_claim(claim) and not current_user.is_admin:
raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。")