Compare commits
6 Commits
bc743adef3
...
f17098aa58
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f17098aa58 | ||
|
|
8094333e3b | ||
|
|
0122f3b250 | ||
|
|
dc4cad2baa | ||
|
|
e725b7f19c | ||
|
|
84a8998e59 |
@@ -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
@@ -25,6 +25,7 @@ server/storage/receipt_folder/
|
||||
test-results/
|
||||
.codex-remote-attachments/
|
||||
tmp-*.png
|
||||
tmp/
|
||||
.nezha/
|
||||
.omo/
|
||||
.env
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
98
server/src/app/services/document_preview.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 61 KiB |
@@ -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": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 153 KiB |
@@ -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": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 153 KiB |
@@ -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": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 134 KiB |
@@ -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": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 134 KiB |
@@ -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": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 134 KiB |
@@ -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": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 134 KiB |
@@ -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": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 134 KiB |
@@ -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": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 133 KiB |
@@ -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": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 133 KiB |
@@ -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 18;Wuhan 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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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元"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
12
start.sh
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -32,6 +32,7 @@ export function fetchReceiptFolderAsset(pathOrUrl) {
|
||||
throw new Error('票据文件地址为空。')
|
||||
}
|
||||
return apiRequest(target, {
|
||||
cache: 'no-store',
|
||||
responseType: 'blob'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
@ai-conversation-history-change="handleAiConversationHistoryChange"
|
||||
@open-assistant="openSmartEntry"
|
||||
@open-document="openWorkbenchDocument"
|
||||
@request-updated="handleRequestUpdated"
|
||||
/>
|
||||
|
||||
<TravelRequestDetailView
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 : []
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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('新增费用明细失败,请稍后重试。')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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: {
|
||||
|
||||
90
web/tests/expense-attachment-draft-selection.test.mjs
Normal 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\(\)/
|
||||
)
|
||||
})
|
||||
14
web/tests/receipt-folder-asset-cache.test.mjs
Normal 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'/)
|
||||
})
|
||||
@@ -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'/)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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:-}"
|
||||
|
||||