6 Commits

Author SHA1 Message Date
caoxiaozhu
f17098aa58 chore(repo): 移除误跟踪的 expense_claims 运行时票据文件
server/storage/expense_claims/ 下 30 个用户上传票据/预览图/meta 此前在 gitignore 规则生效前被误提交,运行时会持续产生内容变更。
本次 git rm --cached 移除版本跟踪(本地文件保留),配合已有 server/storage/expense_claims/ 忽略规则,避免后续误入库。
2026-06-23 09:43:07 +08:00
caoxiaozhu
8094333e3b style(web): 通知中心列表行布局重构与时间标签格式化
- TopBar 通知行改为 row-main/row-head/row-foot 结构化布局,标题加粗、分类改为 pill、箭头随标题右侧
- formatNotificationTime 改名 formatNotificationTimeLabel,新增 ISO/短日期直通匹配与超长截断兜底
- 更新 sidebar-document-unread-dot 测试
2026-06-23 09:42:56 +08:00
caoxiaozhu
0122f3b250 chore(rules): 更新交通/通信/差旅/出差等财务规则表 2026-06-23 09:42:43 +08:00
caoxiaozhu
dc4cad2baa chore(env): WEB_PORT 统一回退为 5173 并烟雾检查改为可开关
- .env.example/docker-compose(.full).yml WEB_PORT 默认值 5273→5173(Vite 默认),CORS_ORIGINS 同步
- docker-compose 注入 WEB_PORT/SERVER_PORT 环境变量,健康检查端口随之更新
- start.sh 新增 SERVER_SMOKE_CHECK_ENABLED 开关,默认关闭烟雾检查,仅健康探测
- web/web_start.sh 适配端口
- .gitignore 补充忽略 tmp/ 运行时目录
2026-06-23 09:42:40 +08:00
caoxiaozhu
e725b7f19c feat(web): 票据夹资产缓存接入与 AI 工作台附件流程完善
- ReceiptFolderView 删除票据后提示已关联附件副本保留,接入 useToast;fetchReceiptFolderAsset 加 no-store 避免预览缓存
- PersonalWorkbenchAiMode 附件区/对话气泡适配资产缓存,personal-workbench-ai-mode.css 调整布局
- usePersonalWorkbenchAiMode/useWorkbenchAiApplicationPreviewFlow/useWorkbenchAiAttachmentAssociationFlow/useWorkbenchAiStewardFlow 完善附件草稿选择与关联流程
- travelRequestDetailSmartEntryRecognition 智能识别增强,AppShellRouteView/PersonalWorkbenchView/useApplicationPreviewEditor/useTravelReimbursementSubmitComposer 等配套适配
- 新增 expense-attachment-draft-selection、receipt-folder-asset-cache、travel-request-detail-smart-entry-recognition 测试,更新 attachment-association-confirmation、expense-application-fast-preview、workbench-ai-mode-switch 测试
2026-06-23 09:42:13 +08:00
caoxiaozhu
84a8998e59 feat(server): 票据文件夹资产缓存与文档预览统一生成
- 新增 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 回归测试
2026-06-23 09:42:00 +08:00
81 changed files with 2012 additions and 1028 deletions

View File

@@ -14,9 +14,9 @@ VITE_ADMIN_EMAIL=
# Admin login credentials are stored separately under server/.secrets/
WEB_HOST=0.0.0.0
WEB_PORT=5273
WEB_PORT=5173
VITE_WEB_HOST=0.0.0.0
VITE_WEB_PORT=5273
VITE_WEB_PORT=5173
SERVER_HOST=0.0.0.0
SERVER_PORT=8000
@@ -52,4 +52,4 @@ OCR_DEVICE=
OCR_TIMEOUT_SECONDS=180
OCR_MAX_CONCURRENT_WORKERS=1
CORS_ORIGINS='["http://127.0.0.1:5273","http://localhost:5273","http://0.0.0.0:5273"]'
CORS_ORIGINS='["http://127.0.0.1:5173","http://localhost:5173","http://0.0.0.0:5173"]'

1
.gitignore vendored
View File

@@ -25,6 +25,7 @@ server/storage/receipt_folder/
test-results/
.codex-remote-attachments/
tmp-*.png
tmp/
.nezha/
.omo/
.env

View File

@@ -12,7 +12,9 @@ services:
condition: service_started
environment:
WEB_HOST: 0.0.0.0
WEB_PORT: "${WEB_PORT:-5173}"
SERVER_HOST: 0.0.0.0
SERVER_PORT: "${SERVER_PORT:-8000}"
SERVER_VENV_DIR: /tmp/x-financial-server-venv
X_FINANCIAL_PREFER_ENV_FILE: "false"
POSTGRES_HOST: postgres
@@ -28,7 +30,7 @@ services:
QDRANT_URL: "http://qdrant:6333"
LIGHTRAG_WORKSPACE: "x_financial_knowledge"
ports:
- "${WEB_PORT:-5273}:${WEB_PORT:-5273}"
- "${WEB_PORT:-5173}:${WEB_PORT:-5173}"
- "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}"
- "2223:22"
volumes:
@@ -67,7 +69,7 @@ services:
cd /app &&
./start.sh all
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${WEB_PORT:-5273}/ >/dev/null || exit 1"]
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${WEB_PORT:-5173}/ >/dev/null || exit 1"]
interval: 15s
timeout: 5s
retries: 10

View File

@@ -5,7 +5,9 @@ services:
restart: unless-stopped
environment:
WEB_HOST: 0.0.0.0
WEB_PORT: "${WEB_PORT:-5173}"
SERVER_HOST: 0.0.0.0
SERVER_PORT: "${SERVER_PORT:-8000}"
SERVER_VENV_DIR: /tmp/x-financial-server-venv
X_FINANCIAL_PREFER_ENV_FILE: "true"
ONLYOFFICE_ENABLED: "${ONLYOFFICE_ENABLED:-false}"
@@ -15,7 +17,7 @@ services:
QDRANT_URL: "${QDRANT_URL:-}"
LIGHTRAG_WORKSPACE: "x_financial_knowledge"
ports:
- "${WEB_PORT:-5273}:${WEB_PORT:-5273}"
- "${WEB_PORT:-5173}:${WEB_PORT:-5173}"
- "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}"
- "2223:22"
volumes:
@@ -54,7 +56,7 @@ services:
cd /app &&
./start.sh all
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${WEB_PORT:-5273}/ >/dev/null || exit 1"]
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${WEB_PORT:-5173}/ >/dev/null || exit 1"]
interval: 15s
timeout: 5s
retries: 10

View File

@@ -92,7 +92,7 @@ def preview_receipt(receipt_id: str, current_user: CurrentUser) -> FileResponse:
file_path, media_type, file_name = ReceiptFolderService().resolve_preview(receipt_id, current_user)
except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Receipt preview not found") from exc
return FileResponse(file_path, media_type=media_type, filename=file_name)
return FileResponse(file_path, media_type=media_type, filename=file_name, headers={"Cache-Control": "no-store"})
@router.get(

View File

@@ -25,11 +25,15 @@ AMOUNT_PATTERNS = (
re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)"),
re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"),
)
DATE_PATTERN = re.compile(r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.](?:3[01]|[12]\d|0?[1-9])日?)")
DATE_PATTERN = re.compile(
r"((?:20\d{2}|19\d{2})(?:[-/年.]|\s+)(?:1[0-2]|0?[1-9])"
r"(?:[-/月.]|\s+)(?:3[01]|[12]\d|0?[1-9])日?)"
)
TIME_PATTERN = re.compile(r"(?<!\d)([01]?\d|2[0-3])[:]([0-5]\d)(?!\d)")
INVOICE_NUMBER_PATTERN = re.compile(r"(?:发票号码|票号|单号|订单号)[:\s]*([A-Za-z0-9-]{6,24})")
INVOICE_CODE_PATTERN = re.compile(r"(?:发票代码)[:\s]*([A-Za-z0-9-]{6,24})")
TRIP_NO_PATTERN = re.compile(r"(?:车次|航班(?:号)?)[:\s]*([A-Za-z0-9]{2,12})")
TRAIN_STANDALONE_NO_PATTERN = re.compile(r"(?<![A-Za-z0-9])([GCDZKTLYS]\d{1,5})(?![A-Za-z0-9])", re.IGNORECASE)
ROUTE_PATTERN = re.compile(r"([\u4e00-\u9fa5]{2,12})\s*(?:至|→|->|-)\s*([\u4e00-\u9fa5]{2,12})")
MERCHANT_PATTERNS = (
re.compile(r"(?:销售方(?:名称)?|商户(?:名称)?|开票方(?:名称)?|收款方(?:名称)?)[:\s]*([A-Za-z0-9\u4e00-\u9fa5()·&\\-]{2,40})"),
@@ -300,6 +304,14 @@ def _match_document_rule(compact_text: str) -> RuleMatch:
best_score = score
if best_score <= 0:
train_rule = DOCUMENT_TYPE_RULE_MAP.get("train_ticket")
if train_rule and _looks_like_train_ticket(compact_text):
return RuleMatch(
rule=train_rule,
confidence=0.82,
evidence=("车次", "12306"),
score=3.8,
)
return RuleMatch(rule=None, confidence=0.0, evidence=(), score=0.0)
confidence = min(0.94, 0.30 + min(best_score, 4.8) * 0.12)
@@ -311,6 +323,17 @@ def _match_document_rule(compact_text: str) -> RuleMatch:
)
def _looks_like_train_ticket(compact_text: str) -> bool:
text = str(compact_text or "").lower()
if not re.search(r"[gcdzktlys]\d{1,5}", text, flags=re.IGNORECASE):
return False
if "12306" in text or "95306" in text:
return True
if re.search(r"[\u4e00-\u9fa5]{2,12}(?:至|到|→|->|—||-)[\u4e00-\u9fa5]{2,12}", text):
return True
return "wuhan" in text and "shanghai" in text
def _extract_json_payload(response_text: str | None) -> dict[str, Any] | None:
if not response_text:
return None
@@ -521,33 +544,48 @@ def _merge_document_fields(
def _extract_document_fields(text: str, document_type: str = "") -> list[DocumentField]:
fields: list[DocumentField] = []
normalized_type = str(document_type or "").strip().lower()
def append_field(key: str, label: str, value: str) -> None:
cleaned = _clean_field_value(value)
if not cleaned:
return
if any(field.key == key for field in fields if field.key):
return
fields.append(DocumentField(key=key, label=label, value=cleaned))
amount = _extract_amount(text)
if amount:
fields.append(DocumentField(key="amount", label="金额", value=amount))
append_field("amount", "金额", amount)
date_value = _extract_date(text, document_type=document_type)
if date_value:
fields.append(DocumentField(key="date", label="日期", value=date_value))
append_field("date", "日期", date_value)
merchant = _extract_merchant(text)
if merchant:
fields.append(DocumentField(key="merchant_name", label="商户", value=merchant))
append_field("merchant_name", "商户", merchant)
invoice_number = _extract_pattern(INVOICE_NUMBER_PATTERN, text)
if invoice_number:
fields.append(DocumentField(key="invoice_number", label="票据号码", value=invoice_number))
append_field("invoice_number", "票据号码", invoice_number)
invoice_code = _extract_pattern(INVOICE_CODE_PATTERN, text)
if invoice_code:
fields.append(DocumentField(key="invoice_code", label="发票代码", value=invoice_code))
append_field("invoice_code", "发票代码", invoice_code)
trip_no = _extract_pattern(TRIP_NO_PATTERN, text)
if not trip_no and normalized_type == "train_ticket":
trip_no = _extract_pattern(TRAIN_STANDALONE_NO_PATTERN, text)
if trip_no:
fields.append(DocumentField(key="trip_no", label="车次/航班", value=trip_no))
append_field("trip_no", "车次/航班", trip_no.upper())
route = _extract_route(text)
if route:
fields.append(DocumentField(key="route", label="行程", value=route))
append_field("route", "行程", route)
if normalized_type == "train_ticket" and not any(field.key == "amount" for field in fields):
append_field("amount", "金额", _extract_loose_decimal_amount(text))
return fields
@@ -621,6 +659,7 @@ def _format_date_match_with_time(text: str, match: re.Match[str]) -> str:
raw_value = str(match.group(1) or "").strip()
normalized = raw_value.replace("", "-").replace("", "-").replace("", "")
normalized = normalized.replace("/", "-").replace(".", "-")
normalized = re.sub(r"\s+", "-", normalized)
parts = [part for part in normalized.split("-") if part]
if len(parts) != 3:
return raw_value
@@ -703,6 +742,23 @@ def _extract_route(text: str) -> str:
return f"{start}-{end}"
def _extract_loose_decimal_amount(text: str) -> str:
best_value: Decimal | None = None
for match in re.finditer(r"(?<!\d)(\d{1,6}\.\d{1,2})(?!\d)", str(text or "")):
try:
candidate = Decimal(match.group(1)).quantize(Decimal("0.01"))
except InvalidOperation:
continue
if candidate <= Decimal("0.00"):
continue
if best_value is None or candidate > best_value:
best_value = candidate
if best_value is None:
return ""
text_value = format(best_value, "f").rstrip("0").rstrip(".")
return f"{text_value}"
def _extract_pattern(pattern: re.Pattern[str], text: str) -> str:
match = pattern.search(text)
if not match:

View File

@@ -0,0 +1,98 @@
from __future__ import annotations
import base64
import binascii
import mimetypes
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
class DocumentPreviewAssets:
PDF_RENDERER_ID = "pdftoppm-png-r160-poppler-data"
PDF_PREVIEW_MEDIA_TYPE = "image/png"
PDF_PREVIEW_SUFFIX = ".png"
@staticmethod
def decode_data_url(payload: str) -> tuple[str, bytes] | None:
normalized = str(payload or "").strip()
matched = re.match(
r"^data:(?P<media>[\w.+-]+/[\w.+-]+);base64,(?P<body>.+)$",
normalized,
flags=re.DOTALL,
)
if not matched:
return None
try:
content = base64.b64decode(matched.group("body"), validate=True)
except (binascii.Error, ValueError):
return None
return matched.group("media"), content
@classmethod
def renderer_id_for_source(cls, media_type: str | None) -> str:
return cls.PDF_RENDERER_ID if str(media_type or "").strip() == "application/pdf" else ""
@classmethod
def write_data_url_preview(
cls,
*,
preview_dir: Path,
preview_name_stem: str,
preview_data_url: str,
) -> tuple[Path, str, str] | None:
decoded = cls.decode_data_url(preview_data_url)
if decoded is None:
return None
preview_media_type, preview_content = decoded
suffix = mimetypes.guess_extension(preview_media_type) or ".bin"
preview_name = f"{Path(preview_name_stem).stem}{suffix}"
preview_path = preview_dir / preview_name
preview_path.write_bytes(preview_content)
return preview_path, preview_media_type, preview_name
@classmethod
def render_pdf_first_page(
cls,
*,
pdf_path: Path,
preview_path: Path,
timeout_seconds: int | float,
) -> Path:
preview_path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=".pdf-preview-", dir=str(preview_path.parent)) as temp_dir:
prefix = Path(temp_dir) / "page"
completed = subprocess.run(
[
"pdftoppm",
"-png",
"-r",
"160",
str(pdf_path),
str(prefix),
],
capture_output=True,
text=True,
timeout=timeout_seconds,
check=False,
)
if completed.returncode != 0:
detail = (completed.stderr or completed.stdout or "").strip()
raise RuntimeError(detail or "pdftoppm failed to render PDF preview.")
pages = sorted(Path(temp_dir).glob("page-*.png"), key=cls._extract_pdf_page_sort_key)
if not pages:
raise RuntimeError("pdftoppm did not generate a preview image.")
shutil.copyfile(pages[0], preview_path)
return preview_path
@staticmethod
def _extract_pdf_page_sort_key(path: Path) -> tuple[int, str]:
suffix = path.stem.rsplit("-", 1)[-1]
try:
return int(suffix), path.name
except ValueError:
return 0, path.name

View File

