feat(expense): add persistent zero-entry receipt association
This commit is contained in:
746
server/src/app/services/expense_receipt_association.py
Normal file
746
server/src/app/services/expense_receipt_association.py
Normal file
@@ -0,0 +1,746 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import shutil
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.schemas.receipt_folder import ReceiptFolderDetailRead
|
||||
from app.schemas.reimbursement import ExpenseClaimItemCreate
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claim_constants import DOCUMENT_TYPE_ITEM_TYPE_MAP
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.expense_receipt_matcher import (
|
||||
ExpenseReceiptMatchCandidate,
|
||||
ExpenseReceiptMatcher,
|
||||
extract_city_tokens,
|
||||
normalize_date_token,
|
||||
)
|
||||
from app.services.receipt_folder import ReceiptFolderService
|
||||
|
||||
_claim_locks: dict[str, Lock] = {}
|
||||
_claim_locks_guard = Lock()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ReceiptMutation:
|
||||
receipt: ReceiptFolderDetailRead
|
||||
item_id: str
|
||||
original_meta: dict[str, Any]
|
||||
attachment_dir: str = ""
|
||||
attachment_backup_dir: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _AssociationDbSnapshot:
|
||||
tenant_id: str
|
||||
case_ids: set[str]
|
||||
link_ids: set[str]
|
||||
event_ids: set[str]
|
||||
claim_id: str
|
||||
receipt_ids: list[str]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ClaimMutationSnapshot:
|
||||
claim_id: str
|
||||
amount: Decimal
|
||||
invoice_count: int
|
||||
risk_flags_json: list[Any]
|
||||
approval_stage: str
|
||||
submitted_at: Any
|
||||
status: str
|
||||
items: dict[str, dict[str, Any]]
|
||||
|
||||
|
||||
class ExpenseReceiptAssociationService:
|
||||
"""高置信票据归集编排;低置信结果保持严格零写入。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
self.claim_service = ExpenseClaimService(db)
|
||||
self.case_service = ExpenseCaseService(db)
|
||||
self.receipt_service = ReceiptFolderService()
|
||||
self.matcher = ExpenseReceiptMatcher(db)
|
||||
self.attachment_storage = ExpenseClaimAttachmentStorage()
|
||||
|
||||
def associate(
|
||||
self,
|
||||
*,
|
||||
receipts: list[ReceiptFolderDetailRead],
|
||||
current_user: CurrentUserContext,
|
||||
) -> dict[str, Any]:
|
||||
match = self.matcher.match(receipts=receipts, current_user=current_user)
|
||||
if match.requires_confirmation or match.recommended is None:
|
||||
return self._build_confirmation_result(match)
|
||||
|
||||
recommended = match.recommended
|
||||
claim = recommended.claim
|
||||
if claim is None:
|
||||
return self._build_confirmation_result(match)
|
||||
initial_claim_id = str(claim.id or "")
|
||||
with _claim_execution_lock(
|
||||
self.db,
|
||||
tenant_id=current_user.tenant_id,
|
||||
claim_id=initial_claim_id,
|
||||
):
|
||||
locked_match = self.matcher.match(receipts=receipts, current_user=current_user)
|
||||
locked_recommended = locked_match.recommended
|
||||
locked_claim = getattr(locked_recommended, "claim", None)
|
||||
if locked_match.requires_confirmation or locked_claim is None:
|
||||
return self._build_confirmation_result(locked_match)
|
||||
if str(locked_claim.id or "") != initial_claim_id:
|
||||
locked_match.resolution = "requires_confirmation"
|
||||
locked_match.requires_confirmation = True
|
||||
locked_match.exceptions.append(
|
||||
"并发处理期间候选报销草稿发生变化,本次未写入,请重新确认。"
|
||||
)
|
||||
return self._build_confirmation_result(locked_match)
|
||||
return self._associate_matched(
|
||||
receipts=receipts,
|
||||
current_user=current_user,
|
||||
match=locked_match,
|
||||
recommended=locked_recommended,
|
||||
claim=locked_claim,
|
||||
)
|
||||
|
||||
def _associate_matched(
|
||||
self,
|
||||
*,
|
||||
receipts: list[ReceiptFolderDetailRead],
|
||||
current_user: CurrentUserContext,
|
||||
match: Any,
|
||||
recommended: ExpenseReceiptMatchCandidate,
|
||||
claim: ExpenseClaim,
|
||||
) -> dict[str, Any]:
|
||||
claim_snapshot = self._capture_claim_snapshot(claim)
|
||||
db_snapshot = self._capture_db_snapshot(
|
||||
tenant_id=current_user.tenant_id,
|
||||
claim_id=claim.id,
|
||||
receipt_ids=[receipt.id for receipt in receipts],
|
||||
)
|
||||
expense_case = self._ensure_expense_case(
|
||||
recommended,
|
||||
claim=claim,
|
||||
tenant_id=current_user.tenant_id,
|
||||
)
|
||||
correlation_id = self.case_service.normalize_correlation_id(
|
||||
"zero-entry:" + ":".join(sorted(receipt.id for receipt in receipts))
|
||||
)
|
||||
mutations: list[_ReceiptMutation] = []
|
||||
uploaded_count = 0
|
||||
skipped_count = 0
|
||||
try:
|
||||
for receipt in receipts:
|
||||
if self._is_linked_to_claim(receipt, claim.id):
|
||||
skipped_count += 1
|
||||
item_id = str((receipt.raw_meta or {}).get("linked_item_id") or "").strip()
|
||||
self._record_receipt_events(
|
||||
expense_case,
|
||||
receipt=receipt,
|
||||
claim=claim,
|
||||
item_id=item_id,
|
||||
current_user=current_user,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
continue
|
||||
if self._is_linked_to_other_claim(receipt, claim.id):
|
||||
raise ValueError("票据已归属其他报销单,自动归集已停止。")
|
||||
|
||||
target_item = self._resolve_target_item(
|
||||
claim_id=claim.id,
|
||||
receipt=receipt,
|
||||
current_user=current_user,
|
||||
)
|
||||
source_path, media_type, file_name = self.receipt_service.resolve_source(
|
||||
receipt.id,
|
||||
current_user,
|
||||
)
|
||||
mutation = self._prepare_receipt_mutation(
|
||||
receipt=receipt,
|
||||
claim_id=claim.id,
|
||||
item_id=target_item.id,
|
||||
)
|
||||
mutations.append(mutation)
|
||||
result = self.claim_service.upload_claim_item_attachment(
|
||||
claim_id=claim.id,
|
||||
item_id=target_item.id,
|
||||
filename=file_name,
|
||||
content=source_path.read_bytes(),
|
||||
media_type=media_type,
|
||||
current_user=current_user,
|
||||
source_receipt_id=receipt.id,
|
||||
commit=False,
|
||||
link_source_receipt=False,
|
||||
write_audit=False,
|
||||
refresh_pre_review=False,
|
||||
)
|
||||
if result is None:
|
||||
raise ValueError("无法把票据写入目标报销草稿。")
|
||||
self.case_service.link_resource(
|
||||
expense_case,
|
||||
resource_type="receipt",
|
||||
resource_id=receipt.id,
|
||||
relation_type="receipt",
|
||||
tenant_id=current_user.tenant_id,
|
||||
)
|
||||
self._record_receipt_events(
|
||||
expense_case,
|
||||
receipt=receipt,
|
||||
claim=claim,
|
||||
item_id=target_item.id,
|
||||
current_user=current_user,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
self.receipt_service.mark_receipt_linked(
|
||||
receipt_id=receipt.id,
|
||||
current_user=current_user,
|
||||
claim_id=claim.id,
|
||||
claim_no=claim.claim_no,
|
||||
item_id=target_item.id,
|
||||
)
|
||||
uploaded_count += 1
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
self._discard_attachment_backups(mutations)
|
||||
except Exception as exc:
|
||||
self.db.rollback()
|
||||
compensation_errors: list[str] = []
|
||||
try:
|
||||
self._restore_claim_snapshot(
|
||||
claim_snapshot,
|
||||
db_snapshot=db_snapshot,
|
||||
expense_case_id=expense_case.id,
|
||||
)
|
||||
except Exception as compensation_exc:
|
||||
compensation_errors.append(str(compensation_exc))
|
||||
for mutation in reversed(mutations):
|
||||
try:
|
||||
self.receipt_service.restore_receipt_meta(
|
||||
receipt_id=mutation.receipt.id,
|
||||
current_user=current_user,
|
||||
meta=mutation.original_meta,
|
||||
)
|
||||
except Exception as compensation_exc:
|
||||
compensation_errors.append(str(compensation_exc))
|
||||
try:
|
||||
self._restore_attachment_directory(mutation)
|
||||
except Exception as compensation_exc:
|
||||
compensation_errors.append(str(compensation_exc))
|
||||
if compensation_errors:
|
||||
raise RuntimeError(
|
||||
f"{exc};自动补偿未完全成功:{';'.join(compensation_errors)}"
|
||||
) from exc
|
||||
raise
|
||||
|
||||
return self._build_success_result(
|
||||
match_candidate=recommended,
|
||||
expense_case=expense_case,
|
||||
claim=claim,
|
||||
uploaded_count=uploaded_count,
|
||||
skipped_count=skipped_count,
|
||||
candidates=[candidate.to_payload() for candidate in match.candidates],
|
||||
)
|
||||
|
||||
def _ensure_expense_case(
|
||||
self,
|
||||
candidate: ExpenseReceiptMatchCandidate,
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
tenant_id: str,
|
||||
) -> ExpenseCase:
|
||||
if candidate.expense_case is not None:
|
||||
self.case_service.link_claim(
|
||||
candidate.expense_case,
|
||||
claim,
|
||||
tenant_id=tenant_id,
|
||||
relation_type="generated_reimbursement" if candidate.application_claim else "claim",
|
||||
)
|
||||
return candidate.expense_case
|
||||
if candidate.application_claim is not None:
|
||||
expense_case = self.case_service.ensure_case_for_claim(
|
||||
candidate.application_claim,
|
||||
tenant_id=tenant_id,
|
||||
relation_type="application",
|
||||
)
|
||||
self.case_service.link_claim(
|
||||
expense_case,
|
||||
claim,
|
||||
tenant_id=tenant_id,
|
||||
relation_type="generated_reimbursement",
|
||||
)
|
||||
return expense_case
|
||||
return self.case_service.ensure_case_for_claim(claim, tenant_id=tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def _capture_claim_snapshot(claim: ExpenseClaim) -> _ClaimMutationSnapshot:
|
||||
return _ClaimMutationSnapshot(
|
||||
claim_id=str(claim.id or ""),
|
||||
amount=Decimal(claim.amount or Decimal("0.00")),
|
||||
invoice_count=int(claim.invoice_count or 0),
|
||||
risk_flags_json=copy.deepcopy(list(claim.risk_flags_json or [])),
|
||||
approval_stage=str(claim.approval_stage or ""),
|
||||
submitted_at=claim.submitted_at,
|
||||
status=str(claim.status or ""),
|
||||
items={
|
||||
str(item.id): {
|
||||
"item_date": item.item_date,
|
||||
"item_type": item.item_type,
|
||||
"item_reason": item.item_reason,
|
||||
"item_location": item.item_location,
|
||||
"item_note": item.item_note,
|
||||
"item_amount": Decimal(item.item_amount or Decimal("0.00")),
|
||||
"invoice_id": item.invoice_id,
|
||||
}
|
||||
for item in list(claim.items or [])
|
||||
},
|
||||
)
|
||||
|
||||
def _capture_db_snapshot(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
claim_id: str,
|
||||
receipt_ids: list[str],
|
||||
) -> _AssociationDbSnapshot:
|
||||
normalized_tenant = self.case_service.normalize_tenant_id(tenant_id)
|
||||
receipt_ids = list(dict.fromkeys(str(item or "").strip() for item in receipt_ids if item))
|
||||
links = list(
|
||||
self.db.scalars(
|
||||
select(ExpenseCaseLink).where(
|
||||
ExpenseCaseLink.tenant_id == normalized_tenant,
|
||||
(
|
||||
(ExpenseCaseLink.resource_type == "expense_claim")
|
||||
& (ExpenseCaseLink.resource_id == claim_id)
|
||||
)
|
||||
| (
|
||||
(ExpenseCaseLink.resource_type == "receipt")
|
||||
& ExpenseCaseLink.resource_id.in_(receipt_ids)
|
||||
),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
events = list(
|
||||
self.db.scalars(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.tenant_id == normalized_tenant,
|
||||
BusinessEvent.aggregate_type == "receipt",
|
||||
BusinessEvent.aggregate_id.in_(receipt_ids),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
return _AssociationDbSnapshot(
|
||||
tenant_id=normalized_tenant,
|
||||
case_ids=set(
|
||||
self.db.scalars(
|
||||
select(ExpenseCase.id).where(ExpenseCase.tenant_id == normalized_tenant)
|
||||
).all()
|
||||
),
|
||||
link_ids={str(link.id) for link in links},
|
||||
event_ids={str(event.id) for event in events},
|
||||
claim_id=str(claim_id or ""),
|
||||
receipt_ids=receipt_ids,
|
||||
)
|
||||
|
||||
def _restore_claim_snapshot(
|
||||
self,
|
||||
snapshot: _ClaimMutationSnapshot,
|
||||
*,
|
||||
db_snapshot: _AssociationDbSnapshot,
|
||||
expense_case_id: str,
|
||||
) -> None:
|
||||
claim = self.db.get(ExpenseClaim, snapshot.claim_id)
|
||||
if claim is None:
|
||||
return
|
||||
for item in list(claim.items or []):
|
||||
item_id = str(item.id or "")
|
||||
item_snapshot = snapshot.items.get(item_id)
|
||||
if item_snapshot is None:
|
||||
claim.items.remove(item)
|
||||
self.db.delete(item)
|
||||
continue
|
||||
for key, value in item_snapshot.items():
|
||||
setattr(item, key, value)
|
||||
claim.amount = snapshot.amount
|
||||
claim.invoice_count = snapshot.invoice_count
|
||||
claim.risk_flags_json = copy.deepcopy(snapshot.risk_flags_json)
|
||||
claim.approval_stage = snapshot.approval_stage
|
||||
claim.submitted_at = snapshot.submitted_at
|
||||
claim.status = snapshot.status
|
||||
for event in list(
|
||||
self.db.scalars(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.tenant_id == db_snapshot.tenant_id,
|
||||
BusinessEvent.aggregate_type == "receipt",
|
||||
BusinessEvent.aggregate_id.in_(db_snapshot.receipt_ids),
|
||||
)
|
||||
).all()
|
||||
):
|
||||
if str(event.id) not in db_snapshot.event_ids:
|
||||
self.db.delete(event)
|
||||
for link in list(
|
||||
self.db.scalars(
|
||||
select(ExpenseCaseLink).where(
|
||||
ExpenseCaseLink.tenant_id == db_snapshot.tenant_id,
|
||||
(
|
||||
(ExpenseCaseLink.resource_type == "expense_claim")
|
||||
& (ExpenseCaseLink.resource_id == db_snapshot.claim_id)
|
||||
)
|
||||
| (
|
||||
(ExpenseCaseLink.resource_type == "receipt")
|
||||
& ExpenseCaseLink.resource_id.in_(db_snapshot.receipt_ids)
|
||||
),
|
||||
)
|
||||
).all()
|
||||
):
|
||||
if str(link.id) not in db_snapshot.link_ids:
|
||||
self.db.delete(link)
|
||||
if expense_case_id not in db_snapshot.case_ids:
|
||||
created_case = self.db.get(ExpenseCase, expense_case_id)
|
||||
if created_case is not None:
|
||||
self.db.delete(created_case)
|
||||
self.db.commit()
|
||||
|
||||
def _prepare_receipt_mutation(
|
||||
self,
|
||||
*,
|
||||
receipt: ReceiptFolderDetailRead,
|
||||
claim_id: str,
|
||||
item_id: str,
|
||||
) -> _ReceiptMutation:
|
||||
attachment_dir = self.attachment_storage.build_item_dir(
|
||||
claim_id,
|
||||
item_id,
|
||||
)
|
||||
backup_root = ""
|
||||
if attachment_dir.exists():
|
||||
backup_path = Path(tempfile.mkdtemp(prefix="x-financial-attachment-backup-"))
|
||||
shutil.copytree(attachment_dir, backup_path / "item")
|
||||
backup_root = str(backup_path)
|
||||
return _ReceiptMutation(
|
||||
receipt=receipt,
|
||||
item_id=item_id,
|
||||
original_meta=copy.deepcopy(dict(receipt.raw_meta or {})),
|
||||
attachment_dir=str(attachment_dir),
|
||||
attachment_backup_dir=backup_root,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _discard_attachment_backups(mutations: list[_ReceiptMutation]) -> None:
|
||||
for mutation in mutations:
|
||||
if mutation.attachment_backup_dir:
|
||||
shutil.rmtree(mutation.attachment_backup_dir, ignore_errors=True)
|
||||
|
||||
@staticmethod
|
||||
def _restore_attachment_directory(mutation: _ReceiptMutation) -> None:
|
||||
attachment_dir = mutation.attachment_dir
|
||||
if not attachment_dir:
|
||||
return
|
||||
shutil.rmtree(attachment_dir, ignore_errors=True)
|
||||
backup_dir = mutation.attachment_backup_dir
|
||||
backup_item_dir = Path(backup_dir) / "item" if backup_dir else None
|
||||
if backup_item_dir is not None and backup_item_dir.exists():
|
||||
shutil.copytree(backup_item_dir, attachment_dir)
|
||||
if backup_dir:
|
||||
shutil.rmtree(backup_dir, ignore_errors=True)
|
||||
|
||||
def _record_receipt_events(
|
||||
self,
|
||||
expense_case: ExpenseCase,
|
||||
*,
|
||||
receipt: ReceiptFolderDetailRead,
|
||||
claim: ExpenseClaim,
|
||||
item_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
correlation_id: str,
|
||||
) -> None:
|
||||
self.case_service.link_resource(
|
||||
expense_case,
|
||||
resource_type="receipt",
|
||||
resource_id=receipt.id,
|
||||
relation_type="receipt",
|
||||
tenant_id=current_user.tenant_id,
|
||||
)
|
||||
received_event = self.case_service.record_resource_event(
|
||||
expense_case,
|
||||
aggregate_type="receipt",
|
||||
aggregate_id=receipt.id,
|
||||
event_type="receipt_received",
|
||||
actor_id=current_user.username,
|
||||
tenant_id=current_user.tenant_id,
|
||||
correlation_id=correlation_id,
|
||||
idempotency_key=f"receipt-received:{receipt.id}",
|
||||
payload={
|
||||
"file_name": receipt.file_name,
|
||||
"document_type": receipt.document_type,
|
||||
"scene_code": receipt.scene_code,
|
||||
},
|
||||
)
|
||||
self.case_service.record_resource_event(
|
||||
expense_case,
|
||||
aggregate_type="receipt",
|
||||
aggregate_id=receipt.id,
|
||||
event_type="attachment_associated",
|
||||
actor_id=current_user.username,
|
||||
tenant_id=current_user.tenant_id,
|
||||
correlation_id=correlation_id,
|
||||
causation_id=received_event.id,
|
||||
idempotency_key=f"receipt-associated:{receipt.id}:{claim.id}",
|
||||
payload={
|
||||
"claim_id": claim.id,
|
||||
"claim_no": claim.claim_no,
|
||||
"item_id": item_id,
|
||||
},
|
||||
)
|
||||
|
||||
def _resolve_target_item(
|
||||
self,
|
||||
*,
|
||||
claim_id: str,
|
||||
receipt: ReceiptFolderDetailRead,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseClaimItem:
|
||||
claim = self.claim_service.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
raise ValueError("匹配到的报销草稿不存在,请刷新后再试。")
|
||||
|
||||
preferred_type = resolve_receipt_item_type(receipt)
|
||||
empty_items = [
|
||||
item
|
||||
for item in list(claim.items or [])
|
||||
if not str(item.invoice_id or "").strip() and not item.is_system_generated
|
||||
]
|
||||
for item in empty_items:
|
||||
if preferred_type and str(item.item_type or "").strip() == preferred_type:
|
||||
return item
|
||||
if empty_items:
|
||||
return empty_items[0]
|
||||
|
||||
before_ids = {str(item.id) for item in list(claim.items or [])}
|
||||
created_claim = self.claim_service.create_claim_item(
|
||||
claim_id=claim.id,
|
||||
payload=build_item_payload_from_receipt(claim, receipt, preferred_type),
|
||||
current_user=current_user,
|
||||
commit=False,
|
||||
write_audit=False,
|
||||
refresh_pre_review=False,
|
||||
)
|
||||
if created_claim is None:
|
||||
raise ValueError("无法创建票据归集明细,请进入详情页手动处理。")
|
||||
for item in list(created_claim.items or []):
|
||||
if str(item.id) not in before_ids and not str(item.invoice_id or "").strip():
|
||||
return item
|
||||
raise ValueError("无法找到可归集的费用明细,请进入详情页手动处理。")
|
||||
|
||||
@staticmethod
|
||||
def _is_linked_to_claim(receipt: ReceiptFolderDetailRead, claim_id: str) -> bool:
|
||||
return (
|
||||
str(receipt.status or "").strip().lower() == "linked"
|
||||
and str(receipt.linked_claim_id or "").strip() == str(claim_id or "").strip()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_linked_to_other_claim(receipt: ReceiptFolderDetailRead, claim_id: str) -> bool:
|
||||
linked_claim_id = str(receipt.linked_claim_id or "").strip()
|
||||
return bool(
|
||||
str(receipt.status or "").strip().lower() == "linked"
|
||||
and linked_claim_id
|
||||
and linked_claim_id != str(claim_id or "").strip()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_confirmation_result(match: Any) -> dict[str, Any]:
|
||||
recommended = match.recommended
|
||||
return {
|
||||
"resolution": "requires_confirmation",
|
||||
"requires_confirmation": True,
|
||||
"claim_id": str(getattr(getattr(recommended, "claim", None), "id", "") or ""),
|
||||
"claim_no": str(getattr(getattr(recommended, "claim", None), "claim_no", "") or ""),
|
||||
"expense_case_id": str(
|
||||
getattr(getattr(recommended, "expense_case", None), "id", "") or ""
|
||||
),
|
||||
"application_claim_id": str(
|
||||
getattr(getattr(recommended, "application_claim", None), "id", "") or ""
|
||||
),
|
||||
"application_claim_no": str(
|
||||
getattr(getattr(recommended, "application_claim", None), "claim_no", "") or ""
|
||||
),
|
||||
"confidence": str(getattr(recommended, "confidence", "low") or "low"),
|
||||
"confidence_score": float(getattr(recommended, "normalized_score", 0.0) or 0.0),
|
||||
"match_reasons": list(getattr(recommended, "reasons", []) or []),
|
||||
"exceptions": list(match.exceptions or []),
|
||||
"missing_fields": [],
|
||||
"risk_items": [],
|
||||
"candidates": [candidate.to_payload() for candidate in match.candidates],
|
||||
"uploaded_count": 0,
|
||||
"skipped_count": 0,
|
||||
"draft_payload": None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_success_result(
|
||||
*,
|
||||
match_candidate: ExpenseReceiptMatchCandidate,
|
||||
expense_case: ExpenseCase,
|
||||
claim: ExpenseClaim,
|
||||
uploaded_count: int,
|
||||
skipped_count: int,
|
||||
candidates: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
risk_items = [
|
||||
str(flag.get("message") or flag.get("label") or "").strip()
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
if isinstance(flag, dict)
|
||||
and str(flag.get("severity") or "").strip().lower() in {"warning", "high", "critical"}
|
||||
and str(flag.get("message") or flag.get("label") or "").strip()
|
||||
]
|
||||
application = match_candidate.application_claim
|
||||
return {
|
||||
"resolution": "auto_associated",
|
||||
"requires_confirmation": False,
|
||||
"claim_id": str(claim.id or ""),
|
||||
"claim_no": str(claim.claim_no or ""),
|
||||
"expense_case_id": str(expense_case.id or ""),
|
||||
"application_claim_id": str(getattr(application, "id", "") or ""),
|
||||
"application_claim_no": str(getattr(application, "claim_no", "") or ""),
|
||||
"confidence": match_candidate.confidence,
|
||||
"confidence_score": match_candidate.normalized_score,
|
||||
"match_reasons": list(match_candidate.reasons),
|
||||
"exceptions": [],
|
||||
"missing_fields": [],
|
||||
"risk_items": risk_items,
|
||||
"candidates": candidates,
|
||||
"uploaded_count": uploaded_count,
|
||||
"skipped_count": skipped_count,
|
||||
"draft_payload": {
|
||||
"draft_type": "expense",
|
||||
"title": f"费用草稿 {claim.claim_no}",
|
||||
"body": f"已自动归集 {uploaded_count} 份票据。",
|
||||
"confirmation_required": bool(risk_items),
|
||||
"claim_id": claim.id,
|
||||
"claim_no": claim.claim_no,
|
||||
"status": claim.status,
|
||||
"approval_stage": claim.approval_stage,
|
||||
"expense_type": claim.expense_type,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _claim_execution_lock(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
claim_id: str,
|
||||
):
|
||||
lock_key = f"claim:{str(tenant_id or 'default').strip() or 'default'}:{claim_id}"
|
||||
with _claim_locks_guard:
|
||||
process_lock = _claim_locks.setdefault(lock_key, Lock())
|
||||
process_lock.acquire()
|
||||
|
||||
advisory_key: int | None = None
|
||||
try:
|
||||
# 初次匹配产生的只读事务可能早于互斥锁;进入锁后必须读取最新提交状态。
|
||||
db.rollback()
|
||||
db.expire_all()
|
||||
if db.get_bind().dialect.name == "postgresql":
|
||||
advisory_key = _stable_advisory_lock_key(lock_key)
|
||||
db.execute(
|
||||
text("SELECT pg_advisory_lock(:lock_key)"),
|
||||
{"lock_key": advisory_key},
|
||||
)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
if advisory_key is not None:
|
||||
db.rollback()
|
||||
db.execute(
|
||||
text("SELECT pg_advisory_unlock(:lock_key)"),
|
||||
{"lock_key": advisory_key},
|
||||
)
|
||||
except Exception:
|
||||
# 连接关闭也会释放会话级锁,解锁失败不能覆盖原始业务异常。
|
||||
db.rollback()
|
||||
finally:
|
||||
process_lock.release()
|
||||
|
||||
|
||||
def _stable_advisory_lock_key(value: str) -> int:
|
||||
return int.from_bytes(
|
||||
hashlib.sha256(value.encode("utf-8")).digest()[:8],
|
||||
byteorder="big",
|
||||
signed=True,
|
||||
)
|
||||
|
||||
|
||||
def resolve_receipt_item_type(receipt: ReceiptFolderDetailRead) -> str:
|
||||
document_type = str(receipt.document_type or "").strip()
|
||||
if document_type in DOCUMENT_TYPE_ITEM_TYPE_MAP:
|
||||
return DOCUMENT_TYPE_ITEM_TYPE_MAP[document_type]
|
||||
scene_code = str(receipt.scene_code or "").strip()
|
||||
return "travel" if scene_code == "travel" else scene_code or "other"
|
||||
|
||||
|
||||
def build_item_payload_from_receipt(
|
||||
claim: ExpenseClaim,
|
||||
receipt: ReceiptFolderDetailRead,
|
||||
preferred_type: str,
|
||||
) -> ExpenseClaimItemCreate:
|
||||
item_date = resolve_receipt_item_date(receipt) or (
|
||||
claim.occurred_at.date() if claim.occurred_at else None
|
||||
)
|
||||
return ExpenseClaimItemCreate(
|
||||
item_date=item_date,
|
||||
item_type=preferred_type or str(claim.expense_type or "").strip() or "other",
|
||||
item_reason=str(receipt.summary or receipt.file_name or "").strip(),
|
||||
item_location=resolve_receipt_item_location(receipt) or str(claim.location or "").strip(),
|
||||
item_amount=Decimal("0.00"),
|
||||
)
|
||||
|
||||
|
||||
def resolve_receipt_item_date(receipt: ReceiptFolderDetailRead) -> date | None:
|
||||
for value in [
|
||||
*[
|
||||
field.value
|
||||
for field in list(receipt.fields or [])
|
||||
if "日期" in str(field.label or "") or "时间" in str(field.label or "")
|
||||
],
|
||||
receipt.document_date,
|
||||
]:
|
||||
token = normalize_date_token(value)
|
||||
if len(token) == 10:
|
||||
try:
|
||||
return date.fromisoformat(token)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def resolve_receipt_item_location(receipt: ReceiptFolderDetailRead) -> str:
|
||||
for field in list(receipt.fields or []):
|
||||
label = str(field.label or "")
|
||||
value = str(field.value or "").strip()
|
||||
if value and ("行程" in label or "到达" in label or "地点" in label or "城市" in label):
|
||||
cities = extract_city_tokens(value)
|
||||
return cities[-1] if cities else value[:40]
|
||||
cities = extract_city_tokens(
|
||||
"\n".join(
|
||||
str(value or "") for value in (receipt.file_name, receipt.summary, receipt.ocr_text)
|
||||
)
|
||||
)
|
||||
return cities[-1] if cities else ""
|
||||
Reference in New Issue
Block a user