feat: 增强差旅报销审核流程与票据智能推理
优化本体解析和编排器的差旅场景处理能力,完善报销单草稿 保存和费用明细同步逻辑,前端报销创建页面增加行程推理和 票据审核交互,新增助手会话快照工具函数,补充单元测试。
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user