@@ -336,7 +336,27 @@ class ExpenseClaimAttachmentAnalysisMixin:
@staticmethod
def _has_date_like_text(text: str) -> bool:
return bool(re.search(r"(20\d{2}[年/\-.]\d{1,2}[月/\-.]\d{1,2}日?)", text))
return bool(re.search(r"(20\d{2}(?:[年/\-.]|\s+)\d{1,2}(?:[月/\-.]|\s+)\d{1,2}日?)", text))
@staticmethod
def _has_document_date_field(document_info: dict[str, Any]) -> bool:
date_keys = DOCUMENT_TRIP_DATE_KEYS | DOCUMENT_GENERIC_DATE_KEYS | DOCUMENT_INVOICE_DATE_KEYS
date_label_tokens = (
*DOCUMENT_TRIP_DATE_LABEL_TOKENS,
*DOCUMENT_GENERIC_DATE_LABEL_TOKENS,
*DOCUMENT_INVOICE_DATE_LABEL_TOKENS,
)
for field in list(document_info.get("fields") or []):
if not isinstance(field, dict):
continue
value = str(field.get("value") or "").strip()
if not value:
continue
key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "")
if key in date_keys or any(token in label for token in date_label_tokens):
return True
return False
@staticmethod
def _normalize_match_text(text: str) -> str:
@@ -538,6 +558,12 @@ class ExpenseClaimAttachmentAnalysisMixin:
recognized_document_label = str(document_info.get("document_type_label") or "其他单据").strip() or "其他单据"
requirement_matches = bool(requirement_check.get("matches"))
mismatch_severity = str(requirement_check.get("mismatch_severity") or "high").strip().lower() or "high"
document_fields = [
field
for field in list(document_info.get("fields") or [])
if isinstance(field, dict) and str(field.get("value") or "").strip()
]
has_readable_content = bool(line_count > 0 or compact_text or document_fields)
has_ticket_keyword = any(
keyword in compact_text
@@ -556,15 +582,18 @@ class ExpenseClaimAttachmentAnalysisMixin:
)
)
amount_candidates = self._extract_amount_candidates(text)
field_amount = self._resolve_document_field_amount({"document_fields": document_fields})
if field_amount is not None and field_amount not in amount_candidates:
amount_candidates.insert(0, field_amount)
item_amount = Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
has_matching_amount = any(abs(candidate - item_amount) <= Decimal("1.00") for candidate in amount_candidates)
has_date_text = self._has_date_like_text(text)
has_date_text = self._has_date_like_text(text) or self._has_document_date_field(document_info)
amount_mismatch = bool(amount_candidates) and item_amount > Decimal("0.00") and not has_matching_amount
points: list[str] = []
if warnings:
points.append(f"识别提示:{warnings[0]}")
if line_count == 0 or not compact_text:
if not has_readable_content:
points.append("附件内容:未识别到有效文字,当前附件更像普通图片或内容过于模糊。")
if recognized_document_type == "other" and not has_ticket_keyword:
points.append("票据类型:未识别到发票、票据、电子行程单等关键字,暂无法判断票据类型。")
@@ -617,8 +646,7 @@ class ExpenseClaimAttachmentAnalysisMixin:
headline = "AI提示住宿金额超出报销标准"
summary = "当前住宿票据金额超过规则中心差旅住宿标准,已作为风险项保留在单据中;如需按特殊情况提交,请补充超标原因。"
elif (
line_count == 0
or not compact_text
not has_readable_content
or (recognized_document_type == "other" and not has_ticket_keyword and issue_count >= 2)
or (not requirement_matches and mismatch_severity == "high")
or (purpose_mismatch_point and amount_mismatch)

View File

@@ -119,6 +119,13 @@ class ExpenseClaimAttachmentDocumentMixin:
metadata=metadata,
item=item,
)
metadata = self._refresh_pdf_attachment_preview_meta_if_needed(
file_path=file_path,
metadata=metadata,
)
if self._attachment_metadata_needs_analysis_refresh(metadata):
self._refresh_item_attachment_analysis(item)
metadata = self._attachment_storage.read_meta(file_path)
uploaded_at_value = metadata.get("uploaded_at")
uploaded_at = None
if isinstance(uploaded_at_value, str) and uploaded_at_value.strip():
@@ -157,6 +164,68 @@ class ExpenseClaimAttachmentDocumentMixin:
"requirement_check": requirement_check,
}
@classmethod
def _attachment_metadata_needs_analysis_refresh(cls, metadata: dict[str, Any]) -> bool:
analysis = metadata.get("analysis")
if not isinstance(analysis, dict):
return cls._attachment_metadata_has_ocr_signal(metadata)
points = [
str(point or "").strip()
for point in list(analysis.get("points") or [])
if str(point or "").strip()
]
if not points:
return False
if any("未识别到有效文字" in point for point in points):
return cls._attachment_metadata_has_readable_signal(metadata)
if any("未识别到列车出发时间" in point or "未识别到开票日期" in point for point in points):
return cls._attachment_metadata_has_date_field(metadata)
return False
@classmethod
def _attachment_metadata_has_ocr_signal(cls, metadata: dict[str, Any]) -> bool:
return bool(
str(metadata.get("ocr_text") or "").strip()
or str(metadata.get("ocr_summary") or "").strip()
or int(metadata.get("ocr_line_count") or 0) > 0
or cls._attachment_metadata_document_fields(metadata)
)
@classmethod
def _attachment_metadata_has_readable_signal(cls, metadata: dict[str, Any]) -> bool:
return bool(
str(metadata.get("ocr_text") or "").strip()
or str(metadata.get("ocr_summary") or "").strip()
or int(metadata.get("ocr_line_count") or 0) > 0
or cls._attachment_metadata_document_fields(metadata)
)
@staticmethod
def _attachment_metadata_document_fields(metadata: dict[str, Any]) -> list[dict[str, Any]]:
document_info = metadata.get("document_info")
if not isinstance(document_info, dict):
return []
return [
field
for field in list(document_info.get("fields") or [])
if isinstance(field, dict) and str(field.get("value") or "").strip()
]
@classmethod
def _attachment_metadata_has_date_field(cls, metadata: dict[str, Any]) -> bool:
for field in cls._attachment_metadata_document_fields(metadata):
key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "")
if key in {"date", "tripdate", "departuredate", "invoicedate"}:
return True
if any(token in label for token in ("日期", "时间", "出发")):
return True
return False
def _build_attachment_document_info(self, document: Any) -> dict[str, Any]:
insight = build_document_insight(
filename=str(getattr(document, "filename", "") or ""),

View File

@@ -32,6 +32,7 @@ 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
@@ -238,6 +239,7 @@ class ExpenseClaimAttachmentOperationsMixin:
"preview_storage_key": str(preview_meta["preview_storage_key"]),
"preview_media_type": str(preview_meta["preview_media_type"]),
"preview_file_name": str(preview_meta["preview_file_name"]),
"preview_rendered_with": str(preview_meta.get("preview_rendered_with") or ""),
"analysis": attachment_analysis,
"document_info": document_info,
"requirement_check": requirement_check,
@@ -673,6 +675,60 @@ class ExpenseClaimAttachmentOperationsMixin:
self._attachment_storage.write_meta(file_path, metadata)
return metadata
def _refresh_pdf_attachment_preview_meta_if_needed(
self,
*,
file_path: Path,
metadata: dict[str, Any],
) -> dict[str, Any]:
if not metadata:
return metadata
media_type = str(
metadata.get("media_type")
or self._attachment_presentation.resolve_media_type(file_path.name)
).strip()
if media_type != "application/pdf":
return metadata
preview_storage_key = str(metadata.get("preview_storage_key") or "").strip()
preview_path = self._attachment_storage.resolve_path(preview_storage_key) if preview_storage_key else None
if (
preview_path is not None
and preview_path.exists()
and str(metadata.get("preview_kind") or "").strip() == "image"
and str(metadata.get("preview_media_type") or "").strip() == DocumentPreviewAssets.PDF_PREVIEW_MEDIA_TYPE
and str(metadata.get("preview_rendered_with") or "").strip() == DocumentPreviewAssets.PDF_RENDERER_ID
):
return metadata
preview_name = str(metadata.get("preview_file_name") or "").strip()
if not preview_name or not preview_name.lower().endswith(DocumentPreviewAssets.PDF_PREVIEW_SUFFIX):
preview_name = f"{file_path.stem}.preview{DocumentPreviewAssets.PDF_PREVIEW_SUFFIX}"
preview_path = file_path.parent / preview_name
try:
DocumentPreviewAssets.render_pdf_first_page(
pdf_path=file_path,
preview_path=preview_path,
timeout_seconds=OcrService(self.db).settings.ocr_timeout_seconds,
)
except Exception:
return metadata
metadata.update(
{
"previewable": True,
"preview_kind": "image",
"preview_storage_key": self._attachment_storage.to_storage_key(preview_path),
"preview_media_type": DocumentPreviewAssets.PDF_PREVIEW_MEDIA_TYPE,
"preview_file_name": preview_path.name,
"preview_rendered_with": DocumentPreviewAssets.PDF_RENDERER_ID,
}
)
self._attachment_storage.write_meta(file_path, metadata)
return metadata
def _resolve_item_attachment_preview_content(self, item: ExpenseClaimItem) -> tuple[Path, str, str]:
file_path, media_type, filename = self._resolve_item_attachment_content(item)
metadata = self._attachment_storage.read_meta(file_path)
@@ -681,6 +737,10 @@ class ExpenseClaimAttachmentOperationsMixin:
metadata=metadata,
item=item,
)
metadata = self._refresh_pdf_attachment_preview_meta_if_needed(
file_path=file_path,
metadata=metadata,
)
preview_storage_key = str(metadata.get("preview_storage_key") or "").strip()
preview_file_name = str(metadata.get("preview_file_name") or "").strip()
preview_media_type = str(metadata.get("preview_media_type") or "").strip()

View File

@@ -1,13 +1,11 @@
from __future__ import annotations
import base64
import binascii
import mimetypes
import re
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
@@ -42,6 +40,7 @@ class ExpenseClaimAttachmentPresentation:
"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:
@@ -51,6 +50,7 @@ class ExpenseClaimAttachmentPresentation:
"preview_storage_key": storage_key,
"preview_media_type": media_type,
"preview_file_name": filename,
"preview_rendered_with": "",
}
return {
@@ -59,6 +59,7 @@ class ExpenseClaimAttachmentPresentation:
"preview_storage_key": "",
"preview_media_type": "",
"preview_file_name": "",
"preview_rendered_with": "",
}
@staticmethod
@@ -72,15 +73,7 @@ class ExpenseClaimAttachmentPresentation:
@staticmethod
def decode_data_url(payload: str) -> tuple[str, bytes] | None:
normalized = str(payload or "").strip()
matched = re.match(r"^data:(?P<media>[\w.+-]+/[\w.+-]+);base64,(?P<body>.+)$", normalized, flags=re.DOTALL)
if not matched:
return None
try:
content = base64.b64decode(matched.group("body"), validate=True)
except (binascii.Error, ValueError):
return None
return matched.group("media"), content
return DocumentPreviewAssets.decode_data_url(payload)
def _write_preview_asset_from_data_url(
self,
@@ -89,16 +82,11 @@ class ExpenseClaimAttachmentPresentation:
original_filename: str,
preview_data_url: str,
) -> tuple[Path, str, str] | None:
decoded = self.decode_data_url(preview_data_url)
if decoded is None:
return None
preview_media_type, preview_content = decoded
suffix = mimetypes.guess_extension(preview_media_type) or ".bin"
preview_name = f"{Path(original_filename).stem}.preview{suffix}"
preview_path = attachment_dir / preview_name
preview_path.write_bytes(preview_content)
return preview_path, preview_media_type, preview_name
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:

View File

