diff --git a/server/src/app/services/agent_conversations.py b/server/src/app/services/agent_conversations.py index d7733cd..9a70fcc 100644 --- a/server/src/app/services/agent_conversations.py +++ b/server/src/app/services/agent_conversations.py @@ -193,15 +193,18 @@ class AgentConversationService: if conversation is None: return None + next_sequence = int(conversation.message_count or 0) + 1 + normalized_message_json = dict(message_json or {}) + normalized_message_json.setdefault("sequence", next_sequence) message = AgentConversationMessage( conversation_id=conversation_id, run_id=run_id, role=str(role or "user").strip() or "user", content=normalized_content, - message_json=message_json or {}, + message_json=normalized_message_json, created_at=datetime.now(UTC), ) - conversation.message_count = int(conversation.message_count or 0) + 1 + conversation.message_count = next_sequence if role == "user" and not conversation.title: conversation.title = normalized_content[:48] conversation.updated_at = datetime.now(UTC) @@ -220,15 +223,9 @@ class AgentConversationService: normalized_id = str(conversation_id or "").strip() if not normalized_id or limit <= 0: return [] - - stmt = ( - select(AgentConversationMessage) - .where(AgentConversationMessage.conversation_id == normalized_id) - .order_by(AgentConversationMessage.created_at.desc()) - .limit(limit) - ) - messages = list(self.db.scalars(stmt).all()) - messages.reverse() + messages = self.list_messages(normalized_id) + if limit > 0: + messages = messages[-limit:] return [ { "role": item.role, @@ -252,11 +249,13 @@ class AgentConversationService: stmt = ( select(AgentConversationMessage) .where(AgentConversationMessage.conversation_id == normalized_id) - .order_by(AgentConversationMessage.created_at.asc(), AgentConversationMessage.id.asc()) + .order_by(AgentConversationMessage.created_at.asc()) ) + messages = list(self.db.scalars(stmt).all()) + messages.sort(key=self._message_sort_key) if limit and limit > 0: - stmt = stmt.limit(limit) - return list(self.db.scalars(stmt).all()) + return messages[:limit] + return messages def update_state( self, @@ -396,6 +395,30 @@ class AgentConversationService: "created_at": message.created_at, } + @staticmethod + def _message_sort_key(message: AgentConversationMessage) -> tuple[int, datetime, str, int, str]: + message_json = dict(message.message_json or {}) + sequence = AgentConversationService._coerce_message_sequence(message_json.get("sequence")) + created_at = message.created_at or datetime.min.replace(tzinfo=UTC) + run_id = str(message.run_id or "") + role_priority = 0 if str(message.role or "").strip() == "user" else 1 + fallback_sequence = sequence if sequence is not None else 10**9 + return ( + fallback_sequence, + created_at, + run_id, + role_priority, + str(message.id or ""), + ) + + @staticmethod + def _coerce_message_sequence(value: Any) -> int | None: + try: + normalized = int(value) + except (TypeError, ValueError): + return None + return normalized if normalized > 0 else None + @staticmethod def _is_empty_value(value: Any) -> bool: if value is None: diff --git a/server/src/app/services/agent_foundation.py b/server/src/app/services/agent_foundation.py index e65de48..c33ecbf 100644 --- a/server/src/app/services/agent_foundation.py +++ b/server/src/app/services/agent_foundation.py @@ -35,6 +35,38 @@ from app.models.financial_record import ( logger = get_logger("app.services.agent_foundation") +DEMO_EXPENSE_CLAIM_SIGNATURES = { + ( + "EXP-202605-001", + "张三", + "华南客户拜访差旅报销", + "3280.00", + "submitted", + ), + ( + "EXP-202605-002", + "李四", + "客户路演餐费", + "860.00", + "approved", + ), + ( + "EXP-202605-003", + "王五", + "市场活动会务差旅", + "3280.00", + "review", + ), +} +DEMO_RECEIVABLE_SIGNATURES = { + ("AR-202605-001", "客户A", "50000.00", "partial"), + ("AR-202605-002", "客户B", "78000.00", "overdue"), +} +DEMO_PAYABLE_SIGNATURES = { + ("AP-202605-001", "供应商A", "33000.00", "scheduled"), + ("AP-202605-002", "供应商B", "96000.00", "overdue"), +} + def prepare_agent_foundation() -> None: settings = get_settings() @@ -55,7 +87,7 @@ class AgentFoundationService: try: Base.metadata.create_all(bind=self.db.get_bind()) self._seed_agent_assets() - self._seed_financial_records() + self._sync_demo_financial_records() self._seed_runs_and_logs() self.db.commit() except Exception: @@ -63,6 +95,12 @@ class AgentFoundationService: logger.exception("Failed to prepare agent foundation") raise + def _sync_demo_financial_records(self) -> None: + if get_settings().seed_demo_financial_records: + self._seed_financial_records() + return + self._purge_demo_financial_records() + def _seed_agent_assets(self) -> None: existing_codes = set(self.db.scalars(select(AgentAsset.code)).all()) if existing_codes: @@ -568,6 +606,41 @@ class AgentFoundationService: self.db.add_all([claim_1, claim_2, claim_3, *ar_records, *ap_records]) + def _purge_demo_financial_records(self) -> None: + demo_claims = list(self.db.scalars(select(ExpenseClaim)).all()) + for claim in demo_claims: + signature = ( + str(claim.claim_no or "").strip(), + str(claim.employee_name or "").strip(), + str(claim.reason or "").strip(), + f"{Decimal(claim.amount or 0):.2f}", + str(claim.status or "").strip(), + ) + if signature in DEMO_EXPENSE_CLAIM_SIGNATURES: + self.db.delete(claim) + + demo_receivables = list(self.db.scalars(select(AccountsReceivableRecord)).all()) + for record in demo_receivables: + signature = ( + str(record.receivable_no or "").strip(), + str(record.customer_name or "").strip(), + f"{Decimal(record.amount_outstanding or 0):.2f}", + str(record.status or "").strip(), + ) + if signature in DEMO_RECEIVABLE_SIGNATURES: + self.db.delete(record) + + demo_payables = list(self.db.scalars(select(AccountsPayableRecord)).all()) + for record in demo_payables: + signature = ( + str(record.payable_no or "").strip(), + str(record.vendor_name or "").strip(), + f"{Decimal(record.amount_outstanding or 0):.2f}", + str(record.status or "").strip(), + ) + if signature in DEMO_PAYABLE_SIGNATURES: + self.db.delete(record) + def _seed_runs_and_logs(self) -> None: if self.db.scalar(select(AgentRun.id).limit(1)) is not None: return diff --git a/server/src/app/services/orchestrator.py b/server/src/app/services/orchestrator.py index 83025c1..42fc96a 100644 --- a/server/src/app/services/orchestrator.py +++ b/server/src/app/services/orchestrator.py @@ -1,11 +1,11 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from time import perf_counter from typing import Any -from sqlalchemy import func, or_, select +from sqlalchemy import and_, func, or_, select from sqlalchemy.orm import Session from app.core.agent_enums import ( @@ -60,8 +60,38 @@ class ExecutionOutcome: failed_tool_count: int -PRIVILEGED_EXPENSE_QUERY_ROLE_CODES = {"manager", "finance", "approver", "auditor", "executive"} +PRIVILEGED_EXPENSE_QUERY_ROLE_CODES = {"finance"} SELF_REFERENCE_KEYWORDS = ("我的", "我自己", "本人", "我名下", "给我查", "我提交", "我申请") +EXPENSE_QUERY_RECENT_WINDOW_DAYS = 10 +EXPENSE_QUERY_PREVIEW_LIMIT = 20 +EXPENSE_STATUS_LABELS = { + "draft": "草稿", + "submitted": "已提交", + "review": "审核中", + "approved": "已通过", + "rejected": "已驳回", + "paid": "已付款", +} +EXPENSE_STATUS_GROUP_LABELS = { + "draft": "草稿", + "in_progress": "审批中", + "completed": "审批完成", + "other": "其他状态", +} +EXPENSE_STATUS_GROUP_ORDER = ("draft", "in_progress", "completed", "other") +EXPENSE_TYPE_LABELS = { + "travel": "差旅费", + "hotel": "住宿费", + "transport": "交通费", + "meal": "餐费", + "meeting": "会务费", + "entertainment": "业务招待费", + "office": "办公费", + "training": "培训费", + "communication": "通讯费", + "welfare": "福利费", + "other": "其他费用", +} class OrchestratorService: @@ -850,47 +880,85 @@ class OrchestratorService: message: str, ) -> dict[str, Any]: if ontology.scenario == "expense": - count_stmt = select(func.count()).select_from(ExpenseClaim) - amount_stmt = select(func.coalesce(func.sum(ExpenseClaim.amount), 0)).select_from(ExpenseClaim) - preview_stmt = ( - select(ExpenseClaim) - .order_by(ExpenseClaim.occurred_at.desc(), ExpenseClaim.created_at.desc()) - .limit(5) - ) conditions, scope_label, scoped_to_current_user = self._build_expense_query_scope( ontology=ontology, user_id=user_id, context_json=context_json, message=message, ) + count_stmt = select(func.count()).select_from(ExpenseClaim) + amount_stmt = select(func.coalesce(func.sum(ExpenseClaim.amount), 0)).select_from(ExpenseClaim) for condition in conditions: count_stmt = count_stmt.where(condition) amount_stmt = amount_stmt.where(condition) - preview_stmt = preview_stmt.where(condition) total_count = int(self.db.scalar(count_stmt) or 0) total_amount = float(self.db.scalar(amount_stmt) or 0) + + recent_window_applied = self._should_limit_expense_query_to_recent_window(ontology) + display_count = total_count + display_amount = total_amount + older_record_count = 0 + display_conditions = list(conditions) + window_start_date: str | None = None + window_end_date: str | None = None + + if recent_window_applied: + reference_now = self._resolve_reference_now(context_json) + recent_window_start, recent_window_end = self._resolve_expense_recent_window_bounds(reference_now) + recent_condition = self._build_expense_recent_window_condition( + recent_window_start, + recent_window_end, + ) + display_conditions.append(recent_condition) + window_start_date = recent_window_start.date().isoformat() + window_end_date = (recent_window_end - timedelta(microseconds=1)).date().isoformat() + + recent_count_stmt = select(func.count()).select_from(ExpenseClaim).where(recent_condition) + recent_amount_stmt = select(func.coalesce(func.sum(ExpenseClaim.amount), 0)).select_from(ExpenseClaim).where( + recent_condition + ) + for condition in conditions: + recent_count_stmt = recent_count_stmt.where(condition) + recent_amount_stmt = recent_amount_stmt.where(condition) + display_count = int(self.db.scalar(recent_count_stmt) or 0) + display_amount = float(self.db.scalar(recent_amount_stmt) or 0) + older_record_count = max(0, total_count - display_count) + + preview_stmt = ( + select(ExpenseClaim) + .order_by( + func.coalesce( + ExpenseClaim.submitted_at, + ExpenseClaim.created_at, + ExpenseClaim.occurred_at, + ).desc(), + ExpenseClaim.occurred_at.desc(), + ) + .limit(EXPENSE_QUERY_PREVIEW_LIMIT) + ) + for condition in display_conditions: + preview_stmt = preview_stmt.where(condition) preview_claims = list(self.db.scalars(preview_stmt).all()) + status_groups = self._build_expense_status_groups(display_conditions) return { - "record_count": total_count, - "total_amount": round(total_amount, 2), + "result_type": "expense_claim_list", + "record_count": display_count, + "total_amount": round(display_amount, 2), "scope_label": scope_label, "scoped_to_current_user": scoped_to_current_user, + "recent_window_applied": recent_window_applied, + "window_days": EXPENSE_QUERY_RECENT_WINDOW_DAYS if recent_window_applied else None, + "window_start_date": window_start_date, + "window_end_date": window_end_date, + "preview_count": len(preview_claims), + "older_record_count": older_record_count, "records": [ - { - "claim_id": claim.id, - "claim_no": claim.claim_no, - "employee_name": claim.employee_name, - "expense_type": claim.expense_type, - "amount": round(float(claim.amount), 2), - "status": claim.status, - "approval_stage": claim.approval_stage, - "occurred_at": claim.occurred_at.date().isoformat() if claim.occurred_at else "", - "reason": claim.reason, - "location": claim.location, - } + self._build_expense_query_record(claim) for claim in preview_claims ], - "has_more": total_count > len(preview_claims), + "status_groups": status_groups, + "has_more_in_window": display_count > len(preview_claims), + "total_matched_count": total_count, } if ontology.scenario == "accounts_receivable": @@ -926,6 +994,122 @@ class OrchestratorService: "outstanding_amount": round(total_amount, 2), } + @staticmethod + def _should_limit_expense_query_to_recent_window( + ontology: OntologyParseResult, + ) -> bool: + has_explicit_claim_no = any( + item.type == "expense_claim" + and str(item.normalized_value or item.value or "").strip() + for item in ontology.entities + ) + has_explicit_time_range = bool( + ontology.time_range.start_date or ontology.time_range.end_date + ) + return not has_explicit_claim_no and not has_explicit_time_range + + @staticmethod + def _resolve_reference_now(context_json: dict[str, Any]) -> datetime: + raw_value = str(context_json.get("client_now_iso") or "").strip() + if raw_value: + normalized = raw_value.replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + if parsed.tzinfo is None: + return parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + except ValueError: + pass + return datetime.now(UTC) + + @staticmethod + def _resolve_expense_recent_window_bounds( + reference_now: datetime, + ) -> tuple[datetime, datetime]: + normalized_now = reference_now.astimezone(UTC) + window_end = normalized_now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) + window_start = window_end - timedelta(days=EXPENSE_QUERY_RECENT_WINDOW_DAYS) + return window_start, window_end + + @staticmethod + def _build_expense_recent_window_condition( + window_start: datetime, + window_end: datetime, + ) -> Any: + document_datetime = func.coalesce( + ExpenseClaim.submitted_at, + ExpenseClaim.created_at, + ExpenseClaim.occurred_at, + ) + return and_(document_datetime >= window_start, document_datetime < window_end) + + def _build_expense_status_groups( + self, + conditions: list[Any], + ) -> list[dict[str, Any]]: + stmt = select(ExpenseClaim.status, func.count()).select_from(ExpenseClaim).group_by(ExpenseClaim.status) + for condition in conditions: + stmt = stmt.where(condition) + + grouped_counts = { + key: 0 + for key in EXPENSE_STATUS_GROUP_ORDER + } + for status, count in self.db.execute(stmt).all(): + group_key, _ = self._resolve_expense_status_group(str(status or "").strip()) + grouped_counts[group_key] = grouped_counts.get(group_key, 0) + int(count or 0) + + return [ + { + "key": key, + "label": EXPENSE_STATUS_GROUP_LABELS[key], + "count": grouped_counts.get(key, 0), + } + for key in EXPENSE_STATUS_GROUP_ORDER + if grouped_counts.get(key, 0) > 0 + ] + + @staticmethod + def _resolve_expense_status_group(status: str) -> tuple[str, str]: + normalized = str(status or "").strip().lower() + if normalized == "draft": + return "draft", EXPENSE_STATUS_GROUP_LABELS["draft"] + if normalized in {"submitted", "review"}: + return "in_progress", EXPENSE_STATUS_GROUP_LABELS["in_progress"] + if normalized in {"approved", "paid"}: + return "completed", EXPENSE_STATUS_GROUP_LABELS["completed"] + return "other", EXPENSE_STATUS_GROUP_LABELS["other"] + + @staticmethod + def _resolve_expense_query_document_datetime( + claim: ExpenseClaim, + ) -> datetime | None: + return claim.submitted_at or claim.created_at or claim.occurred_at + + def _build_expense_query_record( + self, + claim: ExpenseClaim, + ) -> dict[str, Any]: + status_group, status_group_label = self._resolve_expense_status_group(claim.status) + document_datetime = self._resolve_expense_query_document_datetime(claim) + return { + "claim_id": claim.id, + "claim_no": claim.claim_no, + "employee_name": claim.employee_name, + "expense_type": claim.expense_type, + "expense_type_label": EXPENSE_TYPE_LABELS.get(claim.expense_type, claim.expense_type or "报销"), + "amount": round(float(claim.amount), 2), + "status": claim.status, + "status_label": EXPENSE_STATUS_LABELS.get(claim.status, claim.status or "处理中"), + "status_group": status_group, + "status_group_label": status_group_label, + "approval_stage": claim.approval_stage, + "document_date": document_datetime.date().isoformat() if document_datetime else "", + "occurred_at": claim.occurred_at.date().isoformat() if claim.occurred_at else "", + "reason": claim.reason, + "location": claim.location, + } + def _build_expense_query_scope( self, *, @@ -1045,7 +1229,6 @@ class OrchestratorService: context_json: dict[str, Any], ) -> tuple[list[Any], str]: normalized_user_id = str(user_id or "").strip() - display_name = str(context_json.get("name") or "").strip() employee = None if normalized_user_id: employee = self.db.scalar( @@ -1076,21 +1259,17 @@ class OrchestratorService: add_condition("employee_id", employee.id) add_condition("employee_name", employee.name) add_condition("employee_name", employee.email) - if not display_name: - display_name = employee.name + else: + add_condition("employee_id", normalized_user_id) + add_condition("employee_name", normalized_user_id) - add_condition("employee_name", display_name) - add_condition("employee_name", normalized_user_id) - - subject_name = display_name or (employee.name if employee is not None else "") or normalized_user_id + subject_name = (employee.name if employee is not None else "") or normalized_user_id if subject_name: return conditions, "你的报销单" return conditions, "当前用户的报销单" @staticmethod def _has_privileged_expense_query_access(context_json: dict[str, Any]) -> bool: - if bool(context_json.get("is_admin")): - return True role_codes = { str(item).strip().lower() for item in context_json.get("role_codes", []) @@ -1147,6 +1326,8 @@ class OrchestratorService: "requires_confirmation": response.requires_confirmation, "degraded": degraded, } + if response.query_payload is not None: + result["query_payload"] = response.query_payload.model_dump() if response.draft_payload is not None: result["draft_payload"] = response.draft_payload.model_dump() if response.review_payload is not None: diff --git a/server/src/app/services/user_agent.py b/server/src/app/services/user_agent.py index 05ab95f..2237532 100644 --- a/server/src/app/services/user_agent.py +++ b/server/src/app/services/user_agent.py @@ -14,6 +14,9 @@ from app.schemas.agent_asset import AgentAssetListItem from app.schemas.user_agent import ( UserAgentCitation, UserAgentDraftPayload, + UserAgentExpenseQueryRecord, + UserAgentQueryPayload, + UserAgentQueryStatusGroup, UserAgentReviewAction, UserAgentReviewEditField, UserAgentReviewClaimGroup, @@ -94,6 +97,13 @@ EXPENSE_STATUS_LABELS = { "paid": "已付款", } +EXPENSE_STATUS_GROUP_LABELS = { + "draft": "草稿", + "in_progress": "审批中", + "completed": "审批完成", + "other": "其他状态", +} + SLOT_LABELS = { "expense_type": "报销类型", "customer_name": "客户名称", @@ -132,6 +142,7 @@ class UserAgentService: citations = self._build_rule_citations(payload) suggested_actions = self._build_suggested_actions(payload) risk_flags = self._resolve_risk_flags(payload) + query_payload = self._build_query_payload(payload) draft_payload = ( self._build_draft_payload(payload) if payload.ontology.intent == "draft" @@ -153,6 +164,7 @@ class UserAgentService: answer=review_answer or str(payload.tool_payload["message"]), citations=citations, suggested_actions=suggested_actions, + query_payload=query_payload, review_payload=review_payload, risk_flags=risk_flags, requires_confirmation=payload.requires_confirmation, @@ -163,6 +175,7 @@ class UserAgentService: answer=review_answer, citations=citations, suggested_actions=suggested_actions, + query_payload=query_payload, draft_payload=draft_payload, review_payload=review_payload, risk_flags=risk_flags, @@ -177,6 +190,7 @@ class UserAgentService: answer=guided_answer, citations=citations, suggested_actions=suggested_actions, + query_payload=query_payload, draft_payload=draft_payload, review_payload=review_payload, risk_flags=risk_flags, @@ -203,6 +217,7 @@ class UserAgentService: answer=answer or fallback_answer, citations=citations, suggested_actions=suggested_actions, + query_payload=query_payload, draft_payload=draft_payload, review_payload=review_payload, risk_flags=risk_flags, @@ -396,43 +411,58 @@ class UserAgentService: subject = self._resolve_subject(payload) if scenario == "expense": - record_count = int(data.get("record_count") or 0) - total_amount = float(data.get("total_amount") or 0) + query_payload = self._build_query_payload(payload) scope_label = str(data.get("scope_label") or subject).strip() or subject - preview_records = data.get("records") - if record_count <= 0: + if query_payload is None: return f"当前没有查到{scope_label}。你可以补充时间范围、单号或状态继续筛选。" - summary = f"查到{scope_label}共 {record_count} 笔,金额合计 {total_amount:.2f} 元。" - if not isinstance(preview_records, list) or not preview_records: - return f"{summary} 如需继续处理,可以查看明细或生成处理意见草稿。" + window_prefix = ( + f"{query_payload.window_start_date} 至 {query_payload.window_end_date}" + if query_payload.recent_window_applied + and query_payload.window_start_date + and query_payload.window_end_date + else ( + f"近 {query_payload.window_days} 日内" + if query_payload.recent_window_applied and query_payload.window_days + else "当前条件下" + ) + ) + if query_payload.record_count <= 0: + if query_payload.older_record_count > 0 and query_payload.window_days: + return ( + f"{window_prefix}没有查到{query_payload.scope_label}。" + f"另有 {query_payload.older_record_count} 笔超过 {query_payload.window_days} 日的单据," + "请前往个人报销中心查看。" + ) + return f"{window_prefix}没有查到{query_payload.scope_label}。你可以补充时间范围、单号或状态继续筛选。" - preview_text: list[str] = [] - for item in preview_records[:3]: - if not isinstance(item, dict): - continue - claim_no = str(item.get("claim_no") or "未编号").strip() or "未编号" - occurred_at = str(item.get("occurred_at") or "").strip() - expense_type = EXPENSE_TYPE_LABELS.get( - str(item.get("expense_type") or "").strip(), - str(item.get("expense_type") or "报销").strip() or "报销", + group_lines = [ + f"{item.label} {item.count} 笔" + for item in query_payload.status_groups + if item.count > 0 + ] + answer_parts = [ + f"我先为你列出{window_prefix}的{query_payload.scope_label}," + f"共 {query_payload.record_count} 笔,金额合计 {query_payload.total_amount:.2f} 元。" + ] + if group_lines: + answer_parts.append(f"其中包括:{'、'.join(group_lines)}。") + + hint_parts: list[str] = [] + if query_payload.has_more_in_window and query_payload.preview_count < query_payload.record_count: + hint_parts.append( + f"下方先展示最近 {query_payload.preview_count} 笔,你可以直接点击单据查看详情。" ) - amount = float(item.get("amount") or 0) - status = EXPENSE_STATUS_LABELS.get( - str(item.get("status") or "").strip(), - str(item.get("status") or "处理中").strip() or "处理中", - ) - date_prefix = f"{occurred_at}," if occurred_at else "" - preview_text.append( - f"{claim_no}({date_prefix}{expense_type},{amount:.2f} 元,{status})" + elif query_payload.records: + hint_parts.append("下方已列出本次命中的真实单据,可直接点击查看详情。") + + if query_payload.older_record_count > 0 and query_payload.window_days: + hint_parts.append( + f"另有 {query_payload.older_record_count} 笔超过 {query_payload.window_days} 日的单据," + "请前往个人报销中心查看。" ) - if not preview_text: - return f"{summary} 如需继续处理,可以查看明细或生成处理意见草稿。" - - has_more = bool(data.get("has_more")) or record_count > len(preview_records) - more_hint = " 当前先展示最近几笔,可继续查看明细。" if has_more else "" - return f"{summary} 其中包括:{';'.join(preview_text)}。{more_hint}".strip() + return " ".join(answer_parts + hint_parts).strip() if scenario == "accounts_receivable": record_count = int(data.get("record_count") or 0) @@ -452,6 +482,81 @@ class UserAgentService: return "已完成当前查询,但暂时没有更多结构化结果可展示。" + def _build_query_payload( + self, + payload: UserAgentRequest, + ) -> UserAgentQueryPayload | None: + if payload.ontology.scenario != "expense" or payload.ontology.intent not in {"query", "compare"}: + return None + + result_type = str(payload.tool_payload.get("result_type") or "").strip() + if result_type and result_type != "expense_claim_list": + return None + + records: list[UserAgentExpenseQueryRecord] = [] + for item in payload.tool_payload.get("records") or []: + if not isinstance(item, dict): + continue + amount = float(item.get("amount") or 0) + records.append( + UserAgentExpenseQueryRecord( + claim_id=str(item.get("claim_id") or "").strip(), + claim_no=str(item.get("claim_no") or "").strip() or "未编号", + employee_name=str(item.get("employee_name") or "").strip(), + expense_type=str(item.get("expense_type") or "").strip(), + expense_type_label=str(item.get("expense_type_label") or "").strip() + or EXPENSE_TYPE_LABELS.get(str(item.get("expense_type") or "").strip(), "报销"), + amount=round(amount, 2), + status=str(item.get("status") or "").strip(), + status_label=str(item.get("status_label") or "").strip() + or EXPENSE_STATUS_LABELS.get(str(item.get("status") or "").strip(), "处理中"), + status_group=str(item.get("status_group") or "").strip() or "other", + status_group_label=str(item.get("status_group_label") or "").strip() + or EXPENSE_STATUS_GROUP_LABELS.get(str(item.get("status_group") or "").strip(), "其他状态"), + approval_stage=str(item.get("approval_stage") or "").strip() or None, + document_date=str(item.get("document_date") or "").strip(), + occurred_at=str(item.get("occurred_at") or "").strip(), + reason=str(item.get("reason") or "").strip(), + location=str(item.get("location") or "").strip(), + ) + ) + + status_groups: list[UserAgentQueryStatusGroup] = [] + for item in payload.tool_payload.get("status_groups") or []: + if not isinstance(item, dict): + continue + status_groups.append( + UserAgentQueryStatusGroup( + key=str(item.get("key") or "").strip() or "other", + label=str(item.get("label") or "").strip() or "其他状态", + count=max(0, int(item.get("count") or 0)), + ) + ) + + return UserAgentQueryPayload( + result_type="expense_claim_list", + scope_label=str(payload.tool_payload.get("scope_label") or self._resolve_subject(payload)).strip() or "报销单", + recent_window_applied=bool(payload.tool_payload.get("recent_window_applied")), + window_days=( + int(payload.tool_payload["window_days"]) + if payload.tool_payload.get("window_days") not in {None, ""} + else None + ), + window_start_date=( + str(payload.tool_payload.get("window_start_date") or "").strip() or None + ), + window_end_date=( + str(payload.tool_payload.get("window_end_date") or "").strip() or None + ), + record_count=max(0, int(payload.tool_payload.get("record_count") or 0)), + preview_count=max(0, int(payload.tool_payload.get("preview_count") or len(records))), + older_record_count=max(0, int(payload.tool_payload.get("older_record_count") or 0)), + has_more_in_window=bool(payload.tool_payload.get("has_more_in_window") or payload.tool_payload.get("has_more")), + total_amount=round(float(payload.tool_payload.get("total_amount") or 0), 2), + status_groups=status_groups, + records=records, + ) + def _build_explain_answer( self, payload: UserAgentRequest,