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"])

View File

@@ -4,6 +4,7 @@ from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetTestR
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.models.approval import ApprovalRecord
from app.models.audit_log import AuditLog
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.employee_change_log import EmployeeChangeLog
from app.models.employee import Employee
from app.models.financial_record import (
@@ -33,6 +34,9 @@ __all__ = [
"AgentToolCall",
"ApprovalRecord",
"AuditLog",
"BudgetAllocation",
"BudgetReservation",
"BudgetTransaction",
"Employee",
"EmployeeChangeLog",
"ExpenseClaim",

View File

@@ -3,6 +3,7 @@ from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersi
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.models.approval import ApprovalRecord
from app.models.audit_log import AuditLog
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.employee_change_log import EmployeeChangeLog
from app.models.employee import Employee
from app.models.financial_record import (
@@ -32,6 +33,9 @@ __all__ = [
"AgentToolCall",
"ApprovalRecord",
"AuditLog",
"BudgetAllocation",
"BudgetReservation",
"BudgetTransaction",
"Employee",
"EmployeeChangeLog",
"ExpenseClaim",

View File

@@ -0,0 +1,115 @@
from __future__ import annotations
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import DateTime, ForeignKey, Index, Numeric, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
from app.db.base_class import Base
class BudgetAllocation(Base):
__tablename__ = "budget_allocations"
__table_args__ = (
UniqueConstraint(
"fiscal_year",
"period_key",
"department_id",
"cost_center",
"project_code",
"subject_code",
name="uq_budget_allocation_dimension",
),
Index("ix_budget_allocations_dimension", "fiscal_year", "period_key", "subject_code"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
budget_no: Mapped[str] = mapped_column(String(50), unique=True, index=True)
fiscal_year: Mapped[int] = mapped_column(index=True)
period_type: Mapped[str] = mapped_column(String(20), default="quarter", index=True)
period_key: Mapped[str] = mapped_column(String(30), index=True)
department_id: Mapped[str | None] = mapped_column(
ForeignKey("organization_units.id"), nullable=True, index=True
)
department_name: Mapped[str] = mapped_column(String(100), index=True)
cost_center: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
project_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
subject_code: Mapped[str] = mapped_column(String(50), index=True)
subject_name: Mapped[str] = mapped_column(String(100))
original_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=Decimal("0.00"))
adjusted_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=Decimal("0.00"))
status: Mapped[str] = mapped_column(String(30), default="active", index=True)
warning_threshold: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("80.00"))
control_action: Mapped[str] = mapped_column(String(30), default="block")
description: Mapped[str | None] = mapped_column(Text(), nullable=True)
created_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
transactions = relationship("BudgetTransaction", back_populates="allocation")
reservations = relationship("BudgetReservation", back_populates="allocation")
class BudgetReservation(Base):
__tablename__ = "budget_reservations"
__table_args__ = (
Index("ix_budget_reservations_source", "source_type", "source_id"),
Index("ix_budget_reservations_status", "allocation_id", "source_status"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
reservation_no: Mapped[str] = mapped_column(String(50), unique=True, index=True)
allocation_id: Mapped[str] = mapped_column(ForeignKey("budget_allocations.id"), index=True)
source_type: Mapped[str] = mapped_column(String(40), index=True)
source_id: Mapped[str] = mapped_column(String(64), index=True)
source_no: Mapped[str] = mapped_column(String(80), index=True)
source_status: Mapped[str] = mapped_column(String(30), default="active", index=True)
amount: Mapped[Decimal] = mapped_column(Numeric(14, 2))
consumed_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=Decimal("0.00"))
released_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=Decimal("0.00"))
context_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
released_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
allocation = relationship("BudgetAllocation", back_populates="reservations")
transactions = relationship("BudgetTransaction", back_populates="reservation")
class BudgetTransaction(Base):
__tablename__ = "budget_transactions"
__table_args__ = (
Index("ix_budget_transactions_allocation_created", "allocation_id", "created_at"),
Index("ix_budget_transactions_source", "source_type", "source_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
transaction_no: Mapped[str] = mapped_column(String(50), unique=True, index=True)
allocation_id: Mapped[str] = mapped_column(ForeignKey("budget_allocations.id"), index=True)
reservation_id: Mapped[str | None] = mapped_column(
ForeignKey("budget_reservations.id"), nullable=True, index=True
)
source_type: Mapped[str] = mapped_column(String(40), index=True)
source_id: Mapped[str] = mapped_column(String(64), index=True)
source_no: Mapped[str] = mapped_column(String(80), index=True)
transaction_type: Mapped[str] = mapped_column(String(30), index=True)
amount: Mapped[Decimal] = mapped_column(Numeric(14, 2))
before_available_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2))
after_available_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2))
operator: Mapped[str | None] = mapped_column(String(100), nullable=True)
reason: Mapped[str | None] = mapped_column(Text(), nullable=True)
context_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
allocation = relationship("BudgetAllocation", back_populates="transactions")
reservation = relationship("BudgetReservation", back_populates="transactions")

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
class BudgetAllocationCreate(BaseModel):
fiscal_year: int = Field(ge=2000, le=2100)
period_type: str = Field(default="quarter", max_length=20)
period_key: str = Field(min_length=1, max_length=30)
department_id: str | None = Field(default=None, max_length=36)
department_name: str = Field(min_length=1, max_length=100)
cost_center: str | None = Field(default=None, max_length=50)
project_code: str | None = Field(default=None, max_length=50)
subject_code: str = Field(min_length=1, max_length=50)
subject_name: str = Field(min_length=1, max_length=100)
original_amount: Decimal = Field(ge=0)
warning_threshold: Decimal = Field(default=Decimal("80.00"), ge=0, le=100)
control_action: str = Field(default="block", max_length=30)
description: str | None = Field(default=None, max_length=500)
class BudgetCheckRequest(BaseModel):
fiscal_year: int | None = Field(default=None, ge=2000, le=2100)
period_key: str | None = Field(default=None, max_length=30)
department_id: str | None = Field(default=None, max_length=36)
department_name: str | None = Field(default=None, max_length=100)
cost_center: str | None = Field(default=None, max_length=50)
project_code: str | None = Field(default=None, max_length=50)
subject_code: str = Field(min_length=1, max_length=50)
amount: Decimal = Field(ge=0)
class BudgetOperationRequest(BudgetCheckRequest):
source_type: str = Field(min_length=1, max_length=40)
source_id: str = Field(min_length=1, max_length=64)
source_no: str = Field(min_length=1, max_length=80)
reason: str | None = Field(default=None, max_length=500)
class BudgetBalanceRead(BaseModel):
total_amount: Decimal
reserved_amount: Decimal
consumed_amount: Decimal
available_amount: Decimal
usage_rate: Decimal
class BudgetAllocationRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
budget_no: str
fiscal_year: int
period_type: str
period_key: str
department_id: str | None
department_name: str
cost_center: str | None
project_code: str | None
subject_code: str
subject_name: str
original_amount: Decimal
adjusted_amount: Decimal
status: str
warning_threshold: Decimal
control_action: str
description: str | None = None
balance: BudgetBalanceRead
created_at: datetime
updated_at: datetime
class BudgetTransactionRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
transaction_no: str
allocation_id: str
reservation_id: str | None
source_type: str
source_id: str
source_no: str
transaction_type: str
amount: Decimal
before_available_amount: Decimal
after_available_amount: Decimal
operator: str | None
reason: str | None
created_at: datetime
class BudgetSummaryRead(BaseModel):
fiscal_year: int | None = None
period_key: str | None = None
total_amount: Decimal
reserved_amount: Decimal
consumed_amount: Decimal
available_amount: Decimal
warning_count: int
over_budget_count: int
allocations: list[BudgetAllocationRead] = Field(default_factory=list)
class BudgetCheckRead(BaseModel):
passed: bool
blocking_reasons: list[str] = Field(default_factory=list)
flags: list[dict] = Field(default_factory=list)
allocation: BudgetAllocationRead | None = None
class BudgetOperationRead(BaseModel):
ok: bool
message: str
reservation_id: str | None = None
allocation: BudgetAllocationRead | None = None
transaction: BudgetTransactionRead | None = None
flags: list[dict] = Field(default_factory=list)

View File

@@ -23,7 +23,7 @@ class SettingsCompanyForm(BaseModel):
class SettingsAdminForm(BaseModel):
adminAccount: str = Field(min_length=1, max_length=120)
adminEmail: str = Field(min_length=1, max_length=255)
adminEmail: str = Field(default="", max_length=255)
newPassword: str = Field(default="", max_length=128)
confirmPassword: str = Field(default="", max_length=128)
sessionTimeout: int = Field(default=30, ge=5, le=240)

View File

@@ -342,6 +342,10 @@ class AgentAssetSpreadsheetManager:
]
)
@staticmethod
def build_rule_workbook(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
return _build_xlsx_bytes(sheets)
@staticmethod
def build_blank_rule_workbook(sheet_name: str = "规则配置") -> bytes:
return _build_xlsx_bytes([(sheet_name, [[""]])])

View File

@@ -1,12 +1,8 @@
from __future__ import annotations
import hashlib
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from pathlib import Path
from datetime import UTC, datetime
from sqlalchemy import inspect, select, text
from sqlalchemy import select
from app.core.agent_enums import (
AgentAssetContentType,
@@ -14,34 +10,15 @@ from app.core.agent_enums import (
AgentAssetStatus,
AgentAssetType,
AgentName,
AgentPermissionLevel,
AgentReviewStatus,
AgentRunSource,
AgentRunStatus,
AgentToolType,
)
from app.core.logging import get_logger
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.models.audit_log import AuditLog
from app.models.financial_record import (
AccountsPayableRecord,
AccountsReceivableRecord,
ExpenseClaim,
ExpenseClaimItem,
)
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import (
AgentAssetSpreadsheetManager,
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
FINANCE_RULES_LIBRARY,
RISK_RULES_LIBRARY,
)
from app.services.expense_rule_runtime import (
build_scene_submission_standard_markdown,
build_travel_risk_control_standard_markdown,
AgentAssetSpreadsheetManager,
)
from app.services.agent_foundation_constants import (
ATTACHMENT_RULE_ASSET_CODE,
@@ -50,13 +27,7 @@ from app.services.agent_foundation_constants import (
COMPANY_COMMUNICATION_RULE_VERSION,
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
COMPANY_TRAVEL_RULE_VERSION,
DEMO_EXPENSE_CLAIM_SIGNATURES,
DEMO_PAYABLE_SIGNATURES,
DEMO_RECEIVABLE_SIGNATURES,
LEGACY_RULE_CODES,
PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
)
from app.core.logging import get_logger
logger = get_logger("app.services.agent_foundation")
@@ -352,6 +323,8 @@ class AgentFoundationAssetSeedMixin:
actor_name="系统初始化",
)
self._hide_deprecated_finance_rule_assets()
self.db.add_all(
[
AgentAssetVersion(

View File

@@ -1,12 +1,8 @@
from __future__ import annotations
import hashlib
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from pathlib import Path
from datetime import UTC, datetime
from sqlalchemy import inspect, select, text
from sqlalchemy import select
from app.core.agent_enums import (
AgentAssetContentType,
@@ -14,34 +10,15 @@ from app.core.agent_enums import (
AgentAssetStatus,
AgentAssetType,
AgentName,
AgentPermissionLevel,
AgentReviewStatus,
AgentRunSource,
AgentRunStatus,
AgentToolType,
)
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.models.audit_log import AuditLog
from app.models.financial_record import (
AccountsPayableRecord,
AccountsReceivableRecord,
ExpenseClaim,
ExpenseClaimItem,
)
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.core.logging import get_logger
from app.models.agent_asset import AgentAsset
from app.services.agent_asset_spreadsheet import (
AgentAssetSpreadsheetManager,
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
FINANCE_RULES_LIBRARY,
RISK_RULES_LIBRARY,
)
from app.services.expense_rule_runtime import (
build_scene_submission_standard_markdown,
build_travel_risk_control_standard_markdown,
AgentAssetSpreadsheetManager,
)
from app.services.agent_foundation_constants import (
ATTACHMENT_RULE_ASSET_CODE,
@@ -50,13 +27,7 @@ from app.services.agent_foundation_constants import (
COMPANY_COMMUNICATION_RULE_VERSION,
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
COMPANY_TRAVEL_RULE_VERSION,
DEMO_EXPENSE_CLAIM_SIGNATURES,
DEMO_PAYABLE_SIGNATURES,
DEMO_RECEIVABLE_SIGNATURES,
LEGACY_RULE_CODES,
PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
)
from app.core.logging import get_logger
logger = get_logger("app.services.agent_foundation")
@@ -509,6 +480,8 @@ class AgentFoundationAssetTopUpMixin:
reviewed_at=datetime.now(UTC),
)
self._hide_deprecated_finance_rule_assets()
if "skill.ar.aging_summary" not in existing_codes:
asset = self._create_seed_asset(

View File

@@ -82,9 +82,9 @@ COMPANY_TRAVEL_RULE_VERSION = "v1.0.0"
COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0"
COMPANY_TRAVEL_RULE_SCENARIO_JSON = ("差旅",)
COMPANY_TRAVEL_RULE_SCENARIO_JSON = ("差旅",)
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("用科目",)
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("通信",)
ATTACHMENT_RULE_RUNTIME_CONFIG = {

View File

@@ -23,6 +23,21 @@ from app.services.agent_foundation_constants import (
logger = get_logger("app.services.agent_foundation")
EXPENSE_TYPE_SCENARIO_LABELS = {
"travel": "差旅费",
"hotel": "住宿费",
"transport": "交通费",
"meal": "业务招待费",
"meeting": "会务费",
"marketing": "市场推广费",
"office": "办公用品费",
"training": "培训费",
"software": "软件服务费",
"communication": "通信费",
"welfare": "福利费",
}
class AgentFoundationRiskRuleMixin:
def _iter_platform_risk_manifests(self) -> list[tuple[str, dict[str, object]]]:
@@ -123,8 +138,54 @@ class AgentFoundationRiskRuleMixin:
return "通用"
@staticmethod
def _resolve_manifest_expense_types(manifest: dict[str, object]) -> list[str]:
def _collect(value: object) -> list[str]:
if isinstance(value, str):
return [value]
if isinstance(value, (list, tuple, set)):
return [str(item or "").strip() for item in value]
return []
candidates: list[str] = []
candidates.extend(_collect(manifest.get("expense_types")))
applies_to = (
manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {}
)
candidates.extend(_collect(applies_to.get("expense_types")))
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
candidates.extend(_collect(metadata.get("expense_types")))
normalized: list[str] = []
seen: set[str] = set()
for item in candidates:
value = item.strip().lower()
if not value or value in seen:
continue
seen.add(value)
normalized.append(value)
return normalized
@staticmethod
def _expense_type_scenario_labels(expense_types: list[str]) -> list[str]:
labels: list[str] = []
seen: set[str] = set()
for expense_type in expense_types:
label = EXPENSE_TYPE_SCENARIO_LABELS.get(expense_type)
if not label or label in seen:
continue
seen.add(label)
labels.append(label)
return labels
def _platform_risk_scenario_json(self, manifest: dict[str, object]) -> list[str]:
labels = self._expense_type_scenario_labels(self._resolve_manifest_expense_types(manifest))
if labels:
return labels
category = self._resolve_platform_risk_category(manifest)
return [category] if category else ["通用"]
@@ -139,7 +200,7 @@ class AgentFoundationRiskRuleMixin:
risk_category = self._resolve_platform_risk_category(manifest)
return {
config = {
"severity": str(fail_outcome.get("severity") or "medium"),
@@ -176,6 +237,20 @@ class AgentFoundationRiskRuleMixin:
),
}
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
for key in (
"finance_rule_code",
"finance_rule_sheet",
"business_stage",
"expense_types",
"budget_required",
):
value = manifest.get(key)
if value is None and isinstance(metadata, dict):
value = metadata.get(key)
if value is not None:
config[key] = value
return config
def _build_platform_risk_seed_assets(self) -> list[AgentAsset]:
@@ -242,6 +317,12 @@ class AgentFoundationRiskRuleMixin:
before_count = len(existing_codes)
self._ensure_platform_risk_rules_from_library(existing_codes)
manifest_codes = {
str(manifest.get("rule_code") or "").strip()
for _, manifest in self._iter_platform_risk_manifests()
if str(manifest.get("rule_code") or "").strip()
}
self._hide_stale_demo_risk_rules(manifest_codes)
self.db.flush()
@@ -265,6 +346,25 @@ class AgentFoundationRiskRuleMixin:
return manifest_count
def _hide_stale_demo_risk_rules(self, manifest_codes: set[str]) -> None:
assets = self.db.scalars(
select(AgentAsset).where(AgentAsset.asset_type == AgentAssetType.RULE.value)
).all()
for asset in assets:
config = asset.config_json if isinstance(asset.config_json, dict) else {}
if config.get("source_ref") != "费用管控 Demo 风险规则库":
continue
if asset.code in manifest_codes:
continue
asset.status = AgentAssetStatus.DISABLED.value
asset.config_json = {
**config,
"enabled": False,
"tag": "废弃风险规则",
"deprecated": True,
"deprecated_reason": "对应风险规则 JSON 已删除,不再参与费用管控 Demo。",
}
def _ensure_platform_risk_rules_from_library(self, existing_codes: set[str]) -> None:
for file_name, manifest in self._iter_platform_risk_manifests():

View File

@@ -1,66 +1,115 @@
from __future__ import annotations
import hashlib
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from pathlib import Path
from sqlalchemy import inspect, select, text
from sqlalchemy import select
from app.core.agent_enums import (
AgentAssetContentType,
AgentAssetDomain,
AgentAssetStatus,
AgentAssetType,
AgentName,
AgentPermissionLevel,
AgentReviewStatus,
AgentRunSource,
AgentRunStatus,
AgentToolType,
)
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.models.audit_log import AuditLog
from app.models.financial_record import (
AccountsPayableRecord,
AccountsReceivableRecord,
ExpenseClaim,
ExpenseClaimItem,
)
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.core.logging import get_logger
from app.models.agent_asset import AgentAsset
from app.services.agent_asset_spreadsheet import (
AgentAssetSpreadsheetManager,
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
FINANCE_RULES_LIBRARY,
RISK_RULES_LIBRARY,
)
from app.services.expense_rule_runtime import (
build_scene_submission_standard_markdown,
build_travel_risk_control_standard_markdown,
AgentAssetSpreadsheetManager,
)
from app.services.agent_foundation_constants import (
ATTACHMENT_RULE_ASSET_CODE,
ATTACHMENT_RULE_RUNTIME_CONFIG,
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON,
COMPANY_COMMUNICATION_RULE_VERSION,
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
COMPANY_TRAVEL_RULE_VERSION,
DEMO_EXPENSE_CLAIM_SIGNATURES,
DEMO_PAYABLE_SIGNATURES,
DEMO_RECEIVABLE_SIGNATURES,
LEGACY_RULE_CODES,
PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
)
from app.core.logging import get_logger
from app.services.finance_rule_catalog import (
DEPRECATED_FINANCE_RULE_CODES,
DEPRECATED_FINANCE_RULE_REPLACEMENTS,
)
logger = get_logger("app.services.agent_foundation")
class AgentFoundationSpreadsheetMixin:
def sync_finance_rule_assets_from_catalog(self) -> int:
synced_count = self._ensure_core_finance_rule_asset_metadata()
self._hide_deprecated_finance_rule_assets()
self.db.flush()
return synced_count
def _ensure_core_finance_rule_asset_metadata(self) -> int:
synced_count = 0
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
scenario_category=COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
finance_rule_sheet="差旅住宿费标准",
expense_types=["travel", "hotel", "transport"],
)
)
synced_count += int(
self._ensure_core_finance_rule_asset(
code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
scenario_category=COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
finance_rule_sheet="通信费报销标准",
expense_types=["communication"],
)
)
return synced_count
def _ensure_core_finance_rule_asset(
self,
*,
code: str,
scenario_category: str,
finance_rule_sheet: str,
expense_types: list[str],
) -> bool:
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code))
if asset is None:
return False
asset.scenario_json = [scenario_category]
asset.config_json = {
**(asset.config_json or {}),
"enabled": True,
"tag": "财务规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"scenario_category": scenario_category,
"ai_review_category": scenario_category,
"finance_rule_code": code,
"finance_rule_sheet": finance_rule_sheet,
"expense_types": expense_types,
"business_stage": ["expense_application", "reimbursement"],
"budget_required": True,
}
return True
def _hide_deprecated_finance_rule_assets(self) -> None:
for code in DEPRECATED_FINANCE_RULE_CODES:
asset = self.db.scalar(select(AgentAsset).where(AgentAsset.code == code))
if asset is None:
continue
asset.status = AgentAssetStatus.DISABLED.value
asset.scenario_json = ["已废弃"]
replacement = DEPRECATED_FINANCE_RULE_REPLACEMENTS.get(code)
deprecated_reason = (
"交通/住宿细分并入公司差旅费报销规则,不再作为独立财务规则展示。"
if replacement
else (
"该费用类型没有独立职务金额分档,额度控制转入预算中心,"
"不再作为独立财务规则表展示。"
)
)
asset.config_json = {
**(asset.config_json or {}),
"enabled": False,
"tag": "废弃规则",
"deprecated": True,
"deprecated_reason": deprecated_reason,
}
if replacement:
asset.config_json["replaced_by"] = replacement
def _ensure_company_travel_rule_spreadsheet_seed(
self,
@@ -251,6 +300,8 @@ class AgentFoundationSpreadsheetMixin:
fallback_sheet_name: str,
workbook_sheets: list[tuple[str, list[list[object]]]] | None = None,
):
manager = AgentAssetSpreadsheetManager()
@@ -271,6 +322,8 @@ class AgentFoundationSpreadsheetMixin:
fallback_sheet_name=fallback_sheet_name,
workbook_sheets=workbook_sheets,
),
actor_name=actor_name,
@@ -379,6 +432,8 @@ class AgentFoundationSpreadsheetMixin:
fallback_sheet_name: str,
workbook_sheets: list[tuple[str, list[list[object]]]] | None = None,
) -> bytes:
live_key = (
@@ -397,4 +452,8 @@ class AgentFoundationSpreadsheetMixin:
return live_path.read_bytes()
if workbook_sheets is not None:
return AgentAssetSpreadsheetManager.build_rule_workbook(workbook_sheets)
return AgentAssetSpreadsheetManager.build_blank_rule_workbook(fallback_sheet_name)

View File

@@ -22,9 +22,10 @@ logger = get_logger("app.services.auth")
ROLE_LABELS = {
"manager": "管理员",
"finance": "财务人员",
"executive": "高级管理人员",
"executive": "高级财务人员",
"approver": "审批负责人",
"auditor": "审计观察",
"budget_monitor": "预算监控",
"auditor": "预算监控员",
"user": "使用者",
}

View File

@@ -0,0 +1,776 @@
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.base import Base
from app.models.budget import BudgetAllocation, BudgetReservation
from app.models.financial_record import ExpenseClaim
from app.schemas.budget import (
BudgetAllocationCreate,
BudgetAllocationRead,
BudgetCheckRead,
BudgetCheckRequest,
BudgetOperationRequest,
BudgetOperationRead,
BudgetSummaryRead,
BudgetTransactionRead,
)
from app.services.budget_support import BudgetSupportMixin
from app.services.budget_types import BudgetControlError, SUPPORTED_BUDGET_SUBJECT_CODES
class BudgetService(BudgetSupportMixin):
def __init__(self, db: Session) -> None:
self.db = db
def ensure_budget_ready(self) -> None:
Base.metadata.create_all(bind=self.db.get_bind())
exists = self.db.scalar(select(BudgetAllocation.id).limit(1))
if exists:
return
self._seed_default_allocations()
def list_allocations(
self,
*,
fiscal_year: int | None = None,
period_key: str | None = None,
department_id: str | None = None,
department_name: str | None = None,
cost_center: str | None = None,
) -> list[BudgetAllocationRead]:
self.ensure_budget_ready()
stmt = select(BudgetAllocation).order_by(
BudgetAllocation.fiscal_year.desc(),
BudgetAllocation.period_key.asc(),
BudgetAllocation.department_name.asc(),
BudgetAllocation.subject_code.asc(),
).where(BudgetAllocation.subject_code.in_(SUPPORTED_BUDGET_SUBJECT_CODES))
if fiscal_year is not None:
stmt = stmt.where(BudgetAllocation.fiscal_year == fiscal_year)
if period_key:
stmt = stmt.where(BudgetAllocation.period_key == period_key)
if department_id:
stmt = stmt.where(BudgetAllocation.department_id == department_id)
if department_name:
stmt = stmt.where(BudgetAllocation.department_name == department_name)
if cost_center:
stmt = stmt.where(BudgetAllocation.cost_center == cost_center)
return [self.serialize_allocation(row) for row in self.db.scalars(stmt).all()]
def get_summary(
self,
*,
fiscal_year: int | None = None,
period_key: str | None = None,
department_id: str | None = None,
department_name: str | None = None,
cost_center: str | None = None,
) -> BudgetSummaryRead:
allocations = self.list_allocations(
fiscal_year=fiscal_year,
period_key=period_key,
department_id=department_id,
department_name=department_name,
cost_center=cost_center,
)
total_amount = sum((item.balance.total_amount for item in allocations), Decimal("0.00"))
reserved_amount = sum((item.balance.reserved_amount for item in allocations), Decimal("0.00"))
consumed_amount = sum((item.balance.consumed_amount for item in allocations), Decimal("0.00"))
available_amount = sum((item.balance.available_amount for item in allocations), Decimal("0.00"))
warning_count = sum(
1
for item in allocations
if item.balance.usage_rate >= item.warning_threshold
and item.balance.available_amount >= Decimal("0.00")
)
over_budget_count = sum(
1 for item in allocations if item.balance.available_amount < Decimal("0.00")
)
return BudgetSummaryRead(
fiscal_year=fiscal_year,
period_key=period_key,
total_amount=total_amount,
reserved_amount=reserved_amount,
consumed_amount=consumed_amount,
available_amount=available_amount,
warning_count=warning_count,
over_budget_count=over_budget_count,
allocations=allocations,
)
def create_or_update_allocation(
self,
payload: BudgetAllocationCreate,
*,
operator: str,
) -> BudgetAllocationRead:
self.ensure_budget_ready()
subject_code = self._normalize_subject_code(payload.subject_code)
if not self._is_supported_budget_subject(subject_code):
raise ValueError("demo 阶段预算中心只维护差旅、通信、招待费、办公用品四类预算。")
period_key = self._normalize_period_key(payload.fiscal_year, payload.period_key)
existing = self._find_exact_allocation(
fiscal_year=payload.fiscal_year,
period_key=period_key,
department_id=payload.department_id,
department_name=payload.department_name,
cost_center=payload.cost_center,
project_code=payload.project_code,
subject_code=subject_code,
)
if existing is None:
allocation = BudgetAllocation(
budget_no=self._make_no("BUD"),
fiscal_year=payload.fiscal_year,
period_type=self._normalize_period_type(payload.period_type),
period_key=period_key,
department_id=self._blank_to_none(payload.department_id),
department_name=payload.department_name.strip(),
cost_center=self._blank_to_none(payload.cost_center),
project_code=self._blank_to_none(payload.project_code),
subject_code=subject_code,
subject_name=payload.subject_name.strip(),
original_amount=self._money(payload.original_amount),
adjusted_amount=Decimal("0.00"),
status="active",
warning_threshold=self._percent(payload.warning_threshold),
control_action=self._normalize_control_action(payload.control_action),
description=self._blank_to_none(payload.description),
created_by=operator,
updated_by=operator,
)
self.db.add(allocation)
self.db.flush()
self._record_transaction(
allocation=allocation,
transaction_type="init",
amount=allocation.original_amount,
before_available=Decimal("0.00"),
after_available=self.get_balance(allocation).available_amount,
source_type="budget_allocation",
source_id=allocation.id,
source_no=allocation.budget_no,
operator=operator,
reason="初始化预算额度",
)
self.db.flush()
return self.serialize_allocation(allocation)
before_balance = self.get_balance(existing)
original_before = self._money(existing.original_amount)
existing.period_type = self._normalize_period_type(payload.period_type)
existing.department_name = payload.department_name.strip()
existing.cost_center = self._blank_to_none(payload.cost_center)
existing.project_code = self._blank_to_none(payload.project_code)
existing.subject_name = payload.subject_name.strip()
existing.original_amount = self._money(payload.original_amount)
existing.warning_threshold = self._percent(payload.warning_threshold)
existing.control_action = self._normalize_control_action(payload.control_action)
existing.description = self._blank_to_none(payload.description)
existing.updated_by = operator
self.db.flush()
amount_delta = self._money(existing.original_amount) - original_before
if amount_delta:
self._record_transaction(
allocation=existing,
transaction_type="adjust",
amount=amount_delta,
before_available=before_balance.available_amount,
after_available=self.get_balance(existing).available_amount,
source_type="budget_allocation",
source_id=existing.id,
source_no=existing.budget_no,
operator=operator,
reason="调整预算额度",
)
self.db.flush()
return self.serialize_allocation(existing)
def check(self, payload: BudgetCheckRequest) -> BudgetCheckRead:
self.ensure_budget_ready()
subject_code = self._normalize_subject_code(payload.subject_code)
if not self._is_supported_budget_subject(subject_code):
flag = self._build_budget_flag(
event_type="budget_control_skipped",
severity="low",
label="预算暂不管控",
message="demo 阶段该费用类型暂不纳入预算计算。",
amount=self._money(payload.amount),
extra={"subject_code": subject_code},
)
return BudgetCheckRead(passed=True, flags=[flag])
allocation = self._find_allocation_for_dimension(
fiscal_year=payload.fiscal_year,
period_key=payload.period_key,
department_id=payload.department_id,
department_name=payload.department_name,
cost_center=payload.cost_center,
project_code=payload.project_code,
subject_code=subject_code,
)
amount = self._money(payload.amount)
if allocation is None:
flag = self._build_budget_flag(
event_type="budget_missing",
severity="high",
label="预算归属缺失",
message="未找到匹配的预算额度,当前单据不能进入费用控制闭环。",
amount=amount,
)
return BudgetCheckRead(passed=False, blocking_reasons=[flag["message"]], flags=[flag])
review = self._review_allocation_amount(allocation, amount)
return BudgetCheckRead(
passed=not review["blocking_reasons"],
blocking_reasons=review["blocking_reasons"],
flags=review["flags"],
allocation=self.serialize_allocation(allocation),
)
def review_claim_budget(self, claim: ExpenseClaim) -> dict[str, list[Any]]:
self.ensure_budget_ready()
if not self._claim_uses_budget_control(claim):
return {"flags": [], "blocking_reasons": []}
allocation = self._find_allocation_for_claim(claim)
amount = self._money(claim.amount or Decimal("0.00"))
if allocation is None:
if self._budget_table_empty():
allocation = self._create_fallback_allocation_for_claim(claim)
else:
flag = self._build_budget_flag(
event_type="budget_missing",
severity="high",
label="预算归属缺失",
message=f"单据 {claim.claim_no} 未找到预算池额度,请先在预算中心建立预算。",
amount=amount,
)
return {"flags": [flag], "blocking_reasons": [flag["message"]]}
review = self._review_allocation_amount(allocation, amount)
return {"flags": review["flags"], "blocking_reasons": review["blocking_reasons"]}
def reserve_for_claim(
self,
claim: ExpenseClaim,
*,
source_type: str,
operator: str,
) -> list[dict[str, Any]]:
self.ensure_budget_ready()
if not self._claim_uses_budget_control(claim):
return []
existing = self._find_active_reservation(source_type=source_type, source_id=claim.id)
if existing is not None:
return [
self._build_operation_flag(
existing.allocation,
event_type="budget_reserve_reused",
label="预算预占已存在",
message=f"单据 {claim.claim_no} 已存在有效预算预占,本次提交复用原预占。",
amount=existing.amount,
reservation_id=existing.id,
)
]
allocation = self._find_allocation_for_claim(claim)
if allocation is None and self._budget_table_empty():
allocation = self._create_fallback_allocation_for_claim(claim)
if allocation is None:
raise BudgetControlError([f"单据 {claim.claim_no} 未找到预算池额度,请先建立预算。"])
amount = self._money(claim.amount or Decimal("0.00"))
review = self._review_allocation_amount(allocation, amount)
if review["blocking_reasons"]:
raise BudgetControlError(review["blocking_reasons"], flags=review["flags"])
before_balance = self.get_balance(allocation)
reservation = BudgetReservation(
reservation_no=self._make_no("BRS"),
allocation_id=allocation.id,
source_type=source_type,
source_id=claim.id,
source_no=claim.claim_no,
source_status="active",
amount=amount,
context_json=self._claim_context(claim),
)
self.db.add(reservation)
self.db.flush()
after_balance = self.get_balance(allocation)
transaction = self._record_transaction(
allocation=allocation,
reservation=reservation,
transaction_type="reserve",
amount=amount,
before_available=before_balance.available_amount,
after_available=after_balance.available_amount,
source_type=source_type,
source_id=claim.id,
source_no=claim.claim_no,
operator=operator,
reason="单据提交预算预占",
)
self.db.flush()
flag = self._build_operation_flag(
allocation,
event_type="budget_reserved",
label="预算已预占",
message=f"已为单据 {claim.claim_no} 预占预算 {amount} 元。",
amount=amount,
reservation_id=reservation.id,
transaction_id=transaction.id,
)
return [*review["flags"], flag]
def release_for_claim(
self,
claim: ExpenseClaim,
*,
source_type: str,
operator: str,
reason: str,
) -> list[dict[str, Any]]:
self.ensure_budget_ready()
reservations = self._find_active_reservations(source_type=source_type, source_id=claim.id)
flags: list[dict[str, Any]] = []
for reservation in reservations:
allocation = reservation.allocation
before_balance = self.get_balance(allocation)
amount = self._money(reservation.amount)
reservation.source_status = "released"
reservation.released_amount = amount
reservation.released_at = datetime.now(UTC)
self.db.flush()
after_balance = self.get_balance(allocation)
transaction = self._record_transaction(
allocation=allocation,
reservation=reservation,
transaction_type="release",
amount=amount,
before_available=before_balance.available_amount,
after_available=after_balance.available_amount,
source_type=source_type,
source_id=claim.id,
source_no=claim.claim_no,
operator=operator,
reason=reason,
)
flags.append(
self._build_operation_flag(
allocation,
event_type="budget_released",
label="预算预占已释放",
message=f"单据 {claim.claim_no} 已释放预算预占 {amount} 元。",
amount=amount,
reservation_id=reservation.id,
transaction_id=transaction.id,
)
)
self.db.flush()
return flags
def consume_for_claim(
self,
claim: ExpenseClaim,
*,
operator: str,
reason: str,
) -> dict[str, Any] | None:
self.ensure_budget_ready()
if not self._claim_uses_budget_control(claim):
return None
reservations = self._find_active_reservations(source_type="claim", source_id=claim.id)
amount = self._money(claim.amount or Decimal("0.00"))
if reservations:
reservation = reservations[0]
allocation = reservation.allocation
before_balance = self.get_balance(allocation)
reserved_amount = self._money(reservation.amount)
consume_amount = min(amount, reserved_amount)
release_amount = max(reserved_amount - consume_amount, Decimal("0.00"))
reservation.source_status = "consumed"
reservation.consumed_amount = consume_amount
reservation.released_amount = release_amount
reservation.consumed_at = datetime.now(UTC)
self.db.flush()
after_balance = self.get_balance(allocation)
transaction = self._record_transaction(
allocation=allocation,
reservation=reservation,
transaction_type="consume",
amount=consume_amount,
before_available=before_balance.available_amount,
after_available=after_balance.available_amount,
source_type="claim",
source_id=claim.id,
source_no=claim.claim_no,
operator=operator,
reason=reason,
)
self.db.flush()
return self._build_operation_flag(
allocation,
event_type="budget_consumed",
label="预算已核销",
message=f"单据 {claim.claim_no} 已核销预算 {consume_amount} 元。",
amount=consume_amount,
reservation_id=reservation.id,
transaction_id=transaction.id,
)
allocation = self._find_allocation_for_claim(claim)
if allocation is None:
return self._build_budget_flag(
event_type="budget_consume_skipped",
severity="low",
label="预算未核销",
message=f"单据 {claim.claim_no} 未找到预算池额度,按存量单据兼容放行。",
amount=amount,
)
review = self._review_allocation_amount(allocation, amount)
if review["blocking_reasons"]:
raise BudgetControlError(review["blocking_reasons"], flags=review["flags"])
before_balance = self.get_balance(allocation)
transaction = self._record_transaction(
allocation=allocation,
transaction_type="consume",
amount=amount,
before_available=before_balance.available_amount,
after_available=before_balance.available_amount - amount,
source_type="claim",
source_id=claim.id,
source_no=claim.claim_no,
operator=operator,
reason=reason,
)
self.db.flush()
return self._build_operation_flag(
allocation,
event_type="budget_consumed",
label="预算已核销",
message=f"单据 {claim.claim_no} 已核销预算 {amount} 元。",
amount=amount,
transaction_id=transaction.id,
)
def transfer_application_reservation(
self,
*,
application_claim: ExpenseClaim,
draft_claim: ExpenseClaim,
operator: str,
) -> dict[str, Any] | None:
self.ensure_budget_ready()
reservation = self._find_active_reservation(
source_type="application",
source_id=application_claim.id,
)
if reservation is None:
return None
allocation = reservation.allocation
before_balance = self.get_balance(allocation)
reservation.source_type = "claim"
reservation.source_id = draft_claim.id
reservation.source_no = draft_claim.claim_no
context = dict(reservation.context_json or {})
context["application_claim_id"] = application_claim.id
context["application_claim_no"] = application_claim.claim_no
context["draft_claim_id"] = draft_claim.id
context["draft_claim_no"] = draft_claim.claim_no
reservation.context_json = context
self.db.flush()
transaction = self._record_transaction(
allocation=allocation,
reservation=reservation,
transaction_type="transfer",
amount=Decimal("0.00"),
before_available=before_balance.available_amount,
after_available=self.get_balance(allocation).available_amount,
source_type="claim",
source_id=draft_claim.id,
source_no=draft_claim.claim_no,
operator=operator,
reason="申请审批通过后预算预占转入报销草稿",
)
self.db.flush()
return self._build_operation_flag(
allocation,
event_type="budget_reservation_transferred",
label="预算预占已转入报销",
message=f"申请 {application_claim.claim_no} 的预算预占已转入报销草稿 {draft_claim.claim_no}",
amount=reservation.amount,
reservation_id=reservation.id,
transaction_id=transaction.id,
)
def execute_operation(
self,
payload: BudgetOperationRequest,
*,
transaction_type: str,
operator: str,
) -> BudgetOperationRead:
self.ensure_budget_ready()
normalized_type = str(transaction_type or "").strip().lower()
subject_code = self._normalize_subject_code(payload.subject_code)
if not self._is_supported_budget_subject(subject_code):
raise BudgetControlError(["demo 阶段该费用类型暂不纳入预算计算。"])
allocation = self._find_allocation_for_dimension(
fiscal_year=payload.fiscal_year,
period_key=payload.period_key,
department_id=payload.department_id,
department_name=payload.department_name,
cost_center=payload.cost_center,
project_code=payload.project_code,
subject_code=subject_code,
)
if allocation is None:
raise BudgetControlError(["未找到匹配的预算额度。"])
amount = self._money(payload.amount)
if normalized_type == "reserve":
return self._execute_manual_reserve_operation(payload, allocation, amount, operator)
if normalized_type == "release":
return self._execute_manual_release_operation(payload, operator)
if normalized_type == "consume":
return self._execute_manual_consume_operation(payload, allocation, amount, operator)
before_balance = self.get_balance(allocation)
transaction = self._record_transaction(
allocation=allocation,
transaction_type=normalized_type,
amount=amount,
before_available=before_balance.available_amount,
after_available=before_balance.available_amount - amount,
source_type=payload.source_type,
source_id=payload.source_id,
source_no=payload.source_no,
operator=operator,
reason=payload.reason,
)
self.db.flush()
return BudgetOperationRead(
ok=True,
message="预算操作已记录。",
allocation=self.serialize_allocation(allocation),
transaction=BudgetTransactionRead.model_validate(transaction),
)
def _execute_manual_reserve_operation(
self,
payload: BudgetOperationRequest,
allocation: BudgetAllocation,
amount: Decimal,
operator: str,
) -> BudgetOperationRead:
existing = self._find_active_reservation(
source_type=payload.source_type,
source_id=payload.source_id,
)
if existing is not None:
flag = self._build_operation_flag(
existing.allocation,
event_type="budget_reserve_reused",
label="预算预占已存在",
message=f"来源 {payload.source_no} 已存在有效预算预占。",
amount=existing.amount,
reservation_id=existing.id,
)
return BudgetOperationRead(
ok=True,
message="预算预占已存在。",
reservation_id=existing.id,
allocation=self.serialize_allocation(existing.allocation),
flags=[flag],
)
review = self._review_allocation_amount(allocation, amount)
if review["blocking_reasons"]:
raise BudgetControlError(review["blocking_reasons"], flags=review["flags"])
before_balance = self.get_balance(allocation)
reservation = BudgetReservation(
reservation_no=self._make_no("BRS"),
allocation_id=allocation.id,
source_type=payload.source_type,
source_id=payload.source_id,
source_no=payload.source_no,
source_status="active",
amount=amount,
context_json={"manual_operation": True},
)
self.db.add(reservation)
self.db.flush()
after_balance = self.get_balance(allocation)
transaction = self._record_transaction(
allocation=allocation,
reservation=reservation,
transaction_type="reserve",
amount=amount,
before_available=before_balance.available_amount,
after_available=after_balance.available_amount,
source_type=payload.source_type,
source_id=payload.source_id,
source_no=payload.source_no,
operator=operator,
reason=payload.reason,
)
self.db.flush()
flag = self._build_operation_flag(
allocation,
event_type="budget_reserved",
label="预算已预占",
message=f"来源 {payload.source_no} 已预占预算 {amount} 元。",
amount=amount,
reservation_id=reservation.id,
transaction_id=transaction.id,
)
return BudgetOperationRead(
ok=True,
message="预算预占已记录。",
reservation_id=reservation.id,
allocation=self.serialize_allocation(allocation),
transaction=BudgetTransactionRead.model_validate(transaction),
flags=[*review["flags"], flag],
)
def _execute_manual_release_operation(
self,
payload: BudgetOperationRequest,
operator: str,
) -> BudgetOperationRead:
reservation = self._find_active_reservation(
source_type=payload.source_type,
source_id=payload.source_id,
)
if reservation is None:
raise BudgetControlError(["未找到可释放的预算预占。"])
allocation = reservation.allocation
before_balance = self.get_balance(allocation)
amount = self._money(reservation.amount)
reservation.source_status = "released"
reservation.released_amount = amount
reservation.released_at = datetime.now(UTC)
self.db.flush()
after_balance = self.get_balance(allocation)
transaction = self._record_transaction(
allocation=allocation,
reservation=reservation,
transaction_type="release",
amount=amount,
before_available=before_balance.available_amount,
after_available=after_balance.available_amount,
source_type=payload.source_type,
source_id=payload.source_id,
source_no=payload.source_no,
operator=operator,
reason=payload.reason,
)
self.db.flush()
flag = self._build_operation_flag(
allocation,
event_type="budget_released",
label="预算预占已释放",
message=f"来源 {payload.source_no} 已释放预算预占 {amount} 元。",
amount=amount,
reservation_id=reservation.id,
transaction_id=transaction.id,
)
return BudgetOperationRead(
ok=True,
message="预算释放已记录。",
reservation_id=reservation.id,
allocation=self.serialize_allocation(allocation),
transaction=BudgetTransactionRead.model_validate(transaction),
flags=[flag],
)
def _execute_manual_consume_operation(
self,
payload: BudgetOperationRequest,
allocation: BudgetAllocation,
amount: Decimal,
operator: str,
) -> BudgetOperationRead:
reservation = self._find_active_reservation(
source_type=payload.source_type,
source_id=payload.source_id,
)
if reservation is None:
return self._record_direct_operation(payload, allocation, "consume", amount, operator)
before_balance = self.get_balance(reservation.allocation)
reserved_amount = self._money(reservation.amount)
consume_amount = min(amount, reserved_amount)
reservation.source_status = "consumed"
reservation.consumed_amount = consume_amount
reservation.released_amount = max(reserved_amount - consume_amount, Decimal("0.00"))
reservation.consumed_at = datetime.now(UTC)
self.db.flush()
after_balance = self.get_balance(reservation.allocation)
transaction = self._record_transaction(
allocation=reservation.allocation,
reservation=reservation,
transaction_type="consume",
amount=consume_amount,
before_available=before_balance.available_amount,
after_available=after_balance.available_amount,
source_type=payload.source_type,
source_id=payload.source_id,
source_no=payload.source_no,
operator=operator,
reason=payload.reason,
)
self.db.flush()
flag = self._build_operation_flag(
reservation.allocation,
event_type="budget_consumed",
label="预算已核销",
message=f"来源 {payload.source_no} 已核销预算 {consume_amount} 元。",
amount=consume_amount,
reservation_id=reservation.id,
transaction_id=transaction.id,
)
return BudgetOperationRead(
ok=True,
message="预算核销已记录。",
reservation_id=reservation.id,
allocation=self.serialize_allocation(reservation.allocation),
transaction=BudgetTransactionRead.model_validate(transaction),
flags=[flag],
)
def _record_direct_operation(
self,
payload: BudgetOperationRequest,
allocation: BudgetAllocation,
transaction_type: str,
amount: Decimal,
operator: str,
) -> BudgetOperationRead:
before_balance = self.get_balance(allocation)
transaction = self._record_transaction(
allocation=allocation,
transaction_type=transaction_type,
amount=amount,
before_available=before_balance.available_amount,
after_available=before_balance.available_amount - amount,
source_type=payload.source_type,
source_id=payload.source_id,
source_no=payload.source_no,
operator=operator,
reason=payload.reason,
)
self.db.flush()
return BudgetOperationRead(
ok=True,
message="预算操作已记录。",
allocation=self.serialize_allocation(allocation),
transaction=BudgetTransactionRead.model_validate(transaction),
)

View File

@@ -0,0 +1,623 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import select
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
from app.models.financial_record import ExpenseClaim
from app.models.organization import OrganizationUnit
from app.schemas.budget import BudgetAllocationRead, BudgetTransactionRead
from app.services.budget_types import (
BUDGET_SUBJECT_LABELS,
BudgetBalance,
DEFAULT_SUBJECT_AMOUNTS,
SUBJECT_CODE_ALIASES,
SUPPORTED_BUDGET_SUBJECT_CODES,
)
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
class BudgetSupportMixin:
def serialize_allocation(self, allocation: BudgetAllocation) -> BudgetAllocationRead:
return BudgetAllocationRead(
id=allocation.id,
budget_no=allocation.budget_no,
fiscal_year=allocation.fiscal_year,
period_type=allocation.period_type,
period_key=allocation.period_key,
department_id=allocation.department_id,
department_name=allocation.department_name,
cost_center=allocation.cost_center,
project_code=allocation.project_code,
subject_code=allocation.subject_code,
subject_name=allocation.subject_name,
original_amount=self._money(allocation.original_amount),
adjusted_amount=self._money(allocation.adjusted_amount),
status=allocation.status,
warning_threshold=self._percent(allocation.warning_threshold),
control_action=allocation.control_action,
description=allocation.description,
balance=self.get_balance(allocation).to_read(),
created_at=allocation.created_at,
updated_at=allocation.updated_at,
)
def get_balance(self, allocation: BudgetAllocation) -> BudgetBalance:
reservations = self.db.scalars(
select(BudgetReservation).where(
BudgetReservation.allocation_id == allocation.id,
BudgetReservation.source_status == "active",
)
).all()
transactions = self.db.scalars(
select(BudgetTransaction).where(BudgetTransaction.allocation_id == allocation.id)
).all()
reserved_amount = sum((self._money(item.amount) for item in reservations), Decimal("0.00"))
consumed_amount = Decimal("0.00")
for transaction in transactions:
transaction_type = str(transaction.transaction_type or "").strip().lower()
amount = self._money(transaction.amount)
if transaction_type == "consume":
consumed_amount += amount
elif transaction_type == "rollback":
consumed_amount -= amount
total_amount = self._money(allocation.original_amount) + self._money(allocation.adjusted_amount)
available_amount = total_amount - reserved_amount - consumed_amount
usage_amount = reserved_amount + consumed_amount
usage_rate = Decimal("0.00")
if total_amount > Decimal("0.00"):
usage_rate = ((usage_amount / total_amount) * Decimal("100")).quantize(Decimal("0.01"))
return BudgetBalance(
total_amount=total_amount,
reserved_amount=reserved_amount,
consumed_amount=consumed_amount,
available_amount=available_amount,
usage_rate=usage_rate,
)
def list_transactions(self, allocation_id: str) -> list[BudgetTransactionRead]:
self.ensure_budget_ready()
rows = self.db.scalars(
select(BudgetTransaction)
.where(BudgetTransaction.allocation_id == allocation_id)
.order_by(BudgetTransaction.created_at.desc())
).all()
return [BudgetTransactionRead.model_validate(row) for row in rows]
def get_allocation_row(self, allocation_id: str) -> BudgetAllocation | None:
self.ensure_budget_ready()
return self.db.get(BudgetAllocation, allocation_id)
def _review_allocation_amount(
self,
allocation: BudgetAllocation,
amount: Decimal,
) -> dict[str, list[Any]]:
balance = self.get_balance(allocation)
flags: list[dict[str, Any]] = []
blocking_reasons: list[str] = []
if str(allocation.status or "").strip().lower() == "frozen":
message = f"预算 {allocation.budget_no} 已冻结,不能继续占用。"
flags.append(
self._build_operation_flag(
allocation,
event_type="budget_frozen",
label="预算已冻结",
message=message,
severity="high",
amount=amount,
)
)
blocking_reasons.append(message)
return {"flags": flags, "blocking_reasons": blocking_reasons}
if amount > balance.available_amount:
over_amount = amount - balance.available_amount
message = (
f"预算 {allocation.budget_no} 可用余额 {balance.available_amount} 元,"
f"当前单据金额 {amount} 元,超出 {over_amount} 元。"
)
flags.append(
self._build_operation_flag(
allocation,
event_type="budget_insufficient",
label="预算余额不足",
message=message,
severity="high",
amount=amount,
extra={"available_amount": str(balance.available_amount), "over_budget_amount": str(over_amount)},
)
)
blocking_reasons.append(message)
return {"flags": flags, "blocking_reasons": blocking_reasons}
after_usage = balance.reserved_amount + balance.consumed_amount + amount
usage_rate = Decimal("0.00")
if balance.total_amount > Decimal("0.00"):
usage_rate = ((after_usage / balance.total_amount) * Decimal("100")).quantize(Decimal("0.01"))
if usage_rate >= self._percent(allocation.warning_threshold):
flags.append(
self._build_operation_flag(
allocation,
event_type="budget_warning",
label="预算接近预警线",
message=(
f"预算 {allocation.budget_no} 本次占用后使用率预计达到 {usage_rate}%"
f"已达到预警线 {allocation.warning_threshold}%。"
),
severity="medium",
amount=amount,
extra={"usage_rate": str(usage_rate)},
)
)
return {"flags": flags, "blocking_reasons": blocking_reasons}
def build_claim_budget_context(self, claim: ExpenseClaim) -> dict[str, Any]:
self.ensure_budget_ready()
amount = self._money(claim.amount or Decimal("0.00"))
fiscal_year, period_key = self._period_from_claim(claim)
subject_code = self._subject_code_from_claim(claim)
if not self._is_supported_budget_subject(subject_code):
return {
"matched": False,
"budget_applicable": False,
"skip_reason": "demo_budget_subject_not_enabled",
"claim_amount": str(amount),
"fiscal_year": fiscal_year,
"period_key": period_key,
"subject_code": subject_code,
"department_id": claim.department_id,
"department_name": claim.department_name,
"cost_center": self._resolve_claim_cost_center(claim),
}
allocation = self._find_allocation_for_claim(claim)
if allocation is None:
return {
"matched": False,
"budget_applicable": True,
"claim_amount": str(amount),
"fiscal_year": fiscal_year,
"period_key": period_key,
"subject_code": subject_code,
"department_id": claim.department_id,
"department_name": claim.department_name,
"cost_center": self._resolve_claim_cost_center(claim),
}
balance = self.get_balance(allocation)
over_budget_amount = max(amount - balance.available_amount, Decimal("0.00"))
return {
"matched": True,
"budget_applicable": True,
"allocation_id": allocation.id,
"budget_no": allocation.budget_no,
"claim_amount": str(amount),
"total_amount": str(balance.total_amount),
"reserved_amount": str(balance.reserved_amount),
"consumed_amount": str(balance.consumed_amount),
"available_amount": str(balance.available_amount),
"usage_rate": str(balance.usage_rate),
"over_budget_amount": str(over_budget_amount),
"warning_threshold": str(allocation.warning_threshold),
"control_action": allocation.control_action,
"fiscal_year": allocation.fiscal_year,
"period_key": allocation.period_key,
"subject_code": allocation.subject_code,
"subject_name": allocation.subject_name,
"department_id": allocation.department_id,
"department_name": allocation.department_name,
"cost_center": allocation.cost_center,
"project_code": allocation.project_code,
}
def _find_allocation_for_claim(self, claim: ExpenseClaim) -> BudgetAllocation | None:
fiscal_year, period_key = self._period_from_claim(claim)
return self._find_allocation_for_dimension(
fiscal_year=fiscal_year,
period_key=period_key,
department_id=claim.department_id,
department_name=claim.department_name,
cost_center=self._resolve_claim_cost_center(claim),
project_code=claim.project_code,
subject_code=self._subject_code_from_claim(claim),
)
def _find_allocation_for_dimension(
self,
*,
fiscal_year: int | None,
period_key: str | None,
department_id: str | None,
department_name: str | None,
cost_center: str | None,
project_code: str | None,
subject_code: str,
) -> BudgetAllocation | None:
now = datetime.now(UTC)
year = fiscal_year or now.year
key = self._normalize_period_key(year, period_key or self._quarter_key(year, now.month))
normalized_subject = self._normalize_subject_code(subject_code)
candidates = list(
self.db.scalars(
select(BudgetAllocation)
.where(BudgetAllocation.fiscal_year == year)
.where(BudgetAllocation.period_key == key)
.where(BudgetAllocation.subject_code == normalized_subject)
.where(BudgetAllocation.status.in_(["active", "published"]))
.order_by(BudgetAllocation.project_code.desc().nullslast())
).all()
)
if not candidates:
return None
normalized_department_id = self._blank_to_none(department_id)
normalized_department_name = str(department_name or "").strip()
normalized_cost_center = self._blank_to_none(cost_center)
normalized_project_code = self._blank_to_none(project_code)
for item in candidates:
if normalized_project_code and item.project_code and item.project_code != normalized_project_code:
continue
if normalized_department_id and item.department_id == normalized_department_id:
return item
if normalized_cost_center and item.cost_center == normalized_cost_center:
return item
if normalized_department_name and item.department_name == normalized_department_name:
return item
return None
def _find_exact_allocation(
self,
*,
fiscal_year: int,
period_key: str,
department_id: str | None,
department_name: str,
cost_center: str | None,
project_code: str | None,
subject_code: str,
) -> BudgetAllocation | None:
rows = self.db.scalars(
select(BudgetAllocation)
.where(BudgetAllocation.fiscal_year == fiscal_year)
.where(BudgetAllocation.period_key == period_key)
.where(BudgetAllocation.subject_code == subject_code)
).all()
normalized_department_id = self._blank_to_none(department_id)
normalized_department_name = department_name.strip()
normalized_cost_center = self._blank_to_none(cost_center)
normalized_project_code = self._blank_to_none(project_code)
for row in rows:
if row.project_code != normalized_project_code:
continue
if normalized_department_id and row.department_id == normalized_department_id:
return row
if normalized_cost_center and row.cost_center == normalized_cost_center:
return row
if row.department_name == normalized_department_name:
return row
return None
def _find_active_reservation(self, *, source_type: str, source_id: str) -> BudgetReservation | None:
return self.db.scalar(
select(BudgetReservation)
.where(BudgetReservation.source_type == source_type)
.where(BudgetReservation.source_id == source_id)
.where(BudgetReservation.source_status == "active")
.order_by(BudgetReservation.created_at.desc())
.limit(1)
)
def _find_active_reservations(self, *, source_type: str, source_id: str) -> list[BudgetReservation]:
return list(
self.db.scalars(
select(BudgetReservation)
.where(BudgetReservation.source_type == source_type)
.where(BudgetReservation.source_id == source_id)
.where(BudgetReservation.source_status == "active")
).all()
)
def _seed_default_allocations(self) -> None:
units = list(
self.db.scalars(
select(OrganizationUnit).where(OrganizationUnit.unit_type == "department")
).all()
)
if not units:
return
year = datetime.now(UTC).year
for unit in units:
for quarter in range(1, 5):
period_key = f"{year}Q{quarter}"
for subject_code, amount in DEFAULT_SUBJECT_AMOUNTS.items():
allocation = BudgetAllocation(
budget_no=self._make_no("BUD"),
fiscal_year=year,
period_type="quarter",
period_key=period_key,
department_id=unit.id,
department_name=unit.name,
cost_center=unit.cost_center,
project_code=None,
subject_code=subject_code,
subject_name=self._subject_label(subject_code),
original_amount=amount,
adjusted_amount=Decimal("0.00"),
status="active",
warning_threshold=Decimal("80.00"),
control_action="block",
description="系统初始化预算池额度",
created_by="system",
updated_by="system",
)
self.db.add(allocation)
self.db.flush()
self._record_transaction(
allocation=allocation,
transaction_type="init",
amount=amount,
before_available=Decimal("0.00"),
after_available=amount,
source_type="budget_seed",
source_id=allocation.id,
source_no=allocation.budget_no,
operator="system",
reason="系统初始化预算池额度",
)
self.db.flush()
def _create_fallback_allocation_for_claim(self, claim: ExpenseClaim) -> BudgetAllocation:
fiscal_year, period_key = self._period_from_claim(claim)
subject_code = self._subject_code_from_claim(claim)
allocation = BudgetAllocation(
budget_no=self._make_no("BUD"),
fiscal_year=fiscal_year,
period_type="quarter",
period_key=period_key,
department_id=claim.department_id,
department_name=str(claim.department_name or "未归属部门").strip() or "未归属部门",
cost_center=self._resolve_claim_cost_center(claim),
project_code=claim.project_code,
subject_code=subject_code,
subject_name=self._subject_label(subject_code),
original_amount=DEFAULT_SUBJECT_AMOUNTS.get(subject_code, Decimal("100000.00")),
adjusted_amount=Decimal("0.00"),
status="active",
warning_threshold=Decimal("80.00"),
control_action="block",
description="测试或演示环境自动补齐预算池额度",
created_by="system",
updated_by="system",
)
self.db.add(allocation)
self.db.flush()
self._record_transaction(
allocation=allocation,
transaction_type="init",
amount=allocation.original_amount,
before_available=Decimal("0.00"),
after_available=allocation.original_amount,
source_type="budget_seed",
source_id=allocation.id,
source_no=allocation.budget_no,
operator="system",
reason="自动补齐预算池额度",
)
self.db.flush()
return allocation
def _budget_table_empty(self) -> bool:
return self.db.scalar(select(BudgetAllocation.id).limit(1)) is None
def _record_transaction(
self,
*,
allocation: BudgetAllocation,
transaction_type: str,
amount: Decimal,
before_available: Decimal,
after_available: Decimal,
source_type: str,
source_id: str,
source_no: str,
operator: str | None,
reason: str | None,
reservation: BudgetReservation | None = None,
context_json: dict[str, Any] | None = None,
) -> BudgetTransaction:
transaction = BudgetTransaction(
transaction_no=self._make_no("BTX"),
allocation_id=allocation.id,
reservation_id=reservation.id if reservation is not None else None,
source_type=source_type,
source_id=source_id,
source_no=source_no,
transaction_type=transaction_type,
amount=self._money(amount),
before_available_amount=self._money(before_available),
after_available_amount=self._money(after_available),
operator=operator,
reason=reason,
context_json=context_json or {},
)
self.db.add(transaction)
return transaction
@staticmethod
def _build_budget_flag(
*,
event_type: str,
severity: str,
label: str,
message: str,
amount: Decimal,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
payload = {
"source": "budget_control",
"event_type": event_type,
"severity": severity,
"label": label,
"message": message,
"amount": str(amount),
"created_at": datetime.now(UTC).isoformat(),
}
payload.update(extra or {})
return payload
def _build_operation_flag(
self,
allocation: BudgetAllocation,
*,
event_type: str,
label: str,
message: str,
amount: Decimal,
severity: str = "info",
reservation_id: str | None = None,
transaction_id: str | None = None,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
balance = self.get_balance(allocation)
payload = self._build_budget_flag(
event_type=event_type,
severity=severity,
label=label,
message=message,
amount=amount,
extra={
"allocation_id": allocation.id,
"budget_no": allocation.budget_no,
"subject_code": allocation.subject_code,
"subject_name": allocation.subject_name,
"available_amount": str(balance.available_amount),
"reserved_amount": str(balance.reserved_amount),
"consumed_amount": str(balance.consumed_amount),
**(extra or {}),
},
)
if reservation_id:
payload["reservation_id"] = reservation_id
if transaction_id:
payload["transaction_id"] = transaction_id
return payload
@staticmethod
def _money(value: Any) -> Decimal:
return Decimal(str(value or "0")).quantize(Decimal("0.01"))
@staticmethod
def _percent(value: Any) -> Decimal:
return Decimal(str(value or "0")).quantize(Decimal("0.01"))
@staticmethod
def _blank_to_none(value: str | None) -> str | None:
text = str(value or "").strip()
return text or None
@staticmethod
def _make_no(prefix: str) -> str:
return f"{prefix}-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8].upper()}"
@staticmethod
def _normalize_period_type(value: str | None) -> str:
text = str(value or "").strip().lower()
return text if text in {"month", "quarter", "year"} else "quarter"
@staticmethod
def _normalize_period_key(year: int, value: str | None) -> str:
text = str(value or "").strip().upper().replace("", "").replace("", "").replace("季度", "")
if text.startswith(str(year)) and "Q" in text:
return text
if text in {"Q1", "Q2", "Q3", "Q4"}:
return f"{year}{text}"
return text or f"{year}Q1"
@staticmethod
def _quarter_key(year: int, month: int) -> str:
quarter = ((max(1, min(month, 12)) - 1) // 3) + 1
return f"{year}Q{quarter}"
def _period_from_claim(self, claim: ExpenseClaim) -> tuple[int, str]:
occurred_at = claim.occurred_at or claim.submitted_at or datetime.now(UTC)
return occurred_at.year, self._quarter_key(occurred_at.year, occurred_at.month)
def _subject_code_from_claim(self, claim: ExpenseClaim) -> str:
expense_type = str(claim.expense_type or "").strip().lower()
if expense_type.endswith("_application"):
expense_type = expense_type.removesuffix("_application")
expense_type = SUBJECT_CODE_ALIASES.get(expense_type, expense_type)
if expense_type in DEFAULT_SUBJECT_AMOUNTS or expense_type in EXPENSE_TYPE_LABELS:
return expense_type
resolved = resolve_expense_type_code_from_text(expense_type)
if resolved:
return SUBJECT_CODE_ALIASES.get(resolved, resolved)
return resolved or expense_type or "other"
@staticmethod
def _normalize_subject_code(value: str | None) -> str:
text = str(value or "").strip().lower()
if text.endswith("_application"):
text = text.removesuffix("_application")
text = SUBJECT_CODE_ALIASES.get(text, text)
resolved = resolve_expense_type_code_from_text(text)
if resolved:
return SUBJECT_CODE_ALIASES.get(resolved, resolved)
return text or "other"
@staticmethod
def _is_supported_budget_subject(subject_code: str | None) -> bool:
return str(subject_code or "").strip().lower() in SUPPORTED_BUDGET_SUBJECT_CODES
def _claim_uses_budget_control(self, claim: ExpenseClaim) -> bool:
return self._is_supported_budget_subject(self._subject_code_from_claim(claim))
@staticmethod
def _subject_label(code: str) -> str:
return BUDGET_SUBJECT_LABELS.get(code, EXPENSE_TYPE_LABELS.get(code, code))
@staticmethod
def _normalize_control_action(value: str | None) -> str:
text = str(value or "").strip().lower()
if text in {"block", "control", "管控", "强控"}:
return "block"
if text in {"warn", "warning", "提醒", "预警"}:
return "warn"
if text in {"allow", "normal", "正常", "放行"}:
return "allow"
return "block"
def _resolve_claim_cost_center(self, claim: ExpenseClaim) -> str | None:
employee = getattr(claim, "employee", None)
if employee is not None:
cost_center = self._blank_to_none(getattr(employee, "cost_center", None))
if cost_center:
return cost_center
organization_unit = getattr(employee, "organization_unit", None)
if organization_unit is not None:
cost_center = self._blank_to_none(getattr(organization_unit, "cost_center", None))
if cost_center:
return cost_center
return None
def _claim_context(self, claim: ExpenseClaim) -> dict[str, Any]:
fiscal_year, period_key = self._period_from_claim(claim)
return {
"claim_id": claim.id,
"claim_no": claim.claim_no,
"employee_id": claim.employee_id,
"employee_name": claim.employee_name,
"department_id": claim.department_id,
"department_name": claim.department_name,
"cost_center": self._resolve_claim_cost_center(claim),
"project_code": claim.project_code,
"expense_type": claim.expense_type,
"subject_code": self._subject_code_from_claim(claim),
"fiscal_year": fiscal_year,
"period_key": period_key,
}

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
from app.schemas.budget import BudgetBalanceRead
DEFAULT_SUBJECT_AMOUNTS: dict[str, Decimal] = {
"travel": Decimal("600000.00"),
"meal": Decimal("420000.00"),
"office": Decimal("180000.00"),
"communication": Decimal("120000.00"),
}
BUDGET_SUBJECT_LABELS = {
"travel": "差旅",
"communication": "通信",
"meal": "招待费",
"office": "办公用品",
}
SUPPORTED_BUDGET_SUBJECT_CODES = frozenset(DEFAULT_SUBJECT_AMOUNTS)
SUBJECT_CODE_ALIASES = {
"entertainment": "meal",
"purchase": "office",
}
MUTATING_TRANSACTION_TYPES = {"adjust", "reserve", "release", "consume", "rollback"}
class BudgetControlError(ValueError):
def __init__(self, reasons: list[str], *, flags: list[dict[str, Any]] | None = None) -> None:
self.reasons = [reason for reason in reasons if reason]
self.flags = list(flags or [])
super().__init__("".join(self.reasons) or "预算校验未通过")
@dataclass(frozen=True)
class BudgetBalance:
total_amount: Decimal
reserved_amount: Decimal
consumed_amount: Decimal
available_amount: Decimal
usage_rate: Decimal
def to_read(self) -> BudgetBalanceRead:
return BudgetBalanceRead(
total_amount=self.total_amount,
reserved_amount=self.reserved_amount,
consumed_amount=self.consumed_amount,
available_amount=self.available_amount,
usage_rate=self.usage_rate,
)

View File

@@ -128,6 +128,7 @@ class EmployeeService:
for status in STATUS_ORDER
]
visible_role_codes = {item["role_code"] for item in ROLE_DEFINITIONS}
role_options = [
EmployeeRoleOptionRead(
id=role.role_code,
@@ -137,6 +138,7 @@ class EmployeeService:
permissions=list(ROLE_PERMISSION_MAP.get(role.role_code, [])),
)
for role in self._sorted_roles(self.repository.list_roles())
if role.role_code in visible_role_codes
]
canonical_department_codes = set(CANONICAL_DEPARTMENT_CODES)
@@ -470,6 +472,11 @@ class EmployeeService:
def _seed_roles(self) -> None:
existing_by_code = {role.role_code: role for role in self.repository.list_roles()}
legacy_auditor = existing_by_code.get("auditor")
if legacy_auditor is not None and "budget_monitor" not in existing_by_code:
legacy_auditor.role_code = "budget_monitor"
existing_by_code["budget_monitor"] = legacy_auditor
existing_by_code.pop("auditor", None)
for definition in ROLE_DEFINITIONS:
role = existing_by_code.get(definition["role_code"])
@@ -481,6 +488,9 @@ class EmployeeService:
)
self.db.add(role)
existing_by_code[role.role_code] = role
else:
role.name = definition["name"]
role.description = definition["description"]
self.db.flush()

View File

@@ -99,7 +99,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"spotlight": False,
"updated_at": "2026-05-07 09:35",
"last_sync_at": "2026-05-07 09:10",
"role_codes": ["finance", "auditor"],
"role_codes": ["finance", "budget_monitor"],
},
{
"employee_no": "E10289",
@@ -143,7 +143,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"spotlight": False,
"updated_at": "2026-05-05 09:18",
"last_sync_at": "2026-05-05 09:18",
"role_codes": ["manager", "auditor"],
"role_codes": ["manager", "budget_monitor"],
},
{
"employee_no": "E11618",
@@ -363,7 +363,7 @@ EMPLOYEE_DEFINITIONS_PART_1 = [
"spotlight": False,
"updated_at": "2026-05-06 13:08",
"last_sync_at": "2026-05-06 13:08",
"role_codes": ["user", "approver", "auditor"],
"role_codes": ["user", "approver", "budget_monitor"],
},
{
"employee_no": "E11991",

View File

@@ -87,7 +87,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"spotlight": True,
"updated_at": "2026-05-07 09:52",
"last_sync_at": "2026-05-07 09:52",
"role_codes": ["auditor", "finance"],
"role_codes": ["budget_monitor", "finance"],
"history": [
{
"action": "更新审计观察范围",
@@ -121,7 +121,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"spotlight": False,
"updated_at": "2026-05-07 08:58",
"last_sync_at": "2026-05-07 08:40",
"role_codes": ["auditor"],
"role_codes": ["budget_monitor"],
},
{
"employee_no": "E12688",
@@ -385,7 +385,7 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"spotlight": False,
"updated_at": "2026-05-03 13:18",
"last_sync_at": "2026-05-03 13:18",
"role_codes": ["auditor"],
"role_codes": ["budget_monitor"],
},
{
"employee_no": "E12790",
@@ -407,6 +407,6 @@ EMPLOYEE_DEFINITIONS_PART_2 = [
"spotlight": False,
"updated_at": "2026-05-06 08:56",
"last_sync_at": "2026-05-06 08:56",
"role_codes": ["user", "auditor"],
"role_codes": ["user", "budget_monitor"],
},
]

View File

@@ -5,7 +5,7 @@ ROLE_DISPLAY_ORDER = {
"finance": 2,
"approver": 3,
"executive": 4,
"auditor": 5,
"budget_monitor": 5,
"user": 6,
}
@@ -13,7 +13,7 @@ ROLE_DEFINITIONS = [
{
"role_code": "user",
"name": "使用者",
"description": "可以发起报销、查看个人单据和使用 AI 助手。",
"description": "可以发起费用申请、报销、查看个人单据和使用 AI 助手。",
},
{
"role_code": "finance",
@@ -27,8 +27,8 @@ ROLE_DEFINITIONS = [
},
{
"role_code": "executive",
"name": "高级管理人员",
"description": "可以查看跨部门数据看板与关键审批结果。",
"name": "高级财务人员",
"description": "可以查看跨部门预算、经营看板与关键财务审批结果。",
},
{
"role_code": "approver",
@@ -36,17 +36,17 @@ ROLE_DEFINITIONS = [
"description": "可以处理审批中心中的待审单据。",
},
{
"role_code": "auditor",
"name": "审计观察",
"description": "可以查看变更记录和权限调整历史",
"role_code": "budget_monitor",
"name": "预算监控",
"description": "可以查看本部门预算执行、预警和占用情况",
},
]
ROLE_PERMISSION_MAP = {
"user": ["可发起差旅申请与报销", "可查看个人单据与票据识别结果"],
"user": ["可发起费用申请与报销", "可查看个人单据与票据识别结果"],
"finance": ["可处理财务复核任务", "可查看风险校验与财务知识库"],
"manager": ["可维护员工档案与组织结构", "可配置系统角色与访问边界"],
"executive": ["可查看跨部门经营看板", "处理高金额报销最终审批"],
"executive": ["可查看全部部门预算", "维护预算额度与处理关键财务审批"],
"approver": ["可处理本部门待审单据", "可查看审批链路与 SLA 状态"],
"auditor": ["可查看权限变更与审计留痕", "导出员工权限观察记录"],
"budget_monitor": ["可查看本部门预算执行", "跟踪本部门预算预警与占用"],
}

View File

@@ -13,7 +13,7 @@ from app.models.organization import OrganizationUnit
PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"}
ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive", "auditor"}
ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive"}
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
CLAIM_DELETE_ROLE_CODES = {"executive"}
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
from typing import Any
from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaim
from app.services.budget import BudgetService
class ExpenseClaimBudgetFlowMixin:
def _reserve_budget_for_submission(
self,
claim: ExpenseClaim,
current_user: CurrentUserContext,
*,
is_application_claim: bool,
) -> list[dict[str, Any]]:
source_type = "application" if is_application_claim else "claim"
return BudgetService(self.db).reserve_for_claim(
claim,
source_type=source_type,
operator=self._resolve_budget_operator(current_user),
)
def _release_budget_for_return(
self,
claim: ExpenseClaim,
current_user: CurrentUserContext,
*,
reason: str,
) -> list[dict[str, Any]]:
is_application_claim = self._is_expense_application_claim(claim)
source_type = "application" if is_application_claim else "claim"
return BudgetService(self.db).release_for_claim(
claim,
source_type=source_type,
operator=self._resolve_budget_operator(current_user),
reason=reason,
)
def _release_budget_for_delete(
self,
claim: ExpenseClaim,
current_user: CurrentUserContext,
) -> None:
is_application_claim = self._is_expense_application_claim(claim)
source_type = "application" if is_application_claim else "claim"
BudgetService(self.db).release_for_claim(
claim,
source_type=source_type,
operator=self._resolve_budget_operator(current_user),
reason="单据删除释放预算预占",
)
def _consume_budget_for_finance_approval(
self,
claim: ExpenseClaim,
current_user: CurrentUserContext,
) -> dict[str, Any] | None:
return BudgetService(self.db).consume_for_claim(
claim,
operator=self._resolve_budget_operator(current_user),
reason="财务终审通过核销预算",
)
def _transfer_application_budget_to_reimbursement(
self,
*,
application_claim: ExpenseClaim,
draft_claim: ExpenseClaim,
current_user: CurrentUserContext,
) -> dict[str, Any] | None:
return BudgetService(self.db).transfer_application_reservation(
application_claim=application_claim,
draft_claim=draft_claim,
operator=self._resolve_budget_operator(current_user),
)
@staticmethod
def _append_budget_flags(
risk_flags: list[Any] | None,
budget_flags: list[dict[str, Any]] | dict[str, Any] | None,
) -> list[Any]:
if budget_flags is None:
return list(risk_flags or [])
if isinstance(budget_flags, dict):
next_flags = [budget_flags]
else:
next_flags = list(budget_flags or [])
if not next_flags:
return list(risk_flags or [])
return [*list(risk_flags or []), *next_flags]
@staticmethod
def _resolve_budget_operator(current_user: CurrentUserContext) -> str:
return current_user.name or current_user.username or "system"

View File

@@ -10,9 +10,11 @@ from app.models.agent_asset import AgentAsset
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.budget import BudgetService
from app.services.expense_rule_runtime import (
RuntimeTravelPolicy,
)
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
@@ -29,6 +31,16 @@ class ExpenseClaimPlatformRiskMixin:
return {"flags": [], "blocking_reasons": []}
contexts = self._build_claim_attachment_contexts(claim)
contexts.append(
{
"index": len(contexts) + 1,
"item": None,
"document_info": {},
"ocr_text": "",
"ocr_summary": "",
"budget_context": BudgetService(self.db).build_claim_budget_context(claim),
}
)
flags: list[dict[str, Any]] = []
blocking_reasons: list[str] = []
@@ -163,24 +175,18 @@ class ExpenseClaimPlatformRiskMixin:
if min_attachments and int(claim.invoice_count or 0) < min_attachments and not contexts:
return False
expense_types = {
str(claim.expense_type or "").strip().lower(),
*{
str(item.item_type or "").strip().lower()
for item in list(claim.items or [])
if str(item.item_type or "").strip()
},
}
expense_types = self._normalize_expense_type_values(
str(claim.expense_type or ""),
*[str(item.item_type or "") for item in list(claim.items or [])],
)
domains = {
str(value or "").strip().lower()
for value in list(applies_to.get("domains") or [])
if str(value or "").strip()
}
configured_expense_types = {
str(value or "").strip().lower()
for value in list(applies_to.get("expense_types") or [])
if str(value or "").strip()
}
configured_expense_types = self._normalize_expense_type_values(
*[str(value or "") for value in list(applies_to.get("expense_types") or [])]
)
if configured_expense_types and not (expense_types & configured_expense_types):
return False
@@ -193,6 +199,19 @@ class ExpenseClaimPlatformRiskMixin:
return True
@staticmethod
def _normalize_expense_type_values(*values: str) -> set[str]:
normalized: set[str] = set()
for value in values:
raw = str(value or "").strip()
if not raw:
continue
normalized.add(raw.lower())
resolved = resolve_expense_type_code_from_text(raw)
if resolved:
normalized.add(resolved)
return normalized
def _risk_domains_match_claim(
self,
domains: set[str],
@@ -213,6 +232,9 @@ class ExpenseClaimPlatformRiskMixin:
}
)
if "expense" in domains:
return True
if "travel" in domains:
if expense_types & {"travel", "hotel", "transport"}:
return True

View File

@@ -41,6 +41,7 @@ from app.services.expense_claim_application_handoff import ExpenseClaimApplicati
from app.services.expense_claim_attachment_analysis import ExpenseClaimAttachmentAnalysisMixin
from app.services.expense_claim_attachment_document import ExpenseClaimAttachmentDocumentMixin
from app.services.expense_claim_attachment_operations import ExpenseClaimAttachmentOperationsMixin
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin
from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin
from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin
@@ -127,6 +128,7 @@ from app.services.ocr import OcrService
class ExpenseClaimService(
ExpenseClaimApplicationHandoffMixin,
ExpenseClaimBudgetFlowMixin,
ExpenseClaimAttachmentOperationsMixin,
ExpenseClaimReviewPreviewMixin,
ExpenseClaimDraftFlowMixin,
@@ -437,6 +439,11 @@ class ExpenseClaimService(
if missing_fields:
raise ExpenseClaimSubmissionBlockedError(missing_fields)
budget_flags = self._reserve_budget_for_submission(
claim,
current_user,
is_application_claim=is_application_claim,
)
before_json = self._serialize_claim(claim)
if is_application_claim:
submitted_at = datetime.now(UTC)
@@ -453,7 +460,7 @@ class ExpenseClaimService(
"event_type": "expense_application_submission",
"severity": "info",
"label": "申请提交",
"message": "费用申请已提交至直属领导审批,并同步纳入预算管理口径",
"message": "费用申请已提交至直属领导审批,请等待审核结果",
"previous_status": str(claim.status or "").strip(),
"previous_approval_stage": str(claim.approval_stage or "").strip(),
"next_status": "submitted",
@@ -462,9 +469,10 @@ class ExpenseClaimService(
}
claim.status = "submitted"
claim.approval_stage = "直属领导审批"
claim.risk_flags_json = [*preserved_flags, submit_flag]
claim.risk_flags_json = self._append_budget_flags([*preserved_flags, submit_flag], budget_flags)
claim.submitted_at = submitted_at
else:
claim.risk_flags_json = self._append_budget_flags(claim.risk_flags_json, budget_flags)
review_result = self._run_ai_submission_review(claim)
claim.status = str(review_result.get("status") or "supplement")
@@ -520,11 +528,12 @@ class ExpenseClaimService(
if not self._access_policy.has_claim_delete_access(current_user):
self._ensure_draft_claim(claim)
if not self._access_policy.is_claim_owned_by_current_user(claim, current_user):
raise ValueError("只有高级管理人员可以删除非本人单据,申请人仅可删除自己的草稿、待补充或退回单据。")
raise ValueError("只有高级财务人员可以删除非本人单据,申请人仅可删除自己的草稿、待补充或退回单据。")
before_json = self._serialize_claim(claim)
resource_id = claim.id
self._release_budget_for_delete(claim, current_user)
self._attachment_storage.delete_claim_files(claim)
self.db.delete(claim)
self.db.commit()
@@ -554,7 +563,7 @@ class ExpenseClaimService(
return None
if not self._access_policy.can_return_claim(current_user, claim):
raise ValueError("只有财务人员、高级管理人员或当前审批人可以退回报销单。")
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
normalized_status = str(claim.status or "").strip().lower()
if normalized_status == "draft":
@@ -619,10 +628,18 @@ class ExpenseClaimService(
if unknown_reason_codes:
return_flag["unknown_reason_codes"] = unknown_reason_codes
budget_flags = self._release_budget_for_return(
claim,
current_user,
reason=message,
)
claim.status = "returned"
claim.approval_stage = "待提交"
claim.submitted_at = None
claim.risk_flags_json = [*list(claim.risk_flags_json or []), return_flag]
claim.risk_flags_json = self._append_budget_flags(
[*list(claim.risk_flags_json or []), return_flag],
budget_flags,
)
self.db.commit()
self.db.refresh(claim)
@@ -691,6 +708,11 @@ class ExpenseClaimService(
before_json = self._serialize_claim(claim)
operator = self._access_policy.resolve_current_user_display_name(current_user)
budget_flags: list[dict[str, Any]] = []
if approval_source == "finance_approval" and not is_application_claim:
consumed_budget_flag = self._consume_budget_for_finance_approval(claim, current_user)
if consumed_budget_flag is not None:
budget_flags.append(consumed_budget_flag)
approval_flag = {
"source": approval_source,
"event_type": event_type,
@@ -723,7 +745,21 @@ class ExpenseClaimService(
approval_flag=approval_flag,
operator=operator,
)
claim.risk_flags_json = [*list(claim.risk_flags_json or []), approval_flag]
transferred_budget_flag = self._transfer_application_budget_to_reimbursement(
application_claim=claim,
draft_claim=generated_draft,
current_user=current_user,
)
if transferred_budget_flag is not None:
budget_flags.append(transferred_budget_flag)
generated_draft.risk_flags_json = self._append_budget_flags(
generated_draft.risk_flags_json,
transferred_budget_flag,
)
claim.risk_flags_json = self._append_budget_flags(
[*list(claim.risk_flags_json or []), approval_flag],
budget_flags,
)
self.db.commit()
self.db.refresh(claim)

View File

@@ -0,0 +1,20 @@
from __future__ import annotations
from app.services.agent_asset_spreadsheet import COMPANY_TRAVEL_EXPENSE_RULE_CODE
DEPRECATED_FINANCE_RULE_CODES = (
"rule.expense.company_transport_hotel_detail_reimbursement",
"rule.expense.company_meal_expense_reimbursement",
"rule.expense.company_marketing_expense_reimbursement",
"rule.expense.company_meeting_expense_reimbursement",
"rule.expense.company_office_expense_reimbursement",
"rule.expense.company_training_expense_reimbursement",
"rule.expense.company_software_expense_reimbursement",
"rule.expense.company_welfare_expense_reimbursement",
)
DEPRECATED_FINANCE_RULE_REPLACEMENTS = {
"rule.expense.company_transport_hotel_detail_reimbursement": (
COMPANY_TRAVEL_EXPENSE_RULE_CODE
),
}

View File

@@ -519,6 +519,8 @@ class RiskRuleTemplateExecutor:
)
if normalized.startswith("attachment."):
return self._resolve_attachment_values(normalized.removeprefix("attachment."), contexts)
if normalized.startswith("budget."):
return self._resolve_budget_values(normalized.removeprefix("budget."), contexts)
return []
@staticmethod
@@ -566,6 +568,19 @@ class RiskRuleTemplateExecutor:
values.extend(self._scan_document_values(document_info, field_key))
return self._normalize_values(values)
def _resolve_budget_values(self, field_key: str, contexts: list[dict[str, Any]]) -> list[str]:
values: list[Any] = []
for context in contexts:
if not isinstance(context, dict):
continue
budget_context = context.get("budget_context")
if not isinstance(budget_context, dict):
continue
for key in {field_key, field_key.replace("_", ""), field_key.replace("-", "_")}:
if key in budget_context:
values.append(budget_context.get(key))
return self._normalize_values(values)
def _scan_document_values(self, document_info: dict[str, Any], field_key: str) -> list[Any]:
values: list[Any] = []
for key in {field_key, field_key.replace("_", ""), field_key.replace("_", "-")}:

View File

@@ -164,7 +164,7 @@ class UserAgentApplicationMixin:
f"申请单号:{application_no}",
"申请信息:\n" + self._build_application_summary_table(facts),
f"当前状态:{manager_name}审核中。",
"预算处理:用户预估费用已作为预算占用参考,等待领导审核确认。",
"费用预估:预计费用已随申请提交,等待领导审核确认。",
]
)