feat: 报销审批流重构与管家计划全链路贯通

- 重构报销状态注册表、审批流路由与平台风险标记
- 完善管家意图规划器与模型计划构建器全链路
- 新增 OCR Worker 脚本、数据库会话管理与通知状态
- 优化文档中心、日志视图、预算中心与员工管理交互
- 增强工作台摘要、图标资源与全局主题样式
- 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-06 17:19:07 +08:00
parent f60cebadb8
commit e124e4bbcb
162 changed files with 9161 additions and 1941 deletions

View File

@@ -5,7 +5,11 @@ from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import or_, select
from app.models.financial_record import ExpenseClaim
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.expense_claim_workflow_constants import APPLICATION_ARCHIVE_STAGE
APPLICATION_REIMBURSEMENT_TYPE_MAP = {
@@ -15,6 +19,7 @@ APPLICATION_REIMBURSEMENT_TYPE_MAP = {
"expense_application": "other",
"application": "other",
}
APPLICATION_LINK_FLAG_SOURCES = {"application_handoff", "application_link"}
class ExpenseClaimApplicationHandoffMixin:
@@ -130,3 +135,116 @@ class ExpenseClaimApplicationHandoffMixin:
approval_flag["handoff_event_type"] = "expense_application_to_reimbursement_draft"
approval_flag["handoff_message"] = f"已生成报销草稿 {draft_claim.claim_no}"
return draft_claim
@staticmethod
def _collect_application_references_from_reimbursement(claim: ExpenseClaim) -> tuple[set[str], set[str]]:
application_ids: set[str] = set()
application_nos: set[str] = set()
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
source = str(flag.get("source") or "").strip()
has_application_reference = any(
str(flag.get(key) or "").strip()
for key in (
"application_claim_id",
"applicationClaimId",
"application_claim_no",
"applicationClaimNo",
)
)
if source not in APPLICATION_LINK_FLAG_SOURCES and not has_application_reference:
continue
application_id = str(flag.get("application_claim_id") or flag.get("applicationClaimId") or "").strip()
application_no = str(flag.get("application_claim_no") or flag.get("applicationClaimNo") or "").strip()
if application_id:
application_ids.add(application_id)
if application_no:
application_nos.add(application_no)
return application_ids, application_nos
def _find_linked_application_claims(self, reimbursement_claim: ExpenseClaim) -> list[ExpenseClaim]:
application_ids, application_nos = self._collect_application_references_from_reimbursement(reimbursement_claim)
conditions = []
if application_ids:
conditions.append(ExpenseClaim.id.in_(application_ids))
if application_nos:
conditions.append(ExpenseClaim.claim_no.in_(application_nos))
if not conditions:
return []
claims = list(self.db.scalars(select(ExpenseClaim).where(or_(*conditions))).all())
return [claim for claim in claims if self._is_expense_application_claim(claim)]
def _archive_linked_applications_after_reimbursement_paid(
self,
*,
reimbursement_claim: ExpenseClaim,
payment_flag: dict[str, Any],
operator: str,
current_user: Any,
) -> list[dict[str, str]]:
archived_applications: list[dict[str, str]] = []
payment_event_id = str(payment_flag.get("payment_event_id") or "").strip()
for application_claim in self._find_linked_application_claims(reimbursement_claim):
previous_status = str(application_claim.status or "").strip()
previous_stage = str(application_claim.approval_stage or "").strip()
if previous_stage == APPLICATION_ARCHIVE_STAGE:
continue
normalized_status = previous_status.lower()
if normalized_status not in {"approved", "completed"}:
continue
before_json = self._serialize_claim(application_claim)
archive_flag = with_risk_business_stage(
{
"source": "application_archive_sync",
"event_type": "expense_application_archived_by_reimbursement",
"archive_event_id": str(uuid.uuid4()),
"severity": "info",
"label": "申请归档",
"message": (
f"关联报销单 {reimbursement_claim.claim_no} 已完成付款,"
"系统同步将申请单归档。"
),
"operator": operator,
"operator_username": getattr(current_user, "username", ""),
"operator_role_codes": [
str(item).strip().lower()
for item in getattr(current_user, "role_codes", [])
if str(item).strip()
],
"application_claim_id": application_claim.id,
"application_claim_no": application_claim.claim_no,
"reimbursement_claim_id": reimbursement_claim.id,
"reimbursement_claim_no": reimbursement_claim.claim_no,
"payment_event_id": payment_event_id,
"previous_status": previous_status,
"previous_approval_stage": previous_stage,
"next_status": "approved",
"next_approval_stage": APPLICATION_ARCHIVE_STAGE,
"created_at": datetime.now(UTC).isoformat(),
},
"expense_application",
)
application_claim.status = "approved"
application_claim.approval_stage = APPLICATION_ARCHIVE_STAGE
application_claim.risk_flags_json = [*list(application_claim.risk_flags_json or []), archive_flag]
archived_applications.append(
{
"application_claim_id": application_claim.id,
"application_claim_no": str(application_claim.claim_no or "").strip(),
"next_approval_stage": APPLICATION_ARCHIVE_STAGE,
}
)
self.audit_service.log_action(
actor=operator,
action="expense_application.archive_by_reimbursement",
resource_type="expense_claim",
resource_id=application_claim.id,
before_json=before_json,
after_json=self._serialize_claim(application_claim),
)
return archived_applications