feat: 新增预算中心本体与风险规则评分回填

后端新增预算本体解析模块和风险规则评分回填服务,优化规则
生成本体对齐和提示词构建,增强费用类型关键词和本体验证,
完善报销查询和审计接口,前端预算中心页面增加对话框和本
体工具函数,重构审计页面元数据和视图模型,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-26 12:16:20 +08:00
parent 0e861d8fa6
commit e1e515ecae
53 changed files with 4350 additions and 921 deletions

View File

@@ -32,6 +32,46 @@ over_budget
budget_warning
```
## 预算字段设计
预算中心字段分为四层,前端弹窗、预算台账、后端本体解析都必须使用同一套语义键。
### 预算主信息
- `budget_period`:预算周期,支持年度、季度、月份。
- `department`:所属部门,来自真实组织/部门数据。
- `cost_center`:成本中心,跟随部门归属。
- `budget_owner`:预算负责人。
- `budget_version`:预算版本,例如 `V1.0(初始版本)`
- `budget_status`:预算状态,第一版限定为 `编制中 / 已发布 / 已冻结`
- `budget_description`:预算说明。
### 预算明细
- `budget_subject`:预算科目,对应页面费用类型。
- `budget_subject_code`:预算科目编码,例如 `travel / office / training`
- `budget_amount`:预算金额。
- `warning_threshold`:预警线,例如 `70% / 80%`
- `control_action`:控制动作,第一版限定为 `正常 / 提醒 / 管控`
- `budget_remark`:明细备注。
### 预算执行
- `reserved_amount`:已占用/已预占金额。
- `consumed_amount`:已发生/已核销金额。
- `available_amount`:剩余可用金额。
- `budget_usage_rate`:预算执行率。
- `over_budget`:是否超预算。
- `budget_warning`:是否触发预算预警。
### 本体映射规则
- 页面字段使用驼峰变量,但提交/上下文统一映射为 snake_case 本体字段。
- 本体 `scenario=budget` 负责预算编制、预算查询、预算预警、预算占用、预算不足解释。
- 费用申请/报销仍使用 `scenario=expense`,但预算占用字段必须引用 `budget_subject / budget_period / cost_center`
- 问句中出现“预算金额、可用预算、剩余预算、预算占用、成本中心、预警线、超预算、预算不足”等词,应优先识别为 `budget` 场景。
- 本体输出中,预算字段优先进入 `entities`;金额类查询同步进入 `metrics`;筛选口径进入 `constraints`
## AI解释能力
需要支持的问题:
@@ -48,4 +88,3 @@ budget_warning
- [ ] AI能解释预算不足原因。
- [ ] 首页预算看板来自后端真实汇总。
- [ ] 预算中心和AI回答的金额一致。

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -80,6 +80,18 @@ def require_admin_user(
)
def require_platform_admin_user(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> CurrentUserContext:
if current_user.is_admin:
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有 admin 管理员可以执行该操作。",
)
def require_rule_editor_user(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> CurrentUserContext:
@@ -102,5 +114,5 @@ def require_rule_reviewer_user(
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="只有高级管理人员可以审核、发布或恢复正式规则",
detail="只有高级管理人员或 admin 管理员可以执行该操作",
)

View File

@@ -10,7 +10,7 @@ from app.api.deps import (
CurrentUserContext,
get_current_user,
get_db,
require_admin_user,
require_platform_admin_user,
require_rule_editor_user,
require_rule_reviewer_user,
)
@@ -58,7 +58,7 @@ RequestIdHeader = Annotated[
Header(description="外部请求 ID用于串联审计日志和上游调用链。"),
]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
AdminUser = Annotated[CurrentUserContext, Depends(require_admin_user)]
PlatformAdminUser = Annotated[CurrentUserContext, Depends(require_platform_admin_user)]
RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)]
RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)]
@@ -187,7 +187,7 @@ def get_agent_asset_risk_rule_latest_test(
def simulate_agent_asset_risk_rule_test(
asset_id: str,
payload: AgentAssetRiskRuleSimulationRequest,
_: RuleEditorUser,
_: PlatformAdminUser,
db: DbSession,
) -> AgentAssetRiskRuleSimulationRead:
try:
@@ -205,7 +205,7 @@ def simulate_agent_asset_risk_rule_test(
def run_agent_asset_risk_rule_sample_test(
asset_id: str,
payload: AgentAssetRiskRuleSampleTestRequest,
current_user: RuleEditorUser,
current_user: PlatformAdminUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
@@ -230,7 +230,7 @@ def run_agent_asset_risk_rule_sample_test(
def run_agent_asset_risk_rule_scenario_test(
asset_id: str,
payload: AgentAssetRiskRuleScenarioTestRequest,
current_user: RuleEditorUser,
current_user: PlatformAdminUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
@@ -255,7 +255,7 @@ def run_agent_asset_risk_rule_scenario_test(
def confirm_agent_asset_risk_rule_test_report(
asset_id: str,
payload: AgentAssetRiskRuleReportRequest,
current_user: RuleEditorUser,
current_user: PlatformAdminUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
@@ -301,12 +301,12 @@ def save_agent_asset_rule_json(
response_model=AgentAssetRead,
status_code=status.HTTP_201_CREATED,
summary="根据自然语言新建风险规则草稿",
description="根据业务域、风险等级和自然语言描述生成 JSON 风险规则,并保存为待审核草稿资产。",
description="根据业务域、自然语言描述和风险评分模型生成 JSON 风险规则,并保存为待上线草稿资产。",
)
def generate_agent_asset_risk_rule(
payload: AgentAssetRiskRuleGenerateRequest,
background_tasks: BackgroundTasks,
current_user: RuleEditorUser,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
@@ -550,6 +550,7 @@ def list_agent_asset_spreadsheet_change_records(
)
def create_agent_asset(
payload: AgentAssetCreate,
current_user: RuleReviewerUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
@@ -557,7 +558,7 @@ def create_agent_asset(
try:
return AgentAssetService(db).create_asset(
payload,
actor=(x_actor or payload.owner).strip() or "system",
actor=(x_actor or current_user.name or payload.owner).strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
@@ -583,15 +584,21 @@ def create_agent_asset(
def update_agent_asset(
asset_id: str,
payload: AgentAssetUpdate,
current_user: CurrentUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRead:
try:
role_codes = {item.strip() for item in current_user.role_codes}
if (payload.status is not None or payload.published_version is not None) and not (
current_user.is_admin or "manager" in role_codes
):
raise PermissionError("只有高级管理员或 admin 管理员可以更改规则上线状态。")
return AgentAssetService(db).update_asset(
asset_id,
payload,
actor=(x_actor or "system").strip() or "system",
actor=(x_actor or current_user.name or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
@@ -846,7 +853,7 @@ def publish_agent_asset_risk_rule(
)
def delete_agent_asset(
asset_id: str,
current_user: RuleEditorUser,
current_user: PlatformAdminUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,

View File

@@ -112,6 +112,11 @@ class AgentAssetRuleJsonRead(BaseModel):
class AgentAssetRiskRuleGenerateRequest(BaseModel):
business_domain: AgentAssetDomain = AgentAssetDomain.EXPENSE
business_stage: str | None = Field(
default="reimbursement",
pattern="^(expense_application|reimbursement)$",
max_length=40,
)
expense_category: str | None = Field(default=None, max_length=40)
rule_title: str | None = Field(default=None, max_length=80)
risk_level: str | None = Field(default=None, pattern="^(low|medium|high|critical)$")

View File

@@ -8,6 +8,7 @@ OntologyScenario = Literal[
"expense",
"accounts_receivable",
"accounts_payable",
"budget",
"knowledge",
"unknown",
]

View File

@@ -35,6 +35,8 @@ class AgentAssetRiskRuleLevelMixin:
actor: str,
request_id: str | None = None,
) -> AgentAsset:
del asset_id, risk_level, actor, request_id
raise ValueError("风险等级和分数由评分模型自动计算,不能手动修改。")
asset = self._resolve_asset(asset_id)
self._require_json_risk_asset(asset)
normalized_level = self._normalize_risk_rule_level(risk_level)

View File

@@ -148,11 +148,12 @@ class AgentAssetRiskRuleTestingMixin:
if not body.confirm_passed:
raise ValueError("请确认测试通过后再保存测试报告。")
summary = "测试报告已确认,当前版本可提交审核"
summary = "测试报告已确认,当前版本可上线"
if scenario is None:
summary = "快速样例测试已确认通过,真实场景试运行未执行。"
elif not scenario.passed:
summary = "快速样例测试已确认通过,真实场景试运行未找到可测样本。"
self._mark_risk_rule_operation(asset, action="test", actor=actor)
return self._create_test_run(
asset,
version=version,
@@ -162,9 +163,9 @@ class AgentAssetRiskRuleTestingMixin:
input_json={"confirm_passed": True, "note": body.note or ""},
result_json={
"sample_test_run_id": sample.id,
"scenario_test_run_id": scenario.id,
"scenario_test_run_id": scenario.id if scenario else "",
"sample_summary": sample.summary,
"scenario_summary": scenario.summary,
"scenario_summary": scenario.summary if scenario else "",
},
actor=actor,
request_id=request_id,
@@ -308,6 +309,11 @@ class AgentAssetRiskRuleTestingMixin:
config_json = dict(asset.config_json or {})
config_json["enabled"] = bool(enabled)
self._set_risk_rule_status_for_online_toggle(asset, enabled=enabled, actor=actor)
config_json["last_operation"] = self._build_last_operation(
action="online" if enabled else "offline",
actor=actor,
)
asset.config_json = config_json
updated = self.repository.save_asset(asset)
self.audit_service.log_action(
@@ -321,6 +327,50 @@ class AgentAssetRiskRuleTestingMixin:
)
return updated
def _set_risk_rule_status_for_online_toggle(
self,
asset: AgentAsset,
*,
enabled: bool,
actor: str,
) -> None:
if enabled:
version = self._resolve_target_version(asset, None)
approved_review = self.repository.get_review(
asset.id, version, AgentReviewStatus.APPROVED.value
)
if approved_review is None:
self.db.add(
AgentAssetReview(
asset_id=asset.id,
version=version,
reviewer=actor,
review_status=AgentReviewStatus.APPROVED.value,
review_note="直接上线风险规则。",
reviewed_at=datetime.now(UTC),
)
)
asset.published_version = version
asset.reviewer = actor
asset.status = AgentAssetStatus.ACTIVE.value
return
asset.status = AgentAssetStatus.DISABLED.value
def _mark_risk_rule_operation(self, asset: AgentAsset, *, action: str, actor: str) -> None:
config_json = dict(asset.config_json or {})
config_json["last_operation"] = self._build_last_operation(action=action, actor=actor)
asset.config_json = config_json
self.db.add(asset)
@staticmethod
def _build_last_operation(*, action: str, actor: str) -> dict[str, str]:
return {
"action": action,
"actor": str(actor or "system").strip() or "system",
"at": datetime.now(UTC).isoformat(),
}
def _load_risk_rule_for_test(
self, asset_id: str, version: str | None
) -> tuple[AgentAsset, str, dict[str, Any]]:

View File

@@ -37,6 +37,7 @@ from app.services.agent_asset_spreadsheet_helpers import AgentAssetSpreadsheetHe
from app.services.agent_asset_timeline import AgentAssetTimelineMixin
from app.services.agent_foundation import AgentFoundationService
from app.services.audit import AuditLogService
from app.services.risk_rule_score_backfill import backfill_missing_risk_rule_score
logger = get_logger("app.services.agent_assets")
@@ -79,6 +80,11 @@ class AgentAssetService(
asset = self.repository.get(asset_id)
if asset is None:
return None
try:
if backfill_missing_risk_rule_score(asset):
asset = self.repository.save_asset(asset)
except Exception:
logger.warning("Failed to backfill risk rule score asset_id=%s", asset_id, exc_info=True)
working_version = self._resolve_working_version(asset)
recent_versions = self._sort_versions(

View File

@@ -17,8 +17,10 @@ EXPENSE_TYPE_LABELS = {
"meal": "业务招待",
"meeting": "会务",
"entertainment": "招待",
"marketing": "市场推广",
"office": "办公用品",
"training": "培训",
"software": "软件服务",
"communication": "通讯",
"welfare": "福利",
}
@@ -52,8 +54,21 @@ DOCUMENT_TYPE_SCENE_MAP = {
"meeting_invoice": "meeting",
"training_invoice": "training",
}
DOCUMENT_FACT_ITEM_TYPES = {"train_ticket", "flight_ticket", "hotel_ticket", "ride_ticket", "ship_ticket", "ferry_ticket"}
ROUTE_DESCRIPTION_ITEM_TYPES = {"train_ticket", "flight_ticket", "ship_ticket", "ferry_ticket", "ride_ticket"}
DOCUMENT_FACT_ITEM_TYPES = {
"train_ticket",
"flight_ticket",
"hotel_ticket",
"ride_ticket",
"ship_ticket",
"ferry_ticket",
}
ROUTE_DESCRIPTION_ITEM_TYPES = {
"train_ticket",
"flight_ticket",
"ship_ticket",
"ferry_ticket",
"ride_ticket",
}
DOCUMENT_TRIP_DATE_LABELS = {
"train_ticket": "列车出发时间",
"flight_itinerary": "起飞日期",
@@ -118,7 +133,17 @@ DOCUMENT_ROUTE_TEXT_PATTERN = re.compile(
r"([A-Za-z0-9\u4e00-\u9fa5()·]{2,40})\s*(?:至|到|→|->|—||-)\s*"
r"([A-Za-z0-9\u4e00-\u9fa5()·]{2,40})"
)
DOCUMENT_ROUTE_ORIGIN_LABELS = {"起点", "上车", "上车地点", "上车地址", "出发", "出发地", "出发站", "始发站", "乘车起点"}
DOCUMENT_ROUTE_ORIGIN_LABELS = {
"起点",
"上车",
"上车地点",
"上车地址",
"出发",
"出发地",
"出发站",
"始发站",
"乘车起点",
}
DOCUMENT_ROUTE_DESTINATION_LABELS = {
"终点",
"下车",
@@ -140,9 +165,11 @@ EXPENSE_SCENE_KEYWORDS = {
"transport",
"meal",
"entertainment",
"marketing",
"office",
"meeting",
"training",
"software",
"communication",
"welfare",
)
@@ -158,9 +185,11 @@ EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES = {
"transport": {"transport", "travel"},
"meal": {"meal", "entertainment"},
"entertainment": {"entertainment", "meal"},
"marketing": {"marketing"},
"office": {"office"},
"meeting": {"meeting"},
"training": {"training"},
"software": {"software"},
}
DOCUMENT_SCENE_LABELS = {
"travel": "差旅",
@@ -168,9 +197,11 @@ DOCUMENT_SCENE_LABELS = {
"transport": "交通",
"meal": "业务招待",
"entertainment": "业务招待",
"marketing": "市场推广",
"office": "办公用品",
"meeting": "会务",
"training": "培训",
"software": "软件服务",
"other": "其他票据",
}
DOCUMENT_ASSOCIATION_REVIEW_ACTIONS = {
@@ -191,7 +222,10 @@ RETURN_REASON_OPTIONS = {
"approval_question": "审批人需要补充说明",
}
MAX_CLAIM_NO_RETRY_ATTEMPTS = 3
DOCUMENT_DATE_PATTERN = re.compile(r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.](?:3[01]|[12]\d|0?[1-9])日?)")
DOCUMENT_DATE_PATTERN = re.compile(
r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.]"
r"(?:3[01]|[12]\d|0?[1-9])日?)"
)
SYSTEM_GENERATED_REASON_PREFIXES = (
"我上传了",
"请按当前已识别信息",

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
from typing import Iterable
from collections.abc import Iterable
EXPENSE_TYPE_KEYWORD_GROUPS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
(
@@ -132,6 +131,22 @@ EXPENSE_TYPE_KEYWORD_GROUPS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
"布展",
),
),
(
"marketing",
"市场推广费",
(
"市场推广费",
"市场推广",
"推广费",
"广告费",
"广告投放",
"投放费",
"品牌宣传",
"宣传费",
"营销物料",
"推广物料",
),
),
(
"office",
"办公用品费",
@@ -177,6 +192,24 @@ EXPENSE_TYPE_KEYWORD_GROUPS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
"认证",
),
),
(
"software",
"软件服务费",
(
"软件服务费",
"软件费",
"软件订阅",
"SaaS",
"SAAS",
"saas",
"SaaS订阅",
"系统服务费",
"云服务费",
"云资源",
"平台服务费",
"技术服务费",
),
),
(
"communication",
"通讯费",

View File

@@ -0,0 +1,269 @@
from __future__ import annotations
import re
from typing import Any
from app.schemas.ontology import OntologyEntity, OntologyMetric
from app.services.ontology_rules import (
BUDGET_CONTEXT_TYPES,
BUDGET_CONTROL_ACTION_KEYWORDS,
BUDGET_KEYWORDS,
BUDGET_REQUIRED_SLOT_KEYS,
BUDGET_STATUS_KEYWORDS,
BUDGET_SUBJECT_KEYWORDS,
BUDGET_SUBJECT_LABEL_BY_CODE,
)
class BudgetOntologyMixin:
@staticmethod
def _is_budget_context_value(context_json: dict[str, Any]) -> bool:
document_type = str(context_json.get("document_type") or "").strip()
entry_source = str(context_json.get("entry_source") or "").strip()
session_type = str(context_json.get("session_type") or "").strip()
conversation_scenario = str(context_json.get("conversation_scenario") or "").strip()
return (
document_type in BUDGET_CONTEXT_TYPES
or entry_source in BUDGET_CONTEXT_TYPES
or session_type in BUDGET_CONTEXT_TYPES
or conversation_scenario == "budget"
)
@staticmethod
def _has_budget_signal(compact_query: str) -> bool:
return any(keyword in compact_query for keyword in BUDGET_KEYWORDS)
@staticmethod
def _infer_budget_missing_slots(
entities: list[OntologyEntity],
context_json: dict[str, Any],
) -> list[str]:
entity_types = {item.type for item in entities}
budget_values = context_json.get("budget_header")
if not isinstance(budget_values, dict):
budget_values = {}
detail_values = context_json.get("budget_details")
if not isinstance(detail_values, list):
detail_values = []
missing_slots: list[str] = []
has_budget_period = str(budget_values.get("budget_period") or "").strip()
has_department = str(budget_values.get("department") or "").strip()
if "budget_period" not in entity_types and not has_budget_period:
missing_slots.append("budget_period")
if "department" not in entity_types and not has_department:
missing_slots.append("department")
has_subject = "budget_subject" in entity_types or any(
str(item.get("budget_subject") or "").strip()
for item in detail_values
if isinstance(item, dict)
)
if not has_subject:
missing_slots.append("budget_subject")
has_amount = "budget_amount" in entity_types or any(
str(item.get("budget_amount") or "").strip()
for item in detail_values
if isinstance(item, dict)
)
if not has_amount:
missing_slots.append("budget_amount")
return [item for item in BUDGET_REQUIRED_SLOT_KEYS if item in missing_slots]
@staticmethod
def _extract_budget_metrics(compact_query: str) -> list[OntologyMetric]:
metrics: list[OntologyMetric] = []
if any(keyword in compact_query for keyword in ("预算金额", "预算总额", "预算额度")):
metrics.append(OntologyMetric(name="budget_amount", aggregation="sum", unit="CNY"))
if any(
keyword in compact_query
for keyword in ("可用预算", "剩余预算", "可用余额", "剩余可用")
):
metrics.append(OntologyMetric(name="available_amount", aggregation="sum", unit="CNY"))
if any(
keyword in compact_query
for keyword in ("已占用", "已预占", "预算占用", "占用金额")
):
metrics.append(OntologyMetric(name="reserved_amount", aggregation="sum", unit="CNY"))
if any(keyword in compact_query for keyword in ("已发生", "已核销", "已消耗", "已使用")):
metrics.append(OntologyMetric(name="consumed_amount", aggregation="sum", unit="CNY"))
if any(keyword in compact_query for keyword in ("执行率", "使用率")):
metrics.append(
OntologyMetric(name="budget_usage_rate", aggregation="ratio", unit="percent")
)
return metrics
def _extract_budget_entities(
self,
query: str,
compact_query: str,
context_json: dict[str, Any],
) -> list[OntologyEntity]:
entities: list[OntologyEntity] = []
if self._is_budget_context_value(context_json) or self._has_budget_signal(compact_query):
entities.append(
self._make_entity(
"document_type",
"预算",
"budget_plan",
role="target",
confidence=0.94,
)
)
entities.append(
self._make_entity(
"workflow_stage",
"预算控制",
"budget_control",
role="target",
confidence=0.9,
)
)
period_pattern = (
r"(?P<year>20\d{2})\s*年\s*"
r"(?:(?P<quarter>Q[1-4]|[一二三四]季度)|(?P<month>\d{1,2})\s*月|度)?"
)
for match in re.finditer(period_pattern, query, flags=re.IGNORECASE):
year = match.group("year")
quarter = match.group("quarter")
month = match.group("month")
if quarter:
quarter_text = quarter.upper() if quarter.upper().startswith("Q") else quarter
normalized = f"{year}{quarter_text}"
elif month:
normalized = f"{year}{int(month)}"
else:
normalized = f"{year}年度"
entities.append(
self._make_entity(
"budget_period",
match.group(0).strip(),
normalized,
role="filter",
confidence=0.88,
)
)
for code in re.findall(r"CC-\d+", query, flags=re.IGNORECASE):
entities.append(
self._make_entity(
"cost_center",
code,
code.upper(),
role="filter",
confidence=0.92,
)
)
for label, normalized in BUDGET_SUBJECT_KEYWORDS.items():
if label in query:
subject_label = BUDGET_SUBJECT_LABEL_BY_CODE.get(normalized, label)
entities.append(
self._make_entity(
"budget_subject",
label,
normalized,
role="filter",
confidence=0.9,
)
)
entities.append(
self._make_entity(
"expense_type",
subject_label,
normalized,
role="filter",
confidence=0.9,
)
)
for label, normalized in BUDGET_STATUS_KEYWORDS.items():
if label in query:
entities.append(
self._make_entity(
"budget_status",
label,
normalized,
role="filter",
confidence=0.86,
)
)
for label, normalized in BUDGET_CONTROL_ACTION_KEYWORDS.items():
if label in query:
entities.append(
self._make_entity(
"control_action",
label,
normalized,
role="target",
confidence=0.84,
)
)
version_match = re.search(r"V\d+(?:\.\d+){0,2}", query, flags=re.IGNORECASE)
if version_match:
version = version_match.group(0).upper()
entities.append(
self._make_entity(
"budget_version",
version,
version,
role="filter",
confidence=0.86,
)
)
warning_match = re.search(r"(?:预警线|预警阈值|预算预警)\s*(?P<value>\d{1,3})\s*%", query)
if warning_match:
value = f"{warning_match.group('value')}%"
entities.append(
self._make_entity(
"warning_threshold",
value,
value,
role="threshold",
confidence=0.9,
)
)
entities.extend(self._extract_budget_amount_entities(query))
return entities
def _extract_budget_amount_entities(self, query: str) -> list[OntologyEntity]:
entities: list[OntologyEntity] = []
patterns = (
(
"budget_amount",
r"(?:预算金额|预算额度|预算总额)\s*(?P<value>\d+(?:\.\d+)?)\s*(?P<unit>万元|万|元)?",
),
(
"available_amount",
r"(?:可用预算|剩余预算|可用余额|剩余可用)\s*(?P<value>\d+(?:\.\d+)?)\s*(?P<unit>万元|万|元)?",
),
(
"reserved_amount",
r"(?:已占用|已预占|占用金额|预算占用)\s*(?P<value>\d+(?:\.\d+)?)\s*(?P<unit>万元|万|元)?",
),
(
"consumed_amount",
r"(?:已发生|已核销|已消耗|已使用)\s*(?P<value>\d+(?:\.\d+)?)\s*(?P<unit>万元|万|元)?",
),
)
for entity_type, pattern in patterns:
for match in re.finditer(pattern, query):
raw_value = match.group("value")
unit = match.group("unit")
amount_value = self._normalize_amount(raw_value, unit)
display_value = f"{raw_value}{unit or ''}"
entities.append(
self._make_entity(
entity_type,
display_value,
str(amount_value),
role="target",
confidence=0.9,
)
)
return entities

View File

@@ -15,8 +15,10 @@ from app.schemas.ontology import (
OntologyTimeRange,
)
from app.services.ontology_rules import (
AR_CORE_KEYWORDS,
AP_CORE_KEYWORDS,
AR_CORE_KEYWORDS,
BUDGET_DRAFT_KEYWORDS,
BUDGET_OPERATE_KEYWORDS,
COMPARE_KEYWORDS,
DRAFT_FOLLOW_UP_KEYWORDS,
DRAFT_KEYWORDS,
@@ -27,13 +29,13 @@ from app.services.ontology_rules import (
EXPLAIN_KEYWORDS,
GENERIC_EXPENSE_PROMPTS,
KNOWLEDGE_INTENTS,
LlmOntologyEntityHint,
LlmOntologyParseResult,
OPERATE_KEYWORDS,
QUERY_KEYWORDS,
RISK_KEYWORDS,
SCENARIO_KEYWORDS,
STATUS_KEYWORDS,
LlmOntologyEntityHint,
LlmOntologyParseResult,
)
logger = get_logger("app.services.ontology")
@@ -99,6 +101,9 @@ class OntologyDetectionMixin:
best_scenario = max(scores, key=scores.get)
best_score = scores[best_scenario]
if scores.get("budget", 0.0) > 0 and scores["budget"] >= best_score:
best_scenario = "budget"
best_score = scores["budget"]
if best_score <= 0:
if "单据" in compact_query and any(
keyword in compact_query for keyword in STATUS_KEYWORDS
@@ -111,9 +116,10 @@ class OntologyDetectionMixin:
scores["expense"],
scores["accounts_receivable"],
scores["accounts_payable"],
scores["budget"],
]
if max(business_scores) > 0:
best_scenario = ("expense", "accounts_receivable", "accounts_payable")[
best_scenario = ("expense", "accounts_receivable", "accounts_payable", "budget")[
business_scores.index(max(business_scores))
]
best_score = max(business_scores)
@@ -130,6 +136,14 @@ class OntologyDetectionMixin:
) -> tuple[str, float]:
if any(keyword in compact_query for keyword in OPERATE_KEYWORDS):
return "operate", 0.30
if scenario == "budget" and any(
keyword in compact_query for keyword in BUDGET_OPERATE_KEYWORDS
):
return "operate", 0.30
if scenario == "budget" and any(
keyword in compact_query for keyword in BUDGET_DRAFT_KEYWORDS
):
return "draft", 0.28
status_document_query = (
"单据" in compact_query
and any(keyword in compact_query for keyword in STATUS_KEYWORDS)
@@ -383,13 +397,15 @@ class OntologyDetectionMixin:
"你的任务是把用户输入解析为固定 JSON用于后续路由、追问和权限判断。"
"只输出 JSON 对象,不要输出 Markdown、代码块、解释、标题或 <think>。"
"场景 scenario 只能是expense, accounts_receivable, "
"accounts_payable, knowledge, unknown。"
"accounts_payable, budget, knowledge, unknown。"
"意图 intent 只能是query, explain, compare, risk_check, draft, operate。"
"如果用户是在描述一笔待处理费用、待报销事项、上传票据或希望整理报销,"
"即使没有明确说“生成草稿”,也优先使用 expense + draft。"
"如果提供了 conversation_history必须把最近轮次作为当前追问的上下文"
"正确理解“这个”“那笔”“改成 800”“继续补充”这类省略表达。"
"出现“客户”不等于应收,出现“供应商”不等于应付,必须结合动作词和业务目标判断。"
"预算编制、预算金额、成本中心、预算科目、预算预警、预算占用、"
"剩余预算、可用预算、超预算、预算不足等问题必须使用 budget 场景。"
"只有明确查询、统计、列出、多少、明细、对比时才优先使用 query 或 compare。"
"附件名称和 OCR 摘要只作为辅助证据,不能编造未出现的事实。"
"如果用户明确提到打车、的士票、出租车票、网约车、乘车费、车费等交通票据,"
@@ -397,7 +413,8 @@ class OntologyDetectionMixin:
"不要输出用户原文未出现、且与规则候选冲突的费用类型。"
"信息不足时 clarification_required=true并给出一句简短中文追问。"
"missing_slots 使用简短 snake_case例如 expense_type, amount, "
"customer_name, participants, attachments"
"customer_name, participants, attachments, budget_period, "
"budget_subject, budget_amount。"
"entity_hints 只填写你比较确定的业务对象;如果不确定,可以返回空数组。"
"费用申请场景下,建议把干净的申请事由放入 type=reason"
"把出行方式放入 type=transport_mode取值优先为飞机、火车、轮船。"
@@ -422,6 +439,9 @@ class OntologyDetectionMixin:
'"confidence": 0.86},\n'
' {"type": "reason", "value": "服务客户业务部署", '
'"normalized_value": "服务客户业务部署", "role": "target", '
'"confidence": 0.86},\n'
' {"type": "budget_subject", "value": "差旅费", '
'"normalized_value": "travel", "role": "filter", '
'"confidence": 0.86}\n'
" ]\n"
"}"

View File

@@ -14,28 +14,28 @@ from app.schemas.ontology import (
OntologyTimeRange,
)
from app.services.document_numbering import DOCUMENT_NUMBER_EXTRACT_PATTERN
from app.services.ontology_budget import BudgetOntologyMixin
from app.services.ontology_rules import (
AMOUNT_PATTERN,
DATE_RANGE_PATTERN,
EXPLICIT_DATE_PATTERN,
EXPLICIT_MONTH_PATTERN,
EXPENSE_APPLICATION_ATTACHMENT_REQUIRED_TYPES,
EXPENSE_APPLICATION_CONTEXT_TYPES,
EXPENSE_APPLICATION_KEYWORDS,
EXPENSE_APPLICATION_REQUIRED_SLOT_KEYS,
EXPENSE_TYPE_KEYWORDS,
EXPLICIT_DATE_PATTERN,
EXPLICIT_MONTH_PATTERN,
GENERIC_EXPENSE_APPLICATION_PROMPTS,
GENERIC_EXPENSE_PROMPTS,
LOCATION_KEYWORDS,
MONTH_DAY_PATTERN,
MONTH_DAY_RANGE_PATTERN,
ReferenceCatalog,
STATUS_KEYWORDS,
TOP_N_PATTERN,
ReferenceCatalog,
)
class OntologyExtractionMixin:
class OntologyExtractionMixin(BudgetOntologyMixin):
@staticmethod
def _is_expense_application_context_value(context_json: dict[str, Any]) -> bool:
document_type = str(context_json.get("document_type") or "").strip()
@@ -63,6 +63,9 @@ class OntologyExtractionMixin:
time_range: OntologyTimeRange,
context_json: dict[str, Any],
) -> list[str]:
if scenario == "budget" and intent == "draft":
return self._infer_budget_missing_slots(entities, context_json)
if scenario != "expense" or intent != "draft":
return []
@@ -87,7 +90,8 @@ class OntologyExtractionMixin:
for item in entities
if item.type == "expense_type"
}
if "expense_type" not in entity_types and not str(form_values.get("expense_type") or "").strip():
form_expense_type = str(form_values.get("expense_type") or "").strip()
if "expense_type" not in entity_types and not form_expense_type:
missing_slots.append("expense_type")
if "amount" not in entity_types and not str(form_values.get("amount") or "").strip():
missing_slots.append("amount")
@@ -103,7 +107,10 @@ class OntologyExtractionMixin:
).strip()
if not reason_value and compact_query in GENERIC_EXPENSE_APPLICATION_PROMPTS:
missing_slots.append("reason")
if attachment_count <= 0 and expense_type_codes & EXPENSE_APPLICATION_ATTACHMENT_REQUIRED_TYPES:
if (
attachment_count <= 0
and expense_type_codes & EXPENSE_APPLICATION_ATTACHMENT_REQUIRED_TYPES
):
missing_slots.append("attachments")
ordered_keys = [*EXPENSE_APPLICATION_REQUIRED_SLOT_KEYS, "attachments"]
return [item for item in ordered_keys if item in missing_slots]
@@ -193,6 +200,9 @@ class OntologyExtractionMixin:
)
)
for entity in self._extract_budget_entities(query, compact_query, context_json):
upsert(entity)
for match in re.finditer(r"客户\s*([A-Za-z0-9一二三四五六七八九十]+)", query):
suffix = match.group(1).strip()
normalized = f"客户{suffix}".replace(" ", "")
@@ -257,7 +267,15 @@ class OntologyExtractionMixin:
upsert(self._make_entity("contract", code, code.upper()))
for location in LOCATION_KEYWORDS:
if location in query:
upsert(self._make_entity("location", location, location, role="filter", confidence=0.86))
upsert(
self._make_entity(
"location",
location,
location,
role="filter",
confidence=0.86,
)
)
for label, normalized in EXPENSE_TYPE_KEYWORDS.items():
if label in query:
@@ -301,34 +319,139 @@ class OntologyExtractionMixin:
"高速费",
)
):
upsert(self._make_entity("expense_type", "交通", "transport", role="filter", confidence=0.9))
if any(keyword in query for keyword in ("出差", "机票", "飞机票", "航班", "火车票", "火车", "高铁票", "高铁", "动车", "行程单")):
upsert(self._make_entity("expense_type", "差旅", "travel", role="filter", confidence=0.88))
if any(keyword in query for keyword in ("酒店", "酒店发票", "住宿", "住宿费", "宾馆", "民宿", "房费", "客房")):
upsert(self._make_entity("expense_type", "住宿", "hotel", role="filter", confidence=0.86))
if (
not has_customer_entertainment_signal
and any(keyword in query for keyword in ("餐费", "用餐", "午餐", "晚餐", "早餐", "餐饮"))
):
upsert(self._make_entity("expense_type", "业务招待费", "meal", role="filter", confidence=0.84))
upsert(
self._make_entity(
"expense_type",
"交通",
"transport",
role="filter",
confidence=0.9,
)
)
if any(
keyword in query
for keyword in ("办公用品", "文具", "耗材", "办公耗材", "打印纸", "办公设备", "键盘", "鼠标", "白板", "硒鼓", "墨盒")
for keyword in (
"出差",
"机票",
"飞机票",
"航班",
"火车票",
"火车",
"高铁票",
"高铁",
"动车",
"行程单",
)
):
upsert(self._make_entity("expense_type", "办公用品费", "office", role="filter", confidence=0.87))
upsert(
self._make_entity(
"expense_type",
"差旅",
"travel",
role="filter",
confidence=0.88,
)
)
if any(keyword in query for keyword in ("培训", "讲师费", "课时费", "课程费", "教材", "认证费", "考试费")):
upsert(self._make_entity("expense_type", "培训费", "training", role="filter", confidence=0.84))
if any(
keyword in query
for keyword in ("酒店", "酒店发票", "住宿", "住宿费", "宾馆", "民宿", "房费", "客房")
):
upsert(
self._make_entity(
"expense_type",
"住宿",
"hotel",
role="filter",
confidence=0.86,
)
)
if any(keyword in query for keyword in ("通讯费", "话费", "电话费", "手机费", "流量费", "宽带费", "网络费")):
upsert(self._make_entity("expense_type", "通讯费", "communication", role="filter", confidence=0.84))
if (
not has_customer_entertainment_signal
and any(
keyword in query
for keyword in ("餐费", "用餐", "午餐", "晚餐", "早餐", "餐饮")
)
):
upsert(
self._make_entity(
"expense_type",
"业务招待费",
"meal",
role="filter",
confidence=0.84,
)
)
if any(keyword in query for keyword in ("福利费", "团建", "慰问", "节日福利", "体检费", "员工关怀")):
upsert(self._make_entity("expense_type", "福利费", "welfare", role="filter", confidence=0.84))
if any(
keyword in query
for keyword in (
"办公用品",
"文具",
"耗材",
"办公耗材",
"打印纸",
"办公设备",
"键盘",
"鼠标",
"白板",
"硒鼓",
"墨盒",
)
):
upsert(
self._make_entity(
"expense_type",
"办公用品费",
"office",
role="filter",
confidence=0.87,
)
)
if any(
keyword in query
for keyword in ("培训", "讲师费", "课时费", "课程费", "教材", "认证费", "考试费")
):
upsert(
self._make_entity(
"expense_type",
"培训费",
"training",
role="filter",
confidence=0.84,
)
)
if any(
keyword in query
for keyword in ("通讯费", "话费", "电话费", "手机费", "流量费", "宽带费", "网络费")
):
upsert(
self._make_entity(
"expense_type",
"通讯费",
"communication",
role="filter",
confidence=0.84,
)
)
if any(
keyword in query
for keyword in ("福利费", "团建", "慰问", "节日福利", "体检费", "员工关怀")
):
upsert(
self._make_entity(
"expense_type",
"福利费",
"welfare",
role="filter",
confidence=0.84,
)
)
for amount in self._extract_amount_entities(query):
upsert(amount)
@@ -380,6 +503,20 @@ class OntologyExtractionMixin:
@staticmethod
def _infer_scenario_from_entities(entities: list[OntologyEntity]) -> str | None:
entity_types = {item.type for item in entities}
if entity_types & {
"budget_period",
"budget_subject",
"budget_status",
"budget_version",
"budget_amount",
"available_amount",
"reserved_amount",
"consumed_amount",
"cost_center",
"warning_threshold",
"control_action",
}:
return "budget"
if entity_types & {"vendor", "payable"}:
return "accounts_payable"
if entity_types & {"customer", "receivable", "contract"}:
@@ -548,9 +685,11 @@ class OntologyExtractionMixin:
if any(
keyword in compact_query
for keyword in ("多少钱", "金额", "总额", "支出", "回款", "应收", "应付")
for keyword in ("多少钱", "金额", "总额", "支出", "回款", "应收", "应付", "预算")
):
upsert(OntologyMetric(name="amount", aggregation="sum", unit="CNY"))
for metric in self._extract_budget_metrics(compact_query):
upsert(metric)
if any(keyword in compact_query for keyword in ("多少笔", "几笔", "数量", "条数", "单数")):
upsert(OntologyMetric(name="count", aggregation="count", unit="records"))
if "超标" in compact_query or "超预算" in compact_query:
@@ -600,6 +739,17 @@ class OntologyExtractionMixin:
"expense_type",
"document_type",
"workflow_stage",
"budget_period",
"budget_subject",
"budget_status",
"budget_version",
"budget_amount",
"available_amount",
"reserved_amount",
"consumed_amount",
"cost_center",
"warning_threshold",
"control_action",
}:
upsert(
OntologyConstraint(

View File

@@ -6,7 +6,10 @@ from dataclasses import dataclass
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.ontology import OntologyIntent, OntologyScenario
from app.services.expense_type_keywords import build_expense_type_keyword_map
from app.services.expense_type_keywords import (
EXPENSE_TYPE_LABEL_BY_CODE,
build_expense_type_keyword_map,
)
DATE_RANGE_PATTERN = re.compile(
r"(?P<start>\d{4}-\d{1,2}-\d{1,2})\s*(?:到|至|~|-)\s*(?P<end>\d{4}-\d{1,2}-\d{1,2})"
@@ -61,6 +64,27 @@ SCENARIO_KEYWORDS = {
("待付", 0.16),
("打款", 0.18),
),
"budget": (
("预算中心", 0.28),
("预算管理", 0.26),
("预算编制", 0.24),
("预算", 0.20),
("预算额度", 0.22),
("预算金额", 0.22),
("可用预算", 0.22),
("剩余预算", 0.22),
("预算余额", 0.20),
("预算占用", 0.22),
("预算预占", 0.22),
("预占", 0.16),
("核销", 0.16),
("成本中心", 0.22),
("预算科目", 0.22),
("预算预警", 0.22),
("预警线", 0.18),
("超预算", 0.24),
("预算不足", 0.24),
),
"knowledge": (
("制度", 0.20),
("规则", 0.20),
@@ -216,6 +240,56 @@ EXPENSE_APPLICATION_ATTACHMENT_REQUIRED_TYPES = {
"office",
"training",
}
BUDGET_CONTEXT_TYPES = {
"budget",
"budget_plan",
"budget_center",
"budget_management",
}
BUDGET_KEYWORDS = tuple(keyword for keyword, _weight in SCENARIO_KEYWORDS["budget"])
BUDGET_DRAFT_KEYWORDS = (
"新建预算",
"创建预算",
"编制预算",
"编辑预算",
"调整预算",
"保存预算",
"预算草稿",
)
BUDGET_OPERATE_KEYWORDS = (
"发布预算",
"冻结预算",
"解冻预算",
"启用预算",
"停用预算",
)
BUDGET_REQUIRED_SLOT_KEYS = (
"budget_period",
"department",
"budget_subject",
"budget_amount",
)
BUDGET_SUBJECT_KEYWORDS = EXPENSE_TYPE_KEYWORDS
BUDGET_SUBJECT_LABEL_BY_CODE = EXPENSE_TYPE_LABEL_BY_CODE
BUDGET_STATUS_KEYWORDS = {
"编制中": "drafting",
"草稿": "draft",
"已发布": "published",
"发布": "published",
"已冻结": "frozen",
"冻结": "frozen",
"已关闭": "closed",
"关闭": "closed",
}
BUDGET_CONTROL_ACTION_KEYWORDS = {
"提醒": "remind",
"预警": "remind",
"正常": "allow",
"允许": "allow",
"管控": "control",
"阻断": "block",
"禁止": "block",
}
MISSING_SLOT_LABELS = {
"expense_type": "费用类型",
"amount": "金额",
@@ -226,6 +300,13 @@ MISSING_SLOT_LABELS = {
"time_range": "发生时间",
"reason": "事由说明",
"document_id": "单据号",
"department": "所属部门",
"budget_period": "预算周期",
"budget_subject": "预算科目",
"budget_amount": "预算金额",
"cost_center": "成本中心",
"warning_threshold": "预警线",
"control_action": "控制动作",
}
STATUS_KEYWORDS = {
@@ -278,7 +359,7 @@ LOCATION_KEYWORDS = (
)
PRIVILEGED_ROLE_CODES = {"manager", "finance", "approver", "executive"}
CONTEXTUAL_SCENARIOS = {"expense", "accounts_receivable", "accounts_payable", "knowledge"}
CONTEXTUAL_SCENARIOS = {"expense", "accounts_receivable", "accounts_payable", "budget", "knowledge"}
KNOWLEDGE_INTENTS = {"query", "explain", "compare"}

View File

@@ -12,7 +12,6 @@ from app.schemas.ontology import (
OntologyTimeRange,
)
from app.services.ontology_rules import (
AMOUNT_PATTERN,
EXPENSE_REVIEW_ACTIONS,
MISSING_SLOT_LABELS,
OPERATE_KEYWORDS,
@@ -37,6 +36,14 @@ class OntologyValidationMixin:
append("invoice_anomaly")
if any(keyword in compact_query for keyword in ("超标", "超预算", "超限")):
append("amount_over_limit")
if scenario == "budget" and any(
keyword in compact_query for keyword in ("预算不足", "超预算", "超支")
):
append("budget_over_limit")
if scenario == "budget" and any(
keyword in compact_query for keyword in ("预算预警", "触发预警", "接近预算")
):
append("budget_warning")
if scenario == "accounts_receivable" and any(
keyword in compact_query for keyword in ("逾期", "账龄", "欠款", "未回款")
):

View File

@@ -83,8 +83,10 @@ EXPENSE_TYPE_LABELS = {
"meal": "业务招待费",
"meeting": "会务费",
"entertainment": "业务招待费",
"marketing": "市场推广费",
"office": "办公用品费",
"training": "培训费",
"software": "软件服务费",
"communication": "通讯费",
"welfare": "福利费",
"other": "其他费用",
@@ -131,7 +133,9 @@ class OrchestratorDatabaseQueryBuilder:
message=message,
)
count_stmt = select(func.count()).select_from(ExpenseClaim)
amount_stmt = select(func.coalesce(func.sum(ExpenseClaim.amount), 0)).select_from(ExpenseClaim)
amount_stmt = select(func.coalesce(func.sum(ExpenseClaim.amount), 0)).select_from(
ExpenseClaim
)
for condition in conditions:
count_stmt = count_stmt.where(condition)
amount_stmt = amount_stmt.where(condition)
@@ -148,7 +152,9 @@ class OrchestratorDatabaseQueryBuilder:
if recent_window_applied:
reference_now = self._resolve_reference_now(context_json)
recent_window_start, recent_window_end = self._resolve_expense_recent_window_bounds(reference_now)
recent_window_start, recent_window_end = self._resolve_expense_recent_window_bounds(
reference_now
)
recent_condition = self._build_expense_recent_window_condition(
recent_window_start,
recent_window_end,
@@ -157,9 +163,13 @@ class OrchestratorDatabaseQueryBuilder:
window_start_date = recent_window_start.date().isoformat()
window_end_date = (recent_window_end - timedelta(microseconds=1)).date().isoformat()
recent_count_stmt = select(func.count()).select_from(ExpenseClaim).where(recent_condition)
recent_amount_stmt = select(func.coalesce(func.sum(ExpenseClaim.amount), 0)).select_from(ExpenseClaim).where(
recent_condition
recent_count_stmt = (
select(func.count()).select_from(ExpenseClaim).where(recent_condition)
)
recent_amount_stmt = (
select(func.coalesce(func.sum(ExpenseClaim.amount), 0))
.select_from(ExpenseClaim)
.where(recent_condition)
)
for condition in conditions:
recent_count_stmt = recent_count_stmt.where(condition)
@@ -189,7 +199,11 @@ class OrchestratorDatabaseQueryBuilder:
"record_count": display_count,
"total_amount": round(display_amount, 2),
"scope_label": scope_label,
"title": f"最近 {len(preview_claims)}{scope_label}" if preview_claims else f"{scope_label}筛选结果",
"title": (
f"最近 {len(preview_claims)}{scope_label}"
if preview_claims
else f"{scope_label}筛选结果"
),
"scoped_to_current_user": scoped_to_current_user,
"recent_window_applied": recent_window_applied,
"window_days": EXPENSE_QUERY_RECENT_WINDOW_DAYS if recent_window_applied else None,
@@ -280,7 +294,8 @@ class OrchestratorDatabaseQueryBuilder:
reference_now: datetime,
) -> tuple[datetime, datetime]:
normalized_now = reference_now.astimezone(UTC)
window_end = normalized_now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
window_end = normalized_now.replace(hour=0, minute=0, second=0, microsecond=0)
window_end += timedelta(days=1)
window_start = window_end - timedelta(days=EXPENSE_QUERY_RECENT_WINDOW_DAYS)
return window_start, window_end
@@ -300,7 +315,11 @@ class OrchestratorDatabaseQueryBuilder:
self,
conditions: list[Any],
) -> list[dict[str, Any]]:
stmt = select(ExpenseClaim.status, func.count()).select_from(ExpenseClaim).group_by(ExpenseClaim.status)
stmt = (
select(ExpenseClaim.status, func.count())
.select_from(ExpenseClaim)
.group_by(ExpenseClaim.status)
)
for condition in conditions:
stmt = stmt.where(condition)
@@ -356,7 +375,10 @@ class OrchestratorDatabaseQueryBuilder:
"claim_no": claim.claim_no,
"employee_name": claim.employee_name,
"expense_type": claim.expense_type,
"expense_type_label": EXPENSE_TYPE_LABELS.get(claim.expense_type, claim.expense_type or "报销"),
"expense_type_label": EXPENSE_TYPE_LABELS.get(
claim.expense_type,
claim.expense_type or "报销",
),
"amount": round(float(claim.amount), 2),
"status": claim.status,
"status_label": status_label,
@@ -378,7 +400,11 @@ class OrchestratorDatabaseQueryBuilder:
normalized_flags: list[dict[str, str]] = []
for index, raw_flag in enumerate(raw_flags, start=1):
if isinstance(raw_flag, dict):
raw_level = str(raw_flag.get("severity") or raw_flag.get("level") or "").strip().lower()
raw_level = (
str(raw_flag.get("severity") or raw_flag.get("level") or "")
.strip()
.lower()
)
level = raw_level if raw_level in EXPENSE_RISK_LEVEL_LABELS else "medium"
summary = str(
raw_flag.get("message")
@@ -397,7 +423,11 @@ class OrchestratorDatabaseQueryBuilder:
raw_text = str(raw_flag or "").strip()
if not raw_text:
continue
level = "high" if any(keyword in raw_text for keyword in ("高风险", "超标", "重复", "异常")) else "medium"
level = (
"high"
if any(keyword in raw_text for keyword in ("高风险", "超标", "重复", "异常"))
else "medium"
)
summary = raw_text
detail = raw_text
title = EXPENSE_RISK_LEVEL_LABELS[level]
@@ -436,14 +466,16 @@ class OrchestratorDatabaseQueryBuilder:
dict.fromkeys(
str(item.normalized_value or item.value or "").strip().upper()
for item in ontology.entities
if item.type == "expense_claim" and str(item.normalized_value or item.value or "").strip()
if item.type == "expense_claim"
and str(item.normalized_value or item.value or "").strip()
)
)
expense_types = list(
dict.fromkeys(
str(item.normalized_value or item.value or "").strip()
for item in ontology.entities
if item.type == "expense_type" and str(item.normalized_value or item.value or "").strip()
if item.type == "expense_type"
and str(item.normalized_value or item.value or "").strip()
)
)
project_values = self._collect_expense_query_filter_values(ontology, "project")
@@ -551,7 +583,11 @@ class OrchestratorDatabaseQueryBuilder:
else:
scope_label = "全部报销单"
return conditions, self._compose_expense_scope_label(scope_label, status_values), scoped_to_current_user
return (
conditions,
self._compose_expense_scope_label(scope_label, status_values),
scoped_to_current_user,
)
@staticmethod
def _resolve_expense_query_status_values(

View File

@@ -22,6 +22,7 @@ from app.services.risk_rule_flow_diagram import (
from app.services.risk_rule_generation_ontology import (
BUSINESS_DOMAIN_LABELS,
DOMAIN_FIELD_PREFIXES,
EXPENSE_BUSINESS_STAGE_LABELS,
EXPENSE_RISK_CATEGORY_ALIASES,
EXPENSE_RISK_CATEGORY_LABELS,
FIELD_ONTOLOGY,
@@ -75,6 +76,8 @@ class RiskRuleGenerationService:
raise ValueError("规则标题至少需要 2 个字。")
requires_attachment = bool(body.requires_attachment)
business_stage = self._normalize_business_stage(body.business_stage, domain)
business_stage_label = EXPENSE_BUSINESS_STAGE_LABELS.get(business_stage, "费用报销")
expense_category = self._normalize_expense_category(body.expense_category, domain)
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
@@ -83,6 +86,8 @@ class RiskRuleGenerationService:
draft = self._compile_with_model(
natural_language=natural_language,
domain=domain,
business_stage=business_stage,
business_stage_label=business_stage_label,
expense_category=expense_category,
expense_category_label=expense_category_label,
fields=fields,
@@ -113,6 +118,8 @@ class RiskRuleGenerationService:
draft,
natural_language=natural_language,
domain=domain,
business_stage=business_stage,
business_stage_label=business_stage_label,
expense_category=expense_category,
expense_category_label=expense_category_label,
risk_level=risk_level,
@@ -155,6 +162,8 @@ class RiskRuleGenerationService:
"requires_attachment": requires_attachment,
"tag": "风险规则",
"detail_mode": "json_risk",
"business_stage": business_stage,
"business_stage_label": business_stage_label,
"expense_category": expense_category,
"expense_category_label": expense_category_label,
"risk_category": payload.get("risk_category"),
@@ -167,6 +176,11 @@ class RiskRuleGenerationService:
"evaluator": payload.get("evaluator"),
"generated_by": "natural_language",
"source_ref": "自然语言风险规则",
"last_operation": {
"action": "create",
"actor": actor,
"at": datetime.now(UTC).isoformat(),
},
},
)
self.db.add(asset)
@@ -192,6 +206,7 @@ class RiskRuleGenerationService:
"risk_level": risk_level,
"risk_score": risk_score["score"],
"domain": domain,
"business_stage": business_stage,
"expense_category": expense_category,
"requires_attachment": requires_attachment,
},
@@ -205,6 +220,8 @@ class RiskRuleGenerationService:
*,
natural_language: str,
domain: str,
business_stage: str,
business_stage_label: str,
expense_category: str | None,
expense_category_label: str,
fields: list[RiskRuleField],
@@ -221,6 +238,8 @@ class RiskRuleGenerationService:
messages = build_risk_rule_compiler_messages(
domain=domain,
domain_label=BUSINESS_DOMAIN_LABELS[domain],
business_stage=business_stage,
business_stage_label=business_stage_label,
expense_category=expense_category,
expense_category_label=expense_category_label,
natural_language=natural_language,
@@ -372,6 +391,8 @@ class RiskRuleGenerationService:
*,
natural_language: str,
domain: str,
business_stage: str,
business_stage_label: str,
expense_category: str | None,
expense_category_label: str,
risk_level: str,
@@ -408,6 +429,8 @@ class RiskRuleGenerationService:
"field_keys": field_keys,
"condition_summary": condition_summary,
"natural_language": natural_language,
"business_stage": business_stage,
"business_stage_label": business_stage_label,
}
semantic_type = str(draft.get("semantic_type") or "").strip()
if semantic_type:
@@ -431,6 +454,8 @@ class RiskRuleGenerationService:
params["keywords"] = keywords
params["search_fields"] = field_keys
applies_to: dict[str, Any] = {"domains": [domain]}
if business_stage:
applies_to["business_stages"] = [business_stage]
if expense_category:
applies_to["expense_categories"] = [expense_category]
@@ -485,6 +510,8 @@ class RiskRuleGenerationService:
"rule_title": rule_title,
"expense_category": expense_category,
"expense_category_label": expense_category_label,
"business_stage": business_stage,
"business_stage_label": business_stage_label,
"natural_language": natural_language,
"business_explanation": self._clean_text(draft.get("description")),
"condition_summary": condition_summary,
@@ -558,6 +585,19 @@ class RiskRuleGenerationService:
raise ValueError(f"费用领域仅支持:{allowed}")
return normalized
@staticmethod
def _normalize_business_stage(value: str | None, domain: str) -> str:
if domain != AgentAssetDomain.EXPENSE.value:
return "reimbursement"
normalized = str(value or "reimbursement").strip().lower()
if not normalized:
normalized = "reimbursement"
if normalized not in EXPENSE_BUSINESS_STAGE_LABELS:
allowed = "".join(EXPENSE_BUSINESS_STAGE_LABELS.values())
raise ValueError(f"业务环节仅支持:{allowed}")
return normalized
def _resolve_fields(self, text: str, *, domain: str) -> list[RiskRuleField]:
prefixes = DOMAIN_FIELD_PREFIXES.get(domain, ())
candidates = [field for field in FIELD_ONTOLOGY if field.key.startswith(prefixes)]

View File

@@ -12,6 +12,7 @@ from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.audit import AuditLogService
from app.services.risk_rule_generation import (
BUSINESS_DOMAIN_LABELS,
EXPENSE_BUSINESS_STAGE_LABELS,
EXPENSE_RISK_CATEGORY_LABELS,
RiskRuleGenerationService,
)
@@ -49,6 +50,8 @@ class RiskRuleGenerationJobService:
natural_language = self._validate_natural_language(body)
rule_title = self._validate_rule_title(body)
requires_attachment = bool(body.requires_attachment)
business_stage = self.generator._normalize_business_stage(body.business_stage, domain)
business_stage_label = EXPENSE_BUSINESS_STAGE_LABELS.get(business_stage, "费用报销")
expense_category = self.generator._normalize_expense_category(body.expense_category, domain)
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
@@ -82,6 +85,8 @@ class RiskRuleGenerationJobService:
"requires_attachment": requires_attachment,
"tag": "风险规则",
"detail_mode": "json_risk",
"business_stage": business_stage,
"business_stage_label": business_stage_label,
"expense_category": expense_category,
"expense_category_label": expense_category_label,
"risk_category": category_label,
@@ -94,6 +99,11 @@ class RiskRuleGenerationJobService:
"generation_status": AgentAssetStatus.GENERATING.value,
"generation_started_at": created_at.isoformat(),
"generation_request": self._dump_generation_request(body),
"last_operation": {
"action": "generate",
"actor": actor,
"at": created_at.isoformat(),
},
},
)
self.db.add(asset)
@@ -107,6 +117,7 @@ class RiskRuleGenerationJobService:
after_json={
"rule_code": rule_code,
"domain": domain,
"business_stage": business_stage,
"expense_category": expense_category,
},
request_id=request_id,
@@ -181,6 +192,8 @@ class RiskRuleGenerationJobService:
natural_language = self._validate_natural_language(body)
rule_title = self._validate_rule_title(body)
requires_attachment = bool(body.requires_attachment)
business_stage = self.generator._normalize_business_stage(body.business_stage, domain)
business_stage_label = EXPENSE_BUSINESS_STAGE_LABELS.get(business_stage, "费用报销")
expense_category = self.generator._normalize_expense_category(body.expense_category, domain)
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
created_at = asset.created_at or datetime.now(UTC)
@@ -189,6 +202,8 @@ class RiskRuleGenerationJobService:
draft = self.generator._compile_with_model(
natural_language=natural_language,
domain=domain,
business_stage=business_stage,
business_stage_label=business_stage_label,
expense_category=expense_category,
expense_category_label=expense_category_label,
fields=fields,
@@ -219,6 +234,8 @@ class RiskRuleGenerationJobService:
draft,
natural_language=natural_language,
domain=domain,
business_stage=business_stage,
business_stage_label=business_stage_label,
expense_category=expense_category,
expense_category_label=expense_category_label,
risk_level=risk_level,
@@ -247,6 +264,8 @@ class RiskRuleGenerationJobService:
"requires_attachment": requires_attachment,
"tag": "风险规则",
"detail_mode": "json_risk",
"business_stage": business_stage,
"business_stage_label": business_stage_label,
"expense_category": expense_category,
"expense_category_label": expense_category_label,
"risk_category": payload.get("risk_category"),
@@ -261,6 +280,11 @@ class RiskRuleGenerationJobService:
"source_ref": "自然语言风险规则",
"generation_status": "completed",
"generation_completed_at": datetime.now(UTC).isoformat(),
"last_operation": {
"action": "create",
"actor": actor,
"at": datetime.now(UTC).isoformat(),
},
}
asset.code = rule_code
@@ -296,6 +320,7 @@ class RiskRuleGenerationJobService:
"risk_level": risk_level,
"risk_score": risk_score["score"],
"domain": domain,
"business_stage": business_stage,
"expense_category": expense_category,
"requires_attachment": requires_attachment,
},

View File

@@ -46,6 +46,11 @@ EXPENSE_RISK_CATEGORY_ALIASES = {
"entertainment": "meal",
}
EXPENSE_BUSINESS_STAGE_LABELS: dict[str, str] = {
"expense_application": "费用申请",
"reimbursement": "费用报销",
}
FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = (
RiskRuleField("claim.reason", "报销事由", "text", "claim", ("事由", "说明", "理由", "用途")),
RiskRuleField(

View File

@@ -8,6 +8,8 @@ def build_risk_rule_compiler_messages(
*,
domain: str,
domain_label: str,
business_stage: str,
business_stage_label: str,
expense_category: str | None,
expense_category_label: str,
natural_language: str,
@@ -74,6 +76,9 @@ def build_risk_rule_compiler_messages(
}
guardrails = [
"只能输出 JSON 对象,不能输出 Markdown 或解释。",
"必须区分业务环节:费用申请是事前风控,费用报销是事后核验;不要把二者的字段和流程语义混用。",
"费用申请阶段更关注预算余额、申请金额、申请事由、预计行程、预计费用科目、是否超预算或缺少前置审批。",
"费用报销阶段更关注真实票据、报销明细、发生日期、附件识别结果和申请/行程/票据一致性。",
"字段必须来自 available_fields不能编造字段。",
"多步骤规则要使用 composite_rule_v1先抽取事实变量再写 conditions 和 hit_logic不要压扁成单个关键词判断。",
"城市/地点/路线一致性必须用 field_compare_v1 或 semantic_type=travel_route_city_consistency。",
@@ -88,6 +93,8 @@ def build_risk_rule_compiler_messages(
"keyword_match_v1 只用于品名、摘要、票据全文中出现明确风险词的规则。",
"不要直接指定 risk_level 或 risk_score只输出 risk_scoring_evidence后端会按固定评分模型计算 0-100 分和风险等级。",
"评分证据必须围绕六个指标:业务影响、违规确定性、证据强度、例外/规避空间、处置强度、场景敏感度。",
"若规则语义是可修复的低风险提醒,例如资料要素缺失但归属清晰、仅提醒/提示/补齐且不退回不阻断,则 impact_level 和 control_action 应保持低强度。",
"只有涉及造假、重复报销、金额超标、城市/日期不一致、禁止提交、退回修改、阻断或审计复核时,才应给 high 或 critical 的评分证据。",
]
examples = [
{
@@ -114,6 +121,26 @@ def build_risk_rule_compiler_messages(
"keywords": [],
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
},
},
{
"user_rule": (
"差旅报销时,票据已上传但发票号码或商品服务名称缺失,且报销事由、人员和部门"
"能够说明费用归属,则标记为低风险,仅提醒补齐票据要素。"
),
"expected": {
"template_key": "field_required_v1",
"field_keys": ["attachment.invoice_no", "attachment.goods_name", "claim.reason"],
"condition_summary": "票据要素缺失但费用归属清晰时,仅提示补齐。",
"risk_scoring_evidence": {
"impact_level": "low",
"violation_certainty": "medium",
"evidence_strength": "medium",
"exception_dependence": "low",
"control_action": "remind",
"business_sensitivity": "medium",
"reason": "命中后只做补齐提醒,不阻断、不退回,也不涉及舞弊或金额越权。",
},
},
}
]
return [
@@ -134,6 +161,8 @@ def build_risk_rule_compiler_messages(
{
"business_domain": domain,
"business_domain_label": domain_label,
"business_stage": business_stage,
"business_stage_label": business_stage_label,
"expense_category": expense_category,
"expense_category_label": expense_category_label,
"natural_language": natural_language,

View File

@@ -0,0 +1,227 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from app.models.agent_asset import AgentAsset
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.risk_rule_scoring import RISK_SCORE_MODEL_VERSION, calculate_risk_rule_score
def backfill_missing_risk_rule_score(
asset: AgentAsset,
*,
rule_library_manager: AgentAssetRuleLibraryManager | None = None,
) -> bool:
config_json = dict(asset.config_json or {})
if str(config_json.get("detail_mode") or "").strip().lower() != "json_risk":
return False
if _has_current_score(config_json):
return False
manager = rule_library_manager or AgentAssetRuleLibraryManager()
library = str(config_json.get("rule_library") or RISK_RULES_LIBRARY).strip() or RISK_RULES_LIBRARY
file_name = _resolve_rule_file_name(asset, config_json)
if not file_name:
return False
manifest = manager.read_rule_library_json(library=library, file_name=file_name)
if _has_current_score(manifest) or _has_current_score(manifest.get("metadata")):
score = _read_existing_score(manifest)
else:
score = _calculate_score(asset, manifest, config_json)
_apply_score_to_manifest(manifest, score)
manager.write_rule_library_json(library=library, file_name=file_name, payload=manifest)
_apply_score_to_config(config_json, manifest, score)
asset.config_json = config_json
return True
def _resolve_rule_file_name(asset: AgentAsset, config_json: dict[str, Any]) -> str:
rule_document = config_json.get("rule_document")
if isinstance(rule_document, dict):
file_name = str(rule_document.get("file_name") or "").strip()
if file_name:
return file_name
code = str(asset.code or "").strip()
return f"{code}.json" if code else ""
def _calculate_score(
asset: AgentAsset,
manifest: dict[str, Any],
config_json: dict[str, Any],
) -> dict[str, Any]:
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
fields = _read_fields(manifest)
field_keys = _read_field_keys(manifest, fields)
draft = {
"template_key": manifest.get("template_key") or params.get("template_key"),
"field_keys": field_keys,
"description": manifest.get("description") or asset.description,
"condition_summary": metadata.get("condition_summary") or params.get("condition_summary"),
"formula": params.get("formula"),
"message_template": params.get("message_template"),
"conditions": params.get("conditions") if isinstance(params.get("conditions"), list) else [],
"keywords": params.get("keywords") if isinstance(params.get("keywords"), list) else [],
"exception_keywords": params.get("exception_keywords")
if isinstance(params.get("exception_keywords"), list)
else [],
"flow": metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {},
}
if isinstance(params.get("rule_ir"), dict):
draft["rule_ir"] = params["rule_ir"]
generation_request = (
config_json.get("generation_request")
if isinstance(config_json.get("generation_request"), dict)
else {}
)
natural_language = str(
metadata.get("natural_language")
or params.get("natural_language")
or generation_request.get("natural_language")
or manifest.get("description")
or asset.description
or ""
)
expense_category = str(
metadata.get("expense_category") or config_json.get("expense_category") or ""
).strip() or None
expense_category_label = str(
metadata.get("expense_category_label")
or config_json.get("expense_category_label")
or manifest.get("risk_category")
or ""
).strip()
requires_attachment = bool(
manifest.get("requires_attachment") or config_json.get("requires_attachment")
)
return calculate_risk_rule_score(
natural_language=natural_language,
draft=draft,
fields=fields,
expense_category=expense_category,
expense_category_label=expense_category_label,
requires_attachment=requires_attachment,
)
def _read_fields(manifest: dict[str, Any]) -> list[Any]:
inputs = manifest.get("inputs") if isinstance(manifest.get("inputs"), dict) else {}
rows = inputs.get("fields") if isinstance(inputs.get("fields"), list) else []
return [
SimpleNamespace(
key=str(row.get("key") or "").strip(),
label=str(row.get("label") or "").strip(),
field_type=str(row.get("type") or "").strip(),
source=str(row.get("source") or "").strip(),
)
for row in rows
if isinstance(row, dict) and str(row.get("key") or "").strip()
]
def _read_field_keys(manifest: dict[str, Any], fields: list[Any]) -> list[str]:
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
raw_keys = params.get("field_keys") or params.get("required_fields")
if isinstance(raw_keys, list):
keys = [str(item or "").strip() for item in raw_keys if str(item or "").strip()]
if keys:
return keys
return [str(getattr(field, "key", "") or "").strip() for field in fields]
def _apply_score_to_manifest(manifest: dict[str, Any], score: dict[str, Any]) -> None:
level = str(score.get("level") or "medium")
manifest["severity"] = level
manifest["risk_score"] = int(score.get("score") or 0)
manifest["risk_level"] = level
manifest["risk_level_label"] = str(score.get("level_label") or "")
manifest["risk_score_detail"] = score
outcomes = manifest.setdefault("outcomes", {})
if isinstance(outcomes, dict):
fail = outcomes.setdefault("fail", {})
if isinstance(fail, dict):
fail["severity"] = level
fail["risk_score"] = int(score.get("score") or 0)
metadata = manifest.setdefault("metadata", {})
if isinstance(metadata, dict):
metadata["risk_score"] = int(score.get("score") or 0)
metadata["risk_level"] = level
metadata["risk_level_label"] = str(score.get("level_label") or "")
metadata["risk_score_model"] = score.get("model")
metadata["risk_score_detail"] = score
def _apply_score_to_config(
config_json: dict[str, Any],
manifest: dict[str, Any],
score: dict[str, Any],
) -> None:
level = str(score.get("level") or manifest.get("risk_level") or "medium")
config_json["severity"] = level
config_json["risk_score"] = int(score.get("score") or 0)
config_json["risk_level"] = level
config_json["risk_level_label"] = str(score.get("level_label") or "")
config_json["risk_score_detail"] = score
def _has_score(value: Any) -> bool:
if not isinstance(value, dict):
return False
try:
score = int(value.get("risk_score") if value.get("risk_score") is not None else value.get("score"))
except (TypeError, ValueError):
return False
return 0 <= score <= 100
def _has_current_score(value: Any) -> bool:
if not _has_score(value):
return False
return _read_score_model(value) == RISK_SCORE_MODEL_VERSION
def _read_score_model(value: Any) -> str:
if not isinstance(value, dict):
return ""
detail = value.get("risk_score_detail")
if isinstance(detail, dict):
model = str(detail.get("model") or "").strip()
if model:
return model
metadata = value.get("metadata")
if isinstance(metadata, dict):
detail = metadata.get("risk_score_detail")
if isinstance(detail, dict):
model = str(detail.get("model") or "").strip()
if model:
return model
model = str(metadata.get("risk_score_model") or "").strip()
if model:
return model
return str(value.get("risk_score_model") or value.get("model") or "").strip()
def _read_existing_score(manifest: dict[str, Any]) -> dict[str, Any]:
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
detail = metadata.get("risk_score_detail")
if isinstance(detail, dict) and _has_score(detail):
return dict(detail)
detail = manifest.get("risk_score_detail")
if isinstance(detail, dict) and _has_score(detail):
return dict(detail)
score = int(metadata.get("risk_score") or manifest.get("risk_score") or 0)
level = str(metadata.get("risk_level") or manifest.get("risk_level") or "medium")
return {
"score": score,
"level": level,
"level_label": str(metadata.get("risk_level_label") or manifest.get("risk_level_label") or ""),
"model": metadata.get("risk_score_model"),
}

View File

@@ -11,7 +11,7 @@ RISK_LEVEL_LABELS: dict[str, str] = {
"critical": "极高风险",
}
RISK_SCORE_MODEL_VERSION = "risk_score_v1"
RISK_SCORE_MODEL_VERSION = "risk_score_v3"
RISK_SCORE_WEIGHTS: dict[str, float] = {
"impact": 0.35,
@@ -115,6 +115,7 @@ def calculate_risk_rule_score(
draft.get("formula"),
draft.get("message_template"),
)
hard_signal_text = _strip_negated_risk_context(text)
template_key = str(draft.get("template_key") or "").strip()
field_keys = _read_string_list(draft.get("field_keys"))
condition_count = len(draft.get("conditions") if isinstance(draft.get("conditions"), list) else [])
@@ -122,7 +123,7 @@ def calculate_risk_rule_score(
components = {
"impact": _component_score(
evidence.get("impact_level"),
_infer_impact_score(text, template_key=template_key),
_infer_impact_score(hard_signal_text, template_key=template_key),
),
"certainty": _component_score(
evidence.get("violation_certainty"),
@@ -142,12 +143,18 @@ def calculate_risk_rule_score(
),
"sensitivity": _component_score(
evidence.get("business_sensitivity"),
_infer_sensitivity_score(text, expense_category=expense_category),
_infer_sensitivity_score(hard_signal_text, expense_category=expense_category),
),
}
score = _clamp_score(
raw_score = _clamp_score(
round(sum(components[key] * RISK_SCORE_WEIGHTS[key] for key in RISK_SCORE_WEIGHTS))
)
score, calibration = _calibrate_score(
raw_score,
text=text,
hard_signal_text=hard_signal_text,
components=components,
)
level = risk_level_from_score(score)
return {
"score": score,
@@ -156,6 +163,7 @@ def calculate_risk_rule_score(
"model": RISK_SCORE_MODEL_VERSION,
"weights": RISK_SCORE_WEIGHTS,
"components": components,
"calibration": calibration,
"ai_evidence": evidence,
"basis": {
"template_key": template_key,
@@ -277,6 +285,8 @@ def _infer_action_score(text: str, draft: dict[str, Any]) -> int:
return 78
if _contains_any(corpus, "人工复核", "复核", "审核"):
return 65
if _contains_any(corpus, "提醒", "提示", "补齐"):
return 35
if _contains_any(corpus, "补充", "说明"):
return 48
return 35
@@ -292,6 +302,69 @@ def _infer_sensitivity_score(text: str, *, expense_category: str | None) -> int:
return 45
def _calibrate_score(
score: int,
*,
text: str,
hard_signal_text: str,
components: dict[str, int],
) -> tuple[int, dict[str, Any]]:
calibration: dict[str, Any] = {"raw_score": score, "rules": []}
if _is_low_control_rule(text, hard_signal_text, components):
calibrated = min(score, 30)
calibration["rules"].append(
{
"name": "explicit_low_control_cap",
"score_before": score,
"score_after": calibrated,
"reason": "规则语义明确为低风险,且控制动作仅为提醒、提示、补齐或补充说明。",
}
)
score = calibrated
return score, calibration
def _is_low_control_rule(text: str, hard_signal_text: str, components: dict[str, int]) -> bool:
if not _contains_any(text, "低风险", "轻微风险", "轻微", "提醒", "提示", "补齐"):
return False
if _contains_any(
hard_signal_text,
"高风险",
"极高风险",
"严重",
"重大",
"造假",
"虚假",
"伪造",
"重复报销",
"骗取",
"套取",
"不一致",
"超预算",
"超标准",
"阻断",
"禁止",
"退回",
"驳回",
):
return False
return components.get("action", 100) <= ACTION_SCORE_MAP["supplement"]
def _strip_negated_risk_context(text: str) -> str:
normalized = str(text or "")
if not normalized:
return ""
negated_risk_pattern = (
r"(?:暂未|未|没有|无|不存在)"
r"(?:发现|存在)?"
r"[^,。;;,.]*"
r"(?:冲突|异常|重复报销|造假|虚假|伪造|超标|超预算|高风险|不一致|迹象)"
r"[^,。;;,.]*"
)
return re.sub(negated_risk_pattern, "", normalized)
def _replace_or_append_risk_label(value: str, level_label: str) -> str:
normalized = str(value or "").strip()
if not normalized:

View File

@@ -38,8 +38,10 @@ EXPENSE_TYPE_LABELS = {
"meal": "业务招待费",
"meeting": "会务费",
"entertainment": "业务招待费",
"marketing": "市场推广费",
"office": "办公用品费",
"training": "培训费",
"software": "软件服务费",
"communication": "通讯费",
"welfare": "福利费",
"other": "其他费用",
@@ -49,10 +51,12 @@ GROUP_SCENE_LABELS = {
"travel": "差旅费",
"entertainment": "业务招待费",
"meal": "业务招待费",
"marketing": "市场推广费",
"transport": "交通费",
"hotel": "住宿费",
"office": "办公用品费",
"training": "培训费",
"software": "软件服务费",
"communication": "通讯费",
"welfare": "福利费",
"other": "其他费用",
@@ -64,8 +68,10 @@ EXPENSE_SCENE_SELECTION_OPTIONS = (
("hotel", "住宿费", "单独住宿、酒店发票等场景。"),
("meal", "业务招待费", "客户接待、工作餐、加班餐、餐饮票据等场景。"),
("meeting", "会务费", "会议、论坛、会场、参会等场景。"),
("marketing", "市场推广费", "广告投放、品牌宣传、营销物料等推广场景。"),
("office", "办公用品费", "办公用品、耗材、办公设备等采购场景。"),
("training", "培训费", "培训课程、讲师费、教材、认证等场景。"),
("software", "软件服务费", "软件订阅、云资源、平台服务等技术服务场景。"),
("communication", "通讯费", "话费、流量、宽带、网络等场景。"),
("welfare", "福利费", "团建、体检、慰问、节日福利等场景。"),
("other", "其他费用", "暂不属于以上分类的报销场景。"),
@@ -110,7 +116,10 @@ AMOUNT_TEXT_PATTERN = re.compile(
r"(\d+(?:\.\d+)?)\s*(?:万元|万员|万圆|万园|万块|万元整|元整|块钱|块|元|员|圆|园|万)"
)
TRAVEL_REVIEW_HOTEL_NIGHT_PATTERN = re.compile(r"(\d+)\s*(?:晚|间夜)")
TRAVEL_ROUTE_PATTERN = re.compile(r"([\u4e00-\u9fa5]{2,12})\s*(?:至|→|->|-|—)\s*([\u4e00-\u9fa5]{2,12})")
TRAVEL_ROUTE_PATTERN = re.compile(
r"([\u4e00-\u9fa5]{2,12})\s*(?:至|→|->|-|—)\s*"
r"([\u4e00-\u9fa5]{2,12})"
)
SOURCE_LABELS = {
"user_text": "用户描述",
@@ -137,8 +146,10 @@ INFERRED_REASON_LABELS = {
"meal": "业务招待",
"meeting": "会务活动",
"entertainment": "客户接待",
"marketing": "市场推广",
"office": "办公用品采购",
"training": "培训学习",
"software": "软件服务",
"communication": "通讯使用",
"welfare": "员工福利",
"other": "其他费用",

View File

@@ -14,8 +14,8 @@
"updated_at": "2026-05-17T09:28:28.999515+00:00",
"uploaded_by": "admin",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-17T10:01:33.272539+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.726523+00:00",
"ingest_completed_at": "2026-05-17T10:01:33.272539+00:00",
"ingest_document_name": "远光《公司支出管理办法2024》.pdf",
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
@@ -35,8 +35,8 @@
"updated_at": "2026-05-22T07:00:22.328877+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T09:22:25.565409+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.731130+00:00",
"ingest_completed_at": "2026-05-22T09:22:25.565409+00:00",
"ingest_document_name": "远光软件会计科目使用说明.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:22.328877+00:00",
@@ -56,8 +56,8 @@
"updated_at": "2026-05-22T07:00:22.011016+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-23T14:30:33.605531+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.735501+00:00",
"ingest_completed_at": "2026-05-23T14:30:33.605531+00:00",
"ingest_document_name": "远光软件财务基础知识手册.docx",
"ingest_document_updated_at": "2026-05-22T07:00:22.011016+00:00",
@@ -77,8 +77,8 @@
"updated_at": "2026-05-22T07:00:22.352133+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T09:23:11.334499+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.739842+00:00",
"ingest_completed_at": "2026-05-22T09:23:11.334499+00:00",
"ingest_document_name": "远光软件财务术语解释手册.docx",
"ingest_document_updated_at": "2026-05-22T07:00:22.352133+00:00",
@@ -98,8 +98,8 @@
"updated_at": "2026-05-22T07:00:22.304623+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T09:24:18.933073+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.744555+00:00",
"ingest_completed_at": "2026-05-22T09:24:18.933073+00:00",
"ingest_document_name": "远光软件高新技术企业税收优惠政策汇总.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:22.304623+00:00",
@@ -119,8 +119,8 @@
"updated_at": "2026-05-22T07:00:18.153373+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:01:43.168774+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.762391+00:00",
"ingest_completed_at": "2026-05-22T16:01:43.168774+00:00",
"ingest_document_name": "远光软件公司内部控制基本规范.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:18.153373+00:00",
@@ -140,8 +140,8 @@
"updated_at": "2026-05-22T07:00:18.190399+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:03:00.735908+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.773116+00:00",
"ingest_completed_at": "2026-05-22T16:03:00.735908+00:00",
"ingest_document_name": "远光软件公司合同管理制度.docx",
"ingest_document_updated_at": "2026-05-22T07:00:18.190399+00:00",
@@ -161,8 +161,8 @@
"updated_at": "2026-05-22T07:00:17.798679+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:03:46.921675+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.784020+00:00",
"ingest_completed_at": "2026-05-22T16:03:46.921675+00:00",
"ingest_document_name": "远光软件公司财务管理制度总则.docx",
"ingest_document_updated_at": "2026-05-22T07:00:17.798679+00:00",
@@ -182,8 +182,8 @@
"updated_at": "2026-05-22T07:00:18.531598+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:04:58.719410+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.799323+00:00",
"ingest_completed_at": "2026-05-22T16:04:58.719410+00:00",
"ingest_document_name": "远光软件公司资产管理制度.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:18.531598+00:00",
@@ -203,8 +203,8 @@
"updated_at": "2026-05-22T07:00:18.221073+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:06:08.172318+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.814611+00:00",
"ingest_completed_at": "2026-05-22T16:06:08.172318+00:00",
"ingest_document_name": "远光软件公司采购管理办法.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:18.221073+00:00",
@@ -224,8 +224,8 @@
"updated_at": "2026-05-22T07:00:19.734422+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:06:48.466110+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.830249+00:00",
"ingest_completed_at": "2026-05-22T16:06:48.466110+00:00",
"ingest_document_name": "远光软件公司差旅费管理办法.docx",
"ingest_document_updated_at": "2026-05-22T07:00:19.734422+00:00",
@@ -245,8 +245,8 @@
"updated_at": "2026-05-22T07:00:20.095824+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:07:23.262328+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.847094+00:00",
"ingest_completed_at": "2026-05-22T16:07:23.262328+00:00",
"ingest_document_name": "远光软件出差审批流程说明.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:20.095824+00:00",
@@ -266,8 +266,8 @@
"updated_at": "2026-05-22T07:00:20.128471+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:08:02.190081+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.865452+00:00",
"ingest_completed_at": "2026-05-22T16:08:02.190081+00:00",
"ingest_document_name": "远光软件国际出差管理规定.docx",
"ingest_document_updated_at": "2026-05-22T07:00:20.128471+00:00",
@@ -287,8 +287,8 @@
"updated_at": "2026-05-22T07:00:19.759954+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:09:23.091744+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.888420+00:00",
"ingest_completed_at": "2026-05-22T16:09:23.091744+00:00",
"ingest_document_name": "远光软件差旅费标准速查表.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:19.759954+00:00",
@@ -308,8 +308,8 @@
"updated_at": "2026-05-22T07:00:18.922298+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:11:04.764727+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.905615+00:00",
"ingest_completed_at": "2026-05-22T16:11:04.764727+00:00",
"ingest_document_name": "远光软件公司发票审核标准.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:18.922298+00:00",
@@ -329,8 +329,8 @@
"updated_at": "2026-05-22T07:00:18.560177+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:11:54.017817+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.919568+00:00",
"ingest_completed_at": "2026-05-22T16:11:54.017817+00:00",
"ingest_document_name": "远光软件公司发票管理规范.docx",
"ingest_document_updated_at": "2026-05-22T07:00:18.560177+00:00",
@@ -350,8 +350,8 @@
"updated_at": "2026-05-22T07:00:18.888128+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:12:23.821434+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.934348+00:00",
"ingest_completed_at": "2026-05-22T16:12:23.821434+00:00",
"ingest_document_name": "远光软件公司增值税发票操作指南.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:18.888128+00:00",
@@ -371,8 +371,8 @@
"updated_at": "2026-05-22T07:00:18.953110+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:13:15.450300+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.949214+00:00",
"ingest_completed_at": "2026-05-22T16:13:15.450300+00:00",
"ingest_document_name": "远光软件公司电子发票管理办法.docx",
"ingest_document_updated_at": "2026-05-22T07:00:18.953110+00:00",
@@ -392,8 +392,8 @@
"updated_at": "2026-05-22T07:00:21.585718+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:13:44.636629+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.963406+00:00",
"ingest_completed_at": "2026-05-22T16:13:44.636629+00:00",
"ingest_document_name": "远光软件企业所得税汇算清缴操作手册.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:21.585718+00:00",
@@ -413,8 +413,8 @@
"updated_at": "2026-05-22T07:00:20.881351+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:14:50.092490+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.976986+00:00",
"ingest_completed_at": "2026-05-22T16:14:50.092490+00:00",
"ingest_document_name": "远光软件公司税务管理制度.docx",
"ingest_document_updated_at": "2026-05-22T07:00:20.881351+00:00",
@@ -434,8 +434,8 @@
"updated_at": "2026-05-22T07:00:21.606227+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:15:56.676286+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:00.995972+00:00",
"ingest_completed_at": "2026-05-22T16:15:56.676286+00:00",
"ingest_document_name": "远光软件研发费用加计扣除管理办法.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:21.606227+00:00",
@@ -455,8 +455,8 @@
"updated_at": "2026-05-22T07:00:21.202633+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:16:06.540773+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:01.010947+00:00",
"ingest_completed_at": "2026-05-22T16:16:06.540773+00:00",
"ingest_document_name": "远光软件软件产品增值税即征即退操作指南.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:21.202633+00:00",
@@ -476,8 +476,8 @@
"updated_at": "2026-05-22T07:00:22.379307+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:23:24.252614+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:01.025910+00:00",
"ingest_completed_at": "2026-05-22T16:23:24.252614+00:00",
"ingest_document_name": "远光软件公司预算管理制度.docx",
"ingest_document_updated_at": "2026-05-22T07:00:22.379307+00:00",
@@ -497,8 +497,8 @@
"updated_at": "2026-05-22T07:00:22.760169+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:23:29.997956+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:01.044022+00:00",
"ingest_completed_at": "2026-05-22T16:23:29.997956+00:00",
"ingest_document_name": "远光软件年度预算编制指南.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:22.760169+00:00",
@@ -518,8 +518,8 @@
"updated_at": "2026-05-22T07:00:22.848272+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:24:37.382612+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:17.402454+00:00",
"ingest_completed_at": "2026-05-22T16:24:37.382612+00:00",
"ingest_document_name": "远光软件预算执行分析报告模板.docx",
"ingest_document_updated_at": "2026-05-22T07:00:22.848272+00:00",
@@ -539,8 +539,8 @@
"updated_at": "2026-05-22T07:00:22.803708+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:24:45.161319+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:17.417444+00:00",
"ingest_completed_at": "2026-05-22T16:24:45.161319+00:00",
"ingest_document_name": "远光软件预算编制模板.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:22.803708+00:00",
@@ -560,8 +560,8 @@
"updated_at": "2026-05-22T07:00:21.971983+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:25:33.968414+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:17.433923+00:00",
"ingest_completed_at": "2026-05-22T16:25:33.968414+00:00",
"ingest_document_name": "远光软件财务共享服务SLA标准.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:21.971983+00:00",
@@ -581,8 +581,8 @@
"updated_at": "2026-05-22T07:00:21.634300+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:26:05.301987+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:17.450037+00:00",
"ingest_completed_at": "2026-05-22T16:26:05.301987+00:00",
"ingest_document_name": "远光软件财务共享服务中心运营管理办法.docx",
"ingest_document_updated_at": "2026-05-22T07:00:21.634300+00:00",
@@ -602,8 +602,8 @@
"updated_at": "2026-05-22T07:00:21.945868+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:26:54.048075+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:17.471635+00:00",
"ingest_completed_at": "2026-05-22T16:26:54.048075+00:00",
"ingest_document_name": "远光软件财务共享服务操作手册.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:21.945868+00:00",
@@ -623,8 +623,8 @@
"updated_at": "2026-05-22T07:00:19.662743+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:27:31.775974+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:17.489793+00:00",
"ingest_completed_at": "2026-05-22T16:27:31.775974+00:00",
"ingest_document_name": "远光软件报销流程培训手册.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:19.662743+00:00",
@@ -644,8 +644,8 @@
"updated_at": "2026-05-22T07:00:19.323921+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:27:44.244066+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:17.505506+00:00",
"ingest_completed_at": "2026-05-22T16:27:44.244066+00:00",
"ingest_document_name": "远光软件新员工财务培训课件.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:19.323921+00:00",
@@ -665,8 +665,8 @@
"updated_at": "2026-05-22T07:00:18.988700+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:28:24.573683+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:17.520887+00:00",
"ingest_completed_at": "2026-05-22T16:28:24.573683+00:00",
"ingest_document_name": "远光软件财务制度培训手册.docx",
"ingest_document_updated_at": "2026-05-22T07:00:18.988700+00:00",
@@ -686,8 +686,8 @@
"updated_at": "2026-05-22T07:00:19.686485+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:29:03.349502+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:17.542919+00:00",
"ingest_completed_at": "2026-05-22T16:29:03.349502+00:00",
"ingest_document_name": "远光软件财务培训课程安排.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:19.686485+00:00",
@@ -707,8 +707,8 @@
"updated_at": "2026-05-22T07:00:20.476077+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:29:29.050791+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:17.558881+00:00",
"ingest_completed_at": "2026-05-22T16:29:29.050791+00:00",
"ingest_document_name": "远光软件报销问题处理指引.xlsx",
"ingest_document_updated_at": "2026-05-22T07:00:20.476077+00:00",
@@ -728,8 +728,8 @@
"updated_at": "2026-05-22T07:00:20.453567+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:35:03.548506+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:17.575410+00:00",
"ingest_completed_at": "2026-05-22T16:35:03.548506+00:00",
"ingest_document_name": "远光软件财务制度问答汇总.pdf",
"ingest_document_updated_at": "2026-05-22T07:00:20.453567+00:00",
@@ -749,8 +749,8 @@
"updated_at": "2026-05-22T07:00:20.158497+00:00",
"uploaded_by": "系统导入",
"version_number": 1,
"ingest_status": 3,
"ingest_status_updated_at": "2026-05-22T16:35:27.056080+00:00",
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-26T02:39:17.593165+00:00",
"ingest_completed_at": "2026-05-22T16:35:27.056080+00:00",
"ingest_document_name": "远光软件财务报销常见问题解答.docx",
"ingest_document_updated_at": "2026-05-22T07:00:20.158497+00:00",

View File

@@ -10,7 +10,6 @@ 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.schemas.ontology import OntologyParseRequest
from app.services.ontology import LlmOntologyParseResult, SemanticOntologyService
@@ -27,6 +26,8 @@ def build_session_factory() -> sessionmaker[Session]:
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
session_factory = build_session_factory()
from app.main import create_app
app = create_app()
def override_db() -> Generator[Session, None, None]:
@@ -260,6 +261,106 @@ def test_semantic_ontology_service_extracts_entities_time_and_constraints() -> N
assert result.time_range.end_date == "2026-04-30"
def test_semantic_ontology_service_extracts_budget_query_fields() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="查询 CC-4100 2026年度差旅费可用预算和预算占用",
user_id="pytest",
)
)
entity_map = {item.type: item.normalized_value for item in result.entities}
metric_names = {item.name for item in result.metrics}
assert result.scenario == "budget"
assert result.intent == "query"
assert entity_map["cost_center"] == "CC-4100"
assert entity_map["budget_period"] == "2026年度"
assert entity_map["budget_subject"] == "travel"
assert entity_map["expense_type"] == "travel"
assert {"available_amount", "reserved_amount"}.issubset(metric_names)
def test_semantic_ontology_service_extracts_budget_edit_fields() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="编辑预算2026年度 CC-4100 差旅费预算金额60万元预警线80%,控制动作提醒",
user_id="pytest",
context_json={
"document_type": "budget_plan",
"entry_source": "budget_center",
"conversation_scenario": "budget",
},
)
)
entity_map = {item.type: item.normalized_value for item in result.entities}
assert result.scenario == "budget"
assert result.intent == "draft"
assert result.permission.level == "draft_write"
assert entity_map["budget_period"] == "2026年度"
assert entity_map["budget_subject"] == "travel"
assert entity_map["expense_type"] == "travel"
assert entity_map["budget_amount"] == "600000"
assert entity_map["warning_threshold"] == "80%"
assert entity_map["control_action"] == "remind"
def test_semantic_ontology_service_extracts_quarter_budget_period() -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(
query="查询 CC-4100 2026年Q3 住宿费预算金额",
user_id="pytest",
)
)
entity_map = {item.type: item.normalized_value for item in result.entities}
assert result.scenario == "budget"
assert entity_map["budget_period"] == "2026年Q3"
assert entity_map["budget_subject"] == "hotel"
assert entity_map["expense_type"] == "hotel"
@pytest.mark.parametrize(
"query,expected_code,expected_label",
[
("查询2026年度市场推广费预算余额", "marketing", "市场推广费"),
("查看2026年度软件服务费已占用金额", "software", "软件服务费"),
("统计2026年度业务招待费预算金额", "meal", "业务招待费"),
],
)
def test_semantic_ontology_service_links_budget_subject_to_expense_type(
query: str,
expected_code: str,
expected_label: str,
) -> None:
session_factory = build_session_factory()
with session_factory() as db:
result = SemanticOntologyService(db).parse(
OntologyParseRequest(query=query, user_id="pytest")
)
assert result.scenario == "budget"
assert any(
item.type == "budget_subject" and item.normalized_value == expected_code
for item in result.entities
)
assert any(
item.type == "expense_type"
and item.normalized_value == expected_code
and item.value == expected_label
for item in result.entities
)
def test_semantic_ontology_service_extracts_new_document_numbers() -> None:
session_factory = build_session_factory()
with session_factory() as db:

View File

@@ -3,7 +3,9 @@ from __future__ import annotations
import json
from datetime import UTC, date, datetime
from decimal import Decimal
from types import SimpleNamespace
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
@@ -32,6 +34,7 @@ from app.services.risk_rule_flow_diagram import (
from app.services.risk_rule_generation import RiskRuleGenerationService
from app.services.risk_rule_generation_jobs import RiskRuleGenerationJobService
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
from app.services.risk_rule_scoring import calculate_risk_rule_score, risk_level_from_score
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
@@ -113,6 +116,8 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
assert asset.config_json["evaluator"] == "template_rule"
assert asset.config_json["expense_category"] == "travel"
assert asset.config_json["risk_category"] == "差旅费"
assert asset.config_json["business_stage"] == "reimbursement"
assert asset.config_json["business_stage_label"] == "费用报销"
assert asset.scenario_json == ["差旅费"]
assert asset.current_version == "v0.1.0"
@@ -122,9 +127,15 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
assert payload["rule_code"] == asset.code
assert payload["name"] == "差旅住宿城市一致性校验"
assert payload["applies_to"]["expense_categories"] == ["travel"]
assert payload["applies_to"]["business_stages"] == ["reimbursement"]
assert payload["risk_category"] == "差旅费"
assert payload["metadata"]["expense_category"] == "travel"
assert payload["metadata"]["business_stage"] == "reimbursement"
assert payload["metadata"]["business_stage_label"] == "费用报销"
assert payload["metadata"]["rule_title"] == "差旅住宿城市一致性校验"
assert isinstance(payload["metadata"]["risk_score"], int)
assert payload["metadata"]["risk_level"] == payload["outcomes"]["fail"]["severity"]
assert asset.config_json["risk_score"] == payload["metadata"]["risk_score"]
assert payload["outcomes"]["fail"]["severity"] == "high"
assert payload["template_key"] == "field_compare_v1"
assert payload["metadata"]["natural_language"].startswith("住宿城市")
@@ -147,7 +158,141 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
assert "feDropShadow" not in payload["flow_diagram_svg"]
def test_set_risk_rule_level_updates_manifest_config_and_flow_svg(tmp_path) -> None:
def test_risk_score_model_thresholds_and_critical_level() -> None:
assert risk_level_from_score(30) == "low"
assert risk_level_from_score(31) == "medium"
assert risk_level_from_score(61) == "high"
assert risk_level_from_score(81) == "critical"
result = calculate_risk_rule_score(
natural_language="同一发票号码重复报销时禁止提交并进入审计复核。",
draft={
"template_key": "composite_rule_v1",
"field_keys": ["attachment.invoice_no", "claim.amount"],
"conditions": [{"id": "duplicate_invoice", "operator": "overlap"}],
"risk_scoring_evidence": {
"impact_level": "critical",
"violation_certainty": "critical",
"evidence_strength": "high",
"exception_dependence": "medium",
"control_action": "block",
"business_sensitivity": "critical",
},
},
fields=[],
expense_category="travel",
expense_category_label="差旅费",
requires_attachment=True,
)
assert result["score"] >= 81
assert result["level"] == "critical"
assert result["level_label"] == "极高风险"
def test_generate_expense_application_risk_rule_marks_business_stage(tmp_path) -> None:
with build_session() as db:
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
service = RiskRuleGenerationService(
db,
rule_library_manager=manager,
runtime_chat_service=NullRuntimeChatService(),
)
asset_id = service.generate_rule_asset(
AgentAssetRiskRuleGenerateRequest(
business_domain=AgentAssetDomain.EXPENSE,
business_stage="expense_application",
expense_category="travel",
rule_title="差旅申请预算余额校验",
natural_language="费用申请时,若差旅申请金额超过可用预算余额,则提示风险并要求补充审批说明。",
),
actor="pytest",
)
asset = db.get(AgentAsset, asset_id)
assert asset is not None
assert asset.config_json["business_stage"] == "expense_application"
assert asset.config_json["business_stage_label"] == "费用申请"
payload = manager.read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=asset.config_json["rule_document"]["file_name"],
)
assert payload["applies_to"]["business_stages"] == ["expense_application"]
assert payload["metadata"]["business_stage_label"] == "费用申请"
assert payload["params"]["business_stage_label"] == "费用申请"
def test_risk_score_model_keeps_explicit_low_control_rules_low() -> None:
field_keys = ["attachment.invoice_no", "attachment.goods_name", "claim.reason"]
result = calculate_risk_rule_score(
natural_language=(
"差旅报销时,票据已上传但发票号码或商品服务名称缺失,"
"且报销事由、人员和部门能够说明费用归属,则标记为低风险,"
"仅提醒补齐票据要素。"
),
draft={
"template_key": "field_required_v1",
"field_keys": field_keys,
"condition_summary": "票据要素缺失但归属清晰时提醒补齐。",
},
fields=[SimpleNamespace(key=key, source=key.split(".", 1)[0]) for key in field_keys],
expense_category="travel",
expense_category_label="差旅费",
requires_attachment=True,
)
assert result["score"] <= 30
assert result["level"] == "low"
assert result["calibration"]["rules"][0]["name"] == "explicit_low_control_cap"
def test_risk_score_model_ignores_negated_hard_risk_words_for_low_rules() -> None:
result = calculate_risk_rule_score(
natural_language=(
"差旅费报销提交时,若缺少申报目的地、明细地点或明细事由,"
"但暂未发现票据城市冲突、金额异常或重复报销迹象,则标记为低风险,"
"提示经办人补齐基础差旅信息后继续提交。"
),
draft={
"template_key": "field_required_v1",
"field_keys": ["claim.location", "item.item_location", "item.item_reason"],
"condition_summary": "基础差旅字段缺失但暂无硬风险迹象时提示补齐。",
},
fields=[],
expense_category="travel",
expense_category_label="差旅费",
requires_attachment=False,
)
assert result["score"] <= 30
assert result["level"] == "low"
def test_risk_score_model_does_not_cap_hard_risk_signals() -> None:
result = calculate_risk_rule_score(
natural_language=(
"差旅报销时,交通票或住宿票据城市均无法与申报目的地一致,"
"且没有绕行、跨城办事或改签说明,则标记为高风险,要求补充说明或退回修改。"
),
draft={
"template_key": "composite_rule_v1",
"field_keys": ["claim.destination_city", "attachment.route_cities"],
"conditions": [{"id": "city_mismatch", "operator": "not_overlap"}],
},
fields=[],
expense_category="travel",
expense_category_label="差旅费",
requires_attachment=True,
)
assert result["score"] >= 61
assert result["level"] == "high"
assert not result["calibration"]["rules"]
def test_set_risk_rule_level_rejects_manual_override(tmp_path) -> None:
with build_session() as db:
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
generator = RiskRuleGenerationService(
@@ -169,31 +314,16 @@ def test_set_risk_rule_level_updates_manifest_config_and_flow_svg(tmp_path) -> N
asset_service = AgentAssetService(db)
asset_service.rule_library_manager = manager
updated = asset_service.set_risk_rule_level(
with pytest.raises(ValueError, match="评分模型"):
asset_service.set_risk_rule_level(
asset_id,
risk_level="low",
actor="pytest",
)
assert updated.config_json["severity"] == "low"
asset = db.get(AgentAsset, asset_id)
assert asset is not None
assert asset.config_json["risk_level_label"] == "低风险"
file_name = asset.config_json["rule_document"]["file_name"]
payload = manager.read_rule_library_json(
library=RISK_RULES_LIBRARY,
file_name=file_name,
)
assert payload["outcomes"]["fail"]["severity"] == "low"
assert payload["metadata"]["risk_level"] == "low"
assert payload["metadata"]["risk_level_label"] == "低风险"
assert "低风险" in payload["metadata"]["flow"]["fail"]
assert "#2563eb" in payload["flow_diagram_svg"]
assert "#dc2626" not in payload["flow_diagram_svg"]
version = asset_service.repository.get_version(asset_id, asset.working_version)
assert version is not None
assert '"severity": "low"' in version.content
assert asset.config_json["severity"] != "low"
def test_enqueue_risk_rule_generation_creates_visible_generating_asset(tmp_path) -> None:
@@ -774,7 +904,10 @@ def test_risk_rule_requires_test_report_before_review_and_publish(tmp_path) -> N
enabled=False,
actor="manager",
)
assert disabled.status == AgentAssetStatus.DISABLED.value
assert disabled.published_version == asset.working_version
assert disabled.config_json["enabled"] is False
assert disabled.config_json["last_operation"]["action"] == "offline"
rule_document = disabled.config_json["rule_document"]
manifest = manager.read_rule_library_json(
library=RISK_RULES_LIBRARY,
@@ -782,6 +915,11 @@ def test_risk_rule_requires_test_report_before_review_and_publish(tmp_path) -> N
)
assert manifest["enabled"] is False
enabled = service.set_risk_rule_enabled(asset_id, enabled=True, actor="manager")
assert enabled.status == AgentAssetStatus.ACTIVE.value
assert enabled.config_json["enabled"] is True
assert enabled.config_json["last_operation"]["action"] == "online"
attachment_required_id = generator.generate_rule_asset(
AgentAssetRiskRuleGenerateRequest(
business_domain=AgentAssetDomain.EXPENSE,

BIN
web/UI/编辑预算.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@@ -0,0 +1,349 @@
.budget-dialog-backdrop,
.budget-dialog-backdrop * {
box-sizing: border-box;
}
.budget-dialog-backdrop {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
background: rgba(15, 23, 42, .52);
backdrop-filter: blur(1px);
}
.budget-edit-dialog {
width: min(1024px, calc(100vw - 48px));
max-height: calc(100vh - 56px);
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
border-radius: 8px;
background: #fff;
box-shadow: 0 24px 72px rgba(15, 23, 42, .28);
overflow: hidden;
}
.budget-edit-head {
min-height: 56px;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #edf1f6;
}
.budget-edit-head strong {
color: #111827;
font-size: 18px;
font-weight: 800;
}
.budget-dialog-close {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
font-size: 20px;
cursor: pointer;
transition: background 160ms ease, color 160ms ease;
}
.budget-dialog-close:hover {
background: #f1f5f9;
color: #0f172a;
}
.budget-edit-body {
min-height: 0;
padding: 18px 24px 16px;
overflow: auto;
}
.budget-edit-section + .budget-edit-section {
margin-top: 18px;
}
.budget-edit-section h3 {
margin: 0 0 12px;
color: #111827;
font-size: 15px;
line-height: 1.35;
font-weight: 800;
}
.budget-edit-form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px 28px;
}
.budget-edit-form-grid label,
.budget-edit-textarea {
min-width: 0;
display: grid;
gap: 7px;
color: #334155;
font-size: 13px;
font-weight: 750;
}
.budget-edit-form-grid label.required > span::after {
content: "*";
margin-left: 3px;
color: #ef4444;
}
.budget-edit-form-grid select,
.budget-edit-textarea textarea,
.budget-edit-table input,
.budget-edit-table select {
width: 100%;
border: 1px solid #dbe4ee;
border-radius: 6px;
background: #fff;
color: #111827;
font-size: 14px;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.budget-edit-form-grid select {
height: 38px;
padding: 0 34px 0 12px;
}
.budget-edit-form-grid select:focus,
.budget-edit-textarea textarea:focus,
.budget-edit-table input:focus,
.budget-edit-table select:focus {
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, .12);
}
.budget-edit-textarea {
position: relative;
margin-top: 14px;
}
.budget-edit-textarea textarea {
min-height: 86px;
resize: none;
padding: 12px 14px 24px;
line-height: 1.6;
}
.budget-edit-textarea em {
position: absolute;
right: 12px;
bottom: 9px;
color: #94a3b8;
font-size: 12px;
font-style: normal;
font-weight: 500;
}
.budget-edit-table-wrap {
border: 1px solid #edf1f6;
border-radius: 8px;
overflow-x: auto;
}
.budget-edit-table {
width: 100%;
min-width: 760px;
border-collapse: collapse;
table-layout: fixed;
}
.budget-edit-table th,
.budget-edit-table td {
height: 48px;
padding: 8px 10px;
border-right: 1px solid #edf1f6;
border-bottom: 1px solid #edf1f6;
text-align: center;
vertical-align: middle;
}
.budget-edit-table th:last-child,
.budget-edit-table td:last-child {
border-right: 0;
}
.budget-edit-table tbody tr:last-child td {
border-bottom: 0;
}
.budget-edit-table th {
background: #fbfcfe;
color: #334155;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
}
.budget-edit-table th i {
color: #ef4444;
font-style: normal;
}
.budget-edit-table th:nth-child(1) { width: 180px; }
.budget-edit-table th:nth-child(2) { width: 168px; }
.budget-edit-table th:nth-child(3) { width: 120px; }
.budget-edit-table th:nth-child(4) { width: 120px; }
.budget-edit-table th:nth-child(6) { width: 68px; }
.budget-edit-table input,
.budget-edit-table select {
height: 34px;
padding: 0 10px;
text-align: center;
}
.budget-edit-table td:nth-child(5) input {
text-align: left;
}
.budget-row-delete {
width: 32px;
height: 32px;
display: inline-grid;
place-items: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
font-size: 18px;
cursor: pointer;
}
.budget-row-delete:hover {
background: #fef2f2;
color: #dc2626;
}
.budget-add-row-btn {
height: 28px;
margin-top: 8px;
padding: 0 10px;
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid rgba(16, 185, 129, .42);
border-radius: 6px;
background: #fff;
color: #059669;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.budget-edit-total {
height: 42px;
margin-top: 8px;
padding: 0 14px;
display: grid;
grid-template-columns: 120px 1fr;
align-items: center;
border: 1px solid #edf1f6;
border-radius: 8px;
background: #fbfcfe;
}
.budget-edit-total span,
.budget-edit-total strong {
color: #111827;
font-size: 14px;
font-weight: 800;
}
.budget-edit-foot {
padding: 18px 24px 20px;
display: flex;
align-items: center;
justify-content: center;
gap: 18px;
border-top: 1px solid #edf1f6;
background: #fff;
}
.budget-edit-foot button {
height: 40px;
min-width: 156px;
border-radius: 7px;
font-size: 14px;
font-weight: 800;
cursor: pointer;
}
.budget-edit-cancel {
border: 1px solid #dbe4ee;
background: #fff;
color: #334155;
}
.budget-edit-draft {
border: 1px solid #10b981;
background: #fff;
color: #059669;
}
.budget-edit-publish {
border: 0;
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
box-shadow: 0 10px 24px rgba(5, 150, 105, .20);
}
.budget-dialog-fade-enter-active,
.budget-dialog-fade-leave-active {
transition: opacity 180ms ease;
}
.budget-dialog-fade-enter-active .budget-edit-dialog,
.budget-dialog-fade-leave-active .budget-edit-dialog {
transition: transform 200ms ease, opacity 180ms ease;
}
.budget-dialog-fade-enter-from,
.budget-dialog-fade-leave-to {
opacity: 0;
}
.budget-dialog-fade-enter-from .budget-edit-dialog,
.budget-dialog-fade-leave-to .budget-edit-dialog {
opacity: 0;
transform: translateY(12px);
}
@media (max-width: 860px) {
.budget-dialog-backdrop {
align-items: flex-start;
padding: 18px;
}
.budget-edit-dialog {
width: 100%;
max-height: calc(100vh - 36px);
}
.budget-edit-form-grid {
grid-template-columns: 1fr;
}
.budget-edit-foot {
flex-direction: column;
gap: 10px;
}
.budget-edit-foot button {
width: 100%;
}
}

View File

@@ -5,138 +5,245 @@
color: #1f2937;
}
.budget-local-head {
min-height: 34px;
display: flex;
align-items: center;
}
.budget-local-head h2 {
margin: 0;
color: #111827;
font-size: 24px;
line-height: 1.2;
font-weight: 800;
}
.budget-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border: 1px solid #e5eaf1;
border-radius: 8px;
background: #fff;
overflow: hidden;
gap: 12px;
}
.budget-summary-card {
min-height: 118px;
padding: 22px 28px;
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
align-items: center;
gap: 18px;
border-right: 1px solid #edf1f6;
--accent: #10b981;
position: relative;
min-height: 112px;
padding: 12px 14px 10px;
display: flex;
flex-direction: column;
border: 1px solid #dbe4ee;
border-left: 3px solid var(--accent);
border-radius: 8px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
animation: dashboardItemIn 520ms var(--ease) both;
animation-delay: var(--delay, 0ms);
transition: box-shadow 200ms ease, transform 200ms ease;
}
.budget-summary-card:last-child {
border-right: 0;
.budget-summary-card:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, .06);
transform: translateY(-1px);
}
.budget-summary-card.green {
--accent: #10b981;
}
.budget-summary-card.blue {
--accent: #3b82f6;
}
.budget-summary-card.orange {
--accent: #f59e0b;
}
.budget-summary-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
min-width: 0;
}
.summary-icon {
width: 54px;
height: 54px;
border-radius: 50%;
width: 26px;
height: 26px;
border-radius: 7px;
display: grid;
place-items: center;
font-size: 30px;
}
.summary-icon.green {
background: #e8f7ef;
color: #07965f;
}
.summary-icon.blue {
background: #edf4ff;
color: #2f7fd7;
}
.summary-icon.orange {
background: #fff4e5;
color: #df9300;
}
.budget-summary-card span:not(.summary-icon) {
display: block;
color: #1f2937;
background: color-mix(in srgb, var(--accent) 10%, white);
color: var(--accent);
font-size: 14px;
font-weight: 700;
flex: 0 0 auto;
animation: iconPop 560ms var(--ease) both;
animation-delay: calc(var(--delay, 0ms) + 100ms);
}
.budget-summary-card strong {
.budget-summary-card .summary-label {
display: block;
margin-top: 8px;
color: #111827;
font-size: 24px;
line-height: 1;
min-width: 0;
color: #64748b;
font-size: 11px;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.summary-value {
display: block;
min-height: 22px;
margin-bottom: 6px;
color: #0f172a;
font-size: clamp(16px, 1.2vw, 20px);
line-height: 1;
font-weight: 800;
font-variant-numeric: tabular-nums;
white-space: nowrap;
letter-spacing: 0;
}
.summary-comparison-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding-top: 6px;
border-top: 1px solid #f1f5f9;
min-width: 0;
flex-wrap: wrap;
}
.comparison-pill {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2px;
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
line-height: 1.45;
font-weight: 700;
white-space: nowrap;
}
.budget-summary-card em {
display: block;
margin-top: 10px;
color: #8a94a6;
font-size: 13px;
.comparison-pill b {
color: inherit;
font-size: 11px;
font-weight: 600;
}
.comparison-pill em {
font-style: normal;
font-variant-numeric: tabular-nums;
}
.comparison-pill i {
font-size: 11px;
}
.comparison-pill.up {
background: rgba(22, 163, 74, .08);
color: #16a34a;
}
.comparison-pill.down {
background: rgba(239, 68, 68, .08);
color: #dc2626;
}
.budget-filter-bar {
min-height: 62px;
border: 1px solid #e5eaf1;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
padding: 12px 18px;
padding: 14px 16px;
display: flex;
align-items: center;
gap: 22px;
justify-content: space-between;
gap: 16px;
}
.budget-filter-set,
.budget-action-set {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
min-width: 0;
}
.budget-filter-bar label {
display: inline-flex;
align-items: center;
gap: 10px;
color: #1f2937;
font-size: 14px;
font-weight: 700;
gap: 8px;
color: #64748b;
font-size: 13px;
font-weight: 750;
white-space: nowrap;
}
.budget-filter-bar select,
.budget-table-foot select {
height: 34px;
min-width: 150px;
border: 1px solid #dbe2ec;
border-radius: 5px;
.budget-filter-bar select {
min-height: 38px;
min-width: 128px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #1f2937;
padding: 0 34px 0 12px;
color: #334155;
padding: 0 34px 0 14px;
font-size: 14px;
font-weight: 750;
transition: border-color 160ms ease, box-shadow 160ms ease, color 160ms ease;
}
.budget-filter-bar select:hover {
border-color: rgba(16, 185, 129, .32);
color: #0f9f78;
}
.budget-filter-bar select:focus {
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, .14);
outline: none;
}
.budget-primary-btn {
margin-left: auto;
height: 36px;
min-height: 40px;
border: 0;
border-radius: 5px;
background: #0aa66f;
border-radius: 10px;
background: linear-gradient(135deg, #10b981, #059669);
color: #fff;
padding: 0 18px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
cursor: pointer;
box-shadow: 0 10px 24px rgba(5, 150, 105, .2);
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
}
.budget-primary-btn:hover {
transform: translateY(-1px);
box-shadow: 0 14px 28px rgba(5, 150, 105, .24);
filter: saturate(1.02);
}
.budget-ghost-btn {
min-height: 38px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #334155;
padding: 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
cursor: pointer;
transition: border-color 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.budget-ghost-btn:hover {
border-color: rgba(16, 185, 129, .32);
color: #0f9f78;
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
}
.budget-work-grid {
@@ -240,7 +347,7 @@
border-right: 1px solid #edf1f6;
color: #273142;
font-size: 14px;
text-align: left;
text-align: center;
white-space: nowrap;
}
@@ -259,6 +366,7 @@
width: 96px;
display: grid;
gap: 6px;
margin: 0 auto;
}
.budget-rate span {
@@ -304,6 +412,7 @@
.budget-row-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
}
@@ -324,24 +433,84 @@
gap: 10px;
}
.budget-table-foot button {
.budget-page-summary {
color: #64748b;
font-size: 14px;
font-weight: 650;
}
.budget-pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.budget-pager button {
width: 32px;
height: 32px;
border: 1px solid #dbe2ec;
border-radius: 5px;
background: #fff;
color: #64748b;
}
.budget-table-foot button.active {
border-color: #10a873;
color: #10a873;
font-weight: 800;
}
.budget-table-foot span {
color: #4b5563;
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.budget-pager button:hover:not(.active):not(:disabled) {
background: #fff;
color: #059669;
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
}
.budget-pager button.active {
background: #059669;
color: #fff;
box-shadow: 0 8px 16px rgba(5, 150, 105, .20);
}
.budget-pager button:disabled {
color: #94a3b8;
cursor: not-allowed;
}
.budget-page-size {
min-height: 38px;
min-width: 112px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
padding: 0 14px;
border: 1px solid #d7e0ea;
border-radius: 10px;
background: #fff;
color: #334155;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
cursor: pointer;
transition: border-color 160ms ease, color 160ms ease;
}
.budget-page-size:hover {
border-color: rgba(16, 185, 129, .32);
color: #0f9f78;
}
.budget-page-size select {
appearance: none;
border: 0;
background: transparent;
color: inherit;
font: inherit;
outline: none;
cursor: pointer;
}
.budget-bottom-grid {
@@ -448,6 +617,32 @@
text-align: right;
}
@keyframes dashboardItemIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes iconPop {
0% {
opacity: 0;
transform: scale(.82);
}
70% {
opacity: 1;
transform: scale(1.04);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 1280px) {
.budget-summary-grid,
.budget-bottom-grid {
@@ -480,7 +675,24 @@
.budget-filter-bar label,
.budget-filter-bar select,
.budget-primary-btn {
.budget-filter-set,
.budget-action-set,
.budget-primary-btn,
.budget-ghost-btn {
width: 100%;
}
.budget-filter-bar label {
justify-content: space-between;
}
.budget-table-foot {
justify-content: flex-start;
flex-wrap: wrap;
}
.budget-pager,
.budget-page-size {
width: 100%;
}

View File

@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
import { icons } from '../data/icons.js'
export const appViews = ['overview', 'workbench', 'documents', 'budget', 'policies', 'audit', 'employees', 'logs', 'settings']
export const appViews = ['overview', 'workbench', 'documents', 'budget', 'audit', 'employees', 'policies', 'logs', 'settings']
export const navItems = [
{
@@ -38,21 +38,13 @@ export const navItems = [
title: '预算中心',
desc: '配置部门、项目及费用类型预算,跟踪申请占用、报销核销与超预算预警。'
},
{
id: 'policies',
label: '制度知识',
navHint: '查看制度与知识库',
icon: icons.file,
title: '制度与知识库',
desc: '统一管理制度文档、检索入口与知识资产。'
},
{
id: 'audit',
label: '任务规则中心',
navHint: '查看和管理任务规则配置',
navHint: '查看和管理规则配置',
icon: icons.skill,
title: '任务规则中心',
desc: '集中管理规则文件、外部 MCP 服务与定时任务调度。'
desc: '集中管理财务规则、风险规则、技能与外部 MCP 服务。'
},
{
id: 'employees',
@@ -62,6 +54,14 @@ export const navItems = [
title: '员工与组织管理',
desc: '维护员工账号、组织结构与角色权限。'
},
{
id: 'policies',
label: '制度知识',
navHint: '查看制度与知识库',
icon: icons.file,
title: '制度与知识库',
desc: '统一管理制度文档、检索入口与知识资产。'
},
{
id: 'logs',
label: '日志管理',

View File

@@ -17,9 +17,11 @@ const EXPENSE_TYPE_LABELS = {
ride_ticket: '乘车',
travel_allowance: '出差补贴',
entertainment: '业务招待费',
marketing: '市场推广费',
office: '办公用品费',
meeting: '会务费',
training: '培训费',
software: '软件服务费',
hotel: '住宿费',
transport: '交通费',
meal: '业务招待费',

View File

@@ -5,7 +5,7 @@ export const icons = {
workspace: iconPath('<path d="M4 20h16"/><path d="M6 20V8a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v12"/><path d="M9 10h6"/><path d="M9 14h6"/><path d="M12 3v3"/>'),
list: iconPath('<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>'),
approval: iconPath('<path d="M9 11l2 2 4-5"/><path d="M20 12v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h8"/><path d="M17 3h4v4"/>'),
budget: iconPath('<path d="M4 19V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v14"/><path d="M4 19h16"/><path d="M8 15v-4"/><path d="M12 15V8"/><path d="M16 15v-6"/>'),
budget: iconPath('<path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>'),
archive: iconPath('<path d="M3 7h18v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7l2-3h14l2 3"/><path d="M10 12h4"/>'),
file: iconPath('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/>'),
skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'),

View File

@@ -37,6 +37,10 @@ export function isManagerUser(user) {
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
}
export function isPlatformAdminUser(user) {
return Boolean(user?.isAdmin)
}
export function isFinanceUser(user) {
return normalizedRoleCodes(user).includes('finance')
}

View File

@@ -0,0 +1,199 @@
export const BUDGET_ONTOLOGY_FIELDS = [
{
key: 'budget_period',
label: '预算周期',
scope: 'budget_header',
required: true,
aliases: ['预算周期', '预算期间', '年度', '季度', '月份']
},
{
key: 'department',
label: '所属部门',
scope: 'budget_header',
required: true,
aliases: ['所属部门', '预算部门', '部门']
},
{
key: 'cost_center',
label: '成本中心',
scope: 'budget_header',
required: true,
aliases: ['成本中心', '成本中心编码']
},
{
key: 'budget_owner',
label: '预算负责人',
scope: 'budget_header',
required: true,
aliases: ['预算负责人', '负责人', '编制人']
},
{
key: 'budget_version',
label: '预算版本',
scope: 'budget_header',
required: true,
aliases: ['预算版本', '版本']
},
{
key: 'budget_status',
label: '预算状态',
scope: 'budget_header',
required: true,
aliases: ['预算状态', '状态']
},
{
key: 'budget_description',
label: '预算说明',
scope: 'budget_header',
required: false,
aliases: ['预算说明', '编制说明', '说明']
},
{
key: 'budget_subject',
label: '预算科目',
scope: 'budget_detail',
required: true,
aliases: ['预算科目', '费用类型', '费用科目']
},
{
key: 'budget_amount',
label: '预算金额',
scope: 'budget_detail',
required: true,
aliases: ['预算金额', '预算额度', '预算总额']
},
{
key: 'reserved_amount',
label: '已占用',
scope: 'budget_execution',
required: false,
aliases: ['已占用', '已预占', '占用金额']
},
{
key: 'consumed_amount',
label: '已发生',
scope: 'budget_execution',
required: false,
aliases: ['已发生', '已核销', '已消耗', '已使用']
},
{
key: 'available_amount',
label: '剩余可用',
scope: 'budget_execution',
required: false,
aliases: ['剩余可用', '可用余额', '剩余预算', '可用预算']
},
{
key: 'warning_threshold',
label: '预警线',
scope: 'budget_control',
required: true,
aliases: ['预警线', '预警阈值', '预算预警']
},
{
key: 'control_action',
label: '控制动作',
scope: 'budget_control',
required: true,
aliases: ['控制动作', '管控动作', '超预算控制']
},
{
key: 'budget_remark',
label: '备注',
scope: 'budget_detail',
required: false,
aliases: ['备注', '说明']
}
]
export const BUDGET_FIELD_KEYS = Object.freeze(
BUDGET_ONTOLOGY_FIELDS.reduce((result, field) => {
result[field.key] = field.key
return result
}, {})
)
export const BUDGET_STATUS_OPTIONS = ['编制中', '已发布', '已冻结']
export const BUDGET_WARNING_OPTIONS = ['60%', '70%', '80%', '90%']
export const BUDGET_CONTROL_ACTION_OPTIONS = ['正常', '提醒', '管控']
export const BUDGET_YEAR_OPTIONS = ['2026', '2027', '2028']
export const BUDGET_QUARTER_OPTIONS = ['Q1', 'Q2', 'Q3', 'Q4']
export const BUDGET_EXPENSE_TYPE_OPTIONS = Object.freeze([
{ value: 'travel', label: '差旅费' },
{ value: 'hotel', label: '住宿费' },
{ value: 'transport', label: '交通费' },
{ value: 'meal', label: '业务招待费' },
{ value: 'meeting', label: '会务费' },
{ value: 'marketing', label: '市场推广费' },
{ value: 'office', label: '办公用品费' },
{ value: 'training', label: '培训费' },
{ value: 'software', label: '软件服务费' },
{ value: 'communication', label: '通讯费' },
{ value: 'welfare', label: '福利费' }
])
const BUDGET_EXPENSE_TYPE_BY_CODE = Object.freeze(
BUDGET_EXPENSE_TYPE_OPTIONS.reduce((result, item) => {
result[item.value] = item
return result
}, {})
)
export function resolveBudgetExpenseTypeLabel(code, fallback = '') {
return BUDGET_EXPENSE_TYPE_BY_CODE[String(code || '').trim()]?.label || fallback
}
export function formatBudgetPeriod(year, quarter) {
const normalizedYear = String(year || '').replace(/[^\d]/g, '') || '2026'
const normalizedQuarter = BUDGET_QUARTER_OPTIONS.includes(String(quarter || '').trim())
? String(quarter || '').trim()
: BUDGET_QUARTER_OPTIONS[0]
return `${normalizedYear}${normalizedQuarter}`
}
export function buildBudgetOntologyContext({ form = {}, rows = [], departments = [] } = {}) {
const department = departments.find((item) => item.code === form.departmentCode) || {}
const budgetYear =
String(form.budgetYear || '').replace(/[^\d]/g, '') ||
String(form.budgetPeriod || '').replace(/[^\d]/g, '').slice(0, 4) ||
'2026'
const budgetQuarter = BUDGET_QUARTER_OPTIONS.includes(String(form.budgetQuarter || '').trim())
? String(form.budgetQuarter || '').trim()
: BUDGET_QUARTER_OPTIONS[0]
const budgetPeriod = form.budgetYear || form.budgetQuarter
? formatBudgetPeriod(budgetYear, budgetQuarter)
: form.budgetPeriod || formatBudgetPeriod(budgetYear, budgetQuarter)
return {
document_type: 'budget_plan',
entry_source: 'budget_center',
conversation_scenario: 'budget',
budget_fields: BUDGET_ONTOLOGY_FIELDS,
budget_header: {
budget_period: budgetPeriod,
budget_year: budgetYear,
budget_quarter: budgetQuarter,
department: department.name || '',
department_code: form.departmentCode || '',
cost_center: form.costCenter || department.costCenter || '',
budget_owner: form.budgetOwner || '',
budget_version: form.budgetVersion || '',
budget_status: form.budgetStatus || '',
budget_description: form.budgetDescription || ''
},
budget_details: rows.map((row) => {
const code = String(row.budgetSubjectCode || '').trim()
const option = BUDGET_EXPENSE_TYPE_BY_CODE[code]
const label = option?.label || row.budgetSubject || ''
return {
budget_subject: label,
budget_subject_code: option?.value || code,
expense_type: option?.value || code,
expense_type_label: label,
budget_amount: row.budgetAmount || '',
warning_threshold: row.warningThreshold || '',
control_action: row.controlAction || '',
budget_remark: row.budgetRemark || ''
}
})
}
}

View File

@@ -5,8 +5,10 @@ const EXPENSE_TYPE_LABELS = {
meal: '业务招待费',
entertainment: '业务招待费',
meeting: '会务费',
marketing: '市场推广费',
office: '办公用品费',
training: '培训费',
software: '软件服务费',
communication: '通讯费',
welfare: '福利费',
other: '其他费用'

View File

@@ -27,8 +27,10 @@ const DEFAULT_EXPENSE_TYPE_LABELS = {
meal: '业务招待费',
meeting: '会务费',
entertainment: '业务招待费',
marketing: '市场推广费',
office: '办公用品费',
training: '培训费',
software: '软件服务费',
communication: '通讯费',
welfare: '福利费',
other: '其他费用'

View File

@@ -261,7 +261,7 @@
<div class="card-head">
<div>
<h3>基本信息</h3>
<p>这条规则的业务域风险等级创建时间上线状态和审核历史</p>
<p>这条规则的业务域风险等级创建时间上线状态和最近操作</p>
</div>
</div>
<div class="json-risk-meta-grid">
@@ -273,6 +273,10 @@
<span class="json-risk-meta-label">适用场景</span>
<span class="json-risk-meta-value">{{ selectedSkill.riskCategory || selectedSkill.scope || '-' }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">业务环节</span>
<span class="json-risk-meta-value">{{ selectedSkill.businessStageLabel || '-' }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">风险等级</span>
<span class="json-risk-meta-value">
@@ -288,17 +292,17 @@
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">是否上线</span>
<span class="json-risk-meta-value">
<span class="meta-status-indicator" :class="{ 'is-active': selectedSkill.isOnlineLabel === '是' }">
<span class="meta-status-indicator" :class="{ 'is-active': selectedSkill.isOnlineValue }">
<span class="indicator-dot"></span>
{{ selectedSkill.isOnlineLabel || '' }}
{{ selectedSkill.isOnlineLabel || '待上线' }}
</span>
</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">是否启用</span>
<span class="json-risk-meta-label">规则状态</span>
<span class="json-risk-meta-value">
<span class="json-risk-meta-badge" :class="selectedSkill.isEnabledTone">
{{ selectedSkill.isEnabledLabel || '-' }}
<span class="json-risk-meta-badge" :class="selectedSkill.statusTone">
{{ selectedSkill.status || '-' }}
</span>
</span>
</div>
@@ -329,6 +333,10 @@
<span class="json-risk-meta-label">上线时间</span>
<span class="json-risk-meta-value">{{ selectedSkill.publishedAt || '-' }}</span>
</div>
<div class="json-risk-meta-item">
<span class="json-risk-meta-label">最后操作</span>
<span class="json-risk-meta-value">{{ selectedSkill.lastOperationLabel || '-' }}</span>
</div>
<div class="json-risk-meta-item full-width">
<span class="json-risk-meta-label">使用字段</span>
<span class="json-risk-meta-value">{{ selectedSkill.riskRuleFieldSummary || '-' }}</span>
@@ -623,17 +631,6 @@
</aside>
<aside v-else class="detail-side">
<article class="side-card panel">
<div class="card-head">
<div>
<h3>{{ selectedSkill.triggerTitle }}</h3>
<p>{{ selectedSkill.triggerDesc }}</p>
</div>
</div>
<div class="tag-list">
<span v-for="item in selectedSkill.triggers" :key="item">{{ item }}</span>
</div>
</article>
<article class="side-card panel">
<div class="card-head">
@@ -702,64 +699,35 @@
<button
v-if="canToggleRiskRuleEnabled"
class="minor-action enable-action"
:class="{ 'is-on': selectedSkill.isEnabledValue }"
:class="{ 'is-on': selectedSkill.isOnlineValue }"
type="button"
:disabled="detailBusy"
@click="toggleSelectedRiskRuleEnabled"
>
<i :class="selectedSkill.isEnabledValue ? 'mdi mdi-toggle-switch' : 'mdi mdi-toggle-switch-off-outline'"></i>
<span>{{ selectedSkill.isEnabledValue ? '已启用' : '已停用' }}</span>
<i :class="selectedSkill.isOnlineValue ? 'mdi mdi-toggle-switch' : 'mdi mdi-toggle-switch-off-outline'"></i>
<span>{{ selectedSkill.isOnlineValue ? '已上线' : '已下线' }}</span>
</button>
<button
v-if="canOpenRiskRuleTest"
class="minor-action"
type="button"
:disabled="!canOpenRiskRuleTest"
:disabled="detailBusy"
@click="openRiskRuleTestDialog"
>
<i class="mdi mdi-flask-outline"></i>
<span>测试规则</span>
</button>
<button
v-if="selectedSkillUsesJsonRisk && canEditSelected"
v-if="canDeleteRiskRule"
class="minor-action danger-action"
type="button"
:disabled="!canDeleteRiskRule"
:disabled="detailBusy"
@click="openDeleteRiskRuleDialog"
:title="canDeleteRiskRule ? '删除未发布规则' : '已发布过的规则不能删除'"
title="删除未发布规则"
>
<i class="mdi mdi-delete-outline"></i>
<span>删除规则</span>
</button>
<button
v-if="canEditSelected && !riskRuleInReview"
class="major-action"
type="button"
:disabled="!canOpenRiskRuleReviewSubmit"
@click="openSubmitReviewDialog"
>
<i class="mdi mdi-send-outline"></i>
<span>提交审核</span>
</button>
<button
v-if="canManageSelected && riskRuleInReview"
class="minor-action"
type="button"
:disabled="!canReturnRiskRule"
@click="openReturnRiskRuleDialog"
>
<i class="mdi mdi-keyboard-return"></i>
<span>回退规则</span>
</button>
<button
v-if="canManageSelected && riskRuleInReview"
class="major-action"
type="button"
:disabled="!canPublishRiskRule"
@click="openPublishRiskRuleDialog"
>
<i class="mdi mdi-rocket-launch-outline"></i>
<span>发布上线</span>
</button>
</template>
<button
v-else-if="selectedSkill.usesSpreadsheetRule"
@@ -1191,7 +1159,7 @@
badge="自然语言规则"
badge-tone="info"
title="新建风险规则"
description="默认创建报销类风险规则。选择费用领域后填写规则标题与自然语言描述,系统会根据评分模型自动计算风险分数和等级。"
description="默认创建费用类风险规则。选择业务环节和费用领域后填写规则标题与自然语言描述,系统会根据评分模型自动计算风险分数和等级。"
cancel-text="取消"
confirm-text="开始生成"
busy-text="生成中..."
@@ -1203,6 +1171,21 @@
@confirm="submitRiskRuleCreate"
>
<div class="risk-rule-create-form">
<label>
<span>业务环节</span>
<select
v-model="riskRuleCreateForm.business_stage"
:disabled="riskRuleCreateBusy"
>
<option
v-for="option in riskRuleBusinessStageOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
<label>
<span>费用领域</span>
<select
@@ -1218,6 +1201,16 @@
</option>
</select>
</label>
<label>
<span>是否上传附件</span>
<select
v-model="riskRuleCreateForm.requires_attachment"
:disabled="riskRuleCreateBusy"
>
<option :value="true"></option>
<option :value="false"></option>
</select>
</label>
<label class="span-2">
<span>规则标题</span>
<input
@@ -1227,17 +1220,6 @@
placeholder="例如:差旅目的地与票据城市一致性校验"
/>
</label>
<label class="risk-rule-create-toggle span-2">
<input
v-model="riskRuleCreateForm.requires_attachment"
type="checkbox"
:disabled="riskRuleCreateBusy"
/>
<span>
<strong>测试时需要上传附件</strong>
<small>适用于依赖发票行程单合同等单据 OCR 字段的规则不勾选则测试窗口不显示附件上传</small>
</span>
</label>
<label class="span-2">
<span>自然语言规则</span>
<textarea

View File

@@ -1,27 +1,48 @@
<template>
<section class="budget-center-page">
<header class="budget-local-head">
<h2>预算管理</h2>
</header>
<section class="budget-summary-grid" aria-label="预算概览">
<article v-for="metric in budgetMetrics" :key="metric.label" class="budget-summary-card">
<span class="summary-icon" :class="metric.tone">
<article
v-for="(metric, index) in budgetMetrics"
:key="metric.label"
class="budget-summary-card"
:class="metric.tone"
:style="{ '--delay': `${index * 55}ms` }"
>
<div class="budget-summary-head">
<span class="summary-icon">
<i :class="metric.icon"></i>
</span>
<div>
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
<em>{{ metric.note }}</em>
<span class="summary-label">{{ metric.label }}</span>
</div>
<strong class="summary-value">{{ metric.value }}</strong>
<div class="summary-comparison-row">
<span class="comparison-pill" :class="metric.yoy.tone">
<b>同比</b>
<em>{{ metric.yoy.value }}</em>
<i :class="metric.yoy.icon"></i>
</span>
<span class="comparison-pill" :class="metric.mom.tone">
<b>环比</b>
<em>{{ metric.mom.value }}</em>
<i :class="metric.mom.icon"></i>
</span>
</div>
</article>
</section>
<section class="budget-filter-bar">
<div class="budget-filter-set">
<label>
<span>预算周期</span>
<select v-model="filters.period">
<option v-for="period in periods" :key="period">{{ period }}</option>
<span>预算年度</span>
<select v-model="filters.year">
<option v-for="year in years" :key="year" :value="year">{{ year }}年度</option>
</select>
</label>
<label>
<span>预算季度</span>
<select v-model="filters.quarter">
<option v-for="quarter in quarters" :key="quarter" :value="quarter">{{ quarter }}</option>
</select>
</label>
<label>
@@ -36,10 +57,17 @@
<option v-for="status in statuses" :key="status">{{ status }}</option>
</select>
</label>
<button class="budget-primary-btn" type="button">
<i class="mdi mdi-plus"></i>
<span>新建预算</span>
</div>
<div class="budget-action-set">
<button class="budget-primary-btn" type="button" @click="openBudgetEditDialog">
<i class="mdi mdi-pencil-outline"></i>
<span>编辑预算</span>
</button>
<button class="budget-ghost-btn" type="button">
<i class="mdi mdi-text-box-outline"></i>
<span>预算详情</span>
</button>
</div>
</section>
<section class="budget-work-grid">
@@ -81,7 +109,6 @@
<th>使用率</th>
<th>预警线</th>
<th>控制动作</th>
<th>操作</th>
</tr>
</thead>
<tbody>
@@ -99,24 +126,49 @@
</td>
<td :class="row.warningTone">{{ row.warningLine }}</td>
<td>{{ row.action }}</td>
<td>
<div class="budget-row-actions">
<button type="button">详情</button>
<button type="button">编辑</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<footer class="budget-table-foot">
<button type="button" disabled><i class="mdi mdi-chevron-left"></i></button>
<button type="button" class="active">1</button>
<button type="button" disabled><i class="mdi mdi-chevron-right"></i></button>
<select aria-label="每页条数">
<option>10 /</option>
<div class="budget-pager" aria-label="预算分页">
<button
class="page-nav"
type="button"
:disabled="budgetPage <= 1"
@click="changeBudgetPage(-1)"
>
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in budgetPageNumbers"
:key="page"
type="button"
:class="{ active: page === budgetPage }"
@click="goToBudgetPage(page)"
>
{{ page }}
</button>
<button
class="page-nav"
type="button"
:disabled="budgetPage >= totalBudgetPages"
@click="changeBudgetPage(1)"
>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<label class="budget-page-size">
<select v-model.number="budgetPageSize" aria-label="每页条数">
<option v-for="size in budgetPageSizeOptions" :key="size" :value="size">
{{ size }} /
</option>
</select>
<span> {{ visibleBudgetRows.length }} </span>
<i class="mdi mdi-chevron-down"></i>
</label>
<span class="budget-page-summary">
{{ totalBudgetRows }} 当前第 {{ budgetPage }} / {{ totalBudgetPages }}
</span>
</footer>
</article>
</section>
@@ -152,9 +204,149 @@
</div>
</article>
</section>
<Teleport to="body">
<Transition name="budget-dialog-fade">
<div v-if="budgetEditOpen" class="budget-dialog-backdrop" @click.self="closeBudgetEditDialog">
<section class="budget-edit-dialog" role="dialog" aria-modal="true" aria-label="编辑预算">
<header class="budget-edit-head">
<strong>编辑预算</strong>
<button class="budget-dialog-close" type="button" aria-label="关闭" @click="closeBudgetEditDialog">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="budget-edit-body">
<section class="budget-edit-section">
<h3>基本信息</h3>
<div class="budget-edit-form-grid">
<label class="required">
<span>预算年度</span>
<select v-model="budgetEditForm.budgetYear">
<option v-for="year in years" :key="year" :value="year">{{ year }}年度</option>
</select>
</label>
<label class="required">
<span>预算季度</span>
<select v-model="budgetEditForm.budgetQuarter">
<option v-for="quarter in quarters" :key="quarter" :value="quarter">{{ quarter }}</option>
</select>
</label>
<label class="required">
<span>所属部门</span>
<select v-model="budgetEditForm.departmentCode">
<option v-for="department in departments" :key="department.code" :value="department.code">
{{ department.name }}
</option>
</select>
</label>
<label class="required">
<span>预算负责人</span>
<select v-model="budgetEditForm.budgetOwner">
<option>张晓明</option>
<option>李娜</option>
<option>王凯</option>
</select>
</label>
<label class="required">
<span>预算版本</span>
<select v-model="budgetEditForm.budgetVersion">
<option>V1.0初始版本</option>
<option>V1.1调整版本</option>
<option>V2.0发布版本</option>
</select>
</label>
<label class="required">
<span>预算状态</span>
<select v-model="budgetEditForm.budgetStatus">
<option v-for="status in statusOptions" :key="status">{{ status }}</option>
</select>
</label>
</div>
<label class="budget-edit-textarea">
<span>预算说明</span>
<textarea v-model="budgetEditForm.budgetDescription" maxlength="300"></textarea>
<em>{{ budgetEditForm.budgetDescription.length }}/300</em>
</label>
</section>
<section class="budget-edit-section">
<h3>预算明细</h3>
<div class="budget-edit-table-wrap">
<table class="budget-edit-table">
<thead>
<tr>
<th>费用类型 <i>*</i></th>
<th>预算金额 <i>*</i></th>
<th>预警线% <i>*</i></th>
<th>控制动作 <i>*</i></th>
<th>备注</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in budgetEditRows" :key="row.id">
<td>
<select v-model="row.budgetSubjectCode" @change="syncBudgetRowSubject(row)">
<option
v-for="option in expenseTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</td>
<td><input v-model="row.budgetAmount" type="text" inputmode="decimal" /></td>
<td>
<select v-model="row.warningThreshold">
<option v-for="warning in warningOptions" :key="warning">{{ warning }}</option>
</select>
</td>
<td>
<select v-model="row.controlAction">
<option v-for="action in controlActionOptions" :key="action">{{ action }}</option>
</select>
</td>
<td><input v-model="row.budgetRemark" type="text" /></td>
<td>
<button
class="budget-row-delete"
type="button"
aria-label="删除预算明细"
@click="removeBudgetDetailRow(row.id)"
>
<i class="mdi mdi-delete-outline"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<button class="budget-add-row-btn" type="button" @click="addBudgetDetailRow">
<i class="mdi mdi-plus"></i>
<span>添加行</span>
</button>
<div class="budget-edit-total">
<span>合计</span>
<strong>{{ budgetEditTotal }}</strong>
</div>
</section>
</div>
<footer class="budget-edit-foot">
<button class="budget-edit-cancel" type="button" @click="closeBudgetEditDialog">取消</button>
<button class="budget-edit-draft" type="button" @click="saveBudgetDraft">保存草稿</button>
<button class="budget-edit-publish" type="button" @click="publishBudget">保存并发布</button>
</footer>
</section>
</div>
</Transition>
</Teleport>
</section>
</template>
<script src="./scripts/BudgetCenterView.js"></script>
<style scoped src="../assets/styles/views/budget-center-view.css"></style>
<style scoped src="../assets/styles/views/budget-center-dialog.css"></style>

View File

@@ -31,7 +31,7 @@ import {
updateAgentAsset
} from '../../services/agentAssets.js'
import { loadOnlyOfficeApi } from '../../services/onlyoffice.js'
import { isFinanceUser, isManagerUser } from '../../utils/accessControl.js'
import { isFinanceUser, isManagerUser, isPlatformAdminUser } from '../../utils/accessControl.js'
import { buildOnlyOfficeEditorConfig } from './onlyOfficePreviewConfig.js'
import {
buildReviewNote,
@@ -69,6 +69,7 @@ import {
} from './auditViewModel.js'
import {
createDefaultRiskRuleForm,
RISK_RULE_BUSINESS_STAGE_OPTIONS,
RISK_RULE_EXPENSE_CATEGORY_OPTIONS
} from './auditViewRiskRuleModel.js'
@@ -144,11 +145,11 @@ export default {
financialRules: [],
riskRules: [],
skills: [],
mcp: [],
tasks: []
mcp: []
})
const isAdmin = computed(() => isManagerUser(currentUser.value))
const isAdmin = computed(() => isPlatformAdminUser(currentUser.value))
const isRuleManager = computed(() => isManagerUser(currentUser.value))
const isFinance = computed(() => isFinanceUser(currentUser.value))
const activeMeta = computed(() => TAB_META[activeType.value])
const activeTabLabel = computed(() => activeMeta.value.label)
@@ -162,7 +163,7 @@ export default {
const showVersionColumn = computed(() => activeMeta.value.showVersionColumn !== false)
const showStatusColumn = computed(() => activeMeta.value.showStatusColumn !== false)
const showOnlineColumn = computed(() => false)
const showEnabledColumn = computed(() => activeType.value === 'riskRules')
const showEnabledColumn = computed(() => false)
const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules')
const selectedSkillUsesSpreadsheet = computed(
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesSpreadsheetRule)
@@ -171,6 +172,9 @@ export default {
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesJsonRiskRule)
)
const canManageSelected = computed(
() => isRuleManager.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock
)
const canAdminOperateSelected = computed(
() => isAdmin.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock
)
const canEditSelected = computed(
@@ -180,7 +184,7 @@ export default {
(isAdmin.value || isFinance.value)
)
const canCreateRiskRule = computed(
() => activeType.value === 'riskRules' && (isAdmin.value || isFinance.value) && !detailBusy.value
() => activeType.value === 'riskRules' && isRuleManager.value && !detailBusy.value
)
const latestRiskRuleTestSummary = computed(() => selectedSkill.value?.latestTestSummary || null)
const riskRuleTestPassed = computed(() => Boolean(latestRiskRuleTestSummary.value?.test_passed))
@@ -196,27 +200,20 @@ export default {
const canOpenRiskRuleTest = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canEditSelected.value &&
canAdminOperateSelected.value &&
Boolean(selectedSkill.value?.id) &&
!riskRuleGenerationBusy.value &&
!riskRuleGenerationFailed.value &&
!detailBusy.value
!riskRuleGenerationFailed.value
)
const canDeleteRiskRule = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canEditSelected.value &&
canAdminOperateSelected.value &&
Boolean(selectedSkill.value?.id) &&
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '') &&
!detailBusy.value
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '')
)
const canOpenRiskRuleReviewSubmit = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canSubmitReview.value &&
!riskRuleInReview.value &&
!riskRuleGenerationBusy.value &&
!riskRuleGenerationFailed.value
() => false
)
const canSubmitRiskRuleReview = computed(
() =>
@@ -224,17 +221,14 @@ export default {
riskRuleTestPassed.value
)
const canReturnRiskRule = computed(
() => selectedSkillUsesJsonRisk.value && canManageSelected.value && riskRuleInReview.value
() => false
)
const canPublishRiskRule = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canManageSelected.value &&
riskRuleInReview.value &&
riskRuleTestPassed.value
false
)
const canToggleRiskRuleEnabled = computed(
() => selectedSkillUsesJsonRisk.value && canManageSelected.value && !detailBusy.value
() => selectedSkillUsesJsonRisk.value && canManageSelected.value
)
const riskRuleCreateBusy = computed(() => actionState.value === 'generate-risk-rule')
const canEditMarkdown = computed(() => canEditSelected.value && selectedSkillIsRule.value)
@@ -242,7 +236,11 @@ export default {
() => selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
)
const canSubmitReview = computed(
() => canEditSelected.value && selectedSkillIsRule.value && isDisplayingWorkingVersion.value
() =>
!selectedSkillUsesJsonRisk.value &&
canEditSelected.value &&
selectedSkillIsRule.value &&
isDisplayingWorkingVersion.value
)
const hasReviewSubmitReviewers = computed(() => reviewSubmitReviewerOptions.value.length > 0)
const canReviewSelected = computed(
@@ -370,7 +368,7 @@ export default {
)
const showStatusFilter = computed(() => true)
const showOnlineFilter = computed(() => false)
const showEnabledFilter = computed(() => activeType.value === 'riskRules')
const showEnabledFilter = computed(() => false)
const selectedRiskScenarioLabel = computed(
() =>
RISK_SCENARIO_OPTIONS.find((item) => item.value === selectedRiskScenario.value)?.label ||
@@ -646,6 +644,7 @@ export default {
const detail = await generateRiskRuleAsset(
{
business_domain: 'expense',
business_stage: riskRuleCreateForm.value.business_stage,
expense_category: riskRuleCreateForm.value.expense_category,
rule_title: ruleTitle,
requires_attachment: Boolean(riskRuleCreateForm.value.requires_attachment),
@@ -1007,8 +1006,13 @@ export default {
}
async function loadAssets(options = {}) {
const shouldShowLoading = !options.silent && !options.background
if (shouldShowLoading) {
loading.value = true
}
if (!options.silent) {
errorMessage.value = ''
}
try {
const payload = await fetchAgentAssets({ assetType: activeMeta.value.assetType })
@@ -1037,6 +1041,9 @@ export default {
}
}
} catch (error) {
if (options.silent || options.background) {
return
}
if (activeMeta.value.assetType === 'rule') {
assetBuckets.value = {
...assetBuckets.value,
@@ -1056,12 +1063,14 @@ export default {
toast(errorMessage.value)
}
} finally {
if (shouldShowLoading) {
loading.value = false
}
}
}
async function refreshCurrentAssets() {
await loadAssets({ force: true, silent: true })
await loadAssets({ force: true, silent: true, background: true })
}
async function loadSelectedAssetDetail(assetId) {
@@ -1110,6 +1119,39 @@ export default {
}
}
function mergeSelectedRuleLifecycle(detail) {
if (!selectedSkill.value || !detail) {
return
}
const next = buildDetailViewModel(detail, runs.value)
selectedSkill.value = {
...selectedSkill.value,
status: next.status,
statusValue: next.statusValue,
statusTone: next.statusTone,
publishedVersion: next.publishedVersion,
workingVersion: next.workingVersion,
currentVersion: next.currentVersion,
displayVersion: next.displayVersion,
reviewer: next.reviewer,
publisher: next.publisher,
publishedAt: next.publishedAt,
isOnlineValue: next.isOnlineValue,
isOnlineLabel: next.isOnlineLabel,
isOnlineTone: next.isOnlineTone,
isEnabledValue: next.isEnabledValue,
isEnabledLabel: next.isEnabledLabel,
isEnabledTone: next.isEnabledTone,
latestTestSummary: next.latestTestSummary,
lastOperationLabel: next.lastOperationLabel,
lastOperationTone: next.lastOperationTone,
publishMeta: next.publishMeta,
publishState: next.publishState,
updatedAt: next.updatedAt,
configJson: next.configJson
}
}
async function loadRiskRuleJson(assetId) {
if (!assetId || !selectedSkill.value?.usesJsonRiskRule) {
return
@@ -1525,6 +1567,9 @@ export default {
}
function openRiskRuleTestDialog() {
if (detailBusy.value) {
return
}
if (!canOpenRiskRuleTest.value) {
if (!selectedSkill.value?.id) {
toast('规则详情还没有加载完成,请稍后再测试。')
@@ -1544,7 +1589,8 @@ export default {
}
await refreshCurrentAssets()
if (selectedSkill.value?.id) {
await loadSelectedAssetDetail(selectedSkill.value.id)
const detail = await fetchAgentAssetDetail(selectedSkill.value.id)
mergeSelectedRuleLifecycle(detail)
}
}
@@ -1659,15 +1705,15 @@ export default {
return
}
const assetId = selectedSkill.value.id
const nextEnabled = !selectedSkill.value.isEnabledValue
const nextEnabled = !selectedSkill.value.isOnlineValue
actionState.value = 'toggle-risk-rule-enabled'
try {
await setRiskRuleAssetEnabled(assetId, nextEnabled, { actor: resolveActor() })
const detail = await setRiskRuleAssetEnabled(assetId, nextEnabled, { actor: resolveActor() })
mergeSelectedRuleLifecycle(detail)
await refreshCurrentAssets()
await loadSelectedAssetDetail(assetId)
toast(nextEnabled ? '风险规则已启用。' : '风险规则已停用,不会进入业务扫描。')
toast(nextEnabled ? '风险规则已上线。' : '风险规则已下线,不会进入业务扫描。')
} catch (error) {
toast(error?.message || '风险规则启用状态更新失败,请稍后重试。')
toast(error?.message || '风险规则上线状态更新失败,请稍后重试。')
} finally {
actionState.value = ''
}
@@ -1851,6 +1897,7 @@ export default {
riskRuleReturnOpen,
riskRulePublishOpen,
riskRuleReturnNote,
riskRuleBusinessStageOptions: RISK_RULE_BUSINESS_STAGE_OPTIONS,
riskRuleExpenseCategoryOptions: RISK_RULE_EXPENSE_CATEGORY_OPTIONS,
showReviewNote,
spreadsheetUploadInput,

View File

@@ -1,7 +1,18 @@
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
import { fetchEmployeeMeta } from '../../services/employees.js'
import {
BUDGET_CONTROL_ACTION_OPTIONS,
BUDGET_EXPENSE_TYPE_OPTIONS,
BUDGET_QUARTER_OPTIONS,
BUDGET_STATUS_OPTIONS,
BUDGET_WARNING_OPTIONS,
BUDGET_YEAR_OPTIONS,
buildBudgetOntologyContext,
formatBudgetPeriod,
resolveBudgetExpenseTypeLabel
} from '../../utils/budgetOntology.js'
const FALLBACK_DEPARTMENTS = [
{ code: 'MARKET-DEPT', name: '市场部', costCenter: 'CC-4100' },
@@ -12,13 +23,34 @@ const FALLBACK_DEPARTMENTS = [
{ code: 'PRESIDENT-OFFICE', name: '总裁办', costCenter: 'CC-1000' }
]
const EXPENSE_BLUEPRINTS = [
{ expenseType: '市场推广费', total: 500000, used: 186400, occupied: 120000, warning: 80, action: '提醒' },
{ expenseType: '差旅费', total: 600000, used: 242300, occupied: 150000, warning: 80, action: '提醒' },
{ expenseType: '办公费', total: 300000, used: 68500, occupied: 60000, warning: 70, action: '正常' },
{ expenseType: '培训费', total: 200000, used: 42300, occupied: 20000, warning: 70, action: '正常' },
{ expenseType: '软件服务费', total: 600000, used: 249500, occupied: 240800, warning: 80, action: '管控' }
]
const EXPENSE_BUDGET_SEED = {
travel: { total: 600000, used: 242300, occupied: 150000, warning: 80, action: '提醒' },
hotel: { total: 360000, used: 139800, occupied: 84000, warning: 80, action: '提醒' },
transport: { total: 280000, used: 104600, occupied: 56000, warning: 75, action: '提醒' },
meal: { total: 420000, used: 168200, occupied: 118000, warning: 80, action: '管控' },
meeting: { total: 260000, used: 84500, occupied: 52000, warning: 75, action: '提醒' },
marketing: { total: 500000, used: 186400, occupied: 120000, warning: 80, action: '提醒' },
office: { total: 300000, used: 68500, occupied: 60000, warning: 70, action: '正常' },
training: { total: 200000, used: 42300, occupied: 20000, warning: 70, action: '正常' },
software: { total: 600000, used: 249500, occupied: 240800, warning: 80, action: '管控' },
communication: { total: 120000, used: 38600, occupied: 18000, warning: 70, action: '正常' },
welfare: { total: 240000, used: 96500, occupied: 42000, warning: 75, action: '提醒' }
}
const DEFAULT_EXPENSE_BUDGET = {
total: 100000,
used: 0,
occupied: 0,
warning: 70,
action: '正常'
}
const EXPENSE_BLUEPRINTS = BUDGET_EXPENSE_TYPE_OPTIONS.map((option) => ({
...DEFAULT_EXPENSE_BUDGET,
...EXPENSE_BUDGET_SEED[option.value],
budgetSubjectCode: option.value,
expenseType: option.label
}))
const currency = (value) =>
Number(value || 0).toLocaleString('zh-CN', {
@@ -26,14 +58,29 @@ const currency = (value) =>
maximumFractionDigits: 2
})
const comparison = (value, direction) => ({
value,
tone: direction === 'down' ? 'down' : 'up',
icon: direction === 'down' ? 'mdi mdi-arrow-down' : 'mdi mdi-arrow-up'
})
const parseBudgetAmount = (value) => Number(String(value || '').replace(/[^\d.-]/g, '')) || 0
const makeBudgetRowId = () => `budget-row-${Date.now()}-${Math.random().toString(16).slice(2)}`
const BUDGET_PAGE_SIZE_OPTIONS = [5, 10]
function buildDepartmentRows(departmentCode) {
const seed = Array.from(String(departmentCode || '')).reduce((sum, char) => sum + char.charCodeAt(0), 0)
const seed = Array.from(String(departmentCode || '')).reduce(
(sum, char) => sum + char.charCodeAt(0),
0
)
const factor = 0.88 + (seed % 18) / 100
return EXPENSE_BLUEPRINTS.map((item, index) => {
const totalAmount = Math.round(item.total * factor)
const usedAmount = Math.round(item.used * (0.9 + ((seed + index) % 12) / 100))
const occupiedAmount = Math.round(item.occupied * (0.92 + ((seed + index * 3) % 10) / 100))
const occupiedAmount = Math.round(
item.occupied * (0.92 + ((seed + index * 3) % 10) / 100)
)
const leftAmount = Math.max(totalAmount - usedAmount - occupiedAmount, 0)
const rate = Number((((usedAmount + occupiedAmount) / totalAmount) * 100).toFixed(2))
@@ -80,18 +127,36 @@ export default {
const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code)
const departmentKeyword = ref('')
const filters = ref({
period: '2026年度',
year: '2026',
quarter: 'Q1',
expenseType: '全部',
status: '全部'
})
const budgetPage = ref(1)
const budgetPageSize = ref(5)
const budgetEditOpen = ref(false)
const budgetEditForm = ref({
budgetYear: '2026',
budgetQuarter: 'Q1',
budgetPeriod: '2026年Q1',
departmentCode: FALLBACK_DEPARTMENTS[0].code,
costCenter: FALLBACK_DEPARTMENTS[0].costCenter,
budgetOwner: '张晓明',
budgetVersion: 'V1.0(初始版本)',
budgetStatus: '编制中',
budgetDescription: ''
})
const budgetEditRows = ref([])
const activeDepartment = computed(() =>
departments.value.find((item) => item.code === activeDepartmentCode.value) || departments.value[0]
)
const activeDepartmentName = computed(() => activeDepartment.value?.name || '市场部')
const departmentRows = computed(() => buildDepartmentRows(activeDepartment.value?.code || activeDepartmentCode.value))
const visibleBudgetRows = computed(() =>
const departmentRows = computed(() =>
buildDepartmentRows(activeDepartment.value?.code || activeDepartmentCode.value)
)
const filteredBudgetRows = computed(() =>
departmentRows.value
.filter((row) => filters.value.expenseType === '全部' || row.expenseType === filters.value.expenseType)
.filter((row) => {
@@ -101,6 +166,21 @@ export default {
return row.rateTone === 'ok'
})
)
const totalBudgetRows = computed(() => filteredBudgetRows.value.length)
const totalBudgetPages = computed(() =>
Math.max(1, Math.ceil(totalBudgetRows.value / Number(budgetPageSize.value || 5)))
)
const currentBudgetPage = computed(() =>
Math.min(Math.max(1, budgetPage.value), totalBudgetPages.value)
)
const budgetPageNumbers = computed(() =>
Array.from({ length: totalBudgetPages.value }, (_, index) => index + 1)
)
const visibleBudgetRows = computed(() => {
const pageSize = Number(budgetPageSize.value || 5)
const start = (currentBudgetPage.value - 1) * pageSize
return filteredBudgetRows.value.slice(start, start + pageSize)
})
const totals = computed(() => {
const rows = departmentRows.value
@@ -119,30 +199,34 @@ export default {
{
label: '预算总额',
value: `¥${currency(totals.value.total)}`,
note: '本年累计',
yoy: comparison('+8.42%', 'up'),
mom: comparison('+2.16%', 'up'),
tone: 'green',
icon: 'mdi mdi-wallet-outline'
},
{
label: '已发生',
value: `¥${currency(totals.value.used)}`,
note: `占比 ${((totals.value.used / totals.value.total) * 100).toFixed(2)}%`,
yoy: comparison('+12.68%', 'up'),
mom: comparison('+4.35%', 'up'),
tone: 'blue',
icon: 'mdi mdi-chart-line'
},
{
label: '已占用',
value: `¥${currency(totals.value.occupied)}`,
note: `占比 ${((totals.value.occupied / totals.value.total) * 100).toFixed(2)}%`,
yoy: comparison('+6.37%', 'up'),
mom: comparison('-1.84%', 'down'),
tone: 'orange',
icon: 'mdi mdi-briefcase-check-outline'
},
{
label: '剩余可用',
value: `¥${currency(totals.value.left)}`,
note: `占比 ${((totals.value.left / totals.value.total) * 100).toFixed(2)}%`,
yoy: comparison('-3.26%', 'down'),
mom: comparison('-2.08%', 'down'),
tone: 'green',
icon: 'mdi mdi-currency-cny'
icon: 'mdi mdi-cash'
}
])
@@ -170,6 +254,103 @@ export default {
)
const trendData = computed(() => buildTrendData(departmentRows.value))
const budgetEditTotal = computed(() =>
currency(
budgetEditRows.value.reduce(
(sum, row) => sum + parseBudgetAmount(row.budgetAmount),
0
)
)
)
const budgetOntologyContext = computed(() =>
buildBudgetOntologyContext({
form: budgetEditForm.value,
rows: budgetEditRows.value,
departments: departments.value
})
)
function buildEditableRows() {
return departmentRows.value.map((row) => ({
id: makeBudgetRowId(),
budgetSubject: row.expenseType,
budgetSubjectCode: row.budgetSubjectCode || '',
budgetAmount: currency(row.totalAmount),
warningThreshold: `${row.warning}%`,
controlAction: row.action,
budgetRemark: `${row.expenseType}相关费用`
}))
}
function resolveNextExpenseTypeOption() {
const usedCodes = new Set(budgetEditRows.value.map((row) => row.budgetSubjectCode))
return (
BUDGET_EXPENSE_TYPE_OPTIONS.find((item) => !usedCodes.has(item.value)) ||
BUDGET_EXPENSE_TYPE_OPTIONS[0]
)
}
function syncBudgetRowSubject(row) {
row.budgetSubject = resolveBudgetExpenseTypeLabel(row.budgetSubjectCode, row.budgetSubject)
}
function openBudgetEditDialog() {
const department = activeDepartment.value
const budgetPeriod = formatBudgetPeriod(filters.value.year, filters.value.quarter)
budgetEditForm.value = {
budgetYear: filters.value.year,
budgetQuarter: filters.value.quarter,
budgetPeriod,
departmentCode: department?.code || activeDepartmentCode.value,
costCenter: department?.costCenter || '',
budgetOwner: '张晓明',
budgetVersion: 'V1.0(初始版本)',
budgetStatus: '编制中',
budgetDescription: `${department?.name || '当前部门'}2026年度预算编制用于指导费用支出及控制成本确保资源合理使用。`
}
budgetEditRows.value = buildEditableRows()
budgetEditOpen.value = true
}
function closeBudgetEditDialog() {
budgetEditOpen.value = false
}
function addBudgetDetailRow() {
const option = resolveNextExpenseTypeOption()
budgetEditRows.value.push({
id: makeBudgetRowId(),
budgetSubject: option.label,
budgetSubjectCode: option.value,
budgetAmount: '0.00',
warningThreshold: '70%',
controlAction: '正常',
budgetRemark: ''
})
}
function removeBudgetDetailRow(rowId) {
if (budgetEditRows.value.length <= 1) return
budgetEditRows.value = budgetEditRows.value.filter((row) => row.id !== rowId)
}
function goToBudgetPage(page) {
budgetPage.value = Math.min(Math.max(Number(page) || 1, 1), totalBudgetPages.value)
}
function changeBudgetPage(direction) {
goToBudgetPage(currentBudgetPage.value + direction)
}
function saveBudgetDraft() {
budgetEditForm.value.budgetStatus = '编制中'
closeBudgetEditDialog()
}
function publishBudget() {
budgetEditForm.value.budgetStatus = '已发布'
closeBudgetEditDialog()
}
async function loadDepartments() {
try {
@@ -198,19 +379,65 @@ export default {
void loadDepartments()
})
watch(
[
activeDepartmentCode,
budgetPageSize,
() => filters.value.year,
() => filters.value.quarter,
() => filters.value.expenseType,
() => filters.value.status
],
() => {
budgetPage.value = 1
}
)
watch(totalBudgetPages, (pages) => {
if (budgetPage.value > pages) {
budgetPage.value = pages
}
})
return {
activeDepartmentCode,
activeDepartmentName,
addBudgetDetailRow,
budgetEditForm,
budgetEditOpen,
budgetEditRows,
budgetEditTotal,
budgetMetrics,
budgetOntologyContext,
budgetPage: currentBudgetPage,
budgetPageNumbers,
budgetPageSize,
budgetPageSizeOptions: BUDGET_PAGE_SIZE_OPTIONS,
closeBudgetEditDialog,
controlActionOptions: BUDGET_CONTROL_ACTION_OPTIONS,
changeBudgetPage,
departmentKeyword,
departments,
expenseTypeOptions: BUDGET_EXPENSE_TYPE_OPTIONS,
expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)],
filters,
periods: ['2026年度', '2026年Q2', '2026年5月'],
openBudgetEditDialog,
quarters: BUDGET_QUARTER_OPTIONS,
publishBudget,
removeBudgetDetailRow,
saveBudgetDraft,
statusOptions: BUDGET_STATUS_OPTIONS,
statuses: ['全部', '正常', '预警', '管控'],
syncBudgetRowSubject,
goToBudgetPage,
totalBudgetPages,
totalBudgetRows,
trendData,
visibleBudgetRows,
visibleDepartments,
warnings
warningOptions: BUDGET_WARNING_OPTIONS,
warnings,
years: BUDGET_YEAR_OPTIONS
}
}
}

View File

@@ -56,23 +56,6 @@ export const TYPE_META = {
version: '当前版本',
metric: '超时配置'
}
},
tasks: {
assetType: 'task',
label: '任务',
typeLabel: '任务',
createButtonLabel: '任务已接入',
hintText: '任务页签已接到真实资产 API可查看调度周期、执行 Agent 和最近执行结果。',
searchPlaceholder: '搜索任务名称、编码或负责人',
tableColumns: {
name: '任务名称',
category: '业务域',
owner: '负责人',
scope: '适用场景',
runtime: '调度周期',
version: '当前版本',
metric: '执行 Agent'
}
}
}
@@ -113,20 +96,15 @@ export const TAB_META = {
...TYPE_META.mcp,
typeKey: 'mcp',
badgeTone: 'amber'
},
tasks: {
...TYPE_META.tasks,
typeKey: 'tasks',
badgeTone: 'violet'
}
}
export const STATUS_META = {
generating: { label: '生成中', tone: 'info' },
draft: { label: '草稿中', tone: 'draft' },
draft: { label: '待上线', tone: 'draft' },
review: { label: '待审核', tone: 'warning' },
active: { label: '已上线', tone: 'success' },
disabled: { label: '已停用', tone: 'disabled' },
disabled: { label: '已下线', tone: 'disabled' },
failed: { label: '生成失败', tone: 'danger' }
}
@@ -230,34 +208,16 @@ export const DETAIL_TITLES = {
historyDesc: '最近版本记录',
publishTitle: '服务状态',
publishDesc: 'MCP 资产已接入规则中心,但真实外部调用仍以后续链路集成为准。'
},
tasks: {
configTitle: '任务配置',
configDesc: '展示调度周期、执行 Agent 和任务编码。',
detailTitle: '任务结构',
detailDesc: '按调度计划、目标场景和运行结果组织任务信息。',
outputTitle: '运行要求',
outputDesc: '任务详情重点展示调度 Agent、最近运行结果和运行日志入口。',
ruleListTitle: '运行要求',
checkListTitle: '最近执行',
triggerTitle: '适用场景',
triggerDesc: '当前任务覆盖的业务场景',
toolTitle: '最近调用',
toolDesc: '根据 AgentRun 中的最近执行记录回显任务运行情况',
historyTitle: '版本历史',
historyDesc: '最近版本记录',
publishTitle: '调度状态',
publishDesc: '任务资产已接入规则中心,后续 Day 4 运行时会继续消费这些配置。'
}
}
export const STATUS_OPTIONS = [
{ value: '', label: '全部状态' },
{ value: 'generating', label: '生成中' },
{ value: 'draft', label: '草稿中' },
{ value: 'draft', label: '待上线' },
{ value: 'review', label: '待审核' },
{ value: 'active', label: '已上线' },
{ value: 'disabled', label: '已停用' },
{ value: 'disabled', label: '已下线' },
{ value: 'failed', label: '生成失败' }
]

View File

@@ -207,6 +207,65 @@ export function resolveRiskRuleEnabled(source, rulePayload = null) {
return true
}
const LAST_OPERATION_LABELS = {
generate: '开始生成',
create: '创建',
test: '测试',
online: '上线',
offline: '下线',
delete: '删除',
update: '更新'
}
const RISK_RULE_BUSINESS_STAGE_LABELS = {
expense_application: '费用申请',
reimbursement: '费用报销'
}
function resolveRiskRuleBusinessStage(source, rulePayload = null) {
const configJson = readConfigJson(source)
const metadata = rulePayload && typeof rulePayload === 'object' ? rulePayload.metadata || {} : {}
const stage =
normalizeText(configJson.business_stage) ||
normalizeText(metadata.business_stage) ||
normalizeText(rulePayload?.business_stage)
const label =
normalizeText(configJson.business_stage_label) ||
normalizeText(metadata.business_stage_label) ||
RISK_RULE_BUSINESS_STAGE_LABELS[stage]
return {
value: stage || 'reimbursement',
label: label || '费用报销'
}
}
function resolveRiskRuleOnlineMeta(statusValue) {
if (statusValue === 'active') {
return { label: '已上线', tone: 'success', online: true }
}
if (statusValue === 'disabled') {
return { label: '已下线', tone: 'disabled', online: false }
}
if (statusValue === 'generating') {
return { label: '生成中', tone: 'info', online: false }
}
if (statusValue === 'failed') {
return { label: '生成失败', tone: 'danger', online: false }
}
return { label: '待上线', tone: 'draft', online: false }
}
function resolveLastOperationLabel(source, fallback = {}) {
const configJson = readConfigJson(source)
const operation = isPlainObject(configJson.last_operation) ? configJson.last_operation : {}
const action = normalizeText(operation.action) || normalizeText(fallback.action) || 'create'
const actor = normalizeText(operation.actor) || normalizeText(fallback.actor) || '系统'
const at = normalizeText(operation.at) || normalizeText(fallback.at)
const actionLabel = LAST_OPERATION_LABELS[action] || action
const timeLabel = formatDateTime(at)
return timeLabel && timeLabel !== '-' ? `${actionLabel}${actor} · ${timeLabel}` : `${actionLabel}${actor}`
}
export function readRuleDocumentMeta(value) {
const configJson = readConfigJson(value)
return isPlainObject(configJson.rule_document) ? configJson.rule_document : null
@@ -458,12 +517,13 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
normalizeText(apiConfig.expense_category_label) ||
normalizeText(rulePayload.risk_category) ||
resolveRiskRuleCategory({ ...target, risk_category: rulePayload.risk_category, config_json: rulePayload })
const businessStage = resolveRiskRuleBusinessStage(target, rulePayload)
const riskRuleFields = resolveRiskRuleFields(rulePayload)
const riskRuleCreatedAt = resolveRiskRuleCreatedAt(rulePayload, target.createdAt || target.updatedAt)
const riskRuleScoreLevel = resolveRiskRuleScoreLevel(rulePayload, apiConfig)
const statusValue = apiPayload?.status || target.statusValue || 'draft'
const isOnlineLabel = statusValue === 'active' ? '是' : '否'
const onlineMeta = resolveRiskRuleOnlineMeta(statusValue)
const isEnabledValue = resolveRiskRuleEnabled(target, rulePayload)
const publisher =
@@ -488,6 +548,8 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
riskRuleBusinessDescription: resolveRiskRuleBusinessDescription(rulePayload, fullDescription),
riskRuleSubtitle: buildRiskListSubtitle(fullDescription, 48),
riskCategory,
businessStageValue: businessStage.value,
businessStageLabel: businessStage.label,
scope: riskCategory,
riskRuleSourceRef: resolveRiskRuleSourceRef(rulePayload),
riskRuleSeverity: riskRuleScoreLevel || resolveRiskRuleSeverity(rulePayload),
@@ -521,10 +583,16 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
outcomes: apiPayload?.outcomes || rulePayload.outcomes || {}
},
riskRuleJsonText: JSON.stringify(rulePayload, null, 2),
isOnlineLabel,
isOnlineValue: onlineMeta.online,
isOnlineLabel: onlineMeta.label,
isOnlineTone: onlineMeta.tone,
isEnabledValue,
isEnabledLabel: isEnabledValue ? '是' : '否',
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
lastOperationLabel: resolveLastOperationLabel(target, {
actor: publisher,
at: riskRuleCreatedAt
}),
publisher,
publishedAt
}
@@ -747,7 +815,7 @@ export function resolveTypeKey(assetType) {
if (assetType === 'mcp') {
return 'mcp'
}
return 'tasks'
return ''
}
export function formatSeverity(value) {
@@ -778,23 +846,6 @@ export function formatOutputSummary(items) {
return `${items.length} 项输出`
}
export function formatTaskRisk(scenarios) {
if (Array.isArray(scenarios) && scenarios.includes('risk_check')) {
return '高风险'
}
if (
Array.isArray(scenarios) &&
(scenarios.includes('accounts_receivable') || scenarios.includes('accounts_payable'))
) {
return '中风险'
}
return '常规'
}
export function findLatestTaskRun(runs, assetId) {
return runs.find((item) => item.task_id === assetId) || null
}
export function findLatestMcpCall(runs, assetCode) {
const expectedToolName = normalizeText(assetCode).replace(/^mcp\./, '')
@@ -827,7 +878,7 @@ export function buildRowRuntime(asset, typeKey) {
if (typeKey === 'mcp') {
return normalizeText(asset.config_json?.endpoint) || '未配置地址'
}
return normalizeText(asset.config_json?.cron) || '未配置调度'
return ''
}
export function buildRowMetric(asset, typeKey) {
@@ -840,7 +891,7 @@ export function buildRowMetric(asset, typeKey) {
if (typeKey === 'mcp') {
return asset.config_json?.timeout_ms ? `${asset.config_json.timeout_ms} ms` : '未配置超时'
}
return normalizeText(asset.config_json?.agent) || '未配置 Agent'
return ''
}
export function formatSpreadsheetChangeSummary(summary) {
@@ -885,7 +936,8 @@ export function buildListItem(asset) {
const listSubtitle = isRiskRule
? buildRiskListSubtitle(asset.description)
: normalizeText(asset.description)
const isOnlineValue = asset.status === 'active'
const onlineMeta = resolveRiskRuleOnlineMeta(asset.status)
const isOnlineValue = onlineMeta.online
const isEnabledValue = usesJsonRiskRule ? resolveRiskRuleEnabled(asset) : true
const reviewer = normalizeText(asset.reviewer) || '待分配'
const creator =
@@ -895,6 +947,9 @@ export function buildListItem(asset) {
'未知'
const publisher = isRiskRule ? creator : ''
const riskRuleCreatedAt = formatDateTime(asset.created_at || asset.updated_at)
const businessStage = usesJsonRiskRule
? resolveRiskRuleBusinessStage(asset)
: { value: '', label: '' }
return {
id: asset.id,
@@ -915,6 +970,8 @@ export function buildListItem(asset) {
reviewer,
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json),
riskCategory: ruleScenarioCategory,
businessStageValue: businessStage.value,
businessStageLabel: businessStage.label,
model: buildRowRuntime(asset, typeKey),
version: workingVersion,
versionDisplay: typeKey === 'rules' ? `${changeCount}` : workingVersion,
@@ -928,8 +985,8 @@ export function buildListItem(asset) {
publisher,
publishedAt: isOnlineValue ? formatDateTime(asset.published_at || asset.updated_at) : '-',
isOnlineValue,
isOnlineLabel: isOnlineValue ? '是' : '否',
isOnlineTone: isOnlineValue ? 'success' : 'disabled',
isOnlineLabel: onlineMeta.label,
isOnlineTone: onlineMeta.tone,
isEnabledValue,
isEnabledLabel: isEnabledValue ? '是' : '否',
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
@@ -1002,21 +1059,7 @@ export function buildMcpFields(detail, latestCall) {
]
}
export function buildTaskFields(detail, latestRun) {
const content = detail.current_version_content || {}
return [
{ label: '任务编码', value: detail.code },
{ label: 'Cron', value: normalizeText(detail.config_json?.cron) || normalizeText(content.schedule) || '未配置' },
{ label: '执行 Agent', value: normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置' },
{ label: '风险等级', value: formatTaskRisk(detail.scenario_json) },
{
label: '最近执行',
value: latestRun ? formatDateTime(latestRun.started_at) : '暂无执行记录'
}
]
}
export function buildFields(detail, typeKey, latestRun, latestCall) {
export function buildFields(detail, typeKey, latestCall) {
if (typeKey === 'rules') {
return buildRuleFields(detail)
}
@@ -1026,10 +1069,10 @@ export function buildFields(detail, typeKey, latestRun, latestCall) {
if (typeKey === 'mcp') {
return buildMcpFields(detail, latestCall)
}
return buildTaskFields(detail, latestRun)
return []
}
export function buildPromptSections(detail, typeKey, latestRun, latestCall) {
export function buildPromptSections(detail, typeKey) {
const content = detail.current_version_content || {}
if (typeKey === 'skills') {
@@ -1075,26 +1118,10 @@ export function buildPromptSections(detail, typeKey, latestRun, latestCall) {
]
}
return [
{
title: '任务场景',
intent: '调度目标',
content: formatScenarioList(detail.scenario_json)
},
{
title: '执行 Agent',
intent: '运行主体',
content: normalizeText(content.target_agent) || normalizeText(detail.config_json?.agent) || '未配置执行 Agent。'
},
{
title: '最近执行结果',
intent: '运行反馈',
content: latestRun?.result_summary || latestRun?.error_message || '暂无执行记录。'
}
]
return []
}
export function buildOutputRules(detail, typeKey, latestRun, latestCall) {
export function buildOutputRules(detail, typeKey) {
const content = detail.current_version_content || {}
if (typeKey === 'rules') {
@@ -1130,15 +1157,10 @@ export function buildOutputRules(detail, typeKey, latestRun, latestCall) {
]
}
return [
`调度周期:${normalizeText(detail.config_json?.cron) || normalizeText(content.schedule) || '未配置'}`,
`执行 Agent${normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置'}`,
`风险等级:${formatTaskRisk(detail.scenario_json)}`,
`最近执行结果:${latestRun?.status || '暂无执行记录'}`
]
return []
}
export function buildTests(detail, typeKey, latestRun, latestCall) {
export function buildTests(detail, typeKey, latestCall) {
if (typeKey === 'rules') {
const reviewMeta = resolveReviewMeta(detail.latest_review?.review_status)
return [
@@ -1195,23 +1217,10 @@ export function buildTests(detail, typeKey, latestRun, latestCall) {
]
}
return [
{
name: '最近运行状态',
input: latestRun?.run_id || '暂无运行',
result: latestRun?.status || '未记录',
tone: latestRun?.status === 'failed' || latestRun?.status === 'blocked' ? 'danger' : 'success'
},
{
name: '结果摘要',
input: latestRun?.agent || normalizeText(detail.config_json?.agent) || '未配置',
result: latestRun?.result_summary || '暂无摘要',
tone: 'success'
}
]
return []
}
export function buildTools(detail, typeKey, latestRun, latestCall) {
export function buildTools(detail, typeKey, latestCall) {
const content = detail.current_version_content || {}
if (typeKey === 'skills') {
@@ -1246,26 +1255,7 @@ export function buildTools(detail, typeKey, latestRun, latestCall) {
]
}
return [
{
name: normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置 Agent',
scope: '执行 Agent',
mode: '调度',
tone: 'active'
},
{
name: latestRun?.run_id || '暂无执行记录',
scope: '最近 Run',
mode: latestRun?.status || '未执行',
tone: latestRun?.status === 'failed' || latestRun?.status === 'blocked' ? 'danger' : 'active'
},
{
name: latestRun?.permission_level || '未记录',
scope: '权限级别',
mode: 'Trace',
tone: 'safe'
}
]
return []
}
export function buildPublishDescription(detail, typeKey) {
@@ -1279,14 +1269,16 @@ export function buildPublishDescription(detail, typeKey) {
return '当前规则需要先完成审核,再调用上线接口正式激活。'
}
return DETAIL_TITLES[typeKey].publishDesc
return DETAIL_TITLES[typeKey]?.publishDesc || ''
}
export function buildDetailViewModel(detail, runs) {
const typeKey = resolveTypeKey(detail.asset_type)
const tabId = resolveTabId(detail, typeKey) || typeKey
if (!typeKey || !tabId) {
return null
}
const tabMeta = resolveTabMeta(tabId, typeKey)
const latestRun = typeKey === 'tasks' ? findLatestTaskRun(runs, detail.id) : null
const latestCall = typeKey === 'mcp' ? findLatestMcpCall(runs, detail.code) : null
const configJson = readConfigJson(detail)
const statusMeta = resolveStatusMeta(detail.status)
@@ -1320,6 +1312,14 @@ export function buildDetailViewModel(detail, runs) {
normalizeText(detail.owner) ||
normalizeText(detail.recent_versions?.[0]?.created_by) ||
'未知'
const onlineMeta = resolveRiskRuleOnlineMeta(detail.status)
const businessStage = usesJsonRiskRule
? resolveRiskRuleBusinessStage(detail)
: { value: '', label: '' }
const initialRiskRuleScore = resolveRiskRuleScore(configJson, configJson)
const initialRiskRuleScoreLevel = resolveRiskRuleScoreLevel(configJson, configJson)
const initialRiskRuleSeverity = initialRiskRuleScoreLevel || resolveRiskRuleSeverity(configJson)
return {
id: detail.id,
@@ -1335,6 +1335,8 @@ export function buildDetailViewModel(detail, runs) {
reviewer: detail.reviewer || detail.latest_review?.reviewer || '待分配',
category: resolveDomainLabel(detail.domain),
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(detail.scenario_json),
businessStageValue: businessStage.value,
businessStageLabel: businessStage.label,
version: detail.working_version || detail.current_version || '-',
currentVersion: detail.current_version || '-',
publishedVersion: detail.published_version || '-',
@@ -1356,15 +1358,19 @@ export function buildDetailViewModel(detail, runs) {
riskRuleBusinessDescription: '',
riskRuleSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : '',
riskRuleSourceRef: '',
riskRuleSeverity: 'medium',
riskRuleSeverityLabel: '中风险',
riskRuleScore: null,
riskRuleScoreLabel: '待计算',
riskRuleScoreLevel: 'medium',
riskRuleScoreDetail: null,
riskRuleSeverity: initialRiskRuleSeverity,
riskRuleScore: initialRiskRuleScore,
riskRuleScoreLevel: initialRiskRuleScoreLevel || initialRiskRuleSeverity,
riskRuleScoreDetail: resolveRiskRuleScoreDetail(configJson, configJson),
riskRuleSeverityLabel: initialRiskRuleScoreLevel
? resolveRiskRuleScoreLabel(configJson, configJson)
: resolveRiskRuleSeverityLabel(configJson),
riskRuleScoreLabel: resolveRiskRuleScoreLabel(configJson, configJson),
riskRuleCreatedAt: formatDateTime(detail.created_at),
riskRuleAgeLabel: formatRiskRuleAge(detail.created_at),
isOnlineLabel: detail.status === 'active' ? '是' : '否',
isOnlineValue: onlineMeta.online,
isOnlineLabel: onlineMeta.label,
isOnlineTone: onlineMeta.tone,
isEnabledValue,
isEnabledLabel: isEnabledValue ? '是' : '否',
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
@@ -1381,6 +1387,11 @@ export function buildDetailViewModel(detail, runs) {
history.find((item) => item.isPublished || item.lifecycleState === 'published')?.time ||
(detail.published_at ? formatDateTime(detail.published_at) : '') ||
(detail.latest_review?.reviewed_at ? formatDateTime(detail.latest_review.reviewed_at) : '-'),
lastOperationLabel: resolveLastOperationLabel(detail, {
actor: riskRuleCreator,
at: detail.created_at
}),
lastOperationTone: onlineMeta.tone,
riskRuleFields: [],
riskRuleFieldSummary: '未识别字段',
riskRuleFlow: resolveRiskRuleFlow({}, []),
@@ -1411,13 +1422,12 @@ export function buildDetailViewModel(detail, runs) {
reviewStatusValue: detail.latest_review?.review_status || '',
reviewTimeLabel: formatDateTime(detail.latest_review?.reviewed_at),
reviewNote: detail.latest_review?.review_note || '',
latestRun,
latestCall,
fields: buildFields(detail, typeKey, latestRun, latestCall),
fields: buildFields(detail, typeKey, latestCall),
promptSections:
typeKey === 'rules' ? [] : buildPromptSections(detail, typeKey, latestRun, latestCall),
outputRules: buildOutputRules(detail, typeKey, latestRun, latestCall),
tests: buildTests(detail, typeKey, latestRun, latestCall),
typeKey === 'rules' ? [] : buildPromptSections(detail, typeKey),
outputRules: buildOutputRules(detail, typeKey),
tests: buildTests(detail, typeKey, latestCall),
triggers:
typeKey === 'rules'
? [ruleScenarioCategory || '通用']
@@ -1446,7 +1456,7 @@ export function buildDetailViewModel(detail, runs) {
tone: 'safe'
}
]
: buildTools(detail, typeKey, latestRun, latestCall),
: buildTools(detail, typeKey, latestCall),
history,
configTitle: titles.configTitle,
configDesc: titles.configDesc,
@@ -1467,8 +1477,6 @@ export function buildDetailViewModel(detail, runs) {
publishMeta:
typeKey === 'rules'
? `最近保存:${formatDateTime(detail.updated_at)}`
: latestRun
? `最近运行:${formatDateTime(latestRun.started_at)}`
: `最近更新:${formatDateTime(detail.updated_at)}`,
publishState: statusMeta.label,
latestReviewVersion: detail.latest_review?.version || detail.current_version || '-',

View File

@@ -16,6 +16,11 @@ export const RISK_RULE_EXPENSE_CATEGORY_OPTIONS = [
{ value: 'welfare', label: '福利费' }
]
export const RISK_RULE_BUSINESS_STAGE_OPTIONS = [
{ value: 'expense_application', label: '费用申请' },
{ value: 'reimbursement', label: '费用报销' }
]
export const RISK_RULE_LEVEL_OPTIONS = [
{ value: 'low', label: '低风险' },
{ value: 'medium', label: '中风险' },
@@ -49,6 +54,7 @@ const CITY_ROUTE_SEMANTIC_TYPES = new Set([
export function createDefaultRiskRuleForm() {
return {
business_domain: 'expense',
business_stage: 'reimbursement',
expense_category: 'travel',
rule_title: '',
requires_attachment: false,

View File

@@ -25,8 +25,10 @@ export const EXPENSE_TYPE_LABELS = {
meal: '业务招待费',
meeting: '会务费',
entertainment: '业务招待费',
marketing: '市场推广费',
office: '办公用品费',
training: '培训费',
software: '软件服务费',
communication: '通讯费',
welfare: '福利费',
other: '其他费用'
@@ -96,8 +98,10 @@ export const REVIEW_FALLBACK_GROUP_CODES = [
'hotel',
'meal',
'meeting',
'marketing',
'office',
'training',
'software',
'communication',
'welfare'
]
@@ -113,7 +117,9 @@ export const REVIEW_CATEGORY_PRESET_OPTIONS = [
export const REVIEW_OTHER_CATEGORY_OPTIONS = [
{ key: 'meeting', label: '会务费' },
{ key: 'marketing', label: '市场推广费' },
{ key: 'training', label: '培训费' },
{ key: 'software', label: '软件服务费' },
{ key: 'communication', label: '通讯费' },
{ key: 'welfare', label: '福利费' },
{ key: 'other', label: '其他费用' }
@@ -140,9 +146,11 @@ export const CATEGORY_CONFIDENCE_KEYWORDS = {
transport: [TRANSPORT_KEYWORD_PATTERN],
meal: [/业务招待|招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同|餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/],
meeting: [/会务|会议|论坛|展会|参会|会场/],
marketing: [/市场推广|推广费|广告|投放|品牌宣传|营销物料|推广物料/],
entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/],
office: [/办公|工位|耗材|白板|键盘|鼠标|打印|文具|采购/],
training: [/培训|授课|讲师|课程|签到|讲义/],
software: [/软件|SaaS|订阅|系统服务|云服务|云资源|平台服务|技术服务/],
communication: [/通讯|电话|流量|话费|宽带|网络/],
welfare: [/福利|体检|团建|节日|慰问|关怀/]
}

View File

@@ -0,0 +1,91 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
BUDGET_EXPENSE_TYPE_OPTIONS,
BUDGET_ONTOLOGY_FIELDS,
BUDGET_QUARTER_OPTIONS,
BUDGET_YEAR_OPTIONS,
buildBudgetOntologyContext
} from '../src/utils/budgetOntology.js'
test('budget ontology fields expose required budget center keys', () => {
const requiredKeys = BUDGET_ONTOLOGY_FIELDS
.filter((field) => field.required)
.map((field) => field.key)
assert.deepEqual(requiredKeys, [
'budget_period',
'department',
'cost_center',
'budget_owner',
'budget_version',
'budget_status',
'budget_subject',
'budget_amount',
'warning_threshold',
'control_action'
])
})
test('budget ontology context maps dialog fields to ontology payload', () => {
const context = buildBudgetOntologyContext({
form: {
budgetYear: '2026',
budgetQuarter: 'Q2',
departmentCode: 'MARKET-DEPT',
budgetOwner: '张晓明',
budgetVersion: 'V1.0(初始版本)',
budgetStatus: '编制中',
budgetDescription: '市场部预算编制'
},
departments: [
{ code: 'MARKET-DEPT', name: '市场部', costCenter: 'CC-4100' }
],
rows: [
{
budgetSubject: '差旅费',
budgetSubjectCode: 'travel',
budgetAmount: '600,000.00',
warningThreshold: '80%',
controlAction: '提醒',
budgetRemark: '差旅相关费用'
}
]
})
assert.equal(context.document_type, 'budget_plan')
assert.equal(context.conversation_scenario, 'budget')
assert.equal(context.budget_header.budget_period, '2026年Q2')
assert.equal(context.budget_header.budget_year, '2026')
assert.equal(context.budget_header.budget_quarter, 'Q2')
assert.equal(context.budget_header.department, '市场部')
assert.equal(context.budget_header.cost_center, 'CC-4100')
assert.equal(context.budget_details[0].budget_subject_code, 'travel')
assert.equal(context.budget_details[0].expense_type, 'travel')
assert.equal(context.budget_details[0].expense_type_label, '差旅费')
assert.equal(context.budget_details[0].warning_threshold, '80%')
})
test('budget expense type options expose real expense type codes', () => {
const optionCodes = BUDGET_EXPENSE_TYPE_OPTIONS.map((item) => item.value)
assert.deepEqual(optionCodes, [
'travel',
'hotel',
'transport',
'meal',
'meeting',
'marketing',
'office',
'training',
'software',
'communication',
'welfare'
])
})
test('budget center exposes separate year and quarter dimensions', () => {
assert.deepEqual(BUDGET_YEAR_OPTIONS, ['2026', '2027', '2028'])
assert.deepEqual(BUDGET_QUARTER_OPTIONS, ['Q1', 'Q2', 'Q3', 'Q4'])
})