feat: 重构报销单AI预审流程并添加平台风险规则引擎
- 将AI验审改为AI预审,高风险不再拦截而是随单流转给审批人复核 - 新增平台风险规则评估引擎,支持事由过短、票据异常、重复发票等多种评估器 - 用户上下文增加部门信息(department_name),认证流程同步关联组织架构 - 规则scenario_json改为中文标签(差旅/费用科目),统一场景分类 - 新增orchestrator审核流程测试用例 - 前端更新审计视图、差旅报销等相关页面
This commit is contained in:
@@ -22,6 +22,7 @@ class CurrentUserContext:
|
|||||||
name: str
|
name: str
|
||||||
role_codes: list[str]
|
role_codes: list[str]
|
||||||
is_admin: bool
|
is_admin: bool
|
||||||
|
department_name: str = ""
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
def get_current_user(
|
||||||
@@ -41,6 +42,10 @@ def get_current_user(
|
|||||||
str | None,
|
str | None,
|
||||||
Header(description="是否管理员,支持 `true/false/1/0`。"),
|
Header(description="是否管理员,支持 `true/false/1/0`。"),
|
||||||
] = None,
|
] = None,
|
||||||
|
x_auth_department: Annotated[
|
||||||
|
str | None,
|
||||||
|
Header(description="当前登录人的所属部门。"),
|
||||||
|
] = None,
|
||||||
) -> CurrentUserContext:
|
) -> CurrentUserContext:
|
||||||
role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()]
|
role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()]
|
||||||
is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"}
|
is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
@@ -59,6 +64,7 @@ def get_current_user(
|
|||||||
name=name or username,
|
name=name or username,
|
||||||
role_codes=role_codes,
|
role_codes=role_codes,
|
||||||
is_admin=is_admin,
|
is_admin=is_admin,
|
||||||
|
department_name=(x_auth_department or "").strip(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class AuthUserRead(BaseModel):
|
|||||||
username: str
|
username: str
|
||||||
name: str
|
name: str
|
||||||
role: str
|
role: str
|
||||||
|
department: str = ""
|
||||||
|
departmentName: str = ""
|
||||||
position: str = ""
|
position: str = ""
|
||||||
grade: str = ""
|
grade: str = ""
|
||||||
roleCodes: list[str] = Field(default_factory=list)
|
roleCodes: list[str] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ LEGACY_RULE_CODES = (
|
|||||||
ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements"
|
ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements"
|
||||||
COMPANY_TRAVEL_RULE_VERSION = "v1.0.0"
|
COMPANY_TRAVEL_RULE_VERSION = "v1.0.0"
|
||||||
COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0"
|
COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0"
|
||||||
|
COMPANY_TRAVEL_RULE_SCENARIO_JSON = ("差旅",)
|
||||||
|
COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("费用科目",)
|
||||||
|
|
||||||
ATTACHMENT_RULE_RUNTIME_CONFIG = {
|
ATTACHMENT_RULE_RUNTIME_CONFIG = {
|
||||||
"kind": "policy_rule_draft",
|
"kind": "policy_rule_draft",
|
||||||
@@ -271,7 +273,7 @@ class AgentFoundationService:
|
|||||||
name="公司差旅费报销规则",
|
name="公司差旅费报销规则",
|
||||||
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
|
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
|
||||||
domain=AgentAssetDomain.EXPENSE.value,
|
domain=AgentAssetDomain.EXPENSE.value,
|
||||||
scenario_json=["expense", "travel_policy", "travel_standard"],
|
scenario_json=list(COMPANY_TRAVEL_RULE_SCENARIO_JSON),
|
||||||
owner="财务制度管理组",
|
owner="财务制度管理组",
|
||||||
reviewer="顾承宇",
|
reviewer="顾承宇",
|
||||||
status=AgentAssetStatus.ACTIVE.value,
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
@@ -284,6 +286,8 @@ class AgentFoundationService:
|
|||||||
"tag": "财务规则",
|
"tag": "财务规则",
|
||||||
"detail_mode": "spreadsheet",
|
"detail_mode": "spreadsheet",
|
||||||
"rule_library": FINANCE_RULES_LIBRARY,
|
"rule_library": FINANCE_RULES_LIBRARY,
|
||||||
|
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||||
|
"ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||||
"rule_template_label": "差旅报销 Excel 模板",
|
"rule_template_label": "差旅报销 Excel 模板",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -294,7 +298,7 @@ class AgentFoundationService:
|
|||||||
name="公司通信费报销规则",
|
name="公司通信费报销规则",
|
||||||
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
|
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
|
||||||
domain=AgentAssetDomain.EXPENSE.value,
|
domain=AgentAssetDomain.EXPENSE.value,
|
||||||
scenario_json=["expense", "communication_expense", "expense_standard"],
|
scenario_json=list(COMPANY_COMMUNICATION_RULE_SCENARIO_JSON),
|
||||||
owner="财务制度管理组",
|
owner="财务制度管理组",
|
||||||
reviewer="顾承宇",
|
reviewer="顾承宇",
|
||||||
status=AgentAssetStatus.ACTIVE.value,
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
@@ -307,6 +311,8 @@ class AgentFoundationService:
|
|||||||
"tag": "财务规则",
|
"tag": "财务规则",
|
||||||
"detail_mode": "spreadsheet",
|
"detail_mode": "spreadsheet",
|
||||||
"rule_library": FINANCE_RULES_LIBRARY,
|
"rule_library": FINANCE_RULES_LIBRARY,
|
||||||
|
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||||
|
"ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||||
"rule_template_label": "通信费报销 Excel 模板",
|
"rule_template_label": "通信费报销 Excel 模板",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1270,7 +1276,7 @@ class AgentFoundationService:
|
|||||||
name="公司差旅费报销规则",
|
name="公司差旅费报销规则",
|
||||||
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
|
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
|
||||||
domain=AgentAssetDomain.EXPENSE.value,
|
domain=AgentAssetDomain.EXPENSE.value,
|
||||||
scenario_json=["expense", "travel_policy", "travel_standard"],
|
scenario_json=list(COMPANY_TRAVEL_RULE_SCENARIO_JSON),
|
||||||
owner="财务制度管理组",
|
owner="财务制度管理组",
|
||||||
reviewer="顾承宇",
|
reviewer="顾承宇",
|
||||||
status=AgentAssetStatus.ACTIVE.value,
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
@@ -1280,6 +1286,8 @@ class AgentFoundationService:
|
|||||||
"enabled": True,
|
"enabled": True,
|
||||||
"tag": "财务规则",
|
"tag": "财务规则",
|
||||||
"detail_mode": "spreadsheet",
|
"detail_mode": "spreadsheet",
|
||||||
|
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||||
|
"ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||||
"rule_template_label": "差旅报销 Excel 模板",
|
"rule_template_label": "差旅报销 Excel 模板",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1290,7 +1298,7 @@ class AgentFoundationService:
|
|||||||
name="公司通信费报销规则",
|
name="公司通信费报销规则",
|
||||||
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
|
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
|
||||||
domain=AgentAssetDomain.EXPENSE.value,
|
domain=AgentAssetDomain.EXPENSE.value,
|
||||||
scenario_json=["expense", "communication_expense", "expense_standard"],
|
scenario_json=list(COMPANY_COMMUNICATION_RULE_SCENARIO_JSON),
|
||||||
owner="财务制度管理组",
|
owner="财务制度管理组",
|
||||||
reviewer="顾承宇",
|
reviewer="顾承宇",
|
||||||
status=AgentAssetStatus.ACTIVE.value,
|
status=AgentAssetStatus.ACTIVE.value,
|
||||||
@@ -1300,11 +1308,14 @@ class AgentFoundationService:
|
|||||||
"enabled": True,
|
"enabled": True,
|
||||||
"tag": "财务规则",
|
"tag": "财务规则",
|
||||||
"detail_mode": "spreadsheet",
|
"detail_mode": "spreadsheet",
|
||||||
|
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||||
|
"ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||||
"rule_template_label": "通信费报销 Excel 模板",
|
"rule_template_label": "通信费报销 Excel 模板",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if company_travel_rule is not None:
|
if company_travel_rule is not None:
|
||||||
|
company_travel_rule.scenario_json = list(COMPANY_TRAVEL_RULE_SCENARIO_JSON)
|
||||||
if not str(company_travel_rule.current_version or "").strip():
|
if not str(company_travel_rule.current_version or "").strip():
|
||||||
company_travel_rule.current_version = COMPANY_TRAVEL_RULE_VERSION
|
company_travel_rule.current_version = COMPANY_TRAVEL_RULE_VERSION
|
||||||
if not str(company_travel_rule.working_version or "").strip():
|
if not str(company_travel_rule.working_version or "").strip():
|
||||||
@@ -1321,6 +1332,8 @@ class AgentFoundationService:
|
|||||||
"tag": "财务规则",
|
"tag": "财务规则",
|
||||||
"detail_mode": "spreadsheet",
|
"detail_mode": "spreadsheet",
|
||||||
"rule_library": FINANCE_RULES_LIBRARY,
|
"rule_library": FINANCE_RULES_LIBRARY,
|
||||||
|
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||||
|
"ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
|
||||||
"rule_template_label": "差旅报销 Excel 模板",
|
"rule_template_label": "差旅报销 Excel 模板",
|
||||||
}
|
}
|
||||||
company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed(
|
company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed(
|
||||||
@@ -1351,6 +1364,7 @@ class AgentFoundationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if company_communication_rule is not None:
|
if company_communication_rule is not None:
|
||||||
|
company_communication_rule.scenario_json = list(COMPANY_COMMUNICATION_RULE_SCENARIO_JSON)
|
||||||
if not str(company_communication_rule.current_version or "").strip():
|
if not str(company_communication_rule.current_version or "").strip():
|
||||||
company_communication_rule.current_version = COMPANY_COMMUNICATION_RULE_VERSION
|
company_communication_rule.current_version = COMPANY_COMMUNICATION_RULE_VERSION
|
||||||
if not str(company_communication_rule.working_version or "").strip():
|
if not str(company_communication_rule.working_version or "").strip():
|
||||||
@@ -1367,6 +1381,8 @@ class AgentFoundationService:
|
|||||||
"tag": "财务规则",
|
"tag": "财务规则",
|
||||||
"detail_mode": "spreadsheet",
|
"detail_mode": "spreadsheet",
|
||||||
"rule_library": FINANCE_RULES_LIBRARY,
|
"rule_library": FINANCE_RULES_LIBRARY,
|
||||||
|
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||||
|
"ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
|
||||||
"rule_template_label": "通信费报销 Excel 模板",
|
"rule_template_label": "通信费报销 Excel 模板",
|
||||||
}
|
}
|
||||||
company_communication_rule_meta = self._ensure_company_communication_rule_spreadsheet_seed(
|
company_communication_rule_meta = self._ensure_company_communication_rule_spreadsheet_seed(
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class AuthenticatedUser:
|
|||||||
username: str
|
username: str
|
||||||
name: str
|
name: str
|
||||||
role: str
|
role: str
|
||||||
|
department: str
|
||||||
position: str
|
position: str
|
||||||
grade: str
|
grade: str
|
||||||
role_codes: list[str]
|
role_codes: list[str]
|
||||||
@@ -78,6 +79,7 @@ class AuthService:
|
|||||||
username=admin_username or admin_email,
|
username=admin_username or admin_email,
|
||||||
name=display_name,
|
name=display_name,
|
||||||
role="管理员",
|
role="管理员",
|
||||||
|
department="",
|
||||||
position="系统管理员",
|
position="系统管理员",
|
||||||
grade="",
|
grade="",
|
||||||
role_codes=["manager"],
|
role_codes=["manager"],
|
||||||
@@ -94,7 +96,7 @@ class AuthService:
|
|||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Employee)
|
select(Employee)
|
||||||
.options(selectinload(Employee.roles))
|
.options(selectinload(Employee.organization_unit), selectinload(Employee.roles))
|
||||||
.where(func.lower(Employee.email) == identifier.lower())
|
.where(func.lower(Employee.email) == identifier.lower())
|
||||||
)
|
)
|
||||||
employee = self.db.execute(stmt).scalars().first()
|
employee = self.db.execute(stmt).scalars().first()
|
||||||
@@ -120,6 +122,7 @@ class AuthService:
|
|||||||
username=employee.email,
|
username=employee.email,
|
||||||
name=employee.name,
|
name=employee.name,
|
||||||
role=ROLE_LABELS.get(primary_role_code, "使用者"),
|
role=ROLE_LABELS.get(primary_role_code, "使用者"),
|
||||||
|
department=employee.organization_unit.name if employee.organization_unit is not None else "",
|
||||||
position=employee.position,
|
position=employee.position,
|
||||||
grade=employee.grade,
|
grade=employee.grade,
|
||||||
role_codes=role_codes or ["user"],
|
role_codes=role_codes or ["user"],
|
||||||
@@ -134,6 +137,8 @@ class AuthService:
|
|||||||
username=user.username,
|
username=user.username,
|
||||||
name=user.name,
|
name=user.name,
|
||||||
role=user.role,
|
role=user.role,
|
||||||
|
department=user.department,
|
||||||
|
departmentName=user.department,
|
||||||
position=user.position,
|
position=user.position,
|
||||||
grade=user.grade,
|
grade=user.grade,
|
||||||
roleCodes=user.role_codes,
|
roleCodes=user.role_codes,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -122,6 +122,8 @@ RISK_KEYWORDS = ("风险", "异常", "重复", "超标", "超预算", "逾期",
|
|||||||
DRAFT_KEYWORDS = ("生成", "草稿", "起草", "拟一份", "创建", "发起", "准备")
|
DRAFT_KEYWORDS = ("生成", "草稿", "起草", "拟一份", "创建", "发起", "准备")
|
||||||
DRAFT_FOLLOW_UP_KEYWORDS = (
|
DRAFT_FOLLOW_UP_KEYWORDS = (
|
||||||
"继续",
|
"继续",
|
||||||
|
"下一步",
|
||||||
|
"核对",
|
||||||
"补充",
|
"补充",
|
||||||
"补一下",
|
"补一下",
|
||||||
"修改",
|
"修改",
|
||||||
@@ -138,6 +140,13 @@ DRAFT_FOLLOW_UP_KEYWORDS = (
|
|||||||
"日期是",
|
"日期是",
|
||||||
"时间是",
|
"时间是",
|
||||||
)
|
)
|
||||||
|
EXPENSE_REVIEW_ACTIONS = {
|
||||||
|
"save_draft",
|
||||||
|
"next_step",
|
||||||
|
"edit_review",
|
||||||
|
"link_to_existing_draft",
|
||||||
|
"create_new_claim_from_documents",
|
||||||
|
}
|
||||||
OPERATE_KEYWORDS = (
|
OPERATE_KEYWORDS = (
|
||||||
"直接付款",
|
"直接付款",
|
||||||
"帮我付款",
|
"帮我付款",
|
||||||
@@ -641,6 +650,11 @@ class SemanticOntologyService:
|
|||||||
value = str(context_json.get("conversation_scenario") or "").strip()
|
value = str(context_json.get("conversation_scenario") or "").strip()
|
||||||
if value in CONTEXTUAL_SCENARIOS:
|
if value in CONTEXTUAL_SCENARIOS:
|
||||||
return value
|
return value
|
||||||
|
review_action = str(context_json.get("review_action") or "").strip()
|
||||||
|
if review_action in EXPENSE_REVIEW_ACTIONS:
|
||||||
|
return "expense"
|
||||||
|
if str(context_json.get("draft_claim_id") or "").strip():
|
||||||
|
return "expense"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -739,6 +753,9 @@ class SemanticOntologyService:
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
context_scenario = self._resolve_context_scenario(context_json)
|
context_scenario = self._resolve_context_scenario(context_json)
|
||||||
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
|
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
|
||||||
|
review_action = str(context_json.get("review_action") or "").strip()
|
||||||
|
if review_action in EXPENSE_REVIEW_ACTIONS:
|
||||||
|
return True
|
||||||
if context_scenario != "expense" and not draft_claim_id:
|
if context_scenario != "expense" and not draft_claim_id:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -1682,7 +1699,8 @@ class SemanticOntologyService:
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
if scenario != "expense" or intent != "draft":
|
if scenario != "expense" or intent != "draft":
|
||||||
return False
|
return False
|
||||||
return str(context_json.get("review_action") or "").strip() == "save_draft"
|
review_action = str(context_json.get("review_action") or "").strip()
|
||||||
|
return review_action in EXPENSE_REVIEW_ACTIONS
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _display_slot_label(slot: str) -> str:
|
def _display_slot_label(slot: str) -> str:
|
||||||
|
|||||||
@@ -173,8 +173,10 @@ class OrchestratorService:
|
|||||||
task_asset=task_asset,
|
task_asset=task_asset,
|
||||||
)
|
)
|
||||||
selected_capability_codes = self._flatten_capability_codes(capabilities)
|
selected_capability_codes = self._flatten_capability_codes(capabilities)
|
||||||
|
is_expense_review_action = self._is_expense_review_action(context_json)
|
||||||
requires_confirmation = (
|
requires_confirmation = (
|
||||||
ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value
|
ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value
|
||||||
|
and not is_expense_review_action
|
||||||
)
|
)
|
||||||
|
|
||||||
route_json = {
|
route_json = {
|
||||||
@@ -526,7 +528,11 @@ class OrchestratorService:
|
|||||||
failed_tool_count=1 if degraded else 0,
|
failed_tool_count=1 if degraded else 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
next_step = self._resolve_next_step(ontology, payload.source)
|
next_step = self._resolve_next_step(
|
||||||
|
ontology,
|
||||||
|
payload.source,
|
||||||
|
context_json=context_json,
|
||||||
|
)
|
||||||
if next_step == "query_database":
|
if next_step == "query_database":
|
||||||
tool_payload, degraded = self._invoke_tool(
|
tool_payload, degraded = self._invoke_tool(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
@@ -662,7 +668,7 @@ class OrchestratorService:
|
|||||||
"degraded": True,
|
"degraded": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
if ontology.scenario == "expense":
|
if ontology.scenario == "expense" or self._is_expense_review_action(context_json):
|
||||||
tool_type = AgentToolType.DATABASE.value
|
tool_type = AgentToolType.DATABASE.value
|
||||||
tool_name = "database.expense_claims.save_or_submit"
|
tool_name = "database.expense_claims.save_or_submit"
|
||||||
executor = lambda: self.expense_claim_service.save_or_submit_from_ontology(
|
executor = lambda: self.expense_claim_service.save_or_submit_from_ontology(
|
||||||
@@ -782,7 +788,14 @@ class OrchestratorService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_next_step(ontology: OntologyParseResult, source: str) -> str:
|
def _resolve_next_step(
|
||||||
|
ontology: OntologyParseResult,
|
||||||
|
source: str,
|
||||||
|
*,
|
||||||
|
context_json: dict[str, Any] | None = None,
|
||||||
|
) -> str:
|
||||||
|
if OrchestratorService._is_expense_review_action(context_json or {}):
|
||||||
|
return "create_draft"
|
||||||
if ontology.clarification_required:
|
if ontology.clarification_required:
|
||||||
return "ask_clarification"
|
return "ask_clarification"
|
||||||
if ontology.intent == "draft":
|
if ontology.intent == "draft":
|
||||||
@@ -795,6 +808,17 @@ class OrchestratorService:
|
|||||||
return "query_database"
|
return "query_database"
|
||||||
return "create_draft"
|
return "create_draft"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_expense_review_action(context_json: dict[str, Any]) -> bool:
|
||||||
|
review_action = str((context_json or {}).get("review_action") or "").strip()
|
||||||
|
return review_action in {
|
||||||
|
"save_draft",
|
||||||
|
"next_step",
|
||||||
|
"edit_review",
|
||||||
|
"link_to_existing_draft",
|
||||||
|
"create_new_claim_from_documents",
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _flatten_capability_codes(
|
def _flatten_capability_codes(
|
||||||
capabilities: dict[str, list[AgentAssetListItem | AgentAssetRead]],
|
capabilities: dict[str, list[AgentAssetListItem | AgentAssetRead]],
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ class UserAgentService:
|
|||||||
query_payload = self._build_query_payload(payload)
|
query_payload = self._build_query_payload(payload)
|
||||||
draft_payload = (
|
draft_payload = (
|
||||||
self._build_draft_payload(payload)
|
self._build_draft_payload(payload)
|
||||||
if payload.ontology.intent == "draft"
|
if self._should_build_draft_payload(payload)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
review_payload = self._build_review_payload(
|
review_payload = self._build_review_payload(
|
||||||
@@ -1683,7 +1683,10 @@ class UserAgentService:
|
|||||||
if not risk_flags and not platform_messages:
|
if not risk_flags and not platform_messages:
|
||||||
return "当前未识别到明确风险标签,建议继续查看原始明细或补充更多上下文。"
|
return "当前未识别到明确风险标签,建议继续查看原始明细或补充更多上下文。"
|
||||||
|
|
||||||
reasons = [RISK_REASON_MAP.get(flag, f"{flag} 需要人工进一步确认。") for flag in risk_flags]
|
reasons = [
|
||||||
|
f"{flag}:{RISK_REASON_MAP.get(flag, f'{flag} 需要人工进一步确认。')}"
|
||||||
|
for flag in risk_flags
|
||||||
|
]
|
||||||
if platform_messages:
|
if platform_messages:
|
||||||
reasons.extend(platform_messages)
|
reasons.extend(platform_messages)
|
||||||
citation_text = (
|
citation_text = (
|
||||||
@@ -1764,6 +1767,17 @@ class UserAgentService:
|
|||||||
approval_stage=approval_stage,
|
approval_stage=approval_stage,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _should_build_draft_payload(payload: UserAgentRequest) -> bool:
|
||||||
|
if payload.ontology.intent == "draft":
|
||||||
|
return True
|
||||||
|
if payload.ontology.scenario != "expense":
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
str(payload.tool_payload.get(key) or "").strip()
|
||||||
|
for key in ("claim_id", "claim_no", "status")
|
||||||
|
)
|
||||||
|
|
||||||
def _build_suggested_actions(
|
def _build_suggested_actions(
|
||||||
self,
|
self,
|
||||||
payload: UserAgentRequest,
|
payload: UserAgentRequest,
|
||||||
@@ -1868,6 +1882,7 @@ class UserAgentService:
|
|||||||
payload,
|
payload,
|
||||||
slot_cards=slot_cards,
|
slot_cards=slot_cards,
|
||||||
)
|
)
|
||||||
|
submission_blocked = bool(payload.tool_payload.get("submission_blocked"))
|
||||||
risk_briefs = self._build_review_risk_briefs(
|
risk_briefs = self._build_review_risk_briefs(
|
||||||
payload,
|
payload,
|
||||||
citations=citations,
|
citations=citations,
|
||||||
@@ -1877,7 +1892,7 @@ class UserAgentService:
|
|||||||
association_choice_pending = self._is_review_association_choice_pending(payload)
|
association_choice_pending = self._is_review_association_choice_pending(payload)
|
||||||
can_proceed = (
|
can_proceed = (
|
||||||
False
|
False
|
||||||
if association_choice_pending
|
if association_choice_pending or submission_blocked
|
||||||
else self._can_proceed_review(
|
else self._can_proceed_review(
|
||||||
payload,
|
payload,
|
||||||
missing_slot_keys=missing_slot_keys,
|
missing_slot_keys=missing_slot_keys,
|
||||||
@@ -2157,6 +2172,15 @@ class UserAgentService:
|
|||||||
claim_groups: list[UserAgentReviewClaimGroup],
|
claim_groups: list[UserAgentReviewClaimGroup],
|
||||||
) -> list[UserAgentReviewRiskBrief]:
|
) -> list[UserAgentReviewRiskBrief]:
|
||||||
briefs: list[UserAgentReviewRiskBrief] = []
|
briefs: list[UserAgentReviewRiskBrief] = []
|
||||||
|
for reason in self._resolve_submission_blocked_reasons(payload):
|
||||||
|
briefs.append(
|
||||||
|
UserAgentReviewRiskBrief(
|
||||||
|
title="AI预审未通过",
|
||||||
|
level="high",
|
||||||
|
content=reason,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
employee_name = self._collect_entity_values(payload).get("employee_name") or str(
|
employee_name = self._collect_entity_values(payload).get("employee_name") or str(
|
||||||
payload.context_json.get("name") or ""
|
payload.context_json.get("name") or ""
|
||||||
).strip()
|
).strip()
|
||||||
@@ -2229,6 +2253,36 @@ class UserAgentService:
|
|||||||
|
|
||||||
return briefs[:4]
|
return briefs[:4]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_submission_blocked_reasons(payload: UserAgentRequest) -> list[str]:
|
||||||
|
raw_reasons = payload.tool_payload.get("submission_blocked_reasons")
|
||||||
|
if raw_reasons is None:
|
||||||
|
raw_reasons = payload.tool_payload.get("missing_fields")
|
||||||
|
|
||||||
|
reasons: list[str] = []
|
||||||
|
if isinstance(raw_reasons, list):
|
||||||
|
reasons.extend(str(item or "").strip() for item in raw_reasons)
|
||||||
|
elif isinstance(raw_reasons, str):
|
||||||
|
reasons.extend(
|
||||||
|
item.strip()
|
||||||
|
for item in re.split(r"[;;\n]+", raw_reasons)
|
||||||
|
if item.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not reasons:
|
||||||
|
message = str(payload.tool_payload.get("message") or "").strip()
|
||||||
|
prefix = "提交前请先补全信息:"
|
||||||
|
if message.startswith(prefix):
|
||||||
|
message = message[len(prefix):].strip()
|
||||||
|
if message:
|
||||||
|
reasons.extend(
|
||||||
|
item.strip()
|
||||||
|
for item in re.split(r"[;;\n]+", message)
|
||||||
|
if item.strip() and not item.strip().startswith("AI预审暂未通过")
|
||||||
|
)
|
||||||
|
|
||||||
|
return list(dict.fromkeys(reason for reason in reasons if reason))
|
||||||
|
|
||||||
def _build_review_confirmation_actions(
|
def _build_review_confirmation_actions(
|
||||||
self,
|
self,
|
||||||
payload: UserAgentRequest,
|
payload: UserAgentRequest,
|
||||||
@@ -2383,6 +2437,16 @@ class UserAgentService:
|
|||||||
stage_text = draft_payload.approval_stage or "审批中"
|
stage_text = draft_payload.approval_stage or "审批中"
|
||||||
return f"报销单 {draft_payload.claim_no or ''} 已提交,当前节点为 {stage_text}。".strip()
|
return f"报销单 {draft_payload.claim_no or ''} 已提交,当前节点为 {stage_text}。".strip()
|
||||||
if payload.tool_payload.get("submission_blocked"):
|
if payload.tool_payload.get("submission_blocked"):
|
||||||
|
reasons = self._resolve_submission_blocked_reasons(payload)
|
||||||
|
if reasons:
|
||||||
|
reason_lines = "\n".join(
|
||||||
|
f"{index}. {reason}" for index, reason in enumerate(reasons, start=1)
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"AI预审暂未通过,所以还没有提交到审批人。\n"
|
||||||
|
f"{reason_lines}\n"
|
||||||
|
"请先处理以上项目;处理完成后再点继续下一步。"
|
||||||
|
)
|
||||||
return str(payload.tool_payload.get("message") or "").strip() or "当前报销单暂时还不能提交审批。"
|
return str(payload.tool_payload.get("message") or "").strip() or "当前报销单暂时还不能提交审批。"
|
||||||
return (
|
return (
|
||||||
f"{self._build_review_intent_summary(payload, slot_cards=review_payload.slot_cards, claim_groups=review_payload.claim_groups)} "
|
f"{self._build_review_intent_summary(payload, slot_cards=review_payload.slot_cards, claim_groups=review_payload.claim_groups)} "
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ from app.schemas.agent_asset import (
|
|||||||
AgentAssetVersionCreate,
|
AgentAssetVersionCreate,
|
||||||
)
|
)
|
||||||
from app.services.agent_asset_spreadsheet import (
|
from app.services.agent_asset_spreadsheet import (
|
||||||
|
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||||||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||||||
|
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||||
FINANCE_RULES_LIBRARY,
|
FINANCE_RULES_LIBRARY,
|
||||||
)
|
)
|
||||||
@@ -145,6 +147,26 @@ def test_agent_asset_service_seeds_all_foundation_asset_types() -> None:
|
|||||||
assert len(service.list_assets(asset_type=AgentAssetType.TASK.value)) >= 3
|
assert len(service.list_assets(asset_type=AgentAssetType.TASK.value)) >= 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_finance_rules_use_risk_rule_scenario_categories() -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
service = AgentAssetService(db)
|
||||||
|
|
||||||
|
rules = service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||||
|
travel_rule = next(item for item in rules if item.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
||||||
|
communication_rule = next(
|
||||||
|
item for item in rules if item.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE
|
||||||
|
)
|
||||||
|
travel_config = travel_rule.config_json or {}
|
||||||
|
communication_config = communication_rule.config_json or {}
|
||||||
|
|
||||||
|
assert travel_rule.scenario_json == ["差旅"]
|
||||||
|
assert travel_config["scenario_category"] == "差旅"
|
||||||
|
assert travel_config["ai_review_category"] == "差旅"
|
||||||
|
assert communication_rule.scenario_json == ["费用科目"]
|
||||||
|
assert communication_config["scenario_category"] == "费用科目"
|
||||||
|
assert communication_config["ai_review_category"] == "费用科目"
|
||||||
|
|
||||||
|
|
||||||
def test_agent_asset_service_can_activate_rule_after_review() -> None:
|
def test_agent_asset_service_can_activate_rule_after_review() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
service = AgentAssetService(db)
|
service = AgentAssetService(db)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.api.deps import CurrentUserContext
|
|||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.models.employee import Employee
|
from app.models.employee import Employee
|
||||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||||
|
from app.models.organization import OrganizationUnit
|
||||||
from app.schemas.ontology import OntologyParseRequest
|
from app.schemas.ontology import OntologyParseRequest
|
||||||
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
|
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
|
||||||
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate
|
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate
|
||||||
@@ -76,6 +77,16 @@ def test_validate_claim_for_submission_allows_office_claim_without_location() ->
|
|||||||
assert not any("缺少地点" in item for item in issues)
|
assert not any("缺少地点" in item for item in issues)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_claim_for_submission_allows_transport_claim_without_location() -> None:
|
||||||
|
service = ExpenseClaimService.__new__(ExpenseClaimService)
|
||||||
|
claim = build_claim(expense_type="transport", location="待补充")
|
||||||
|
|
||||||
|
issues = service._validate_claim_for_submission(claim)
|
||||||
|
|
||||||
|
assert "业务地点未完善" not in issues
|
||||||
|
assert not any("缺少地点" in item for item in issues)
|
||||||
|
|
||||||
|
|
||||||
def test_validate_claim_for_submission_still_requires_location_for_travel_claim() -> None:
|
def test_validate_claim_for_submission_still_requires_location_for_travel_claim() -> None:
|
||||||
service = ExpenseClaimService.__new__(ExpenseClaimService)
|
service = ExpenseClaimService.__new__(ExpenseClaimService)
|
||||||
claim = build_claim(expense_type="travel", location="待补充")
|
claim = build_claim(expense_type="travel", location="待补充")
|
||||||
@@ -666,7 +677,54 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
|
|||||||
assert submitted.submitted_at is not None
|
assert submitted.submitted_at is not None
|
||||||
|
|
||||||
|
|
||||||
def test_submit_claim_blocks_high_risk_attachment_at_ai_review(monkeypatch, tmp_path) -> None:
|
def test_submit_claim_backfills_department_from_current_employee() -> None:
|
||||||
|
current_user = CurrentUserContext(
|
||||||
|
username="emp-dept@example.com",
|
||||||
|
name="张三",
|
||||||
|
role_codes=[],
|
||||||
|
is_admin=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with build_session() as db:
|
||||||
|
department = OrganizationUnit(
|
||||||
|
unit_code="D7200",
|
||||||
|
name="销售部",
|
||||||
|
)
|
||||||
|
manager = Employee(
|
||||||
|
employee_no="E7200",
|
||||||
|
name="李经理",
|
||||||
|
email="manager-dept@example.com",
|
||||||
|
)
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="E7201",
|
||||||
|
name="张三",
|
||||||
|
email="emp-dept@example.com",
|
||||||
|
organization_unit=department,
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
|
claim = build_claim(expense_type="transport", location="待补充")
|
||||||
|
claim.employee = None
|
||||||
|
claim.employee_id = None
|
||||||
|
claim.employee_name = "张三"
|
||||||
|
claim.department_id = None
|
||||||
|
claim.department_name = "待补充"
|
||||||
|
claim.items[0].item_location = "待补充"
|
||||||
|
db.add_all([department, manager, employee, claim])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
|
||||||
|
|
||||||
|
assert submitted is not None
|
||||||
|
assert submitted.status == "submitted"
|
||||||
|
assert submitted.department_id == department.id
|
||||||
|
assert submitted.department_name == "销售部"
|
||||||
|
assert submitted.approval_stage == "直属领导审批"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_claim_routes_high_risk_attachment_to_approval_with_review_flag(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
current_user = CurrentUserContext(
|
current_user = CurrentUserContext(
|
||||||
username="emp-risk@example.com",
|
username="emp-risk@example.com",
|
||||||
name="张三",
|
name="张三",
|
||||||
@@ -732,16 +790,19 @@ def test_submit_claim_blocks_high_risk_attachment_at_ai_review(monkeypatch, tmp_
|
|||||||
submitted = service.submit_claim(claim.id, current_user)
|
submitted = service.submit_claim(claim.id, current_user)
|
||||||
|
|
||||||
assert submitted is not None
|
assert submitted is not None
|
||||||
assert submitted.status == "supplement"
|
assert submitted.status == "submitted"
|
||||||
assert submitted.approval_stage == "待补充"
|
assert submitted.approval_stage == "直属领导审批"
|
||||||
assert submitted.submitted_at is None
|
assert submitted.submitted_at is not None
|
||||||
assert any(
|
assert any(
|
||||||
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "submission_review"
|
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "submission_review"
|
||||||
for flag in list(submitted.risk_flags_json or [])
|
for flag in list(submitted.risk_flags_json or [])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_submit_claim_blocks_travel_route_mismatch_without_explanation(monkeypatch, tmp_path) -> None:
|
def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
current_user = CurrentUserContext(
|
current_user = CurrentUserContext(
|
||||||
username="emp-travel@example.com",
|
username="emp-travel@example.com",
|
||||||
name="张三",
|
name="张三",
|
||||||
@@ -876,8 +937,8 @@ def test_submit_claim_blocks_travel_route_mismatch_without_explanation(monkeypat
|
|||||||
submitted = service.submit_claim(claim.id, current_user)
|
submitted = service.submit_claim(claim.id, current_user)
|
||||||
|
|
||||||
assert submitted is not None
|
assert submitted is not None
|
||||||
assert submitted.status == "supplement"
|
assert submitted.status == "submitted"
|
||||||
assert submitted.approval_stage == "待补充"
|
assert submitted.approval_stage == "直属领导审批"
|
||||||
assert any(
|
assert any(
|
||||||
isinstance(flag, dict)
|
isinstance(flag, dict)
|
||||||
and str(flag.get("source") or "").strip() == "submission_review"
|
and str(flag.get("source") or "").strip() == "submission_review"
|
||||||
@@ -889,7 +950,10 @@ def test_submit_claim_blocks_travel_route_mismatch_without_explanation(monkeypat
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_submit_claim_blocks_hotel_amount_over_travel_policy_without_explanation(monkeypatch, tmp_path) -> None:
|
def test_submit_claim_routes_hotel_amount_over_travel_policy_to_approval_with_review_flag(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
current_user = CurrentUserContext(
|
current_user = CurrentUserContext(
|
||||||
username="emp-hotel@example.com",
|
username="emp-hotel@example.com",
|
||||||
name="张三",
|
name="张三",
|
||||||
@@ -1024,8 +1088,8 @@ def test_submit_claim_blocks_hotel_amount_over_travel_policy_without_explanation
|
|||||||
submitted = service.submit_claim(claim.id, current_user)
|
submitted = service.submit_claim(claim.id, current_user)
|
||||||
|
|
||||||
assert submitted is not None
|
assert submitted is not None
|
||||||
assert submitted.status == "supplement"
|
assert submitted.status == "submitted"
|
||||||
assert submitted.approval_stage == "待补充"
|
assert submitted.approval_stage == "直属领导审批"
|
||||||
assert any(
|
assert any(
|
||||||
isinstance(flag, dict)
|
isinstance(flag, dict)
|
||||||
and str(flag.get("source") or "").strip() == "submission_review"
|
and str(flag.get("source") or "").strip() == "submission_review"
|
||||||
|
|||||||
@@ -310,7 +310,9 @@ def test_semantic_ontology_service_keeps_travel_amount_follow_up_in_knowledge_qu
|
|||||||
assert result.clarification_required is False
|
assert result.clarification_required is False
|
||||||
|
|
||||||
|
|
||||||
def test_semantic_ontology_service_rejects_draft_intent_inside_knowledge_session(monkeypatch) -> None:
|
def test_semantic_ontology_service_rejects_draft_intent_inside_knowledge_session(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
service = SemanticOntologyService(db)
|
service = SemanticOntologyService(db)
|
||||||
@@ -348,6 +350,28 @@ def test_semantic_ontology_service_rejects_draft_intent_inside_knowledge_session
|
|||||||
assert result.clarification_question is None
|
assert result.clarification_question is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_next_step_context_inherits_expense_draft_flow() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
result = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query="我已核对右侧识别结果,请进入下一步。",
|
||||||
|
user_id="pytest",
|
||||||
|
context_json={
|
||||||
|
"review_action": "next_step",
|
||||||
|
"draft_claim_id": "claim-1",
|
||||||
|
"attachment_count": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.scenario == "expense"
|
||||||
|
assert result.intent == "draft"
|
||||||
|
assert result.permission.level == "draft_write"
|
||||||
|
assert result.clarification_required is False
|
||||||
|
assert result.clarification_question is None
|
||||||
|
|
||||||
|
|
||||||
def test_semantic_ontology_service_prefers_expense_for_customer_entertainment_narrative() -> None:
|
def test_semantic_ontology_service_prefers_expense_for_customer_entertainment_narrative() -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
|
|||||||
175
server/tests/test_orchestrator_review_flow.py
Normal file
175
server/tests/test_orchestrator_review_flow.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.models.employee import Employee
|
||||||
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||||
|
from app.schemas.orchestrator import OrchestratorRequest
|
||||||
|
from app.services.orchestrator import OrchestratorService
|
||||||
|
|
||||||
|
|
||||||
|
def build_session_factory() -> sessionmaker[Session]:
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_next_step_run_submits_existing_claim_and_returns_draft_payload(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||||
|
lambda *_args, **_kwargs: None,
|
||||||
|
)
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
manager = Employee(
|
||||||
|
employee_no="E9000",
|
||||||
|
name="李经理",
|
||||||
|
email="manager-next@example.com",
|
||||||
|
)
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="E9001",
|
||||||
|
name="张三",
|
||||||
|
email="emp-next@example.com",
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
id="claim-next-step",
|
||||||
|
claim_no="EXP-202605-001",
|
||||||
|
employee=employee,
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name="张三",
|
||||||
|
department_name="销售部",
|
||||||
|
expense_type="office",
|
||||||
|
reason="采购办公用品",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("128.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=datetime(2026, 5, 20, 9, 0, tzinfo=UTC),
|
||||||
|
status="draft",
|
||||||
|
approval_stage="待提交",
|
||||||
|
items=[
|
||||||
|
ExpenseClaimItem(
|
||||||
|
item_date=date(2026, 5, 20),
|
||||||
|
item_type="office",
|
||||||
|
item_reason="采购办公用品",
|
||||||
|
item_location="上海",
|
||||||
|
item_amount=Decimal("128.00"),
|
||||||
|
invoice_id="office-invoice.png",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db.add_all([manager, employee, claim])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = OrchestratorService(db).run(
|
||||||
|
OrchestratorRequest(
|
||||||
|
source="user_message",
|
||||||
|
user_id="emp-next@example.com",
|
||||||
|
message="我已核对右侧识别结果,请进入下一步。",
|
||||||
|
context_json={
|
||||||
|
"review_action": "next_step",
|
||||||
|
"draft_claim_id": claim.id,
|
||||||
|
"attachment_count": 1,
|
||||||
|
"name": "张三",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.refresh(claim)
|
||||||
|
assert response.status == "succeeded"
|
||||||
|
assert response.requires_confirmation is False
|
||||||
|
assert response.result["draft_payload"]["status"] == "submitted"
|
||||||
|
assert response.result["draft_payload"]["approval_stage"] == "直属领导审批"
|
||||||
|
assert claim.status == "submitted"
|
||||||
|
assert claim.approval_stage == "直属领导审批"
|
||||||
|
assert claim.submitted_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.runtime_chat.RuntimeChatService.complete",
|
||||||
|
lambda *_args, **_kwargs: None,
|
||||||
|
)
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="E9011",
|
||||||
|
name="张三",
|
||||||
|
email="emp-blocked@example.com",
|
||||||
|
)
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
id="claim-next-step-blocked",
|
||||||
|
claim_no="EXP-202605-002",
|
||||||
|
employee=employee,
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name="张三",
|
||||||
|
department_name="待补充",
|
||||||
|
expense_type="office",
|
||||||
|
reason="采购办公用品",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("128.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=datetime(2026, 5, 20, 9, 0, tzinfo=UTC),
|
||||||
|
status="draft",
|
||||||
|
approval_stage="待提交",
|
||||||
|
items=[
|
||||||
|
ExpenseClaimItem(
|
||||||
|
item_date=date(2026, 5, 20),
|
||||||
|
item_type="office",
|
||||||
|
item_reason="采购办公用品",
|
||||||
|
item_location="上海",
|
||||||
|
item_amount=Decimal("128.00"),
|
||||||
|
invoice_id="office-invoice.png",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db.add_all([employee, claim])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = OrchestratorService(db).run(
|
||||||
|
OrchestratorRequest(
|
||||||
|
source="user_message",
|
||||||
|
user_id="emp-blocked@example.com",
|
||||||
|
message="我已核对右侧识别结果,请进入下一步。",
|
||||||
|
context_json={
|
||||||
|
"review_action": "next_step",
|
||||||
|
"draft_claim_id": claim.id,
|
||||||
|
"attachment_count": 1,
|
||||||
|
"name": "张三",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = response.result
|
||||||
|
review_payload = result["review_payload"]
|
||||||
|
actions = {
|
||||||
|
str(item.get("action_type") or "").strip()
|
||||||
|
for item in review_payload["confirmation_actions"]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert response.status == "succeeded"
|
||||||
|
assert result["draft_payload"]["status"] == "draft"
|
||||||
|
assert "AI预审暂未通过" in result["answer"]
|
||||||
|
assert "所属部门未完善" in result["answer"]
|
||||||
|
assert "next_step" not in actions
|
||||||
|
assert "save_draft" in actions
|
||||||
|
assert any(
|
||||||
|
"所属部门未完善" in str(item.get("content") or "")
|
||||||
|
for item in review_payload["risk_briefs"]
|
||||||
|
)
|
||||||
@@ -757,6 +757,43 @@ def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_agent_returns_submitted_draft_payload_for_review_next_step() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
context_json = {
|
||||||
|
"review_action": "next_step",
|
||||||
|
"draft_claim_id": "claim-1",
|
||||||
|
"attachment_count": 1,
|
||||||
|
}
|
||||||
|
ontology = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query="我已核对右侧识别结果,请进入下一步。",
|
||||||
|
user_id="pytest",
|
||||||
|
context_json=context_json,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
response = UserAgentService(db).respond(
|
||||||
|
UserAgentRequest(
|
||||||
|
run_id=ontology.run_id,
|
||||||
|
user_id="pytest",
|
||||||
|
message="我已核对右侧识别结果,请进入下一步。",
|
||||||
|
ontology=ontology,
|
||||||
|
context_json=context_json,
|
||||||
|
tool_payload={
|
||||||
|
"claim_id": "claim-1",
|
||||||
|
"claim_no": "BX202605200001",
|
||||||
|
"status": "submitted",
|
||||||
|
"approval_stage": "直属领导审批",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.draft_payload is not None
|
||||||
|
assert response.draft_payload.status == "submitted"
|
||||||
|
assert response.draft_payload.confirmation_required is False
|
||||||
|
assert "当前节点为 直属领导审批" in response.answer
|
||||||
|
|
||||||
|
|
||||||
def test_user_agent_builds_review_payload_for_multi_document_expense_flow() -> None:
|
def test_user_agent_builds_review_payload_for_multi_document_expense_flow() -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { useNavigation, navItems } from './useNavigation.js'
|
import { useNavigation, navItems } from './useNavigation.js'
|
||||||
@@ -127,6 +127,14 @@ export function useAppShell() {
|
|||||||
const logDetailMode = computed(() => route.name === 'app-log-detail')
|
const logDetailMode = computed(() => route.name === 'app-log-detail')
|
||||||
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
|
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
|
||||||
|
|
||||||
|
const requestsListActive = computed(() => activeView.value === 'requests' && !detailMode.value)
|
||||||
|
|
||||||
|
watch(requestsListActive, (isActive, wasActive) => {
|
||||||
|
if (isActive && !wasActive) {
|
||||||
|
void reloadRequests()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const topBarView = computed(() => {
|
const topBarView = computed(() => {
|
||||||
if (detailMode.value) {
|
if (detailMode.value) {
|
||||||
return {
|
return {
|
||||||
@@ -243,7 +251,7 @@ export function useAppShell() {
|
|||||||
smartEntryOpen.value = false
|
smartEntryOpen.value = false
|
||||||
await reloadRequests()
|
await reloadRequests()
|
||||||
if (status === 'submitted') {
|
if (status === 'submitted') {
|
||||||
toast(`${claimNo || '该'}单据已完成 AI验审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`)
|
toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`)
|
||||||
} else {
|
} else {
|
||||||
toast(`${claimNo || '该'}单据已保存到草稿,请到报销页面查看。`)
|
toast(`${claimNo || '该'}单据已保存到草稿,请到报销页面查看。`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,6 @@ const EXPENSE_TYPE_LABELS = {
|
|||||||
|
|
||||||
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
||||||
'travel',
|
'travel',
|
||||||
'hotel',
|
|
||||||
'transport',
|
|
||||||
'meal',
|
|
||||||
'meeting',
|
'meeting',
|
||||||
'entertainment'
|
'entertainment'
|
||||||
])
|
])
|
||||||
@@ -26,7 +23,7 @@ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
|||||||
const REIMBURSEMENT_PROGRESS_LABELS = [
|
const REIMBURSEMENT_PROGRESS_LABELS = [
|
||||||
'保存草稿',
|
'保存草稿',
|
||||||
'待提交',
|
'待提交',
|
||||||
'AI验审',
|
'AI预审',
|
||||||
'直属领导审批',
|
'直属领导审批',
|
||||||
'财务审批',
|
'财务审批',
|
||||||
'归档入账'
|
'归档入账'
|
||||||
@@ -135,10 +132,10 @@ function resolveWorkflowNode(claim, approvalMeta) {
|
|||||||
|
|
||||||
if (rawNode) {
|
if (rawNode) {
|
||||||
if (rawNode === '审批流转') {
|
if (rawNode === '审批流转') {
|
||||||
return 'AI验审'
|
return 'AI预审'
|
||||||
}
|
}
|
||||||
if (rawNode === '待补充') {
|
if (rawNode === '待补充') {
|
||||||
return approvalMeta.key === 'draft' ? '待提交' : 'AI验审'
|
return approvalMeta.key === 'draft' ? '待提交' : 'AI预审'
|
||||||
}
|
}
|
||||||
return rawNode
|
return rawNode
|
||||||
}
|
}
|
||||||
@@ -151,7 +148,7 @@ function resolveWorkflowNode(claim, approvalMeta) {
|
|||||||
return '归档入账'
|
return '归档入账'
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'AI验审'
|
return 'AI预审'
|
||||||
}
|
}
|
||||||
|
|
||||||
function stringifyRiskFlag(value) {
|
function stringifyRiskFlag(value) {
|
||||||
@@ -220,7 +217,7 @@ function resolveProgressCurrentIndex(approvalMeta, workflowNode) {
|
|||||||
) {
|
) {
|
||||||
return 3
|
return 3
|
||||||
}
|
}
|
||||||
if (normalizedNode.includes('AI验审') || normalizedNode.includes('审批流转')) {
|
if (normalizedNode.includes('AI预审') || normalizedNode.includes('AI验审') || normalizedNode.includes('审批流转')) {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
if (normalizedNode.includes('待提交')) {
|
if (normalizedNode.includes('待提交')) {
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ function buildAnonymousUser() {
|
|||||||
username: '',
|
username: '',
|
||||||
name: '',
|
name: '',
|
||||||
role: '',
|
role: '',
|
||||||
|
department: '',
|
||||||
|
departmentName: '',
|
||||||
position: '',
|
position: '',
|
||||||
grade: '',
|
grade: '',
|
||||||
roleCodes: [],
|
roleCodes: [],
|
||||||
@@ -107,6 +109,8 @@ function buildLegacyAdminUser(username = '') {
|
|||||||
username: normalized,
|
username: normalized,
|
||||||
name,
|
name,
|
||||||
role: DEFAULT_USER_ROLE,
|
role: DEFAULT_USER_ROLE,
|
||||||
|
department: '',
|
||||||
|
departmentName: '',
|
||||||
position: DEFAULT_USER_ROLE,
|
position: DEFAULT_USER_ROLE,
|
||||||
grade: '',
|
grade: '',
|
||||||
roleCodes: ['manager'],
|
roleCodes: ['manager'],
|
||||||
@@ -135,6 +139,8 @@ function readStoredUser() {
|
|||||||
username,
|
username,
|
||||||
name,
|
name,
|
||||||
role: String(payload.role || DEFAULT_USER_ROLE),
|
role: String(payload.role || DEFAULT_USER_ROLE),
|
||||||
|
department: String(payload.department || payload.departmentName || ''),
|
||||||
|
departmentName: String(payload.departmentName || payload.department || ''),
|
||||||
position: String(payload.position || ''),
|
position: String(payload.position || ''),
|
||||||
grade: String(payload.grade || ''),
|
grade: String(payload.grade || ''),
|
||||||
roleCodes,
|
roleCodes,
|
||||||
|
|||||||
@@ -47,8 +47,10 @@ function readCurrentUserHeaders() {
|
|||||||
const name = String(payload?.name || username).trim()
|
const name = String(payload?.name || username).trim()
|
||||||
const roleCodes = Array.isArray(payload?.roleCodes) ? payload.roleCodes.filter(Boolean) : []
|
const roleCodes = Array.isArray(payload?.roleCodes) ? payload.roleCodes.filter(Boolean) : []
|
||||||
const isAdmin = Boolean(payload?.isAdmin)
|
const isAdmin = Boolean(payload?.isAdmin)
|
||||||
|
const department = String(payload?.department || payload?.departmentName || '').trim()
|
||||||
const safeUsername = pickSafeHeaderValue(username)
|
const safeUsername = pickSafeHeaderValue(username)
|
||||||
const safeName = pickSafeHeaderValue(name)
|
const safeName = pickSafeHeaderValue(name)
|
||||||
|
const safeDepartment = pickSafeHeaderValue(department)
|
||||||
|
|
||||||
if (!safeUsername && !safeName) {
|
if (!safeUsername && !safeName) {
|
||||||
return {}
|
return {}
|
||||||
@@ -67,6 +69,10 @@ function readCurrentUserHeaders() {
|
|||||||
headers['x-auth-name'] = safeName
|
headers['x-auth-name'] = safeName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (safeDepartment) {
|
||||||
|
headers['x-auth-department'] = safeDepartment
|
||||||
|
}
|
||||||
|
|
||||||
return headers
|
return headers
|
||||||
} catch {
|
} catch {
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -64,38 +64,6 @@
|
|||||||
<div class="hero-stat">
|
<div class="hero-stat">
|
||||||
<span>{{ selectedSkillIsRule ? '当前展示版本' : '当前版本' }}</span>
|
<span>{{ selectedSkillIsRule ? '当前展示版本' : '当前版本' }}</span>
|
||||||
<strong>{{ selectedSkill.displayVersion || selectedSkill.version }}</strong>
|
<strong>{{ selectedSkill.displayVersion || selectedSkill.version }}</strong>
|
||||||
</div>
|
|
||||||
<div class="hero-stat">
|
|
||||||
<span>最近更新</span>
|
|
||||||
<strong>{{ selectedSkill.updatedAt }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-if="detailError" class="detail-inline-state panel error">
|
|
||||||
<i class="mdi mdi-alert-circle-outline"></i>
|
|
||||||
<div>
|
|
||||||
<strong>资产详情加载失败</strong>
|
|
||||||
<p>{{ detailError }}</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-else-if="detailLoading && selectedSkill.loading" class="detail-inline-state panel">
|
|
||||||
<i class="mdi mdi-loading mdi-spin"></i>
|
|
||||||
<div>
|
|
||||||
<strong>正在加载资产详情</strong>
|
|
||||||
<p>列表数据已就绪,正在补充版本、审核和运行信息。</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
v-else-if="selectedSkill.usesSpreadsheetRule"
|
|
||||||
class="spreadsheet-editor-shell panel"
|
|
||||||
>
|
|
||||||
<header class="spreadsheet-editor-head">
|
|
||||||
<div class="spreadsheet-editor-title">
|
|
||||||
<div class="skill-badge" :class="selectedSkill.badgeTone">{{ selectedSkill.typeLabel }}</div>
|
|
||||||
<div>
|
|
||||||
<h2>{{ selectedSkill.name }}</h2>
|
<h2>{{ selectedSkill.name }}</h2>
|
||||||
<p>{{ selectedSkill.summary || '当前资产尚未补充说明。' }}</p>
|
<p>{{ selectedSkill.summary || '当前资产尚未补充说明。' }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -832,7 +800,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="showStatusFilter"
|
v-if="showStatusFilter"
|
||||||
class="picker-filter"
|
class="picker-filter"
|
||||||
:class="{ open: activeFilterPopover === 'status' }"
|
:class="{ open: activeFilterPopover === 'status' }"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1259,7 +1259,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<span class="assistant-badge">修改识别信息</span>
|
<span class="assistant-badge">修改识别信息</span>
|
||||||
<h3>请按当前识别结果逐项修改</h3>
|
<h3>请按当前识别结果逐项修改</h3>
|
||||||
<p>修改后会重新发送到智能体,右侧识别结果会按新内容刷新。</p>
|
<p>修改会先保存到右侧核对信息,提交下一步时再进行 AI 预审。</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" type="button" aria-label="关闭修改面板" :disabled="reviewActionBusy" @click="closeEditReviewDialog">
|
<button class="close-btn" type="button" aria-label="关闭修改面板" :disabled="reviewActionBusy" @click="closeEditReviewDialog">
|
||||||
<i class="mdi mdi-close"></i>
|
<i class="mdi mdi-close"></i>
|
||||||
@@ -1294,7 +1294,7 @@
|
|||||||
<div class="review-edit-actions">
|
<div class="review-edit-actions">
|
||||||
<button type="button" class="secondary-dialog-btn" :disabled="reviewActionBusy" @click="closeEditReviewDialog">取消</button>
|
<button type="button" class="secondary-dialog-btn" :disabled="reviewActionBusy" @click="closeEditReviewDialog">取消</button>
|
||||||
<button type="button" class="primary-dialog-btn" :disabled="reviewActionBusy" @click="applyEditedReview">
|
<button type="button" class="primary-dialog-btn" :disabled="reviewActionBusy" @click="applyEditedReview">
|
||||||
{{ reviewActionBusy ? '提交中...' : '确认修改' }}
|
{{ reviewActionBusy ? '保存中...' : '确认修改' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ function resolveRiskItems(request) {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
text: 'AI验审已通过,当前未发现额外风险。',
|
text: 'AI预审已通过,当前未发现额外风险。',
|
||||||
level: '低',
|
level: '低',
|
||||||
tone: 'low',
|
tone: 'low',
|
||||||
icon: 'mdi mdi-shield-check'
|
icon: 'mdi mdi-shield-check'
|
||||||
|
|||||||
@@ -303,6 +303,8 @@ const RISK_SCENARIO_OPTIONS = [
|
|||||||
{ value: '通用', label: '通用' }
|
{ value: '通用', label: '通用' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const RISK_SCENARIO_VALUES = new Set(RISK_SCENARIO_OPTIONS.map((item) => item.value).filter(Boolean))
|
||||||
|
|
||||||
const LEGACY_RISK_SCENARIO_KEYS = new Set([
|
const LEGACY_RISK_SCENARIO_KEYS = new Set([
|
||||||
'expense',
|
'expense',
|
||||||
'risk_check',
|
'risk_check',
|
||||||
@@ -313,7 +315,10 @@ const LEGACY_RISK_SCENARIO_KEYS = new Set([
|
|||||||
'travel_standard',
|
'travel_standard',
|
||||||
'attachment_policy',
|
'attachment_policy',
|
||||||
'scene_policy',
|
'scene_policy',
|
||||||
'invoice_anomaly'
|
'invoice_anomaly',
|
||||||
|
'communication_expense',
|
||||||
|
'expense_standard',
|
||||||
|
'approval_required'
|
||||||
])
|
])
|
||||||
|
|
||||||
const SPREADSHEET_DETAIL_MODE = 'spreadsheet'
|
const SPREADSHEET_DETAIL_MODE = 'spreadsheet'
|
||||||
@@ -409,7 +414,7 @@ function createPreviewRuleDetailPayload() {
|
|||||||
name: '公司差旅费报销规则',
|
name: '公司差旅费报销规则',
|
||||||
description: '前端预览态:先展示 Excel 规则详情页布局、版本卡片和编辑入口位置。',
|
description: '前端预览态:先展示 Excel 规则详情页布局、版本卡片和编辑入口位置。',
|
||||||
domain: 'expense',
|
domain: 'expense',
|
||||||
scenario_json: ['expense', 'travel_policy', 'travel_standard'],
|
scenario_json: ['差旅'],
|
||||||
owner: '财务制度管理组',
|
owner: '财务制度管理组',
|
||||||
reviewer: '顾承宇',
|
reviewer: '顾承宇',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@@ -422,6 +427,8 @@ function createPreviewRuleDetailPayload() {
|
|||||||
tag: '财务规则',
|
tag: '财务规则',
|
||||||
detail_mode: 'spreadsheet',
|
detail_mode: 'spreadsheet',
|
||||||
runtime_kind: 'travel_policy',
|
runtime_kind: 'travel_policy',
|
||||||
|
scenario_category: '差旅',
|
||||||
|
ai_review_category: '差旅',
|
||||||
rule_template_label: '差旅报销 Excel 模板',
|
rule_template_label: '差旅报销 Excel 模板',
|
||||||
rule_document: {
|
rule_document: {
|
||||||
...currentMeta,
|
...currentMeta,
|
||||||
@@ -588,26 +595,37 @@ function inferRiskCategoryFromCode(code) {
|
|||||||
return '通用'
|
return '通用'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRiskScenarioCategory(value) {
|
||||||
|
const normalized = normalizeText(value)
|
||||||
|
return RISK_SCENARIO_VALUES.has(normalized) ? normalized : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function readScenarioItems(source) {
|
||||||
|
if (Array.isArray(source?.scenario_json)) {
|
||||||
|
return source.scenario_json
|
||||||
|
}
|
||||||
|
if (Array.isArray(source?.scenarioList)) {
|
||||||
|
return source.scenarioList
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
function resolveRiskRuleCategory(source) {
|
function resolveRiskRuleCategory(source) {
|
||||||
const configJson = readConfigJson(source)
|
const configJson = readConfigJson(source)
|
||||||
const explicit = normalizeText(configJson.risk_category)
|
const explicit = normalizeRiskScenarioCategory(configJson.risk_category)
|
||||||
if (explicit) {
|
if (explicit) {
|
||||||
return explicit
|
return explicit
|
||||||
}
|
}
|
||||||
|
|
||||||
const payloadCategory = normalizeText(source?.risk_category)
|
const payloadCategory = normalizeRiskScenarioCategory(source?.risk_category)
|
||||||
if (payloadCategory) {
|
if (payloadCategory) {
|
||||||
return payloadCategory
|
return payloadCategory
|
||||||
}
|
}
|
||||||
|
|
||||||
const scenarioItems = Array.isArray(source?.scenario_json)
|
const scenarioItems = readScenarioItems(source)
|
||||||
? source.scenario_json
|
|
||||||
: Array.isArray(source?.scenarioList)
|
|
||||||
? source.scenarioList
|
|
||||||
: []
|
|
||||||
const businessScenario = scenarioItems
|
const businessScenario = scenarioItems
|
||||||
.map((item) => normalizeText(item))
|
.map((item) => normalizeText(item))
|
||||||
.find((item) => item && !LEGACY_RISK_SCENARIO_KEYS.has(item))
|
.find((item) => item && !LEGACY_RISK_SCENARIO_KEYS.has(item) && RISK_SCENARIO_VALUES.has(item))
|
||||||
if (businessScenario) {
|
if (businessScenario) {
|
||||||
return businessScenario
|
return businessScenario
|
||||||
}
|
}
|
||||||
@@ -615,6 +633,75 @@ function resolveRiskRuleCategory(source) {
|
|||||||
return inferRiskCategoryFromCode(source?.code)
|
return inferRiskCategoryFromCode(source?.code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inferFinancialRuleCategory(source) {
|
||||||
|
const configJson = readConfigJson(source)
|
||||||
|
const explicit =
|
||||||
|
normalizeRiskScenarioCategory(configJson.scenario_category) ||
|
||||||
|
normalizeRiskScenarioCategory(configJson.ai_review_category) ||
|
||||||
|
normalizeRiskScenarioCategory(configJson.risk_category) ||
|
||||||
|
normalizeRiskScenarioCategory(source?.scenario_category) ||
|
||||||
|
normalizeRiskScenarioCategory(source?.risk_category)
|
||||||
|
if (explicit) {
|
||||||
|
return explicit
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenarioCategory = readScenarioItems(source)
|
||||||
|
.map((item) => normalizeRiskScenarioCategory(item))
|
||||||
|
.find(Boolean)
|
||||||
|
if (scenarioCategory) {
|
||||||
|
return scenarioCategory
|
||||||
|
}
|
||||||
|
|
||||||
|
const configRuntimeRule = isPlainObject(configJson.runtime_rule) ? configJson.runtime_rule : {}
|
||||||
|
const haystack = [
|
||||||
|
source?.code,
|
||||||
|
source?.name,
|
||||||
|
source?.description,
|
||||||
|
configJson.runtime_kind,
|
||||||
|
configRuntimeRule.kind,
|
||||||
|
configRuntimeRule.scenario,
|
||||||
|
configRuntimeRule.template_key,
|
||||||
|
...readScenarioItems(source)
|
||||||
|
]
|
||||||
|
.map((item) => normalizeText(item).toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
if (!haystack) {
|
||||||
|
return '通用'
|
||||||
|
}
|
||||||
|
if (/(travel|trip|差旅|出差|住宿|酒店)/i.test(haystack)) {
|
||||||
|
return '差旅'
|
||||||
|
}
|
||||||
|
if (/(invoice|receipt|attachment|票据|发票|单据|附件)/i.test(haystack)) {
|
||||||
|
return '发票'
|
||||||
|
}
|
||||||
|
if (/(meal|dining|entertainment|餐饮|招待|餐费|用餐)/i.test(haystack)) {
|
||||||
|
return '餐饮招待'
|
||||||
|
}
|
||||||
|
if (/(transport|traffic|taxi|交通|出行|打车|机票|火车|高铁|地铁|公交)/i.test(haystack)) {
|
||||||
|
return '交通出行'
|
||||||
|
}
|
||||||
|
if (/(office|material|suppl|办公|物料|耗材)/i.test(haystack)) {
|
||||||
|
return '办公物料'
|
||||||
|
}
|
||||||
|
if (/(communication|telecom|phone|expense_standard|费用科目|费用标准|通信|通讯|手机|补贴|福利|科目)/i.test(haystack)) {
|
||||||
|
return '费用科目'
|
||||||
|
}
|
||||||
|
return '通用'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRuleScenarioCategory(source, tabId = '') {
|
||||||
|
const resolvedTabId = tabId || resolveRuleTabId(source)
|
||||||
|
if (resolvedTabId === 'riskRules' || isJsonRiskRuleSource(source)) {
|
||||||
|
return resolveRiskRuleCategory(source)
|
||||||
|
}
|
||||||
|
if (resolvedTabId === 'financialRules') {
|
||||||
|
return inferFinancialRuleCategory(source)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
function buildRiskListSubtitle(text, maxLength = 42) {
|
function buildRiskListSubtitle(text, maxLength = 42) {
|
||||||
const normalized = normalizeText(text)
|
const normalized = normalizeText(text)
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -1006,7 +1093,7 @@ function buildListItem(asset) {
|
|||||||
const usesSpreadsheetRule = typeKey === 'rules' && isSpreadsheetRuleSource(asset)
|
const usesSpreadsheetRule = typeKey === 'rules' && isSpreadsheetRuleSource(asset)
|
||||||
const usesJsonRiskRule = typeKey === 'rules' && isJsonRiskRuleSource(asset)
|
const usesJsonRiskRule = typeKey === 'rules' && isJsonRiskRuleSource(asset)
|
||||||
const ruleDocument = readRuleDocumentMeta(asset)
|
const ruleDocument = readRuleDocumentMeta(asset)
|
||||||
const riskCategory = isRiskRule ? resolveRiskRuleCategory(asset) : ''
|
const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(asset, tabId) : ''
|
||||||
const listSubtitle = isRiskRule
|
const listSubtitle = isRiskRule
|
||||||
? buildRiskListSubtitle(asset.description)
|
? buildRiskListSubtitle(asset.description)
|
||||||
: normalizeText(asset.description)
|
: normalizeText(asset.description)
|
||||||
@@ -1028,8 +1115,8 @@ function buildListItem(asset) {
|
|||||||
category: resolveDomainLabel(asset.domain),
|
category: resolveDomainLabel(asset.domain),
|
||||||
owner: asset.owner,
|
owner: asset.owner,
|
||||||
reviewer: asset.reviewer || '待分配',
|
reviewer: asset.reviewer || '待分配',
|
||||||
scope: isRiskRule ? riskCategory || '通用' : formatScenarioList(asset.scenario_json),
|
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json),
|
||||||
riskCategory,
|
riskCategory: ruleScenarioCategory,
|
||||||
model: buildRowRuntime(asset, typeKey),
|
model: buildRowRuntime(asset, typeKey),
|
||||||
version: workingVersion,
|
version: workingVersion,
|
||||||
versionDisplay: typeKey === 'rules' ? `${changeCount} 次` : workingVersion,
|
versionDisplay: typeKey === 'rules' ? `${changeCount} 次` : workingVersion,
|
||||||
@@ -1050,6 +1137,7 @@ function buildListItem(asset) {
|
|||||||
|
|
||||||
function buildRuleFields(detail) {
|
function buildRuleFields(detail) {
|
||||||
const ruleDocument = readRuleDocumentMeta(detail)
|
const ruleDocument = readRuleDocumentMeta(detail)
|
||||||
|
const ruleScenarioCategory = resolveRuleScenarioCategory(detail)
|
||||||
return [
|
return [
|
||||||
{ label: '规则编码', value: detail.code },
|
{ label: '规则编码', value: detail.code },
|
||||||
{
|
{
|
||||||
@@ -1073,7 +1161,7 @@ function buildRuleFields(detail) {
|
|||||||
label: '运行时类型',
|
label: '运行时类型',
|
||||||
value: normalizeText(detail.config_json?.runtime_kind) || 'policy_rule_draft'
|
value: normalizeText(detail.config_json?.runtime_kind) || 'policy_rule_draft'
|
||||||
},
|
},
|
||||||
{ label: '适用场景', value: formatScenarioList(detail.scenario_json) },
|
{ label: '适用场景', value: ruleScenarioCategory || '通用' },
|
||||||
{ label: '线上版本', value: detail.published_version || '-' },
|
{ label: '线上版本', value: detail.published_version || '-' },
|
||||||
{ label: '工作版本', value: detail.working_version || detail.current_version || '-' }
|
{ label: '工作版本', value: detail.working_version || detail.current_version || '-' }
|
||||||
]
|
]
|
||||||
@@ -1417,6 +1505,7 @@ function buildDetailViewModel(detail, runs) {
|
|||||||
const ruleTemplateKey = normalizeText(configJson.rule_template_key || previewRuntimeRule.template_key)
|
const ruleTemplateKey = normalizeText(configJson.rule_template_key || previewRuntimeRule.template_key)
|
||||||
const ruleTemplateLabel = normalizeText(configJson.rule_template_label) || resolveRuleTemplateLabel(ruleTemplateKey)
|
const ruleTemplateLabel = normalizeText(configJson.rule_template_label) || resolveRuleTemplateLabel(ruleTemplateKey)
|
||||||
const runtimeKind = normalizeText(configJson.runtime_kind || previewRuntimeRule.kind) || 'policy_rule_draft'
|
const runtimeKind = normalizeText(configJson.runtime_kind || previewRuntimeRule.kind) || 'policy_rule_draft'
|
||||||
|
const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(detail, tabId) : ''
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: detail.id,
|
id: detail.id,
|
||||||
@@ -1431,7 +1520,7 @@ function buildDetailViewModel(detail, runs) {
|
|||||||
owner: detail.owner,
|
owner: detail.owner,
|
||||||
reviewer: detail.reviewer || detail.latest_review?.reviewer || '待分配',
|
reviewer: detail.reviewer || detail.latest_review?.reviewer || '待分配',
|
||||||
category: resolveDomainLabel(detail.domain),
|
category: resolveDomainLabel(detail.domain),
|
||||||
scope: usesJsonRiskRule ? resolveRiskRuleCategory(detail) || '通用' : formatScenarioList(detail.scenario_json),
|
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(detail.scenario_json),
|
||||||
version: detail.working_version || detail.current_version || '-',
|
version: detail.working_version || detail.current_version || '-',
|
||||||
currentVersion: detail.current_version || '-',
|
currentVersion: detail.current_version || '-',
|
||||||
publishedVersion: detail.published_version || '-',
|
publishedVersion: detail.published_version || '-',
|
||||||
@@ -1451,9 +1540,13 @@ function buildDetailViewModel(detail, runs) {
|
|||||||
riskRuleDescription: '',
|
riskRuleDescription: '',
|
||||||
riskRuleSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : '',
|
riskRuleSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : '',
|
||||||
riskRuleSourceRef: '',
|
riskRuleSourceRef: '',
|
||||||
riskCategory: usesJsonRiskRule ? resolveRiskRuleCategory(detail) : '',
|
riskCategory: typeKey === 'rules' ? ruleScenarioCategory : '',
|
||||||
ruleDocument,
|
ruleDocument,
|
||||||
scenarioList: Array.isArray(detail.scenario_json) ? [...detail.scenario_json] : [],
|
scenarioList: typeKey === 'rules' && ruleScenarioCategory
|
||||||
|
? [ruleScenarioCategory]
|
||||||
|
: Array.isArray(detail.scenario_json)
|
||||||
|
? [...detail.scenario_json]
|
||||||
|
: [],
|
||||||
markdownContent: previewMarkdown,
|
markdownContent: previewMarkdown,
|
||||||
runtimeRuleText: stringifyRuntimeRule(previewRuntimeRule),
|
runtimeRuleText: stringifyRuntimeRule(previewRuntimeRule),
|
||||||
ruleTemplateKey,
|
ruleTemplateKey,
|
||||||
@@ -1474,7 +1567,12 @@ function buildDetailViewModel(detail, runs) {
|
|||||||
typeKey === 'rules' ? [] : buildPromptSections(detail, typeKey, latestRun, latestCall),
|
typeKey === 'rules' ? [] : buildPromptSections(detail, typeKey, latestRun, latestCall),
|
||||||
outputRules: buildOutputRules(detail, typeKey, latestRun, latestCall),
|
outputRules: buildOutputRules(detail, typeKey, latestRun, latestCall),
|
||||||
tests: buildTests(detail, typeKey, latestRun, latestCall),
|
tests: buildTests(detail, typeKey, latestRun, latestCall),
|
||||||
triggers: detail.scenario_json?.length ? detail.scenario_json.map((item) => SCENARIO_LABELS[item] || item) : ['未配置场景'],
|
triggers:
|
||||||
|
typeKey === 'rules'
|
||||||
|
? [ruleScenarioCategory || '通用']
|
||||||
|
: detail.scenario_json?.length
|
||||||
|
? detail.scenario_json.map((item) => SCENARIO_LABELS[item] || item)
|
||||||
|
: ['未配置场景'],
|
||||||
tools:
|
tools:
|
||||||
typeKey === 'rules'
|
typeKey === 'rules'
|
||||||
? [
|
? [
|
||||||
@@ -1769,7 +1867,9 @@ export default {
|
|||||||
const selectedStatusLabel = computed(
|
const selectedStatusLabel = computed(
|
||||||
() => STATUS_OPTIONS.find((item) => item.value === selectedStatus.value)?.label || '状态'
|
() => STATUS_OPTIONS.find((item) => item.value === selectedStatus.value)?.label || '状态'
|
||||||
)
|
)
|
||||||
const showRiskScenarioFilter = computed(() => activeType.value === 'riskRules')
|
const showRiskScenarioFilter = computed(() =>
|
||||||
|
['financialRules', 'riskRules'].includes(activeType.value)
|
||||||
|
)
|
||||||
const showStatusFilter = computed(() => activeType.value !== 'riskRules')
|
const showStatusFilter = computed(() => activeType.value !== 'riskRules')
|
||||||
const selectedRiskScenarioLabel = computed(
|
const selectedRiskScenarioLabel = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -1799,6 +1899,13 @@ export default {
|
|||||||
})
|
})
|
||||||
const auditEmptyState = computed(() => {
|
const auditEmptyState = computed(() => {
|
||||||
const hasFilters = activeFilterTokens.value.length > 0
|
const hasFilters = activeFilterTokens.value.length > 0
|
||||||
|
const supportedFilters = [
|
||||||
|
'业务域',
|
||||||
|
'负责人',
|
||||||
|
...(showRiskScenarioFilter.value ? ['使用场景'] : []),
|
||||||
|
...(showStatusFilter.value ? ['状态'] : []),
|
||||||
|
'关键词'
|
||||||
|
]
|
||||||
|
|
||||||
if (!currentAssets.value.length) {
|
if (!currentAssets.value.length) {
|
||||||
return {
|
return {
|
||||||
@@ -1810,10 +1917,10 @@ export default {
|
|||||||
actionIcon: '',
|
actionIcon: '',
|
||||||
tone: 'amber',
|
tone: 'amber',
|
||||||
artLabel: 'ASSET',
|
artLabel: 'ASSET',
|
||||||
tips:
|
tips: [
|
||||||
activeType.value === 'riskRules'
|
'切换页签可查看其他资产类型',
|
||||||
? ['切换页签可查看其他资产类型', '支持按业务域、负责人和使用场景做过滤']
|
`支持按${supportedFilters.slice(0, -1).join('、')}和关键词做过滤`
|
||||||
: ['切换页签可查看其他资产类型', '支持按业务域、负责人和状态做过滤']
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1821,9 +1928,7 @@ export default {
|
|||||||
eyebrow: '筛选结果为空',
|
eyebrow: '筛选结果为空',
|
||||||
title: `没有找到匹配的${activeTabLabel.value}`,
|
title: `没有找到匹配的${activeTabLabel.value}`,
|
||||||
desc: hasFilters
|
desc: hasFilters
|
||||||
? showRiskScenarioFilter.value
|
? `试试清空${supportedFilters.join('、')}筛选,再重新查看。`
|
||||||
? '试试清空业务域、负责人、使用场景或关键词筛选,再重新查看。'
|
|
||||||
: '试试清空业务域、负责人、状态或关键词筛选,再重新查看。'
|
|
||||||
: `当前列表中还没有满足展示条件的${activeTabLabel.value}资产。`,
|
: `当前列表中还没有满足展示条件的${activeTabLabel.value}资产。`,
|
||||||
icon: hasFilters ? 'mdi mdi-tune-variant' : 'mdi mdi-view-grid-outline',
|
icon: hasFilters ? 'mdi mdi-tune-variant' : 'mdi mdi-view-grid-outline',
|
||||||
actionLabel: hasFilters ? '清空筛选' : '',
|
actionLabel: hasFilters ? '清空筛选' : '',
|
||||||
@@ -1831,9 +1936,12 @@ export default {
|
|||||||
tone: hasFilters ? 'emerald' : 'slate',
|
tone: hasFilters ? 'emerald' : 'slate',
|
||||||
artLabel: hasFilters ? 'FILTER' : 'QUEUE',
|
artLabel: hasFilters ? 'FILTER' : 'QUEUE',
|
||||||
tips: hasFilters
|
tips: hasFilters
|
||||||
? showRiskScenarioFilter.value
|
? [
|
||||||
? ['业务域、负责人、使用场景与关键词会叠加过滤', '可以换个规则名称或场景分类继续搜索']
|
`${supportedFilters.join('、')}会叠加过滤`,
|
||||||
: ['业务域、负责人、状态与关键词会叠加过滤', '可以换个编码、名称或负责人关键词继续搜索']
|
showRiskScenarioFilter.value
|
||||||
|
? '可以换个规则名称或场景分类继续搜索'
|
||||||
|
: '可以换个编码、名称或负责人关键词继续搜索'
|
||||||
|
]
|
||||||
: ['列表展示来自真实资产 API', '切换资产类型后会自动重新拉取数据']
|
: ['列表展示来自真实资产 API', '切换资产类型后会自动重新拉取数据']
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -209,17 +209,11 @@ const FLOW_STEP_FALLBACKS = {
|
|||||||
runningText: '正在识别票据附件...',
|
runningText: '正在识别票据附件...',
|
||||||
completedText: '票据识别完成'
|
completedText: '票据识别完成'
|
||||||
},
|
},
|
||||||
agent: {
|
'expense-claim-draft': {
|
||||||
title: '智能体编排',
|
title: '报销草稿处理',
|
||||||
tool: 'UserAgent',
|
tool: 'database.expense_claims.save_or_submit',
|
||||||
runningText: '正在调用财务智能体...',
|
runningText: '正在根据识别结果更新草稿和右侧核对信息...',
|
||||||
completedText: '智能体处理完成'
|
completedText: '草稿和核对信息已更新'
|
||||||
},
|
|
||||||
result: {
|
|
||||||
title: '生成结果',
|
|
||||||
tool: 'ResultGenerator',
|
|
||||||
runningText: '正在生成解释与草稿...',
|
|
||||||
completedText: '结果已生成'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const ASSISTANT_DISPLAY_NAME = '财务助手'
|
const ASSISTANT_DISPLAY_NAME = '财务助手'
|
||||||
@@ -345,41 +339,6 @@ function formatMessageTime(value) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFlowSteps(options = {}) {
|
|
||||||
const keys = []
|
|
||||||
if (options.includeIntent) {
|
|
||||||
keys.push('intent')
|
|
||||||
}
|
|
||||||
if (options.includeOcr) {
|
|
||||||
keys.push('ocr')
|
|
||||||
}
|
|
||||||
if (options.includeExtraction) {
|
|
||||||
keys.push('extraction')
|
|
||||||
}
|
|
||||||
if (options.includeAgent) {
|
|
||||||
keys.push('agent')
|
|
||||||
}
|
|
||||||
if (options.includeResult) {
|
|
||||||
keys.push('result')
|
|
||||||
}
|
|
||||||
|
|
||||||
return keys.map((key, index) => {
|
|
||||||
const definition = FLOW_STEP_FALLBACKS[key] || {}
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
index: index + 1,
|
|
||||||
title: definition.title || '智能体工具调用',
|
|
||||||
tool: definition.tool || 'AgentTool',
|
|
||||||
status: FLOW_STEP_STATUS_PENDING,
|
|
||||||
detail: '',
|
|
||||||
durationMs: null,
|
|
||||||
startedAt: 0,
|
|
||||||
finishedAt: 0,
|
|
||||||
error: ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSemanticEntityValue(entity) {
|
function formatSemanticEntityValue(entity) {
|
||||||
const normalizedValue = String(entity?.normalized_value || '').trim()
|
const normalizedValue = String(entity?.normalized_value || '').trim()
|
||||||
const rawValue = String(entity?.value || '').trim()
|
const rawValue = String(entity?.value || '').trim()
|
||||||
@@ -577,11 +536,8 @@ function formatFlowDuration(ms) {
|
|||||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||||
return '--'
|
return '--'
|
||||||
}
|
}
|
||||||
if (numericValue < 100) {
|
|
||||||
return '<0.1s'
|
|
||||||
}
|
|
||||||
if (numericValue < 1000) {
|
if (numericValue < 1000) {
|
||||||
return `${(numericValue / 1000).toFixed(1)}s`
|
return `${Math.max(0.1, numericValue / 1000).toFixed(1)}s`
|
||||||
}
|
}
|
||||||
if (numericValue < 10000) {
|
if (numericValue < 10000) {
|
||||||
return `${(numericValue / 1000).toFixed(1)}s`
|
return `${(numericValue / 1000).toFixed(1)}s`
|
||||||
@@ -639,34 +595,6 @@ function resolveToolCallDurationMs(toolCall, index, toolCalls, run) {
|
|||||||
return finishedAt - startedAt
|
return finishedAt - startedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveResultStepDurationMs(run) {
|
|
||||||
const runFinishedAt = parseFlowTimestamp(run?.finished_at)
|
|
||||||
if (!runFinishedAt) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
|
|
||||||
const semanticFinishedAt = (
|
|
||||||
toolCalls
|
|
||||||
.map((item, index) => {
|
|
||||||
const startedAt = parseFlowTimestamp(item?.created_at)
|
|
||||||
const durationMs = resolveToolCallDurationMs(item, index, toolCalls, run)
|
|
||||||
if (!startedAt || !durationMs) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return startedAt + durationMs
|
|
||||||
})
|
|
||||||
.filter((value) => value > 0)
|
|
||||||
.sort((left, right) => right - left)[0]
|
|
||||||
) || parseFlowTimestamp(run?.started_at)
|
|
||||||
|
|
||||||
if (!semanticFinishedAt || runFinishedAt <= semanticFinishedAt) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return runFinishedAt - semanticFinishedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeRequest(request) {
|
function sanitizeRequest(request) {
|
||||||
if (!request || typeof request !== 'object') return null
|
if (!request || typeof request !== 'object') return null
|
||||||
|
|
||||||
@@ -1559,7 +1487,7 @@ function buildDraftSavedPayload({
|
|||||||
status: String(draftPayload?.status || '').trim(),
|
status: String(draftPayload?.status || '').trim(),
|
||||||
approvalStage: String(draftPayload?.approval_stage || '').trim(),
|
approvalStage: String(draftPayload?.approval_stage || '').trim(),
|
||||||
person: String(currentUser?.name || '').trim() || '当前用户',
|
person: String(currentUser?.name || '').trim() || '当前用户',
|
||||||
dept: String(currentUser?.role || '').trim() || '待补充部门',
|
dept: String(currentUser?.department || currentUser?.departmentName || '').trim() || '待补充部门',
|
||||||
entity: String(linkedRequest?.entity || '').trim() || 'Northstar China Ltd.',
|
entity: String(linkedRequest?.entity || '').trim() || 'Northstar China Ltd.',
|
||||||
typeCode,
|
typeCode,
|
||||||
typeLabel,
|
typeLabel,
|
||||||
@@ -2525,11 +2453,103 @@ function buildReviewRiskSummary(reviewPayload) {
|
|||||||
|
|
||||||
function buildReviewRiskItems(reviewPayload) {
|
function buildReviewRiskItems(reviewPayload) {
|
||||||
return resolveReviewRiskBriefs(reviewPayload)
|
return resolveReviewRiskBriefs(reviewPayload)
|
||||||
.map((brief) => String(brief?.content || '').trim())
|
.map((brief) => {
|
||||||
|
const title = String(brief?.title || '').trim()
|
||||||
|
const content = String(brief?.content || '').trim()
|
||||||
|
if (title && content) return `${title}:${content}`
|
||||||
|
return content || title
|
||||||
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) {
|
||||||
|
const state = inlineState || createEmptyInlineReviewState()
|
||||||
|
if (slotKey === 'expense_type') return String(state.expense_type || '').trim()
|
||||||
|
if (slotKey === 'customer_name') return String(state.customer_name || '').trim()
|
||||||
|
if (slotKey === 'time_range') return String(state.occurred_date || '').trim()
|
||||||
|
if (slotKey === 'location') return String(state.location || '').trim()
|
||||||
|
if (slotKey === 'merchant_name') return String(state.merchant_name || '').trim()
|
||||||
|
if (slotKey === 'amount') return String(state.amount || '').trim()
|
||||||
|
if (slotKey === 'reason') return String(state.reason_value || state.scene_label || '').trim()
|
||||||
|
if (slotKey === 'participants') return String(state.participants || '').trim()
|
||||||
|
if (slotKey === 'attachments') {
|
||||||
|
return String(state.attachment_names || '').trim()
|
||||||
|
|| (Number(state.attachment_count || 0) > 0 ? `${Number(state.attachment_count)} 份附件` : '')
|
||||||
|
|| (Number(state.pending_attachment_count || 0) > 0 ? `${Number(state.pending_attachment_count)} 份待上传附件` : '')
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocallySyncedReviewActions(reviewPayload, canProceed) {
|
||||||
|
const actions = Array.isArray(reviewPayload?.confirmation_actions)
|
||||||
|
? reviewPayload.confirmation_actions.map((item) => ({ ...item }))
|
||||||
|
: []
|
||||||
|
const actionTypes = new Set(actions.map((item) => String(item?.action_type || '').trim()))
|
||||||
|
const associationPending = actionTypes.has('link_to_existing_draft') || actionTypes.has('create_new_claim_from_documents')
|
||||||
|
|
||||||
|
if (!canProceed || associationPending) {
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...actions.filter((item) => !['save_draft', 'next_step'].includes(String(item?.action_type || '').trim())),
|
||||||
|
{
|
||||||
|
label: '继续下一步',
|
||||||
|
action_type: 'next_step',
|
||||||
|
description: '当前信息已齐全,进入 AI 预审、风险校验和审批路径确认。',
|
||||||
|
emphasis: 'primary'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocallySyncedReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
|
||||||
|
if (!reviewPayload || typeof reviewPayload !== 'object') {
|
||||||
|
return reviewPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSlotCards = (Array.isArray(reviewPayload.slot_cards) ? reviewPayload.slot_cards : []).map((slot) => {
|
||||||
|
const value = resolveInlineReviewSlotValue(slot.key, inlineState)
|
||||||
|
const required = Boolean(slot.required)
|
||||||
|
const filled = Boolean(value)
|
||||||
|
return {
|
||||||
|
...slot,
|
||||||
|
value: value || slot.value || '',
|
||||||
|
normalized_value: value || slot.normalized_value || '',
|
||||||
|
raw_value: value || slot.raw_value || '',
|
||||||
|
source: filled ? 'user_form' : slot.source,
|
||||||
|
source_label: filled ? '用户修改' : slot.source_label,
|
||||||
|
confidence: filled ? Math.max(Number(slot.confidence || 0), 0.98) : Number(slot.confidence || 0),
|
||||||
|
confirmed: filled || Boolean(slot.confirmed),
|
||||||
|
status: required && !filled ? 'missing' : filled ? 'identified' : slot.status,
|
||||||
|
hint: required && !filled ? slot.hint : ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const missingSlots = nextSlotCards
|
||||||
|
.filter((slot) => slot.required && slot.status === 'missing')
|
||||||
|
.map((slot) => slot.label || slot.key)
|
||||||
|
const canProceed = missingSlots.length === 0 && (Array.isArray(reviewPayload.claim_groups) ? reviewPayload.claim_groups.length > 0 : true)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...reviewPayload,
|
||||||
|
can_proceed: canProceed,
|
||||||
|
missing_slots: missingSlots,
|
||||||
|
slot_cards: nextSlotCards,
|
||||||
|
confirmation_actions: buildLocallySyncedReviewActions(reviewPayload, canProceed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocalReviewCompletionMessage(reviewPayload) {
|
||||||
|
const missingSlots = Array.isArray(reviewPayload?.missing_slots) ? reviewPayload.missing_slots : []
|
||||||
|
if (reviewPayload?.can_proceed && !missingSlots.length) {
|
||||||
|
return '当前所有必填信息已处理完成,可以点击“继续下一步”进入 AI 预审。'
|
||||||
|
}
|
||||||
|
if (missingSlots.length) {
|
||||||
|
return `当前还剩 ${missingSlots.length} 项待补充:${missingSlots.join('、')}。`
|
||||||
|
}
|
||||||
|
return '当前信息已保存,可以继续核对右侧状态。'
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeInlineReviewComparableState(state) {
|
function normalizeInlineReviewComparableState(state) {
|
||||||
const source = state && typeof state === 'object' ? state : {}
|
const source = state && typeof state === 'object' ? state : {}
|
||||||
return {
|
return {
|
||||||
@@ -2583,6 +2603,49 @@ function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = [])
|
|||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildInlineReviewChangePhrases(baseState, nextState, pendingFiles = []) {
|
||||||
|
const base = normalizeInlineReviewComparableState(baseState)
|
||||||
|
const next = normalizeInlineReviewComparableState(nextState)
|
||||||
|
const fieldConfigs = [
|
||||||
|
{ key: 'occurred_date', label: '发生时间', format: (value) => value || '待补充' },
|
||||||
|
{ key: 'amount', label: '金额', format: (value) => formatAmountDisplay(value) || '待补充' },
|
||||||
|
{ key: 'scene_label', label: '场景', format: (value) => value || '待补充' },
|
||||||
|
{ key: 'customer_name', label: '关联客户', format: (value) => value || '待补充' },
|
||||||
|
{ key: 'location', label: '业务地点', format: (value) => value || '待补充' },
|
||||||
|
{ key: 'merchant_name', label: '酒店/商户', format: (value) => value || '待补充' },
|
||||||
|
{ key: 'participants', label: '同行人员', format: (value) => value || '待补充' },
|
||||||
|
{ key: 'expense_type', label: '报销分类', format: (value) => value || '待补充' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const phrases = fieldConfigs.reduce((result, item) => {
|
||||||
|
if (base[item.key] !== next[item.key]) {
|
||||||
|
result.push(`${item.label}修改为 ${item.format(next[item.key])}`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (base.attachment_names !== next.attachment_names || pendingFiles.length) {
|
||||||
|
phrases.push(`票据修改为 ${next.attachment_names || (pendingFiles.length ? `已选择 ${pendingFiles.length} 份附件` : '待上传')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return phrases
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocalReviewSavedMessage(baseState, nextState, pendingFiles = [], baseDrafts = [], nextDrafts = []) {
|
||||||
|
const phrases = buildInlineReviewChangePhrases(baseState, nextState, pendingFiles)
|
||||||
|
const documentLines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts)
|
||||||
|
|
||||||
|
if (documentLines.length) {
|
||||||
|
phrases.push(`${documentLines.length} 张票据识别信息更新为最新修改`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!phrases.length) {
|
||||||
|
return '右侧核对信息已保存。'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `已将${phrases.join(',')}。`
|
||||||
|
}
|
||||||
|
|
||||||
function buildInlineReviewUserText(baseState, nextState, pendingFiles = []) {
|
function buildInlineReviewUserText(baseState, nextState, pendingFiles = []) {
|
||||||
const lines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles)
|
const lines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles)
|
||||||
if (!lines.length) {
|
if (!lines.length) {
|
||||||
@@ -2894,7 +2957,7 @@ export default {
|
|||||||
const flowRunId = ref('')
|
const flowRunId = ref('')
|
||||||
const flowStartedAt = ref(0)
|
const flowStartedAt = ref(0)
|
||||||
const flowFinishedAt = ref(0)
|
const flowFinishedAt = ref(0)
|
||||||
const flowSteps = ref(createFlowSteps())
|
const flowSteps = ref([])
|
||||||
const flowRefreshBusy = ref(false)
|
const flowRefreshBusy = ref(false)
|
||||||
const flowTick = ref(Date.now())
|
const flowTick = ref(Date.now())
|
||||||
let flowTickTimer = 0
|
let flowTickTimer = 0
|
||||||
@@ -3415,7 +3478,7 @@ export default {
|
|||||||
flowRunId.value = ''
|
flowRunId.value = ''
|
||||||
flowStartedAt.value = 0
|
flowStartedAt.value = 0
|
||||||
flowFinishedAt.value = 0
|
flowFinishedAt.value = 0
|
||||||
flowSteps.value = createFlowSteps()
|
flowSteps.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function adjustComposerTextareaHeight() {
|
function adjustComposerTextareaHeight() {
|
||||||
@@ -3454,22 +3517,14 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetFlowRun(options = {}) {
|
function resetFlowRun() {
|
||||||
clearFlowSimulationTimers()
|
clearFlowSimulationTimers()
|
||||||
flowRunId.value = ''
|
flowRunId.value = ''
|
||||||
flowStartedAt.value = Date.now()
|
flowStartedAt.value = Date.now()
|
||||||
flowFinishedAt.value = 0
|
flowFinishedAt.value = 0
|
||||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
|
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
|
||||||
insightPanelCollapsed.value = false
|
insightPanelCollapsed.value = false
|
||||||
const hasText = Boolean(String(options.rawText || '').trim())
|
flowSteps.value = []
|
||||||
const attachmentCount = Number(options.attachmentCount || 0)
|
|
||||||
flowSteps.value = createFlowSteps({
|
|
||||||
includeIntent: hasText,
|
|
||||||
includeOcr: attachmentCount > 0,
|
|
||||||
includeExtraction: hasText || attachmentCount > 0,
|
|
||||||
includeAgent: true,
|
|
||||||
includeResult: true
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function findFlowDefinition(key) {
|
function findFlowDefinition(key) {
|
||||||
@@ -3511,13 +3566,6 @@ export default {
|
|||||||
const existingStep = flowSteps.value.find((step) => step.key === key)
|
const existingStep = flowSteps.value.find((step) => step.key === key)
|
||||||
if (!existingStep) {
|
if (!existingStep) {
|
||||||
const nextStep = createFlowStep(key, patch)
|
const nextStep = createFlowStep(key, patch)
|
||||||
const resultIndex = flowSteps.value.findIndex((step) => step.key === 'result')
|
|
||||||
if (resultIndex !== -1 && key !== 'result') {
|
|
||||||
const nextSteps = [...flowSteps.value]
|
|
||||||
nextSteps.splice(resultIndex, 0, nextStep)
|
|
||||||
flowSteps.value = normalizeFlowStepIndexes(nextSteps)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
flowSteps.value = normalizeFlowStepIndexes([...flowSteps.value, nextStep])
|
flowSteps.value = normalizeFlowStepIndexes([...flowSteps.value, nextStep])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -3529,11 +3577,15 @@ export default {
|
|||||||
|
|
||||||
function startFlowStep(key, patch = {}) {
|
function startFlowStep(key, patch = {}) {
|
||||||
const normalizedPatch = normalizeFlowStepPatch(key, patch)
|
const normalizedPatch = normalizeFlowStepPatch(key, patch)
|
||||||
|
const explicitStartedAt = Number(normalizedPatch.startedAt)
|
||||||
|
const startedAt = Number.isFinite(explicitStartedAt) && explicitStartedAt > 0
|
||||||
|
? explicitStartedAt
|
||||||
|
: Date.now()
|
||||||
upsertFlowStep(key, {
|
upsertFlowStep(key, {
|
||||||
...normalizedPatch,
|
...normalizedPatch,
|
||||||
status: FLOW_STEP_STATUS_RUNNING,
|
status: FLOW_STEP_STATUS_RUNNING,
|
||||||
detail: normalizedPatch.detail,
|
detail: normalizedPatch.detail,
|
||||||
startedAt: Date.now(),
|
startedAt,
|
||||||
finishedAt: 0,
|
finishedAt: 0,
|
||||||
durationMs: null,
|
durationMs: null,
|
||||||
error: ''
|
error: ''
|
||||||
@@ -3544,14 +3596,16 @@ export default {
|
|||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const definition = findFlowDefinition(key)
|
const definition = findFlowDefinition(key)
|
||||||
const currentStep = flowSteps.value.find((step) => step.key === key)
|
const currentStep = flowSteps.value.find((step) => step.key === key)
|
||||||
const startedAt = currentStep?.startedAt || now
|
const explicitDuration = Number(durationMs)
|
||||||
|
const hasExplicitDuration = Number.isFinite(explicitDuration) && explicitDuration >= 0
|
||||||
|
const startedAt = currentStep?.startedAt || (hasExplicitDuration ? Math.max(0, now - explicitDuration) : now)
|
||||||
upsertFlowStep(key, {
|
upsertFlowStep(key, {
|
||||||
...patch,
|
...patch,
|
||||||
status: FLOW_STEP_STATUS_COMPLETED,
|
status: FLOW_STEP_STATUS_COMPLETED,
|
||||||
detail: detail || definition?.completedText || '',
|
detail: detail || definition?.completedText || '',
|
||||||
startedAt,
|
startedAt,
|
||||||
finishedAt: now,
|
finishedAt: now,
|
||||||
durationMs: Number.isFinite(Number(durationMs)) ? Number(durationMs) : now - startedAt,
|
durationMs: hasExplicitDuration ? explicitDuration : Math.max(0, now - startedAt),
|
||||||
error: ''
|
error: ''
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -3601,7 +3655,12 @@ export default {
|
|||||||
function failCurrentFlowStep(error) {
|
function failCurrentFlowStep(error) {
|
||||||
clearFlowSimulationTimers()
|
clearFlowSimulationTimers()
|
||||||
const currentStep = runningFlowStep.value || flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_PENDING)
|
const currentStep = runningFlowStep.value || flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_PENDING)
|
||||||
failFlowStep(currentStep?.key || 'result', error?.message || '智能体调用失败', error?.message || '')
|
failFlowStep(
|
||||||
|
currentStep?.key || 'orchestrator-error',
|
||||||
|
error?.message || '智能体调用失败',
|
||||||
|
error?.message || '',
|
||||||
|
currentStep ? {} : { title: '流程调用', tool: 'Orchestrator' }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function startSemanticFlowPreview(rawText, options = {}) {
|
function startSemanticFlowPreview(rawText, options = {}) {
|
||||||
@@ -3646,9 +3705,63 @@ export default {
|
|||||||
flowSimulationTimers.push(startExtractionTimer)
|
flowSimulationTimers.push(startExtractionTimer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startReviewActionFlowStep(reviewAction) {
|
||||||
|
if (reviewAction !== 'next_step') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startFlowStep('pre-submit-review', {
|
||||||
|
title: 'AI预审与风险识别',
|
||||||
|
tool: 'ExpenseClaimService.submit_claim',
|
||||||
|
detail: '正在校验财务规则、风险规则和审批路径...'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function startExpenseClaimDraftFlowStep(reviewAction, options = {}) {
|
||||||
|
if (isKnowledgeSession.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (reviewAction === 'next_step') {
|
||||||
|
startReviewActionFlowStep(reviewAction)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentCount = Math.max(0, Number(options.attachmentCount || 0))
|
||||||
|
const configs = {
|
||||||
|
save_draft: {
|
||||||
|
title: '报销草稿保存',
|
||||||
|
detail: '正在保存当前核对结果...'
|
||||||
|
},
|
||||||
|
link_to_existing_draft: {
|
||||||
|
title: '票据关联草稿',
|
||||||
|
detail: '正在把本次票据关联到现有草稿...'
|
||||||
|
},
|
||||||
|
create_new_claim_from_documents: {
|
||||||
|
title: '新建报销草稿',
|
||||||
|
detail: '正在根据当前票据新建报销草稿...'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const config = configs[reviewAction] || {
|
||||||
|
title: '报销草稿处理',
|
||||||
|
detail: attachmentCount
|
||||||
|
? '正在根据 OCR 结果更新草稿和右侧核对信息...'
|
||||||
|
: '正在更新草稿和右侧核对信息...'
|
||||||
|
}
|
||||||
|
|
||||||
|
startFlowStep('expense-claim-draft', {
|
||||||
|
title: config.title,
|
||||||
|
tool: 'database.expense_claims.save_or_submit',
|
||||||
|
detail: config.detail
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function resolveToolCallFlowMeta(toolCall, index) {
|
function resolveToolCallFlowMeta(toolCall, index) {
|
||||||
const toolType = String(toolCall?.tool_type || '').toLowerCase()
|
const toolType = String(toolCall?.tool_type || '').toLowerCase()
|
||||||
const toolName = String(toolCall?.tool_name || '').toLowerCase()
|
const toolName = String(toolCall?.tool_name || '').toLowerCase()
|
||||||
|
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
||||||
|
? toolCall.response_json
|
||||||
|
: {}
|
||||||
|
const responseMessage = String(response.message || '').trim()
|
||||||
const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}`
|
const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}`
|
||||||
if (toolType.includes('rule')) {
|
if (toolType.includes('rule')) {
|
||||||
return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' }
|
return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' }
|
||||||
@@ -3660,7 +3773,15 @@ export default {
|
|||||||
return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' }
|
return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' }
|
||||||
}
|
}
|
||||||
if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) {
|
if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) {
|
||||||
return { key, title: '报销草稿处理', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
if (
|
||||||
|
response.submission_blocked ||
|
||||||
|
String(response.status || '').trim() === 'submitted' ||
|
||||||
|
responseMessage.includes('AI预审') ||
|
||||||
|
responseMessage.includes('审批')
|
||||||
|
) {
|
||||||
|
return { key: 'pre-submit-review', title: 'AI预审与风险识别', tool: 'ExpenseClaimService.submit_claim' }
|
||||||
|
}
|
||||||
|
return { key: 'expense-claim-draft', title: '报销草稿处理', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
||||||
}
|
}
|
||||||
if (toolType.includes('database')) {
|
if (toolType.includes('database')) {
|
||||||
return { key, title: '数据查询/字段处理', tool: toolCall?.tool_name || 'DatabaseTool' }
|
return { key, title: '数据查询/字段处理', tool: toolCall?.tool_name || 'DatabaseTool' }
|
||||||
@@ -3675,6 +3796,12 @@ export default {
|
|||||||
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
||||||
? toolCall.response_json
|
? toolCall.response_json
|
||||||
: {}
|
: {}
|
||||||
|
if (String(response.status || '').trim() === 'submitted') {
|
||||||
|
return `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
|
||||||
|
}
|
||||||
|
if (response.submission_blocked) {
|
||||||
|
return String(response.message || '').trim() || 'AI预审发现待补充项,暂未提交审批'
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
String(response.message || response.summary || response.result_summary || '').trim()
|
String(response.message || response.summary || response.result_summary || '').trim()
|
||||||
|| String(toolCall?.tool_name || '').trim()
|
|| String(toolCall?.tool_name || '').trim()
|
||||||
@@ -3687,22 +3814,17 @@ export default {
|
|||||||
if (run?.semantic_parse && flowSteps.value.some((step) => step.key === 'intent')) {
|
if (run?.semantic_parse && flowSteps.value.some((step) => step.key === 'intent')) {
|
||||||
clearFlowSimulationTimers()
|
clearFlowSimulationTimers()
|
||||||
const semanticDurations = resolveSemanticPhaseDurations(run)
|
const semanticDurations = resolveSemanticPhaseDurations(run)
|
||||||
|
const intentStep = flowSteps.value.find((step) => step.key === 'intent')
|
||||||
|
const extractionStep = flowSteps.value.find((step) => step.key === 'extraction')
|
||||||
completePendingFlowStep(
|
completePendingFlowStep(
|
||||||
'intent',
|
'intent',
|
||||||
summarizeSemanticIntentDetail(run.semantic_parse),
|
summarizeSemanticIntentDetail(run.semantic_parse),
|
||||||
semanticDurations.intentMs
|
intentStep?.startedAt ? null : semanticDurations.intentMs
|
||||||
)
|
)
|
||||||
completePendingFlowStep(
|
completePendingFlowStep(
|
||||||
'extraction',
|
'extraction',
|
||||||
summarizeSemanticParseDetail(run.semantic_parse, run?.ontology_json || {}),
|
summarizeSemanticParseDetail(run.semantic_parse, run?.ontology_json || {}),
|
||||||
semanticDurations.extractionMs
|
extractionStep?.startedAt ? null : semanticDurations.extractionMs
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (flowSteps.value.some((step) => step.key === 'agent')) {
|
|
||||||
completePendingFlowStep(
|
|
||||||
'agent',
|
|
||||||
toolCalls.length ? `已完成 ${toolCalls.length} 个工具调用` : FLOW_STEP_FALLBACKS.agent.completedText
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3734,12 +3856,10 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
flowSteps.value
|
flowSteps.value
|
||||||
.filter((step) => step.key !== 'result' && ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status))
|
.filter((step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status))
|
||||||
.forEach((step) => {
|
.forEach((step) => {
|
||||||
completeFlowStep(step.key, resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED }))
|
completeFlowStep(step.key, resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED }))
|
||||||
})
|
})
|
||||||
startFlowStep('result', '正在返回处理结果...')
|
|
||||||
completeFlowStep('result', '结果已返回到对话区', resolveResultStepDurationMs(run))
|
|
||||||
flowFinishedAt.value = Date.now()
|
flowFinishedAt.value = Date.now()
|
||||||
if (reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) {
|
if (reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) {
|
||||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
||||||
@@ -4419,7 +4539,7 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveInlineReviewChanges() {
|
function saveInlineReviewChanges() {
|
||||||
if (!activeReviewPayload.value || !reviewHasUnsavedChanges.value || reviewActionBusy.value) return
|
if (!activeReviewPayload.value || !reviewHasUnsavedChanges.value || reviewActionBusy.value) return
|
||||||
|
|
||||||
if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) {
|
if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) {
|
||||||
@@ -4429,28 +4549,36 @@ export default {
|
|||||||
reviewActionBusy.value = true
|
reviewActionBusy.value = true
|
||||||
try {
|
try {
|
||||||
const fields = mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value)
|
const fields = mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value)
|
||||||
const documentCorrectionMessage = buildReviewDocumentCorrectionMessage(
|
const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, reviewInlineForm.value)
|
||||||
reviewDocumentBaseDrafts.value,
|
const messageText = `${buildLocalReviewSavedMessage(
|
||||||
reviewDocumentDrafts.value
|
|
||||||
)
|
|
||||||
await submitComposer({
|
|
||||||
rawText: [buildReviewCorrectionMessage(fields), documentCorrectionMessage].filter(Boolean).join('\n'),
|
|
||||||
userText: buildReviewSubmitUserText(
|
|
||||||
reviewInlineBaseForm.value,
|
reviewInlineBaseForm.value,
|
||||||
reviewInlineForm.value,
|
reviewInlineForm.value,
|
||||||
reviewInlinePendingFiles.value,
|
reviewInlinePendingFiles.value,
|
||||||
reviewDocumentBaseDrafts.value,
|
reviewDocumentBaseDrafts.value,
|
||||||
reviewDocumentDrafts.value
|
reviewDocumentDrafts.value
|
||||||
),
|
)} ${buildLocalReviewCompletionMessage(nextReviewPayload)}`
|
||||||
pendingText: '正在保存修改并刷新右侧核对信息...',
|
|
||||||
files: reviewInlinePendingFiles.value,
|
reviewInlineBaseFields.value = cloneReviewEditFields(fields)
|
||||||
systemGenerated: true,
|
reviewInlineBaseForm.value = { ...reviewInlineForm.value }
|
||||||
extraContext: {
|
reviewDocumentBaseDrafts.value = cloneReviewDocumentDrafts(reviewDocumentDrafts.value)
|
||||||
review_action: 'edit_review',
|
if (latestReviewMessage.value) {
|
||||||
review_form_values: buildReviewFormValues(fields),
|
latestReviewMessage.value.reviewPayload = nextReviewPayload
|
||||||
review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value)
|
|
||||||
}
|
}
|
||||||
})
|
if (currentInsight.value?.agent) {
|
||||||
|
currentInsight.value = {
|
||||||
|
...currentInsight.value,
|
||||||
|
agent: {
|
||||||
|
...currentInsight.value.agent,
|
||||||
|
reviewPayload: nextReviewPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages.value.push(createMessage('assistant', messageText, [], {
|
||||||
|
meta: ['本地修改'],
|
||||||
|
draftPayload: latestReviewMessage.value?.draftPayload || null,
|
||||||
|
reviewPayload: nextReviewPayload
|
||||||
|
}))
|
||||||
|
nextTick(scrollToBottom)
|
||||||
} finally {
|
} finally {
|
||||||
reviewActionBusy.value = false
|
reviewActionBusy.value = false
|
||||||
}
|
}
|
||||||
@@ -4517,6 +4645,7 @@ export default {
|
|||||||
const extraContext = options.extraContext && typeof options.extraContext === 'object'
|
const extraContext = options.extraContext && typeof options.extraContext === 'object'
|
||||||
? { ...options.extraContext }
|
? { ...options.extraContext }
|
||||||
: {}
|
: {}
|
||||||
|
const reviewAction = String(extraContext.review_action || '').trim()
|
||||||
const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value)
|
const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value)
|
||||||
const hasExistingDocumentEvent =
|
const hasExistingDocumentEvent =
|
||||||
Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0
|
Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0
|
||||||
@@ -4527,14 +4656,14 @@ export default {
|
|||||||
hasExistingDocumentEvent &&
|
hasExistingDocumentEvent &&
|
||||||
!resolvedUploadDisposition &&
|
!resolvedUploadDisposition &&
|
||||||
!options.skipUploadDecisionPrompt &&
|
!options.skipUploadDecisionPrompt &&
|
||||||
!String(extraContext.review_action || '').trim()
|
!reviewAction
|
||||||
) {
|
) {
|
||||||
uploadDecisionDialogOpen.value = true
|
uploadDecisionDialogOpen.value = true
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
resetFlowRun({ rawText, attachmentCount: files.length })
|
resetFlowRun()
|
||||||
if (rawText) {
|
if (rawText && !reviewAction) {
|
||||||
startFlowStep('intent', '正在识别业务意图...')
|
startFlowStep('intent', '正在识别业务意图...')
|
||||||
startSemanticFlowPreview(rawText, { attachmentCount: files.length })
|
startSemanticFlowPreview(rawText, { attachmentCount: files.length })
|
||||||
}
|
}
|
||||||
@@ -4589,17 +4718,18 @@ export default {
|
|||||||
let ocrFilePreviews = []
|
let ocrFilePreviews = []
|
||||||
|
|
||||||
if (files.length) {
|
if (files.length) {
|
||||||
startFlowStep('ocr', `正在识别 ${files.length} 份附件...`)
|
const ocrStartedAt = Date.now()
|
||||||
|
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
|
||||||
try {
|
try {
|
||||||
ocrPayload = await recognizeOcrFiles(files)
|
ocrPayload = await recognizeOcrFiles(files)
|
||||||
ocrSummary = buildOcrSummary(ocrPayload)
|
ocrSummary = buildOcrSummary(ocrPayload)
|
||||||
ocrDocuments = normalizeOcrDocuments(ocrPayload)
|
ocrDocuments = normalizeOcrDocuments(ocrPayload)
|
||||||
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
|
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
|
||||||
rememberFilePreviews(ocrFilePreviews)
|
rememberFilePreviews(ocrFilePreviews)
|
||||||
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`)
|
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('OCR request failed:', error)
|
console.warn('OCR request failed:', error)
|
||||||
completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称')
|
completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称', Date.now() - ocrStartedAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4619,16 +4749,9 @@ export default {
|
|||||||
extraContext.review_action = 'create_new_claim_from_documents'
|
extraContext.review_action = 'create_new_claim_from_documents'
|
||||||
}
|
}
|
||||||
|
|
||||||
const runningExtractionStep = flowSteps.value.find(
|
startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), {
|
||||||
(step) => step.key === 'extraction' && step.status === FLOW_STEP_STATUS_RUNNING
|
attachmentCount: effectiveFileNames.length
|
||||||
)
|
})
|
||||||
if (runningExtractionStep) {
|
|
||||||
completeFlowStep(
|
|
||||||
'extraction',
|
|
||||||
runningExtractionStep.detail || FLOW_STEP_FALLBACKS.extraction.completedText
|
|
||||||
)
|
|
||||||
}
|
|
||||||
startFlowStep('agent', FLOW_STEP_FALLBACKS.agent.runningText)
|
|
||||||
|
|
||||||
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
|
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
|
||||||
const payload = await runOrchestrator(
|
const payload = await runOrchestrator(
|
||||||
@@ -4642,6 +4765,8 @@ export default {
|
|||||||
is_admin: Boolean(user.isAdmin),
|
is_admin: Boolean(user.isAdmin),
|
||||||
name: user.name || '',
|
name: user.name || '',
|
||||||
role: user.role || '',
|
role: user.role || '',
|
||||||
|
department: user.department || user.departmentName || '',
|
||||||
|
department_name: user.department || user.departmentName || '',
|
||||||
position: user.position || '',
|
position: user.position || '',
|
||||||
grade: user.grade || '',
|
grade: user.grade || '',
|
||||||
...buildClientTimeContext(),
|
...buildClientTimeContext(),
|
||||||
@@ -4749,7 +4874,10 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openEditReviewDialog(message) {
|
function openEditReviewDialog(message) {
|
||||||
reviewEditFields.value = cloneReviewEditFields(message?.reviewPayload?.edit_fields)
|
const sourceFields = reviewInlineBaseFields.value.length
|
||||||
|
? mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value)
|
||||||
|
: cloneReviewEditFields(message?.reviewPayload?.edit_fields)
|
||||||
|
reviewEditFields.value = cloneReviewEditFields(sourceFields)
|
||||||
reviewActionMessageId.value = String(message?.id || '')
|
reviewActionMessageId.value = String(message?.id || '')
|
||||||
reviewEditDialogOpen.value = true
|
reviewEditDialogOpen.value = true
|
||||||
}
|
}
|
||||||
@@ -4761,22 +4889,46 @@ export default {
|
|||||||
reviewActionMessageId.value = ''
|
reviewActionMessageId.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyEditedReview() {
|
function applyEditedReview() {
|
||||||
if (reviewActionBusy.value) return
|
if (reviewActionBusy.value) return
|
||||||
|
|
||||||
reviewActionBusy.value = true
|
reviewActionBusy.value = true
|
||||||
try {
|
try {
|
||||||
const fields = cloneReviewEditFields(reviewEditFields.value)
|
const fields = cloneReviewEditFields(reviewEditFields.value)
|
||||||
await submitComposer({
|
const nextInlineState = buildInlineReviewState({
|
||||||
rawText: buildReviewCorrectionMessage(fields),
|
...(activeReviewPayload.value || {}),
|
||||||
userText: '我已修改识别信息,请按最新内容更新。',
|
edit_fields: fields
|
||||||
pendingText: '正在根据修改内容重新识别...',
|
|
||||||
systemGenerated: true,
|
|
||||||
extraContext: {
|
|
||||||
review_action: 'edit_review',
|
|
||||||
review_form_values: buildReviewFormValues(fields)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, nextInlineState)
|
||||||
|
const messageText = `${buildLocalReviewSavedMessage(
|
||||||
|
reviewInlineForm.value,
|
||||||
|
nextInlineState,
|
||||||
|
[],
|
||||||
|
reviewDocumentBaseDrafts.value,
|
||||||
|
reviewDocumentDrafts.value
|
||||||
|
)} ${buildLocalReviewCompletionMessage(nextReviewPayload)}`
|
||||||
|
|
||||||
|
reviewInlineForm.value = { ...nextInlineState }
|
||||||
|
reviewInlineBaseForm.value = { ...nextInlineState }
|
||||||
|
reviewInlineBaseFields.value = cloneReviewEditFields(fields)
|
||||||
|
if (latestReviewMessage.value) {
|
||||||
|
latestReviewMessage.value.reviewPayload = nextReviewPayload
|
||||||
|
}
|
||||||
|
if (currentInsight.value?.agent) {
|
||||||
|
currentInsight.value = {
|
||||||
|
...currentInsight.value,
|
||||||
|
agent: {
|
||||||
|
...currentInsight.value.agent,
|
||||||
|
reviewPayload: nextReviewPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages.value.push(createMessage('assistant', messageText, [], {
|
||||||
|
meta: ['本地修改'],
|
||||||
|
draftPayload: latestReviewMessage.value?.draftPayload || null,
|
||||||
|
reviewPayload: nextReviewPayload
|
||||||
|
}))
|
||||||
|
nextTick(scrollToBottom)
|
||||||
} finally {
|
} finally {
|
||||||
reviewActionBusy.value = false
|
reviewActionBusy.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,9 +44,6 @@ const DOCUMENT_TYPE_LABELS = {
|
|||||||
|
|
||||||
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
||||||
'travel',
|
'travel',
|
||||||
'hotel',
|
|
||||||
'transport',
|
|
||||||
'meal',
|
|
||||||
'meeting',
|
'meeting',
|
||||||
'entertainment'
|
'entertainment'
|
||||||
])
|
])
|
||||||
@@ -100,7 +97,7 @@ function buildFallbackProgressSteps() {
|
|||||||
return [
|
return [
|
||||||
{ index: 1, label: '保存草稿', time: '已完成', done: true, active: true },
|
{ index: 1, label: '保存草稿', time: '已完成', done: true, active: true },
|
||||||
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
|
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
|
||||||
{ index: 3, label: 'AI验审', time: '待处理' },
|
{ index: 3, label: 'AI预审', time: '待处理' },
|
||||||
{ index: 4, label: '直属领导审批', time: '待处理' },
|
{ index: 4, label: '直属领导审批', time: '待处理' },
|
||||||
{ index: 5, label: '财务审批', time: '待处理' },
|
{ index: 5, label: '财务审批', time: '待处理' },
|
||||||
{ index: 6, label: '归档入账', time: '待处理' }
|
{ index: 6, label: '归档入账', time: '待处理' }
|
||||||
@@ -281,9 +278,6 @@ function buildDraftBlockingIssues(request, expenseItems) {
|
|||||||
if (isPlaceholderValue(request.profileName)) {
|
if (isPlaceholderValue(request.profileName)) {
|
||||||
issues.push('申请人未完善')
|
issues.push('申请人未完善')
|
||||||
}
|
}
|
||||||
if (isPlaceholderValue(request.profileDepartment)) {
|
|
||||||
issues.push('所属部门未完善')
|
|
||||||
}
|
|
||||||
if (isPlaceholderValue(request.typeLabel)) {
|
if (isPlaceholderValue(request.typeLabel)) {
|
||||||
issues.push('报销类型未完善')
|
issues.push('报销类型未完善')
|
||||||
}
|
}
|
||||||
@@ -1097,9 +1091,9 @@ export default {
|
|||||||
const claimStatus = String(payload?.status || '').trim().toLowerCase()
|
const claimStatus = String(payload?.status || '').trim().toLowerCase()
|
||||||
const approvalStage = String(payload?.approval_stage || payload?.approvalStage || '').trim()
|
const approvalStage = String(payload?.approval_stage || payload?.approvalStage || '').trim()
|
||||||
if (claimStatus === 'submitted') {
|
if (claimStatus === 'submitted') {
|
||||||
toast(`${request.value.id} 已完成 AI验审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`)
|
toast(`${request.value.id} 已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`)
|
||||||
} else if (claimStatus === 'supplement') {
|
} else if (claimStatus === 'supplement') {
|
||||||
toast(`${request.value.id} AI验审未通过,已转待补充。`)
|
toast(`${request.value.id} AI预审未通过,已转待补充。`)
|
||||||
} else {
|
} else {
|
||||||
toast(`${request.value.id} 提交结果已更新。`)
|
toast(`${request.value.id} 提交结果已更新。`)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user