feat: 新增预算费控模型与报销审批流引擎
后端新增预算费控服务和报销单审批流模块,引入申请人费用画像 算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常 量和明细同步,更新差旅报销规则电子表格,前端新增预算分析 组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧 边栏和顶栏样式,补充单元测试。
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user