feat: 增强差旅报销审核流程与票据智能推理

优化本体解析和编排器的差旅场景处理能力,完善报销单草稿
保存和费用明细同步逻辑,前端报销创建页面增加行程推理和
票据审核交互,新增助手会话快照工具函数,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-21 16:09:47 +08:00
parent f28d7e6d16
commit e701fa01da
33 changed files with 3033 additions and 337 deletions

View File

@@ -22,6 +22,9 @@ STATEFUL_CONTEXT_KEYS = (
"business_time_context",
)
REVIEW_FLOW_CONTEXT_KEYS = {
"draft_claim_id",
"draft_claim_no",
"draft_status",
"request_context",
"attachment_names",
"attachment_count",
@@ -131,7 +134,11 @@ class AgentConversationService:
resolved_retention_days = retention_days or self._resolve_retention_days()
cutoff = datetime.now(UTC) - timedelta(days=max(1, resolved_retention_days))
stmt = select(AgentConversation).where(AgentConversation.updated_at < cutoff)
expired_conversations = list(self.db.scalars(stmt).all())
expired_conversations = [
conversation
for conversation in self.db.scalars(stmt).all()
if not self._is_saved_conversation(conversation)
]
if not expired_conversations:
return 0
@@ -141,6 +148,13 @@ class AgentConversationService:
self.db.commit()
return len(expired_conversations)
@staticmethod
def _is_saved_conversation(conversation: AgentConversation) -> bool:
if str(conversation.draft_claim_id or "").strip():
return True
state_json = dict(conversation.state_json or {})
return bool(str(state_json.get("draft_claim_id") or "").strip())
def _resolve_retention_days(self) -> int:
try:
settings_row, _ = SettingsService(self.db).ensure_settings_ready()
@@ -232,6 +246,9 @@ class AgentConversationService:
context_json=merged,
message=message,
)
if not should_hydrate_review_flow:
for key in REVIEW_FLOW_CONTEXT_KEYS:
merged.pop(key, None)
merged["conversation_id"] = conversation.conversation_id
merged["conversation_history"] = self.list_message_history(
@@ -264,7 +281,12 @@ class AgentConversationService:
context_json: dict[str, Any],
message: str | None,
) -> bool:
if isinstance(context_json.get("expense_scene_selection"), dict):
return True
if AgentConversationService._resolve_draft_claim_id(context_json):
compact_message = str(message or "").replace(" ", "")
if compact_message and any(keyword in compact_message for keyword in NEW_EXPENSE_PROMPT_KEYWORDS):
return False
return True
if str(context_json.get("review_action") or "").strip():
return True

View File

@@ -177,8 +177,8 @@ SUPPORTED_DOCUMENT_TYPES = tuple(DOCUMENT_TYPE_RULE_MAP.keys()) + ("other",)
AMOUNT_PATTERNS = (
re.compile(
r"(?:价税合计|合计金额|费用合计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额)"
r"[:\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)"
r"(?:价税合计|合计金额|费用合计|总费用|费用总计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额|房费|住宿费)"
r"[:\s¥¥人民币为是]*([0-9]+(?:[.,][0-9]{1,2})?)"
),
re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)"),
re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"),
@@ -721,6 +721,8 @@ def _extract_amount(text: str) -> str:
continue
if candidate <= Decimal("0.00"):
continue
if _is_amount_match_date_fragment(candidate, text, match.start(1), match.end(1)):
continue
if best_value is None or candidate > best_value:
best_value = candidate
@@ -731,6 +733,22 @@ def _extract_amount(text: str) -> str:
return f"{text_value}"
def _is_amount_match_date_fragment(amount: Decimal, text: str, start: int, end: int) -> bool:
if start < 0 or end < 0:
return False
normalized = amount.quantize(Decimal("0.01"))
if normalized != normalized.to_integral_value() or normalized < Decimal("1900") or normalized > Decimal("2099"):
return False
before = str(text or "")[max(0, start - 8):start]
after = str(text or "")[end:end + 10]
if re.match(r"\s*(?:年|[-/.])\s*\d{1,2}", after):
return True
if re.search(r"\d{1,2}\s*(?:年|[-/.])\s*$", before):
return True
return False
def _extract_date(text: str, *, document_type: str = "") -> str:
matches = list(DATE_PATTERN.finditer(text))
if not matches:

View File

