feat: 新增员工行为画像算法与费用风险标签体系
后端新增员工行为画像算法模块,支持标签规则引擎和评分计算, 完善员工模型、银行信息、序列化和导入逻辑,优化报销审批流 和工作流常量,增强 Hermes 同步和知识同步能力,前端新增费 用画像详情弹窗、雷达图和风险卡片组件,完善登录页和工作台 样式,优化文档中心和归档中心交互,补充单元测试。
This commit is contained in:
@@ -37,7 +37,17 @@ def init_default_config():
|
||||
cron_expression="0 9 * * 1", # 每周一早9点(在简化版中暂时代表周报频率)
|
||||
is_enabled=True
|
||||
))
|
||||
|
||||
|
||||
# 初始化 employee_behavior_profile_scan:默认关闭,避免全员画像过频。
|
||||
existing_profile = db.query(HermesTaskConfig).filter_by(task_type="employee_behavior_profile_scan").first()
|
||||
if not existing_profile:
|
||||
logger.info("No employee_behavior_profile_scan config found. Initializing default config.")
|
||||
db.add(HermesTaskConfig(
|
||||
task_type="employee_behavior_profile_scan",
|
||||
cron_expression="0 8 * * 1",
|
||||
is_enabled=False
|
||||
))
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize default config: {e}")
|
||||
|
||||
@@ -5,9 +5,34 @@ from .applicant_expense_profile import (
|
||||
ApplicantExpenseProfileResult,
|
||||
evaluate_applicant_expense_profile,
|
||||
)
|
||||
from .employee_behavior_profile import (
|
||||
ALGORITHM_VERSION as EMPLOYEE_BEHAVIOR_PROFILE_ALGORITHM_VERSION,
|
||||
ProfileComponent,
|
||||
ProfileScoreResult,
|
||||
build_review_suggestions,
|
||||
calculate_review_priority_score,
|
||||
evaluate_weighted_profile,
|
||||
level_from_score as employee_profile_level_from_score,
|
||||
normalize_by_peer_percentiles,
|
||||
percentile,
|
||||
score_by_bands,
|
||||
)
|
||||
from .employee_behavior_profile_tags import build_profile_radar, build_profile_tags
|
||||
|
||||
__all__ = [
|
||||
"ApplicantExpenseProfileInput",
|
||||
"ApplicantExpenseProfileResult",
|
||||
"EMPLOYEE_BEHAVIOR_PROFILE_ALGORITHM_VERSION",
|
||||
"ProfileComponent",
|
||||
"ProfileScoreResult",
|
||||
"build_review_suggestions",
|
||||
"build_profile_radar",
|
||||
"build_profile_tags",
|
||||
"calculate_review_priority_score",
|
||||
"evaluate_applicant_expense_profile",
|
||||
"evaluate_weighted_profile",
|
||||
"employee_profile_level_from_score",
|
||||
"normalize_by_peer_percentiles",
|
||||
"percentile",
|
||||
"score_by_bands",
|
||||
]
|
||||
|
||||
345
server/src/app/algorithem/employee_behavior_profile.py
Normal file
345
server/src/app/algorithem/employee_behavior_profile.py
Normal file
@@ -0,0 +1,345 @@
|
||||
"""Employee behavior profile scoring algorithms.
|
||||
|
||||
This module is deliberately pure: database services prepare metrics, while
|
||||
the formula layer owns normalization, score composition, levels, and advice.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
ALGORITHM_VERSION = "employee_behavior_profile.v1"
|
||||
|
||||
LEVEL_NORMAL = "normal"
|
||||
LEVEL_WATCH = "watch"
|
||||
LEVEL_REVIEW = "review"
|
||||
LEVEL_ESCALATION = "escalation"
|
||||
|
||||
PROFILE_LABELS = {
|
||||
"expense": "费用支出画像",
|
||||
"process_quality": "流程质量画像",
|
||||
"ai_usage": "AI 协作强度",
|
||||
"approval": "审批行为画像",
|
||||
}
|
||||
|
||||
LEVEL_LABELS = {
|
||||
LEVEL_NORMAL: "正常",
|
||||
LEVEL_WATCH: "关注",
|
||||
LEVEL_REVIEW: "复核",
|
||||
LEVEL_ESCALATION: "升级关注",
|
||||
}
|
||||
|
||||
ZERO = Decimal("0")
|
||||
ONE = Decimal("1")
|
||||
HUNDRED = Decimal("100")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ProfileComponent:
|
||||
code: str
|
||||
label: str
|
||||
score: int
|
||||
value: Any = None
|
||||
unit: str = ""
|
||||
weight: Decimal = Decimal("0")
|
||||
detail: str = ""
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"code": self.code,
|
||||
"label": self.label,
|
||||
"score": _clamp_score(self.score),
|
||||
"value": _format_value(self.value),
|
||||
"unit": self.unit,
|
||||
"weight": _format_decimal(self.weight),
|
||||
"detail": self.detail,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ProfileScoreResult:
|
||||
profile_type: str
|
||||
profile_score: int
|
||||
profile_level: str
|
||||
components: list[ProfileComponent] = field(default_factory=list)
|
||||
metrics: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def profile_label(self) -> str:
|
||||
return PROFILE_LABELS.get(self.profile_type, self.profile_type)
|
||||
|
||||
@property
|
||||
def profile_level_label(self) -> str:
|
||||
return LEVEL_LABELS.get(self.profile_level, self.profile_level)
|
||||
|
||||
def top_contributors(self, limit: int = 5) -> list[dict[str, Any]]:
|
||||
ranked = sorted(
|
||||
self.components,
|
||||
key=lambda item: (Decimal(_clamp_score(item.score)) * item.weight, item.score),
|
||||
reverse=True,
|
||||
)
|
||||
return [item.as_dict() for item in ranked[: max(0, limit)] if item.score > 0]
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"profile_type": self.profile_type,
|
||||
"profile_label": self.profile_label,
|
||||
"profile_score": self.profile_score,
|
||||
"profile_level": self.profile_level,
|
||||
"profile_level_label": self.profile_level_label,
|
||||
"components": [item.as_dict() for item in self.components],
|
||||
"top_contributors": self.top_contributors(),
|
||||
"metrics": _json_safe(self.metrics),
|
||||
}
|
||||
|
||||
|
||||
def normalize_by_peer_percentiles(value: Any, p50: Any, p90: Any) -> int:
|
||||
"""Map a metric to 0-100 with peer P50 as zero and peer P90 as full score."""
|
||||
|
||||
current = _to_decimal(value)
|
||||
median = _to_decimal(p50)
|
||||
high = _to_decimal(p90)
|
||||
if current <= median or high <= median:
|
||||
return 0
|
||||
raw_score = HUNDRED * (current - median) / (high - median)
|
||||
return _clamp_score(raw_score)
|
||||
|
||||
|
||||
def score_by_bands(value: Any, bands: list[tuple[Any, int]]) -> int:
|
||||
"""Piecewise linear score where each tuple is a threshold and score."""
|
||||
|
||||
normalized = _to_decimal(value)
|
||||
if not bands:
|
||||
return 0
|
||||
|
||||
points = [(_to_decimal(threshold), _clamp_score(score)) for threshold, score in bands]
|
||||
points.sort(key=lambda item: item[0])
|
||||
|
||||
if normalized <= points[0][0]:
|
||||
return points[0][1]
|
||||
|
||||
for index in range(1, len(points)):
|
||||
previous_threshold, previous_score = points[index - 1]
|
||||
next_threshold, next_score = points[index]
|
||||
if normalized > next_threshold:
|
||||
continue
|
||||
if next_threshold == previous_threshold:
|
||||
return next_score
|
||||
ratio = (normalized - previous_threshold) / (next_threshold - previous_threshold)
|
||||
interpolated = Decimal(previous_score) + ratio * Decimal(next_score - previous_score)
|
||||
return _clamp_score(interpolated)
|
||||
|
||||
return points[-1][1]
|
||||
|
||||
|
||||
def evaluate_weighted_profile(
|
||||
profile_type: str,
|
||||
components: list[ProfileComponent],
|
||||
metrics: dict[str, Any] | None = None,
|
||||
) -> ProfileScoreResult:
|
||||
total_weight = sum((_to_decimal(item.weight) for item in components), ZERO)
|
||||
if total_weight <= ZERO:
|
||||
profile_score = max((_clamp_score(item.score) for item in components), default=0)
|
||||
else:
|
||||
weighted = (
|
||||
sum(Decimal(_clamp_score(item.score)) * _to_decimal(item.weight) for item in components)
|
||||
/ total_weight
|
||||
)
|
||||
profile_score = _clamp_score(weighted)
|
||||
|
||||
return ProfileScoreResult(
|
||||
profile_type=profile_type,
|
||||
profile_score=profile_score,
|
||||
profile_level=level_from_score(profile_score),
|
||||
components=components,
|
||||
metrics=metrics or {},
|
||||
)
|
||||
|
||||
|
||||
def calculate_review_priority_score(
|
||||
*,
|
||||
expense_profile_score: Any,
|
||||
process_quality_score: Any,
|
||||
) -> int:
|
||||
weighted = _to_decimal(expense_profile_score) * Decimal("0.70") + _to_decimal(
|
||||
process_quality_score
|
||||
) * Decimal("0.30")
|
||||
return _clamp_score(weighted)
|
||||
|
||||
|
||||
def build_review_suggestions(
|
||||
*,
|
||||
expense_profile_score: Any,
|
||||
process_quality_score: Any,
|
||||
requested_days: Any = None,
|
||||
peer_days_p75: Any = None,
|
||||
policy_limit: Any = None,
|
||||
peer_unit_amount_p75: Any = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
review_score = calculate_review_priority_score(
|
||||
expense_profile_score=expense_profile_score,
|
||||
process_quality_score=process_quality_score,
|
||||
)
|
||||
level = level_from_score(review_score)
|
||||
suggestions: list[dict[str, Any]] = []
|
||||
|
||||
if _to_decimal(requested_days) > ZERO and _to_decimal(peer_days_p75) > ZERO:
|
||||
suggested_days = min(
|
||||
_to_decimal(requested_days),
|
||||
_to_decimal(peer_days_p75) * _level_factor(level),
|
||||
)
|
||||
if suggested_days < _to_decimal(requested_days):
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "review_travel_days",
|
||||
"severity": _severity_from_level(level),
|
||||
"message": "建议复核出差天数和业务必要性。",
|
||||
"recommended_upper": _format_decimal(suggested_days),
|
||||
"unit": "天",
|
||||
}
|
||||
)
|
||||
|
||||
unit_amount_upper = _resolve_entertainment_unit_upper(
|
||||
level=level,
|
||||
policy_limit=policy_limit,
|
||||
peer_unit_amount_p75=peer_unit_amount_p75,
|
||||
)
|
||||
if unit_amount_upper is not None:
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "review_entertainment_unit_amount",
|
||||
"severity": _severity_from_level(level),
|
||||
"message": "建议复核业务招待人均金额和客户招待必要性。",
|
||||
"recommended_upper": _format_decimal(unit_amount_upper),
|
||||
"unit": "元/人",
|
||||
}
|
||||
)
|
||||
|
||||
if expense_profile_score and _to_decimal(expense_profile_score) >= Decimal("60"):
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "review_expense_pattern",
|
||||
"severity": _severity_from_level(level),
|
||||
"message": "申请人近期费用节奏高于同组基准,建议核对费用标准和预算占用。",
|
||||
}
|
||||
)
|
||||
|
||||
if process_quality_score and _to_decimal(process_quality_score) >= Decimal("60"):
|
||||
suggestions.append(
|
||||
{
|
||||
"type": "review_material_quality",
|
||||
"severity": "medium",
|
||||
"message": "申请人近期材料质量波动较高,建议重点核对附件、事由和票据一致性。",
|
||||
}
|
||||
)
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
def level_from_score(score: Any) -> 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 percentile(values: list[Any], percent: Any) -> Decimal:
|
||||
normalized_values = sorted(_to_decimal(item) for item in values if _to_decimal(item) >= ZERO)
|
||||
if not normalized_values:
|
||||
return ZERO
|
||||
if len(normalized_values) == 1:
|
||||
return normalized_values[0]
|
||||
|
||||
pct = max(ZERO, min(HUNDRED, _to_decimal(percent)))
|
||||
position = (Decimal(len(normalized_values) - 1) * pct) / HUNDRED
|
||||
lower_index = int(position.to_integral_value(rounding=ROUND_FLOOR))
|
||||
upper_index = int(position.to_integral_value(rounding=ROUND_CEILING))
|
||||
if lower_index == upper_index:
|
||||
return normalized_values[lower_index]
|
||||
|
||||
fraction = position - Decimal(lower_index)
|
||||
return (
|
||||
normalized_values[lower_index]
|
||||
+ (normalized_values[upper_index] - normalized_values[lower_index]) * fraction
|
||||
)
|
||||
|
||||
|
||||
def _resolve_entertainment_unit_upper(
|
||||
*,
|
||||
level: str,
|
||||
policy_limit: Any,
|
||||
peer_unit_amount_p75: Any,
|
||||
) -> Decimal | None:
|
||||
policy = _to_decimal(policy_limit)
|
||||
peer = _to_decimal(peer_unit_amount_p75)
|
||||
candidates = [item for item in (policy, peer * _level_factor(level)) if item > ZERO]
|
||||
if not candidates:
|
||||
return None
|
||||
return min(candidates)
|
||||
|
||||
|
||||
def _level_factor(level: str) -> Decimal:
|
||||
if level == LEVEL_ESCALATION:
|
||||
return Decimal("0.90")
|
||||
if level == LEVEL_REVIEW:
|
||||
return Decimal("1.00")
|
||||
if level == LEVEL_WATCH:
|
||||
return Decimal("1.10")
|
||||
return Decimal("1.20")
|
||||
|
||||
|
||||
def _severity_from_level(level: str) -> str:
|
||||
if level == LEVEL_ESCALATION:
|
||||
return "high"
|
||||
if level == LEVEL_REVIEW:
|
||||
return "medium"
|
||||
return "low"
|
||||
|
||||
|
||||
def _clamp_score(value: Any) -> int:
|
||||
try:
|
||||
normalized = _to_decimal(value)
|
||||
except InvalidOperation:
|
||||
return 0
|
||||
bounded = max(ZERO, min(HUNDRED, normalized))
|
||||
return int(bounded.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _to_decimal(value: Any) -> Decimal:
|
||||
if value is None:
|
||||
return ZERO
|
||||
if isinstance(value, Decimal):
|
||||
return value
|
||||
if isinstance(value, bool):
|
||||
return ONE if value else ZERO
|
||||
try:
|
||||
return Decimal(str(value).strip() or "0")
|
||||
except (InvalidOperation, ValueError):
|
||||
return ZERO
|
||||
|
||||
|
||||
def _format_decimal(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
decimal_value = _to_decimal(value)
|
||||
return str(decimal_value.quantize(Decimal("0.0001")).normalize())
|
||||
|
||||
|
||||
def _format_value(value: Any) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
return _format_decimal(value)
|
||||
if isinstance(value, dict):
|
||||
return {key: _format_value(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_format_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
return _format_value(value)
|
||||
812
server/src/app/algorithem/employee_behavior_profile_tag_rules.py
Normal file
812
server/src/app/algorithem/employee_behavior_profile_tag_rules.py
Normal file
@@ -0,0 +1,812 @@
|
||||
"""Rule definitions for employee behavior profile tags."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
PROFILE_TAG_ALGORITHM_VERSION = "employee_behavior_profile_tags.v1"
|
||||
|
||||
|
||||
def append_expense_tags(tags: list[dict[str, Any]], index: dict[str, Mapping[str, Any]]) -> None:
|
||||
expense = index.get("expense")
|
||||
process = index.get("process_quality")
|
||||
if not expense:
|
||||
return
|
||||
metrics = metrics_of(expense)
|
||||
amount_share = number(metrics.get("amount_share"))
|
||||
amount_total = number(metrics.get("amount_total"))
|
||||
claim_count = number(metrics.get("claim_count"))
|
||||
current_amount = number(metrics.get("current_claim_amount"))
|
||||
return_count = number(metrics_of(process).get("return_count")) if process else 0
|
||||
|
||||
add_tag(
|
||||
tags,
|
||||
"expense_king",
|
||||
"费用之王",
|
||||
"费用集中度高",
|
||||
"expense",
|
||||
"risk",
|
||||
max(
|
||||
component(index, "expense", "amount_occupancy_score") / 100,
|
||||
band(amount_share, 0.15, 0.45),
|
||||
),
|
||||
(
|
||||
f"近{int(metrics.get('window_days') or 90)}天费用占比达到"
|
||||
f"{percent(amount_share)},费用总额为{money(amount_total)}。"
|
||||
),
|
||||
[
|
||||
evidence("amount_share", amount_share, threshold=0.30, unit="比例"),
|
||||
evidence("amount_total", amount_total, unit="元"),
|
||||
],
|
||||
["expense_intensity"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
add_tag(
|
||||
tags,
|
||||
"high_frequency_applicant",
|
||||
"高频申请人",
|
||||
"申请频次高",
|
||||
"expense",
|
||||
"behavior",
|
||||
max(component(index, "expense", "frequency_score") / 100, band(claim_count, 3, 8)),
|
||||
f"窗口期内累计提交{int(claim_count)}笔费用申请。",
|
||||
[evidence("claim_count", claim_count, threshold=3, unit="次")],
|
||||
["application_rhythm"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
avg_amount = amount_total / claim_count if claim_count > 0 else 0
|
||||
add_tag(
|
||||
tags,
|
||||
"micro_high_frequency",
|
||||
"小额高频",
|
||||
"小额高频",
|
||||
"expense",
|
||||
"behavior",
|
||||
min(band(claim_count, 3, 8), band(3000 - avg_amount, 0, 2500)),
|
||||
f"窗口期内申请{int(claim_count)}笔,单笔均额约{money(avg_amount)}。",
|
||||
[evidence("avg_amount", avg_amount, threshold=3000, unit="元")],
|
||||
["application_rhythm"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
add_tag(
|
||||
tags,
|
||||
"large_amount_deviation",
|
||||
"大额偏离者",
|
||||
"当前金额偏高",
|
||||
"expense",
|
||||
"risk",
|
||||
max(
|
||||
component(index, "expense", "current_claim_deviation_score") / 100,
|
||||
component(index, "expense", "peer_deviation_score") / 100,
|
||||
band(current_amount, 3000, 10000),
|
||||
),
|
||||
f"当前单据金额{money(current_amount)},已形成明显金额偏离。",
|
||||
[evidence("current_claim_amount", current_amount, unit="元")],
|
||||
["expense_intensity"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
add_tag_from_metric(
|
||||
tags,
|
||||
metrics,
|
||||
"budget_sprint",
|
||||
"预算冲刺型",
|
||||
"近期费用集中",
|
||||
"expense",
|
||||
"risk",
|
||||
"amount_30_to_90_ratio",
|
||||
0.55,
|
||||
0.85,
|
||||
["expense_intensity"],
|
||||
)
|
||||
if amount_total > 0 and claim_count >= 1 and return_count == 0:
|
||||
add_tag(
|
||||
tags,
|
||||
"cost_controlled",
|
||||
"成本克制型",
|
||||
"成本克制",
|
||||
"expense",
|
||||
"positive",
|
||||
min(band(60 - score_of(expense), 0, 50), 1),
|
||||
"窗口期内费用画像较低且没有退单记录。",
|
||||
[evidence("profile_score", score_of(expense), threshold=40, unit="分")],
|
||||
["expense_intensity"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
add_tag(
|
||||
tags,
|
||||
"adjustment_frequent",
|
||||
"调减高发",
|
||||
"历史调减较多",
|
||||
"expense",
|
||||
"risk",
|
||||
max(
|
||||
component(index, "expense", "adjustment_history_score") / 100,
|
||||
band(return_count, 1, 4),
|
||||
),
|
||||
f"窗口期内退回或调减相关记录约{int(return_count)}次。",
|
||||
[evidence("return_count", return_count, threshold=2, unit="次")],
|
||||
["process_pressure"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
add_tag_from_metric(
|
||||
tags,
|
||||
metrics,
|
||||
"expense_type_wide",
|
||||
"费用类型跨度大",
|
||||
"费用类型分散",
|
||||
"expense",
|
||||
"behavior",
|
||||
"expense_type_entropy",
|
||||
0.60,
|
||||
1.00,
|
||||
["application_rhythm"],
|
||||
)
|
||||
|
||||
|
||||
def append_travel_entertainment_tags(
|
||||
tags: list[dict[str, Any]], index: dict[str, Mapping[str, Any]]
|
||||
) -> None:
|
||||
expense = index.get("expense")
|
||||
if not expense:
|
||||
return
|
||||
metrics = metrics_of(expense)
|
||||
scope = str(metrics.get("expense_type_scope") or "")
|
||||
requested_days = number(metrics.get("requested_days"))
|
||||
peer_days_p75 = number(metrics.get("peer_days_p75"))
|
||||
amount_total = number(metrics.get("amount_total"))
|
||||
claim_count = number(metrics.get("claim_count"))
|
||||
|
||||
if peer_days_p75 > 0:
|
||||
add_tag(
|
||||
tags,
|
||||
"long_trip_master",
|
||||
"长差达人",
|
||||
"出差天数偏长",
|
||||
"travel",
|
||||
"risk",
|
||||
band(requested_days / peer_days_p75, 1.2, 1.8),
|
||||
f"当前出差天数为{format_number(requested_days)}天,同组P75约{format_number(peer_days_p75)}天。",
|
||||
[
|
||||
evidence("requested_days", requested_days, unit="天"),
|
||||
evidence("peer_days_p75", peer_days_p75, unit="天"),
|
||||
],
|
||||
["travel_entertainment"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
if scope in {"travel", "overall"}:
|
||||
add_tag(
|
||||
tags,
|
||||
"travel_frequent",
|
||||
"出差高频客",
|
||||
"出差频次高",
|
||||
"travel",
|
||||
"behavior",
|
||||
max(component(index, "expense", "frequency_score") / 100, band(claim_count, 3, 8)),
|
||||
f"窗口期内差旅相关申请{int(claim_count)}笔。",
|
||||
[evidence("travel_claim_count", claim_count, threshold=3, unit="次")],
|
||||
["travel_entertainment"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
daily_amount = amount_total / requested_days if requested_days > 0 else 0
|
||||
add_tag(
|
||||
tags,
|
||||
"travel_daily_high",
|
||||
"差旅日均偏高",
|
||||
"差旅日均偏高",
|
||||
"travel",
|
||||
"risk",
|
||||
min(
|
||||
component(index, "expense", "peer_deviation_score") / 100,
|
||||
band(daily_amount, 1000, 3000),
|
||||
),
|
||||
f"差旅日均金额约{money(daily_amount)}。",
|
||||
[evidence("travel_daily_amount", daily_amount, unit="元/天")],
|
||||
["travel_entertainment"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
add_tag_from_metric(
|
||||
tags,
|
||||
metrics,
|
||||
"hotel_high_standard",
|
||||
"住宿标准偏高",
|
||||
"住宿单价偏高",
|
||||
"travel",
|
||||
"risk",
|
||||
"hotel_nightly_amount",
|
||||
number(metrics.get("peer_hotel_nightly_p75")),
|
||||
number(metrics.get("peer_hotel_nightly_p90")),
|
||||
["travel_entertainment"],
|
||||
)
|
||||
add_tag_from_metric(
|
||||
tags,
|
||||
metrics,
|
||||
"transport_high_cost",
|
||||
"交通成本偏高",
|
||||
"交通成本偏高",
|
||||
"travel",
|
||||
"risk",
|
||||
"transport_daily_amount",
|
||||
number(metrics.get("peer_transport_daily_p75")),
|
||||
number(metrics.get("peer_transport_daily_p90")),
|
||||
["travel_entertainment"],
|
||||
)
|
||||
if scope in {"entertainment", "meal", "overall"}:
|
||||
add_tag(
|
||||
tags,
|
||||
"entertainment_active",
|
||||
"招待活跃户",
|
||||
"招待频次高",
|
||||
"entertainment",
|
||||
"behavior",
|
||||
max(component(index, "expense", "frequency_score") / 100, band(claim_count, 2, 6)),
|
||||
f"窗口期内招待相关申请{int(claim_count)}笔。",
|
||||
[evidence("entertainment_count", claim_count, threshold=2, unit="次")],
|
||||
["travel_entertainment"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
unit_amount = number(metrics.get("entertainment_unit_amount"))
|
||||
peer_unit_p75 = number(metrics.get("peer_unit_amount_p75"))
|
||||
if unit_amount > 0 or peer_unit_p75 > 0:
|
||||
add_tag(
|
||||
tags,
|
||||
"entertainment_unit_high",
|
||||
"人均招待偏高",
|
||||
"人均招待偏高",
|
||||
"entertainment",
|
||||
"risk",
|
||||
band(unit_amount / peer_unit_p75, 1.0, 1.6) if peer_unit_p75 > 0 else 0,
|
||||
f"招待人均金额约{money(unit_amount)},同组P75约{money(peer_unit_p75)}。",
|
||||
[
|
||||
evidence("entertainment_unit_amount", unit_amount, unit="元/人"),
|
||||
evidence("peer_unit_amount_p75", peer_unit_p75, unit="元/人"),
|
||||
],
|
||||
["travel_entertainment"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
add_tag_from_metric(
|
||||
tags,
|
||||
metrics,
|
||||
"repeat_client_host",
|
||||
"重复客户招待高",
|
||||
"同客户招待集中",
|
||||
"entertainment",
|
||||
"behavior",
|
||||
"max_client_entertainment_count",
|
||||
3,
|
||||
6,
|
||||
["travel_entertainment"],
|
||||
)
|
||||
add_tag_from_metric(
|
||||
tags,
|
||||
metrics,
|
||||
"holiday_expense_active",
|
||||
"节假日费用活跃",
|
||||
"节假日费用活跃",
|
||||
"expense",
|
||||
"behavior",
|
||||
"holiday_claim_ratio",
|
||||
0.25,
|
||||
0.60,
|
||||
["application_rhythm"],
|
||||
)
|
||||
|
||||
|
||||
def append_process_tags(tags: list[dict[str, Any]], index: dict[str, Mapping[str, Any]]) -> None:
|
||||
process = index.get("process_quality")
|
||||
if not process:
|
||||
return
|
||||
metrics = metrics_of(process)
|
||||
return_count = number(metrics.get("return_count"))
|
||||
missing_attachment = number(metrics.get("missing_attachment_count"))
|
||||
mismatch_count = number(metrics.get("invoice_mismatch_count"))
|
||||
missing_context = number(metrics.get("missing_business_context_count"))
|
||||
|
||||
add_tag(
|
||||
tags,
|
||||
"return_frequent",
|
||||
"退单常客",
|
||||
"退单频次高",
|
||||
"process",
|
||||
"risk",
|
||||
max(
|
||||
component(index, "process_quality", "return_count_score") / 100,
|
||||
band(return_count, 1, 4),
|
||||
),
|
||||
f"窗口期内退单或退回相关记录约{int(return_count)}次。",
|
||||
[evidence("return_count", return_count, threshold=2, unit="次")],
|
||||
["process_pressure"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
add_tag(
|
||||
tags,
|
||||
"material_patch",
|
||||
"材料补丁户",
|
||||
"材料补充较多",
|
||||
"process",
|
||||
"risk",
|
||||
max(
|
||||
component(index, "process_quality", "missing_attachment_score") / 100,
|
||||
band(missing_attachment + missing_context, 2, 5),
|
||||
),
|
||||
f"附件和业务上下文缺失累计{int(missing_attachment + missing_context)}项。",
|
||||
[
|
||||
evidence(
|
||||
"missing_material_count",
|
||||
missing_attachment + missing_context,
|
||||
threshold=3,
|
||||
unit="项",
|
||||
)
|
||||
],
|
||||
["material_completeness"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
add_tag(
|
||||
tags,
|
||||
"invoice_unstable",
|
||||
"票据不稳",
|
||||
"票据一致性弱",
|
||||
"process",
|
||||
"risk",
|
||||
max(
|
||||
component(index, "process_quality", "invoice_mismatch_score") / 100,
|
||||
band(mismatch_count, 1, 3),
|
||||
),
|
||||
f"票据或明细金额不一致记录{int(mismatch_count)}次。",
|
||||
[evidence("invoice_mismatch_count", mismatch_count, threshold=1, unit="次")],
|
||||
["material_completeness"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
add_tag(
|
||||
tags,
|
||||
"reason_thin",
|
||||
"事由空心化",
|
||||
"事由说明偏弱",
|
||||
"process",
|
||||
"risk",
|
||||
max(
|
||||
component(index, "process_quality", "missing_business_context_score") / 100,
|
||||
band(missing_context, 2, 5),
|
||||
),
|
||||
f"业务事由、地点或项目等上下文缺失{int(missing_context)}项。",
|
||||
[evidence("missing_business_context_count", missing_context, threshold=3, unit="项")],
|
||||
["material_completeness"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
add_tag_from_metric(
|
||||
tags,
|
||||
metrics,
|
||||
"resubmit_slow",
|
||||
"补充材料慢",
|
||||
"补充响应偏慢",
|
||||
"process",
|
||||
"risk",
|
||||
"avg_resubmit_hours",
|
||||
number(metrics.get("peer_resubmit_hours_p75")),
|
||||
number(metrics.get("peer_resubmit_hours_p90")),
|
||||
["process_pressure"],
|
||||
)
|
||||
add_tag_from_metric(
|
||||
tags,
|
||||
metrics,
|
||||
"repeat_issue",
|
||||
"重复问题未改善",
|
||||
"同类问题反复",
|
||||
"process",
|
||||
"risk",
|
||||
"same_issue_repeat_count",
|
||||
2,
|
||||
4,
|
||||
["process_pressure"],
|
||||
)
|
||||
if (
|
||||
score_of(process) < 40
|
||||
and return_count == 0
|
||||
and missing_attachment == 0
|
||||
and mismatch_count == 0
|
||||
):
|
||||
add_tag(
|
||||
tags,
|
||||
"clean_first_pass",
|
||||
"材料清爽",
|
||||
"一次通过质量好",
|
||||
"process",
|
||||
"positive",
|
||||
band(40 - score_of(process), 0, 40),
|
||||
"窗口期内未发现退单、附件缺失或票据金额不一致。",
|
||||
[evidence("process_quality_score", score_of(process), threshold=40, unit="分")],
|
||||
["material_completeness"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
add_tag_from_metric(
|
||||
tags,
|
||||
metrics,
|
||||
"large_return_amount",
|
||||
"高额退回",
|
||||
"退回金额偏高",
|
||||
"process",
|
||||
"risk",
|
||||
"returned_amount_ratio",
|
||||
0.20,
|
||||
0.50,
|
||||
["process_pressure"],
|
||||
)
|
||||
|
||||
|
||||
def append_ai_tags(tags: list[dict[str, Any]], index: dict[str, Mapping[str, Any]]) -> None:
|
||||
ai_profile = index.get("ai_usage")
|
||||
process = index.get("process_quality")
|
||||
if not ai_profile:
|
||||
return
|
||||
metrics = metrics_of(ai_profile)
|
||||
ai_runs = number(metrics.get("ai_run_count"))
|
||||
estimated_tokens = number(metrics.get("estimated_token_count"))
|
||||
exact_tokens = number(metrics.get("exact_token_count"))
|
||||
token_count = exact_tokens or estimated_tokens
|
||||
failed_calls = number(metrics.get("failed_tool_call_count"))
|
||||
tool_calls = max(number(metrics.get("tool_call_count")), 1)
|
||||
process_score = score_of(process)
|
||||
|
||||
add_tag(
|
||||
tags,
|
||||
"ai_heavy",
|
||||
"AI 重度用户",
|
||||
"AI 使用频繁",
|
||||
"ai",
|
||||
"behavior",
|
||||
max(component(index, "ai_usage", "ai_call_count_score") / 100, band(ai_runs, 3, 20)),
|
||||
f"窗口期内 AI 调用{int(ai_runs)}次。",
|
||||
[evidence("ai_run_count", ai_runs, threshold=10, unit="次")],
|
||||
["ai_collaboration"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
add_tag(
|
||||
tags,
|
||||
"token_high",
|
||||
"Token 高耗用户",
|
||||
"Token 消耗较高",
|
||||
"ai",
|
||||
"behavior",
|
||||
max(component(index, "ai_usage", "token_cost_score") / 100, band(token_count, 8000, 20000)),
|
||||
(
|
||||
f"窗口期内 Token 口径为{metrics.get('token_count_mode') or 'unknown'},"
|
||||
f"数量约{int(token_count)}。"
|
||||
),
|
||||
[evidence("token_count", token_count, threshold=8000, unit="tokens")],
|
||||
["ai_collaboration"],
|
||||
data_quality=0.75 if estimated_tokens and not exact_tokens else data_quality(metrics),
|
||||
)
|
||||
if ai_runs >= 3:
|
||||
add_tag(
|
||||
tags,
|
||||
"ai_effective",
|
||||
"AI 高效协作者",
|
||||
"AI 协作有效",
|
||||
"ai",
|
||||
"positive",
|
||||
min(band(ai_runs, 3, 12), band(60 - process_score, 0, 40)),
|
||||
"AI 使用较活跃,且流程质量画像保持较低关注。",
|
||||
[evidence("process_quality_score", process_score, threshold=40, unit="分")],
|
||||
["ai_collaboration"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
add_tag(
|
||||
tags,
|
||||
"ai_dependency_unimproved",
|
||||
"AI 依赖未改善",
|
||||
"AI 使用高但质量未改善",
|
||||
"ai",
|
||||
"risk",
|
||||
min(band(ai_runs, 3, 12), band(process_score, 60, 100)),
|
||||
"AI 使用较活跃,但流程质量画像仍然偏高。",
|
||||
[evidence("process_quality_score", process_score, threshold=60, unit="分")],
|
||||
["ai_collaboration"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
add_tag(
|
||||
tags,
|
||||
"ai_failure_cluster",
|
||||
"AI 调用失败集中",
|
||||
"AI 调用失败偏多",
|
||||
"ai",
|
||||
"risk",
|
||||
max(
|
||||
component(index, "ai_usage", "failed_ai_call_score") / 100,
|
||||
band(failed_calls / tool_calls, 0.20, 0.60),
|
||||
),
|
||||
f"工具调用失败{int(failed_calls)}次,失败率约{percent(failed_calls / tool_calls)}。",
|
||||
[evidence("failed_tool_call_rate", failed_calls / tool_calls, threshold=0.20, unit="比例")],
|
||||
["ai_collaboration"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
add_tag_from_metric(
|
||||
tags,
|
||||
metrics,
|
||||
"ai_override_frequent",
|
||||
"AI 建议常被覆盖",
|
||||
"AI 建议覆盖较多",
|
||||
"ai",
|
||||
"behavior",
|
||||
"ai_override_rate",
|
||||
0.40,
|
||||
0.80,
|
||||
["ai_collaboration"],
|
||||
)
|
||||
|
||||
|
||||
def append_approval_tags(tags: list[dict[str, Any]], index: dict[str, Mapping[str, Any]]) -> None:
|
||||
approval = index.get("approval")
|
||||
if not approval:
|
||||
return
|
||||
metrics = metrics_of(approval)
|
||||
record_count = number(metrics.get("approval_record_count"))
|
||||
direct_ratio = number(metrics.get("direct_approve_ratio"))
|
||||
return_count = number(metrics.get("return_count"))
|
||||
return_rate = return_count / record_count if record_count else 0
|
||||
|
||||
add_tag_from_metric(
|
||||
tags,
|
||||
metrics,
|
||||
"speed_reviewer",
|
||||
"急速审核员",
|
||||
"快速审核型",
|
||||
"approval",
|
||||
"behavior",
|
||||
"review_duration_speed_score",
|
||||
0.60,
|
||||
1.00,
|
||||
["approval_efficiency"],
|
||||
reason_prefix="平均审核时长处于较快区间",
|
||||
)
|
||||
add_tag(
|
||||
tags,
|
||||
"cautious_reviewer",
|
||||
"谨慎审核员",
|
||||
"谨慎审核型",
|
||||
"approval",
|
||||
"behavior",
|
||||
max(
|
||||
band(return_rate, 0.20, 0.60),
|
||||
component(index, "approval", "system_advice_override_score") / 100,
|
||||
),
|
||||
f"审批退回率约{percent(return_rate)}。",
|
||||
[evidence("return_rate", return_rate, threshold=0.20, unit="比例")],
|
||||
["approval_control"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
add_tag_from_metric(
|
||||
tags,
|
||||
metrics,
|
||||
"gatekeeper",
|
||||
"退回把关型",
|
||||
"退回把关强",
|
||||
"approval",
|
||||
"behavior",
|
||||
"high_risk_return_rate",
|
||||
0.30,
|
||||
0.70,
|
||||
["approval_control"],
|
||||
)
|
||||
add_tag_from_metric(
|
||||
tags,
|
||||
metrics,
|
||||
"high_risk_fast_pass",
|
||||
"高风险快通过",
|
||||
"高风险快通过",
|
||||
"approval",
|
||||
"risk",
|
||||
"high_risk_fast_pass_count",
|
||||
1,
|
||||
3,
|
||||
["approval_efficiency"],
|
||||
)
|
||||
add_tag_from_metric(
|
||||
tags,
|
||||
metrics,
|
||||
"sla_delayer",
|
||||
"SLA 拖延型",
|
||||
"审批超时偏多",
|
||||
"approval",
|
||||
"risk",
|
||||
"sla_overdue_rate",
|
||||
0.25,
|
||||
0.60,
|
||||
["approval_efficiency"],
|
||||
)
|
||||
if record_count >= 3 and 0.25 <= return_rate <= 0.75 and direct_ratio < 0.90:
|
||||
add_tag(
|
||||
tags,
|
||||
"steady_reviewer",
|
||||
"稳健审核员",
|
||||
"稳健审核型",
|
||||
"approval",
|
||||
"positive",
|
||||
0.80,
|
||||
"审批通过和退回节奏相对均衡,未发现高风险快通过记录。",
|
||||
[evidence("approval_record_count", record_count, threshold=3, unit="次")],
|
||||
["approval_control"],
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
|
||||
|
||||
def add_tag_from_metric(
|
||||
tags: list[dict[str, Any]],
|
||||
metrics: Mapping[str, Any],
|
||||
code: str,
|
||||
label: str,
|
||||
display_label: str,
|
||||
category: str,
|
||||
polarity: str,
|
||||
metric_key: str,
|
||||
low: float,
|
||||
high: float,
|
||||
radar_dimensions: list[str],
|
||||
*,
|
||||
reason_prefix: str | None = None,
|
||||
) -> None:
|
||||
value = number(metrics.get(metric_key))
|
||||
if value <= 0 or high <= low:
|
||||
return
|
||||
strength = band(value, low, high)
|
||||
add_tag(
|
||||
tags,
|
||||
code,
|
||||
label,
|
||||
display_label,
|
||||
category,
|
||||
polarity,
|
||||
strength,
|
||||
f"{reason_prefix or display_label},{metric_key}={format_number(value)}。",
|
||||
[evidence(metric_key, value, threshold=low)],
|
||||
radar_dimensions,
|
||||
data_quality=data_quality(metrics),
|
||||
)
|
||||
|
||||
|
||||
def add_tag(
|
||||
tags: list[dict[str, Any]],
|
||||
code: str,
|
||||
label: str,
|
||||
display_label: str,
|
||||
category: str,
|
||||
polarity: str,
|
||||
strength: float,
|
||||
reason: str,
|
||||
evidence_items: list[dict[str, Any]],
|
||||
radar_dimensions: list[str],
|
||||
*,
|
||||
consistency: float = 0.75,
|
||||
recency: float = 0.85,
|
||||
data_quality: float = 0.85,
|
||||
sample_reliability: float = 0.75,
|
||||
) -> None:
|
||||
normalized_strength = clamp01(strength)
|
||||
if normalized_strength <= 0:
|
||||
return
|
||||
tag_score = clamp_score(
|
||||
100 * (0.55 * normalized_strength + 0.25 * consistency + 0.20 * recency)
|
||||
)
|
||||
confidence = clamp01(
|
||||
data_quality * (0.65 * normalized_strength + 0.20 * sample_reliability + 0.15 * consistency)
|
||||
)
|
||||
tags.append(
|
||||
{
|
||||
"code": code,
|
||||
"label": label,
|
||||
"display_label": display_label,
|
||||
"category": category,
|
||||
"polarity": polarity,
|
||||
"score": tag_score,
|
||||
"confidence": round(confidence, 2),
|
||||
"reason": reason,
|
||||
"evidence": [item for item in evidence_items if item],
|
||||
"radar_dimensions": radar_dimensions,
|
||||
"algorithm_version": PROFILE_TAG_ALGORITHM_VERSION,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def profile_index(
|
||||
profiles: list[Mapping[str, Any]] | tuple[Mapping[str, Any], ...],
|
||||
) -> dict[str, Mapping[str, Any]]:
|
||||
return {
|
||||
str(profile.get("profile_type") or ""): profile
|
||||
for profile in profiles
|
||||
if str(profile.get("profile_type") or "")
|
||||
}
|
||||
|
||||
|
||||
def metrics_of(profile: Mapping[str, Any] | None) -> Mapping[str, Any]:
|
||||
if not profile:
|
||||
return {}
|
||||
value = profile.get("metrics")
|
||||
return value if isinstance(value, Mapping) else {}
|
||||
|
||||
|
||||
def score_of(profile: Mapping[str, Any] | None) -> int:
|
||||
return clamp_score(number(profile.get("score") if profile else 0))
|
||||
|
||||
|
||||
def component(index: dict[str, Mapping[str, Any]], profile_type: str, code: str) -> int:
|
||||
profile = index.get(profile_type)
|
||||
if not profile:
|
||||
return 0
|
||||
for item in profile.get("top_contributors") or []:
|
||||
if isinstance(item, Mapping) and item.get("code") == code:
|
||||
return clamp_score(number(item.get("score")))
|
||||
return 0
|
||||
|
||||
|
||||
def tag_score(tags: list[Mapping[str, Any]], code: str, *, invert: bool = False) -> int:
|
||||
score = max((int(tag.get("score") or 0) for tag in tags if tag.get("code") == code), default=0)
|
||||
return 100 - score if invert and score > 0 else score
|
||||
|
||||
|
||||
def data_quality(metrics: Mapping[str, Any]) -> float:
|
||||
sample_size = number(metrics.get("peer_sample_size"))
|
||||
sample_score = 0.60 if sample_size <= 0 else min(1, max(0.65, sample_size / 10))
|
||||
fallback_level = number(metrics.get("peer_group_fallback_level"))
|
||||
fallback_penalty = min(0.20, fallback_level * 0.05)
|
||||
return clamp01(sample_score - fallback_penalty)
|
||||
|
||||
|
||||
def scene_priority(tag: Mapping[str, Any], scene: str) -> int:
|
||||
if scene != "approval":
|
||||
return 1
|
||||
category = str(tag.get("category") or "")
|
||||
return 2 if category in {"expense", "travel", "entertainment", "process"} else 0
|
||||
|
||||
|
||||
def evidence(
|
||||
metric: str,
|
||||
value: Any,
|
||||
*,
|
||||
threshold: Any | None = None,
|
||||
unit: str = "",
|
||||
) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {
|
||||
"metric": metric,
|
||||
"value": format_number(number(value)),
|
||||
}
|
||||
if threshold is not None:
|
||||
result["threshold"] = format_number(number(threshold))
|
||||
if unit:
|
||||
result["unit"] = unit
|
||||
return result
|
||||
|
||||
|
||||
def band(value: Any, low: Any, high: Any) -> float:
|
||||
normalized = number(value)
|
||||
low_value = number(low)
|
||||
high_value = number(high)
|
||||
if high_value <= low_value:
|
||||
return 0
|
||||
return clamp01((normalized - low_value) / (high_value - low_value))
|
||||
|
||||
|
||||
def number(value: Any) -> float:
|
||||
try:
|
||||
return float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def clamp01(value: Any) -> float:
|
||||
return max(0, min(1, number(value)))
|
||||
|
||||
|
||||
def clamp_score(value: Any) -> int:
|
||||
return max(0, min(100, int(round(number(value)))))
|
||||
|
||||
|
||||
def percent(value: Any) -> str:
|
||||
return f"{round(number(value) * 100)}%"
|
||||
|
||||
|
||||
def money(value: Any) -> str:
|
||||
return f"{round(number(value), 2):g}元"
|
||||
|
||||
|
||||
def format_number(value: Any) -> str:
|
||||
normalized = number(value)
|
||||
return f"{normalized:.4f}".rstrip("0").rstrip(".")
|
||||
209
server/src/app/algorithem/employee_behavior_profile_tags.py
Normal file
209
server/src/app/algorithem/employee_behavior_profile_tags.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Employee behavior profile tags and radar scoring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from app.algorithem.employee_behavior_profile import LEVEL_LABELS, level_from_score
|
||||
from app.algorithem.employee_behavior_profile_tag_rules import (
|
||||
PROFILE_TAG_ALGORITHM_VERSION,
|
||||
append_ai_tags,
|
||||
append_approval_tags,
|
||||
append_expense_tags,
|
||||
append_process_tags,
|
||||
append_travel_entertainment_tags,
|
||||
clamp_score,
|
||||
component,
|
||||
number,
|
||||
profile_index,
|
||||
scene_priority,
|
||||
tag_score,
|
||||
)
|
||||
|
||||
APPROVAL_RADAR_CODES = {
|
||||
"expense_intensity",
|
||||
"application_rhythm",
|
||||
"travel_entertainment",
|
||||
"material_completeness",
|
||||
"process_pressure",
|
||||
}
|
||||
|
||||
RADAR_LABELS = {
|
||||
"expense_intensity": "费用强度",
|
||||
"application_rhythm": "申请节奏",
|
||||
"travel_entertainment": "差旅招待",
|
||||
"material_completeness": "材料完整度压力",
|
||||
"process_pressure": "流程压力",
|
||||
"ai_collaboration": "AI 协作强度",
|
||||
"approval_efficiency": "审批效率特征",
|
||||
"approval_control": "审批把关特征",
|
||||
}
|
||||
|
||||
|
||||
def build_profile_tags(
|
||||
profiles: Iterable[Mapping[str, Any]],
|
||||
*,
|
||||
scene: str = "approval",
|
||||
) -> list[dict[str, Any]]:
|
||||
payloads = list(profiles)
|
||||
index = profile_index(payloads)
|
||||
tags: list[dict[str, Any]] = []
|
||||
append_expense_tags(tags, index)
|
||||
append_travel_entertainment_tags(tags, index)
|
||||
append_process_tags(tags, index)
|
||||
append_ai_tags(tags, index)
|
||||
append_approval_tags(tags, index)
|
||||
|
||||
active_tags = [
|
||||
tag
|
||||
for tag in tags
|
||||
if int(tag["score"]) >= 60 and float(tag["confidence"]) >= 0.55
|
||||
]
|
||||
active_tags.sort(
|
||||
key=lambda item: (
|
||||
scene_priority(item, scene),
|
||||
float(item["confidence"]),
|
||||
int(item["score"]),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return active_tags[:12 if scene == "approval" else 24]
|
||||
|
||||
|
||||
def build_profile_radar(
|
||||
profiles: Iterable[Mapping[str, Any]],
|
||||
profile_tags: Iterable[Mapping[str, Any]],
|
||||
*,
|
||||
scene: str = "approval",
|
||||
) -> dict[str, Any]:
|
||||
payloads = list(profiles)
|
||||
index = profile_index(payloads)
|
||||
tags = list(profile_tags)
|
||||
dimensions = [
|
||||
_dimension(
|
||||
"expense_intensity",
|
||||
[
|
||||
component(index, "expense", "amount_occupancy_score"),
|
||||
component(index, "expense", "peer_deviation_score"),
|
||||
component(index, "expense", "current_claim_deviation_score"),
|
||||
tag_score(tags, "expense_king"),
|
||||
tag_score(tags, "large_amount_deviation"),
|
||||
],
|
||||
tags,
|
||||
),
|
||||
_dimension(
|
||||
"application_rhythm",
|
||||
[
|
||||
component(index, "expense", "frequency_score"),
|
||||
tag_score(tags, "high_frequency_applicant"),
|
||||
tag_score(tags, "micro_high_frequency"),
|
||||
tag_score(tags, "expense_type_wide"),
|
||||
tag_score(tags, "holiday_expense_active"),
|
||||
],
|
||||
tags,
|
||||
),
|
||||
_dimension(
|
||||
"travel_entertainment",
|
||||
[
|
||||
tag_score(tags, "long_trip_master"),
|
||||
tag_score(tags, "travel_frequent"),
|
||||
tag_score(tags, "travel_daily_high"),
|
||||
tag_score(tags, "hotel_high_standard"),
|
||||
tag_score(tags, "transport_high_cost"),
|
||||
tag_score(tags, "entertainment_active"),
|
||||
tag_score(tags, "entertainment_unit_high"),
|
||||
tag_score(tags, "repeat_client_host"),
|
||||
],
|
||||
tags,
|
||||
),
|
||||
_dimension(
|
||||
"material_completeness",
|
||||
[
|
||||
component(index, "process_quality", "missing_attachment_score"),
|
||||
component(index, "process_quality", "invoice_mismatch_score"),
|
||||
component(index, "process_quality", "missing_business_context_score"),
|
||||
tag_score(tags, "material_patch"),
|
||||
tag_score(tags, "invoice_unstable"),
|
||||
tag_score(tags, "reason_thin"),
|
||||
tag_score(tags, "clean_first_pass", invert=True),
|
||||
],
|
||||
tags,
|
||||
),
|
||||
_dimension(
|
||||
"process_pressure",
|
||||
[
|
||||
component(index, "process_quality", "return_count_score"),
|
||||
component(index, "process_quality", "resubmit_duration_score"),
|
||||
tag_score(tags, "return_frequent"),
|
||||
tag_score(tags, "adjustment_frequent"),
|
||||
tag_score(tags, "resubmit_slow"),
|
||||
tag_score(tags, "repeat_issue"),
|
||||
tag_score(tags, "large_return_amount"),
|
||||
],
|
||||
tags,
|
||||
),
|
||||
_dimension(
|
||||
"ai_collaboration",
|
||||
[
|
||||
component(index, "ai_usage", "ai_call_count_score"),
|
||||
component(index, "ai_usage", "token_cost_score"),
|
||||
component(index, "ai_usage", "failed_ai_call_score"),
|
||||
tag_score(tags, "ai_heavy"),
|
||||
tag_score(tags, "token_high"),
|
||||
tag_score(tags, "ai_dependency_unimproved"),
|
||||
],
|
||||
tags,
|
||||
),
|
||||
_dimension(
|
||||
"approval_efficiency",
|
||||
[
|
||||
component(index, "approval", "avg_review_duration_score"),
|
||||
component(index, "approval", "sla_overdue_score"),
|
||||
tag_score(tags, "speed_reviewer"),
|
||||
tag_score(tags, "high_risk_fast_pass"),
|
||||
tag_score(tags, "sla_delayer"),
|
||||
],
|
||||
tags,
|
||||
),
|
||||
_dimension(
|
||||
"approval_control",
|
||||
[
|
||||
component(index, "approval", "direct_approve_ratio_score"),
|
||||
component(index, "approval", "high_risk_approve_score"),
|
||||
component(index, "approval", "system_advice_override_score"),
|
||||
tag_score(tags, "cautious_reviewer"),
|
||||
tag_score(tags, "gatekeeper"),
|
||||
tag_score(tags, "steady_reviewer"),
|
||||
],
|
||||
tags,
|
||||
),
|
||||
]
|
||||
if scene == "approval":
|
||||
dimensions = [item for item in dimensions if item["code"] in APPROVAL_RADAR_CODES]
|
||||
return {
|
||||
"algorithm_version": PROFILE_TAG_ALGORITHM_VERSION,
|
||||
"dimensions": dimensions,
|
||||
}
|
||||
|
||||
|
||||
def _dimension(code: str, values: list[float], tags: list[Mapping[str, Any]]) -> dict[str, Any]:
|
||||
valid_values = [max(0, min(100, number(value))) for value in values if number(value) > 0]
|
||||
score = clamp_score(sum(valid_values) / len(valid_values)) if valid_values else 0
|
||||
top_tags = [
|
||||
str(tag.get("code"))
|
||||
for tag in sorted(
|
||||
[tag for tag in tags if code in (tag.get("radar_dimensions") or [])],
|
||||
key=lambda item: (int(item.get("score") or 0), float(item.get("confidence") or 0)),
|
||||
reverse=True,
|
||||
)[:3]
|
||||
]
|
||||
level = level_from_score(score)
|
||||
return {
|
||||
"code": code,
|
||||
"label": RADAR_LABELS.get(code, code),
|
||||
"score": score,
|
||||
"level": level,
|
||||
"level_label": LEVEL_LABELS.get(level, level),
|
||||
"top_tags": top_tags,
|
||||
}
|
||||
39
server/src/app/api/v1/endpoints/employee_profiles.py
Normal file
39
server/src/app/api/v1/endpoints/employee_profiles.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
||||
from app.schemas.employee_profile import EmployeeProfileLatestRead
|
||||
from app.services.employee_behavior_profile_service import EmployeeBehaviorProfileService
|
||||
|
||||
router = APIRouter(prefix="/employee-profiles")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{employee_id}/latest",
|
||||
response_model=EmployeeProfileLatestRead,
|
||||
summary="读取员工最新业务行为画像",
|
||||
description="返回员工在指定场景下的最新画像快照,审批场景默认只展示费用支出和流程质量画像。",
|
||||
)
|
||||
def get_employee_latest_profile(
|
||||
employee_id: str,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
scene: Annotated[str, Query(max_length=50)] = "approval",
|
||||
claim_id: Annotated[str | None, Query(max_length=80)] = None,
|
||||
window_days: Annotated[int, Query(ge=1, le=365)] = 90,
|
||||
expense_type_scope: Annotated[str, Query(max_length=50)] = "overall",
|
||||
) -> EmployeeProfileLatestRead:
|
||||
del current_user
|
||||
return EmployeeBehaviorProfileService(db).get_latest_profile(
|
||||
employee_id=employee_id,
|
||||
scene=scene,
|
||||
claim_id=claim_id,
|
||||
window_days=window_days,
|
||||
expense_type_scope=expense_type_scope,
|
||||
)
|
||||
@@ -601,6 +601,38 @@ def approve_expense_claim(
|
||||
return claim
|
||||
|
||||
|
||||
@router.post(
|
||||
"/claims/{claim_id}/pay",
|
||||
response_model=ExpenseClaimRead,
|
||||
summary="确认报销单已付款",
|
||||
description="财务人员或高级财务人员确认待付款报销单已完成付款。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
"description": "单据不存在。",
|
||||
},
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
"model": ErrorResponse,
|
||||
"description": "当前用户或单据状态不允许确认付款。",
|
||||
},
|
||||
},
|
||||
)
|
||||
def pay_expense_claim(
|
||||
claim_id: str,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> ExpenseClaimRead:
|
||||
service = ExpenseClaimService(db)
|
||||
try:
|
||||
claim = service.mark_claim_paid(claim_id, current_user)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
if claim is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||
return claim
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/claims/{claim_id}",
|
||||
response_model=ExpenseClaimActionResponse,
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.api.v1.endpoints.auth import router as auth_router
|
||||
from app.api.v1.endpoints.bootstrap import router as bootstrap_router
|
||||
from app.api.v1.endpoints.budgets import router as budgets_router
|
||||
from app.api.v1.endpoints.employees import router as employees_router
|
||||
from app.api.v1.endpoints.employee_profiles import router as employee_profiles_router
|
||||
from app.api.v1.endpoints.health import router as health_router
|
||||
from app.api.v1.endpoints.knowledge import router as knowledge_router
|
||||
from app.api.v1.endpoints.ocr import router as ocr_router
|
||||
@@ -29,6 +30,7 @@ router.include_router(ocr_router, tags=["ocr"])
|
||||
router.include_router(ontology_router, tags=["ontology"])
|
||||
router.include_router(orchestrator_router, tags=["orchestrator"])
|
||||
router.include_router(employees_router, prefix="/employees", tags=["employees"])
|
||||
router.include_router(employee_profiles_router, tags=["employee-profiles"])
|
||||
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
|
||||
router.include_router(settings_router, tags=["settings"])
|
||||
router.include_router(system_logs_router, tags=["system-logs"])
|
||||
|
||||
@@ -6,6 +6,7 @@ from app.models.approval import ApprovalRecord
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||
from app.models.employee_change_log import EmployeeChangeLog
|
||||
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import (
|
||||
AccountsPayableRecord,
|
||||
@@ -13,6 +14,8 @@ from app.models.financial_record import (
|
||||
ExpenseClaim,
|
||||
ExpenseClaimItem,
|
||||
)
|
||||
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
|
||||
from app.models.hermes_report import HermesRiskReport
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.reimbursement import ReimbursementRequest
|
||||
from app.models.role import Role
|
||||
@@ -38,9 +41,13 @@ __all__ = [
|
||||
"BudgetReservation",
|
||||
"BudgetTransaction",
|
||||
"Employee",
|
||||
"EmployeeBehaviorProfileSnapshot",
|
||||
"EmployeeChangeLog",
|
||||
"ExpenseClaim",
|
||||
"ExpenseClaimItem",
|
||||
"HermesTaskConfig",
|
||||
"HermesTaskExecutionLog",
|
||||
"HermesRiskReport",
|
||||
"OrganizationUnit",
|
||||
"ReimbursementRequest",
|
||||
"Role",
|
||||
|
||||
@@ -5,6 +5,7 @@ from app.models.approval import ApprovalRecord
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||
from app.models.employee_change_log import EmployeeChangeLog
|
||||
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import (
|
||||
AccountsPayableRecord,
|
||||
@@ -37,6 +38,7 @@ __all__ = [
|
||||
"BudgetReservation",
|
||||
"BudgetTransaction",
|
||||
"Employee",
|
||||
"EmployeeBehaviorProfileSnapshot",
|
||||
"EmployeeChangeLog",
|
||||
"ExpenseClaim",
|
||||
"ExpenseClaimItem",
|
||||
|
||||
@@ -32,6 +32,9 @@ class Employee(Base):
|
||||
grade: Mapped[str] = mapped_column(String(20), default="P3", index=True)
|
||||
cost_center: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
finance_owner_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
bank_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
bank_account_no: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
bank_account_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
employment_status: Mapped[str] = mapped_column(String(30), default="在职", index=True)
|
||||
sync_state: Mapped[str] = mapped_column(String(30), default="已同步")
|
||||
|
||||
62
server/src/app/models/employee_behavior_profile.py
Normal file
62
server/src/app/models/employee_behavior_profile.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class EmployeeBehaviorProfileSnapshot(Base):
|
||||
__tablename__ = "employee_behavior_profile_snapshots"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_employee_behavior_profile_latest",
|
||||
"subject_id",
|
||||
"profile_type",
|
||||
"window_days",
|
||||
"expense_type_scope",
|
||||
"calculated_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
subject_type: Mapped[str] = mapped_column(String(30), default="employee", index=True)
|
||||
subject_id: Mapped[str] = mapped_column(String(100), index=True)
|
||||
subject_name: Mapped[str] = mapped_column(String(100), index=True)
|
||||
department_id: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
department_name: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
|
||||
position: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
grade: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
|
||||
|
||||
profile_type: Mapped[str] = mapped_column(String(50), index=True)
|
||||
window_days: Mapped[int] = mapped_column(Integer, index=True)
|
||||
expense_type_scope: Mapped[str] = mapped_column(String(50), default="overall", index=True)
|
||||
peer_group_key: Mapped[str] = mapped_column(String(255), default="")
|
||||
peer_group_fallback_level: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
profile_score: Mapped[int] = mapped_column(Integer, default=0)
|
||||
profile_level: Mapped[str] = mapped_column(String(30), default="normal", index=True)
|
||||
metrics_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
basis_codes_json: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||
source_task_type: Mapped[str] = mapped_column(
|
||||
String(80), default="employee_behavior_profile_scan"
|
||||
)
|
||||
source_task_log_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("hermes_task_execution_logs.id"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
algorithm_version: Mapped[str] = mapped_column(
|
||||
String(80), default="employee_behavior_profile.v1"
|
||||
)
|
||||
calculated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
source_task_log = relationship("HermesTaskExecutionLog")
|
||||
@@ -78,6 +78,9 @@ class EmployeeRead(BaseModel):
|
||||
joinDate: str | None = None
|
||||
location: str | None = None
|
||||
costCenter: str | None = None
|
||||
bankName: str | None = None
|
||||
bankAccountNo: str | None = None
|
||||
bankAccountName: str | None = None
|
||||
updatedAt: str | None = None
|
||||
lastSync: str | None = None
|
||||
syncState: str
|
||||
@@ -100,6 +103,9 @@ class EmployeeCreate(BaseModel):
|
||||
grade: str = Field(default="P3", max_length=20)
|
||||
cost_center: str | None = Field(default=None, max_length=50)
|
||||
finance_owner_name: str | None = Field(default=None, max_length=100)
|
||||
bank_name: str | None = Field(default=None, max_length=120)
|
||||
bank_account_no: str | None = Field(default=None, max_length=80)
|
||||
bank_account_name: str | None = Field(default=None, max_length=100)
|
||||
employment_status: str = Field(default="在职", max_length=30)
|
||||
sync_state: str = Field(default="已同步", max_length=30)
|
||||
spotlight: bool = False
|
||||
@@ -148,6 +154,9 @@ class EmployeeUpdate(BaseModel):
|
||||
grade: str | None = Field(default=None, min_length=1, max_length=20)
|
||||
cost_center: str | None = Field(default=None, max_length=50)
|
||||
finance_owner_name: str | None = Field(default=None, max_length=100)
|
||||
bank_name: str | None = Field(default=None, max_length=120)
|
||||
bank_account_no: str | None = Field(default=None, max_length=80)
|
||||
bank_account_name: str | None = Field(default=None, max_length=100)
|
||||
organization_unit_code: str | None = Field(default=None, max_length=50)
|
||||
manager_employee_no: str | None = Field(default=None, max_length=50)
|
||||
role_codes: list[str] | None = None
|
||||
|
||||
68
server/src/app/schemas/employee_profile.py
Normal file
68
server/src/app/schemas/employee_profile.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EmployeeProfilePeerGroupRead(BaseModel):
|
||||
key: str = ""
|
||||
fallback_level: int = 0
|
||||
sample_size: int = 0
|
||||
|
||||
|
||||
class EmployeeProfileRead(BaseModel):
|
||||
profile_type: str
|
||||
profile_label: str
|
||||
score: int
|
||||
level: str
|
||||
level_label: str
|
||||
metrics: dict[str, Any] = Field(default_factory=dict)
|
||||
top_contributors: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EmployeeProfileTagRead(BaseModel):
|
||||
code: str
|
||||
label: str
|
||||
display_label: str
|
||||
category: str
|
||||
polarity: str = "behavior"
|
||||
score: int
|
||||
confidence: float
|
||||
reason: str = ""
|
||||
evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||
radar_dimensions: list[str] = Field(default_factory=list)
|
||||
algorithm_version: str = ""
|
||||
|
||||
|
||||
class EmployeeProfileRadarDimensionRead(BaseModel):
|
||||
code: str
|
||||
label: str
|
||||
score: int
|
||||
level: str
|
||||
level_label: str
|
||||
top_tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EmployeeProfileRadarRead(BaseModel):
|
||||
algorithm_version: str = ""
|
||||
dimensions: list[EmployeeProfileRadarDimensionRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EmployeeProfileLatestRead(BaseModel):
|
||||
employee_id: str
|
||||
employee_name: str = ""
|
||||
scene: str = "approval"
|
||||
window_days: int = 90
|
||||
expense_type_scope: str = "overall"
|
||||
calculated_at: datetime | None = None
|
||||
peer_group: EmployeeProfilePeerGroupRead = Field(default_factory=EmployeeProfilePeerGroupRead)
|
||||
review_priority_score: int = 0
|
||||
review_priority_level: str = "normal"
|
||||
review_priority_label: str = "正常"
|
||||
profiles: list[EmployeeProfileRead] = Field(default_factory=list)
|
||||
profile_tags: list[EmployeeProfileTagRead] = Field(default_factory=list)
|
||||
radar: EmployeeProfileRadarRead = Field(default_factory=EmployeeProfileRadarRead)
|
||||
review_suggestions: list[dict[str, Any]] = Field(default_factory=list)
|
||||
empty_reason: str = ""
|
||||
@@ -12,6 +12,7 @@ from app.core.agent_enums import (
|
||||
AgentName,
|
||||
AgentReviewStatus,
|
||||
)
|
||||
from app.core.config import SERVER_DIR
|
||||
from app.core.logging import get_logger
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
@@ -27,6 +28,7 @@ from app.services.agent_foundation_constants import (
|
||||
COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
|
||||
COMPANY_TRAVEL_RULE_VERSION,
|
||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_SKILL_CATEGORIES,
|
||||
DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP,
|
||||
)
|
||||
@@ -38,11 +40,41 @@ class AgentFoundationAssetSeedMixin:
|
||||
def _digital_employee_task_config(self, code: str, cron: str) -> dict[str, object]:
|
||||
return {
|
||||
"cron": cron,
|
||||
"schedule": cron,
|
||||
"cron_expression": cron,
|
||||
"agent": AgentName.HERMES.value,
|
||||
"task_type": code.replace("task.hermes.", "").replace(".", "_"),
|
||||
"skill_category": DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP.get(code, "整理"),
|
||||
"skill_category_options": list(DIGITAL_EMPLOYEE_SKILL_CATEGORIES),
|
||||
}
|
||||
|
||||
def _finance_policy_knowledge_skill_markdown(self) -> str:
|
||||
skill_path = (
|
||||
SERVER_DIR
|
||||
/ "src"
|
||||
/ "app"
|
||||
/ "skills"
|
||||
/ "domain"
|
||||
/ "finance-policy-knowledge-organizer"
|
||||
/ "SKILL.md"
|
||||
)
|
||||
if skill_path.exists():
|
||||
return skill_path.read_text(encoding="utf-8").strip()
|
||||
return "\n".join(
|
||||
[
|
||||
"---",
|
||||
"name: finance-policy-knowledge-organizer",
|
||||
"description: 用于整理公司财务知识制度。",
|
||||
"---",
|
||||
"",
|
||||
"# 整理公司财务知识制度",
|
||||
"",
|
||||
"## 功能说明",
|
||||
"",
|
||||
"整理公司财务制度、报销口径、审批要求和知识库资料,输出可复核的结构化知识。",
|
||||
]
|
||||
)
|
||||
|
||||
def _digital_employee_task_content(
|
||||
self,
|
||||
code: str,
|
||||
@@ -254,59 +286,11 @@ class AgentFoundationAssetSeedMixin:
|
||||
config_json={"endpoint": "mock://ledger/snapshot", "timeout_ms": 1500},
|
||||
)
|
||||
|
||||
task_asset = AgentAsset(
|
||||
finance_policy_knowledge_task = AgentAsset(
|
||||
asset_type=AgentAssetType.TASK.value,
|
||||
code="task.hermes.daily_risk_scan",
|
||||
name="Hermes 每日风险巡检",
|
||||
description="每天早上巡检重复报销、金额超标、逾期应收和异常付款。",
|
||||
domain=AgentAssetDomain.SYSTEM.value,
|
||||
scenario_json=["schedule", "risk_check"],
|
||||
owner="风控与审计部",
|
||||
reviewer="顾承宇",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
published_version="v1.0.0",
|
||||
working_version="v1.0.0",
|
||||
config_json=self._digital_employee_task_config("task.hermes.daily_risk_scan", "0 9 * * *"),
|
||||
)
|
||||
|
||||
ar_summary_task = AgentAsset(
|
||||
asset_type=AgentAssetType.TASK.value,
|
||||
code="task.hermes.weekly_ar_summary",
|
||||
name="Hermes 每周应收账龄汇总",
|
||||
description="每周汇总逾期应收、账龄分布和客户风险变化。",
|
||||
domain=AgentAssetDomain.SYSTEM.value,
|
||||
scenario_json=["schedule", "accounts_receivable", "summary"],
|
||||
owner="风控与审计部",
|
||||
reviewer="顾承宇",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
published_version="v1.0.0",
|
||||
working_version="v1.0.0",
|
||||
config_json=self._digital_employee_task_config("task.hermes.weekly_ar_summary", "0 10 * * 1"),
|
||||
)
|
||||
|
||||
rule_digest_task = AgentAsset(
|
||||
asset_type=AgentAssetType.TASK.value,
|
||||
code="task.hermes.rule_review_digest",
|
||||
name="Hermes 规则待审摘要",
|
||||
description="每天汇总待审规则、待补样例和被拒规则修订建议。",
|
||||
domain=AgentAssetDomain.SYSTEM.value,
|
||||
scenario_json=["schedule", "rule_center", "review_digest"],
|
||||
owner="风控与审计部",
|
||||
reviewer="顾承宇",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
published_version="v1.0.0",
|
||||
working_version="v1.0.0",
|
||||
config_json=self._digital_employee_task_config("task.hermes.rule_review_digest", "0 18 * * *"),
|
||||
)
|
||||
|
||||
knowledge_index_task = AgentAsset(
|
||||
asset_type=AgentAssetType.TASK.value,
|
||||
code="task.hermes.knowledge_index_sync",
|
||||
name="Hermes ??????",
|
||||
description="?????????? LightRAG ???????",
|
||||
code=DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
|
||||
name="整理公司财务知识制度",
|
||||
description="按计划整理公司财务制度、报销口径、审批要求和知识库资料,形成可复核的结构化知识。",
|
||||
domain=AgentAssetDomain.SYSTEM.value,
|
||||
scenario_json=["schedule", "knowledge", "rule_center"],
|
||||
owner="财务制度管理组",
|
||||
@@ -315,7 +299,16 @@ class AgentFoundationAssetSeedMixin:
|
||||
current_version="v1.0.0",
|
||||
published_version="v1.0.0",
|
||||
working_version="v1.0.0",
|
||||
config_json=self._digital_employee_task_config("task.hermes.knowledge_index_sync", "0 0 * * *"),
|
||||
config_json={
|
||||
**self._digital_employee_task_config(
|
||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
|
||||
"0 3 * * *",
|
||||
),
|
||||
"skill_name": "finance-policy-knowledge-organizer",
|
||||
"folder": "财务制度",
|
||||
"changed_only": True,
|
||||
"output_format": "knowledge_organizing_report",
|
||||
},
|
||||
)
|
||||
|
||||
self.db.add_all(
|
||||
@@ -330,10 +323,7 @@ class AgentFoundationAssetSeedMixin:
|
||||
skill_ar_asset,
|
||||
invoice_mcp_asset,
|
||||
ledger_mcp_asset,
|
||||
task_asset,
|
||||
ar_summary_task,
|
||||
rule_digest_task,
|
||||
knowledge_index_task,
|
||||
finance_policy_knowledge_task,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -493,54 +483,11 @@ class AgentFoundationAssetSeedMixin:
|
||||
created_by="系统初始化",
|
||||
),
|
||||
AgentAssetVersion(
|
||||
asset=task_asset,
|
||||
asset=finance_policy_knowledge_task,
|
||||
version="v1.0.0",
|
||||
content=self._digital_employee_task_content(
|
||||
"task.hermes.daily_risk_scan",
|
||||
"daily_risk_scan",
|
||||
"0 9 * * *",
|
||||
),
|
||||
content_type=AgentAssetContentType.JSON.value,
|
||||
change_note="初始化任务快照。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
AgentAssetVersion(
|
||||
asset=ar_summary_task,
|
||||
version="v1.0.0",
|
||||
content=self._digital_employee_task_content(
|
||||
"task.hermes.weekly_ar_summary",
|
||||
"weekly_ar_summary",
|
||||
"0 10 * * 1",
|
||||
),
|
||||
content_type=AgentAssetContentType.JSON.value,
|
||||
change_note="初始化应收账龄汇总任务。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
AgentAssetVersion(
|
||||
asset=rule_digest_task,
|
||||
version="v1.0.0",
|
||||
content=self._digital_employee_task_content(
|
||||
"task.hermes.rule_review_digest",
|
||||
"rule_review_digest",
|
||||
"0 18 * * *",
|
||||
),
|
||||
content_type=AgentAssetContentType.JSON.value,
|
||||
change_note="初始化规则待审摘要任务。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
AgentAssetVersion(
|
||||
asset=knowledge_index_task,
|
||||
version="v1.0.0",
|
||||
content=self._digital_employee_task_content(
|
||||
"task.hermes.knowledge_index_sync",
|
||||
"knowledge_index_sync",
|
||||
"0 0 * * *",
|
||||
folder="报销制度",
|
||||
changed_only=True,
|
||||
index_engine="lightrag",
|
||||
),
|
||||
content_type=AgentAssetContentType.JSON.value,
|
||||
change_note="初始化制度知识与规则草稿形成任务。",
|
||||
content=self._finance_policy_knowledge_skill_markdown(),
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
change_note="初始化整理公司财务知识制度能力。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.core.agent_enums import (
|
||||
)
|
||||
from app.core.logging import get_logger
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.models.agent_run import AgentRun
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
@@ -26,6 +27,8 @@ from app.services.agent_foundation_constants import (
|
||||
COMPANY_COMMUNICATION_RULE_VERSION,
|
||||
COMPANY_TRAVEL_RULE_SCENARIO_JSON,
|
||||
COMPANY_TRAVEL_RULE_VERSION,
|
||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_LEGACY_TASK_CODES,
|
||||
DIGITAL_EMPLOYEE_SKILL_CATEGORIES,
|
||||
DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP,
|
||||
)
|
||||
@@ -34,6 +37,26 @@ logger = get_logger("app.services.agent_foundation")
|
||||
|
||||
|
||||
class AgentFoundationAssetTopUpMixin:
|
||||
def _remove_legacy_digital_employee_assets(self) -> None:
|
||||
assets = list(
|
||||
self.db.scalars(
|
||||
select(AgentAsset).where(AgentAsset.code.in_(DIGITAL_EMPLOYEE_LEGACY_TASK_CODES))
|
||||
).all()
|
||||
)
|
||||
if not assets:
|
||||
return
|
||||
|
||||
asset_ids = [asset.id for asset in assets]
|
||||
runs = list(
|
||||
self.db.scalars(select(AgentRun).where(AgentRun.task_id.in_(asset_ids))).all()
|
||||
)
|
||||
for run in runs:
|
||||
run.task_id = None
|
||||
self.db.add(run)
|
||||
|
||||
for asset in assets:
|
||||
self.db.delete(asset)
|
||||
|
||||
def _sync_digital_employee_skill_categories(self) -> None:
|
||||
category_options = list(DIGITAL_EMPLOYEE_SKILL_CATEGORIES)
|
||||
has_changes = False
|
||||
@@ -45,6 +68,10 @@ class AgentFoundationAssetTopUpMixin:
|
||||
|
||||
config_json = dict(asset.config_json or {})
|
||||
changed = False
|
||||
task_type = code.replace("task.hermes.", "").replace(".", "_")
|
||||
if config_json.get("task_type") != task_type:
|
||||
config_json["task_type"] = task_type
|
||||
changed = True
|
||||
if config_json.get("skill_category") != category:
|
||||
config_json["skill_category"] = category
|
||||
changed = True
|
||||
@@ -63,6 +90,7 @@ class AgentFoundationAssetTopUpMixin:
|
||||
def _top_up_agent_assets(self, existing_codes: set[str]) -> None:
|
||||
|
||||
self._remove_legacy_rule_assets()
|
||||
self._remove_legacy_digital_employee_assets()
|
||||
|
||||
existing_codes = set(self.db.scalars(select(AgentAsset.code)).all())
|
||||
self._sync_digital_employee_skill_categories()
|
||||
@@ -572,91 +600,82 @@ class AgentFoundationAssetTopUpMixin:
|
||||
created_by="系统初始化",
|
||||
)
|
||||
|
||||
if "task.hermes.weekly_ar_summary" not in existing_codes:
|
||||
finance_policy_cron = "0 3 * * *"
|
||||
finance_policy_config = {
|
||||
**self._digital_employee_task_config(
|
||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
|
||||
finance_policy_cron,
|
||||
),
|
||||
"schedule": finance_policy_cron,
|
||||
"cron_expression": finance_policy_cron,
|
||||
"skill_name": "finance-policy-knowledge-organizer",
|
||||
"folder": "财务制度",
|
||||
"changed_only": True,
|
||||
"output_format": "knowledge_organizing_report",
|
||||
}
|
||||
|
||||
if DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE not in existing_codes:
|
||||
|
||||
asset = self._create_seed_asset(
|
||||
asset_type=AgentAssetType.TASK.value,
|
||||
code="task.hermes.weekly_ar_summary",
|
||||
name="Hermes 每周应收账龄汇总",
|
||||
description="每周汇总逾期应收、账龄分布和客户风险变化。",
|
||||
domain=AgentAssetDomain.SYSTEM.value,
|
||||
scenario_json=["schedule", "accounts_receivable", "summary"],
|
||||
owner="风控与审计部",
|
||||
reviewer="顾承宇",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
config_json=self._digital_employee_task_config("task.hermes.weekly_ar_summary", "0 10 * * 1"),
|
||||
)
|
||||
|
||||
self._ensure_asset_version(
|
||||
asset,
|
||||
version="v1.0.0",
|
||||
content=self._digital_employee_task_content(
|
||||
"task.hermes.weekly_ar_summary",
|
||||
"weekly_ar_summary",
|
||||
"0 10 * * 1",
|
||||
),
|
||||
content_type=AgentAssetContentType.JSON.value,
|
||||
change_note="初始化应收账龄汇总任务。",
|
||||
created_by="系统初始化",
|
||||
)
|
||||
|
||||
if "task.hermes.rule_review_digest" not in existing_codes:
|
||||
|
||||
asset = self._create_seed_asset(
|
||||
asset_type=AgentAssetType.TASK.value,
|
||||
code="task.hermes.rule_review_digest",
|
||||
name="Hermes 规则待审摘要",
|
||||
description="每天汇总待审规则、待补样例和被拒规则修订建议。",
|
||||
domain=AgentAssetDomain.SYSTEM.value,
|
||||
scenario_json=["schedule", "rule_center", "review_digest"],
|
||||
owner="风控与审计部",
|
||||
reviewer="顾承宇",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
config_json=self._digital_employee_task_config("task.hermes.rule_review_digest", "0 18 * * *"),
|
||||
)
|
||||
|
||||
self._ensure_asset_version(
|
||||
asset,
|
||||
version="v1.0.0",
|
||||
content=self._digital_employee_task_content(
|
||||
"task.hermes.rule_review_digest",
|
||||
"rule_review_digest",
|
||||
"0 18 * * *",
|
||||
),
|
||||
content_type=AgentAssetContentType.JSON.value,
|
||||
change_note="初始化规则待审摘要任务。",
|
||||
created_by="系统初始化",
|
||||
)
|
||||
|
||||
if "task.hermes.knowledge_index_sync" not in existing_codes:
|
||||
|
||||
asset = self._create_seed_asset(
|
||||
asset_type=AgentAssetType.TASK.value,
|
||||
code="task.hermes.knowledge_index_sync",
|
||||
name="Hermes ??????",
|
||||
description="?????????? LightRAG ???????",
|
||||
code=DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
|
||||
name="整理公司财务知识制度",
|
||||
description="按计划整理公司财务制度、报销口径、审批要求和知识库资料,形成可复核的结构化知识。",
|
||||
domain=AgentAssetDomain.SYSTEM.value,
|
||||
scenario_json=["schedule", "knowledge", "rule_center"],
|
||||
owner="财务制度管理组",
|
||||
reviewer="顾承宇",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
config_json=self._digital_employee_task_config("task.hermes.knowledge_index_sync", "0 0 * * *"),
|
||||
config_json=finance_policy_config,
|
||||
)
|
||||
|
||||
self._ensure_asset_version(
|
||||
asset,
|
||||
version="v1.0.0",
|
||||
content=self._digital_employee_task_content(
|
||||
"task.hermes.knowledge_index_sync",
|
||||
"knowledge_index_sync",
|
||||
"0 0 * * *",
|
||||
folder="报销制度",
|
||||
changed_only=True,
|
||||
),
|
||||
content_type=AgentAssetContentType.JSON.value,
|
||||
change_note="初始化制度知识与规则草稿形成任务。",
|
||||
created_by="系统初始化",
|
||||
else:
|
||||
|
||||
asset = self.db.scalar(
|
||||
select(AgentAsset).where(AgentAsset.code == DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE)
|
||||
)
|
||||
if asset is None:
|
||||
return
|
||||
existing_config = dict(asset.config_json or {})
|
||||
existing_cron = (
|
||||
existing_config.get("cron")
|
||||
or existing_config.get("schedule")
|
||||
or existing_config.get("cron_expression")
|
||||
)
|
||||
schedule_config = (
|
||||
{
|
||||
"cron": existing_cron,
|
||||
"schedule": existing_cron,
|
||||
"cron_expression": existing_cron,
|
||||
}
|
||||
if existing_cron
|
||||
else {}
|
||||
)
|
||||
asset.name = "整理公司财务知识制度"
|
||||
asset.description = "按计划整理公司财务制度、报销口径、审批要求和知识库资料,形成可复核的结构化知识。"
|
||||
asset.owner = "财务制度管理组"
|
||||
asset.domain = AgentAssetDomain.SYSTEM.value
|
||||
asset.scenario_json = ["schedule", "knowledge", "rule_center"]
|
||||
asset.config_json = {
|
||||
**existing_config,
|
||||
"agent": "hermes",
|
||||
"task_type": "finance_policy_knowledge_organize",
|
||||
"skill_category": "整理",
|
||||
"skill_category_options": list(DIGITAL_EMPLOYEE_SKILL_CATEGORIES),
|
||||
"skill_name": "finance-policy-knowledge-organizer",
|
||||
"folder": existing_config.get("folder") or "财务制度",
|
||||
"changed_only": existing_config.get("changed_only", True),
|
||||
"output_format": "knowledge_organizing_report",
|
||||
**schedule_config,
|
||||
}
|
||||
self.db.add(asset)
|
||||
|
||||
self._ensure_asset_version(
|
||||
asset,
|
||||
version="v1.0.0",
|
||||
content=self._finance_policy_knowledge_skill_markdown(),
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
change_note="初始化整理公司财务知识制度能力。",
|
||||
created_by="系统初始化",
|
||||
)
|
||||
|
||||
@@ -88,18 +88,18 @@ COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("通信费",)
|
||||
|
||||
DIGITAL_EMPLOYEE_SKILL_CATEGORIES = ("积累", "升级", "整理", "评估")
|
||||
|
||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE = "task.hermes.finance_policy_knowledge_organize"
|
||||
|
||||
DIGITAL_EMPLOYEE_LEGACY_TASK_CODES = (
|
||||
"task.hermes.daily_risk_scan",
|
||||
"task.hermes.weekly_ar_summary",
|
||||
"task.hermes.rule_review_digest",
|
||||
"task.hermes.knowledge_index_sync",
|
||||
"task.hermes.llm_wiki_rule_formation",
|
||||
)
|
||||
|
||||
DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP = {
|
||||
|
||||
"task.hermes.daily_risk_scan": "评估",
|
||||
|
||||
"task.hermes.weekly_ar_summary": "整理",
|
||||
|
||||
"task.hermes.rule_review_digest": "升级",
|
||||
|
||||
"task.hermes.knowledge_index_sync": "积累",
|
||||
|
||||
"task.hermes.llm_wiki_rule_formation": "积累",
|
||||
|
||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE: "整理",
|
||||
}
|
||||
|
||||
ATTACHMENT_RULE_RUNTIME_CONFIG = {
|
||||
|
||||
@@ -53,6 +53,7 @@ from app.services.agent_foundation_constants import (
|
||||
DEMO_EXPENSE_CLAIM_SIGNATURES,
|
||||
DEMO_PAYABLE_SIGNATURES,
|
||||
DEMO_RECEIVABLE_SIGNATURES,
|
||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
|
||||
LEGACY_RULE_CODES,
|
||||
PLATFORM_DESTINATION_LOCATION_RULE_FILENAME,
|
||||
)
|
||||
@@ -411,7 +412,7 @@ class AgentFoundationFinancialSeedMixin:
|
||||
|
||||
task_asset = self.db.scalar(
|
||||
|
||||
select(AgentAsset).where(AgentAsset.code == "task.hermes.daily_risk_scan")
|
||||
select(AgentAsset).where(AgentAsset.code == DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE)
|
||||
|
||||
)
|
||||
|
||||
@@ -711,7 +712,7 @@ class AgentFoundationFinancialSeedMixin:
|
||||
|
||||
resource_type="task",
|
||||
|
||||
resource_id="task.hermes.daily_risk_scan",
|
||||
resource_id=DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE,
|
||||
|
||||
before_json={"status": "idle"},
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from collections import Counter
|
||||
from datetime import UTC, date, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import inspect, select, text
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
@@ -28,6 +28,8 @@ from app.schemas.employee import (
|
||||
EmployeeUpdate,
|
||||
)
|
||||
from app.services.employee_import import EmployeeImportCoordinator
|
||||
from app.services.employee_bank_info import apply_default_bank_info
|
||||
from app.services.employee_schema import ensure_employee_schema
|
||||
from app.services.employee_serialization import serialize_employee
|
||||
from app.services.employee_spreadsheet import build_import_template_bytes
|
||||
from app.services.employee_seed import (
|
||||
@@ -86,12 +88,13 @@ class EmployeeService:
|
||||
def ensure_directory_ready(self) -> None:
|
||||
try:
|
||||
Base.metadata.create_all(bind=self.db.get_bind())
|
||||
self._ensure_employee_schema()
|
||||
ensure_employee_schema(self.db)
|
||||
self._prune_extra_seed_employees()
|
||||
self._seed_roles()
|
||||
self._seed_organization_units()
|
||||
self._seed_employees()
|
||||
self._normalize_legacy_employee_departments()
|
||||
self._backfill_employee_bank_info()
|
||||
self.db.commit()
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
@@ -191,12 +194,16 @@ class EmployeeService:
|
||||
grade=payload.grade,
|
||||
cost_center=payload.cost_center,
|
||||
finance_owner_name=payload.finance_owner_name,
|
||||
bank_name=normalize_optional_text(payload.bank_name),
|
||||
bank_account_no=normalize_optional_text(payload.bank_account_no),
|
||||
bank_account_name=normalize_optional_text(payload.bank_account_name),
|
||||
employment_status=payload.employment_status,
|
||||
sync_state=payload.sync_state,
|
||||
spotlight=payload.spotlight,
|
||||
password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD),
|
||||
last_sync_at=datetime.now(UTC),
|
||||
)
|
||||
apply_default_bank_info(employee)
|
||||
|
||||
if payload.organization_unit_code:
|
||||
organization_code = normalize_organization_unit_code(payload.organization_unit_code)
|
||||
@@ -305,6 +312,24 @@ class EmployeeService:
|
||||
employee.finance_owner_name = finance_owner_name
|
||||
changed_fields.append("财务归口")
|
||||
|
||||
if "bank_account_name" in payload.model_fields_set:
|
||||
bank_account_name = normalize_optional_text(payload.bank_account_name)
|
||||
if bank_account_name != employee.bank_account_name:
|
||||
employee.bank_account_name = bank_account_name
|
||||
changed_fields.append("银行户名")
|
||||
|
||||
if "bank_name" in payload.model_fields_set:
|
||||
bank_name = normalize_optional_text(payload.bank_name)
|
||||
if bank_name != employee.bank_name:
|
||||
employee.bank_name = bank_name
|
||||
changed_fields.append("开户行")
|
||||
|
||||
if "bank_account_no" in payload.model_fields_set:
|
||||
bank_account_no = normalize_optional_text(payload.bank_account_no)
|
||||
if bank_account_no != employee.bank_account_no:
|
||||
employee.bank_account_no = bank_account_no
|
||||
changed_fields.append("银行账号")
|
||||
|
||||
if "organization_unit_code" in payload.model_fields_set:
|
||||
organization_code = normalize_organization_unit_code(
|
||||
normalize_optional_text(payload.organization_unit_code)
|
||||
@@ -581,6 +606,9 @@ class EmployeeService:
|
||||
grade=definition.get("grade", "P3"),
|
||||
cost_center=definition.get("cost_center"),
|
||||
finance_owner_name=definition.get("finance_owner_name"),
|
||||
bank_name=definition.get("bank_name"),
|
||||
bank_account_no=definition.get("bank_account_no"),
|
||||
bank_account_name=definition.get("bank_account_name"),
|
||||
employment_status=definition.get("employment_status", "在职"),
|
||||
sync_state=definition.get("sync_state", "已同步"),
|
||||
spotlight=bool(definition.get("spotlight")),
|
||||
@@ -606,6 +634,8 @@ class EmployeeService:
|
||||
if not employee.password_hash:
|
||||
employee.password_hash = hash_password(DEFAULT_EMPLOYEE_PASSWORD)
|
||||
|
||||
apply_default_bank_info(employee)
|
||||
|
||||
if not employee.roles:
|
||||
employee.roles = self._sorted_roles(
|
||||
[
|
||||
@@ -655,6 +685,9 @@ class EmployeeService:
|
||||
"location",
|
||||
"cost_center",
|
||||
"finance_owner_name",
|
||||
"bank_name",
|
||||
"bank_account_no",
|
||||
"bank_account_name",
|
||||
"employment_status",
|
||||
"sync_state",
|
||||
):
|
||||
@@ -673,6 +706,8 @@ class EmployeeService:
|
||||
if not employee.password_hash:
|
||||
employee.password_hash = hash_password(DEFAULT_EMPLOYEE_PASSWORD)
|
||||
|
||||
apply_default_bank_info(employee)
|
||||
|
||||
role_codes = [item for item in definition.get("role_codes", []) if item in roles_by_code]
|
||||
if role_codes:
|
||||
merged_roles = {role.role_code: role for role in employee.roles}
|
||||
@@ -691,19 +726,9 @@ class EmployeeService:
|
||||
if employee is not None:
|
||||
self.db.delete(employee)
|
||||
|
||||
def _ensure_employee_schema(self) -> None:
|
||||
bind = self.db.get_bind()
|
||||
inspector = inspect(bind)
|
||||
if "employees" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
column_names = {column["name"] for column in inspector.get_columns("employees")}
|
||||
if "password_hash" not in column_names:
|
||||
self.db.execute(text("ALTER TABLE employees ADD COLUMN password_hash VARCHAR(255)"))
|
||||
if "compliance_score" not in column_names:
|
||||
self.db.execute(
|
||||
text("ALTER TABLE employees ADD COLUMN compliance_score INTEGER DEFAULT 100 NOT NULL")
|
||||
)
|
||||
def _backfill_employee_bank_info(self) -> None:
|
||||
for employee in self.repository.list():
|
||||
apply_default_bank_info(employee)
|
||||
self.db.flush()
|
||||
|
||||
def _seed_employee_history(self, employee: Employee, definition: dict[str, Any]) -> None:
|
||||
|
||||
26
server/src/app/services/employee_bank_info.py
Normal file
26
server/src/app/services/employee_bank_info.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
from app.models.employee import Employee
|
||||
|
||||
DEFAULT_EMPLOYEE_BANK_NAME = "招商银行深圳科技园支行"
|
||||
|
||||
|
||||
def build_default_bank_account_no(employee_no: str | None) -> str | None:
|
||||
text = str(employee_no or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
numeric = str(int(digest[:18], 16)).zfill(13)[-13:]
|
||||
return f"622588{numeric}"
|
||||
|
||||
|
||||
def apply_default_bank_info(employee: Employee) -> None:
|
||||
if not employee.bank_account_name and employee.name:
|
||||
employee.bank_account_name = employee.name
|
||||
if not employee.bank_name:
|
||||
employee.bank_name = DEFAULT_EMPLOYEE_BANK_NAME
|
||||
if not employee.bank_account_no:
|
||||
employee.bank_account_no = build_default_bank_account_no(employee.employee_no)
|
||||
163
server/src/app/services/employee_behavior_profile_helpers.py
Normal file
163
server/src/app/services/employee_behavior_profile_helpers.py
Normal file
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from app.models.agent_run import AgentRun
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
|
||||
TRAVEL_EXPENSE_TYPES = {
|
||||
"travel",
|
||||
"train_ticket",
|
||||
"flight_ticket",
|
||||
"hotel_ticket",
|
||||
"ride_ticket",
|
||||
"travel_allowance",
|
||||
}
|
||||
ENTERTAINMENT_EXPENSE_TYPES = {"meal", "entertainment"}
|
||||
|
||||
|
||||
class EmployeeBehaviorProfileMetricHelpers:
|
||||
def _sum_amount_by_employee(self, claims: list[ExpenseClaim]) -> dict[str, Decimal]:
|
||||
grouped: dict[str, Decimal] = defaultdict(Decimal)
|
||||
for claim in claims:
|
||||
grouped[self._claim_employee_key(claim)] += self._decimal(claim.amount)
|
||||
return dict(grouped)
|
||||
|
||||
def _count_by_employee(self, claims: list[ExpenseClaim]) -> dict[str, int]:
|
||||
grouped: dict[str, int] = defaultdict(int)
|
||||
for claim in claims:
|
||||
grouped[self._claim_employee_key(claim)] += 1
|
||||
return dict(grouped)
|
||||
|
||||
def _return_count_by_employee(self, claims: list[ExpenseClaim]) -> dict[str, int]:
|
||||
grouped: dict[str, int] = defaultdict(int)
|
||||
for claim in claims:
|
||||
grouped[self._claim_employee_key(claim)] += self._return_count([claim])
|
||||
return dict(grouped)
|
||||
|
||||
def _claim_employee_key(self, claim: ExpenseClaim) -> str:
|
||||
return str(claim.employee_id or claim.employee_name or "unknown").strip()
|
||||
|
||||
def _employee_identifiers(self, employee: Employee) -> set[str]:
|
||||
return {
|
||||
item
|
||||
for item in (
|
||||
employee.id,
|
||||
employee.employee_no,
|
||||
employee.email,
|
||||
employee.name,
|
||||
)
|
||||
if str(item or "").strip()
|
||||
}
|
||||
|
||||
def _return_count(self, claims: list[ExpenseClaim]) -> int:
|
||||
count = 0
|
||||
for claim in claims:
|
||||
status = str(claim.status or "").lower()
|
||||
if status in {"returned", "supplement", "rejected"}:
|
||||
count += 1
|
||||
for flag in claim.risk_flags_json or []:
|
||||
if isinstance(flag, dict) and str(flag.get("source") or "") == "manual_return":
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def _missing_attachment_count(self, claim: ExpenseClaim) -> int:
|
||||
if not claim.items:
|
||||
return int((claim.invoice_count or 0) <= 0)
|
||||
return sum(1 for item in claim.items if not str(item.invoice_id or "").strip())
|
||||
|
||||
def _has_amount_mismatch(self, claim: ExpenseClaim) -> bool:
|
||||
if not claim.items:
|
||||
return False
|
||||
item_total = sum((self._decimal(item.item_amount) for item in claim.items), Decimal("0"))
|
||||
return abs(item_total - self._decimal(claim.amount)) > Decimal("0.01")
|
||||
|
||||
def _missing_context_count(self, claim: ExpenseClaim) -> int:
|
||||
missing = 0
|
||||
for value in (claim.reason, claim.location, claim.project_code):
|
||||
if self._is_missing_value(value):
|
||||
missing += 1
|
||||
for item in claim.items or []:
|
||||
if self._is_missing_value(item.item_reason):
|
||||
missing += 1
|
||||
if item.item_type in TRAVEL_EXPENSE_TYPES and self._is_missing_value(
|
||||
item.item_location
|
||||
):
|
||||
missing += 1
|
||||
return missing
|
||||
|
||||
def _claim_travel_days(self, claim: ExpenseClaim | None) -> Decimal:
|
||||
if claim is None:
|
||||
return Decimal("0")
|
||||
dates = {
|
||||
item.item_date
|
||||
for item in claim.items or []
|
||||
if item.item_type in TRAVEL_EXPENSE_TYPES and item.item_date is not None
|
||||
}
|
||||
if dates:
|
||||
return Decimal(max(1, len(dates)))
|
||||
return Decimal("1") if claim.expense_type in TRAVEL_EXPENSE_TYPES else Decimal("0")
|
||||
|
||||
def _entertainment_unit_amount(self, claim: ExpenseClaim) -> Decimal:
|
||||
if claim.expense_type not in ENTERTAINMENT_EXPENSE_TYPES:
|
||||
return Decimal("0")
|
||||
attendee_count = self._extract_attendee_count(claim)
|
||||
if attendee_count <= 0:
|
||||
return Decimal("0")
|
||||
return self._decimal(claim.amount) / Decimal(attendee_count)
|
||||
|
||||
def _extract_attendee_count(self, claim: ExpenseClaim) -> int:
|
||||
text = " ".join(
|
||||
[claim.reason or "", *(item.item_reason or "" for item in claim.items or [])]
|
||||
)
|
||||
for token in ("人", "位"):
|
||||
parts = text.split(token)
|
||||
for part in parts:
|
||||
digits = "".join(ch for ch in part[-3:] if ch.isdigit())
|
||||
if digits:
|
||||
return max(1, int(digits))
|
||||
return 0
|
||||
|
||||
def _estimate_tokens(self, runs: list[AgentRun]) -> int:
|
||||
total = 0
|
||||
for run in runs:
|
||||
payload = {
|
||||
"ontology": run.ontology_json,
|
||||
"route": run.route_json,
|
||||
"summary": run.result_summary,
|
||||
"error": run.error_message,
|
||||
"tools": [
|
||||
{
|
||||
"request": tool.request_json,
|
||||
"response": tool.response_json,
|
||||
"error": tool.error_message,
|
||||
}
|
||||
for tool in run.tool_calls
|
||||
],
|
||||
}
|
||||
text = json.dumps(payload, ensure_ascii=False, default=str)
|
||||
total += max(0, len(text) // 4)
|
||||
return total
|
||||
|
||||
@staticmethod
|
||||
def _is_missing_value(value: Any) -> bool:
|
||||
text = str(value or "").strip()
|
||||
return not text or text in {"待补充", "暂无", "无", "未知"}
|
||||
|
||||
@staticmethod
|
||||
def _decimal(value: Any) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(value or "0"))
|
||||
except Exception:
|
||||
return Decimal("0")
|
||||
|
||||
@staticmethod
|
||||
def _format_decimal(value: Any) -> str:
|
||||
try:
|
||||
return str(Decimal(str(value or "0")).quantize(Decimal("0.0001")).normalize())
|
||||
except Exception:
|
||||
return "0"
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.algorithem.employee_behavior_profile import build_review_suggestions
|
||||
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
|
||||
|
||||
|
||||
def build_profile_payloads(
|
||||
rows: list[EmployeeBehaviorProfileSnapshot],
|
||||
) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"profile_type": row.profile_type,
|
||||
"profile_label": row.profile_type,
|
||||
"score": row.profile_score,
|
||||
"level": row.profile_level,
|
||||
"metrics": row.metrics_json or {},
|
||||
"top_contributors": row.basis_codes_json or [],
|
||||
}
|
||||
for row in sorted(rows, key=lambda item: item.profile_type)
|
||||
]
|
||||
|
||||
|
||||
def build_latest_review_suggestions(
|
||||
*,
|
||||
rows: list[EmployeeBehaviorProfileSnapshot],
|
||||
expense_score: int,
|
||||
process_score: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
expense_row = next((row for row in rows if row.profile_type == "expense"), None)
|
||||
metrics = expense_row.metrics_json if expense_row is not None else {}
|
||||
formula_suggestions = build_review_suggestions(
|
||||
expense_profile_score=expense_score,
|
||||
process_quality_score=process_score,
|
||||
requested_days=metrics.get("requested_days"),
|
||||
peer_days_p75=metrics.get("peer_days_p75"),
|
||||
peer_unit_amount_p75=metrics.get("peer_unit_amount_p75"),
|
||||
)
|
||||
merged = [*formula_suggestions, *_merge_review_suggestions(rows)]
|
||||
seen: set[str] = set()
|
||||
unique: list[dict[str, Any]] = []
|
||||
for item in merged:
|
||||
key = str(item.get("type") or item.get("message") or "").strip()
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append(item)
|
||||
return unique[:5]
|
||||
|
||||
|
||||
def _merge_review_suggestions(
|
||||
rows: list[EmployeeBehaviorProfileSnapshot],
|
||||
) -> list[dict[str, Any]]:
|
||||
merged: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for row in rows:
|
||||
for suggestion in (row.metrics_json or {}).get("review_suggestions") or []:
|
||||
key = str(suggestion.get("type") or suggestion.get("message") or "")
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
merged.append(suggestion)
|
||||
return merged[:5]
|
||||
816
server/src/app/services/employee_behavior_profile_service.py
Normal file
816
server/src/app/services/employee_behavior_profile_service.py
Normal file
@@ -0,0 +1,816 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.algorithem.employee_behavior_profile import (
|
||||
ALGORITHM_VERSION,
|
||||
LEVEL_LABELS,
|
||||
PROFILE_LABELS,
|
||||
ProfileComponent,
|
||||
build_review_suggestions,
|
||||
calculate_review_priority_score,
|
||||
evaluate_weighted_profile,
|
||||
level_from_score,
|
||||
normalize_by_peer_percentiles,
|
||||
percentile,
|
||||
score_by_bands,
|
||||
)
|
||||
from app.algorithem.employee_behavior_profile_tags import build_profile_radar, build_profile_tags
|
||||
from app.db.base import Base
|
||||
from app.models.agent_run import AgentRun
|
||||
from app.models.approval import ApprovalRecord
|
||||
from app.models.employee import Employee
|
||||
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.employee_profile import (
|
||||
EmployeeProfileLatestRead,
|
||||
EmployeeProfilePeerGroupRead,
|
||||
EmployeeProfileRead,
|
||||
)
|
||||
from app.services.employee_behavior_profile_helpers import (
|
||||
ENTERTAINMENT_EXPENSE_TYPES,
|
||||
TRAVEL_EXPENSE_TYPES,
|
||||
EmployeeBehaviorProfileMetricHelpers,
|
||||
)
|
||||
from app.services.employee_behavior_profile_response import (
|
||||
build_latest_review_suggestions,
|
||||
build_profile_payloads,
|
||||
)
|
||||
|
||||
PROFILE_TYPES_FOR_APPROVAL = {"expense", "process_quality"}
|
||||
ATTENTION_LEVELS = {"watch", "review", "escalation"}
|
||||
PENDING_CLAIM_STATUSES = {"submitted", "review", "in_progress", "pending", "pending_review"}
|
||||
DEFAULT_WINDOWS = (30, 90, 180)
|
||||
|
||||
|
||||
class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def ensure_storage_ready(self) -> None:
|
||||
Base.metadata.create_all(
|
||||
bind=self.db.get_bind(), tables=[EmployeeBehaviorProfileSnapshot.__table__]
|
||||
)
|
||||
|
||||
def scan_profiles(
|
||||
self,
|
||||
*,
|
||||
log_id: str | None = None,
|
||||
window_days: tuple[int, ...] = DEFAULT_WINDOWS,
|
||||
limit: int = 120,
|
||||
) -> dict[str, Any]:
|
||||
self.ensure_storage_ready()
|
||||
employee_ids = self._resolve_target_employee_ids(limit=limit)
|
||||
snapshot_count = 0
|
||||
high_attention_count = 0
|
||||
|
||||
for employee_id in employee_ids:
|
||||
snapshots = self.refresh_employee_profiles(
|
||||
employee_id=employee_id,
|
||||
window_days=window_days,
|
||||
expense_type_scope="overall",
|
||||
source_task_type="employee_behavior_profile_scan",
|
||||
source_task_log_id=log_id,
|
||||
commit=False,
|
||||
)
|
||||
snapshot_count += len(snapshots)
|
||||
high_attention_count += int(
|
||||
any(item.profile_level in ATTENTION_LEVELS for item in snapshots)
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
return {
|
||||
"target_employee_count": len(employee_ids),
|
||||
"snapshot_count": snapshot_count,
|
||||
"high_attention_employee_count": high_attention_count,
|
||||
"window_days": list(window_days),
|
||||
"algorithm_version": ALGORITHM_VERSION,
|
||||
}
|
||||
|
||||
def refresh_employee_profiles(
|
||||
self,
|
||||
*,
|
||||
employee_id: str,
|
||||
window_days: tuple[int, ...] = DEFAULT_WINDOWS,
|
||||
expense_type_scope: str = "overall",
|
||||
source_task_type: str = "api_on_demand",
|
||||
source_task_log_id: str | None = None,
|
||||
claim_id: str | None = None,
|
||||
commit: bool = True,
|
||||
) -> list[EmployeeBehaviorProfileSnapshot]:
|
||||
self.ensure_storage_ready()
|
||||
employee = self.db.get(Employee, employee_id)
|
||||
if employee is None:
|
||||
return []
|
||||
|
||||
now = datetime.now(UTC)
|
||||
snapshots: list[EmployeeBehaviorProfileSnapshot] = []
|
||||
for days in window_days:
|
||||
context = self._build_window_context(
|
||||
employee=employee,
|
||||
window_days=days,
|
||||
expense_type_scope=expense_type_scope,
|
||||
claim_id=claim_id,
|
||||
now=now,
|
||||
)
|
||||
for result in (
|
||||
self._calculate_expense_profile(context),
|
||||
self._calculate_process_quality_profile(context),
|
||||
self._calculate_ai_usage_profile(context),
|
||||
self._calculate_approval_behavior_profile(context),
|
||||
):
|
||||
snapshot = EmployeeBehaviorProfileSnapshot(
|
||||
subject_type="employee",
|
||||
subject_id=employee.id,
|
||||
subject_name=employee.name,
|
||||
department_id=employee.organization_unit_id,
|
||||
department_name=context["department_name"],
|
||||
position=employee.position,
|
||||
grade=employee.grade,
|
||||
profile_type=result.profile_type,
|
||||
window_days=days,
|
||||
expense_type_scope=expense_type_scope,
|
||||
peer_group_key=context["peer_group_key"],
|
||||
peer_group_fallback_level=context["peer_group_fallback_level"],
|
||||
profile_score=result.profile_score,
|
||||
profile_level=result.profile_level,
|
||||
metrics_json=result.metrics,
|
||||
basis_codes_json=result.top_contributors(),
|
||||
source_task_type=source_task_type,
|
||||
source_task_log_id=source_task_log_id,
|
||||
algorithm_version=ALGORITHM_VERSION,
|
||||
calculated_at=now,
|
||||
)
|
||||
self.db.add(snapshot)
|
||||
snapshots.append(snapshot)
|
||||
|
||||
if commit:
|
||||
self.db.commit()
|
||||
return snapshots
|
||||
|
||||
def get_latest_profile(
|
||||
self,
|
||||
*,
|
||||
employee_id: str,
|
||||
scene: str = "approval",
|
||||
claim_id: str | None = None,
|
||||
window_days: int = 90,
|
||||
expense_type_scope: str = "overall",
|
||||
) -> EmployeeProfileLatestRead:
|
||||
self.ensure_storage_ready()
|
||||
employee = self.db.get(Employee, employee_id)
|
||||
if employee is None:
|
||||
return EmployeeProfileLatestRead(
|
||||
employee_id=employee_id,
|
||||
scene=scene,
|
||||
window_days=window_days,
|
||||
expense_type_scope=expense_type_scope,
|
||||
empty_reason="员工不存在或尚未同步。",
|
||||
)
|
||||
|
||||
resolved_scope = self._resolve_scope_from_claim(claim_id, expense_type_scope)
|
||||
rows = self._load_latest_snapshots(
|
||||
employee_id=employee_id,
|
||||
window_days=window_days,
|
||||
expense_type_scope=resolved_scope,
|
||||
scene=scene,
|
||||
)
|
||||
if not rows and claim_id:
|
||||
self.refresh_employee_profiles(
|
||||
employee_id=employee_id,
|
||||
window_days=(window_days,),
|
||||
expense_type_scope=resolved_scope,
|
||||
source_task_type="api_on_demand",
|
||||
claim_id=claim_id,
|
||||
)
|
||||
rows = self._load_latest_snapshots(
|
||||
employee_id=employee_id,
|
||||
window_days=window_days,
|
||||
expense_type_scope=resolved_scope,
|
||||
scene=scene,
|
||||
)
|
||||
|
||||
return self._serialize_latest_profile(
|
||||
employee=employee,
|
||||
rows=rows,
|
||||
scene=scene,
|
||||
window_days=window_days,
|
||||
expense_type_scope=resolved_scope,
|
||||
)
|
||||
|
||||
def _build_window_context(
|
||||
self,
|
||||
*,
|
||||
employee: Employee,
|
||||
window_days: int,
|
||||
expense_type_scope: str,
|
||||
claim_id: str | None,
|
||||
now: datetime,
|
||||
) -> dict[str, Any]:
|
||||
cutoff = now - timedelta(days=window_days)
|
||||
all_claims = self._fetch_claims_since(cutoff)
|
||||
scoped_claims = [
|
||||
claim for claim in all_claims if self._is_claim_in_scope(claim, expense_type_scope)
|
||||
]
|
||||
employee_claims = [claim for claim in scoped_claims if claim.employee_id == employee.id]
|
||||
peer_claims, fallback_level = self._resolve_peer_claims(
|
||||
claims=scoped_claims,
|
||||
employee=employee,
|
||||
)
|
||||
current_claim = next((claim for claim in all_claims if claim.id == claim_id), None)
|
||||
|
||||
peer_amount_by_employee = self._sum_amount_by_employee(peer_claims)
|
||||
peer_count_by_employee = self._count_by_employee(peer_claims)
|
||||
peer_return_count_by_employee = self._return_count_by_employee(peer_claims)
|
||||
peer_current_amounts = [self._decimal(claim.amount) for claim in peer_claims]
|
||||
peer_travel_days = [self._claim_travel_days(claim) for claim in peer_claims]
|
||||
peer_entertainment_units = [
|
||||
self._entertainment_unit_amount(claim)
|
||||
for claim in peer_claims
|
||||
if self._entertainment_unit_amount(claim) > Decimal("0")
|
||||
]
|
||||
|
||||
department_name = employee.organization_unit.name if employee.organization_unit else ""
|
||||
department_name = department_name or (
|
||||
employee_claims[0].department_name if employee_claims else ""
|
||||
)
|
||||
peer_group_key = "|".join(
|
||||
[
|
||||
department_name or "company",
|
||||
employee.position or "position",
|
||||
employee.grade or "grade",
|
||||
expense_type_scope,
|
||||
str(window_days),
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
"employee": employee,
|
||||
"employee_identifiers": self._employee_identifiers(employee),
|
||||
"department_name": department_name,
|
||||
"window_days": window_days,
|
||||
"expense_type_scope": expense_type_scope,
|
||||
"cutoff": cutoff,
|
||||
"now": now,
|
||||
"employee_claims": employee_claims,
|
||||
"peer_claims": peer_claims,
|
||||
"current_claim": current_claim,
|
||||
"peer_group_key": peer_group_key,
|
||||
"peer_group_fallback_level": fallback_level,
|
||||
"peer_sample_size": len({self._claim_employee_key(claim) for claim in peer_claims}),
|
||||
"peer_amount_p50": percentile(list(peer_amount_by_employee.values()), 50),
|
||||
"peer_amount_p90": percentile(list(peer_amount_by_employee.values()), 90),
|
||||
"peer_count_p50": percentile(list(peer_count_by_employee.values()), 50),
|
||||
"peer_count_p90": percentile(list(peer_count_by_employee.values()), 90),
|
||||
"peer_return_p50": percentile(list(peer_return_count_by_employee.values()), 50),
|
||||
"peer_return_p90": percentile(list(peer_return_count_by_employee.values()), 90),
|
||||
"peer_claim_amount_p50": percentile(peer_current_amounts, 50),
|
||||
"peer_claim_amount_p90": percentile(peer_current_amounts, 90),
|
||||
"peer_days_p75": percentile(peer_travel_days, 75),
|
||||
"peer_unit_amount_p75": percentile(peer_entertainment_units, 75),
|
||||
"department_amount_total": sum(
|
||||
(self._decimal(claim.amount) for claim in peer_claims), Decimal("0")
|
||||
),
|
||||
}
|
||||
|
||||
def _calculate_expense_profile(self, context: dict[str, Any]):
|
||||
claims = context["employee_claims"]
|
||||
amount_total = sum((self._decimal(claim.amount) for claim in claims), Decimal("0"))
|
||||
current_claim = context["current_claim"]
|
||||
current_amount = (
|
||||
self._decimal(current_claim.amount) if current_claim is not None else Decimal("0")
|
||||
)
|
||||
current_days = (
|
||||
self._claim_travel_days(current_claim) if current_claim is not None else Decimal("0")
|
||||
)
|
||||
department_amount = max(context["department_amount_total"], Decimal("0"))
|
||||
amount_share = (
|
||||
amount_total / department_amount if department_amount > Decimal("0") else Decimal("0")
|
||||
)
|
||||
|
||||
frequency_score = normalize_by_peer_percentiles(
|
||||
len(claims),
|
||||
context["peer_count_p50"],
|
||||
context["peer_count_p90"],
|
||||
)
|
||||
budget_score = score_by_bands(
|
||||
amount_share,
|
||||
[
|
||||
(Decimal("0.05"), 0),
|
||||
(Decimal("0.15"), 45),
|
||||
(Decimal("0.30"), 80),
|
||||
(Decimal("0.45"), 100),
|
||||
],
|
||||
)
|
||||
peer_deviation_score = normalize_by_peer_percentiles(
|
||||
amount_total,
|
||||
context["peer_amount_p50"],
|
||||
context["peer_amount_p90"],
|
||||
)
|
||||
adjustment_score = normalize_by_peer_percentiles(
|
||||
self._return_count(claims),
|
||||
context["peer_return_p50"],
|
||||
context["peer_return_p90"],
|
||||
)
|
||||
current_score = max(
|
||||
normalize_by_peer_percentiles(
|
||||
current_amount,
|
||||
context["peer_claim_amount_p50"],
|
||||
context["peer_claim_amount_p90"],
|
||||
),
|
||||
score_by_bands(
|
||||
current_days / context["peer_days_p75"] if context["peer_days_p75"] else 0,
|
||||
[
|
||||
(Decimal("1.0"), 0),
|
||||
(Decimal("1.3"), 40),
|
||||
(Decimal("1.8"), 80),
|
||||
(Decimal("2.2"), 100),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
result = evaluate_weighted_profile(
|
||||
"expense",
|
||||
[
|
||||
ProfileComponent(
|
||||
"frequency_score",
|
||||
"费用申请频次",
|
||||
frequency_score,
|
||||
len(claims),
|
||||
"次",
|
||||
Decimal("0.20"),
|
||||
),
|
||||
ProfileComponent(
|
||||
"amount_occupancy_score",
|
||||
"预算占用强度",
|
||||
budget_score,
|
||||
amount_share,
|
||||
"占比",
|
||||
Decimal("0.25"),
|
||||
),
|
||||
ProfileComponent(
|
||||
"peer_deviation_score",
|
||||
"同组金额偏离",
|
||||
peer_deviation_score,
|
||||
amount_total,
|
||||
"元",
|
||||
Decimal("0.25"),
|
||||
),
|
||||
ProfileComponent(
|
||||
"adjustment_history_score",
|
||||
"历史退回调减",
|
||||
adjustment_score,
|
||||
self._return_count(claims),
|
||||
"次",
|
||||
Decimal("0.15"),
|
||||
),
|
||||
ProfileComponent(
|
||||
"current_claim_deviation_score",
|
||||
"当前单据偏离",
|
||||
current_score,
|
||||
current_amount,
|
||||
"元",
|
||||
Decimal("0.15"),
|
||||
),
|
||||
],
|
||||
metrics={
|
||||
**self._common_metrics(context),
|
||||
"claim_count": len(claims),
|
||||
"amount_total": self._format_decimal(amount_total),
|
||||
"amount_share": self._format_decimal(amount_share),
|
||||
"current_claim_amount": self._format_decimal(current_amount),
|
||||
"requested_days": self._format_decimal(current_days),
|
||||
"peer_days_p75": self._format_decimal(context["peer_days_p75"]),
|
||||
"peer_unit_amount_p75": self._format_decimal(context["peer_unit_amount_p75"]),
|
||||
},
|
||||
)
|
||||
result.metrics["review_suggestions"] = build_review_suggestions(
|
||||
expense_profile_score=result.profile_score,
|
||||
process_quality_score=0,
|
||||
requested_days=current_days,
|
||||
peer_days_p75=context["peer_days_p75"],
|
||||
peer_unit_amount_p75=context["peer_unit_amount_p75"],
|
||||
)
|
||||
return result
|
||||
|
||||
def _calculate_process_quality_profile(self, context: dict[str, Any]):
|
||||
claims = context["employee_claims"]
|
||||
missing_attachment_count = sum(self._missing_attachment_count(claim) for claim in claims)
|
||||
mismatch_count = sum(1 for claim in claims if self._has_amount_mismatch(claim))
|
||||
missing_context_count = sum(self._missing_context_count(claim) for claim in claims)
|
||||
return_count = self._return_count(claims)
|
||||
resubmit_duration_score = 0
|
||||
|
||||
return evaluate_weighted_profile(
|
||||
"process_quality",
|
||||
[
|
||||
ProfileComponent(
|
||||
"return_count_score",
|
||||
"退单次数",
|
||||
score_by_bands(return_count, [(0, 0), (1, 45), (2, 70), (4, 100)]),
|
||||
return_count,
|
||||
"次",
|
||||
Decimal("0.25"),
|
||||
),
|
||||
ProfileComponent(
|
||||
"missing_attachment_score",
|
||||
"附件缺失",
|
||||
score_by_bands(missing_attachment_count, [(0, 0), (1, 35), (3, 75), (5, 100)]),
|
||||
missing_attachment_count,
|
||||
"项",
|
||||
Decimal("0.20"),
|
||||
),
|
||||
ProfileComponent(
|
||||
"invoice_mismatch_score",
|
||||
"票据金额不一致",
|
||||
score_by_bands(mismatch_count, [(0, 0), (1, 60), (2, 85)]),
|
||||
mismatch_count,
|
||||
"次",
|
||||
Decimal("0.20"),
|
||||
),
|
||||
ProfileComponent(
|
||||
"resubmit_duration_score",
|
||||
"重提耗时",
|
||||
resubmit_duration_score,
|
||||
0,
|
||||
"小时",
|
||||
Decimal("0.15"),
|
||||
"当前审批事件尚未结构化,暂不计入。",
|
||||
),
|
||||
ProfileComponent(
|
||||
"missing_business_context_score",
|
||||
"业务上下文缺失",
|
||||
score_by_bands(missing_context_count, [(0, 0), (1, 30), (3, 70), (5, 100)]),
|
||||
missing_context_count,
|
||||
"项",
|
||||
Decimal("0.20"),
|
||||
),
|
||||
],
|
||||
metrics={
|
||||
**self._common_metrics(context),
|
||||
"return_count": return_count,
|
||||
"missing_attachment_count": missing_attachment_count,
|
||||
"invoice_mismatch_count": mismatch_count,
|
||||
"missing_business_context_count": missing_context_count,
|
||||
"resubmit_duration_status": "unavailable",
|
||||
},
|
||||
)
|
||||
|
||||
def _calculate_ai_usage_profile(self, context: dict[str, Any]):
|
||||
runs = self._fetch_agent_runs(context["employee_identifiers"], context["cutoff"])
|
||||
tool_calls = [tool for run in runs for tool in run.tool_calls]
|
||||
failed_calls = [
|
||||
tool for tool in tool_calls if str(tool.status or "").lower() not in {"success", "ok"}
|
||||
]
|
||||
estimated_tokens = self._estimate_tokens(runs)
|
||||
override_score = 0
|
||||
|
||||
token_mode = "estimated_token_count" if estimated_tokens else "unavailable"
|
||||
return evaluate_weighted_profile(
|
||||
"ai_usage",
|
||||
[
|
||||
ProfileComponent(
|
||||
"ai_call_count_score",
|
||||
"AI 调用次数",
|
||||
score_by_bands(len(runs), [(0, 0), (3, 25), (10, 65), (20, 100)]),
|
||||
len(runs),
|
||||
"次",
|
||||
Decimal("0.25"),
|
||||
),
|
||||
ProfileComponent(
|
||||
"token_cost_score",
|
||||
"Token 使用强度",
|
||||
score_by_bands(
|
||||
estimated_tokens, [(0, 0), (2000, 25), (8000, 65), (20000, 100)]
|
||||
),
|
||||
estimated_tokens,
|
||||
"tokens",
|
||||
Decimal("0.25"),
|
||||
),
|
||||
ProfileComponent(
|
||||
"ai_generated_claim_ratio_score",
|
||||
"AI 生成申请比例",
|
||||
score_by_bands(len(runs), [(0, 0), (2, 20), (8, 60), (16, 90)]),
|
||||
len(runs),
|
||||
"次",
|
||||
Decimal("0.20"),
|
||||
),
|
||||
ProfileComponent(
|
||||
"ai_suggestion_override_score",
|
||||
"AI 建议覆盖",
|
||||
override_score,
|
||||
0,
|
||||
"次",
|
||||
Decimal("0.20"),
|
||||
"当前缺少结构化采纳字段,暂不计入。",
|
||||
),
|
||||
ProfileComponent(
|
||||
"failed_ai_call_score",
|
||||
"AI 调用失败",
|
||||
score_by_bands(len(failed_calls), [(0, 0), (1, 35), (3, 80)]),
|
||||
len(failed_calls),
|
||||
"次",
|
||||
Decimal("0.10"),
|
||||
),
|
||||
],
|
||||
metrics={
|
||||
**self._common_metrics(context),
|
||||
"ai_run_count": len(runs),
|
||||
"tool_call_count": len(tool_calls),
|
||||
"failed_tool_call_count": len(failed_calls),
|
||||
"token_count_mode": token_mode,
|
||||
"estimated_token_count": estimated_tokens,
|
||||
"exact_token_count": None,
|
||||
},
|
||||
)
|
||||
|
||||
def _calculate_approval_behavior_profile(self, context: dict[str, Any]):
|
||||
records = self._fetch_approval_records(context["employee"].id, context["cutoff"])
|
||||
approve_count = sum(
|
||||
1 for item in records if str(item.action or "").lower() in {"approve", "approved"}
|
||||
)
|
||||
return_count = sum(1 for item in records if "return" in str(item.action or "").lower())
|
||||
direct_approve_ratio = (
|
||||
Decimal(approve_count) / Decimal(len(records)) if records else Decimal("0")
|
||||
)
|
||||
|
||||
return evaluate_weighted_profile(
|
||||
"approval",
|
||||
[
|
||||
ProfileComponent(
|
||||
"avg_review_duration_score",
|
||||
"平均审核时长",
|
||||
0,
|
||||
0,
|
||||
"小时",
|
||||
Decimal("0.20"),
|
||||
"当前审批耗时字段尚未结构化。",
|
||||
),
|
||||
ProfileComponent(
|
||||
"sla_overdue_score",
|
||||
"SLA 超时",
|
||||
0,
|
||||
0,
|
||||
"次",
|
||||
Decimal("0.20"),
|
||||
"当前 SLA 字段尚未结构化。",
|
||||
),
|
||||
ProfileComponent(
|
||||
"direct_approve_ratio_score",
|
||||
"直接通过率",
|
||||
score_by_bands(
|
||||
direct_approve_ratio,
|
||||
[(Decimal("0.5"), 0), (Decimal("0.8"), 45), (Decimal("0.95"), 80)],
|
||||
),
|
||||
direct_approve_ratio,
|
||||
"比例",
|
||||
Decimal("0.20"),
|
||||
),
|
||||
ProfileComponent(
|
||||
"high_risk_approve_score",
|
||||
"高风险单据通过",
|
||||
0,
|
||||
0,
|
||||
"次",
|
||||
Decimal("0.20"),
|
||||
"待与风险画像联动。",
|
||||
),
|
||||
ProfileComponent(
|
||||
"system_advice_override_score",
|
||||
"系统建议覆盖",
|
||||
score_by_bands(return_count, [(0, 0), (2, 25), (5, 70)]),
|
||||
return_count,
|
||||
"次",
|
||||
Decimal("0.20"),
|
||||
),
|
||||
],
|
||||
metrics={
|
||||
**self._common_metrics(context),
|
||||
"approval_record_count": len(records),
|
||||
"approve_count": approve_count,
|
||||
"return_count": return_count,
|
||||
"direct_approve_ratio": self._format_decimal(direct_approve_ratio),
|
||||
},
|
||||
)
|
||||
|
||||
def _load_latest_snapshots(
|
||||
self,
|
||||
*,
|
||||
employee_id: str,
|
||||
window_days: int,
|
||||
expense_type_scope: str,
|
||||
scene: str,
|
||||
) -> list[EmployeeBehaviorProfileSnapshot]:
|
||||
allowed_types = PROFILE_TYPES_FOR_APPROVAL if scene == "approval" else None
|
||||
rows = self._query_latest_rows(
|
||||
employee_id=employee_id,
|
||||
window_days=window_days,
|
||||
expense_type_scope=expense_type_scope,
|
||||
allowed_types=allowed_types,
|
||||
)
|
||||
if rows or expense_type_scope == "overall":
|
||||
return rows
|
||||
return self._query_latest_rows(
|
||||
employee_id=employee_id,
|
||||
window_days=window_days,
|
||||
expense_type_scope="overall",
|
||||
allowed_types=allowed_types,
|
||||
)
|
||||
|
||||
def _query_latest_rows(
|
||||
self,
|
||||
*,
|
||||
employee_id: str,
|
||||
window_days: int,
|
||||
expense_type_scope: str,
|
||||
allowed_types: set[str] | None,
|
||||
) -> list[EmployeeBehaviorProfileSnapshot]:
|
||||
stmt = select(EmployeeBehaviorProfileSnapshot).where(
|
||||
EmployeeBehaviorProfileSnapshot.subject_id == employee_id,
|
||||
EmployeeBehaviorProfileSnapshot.window_days == window_days,
|
||||
EmployeeBehaviorProfileSnapshot.expense_type_scope == expense_type_scope,
|
||||
)
|
||||
if allowed_types:
|
||||
stmt = stmt.where(EmployeeBehaviorProfileSnapshot.profile_type.in_(allowed_types))
|
||||
|
||||
rows = list(
|
||||
self.db.scalars(
|
||||
stmt.order_by(EmployeeBehaviorProfileSnapshot.calculated_at.desc())
|
||||
).all()
|
||||
)
|
||||
latest_by_type: dict[str, EmployeeBehaviorProfileSnapshot] = {}
|
||||
for row in rows:
|
||||
latest_by_type.setdefault(row.profile_type, row)
|
||||
return list(latest_by_type.values())
|
||||
|
||||
def _serialize_latest_profile(
|
||||
self,
|
||||
*,
|
||||
employee: Employee,
|
||||
rows: list[EmployeeBehaviorProfileSnapshot],
|
||||
scene: str,
|
||||
window_days: int,
|
||||
expense_type_scope: str,
|
||||
) -> EmployeeProfileLatestRead:
|
||||
if not rows:
|
||||
return EmployeeProfileLatestRead(
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
scene=scene,
|
||||
window_days=window_days,
|
||||
expense_type_scope=expense_type_scope,
|
||||
empty_reason="当前员工尚未形成画像快照。",
|
||||
)
|
||||
|
||||
rows_by_type = {row.profile_type: row for row in rows}
|
||||
expense_score = (
|
||||
rows_by_type.get("expense").profile_score if rows_by_type.get("expense") else 0
|
||||
)
|
||||
process_score = (
|
||||
rows_by_type.get("process_quality").profile_score
|
||||
if rows_by_type.get("process_quality")
|
||||
else 0
|
||||
)
|
||||
review_score = calculate_review_priority_score(
|
||||
expense_profile_score=expense_score,
|
||||
process_quality_score=process_score,
|
||||
)
|
||||
review_level = level_from_score(review_score)
|
||||
anchor = rows_by_type.get("expense") or rows[0]
|
||||
suggestions = build_latest_review_suggestions(
|
||||
rows=rows,
|
||||
expense_score=expense_score,
|
||||
process_score=process_score,
|
||||
)
|
||||
profile_payloads = build_profile_payloads(rows)
|
||||
profile_tags = build_profile_tags(profile_payloads, scene=scene)
|
||||
radar = build_profile_radar(profile_payloads, profile_tags, scene=scene)
|
||||
|
||||
return EmployeeProfileLatestRead(
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
scene=scene,
|
||||
window_days=window_days,
|
||||
expense_type_scope=expense_type_scope,
|
||||
calculated_at=max(row.calculated_at for row in rows if row.calculated_at),
|
||||
peer_group=EmployeeProfilePeerGroupRead(
|
||||
key=anchor.peer_group_key,
|
||||
fallback_level=anchor.peer_group_fallback_level,
|
||||
sample_size=int((anchor.metrics_json or {}).get("peer_sample_size") or 0),
|
||||
),
|
||||
review_priority_score=review_score,
|
||||
review_priority_level=review_level,
|
||||
review_priority_label=LEVEL_LABELS.get(review_level, review_level),
|
||||
profiles=[
|
||||
EmployeeProfileRead(
|
||||
profile_type=payload["profile_type"],
|
||||
profile_label=PROFILE_LABELS.get(
|
||||
payload["profile_type"], payload["profile_type"]
|
||||
),
|
||||
score=payload["score"],
|
||||
level=payload["level"],
|
||||
level_label=LEVEL_LABELS.get(payload["level"], payload["level"]),
|
||||
metrics=payload["metrics"],
|
||||
top_contributors=payload["top_contributors"],
|
||||
)
|
||||
for payload in profile_payloads
|
||||
],
|
||||
profile_tags=profile_tags,
|
||||
radar=radar,
|
||||
review_suggestions=suggestions,
|
||||
)
|
||||
|
||||
def _resolve_target_employee_ids(self, *, limit: int) -> list[str]:
|
||||
cutoff = datetime.now(UTC) - timedelta(days=180)
|
||||
claim_stmt = select(ExpenseClaim.employee_id).where(
|
||||
ExpenseClaim.employee_id.is_not(None),
|
||||
or_(
|
||||
ExpenseClaim.occurred_at >= cutoff,
|
||||
ExpenseClaim.status.in_(PENDING_CLAIM_STATUSES),
|
||||
),
|
||||
)
|
||||
snapshot_stmt = select(EmployeeBehaviorProfileSnapshot.subject_id).where(
|
||||
EmployeeBehaviorProfileSnapshot.profile_level.in_(ATTENTION_LEVELS)
|
||||
)
|
||||
ordered: list[str] = []
|
||||
for value in [*self.db.scalars(claim_stmt).all(), *self.db.scalars(snapshot_stmt).all()]:
|
||||
employee_id = str(value or "").strip()
|
||||
if employee_id and employee_id not in ordered:
|
||||
ordered.append(employee_id)
|
||||
if len(ordered) >= limit:
|
||||
break
|
||||
return ordered
|
||||
|
||||
def _fetch_claims_since(self, cutoff: datetime) -> list[ExpenseClaim]:
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.options(selectinload(ExpenseClaim.items), selectinload(ExpenseClaim.employee))
|
||||
.where(ExpenseClaim.occurred_at >= cutoff)
|
||||
)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def _fetch_agent_runs(self, identifiers: set[str], cutoff: datetime) -> list[AgentRun]:
|
||||
if not identifiers:
|
||||
return []
|
||||
stmt = (
|
||||
select(AgentRun)
|
||||
.options(selectinload(AgentRun.tool_calls))
|
||||
.where(AgentRun.started_at >= cutoff, AgentRun.user_id.in_(identifiers))
|
||||
)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def _fetch_approval_records(self, employee_id: str, cutoff: datetime) -> list[ApprovalRecord]:
|
||||
stmt = select(ApprovalRecord).where(
|
||||
ApprovalRecord.approver_id == employee_id,
|
||||
ApprovalRecord.created_at >= cutoff,
|
||||
)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def _resolve_peer_claims(
|
||||
self,
|
||||
*,
|
||||
claims: list[ExpenseClaim],
|
||||
employee: Employee,
|
||||
) -> tuple[list[ExpenseClaim], int]:
|
||||
department_name = employee.organization_unit.name if employee.organization_unit else ""
|
||||
department_claims = [
|
||||
claim
|
||||
for claim in claims
|
||||
if claim.department_id == employee.organization_unit_id
|
||||
or (department_name and claim.department_name == department_name)
|
||||
]
|
||||
if len({self._claim_employee_key(claim) for claim in department_claims}) >= 3:
|
||||
return department_claims, 0
|
||||
return claims, 3
|
||||
|
||||
def _resolve_scope_from_claim(self, claim_id: str | None, expense_type_scope: str) -> str:
|
||||
normalized = str(expense_type_scope or "overall").strip() or "overall"
|
||||
if normalized != "overall" or not claim_id:
|
||||
return normalized
|
||||
claim = self.db.get(ExpenseClaim, claim_id)
|
||||
return str(claim.expense_type or "overall").strip() if claim is not None else normalized
|
||||
|
||||
def _is_claim_in_scope(self, claim: ExpenseClaim, expense_type_scope: str) -> bool:
|
||||
scope = str(expense_type_scope or "overall").strip()
|
||||
if scope == "overall":
|
||||
return True
|
||||
if scope == "entertainment":
|
||||
return claim.expense_type in ENTERTAINMENT_EXPENSE_TYPES
|
||||
if scope == "travel":
|
||||
return claim.expense_type in TRAVEL_EXPENSE_TYPES
|
||||
return claim.expense_type == scope
|
||||
|
||||
def _common_metrics(self, context: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"window_days": context["window_days"],
|
||||
"expense_type_scope": context["expense_type_scope"],
|
||||
"peer_group_key": context["peer_group_key"],
|
||||
"peer_group_fallback_level": context["peer_group_fallback_level"],
|
||||
"peer_sample_size": context["peer_sample_size"],
|
||||
"algorithm_version": ALGORITHM_VERSION,
|
||||
}
|
||||
@@ -22,6 +22,7 @@ from app.services.employee_spreadsheet import (
|
||||
parse_employee_workbook,
|
||||
)
|
||||
from app.services.employee_seed import normalize_organization_unit_code
|
||||
from app.services.employee_bank_info import apply_default_bank_info
|
||||
|
||||
logger = get_logger("app.services.employee")
|
||||
|
||||
@@ -72,6 +73,9 @@ class EmployeeImportCoordinator:
|
||||
employee.manager.employee_no if employee.manager else "",
|
||||
employee.finance_owner_name or "",
|
||||
employee.cost_center or "",
|
||||
employee.bank_account_name or "",
|
||||
employee.bank_name or "",
|
||||
employee.bank_account_no or "",
|
||||
employee.employment_status,
|
||||
role_codes,
|
||||
]
|
||||
@@ -267,9 +271,13 @@ class EmployeeImportCoordinator:
|
||||
employee.grade = row.grade
|
||||
employee.finance_owner_name = row.finance_owner_name
|
||||
employee.cost_center = row.cost_center
|
||||
employee.bank_account_name = row.bank_account_name
|
||||
employee.bank_name = row.bank_name
|
||||
employee.bank_account_no = row.bank_account_no
|
||||
employee.employment_status = row.employment_status
|
||||
employee.sync_state = "已同步"
|
||||
employee.last_sync_at = now
|
||||
apply_default_bank_info(employee)
|
||||
|
||||
organization_code = normalize_organization_unit_code(row.organization_unit_code)
|
||||
if organization_code:
|
||||
|
||||
25
server/src/app/services/employee_schema.py
Normal file
25
server/src/app/services/employee_schema.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
EMPLOYEE_SCHEMA_COLUMNS: dict[str, str] = {
|
||||
"password_hash": "ALTER TABLE employees ADD COLUMN password_hash VARCHAR(255)",
|
||||
"compliance_score": "ALTER TABLE employees ADD COLUMN compliance_score INTEGER DEFAULT 100 NOT NULL",
|
||||
"bank_name": "ALTER TABLE employees ADD COLUMN bank_name VARCHAR(120)",
|
||||
"bank_account_no": "ALTER TABLE employees ADD COLUMN bank_account_no VARCHAR(80)",
|
||||
"bank_account_name": "ALTER TABLE employees ADD COLUMN bank_account_name VARCHAR(100)",
|
||||
}
|
||||
|
||||
|
||||
def ensure_employee_schema(db: Session) -> None:
|
||||
bind = db.get_bind()
|
||||
inspector = inspect(bind)
|
||||
if "employees" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
column_names = {column["name"] for column in inspector.get_columns("employees")}
|
||||
for column_name, ddl in EMPLOYEE_SCHEMA_COLUMNS.items():
|
||||
if column_name not in column_names:
|
||||
db.execute(text(ddl))
|
||||
db.flush()
|
||||
@@ -62,6 +62,9 @@ def serialize_employee(
|
||||
joinDate=format_date(employee.join_date),
|
||||
location=employee.location,
|
||||
costCenter=employee.cost_center,
|
||||
bankName=employee.bank_name,
|
||||
bankAccountNo=employee.bank_account_no,
|
||||
bankAccountName=employee.bank_account_name,
|
||||
updatedAt=format_datetime(employee.updated_at or employee.created_at),
|
||||
lastSync=format_datetime(employee.last_sync_at),
|
||||
syncState=employee.sync_state,
|
||||
|
||||
@@ -26,6 +26,9 @@ EMPLOYEE_HEADERS: tuple[str, ...] = (
|
||||
"直属上级工号",
|
||||
"财务归口",
|
||||
"成本中心",
|
||||
"银行户名",
|
||||
"开户行",
|
||||
"银行账号",
|
||||
"在职状态*",
|
||||
"角色编码",
|
||||
)
|
||||
@@ -45,6 +48,9 @@ HEADER_TO_FIELD: dict[str, str] = {
|
||||
"直属上级工号": "manager_employee_no",
|
||||
"财务归口": "finance_owner_name",
|
||||
"成本中心": "cost_center",
|
||||
"银行户名": "bank_account_name",
|
||||
"开户行": "bank_name",
|
||||
"银行账号": "bank_account_no",
|
||||
"在职状态*": "employment_status",
|
||||
"角色编码": "role_codes",
|
||||
}
|
||||
@@ -72,6 +78,9 @@ class EmployeeImportRow:
|
||||
manager_employee_no: str | None
|
||||
finance_owner_name: str | None
|
||||
cost_center: str | None
|
||||
bank_account_name: str | None
|
||||
bank_name: str | None
|
||||
bank_account_no: str | None
|
||||
employment_status: str
|
||||
role_codes: list[str]
|
||||
|
||||
@@ -107,6 +116,9 @@ def build_import_template_bytes() -> bytes:
|
||||
("直属上级工号", "可选,须为系统中已有员工编号,或出现在本次导入表中。"),
|
||||
("财务归口", "可选。"),
|
||||
("成本中心", "可选。"),
|
||||
("银行户名", "可选,留空时默认使用员工姓名。"),
|
||||
("开户行", "可选,留空时使用系统默认演示开户行。"),
|
||||
("银行账号", "可选,留空时系统按员工编号生成演示账号。"),
|
||||
("在职状态*", "必填:在职、试用中、停用。"),
|
||||
("角色编码", "可选,多个角色用英文逗号分隔,例如 user,finance;留空默认为 user。"),
|
||||
("导入规则", "全部校验通过后才写入数据库;任一行有错则整批不导入,原有数据保持不变。"),
|
||||
@@ -319,6 +331,9 @@ def _parse_data_row(
|
||||
manager_employee_no=values["manager_employee_no"] or None,
|
||||
finance_owner_name=values["finance_owner_name"] or None,
|
||||
cost_center=values["cost_center"] or None,
|
||||
bank_account_name=values["bank_account_name"] or None,
|
||||
bank_name=values["bank_name"] or None,
|
||||
bank_account_no=values["bank_account_no"] or None,
|
||||
employment_status=employment_status,
|
||||
role_codes=role_codes or list(DEFAULT_ROLE_CODES),
|
||||
),
|
||||
|
||||
@@ -17,6 +17,8 @@ from app.services.expense_claim_workflow_constants import (
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
FINANCE_APPROVAL_STAGE,
|
||||
PAYMENT_PAID_STAGE,
|
||||
PAYMENT_PENDING_STATUS,
|
||||
)
|
||||
|
||||
|
||||
@@ -29,6 +31,7 @@ BUDGET_MONITOR_APPROVAL_GRADE = "P8"
|
||||
CLAIM_DELETE_ROLE_CODES = {"executive"}
|
||||
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
|
||||
APPLICATION_ARCHIVED_STAGES = (APPROVAL_DONE_STAGE, "申请归档", "completed")
|
||||
ARCHIVED_REIMBURSEMENT_STAGES = (ARCHIVE_ACCOUNTING_STAGE, PAYMENT_PAID_STAGE, "completed")
|
||||
|
||||
|
||||
class ExpenseClaimAccessPolicy:
|
||||
@@ -60,7 +63,7 @@ class ExpenseClaimAccessPolicy:
|
||||
normalized_type.like("%\\_application", escape="\\"),
|
||||
)
|
||||
return or_(
|
||||
stage == ARCHIVE_ACCOUNTING_STAGE,
|
||||
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
|
||||
stage == "completed",
|
||||
and_(
|
||||
application_condition,
|
||||
@@ -72,7 +75,7 @@ class ExpenseClaimAccessPolicy:
|
||||
or_(
|
||||
stage == "",
|
||||
stage.is_(None),
|
||||
stage == ARCHIVE_ACCOUNTING_STAGE,
|
||||
stage.in_(ARCHIVED_REIMBURSEMENT_STAGES),
|
||||
stage == "completed",
|
||||
),
|
||||
),
|
||||
@@ -88,7 +91,7 @@ class ExpenseClaimAccessPolicy:
|
||||
def is_archived_claim(claim: ExpenseClaim) -> bool:
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
stage = str(claim.approval_stage or "").strip()
|
||||
if stage in {ARCHIVE_ACCOUNTING_STAGE, "completed"}:
|
||||
if stage in set(ARCHIVED_REIMBURSEMENT_STAGES):
|
||||
return True
|
||||
normalized_type = str(claim.expense_type or "").strip().lower()
|
||||
claim_no = str(claim.claim_no or "").strip().upper()
|
||||
@@ -103,7 +106,7 @@ class ExpenseClaimAccessPolicy:
|
||||
and stage in APPLICATION_ARCHIVED_STAGES
|
||||
):
|
||||
return True
|
||||
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", ARCHIVE_ACCOUNTING_STAGE, "completed"}
|
||||
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", *ARCHIVED_REIMBURSEMENT_STAGES}
|
||||
|
||||
def can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
@@ -136,6 +139,15 @@ class ExpenseClaimAccessPolicy:
|
||||
)
|
||||
return False
|
||||
|
||||
def can_mark_claim_paid(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
if str(claim.status or "").strip().lower() != PAYMENT_PENDING_STATUS:
|
||||
return False
|
||||
if self.is_claim_owned_by_current_user(claim, current_user):
|
||||
return False
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
return bool(self.normalize_role_codes(current_user) & PRIVILEGED_CLAIM_ROLE_CODES)
|
||||
|
||||
def is_current_direct_manager_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
if not (role_codes & APPROVAL_VISIBLE_CLAIM_ROLE_CODES):
|
||||
|
||||
@@ -7,10 +7,13 @@ 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,
|
||||
PAYMENT_PAID_STAGE,
|
||||
PAYMENT_PAID_STATUS,
|
||||
PAYMENT_PENDING_STAGE,
|
||||
PAYMENT_PENDING_STATUS,
|
||||
)
|
||||
|
||||
|
||||
@@ -67,9 +70,9 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
approval_source = "finance_approval"
|
||||
event_type = "expense_claim_finance_approval"
|
||||
label = "财务审核通过"
|
||||
next_status = "approved"
|
||||
next_stage = ARCHIVE_ACCOUNTING_STAGE
|
||||
default_message = "{operator} 已完成财务审核,进入归档入账。"
|
||||
next_status = PAYMENT_PENDING_STATUS
|
||||
next_stage = PAYMENT_PENDING_STAGE
|
||||
default_message = "{operator} 已完成财务审核,进入待付款。"
|
||||
else:
|
||||
raise ValueError("当前节点不支持审批通过。")
|
||||
|
||||
@@ -160,6 +163,65 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
|
||||
return claim
|
||||
|
||||
def mark_claim_paid(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
):
|
||||
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 == PAYMENT_PAID_STATUS:
|
||||
raise ValueError("该报销单已付款,无需重复确认。")
|
||||
if normalized_status != PAYMENT_PENDING_STATUS:
|
||||
raise ValueError("只有待付款状态的报销单可以确认已付款。")
|
||||
if not self._access_policy.can_mark_claim_paid(current_user, claim):
|
||||
raise ValueError("只有财务人员或高级财务人员可以确认付款,且不能处理本人单据。")
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
operator = self._access_policy.resolve_current_user_display_name(current_user)
|
||||
previous_stage = str(claim.approval_stage or "").strip()
|
||||
payment_flag = {
|
||||
"source": "payment",
|
||||
"event_type": "expense_claim_payment_completed",
|
||||
"payment_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": "付款完成",
|
||||
"message": f"{operator} 已确认付款,报销单进入已付款。",
|
||||
"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": PAYMENT_PAID_STATUS,
|
||||
"next_approval_stage": PAYMENT_PAID_STAGE,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
claim.status = PAYMENT_PAID_STATUS
|
||||
claim.approval_stage = PAYMENT_PAID_STAGE
|
||||
claim.risk_flags_json = [*list(claim.risk_flags_json or []), payment_flag]
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=operator,
|
||||
action="expense_claim.mark_paid",
|
||||
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 [])):
|
||||
|
||||
@@ -3,4 +3,7 @@ BUDGET_MANAGER_APPROVAL_STAGE = "预算管理者审批"
|
||||
FINANCE_APPROVAL_STAGE = "财务审批"
|
||||
APPROVAL_DONE_STAGE = "审批完成"
|
||||
ARCHIVE_ACCOUNTING_STAGE = "归档入账"
|
||||
|
||||
PAYMENT_PENDING_STATUS = "pending_payment"
|
||||
PAYMENT_PAID_STATUS = "paid"
|
||||
PAYMENT_PENDING_STAGE = "待付款"
|
||||
PAYMENT_PAID_STAGE = "已付款"
|
||||
|
||||
24
server/src/app/services/hermes_employee_profile_scanner.py
Normal file
24
server/src/app/services/hermes_employee_profile_scanner.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.services.employee_behavior_profile_service import EmployeeBehaviorProfileService
|
||||
|
||||
logger = get_logger("app.services.hermes_employee_profile_scanner")
|
||||
|
||||
|
||||
class HermesEmployeeProfileScannerService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def scan_employee_profiles(self, log_id: str | None = None) -> dict:
|
||||
logger.info("Starting Hermes employee behavior profile scan...")
|
||||
summary = EmployeeBehaviorProfileService(self.db).scan_profiles(log_id=log_id)
|
||||
logger.info(
|
||||
"Hermes employee profile scan completed: %s",
|
||||
json.dumps(summary, ensure_ascii=False),
|
||||
)
|
||||
return summary
|
||||
@@ -1,8 +1,6 @@
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
import traceback
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -10,8 +8,9 @@ from sqlalchemy.orm import Session
|
||||
from app.core.logging import get_logger
|
||||
from app.db.session import get_session_factory
|
||||
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
|
||||
from app.services.hermes_risk_scanner import HermesRiskScannerService
|
||||
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService
|
||||
from app.services.hermes_expense_report import HermesExpenseReportService
|
||||
from app.services.hermes_risk_scanner import HermesRiskScannerService
|
||||
|
||||
logger = get_logger("app.services.hermes_scheduler")
|
||||
|
||||
@@ -52,7 +51,7 @@ class HermesScheduler:
|
||||
self._check_and_run_tasks()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in Hermes run loop: {e}", exc_info=True)
|
||||
|
||||
|
||||
# 睡眠一分钟,每分钟轮询一次
|
||||
if self._stop_event.wait(60.0):
|
||||
break
|
||||
@@ -61,50 +60,95 @@ class HermesScheduler:
|
||||
db = self.session_factory()
|
||||
try:
|
||||
# 获取所有启用的任务配置
|
||||
stmt = select(HermesTaskConfig).where(HermesTaskConfig.is_enabled == True)
|
||||
stmt = select(HermesTaskConfig).where(HermesTaskConfig.is_enabled)
|
||||
configs = db.scalars(stmt).all()
|
||||
|
||||
|
||||
for config in configs:
|
||||
if self._should_run_now(db, config):
|
||||
self._execute_task(db, config)
|
||||
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _should_run_now(self, db: Session, config: HermesTaskConfig) -> bool:
|
||||
# 简单策略:检查是否在过去24小时内运行过。
|
||||
# 如果没有 croniter 库,我们暂时采用按天执行的简化逻辑
|
||||
stmt = select(HermesTaskExecutionLog).where(
|
||||
HermesTaskExecutionLog.config_id == config.id,
|
||||
HermesTaskExecutionLog.status.in_(["success", "running"])
|
||||
).order_by(HermesTaskExecutionLog.started_at.desc()).limit(1)
|
||||
|
||||
scheduled_at = self._resolve_last_scheduled_at(config.cron_expression)
|
||||
stmt = (
|
||||
select(HermesTaskExecutionLog)
|
||||
.where(
|
||||
HermesTaskExecutionLog.config_id == config.id,
|
||||
HermesTaskExecutionLog.status.in_(["success", "running"]),
|
||||
)
|
||||
.order_by(HermesTaskExecutionLog.started_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
last_log = db.scalars(stmt).first()
|
||||
|
||||
|
||||
if not last_log:
|
||||
return True # 从未执行过,立即执行
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
elapsed_hours = (now - last_log.started_at).total_seconds() / 3600
|
||||
|
||||
# 简化:只要距离上次成功执行超过了 23.5 小时,就认为该跑了(模拟每天跑一次)
|
||||
if elapsed_hours >= 23.5:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
return last_log.started_at < scheduled_at
|
||||
|
||||
def _resolve_last_scheduled_at(self, cron_expression: str | None) -> datetime:
|
||||
now = datetime.now(UTC)
|
||||
parsed = self._parse_simple_cron(cron_expression)
|
||||
if parsed is None:
|
||||
return now - timedelta(hours=23.5)
|
||||
|
||||
minute, hour, weekday = parsed
|
||||
scheduled_at = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if weekday is None:
|
||||
if scheduled_at > now:
|
||||
scheduled_at -= timedelta(days=1)
|
||||
return scheduled_at
|
||||
|
||||
days_back = (now.weekday() - weekday) % 7
|
||||
scheduled_at = (now - timedelta(days=days_back)).replace(
|
||||
hour=hour,
|
||||
minute=minute,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
)
|
||||
if scheduled_at > now:
|
||||
scheduled_at -= timedelta(days=7)
|
||||
return scheduled_at
|
||||
|
||||
def _parse_simple_cron(self, cron_expression: str | None) -> tuple[int, int, int | None] | None:
|
||||
parts = str(cron_expression or "").strip().split()
|
||||
if len(parts) < 5:
|
||||
return None
|
||||
minute = self._parse_cron_number(parts[0], minimum=0, maximum=59)
|
||||
hour = self._parse_cron_number(parts[1], minimum=0, maximum=23)
|
||||
if minute is None or hour is None:
|
||||
return None
|
||||
|
||||
weekday: int | None = None
|
||||
if parts[4] != "*":
|
||||
raw_weekday = self._parse_cron_number(parts[4], minimum=0, maximum=7)
|
||||
if raw_weekday is None:
|
||||
return None
|
||||
weekday = 6 if raw_weekday in {0, 7} else raw_weekday - 1
|
||||
return minute, hour, weekday
|
||||
|
||||
@staticmethod
|
||||
def _parse_cron_number(value: str, *, minimum: int, maximum: int) -> int | None:
|
||||
try:
|
||||
parsed = int(str(value).strip())
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed < minimum or parsed > maximum:
|
||||
return None
|
||||
return parsed
|
||||
|
||||
def _execute_task(self, db: Session, config: HermesTaskConfig) -> None:
|
||||
logger.info(f"Triggering Hermes task: {config.task_type} (Config ID: {config.id})")
|
||||
|
||||
|
||||
# 创建执行日志,标记为 running
|
||||
log_record = HermesTaskExecutionLog(
|
||||
config_id=config.id,
|
||||
status="running"
|
||||
)
|
||||
log_record = HermesTaskExecutionLog(config_id=config.id, status="running")
|
||||
db.add(log_record)
|
||||
db.commit()
|
||||
db.refresh(log_record)
|
||||
|
||||
|
||||
try:
|
||||
if config.task_type == "global_risk_scan":
|
||||
scanner = HermesRiskScannerService(db)
|
||||
@@ -112,17 +156,26 @@ class HermesScheduler:
|
||||
elif config.task_type == "weekly_expense_report":
|
||||
reporter = HermesExpenseReportService(db)
|
||||
reporter.generate_weekly_report(log_id=log_record.id)
|
||||
|
||||
elif config.task_type == "employee_behavior_profile_scan":
|
||||
scanner = HermesEmployeeProfileScannerService(db)
|
||||
summary = scanner.scan_employee_profiles(log_id=log_record.id)
|
||||
log_record.result_summary = (
|
||||
f"员工画像巡检完成:目标 {summary.get('target_employee_count', 0)} 人,"
|
||||
f"生成 {summary.get('snapshot_count', 0)} 条快照,"
|
||||
f"重点关注 {summary.get('high_attention_employee_count', 0)} 人。"
|
||||
)
|
||||
|
||||
log_record.status = "success"
|
||||
log_record.completed_at = datetime.now(timezone.utc)
|
||||
log_record.result_summary = "Task executed successfully."
|
||||
|
||||
log_record.completed_at = datetime.now(UTC)
|
||||
if not log_record.result_summary:
|
||||
log_record.result_summary = "Task executed successfully."
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to execute Hermes task {config.task_type}: {e}")
|
||||
log_record.status = "failed"
|
||||
log_record.completed_at = datetime.now(timezone.utc)
|
||||
log_record.completed_at = datetime.now(UTC)
|
||||
log_record.error_trace = traceback.format_exc()
|
||||
|
||||
|
||||
finally:
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from app.core.config import ROOT_DIR
|
||||
from app.core.config import ROOT_DIR, SERVER_DIR
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -43,18 +43,28 @@ def sync_repository_hermes_skills(
|
||||
source_root: Path | None = None,
|
||||
target_root: Path | None = None,
|
||||
) -> Path:
|
||||
source = source_root or ROOT_DIR / "hermes" / "skills"
|
||||
target = target_root or get_hermes_home() / "skills"
|
||||
if not source.exists():
|
||||
sources = (
|
||||
(source_root,)
|
||||
if source_root is not None
|
||||
else (
|
||||
SERVER_DIR / "src" / "app" / "skills",
|
||||
ROOT_DIR / "hermes" / "skills",
|
||||
)
|
||||
)
|
||||
|
||||
existing_sources = [source for source in sources if source and source.exists()]
|
||||
if not existing_sources:
|
||||
return target
|
||||
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
for item in source.iterdir():
|
||||
destination = target / item.name
|
||||
if item.is_dir():
|
||||
shutil.copytree(item, destination, dirs_exist_ok=True)
|
||||
elif item.is_file():
|
||||
shutil.copy2(item, destination)
|
||||
for source in existing_sources:
|
||||
for item in source.iterdir():
|
||||
destination = target / item.name
|
||||
if item.is_dir():
|
||||
shutil.copytree(item, destination, dirs_exist_ok=True)
|
||||
elif item.is_file():
|
||||
shutil.copy2(item, destination)
|
||||
return target
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.agent_enums import AgentName, AgentPermissionLevel, AgentRunSource, AgentRunStatus
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.services.agent_foundation_constants import DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE
|
||||
from app.services.agent_runs import AgentRunService
|
||||
from app.services.knowledge import (
|
||||
KNOWLEDGE_INGEST_STATUS_FAILED,
|
||||
@@ -109,7 +110,7 @@ class KnowledgeSyncDispatchService:
|
||||
)
|
||||
|
||||
task_asset = self.db.scalar(
|
||||
select(AgentAsset).where(AgentAsset.code == "task.hermes.knowledge_index_sync")
|
||||
select(AgentAsset).where(AgentAsset.code == DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE)
|
||||
)
|
||||
run = self.run_service.create_run(
|
||||
agent=AgentName.HERMES.value,
|
||||
|
||||
@@ -27,9 +27,11 @@ EXPENSE_STATUS_LABELS = {
|
||||
"review": "审核中",
|
||||
"approved": "已通过",
|
||||
"rejected": "已驳回",
|
||||
"pending_payment": "待付款",
|
||||
"paid": "归档",
|
||||
}
|
||||
EXPENSE_QUERY_STATUS_KEYWORDS = (
|
||||
(("待付款", "待支付", "待打款"), ("pending_payment",)),
|
||||
(("归档", "已归档", "入账", "已入账", "已付款"), ("archived",)),
|
||||
(("审批通过", "审核通过", "已通过", "已审核"), ("approved",)),
|
||||
(("审批中", "审核中", "进行中", "流程中"), ("submitted", "review")),
|
||||
@@ -48,6 +50,9 @@ EXPENSE_STATUS_ALIASES = {
|
||||
"审批通过": "approved",
|
||||
"审核通过": "approved",
|
||||
"已审核": "approved",
|
||||
"待付款": "pending_payment",
|
||||
"待支付": "pending_payment",
|
||||
"待打款": "pending_payment",
|
||||
"审批中": "review",
|
||||
"审核中": "review",
|
||||
"进行中": "review",
|
||||
@@ -65,10 +70,11 @@ EXPENSE_STATUS_ALIASES = {
|
||||
EXPENSE_STATUS_GROUP_LABELS = {
|
||||
"draft": "草稿",
|
||||
"in_progress": "审批中",
|
||||
"pending_payment": "待付款",
|
||||
"completed": "审批完成",
|
||||
"other": "其他状态",
|
||||
}
|
||||
EXPENSE_STATUS_GROUP_ORDER = ("draft", "in_progress", "completed", "other")
|
||||
EXPENSE_STATUS_GROUP_ORDER = ("draft", "in_progress", "pending_payment", "completed", "other")
|
||||
EXPENSE_RISK_LEVEL_LABELS = {
|
||||
"high": "高风险",
|
||||
"medium": "中风险",
|
||||
@@ -348,6 +354,8 @@ class OrchestratorDatabaseQueryBuilder:
|
||||
return "draft", EXPENSE_STATUS_GROUP_LABELS["draft"]
|
||||
if normalized in {"submitted", "review"}:
|
||||
return "in_progress", EXPENSE_STATUS_GROUP_LABELS["in_progress"]
|
||||
if normalized == "pending_payment":
|
||||
return "pending_payment", EXPENSE_STATUS_GROUP_LABELS["pending_payment"]
|
||||
if normalized in {"approved", "paid"}:
|
||||
return "completed", EXPENSE_STATUS_GROUP_LABELS["completed"]
|
||||
return "other", EXPENSE_STATUS_GROUP_LABELS["other"]
|
||||
|
||||
@@ -707,10 +707,10 @@ class SettingsService:
|
||||
parts = time_str.split(":")
|
||||
if len(parts) == 2:
|
||||
# 简单映射:把时分放进去,后面保留为 * * * (或者保留旧的后半段)
|
||||
# 这里偷个懒,风险扫描每天跑,周报每周一跑
|
||||
# 这里偷个懒,风险扫描每天跑,周报和员工画像默认每周一跑
|
||||
if task_type == "global_risk_scan":
|
||||
config.cron_expression = f"{int(parts[1])} {int(parts[0])} * * *"
|
||||
elif task_type == "weekly_expense_report":
|
||||
elif task_type in {"weekly_expense_report", "employee_behavior_profile_scan"}:
|
||||
config.cron_expression = f"{int(parts[1])} {int(parts[0])} * * 1"
|
||||
else:
|
||||
config.cron_expression = f"{int(parts[1])} {int(parts[0])} * * *"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: finance-policy-knowledge-organizer
|
||||
description: 用于整理公司财务知识制度,把制度文件、报销口径、审批要求和知识库更新沉淀为可检索、可引用、可复核的结构化知识。
|
||||
---
|
||||
|
||||
# 整理公司财务知识制度
|
||||
|
||||
## 使用场景
|
||||
|
||||
当任务要求整理公司财务制度、报销政策、审批口径、票据要求、预算规范或知识库资料时,使用该能力。
|
||||
|
||||
## 工作目标
|
||||
|
||||
- 读取指定范围内的财务制度、知识库文档和变更材料。
|
||||
- 按制度主题、费用类型、审批阶段、票据要求和风险口径进行归类。
|
||||
- 抽取可引用的条款、适用范围、例外条件、执行口径和待确认问题。
|
||||
- 保留原始来源,不覆盖制度原文,不直接发布线上规则。
|
||||
- 输出管理员可复核的知识整理结果。
|
||||
|
||||
## 处理步骤
|
||||
|
||||
1. 确认整理范围,包括文件夹、文档、变更时间和是否只处理增量内容。
|
||||
2. 建立目录结构,优先按费用类型、审批场景和制度主题归档。
|
||||
3. 提取制度条款,保留条款标题、正文摘要、适用对象、触发条件和来源位置。
|
||||
4. 标记冲突、缺失或需要人工确认的口径,避免自行补写制度结论。
|
||||
5. 生成结构化结果,包含知识条目、来源索引、归类标签和复核建议。
|
||||
|
||||
## 输出要求
|
||||
|
||||
输出应包含:
|
||||
|
||||
- `summary`:本次整理概况。
|
||||
- `categories`:制度主题和费用类型分类。
|
||||
- `knowledge_items`:可复核的知识条目。
|
||||
- `source_refs`:来源文件、章节或页码。
|
||||
- `open_questions`:需要管理员确认的问题。
|
||||
- `next_actions`:后续维护建议。
|
||||
|
||||
## 执行约束
|
||||
|
||||
- 不凭空编造制度内容。
|
||||
- 不把未确认内容写成正式规则。
|
||||
- 不直接修改原始制度文件。
|
||||
- 对金额、期限、城市档位和审批权限等高风险字段必须保留来源。
|
||||
- 没有明确来源时,只能标记为待确认。
|
||||
177
server/tests/test_employee_behavior_profile_algorithm.py
Normal file
177
server/tests/test_employee_behavior_profile_algorithm.py
Normal file
@@ -0,0 +1,177 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from app.algorithem.employee_behavior_profile import (
|
||||
ProfileComponent,
|
||||
build_review_suggestions,
|
||||
calculate_review_priority_score,
|
||||
evaluate_weighted_profile,
|
||||
level_from_score,
|
||||
normalize_by_peer_percentiles,
|
||||
percentile,
|
||||
)
|
||||
from app.algorithem.employee_behavior_profile_tags import build_profile_radar, build_profile_tags
|
||||
|
||||
|
||||
def test_peer_percentile_normalization_and_level_mapping() -> None:
|
||||
assert percentile([10, 20, 30, 40, 50], 90) == Decimal("46.0")
|
||||
assert normalize_by_peer_percentiles(35, 20, 50) == 50
|
||||
assert normalize_by_peer_percentiles(10, 20, 50) == 0
|
||||
assert level_from_score(82) == "escalation"
|
||||
|
||||
|
||||
def test_weighted_profile_uses_component_weights() -> None:
|
||||
result = evaluate_weighted_profile(
|
||||
"expense",
|
||||
[
|
||||
ProfileComponent("frequency_score", "申请频次", 80, weight=Decimal("0.20")),
|
||||
ProfileComponent("amount_occupancy_score", "预算占用", 60, weight=Decimal("0.30")),
|
||||
ProfileComponent("peer_deviation_score", "同组偏离", 40, weight=Decimal("0.50")),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.profile_score == 54
|
||||
assert result.profile_level == "watch"
|
||||
assert result.top_contributors(1)[0]["code"] == "peer_deviation_score"
|
||||
|
||||
|
||||
def test_review_priority_excludes_ai_usage_score() -> None:
|
||||
assert (
|
||||
calculate_review_priority_score(
|
||||
expense_profile_score=80,
|
||||
process_quality_score=20,
|
||||
)
|
||||
== 62
|
||||
)
|
||||
assert calculate_review_priority_score(
|
||||
expense_profile_score=80,
|
||||
process_quality_score=20,
|
||||
) == calculate_review_priority_score(
|
||||
expense_profile_score=80,
|
||||
process_quality_score=20,
|
||||
)
|
||||
|
||||
|
||||
def test_review_suggestions_generate_caps_without_auto_penalty() -> None:
|
||||
suggestions = build_review_suggestions(
|
||||
expense_profile_score=72,
|
||||
process_quality_score=65,
|
||||
requested_days=Decimal("5"),
|
||||
peer_days_p75=Decimal("3"),
|
||||
policy_limit=Decimal("800"),
|
||||
peer_unit_amount_p75=Decimal("600"),
|
||||
)
|
||||
|
||||
types = {item["type"] for item in suggestions}
|
||||
assert "review_travel_days" in types
|
||||
assert "review_entertainment_unit_amount" in types
|
||||
assert any(item["recommended_upper"] == "3" for item in suggestions)
|
||||
|
||||
|
||||
def test_profile_tags_and_approval_radar_use_quantified_evidence() -> None:
|
||||
profiles = [
|
||||
{
|
||||
"profile_type": "expense",
|
||||
"score": 82,
|
||||
"level": "escalation",
|
||||
"metrics": {
|
||||
"window_days": 90,
|
||||
"expense_type_scope": "travel",
|
||||
"peer_sample_size": 20,
|
||||
"amount_total": "128000",
|
||||
"amount_share": "0.34",
|
||||
"claim_count": 6,
|
||||
"current_claim_amount": "56000",
|
||||
"requested_days": "5",
|
||||
"peer_days_p75": "3",
|
||||
},
|
||||
"top_contributors": [
|
||||
{"code": "amount_occupancy_score", "score": 90},
|
||||
{"code": "peer_deviation_score", "score": 88},
|
||||
{"code": "current_claim_deviation_score", "score": 86},
|
||||
{"code": "frequency_score", "score": 84},
|
||||
],
|
||||
},
|
||||
{
|
||||
"profile_type": "process_quality",
|
||||
"score": 68,
|
||||
"level": "review",
|
||||
"metrics": {
|
||||
"peer_sample_size": 20,
|
||||
"return_count": 2,
|
||||
"missing_attachment_count": 3,
|
||||
"invoice_mismatch_count": 1,
|
||||
"missing_business_context_count": 2,
|
||||
},
|
||||
"top_contributors": [
|
||||
{"code": "return_count_score", "score": 70},
|
||||
{"code": "missing_attachment_score", "score": 75},
|
||||
{"code": "invoice_mismatch_score", "score": 60},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
tags = build_profile_tags(profiles, scene="approval")
|
||||
tag_codes = {item["code"] for item in tags}
|
||||
assert {"expense_king", "large_amount_deviation", "return_frequent"} <= tag_codes
|
||||
assert all(item["evidence"] for item in tags)
|
||||
|
||||
radar = build_profile_radar(profiles, tags, scene="approval")
|
||||
dimensions = {item["code"]: item for item in radar["dimensions"]}
|
||||
assert set(dimensions) == {
|
||||
"expense_intensity",
|
||||
"application_rhythm",
|
||||
"travel_entertainment",
|
||||
"material_completeness",
|
||||
"process_pressure",
|
||||
}
|
||||
assert dimensions["expense_intensity"]["score"] >= 70
|
||||
assert "expense_king" in dimensions["expense_intensity"]["top_tags"]
|
||||
|
||||
|
||||
def test_profile_tags_include_ai_and_approval_traits_outside_approval_scene() -> None:
|
||||
profiles = [
|
||||
{
|
||||
"profile_type": "ai_usage",
|
||||
"score": 72,
|
||||
"level": "review",
|
||||
"metrics": {
|
||||
"peer_sample_size": 15,
|
||||
"ai_run_count": 14,
|
||||
"tool_call_count": 10,
|
||||
"failed_tool_call_count": 3,
|
||||
"estimated_token_count": 22000,
|
||||
"token_count_mode": "estimated_token_count",
|
||||
},
|
||||
"top_contributors": [
|
||||
{"code": "ai_call_count_score", "score": 75},
|
||||
{"code": "token_cost_score", "score": 70},
|
||||
{"code": "failed_ai_call_score", "score": 80},
|
||||
],
|
||||
},
|
||||
{
|
||||
"profile_type": "approval",
|
||||
"score": 64,
|
||||
"level": "review",
|
||||
"metrics": {
|
||||
"peer_sample_size": 12,
|
||||
"approval_record_count": 6,
|
||||
"direct_approve_ratio": "0.5",
|
||||
"return_count": 3,
|
||||
"sla_overdue_rate": "0.4",
|
||||
},
|
||||
"top_contributors": [
|
||||
{"code": "system_advice_override_score", "score": 70},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
tags = build_profile_tags(profiles, scene="operations")
|
||||
tag_codes = {item["code"] for item in tags}
|
||||
assert {"ai_heavy", "token_high", "ai_failure_cluster", "cautious_reviewer"} <= tag_codes
|
||||
|
||||
radar = build_profile_radar(profiles, tags, scene="operations")
|
||||
assert len(radar["dimensions"]) == 8
|
||||
assert any(
|
||||
item["code"] == "ai_collaboration" and item["score"] > 0
|
||||
for item in radar["dimensions"]
|
||||
)
|
||||
242
server/tests/test_employee_behavior_profile_service.py
Normal file
242
server/tests/test_employee_behavior_profile_service.py
Normal file
@@ -0,0 +1,242 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.db.base import Base
|
||||
from app.main import create_app
|
||||
from app.models.agent_run import AgentRun, AgentToolCall
|
||||
from app.models.employee import Employee
|
||||
from app.models.employee_behavior_profile import EmployeeBehaviorProfileSnapshot
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.services.employee_behavior_profile_service import EmployeeBehaviorProfileService
|
||||
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService
|
||||
from app.services.hermes_scheduler import HermesScheduler
|
||||
|
||||
|
||||
def build_session_factory() -> sessionmaker[Session]:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def seed_profile_data(db: Session) -> None:
|
||||
org = OrganizationUnit(
|
||||
id="dept-sales",
|
||||
unit_code="SALES",
|
||||
name="市场部",
|
||||
unit_type="department",
|
||||
)
|
||||
employee = Employee(
|
||||
id="emp-main",
|
||||
employee_no="E1001",
|
||||
name="张三",
|
||||
email="zhangsan@example.com",
|
||||
position="客户经理",
|
||||
grade="P5",
|
||||
organization_unit=org,
|
||||
)
|
||||
peer_a = Employee(
|
||||
id="emp-peer-a",
|
||||
employee_no="E1002",
|
||||
name="李四",
|
||||
email="lisi@example.com",
|
||||
position="客户经理",
|
||||
grade="P5",
|
||||
organization_unit=org,
|
||||
)
|
||||
peer_b = Employee(
|
||||
id="emp-peer-b",
|
||||
employee_no="E1003",
|
||||
name="王五",
|
||||
email="wangwu@example.com",
|
||||
position="客户经理",
|
||||
grade="P5",
|
||||
organization_unit=org,
|
||||
)
|
||||
db.add_all([org, employee, peer_a, peer_b])
|
||||
now = datetime.now(UTC)
|
||||
|
||||
claims = [
|
||||
_build_claim(
|
||||
"claim-main-1",
|
||||
employee.id,
|
||||
employee.name,
|
||||
Decimal("5000"),
|
||||
now - timedelta(days=5),
|
||||
missing_attachment=True,
|
||||
),
|
||||
_build_claim(
|
||||
"claim-main-2",
|
||||
employee.id,
|
||||
employee.name,
|
||||
Decimal("4200"),
|
||||
now - timedelta(days=15),
|
||||
returned=True,
|
||||
),
|
||||
_build_claim(
|
||||
"claim-main-3", employee.id, employee.name, Decimal("3800"), now - timedelta(days=30)
|
||||
),
|
||||
_build_claim(
|
||||
"claim-peer-a", peer_a.id, peer_a.name, Decimal("1200"), now - timedelta(days=12)
|
||||
),
|
||||
_build_claim(
|
||||
"claim-peer-b", peer_b.id, peer_b.name, Decimal("1500"), now - timedelta(days=18)
|
||||
),
|
||||
]
|
||||
db.add_all(claims)
|
||||
db.add(
|
||||
AgentRun(
|
||||
run_id="run-main-1",
|
||||
agent="hermes",
|
||||
source="user_message",
|
||||
user_id=employee.email,
|
||||
status="success",
|
||||
result_summary="AI 已辅助生成报销说明。",
|
||||
started_at=now - timedelta(days=2),
|
||||
tool_calls=[
|
||||
AgentToolCall(
|
||||
run_id="run-main-1",
|
||||
tool_type="expense",
|
||||
tool_name="claim_draft",
|
||||
request_json={"message": "出差报销"},
|
||||
response_json={"ok": True},
|
||||
status="success",
|
||||
duration_ms=120,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _build_claim(
|
||||
claim_id: str,
|
||||
employee_id: str,
|
||||
employee_name: str,
|
||||
amount: Decimal,
|
||||
occurred_at: datetime,
|
||||
*,
|
||||
missing_attachment: bool = False,
|
||||
returned: bool = False,
|
||||
) -> ExpenseClaim:
|
||||
invoice_id = None if missing_attachment else f"invoice-{claim_id}"
|
||||
return ExpenseClaim(
|
||||
id=claim_id,
|
||||
claim_no=f"EXP-{claim_id}",
|
||||
employee_id=employee_id,
|
||||
employee_name=employee_name,
|
||||
department_id="dept-sales",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-001",
|
||||
expense_type="travel",
|
||||
reason="客户拜访出差",
|
||||
location="北京",
|
||||
amount=amount,
|
||||
currency="CNY",
|
||||
invoice_count=0 if missing_attachment else 1,
|
||||
occurred_at=occurred_at,
|
||||
submitted_at=occurred_at,
|
||||
status="returned" if returned else "submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[{"source": "manual_return", "message": "补充票据"}] if returned else [],
|
||||
items=[
|
||||
ExpenseClaimItem(
|
||||
id=f"item-{claim_id}",
|
||||
claim_id=claim_id,
|
||||
item_date=date.today(),
|
||||
item_type="travel",
|
||||
item_reason="客户拜访出差",
|
||||
item_location="北京",
|
||||
item_amount=amount,
|
||||
invoice_id=invoice_id,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_service_scans_snapshots_and_filters_approval_scene() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
seed_profile_data(db)
|
||||
summary = HermesEmployeeProfileScannerService(db).scan_employee_profiles(log_id=None)
|
||||
|
||||
assert summary["target_employee_count"] >= 1
|
||||
assert db.query(EmployeeBehaviorProfileSnapshot).count() >= 4
|
||||
|
||||
latest = EmployeeBehaviorProfileService(db).get_latest_profile(
|
||||
employee_id="emp-main",
|
||||
scene="approval",
|
||||
claim_id="claim-main-1",
|
||||
window_days=90,
|
||||
expense_type_scope="travel",
|
||||
)
|
||||
|
||||
assert latest.employee_id == "emp-main"
|
||||
assert {item.profile_type for item in latest.profiles} == {"expense", "process_quality"}
|
||||
assert latest.review_priority_score > 0
|
||||
assert latest.peer_group.sample_size >= 3
|
||||
assert latest.profile_tags
|
||||
assert latest.radar.dimensions
|
||||
|
||||
|
||||
def test_latest_profile_endpoint_returns_approval_payload() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
seed_profile_data(db)
|
||||
|
||||
app = create_app()
|
||||
|
||||
def override_db() -> Generator[Session, None, None]:
|
||||
db = session_factory()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/employee-profiles/emp-main/latest",
|
||||
params={
|
||||
"scene": "approval",
|
||||
"claim_id": "claim-main-1",
|
||||
"window_days": 90,
|
||||
"expense_type_scope": "travel",
|
||||
},
|
||||
headers={"x-auth-username": "auditor", "x-auth-name": "auditor"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["employee_id"] == "emp-main"
|
||||
assert {item["profile_type"] for item in payload["profiles"]} == {"expense", "process_quality"}
|
||||
assert payload["review_priority_score"] >= 0
|
||||
assert payload["profile_tags"]
|
||||
assert {item["code"] for item in payload["radar"]["dimensions"]} == {
|
||||
"expense_intensity",
|
||||
"application_rhythm",
|
||||
"travel_entertainment",
|
||||
"material_completeness",
|
||||
"process_pressure",
|
||||
}
|
||||
|
||||
|
||||
def test_hermes_scheduler_parses_weekly_profile_cron() -> None:
|
||||
scheduler = HermesScheduler()
|
||||
|
||||
assert scheduler._parse_simple_cron("30 8 * * 1") == (30, 8, 0)
|
||||
assert scheduler._parse_simple_cron("0 9 * * *") == (0, 9, None)
|
||||
assert scheduler._parse_simple_cron("bad cron") is None
|
||||
@@ -44,6 +44,7 @@ def test_employee_directory_seeds_rich_employee_data() -> None:
|
||||
assert any("审批负责人" in item.roles for item in employees)
|
||||
assert any(item.permissions for item in employees)
|
||||
assert any(item.history for item in employees)
|
||||
assert all(item.bankName and item.bankAccountNo and item.bankAccountName for item in employees)
|
||||
|
||||
role_count = db.scalar(select(func.count()).select_from(Role))
|
||||
org_count = db.scalar(select(func.count()).select_from(OrganizationUnit))
|
||||
@@ -84,6 +85,9 @@ def test_update_employee_persists_changes_and_hashes_password() -> None:
|
||||
grade="P6",
|
||||
finance_owner_name="共享财务中心",
|
||||
cost_center="CC-TEST-01",
|
||||
bank_account_name="测试员工A",
|
||||
bank_name="招商银行上海分行",
|
||||
bank_account_no="622588000000000001",
|
||||
role_codes=["finance", "user"],
|
||||
password="12345",
|
||||
),
|
||||
@@ -98,6 +102,9 @@ def test_update_employee_persists_changes_and_hashes_password() -> None:
|
||||
assert updated.grade == "P6"
|
||||
assert updated.financeOwner == "共享财务中心"
|
||||
assert updated.costCenter == "CC-TEST-01"
|
||||
assert updated.bankAccountName == "测试员工A"
|
||||
assert updated.bankName == "招商银行上海分行"
|
||||
assert updated.bankAccountNo == "622588000000000001"
|
||||
assert updated.roleCodes == ["finance", "user"]
|
||||
assert persisted is not None
|
||||
assert persisted.password_hash is not None
|
||||
|
||||
@@ -59,6 +59,9 @@ def test_import_employees_rejects_invalid_row_without_writing() -> None:
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"在职",
|
||||
"user",
|
||||
]
|
||||
@@ -98,6 +101,9 @@ def test_import_employees_updates_existing_employee() -> None:
|
||||
"",
|
||||
"华东财务组",
|
||||
"CC-TEST",
|
||||
"导入户名",
|
||||
"招商银行上海分行",
|
||||
"622588000000000002",
|
||||
"在职",
|
||||
"user",
|
||||
]
|
||||
@@ -112,6 +118,9 @@ def test_import_employees_updates_existing_employee() -> None:
|
||||
assert updated is not None
|
||||
assert updated.name == new_name
|
||||
assert updated.phone == "13900000001"
|
||||
assert updated.bankAccountName == "导入户名"
|
||||
assert updated.bankName == "招商银行上海分行"
|
||||
assert updated.bankAccountNo == "622588000000000002"
|
||||
|
||||
|
||||
def test_import_employees_creates_new_employee() -> None:
|
||||
@@ -136,6 +145,9 @@ def test_import_employees_creates_new_employee() -> None:
|
||||
"E10234",
|
||||
"华东财务组",
|
||||
"CC-9001",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"在职",
|
||||
"user",
|
||||
]
|
||||
@@ -151,3 +163,6 @@ def test_import_employees_creates_new_employee() -> None:
|
||||
).scalar_one()
|
||||
assert imported.name == "导入新员工"
|
||||
assert imported.email == "import.new.user@xfinance.com"
|
||||
assert imported.bank_account_name == "导入新员工"
|
||||
assert imported.bank_name
|
||||
assert imported.bank_account_no
|
||||
|
||||
@@ -2680,6 +2680,23 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() ->
|
||||
approval_stage="财务审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-ARCH-PAID",
|
||||
employee_name="丙",
|
||||
department_name="C部",
|
||||
project_code="PRJ-C",
|
||||
expense_type="office",
|
||||
reason="C 报销",
|
||||
location="深圳",
|
||||
amount=Decimal("180.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 11, 14, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 15, 0, tzinfo=UTC),
|
||||
status="paid",
|
||||
approval_stage="已付款",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="AP-20260525120000-ABCDEFGH",
|
||||
employee_name="丙",
|
||||
@@ -2722,6 +2739,7 @@ def test_list_archived_claims_returns_company_archived_records_for_finance() ->
|
||||
|
||||
assert {claim.claim_no for claim in claims} == {
|
||||
"EXP-ARCH-101",
|
||||
"EXP-ARCH-PAID",
|
||||
"AP-20260525120000-ABCDEFGH",
|
||||
}
|
||||
|
||||
@@ -3894,7 +3912,7 @@ def test_finance_cannot_operate_own_claim_in_finance_stage() -> None:
|
||||
assert claim.risk_flags_json == []
|
||||
|
||||
|
||||
def test_finance_can_approve_claim_to_archive_stage() -> None:
|
||||
def test_finance_can_approve_claim_to_pending_payment_stage() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-approve@example.com",
|
||||
name="财务复核",
|
||||
@@ -3931,19 +3949,65 @@ def test_finance_can_approve_claim_to_archive_stage() -> None:
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == "归档入账"
|
||||
assert approved.status == "pending_payment"
|
||||
assert approved.approval_stage == "待付款"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "finance_approval"
|
||||
and flag.get("event_type") == "expense_claim_finance_approval"
|
||||
and flag.get("opinion") == "票据与明细一致,同意入账。"
|
||||
and flag.get("previous_approval_stage") == "财务审批"
|
||||
and flag.get("next_approval_stage") == "归档入账"
|
||||
and flag.get("next_status") == "pending_payment"
|
||||
and flag.get("next_approval_stage") == "待付款"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_finance_can_mark_pending_payment_claim_as_paid() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-pay@example.com",
|
||||
name="财务付款",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-FIN-PAY-201",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="transport",
|
||||
reason="交通报销",
|
||||
location="上海",
|
||||
amount=Decimal("66.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="pending_payment",
|
||||
approval_stage="待付款",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
paid = ExpenseClaimService(db).mark_claim_paid(claim.id, current_user)
|
||||
|
||||
assert paid is not None
|
||||
assert paid.status == "paid"
|
||||
assert paid.approval_stage == "已付款"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "payment"
|
||||
and flag.get("event_type") == "expense_claim_payment_completed"
|
||||
and flag.get("previous_status") == "pending_payment"
|
||||
and flag.get("next_status") == "paid"
|
||||
and flag.get("next_approval_stage") == "已付款"
|
||||
for flag in paid.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_return_claim_rejects_already_returned_claim_without_adding_event() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-returned@example.com",
|
||||
|
||||
@@ -364,7 +364,7 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review()
|
||||
assert "manager-approve-api@example.com" not in approval_events[0]["message"]
|
||||
|
||||
|
||||
def test_approve_application_endpoint_completes_after_direct_manager_review() -> None:
|
||||
def test_approve_application_endpoint_routes_direct_manager_review_to_budget_review() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
manager = Employee(
|
||||
@@ -415,15 +415,15 @@ def test_approve_application_endpoint_completes_after_direct_manager_review() ->
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["status"] == "approved"
|
||||
assert payload["approval_stage"] == "审批完成"
|
||||
assert payload["status"] == "submitted"
|
||||
assert payload["approval_stage"] == "预算管理者审批"
|
||||
assert any(
|
||||
item["source"] == "manual_approval"
|
||||
and item["event_type"] == "expense_application_approval"
|
||||
and item["opinion"] == "业务必要,同意申请。"
|
||||
and item["operator"] == "李经理"
|
||||
and item["next_status"] == "approved"
|
||||
and item["next_approval_stage"] == "审批完成"
|
||||
and item["next_status"] == "submitted"
|
||||
and item["next_approval_stage"] == "预算管理者审批"
|
||||
for item in payload["risk_flags_json"]
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user