feat: 新增预算后端服务与差旅风险规则库
后端新增预算模型、端点和服务模块,支持预算 CRUD 和余额 查询,清理旧生成规则文件并替换为按严重等级分类的差旅风 险规则库,优化认证权限和报销单访问策略,新增财务规则目 录和演示数据构建脚本,前端预算中心增加对话框交互,完善 审计页面运行时模型和元数据展示,补充单元测试。
This commit is contained in:
@@ -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,
|
||||
|
||||
338
server/src/app/api/v1/endpoints/budgets.py
Normal file
338
server/src/app/api/v1/endpoints/budgets.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user