feat: 新增预算后端服务与差旅风险规则库
后端新增预算模型、端点和服务模块,支持预算 CRUD 和余额 查询,清理旧生成规则文件并替换为按严重等级分类的差旅风 险规则库,优化认证权限和报销单访问策略,新增财务规则目 录和演示数据构建脚本,前端预算中心增加对话框交互,完善 审计页面运行时模型和元数据展示,补充单元测试。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -695,9 +695,9 @@ def create_agent_asset_review(
|
||||
role_codes = {item.strip() for item in current_user.role_codes}
|
||||
if payload.review_status.value == "pending":
|
||||
if not (current_user.is_admin or "manager" in role_codes or "finance" in role_codes):
|
||||
raise PermissionError("只有财务人员或高级管理人员可以提交审核。")
|
||||
raise PermissionError("只有财务人员或高级财务人员可以提交审核。")
|
||||
elif not (current_user.is_admin or "manager" in role_codes):
|
||||
raise PermissionError("只有高级管理人员可以审核规则。")
|
||||
raise PermissionError("只有高级财务人员可以审核规则。")
|
||||
return AgentAssetService(db).create_review(
|
||||
asset_id,
|
||||
payload,
|
||||
@@ -746,7 +746,7 @@ def activate_agent_asset(
|
||||
response_model=AgentAssetRead,
|
||||
summary="设置风险规则启用状态",
|
||||
description=(
|
||||
"高级管理人员可独立启用或停用 JSON 风险规则;停用后即使已上线也不会进入真实业务扫描。"
|
||||
"高级财务人员可独立启用或停用 JSON 风险规则;停用后即使已上线也不会进入真实业务扫描。"
|
||||
),
|
||||
)
|
||||
def set_agent_asset_risk_rule_enabled(
|
||||
@@ -797,7 +797,7 @@ def set_agent_asset_risk_rule_level(
|
||||
"/{asset_id}/return",
|
||||
response_model=AgentAssetRiskRuleLatestTestSummary,
|
||||
summary="回退待审核风险规则",
|
||||
description="高级管理人员将待审核风险规则回退到草稿,并记录回退原因。",
|
||||
description="高级财务人员将待审核风险规则回退到草稿,并记录回退原因。",
|
||||
)
|
||||
def return_agent_asset_risk_rule(
|
||||
asset_id: str,
|
||||
@@ -822,7 +822,7 @@ def return_agent_asset_risk_rule(
|
||||
"/{asset_id}/publish",
|
||||
response_model=AgentAssetRead,
|
||||
summary="审核并发布风险规则",
|
||||
description="高级管理人员确认测试通过后,将待审核风险规则一次性审核通过并发布上线。",
|
||||
description="高级财务人员确认测试通过后,将待审核风险规则一次性审核通过并发布上线。",
|
||||
)
|
||||
def publish_agent_asset_risk_rule(
|
||||
asset_id: str,
|
||||
|
||||
338
server/src/app/api/v1/endpoints/budgets.py
Normal file
338
server/src/app/api/v1/endpoints/budgets.py
Normal file
@@ -0,0 +1,338 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import (
|
||||
CurrentUserContext,
|
||||
get_current_user,
|
||||
get_db,
|
||||
is_budget_scope_limited_user,
|
||||
require_budget_editor_user,
|
||||
require_budget_viewer_user,
|
||||
)
|
||||
from app.models.employee import Employee
|
||||
from app.models.budget import BudgetAllocation
|
||||
from app.schemas.budget import (
|
||||
BudgetAllocationCreate,
|
||||
BudgetAllocationRead,
|
||||
BudgetCheckRead,
|
||||
BudgetCheckRequest,
|
||||
BudgetOperationRead,
|
||||
BudgetOperationRequest,
|
||||
BudgetSummaryRead,
|
||||
BudgetTransactionRead,
|
||||
)
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.services.budget import BudgetControlError, BudgetService
|
||||
|
||||
router = APIRouter(prefix="/budgets")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||
BudgetViewer = Annotated[CurrentUserContext, Depends(require_budget_viewer_user)]
|
||||
BudgetEditor = Annotated[CurrentUserContext, Depends(require_budget_editor_user)]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/summary",
|
||||
response_model=BudgetSummaryRead,
|
||||
summary="读取预算中心汇总",
|
||||
)
|
||||
def get_budget_summary(
|
||||
db: DbSession,
|
||||
current_user: BudgetViewer,
|
||||
fiscal_year: Annotated[int | None, Query(alias="year")] = None,
|
||||
period_key: Annotated[str | None, Query(alias="period")] = None,
|
||||
department_id: str | None = None,
|
||||
department_name: str | None = None,
|
||||
cost_center: str | None = None,
|
||||
) -> BudgetSummaryRead:
|
||||
scope = _resolve_budget_query_scope(
|
||||
db,
|
||||
current_user,
|
||||
department_id=department_id,
|
||||
department_name=department_name,
|
||||
cost_center=cost_center,
|
||||
)
|
||||
return BudgetService(db).get_summary(
|
||||
fiscal_year=fiscal_year,
|
||||
period_key=period_key,
|
||||
**scope,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/allocations",
|
||||
response_model=list[BudgetAllocationRead],
|
||||
summary="查询预算额度列表",
|
||||
)
|
||||
def list_budget_allocations(
|
||||
db: DbSession,
|
||||
current_user: BudgetViewer,
|
||||
fiscal_year: Annotated[int | None, Query(alias="year")] = None,
|
||||
period_key: Annotated[str | None, Query(alias="period")] = None,
|
||||
department_id: str | None = None,
|
||||
department_name: str | None = None,
|
||||
cost_center: str | None = None,
|
||||
) -> list[BudgetAllocationRead]:
|
||||
scope = _resolve_budget_query_scope(
|
||||
db,
|
||||
current_user,
|
||||
department_id=department_id,
|
||||
department_name=department_name,
|
||||
cost_center=cost_center,
|
||||
)
|
||||
return BudgetService(db).list_allocations(
|
||||
fiscal_year=fiscal_year,
|
||||
period_key=period_key,
|
||||
**scope,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/allocations",
|
||||
response_model=BudgetAllocationRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="创建或更新预算额度",
|
||||
responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}},
|
||||
)
|
||||
def create_budget_allocation(
|
||||
payload: BudgetAllocationCreate,
|
||||
db: DbSession,
|
||||
current_user: BudgetEditor,
|
||||
) -> BudgetAllocationRead:
|
||||
try:
|
||||
allocation = BudgetService(db).create_or_update_allocation(
|
||||
payload,
|
||||
operator=current_user.name or current_user.username,
|
||||
)
|
||||
db.commit()
|
||||
return allocation
|
||||
except ValueError as error:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
|
||||
@router.get(
|
||||
"/allocations/{allocation_id}/transactions",
|
||||
response_model=list[BudgetTransactionRead],
|
||||
summary="读取预算交易台账",
|
||||
)
|
||||
def list_budget_transactions(
|
||||
allocation_id: str,
|
||||
db: DbSession,
|
||||
current_user: BudgetViewer,
|
||||
) -> list[BudgetTransactionRead]:
|
||||
allocation = BudgetService(db).get_allocation_row(allocation_id)
|
||||
if allocation is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="预算额度不存在。")
|
||||
if not _allocation_visible_to_user(db, current_user, allocation):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能查看其他部门预算流水。")
|
||||
return BudgetService(db).list_transactions(allocation_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/check",
|
||||
response_model=BudgetCheckRead,
|
||||
summary="校验预算可用余额",
|
||||
)
|
||||
def check_budget(payload: BudgetCheckRequest, db: DbSession, current_user: BudgetViewer) -> BudgetCheckRead:
|
||||
scope = _resolve_budget_query_scope(
|
||||
db,
|
||||
current_user,
|
||||
department_id=payload.department_id,
|
||||
department_name=payload.department_name,
|
||||
cost_center=payload.cost_center,
|
||||
)
|
||||
scoped_payload = payload.model_copy(update=scope)
|
||||
return BudgetService(db).check(scoped_payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reserve",
|
||||
response_model=BudgetOperationRead,
|
||||
summary="记录预算预占台账",
|
||||
responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}},
|
||||
)
|
||||
def reserve_budget(
|
||||
payload: BudgetOperationRequest,
|
||||
db: DbSession,
|
||||
current_user: BudgetEditor,
|
||||
) -> BudgetOperationRead:
|
||||
return _execute_budget_operation(
|
||||
payload,
|
||||
db=db,
|
||||
current_user=current_user,
|
||||
transaction_type="reserve",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/release",
|
||||
response_model=BudgetOperationRead,
|
||||
summary="记录预算释放台账",
|
||||
responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}},
|
||||
)
|
||||
def release_budget(
|
||||
payload: BudgetOperationRequest,
|
||||
db: DbSession,
|
||||
current_user: BudgetEditor,
|
||||
) -> BudgetOperationRead:
|
||||
return _execute_budget_operation(
|
||||
payload,
|
||||
db=db,
|
||||
current_user=current_user,
|
||||
transaction_type="release",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/consume",
|
||||
response_model=BudgetOperationRead,
|
||||
summary="记录预算核销台账",
|
||||
responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}},
|
||||
)
|
||||
def consume_budget(
|
||||
payload: BudgetOperationRequest,
|
||||
db: DbSession,
|
||||
current_user: BudgetEditor,
|
||||
) -> BudgetOperationRead:
|
||||
return _execute_budget_operation(
|
||||
payload,
|
||||
db=db,
|
||||
current_user=current_user,
|
||||
transaction_type="consume",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rollback",
|
||||
response_model=BudgetOperationRead,
|
||||
summary="记录预算核销回滚台账",
|
||||
responses={status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse}},
|
||||
)
|
||||
def rollback_budget(
|
||||
payload: BudgetOperationRequest,
|
||||
db: DbSession,
|
||||
current_user: BudgetEditor,
|
||||
) -> BudgetOperationRead:
|
||||
return _execute_budget_operation(
|
||||
payload,
|
||||
db=db,
|
||||
current_user=current_user,
|
||||
transaction_type="rollback",
|
||||
)
|
||||
|
||||
|
||||
def _execute_budget_operation(
|
||||
payload: BudgetOperationRequest,
|
||||
*,
|
||||
db: Session,
|
||||
current_user: CurrentUserContext,
|
||||
transaction_type: str,
|
||||
) -> BudgetOperationRead:
|
||||
try:
|
||||
result = BudgetService(db).execute_operation(
|
||||
payload,
|
||||
transaction_type=transaction_type,
|
||||
operator=current_user.name or current_user.username,
|
||||
)
|
||||
db.commit()
|
||||
return result
|
||||
except BudgetControlError as error:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
except ValueError as error:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
|
||||
def _resolve_budget_query_scope(
|
||||
db: Session,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
department_id: str | None = None,
|
||||
department_name: str | None = None,
|
||||
cost_center: str | None = None,
|
||||
) -> dict[str, str | None]:
|
||||
if not is_budget_scope_limited_user(current_user):
|
||||
return {
|
||||
"department_id": _blank_to_none(department_id),
|
||||
"department_name": _blank_to_none(department_name),
|
||||
"cost_center": _blank_to_none(cost_center),
|
||||
}
|
||||
|
||||
employee = _resolve_current_employee(db, current_user)
|
||||
scoped_cost_center = (
|
||||
_blank_to_none(current_user.cost_center)
|
||||
or _blank_to_none(getattr(employee, "cost_center", None))
|
||||
or _blank_to_none(getattr(getattr(employee, "organization_unit", None), "cost_center", None))
|
||||
)
|
||||
scoped_department_name = (
|
||||
_blank_to_none(current_user.department_name)
|
||||
or _blank_to_none(getattr(getattr(employee, "organization_unit", None), "name", None))
|
||||
)
|
||||
|
||||
if not scoped_cost_center and not scoped_department_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="预算监控员缺少部门归属,不能查看预算中心。",
|
||||
)
|
||||
|
||||
return {
|
||||
"department_id": None,
|
||||
"department_name": None if scoped_cost_center else scoped_department_name,
|
||||
"cost_center": scoped_cost_center,
|
||||
}
|
||||
|
||||
|
||||
def _allocation_visible_to_user(
|
||||
db: Session,
|
||||
current_user: CurrentUserContext,
|
||||
allocation: BudgetAllocation,
|
||||
) -> bool:
|
||||
if not is_budget_scope_limited_user(current_user):
|
||||
return True
|
||||
|
||||
scope = _resolve_budget_query_scope(db, current_user)
|
||||
scoped_cost_center = scope.get("cost_center")
|
||||
scoped_department_name = scope.get("department_name")
|
||||
if scoped_cost_center:
|
||||
return str(allocation.cost_center or "").strip() == scoped_cost_center
|
||||
if scoped_department_name:
|
||||
return str(allocation.department_name or "").strip() == scoped_department_name
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_current_employee(db: Session, current_user: CurrentUserContext) -> Employee | None:
|
||||
identities = [
|
||||
str(current_user.username or "").strip(),
|
||||
str(current_user.name or "").strip(),
|
||||
]
|
||||
identities = [item for item in dict.fromkeys(identities) if item]
|
||||
if not identities:
|
||||
return None
|
||||
|
||||
lowered = [item.lower() for item in identities]
|
||||
stmt = (
|
||||
select(Employee)
|
||||
.options(selectinload(Employee.organization_unit))
|
||||
.where(
|
||||
or_(
|
||||
func.lower(Employee.email).in_(lowered),
|
||||
Employee.employee_no.in_(identities),
|
||||
Employee.name.in_(identities),
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
return db.scalars(stmt).first()
|
||||
|
||||
|
||||
def _blank_to_none(value: str | None) -> str | None:
|
||||
text = str(value or "").strip()
|
||||
return text or None
|
||||
@@ -505,7 +505,7 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
|
||||
"/claims/{claim_id}/return",
|
||||
response_model=ExpenseClaimRead,
|
||||
summary="退回报销单",
|
||||
description="财务人员、高级管理人员或当前审批人可将可见报销单退回到待提交状态。",
|
||||
description="财务人员、高级财务人员或当前审批人可将可见报销单退回到待提交状态。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
@@ -571,7 +571,7 @@ def approve_expense_claim(
|
||||
"/claims/{claim_id}",
|
||||
response_model=ExpenseClaimActionResponse,
|
||||
summary="删除报销单",
|
||||
description="申请人仅可删除自己的草稿、待补充或退回单据;高级管理人员可删除可见的非归档单据;已归档单据仅高级管理员可删除,财务人员没有删除权限。",
|
||||
description="申请人仅可删除自己的草稿、待补充或退回单据;高级财务人员可删除可见的非归档单据;已归档单据仅高级管理员可删除,财务人员没有删除权限。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
|
||||
@@ -5,6 +5,7 @@ from app.api.v1.endpoints.agent_runs import router as agent_runs_router
|
||||
from app.api.v1.endpoints.audit_logs import router as audit_logs_router
|
||||
from app.api.v1.endpoints.auth import router as auth_router
|
||||
from app.api.v1.endpoints.bootstrap import router as bootstrap_router
|
||||
from app.api.v1.endpoints.budgets import router as budgets_router
|
||||
from app.api.v1.endpoints.employees import router as employees_router
|
||||
from app.api.v1.endpoints.health import router as health_router
|
||||
from app.api.v1.endpoints.knowledge import router as knowledge_router
|
||||
@@ -19,6 +20,7 @@ router = APIRouter()
|
||||
router.include_router(health_router, tags=["health"])
|
||||
router.include_router(bootstrap_router, tags=["bootstrap"])
|
||||
router.include_router(auth_router, tags=["auth"])
|
||||
router.include_router(budgets_router, tags=["budgets"])
|
||||
router.include_router(agent_assets_router, tags=["agent-assets"])
|
||||
router.include_router(agent_runs_router, tags=["agent-runs"])
|
||||
router.include_router(audit_logs_router, tags=["audit-logs"])
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
115
server/src/app/models/budget.py
Normal file
115
server/src/app/models/budget.py
Normal 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")
|
||||
120
server/src/app/schemas/budget.py
Normal file
120
server/src/app/schemas/budget.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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, [[""]])])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -22,9 +22,10 @@ logger = get_logger("app.services.auth")
|
||||
ROLE_LABELS = {
|
||||
"manager": "管理员",
|
||||
"finance": "财务人员",
|
||||
"executive": "高级管理人员",
|
||||
"executive": "高级财务人员",
|
||||
"approver": "审批负责人",
|
||||
"auditor": "审计观察员",
|
||||
"budget_monitor": "预算监控员",
|
||||
"auditor": "预算监控员",
|
||||
"user": "使用者",
|
||||
}
|
||||
|
||||
|
||||
776
server/src/app/services/budget.py
Normal file
776
server/src/app/services/budget.py
Normal 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),
|
||||
)
|
||||
623
server/src/app/services/budget_support.py
Normal file
623
server/src/app/services/budget_support.py
Normal 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,
|
||||
}
|
||||
54
server/src/app/services/budget_types.py
Normal file
54
server/src/app/services/budget_types.py
Normal 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,
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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": ["可查看本部门预算执行", "可跟踪本部门预算预警与占用"],
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
96
server/src/app/services/expense_claim_budget_flow.py
Normal file
96
server/src/app/services/expense_claim_budget_flow.py
Normal 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
20
server/src/app/services/finance_rule_catalog.py
Normal file
20
server/src/app/services/finance_rule_catalog.py
Normal 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
|
||||
),
|
||||
}
|
||||
@@ -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("_", "-")}:
|
||||
|
||||
@@ -164,7 +164,7 @@ class UserAgentApplicationMixin:
|
||||
f"申请单号:{application_no}",
|
||||
"申请信息:\n" + self._build_application_summary_table(facts),
|
||||
f"当前状态:{manager_name}审核中。",
|
||||
"预算处理:用户预估费用已作为预算占用参考,等待领导审核确认。",
|
||||
"费用预估:预计费用已随申请提交,等待领导审核确认。",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user