@@ -15,7 +15,7 @@ from types import SimpleNamespace
from typing import Any
from urllib.parse import quote
from sqlalchemy import and_, func, or_, select
from sqlalchemy import and_, func, inspect as sqlalchemy_inspect, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, selectinload
@@ -78,6 +78,7 @@ TRAVEL_DETAIL_ITEM_TYPES = {
"ride_ticket",
"travel_allowance",
}
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES = {"train_ticket", "flight_ticket"}
DOCUMENT_TYPE_ITEM_TYPE_MAP = {
"train_ticket": "train_ticket",
"flight_itinerary": "flight_ticket",
@@ -97,8 +98,8 @@ DOCUMENT_TYPE_SCENE_MAP = {
"meeting_invoice": "meeting",
"training_invoice": "training",
}
DOCUMENT_FACT_ITEM_TYPES = {"train_ticket", "flight_ticket", "hotel_ticket", "ride_ticket"}
ROUTE_DESCRIPTION_ITEM_TYPES = {"train_ticket", "flight_ticket", "ride_ticket"}
DOCUMENT_FACT_ITEM_TYPES = {"train_ticket", "flight_ticket", "hotel_ticket", "ride_ticket", "ship_ticket", "ferry_ticket"}
ROUTE_DESCRIPTION_ITEM_TYPES = {"train_ticket", "flight_ticket", "ship_ticket", "ferry_ticket", "ride_ticket"}
DOCUMENT_TRIP_DATE_LABELS = {
"train_ticket": "列车出发时间",
"flight_itinerary": "起飞日期",
@@ -253,6 +254,11 @@ DOCUMENT_ASSOCIATION_REVIEW_ACTIONS = {
"link_to_existing_draft",
"create_new_claim_from_documents",
}
PERSISTENT_EXPENSE_REVIEW_ACTIONS = {
"save_draft",
"next_step",
*DOCUMENT_ASSOCIATION_REVIEW_ACTIONS,
}
RETURN_REASON_OPTIONS = {
"missing_attachment": "附件缺失或不清晰",
"invoice_mismatch": "票据类型/金额与明细不一致",
@@ -262,11 +268,11 @@ RETURN_REASON_OPTIONS = {
"approval_question": "审批人需要补充说明",
}
MAX_CLAIM_NO_RETRY_ATTEMPTS = 3
DOCUMENT_AMOUNT_PATTERNS = (
re.compile(
r"(?:价税合计|合计金额|费用合计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额)"
r"[:\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)"
),
DOCUMENT_AMOUNT_PATTERNS = (
re.compile(
r"(?:价税合计|合计金额|费用合计|总费用|费用总计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额|房费|住宿费)"
r"[:\s¥¥人民币为是]*([0-9]+(?:[.,][0-9]{1,2})?)"
),
re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)"),
re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"),
)
@@ -518,21 +524,21 @@ class ExpenseClaimService:
if payload.item_date is not None:
item.item_date = payload.item_date
if payload.item_type is not None:
item.item_type = self._normalize_optional_text(payload.item_type, fallback=item.item_type) or item.item_type
if payload.item_reason is not None:
item.item_reason = (
self._normalize_optional_text(payload.item_reason, fallback=item.item_reason) or item.item_reason
)
if payload.item_location is not None:
item.item_location = (
self._normalize_optional_text(payload.item_location, fallback=item.item_location) or item.item_location
)
if payload.item_amount is not None:
amount = payload.item_amount.quantize(Decimal("0.01"))
if amount <= Decimal("0.00"):
raise ValueError("费用金额必须大于 0。")
item.item_amount = amount
if payload.item_type is not None:
item.item_type = self._normalize_optional_text(payload.item_type, fallback=item.item_type) or item.item_type
if payload.item_reason is not None:
item.item_reason = (
self._normalize_optional_text(payload.item_reason, allow_empty=True) or ""
)
if payload.item_location is not None:
item.item_location = (
self._normalize_optional_text(payload.item_location, allow_empty=True) or ""
)
if payload.item_amount is not None:
amount = payload.item_amount.quantize(Decimal("0.01"))
if amount < Decimal("0.00"):
raise ValueError("费用金额不能小于 0。")
item.item_amount = amount
if payload.invoice_id is not None:
item.invoice_id = self._normalize_optional_text(payload.invoice_id, allow_empty=True)
@@ -794,6 +800,10 @@ class ExpenseClaimService:
"claim_id": claim.id,
"item_id": item.id,
"invoice_id": item.invoice_id,
"item_date": item.item_date.isoformat() if item.item_date else None,
"item_type": item.item_type,
"item_reason": item.item_reason,
"item_location": item.item_location,
"item_amount": item.item_amount,
"claim_amount": claim.amount,
"attachment": self._build_attachment_payload(item),
@@ -929,26 +939,29 @@ class ExpenseClaimService:
return claim
def save_or_submit_from_ontology(
self,
*,
run_id: str,
user_id: str | None,
def save_or_submit_from_ontology(
self,
*,
run_id: str,
user_id: str | None,
message: str,
ontology: OntologyParseResult,
context_json: dict[str, Any],
) -> dict[str, Any]:
result = self.upsert_draft_from_ontology(
run_id=run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json=context_json,
)
review_action = str(context_json.get("review_action") or "").strip()
if review_action != "next_step":
return result
ontology: OntologyParseResult,
context_json: dict[str, Any],
) -> dict[str, Any]:
review_action = str(context_json.get("review_action") or "").strip()
if review_action not in PERSISTENT_EXPENSE_REVIEW_ACTIONS:
return self._build_expense_review_preview_result(context_json)
result = self.upsert_draft_from_ontology(
run_id=run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json=context_json,
)
if review_action != "next_step":
return result
claim_id = str(result.get("claim_id") or "").strip()
if not claim_id or result.get("draft_limit_reached"):
@@ -1029,9 +1042,22 @@ class ExpenseClaimService:
"status": claim.status,
"approval_stage": claim.approval_stage,
"amount": float(claim.amount),
"invoice_count": int(claim.invoice_count or 0),
}
"invoice_count": int(claim.invoice_count or 0),
}
def _build_expense_review_preview_result(self, context_json: dict[str, Any]) -> dict[str, Any]:
attachment_count = self._resolve_attachment_count(context_json)
return {
"message": (
"我已根据当前信息整理出待核对的报销内容,但尚未保存为草稿。"
"请在右侧核对信息,只有点击“保存为草稿”或“继续下一步”后才会正式写入单据。"
),
"draft_only": True,
"preview_only": True,
"status": "preview",
"invoice_count": attachment_count,
}
def delete_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
@@ -1832,7 +1858,7 @@ class ExpenseClaimService:
for document in context_documents:
document_type = str(document.get("document_type") or "").strip()
scene_code = str(document.get("scene_code") or "").strip()
if document_type in {"train_ticket", "flight_itinerary", "hotel_invoice"} or scene_code == "travel":
if document_type in {"train_ticket", "flight_itinerary"} or scene_code == "travel":
return True
return False
@@ -2241,33 +2267,57 @@ class ExpenseClaimService:
return ""
def _resolve_document_item_amount(self, document: dict[str, Any]) -> Decimal | None:
text = " ".join(
[
str(document.get("summary") or "").strip(),
str(document.get("text") or "").strip(),
]
).strip()
field_amount = self._resolve_document_field_amount(document)
text_amount = self._resolve_document_text_amount(text)
if field_amount is not None:
if self._is_date_like_amount_candidate(field_amount, text):
return text_amount
return field_amount
return text_amount
def _resolve_document_field_amount(self, document: dict[str, Any]) -> Decimal | None:
for field in list(document.get("document_fields") or []):
if not isinstance(field, dict):
continue
key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "")
value = self._parse_document_amount_value(str(field.get("value") or ""))
if value is None:
continue
if key in {
"amount",
"totalamount",
"paymentamount",
"paidamount",
"actualamount",
} or any(
token in label
for token in ("金额", "价税合计", "合计", "总额", "总计", "票价", "支付金额", "实付金额", "实收金额")
):
return value
text = " ".join(
[
str(document.get("summary") or "").strip(),
str(document.get("text") or "").strip(),
]
).strip()
return self._parse_document_amount_value(text)
continue
key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "")
is_amount_field = key in {
"amount",
"totalamount",
"paymentamount",
"paidamount",
"actualamount",
} or any(
token in label
for token in ("金额", "价税合计", "合计", "总额", "总计", "票价", "支付金额", "实付金额", "实收金额")
)
if not is_amount_field:
continue
raw_value = str(field.get("value") or "")
value = self._parse_document_amount_value(raw_value) or self._parse_plain_document_amount_value(raw_value)
if value is not None:
return value
return None
def _resolve_document_text_amount(self, text: str) -> Decimal | None:
candidates = [
candidate
for candidate in self._extract_amount_candidates(text)
if not self._is_date_like_amount_candidate(candidate, text)
]
if not candidates:
return None
return max(candidates)
def _parse_document_amount_value(self, value: str) -> Decimal | None:
raw_value = str(value or "").strip()
@@ -2282,9 +2332,45 @@ class ExpenseClaimService:
amount = Decimal(numeric).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
continue
if amount > Decimal("0.00"):
return amount
return None
if amount > Decimal("0.00"):
return amount
return None
@staticmethod
def _parse_plain_document_amount_value(value: str) -> Decimal | None:
raw_value = str(value or "").strip()
if not re.fullmatch(r"[0-9]{1,6}(?:[.,][0-9]{1,2})?", raw_value):
return None
try:
amount = Decimal(raw_value.replace(",", ".")).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
return amount if amount > Decimal("0.00") else None
@staticmethod
def _is_probable_year_amount(amount: Decimal | None) -> bool:
if amount is None:
return False
try:
normalized = Decimal(amount).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return False
return normalized == normalized.to_integral_value() and Decimal("1900") <= normalized <= Decimal("2099")
@classmethod
def _is_date_like_amount_candidate(cls, amount: Decimal | None, text: str) -> bool:
if not cls._is_probable_year_amount(amount):
return False
year = str(int(Decimal(amount or 0)))
pattern = re.compile(rf"(?<!\d){re.escape(year)}\s*(?:年|[-/.])\s*\d{{1,2}}")
return bool(pattern.search(str(text or "")))
@staticmethod
def _format_decimal_amount(amount: Decimal | None) -> str:
if amount is None:
return ""
normalized = Decimal(amount).quantize(Decimal("0.01"))
return format(normalized, "f")
def _resolve_document_item_date(self, document: dict[str, Any], *, fallback: date) -> date:
return self._resolve_document_item_date_candidate(document) or fallback
@@ -3318,6 +3404,54 @@ class ExpenseClaimService:
if amount is not None and amount > Decimal("0.00"):
item.item_amount = amount
def _build_attachment_expense_audit_points(
self,
*,
document: Any,
item: ExpenseClaimItem,
document_info: dict[str, Any],
) -> list[str]:
text = " ".join(
[
str(getattr(document, "summary", "") or "").strip(),
str(getattr(document, "text", "") or "").strip(),
]
).strip()
document_payload = {
"document_fields": document_info.get("fields") or [],
"summary": str(getattr(document, "summary", "") or ""),
"text": str(getattr(document, "text", "") or ""),
}
field_amount = self._resolve_document_field_amount(document_payload)
audited_amount = self._resolve_document_item_amount(document_payload)
item_amount = Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
points: list[str] = []
if (
field_amount is not None
and audited_amount is not None
and self._is_date_like_amount_candidate(field_amount, text)
and abs(field_amount - audited_amount) > Decimal("1.00")
):
points.append(
"费用核算OCR 金额疑似误取日期"
f" {self._format_decimal_amount(field_amount)}"
f"已按票据文本中的总费用 {self._format_decimal_amount(audited_amount)} 元回填,"
"请核对酒店或票据原文总额。"
)
if (
audited_amount is not None
and item_amount > Decimal("0.00")
and abs(audited_amount - item_amount) > Decimal("1.00")
):
points.append(
f"费用核算:票据文本复核金额为 {self._format_decimal_amount(audited_amount)} 元,"
f"当前明细金额为 {self._format_decimal_amount(item_amount)} 元,请确认是否需要调整。"
)
return points
def _backfill_item_date_from_attachment(
self,
*,
@@ -3428,33 +3562,53 @@ class ExpenseClaimService:
values: list[Decimal] = []
seen: set[Decimal] = set()
def append_candidate(raw: str) -> None:
compact = str(raw or "").replace(",", ".").strip()
if not compact:
return
try:
candidate = Decimal(compact).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return
if candidate in seen:
return
seen.add(candidate)
values.append(candidate)
for pattern in (
r"(?:金额|价税合计|合计|小写|实收金额|支付金额|订单金额|总额|票价|房费|餐费)[:\s¥¥]*([0-9]{1,6}(?:[.,][0-9]{1,2})?)",
r"[¥¥]\s*([0-9]{1,6}(?:[.,][0-9]{1,2})?)",
r"([0-9]{1,6}(?:[.,][0-9]{1,2})?)\s*元",
):
for raw in re.findall(pattern, text, flags=re.IGNORECASE):
append_candidate(raw)
if values:
return values
for raw in re.findall(r"(?<!\d)(\d{1,6}\.\d{1,2})(?!\d)", text):
append_candidate(raw)
return values
def append_candidate(raw: str, *, source_text: str = "", start: int = -1, end: int = -1) -> None:
compact = str(raw or "").replace(",", ".").strip()
if not compact:
return
try:
candidate = Decimal(compact).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return
if ExpenseClaimService._is_amount_match_date_fragment(candidate, source_text, start, end):
return
if candidate in seen:
return
seen.add(candidate)
values.append(candidate)
for pattern in (
r"(?:金额|价税合计|合计|小写|实收金额|支付金额|订单金额|总额|总计|总费用|费用总计|票价|房费|住宿费|餐费)[:\s¥¥人民币为是]*([0-9]{1,6}(?:[.,][0-9]{1,2})?)",
r"[¥¥]\s*([0-9]{1,6}(?:[.,][0-9]{1,2})?)",
r"([0-9]{1,6}(?:[.,][0-9]{1,2})?)\s*元",
):
for match in re.finditer(pattern, text, flags=re.IGNORECASE):
append_candidate(match.group(1), source_text=text, start=match.start(1), end=match.end(1))
if values:
return values
for match in re.finditer(r"(?<!\d)(\d{1,6}\.\d{1,2})(?!\d)", text):
append_candidate(match.group(1), source_text=text, start=match.start(1), end=match.end(1))
return values
@staticmethod
def _is_amount_match_date_fragment(
amount: Decimal,
text: str,
start: int,
end: int,
) -> bool:
if start < 0 or end < 0 or not ExpenseClaimService._is_probable_year_amount(amount):
return False
before = str(text or "")[max(0, start - 8):start]
after = str(text or "")[end:end + 10]
if re.match(r"\s*(?:年|[-/.])\s*\d{1,2}", after):
return True
if re.search(r"\d{1,2}\s*(?:年|[-/.])\s*$", before):
return True
return False
@staticmethod
def _has_date_like_text(text: str) -> bool:
@@ -3559,7 +3713,7 @@ class ExpenseClaimService:
example = "广州南-北京南" if item_type != "ride_ticket" else "深圳北站-腾讯滨海大厦"
current = f"当前为“{reason[:30]}”," if reason else ""
return (
f"行程说明:{current}格式应为“始地-目的地”,"
f"行程说明:{current}格式应为“始地-目的地”,"
f"例如“{example}”,请按票据行程补充。"
)
@@ -3633,6 +3787,11 @@ class ExpenseClaimService:
item=item,
document_info=document_info,
)
expense_audit_points = self._build_attachment_expense_audit_points(
document=document,
item=item,
document_info=document_info,
)
recognized_document_type = str(document_info.get("document_type") or "other").strip().lower() or "other"
recognized_document_label = str(document_info.get("document_type_label") or "其他单据").strip() or "其他单据"
requirement_matches = bool(requirement_check.get("matches"))
@@ -3678,8 +3837,9 @@ class ExpenseClaimService:
"开票日期或业务发生日期",
)
points.append(f"日期字段:未识别到{date_requirement}")
if not requirement_matches:
points.append(f"附件类型要求:{requirement_check.get('message')}")
if not requirement_matches:
points.append(f"附件类型要求:{requirement_check.get('message')}")
points.extend(expense_audit_points)
if purpose_mismatch_point:
points.append(purpose_mismatch_point)
if route_format_point:
@@ -3721,6 +3881,7 @@ class ExpenseClaimService:
elif (
purpose_mismatch_point
or route_format_point
or expense_audit_points
or amount_mismatch
or issue_count >= 2
or warnings
@@ -3732,7 +3893,9 @@ class ExpenseClaimService:
headline = "AI提示附件存在明显待整改项"
summary = "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。"
if route_format_point and issue_count == 1:
summary = "票据行程已识别,但费用明细说明未按“始地-目的地”格式填写。"
summary = "票据行程已识别,但费用明细说明未按“始地-目的地”格式填写。"
elif expense_audit_points and issue_count == len(expense_audit_points):
summary = "OCR 金额已完成二次核算,请按票据原文总额复核。"
suggestion = {
"high": "建议过滤当前不匹配的票据,重新上传符合当前费用场景的清晰原件。",
@@ -5337,10 +5500,165 @@ class ExpenseClaimService:
return True
return scene_code == "travel"
def _sync_claim_from_items(self, claim: ExpenseClaim) -> None:
if not claim.items:
claim.amount = Decimal("0.00")
claim.invoice_count = 0
def _sync_travel_allowance_item(self, claim: ExpenseClaim) -> None:
items = list(claim.items or [])
allowance_items = [
item for item in items if str(item.item_type or "").strip().lower() == "travel_allowance"
]
business_items = [
item for item in items if str(item.item_type or "").strip().lower() != "travel_allowance"
]
business_types = {str(item.item_type or "").strip().lower() for item in business_items}
is_travel_claim = str(claim.expense_type or "").strip().lower() == "travel"
has_travel_detail = bool(business_types & TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES)
if not is_travel_claim and not has_travel_detail:
for item in allowance_items:
self._discard_claim_item(claim, item)
return
grade = str(claim.employee_grade or "").strip()
if not grade:
return
allowance_location = self._resolve_travel_allowance_location_from_claim(
claim=claim,
business_items=business_items,
)
if not allowance_location:
return
existing_allowance = allowance_items[0] if allowance_items else None
days, start_date, end_date = self._resolve_travel_allowance_days_from_claim(
claim=claim,
business_items=business_items,
existing_allowance=existing_allowance,
)
if days < 1:
return
try:
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
result = TravelReimbursementCalculatorService(self.db).calculate(
TravelReimbursementCalculatorRequest(
days=days,
location=allowance_location,
grade=grade,
),
CurrentUserContext(
username=str(claim.employee_id or claim.employee_name or "system"),
name=str(claim.employee_name or ""),
role_codes=[],
is_admin=False,
),
)
except ValueError:
return
allowance_amount = Decimal(result.allowance_amount or Decimal("0.00")).quantize(Decimal("0.01"))
allowance_rate = Decimal(result.total_allowance_rate or Decimal("0.00")).quantize(Decimal("0.01"))
if allowance_amount <= Decimal("0.00") or allowance_rate <= Decimal("0.00"):
return
item = existing_allowance
if item is None:
item = ExpenseClaimItem(claim_id=claim.id)
claim.items.append(item)
self.db.add(item)
for duplicate in allowance_items[1:]:
self._discard_claim_item(claim, duplicate)
item.item_date = end_date
item.item_type = "travel_allowance"
item.item_reason = (
f"系统自动计算出差补贴:{result.matched_city}{days}天,"
f"{allowance_rate:.2f}元/天"
)
item.item_location = str(result.allowance_region or allowance_location).strip()
item.item_amount = allowance_amount
item.invoice_id = None
def _discard_claim_item(self, claim: ExpenseClaim, item: ExpenseClaimItem) -> None:
if item in claim.items:
claim.items.remove(item)
state = sqlalchemy_inspect(item)
if state.persistent:
self.db.delete(item)
elif state.pending:
self.db.expunge(item)
@staticmethod
def _resolve_travel_allowance_days_from_claim(
*,
claim: ExpenseClaim,
business_items: list[ExpenseClaimItem],
existing_allowance: ExpenseClaimItem | None,
) -> tuple[int, date, date]:
dated_items = sorted(
[item.item_date for item in business_items if item.item_date is not None]
)
if dated_items:
start_date = dated_items[0]
end_date = dated_items[-1]
elif claim.occurred_at is not None:
start_date = claim.occurred_at.date()
end_date = start_date
else:
start_date = date.today()
end_date = start_date
days = (end_date - start_date).days + 1
existing_days = ExpenseClaimService._extract_travel_allowance_days(existing_allowance)
unique_dates = {value for value in dated_items}
if existing_days > days and len(unique_dates) <= 1:
days = existing_days
end_date = start_date + timedelta(days=days - 1)
return max(1, days), start_date, end_date
@staticmethod
def _extract_travel_allowance_days(item: ExpenseClaimItem | None) -> int:
if item is None:
return 0
match = re.search(r"(\d+)\s*天", str(item.item_reason or ""))
if not match:
return 0
try:
return max(0, int(match.group(1)))
except ValueError:
return 0
@staticmethod
def _resolve_travel_allowance_location_from_claim(
*,
claim: ExpenseClaim,
business_items: list[ExpenseClaimItem],
) -> str:
claim_location = str(claim.location or "").strip()
if claim_location and claim_location not in {"待补充", "未知", "暂无", "非必填"}:
return claim_location
sorted_items = sorted(
business_items,
key=lambda item: (item.item_date or date.max, ExpenseClaimService._normalize_sort_datetime(item.created_at)),
)
for item in sorted_items:
location = str(item.item_location or "").strip()
if location and location not in {"待补充", "未知", "暂无", "非必填"}:
return location
reason = str(item.item_reason or "").strip()
for separator in ("-", "", "", "", "->"):
if separator in reason:
destination = reason.split(separator)[-1].strip()
if destination:
return destination
return ""
def _sync_claim_from_items(self, claim: ExpenseClaim) -> None:
self._sync_travel_allowance_item(claim)
if not claim.items:
claim.amount = Decimal("0.00")
claim.invoice_count = 0
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, [])
return
@@ -5391,7 +5709,7 @@ class ExpenseClaimService:
) -> str:
fallback_type = str(fallback or "").strip() or "other"
item_types = {str(item.item_type or "").strip().lower() for item in items}
if item_types & TRAVEL_DETAIL_ITEM_TYPES:
if item_types & (TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES | {"travel_allowance"}):
return "travel"
return fallback_type
@@ -5572,21 +5890,22 @@ class ExpenseClaimService:
if not claim.items:
issues.append("费用明细不能为空")
for index, item in enumerate(claim.items, start=1):
prefix = f"费用明细第 {index}"
item_location_required = self._is_location_required_expense_type(item.item_type or claim.expense_type)
if item.item_date is None:
issues.append(f"{prefix}缺少日期")
for index, item in enumerate(claim.items, start=1):
prefix = f"费用明细第 {index}"
is_system_generated = str(item.item_type or "").strip().lower() in SYSTEM_GENERATED_ITEM_TYPES
item_location_required = self._is_location_required_expense_type(item.item_type or claim.expense_type)
if item.item_date is None:
issues.append(f"{prefix}缺少日期")
if self._is_missing_value(item.item_type):
issues.append(f"{prefix}缺少费用项目")
if self._is_missing_value(item.item_reason):
issues.append(f"{prefix}缺少说明")
if item_location_required and self._is_missing_value(item.item_location):
issues.append(f"{prefix}缺少地点")
if item.item_amount is None or item.item_amount <= Decimal("0.00"):
issues.append(f"{prefix}缺少金额")
if self._is_missing_value(item.invoice_id):
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):
issues.append(f"{prefix}缺少票据标识")
return issues

View File

@@ -62,11 +62,13 @@ AMOUNT_PATTERN = re.compile(
TOP_N_PATTERN = re.compile(r"(?:top|TOP|前|最高的?|最低的?)\s*(?P<top>\d+)")
SCENARIO_KEYWORDS = {
"expense": (
("报销", 0.20),
("", 0.20),
("差旅", 0.20),
("费用", 0.14),
"expense": (
("报销", 0.20),
("销单", 0.20),
("单据报销", 0.18),
("报账", 0.20),
("差旅", 0.20),
("费用", 0.14),
("发票", 0.14),
("票据", 0.12),
("借款", 0.12),
@@ -249,16 +251,51 @@ MISSING_SLOT_LABELS = {
"document_id": "单据号",
}
STATUS_KEYWORDS = {
"逾期": "overdue",
"审批": "pending",
"": "pending",
"已审批": "approved",
"通过": "approved",
"已付款": "paid",
"未付款": "unpaid",
"未回款": "unreceived",
}
STATUS_KEYWORDS = {
"草稿": "draft",
"提交": "draft",
"补充": "supplement",
"退回": "returned",
"退回": "returned",
"进行中": "review",
"审批中": "review",
"审核中": "review",
"流转中": "review",
"已提交": "submitted",
"逾期": "overdue",
"待审批": "pending",
"待审": "pending",
"已审批": "approved",
"已通过": "approved",
"已审核": "approved",
"已入账": "paid",
"已付款": "paid",
"未付款": "unpaid",
"未回款": "unreceived",
}
LOCATION_KEYWORDS = (
"北京",
"上海",
"广州",
"深圳",
"杭州",
"南京",
"苏州",
"成都",
"重庆",
"天津",
"武汉",
"西安",
"郑州",
"长沙",
"青岛",
"厦门",
"宁波",
"合肥",
"济南",
"福州",
)
PRIVILEGED_ROLE_CODES = {"manager", "finance", "approver", "executive"}
CONTEXTUAL_SCENARIOS = {"expense", "accounts_receivable", "accounts_payable", "knowledge"}
@@ -683,9 +720,13 @@ class SemanticOntologyService:
scores[scenario] += weight
best_scenario = max(scores, key=scores.get)
best_score = scores[best_scenario]
if best_score <= 0:
return "unknown", 0.0
best_score = scores[best_scenario]
if best_score <= 0:
if "单据" in compact_query and any(
keyword in compact_query for keyword in STATUS_KEYWORDS
):
return "expense", 0.14
return "unknown", 0.0
if best_scenario == "knowledge":
business_scores = [
@@ -701,18 +742,52 @@ class SemanticOntologyService:
return best_scenario, round(min(best_score, 0.34), 2)
def _detect_intent(
self,
compact_query: str,
def _detect_intent(
self,
compact_query: str,
*,
scenario: str,
entities: list[OntologyEntity],
time_range: OntologyTimeRange,
) -> tuple[str, float]:
if any(keyword in compact_query for keyword in OPERATE_KEYWORDS):
return "operate", 0.30
if any(keyword in compact_query for keyword in DRAFT_KEYWORDS):
return "draft", 0.26
) -> tuple[str, float]:
if any(keyword in compact_query for keyword in OPERATE_KEYWORDS):
return "operate", 0.30
status_document_query = (
"单据" in compact_query
and any(keyword in compact_query for keyword in STATUS_KEYWORDS)
and not any(keyword in compact_query for keyword in DRAFT_KEYWORDS if keyword != "草稿")
)
historical_document_query = any(
keyword in compact_query
for keyword in ("报销的单据", "报销单据", "报销过的单据", "报销记录")
)
if scenario == "expense" and any(
keyword in compact_query
for keyword in (
"报销了吗",
"报销了么",
"报销了没",
"报销了没有",
"报销没",
"单据状态",
"审批状态",
"报销进度",
"到哪了",
"到了哪",
"有没有报销",
"是否报销",
"进行中的单据",
"草稿单据",
"草稿的单据",
"待补充单据",
"审批中的单据",
"已提交单据",
"已入账单据",
)
) or (scenario == "expense" and (status_document_query or historical_document_query)):
return "query", 0.24
if any(keyword in compact_query for keyword in DRAFT_KEYWORDS):
return "draft", 0.26
if scenario == "expense" and self._is_generic_expense_prompt(compact_query):
return "draft", 0.24
if any(keyword in compact_query for keyword in COMPARE_KEYWORDS):
@@ -1177,13 +1252,16 @@ class SemanticOntologyService:
upsert(self._make_entity("receivable", code, code.upper()))
for code in re.findall(r"AP-\d{6}-\d{3}", query, flags=re.IGNORECASE):
upsert(self._make_entity("payable", code, code.upper()))
for code in re.findall(r"INV-[A-Z]+-\d+", query, flags=re.IGNORECASE):
upsert(self._make_entity("invoice", code, code.upper()))
for code in re.findall(r"CTR-[A-Z]+-\d+", query, flags=re.IGNORECASE):
upsert(self._make_entity("contract", code, code.upper()))
for label, normalized in EXPENSE_TYPE_KEYWORDS.items():
if label in query:
for code in re.findall(r"INV-[A-Z]+-\d+", query, flags=re.IGNORECASE):
upsert(self._make_entity("invoice", code, code.upper()))
for code in re.findall(r"CTR-[A-Z]+-\d+", query, flags=re.IGNORECASE):
upsert(self._make_entity("contract", code, code.upper()))
for location in LOCATION_KEYWORDS:
if location in query:
upsert(self._make_entity("location", location, location, role="filter", confidence=0.86))
for label, normalized in EXPENSE_TYPE_KEYWORDS.items():
if label in query:
upsert(self._make_entity("expense_type", label, normalized, role="filter"))
has_customer_entertainment_signal = "客户" in query and any(
@@ -1339,11 +1417,17 @@ class SemanticOntologyService:
start = date(today.year, start_month, 1)
end = date(today.year, end_month, calendar.monthrange(today.year, end_month)[1])
return self._range(start, end, "本季度", "quarter"), 0.10
if "今年" in query:
return (
self._range(date(today.year, 1, 1), date(today.year, 12, 31), "今年", "year"),
0.10,
)
if "今年" in query:
return (
self._range(date(today.year, 1, 1), date(today.year, 12, 31), "今年", "year"),
0.10,
)
if "去年" in query or "上一年" in query:
year = today.year - 1
return (
self._range(date(year, 1, 1), date(year, 12, 31), "去年", "year"),
0.10,
)
match = DATE_RANGE_PATTERN.search(query)
if match:
@@ -1491,10 +1575,11 @@ class SemanticOntologyService:
"employee",
"department",
"customer",
"vendor",
"project",
"expense_type",
}:
"vendor",
"project",
"location",
"expense_type",
}:
upsert(
OntologyConstraint(
field=entity.type,

View File

@@ -670,19 +670,32 @@ class OrchestratorService:
}
if ontology.scenario == "expense" or self._is_expense_review_action(context_json):
tool_type = AgentToolType.DATABASE.value
tool_name = "database.expense_claims.save_or_submit"
executor = lambda: self.expense_claim_service.save_or_submit_from_ontology(
run_id=run_id,
user_id=payload.user_id,
message=payload.message or "",
ontology=ontology,
context_json=context_json,
)
fallback_factory = lambda exc: {
"message": f"报销草稿落库失败,请稍后再试:{exc}",
"degraded": True,
}
is_persistence_action = self._is_expense_persistence_action(context_json)
tool_type = (
AgentToolType.DATABASE.value
if is_persistence_action
else AgentToolType.LLM.value
)
tool_name = (
"database.expense_claims.save_or_submit"
if is_persistence_action
else "user_agent.expense_review_preview"
)
executor = lambda: self.expense_claim_service.save_or_submit_from_ontology(
run_id=run_id,
user_id=payload.user_id,
message=payload.message or "",
ontology=ontology,
context_json=context_json,
)
fallback_factory = lambda exc: {
"message": (
f"报销草稿落库失败,请稍后再试:{exc}"
if is_persistence_action
else f"报销内容预览生成失败,请稍后再试:{exc}"
),
"degraded": True,
}
tool_payload, degraded = self._invoke_tool(
run_id=run_id,
@@ -819,6 +832,16 @@ class OrchestratorService:
"link_to_existing_draft",
"create_new_claim_from_documents",
}
@staticmethod
def _is_expense_persistence_action(context_json: dict[str, Any]) -> bool:
review_action = str((context_json or {}).get("review_action") or "").strip()
return review_action in {
"save_draft",
"next_step",
"link_to_existing_draft",
"create_new_claim_from_documents",
}
@staticmethod
def _flatten_capability_codes(
@@ -1165,16 +1188,18 @@ class OrchestratorService:
if item.type == "expense_claim" and str(item.normalized_value or item.value or "").strip()
)
)
expense_types = list(
dict.fromkeys(
str(item.normalized_value or item.value or "").strip()
for item in ontology.entities
if item.type == "expense_type" and str(item.normalized_value or item.value or "").strip()
)
)
status_values = list(
dict.fromkeys(
str(item.value).strip()
expense_types = list(
dict.fromkeys(
str(item.normalized_value or item.value or "").strip()
for item in ontology.entities
if item.type == "expense_type" and str(item.normalized_value or item.value or "").strip()
)
)
project_values = self._collect_expense_query_filter_values(ontology, "project")
location_values = self._collect_expense_query_filter_values(ontology, "location")
status_values = list(
dict.fromkeys(
str(item.value).strip()
for item in ontology.constraints
if item.field == "status" and item.operator == "=" and str(item.value).strip()
)
@@ -1189,10 +1214,24 @@ class OrchestratorService:
if expense_claim_nos:
conditions.append(ExpenseClaim.claim_no.in_(expense_claim_nos))
if expense_types:
conditions.append(ExpenseClaim.expense_type.in_(expense_types))
if status_values:
conditions.append(ExpenseClaim.status.in_(status_values))
if expense_types:
conditions.append(ExpenseClaim.expense_type.in_(expense_types))
if status_values:
conditions.append(ExpenseClaim.status.in_(status_values))
if project_values:
project_conditions = []
for value in project_values:
pattern = f"%{value}%"
project_conditions.append(ExpenseClaim.project_code.ilike(pattern))
project_conditions.append(ExpenseClaim.reason.ilike(pattern))
conditions.append(or_(*project_conditions))
if location_values:
location_conditions = []
for value in location_values:
pattern = f"%{value}%"
location_conditions.append(ExpenseClaim.location.ilike(pattern))
location_conditions.append(ExpenseClaim.reason.ilike(pattern))
conditions.append(or_(*location_conditions))
for item in amount_constraints:
amount_value = float(item.value)
@@ -1251,11 +1290,31 @@ class OrchestratorService:
scoped_to_current_user = True
else:
scope_label = "全部报销单"
return conditions, scope_label, scoped_to_current_user
def _build_current_user_claim_conditions(
self,
return conditions, scope_label, scoped_to_current_user
@staticmethod
def _collect_expense_query_filter_values(
ontology: OntologyParseResult,
field_name: str,
) -> list[str]:
values: list[str] = []
for entity in ontology.entities:
if entity.type != field_name:
continue
value = str(entity.normalized_value or entity.value or "").strip()
if value:
values.append(value)
for constraint in ontology.constraints:
if constraint.field != field_name or constraint.operator != "=":
continue
value = str(constraint.value or "").strip()
if value:
values.append(value)
return list(dict.fromkeys(values))
def _build_current_user_claim_conditions(
self,
*,
user_id: str | None,
context_json: dict[str, Any],

View File

@@ -97,6 +97,15 @@ GROUP_SCENE_LABELS = {
"other": "其他费用",
}
EXPENSE_SCENE_SELECTION_OPTIONS = (
("travel", "差旅费", "出差、长途交通、住宿、差旅补贴等场景。"),
("transport", "交通费", "市内打车、停车、过路费等日常交通场景。"),
("hotel", "住宿费", "单独住宿、酒店发票等场景。"),
("entertainment", "业务招待费", "客户接待、宴请、招待等场景。"),
("office", "办公费", "办公用品、耗材、办公设备等采购场景。"),
("other", "其他费用", "暂不属于以上分类的报销场景。"),
)
KNOWLEDGE_MODEL_MAIN_TIMEOUT_SECONDS = 3
KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS = 5
KNOWLEDGE_MODEL_TIMEOUT_SECONDS = KNOWLEDGE_MODEL_BACKUP_TIMEOUT_SECONDS
@@ -275,6 +284,17 @@ class UserAgentService:
AgentFoundationService(self.db).ensure_foundation_ready()
citations = self._build_citations(payload)
suggested_actions = self._build_suggested_actions(payload)
if self._should_prompt_expense_scene_selection(payload):
return UserAgentResponse(
answer=self._build_expense_scene_selection_answer(payload),
citations=citations,
suggested_actions=suggested_actions,
query_payload=None,
draft_payload=None,
review_payload=None,
risk_flags=[],
requires_confirmation=False,
)
risk_flags = self._resolve_risk_flags(payload)
query_payload = self._build_query_payload(payload)
draft_payload = (
@@ -1801,6 +1821,11 @@ class UserAgentService:
@staticmethod
def _should_build_draft_payload(payload: UserAgentRequest) -> bool:
if payload.ontology.scenario == "expense" and payload.tool_payload.get("preview_only"):
return any(
str(payload.tool_payload.get(key) or "").strip()
for key in ("claim_id", "claim_no")
)
if payload.ontology.intent == "draft":
return True
if payload.ontology.scenario != "expense":
@@ -1817,6 +1842,21 @@ class UserAgentService:
if payload.ontology.scenario == "knowledge":
return []
if self._should_prompt_expense_scene_selection(payload):
return [
UserAgentSuggestedAction(
label=label,
action_type="select_expense_type",
description=description,
payload={
"expense_type": code,
"expense_type_label": label,
"original_message": payload.message,
},
)
for code, label, description in EXPENSE_SCENE_SELECTION_OPTIONS
]
if self._is_generic_expense_prompt(payload):
return [
UserAgentSuggestedAction(
@@ -1886,6 +1926,35 @@ class UserAgentService:
),
]
def _should_prompt_expense_scene_selection(self, payload: UserAgentRequest) -> bool:
if payload.ontology.scenario != "expense":
return False
if payload.ontology.intent not in {"draft", "operate"}:
return False
if str(payload.context_json.get("review_action") or "").strip():
return False
review_form_values = self._resolve_review_form_values(payload)
if str(review_form_values.get("expense_type") or review_form_values.get("reimbursement_type") or "").strip():
return False
if self._resolve_attachment_count(payload) > 0 or self._resolve_ocr_documents(payload):
return False
return not any(
item.type == "expense_type" and str(item.normalized_value or item.value or "").strip()
for item in payload.ontology.entities
)
@staticmethod
def _build_expense_scene_selection_answer(payload: UserAgentRequest) -> str:
has_time = bool(payload.ontology.time_range.start_date or payload.ontology.time_range.raw)
context_hint = "我先识别到这是一次报销申请"
if has_time:
context_hint += ",并看到了业务发生时间"
return (
f"{context_hint}。但你还没有明确这笔单据属于哪类报销。"
"请先在下面选择报销场景,我会按你选择的场景再继续识别时间、地点、事由、金额和所需票据,"
"避免系统先入为主把项目支持、部署等描述误判成差旅。"
)
def _build_review_payload(
self,
payload: UserAgentRequest,
@@ -3363,6 +3432,17 @@ class UserAgentService:
)
review_action = str(payload.context_json.get("review_action") or "").strip()
if payload.tool_payload.get("preview_only") and not review_action:
base_message = review_payload.body_message or self._build_review_intent_summary(
payload,
slot_cards=review_payload.slot_cards,
claim_groups=review_payload.claim_groups,
)
return (
f"{base_message} "
"本次只是核对预览,尚未保存为草稿;需要暂存时请点击“保存为草稿”,"
"需要正式提交时再点击“继续下一步”。"
)
if review_action == "save_draft":
if draft_payload is not None and draft_payload.claim_no:
return (