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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user