diff --git a/document/development/ui/personal-workbench-home-reference.html b/document/development/ui/personal-workbench-home-reference.html new file mode 100644 index 0000000..634d783 --- /dev/null +++ b/document/development/ui/personal-workbench-home-reference.html @@ -0,0 +1,746 @@ + + + + + + X-Financial 个人工作台首页参考稿 + + + +
+ + +
+
+
+ 个人工作台 + 把费用申请、报销处理、进度查询和制度问答集中到一个入口。 +
+
+ A + admin +
+
+ +
+
+
+ +
+ +
+

嗨,admin,描述您想做的事,AI 会直接帮您处理

+

+ 我会自动识别您的意图,协助完成费用申请、报销、查询和制度问答等业务工作, + 并把事情推进到可执行的下一步。 +

+ +
+
例如:帮我查一下上周提交的差旅报销到哪一步了
+ + +
+
+
+ +
+
+
+ + +
+

费用申请

+

发起招待、差旅、采购等费用事项

+
+ +
+
+ + +
+

报销处理

+

上传票据,生成草稿并核对材料

+
+ +
+
+ + +
+

进度查询

+

查询单据状态、审批节点和到账情况

+
+ +
+
+ + +
+

制度问答

+

咨询标准、附件要求和可报销边界

+
+
+ +
+
+
+

报销待办

+ 查看全部 +
+
+
+ +
+ 业务招待报销建议补参与人员 + AI 建议:补充客户单位、客户人数、我方陪同人员 +
+ 去补充 +
+
+ +
+ 差旅报销单待提交 + 补齐出发交通,可直接生成报销单 +
+ 继续填 +
+
+ +
+ 有 5 张票据未关联报销单 + 其中 3 张疑似交通费,可合并生成交通报销 +
+ 去整理 +
+
+
+ +
+
+

报销进度

+ 查看全部 +
+
+
+ +
+ 差旅报销 + 提交时间:2026-05-03 +
+
¥3,280主管审批中
+
+
+ +
+ 交通报销 + 提交时间:2026-05-02 +
+
¥126财务复核中
+
+
+ +
+ 办公采购 + 提交时间:2026-05-01 +
+
¥458已到账
+
+
+
+
+ +
+
+
+

智能概览

+ 本月 +
+
+
12待处理事项
+
86%材料完整率
+
2.4天平均审批时长
+
+
+ +
+
+

最新报销制度

