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:
caoxiaozhu
2026-06-24 10:42:24 +08:00
parent 332f77389d
commit 0264a4b5b4
41 changed files with 1273 additions and 182 deletions

View File

@@ -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",