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/
|
# Admin login credentials are stored separately under server/.secrets/
|
||||||
|
|
||||||
WEB_HOST=0.0.0.0
|
WEB_HOST=0.0.0.0
|
||||||
WEB_PORT=5273
|
WEB_PORT=5173
|
||||||
VITE_WEB_HOST=0.0.0.0
|
VITE_WEB_HOST=0.0.0.0
|
||||||
VITE_WEB_PORT=5273
|
VITE_WEB_PORT=5173
|
||||||
|
|
||||||
SERVER_HOST=0.0.0.0
|
SERVER_HOST=0.0.0.0
|
||||||
SERVER_PORT=8000
|
SERVER_PORT=8000
|
||||||
@@ -52,4 +52,4 @@ OCR_DEVICE=
|
|||||||
OCR_TIMEOUT_SECONDS=180
|
OCR_TIMEOUT_SECONDS=180
|
||||||
OCR_MAX_CONCURRENT_WORKERS=1
|
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/
|
test-results/
|
||||||
.codex-remote-attachments/
|
.codex-remote-attachments/
|
||||||
tmp-*.png
|
tmp-*.png
|
||||||
|
tmp/
|
||||||
.nezha/
|
.nezha/
|
||||||
.omo/
|
.omo/
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
environment:
|
environment:
|
||||||
WEB_HOST: 0.0.0.0
|
WEB_HOST: 0.0.0.0
|
||||||
|
WEB_PORT: "${WEB_PORT:-5173}"
|
||||||
SERVER_HOST: 0.0.0.0
|
SERVER_HOST: 0.0.0.0
|
||||||
|
SERVER_PORT: "${SERVER_PORT:-8000}"
|
||||||
SERVER_VENV_DIR: /tmp/x-financial-server-venv
|
SERVER_VENV_DIR: /tmp/x-financial-server-venv
|
||||||
X_FINANCIAL_PREFER_ENV_FILE: "false"
|
X_FINANCIAL_PREFER_ENV_FILE: "false"
|
||||||
POSTGRES_HOST: postgres
|
POSTGRES_HOST: postgres
|
||||||
@@ -28,7 +30,7 @@ services:
|
|||||||
QDRANT_URL: "http://qdrant:6333"
|
QDRANT_URL: "http://qdrant:6333"
|
||||||
LIGHTRAG_WORKSPACE: "x_financial_knowledge"
|
LIGHTRAG_WORKSPACE: "x_financial_knowledge"
|
||||||
ports:
|
ports:
|
||||||
- "${WEB_PORT:-5273}:${WEB_PORT:-5273}"
|
- "${WEB_PORT:-5173}:${WEB_PORT:-5173}"
|
||||||
- "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}"
|
- "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}"
|
||||||
- "2223:22"
|
- "2223:22"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -67,7 +69,7 @@ services:
|
|||||||
cd /app &&
|
cd /app &&
|
||||||
./start.sh all
|
./start.sh all
|
||||||
healthcheck:
|
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
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
WEB_HOST: 0.0.0.0
|
WEB_HOST: 0.0.0.0
|
||||||
|
WEB_PORT: "${WEB_PORT:-5173}"
|
||||||
SERVER_HOST: 0.0.0.0
|
SERVER_HOST: 0.0.0.0
|
||||||
|
SERVER_PORT: "${SERVER_PORT:-8000}"
|
||||||
SERVER_VENV_DIR: /tmp/x-financial-server-venv
|
SERVER_VENV_DIR: /tmp/x-financial-server-venv
|
||||||
X_FINANCIAL_PREFER_ENV_FILE: "true"
|
X_FINANCIAL_PREFER_ENV_FILE: "true"
|
||||||
ONLYOFFICE_ENABLED: "${ONLYOFFICE_ENABLED:-false}"
|
ONLYOFFICE_ENABLED: "${ONLYOFFICE_ENABLED:-false}"
|
||||||
@@ -15,7 +17,7 @@ services:
|
|||||||
QDRANT_URL: "${QDRANT_URL:-}"
|
QDRANT_URL: "${QDRANT_URL:-}"
|
||||||
LIGHTRAG_WORKSPACE: "x_financial_knowledge"
|
LIGHTRAG_WORKSPACE: "x_financial_knowledge"
|
||||||
ports:
|
ports:
|
||||||
- "${WEB_PORT:-5273}:${WEB_PORT:-5273}"
|
- "${WEB_PORT:-5173}:${WEB_PORT:-5173}"
|
||||||
- "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}"
|
- "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}"
|
||||||
- "2223:22"
|
- "2223:22"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -54,7 +56,7 @@ services:
|
|||||||
cd /app &&
|
cd /app &&
|
||||||
./start.sh all
|
./start.sh all
|
||||||
healthcheck:
|
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
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
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)
|
file_path, media_type, file_name = ReceiptFolderService().resolve_preview(receipt_id, current_user)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Receipt preview not found") from 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(
|
@router.get(
|
||||||
|
|||||||
@@ -25,11 +25,15 @@ AMOUNT_PATTERNS = (
|
|||||||
re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)"),
|
re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)"),
|
||||||
re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"),
|
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)")
|
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_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})")
|
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})")
|
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})")
|
ROUTE_PATTERN = re.compile(r"([\u4e00-\u9fa5]{2,12})\s*(?:至|→|->|-)\s*([\u4e00-\u9fa5]{2,12})")
|
||||||
MERCHANT_PATTERNS = (
|
MERCHANT_PATTERNS = (
|
||||||
re.compile(r"(?:销售方(?:名称)?|商户(?:名称)?|开票方(?:名称)?|收款方(?:名称)?)[::\s]*([A-Za-z0-9\u4e00-\u9fa5()()·&\\-]{2,40})"),
|
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
|
best_score = score
|
||||||
|
|
||||||
if best_score <= 0:
|
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)
|
return RuleMatch(rule=None, confidence=0.0, evidence=(), score=0.0)
|
||||||
|
|
||||||
confidence = min(0.94, 0.30 + min(best_score, 4.8) * 0.12)
|
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:
|
def _extract_json_payload(response_text: str | None) -> dict[str, Any] | None:
|
||||||
if not response_text:
|
if not response_text:
|
||||||
return None
|
return None
|
||||||
@@ -521,33 +544,48 @@ def _merge_document_fields(
|
|||||||
|
|
||||||
def _extract_document_fields(text: str, document_type: str = "") -> list[DocumentField]:
|
def _extract_document_fields(text: str, document_type: str = "") -> list[DocumentField]:
|
||||||
fields: 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)
|
amount = _extract_amount(text)
|
||||||
if amount:
|
if amount:
|
||||||
fields.append(DocumentField(key="amount", label="金额", value=amount))
|
append_field("amount", "金额", amount)
|
||||||
|
|
||||||
date_value = _extract_date(text, document_type=document_type)
|
date_value = _extract_date(text, document_type=document_type)
|
||||||
if date_value:
|
if date_value:
|
||||||
fields.append(DocumentField(key="date", label="日期", value=date_value))
|
append_field("date", "日期", date_value)
|
||||||
|
|
||||||
merchant = _extract_merchant(text)
|
merchant = _extract_merchant(text)
|
||||||
if merchant:
|
if merchant:
|
||||||
fields.append(DocumentField(key="merchant_name", label="商户", value=merchant))
|
append_field("merchant_name", "商户", merchant)
|
||||||
|
|
||||||
invoice_number = _extract_pattern(INVOICE_NUMBER_PATTERN, text)
|
invoice_number = _extract_pattern(INVOICE_NUMBER_PATTERN, text)
|
||||||
if invoice_number:
|
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)
|
invoice_code = _extract_pattern(INVOICE_CODE_PATTERN, text)
|
||||||
if invoice_code:
|
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)
|
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:
|
if trip_no:
|
||||||
fields.append(DocumentField(key="trip_no", label="车次/航班", value=trip_no))
|
append_field("trip_no", "车次/航班", trip_no.upper())
|
||||||
|
|
||||||
route = _extract_route(text)
|
route = _extract_route(text)
|
||||||
if route:
|
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
|
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()
|
raw_value = str(match.group(1) or "").strip()
|
||||||
normalized = raw_value.replace("年", "-").replace("月", "-").replace("日", "")
|
normalized = raw_value.replace("年", "-").replace("月", "-").replace("日", "")
|
||||||
normalized = normalized.replace("/", "-").replace(".", "-")
|
normalized = normalized.replace("/", "-").replace(".", "-")
|
||||||
|
normalized = re.sub(r"\s+", "-", normalized)
|
||||||
parts = [part for part in normalized.split("-") if part]
|
parts = [part for part in normalized.split("-") if part]
|
||||||
if len(parts) != 3:
|
if len(parts) != 3:
|
||||||
return raw_value
|
return raw_value
|
||||||
@@ -703,6 +742,23 @@ def _extract_route(text: str) -> str:
|
|||||||
return f"{start}-{end}"
|
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:
|
def _extract_pattern(pattern: re.Pattern[str], text: str) -> str:
|
||||||
match = pattern.search(text)
|
match = pattern.search(text)
|
||||||
if not match:
|
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
|
@staticmethod
|
||||||
def _has_date_like_text(text: str) -> bool:
|
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
|
@staticmethod
|
||||||
def _normalize_match_text(text: str) -> str:
|
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 "其他单据"
|
recognized_document_label = str(document_info.get("document_type_label") or "其他单据").strip() or "其他单据"
|
||||||
requirement_matches = bool(requirement_check.get("matches"))
|
requirement_matches = bool(requirement_check.get("matches"))
|
||||||
mismatch_severity = str(requirement_check.get("mismatch_severity") or "high").strip().lower() or "high"
|
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(
|
has_ticket_keyword = any(
|
||||||
keyword in compact_text
|
keyword in compact_text
|
||||||
@@ -556,15 +582,18 @@ class ExpenseClaimAttachmentAnalysisMixin:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
amount_candidates = self._extract_amount_candidates(text)
|
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"))
|
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_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
|
amount_mismatch = bool(amount_candidates) and item_amount > Decimal("0.00") and not has_matching_amount
|
||||||
|
|
||||||
points: list[str] = []
|
points: list[str] = []
|
||||||
if warnings:
|
if warnings:
|
||||||
points.append(f"识别提示:{warnings[0]}")
|
points.append(f"识别提示:{warnings[0]}")
|
||||||
if line_count == 0 or not compact_text:
|
if not has_readable_content:
|
||||||
points.append("附件内容:未识别到有效文字,当前附件更像普通图片或内容过于模糊。")
|
points.append("附件内容:未识别到有效文字,当前附件更像普通图片或内容过于模糊。")
|
||||||
if recognized_document_type == "other" and not has_ticket_keyword:
|
if recognized_document_type == "other" and not has_ticket_keyword:
|
||||||
points.append("票据类型:未识别到发票、票据、电子行程单等关键字,暂无法判断票据类型。")
|
points.append("票据类型:未识别到发票、票据、电子行程单等关键字,暂无法判断票据类型。")
|
||||||
@@ -617,8 +646,7 @@ class ExpenseClaimAttachmentAnalysisMixin:
|
|||||||
headline = "AI提示:住宿金额超出报销标准"
|
headline = "AI提示:住宿金额超出报销标准"
|
||||||
summary = "当前住宿票据金额超过规则中心差旅住宿标准,已作为风险项保留在单据中;如需按特殊情况提交,请补充超标原因。"
|
summary = "当前住宿票据金额超过规则中心差旅住宿标准,已作为风险项保留在单据中;如需按特殊情况提交,请补充超标原因。"
|
||||||
elif (
|
elif (
|
||||||
line_count == 0
|
not has_readable_content
|
||||||
or not compact_text
|
|
||||||
or (recognized_document_type == "other" and not has_ticket_keyword and issue_count >= 2)
|
or (recognized_document_type == "other" and not has_ticket_keyword and issue_count >= 2)
|
||||||
or (not requirement_matches and mismatch_severity == "high")
|
or (not requirement_matches and mismatch_severity == "high")
|
||||||
or (purpose_mismatch_point and amount_mismatch)
|
or (purpose_mismatch_point and amount_mismatch)
|
||||||
|
|||||||
@@ -119,6 +119,13 @@ class ExpenseClaimAttachmentDocumentMixin:
|
|||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
item=item,
|
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_value = metadata.get("uploaded_at")
|
||||||
uploaded_at = None
|
uploaded_at = None
|
||||||
if isinstance(uploaded_at_value, str) and uploaded_at_value.strip():
|
if isinstance(uploaded_at_value, str) and uploaded_at_value.strip():
|
||||||
@@ -157,6 +164,68 @@ class ExpenseClaimAttachmentDocumentMixin:
|
|||||||
"requirement_check": requirement_check,
|
"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]:
|
def _build_attachment_document_info(self, document: Any) -> dict[str, Any]:
|
||||||
insight = build_document_insight(
|
insight = build_document_insight(
|
||||||
filename=str(getattr(document, "filename", "") or ""),
|
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_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||||
from app.services.agent_foundation import AgentFoundationService
|
from app.services.agent_foundation import AgentFoundationService
|
||||||
from app.services.audit import AuditLogService
|
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.document_intelligence import build_document_insight
|
||||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||||
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
|
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_storage_key": str(preview_meta["preview_storage_key"]),
|
||||||
"preview_media_type": str(preview_meta["preview_media_type"]),
|
"preview_media_type": str(preview_meta["preview_media_type"]),
|
||||||
"preview_file_name": str(preview_meta["preview_file_name"]),
|
"preview_file_name": str(preview_meta["preview_file_name"]),
|
||||||
|
"preview_rendered_with": str(preview_meta.get("preview_rendered_with") or ""),
|
||||||
"analysis": attachment_analysis,
|
"analysis": attachment_analysis,
|
||||||
"document_info": document_info,
|
"document_info": document_info,
|
||||||
"requirement_check": requirement_check,
|
"requirement_check": requirement_check,
|
||||||
@@ -673,6 +675,60 @@ class ExpenseClaimAttachmentOperationsMixin:
|
|||||||
self._attachment_storage.write_meta(file_path, metadata)
|
self._attachment_storage.write_meta(file_path, metadata)
|
||||||
return 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]:
|
def _resolve_item_attachment_preview_content(self, item: ExpenseClaimItem) -> tuple[Path, str, str]:
|
||||||
file_path, media_type, filename = self._resolve_item_attachment_content(item)
|
file_path, media_type, filename = self._resolve_item_attachment_content(item)
|
||||||
metadata = self._attachment_storage.read_meta(file_path)
|
metadata = self._attachment_storage.read_meta(file_path)
|
||||||
@@ -681,6 +737,10 @@ class ExpenseClaimAttachmentOperationsMixin:
|
|||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
item=item,
|
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_storage_key = str(metadata.get("preview_storage_key") or "").strip()
|
||||||
preview_file_name = str(metadata.get("preview_file_name") or "").strip()
|
preview_file_name = str(metadata.get("preview_file_name") or "").strip()
|
||||||
preview_media_type = str(metadata.get("preview_media_type") or "").strip()
|
preview_media_type = str(metadata.get("preview_media_type") or "").strip()
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
|
||||||
import binascii
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from app.services.document_preview import DocumentPreviewAssets
|
||||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
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_storage_key": self.storage.to_storage_key(preview_path),
|
||||||
"preview_media_type": preview_media_type,
|
"preview_media_type": preview_media_type,
|
||||||
"preview_file_name": preview_file_name,
|
"preview_file_name": preview_file_name,
|
||||||
|
"preview_rendered_with": DocumentPreviewAssets.renderer_id_for_source(media_type),
|
||||||
}
|
}
|
||||||
|
|
||||||
if preview_kind:
|
if preview_kind:
|
||||||
@@ -51,6 +50,7 @@ class ExpenseClaimAttachmentPresentation:
|
|||||||
"preview_storage_key": storage_key,
|
"preview_storage_key": storage_key,
|
||||||
"preview_media_type": media_type,
|
"preview_media_type": media_type,
|
||||||
"preview_file_name": filename,
|
"preview_file_name": filename,
|
||||||
|
"preview_rendered_with": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -59,6 +59,7 @@ class ExpenseClaimAttachmentPresentation:
|
|||||||
"preview_storage_key": "",
|
"preview_storage_key": "",
|
||||||
"preview_media_type": "",
|
"preview_media_type": "",
|
||||||
"preview_file_name": "",
|
"preview_file_name": "",
|
||||||
|
"preview_rendered_with": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -72,15 +73,7 @@ class ExpenseClaimAttachmentPresentation:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def decode_data_url(payload: str) -> tuple[str, bytes] | None:
|
def decode_data_url(payload: str) -> tuple[str, bytes] | None:
|
||||||
normalized = str(payload or "").strip()
|
return DocumentPreviewAssets.decode_data_url(payload)
|
||||||
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
|
|
||||||
|
|
||||||
def _write_preview_asset_from_data_url(
|
def _write_preview_asset_from_data_url(
|
||||||
self,
|
self,
|
||||||
@@ -89,16 +82,11 @@ class ExpenseClaimAttachmentPresentation:
|
|||||||
original_filename: str,
|
original_filename: str,
|
||||||
preview_data_url: str,
|
preview_data_url: str,
|
||||||
) -> tuple[Path, str, str] | None:
|
) -> tuple[Path, str, str] | None:
|
||||||
decoded = self.decode_data_url(preview_data_url)
|
return DocumentPreviewAssets.write_data_url_preview(
|
||||||
if decoded is None:
|
preview_dir=attachment_dir,
|
||||||
return None
|
preview_name_stem=f"{Path(original_filename).stem}.preview",
|
||||||
|
preview_data_url=preview_data_url,
|
||||||
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
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build_preview_client_path(claim_id: str, item_id: str) -> str:
|
def build_preview_client_path(claim_id: str, item_id: str) -> str:
|
||||||
|
|||||||
@@ -537,7 +537,7 @@ class OcrService:
|
|||||||
if page_summary:
|
if page_summary:
|
||||||
aggregated.summary_fragments.append(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:
|
if page_text:
|
||||||
aggregated.text_fragments.append(page_text)
|
aggregated.text_fragments.append(page_text)
|
||||||
|
|
||||||
@@ -626,6 +626,22 @@ class OcrService:
|
|||||||
return descriptor.text_layer
|
return descriptor.text_layer
|
||||||
return ""
|
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
|
@staticmethod
|
||||||
def _build_lines(
|
def _build_lines(
|
||||||
items: list[dict],
|
items: list[dict],
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from app.api.deps import CurrentUserContext
|
from app.api.deps import CurrentUserContext
|
||||||
from app.core.config import get_settings
|
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 (
|
from app.schemas.receipt_folder import (
|
||||||
ReceiptFolderDeleteResponse,
|
ReceiptFolderDeleteResponse,
|
||||||
ReceiptFolderDetailRead,
|
ReceiptFolderDetailRead,
|
||||||
@@ -20,11 +20,13 @@ from app.schemas.receipt_folder import (
|
|||||||
ReceiptFolderItemRead,
|
ReceiptFolderItemRead,
|
||||||
ReceiptFolderUpdate,
|
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
|
from app.services.ocr import SUPPORTED_SUFFIXES
|
||||||
|
|
||||||
RECEIPT_DATE_PATTERN = re.compile(
|
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)")
|
RECEIPT_TIME_PATTERN = re.compile(r"(?<!\d)([01]?\d|2[0-3])[::]([0-5]\d)(?!\d)")
|
||||||
TRAIN_INVOICE_DATE_PATTERN = re.compile(
|
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_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_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_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_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:
|
class ReceiptFolderStorageMixin:
|
||||||
@@ -101,18 +105,19 @@ class ReceiptFolderStorageMixin:
|
|||||||
document: Any | None,
|
document: Any | None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
preview_data_url = str(getattr(document, "preview_data_url", "") or "").strip()
|
preview_data_url = str(getattr(document, "preview_data_url", "") or "").strip()
|
||||||
decoded = ExpenseClaimAttachmentPresentation.decode_data_url(preview_data_url)
|
preview_asset = DocumentPreviewAssets.write_data_url_preview(
|
||||||
if decoded is not None:
|
preview_dir=receipt_dir,
|
||||||
preview_media_type, preview_content = decoded
|
preview_name_stem="preview",
|
||||||
suffix = mimetypes.guess_extension(preview_media_type) or ".bin"
|
preview_data_url=preview_data_url,
|
||||||
preview_name = f"preview{suffix}"
|
)
|
||||||
preview_path = receipt_dir / preview_name
|
if preview_asset is not None:
|
||||||
preview_path.write_bytes(preview_content)
|
_, preview_media_type, preview_name = preview_asset
|
||||||
return {
|
return {
|
||||||
"previewable": True,
|
"previewable": True,
|
||||||
"preview_kind": "image",
|
"preview_kind": "image",
|
||||||
"preview_file_name": preview_name,
|
"preview_file_name": preview_name,
|
||||||
"preview_media_type": preview_media_type,
|
"preview_media_type": preview_media_type,
|
||||||
|
"preview_rendered_with": DocumentPreviewAssets.renderer_id_for_source(media_type),
|
||||||
}
|
}
|
||||||
if self._is_previewable(media_type):
|
if self._is_previewable(media_type):
|
||||||
return {
|
return {
|
||||||
@@ -120,14 +125,67 @@ class ReceiptFolderStorageMixin:
|
|||||||
"preview_kind": "image" if media_type.startswith("image/") else "pdf",
|
"preview_kind": "image" if media_type.startswith("image/") else "pdf",
|
||||||
"preview_file_name": source_path.name,
|
"preview_file_name": source_path.name,
|
||||||
"preview_media_type": media_type,
|
"preview_media_type": media_type,
|
||||||
|
"preview_rendered_with": "",
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"previewable": False,
|
"previewable": False,
|
||||||
"preview_kind": "",
|
"preview_kind": "",
|
||||||
"preview_file_name": "",
|
"preview_file_name": "",
|
||||||
"preview_media_type": "",
|
"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
|
@staticmethod
|
||||||
def _is_previewable(media_type: str) -> bool:
|
def _is_previewable(media_type: str) -> bool:
|
||||||
return str(media_type or "").startswith("image/") or str(media_type or "") == "application/pdf"
|
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:
|
def _build_item(self, meta: dict[str, Any]) -> ReceiptFolderItemRead:
|
||||||
receipt_id = str(meta.get("id") or "").strip()
|
receipt_id = str(meta.get("id") or "").strip()
|
||||||
status_value = str(meta.get("status") or "unlinked").strip() or "unlinked"
|
status_value = str(meta.get("status") or "unlinked").strip() or "unlinked"
|
||||||
|
identity = self._resolve_receipt_document_identity(meta)
|
||||||
return ReceiptFolderItemRead(
|
return ReceiptFolderItemRead(
|
||||||
id=receipt_id,
|
id=receipt_id,
|
||||||
file_name=str(meta.get("file_name") or ""),
|
file_name=str(meta.get("file_name") or ""),
|
||||||
@@ -263,10 +322,10 @@ class ReceiptFolderItemMixin:
|
|||||||
size_bytes=int(meta.get("size_bytes") or 0),
|
size_bytes=int(meta.get("size_bytes") or 0),
|
||||||
status=status_value,
|
status=status_value,
|
||||||
status_label="已关联" if status_value == "linked" else "未关联",
|
status_label="已关联" if status_value == "linked" else "未关联",
|
||||||
document_type=str(meta.get("document_type") or "other"),
|
document_type=identity["document_type"],
|
||||||
document_type_label=str(meta.get("document_type_label") or "其他单据"),
|
document_type_label=identity["document_type_label"],
|
||||||
scene_code=str(meta.get("scene_code") or "other"),
|
scene_code=identity["scene_code"],
|
||||||
scene_label=str(meta.get("scene_label") or "其他票据"),
|
scene_label=identity["scene_label"],
|
||||||
summary=str(meta.get("summary") or ""),
|
summary=str(meta.get("summary") or ""),
|
||||||
amount=self._resolve_editable_or_field(meta, "amount", labels=("金额", "价税合计", "票价")),
|
amount=self._resolve_editable_or_field(meta, "amount", labels=("金额", "价税合计", "票价")),
|
||||||
document_date=self._resolve_receipt_document_date(meta),
|
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()],
|
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]:
|
def _resolve_fields(self, meta: dict[str, Any]) -> list[ReceiptFolderFieldRead]:
|
||||||
fields = [
|
fields = [
|
||||||
ReceiptFolderFieldRead(
|
ReceiptFolderFieldRead(
|
||||||
@@ -503,7 +594,15 @@ class ReceiptFolderTrainTicketMixin:
|
|||||||
if str(document_type or "").strip().lower() == "train_ticket":
|
if str(document_type or "").strip().lower() == "train_ticket":
|
||||||
return True
|
return True
|
||||||
compact = "".join([document_type_label, scene_label, text]).replace(" ", "")
|
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
|
@classmethod
|
||||||
def _is_train_ticket_meta(cls, meta: dict[str, Any]) -> bool:
|
def _is_train_ticket_meta(cls, meta: dict[str, Any]) -> bool:
|
||||||
@@ -581,6 +680,7 @@ class ReceiptFolderTrainTicketMixin:
|
|||||||
return raw
|
return raw
|
||||||
normalized = match.group(1).replace("年", "-").replace("月", "-").replace("日", "")
|
normalized = match.group(1).replace("年", "-").replace("月", "-").replace("日", "")
|
||||||
normalized = normalized.replace("/", "-").replace(".", "-")
|
normalized = normalized.replace("/", "-").replace(".", "-")
|
||||||
|
normalized = re.sub(r"\s+", "-", normalized)
|
||||||
parts = [part for part in normalized.split("-") if part]
|
parts = [part for part in normalized.split("-") if part]
|
||||||
if len(parts) != 3:
|
if len(parts) != 3:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
@@ -651,7 +751,28 @@ class ReceiptFolderTrainTicketMixin:
|
|||||||
cleaned = re.sub(r"[^·\u4e00-\u9fa5]", "", str(value or "")).strip()
|
cleaned = re.sub(r"[^·\u4e00-\u9fa5]", "", str(value or "")).strip()
|
||||||
if not 2 <= len(cleaned) <= 8:
|
if not 2 <= len(cleaned) <= 8:
|
||||||
return ""
|
return ""
|
||||||
if any(token in cleaned for token in ("电子", "客票", "铁路", "发票", "税务", "湖北省", "中国铁路", "开票", "日期")):
|
if any(
|
||||||
|
token in cleaned
|
||||||
|
for token in (
|
||||||
|
"电子",
|
||||||
|
"客票",
|
||||||
|
"铁路",
|
||||||
|
"发票",
|
||||||
|
"税务",
|
||||||
|
"湖北省",
|
||||||
|
"中国铁路",
|
||||||
|
"开票",
|
||||||
|
"日期",
|
||||||
|
"车厢",
|
||||||
|
"座位",
|
||||||
|
"票价",
|
||||||
|
"金额",
|
||||||
|
"行程",
|
||||||
|
"出发",
|
||||||
|
"到达",
|
||||||
|
"车次",
|
||||||
|
)
|
||||||
|
):
|
||||||
return ""
|
return ""
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
@@ -660,20 +781,29 @@ class ReceiptFolderTrainTicketMixin:
|
|||||||
labeled = cls._extract_first(TRAIN_ID_PATTERN, text)
|
labeled = cls._extract_first(TRAIN_ID_PATTERN, text)
|
||||||
if labeled:
|
if labeled:
|
||||||
return labeled
|
return labeled
|
||||||
|
fallback = ""
|
||||||
for line in str(text or "").replace("\r", "\n").splitlines():
|
for line in str(text or "").replace("\r", "\n").splitlines():
|
||||||
compact_line = line.replace(" ", "")
|
compact_line = line.replace(" ", "")
|
||||||
if any(token in compact_line for token in ("发票号码", "电子客票号", "客票号", "订单号")):
|
if any(token in compact_line for token in ("发票号码", "电子客票号", "客票号", "订单号")):
|
||||||
continue
|
continue
|
||||||
match = TRAIN_ID_FALLBACK_PATTERN.search(compact_line)
|
match = TRAIN_ID_FALLBACK_PATTERN.search(compact_line)
|
||||||
if match:
|
if not match:
|
||||||
return str(match.group(1) or "").strip()
|
continue
|
||||||
return ""
|
candidate = str(match.group(1) or "").strip()
|
||||||
|
if "*" in candidate:
|
||||||
|
return candidate
|
||||||
|
if not fallback:
|
||||||
|
fallback = candidate
|
||||||
|
return fallback
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_train_carriage_and_seat(text: str) -> tuple[str, str]:
|
def _extract_train_carriage_and_seat(text: str) -> tuple[str, str]:
|
||||||
combined_match = TRAIN_COMBINED_SEAT_PATTERN.search(str(text or ""))
|
combined_match = TRAIN_COMBINED_SEAT_PATTERN.search(str(text or ""))
|
||||||
if combined_match:
|
if combined_match:
|
||||||
return f"{combined_match.group(1)}车", combined_match.group(2)
|
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(" ", "")
|
carriage_no = ReceiptFolderService._extract_first(TRAIN_CARRIAGE_PATTERN, text).replace(" ", "")
|
||||||
seat_no = ReceiptFolderService._extract_first(TRAIN_SEAT_NO_PATTERN, text)
|
seat_no = ReceiptFolderService._extract_first(TRAIN_SEAT_NO_PATTERN, text)
|
||||||
return carriage_no, seat_no
|
return carriage_no, seat_no
|
||||||
@@ -681,6 +811,12 @@ class ReceiptFolderTrainTicketMixin:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_train_fare(text: str) -> str:
|
def _extract_train_fare(text: str) -> str:
|
||||||
match = TRAIN_FARE_PATTERN.search(str(text or ""))
|
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:
|
if not match:
|
||||||
return ""
|
return ""
|
||||||
value = str(match.group(1) or "").replace(",", ".").strip()
|
value = str(match.group(1) or "").replace(",", ".").strip()
|
||||||
@@ -721,13 +857,10 @@ class ReceiptFolderService(ReceiptFolderStorageMixin, ReceiptFolderItemMixin, Re
|
|||||||
)
|
)
|
||||||
if existing_receipt is not None:
|
if existing_receipt is not None:
|
||||||
enriched.append(
|
enriched.append(
|
||||||
document.model_copy(
|
self._enrich_ocr_document_with_receipt(
|
||||||
update={
|
document,
|
||||||
"receipt_id": existing_receipt.id,
|
receipt=existing_receipt,
|
||||||
"receipt_status": existing_receipt.status,
|
current_user=current_user,
|
||||||
"receipt_preview_url": existing_receipt.preview_url,
|
|
||||||
"receipt_source_url": existing_receipt.source_url,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -744,14 +877,11 @@ class ReceiptFolderService(ReceiptFolderStorageMixin, ReceiptFolderItemMixin, Re
|
|||||||
warning = "已上传过同样的单据,请不要重复上传。"
|
warning = "已上传过同样的单据,请不要重复上传。"
|
||||||
existing_warnings = [str(item) for item in list(document.warnings or []) if str(item).strip()]
|
existing_warnings = [str(item) for item in list(document.warnings or []) if str(item).strip()]
|
||||||
enriched.append(
|
enriched.append(
|
||||||
document.model_copy(
|
self._enrich_ocr_document_with_receipt(
|
||||||
update={
|
document,
|
||||||
"receipt_id": duplicate_receipt.id,
|
receipt=duplicate_receipt,
|
||||||
"receipt_status": duplicate_receipt.status,
|
current_user=current_user,
|
||||||
"receipt_preview_url": duplicate_receipt.preview_url,
|
extra_warnings=[*existing_warnings, warning],
|
||||||
"receipt_source_url": duplicate_receipt.source_url,
|
|
||||||
"warnings": list(dict.fromkeys([*existing_warnings, warning])),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -763,16 +893,77 @@ class ReceiptFolderService(ReceiptFolderStorageMixin, ReceiptFolderItemMixin, Re
|
|||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
)
|
)
|
||||||
enriched.append(
|
enriched.append(
|
||||||
document.model_copy(
|
self._enrich_ocr_document_with_receipt(
|
||||||
update={
|
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_id": receipt.id,
|
||||||
"receipt_status": receipt.status,
|
"receipt_status": receipt.status,
|
||||||
"receipt_preview_url": receipt.preview_url,
|
"receipt_preview_url": receipt.preview_url,
|
||||||
"receipt_source_url": receipt.source_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(
|
def save_receipt(
|
||||||
self,
|
self,
|
||||||
@@ -1024,6 +1215,7 @@ class ReceiptFolderService(ReceiptFolderStorageMixin, ReceiptFolderItemMixin, Re
|
|||||||
def resolve_preview(self, receipt_id: str, current_user: CurrentUserContext) -> tuple[Path, str, str]:
|
def resolve_preview(self, receipt_id: str, current_user: CurrentUserContext) -> tuple[Path, str, str]:
|
||||||
meta = self._read_receipt_meta(receipt_id, current_user)
|
meta = self._read_receipt_meta(receipt_id, current_user)
|
||||||
receipt_dir = self._receipt_dir(self._owner_key(current_user), receipt_id)
|
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()
|
preview_name = str(meta.get("preview_file_name") or "").strip()
|
||||||
if preview_name:
|
if preview_name:
|
||||||
preview_path = self._assert_child(receipt_dir / 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):
|
if self._is_previewable(source_media_type):
|
||||||
return source_path, source_media_type, source_name
|
return source_path, source_media_type, source_name
|
||||||
raise FileNotFoundError("Receipt preview not found")
|
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)
|
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:
|
def test_document_intelligence_labels_train_ticket_date_as_train_departure_time() -> None:
|
||||||
insight = build_document_insight(
|
insight = build_document_insight(
|
||||||
filename="铁路电子客票.pdf",
|
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
|
assert deleted_response.status_code == 404
|
||||||
finally:
|
finally:
|
||||||
get_settings.cache_clear()
|
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。"]
|
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(
|
def test_ocr_service_passes_configured_device_to_worker(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
from app.api.deps import CurrentUserContext
|
from app.api.deps import CurrentUserContext
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.schemas.ocr import OcrRecognizeDocumentRead
|
from app.schemas.ocr import OcrRecognizeDocumentRead
|
||||||
|
from app.services.document_preview import DocumentPreviewAssets
|
||||||
from app.services.receipt_folder import ReceiptFolderService
|
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()
|
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:
|
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"))
|
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import json
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from datetime import UTC, date, datetime
|
from datetime import UTC, date, datetime
|
||||||
from decimal import Decimal
|
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.risk_observation import RiskObservation, RiskObservationFeedback
|
||||||
from app.models.role import Role
|
from app.models.role import Role
|
||||||
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
|
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.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||||
from app.services.ocr import OcrService
|
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"]
|
meta_payload = upload_response.json()["attachment"]
|
||||||
assert meta_payload["preview_kind"] == "image"
|
assert meta_payload["preview_kind"] == "image"
|
||||||
assert meta_payload["preview_url"].endswith(f"/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview")
|
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(
|
preview_response = client.get(
|
||||||
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment/preview",
|
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
|
fi
|
||||||
|
|
||||||
SERVER_STARTUP_TIMEOUT="${SERVER_STARTUP_TIMEOUT:-300}"
|
SERVER_STARTUP_TIMEOUT="${SERVER_STARTUP_TIMEOUT:-300}"
|
||||||
|
SERVER_SMOKE_CHECK_ENABLED="${SERVER_SMOKE_CHECK_ENABLED:-false}"
|
||||||
SETUP_COMPLETED="${SETUP_COMPLETED:-false}"
|
SETUP_COMPLETED="${SETUP_COMPLETED:-false}"
|
||||||
APP_DEBUG="${APP_DEBUG:-true}"
|
APP_DEBUG="${APP_DEBUG:-true}"
|
||||||
APP_ENV="${APP_ENV:-local}"
|
APP_ENV="${APP_ENV:-local}"
|
||||||
@@ -220,7 +221,16 @@ probe_server_ready() {
|
|||||||
_health_url="${1:-$(server_probe_url)}"
|
_health_url="${1:-$(server_probe_url)}"
|
||||||
_smoke_url="${2:-$(server_smoke_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() {
|
prepare_web() {
|
||||||
|
|||||||
@@ -542,6 +542,48 @@
|
|||||||
letter-spacing: 0;
|
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 {
|
.workbench-ai-file-card__remove {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
@@ -2035,7 +2077,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.application-preview-input {
|
.application-preview-input {
|
||||||
width: 100%;
|
width: min(100%, 420px);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
@@ -2049,7 +2091,34 @@
|
|||||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.11);
|
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 {
|
.application-preview-select {
|
||||||
|
width: min(100%, 240px);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,17 @@
|
|||||||
<span class="workbench-ai-file-card__body">
|
<span class="workbench-ai-file-card__body">
|
||||||
<strong :title="file.name">{{ file.name }}</strong>
|
<strong :title="file.name">{{ file.name }}</strong>
|
||||||
<small>{{ file.typeLabel }}</small>
|
<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>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -424,9 +435,23 @@
|
|||||||
<span class="application-preview-label" role="cell">{{ row.label }}</span>
|
<span class="application-preview-label" role="cell">{{ row.label }}</span>
|
||||||
<span class="application-preview-value" role="cell">
|
<span class="application-preview-value" role="cell">
|
||||||
<input
|
<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"
|
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"
|
type="text"
|
||||||
autofocus
|
autofocus
|
||||||
:disabled="isApplicationPreviewEstimatePending(message)"
|
:disabled="isApplicationPreviewEstimatePending(message)"
|
||||||
@@ -437,7 +462,7 @@
|
|||||||
<select
|
<select
|
||||||
v-else-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'select'"
|
v-else-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'select'"
|
||||||
v-model="applicationPreviewEditor.draftValue"
|
v-model="applicationPreviewEditor.draftValue"
|
||||||
class="application-preview-input application-preview-select"
|
:class="['application-preview-input', 'application-preview-select', `application-preview-input--${row.key}`]"
|
||||||
autofocus
|
autofocus
|
||||||
:disabled="isApplicationPreviewEstimatePending(message)"
|
:disabled="isApplicationPreviewEstimatePending(message)"
|
||||||
@click.stop
|
@click.stop
|
||||||
@@ -548,6 +573,17 @@
|
|||||||
<span class="workbench-ai-file-card__body">
|
<span class="workbench-ai-file-card__body">
|
||||||
<strong :title="file.name">{{ file.name }}</strong>
|
<strong :title="file.name">{{ file.name }}</strong>
|
||||||
<small>{{ file.typeLabel }}</small>
|
<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>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { usePersonalWorkbenchAiMode } from '../../composables/workbenchAiMode/us
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
sidebarCommand: { type: Object, default: () => ({ seq: 0, type: '', payload: null }) }
|
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 {
|
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)
|
} = usePersonalWorkbenchAiMode(props, emit)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -204,18 +204,22 @@
|
|||||||
<span class="notification-type-icon" :class="item.tone">
|
<span class="notification-type-icon" :class="item.tone">
|
||||||
<i :class="resolveNotificationIcon(item)"></i>
|
<i :class="resolveNotificationIcon(item)"></i>
|
||||||
</span>
|
</span>
|
||||||
<span class="notification-copy">
|
<span class="notification-row-main">
|
||||||
|
<span class="notification-row-head">
|
||||||
<span class="notification-title-line">
|
<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>
|
<b v-if="item.badge">{{ item.badge }}</b>
|
||||||
</span>
|
</span>
|
||||||
<small>{{ item.description }}</small>
|
<span class="notification-row-action" aria-hidden="true">
|
||||||
<span class="notification-meta">
|
<i class="mdi mdi-chevron-right"></i>
|
||||||
<em>{{ item.category || '系统通知' }}</em>
|
</span>
|
||||||
<time>{{ item.time }}</time>
|
</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>
|
||||||
</span>
|
</span>
|
||||||
<i class="mdi mdi-chevron-right notification-row-arrow"></i>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="notification-empty">
|
<div v-else class="notification-empty">
|
||||||
@@ -516,12 +520,28 @@ function normalizeNotificationId(value) {
|
|||||||
return String(value || '').trim()
|
return String(value || '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNotificationTime(value) {
|
function formatNotificationTimeLabel(value) {
|
||||||
const date = new Date(value)
|
const raw = String(value || '').trim()
|
||||||
if (!Number.isFinite(date.getTime())) {
|
if (!raw) {
|
||||||
return '最近更新'
|
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 month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
const hour = String(date.getHours()).padStart(2, '0')
|
const hour = String(date.getHours()).padStart(2, '0')
|
||||||
@@ -563,7 +583,8 @@ const documentNotificationItems = computed(() =>
|
|||||||
kind: 'document',
|
kind: 'document',
|
||||||
title: `${row.documentTypeLabel || '单据'} ${row.documentNo || row.claimId || '待生成'}`,
|
title: `${row.documentTypeLabel || '单据'} ${row.documentNo || row.claimId || '待生成'}`,
|
||||||
description: resolveDocumentNotificationDescription(row),
|
description: resolveDocumentNotificationDescription(row),
|
||||||
time: formatNotificationTime(row.updatedAt || row.createdAt),
|
time: row.updatedAt || row.createdAt,
|
||||||
|
timeLabel: formatNotificationTimeLabel(row.updatedAt || row.createdAt),
|
||||||
category: row.sourceLabel || '单据中心',
|
category: row.sourceLabel || '单据中心',
|
||||||
tone: resolveDocumentNotificationTone({ ...row, isUnread: unread }),
|
tone: resolveDocumentNotificationTone({ ...row, isUnread: unread }),
|
||||||
unread,
|
unread,
|
||||||
@@ -587,12 +608,15 @@ const workbenchNotificationItems = computed(() => (
|
|||||||
if (!id || isNotificationHidden(id)) {
|
if (!id || isNotificationHidden(id)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
const notificationTime = item.time || item.updatedAt || item.due
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
id,
|
id,
|
||||||
kind: 'workbench',
|
kind: 'workbench',
|
||||||
category: item.category || '个人工作台',
|
category: item.category || '个人工作台',
|
||||||
|
time: notificationTime,
|
||||||
|
timeLabel: formatNotificationTimeLabel(item.time || item.updatedAt || item.due),
|
||||||
unread: Boolean(item.unread) && !readNotificationIds.value.has(id),
|
unread: Boolean(item.unread) && !readNotificationIds.value.has(id),
|
||||||
icon: item.icon || resolveNotificationIcon(item)
|
icon: item.icon || resolveNotificationIcon(item)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
const {
|
const {
|
||||||
applicationPreviewEditor,
|
applicationPreviewEditor,
|
||||||
resolveApplicationPreviewEditorControl,
|
resolveApplicationPreviewEditorControl,
|
||||||
|
resolveApplicationPreviewEditorDateMax,
|
||||||
|
resolveApplicationPreviewEditorDateMin,
|
||||||
resolveApplicationPreviewEditorOptions,
|
resolveApplicationPreviewEditorOptions,
|
||||||
refreshApplicationPreviewEstimate,
|
refreshApplicationPreviewEstimate,
|
||||||
isApplicationPreviewEditing,
|
isApplicationPreviewEditing,
|
||||||
@@ -112,7 +114,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
} = useWorkbenchComposerDate({ draft: assistantDraft, focusInput: focusAiModeInput })
|
} = useWorkbenchComposerDate({ draft: assistantDraft, focusInput: focusAiModeInput })
|
||||||
|
|
||||||
const aiModeActionItems = AI_MODE_ACTION_ITEMS
|
const aiModeActionItems = AI_MODE_ACTION_ITEMS
|
||||||
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value))
|
|
||||||
const displayUserName = computed(() => {
|
const displayUserName = computed(() => {
|
||||||
const user = currentUser.value || {}
|
const user = currentUser.value || {}
|
||||||
return String(user.name || user.username || '同事').trim() || '同事'
|
return String(user.name || user.username || '同事').trim() || '同事'
|
||||||
@@ -161,9 +162,19 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
scrollInlineConversationToBottom,
|
scrollInlineConversationToBottom,
|
||||||
sending,
|
sending,
|
||||||
streamOrSetInlineAssistantContent,
|
streamOrSetInlineAssistantContent,
|
||||||
|
notifyRequestUpdated: (payload) => emit('request-updated', payload),
|
||||||
toast
|
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({
|
const applicationFlow = useWorkbenchAiApplicationPreviewFlow({
|
||||||
activateInlineConversation,
|
activateInlineConversation,
|
||||||
applicationPreviewEditor,
|
applicationPreviewEditor,
|
||||||
@@ -189,6 +200,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
refreshApplicationPreviewEstimate,
|
refreshApplicationPreviewEstimate,
|
||||||
removeWorkbenchDateTag,
|
removeWorkbenchDateTag,
|
||||||
replaceInlineMessage,
|
replaceInlineMessage,
|
||||||
|
resolveApplicationPreviewEditorDateMax,
|
||||||
|
resolveApplicationPreviewEditorDateMin,
|
||||||
resolveApplicationPreviewEditorControl,
|
resolveApplicationPreviewEditorControl,
|
||||||
resolveApplicationPreviewEditorOptions,
|
resolveApplicationPreviewEditorOptions,
|
||||||
resolveInlineThinkingEvents,
|
resolveInlineThinkingEvents,
|
||||||
@@ -776,6 +789,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
requestDeleteCurrentConversation: () => sessionCommands.requestDeleteCurrentConversation(deleteDialogOpen),
|
requestDeleteCurrentConversation: () => sessionCommands.requestDeleteCurrentConversation(deleteDialogOpen),
|
||||||
resolveApplicationPreviewEditorOptions: applicationFlow.resolveApplicationPreviewEditorOptions,
|
resolveApplicationPreviewEditorOptions: applicationFlow.resolveApplicationPreviewEditorOptions,
|
||||||
resolveInlineApplicationPreviewEditorControl: applicationFlow.resolveInlineApplicationPreviewEditorControl,
|
resolveInlineApplicationPreviewEditorControl: applicationFlow.resolveInlineApplicationPreviewEditorControl,
|
||||||
|
resolveInlineApplicationPreviewEditorDateMax: applicationFlow.resolveInlineApplicationPreviewEditorDateMax,
|
||||||
|
resolveInlineApplicationPreviewEditorDateMin: applicationFlow.resolveInlineApplicationPreviewEditorDateMin,
|
||||||
resolveInlineApplicationPreviewMissingFields: applicationFlow.resolveInlineApplicationPreviewMissingFields,
|
resolveInlineApplicationPreviewMissingFields: applicationFlow.resolveInlineApplicationPreviewMissingFields,
|
||||||
resolveInlineApplicationPreviewRows: applicationFlow.resolveInlineApplicationPreviewRows,
|
resolveInlineApplicationPreviewRows: applicationFlow.resolveInlineApplicationPreviewRows,
|
||||||
resolveInlineAttachmentOcrDocuments,
|
resolveInlineAttachmentOcrDocuments,
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
refreshApplicationPreviewEstimate,
|
refreshApplicationPreviewEstimate,
|
||||||
removeWorkbenchDateTag,
|
removeWorkbenchDateTag,
|
||||||
replaceInlineMessage,
|
replaceInlineMessage,
|
||||||
|
resolveApplicationPreviewEditorDateMax,
|
||||||
|
resolveApplicationPreviewEditorDateMin,
|
||||||
resolveApplicationPreviewEditorControl,
|
resolveApplicationPreviewEditorControl,
|
||||||
resolveApplicationPreviewEditorOptions,
|
resolveApplicationPreviewEditorOptions,
|
||||||
resolveInlineThinkingEvents,
|
resolveInlineThinkingEvents,
|
||||||
@@ -105,8 +107,15 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveInlineApplicationPreviewEditorControl(fieldKey) {
|
function resolveInlineApplicationPreviewEditorControl(fieldKey) {
|
||||||
const control = resolveApplicationPreviewEditorControl(fieldKey)
|
return resolveApplicationPreviewEditorControl(fieldKey)
|
||||||
return control === 'date' ? 'text' : control
|
}
|
||||||
|
|
||||||
|
function resolveInlineApplicationPreviewEditorDateMin(message, fieldKey) {
|
||||||
|
return resolveApplicationPreviewEditorDateMin?.(message, fieldKey) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInlineApplicationPreviewEditorDateMax(message, fieldKey) {
|
||||||
|
return resolveApplicationPreviewEditorDateMax?.(message, fieldKey) || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInlineApplicationPreviewSuggestedActions(applicationPreview = {}, draftPayload = null) {
|
function buildInlineApplicationPreviewSuggestedActions(applicationPreview = {}, draftPayload = null) {
|
||||||
@@ -180,6 +189,14 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
return '申请核对表已补齐,费用测算已同步。你仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。'
|
return '申请核对表已补齐,费用测算已同步。你仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildInlineApplicationActionFailureText(error, isSubmit) {
|
||||||
|
return [
|
||||||
|
isSubmit ? '### 申请提交失败' : '### 申请草稿保存失败',
|
||||||
|
error?.message || (isSubmit ? '申请提交失败,请稍后重试。' : '申请草稿保存失败,请稍后重试。'),
|
||||||
|
'我已保留当前申请核对表,您可以修改后重试,也可以稍后再次保存。'
|
||||||
|
].join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
function resolveLatestApplicationPreviewMessage() {
|
function resolveLatestApplicationPreviewMessage() {
|
||||||
return [...conversationMessages.value]
|
return [...conversationMessages.value]
|
||||||
.reverse()
|
.reverse()
|
||||||
@@ -385,8 +402,14 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
replaceInlineMessage(
|
replaceInlineMessage(
|
||||||
pendingMessage.id,
|
pendingMessage.id,
|
||||||
createInlineMessage('assistant', error?.message || (isSubmit ? '申请提交失败,请稍后重试。' : '申请草稿保存失败,请稍后重试。'), {
|
createInlineMessage('assistant', buildInlineApplicationActionFailureText(error, isSubmit), {
|
||||||
id: pendingMessage.id,
|
id: pendingMessage.id,
|
||||||
|
applicationPreview: targetMessage.applicationPreview,
|
||||||
|
draftPayload: targetMessage.draftPayload || options.draftPayload || null,
|
||||||
|
suggestedActions: buildInlineApplicationPreviewSuggestedActions(
|
||||||
|
targetMessage.applicationPreview,
|
||||||
|
targetMessage.draftPayload || options.draftPayload || null
|
||||||
|
),
|
||||||
stewardPlan: {
|
stewardPlan: {
|
||||||
streamStatus: 'failed',
|
streamStatus: 'failed',
|
||||||
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
|
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
|
||||||
@@ -504,6 +527,8 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
openApplicationPreviewEditor,
|
openApplicationPreviewEditor,
|
||||||
resolveApplicationPreviewEditorOptions,
|
resolveApplicationPreviewEditorOptions,
|
||||||
resolveInlineApplicationPreviewEditorControl,
|
resolveInlineApplicationPreviewEditorControl,
|
||||||
|
resolveInlineApplicationPreviewEditorDateMax,
|
||||||
|
resolveInlineApplicationPreviewEditorDateMin,
|
||||||
resolveInlineApplicationPreviewMissingFields,
|
resolveInlineApplicationPreviewMissingFields,
|
||||||
resolveInlineApplicationPreviewRows,
|
resolveInlineApplicationPreviewRows,
|
||||||
startAiApplicationPreview
|
startAiApplicationPreview
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
|
import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
|
||||||
import { syncExpenseClaimFilesToDraft } from '../../utils/expenseClaimAttachmentSync.js'
|
import { syncExpenseClaimFilesToDraft } from '../../utils/expenseClaimAttachmentSync.js'
|
||||||
import { collectReceiptFiles } from '../../views/scripts/travelReimbursementAttachmentModel.js'
|
import {
|
||||||
|
buildFileIdentity,
|
||||||
|
collectReceiptFiles
|
||||||
|
} from '../../views/scripts/travelReimbursementAttachmentModel.js'
|
||||||
import {
|
import {
|
||||||
createExpenseClaimItem,
|
createExpenseClaimItem,
|
||||||
extractExpenseClaimItems,
|
extractExpenseClaimItems,
|
||||||
@@ -76,42 +81,256 @@ export function useWorkbenchAiAttachmentAssociationFlow({
|
|||||||
scrollInlineConversationToBottom,
|
scrollInlineConversationToBottom,
|
||||||
sending,
|
sending,
|
||||||
streamOrSetInlineAssistantContent,
|
streamOrSetInlineAssistantContent,
|
||||||
|
notifyRequestUpdated,
|
||||||
toast
|
toast
|
||||||
}) {
|
}) {
|
||||||
async function collectAiModeReceiptContext(files = []) {
|
const aiModeReceiptContextCache = new Map()
|
||||||
const safeFiles = Array.isArray(files) ? files : []
|
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
|
const attachmentNames = safeFiles
|
||||||
.map((file) => String(file?.name || '').trim())
|
.map((file) => String(file?.name || '').trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
|
|
||||||
const ocrSourceFileNames = ocrFiles
|
const ocrSourceFileNames = ocrFiles
|
||||||
.map((file) => String(file?.name || '').trim())
|
.map((file) => String(file?.name || '').trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
return {
|
||||||
const baseContext = {
|
|
||||||
attachmentNames,
|
attachmentNames,
|
||||||
attachmentCount: attachmentNames.length,
|
attachmentCount: attachmentNames.length,
|
||||||
ocrSourceFileNames,
|
ocrSourceFileNames,
|
||||||
ocrSummary: '',
|
ocrSummary: '',
|
||||||
ocrDocuments: []
|
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) {
|
if (!ocrFiles.length) {
|
||||||
return baseContext
|
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 {
|
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({
|
const collected = await collectReceiptFiles({
|
||||||
files: ocrFiles,
|
files: ocrFiles,
|
||||||
recognizeOcrFiles
|
recognizeOcrFiles
|
||||||
})
|
})
|
||||||
return {
|
const context = buildAiModeReceiptContextFromCollected(baseContext, collected)
|
||||||
...baseContext,
|
rememberAiModeReceiptContext(cacheKey, context)
|
||||||
ocrSummary: String(collected.ocrSummary || '').trim(),
|
applyAiModeReceiptRecognitionResult(ocrFiles, context)
|
||||||
ocrDocuments: Array.isArray(collected.ocrDocuments) ? collected.ocrDocuments : []
|
return context
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('AI mode OCR request failed:', error)
|
console.warn('AI mode OCR request failed:', error)
|
||||||
|
setAiModeReceiptRecognitionState(ocrFiles, {
|
||||||
|
status: 'failed',
|
||||||
|
label: '识别失败',
|
||||||
|
title: error?.message || '智能录入 OCR 识别失败'
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
...baseContext,
|
...baseContext,
|
||||||
ocrError: error?.message || 'OCR识别失败,已继续使用附件名称。'
|
ocrError: error?.message || 'OCR识别失败,已继续使用附件名称。'
|
||||||
@@ -220,6 +439,13 @@ export function useWorkbenchAiAttachmentAssociationFlow({
|
|||||||
createExpenseClaimItem,
|
createExpenseClaimItem,
|
||||||
uploadExpenseClaimItemAttachment
|
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({
|
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationResultMessage({
|
||||||
claimNo: runtime.claimNo,
|
claimNo: runtime.claimNo,
|
||||||
fileNames: runtime.fileNames,
|
fileNames: runtime.fileNames,
|
||||||
@@ -281,10 +507,7 @@ export function useWorkbenchAiAttachmentAssociationFlow({
|
|||||||
scrollInlineConversationToBottom()
|
scrollInlineConversationToBottom()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const collected = await collectReceiptFiles({
|
const collected = await collectAiModeReceiptContext(files)
|
||||||
files,
|
|
||||||
recognizeOcrFiles
|
|
||||||
})
|
|
||||||
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(collected, files)
|
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(collected, files)
|
||||||
const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
|
const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
|
||||||
const claims = extractExpenseClaimItems(claimsPayload)
|
const claims = extractExpenseClaimItems(claimsPayload)
|
||||||
@@ -351,7 +574,9 @@ export function useWorkbenchAiAttachmentAssociationFlow({
|
|||||||
return {
|
return {
|
||||||
collectAiModeReceiptContext,
|
collectAiModeReceiptContext,
|
||||||
confirmAiAttachmentAssociation,
|
confirmAiAttachmentAssociation,
|
||||||
|
primeAiModeReceiptContext,
|
||||||
requestAiAttachmentAssociationReply,
|
requestAiAttachmentAssociationReply,
|
||||||
|
resolveAiModeReceiptRecognitionState,
|
||||||
resolveAiAttachmentAssociationClaimNo
|
resolveAiAttachmentAssociationClaimNo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
buildRequiredApplicationSelectionText,
|
buildRequiredApplicationSelectionText,
|
||||||
filterRequiredApplicationCandidates
|
filterRequiredApplicationCandidates
|
||||||
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
|
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
|
||||||
|
import {
|
||||||
|
buildInlineAttachmentOcrDetails
|
||||||
|
} from './workbenchAiMessageModel.js'
|
||||||
|
|
||||||
function shouldCheckAiRequiredApplicationGate(prompt) {
|
function shouldCheckAiRequiredApplicationGate(prompt) {
|
||||||
const compact = String(prompt || '').replace(/\s+/g, '')
|
const compact = String(prompt || '').replace(/\s+/g, '')
|
||||||
@@ -269,6 +272,7 @@ export function useWorkbenchAiStewardFlow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const receiptContext = await collectAiModeReceiptContext(files)
|
const receiptContext = await collectAiModeReceiptContext(files)
|
||||||
|
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(receiptContext, files)
|
||||||
const planRequest = buildStewardPlanRequest({
|
const planRequest = buildStewardPlanRequest({
|
||||||
rawText: prompt,
|
rawText: prompt,
|
||||||
files,
|
files,
|
||||||
@@ -330,7 +334,8 @@ export function useWorkbenchAiStewardFlow({
|
|||||||
},
|
},
|
||||||
suggestedActions: requiredApplicationContinuationFlow
|
suggestedActions: requiredApplicationContinuationFlow
|
||||||
? buildAiRequiredApplicationGateSuggestedActions(requiredApplicationContinuationFlow, prompt)
|
? buildAiRequiredApplicationGateSuggestedActions(requiredApplicationContinuationFlow, prompt)
|
||||||
: buildStewardSuggestedActions(plan)
|
: buildStewardSuggestedActions(plan),
|
||||||
|
attachmentOcrDetails
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
persistCurrentConversation()
|
persistCurrentConversation()
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export function fetchReceiptFolderAsset(pathOrUrl) {
|
|||||||
throw new Error('票据文件地址为空。')
|
throw new Error('票据文件地址为空。')
|
||||||
}
|
}
|
||||||
return apiRequest(target, {
|
return apiRequest(target, {
|
||||||
|
cache: 'no-store',
|
||||||
responseType: 'blob'
|
responseType: 'blob'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,7 @@
|
|||||||
@ai-conversation-history-change="handleAiConversationHistoryChange"
|
@ai-conversation-history-change="handleAiConversationHistoryChange"
|
||||||
@open-assistant="openSmartEntry"
|
@open-assistant="openSmartEntry"
|
||||||
@open-document="openWorkbenchDocument"
|
@open-document="openWorkbenchDocument"
|
||||||
|
@request-updated="handleRequestUpdated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TravelRequestDetailView
|
<TravelRequestDetailView
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
@conversation-change="emit('ai-conversation-change', $event)"
|
@conversation-change="emit('ai-conversation-change', $event)"
|
||||||
@conversation-history-change="emit('ai-conversation-history-change', $event)"
|
@conversation-history-change="emit('ai-conversation-history-change', $event)"
|
||||||
@open-document="emit('open-document', $event)"
|
@open-document="emit('open-document', $event)"
|
||||||
|
@request-updated="emit('request-updated', $event)"
|
||||||
/>
|
/>
|
||||||
<PersonalWorkbench
|
<PersonalWorkbench
|
||||||
v-else
|
v-else
|
||||||
@@ -31,7 +32,7 @@ defineProps({
|
|||||||
aiSidebarCommand: { type: Object, default: () => ({ seq: 0, type: '', payload: null }) }
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped src="../assets/styles/views/personal-workbench-view.css"></style>
|
<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 EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
|
||||||
import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
||||||
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
||||||
|
import { useToast } from '../composables/useToast.js'
|
||||||
import { fetchExpenseClaims } from '../services/reimbursements.js'
|
import { fetchExpenseClaims } from '../services/reimbursements.js'
|
||||||
import {
|
import {
|
||||||
buildReceiptFile,
|
buildReceiptFile,
|
||||||
@@ -383,6 +384,7 @@ import { createReceiptFolderListFilterModel } from './scripts/receiptFolderListF
|
|||||||
const NEW_CLAIM_VALUE = '__new_claim__'
|
const NEW_CLAIM_VALUE = '__new_claim__'
|
||||||
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
|
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
|
||||||
const emit = defineEmits(['open-assistant', 'detail-open-change', 'detail-topbar-change'])
|
const emit = defineEmits(['open-assistant', 'detail-open-change', 'detail-topbar-change'])
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
const activeStatus = ref('all')
|
const activeStatus = ref('all')
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
@@ -687,6 +689,7 @@ async function deleteCurrentReceipt() {
|
|||||||
await deleteReceiptFolderItem(selectedReceipt.value.id)
|
await deleteReceiptFolderItem(selectedReceipt.value.id)
|
||||||
backToList()
|
backToList()
|
||||||
await reloadReceipts()
|
await reloadReceipts()
|
||||||
|
toast('已从票据夹删除;已关联到报销单的附件副本会保留。')
|
||||||
} finally {
|
} finally {
|
||||||
deleting.value = false
|
deleting.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,24 @@ export function buildUnsavedDraftAttachmentConfirmationMessage({ fileNames = []
|
|||||||
].join('\n').trim()
|
].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) {
|
export function normalizeOcrDocuments(payload) {
|
||||||
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
||||||
return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => ({
|
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_status: String(item.receipt_status || item.receiptStatus || '').trim(),
|
||||||
receipt_preview_url: String(item.receipt_preview_url || item.receiptPreviewUrl || '').trim(),
|
receipt_preview_url: String(item.receipt_preview_url || item.receiptPreviewUrl || '').trim(),
|
||||||
receipt_source_url: String(item.receipt_source_url || item.receiptSourceUrl || '').trim(),
|
receipt_source_url: String(item.receipt_source_url || item.receiptSourceUrl || '').trim(),
|
||||||
document_fields: Array.isArray(item.document_fields)
|
document_fields: normalizeOcrDocumentFields(item),
|
||||||
? 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)
|
|
||||||
: [],
|
|
||||||
warnings: Array.isArray(item.warnings) ? item.warnings : []
|
warnings: Array.isArray(item.warnings) ? item.warnings : []
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
} from '../../services/reimbursements.js'
|
} from '../../services/reimbursements.js'
|
||||||
import {
|
import {
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
|
isSystemGeneratedExpenseItemSource,
|
||||||
normalizeIsoDateValue
|
normalizeIsoDateValue
|
||||||
} from './travelRequestDetailExpenseModel.js'
|
} from './travelRequestDetailExpenseModel.js'
|
||||||
|
|
||||||
@@ -109,11 +110,24 @@ export function subscribeSmartEntryRecognitionTask(claimId, listener) {
|
|||||||
|
|
||||||
function resolveSmartEntryTaskAvailableItems(itemSnapshots) {
|
function resolveSmartEntryTaskAvailableItems(itemSnapshots) {
|
||||||
return (Array.isArray(itemSnapshots) ? 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() }))
|
.map((item) => ({ id: String(item.id || '').trim() }))
|
||||||
.filter((item) => item.id)
|
.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) {
|
async function resolveSmartEntryRecognitionTaskItem(task) {
|
||||||
const availableItem = task.availableItems.shift()
|
const availableItem = task.availableItems.shift()
|
||||||
if (availableItem?.id) {
|
if (availableItem?.id) {
|
||||||
@@ -122,10 +136,7 @@ async function resolveSmartEntryRecognitionTaskItem(task) {
|
|||||||
|
|
||||||
const claim = await createExpenseClaimItem(task.claimId, {})
|
const claim = await createExpenseClaimItem(task.claimId, {})
|
||||||
const items = Array.isArray(claim?.items) ? claim.items : []
|
const items = Array.isArray(claim?.items) ? claim.items : []
|
||||||
const createdItem = items.find((entry) => {
|
const createdItem = resolveCreatedSmartEntryRecognitionItem(items, task.knownItemIds)
|
||||||
const itemId = String(entry?.id || '').trim()
|
|
||||||
return itemId && !task.knownItemIds.has(itemId)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!createdItem) {
|
if (!createdItem) {
|
||||||
throw new Error('新增费用明细失败,请稍后重试。')
|
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) {
|
function shouldRefreshTransportEstimate(fieldKey) {
|
||||||
return ['transportMode', 'time', 'time_return', 'location', 'days'].includes(fieldKey)
|
return ['transportMode', 'time', 'time_return', 'location', 'days'].includes(fieldKey)
|
||||||
}
|
}
|
||||||
@@ -271,6 +300,18 @@ export function useApplicationPreviewEditor({
|
|||||||
return fieldKey === 'transportMode' ? APPLICATION_TRANSPORT_MODE_OPTIONS : []
|
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) {
|
function isApplicationPreviewEditing(message, fieldKey) {
|
||||||
return (
|
return (
|
||||||
String(applicationPreviewEditor.value.messageId || '') === String(message?.id || '') &&
|
String(applicationPreviewEditor.value.messageId || '') === String(message?.id || '') &&
|
||||||
@@ -288,12 +329,15 @@ export function useApplicationPreviewEditor({
|
|||||||
const dateState = isApplicationPreviewDateField(fieldKey)
|
const dateState = isApplicationPreviewDateField(fieldKey)
|
||||||
? parseEditorDateValue(fields.time || normalizedValue)
|
? parseEditorDateValue(fields.time || normalizedValue)
|
||||||
: {}
|
: {}
|
||||||
|
const draftValue = isApplicationPreviewDateField(fieldKey)
|
||||||
|
? resolveApplicationPreviewDateDraftValue(fieldKey, dateState)
|
||||||
|
: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
|
||||||
|
? ''
|
||||||
|
: normalizedValue
|
||||||
applicationPreviewEditor.value = {
|
applicationPreviewEditor.value = {
|
||||||
messageId: String(message.id || ''),
|
messageId: String(message.id || ''),
|
||||||
fieldKey,
|
fieldKey,
|
||||||
draftValue: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
|
draftValue,
|
||||||
? ''
|
|
||||||
: normalizedValue,
|
|
||||||
committing: false,
|
committing: false,
|
||||||
...dateState
|
...dateState
|
||||||
}
|
}
|
||||||
@@ -351,6 +395,17 @@ export function useApplicationPreviewEditor({
|
|||||||
}
|
}
|
||||||
return false
|
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({
|
const nextPreview = normalizeApplicationPreview({
|
||||||
...message.applicationPreview,
|
...message.applicationPreview,
|
||||||
fields: buildEditedApplicationPreviewFields(
|
fields: buildEditedApplicationPreviewFields(
|
||||||
@@ -403,6 +458,8 @@ export function useApplicationPreviewEditor({
|
|||||||
resolveApplicationPreviewRows,
|
resolveApplicationPreviewRows,
|
||||||
resolveApplicationPreviewEditorControl,
|
resolveApplicationPreviewEditorControl,
|
||||||
resolveApplicationPreviewEditorOptions,
|
resolveApplicationPreviewEditorOptions,
|
||||||
|
resolveApplicationPreviewEditorDateMin,
|
||||||
|
resolveApplicationPreviewEditorDateMax,
|
||||||
refreshApplicationPreviewEstimate,
|
refreshApplicationPreviewEstimate,
|
||||||
isApplicationPreviewEditing,
|
isApplicationPreviewEditing,
|
||||||
isApplicationPreviewDateEditorOpen,
|
isApplicationPreviewDateEditorOpen,
|
||||||
|
|||||||
@@ -80,10 +80,14 @@ export function useTravelReimbursementCreateViewControls({
|
|||||||
pendingText: `已选择草稿 ${record.claimNo},正在识别并归集附件...`,
|
pendingText: `已选择草稿 ${record.claimNo},正在识别并归集附件...`,
|
||||||
files,
|
files,
|
||||||
uploadDisposition: 'continue_existing',
|
uploadDisposition: 'continue_existing',
|
||||||
|
skipDraftAssociationPrompt: true,
|
||||||
|
associationConfirmed: true,
|
||||||
extraContext: {
|
extraContext: {
|
||||||
|
review_action: 'link_to_existing_draft',
|
||||||
draft_claim_id: claimId,
|
draft_claim_id: claimId,
|
||||||
selected_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'
|
const fallbackAnswer = reviewActionResult === 'link_to_existing_draft'
|
||||||
? (resultClaimNo ? `已将本次上传的票据关联到草稿 ${resultClaimNo}。` : '已将本次上传的票据关联到现有草稿。')
|
? (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'
|
const operationFeedbackContext = String(payload?.status || '').trim() === 'succeeded'
|
||||||
? emitOperationCompleted?.(payload, {
|
? emitOperationCompleted?.(payload, {
|
||||||
operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round')
|
operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round')
|
||||||
@@ -702,25 +732,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
}
|
}
|
||||||
persistSessionState()
|
persistSessionState()
|
||||||
nextTick(scrollToBottom)
|
nextTick(scrollToBottom)
|
||||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
|
||||||
if (!effectiveIsKnowledgeSession && resolvedDraftClaimId && files.length) {
|
if (!effectiveIsKnowledgeSession && resolvedDraftClaimId && files.length) {
|
||||||
const persistComposerFilesToDraft = async () => {
|
if (!attachmentSyncCompleted) {
|
||||||
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 || '票据已归集到草稿,但附件原件保存失败,请在单据详情中重新上传。')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const persistTask = persistComposerFilesToDraft()
|
const persistTask = persistComposerFilesToDraft()
|
||||||
if (detailScopedUpload) {
|
if (detailScopedUpload) {
|
||||||
await persistTask
|
await persistTask
|
||||||
@@ -728,6 +741,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
void persistTask
|
void persistTask
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearFlowSimulationTimers()
|
clearFlowSimulationTimers()
|
||||||
if (!stewardDelegated) {
|
if (!stewardDelegated) {
|
||||||
|
|||||||
@@ -263,6 +263,36 @@ test('OCR documents keep full recognized text for backend context', () => {
|
|||||||
assert.match(documents[0].text, /电子客票号:E1234567890/)
|
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 () => {
|
test('receipt files are collected through a single OCR persistence entry before draft association', async () => {
|
||||||
const files = [
|
const files = [
|
||||||
{ name: 'invoice.png' }
|
{ 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')
|
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 () => {
|
test('application preview editor estimates after shorthand return date input', async () => {
|
||||||
const preview = normalizeApplicationPreview({
|
const preview = normalizeApplicationPreview({
|
||||||
fields: {
|
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/)
|
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', () => {
|
test('topbar notification state is persisted through backend API with local fallback', () => {
|
||||||
assert.match(notificationStatesService, /apiRequest\('\/notification-states'\)/)
|
assert.match(notificationStatesService, /apiRequest\('\/notification-states'\)/)
|
||||||
assert.match(notificationStatesService, /apiRequest\('\/notification-states',\s*\{[\s\S]*method:\s*'POST'/)
|
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, /rows="3"/)
|
||||||
assert.match(aiModeSurface, /workbench-ai-composer-toolbar/)
|
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, /<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, /:aria-label="`移除附件 \$\{file\.name\}`"/)
|
||||||
assert.match(aiModeSurface, /function removeAiModeFile\(fileKey\)/)
|
assert.match(aiModeSurface, /function removeAiModeFile\(fileKey\)/)
|
||||||
assert.match(aiModeSurface, /const selectedFileCards = computed/)
|
assert.match(aiModeSurface, /const selectedFileCards = computed/)
|
||||||
assert.match(aiModeSurface, /resolveAiComposerFileType\(file\)/)
|
assert.match(aiModeSurface, /resolveAiComposerFileType\(file\)/)
|
||||||
assert.match(aiModeSurface, /AI_COMPOSER_FILE_TYPE_META = \{[\s\S]*pdf:\s*\{ label:\s*'PDF'/)
|
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, /MAX_ATTACHMENTS,[\s\S]*mergeFilesWithLimit[\s\S]*travelReimbursementAttachmentModel\.js/)
|
||||||
assert.match(aiModeSurface, /import \* as aiAttachmentAssociationModel from '\.\.\/\.\.\/utils\/aiAttachmentAssociationModel\.js'/)
|
assert.match(aiModeSurface, /import \* as aiAttachmentAssociationModel from '\.\.\/\.\.\/utils\/aiAttachmentAssociationModel\.js'/)
|
||||||
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch/)
|
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, /function findAiAttachmentAssociationRuntime\(options = \{\}\)/)
|
||||||
assert.match(aiModeSurface, /resolveAiAttachmentAssociationClaimNo\(actionPayload\)/)
|
assert.match(aiModeSurface, /resolveAiAttachmentAssociationClaimNo\(actionPayload\)/)
|
||||||
assert.match(aiModeSurface, /if \(actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION\)/)
|
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, /const claims = extractExpenseClaimItems\(claimsPayload\)/)
|
||||||
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch\(claims, collected\.ocrDocuments\)/)
|
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch\(claims, collected\.ocrDocuments\)/)
|
||||||
assert.match(aiModeSurface, /aiAttachmentAssociationRuntime\.set\(associationId/)
|
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, /mdi mdi-calendar-range/)
|
||||||
assert.match(aiModeSurface, /workbench-ai-date-popover/)
|
assert.match(aiModeSurface, /workbench-ai-date-popover/)
|
||||||
assert.match(aiModeSurface, /type="date"/)
|
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.doesNotMatch(aiModeSurface, /mdi mdi-web/)
|
||||||
assert.match(aiModeSurface, /mdi mdi-microphone-outline/)
|
assert.match(aiModeSurface, /mdi mdi-microphone-outline/)
|
||||||
assert.match(aiModeSurface, /mdi mdi-arrow-up/)
|
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-action-link\)/)
|
||||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-table-wrap\)/)
|
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, /\.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, /import \{ fetchSettings \} from '\.\.\/\.\.\/services\/settings\.js'/)
|
||||||
assert.match(aiModeSurface, /fetchStewardPlan,[\s\S]*fetchStewardPlanStream[\s\S]*services\/steward\.js'/)
|
assert.match(aiModeSurface, /fetchStewardPlan,[\s\S]*fetchStewardPlanStream[\s\S]*services\/steward\.js'/)
|
||||||
assert.match(aiModeSurface, /import \{ useWorkbenchComposerDate \} from '\.\.\/useWorkbenchComposerDate\.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, /buildStewardPlanRequest/)
|
||||||
assert.match(aiModeSurface, /buildStewardPlanMessageText/)
|
assert.match(aiModeSurface, /buildStewardPlanMessageText/)
|
||||||
assert.match(aiModeSurface, /buildStewardSuggestedActions/)
|
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, /function startInlineConversation\(prompt, entry = \{\}, files = \[\]\)/)
|
||||||
assert.match(aiModeSurface, /activateInlineConversation\(\{[\s\S]*title:[\s\S]*\}\)[\s\S]*conversationMessages\.value\.push\(createInlineMessage\('user'/)
|
assert.match(aiModeSurface, /activateInlineConversation\(\{[\s\S]*title:[\s\S]*\}\)[\s\S]*conversationMessages\.value\.push\(createInlineMessage\('user'/)
|
||||||
assert.match(aiModeSurface, /persistCurrentConversation\(\)/)
|
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 \(!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, /if \(actionType === AI_APPLICATION_ACTION_SUBMIT\) \{[\s\S]*buildInlineApplicationResultTable\(draftPayload/)
|
||||||
assert.match(aiModeSurface, /需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。/)
|
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, /\*\*申请单号:\*\*/)
|
||||||
assert.doesNotMatch(aiModeSurface, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
|
assert.doesNotMatch(aiModeSurface, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
|
||||||
assert.doesNotMatch(aiModeSurface, /runOrchestrator\(/)
|
assert.doesNotMatch(aiModeSurface, /runOrchestrator\(/)
|
||||||
@@ -489,14 +512,51 @@ test('AI mode screen follows the approved reference structure', () => {
|
|||||||
assert.ok(pngPresentation.minimumForegroundHeightRatio > 0.9)
|
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', () => {
|
test('AI mode normal assistant requests include OCR context for uploaded receipts', () => {
|
||||||
assert.match(aiModeSurface, /function isLikelyAiModeOcrFile\(file = \{\}\)/)
|
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, /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, /collectReceiptFiles\(\{[\s\S]*files:\s*ocrFiles,[\s\S]*recognizeOcrFiles[\s\S]*\}\)/)
|
||||||
assert.match(aiModeSurface, /const receiptContext = await collectAiModeReceiptContext\(files\)/)
|
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_summary:\s*receiptContext\.ocrSummary/)
|
||||||
assert.match(aiModeSurface, /ocr_documents:\s*receiptContext\.ocrDocuments/)
|
assert.match(aiModeSurface, /ocr_documents:\s*receiptContext\.ocrDocuments/)
|
||||||
assert.match(aiModeSurface, /attachment_names:\s*receiptContext\.attachmentNames/)
|
assert.match(aiModeSurface, /attachment_names:\s*receiptContext\.attachmentNames/)
|
||||||
assert.match(aiModeSurface, /attachment_count:\s*receiptContext\.attachmentCount/)
|
assert.match(aiModeSurface, /attachment_count:\s*receiptContext\.attachmentCount/)
|
||||||
assert.match(aiModeSurface, /ocr_source_file_names:\s*receiptContext\.ocrSourceFileNames/)
|
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
|
fi
|
||||||
|
|
||||||
WEB_HOST="${WEB_HOST:-0.0.0.0}"
|
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_SETUP_COMPLETED="${SETUP_COMPLETED:-false}"
|
||||||
export VITE_COMPANY_NAME="${COMPANY_NAME:-}"
|
export VITE_COMPANY_NAME="${COMPANY_NAME:-}"
|
||||||
|
|||||||