feat(expense): add persistent zero-entry receipt association
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""add persistent attachment association jobs
|
||||
|
||||
Revision ID: 20260716_0006
|
||||
Revises: 20260714_0005
|
||||
Create Date: 2026-07-16 09:55:00
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260716_0006"
|
||||
down_revision: str | None = "20260714_0005"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"attachment_association_jobs",
|
||||
sa.Column("id", sa.String(length=80), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("owner_username", sa.String(length=255), nullable=False),
|
||||
sa.Column("owner_name", sa.String(length=255), server_default="", nullable=False),
|
||||
sa.Column("owner_context_json", sa.JSON(), nullable=False),
|
||||
sa.Column("dedupe_key", sa.String(length=64), nullable=False),
|
||||
sa.Column("generation", sa.Integer(), server_default="1", nullable=False),
|
||||
sa.Column("receipt_ids_json", sa.JSON(), nullable=False),
|
||||
sa.Column("prompt", sa.Text(), server_default="", nullable=False),
|
||||
sa.Column("conversation_id", sa.String(length=120), server_default="", nullable=False),
|
||||
sa.Column("status", sa.String(length=20), server_default="queued", nullable=False),
|
||||
sa.Column("message", sa.Text(), server_default="", nullable=False),
|
||||
sa.Column("claim_id", sa.String(length=36), server_default="", nullable=False),
|
||||
sa.Column("claim_no", sa.String(length=80), server_default="", nullable=False),
|
||||
sa.Column("uploaded_count", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("skipped_count", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("resolution", sa.String(length=40), server_default="pending", nullable=False),
|
||||
sa.Column(
|
||||
"requires_confirmation",
|
||||
sa.Boolean(),
|
||||
server_default=sa.false(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("expense_case_id", sa.String(length=36), server_default="", nullable=False),
|
||||
sa.Column("application_claim_id", sa.String(length=36), server_default="", nullable=False),
|
||||
sa.Column("application_claim_no", sa.String(length=80), server_default="", nullable=False),
|
||||
sa.Column("confidence", sa.String(length=20), server_default="", nullable=False),
|
||||
sa.Column("confidence_score", sa.Float(), server_default="0", nullable=False),
|
||||
sa.Column("match_reasons_json", sa.JSON(), nullable=False),
|
||||
sa.Column("exceptions_json", sa.JSON(), nullable=False),
|
||||
sa.Column("missing_fields_json", sa.JSON(), nullable=False),
|
||||
sa.Column("risk_items_json", sa.JSON(), nullable=False),
|
||||
sa.Column("candidates_json", sa.JSON(), nullable=False),
|
||||
sa.Column("draft_payload_json", sa.JSON(), nullable=True),
|
||||
sa.Column("error", sa.Text(), server_default="", nullable=False),
|
||||
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('queued', 'running', 'succeeded', 'failed')",
|
||||
name="ck_attachment_association_jobs_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"attempt_count >= 0",
|
||||
name="ck_attachment_association_jobs_attempt_count",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"generation >= 1",
|
||||
name="ck_attachment_association_jobs_generation",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status != 'running' OR lease_expires_at IS NOT NULL",
|
||||
name="ck_attachment_association_jobs_running_lease",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"owner_username",
|
||||
"dedupe_key",
|
||||
"generation",
|
||||
name="uq_attachment_association_jobs_owner_dedupe",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_attachment_association_jobs_owner_time",
|
||||
"attachment_association_jobs",
|
||||
["tenant_id", "owner_username", "created_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_attachment_association_jobs_status_lease",
|
||||
"attachment_association_jobs",
|
||||
["status", "lease_expires_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_attachment_association_jobs_status_lease",
|
||||
table_name="attachment_association_jobs",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_attachment_association_jobs_owner_time",
|
||||
table_name="attachment_association_jobs",
|
||||
)
|
||||
op.drop_table("attachment_association_jobs")
|
||||
@@ -3,8 +3,9 @@ from __future__ import annotations
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext, get_current_user
|
||||
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
||||
from app.db.session import get_session_factory
|
||||
from app.schemas.attachment_association_job import (
|
||||
AttachmentAssociationJobCreate,
|
||||
@@ -19,6 +20,7 @@ from app.services.attachment_association_jobs import (
|
||||
|
||||
router = APIRouter(prefix="/reimbursements/attachment-association-jobs")
|
||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||
Database = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -38,15 +40,15 @@ def create_attachment_association_job_endpoint(
|
||||
payload: AttachmentAssociationJobCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
) -> AttachmentAssociationJobRead:
|
||||
try:
|
||||
job = create_attachment_association_job(payload, current_user)
|
||||
job = create_attachment_association_job(payload, current_user, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
background_tasks.add_task(
|
||||
run_attachment_association_job,
|
||||
job.job_id,
|
||||
current_user,
|
||||
get_session_factory(),
|
||||
)
|
||||
return job
|
||||
@@ -66,10 +68,17 @@ def create_attachment_association_job_endpoint(
|
||||
)
|
||||
def get_attachment_association_job_endpoint(
|
||||
job_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
) -> AttachmentAssociationJobRead:
|
||||
job = get_attachment_association_job(job_id, current_user)
|
||||
job = get_attachment_association_job(job_id, current_user, db)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件关联任务不存在或已失效。")
|
||||
if job.status in {"queued", "running"}:
|
||||
background_tasks.add_task(
|
||||
run_attachment_association_job,
|
||||
job.job_id,
|
||||
get_session_factory(),
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.models.ai_application_preview import AIApplicationPreviewDecision
|
||||
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
|
||||
from app.models.approval import ApprovalRecord
|
||||
from app.models.attachment_association_job import AttachmentAssociationJob
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.auth_session import AuthSession
|
||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||
@@ -59,6 +60,7 @@ __all__ = [
|
||||
"AIDecision",
|
||||
"AIDecisionFeedback",
|
||||
"ApprovalRecord",
|
||||
"AttachmentAssociationJob",
|
||||
"AuditLog",
|
||||
"AuthSession",
|
||||
"BudgetAllocation",
|
||||
|
||||
@@ -64,8 +64,23 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
|
||||
"workflow_outcomes",
|
||||
}
|
||||
),
|
||||
"20260716_0006": frozenset(
|
||||
{
|
||||
"expense_cases",
|
||||
"expense_case_links",
|
||||
"business_events",
|
||||
"auth_sessions",
|
||||
"attachment_association_jobs",
|
||||
"ai_application_preview_decisions",
|
||||
"ai_decisions",
|
||||
"ai_decision_feedback",
|
||||
"memory_entries",
|
||||
"memory_evidence_links",
|
||||
"workflow_outcomes",
|
||||
}
|
||||
),
|
||||
}
|
||||
if MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0005"] != MIGRATION_OWNED_TABLES:
|
||||
if MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0006"] != MIGRATION_OWNED_TABLES:
|
||||
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.db.base import Base
|
||||
MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
|
||||
{
|
||||
"auth_sessions",
|
||||
"attachment_association_jobs",
|
||||
"ai_application_preview_decisions",
|
||||
"ai_decisions",
|
||||
"ai_decision_feedback",
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.models.ai_application_preview import AIApplicationPreviewDecision
|
||||
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
|
||||
from app.models.approval import ApprovalRecord
|
||||
from app.models.attachment_association_job import AttachmentAssociationJob
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.auth_session import AuthSession
|
||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||
@@ -53,6 +54,7 @@ __all__ = [
|
||||
"AgentTraceEvent",
|
||||
"AIApplicationPreviewDecision",
|
||||
"ApprovalRecord",
|
||||
"AttachmentAssociationJob",
|
||||
"AuditLog",
|
||||
"AuthSession",
|
||||
"AIDecision",
|
||||
|
||||
110
server/src/app/models/attachment_association_job.py
Normal file
110
server/src/app/models/attachment_association_job.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
Float,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class AttachmentAssociationJob(Base):
|
||||
"""可跨进程恢复的票据归集任务状态。"""
|
||||
|
||||
__tablename__ = "attachment_association_jobs"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"owner_username",
|
||||
"dedupe_key",
|
||||
"generation",
|
||||
name="uq_attachment_association_jobs_owner_dedupe",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('queued', 'running', 'succeeded', 'failed')",
|
||||
name="ck_attachment_association_jobs_status",
|
||||
),
|
||||
CheckConstraint(
|
||||
"attempt_count >= 0",
|
||||
name="ck_attachment_association_jobs_attempt_count",
|
||||
),
|
||||
CheckConstraint(
|
||||
"generation >= 1",
|
||||
name="ck_attachment_association_jobs_generation",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status != 'running' OR lease_expires_at IS NOT NULL",
|
||||
name="ck_attachment_association_jobs_running_lease",
|
||||
),
|
||||
Index(
|
||||
"ix_attachment_association_jobs_owner_time",
|
||||
"tenant_id",
|
||||
"owner_username",
|
||||
"created_at",
|
||||
),
|
||||
Index(
|
||||
"ix_attachment_association_jobs_status_lease",
|
||||
"status",
|
||||
"lease_expires_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
owner_username: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
owner_name: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
owner_context_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||
dedupe_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
generation: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
receipt_ids_json: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
||||
prompt: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
conversation_id: Mapped[str] = mapped_column(String(120), nullable=False, default="")
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued")
|
||||
message: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
claim_id: Mapped[str] = mapped_column(String(36), nullable=False, default="")
|
||||
claim_no: Mapped[str] = mapped_column(String(80), nullable=False, default="")
|
||||
uploaded_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
skipped_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
resolution: Mapped[str] = mapped_column(String(40), nullable=False, default="pending")
|
||||
requires_confirmation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
expense_case_id: Mapped[str] = mapped_column(String(36), nullable=False, default="")
|
||||
application_claim_id: Mapped[str] = mapped_column(String(36), nullable=False, default="")
|
||||
application_claim_no: Mapped[str] = mapped_column(String(80), nullable=False, default="")
|
||||
confidence: Mapped[str] = mapped_column(String(20), nullable=False, default="")
|
||||
confidence_score: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
match_reasons_json: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
||||
exceptions_json: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
||||
missing_fields_json: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
||||
risk_items_json: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
||||
candidates_json: Mapped[list[dict[str, Any]]] = mapped_column(JSON, nullable=False, default=list)
|
||||
draft_payload_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
error: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
lease_expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -8,21 +8,33 @@ from pydantic import BaseModel, Field, field_validator
|
||||
class AttachmentAssociationJobCreate(BaseModel):
|
||||
receipt_ids: list[str] = Field(default_factory=list, description="票据夹持久化票据 ID。")
|
||||
prompt: str = Field(default="", max_length=1000, description="用户发送时的上下文说明。")
|
||||
conversation_id: str = Field(default="", max_length=120, description="前端会话 ID,用于状态恢复。")
|
||||
conversation_id: str = Field(
|
||||
default="", max_length=120, description="前端会话 ID,用于状态恢复。"
|
||||
)
|
||||
|
||||
@field_validator("receipt_ids")
|
||||
@classmethod
|
||||
def validate_receipt_ids(cls, value: list[str]) -> list[str]:
|
||||
receipt_ids = [
|
||||
str(item or "").strip()
|
||||
for item in list(value or [])
|
||||
if str(item or "").strip()
|
||||
str(item or "").strip() for item in list(value or []) if str(item or "").strip()
|
||||
]
|
||||
if not receipt_ids:
|
||||
raise ValueError("请先完成附件 OCR 识别,再发起自动关联。")
|
||||
return list(dict.fromkeys(receipt_ids))
|
||||
|
||||
|
||||
class AttachmentAssociationCandidateRead(BaseModel):
|
||||
target_type: str = "reimbursement_draft"
|
||||
expense_case_id: str = ""
|
||||
application_claim_id: str = ""
|
||||
application_claim_no: str = ""
|
||||
claim_id: str = ""
|
||||
claim_no: str = ""
|
||||
confidence: str = "low"
|
||||
score: float = 0.0
|
||||
match_reasons: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AttachmentAssociationJobRead(BaseModel):
|
||||
job_id: str
|
||||
status: str
|
||||
@@ -32,9 +44,21 @@ class AttachmentAssociationJobRead(BaseModel):
|
||||
claim_no: str = ""
|
||||
uploaded_count: int = 0
|
||||
skipped_count: int = 0
|
||||
resolution: str = "pending"
|
||||
requires_confirmation: bool = False
|
||||
expense_case_id: str = ""
|
||||
application_claim_id: str = ""
|
||||
application_claim_no: str = ""
|
||||
confidence: str = ""
|
||||
confidence_score: float = 0.0
|
||||
match_reasons: list[str] = Field(default_factory=list)
|
||||
exceptions: list[str] = Field(default_factory=list)
|
||||
missing_fields: list[str] = Field(default_factory=list)
|
||||
risk_items: list[str] = Field(default_factory=list)
|
||||
candidates: list[AttachmentAssociationCandidateRead] = Field(default_factory=list)
|
||||
draft_payload: dict[str, object] | None = None
|
||||
error: str = ""
|
||||
prompt: str = ""
|
||||
conversation_id: str = ""
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
267
server/src/app/services/attachment_association_job_store.py
Normal file
267
server/src/app/services/attachment_association_job_store.py
Normal 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 ""),
|
||||
)
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} 已上传并关联到当前费用明细。",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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 ""
|
||||
604
server/src/app/services/expense_receipt_matcher.py
Normal file
604
server/src/app/services/expense_receipt_matcher.py
Normal 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
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.db.schema_ownership import MIGRATION_OWNED_TABLES, create_legacy_schema
|
||||
|
||||
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
|
||||
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
|
||||
HEAD_REVISION = "20260714_0005"
|
||||
HEAD_REVISION = "20260716_0006"
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
|
||||
|
||||
@@ -274,11 +274,27 @@ def _assert_head_schema(engine: Engine) -> None:
|
||||
"uq_memory_evidence_links_entry_case",
|
||||
("tenant_id", "memory_entry_id", "expense_case_id"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"attachment_association_jobs",
|
||||
"uq_attachment_association_jobs_owner_dedupe",
|
||||
("tenant_id", "owner_username", "dedupe_key", "generation"),
|
||||
)
|
||||
_assert_check_constraint(
|
||||
engine,
|
||||
"memory_entries",
|
||||
"ck_memory_entries_expired_fields",
|
||||
)
|
||||
_assert_check_constraint(
|
||||
engine,
|
||||
"attachment_association_jobs",
|
||||
"ck_attachment_association_jobs_running_lease",
|
||||
)
|
||||
_assert_check_constraint(
|
||||
engine,
|
||||
"attachment_association_jobs",
|
||||
"ck_attachment_association_jobs_generation",
|
||||
)
|
||||
|
||||
_assert_indexes(
|
||||
engine,
|
||||
@@ -318,6 +334,21 @@ def _assert_head_schema(engine: Engine) -> None:
|
||||
"ix_auth_sessions_tenant_username": ("tenant_id", "username"),
|
||||
},
|
||||
)
|
||||
_assert_indexes(
|
||||
engine,
|
||||
"attachment_association_jobs",
|
||||
{
|
||||
"ix_attachment_association_jobs_owner_time": (
|
||||
"tenant_id",
|
||||
"owner_username",
|
||||
"created_at",
|
||||
),
|
||||
"ix_attachment_association_jobs_status_lease": (
|
||||
"status",
|
||||
"lease_expires_at",
|
||||
),
|
||||
},
|
||||
)
|
||||
_assert_indexes(
|
||||
engine,
|
||||
"ai_application_preview_decisions",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,7 @@ def test_unversioned_database_without_migration_owned_tables_is_safe(engine: Eng
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"owned_table",
|
||||
sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0005"]),
|
||||
sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0006"]),
|
||||
)
|
||||
def test_unversioned_database_with_any_migration_owned_table_is_rejected(
|
||||
engine: Engine,
|
||||
@@ -100,6 +100,11 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
|
||||
"20260714_0005",
|
||||
MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0005"] - {"memory_entries"},
|
||||
),
|
||||
(
|
||||
"20260716_0006",
|
||||
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0006"]
|
||||
- {"attachment_association_jobs"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected(
|
||||
|
||||
@@ -20,6 +20,7 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
|
||||
"ai_decision_feedback",
|
||||
"ai_decisions",
|
||||
"auth_sessions",
|
||||
"attachment_association_jobs",
|
||||
"business_events",
|
||||
"expense_case_links",
|
||||
"expense_cases",
|
||||
|
||||
Reference in New Issue
Block a user