feat: 新增预算费控模型与报销审批流引擎
后端新增预算费控服务和报销单审批流模块,引入申请人费用画像 算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常 量和明细同步,更新差旅报销规则电子表格,前端新增预算分析 组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧 边栏和顶栏样式,补充单元测试。
This commit is contained in:
1472
document/development/budget-expense-control-model-plan/index.html
Normal file
1472
document/development/budget-expense-control-model-plan/index.html
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
21
server/src/app/algorithem/README.md
Normal file
21
server/src/app/algorithem/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# X-Financial 核心算法推演目录
|
||||||
|
|
||||||
|
> 目录名 `algorithem` 沿用当前任务指定拼写。该目录用于沉淀核心算法推演、公式口径和可审计实现,避免把算法细节直接堆进 `services`。
|
||||||
|
|
||||||
|
## 目录职责
|
||||||
|
|
||||||
|
- 保存预算、费用、风控、知识检索等核心算法的推演文档。
|
||||||
|
- 记录公式、权重、阈值、输入输出协议和边界案例。
|
||||||
|
- 为后续 Python 实现、单元测试和接口协议提供依据。
|
||||||
|
|
||||||
|
## 当前算法主题
|
||||||
|
|
||||||
|
- `applicant_expense_profile_formula.md`:申请人费用画像与审核建议量化公式。
|
||||||
|
- `applicant_expense_profile.py`:申请人费用画像评分的第一版纯算法实现。
|
||||||
|
|
||||||
|
## 落地原则
|
||||||
|
|
||||||
|
- 算法先有可解释公式,再进入业务服务实现。
|
||||||
|
- 硬规则、评分权重和自然语言解释要分层。
|
||||||
|
- 所有核心算法模块都要遵守 800 行上限,按职责拆分。
|
||||||
|
- 涉及审批建议时,输出“依据 + 建议动作”,不要直接给人贴负面标签。
|
||||||
13
server/src/app/algorithem/__init__.py
Normal file
13
server/src/app/algorithem/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
445
server/src/app/algorithem/applicant_expense_profile.py
Normal file
445
server/src/app/algorithem/applicant_expense_profile.py
Normal 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)))
|
||||||
224
server/src/app/algorithem/applicant_expense_profile_formula.md
Normal file
224
server/src/app/algorithem/applicant_expense_profile_formula.md
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
194
server/src/app/services/budget_expense_control.py
Normal file
194
server/src/app/services/budget_expense_control.py
Normal 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'))}"
|
||||||
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
173
server/src/app/services/expense_claim_approval_flow.py
Normal file
173
server/src/app/services/expense_claim_approval_flow.py
Normal 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 ""
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
DIRECT_MANAGER_APPROVAL_STAGE = "直属领导审批"
|
||||||
|
BUDGET_MANAGER_APPROVAL_STAGE = "预算管理者审批"
|
||||||
|
FINANCE_APPROVAL_STAGE = "财务审批"
|
||||||
|
APPROVAL_DONE_STAGE = "审批完成"
|
||||||
|
ARCHIVE_ACCOUNTING_STAGE = "归档入账"
|
||||||
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
74
server/tests/test_applicant_expense_profile_algorithm.py
Normal file
74
server/tests/test_applicant_expense_profile_algorithm.py
Normal 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
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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] == []
|
||||||
|
|||||||
35
server/tests/test_knowledge_rag_runtime.py
Normal file
35
server/tests/test_knowledge_rag_runtime.py
Normal 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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
65
server/tests/test_knowledge_sync.py
Normal file
65
server/tests/test_knowledge_sync.py
Normal 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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
210
web/src/assets/styles/detail-page-corners.css
Normal file
210
web/src/assets/styles/detail-page-corners.css
Normal 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);
|
||||||
|
}
|
||||||
@@ -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%; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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: '' },
|
||||||
|
|||||||
293
web/src/components/travel/TravelRequestBudgetAnalysis.vue
Normal file
293
web/src/components/travel/TravelRequestBudgetAnalysis.vue
Normal 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>
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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('领导审批')
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 }">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
263
web/src/views/scripts/auditViewDigitalEmployeeModel.js
Normal file
263
web/src/views/scripts/auditViewDigitalEmployeeModel.js
Normal 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 || '-' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: '数字员工技能由资产状态和调度配置共同决定是否启动。'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: '李经理' }
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;/)
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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/)
|
||||||
|
|||||||
Reference in New Issue
Block a user