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

File diff suppressed because it is too large Load Diff

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 sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db 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.common import ErrorResponse
from app.schemas.reimbursement import ( from app.schemas.reimbursement import (
ExpenseClaimAttachmentActionResponse, ExpenseClaimAttachmentActionResponse,
@@ -25,6 +26,7 @@ from app.schemas.reimbursement import (
TravelReimbursementCalculatorResponse, TravelReimbursementCalculatorResponse,
) )
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
from app.services.budget import BudgetService
from app.services.reimbursement import ReimbursementService from app.services.reimbursement import ReimbursementService
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService 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 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( @router.patch(
"/claims/{claim_id}", "/claims/{claim_id}",
response_model=ExpenseClaimRead, response_model=ExpenseClaimRead,
@@ -538,7 +572,7 @@ def return_expense_claim(
"/claims/{claim_id}/approve", "/claims/{claim_id}/approve",
response_model=ExpenseClaimRead, response_model=ExpenseClaimRead,
summary="审批通过单据", summary="审批通过单据",
description="费用申请由直属领导审批通过后完成;报销单直属领导审批后流转到财务审批,财务终审通过后进入归档入账", description="费用申请由直属领导审批后流转到预算管理者审批,预算审核通过后生成报销草稿;报销单直属领导审批后流转到财务审批。",
responses={ responses={
status.HTTP_404_NOT_FOUND: { status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse, "model": ErrorResponse,

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from typing import Any
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -144,6 +145,17 @@ class BudgetCheckRead(BaseModel):
allocation: BudgetAllocationRead | None = None 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): class BudgetOperationRead(BaseModel):
ok: bool ok: bool
message: str message: str

View File

@@ -87,7 +87,10 @@ class AgentFoundationService(
def _foundation_cache_key(self) -> str: def _foundation_cache_key(self) -> str:
bind = self.db.get_bind() 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: def _ensure_financial_record_schema(self) -> None:
bind = self.db.get_bind() 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") logger = get_logger("app.services.agent_runs")
KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT = timedelta(minutes=30) KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT = timedelta(minutes=30)
KNOWLEDGE_SYNC_JOB_TYPES = {"knowledge_index_sync", "llm_wiki_sync"}
class AgentRunService: class AgentRunService:
@@ -262,7 +263,7 @@ class AgentRunService:
continue continue
route_json = dict(run.route_json or {}) 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 continue
heartbeat_at = self._parse_heartbeat_time( heartbeat_at = self._parse_heartbeat_time(

View File

@@ -20,6 +20,7 @@ from app.schemas.budget import (
BudgetSummaryRead, BudgetSummaryRead,
BudgetTransactionRead, BudgetTransactionRead,
) )
from app.services.budget_expense_control import BudgetExpenseControlModel
from app.services.budget_support import BudgetSupportMixin from app.services.budget_support import BudgetSupportMixin
from app.services.budget_types import BudgetControlError, SUPPORTED_BUDGET_SUBJECT_CODES from app.services.budget_types import BudgetControlError, SUPPORTED_BUDGET_SUBJECT_CODES
@@ -112,6 +113,9 @@ class BudgetService(BudgetSupportMixin):
warnings=warnings, 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( def create_or_update_allocation(
self, self,
payload: BudgetAllocationCreate, 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) 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")) over_budget_amount = max(amount - balance.available_amount, Decimal("0.00"))
return { return {
"matched": True, "matched": True,
@@ -319,6 +327,7 @@ class BudgetSupportMixin:
"claim_amount": str(amount), "claim_amount": str(amount),
"total_amount": str(balance.total_amount), "total_amount": str(balance.total_amount),
"reserved_amount": str(balance.reserved_amount), "reserved_amount": str(balance.reserved_amount),
"current_reserved_amount": str(current_reserved_amount),
"consumed_amount": str(balance.consumed_amount), "consumed_amount": str(balance.consumed_amount),
"available_amount": str(balance.available_amount), "available_amount": str(balance.available_amount),
"usage_rate": str(balance.usage_rate), "usage_rate": str(balance.usage_rate),
@@ -335,6 +344,14 @@ class BudgetSupportMixin:
"project_code": allocation.project_code, "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: def _find_allocation_for_claim(self, claim: ExpenseClaim) -> BudgetAllocation | None:
fiscal_year, period_key = self._period_from_claim(claim) fiscal_year, period_key = self._period_from_claim(claim)
return self._find_allocation_for_dimension( 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.employee import Employee
from app.models.financial_record import ExpenseClaim from app.models.financial_record import ExpenseClaim
from app.models.organization import OrganizationUnit 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"} PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"}
ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive"} ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive"}
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"} 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"} CLAIM_DELETE_ROLE_CODES = {"executive"}
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid") ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
APPLICATION_ARCHIVED_STAGES = ("审批完成", "申请归档", "completed") APPLICATION_ARCHIVED_STAGES = (APPROVAL_DONE_STAGE, "申请归档", "completed")
class ExpenseClaimAccessPolicy: class ExpenseClaimAccessPolicy:
@@ -49,7 +60,7 @@ class ExpenseClaimAccessPolicy:
normalized_type.like("%\\_application", escape="\\"), normalized_type.like("%\\_application", escape="\\"),
) )
return or_( return or_(
stage == "归档入账", stage == ARCHIVE_ACCOUNTING_STAGE,
stage == "completed", stage == "completed",
and_( and_(
application_condition, application_condition,
@@ -61,7 +72,7 @@ class ExpenseClaimAccessPolicy:
or_( or_(
stage == "", stage == "",
stage.is_(None), stage.is_(None),
stage == "归档入账", stage == ARCHIVE_ACCOUNTING_STAGE,
stage == "completed", stage == "completed",
), ),
), ),
@@ -77,7 +88,7 @@ class ExpenseClaimAccessPolicy:
def is_archived_claim(claim: ExpenseClaim) -> bool: def is_archived_claim(claim: ExpenseClaim) -> bool:
normalized_status = str(claim.status or "").strip().lower() normalized_status = str(claim.status or "").strip().lower()
stage = str(claim.approval_stage or "").strip() stage = str(claim.approval_stage or "").strip()
if stage in {"归档入账", "completed"}: if stage in {ARCHIVE_ACCOUNTING_STAGE, "completed"}:
return True return True
normalized_type = str(claim.expense_type or "").strip().lower() normalized_type = str(claim.expense_type or "").strip().lower()
claim_no = str(claim.claim_no or "").strip().upper() claim_no = str(claim.claim_no or "").strip().upper()
@@ -92,7 +103,7 @@ class ExpenseClaimAccessPolicy:
and stage in APPLICATION_ARCHIVED_STAGES and stage in APPLICATION_ARCHIVED_STAGES
): ):
return True 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: def can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
normalized_status = str(claim.status or "").strip().lower() normalized_status = str(claim.status or "").strip().lower()
@@ -100,9 +111,11 @@ class ExpenseClaimAccessPolicy:
return False return False
stage = str(claim.approval_stage or "").strip() 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) 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( return self.has_privileged_claim_access(current_user) and not self.is_claim_owned_by_current_user(
claim, claim,
current_user, current_user,
@@ -111,9 +124,11 @@ class ExpenseClaimAccessPolicy:
def can_approve_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool: def can_approve_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
stage = str(claim.approval_stage or "").strip() 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) 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) role_codes = self.normalize_role_codes(current_user)
return ( return (
(current_user.is_admin or "finance" in role_codes) (current_user.is_admin or "finance" in role_codes)
@@ -127,7 +142,7 @@ class ExpenseClaimAccessPolicy:
return False return False
if str(claim.status or "").strip().lower() != "submitted": if str(claim.status or "").strip().lower() != "submitted":
return False return False
if str(claim.approval_stage or "").strip() != "直属领导审批": if str(claim.approval_stage or "").strip() != DIRECT_MANAGER_APPROVAL_STAGE:
return False return False
current_employee = self.resolve_current_employee(current_user) current_employee = self.resolve_current_employee(current_user)
@@ -149,6 +164,65 @@ class ExpenseClaimAccessPolicy:
return self.resolve_claim_manager_name(claim) == approver_name 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 @staticmethod
def normalize_role_codes(current_user: CurrentUserContext) -> set[str]: def normalize_role_codes(current_user: CurrentUserContext) -> set[str]:
return { return {
@@ -157,6 +231,51 @@ class ExpenseClaimAccessPolicy:
if str(item).strip() 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: def resolve_current_employee(self, current_user: CurrentUserContext) -> Employee | None:
return self.resolve_employee_by_identity_candidates( return self.resolve_employee_by_identity_candidates(
[ [
@@ -375,7 +494,7 @@ class ExpenseClaimAccessPolicy:
).strip() ).strip()
pending_leader_approval_parts = [ pending_leader_approval_parts = [
ExpenseClaim.status == "submitted", ExpenseClaim.status == "submitted",
ExpenseClaim.approval_stage == "直属领导审批", ExpenseClaim.approval_stage == DIRECT_MANAGER_APPROVAL_STAGE,
] ]
if employee is not None: if employee is not None:
pending_leader_approval_parts.append( pending_leader_approval_parts.append(
@@ -399,17 +518,55 @@ class ExpenseClaimAccessPolicy:
return conditions 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: def apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
role_codes = self.normalize_role_codes(current_user) role_codes = self.normalize_role_codes(current_user)
if current_user.is_admin or "executive" in role_codes: if current_user.is_admin or "executive" in role_codes:
return stmt.where(ExpenseClaim.status == "submitted") return stmt.where(ExpenseClaim.status == "submitted")
conditions = []
if "finance" in role_codes: if "finance" in role_codes:
return stmt.where( conditions.append(and_(
ExpenseClaim.status == "submitted", 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: if not conditions:
return stmt.where(ExpenseClaim.id == "__no_visible_claim__") return stmt.where(ExpenseClaim.id == "__no_visible_claim__")
@@ -440,6 +597,7 @@ class ExpenseClaimAccessPolicy:
return stmt.where(ExpenseClaim.id == "__no_visible_claim__") return stmt.where(ExpenseClaim.id == "__no_visible_claim__")
if include_approval_scope: if include_approval_scope:
conditions.extend(self.build_budget_approval_claim_conditions(current_user))
conditions.extend(self.build_approval_claim_conditions(current_user)) conditions.extend(self.build_approval_claim_conditions(current_user))
return stmt.where(or_(*conditions)) return stmt.where(or_(*conditions))

View File

@@ -68,7 +68,10 @@ class ExpenseClaimApplicationHandoffMixin:
"application_claim_no": application_claim.claim_no, "application_claim_no": application_claim.claim_no,
"application_budget_amount": str(application_claim.amount or Decimal("0.00")), "application_budget_amount": str(application_claim.amount or Decimal("0.00")),
"application_approval_event_id": str(approval_flag.get("approval_event_id") or ""), "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(), "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_budget_basis_missing": "预算测算依据不足",
"application_policy_mismatch": "制度口径不匹配", "application_policy_mismatch": "制度口径不匹配",
"application_attachment_needed": "前置材料需补充", "application_attachment_needed": "前置材料需补充",
"application_other": "其他",
} }
MAX_CLAIM_NO_RETRY_ATTEMPTS = 3 MAX_CLAIM_NO_RETRY_ATTEMPTS = 3
DOCUMENT_DATE_PATTERN = re.compile( 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.api.deps import CurrentUserContext
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
from app.models.agent_asset import AgentAsset from app.models.agent_asset import AgentAsset
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
@@ -50,7 +51,7 @@ class ExpenseClaimItemSyncMixin:
self._discard_claim_item(claim, item) self._discard_claim_item(claim, item)
return return
grade = str(claim.employee_grade or "").strip() grade = self._resolve_claim_employee_grade(claim)
if not grade: if not grade:
return return
@@ -115,6 +116,16 @@ class ExpenseClaimItemSyncMixin:
item.item_amount = allowance_amount item.item_amount = allowance_amount
item.invoice_id = None 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: def _discard_claim_item(self, claim: ExpenseClaim, item: ExpenseClaimItem) -> None:
if item in claim.items: if item in claim.items:
claim.items.remove(item) claim.items.remove(item)

View File

@@ -204,6 +204,8 @@ class ExpenseClaimReadModelMixin:
normalized = str(stage or "").strip() normalized = str(stage or "").strip()
if "直属" in normalized or "领导" in normalized or "负责人" in normalized: if "直属" in normalized or "领导" in normalized or "负责人" in normalized:
return "direct_manager" return "direct_manager"
if "预算" in normalized:
return "budget"
if "财务" in normalized: if "财务" in normalized:
return "finance" return "finance"
if "AI" in normalized or "预审" in normalized: 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_intelligence import build_document_insight
from app.services.document_numbering import is_application_claim_no from app.services.document_numbering import is_application_claim_no
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy 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_presentation import ExpenseClaimAttachmentPresentation
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin
@@ -127,6 +128,7 @@ from app.services.ocr import OcrService
class ExpenseClaimService( class ExpenseClaimService(
ExpenseClaimApprovalFlowMixin,
ExpenseClaimApplicationHandoffMixin, ExpenseClaimApplicationHandoffMixin,
ExpenseClaimBudgetFlowMixin, ExpenseClaimBudgetFlowMixin,
ExpenseClaimAttachmentOperationsMixin, ExpenseClaimAttachmentOperationsMixin,
@@ -234,6 +236,18 @@ class ExpenseClaimService(
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True) stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
return self.db.scalar(stmt) 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( def update_claim(
self, self,
*, *,
@@ -562,9 +576,6 @@ class ExpenseClaimService(
if claim is None: if claim is None:
return None return None
if not self._access_policy.can_return_claim(current_user, claim):
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
normalized_status = str(claim.status or "").strip().lower() normalized_status = str(claim.status or "").strip().lower()
if normalized_status == "draft": if normalized_status == "draft":
raise ValueError("草稿状态无需退回。") raise ValueError("草稿状态无需退回。")
@@ -573,6 +584,9 @@ class ExpenseClaimService(
if normalized_status in {"approved", "completed", "paid"}: if normalized_status in {"approved", "completed", "paid"}:
raise ValueError("已完成单据不允许退回。") raise ValueError("已完成单据不允许退回。")
if not self._access_policy.can_return_claim(current_user, claim):
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
before_json = self._serialize_claim(claim) before_json = self._serialize_claim(claim)
operator = self._access_policy.resolve_current_user_display_name(current_user) operator = self._access_policy.resolve_current_user_display_name(current_user)
previous_status = str(claim.status or "").strip() previous_status = str(claim.status or "").strip()
@@ -580,21 +594,25 @@ class ExpenseClaimService(
previous_stage_key = self._normalize_return_stage_key(previous_stage) previous_stage_key = self._normalize_return_stage_key(previous_stage)
is_application_claim = self._is_expense_application_claim(claim) is_application_claim = self._is_expense_application_claim(claim)
is_direct_manager_return = previous_stage_key == "direct_manager" 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 = ( return_event_type = (
"expense_application_return" "expense_application_return"
if is_application_claim and is_direct_manager_return if is_application_return
else "expense_claim_return" else "expense_claim_return"
) )
return_label = ( return_label = (
"领导退回" "领导退回"
if is_application_claim and is_direct_manager_return if is_application_claim and is_direct_manager_return
else "预算退回"
if is_application_claim and is_budget_return
else "人工退回" else "人工退回"
) )
return_reason = str(reason or "").strip() return_reason = str(reason or "").strip()
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes) reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
normalized_reason_codes = reason_code_payload["reason_codes"] normalized_reason_codes = reason_code_payload["reason_codes"]
unknown_reason_codes = reason_code_payload["unknown_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 code.startswith("application_") for code in normalized_reason_codes
): ):
raise ValueError("申请单退回必须选择至少一个退单类型。") raise ValueError("申请单退回必须选择至少一个退单类型。")
@@ -627,6 +645,7 @@ class ExpenseClaimService(
"reason": return_reason, "reason": return_reason,
"opinion": message, "opinion": message,
"leader_opinion": message if is_application_claim and is_direct_manager_return else "", "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, "reason_codes": normalized_reason_codes,
"risk_points": risk_points, "risk_points": risk_points,
"operator": operator, "operator": operator,
@@ -676,204 +695,6 @@ class ExpenseClaimService(
return claim 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 {} status_payload = status_map.get(document_id) or {}
rag_status = str(status_payload.get("status") or "").strip().lower() rag_status = str(status_payload.get("status") or "").strip().lower()
linked_run_status = resolve_linked_ingest_run_status(entry, db=self.db) 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", "pending",
"processing", "processing",
"preprocessed", "preprocessed",

View File

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

View File

@@ -229,7 +229,13 @@ class _LightRagRuntime:
raise KnowledgeRagError(str(getattr(result, "message", "") or "LightRAG 删除文档失败。")) raise KnowledgeRagError(str(getattr(result, "message", "") or "LightRAG 删除文档失败。"))
def _probe_embedding_dimension(self, config: RuntimeModelConfig) -> int: 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): if not vectors or not isinstance(vectors[0], list):
raise KnowledgeRagError("无法从 embedding 模型返回结果中解析向量维度。") raise KnowledgeRagError("无法从 embedding 模型返回结果中解析向量维度。")
dimension = len(vectors[0]) dimension = len(vectors[0])

View File

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

View File

@@ -148,22 +148,7 @@ class UserAgentService(
requires_confirmation=payload.requires_confirmation, 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( fallback_answer = self._build_fallback_answer(
payload, payload,
citations=citations, citations=citations,

View File

@@ -86,7 +86,6 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
*, *,
citations: list[UserAgentCitation], citations: list[UserAgentCitation],
) -> str | None: ) -> str | None:
return None
if payload.ontology.scenario != "knowledge": if payload.ontology.scenario != "knowledge":
return None return None
if str(payload.tool_payload.get("result_type") or "").strip() != "knowledge_search": 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( primary_heading = self._format_knowledge_heading_label(
str(primary_item.get("heading") or "").strip() 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] = [] lines: list[str] = []
if user_name: if user_name:
@@ -139,20 +141,42 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
if primary_heading: if primary_heading:
source_prefix = f"{source_prefix}{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": if str(primary_item.get("kind") or "") == "table":
lines.append(f"{source_prefix},当前能直接确认的是:") table_content = str(primary_item.get("content") or "")
lines.append(self._extract_relevant_table_preview(str(primary_item.get("content") or ""), query_terms)) 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: else:
if not primary_lines: if not primary_lines:
lines.append( summary = self._summarize_knowledge_evidence_content(primary_item, query_terms)
conclusion_lines.append(
f"{source_prefix},当前能直接确认的是:" f"{source_prefix},当前能直接确认的是:"
f"{self._summarize_knowledge_evidence_content(primary_item, query_terms)}" f"{summary}"
) )
elif len(primary_lines) == 1: 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: else:
lines.append(f"{source_prefix},当前能直接确认的是:") subject = self._build_knowledge_answer_subject(question, primary_heading)
lines.extend(primary_lines) 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] = [] notes: list[str] = []
location_note = self._build_missing_location_grounding_note(question, evidence_items) 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): if self._question_requires_explicit_condition(question) and not self._answer_evidence_has_numeric_or_condition(evidence_items):
notes.append("当前命中的证据更偏规则说明或流程约束,还没有直接给出可立即套用的数值或完整条件。") notes.append("当前命中的证据更偏规则说明或流程约束,还没有直接给出可立即套用的数值或完整条件。")
self._append_markdown_section(lines, "结论", conclusion_lines)
self._append_markdown_section(lines, "依据", evidence_lines)
if notes: if notes:
lines.append("") self._append_markdown_section(lines, "说明", [f"- {note}" for note in notes])
lines.append("说明:")
lines.extend(f"- {note}" for note in notes)
return "\n".join(line for line in lines if line is not None).strip() 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 @staticmethod
def _resolve_knowledge_question(payload: UserAgentRequest) -> str: def _resolve_knowledge_question(payload: UserAgentRequest) -> str:
return str(payload.context_json.get("user_input_text") or payload.message or "").strip() 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( def _collect_direct_knowledge_answer_lines(
self, self,
ordered_evidence_items: list[dict[str, Any]], ordered_evidence_items: list[dict[str, Any]],
*,
query_terms: list[str] | None = None,
) -> list[str]: ) -> list[str]:
if not ordered_evidence_items: if not ordered_evidence_items:
return [] return []
@@ -509,8 +585,18 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
lines: list[str] = [] lines: list[str] = []
seen: set[str] = set() seen: set[str] = set()
for item in related_items: for item in related_items:
rendered = self._render_knowledge_evidence_text(item) item_kind = str(item.get("kind") or "").strip()
for line in rendered.splitlines(): 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() normalized = str(line or "").strip()
if not normalized or normalized in seen: if not normalized or normalized in seen:
continue continue
@@ -573,13 +659,21 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
or "相关制度" or "相关制度"
).strip() ).strip()
user_name = str(payload.context_json.get("name") 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: if not hits:
return ( self._append_markdown_section(
f"{prefix}我已经从《{title}》中检索到与你这次问题相关的制度依据," answer_lines,
"但本次答案生成环节暂时没有成功返回。请稍后重试一次;如果仍然失败," "结论",
"建议先检查主对话模型的连通性。" [f"当前没有拿到可用于回答这个问题的《{title}》知识库命中。"],
) )
self._append_markdown_section(
answer_lines,
"说明",
["- 我不会用相似主题或外部常识硬凑答案;请补充更具体的关键词后再试一次。"],
)
return "\n".join(answer_lines).strip()
evidence_lines: list[str] = [] evidence_lines: list[str] = []
for item in evidence_items[:3]: for item in evidence_items[:3]:
@@ -614,19 +708,28 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
evidence_lines.append(f"- **《{item_title}》**{excerpt}") evidence_lines.append(f"- **《{item_title}》**{excerpt}")
if not evidence_lines: if not evidence_lines:
return ( self._append_markdown_section(
f"{prefix}当前《{title}》里可用于回答的关键条款还不够明确。" answer_lines,
"请补充费用类型、适用地区、职级或具体业务场景,我再继续帮你缩小范围。" "结论",
[f"当前《{title}》里可用于回答这个问题的关键条款还不够明确。"],
) )
self._append_markdown_section(
answer_lines,
"说明",
["- 请补充费用类型、适用地区、职级或具体业务场景,我再继续帮你缩小范围。"],
)
return "\n".join(answer_lines).strip()
return "\n".join( self._append_markdown_section(
[ answer_lines,
f"{prefix}我先根据当前制度依据给出可以确认的部分。", "结论",
"", ["我先根据当前制度依据给出可以确认的部分。"],
"**依据**", )
*evidence_lines, self._append_markdown_section(answer_lines, "依据", evidence_lines)
"", self._append_markdown_section(
"**说明**:以上只使用当前命中的知识库证据;没有在证据中出现的适用条件或金额,我不会替你默认补齐。", answer_lines,
] "说明",
).strip() ["- 以上只使用当前命中的知识库证据;没有在证据中出现的适用条件或金额,我不会替你默认补齐。"],
)
return "\n".join(answer_lines).strip()

View File

@@ -280,10 +280,15 @@ class UserAgentResponseMixin:
if payload.ontology.scenario == "knowledge": if payload.ontology.scenario == "knowledge":
answer_style_instruction = ( answer_style_instruction = (
"你是财务制度知识问答助手。只能依据 facts.tool_payload.hits、facts.knowledge_answer_evidence、citations 与 conversation_history 回答," "你是财务制度知识问答助手。只能依据 facts.tool_payload.hits、facts.knowledge_answer_evidence、citations 与 conversation_history 回答,"
"不要扩展成通用助手。优先直接回答,不要复述思考过程,不要输出 JSON、代码块或 <think>" "不要扩展成通用助手。优先直接回答,不要复述思考过程,不要输出 JSON、代码块或可见思考过程"
"禁止使用“已命中”“答案整理阶段”“稍后重试”。"
"最终答复必须使用 Markdown优先包含“## 结论”“## 依据”“## 说明”这三个二级标题;"
"如果某一部分没有内容,可以省略该标题。"
"回答风格要像一位真正熟悉制度的财务伙伴:先直接回应用户的核心问题,再用一张简洁表格或短段落说明依据," "回答风格要像一位真正熟悉制度的财务伙伴:先直接回应用户的核心问题,再用一张简洁表格或短段落说明依据,"
"最后补充最重要的注意事项。不要写成“已检索到内容”的系统回执,也不要把命中片段连缀成答案。" "最后补充最重要的注意事项。不要写成“已检索到内容”的系统回执,也不要把命中片段连缀成答案。"
"必须优先回答用户当前这句话本身,不能把制度标题、制度全文或完整标准表当成主答案。" "必须优先回答用户当前这句话本身,不能把制度标题、制度全文或完整标准表当成主答案。"
"回答前先判断召回内容是否真的能回答当前问题;如果不能,必须明确说当前知识库没有找到直接依据,"
"不要改答相邻主题,也不要用相似条款硬凑答案。"
"如果用户问的是某次具体行程“一共能报多少”,就先给“当前已能确认的金额”,再用一张很短的表说明项目、" "如果用户问的是某次具体行程“一共能报多少”,就先给“当前已能确认的金额”,再用一张很短的表说明项目、"
"适用标准、计算式和结果;如果总额还缺少住宿晚数、实际票据或其他必要条件,就明确写出“暂不能确认的部分”。" "适用标准、计算式和结果;如果总额还缺少住宿晚数、实际票据或其他必要条件,就明确写出“暂不能确认的部分”。"
"只有用户明确在问“标准有哪些”或“制度全文怎么规定”时,才展开完整标准表。" "只有用户明确在问“标准有哪些”或“制度全文怎么规定”时,才展开完整标准表。"
@@ -488,7 +493,7 @@ class UserAgentResponseMixin:
citations: list[UserAgentCitation], citations: list[UserAgentCitation],
) -> str: ) -> str:
if str(payload.tool_payload.get("result_type") or "").strip() == "knowledge_search": 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) return self._build_knowledge_search_answer(payload, citations)
tool_message = str(payload.tool_payload.get("message") or "").strip() 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", "updated_at": "2026-05-17T09:28:28.999515+00:00",
"uploaded_by": "admin", "uploaded_by": "admin",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:08.579777+00:00", "ingest_status_updated_at": "2026-05-17T10:01:33.272539+00:00",
"ingest_completed_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_name": "远光《公司支出管理办法2024》.pdf",
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00", "ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
"ingest_document_sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece", "ingest_document_sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece",
"ingest_agent_run_id": "run_7236fb72747742a3" "ingest_agent_run_id": "run_b5984bade5324755"
}, },
{ {
"id": "c7601043d9944ef2bcf4d3f67ed253f7", "id": "c7601043d9944ef2bcf4d3f67ed253f7",
@@ -35,8 +35,8 @@
"updated_at": "2026-05-22T07:00:22.328877+00:00", "updated_at": "2026-05-22T07:00:22.328877+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:09.863684+00:00", "ingest_status_updated_at": "2026-05-22T09:22:25.565409+00:00",
"ingest_completed_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_name": "远光软件会计科目使用说明.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:22.328877+00:00", "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", "updated_at": "2026-05-22T07:00:22.011016+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:00:50.652735+00:00", "ingest_status_updated_at": "2026-05-23T14:30:33.605531+00:00",
"ingest_completed_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_name": "远光软件财务基础知识手册.docx",
"ingest_document_updated_at": "2026-05-22T07:00:22.011016+00:00", "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", "updated_at": "2026-05-22T07:00:22.352133+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:00:51.908821+00:00", "ingest_status_updated_at": "2026-05-22T09:23:11.334499+00:00",
"ingest_completed_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_name": "远光软件财务术语解释手册.docx",
"ingest_document_updated_at": "2026-05-22T07:00:22.352133+00:00", "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", "updated_at": "2026-05-22T07:00:22.304623+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:13.581834+00:00", "ingest_status_updated_at": "2026-05-22T09:24:18.933073+00:00",
"ingest_completed_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_name": "远光软件高新技术企业税收优惠政策汇总.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:22.304623+00:00", "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", "updated_at": "2026-05-22T07:00:18.153373+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:00:53.906324+00:00", "ingest_status_updated_at": "2026-05-22T16:01:43.168774+00:00",
"ingest_completed_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_name": "远光软件公司内部控制基本规范.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:18.153373+00:00", "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", "updated_at": "2026-05-22T07:00:18.190399+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:00:55.339114+00:00", "ingest_status_updated_at": "2026-05-22T16:03:00.735908+00:00",
"ingest_completed_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_name": "远光软件公司合同管理制度.docx",
"ingest_document_updated_at": "2026-05-22T07:00:18.190399+00:00", "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", "updated_at": "2026-05-22T07:00:17.798679+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:00:56.741808+00:00", "ingest_status_updated_at": "2026-05-22T16:03:46.921675+00:00",
"ingest_completed_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_name": "远光软件公司财务管理制度总则.docx",
"ingest_document_updated_at": "2026-05-22T07:00:17.798679+00:00", "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", "updated_at": "2026-05-22T07:00:18.531598+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:19.014702+00:00", "ingest_status_updated_at": "2026-05-22T16:04:58.719410+00:00",
"ingest_completed_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_name": "远光软件公司资产管理制度.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:18.531598+00:00", "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", "updated_at": "2026-05-22T07:00:18.221073+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:00:59.485821+00:00", "ingest_status_updated_at": "2026-05-22T16:06:08.172318+00:00",
"ingest_completed_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_name": "远光软件公司采购管理办法.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:18.221073+00:00", "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", "updated_at": "2026-05-22T07:00:19.734422+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:00.774887+00:00", "ingest_status_updated_at": "2026-05-22T16:06:48.466110+00:00",
"ingest_completed_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_name": "远光软件公司差旅费管理办法.docx",
"ingest_document_updated_at": "2026-05-22T07:00:19.734422+00:00", "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", "updated_at": "2026-05-22T07:00:20.095824+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:02.037101+00:00", "ingest_status_updated_at": "2026-05-22T16:07:23.262328+00:00",
"ingest_completed_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_name": "远光软件出差审批流程说明.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:20.095824+00:00", "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", "updated_at": "2026-05-22T07:00:20.128471+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:24.076574+00:00", "ingest_status_updated_at": "2026-05-22T16:08:02.190081+00:00",
"ingest_completed_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_name": "远光软件国际出差管理规定.docx",
"ingest_document_updated_at": "2026-05-22T07:00:20.128471+00:00", "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", "updated_at": "2026-05-22T07:00:19.759954+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:25.270086+00:00", "ingest_status_updated_at": "2026-05-22T16:09:23.091744+00:00",
"ingest_completed_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_name": "远光软件差旅费标准速查表.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:19.759954+00:00", "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", "updated_at": "2026-05-22T07:00:18.922298+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:26.510710+00:00", "ingest_status_updated_at": "2026-05-22T16:11:04.764727+00:00",
"ingest_completed_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_name": "远光软件公司发票审核标准.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:18.922298+00:00", "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", "updated_at": "2026-05-22T07:00:18.560177+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:06.719118+00:00", "ingest_status_updated_at": "2026-05-22T16:11:54.017817+00:00",
"ingest_completed_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_name": "远光软件公司发票管理规范.docx",
"ingest_document_updated_at": "2026-05-22T07:00:18.560177+00:00", "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", "updated_at": "2026-05-22T07:00:18.888128+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:28.865726+00:00", "ingest_status_updated_at": "2026-05-22T16:12:23.821434+00:00",
"ingest_completed_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_name": "远光软件公司增值税发票操作指南.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:18.888128+00:00", "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", "updated_at": "2026-05-22T07:00:18.953110+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:30.095619+00:00", "ingest_status_updated_at": "2026-05-22T16:13:15.450300+00:00",
"ingest_completed_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_name": "远光软件公司电子发票管理办法.docx",
"ingest_document_updated_at": "2026-05-22T07:00:18.953110+00:00", "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", "updated_at": "2026-05-22T07:00:21.585718+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:09.790447+00:00", "ingest_status_updated_at": "2026-05-22T16:13:44.636629+00:00",
"ingest_completed_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_name": "远光软件企业所得税汇算清缴操作手册.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:21.585718+00:00", "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", "updated_at": "2026-05-22T07:00:20.881351+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:11.027818+00:00", "ingest_status_updated_at": "2026-05-22T16:14:50.092490+00:00",
"ingest_completed_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_name": "远光软件公司税务管理制度.docx",
"ingest_document_updated_at": "2026-05-22T07:00:20.881351+00:00", "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", "updated_at": "2026-05-22T07:00:21.606227+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:33.826025+00:00", "ingest_status_updated_at": "2026-05-22T16:15:56.676286+00:00",
"ingest_completed_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_name": "远光软件研发费用加计扣除管理办法.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:21.606227+00:00", "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", "updated_at": "2026-05-22T07:00:21.202633+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:13.991763+00:00", "ingest_status_updated_at": "2026-05-22T16:16:06.540773+00:00",
"ingest_completed_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_name": "远光软件软件产品增值税即征即退操作指南.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:21.202633+00:00", "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", "updated_at": "2026-05-22T07:00:22.379307+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:15.257700+00:00", "ingest_status_updated_at": "2026-05-22T16:23:24.252614+00:00",
"ingest_completed_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_name": "远光软件公司预算管理制度.docx",
"ingest_document_updated_at": "2026-05-22T07:00:22.379307+00:00", "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", "updated_at": "2026-05-22T07:00:22.760169+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:16.510610+00:00", "ingest_status_updated_at": "2026-05-22T16:23:29.997956+00:00",
"ingest_completed_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_name": "远光软件年度预算编制指南.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:22.760169+00:00", "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", "updated_at": "2026-05-22T07:00:22.848272+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:38.728430+00:00", "ingest_status_updated_at": "2026-05-22T16:24:37.382612+00:00",
"ingest_completed_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_name": "远光软件预算执行分析报告模板.docx",
"ingest_document_updated_at": "2026-05-22T07:00:22.848272+00:00", "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", "updated_at": "2026-05-22T07:00:22.803708+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:19.050297+00:00", "ingest_status_updated_at": "2026-05-22T16:24:45.161319+00:00",
"ingest_completed_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_name": "远光软件预算编制模板.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:22.803708+00:00", "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", "updated_at": "2026-05-22T07:00:21.971983+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:20.323058+00:00", "ingest_status_updated_at": "2026-05-22T16:25:33.968414+00:00",
"ingest_completed_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_name": "远光软件财务共享服务SLA标准.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:21.971983+00:00", "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", "updated_at": "2026-05-22T07:00:21.634300+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:21.585474+00:00", "ingest_status_updated_at": "2026-05-22T16:26:05.301987+00:00",
"ingest_completed_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_name": "远光软件财务共享服务中心运营管理办法.docx",
"ingest_document_updated_at": "2026-05-22T07:00:21.634300+00:00", "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", "updated_at": "2026-05-22T07:00:21.945868+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:43.752235+00:00", "ingest_status_updated_at": "2026-05-22T16:26:54.048075+00:00",
"ingest_completed_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_name": "远光软件财务共享服务操作手册.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:21.945868+00:00", "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", "updated_at": "2026-05-22T07:00:19.662743+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:24.093834+00:00", "ingest_status_updated_at": "2026-05-22T16:27:31.775974+00:00",
"ingest_completed_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_name": "远光软件报销流程培训手册.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:19.662743+00:00", "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", "updated_at": "2026-05-22T07:00:19.323921+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:25.246857+00:00", "ingest_status_updated_at": "2026-05-22T16:27:44.244066+00:00",
"ingest_completed_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_name": "远光软件新员工财务培训课件.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:19.323921+00:00", "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", "updated_at": "2026-05-22T07:00:18.988700+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:26.471932+00:00", "ingest_status_updated_at": "2026-05-22T16:28:24.573683+00:00",
"ingest_completed_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_name": "远光软件财务制度培训手册.docx",
"ingest_document_updated_at": "2026-05-22T07:00:18.988700+00:00", "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", "updated_at": "2026-05-22T07:00:19.686485+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:48.525207+00:00", "ingest_status_updated_at": "2026-05-22T16:29:03.349502+00:00",
"ingest_completed_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_name": "远光软件财务培训课程安排.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:19.686485+00:00", "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", "updated_at": "2026-05-22T07:00:20.476077+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:49.746825+00:00", "ingest_status_updated_at": "2026-05-22T16:29:29.050791+00:00",
"ingest_completed_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_name": "远光软件报销问题处理指引.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:20.476077+00:00", "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", "updated_at": "2026-05-22T07:00:20.453567+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:30.343781+00:00", "ingest_status_updated_at": "2026-05-22T16:35:03.548506+00:00",
"ingest_completed_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_name": "远光软件财务制度问答汇总.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:20.453567+00:00", "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", "updated_at": "2026-05-22T07:00:20.158497+00:00",
"uploaded_by": "系统导入", "uploaded_by": "系统导入",
"version_number": 1, "version_number": 1,
"ingest_status": 4, "ingest_status": 3,
"ingest_status_updated_at": "2026-05-26T16:01:31.573128+00:00", "ingest_status_updated_at": "2026-05-22T16:35:27.056080+00:00",
"ingest_completed_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_name": "远光软件财务报销常见问题解答.docx",
"ingest_document_updated_at": "2026-05-22T07:00:20.158497+00:00", "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) 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: def test_agent_run_service_updates_existing_tool_call() -> None:
with build_session() as db: with build_session() as db:
service = AgentRunService(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.db.base import Base
from app.main import create_app from app.main import create_app
from app.models.budget import BudgetAllocation, BudgetTransaction 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]: def build_session_factory() -> sessionmaker[Session]:
@@ -109,6 +113,27 @@ def seed_budget_allocations(db: Session) -> None:
db.commit() 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: def test_admin_can_view_all_budget_allocations_without_is_admin_header() -> None:
client, session_factory = build_client() client, session_factory = build_client()
with session_factory() as db: 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.status_code == 201
assert admin_response.json()["department_name"] == "销售部" 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.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit from app.models.organization import OrganizationUnit
from app.models.role import Role
from app.schemas.ontology import OntologyParseRequest from app.schemas.ontology import OntologyParseRequest
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate, ExpenseClaimUpdate from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate, ExpenseClaimUpdate
from app.services.agent_conversations import AgentConversationService 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_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
from app.services.ontology import SemanticOntologyService from app.services.ontology import SemanticOntologyService
@@ -108,6 +110,16 @@ def _seed_budget_allocation(
return 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: def test_validate_claim_for_submission_allows_office_claim_without_location() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService) service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="office", location="待补充") 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: def test_direct_manager_can_route_application_claim_to_budget_approval_then_budget_manager_creates_draft() -> None:
current_user = CurrentUserContext( manager_user = CurrentUserContext(
username="manager-application-approve@example.com", username="manager-application-approve@example.com",
name="李经理", name="李经理",
role_codes=["manager"], role_codes=["manager"],
is_admin=False, 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: 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( manager = Employee(
employee_no="E8112", employee_no="E8112",
name="李经理", name="李经理",
email="manager-application-approve@example.com", 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 = Employee(
employee_no="E8113", employee_no="E8113",
name="张三", name="张三",
email="zhangsan-application-approve@example.com", email="zhangsan-application-approve@example.com",
manager=manager, manager=manager,
organization_unit=department,
) )
db.add_all([manager, employee]) db.add_all([department, manager, budget_manager, employee])
db.flush() db.flush()
claim = ExpenseClaim( claim = ExpenseClaim(
claim_no="APP-20260525-APPROVE", claim_no="APP-20260525-APPROVE",
employee_id=employee.id, employee_id=employee.id,
employee_name="张三", employee_name="张三",
department_id=department.id,
department_name="交付部", department_name="交付部",
project_code="PRJ-A", project_code="PRJ-A",
expense_type="travel_application", expense_type="travel_application",
@@ -3364,10 +3399,33 @@ def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -
db.commit() db.commit()
claim_id = claim.id 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( approved = ExpenseClaimService(db).approve_claim(
claim_id, claim_id,
current_user, budget_user,
opinion="业务必要,同意申请", opinion="预算额度可承接,同意。",
) )
assert approved is not None 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("event_type") == "expense_application_to_reimbursement_draft"
and flag.get("application_claim_no") == "APP-20260525-APPROVE" and flag.get("application_claim_no") == "APP-20260525-APPROVE"
and flag.get("leader_opinion") == "业务必要,同意申请。" and flag.get("leader_opinion") == "业务必要,同意申请。"
and flag.get("budget_opinion") == "预算额度可承接,同意。"
for flag in generated_draft.risk_flags_json for flag in generated_draft.risk_flags_json
) )
assert any( assert any(
isinstance(flag, dict) isinstance(flag, dict)
and flag.get("source") == "manual_approval" and flag.get("source") == "budget_approval"
and flag.get("event_type") == "expense_application_approval" and flag.get("event_type") == "expense_application_budget_approval"
and flag.get("opinion") == "业务必要,同意申请" and flag.get("opinion") == "预算额度可承接,同意。"
and flag.get("previous_approval_stage") == "直属领导审批" and flag.get("previous_approval_stage") == "预算管理者审批"
and flag.get("next_status") == "approved" and flag.get("next_status") == "approved"
and flag.get("next_approval_stage") == "审批完成" and flag.get("next_approval_stage") == "审批完成"
and flag.get("generated_draft_claim_id") == generated_draft.id 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, claim.id,
manager_user, manager_user,
reason="预算说明不够清楚,请补充项目必要性。", reason="预算说明不够清楚,请补充项目必要性。",
reason_codes=["application_business_need_unclear", "application_budget_basis_missing"], reason_codes=["application_budget_basis_missing"],
) )
assert returned is not None 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["leader_opinion"] == "预算说明不够清楚,请补充项目必要性。"
assert return_event["return_stage"] == "直属领导审批" assert return_event["return_stage"] == "直属领导审批"
assert return_event["return_stage_key"] == "direct_manager" assert return_event["return_stage_key"] == "direct_manager"
assert return_event["reason_codes"] == [ assert return_event["reason_codes"] == ["application_budget_basis_missing"]
"application_business_need_unclear", assert return_event["risk_points"] == ["预算测算依据不足"]
"application_budget_basis_missing",
]
assert return_event["risk_points"] == ["业务必要性说明不足", "预算测算依据不足"]
assert return_event["next_status"] == "returned" assert return_event["next_status"] == "returned"
assert return_event["next_approval_stage"] == "待提交" assert return_event["next_approval_stage"] == "待提交"
@@ -3515,20 +3571,43 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
role_codes=["manager"], role_codes=["manager"],
is_admin=False, 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: 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( manager = Employee(
employee_no="M-BUDGET-APP", employee_no="M-BUDGET-APP",
name="李经理", name="李经理",
email="manager-application-budget@example.com", 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 = Employee(
employee_no="E-BUDGET-APP", employee_no="E-BUDGET-APP",
name="张三", name="张三",
email="application-budget-owner-approve@example.com", email="application-budget-owner-approve@example.com",
manager=manager, manager=manager,
organization_unit=department,
) )
db.add_all([manager, employee]) db.add_all([department, manager, budget_manager, employee])
db.flush() db.flush()
_seed_budget_allocation( _seed_budget_allocation(
db, db,
@@ -3560,10 +3639,19 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
service = ExpenseClaimService(db) service = ExpenseClaimService(db)
service.submit_claim(claim.id, owner) service.submit_claim(claim.id, owner)
approved = service.approve_claim(claim.id, manager_user, opinion="同意申请") leader_approved = service.approve_claim(claim.id, manager_user, opinion="同意申请")
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
reservation = db.query(BudgetReservation).one() 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 approved is not None
assert reservation.source_type == "claim" assert reservation.source_type == "claim"
assert reservation.source_id == generated_draft.id 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( current_user = CurrentUserContext(
username="manager-application-required-opinion@example.com", username="manager-application-required-opinion@example.com",
name="李经理", name="李经理",
@@ -3620,19 +3708,79 @@ def test_direct_manager_approval_requires_leader_opinion() -> None:
db.commit() db.commit()
claim_id = claim.id claim_id = claim.id
with pytest.raises(ValueError, match="领导审核意见不能为空"): approved = ExpenseClaimService(db).approve_claim(
ExpenseClaimService(db).approve_claim( claim_id,
claim_id, current_user,
current_user, opinion=" ",
opinion=" ", )
)
db.refresh(claim) assert approved is not None
assert claim.status == "submitted" assert approved.status == "submitted"
assert claim.approval_stage == "直属领导审批" 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 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: def test_finance_approve_reimbursement_consumes_budget_reservation() -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="finance-budget-approve@example.com", 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) claims = ExpenseClaimService(db).list_approval_claims(current_user)
assert [claim.claim_no for claim in claims] == ["EXP-FIN-LIST-202"] 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" 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() knowledge_rag_module.shutdown_knowledge_rag_runtime()
created_runtimes = [] created_runtimes = []
@@ -270,8 +270,8 @@ def test_runtime_cache_is_isolated_by_thread(monkeypatch) -> None:
thread.start() thread.start()
thread.join(timeout=5) thread.join(timeout=5)
assert len(created_runtimes) == 2 assert len(created_runtimes) == 1
assert worker_runtimes[0] is not main_runtime assert worker_runtimes[0] is main_runtime
knowledge_rag_module.shutdown_knowledge_rag_runtime() knowledge_rag_module.shutdown_knowledge_rag_runtime()
assert all(runtime.finalized for runtime in created_runtimes) 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.agent_runs import AgentRunService
from app.services.knowledge import ( from app.services.knowledge import (
KNOWLEDGE_INGEST_STATUS_FAILED, KNOWLEDGE_INGEST_STATUS_FAILED,
KNOWLEDGE_INGEST_STATUS_INGESTED,
KNOWLEDGE_INGEST_STATUS_SYNCING, KNOWLEDGE_INGEST_STATUS_SYNCING,
KnowledgeService, 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) entry = next(item for item in index["documents"] if item["id"] == uploaded.id)
assert changed is True assert changed is True
assert entry["ingest_status"] == KNOWLEDGE_INGEST_STATUS_FAILED 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 import secret_box
from app.core.secret_box import encrypt_secret from app.core.secret_box import encrypt_secret
from app.db.base import Base 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_model_setting import SystemModelSetting
from app.models.system_setting import SystemSetting from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret from app.models.system_setting_secret import SystemSettingSecret
@@ -30,6 +31,8 @@ def build_session(db_file: Path) -> Session:
SystemSetting.__table__.create(bind=engine) SystemSetting.__table__.create(bind=engine)
SystemSettingSecret.__table__.create(bind=engine) SystemSettingSecret.__table__.create(bind=engine)
SystemModelSetting.__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) session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory() return session_factory()
@@ -47,6 +50,9 @@ def test_settings_service_persists_non_secret_and_secret_fields(monkeypatch) ->
with build_session(temp_dir / "settings.db") as db: with build_session(temp_dir / "settings.db") as db:
service = SettingsService(db) service = SettingsService(db)
initial_snapshot = service.get_settings_snapshot() 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 = initial_snapshot.model_dump()
payload["companyForm"]["companyName"] = "YGSOFT" payload["companyForm"]["companyName"] = "YGSOFT"

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 "不能把第一列的数值直接套给后面的列名" 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 "禁止使用“已命中”“答案整理阶段”“稍后重试”" in messages[0]["content"]
assert "knowledge_evidence_blocks" in messages[0]["content"] assert "knowledge_evidence_blocks" in messages[0]["content"]
assert '"knowledge_answer_evidence": []' in messages[1]["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 answer.startswith("张三,您好。")
assert "## 结论" in answer
assert "## 依据" in answer
assert "## 说明" in answer
assert "我先根据当前制度依据给出可以确认的部分" in answer assert "我先根据当前制度依据给出可以确认的部分" in answer
assert "已命中" not in answer assert "已命中" not 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 answer == "测试回答"
assert captured["timeout_seconds"] == 5 assert captured["timeout_seconds"] == 30
assert captured["slot_timeouts"] == {"main": 3, "backup": 5} assert captured["slot_timeouts"] == {"main": 20, "backup": 30}
assert captured["max_attempts"] == 1 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 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() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:
ontology = SemanticOntologyService(db).parse( ontology = SemanticOntologyService(db).parse(
@@ -560,11 +565,14 @@ def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> Non
) )
) )
service = UserAgentService(db) service = UserAgentService(db)
monkeypatch.setattr( captured: dict[str, object] = {}
service,
"_generate_answer_with_model", def fake_generate_answer(payload, **kwargs):
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("model should not be called")), captured["payload"] = payload
) captured.update(kwargs)
return "## 结论\n\n员工应在费用发生后 30 日内提交报销申请。"
monkeypatch.setattr(service, "_generate_answer_with_model", fake_generate_answer)
response = service.respond( response = service.respond(
UserAgentRequest( UserAgentRequest(
@@ -593,10 +601,11 @@ def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> Non
) )
) )
assert response.answer.startswith("张三,您好。") assert captured["payload"].ontology.scenario == "knowledge"
assert "**结论**" in response.answer assert "费用报销制度" in captured["fallback_answer"]
assert "## 依据" in captured["fallback_answer"]
assert response.answer.startswith("## 结论")
assert "30 日内提交报销申请" in response.answer assert "30 日内提交报销申请" in response.answer
assert "## 依据" not 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 "| 项目 | 港澳台 | 其他地区 | 国外 |" in answer
assert "| 餐补 | 75 | 55 | 140 |" in answer assert "| 餐补 | 75 | 55 | 140 |" in answer
assert "餐补的标准为" 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: 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 answer is not None
assert "没有直接写出“北京”对应的地区档位或映射关系" in answer assert "没有直接写出“北京”对应的地区档位或映射关系" in answer
assert "**说明**" in answer assert "## 说明" in answer
assert "## 依据" not in answer assert "## 依据" in answer
def test_user_agent_fast_knowledge_answer_expands_lead_in_list_items() -> None: 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 answer is not None
assert "**结论**" in answer assert "## 结论" in answer
assert "登机牌、高速道路通行记录" in answer assert "登机牌、高速道路通行记录" in answer
assert "支付记录" in answer assert "支付记录" in answer
assert "出差审批邮件、短信、微信等" in answer assert "出差审批邮件、短信、微信等" in answer
assert "3" not in answer assert "3" not in answer
assert "## 依据" not in answer assert "## 依据" in answer
def test_user_agent_model_prompt_supports_contextual_personalization() -> None: def test_user_agent_model_prompt_supports_contextual_personalization() -> None:

View File

@@ -506,6 +506,29 @@
width: 100%; width: 100%;
} }
:global(.rail-tooltip-popper) {
max-width: 180px;
padding: 7px 10px !important;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22) !important;
border-radius: 4px !important;
background: rgba(255, 255, 255, 0.98) !important;
color: #1f2937 !important;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.14) !important;
font-size: 12px;
font-weight: 700;
line-height: 1.4;
letter-spacing: 0;
}
:global(.rail-tooltip-popper.el-popper.is-light) {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22) !important;
}
:global(.rail-tooltip-popper .el-popper__arrow::before) {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22) !important;
background: rgba(255, 255, 255, 0.98) !important;
}
@keyframes railUserMenuIn { @keyframes railUserMenuIn {
from { from {
opacity: 0; opacity: 0;

View File

@@ -270,6 +270,12 @@
font-size: 14px; font-size: 14px;
} }
.detail-alert-pill.neutral {
border-color: #d7e0ea;
background: #f8fafc;
color: #334155;
}
.detail-alert-pill.success { .detail-alert-pill.success {
border-color: var(--success-line); border-color: var(--success-line);
background: var(--success-soft); background: var(--success-soft);

View File

@@ -508,3 +508,17 @@
max-height: min(34dvh, 360px); max-height: min(34dvh, 360px);
} }
} }
.review-insight-title-copy {
display: flex;
align-items: center;
gap: 8px;
}
.review-insight-title-copy .title-icon {
font-size: 20px;
color: var(--theme-primary, #3a7ca5);
display: inline-flex;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,210 @@
:root {
--enterprise-detail-radius: 4px;
}
.approval-page.approval-page .approval-detail :is(
.detail-hero,
.progress-card,
.detail-card,
.detail-side-card,
.detail-note,
.detail-note-editor textarea,
.detail-expense-table,
.detail-attachment,
.risk-list,
.application-detail-fact,
.application-budget-analysis__state,
.application-budget-analysis__metrics article,
.application-budget-analysis__summary,
.application-budget-analysis__lists > div,
.application-leader-opinion,
.application-leader-opinion-event,
.draft-blocking-note,
.draft-blocking-issue,
.expense-file-chip,
.expense-editor-panel,
.expense-editor-grid input,
.expense-editor-grid select,
.expense-total-under-table,
.attachment-insight-pane,
.attachment-source-pane,
.attachment-preview-card,
.attachment-preview-nav,
.attachment-preview-close,
.attachment-preview-alert,
.attachment-preview-action,
.attachment-preview-empty,
.attachment-risk-card,
.attachment-insight-section,
.risk-summary-card,
.risk-detail-card,
.risk-advice-card,
.risk-advice-meta > div,
.risk-override-card,
.risk-override-nav-btn,
.system-row-lock,
.system-attachment-note,
.submit-confirm-summary,
.smart-entry-btn,
.icon-action,
.inline-action
) {
border-radius: var(--enterprise-detail-radius);
}
.approval-page.approval-page .ai-entry-modal :is(
.detail-modal,
.modal-card,
.close-btn,
.ai-chat-card,
.ai-preview-card,
.ai-chat-content,
.ai-composer,
.ai-composer-surface,
.ai-tool-btn,
.ai-upload-btn,
.ai-send-btn,
.preview-field,
.preview-empty,
.ai-preview-secondary,
.ai-preview-primary,
.modal-action
) {
border-radius: var(--enterprise-detail-radius);
}
.approval-page.approval-page :is(
.approval-opinion-field textarea,
.return-reason-option,
.return-reason-section textarea
) {
border-radius: var(--enterprise-detail-radius);
}
.log-detail-page.log-detail-page :is(
.detail-state,
.detail-state button,
.detail-hero,
.refresh-btn,
.detail-card,
.info-grid > div,
.feedback-grid > div,
.trace-step,
.code-block,
.inline-empty,
.detail-actions button,
.knowledge-ingest-panel,
.ingest-run-info,
.info-item,
.run-info-card,
.run-stat-card,
.graph-search,
.graph-toolbar button,
.graph-theater,
.graph-stage,
.graph-toolbar,
.graph-inspector,
.node-facts > div,
.node-meta,
.node-detail-panel,
.detail-section,
.evidence-document,
.evidence-chunk,
.evidence-empty,
.node-evidence-card,
.relation-detail-list button,
.detail-empty
) {
border-radius: var(--enterprise-detail-radius);
}
.skill-detail.skill-detail :is(
.detail-hero,
.detail-inline-state,
.detail-loading-state,
.review-note-block,
.hero-stat,
.detail-card,
.side-card,
.field input,
.field textarea,
.prompt-block textarea,
.json-editor,
.markdown-editor,
.spreadsheet-editor-shell,
.spreadsheet-workbench,
.spreadsheet-change-center,
.version-pair-card,
.change-center-item,
.change-record-preview,
.spreadsheet-meta-strip span,
.json-risk-editor-shell,
.json-risk-generation-failure,
.json-risk-meta-item,
.json-risk-description-text,
.json-risk-description-source,
.json-risk-flow-card,
.diagram-zoom-controls,
.rule-spreadsheet-stage,
.compare-panel,
.compare-summary-grid article,
.compare-sheet-list article,
.change-detail-meta article,
.compare-sheet-list span,
.compare-table-wrap,
.subtle-banner,
.preview-mode-note,
.prompt-block,
.contract-panel,
.version-row,
.version-modal-summary div,
.version-modal-note,
.review-submit-test-state,
.risk-rule-action-confirm,
.risk-rule-action-note,
.risk-rule-action-note textarea,
.review-submit-form input:not([type='checkbox']),
.review-submit-form select,
.review-submit-form textarea,
.review-submit-hint,
.publish-summary,
.empty-side-note,
.back-action,
.minor-action,
.major-action,
.mini-btn,
.risk-level-menu,
.risk-level-option
) {
border-radius: var(--enterprise-detail-radius);
}
.skill-detail.skill-detail .rule-drawer-backdrop :is(
.rule-drawer,
.rule-drawer-state,
.change-detail-meta article,
.compare-panel,
.compare-sheet-list span,
.compare-table-wrap
) {
border-radius: var(--enterprise-detail-radius);
}
.employee-center.employee-center .employee-detail :is(
.detail-hero,
.hero-profile,
.hero-tag,
.hero-stat,
.detail-card,
.side-card,
.history-row,
.field input,
.field textarea,
.role-option,
.sync-card,
.permission-pill,
.detail-actions button,
.detail-action-group
) {
border-radius: var(--enterprise-detail-radius);
}

View File

@@ -420,7 +420,7 @@
table { table {
width: 100%; width: 100%;
min-width: 1320px; min-width: 1420px;
border-collapse: collapse; border-collapse: collapse;
table-layout: fixed; table-layout: fixed;
} }
@@ -429,8 +429,9 @@ table {
.col-created { width: 10%; } .col-created { width: 10%; }
.col-stay { width: 9%; } .col-stay { width: 9%; }
.col-doc-type { width: 9%; } .col-doc-type { width: 9%; }
.col-scene { width: 10%; } .col-scene { width: 9%; }
.col-title { width: 18%; } .col-initiator { width: 8%; }
.col-title { width: 16%; }
.col-amount { width: 9%; } .col-amount { width: 9%; }
.col-node { width: 12%; } .col-node { width: 12%; }
.col-status { width: 8%; } .col-status { width: 8%; }

View File

@@ -85,6 +85,7 @@
} }
.console-toolbar { .console-toolbar {
--logs-filter-control-height: 38px;
display: grid; display: grid;
grid-template-columns: minmax(220px, 1.35fr) repeat(2, minmax(138px, 0.7fr)) auto; grid-template-columns: minmax(220px, 1.35fr) repeat(2, minmax(138px, 0.7fr)) auto;
gap: 10px; gap: 10px;
@@ -103,9 +104,9 @@
} }
.field-input { .field-input {
min-height: 38px; min-height: var(--logs-filter-control-height);
border: 1px solid #d8e1eb; border: 1px solid #d8e1eb;
border-radius: 8px; border-radius: 4px;
background: #fff; background: #fff;
} }
@@ -130,10 +131,38 @@
outline: none; outline: none;
} }
.console-toolbar :deep(.enterprise-select) {
width: 100%;
}
.console-toolbar :deep(.el-select__wrapper) {
min-height: var(--logs-filter-control-height);
height: var(--logs-filter-control-height);
padding: 0 12px;
border-radius: 4px;
box-shadow: 0 0 0 1px #d8e1eb inset;
}
.console-toolbar :deep(.el-select__wrapper:hover) {
box-shadow: 0 0 0 1px #b8c2d2 inset;
}
.console-toolbar :deep(.el-select__wrapper.is-focused) {
box-shadow:
0 0 0 1px var(--theme-primary) inset,
0 0 0 3px var(--theme-focus-ring);
}
.console-toolbar :deep(.el-select__placeholder),
.console-toolbar :deep(.el-select__selected-item) {
font-size: 13px;
line-height: var(--logs-filter-control-height);
}
.toolbar-btn { .toolbar-btn {
min-height: 38px; min-height: var(--logs-filter-control-height);
padding: 0 14px; padding: 0 14px;
border-radius: 8px; border-radius: 4px;
border: 1px solid #d8e1eb; border: 1px solid #d8e1eb;
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;

View File

@@ -38,7 +38,7 @@
.panel-title { .panel-title {
display: flex; display: flex;
align-items: flex-start; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
} }
@@ -90,6 +90,14 @@
outline: none; outline: none;
} }
.panel-tools {
min-width: min(470px, 100%);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.library-body { .library-body {
min-height: 0; min-height: 0;
display: grid; display: grid;
@@ -101,7 +109,7 @@
.folder-rail { .folder-rail {
min-height: 0; min-height: 0;
display: grid; display: grid;
grid-template-rows: minmax(0, 1fr) auto; grid-template-rows: minmax(0, 1fr);
gap: 12px; gap: 12px;
border-right: 1px solid #edf2f7; border-right: 1px solid #edf2f7;
padding-right: 12px; padding-right: 12px;
@@ -148,45 +156,38 @@
font-size: 11px; font-size: 11px;
} }
.folder-sync-block { .knowledge-sync-btn {
display: grid;
gap: 8px;
}
.new-folder-btn {
min-height: 36px; min-height: 36px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: 7px;
border: 1px solid rgba(var(--theme-primary-rgb), .28); padding: 0 14px;
border: 1px solid #1d4ed8;
border-radius: 8px; border-radius: 8px;
background: var(--theme-primary-light-9); background: #2563eb;
color: var(--theme-primary-active); color: #fff;
font-size: 13px; font-size: 13px;
font-weight: 850; font-weight: 850;
} white-space: nowrap;
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.18);
.new-folder-btn.fixed {
border-color: rgba(148, 163, 184, 0.3);
background: #f8fafc;
color: #64748b;
}
.knowledge-sync-btn:not(:disabled) {
cursor: pointer; cursor: pointer;
transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
} }
.knowledge-sync-btn:not(:disabled):hover { .knowledge-sync-btn:hover:not(:disabled) {
border-color: rgba(var(--theme-primary-rgb), 0.38); border-color: #1e40af;
background: var(--theme-primary-light-9); background: #1d4ed8;
color: var(--theme-primary-active); box-shadow: 0 10px 22px rgba(37, 99, 235, 0.24);
transform: translateY(-1px);
} }
.folder-sync-meta { .knowledge-sync-btn:disabled {
cursor: not-allowed;
border-color: #cbd5e1;
background: #e2e8f0;
color: #64748b; color: #64748b;
font-size: 12px; box-shadow: none;
line-height: 1.6;
} }
.document-area { .document-area {
@@ -1179,6 +1180,12 @@ th {
align-items: stretch; align-items: stretch;
} }
.panel-tools,
.file-search,
.knowledge-sync-btn {
width: 100%;
}
.summary-grid, .summary-grid,
.list-foot { .list-foot {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -99,7 +99,8 @@
font-style: normal; font-style: normal;
} }
.field input { .field input,
.field :deep(.el-select__wrapper) {
width: 100%; width: 100%;
min-height: 44px; min-height: 44px;
padding: 0 14px; padding: 0 14px;
@@ -116,7 +117,8 @@
color: #94a3b8; color: #94a3b8;
} }
.field input:focus { .field input:focus,
.field :deep(.el-select__wrapper.is-focused) {
outline: none; outline: none;
border-color: var(--theme-primary); border-color: var(--theme-primary);
box-shadow: 0 0 0 3px var(--theme-focus-ring); box-shadow: 0 0 0 3px var(--theme-focus-ring);

View File

@@ -9,33 +9,52 @@
</svg> </svg>
</div> </div>
<strong class="brand-name">{{ displayCompanyName }}</strong> <strong class="brand-name">{{ displayCompanyName }}</strong>
<button <ElTooltip
class="rail-collapse-btn" :content="collapseTooltipContent"
type="button" placement="right"
:aria-label="collapsed ? '展开侧边栏' : '折叠侧边栏'" effect="light"
:title="collapsed ? '展开侧边栏' : '折叠侧边栏'" :show-after="180"
:aria-expanded="!collapsed" :hide-after="0"
@click="emit('toggle-collapse')" :offset="12"
popper-class="rail-tooltip-popper"
> >
<i :class="collapsed ? 'mdi mdi-chevron-right' : 'mdi mdi-chevron-left'"></i> <button
</button> class="rail-collapse-btn"
type="button"
:aria-label="collapseTooltipContent"
:aria-expanded="!collapsed"
@click="emit('toggle-collapse')"
>
<i :class="collapsed ? 'mdi mdi-chevron-right' : 'mdi mdi-chevron-left'"></i>
</button>
</ElTooltip>
</div> </div>
<nav class="rail-nav" aria-label="功能导航"> <nav class="rail-nav" aria-label="功能导航">
<button <ElTooltip
v-for="item in decoratedNavItems" v-for="item in decoratedNavItems"
:key="item.id" :key="item.id"
class="nav-btn" :content="item.displayLabel"
:class="{ active: activeView === item.id }" placement="right"
type="button" effect="light"
:title="collapsed ? item.displayLabel : undefined" :disabled="!collapsed"
@click="emit('navigate', item.id)" :show-after="180"
:hide-after="0"
:offset="12"
popper-class="rail-tooltip-popper"
> >
<span class="nav-icon" v-html="item.icon"></span> <button
<span class="nav-label">{{ item.displayLabel }}</span> class="nav-btn"
<span v-if="item.hasNewMessage" class="nav-unread-dot" aria-hidden="true"></span> :class="{ active: activeView === item.id }"
<span v-if="item.badge" class="nav-badge">{{ item.badge }}</span> type="button"
</button> @click="emit('navigate', item.id)"
>
<span class="nav-icon" v-html="item.icon"></span>
<span class="nav-label">{{ item.displayLabel }}</span>
<span v-if="item.hasNewMessage" class="nav-unread-dot" aria-hidden="true"></span>
<span v-if="item.badge" class="nav-badge">{{ item.badge }}</span>
</button>
</ElTooltip>
</nav> </nav>
<div <div
@@ -69,19 +88,31 @@
</div> </div>
</Teleport> </Teleport>
<div class="user-summary" tabindex="0" aria-label="用户信息" :title="collapsed ? displayUser.name : undefined"> <ElTooltip
<span class="user-avatar">{{ displayUser.avatar }}</span> :content="userTooltipContent"
<span class="user-copy"> placement="top"
<strong>{{ displayUser.name }}</strong> effect="light"
<span>{{ displayUser.role }}</span> :disabled="!collapsed"
</span> :show-after="180"
<i class="mdi mdi-chevron-up"></i> :hide-after="0"
</div> :offset="10"
popper-class="rail-tooltip-popper"
>
<div class="user-summary" tabindex="0" :aria-label="userTooltipContent">
<span class="user-avatar">{{ displayUser.avatar }}</span>
<span class="user-copy">
<strong>{{ displayUser.name }}</strong>
<span>{{ displayUser.role }}</span>
</span>
<i class="mdi mdi-chevron-up"></i>
</div>
</ElTooltip>
</div> </div>
</aside> </aside>
</template> </template>
<script setup> <script setup>
import { ElTooltip } from 'element-plus'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js' import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
@@ -154,6 +185,8 @@ const displayUser = computed(() => ({
})) }))
const displayCompanyName = computed(() => props.companyName || 'X-Financial') const displayCompanyName = computed(() => props.companyName || 'X-Financial')
const collapseTooltipContent = computed(() => (props.collapsed ? '展开侧边栏' : '折叠侧边栏'))
const userTooltipContent = computed(() => [displayUser.value.name, displayUser.value.role].filter(Boolean).join(' · '))
const userMenuOpen = ref(false) const userMenuOpen = ref(false)
let userMenuCloseTimer = null let userMenuCloseTimer = null

View File

@@ -91,7 +91,7 @@
class="detail-alert-pill" class="detail-alert-pill"
:class="alert.tone" :class="alert.tone"
> >
<i class="mdi mdi-alert-circle-outline"></i> <i :class="alert.icon || 'mdi mdi-alert-circle-outline'"></i>
<span>{{ alert.label }}</span> <span>{{ alert.label }}</span>
</span> </span>
</div> </div>

View File

@@ -22,14 +22,15 @@
<label <label
v-for="option in options" v-for="option in options"
:key="option.code" :key="option.code"
:class="['return-reason-option', { active: selectedCodes.includes(option.code) }]" :class="['return-reason-option', { active: isOptionActive(option.code) }]"
> >
<input <input
v-model="selectedCodes" :type="application ? 'radio' : 'checkbox'"
type="checkbox" :name="application ? 'application-return-reason' : undefined"
:checked="isOptionActive(option.code)"
:value="option.code" :value="option.code"
:disabled="busy" :disabled="busy"
@change="handleOptionChange" @change="handleOptionChange(option)"
/> />
<i :class="option.icon"></i> <i :class="option.icon"></i>
<strong>{{ option.label }}</strong> <strong>{{ option.label }}</strong>
@@ -99,6 +100,12 @@ const APPLICATION_RETURN_REASON_OPTIONS = [
label: '前置材料需补充', label: '前置材料需补充',
icon: 'mdi mdi-file-document-plus-outline', icon: 'mdi mdi-file-document-plus-outline',
defaultReason: '请补充会议通知、客户邀约、项目安排或其他能支撑申请必要性的材料。' defaultReason: '请补充会议通知、客户邀约、项目安排或其他能支撑申请必要性的材料。'
},
{
code: 'application_other',
label: '其他',
icon: 'mdi mdi-pencil-box-outline',
defaultReason: ''
} }
] ]
@@ -117,12 +124,18 @@ const props = defineProps({
const emit = defineEmits(['close', 'confirm']) const emit = defineEmits(['close', 'confirm'])
const selectedCodes = ref([]) const selectedCodes = ref([])
const selectedApplicationCode = ref('')
const reasonText = ref('') const reasonText = ref('')
const touched = ref(false) const touched = ref(false)
const selectionTouched = ref(false) const selectionTouched = ref(false)
const lastAutoReason = ref('') const lastAutoReason = ref('')
const options = computed(() => (props.application ? APPLICATION_RETURN_REASON_OPTIONS : CLAIM_RETURN_REASON_OPTIONS)) const options = computed(() => (props.application ? APPLICATION_RETURN_REASON_OPTIONS : CLAIM_RETURN_REASON_OPTIONS))
const selectedReasonCodes = computed(() => (
props.application
? (selectedApplicationCode.value ? [selectedApplicationCode.value] : [])
: selectedCodes.value
))
const dialogBadge = computed(() => (props.application ? '退回申请' : '退回单据')) const dialogBadge = computed(() => (props.application ? '退回申请' : '退回单据'))
const optionsTitle = computed(() => (props.application ? '退单选项' : '默认风险点')) const optionsTitle = computed(() => (props.application ? '退单选项' : '默认风险点'))
const optionsAriaLabel = computed(() => (props.application ? '申请退单选项' : '默认退回风险点')) const optionsAriaLabel = computed(() => (props.application ? '申请退单选项' : '默认退回风险点'))
@@ -133,10 +146,10 @@ const reasonPlaceholder = computed(() => (
)) ))
const trimmedReason = computed(() => reasonText.value.trim()) const trimmedReason = computed(() => reasonText.value.trim())
const selectionError = computed(() => { const selectionError = computed(() => {
if (!props.application || !selectionTouched.value || selectedCodes.value.length > 0) { if (!props.application || !selectionTouched.value || selectedReasonCodes.value.length > 0) {
return '' return ''
} }
return '请选择至少一个退单选项,便于后续看板统计。' return '请选择一个退单选项,便于后续看板统计。'
}) })
const reasonError = computed(() => { const reasonError = computed(() => {
if (!touched.value || trimmedReason.value.length >= 6) { if (!touched.value || trimmedReason.value.length >= 6) {
@@ -159,6 +172,7 @@ watch(
(open) => { (open) => {
if (open) { if (open) {
selectedCodes.value = [] selectedCodes.value = []
selectedApplicationCode.value = ''
reasonText.value = '' reasonText.value = ''
touched.value = false touched.value = false
selectionTouched.value = false selectionTouched.value = false
@@ -167,25 +181,35 @@ watch(
} }
) )
watch(selectedCodes, () => { function syncApplicationDefaultReason(option) {
if (!props.application) { const defaultReason = String(option?.defaultReason || '').trim()
return
}
const defaultReason = selectedCodes.value
.map((code) => options.value.find((option) => option.code === code)?.defaultReason || '')
.filter(Boolean)
.join('\n')
const canAutoFill = !touched.value || !reasonText.value.trim() || reasonText.value === lastAutoReason.value const canAutoFill = !touched.value || !reasonText.value.trim() || reasonText.value === lastAutoReason.value
if (canAutoFill) { if (canAutoFill) {
reasonText.value = defaultReason reasonText.value = defaultReason
} }
lastAutoReason.value = defaultReason lastAutoReason.value = defaultReason
}) }
function handleOptionChange() { function isOptionActive(code) {
return props.application ? selectedApplicationCode.value === code : selectedCodes.value.includes(code)
}
function handleOptionChange(option) {
selectionTouched.value = true selectionTouched.value = true
if (props.application) {
selectedApplicationCode.value = option.code
syncApplicationDefaultReason(option)
return
}
const selected = new Set(selectedCodes.value)
if (selected.has(option.code)) {
selected.delete(option.code)
} else {
selected.add(option.code)
}
selectedCodes.value = Array.from(selected)
} }
function handleClose() { function handleClose() {
@@ -197,13 +221,13 @@ function handleClose() {
function handleConfirm() { function handleConfirm() {
touched.value = true touched.value = true
selectionTouched.value = true selectionTouched.value = true
if ((props.application && selectedCodes.value.length === 0) || trimmedReason.value.length < 6 || props.busy) { if ((props.application && selectedReasonCodes.value.length === 0) || trimmedReason.value.length < 6 || props.busy) {
return return
} }
emit('confirm', { emit('confirm', {
reason: trimmedReason.value, reason: trimmedReason.value,
reason_codes: [...selectedCodes.value] reason_codes: [...selectedReasonCodes.value]
}) })
} }
</script> </script>

View File

@@ -16,6 +16,7 @@
</div> </div>
<div v-else class="review-insight-title-row"> <div v-else class="review-insight-title-row">
<div class="review-insight-title-copy"> <div class="review-insight-title-copy">
<i v-if="!ui.activeReviewPayload && ui.isReviewFlowDrawer" :class="ui.reviewFlowDrawerIcon" class="title-icon"></i>
<h3>{{ ui.reviewDrawerTitle }}</h3> <h3>{{ ui.reviewDrawerTitle }}</h3>
</div> </div>
</div> </div>
@@ -23,7 +24,7 @@
<p v-if="!ui.activeReviewPayload && !ui.isReviewFlowDrawer">{{ ui.currentInsight.summary }}</p> <p v-if="!ui.activeReviewPayload && !ui.isReviewFlowDrawer">{{ ui.currentInsight.summary }}</p>
</div> </div>
<div v-if="ui.activeReviewPayload || ui.isReviewFlowDrawer" class="review-insight-tools"> <div v-if="ui.activeReviewPayload" class="review-insight-tools">
<button <button
v-if="ui.activeReviewPayload && ui.reviewOverviewDrawerAvailable" v-if="ui.activeReviewPayload && ui.reviewOverviewDrawerAvailable"
type="button" type="button"

View File

@@ -14,21 +14,6 @@
@close="emit('close')" @close="emit('close')"
@confirm="emit('confirm')" @confirm="emit('confirm')"
> >
<div class="submit-confirm-summary" aria-label="领导审批通过摘要">
<div class="submit-confirm-row">
<span>单据编号</span>
<strong>{{ documentNo }}</strong>
</div>
<div class="submit-confirm-row">
<span>当前节点</span>
<strong>{{ node }}</strong>
</div>
<div class="submit-confirm-row">
<span>{{ summaryLabel }}</span>
<strong>{{ nextStage }}</strong>
</div>
</div>
<label class="approval-opinion-field"> <label class="approval-opinion-field">
<span> <span>
{{ opinionTitle }} {{ opinionTitle }}
@@ -64,10 +49,6 @@ const props = defineProps({
confirmText: { type: String, required: true }, confirmText: { type: String, required: true },
busyText: { type: String, required: true }, busyText: { type: String, required: true },
busy: { type: Boolean, required: true }, busy: { type: Boolean, required: true },
documentNo: { type: [String, Number], required: true },
node: { type: String, default: '' },
summaryLabel: { type: String, required: true },
nextStage: { type: String, required: true },
opinionTitle: { type: String, required: true }, opinionTitle: { type: String, required: true },
opinion: { type: String, default: '' }, opinion: { type: String, default: '' },
opinionPlaceholder: { type: String, default: '' }, opinionPlaceholder: { type: String, default: '' },

View File

@@ -0,0 +1,293 @@
<template>
<section class="application-budget-analysis" aria-label="预算分析">
<div class="application-budget-analysis__head">
<span><i class="mdi mdi-chart-donut"></i>预算分析</span>
<strong v-if="analysis && !loading">{{ scoreLabel }}</strong>
</div>
<div v-if="loading" class="application-budget-analysis__state">正在读取预算管控模型...</div>
<div v-else-if="error" class="application-budget-analysis__state danger">{{ error }}</div>
<div v-else-if="analysis" class="application-budget-analysis__body">
<div class="application-budget-analysis__metrics">
<article v-for="metric in metrics" :key="metric.key">
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
</article>
</div>
<div class="application-budget-analysis__summary">
<div :class="['application-budget-score', analysis.risk_level || 'low']">
<span>{{ analysis.score }}</span>
<em>综合评分</em>
</div>
<p>{{ analysis.summary }}</p>
</div>
<div class="application-budget-analysis__lists">
<div>
<span>规则依据</span>
<ul>
<li v-for="item in analysis.basis" :key="item">{{ item }}</li>
</ul>
</div>
<div>
<span>模型建议</span>
<ul>
<li v-for="item in analysis.suggestions" :key="item">{{ item }}</li>
</ul>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { fetchExpenseClaimBudgetAnalysis } from '../../services/reimbursements.js'
const props = defineProps({
claimId: { type: String, default: '' }
})
const analysis = ref(null)
const loading = ref(false)
const error = ref('')
const scoreLabel = computed(() => {
const labels = {
recommended: '建议通过',
caution: '谨慎通过',
review: '需要复核',
block: '不建议直接通过',
reference: '参考口径'
}
return labels[String(analysis.value?.rating || '').trim()] || '模型建议'
})
const metrics = computed(() => {
const metric = analysis.value?.metrics || {}
return [
{ key: 'total', label: '当前预算额度', value: formatMoney(metric.total_amount) },
{ key: 'ratio', label: '此次费用占预算', value: formatPercent(metric.claim_amount_ratio) },
{ key: 'after', label: '审批后使用率', value: formatPercent(metric.after_usage_rate) },
{ key: 'available', label: '当前可用预算', value: formatMoney(metric.available_amount) }
]
})
watch(
() => props.claimId,
(claimId) => {
loadAnalysis(claimId)
},
{ immediate: true }
)
async function loadAnalysis(claimId) {
const normalizedClaimId = String(claimId || '').trim()
analysis.value = null
error.value = ''
if (!normalizedClaimId) {
return
}
loading.value = true
try {
analysis.value = await fetchExpenseClaimBudgetAnalysis(normalizedClaimId)
} catch (err) {
error.value = err?.message || '预算分析加载失败,请稍后重试。'
} finally {
loading.value = false
}
}
function formatMoney(value) {
const amount = Number(value)
if (!Number.isFinite(amount)) {
return '待匹配'
}
return `${amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元`
}
function formatPercent(value) {
const amount = Number(value)
if (!Number.isFinite(amount)) {
return '0.00%'
}
return `${amount.toFixed(2)}%`
}
</script>
<style scoped>
.application-budget-analysis {
display: grid;
gap: 14px;
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid #e2e8f0;
}
.application-budget-analysis__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.application-budget-analysis__head span {
display: inline-flex;
align-items: center;
gap: 8px;
color: #0f172a;
font-size: 15px;
font-weight: 850;
}
.application-budget-analysis__head i {
color: var(--theme-primary-active);
font-size: 18px;
}
.application-budget-analysis__head strong {
border-radius: 999px;
padding: 4px 10px;
background: rgba(var(--theme-primary-rgb), .1);
color: var(--theme-primary-active);
font-size: 12px;
}
.application-budget-analysis__state {
border-radius: 8px;
padding: 10px 12px;
background: #f8fafc;
color: #64748b;
font-size: 13px;
}
.application-budget-analysis__state.danger {
background: #fff1f2;
color: #be123c;
}
.application-budget-analysis__body {
display: grid;
gap: 14px;
}
.application-budget-analysis__metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.application-budget-analysis__metrics article {
min-width: 0;
border-radius: 8px;
padding: 10px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.application-budget-analysis__metrics span,
.application-budget-analysis__lists span {
display: block;
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.application-budget-analysis__metrics strong {
display: block;
margin-top: 5px;
color: #0f172a;
font-size: 15px;
font-weight: 850;
}
.application-budget-analysis__summary {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
align-items: center;
border-radius: 8px;
padding: 12px;
background: #fff;
border: 1px solid #e2e8f0;
}
.application-budget-analysis__summary p {
margin: 0;
color: #334155;
font-size: 13px;
line-height: 1.7;
}
.application-budget-score {
display: grid;
place-items: center;
width: 72px;
height: 72px;
border-radius: 50%;
background: #eef6ff;
color: #2563eb;
}
.application-budget-score.medium {
background: #fff7ed;
color: #c2410c;
}
.application-budget-score.high {
background: #fff1f2;
color: #be123c;
}
.application-budget-score span {
font-size: 22px;
font-weight: 900;
line-height: 1;
}
.application-budget-score em {
font-style: normal;
font-size: 11px;
font-weight: 800;
}
.application-budget-analysis__lists {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.application-budget-analysis__lists > div {
border-radius: 8px;
padding: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.application-budget-analysis__lists ul {
margin: 8px 0 0;
padding-left: 18px;
color: #334155;
font-size: 12px;
line-height: 1.7;
}
@media (max-width: 900px) {
.application-budget-analysis__metrics,
.application-budget-analysis__lists {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 640px) {
.application-budget-analysis__metrics,
.application-budget-analysis__lists,
.application-budget-analysis__summary {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -57,6 +57,7 @@ const REIMBURSEMENT_PROGRESS_LABELS = [
const APPLICATION_PROGRESS_LABELS = [ const APPLICATION_PROGRESS_LABELS = [
'创建申请', '创建申请',
'直属领导审批', '直属领导审批',
'预算管理者审批',
'审批完成' '审批完成'
] ]
@@ -386,10 +387,13 @@ function resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode) {
const normalizedNode = String(workflowNode || '').trim() const normalizedNode = String(workflowNode || '').trim()
if (approvalMeta.key === 'completed') { if (approvalMeta.key === 'completed') {
return 2 return 3
} }
if (normalizedNode.includes('审批完成') || normalizedNode.includes('申请完成')) { if (normalizedNode.includes('审批完成') || normalizedNode.includes('申请完成')) {
return 3
}
if (normalizedNode.includes('预算')) {
return 2 return 2
} }
if ( if (
@@ -437,15 +441,42 @@ function resolveApplicationApproverName(claim) {
) || '直属领导' ) || '直属领导'
} }
function resolveApplicationBudgetApproverName(claim) {
const routeEvent = findApprovalEventForStep(claim, '直属领导审批')
return resolveDisplayName(
routeEvent?.next_approver_name,
routeEvent?.nextApproverName,
routeEvent?.budget_approver_name,
routeEvent?.budgetApproverName
) || 'P8预算监控者'
}
function resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta) { function resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta) {
const normalizedLabel = normalizeText(label)
const workflowNode = normalizeText(claim?.approval_stage || claim?.workflowNode)
if ( if (
documentTypeCode === DOCUMENT_TYPE_APPLICATION documentTypeCode === DOCUMENT_TYPE_APPLICATION
&& approvalMeta.key !== 'completed' && approvalMeta.key !== 'completed'
&& normalizeText(label) === '直属领导审批' && normalizedLabel === '直属领导审批'
&& (
workflowNode.includes('直属领导')
|| workflowNode.includes('领导审批')
|| workflowNode.includes('部门负责人')
|| workflowNode.includes('负责人审批')
)
) { ) {
return `等待 ${resolveApplicationApproverName(claim)} 批复` return `等待 ${resolveApplicationApproverName(claim)} 批复`
} }
if (
documentTypeCode === DOCUMENT_TYPE_APPLICATION
&& approvalMeta.key !== 'completed'
&& normalizedLabel === '预算管理者审批'
&& workflowNode.includes('预算')
) {
return `等待 ${resolveApplicationBudgetApproverName(claim)} 批复`
}
return label return label
} }
@@ -471,7 +502,7 @@ function findApprovalEventForStep(claim, label) {
} }
const source = normalizeText(flag.source) const source = normalizeText(flag.source)
if (!['manual_approval', 'finance_approval'].includes(source)) { if (!['manual_approval', 'budget_approval', 'finance_approval'].includes(source)) {
return false return false
} }
@@ -482,10 +513,19 @@ function findApprovalEventForStep(claim, label) {
return ( return (
previousStage.includes('直属领导') previousStage.includes('直属领导')
|| previousStage.includes('领导审批') || previousStage.includes('领导审批')
|| nextStage.includes('预算')
|| nextStage.includes('财务') || nextStage.includes('财务')
) )
} }
if (stepLabel === '预算管理者审批') {
return (
source === 'budget_approval'
|| previousStage.includes('预算')
|| nextStage.includes('审批完成')
)
}
if (stepLabel === '财务审批') { if (stepLabel === '财务审批') {
return ( return (
previousStage.includes('财务') previousStage.includes('财务')
@@ -557,15 +597,16 @@ function buildCompletedStepMeta(claim, label) {
return buildProgressStepMeta('AI预审通过', reviewedAt) return buildProgressStepMeta('AI预审通过', reviewedAt)
} }
if (stepLabel === '直属领导审批' || stepLabel === '财务审批') { if (stepLabel === '直属领导审批' || stepLabel === '预算管理者审批' || stepLabel === '财务审批') {
const approvalEvent = findApprovalEventForStep(claim, stepLabel) const approvalEvent = findApprovalEventForStep(claim, stepLabel)
if (approvalEvent) { if (approvalEvent) {
const operator = resolveDisplayName( const operator = resolveDisplayName(
approvalEvent.operator, approvalEvent.operator,
approvalEvent.operator_name, approvalEvent.operator_name,
approvalEvent.operatorName, approvalEvent.operatorName,
stepLabel === '直属领导审批' ? claim?.manager_name : '' stepLabel === '直属领导审批' ? claim?.manager_name : '',
) || (stepLabel === '财务审批' ? '财务' : '直属领导') stepLabel === '预算管理者审批' ? approvalEvent.next_approver_name : ''
) || (stepLabel === '财务审批' ? '财务' : stepLabel === '预算管理者审批' ? '预算监控者' : '直属领导')
const approvedAt = formatDateTime(approvalEvent.created_at || approvalEvent.createdAt) const approvedAt = formatDateTime(approvalEvent.created_at || approvalEvent.createdAt)
return buildProgressStepMeta(`${operator}通过`, approvedAt, `${operator}审批通过 ${approvedAt}`.trim()) return buildProgressStepMeta(`${operator}通过`, approvedAt, `${operator}审批通过 ${approvedAt}`.trim())
} }
@@ -626,6 +667,10 @@ function resolveCurrentStepStartedAt(claim, label) {
if (stepLabel === '直属领导审批') { if (stepLabel === '直属领导审批') {
return claim?.submitted_at || claim?.updated_at || claim?.created_at return claim?.submitted_at || claim?.updated_at || claim?.created_at
} }
if (stepLabel === '预算管理者审批') {
const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批')
return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at
}
if (stepLabel === '财务审批') { if (stepLabel === '财务审批') {
const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批') const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批')
return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at

View File

@@ -9,6 +9,7 @@ import router from './router/index.js'
import { installThemeSkin } from './composables/useThemeSkin.js' import { installThemeSkin } from './composables/useThemeSkin.js'
import { installSessionNavigation } from './composables/useSystemState.js' import { installSessionNavigation } from './composables/useSystemState.js'
import './assets/styles/element-plus-theme.css' import './assets/styles/element-plus-theme.css'
import './assets/styles/detail-page-corners.css'
const app = createApp(App) const app = createApp(App)

View File

@@ -16,6 +16,10 @@ export function fetchExpenseClaimDetail(claimId) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`) return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`)
} }
export function fetchExpenseClaimBudgetAnalysis(claimId) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/budget-analysis`)
}
export function updateExpenseClaim(claimId, payload = {}) { export function updateExpenseClaim(claimId, payload = {}) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`, { return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`, {
method: 'PATCH', method: 'PATCH',

View File

@@ -20,8 +20,9 @@ const VIEW_ROLE_RULES = {
settings: ['manager'] settings: ['manager']
} }
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive']) const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver']) const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver', 'budget_monitor'])
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver']) const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
const CLAIM_BUDGET_APPROVAL_GRADE = 'P8'
function normalizedRoleCodes(user) { function normalizedRoleCodes(user) {
if (!user) { if (!user) {
@@ -55,6 +56,25 @@ function identityIntersects(leftValues, rightValues) {
return leftValues.some((item) => rightSet.has(item)) return leftValues.some((item) => rightSet.has(item))
} }
function normalizedGrade(user) {
return String(user?.grade || user?.employeeGrade || '').trim().toUpperCase()
}
function departmentIntersects(request, user) {
const requestDepartments = collectIdentityNames(
request?.dept,
request?.departmentName,
request?.department_name
)
const currentDepartments = collectIdentityNames(
user?.department,
user?.departmentName,
user?.department_name
)
return requestDepartments.length > 0 && identityIntersects(requestDepartments, currentDepartments)
}
function hasPlatformAdminIdentity(user) { function hasPlatformAdminIdentity(user) {
if (!user) { if (!user) {
return false return false
@@ -130,6 +150,25 @@ export function canApproveLeaderExpenseClaims(user) {
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode)) return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
} }
export function canApproveBudgetExpenseApplications(user, request = null) {
if (isPlatformAdminUser(user)) {
return true
}
const roleCodes = normalizedRoleCodes(user)
if (roleCodes.includes('executive')) {
return true
}
if (!roleCodes.includes('budget_monitor')) {
return false
}
if (normalizedGrade(user) !== CLAIM_BUDGET_APPROVAL_GRADE) {
return false
}
return request ? departmentIntersects(request, user) : true
}
export function isCurrentRequestApplicant(request, user) { export function isCurrentRequestApplicant(request, user) {
const applicantNames = collectIdentityNames( const applicantNames = collectIdentityNames(
request?.person, request?.person,

View File

@@ -1,5 +1,6 @@
import { mapExpenseClaimToRequest } from '../composables/useRequests.js' import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import { import {
canApproveBudgetExpenseApplications,
canApproveLeaderExpenseClaims, canApproveLeaderExpenseClaims,
isCurrentDirectManagerForRequest, isCurrentDirectManagerForRequest,
isCurrentRequestApplicant, isCurrentRequestApplicant,
@@ -17,6 +18,10 @@ export function canProcessApprovalRequest(request, currentUser) {
return true return true
} }
if (node.includes('预算')) {
return canApproveBudgetExpenseApplications(currentUser, request)
}
const isLeaderApprovalNode = ( const isLeaderApprovalNode = (
node.includes('直属领导') node.includes('直属领导')
|| node.includes('领导审批') || node.includes('领导审批')

View File

@@ -115,10 +115,69 @@ export function hasPendingInfo(request) {
return false return false
} }
function resolveDetailAlertTone(request) { function getRiskFlags(request) {
if (request?.approvalKey === 'completed') return 'success' if (Array.isArray(request?.riskFlags)) {
if (request?.approvalKey === 'rejected') return 'danger' return request.riskFlags
return 'warning' }
if (Array.isArray(request?.risk_flags_json)) {
return request.risk_flags_json
}
return []
}
function parseNonNegativeInteger(value) {
const nextValue = Number(value)
return Number.isFinite(nextValue) && nextValue > 0 ? Math.floor(nextValue) : 0
}
function resolveSlaReminderCount(request) {
const directCount = [
request?.slaReminderCount,
request?.sla_reminder_count,
request?.slaUrgeCount,
request?.sla_urge_count,
request?.urgeCount,
request?.urge_count,
request?.reminderCount,
request?.reminder_count
].reduce((max, value) => Math.max(max, parseNonNegativeInteger(value)), 0)
if (directCount > 0) {
return directCount
}
return getRiskFlags(request).reduce((count, flag) => {
if (!flag || typeof flag !== 'object') {
return count
}
const explicitCount = [
flag.slaReminderCount,
flag.sla_reminder_count,
flag.slaUrgeCount,
flag.sla_urge_count,
flag.urgeCount,
flag.urge_count,
flag.reminderCount,
flag.reminder_count
].reduce((max, value) => Math.max(max, parseNonNegativeInteger(value)), 0)
if (explicitCount > 0) {
return count + explicitCount
}
const signal = [
flag.source,
flag.event_type,
flag.eventType,
flag.action,
flag.type,
flag.label,
flag.message
].join(' ')
return /sla|remind|reminder|urge|催单/i.test(signal) ? count + 1 : count
}, 0)
} }
export function buildDetailAlerts(request) { export function buildDetailAlerts(request) {
@@ -127,11 +186,13 @@ export function buildDetailAlerts(request) {
} }
const alerts = [] const alerts = []
const nodeLabel = String(request.node || request.approval || '').trim() const slaReminderCount = resolveSlaReminderCount(request)
if (nodeLabel) { alerts.push({
alerts.push({ label: nodeLabel, tone: resolveDetailAlertTone(request) }) label: `SLA 催单次数 ${slaReminderCount}`,
} tone: slaReminderCount > 0 ? 'warning' : 'neutral',
icon: 'mdi mdi-bell-ring-outline'
})
if (hasMissingAttachment(request)) { if (hasMissingAttachment(request)) {
alerts.push({ label: '缺少票据', tone: 'warning' }) alerts.push({ label: '缺少票据', tone: 'warning' })

View File

@@ -171,6 +171,7 @@
<col v-if="showStayTimeColumn" class="col-stay"> <col v-if="showStayTimeColumn" class="col-stay">
<col class="col-doc-type"> <col class="col-doc-type">
<col class="col-scene"> <col class="col-scene">
<col class="col-initiator">
<col class="col-title"> <col class="col-title">
<col class="col-amount"> <col class="col-amount">
<col class="col-node"> <col class="col-node">
@@ -184,6 +185,7 @@
<th v-if="showStayTimeColumn">停留时间</th> <th v-if="showStayTimeColumn">停留时间</th>
<th>单据类型</th> <th>单据类型</th>
<th>费用场景</th> <th>费用场景</th>
<th>发起人</th>
<th>事项</th> <th>事项</th>
<th>金额</th> <th>金额</th>
<th>当前环节</th> <th>当前环节</th>
@@ -201,6 +203,7 @@
<td v-if="showStayTimeColumn">{{ row.stayTimeDisplay }}</td> <td v-if="showStayTimeColumn">{{ row.stayTimeDisplay }}</td>
<td><span class="doc-kind-tag" :class="row.documentTypeCode">{{ row.documentTypeLabel }}</span></td> <td><span class="doc-kind-tag" :class="row.documentTypeCode">{{ row.documentTypeLabel }}</span></td>
<td><span class="type-tag" :class="row.typeTone">{{ row.typeLabel }}</span></td> <td><span class="type-tag" :class="row.typeTone">{{ row.typeLabel }}</span></td>
<td>{{ row.initiatorName }}</td>
<td>{{ row.reason }}</td> <td>{{ row.reason }}</td>
<td>{{ row.amountDisplay }}</td> <td>{{ row.amountDisplay }}</td>
<td>{{ row.node }}</td> <td>{{ row.node }}</td>
@@ -437,6 +440,7 @@ const filteredRows = computed(() => {
row.documentNo, row.documentNo,
row.documentTypeLabel, row.documentTypeLabel,
row.typeLabel, row.typeLabel,
row.initiatorName,
row.reason, row.reason,
row.node, row.node,
row.statusLabel row.statusLabel
@@ -538,6 +542,16 @@ function buildDocumentRow(request, options = {}) {
const documentTypeLabel = const documentTypeLabel =
normalized.documentTypeLabel normalized.documentTypeLabel
|| (documentTypeCode === DOCUMENT_TYPE_APPLICATION ? '申请单' : '报销单') || (documentTypeCode === DOCUMENT_TYPE_APPLICATION ? '申请单' : '报销单')
const initiatorName = String(
normalized.person
|| normalized.employeeName
|| normalized.profileName
|| normalized.applicant
|| request?.employee_name
|| request?.employeeName
|| request?.person
|| ''
).trim() || '待补充'
return { return {
...normalized, ...normalized,
@@ -547,6 +561,7 @@ function buildDocumentRow(request, options = {}) {
documentTypeLabel, documentTypeLabel,
claimId, claimId,
documentNo, documentNo,
initiatorName,
node: archived ? resolveArchivedDocumentNode(normalized, documentTypeCode) : (normalized.node || normalized.workflowNode || '待提交'), node: archived ? resolveArchivedDocumentNode(normalized, documentTypeCode) : (normalized.node || normalized.workflowNode || '待提交'),
statusGroup, statusGroup,
statusLabel, statusLabel,

View File

@@ -31,7 +31,6 @@
v-model="systemLevelFilter" v-model="systemLevelFilter"
:options="systemLevelFilterOptions" :options="systemLevelFilterOptions"
placeholder="全部" placeholder="全部"
size="small"
/> />
</label> </label>
@@ -41,7 +40,6 @@
v-model="systemEventTypeFilter" v-model="systemEventTypeFilter"
:options="systemEventTypeFilterOptions" :options="systemEventTypeFilterOptions"
placeholder="全部" placeholder="全部"
size="small"
/> />
</label> </label>

View File

@@ -8,10 +8,21 @@
<h2>文档库 / 文件夹</h2> <h2>文档库 / 文件夹</h2>
<p>默认展示文件列表点击具体文件后以弹窗方式展开预览</p> <p>默认展示文件列表点击具体文件后以弹窗方式展开预览</p>
</div> </div>
<label class="file-search"> <div class="panel-tools">
<i class="mdi mdi-magnify"></i> <label class="file-search">
<input v-model="documentSearch" type="search" placeholder="搜索当前文件夹内文件" /> <i class="mdi mdi-magnify"></i>
</label> <input v-model="documentSearch" type="search" placeholder="搜索当前文件夹内文件" />
</label>
<button
class="knowledge-sync-btn"
type="button"
:disabled="!canTriggerKnowledgeSync"
@click="handleKnowledgeSync"
>
<i :class="syncingFolder ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-book-sync-outline'"></i>
<span>{{ knowledgeSyncButtonLabel }}</span>
</button>
</div>
</header> </header>
<div class="library-body"> <div class="library-body">
@@ -30,18 +41,6 @@
</button> </button>
</nav> </nav>
<div class="folder-sync-block">
<button
class="new-folder-btn fixed knowledge-sync-btn"
type="button"
:disabled="!canTriggerKnowledgeSync"
@click="handleKnowledgeSync"
>
<i :class="syncingFolder ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-book-sync-outline'"></i>
<span>{{ knowledgeSyncButtonLabel }}</span>
</button>
<p class="folder-sync-meta">{{ knowledgeSyncHint }}</p>
</div>
</aside> </aside>
<section class="document-area" :class="{ 'read-only': !isAdmin }"> <section class="document-area" :class="{ 'read-only': !isAdmin }">

View File

@@ -177,6 +177,11 @@
</div> </div>
</div> </div>
<TravelRequestBudgetAnalysis
v-if="showBudgetAnalysis"
:claim-id="request.claimId"
/>
<div v-if="showApplicationLeaderOpinion" class="application-leader-opinion"> <div v-if="showApplicationLeaderOpinion" class="application-leader-opinion">
<div class="application-leader-opinion-head"> <div class="application-leader-opinion-head">
<span><i class="mdi mdi-account-tie-outline"></i>领导意见</span> <span><i class="mdi mdi-account-tie-outline"></i>领导意见</span>
@@ -760,10 +765,6 @@
:confirm-text="approveConfirmText" :confirm-text="approveConfirmText"
:busy-text="approveBusyText" :busy-text="approveBusyText"
:busy="approveBusy" :busy="approveBusy"
:document-no="request.documentNo || request.id"
:node="request.node"
:summary-label="approvalConfirmSummaryLabel"
:next-stage="approvalNextStage"
:opinion-title="approvalOpinionTitle" :opinion-title="approvalOpinionTitle"
v-model:opinion="leaderOpinion" v-model:opinion="leaderOpinion"
:opinion-placeholder="approvalOpinionPlaceholder" :opinion-placeholder="approvalOpinionPlaceholder"

View File

@@ -372,7 +372,7 @@ function matchKeyword(employee, keyword) {
return true return true
} }
const haystack = [ const fields = [
employee.name, employee.name,
employee.employeeNo, employee.employeeNo,
employee.department, employee.department,
@@ -380,9 +380,13 @@ function matchKeyword(employee, keyword) {
employee.email, employee.email,
employee.manager, employee.manager,
employee.financeOwner, employee.financeOwner,
employee.syncState, employee.syncState
...(employee.roles || [])
] ]
const roles = Array.isArray(employee.roles) ? employee.roles : []
const haystack = [...fields, ...roles]
.map((val) => String(val || '').trim())
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
.toLowerCase() .toLowerCase()

View File

@@ -173,22 +173,6 @@ export default {
} }
return '知识归纳' return '知识归纳'
}) })
const knowledgeSyncHint = computed(() => {
const stats = activeFolderIngestStats.value
if (!activeFolder.value) {
return '请选择一个固定知识目录后再触发归纳。'
}
if (!stats.total) {
return '当前目录暂无文档,上传后即可进行知识归纳。'
}
if (stats.syncing > 0) {
return `当前目录有 ${stats.syncing} 份文档正在归纳,完成后会自动刷新状态。`
}
if (stats.pending > 0 || stats.failed > 0) {
return `当前目录待归纳 ${stats.pending} 份,需重试 ${stats.failed} 份。`
}
return `当前目录 ${stats.ingested} 份文档已归纳,可手动触发一次增量检查。`
})
const canTriggerKnowledgeSync = computed( const canTriggerKnowledgeSync = computed(
() => () =>
isAdmin.value isAdmin.value
@@ -448,7 +432,7 @@ export default {
const payload = await syncKnowledgeLibrary({ const payload = await syncKnowledgeLibrary({
folder: activeFolder.value, folder: activeFolder.value,
documentIds: [], documentIds: [],
force: false force: true
}) })
const queuedIds = Array.isArray(payload?.document_ids) ? payload.document_ids : [] const queuedIds = Array.isArray(payload?.document_ids) ? payload.document_ids : []
@@ -462,7 +446,8 @@ export default {
} }
await loadLibrary({ preserveSelection: true }) await loadLibrary({ preserveSelection: true })
toast(payload?.summary || '\u77e5\u8bc6\u5f52\u7eb3\u4efb\u52a1\u5df2\u63d0\u4ea4\u3002') const runHint = payload?.agent_run_id ? `日志编号:${payload.agent_run_id}` : ''
toast([payload?.summary || '知识归纳任务已提交。', runHint].filter(Boolean).join(' '))
} catch (error) { } catch (error) {
await loadLibrary({ preserveSelection: true }) await loadLibrary({ preserveSelection: true })
toast(error.message || '\u77e5\u8bc6\u5f52\u7eb3\u89e6\u53d1\u5931\u8d25\u3002') toast(error.message || '\u77e5\u8bc6\u5f52\u7eb3\u89e6\u53d1\u5931\u8d25\u3002')
@@ -648,7 +633,6 @@ export default {
handleKnowledgeSync, handleKnowledgeSync,
isAdmin, isAdmin,
knowledgeSyncButtonLabel, knowledgeSyncButtonLabel,
knowledgeSyncHint,
loading, loading,
pageSize, pageSize,
pageSizeOptions, pageSizeOptions,

View File

@@ -5,6 +5,7 @@ import { useToast } from '../../composables/useToast.js'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue' import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import TravelRequestApprovalDialog from '../../components/travel/TravelRequestApprovalDialog.vue' import TravelRequestApprovalDialog from '../../components/travel/TravelRequestApprovalDialog.vue'
import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue'
import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue' import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue'
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue' import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
import { import {
@@ -22,6 +23,7 @@ import {
updateExpenseClaimItem updateExpenseClaimItem
} from '../../services/reimbursements.js' } from '../../services/reimbursements.js'
import { import {
canApproveBudgetExpenseApplications,
canApproveLeaderExpenseClaims, canApproveLeaderExpenseClaims,
canDeleteArchivedExpenseClaims, canDeleteArchivedExpenseClaims,
canManageExpenseClaims, canManageExpenseClaims,
@@ -369,6 +371,7 @@ export default {
ConfirmDialog, ConfirmDialog,
EnterpriseSelect, EnterpriseSelect,
TravelRequestApprovalDialog, TravelRequestApprovalDialog,
TravelRequestBudgetAnalysis,
TravelRequestDeleteDialog, TravelRequestDeleteDialog,
TravelRequestReturnDialog TravelRequestReturnDialog
}, },
@@ -490,6 +493,10 @@ export default {
const node = String(request.value.node || request.value.approvalStage || '').trim() const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '财务审批' return node === '财务审批'
}) })
const isBudgetApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '预算管理者审批'
})
const isCurrentApplicant = computed(() => isCurrentRequestApplicant(request.value, currentUser.value)) const isCurrentApplicant = computed(() => isCurrentRequestApplicant(request.value, currentUser.value))
const isCurrentDirectManagerApprover = computed(() => ( const isCurrentDirectManagerApprover = computed(() => (
canApproveLeaderExpenseClaims(currentUser.value) canApproveLeaderExpenseClaims(currentUser.value)
@@ -501,6 +508,18 @@ export default {
&& isFinanceUser(currentUser.value) && isFinanceUser(currentUser.value)
&& !isCurrentApplicant.value && !isCurrentApplicant.value
)) ))
const canProcessBudgetApprovalStage = computed(() => (
isApplicationDocument.value
&& isBudgetApprovalStage.value
&& canApproveBudgetExpenseApplications(currentUser.value, request.value)
&& !isCurrentApplicant.value
))
const showBudgetAnalysis = computed(() => (
isApplicationDocument.value
&& isBudgetApprovalStage.value
&& canApproveBudgetExpenseApplications(currentUser.value, request.value)
&& !isCurrentApplicant.value
))
const canReturnRequest = computed(() => { const canReturnRequest = computed(() => {
if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) { if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) {
return false return false
@@ -508,6 +527,9 @@ export default {
if (isDirectManagerApprovalStage.value) { if (isDirectManagerApprovalStage.value) {
return isCurrentDirectManagerApprover.value return isCurrentDirectManagerApprover.value
} }
if (isBudgetApprovalStage.value) {
return canProcessBudgetApprovalStage.value
}
return canProcessFinanceApprovalStage.value return canProcessFinanceApprovalStage.value
}) })
const canApproveRequest = computed(() => const canApproveRequest = computed(() =>
@@ -520,6 +542,7 @@ export default {
&& isCurrentDirectManagerApprover.value && isCurrentDirectManagerApprover.value
) )
|| canProcessFinanceApprovalStage.value || canProcessFinanceApprovalStage.value
|| canProcessBudgetApprovalStage.value
) )
) )
const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value)) const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value))
@@ -536,39 +559,43 @@ export default {
isApplicationDocument.value isApplicationDocument.value
&& hasLeaderApprovalEvents.value && hasLeaderApprovalEvents.value
)) ))
const requiresApprovalOpinion = computed(() => isDirectManagerApprovalStage.value) const requiresApprovalOpinion = computed(() => false)
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见')) const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '附加意见'))
const approvalOpinionPlaceholder = computed(() => { const approvalOpinionPlaceholder = computed(() => {
if (isFinanceApprovalStage.value) { if (isFinanceApprovalStage.value) {
return '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。' return '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
} }
if (isApplicationDocument.value) { if (isApplicationDocument.value) {
return '请输入审批意见,可补充业务必要性、预算合理性或执行要求。' return '可选填审批补充说明,例如业务必要性、预算合理性或执行要求;不填写默认为同意。'
} }
return '请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。' return '可选填审批补充说明,例如核实情况、费用合理性或后续财务关注点;不填写默认为同意。'
}) })
const approvalOpinionHint = computed(() => { const approvalOpinionHint = computed(() => {
if (isFinanceApprovalStage.value) { if (isFinanceApprovalStage.value) {
return '审核通过后将进入归档入账。' return '审核通过后将进入归档入账。'
} }
return isApplicationDocument.value ? '领导意见为必填,确认后会生成报销草稿。' : '领导意见为必填,审批通过后将流转至财务审批。' if (isBudgetApprovalStage.value) {
return '不填写附加意见则默认同意,确认后会归档申请单并生成报销草稿。'
}
return isApplicationDocument.value ? '不填写附加意见则默认同意,确认后会流转至预算管理者审批。' : '不填写附加意见则默认同意,审批通过后将流转至财务审批。'
})
const approvalConfirmBadge = computed(() => {
if (isFinanceApprovalStage.value) {
return '财务终审'
}
return isBudgetApprovalStage.value ? '预算审核' : '领导审批'
}) })
const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批'))
const approvalConfirmDescription = computed(() => { const approvalConfirmDescription = computed(() => {
if (isFinanceApprovalStage.value) { if (isFinanceApprovalStage.value) {
return '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。' return '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
} }
if (isApplicationDocument.value) { if (isApplicationDocument.value) {
return '确认后该申请单会完成直属领导审批,并自动进入申请人的报销草稿中。' return isBudgetApprovalStage.value
? '确认后该申请单会完成预算审核,归档申请单,并自动进入申请人的报销草稿中。'
: '确认后该申请单会完成直属领导审批,并流转给预算管理者进一步审核。'
} }
return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。' return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
}) })
const approvalNextStage = computed(() => {
if (isFinanceApprovalStage.value) {
return '归档入账'
}
return isApplicationDocument.value ? '报销草稿' : '财务审批'
})
const approveActionLabel = computed(() => (isApplicationDocument.value ? '确认审核' : '审批通过')) const approveActionLabel = computed(() => (isApplicationDocument.value ? '确认审核' : '审批通过'))
const approveBusyLabel = computed(() => (isApplicationDocument.value ? '确认中' : '通过中')) const approveBusyLabel = computed(() => (isApplicationDocument.value ? '确认中' : '通过中'))
const approveConfirmTitle = computed(() => ( const approveConfirmTitle = computed(() => (
@@ -581,15 +608,14 @@ export default {
? '退回后该申请单会回到待提交状态,申请人需要调整后重新提交。' ? '退回后该申请单会回到待提交状态,申请人需要调整后重新提交。'
: '退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。' : '退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。'
)) ))
const approvalConfirmSummaryLabel = computed(() => (
isApplicationDocument.value ? '生成结果' : '下一节点'
))
const approvalSuccessToast = computed(() => { const approvalSuccessToast = computed(() => {
if (isFinanceApprovalStage.value) { if (isFinanceApprovalStage.value) {
return `${request.value.id} 已完成财务终审,进入归档入账。` return `${request.value.id} 已完成财务终审,进入归档入账。`
} }
return isApplicationDocument.value return isApplicationDocument.value
? `${request.value.id} 已确认审核,正在生成报销草稿。` ? isBudgetApprovalStage.value
? `${request.value.id} 已完成预算审核,正在生成报销草稿。`
: `${request.value.id} 已确认审核,已流转至预算管理者审批。`
: `${request.value.id} 已审批通过,流转至财务审批。` : `${request.value.id} 已审批通过,流转至财务审批。`
}) })
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据')) const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
@@ -1751,15 +1777,10 @@ export default {
return return
} }
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
toast('请先填写领导意见,填写后才能确认审核。')
return
}
approveBusy.value = true approveBusy.value = true
try { try {
const responsePayload = await approveExpenseClaim(request.value.claimId, { const responsePayload = await approveExpenseClaim(request.value.claimId, {
opinion: leaderOpinion.value.trim() opinion: leaderOpinion.value.trim() || '同意'
}) })
const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload) const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload)
approveConfirmDialogOpen.value = false approveConfirmDialogOpen.value = false
@@ -1805,7 +1826,7 @@ export default {
emit, actionBusy, aiAdvice, aiAdviceHint, aiAdviceTitle, attachmentPreviewError, attachmentPreviewIndexLabel, emit, actionBusy, aiAdvice, aiAdviceHint, aiAdviceTitle, attachmentPreviewError, attachmentPreviewIndexLabel,
attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen, attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen,
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge, attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
approvalConfirmDescription, approvalConfirmSummaryLabel, approvalNextStage, approvalOpinionHint, approvalConfirmDescription, approvalOpinionHint,
approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel, approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel,
applicationDetailFactItems, applicationDetailFactItems,
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim, approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
@@ -1836,6 +1857,7 @@ export default {
requiresApprovalOpinion, requiresApprovalOpinion,
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId, riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
showAiAdvicePanel, showApplicationLeaderOpinion, showAiAdvicePanel, showApplicationLeaderOpinion,
showBudgetAnalysis,
showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
submitRiskWarnings, submitRiskWarnings,
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit

View File

@@ -0,0 +1,263 @@
const DIGITAL_EMPLOYEE_AGENT = 'hermes'
const TASK_TYPE_LABELS = {
daily_risk_scan: '每日风险巡检',
global_risk_scan: '全局风险巡检',
weekly_ar_summary: '周度应收账龄汇总',
weekly_expense_report: '周度费用洞察',
rule_review_digest: '规则待审摘要',
knowledge_index_sync: '知识库归集',
x_financial_callback: '任务回调上报'
}
const CONTENT_LABELS = {
task_type: '技能类型',
schedule: '执行计划',
cron: '调度表达式',
folder: '归集范围',
changed_only: '仅处理变更',
force: '强制重建',
index_engine: '索引引擎',
callback_type: '回调类型',
status: '回写状态',
summary: '结果摘要'
}
const HIDDEN_CONTENT_KEYS = new Set([
'agent',
'target_agent',
'callback_token',
'token',
'api_key',
'authorization'
])
export function normalizeDigitalEmployeeText(value) {
return String(value ?? '').trim()
}
export function sanitizeDigitalEmployeeText(value, fallback = '') {
const text = normalizeDigitalEmployeeText(value)
.replace(/hermes/gi, '数字员工')
.replace(/赫尔墨斯/g, '数字员工')
.replace(/\s+/g, ' ')
.trim()
return text || fallback
}
export function sanitizeDigitalEmployeeName(value, fallback = '数字员工技能') {
const text = sanitizeDigitalEmployeeText(value, fallback)
.replace(/^数字员工[\s·:-]*/i, '')
.trim()
return text || fallback
}
export function parseDigitalEmployeeContent(value) {
if (!value) {
return {}
}
if (typeof value === 'object' && !Array.isArray(value)) {
return value
}
if (typeof value !== 'string') {
return {}
}
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
export function resolveDigitalEmployeeTaskType(source = {}, content = {}) {
const config = source.config_json || source.configJson || {}
const raw =
normalizeDigitalEmployeeText(content.task_type) ||
normalizeDigitalEmployeeText(config.task_type) ||
normalizeDigitalEmployeeText(source.task_type) ||
normalizeDigitalEmployeeText(source.code).replace(/^task\.hermes\./i, '')
return raw.replace(/[-.]/g, '_')
}
export function isDigitalEmployeeAsset(source = {}) {
const config = source.config_json || source.configJson || {}
const haystack = [
source.asset_type,
source.code,
source.name,
source.description,
config.agent,
config.target_agent,
config.worker,
config.runtime_agent
]
.map((item) => normalizeDigitalEmployeeText(item).toLowerCase())
.filter(Boolean)
.join(' ')
return (
normalizeDigitalEmployeeText(source.asset_type) === 'task' &&
(haystack.includes(DIGITAL_EMPLOYEE_AGENT) || haystack.includes('task.hermes.'))
)
}
export function formatDigitalEmployeeCron(value) {
const raw = normalizeDigitalEmployeeText(value)
if (!raw) {
return '手动触发'
}
const parts = raw.split(/\s+/)
if (parts.length < 5) {
return sanitizeDigitalEmployeeText(raw)
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
const hourNumber = Number(hour)
const minuteNumber = Number(minute)
const timeLabel =
Number.isFinite(hourNumber) && Number.isFinite(minuteNumber)
? `${String(hourNumber).padStart(2, '0')}:${String(minuteNumber).padStart(2, '0')}`
: `${hour}:${minute}`
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
return `每天 ${timeLabel}`
}
if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') {
const weekdayLabels = {
'0': '周日',
'1': '周一',
'2': '周二',
'3': '周三',
'4': '周四',
'5': '周五',
'6': '周六',
'7': '周日'
}
return `${weekdayLabels[dayOfWeek] || `${dayOfWeek}`} ${timeLabel}`
}
return sanitizeDigitalEmployeeText(raw)
}
export function resolveDigitalEmployeeSchedule(source = {}, content = {}) {
const config = source.config_json || source.configJson || {}
const raw =
normalizeDigitalEmployeeText(content.schedule) ||
normalizeDigitalEmployeeText(config.cron) ||
normalizeDigitalEmployeeText(config.schedule) ||
normalizeDigitalEmployeeText(config.cron_expression)
return {
value: raw,
label: formatDigitalEmployeeCron(raw)
}
}
export function resolveDigitalEmployeeEnabled(source = {}) {
const config = source.config_json || source.configJson || {}
if (config.enabled === false || config.is_enabled === false) {
return false
}
if (source.enabled === false || source.is_enabled === false) {
return false
}
return normalizeDigitalEmployeeText(source.status || 'active') === 'active'
}
export function resolveDigitalEmployeeDisplayCode(source = {}, content = {}) {
const taskType = resolveDigitalEmployeeTaskType(source, content)
return taskType ? `digital.${taskType}` : 'digital.skill'
}
function formatDigitalEmployeeValue(value) {
if (typeof value === 'boolean') {
return value ? '是' : '否'
}
if (Array.isArray(value)) {
return value.map((item) => sanitizeDigitalEmployeeText(item)).filter(Boolean).join('、') || '-'
}
if (value && typeof value === 'object') {
return sanitizeDigitalEmployeeText(JSON.stringify(value, null, 2))
}
return sanitizeDigitalEmployeeText(value, '-')
}
export function buildDigitalEmployeeContentRows(content = {}) {
return Object.entries(content)
.filter(([key]) => !HIDDEN_CONTENT_KEYS.has(normalizeDigitalEmployeeText(key).toLowerCase()))
.map(([key, value]) => ({
key,
label: CONTENT_LABELS[key] || key,
value: formatDigitalEmployeeValue(value)
}))
}
export function buildDigitalEmployeeContentPreview(content = {}) {
const visiblePayload = {}
for (const [key, value] of Object.entries(content)) {
if (HIDDEN_CONTENT_KEYS.has(normalizeDigitalEmployeeText(key).toLowerCase())) {
continue
}
visiblePayload[key] = value
}
return sanitizeDigitalEmployeeText(JSON.stringify(visiblePayload, null, 2))
}
export function buildDigitalEmployeeListMeta(source = {}) {
const content = parseDigitalEmployeeContent(source.current_version_content)
const taskType = resolveDigitalEmployeeTaskType(source, content)
const schedule = resolveDigitalEmployeeSchedule(source, content)
const enabled = resolveDigitalEmployeeEnabled(source)
const fallbackName = TASK_TYPE_LABELS[taskType] || '数字员工技能'
return {
name: sanitizeDigitalEmployeeName(source.name, fallbackName),
code: resolveDigitalEmployeeDisplayCode(source, content),
summary: sanitizeDigitalEmployeeText(source.description, '面向后台自动执行的数字员工技能。'),
category: '数字员工',
owner: sanitizeDigitalEmployeeText(source.owner, '平台运营'),
reviewer: sanitizeDigitalEmployeeText(source.reviewer, '系统'),
scope: schedule.label,
scheduleLabel: schedule.label,
executionMode: schedule.value ? '定时执行' : '手动触发',
enabled,
enabledLabel: enabled ? '已启动' : '未启动',
enabledTone: enabled ? 'success' : 'disabled',
taskType
}
}
export function buildDigitalEmployeeDetailMeta(source = {}) {
const content = parseDigitalEmployeeContent(source.current_version_content)
const listMeta = buildDigitalEmployeeListMeta({
...source,
current_version_content: content
})
const schedule = resolveDigitalEmployeeSchedule(source, content)
const contentRows = buildDigitalEmployeeContentRows(content)
return {
...listMeta,
rawCode: normalizeDigitalEmployeeText(source.code),
description: sanitizeDigitalEmployeeText(
source.description,
'该技能由后台数字员工按计划执行,并把结果沉淀到对应业务资产或运行日志中。'
),
contentRows,
contentPreview: buildDigitalEmployeeContentPreview(content),
scheduleRows: [
{ label: '执行计划', value: schedule.label },
{ label: '调度表达式', value: schedule.value || '手动触发' },
{ label: '启动状态', value: listMeta.enabledLabel, tone: listMeta.enabledTone },
{ label: '执行方式', value: listMeta.executionMode }
],
overviewRows: [
{ label: '能力编号', value: listMeta.code },
{ label: '业务归口', value: listMeta.owner },
{ label: '当前版本', value: source.working_version || source.current_version || '-' },
{ label: '最近更新', value: source.updated_at || '-' }
]
}
}

View File

@@ -96,6 +96,32 @@ export const TAB_META = {
...TYPE_META.mcp, ...TYPE_META.mcp,
typeKey: 'mcp', typeKey: 'mcp',
badgeTone: 'amber' badgeTone: 'amber'
},
digitalWorkers: {
assetType: 'task',
typeKey: 'digitalWorkers',
label: '数字员工',
typeLabel: '数字员工',
createButtonLabel: '数字员工已接入',
hintText: '归集后台自动执行的数字员工技能,可查看技能内容、执行计划、启动状态和最近版本。',
searchPlaceholder: '搜索数字员工技能、编号、执行计划或维护人',
showMetricColumn: true,
showRuntimeColumn: true,
showVersionColumn: true,
showStatusColumn: true,
showEnabledColumn: true,
tableColumns: {
name: '技能名称',
category: '归集标签',
owner: '维护归口',
scope: '执行计划',
runtime: '触发方式',
version: '当前版本',
status: '资产状态',
metric: '运行方式',
updatedAt: '最近更新'
},
badgeTone: 'violet'
} }
} }
@@ -208,6 +234,24 @@ export const DETAIL_TITLES = {
historyDesc: '最近版本记录', historyDesc: '最近版本记录',
publishTitle: '服务状态', publishTitle: '服务状态',
publishDesc: 'MCP 资产已接入规则中心,但真实外部调用仍以后续链路集成为准。' publishDesc: 'MCP 资产已接入规则中心,但真实外部调用仍以后续链路集成为准。'
},
digitalWorkers: {
configTitle: '技能档案',
configDesc: '展示数字员工技能的编号、归口、执行计划和启停状态。',
detailTitle: '技能内容',
detailDesc: '展示当前版本记录的任务类型、调度范围和执行参数。',
outputTitle: '执行安排',
outputDesc: '展示什么时候执行、是否启动,以及当前运行方式。',
ruleListTitle: '技能参数',
checkListTitle: '启动状态',
triggerTitle: '执行计划',
triggerDesc: '当前技能的计划执行时间或触发方式。',
toolTitle: '运行归口',
toolDesc: '数字员工技能由后台调度执行,运行结果进入对应日志或业务资产。',
historyTitle: '版本记录',
historyDesc: '最近的技能配置快照。',
publishTitle: '启动状态',
publishDesc: '数字员工技能由资产状态和调度配置共同决定是否启动。'
} }
} }

View File

@@ -34,6 +34,13 @@ import {
resolveRiskRuleSeverity, resolveRiskRuleSeverity,
resolveRiskRuleSeverityLabel resolveRiskRuleSeverityLabel
} from './auditViewRiskRuleModel.js' } from './auditViewRiskRuleModel.js'
import {
buildDigitalEmployeeContentRows,
buildDigitalEmployeeDetailMeta,
buildDigitalEmployeeListMeta,
isDigitalEmployeeAsset,
sanitizeDigitalEmployeeText
} from './auditViewDigitalEmployeeModel.js'
const EXPENSE_TYPE_SCENARIO_LABELS = { const EXPENSE_TYPE_SCENARIO_LABELS = {
travel: '差旅费', travel: '差旅费',
@@ -335,6 +342,9 @@ export function resolveTabId(source, typeKey) {
if (typeKey === 'rules') { if (typeKey === 'rules') {
return resolveRuleTabId(source) return resolveRuleTabId(source)
} }
if (typeKey === 'digitalWorkers') {
return isDigitalEmployeeAsset(source) ? 'digitalWorkers' : ''
}
return typeKey return typeKey
} }
@@ -895,6 +905,9 @@ export function resolveTypeKey(assetType) {
if (assetType === 'mcp') { if (assetType === 'mcp') {
return 'mcp' return 'mcp'
} }
if (assetType === 'task') {
return 'digitalWorkers'
}
return '' return ''
} }
@@ -958,6 +971,9 @@ export function buildRowRuntime(asset, typeKey) {
if (typeKey === 'mcp') { if (typeKey === 'mcp') {
return normalizeText(asset.config_json?.endpoint) || '未配置地址' return normalizeText(asset.config_json?.endpoint) || '未配置地址'
} }
if (typeKey === 'digitalWorkers') {
return buildDigitalEmployeeListMeta(asset).executionMode
}
return '' return ''
} }
@@ -971,6 +987,9 @@ export function buildRowMetric(asset, typeKey) {
if (typeKey === 'mcp') { if (typeKey === 'mcp') {
return asset.config_json?.timeout_ms ? `${asset.config_json.timeout_ms} ms` : '未配置超时' return asset.config_json?.timeout_ms ? `${asset.config_json.timeout_ms} ms` : '未配置超时'
} }
if (typeKey === 'digitalWorkers') {
return buildDigitalEmployeeListMeta(asset).executionMode
}
return '' return ''
} }
@@ -1042,6 +1061,19 @@ export function buildListItem(asset) {
? resolveRiskRuleScoreLabel(asset.config_json, asset.config_json) || resolveRiskRuleSeverityLabel(asset.config_json) ? resolveRiskRuleScoreLabel(asset.config_json, asset.config_json) || resolveRiskRuleSeverityLabel(asset.config_json)
: resolveRiskRuleSeverityLabel(asset.config_json) : resolveRiskRuleSeverityLabel(asset.config_json)
: '' : ''
const digitalMeta = typeKey === 'digitalWorkers' ? buildDigitalEmployeeListMeta(asset) : null
const displayName = digitalMeta?.name || asset.name
const displayCode = digitalMeta?.code || asset.code
const displaySummary = digitalMeta?.summary || listSubtitle
const displayOwner = digitalMeta?.owner || (isRiskRule ? creator : asset.owner)
const displayReviewer = digitalMeta?.reviewer || reviewer
const displayCategory = digitalMeta?.category || resolveDomainLabel(asset.domain)
const displayScope =
digitalMeta?.scope ||
(typeKey === 'rules' ? ruleScenarioCategory || '閫氱敤' : formatScenarioList(asset.scenario_json))
const displayEnabledValue = digitalMeta ? digitalMeta.enabled : isEnabledValue
const displayEnabledLabel = digitalMeta?.enabledLabel || (isEnabledValue ? '鏄? : '?)
const displayEnabledTone = digitalMeta?.enabledTone || (isEnabledValue ? 'success' : 'disabled')
return { return {
id: asset.id, id: asset.id,
@@ -1052,15 +1084,17 @@ export function buildListItem(asset) {
usesJsonRiskRule, usesJsonRiskRule,
ruleDocument, ruleDocument,
typeLabel: tabMeta.typeLabel, typeLabel: tabMeta.typeLabel,
short: makeShort(asset.name), short: makeShort(displayName),
name: asset.name, name: displayName,
code: asset.code, code: displayCode,
summary: listSubtitle, rawCode: asset.code,
listSubtitle, summary: displaySummary,
category: resolveDomainLabel(asset.domain), listSubtitle: displaySummary,
owner: isRiskRule ? creator : asset.owner, category: displayCategory,
reviewer, owner: displayOwner,
reviewer: displayReviewer,
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json), scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json),
scope: displayScope,
riskCategory: ruleScenarioCategory, riskCategory: ruleScenarioCategory,
scenarioList: ruleScenarioList, scenarioList: ruleScenarioList,
businessStageValue: businessStage.value, businessStageValue: businessStage.value,
@@ -1086,6 +1120,9 @@ export function buildListItem(asset) {
isEnabledValue, isEnabledValue,
isEnabledLabel: isEnabledValue ? '是' : '否', isEnabledLabel: isEnabledValue ? '是' : '否',
isEnabledTone: isEnabledValue ? 'success' : 'disabled', isEnabledTone: isEnabledValue ? 'success' : 'disabled',
isEnabledValue: displayEnabledValue,
isEnabledLabel: displayEnabledLabel,
isEnabledTone: displayEnabledTone,
modifiedBy, modifiedBy,
changeCount, changeCount,
updatedAt: isRiskRule ? riskRuleCreatedAt : formatDateTime(asset.updated_at), updatedAt: isRiskRule ? riskRuleCreatedAt : formatDateTime(asset.updated_at),
@@ -1417,6 +1454,25 @@ export function buildDetailViewModel(detail, runs) {
const initialRiskRuleScore = resolveRiskRuleScore(configJson, configJson) const initialRiskRuleScore = resolveRiskRuleScore(configJson, configJson)
const initialRiskRuleScoreLevel = resolveRiskRuleScoreLevel(configJson, configJson) const initialRiskRuleScoreLevel = resolveRiskRuleScoreLevel(configJson, configJson)
const initialRiskRuleSeverity = initialRiskRuleScoreLevel || resolveRiskRuleSeverity(configJson) const initialRiskRuleSeverity = initialRiskRuleScoreLevel || resolveRiskRuleSeverity(configJson)
const digitalMeta = typeKey === 'digitalWorkers'
? buildDigitalEmployeeDetailMeta({
...detail,
updated_at: formatDateTime(detail.updated_at)
})
: null
const detailName = digitalMeta?.name || detail.name
const detailCode = digitalMeta?.code || detail.code
const detailSummary = digitalMeta?.description ||
(usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : detail.description)
const detailOwner = digitalMeta?.owner || detail.owner
const detailReviewer = digitalMeta?.reviewer || detail.reviewer || detail.latest_review?.reviewer || '寰呭垎閰?
const detailCategory = digitalMeta?.category || resolveDomainLabel(detail.domain)
const detailScope =
digitalMeta?.scope ||
(typeKey === 'rules' ? ruleScenarioCategory || '閫氱敤' : formatScenarioList(detail.scenario_json))
const detailEnabledValue = digitalMeta ? digitalMeta.enabled : isEnabledValue
const detailEnabledLabel = digitalMeta?.enabledLabel || (isEnabledValue ? '? : '鍚?)
const detailEnabledTone = digitalMeta?.enabledTone || (isEnabledValue ? 'success' : 'disabled')
return { return {
id: detail.id, id: detail.id,

View File

@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { import {
canApproveBudgetExpenseApplications,
canApproveLeaderExpenseClaims, canApproveLeaderExpenseClaims,
canAccessAppView, canAccessAppView,
canDeleteArchivedExpenseClaims, canDeleteArchivedExpenseClaims,
@@ -22,6 +23,24 @@ test('direct approvers can return claims without receiving delete permissions',
assert.equal(canReturnExpenseClaims(approverUser), true) assert.equal(canReturnExpenseClaims(approverUser), true)
assert.equal(canApproveLeaderExpenseClaims(managerUser), true) assert.equal(canApproveLeaderExpenseClaims(managerUser), true)
assert.equal(canApproveLeaderExpenseClaims(approverUser), true) assert.equal(canApproveLeaderExpenseClaims(approverUser), true)
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['budget_monitor'], grade: 'P6' }), false)
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['budget_monitor'], grade: 'P8' }), true)
assert.equal(
canApproveBudgetExpenseApplications(
{ roleCodes: ['budget_monitor'], grade: 'P8', departmentName: '交付部' },
{ departmentName: '交付部' }
),
true
)
assert.equal(
canApproveBudgetExpenseApplications(
{ roleCodes: ['budget_monitor'], grade: 'P8', departmentName: '财务部' },
{ departmentName: '交付部' }
),
false
)
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: [], grade: 'P8' }), false)
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['executive'] }), true)
assert.equal(canManageExpenseClaims(managerUser), false) assert.equal(canManageExpenseClaims(managerUser), false)
assert.equal(canManageExpenseClaims(approverUser), false) assert.equal(canManageExpenseClaims(approverUser), false)
}) })
@@ -81,6 +100,37 @@ test('finance approval inbox only processes finance-stage requests', () => {
) )
}) })
test('budget approval inbox only processes budget-stage requests for budget monitor or senior finance roles', () => {
const budgetUser = { roleCodes: ['budget_monitor'], grade: 'P8', name: '赵预算', departmentName: '交付部' }
const otherDepartmentBudgetUser = { roleCodes: ['budget_monitor'], grade: 'P8', name: '王预算', departmentName: '财务部' }
const seniorFinanceUser = { roleCodes: ['executive'], grade: 'P7', name: '高级财务' }
const p8WithoutBudgetRole = { roleCodes: ['manager'], grade: 'P8', name: '高职级经理' }
assert.equal(
canProcessApprovalRequest({ workflowNode: '预算管理者审批', person: '张三', departmentName: '交付部' }, budgetUser),
true
)
assert.equal(
canProcessApprovalRequest({ workflowNode: '预算管理者审批', person: '张三', departmentName: '交付部' }, seniorFinanceUser),
true
)
assert.equal(
canProcessApprovalRequest(
{ workflowNode: '预算管理者审批', person: '张三', departmentName: '交付部' },
otherDepartmentBudgetUser
),
false
)
assert.equal(
canProcessApprovalRequest({ workflowNode: '预算管理者审批', person: '张三' }, p8WithoutBudgetRole),
false
)
assert.equal(
canProcessApprovalRequest({ workflowNode: '财务审批', person: '张三' }, budgetUser),
false
)
})
test('users with both finance and manager roles can process both relevant stages', () => { test('users with both finance and manager roles can process both relevant stages', () => {
const financeManagerUser = { roleCodes: ['finance', 'manager'], name: '李经理' } const financeManagerUser = { roleCodes: ['finance', 'manager'], name: '李经理' }

View File

@@ -57,7 +57,7 @@ test('detail topbar ignores system allowance rows when checking missing tickets'
assert.equal(hasMissingAttachment(request), false) assert.equal(hasMissingAttachment(request), false)
assert.equal(hasPendingInfo(request), false) assert.equal(hasPendingInfo(request), false)
assert.deepEqual(alerts, ['直属领导审批']) assert.deepEqual(alerts, ['SLA 催单次数 0'])
}) })
test('detail topbar still flags real manual rows without required ticket info', () => { test('detail topbar still flags real manual rows without required ticket info', () => {
@@ -96,7 +96,7 @@ test('detail topbar still flags real manual rows without required ticket info',
assert.equal(hasMissingAttachment(request), true) assert.equal(hasMissingAttachment(request), true)
assert.equal(hasPendingInfo(request), true) assert.equal(hasPendingInfo(request), true)
assert.deepEqual(alerts, ['待提交', '缺少票据', '待补信息']) assert.deepEqual(alerts, ['SLA 催单次数 0', '缺少票据', '待补信息'])
}) })
test('application detail topbar does not ask for receipt attachments', () => { test('application detail topbar does not ask for receipt attachments', () => {
@@ -122,5 +122,29 @@ test('application detail topbar does not ask for receipt attachments', () => {
assert.equal(hasMissingAttachment(request), false) assert.equal(hasMissingAttachment(request), false)
assert.equal(alerts.includes('缺少票据'), false) assert.equal(alerts.includes('缺少票据'), false)
assert.deepEqual(alerts, ['直属领导审批']) assert.deepEqual(alerts, ['SLA 催单次数 0'])
})
test('detail topbar shows SLA reminder count from direct fields and reminder events', () => {
const directAlerts = buildDetailAlerts({
node: '直属领导审批',
approvalKey: 'in_progress',
slaReminderCount: 2,
expenseItems: []
})
const eventAlerts = buildDetailAlerts({
node: '直属领导审批',
approvalKey: 'in_progress',
riskFlags: [
{ source: 'sla_reminder', message: '下属已催单' },
{ event_type: 'urge', message: '再次催单' }
],
expenseItems: []
})
assert.equal(directAlerts[0].label, 'SLA 催单次数 2')
assert.equal(directAlerts[0].tone, 'warning')
assert.equal(directAlerts[0].icon, 'mdi mdi-bell-ring-outline')
assert.equal(eventAlerts[0].label, 'SLA 催单次数 2')
}) })

View File

@@ -85,15 +85,20 @@ test('documents center list shows created time and conditional stay time columns
assert.match(documentsCenterView, /import \{[\s\S]*formatDocumentListTime[\s\S]*resolveDocumentStayTimeDisplay[\s\S]*\} from '..\/utils\/documentCenterTime\.js'/) assert.match(documentsCenterView, /import \{[\s\S]*formatDocumentListTime[\s\S]*resolveDocumentStayTimeDisplay[\s\S]*\} from '..\/utils\/documentCenterTime\.js'/)
assert.match(documentsCenterView, /<col class="col-created">/) assert.match(documentsCenterView, /<col class="col-created">/)
assert.match(documentsCenterView, /<col v-if="showStayTimeColumn" class="col-stay">/) assert.match(documentsCenterView, /<col v-if="showStayTimeColumn" class="col-stay">/)
assert.match(documentsCenterView, /<col class="col-initiator">/)
assert.match(documentsCenterView, /<th>单号<\/th>[\s\S]*<th>创建时间<\/th>[\s\S]*<th v-if="showStayTimeColumn">停留时间<\/th>/) assert.match(documentsCenterView, /<th>单号<\/th>[\s\S]*<th>创建时间<\/th>[\s\S]*<th v-if="showStayTimeColumn">停留时间<\/th>/)
assert.match(documentsCenterView, /<th>费用场景<\/th>[\s\S]*<th>发起人<\/th>[\s\S]*<th>事项<\/th>/)
assert.match(documentsCenterView, /<td>\{\{ row\.createdAtDisplay \}\}<\/td>/) assert.match(documentsCenterView, /<td>\{\{ row\.createdAtDisplay \}\}<\/td>/)
assert.match(documentsCenterView, /<td v-if="showStayTimeColumn">\{\{ row\.stayTimeDisplay \}\}<\/td>/) assert.match(documentsCenterView, /<td v-if="showStayTimeColumn">\{\{ row\.stayTimeDisplay \}\}<\/td>/)
assert.match(documentsCenterView, /<td>\{\{ row\.initiatorName \}\}<\/td>/)
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/const showStayTimeColumn = computed\(\(\) =>[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REVIEW/ /const showStayTimeColumn = computed\(\(\) =>[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REVIEW/
) )
assert.match(documentsCenterView, /createdAtDisplay: formatDocumentListTime\(createdAtSource\)/) assert.match(documentsCenterView, /createdAtDisplay: formatDocumentListTime\(createdAtSource\)/)
assert.match(documentsCenterView, /stayTimeDisplay: resolveDocumentStayTimeDisplay\(normalized\)/) assert.match(documentsCenterView, /stayTimeDisplay: resolveDocumentStayTimeDisplay\(normalized\)/)
assert.match(documentsCenterView, /initiatorName,/)
assert.match(documentsCenterView, /row\.initiatorName/)
}) })
test('documents center action buttons are scoped to application and reimbursement tabs', () => { test('documents center action buttons are scoped to application and reimbursement tabs', () => {
@@ -225,9 +230,10 @@ test('documents center status dropdown uses compact filter styling', () => {
assert.match(documentsCenterStyles, /\.documents-list\s*\{[\s\S]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\) auto;/) assert.match(documentsCenterStyles, /\.documents-list\s*\{[\s\S]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\) auto;/)
assert.match(documentsCenterStyles, /\.status-tabs button\s*\{[\s\S]*display:\s*inline-flex;/) assert.match(documentsCenterStyles, /\.status-tabs button\s*\{[\s\S]*display:\s*inline-flex;/)
assert.match(documentsCenterStyles, /\.scope-tab-badge\s*\{[\s\S]*border-radius:\s*999px;/) assert.match(documentsCenterStyles, /\.scope-tab-badge\s*\{[\s\S]*border-radius:\s*999px;/)
assert.match(documentsCenterStyles, /min-width:\s*1320px;/) assert.match(documentsCenterStyles, /min-width:\s*1420px;/)
assert.match(documentsCenterStyles, /\.col-created\s*\{\s*width:\s*10%;\s*\}/) assert.match(documentsCenterStyles, /\.col-created\s*\{\s*width:\s*10%;\s*\}/)
assert.match(documentsCenterStyles, /\.col-stay\s*\{\s*width:\s*9%;\s*\}/) assert.match(documentsCenterStyles, /\.col-stay\s*\{\s*width:\s*9%;\s*\}/)
assert.match(documentsCenterStyles, /\.col-initiator\s*\{\s*width:\s*8%;\s*\}/)
assert.match(documentsCenterStyles, /\.document-status-filter\s*\{[\s\S]*display:\s*inline-flex;/) assert.match(documentsCenterStyles, /\.document-status-filter\s*\{[\s\S]*display:\s*inline-flex;/)
assert.match(documentsCenterStyles, /\.document-status-filter\s*\{[\s\S]*min-height:\s*38px;/) assert.match(documentsCenterStyles, /\.document-status-filter\s*\{[\s\S]*min-height:\s*38px;/)
assert.match(documentsCenterStyles, /\.status-filter-trigger\s*\{[\s\S]*min-width:\s*154px;/) assert.match(documentsCenterStyles, /\.status-filter-trigger\s*\{[\s\S]*min-width:\s*154px;/)

View File

@@ -5,10 +5,12 @@ import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js'
const CREATE_APPLICATION = '\u521b\u5efa\u7533\u8bf7' const CREATE_APPLICATION = '\u521b\u5efa\u7533\u8bf7'
const DIRECT_MANAGER_APPROVAL = '\u76f4\u5c5e\u9886\u5bfc\u5ba1\u6279' const DIRECT_MANAGER_APPROVAL = '\u76f4\u5c5e\u9886\u5bfc\u5ba1\u6279'
const BUDGET_MANAGER_APPROVAL = '\u9884\u7b97\u7ba1\u7406\u8005\u5ba1\u6279'
const APPROVAL_COMPLETED = '\u5ba1\u6279\u5b8c\u6210' const APPROVAL_COMPLETED = '\u5ba1\u6279\u5b8c\u6210'
const RETURNED = '\u9000\u56de' const RETURNED = '\u9000\u56de'
const WAIT_SUBMIT = '\u5f85\u63d0\u4ea4' const WAIT_SUBMIT = '\u5f85\u63d0\u4ea4'
const WAIT_LEADER_LI_APPROVAL = '\u7b49\u5f85 Leader Li \u6279\u590d' const WAIT_LEADER_LI_APPROVAL = '\u7b49\u5f85 Leader Li \u6279\u590d'
const WAIT_BUDGET_ZHAO_APPROVAL = '\u7b49\u5f85 \u8d75\u9884\u7b97 \u6279\u590d'
const LEADER_RETURNED_STATUS = '\u9886\u5bfc\u5df2\u9000\u56de\uff0c\u5f85\u91cd\u65b0\u63d0\u4ea4' const LEADER_RETURNED_STATUS = '\u9886\u5bfc\u5df2\u9000\u56de\uff0c\u5f85\u91cd\u65b0\u63d0\u4ea4'
test('application claims are mapped as application documents', () => { test('application claims are mapped as application documents', () => {
@@ -41,7 +43,7 @@ test('application claims are mapped as application documents', () => {
assert.equal(request.expenseTableSummary, '预计金额已随申请提交') assert.equal(request.expenseTableSummary, '预计金额已随申请提交')
assert.deepEqual( assert.deepEqual(
request.progressSteps.map((step) => step.label), request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, APPROVAL_COMPLETED] [CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, BUDGET_MANAGER_APPROVAL, APPROVAL_COMPLETED]
) )
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false) assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false) assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false)
@@ -50,6 +52,47 @@ test('application claims are mapped as application documents', () => {
assert.equal(request.progressSteps.find((step) => step.label === WAIT_LEADER_LI_APPROVAL)?.current, true) assert.equal(request.progressSteps.find((step) => step.label === WAIT_LEADER_LI_APPROVAL)?.current, true)
}) })
test('application claims wait for department P8 budget monitor after leader approval', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-budget',
claim_no: 'AP-20260525103145-BUDGET',
employee_name: '张三',
department_name: '交付部',
manager_name: 'Leader Li',
expense_type: 'travel_application',
reason: '支撑国网服务器上线部署',
location: '上海',
amount: 12000,
invoice_count: 0,
occurred_at: '2026-05-25T00:00:00.000Z',
submitted_at: '2026-05-25T02:00:00.000Z',
created_at: '2026-05-25T01:30:00.000Z',
updated_at: '2026-05-25T03:00:00.000Z',
status: 'submitted',
approval_stage: BUDGET_MANAGER_APPROVAL,
risk_flags_json: [
{
source: 'manual_approval',
event_type: 'expense_application_approval',
operator: 'Leader Li',
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
next_approval_stage: BUDGET_MANAGER_APPROVAL,
next_approver_name: '赵预算',
next_approver_grade: 'P8',
created_at: '2026-05-25T03:00:00.000Z'
}
],
items: []
})
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_ZHAO_APPROVAL, APPROVAL_COMPLETED]
)
assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_ZHAO_APPROVAL)?.current, true)
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过')
})
test('returned application claims include leader return node and supplement status', () => { test('returned application claims include leader return node and supplement status', () => {
const request = mapExpenseClaimToRequest({ const request = mapExpenseClaimToRequest({
id: 'claim-application-returned', id: 'claim-application-returned',
@@ -86,7 +129,7 @@ test('returned application claims include leader return node and supplement stat
assert.deepEqual( assert.deepEqual(
request.progressSteps.map((step) => step.label), request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, RETURNED, WAIT_SUBMIT] [CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, RETURNED, WAIT_SUBMIT]
) )
assert.equal(request.progressSteps.find((step) => step.label === RETURNED)?.time, 'Leader Li\u9000\u56de') assert.equal(request.progressSteps.find((step) => step.label === RETURNED)?.time, 'Leader Li\u9000\u56de')
assert.match(request.progressSteps.find((step) => step.label === RETURNED)?.detail, /2026-05-25/) assert.match(request.progressSteps.find((step) => step.label === RETURNED)?.detail, /2026-05-25/)
@@ -96,7 +139,7 @@ test('returned application claims include leader return node and supplement stat
assert.equal(request.progressSteps.some((step) => step.label === APPROVAL_COMPLETED), false) assert.equal(request.progressSteps.some((step) => step.label === APPROVAL_COMPLETED), false)
}) })
test('approved application claims complete after direct manager approval only', () => { test('approved application claims complete after budget approval', () => {
const request = mapExpenseClaimToRequest({ const request = mapExpenseClaimToRequest({
id: 'claim-application-approved', id: 'claim-application-approved',
claim_no: 'AP-20260525113045-HGFEDCBA', claim_no: 'AP-20260525113045-HGFEDCBA',
@@ -120,6 +163,16 @@ test('approved application claims complete after direct manager approval only',
event_type: 'expense_application_approval', event_type: 'expense_application_approval',
operator: '李经理', operator: '李经理',
previous_approval_stage: '直属领导审批', previous_approval_stage: '直属领导审批',
next_approval_stage: '预算管理者审批',
next_approver_name: '赵预算',
next_approver_grade: 'P8',
created_at: '2026-05-25T03:00:00.000Z'
},
{
source: 'budget_approval',
event_type: 'expense_application_budget_approval',
operator: '赵预算',
previous_approval_stage: '预算管理者审批',
next_approval_stage: '审批完成', next_approval_stage: '审批完成',
created_at: '2026-05-25T03:00:00.000Z' created_at: '2026-05-25T03:00:00.000Z'
} }
@@ -131,10 +184,11 @@ test('approved application claims complete after direct manager approval only',
assert.equal(request.workflowNode, '审批完成') assert.equal(request.workflowNode, '审批完成')
assert.deepEqual( assert.deepEqual(
request.progressSteps.map((step) => step.label), request.progressSteps.map((step) => step.label),
['创建申请', '直属领导审批', '审批完成'] ['创建申请', '直属领导审批', '预算管理者审批', '审批完成']
) )
assert.equal(request.progressSteps.every((step) => step.done), true) assert.equal(request.progressSteps.every((step) => step.done), true)
assert.equal(request.progressSteps.find((step) => step.label === '直属领导审批')?.time, '李经理通过') assert.equal(request.progressSteps.find((step) => step.label === '直属领导审批')?.time, '李经理通过')
assert.equal(request.progressSteps.find((step) => step.label === '预算管理者审批')?.time, '赵预算通过')
}) })
test('progress steps show approval operator time and current stay duration', () => { test('progress steps show approval operator time and current stay duration', () => {

View File

@@ -19,6 +19,10 @@ const approvalDialog = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestApprovalDialog.vue', import.meta.url)), fileURLToPath(new URL('../src/components/travel/TravelRequestApprovalDialog.vue', import.meta.url)),
'utf8' 'utf8'
) )
const budgetAnalysisComponent = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestBudgetAnalysis.vue', import.meta.url)),
'utf8'
)
const reimbursementService = readFileSync( const reimbursementService = readFileSync(
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)), fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
'utf8' 'utf8'
@@ -53,18 +57,23 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/) assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/)
assert.match(detailScript, /const canApproveRequest = computed/) assert.match(detailScript, /const canApproveRequest = computed/)
assert.match(detailScript, /canApproveLeaderExpenseClaims/) assert.match(detailScript, /canApproveLeaderExpenseClaims/)
assert.match(detailScript, /canApproveBudgetExpenseApplications/)
assert.match(detailScript, /isCurrentDirectManagerForRequest/) assert.match(detailScript, /isCurrentDirectManagerForRequest/)
assert.match(detailScript, /isCurrentRequestApplicant/) assert.match(detailScript, /isCurrentRequestApplicant/)
assert.match(detailScript, /isFinanceApprovalStage/) assert.match(detailScript, /isFinanceApprovalStage/)
assert.match(detailScript, /const isBudgetApprovalStage = computed/)
assert.match(detailScript, /const showBudgetAnalysis = computed/)
assert.match(detailScript, /const isCurrentApplicant = computed/) assert.match(detailScript, /const isCurrentApplicant = computed/)
assert.match(detailScript, /const isCurrentDirectManagerApprover = computed/) assert.match(detailScript, /const isCurrentDirectManagerApprover = computed/)
assert.match(detailScript, /const canProcessFinanceApprovalStage = computed/) assert.match(detailScript, /const canProcessFinanceApprovalStage = computed/)
assert.match(detailScript, /const canProcessBudgetApprovalStage = computed/)
assert.match(detailScript, /approvalOpinionTitle/) assert.match(detailScript, /approvalOpinionTitle/)
assert.match(detailScript, /approvalConfirmDescription/) assert.match(detailScript, /approvalConfirmDescription/)
assert.match(detailScript, /approvalNextStage/) assert.doesNotMatch(detailScript, /approvalNextStage/)
assert.doesNotMatch(detailScript, /showApplicationLeaderOpinionInput/) assert.doesNotMatch(detailScript, /showApplicationLeaderOpinionInput/)
assert.doesNotMatch(detailScript, /showLeaderApprovalPanel/) assert.doesNotMatch(detailScript, /showLeaderApprovalPanel/)
assert.match(detailScript, /const requiresApprovalOpinion = computed\(\(\) => isDirectManagerApprovalStage\.value\)/) assert.match(detailScript, /const requiresApprovalOpinion = computed\(\(\) => false\)/)
assert.match(detailScript, /approvalOpinionTitle = computed\(\(\) => \(isFinanceApprovalStage\.value \? '财务意见' : '附加意见'\)\)/)
assert.match(detailScript, /buildLeaderApprovalEvents/) assert.match(detailScript, /buildLeaderApprovalEvents/)
assert.match(detailScript, /buildLeaderApprovalInfo/) assert.match(detailScript, /buildLeaderApprovalInfo/)
assert.match(detailScript, /const leaderApprovalEvents = computed/) assert.match(detailScript, /const leaderApprovalEvents = computed/)
@@ -76,11 +85,13 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.match(detailScript, /isDirectManagerApprovalStage\.value\)[\s\S]*return isCurrentDirectManagerApprover\.value/) assert.match(detailScript, /isDirectManagerApprovalStage\.value\)[\s\S]*return isCurrentDirectManagerApprover\.value/)
assert.match(detailScript, /isDirectManagerApprovalStage\.value[\s\S]*&& isCurrentDirectManagerApprover\.value/) assert.match(detailScript, /isDirectManagerApprovalStage\.value[\s\S]*&& isCurrentDirectManagerApprover\.value/)
assert.match(detailScript, /canProcessFinanceApprovalStage\.value/) assert.match(detailScript, /canProcessFinanceApprovalStage\.value/)
assert.match(detailScript, /canProcessBudgetApprovalStage\.value/)
assert.doesNotMatch(detailScript, /leaderApprovalReadonlyText/) assert.doesNotMatch(detailScript, /leaderApprovalReadonlyText/)
assert.match(detailScript, /resolveGeneratedDraftClaimNo/) assert.match(detailScript, /resolveGeneratedDraftClaimNo/)
assert.match(detailScript, /approveActionLabel/) assert.match(detailScript, /approveActionLabel/)
assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\)/) assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\) \|\| '同意'/)
assert.match(detailScript, /报销草稿 \$\{generatedDraftClaimNo\} 已生成/) assert.match(detailScript, /报销草稿 \$\{generatedDraftClaimNo\} 已生成/)
assert.match(detailScript, /流转至预算管理者审批/)
assert.doesNotMatch(detailTemplate, /v-if="showLeaderApprovalPanel"/) assert.doesNotMatch(detailTemplate, /v-if="showLeaderApprovalPanel"/)
assert.doesNotMatch(detailTemplate, /showApplicationLeaderOpinionInput/) assert.doesNotMatch(detailTemplate, /showApplicationLeaderOpinionInput/)
@@ -96,6 +107,7 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.doesNotMatch(detailTemplate, /leaderApprovalReadonlyText/) assert.doesNotMatch(detailTemplate, /leaderApprovalReadonlyText/)
assert.doesNotMatch(detailTemplate, /\u5f85\u76f4\u5c5e\u9886\u5bfc\u586b\u5199\u5ba1\u6279\u610f\u89c1/) assert.doesNotMatch(detailTemplate, /\u5f85\u76f4\u5c5e\u9886\u5bfc\u586b\u5199\u5ba1\u6279\u610f\u89c1/)
assert.match(detailTemplate, /领导意见/) assert.match(detailTemplate, /领导意见/)
assert.match(detailTemplate, /<TravelRequestBudgetAnalysis[\s\S]*v-if="showBudgetAnalysis"[\s\S]*:claim-id="request\.claimId"/)
assert.match(approvalDialog, /\{\{ opinionTitle \}\}/) assert.match(approvalDialog, /\{\{ opinionTitle \}\}/)
assert.doesNotMatch(detailTemplate, /v-model="leaderOpinion"/) assert.doesNotMatch(detailTemplate, /v-model="leaderOpinion"/)
assert.match(detailTemplate, /@click="handleApproveRequest"/) assert.match(detailTemplate, /@click="handleApproveRequest"/)
@@ -105,7 +117,10 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.match(detailTemplate, /:description="approvalConfirmDescription"/) assert.match(detailTemplate, /:description="approvalConfirmDescription"/)
assert.match(detailTemplate, /:confirm-text="approveConfirmText"/) assert.match(detailTemplate, /:confirm-text="approveConfirmText"/)
assert.match(detailTemplate, /:busy-text="approveBusyText"/) assert.match(detailTemplate, /:busy-text="approveBusyText"/)
assert.match(detailTemplate, /:next-stage="approvalNextStage"/) assert.doesNotMatch(detailTemplate, /:next-stage="approvalNextStage"/)
assert.doesNotMatch(approvalDialog, /submit-confirm-summary/)
assert.doesNotMatch(approvalDialog, /单据编号/)
assert.doesNotMatch(approvalDialog, /当前节点/)
assert.match(detailTemplate, /v-model:opinion="leaderOpinion"/) assert.match(detailTemplate, /v-model:opinion="leaderOpinion"/)
assert.match(detailTemplate, /:opinion-placeholder="approvalOpinionPlaceholder"/) assert.match(detailTemplate, /:opinion-placeholder="approvalOpinionPlaceholder"/)
assert.match(detailTemplate, /:opinion-hint="approvalOpinionHint"/) assert.match(detailTemplate, /:opinion-hint="approvalOpinionHint"/)
@@ -119,8 +134,8 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.doesNotMatch(handleApproveRequest, /approveExpenseClaim/) assert.doesNotMatch(handleApproveRequest, /approveExpenseClaim/)
assert.doesNotMatch(handleApproveRequest, /leaderOpinion\.value\.trim/) assert.doesNotMatch(handleApproveRequest, /leaderOpinion\.value\.trim/)
assert.match(confirmApproveRequest, /approveExpenseClaim/) assert.match(confirmApproveRequest, /approveExpenseClaim/)
assert.match(confirmApproveRequest, /requiresApprovalOpinion\.value && !leaderOpinion\.value\.trim\(\)/) assert.doesNotMatch(confirmApproveRequest, /requiresApprovalOpinion\.value && !leaderOpinion\.value\.trim\(\)/)
assert.match(confirmApproveRequest, /请先填写领导意见,填写后才能确认审核。/) assert.doesNotMatch(confirmApproveRequest, /请先填写领导意见,填写后才能确认审核。/)
assert.match(approvalDialog, /<textarea/) assert.match(approvalDialog, /<textarea/)
assert.match(approvalDialog, /update:opinion/) assert.match(approvalDialog, /update:opinion/)
@@ -141,4 +156,11 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.match(reimbursementService, /export function approveExpenseClaim\(claimId, payload = \{\}\)/) assert.match(reimbursementService, /export function approveExpenseClaim\(claimId, payload = \{\}\)/)
assert.match(reimbursementService, /\/approve/) assert.match(reimbursementService, /\/approve/)
assert.match(reimbursementService, /export function fetchExpenseClaimBudgetAnalysis/)
assert.match(reimbursementService, /\/budget-analysis/)
assert.match(budgetAnalysisComponent, /预算分析/)
assert.match(budgetAnalysisComponent, /当前预算额度/)
assert.match(budgetAnalysisComponent, /此次费用占预算/)
assert.match(budgetAnalysisComponent, /综合评分/)
assert.match(budgetAnalysisComponent, /fetchExpenseClaimBudgetAnalysis/)
}) })

View File

@@ -684,9 +684,12 @@ test('return reason dialog is wired into approval and detail return actions', ()
assert.match(returnReasonDialog, /application_budget_basis_missing/) assert.match(returnReasonDialog, /application_budget_basis_missing/)
assert.match(returnReasonDialog, /application_policy_mismatch/) assert.match(returnReasonDialog, /application_policy_mismatch/)
assert.match(returnReasonDialog, /application_attachment_needed/) assert.match(returnReasonDialog, /application_attachment_needed/)
assert.match(returnReasonDialog, /application_other/)
assert.match(returnReasonDialog, /退单选项/) assert.match(returnReasonDialog, /退单选项/)
assert.match(returnReasonDialog, /selectionError/) assert.match(returnReasonDialog, /selectionError/)
assert.match(returnReasonDialog, /selectedCodes\.value\.length === 0/) assert.match(returnReasonDialog, /selectedApplicationCode/)
assert.match(returnReasonDialog, /application \? 'radio' : 'checkbox'/)
assert.match(returnReasonDialog, /selectedReasonCodes\.value\.length === 0/)
assert.match(returnReasonDialog, /lastAutoReason/) assert.match(returnReasonDialog, /lastAutoReason/)
assert.match(returnReasonDialog, /reason_codes/) assert.match(returnReasonDialog, /reason_codes/)
assert.match(approvalCenterTemplate, /<TravelRequestDetailView/) assert.match(approvalCenterTemplate, /<TravelRequestDetailView/)