feat: 重构报销单AI预审流程并添加平台风险规则引擎

- 将AI验审改为AI预审,高风险不再拦截而是随单流转给审批人复核
- 新增平台风险规则评估引擎,支持事由过短、票据异常、重复发票等多种评估器
- 用户上下文增加部门信息(department_name),认证流程同步关联组织架构
- 规则scenario_json改为中文标签(差旅/费用科目),统一场景分类
- 新增orchestrator审核流程测试用例
- 前端更新审计视图、差旅报销等相关页面
This commit is contained in:
caoxiaozhu
2026-05-20 09:36:01 +08:00
parent 2574bc81d1
commit 57957d11a0
23 changed files with 2109 additions and 553 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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 || '该'}单据已保存到草稿,请到报销页面查看。`)
} }

View File

@@ -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('待提交')) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', '切换资产类型后会自动重新拉取数据']
} }
}) })

View File

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

View File

@@ -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} 提交结果已更新。`)
} }