refactor(server): user_agent/steward/ocr 等服务重构并适配关联任务
- user_agent 拆分 application/locations/knowledge/response/review 四个子模块,接入申请位置语义与关联草稿分支 - steward planner/runtime/slot/plan_builder 决策链路重构,travel_reimbursement_calculator/orchestrator_expense_query 适配 - ocr/document_preview/document_intelligence/receipt_folder 复用预览与资产缓存,expense_claim_draft_flow/application_handoff 适配 - pyproject.toml 新增依赖,paddleocr bootstrap 脚本与 server_start.sh 调整 - 更新差旅/交通/通信等财务规则表,同步 document_intelligence/ocr/receipt_folder/user_agent 等测试
This commit is contained in:
@@ -888,6 +888,34 @@ def test_travel_reimbursement_calculator_rejects_unrecognized_location() -> None
|
||||
)
|
||||
|
||||
|
||||
def test_travel_reimbursement_calculator_normalizes_location_mixed_with_business_content() -> None:
|
||||
with build_session() as db:
|
||||
db.add(
|
||||
Employee(
|
||||
employee_no="E9004",
|
||||
name="混合地点员工",
|
||||
email="mixed-location@example.com",
|
||||
position="产品经理",
|
||||
grade="P4",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
result = TravelReimbursementCalculatorService(db).calculate(
|
||||
TravelReimbursementCalculatorRequest(days=4, location="上海辅助国网仿生产服务器"),
|
||||
CurrentUserContext(
|
||||
username="mixed-location@example.com",
|
||||
name="混合地点员工",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
),
|
||||
)
|
||||
|
||||
assert result.location == "上海市"
|
||||
assert result.matched_city == "上海"
|
||||
assert result.hotel_amount > 0
|
||||
|
||||
|
||||
def test_agent_run_service_lists_seeded_trace_data() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentRunService(db)
|
||||
|
||||
@@ -84,6 +84,33 @@ def test_document_intelligence_prefers_train_ticket_for_railway_e_ticket_invoice
|
||||
assert any(field.label == "金额" and field.value == "354元" for field in insight.fields)
|
||||
|
||||
|
||||
def test_document_intelligence_train_ticket_uses_railway_merchant_not_invoice_title() -> None:
|
||||
insight = build_document_insight(
|
||||
filename="2月20_武汉-上海.pdf",
|
||||
summary="电子发票(铁路电子客票);发票监;统一 制",
|
||||
text=(
|
||||
"电子发票(铁路电子客票)\n"
|
||||
"发票号码:26429165800002785705 湖北\n"
|
||||
"开票日期: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"
|
||||
"购买方名称:曹笑竹 统一社会信用代码:\n"
|
||||
"买票请到12306 发货请到95306\n"
|
||||
"中国铁路祝您旅途愉快"
|
||||
),
|
||||
)
|
||||
|
||||
assert insight.document_type == "train_ticket"
|
||||
fields = {field.label: field.value for field in insight.fields}
|
||||
assert fields["商户"] == "中国铁路"
|
||||
assert fields["金额"] == "354元"
|
||||
assert fields["列车出发时间"] == "2026-02-20 07:55"
|
||||
|
||||
|
||||
def test_document_intelligence_recovers_train_ticket_from_english_station_ocr_text() -> None:
|
||||
insight = build_document_insight(
|
||||
filename="2月20_武汉-上海.pdf",
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.schemas.reimbursement import (
|
||||
)
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.budget import BudgetService
|
||||
from app.services.document_preview import DocumentPreviewAssets
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
@@ -3314,6 +3315,68 @@ def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(mon
|
||||
assert filename == "legacy-ticket.pdf"
|
||||
|
||||
|
||||
def test_attachment_pdf_preview_falls_back_to_source_when_render_fonts_missing(monkeypatch, tmp_path) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
name="张三",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
||||
|
||||
with build_session() as db:
|
||||
claim = build_claim(expense_type="train", location="上海")
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
attachment_dir = tmp_path / claim.id / claim.items[0].id
|
||||
attachment_dir.mkdir(parents=True)
|
||||
file_path = attachment_dir / "2月20_武汉-上海.pdf"
|
||||
preview_path = attachment_dir / "2月20_武汉-上海.preview.png"
|
||||
file_path.write_bytes(b"%PDF-1.7 fake")
|
||||
preview_path.write_bytes(b"broken-preview")
|
||||
claim.items[0].invoice_id = f"{claim.id}/{claim.items[0].id}/{file_path.name}"
|
||||
db.commit()
|
||||
|
||||
storage = ExpenseClaimAttachmentStorage()
|
||||
storage.write_meta(
|
||||
file_path,
|
||||
{
|
||||
"file_name": file_path.name,
|
||||
"storage_key": storage.to_storage_key(file_path),
|
||||
"media_type": "application/pdf",
|
||||
"previewable": True,
|
||||
"preview_kind": "image",
|
||||
"preview_storage_key": storage.to_storage_key(preview_path),
|
||||
"preview_media_type": "image/png",
|
||||
"preview_file_name": preview_path.name,
|
||||
"preview_rendered_with": "pdftoppm-png-r160-poppler-data",
|
||||
},
|
||||
)
|
||||
|
||||
def fake_render_pdf_first_page(*, pdf_path, preview_path, timeout_seconds):
|
||||
raise RuntimeError("Missing language pack for 'Adobe-GB1' mapping")
|
||||
|
||||
monkeypatch.setattr(DocumentPreviewAssets, "render_pdf_first_page", fake_render_pdf_first_page)
|
||||
|
||||
resolved_path, media_type, filename = ExpenseClaimService(db).get_claim_item_attachment_preview_content(
|
||||
claim_id=claim.id,
|
||||
item_id=claim.items[0].id,
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
assert resolved_path == file_path
|
||||
assert media_type == "application/pdf"
|
||||
assert filename == file_path.name
|
||||
refreshed_meta = storage.read_meta(file_path)
|
||||
assert refreshed_meta["preview_kind"] == "pdf"
|
||||
assert refreshed_meta["preview_storage_key"] == storage.to_storage_key(file_path)
|
||||
assert refreshed_meta["preview_media_type"] == "application/pdf"
|
||||
assert refreshed_meta["preview_file_name"] == file_path.name
|
||||
assert refreshed_meta["preview_rendered_with"] == ""
|
||||
|
||||
|
||||
def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-submit@example.com",
|
||||
@@ -5199,6 +5262,103 @@ def test_admin_delete_claim_unlinks_receipt_folder_items(monkeypatch, tmp_path)
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_admin_delete_linked_reimbursement_resets_application_link_status() -> None:
|
||||
admin_user = CurrentUserContext(
|
||||
username="superadmin",
|
||||
name="系统管理员",
|
||||
role_codes=["admin"],
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
application_claim = ExpenseClaim(
|
||||
id="application-delete-linked-reimbursement",
|
||||
claim_no="APP-DEL-LINKED-APPLICATION",
|
||||
employee_name="张三",
|
||||
department_name="交付部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel_application",
|
||||
reason="支撑国网仿生产环境部署",
|
||||
location="上海",
|
||||
amount=Decimal("3000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 6, 21, 22, 30, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 6, 21, 22, 35, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage=APPLICATION_LINK_STATUS_STAGE,
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "manual_approval",
|
||||
"event_type": "expense_application_approval",
|
||||
"operator": "向万红",
|
||||
"previous_approval_stage": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
"next_status": "approved",
|
||||
"next_approval_stage": APPLICATION_LINK_STATUS_STAGE,
|
||||
"generated_draft_claim_id": "reimbursement-delete-linked-application",
|
||||
"generated_draft_claim_no": "RDELETE01",
|
||||
"created_at": "2026-06-21T22:45:00+00:00",
|
||||
}
|
||||
],
|
||||
)
|
||||
reimbursement_claim = ExpenseClaim(
|
||||
id="reimbursement-delete-linked-application",
|
||||
claim_no="RDELETE01",
|
||||
employee_name="张三",
|
||||
department_name="交付部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel",
|
||||
reason="支撑国网仿生产环境部署报销",
|
||||
location="上海",
|
||||
amount=Decimal("3000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 6, 21, 22, 30, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "application_handoff",
|
||||
"event_type": "expense_application_to_reimbursement_draft",
|
||||
"application_claim_id": application_claim.id,
|
||||
"application_claim_no": application_claim.claim_no,
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add_all([application_claim, reimbursement_claim])
|
||||
db.commit()
|
||||
|
||||
deleted = ExpenseClaimService(db).delete_claim(reimbursement_claim.id, admin_user)
|
||||
|
||||
assert deleted is not None
|
||||
assert deleted.claim_no == "RDELETE01"
|
||||
assert db.get(ExpenseClaim, reimbursement_claim.id) is None
|
||||
db.refresh(application_claim)
|
||||
assert application_claim.status == "approved"
|
||||
assert application_claim.approval_stage == APPLICATION_LINK_STATUS_STAGE
|
||||
|
||||
approval_flag = next(
|
||||
flag
|
||||
for flag in application_claim.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("event_type") == "expense_application_approval"
|
||||
)
|
||||
assert "generated_draft_claim_id" not in approval_flag
|
||||
assert "generated_draft_claim_no" not in approval_flag
|
||||
|
||||
sync_flag = next(
|
||||
flag
|
||||
for flag in application_claim.risk_flags_json
|
||||
if isinstance(flag, dict) and flag.get("event_type") == "expense_application_reimbursement_deleted"
|
||||
)
|
||||
assert sync_flag["source"] == "application_link_sync"
|
||||
assert sync_flag["severity"] == "info"
|
||||
assert sync_flag["actionability"] == "system_trace"
|
||||
assert sync_flag["deleted_reimbursement_claim_id"] == "reimbursement-delete-linked-application"
|
||||
assert sync_flag["deleted_reimbursement_claim_no"] == "RDELETE01"
|
||||
assert sync_flag["next_approval_stage"] == APPLICATION_LINK_STATUS_STAGE
|
||||
|
||||
|
||||
def test_direct_manager_can_return_subordinate_claim_to_pending_submission() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager-return@example.com",
|
||||
|
||||
@@ -85,6 +85,31 @@ def test_notification_state_service_persists_user_scoped_read_and_hidden_state()
|
||||
assert other_saved.states[0].hidden_at is None
|
||||
|
||||
|
||||
def test_notification_state_storage_ready_runs_once_per_database_bind(monkeypatch) -> None:
|
||||
with build_session() as db:
|
||||
service = NotificationStateService(db)
|
||||
user = CurrentUserContext(username="alice", name="Alice", role_codes=[], is_admin=False)
|
||||
calls: list[object] = []
|
||||
original_create_all = Base.metadata.create_all
|
||||
|
||||
def track_create_all(*args, **kwargs):
|
||||
calls.append(kwargs.get("bind"))
|
||||
return original_create_all(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Base.metadata, "create_all", track_create_all)
|
||||
|
||||
service.list_states(user)
|
||||
service.list_states(user)
|
||||
service.patch_states(
|
||||
NotificationStateBatchPatch(
|
||||
states=[NotificationStatePatch(notification_id="workbench:todo:EXP-002", read=True)]
|
||||
),
|
||||
user,
|
||||
)
|
||||
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_notification_state_endpoint_reads_and_updates_current_user_state() -> None:
|
||||
client = build_client()
|
||||
headers = {"x-auth-username": "alice", "x-auth-name": "Alice"}
|
||||
|
||||
@@ -5,19 +5,23 @@ import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.services import document_preview
|
||||
from app.services.ocr import OcrService
|
||||
|
||||
|
||||
def test_ocr_runtime_installers_include_poppler_cjk_data() -> None:
|
||||
def test_ocr_runtime_installers_include_cjk_safe_pdf_rendering_tools() -> None:
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
dependency_sources = [
|
||||
repo_root / "docker-compose.yml",
|
||||
repo_root / "docker-compose.full.yml",
|
||||
repo_root / "server" / "scripts" / "bootstrap_paddleocr_mobile.sh",
|
||||
repo_root / "server" / "scripts" / "bootstrap_paddleocr_gpu.sh",
|
||||
]
|
||||
|
||||
for path in dependency_sources:
|
||||
assert "poppler-data" in path.read_text(encoding="utf-8")
|
||||
content = path.read_text(encoding="utf-8")
|
||||
assert "poppler-data" in content
|
||||
assert "mupdf-tools" in content
|
||||
|
||||
|
||||
def test_ocr_service_uses_worker_runtime_and_keeps_unsupported_files_as_warnings(
|
||||
@@ -163,6 +167,7 @@ def test_ocr_service_passes_configured_device_to_worker(
|
||||
text: bool,
|
||||
timeout: int,
|
||||
check: bool,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
captured_commands.append(command)
|
||||
return subprocess.CompletedProcess(
|
||||
@@ -194,12 +199,12 @@ def test_ocr_service_converts_pdf_to_images_and_returns_image_preview(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]:
|
||||
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> tuple[list[Path], bool]:
|
||||
first = output_dir / "page-1.png"
|
||||
second = output_dir / "page-2.png"
|
||||
first.write_bytes(b"fake-page-1")
|
||||
second.write_bytes(b"fake-page-2")
|
||||
return [first, second]
|
||||
return [first, second], True
|
||||
|
||||
def fake_invoke_worker(
|
||||
self,
|
||||
@@ -281,26 +286,143 @@ def test_ocr_service_converts_pdf_to_images_and_returns_image_preview(
|
||||
assert recognized.lines[1].page_index == 1
|
||||
|
||||
|
||||
def test_ocr_service_uses_pdf_text_layer_without_worker_runtime(
|
||||
def test_ocr_service_rejects_pdf_ocr_when_rendered_image_fonts_are_broken(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]:
|
||||
page = output_dir / "page-1.png"
|
||||
page.write_bytes(b"fake-rendered-page")
|
||||
return [page]
|
||||
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> tuple[list[Path], bool]:
|
||||
raise RuntimeError("PDF 转图片失败:检测到中文字体映射缺失,未生成可 OCR 的图片。")
|
||||
|
||||
def fail_resolve_python(self) -> str:
|
||||
raise AssertionError("PDF 文本层可用时不应强制解析 OCR worker。")
|
||||
|
||||
def fail_invoke_worker(self, **kwargs) -> dict:
|
||||
raise AssertionError("PDF 文本层可用时不应调用 OCR worker。")
|
||||
def fake_invoke_worker(
|
||||
self,
|
||||
*,
|
||||
python_bin: str,
|
||||
worker_path: str,
|
||||
input_paths: list[Path],
|
||||
) -> dict:
|
||||
raise AssertionError("PDF 转图片已确认丢中文时,不应继续调用 OCR worker。")
|
||||
|
||||
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
|
||||
monkeypatch.setattr(OcrService, "_resolve_python_bin", fail_resolve_python)
|
||||
monkeypatch.setattr(OcrService, "_resolve_python_bin", lambda self: "python")
|
||||
monkeypatch.setattr(OcrService, "_resolve_worker_path", lambda self: "worker.py")
|
||||
monkeypatch.setattr(OcrService, "_convert_pdf_to_images", fake_convert_pdf_to_images)
|
||||
monkeypatch.setattr(OcrService, "_invoke_worker", fail_invoke_worker)
|
||||
monkeypatch.setattr(OcrService, "_invoke_worker", fake_invoke_worker)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
result = OcrService().recognize_files(
|
||||
[
|
||||
("2月20_武汉-上海.pdf", b"%PDF-1.7 fake", "application/pdf"),
|
||||
]
|
||||
)
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
failed = result.documents[0]
|
||||
assert failed.line_count == 0
|
||||
assert failed.preview_kind == ""
|
||||
assert failed.preview_data_url == ""
|
||||
assert failed.warnings == ["PDF 转图片失败:检测到中文字体映射缺失,未生成可 OCR 的图片。"]
|
||||
|
||||
|
||||
def test_ocr_pdf_conversion_tries_next_renderer_when_poppler_font_mapping_fails(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
output_dir = tmp_path / "pages"
|
||||
output_dir.mkdir()
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_run(
|
||||
command: list[str],
|
||||
*,
|
||||
capture_output: bool,
|
||||
text: bool,
|
||||
timeout: int,
|
||||
check: bool,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
calls.append(Path(command[0]).name)
|
||||
if Path(command[0]).name == "pdftoppm":
|
||||
(output_dir / "page-1.png").write_bytes(b"broken-preview")
|
||||
return subprocess.CompletedProcess(
|
||||
args=command,
|
||||
returncode=0,
|
||||
stdout="",
|
||||
stderr="Syntax Error: Missing language pack for 'Adobe-GB1' mapping",
|
||||
)
|
||||
(output_dir / "page-1.png").write_bytes(b"rendered-with-chinese")
|
||||
return subprocess.CompletedProcess(
|
||||
args=command,
|
||||
returncode=0,
|
||||
stdout="",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
document_preview.shutil,
|
||||
"which",
|
||||
lambda name: f"/usr/bin/{name}" if name in {"pdftoppm", "mutool"} else None,
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
pages, preview_usable = OcrService()._convert_pdf_to_images(
|
||||
pdf_path=tmp_path / "ticket.pdf",
|
||||
output_dir=output_dir,
|
||||
)
|
||||
|
||||
assert pages == [output_dir / "page-1.png"]
|
||||
assert preview_usable is True
|
||||
assert calls == ["pdftoppm", "mutool"]
|
||||
|
||||
|
||||
def test_ocr_service_invokes_worker_even_when_pdf_text_layer_is_usable(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
calls = {"worker": 0}
|
||||
|
||||
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> tuple[list[Path], bool]:
|
||||
page = output_dir / "page-1.png"
|
||||
page.write_bytes(b"fake-rendered-page")
|
||||
return [page], True
|
||||
|
||||
def fake_invoke_worker(
|
||||
self,
|
||||
*,
|
||||
python_bin: str,
|
||||
worker_path: str,
|
||||
input_paths: list[Path],
|
||||
) -> dict:
|
||||
calls["worker"] += 1
|
||||
return {
|
||||
"engine": "paddleocr_mobile",
|
||||
"model": "PP-OCRv5_mobile",
|
||||
"documents": [
|
||||
{
|
||||
"input_path": str(input_paths[0]),
|
||||
"engine": "paddleocr_mobile",
|
||||
"model": "PP-OCRv5_mobile",
|
||||
"text": "电子发票(铁路电子客票) 武汉站 上海虹桥站 G458 票价 ¥354.00",
|
||||
"summary": "铁路电子客票",
|
||||
"avg_score": 0.95,
|
||||
"line_count": 1,
|
||||
"page_count": 1,
|
||||
"warnings": [],
|
||||
"lines": [
|
||||
{
|
||||
"text": "电子发票(铁路电子客票) 武汉站 上海虹桥站 G458 票价 ¥354.00",
|
||||
"score": 0.95,
|
||||
"box": [[1, 2], [10, 2], [10, 8], [1, 8]],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
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, "_convert_pdf_to_images", fake_convert_pdf_to_images)
|
||||
monkeypatch.setattr(OcrService, "_invoke_worker", fake_invoke_worker)
|
||||
monkeypatch.setattr(
|
||||
OcrService,
|
||||
"_extract_pdf_text_layer",
|
||||
@@ -326,9 +448,9 @@ def test_ocr_service_uses_pdf_text_layer_without_worker_runtime(
|
||||
|
||||
recognized = result.documents[0]
|
||||
assert result.success_count == 1
|
||||
assert calls["worker"] == 1
|
||||
assert recognized.document_type == "train_ticket"
|
||||
assert "电子发票(铁路电子客票)" in recognized.text
|
||||
assert "电子客票号:6580061086021391007342026" in recognized.text
|
||||
assert any(field.label == "金额" and field.value == "354元" for field in recognized.document_fields)
|
||||
assert recognized.preview_kind == "image"
|
||||
assert recognized.preview_data_url.startswith("data:image/png;base64,")
|
||||
@@ -392,14 +514,22 @@ def test_ocr_service_reuses_cached_document_for_same_content(
|
||||
assert second.documents[0].summary == first.documents[0].summary
|
||||
|
||||
|
||||
def test_ocr_cache_key_includes_pdf_render_pipeline_version() -> None:
|
||||
cache_key = OcrService()._build_cache_key(b"same-pdf-content")
|
||||
|
||||
assert "pdf-image-ocr:" in cache_key
|
||||
assert document_preview.DocumentPreviewAssets.PDF_RENDERER_ID in cache_key
|
||||
assert "no-pdf-direct" in cache_key
|
||||
|
||||
|
||||
def test_ocr_service_prefers_pdf_text_layer_when_rendered_ocr_is_placeholder_heavy(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]:
|
||||
def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> tuple[list[Path], bool]:
|
||||
page = output_dir / "page-1.png"
|
||||
page.write_bytes(b"fake-page")
|
||||
return [page]
|
||||
return [page], True
|
||||
|
||||
def fake_invoke_worker(
|
||||
self,
|
||||
|
||||
@@ -4,7 +4,7 @@ import base64
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.config import get_settings
|
||||
from app.schemas.ocr import OcrRecognizeDocumentRead
|
||||
from app.schemas.ocr import OcrRecognizeDocumentRead, OcrRecognizeFieldRead
|
||||
from app.services.document_preview import DocumentPreviewAssets
|
||||
from app.services.receipt_folder import ReceiptFolderService
|
||||
|
||||
@@ -72,6 +72,55 @@ def test_receipt_folder_train_ticket_uses_invoice_date_and_enriches_fields(monke
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_receipt_folder_pdf_save_eagerly_renders_image_preview(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,
|
||||
)
|
||||
|
||||
def fake_render_pdf_first_page(*, pdf_path, preview_path, timeout_seconds):
|
||||
preview_path.write_bytes(b"rendered-preview")
|
||||
return preview_path
|
||||
|
||||
monkeypatch.setattr(DocumentPreviewAssets, "render_pdf_first_page", fake_render_pdf_first_page)
|
||||
|
||||
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="铁路电子客票 武汉 上海虹桥 354.00",
|
||||
summary="铁路电子客票,武汉至上海虹桥。",
|
||||
),
|
||||
)
|
||||
|
||||
receipt_dir = next(service.root.glob("pytest/*"))
|
||||
preview_path = receipt_dir / "preview.png"
|
||||
meta = service._read_meta(receipt_dir)
|
||||
|
||||
assert receipt.preview_kind == "image"
|
||||
assert preview_path.read_bytes() == b"rendered-preview"
|
||||
assert meta["preview_file_name"] == "preview.png"
|
||||
assert meta["preview_media_type"] == "image/png"
|
||||
assert meta["preview_rendered_with"] == DocumentPreviewAssets.PDF_RENDERER_ID
|
||||
|
||||
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"
|
||||
finally:
|
||||
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()
|
||||
@@ -123,6 +172,213 @@ def test_receipt_folder_pdf_preview_regenerates_stale_cached_image(monkeypatch,
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_receipt_folder_pdf_preview_falls_back_to_source_when_render_fonts_missing(
|
||||
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"broken-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.7 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/*"))
|
||||
meta = service._read_meta(receipt_dir)
|
||||
meta["preview_rendered_with"] = "pdftoppm-png-r160-poppler-data"
|
||||
service._write_meta(receipt_dir, meta)
|
||||
|
||||
def fake_render_pdf_first_page(*, pdf_path, preview_path, timeout_seconds):
|
||||
raise RuntimeError("Missing language pack for 'Adobe-GB1' mapping")
|
||||
|
||||
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 == receipt_dir / "2月20_武汉-上海.pdf"
|
||||
assert media_type == "application/pdf"
|
||||
assert file_name == "2月20_武汉-上海.pdf"
|
||||
refreshed_meta = service._read_meta(receipt_dir)
|
||||
assert refreshed_meta["preview_kind"] == "pdf"
|
||||
assert refreshed_meta["preview_file_name"] == "2月20_武汉-上海.pdf"
|
||||
assert refreshed_meta["preview_media_type"] == "application/pdf"
|
||||
assert refreshed_meta["preview_rendered_with"] == ""
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_receipt_folder_train_ticket_extracts_passenger_from_id_line_and_purchase_name(
|
||||
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=(
|
||||
"电子发票(铁路电子客票)\n"
|
||||
"发票号码:26429165800002785705 湖北\n"
|
||||
"开票日期: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"
|
||||
"购买方名称:曹笑竹 统一社会信用代码:\n"
|
||||
"买票请到12306 发货请到95306\n"
|
||||
"中国铁路祝您旅途愉快"
|
||||
),
|
||||
summary="电子发票(铁路电子客票);发票监;统一 制",
|
||||
document_type="train_ticket",
|
||||
document_type_label="火车/高铁票",
|
||||
scene_code="travel",
|
||||
scene_label="差旅票据",
|
||||
document_fields=[
|
||||
OcrRecognizeFieldRead(key="merchant_name", label="商户", value="电子发票(铁路"),
|
||||
OcrRecognizeFieldRead(key="amount", label="金额", value="354元"),
|
||||
OcrRecognizeFieldRead(key="date", label="列车出发时间", value="2026-02-20 07:55"),
|
||||
OcrRecognizeFieldRead(key="trip_no", label="车次", value="G458"),
|
||||
OcrRecognizeFieldRead(key="route", label="行程", value="武汉-上海"),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
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["乘车人"] == "曹笑竹"
|
||||
assert fields["出发地点"] == "武汉"
|
||||
assert fields["到达地点"] == "上海虹桥"
|
||||
assert fields["身份证号"] == "4201061987****1615"
|
||||
assert fields["电子客票号"] == "6580061086021391007342026"
|
||||
assert fields["开票日期"] == "2026-05-18"
|
||||
assert fields["列车出发时间"] == "2026-02-20 07:55"
|
||||
assert fields["车厢"] == "06车"
|
||||
assert fields["座位号"] == "01B"
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_receipt_folder_train_ticket_repairs_invalid_generated_fields_from_ocr_text(
|
||||
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月21日_上海-深圳.png",
|
||||
content=b"fake image",
|
||||
media_type="image/png",
|
||||
current_user=current_user,
|
||||
document=OcrRecognizeDocumentRead(
|
||||
filename="2月21日_上海-深圳.png",
|
||||
media_type="image/png",
|
||||
text=(
|
||||
"行程单示意\n"
|
||||
"出票渠道:示例平台\n"
|
||||
"非官方车票\n"
|
||||
"不可报销\n"
|
||||
"仅供演示\n"
|
||||
"创建日期:2026年02月15日\n"
|
||||
"订单号:DEMO202602210001\n"
|
||||
"单据编号:DEMO-IT-000001\n"
|
||||
"上海虹桥\n"
|
||||
"G999\n"
|
||||
"深圳北\n"
|
||||
"站\n"
|
||||
"站\n"
|
||||
"Shanghaihongqiao\n"
|
||||
"Shenzhenbei\n"
|
||||
"2026年02月21日\n"
|
||||
"08:30出发\n"
|
||||
"全程约7小时30分\n"
|
||||
"15:00到达\n"
|
||||
"DEMO\n"
|
||||
"乘客:示例旅客\n"
|
||||
"车厢:05车\n"
|
||||
"席别:二等座\n"
|
||||
"-\n"
|
||||
"扫码无效\n"
|
||||
"证件号:310101199001010000\n"
|
||||
"座位:08A\n"
|
||||
"票价:¥438.00\n"
|
||||
"仅为演示"
|
||||
),
|
||||
summary="行程单示意;出票渠道:示例平台;非官方车票",
|
||||
document_type="train_ticket",
|
||||
document_type_label="火车/高铁票",
|
||||
scene_code="travel",
|
||||
scene_label="差旅票据",
|
||||
document_fields=[
|
||||
OcrRecognizeFieldRead(key="amount", label="金额", value="438元"),
|
||||
OcrRecognizeFieldRead(key="date", label="列车出发时间", value="2026-02-21 08:30"),
|
||||
OcrRecognizeFieldRead(key="invoice_number", label="票据号码", value="DEMO202602210001"),
|
||||
OcrRecognizeFieldRead(key="trip_no", label="车次", value="G999"),
|
||||
OcrRecognizeFieldRead(key="route", label="行程", value="上海-深圳"),
|
||||
OcrRecognizeFieldRead(key="departure_station", label="出发地点", value="二等座"),
|
||||
OcrRecognizeFieldRead(key="arrival_station", label="到达地点", value="扫码无效"),
|
||||
OcrRecognizeFieldRead(key="passenger_name", label="乘车人", value="席别二等座"),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
detail = service.get_receipt(receipt.id, current_user)
|
||||
fields = {field.label: field.value for field in detail.fields}
|
||||
assert fields["出发地点"] == "上海虹桥"
|
||||
assert fields["到达地点"] == "深圳北"
|
||||
assert fields["乘车人"] == "示例旅客"
|
||||
assert fields["身份证号"] == "310101199001010000"
|
||||
assert fields["席别"] == "二等座"
|
||||
assert fields["车厢"] == "05车"
|
||||
assert fields["座位号"] == "08A"
|
||||
assert fields["票价"] == "438.00元"
|
||||
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()
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.schemas.user_agent import UserAgentCitation, UserAgentRequest, UserAgentReviewRiskBrief
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.application_location_semantics import resolve_jieba_tokens
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
from app.services.user_agent import UserAgentService
|
||||
from app.services.user_agent_documents import UserAgentDocumentService
|
||||
@@ -763,6 +764,67 @@ def test_user_agent_application_submit_blocks_overlapping_travel_dates() -> None
|
||||
assert response.draft_payload is None
|
||||
|
||||
|
||||
def test_user_agent_application_submit_normalizes_location_mixed_with_business_content() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
response = build_application_user_agent_response(
|
||||
db,
|
||||
"确认提交",
|
||||
context_overrides={
|
||||
"manager_name": "向万红",
|
||||
"application_preview": {
|
||||
"fields": {
|
||||
"applicationType": "差旅费用申请",
|
||||
"time": "2026-02-20 至 2026-02-23",
|
||||
"location": "上海辅助国网仿生产服务器",
|
||||
"reason": "辅助国网仿生产服务器部署",
|
||||
"days": "4天",
|
||||
"transportMode": "火车",
|
||||
"amount": "2120元",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
claim = application_claim_query(db).one()
|
||||
assert claim.location == "上海市"
|
||||
assert claim.reason == "辅助国网仿生产服务器部署"
|
||||
assert "申请单据已生成" in response.answer
|
||||
assert response.draft_payload is not None
|
||||
|
||||
|
||||
def test_user_agent_application_submit_splits_location_and_reason_from_raw_sentence() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
response = build_application_user_agent_response(
|
||||
db,
|
||||
"确认提交",
|
||||
history=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "2026-02-20 至 2026-02-23,去上海辅助国网仿生产服务器部署,火车",
|
||||
}
|
||||
],
|
||||
context_overrides={
|
||||
"manager_name": "向万红",
|
||||
"grade": "P5",
|
||||
"department_name": "技术部",
|
||||
},
|
||||
)
|
||||
|
||||
claim = application_claim_query(db).one()
|
||||
assert claim.location == "上海市"
|
||||
assert claim.reason == "辅助国网仿生产服务器部署"
|
||||
assert "申请单据已生成" in response.answer
|
||||
|
||||
|
||||
def test_application_sentence_jieba_tokenizer_recognizes_location_boundary() -> None:
|
||||
tokens = resolve_jieba_tokens("上海辅助国网仿生产服务器部署")
|
||||
|
||||
assert ("上海", "ns") in tokens
|
||||
assert [word for word, _ in tokens] == ["上海", "辅助", "国网", "仿生产", "服务器", "部署"]
|
||||
|
||||
|
||||
def test_user_agent_application_maps_preview_travel_type_label() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
@@ -2155,7 +2217,7 @@ def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None:
|
||||
context_json={"review_action": "save_draft"},
|
||||
tool_payload={
|
||||
"draft_limit_reached": True,
|
||||
"message": "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。",
|
||||
"message": "您当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。",
|
||||
"status": "blocked",
|
||||
},
|
||||
)
|
||||
@@ -2163,7 +2225,7 @@ def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None:
|
||||
|
||||
assert (
|
||||
response.answer
|
||||
== "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。"
|
||||
== "您当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user