feat: 新增预算费控模型与报销审批流引擎

后端新增预算费控服务和报销单审批流模块,引入申请人费用画像
算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常
量和明细同步,更新差旅报销规则电子表格,前端新增预算分析
组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧
边栏和顶栏样式,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-27 17:31:27 +08:00
parent cbb98f4469
commit d4d5d40569
75 changed files with 5393 additions and 686 deletions

View File

@@ -10,14 +10,25 @@ from app.api.deps import CurrentUserContext
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.organization import OrganizationUnit
from app.models.role import Role
from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE,
ARCHIVE_ACCOUNTING_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"}
ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive"}
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
BUDGET_APPROVAL_ROLE_CODES = {"budget_monitor", "executive"}
BUDGET_MONITOR_ROLE_CODE = "budget_monitor"
BUDGET_MONITOR_APPROVAL_GRADE = "P8"
CLAIM_DELETE_ROLE_CODES = {"executive"}
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
APPLICATION_ARCHIVED_STAGES = ("审批完成", "申请归档", "completed")
APPLICATION_ARCHIVED_STAGES = (APPROVAL_DONE_STAGE, "申请归档", "completed")
class ExpenseClaimAccessPolicy:
@@ -49,7 +60,7 @@ class ExpenseClaimAccessPolicy:
normalized_type.like("%\\_application", escape="\\"),
)
return or_(
stage == "归档入账",
stage == ARCHIVE_ACCOUNTING_STAGE,
stage == "completed",
and_(
application_condition,
@@ -61,7 +72,7 @@ class ExpenseClaimAccessPolicy:
or_(
stage == "",
stage.is_(None),
stage == "归档入账",
stage == ARCHIVE_ACCOUNTING_STAGE,
stage == "completed",
),
),
@@ -77,7 +88,7 @@ class ExpenseClaimAccessPolicy:
def is_archived_claim(claim: ExpenseClaim) -> bool:
normalized_status = str(claim.status or "").strip().lower()
stage = str(claim.approval_stage or "").strip()
if stage in {"归档入账", "completed"}:
if stage in {ARCHIVE_ACCOUNTING_STAGE, "completed"}:
return True
normalized_type = str(claim.expense_type or "").strip().lower()
claim_no = str(claim.claim_no or "").strip().upper()
@@ -92,7 +103,7 @@ class ExpenseClaimAccessPolicy:
and stage in APPLICATION_ARCHIVED_STAGES
):
return True
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", "归档入账", "completed"}
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", ARCHIVE_ACCOUNTING_STAGE, "completed"}
def can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
normalized_status = str(claim.status or "").strip().lower()
@@ -100,9 +111,11 @@ class ExpenseClaimAccessPolicy:
return False
stage = str(claim.approval_stage or "").strip()
if stage == "直属领导审批":
if stage == DIRECT_MANAGER_APPROVAL_STAGE:
return self.is_current_direct_manager_approver(current_user, claim)
if stage == "财务审批":
if stage == BUDGET_MANAGER_APPROVAL_STAGE:
return self.is_budget_manager_approver(current_user, claim)
if stage == FINANCE_APPROVAL_STAGE:
return self.has_privileged_claim_access(current_user) and not self.is_claim_owned_by_current_user(
claim,
current_user,
@@ -111,9 +124,11 @@ class ExpenseClaimAccessPolicy:
def can_approve_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
stage = str(claim.approval_stage or "").strip()
if stage == "直属领导审批":
if stage == DIRECT_MANAGER_APPROVAL_STAGE:
return self.is_current_direct_manager_approver(current_user, claim)
if stage == "财务审批":
if stage == BUDGET_MANAGER_APPROVAL_STAGE:
return self.is_budget_manager_approver(current_user, claim)
if stage == FINANCE_APPROVAL_STAGE:
role_codes = self.normalize_role_codes(current_user)
return (
(current_user.is_admin or "finance" in role_codes)
@@ -127,7 +142,7 @@ class ExpenseClaimAccessPolicy:
return False
if str(claim.status or "").strip().lower() != "submitted":
return False
if str(claim.approval_stage or "").strip() != "直属领导审批":
if str(claim.approval_stage or "").strip() != DIRECT_MANAGER_APPROVAL_STAGE:
return False
current_employee = self.resolve_current_employee(current_user)
@@ -149,6 +164,65 @@ class ExpenseClaimAccessPolicy:
return self.resolve_claim_manager_name(claim) == approver_name
def is_budget_manager_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
if str(claim.status or "").strip().lower() != "submitted":
return False
if str(claim.approval_stage or "").strip() != BUDGET_MANAGER_APPROVAL_STAGE:
return False
if self.is_claim_owned_by_current_user(claim, current_user):
return False
if current_user.is_admin:
return True
role_codes = self.normalize_role_codes(current_user)
if "executive" in role_codes:
return True
return self.is_department_p8_budget_monitor(current_user, claim)
def is_budget_manager_user(self, current_user: CurrentUserContext) -> bool:
if current_user.is_admin:
return True
role_codes = self.normalize_role_codes(current_user)
return bool(role_codes & BUDGET_APPROVAL_ROLE_CODES)
def is_department_p8_budget_monitor(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
role_codes = self.normalize_role_codes(current_user)
if BUDGET_MONITOR_ROLE_CODE not in role_codes:
return False
current_employee = self.resolve_current_employee(current_user)
if current_employee is None:
return False
if not self._employee_has_budget_approval_grade(current_employee):
return False
return self._employee_matches_claim_department(current_employee, current_user, claim)
def resolve_department_budget_manager(self, claim: ExpenseClaim) -> Employee | None:
department_ids, department_names = self._collect_claim_department_identity(claim)
department_conditions = []
if department_ids:
department_conditions.append(Employee.organization_unit_id.in_(department_ids))
if department_names:
department_conditions.append(Employee.organization_unit.has(OrganizationUnit.name.in_(department_names)))
if not department_conditions:
return None
stmt = (
select(Employee)
.options(selectinload(Employee.organization_unit), selectinload(Employee.roles))
.where(
func.upper(func.coalesce(Employee.grade, "")) == BUDGET_MONITOR_APPROVAL_GRADE,
Employee.roles.any(Role.role_code == BUDGET_MONITOR_ROLE_CODE),
or_(*department_conditions),
)
.order_by(Employee.name.asc(), Employee.employee_no.asc())
.limit(1)
)
claim_employee_id = str(claim.employee_id or "").strip()
if claim_employee_id:
stmt = stmt.where(Employee.id != claim_employee_id)
return self.db.scalar(stmt)
@staticmethod
def normalize_role_codes(current_user: CurrentUserContext) -> set[str]:
return {
@@ -157,6 +231,51 @@ class ExpenseClaimAccessPolicy:
if str(item).strip()
}
@staticmethod
def _employee_has_budget_approval_grade(employee: Employee) -> bool:
return str(employee.grade or "").strip().upper() == BUDGET_MONITOR_APPROVAL_GRADE
def _employee_matches_claim_department(
self,
employee: Employee,
current_user: CurrentUserContext,
claim: ExpenseClaim,
) -> bool:
claim_department_ids, claim_department_names = self._collect_claim_department_identity(claim)
employee_department_ids = {
str(employee.organization_unit_id or "").strip(),
}
employee_department_names = {
str(current_user.department_name or "").strip(),
}
if employee.organization_unit is not None:
employee_department_names.add(str(employee.organization_unit.name or "").strip())
employee_department_ids.discard("")
employee_department_names.discard("")
return bool(
(claim_department_ids and employee_department_ids & claim_department_ids)
or (claim_department_names and employee_department_names & claim_department_names)
)
def _collect_claim_department_identity(self, claim: ExpenseClaim) -> tuple[set[str], set[str]]:
department_ids = {
str(claim.department_id or "").strip(),
}
department_names = {
str(claim.department_name or "").strip(),
}
claim_employee = self.resolve_claim_employee_for_backfill(claim)
if claim_employee is not None:
department_ids.add(str(claim_employee.organization_unit_id or "").strip())
if claim_employee.organization_unit is not None:
department_names.add(str(claim_employee.organization_unit.name or "").strip())
department_ids.discard("")
department_names.discard("")
return department_ids, department_names
def resolve_current_employee(self, current_user: CurrentUserContext) -> Employee | None:
return self.resolve_employee_by_identity_candidates(
[
@@ -375,7 +494,7 @@ class ExpenseClaimAccessPolicy:
).strip()
pending_leader_approval_parts = [
ExpenseClaim.status == "submitted",
ExpenseClaim.approval_stage == "直属领导审批",
ExpenseClaim.approval_stage == DIRECT_MANAGER_APPROVAL_STAGE,
]
if employee is not None:
pending_leader_approval_parts.append(
@@ -399,17 +518,55 @@ class ExpenseClaimAccessPolicy:
return conditions
def build_budget_approval_claim_conditions(self, current_user: CurrentUserContext) -> list[Any]:
role_codes = self.normalize_role_codes(current_user)
if BUDGET_MONITOR_ROLE_CODE not in role_codes:
return []
employee = self.resolve_current_employee(current_user)
if employee is None or not self._employee_has_budget_approval_grade(employee):
return []
pending_budget_approval_parts = [
ExpenseClaim.status == "submitted",
ExpenseClaim.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE,
]
pending_budget_approval_parts.append(
or_(ExpenseClaim.employee_id.is_(None), ExpenseClaim.employee_id != employee.id)
)
if employee.name:
pending_budget_approval_parts.append(ExpenseClaim.employee_name != employee.name)
department_conditions = []
department_name = str(current_user.department_name or "").strip()
if employee.organization_unit_id:
department_conditions.append(ExpenseClaim.department_id == employee.organization_unit_id)
subordinate_department_employee_ids = select(Employee.id).where(
Employee.organization_unit_id == employee.organization_unit_id
)
department_conditions.append(ExpenseClaim.employee_id.in_(subordinate_department_employee_ids))
if employee.organization_unit is not None and employee.organization_unit.name:
department_conditions.append(ExpenseClaim.department_name == employee.organization_unit.name)
if department_name:
department_conditions.append(ExpenseClaim.department_name == department_name)
if not department_conditions:
return []
pending_budget_approval_parts.append(or_(*department_conditions))
return [and_(*pending_budget_approval_parts)]
def apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
role_codes = self.normalize_role_codes(current_user)
if current_user.is_admin or "executive" in role_codes:
return stmt.where(ExpenseClaim.status == "submitted")
conditions = []
if "finance" in role_codes:
return stmt.where(
conditions.append(and_(
ExpenseClaim.status == "submitted",
ExpenseClaim.approval_stage == "财务审批",
)
ExpenseClaim.approval_stage == FINANCE_APPROVAL_STAGE,
))
conditions = self.build_approval_claim_conditions(current_user)
conditions.extend(self.build_budget_approval_claim_conditions(current_user))
conditions.extend(self.build_approval_claim_conditions(current_user))
if not conditions:
return stmt.where(ExpenseClaim.id == "__no_visible_claim__")
@@ -440,6 +597,7 @@ class ExpenseClaimAccessPolicy:
return stmt.where(ExpenseClaim.id == "__no_visible_claim__")
if include_approval_scope:
conditions.extend(self.build_budget_approval_claim_conditions(current_user))
conditions.extend(self.build_approval_claim_conditions(current_user))
return stmt.where(or_(*conditions))