feat(expense): add persistent zero-entry receipt association

This commit is contained in:
caoxiaozhu
2026-07-16 10:23:23 +08:00
parent 54754b5502
commit ae3f02c35a
30 changed files with 4450 additions and 810 deletions

View File

@@ -0,0 +1,267 @@
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.attachment_association_job import AttachmentAssociationJob
from app.schemas.attachment_association_job import (
AttachmentAssociationJobCreate,
AttachmentAssociationJobRead,
)
JOB_LEASE_SECONDS = 300
@dataclass(slots=True)
class ClaimedAttachmentAssociationJob:
job_id: str
receipt_ids: list[str]
current_user: CurrentUserContext
attempt_count: int
def create_persistent_job(
db: Session,
payload: AttachmentAssociationJobCreate,
current_user: CurrentUserContext,
) -> AttachmentAssociationJobRead:
tenant_id = normalize_tenant_id(current_user.tenant_id)
owner_username = str(current_user.username or current_user.name or "").strip()
receipt_ids = list(payload.receipt_ids)
dedupe_key = build_job_dedupe_key(receipt_ids)
existing = db.scalar(
_latest_generation_query(
tenant_id=tenant_id,
owner_username=owner_username,
dedupe_key=dedupe_key,
)
)
if existing is not None and _can_reuse_job(existing):
return job_to_read(existing)
generation = int(existing.generation or 0) + 1 if existing is not None else 1
job = AttachmentAssociationJob(
id=f"attachment-association-{uuid4()}",
tenant_id=tenant_id,
owner_username=owner_username,
owner_name=str(current_user.name or "").strip(),
owner_context_json=serialize_current_user(current_user),
dedupe_key=dedupe_key,
generation=generation,
receipt_ids_json=receipt_ids,
prompt=str(payload.prompt or "").strip(),
conversation_id=str(payload.conversation_id or "").strip(),
status="queued",
message="已创建附件关联任务,等待后台处理。",
)
db.add(job)
try:
db.commit()
except IntegrityError:
db.rollback()
existing = db.scalar(
_latest_generation_query(
tenant_id=tenant_id,
owner_username=owner_username,
dedupe_key=dedupe_key,
)
)
if existing is None:
raise
return job_to_read(existing)
db.refresh(job)
return job_to_read(job)
def get_authorized_persistent_job(
db: Session,
job_id: str,
current_user: CurrentUserContext,
) -> AttachmentAssociationJob | None:
job = db.get(AttachmentAssociationJob, str(job_id or "").strip())
if job is None or job.tenant_id != normalize_tenant_id(current_user.tenant_id):
return None
if current_user.is_admin:
return job
return job if job.owner_username == str(current_user.username or "").strip() else None
def claim_persistent_job(
db: Session,
job_id: str,
) -> ClaimedAttachmentAssociationJob | None:
job = db.scalar(
select(AttachmentAssociationJob)
.where(AttachmentAssociationJob.id == str(job_id or "").strip())
.with_for_update()
)
if job is None or job.status in {"succeeded", "failed"}:
db.rollback()
return None
now = datetime.now(UTC)
lease_expires_at = as_utc(job.lease_expires_at)
if job.status == "running" and lease_expires_at is not None and lease_expires_at > now:
db.rollback()
return None
job.status = "running"
job.message = "正在匹配费用事件和可关联草稿..."
job.attempt_count = int(job.attempt_count or 0) + 1
job.lease_expires_at = now + timedelta(seconds=JOB_LEASE_SECONDS)
job.updated_at = now
claimed = ClaimedAttachmentAssociationJob(
job_id=job.id,
receipt_ids=[str(item) for item in list(job.receipt_ids_json or []) if str(item)],
current_user=deserialize_current_user(job.owner_context_json),
attempt_count=job.attempt_count,
)
db.commit()
return claimed
def update_persistent_job(
db: Session,
job_id: str,
*,
expected_attempt_count: int,
**updates: Any,
) -> None:
job = db.scalar(
select(AttachmentAssociationJob)
.where(
AttachmentAssociationJob.id == str(job_id or "").strip(),
AttachmentAssociationJob.attempt_count == expected_attempt_count,
AttachmentAssociationJob.status == "running",
)
.with_for_update()
)
if job is None:
db.rollback()
return
for key, value in updates.items():
if hasattr(job, key):
setattr(job, key, value)
job.lease_expires_at = None
job.updated_at = datetime.now(UTC)
db.commit()
def job_to_read(job: AttachmentAssociationJob) -> AttachmentAssociationJobRead:
return AttachmentAssociationJobRead(
job_id=job.id,
status=job.status,
message=job.message,
receipt_ids=list(job.receipt_ids_json or []),
claim_id=job.claim_id,
claim_no=job.claim_no,
uploaded_count=job.uploaded_count,
skipped_count=job.skipped_count,
resolution=job.resolution,
requires_confirmation=job.requires_confirmation,
expense_case_id=job.expense_case_id,
application_claim_id=job.application_claim_id,
application_claim_no=job.application_claim_no,
confidence=job.confidence,
confidence_score=job.confidence_score,
match_reasons=list(job.match_reasons_json or []),
exceptions=list(job.exceptions_json or []),
missing_fields=list(job.missing_fields_json or []),
risk_items=list(job.risk_items_json or []),
candidates=list(job.candidates_json or []),
draft_payload=dict(job.draft_payload_json) if job.draft_payload_json else None,
error=job.error,
prompt=job.prompt,
conversation_id=job.conversation_id,
created_at=job.created_at,
updated_at=job.updated_at,
)
def _latest_generation_query(
*,
tenant_id: str,
owner_username: str,
dedupe_key: str,
):
return (
select(AttachmentAssociationJob)
.where(
AttachmentAssociationJob.tenant_id == tenant_id,
AttachmentAssociationJob.owner_username == owner_username,
AttachmentAssociationJob.dedupe_key == dedupe_key,
)
.order_by(
AttachmentAssociationJob.generation.desc(),
AttachmentAssociationJob.created_at.desc(),
)
.limit(1)
)
def _can_reuse_job(job: AttachmentAssociationJob) -> bool:
if job.status in {"queued", "running"}:
return True
return (
job.status == "succeeded"
and not bool(job.requires_confirmation)
and str(job.resolution or "").strip() == "auto_associated"
)
def build_job_dedupe_key(receipt_ids: list[str]) -> str:
normalized = "\n".join(sorted({str(item or "").strip() for item in receipt_ids if item}))
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
def normalize_tenant_id(value: str | None) -> str:
return str(value or "default").strip() or "default"
def as_utc(value: datetime | None) -> datetime | None:
if value is None:
return None
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
def serialize_current_user(current_user: CurrentUserContext) -> dict[str, Any]:
return {
"username": current_user.username,
"name": current_user.name,
"role_codes": list(current_user.role_codes or []),
"is_admin": bool(current_user.is_admin),
"tenant_id": normalize_tenant_id(current_user.tenant_id),
"department_name": current_user.department_name,
"cost_center": current_user.cost_center,
"position": current_user.position,
"grade": current_user.grade,
"employee_no": current_user.employee_no,
"manager_name": current_user.manager_name,
"employee_id": current_user.employee_id,
"auth_session_id": current_user.auth_session_id,
}
def deserialize_current_user(payload: dict[str, Any] | None) -> CurrentUserContext:
data = dict(payload or {})
return CurrentUserContext(
username=str(data.get("username") or "").strip(),
name=str(data.get("name") or "").strip(),
role_codes=[str(item) for item in list(data.get("role_codes") or []) if str(item)],
is_admin=bool(data.get("is_admin")),
tenant_id=normalize_tenant_id(str(data.get("tenant_id") or "default")),
department_name=str(data.get("department_name") or ""),
cost_center=str(data.get("cost_center") or ""),
position=str(data.get("position") or ""),
grade=str(data.get("grade") or ""),
employee_no=str(data.get("employee_no") or ""),
manager_name=str(data.get("manager_name") or ""),
employee_id=str(data.get("employee_id") or ""),
auth_session_id=str(data.get("auth_session_id") or ""),
)

View File

