- 新增 document_preview 模块,DocumentPreviewAssets 统一处理 data URL 解码、pdftoppm PNG 预览生成(poppler-data 编码)、renderer_id 标识 - receipt_folder 服务复用预览生成,缓存票据资产并提供清理;删除票据时保留已关联报销单的附件副本 - document_intelligence 新增票据预览/资产缓存接入与字段提取增强;ocr 抽取复用预览工具,附件分析/文档/操作/展示四个子模块同步适配 - receipt_folder 端点补充资产缓存头,补/扩 document_intelligence、ocr_endpoints、ocr_service、receipt_folder_service、reimbursement_endpoints 测试,新增 attachment_analysis 回归测试
127 lines
4.7 KiB
Python
127 lines
4.7 KiB
Python
from __future__ import annotations
|
|
|
|
import mimetypes
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import quote
|
|
|
|
from app.services.document_preview import DocumentPreviewAssets
|
|
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
|
|
|
|
|
class ExpenseClaimAttachmentPresentation:
|
|
def __init__(self, storage: ExpenseClaimAttachmentStorage) -> None:
|
|
self.storage = storage
|
|
|
|
def build_preview_meta(
|
|
self,
|
|
*,
|
|
file_path: Path,
|
|
media_type: str,
|
|
ocr_document: Any | None,
|
|
) -> dict[str, Any]:
|
|
filename = file_path.name
|
|
storage_key = self.storage.to_storage_key(file_path)
|
|
preview_kind = self.resolve_preview_kind(media_type, filename)
|
|
|
|
preview_data_url = str(getattr(ocr_document, "preview_data_url", "") or "").strip()
|
|
preview_source_kind = str(getattr(ocr_document, "preview_kind", "") or "").strip()
|
|
if preview_source_kind == "image" and preview_data_url:
|
|
preview_asset = self._write_preview_asset_from_data_url(
|
|
attachment_dir=file_path.parent,
|
|
original_filename=filename,
|
|
preview_data_url=preview_data_url,
|
|
)
|
|
if preview_asset is not None:
|
|
preview_path, preview_media_type, preview_file_name = preview_asset
|
|
return {
|
|
"previewable": True,
|
|
"preview_kind": "image",
|
|
"preview_storage_key": self.storage.to_storage_key(preview_path),
|
|
"preview_media_type": preview_media_type,
|
|
"preview_file_name": preview_file_name,
|
|
"preview_rendered_with": DocumentPreviewAssets.renderer_id_for_source(media_type),
|
|
}
|
|
|
|
if preview_kind:
|
|
return {
|
|
"previewable": True,
|
|
"preview_kind": preview_kind,
|
|
"preview_storage_key": storage_key,
|
|
"preview_media_type": media_type,
|
|
"preview_file_name": filename,
|
|
"preview_rendered_with": "",
|
|
}
|
|
|
|
return {
|
|
"previewable": False,
|
|
"preview_kind": "",
|
|
"preview_storage_key": "",
|
|
"preview_media_type": "",
|
|
"preview_file_name": "",
|
|
"preview_rendered_with": "",
|
|
}
|
|
|
|
@staticmethod
|
|
def resolve_preview_kind(media_type: str | None, filename: str) -> str:
|
|
resolved = str(media_type or "").strip() or (mimetypes.guess_type(filename)[0] or "")
|
|
if resolved.startswith("image/"):
|
|
return "image"
|
|
if resolved == "application/pdf":
|
|
return "pdf"
|
|
return ""
|
|
|
|
@staticmethod
|
|
def decode_data_url(payload: str) -> tuple[str, bytes] | None:
|
|
return DocumentPreviewAssets.decode_data_url(payload)
|
|
|
|
def _write_preview_asset_from_data_url(
|
|
self,
|
|
*,
|
|
attachment_dir: Path,
|
|
original_filename: str,
|
|
preview_data_url: str,
|
|
) -> tuple[Path, str, str] | None:
|
|
return DocumentPreviewAssets.write_data_url_preview(
|
|
preview_dir=attachment_dir,
|
|
preview_name_stem=f"{Path(original_filename).stem}.preview",
|
|
preview_data_url=preview_data_url,
|
|
)
|
|
|
|
@staticmethod
|
|
def build_preview_client_path(claim_id: str, item_id: str) -> str:
|
|
return (
|
|
"/reimbursements/claims/"
|
|
f"{quote(str(claim_id or '').strip(), safe='')}"
|
|
f"/items/{quote(str(item_id or '').strip(), safe='')}/attachment/preview"
|
|
)
|
|
|
|
@staticmethod
|
|
def resolve_media_type(filename: str, *, fallback: str | None = None) -> str:
|
|
guessed = mimetypes.guess_type(filename)[0]
|
|
return str(guessed or fallback or "application/octet-stream")
|
|
|
|
@staticmethod
|
|
def is_previewable_media_type(media_type: str | None, filename: str) -> bool:
|
|
resolved = str(media_type or "").strip() or (mimetypes.guess_type(filename)[0] or "")
|
|
return resolved.startswith("image/") or resolved == "application/pdf"
|
|
|
|
@staticmethod
|
|
def resolve_display_name(storage_key: str | None) -> str:
|
|
return Path(str(storage_key or "").strip()).name
|
|
|
|
@classmethod
|
|
def merge_reference(cls, current_invoice_id: str | None, next_invoice_id: str | None) -> str | None:
|
|
normalized_next = str(next_invoice_id or "").strip()
|
|
if not normalized_next:
|
|
return None
|
|
|
|
normalized_current = str(current_invoice_id or "").strip()
|
|
if (
|
|
normalized_current
|
|
and cls.resolve_display_name(normalized_current) == cls.resolve_display_name(normalized_next)
|
|
):
|
|
return normalized_current
|
|
|
|
return normalized_next
|