feat: 新增预算后端服务与差旅风险规则库

后端新增预算模型、端点和服务模块,支持预算 CRUD 和余额
查询,清理旧生成规则文件并替换为按严重等级分类的差旅风
险规则库,优化认证权限和报销单访问策略,新增财务规则目
录和演示数据构建脚本,前端预算中心增加对话框交互,完善
审计页面运行时模型和元数据展示,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-26 17:29:35 +08:00
parent e1e515ecae
commit e7bef0883d
85 changed files with 6443 additions and 1497 deletions

View File

@@ -695,9 +695,9 @@ def create_agent_asset_review(
role_codes = {item.strip() for item in current_user.role_codes}
if payload.review_status.value == "pending":
if not (current_user.is_admin or "manager" in role_codes or "finance" in role_codes):
raise PermissionError("只有财务人员或高级管理人员可以提交审核。")
raise PermissionError("只有财务人员或高级财务人员可以提交审核。")
elif not (current_user.is_admin or "manager" in role_codes):
raise PermissionError("只有高级管理人员可以审核规则。")
raise PermissionError("只有高级财务人员可以审核规则。")
return AgentAssetService(db).create_review(
asset_id,
payload,
@@ -746,7 +746,7 @@ def activate_agent_asset(
response_model=AgentAssetRead,
summary="设置风险规则启用状态",
description=(
"高级管理人员可独立启用或停用 JSON 风险规则;停用后即使已上线也不会进入真实业务扫描。"
"高级财务人员可独立启用或停用 JSON 风险规则;停用后即使已上线也不会进入真实业务扫描。"
),
)
def set_agent_asset_risk_rule_enabled(
@@ -797,7 +797,7 @@ def set_agent_asset_risk_rule_level(
"/{asset_id}/return",
response_model=AgentAssetRiskRuleLatestTestSummary,
summary="回退待审核风险规则",
description="高级管理人员将待审核风险规则回退到草稿,并记录回退原因。",
description="高级财务人员将待审核风险规则回退到草稿,并记录回退原因。",
)
def return_agent_asset_risk_rule(
asset_id: str,
@@ -822,7 +822,7 @@ def return_agent_asset_risk_rule(
"/{asset_id}/publish",
response_model=AgentAssetRead,
summary="审核并发布风险规则",
description="高级管理人员确认测试通过后,将待审核风险规则一次性审核通过并发布上线。",
description="高级财务人员确认测试通过后,将待审核风险规则一次性审核通过并发布上线。",
)
def publish_agent_asset_risk_rule(
asset_id: str,

View File

@@ -0,0 +1,338 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, or_, select
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from app.api.deps import (
CurrentUserContext,
get_current_user,
get_db,
is_budget_scope_limited_user,
require_budget_editor_user,
require_budget_viewer_user,
)
from app.models.employee import Employee
from app.models.budget import BudgetAllocation
from app.schemas.budget import (
BudgetAllocationCreate,
BudgetAllocationRead,
BudgetCheckRead,
BudgetCheckRequest,
BudgetOperationRead,
BudgetOperationRequest,
BudgetSummaryRead,
BudgetTransactionRead,
)
from app.schemas.common import ErrorResponse
from app.services.budget import BudgetControlError, BudgetService
router = APIRouter(prefix="/budgets")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
BudgetViewer = Annotated[CurrentUserContext, Depends(require_budget_viewer_user)]
BudgetEditor = Annotated[CurrentUserContext, Depends(require_budget_editor_user)]
@router.get(
"/summary",
response_model=BudgetSummaryRead,
summary="读取预算中心汇总",
)
def get_budget_summary(
db: DbSession,
current_user: BudgetViewer,
fiscal_year: Annotated[int | None, Query(alias="year")] = None,
period_key: Annotated[str | None, Query(alias="period")] = None,
department_id: str | None = None,
department_name: str | None = None,
cost_center: str | None = None,
) -> BudgetSummaryRead:
scope = _resolve_budget_query_scope(
db,
current_user,
department_id=department_id,
department_name=department_name,
cost_center=cost_center,
)
return BudgetService(db).get_summary(
fiscal_year=fiscal_year,
period_key=period_key,
**scope,
)
@router.get(
"/allocations",
response_model=list[BudgetAllocationRead],
summary="查询预算额度列表",
)
def list_budget_allocations(
db: DbSession,
current_user: BudgetViewer,
fiscal_year: Annotated[int | None, Query(alias="year")] = None,
period_key: Annotated[str | None, Query(alias="period")] = None,
department_id: str | None = None,
department_name: str | None = None,
cost_center: str | None = None,
) -> list[BudgetAllocationRead]:
scope = _resolve_budget_query_scope(
db,
current_user,
department_id=department_id,
department_name=department_name,
cost_center=cost_center,
)
return BudgetService(db).list_allocations(
fiscal_year=fiscal_year,
period_key=period_key,
**scope,
)
@router.post(
"/allocations",
response_model=BudgetAllocationRead,
status_code=status.HTTP_201_CREATED,
summary="创建或更新预算额度",
responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}},
)
def create_budget_allocation(
payload: BudgetAllocationCreate,
db: DbSession,
current_user: BudgetEditor,
) -> BudgetAllocationRead:
try:
allocation = BudgetService(db).create_or_update_allocation(
payload,
operator=current_user.name or current_user.username,
)
db.commit()
return allocation
except ValueError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
@router.get(
"/allocations/{allocation_id}/transactions",
response_model=list[BudgetTransactionRead],
summary="读取预算交易台账",
)
def list_budget_transactions(
allocation_id: str,
db: DbSession,
current_user: BudgetViewer,
) -> list[BudgetTransactionRead]:
allocation = BudgetService(db).get_allocation_row(allocation_id)
if allocation is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="预算额度不存在。")
if not _allocation_visible_to_user(db, current_user, allocation):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能查看其他部门预算流水。")
return BudgetService(db).list_transactions(allocation_id)
@router.post(
"/check",
response_model=BudgetCheckRead,
summary="校验预算可用余额",
)
def check_budget(payload: BudgetCheckRequest, db: DbSession, current_user: BudgetViewer) -> BudgetCheckRead:
scope = _resolve_budget_query_scope(
db,
current_user,
department_id=payload.department_id,
department_name=payload.department_name,
cost_center=payload.cost_center,
)
scoped_payload = payload.model_copy(update=scope)
return BudgetService(db).check(scoped_payload)
@router.post(
"/reserve",
response_model=BudgetOperationRead,
summary="记录预算预占台账",
responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}},
)
def reserve_budget(
payload: BudgetOperationRequest,
db: DbSession,
current_user: BudgetEditor,
) -> BudgetOperationRead:
return _execute_budget_operation(
payload,
db=db,
current_user=current_user,
transaction_type="reserve",
)
@router.post(
"/release",
response_model=BudgetOperationRead,
summary="记录预算释放台账",
responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}},
)
def release_budget(
payload: BudgetOperationRequest,
db: DbSession,
current_user: BudgetEditor,
) -> BudgetOperationRead:
return _execute_budget_operation(
payload,
db=db,
current_user=current_user,
transaction_type="release",
)
@router.post(
"/consume",
response_model=BudgetOperationRead,
summary="记录预算核销台账",
responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}},
)
def consume_budget(
payload: BudgetOperationRequest,
db: DbSession,
current_user: BudgetEditor,
) -> BudgetOperationRead:
return _execute_budget_operation(
payload,
db=db,
current_user=current_user,
transaction_type="consume",
)
@router.post(
"/rollback",
response_model=BudgetOperationRead,
summary="记录预算核销回滚台账",
responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}},
)
def rollback_budget(
payload: BudgetOperationRequest,
db: DbSession,
current_user: BudgetEditor,
) -> BudgetOperationRead:
return _execute_budget_operation(
payload,
db=db,
current_user=current_user,
transaction_type="rollback",
)
def _execute_budget_operation(
payload: BudgetOperationRequest,
*,
db: Session,
current_user: CurrentUserContext,
transaction_type: str,
) -> BudgetOperationRead:
try:
result = BudgetService(db).execute_operation(
payload,
transaction_type=transaction_type,
operator=current_user.name or current_user.username,
)
db.commit()
return result
except BudgetControlError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
except ValueError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
def _resolve_budget_query_scope(
db: Session,
current_user: CurrentUserContext,
*,
department_id: str | None = None,
department_name: str | None = None,
cost_center: str | None = None,
) -> dict[str, str | None]:
if not is_budget_scope_limited_user(current_user):
return {
"department_id": _blank_to_none(department_id),
"department_name": _blank_to_none(department_name),
"cost_center": _blank_to_none(cost_center),
}
employee = _resolve_current_employee(db, current_user)
scoped_cost_center = (
_blank_to_none(current_user.cost_center)
or _blank_to_none(getattr(employee, "cost_center", None))
or _blank_to_none(getattr(getattr(employee, "organization_unit", None), "cost_center", None))
)
scoped_department_name = (
_blank_to_none(current_user.department_name)
or _blank_to_none(getattr(getattr(employee, "organization_unit", None), "name", None))
)
if not scoped_cost_center and not scoped_department_name:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="预算监控员缺少部门归属,不能查看预算中心。",
)
return {
"department_id": None,
"department_name": None if scoped_cost_center else scoped_department_name,
"cost_center": scoped_cost_center,
}
def _allocation_visible_to_user(
db: Session,
current_user: CurrentUserContext,
allocation: BudgetAllocation,
) -> bool:
if not is_budget_scope_limited_user(current_user):
return True
scope = _resolve_budget_query_scope(db, current_user)
scoped_cost_center = scope.get("cost_center")
scoped_department_name = scope.get("department_name")
if scoped_cost_center:
return str(allocation.cost_center or "").strip() == scoped_cost_center
if scoped_department_name:
return str(allocation.department_name or "").strip() == scoped_department_name
return False
def _resolve_current_employee(db: Session, current_user: CurrentUserContext) -> Employee | None:
identities = [
str(current_user.username or "").strip(),
str(current_user.name or "").strip(),
]
identities = [item for item in dict.fromkeys(identities) if item]
if not identities:
return None
lowered = [item.lower() for item in identities]
stmt = (
select(Employee)
.options(selectinload(Employee.organization_unit))
.where(
or_(
func.lower(Employee.email).in_(lowered),
Employee.employee_no.in_(identities),
Employee.name.in_(identities),
)
)
.limit(1)
)
return db.scalars(stmt).first()
def _blank_to_none(value: str | None) -> str | None:
text = str(value or "").strip()
return text or None

View File

@@ -505,7 +505,7 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
"/claims/{claim_id}/return",
response_model=ExpenseClaimRead,
summary="退回报销单",
description="财务人员、高级管理人员或当前审批人可将可见报销单退回到待提交状态。",
description="财务人员、高级财务人员或当前审批人可将可见报销单退回到待提交状态。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
@@ -571,7 +571,7 @@ def approve_expense_claim(
"/claims/{claim_id}",
response_model=ExpenseClaimActionResponse,
summary="删除报销单",
description="申请人仅可删除自己的草稿、待补充或退回单据;高级管理人员可删除可见的非归档单据;已归档单据仅高级管理员可删除,财务人员没有删除权限。",
description="申请人仅可删除自己的草稿、待补充或退回单据;高级财务人员可删除可见的非归档单据;已归档单据仅高级管理员可删除,财务人员没有删除权限。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,