@@ -1,222 +1,124 @@
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import UTC, date, datetime
from decimal import Decimal
import hashlib
from collections.abc import Callable
from contextlib import contextmanager
from threading import Lock
from typing import Any, Callable
from uuid import uuid4
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session, sessionmaker
from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.attachment_association_job import (
AttachmentAssociationJobCreate,
AttachmentAssociationJobRead,
)
from app.schemas.receipt_folder import ReceiptFolderDetailRead
from app.schemas.reimbursement import ExpenseClaimItemCreate
from app.services.expense_claim_constants import (
DOCUMENT_TYPE_ITEM_TYPE_MAP,
EDITABLE_CLAIM_STATUSES,
from app.services.attachment_association_job_store import (
ClaimedAttachmentAssociationJob,
claim_persistent_job,
create_persistent_job,
get_authorized_persistent_job,
job_to_read,
update_persistent_job,
)
from app.services.expense_claims import ExpenseClaimService
from app.services.expense_receipt_association import ExpenseReceiptAssociationService
from app.services.receipt_folder import ReceiptFolderService
SessionFactory = sessionmaker[Session] | Callable[[], Session]
CITY_NAMES = (
"北京",
"上海",
"广州",
"深圳",
"武汉",
"南京",
"杭州",
"成都",
"重庆",
"西安",
"天津",
"苏州",
"长沙",
"郑州",
"青岛",
"厦门",
"宁波",
"无锡",
"合肥",
"福州",
"昆明",
"大连",
"沈阳",
"济南",
"哈尔滨",
"长春",
"南昌",
"太原",
"贵阳",
"南宁",
"石家庄",
"兰州",
"银川",
"西宁",
"海口",
"拉萨",
)
TERMINAL_STATUSES = {"succeeded", "failed"}
@dataclass(slots=True)
class AttachmentAssociationJobState:
job_id: str
owner_username: str
owner_name: str
receipt_ids: list[str]
prompt: str = ""
conversation_id: str = ""
status: str = "queued"
message: str = "已创建附件关联任务,等待后台处理。"
claim_id: str = ""
claim_no: str = ""
uploaded_count: int = 0
skipped_count: int = 0
error: str = ""
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
def to_read(self) -> AttachmentAssociationJobRead:
return AttachmentAssociationJobRead(
job_id=self.job_id,
status=self.status,
message=self.message,
receipt_ids=list(self.receipt_ids),
claim_id=self.claim_id,
claim_no=self.claim_no,
uploaded_count=self.uploaded_count,
skipped_count=self.skipped_count,
error=self.error,
prompt=self.prompt,
conversation_id=self.conversation_id,
created_at=self.created_at,
updated_at=self.updated_at,
)
@dataclass(slots=True)
class AttachmentAssociationCandidate:
claim: ExpenseClaim
score: int
reasons: list[str]
_jobs: dict[str, AttachmentAssociationJobState] = {}
_jobs_lock = Lock()
_receipt_locks: dict[str, Lock] = {}
_job_claim_locks: dict[str, Lock] = {}
_receipt_locks_guard = Lock()
def clear_attachment_association_jobs_for_tests() -> None:
with _jobs_lock:
_jobs.clear()
"""测试数据库按用例隔离;这里只清理进程级互斥锁。"""
with _receipt_locks_guard:
_receipt_locks.clear()
_job_claim_locks.clear()
def create_attachment_association_job(
payload: AttachmentAssociationJobCreate,
current_user: CurrentUserContext,
db: Session,
) -> AttachmentAssociationJobRead:
job_id = f"attachment-association-{uuid4()}"
state = AttachmentAssociationJobState(
job_id=job_id,
owner_username=str(current_user.username or "").strip(),
owner_name=str(current_user.name or "").strip(),
receipt_ids=list(payload.receipt_ids),
prompt=str(payload.prompt or "").strip(),
conversation_id=str(payload.conversation_id or "").strip(),
)
with _jobs_lock:
_jobs[job_id] = state
return state.to_read()
return create_persistent_job(db, payload, current_user)
def get_attachment_association_job(
job_id: str,
current_user: CurrentUserContext,
db: Session,
) -> AttachmentAssociationJobRead | None:
state = _get_authorized_state(job_id, current_user)
return state.to_read() if state is not None else None
job = get_authorized_persistent_job(db, job_id, current_user)
return job_to_read(job) if job is not None else None
def run_attachment_association_job(
job_id: str,
current_user: CurrentUserContext,
session_factory: sessionmaker[Session] | Callable[[], Session],
session_factory: SessionFactory,
) -> None:
state = _get_authorized_state(job_id, current_user)
if state is None or state.status in TERMINAL_STATUSES:
with _receipt_locks_guard:
claim_lock = _job_claim_locks.setdefault(str(job_id or "").strip(), Lock())
with claim_lock:
with session_factory() as claim_db:
claimed = claim_persistent_job(claim_db, job_id)
if claimed is None:
return
_update_job(job_id, status="running", message="正在匹配可关联的报销草稿...")
try:
with session_factory() as db:
result = AttachmentAssociationJobRunner(db).run(
receipt_ids=state.receipt_ids,
current_user=current_user,
with _receipt_execution_locks(db, claimed):
result = AttachmentAssociationJobRunner(db).run(
receipt_ids=claimed.receipt_ids,
current_user=claimed.current_user,
)
requires_confirmation = bool(result.get("requires_confirmation"))
if requires_confirmation:
exceptions = [
str(item) for item in list(result.get("exceptions") or []) if str(item).strip()
]
message = exceptions[0] if exceptions else "匹配结果需要确认,系统未修改任何报销数据。"
else:
uploaded_count = int(result.get("uploaded_count") or 0)
skipped_count = int(result.get("skipped_count") or 0)
if uploaded_count == 0 and skipped_count > 0:
message = "票据此前已经归集到目标草稿,本次未重复写入。"
else:
message = (
f"已自动关联到 {result.get('claim_no') or '报销草稿'}"
f"成功归集 {uploaded_count} 份附件。"
)
with session_factory() as update_db:
update_persistent_job(
update_db,
job_id,
expected_attempt_count=claimed.attempt_count,
status="succeeded",
message=message,
error="",
**_job_result_updates(result),
)
_update_job(
job_id,
status="succeeded",
message=f"已自动关联到 {result['claim_no']},成功归集 {result['uploaded_count']} 份附件。",
claim_id=str(result["claim_id"]),
claim_no=str(result["claim_no"]),
uploaded_count=int(result["uploaded_count"]),
skipped_count=int(result["skipped_count"]),
error="",
)
except Exception as exc:
message = str(exc).strip() or "自动关联任务执行失败,请稍后重试。"
_update_job(
job_id,
status="failed",
message=message,
error=message,
)
def _get_authorized_state(
job_id: str,
current_user: CurrentUserContext,
) -> AttachmentAssociationJobState | None:
normalized_job_id = str(job_id or "").strip()
with _jobs_lock:
state = _jobs.get(normalized_job_id)
if state is None:
return None
if current_user.is_admin:
return state
username = str(current_user.username or "").strip()
name = str(current_user.name or "").strip()
if username and username == state.owner_username:
return state
if name and name == state.owner_name:
return state
return None
def _update_job(job_id: str, **updates: Any) -> None:
with _jobs_lock:
state = _jobs.get(str(job_id or "").strip())
if state is None:
return
for key, value in updates.items():
if hasattr(state, key):
setattr(state, key, value)
state.updated_at = datetime.now(UTC)
with session_factory() as update_db:
update_persistent_job(
update_db,
job_id,
expected_attempt_count=claimed.attempt_count,
status="failed",
message=message,
error=message,
resolution="failed",
)
class AttachmentAssociationJobRunner:
def __init__(self, db: Session) -> None:
self.db = db
self.claim_service = ExpenseClaimService(db)
self.receipt_service = ReceiptFolderService()
def run(
@@ -226,57 +128,23 @@ class AttachmentAssociationJobRunner:
current_user: CurrentUserContext,
) -> dict[str, Any]:
receipts = self._load_receipts(receipt_ids, current_user)
candidates = self._rank_claims(receipts, current_user)
if not candidates:
raise ValueError("没有找到可自动关联的报销草稿,请先新建草稿或补充说明。")
recommended = candidates[0]
runner_up = candidates[1] if len(candidates) > 1 else None
if recommended.score < 5 or (runner_up is not None and recommended.score - runner_up.score < 2):
raise ValueError("找到多个可能关联的报销草稿,请补充说明或手动选择后再归集。")
uploaded_count = 0
skipped_count = 0
for receipt in receipts:
if self._is_linked_to_other_claim(receipt, recommended.claim.id):
skipped_count += 1
continue
target_item = self._resolve_target_item(
claim_id=recommended.claim.id,
receipt=receipt,
current_user=current_user,
)
source_path, media_type, file_name = self.receipt_service.resolve_source(receipt.id, current_user)
result = self.claim_service.upload_claim_item_attachment(
claim_id=recommended.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,
)
if result is None:
skipped_count += 1
else:
uploaded_count += 1
if uploaded_count <= 0:
raise ValueError("未能归集任何附件,请进入报销单详情手动核对。")
return {
"claim_id": recommended.claim.id,
"claim_no": recommended.claim.claim_no,
"uploaded_count": uploaded_count,
"skipped_count": skipped_count,
}
return ExpenseReceiptAssociationService(self.db).associate(
receipts=receipts,
current_user=current_user,
)
def _load_receipts(
self,
receipt_ids: list[str],
current_user: CurrentUserContext,
) -> list[ReceiptFolderDetailRead]:
receipts = []
for receipt_id in list(dict.fromkeys(str(item or "").strip() for item in receipt_ids if str(item or "").strip())):
receipts: list[ReceiptFolderDetailRead] = []
normalized_ids = list(
dict.fromkeys(
str(item or "").strip() for item in receipt_ids if str(item or "").strip()
)
)
for receipt_id in normalized_ids:
try:
receipts.append(self.receipt_service.get_receipt(receipt_id, current_user))
except FileNotFoundError as exc:
@@ -285,265 +153,87 @@ class AttachmentAssociationJobRunner:
raise ValueError("当前附件没有持久化票据记录,请重新上传后再试。")
return receipts
def _rank_claims(
self,
receipts: list[ReceiptFolderDetailRead],
current_user: CurrentUserContext,
) -> list[AttachmentAssociationCandidate]:
signals = _collect_receipt_signals(receipts)
claims = [
claim
for claim in self.claim_service.list_claims(current_user)
if self._is_auto_association_candidate(claim)
]
ranked = [
candidate
for candidate in (
self._score_claim(claim, signals)
for claim in claims
)
if candidate.score > 0
]
return sorted(ranked, key=lambda item: item.score, reverse=True)
def _is_auto_association_candidate(self, claim: ExpenseClaim) -> bool:
status = str(claim.status or "").strip().lower()
if status not in EDITABLE_CLAIM_STATUSES:
return False
return not self.claim_service._is_expense_application_claim(claim)
@contextmanager
def _receipt_execution_locks(
db: Session,
claimed: ClaimedAttachmentAssociationJob,
):
lock_keys = sorted(
{
f"{claimed.current_user.tenant_id}:{receipt_id}"
for receipt_id in claimed.receipt_ids
if receipt_id
}
)
with _receipt_locks_guard:
process_locks = [_receipt_locks.setdefault(key, Lock()) for key in lock_keys]
for process_lock in process_locks:
process_lock.acquire()
def _score_claim(
self,
claim: ExpenseClaim,
signals: dict[str, Any],
) -> AttachmentAssociationCandidate:
claim_text = _build_claim_text(claim)
compact_claim_text = _normalize_text(claim_text)
claim_dates = _extract_date_tokens(claim_text)
claim_cities = _unique([*_extract_city_tokens(claim_text), *_extract_city_tokens(claim.location)])
reasons: list[str] = []
score = 0
if _dates_overlap(signals["dates"], claim_dates):
score += 4
reasons.append("票据日期与报销单日期一致")
matched_cities = [city for city in signals["cities"] if city in compact_claim_text]
if matched_cities:
score += min(4, len(matched_cities) * 2)
reasons.append(f"地点或行程包含 {''.join(matched_cities)}")
if len(claim_cities) >= 2 and len(matched_cities) >= 2:
score += 2
reasons.append("票据往返城市与报销事由吻合")
if str(claim.status or "").strip().lower() == "draft":
score += 1
reasons.append("当前单据仍是可归集草稿")
return AttachmentAssociationCandidate(claim=claim, score=score, reasons=reasons)
@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() == "linked" and linked_claim_id and linked_claim_id != claim_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,
)
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("无法找到可归集的费用明细,请进入详情页手动处理。")
advisory_keys: list[int] = []
try:
if db.get_bind().dialect.name == "postgresql":
advisory_keys = [_advisory_lock_key(key) for key in lock_keys]
for advisory_key in advisory_keys:
db.execute(
text("SELECT pg_advisory_lock(:lock_key)"),
{"lock_key": advisory_key},
)
yield
finally:
try:
if db.get_bind().dialect.name == "postgresql":
# 业务异常可能让当前事务失效;会话级 advisory lock 必须在可用事务中解锁。
db.rollback()
for advisory_key in reversed(advisory_keys):
db.execute(
text("SELECT pg_advisory_unlock(:lock_key)"),
{"lock_key": advisory_key},
)
except Exception:
# 连接关闭也会释放会话级锁,不能让解锁异常覆盖原始业务异常。
db.rollback()
finally:
for process_lock in reversed(process_locks):
process_lock.release()
def _normalize_text(value: Any) -> str:
return re.sub(r"\s+", "", str(value or "").strip())
def _advisory_lock_key(value: str) -> int:
return int.from_bytes(
hashlib.sha256(value.encode("utf-8")).digest()[:8],
byteorder="big",
signed=True,
)
def _unique(values: list[str] | tuple[str, ...]) -> list[str]:
return list(dict.fromkeys(str(item or "").strip() for item in values if str(item or "").strip()))
def _extract_date_tokens(text: Any) -> list[str]:
source = str(text or "")
matches = [
*re.finditer(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}", source),
*re.finditer(r"\d{1,2}月\d{1,2}", source),
]
return _unique([_normalize_date_token(match.group(0)) for match in matches])
def _normalize_date_token(value: Any) -> str:
if isinstance(value, (date, datetime)):
return value.isoformat()[:10]
text = str(value or "").strip()
full_match = re.search(r"(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})", text)
if full_match:
year, month, day = full_match.groups()
return f"{year}-{month.zfill(2)}-{day.zfill(2)}"
short_match = re.search(r"(\d{1,2})月(\d{1,2})", text)
if short_match:
month, day = short_match.groups()
return f"{month.zfill(2)}-{day.zfill(2)}"
return ""
def _extract_city_tokens(text: Any) -> list[str]:
compact = _normalize_text(text)
if not compact:
return []
return [city for city in CITY_NAMES if city in compact]
def _dates_overlap(left: list[str], right: list[str]) -> bool:
for left_date in left:
if not left_date:
continue
for right_date in right:
if right_date and (left_date == right_date or left_date.endswith(right_date) or right_date.endswith(left_date)):
return True
return False
def _collect_receipt_signals(receipts: list[ReceiptFolderDetailRead]) -> dict[str, Any]:
text = "\n".join(_build_receipt_text(receipt) for receipt in receipts)
dates = _unique([
*_extract_date_tokens(text),
*[str(receipt.document_date or "").strip() for receipt in receipts],
])
def _job_result_updates(result: dict[str, Any]) -> dict[str, Any]:
return {
"text": text,
"dates": dates,
"cities": _unique(_extract_city_tokens(text)),
"claim_id": str(result.get("claim_id") or ""),
"claim_no": str(result.get("claim_no") or ""),
"uploaded_count": int(result.get("uploaded_count") or 0),
"skipped_count": int(result.get("skipped_count") or 0),
"resolution": str(result.get("resolution") or ""),
"requires_confirmation": bool(result.get("requires_confirmation")),
"expense_case_id": str(result.get("expense_case_id") or ""),
"application_claim_id": str(result.get("application_claim_id") or ""),
"application_claim_no": str(result.get("application_claim_no") or ""),
"confidence": str(result.get("confidence") or ""),
"confidence_score": float(result.get("confidence_score") or 0.0),
"match_reasons_json": [
str(item) for item in list(result.get("match_reasons") or [])
],
"exceptions_json": [str(item) for item in list(result.get("exceptions") or [])],
"missing_fields_json": [
str(item) for item in list(result.get("missing_fields") or [])
],
"risk_items_json": [str(item) for item in list(result.get("risk_items") or [])],
"candidates_json": [
dict(item) for item in list(result.get("candidates") or []) if isinstance(item, dict)
],
"draft_payload_json": (
dict(result["draft_payload"])
if isinstance(result.get("draft_payload"), dict)
else None
),
}
def _build_receipt_text(receipt: ReceiptFolderDetailRead) -> str:
fields_text = "\n".join(
f"{field.label} {field.value}"
for field in list(receipt.fields or [])
if str(field.label or field.value or "").strip()
)
return "\n".join(
value
for value in (
receipt.file_name,
receipt.summary,
receipt.ocr_text,
receipt.document_date,
receipt.merchant_name,
fields_text,
)
if str(value or "").strip()
)
def _build_claim_text(claim: ExpenseClaim) -> str:
item_text = "\n".join(
" ".join(
str(value or "").strip()
for value in (
item.item_date.isoformat() if item.item_date else "",
item.item_type,
item.item_reason,
item.item_location,
item.item_note,
)
if str(value or "").strip()
)
for item in list(claim.items or [])
)
occurred_at = claim.occurred_at.isoformat()[:10] if claim.occurred_at else ""
return "\n".join(
value
for value in (
claim.claim_no,
claim.expense_type,
claim.status,
claim.reason,
claim.location,
occurred_at,
item_text,
)
if str(value or "").strip()
)
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()
if scene_code == "travel":
return "travel"
return 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(_build_receipt_text(receipt))
return cities[-1] if cities else ""

View File

@@ -135,6 +135,113 @@ class ExpenseCaseService:
self.db.flush()
return link
def link_resource(
self,
expense_case: ExpenseCase,
*,
resource_type: str,
resource_id: str,
relation_type: str,
tenant_id: str | None = None,
) -> ExpenseCaseLink:
"""把非 Claim 资源幂等关联到费用事件,不负责提交事务。"""
normalized_tenant = self.normalize_tenant_id(tenant_id or expense_case.tenant_id)
normalized_type = str(resource_type or "").strip()
normalized_id = str(resource_id or "").strip()
normalized_relation = str(relation_type or "").strip()
if expense_case.tenant_id != normalized_tenant:
raise PermissionError("不能把资源关联到其他租户的费用事件。")
if not normalized_type or not normalized_id or not normalized_relation:
raise ValueError("费用事件资源关联缺少必要字段。")
existing_link = self.db.scalar(
select(ExpenseCaseLink).where(
ExpenseCaseLink.resource_type == normalized_type,
ExpenseCaseLink.resource_id == normalized_id,
)
)
if existing_link is not None:
if (
existing_link.tenant_id != normalized_tenant
or existing_link.expense_case_id != expense_case.id
):
raise PermissionError("资源已经关联到其他费用事件。")
return existing_link
link = ExpenseCaseLink(
id=str(uuid.uuid4()),
tenant_id=normalized_tenant,
expense_case_id=expense_case.id,
resource_type=normalized_type,
resource_id=normalized_id,
relation_type=normalized_relation,
)
self.db.add(link)
self.db.flush()
return link
def record_resource_event(
self,
expense_case: ExpenseCase,
*,
aggregate_type: str,
aggregate_id: str,
event_type: str,
actor_id: str,
idempotency_key: str,
tenant_id: str | None = None,
correlation_id: str | None = None,
causation_id: str | None = None,
payload: dict[str, Any] | None = None,
delivery_status: str = "pending",
) -> BusinessEvent:
"""为票据等通用资源写入幂等业务事件,不负责提交事务。"""
normalized_tenant = self.normalize_tenant_id(tenant_id or expense_case.tenant_id)
normalized_type = str(aggregate_type or "").strip()
normalized_id = str(aggregate_id or "").strip()
normalized_event_type = str(event_type or "").strip()
if expense_case.tenant_id != normalized_tenant:
raise PermissionError("不能向其他租户的费用事件写入业务事件。")
if not normalized_type or not normalized_id or not normalized_event_type:
raise ValueError("业务事件缺少聚合或事件类型。")
normalized_idempotency_key = self._normalize_idempotency_key(idempotency_key)
existing_event = self.db.scalar(
select(BusinessEvent).where(
BusinessEvent.tenant_id == normalized_tenant,
BusinessEvent.aggregate_type == normalized_type,
BusinessEvent.aggregate_id == normalized_id,
BusinessEvent.event_type == normalized_event_type,
BusinessEvent.idempotency_key == normalized_idempotency_key,
)
)
if existing_event is not None:
if existing_event.expense_case_id != expense_case.id:
raise PermissionError("业务事件已经属于其他费用事件。")
return existing_event
normalized_correlation_id = self.normalize_correlation_id(correlation_id)
event = BusinessEvent(
id=str(uuid.uuid4()),
tenant_id=normalized_tenant,
expense_case_id=expense_case.id,
aggregate_type=normalized_type,
aggregate_id=normalized_id,
event_type=normalized_event_type,
event_version=1,
idempotency_key=normalized_idempotency_key,
correlation_id=normalized_correlation_id,
causation_id=self.normalize_correlation_id(causation_id) if causation_id else None,
actor_id=str(actor_id or "system").strip() or "system",
actor_type="system" if str(actor_id or "").strip() == "system" else "user",
payload_json=dict(payload or {}),
delivery_status=str(delivery_status or "pending").strip() or "pending",
occurred_at=datetime.now(UTC),
)
self.db.add(event)
self.db.flush()
return event
def record_claim_event(
self,
claim: ExpenseClaim,

View File

@@ -1,113 +1,14 @@
from __future__ import annotations
import json
import re
import shutil
import uuid
from collections import defaultdict
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal, InvalidOperation
from datetime import UTC, datetime
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from sqlalchemy import func, or_, select
from sqlalchemy import inspect as sqlalchemy_inspect
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, selectinload
from app.api.deps import CurrentUserContext
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
from app.models.agent_asset import AgentAsset
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.ontology import OntologyEntity, OntologyParseResult
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
from app.services.audit import AuditLogService
from app.services.document_preview import DocumentPreviewAssets
from app.services.document_intelligence import build_document_insight
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claim_constants import (
EXPENSE_TYPE_LABELS,
MAX_DRAFT_CLAIMS_PER_USER,
EDITABLE_CLAIM_STATUSES,
SYSTEM_GENERATED_ITEM_TYPES,
TRAVEL_DETAIL_ITEM_TYPES,
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
DOCUMENT_TYPE_ITEM_TYPE_MAP,
DOCUMENT_TYPE_SCENE_MAP,
DOCUMENT_FACT_ITEM_TYPES,
ROUTE_DESCRIPTION_ITEM_TYPES,
DOCUMENT_TRIP_DATE_LABELS,
DOCUMENT_TRIP_DATE_REQUIREMENT_LABELS,
DOCUMENT_TRIP_DATE_KEYS,
DOCUMENT_GENERIC_DATE_KEYS,
DOCUMENT_INVOICE_DATE_KEYS,
DOCUMENT_TRIP_DATE_LABEL_TOKENS,
DOCUMENT_GENERIC_DATE_LABEL_TOKENS,
DOCUMENT_INVOICE_DATE_LABEL_TOKENS,
DOCUMENT_ROUTE_FORMAT_PATTERN,
DOCUMENT_ROUTE_TEXT_PATTERN,
DOCUMENT_ROUTE_ORIGIN_LABELS,
DOCUMENT_ROUTE_DESTINATION_LABELS,
GENERIC_ATTACHMENT_BACKFILL_ITEM_TYPES,
LOCATION_REQUIRED_EXPENSE_TYPES,
EXPENSE_SCENE_KEYWORDS,
EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES,
DOCUMENT_SCENE_LABELS,
DOCUMENT_ASSOCIATION_REVIEW_ACTIONS,
PERSISTENT_EXPENSE_REVIEW_ACTIONS,
RETURN_REASON_OPTIONS,
MAX_CLAIM_NO_RETRY_ATTEMPTS,
DOCUMENT_DATE_PATTERN,
SYSTEM_GENERATED_REASON_PREFIXES,
LEADING_REASON_TIME_PATTERNS,
AI_REVIEW_LOOKBACK_DAYS,
AI_REVIEW_REPEAT_RISK_WARNING_COUNT,
AI_REVIEW_REPEAT_RISK_BLOCK_COUNT,
TRAVEL_REVIEW_RELEVANT_EXPENSE_TYPES,
TRAVEL_REVIEW_LONG_DISTANCE_DOCUMENT_TYPES,
TRAVEL_POLICY_CITY_TIERS,
TRAVEL_POLICY_CITY_MATCH_ORDER,
TRAVEL_POLICY_BAND_LABELS,
TRAVEL_POLICY_HOTEL_LIMITS,
TRAVEL_POLICY_ALLOWED_TRANSPORT_LEVELS,
TRAVEL_POLICY_ROUTE_EXCEPTION_KEYWORDS,
TRAVEL_POLICY_STANDARD_EXCEPTION_KEYWORDS,
TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS,
TRAVEL_POLICY_TRAIN_CLASS_PATTERNS,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
)
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
from app.services.expense_amounts import (
extract_amount_candidates,
format_decimal_amount,
is_amount_match_date_fragment,
is_date_like_amount_candidate,
is_probable_year_amount,
parse_document_amount_value,
parse_plain_document_amount_value,
resolve_document_field_amount,
resolve_document_item_amount,
resolve_document_text_amount,
)
from app.services.expense_rule_runtime import (
DEFAULT_SCENE_RULE_ASSET_CODE,
ExpenseRuleRuntimeService,
RuntimeTravelPolicy,
build_default_expense_rule_catalog,
resolve_document_type_label,
)
from app.services.ocr import OcrService
from app.services.receipt_folder import ReceiptFolderService
@@ -123,6 +24,10 @@ class ExpenseClaimAttachmentOperationsMixin:
media_type: str | None,
current_user: CurrentUserContext,
source_receipt_id: str = "",
commit: bool = True,
link_source_receipt: bool = True,
write_audit: bool = True,
refresh_pre_review: bool = True,
) -> dict[str, Any] | None:
claim, item = self._get_claim_item_or_raise(
claim_id=claim_id,
@@ -260,30 +165,36 @@ class ExpenseClaimAttachmentOperationsMixin:
"source_receipt_id": str(source_receipt_id or "").strip(),
}
self._attachment_storage.write_meta(file_path, meta)
ReceiptFolderService().save_linked_attachment(
file_path=file_path,
media_type=resolved_media_type,
document=ocr_document,
current_user=current_user,
claim_id=claim.id,
claim_no=claim.claim_no,
item_id=item.id,
source_receipt_id=source_receipt_id,
)
if link_source_receipt:
ReceiptFolderService().save_linked_attachment(
file_path=file_path,
media_type=resolved_media_type,
document=ocr_document,
current_user=current_user,
claim_id=claim.id,
claim_no=claim.claim_no,
item_id=item.id,
source_receipt_id=source_receipt_id,
)
self._sync_claim_from_items(claim)
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
self.db.commit()
self.db.refresh(claim)
if refresh_pre_review:
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
if commit:
self.db.commit()
self.db.refresh(claim)
else:
self.db.flush()
self.audit_service.log_action(
actor=current_user.name or current_user.username,
action="expense_claim.attachment_upload",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
if write_audit:
self.audit_service.log_action(
actor=current_user.name or current_user.username,
action="expense_claim.attachment_upload",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
return {
"message": f"{normalized_name} 已上传并关联到当前费用明细。",

View File

@@ -1,31 +1,21 @@
from __future__ import annotations
import json
import re
import shutil
import uuid
from collections.abc import Callable
from collections import defaultdict
from datetime import UTC, date, datetime, timedelta
from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from sqlalchemy import delete, func, or_, select
from sqlalchemy import inspect as sqlalchemy_inspect
from sqlalchemy.exc import IntegrityError
from sqlalchemy import delete, select
from sqlalchemy.orm import Session, selectinload
from app.api.deps import CurrentUserContext
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
from app.models.agent_asset import AgentAsset
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent
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,
ExpenseClaimItemUpdate,
@@ -33,114 +23,40 @@ from app.schemas.reimbursement import (
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
from app.services.audit import AuditLogService
from app.services.document_intelligence import build_document_insight
from app.services.document_numbering import is_application_claim_no
from app.services.budget_types import BudgetControlError
from app.services.document_numbering import is_application_claim_no
from app.services.expense_cases import ExpenseCaseService
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin
from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin
from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin
from app.services.expense_claim_attachment_analysis import ExpenseClaimAttachmentAnalysisMixin
from app.services.expense_claim_attachment_document import ExpenseClaimAttachmentDocumentMixin
from app.services.expense_claim_attachment_operations import ExpenseClaimAttachmentOperationsMixin
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
from app.services.expense_claim_workflow_constants import DIRECT_MANAGER_APPROVAL_STAGE
from app.services.expense_claim_workflow_repair import ExpenseClaimWorkflowRepairMixin
from app.services.expense_claim_constants import (
RETURN_REASON_OPTIONS,
STANDARD_ADJUSTMENT_RISK_SOURCE,
)
from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin
from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin
from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin
from app.services.expense_claim_draft_persistence import ExpenseClaimDraftPersistenceMixin
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin
from app.services.expense_claim_pagination import ExpenseClaimPaginationMixin
from app.services.expense_claim_pre_review import ExpenseClaimPreReviewMixin
from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
from app.services.receipt_folder import ReceiptFolderService
from app.services.expense_claim_constants import (
EXPENSE_TYPE_LABELS,
MAX_DRAFT_CLAIMS_PER_USER,
EDITABLE_CLAIM_STATUSES,
SYSTEM_GENERATED_ITEM_TYPES,
TRAVEL_DETAIL_ITEM_TYPES,
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
DOCUMENT_TYPE_ITEM_TYPE_MAP,
DOCUMENT_TYPE_SCENE_MAP,
DOCUMENT_FACT_ITEM_TYPES,
ROUTE_DESCRIPTION_ITEM_TYPES,
DOCUMENT_TRIP_DATE_LABELS,
DOCUMENT_TRIP_DATE_REQUIREMENT_LABELS,
DOCUMENT_TRIP_DATE_KEYS,
DOCUMENT_GENERIC_DATE_KEYS,
DOCUMENT_INVOICE_DATE_KEYS,
DOCUMENT_TRIP_DATE_LABEL_TOKENS,
DOCUMENT_GENERIC_DATE_LABEL_TOKENS,
DOCUMENT_INVOICE_DATE_LABEL_TOKENS,
DOCUMENT_ROUTE_FORMAT_PATTERN,
DOCUMENT_ROUTE_TEXT_PATTERN,
DOCUMENT_ROUTE_ORIGIN_LABELS,
DOCUMENT_ROUTE_DESTINATION_LABELS,
GENERIC_ATTACHMENT_BACKFILL_ITEM_TYPES,
LOCATION_REQUIRED_EXPENSE_TYPES,
EXPENSE_SCENE_KEYWORDS,
EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES,
DOCUMENT_SCENE_LABELS,
DOCUMENT_ASSOCIATION_REVIEW_ACTIONS,
PERSISTENT_EXPENSE_REVIEW_ACTIONS,
RETURN_REASON_OPTIONS,
MAX_CLAIM_NO_RETRY_ATTEMPTS,
DOCUMENT_DATE_PATTERN,
SYSTEM_GENERATED_REASON_PREFIXES,
LEADING_REASON_TIME_PATTERNS,
AI_REVIEW_LOOKBACK_DAYS,
AI_REVIEW_REPEAT_RISK_WARNING_COUNT,
AI_REVIEW_REPEAT_RISK_BLOCK_COUNT,
TRAVEL_REVIEW_RELEVANT_EXPENSE_TYPES,
TRAVEL_REVIEW_LONG_DISTANCE_DOCUMENT_TYPES,
TRAVEL_POLICY_CITY_TIERS,
TRAVEL_POLICY_CITY_MATCH_ORDER,
TRAVEL_POLICY_BAND_LABELS,
TRAVEL_POLICY_HOTEL_LIMITS,
TRAVEL_POLICY_ALLOWED_TRANSPORT_LEVELS,
TRAVEL_POLICY_ROUTE_EXCEPTION_KEYWORDS,
TRAVEL_POLICY_STANDARD_EXCEPTION_KEYWORDS,
TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS,
TRAVEL_POLICY_TRAIN_CLASS_PATTERNS,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
STANDARD_ADJUSTMENT_RISK_SOURCE,
)
from app.services.expense_cases import ExpenseCaseService
from app.services.expense_claim_risk_flags import dedupe_claim_risk_flags
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
from app.services.expense_amounts import (
extract_amount_candidates,
format_decimal_amount,
is_amount_match_date_fragment,
is_date_like_amount_candidate,
is_probable_year_amount,
parse_document_amount_value,
parse_plain_document_amount_value,
resolve_document_field_amount,
resolve_document_item_amount,
resolve_document_text_amount,
)
from app.services.expense_rule_runtime import (
DEFAULT_SCENE_RULE_ASSET_CODE,
ExpenseRuleRuntimeService,
RuntimeTravelPolicy,
build_default_expense_rule_catalog,
resolve_document_type_label,
)
from app.services.ocr import OcrService
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.expense_claim_workflow_constants import DIRECT_MANAGER_APPROVAL_STAGE
from app.services.expense_claim_workflow_repair import ExpenseClaimWorkflowRepairMixin
from app.services.receipt_folder import ReceiptFolderService
class ExpenseClaimStandardAdjustmentMixin:
@@ -260,7 +176,9 @@ class ExpenseClaimStandardAdjustmentMixin:
return None
try:
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.travel_reimbursement_calculator import (
TravelReimbursementCalculatorService,
)
result = TravelReimbursementCalculatorService(self.db).calculate(
TravelReimbursementCalculatorRequest(
@@ -473,6 +391,9 @@ class ExpenseClaimItemActionMixin:
claim_id: str,
payload: ExpenseClaimItemCreate | None,
current_user: CurrentUserContext,
commit: bool = True,
write_audit: bool = True,
refresh_pre_review: bool = True,
) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
@@ -507,18 +428,23 @@ class ExpenseClaimItemActionMixin:
self.db.add(item)
self._sync_claim_from_items(claim)
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
self.db.commit()
self.db.refresh(claim)
if refresh_pre_review:
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
if commit:
self.db.commit()
self.db.refresh(claim)
else:
self.db.flush()
self.audit_service.log_action(
actor=current_user.name or current_user.username,
action="expense_claim.item_create",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
if write_audit:
self.audit_service.log_action(
actor=current_user.name or current_user.username,
action="expense_claim.item_create",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
return claim

View 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 ""

View File

@@ -0,0 +1,604 @@
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.api.deps import CurrentUserContext
from app.models.expense_case import ExpenseCase, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
from app.schemas.receipt_folder import ReceiptFolderDetailRead
from app.services.expense_cases import ExpenseCaseService
from app.services.expense_claim_constants import EDITABLE_CLAIM_STATUSES
from app.services.expense_claims import ExpenseClaimService
CITY_NAMES = (
"北京",
"上海",
"广州",
"深圳",
"武汉",
"南京",
"杭州",
"成都",
"重庆",
"西安",
"天津",
"苏州",
"长沙",
"郑州",
"青岛",
"厦门",
"宁波",
"无锡",
"合肥",
"福州",
"昆明",
"大连",
"沈阳",
"济南",
"哈尔滨",
"长春",
"南昌",
"太原",
"贵阳",
"南宁",
"石家庄",
"兰州",
"银川",
"西宁",
"海口",
"拉萨",
)
HIGH_CONFIDENCE_SCORE = 7
MINIMUM_SCORE_LEAD = 2
MINIMUM_PER_RECEIPT_SIGNAL_SCORE = 4
APPROVED_APPLICATION_STATUSES = {"approved", "completed"}
@dataclass(slots=True)
class ExpenseReceiptMatchCandidate:
target_type: str
claim: ExpenseClaim | None
application_claim: ExpenseClaim | None
expense_case: ExpenseCase | None
score: int
reasons: list[str] = field(default_factory=list)
@property
def confidence(self) -> str:
if self.score >= HIGH_CONFIDENCE_SCORE:
return "high"
if self.score >= 4:
return "medium"
return "low"
@property
def normalized_score(self) -> float:
return round(min(1.0, max(0.0, self.score / 15)), 4)
def to_payload(self) -> dict[str, Any]:
return {
"target_type": self.target_type,
"expense_case_id": str(getattr(self.expense_case, "id", "") or ""),
"application_claim_id": str(getattr(self.application_claim, "id", "") or ""),
"application_claim_no": str(getattr(self.application_claim, "claim_no", "") or ""),
"claim_id": str(getattr(self.claim, "id", "") or ""),
"claim_no": str(getattr(self.claim, "claim_no", "") or ""),
"confidence": self.confidence,
"score": self.normalized_score,
"match_reasons": list(self.reasons),
}
@dataclass(slots=True)
class ExpenseReceiptMatchResult:
resolution: str
requires_confirmation: bool
recommended: ExpenseReceiptMatchCandidate | None = None
candidates: list[ExpenseReceiptMatchCandidate] = field(default_factory=list)
exceptions: list[str] = field(default_factory=list)
class ExpenseReceiptMatcher:
"""只读票据匹配器;评分阶段绝不修改 Claim、Case 或票据。"""
def __init__(self, db: Session) -> None:
self.db = db
self.claim_service = ExpenseClaimService(db)
self.case_service = ExpenseCaseService(db)
def match(
self,
*,
receipts: list[ReceiptFolderDetailRead],
current_user: CurrentUserContext,
) -> ExpenseReceiptMatchResult:
signals = collect_receipt_signals(receipts)
accessible_claims = self._filter_claims_for_tenant(
self._list_accessible_claims(current_user),
tenant_id=current_user.tenant_id,
)
accessible_by_id = {str(claim.id): claim for claim in accessible_claims if claim.id}
linked_claim_ids = {
str(receipt.linked_claim_id or "").strip()
for receipt in receipts
if str(receipt.status or "").strip().lower() == "linked"
and str(receipt.linked_claim_id or "").strip()
}
if len(linked_claim_ids) > 1:
return ExpenseReceiptMatchResult(
resolution="requires_confirmation",
requires_confirmation=True,
exceptions=["所选票据已经分属多个报销单,请先核对票据归属。"],
)
if linked_claim_ids:
linked_claim = accessible_by_id.get(next(iter(linked_claim_ids)))
if linked_claim is None or not self._is_editable_reimbursement(linked_claim):
return ExpenseReceiptMatchResult(
resolution="requires_confirmation",
requires_confirmation=True,
exceptions=["票据已经关联到不可编辑或无权访问的报销单。"],
)
replay_candidate = self._score_claim(
linked_claim,
signals=signals,
tenant_id=current_user.tenant_id,
accessible_by_id=accessible_by_id,
)
replay_candidate.score = max(replay_candidate.score, 15)
replay_candidate.reasons.append("票据已经归集到该草稿,本次按幂等重放处理")
if not self._all_receipts_support_claim(receipts, linked_claim):
return ExpenseReceiptMatchResult(
resolution="requires_confirmation",
requires_confirmation=True,
recommended=replay_candidate,
candidates=[replay_candidate],
exceptions=["所选票据并非都与已关联草稿具备独立匹配证据,请逐份确认。"],
)
return ExpenseReceiptMatchResult(
resolution="auto_associated",
requires_confirmation=False,
recommended=replay_candidate,
candidates=[replay_candidate],
)
draft_candidates = [
self._score_claim(
claim,
signals=signals,
tenant_id=current_user.tenant_id,
accessible_by_id=accessible_by_id,
)
for claim in accessible_claims
if self._is_editable_reimbursement(claim)
]
ranked_drafts = self._rank(
candidate for candidate in draft_candidates if candidate.score > 0
)
if ranked_drafts:
recommended = ranked_drafts[0]
runner_up = ranked_drafts[1] if len(ranked_drafts) > 1 else None
has_required_score = recommended.score >= HIGH_CONFIDENCE_SCORE
has_clear_lead = (
runner_up is None or recommended.score - runner_up.score >= MINIMUM_SCORE_LEAD
)
has_consistent_receipts = self._all_receipts_support_claim(
receipts,
recommended.claim,
)
if has_required_score and has_clear_lead and has_consistent_receipts:
return ExpenseReceiptMatchResult(
resolution="auto_associated",
requires_confirmation=False,
recommended=recommended,
candidates=ranked_drafts[:5],
)
exception = (
"同批票据并非每一份都与候选草稿具备独立匹配证据,请逐份确认。"
if not has_consistent_receipts
else "票据与多个报销草稿的匹配证据不足,请确认目标单据。"
)
return ExpenseReceiptMatchResult(
resolution="requires_confirmation",
requires_confirmation=True,
recommended=recommended,
candidates=ranked_drafts[:5],
exceptions=[exception],
)
application_candidates = [
self._score_application(
claim,
signals=signals,
tenant_id=current_user.tenant_id,
)
for claim in accessible_claims
if self._is_approved_application(claim)
]
ranked_applications = self._rank(
candidate for candidate in application_candidates if candidate.score > 0
)
if ranked_applications:
return ExpenseReceiptMatchResult(
resolution="requires_confirmation",
requires_confirmation=True,
recommended=ranked_applications[0],
candidates=ranked_applications[:5],
exceptions=["找到可能的已审批申请,但没有可安全归集的系统报销草稿。"],
)
return ExpenseReceiptMatchResult(
resolution="requires_confirmation",
requires_confirmation=True,
exceptions=["没有找到与当前票据匹配的报销草稿,请确认申请或补充行程信息。"],
)
def _list_accessible_claims(
self,
current_user: CurrentUserContext,
) -> list[ExpenseClaim]:
"""复用访问范围条件做纯查询,不触发旧工作流修复或提交。"""
stmt = (
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.items))
.order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc())
)
stmt = self.claim_service._access_policy.apply_claim_scope(stmt, current_user)
return [
claim
for claim in list(self.db.scalars(stmt).all())
if self.claim_service._access_policy.is_claim_owned_by_current_user(
claim,
current_user,
)
]
@staticmethod
def _all_receipts_support_claim(
receipts: list[ReceiptFolderDetailRead],
claim: ExpenseClaim | None,
) -> bool:
if claim is None:
return False
for receipt in receipts:
if (
str(receipt.status or "").strip().lower() == "linked"
and str(receipt.linked_claim_id or "").strip() == str(claim.id or "").strip()
):
continue
signal_score, _reasons = score_claim_signals(
claim,
collect_receipt_signals([receipt]),
)
if signal_score < MINIMUM_PER_RECEIPT_SIGNAL_SCORE:
return False
return True
def _filter_claims_for_tenant(
self,
claims: list[ExpenseClaim],
*,
tenant_id: str,
) -> list[ExpenseClaim]:
"""ExpenseClaim 尚无 tenant_id非默认租户必须由同租户 Case Link 证明归属。"""
normalized_tenant = self.case_service.normalize_tenant_id(tenant_id)
claim_ids = [str(claim.id) for claim in claims if str(claim.id or "").strip()]
if not claim_ids:
return []
links = list(
self.db.scalars(
select(ExpenseCaseLink).where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id.in_(claim_ids),
)
).all()
)
links_by_claim_id = {str(link.resource_id): link for link in links}
case_ids = list({str(link.expense_case_id) for link in links})
cases_by_id = {
str(expense_case.id): expense_case
for expense_case in list(
self.db.scalars(select(ExpenseCase).where(ExpenseCase.id.in_(case_ids))).all()
)
}
filtered: list[ExpenseClaim] = []
for claim in claims:
link = links_by_claim_id.get(str(claim.id))
if link is None:
if normalized_tenant == "default":
filtered.append(claim)
continue
expense_case = cases_by_id.get(str(link.expense_case_id))
if (
link.tenant_id == normalized_tenant
and expense_case is not None
and expense_case.tenant_id == normalized_tenant
):
filtered.append(claim)
return filtered
@staticmethod
def _rank(candidates: Any) -> list[ExpenseReceiptMatchCandidate]:
return sorted(
list(candidates),
key=lambda item: (-item.score, str(getattr(item.claim, "claim_no", ""))),
)
def _score_claim(
self,
claim: ExpenseClaim,
*,
signals: dict[str, Any],
tenant_id: str,
accessible_by_id: dict[str, ExpenseClaim],
) -> ExpenseReceiptMatchCandidate:
expense_case, application_claim = self._resolve_case_and_application(
claim,
tenant_id=tenant_id,
accessible_by_id=accessible_by_id,
)
score, reasons = score_claim_signals(claim, signals)
if str(claim.status or "").strip().lower() == "draft":
score += 1
reasons.append("当前单据仍是可归集草稿")
if expense_case is not None:
score += 1
reasons.append("报销草稿已纳入同一费用事件")
if application_claim is not None and self._is_approved_application(application_claim):
score += 4
reasons.append(f"关联申请 {application_claim.claim_no} 已审批通过")
return ExpenseReceiptMatchCandidate(
target_type="reimbursement_draft",
claim=claim,
application_claim=application_claim,
expense_case=expense_case,
score=score,
reasons=reasons,
)
def _score_application(
self,
claim: ExpenseClaim,
*,
signals: dict[str, Any],
tenant_id: str,
) -> ExpenseReceiptMatchCandidate:
score, reasons = score_claim_signals(claim, signals)
expense_case = self._resolve_case(claim, tenant_id=tenant_id)
if expense_case is not None:
score += 1
reasons.append("申请已纳入费用事件")
score += 2
reasons.append("申请已审批通过")
return ExpenseReceiptMatchCandidate(
target_type="approved_application",
claim=None,
application_claim=claim,
expense_case=expense_case,
score=score,
reasons=reasons,
)
def _resolve_case_and_application(
self,
claim: ExpenseClaim,
*,
tenant_id: str,
accessible_by_id: dict[str, ExpenseClaim],
) -> tuple[ExpenseCase | None, ExpenseClaim | None]:
expense_case = self._resolve_case(claim, tenant_id=tenant_id)
if expense_case is None:
return None, self._resolve_flag_application(claim, accessible_by_id)
links = list(
self.db.scalars(
select(ExpenseCaseLink).where(
ExpenseCaseLink.tenant_id == self.case_service.normalize_tenant_id(tenant_id),
ExpenseCaseLink.expense_case_id == expense_case.id,
ExpenseCaseLink.resource_type == "expense_claim",
)
).all()
)
for link in links:
linked_claim = accessible_by_id.get(str(link.resource_id))
if linked_claim is not None and self.claim_service._is_expense_application_claim(
linked_claim
):
return expense_case, linked_claim
return expense_case, self._resolve_flag_application(claim, accessible_by_id)
def _resolve_case(self, claim: ExpenseClaim, *, tenant_id: str) -> ExpenseCase | None:
normalized_tenant = self.case_service.normalize_tenant_id(tenant_id)
link = self.db.scalar(
select(ExpenseCaseLink).where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id == claim.id,
)
)
if link is None or link.tenant_id != normalized_tenant:
return None
return self.db.scalar(
select(ExpenseCase).where(
ExpenseCase.id == link.expense_case_id,
ExpenseCase.tenant_id == normalized_tenant,
)
)
@staticmethod
def _resolve_flag_application(
claim: ExpenseClaim,
accessible_by_id: dict[str, ExpenseClaim],
) -> ExpenseClaim | None:
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
application_id = str(
flag.get("application_claim_id") or flag.get("applicationClaimId") or ""
).strip()
application = accessible_by_id.get(application_id)
if application is not None:
return application
return None
def _is_editable_reimbursement(self, claim: ExpenseClaim) -> bool:
status = str(claim.status or "").strip().lower()
return (
status in EDITABLE_CLAIM_STATUSES
and not self.claim_service._is_expense_application_claim(claim)
)
def _is_approved_application(self, claim: ExpenseClaim) -> bool:
status = str(claim.status or "").strip().lower()
return (
status in APPROVED_APPLICATION_STATUSES
and self.claim_service._is_expense_application_claim(claim)
)
def normalize_text(value: Any) -> str:
return re.sub(r"\s+", "", str(value or "").strip())
def unique(values: list[str] | tuple[str, ...]) -> list[str]:
return list(
dict.fromkeys(str(item or "").strip() for item in values if str(item or "").strip())
)
def extract_date_tokens(text: Any) -> list[str]:
source = str(text or "")
matches = [
*re.finditer(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}", source),
*re.finditer(r"\d{1,2}月\d{1,2}", source),
]
return unique([normalize_date_token(match.group(0)) for match in matches])
def normalize_date_token(value: Any) -> str:
if isinstance(value, (date, datetime)):
return value.isoformat()[:10]
text = str(value or "").strip()
full_match = re.search(r"(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})", text)
if full_match:
year, month, day = full_match.groups()
return f"{year}-{month.zfill(2)}-{day.zfill(2)}"
short_match = re.search(r"(\d{1,2})月(\d{1,2})", text)
if short_match:
month, day = short_match.groups()
return f"{month.zfill(2)}-{day.zfill(2)}"
return ""
def extract_city_tokens(text: Any) -> list[str]:
compact = normalize_text(text)
return [city for city in CITY_NAMES if city in compact] if compact else []
def dates_overlap(left: list[str], right: list[str]) -> bool:
return any(
left_date
and right_date
and (
left_date == right_date
or left_date.endswith(right_date)
or right_date.endswith(left_date)
)
for left_date in left
for right_date in right
)
def collect_receipt_signals(receipts: list[ReceiptFolderDetailRead]) -> dict[str, Any]:
text = "\n".join(build_receipt_text(receipt) for receipt in receipts)
return {
"text": text,
"dates": unique(
[
*extract_date_tokens(text),
*[str(receipt.document_date or "").strip() for receipt in receipts],
]
),
"cities": unique(extract_city_tokens(text)),
"scenes": unique([str(receipt.scene_code or "").strip().lower() for receipt in receipts]),
}
def build_receipt_text(receipt: ReceiptFolderDetailRead) -> str:
fields_text = "\n".join(
f"{field.label} {field.value}"
for field in list(receipt.fields or [])
if str(field.label or field.value or "").strip()
)
return "\n".join(
value
for value in (
receipt.file_name,
receipt.summary,
receipt.ocr_text,
receipt.document_date,
receipt.merchant_name,
fields_text,
)
if str(value or "").strip()
)
def build_claim_text(claim: ExpenseClaim) -> str:
item_text = "\n".join(
" ".join(
str(value or "").strip()
for value in (
item.item_date.isoformat() if item.item_date else "",
item.item_type,
item.item_reason,
item.item_location,
item.item_note,
)
if str(value or "").strip()
)
for item in list(claim.items or [])
)
occurred_at = claim.occurred_at.isoformat()[:10] if claim.occurred_at else ""
return "\n".join(
value
for value in (
claim.claim_no,
claim.expense_type,
claim.status,
claim.reason,
claim.location,
occurred_at,
item_text,
)
if str(value or "").strip()
)
def score_claim_signals(claim: ExpenseClaim, signals: dict[str, Any]) -> tuple[int, list[str]]:
claim_text = build_claim_text(claim)
compact_claim_text = normalize_text(claim_text)
claim_dates = extract_date_tokens(claim_text)
claim_cities = unique([*extract_city_tokens(claim_text), *extract_city_tokens(claim.location)])
reasons: list[str] = []
score = 0
if dates_overlap(list(signals.get("dates") or []), claim_dates):
score += 4
reasons.append("票据日期与单据日期一致")
matched_cities = [
city for city in list(signals.get("cities") or []) if city in compact_claim_text
]
if matched_cities:
score += min(4, len(matched_cities) * 2)
reasons.append(f"地点或行程包含 {''.join(matched_cities)}")
if len(claim_cities) >= 2 and len(matched_cities) >= 2:
score += 2
reasons.append("票据往返城市与单据事由吻合")
claim_scene = str(claim.expense_type or "").strip().lower().removesuffix("_application")
receipt_scenes = set(signals.get("scenes") or [])
if claim_scene and claim_scene in receipt_scenes:
score += 2
reasons.append("票据费用场景与单据类型一致")
return score, reasons

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import json
import hashlib
import json
import mimetypes
import re
import shutil
@@ -20,8 +20,8 @@ from app.schemas.receipt_folder import (
ReceiptFolderItemRead,
ReceiptFolderUpdate,
)
from app.services.document_preview import DocumentPreviewAssets
from app.services.document_intelligence import build_document_insight
from app.services.document_preview import DocumentPreviewAssets
from app.services.ocr import SUPPORTED_SUFFIXES
RECEIPT_DATE_PATTERN = re.compile(
@@ -116,9 +116,16 @@ class ReceiptFolderStorageMixin:
@staticmethod
def _owner_key(current_user: CurrentUserContext) -> str:
raw = str(current_user.username or current_user.name or "anonymous").strip().lower()
normalized = re.sub(r"[^\w.\-\u4e00-\u9fff]+", "_", raw).strip("._")
return normalized or "anonymous"
raw_owner = str(current_user.username or current_user.name or "anonymous").strip().lower()
owner = re.sub(r"[^\w.\-\u4e00-\u9fff]+", "_", raw_owner).strip("._") or "anonymous"
raw_tenant = str(getattr(current_user, "tenant_id", "default") or "default").strip().lower()
tenant = re.sub(r"[^\w.\-]+", "_", raw_tenant).strip("._") or "default"
# 默认租户继续读取历史目录;其他租户必须使用独立命名空间。
if raw_tenant == "default":
return owner
tenant_digest = hashlib.sha256(raw_tenant.encode("utf-8")).hexdigest()[:12]
owner_digest = hashlib.sha256(raw_owner.encode("utf-8")).hexdigest()[:12]
return f"{tenant[:48]}-{tenant_digest}__{owner[:64]}-{owner_digest}"
@staticmethod
def _should_persist_source(filename: str, content: bytes) -> bool:
@@ -1180,6 +1187,8 @@ class ReceiptFolderService(ReceiptFolderStorageMixin, ReceiptFolderItemMixin, Re
meta = {
"id": receipt_id,
"owner_key": owner_key,
"tenant_id": str(getattr(current_user, "tenant_id", "default") or "default").strip()
or "default",
"file_name": normalized_name,
"source_file_name": normalized_name,
"media_type": resolved_media_type,
@@ -1258,6 +1267,23 @@ class ReceiptFolderService(ReceiptFolderStorageMixin, ReceiptFolderItemMixin, Re
self._write_meta(receipt_dir, meta)
return self._build_item(meta)
def restore_receipt_meta(
self,
*,
receipt_id: str,
current_user: CurrentUserContext,
meta: dict[str, Any],
) -> None:
"""仅供跨数据库/文件事务补偿使用,恢复调用前的票据元数据。"""
receipt_dir = self._receipt_dir(self._owner_key(current_user), receipt_id)
restored = json.loads(json.dumps(meta, ensure_ascii=False))
restored["id"] = str(receipt_id or "").strip()
restored["owner_key"] = self._owner_key(current_user)
restored["tenant_id"] = str(
getattr(current_user, "tenant_id", "default") or "default"
).strip() or "default"
self._write_meta(receipt_dir, restored)
def list_receipts(
self,
*,