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

@@ -17,11 +17,12 @@ def get_db() -> Generator[Session, None, None]:
@dataclass(slots=True)
class CurrentUserContext:
username: str
name: str
role_codes: list[str]
is_admin: bool
class CurrentUserContext:
username: str
name: str
role_codes: list[str]
is_admin: bool
department_name: str = ""
def get_current_user(
@@ -41,6 +42,10 @@ def get_current_user(
str | None,
Header(description="是否管理员,支持 `true/false/1/0`。"),
] = None,
x_auth_department: Annotated[
str | None,
Header(description="当前登录人的所属部门。"),
] = None,
) -> CurrentUserContext:
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"}
@@ -56,10 +61,11 @@ def get_current_user(
return CurrentUserContext(
username=username or name,
name=name or username,
role_codes=role_codes,
is_admin=is_admin,
)
name=name or username,
role_codes=role_codes,
is_admin=is_admin,
department_name=(x_auth_department or "").strip(),
)
def require_admin_user(

View File

@@ -12,6 +12,8 @@ class AuthUserRead(BaseModel):
username: str
name: str
role: str
department: str = ""
departmentName: str = ""
position: str = ""
grade: str = ""
roleCodes: list[str] = Field(default_factory=list)

View File

@@ -93,9 +93,11 @@ LEGACY_RULE_CODES = (
"rule.ap.payment_dual_review",
)
ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements"
COMPANY_TRAVEL_RULE_VERSION = "v1.0.0"
COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0"
ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements"
COMPANY_TRAVEL_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 = {
"kind": "policy_rule_draft",
@@ -267,49 +269,53 @@ class AgentFoundationService:
)
company_travel_rule = AgentAsset(
asset_type=AgentAssetType.RULE.value,
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
name="公司差旅费报销规则",
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=["expense", "travel_policy", "travel_standard"],
owner="财务制度管理组",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
name="公司差旅费报销规则",
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=list(COMPANY_TRAVEL_RULE_SCENARIO_JSON),
owner="财务制度管理组",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version=COMPANY_TRAVEL_RULE_VERSION,
published_version=COMPANY_TRAVEL_RULE_VERSION,
working_version=COMPANY_TRAVEL_RULE_VERSION,
config_json={
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"rule_template_label": "差旅报销 Excel 模板",
},
)
"tag": "财务规则",
"detail_mode": "spreadsheet",
"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 模板",
},
)
platform_risk_assets = self._build_platform_risk_seed_assets()
company_communication_rule = AgentAsset(
asset_type=AgentAssetType.RULE.value,
code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
name="公司通信费报销规则",
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=["expense", "communication_expense", "expense_standard"],
owner="财务制度管理组",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
name="公司通信费报销规则",
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=list(COMPANY_COMMUNICATION_RULE_SCENARIO_JSON),
owner="财务制度管理组",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version=COMPANY_COMMUNICATION_RULE_VERSION,
published_version=COMPANY_COMMUNICATION_RULE_VERSION,
working_version=COMPANY_COMMUNICATION_RULE_VERSION,
config_json={
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"rule_template_label": "通信费报销 Excel 模板",
},
)
"tag": "财务规则",
"detail_mode": "spreadsheet",
"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 模板",
},
)
skill_expense_asset = AgentAsset(
asset_type=AgentAssetType.SKILL.value,
code="skill.expense.summary_lookup",
@@ -1266,47 +1272,52 @@ class AgentFoundationService:
if COMPANY_TRAVEL_EXPENSE_RULE_CODE not in existing_codes:
company_travel_rule = self._create_seed_asset(
asset_type=AgentAssetType.RULE.value,
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
name="公司差旅费报销规则",
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=["expense", "travel_policy", "travel_standard"],
owner="财务制度管理组",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
name="公司差旅费报销规则",
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=list(COMPANY_TRAVEL_RULE_SCENARIO_JSON),
owner="财务制度管理组",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version=COMPANY_TRAVEL_RULE_VERSION,
config_json={
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"detail_mode": "spreadsheet",
"rule_template_label": "差旅报销 Excel 模板",
},
)
"enabled": True,
"tag": "财务规则",
"detail_mode": "spreadsheet",
"scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0],
"rule_template_label": "差旅报销 Excel 模板",
},
)
if COMPANY_COMMUNICATION_EXPENSE_RULE_CODE not in existing_codes:
company_communication_rule = self._create_seed_asset(
asset_type=AgentAssetType.RULE.value,
code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
name="公司通信费报销规则",
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=["expense", "communication_expense", "expense_standard"],
owner="财务制度管理组",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
name="公司通信费报销规则",
description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。",
domain=AgentAssetDomain.EXPENSE.value,
scenario_json=list(COMPANY_COMMUNICATION_RULE_SCENARIO_JSON),
owner="财务制度管理组",
reviewer="顾承宇",
status=AgentAssetStatus.ACTIVE.value,
current_version=COMPANY_COMMUNICATION_RULE_VERSION,
config_json={
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"detail_mode": "spreadsheet",
"rule_template_label": "通信费报销 Excel 模板",
},
)
if company_travel_rule is not None:
if not str(company_travel_rule.current_version or "").strip():
company_travel_rule.current_version = COMPANY_TRAVEL_RULE_VERSION
"enabled": True,
"tag": "财务规则",
"detail_mode": "spreadsheet",
"scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
"ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0],
"rule_template_label": "通信费报销 Excel 模板",
},
)
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():
company_travel_rule.current_version = COMPANY_TRAVEL_RULE_VERSION
if not str(company_travel_rule.working_version or "").strip():
company_travel_rule.working_version = company_travel_rule.current_version
if not str(company_travel_rule.published_version or "").strip():
@@ -1318,11 +1329,13 @@ class AgentFoundationService:
**(company_travel_rule.config_json or {}),
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"rule_template_label": "差旅报销 Excel 模板",
}
"tag": "财务规则",
"detail_mode": "spreadsheet",
"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 模板",
}
company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed(
company_travel_rule,
version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION),
@@ -1350,9 +1363,10 @@ class AgentFoundationService:
reviewed_at=datetime.now(UTC),
)
if company_communication_rule is not None:
if not str(company_communication_rule.current_version or "").strip():
company_communication_rule.current_version = COMPANY_COMMUNICATION_RULE_VERSION
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():
company_communication_rule.current_version = COMPANY_COMMUNICATION_RULE_VERSION
if not str(company_communication_rule.working_version or "").strip():
company_communication_rule.working_version = company_communication_rule.current_version
if not str(company_communication_rule.published_version or "").strip():
@@ -1364,11 +1378,13 @@ class AgentFoundationService:
**(company_communication_rule.config_json or {}),
"severity": "medium",
"enabled": True,
"tag": "财务规则",
"detail_mode": "spreadsheet",
"rule_library": FINANCE_RULES_LIBRARY,
"rule_template_label": "通信费报销 Excel 模板",
}
"tag": "财务规则",
"detail_mode": "spreadsheet",
"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 模板",
}
company_communication_rule_meta = self._ensure_company_communication_rule_spreadsheet_seed(
company_communication_rule,
version=str(company_communication_rule.current_version or COMPANY_COMMUNICATION_RULE_VERSION),

View File

@@ -31,6 +31,7 @@ class AuthenticatedUser:
username: str
name: str
role: str
department: str
position: str
grade: str
role_codes: list[str]
@@ -78,6 +79,7 @@ class AuthService:
username=admin_username or admin_email,
name=display_name,
role="管理员",
department="",
position="系统管理员",
grade="",
role_codes=["manager"],
@@ -94,7 +96,7 @@ class AuthService:
stmt = (
select(Employee)
.options(selectinload(Employee.roles))
.options(selectinload(Employee.organization_unit), selectinload(Employee.roles))
.where(func.lower(Employee.email) == identifier.lower())
)
employee = self.db.execute(stmt).scalars().first()
@@ -120,6 +122,7 @@ class AuthService:
username=employee.email,
name=employee.name,
role=ROLE_LABELS.get(primary_role_code, "使用者"),
department=employee.organization_unit.name if employee.organization_unit is not None else "",
position=employee.position,
grade=employee.grade,
role_codes=role_codes or ["user"],
@@ -134,6 +137,8 @@ class AuthService:
username=user.username,
name=user.name,
role=user.role,
department=user.department,
departmentName=user.department,
position=user.position,
grade=user.grade,
roleCodes=user.role_codes,

File diff suppressed because it is too large Load Diff

View File

@@ -120,11 +120,13 @@ EXPLAIN_KEYWORDS = ("为什么", "依据", "原因", "怎么处理", "是否可
COMPARE_KEYWORDS = ("对比", "比较", "相比", "差异", "变化")
RISK_KEYWORDS = ("风险", "异常", "重复", "超标", "超预算", "逾期", "验真", "巡检")
DRAFT_KEYWORDS = ("生成", "草稿", "起草", "拟一份", "创建", "发起", "准备")
DRAFT_FOLLOW_UP_KEYWORDS = (
"继续",
"补充",
"补一下",
"修改",
DRAFT_FOLLOW_UP_KEYWORDS = (
"继续",
"下一步",
"核对",
"补充",
"补一下",
"修改",
"改成",
"改为",
"换成",
@@ -136,9 +138,16 @@ DRAFT_FOLLOW_UP_KEYWORDS = (
"地点是",
"金额是",
"日期是",
"时间是",
)
OPERATE_KEYWORDS = (
"时间是",
)
EXPENSE_REVIEW_ACTIONS = {
"save_draft",
"next_step",
"edit_review",
"link_to_existing_draft",
"create_new_claim_from_documents",
}
OPERATE_KEYWORDS = (
"直接付款",
"帮我付款",
"安排付款",
@@ -636,12 +645,17 @@ class SemanticOntologyService:
def _compact(text: str) -> str:
return re.sub(r"\s+", "", text).lower()
@staticmethod
def _resolve_context_scenario(context_json: dict[str, Any]) -> str | None:
value = str(context_json.get("conversation_scenario") or "").strip()
if value in CONTEXTUAL_SCENARIOS:
return value
return None
@staticmethod
def _resolve_context_scenario(context_json: dict[str, Any]) -> str | None:
value = str(context_json.get("conversation_scenario") or "").strip()
if value in CONTEXTUAL_SCENARIOS:
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
@staticmethod
def _resolve_session_type_scenario(context_json: dict[str, Any]) -> str | None:
@@ -728,19 +742,22 @@ class SemanticOntologyService:
)
return len(compact_query) <= 12 and not has_domain_keyword
def _should_inherit_expense_draft(
self,
compact_query: str,
def _should_inherit_expense_draft(
self,
compact_query: str,
*,
scenario: str,
entities: list[OntologyEntity],
time_range: OntologyTimeRange,
context_json: dict[str, Any],
) -> bool:
context_scenario = self._resolve_context_scenario(context_json)
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
if context_scenario != "expense" and not draft_claim_id:
return False
context_json: dict[str, Any],
) -> bool:
context_scenario = self._resolve_context_scenario(context_json)
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:
return False
if any(keyword in compact_query for keyword in DRAFT_FOLLOW_UP_KEYWORDS):
return True
@@ -1674,15 +1691,16 @@ class SemanticOntologyService:
return False, None
@staticmethod
def _allow_incomplete_draft(
context_json: dict[str, Any],
*,
scenario: str,
intent: str,
def _allow_incomplete_draft(
context_json: dict[str, Any],
*,
scenario: str,
intent: str,
) -> bool:
if scenario != "expense" or intent != "draft":
return False
return str(context_json.get("review_action") or "").strip() == "save_draft"
if scenario != "expense" or intent != "draft":
return False
review_action = str(context_json.get("review_action") or "").strip()
return review_action in EXPENSE_REVIEW_ACTIONS
@staticmethod
def _display_slot_label(slot: str) -> str:

View File

@@ -173,9 +173,11 @@ class OrchestratorService:
task_asset=task_asset,
)
selected_capability_codes = self._flatten_capability_codes(capabilities)
requires_confirmation = (
ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value
)
is_expense_review_action = self._is_expense_review_action(context_json)
requires_confirmation = (
ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value
and not is_expense_review_action
)
route_json = {
"orchestrated_by": AgentName.ORCHESTRATOR.value,
@@ -526,7 +528,11 @@ class OrchestratorService:
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":
tool_payload, degraded = self._invoke_tool(
run_id=run_id,
@@ -662,9 +668,9 @@ class OrchestratorService:
"degraded": True,
}
if ontology.scenario == "expense":
tool_type = AgentToolType.DATABASE.value
tool_name = "database.expense_claims.save_or_submit"
if ontology.scenario == "expense" or self._is_expense_review_action(context_json):
tool_type = AgentToolType.DATABASE.value
tool_name = "database.expense_claims.save_or_submit"
executor = lambda: self.expense_claim_service.save_or_submit_from_ontology(
run_id=run_id,
user_id=payload.user_id,
@@ -781,10 +787,17 @@ class OrchestratorService:
failed_tool_count=failed_tool_count,
)
@staticmethod
def _resolve_next_step(ontology: OntologyParseResult, source: str) -> str:
if ontology.clarification_required:
return "ask_clarification"
@staticmethod
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:
return "ask_clarification"
if ontology.intent == "draft":
return "create_draft"
if ontology.scenario == "knowledge" or ontology.intent == "explain":
@@ -793,7 +806,18 @@ class OrchestratorService:
return "run_rule"
if ontology.intent in {"query", "compare"}:
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
def _flatten_capability_codes(

View File

@@ -255,7 +255,7 @@ class UserAgentService:
query_payload = self._build_query_payload(payload)
draft_payload = (
self._build_draft_payload(payload)
if payload.ontology.intent == "draft"
if self._should_build_draft_payload(payload)
else None
)
review_payload = self._build_review_payload(
@@ -1683,7 +1683,10 @@ class UserAgentService:
if not risk_flags and not platform_messages:
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:
reasons.extend(platform_messages)
citation_text = (
@@ -1764,6 +1767,17 @@ class UserAgentService:
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(
self,
payload: UserAgentRequest,
@@ -1868,6 +1882,7 @@ class UserAgentService:
payload,
slot_cards=slot_cards,
)
submission_blocked = bool(payload.tool_payload.get("submission_blocked"))
risk_briefs = self._build_review_risk_briefs(
payload,
citations=citations,
@@ -1877,7 +1892,7 @@ class UserAgentService:
association_choice_pending = self._is_review_association_choice_pending(payload)
can_proceed = (
False
if association_choice_pending
if association_choice_pending or submission_blocked
else self._can_proceed_review(
payload,
missing_slot_keys=missing_slot_keys,
@@ -2157,6 +2172,15 @@ class UserAgentService:
claim_groups: list[UserAgentReviewClaimGroup],
) -> 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(
payload.context_json.get("name") or ""
).strip()
@@ -2229,6 +2253,36 @@ class UserAgentService:
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(
self,
payload: UserAgentRequest,
@@ -2383,6 +2437,16 @@ class UserAgentService:
stage_text = draft_payload.approval_stage or "审批中"
return f"报销单 {draft_payload.claim_no or ''} 已提交,当前节点为 {stage_text}".strip()
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 (
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,
)
from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
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
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:
with build_session() as db:
service = AgentAssetService(db)

View File

@@ -11,6 +11,7 @@ from app.api.deps import CurrentUserContext
from app.db.base import Base
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit
from app.schemas.ontology import OntologyParseRequest
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
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)
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:
service = ExpenseClaimService.__new__(ExpenseClaimService)
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
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(
username="emp-risk@example.com",
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)
assert submitted is not None
assert submitted.status == "supplement"
assert submitted.approval_stage == "待补充"
assert submitted.submitted_at is None
assert submitted.status == "submitted"
assert submitted.approval_stage == "直属领导审批"
assert submitted.submitted_at is not None
assert any(
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "submission_review"
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(
username="emp-travel@example.com",
name="张三",
@@ -876,8 +937,8 @@ def test_submit_claim_blocks_travel_route_mismatch_without_explanation(monkeypat
submitted = service.submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "supplement"
assert submitted.approval_stage == "待补充"
assert submitted.status == "submitted"
assert submitted.approval_stage == "直属领导审批"
assert any(
isinstance(flag, dict)
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(
username="emp-hotel@example.com",
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)
assert submitted is not None
assert submitted.status == "supplement"
assert submitted.approval_stage == "待补充"
assert submitted.status == "submitted"
assert submitted.approval_stage == "直属领导审批"
assert any(
isinstance(flag, dict)
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
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()
with session_factory() as db:
service = SemanticOntologyService(db)
@@ -341,11 +343,33 @@ def test_semantic_ontology_service_rejects_draft_intent_inside_knowledge_session
},
)
)
assert result.scenario == "knowledge"
assert result.intent == "query"
assert result.clarification_required is False
assert result.clarification_question is None
assert result.scenario == "knowledge"
assert result.intent == "query"
assert result.clarification_required is False
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:

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

@@ -727,7 +727,7 @@ def test_user_agent_draft_returns_structured_payload() -> None:
assert response.answer == response.review_payload.body_message
def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None:
def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None:
session_factory = build_session_factory()
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
@@ -751,13 +751,50 @@ def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None:
)
)
assert (
response.answer
== "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。"
)
def test_user_agent_builds_review_payload_for_multi_document_expense_flow() -> None:
assert (
response.answer
== "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。"
)
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:
session_factory = build_session_factory()
with session_factory() as db:
yesterday = (datetime.now(UTC).date() - timedelta(days=1)).isoformat()