feat: 新增预算费控模型与报销审批流引擎

后端新增预算费控服务和报销单审批流模块,引入申请人费用画像
算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常
量和明细同步,更新差旅报销规则电子表格,前端新增预算分析
组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧
边栏和顶栏样式,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-27 17:31:27 +08:00
parent cbb98f4469
commit d4d5d40569
75 changed files with 5393 additions and 686 deletions

View File

@@ -0,0 +1,21 @@
# X-Financial 核心算法推演目录
> 目录名 `algorithem` 沿用当前任务指定拼写。该目录用于沉淀核心算法推演、公式口径和可审计实现,避免把算法细节直接堆进 `services`。
## 目录职责
- 保存预算、费用、风控、知识检索等核心算法的推演文档。
- 记录公式、权重、阈值、输入输出协议和边界案例。
- 为后续 Python 实现、单元测试和接口协议提供依据。
## 当前算法主题
- `applicant_expense_profile_formula.md`:申请人费用画像与审核建议量化公式。
- `applicant_expense_profile.py`:申请人费用画像评分的第一版纯算法实现。
## 落地原则
- 算法先有可解释公式,再进入业务服务实现。
- 硬规则、评分权重和自然语言解释要分层。
- 所有核心算法模块都要遵守 800 行上限,按职责拆分。
- 涉及审批建议时,输出“依据 + 建议动作”,不要直接给人贴负面标签。

View File

@@ -0,0 +1,13 @@
"""Core algorithm derivations for X-Financial."""
from .applicant_expense_profile import (
ApplicantExpenseProfileInput,
ApplicantExpenseProfileResult,
evaluate_applicant_expense_profile,
)
__all__ = [
"ApplicantExpenseProfileInput",
"ApplicantExpenseProfileResult",
"evaluate_applicant_expense_profile",
]

View File