+ 查看全部 +
+
+
+ +
+ 差旅报销管理办法(2026版) + 更新住宿标准与交通等级规则 +
+ 查看 +
+
+
+
+
+
+
+ + diff --git a/document/development/ui/personal-workbench-home-reference.png b/document/development/ui/personal-workbench-home-reference.png new file mode 100644 index 0000000..7621545 Binary files /dev/null and b/document/development/ui/personal-workbench-home-reference.png differ diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py index b5f69e0..477a8ac 100644 --- a/server/src/app/api/v1/endpoints/reimbursements.py +++ b/server/src/app/api/v1/endpoints/reimbursements.py @@ -537,12 +537,12 @@ def return_expense_claim( @router.post( "/claims/{claim_id}/approve", response_model=ExpenseClaimRead, - summary="审批通过报销单", - description="直属领导审批通过后流转到财务审批;财务终审通过后进入归档入账。", + summary="审批通过单据", + description="费用申请由直属领导审批通过后完成;报销单直属领导审批后流转到财务审批,财务终审通过后进入归档入账。", responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, - "description": "报销单不存在。", + "description": "单据不存在。", }, status.HTTP_400_BAD_REQUEST: { "model": ErrorResponse, diff --git a/server/src/app/services/agent_asset_onlyoffice.py b/server/src/app/services/agent_asset_onlyoffice.py index 2c99410..9b8164c 100644 --- a/server/src/app/services/agent_asset_onlyoffice.py +++ b/server/src/app/services/agent_asset_onlyoffice.py @@ -11,7 +11,8 @@ import jwt from app.api.deps import CurrentUserContext from app.core.config import get_settings -from app.schemas.agent_asset import AgentAssetOnlyOfficeConfigRead +from app.models.agent_asset import AgentAsset +from app.schemas.agent_asset import AgentAssetOnlyOfficeConfigRead, AgentAssetRead from app.services.agent_asset_spreadsheet import ( COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, FINANCE_RULES_LIBRARY, @@ -40,9 +41,7 @@ class OnlyOfficeCallbackPayload: class AgentAssetOnlyOfficeMixin: @staticmethod def _resolve_onlyoffice_settings(): - from app.services import agent_assets - - return agent_assets.resolve_onlyoffice_settings() + return resolve_onlyoffice_settings() def build_rule_spreadsheet_onlyoffice_config( self, diff --git a/server/src/app/services/agent_conversations.py b/server/src/app/services/agent_conversations.py index 814e0ff..919ddb2 100644 --- a/server/src/app/services/agent_conversations.py +++ b/server/src/app/services/agent_conversations.py @@ -258,6 +258,8 @@ class AgentConversationService: ) if not should_hydrate_review_flow: for key in REVIEW_FLOW_CONTEXT_KEYS: + if key == "business_time_context" and not self._is_empty_value(merged.get(key)): + continue merged.pop(key, None) merged["conversation_id"] = conversation.conversation_id diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 338e80f..41c6c85 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -143,6 +143,40 @@ class ExpenseClaimService( self._attachment_storage = ExpenseClaimAttachmentStorage() self._attachment_presentation = ExpenseClaimAttachmentPresentation(self._attachment_storage) + @staticmethod + def _is_expense_application_claim(claim: ExpenseClaim) -> bool: + claim_no = str(getattr(claim, "claim_no", "") or "").strip().upper() + expense_type = str(getattr(claim, "expense_type", "") or "").strip().lower() + document_type = str( + getattr(claim, "document_type_code", "") + or getattr(claim, "document_type", "") + or "" + ).strip().lower() + return ( + claim_no.startswith("APP-") + or expense_type == "application" + or expense_type.endswith("_application") + or document_type in {"application", "expense_application"} + ) + + def _validate_application_claim_for_submission(self, claim: ExpenseClaim) -> list[str]: + issues: list[str] = [] + if self._is_missing_value(claim.employee_name): + issues.append("申请人未完善") + if self._is_missing_value(claim.department_name): + issues.append("所属部门未完善") + if self._is_missing_value(claim.expense_type): + issues.append("申请类型未完善") + if self._is_missing_value(claim.reason): + issues.append("申请事由未完善") + if self._is_missing_value(claim.location): + issues.append("业务地点未完善") + if claim.amount is None or claim.amount <= Decimal("0.00"): + issues.append("预计总费用未完善") + if claim.occurred_at is None: + issues.append("申请时间未完善") + return issues + def list_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]: stmt = ( select(ExpenseClaim) @@ -389,18 +423,51 @@ class ExpenseClaimService( self._ensure_draft_claim(claim) self._access_policy.backfill_claim_identity_from_current_user(claim, current_user) - self._sync_claim_from_items(claim) - missing_fields = self._validate_claim_for_submission(claim) + is_application_claim = self._is_expense_application_claim(claim) + if not is_application_claim: + self._sync_claim_from_items(claim) + missing_fields = ( + self._validate_application_claim_for_submission(claim) + if is_application_claim + else self._validate_claim_for_submission(claim) + ) if missing_fields: raise ExpenseClaimSubmissionBlockedError(missing_fields) before_json = self._serialize_claim(claim) - review_result = self._run_ai_submission_review(claim) + if is_application_claim: + submitted_at = datetime.now(UTC) + preserved_flags = [ + flag + for flag in list(claim.risk_flags_json or []) + if not ( + isinstance(flag, dict) + and str(flag.get("source") or "").strip() in {"submission_review", "attachment_analysis"} + ) + ] + submit_flag = { + "source": "application_submission", + "event_type": "expense_application_submission", + "severity": "info", + "label": "申请提交", + "message": "费用申请已提交至直属领导审批,并同步纳入预算管理口径。", + "previous_status": str(claim.status or "").strip(), + "previous_approval_stage": str(claim.approval_stage or "").strip(), + "next_status": "submitted", + "next_approval_stage": "直属领导审批", + "created_at": submitted_at.isoformat(), + } + claim.status = "submitted" + claim.approval_stage = "直属领导审批" + claim.risk_flags_json = [*preserved_flags, submit_flag] + claim.submitted_at = submitted_at + else: + review_result = self._run_ai_submission_review(claim) - claim.status = str(review_result.get("status") or "supplement") - claim.approval_stage = str(review_result.get("approval_stage") or "待补充") - claim.risk_flags_json = list(review_result.get("risk_flags") or []) - claim.submitted_at = datetime.now(UTC) if claim.status == "submitted" else None + claim.status = str(review_result.get("status") or "supplement") + claim.approval_stage = str(review_result.get("approval_stage") or "待补充") + claim.risk_flags_json = list(review_result.get("risk_flags") or []) + claim.submitted_at = datetime.now(UTC) if claim.status == "submitted" else None self.db.commit() self.db.refresh(claim) @@ -562,19 +629,29 @@ class ExpenseClaimService( normalized_status = str(claim.status or "").strip().lower() if normalized_status != "submitted": - raise ValueError("只有审批中的报销单可以审批通过。") + raise ValueError("只有审批中的单据可以审批通过。") previous_stage = str(claim.approval_stage or "").strip() + is_application_claim = self._is_expense_application_claim(claim) if previous_stage == "直属领导审批": if not self._access_policy.can_approve_claim(current_user, claim): - raise ValueError("只有当前直属领导审批人可以审批通过该报销单。") + raise ValueError("只有当前直属领导审批人可以审批通过该单据。") approval_source = "manual_approval" - event_type = "expense_claim_approval" - label = "领导审批通过" - next_status = "submitted" - next_stage = "财务审批" - default_message = "{operator} 已审批通过,流转至{next_stage}。" + if is_application_claim: + event_type = "expense_application_approval" + label = "领导审批通过" + next_status = "approved" + next_stage = "审批完成" + default_message = "{operator} 已审批通过,申请流程完成。" + else: + event_type = "expense_claim_approval" + label = "领导审批通过" + next_status = "submitted" + next_stage = "财务审批" + default_message = "{operator} 已审批通过,流转至{next_stage}。" elif previous_stage == "财务审批": + if is_application_claim: + raise ValueError("费用申请无需财务审批,直属领导审批通过后即完成。") if not self._access_policy.can_approve_claim(current_user, claim): raise ValueError("只有财务人员可以完成财务终审。") approval_source = "finance_approval" @@ -606,7 +683,7 @@ class ExpenseClaimService( ], "previous_status": str(claim.status or "").strip(), "previous_approval_stage": previous_stage, - "next_status": "submitted", + "next_status": next_status, "next_approval_stage": next_stage, "created_at": datetime.now(UTC).isoformat(), } diff --git a/server/src/app/services/ontology_detection.py b/server/src/app/services/ontology_detection.py index d91d348..91499e3 100644 --- a/server/src/app/services/ontology_detection.py +++ b/server/src/app/services/ontology_detection.py @@ -78,10 +78,12 @@ class OntologyDetectionMixin: document_type = str(context_json.get("document_type") or "").strip() application_stage = str(context_json.get("application_stage") or "").strip() entry_source = str(context_json.get("entry_source") or "").strip() + session_type = str(context_json.get("session_type") or "").strip() return ( document_type in EXPENSE_APPLICATION_CONTEXT_TYPES or application_stage in EXPENSE_APPLICATION_CONTEXT_TYPES - or entry_source in {"documents_application", "expense_application"} + or session_type in EXPENSE_APPLICATION_CONTEXT_TYPES + or entry_source in {"application", "documents_application", "expense_application"} ) @staticmethod diff --git a/server/src/app/services/ontology_extraction.py b/server/src/app/services/ontology_extraction.py index 219761e..3184282 100644 --- a/server/src/app/services/ontology_extraction.py +++ b/server/src/app/services/ontology_extraction.py @@ -40,10 +40,12 @@ class OntologyExtractionMixin: document_type = str(context_json.get("document_type") or "").strip() application_stage = str(context_json.get("application_stage") or "").strip() entry_source = str(context_json.get("entry_source") or "").strip() + session_type = str(context_json.get("session_type") or "").strip() return ( document_type in EXPENSE_APPLICATION_CONTEXT_TYPES or application_stage in EXPENSE_APPLICATION_CONTEXT_TYPES - or entry_source in {"documents_application", "expense_application"} + or session_type in EXPENSE_APPLICATION_CONTEXT_TYPES + or entry_source in {"application", "documents_application", "expense_application"} ) @staticmethod diff --git a/server/src/app/services/orchestrator.py b/server/src/app/services/orchestrator.py index c556450..55d2fc6 100644 --- a/server/src/app/services/orchestrator.py +++ b/server/src/app/services/orchestrator.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from datetime import UTC, datetime from typing import Any @@ -31,6 +32,11 @@ from app.services.knowledge import KnowledgeService from app.services.ontology import SemanticOntologyService from app.services.orchestrator_execution import ExecutionOutcome, OrchestratorExecutionEngine from app.services.orchestrator_expense_query import OrchestratorDatabaseQueryBuilder +from app.services.user_agent_application import ( + APPLICATION_CONTEXT_VALUES, + APPLICATION_SHORT_CONFIRMATIONS, + APPLICATION_SUBMIT_KEYWORDS, +) from app.services.user_agent import UserAgentService logger = get_logger("app.services.orchestrator") @@ -131,9 +137,11 @@ class OrchestratorService: ) selected_capability_codes = self.execution_engine._flatten_capability_codes(capabilities) is_expense_review_action = self.execution_engine._is_expense_review_action(context_json) + is_expense_application_context = self._is_expense_application_context(context_json) requires_confirmation = ( ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value and not is_expense_review_action + and not is_expense_application_context ) route_json = { @@ -188,8 +196,16 @@ class OrchestratorService: "parse_strategy": ontology.parse_strategy, } ) + clarification_status = ( + AgentRunStatus.SUCCEEDED.value + if self._is_application_submit_result(clarification_result) + else AgentRunStatus.BLOCKED.value + ) + if clarification_status == AgentRunStatus.SUCCEEDED.value: + clarification_result["clarification_required"] = False + clarification_result["missing_slots"] = [] outcome = ExecutionOutcome( - status=AgentRunStatus.BLOCKED.value, + status=clarification_status, result=clarification_result, degraded=False, tool_count=0, @@ -233,11 +249,23 @@ class OrchestratorService: context_json=context_json, ) + result_requires_confirmation = bool(outcome.result.get("requires_confirmation")) + response_requires_confirmation = requires_confirmation or ( + is_expense_application_context and result_requires_confirmation + ) final_status = ( AgentRunStatus.BLOCKED.value - if requires_confirmation - and outcome.status == AgentRunStatus.SUCCEEDED.value - and ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value + if outcome.status == AgentRunStatus.SUCCEEDED.value + and ( + ( + requires_confirmation + and ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value + ) + or ( + is_expense_application_context + and result_requires_confirmation + ) + ) else outcome.status ) response_status = self._normalize_response_status(final_status) @@ -259,7 +287,7 @@ class OrchestratorService: ontology_json=self.execution_engine._build_ontology_json(ontology), route_json={ **route_json, - "requires_confirmation": requires_confirmation, + "requires_confirmation": response_requires_confirmation, "degraded": outcome.degraded, }, permission_level=ontology.permission.level, @@ -297,7 +325,7 @@ class OrchestratorService: "route_reason": route_reason, "permission_level": ontology.permission.level, "status": response_status, - "requires_confirmation": requires_confirmation, + "requires_confirmation": response_requires_confirmation, "trace_summary": trace_summary.model_dump(), "result": outcome.result, }, @@ -311,7 +339,7 @@ class OrchestratorService: permission_level=ontology.permission.level, status=response_status, result=outcome.result, - requires_confirmation=requires_confirmation, + requires_confirmation=response_requires_confirmation, trace_summary=trace_summary, ) except Exception as exc: @@ -462,3 +490,54 @@ class OrchestratorService: if status == AgentRunStatus.BLOCKED.value: return "blocked" return "succeeded" + + @staticmethod + def _is_expense_application_context(context_json: dict[str, Any]) -> bool: + context_json = context_json or {} + context_values = { + str(context_json.get("session_type") or "").strip(), + str(context_json.get("entry_source") or "").strip(), + str(context_json.get("document_type") or "").strip(), + str(context_json.get("application_stage") or "").strip(), + } + conversation_state = context_json.get("conversation_state") + if isinstance(conversation_state, dict): + context_values.update( + { + str(conversation_state.get("session_type") or "").strip(), + str(conversation_state.get("entry_source") or "").strip(), + str(conversation_state.get("document_type") or "").strip(), + str(conversation_state.get("application_stage") or "").strip(), + } + ) + if context_values & APPLICATION_CONTEXT_VALUES: + return True + + history = context_json.get("conversation_history") + if not isinstance(history, list): + return False + current_message = re.sub(r"\s+", "", str(context_json.get("user_input_text") or "")) + looks_like_submit = ( + any(keyword in current_message for keyword in APPLICATION_SUBMIT_KEYWORDS) + or current_message in APPLICATION_SHORT_CONFIRMATIONS + or not current_message + ) + if not looks_like_submit: + return False + for item in history[-6:]: + if not isinstance(item, dict): + continue + content = str(item.get("content") or "") + if "#application-submit" in content or ("费用申请" in content and "确认" in content): + return True + return False + + @staticmethod + def _is_application_submit_result(result: dict[str, Any]) -> bool: + draft_payload = result.get("draft_payload") + return ( + isinstance(draft_payload, dict) + and str(draft_payload.get("draft_type") or "").strip() == "expense_application" + and str(draft_payload.get("status") or "").strip() == "submitted" + and bool(str(draft_payload.get("claim_no") or draft_payload.get("claim_id") or "").strip()) + ) diff --git a/server/src/app/services/user_agent.py b/server/src/app/services/user_agent.py index 1fe9ce2..295ba81 100644 --- a/server/src/app/services/user_agent.py +++ b/server/src/app/services/user_agent.py @@ -44,6 +44,7 @@ from app.services.user_agent_documents import UserAgentDocumentService from app.services.user_agent_knowledge import UserAgentKnowledgeMixin from app.services.user_agent_constants import * +from app.services.user_agent_application import UserAgentApplicationMixin from app.services.user_agent_response import UserAgentResponseMixin from app.services.user_agent_review_core import UserAgentReviewCoreMixin from app.services.user_agent_review_messages import UserAgentReviewMessageMixin @@ -55,6 +56,7 @@ from app.services.user_agent_review_travel_receipts import UserAgentReviewTravel class UserAgentService( UserAgentResponseMixin, + UserAgentApplicationMixin, UserAgentKnowledgeMixin, UserAgentReviewCoreMixin, UserAgentReviewTravelPolicyMixin, @@ -72,6 +74,12 @@ class UserAgentService( def respond(self, payload: UserAgentRequest) -> UserAgentResponse: AgentFoundationService(self.db).ensure_foundation_ready() citations = self._build_citations(payload) + risk_flags = self._resolve_risk_flags(payload) + if self._is_expense_application_request(payload): + return self._build_expense_application_response( + payload, + risk_flags=risk_flags, + ) suggested_actions = self._build_suggested_actions(payload) if self._should_prompt_expense_scene_selection(payload): return UserAgentResponse( @@ -84,7 +92,6 @@ class UserAgentService( risk_flags=[], requires_confirmation=False, ) - risk_flags = self._resolve_risk_flags(payload) query_payload = self._build_query_payload(payload) draft_payload = ( self._build_draft_payload(payload) diff --git a/server/src/app/services/user_agent_application.py b/server/src/app/services/user_agent_application.py new file mode 100644 index 0000000..1b5b53f --- /dev/null +++ b/server/src/app/services/user_agent_application.py @@ -0,0 +1,958 @@ +from __future__ import annotations + +import hashlib +import re +from datetime import UTC, datetime, timedelta +from decimal import Decimal, InvalidOperation + +from sqlalchemy import select + +from app.api.deps import CurrentUserContext +from app.models.financial_record import ExpenseClaim +from app.schemas.user_agent import ( + UserAgentDraftPayload, + UserAgentRequest, + UserAgentResponse, + UserAgentSuggestedAction, +) +from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy + +APPLICATION_CONTEXT_VALUES = { + "application", + "documents_application", + "expense_application", + "pre_approval", + "preapproval", +} +APPLICATION_BASE_FIELDS = ("time", "location", "reason") +APPLICATION_TRANSPORT_OPTIONS = ("飞机", "火车", "轮船") +APPLICATION_TRANSPORT_KEYWORDS = { + "飞机": ("飞机", "机票", "航班", "乘机", "坐飞机"), + "火车": ("火车", "高铁", "动车", "铁路", "列车"), + "轮船": ("轮船", "船", "客轮", "邮轮", "坐船"), +} +APPLICATION_DESTINATION_PREFIXES = ( + "上海", + "北京", + "广州", + "深圳", + "杭州", + "南京", + "苏州", + "成都", + "重庆", + "武汉", + "西安", + "天津", + "宁波", + "青岛", + "长沙", + "郑州", + "济南", + "合肥", + "福州", + "厦门", + "昆明", + "南昌", + "沈阳", + "大连", + "无锡", + "佛山", + "东莞", +) +APPLICATION_REASON_VERBS = ( + "支撑", + "支持", + "部署", + "上线", + "实施", + "驻场", + "拜访", + "验收", + "会议", + "采购", + "培训", + "协助", + "处理", + "办理", + "参加", + "进行", +) +APPLICATION_SUBMIT_KEYWORDS = ( + "确认提交", + "确认申请", + "提交审核", + "确认无误提交", + "直接提交", +) +APPLICATION_SHORT_CONFIRMATIONS = {"提交", "确认", "是", "好的", "可以", "没问题"} + + +class UserAgentApplicationMixin: + @staticmethod + def _is_expense_application_request(payload: UserAgentRequest) -> bool: + context_json = payload.context_json or {} + context_values = { + str(context_json.get("session_type") or "").strip(), + str(context_json.get("entry_source") or "").strip(), + str(context_json.get("document_type") or "").strip(), + str(context_json.get("application_stage") or "").strip(), + } + conversation_state = context_json.get("conversation_state") + if isinstance(conversation_state, dict): + context_values.update( + { + str(conversation_state.get("session_type") or "").strip(), + str(conversation_state.get("entry_source") or "").strip(), + str(conversation_state.get("document_type") or "").strip(), + str(conversation_state.get("application_stage") or "").strip(), + } + ) + if context_values & APPLICATION_CONTEXT_VALUES: + return True + + history = context_json.get("conversation_history") + if not isinstance(history, list): + return False + compact_message = re.sub(r"\s+", "", str(payload.message or "")) + looks_like_submit = ( + any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS) + or compact_message in APPLICATION_SHORT_CONFIRMATIONS + ) + if not looks_like_submit: + return False + return any( + isinstance(item, dict) + and str(item.get("role") or "").strip() == "assistant" + and ( + "#application-submit" in str(item.get("content") or "") + or ("费用申请" in str(item.get("content") or "") and "确认" in str(item.get("content") or "")) + ) + for item in history[-6:] + ) + + def _build_expense_application_response( + self, + payload: UserAgentRequest, + *, + risk_flags: list[str], + ) -> UserAgentResponse: + facts = self._resolve_expense_application_facts(payload) + step = self._resolve_expense_application_step(payload, facts) + application_claim = None + if step == "submitted": + application_claim = self._create_expense_application_record(payload, facts) + facts["application_no"] = application_claim.claim_no + facts["application_claim_id"] = application_claim.id + facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim) + return UserAgentResponse( + answer=self._build_expense_application_answer(payload, facts=facts, step=step), + citations=[], + suggested_actions=self._build_expense_application_actions(step, facts), + query_payload=None, + draft_payload=self._build_submitted_application_payload(application_claim, facts), + review_payload=None, + risk_flags=risk_flags, + requires_confirmation=step == "preview", + ) + + def _build_expense_application_answer( + self, + payload: UserAgentRequest, + *, + facts: dict[str, str], + step: str, + ) -> str: + recognized_table = self._build_application_summary_table(facts, include_empty=False) + + if step == "ask_missing": + missing_fields = self._resolve_application_missing_fields(facts) + missing_text = "、".join( + self._display_application_slot_label(item) + for item in missing_fields + ) + return "\n\n".join( + [ + "我已按「费用申请 / 事前审批」来处理这条内容。", + "已识别信息:\n" + recognized_table, + f"当前还需要补充:{missing_text}。", + "请一次性补齐上述字段,我会继续生成模拟申请结果并让你确认是否提交。", + ] + ) + + if step == "submitted": + application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts) + manager_name = str(facts.get("manager_name") or "").strip() or "直属领导" + return "\n\n".join( + [ + f"当前操作已完成,单据已经推送给 {manager_name} 进行审核,请耐心等待。", + f"申请单号:{application_no}", + "申请信息:\n" + self._build_application_summary_table(facts), + f"当前状态:{manager_name}审核中。", + "预算处理:预计总费用已作为预算占用参考,等待领导审核确认。", + ] + ) + + return "\n\n".join( + [ + "这是模拟的费用申请结果,请核对:", + self._build_application_summary_table(facts), + "请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。", + ] + ) + + def _resolve_expense_application_facts(self, payload: UserAgentRequest) -> dict[str, str]: + facts = { + "time": "", + "location": "", + "reason": "", + "days": "", + "transport_mode": "", + "amount": "", + "application_type": "", + } + for message, is_current in self._iter_application_user_messages(payload): + partial = { + "time": self._resolve_application_time(payload, message=message) if is_current else self._resolve_application_time_from_text(message), + "location": self._resolve_application_location(payload, message=message, use_entities=is_current), + "reason": self._resolve_application_reason(message), + "days": self._resolve_application_days(message), + "transport_mode": self._resolve_application_transport_mode(message), + "amount": self._resolve_application_amount(payload, message=message) if is_current else self._resolve_application_amount_from_text(message), + "application_type": self._resolve_application_type_from_text(message), + } + for key, value in partial.items(): + if value: + facts[key] = value + + if not facts["application_type"]: + facts["application_type"] = self._infer_application_type(facts) + facts["time"] = self._expand_application_time_with_days( + facts.get("time", ""), + facts.get("days", ""), + ) + return facts + + def _resolve_expense_application_step( + self, + payload: UserAgentRequest, + facts: dict[str, str], + ) -> str: + if self._resolve_application_missing_base_fields(facts): + return "ask_missing" + if self._resolve_application_missing_followup_fields(facts): + return "ask_missing" + if self._is_application_submit_confirmation(payload): + return "submitted" + return "preview" + + @staticmethod + def _iter_application_user_messages(payload: UserAgentRequest) -> list[tuple[str, bool]]: + messages: list[tuple[str, bool]] = [] + history = (payload.context_json or {}).get("conversation_history") + if isinstance(history, list): + for item in history: + if not isinstance(item, dict): + continue + if str(item.get("role") or "").strip() != "user": + continue + content = str(item.get("content") or "").strip() + if content: + messages.append((content, False)) + current_message = str(payload.message or "").strip() + if current_message: + messages.append((current_message, True)) + return messages + + @staticmethod + def _resolve_application_missing_base_fields(facts: dict[str, str]) -> list[str]: + return [field for field in APPLICATION_BASE_FIELDS if not str(facts.get(field) or "").strip()] + + @staticmethod + def _resolve_application_missing_followup_fields(facts: dict[str, str]) -> list[str]: + return [ + field + for field in ("transport_mode", "amount") + if not str(facts.get(field) or "").strip() + ] + + def _resolve_application_missing_fields(self, facts: dict[str, str]) -> list[str]: + return [ + *self._resolve_application_missing_base_fields(facts), + *self._resolve_application_missing_followup_fields(facts), + ] + + @staticmethod + def _resolve_application_time(payload: UserAgentRequest, *, message: str | None = None) -> str: + if message and UserAgentApplicationMixin._resolve_application_time_from_text(message): + return UserAgentApplicationMixin._resolve_application_time_from_text(message) + + context_time = UserAgentApplicationMixin._resolve_application_time_from_context(payload.context_json or {}) + if context_time: + return context_time + + time_range = payload.ontology.time_range + if time_range.start_date and time_range.end_date: + return ( + time_range.start_date + if time_range.start_date == time_range.end_date + else f"{time_range.start_date} 至 {time_range.end_date}" + ) + return str(time_range.raw or "").strip() + + @staticmethod + def _resolve_application_time_from_text(message: str) -> str: + labeled = UserAgentApplicationMixin._resolve_application_labeled_value( + message, + ("发生时间", "业务发生时间", "申请时间", "时间"), + ) + if labeled: + return labeled + match = re.search( + r"(?P20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)", + str(message or ""), + ) + return match.group("date").rstrip("日") if match else "" + + @staticmethod + def _resolve_application_time_from_context(context_json: dict[str, object]) -> str: + business_time_context = context_json.get("business_time_context") + if not isinstance(business_time_context, dict): + return "" + start_date = str(business_time_context.get("start_date") or "").strip() + end_date = str(business_time_context.get("end_date") or start_date).strip() + display_value = str(business_time_context.get("display_value") or "").strip() + if start_date and end_date: + return start_date if start_date == end_date else f"{start_date} 至 {end_date}" + return display_value + + @staticmethod + def _resolve_application_labeled_value(message: str, labels: tuple[str, ...]) -> str: + label_pattern = "|".join(re.escape(label) for label in labels) + match = re.search( + rf"(?:{label_pattern})[::]\s*(?P[^\n,。;;]+)", + str(message or ""), + ) + return match.group("value").strip() if match else "" + + def _resolve_application_entity_or_label( + self, + payload: UserAgentRequest, + entity_type: str, + labels: tuple[str, ...], + ) -> str: + entity_value = next( + ( + str(item.normalized_value or item.value or "").strip() + for item in payload.ontology.entities + if item.type == entity_type + and str(item.normalized_value or item.value or "").strip() + ), + "", + ) + return entity_value or self._resolve_application_labeled_value(payload.message, labels) + + def _resolve_application_location( + self, + payload: UserAgentRequest, + *, + message: str, + use_entities: bool, + ) -> str: + entity_or_labeled = ( + self._resolve_application_entity_or_label(payload, "location", ("地点", "业务地点", "发生地点")) + if use_entities + else self._resolve_application_labeled_value(message, ("地点", "业务地点", "发生地点")) + ) + return entity_or_labeled or self._resolve_application_location_from_text(message) + + @staticmethod + def _resolve_application_location_from_text(message: str) -> str: + compact = re.sub(r"\s+", "", str(message or "")) + if not compact: + return "" + + for pattern in ( + r"(?:出差|去|到|赴|前往)(?P[\u4e00-\u9fa5]{1,24})", + r"(?P[\u4e00-\u9fa5]{1,12})(?:出差|驻场)", + ): + match = re.search(pattern, compact) + if not match: + continue + target = str(match.group("target") or "").strip() + location = UserAgentApplicationMixin._normalize_application_location_target(target) + if location: + return location + return "" + + @staticmethod + def _normalize_application_location_target(target: str) -> str: + text = str(target or "").strip("::,,。;;") + if not text: + return "" + known = next((item for item in APPLICATION_DESTINATION_PREFIXES if text.startswith(item)), "") + if known: + return known + + verb_indexes = [ + index + for keyword in APPLICATION_REASON_VERBS + for index in [text.find(keyword)] + if index > 0 + ] + if verb_indexes: + return text[: min(verb_indexes)] + return text[:12] + + @staticmethod + def _resolve_application_days(message: str) -> str: + labeled = UserAgentApplicationMixin._resolve_application_labeled_value( + message, + ("天数", "出差天数", "申请天数"), + ) + if labeled: + return labeled if labeled.endswith("天") else f"{labeled}天" + match = re.search(r"(?P\d+|[一二两三四五六七八九十]{1,3})\s*天", str(message or "")) + return f"{match.group('days')}天" if match else "" + + @staticmethod + def _resolve_application_reason(message: str) -> str: + labeled = UserAgentApplicationMixin._resolve_application_labeled_value( + message, + ("事由", "申请事由", "出差事由", "原因", "用途"), + ) + if labeled: + return labeled + + text = str(message or "").strip() + if not text: + return "" + + candidates: list[str] = [] + for segment in re.split(r"[\n,。;;]+", text): + candidate = UserAgentApplicationMixin._cleanup_application_reason_candidate(segment) + if candidate: + candidates.append(candidate) + + if not candidates: + return "" + return max(candidates, key=len) + + @staticmethod + def _cleanup_application_reason_candidate(segment: str) -> str: + text = str(segment or "").strip() + if not text: + return "" + + text = re.sub( + r"^(?:发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|预计总费用|预计费用|预计金额|申请金额|预算|金额)[::]\s*", + "", + text, + ) + if re.fullmatch(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", text): + return "" + if re.fullmatch(r"(?P\d+|[一二两三四五六七八九十]{1,3})\s*天", text): + return "" + if re.fullmatch(r"(?:¥|¥)?\s*\d+(?:\.\d+)?\s*(?:元|块|万元)?", text): + return "" + if "时间" in text and re.search(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}", text): + return "" + if re.fullmatch(r"(?:去|到|前往)?[\u4e00-\u9fa5]{1,8}出差(?P\d+|[一二两三四五六七八九十]{1,3})?天?", text): + return "" + + text = re.sub(r"^.*?(?:出差|前往|去|到|赴)[\u4e00-\u9fa5]{1,8}(?:出差)?(?P\d+|[一二两三四五六七八九十]{1,3})?天?[,,\s]*", "", text) + text = re.sub(r"^(?:出差|申请|费用申请|业务|本次|去|到|前往)\s*", "", text) + text = text.strip(" ::,,。;;") + if not text: + return "" + if re.fullmatch(r"[\u4e00-\u9fa5]{1,8}", text) and not any(keyword in text for keyword in APPLICATION_REASON_VERBS): + return "" + return text + + @staticmethod + def _expand_application_time_with_days(time_text: str, days_text: str) -> str: + normalized_time = str(time_text or "").strip() + if not normalized_time or re.search(r"\s*(?:至|到|~|-{2,}|—)\s*", normalized_time): + return normalized_time + + days = UserAgentApplicationMixin._resolve_application_days_count(days_text) + if not days: + return normalized_time + + match = re.search( + r"(?P20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)", + normalized_time, + ) + if not match: + return normalized_time + + parsed_start = UserAgentApplicationMixin._parse_application_date(match.group("date")) + if parsed_start is None: + return normalized_time + + end_date = parsed_start + timedelta(days=days) + return f"{parsed_start:%Y-%m-%d} 至 {end_date:%Y-%m-%d}" + + @staticmethod + def _resolve_application_days_count(days_text: str) -> int: + text = str(days_text or "").strip() + if not text: + return 0 + digit_match = re.search(r"\d+", text) + if digit_match: + return max(0, int(digit_match.group(0))) + + chinese_match = re.search(r"[一二两三四五六七八九十]{1,3}", text) + if not chinese_match: + return 0 + return UserAgentApplicationMixin._parse_chinese_number(chinese_match.group(0)) + + @staticmethod + def _parse_chinese_number(value: str) -> int: + digits = { + "一": 1, + "二": 2, + "两": 2, + "三": 3, + "四": 4, + "五": 5, + "六": 6, + "七": 7, + "八": 8, + "九": 9, + } + text = str(value or "").strip() + if not text: + return 0 + if text == "十": + return 10 + if "十" in text: + left, _, right = text.partition("十") + tens = digits.get(left, 1) if left else 1 + ones = digits.get(right, 0) if right else 0 + return tens * 10 + ones + return digits.get(text, 0) + + @staticmethod + def _parse_application_date(value: str) -> datetime | None: + normalized = str(value or "").strip().rstrip("日").replace("年", "-").replace("月", "-") + normalized = normalized.replace("/", "-").replace(".", "-") + parts = [part for part in normalized.split("-") if part] + if len(parts) != 3: + return None + try: + year, month, day = (int(part) for part in parts) + return datetime(year, month, day) + except ValueError: + return None + + def _resolve_application_amount( + self, + payload: UserAgentRequest, + *, + message: str | None = None, + ) -> str: + entity_amount = next( + ( + str(item.normalized_value or item.value or "").strip() + for item in payload.ontology.entities + if item.type == "amount" + and str(item.normalized_value or item.value or "").strip() + ), + "", + ) + if entity_amount: + return entity_amount if entity_amount.endswith("元") else f"{entity_amount}元" + return self._resolve_application_amount_from_text(message or payload.message) + + @staticmethod + def _resolve_application_amount_from_text(message: str) -> str: + labeled = UserAgentApplicationMixin._resolve_application_labeled_value( + message, + ("预计总费用", "预计费用", "预计金额", "申请金额", "预算", "费用", "金额"), + ) + if labeled: + return UserAgentApplicationMixin._normalize_application_amount(labeled) + match = re.search( + r"(?P\d+(?:\.\d+)?\s*万?\s*(?:元|块|人民币))", + str(message or ""), + ) + return UserAgentApplicationMixin._normalize_application_amount(match.group("amount")) if match else "" + + @staticmethod + def _normalize_application_amount(value: str) -> str: + normalized = str(value or "").strip() + if not normalized: + return "" + normalized = re.sub(r"\s+", "", normalized) + if normalized.endswith(("元", "块")) or "人民币" in normalized: + return normalized.replace("块", "元").replace("人民币", "") + return f"{normalized}元" + + @staticmethod + def _resolve_application_transport_mode(message: str) -> str: + compact_message = re.sub(r"\s+", "", str(message or "")) + for transport, keywords in APPLICATION_TRANSPORT_KEYWORDS.items(): + if any(keyword in compact_message for keyword in keywords): + return transport + labeled = UserAgentApplicationMixin._resolve_application_labeled_value( + message, + ("出行方式", "交通方式", "交通工具", "出行工具"), + ) + if labeled: + for transport, keywords in APPLICATION_TRANSPORT_KEYWORDS.items(): + if transport in labeled or any(keyword in labeled for keyword in keywords): + return transport + return labeled + return "" + + @staticmethod + def _resolve_application_type_from_text(message: str) -> str: + return UserAgentApplicationMixin._resolve_application_labeled_value( + message, + ("申请类型", "费用类型"), + ) + + @staticmethod + def _resolve_application_missing_slots(payload: UserAgentRequest) -> list[str]: + return [ + str(item or "").strip() + for item in payload.ontology.missing_slots + if str(item or "").strip() + ] + + @staticmethod + def _display_application_slot_label(slot: str) -> str: + return { + "expense_type": "申请类型", + "amount": "预计金额/预算", + "time_range": "发生时间", + "time": "发生时间", + "location": "地点", + "reason": "申请事由", + "days": "天数", + "transport_mode": "出行方式", + "attachments": "申请材料/附件", + "customer_name": "业务对象", + "participants": "参与人员", + }.get(str(slot or "").strip(), str(slot or "").strip()) + + def _build_expense_application_actions( + self, + step: str, + facts: dict[str, str], + ) -> list[UserAgentSuggestedAction]: + if step == "ask_missing": + missing_fields = self._resolve_application_missing_fields(facts) + return [ + UserAgentSuggestedAction( + label="一次性补充申请信息", + action_type="prefill_composer", + description="在输入框预填所有待补充字段,填写后一次提交。", + payload={ + "application_fields": missing_fields, + "prompt_prefill": self._build_application_prefill_template(missing_fields), + "missing_fields": missing_fields, + }, + ) + ] + if step == "preview": + return [] + if step == "submitted": + return [] + return [] + + @staticmethod + def _resolve_application_prefill_config(field: str) -> tuple[str, str]: + config = { + "time": ("补充发生时间", "申请时间段:"), + "location": ("补充地点", "地点:"), + "reason": ("补充申请事由", "事由:"), + "days": ("补充天数", "天数:"), + "transport_mode": ("补充出行方式", "出行方式:"), + "amount": ("补充预计总费用", "预计总费用:"), + } + return config.get(field, ("补充申请信息", "")) + + @classmethod + def _build_application_prefill_template(cls, fields: list[str]) -> str: + lines = [ + prefill + for field in fields + for _, prefill in [cls._resolve_application_prefill_config(field)] + if prefill + ] + return "\n".join(lines) + + @classmethod + def _build_application_prefill_action(cls, field: str) -> UserAgentSuggestedAction: + label, prefill = cls._resolve_application_prefill_config(field) + return UserAgentSuggestedAction( + label=label, + action_type="prefill_composer", + description=f"在输入框预填“{prefill}”,用户补充后再提交。", + payload={ + "application_field": field, + "prompt_prefill": prefill, + "missing_fields": [field], + }, + ) + + @staticmethod + def _infer_application_type(facts: dict[str, str]) -> str: + text = " ".join(str(facts.get(key) or "") for key in ("reason", "transport_mode", "days")) + if "采购" in text: + return "采购费用申请" + if "会议" in text or "会务" in text: + return "会务费用申请" + return "差旅费用申请" + + @staticmethod + def _build_application_summary(facts: dict[str, str]) -> str: + return "\n".join( + f"{label}:{value or '待补充'}" + for label, value in ( + ("申请类型", facts.get("application_type", "")), + ("发生时间", facts.get("time", "")), + ("地点", facts.get("location", "")), + ("事由", facts.get("reason", "")), + ("天数", facts.get("days", "")), + ("出行方式", facts.get("transport_mode", "")), + ("预计总费用", facts.get("amount", "")), + ) + ) + + @staticmethod + def _build_application_summary_table( + facts: dict[str, str], + *, + include_empty: bool = True, + ) -> str: + rows = [ + ("申请类型", facts.get("application_type", "")), + ("发生时间", facts.get("time", "")), + ("地点", facts.get("location", "")), + ("事由", facts.get("reason", "")), + ("天数", facts.get("days", "")), + ("出行方式", facts.get("transport_mode", "")), + ("预计总费用", facts.get("amount", "")), + ] + visible_rows = rows if include_empty else [(label, value) for label, value in rows if str(value or "").strip()] + if not visible_rows: + visible_rows = [("申请描述", "已收到,正在按费用申请上下文继续整理")] + lines = ["| 字段 | 内容 |", "| --- | --- |"] + lines.extend(f"| {label} | {value or '待补充'} |" for label, value in visible_rows) + return "\n".join(lines) + + def _create_expense_application_record( + self, + payload: UserAgentRequest, + facts: dict[str, str], + ) -> ExpenseClaim: + claim_no = self._build_application_claim_no(payload, facts) + existing = self.db.scalar( + select(ExpenseClaim) + .where(ExpenseClaim.claim_no == claim_no) + .limit(1) + ) + if existing is not None: + return existing + + current_user = self._build_application_current_user(payload) + access_policy = ExpenseClaimAccessPolicy(self.db) + employee = access_policy.resolve_current_employee(current_user) + department_name = str(current_user.department_name or "").strip() or "待补充" + department_id = None + employee_id = None + employee_name = str(current_user.username or current_user.name or payload.user_id or "anonymous").strip() + + if employee is not None: + employee_id = employee.id + employee_name = str(employee.name or employee.employee_no or employee.email or employee_name).strip() + department_id = employee.organization_unit_id + if employee.organization_unit is not None and employee.organization_unit.name: + department_name = str(employee.organization_unit.name).strip() + + claim = ExpenseClaim( + claim_no=claim_no, + employee_id=employee_id, + employee_name=employee_name, + department_id=department_id, + department_name=department_name, + project_code=None, + expense_type=self._resolve_application_expense_type_code(facts), + reason=str(facts.get("reason") or "费用申请").strip() or "费用申请", + location=str(facts.get("location") or "待补充").strip() or "待补充", + amount=self._parse_application_amount_to_decimal(facts.get("amount", "")), + currency="CNY", + invoice_count=0, + occurred_at=self._parse_application_occurred_at(facts.get("time", "")), + submitted_at=datetime.now(UTC), + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=[], + ) + self.db.add(claim) + self.db.commit() + self.db.refresh(claim) + return claim + + def _resolve_application_manager_name( + self, + payload: UserAgentRequest, + claim: ExpenseClaim | None = None, + ) -> str: + if claim is not None: + manager_name = ExpenseClaimAccessPolicy.resolve_claim_manager_name(claim) + if manager_name and not ExpenseClaimAccessPolicy.is_missing_value(manager_name): + return manager_name + + context_json = payload.context_json or {} + for key in ("manager_name", "managerName", "direct_manager_name", "directManagerName"): + value = str(context_json.get(key) or "").strip() + if value and not ExpenseClaimAccessPolicy.is_missing_value(value): + return value + return "" + + @staticmethod + def _build_application_current_user(payload: UserAgentRequest) -> CurrentUserContext: + context_json = payload.context_json or {} + raw_role_codes = context_json.get("role_codes") + if isinstance(raw_role_codes, list): + role_codes = [str(item).strip() for item in raw_role_codes if str(item).strip()] + else: + role_codes = [item.strip() for item in str(raw_role_codes or "").split(",") if item.strip()] + username = str( + payload.user_id + or context_json.get("username") + or context_json.get("user_id") + or context_json.get("employee_no") + or context_json.get("name") + or "anonymous" + ).strip() + name = str(context_json.get("name") or context_json.get("user_name") or username).strip() + return CurrentUserContext( + username=username or name or "anonymous", + name=name or username or "anonymous", + role_codes=role_codes, + is_admin=bool(context_json.get("is_admin")), + department_name=str( + context_json.get("department_name") + or context_json.get("department") + or context_json.get("departmentName") + or "" + ).strip(), + ) + + @staticmethod + def _resolve_application_expense_type_code(facts: dict[str, str]) -> str: + application_type = str(facts.get("application_type") or "").strip() + if "差旅" in application_type: + return "travel_application" + if "采购" in application_type: + return "purchase_application" + if "会务" in application_type or "会议" in application_type: + return "meeting_application" + return "expense_application" + + @staticmethod + def _parse_application_amount_to_decimal(amount_text: str) -> Decimal: + normalized = str(amount_text or "").replace(",", "").replace(",", "").strip() + match = re.search(r"\d+(?:\.\d+)?", normalized) + if not match: + return Decimal("0.00") + try: + return Decimal(match.group(0)).quantize(Decimal("0.01")) + except (InvalidOperation, ValueError): + return Decimal("0.00") + + @staticmethod + def _parse_application_occurred_at(time_text: str) -> datetime: + normalized = str(time_text or "") + match = re.search(r"(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})", normalized) + if match: + year, month, day = (int(part) for part in match.groups()) + return datetime(year, month, day, tzinfo=UTC) + return datetime.now(UTC) + + def _build_submitted_application_payload( + self, + claim: ExpenseClaim | None, + facts: dict[str, str], + ) -> UserAgentDraftPayload | None: + if claim is None: + return None + return UserAgentDraftPayload( + draft_type="expense_application", + title=str(facts.get("application_type") or "费用申请").strip() or "费用申请", + body=self._build_application_summary(facts), + confirmation_required=False, + claim_id=claim.id, + claim_no=claim.claim_no, + status=claim.status, + approval_stage=claim.approval_stage, + ) + + def _is_application_submit_confirmation(self, payload: UserAgentRequest) -> bool: + compact_message = re.sub(r"\s+", "", str(payload.message or "")) + if any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS): + return True + if compact_message not in APPLICATION_SHORT_CONFIRMATIONS: + return False + history = (payload.context_json or {}).get("conversation_history") + if not isinstance(history, list): + return False + return any( + isinstance(item, dict) + and str(item.get("role") or "").strip() == "assistant" + and ( + "是否确认提交" in str(item.get("content") or "") + or "当前状态:待确认提交" in str(item.get("content") or "") + or "#application-submit" in str(item.get("content") or "") + or "确认无误后" in str(item.get("content") or "") + ) + for item in history[-4:] + ) + + def _build_simulated_application_no( + self, + payload: UserAgentRequest, + facts: dict[str, str], + ) -> str: + return self._build_simulated_application_no_from_facts( + facts, + fallback_seed=str(payload.run_id or ""), + ) + + @staticmethod + def _build_simulated_application_no_from_facts( + facts: dict[str, str], + *, + fallback_seed: str = "", + ) -> str: + raw_date = str(facts.get("time") or "") + match = re.search(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}", raw_date) + date_text = match.group(0) if match else datetime.now().strftime("%Y-%m-%d") + digits = re.sub(r"\D", "", date_text)[:8].ljust(8, "0") + seed = re.sub(r"[^A-Za-z0-9]", "", fallback_seed)[-6:] or "SIM001" + return f"APP-{digits}-{seed.upper()}" + + def _build_application_claim_no( + self, + payload: UserAgentRequest, + facts: dict[str, str], + ) -> str: + context_json = payload.context_json or {} + seed_source = "|".join( + str(item or "").strip() + for item in ( + context_json.get("conversation_id"), + payload.user_id, + facts.get("time"), + facts.get("location"), + facts.get("reason"), + facts.get("amount"), + ) + ) + digest = hashlib.sha1(seed_source.encode("utf-8")).hexdigest()[:6] + return self._build_simulated_application_no_from_facts(facts, fallback_seed=digest) diff --git a/server/tests/test_agent_asset_service.py b/server/tests/test_agent_asset_service.py index a5b75fc..de12707 100644 --- a/server/tests/test_agent_asset_service.py +++ b/server/tests/test_agent_asset_service.py @@ -497,7 +497,7 @@ def test_spreadsheet_change_records_include_all_modified_sheets() -> None: def test_editable_spreadsheet_onlyoffice_config_enables_forcesave(monkeypatch) -> None: with build_session() as db: monkeypatch.setattr( - "app.services.agent_assets.resolve_onlyoffice_settings", + "app.services.agent_asset_onlyoffice.resolve_onlyoffice_settings", lambda: OnlyOfficeRuntimeConfig( enabled=True, public_url="http://onlyoffice.example.com", diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index 8da0b30..5867e38 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -2883,6 +2883,133 @@ def test_direct_manager_can_approve_subordinate_claim_to_finance_review() -> Non ) +def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch: pytest.MonkeyPatch) -> None: + current_user = CurrentUserContext( + username="application-owner@example.com", + name="张三", + role_codes=["employee"], + is_admin=True, + ) + + with build_session() as db: + claim = ExpenseClaim( + claim_no="APP-20260525-SUBMIT", + employee_name="张三", + department_name="交付部", + project_code="PRJ-A", + expense_type="travel_application", + reason="支撑国网服务器上线部署", + location="上海", + amount=Decimal("12000.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC), + submitted_at=None, + status="draft", + approval_stage="待提交", + risk_flags_json=[ + { + "source": "submission_review", + "severity": "medium", + "message": "旧 AI 预审提示不应保留到申请单提交结果。", + } + ], + ) + db.add(claim) + db.commit() + claim_id = claim.id + service = ExpenseClaimService(db) + + def fail_ai_review(_claim: ExpenseClaim) -> dict[str, object]: + raise AssertionError("费用申请提交不应进入 AI 预审") + + monkeypatch.setattr(service, "_run_ai_submission_review", fail_ai_review) + + submitted = service.submit_claim(claim_id, current_user) + + assert submitted is not None + assert submitted.status == "submitted" + assert submitted.approval_stage == "直属领导审批" + assert submitted.invoice_count == 0 + assert submitted.items == [] + assert not any( + isinstance(flag, dict) and flag.get("source") == "submission_review" + for flag in submitted.risk_flags_json + ) + assert any( + isinstance(flag, dict) + and flag.get("source") == "application_submission" + and flag.get("event_type") == "expense_application_submission" + and flag.get("next_approval_stage") == "直属领导审批" + for flag in submitted.risk_flags_json + ) + + +def test_direct_manager_can_approve_application_claim_to_completed_stage() -> None: + current_user = CurrentUserContext( + username="manager-application-approve@example.com", + name="李经理", + role_codes=["manager"], + is_admin=False, + ) + + with build_session() as db: + manager = Employee( + employee_no="E8112", + name="李经理", + email="manager-application-approve@example.com", + ) + employee = Employee( + employee_no="E8113", + name="张三", + email="zhangsan-application-approve@example.com", + manager=manager, + ) + db.add_all([manager, employee]) + db.flush() + claim = ExpenseClaim( + claim_no="APP-20260525-APPROVE", + employee_id=employee.id, + employee_name="张三", + department_name="交付部", + project_code="PRJ-A", + expense_type="travel_application", + reason="支撑国网服务器上线部署", + location="上海", + amount=Decimal("12000.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=[], + ) + db.add(claim) + db.commit() + claim_id = claim.id + + approved = ExpenseClaimService(db).approve_claim( + claim_id, + current_user, + opinion="业务必要,同意申请。", + ) + + assert approved is not None + assert approved.status == "approved" + assert approved.approval_stage == "审批完成" + assert any( + isinstance(flag, dict) + and flag.get("source") == "manual_approval" + and flag.get("event_type") == "expense_application_approval" + and flag.get("opinion") == "业务必要,同意申请。" + and flag.get("previous_approval_stage") == "直属领导审批" + and flag.get("next_status") == "approved" + and flag.get("next_approval_stage") == "审批完成" + for flag in approved.risk_flags_json + ) + + def test_finance_can_approve_claim_to_archive_stage() -> None: current_user = CurrentUserContext( username="finance-approve@example.com", diff --git a/server/tests/test_ontology_service.py b/server/tests/test_ontology_service.py index 3efcb7a..1d6a201 100644 --- a/server/tests/test_ontology_service.py +++ b/server/tests/test_ontology_service.py @@ -649,6 +649,40 @@ def test_semantic_ontology_service_requires_attachment_for_meeting_application() assert "attachments" in result.missing_slots +def test_semantic_ontology_service_treats_application_session_as_application_context() -> None: + session_factory = build_session_factory() + with session_factory() as db: + result = SemanticOntologyService(db).parse( + OntologyParseRequest( + query=( + "发生时间:2026-05-25\n" + "地点:上海\n" + "事由:支持上海国网服务器部署\n" + "天数:3天" + ), + user_id="pytest", + context_json={ + "session_type": "application", + "entry_source": "application", + "attachment_count": 0, + }, + ) + ) + + assert result.scenario == "expense" + assert result.intent == "draft" + assert any( + item.type == "document_type" and item.normalized_value == "expense_application" + for item in result.entities + ) + assert any( + item.type == "workflow_stage" and item.normalized_value == "pre_approval" + for item in result.entities + ) + assert "expense_type" in result.missing_slots + assert "amount" in result.missing_slots + + def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None: session_factory = build_session_factory() with session_factory() as db: diff --git a/server/tests/test_orchestrator_review_flow.py b/server/tests/test_orchestrator_review_flow.py index d8bcc4b..f13fed6 100644 --- a/server/tests/test_orchestrator_review_flow.py +++ b/server/tests/test_orchestrator_review_flow.py @@ -11,6 +11,7 @@ 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.ontology import OntologyParseResult, OntologyPermission from app.schemas.orchestrator import OrchestratorRequest from app.services.agent_conversations import AgentConversationService from app.services.orchestrator import OrchestratorService @@ -228,6 +229,50 @@ def test_conversation_hydration_does_not_reuse_review_type_for_fresh_expense_pro assert continued_context["review_form_values"]["expense_type"] == "差旅费" +def test_conversation_hydration_preserves_incoming_application_time_context() -> None: + session_factory = build_session_factory() + with session_factory() as db: + service = AgentConversationService(db) + conversation = service.get_or_create_conversation( + conversation_id="conv-application-time-context", + user_id="emp-application-time@example.com", + source="user_message", + context_json={ + "session_type": "application", + "entry_source": "application", + "business_time_context": { + "mode": "single", + "start_date": "2026-05-01", + "end_date": "2026-05-01", + "display_value": "2026-05-01", + }, + }, + ) + + stale_context = service.hydrate_context_json( + conversation=conversation, + context_json={"session_type": "application", "entry_source": "application"}, + message="apply travel expense", + ) + fresh_context = service.hydrate_context_json( + conversation=conversation, + context_json={ + "session_type": "application", + "entry_source": "application", + "business_time_context": { + "mode": "single", + "start_date": "2026-05-25", + "end_date": "2026-05-25", + "display_value": "2026-05-25", + }, + }, + message="apply travel expense", + ) + + assert "business_time_context" not in stale_context + assert fresh_context["business_time_context"]["start_date"] == "2026-05-25" + + def test_conversation_scope_creates_new_session_for_different_claim() -> None: session_factory = build_session_factory() with session_factory() as db: @@ -543,3 +588,225 @@ def test_orchestrator_prompts_scene_choices_before_review_for_fresh_ambiguous_ex assert result.get("draft_payload") is None assert "请先在下面选择报销场景" in result["answer"] assert [item["label"] for item in result["suggested_actions"][:3]] == ["差旅费", "交通费", "住宿费"] + + +def test_orchestrator_application_session_does_not_use_reimbursement_scene_prompt( + monkeypatch, +) -> None: + monkeypatch.setattr( + "app.services.runtime_chat.RuntimeChatService.complete", + lambda *_args, **_kwargs: None, + ) + session_factory = build_session_factory() + message = ( + "发生时间:2026-05-25\n" + "地点:上海\n" + "事由:支持上海国网服务器部署\n" + "天数:3天" + ) + with session_factory() as db: + response = OrchestratorService(db).run( + OrchestratorRequest( + source="user_message", + user_id="application-session@example.com", + message=message, + context_json={ + "session_type": "application", + "entry_source": "application", + "name": "申请员工", + }, + ) + ) + + result = response.result + assert response.status == "blocked" + assert response.trace_summary.scenario == "expense" + assert "费用申请" in result["answer"] + assert "| 发生时间 | 2026-05-25" in result["answer"] + assert "请先在下面选择报销场景" not in result["answer"] + assert result.get("review_payload") is None + + +def test_orchestrator_application_session_guides_transport_amount_and_submit( + monkeypatch, +) -> None: + monkeypatch.setattr( + "app.services.runtime_chat.RuntimeChatService.complete", + lambda *_args, **_kwargs: None, + ) + session_factory = build_session_factory() + initial_message = ( + "发生时间:2026-05-25\n" + "地点:上海\n" + "事由:支持上海国网服务器部署\n" + "天数:3天" + ) + context_json = { + "session_type": "application", + "entry_source": "application", + "name": "申请员工", + "manager_name": "陈硕", + } + with session_factory() as db: + service = OrchestratorService(db) + + first = service.run( + OrchestratorRequest( + source="user_message", + user_id="application-flow@example.com", + message=initial_message, + context_json=context_json, + ) + ) + second = service.run( + OrchestratorRequest( + source="user_message", + user_id="application-flow@example.com", + conversation_id=first.conversation_id, + message="飞机", + context_json=context_json, + ) + ) + third = service.run( + OrchestratorRequest( + source="user_message", + user_id="application-flow@example.com", + conversation_id=first.conversation_id, + message="预计总费用:12000元", + context_json=context_json, + ) + ) + fourth = service.run( + OrchestratorRequest( + source="user_message", + user_id="application-flow@example.com", + conversation_id=first.conversation_id, + message="确认提交", + context_json=context_json, + ) + ) + + assert first.status == "blocked" + assert "当前还需要补充:出行方式、预计金额/预算" in first.result["answer"] + assert [item["label"] for item in first.result["suggested_actions"]] == ["一次性补充申请信息"] + assert first.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "出行方式:\n预计总费用:" + + assert "当前还需要补充:预计金额/预算" in second.result["answer"] + assert [item["label"] for item in second.result["suggested_actions"]] == ["一次性补充申请信息"] + assert second.result["suggested_actions"][0]["action_type"] == "prefill_composer" + assert second.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "预计总费用:" + + assert "这是模拟的费用申请结果" in third.result["answer"] + assert "| 事由 | 支持上海国网服务器部署 |" in third.result["answer"] + assert "请核对上述信息无误" in third.result["answer"] + assert "[确认](#application-submit)" in third.result["answer"] + assert third.status == "blocked" + assert third.result["requires_confirmation"] is True + assert third.result["suggested_actions"] == [] + + assert fourth.status == "succeeded" + assert fourth.result["clarification_required"] is False + assert fourth.result["missing_slots"] == [] + assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in fourth.result["answer"] + assert "当前状态:陈硕审核中" in fourth.result["answer"] + assert fourth.result["suggested_actions"] == [] + application_claims = [ + claim + for claim in db.query(ExpenseClaim).all() + if claim.claim_no.startswith("APP-20260525-") + ] + assert len(application_claims) == 1 + assert application_claims[0].status == "submitted" + assert application_claims[0].approval_stage == "直属领导审批" + assert fourth.result["draft_payload"]["claim_no"] == application_claims[0].claim_no + + +def test_orchestrator_application_submit_bypasses_generic_operation_block( + monkeypatch, +) -> None: + monkeypatch.setattr( + "app.services.runtime_chat.RuntimeChatService.complete", + lambda *_args, **_kwargs: None, + ) + session_factory = build_session_factory() + initial_message = ( + "发生时间:2026-05-25\n" + "地点:上海\n" + "事由:支持上海国网服务器部署\n" + "天数:3天" + ) + context_json = { + "session_type": "application", + "entry_source": "application", + "name": "申请员工", + "manager_name": "陈硕", + } + with session_factory() as db: + service = OrchestratorService(db) + + first = service.run( + OrchestratorRequest( + source="user_message", + user_id="application-approval-required@example.com", + message=initial_message, + context_json=context_json, + ) + ) + service.run( + OrchestratorRequest( + source="user_message", + user_id="application-approval-required@example.com", + conversation_id=first.conversation_id, + message="飞机", + context_json=context_json, + ) + ) + preview = service.run( + OrchestratorRequest( + source="user_message", + user_id="application-approval-required@example.com", + conversation_id=first.conversation_id, + message="预计总费用:12000元", + context_json=context_json, + ) + ) + + def approval_required_parse_for_run(self, request, run_id): # noqa: ANN001 + return OntologyParseResult( + scenario="expense", + intent="operate", + entities=[], + permission=OntologyPermission( + level="approval_required", + allowed=False, + reason="操作类请求需要人工审批确认。", + ), + confidence=0.95, + missing_slots=[], + ambiguity=[], + clarification_required=False, + clarification_question=None, + run_id=run_id, + ) + + monkeypatch.setattr( + "app.services.ontology.SemanticOntologyService.parse_for_run", + approval_required_parse_for_run, + ) + submitted = service.run( + OrchestratorRequest( + source="user_message", + user_id="application-approval-required@example.com", + conversation_id=first.conversation_id, + message="确认提交", + context_json=context_json, + ) + ) + + assert preview.status == "blocked" + assert submitted.status == "succeeded" + assert submitted.requires_confirmation is False + assert "操作类请求需要人工审批确认" not in submitted.result["answer"] + assert "当前仅返回确认摘要" not in submitted.result["answer"] + assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in submitted.result["answer"] + assert submitted.result["draft_payload"]["status"] == "submitted" diff --git a/server/tests/test_reimbursement_endpoints.py b/server/tests/test_reimbursement_endpoints.py index 3679630..399e2cf 100644 --- a/server/tests/test_reimbursement_endpoints.py +++ b/server/tests/test_reimbursement_endpoints.py @@ -364,6 +364,70 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review() assert "manager-approve-api@example.com" not in approval_events[0]["message"] +def test_approve_application_endpoint_completes_after_direct_manager_review() -> None: + client, session_factory = build_client() + with session_factory() as db: + manager = Employee( + id="mgr-application-approve-1", + employee_no="E21002", + name="李经理", + email="manager-application-approve-api@example.com", + ) + employee = Employee( + id="emp-application-approve-1", + employee_no="E11002", + name="张三", + email="zhangsan-application-approve-api@example.com", + manager=manager, + ) + claim = ExpenseClaim( + id="claim-application-approve-1", + claim_no="APP-20260525-API001", + employee_id=employee.id, + employee_name="张三", + department_id="dept-1", + department_name="交付部", + project_code=None, + expense_type="travel_application", + reason="支撑国网服务器上线部署", + location="上海", + amount=Decimal("12000.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 5, 25, tzinfo=UTC), + submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=[], + ) + db.add_all([manager, employee, claim]) + db.commit() + + response = client.post( + "/api/v1/reimbursements/claims/claim-application-approve-1/approve", + json={"opinion": "业务必要,同意申请。"}, + headers={ + "X-Auth-Username": "manager-application-approve-api@example.com", + "X-Auth-Name": "manager-application-approve-api@example.com", + "X-Auth-Role-Codes": "manager", + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "approved" + assert payload["approval_stage"] == "审批完成" + assert any( + item["source"] == "manual_approval" + and item["event_type"] == "expense_application_approval" + and item["opinion"] == "业务必要,同意申请。" + and item["operator"] == "李经理" + and item["next_status"] == "approved" + and item["next_approval_stage"] == "审批完成" + for item in payload["risk_flags_json"] + ) + + def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, tmp_path) -> None: preview_bytes = b"fake-preview-png" preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}" diff --git a/server/tests/test_user_agent_service.py b/server/tests/test_user_agent_service.py index ac7220c..9bc047a 100644 --- a/server/tests/test_user_agent_service.py +++ b/server/tests/test_user_agent_service.py @@ -29,6 +29,41 @@ def build_session_factory() -> sessionmaker[Session]: return sessionmaker(bind=engine, autoflush=False, autocommit=False) +def build_application_user_agent_response( + db: Session, + message: str, + *, + history: list[dict[str, object]] | None = None, + context_overrides: dict[str, object] | None = None, +): + context_json = { + "session_type": "application", + "entry_source": "application", + "attachment_count": 0, + } + if context_overrides: + context_json.update(context_overrides) + if history is not None: + context_json["conversation_history"] = history + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query=message, + user_id="pytest", + context_json=context_json, + ) + ) + return UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message=message, + ontology=ontology, + context_json=context_json, + tool_payload={"clarification_required": ontology.clarification_required}, + ) + ) + + def test_user_agent_query_returns_readable_answer_and_actions() -> None: session_factory = build_session_factory() with session_factory() as db: @@ -137,6 +172,216 @@ def test_user_agent_knowledge_prompt_enforces_knowledge_boundary() -> None: assert '"knowledge_answer_evidence": []' in messages[1]["content"] +def test_user_agent_application_context_uses_application_language() -> None: + session_factory = build_session_factory() + message = ( + "发生时间:2026-05-25\n" + "地点:上海\n" + "事由:支持上海国网服务器部署\n" + "天数:3天" + ) + context_json = { + "session_type": "application", + "entry_source": "application", + "attachment_count": 0, + } + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query=message, + user_id="pytest", + context_json=context_json, + ) + ) + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message=message, + ontology=ontology, + context_json=context_json, + tool_payload={"clarification_required": True}, + ) + ) + + assert "费用申请" in response.answer + assert "| 字段 | 内容 |" in response.answer + assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" in response.answer + assert "支持上海国网服务器部署" in response.answer + assert "当前还需要补充:出行方式、预计金额/预算" in response.answer + assert "请先在下面选择报销场景" not in response.answer + assert response.review_payload is None + assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"] + assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n预计总费用:" + + +def test_user_agent_application_infers_natural_reason_and_expands_single_date() -> None: + session_factory = build_session_factory() + message = "发生时间:2026-05-25\n去上海出差3天,支撑上海国网服务器部署" + with session_factory() as db: + response = build_application_user_agent_response(db, message) + + assert "| 发生时间 | 2026-05-25 至 2026-05-28 |" in response.answer + assert "| 地点 | 上海 |" in response.answer + assert "| 事由 | 支撑上海国网服务器部署 |" in response.answer + assert "当前还需要先补充:申请事由" not in response.answer + assert "当前还需要补充:出行方式、预计金额/预算" in response.answer + assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"] + + +def test_user_agent_application_uses_selected_time_and_natural_language_fields() -> None: + session_factory = build_session_factory() + message = "出差上海,支撑国网服务器上线部署" + context_json = { + "session_type": "application", + "entry_source": "application", + "business_time_context": { + "mode": "single", + "start_date": "2026-05-25", + "end_date": "2026-05-25", + "display_value": "2026-05-25", + }, + } + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query=message, + user_id="pytest", + context_json=context_json, + ) + ) + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message=message, + ontology=ontology, + context_json=context_json, + tool_payload={"clarification_required": True}, + ) + ) + + assert "| 发生时间 | 2026-05-25 |" in response.answer + assert "| 地点 | 上海 |" in response.answer + assert "| 事由 | 支撑国网服务器上线部署 |" in response.answer + assert "当前还需要补充:出行方式、预计金额/预算" in response.answer + assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"] + assert response.suggested_actions[0].action_type == "prefill_composer" + assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n预计总费用:" + + +def test_user_agent_application_asks_amount_after_transport_choice() -> None: + session_factory = build_session_factory() + initial_message = ( + "发生时间:2026-05-25\n" + "地点:上海\n" + "事由:支持上海国网服务器部署\n" + "天数:3天" + ) + with session_factory() as db: + response = build_application_user_agent_response( + db, + "飞机", + history=[{"role": "user", "content": initial_message}], + ) + + assert "| 出行方式 | 飞机 |" in response.answer + assert "当前还需要补充:预计金额/预算" in response.answer + assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"] + assert response.suggested_actions[0].action_type == "prefill_composer" + assert response.suggested_actions[0].payload["prompt_prefill"] == "预计总费用:" + + +def test_user_agent_application_missing_base_actions_prefill_composer() -> None: + session_factory = build_session_factory() + with session_factory() as db: + response = build_application_user_agent_response( + db, + "地点:上海\n事由:支撑国网服务器部署\n天数:3天", + ) + + assert "当前还需要补充:发生时间、出行方式、预计金额/预算" in response.answer + assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"] + assert response.suggested_actions[0].action_type == "prefill_composer" + assert response.suggested_actions[0].payload["prompt_prefill"] == "申请时间段:\n出行方式:\n预计总费用:" + + +def test_user_agent_application_builds_preview_when_amount_is_ready() -> None: + session_factory = build_session_factory() + initial_message = ( + "发生时间:2026-05-25\n" + "地点:上海\n" + "事由:支持上海国网服务器部署\n" + "天数:3天" + ) + with session_factory() as db: + response = build_application_user_agent_response( + db, + "预计总费用:12000元", + history=[ + {"role": "user", "content": initial_message}, + {"role": "user", "content": "飞机"}, + ], + ) + + assert "这是模拟的费用申请结果" in response.answer + assert "| 字段 | 内容 |" in response.answer + assert "| 事由 | 支持上海国网服务器部署 |" in response.answer + assert "| 出行方式 | 飞机 |" in response.answer + assert "| 预计总费用 | 12000元 |" in response.answer + assert "请核对上述信息无误" in response.answer + assert "[确认](#application-submit)" in response.answer + assert response.requires_confirmation is True + assert response.suggested_actions == [] + + +def test_user_agent_application_submit_enters_leader_review() -> None: + session_factory = build_session_factory() + initial_message = ( + "发生时间:2026-05-25\n" + "地点:上海\n" + "事由:支持上海国网服务器部署\n" + "天数:3天" + ) + preview_answer = ( + "这是模拟的费用申请结果,请核对:\n" + "| 字段 | 内容 |\n" + "| --- | --- |\n" + "| 申请类型 | 差旅费用申请 |\n" + "| 发生时间 | 2026-05-25 |\n" + "| 地点 | 上海 |\n" + "| 事由 | 支持上海国网服务器部署 |\n" + "| 天数 | 3天 |\n" + "| 出行方式 | 飞机 |\n" + "| 预计总费用 | 12000元 |\n\n" + "请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。" + ) + with session_factory() as db: + response = build_application_user_agent_response( + db, + "确认提交", + context_overrides={"manager_name": "陈硕"}, + history=[ + {"role": "user", "content": initial_message}, + {"role": "user", "content": "飞机"}, + {"role": "user", "content": "预计总费用:12000元"}, + {"role": "assistant", "content": preview_answer}, + ], + ) + + assert "当前操作已完成,单据已经推送给 陈硕 进行审核,请耐心等待" in response.answer + assert "当前状态:陈硕审核中" in response.answer + assert "预算占用参考" in response.answer + assert "APP-20260525-" in response.answer + assert response.suggested_actions == [] + claim = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("APP-20260525-%")).one() + assert claim.status == "submitted" + assert claim.approval_stage == "直属领导审批" + assert claim.expense_type == "travel_application" + assert claim.amount == Decimal("12000.00") + assert claim.employee_name == "pytest" + + def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None: session_factory = build_session_factory() with session_factory() as db: diff --git a/web/src/assets/styles/app.css b/web/src/assets/styles/app.css index 2af0ff2..85c9b06 100644 --- a/web/src/assets/styles/app.css +++ b/web/src/assets/styles/app.css @@ -15,13 +15,43 @@ } .app { + --sidebar-expanded-width: 220px; + --sidebar-collapsed-width: 64px; + --sidebar-motion: 320ms cubic-bezier(0.22, 1, 0.36, 1); + height: var(--desktop-stage-height, 100dvh); min-height: var(--desktop-stage-height, 100dvh); - display: grid; - grid-template-columns: 220px minmax(0, 1fr); + display: flex; + align-items: stretch; background: var(--bg); } +.app-sidebar { + flex: 0 0 auto; + width: var(--sidebar-expanded-width); + min-width: 0; + overflow: hidden; + will-change: width; + transition: width var(--sidebar-motion); +} + +.app.sidebar-collapsed .app-sidebar { + width: var(--sidebar-collapsed-width); + overflow: visible; + position: relative; + z-index: 200; +} + +.app.sidebar-collapsed > .main { + position: relative; + z-index: 1; +} + +.app > .main { + flex: 1 1 auto; + min-width: 0; +} + .boot-state { min-height: var(--desktop-stage-height, 100dvh); display: grid; @@ -133,9 +163,28 @@ } @media (max-width: 1180px) { - .app { grid-template-columns: 220px minmax(0, 1fr); } + .app-sidebar { + width: var(--sidebar-expanded-width); + } + + .app.sidebar-collapsed .app-sidebar { + width: var(--sidebar-collapsed-width); + } } @media (max-width: 760px) { - .app { display: block; } + .app { + display: block; + } + + .app-sidebar { + width: 100%; + transition: none; + } .workarea { padding: 18px 16px 28px; } } + +@media (prefers-reduced-motion: reduce) { + .app-sidebar { + transition: none; + } +} diff --git a/web/src/assets/styles/views/documents-center-view.css b/web/src/assets/styles/views/documents-center-view.css index dab36cd..6bf2d2f 100644 --- a/web/src/assets/styles/views/documents-center-view.css +++ b/web/src/assets/styles/views/documents-center-view.css @@ -481,6 +481,33 @@ tbody tr:last-child td { font-weight: 800; } +.new-document-badge { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + min-width: 30px; + height: 17px; + margin-right: 6px; + padding: 0 5px; + border: 1px solid #fecaca; + border-radius: 999px; + background: #fff5f5; + color: #dc2626; + font-size: 10px; + font-weight: 900; + line-height: 1; + letter-spacing: .2px; +} + +.new-document-badge::before { + content: ""; + width: 5px; + height: 5px; + border-radius: 999px; + background: #ef4444; +} + .doc-kind-tag, .type-tag, .status-tag { diff --git a/web/src/assets/styles/views/travel-reimbursement-create-view.css b/web/src/assets/styles/views/travel-reimbursement-create-view.css index 6d452f8..81e0104 100644 --- a/web/src/assets/styles/views/travel-reimbursement-create-view.css +++ b/web/src/assets/styles/views/travel-reimbursement-create-view.css @@ -567,6 +567,17 @@ color: #059669; } +.shortcut-chip.active { + border-color: rgba(5, 150, 105, 0.38); + background: rgba(16, 185, 129, 0.1); + color: #047857; + box-shadow: none; +} + +.shortcut-chip.active i { + color: #047857; +} + .shortcut-chip:disabled { opacity: 0.48; cursor: not-allowed; diff --git a/web/src/components/business/PersonalWorkbench.vue b/web/src/components/business/PersonalWorkbench.vue index 880c36d..14b4fe5 100644 --- a/web/src/components/business/PersonalWorkbench.vue +++ b/web/src/components/business/PersonalWorkbench.vue @@ -14,8 +14,8 @@
-

嗨,{{ assistantGreetingName }},描述费用或上传票据,AI 直接帮你判断怎么报

-

自动识别报销类别、核对附件完整性,并生成可继续提交的报销草稿。

+

嗨,{{ assistantGreetingName }},描述您想做的事,AI 会直接帮您处理

+

我会自动识别您的意图,协助完成费用申请、报销、查询和制度问答等业务工作,耐心把事情推进到可执行的下一步。

-