feat: 新增员工行为画像算法与费用风险标签体系

后端新增员工行为画像算法模块,支持标签规则引擎和评分计算,
完善员工模型、银行信息、序列化和导入逻辑,优化报销审批流
和工作流常量,增强 Hermes 同步和知识同步能力,前端新增费
用画像详情弹窗、雷达图和风险卡片组件,完善登录页和工作台
样式,优化文档中心和归档中心交互,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-28 12:09:49 +08:00
parent 04cd6d0f81
commit 8a4a777be7
96 changed files with 9835 additions and 704 deletions

View File

@@ -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="系统初始化",
),
]

View File

@@ -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="系统初始化",
)

View File

@@ -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 = {

View File

@@ -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"},

View File

@@ -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:

View 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)

View 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"

View File

@@ -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]

View 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,
}

View File

@@ -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:

View 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()

View File

@@ -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,

View File

@@ -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),
),

View File

@@ -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):

View File

@@ -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 [])):

View File

@@ -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 = "已付款"

View 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

View File

@@ -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()

View File

@@ -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

View File

@@ -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,

View File

@@ -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"]

View File

@@ -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])} * * *"