feat: 细化差旅票据费用明细分类并自动计算出差补贴

将差旅费用明细拆分为火车票、机票、住宿票、乘车等细分类
型,根据票据字段自动生成行程/事由描述,结合规则引擎自
动计算出差补贴金额,前端适配费用明细编辑和差旅票据审
核交互,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-21 10:57:06 +08:00
parent 8f65661809
commit b183b0bd5e
26 changed files with 2588 additions and 362 deletions

View File

@@ -27,7 +27,12 @@ from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit
from app.schemas.ontology import OntologyEntity, OntologyParseResult
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate
from app.schemas.reimbursement import (
ExpenseClaimItemCreate,
ExpenseClaimItemUpdate,
ExpenseClaimUpdate,
TravelReimbursementCalculatorRequest,
)
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.agent_foundation import AgentFoundationService
@@ -42,10 +47,15 @@ from app.services.expense_rule_runtime import (
)
from app.services.ocr import OcrService
EXPENSE_TYPE_LABELS = {
"travel": "差旅",
"hotel": "住宿",
"transport": "交通",
EXPENSE_TYPE_LABELS = {
"travel": "差旅",
"train_ticket": "火车票",
"flight_ticket": "机票",
"hotel_ticket": "住宿票",
"ride_ticket": "乘车",
"travel_allowance": "出差补贴",
"hotel": "住宿",
"transport": "交通",
"meal": "餐费",
"meeting": "会务",
"entertainment": "招待",
@@ -60,8 +70,45 @@ APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
CLAIM_DELETE_ROLE_CODES = {"executive"}
MAX_DRAFT_CLAIMS_PER_USER = 3
EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned")
SYSTEM_GENERATED_ITEM_TYPES = {"travel_allowance"}
TRAVEL_DETAIL_ITEM_TYPES = {
"train_ticket",
"flight_ticket",
"hotel_ticket",
"ride_ticket",
"travel_allowance",
}
DOCUMENT_TYPE_ITEM_TYPE_MAP = {
"train_ticket": "train_ticket",
"flight_itinerary": "flight_ticket",
"hotel_invoice": "hotel_ticket",
"taxi_receipt": "ride_ticket",
"transport_receipt": "ride_ticket",
}
DOCUMENT_FACT_ITEM_TYPES = {"train_ticket", "flight_ticket", "hotel_ticket", "ride_ticket"}
DOCUMENT_ROUTE_TEXT_PATTERN = re.compile(
r"([A-Za-z0-9\u4e00-\u9fa5()·]{2,40})\s*(?:至|到|→|->|—||-)\s*"
r"([A-Za-z0-9\u4e00-\u9fa5()·]{2,40})"
)
DOCUMENT_ROUTE_ORIGIN_LABELS = {"起点", "上车", "上车地点", "上车地址", "出发", "出发地", "出发站", "始发站", "乘车起点"}
DOCUMENT_ROUTE_DESTINATION_LABELS = {
"终点",
"下车",
"下车地点",
"下车地址",
"到达",
"到达地",
"到达站",
"目的地",
"乘车终点",
}
GENERIC_ATTACHMENT_BACKFILL_ITEM_TYPES = {"", "other", "travel", "transport", "hotel"}
LOCATION_REQUIRED_EXPENSE_TYPES = {
"travel",
"train_ticket",
"flight_ticket",
"hotel_ticket",
"ride_ticket",
"meeting",
"entertainment",
}
@@ -109,9 +156,14 @@ EXPENSE_SCENE_KEYWORDS = {
"training": ("培训", "课程", "讲师", "教材", "学费", "认证"),
}
EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES = {
"travel": {"travel", "hotel", "transport", "meal"},
"hotel": {"hotel"},
EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES = {
"travel": {"travel", "hotel", "transport", "meal"},
"train_ticket": {"travel"},
"flight_ticket": {"travel"},
"hotel_ticket": {"hotel"},
"ride_ticket": {"transport"},
"travel_allowance": set(),
"hotel": {"hotel"},
"transport": {"transport", "travel"},
"meal": {"meal", "entertainment"},
"entertainment": {"entertainment", "meal"},
@@ -343,23 +395,55 @@ class ExpenseClaimService:
)
stmt = self._apply_claim_scope(stmt, current_user, include_approval_scope=True)
return self.db.scalar(stmt)
def update_claim_item(
self,
*,
def update_claim(
self,
*,
claim_id: str,
payload: ExpenseClaimUpdate,
current_user: CurrentUserContext,
) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
self._ensure_draft_pending_claim(claim)
before_json = self._serialize_claim(claim)
if payload.reason is not None:
claim.reason = self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充"
self.db.commit()
self.db.refresh(claim)
self.audit_service.log_action(
actor=current_user.name or current_user.username,
action="expense_claim.update",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
return claim
def update_claim_item(
self,
*,
claim_id: str,
item_id: str,
payload: ExpenseClaimItemUpdate,
current_user: CurrentUserContext,
) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
self._ensure_draft_claim(claim)
item = next((entry for entry in claim.items if entry.id == item_id), None)
if item is None:
raise LookupError("Item not found")
if claim is None:
return None
self._ensure_draft_claim(claim)
item = next((entry for entry in claim.items if entry.id == item_id), None)
if item is None:
raise LookupError("Item not found")
self._ensure_mutable_claim_item(item)
before_json = self._serialize_claim(claim)
@@ -407,12 +491,12 @@ class ExpenseClaimService:
current_user: CurrentUserContext,
) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
self._ensure_draft_claim(claim)
before_json = self._serialize_claim(claim)
payload = payload or ExpenseClaimItemCreate()
if claim is None:
return None
self._ensure_draft_claim(claim)
before_json = self._serialize_claim(claim)
payload = payload or ExpenseClaimItemCreate()
occurred_at = claim.occurred_at if claim.occurred_at is not None else datetime.now(UTC)
item_amount = Decimal("0.00")
@@ -509,11 +593,12 @@ class ExpenseClaimService:
item_id=item_id,
current_user=current_user,
)
if claim is None:
return None
self._ensure_draft_claim(claim)
normalized_name = self._normalize_attachment_filename(filename)
if claim is None:
return None
self._ensure_draft_claim(claim)
self._ensure_mutable_claim_item(item)
normalized_name = self._normalize_attachment_filename(filename)
if not content:
raise ValueError("上传文件不能为空。")
@@ -547,11 +632,20 @@ class ExpenseClaimService:
ocr_document = documents[0]
ocr_status = "recognized"
document_info = self._build_attachment_document_info(ocr_document)
self._backfill_item_type_from_attachment(
item=item,
document_info=document_info,
)
self._backfill_item_amount_from_attachment(
item=item,
document=ocr_document,
document_info=document_info,
)
self._backfill_item_reason_from_attachment(
item=item,
document=ocr_document,
document_info=document_info,
)
requirement_check = self._build_attachment_requirement_check(
item=item,
document_info=document_info,
@@ -694,11 +788,12 @@ class ExpenseClaimService:
item_id=item_id,
current_user=current_user,
)
if claim is None:
return None
self._ensure_draft_claim(claim)
before_json = self._serialize_claim(claim)
if claim is None:
return None
self._ensure_draft_claim(claim)
self._ensure_mutable_claim_item(item)
before_json = self._serialize_claim(claim)
previous_name = self._resolve_attachment_display_name(item.invoice_id)
self._delete_item_attachment_files(item)
item.invoice_id = None
@@ -1234,15 +1329,18 @@ class ExpenseClaimService:
self.db.flush()
if context_documents or attachment_names:
document_specs = self._build_context_item_specs(
context_documents=context_documents,
attachment_names=attachment_names,
occurred_at=final_occurred_at,
expense_type=final_expense_type,
amount=final_amount,
reason=final_reason,
location=final_location,
)
document_specs = self._build_context_item_specs(
context_documents=context_documents,
attachment_names=attachment_names,
occurred_at=final_occurred_at,
expense_type=final_expense_type,
amount=final_amount,
reason=final_reason,
location=final_location,
context_json=context_json,
employee_grade=str(employee.grade or "").strip() if employee is not None else "",
user_id=user_id,
)
else:
document_specs = []
@@ -1486,28 +1584,31 @@ class ExpenseClaimService:
)
return normalized
def _build_context_item_specs(
self,
*,
context_documents: list[dict[str, Any]],
attachment_names: list[str],
occurred_at: datetime,
expense_type: str,
amount: Decimal,
reason: str,
location: str,
) -> list[dict[str, Any]]:
specs: list[dict[str, Any]] = []
if context_documents:
for document in context_documents:
def _build_context_item_specs(
self,
*,
context_documents: list[dict[str, Any]],
attachment_names: list[str],
occurred_at: datetime,
expense_type: str,
amount: Decimal,
reason: str,
location: str,
context_json: dict[str, Any],
employee_grade: str | None = None,
user_id: str = "",
) -> list[dict[str, Any]]:
specs: list[dict[str, Any]] = []
if context_documents:
for document in context_documents:
specs.append(
{
"item_date": self._resolve_document_item_date(document, fallback=occurred_at.date()),
"item_type": self._resolve_document_item_type(document, fallback=expense_type),
"item_reason": reason,
"item_location": location,
"item_amount": self._resolve_document_item_amount(document),
"invoice_id": str(document.get("filename") or "").strip() or None,
"item_date": self._resolve_document_item_date(document, fallback=occurred_at.date()),
"item_type": self._resolve_document_item_type(document, fallback=expense_type),
"item_reason": self._resolve_document_item_reason(document, fallback=reason),
"item_location": location,
"item_amount": self._resolve_document_item_amount(document),
"invoice_id": str(document.get("filename") or "").strip() or None,
}
)
elif attachment_names:
@@ -1535,13 +1636,191 @@ class ExpenseClaimService:
if remaining > Decimal("0.00"):
missing_specs[0]["item_amount"] = remaining
for spec in specs:
if spec.get("item_amount") is None:
spec["item_amount"] = Decimal("0.00")
return specs
def _replace_claim_items(
for spec in specs:
if spec.get("item_amount") is None:
spec["item_amount"] = Decimal("0.00")
allowance_spec = self._build_travel_allowance_item_spec(
context_documents=context_documents,
specs=specs,
occurred_at=occurred_at,
expense_type=expense_type,
location=location,
context_json=context_json,
employee_grade=employee_grade,
user_id=user_id,
)
if allowance_spec is not None:
specs = [spec for spec in specs if str(spec.get("item_type") or "").strip() != "travel_allowance"]
specs.append(allowance_spec)
return specs
def _build_travel_allowance_item_spec(
self,
*,
context_documents: list[dict[str, Any]],
specs: list[dict[str, Any]],
occurred_at: datetime,
expense_type: str,
location: str,
context_json: dict[str, Any],
employee_grade: str | None,
user_id: str,
) -> dict[str, Any] | None:
if not self._should_add_travel_allowance_item(
expense_type=expense_type,
context_documents=context_documents,
context_json=context_json,
):
return None
grade = str(employee_grade or context_json.get("grade") or "").strip()
if not grade:
return None
days, _, end_date = self._resolve_travel_allowance_days(
context_json=context_json,
occurred_at=occurred_at,
)
allowance_location = self._resolve_travel_allowance_location(
location=location,
context_documents=context_documents,
)
if days < 1 or not allowance_location:
return None
try:
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
result = TravelReimbursementCalculatorService(self.db).calculate(
TravelReimbursementCalculatorRequest(
days=days,
location=allowance_location,
grade=grade,
),
CurrentUserContext(
username=user_id,
name="",
role_codes=[],
is_admin=False,
),
)
except ValueError:
return None
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 None
return {
"item_date": end_date,
"item_type": "travel_allowance",
"item_reason": (
f"系统自动计算出差补贴:{result.matched_city}{days}天,"
f"{allowance_rate:.2f}元/天"
),
"item_location": str(result.allowance_region or allowance_location).strip(),
"item_amount": allowance_amount,
"invoice_id": None,
}
@staticmethod
def _should_add_travel_allowance_item(
*,
expense_type: str,
context_documents: list[dict[str, Any]],
context_json: dict[str, Any],
) -> bool:
normalized_expense_type = str(expense_type or "").strip().lower()
if normalized_expense_type == "travel":
return True
review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict):
review_type = str(
review_form_values.get("expense_type")
or review_form_values.get("scene_label")
or review_form_values.get("reason_value")
or ""
)
if any(keyword in review_type for keyword in ("差旅", "出差")):
return True
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":
return True
return False
def _resolve_travel_allowance_days(
self,
*,
context_json: dict[str, Any],
occurred_at: datetime,
) -> tuple[int, date, date]:
start_date = occurred_at.date()
end_date = start_date
business_time_context = context_json.get("business_time_context")
if isinstance(business_time_context, dict):
start_date = self._parse_iso_date_or_default(business_time_context.get("start_date"), start_date)
end_date = self._parse_iso_date_or_default(business_time_context.get("end_date"), start_date)
else:
review_form_values = context_json.get("review_form_values")
if isinstance(review_form_values, dict):
time_text = str(
review_form_values.get("time_range")
or review_form_values.get("business_time")
or review_form_values.get("occurred_date")
or ""
).strip()
matched_dates = re.findall(r"\d{4}-\d{2}-\d{2}", time_text)
if matched_dates:
start_date = self._parse_iso_date_or_default(matched_dates[0], start_date)
end_date = self._parse_iso_date_or_default(matched_dates[-1], start_date)
if end_date < start_date:
end_date = start_date
days = (end_date - start_date).days + 1
return max(1, days), start_date, end_date
@staticmethod
def _parse_iso_date_or_default(value: Any, fallback: date) -> date:
try:
return date.fromisoformat(str(value or "").strip())
except ValueError:
return fallback
@staticmethod
def _resolve_travel_allowance_location(
*,
location: str,
context_documents: list[dict[str, Any]],
) -> str:
normalized_location = str(location or "").strip()
if normalized_location and normalized_location not in {"待补充", "未知", "暂无"}:
return normalized_location
for document in context_documents:
for field in list(document.get("document_fields") or []):
if not isinstance(field, dict):
continue
key = str(field.get("key") or "").strip().lower()
label = str(field.get("label") or "").strip()
value = str(field.get("value") or "").strip()
if key == "route" or "行程" in label:
separators = ("-", "", "", "->")
for separator in separators:
if separator in value:
return value.split(separator)[-1].strip()
if key in {"destination", "arrival_city"} or label in {"目的地", "到达城市"}:
return value
return ""
def _replace_claim_items(
self,
*,
claim: ExpenseClaim,
@@ -1565,18 +1844,28 @@ class ExpenseClaimService:
item.item_reason = spec["item_reason"]
item.item_location = spec["item_location"]
item.item_amount = spec["item_amount"]
item.invoice_id = self._merge_attachment_reference(item.invoice_id, spec["invoice_id"])
item.invoice_id = (
None
if str(spec.get("item_type") or "").strip() in SYSTEM_GENERATED_ITEM_TYPES
else self._merge_attachment_reference(item.invoice_id, spec["invoice_id"])
)
for stale_item in existing_items[len(item_specs) :]:
claim.items.remove(stale_item)
self.db.delete(stale_item)
def _append_document_items(
self,
*,
claim: ExpenseClaim,
item_specs: list[dict[str, Any]],
) -> None:
def _append_document_items(
self,
*,
claim: ExpenseClaim,
item_specs: list[dict[str, Any]],
) -> None:
system_specs = [
spec for spec in item_specs if str(spec.get("item_type") or "").strip() in SYSTEM_GENERATED_ITEM_TYPES
]
normal_specs = [
spec for spec in item_specs if str(spec.get("item_type") or "").strip() not in SYSTEM_GENERATED_ITEM_TYPES
]
existing_invoice_ids = {
str(item.invoice_id or "").strip()
for item in claim.items
@@ -1587,7 +1876,7 @@ class ExpenseClaimService:
for item in claim.items
if str(item.invoice_id or "").strip()
}
for spec in item_specs:
for spec in normal_specs:
invoice_id = str(spec.get("invoice_id") or "").strip()
invoice_name = self._resolve_attachment_display_name(invoice_id)
if invoice_id and (invoice_id in existing_invoice_ids or invoice_name in existing_invoice_names):
@@ -1607,15 +1896,40 @@ class ExpenseClaimService:
if invoice_id:
existing_invoice_ids.add(invoice_id)
existing_invoice_names.add(invoice_name)
if system_specs:
existing_system_items = [
item for item in list(claim.items) if str(item.item_type or "").strip() in SYSTEM_GENERATED_ITEM_TYPES
]
for stale_item in existing_system_items:
claim.items.remove(stale_item)
self.db.delete(stale_item)
for spec in system_specs:
claim.items.append(
ExpenseClaimItem(
claim_id=claim.id,
item_date=spec["item_date"],
item_type=spec["item_type"],
item_reason=spec["item_reason"],
item_location=spec["item_location"],
item_amount=spec["item_amount"],
invoice_id=spec["invoice_id"],
)
)
self.db.add(claim.items[-1])
def _resolve_document_item_type(self, document: dict[str, Any], *, fallback: str) -> str:
scene_code = str(document.get("scene_code") or "").strip()
if scene_code in {"travel", "hotel", "transport", "meal", "office", "meeting", "training"}:
return scene_code
document_type = str(document.get("document_type") or "").strip()
if document_type in {"flight_itinerary", "train_ticket"}:
return "travel"
def _resolve_document_item_type(self, document: dict[str, Any], *, fallback: str) -> str:
document_type = str(document.get("document_type") or "").strip()
mapped_type = DOCUMENT_TYPE_ITEM_TYPE_MAP.get(document_type)
if mapped_type:
return mapped_type
scene_code = str(document.get("scene_code") or "").strip()
if scene_code in {"travel", "hotel", "transport", "meal", "office", "meeting", "training"}:
return scene_code
if document_type in {"flight_itinerary", "train_ticket"}:
return "travel"
if document_type in {"taxi_receipt", "parking_toll_receipt", "transport_receipt"}:
return "transport"
if document_type == "hotel_invoice":
@@ -1639,12 +1953,212 @@ class ExpenseClaimService:
if "会务" in scene_label or "会议" in scene_label:
return "meeting"
if "培训" in scene_label:
return "training"
return fallback or "other"
def _resolve_document_item_amount(self, document: dict[str, Any]) -> Decimal | None:
for field in list(document.get("document_fields") or []):
if not isinstance(field, dict):
return "training"
return fallback or "other"
def _resolve_document_item_reason(self, document: dict[str, Any], *, fallback: str) -> str:
document_type = str(document.get("document_type") or "").strip().lower()
item_type = self._resolve_document_item_type(document, fallback="")
if document_type in {"train_ticket", "flight_itinerary"} or item_type in {"train_ticket", "flight_ticket"}:
route = self._resolve_document_route_value(document)
trip_no = self._resolve_document_fact_field(
document,
keys={"trip_no", "flight_no", "train_no"},
labels={"车次", "航班"},
)
if route and trip_no:
return f"{self._format_document_route(route)}{trip_no}"
if route:
return self._format_document_route(route)
if document_type in {"taxi_receipt", "transport_receipt"} or item_type == "ride_ticket":
route = self._resolve_document_route_value(document)
if route:
return self._format_document_route(route)
if document_type == "hotel_invoice" or item_type == "hotel_ticket":
merchant = self._resolve_document_fact_field(
document,
keys={"merchant_name", "merchant", "seller_name", "vendor_name", "hotel_name"},
labels={"商户", "酒店", "宾馆", "销售方", "开票方"},
)
stay_range = self._resolve_document_stay_range(document)
if merchant and stay_range:
return f"{merchant}{stay_range}"
if merchant:
return merchant
if stay_range:
return stay_range
merchant = self._resolve_document_fact_field(
document,
keys={"merchant_name", "merchant", "seller_name", "vendor_name"},
labels={"商户", "销售方", "开票方", "收款方"},
)
if merchant:
return merchant
summary = str(document.get("summary") or "").strip()
return summary or fallback or ""
def _resolve_document_route_value(self, document: dict[str, Any]) -> str:
route = self._resolve_document_fact_field(
document,
keys={"route", "trip_route"},
labels={"行程", "路线"},
)
if route:
return route
origin = self._resolve_document_fact_field(
document,
keys={
"origin",
"from",
"from_city",
"departure",
"departure_city",
"start",
"start_location",
"start_address",
"pickup_location",
"pickup_address",
"boarding_station",
},
labels=DOCUMENT_ROUTE_ORIGIN_LABELS,
)
destination = self._resolve_document_fact_field(
document,
keys={
"destination",
"to",
"to_city",
"arrival",
"arrival_city",
"end",
"end_location",
"end_address",
"dropoff_location",
"dropoff_address",
"alighting_station",
},
labels=DOCUMENT_ROUTE_DESTINATION_LABELS,
)
if origin and destination:
return f"{origin}-{destination}"
text = " ".join(
[
str(document.get("summary") or "").strip(),
str(document.get("text") or "").strip(),
]
).strip()
text_route = self._extract_document_route_from_text(text)
if text_route:
return text_route
text_origin = self._extract_document_labeled_text_value(text, DOCUMENT_ROUTE_ORIGIN_LABELS)
text_destination = self._extract_document_labeled_text_value(text, DOCUMENT_ROUTE_DESTINATION_LABELS)
if text_origin and text_destination:
return f"{text_origin}-{text_destination}"
return ""
@staticmethod
def _resolve_document_fact_field(
document: dict[str, Any],
*,
keys: set[str],
labels: set[str],
) -> str:
raw_fields = document.get("document_fields")
if not isinstance(raw_fields, list):
raw_fields = document.get("fields")
if not isinstance(raw_fields, list):
return ""
normalized_keys = {str(key or "").strip().lower().replace("_", "") for key in keys}
for field in raw_fields:
if not isinstance(field, dict):
continue
field_key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "")
value = str(field.get("value") or "").strip()
if not value:
continue
if field_key in normalized_keys or any(token in label for token in labels):
return value
return ""
@staticmethod
def _format_document_route(route: str) -> str:
normalized = (
str(route or "")
.strip()
.replace("->", "-")
.replace("", "-")
.replace("", "-")
.replace("", "-")
.replace("", "-")
.replace("", "-")
)
if "-" not in normalized:
return str(route or "").strip()
origin, destination = [part.strip() for part in normalized.split("-", 1)]
origin = origin.removeprefix("").strip()
destination = destination.removeprefix("").removeprefix("").strip()
if not origin or not destination or origin == destination:
return str(route or "").strip()
return f"{origin}{destination}"
@staticmethod
def _extract_document_route_from_text(text: str) -> str:
match = DOCUMENT_ROUTE_TEXT_PATTERN.search(str(text or ""))
if not match:
return ""
origin = str(match.group(1) or "").strip()
destination = str(match.group(2) or "").strip()
if not origin or not destination or origin == destination:
return ""
return f"{origin}-{destination}"
@staticmethod
def _extract_document_labeled_text_value(text: str, labels: set[str]) -> str:
for label in sorted(labels, key=len, reverse=True):
pattern = re.compile(
rf"{re.escape(label)}[:\s]*"
r"([A-Za-z0-9\u4e00-\u9fa5()·\-路街道号弄区县市省园桥站机场中心]{2,50})"
)
match = pattern.search(str(text or ""))
if match:
return str(match.group(1) or "").strip()
return ""
def _resolve_document_stay_range(self, document: dict[str, Any]) -> str:
check_in = self._resolve_document_fact_field(
document,
keys={"check_in", "checkin", "arrival_date", "start_date"},
labels={"入住", "入住日期", "到店", "开始日期"},
)
check_out = self._resolve_document_fact_field(
document,
keys={"check_out", "checkout", "departure_date", "end_date"},
labels={"离店", "退房", "离店日期", "结束日期"},
)
if check_in and check_out:
return f"{check_in}{check_out}"
nights = self._resolve_document_fact_field(
document,
keys={"nights", "night_count", "room_nights"},
labels={"间夜", "晚数", "入住天数"},
)
if nights:
return f"{nights}"
return ""
def _resolve_document_item_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(" ", "")
@@ -2575,6 +3089,21 @@ class ExpenseClaimService:
"fields": normalized_fields,
}
def _backfill_item_type_from_attachment(
self,
*,
item: ExpenseClaimItem,
document_info: dict[str, Any],
) -> None:
current_type = str(item.item_type or "").strip().lower()
if current_type not in GENERIC_ATTACHMENT_BACKFILL_ITEM_TYPES:
return
document_type = str(document_info.get("document_type") or "").strip()
mapped_type = DOCUMENT_TYPE_ITEM_TYPE_MAP.get(document_type)
if mapped_type:
item.item_type = mapped_type
def _backfill_item_amount_from_attachment(
self,
*,
@@ -2596,6 +3125,27 @@ class ExpenseClaimService:
if amount is not None and amount > Decimal("0.00"):
item.item_amount = amount
def _backfill_item_reason_from_attachment(
self,
*,
item: ExpenseClaimItem,
document: Any,
document_info: dict[str, Any],
) -> None:
reason = self._resolve_document_item_reason(
{
"document_type": str(document_info.get("document_type") or "").strip(),
"scene_code": str(document_info.get("scene_code") or "").strip(),
"scene_label": str(document_info.get("scene_label") or "").strip(),
"document_fields": document_info.get("fields") or [],
"summary": str(getattr(document, "summary", "") or ""),
"text": str(getattr(document, "text", "") or ""),
},
fallback=str(item.item_reason or "").strip(),
)
if reason:
item.item_reason = reason
def _build_attachment_requirement_check(
self,
*,
@@ -3063,6 +3613,17 @@ class ExpenseClaimService:
if not self._is_editable_claim_status(claim.status):
raise ValueError("只有草稿、待补充或退回待提交状态的报销单才允许执行该操作。")
@staticmethod
def _ensure_draft_pending_claim(claim: ExpenseClaim) -> None:
status = str(claim.status or "").strip().lower()
if status != "draft":
raise ValueError("只有草稿待提交状态的报销单才允许编辑附加说明。")
@staticmethod
def _ensure_mutable_claim_item(item: ExpenseClaimItem) -> None:
if str(item.item_type or "").strip().lower() in SYSTEM_GENERATED_ITEM_TYPES:
raise ValueError("系统自动计算的费用明细不可手动修改。")
def _delete_submitted_claim_assistant_sessions(self, claim_id: str | None) -> None:
from app.services.agent_conversations import AgentConversationService
@@ -4531,10 +5092,16 @@ class ExpenseClaimService:
primary_item.item_date.day,
tzinfo=UTC,
)
claim.expense_type = str(primary_item.item_type or claim.expense_type or "other").strip() or "other"
claim.reason = (
self._normalize_optional_text(primary_item.item_reason, fallback=claim.reason or "待补充") or "待补充"
)
claim.expense_type = self._resolve_claim_expense_type_from_items(
ordered_items,
fallback=str(primary_item.item_type or claim.expense_type or "other").strip() or "other",
)
primary_item_type = str(primary_item.item_type or "").strip()
if primary_item_type not in DOCUMENT_FACT_ITEM_TYPES:
claim.reason = (
self._normalize_optional_text(primary_item.item_reason, fallback=claim.reason or "待补充")
or "待补充"
)
claim.location = (
self._normalize_optional_text(primary_item.item_location, fallback=claim.location or "待补充")
or "待补充"
@@ -4543,8 +5110,20 @@ class ExpenseClaimService:
claim,
self._build_claim_attachment_risk_flags(ordered_items),
)
if str(claim.status or "").strip().lower() == "draft":
claim.approval_stage = "待提交"
if str(claim.status or "").strip().lower() == "draft":
claim.approval_stage = "待提交"
@staticmethod
def _resolve_claim_expense_type_from_items(
items: list[ExpenseClaimItem],
*,
fallback: str,
) -> 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:
return "travel"
return fallback_type
def _refresh_item_attachment_analysis(self, item: ExpenseClaimItem) -> None:
file_path = self._resolve_attachment_path(item.invoice_id)