refactor(server): split oversized backend services
This commit is contained in:
612
server/src/app/services/expense_claim_draft_flow.py
Normal file
612
server/src/app/services/expense_claim_draft_flow.py
Normal file
@@ -0,0 +1,612 @@
|
||||
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 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_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
|
||||
|
||||
|
||||
class ExpenseClaimDraftFlowMixin:
|
||||
def upsert_draft_from_ontology(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
user_id: str | None,
|
||||
message: str,
|
||||
ontology: OntologyParseResult,
|
||||
context_json: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
self._ensure_ready()
|
||||
context_json = dict(context_json or {})
|
||||
retry_count = self._resolve_claim_no_retry_count(context_json)
|
||||
|
||||
review_action = str(context_json.get("review_action") or "").strip()
|
||||
attachment_names = self._resolve_attachment_names(context_json)
|
||||
context_documents = self._resolve_context_documents(context_json)
|
||||
|
||||
employee = self._resolve_employee(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
)
|
||||
draft_owner_name = (
|
||||
employee.name
|
||||
if employee is not None
|
||||
else self._resolve_employee_name(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
)
|
||||
)
|
||||
|
||||
association_candidate = self._find_association_candidate(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
employee=employee,
|
||||
)
|
||||
if self._should_defer_multi_document_association(
|
||||
context_json=context_json,
|
||||
review_action=review_action,
|
||||
association_candidate=association_candidate,
|
||||
context_documents=context_documents,
|
||||
):
|
||||
document_count = max(len(context_documents), len(attachment_names), self._resolve_attachment_count(context_json))
|
||||
return {
|
||||
"message": (
|
||||
f"检测到你已有草稿 {association_candidate.claim_no},"
|
||||
f"当前新上传了 {document_count} 张票据,请先选择关联到现有草稿,或单独建立新的报销单。"
|
||||
),
|
||||
"draft_only": False,
|
||||
"status": "pending_association_decision",
|
||||
"pending_association_decision": True,
|
||||
"association_candidate_claim_id": association_candidate.id,
|
||||
"association_candidate_claim_no": association_candidate.claim_no,
|
||||
}
|
||||
|
||||
claim = self._find_target_claim(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
review_action=review_action,
|
||||
association_candidate=association_candidate,
|
||||
)
|
||||
is_new_claim = claim is None
|
||||
before_json = self._serialize_claim(claim) if claim is not None else None
|
||||
if is_new_claim:
|
||||
existing_draft_count = self._count_draft_claims_for_owner(
|
||||
employee=employee,
|
||||
user_id=user_id,
|
||||
)
|
||||
if existing_draft_count >= MAX_DRAFT_CLAIMS_PER_USER:
|
||||
return {
|
||||
"message": (
|
||||
f"你当前已保存 {MAX_DRAFT_CLAIMS_PER_USER} 个草稿,请先完成已保存的草稿,"
|
||||
"才能再次新建草稿。"
|
||||
),
|
||||
"draft_limit_reached": True,
|
||||
"draft_only": False,
|
||||
"status": "blocked",
|
||||
"draft_count": existing_draft_count,
|
||||
"max_draft_count": MAX_DRAFT_CLAIMS_PER_USER,
|
||||
}
|
||||
|
||||
amount = self._resolve_amount(ontology.entities, context_json=context_json)
|
||||
occurred_at = self._resolve_occurred_at(ontology, context_json=context_json)
|
||||
explicit_expense_type = self._resolve_explicit_review_expense_type(context_json)
|
||||
inferred_expense_type = self._resolve_expense_type(ontology.entities, context_json=context_json)
|
||||
locked_expense_type = explicit_expense_type
|
||||
if not locked_expense_type and claim is not None and review_action in DOCUMENT_ASSOCIATION_REVIEW_ACTIONS:
|
||||
locked_expense_type = str(claim.expense_type or "").strip()
|
||||
expense_type = locked_expense_type or inferred_expense_type
|
||||
location = self._resolve_location(message=message, context_json=context_json)
|
||||
reason = self._resolve_reason(
|
||||
message=message,
|
||||
context_json=context_json,
|
||||
allow_message_fallback=is_new_claim,
|
||||
)
|
||||
attachment_count = len(attachment_names) or self._resolve_attachment_count(context_json)
|
||||
|
||||
final_amount = amount if amount is not None else (claim.amount if claim is not None else Decimal("0.00"))
|
||||
final_occurred_at = (
|
||||
occurred_at if occurred_at is not None else (claim.occurred_at if claim is not None else datetime.now(UTC))
|
||||
)
|
||||
final_expense_type = expense_type or (claim.expense_type if claim is not None else "other")
|
||||
final_location = location or (claim.location if claim is not None else "待补充")
|
||||
final_reason = reason or (claim.reason if claim is not None else "待补充")
|
||||
final_attachment_count = (
|
||||
attachment_count if attachment_count > 0 else int(claim.invoice_count or 0) if claim is not None else 0
|
||||
)
|
||||
final_risk_flags = self._merge_persistent_claim_risk_flags(
|
||||
existing_flags=list(claim.risk_flags_json or []) if claim is not None else [],
|
||||
next_flags=list(ontology.risk_flags),
|
||||
)
|
||||
if context_documents or attachment_names:
|
||||
document_specs = self._build_context_item_specs(
|
||||
context_documents=context_documents,
|
||||
attachment_names=attachment_names,
|
||||
occurred_at=final_occurred_at,
|
||||
expense_type=final_expense_type,
|
||||
amount=final_amount,
|
||||
reason=final_reason,
|
||||
location=final_location,
|
||||
context_json=context_json,
|
||||
employee_grade=str(employee.grade or "").strip() if employee is not None else "",
|
||||
user_id=user_id,
|
||||
)
|
||||
else:
|
||||
document_specs = []
|
||||
|
||||
if claim is not None and review_action == "link_to_existing_draft" and document_specs:
|
||||
duplicate_result = self._build_duplicate_attachment_block_result(
|
||||
claim=claim,
|
||||
document_specs=document_specs,
|
||||
context_documents=context_documents,
|
||||
)
|
||||
if duplicate_result is not None:
|
||||
return duplicate_result
|
||||
|
||||
try:
|
||||
if claim is None:
|
||||
claim = ExpenseClaim(
|
||||
claim_no=self._generate_claim_no(final_occurred_at),
|
||||
employee_id=employee.id if employee is not None else None,
|
||||
employee_name=draft_owner_name,
|
||||
department_id=employee.organization_unit_id if employee is not None else None,
|
||||
department_name=self._resolve_department_name(
|
||||
employee=employee,
|
||||
context_json=context_json,
|
||||
),
|
||||
project_code=self._resolve_project_code(ontology.entities),
|
||||
expense_type=final_expense_type,
|
||||
reason=final_reason,
|
||||
location=final_location,
|
||||
amount=final_amount,
|
||||
currency="CNY",
|
||||
invoice_count=final_attachment_count,
|
||||
occurred_at=final_occurred_at,
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=final_risk_flags,
|
||||
)
|
||||
self.db.add(claim)
|
||||
else:
|
||||
claim.employee_id = employee.id if employee is not None else claim.employee_id
|
||||
claim.employee_name = (
|
||||
employee.name
|
||||
if employee is not None
|
||||
else self._resolve_employee_name(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
fallback=claim.employee_name,
|
||||
)
|
||||
)
|
||||
claim.department_id = employee.organization_unit_id if employee is not None else claim.department_id
|
||||
claim.department_name = self._resolve_department_name(
|
||||
employee=employee,
|
||||
context_json=context_json,
|
||||
fallback=claim.department_name,
|
||||
)
|
||||
claim.project_code = self._resolve_project_code(ontology.entities) or claim.project_code
|
||||
claim.expense_type = final_expense_type
|
||||
claim.reason = final_reason
|
||||
claim.location = final_location
|
||||
claim.amount = final_amount
|
||||
claim.invoice_count = final_attachment_count
|
||||
claim.occurred_at = final_occurred_at
|
||||
claim.status = "draft"
|
||||
claim.approval_stage = "待提交"
|
||||
claim.risk_flags_json = final_risk_flags
|
||||
|
||||
self.db.flush()
|
||||
if document_specs and (is_new_claim or review_action in DOCUMENT_ASSOCIATION_REVIEW_ACTIONS):
|
||||
if review_action == "link_to_existing_draft" and claim.items:
|
||||
self._append_document_items(
|
||||
claim=claim,
|
||||
item_specs=document_specs,
|
||||
)
|
||||
else:
|
||||
self._replace_claim_items(
|
||||
claim=claim,
|
||||
item_specs=document_specs,
|
||||
)
|
||||
self._sync_claim_from_items(claim)
|
||||
else:
|
||||
self._upsert_primary_item(
|
||||
claim=claim,
|
||||
occurred_at=final_occurred_at,
|
||||
expense_type=final_expense_type,
|
||||
amount=final_amount,
|
||||
reason=final_reason,
|
||||
location=final_location,
|
||||
attachment_names=attachment_names,
|
||||
)
|
||||
self._sync_claim_from_items(claim)
|
||||
if locked_expense_type:
|
||||
claim.expense_type = locked_expense_type
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
except IntegrityError as exc:
|
||||
self.db.rollback()
|
||||
if (
|
||||
is_new_claim
|
||||
and retry_count < MAX_CLAIM_NO_RETRY_ATTEMPTS
|
||||
and self._is_claim_no_conflict_error(exc)
|
||||
):
|
||||
retry_context = dict(context_json)
|
||||
retry_context["_claim_no_retry_count"] = retry_count + 1
|
||||
return self.upsert_draft_from_ontology(
|
||||
run_id=run_id,
|
||||
user_id=user_id,
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json=retry_context,
|
||||
)
|
||||
raise
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=user_id or claim.employee_name or "anonymous",
|
||||
action="expense_claim.draft_upsert",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
request_id=run_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"message": (
|
||||
f"已{'创建' if is_new_claim else '更新'}报销草稿 {claim.claim_no},当前状态为 draft。"
|
||||
"请核对识别结果,确认无误后继续提交。"
|
||||
),
|
||||
"draft_only": True,
|
||||
"claim_id": claim.id,
|
||||
"claim_no": claim.claim_no,
|
||||
"status": claim.status,
|
||||
"amount": float(claim.amount),
|
||||
"invoice_count": int(claim.invoice_count or 0),
|
||||
}
|
||||
|
||||
def _find_target_claim(
|
||||
self,
|
||||
*,
|
||||
ontology: OntologyParseResult,
|
||||
context_json: dict[str, Any],
|
||||
review_action: str = "",
|
||||
association_candidate: ExpenseClaim | None = None,
|
||||
) -> ExpenseClaim | None:
|
||||
if review_action == "create_new_claim_from_documents":
|
||||
return None
|
||||
if review_action == "link_to_existing_draft" and association_candidate is not None:
|
||||
return association_candidate
|
||||
|
||||
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
|
||||
if draft_claim_id:
|
||||
claim = self.db.get(ExpenseClaim, draft_claim_id)
|
||||
if claim is not None and self._is_editable_claim_status(claim.status):
|
||||
return claim
|
||||
return None
|
||||
|
||||
claim_codes = [
|
||||
item.normalized_value
|
||||
for item in ontology.entities
|
||||
if item.type == "expense_claim" and item.normalized_value
|
||||
]
|
||||
if not claim_codes:
|
||||
return None
|
||||
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.where(ExpenseClaim.claim_no.in_(claim_codes))
|
||||
.where(ExpenseClaim.status.in_(EDITABLE_CLAIM_STATUSES))
|
||||
.limit(1)
|
||||
)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
def _find_association_candidate(
|
||||
self,
|
||||
*,
|
||||
ontology: OntologyParseResult,
|
||||
context_json: dict[str, Any],
|
||||
user_id: str | None,
|
||||
employee: Employee | None,
|
||||
) -> ExpenseClaim | None:
|
||||
draft_claim_id = str(context_json.get("draft_claim_id") or "").strip()
|
||||
if draft_claim_id:
|
||||
claim = self.db.get(ExpenseClaim, draft_claim_id)
|
||||
if claim is not None and self._is_editable_claim_status(claim.status):
|
||||
return claim
|
||||
|
||||
owner_filters = self._build_draft_owner_filters(
|
||||
employee=employee,
|
||||
user_id=user_id,
|
||||
)
|
||||
if not owner_filters:
|
||||
fallback_name = self._resolve_employee_name(
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
user_id=user_id,
|
||||
fallback="",
|
||||
)
|
||||
if fallback_name:
|
||||
owner_filters = [ExpenseClaim.employee_name == fallback_name]
|
||||
|
||||
if not owner_filters:
|
||||
return None
|
||||
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.where(ExpenseClaim.status.in_(EDITABLE_CLAIM_STATUSES))
|
||||
.where(or_(*owner_filters))
|
||||
.order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
def _should_defer_multi_document_association(
|
||||
self,
|
||||
*,
|
||||
context_json: dict[str, Any],
|
||||
review_action: str,
|
||||
association_candidate: ExpenseClaim | None,
|
||||
context_documents: list[dict[str, Any]],
|
||||
) -> bool:
|
||||
if association_candidate is None:
|
||||
return False
|
||||
if review_action in DOCUMENT_ASSOCIATION_REVIEW_ACTIONS:
|
||||
return False
|
||||
document_count = max(
|
||||
len(context_documents),
|
||||
len(self._resolve_attachment_names(context_json)),
|
||||
self._resolve_attachment_count(context_json),
|
||||
)
|
||||
return document_count > 1
|
||||
|
||||
def _replace_claim_items(
|
||||
self,
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
item_specs: list[dict[str, Any]],
|
||||
) -> None:
|
||||
existing_items = sorted(
|
||||
list(claim.items),
|
||||
key=lambda item: (
|
||||
item.item_date or date.max,
|
||||
self._normalize_sort_datetime(item.created_at),
|
||||
),
|
||||
)
|
||||
for index, spec in enumerate(item_specs):
|
||||
item = existing_items[index] if index < len(existing_items) else None
|
||||
if item is None:
|
||||
item = ExpenseClaimItem(claim_id=claim.id)
|
||||
claim.items.append(item)
|
||||
self.db.add(item)
|
||||
item.item_date = spec["item_date"]
|
||||
item.item_type = spec["item_type"]
|
||||
item.item_reason = spec["item_reason"]
|
||||
item.item_location = spec["item_location"]
|
||||
item.item_amount = spec["item_amount"]
|
||||
item.invoice_id = (
|
||||
None
|
||||
if str(spec.get("item_type") or "").strip() in SYSTEM_GENERATED_ITEM_TYPES
|
||||
else self._attachment_presentation.merge_reference(item.invoice_id, spec["invoice_id"])
|
||||
)
|
||||
|
||||
for stale_item in existing_items[len(item_specs) :]:
|
||||
claim.items.remove(stale_item)
|
||||
self.db.delete(stale_item)
|
||||
|
||||
def _append_document_items(
|
||||
self,
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
item_specs: list[dict[str, Any]],
|
||||
) -> None:
|
||||
system_specs = [
|
||||
spec for spec in item_specs if str(spec.get("item_type") or "").strip() in SYSTEM_GENERATED_ITEM_TYPES
|
||||
]
|
||||
normal_specs = [
|
||||
spec for spec in item_specs if str(spec.get("item_type") or "").strip() not in SYSTEM_GENERATED_ITEM_TYPES
|
||||
]
|
||||
existing_invoice_ids = {
|
||||
str(item.invoice_id or "").strip()
|
||||
for item in claim.items
|
||||
if str(item.invoice_id or "").strip()
|
||||
}
|
||||
existing_invoice_names = {
|
||||
self._attachment_presentation.resolve_display_name(item.invoice_id)
|
||||
for item in claim.items
|
||||
if str(item.invoice_id or "").strip()
|
||||
}
|
||||
for spec in normal_specs:
|
||||
invoice_id = str(spec.get("invoice_id") or "").strip()
|
||||
invoice_name = self._attachment_presentation.resolve_display_name(invoice_id)
|
||||
if invoice_id and (invoice_id in existing_invoice_ids or invoice_name in existing_invoice_names):
|
||||
continue
|
||||
claim.items.append(
|
||||
ExpenseClaimItem(
|
||||
claim_id=claim.id,
|
||||
item_date=spec["item_date"],
|
||||
item_type=spec["item_type"],
|
||||
item_reason=spec["item_reason"],
|
||||
item_location=spec["item_location"],
|
||||
item_amount=spec["item_amount"],
|
||||
invoice_id=spec["invoice_id"],
|
||||
)
|
||||
)
|
||||
self.db.add(claim.items[-1])
|
||||
if invoice_id:
|
||||
existing_invoice_ids.add(invoice_id)
|
||||
existing_invoice_names.add(invoice_name)
|
||||
|
||||
if system_specs:
|
||||
existing_system_items = [
|
||||
item for item in list(claim.items) if str(item.item_type or "").strip() in SYSTEM_GENERATED_ITEM_TYPES
|
||||
]
|
||||
for stale_item in existing_system_items:
|
||||
claim.items.remove(stale_item)
|
||||
self.db.delete(stale_item)
|
||||
for spec in system_specs:
|
||||
claim.items.append(
|
||||
ExpenseClaimItem(
|
||||
claim_id=claim.id,
|
||||
item_date=spec["item_date"],
|
||||
item_type=spec["item_type"],
|
||||
item_reason=spec["item_reason"],
|
||||
item_location=spec["item_location"],
|
||||
item_amount=spec["item_amount"],
|
||||
invoice_id=spec["invoice_id"],
|
||||
)
|
||||
)
|
||||
self.db.add(claim.items[-1])
|
||||
|
||||
def _build_duplicate_attachment_block_result(
|
||||
self,
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
document_specs: list[dict[str, Any]],
|
||||
context_documents: list[dict[str, Any]],
|
||||
) -> dict[str, Any] | None:
|
||||
duplicate_matches = self._find_duplicate_attachment_matches(
|
||||
claim=claim,
|
||||
document_specs=document_specs,
|
||||
context_documents=context_documents,
|
||||
)
|
||||
if not duplicate_matches:
|
||||
return None
|
||||
|
||||
duplicate_labels = list(
|
||||
dict.fromkeys(
|
||||
str(item.get("incoming_label") or item.get("existing_label") or "").strip()
|
||||
for item in duplicate_matches
|
||||
if str(item.get("incoming_label") or item.get("existing_label") or "").strip()
|
||||
)
|
||||
)
|
||||
duplicate_text = "、".join(duplicate_labels[:3]) or "本次上传票据"
|
||||
reason = (
|
||||
f"检测到本次上传的票据与草稿 {claim.claim_no} 中已有票据重复:{duplicate_text}。"
|
||||
"请重新上传不同的票据后再归集。"
|
||||
)
|
||||
return {
|
||||
"message": reason,
|
||||
"draft_only": False,
|
||||
"status": "blocked",
|
||||
"duplicate_attachment_blocked": True,
|
||||
"duplicate_invoice_blocked": True,
|
||||
"submission_blocked": True,
|
||||
"submission_blocked_reasons": [reason],
|
||||
"missing_fields": [reason],
|
||||
"risk_flags": ["duplicate_invoice"],
|
||||
"duplicate_attachments": duplicate_matches,
|
||||
"claim_id": claim.id,
|
||||
"claim_no": claim.claim_no,
|
||||
"amount": float(claim.amount or Decimal("0.00")),
|
||||
"invoice_count": int(claim.invoice_count or 0),
|
||||
}
|
||||
Reference in New Issue
Block a user