@@ -0,0 +1,445 @@
"""Applicant expense profile scoring algorithm.
The module is intentionally pure and framework-free. Service layers can build the
input snapshot from database records, while this module only owns the formula,
scores, thresholds, and explainable result.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any
ZERO = Decimal("0")
ONE = Decimal("1")
HUNDRED = Decimal("100")
LEVEL_NORMAL = "normal"
LEVEL_WATCH = "watch"
LEVEL_REVIEW = "review"
LEVEL_ESCALATION = "escalation"
@dataclass(slots=True)
class ApplicantExpenseProfileInput:
"""Inputs for applicant expense behavior scoring.
Values should be pre-aggregated by a comparable peer group, such as
department + role + expense type + city grade + project type.
"""
applicant_claim_count_90d: int = 0
peer_claim_count_p75_90d: Any = ZERO
applicant_amount_90d: Any = ZERO
available_peer_budget_90d: Any = ZERO
amount_percentile: Any = ZERO
peer_amount_median_90d: Any = ZERO
adjusted_or_returned_count_180d: int = 0
approved_claim_count_180d: int = 0
requested_days: Any = ZERO
peer_travel_days_p75: Any = ZERO
business_buffer_days: Any = ONE
claim_amount: Any = ZERO
peer_daily_cost_baseline: Any = ZERO
tolerance_factor: Any = Decimal("1.20")
entertainment_amount: Any = ZERO
attendee_count: int = 0
entertainment_standard_cap: Any = ZERO
same_customer_frequency_90d: int = 0
applicant_entertainment_percentile: Any = ZERO
hard_rule_score: int = 0
@dataclass(slots=True)
class ApplicantExpenseProfileResult:
profile_score: int
profile_level: str
recommendation_score: int
recommendation_level: str
frequency_score: int
amount_occupancy_score: int
peer_deviation_score: int
adjustment_history_score: int
current_claim_deviation_score: int
travel_days_deviation_score: int
daily_cost_deviation_score: int
entertainment_deviation_score: int
frequency_ratio: Decimal
budget_share_ratio: Decimal
peer_deviation_ratio: Decimal
adjustment_ratio: Decimal
travel_days_ratio: Decimal
daily_cost_ratio: Decimal
per_capita_entertainment_ratio: Decimal
suggested_days: Decimal | None
suggested_amount_cap: Decimal | None
basis_codes: list[str] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
return {
"profile_score": self.profile_score,
"profile_level": self.profile_level,
"recommendation_score": self.recommendation_score,
"recommendation_level": self.recommendation_level,
"scores": {
"frequency_score": self.frequency_score,
"amount_occupancy_score": self.amount_occupancy_score,
"peer_deviation_score": self.peer_deviation_score,
"adjustment_history_score": self.adjustment_history_score,
"current_claim_deviation_score": self.current_claim_deviation_score,
"travel_days_deviation_score": self.travel_days_deviation_score,
"daily_cost_deviation_score": self.daily_cost_deviation_score,
"entertainment_deviation_score": self.entertainment_deviation_score,
},
"metrics": {
"frequency_ratio": _format_decimal(self.frequency_ratio),
"budget_share_ratio": _format_decimal(self.budget_share_ratio),
"peer_deviation_ratio": _format_decimal(self.peer_deviation_ratio),
"adjustment_ratio": _format_decimal(self.adjustment_ratio),
"travel_days_ratio": _format_decimal(self.travel_days_ratio),
"daily_cost_ratio": _format_decimal(self.daily_cost_ratio),
"per_capita_entertainment_ratio": _format_decimal(
self.per_capita_entertainment_ratio
),
"suggested_days": _format_decimal(self.suggested_days),
"suggested_amount_cap": _format_decimal(self.suggested_amount_cap),
},
"basis_codes": list(self.basis_codes),
}
def evaluate_applicant_expense_profile(
payload: ApplicantExpenseProfileInput,
) -> ApplicantExpenseProfileResult:
frequency_ratio = _ratio(payload.applicant_claim_count_90d, payload.peer_claim_count_p75_90d)
frequency_score = _score_frequency_ratio(frequency_ratio)
budget_share_ratio = _ratio(payload.applicant_amount_90d, payload.available_peer_budget_90d)
amount_percentile_score = _score_percentile(_to_decimal(payload.amount_percentile))
budget_share_score = _score_budget_share_ratio(budget_share_ratio)
amount_occupancy_score = max(amount_percentile_score, budget_share_score)
peer_deviation_ratio = _ratio(payload.applicant_amount_90d, payload.peer_amount_median_90d)
peer_deviation_score = _score_peer_deviation_ratio(peer_deviation_ratio)
adjustment_ratio = _ratio(
payload.adjusted_or_returned_count_180d,
payload.approved_claim_count_180d,
)
adjustment_history_score = _score_adjustment_ratio(adjustment_ratio)
travel_days_ratio = _ratio(payload.requested_days, payload.peer_travel_days_p75)
travel_days_deviation_score = _score_travel_days_ratio(travel_days_ratio)
requested_days = _to_decimal(payload.requested_days)
daily_cost = _ratio(payload.claim_amount, requested_days)
daily_cost_ratio = _ratio(daily_cost, payload.peer_daily_cost_baseline)
daily_cost_deviation_score = _score_daily_cost_ratio(daily_cost_ratio)
per_capita_amount = _ratio(payload.entertainment_amount, payload.attendee_count)
per_capita_entertainment_ratio = _ratio(
per_capita_amount,
payload.entertainment_standard_cap,
)
entertainment_deviation_score = max(
_score_entertainment_per_capita_ratio(per_capita_entertainment_ratio),
_score_same_customer_frequency(payload.same_customer_frequency_90d),
_score_percentile(_to_decimal(payload.applicant_entertainment_percentile)),
)
current_claim_deviation_score = max(
travel_days_deviation_score,
daily_cost_deviation_score,
entertainment_deviation_score,
)
profile_score = _weighted_profile_score(
frequency_score=frequency_score,
amount_occupancy_score=amount_occupancy_score,
peer_deviation_score=peer_deviation_score,
adjustment_history_score=adjustment_history_score,
current_claim_deviation_score=current_claim_deviation_score,
)
hard_rule_score = _clamp_score(payload.hard_rule_score)
recommendation_score = max(profile_score, current_claim_deviation_score, hard_rule_score)
suggested_days = _suggest_days(
requested_days=requested_days,
baseline_days=_to_decimal(payload.peer_travel_days_p75),
business_buffer_days=_to_decimal(payload.business_buffer_days),
)
suggested_amount_cap = _suggest_amount_cap(
suggested_days=suggested_days,
daily_cost_baseline=_to_decimal(payload.peer_daily_cost_baseline),
tolerance_factor=_to_decimal(payload.tolerance_factor),
)
basis_codes = _build_basis_codes(
frequency_ratio=frequency_ratio,
amount_percentile=_to_decimal(payload.amount_percentile),
budget_share_ratio=budget_share_ratio,
peer_deviation_ratio=peer_deviation_ratio,
adjustment_ratio=adjustment_ratio,
travel_days_ratio=travel_days_ratio,
daily_cost_ratio=daily_cost_ratio,
per_capita_entertainment_ratio=per_capita_entertainment_ratio,
same_customer_frequency_90d=payload.same_customer_frequency_90d,
applicant_entertainment_percentile=_to_decimal(payload.applicant_entertainment_percentile),
hard_rule_score=hard_rule_score,
)
return ApplicantExpenseProfileResult(
profile_score=profile_score,
profile_level=level_from_score(profile_score),
recommendation_score=recommendation_score,
recommendation_level=level_from_score(recommendation_score),
frequency_score=frequency_score,
amount_occupancy_score=amount_occupancy_score,
peer_deviation_score=peer_deviation_score,
adjustment_history_score=adjustment_history_score,
current_claim_deviation_score=current_claim_deviation_score,
travel_days_deviation_score=travel_days_deviation_score,
daily_cost_deviation_score=daily_cost_deviation_score,
entertainment_deviation_score=entertainment_deviation_score,
frequency_ratio=frequency_ratio,
budget_share_ratio=budget_share_ratio,
peer_deviation_ratio=peer_deviation_ratio,
adjustment_ratio=adjustment_ratio,
travel_days_ratio=travel_days_ratio,
daily_cost_ratio=daily_cost_ratio,
per_capita_entertainment_ratio=per_capita_entertainment_ratio,
suggested_days=suggested_days,
suggested_amount_cap=suggested_amount_cap,
basis_codes=basis_codes,
)
def level_from_score(score: int) -> str:
normalized = _clamp_score(score)
if normalized >= 80:
return LEVEL_ESCALATION
if normalized >= 60:
return LEVEL_REVIEW
if normalized >= 40:
return LEVEL_WATCH
return LEVEL_NORMAL
def _weighted_profile_score(
*,
frequency_score: int,
amount_occupancy_score: int,
peer_deviation_score: int,
adjustment_history_score: int,
current_claim_deviation_score: int,
) -> int:
weighted = (
Decimal(frequency_score) * Decimal("0.20")
+ Decimal(amount_occupancy_score) * Decimal("0.25")
+ Decimal(peer_deviation_score) * Decimal("0.25")
+ Decimal(adjustment_history_score) * Decimal("0.15")
+ Decimal(current_claim_deviation_score) * Decimal("0.15")
)
return _clamp_score(int(weighted.quantize(Decimal("1"), rounding=ROUND_HALF_UP)))
def _score_frequency_ratio(ratio: Decimal) -> int:
return _score_ratio(
ratio,
[(Decimal("1.0"), 0), (Decimal("1.2"), 30), (Decimal("1.5"), 60), (Decimal("2.0"), 80)],
)
def _score_budget_share_ratio(ratio: Decimal) -> int:
return _score_ratio(ratio, [(Decimal("0.10"), 0), (Decimal("0.20"), 40), (Decimal("0.35"), 70)])
def _score_peer_deviation_ratio(ratio: Decimal) -> int:
return _score_ratio(ratio, [(Decimal("1.0"), 0), (Decimal("1.3"), 40), (Decimal("1.8"), 70)])
def _score_adjustment_ratio(ratio: Decimal) -> int:
return _score_ratio(ratio, [(Decimal("0.05"), 0), (Decimal("0.15"), 40), (Decimal("0.30"), 70)])
def _score_travel_days_ratio(ratio: Decimal) -> int:
return _score_ratio(ratio, [(Decimal("1.2"), 0), (Decimal("1.5"), 40), (Decimal("2.0"), 70)])
def _score_daily_cost_ratio(ratio: Decimal) -> int:
return _score_ratio(ratio, [(Decimal("1.1"), 0), (Decimal("1.3"), 40), (Decimal("1.6"), 70)])
def _score_entertainment_per_capita_ratio(ratio: Decimal) -> int:
return _score_ratio(ratio, [(Decimal("1.0"), 0), (Decimal("1.2"), 40), (Decimal("1.5"), 70)])
def _score_same_customer_frequency(frequency: int) -> int:
if frequency >= 5:
return 100
if frequency >= 3:
return 70
if frequency >= 2:
return 40
return 0
def _score_percentile(percentile: Decimal) -> int:
normalized = max(ZERO, min(HUNDRED, percentile))
if normalized <= Decimal("75"):
return 0
if normalized <= Decimal("85"):
return 40
if normalized <= Decimal("95"):
return 70
return 100
def _score_ratio(ratio: Decimal, bands: list[tuple[Decimal, int]]) -> int:
if ratio <= ZERO:
return 0
for upper_bound, score in bands:
if ratio <= upper_bound:
return score
return 100
def _suggest_days(
*,
requested_days: Decimal,
baseline_days: Decimal,
business_buffer_days: Decimal,
) -> Decimal | None:
if requested_days <= ZERO:
return None
if baseline_days <= ZERO:
return _quantize(requested_days)
return _quantize(min(requested_days, baseline_days + max(ZERO, business_buffer_days)))
def _suggest_amount_cap(
*,
suggested_days: Decimal | None,
daily_cost_baseline: Decimal,
tolerance_factor: Decimal,
) -> Decimal | None:
if suggested_days is None or suggested_days <= ZERO or daily_cost_baseline <= ZERO:
return None
factor = tolerance_factor if tolerance_factor > ZERO else ONE
return _quantize_money(suggested_days * daily_cost_baseline * factor)
def _build_basis_codes(
*,
frequency_ratio: Decimal,
amount_percentile: Decimal,
budget_share_ratio: Decimal,
peer_deviation_ratio: Decimal,
adjustment_ratio: Decimal,
travel_days_ratio: Decimal,
daily_cost_ratio: Decimal,
per_capita_entertainment_ratio: Decimal,
same_customer_frequency_90d: int,
applicant_entertainment_percentile: Decimal,
hard_rule_score: int,
) -> list[str]:
basis_codes: list[str] = []
if frequency_ratio > Decimal("1.5"):
basis_codes.append("applicant.frequency.ratio_review")
elif frequency_ratio > Decimal("1.2"):
basis_codes.append("applicant.frequency.ratio_watch")
if amount_percentile > Decimal("95"):
basis_codes.append("applicant.amount_percentile.p95")
elif amount_percentile > Decimal("85"):
basis_codes.append("applicant.amount_percentile.p85")
if budget_share_ratio > Decimal("0.35"):
basis_codes.append("applicant.budget_share.high")
elif budget_share_ratio > Decimal("0.20"):
basis_codes.append("applicant.budget_share.watch")
if peer_deviation_ratio > Decimal("1.8"):
basis_codes.append("applicant.peer_deviation.escalation")
elif peer_deviation_ratio > Decimal("1.3"):
basis_codes.append("applicant.peer_deviation.review")
if adjustment_ratio > Decimal("0.30"):
basis_codes.append("applicant.adjustment_history.escalation")
elif adjustment_ratio > Decimal("0.15"):
basis_codes.append("applicant.adjustment_history.review")
if travel_days_ratio > Decimal("2.0"):
basis_codes.append("travel.days_ratio.escalation")
elif travel_days_ratio > Decimal("1.5"):
basis_codes.append("travel.days_ratio.review")
elif travel_days_ratio > Decimal("1.2"):
basis_codes.append("travel.days_ratio.watch")
if daily_cost_ratio > Decimal("1.6"):
basis_codes.append("travel.daily_cost_ratio.escalation")
elif daily_cost_ratio > Decimal("1.3"):
basis_codes.append("travel.daily_cost_ratio.review")
elif daily_cost_ratio > Decimal("1.1"):
basis_codes.append("travel.daily_cost_ratio.watch")
if per_capita_entertainment_ratio > Decimal("1.5"):
basis_codes.append("entertainment.per_capita.escalation")
elif per_capita_entertainment_ratio > Decimal("1.2"):
basis_codes.append("entertainment.per_capita.review")
if same_customer_frequency_90d >= 5:
basis_codes.append("entertainment.same_customer.frequency_escalation")
elif same_customer_frequency_90d >= 3:
basis_codes.append("entertainment.same_customer.frequency_review")
if applicant_entertainment_percentile > Decimal("95"):
basis_codes.append("entertainment.amount_percentile.p95")
elif applicant_entertainment_percentile > Decimal("85"):
basis_codes.append("entertainment.amount_percentile.p85")
if hard_rule_score >= 80:
basis_codes.append("hard_rule.escalation")
elif hard_rule_score >= 60:
basis_codes.append("hard_rule.review")
return basis_codes
def _ratio(numerator: Any, denominator: Any) -> Decimal:
divisor = _to_decimal(denominator)
if divisor <= ZERO:
return ZERO
return _quantize(_to_decimal(numerator) / divisor)
def _to_decimal(value: Any) -> Decimal:
if value is None:
return ZERO
if isinstance(value, Decimal):
return value
try:
return Decimal(str(value))
except (InvalidOperation, ValueError):
return ZERO
def _quantize(value: Decimal) -> Decimal:
return value.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
def _quantize_money(value: Decimal) -> Decimal:
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def _format_decimal(value: Decimal | None) -> str | None:
if value is None:
return None
return format(value.normalize(), "f")
def _clamp_score(score: int) -> int:
return max(0, min(100, int(score)))

View File

@@ -0,0 +1,224 @@
# 申请人费用画像与审核建议量化公式
## 目标
申请人费用画像用于回答:
- 该申请人的近期费用节奏是否高于同组基准。
- 本次申请的天数、金额、招待频率是否明显偏离。
- 审批时应普通通过、谨慎通过、重点复核,还是升级审批。
画像结果只作为审批参考,不应直接定义员工为异常人员。
## 同组基准
同组基准必须按业务可比口径聚合:
```text
peer_group =
部门
+ 岗位/职级
+ 费用类型
+ 城市等级/业务区域
+ 项目类型/客户阶段
+ 近 90 天或近 180 天窗口
```
不能用全公司平均值直接比较销售、研发、项目经理等不同岗位。
## 画像风险分
```text
profile_score =
frequency_score * 0.20
+ amount_occupancy_score * 0.25
+ peer_deviation_score * 0.25
+ adjustment_history_score * 0.15
+ current_claim_deviation_score * 0.15
```
等级:
```text
0-39 normal 正常
40-59 watch 需关注
60-79 review 重点复核
80-100 escalation 强复核 / 升级审批
```
## 子分计算
### 高频申请分
```text
frequency_ratio = applicant_claim_count_90d / peer_claim_count_p75_90d
frequency_score =
0 if frequency_ratio <= 1.0
30 if 1.0 < frequency_ratio <= 1.2
60 if 1.2 < frequency_ratio <= 1.5
80 if 1.5 < frequency_ratio <= 2.0
100 if frequency_ratio > 2.0
```
### 高金额占用分
```text
budget_share_ratio = applicant_amount_90d / available_peer_budget_90d
amount_percentile = applicant_amount_90d 在同组中的分位数
amount_occupancy_score =
max(
percentile_score(amount_percentile),
ratio_score(budget_share_ratio)
)
```
建议第一版分位规则:
```text
P0-P75 -> 0
P75-P85 -> 40
P85-P95 -> 70
P95-P100 -> 100
```
### 同组偏离分
```text
peer_deviation_ratio =
applicant_amount_90d / peer_amount_median_90d
peer_deviation_score =
0 if peer_deviation_ratio <= 1.0
40 if 1.0 < peer_deviation_ratio <= 1.3
70 if 1.3 < peer_deviation_ratio <= 1.8
100 if peer_deviation_ratio > 1.8
```
### 历史调减退回分
```text
adjustment_ratio =
adjusted_or_returned_count_180d / approved_claim_count_180d
adjustment_history_score =
0 if adjustment_ratio <= 0.05
40 if 0.05 < adjustment_ratio <= 0.15
70 if 0.15 < adjustment_ratio <= 0.30
100 if adjustment_ratio > 0.30
```
### 本次申请偏离分
```text
current_claim_deviation_score =
max(
travel_days_deviation_score,
daily_cost_deviation_score,
entertainment_deviation_score
)
```
## 出差天数建议
```text
baseline_days = peer_group.travel_days_p75
travel_days_ratio = requested_days / baseline_days
suggested_days = min(
requested_days,
baseline_days + business_buffer_days
)
```
建议阈值:
```text
travel_days_ratio <= 1.2 正常
1.2 < ratio <= 1.5 提醒关注
1.5 < ratio <= 2.0 建议压缩天数或补充说明
ratio > 2.0 重点复核 / 升级审批
```
## 出差费用建议
```text
daily_cost = claim_amount / requested_days
daily_cost_ratio = daily_cost / peer_city_grade_daily_cost_baseline
suggested_amount_cap =
suggested_days
* peer_city_grade_daily_cost_baseline
* tolerance_factor
```
第一版 `tolerance_factor` 建议取 `1.15``1.20`
建议阈值:
```text
daily_cost_ratio <= 1.1 正常
1.1 < ratio <= 1.3 提醒关注
1.3 < ratio <= 1.6 建议调减费用标准
ratio > 1.6 重点复核 / 升级审批
```
## 招待费用建议
```text
per_capita_entertainment_amount = entertainment_amount / attendee_count
per_capita_ratio = per_capita_entertainment_amount / entertainment_standard_cap
same_customer_frequency_90d = count(customer_id, applicant_id, 90d)
applicant_entertainment_percentile =
applicant_entertainment_amount_90d 在同组中的分位数
```
建议阈值:
```text
per_capita_ratio <= 1.0 正常
1.0 < ratio <= 1.2 提醒关注
1.2 < ratio <= 1.5 建议调低招待标准
ratio > 1.5 重点复核 / 升级审批
same_customer_frequency_90d >= 3 要求补充客户推进阶段
percentile >= P85 重点复核
percentile >= P95 升级审批
```
## 审核建议强度
```text
recommendation_strength =
max(
level(profile_score),
level(current_claim_deviation_score),
level(hard_rule_score)
)
```
输出文案必须引用触发指标:
```text
该申请人近 90 天费用占用处于同组 P88
本次出差天数为同类 P75 的 1.67 倍。
建议将出差天数由 5 天调整为 4 天,
或补充客户拜访安排和项目阶段说明。
```
## 输出字段建议
```json
{
"profile_score": 66,
"profile_level": "review",
"peer_percentile": 88,
"travel_days_ratio": "1.67",
"daily_cost_ratio": "1.34",
"same_customer_frequency_90d": 3,
"suggested_days": 4,
"suggested_amount_cap": "6800.00",
"basis_codes": [
"applicant.peer_percentile.p85",
"travel.days_ratio.review"
]
}
```

View File

@@ -7,6 +7,7 @@ from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.budget import BudgetClaimAnalysisRead
from app.schemas.common import ErrorResponse
from app.schemas.reimbursement import (
ExpenseClaimAttachmentActionResponse,
@@ -25,6 +26,7 @@ from app.schemas.reimbursement import (
TravelReimbursementCalculatorResponse,
)
from app.services.expense_claims import ExpenseClaimService
from app.services.budget import BudgetService
from app.services.reimbursement import ReimbursementService
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
@@ -126,6 +128,38 @@ def get_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -
return claim
@router.get(
"/claims/{claim_id}/budget-analysis",
response_model=BudgetClaimAnalysisRead,
summary="读取申请单预算分析",
description="根据当前预算池、申请金额和预算管控模型,返回费用申请的预算影响和评分建议。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "单据不存在。",
},
status.HTTP_403_FORBIDDEN: {
"model": ErrorResponse,
"description": "当前用户无权查看预算分析。",
},
},
)
def get_expense_claim_budget_analysis(
claim_id: str,
db: DbSession,
current_user: CurrentUser,
) -> BudgetClaimAnalysisRead:
service = ExpenseClaimService(db)
if not service.can_view_budget_analysis(current_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有预算监控员或高级财务人员可以查看预算分析。")
claim = service.get_claim(claim_id, current_user)
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
if not service.can_view_budget_analysis(current_user, claim):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有该部门 P8 预算监控员或高级财务人员可以查看预算分析。")
return BudgetService(db).analyze_claim_budget(claim)
@router.patch(
"/claims/{claim_id}",
response_model=ExpenseClaimRead,
@@ -538,7 +572,7 @@ def return_expense_claim(
"/claims/{claim_id}/approve",
response_model=ExpenseClaimRead,
summary="审批通过单据",
description="费用申请由直属领导审批通过后完成;报销单直属领导审批后流转到财务审批,财务终审通过后进入归档入账",
description="费用申请由直属领导审批后流转到预算管理者审批,预算审核通过后生成报销草稿;报销单直属领导审批后流转到财务审批。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
@@ -144,6 +145,17 @@ class BudgetCheckRead(BaseModel):
allocation: BudgetAllocationRead | None = None
class BudgetClaimAnalysisRead(BaseModel):
budget_context: dict[str, Any] = Field(default_factory=dict)
score: int
rating: str
risk_level: str
summary: str
metrics: dict[str, Any] = Field(default_factory=dict)
basis: list[str] = Field(default_factory=list)
suggestions: list[str] = Field(default_factory=list)
class BudgetOperationRead(BaseModel):
ok: bool
message: str

View File

@@ -87,7 +87,10 @@ class AgentFoundationService(
def _foundation_cache_key(self) -> str:
bind = self.db.get_bind()
return str(getattr(bind, "url", "") or id(bind))
url = str(getattr(bind, "url", "") or "")
if url.endswith("/:memory:"):
return f"{url}:{id(bind)}"
return url or str(id(bind))
def _ensure_financial_record_schema(self) -> None:
bind = self.db.get_bind()

View File

@@ -18,6 +18,7 @@ from app.services.knowledge_ingest_log import enrich_knowledge_ingest_route_json
logger = get_logger("app.services.agent_runs")
KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT = timedelta(minutes=30)
KNOWLEDGE_SYNC_JOB_TYPES = {"knowledge_index_sync", "llm_wiki_sync"}
class AgentRunService:
@@ -262,7 +263,7 @@ class AgentRunService:
continue
route_json = dict(run.route_json or {})
if str(route_json.get("job_type") or "").strip() != "knowledge_index_sync":
if str(route_json.get("job_type") or "").strip() not in KNOWLEDGE_SYNC_JOB_TYPES:
continue
heartbeat_at = self._parse_heartbeat_time(

View File

@@ -20,6 +20,7 @@ from app.schemas.budget import (
BudgetSummaryRead,
BudgetTransactionRead,
)
from app.services.budget_expense_control import BudgetExpenseControlModel
from app.services.budget_support import BudgetSupportMixin
from app.services.budget_types import BudgetControlError, SUPPORTED_BUDGET_SUBJECT_CODES
@@ -112,6 +113,9 @@ class BudgetService(BudgetSupportMixin):
warnings=warnings,
)
def analyze_claim_budget(self, claim: ExpenseClaim) -> dict[str, Any]:
return BudgetExpenseControlModel().assess(self.build_claim_budget_context(claim), claim)
def create_or_update_allocation(
self,
payload: BudgetAllocationCreate,

View File

@@ -0,0 +1,194 @@
from __future__ import annotations
from decimal import Decimal, InvalidOperation
from typing import Any
from app.models.financial_record import ExpenseClaim
class BudgetExpenseControlModel:
"""预算费用管控模型:用预算容量、单据影响、规则动作和信息完整度给出建议。"""
def assess(self, budget_context: dict[str, Any], claim: ExpenseClaim | None = None) -> dict[str, Any]:
context = dict(budget_context or {})
amount = self._money(context.get("claim_amount"))
if not context.get("budget_applicable", True):
return self._build_result(
context=context,
score=72,
rating="reference",
risk_level="low",
summary="该费用类型暂未纳入预算强管控,本次仅作为申请合理性参考。",
basis=["当前费用科目未启用预算控制。"],
suggestions=["预算管理者可结合项目必要性和历史同类费用进行人工判断。"],
)
if not context.get("matched"):
return self._build_result(
context=context,
score=38,
rating="block",
risk_level="high",
summary="未匹配到可用预算池,建议先完成预算编制或调整预算维度后再审批。",
basis=["系统按部门、成本中心、项目和费用科目未找到匹配预算额度。"],
suggestions=["请预算编制者补充对应预算池,或核对申请部门、项目和费用类型是否填写正确。"],
)
total = self._money(context.get("total_amount"))
reserved = self._money(context.get("reserved_amount"))
consumed = self._money(context.get("consumed_amount"))
current_reserved = self._money(context.get("current_reserved_amount"))
warning_threshold = self._money(context.get("warning_threshold") or "80")
used_before = max(reserved + consumed - current_reserved, Decimal("0.00"))
available_before = max(total - used_before, Decimal("0.00"))
after_used = used_before + amount
claim_ratio = self._percent(amount, total)
after_usage_rate = self._percent(after_used, total)
over_budget_amount = max(amount - available_before, Decimal("0.00"))
score = 100
basis = [
f"预算池 {context.get('budget_no') or '未命名'} 总额度 {self._fmt(total)} 元。",
f"本次申请金额 {self._fmt(amount)} 元,占预算 {self._fmt(claim_ratio)}%。",
f"审批后预算使用率预计 {self._fmt(after_usage_rate)}%,预警线 {self._fmt(warning_threshold)}%。",
]
suggestions: list[str] = []
if over_budget_amount > Decimal("0.00"):
score -= 55
basis.append(f"按当前预算余额测算,本次申请将超出预算 {self._fmt(over_budget_amount)} 元。")
suggestions.append("建议先追加或调剂预算,再允许申请继续流转。")
elif after_usage_rate >= Decimal("100.00"):
score -= 38
basis.append("审批后预算使用率将达到或超过 100%")
suggestions.append("建议预算管理者复核剩余额度,并确认是否需要预算调剂。")
elif after_usage_rate >= warning_threshold:
score -= 20
basis.append("审批后预算使用率将触达预算预警线。")
suggestions.append("建议关注后续同类费用,必要时提前调整预算节奏。")
elif after_usage_rate >= Decimal("70.00"):
score -= 8
basis.append("审批后预算使用率较高,但尚未触达预警线。")
if claim_ratio >= Decimal("50.00"):
score -= 20
basis.append("单笔申请占预算比例超过 50%,对预算池影响较大。")
suggestions.append("建议补充业务必要性、交付范围和费用拆分依据。")
elif claim_ratio >= Decimal("30.00"):
score -= 12
basis.append("单笔申请占预算比例超过 30%,需要关注预算节奏。")
elif claim_ratio >= Decimal("15.00"):
score -= 5
basis.append("单笔申请占预算比例超过 15%,属于中等预算影响。")
missing_fields = self._collect_context_gaps(claim)
if missing_fields:
score -= min(12, len(missing_fields) * 4)
basis.append(f"申请信息仍缺少:{''.join(missing_fields)}")
suggestions.append("建议申请人补齐业务背景,便于预算管理者判断费用必要性。")
control_action = str(context.get("control_action") or "").strip().lower()
if control_action == "block" and over_budget_amount > Decimal("0.00"):
suggestions.append("该预算池为硬控制口径,超预算时不建议直接通过。")
score = max(0, min(100, int(round(score))))
rating, risk_level = self._rate(score, over_budget_amount)
if not suggestions:
suggestions.append("预算额度与本次费用影响基本匹配,可以结合业务必要性继续审批。")
return self._build_result(
context={
**context,
"claim_amount_ratio": str(claim_ratio),
"after_usage_rate": str(after_usage_rate),
"available_before_amount": str(available_before),
"over_budget_amount": str(over_budget_amount),
},
score=score,
rating=rating,
risk_level=risk_level,
summary=self._summary(rating),
basis=basis,
suggestions=suggestions,
)
@staticmethod
def _collect_context_gaps(claim: ExpenseClaim | None) -> list[str]:
if claim is None:
return []
gaps = []
if not str(claim.reason or "").strip():
gaps.append("申请事由")
if not str(claim.location or "").strip():
gaps.append("地点")
if not str(claim.project_code or "").strip():
gaps.append("项目编号")
return gaps
@staticmethod
def _rate(score: int, over_budget_amount: Decimal) -> tuple[str, str]:
if over_budget_amount > Decimal("0.00") or score < 50:
return "block", "high"
if score < 70:
return "review", "medium"
if score < 85:
return "caution", "medium"
return "recommended", "low"
@staticmethod
def _summary(rating: str) -> str:
summaries = {
"recommended": "预算容量充足,单据费用与当前预算节奏基本匹配。",
"caution": "预算整体可承接,但本次费用对预算池已有一定影响。",
"review": "预算影响偏高,建议预算管理者结合业务必要性复核后再通过。",
"block": "预算风险较高,不建议在未补充依据或调整预算前直接通过。",
}
return summaries.get(rating, "已完成预算费用合理性测算。")
@staticmethod
def _build_result(
*,
context: dict[str, Any],
score: int,
rating: str,
risk_level: str,
summary: str,
basis: list[str],
suggestions: list[str],
) -> dict[str, Any]:
return {
"budget_context": context,
"score": score,
"rating": rating,
"risk_level": risk_level,
"summary": summary,
"basis": basis,
"suggestions": suggestions,
"metrics": {
"claim_amount": context.get("claim_amount"),
"total_amount": context.get("total_amount"),
"claim_amount_ratio": context.get("claim_amount_ratio", "0.00"),
"usage_rate": context.get("usage_rate", "0.00"),
"after_usage_rate": context.get("after_usage_rate", context.get("usage_rate", "0.00")),
"available_amount": context.get("available_amount"),
"available_before_amount": context.get("available_before_amount"),
"over_budget_amount": context.get("over_budget_amount", "0.00"),
},
}
@staticmethod
def _money(value: Any) -> Decimal:
try:
return Decimal(str(value if value is not None else "0")).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return Decimal("0.00")
@staticmethod
def _percent(numerator: Decimal, denominator: Decimal) -> Decimal:
if denominator <= Decimal("0.00"):
return Decimal("0.00")
return ((numerator / denominator) * Decimal("100")).quantize(Decimal("0.01"))
@staticmethod
def _fmt(value: Decimal) -> str:
return f"{value.quantize(Decimal('0.01'))}"

View File

@@ -310,6 +310,14 @@ class BudgetSupportMixin:
}
balance = self.get_balance(allocation)
reservation_source_type = self._reservation_source_type_from_claim(claim)
current_reservation = self._find_active_reservation(
source_type=reservation_source_type,
source_id=claim.id,
)
current_reserved_amount = self._money(
current_reservation.amount if current_reservation is not None else Decimal("0.00")
)
over_budget_amount = max(amount - balance.available_amount, Decimal("0.00"))
return {
"matched": True,
@@ -319,6 +327,7 @@ class BudgetSupportMixin:
"claim_amount": str(amount),
"total_amount": str(balance.total_amount),
"reserved_amount": str(balance.reserved_amount),
"current_reserved_amount": str(current_reserved_amount),
"consumed_amount": str(balance.consumed_amount),
"available_amount": str(balance.available_amount),
"usage_rate": str(balance.usage_rate),
@@ -335,6 +344,14 @@ class BudgetSupportMixin:
"project_code": allocation.project_code,
}
@staticmethod
def _reservation_source_type_from_claim(claim: ExpenseClaim) -> str:
claim_no = str(claim.claim_no or "").strip().upper()
expense_type = str(claim.expense_type or "").strip().lower()
if claim_no.startswith(("AP-", "APP-")) or expense_type == "application" or expense_type.endswith("_application"):
return "application"
return "claim"
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(

View File

@@ -10,14 +10,25 @@ from app.api.deps import CurrentUserContext
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.organization import OrganizationUnit
from app.models.role import Role
from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE,
ARCHIVE_ACCOUNTING_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"}
ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive"}
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
BUDGET_APPROVAL_ROLE_CODES = {"budget_monitor", "executive"}
BUDGET_MONITOR_ROLE_CODE = "budget_monitor"
BUDGET_MONITOR_APPROVAL_GRADE = "P8"
CLAIM_DELETE_ROLE_CODES = {"executive"}
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
APPLICATION_ARCHIVED_STAGES = ("审批完成", "申请归档", "completed")
APPLICATION_ARCHIVED_STAGES = (APPROVAL_DONE_STAGE, "申请归档", "completed")
class ExpenseClaimAccessPolicy:
@@ -49,7 +60,7 @@ class ExpenseClaimAccessPolicy:
normalized_type.like("%\\_application", escape="\\"),
)
return or_(
stage == "归档入账",
stage == ARCHIVE_ACCOUNTING_STAGE,
stage == "completed",
and_(
application_condition,
@@ -61,7 +72,7 @@ class ExpenseClaimAccessPolicy:
or_(
stage == "",
stage.is_(None),
stage == "归档入账",
stage == ARCHIVE_ACCOUNTING_STAGE,
stage == "completed",
),
),
@@ -77,7 +88,7 @@ class ExpenseClaimAccessPolicy:
def is_archived_claim(claim: ExpenseClaim) -> bool:
normalized_status = str(claim.status or "").strip().lower()
stage = str(claim.approval_stage or "").strip()
if stage in {"归档入账", "completed"}:
if stage in {ARCHIVE_ACCOUNTING_STAGE, "completed"}:
return True
normalized_type = str(claim.expense_type or "").strip().lower()
claim_no = str(claim.claim_no or "").strip().upper()
@@ -92,7 +103,7 @@ class ExpenseClaimAccessPolicy:
and stage in APPLICATION_ARCHIVED_STAGES
):
return True
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", "归档入账", "completed"}
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", ARCHIVE_ACCOUNTING_STAGE, "completed"}
def can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
normalized_status = str(claim.status or "").strip().lower()
@@ -100,9 +111,11 @@ class ExpenseClaimAccessPolicy:
return False
stage = str(claim.approval_stage or "").strip()
if stage == "直属领导审批":
if stage == DIRECT_MANAGER_APPROVAL_STAGE:
return self.is_current_direct_manager_approver(current_user, claim)
if stage == "财务审批":
if stage == BUDGET_MANAGER_APPROVAL_STAGE:
return self.is_budget_manager_approver(current_user, claim)
if stage == FINANCE_APPROVAL_STAGE:
return self.has_privileged_claim_access(current_user) and not self.is_claim_owned_by_current_user(
claim,
current_user,
@@ -111,9 +124,11 @@ class ExpenseClaimAccessPolicy:
def can_approve_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
stage = str(claim.approval_stage or "").strip()
if stage == "直属领导审批":
if stage == DIRECT_MANAGER_APPROVAL_STAGE:
return self.is_current_direct_manager_approver(current_user, claim)
if stage == "财务审批":
if stage == BUDGET_MANAGER_APPROVAL_STAGE:
return self.is_budget_manager_approver(current_user, claim)
if stage == FINANCE_APPROVAL_STAGE:
role_codes = self.normalize_role_codes(current_user)
return (
(current_user.is_admin or "finance" in role_codes)
@@ -127,7 +142,7 @@ class ExpenseClaimAccessPolicy:
return False
if str(claim.status or "").strip().lower() != "submitted":
return False
if str(claim.approval_stage or "").strip() != "直属领导审批":
if str(claim.approval_stage or "").strip() != DIRECT_MANAGER_APPROVAL_STAGE:
return False
current_employee = self.resolve_current_employee(current_user)
@@ -149,6 +164,65 @@ class ExpenseClaimAccessPolicy:
return self.resolve_claim_manager_name(claim) == approver_name
def is_budget_manager_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
if str(claim.status or "").strip().lower() != "submitted":
return False
if str(claim.approval_stage or "").strip() != BUDGET_MANAGER_APPROVAL_STAGE:
return False
if self.is_claim_owned_by_current_user(claim, current_user):
return False
if current_user.is_admin:
return True
role_codes = self.normalize_role_codes(current_user)
if "executive" in role_codes:
return True
return self.is_department_p8_budget_monitor(current_user, claim)
def is_budget_manager_user(self, current_user: CurrentUserContext) -> bool:
if current_user.is_admin:
return True
role_codes = self.normalize_role_codes(current_user)
return bool(role_codes & BUDGET_APPROVAL_ROLE_CODES)
def is_department_p8_budget_monitor(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
role_codes = self.normalize_role_codes(current_user)
if BUDGET_MONITOR_ROLE_CODE not in role_codes:
return False
current_employee = self.resolve_current_employee(current_user)
if current_employee is None:
return False
if not self._employee_has_budget_approval_grade(current_employee):
return False
return self._employee_matches_claim_department(current_employee, current_user, claim)
def resolve_department_budget_manager(self, claim: ExpenseClaim) -> Employee | None:
department_ids, department_names = self._collect_claim_department_identity(claim)
department_conditions = []
if department_ids:
department_conditions.append(Employee.organization_unit_id.in_(department_ids))
if department_names:
department_conditions.append(Employee.organization_unit.has(OrganizationUnit.name.in_(department_names)))
if not department_conditions:
return None
stmt = (
select(Employee)
.options(selectinload(Employee.organization_unit), selectinload(Employee.roles))
.where(
func.upper(func.coalesce(Employee.grade, "")) == BUDGET_MONITOR_APPROVAL_GRADE,
Employee.roles.any(Role.role_code == BUDGET_MONITOR_ROLE_CODE),
or_(*department_conditions),
)
.order_by(Employee.name.asc(), Employee.employee_no.asc())
.limit(1)
)
claim_employee_id = str(claim.employee_id or "").strip()
if claim_employee_id:
stmt = stmt.where(Employee.id != claim_employee_id)
return self.db.scalar(stmt)
@staticmethod
def normalize_role_codes(current_user: CurrentUserContext) -> set[str]:
return {
@@ -157,6 +231,51 @@ class ExpenseClaimAccessPolicy:
if str(item).strip()
}
@staticmethod
def _employee_has_budget_approval_grade(employee: Employee) -> bool:
return str(employee.grade or "").strip().upper() == BUDGET_MONITOR_APPROVAL_GRADE
def _employee_matches_claim_department(
self,
employee: Employee,
current_user: CurrentUserContext,
claim: ExpenseClaim,
) -> bool:
claim_department_ids, claim_department_names = self._collect_claim_department_identity(claim)
employee_department_ids = {
str(employee.organization_unit_id or "").strip(),
}
employee_department_names = {
str(current_user.department_name or "").strip(),
}
if employee.organization_unit is not None:
employee_department_names.add(str(employee.organization_unit.name or "").strip())
employee_department_ids.discard("")
employee_department_names.discard("")
return bool(
(claim_department_ids and employee_department_ids & claim_department_ids)
or (claim_department_names and employee_department_names & claim_department_names)
)
def _collect_claim_department_identity(self, claim: ExpenseClaim) -> tuple[set[str], set[str]]:
department_ids = {
str(claim.department_id or "").strip(),
}
department_names = {
str(claim.department_name or "").strip(),
}
claim_employee = self.resolve_claim_employee_for_backfill(claim)
if claim_employee is not None:
department_ids.add(str(claim_employee.organization_unit_id or "").strip())
if claim_employee.organization_unit is not None:
department_names.add(str(claim_employee.organization_unit.name or "").strip())
department_ids.discard("")
department_names.discard("")
return department_ids, department_names
def resolve_current_employee(self, current_user: CurrentUserContext) -> Employee | None:
return self.resolve_employee_by_identity_candidates(
[
@@ -375,7 +494,7 @@ class ExpenseClaimAccessPolicy:
).strip()
pending_leader_approval_parts = [
ExpenseClaim.status == "submitted",
ExpenseClaim.approval_stage == "直属领导审批",
ExpenseClaim.approval_stage == DIRECT_MANAGER_APPROVAL_STAGE,
]
if employee is not None:
pending_leader_approval_parts.append(
@@ -399,17 +518,55 @@ class ExpenseClaimAccessPolicy:
return conditions
def build_budget_approval_claim_conditions(self, current_user: CurrentUserContext) -> list[Any]:
role_codes = self.normalize_role_codes(current_user)
if BUDGET_MONITOR_ROLE_CODE not in role_codes:
return []
employee = self.resolve_current_employee(current_user)
if employee is None or not self._employee_has_budget_approval_grade(employee):
return []
pending_budget_approval_parts = [
ExpenseClaim.status == "submitted",
ExpenseClaim.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE,
]
pending_budget_approval_parts.append(
or_(ExpenseClaim.employee_id.is_(None), ExpenseClaim.employee_id != employee.id)
)
if employee.name:
pending_budget_approval_parts.append(ExpenseClaim.employee_name != employee.name)
department_conditions = []
department_name = str(current_user.department_name or "").strip()
if employee.organization_unit_id:
department_conditions.append(ExpenseClaim.department_id == employee.organization_unit_id)
subordinate_department_employee_ids = select(Employee.id).where(
Employee.organization_unit_id == employee.organization_unit_id
)
department_conditions.append(ExpenseClaim.employee_id.in_(subordinate_department_employee_ids))
if employee.organization_unit is not None and employee.organization_unit.name:
department_conditions.append(ExpenseClaim.department_name == employee.organization_unit.name)
if department_name:
department_conditions.append(ExpenseClaim.department_name == department_name)
if not department_conditions:
return []
pending_budget_approval_parts.append(or_(*department_conditions))
return [and_(*pending_budget_approval_parts)]
def apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
role_codes = self.normalize_role_codes(current_user)
if current_user.is_admin or "executive" in role_codes:
return stmt.where(ExpenseClaim.status == "submitted")
conditions = []
if "finance" in role_codes:
return stmt.where(
conditions.append(and_(
ExpenseClaim.status == "submitted",
ExpenseClaim.approval_stage == "财务审批",
)
ExpenseClaim.approval_stage == FINANCE_APPROVAL_STAGE,
))
conditions = self.build_approval_claim_conditions(current_user)
conditions.extend(self.build_budget_approval_claim_conditions(current_user))
conditions.extend(self.build_approval_claim_conditions(current_user))
if not conditions:
return stmt.where(ExpenseClaim.id == "__no_visible_claim__")
@@ -440,6 +597,7 @@ class ExpenseClaimAccessPolicy:
return stmt.where(ExpenseClaim.id == "__no_visible_claim__")
if include_approval_scope:
conditions.extend(self.build_budget_approval_claim_conditions(current_user))
conditions.extend(self.build_approval_claim_conditions(current_user))
return stmt.where(or_(*conditions))

View File

@@ -68,7 +68,10 @@ class ExpenseClaimApplicationHandoffMixin:
"application_claim_no": application_claim.claim_no,
"application_budget_amount": str(application_claim.amount or Decimal("0.00")),
"application_approval_event_id": str(approval_flag.get("approval_event_id") or ""),
"leader_opinion": str(approval_flag.get("opinion") or "").strip(),
"leader_opinion": str(
approval_flag.get("leader_opinion") or approval_flag.get("opinion") or ""
).strip(),
"budget_opinion": str(approval_flag.get("budget_opinion") or "").strip(),
"created_at": created_at.isoformat(),
}
],

View File

@@ -0,0 +1,173 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from app.api.deps import CurrentUserContext
from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE,
ARCHIVE_ACCOUNTING_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE,
)
class ExpenseClaimApprovalFlowMixin:
def approve_claim(
self,
claim_id: str,
current_user: CurrentUserContext,
*,
opinion: str | None = None,
):
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
normalized_status = str(claim.status or "").strip().lower()
if normalized_status != "submitted":
raise ValueError("只有审批中的单据可以审批通过。")
previous_stage = str(claim.approval_stage or "").strip()
is_application_claim = self._is_expense_application_claim(claim)
next_budget_manager = None
if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE:
if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
approval_source = "manual_approval"
event_type = "expense_application_approval" if is_application_claim else "expense_claim_approval"
label = "领导审批通过"
if is_application_claim:
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
next_status = "submitted"
next_stage = BUDGET_MANAGER_APPROVAL_STAGE
default_message = "{operator} 已确认直属领导审核,流转至预算管理者审批。"
else:
next_status = "submitted"
next_stage = FINANCE_APPROVAL_STAGE
default_message = "{operator} 已审批通过,流转至{next_stage}"
elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
if not is_application_claim:
raise ValueError("只有费用申请需要预算管理者审批。")
if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有当前预算管理者可以审批通过该费用申请。")
approval_source = "budget_approval"
event_type = "expense_application_budget_approval"
label = "预算管理者审核通过"
next_status = "approved"
next_stage = APPROVAL_DONE_STAGE
default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。"
elif previous_stage == FINANCE_APPROVAL_STAGE:
if is_application_claim:
raise ValueError("费用申请需先完成预算管理者审批。")
if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有财务人员可以完成财务终审。")
approval_source = "finance_approval"
event_type = "expense_claim_finance_approval"
label = "财务审核通过"
next_status = "approved"
next_stage = ARCHIVE_ACCOUNTING_STAGE
default_message = "{operator} 已完成财务审核,进入归档入账。"
else:
raise ValueError("当前节点不支持审批通过。")
approval_opinion = str(opinion or "").strip()
if previous_stage in {DIRECT_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE} and not approval_opinion:
approval_opinion = "同意"
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,
"approval_event_id": str(uuid.uuid4()),
"severity": "info",
"label": label,
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
"opinion": approval_opinion,
"operator": operator,
"operator_username": current_user.username,
"operator_role_codes": [
str(item).strip().lower()
for item in current_user.role_codes
if str(item).strip()
],
"previous_status": str(claim.status or "").strip(),
"previous_approval_stage": previous_stage,
"next_status": next_status,
"next_approval_stage": next_stage,
"created_at": datetime.now(UTC).isoformat(),
}
if next_budget_manager is not None:
approval_flag.update(
{
"next_approver_name": str(next_budget_manager.name or "").strip(),
"next_approver_employee_id": next_budget_manager.id,
"next_approver_grade": str(next_budget_manager.grade or "").strip(),
"next_approver_role_code": "budget_monitor",
}
)
claim.status = next_status
claim.approval_stage = next_stage
if claim.submitted_at is None:
claim.submitted_at = datetime.now(UTC)
if is_application_claim and previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
approval_flag["leader_opinion"] = self._resolve_latest_approval_opinion(
claim,
source="manual_approval",
)
approval_flag["budget_opinion"] = approval_opinion
generated_draft = self._create_reimbursement_draft_from_application(
application_claim=claim,
approval_flag=approval_flag,
operator=operator,
)
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)
self.audit_service.log_action(
actor=operator,
action="expense_claim.approve",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
return claim
@staticmethod
def _resolve_latest_approval_opinion(claim, *, source: str) -> str:
for flag in reversed(list(claim.risk_flags_json or [])):
if not isinstance(flag, dict):
continue
if str(flag.get("source") or "").strip() != source:
continue
opinion = str(flag.get("opinion") or flag.get("message") or "").strip()
if opinion:
return opinion
return ""

View File

@@ -225,6 +225,7 @@ RETURN_REASON_OPTIONS = {
"application_budget_basis_missing": "预算测算依据不足",
"application_policy_mismatch": "制度口径不匹配",
"application_attachment_needed": "前置材料需补充",
"application_other": "其他",
}
MAX_CLAIM_NO_RETRY_ATTEMPTS = 3
DOCUMENT_DATE_PATTERN = re.compile(

View File

@@ -12,6 +12,7 @@ from sqlalchemy import inspect as sqlalchemy_inspect
from app.api.deps import CurrentUserContext
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
from app.models.agent_asset import AgentAsset
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
@@ -50,7 +51,7 @@ class ExpenseClaimItemSyncMixin:
self._discard_claim_item(claim, item)
return
grade = str(claim.employee_grade or "").strip()
grade = self._resolve_claim_employee_grade(claim)
if not grade:
return
@@ -115,6 +116,16 @@ class ExpenseClaimItemSyncMixin:
item.item_amount = allowance_amount
item.invoice_id = None
def _resolve_claim_employee_grade(self, claim: ExpenseClaim) -> str:
grade = str(claim.employee_grade or "").strip()
if grade:
return grade
employee_id = str(claim.employee_id or "").strip()
if not employee_id:
return ""
employee = self.db.get(Employee, employee_id)
return str(employee.grade if employee is not None and employee.grade else "").strip()
def _discard_claim_item(self, claim: ExpenseClaim, item: ExpenseClaimItem) -> None:
if item in claim.items:
claim.items.remove(item)

View File

@@ -204,6 +204,8 @@ class ExpenseClaimReadModelMixin:
normalized = str(stage or "").strip()
if "直属" in normalized or "领导" in normalized or "负责人" in normalized:
return "direct_manager"
if "预算" in normalized:
return "budget"
if "财务" in normalized:
return "finance"
if "AI" in normalized or "预审" in normalized:

View File

@@ -0,0 +1,6 @@
DIRECT_MANAGER_APPROVAL_STAGE = "直属领导审批"
BUDGET_MANAGER_APPROVAL_STAGE = "预算管理者审批"
FINANCE_APPROVAL_STAGE = "财务审批"
APPROVAL_DONE_STAGE = "审批完成"
ARCHIVE_ACCOUNTING_STAGE = "归档入账"

View File

@@ -35,6 +35,7 @@ from app.services.audit import AuditLogService
from app.services.document_intelligence import build_document_insight
from app.services.document_numbering import is_application_claim_no
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin
@@ -127,6 +128,7 @@ from app.services.ocr import OcrService
class ExpenseClaimService(
ExpenseClaimApprovalFlowMixin,
ExpenseClaimApplicationHandoffMixin,
ExpenseClaimBudgetFlowMixin,
ExpenseClaimAttachmentOperationsMixin,
@@ -234,6 +236,18 @@ class ExpenseClaimService(
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
return self.db.scalar(stmt)
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
if claim is None:
return self._access_policy.is_budget_manager_user(current_user)
if current_user.is_admin:
return True
role_codes = self._access_policy.normalize_role_codes(current_user)
if "executive" in role_codes:
return True
if self._access_policy.is_claim_owned_by_current_user(claim, current_user):
return False
return self._access_policy.is_department_p8_budget_monitor(current_user, claim)
def update_claim(
self,
*,
@@ -562,9 +576,6 @@ class ExpenseClaimService(
if claim is None:
return None
if not self._access_policy.can_return_claim(current_user, claim):
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
normalized_status = str(claim.status or "").strip().lower()
if normalized_status == "draft":
raise ValueError("草稿状态无需退回。")
@@ -573,6 +584,9 @@ class ExpenseClaimService(
if normalized_status in {"approved", "completed", "paid"}:
raise ValueError("已完成单据不允许退回。")
if not self._access_policy.can_return_claim(current_user, claim):
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
before_json = self._serialize_claim(claim)
operator = self._access_policy.resolve_current_user_display_name(current_user)
previous_status = str(claim.status or "").strip()
@@ -580,21 +594,25 @@ class ExpenseClaimService(
previous_stage_key = self._normalize_return_stage_key(previous_stage)
is_application_claim = self._is_expense_application_claim(claim)
is_direct_manager_return = previous_stage_key == "direct_manager"
is_budget_return = previous_stage_key == "budget"
is_application_return = is_application_claim and (is_direct_manager_return or is_budget_return)
return_event_type = (
"expense_application_return"
if is_application_claim and is_direct_manager_return
if is_application_return
else "expense_claim_return"
)
return_label = (
"领导退回"
if is_application_claim and is_direct_manager_return
else "预算退回"
if is_application_claim and is_budget_return
else "人工退回"
)
return_reason = str(reason or "").strip()
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
normalized_reason_codes = reason_code_payload["reason_codes"]
unknown_reason_codes = reason_code_payload["unknown_reason_codes"]
if is_application_claim and is_direct_manager_return and not any(
if is_application_return and not any(
code.startswith("application_") for code in normalized_reason_codes
):
raise ValueError("申请单退回必须选择至少一个退单类型。")
@@ -627,6 +645,7 @@ class ExpenseClaimService(
"reason": return_reason,
"opinion": message,
"leader_opinion": message if is_application_claim and is_direct_manager_return else "",
"budget_opinion": message if is_application_claim and is_budget_return else "",
"reason_codes": normalized_reason_codes,
"risk_points": risk_points,
"operator": operator,
@@ -676,204 +695,6 @@ class ExpenseClaimService(
return claim
def approve_claim(
self,
claim_id: str,
current_user: CurrentUserContext,
*,
opinion: str | None = None,
) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
normalized_status = str(claim.status or "").strip().lower()
if normalized_status != "submitted":
raise ValueError("只有审批中的单据可以审批通过。")
previous_stage = str(claim.approval_stage or "").strip()
is_application_claim = self._is_expense_application_claim(claim)
if previous_stage == "直属领导审批":
if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
approval_source = "manual_approval"
if is_application_claim:
event_type = "expense_application_approval"
label = "领导审批通过"
next_status = "approved"
next_stage = "审批完成"
default_message = "{operator} 已确认审核,申请流程完成并生成报销草稿。"
else:
event_type = "expense_claim_approval"
label = "领导审批通过"
next_status = "submitted"
next_stage = "财务审批"
default_message = "{operator} 已审批通过,流转至{next_stage}"
elif previous_stage == "财务审批":
if is_application_claim:
raise ValueError("费用申请无需财务审批,直属领导审批通过后即完成。")
if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有财务人员可以完成财务终审。")
approval_source = "finance_approval"
event_type = "expense_claim_finance_approval"
label = "财务审核通过"
next_status = "approved"
next_stage = "归档入账"
default_message = "{operator} 已完成财务审核,进入归档入账。"
else:
raise ValueError("当前节点不支持审批通过。")
approval_opinion = str(opinion or "").strip()
if previous_stage == "直属领导审批" and not approval_opinion:
raise ValueError("领导审核意见不能为空,请填写意见后再确认审核。")
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,
"approval_event_id": str(uuid.uuid4()),
"severity": "info",
"label": label,
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
"opinion": approval_opinion,
"operator": operator,
"operator_username": current_user.username,
"operator_role_codes": [
str(item).strip().lower()
for item in current_user.role_codes
if str(item).strip()
],
"previous_status": str(claim.status or "").strip(),
"previous_approval_stage": previous_stage,
"next_status": next_status,
"next_approval_stage": next_stage,
"created_at": datetime.now(UTC).isoformat(),
}
claim.status = next_status
claim.approval_stage = next_stage
if claim.submitted_at is None:
claim.submitted_at = datetime.now(UTC)
if is_application_claim and previous_stage == "直属领导审批":
generated_draft = self._create_reimbursement_draft_from_application(
application_claim=claim,
approval_flag=approval_flag,
operator=operator,
)
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)
self.audit_service.log_action(
actor=operator,
action="expense_claim.approve",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
return claim

View File

@@ -602,7 +602,15 @@ class KnowledgeService:
status_payload = status_map.get(document_id) or {}
rag_status = str(status_payload.get("status") or "").strip().lower()
linked_run_status = resolve_linked_ingest_run_status(entry, db=self.db)
if linked_run_status == AgentRunStatus.FAILED.value and rag_status in {
if not status_payload:
if (
current_status == KNOWLEDGE_INGEST_STATUS_SYNCING
and linked_run_status == AgentRunStatus.FAILED.value
):
desired_status = KNOWLEDGE_INGEST_STATUS_FAILED
else:
continue
elif linked_run_status == AgentRunStatus.FAILED.value and rag_status in {
"pending",
"processing",
"preprocessed",

View File

@@ -4,8 +4,9 @@ import os
import re
import socket
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any
from typing import Any, Callable
from sqlalchemy.orm import Session
@@ -89,8 +90,10 @@ STRUCTURED_APPENDIX_LEADING_MARKERS = (
)
STRUCTURED_APPENDIX_LEADING_WINDOW = 220
_runtime_lock = threading.RLock()
_runtime_instances: dict[int, _LightRagRuntime] = {}
_runtime_signatures: dict[int, tuple[Any, ...]] = {}
_runtime_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="knowledge-rag-runtime")
_runtime_instances: dict[str, _LightRagRuntime] = {}
_runtime_signatures: dict[str, tuple[Any, ...]] = {}
_RUNTIME_CACHE_KEY = "lightrag"
class KnowledgeRagService:
@@ -133,21 +136,26 @@ class KnowledgeRagService:
runtime_hits: list[dict[str, Any]] = []
runtime_references: list[str] = []
try:
runtime = self._get_runtime()
raw = runtime.query_data(rewritten_query, conversation_history=conversation_history)
data = raw.get("data") if isinstance(raw, dict) else {}
chunks = list(data.get("chunks") or []) if isinstance(data, dict) else []
entities = list(data.get("entities") or []) if isinstance(data, dict) else []
runtime_references = list(data.get("references") or []) if isinstance(data, dict) else []
runtime_hits = self._build_hits_from_query_data(
query=rewritten_query,
chunks=chunks,
entities=entities,
limit=limit,
)
except Exception as exc:
logger.warning("Knowledge query failed: %s", exc)
if not local_result.confident:
try:
raw = self._run_runtime_operation(
lambda runtime: runtime.query_data(
rewritten_query,
conversation_history=conversation_history,
)
)
data = raw.get("data") if isinstance(raw, dict) else {}
chunks = list(data.get("chunks") or []) if isinstance(data, dict) else []
entities = list(data.get("entities") or []) if isinstance(data, dict) else []
runtime_references = list(data.get("references") or []) if isinstance(data, dict) else []
runtime_hits = self._build_hits_from_query_data(
query=rewritten_query,
chunks=chunks,
entities=entities,
limit=limit,
)
except Exception as exc:
logger.warning("Knowledge query failed: %s", exc)
all_hits: dict[str, dict[str, Any]] = {}
for hit in local_result.hits:
@@ -189,7 +197,7 @@ class KnowledgeRagService:
],
"raw_references": runtime_references,
"metadata": {
"retrieval_strategy": "fusion",
"retrieval_strategy": "fusion" if runtime_hits else "local_text_chunks",
"local_total_chunks": local_result.total_chunks,
"local_best_score": local_result.best_score,
},
@@ -244,14 +252,17 @@ class KnowledgeRagService:
file_paths: list[str] = []
document_summaries: list[dict[str, Any]] = []
runtime = self._get_runtime()
existing_statuses = runtime.get_document_statuses(normalized_ids)
existing_statuses = self._run_runtime_operation(
lambda runtime: runtime.get_document_statuses(normalized_ids)
)
for document_id in normalized_ids:
entry = knowledge_service.get_document_entry(document_id)
if force and document_id in existing_statuses:
try:
runtime.delete_document(document_id)
self._run_runtime_operation(
lambda runtime, target_id=document_id: runtime.delete_document(target_id)
)
except Exception as exc:
logger.warning(
"Delete existing LightRAG document failed doc_id=%s: %s", document_id, exc
@@ -277,13 +288,17 @@ class KnowledgeRagService:
)
)
track_id = runtime.insert_documents(
texts=texts,
document_ids=normalized_ids,
file_paths=file_paths,
track_id = self._run_runtime_operation(
lambda runtime: runtime.insert_documents(
texts=texts,
document_ids=normalized_ids,
file_paths=file_paths,
)
)
statuses = runtime.get_document_statuses(normalized_ids)
statuses = self._run_runtime_operation(
lambda runtime: runtime.get_document_statuses(normalized_ids)
)
succeeded_document_ids: list[str] = []
failed_documents: list[dict[str, str]] = []
summary_by_id = {
@@ -344,7 +359,9 @@ class KnowledgeRagService:
if not target_ids:
return {}
try:
statuses = self._get_runtime().get_document_statuses(target_ids)
statuses = self._run_runtime_operation(
lambda runtime: runtime.get_document_statuses(target_ids)
)
except Exception as exc:
logger.warning("Load LightRAG document statuses failed: %s", exc)
return {}
@@ -358,16 +375,40 @@ class KnowledgeRagService:
if not normalized_id:
return
try:
self._get_runtime().delete_document(normalized_id)
self._run_runtime_operation(
lambda runtime: runtime.delete_document(normalized_id)
)
except Exception as exc:
logger.warning("Delete LightRAG document ignored doc_id=%s: %s", normalized_id, exc)
def _get_runtime(self) -> _LightRagRuntime:
def _run_runtime_operation(self, operation: Callable[[_LightRagRuntime], Any]) -> Any:
signature, runtime_kwargs = self._build_runtime_signature()
thread_id = threading.get_ident()
return _runtime_executor.submit(
self._execute_runtime_operation,
signature,
runtime_kwargs,
operation,
).result()
def _execute_runtime_operation(
self,
signature: tuple[Any, ...],
runtime_kwargs: dict[str, Any],
operation: Callable[[_LightRagRuntime], Any],
) -> Any:
return operation(self._get_runtime(signature=signature, runtime_kwargs=runtime_kwargs))
def _get_runtime(
self,
*,
signature: tuple[Any, ...] | None = None,
runtime_kwargs: dict[str, Any] | None = None,
) -> _LightRagRuntime:
if signature is None or runtime_kwargs is None:
signature, runtime_kwargs = self._build_runtime_signature()
with _runtime_lock:
runtime = _runtime_instances.get(thread_id)
if runtime is not None and _runtime_signatures.get(thread_id) == signature:
runtime = _runtime_instances.get(_RUNTIME_CACHE_KEY)
if runtime is not None and _runtime_signatures.get(_RUNTIME_CACHE_KEY) == signature:
return runtime
if runtime is not None:
@@ -377,8 +418,8 @@ class KnowledgeRagService:
logger.warning("Finalize previous LightRAG runtime failed: %s", exc)
runtime = _LightRagRuntime(**runtime_kwargs)
_runtime_instances[thread_id] = runtime
_runtime_signatures[thread_id] = signature
_runtime_instances[_RUNTIME_CACHE_KEY] = runtime
_runtime_signatures[_RUNTIME_CACHE_KEY] = signature
return runtime
def _build_runtime_signature(self) -> tuple[tuple[Any, ...], dict[str, Any]]:
@@ -633,6 +674,10 @@ class KnowledgeRagService:
def shutdown_knowledge_rag_runtime() -> None:
_runtime_executor.submit(_shutdown_runtime_instances).result()
def _shutdown_runtime_instances() -> None:
with _runtime_lock:
for runtime in list(_runtime_instances.values()):
try:

View File

@@ -229,7 +229,13 @@ class _LightRagRuntime:
raise KnowledgeRagError(str(getattr(result, "message", "") or "LightRAG 删除文档失败。"))
def _probe_embedding_dimension(self, config: RuntimeModelConfig) -> int:
vectors = self._request_embeddings(config, ["dimension probe"])
try:
vectors = self._request_embeddings(config, ["dimension probe"])
except Exception as exc:
raise KnowledgeRagError(
"Embedding model probe failed "
f"(slot={config.slot}, provider={config.provider}, model={config.model}): {exc}"
) from exc
if not vectors or not isinstance(vectors[0], list):
raise KnowledgeRagError("无法从 embedding 模型返回结果中解析向量维度。")
dimension = len(vectors[0])

View File

@@ -335,7 +335,12 @@ class SettingsService:
for model_row in model_rows.values():
self.db.refresh(model_row)
return self._serialize(settings_row, secrets_row, model_rows)
return self._serialize(
settings_row,
secrets_row,
model_rows,
self._build_hermes_form_snapshot(),
)
def load_saved_model_api_key(self, slot: str | None) -> str:
if not slot or slot not in MODEL_SLOT_CONFIGS:
@@ -748,7 +753,11 @@ class SettingsService:
hermesForm=hermes_form,
llmForm={
"mainProvider": main_model.provider,
"backupProvider": backup_model.provider,
"mainModel": main_model.model_name,
"mainEndpoint": main_model.endpoint,
"mainApiKey": "",
"mainApiKeyConfigured": bool(main_model.api_key_encrypted),
"backupProvider": backup_model.provider,
"backupModel": backup_model.model_name,
"backupEndpoint": backup_model.endpoint,
"backupApiKey": "",

View File

@@ -148,22 +148,7 @@ class UserAgentService(
requires_confirmation=payload.requires_confirmation,
)
fast_knowledge_answer = self._build_fast_knowledge_answer(
payload,
citations=citations,
)
if fast_knowledge_answer:
return UserAgentResponse(
answer=fast_knowledge_answer,
citations=citations,
suggested_actions=suggested_actions,
query_payload=query_payload,
draft_payload=draft_payload,
review_payload=review_payload,
risk_flags=risk_flags,
requires_confirmation=payload.requires_confirmation,
)
# 知识库问答必须优先让模型基于召回证据组织答案,避免片段渲染抢答导致答非所问。
fallback_answer = self._build_fallback_answer(
payload,
citations=citations,

View File

@@ -86,7 +86,6 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
*,
citations: list[UserAgentCitation],
) -> str | None:
return None
if payload.ontology.scenario != "knowledge":
return None
if str(payload.tool_payload.get("result_type") or "").strip() != "knowledge_search":
@@ -130,7 +129,10 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
primary_heading = self._format_knowledge_heading_label(
str(primary_item.get("heading") or "").strip()
)
primary_lines = self._collect_direct_knowledge_answer_lines(ordered_evidence_items)
primary_lines = self._collect_direct_knowledge_answer_lines(
ordered_evidence_items,
query_terms=query_terms,
)
lines: list[str] = []
if user_name:
@@ -139,20 +141,42 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
if primary_heading:
source_prefix = f"{source_prefix}{primary_heading}"
conclusion_lines: list[str] = []
evidence_lines: list[str] = []
if str(primary_item.get("kind") or "") == "table":
lines.append(f"{source_prefix},当前能直接确认的是:")
lines.append(self._extract_relevant_table_preview(str(primary_item.get("content") or ""), query_terms))
table_content = str(primary_item.get("content") or "")
if self._question_requests_broad_knowledge_table(question):
table_preview = table_content.strip()
else:
table_preview = self._extract_relevant_table_preview(
table_content,
query_terms,
preferred_terms=self._build_knowledge_table_preferred_terms(payload),
)
table_summary = self._summarize_knowledge_table_preview(table_preview)
conclusion_lines.append(f"{source_prefix}{table_summary}")
evidence_lines.append(table_preview)
else:
if not primary_lines:
lines.append(
summary = self._summarize_knowledge_evidence_content(primary_item, query_terms)
conclusion_lines.append(
f"{source_prefix},当前能直接确认的是:"
f"{self._summarize_knowledge_evidence_content(primary_item, query_terms)}"
f"{summary}"
)
elif len(primary_lines) == 1:
lines.append(f"{source_prefix},当前能直接确认的是:{primary_lines[0].strip()}")
conclusion_lines.append(f"{source_prefix},当前能直接确认的是:{primary_lines[0].strip()}")
evidence_lines.extend(primary_lines)
else:
lines.append(f"{source_prefix},当前能直接确认的是:")
lines.extend(primary_lines)
subject = self._build_knowledge_answer_subject(question, primary_heading)
summary = self._summarize_knowledge_lines_conclusion(
primary_lines,
heading=subject,
)
if summary:
conclusion_lines.append(f"{source_prefix}{summary}")
else:
conclusion_lines.append(f"{source_prefix},当前能直接确认的是:")
evidence_lines.extend(primary_lines)
notes: list[str] = []
location_note = self._build_missing_location_grounding_note(question, evidence_items)
@@ -161,14 +185,64 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
if self._question_requires_explicit_condition(question) and not self._answer_evidence_has_numeric_or_condition(evidence_items):
notes.append("当前命中的证据更偏规则说明或流程约束,还没有直接给出可立即套用的数值或完整条件。")
self._append_markdown_section(lines, "结论", conclusion_lines)
self._append_markdown_section(lines, "依据", evidence_lines)
if notes:
lines.append("")
lines.append("说明:")
lines.extend(f"- {note}" for note in notes)
self._append_markdown_section(lines, "说明", [f"- {note}" for note in notes])
return "\n".join(line for line in lines if line is not None).strip()
@staticmethod
def _append_markdown_section(lines: list[str], title: str, body_lines: list[str]) -> None:
cleaned = [str(line or "").rstrip() for line in body_lines if str(line or "").strip()]
if not cleaned:
return
if lines and lines[-1] != "":
lines.append("")
lines.append(f"## {title}")
lines.append("")
lines.extend(cleaned)
@staticmethod
def _build_knowledge_answer_subject(question: str, heading: str = "") -> str:
clean_heading = str(heading or "").strip()
if clean_heading and not any(
marker in clean_heading
for marker in ("问答线索补充", "结构化表格补充", "重点章节摘录", "章节导航")
):
return clean_heading
normalized = re.sub(r"\s+", "", str(question or "").strip())
normalized = re.sub(r"[?。.!]+$", "", normalized)
normalized = re.sub(r"(是什么|有哪些|是多少|如何|怎么|吗|呢)$", "", normalized)
return normalized.strip(":,。.")
@staticmethod
def _build_knowledge_table_preferred_terms(payload: UserAgentRequest) -> list[str]:
terms: list[str] = []
context = payload.context_json or {}
for key in ("grade", "position", "job_grade", "rank", "level"):
value = str(context.get(key) or "").strip()
if value and value not in terms:
terms.append(value)
grade_match = re.fullmatch(r"[Pp](\d{1,2})", str(context.get("grade") or "").strip())
if grade_match:
grade = int(grade_match.group(1))
for start in range(max(0, grade - 4), grade + 1):
for end in range(grade, min(12, grade + 4) + 1):
if start >= end:
continue
for separator in ("", "~", "-", ""):
term = f"P{start}{separator}P{end}"
if term not in terms:
terms.append(term)
return terms
@staticmethod
def _resolve_knowledge_question(payload: UserAgentRequest) -> str:
return str(payload.context_json.get("user_input_text") or payload.message or "").strip()
@@ -484,6 +558,8 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
def _collect_direct_knowledge_answer_lines(
self,
ordered_evidence_items: list[dict[str, Any]],
*,
query_terms: list[str] | None = None,
) -> list[str]:
if not ordered_evidence_items:
return []
@@ -509,8 +585,18 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
lines: list[str] = []
seen: set[str] = set()
for item in related_items:
rendered = self._render_knowledge_evidence_text(item)
for line in rendered.splitlines():
item_kind = str(item.get("kind") or "").strip()
item_content = str(item.get("content") or "")
if item_kind == "paragraph" or self._has_inline_numbered_knowledge_items(item_content):
rendered = self._focus_knowledge_segment_content(
item_content,
query_terms or [],
)
rendered_lines = self._split_inline_numbered_knowledge_items(rendered)
else:
rendered = self._render_knowledge_evidence_text(item)
rendered_lines = rendered.splitlines()
for line in rendered_lines:
normalized = str(line or "").strip()
if not normalized or normalized in seen:
continue
@@ -573,13 +659,21 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
or "相关制度"
).strip()
user_name = str(payload.context_json.get("name") or "").strip()
prefix = f"{user_name},您好。\n" if user_name else ""
answer_lines: list[str] = []
if user_name:
answer_lines.append(f"{user_name},您好。")
if not hits:
return (
f"{prefix}我已经从《{title}》中检索到与你这次问题相关的制度依据,"
"但本次答案生成环节暂时没有成功返回。请稍后重试一次;如果仍然失败,"
"建议先检查主对话模型的连通性。"
self._append_markdown_section(
answer_lines,
"结论",
[f"当前没有拿到可用于回答这个问题的《{title}》知识库命中。"],
)
self._append_markdown_section(
answer_lines,
"说明",
["- 我不会用相似主题或外部常识硬凑答案;请补充更具体的关键词后再试一次。"],
)
return "\n".join(answer_lines).strip()
evidence_lines: list[str] = []
for item in evidence_items[:3]:
@@ -614,19 +708,28 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
evidence_lines.append(f"- **《{item_title}》**{excerpt}")
if not evidence_lines:
return (
f"{prefix}当前《{title}》里可用于回答的关键条款还不够明确。"
"请补充费用类型、适用地区、职级或具体业务场景,我再继续帮你缩小范围。"
self._append_markdown_section(
answer_lines,
"结论",
[f"当前《{title}》里可用于回答这个问题的关键条款还不够明确。"],
)
self._append_markdown_section(
answer_lines,
"说明",
["- 请补充费用类型、适用地区、职级或具体业务场景,我再继续帮你缩小范围。"],
)
return "\n".join(answer_lines).strip()
return "\n".join(
[
f"{prefix}我先根据当前制度依据给出可以确认的部分。",
"",
"**依据**",
*evidence_lines,
"",
"**说明**:以上只使用当前命中的知识库证据;没有在证据中出现的适用条件或金额,我不会替你默认补齐。",
]
).strip()
self._append_markdown_section(
answer_lines,
"结论",
["我先根据当前制度依据给出可以确认的部分。"],
)
self._append_markdown_section(answer_lines, "依据", evidence_lines)
self._append_markdown_section(
answer_lines,
"说明",
["- 以上只使用当前命中的知识库证据;没有在证据中出现的适用条件或金额,我不会替你默认补齐。"],
)
return "\n".join(answer_lines).strip()

View File

@@ -280,10 +280,15 @@ class UserAgentResponseMixin:
if payload.ontology.scenario == "knowledge":
answer_style_instruction = (
"你是财务制度知识问答助手。只能依据 facts.tool_payload.hits、facts.knowledge_answer_evidence、citations 与 conversation_history 回答,"
"不要扩展成通用助手。优先直接回答,不要复述思考过程,不要输出 JSON、代码块或 <think>"
"不要扩展成通用助手。优先直接回答,不要复述思考过程,不要输出 JSON、代码块或可见思考过程"
"禁止使用“已命中”“答案整理阶段”“稍后重试”。"
"最终答复必须使用 Markdown优先包含“## 结论”“## 依据”“## 说明”这三个二级标题;"
"如果某一部分没有内容,可以省略该标题。"
"回答风格要像一位真正熟悉制度的财务伙伴:先直接回应用户的核心问题,再用一张简洁表格或短段落说明依据,"
"最后补充最重要的注意事项。不要写成“已检索到内容”的系统回执,也不要把命中片段连缀成答案。"
"必须优先回答用户当前这句话本身,不能把制度标题、制度全文或完整标准表当成主答案。"
"回答前先判断召回内容是否真的能回答当前问题;如果不能,必须明确说当前知识库没有找到直接依据,"
"不要改答相邻主题,也不要用相似条款硬凑答案。"
"如果用户问的是某次具体行程“一共能报多少”,就先给“当前已能确认的金额”,再用一张很短的表说明项目、"
"适用标准、计算式和结果;如果总额还缺少住宿晚数、实际票据或其他必要条件,就明确写出“暂不能确认的部分”。"
"只有用户明确在问“标准有哪些”或“制度全文怎么规定”时,才展开完整标准表。"
@@ -488,7 +493,7 @@ class UserAgentResponseMixin:
citations: list[UserAgentCitation],
) -> str:
if str(payload.tool_payload.get("result_type") or "").strip() == "knowledge_search":
if citations:
if citations or list(payload.tool_payload.get("hits") or []):
return self._build_knowledge_search_answer(payload, citations)
tool_message = str(payload.tool_payload.get("message") or "").strip()

View File

@@ -14,13 +14,13 @@
"updated_at": "2026-05-17T09:28:28.999515+00:00",
"uploaded_by": "admin",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:08.579777+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-17T10:01:33.272539+00:00",
"ingest_completed_at": "2026-05-17T10:01:33.272539+00:00",
"ingest_document_name": "远光《公司支出管理办法2024》.pdf",
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
"ingest_document_sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece",
"ingest_agent_run_id": "run_7236fb72747742a3"
"ingest_agent_run_id": "run_b5984bade5324755"
},
{
"id": "c7601043d9944ef2bcf4d3f67ed253f7",
@@ -35,8 +35,8 @@
"updated_at": "2026-05-22T07:00:22.328877+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:09.863684+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T09:22:25.565409+00:00",
"ingest_completed_at": "2026-05-22T09:22:25.565409+00:00",
"ingest_document_name": "远光软件会计科目使用说明.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:22.328877+00:00",
@@ -56,8 +56,8 @@
"updated_at": "2026-05-22T07:00:22.011016+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:00:50.652735+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-23T14:30:33.605531+00:00",
"ingest_completed_at": "2026-05-23T14:30:33.605531+00:00",
"ingest_document_name": "远光软件财务基础知识手册.docx",
"ingest_document_updated_at": "2026-05-22T07:00:22.011016+00:00",
@@ -77,8 +77,8 @@
"updated_at": "2026-05-22T07:00:22.352133+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:00:51.908821+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T09:23:11.334499+00:00",
"ingest_completed_at": "2026-05-22T09:23:11.334499+00:00",
"ingest_document_name": "远光软件财务术语解释手册.docx",
"ingest_document_updated_at": "2026-05-22T07:00:22.352133+00:00",
@@ -98,8 +98,8 @@
"updated_at": "2026-05-22T07:00:22.304623+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:13.581834+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T09:24:18.933073+00:00",
"ingest_completed_at": "2026-05-22T09:24:18.933073+00:00",
"ingest_document_name": "远光软件高新技术企业税收优惠政策汇总.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:22.304623+00:00",
@@ -119,8 +119,8 @@
"updated_at": "2026-05-22T07:00:18.153373+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:00:53.906324+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:01:43.168774+00:00",
"ingest_completed_at": "2026-05-22T16:01:43.168774+00:00",
"ingest_document_name": "远光软件公司内部控制基本规范.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:18.153373+00:00",
@@ -140,8 +140,8 @@
"updated_at": "2026-05-22T07:00:18.190399+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:00:55.339114+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:03:00.735908+00:00",
"ingest_completed_at": "2026-05-22T16:03:00.735908+00:00",
"ingest_document_name": "远光软件公司合同管理制度.docx",
"ingest_document_updated_at": "2026-05-22T07:00:18.190399+00:00",
@@ -161,8 +161,8 @@
"updated_at": "2026-05-22T07:00:17.798679+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:00:56.741808+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:03:46.921675+00:00",
"ingest_completed_at": "2026-05-22T16:03:46.921675+00:00",
"ingest_document_name": "远光软件公司财务管理制度总则.docx",
"ingest_document_updated_at": "2026-05-22T07:00:17.798679+00:00",
@@ -182,8 +182,8 @@
"updated_at": "2026-05-22T07:00:18.531598+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:19.014702+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:04:58.719410+00:00",
"ingest_completed_at": "2026-05-22T16:04:58.719410+00:00",
"ingest_document_name": "远光软件公司资产管理制度.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:18.531598+00:00",
@@ -203,8 +203,8 @@
"updated_at": "2026-05-22T07:00:18.221073+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:00:59.485821+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:06:08.172318+00:00",
"ingest_completed_at": "2026-05-22T16:06:08.172318+00:00",
"ingest_document_name": "远光软件公司采购管理办法.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:18.221073+00:00",
@@ -224,8 +224,8 @@
"updated_at": "2026-05-22T07:00:19.734422+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:00.774887+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:06:48.466110+00:00",
"ingest_completed_at": "2026-05-22T16:06:48.466110+00:00",
"ingest_document_name": "远光软件公司差旅费管理办法.docx",
"ingest_document_updated_at": "2026-05-22T07:00:19.734422+00:00",
@@ -245,8 +245,8 @@
"updated_at": "2026-05-22T07:00:20.095824+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:02.037101+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:07:23.262328+00:00",
"ingest_completed_at": "2026-05-22T16:07:23.262328+00:00",
"ingest_document_name": "远光软件出差审批流程说明.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:20.095824+00:00",
@@ -266,8 +266,8 @@
"updated_at": "2026-05-22T07:00:20.128471+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:24.076574+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:08:02.190081+00:00",
"ingest_completed_at": "2026-05-22T16:08:02.190081+00:00",
"ingest_document_name": "远光软件国际出差管理规定.docx",
"ingest_document_updated_at": "2026-05-22T07:00:20.128471+00:00",
@@ -287,8 +287,8 @@
"updated_at": "2026-05-22T07:00:19.759954+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:25.270086+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:09:23.091744+00:00",
"ingest_completed_at": "2026-05-22T16:09:23.091744+00:00",
"ingest_document_name": "远光软件差旅费标准速查表.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:19.759954+00:00",
@@ -308,8 +308,8 @@
"updated_at": "2026-05-22T07:00:18.922298+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:26.510710+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:11:04.764727+00:00",
"ingest_completed_at": "2026-05-22T16:11:04.764727+00:00",
"ingest_document_name": "远光软件公司发票审核标准.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:18.922298+00:00",
@@ -329,8 +329,8 @@
"updated_at": "2026-05-22T07:00:18.560177+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:06.719118+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:11:54.017817+00:00",
"ingest_completed_at": "2026-05-22T16:11:54.017817+00:00",
"ingest_document_name": "远光软件公司发票管理规范.docx",
"ingest_document_updated_at": "2026-05-22T07:00:18.560177+00:00",
@@ -350,8 +350,8 @@
"updated_at": "2026-05-22T07:00:18.888128+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:28.865726+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:12:23.821434+00:00",
"ingest_completed_at": "2026-05-22T16:12:23.821434+00:00",
"ingest_document_name": "远光软件公司增值税发票操作指南.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:18.888128+00:00",
@@ -371,8 +371,8 @@
"updated_at": "2026-05-22T07:00:18.953110+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:30.095619+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:13:15.450300+00:00",
"ingest_completed_at": "2026-05-22T16:13:15.450300+00:00",
"ingest_document_name": "远光软件公司电子发票管理办法.docx",
"ingest_document_updated_at": "2026-05-22T07:00:18.953110+00:00",
@@ -392,8 +392,8 @@
"updated_at": "2026-05-22T07:00:21.585718+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:09.790447+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:13:44.636629+00:00",
"ingest_completed_at": "2026-05-22T16:13:44.636629+00:00",
"ingest_document_name": "远光软件企业所得税汇算清缴操作手册.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:21.585718+00:00",
@@ -413,8 +413,8 @@
"updated_at": "2026-05-22T07:00:20.881351+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:11.027818+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:14:50.092490+00:00",
"ingest_completed_at": "2026-05-22T16:14:50.092490+00:00",
"ingest_document_name": "远光软件公司税务管理制度.docx",
"ingest_document_updated_at": "2026-05-22T07:00:20.881351+00:00",
@@ -434,8 +434,8 @@
"updated_at": "2026-05-22T07:00:21.606227+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:33.826025+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:15:56.676286+00:00",
"ingest_completed_at": "2026-05-22T16:15:56.676286+00:00",
"ingest_document_name": "远光软件研发费用加计扣除管理办法.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:21.606227+00:00",
@@ -455,8 +455,8 @@
"updated_at": "2026-05-22T07:00:21.202633+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:13.991763+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:16:06.540773+00:00",
"ingest_completed_at": "2026-05-22T16:16:06.540773+00:00",
"ingest_document_name": "远光软件软件产品增值税即征即退操作指南.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:21.202633+00:00",
@@ -476,8 +476,8 @@
"updated_at": "2026-05-22T07:00:22.379307+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:15.257700+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:23:24.252614+00:00",
"ingest_completed_at": "2026-05-22T16:23:24.252614+00:00",
"ingest_document_name": "远光软件公司预算管理制度.docx",
"ingest_document_updated_at": "2026-05-22T07:00:22.379307+00:00",
@@ -497,8 +497,8 @@
"updated_at": "2026-05-22T07:00:22.760169+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:16.510610+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:23:29.997956+00:00",
"ingest_completed_at": "2026-05-22T16:23:29.997956+00:00",
"ingest_document_name": "远光软件年度预算编制指南.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:22.760169+00:00",
@@ -518,8 +518,8 @@
"updated_at": "2026-05-22T07:00:22.848272+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:38.728430+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:24:37.382612+00:00",
"ingest_completed_at": "2026-05-22T16:24:37.382612+00:00",
"ingest_document_name": "远光软件预算执行分析报告模板.docx",
"ingest_document_updated_at": "2026-05-22T07:00:22.848272+00:00",
@@ -539,8 +539,8 @@
"updated_at": "2026-05-22T07:00:22.803708+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:19.050297+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:24:45.161319+00:00",
"ingest_completed_at": "2026-05-22T16:24:45.161319+00:00",
"ingest_document_name": "远光软件预算编制模板.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:22.803708+00:00",
@@ -560,8 +560,8 @@
"updated_at": "2026-05-22T07:00:21.971983+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:20.323058+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:25:33.968414+00:00",
"ingest_completed_at": "2026-05-22T16:25:33.968414+00:00",
"ingest_document_name": "远光软件财务共享服务SLA标准.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:21.971983+00:00",
@@ -581,8 +581,8 @@
"updated_at": "2026-05-22T07:00:21.634300+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:21.585474+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:26:05.301987+00:00",
"ingest_completed_at": "2026-05-22T16:26:05.301987+00:00",
"ingest_document_name": "远光软件财务共享服务中心运营管理办法.docx",
"ingest_document_updated_at": "2026-05-22T07:00:21.634300+00:00",
@@ -602,8 +602,8 @@
"updated_at": "2026-05-22T07:00:21.945868+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:43.752235+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:26:54.048075+00:00",
"ingest_completed_at": "2026-05-22T16:26:54.048075+00:00",
"ingest_document_name": "远光软件财务共享服务操作手册.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:21.945868+00:00",
@@ -623,8 +623,8 @@
"updated_at": "2026-05-22T07:00:19.662743+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:24.093834+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:27:31.775974+00:00",
"ingest_completed_at": "2026-05-22T16:27:31.775974+00:00",
"ingest_document_name": "远光软件报销流程培训手册.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:19.662743+00:00",
@@ -644,8 +644,8 @@
"updated_at": "2026-05-22T07:00:19.323921+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:25.246857+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:27:44.244066+00:00",
"ingest_completed_at": "2026-05-22T16:27:44.244066+00:00",
"ingest_document_name": "远光软件新员工财务培训课件.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:19.323921+00:00",
@@ -665,8 +665,8 @@
"updated_at": "2026-05-22T07:00:18.988700+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:26.471932+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:28:24.573683+00:00",
"ingest_completed_at": "2026-05-22T16:28:24.573683+00:00",
"ingest_document_name": "远光软件财务制度培训手册.docx",
"ingest_document_updated_at": "2026-05-22T07:00:18.988700+00:00",
@@ -686,8 +686,8 @@
"updated_at": "2026-05-22T07:00:19.686485+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:48.525207+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:29:03.349502+00:00",
"ingest_completed_at": "2026-05-22T16:29:03.349502+00:00",
"ingest_document_name": "远光软件财务培训课程安排.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:19.686485+00:00",
@@ -707,8 +707,8 @@
"updated_at": "2026-05-22T07:00:20.476077+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:49.746825+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:29:29.050791+00:00",
"ingest_completed_at": "2026-05-22T16:29:29.050791+00:00",
"ingest_document_name": "远光软件报销问题处理指引.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:20.476077+00:00",
@@ -728,8 +728,8 @@
"updated_at": "2026-05-22T07:00:20.453567+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:30.343781+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:35:03.548506+00:00",
"ingest_completed_at": "2026-05-22T16:35:03.548506+00:00",
"ingest_document_name": "远光软件财务制度问答汇总.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:20.453567+00:00",
@@ -749,8 +749,8 @@
"updated_at": "2026-05-22T07:00:20.158497+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-26T16:01:31.573128+00:00",
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:35:27.056080+00:00",
"ingest_completed_at": "2026-05-22T16:35:27.056080+00:00",
"ingest_document_name": "远光软件财务报销常见问题解答.docx",
"ingest_document_updated_at": "2026-05-22T07:00:20.158497+00:00",

View File

@@ -49,6 +49,27 @@ def test_agent_run_service_marks_stale_knowledge_sync_run_failed_on_read() -> No
assert all(item.run_id != created.run_id for item in running_runs)
def test_agent_run_service_marks_stale_llm_wiki_run_failed_on_read() -> None:
with build_session() as db:
service = AgentRunService(db)
created = service.create_run(
agent=AgentName.HERMES.value,
source=AgentRunSource.SCHEDULE.value,
status=AgentRunStatus.RUNNING.value,
route_json={
"job_type": "llm_wiki_sync",
"heartbeat_at": (datetime.now(UTC) - timedelta(minutes=31)).isoformat(),
"requested_document_ids": [],
},
)
fetched = service.get_run(created.run_id)
assert fetched is not None
assert fetched.status == AgentRunStatus.FAILED.value
assert fetched.error_message == "Knowledge index heartbeat timed out."
def test_agent_run_service_updates_existing_tool_call() -> None:
with build_session() as db:
service = AgentRunService(db)

View File

@@ -0,0 +1,74 @@
from decimal import Decimal
from app.algorithem import ApplicantExpenseProfileInput, evaluate_applicant_expense_profile
def test_applicant_profile_recommends_review_and_days_cap() -> None:
result = evaluate_applicant_expense_profile(
ApplicantExpenseProfileInput(
applicant_claim_count_90d=6,
peer_claim_count_p75_90d=4,
applicant_amount_90d=Decimal("42800"),
available_peer_budget_90d=Decimal("150000"),
amount_percentile=Decimal("88"),
peer_amount_median_90d=Decimal("25000"),
adjusted_or_returned_count_180d=3,
approved_claim_count_180d=12,
requested_days=Decimal("5"),
peer_travel_days_p75=Decimal("3"),
business_buffer_days=Decimal("1"),
claim_amount=Decimal("8000"),
peer_daily_cost_baseline=Decimal("1400"),
tolerance_factor=Decimal("1.20"),
)
)
assert result.profile_score == 68
assert result.profile_level == "review"
assert result.recommendation_level == "review"
assert result.travel_days_ratio == Decimal("1.6667")
assert result.suggested_days == Decimal("4.0000")
assert result.suggested_amount_cap == Decimal("6720.00")
assert "applicant.amount_percentile.p85" in result.basis_codes
assert "travel.days_ratio.review" in result.basis_codes
def test_applicant_profile_handles_missing_baselines_without_false_risk() -> None:
result = evaluate_applicant_expense_profile(
ApplicantExpenseProfileInput(
applicant_claim_count_90d=1,
applicant_amount_90d=Decimal("800"),
requested_days=Decimal("2"),
claim_amount=Decimal("800"),
)
)
assert result.profile_score == 0
assert result.profile_level == "normal"
assert result.recommendation_level == "normal"
assert result.frequency_ratio == Decimal("0")
assert result.daily_cost_ratio == Decimal("0")
assert result.suggested_days == Decimal("2.0000")
assert result.suggested_amount_cap is None
assert result.basis_codes == []
def test_entertainment_deviation_can_escalate_recommendation() -> None:
result = evaluate_applicant_expense_profile(
ApplicantExpenseProfileInput(
entertainment_amount=Decimal("3000"),
attendee_count=3,
entertainment_standard_cap=Decimal("600"),
same_customer_frequency_90d=3,
applicant_entertainment_percentile=Decimal("96"),
)
)
assert result.entertainment_deviation_score == 100
assert result.current_claim_deviation_score == 100
assert result.profile_score == 15
assert result.profile_level == "normal"
assert result.recommendation_level == "escalation"
assert "entertainment.per_capita.escalation" in result.basis_codes
assert "entertainment.same_customer.frequency_review" in result.basis_codes
assert "entertainment.amount_percentile.p95" in result.basis_codes

View File

@@ -13,6 +13,10 @@ from app.api.deps import get_db
from app.db.base import Base
from app.main import create_app
from app.models.budget import BudgetAllocation, BudgetTransaction
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.organization import OrganizationUnit
from app.models.role import Role
def build_session_factory() -> sessionmaker[Session]:
@@ -109,6 +113,27 @@ def seed_budget_allocations(db: Session) -> None:
db.commit()
def seed_market_budget_monitor(db: Session) -> tuple[Role, OrganizationUnit]:
role = Role(role_code="budget_monitor", name="预算监控员")
department = OrganizationUnit(
id="dept-market",
unit_code="MARKET-DEPT",
name="市场部",
unit_type="department",
)
employee = Employee(
employee_no="E-BUDGET-MARKET-P8",
name="赵预算",
email="budget-monitor@example.com",
grade="P8",
organization_unit=department,
roles=[role],
)
db.add_all([role, department, employee])
db.flush()
return role, department
def test_admin_can_view_all_budget_allocations_without_is_admin_header() -> None:
client, session_factory = build_client()
with session_factory() as db:
@@ -281,3 +306,69 @@ def test_budget_monitor_cannot_edit_and_admin_can_edit() -> None:
)
assert admin_response.status_code == 201
assert admin_response.json()["department_name"] == "销售部"
def test_budget_analysis_endpoint_is_limited_to_budget_roles() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_budget_allocations(db)
budget_role, market_department = seed_market_budget_monitor(db)
p6_budget_monitor = Employee(
employee_no="E-BUDGET-MARKET-P6",
name="低级预算",
email="p6-budget-monitor@example.com",
grade="P6",
organization_unit=market_department,
roles=[budget_role],
)
db.add(p6_budget_monitor)
db.flush()
claim = ExpenseClaim(
claim_no="APP-BUDGET-ANALYSIS-001",
employee_id=p6_budget_monitor.id,
employee_name="低级预算",
department_id="dept-market",
department_name="市场部",
project_code=None,
expense_type="travel_application",
reason="客户现场交付预算申请",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="预算管理者审批",
risk_flags_json=[],
)
db.add(claim)
db.commit()
claim_id = claim.id
ordinary_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/budget-analysis",
headers={
"x-auth-username": "zhangsan@example.com",
"x-auth-role-codes": "employee",
},
)
monitor_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/budget-analysis",
headers={
"x-auth-username": "budget-monitor@example.com",
"x-auth-role-codes": "budget_monitor",
},
)
p6_monitor_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/budget-analysis",
headers={
"x-auth-username": "p6-budget-monitor@example.com",
"x-auth-role-codes": "budget_monitor",
},
)
assert ordinary_response.status_code == 403
assert p6_monitor_response.status_code == 403
assert monitor_response.status_code == 200
assert Decimal(monitor_response.json()["metrics"]["claim_amount_ratio"]) == Decimal("24.00")

View File

@@ -16,10 +16,12 @@ from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransac
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit
from app.models.role import Role
from app.schemas.ontology import OntologyParseRequest
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate, ExpenseClaimUpdate
from app.services.agent_conversations import AgentConversationService
from app.services.budget import BudgetService
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claims import ExpenseClaimService
from app.services.ontology import SemanticOntologyService
@@ -108,6 +110,16 @@ def _seed_budget_allocation(
return allocation
def _seed_budget_monitor_role(db: Session) -> Role:
role = db.query(Role).filter(Role.role_code == "budget_monitor").one_or_none()
if role is not None:
return role
role = Role(role_code="budget_monitor", name="预算监控员")
db.add(role)
db.flush()
return role
def test_validate_claim_for_submission_allows_office_claim_without_location() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="office", location="待补充")
@@ -3320,32 +3332,55 @@ def test_application_submit_skips_budget_for_non_demo_subject() -> None:
)
def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -> None:
current_user = CurrentUserContext(
def test_direct_manager_can_route_application_claim_to_budget_approval_then_budget_manager_creates_draft() -> None:
manager_user = CurrentUserContext(
username="manager-application-approve@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
budget_user = CurrentUserContext(
username="budget-p8-application-approve@example.com",
name="赵预算",
role_codes=["budget_monitor"],
is_admin=False,
)
with build_session() as db:
budget_role = _seed_budget_monitor_role(db)
department = OrganizationUnit(
unit_code="DELIVERY-BUDGET-APPROVE",
name="交付部",
unit_type="department",
)
manager = Employee(
employee_no="E8112",
name="李经理",
email="manager-application-approve@example.com",
organization_unit=department,
)
budget_manager = Employee(
employee_no="E8112-BUDGET",
name="赵预算",
email="budget-p8-application-approve@example.com",
grade="P8",
organization_unit=department,
roles=[budget_role],
)
employee = Employee(
employee_no="E8113",
name="张三",
email="zhangsan-application-approve@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([manager, employee])
db.add_all([department, manager, budget_manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="APP-20260525-APPROVE",
employee_id=employee.id,
employee_name="张三",
department_id=department.id,
department_name="交付部",
project_code="PRJ-A",
expense_type="travel_application",
@@ -3364,10 +3399,33 @@ def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -
db.commit()
claim_id = claim.id
leader_approved = ExpenseClaimService(db).approve_claim(
claim_id,
manager_user,
opinion="业务必要,同意申请。",
)
assert leader_approved is not None
assert leader_approved.status == "submitted"
assert leader_approved.approval_stage == "预算管理者审批"
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("event_type") == "expense_application_approval"
and flag.get("opinion") == "业务必要,同意申请。"
and flag.get("previous_approval_stage") == "直属领导审批"
and flag.get("next_status") == "submitted"
and flag.get("next_approval_stage") == "预算管理者审批"
and flag.get("next_approver_name") == "赵预算"
and flag.get("next_approver_grade") == "P8"
for flag in leader_approved.risk_flags_json
)
approved = ExpenseClaimService(db).approve_claim(
claim_id,
current_user,
opinion="业务必要,同意申请",
budget_user,
opinion="预算额度可承接,同意。",
)
assert approved is not None
@@ -3400,14 +3458,15 @@ def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -
and flag.get("event_type") == "expense_application_to_reimbursement_draft"
and flag.get("application_claim_no") == "APP-20260525-APPROVE"
and flag.get("leader_opinion") == "业务必要,同意申请。"
and flag.get("budget_opinion") == "预算额度可承接,同意。"
for flag in generated_draft.risk_flags_json
)
assert any(
isinstance(flag, dict)
and flag.get("source") == "manual_approval"
and flag.get("event_type") == "expense_application_approval"
and flag.get("opinion") == "业务必要,同意申请"
and flag.get("previous_approval_stage") == "直属领导审批"
and flag.get("source") == "budget_approval"
and flag.get("event_type") == "expense_application_budget_approval"
and flag.get("opinion") == "预算额度可承接,同意。"
and flag.get("previous_approval_stage") == "预算管理者审批"
and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == "审批完成"
and flag.get("generated_draft_claim_id") == generated_draft.id
@@ -3473,7 +3532,7 @@ def test_direct_manager_return_application_claim_records_return_node_and_opinion
claim.id,
manager_user,
reason="预算说明不够清楚,请补充项目必要性。",
reason_codes=["application_business_need_unclear", "application_budget_basis_missing"],
reason_codes=["application_budget_basis_missing"],
)
assert returned is not None
@@ -3493,11 +3552,8 @@ def test_direct_manager_return_application_claim_records_return_node_and_opinion
assert return_event["leader_opinion"] == "预算说明不够清楚,请补充项目必要性。"
assert return_event["return_stage"] == "直属领导审批"
assert return_event["return_stage_key"] == "direct_manager"
assert return_event["reason_codes"] == [
"application_business_need_unclear",
"application_budget_basis_missing",
]
assert return_event["risk_points"] == ["业务必要性说明不足", "预算测算依据不足"]
assert return_event["reason_codes"] == ["application_budget_basis_missing"]
assert return_event["risk_points"] == ["预算测算依据不足"]
assert return_event["next_status"] == "returned"
assert return_event["next_approval_stage"] == "待提交"
@@ -3515,20 +3571,43 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
role_codes=["manager"],
is_admin=False,
)
budget_user = CurrentUserContext(
username="budget-p8-transfer@example.com",
name="赵预算",
role_codes=["budget_monitor"],
is_admin=False,
)
with build_session() as db:
budget_role = _seed_budget_monitor_role(db)
department = OrganizationUnit(
id="dept-budget-transfer",
unit_code="DELIVERY-BUDGET-TRANSFER",
name="交付部",
unit_type="department",
)
manager = Employee(
employee_no="M-BUDGET-APP",
name="李经理",
email="manager-application-budget@example.com",
organization_unit=department,
)
budget_manager = Employee(
employee_no="P8-BUDGET-APP",
name="赵预算",
email="budget-p8-transfer@example.com",
grade="P8",
organization_unit=department,
roles=[budget_role],
)
employee = Employee(
employee_no="E-BUDGET-APP",
name="张三",
email="application-budget-owner-approve@example.com",
manager=manager,
organization_unit=department,
)
db.add_all([manager, employee])
db.add_all([department, manager, budget_manager, employee])
db.flush()
_seed_budget_allocation(
db,
@@ -3560,10 +3639,19 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
service = ExpenseClaimService(db)
service.submit_claim(claim.id, owner)
approved = service.approve_claim(claim.id, manager_user, opinion="同意申请")
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
leader_approved = service.approve_claim(claim.id, manager_user, opinion="同意申请")
reservation = db.query(BudgetReservation).one()
assert leader_approved is not None
assert leader_approved.approval_stage == "预算管理者审批"
assert reservation.source_type == "application"
assert reservation.source_id == claim.id
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
approved = service.approve_claim(claim.id, budget_user, opinion="预算通过")
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
db.refresh(reservation)
assert approved is not None
assert reservation.source_type == "claim"
assert reservation.source_id == generated_draft.id
@@ -3576,7 +3664,7 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
)
def test_direct_manager_approval_requires_leader_opinion() -> None:
def test_direct_manager_approval_defaults_blank_opinion_to_agree() -> None:
current_user = CurrentUserContext(
username="manager-application-required-opinion@example.com",
name="李经理",
@@ -3620,19 +3708,79 @@ def test_direct_manager_approval_requires_leader_opinion() -> None:
db.commit()
claim_id = claim.id
with pytest.raises(ValueError, match="领导审核意见不能为空"):
ExpenseClaimService(db).approve_claim(
claim_id,
current_user,
opinion=" ",
)
approved = ExpenseClaimService(db).approve_claim(
claim_id,
current_user,
opinion=" ",
)
db.refresh(claim)
assert claim.status == "submitted"
assert claim.approval_stage == "直属领导审批"
assert approved is not None
assert approved.status == "submitted"
assert approved.approval_stage == "预算管理者审批"
assert any(
isinstance(flag, dict)
and flag.get("event_type") == "expense_application_approval"
and flag.get("opinion") == "同意"
and flag.get("next_approval_stage") == "预算管理者审批"
for flag in approved.risk_flags_json
)
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
def test_budget_analysis_uses_current_application_reservation_without_double_counting() -> None:
owner = CurrentUserContext(
username="application-budget-analysis-owner@example.com",
name="张三",
role_codes=["employee"],
is_admin=False,
)
with build_session() as db:
employee = Employee(
employee_no="E-BUDGET-ANALYSIS",
name="张三",
email="application-budget-analysis-owner@example.com",
)
db.add(employee)
db.flush()
_seed_budget_allocation(
db,
department_id="dept-budget-analysis",
department_name="交付部",
amount=Decimal("50000.00"),
)
claim = ExpenseClaim(
claim_no="APP-20260525-ANALYSIS",
employee_id=employee.id,
employee_name="张三",
department_id="dept-budget-analysis",
department_name="交付部",
project_code="PRJ-A",
expense_type="travel_application",
reason="客户现场交付",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
db.add(claim)
db.commit()
ExpenseClaimService(db).submit_claim(claim.id, owner)
analysis = BudgetService(db).analyze_claim_budget(claim)
assert analysis["metrics"]["claim_amount_ratio"] == "24.00"
assert analysis["metrics"]["after_usage_rate"] == "24.00"
assert analysis["budget_context"]["current_reserved_amount"] == "12000.00"
assert analysis["score"] >= 70
assert any("本次申请金额 12000.00 元,占预算 24.00%" in item for item in analysis["basis"])
def test_finance_approve_reimbursement_consumes_budget_reservation() -> None:
current_user = CurrentUserContext(
username="finance-budget-approve@example.com",
@@ -4207,3 +4355,121 @@ def test_list_approval_claims_limits_finance_to_finance_stage_claims() -> None:
claims = ExpenseClaimService(db).list_approval_claims(current_user)
assert [claim.claim_no for claim in claims] == ["EXP-FIN-LIST-202"]
def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applications() -> None:
current_user = CurrentUserContext(
username="budget-p8-list@example.com",
name="赵预算",
role_codes=["budget_monitor"],
is_admin=False,
)
p8_without_budget_role = CurrentUserContext(
username="budget-p8-list@example.com",
name="budget manager",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
budget_role = _seed_budget_monitor_role(db)
delivery_department = OrganizationUnit(
unit_code="DELIVERY-BUDGET-LIST",
name="交付部",
unit_type="department",
)
market_department = OrganizationUnit(
unit_code="MARKET-BUDGET-LIST",
name="市场部",
unit_type="department",
)
budget_manager = Employee(
employee_no="E-P8-BUDGET-LIST",
name="赵预算",
email="budget-p8-list@example.com",
grade="P8",
organization_unit=delivery_department,
roles=[budget_role],
)
employee = Employee(
employee_no="E-BUDGET-LIST-OWNER",
name="张三",
email="budget-list-owner@example.com",
organization_unit=delivery_department,
)
market_employee = Employee(
employee_no="E-BUDGET-LIST-MARKET",
name="王五",
email="budget-list-market@example.com",
organization_unit=market_department,
)
db.add_all([delivery_department, market_department, budget_manager, employee, market_employee])
db.flush()
db.add_all(
[
ExpenseClaim(
claim_no="APP-BUDGET-LIST-201",
employee_id=employee.id,
employee_name="张三",
department_id=delivery_department.id,
department_name="交付部",
project_code="PRJ-BUDGET",
expense_type="travel_application",
reason="预算待审申请",
location="上海",
amount=Decimal("12000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="预算管理者审批",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="APP-BUDGET-LIST-OTHER-DEPT",
employee_id=market_employee.id,
employee_name="王五",
department_id=market_department.id,
department_name="市场部",
project_code="PRJ-BUDGET",
expense_type="travel_application",
reason="其他部门预算待审申请",
location="上海",
amount=Decimal("13000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="预算管理者审批",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-BUDGET-LIST-202",
employee_id=employee.id,
employee_name="张三",
department_id=delivery_department.id,
department_name="交付部",
project_code="PRJ-BUDGET",
expense_type="transport",
reason="财务待审报销",
location="上海",
amount=Decimal("88.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="财务审批",
risk_flags_json=[],
),
]
)
db.commit()
claims = ExpenseClaimService(db).list_approval_claims(current_user)
assert [claim.claim_no for claim in claims] == ["APP-BUDGET-LIST-201"]
claims_without_budget_role = ExpenseClaimService(db).list_approval_claims(p8_without_budget_role)
assert [claim.claim_no for claim in claims_without_budget_role] == []

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
import pytest
from app.services.knowledge_rag_runtime import (
KnowledgeRagError,
RuntimeModelConfig,
_LightRagRuntime,
)
def test_embedding_probe_error_includes_model_context(monkeypatch) -> None:
runtime = _LightRagRuntime.__new__(_LightRagRuntime)
config = RuntimeModelConfig(
slot="embedding",
provider="GLM",
model="Embedding-3",
endpoint="https://open.bigmodel.cn/api/paas/v4/",
api_key="token",
capability="embedding",
)
def fail_embeddings(*_args, **_kwargs):
raise KnowledgeRagError("token expired")
monkeypatch.setattr(runtime, "_request_embeddings", fail_embeddings)
with pytest.raises(KnowledgeRagError) as exc_info:
runtime._probe_embedding_dimension(config)
message = str(exc_info.value)
assert "slot=embedding" in message
assert "provider=GLM" in message
assert "model=Embedding-3" in message
assert "token expired" in message

View File

@@ -238,7 +238,7 @@ def test_resolve_default_qdrant_url_falls_back_to_loopback(monkeypatch) -> None:
assert knowledge_rag_module._resolve_default_qdrant_url() == "http://127.0.0.1:6333"
def test_runtime_cache_is_isolated_by_thread(monkeypatch) -> None:
def test_runtime_cache_uses_dedicated_instance_across_calling_threads(monkeypatch) -> None:
knowledge_rag_module.shutdown_knowledge_rag_runtime()
created_runtimes = []
@@ -270,8 +270,8 @@ def test_runtime_cache_is_isolated_by_thread(monkeypatch) -> None:
thread.start()
thread.join(timeout=5)
assert len(created_runtimes) == 2
assert worker_runtimes[0] is not main_runtime
assert len(created_runtimes) == 1
assert worker_runtimes[0] is main_runtime
knowledge_rag_module.shutdown_knowledge_rag_runtime()
assert all(runtime.finalized for runtime in created_runtimes)

View File

@@ -12,6 +12,7 @@ from app.db.base import Base
from app.services.agent_runs import AgentRunService
from app.services.knowledge import (
KNOWLEDGE_INGEST_STATUS_FAILED,
KNOWLEDGE_INGEST_STATUS_INGESTED,
KNOWLEDGE_INGEST_STATUS_SYNCING,
KnowledgeService,
)
@@ -88,3 +89,41 @@ def test_reconcile_document_ingest_status_keeps_failed_when_linked_run_failed(
entry = next(item for item in index["documents"] if item["id"] == uploaded.id)
assert changed is True
assert entry["ingest_status"] == KNOWLEDGE_INGEST_STATUS_FAILED
def test_reconcile_document_ingest_status_preserves_ingested_when_status_map_missing(
tmp_path,
monkeypatch,
) -> None:
service = KnowledgeService(storage_root=tmp_path)
uploaded = service.upload_document(
"报销制度",
"demo.txt",
b"hello",
CurrentUserContext(
username="admin",
name="管理员",
role_codes=["manager"],
is_admin=True,
),
)
service.set_document_ingest_statuses(
[uploaded.id],
KNOWLEDGE_INGEST_STATUS_INGESTED,
agent_run_id="run_missing_status_map",
)
monkeypatch.setattr(
"app.services.knowledge_rag.KnowledgeRagService.get_document_status_map",
lambda self, _document_ids: {},
)
index = service._load_index()
changed = service._reconcile_document_ingest_statuses(
index,
document_ids=[uploaded.id],
preserve_syncing=False,
)
entry = next(item for item in index["documents"] if item["id"] == uploaded.id)
assert changed is False
assert entry["ingest_status"] == KNOWLEDGE_INGEST_STATUS_INGESTED

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext
from app.db.base import Base
from app.services.knowledge import KNOWLEDGE_INGEST_STATUS_INGESTED, KnowledgeService
from app.services.knowledge_sync import KnowledgeSyncDispatchService
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def test_force_sync_queues_ingested_documents_and_creates_hermes_run(tmp_path, monkeypatch) -> None:
submitted: list[dict[str, object]] = []
user = CurrentUserContext(
username="admin",
name="管理员",
role_codes=["manager"],
is_admin=True,
)
with build_session() as db:
knowledge_service = KnowledgeService(storage_root=tmp_path, db=db)
uploaded = knowledge_service.upload_document("报销制度", "demo.txt", b"hello", user)
document_id = uploaded.id
knowledge_service.set_document_ingest_statuses(
[document_id],
KNOWLEDGE_INGEST_STATUS_INGESTED,
agent_run_id="run_previous",
)
monkeypatch.setattr(
"app.services.knowledge_rag.KnowledgeRagService.get_document_status_map",
lambda self, _document_ids: {},
)
monkeypatch.setattr(
"app.services.knowledge_sync.knowledge_index_task_manager.submit_sync",
lambda **kwargs: submitted.append(kwargs),
)
dispatch_service = KnowledgeSyncDispatchService(db)
dispatch_service.knowledge_service = knowledge_service
result = dispatch_service.queue_sync(
current_user=user,
folder=None,
document_ids=[document_id],
force=True,
changed_only=True,
)
assert result.agent_run_id.startswith("run_")
assert document_id in result.document_ids
assert submitted
assert submitted[0]["agent_run_id"] == result.agent_run_id

View File

@@ -14,6 +14,7 @@ from app.core import admin_secret
from app.core import secret_box
from app.core.secret_box import encrypt_secret
from app.db.base import Base
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
from app.models.system_model_setting import SystemModelSetting
from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret
@@ -27,9 +28,11 @@ def build_session(db_file: Path) -> Session:
f"sqlite+pysqlite:///{db_file.as_posix()}",
connect_args={"check_same_thread": False},
)
SystemSetting.__table__.create(bind=engine)
SystemSettingSecret.__table__.create(bind=engine)
SystemModelSetting.__table__.create(bind=engine)
SystemSetting.__table__.create(bind=engine)
SystemSettingSecret.__table__.create(bind=engine)
SystemModelSetting.__table__.create(bind=engine)
HermesTaskConfig.__table__.create(bind=engine)
HermesTaskExecutionLog.__table__.create(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
@@ -45,9 +48,12 @@ def test_settings_service_persists_non_secret_and_secret_fields(monkeypatch) ->
monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes"))
with build_session(temp_dir / "settings.db") as db:
service = SettingsService(db)
initial_snapshot = service.get_settings_snapshot()
payload = initial_snapshot.model_dump()
service = SettingsService(db)
initial_snapshot = service.get_settings_snapshot()
assert initial_snapshot.llmForm.mainModel == "codex-mini-latest"
assert initial_snapshot.llmForm.mainEndpoint == "https://api.openai.com/v1"
assert initial_snapshot.llmForm.mainApiKey == ""
payload = initial_snapshot.model_dump()
payload["companyForm"]["companyName"] = "YGSOFT"
payload["companyForm"]["displayName"] = "云广软件"

View File

@@ -167,7 +167,9 @@ def test_user_agent_knowledge_prompt_enforces_knowledge_boundary() -> None:
assert "不能用常识、外部知识或主观推断补齐缺失条件" in messages[0]["content"]
assert "不能只依赖排在最前面的片段" in messages[0]["content"]
assert "不能把第一列的数值直接套给后面的列名" in messages[0]["content"]
assert "最终答复必须像助手在认真回答问题" in messages[0]["content"]
assert "最终答复必须使用 Markdown" in messages[0]["content"]
assert "## 结论" in messages[0]["content"]
assert "如果不能,必须明确说当前知识库没有找到直接依据" in messages[0]["content"]
assert "禁止使用“已命中”“答案整理阶段”“稍后重试”" in messages[0]["content"]
assert "knowledge_evidence_blocks" in messages[0]["content"]
assert '"knowledge_answer_evidence": []' in messages[1]["content"]
@@ -435,6 +437,9 @@ def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None:
)
assert answer.startswith("张三,您好。")
assert "## 结论" in answer
assert "## 依据" in answer
assert "## 说明" in answer
assert "我先根据当前制度依据给出可以确认的部分" in answer
assert "已命中" not in answer
assert "答案整理阶段本轮没有及时返回" not in answer
@@ -477,8 +482,8 @@ def test_user_agent_knowledge_answer_generation_uses_fast_timeouts(monkeypatch)
)
assert answer == "测试回答"
assert captured["timeout_seconds"] == 5
assert captured["slot_timeouts"] == {"main": 3, "backup": 5}
assert captured["timeout_seconds"] == 30
assert captured["slot_timeouts"] == {"main": 20, "backup": 30}
assert captured["max_attempts"] == 1
@@ -549,7 +554,7 @@ def test_user_agent_knowledge_terms_keep_accounting_subject_in_long_query() -> N
assert "会计科目" in terms
def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> None:
def test_user_agent_knowledge_answer_uses_model_after_retrieval(monkeypatch) -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
@@ -560,11 +565,14 @@ def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> Non
)
)
service = UserAgentService(db)
monkeypatch.setattr(
service,
"_generate_answer_with_model",
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("model should not be called")),
)
captured: dict[str, object] = {}
def fake_generate_answer(payload, **kwargs):
captured["payload"] = payload
captured.update(kwargs)
return "## 结论\n\n员工应在费用发生后 30 日内提交报销申请。"
monkeypatch.setattr(service, "_generate_answer_with_model", fake_generate_answer)
response = service.respond(
UserAgentRequest(
@@ -593,10 +601,11 @@ def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> Non
)
)
assert response.answer.startswith("张三,您好。")
assert "**结论**" in response.answer
assert captured["payload"].ontology.scenario == "knowledge"
assert "费用报销制度" in captured["fallback_answer"]
assert "## 依据" in captured["fallback_answer"]
assert response.answer.startswith("## 结论")
assert "30 日内提交报销申请" in response.answer
assert "## 依据" not in response.answer
assert "答案整理阶段本轮没有及时返回" not in response.answer
@@ -804,7 +813,8 @@ def test_user_agent_fast_knowledge_answer_renders_relevant_table_preview() -> No
assert "| 项目 | 港澳台 | 其他地区 | 国外 |" in answer
assert "| 餐补 | 75 | 55 | 140 |" in answer
assert "餐补的标准为" in answer
assert "## 依据" not in answer
assert "## 结论" in answer
assert "## 依据" in answer
def test_user_agent_fast_knowledge_answer_uses_user_grade_for_table_row() -> None:
@@ -906,8 +916,8 @@ def test_user_agent_fast_knowledge_answer_notes_missing_location_grounding() ->
assert answer is not None
assert "没有直接写出“北京”对应的地区档位或映射关系" in answer
assert "**说明**" in answer
assert "## 依据" not in answer
assert "## 说明" in answer
assert "## 依据" in answer
def test_user_agent_fast_knowledge_answer_expands_lead_in_list_items() -> None:
@@ -952,12 +962,12 @@ def test_user_agent_fast_knowledge_answer_expands_lead_in_list_items() -> None:
)
assert answer is not None
assert "**结论**" in answer
assert "## 结论" in answer
assert "登机牌、高速道路通行记录" in answer
assert "支付记录" in answer
assert "出差审批邮件、短信、微信等" in answer
assert "3" not in answer
assert "## 依据" not in answer
assert "## 依据" in answer
def test_user_agent_model_prompt_supports_contextual_personalization() -> None: