feat: 优化差旅报销预审流程与个人工作台 UI 体系

- 完善 user_agent_application 申请差旅报销预审槽位与消息组装
- 增强预算助理报告与风险建议卡片交互
- 重构登录页视觉样式与移动端响应式适配
- 优化个人工作台、文档中心、政策中心、员工管理等页面布局
- 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型
- 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-02 14:01:51 +08:00
parent 92444e7eae
commit ca691f3ee0
107 changed files with 5663 additions and 1542 deletions

View File

@@ -703,7 +703,7 @@ def pay_expense_claim(
"/claims/{claim_id}",
response_model=ExpenseClaimActionResponse,
summary="删除报销单",
description="申请人仅可删除自己的草稿、待补充或退回单据;高级财务人员可删除可见的非归档单;已归档单据仅高级管理员可删除,财务人员没有删除权限。",
description="申请单仅系统管理员可删除;报销单申请人仅可删除自己的草稿、待补充或退回单据;高级财务人员可删除可见的非归档报销单;已归档单据仅高级管理员可删除,财务人员没有删除权限。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
@@ -725,8 +725,11 @@ def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
claim_no = str(claim.claim_no or "").strip()
expense_type = str(claim.expense_type or "").strip().lower()
document_label = "申请单" if claim_no.upper().startswith(("AP-", "APP-")) or expense_type.endswith("_application") else "报销单"
return ExpenseClaimActionResponse(
message=f"{claim.claim_no} 报销单已删除。",
message=f"{claim.claim_no} {document_label}已删除。",
claim_id=claim.id,
status="deleted",
)

View File

@@ -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",

View File

@@ -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],

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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(

View File

@@ -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)

View File

@@ -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")

View File

@@ -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(

View File

@@ -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(
"处理建议:当前信息已较完整,确认无误后可以继续下一步;"

View File

@@ -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)

View File

@@ -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))

View File

@@ -165,6 +165,32 @@ def test_validate_claim_for_submission_still_requires_location_for_travel_claim(
assert any("缺少地点" in item for item in issues)
def test_validate_claim_for_submission_does_not_require_optional_ride_receipt() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="transport", location="待补充")
claim.invoice_count = 0
claim.items[0].item_type = "ride_ticket"
claim.items[0].invoice_id = ""
issues = service._validate_claim_for_submission(claim)
assert "票据附件数量不足" not in issues
assert not any("缺少票据标识" in item for item in issues)
def test_validate_claim_for_submission_still_requires_hotel_receipt() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="hotel", location="北京")
claim.invoice_count = 0
claim.items[0].item_type = "hotel_ticket"
claim.items[0].invoice_id = ""
issues = service._validate_claim_for_submission(claim)
assert "票据附件数量不足" in issues
assert any("缺少票据标识" in item for item in issues)
def test_save_or_submit_preview_does_not_create_claim_without_explicit_action() -> None:
user_id = "preview-only@example.com"
message = "业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报"
@@ -342,6 +368,80 @@ def test_upsert_draft_from_ontology_persists_linked_application_context() -> Non
assert link_flag["application_detail"]["application_reason"] == "支撑国网仿生产环境部署"
def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_item() -> None:
user_id = "linked-application-no-receipt@example.com"
message = (
"报销类型:差旅费\n"
"关联申请单AP-202606-001 / 支撑国网仿生产服务器部署 / 2026-02-20 至 2026-02-23 / 上海 / ¥3,000\n"
"报销票据:草稿生成后在详情中上传"
)
with build_session() as db:
employee = Employee(
employee_no="E5104",
name="关联员工",
email=user_id,
grade="P5",
)
db.add(employee)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id=user_id,
)
)
result = ExpenseClaimService(db).upsert_draft_from_ontology(
run_id=ontology.run_id,
user_id=user_id,
message=message,
ontology=ontology,
context_json={
"name": "关联员工",
"user_input_text": message,
"review_action": "save_draft",
"review_form_values": {
"expense_type": "差旅费",
"amount": "¥3,000",
"reason": "支撑国网仿生产服务器部署",
"location": "上海",
"business_location": "上海",
"time_range": "2026-02-20 至 2026-02-23",
"business_time": "2026-02-20 至 2026-02-23",
"application_claim_id": "application-linked-no-receipt",
"application_claim_no": "AP-202606-001",
"application_reason": "支撑国网仿生产服务器部署",
"application_location": "上海",
"application_amount": "3000",
"application_amount_label": "¥3,000",
"application_business_time": "2026-02-20 至 2026-02-23",
},
"expense_scene_selection": {
"expense_type": "travel",
"application_claim_id": "application-linked-no-receipt",
"application_claim_no": "AP-202606-001",
},
},
)
claim = db.get(ExpenseClaim, result["claim_id"])
assert claim is not None
assert claim.expense_type == "travel"
assert claim.reason == "支撑国网仿生产服务器部署"
assert claim.location == "上海"
assert claim.amount == Decimal("0.00")
assert claim.invoice_count == 0
assert claim.items == []
link_flag = next(
flag
for flag in claim.risk_flags_json
if isinstance(flag, dict) and flag.get("source") == "application_link"
)
assert link_flag["application_claim_no"] == "AP-202606-001"
assert link_flag["application_detail"]["application_amount"] == "3000"
def test_unsaved_conversation_expires_after_retention_but_saved_conversation_stays() -> None:
with build_session() as db:
service = AgentConversationService(db)
@@ -2165,7 +2265,7 @@ def test_pre_review_claim_records_ai_result_without_submitting() -> None:
assert reviewed is not None
assert reviewed.status == "draft"
assert reviewed.approval_stage == "AI预审"
assert reviewed.approval_stage == "待提交"
assert reviewed.submitted_at is None
pre_review_flag = next(
flag
@@ -3098,6 +3198,93 @@ def test_executive_can_delete_submitted_claim() -> None:
assert db.get(ExpenseClaim, claim_id) is None
def test_direct_manager_cannot_delete_application_claim() -> None:
current_user = CurrentUserContext(
username="manager-delete-application@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E-APP-DEL-MANAGER",
name="李经理",
email="manager-delete-application@example.com",
)
employee = Employee(
employee_no="E-APP-DEL-EMP",
name="张三",
email="zhangsan-application-delete@example.com",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="APP-DEL-MANAGER-101",
employee_id=employee.id,
employee_name="张三",
department_name="市场部",
project_code=None,
expense_type="travel_application",
reason="差旅申请",
location="上海",
amount=Decimal("1200.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
db.add(claim)
db.commit()
claim_id = claim.id
with pytest.raises(ValueError, match="申请单只有系统管理员可以删除"):
ExpenseClaimService(db).delete_claim(claim_id, current_user)
assert db.get(ExpenseClaim, claim_id) is not None
def test_admin_can_delete_application_claim() -> None:
current_user = CurrentUserContext(
username="superadmin",
name="系统管理员",
role_codes=["manager"],
is_admin=True,
)
with build_session() as db:
claim = ExpenseClaim(
claim_no="APP-DEL-ADMIN-101",
employee_name="张三",
department_name="市场部",
project_code=None,
expense_type="travel_application",
reason="差旅申请",
location="上海",
amount=Decimal("1200.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
db.add(claim)
db.commit()
claim_id = claim.id
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
assert deleted is not None
assert deleted.claim_no == "APP-DEL-ADMIN-101"
assert db.get(ExpenseClaim, claim_id) is None
def test_executive_cannot_delete_archived_claim() -> None:
current_user = CurrentUserContext(
username="executive-archive-delete@example.com",

View File

@@ -268,7 +268,7 @@ def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action(
assert result["draft_payload"]["status"] == "draft"
assert response.conversation_id
assert AgentConversationService(db).get_conversation(response.conversation_id) is not None
assert "AI预审暂未通过" in result["answer"]
assert "自动检测暂未通过" in result["answer"]
assert "所属部门未完善" in result["answer"]
assert "next_step" not in actions
assert "save_draft" in actions
@@ -710,7 +710,7 @@ def test_orchestrator_application_session_does_not_use_reimbursement_scene_promp
assert response.status == "blocked"
assert response.trace_summary.scenario == "expense"
assert "费用申请" in result["answer"]
assert "| 发生时间 | 2026-05-25" in result["answer"]
assert "| 行程时间 | 2026-05-25" in result["answer"]
assert "请先在下面选择报销场景" not in result["answer"]
assert result.get("review_payload") is None

View File

@@ -16,6 +16,7 @@ from app.main import create_app
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
from app.models.role import Role
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
@@ -594,6 +595,31 @@ def test_claim_delete_allows_draft_owner_by_employee_id_without_employee_no_head
client, session_factory = build_client()
with session_factory() as db:
claim, _ = seed_claim(db)
observation = RiskObservation(
id="risk-observation-delete-1",
observation_key="claim-delete-risk-observation-1",
subject_type="expense_claim",
subject_key=claim.id,
subject_label=claim.claim_no,
claim_id=claim.id,
claim_no=claim.claim_no,
risk_type="policy",
risk_signal="draft_pre_review",
title="草稿预审风险",
description="删除草稿时应同步清理关联风险观察。",
risk_score=70,
risk_level="medium",
confidence_score=0.8,
)
feedback = RiskObservationFeedback(
id="risk-observation-feedback-delete-1",
observation=observation,
feedback_type="confirm",
actor="auditor",
)
db.add(observation)
db.add(feedback)
db.commit()
claim_id = claim.id
response = client.delete(
@@ -608,3 +634,5 @@ def test_claim_delete_allows_draft_owner_by_employee_id_without_employee_no_head
with session_factory() as db:
assert db.get(ExpenseClaim, claim_id) is None
assert db.get(RiskObservation, "risk-observation-delete-1") is None
assert db.get(RiskObservationFeedback, "risk-observation-feedback-delete-1") is None

View File

@@ -666,6 +666,82 @@ def test_current_keyword_city_consistency_rule_hits_ticket_city_mismatch() -> No
assert result["evidence"]["city_consistency"]["reference_values"] == ["北京"]
def test_travel_route_city_consistency_allows_normal_round_trip_to_declared_destination() -> None:
manifest = {
"template_key": "field_compare_v1",
"params": {
"template_key": "field_compare_v1",
"semantic_type": "travel_route_city_consistency",
"field_keys": [
"attachment.route_cities",
"claim.location",
"item.item_location",
"employee.location",
"claim.reason",
],
"attachment_city_fields": ["attachment.route_cities"],
"reference_city_fields": ["claim.location", "item.item_location"],
"home_city_fields": ["employee.location"],
"exception_fields": ["claim.reason"],
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
},
"outcomes": {"fail": {"severity": "high"}},
}
claim = ExpenseClaim(
claim_no="TEST-ROUND-TRIP",
employee_name="测试员工",
department_name="测试部门",
expense_type="差旅费",
reason="去上海支撑项目部署",
location="上海",
amount=Decimal("708.00"),
currency="CNY",
invoice_count=2,
occurred_at=datetime.now(UTC),
status="draft",
)
claim.employee = Employee(
employee_no="TEST-ROUND-TRIP-EMP",
name="测试员工",
email="round-trip@example.com",
location="武汉",
)
claim.items = [
ExpenseClaimItem(
item_date=date.today(),
item_type="交通费",
item_reason="去上海支撑项目部署",
item_location="上海",
item_amount=Decimal("354.00"),
)
]
result = RiskRuleTemplateExecutor().evaluate(
manifest,
claim=claim,
contexts=[
{
"document_info": {
"fields": [
{"key": "route", "label": "行程", "value": "武汉-上海"},
],
},
"ocr_text": "铁路电子客票 2026-02-20 武汉-上海 二等座",
},
{
"document_info": {
"fields": [
{"key": "route", "label": "行程", "value": "上海-武汉"},
],
},
"ocr_text": "铁路电子客票 2026-02-23 上海-武汉 二等座",
},
],
)
assert result is None
def test_generate_complex_travel_route_rule_uses_formula_not_keyword_match(tmp_path) -> None:
text = (
"差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;"

View File

@@ -209,7 +209,7 @@ def test_user_agent_application_context_uses_application_language() -> None:
assert "费用申请" in response.answer
assert "| 字段 | 内容 |" in response.answer
assert "| 发生时间 | 2026-05-25 至 2026-05-27 |" in response.answer
assert "| 行程时间 | 2026-05-25 至 2026-05-27 |" in response.answer
assert "支持上海国网服务器部署" in response.answer
assert "当前还需要补充:出行方式" in response.answer
assert "请先在下面选择报销场景" not in response.answer
@@ -224,7 +224,7 @@ def test_user_agent_application_infers_natural_reason_and_expands_single_date()
with session_factory() as db:
response = build_application_user_agent_response(db, message)
assert "| 发生时间 | 2026-05-25 至 2026-05-27 |" in response.answer
assert "| 行程时间 | 2026-05-25 至 2026-05-27 |" in response.answer
assert "| 地点 | 上海市 |" in response.answer
assert "| 事由 | 支撑上海国网服务器部署 |" in response.answer
assert "当前还需要先补充:申请事由" not in response.answer
@@ -250,7 +250,7 @@ def test_user_agent_application_normalizes_location_to_region_city() -> None:
yili_response = build_application_user_agent_response(db, yili_message)
beijing_response = build_application_user_agent_response(db, beijing_message)
assert "| 发生时间 | 2026-05-25 至 2026-05-27 |" in yili_response.answer
assert "| 行程时间 | 2026-05-25 至 2026-05-27 |" in yili_response.answer
assert "| 地点 | 新疆,伊犁 |" in yili_response.answer
assert "| 事由 | 支撑新疆电力仿生产部署 |" in yili_response.answer
assert "伊犁出差" not in yili_response.answer
@@ -289,7 +289,7 @@ def test_user_agent_application_uses_selected_time_and_natural_language_fields()
)
)
assert "| 发生时间 | 2026-05-25 |" in response.answer
assert "| 行程时间 | 2026-05-25 |" in response.answer
assert "| 地点 | 上海市 |" in response.answer
assert "| 事由 | 支撑国网服务器上线部署 |" in response.answer
assert "当前还需要补充:出行方式" in response.answer
@@ -325,6 +325,106 @@ def test_user_agent_application_builds_system_estimate_after_transport_choice()
assert response.suggested_actions == []
def test_user_agent_application_uses_selected_date_range_and_keeps_reason() -> None:
session_factory = build_session_factory()
message = "去上海出差4天支撑国网仿生产环境部署飞机"
context_json = {
"session_type": "application",
"entry_source": "application",
"business_time_context": {
"mode": "range",
"start_date": "2026-02-20",
"end_date": "2026-02-23",
"display_value": "2026-02-20 至 2026-02-23",
},
"name": "曹笑竹",
"department_name": "技术部",
"position": "财务智能化产品经理",
"manager_name": "向万红",
"grade": "P5",
}
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={"clarification_required": ontology.clarification_required},
)
)
assert "| 行程时间 | 2026-02-20 至 2026-02-23 |" in response.answer
assert "| 地点 | 上海市 |" in response.answer
assert "| 事由 | 支撑国网仿生产环境部署 |" in response.answer
assert "| 天数 | 4天 |" in response.answer
assert "| 发生时间 |" not in response.answer
assert "| 事由 | 2026-02-20 至 2026-02-23 |" not in response.answer
def test_user_agent_application_derives_days_from_selected_date_range() -> None:
session_factory = build_session_factory()
message = "去上海出差,支撑国网仿生产服务器部署,火车"
context_json = {
"session_type": "application",
"entry_source": "application",
"business_time_context": {
"mode": "range",
"start_date": "2026-02-20",
"end_date": "2026-02-23",
"display_value": "2026-02-20 至 2026-02-23",
},
"application_preview": {
"fields": {
"applicationType": "差旅费用申请",
"time": "2026-02-20 至 2026-02-23",
"location": "上海市",
"reason": "支撑国网仿生产服务器部署",
"days": "待补充",
"transportMode": "火车",
"grade": "P5",
}
},
"name": "曹笑竹",
"department_name": "技术部",
"position": "财务智能化产品经理",
"manager_name": "向万红",
"grade": "P5",
}
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={"clarification_required": ontology.clarification_required},
)
)
assert "| 行程时间 | 2026-02-20 至 2026-02-23 |" in response.answer
assert "| 天数 | 4天 |" in response.answer
assert "| 天数 | 待补充 |" not in response.answer
assert "4天" in response.answer
assert "1天" not in response.answer
def test_user_agent_application_missing_base_actions_prefill_composer() -> None:
session_factory = build_session_factory()
with session_factory() as db:
@@ -352,7 +452,7 @@ def test_user_agent_application_precomputes_time_from_today_and_days() -> None:
)
assert "这是费用申请核对结果" in response.answer
assert "| 发生时间 | 2026-05-29 至 2026-05-31 |" in response.answer
assert "| 行程时间 | 2026-05-29 至 2026-05-31 |" in response.answer
assert response.requires_confirmation is True
@@ -395,6 +495,45 @@ def test_user_agent_application_builds_preview_when_amount_is_ready() -> None:
assert response.suggested_actions == []
def test_user_agent_application_preview_uses_employee_grade_profile() -> None:
session_factory = build_session_factory()
initial_message = (
"发生时间2026-05-25\n"
"地点:上海\n"
"事由:支持上海国网服务器部署\n"
"天数3天"
)
with session_factory() as db:
employee = Employee(
employee_no="APP-GRADE-001",
name="李文静",
email="pytest-application-grade@example.com",
position="解决方案顾问",
grade="P5",
)
db.add(employee)
db.commit()
response = build_application_user_agent_response(
db,
"预计总费用12000元",
context_overrides={
"name": "李文静",
"manager_name": "王强",
},
history=[
{"role": "user", "content": initial_message},
{"role": "user", "content": "飞机"},
],
)
assert "这是费用申请核对结果" in response.answer
assert "| 姓名 | 李文静 |" in response.answer
assert "| 岗位 | 解决方案顾问 |" in response.answer
assert "| 职级 | P5 |" in response.answer
assert "| 职级 | 待补充 |" not in response.answer
def test_user_agent_application_submit_enters_leader_review() -> None:
session_factory = build_session_factory()
initial_message = (
@@ -408,7 +547,7 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
"| 字段 | 内容 |\n"
"| --- | --- |\n"
"| 申请类型 | 差旅费用申请 |\n"
"| 发生时间 | 2026-05-25 |\n"
"| 行程时间 | 2026-05-25 |\n"
"| 地点 | 上海市 |\n"
"| 事由 | 支持上海国网服务器部署 |\n"
"| 天数 | 3天 |\n"
@@ -443,6 +582,58 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
assert claim.employee_name == "pytest"
def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
session_factory = build_session_factory()
initial_message = (
"行程时间2026-05-25 至 2026-05-27\n"
"地点:上海\n"
"事由:支持上海国网服务器部署\n"
"天数3天\n"
"出行方式:飞机\n"
"预计总费用12000元"
)
preview_answer = (
"这是费用申请核对结果,请核对:\n"
"| 字段 | 内容 |\n"
"| --- | --- |\n"
"| 申请类型 | 差旅费用申请 |\n"
"| 行程时间 | 2026-05-25 至 2026-05-27 |\n"
"| 地点 | 上海市 |\n"
"| 事由 | 支持上海国网服务器部署 |\n"
"| 天数 | 3天 |\n"
"| 出行方式 | 飞机 |\n"
"| 系统预估费用 | 12000元 |\n\n"
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。"
)
history = [
{"role": "user", "content": initial_message},
{"role": "assistant", "content": preview_answer},
]
with session_factory() as db:
first_response = build_application_user_agent_response(
db,
"确认提交",
context_overrides={"manager_name": "陈硕"},
history=history,
)
first_claim = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).one()
second_response = build_application_user_agent_response(
db,
"确认提交",
context_overrides={"manager_name": "陈硕"},
history=history,
)
claims = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).all()
assert len(claims) == 1
assert "申请单据已生成" in first_response.answer
assert "已存在申请单" in second_response.answer
assert "系统没有重复创建" in second_response.answer
assert first_claim.claim_no in second_response.answer
assert second_response.draft_payload is None
def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None:
session_factory = build_session_factory()
with session_factory() as db:
@@ -1173,6 +1364,57 @@ def test_user_agent_continues_identification_after_expense_type_selection() -> N
assert "| 参考合计 |" in response.answer
def test_user_agent_uses_linked_application_context_for_review_slots() -> None:
session_factory = build_session_factory()
with session_factory() as db:
message = "请生成差旅费报销草稿"
context_json = {
"grade": "P4",
"expense_scene_selection": {
"expense_type": "travel",
"expense_type_label": "差旅费",
"original_message": message,
"application_claim_id": "application-linked-1",
"application_claim_no": "AP-202606-001",
},
"review_form_values": {
"expense_type": "差旅费",
"application_claim_id": "application-linked-1",
"application_claim_no": "AP-202606-001",
"application_reason": "支撑国网仿生产环境部署",
"application_location": "北京",
"application_amount": "3000元",
"application_business_time": "2026-06-01 至 2026-06-03",
},
"user_input_text": message,
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest-linked-application-review@example.com",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-linked-application-review@example.com",
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={"draft_only": True},
)
)
assert response.review_payload is not None
slot_map = {item.key: item for item in response.review_payload.slot_cards}
assert slot_map["reason"].value == "支撑国网仿生产环境部署"
assert slot_map["location"].value == "北京"
assert slot_map["amount"].value == "3000.00元"
assert slot_map["time_range"].value == "2026-06-01 至 2026-06-03"
assert "事由说明" not in response.review_payload.missing_slots
def test_user_agent_guides_implicit_expense_draft_request() -> None:
session_factory = build_session_factory()
with session_factory() as db:
@@ -1422,6 +1664,12 @@ def test_user_agent_keeps_travel_range_when_user_adds_receipts_after_text_contex
assert followup_slots["time_range"].value == "2026-02-20 至 2026-02-23"
assert followup_slots["location"].value == "上海"
assert followup_slots["reason"].value == "去上海支撑上海电力服务器部署出差3天"
followup_risk_text = "\n".join(
f"{item.title}\n{item.content}\n{item.detail}"
for item in followup_response.review_payload.risk_briefs
)
assert "票据城市与申报目的地不一致" not in followup_risk_text
assert "差旅目的地与票据城市不一致" not in followup_risk_text
def test_user_agent_does_not_treat_draft_saved_message_as_precheck_risk_for_transport() -> None:
@@ -1697,7 +1945,9 @@ def test_user_agent_save_draft_answer_guides_followup_to_existing_draft() -> Non
assert response.draft_payload is not None
assert response.draft_payload.claim_no == "BX202605220001"
assert "已按您当前确认的信息保存为草稿 BX202605220001" in response.answer
assert "请关联这张草稿" in response.answer
assert "系统已完成草稿规则校验" in response.answer
assert "继续在当前对话上传" in response.answer
assert "请关联这张草稿" not in response.answer
assert "继续保存草稿" not in response.answer
@@ -2264,7 +2514,7 @@ def test_user_agent_review_payload_does_not_fill_hotel_name_from_train_ticket()
]
assert "继续下一步" not in [item.label for item in response.review_payload.confirmation_actions]
assert "酒店住宿发票/住宿清单(必须,当前待上传)" in response.answer
assert "市内交通/乘车票据(非必须" in response.answer
assert "市内交通/乘车票据(非必须" not in response.answer
assert "只能保存为草稿" in response.answer or "保存为草稿" in response.answer
assert "已识别信息:" in response.answer
assert "酒店住宿发票/住宿清单" in response.answer
@@ -2280,7 +2530,7 @@ def test_user_agent_review_payload_does_not_fill_hotel_name_from_train_ticket()
assert "列车出发时间" in field_labels
def test_user_agent_review_payload_allows_next_step_when_only_optional_ride_receipt_is_missing() -> None:
def test_user_agent_review_payload_does_not_prompt_when_only_optional_ride_receipt_is_missing() -> None:
session_factory = build_session_factory()
with session_factory() as db:
query = "我去北京出差,上传了火车票和酒店票,帮我生成差旅费报销草稿"
@@ -2341,14 +2591,11 @@ def test_user_agent_review_payload_allows_next_step_when_only_optional_ride_rece
assert response.review_payload is not None
assert response.review_payload.can_proceed is True
assert response.review_payload.missing_slots == []
receipt_brief = next(item for item in response.review_payload.risk_briefs if item.title == "差旅票据待补充")
assert receipt_brief.level == "info"
assert "市内交通/乘车票据可继续上传(非必须)" in receipt_brief.content
assert "酒店的报销票据待上传(必须)" not in receipt_brief.content
assert not any(item.title == "差旅票据待补充" for item in response.review_payload.risk_briefs)
action_types = [item.action_type for item in response.review_payload.confirmation_actions]
assert "save_draft" in action_types
assert "next_step" in action_types
assert "市内交通/乘车票据(非必须" in response.answer
assert "市内交通/乘车票据(非必须" not in response.answer
assert "继续下一步" in response.answer