@@ -537,7 +537,7 @@ class OcrService:
if page_summary:
aggregated.summary_fragments.append(page_summary)
page_text = str(payload.get("text", "") or "").strip()
page_text = self._resolve_worker_document_text(payload)
if page_text:
aggregated.text_fragments.append(page_text)
@@ -626,6 +626,22 @@ class OcrService:
return descriptor.text_layer
return ""
@staticmethod
def _resolve_worker_document_text(payload: dict) -> str:
for key in ("text", "ocr_text", "raw_text", "full_text"):
value = str(payload.get(key, "") or "").strip()
if value:
return value
lines = payload.get("lines", [])
if not isinstance(lines, list):
return ""
return "\n".join(
str(item.get("text", "") or "").strip()
for item in lines
if isinstance(item, dict) and str(item.get("text", "") or "").strip()
).strip()
@staticmethod
def _build_lines(
items: list[dict],

View File

@@ -12,7 +12,7 @@ from uuid import uuid4
from app.api.deps import CurrentUserContext
from app.core.config import get_settings
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead, OcrRecognizeFieldRead
from app.schemas.receipt_folder import (
ReceiptFolderDeleteResponse,
ReceiptFolderDetailRead,
@@ -20,11 +20,13 @@ from app.schemas.receipt_folder import (
ReceiptFolderItemRead,
ReceiptFolderUpdate,
)
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
from app.services.document_preview import DocumentPreviewAssets
from app.services.document_intelligence import build_document_insight
from app.services.ocr import SUPPORTED_SUFFIXES
RECEIPT_DATE_PATTERN = re.compile(
r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.](?:3[01]|[12]\d|0?[1-9])日?)"
r"((?:20\d{2}|19\d{2})(?:[-/年.]|\s+)(?:1[0-2]|0?[1-9])"
r"(?:[-/月.]|\s+)(?:3[01]|[12]\d|0?[1-9])日?)"
)
RECEIPT_TIME_PATTERN = re.compile(r"(?<!\d)([01]?\d|2[0-3])[:]([0-5]\d)(?!\d)")
TRAIN_INVOICE_DATE_PATTERN = re.compile(
@@ -45,7 +47,9 @@ TRAIN_SEAT_CLASS_PATTERN = re.compile(r"(商务座|特等座|一等座|二等座
TRAIN_CARRIAGE_PATTERN = re.compile(r"(?:车厢|车厢号)\s*[:]?\s*([0-9]{1,2}\s*车?)")
TRAIN_SEAT_NO_PATTERN = re.compile(r"(?:座位|座位号)\s*[:]?\s*([0-9]{1,3}[A-F号]?)", re.IGNORECASE)
TRAIN_COMBINED_SEAT_PATTERN = re.compile(r"([0-9]{1,2})车\s*([0-9]{1,3}[A-F])号?", re.IGNORECASE)
TRAIN_LOOSE_SEAT_PATTERN = re.compile(r"(?<!\d)([0-9]{1,2})\s+([0-9]{1,3}[A-F])(?![A-Za-z0-9])", re.IGNORECASE)
TRAIN_FARE_PATTERN = re.compile(r"(?:票价|金额)\s*[::¥¥\s]*([0-9]+(?:[.,][0-9]{1,2})?)")
TRAIN_LOOSE_FARE_PATTERN = re.compile(r"(?<!\d)([0-9]{1,6}\.\d{1,2})(?!\d)")
class ReceiptFolderStorageMixin:
@@ -101,18 +105,19 @@ class ReceiptFolderStorageMixin:
document: Any | None,
) -> dict[str, Any]:
preview_data_url = str(getattr(document, "preview_data_url", "") or "").strip()
decoded = ExpenseClaimAttachmentPresentation.decode_data_url(preview_data_url)
if decoded is not None:
preview_media_type, preview_content = decoded
suffix = mimetypes.guess_extension(preview_media_type) or ".bin"
preview_name = f"preview{suffix}"
preview_path = receipt_dir / preview_name
preview_path.write_bytes(preview_content)
preview_asset = DocumentPreviewAssets.write_data_url_preview(
preview_dir=receipt_dir,
preview_name_stem="preview",
preview_data_url=preview_data_url,
)
if preview_asset is not None:
_, preview_media_type, preview_name = preview_asset
return {
"previewable": True,
"preview_kind": "image",
"preview_file_name": preview_name,
"preview_media_type": preview_media_type,
"preview_rendered_with": DocumentPreviewAssets.renderer_id_for_source(media_type),
}
if self._is_previewable(media_type):
return {
@@ -120,14 +125,67 @@ class ReceiptFolderStorageMixin:
"preview_kind": "image" if media_type.startswith("image/") else "pdf",
"preview_file_name": source_path.name,
"preview_media_type": media_type,
"preview_rendered_with": "",
}
return {
"previewable": False,
"preview_kind": "",
"preview_file_name": "",
"preview_media_type": "",
"preview_rendered_with": "",
}
def _refresh_pdf_preview_asset_if_needed(
self,
*,
receipt_dir: Path,
meta: dict[str, Any],
) -> dict[str, Any]:
source_name = str(meta.get("source_file_name") or meta.get("file_name") or "").strip()
if not source_name:
return meta
source_path = self._assert_child(receipt_dir / source_name)
source_media_type = self.resolve_media_type(source_path.name, str(meta.get("media_type") or ""))
if source_media_type != "application/pdf" or not source_path.exists():
return meta
preview_name = str(meta.get("preview_file_name") or "").strip()
preview_path = self._assert_child(receipt_dir / preview_name) if preview_name else None
if (
preview_path is not None
and preview_path.exists()
and str(meta.get("preview_kind") or "").strip() == "image"
and str(meta.get("preview_media_type") or "").strip() == DocumentPreviewAssets.PDF_PREVIEW_MEDIA_TYPE
and str(meta.get("preview_rendered_with") or "").strip() == DocumentPreviewAssets.PDF_RENDERER_ID
):
return meta
if not preview_name or not preview_name.lower().endswith(DocumentPreviewAssets.PDF_PREVIEW_SUFFIX):
preview_name = f"preview{DocumentPreviewAssets.PDF_PREVIEW_SUFFIX}"
preview_path = self._assert_child(receipt_dir / preview_name)
try:
DocumentPreviewAssets.render_pdf_first_page(
pdf_path=source_path,
preview_path=preview_path,
timeout_seconds=get_settings().ocr_timeout_seconds,
)
except Exception:
return meta
meta.update(
{
"previewable": True,
"preview_kind": "image",
"preview_file_name": preview_path.name,
"preview_media_type": DocumentPreviewAssets.PDF_PREVIEW_MEDIA_TYPE,
"preview_rendered_with": DocumentPreviewAssets.PDF_RENDERER_ID,
}
)
self._write_meta(receipt_dir, meta)
return meta
@staticmethod
def _is_previewable(media_type: str) -> bool:
return str(media_type or "").startswith("image/") or str(media_type or "") == "application/pdf"
@@ -256,6 +314,7 @@ class ReceiptFolderItemMixin:
def _build_item(self, meta: dict[str, Any]) -> ReceiptFolderItemRead:
receipt_id = str(meta.get("id") or "").strip()
status_value = str(meta.get("status") or "unlinked").strip() or "unlinked"
identity = self._resolve_receipt_document_identity(meta)
return ReceiptFolderItemRead(
id=receipt_id,
file_name=str(meta.get("file_name") or ""),
@@ -263,10 +322,10 @@ class ReceiptFolderItemMixin:
size_bytes=int(meta.get("size_bytes") or 0),
status=status_value,
status_label="已关联" if status_value == "linked" else "未关联",
document_type=str(meta.get("document_type") or "other"),
document_type_label=str(meta.get("document_type_label") or "其他单据"),
scene_code=str(meta.get("scene_code") or "other"),
scene_label=str(meta.get("scene_label") or "其他票据"),
document_type=identity["document_type"],
document_type_label=identity["document_type_label"],
scene_code=identity["scene_code"],
scene_label=identity["scene_label"],
summary=str(meta.get("summary") or ""),
amount=self._resolve_editable_or_field(meta, "amount", labels=("金额", "价税合计", "票价")),
document_date=self._resolve_receipt_document_date(meta),
@@ -283,6 +342,38 @@ class ReceiptFolderItemMixin:
warnings=[str(value) for value in list(meta.get("ocr_warnings") or []) if str(value).strip()],
)
def _resolve_receipt_document_identity(self, meta: dict[str, Any]) -> dict[str, str]:
document_type = str(meta.get("document_type") or "other").strip() or "other"
document_type_label = str(meta.get("document_type_label") or "其他单据").strip() or "其他单据"
scene_code = str(meta.get("scene_code") or "other").strip() or "other"
scene_label = str(meta.get("scene_label") or "其他票据").strip() or "其他票据"
if document_type not in {"", "other"} and document_type_label != "其他单据":
return {
"document_type": document_type,
"document_type_label": document_type_label,
"scene_code": scene_code,
"scene_label": scene_label,
}
insight = build_document_insight(
filename=str(meta.get("file_name") or ""),
summary=str(meta.get("summary") or ""),
text=self._receipt_text(meta),
)
if insight.document_type in {"", "other"}:
return {
"document_type": document_type,
"document_type_label": document_type_label,
"scene_code": scene_code,
"scene_label": scene_label,
}
return {
"document_type": insight.document_type,
"document_type_label": insight.document_type_label,
"scene_code": insight.scene_code,
"scene_label": insight.scene_label,
}
def _resolve_fields(self, meta: dict[str, Any]) -> list[ReceiptFolderFieldRead]:
fields = [
ReceiptFolderFieldRead(
@@ -503,7 +594,15 @@ class ReceiptFolderTrainTicketMixin:
if str(document_type or "").strip().lower() == "train_ticket":
return True
compact = "".join([document_type_label, scene_label, text]).replace(" ", "")
return any(token in compact for token in ("火车", "高铁", "动车", "铁路", "电子客票", "车次"))
if any(token in compact for token in ("火车", "高铁", "动车", "铁路", "电子客票", "车次")):
return True
lower_compact = compact.lower()
return bool(re.search(r"[GCDZKTLYS]\d{1,5}", compact, flags=re.IGNORECASE)) and (
"12306" in compact
or "95306" in compact
or re.search(r"[\u4e00-\u9fa5]{2,12}(?:至|到|→|->|—||-)[\u4e00-\u9fa5]{2,12}", compact)
or ("wuhan" in lower_compact and "shanghai" in lower_compact)
)
@classmethod
def _is_train_ticket_meta(cls, meta: dict[str, Any]) -> bool:
@@ -581,6 +680,7 @@ class ReceiptFolderTrainTicketMixin:
return raw
normalized = match.group(1).replace("", "-").replace("", "-").replace("", "")
normalized = normalized.replace("/", "-").replace(".", "-")
normalized = re.sub(r"\s+", "-", normalized)
parts = [part for part in normalized.split("-") if part]
if len(parts) != 3:
return match.group(1)
@@ -651,7 +751,28 @@ class ReceiptFolderTrainTicketMixin:
cleaned = re.sub(r"[^·\u4e00-\u9fa5]", "", str(value or "")).strip()
if not 2 <= len(cleaned) <= 8:
return ""
if any(token in cleaned for token in ("电子", "客票", "铁路", "发票", "税务", "湖北省", "中国铁路", "开票", "日期")):
if any(
token in cleaned
for token in (
"电子",
"客票",
"铁路",
"发票",
"税务",
"湖北省",
"中国铁路",
"开票",
"日期",
"车厢",
"座位",
"票价",
"金额",
"行程",
"出发",
"到达",
"车次",
)
):
return ""
return cleaned
@@ -660,20 +781,29 @@ class ReceiptFolderTrainTicketMixin:
labeled = cls._extract_first(TRAIN_ID_PATTERN, text)
if labeled:
return labeled
fallback = ""
for line in str(text or "").replace("\r", "\n").splitlines():
compact_line = line.replace(" ", "")
if any(token in compact_line for token in ("发票号码", "电子客票号", "客票号", "订单号")):
continue
match = TRAIN_ID_FALLBACK_PATTERN.search(compact_line)
if match:
return str(match.group(1) or "").strip()
return ""
if not match:
continue
candidate = str(match.group(1) or "").strip()
if "*" in candidate:
return candidate
if not fallback:
fallback = candidate
return fallback
@staticmethod
def _extract_train_carriage_and_seat(text: str) -> tuple[str, str]:
combined_match = TRAIN_COMBINED_SEAT_PATTERN.search(str(text or ""))
if combined_match:
return f"{combined_match.group(1)}", combined_match.group(2)
loose_match = TRAIN_LOOSE_SEAT_PATTERN.search(str(text or ""))
if loose_match:
return f"{loose_match.group(1).zfill(2)}", loose_match.group(2).upper()
carriage_no = ReceiptFolderService._extract_first(TRAIN_CARRIAGE_PATTERN, text).replace(" ", "")
seat_no = ReceiptFolderService._extract_first(TRAIN_SEAT_NO_PATTERN, text)
return carriage_no, seat_no
@@ -681,6 +811,12 @@ class ReceiptFolderTrainTicketMixin:
@staticmethod
def _extract_train_fare(text: str) -> str:
match = TRAIN_FARE_PATTERN.search(str(text or ""))
if not match:
match = max(
list(TRAIN_LOOSE_FARE_PATTERN.finditer(str(text or ""))),
key=lambda item: float(str(item.group(1) or "0").replace(",", ".")),
default=None,
)
if not match:
return ""
value = str(match.group(1) or "").replace(",", ".").strip()
@@ -721,13 +857,10 @@ class ReceiptFolderService(ReceiptFolderStorageMixin, ReceiptFolderItemMixin, Re
)
if existing_receipt is not None:
enriched.append(
document.model_copy(
update={
"receipt_id": existing_receipt.id,
"receipt_status": existing_receipt.status,
"receipt_preview_url": existing_receipt.preview_url,
"receipt_source_url": existing_receipt.source_url,
}
self._enrich_ocr_document_with_receipt(
document,
receipt=existing_receipt,
current_user=current_user,
)
)
continue
@@ -744,14 +877,11 @@ class ReceiptFolderService(ReceiptFolderStorageMixin, ReceiptFolderItemMixin, Re
warning = "已上传过同样的单据,请不要重复上传。"
existing_warnings = [str(item) for item in list(document.warnings or []) if str(item).strip()]
enriched.append(
document.model_copy(
update={
"receipt_id": duplicate_receipt.id,
"receipt_status": duplicate_receipt.status,
"receipt_preview_url": duplicate_receipt.preview_url,
"receipt_source_url": duplicate_receipt.source_url,
"warnings": list(dict.fromkeys([*existing_warnings, warning])),
}
self._enrich_ocr_document_with_receipt(
document,
receipt=duplicate_receipt,
current_user=current_user,
extra_warnings=[*existing_warnings, warning],
)
)
continue
@@ -763,16 +893,77 @@ class ReceiptFolderService(ReceiptFolderStorageMixin, ReceiptFolderItemMixin, Re
current_user=current_user,
)
enriched.append(
document.model_copy(
update={
self._enrich_ocr_document_with_receipt(
document,
receipt=receipt,
current_user=current_user,
)
)
return result.model_copy(update={"documents": enriched})
def _enrich_ocr_document_with_receipt(
self,
document: OcrRecognizeDocumentRead,
*,
receipt: ReceiptFolderItemRead,
current_user: CurrentUserContext,
extra_warnings: list[str] | None = None,
) -> OcrRecognizeDocumentRead:
update: dict[str, Any] = {
"receipt_id": receipt.id,
"receipt_status": receipt.status,
"receipt_preview_url": receipt.preview_url,
"receipt_source_url": receipt.source_url,
}
try:
meta = self._read_receipt_meta(receipt.id, current_user)
except FileNotFoundError:
meta = {}
if meta:
update.update(
{
"text": str(meta.get("ocr_text") or document.text or ""),
"summary": str(meta.get("summary") or document.summary or ""),
"document_type": str(meta.get("document_type") or document.document_type or "other"),
"document_type_label": str(meta.get("document_type_label") or document.document_type_label or "其他单据"),
"scene_code": str(meta.get("scene_code") or document.scene_code or "other"),
"scene_label": str(meta.get("scene_label") or document.scene_label or "其他票据"),
"classification_source": str(meta.get("ocr_classification_source") or document.classification_source or ""),
"classification_confidence": float(
meta.get("ocr_classification_confidence")
or document.classification_confidence
or 0.0
),
"classification_evidence": [
str(value)
for value in list(meta.get("ocr_classification_evidence") or document.classification_evidence or [])
if str(value).strip()
],
"document_fields": self._build_ocr_document_fields_from_meta(meta),
}
)
warnings = [
str(item)
for item in list(extra_warnings if extra_warnings is not None else document.warnings or [])
if str(item).strip()
]
if warnings:
update["warnings"] = list(dict.fromkeys(warnings))
return document.model_copy(update=update)
def _build_ocr_document_fields_from_meta(self, meta: dict[str, Any]) -> list[OcrRecognizeFieldRead]:
return [
OcrRecognizeFieldRead(
key=field.key,
label=field.label,
value=field.value,
)
return result.model_copy(update={"documents": enriched})
for field in self._resolve_fields(meta)
if field.label and field.value
]
def save_receipt(
self,
@@ -1024,6 +1215,7 @@ class ReceiptFolderService(ReceiptFolderStorageMixin, ReceiptFolderItemMixin, Re
def resolve_preview(self, receipt_id: str, current_user: CurrentUserContext) -> tuple[Path, str, str]:
meta = self._read_receipt_meta(receipt_id, current_user)
receipt_dir = self._receipt_dir(self._owner_key(current_user), receipt_id)
meta = self._refresh_pdf_preview_asset_if_needed(receipt_dir=receipt_dir, meta=meta)
preview_name = str(meta.get("preview_file_name") or "").strip()
if preview_name:
preview_path = self._assert_child(receipt_dir / preview_name)
@@ -1038,4 +1230,3 @@ class ReceiptFolderService(ReceiptFolderStorageMixin, ReceiptFolderItemMixin, Re
if self._is_previewable(source_media_type):
return source_path, source_media_type, source_name
raise FileNotFoundError("Receipt preview not found")

View File

@@ -1,84 +0,0 @@
{
"file_name": "行程单_2_鄂AX9877.pdf",
"storage_key": "0d3102fe-a458-42cf-b30c-4cffeeb74668/c99de539-23cb-4f1a-a21f-a40bce93d54e/行程单_2_鄂AX9877.pdf",
"media_type": "application/pdf",
"size_bytes": 32459,
"uploaded_at": "2026-05-16T08:41:42.540134+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "0d3102fe-a458-42cf-b30c-4cffeeb74668/c99de539-23cb-4f1a-a21f-a40bce93d54e/行程单_2_鄂AX9877.preview.png",
"preview_media_type": "image/png",
"preview_file_name": "行程单_2_鄂AX9877.preview.png",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为出租车/网约车票据。",
"附件类型要求:当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。",
"金额字段:已识别到与当前明细接近的金额 35.53 元。"
],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "taxi_receipt",
"document_type_label": "出租车/网约车票据",
"scene_code": "transport",
"scene_label": "交通票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "35.53元"
},
{
"key": "date",
"label": "日期",
"value": "2026-03-04"
},
{
"key": "merchant_name",
"label": "商户",
"value": "全季酒店"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "transport",
"current_expense_type_label": "交通费",
"allowed_scene_labels": [
"交通"
],
"allowed_document_type_labels": [
"停车/通行费票据",
"一般收据/凭证",
"出租车/网约车票据",
"增值税发票"
],
"recognized_scene_code": "transport",
"recognized_scene_label": "交通票据",
"recognized_document_type": "taxi_receipt",
"recognized_document_type_label": "出租车/网约车票据",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "高德地图一打车\n行程单\nAMAP ITINERARY\n1申请时间2026-03-04\n【行程时间2026-03-0407:05至2026-03-0407:33\n|行程人手机号18602700270\n1共计1单行程合计35.53元\n序号\n服务商\n车型\n上车时间\n城市\n起点\n终点\n金额\n经济型\n2026-03-04\n1\n滴滴出行\n武汉市\n全季酒店武汉工程大学店\n武汉站\n35.53元\n07:05\n页码1/1",
"ocr_summary": "高德地图一打车行程单AMAP ITINERARY",
"ocr_avg_score": 0.9819406509399414,
"ocr_line_count": 25,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"滴滴出行",
"滴滴",
"打车",
"上车"
],
"ocr_warnings": []
}

View File

@@ -1,84 +0,0 @@
{
"file_name": "行程单_1_鄂A1S987.pdf",
"storage_key": "281095c5-d85b-428e-924f-250bdd6e0261/c676b663-8851-4b35-be4e-1ba13d46d35e/行程单_1_鄂A1S987.pdf",
"media_type": "application/pdf",
"size_bytes": 34880,
"uploaded_at": "2026-05-16T08:17:53.656595+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "281095c5-d85b-428e-924f-250bdd6e0261/c676b663-8851-4b35-be4e-1ba13d46d35e/行程单_1_鄂A1S987.preview.png",
"preview_media_type": "image/png",
"preview_file_name": "行程单_1_鄂A1S987.preview.png",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为出租车/网约车票据。",
"附件类型要求:当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。",
"金额字段:已识别到与当前明细接近的金额 10.30 元。"
],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "taxi_receipt",
"document_type_label": "出租车/网约车票据",
"scene_code": "transport",
"scene_label": "交通票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "10.3元"
},
{
"key": "date",
"label": "日期",
"value": "2026-03-01"
},
{
"key": "merchant_name",
"label": "商户",
"value": "全季酒店"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "transport",
"current_expense_type_label": "交通费",
"allowed_scene_labels": [
"交通"
],
"allowed_document_type_labels": [
"停车/通行费票据",
"一般收据/凭证",
"出租车/网约车票据",
"增值税发票"
],
"recognized_scene_code": "transport",
"recognized_scene_label": "交通票据",
"recognized_document_type": "taxi_receipt",
"recognized_document_type_label": "出租车/网约车票据",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为交通费,已识别为出租车/网约车票据,符合当前交通费场景的附件要求。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "高德地图一打车\n行程单\nAMAP ITINERARY\n1申请时间2026-03-01\n【行程时间2026-03-0113:23至2026-03-0113:40\n行程人手机号18602700270\n|共计1单行程合计10.30元\n序号\n服务商\n车型\n上车时间\n城市\n起点\n终点\n金额\n经济型\n2026-03-01\n1\n滴滴出行\n13:23\n武汉市\n金融港北地铁站\n全季酒店武汉工程大学店\n10.30元\n页码1/1",
"ocr_summary": "高德地图一打车行程单AMAP ITINERARY",
"ocr_avg_score": 0.9844024634361267,
"ocr_line_count": 25,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"滴滴出行",
"滴滴",
"打车",
"上车"
],
"ocr_warnings": []
}

View File

@@ -1,82 +0,0 @@
{
"file_name": "酒店2.jpg",
"storage_key": "5544b2a0-a6f5-4ef8-b5b6-c1ac1b03772f/07085673-a7df-4622-abb7-12f6552c780d/酒店2.jpg",
"media_type": "image/jpeg",
"size_bytes": 156877,
"uploaded_at": "2026-05-21T14:19:49.450265+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "5544b2a0-a6f5-4ef8-b5b6-c1ac1b03772f/07085673-a7df-4622-abb7-12f6552c780d/酒店2.preview.jpg",
"preview_media_type": "image/jpeg",
"preview_file_name": "酒店2.preview.jpg",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为酒店住宿票据。",
"附件类型要求:当前费用项目为住宿票,已识别为酒店住宿票据。",
"金额字段:已识别到与当前明细接近的金额 2400.00 元。"
],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "hotel_invoice",
"document_type_label": "酒店住宿票据",
"scene_code": "hotel",
"scene_label": "住宿票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "2400元"
},
{
"key": "date",
"label": "日期",
"value": "2026-02-23"
},
{
"key": "merchant_name",
"label": "商户",
"value": "上海喜来登酒店"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "SH-SAMPLE-20260223-003"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "hotel_ticket",
"current_expense_type_label": "住宿票",
"allowed_scene_labels": [],
"allowed_document_type_labels": [],
"recognized_scene_code": "hotel",
"recognized_scene_label": "住宿票据",
"recognized_document_type": "hotel_invoice",
"recognized_document_type_label": "酒店住宿票据",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为住宿票,已识别为酒店住宿票据。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "上海喜来登酒店(样例)\n住宿消费明细单\n单号SH-SAMPLE-20260223-003\n出单期2026年2月23\n宾客姓名\n曹笑竹\n房间类型豪华床房\n入住日期\n2026年2月20日\n住晚数 3晚\n离店期 2026年223日\n付款式 现/信卡/其他\n日期\n项目\n计费说明\n单价\n数量\n金额\n2026年2月20日\n至\n住宿费\n豪华大床房\n¥800/晚\n3\n¥2400\n2026年2月22日\n额写贰仟肆佰元整\n合计¥2400\n温馨提示如您对以上账单有任何疑问请在离店后7天内与酒店联系感谢您的理解与支持。\n酒店联系式上海喜来登酒店\n地址上海市浦东新区银城中路88号 电话021-12345678\n样例票据|仅供系统测试|无效凭证",
"ocr_summary": "上海喜来登酒店样例住宿消费明细单单号SH-SAMPLE-20260223-003",
"ocr_avg_score": 0.9784442763775587,
"ocr_line_count": 32,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.84,
"ocr_classification_evidence": [
"住宿",
"入住",
"离店",
"酒店"
],
"ocr_warnings": []
}

View File

@@ -1,87 +0,0 @@
{
"file_name": "2月23_上海-武汉.pdf",
"storage_key": "5544b2a0-a6f5-4ef8-b5b6-c1ac1b03772f/ac0a7cc8-7152-41e3-bcce-bd358459a5a8/2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"uploaded_at": "2026-05-21T14:03:40.109269+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "5544b2a0-a6f5-4ef8-b5b6-c1ac1b03772f/ac0a7cc8-7152-41e3-bcce-bd358459a5a8/2月23_上海-武汉.preview.png",
"preview_media_type": "image/png",
"preview_file_name": "2月23_上海-武汉.preview.png",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为火车/高铁票。",
"附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。",
"金额字段:已识别到与当前明细接近的金额 354.00 元。"
],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-23 13:54"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26319166100006175398"
},
{
"key": "route",
"label": "行程",
"value": "上海-武汉"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "train_ticket",
"current_expense_type_label": "火车票",
"allowed_scene_labels": [],
"allowed_document_type_labels": [],
"recognized_scene_code": "travel",
"recognized_scene_label": "差旅票据",
"recognized_document_type": "train_ticket",
"recognized_document_type_label": "火车/高铁票",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为火车票,已识别为火车/高铁票。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"ocr_summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9620026834309101,
"ocr_line_count": 24,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"ocr_warnings": []
}

View File

@@ -1,87 +0,0 @@
{
"file_name": "2月20_武汉-上海.pdf",
"storage_key": "5544b2a0-a6f5-4ef8-b5b6-c1ac1b03772f/b4143190-f375-4f6b-8836-23eee534c99e/2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-05-21T14:03:02.982421+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "5544b2a0-a6f5-4ef8-b5b6-c1ac1b03772f/b4143190-f375-4f6b-8836-23eee534c99e/2月20_武汉-上海.preview.png",
"preview_media_type": "image/png",
"preview_file_name": "2月20_武汉-上海.preview.png",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为火车/高铁票。",
"附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。",
"金额字段:已识别到与当前明细接近的金额 354.00 元。"
],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "train_ticket",
"current_expense_type_label": "火车票",
"allowed_scene_labels": [],
"allowed_document_type_labels": [],
"recognized_scene_code": "travel",
"recognized_scene_label": "差旅票据",
"recognized_document_type": "train_ticket",
"recognized_document_type_label": "火车/高铁票",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为火车票,已识别为火车/高铁票。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"ocr_summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"ocr_warnings": []
}

View File

@@ -1,88 +0,0 @@
{
"file_name": "2月20_武汉-上海.pdf",
"storage_key": "b00cb2a5-0af3-4a49-9f7a-1f79d0ab873a/ab4d8fae-f59d-460d-94a8-eaf644c83591/2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-05-22T00:38:09.743522+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "b00cb2a5-0af3-4a49-9f7a-1f79d0ab873a/ab4d8fae-f59d-460d-94a8-eaf644c83591/2月20_武汉-上海.preview.png",
"preview_media_type": "image/png",
"preview_file_name": "2月20_武汉-上海.preview.png",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为火车/高铁票。",
"附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。",
"金额字段:已识别到与当前明细接近的金额 354.00 元。"
],
"rule_basis": [],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "train_ticket",
"current_expense_type_label": "火车票",
"allowed_scene_labels": [],
"allowed_document_type_labels": [],
"recognized_scene_code": "travel",
"recognized_scene_label": "差旅票据",
"recognized_document_type": "train_ticket",
"recognized_document_type_label": "火车/高铁票",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为火车票,已识别为火车/高铁票。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"ocr_summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"ocr_warnings": []
}

View File

@@ -1,88 +0,0 @@
{
"file_name": "2月23_上海-武汉.pdf",
"storage_key": "b00cb2a5-0af3-4a49-9f7a-1f79d0ab873a/b2edd3f3-9efc-44ab-bd3b-60a42f204a60/2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"uploaded_at": "2026-05-22T00:38:30.927361+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "b00cb2a5-0af3-4a49-9f7a-1f79d0ab873a/b2edd3f3-9efc-44ab-bd3b-60a42f204a60/2月23_上海-武汉.preview.png",
"preview_media_type": "image/png",
"preview_file_name": "2月23_上海-武汉.preview.png",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为火车/高铁票。",
"附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。",
"金额字段:已识别到与当前明细接近的金额 354.00 元。"
],
"rule_basis": [],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-23 13:54"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26319166100006175398"
},
{
"key": "route",
"label": "行程",
"value": "上海-武汉"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "train_ticket",
"current_expense_type_label": "火车票",
"allowed_scene_labels": [],
"allowed_document_type_labels": [],
"recognized_scene_code": "travel",
"recognized_scene_label": "差旅票据",
"recognized_document_type": "train_ticket",
"recognized_document_type_label": "火车/高铁票",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为火车票,已识别为火车/高铁票。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"ocr_summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9620026834309101,
"ocr_line_count": 24,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"ocr_warnings": []
}

View File

@@ -1,87 +0,0 @@
{
"file_name": "2月23_上海-武汉.pdf",
"storage_key": "f8bb5866-1a52-4371-a0e5-257493a8a491/52702496-29fa-40e8-beab-662abd302aae/2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"uploaded_at": "2026-05-21T07:15:50.184565+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "f8bb5866-1a52-4371-a0e5-257493a8a491/52702496-29fa-40e8-beab-662abd302aae/2月23_上海-武汉.preview.png",
"preview_media_type": "image/png",
"preview_file_name": "2月23_上海-武汉.preview.png",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为火车/高铁票。",
"附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。",
"金额字段:已识别到与当前明细接近的金额 354.00 元。"
],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-23 13:54"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26319166100006175398"
},
{
"key": "route",
"label": "行程",
"value": "上海-武汉"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "train_ticket",
"current_expense_type_label": "火车票",
"allowed_scene_labels": [],
"allowed_document_type_labels": [],
"recognized_scene_code": "travel",
"recognized_scene_label": "差旅票据",
"recognized_document_type": "train_ticket",
"recognized_document_type_label": "火车/高铁票",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为火车票,已识别为火车/高铁票。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"ocr_summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9620026834309101,
"ocr_line_count": 24,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"ocr_warnings": []
}

View File

@@ -1,87 +0,0 @@
{
"file_name": "2月20_武汉-上海.pdf",
"storage_key": "f8bb5866-1a52-4371-a0e5-257493a8a491/cfe0fe24-f482-4cf1-aee9-cb3acc26dbe4/2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-05-21T07:12:29.488414+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "f8bb5866-1a52-4371-a0e5-257493a8a491/cfe0fe24-f482-4cf1-aee9-cb3acc26dbe4/2月20_武汉-上海.preview.png",
"preview_media_type": "image/png",
"preview_file_name": "2月20_武汉-上海.preview.png",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为火车/高铁票。",
"附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。",
"金额字段:已识别到与当前明细接近的金额 354.00 元。"
],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "train_ticket",
"current_expense_type_label": "火车票",
"allowed_scene_labels": [],
"allowed_document_type_labels": [],
"recognized_scene_code": "travel",
"recognized_scene_label": "差旅票据",
"recognized_document_type": "train_ticket",
"recognized_document_type_label": "火车/高铁票",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为火车票,已识别为火车/高铁票。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"ocr_summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"ocr_warnings": []
}

