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

@@ -1,30 +1,31 @@
from collections.abc import Generator
from dataclasses import dataclass
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_session_factory
def get_db() -> Generator[Session, None, None]:
db = get_session_factory()()
try:
yield db
finally:
db.close()
@dataclass(slots=True)
from collections.abc import Generator
from dataclasses import dataclass
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_session_factory
def get_db() -> Generator[Session, None, None]:
db = get_session_factory()()
try:
yield db
finally:
db.close()
@dataclass(slots=True)
class CurrentUserContext:
username: str
name: str
role_codes: list[str]
is_admin: bool
department_name: str = ""
cost_center: str = ""
def get_current_user(
x_auth_username: Annotated[
str | None,
@@ -46,34 +47,75 @@ def get_current_user(
str | None,
Header(description="当前登录人的所属部门。"),
] = None,
x_auth_cost_center: Annotated[
str | None,
Header(description="当前登录人的成本中心。"),
] = None,
) -> CurrentUserContext:
role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()]
is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"}
username = (x_auth_username or "").strip()
name = (x_auth_name or username).strip()
if not username and not name:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="请先登录后再访问知识库。",
)
return CurrentUserContext(
username=username or name,
role_codes = [
_normalize_role_code(item)
for item in (x_auth_role_codes or "").split(",")
if _normalize_role_code(item)
]
username = (x_auth_username or "").strip()
name = (x_auth_name or username).strip()
is_admin = _resolve_platform_admin_flag(
username=username,
name=name,
role_codes=role_codes,
header_value=x_auth_is_admin,
)
if not username and not name:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="请先登录后再访问知识库。",
)
return CurrentUserContext(
username=username or name,
name=name or username,
role_codes=role_codes,
is_admin=is_admin,
department_name=(x_auth_department or "").strip(),
cost_center=(x_auth_cost_center or "").strip(),
)
def _normalize_role_code(value: str | None) -> str:
role_code = str(value or "").strip().lower()
if role_code == "auditor":
return "budget_monitor"
return role_code
def _current_user_role_codes(current_user: CurrentUserContext) -> set[str]:
return {_normalize_role_code(item) for item in current_user.role_codes if _normalize_role_code(item)}
def _resolve_platform_admin_flag(
*,
username: str,
name: str,
role_codes: list[str],
header_value: str | None,
) -> bool:
if str(header_value or "").strip().lower() in {"1", "true", "yes", "on"}:
return True
identities = {
str(username or "").strip().lower(),
str(name or "").strip().lower(),
}
return "admin" in identities or "admin" in {_normalize_role_code(item) for item in role_codes}
def require_admin_user(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> CurrentUserContext:
if current_user.is_admin or "manager" in current_user.role_codes:
if current_user.is_admin or "manager" in _current_user_role_codes(current_user):
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有管理员可以上传、删除或修改知识库文件。",
@@ -95,24 +137,58 @@ def require_platform_admin_user(
def require_rule_editor_user(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> CurrentUserContext:
role_codes = {item.strip() for item in current_user.role_codes}
role_codes = _current_user_role_codes(current_user)
if current_user.is_admin or "manager" in role_codes or "finance" in role_codes:
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有财务人员或高级管理人员可以编辑规则草稿。",
detail="只有财务人员或高级财务人员可以编辑规则草稿。",
)
def require_rule_reviewer_user(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> CurrentUserContext:
role_codes = {item.strip() for item in current_user.role_codes}
role_codes = _current_user_role_codes(current_user)
if current_user.is_admin or "manager" in role_codes:
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有高级管理人员或 admin 管理员可以执行该操作。",
detail="只有高级财务人员或 admin 管理员可以执行该操作。",
)
def require_budget_viewer_user(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> CurrentUserContext:
role_codes = _current_user_role_codes(current_user)
if current_user.is_admin or role_codes & {"budget_monitor", "executive"}:
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有预算监控员或高级财务人员可以查看预算中心。",
)
def require_budget_editor_user(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> CurrentUserContext:
role_codes = _current_user_role_codes(current_user)
if current_user.is_admin or "executive" in role_codes:
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有 admin 管理员或高级财务人员可以维护预算额度。",
)
def is_budget_scope_limited_user(current_user: CurrentUserContext) -> bool:
if current_user.is_admin:
return False
role_codes = _current_user_role_codes(current_user)
return "budget_monitor" in role_codes and "executive" not in role_codes

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,

View File

@@ -5,6 +5,7 @@ from app.api.v1.endpoints.agent_runs import router as agent_runs_router
from app.api.v1.endpoints.audit_logs import router as audit_logs_router
from app.api.v1.endpoints.auth import router as auth_router
from app.api.v1.endpoints.bootstrap import router as bootstrap_router
from app.api.v1.endpoints.budgets import router as budgets_router
from app.api.v1.endpoints.employees import router as employees_router
from app.api.v1.endpoints.health import router as health_router
from app.api.v1.endpoints.knowledge import router as knowledge_router
@@ -19,6 +20,7 @@ router = APIRouter()
router.include_router(health_router, tags=["health"])
router.include_router(bootstrap_router, tags=["bootstrap"])
router.include_router(auth_router, tags=["auth"])
router.include_router(budgets_router, tags=["budgets"])
router.include_router(agent_assets_router, tags=["agent-assets"])
router.include_router(agent_runs_router, tags=["agent-runs"])
router.include_router(audit_logs_router, tags=["audit-logs"])