feat: 新增预算费控模型与报销审批流引擎
后端新增预算费控服务和报销单审批流模块,引入申请人费用画像 算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常 量和明细同步,更新差旅报销规则电子表格,前端新增预算分析 组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧 边栏和顶栏样式,补充单元测试。
This commit is contained in:
@@ -35,6 +35,7 @@ from app.services.audit import AuditLogService
|
||||
from app.services.document_intelligence import build_document_insight
|
||||
from app.services.document_numbering import is_application_claim_no
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin
|
||||
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin
|
||||
@@ -127,6 +128,7 @@ from app.services.ocr import OcrService
|
||||
|
||||
|
||||
class ExpenseClaimService(
|
||||
ExpenseClaimApprovalFlowMixin,
|
||||
ExpenseClaimApplicationHandoffMixin,
|
||||
ExpenseClaimBudgetFlowMixin,
|
||||
ExpenseClaimAttachmentOperationsMixin,
|
||||
@@ -234,6 +236,18 @@ class ExpenseClaimService(
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
|
||||
if claim is None:
|
||||
return self._access_policy.is_budget_manager_user(current_user)
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
role_codes = self._access_policy.normalize_role_codes(current_user)
|
||||
if "executive" in role_codes:
|
||||
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)
|
||||
|
||||
def update_claim(
|
||||
self,
|
||||
*,
|
||||
@@ -562,9 +576,6 @@ class ExpenseClaimService(
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
if not self._access_policy.can_return_claim(current_user, claim):
|
||||
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
|
||||
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
if normalized_status == "draft":
|
||||
raise ValueError("草稿状态无需退回。")
|
||||
@@ -573,6 +584,9 @@ class ExpenseClaimService(
|
||||
if normalized_status in {"approved", "completed", "paid"}:
|
||||
raise ValueError("已完成单据不允许退回。")
|
||||
|
||||
if not self._access_policy.can_return_claim(current_user, claim):
|
||||
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
operator = self._access_policy.resolve_current_user_display_name(current_user)
|
||||
previous_status = str(claim.status or "").strip()
|
||||
@@ -580,21 +594,25 @@ class ExpenseClaimService(
|
||||
previous_stage_key = self._normalize_return_stage_key(previous_stage)
|
||||
is_application_claim = self._is_expense_application_claim(claim)
|
||||
is_direct_manager_return = previous_stage_key == "direct_manager"
|
||||
is_budget_return = previous_stage_key == "budget"
|
||||
is_application_return = is_application_claim and (is_direct_manager_return or is_budget_return)
|
||||
return_event_type = (
|
||||
"expense_application_return"
|
||||
if is_application_claim and is_direct_manager_return
|
||||
if is_application_return
|
||||
else "expense_claim_return"
|
||||
)
|
||||
return_label = (
|
||||
"领导退回"
|
||||
if is_application_claim and is_direct_manager_return
|
||||
else "预算退回"
|
||||
if is_application_claim and is_budget_return
|
||||
else "人工退回"
|
||||
)
|
||||
return_reason = str(reason or "").strip()
|
||||
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
|
||||
normalized_reason_codes = reason_code_payload["reason_codes"]
|
||||
unknown_reason_codes = reason_code_payload["unknown_reason_codes"]
|
||||
if is_application_claim and is_direct_manager_return and not any(
|
||||
if is_application_return and not any(
|
||||
code.startswith("application_") for code in normalized_reason_codes
|
||||
):
|
||||
raise ValueError("申请单退回必须选择至少一个退单类型。")
|
||||
@@ -627,6 +645,7 @@ class ExpenseClaimService(
|
||||
"reason": return_reason,
|
||||
"opinion": message,
|
||||
"leader_opinion": message if is_application_claim and is_direct_manager_return else "",
|
||||
"budget_opinion": message if is_application_claim and is_budget_return else "",
|
||||
"reason_codes": normalized_reason_codes,
|
||||
"risk_points": risk_points,
|
||||
"operator": operator,
|
||||
@@ -676,204 +695,6 @@ class ExpenseClaimService(
|
||||
|
||||
return claim
|
||||
|
||||
def approve_claim(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
opinion: str | None = None,
|
||||
) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
if normalized_status != "submitted":
|
||||
raise ValueError("只有审批中的单据可以审批通过。")
|
||||
|
||||
previous_stage = str(claim.approval_stage or "").strip()
|
||||
is_application_claim = self._is_expense_application_claim(claim)
|
||||
if previous_stage == "直属领导审批":
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
|
||||
approval_source = "manual_approval"
|
||||
if is_application_claim:
|
||||
event_type = "expense_application_approval"
|
||||
label = "领导审批通过"
|
||||
next_status = "approved"
|
||||
next_stage = "审批完成"
|
||||
default_message = "{operator} 已确认审核,申请流程完成并生成报销草稿。"
|
||||
else:
|
||||
event_type = "expense_claim_approval"
|
||||
label = "领导审批通过"
|
||||
next_status = "submitted"
|
||||
next_stage = "财务审批"
|
||||
default_message = "{operator} 已审批通过,流转至{next_stage}。"
|
||||
elif previous_stage == "财务审批":
|
||||
if is_application_claim:
|
||||
raise ValueError("费用申请无需财务审批,直属领导审批通过后即完成。")
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有财务人员可以完成财务终审。")
|
||||
approval_source = "finance_approval"
|
||||
event_type = "expense_claim_finance_approval"
|
||||
label = "财务审核通过"
|
||||
next_status = "approved"
|
||||
next_stage = "归档入账"
|
||||
default_message = "{operator} 已完成财务审核,进入归档入账。"
|
||||
else:
|
||||
raise ValueError("当前节点不支持审批通过。")
|
||||
|
||||
approval_opinion = str(opinion or "").strip()
|
||||
if previous_stage == "直属领导审批" and not approval_opinion:
|
||||
raise ValueError("领导审核意见不能为空,请填写意见后再确认审核。")
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
operator = self._access_policy.resolve_current_user_display_name(current_user)
|
||||
budget_flags: list[dict[str, Any]] = []
|
||||
if approval_source == "finance_approval" and not is_application_claim:
|
||||
consumed_budget_flag = self._consume_budget_for_finance_approval(claim, current_user)
|
||||
if consumed_budget_flag is not None:
|
||||
budget_flags.append(consumed_budget_flag)
|
||||
approval_flag = {
|
||||
"source": approval_source,
|
||||
"event_type": event_type,
|
||||
"approval_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": label,
|
||||
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
|
||||
"opinion": approval_opinion,
|
||||
"operator": operator,
|
||||
"operator_username": current_user.username,
|
||||
"operator_role_codes": [
|
||||
str(item).strip().lower()
|
||||
for item in current_user.role_codes
|
||||
if str(item).strip()
|
||||
],
|
||||
"previous_status": str(claim.status or "").strip(),
|
||||
"previous_approval_stage": previous_stage,
|
||||
"next_status": next_status,
|
||||
"next_approval_stage": next_stage,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
claim.status = next_status
|
||||
claim.approval_stage = next_stage
|
||||
if claim.submitted_at is None:
|
||||
claim.submitted_at = datetime.now(UTC)
|
||||
if is_application_claim and previous_stage == "直属领导审批":
|
||||
generated_draft = self._create_reimbursement_draft_from_application(
|
||||
application_claim=claim,
|
||||
approval_flag=approval_flag,
|
||||
operator=operator,
|
||||
)
|
||||
transferred_budget_flag = self._transfer_application_budget_to_reimbursement(
|
||||
application_claim=claim,
|
||||
draft_claim=generated_draft,
|
||||
current_user=current_user,
|
||||
)
|
||||
if transferred_budget_flag is not None:
|
||||
budget_flags.append(transferred_budget_flag)
|
||||
generated_draft.risk_flags_json = self._append_budget_flags(
|
||||
generated_draft.risk_flags_json,
|
||||
transferred_budget_flag,
|
||||
)
|
||||
claim.risk_flags_json = self._append_budget_flags(
|
||||
[*list(claim.risk_flags_json or []), approval_flag],
|
||||
budget_flags,
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=operator,
|
||||
action="expense_claim.approve",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
)
|
||||
|
||||
return claim
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user