View File

@@ -1,77 +0,0 @@
{
"file_name": "酒店1.jpg",
"storage_key": "f8bb5866-1a52-4371-a0e5-257493a8a491/f4d818ec-8a17-44e3-98f4-99c1e97b63b1/酒店1.jpg",
"media_type": "image/jpeg",
"size_bytes": 135977,
"uploaded_at": "2026-05-21T07:21:03.814491+00:00",
"previewable": true,
"preview_kind": "image",
"preview_storage_key": "f8bb5866-1a52-4371-a0e5-257493a8a491/f4d818ec-8a17-44e3-98f4-99c1e97b63b1/酒店1.preview.jpg",
"preview_media_type": "image/jpeg",
"preview_file_name": "酒店1.preview.jpg",
"analysis": {
"severity": "pass",
"label": "AI提示符合条件",
"headline": "AI提示附件符合基础校验条件",
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
"points": [
"票据类型:已识别为酒店住宿票据。",
"附件类型要求:当前费用项目为住宿票,已识别为酒店住宿票据。",
"金额字段:已识别到与当前明细接近的金额 2026.00 元。"
],
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。"
},
"document_info": {
"document_type": "hotel_invoice",
"document_type_label": "酒店住宿票据",
"scene_code": "hotel",
"scene_label": "住宿票据",
"fields": [
{
"key": "amount",
"label": "金额",
"value": "2026元"
},
{
"key": "date",
"label": "日期",
"value": "2026-02-23"
},
{
"key": "merchant_name",
"label": "商户",
"value": "上海喜来登酒店"
}
]
},
"requirement_check": {
"matches": true,
"current_expense_type": "hotel_ticket",
"current_expense_type_label": "住宿票",
"allowed_scene_labels": [],
"allowed_document_type_labels": [],
"recognized_scene_code": "hotel",
"recognized_scene_label": "住宿票据",
"recognized_document_type": "hotel_invoice",
"recognized_document_type_label": "酒店住宿票据",
"mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为住宿票,已识别为酒店住宿票据。"
},
"ocr_status": "recognized",
"ocr_error": "",
"ocr_text": "上海喜来登酒店(样例)\n住宿发票\n发票编号SH-SAMPLE-20260223-002\n开票日期2026年2月23日\n客姓名曹笑\n住晚数3晚\n住期2026年220\n房型豪华床房\n离店期2026年223\n预订渠道酒店官\n日期\n项目\n房费单价\n数量\n金额\n2026-02-20至2026-02-22\n住宿费\n¥276/晚\n3晚\n¥828\n合计¥828\n额写捌佰贰拾捌元整\n备注\n以上费用已由酒店收取并开具发票。\n本发票仅含住宿费不含其他增值服务费。\n如有疑问请联系酒店前台或致电酒店财务部。\n样例票据|仅供系统测试|无效凭证",
"ocr_summary": "上海喜来登酒店样例住宿发票发票编号SH-SAMPLE-20260223-002",
"ocr_avg_score": 0.9884135921796163,
"ocr_line_count": 27,
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.84,
"ocr_classification_evidence": [
"住宿",
"房费",
"离店",
"酒店"
],
"ocr_warnings": []
}

View File

@@ -84,6 +84,35 @@ def test_document_intelligence_prefers_train_ticket_for_railway_e_ticket_invoice
assert any(field.label == "金额" and field.value == "354元" for field in insight.fields)
def test_document_intelligence_recovers_train_ticket_from_english_station_ocr_text() -> None:
insight = build_document_insight(
filename="2月20_武汉-上海.pdf",
summary=":26429165800002785705:2026 05 18Wuhan Shanghaihongqiao G458",
text=(
":26429165800002785705\n"
":2026 05 18\n"
"G458\n"
"Wuhan\n"
"Shanghaihongqiao\n"
"2026 02 20 07:55\n"
"06 01B\n"
": 354.00\n"
"4201061987****1615\n"
":6580061086021391007342026\n"
"12306 95306"
),
)
assert insight.document_type == "train_ticket"
assert insight.document_type_label == "火车/高铁票"
assert insight.scene_code == "travel"
fields = {field.label: field.value for field in insight.fields}
assert fields["金额"] == "354元"
assert fields["列车出发时间"] == "2026-02-20 07:55"
assert fields["车次/航班"] == "G458"
assert fields["行程"] == "武汉-上海"
def test_document_intelligence_labels_train_ticket_date_as_train_departure_time() -> None:
insight = build_document_insight(
filename="铁路电子客票.pdf",

View File

@@ -0,0 +1,169 @@
from __future__ import annotations
import json
from decimal import Decimal
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.ocr import OcrService
from test_reimbursement_endpoints import build_client, seed_claim
def test_train_ticket_attachment_with_structured_fields_is_not_flagged_as_unreadable(
monkeypatch,
tmp_path,
) -> None:
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="2月20_武汉-上海.pdf",
media_type="application/pdf",
text=(
":26429165800002785705\n"
":2026 05 18\n"
"G458\n"
"Wuhan\n"
"Shanghaihongqiao\n"
"2026 02 20 07:55\n"
"06 01B\n"
": 354.00\n"
"4201061987****1615\n"
":6580061086021391007342026\n"
"12306 95306"
),
summary="Wuhan Shanghaihongqiao G458 354.00",
avg_score=0.0,
line_count=0,
page_count=1,
warnings=[],
)
],
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client()
with session_factory() as db:
claim, item = seed_claim(db)
claim.expense_type = "travel"
claim.reason = "武汉-上海差旅"
claim.location = "上海"
claim.amount = Decimal("354.00")
item.item_type = "train_ticket"
item.item_reason = "武汉-上海"
item.item_location = "上海"
item.item_amount = Decimal("354.00")
db.commit()
claim_id = claim.id
item_id = item.id
upload_response = client.post(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment",
headers={"x-auth-username": "emp-1", "x-auth-name": "Zhang San"},
files=[("file", ("2月20_武汉-上海.pdf", b"%PDF-1.4 fake", "application/pdf"))],
)
assert upload_response.status_code == 200
attachment = upload_response.json()["attachment"]
analysis = attachment["analysis"]
points = analysis["points"]
assert attachment["document_info"]["document_type"] == "train_ticket"
assert analysis["severity"] == "pass"
assert not any("未识别到有效文字" in point for point in points)
assert not any("未识别到列车出发时间" in point for point in points)
def test_attachment_meta_read_repairs_stale_unreadable_train_ticket_analysis(
monkeypatch,
tmp_path,
) -> None:
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="2月20_武汉-上海.pdf",
media_type="application/pdf",
text=(
":26429165800002785705 :2026 05 18\n"
"G458\n"
"Wuhan Shanghaihongqiao\n"
"2026 02 20 07:55 06 01B\n"
": 354.00\n"
"4201061987****1615\n"
":6580061086021391007342026\n"
"12306 95306"
),
summary="Wuhan Shanghaihongqiao G458 354.00",
avg_score=0.0,
line_count=0,
page_count=1,
warnings=[],
)
],
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client()
with session_factory() as db:
claim, item = seed_claim(db)
claim.expense_type = "travel"
claim.reason = "武汉-上海差旅"
claim.location = "上海"
claim.amount = Decimal("354.00")
item.item_type = "train_ticket"
item.item_reason = "武汉-上海"
item.item_location = "上海"
item.item_amount = Decimal("354.00")
db.commit()
claim_id = claim.id
item_id = item.id
upload_response = client.post(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment",
headers={"x-auth-username": "emp-1", "x-auth-name": "Zhang San"},
files=[("file", ("2月20_武汉-上海.pdf", b"%PDF-1.4 fake", "application/pdf"))],
)
assert upload_response.status_code == 200
meta_path = next(tmp_path.rglob("*.meta.json"))
meta = json.loads(meta_path.read_text(encoding="utf-8"))
meta["analysis"] = {
"severity": "high",
"label": "高风险",
"headline": "AI提示附件不符合票据校验条件",
"summary": "当前附件存在明显异常,票据类型与当前费用场景不匹配,或无法作为有效报销材料。",
"points": [
"附件内容:未识别到有效文字,当前附件更像普通图片或内容过于模糊。",
"日期字段:未识别到列车出发时间或乘车日期。",
],
"rule_basis": [],
"suggestion": "建议过滤当前不匹配的票据,重新上传符合当前费用场景的清晰原件。",
}
meta_path.write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8")
meta_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment/meta",
headers={"x-auth-username": "emp-1", "x-auth-name": "Zhang San"},
)
assert meta_response.status_code == 200
analysis = meta_response.json()["analysis"]
points = analysis["points"]
assert analysis["severity"] == "pass"
assert not any("未识别到有效文字" in point for point in points)
assert not any("未识别到列车出发时间" in point for point in points)

View File

@@ -176,3 +176,73 @@ def test_ocr_recognize_endpoint_returns_structured_payload(monkeypatch, tmp_path
assert deleted_response.status_code == 404
finally:
get_settings.cache_clear()
def test_ocr_recognize_endpoint_returns_receipt_enriched_train_fields(monkeypatch, tmp_path) -> None:
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
engine="paddleocr_mobile",
model="PP-OCRv5_mobile",
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="2月20_武汉-上海.png",
media_type="image/png",
text=(
":26429165800002785705\n"
"G458\n"
"Wuhan\n"
"Shanghaihongqiao\n"
"2026 02 20 07:55\n"
"06 01B\n"
": 354.00\n"
"4201061987****1615\n"
":6580061086021391007342026\n"
"12306 95306"
),
summary="Wuhan Shanghaihongqiao G458 354.00",
avg_score=0.92,
line_count=0,
page_count=1,
document_type="train_ticket",
document_type_label="火车/高铁票",
scene_code="travel",
scene_label="差旅票据",
document_fields=[
OcrRecognizeFieldRead(key="date", label="列车出发时间", value="2026-02-20 07:55"),
OcrRecognizeFieldRead(key="trip_no", label="车次/航班", value="G458"),
OcrRecognizeFieldRead(key="route", label="行程", value="武汉-上海"),
OcrRecognizeFieldRead(key="amount", label="金额", value="354元"),
],
)
],
)
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
try:
client = build_client()
response = client.post(
"/api/v1/ocr/recognize",
headers={"x-auth-username": "pytest", "x-auth-name": "Py Test"},
files=[("files", ("2月20_武汉-上海.png", b"fake-image", "image/png"))],
)
finally:
get_settings.cache_clear()
assert response.status_code == 200
document = response.json()["documents"][0]
fields = {
item["label"]: item["value"]
for item in document["document_fields"]
}
assert document["receipt_id"]
assert fields["身份证号"] == "4201061987****1615"
assert fields["车厢"] == "06车"
assert fields["座位号"] == "01B"
assert fields["票价"] == "354.00元"

View File

