feat: 优化差旅报销预审流程与个人工作台 UI 体系
- 完善 user_agent_application 申请差旅报销预审槽位与消息组装 - 增强预算助理报告与风险建议卡片交互 - 重构登录页视觉样式与移动端响应式适配 - 优化个人工作台、文档中心、政策中心、员工管理等页面布局 - 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型 - 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
This commit is contained in:
@@ -27,6 +27,7 @@ EXPENSE_TYPE_LABELS = {
|
||||
MAX_DRAFT_CLAIMS_PER_USER = 3
|
||||
EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned")
|
||||
SYSTEM_GENERATED_ITEM_TYPES = {"travel_allowance"}
|
||||
OPTIONAL_ATTACHMENT_ITEM_TYPES = {"ride_ticket", "travel_allowance"}
|
||||
TRAVEL_DETAIL_ITEM_TYPES = {
|
||||
"train_ticket",
|
||||
"flight_ticket",
|
||||
|
||||
@@ -307,6 +307,13 @@ class ExpenseClaimDraftFlowMixin:
|
||||
claim.risk_flags_json = final_risk_flags
|
||||
|
||||
self.db.flush()
|
||||
skip_primary_item = self._should_skip_application_link_placeholder_item(
|
||||
claim=claim,
|
||||
context_json=context_json,
|
||||
document_specs=document_specs,
|
||||
attachment_count=attachment_count,
|
||||
amount=amount,
|
||||
)
|
||||
if document_specs and (is_new_claim or review_action in DOCUMENT_ASSOCIATION_REVIEW_ACTIONS):
|
||||
if review_action == "link_to_existing_draft" and claim.items:
|
||||
self._append_document_items(
|
||||
@@ -319,6 +326,8 @@ class ExpenseClaimDraftFlowMixin:
|
||||
item_specs=document_specs,
|
||||
)
|
||||
self._sync_claim_from_items(claim)
|
||||
elif skip_primary_item:
|
||||
self._sync_application_link_draft_without_items(claim)
|
||||
else:
|
||||
self._upsert_primary_item(
|
||||
claim=claim,
|
||||
@@ -379,6 +388,66 @@ class ExpenseClaimDraftFlowMixin:
|
||||
"invoice_count": int(claim.invoice_count or 0),
|
||||
}
|
||||
|
||||
def _sync_application_link_draft_without_items(self, claim: ExpenseClaim) -> None:
|
||||
claim.amount = Decimal("0.00")
|
||||
claim.invoice_count = 0
|
||||
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, [])
|
||||
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(claim, [])
|
||||
|
||||
def _should_skip_application_link_placeholder_item(
|
||||
self,
|
||||
*,
|
||||
claim: ExpenseClaim | None,
|
||||
context_json: dict[str, Any],
|
||||
document_specs: list[dict[str, Any]],
|
||||
attachment_count: int,
|
||||
amount: Decimal | None,
|
||||
) -> bool:
|
||||
if document_specs or attachment_count > 0:
|
||||
return False
|
||||
if claim is not None and list(claim.items or []):
|
||||
return False
|
||||
if self._build_application_link_flag(context_json) is None:
|
||||
return False
|
||||
|
||||
application_amounts = self._resolve_application_amount_candidates(context_json)
|
||||
review_values = self._normalize_context_object(context_json.get("review_form_values"))
|
||||
raw_amount = str(review_values.get("amount") or "").strip()
|
||||
if raw_amount:
|
||||
parsed_amount = self._parse_context_money_amount(raw_amount)
|
||||
if parsed_amount is None:
|
||||
return True
|
||||
return bool(application_amounts and parsed_amount in application_amounts)
|
||||
|
||||
if amount is None or amount <= Decimal("0.00"):
|
||||
return True
|
||||
return bool(application_amounts and amount in application_amounts)
|
||||
|
||||
@classmethod
|
||||
def _resolve_application_amount_candidates(cls, context_json: dict[str, Any]) -> set[Decimal]:
|
||||
review_values = cls._normalize_context_object(context_json.get("review_form_values"))
|
||||
scene_selection = cls._normalize_context_object(context_json.get("expense_scene_selection"))
|
||||
candidates: set[Decimal] = set()
|
||||
for source in (review_values, scene_selection, context_json):
|
||||
for key in ("application_amount", "application_amount_label", "applicationAmount", "applicationAmountLabel"):
|
||||
parsed = cls._parse_context_money_amount(source.get(key))
|
||||
if parsed is not None:
|
||||
candidates.add(parsed)
|
||||
return candidates
|
||||
|
||||
@staticmethod
|
||||
def _parse_context_money_amount(value: Any) -> Decimal | None:
|
||||
raw_value = str(value or "").strip()
|
||||
if not raw_value:
|
||||
return None
|
||||
compact = re.sub(r"[^\d.\-]", "", raw_value.replace(",", ""))
|
||||
if not compact or compact in {"-", ".", "-."}:
|
||||
return None
|
||||
try:
|
||||
return Decimal(compact).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _merge_application_link_flag(
|
||||
risk_flags: list[Any],
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.services.expense_claim_constants import (
|
||||
AI_REVIEW_REPEAT_RISK_WARNING_COUNT,
|
||||
DOCUMENT_FACT_ITEM_TYPES,
|
||||
LOCATION_REQUIRED_EXPENSE_TYPES,
|
||||
OPTIONAL_ATTACHMENT_ITEM_TYPES,
|
||||
SYSTEM_GENERATED_ITEM_TYPES,
|
||||
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
|
||||
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
||||
@@ -399,6 +400,20 @@ class ExpenseClaimItemSyncMixin:
|
||||
return 1
|
||||
return max(0, int(policy.min_attachment_count or 0))
|
||||
|
||||
@staticmethod
|
||||
def _is_attachment_required_item_type(item_type: str | None) -> bool:
|
||||
normalized = str(item_type or "").strip().lower()
|
||||
return normalized not in SYSTEM_GENERATED_ITEM_TYPES and normalized not in OPTIONAL_ATTACHMENT_ITEM_TYPES
|
||||
|
||||
def _resolve_claim_required_attachment_count(self, claim: ExpenseClaim) -> int:
|
||||
required_items = [
|
||||
item for item in list(claim.items or [])
|
||||
if self._is_attachment_required_item_type(item.item_type)
|
||||
]
|
||||
if not required_items:
|
||||
return 0
|
||||
return min(self._resolve_min_attachment_count(claim.expense_type), len(required_items))
|
||||
|
||||
def _build_scene_reason_corpus(self, claim: ExpenseClaim) -> str:
|
||||
parts = [str(claim.reason or "").strip(), str(claim.location or "").strip()]
|
||||
for item in claim.items:
|
||||
@@ -454,16 +469,16 @@ class ExpenseClaimItemSyncMixin:
|
||||
def _format_submission_blocked_message(issues: list[str]) -> str:
|
||||
normalized_issues = [str(issue or "").strip() for issue in issues if str(issue or "").strip()]
|
||||
if not normalized_issues:
|
||||
return "AI预审未通过,但没有返回明确原因,请刷新草稿后重试。"
|
||||
return "自动检测未通过,但没有返回明确原因,请刷新草稿后重试。"
|
||||
|
||||
return "AI预审暂未通过,原因如下:\n" + "\n".join(
|
||||
return "自动检测暂未通过,原因如下:\n" + "\n".join(
|
||||
f"{index}. {issue}" for index, issue in enumerate(normalized_issues, start=1)
|
||||
)
|
||||
|
||||
def _validate_claim_for_submission(self, claim: ExpenseClaim) -> list[str]:
|
||||
issues: list[str] = []
|
||||
claim_location_required = self._is_location_required_expense_type(claim.expense_type)
|
||||
claim_min_attachment_count = self._resolve_min_attachment_count(claim.expense_type)
|
||||
claim_min_attachment_count = self._resolve_claim_required_attachment_count(claim)
|
||||
|
||||
if self._is_missing_value(claim.employee_name):
|
||||
issues.append("申请人未完善")
|
||||
@@ -498,7 +513,7 @@ class ExpenseClaimItemSyncMixin:
|
||||
issues.append(f"{prefix}缺少地点")
|
||||
if item.item_amount is None or item.item_amount <= Decimal("0.00"):
|
||||
issues.append(f"{prefix}缺少金额")
|
||||
if not is_system_generated and self._is_missing_value(item.invoice_id):
|
||||
if self._is_attachment_required_item_type(item.item_type) and self._is_missing_value(item.invoice_id):
|
||||
issues.append(f"{prefix}缺少票据标识")
|
||||
|
||||
return issues
|
||||
|
||||
@@ -68,7 +68,7 @@ class ExpenseClaimPreReviewMixin:
|
||||
),
|
||||
),
|
||||
)
|
||||
claim.approval_stage = "AI预审" if not is_application_claim else claim.approval_stage
|
||||
claim.approval_stage = "待提交" if not is_application_claim else claim.approval_stage
|
||||
claim.submitted_at = None
|
||||
|
||||
self.db.commit()
|
||||
@@ -105,16 +105,16 @@ class ExpenseClaimPreReviewMixin:
|
||||
business_stage: str,
|
||||
) -> dict[str, Any]:
|
||||
if passed:
|
||||
message = "AI预审通过,费用明细和附件可进入下一步提交审批。"
|
||||
message = "自动检测通过,费用明细和附件可提交审批。"
|
||||
else:
|
||||
message = f"AI预审发现 {blocking_count} 条重大风险,请逐条填写原因后再进入下一步。"
|
||||
message = f"自动检测发现 {blocking_count} 条重大风险,请逐条填写原因后再提交审批。"
|
||||
|
||||
return with_risk_business_stage(
|
||||
{
|
||||
"source": "ai_pre_review",
|
||||
"event_type": "expense_claim_ai_pre_review",
|
||||
"severity": "info" if passed else "high",
|
||||
"label": "AI预审通过" if passed else "AI预审未通过",
|
||||
"label": "自动检测通过" if passed else "自动检测未通过",
|
||||
"message": message,
|
||||
"status": "passed" if passed else "failed",
|
||||
"passed": passed,
|
||||
|
||||
@@ -198,7 +198,7 @@ class ExpenseClaimReviewPreviewMixin:
|
||||
if review_message:
|
||||
break
|
||||
return {
|
||||
"message": review_message or f"报销单 {claim.claim_no} 经 AI预审后转为待补充,请先修正后再提交。",
|
||||
"message": review_message or f"报销单 {claim.claim_no} 经自动检测后转为待补充,请先修正后再提交。",
|
||||
"submission_blocked": True,
|
||||
"draft_only": False,
|
||||
"claim_id": claim.id,
|
||||
@@ -211,7 +211,7 @@ class ExpenseClaimReviewPreviewMixin:
|
||||
|
||||
return {
|
||||
"message": (
|
||||
f"报销单 {claim.claim_no} 已完成 AI预审,"
|
||||
f"报销单 {claim.claim_no} 已完成自动检测,"
|
||||
f"当前节点为 {claim.approval_stage or '审批中'}。"
|
||||
),
|
||||
"draft_only": False,
|
||||
|
||||
@@ -62,9 +62,9 @@ class ExpenseClaimRiskReviewMixin(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "high",
|
||||
"label": "AI预审重点复核",
|
||||
"label": "自动检测重点复核",
|
||||
"message": (
|
||||
f"AI预审发现 {len(high_attachment_flags)} 条高风险附件,"
|
||||
f"自动检测发现 {len(high_attachment_flags)} 条高风险附件,"
|
||||
"已随单流转给审批人重点复核。"
|
||||
),
|
||||
}
|
||||
@@ -74,9 +74,9 @@ class ExpenseClaimRiskReviewMixin(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "medium",
|
||||
"label": "AI预审提醒",
|
||||
"label": "自动检测提醒",
|
||||
"message": (
|
||||
f"AI预审发现 {len(medium_attachment_flags)} 条中风险附件,"
|
||||
f"自动检测发现 {len(medium_attachment_flags)} 条中风险附件,"
|
||||
"已随单流转给审批人复核。"
|
||||
),
|
||||
}
|
||||
@@ -90,7 +90,7 @@ class ExpenseClaimRiskReviewMixin(
|
||||
"source": "submission_review",
|
||||
"severity": "medium",
|
||||
"label": "审批链待分配",
|
||||
"message": "AI预审发现直属领导缺失,已提交到审批环节等待分配或复核。",
|
||||
"message": "自动检测发现直属领导缺失,已提交到审批环节等待分配或复核。",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -147,7 +147,7 @@ class ExpenseClaimRiskReviewMixin(
|
||||
)
|
||||
|
||||
if attention_reasons:
|
||||
summary_message = "AI预审发现需审批重点关注事项:" + ";".join(
|
||||
summary_message = "自动检测发现需审批重点关注事项:" + ";".join(
|
||||
dict.fromkeys(attention_reasons)
|
||||
)
|
||||
review_flags.insert(
|
||||
@@ -155,7 +155,7 @@ class ExpenseClaimRiskReviewMixin(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"severity": "medium",
|
||||
"label": "AI预审重点复核",
|
||||
"label": "自动检测重点复核",
|
||||
"message": summary_message,
|
||||
},
|
||||
)
|
||||
@@ -167,7 +167,7 @@ class ExpenseClaimRiskReviewMixin(
|
||||
"approval_stage": "直属领导审批",
|
||||
"risk_flags": preserved_flags + review_flags,
|
||||
"message": (
|
||||
f"报销单 {claim.claim_no} 已完成 AI预审,"
|
||||
f"报销单 {claim.claim_no} 已完成自动检测,"
|
||||
f"现已提交给直属领导 {manager_name or '审批人'} 审批。"
|
||||
),
|
||||
"passed": True,
|
||||
|
||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
@@ -21,6 +21,8 @@ from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetT
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.hermes_report import HermesRiskReport
|
||||
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
||||
from app.schemas.ontology import OntologyEntity, OntologyParseResult
|
||||
from app.schemas.reimbursement import (
|
||||
ExpenseClaimItemCreate,
|
||||
@@ -560,6 +562,9 @@ class ExpenseClaimService(
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
if self._is_expense_application_claim(claim) and not current_user.is_admin:
|
||||
raise ValueError("申请单只有系统管理员可以删除。")
|
||||
|
||||
if self._access_policy.is_archived_claim(claim) and not current_user.is_admin:
|
||||
raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。")
|
||||
|
||||
@@ -572,6 +577,7 @@ class ExpenseClaimService(
|
||||
resource_id = claim.id
|
||||
|
||||
self._release_budget_for_delete(claim, current_user)
|
||||
self._delete_claim_analysis_records(resource_id)
|
||||
self._attachment_storage.delete_claim_files(claim)
|
||||
self.db.delete(claim)
|
||||
self.db.commit()
|
||||
@@ -588,6 +594,16 @@ class ExpenseClaimService(
|
||||
|
||||
return claim
|
||||
|
||||
def _delete_claim_analysis_records(self, claim_id: str) -> None:
|
||||
observation_ids = select(RiskObservation.id).where(RiskObservation.claim_id == claim_id)
|
||||
self.db.execute(
|
||||
delete(RiskObservationFeedback).where(
|
||||
RiskObservationFeedback.observation_id.in_(observation_ids)
|
||||
)
|
||||
)
|
||||
self.db.execute(delete(RiskObservation).where(RiskObservation.claim_id == claim_id))
|
||||
self.db.execute(delete(HermesRiskReport).where(HermesRiskReport.claim_id == claim_id))
|
||||
|
||||
def return_claim(
|
||||
self,
|
||||
claim_id: str,
|
||||
@@ -740,8 +756,6 @@ class ExpenseClaimService(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ CITY_CONSISTENCY_SEMANTIC_TYPES = {
|
||||
"travel_city_consistency",
|
||||
"travel_route_city_consistency",
|
||||
}
|
||||
ROUTE_CITY_SPLIT_PATTERN = re.compile(r"\s*(?:至|到|→|->|-|-|—|~|~|/|、|,|,|;|;)\s*")
|
||||
|
||||
|
||||
class RiskRuleTemplateExecutor:
|
||||
@@ -612,19 +613,32 @@ class RiskRuleTemplateExecutor:
|
||||
) -> list[str]:
|
||||
if len(route_values) < 2:
|
||||
return []
|
||||
allowed = {value.lower() for value in [*reference_values, *home_values] if value}
|
||||
if not allowed:
|
||||
allowed_values = [value for value in [*reference_values, *home_values] if value]
|
||||
if not allowed_values:
|
||||
return []
|
||||
candidates = route_values if home_values else route_values[1:-1]
|
||||
unexpected: list[str] = []
|
||||
for city in candidates:
|
||||
normalized = city.lower()
|
||||
if normalized in allowed:
|
||||
if RiskRuleTemplateExecutor._values_overlap([city], allowed_values):
|
||||
continue
|
||||
if city not in unexpected:
|
||||
unexpected.append(city)
|
||||
return unexpected
|
||||
|
||||
@staticmethod
|
||||
def _expand_route_city_values(values: list[Any]) -> list[Any]:
|
||||
expanded: list[Any] = []
|
||||
for value in values:
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
expanded.extend(RiskRuleTemplateExecutor._expand_route_city_values(list(value)))
|
||||
continue
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
parts = [part.strip() for part in ROUTE_CITY_SPLIT_PATTERN.split(text) if part.strip()]
|
||||
expanded.extend(parts if len(parts) >= 2 else [text])
|
||||
return expanded
|
||||
|
||||
def _resolve_attachment_values(
|
||||
self, field_key: str, contexts: list[dict[str, Any]]
|
||||
) -> list[str]:
|
||||
@@ -643,7 +657,7 @@ class RiskRuleTemplateExecutor:
|
||||
else self._scan_document_values(document_info, "city")
|
||||
)
|
||||
elif field_key == "route_cities":
|
||||
values.extend(self._scan_document_values(document_info, field_key))
|
||||
values.extend(self._expand_route_city_values(self._scan_document_values(document_info, field_key)))
|
||||
else:
|
||||
values.extend(self._scan_document_values(document_info, field_key))
|
||||
return self._normalize_values(values)
|
||||
@@ -878,9 +892,9 @@ class RiskRuleTemplateExecutor:
|
||||
left_set = {value.lower() for value in left_values}
|
||||
right_set = {value.lower() for value in right_values}
|
||||
if operator in {"equals", "in", "overlap"}:
|
||||
return bool(left_set & right_set)
|
||||
return RiskRuleTemplateExecutor._values_overlap(left_values, right_values)
|
||||
if operator in {"not_equals", "not_in", "not_overlap"}:
|
||||
return not bool(left_set & right_set)
|
||||
return not RiskRuleTemplateExecutor._values_overlap(left_values, right_values)
|
||||
if operator == "contains_any":
|
||||
return any(any(right in left for right in right_set) for left in left_set)
|
||||
return bool(left_set & right_set)
|
||||
|
||||
@@ -4,7 +4,7 @@ import re
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
@@ -20,7 +20,10 @@ from app.services.document_numbering import (
|
||||
build_document_number,
|
||||
generate_unique_expense_claim_no,
|
||||
)
|
||||
from app.services.user_agent_application_dates import expand_application_time_with_days
|
||||
from app.services.user_agent_application_dates import (
|
||||
expand_application_time_with_days,
|
||||
resolve_application_days_from_time_range,
|
||||
)
|
||||
from app.services.user_agent_application_locations import normalize_application_location
|
||||
from app.services.application_system_estimate import apply_application_system_estimate_to_facts
|
||||
|
||||
@@ -32,6 +35,43 @@ APPLICATION_CONTEXT_VALUES = {
|
||||
"preapproval",
|
||||
}
|
||||
APPLICATION_BASE_FIELDS = ("time", "location", "reason")
|
||||
APPLICATION_TIME_LABELS = ("行程时间", "招待时间", "申请时间", "发生时间", "业务发生时间", "时间")
|
||||
APPLICATION_FIELD_LABELS = (
|
||||
"申请类型",
|
||||
"费用类型",
|
||||
"姓名",
|
||||
"申请人",
|
||||
"部门",
|
||||
"岗位",
|
||||
"职级",
|
||||
"直属领导",
|
||||
*APPLICATION_TIME_LABELS,
|
||||
"地点",
|
||||
"业务地点",
|
||||
"发生地点",
|
||||
"目的地",
|
||||
"事由",
|
||||
"申请事由",
|
||||
"出差事由",
|
||||
"原因",
|
||||
"用途",
|
||||
"天数",
|
||||
"出差天数",
|
||||
"申请天数",
|
||||
"出行方式",
|
||||
"交通方式",
|
||||
"交通工具",
|
||||
"出行工具",
|
||||
"用户预估费用",
|
||||
"预估费用",
|
||||
"预计总费用",
|
||||
"预计费用",
|
||||
"预计金额",
|
||||
"申请金额",
|
||||
"预算",
|
||||
"金额",
|
||||
"费用",
|
||||
)
|
||||
APPLICATION_TRANSPORT_OPTIONS = ("飞机", "火车", "轮船")
|
||||
APPLICATION_TRANSPORT_KEYWORDS = {
|
||||
"飞机": ("飞机", "机票", "航班", "乘机", "坐飞机"),
|
||||
@@ -64,6 +104,18 @@ APPLICATION_SUBMIT_KEYWORDS = (
|
||||
"直接提交",
|
||||
)
|
||||
APPLICATION_SHORT_CONFIRMATIONS = {"提交", "确认", "是", "好的", "可以", "没问题"}
|
||||
APPLICATION_MISSING_VALUES = {"", "待补充", "待确认", "未知", "暂无", "无", "null", "none"}
|
||||
APPLICATION_DUPLICATE_IGNORED_STATUSES = {
|
||||
"cancelled",
|
||||
"canceled",
|
||||
"void",
|
||||
"voided",
|
||||
"deleted",
|
||||
"已取消",
|
||||
"已作废",
|
||||
"作废",
|
||||
"已删除",
|
||||
}
|
||||
|
||||
|
||||
class UserAgentApplicationMixin:
|
||||
@@ -119,7 +171,12 @@ class UserAgentApplicationMixin:
|
||||
step = self._resolve_expense_application_step(payload, facts)
|
||||
application_claim = None
|
||||
if step == "submitted":
|
||||
application_claim = self._create_expense_application_record(payload, facts)
|
||||
application_claim = self._find_duplicate_expense_application_record(payload, facts)
|
||||
if application_claim is not None:
|
||||
step = "duplicate"
|
||||
facts["duplicate_application_stage"] = str(application_claim.approval_stage or "").strip()
|
||||
else:
|
||||
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)
|
||||
@@ -128,7 +185,11 @@ class UserAgentApplicationMixin:
|
||||
citations=[],
|
||||
suggested_actions=self._build_expense_application_actions(step, facts),
|
||||
query_payload=None,
|
||||
draft_payload=self._build_submitted_application_payload(application_claim, facts),
|
||||
draft_payload=(
|
||||
self._build_submitted_application_payload(application_claim, facts)
|
||||
if step == "submitted"
|
||||
else None
|
||||
),
|
||||
review_payload=None,
|
||||
risk_flags=risk_flags,
|
||||
requires_confirmation=step == "preview",
|
||||
@@ -170,6 +231,19 @@ class UserAgentApplicationMixin:
|
||||
]
|
||||
)
|
||||
|
||||
if step == "duplicate":
|
||||
application_no = str(facts.get("application_no") or "").strip()
|
||||
stage = str(facts.get("duplicate_application_stage") or "").strip() or "处理中"
|
||||
time_label = self._resolve_application_time_label(facts)
|
||||
return "\n\n".join(
|
||||
[
|
||||
f"检测到同一申请人、同一申请类型、同一{time_label}已存在申请单,系统没有重复创建。",
|
||||
f"已有申请单号:{application_no}",
|
||||
f"当前节点:{stage}",
|
||||
"如需继续处理,请在单据中心查看该申请;如果本次业务时间不同,请先调整时间后再提交。",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n\n".join(
|
||||
[
|
||||
"这是费用申请核对结果,请核对:",
|
||||
@@ -225,13 +299,27 @@ class UserAgentApplicationMixin:
|
||||
facts[key] = value
|
||||
|
||||
context_json = payload.context_json or {}
|
||||
current_user = getattr(payload, "current_user", None)
|
||||
context_time = self._resolve_application_time_from_context(context_json)
|
||||
if context_time and self._should_prefer_context_application_time(facts.get("time", ""), context_time):
|
||||
facts["time"] = context_time
|
||||
current_user = self._build_application_current_user(payload)
|
||||
employee = ExpenseClaimAccessPolicy(self.db).resolve_current_employee(current_user)
|
||||
if not facts["applicant"]:
|
||||
facts["applicant"] = str(
|
||||
context_json.get("name")
|
||||
or context_json.get("user_name")
|
||||
or context_json.get("applicant")
|
||||
or getattr(current_user, "name", "")
|
||||
or (employee.name if employee is not None else "")
|
||||
or current_user.name
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["grade"]:
|
||||
facts["grade"] = str(
|
||||
context_json.get("grade")
|
||||
or context_json.get("employee_grade")
|
||||
or context_json.get("employeeGrade")
|
||||
or current_user.grade
|
||||
or (employee.grade if employee is not None else "")
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["department"]:
|
||||
@@ -239,7 +327,12 @@ class UserAgentApplicationMixin:
|
||||
context_json.get("department")
|
||||
or context_json.get("department_name")
|
||||
or context_json.get("departmentName")
|
||||
or getattr(current_user, "department_name", "")
|
||||
or current_user.department_name
|
||||
or (
|
||||
employee.organization_unit.name
|
||||
if employee is not None and employee.organization_unit is not None
|
||||
else ""
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["position"]:
|
||||
@@ -247,6 +340,8 @@ class UserAgentApplicationMixin:
|
||||
context_json.get("position")
|
||||
or context_json.get("employee_position")
|
||||
or context_json.get("employeePosition")
|
||||
or current_user.position
|
||||
or (employee.position if employee is not None else "")
|
||||
or ""
|
||||
).strip()
|
||||
if not facts["manager_name"]:
|
||||
@@ -255,7 +350,17 @@ class UserAgentApplicationMixin:
|
||||
or context_json.get("managerName")
|
||||
or context_json.get("direct_manager_name")
|
||||
or context_json.get("directManagerName")
|
||||
or getattr(current_user, "manager_name", "")
|
||||
or current_user.manager_name
|
||||
or (
|
||||
employee.manager.name
|
||||
if employee is not None and employee.manager is not None
|
||||
else ""
|
||||
)
|
||||
or (
|
||||
employee.organization_unit.manager_name
|
||||
if employee is not None and employee.organization_unit is not None
|
||||
else ""
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
@@ -266,6 +371,10 @@ class UserAgentApplicationMixin:
|
||||
facts.get("days", ""),
|
||||
payload.context_json or {},
|
||||
)
|
||||
if self._is_application_missing_value(facts.get("days", "")):
|
||||
range_days = resolve_application_days_from_time_range(facts.get("time", ""))
|
||||
if range_days:
|
||||
facts["days"] = f"{range_days}天"
|
||||
apply_application_system_estimate_to_facts(facts)
|
||||
return facts
|
||||
|
||||
@@ -285,11 +394,12 @@ class UserAgentApplicationMixin:
|
||||
return value
|
||||
return ""
|
||||
|
||||
reason = UserAgentApplicationMixin._cleanup_application_reason_candidate(pick("reason"))
|
||||
return {
|
||||
"application_type": pick("applicationType", "application_type"),
|
||||
"time": pick("time", "timeRange", "time_range"),
|
||||
"location": pick("location"),
|
||||
"reason": pick("reason"),
|
||||
"reason": reason,
|
||||
"days": pick("days"),
|
||||
"transport_mode": pick("transportMode", "transport_mode"),
|
||||
"amount": pick("amount"),
|
||||
@@ -313,6 +423,10 @@ class UserAgentApplicationMixin:
|
||||
"policy_total_amount": pick("policyTotalAmount", "policy_total_amount"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _is_application_missing_value(value: object) -> bool:
|
||||
return str(value or "").strip().lower() in APPLICATION_MISSING_VALUES
|
||||
|
||||
def _resolve_expense_application_step(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
@@ -384,10 +498,16 @@ class UserAgentApplicationMixin:
|
||||
def _resolve_application_time_from_text(message: str) -> str:
|
||||
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||
message,
|
||||
("发生时间", "业务发生时间", "申请时间", "时间"),
|
||||
APPLICATION_TIME_LABELS,
|
||||
)
|
||||
if labeled:
|
||||
return labeled
|
||||
range_match = re.search(
|
||||
r"(?P<start>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)\s*(?:至|到|~|—|–|--)\s*(?P<end>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)",
|
||||
str(message or ""),
|
||||
)
|
||||
if range_match:
|
||||
return f"{range_match.group('start').rstrip('日')} 至 {range_match.group('end').rstrip('日')}"
|
||||
match = re.search(
|
||||
r"(?P<date>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)",
|
||||
str(message or ""),
|
||||
@@ -406,11 +526,26 @@ class UserAgentApplicationMixin:
|
||||
return start_date if start_date == end_date else f"{start_date} 至 {end_date}"
|
||||
return display_value
|
||||
|
||||
@staticmethod
|
||||
def _should_prefer_context_application_time(current_time: str, context_time: str) -> bool:
|
||||
current = str(current_time or "").strip()
|
||||
context = str(context_time or "").strip()
|
||||
if not context:
|
||||
return False
|
||||
if not current:
|
||||
return True
|
||||
if "至" not in context:
|
||||
return False
|
||||
current_dates = re.findall(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", current)
|
||||
context_dates = re.findall(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", context)
|
||||
return len(current_dates) <= 1 and len(context_dates) >= 2 and current_dates[:1] == context_dates[:1]
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_labeled_value(message: str, labels: tuple[str, ...]) -> str:
|
||||
label_pattern = "|".join(re.escape(label) for label in labels)
|
||||
next_label_pattern = "|".join(re.escape(label) for label in APPLICATION_FIELD_LABELS)
|
||||
match = re.search(
|
||||
rf"(?:{label_pattern})[::]\s*(?P<value>[^\n,。;;]+)",
|
||||
rf"(?:{label_pattern})[::]\s*(?P<value>[\s\S]*?)(?=\s*(?:{next_label_pattern})[::]|[\n,。;;]|$)",
|
||||
str(message or ""),
|
||||
)
|
||||
return match.group("value").strip() if match else ""
|
||||
@@ -478,7 +613,7 @@ class UserAgentApplicationMixin:
|
||||
("事由", "申请事由", "出差事由", "原因", "用途"),
|
||||
)
|
||||
if labeled:
|
||||
return labeled
|
||||
return UserAgentApplicationMixin._cleanup_application_reason_candidate(labeled)
|
||||
|
||||
text = str(message or "").strip()
|
||||
if not text:
|
||||
@@ -492,7 +627,15 @@ class UserAgentApplicationMixin:
|
||||
|
||||
if not candidates:
|
||||
return ""
|
||||
return max(candidates, key=len)
|
||||
business_candidate = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in candidates
|
||||
if any(keyword in candidate for keyword in APPLICATION_REASON_VERBS)
|
||||
),
|
||||
"",
|
||||
)
|
||||
return business_candidate or max(candidates, key=len)
|
||||
|
||||
@staticmethod
|
||||
def _cleanup_application_reason_candidate(segment: str) -> str:
|
||||
@@ -501,10 +644,12 @@ class UserAgentApplicationMixin:
|
||||
return ""
|
||||
|
||||
text = re.sub(
|
||||
r"^(?:发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额)[::]\s*",
|
||||
r"^(?:行程时间|招待时间|发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|目的地|天数|出差天数|申请天数|出行方式|交通方式|交通工具|出行工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)[::]\s*",
|
||||
"",
|
||||
text,
|
||||
)
|
||||
if re.fullmatch(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?\s*(?:至|到|~|—|–|--)\s*20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", text):
|
||||
return ""
|
||||
if re.fullmatch(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", text):
|
||||
return ""
|
||||
if re.fullmatch(r"(?P<days>\d+|[一二两三四五六七八九十]{1,3})\s*天", text):
|
||||
@@ -617,8 +762,8 @@ class UserAgentApplicationMixin:
|
||||
return {
|
||||
"expense_type": "申请类型",
|
||||
"amount": "系统预估费用",
|
||||
"time_range": "发生时间",
|
||||
"time": "发生时间",
|
||||
"time_range": "申请时间",
|
||||
"time": "申请时间",
|
||||
"location": "地点",
|
||||
"reason": "申请事由",
|
||||
"days": "天数",
|
||||
@@ -656,7 +801,7 @@ class UserAgentApplicationMixin:
|
||||
@staticmethod
|
||||
def _resolve_application_prefill_config(field: str) -> tuple[str, str]:
|
||||
config = {
|
||||
"time": ("补充发生时间", "申请时间段:"),
|
||||
"time": ("补充申请时间", "申请时间段:"),
|
||||
"location": ("补充地点", "地点:"),
|
||||
"reason": ("补充申请事由", "事由:"),
|
||||
"days": ("补充天数", "天数:"),
|
||||
@@ -699,7 +844,17 @@ class UserAgentApplicationMixin:
|
||||
return "差旅费用申请"
|
||||
|
||||
@staticmethod
|
||||
def _build_application_summary(facts: dict[str, str]) -> str:
|
||||
def _resolve_application_time_label(facts: dict[str, str]) -> str:
|
||||
application_type = str(facts.get("application_type") or "").strip()
|
||||
if "差旅" in application_type or "出差" in application_type:
|
||||
return "行程时间"
|
||||
if "招待" in application_type or "宴请" in application_type or "餐饮" in application_type:
|
||||
return "招待时间"
|
||||
return "申请时间"
|
||||
|
||||
@classmethod
|
||||
def _build_application_summary(cls, facts: dict[str, str]) -> str:
|
||||
time_label = cls._resolve_application_time_label(facts)
|
||||
return "\n".join(
|
||||
f"{label}:{value or '待补充'}"
|
||||
for label, value in (
|
||||
@@ -709,7 +864,7 @@ class UserAgentApplicationMixin:
|
||||
("岗位", facts.get("position", "")),
|
||||
("职级", facts.get("grade", "")),
|
||||
("直属领导", facts.get("manager_name", "")),
|
||||
("发生时间", facts.get("time", "")),
|
||||
(time_label, facts.get("time", "")),
|
||||
("地点", facts.get("location", "")),
|
||||
("事由", facts.get("reason", "")),
|
||||
("天数", facts.get("days", "")),
|
||||
@@ -722,12 +877,14 @@ class UserAgentApplicationMixin:
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@classmethod
|
||||
def _build_application_summary_table(
|
||||
cls,
|
||||
facts: dict[str, str],
|
||||
*,
|
||||
include_empty: bool = True,
|
||||
) -> str:
|
||||
time_label = cls._resolve_application_time_label(facts)
|
||||
rows = [
|
||||
("申请类型", facts.get("application_type", "")),
|
||||
("姓名", facts.get("applicant", "")),
|
||||
@@ -735,7 +892,7 @@ class UserAgentApplicationMixin:
|
||||
("岗位", facts.get("position", "")),
|
||||
("职级", facts.get("grade", "")),
|
||||
("直属领导", facts.get("manager_name", "")),
|
||||
("发生时间", facts.get("time", "")),
|
||||
(time_label, facts.get("time", "")),
|
||||
("地点", facts.get("location", "")),
|
||||
("事由", facts.get("reason", "")),
|
||||
("天数", facts.get("days", "")),
|
||||
@@ -816,6 +973,90 @@ class UserAgentApplicationMixin:
|
||||
self.db.refresh(claim)
|
||||
return claim
|
||||
|
||||
def _find_duplicate_expense_application_record(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
facts: dict[str, str],
|
||||
) -> ExpenseClaim | None:
|
||||
current_user = self._build_application_current_user(payload)
|
||||
access_policy = ExpenseClaimAccessPolicy(self.db)
|
||||
employee = access_policy.resolve_current_employee(current_user)
|
||||
employee_id = employee.id if employee is not None else None
|
||||
employee_name = str(current_user.username or current_user.name or payload.user_id or "anonymous").strip()
|
||||
if employee is not None:
|
||||
employee_name = str(employee.name or employee.employee_no or employee.email or employee_name).strip()
|
||||
|
||||
employee_filter = ExpenseClaim.employee_name == employee_name
|
||||
if employee_id is not None:
|
||||
employee_filter = or_(ExpenseClaim.employee_id == employee_id, employee_filter)
|
||||
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.where(
|
||||
ExpenseClaim.expense_type == self._resolve_application_expense_type_code(facts),
|
||||
employee_filter,
|
||||
)
|
||||
.order_by(ExpenseClaim.id.desc())
|
||||
.limit(100)
|
||||
)
|
||||
occurred_at = self._parse_application_occurred_at(facts.get("time", ""))
|
||||
for claim in self.db.scalars(stmt).all():
|
||||
if self._is_ignored_application_duplicate_status(claim.status):
|
||||
continue
|
||||
if self._matches_application_business_time(claim, facts, occurred_at):
|
||||
return claim
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_ignored_application_duplicate_status(status: str | None) -> bool:
|
||||
return str(status or "").strip().lower() in APPLICATION_DUPLICATE_IGNORED_STATUSES
|
||||
|
||||
@classmethod
|
||||
def _matches_application_business_time(
|
||||
cls,
|
||||
claim: ExpenseClaim,
|
||||
facts: dict[str, str],
|
||||
occurred_at: datetime,
|
||||
) -> bool:
|
||||
current_time = cls._normalize_application_time_identity(facts.get("time"))
|
||||
existing_detail = cls._extract_application_detail_from_claim(claim)
|
||||
existing_time = cls._normalize_application_time_identity(existing_detail.get("time"))
|
||||
if current_time and existing_time:
|
||||
return current_time == existing_time
|
||||
if claim.occurred_at is None:
|
||||
return False
|
||||
return claim.occurred_at.date() == occurred_at.date()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_application_time_identity(value: object) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
normalized = (
|
||||
normalized.replace("到", "至")
|
||||
.replace("~", "至")
|
||||
.replace("—", "至")
|
||||
.replace("–", "至")
|
||||
.replace("-", "至")
|
||||
.replace("/", "-")
|
||||
)
|
||||
return re.sub(r"\s+", "", normalized)
|
||||
|
||||
@staticmethod
|
||||
def _extract_application_detail_from_claim(claim: ExpenseClaim) -> dict[str, object]:
|
||||
flags = claim.risk_flags_json
|
||||
if isinstance(flags, dict):
|
||||
flags = [flags]
|
||||
if not isinstance(flags, list):
|
||||
return {}
|
||||
for item in flags:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
detail = item.get("application_detail")
|
||||
if isinstance(detail, dict):
|
||||
return detail
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _build_application_detail_flag(facts: dict[str, str]) -> dict[str, object]:
|
||||
return with_risk_business_stage(
|
||||
@@ -895,6 +1136,24 @@ class UserAgentApplicationMixin:
|
||||
or context_json.get("departmentName")
|
||||
or ""
|
||||
).strip(),
|
||||
cost_center=str(context_json.get("cost_center") or context_json.get("costCenter") or "").strip(),
|
||||
position=str(
|
||||
context_json.get("position")
|
||||
or context_json.get("employee_position")
|
||||
or context_json.get("employeePosition")
|
||||
or ""
|
||||
).strip(),
|
||||
grade=str(
|
||||
context_json.get("grade")
|
||||
or context_json.get("employee_grade")
|
||||
or context_json.get("employeeGrade")
|
||||
or ""
|
||||
).strip(),
|
||||
employee_no=str(
|
||||
context_json.get("employee_no")
|
||||
or context_json.get("employeeNo")
|
||||
or ""
|
||||
).strip(),
|
||||
manager_name=str(
|
||||
context_json.get("manager_name")
|
||||
or context_json.get("managerName")
|
||||
|
||||
@@ -43,6 +43,20 @@ def resolve_application_days_count(days_text: str) -> int:
|
||||
return _parse_chinese_number(chinese_match.group(0))
|
||||
|
||||
|
||||
def resolve_application_days_from_time_range(time_text: str) -> int:
|
||||
matches = re.findall(
|
||||
r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?",
|
||||
str(time_text or ""),
|
||||
)
|
||||
if len(matches) < 2:
|
||||
return 0
|
||||
start_date = _parse_application_date(matches[0])
|
||||
end_date = _parse_application_date(matches[-1])
|
||||
if start_date is None or end_date is None or end_date < start_date:
|
||||
return 0
|
||||
return (end_date - start_date).days + 1
|
||||
|
||||
|
||||
def _resolve_start_date(time_text: str, context_json: dict[str, Any]) -> date | None:
|
||||
if time_text:
|
||||
match = re.search(
|
||||
|
||||
@@ -183,9 +183,14 @@ class UserAgentReviewMessageMixin:
|
||||
if draft_payload is not None and draft_payload.claim_no:
|
||||
return (
|
||||
f"已按您当前确认的信息保存为草稿 {draft_payload.claim_no}。"
|
||||
"后续上传附件或补充票据信息时,请关联这张草稿;补齐缺失项后再继续提交。"
|
||||
"系统已完成草稿规则校验,风险与异常可在单据详情查看。"
|
||||
"如果还有其他票据,可以继续在当前对话上传,我会归集到这张草稿。"
|
||||
)
|
||||
return "已按您当前确认的信息保存为草稿。后续上传附件或补充票据信息时,请关联这张草稿;补齐缺失项后再继续提交。"
|
||||
return (
|
||||
"已按您当前确认的信息保存为草稿。"
|
||||
"系统已完成草稿规则校验,风险与异常可在单据详情查看。"
|
||||
"如果还有其他票据,可以继续在当前对话上传,我会归集到这张草稿。"
|
||||
)
|
||||
if review_action == "link_to_existing_draft":
|
||||
document_count = self._resolve_review_document_count(payload)
|
||||
followup_copy = self._build_review_action_followup_copy(review_payload)
|
||||
@@ -221,7 +226,7 @@ class UserAgentReviewMessageMixin:
|
||||
"如果确有特殊情况,请先在附加说明中补充原因;补充后可以继续提交给审批人重点复核。"
|
||||
)
|
||||
return (
|
||||
"AI预审暂未通过,所以还没有提交到审批人。\n"
|
||||
"自动检测暂未通过,所以还没有提交到审批人。\n"
|
||||
f"{reason_lines}\n"
|
||||
"请先处理以上项目;处理完成后再点继续下一步。"
|
||||
)
|
||||
@@ -266,7 +271,7 @@ class UserAgentReviewMessageMixin:
|
||||
"如确有特殊情况,请在附加说明中补充原因后继续提交审批。"
|
||||
)
|
||||
return (
|
||||
f"AI预审未通过:{reason_text}。"
|
||||
f"自动检测未通过:{reason_text}。"
|
||||
"请先根据风险提示补充原因、调整金额或更换附件,整改后再继续提交。"
|
||||
)
|
||||
|
||||
@@ -478,7 +483,7 @@ class UserAgentReviewMessageMixin:
|
||||
if missing_slots:
|
||||
return f"当前仍有 {'、'.join(missing_slots)},暂时只能保存为草稿,补齐后再继续下一步。"
|
||||
if receipt_briefs:
|
||||
return "当前必需票据已具备;如还有市内交通、打车、地铁或停车等乘车票据,可以继续上传,也可以继续下一步或保存草稿。"
|
||||
return "当前仍有必需票据待补充,暂时只能保存为草稿;补齐后再继续下一步。"
|
||||
if review_payload.can_proceed:
|
||||
return "当前信息已较完整,您可以继续下一步,也可以先保存为草稿。"
|
||||
return ""
|
||||
@@ -511,17 +516,9 @@ class UserAgentReviewMessageMixin:
|
||||
for item in travel_receipt_state.get("required_missing_labels", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
optional_labels = [
|
||||
str(item).strip()
|
||||
for item in travel_receipt_state.get("optional_missing_labels", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
|
||||
provide_items: list[str] = []
|
||||
if required_labels:
|
||||
provide_items.append("1. 酒店住宿发票/住宿清单(必须,当前待上传)")
|
||||
if optional_labels:
|
||||
provide_items.append(f"{len(provide_items) + 1}. 市内交通/乘车票据(非必须,如打车、地铁、停车等)")
|
||||
|
||||
sections = [
|
||||
f"您好,{user_name}。我先按票据信息做一次差旅预检。",
|
||||
@@ -546,11 +543,6 @@ class UserAgentReviewMessageMixin:
|
||||
"处理建议:酒店票据仍缺失,暂时不能继续下一步。"
|
||||
"您可以先保存为草稿,补齐后再提交。"
|
||||
)
|
||||
elif can_proceed and optional_labels:
|
||||
sections.append(
|
||||
"处理建议:必需票据已具备。"
|
||||
"如暂时没有乘车票据,也可以继续下一步,或先保存为草稿。"
|
||||
)
|
||||
elif can_proceed:
|
||||
sections.append(
|
||||
"处理建议:当前信息已较完整,确认无误后可以继续下一步;"
|
||||
|
||||
@@ -232,6 +232,17 @@ class UserAgentReviewSlotMixin:
|
||||
evidence="来源于用户修改后的结构化表单。",
|
||||
)
|
||||
|
||||
application_time = str(review_form_values.get("application_business_time") or "").strip()
|
||||
if application_time:
|
||||
return self._build_slot_value(
|
||||
value=application_time,
|
||||
raw_value=application_time,
|
||||
normalized_value=application_time,
|
||||
source="detail_context",
|
||||
confidence=0.86,
|
||||
evidence="来源于已关联申请单,作为本次报销草稿的发生时间依据。",
|
||||
)
|
||||
|
||||
time_range = payload.ontology.time_range
|
||||
if time_range.start_date and time_range.end_date:
|
||||
normalized_value = (
|
||||
@@ -265,6 +276,16 @@ class UserAgentReviewSlotMixin:
|
||||
evidence="来源于用户修改后的结构化表单。",
|
||||
)
|
||||
|
||||
application_location = str(review_form_values.get("application_location") or "").strip()
|
||||
if application_location:
|
||||
return self._build_slot_value(
|
||||
value=application_location,
|
||||
normalized_value=application_location,
|
||||
source="detail_context",
|
||||
confidence=0.86,
|
||||
evidence="来源于已关联申请单,作为本次报销草稿的地点依据。",
|
||||
)
|
||||
|
||||
if str(payload.context_json.get("entry_source") or "").strip() == "detail":
|
||||
request_context = payload.context_json.get("request_context")
|
||||
if isinstance(request_context, dict):
|
||||
@@ -370,6 +391,17 @@ class UserAgentReviewSlotMixin:
|
||||
evidence="来源于用户修改后的结构化表单。",
|
||||
)
|
||||
|
||||
application_reason = str(review_form_values.get("application_reason") or "").strip()
|
||||
if application_reason:
|
||||
return self._build_slot_value(
|
||||
value=application_reason,
|
||||
raw_value=application_reason,
|
||||
normalized_value=application_reason,
|
||||
source="detail_context",
|
||||
confidence=0.9,
|
||||
evidence="来源于已关联申请单,作为本次报销草稿的事由依据。",
|
||||
)
|
||||
|
||||
inferred_reason = self._infer_reason_from_claim_groups(
|
||||
claim_groups=claim_groups,
|
||||
)
|
||||
@@ -420,6 +452,22 @@ class UserAgentReviewSlotMixin:
|
||||
evidence="来源于用户修改后的结构化表单。",
|
||||
)
|
||||
|
||||
application_amount = str(
|
||||
review_form_values.get("application_amount")
|
||||
or review_form_values.get("application_amount_label")
|
||||
or ""
|
||||
).strip()
|
||||
if application_amount:
|
||||
normalized = self._normalize_amount_text(application_amount)
|
||||
return self._build_slot_value(
|
||||
value=normalized,
|
||||
raw_value=application_amount,
|
||||
normalized_value=normalized,
|
||||
source="detail_context",
|
||||
confidence=0.86,
|
||||
evidence="来源于已关联申请单,作为本次报销草稿的金额依据。",
|
||||
)
|
||||
|
||||
amount_value = entity_map.get("amount", "")
|
||||
if amount_value:
|
||||
normalized = self._normalize_amount_text(amount_value)
|
||||
|
||||
@@ -99,9 +99,7 @@ class UserAgentReviewTravelReceiptMixin:
|
||||
}
|
||||
|
||||
has_hotel_invoice = any(self._is_review_hotel_card(card) for card in document_cards)
|
||||
has_local_transport = any(self._is_local_transport_receipt_card(card) for card in document_cards)
|
||||
required_missing_labels = [] if has_hotel_invoice else ["酒店的报销票据待上传(必须)"]
|
||||
optional_missing_labels = [] if has_local_transport else ["市内交通/乘车票据可继续上传(非必须)"]
|
||||
ticket_amount = sum(
|
||||
(self._extract_amount_decimal_from_card(card) or Decimal("0.00"))
|
||||
for card in long_distance_cards
|
||||
@@ -116,9 +114,9 @@ class UserAgentReviewTravelReceiptMixin:
|
||||
"destination": self._resolve_travel_receipt_destination(payload, long_distance_cards),
|
||||
"days": self._resolve_travel_receipt_days(payload, long_distance_cards),
|
||||
"has_hotel_invoice": has_hotel_invoice,
|
||||
"has_local_transport": has_local_transport,
|
||||
"has_local_transport": any(self._is_local_transport_receipt_card(card) for card in document_cards),
|
||||
"required_missing_labels": required_missing_labels,
|
||||
"optional_missing_labels": optional_missing_labels,
|
||||
"optional_missing_labels": [],
|
||||
"blocks_next_step": bool(required_missing_labels),
|
||||
}
|
||||
|
||||
@@ -273,32 +271,20 @@ class UserAgentReviewTravelReceiptMixin:
|
||||
for item in travel_receipt_state.get("required_missing_labels", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
optional_labels = [
|
||||
str(item).strip()
|
||||
for item in travel_receipt_state.get("optional_missing_labels", [])
|
||||
if str(item).strip()
|
||||
]
|
||||
if not required_labels and not optional_labels:
|
||||
if not required_labels:
|
||||
return []
|
||||
|
||||
content_parts = [*required_labels, *optional_labels]
|
||||
required_text = ";".join(required_labels)
|
||||
optional_text = ";".join(optional_labels)
|
||||
return [
|
||||
UserAgentReviewRiskBrief(
|
||||
title="差旅票据待补充",
|
||||
level="warning" if required_labels else "info",
|
||||
content=";".join(content_parts),
|
||||
level="warning",
|
||||
content=required_text,
|
||||
detail=(
|
||||
"系统已识别到长途交通票据,会按差旅报销口径核对住宿、交通等票据完整性。"
|
||||
+ (f"当前必须补充:{required_text}。" if required_text else "")
|
||||
+ (f"当前还可以补充:{optional_text}。" if optional_text else "")
|
||||
),
|
||||
suggestion=(
|
||||
"请先补充酒店住宿发票或住宿清单;在补齐前只能保存为草稿。"
|
||||
if required_labels
|
||||
else "如还有市内交通、打车、地铁或停车等乘车票据,可以继续上传;没有也可以进入下一步或保存草稿。"
|
||||
+ f"当前必须补充:{required_text}。"
|
||||
),
|
||||
suggestion="请先补充酒店住宿发票或住宿清单;在补齐前只能保存为草稿。",
|
||||
)
|
||||
]
|
||||
|
||||
@@ -606,6 +592,10 @@ class UserAgentReviewTravelReceiptMixin:
|
||||
message = str(payload.tool_payload.get("message") or "").strip()
|
||||
for prefix in (
|
||||
"提交前请先补全信息:",
|
||||
"自动检测暂未通过,原因如下:",
|
||||
"自动检测未通过,原因如下:",
|
||||
"自动检测暂未通过:",
|
||||
"自动检测未通过:",
|
||||
"AI预审暂未通过,原因如下:",
|
||||
"AI预审未通过,原因如下:",
|
||||
"AI预审暂未通过:",
|
||||
@@ -618,7 +608,9 @@ class UserAgentReviewTravelReceiptMixin:
|
||||
reasons.extend(
|
||||
item.strip()
|
||||
for item in re.split(r"[;;\n]+", message)
|
||||
if item.strip() and not item.strip().startswith("AI预审暂未通过")
|
||||
if item.strip()
|
||||
and not item.strip().startswith("AI预审暂未通过")
|
||||
and not item.strip().startswith("自动检测暂未通过")
|
||||
)
|
||||
|
||||
return list(dict.fromkeys(reason for reason in reasons if reason))
|
||||
|
||||
Reference in New Issue
Block a user