@@ -101,6 +101,55 @@ print("__OCR_JSON__=" + json.dumps(payload, ensure_ascii=False))
assert skipped.warnings == ["当前仅支持图片和 PDF 文件进行 OCR。"]
def test_ocr_service_recovers_image_text_from_worker_ocr_text(
monkeypatch,
tmp_path: Path,
) -> None:
def fake_invoke_worker(
self,
*,
python_bin: str,
worker_path: str,
input_paths: list[Path],
) -> dict:
return {
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"documents": [
{
"input_path": str(input_paths[0]),
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "铁路电子客票 武汉-上海 2026 02 20 07:55 G458 : 354.00 12306 95306",
"avg_score": 0.92,
"line_count": 0,
"page_count": 1,
"warnings": [],
"lines": [],
}
],
}
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
monkeypatch.setattr(OcrService, "_resolve_python_bin", lambda self: "python")
monkeypatch.setattr(OcrService, "_resolve_worker_path", lambda self: "worker.py")
monkeypatch.setattr(OcrService, "_invoke_worker", fake_invoke_worker)
OcrService._result_cache.clear()
get_settings.cache_clear()
try:
result = OcrService().recognize_files([("train-ticket.png", b"fake-train-image", "image/png")])
finally:
OcrService._result_cache.clear()
get_settings.cache_clear()
recognized = result.documents[0]
assert "铁路电子客票" in recognized.text
assert recognized.document_type == "train_ticket"
assert any(field.label == "列车出发时间" and field.value == "2026-02-20 07:55" for field in recognized.document_fields)
assert any(field.label == "车次/航班" and field.value == "G458" for field in recognized.document_fields)
assert any(field.label == "金额" and field.value == "354元" for field in recognized.document_fields)
def test_ocr_service_passes_configured_device_to_worker(
monkeypatch,
tmp_path: Path,

View File

@@ -1,8 +1,11 @@
from __future__ import annotations
import base64
from app.api.deps import CurrentUserContext
from app.core.config import get_settings
from app.schemas.ocr import OcrRecognizeDocumentRead
from app.services.document_preview import DocumentPreviewAssets
from app.services.receipt_folder import ReceiptFolderService
@@ -69,6 +72,172 @@ def test_receipt_folder_train_ticket_uses_invoice_date_and_enriches_fields(monke
get_settings.cache_clear()
def test_receipt_folder_pdf_preview_regenerates_stale_cached_image(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
try:
current_user = CurrentUserContext(
username="pytest",
name="Py Test",
role_codes=[],
is_admin=False,
)
stale_preview = b"stale-preview"
preview_data_url = f"data:image/png;base64,{base64.b64encode(stale_preview).decode('ascii')}"
service = ReceiptFolderService()
receipt = service.save_receipt(
filename="2月20_武汉-上海.pdf",
content=b"%PDF-1.4 fake",
media_type="application/pdf",
current_user=current_user,
document=OcrRecognizeDocumentRead(
filename="2月20_武汉-上海.pdf",
media_type="application/pdf",
preview_kind="image",
preview_data_url=preview_data_url,
),
)
receipt_dir = next(service.root.glob("pytest/*"))
preview_path = receipt_dir / "preview.png"
assert preview_path.read_bytes() == stale_preview
stale_meta = service._read_meta(receipt_dir)
stale_meta.pop("preview_rendered_with", None)
service._write_meta(receipt_dir, stale_meta)
def fake_render_pdf_first_page(*, pdf_path, preview_path, timeout_seconds):
preview_path.write_bytes(b"refreshed-preview")
return preview_path
monkeypatch.setattr(DocumentPreviewAssets, "render_pdf_first_page", fake_render_pdf_first_page)
resolved_path, media_type, file_name = service.resolve_preview(receipt.id, current_user)
assert resolved_path == preview_path
assert media_type == "image/png"
assert file_name == "preview.png"
assert preview_path.read_bytes() == b"refreshed-preview"
meta = service._read_meta(receipt_dir)
assert meta["preview_rendered_with"] == DocumentPreviewAssets.PDF_RENDERER_ID
finally:
get_settings.cache_clear()
def test_receipt_folder_delete_removes_duplicate_marker(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
try:
current_user = CurrentUserContext(
username="pytest",
name="Py Test",
role_codes=[],
is_admin=False,
)
service = ReceiptFolderService()
content = b"%PDF-1.4 same receipt"
receipt = service.save_receipt(
filename="same-receipt.pdf",
content=content,
media_type="application/pdf",
current_user=current_user,
document=OcrRecognizeDocumentRead(
filename="same-receipt.pdf",
media_type="application/pdf",
text="same receipt amount 354",
document_type="other",
document_type_label="其他单据",
scene_code="other",
scene_label="其他票据",
),
)
receipt_dir = service.root / "pytest" / receipt.id
assert receipt_dir.exists()
duplicate = service.find_duplicate_receipt(
filename="same-receipt.pdf",
content=content,
current_user=current_user,
)
assert duplicate is not None
assert duplicate.id == receipt.id
service.delete_receipt(receipt_id=receipt.id, current_user=current_user)
assert not receipt_dir.exists()
assert (
service.find_duplicate_receipt(
filename="same-receipt.pdf",
content=content,
current_user=current_user,
)
is None
)
finally:
get_settings.cache_clear()
def test_receipt_folder_recovers_train_ticket_detail_from_other_english_ocr(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
try:
current_user = CurrentUserContext(
username="pytest",
name="Py Test",
role_codes=[],
is_admin=False,
)
service = ReceiptFolderService()
receipt = service.save_receipt(
filename="2月20_武汉-上海.pdf",
content=b"%PDF-1.4 fake",
media_type="application/pdf",
current_user=current_user,
document=OcrRecognizeDocumentRead(
filename="2月20_武汉-上海.pdf",
media_type="application/pdf",
text=(
":26429165800002785705\n"
":2026 05 18\n"
"G458\n"
"Wuhan\n"
"Shanghaihongqiao\n"
"2026 02 20 07:55\n"
"06 01B\n"
": 354.00\n"
"4201061987****1615\n"
":6580061086021391007342026\n"
"12306 95306"
),
summary="Wuhan Shanghaihongqiao G458 354.00",
document_type="other",
document_type_label="其他单据",
scene_code="other",
scene_label="其他票据",
),
)
assert receipt.document_type == "train_ticket"
assert receipt.document_type_label == "火车/高铁票"
assert receipt.scene_code == "travel"
assert receipt.amount == "354.00元"
assert receipt.document_date == "2026-02-20"
assert receipt.merchant_name == "中国铁路"
detail = service.get_receipt(receipt.id, current_user)
fields = {field.label: field.value for field in detail.fields}
assert fields["行程"] == "武汉-上海"
assert fields["车次"] == "G458"
assert fields["列车出发时间"] == "2026-02-20 07:55"
assert fields["票价"] == "354.00元"
assert fields["身份证号"] == "4201061987****1615"
assert fields["车厢"] == "06车"
assert fields["座位号"] == "01B"
assert "乘车人" not in fields
finally:
get_settings.cache_clear()
def test_receipt_folder_unlink_receipts_for_claim_marks_linked_receipts_unlinked(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import base64
import json
from collections.abc import Generator
from datetime import UTC, date, datetime
from decimal import Decimal
@@ -19,6 +20,7 @@ from app.models.organization import OrganizationUnit
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
from app.models.role import Role
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.services.document_preview import DocumentPreviewAssets
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.ocr import OcrService
@@ -686,6 +688,9 @@ def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch,
meta_payload = upload_response.json()["attachment"]
assert meta_payload["preview_kind"] == "image"
assert meta_payload["preview_url"].endswith(f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview")
meta_path = next(tmp_path.rglob("invoice.pdf.meta.json"))
stored_meta = json.loads(meta_path.read_text(encoding="utf-8"))
assert stored_meta["preview_rendered_with"] == DocumentPreviewAssets.PDF_RENDERER_ID
preview_response = client.get(
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview",

View File

@@ -109,6 +109,7 @@ if [ "$ENV_OVERRIDE_ONLYOFFICE_JWT_SECRET_SET" = true ]; then
fi
SERVER_STARTUP_TIMEOUT="${SERVER_STARTUP_TIMEOUT:-300}"
SERVER_SMOKE_CHECK_ENABLED="${SERVER_SMOKE_CHECK_ENABLED:-false}"
SETUP_COMPLETED="${SETUP_COMPLETED:-false}"
APP_DEBUG="${APP_DEBUG:-true}"
APP_ENV="${APP_ENV:-local}"
@@ -220,7 +221,16 @@ probe_server_ready() {
_health_url="${1:-$(server_probe_url)}"
_smoke_url="${2:-$(server_smoke_url)}"
probe_server_health "$_health_url" && probe_server_smoke "$_smoke_url"
probe_server_health "$_health_url" || return 1
case "$SERVER_SMOKE_CHECK_ENABLED" in
1|true|TRUE|yes|YES|on|ON)
probe_server_smoke "$_smoke_url"
return $?
;;
esac
return 0
}
prepare_web() {

View File

@@ -542,6 +542,48 @@
letter-spacing: 0;
}
.workbench-ai-file-card__ocr {
max-width: 100%;
display: inline-flex;
align-items: center;
gap: 4px;
color: #2563eb;
font-size: 12px;
font-weight: 800;
line-height: 1.2;
}
.workbench-ai-file-card__ocr i {
flex: 0 0 auto;
font-size: 14px;
line-height: 1;
}
.workbench-ai-file-card__ocr span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workbench-ai-file-card__ocr.is-recognizing i {
animation: workbenchAiOcrSpin 840ms linear infinite;
}
.workbench-ai-file-card__ocr.is-recognized {
color: #047857;
}
.workbench-ai-file-card__ocr.is-failed {
color: #dc2626;
}
@keyframes workbenchAiOcrSpin {
to {
transform: rotate(360deg);
}
}
.workbench-ai-file-card__remove {
width: 30px;
height: 30px;
@@ -2035,7 +2077,7 @@
}
.application-preview-input {
width: 100%;
width: min(100%, 420px);
min-width: 0;
min-height: 34px;
padding: 0 10px;
@@ -2049,7 +2091,34 @@
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.11);
}
.application-preview-date-input {
width: min(100%, 188px);
color-scheme: light;
}
.application-preview-input--time,
.application-preview-input--time_return {
width: min(100%, 188px);
}
.application-preview-input--location {
width: min(100%, 220px);
}
.application-preview-input--reason {
width: min(100%, 680px);
}
.application-preview-input--days {
width: min(100%, 150px);
}
.application-preview-input--transportMode {
width: min(100%, 240px);
}
.application-preview-select {
width: min(100%, 240px);
cursor: pointer;
}

View File

@@ -182,6 +182,17 @@
<span class="workbench-ai-file-card__body">
<strong :title="file.name">{{ file.name }}</strong>
<small>{{ file.typeLabel }}</small>
<small
v-if="file.ocrState?.label"
class="workbench-ai-file-card__ocr"
:class="`is-${file.ocrState.status || 'idle'}`"
:title="file.ocrState.title || file.ocrState.label"
>
<i v-if="file.ocrState.status === 'recognizing'" class="mdi mdi-loading"></i>
<i v-else-if="file.ocrState.status === 'recognized'" class="mdi mdi-text-recognition"></i>
<i v-else-if="file.ocrState.status === 'failed'" class="mdi mdi-alert-circle-outline"></i>
<span>{{ file.ocrState.label }}</span>
</small>
</span>
<button
type="button"
@@ -424,9 +435,23 @@
<span class="application-preview-label" role="cell">{{ row.label }}</span>
<span class="application-preview-value" role="cell">
<input
v-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'text'"
v-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'date'"
v-model="applicationPreviewEditor.draftValue"
class="application-preview-input"
:class="['application-preview-input', 'application-preview-date-input', `application-preview-input--${row.key}`]"
type="date"
:min="resolveInlineApplicationPreviewEditorDateMin(message, row.key)"
:max="resolveInlineApplicationPreviewEditorDateMax(message, row.key)"
autofocus
:disabled="isApplicationPreviewEstimatePending(message)"
@click.stop
@change="commitInlineApplicationPreviewEditor(message)"
@keydown.stop="handleInlineApplicationPreviewEditorKeydown($event, message)"
@blur="commitInlineApplicationPreviewEditor(message)"
/>
<input
v-else-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'text'"
v-model="applicationPreviewEditor.draftValue"
:class="['application-preview-input', `application-preview-input--${row.key}`]"
type="text"
autofocus
:disabled="isApplicationPreviewEstimatePending(message)"
@@ -437,7 +462,7 @@
<select
v-else-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'select'"
v-model="applicationPreviewEditor.draftValue"
class="application-preview-input application-preview-select"
:class="['application-preview-input', 'application-preview-select', `application-preview-input--${row.key}`]"
autofocus
:disabled="isApplicationPreviewEstimatePending(message)"
@click.stop
@@ -548,6 +573,17 @@
<span class="workbench-ai-file-card__body">
<strong :title="file.name">{{ file.name }}</strong>
<small>{{ file.typeLabel }}</small>
<small
v-if="file.ocrState?.label"
class="workbench-ai-file-card__ocr"
:class="`is-${file.ocrState.status || 'idle'}`"
:title="file.ocrState.title || file.ocrState.label"
>
<i v-if="file.ocrState.status === 'recognizing'" class="mdi mdi-loading"></i>
<i v-else-if="file.ocrState.status === 'recognized'" class="mdi mdi-text-recognition"></i>
<i v-else-if="file.ocrState.status === 'failed'" class="mdi mdi-alert-circle-outline"></i>
<span>{{ file.ocrState.label }}</span>
</small>
</span>
<button
type="button"

View File

@@ -7,10 +7,10 @@ import { usePersonalWorkbenchAiMode } from '../../composables/workbenchAiMode/us
const props = defineProps({
sidebarCommand: { type: Object, default: () => ({ seq: 0, type: '', payload: null }) }
})
const emit = defineEmits(['conversation-change', 'conversation-history-change', 'open-document'])
const emit = defineEmits(['conversation-change', 'conversation-history-change', 'open-document', 'request-updated'])
const {
activeConversationTitle, aiModeActionItems, applicationPreviewEditor, applicationSubmitConfirmOpen, assistantDraft, assistantInputRef, canShowInlineSuggestedActions, canSubmitAiModePrompt, cancelDeleteConversation, cancelInlineApplicationSubmitConfirm, clearWorkbenchDateSelection, commitInlineApplicationPreviewEditor, confirmDeleteConversation, confirmInlineApplicationSubmit, conversationMessages, conversationScrollRef, conversationStarted, deleteDialogOpen, displayModelName, displayUserName, fileInputRef, handleAiAnswerMarkdownClick, handleAiModeFilesChange, handleInlineApplicationPreviewEditorKeydown, handleInlineConversationScroll, handleInlineSuggestedAction, handleVoiceInput, hasInlineAttachmentOcrDetails, hasInlineThinking, isAiModeInputLocked, isApplicationPreviewEditing, isApplicationPreviewEstimatePending, isInlineAttachmentOcrExpanded, isInlineSuggestedActionDisabled, isInlineThinkingExpanded, markInlineMessageFeedback, modelSelectorTitle, openApplicationPreviewEditor, quoteInlineMessage, regenerateLastReply, removeAiModeFile, removeWorkbenchDateTag, renderInlineConversationHtml, requestDeleteCurrentConversation, resolveApplicationPreviewEditorOptions, resolveInlineApplicationPreviewEditorControl, resolveInlineApplicationPreviewMissingFields, resolveInlineApplicationPreviewRows, resolveInlineAttachmentOcrDocuments, resolveInlineAttachmentOcrFileCount, resolveInlineThinkingEvents, runAiModeAction, scrollInlineConversationToTop, selectedFileCards, sending, setWorkbenchDateMode, submitAiModePrompt, toggleInlineAttachmentOcrDetails, toggleInlineThinking, toggleWorkbenchDatePicker, triggerAiModeFileUpload, workbenchCanApplyDateSelection, workbenchDateMode, workbenchDatePickerOpen, workbenchDateTagLabel, workbenchRangeEndDate, workbenchRangeStartDate, workbenchSingleDate, applyWorkbenchDateSelection, buildInlineApplicationPreviewFooterText, copyInlineMessage, formatMessageTime, handleWorkbenchDateInputChange
activeConversationTitle, aiModeActionItems, applicationPreviewEditor, applicationSubmitConfirmOpen, assistantDraft, assistantInputRef, canShowInlineSuggestedActions, canSubmitAiModePrompt, cancelDeleteConversation, cancelInlineApplicationSubmitConfirm, clearWorkbenchDateSelection, commitInlineApplicationPreviewEditor, confirmDeleteConversation, confirmInlineApplicationSubmit, conversationMessages, conversationScrollRef, conversationStarted, deleteDialogOpen, displayModelName, displayUserName, fileInputRef, handleAiAnswerMarkdownClick, handleAiModeFilesChange, handleInlineApplicationPreviewEditorKeydown, handleInlineConversationScroll, handleInlineSuggestedAction, handleVoiceInput, hasInlineAttachmentOcrDetails, hasInlineThinking, isAiModeInputLocked, isApplicationPreviewEditing, isApplicationPreviewEstimatePending, isInlineAttachmentOcrExpanded, isInlineSuggestedActionDisabled, isInlineThinkingExpanded, markInlineMessageFeedback, modelSelectorTitle, openApplicationPreviewEditor, quoteInlineMessage, regenerateLastReply, removeAiModeFile, removeWorkbenchDateTag, renderInlineConversationHtml, requestDeleteCurrentConversation, resolveApplicationPreviewEditorOptions, resolveInlineApplicationPreviewEditorControl, resolveInlineApplicationPreviewEditorDateMax, resolveInlineApplicationPreviewEditorDateMin, resolveInlineApplicationPreviewMissingFields, resolveInlineApplicationPreviewRows, resolveInlineAttachmentOcrDocuments, resolveInlineAttachmentOcrFileCount, resolveInlineThinkingEvents, runAiModeAction, scrollInlineConversationToTop, selectedFileCards, sending, setWorkbenchDateMode, submitAiModePrompt, toggleInlineAttachmentOcrDetails, toggleInlineThinking, toggleWorkbenchDatePicker, triggerAiModeFileUpload, workbenchCanApplyDateSelection, workbenchDateMode, workbenchDatePickerOpen, workbenchDateTagLabel, workbenchRangeEndDate, workbenchRangeStartDate, workbenchSingleDate, applyWorkbenchDateSelection, buildInlineApplicationPreviewFooterText, copyInlineMessage, formatMessageTime, handleWorkbenchDateInputChange
} = usePersonalWorkbenchAiMode(props, emit)
</script>

View File

@@ -204,18 +204,22 @@
<span class="notification-type-icon" :class="item.tone">
<i :class="resolveNotificationIcon(item)"></i>
</span>
<span class="notification-copy">
<span class="notification-row-main">
<span class="notification-row-head">
<span class="notification-title-line">
<strong>{{ item.title }}</strong>
<strong class="notification-row-title">{{ item.title }}</strong>
<b v-if="item.badge">{{ item.badge }}</b>
</span>
<small>{{ item.description }}</small>
<span class="notification-meta">
<em>{{ item.category || '系统通知' }}</em>
<time>{{ item.time }}</time>
<span class="notification-row-action" aria-hidden="true">
<i class="mdi mdi-chevron-right"></i>
</span>
</span>
<small class="notification-context">{{ item.description }}</small>
<span class="notification-row-foot">
<span class="notification-category-pill">{{ item.category || '系统通知' }}</span>
<time class="notification-time">{{ item.timeLabel || item.time }}</time>
</span>
</span>
<i class="mdi mdi-chevron-right notification-row-arrow"></i>
</button>
</div>
<div v-else class="notification-empty">
@@ -516,12 +520,28 @@ function normalizeNotificationId(value) {
return String(value || '').trim()
}
function formatNotificationTime(value) {
const date = new Date(value)
if (!Number.isFinite(date.getTime())) {
function formatNotificationTimeLabel(value) {
const raw = String(value || '').trim()
if (!raw) {
return '最近更新'
}
const normalized = raw.replace('T', ' ')
const isoMatched = normalized.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})/)
if (isoMatched) {
return `${isoMatched[2]}-${isoMatched[3]} ${isoMatched[4]}:${isoMatched[5]}`
}
const shortMatched = normalized.match(/^(\d{2})-(\d{2})\s+(\d{2}):(\d{2})/)
if (shortMatched) {
return `${shortMatched[1]}-${shortMatched[2]} ${shortMatched[3]}:${shortMatched[4]}`
}
const date = new Date(raw)
if (!Number.isFinite(date.getTime())) {
return raw.length > 16 ? `${raw.slice(0, 16)}...` : raw
}
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
@@ -563,7 +583,8 @@ const documentNotificationItems = computed(() =>
kind: 'document',
title: `${row.documentTypeLabel || '单据'} ${row.documentNo || row.claimId || '待生成'}`,
description: resolveDocumentNotificationDescription(row),
time: formatNotificationTime(row.updatedAt || row.createdAt),
time: row.updatedAt || row.createdAt,
timeLabel: formatNotificationTimeLabel(row.updatedAt || row.createdAt),
category: row.sourceLabel || '单据中心',
tone: resolveDocumentNotificationTone({ ...row, isUnread: unread }),
unread,
@@ -587,12 +608,15 @@ const workbenchNotificationItems = computed(() => (
if (!id || isNotificationHidden(id)) {
return null
}
const notificationTime = item.time || item.updatedAt || item.due
return {
...item,
id,
kind: 'workbench',
category: item.category || '个人工作台',
time: notificationTime,
timeLabel: formatNotificationTimeLabel(item.time || item.updatedAt || item.due),
unread: Boolean(item.unread) && !readNotificationIds.value.has(id),
icon: item.icon || resolveNotificationIcon(item)
}

View File

@@ -78,6 +78,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
const {
applicationPreviewEditor,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorDateMax,
resolveApplicationPreviewEditorDateMin,
resolveApplicationPreviewEditorOptions,
refreshApplicationPreviewEstimate,
isApplicationPreviewEditing,
@@ -112,7 +114,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
} = useWorkbenchComposerDate({ draft: assistantDraft, focusInput: focusAiModeInput })
const aiModeActionItems = AI_MODE_ACTION_ITEMS
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value))
const displayUserName = computed(() => {
const user = currentUser.value || {}
return String(user.name || user.username || '同事').trim() || '同事'
@@ -161,9 +162,19 @@ export function usePersonalWorkbenchAiMode(props, emit) {
scrollInlineConversationToBottom,
sending,
streamOrSetInlineAssistantContent,
notifyRequestUpdated: (payload) => emit('request-updated', payload),
toast
})
watch(selectedFiles, (files) => {
attachmentFlow.primeAiModeReceiptContext(files)
})
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value).map((card, index) => ({
...card,
ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index])
})))
const applicationFlow = useWorkbenchAiApplicationPreviewFlow({
activateInlineConversation,
applicationPreviewEditor,
@@ -189,6 +200,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
refreshApplicationPreviewEstimate,
removeWorkbenchDateTag,
replaceInlineMessage,
resolveApplicationPreviewEditorDateMax,
resolveApplicationPreviewEditorDateMin,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
resolveInlineThinkingEvents,
@@ -776,6 +789,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
requestDeleteCurrentConversation: () => sessionCommands.requestDeleteCurrentConversation(deleteDialogOpen),
resolveApplicationPreviewEditorOptions: applicationFlow.resolveApplicationPreviewEditorOptions,
resolveInlineApplicationPreviewEditorControl: applicationFlow.resolveInlineApplicationPreviewEditorControl,
resolveInlineApplicationPreviewEditorDateMax: applicationFlow.resolveInlineApplicationPreviewEditorDateMax,
resolveInlineApplicationPreviewEditorDateMin: applicationFlow.resolveInlineApplicationPreviewEditorDateMin,
resolveInlineApplicationPreviewMissingFields: applicationFlow.resolveInlineApplicationPreviewMissingFields,
resolveInlineApplicationPreviewRows: applicationFlow.resolveInlineApplicationPreviewRows,
resolveInlineAttachmentOcrDocuments,

View File

@@ -68,6 +68,8 @@ export function useWorkbenchAiApplicationPreviewFlow({
refreshApplicationPreviewEstimate,
removeWorkbenchDateTag,
replaceInlineMessage,
resolveApplicationPreviewEditorDateMax,
resolveApplicationPreviewEditorDateMin,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
resolveInlineThinkingEvents,
@@ -105,8 +107,15 @@ export function useWorkbenchAiApplicationPreviewFlow({
}
function resolveInlineApplicationPreviewEditorControl(fieldKey) {
const control = resolveApplicationPreviewEditorControl(fieldKey)
return control === 'date' ? 'text' : control
return resolveApplicationPreviewEditorControl(fieldKey)
}
function resolveInlineApplicationPreviewEditorDateMin(message, fieldKey) {
return resolveApplicationPreviewEditorDateMin?.(message, fieldKey) || ''
}
function resolveInlineApplicationPreviewEditorDateMax(message, fieldKey) {
return resolveApplicationPreviewEditorDateMax?.(message, fieldKey) || ''
}
function buildInlineApplicationPreviewSuggestedActions(applicationPreview = {}, draftPayload = null) {
@@ -180,6 +189,14 @@ export function useWorkbenchAiApplicationPreviewFlow({
return '申请核对表已补齐,费用测算已同步。你仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。'
}
function buildInlineApplicationActionFailureText(error, isSubmit) {
return [
isSubmit ? '### 申请提交失败' : '### 申请草稿保存失败',
error?.message || (isSubmit ? '申请提交失败,请稍后重试。' : '申请草稿保存失败,请稍后重试。'),
'我已保留当前申请核对表,您可以修改后重试,也可以稍后再次保存。'
].join('\n\n')
}
function resolveLatestApplicationPreviewMessage() {
return [...conversationMessages.value]
.reverse()
@@ -385,8 +402,14 @@ export function useWorkbenchAiApplicationPreviewFlow({
} catch (error) {
replaceInlineMessage(
pendingMessage.id,
createInlineMessage('assistant', error?.message || (isSubmit ? '申请提交失败,请稍后重试。' : '申请草稿保存失败,请稍后重试。'), {
createInlineMessage('assistant', buildInlineApplicationActionFailureText(error, isSubmit), {
id: pendingMessage.id,
applicationPreview: targetMessage.applicationPreview,
draftPayload: targetMessage.draftPayload || options.draftPayload || null,
suggestedActions: buildInlineApplicationPreviewSuggestedActions(
targetMessage.applicationPreview,
targetMessage.draftPayload || options.draftPayload || null
),
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
@@ -504,6 +527,8 @@ export function useWorkbenchAiApplicationPreviewFlow({
openApplicationPreviewEditor,
resolveApplicationPreviewEditorOptions,
resolveInlineApplicationPreviewEditorControl,
resolveInlineApplicationPreviewEditorDateMax,
resolveInlineApplicationPreviewEditorDateMin,
resolveInlineApplicationPreviewMissingFields,
resolveInlineApplicationPreviewRows,
startAiApplicationPreview

View File

@@ -1,6 +1,11 @@
import { reactive } from 'vue'
import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
import { syncExpenseClaimFilesToDraft } from '../../utils/expenseClaimAttachmentSync.js'
import { collectReceiptFiles } from '../../views/scripts/travelReimbursementAttachmentModel.js'
import {
buildFileIdentity,
collectReceiptFiles
} from '../../views/scripts/travelReimbursementAttachmentModel.js'
import {
createExpenseClaimItem,
extractExpenseClaimItems,
@@ -76,42 +81,256 @@ export function useWorkbenchAiAttachmentAssociationFlow({
scrollInlineConversationToBottom,
sending,
streamOrSetInlineAssistantContent,
notifyRequestUpdated,
toast
}) {
async function collectAiModeReceiptContext(files = []) {
const safeFiles = Array.isArray(files) ? files : []
const aiModeReceiptContextCache = new Map()
const aiModeReceiptRecognitionState = reactive({})
function resolveAiModeReceiptRecognitionStateKey(file) {
return buildFileIdentity(file)
}
function pruneAiModeReceiptRecognitionState(files = []) {
const activeKeys = new Set(
(Array.isArray(files) ? files : [])
.filter((file) => isLikelyAiModeOcrFile(file))
.map((file) => resolveAiModeReceiptRecognitionStateKey(file))
.filter(Boolean)
)
Object.keys(aiModeReceiptRecognitionState).forEach((key) => {
if (!activeKeys.has(key)) {
delete aiModeReceiptRecognitionState[key]
}
})
}
function setAiModeReceiptRecognitionState(files = [], patch = {}) {
const recognitionFiles = (Array.isArray(files) ? files : [])
.filter((file) => isLikelyAiModeOcrFile(file))
recognitionFiles.forEach((file) => {
const key = resolveAiModeReceiptRecognitionStateKey(file)
if (!key) {
return
}
aiModeReceiptRecognitionState[key] = {
...(aiModeReceiptRecognitionState[key] || {}),
fileName: String(file?.name || '').trim(),
...patch
}
})
}
function findAiModeReceiptDocumentForFile(file = {}, documents = [], index = 0) {
const fileName = String(file?.name || '').trim()
if (fileName) {
const exactDocument = documents.find((document) => (
String(document?.filename || document?.name || '').trim() === fileName
))
if (exactDocument) {
return exactDocument
}
}
return documents[index] || null
}
function buildAiModeReceiptRecognitionPendingState() {
return {
status: 'recognizing',
label: '智能录入识别中',
title: '正在调用智能录入 OCR 识别票据内容'
}
}
function buildAiModeReceiptRecognitionDoneState(document = null) {
const detail = String(
document?.document_type_label ||
document?.scene_label ||
document?.document_type ||
''
).trim()
return {
status: 'recognized',
label: detail ? `已识别票据 · ${detail}` : '已识别票据',
title: detail ? `智能录入已完成,识别为${detail}` : '智能录入已完成'
}
}
function applyAiModeReceiptRecognitionResult(files = [], context = {}) {
const documents = Array.isArray(context?.ocrDocuments) ? context.ocrDocuments : []
const recognitionFiles = (Array.isArray(files) ? files : [])
.filter((file) => isLikelyAiModeOcrFile(file))
recognitionFiles.forEach((file, index) => {
const document = findAiModeReceiptDocumentForFile(file, documents, index)
setAiModeReceiptRecognitionState([file], buildAiModeReceiptRecognitionDoneState(document))
})
}
function resolveAiModeReceiptRecognitionState(file) {
const key = resolveAiModeReceiptRecognitionStateKey(file)
return key ? aiModeReceiptRecognitionState[key] || null : null
}
function buildAiModeReceiptBaseContext(safeFiles = [], ocrFiles = []) {
const attachmentNames = safeFiles
.map((file) => String(file?.name || '').trim())
.filter(Boolean)
const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
const ocrSourceFileNames = ocrFiles
.map((file) => String(file?.name || '').trim())
.filter(Boolean)
const baseContext = {
return {
attachmentNames,
attachmentCount: attachmentNames.length,
ocrSourceFileNames,
ocrSummary: '',
ocrDocuments: []
}
}
function buildAiModeReceiptContextCacheKey(ocrFiles = []) {
return (Array.isArray(ocrFiles) ? ocrFiles : [])
.map((file) => buildFileIdentity(file))
.filter(Boolean)
.join('|')
}
function buildAiModeReceiptContextFromCollected(baseContext = {}, collected = {}) {
return {
...baseContext,
ocrPayload: collected.ocrPayload || { documents: collected.ocrDocuments || [] },
ocrSummary: String(collected.ocrSummary || '').trim(),
ocrDocuments: Array.isArray(collected.ocrDocuments) ? collected.ocrDocuments : [],
ocrFilePreviews: Array.isArray(collected.ocrFilePreviews) ? collected.ocrFilePreviews : []
}
}
function rememberAiModeReceiptContext(cacheKey, context) {
if (!cacheKey) {
return
}
aiModeReceiptContextCache.set(cacheKey, {
status: 'resolved',
context: {
ocrPayload: context.ocrPayload,
ocrSummary: context.ocrSummary,
ocrDocuments: context.ocrDocuments,
ocrFilePreviews: context.ocrFilePreviews
}
})
if (aiModeReceiptContextCache.size > 20) {
aiModeReceiptContextCache.delete(aiModeReceiptContextCache.keys().next().value)
}
}
function startAiModeReceiptRecognition(files = []) {
const safeFiles = Array.isArray(files) ? files : []
const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
const cacheKey = buildAiModeReceiptContextCacheKey(ocrFiles)
if (!ocrFiles.length || !cacheKey) {
return null
}
const cached = aiModeReceiptContextCache.get(cacheKey)
if (cached?.status === 'resolved') {
applyAiModeReceiptRecognitionResult(ocrFiles, cached.context)
return null
}
if (cached?.status === 'pending' && cached.promise) {
setAiModeReceiptRecognitionState(ocrFiles, buildAiModeReceiptRecognitionPendingState())
return cached.promise
}
setAiModeReceiptRecognitionState(ocrFiles, buildAiModeReceiptRecognitionPendingState())
const promise = collectReceiptFiles({
files: ocrFiles,
recognizeOcrFiles
}).then((collected) => {
const context = buildAiModeReceiptContextFromCollected(
buildAiModeReceiptBaseContext(safeFiles, ocrFiles),
collected
)
rememberAiModeReceiptContext(cacheKey, context)
applyAiModeReceiptRecognitionResult(ocrFiles, context)
return context
}).catch((error) => {
aiModeReceiptContextCache.delete(cacheKey)
setAiModeReceiptRecognitionState(ocrFiles, {
status: 'failed',
label: '识别失败',
title: error?.message || '智能录入 OCR 识别失败'
})
throw error
})
aiModeReceiptContextCache.set(cacheKey, {
status: 'pending',
promise
})
return promise
}
function primeAiModeReceiptContext(files = []) {
pruneAiModeReceiptRecognitionState(files)
const promise = startAiModeReceiptRecognition(files)
if (promise && typeof promise.catch === 'function') {
promise.catch((error) => {
console.warn('AI mode OCR preload failed:', error)
})
}
}
async function collectAiModeReceiptContext(files = []) {
const safeFiles = Array.isArray(files) ? files : []
const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
const baseContext = buildAiModeReceiptBaseContext(safeFiles, ocrFiles)
if (!ocrFiles.length) {
return baseContext
}
const cacheKey = buildAiModeReceiptContextCacheKey(ocrFiles)
const cached = cacheKey ? aiModeReceiptContextCache.get(cacheKey) : null
if (cached?.status === 'resolved') {
applyAiModeReceiptRecognitionResult(ocrFiles, cached.context)
return {
...baseContext,
...cached.context
}
}
if (cached?.status === 'pending' && cached.promise) {
try {
const cachedContext = await cached.promise
applyAiModeReceiptRecognitionResult(ocrFiles, cachedContext)
return {
...baseContext,
ocrPayload: cachedContext.ocrPayload,
ocrSummary: cachedContext.ocrSummary,
ocrDocuments: cachedContext.ocrDocuments,
ocrFilePreviews: cachedContext.ocrFilePreviews
}
} catch (error) {
console.warn('AI mode OCR preload result unavailable:', error)
}
}
try {
setAiModeReceiptRecognitionState(ocrFiles, buildAiModeReceiptRecognitionPendingState())
const collected = await collectReceiptFiles({
files: ocrFiles,
recognizeOcrFiles
})
return {
...baseContext,
ocrSummary: String(collected.ocrSummary || '').trim(),
ocrDocuments: Array.isArray(collected.ocrDocuments) ? collected.ocrDocuments : []
}
const context = buildAiModeReceiptContextFromCollected(baseContext, collected)
rememberAiModeReceiptContext(cacheKey, context)
applyAiModeReceiptRecognitionResult(ocrFiles, context)
return context
} catch (error) {
console.warn('AI mode OCR request failed:', error)
setAiModeReceiptRecognitionState(ocrFiles, {
status: 'failed',
label: '识别失败',
title: error?.message || '智能录入 OCR 识别失败'
})
return {
...baseContext,
ocrError: error?.message || 'OCR识别失败已继续使用附件名称。'
@@ -220,6 +439,13 @@ export function useWorkbenchAiAttachmentAssociationFlow({
createExpenseClaimItem,
uploadExpenseClaimItemAttachment
})
notifyRequestUpdated?.({
claimId: runtime.claimId,
claimNo: runtime.claimNo,
source: 'ai-workbench-attachment-association-sync',
uploadedCount: Number(syncResult?.uploadedCount || 0),
skippedCount: Number(syncResult?.skippedCount || 0)
})
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationResultMessage({
claimNo: runtime.claimNo,
fileNames: runtime.fileNames,
@@ -281,10 +507,7 @@ export function useWorkbenchAiAttachmentAssociationFlow({
scrollInlineConversationToBottom()
try {
const collected = await collectReceiptFiles({
files,
recognizeOcrFiles
})
const collected = await collectAiModeReceiptContext(files)
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(collected, files)
const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
const claims = extractExpenseClaimItems(claimsPayload)
@@ -351,7 +574,9 @@ export function useWorkbenchAiAttachmentAssociationFlow({
return {
collectAiModeReceiptContext,
confirmAiAttachmentAssociation,
primeAiModeReceiptContext,
requestAiAttachmentAssociationReply,
resolveAiModeReceiptRecognitionState,
resolveAiAttachmentAssociationClaimNo
}
}

View File

@@ -15,6 +15,9 @@ import {
buildRequiredApplicationSelectionText,
filterRequiredApplicationCandidates
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
import {
buildInlineAttachmentOcrDetails
} from './workbenchAiMessageModel.js'
function shouldCheckAiRequiredApplicationGate(prompt) {
const compact = String(prompt || '').replace(/\s+/g, '')
@@ -269,6 +272,7 @@ export function useWorkbenchAiStewardFlow({
}
const receiptContext = await collectAiModeReceiptContext(files)
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(receiptContext, files)
const planRequest = buildStewardPlanRequest({
rawText: prompt,
files,
@@ -330,7 +334,8 @@ export function useWorkbenchAiStewardFlow({
},
suggestedActions: requiredApplicationContinuationFlow
? buildAiRequiredApplicationGateSuggestedActions(requiredApplicationContinuationFlow, prompt)
: buildStewardSuggestedActions(plan)
: buildStewardSuggestedActions(plan),
attachmentOcrDetails
})
)
persistCurrentConversation()

View File

@@ -32,6 +32,7 @@ export function fetchReceiptFolderAsset(pathOrUrl) {
throw new Error('票据文件地址为空。')
}
return apiRequest(target, {
cache: 'no-store',
responseType: 'blob'
})
}

View File

@@ -145,6 +145,7 @@
@ai-conversation-history-change="handleAiConversationHistoryChange"
@open-assistant="openSmartEntry"
@open-document="openWorkbenchDocument"
@request-updated="handleRequestUpdated"
/>
<TravelRequestDetailView

View File

@@ -7,6 +7,7 @@
@conversation-change="emit('ai-conversation-change', $event)"
@conversation-history-change="emit('ai-conversation-history-change', $event)"
@open-document="emit('open-document', $event)"
@request-updated="emit('request-updated', $event)"
/>
<PersonalWorkbench
v-else
@@ -31,7 +32,7 @@ defineProps({
aiSidebarCommand: { type: Object, default: () => ({ seq: 0, type: '', payload: null }) }
})
const emit = defineEmits(['open-assistant', 'open-document', 'ai-conversation-change', 'ai-conversation-history-change'])
const emit = defineEmits(['open-assistant', 'open-document', 'ai-conversation-change', 'ai-conversation-history-change', 'request-updated'])
</script>
<style scoped src="../assets/styles/views/personal-workbench-view.css"></style>

View File

@@ -367,6 +367,7 @@ import EnterpriseDetailCard from '../components/shared/EnterpriseDetailCard.vue'
import EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { useToast } from '../composables/useToast.js'
import { fetchExpenseClaims } from '../services/reimbursements.js'
import {
buildReceiptFile,
@@ -383,6 +384,7 @@ import { createReceiptFolderListFilterModel } from './scripts/receiptFolderListF
const NEW_CLAIM_VALUE = '__new_claim__'
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
const emit = defineEmits(['open-assistant', 'detail-open-change', 'detail-topbar-change'])
const { toast } = useToast()
const activeStatus = ref('all')
const keyword = ref('')
@@ -687,6 +689,7 @@ async function deleteCurrentReceipt() {
await deleteReceiptFolderItem(selectedReceipt.value.id)
backToList()
await reloadReceipts()
toast('已从票据夹删除;已关联到报销单的附件副本会保留。')
} finally {
deleting.value = false
}

View File

@@ -59,6 +59,24 @@ export function buildUnsavedDraftAttachmentConfirmationMessage({ fileNames = []
].join('\n').trim()
}
function normalizeOcrDocumentFields(item = {}) {
const sources = [
item?.document_fields,
item?.fields,
item?.document_info?.fields,
item?.metadata?.fields
]
const fields = sources.find((source) => Array.isArray(source)) || []
return fields
.map((field) => {
const label = String(field?.label || field?.name || field?.key || '').trim()
const key = String(field?.key || field?.name || label || '').trim()
const value = String(field?.value || field?.text || '').trim()
return { key, label, value }
})
.filter((field) => field.key && field.label && field.value)
}
export function normalizeOcrDocuments(payload) {
const documents = Array.isArray(payload?.documents) ? payload.documents : []
return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => ({
@@ -78,15 +96,7 @@ export function normalizeOcrDocuments(payload) {
receipt_status: String(item.receipt_status || item.receiptStatus || '').trim(),
receipt_preview_url: String(item.receipt_preview_url || item.receiptPreviewUrl || '').trim(),
receipt_source_url: String(item.receipt_source_url || item.receiptSourceUrl || '').trim(),
document_fields: Array.isArray(item.document_fields)
? item.document_fields
.map((field) => ({
key: String(field?.key || '').trim(),
label: String(field?.label || '').trim(),
value: String(field?.value || '').trim()
}))
.filter((field) => field.key && field.label && field.value)
: [],
document_fields: normalizeOcrDocumentFields(item),
warnings: Array.isArray(item.warnings) ? item.warnings : []
}))
}

View File

@@ -4,6 +4,7 @@ import {
} from '../../services/reimbursements.js'
import {
formatCurrency,
isSystemGeneratedExpenseItemSource,
normalizeIsoDateValue
} from './travelRequestDetailExpenseModel.js'
@@ -109,11 +110,24 @@ export function subscribeSmartEntryRecognitionTask(claimId, listener) {
function resolveSmartEntryTaskAvailableItems(itemSnapshots) {
return (Array.isArray(itemSnapshots) ? itemSnapshots : [])
.filter((item) => item && !item.isSystemGenerated && !item.invoiceId)
.filter((item) => item && !isSystemGeneratedExpenseItemSource(item) && !item.invoiceId)
.map((item) => ({ id: String(item.id || '').trim() }))
.filter((item) => item.id)
}
export function resolveCreatedSmartEntryRecognitionItem(items = [], knownItemIds = new Set()) {
return (Array.isArray(items) ? items : []).find((entry) => {
const itemId = String(entry?.id || '').trim()
const invoiceId = String(entry?.invoiceId || entry?.invoice_id || '').trim()
return (
itemId
&& !knownItemIds.has(itemId)
&& !invoiceId
&& !isSystemGeneratedExpenseItemSource(entry)
)
}) || null
}
async function resolveSmartEntryRecognitionTaskItem(task) {
const availableItem = task.availableItems.shift()
if (availableItem?.id) {
@@ -122,10 +136,7 @@ async function resolveSmartEntryRecognitionTaskItem(task) {
const claim = await createExpenseClaimItem(task.claimId, {})
const items = Array.isArray(claim?.items) ? claim.items : []
const createdItem = items.find((entry) => {
const itemId = String(entry?.id || '').trim()
return itemId && !task.knownItemIds.has(itemId)
})
const createdItem = resolveCreatedSmartEntryRecognitionItem(items, task.knownItemIds)
if (!createdItem) {
throw new Error('新增费用明细失败,请稍后重试。')

View File

@@ -156,6 +156,35 @@ function buildEmptyEditor() {
}
}
function resolveApplicationPreviewDateDraftValue(fieldKey = '', dateState = {}) {
if (fieldKey === 'time_return') {
return dateState.rangeEndDate || dateState.singleDate || getTodayDateValue()
}
return dateState.rangeStartDate || dateState.singleDate || getTodayDateValue()
}
function validateApplicationPreviewDateRange(value = '') {
const dates = parseEditorDateMatches(value)
if (dates.length < 2) {
return {
valid: true,
message: ''
}
}
const startDate = dates[0]
const endDate = dates[dates.length - 1]
if (startDate > endDate) {
return {
valid: false,
message: '出发时间不能晚于返回时间,请重新选择。'
}
}
return {
valid: true,
message: ''
}
}
function shouldRefreshTransportEstimate(fieldKey) {
return ['transportMode', 'time', 'time_return', 'location', 'days'].includes(fieldKey)
}
@@ -271,6 +300,18 @@ export function useApplicationPreviewEditor({
return fieldKey === 'transportMode' ? APPLICATION_TRANSPORT_MODE_OPTIONS : []
}
function resolveApplicationPreviewEditorDateMin(message, fieldKey) {
if (fieldKey !== 'time_return') return ''
const dateState = parseEditorDateValue(message?.applicationPreview?.fields?.time)
return dateState.rangeStartDate || dateState.singleDate || ''
}
function resolveApplicationPreviewEditorDateMax(message, fieldKey) {
if (fieldKey !== 'time') return ''
const dateState = parseEditorDateValue(message?.applicationPreview?.fields?.time)
return dateState.dateMode === 'range' ? dateState.rangeEndDate : ''
}
function isApplicationPreviewEditing(message, fieldKey) {
return (
String(applicationPreviewEditor.value.messageId || '') === String(message?.id || '') &&
@@ -288,12 +329,15 @@ export function useApplicationPreviewEditor({
const dateState = isApplicationPreviewDateField(fieldKey)
? parseEditorDateValue(fields.time || normalizedValue)
: {}
const draftValue = isApplicationPreviewDateField(fieldKey)
? resolveApplicationPreviewDateDraftValue(fieldKey, dateState)
: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
? ''
: normalizedValue
applicationPreviewEditor.value = {
messageId: String(message.id || ''),
fieldKey,
draftValue: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
? ''
: normalizedValue,
draftValue,
committing: false,
...dateState
}
@@ -351,6 +395,17 @@ export function useApplicationPreviewEditor({
}
return false
}
const dateValidation = isApplicationPreviewDateField(editor.fieldKey)
? validateApplicationPreviewDateRange(nextValue)
: { valid: true, message: '' }
if (!dateValidation.valid) {
toast?.(dateValidation.message || '请确认返回时间不早于出发时间。')
applicationPreviewEditor.value = {
...applicationPreviewEditor.value,
committing: false
}
return false
}
const nextPreview = normalizeApplicationPreview({
...message.applicationPreview,
fields: buildEditedApplicationPreviewFields(
@@ -403,6 +458,8 @@ export function useApplicationPreviewEditor({
resolveApplicationPreviewRows,
resolveApplicationPreviewEditorControl,
resolveApplicationPreviewEditorOptions,
resolveApplicationPreviewEditorDateMin,
resolveApplicationPreviewEditorDateMax,
refreshApplicationPreviewEstimate,
isApplicationPreviewEditing,
isApplicationPreviewDateEditorOpen,

View File

@@ -80,10 +80,14 @@ export function useTravelReimbursementCreateViewControls({
pendingText: `已选择草稿 ${record.claimNo},正在识别并归集附件...`,
files,
uploadDisposition: 'continue_existing',
skipDraftAssociationPrompt: true,
associationConfirmed: true,
extraContext: {
review_action: 'link_to_existing_draft',
draft_claim_id: claimId,
selected_claim_id: claimId,
selected_claim_no: String(record?.claimNo || '').trim()
selected_claim_no: String(record?.claimNo || '').trim(),
attachment_association_confirmed: true
}
})
}

View File

@@ -640,6 +640,36 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const fallbackAnswer = reviewActionResult === 'link_to_existing_draft'
? (resultClaimNo ? `已将本次上传的票据关联到草稿 ${resultClaimNo}` : '已将本次上传的票据关联到现有草稿。')
: '智能体已完成处理。'
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
let attachmentSyncCompleted = false
const persistComposerFilesToDraft = async () => {
try {
const syncResult = await syncComposerFilesToDraft(resolvedDraftClaimId, files)
persistSessionState()
if (detailScopedUpload) {
emitRequestUpdated?.({
claimId: resolvedDraftClaimId,
source: 'detail-smart-entry-attachment-sync',
uploadedCount: Number(syncResult?.uploadedCount || 0),
skippedCount: Number(syncResult?.skippedCount || 0)
})
}
return syncResult
} catch (error) {
console.warn('Failed to persist composer attachments to draft claim:', error)
toast(error?.message || '票据已归集到草稿,但附件原件保存失败,请在单据详情中重新上传。')
return null
}
}
if (
reviewActionResult === 'link_to_existing_draft' &&
!effectiveIsKnowledgeSession &&
resolvedDraftClaimId &&
files.length
) {
await persistComposerFilesToDraft()
attachmentSyncCompleted = true
}
const operationFeedbackContext = String(payload?.status || '').trim() === 'succeeded'
? emitOperationCompleted?.(payload, {
operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round')
@@ -702,25 +732,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
persistSessionState()
nextTick(scrollToBottom)
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
if (!effectiveIsKnowledgeSession && resolvedDraftClaimId && files.length) {
const persistComposerFilesToDraft = async () => {
try {
const syncResult = await syncComposerFilesToDraft(resolvedDraftClaimId, files)
persistSessionState()
if (detailScopedUpload) {
emitRequestUpdated?.({
claimId: resolvedDraftClaimId,
source: 'detail-smart-entry-attachment-sync',
uploadedCount: Number(syncResult?.uploadedCount || 0),
skippedCount: Number(syncResult?.skippedCount || 0)
})
}
} catch (error) {
console.warn('Failed to persist composer attachments to draft claim:', error)
toast(error?.message || '票据已归集到草稿,但附件原件保存失败,请在单据详情中重新上传。')
}
}
if (!attachmentSyncCompleted) {
const persistTask = persistComposerFilesToDraft()
if (detailScopedUpload) {
await persistTask
@@ -728,6 +741,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
void persistTask
}
}
}
} catch (error) {
clearFlowSimulationTimers()
if (!stewardDelegated) {

View File

@@ -263,6 +263,36 @@ test('OCR documents keep full recognized text for backend context', () => {
assert.match(documents[0].text, /电子客票号E1234567890/)
})
test('OCR documents normalize receipt-folder field shapes for AI cards', () => {
const documents = normalizeOcrDocuments({
documents: [
{
filename: 'train-ticket.png',
document_info: {
fields: [
{ label: '身份证号', value: '4201061987****1615' },
{ key: 'seat_no', label: '座位号', value: '01B' }
]
}
},
{
filename: 'hotel.png',
fields: [
{ name: 'amount', label: '金额', value: '450元' }
]
}
]
})
assert.deepEqual(documents[0].document_fields, [
{ key: '身份证号', label: '身份证号', value: '4201061987****1615' },
{ key: 'seat_no', label: '座位号', value: '01B' }
])
assert.deepEqual(documents[1].document_fields, [
{ key: 'amount', label: '金额', value: '450元' }
])
})
test('receipt files are collected through a single OCR persistence entry before draft association', async () => {
const files = [
{ name: 'invoice.png' }

View File

@@ -1902,6 +1902,80 @@ test('application preview editor can edit return date from inline table input',
assert.equal(message.applicationPreview.fields.days, '5\u5929')
})
test('application preview editor opens date fields with native date input values', () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-02-20 \u81f3 2026-02-23',
location: '\u4e0a\u6d77',
reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72',
days: '4\u5929'
}
})
const message = {
id: 'application-preview-editor-native-date-message',
applicationPreview: preview,
text: ''
}
const editor = useApplicationPreviewEditor({
persistSessionState: () => {},
toast: () => {}
})
editor.openApplicationPreviewEditor(message, 'time', message.applicationPreview.fields.time)
assert.equal(editor.resolveApplicationPreviewEditorControl('time'), 'date')
assert.equal(editor.applicationPreviewEditor.value.draftValue, '2026-02-20')
assert.equal(editor.resolveApplicationPreviewEditorDateMax(message, 'time'), '2026-02-23')
editor.openApplicationPreviewEditor(message, 'time_return', '2026-02-23')
assert.equal(editor.resolveApplicationPreviewEditorControl('time_return'), 'date')
assert.equal(editor.applicationPreviewEditor.value.draftValue, '2026-02-23')
assert.equal(editor.resolveApplicationPreviewEditorDateMin(message, 'time_return'), '2026-02-20')
})
test('application preview editor blocks invalid date ranges', async () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-02-20 \u81f3 2026-02-23',
location: '\u4e0a\u6d77',
reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72',
days: '4\u5929',
transportMode: '\u706b\u8f66',
amount: ''
}
})
const message = {
id: 'application-preview-editor-invalid-date-message',
applicationPreview: preview,
text: ''
}
const toastMessages = []
const editor = useApplicationPreviewEditor({
persistSessionState: () => {},
toast: (messageText) => {
toastMessages.push(messageText)
}
})
editor.openApplicationPreviewEditor(message, 'time_return', '2026-02-23')
editor.applicationPreviewEditor.value.draftValue = '2026-02-19'
const returnCommitted = await editor.commitApplicationPreviewEditor(message)
assert.equal(returnCommitted, false)
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-23')
assert.equal(message.applicationPreview.fields.days, '4\u5929')
assert.equal(toastMessages.at(-1), '\u51fa\u53d1\u65f6\u95f4\u4e0d\u80fd\u665a\u4e8e\u8fd4\u56de\u65f6\u95f4\uff0c\u8bf7\u91cd\u65b0\u9009\u62e9\u3002')
editor.openApplicationPreviewEditor(message, 'time', message.applicationPreview.fields.time)
editor.applicationPreviewEditor.value.draftValue = '2026-02-24'
const startCommitted = await editor.commitApplicationPreviewEditor(message)
assert.equal(startCommitted, false)
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-23')
assert.equal(toastMessages.at(-1), '\u51fa\u53d1\u65f6\u95f4\u4e0d\u80fd\u665a\u4e8e\u8fd4\u56de\u65f6\u95f4\uff0c\u8bf7\u91cd\u65b0\u9009\u62e9\u3002')
})
test('application preview editor estimates after shorthand return date input', async () => {
const preview = normalizeApplicationPreview({
fields: {

View File

@@ -0,0 +1,90 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { useTravelReimbursementCreateViewControls } from '../src/views/scripts/useTravelReimbursementCreateViewControls.js'
function ref(value) {
return { value }
}
const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
test('选择候选草稿时直接确认归集并带入附件原件', async () => {
const submitCalls = []
const attachedFiles = [{ name: '2月20 武汉-上海.pdf' }]
const message = {
queryPayload: {
selectionMode: 'draft_association'
}
}
const controls = useTravelReimbursementCreateViewControls({
activeSessionType: ref('expense'),
attachedFiles: ref(attachedFiles),
clearAssistantSessionSnapshot: () => {},
closeAfterBusy: ref(false),
conversationId: ref('conversation-1'),
deleteConversation: async () => {},
deleteSessionBusy: ref(false),
deleteSessionDialogOpen: ref(false),
draftClaimId: ref(''),
emitClose: () => {},
getExpenseQueryActivePage: () => 1,
getExpenseQueryTotalPages: () => 1,
persistSessionState: () => {},
resetCurrentSessionState: () => {},
reviewActionBusy: ref(false),
router: { push: () => {} },
resolveCurrentUserId: () => 'user-1',
sessionSwitchBusy: ref(false),
submitComposer: async (options) => {
submitCalls.push(options)
return { ok: true }
},
submitting: ref(false),
toast: () => {},
workbenchVisible: ref(true)
})
await controls.handleExpenseQueryRecordClick(message, {
claimId: 'claim-1',
claimNo: 'R74CB7C2R'
})
assert.equal(submitCalls.length, 1)
assert.equal(submitCalls[0].associationConfirmed, true)
assert.equal(submitCalls[0].skipDraftAssociationPrompt, true)
assert.equal(submitCalls[0].uploadDisposition, 'continue_existing')
assert.deepEqual(submitCalls[0].files, attachedFiles)
assert.equal(submitCalls[0].files[0], attachedFiles[0])
assert.equal(submitCalls[0].extraContext.review_action, 'link_to_existing_draft')
assert.equal(submitCalls[0].extraContext.attachment_association_confirmed, true)
assert.equal(submitCalls[0].extraContext.draft_claim_id, 'claim-1')
assert.equal(message.queryPayload.selectionLocked, true)
assert.equal(message.queryPayload.selectedClaimId, 'claim-1')
})
test('确认归集到现有草稿时先同步附件再渲染最终结果', () => {
assert.match(
submitComposerScript,
/let attachmentSyncCompleted = false/
)
assert.match(
submitComposerScript,
/if \(\s*reviewActionResult === 'link_to_existing_draft'[\s\S]*await persistComposerFilesToDraft\(\)[\s\S]*attachmentSyncCompleted = true[\s\S]*\}/
)
assert.ok(
submitComposerScript.indexOf('await persistComposerFilesToDraft()') <
submitComposerScript.indexOf('const assistantMessage = createMessage('),
'附件同步应先于最终助手消息,避免详情页先展示空明细和旧风险'
)
assert.match(
submitComposerScript,
/if \(!attachmentSyncCompleted\) \{\s*const persistTask = persistComposerFilesToDraft\(\)/
)
})

View File

@@ -0,0 +1,14 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import test from 'node:test'
const root = process.cwd()
test('receipt folder asset fetch bypasses stale preview cache', () => {
const service = readFileSync(join(root, 'web/src/services/receiptFolder.js'), 'utf8')
assert.match(service, /export function fetchReceiptFolderAsset/)
assert.match(service, /cache: 'no-store'/)
assert.match(service, /responseType: 'blob'/)
})

View File

@@ -96,6 +96,25 @@ test('topbar bell owns document center unread notifications', () => {
assert.doesNotMatch(topbarStyles, /\.notification-dot/)
})
test('topbar notification popover uses inbox-style rows with formatted time labels', () => {
assert.match(topbar, /class="notification-row-main"/)
assert.match(topbar, /class="notification-row-head"/)
assert.match(topbar, /class="notification-row-title"/)
assert.match(topbar, /class="notification-context"/)
assert.match(topbar, /class="notification-row-foot"/)
assert.match(topbar, /class="notification-category-pill"/)
assert.match(topbar, /class="notification-time"/)
assert.match(topbar, /class="notification-row-action"/)
assert.match(topbar, /timeLabel:\s*formatNotificationTimeLabel\(row\.updatedAt \|\| row\.createdAt\)/)
assert.match(topbar, /timeLabel:\s*formatNotificationTimeLabel\(item\.time \|\| item\.updatedAt \|\| item\.due\)/)
assert.doesNotMatch(topbar, /<time>\{\{ item\.time \}\}<\/time>/)
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*36px minmax\(0,\s*1fr\);/)
assert.match(topbarStyles, /\.notification-context\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
assert.match(topbarStyles, /\.notification-row-foot\s*\{[\s\S]*justify-content:\s*space-between;/)
assert.match(topbarStyles, /\.notification-time\s*\{[\s\S]*font-variant-numeric:\s*tabular-nums;/)
assert.match(topbarStyles, /\.notification-row-action\s*\{[\s\S]*width:\s*28px;[\s\S]*height:\s*28px;/)
})
test('topbar notification state is persisted through backend API with local fallback', () => {
assert.match(notificationStatesService, /apiRequest\('\/notification-states'\)/)
assert.match(notificationStatesService, /apiRequest\('\/notification-states',\s*\{[\s\S]*method:\s*'POST'/)

View File

@@ -0,0 +1,35 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
resolveCreatedSmartEntryRecognitionItem
} from '../src/views/scripts/travelRequestDetailSmartEntryRecognition.js'
test('智能录入创建明细后跳过系统补贴行', () => {
const createdItem = resolveCreatedSmartEntryRecognitionItem([
{
id: 'allowance-item',
item_type: 'travel_allowance',
invoice_id: ''
},
{
id: 'business-item',
item_type: 'travel',
invoice_id: ''
}
], new Set())
assert.equal(createdItem?.id, 'business-item')
})
test('智能录入创建明细后没有可上传业务行时返回空', () => {
const createdItem = resolveCreatedSmartEntryRecognitionItem([
{
id: 'allowance-item',
item_type: 'travel_allowance',
invoice_id: ''
}
], new Set())
assert.equal(createdItem, null)
})

View File

@@ -229,12 +229,17 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /rows="3"/)
assert.match(aiModeSurface, /workbench-ai-composer-toolbar/)
assert.match(aiModeSurface, /<article v-for="file in selectedFileCards"[\s\S]*class="workbench-ai-file-card"/)
assert.match(aiModeSurface, /class="workbench-ai-file-card__ocr"/)
assert.match(aiModeSurface, /file\.ocrState\?\.label/)
assert.match(aiModeSurface, /mdi mdi-text-recognition/)
assert.match(aiModeStyles, /\.workbench-ai-file-card__ocr/)
assert.match(aiModeStyles, /workbenchAiOcrSpin/)
assert.match(aiModeSurface, /:aria-label="`移除附件 \$\{file\.name\}`"/)
assert.match(aiModeSurface, /function removeAiModeFile\(fileKey\)/)
assert.match(aiModeSurface, /const selectedFileCards = computed/)
assert.match(aiModeSurface, /resolveAiComposerFileType\(file\)/)
assert.match(aiModeSurface, /AI_COMPOSER_FILE_TYPE_META = \{[\s\S]*pdf:\s*\{ label:\s*'PDF'/)
assert.match(aiModeSurface, /import \{ collectReceiptFiles \} from '\.\.\/\.\.\/views\/scripts\/travelReimbursementAttachmentModel\.js'/)
assert.match(aiModeSurface, /buildFileIdentity,[\s\S]*collectReceiptFiles[\s\S]*travelReimbursementAttachmentModel\.js/)
assert.match(aiModeSurface, /MAX_ATTACHMENTS,[\s\S]*mergeFilesWithLimit[\s\S]*travelReimbursementAttachmentModel\.js/)
assert.match(aiModeSurface, /import \* as aiAttachmentAssociationModel from '\.\.\/\.\.\/utils\/aiAttachmentAssociationModel\.js'/)
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch/)
@@ -261,7 +266,7 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /function findAiAttachmentAssociationRuntime\(options = \{\}\)/)
assert.match(aiModeSurface, /resolveAiAttachmentAssociationClaimNo\(actionPayload\)/)
assert.match(aiModeSurface, /if \(actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION\)/)
assert.match(aiModeSurface, /collectReceiptFiles\(\{[\s\S]*files,[\s\S]*recognizeOcrFiles[\s\S]*\}\)/)
assert.match(aiModeSurface, /const collected = await collectAiModeReceiptContext\(files\)/)
assert.match(aiModeSurface, /const claims = extractExpenseClaimItems\(claimsPayload\)/)
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch\(claims, collected\.ocrDocuments\)/)
assert.match(aiModeSurface, /aiAttachmentAssociationRuntime\.set\(associationId/)
@@ -278,6 +283,14 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /mdi mdi-calendar-range/)
assert.match(aiModeSurface, /workbench-ai-date-popover/)
assert.match(aiModeSurface, /type="date"/)
assert.match(aiModeSurface, /:min="resolveInlineApplicationPreviewEditorDateMin\(message, row\.key\)"/)
assert.match(aiModeSurface, /:max="resolveInlineApplicationPreviewEditorDateMax\(message, row\.key\)"/)
assert.match(aiModeSurface, /resolveInlineApplicationPreviewEditorControl\(row\.key\) === 'date'/)
assert.match(aiModeSurface, /class="\['application-preview-input', 'application-preview-date-input', `application-preview-input--\$\{row\.key\}`\]"/)
assert.match(aiModeSurface, /function resolveInlineApplicationPreviewEditorControl\(fieldKey\) \{[\s\S]*return resolveApplicationPreviewEditorControl\(fieldKey\)/)
assert.match(aiModeSurface, /function resolveInlineApplicationPreviewEditorDateMin\(message, fieldKey\) \{[\s\S]*return resolveApplicationPreviewEditorDateMin\?\.\(message, fieldKey\) \|\| ''/)
assert.match(aiModeSurface, /function resolveInlineApplicationPreviewEditorDateMax\(message, fieldKey\) \{[\s\S]*return resolveApplicationPreviewEditorDateMax\?\.\(message, fieldKey\) \|\| ''/)
assert.doesNotMatch(aiModeSurface, /return control === 'date' \? 'text' : control/)
assert.doesNotMatch(aiModeSurface, /mdi mdi-web/)
assert.match(aiModeSurface, /mdi mdi-microphone-outline/)
assert.match(aiModeSurface, /mdi mdi-arrow-up/)
@@ -342,6 +355,9 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-action-link\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-table-wrap\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-image-frame\)/)
assert.match(aiModeStyles, /\.application-preview-date-input\s*\{[\s\S]*width:\s*min\(100%,\s*188px\);/)
assert.match(aiModeStyles, /\.application-preview-input--location\s*\{[\s\S]*width:\s*min\(100%,\s*220px\);/)
assert.match(aiModeStyles, /\.application-preview-input--reason\s*\{[\s\S]*width:\s*min\(100%,\s*680px\);/)
assert.match(aiModeSurface, /import \{ fetchSettings \} from '\.\.\/\.\.\/services\/settings\.js'/)
assert.match(aiModeSurface, /fetchStewardPlan,[\s\S]*fetchStewardPlanStream[\s\S]*services\/steward\.js'/)
assert.match(aiModeSurface, /import \{ useWorkbenchComposerDate \} from '\.\.\/useWorkbenchComposerDate\.js'/)
@@ -354,7 +370,7 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /buildStewardPlanRequest/)
assert.match(aiModeSurface, /buildStewardPlanMessageText/)
assert.match(aiModeSurface, /buildStewardSuggestedActions/)
assert.match(aiModeSurface, /const emit = defineEmits\(\['conversation-change', 'conversation-history-change', 'open-document'\]\)/)
assert.match(aiModeSurface, /const emit = defineEmits\(\['conversation-change', 'conversation-history-change', 'open-document', 'request-updated'\]\)/)
assert.match(aiModeSurface, /function startInlineConversation\(prompt, entry = \{\}, files = \[\]\)/)
assert.match(aiModeSurface, /activateInlineConversation\(\{[\s\S]*title:[\s\S]*\}\)[\s\S]*conversationMessages\.value\.push\(createInlineMessage\('user'/)
assert.match(aiModeSurface, /persistCurrentConversation\(\)/)
@@ -370,6 +386,13 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /if \(!hasServerStreamedContent\) \{[\s\S]*await streamInlineAssistantContent\(pendingMessage\.id, finalMessageText\)[\s\S]*\}/)
assert.match(aiModeSurface, /if \(actionType === AI_APPLICATION_ACTION_SUBMIT\) \{[\s\S]*buildInlineApplicationResultTable\(draftPayload/)
assert.match(aiModeSurface, /需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。/)
assert.match(aiModeSurface, /function buildInlineApplicationActionFailureText\(error, isSubmit\)/)
assert.match(aiModeSurface, /我已保留当前申请核对表/)
assert.match(aiModeSurface, /applicationPreview:\s*targetMessage\.applicationPreview/)
assert.match(
aiModeSurface,
/suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\([\s\S]*targetMessage\.applicationPreview/
)
assert.doesNotMatch(aiModeSurface, /\*\*申请单号:\*\*/)
assert.doesNotMatch(aiModeSurface, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
assert.doesNotMatch(aiModeSurface, /runOrchestrator\(/)
@@ -489,14 +512,51 @@ test('AI mode screen follows the approved reference structure', () => {
assert.ok(pngPresentation.minimumForegroundHeightRatio > 0.9)
})
test('AI attachment association notifies shell to refresh the target detail page', () => {
const aiModeComponent = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
const workbenchView = readSource('../src/views/PersonalWorkbenchView.vue')
const appShellRouteView = readSource('../src/views/AppShellRouteView.vue')
const aiModeComposable = readSource('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js')
const attachmentFlow = readSource('../src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationFlow.js')
assert.match(aiModeComponent, /defineEmits\(\[[^\]]*'request-updated'/)
assert.match(workbenchView, /@request-updated="emit\('request-updated', \$event\)"/)
assert.match(workbenchView, /defineEmits\(\[[^\]]*'request-updated'/)
assert.match(appShellRouteView, /<PersonalWorkbenchView[\s\S]*@request-updated="handleRequestUpdated"/)
assert.match(
aiModeComposable,
/notifyRequestUpdated:\s*\(payload\)\s*=>\s*emit\('request-updated', payload\)/
)
assert.match(
attachmentFlow,
/notifyRequestUpdated\?\.\(\{[\s\S]*claimId:[\s\S]*runtime\.claimId[\s\S]*uploadedCount:[\s\S]*syncResult\?\.uploadedCount/
)
})
test('AI mode normal assistant requests include OCR context for uploaded receipts', () => {
assert.match(aiModeSurface, /function isLikelyAiModeOcrFile\(file = \{\}\)/)
assert.match(aiModeSurface, /const aiModeReceiptContextCache = new Map\(\)/)
assert.match(aiModeSurface, /const aiModeReceiptRecognitionState = reactive\(\{\}\)/)
assert.match(aiModeSurface, /function resolveAiModeReceiptRecognitionState\(file\)/)
assert.match(aiModeSurface, /resolveAiModeReceiptRecognitionState\(selectedFiles\.value\[index\]\)/)
assert.match(aiModeSurface, /status:\s*'recognizing'[\s\S]*label:\s*'智能录入识别中'/)
assert.match(aiModeSurface, /status:\s*'recognized'[\s\S]*label:\s*detail \? `已识别票据/)
assert.match(aiModeSurface, /status:\s*'failed'[\s\S]*label:\s*'识别失败'/)
assert.match(aiModeSurface, /function primeAiModeReceiptContext\(files = \[\]\)/)
assert.match(aiModeSurface, /function startAiModeReceiptRecognition\(files = \[\]\)/)
assert.match(aiModeSurface, /function buildAiModeReceiptContextCacheKey\(ocrFiles = \[\]\)/)
assert.match(aiModeSurface, /applyAiModeReceiptRecognitionResult\(ocrFiles, context\)/)
assert.match(aiModeSurface, /buildFileIdentity\(file\)/)
assert.match(aiModeSurface, /watch\(selectedFiles, \(files\) => \{[\s\S]*attachmentFlow\.primeAiModeReceiptContext\(files\)/)
assert.match(aiModeSurface, /async function collectAiModeReceiptContext\(files = \[\]\)/)
assert.match(aiModeSurface, /cached\?\.status === 'pending'[\s\S]*await cached\.promise/)
assert.match(aiModeSurface, /collectReceiptFiles\(\{[\s\S]*files:\s*ocrFiles,[\s\S]*recognizeOcrFiles[\s\S]*\}\)/)
assert.match(aiModeSurface, /const receiptContext = await collectAiModeReceiptContext\(files\)/)
assert.match(aiModeSurface, /const attachmentOcrDetails = buildInlineAttachmentOcrDetails\(receiptContext, files\)/)
assert.match(aiModeSurface, /ocr_summary:\s*receiptContext\.ocrSummary/)
assert.match(aiModeSurface, /ocr_documents:\s*receiptContext\.ocrDocuments/)
assert.match(aiModeSurface, /attachment_names:\s*receiptContext\.attachmentNames/)
assert.match(aiModeSurface, /attachment_count:\s*receiptContext\.attachmentCount/)
assert.match(aiModeSurface, /ocr_source_file_names:\s*receiptContext\.ocrSourceFileNames/)
assert.match(aiModeSurface, /attachmentOcrDetails/)
})

View File

@@ -90,7 +90,7 @@ if [ "${X_FINANCIAL_FORCE_SETUP:-false}" = "true" ]; then
fi
WEB_HOST="${WEB_HOST:-0.0.0.0}"
WEB_PORT="${WEB_PORT:-5273}"
WEB_PORT="${WEB_PORT:-5173}"
export VITE_SETUP_COMPLETED="${SETUP_COMPLETED:-false}"
export VITE_COMPANY_NAME="${COMPANY_NAME:-}"