Files
X-Financial/server/tests/test_attachment_association_jobs.py

2027 lines
76 KiB
Python
Raw Normal View History

from __future__ import annotations
import base64
from collections.abc import Generator
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal
from threading import Lock, Thread
from time import sleep
2026-07-13 14:45:36 +08:00
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.api.deps import CurrentUserContext, get_db
from app.api.v1.endpoints import attachment_association_jobs as attachment_jobs_endpoint
from app.core.config import get_settings
from app.main import create_app
from app.models.attachment_association_job import AttachmentAssociationJob
from app.models.employee import Employee
from app.models.expense_case import BusinessEvent, ExpenseCase, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.schemas.attachment_association_job import AttachmentAssociationJobCreate
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead, OcrRecognizeFieldRead
from app.services.attachment_association_job_store import (
claim_persistent_job,
update_persistent_job,
)
from app.services.attachment_association_jobs import (
clear_attachment_association_jobs_for_tests,
create_attachment_association_job,
get_attachment_association_job,
run_attachment_association_job,
)
from app.services.expense_cases import ExpenseCaseService
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
2026-07-13 14:45:36 +08:00
from app.services.expense_claims import ExpenseClaimService
from app.services.expense_receipt_association import ExpenseReceiptAssociationService
from app.services.ocr import OcrService
from app.services.receipt_folder import ReceiptFolderService
from app.test_helpers.db import build_in_memory_session_factory
def build_client(monkeypatch) -> tuple[TestClient, object]:
session_factory = build_in_memory_session_factory()
app = create_app()
2026-07-13 14:45:36 +08:00
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]:
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
monkeypatch.setattr(attachment_jobs_endpoint, "get_session_factory", lambda: session_factory)
return TestClient(app), session_factory
def seed_travel_claim(db: Session) -> ExpenseClaim:
employee = Employee(
id="emp-bg-association",
employee_no="E10001",
name="张三",
email="zhangsan@example.com",
position="实施顾问",
grade="P4",
)
claim = ExpenseClaim(
id="claim-bg-association",
claim_no="BX-20260220-001",
employee_id=employee.id,
employee_name=employee.name,
department_id="dept-delivery",
department_name="交付部",
project_code=None,
expense_type="travel",
reason="辅助国网仿生产服务器部署,武汉往返上海",
location="上海",
amount=Decimal("0.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
item = ExpenseClaimItem(
id="item-bg-association-1",
claim_id=claim.id,
item_date=date(2026, 2, 20),
item_type="train_ticket",
item_reason="武汉至上海高铁",
item_location="上海",
item_amount=Decimal("0.00"),
invoice_id=None,
)
claim.items = [item]
db.add_all([employee, claim])
db.commit()
return claim
def seed_approved_application_for_draft(db: Session, draft: ExpenseClaim) -> ExpenseClaim:
application = ExpenseClaim(
id="application-bg-association",
claim_no="AP-20260220-001",
employee_id=draft.employee_id,
employee_name=draft.employee_name,
department_id=draft.department_id,
department_name=draft.department_name,
project_code=draft.project_code,
expense_type="travel_application",
reason=draft.reason,
location=draft.location,
amount=Decimal("3000.00"),
currency="CNY",
invoice_count=0,
occurred_at=draft.occurred_at,
submitted_at=draft.occurred_at,
status="approved",
approval_stage="已完成",
risk_flags_json=[],
)
db.add(application)
db.flush()
case_service = ExpenseCaseService(db)
expense_case = case_service.ensure_case_for_claim(
application,
tenant_id="default",
relation_type="application",
)
case_service.link_claim(
expense_case,
draft,
tenant_id="default",
relation_type="generated_reimbursement",
)
db.commit()
return application
def seed_standalone_approved_application(db: Session) -> ExpenseClaim:
employee = Employee(
id="emp-application-only",
employee_no="E10001",
name="张三",
email="zhangsan@example.com",
position="实施顾问",
grade="P4",
)
application = ExpenseClaim(
id="application-only-association",
claim_no="AP-20260220-ONLY",
employee_id=employee.id,
employee_name=employee.name,
department_id="dept-delivery",
department_name="交付部",
project_code=None,
expense_type="travel_application",
reason="辅助国网仿生产服务器部署,武汉往返上海",
location="上海",
amount=Decimal("3000.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
submitted_at=datetime(2026, 2, 18, tzinfo=UTC),
status="approved",
approval_stage="已完成",
risk_flags_json=[],
)
db.add_all([employee, application])
db.flush()
ExpenseCaseService(db).ensure_case_for_claim(
application,
tenant_id="default",
relation_type="application",
)
db.commit()
return application
def seed_approved_application_with_flag_only(
db: Session,
draft: ExpenseClaim,
) -> tuple[ExpenseClaim, ExpenseCase]:
application = ExpenseClaim(
id="application-flag-only-association",
claim_no="AP-20260220-FLAG",
employee_id=draft.employee_id,
employee_name=draft.employee_name,
department_id=draft.department_id,
department_name=draft.department_name,
project_code=draft.project_code,
expense_type="travel_application",
reason=draft.reason,
location=draft.location,
amount=Decimal("3000.00"),
currency="CNY",
invoice_count=0,
occurred_at=draft.occurred_at,
submitted_at=draft.occurred_at,
status="approved",
approval_stage="已完成",
risk_flags_json=[],
)
draft.risk_flags_json = [
{
"source": "application_link",
"application_claim_id": application.id,
"application_claim_no": application.claim_no,
}
]
db.add(application)
db.flush()
expense_case = ExpenseCaseService(db).ensure_case_for_claim(
application,
tenant_id="default",
relation_type="application",
)
db.commit()
return application, expense_case
def seed_second_matching_draft(db: Session) -> ExpenseClaim:
claim = ExpenseClaim(
id="claim-bg-association-2",
claim_no="BX-20260220-002",
employee_id="emp-bg-association",
employee_name="张三",
department_id="dept-delivery",
department_name="交付部",
project_code=None,
expense_type="travel",
reason="辅助国网仿生产服务器部署,武汉往返上海",
location="上海",
amount=Decimal("0.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="待提交",
risk_flags_json=[],
items=[
ExpenseClaimItem(
id="item-bg-association-2",
item_date=date(2026, 2, 20),
item_type="train_ticket",
item_reason="武汉至上海高铁",
item_location="上海",
item_amount=Decimal("0.00"),
invoice_id=None,
)
],
)
db.add(claim)
db.commit()
return claim
def save_train_receipt(
*,
service: ReceiptFolderService,
current_user: CurrentUserContext,
filename: str,
route: str,
trip_date: str,
) -> str:
receipt = service.save_receipt(
filename=filename,
content=f"fake-pdf-{filename}".encode(),
media_type="application/pdf",
current_user=current_user,
document=OcrRecognizeDocumentRead(
filename=filename,
media_type="application/pdf",
text=f"电子发票(铁路电子客票) {route} {trip_date} 票价 354 元",
summary=f"铁路电子客票,{route},票价 354 元。",
avg_score=0.96,
line_count=1,
page_count=1,
document_type="train_ticket",
document_type_label="火车/高铁票",
scene_code="travel",
scene_label="差旅票据",
document_fields=[
OcrRecognizeFieldRead(key="date", label="列车出发时间", value=trip_date),
OcrRecognizeFieldRead(key="route", label="行程", value=route),
OcrRecognizeFieldRead(key="amount", label="金额", value="354元"),
],
),
)
return receipt.id
def fake_ocr_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
filename = files[0][0]
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename=filename,
media_type=files[0][2] or "application/pdf",
text="电子发票(铁路电子客票) 武汉 上海 2026-02-20 票价 354 元",
summary="铁路电子客票,武汉至上海,票价 354 元。",
avg_score=0.96,
line_count=1,
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"),
OcrRecognizeFieldRead(key="route", label="行程", value="武汉-上海"),
OcrRecognizeFieldRead(key="amount", label="金额", value="354元"),
],
)
],
)
def fake_ocr_recognize_without_preview(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return fake_ocr_recognize(self, files)
def test_attachment_association_job_links_receipts_after_conversation_exit(
monkeypatch, tmp_path
) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
clear_attachment_association_jobs_for_tests()
monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize)
monkeypatch.setattr(
ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments"
)
try:
client, session_factory = build_client(monkeypatch)
current_user = CurrentUserContext(
tenant_id="default",
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
with session_factory() as db:
draft = seed_travel_claim(db)
seed_approved_application_for_draft(db, draft)
receipt_service = ReceiptFolderService()
receipt_ids = [
save_train_receipt(
service=receipt_service,
current_user=current_user,
filename="2月20 武汉-上海.pdf",
route="武汉-上海",
trip_date="2026-02-20",
),
save_train_receipt(
service=receipt_service,
current_user=current_user,
filename="2月23 上海-武汉.pdf",
route="上海-武汉",
trip_date="2026-02-23",
),
]
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
response = client.post(
"/api/v1/reimbursements/attachment-association-jobs",
headers=headers,
json={
"receipt_ids": receipt_ids,
"prompt": "请帮我处理已上传的附件。",
"conversation_id": "inline-test",
},
)
assert response.status_code == 202
job_id = response.json()["job_id"]
status_response = client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{job_id}",
headers=headers,
)
assert status_response.status_code == 200
payload = status_response.json()
assert payload["status"] == "succeeded"
assert payload["claim_id"] == "claim-bg-association"
assert payload["claim_no"] == "BX-20260220-001"
assert payload["uploaded_count"] == 2
assert payload["resolution"] == "auto_associated"
assert payload["requires_confirmation"] is False
assert payload["expense_case_id"]
assert payload["application_claim_id"] == "application-bg-association"
assert payload["application_claim_no"] == "AP-20260220-001"
assert payload["confidence"] == "high"
assert payload["match_reasons"]
assert payload["draft_payload"]["claim_id"] == "claim-bg-association"
with session_factory() as db:
claim = db.scalar(
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.items))
.where(ExpenseClaim.id == "claim-bg-association")
)
assert claim is not None
attached_items = [item for item in claim.items if item.invoice_id]
assert len(attached_items) == 2
pre_review_flags = [
flag
for flag in list(claim.risk_flags_json or [])
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
]
assert len(pre_review_flags) == 1
assert pre_review_flags[0]["created_at"]
receipt_links = list(
db.scalars(
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_type == "receipt")
).all()
)
assert {link.resource_id for link in receipt_links} == set(receipt_ids)
receipt_events = list(
db.scalars(
select(BusinessEvent).where(BusinessEvent.aggregate_type == "receipt")
).all()
)
assert len(receipt_events) == 4
assert {event.event_type for event in receipt_events} == {
"receipt_received",
"attachment_associated",
}
expense_case = db.scalar(
select(ExpenseCase).where(ExpenseCase.id == receipt_links[0].expense_case_id)
)
assert expense_case is not None
assert expense_case.current_stage == "claiming"
linked_receipts = receipt_service.list_receipts(
current_user=current_user, status_filter="linked"
)
assert {item.id for item in linked_receipts} == set(receipt_ids)
assert {item.linked_claim_id for item in linked_receipts} == {"claim-bg-association"}
finally:
clear_attachment_association_jobs_for_tests()
get_settings.cache_clear()
def test_attachment_association_keeps_receipt_folder_preview_and_fields_after_cache_clear(
monkeypatch,
tmp_path,
) -> None:
preview_bytes = b"receipt-folder-preview-png"
preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}"
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
clear_attachment_association_jobs_for_tests()
monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize_without_preview)
monkeypatch.setattr(
ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments"
)
try:
client, session_factory = build_client(monkeypatch)
current_user = CurrentUserContext(
tenant_id="default",
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
with session_factory() as db:
seed_travel_claim(db)
receipt = ReceiptFolderService().save_receipt(
filename="2月20 武汉-上海.pdf",
content=b"%PDF-1.7 fake-ticket",
media_type="application/pdf",
current_user=current_user,
document=OcrRecognizeDocumentRead(
filename="2月20 武汉-上海.pdf",
media_type="application/pdf",
text=(
"电子发票(铁路电子客票) 武汉站 G458 上海虹桥站 "
"2026年02月20日 07:55开 二等座 票价 354.00"
),
summary="铁路电子客票,武汉-上海,票价 354 元。",
avg_score=0.96,
line_count=1,
page_count=1,
document_type="train_ticket",
document_type_label="火车/高铁票",
scene_code="travel",
scene_label="差旅票据",
preview_kind="image",
preview_data_url=preview_data_url,
document_fields=[
OcrRecognizeFieldRead(
key="date", label="列车出发时间", value="2026-02-20 07:55"
),
OcrRecognizeFieldRead(key="route", label="行程", value="武汉-上海"),
OcrRecognizeFieldRead(key="amount", label="金额", value="354元"),
],
),
)
OcrService.clear_result_cache()
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
response = client.post(
"/api/v1/reimbursements/attachment-association-jobs",
headers=headers,
json={
"receipt_ids": [receipt.id],
"prompt": "请帮我处理已上传的附件。",
"conversation_id": "inline-test",
},
)
assert response.status_code == 202
job_id = response.json()["job_id"]
status_response = client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{job_id}",
headers=headers,
)
assert status_response.status_code == 200
assert status_response.json()["status"] == "succeeded"
with session_factory() as db:
claim = db.scalar(
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.items))
.where(ExpenseClaim.id == "claim-bg-association")
)
assert claim is not None
attached_item = next(item for item in claim.items if item.invoice_id)
metadata = ExpenseClaimService(db).get_claim_item_attachment_meta(
claim_id=claim.id,
item_id=attached_item.id,
current_user=current_user,
)
assert metadata is not None
assert metadata["preview_kind"] == "image"
assert metadata["document_info"]["document_type"] == "train_ticket"
assert metadata["document_info"]["document_type_label"] == "火车/高铁票"
assert {
(field["label"], field["value"]) for field in metadata["document_info"]["fields"]
} >= {
("列车出发时间", "2026-02-20 07:55"),
("行程", "武汉-上海"),
("金额", "354元"),
}
preview_path, media_type, filename = ExpenseClaimService(
db
).get_claim_item_attachment_preview_content(
claim_id=claim.id,
item_id=attached_item.id,
current_user=current_user,
)
assert media_type == "image/png"
assert filename.endswith(".png")
assert preview_path.read_bytes() == preview_bytes
finally:
clear_attachment_association_jobs_for_tests()
get_settings.cache_clear()
def test_attachment_meta_repairs_existing_pdf_fallback_from_source_receipt(
monkeypatch,
tmp_path,
) -> None:
preview_bytes = b"legacy-repaired-preview-png"
preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}"
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
monkeypatch.setattr(
ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments"
)
try:
current_user = CurrentUserContext(
tenant_id="default",
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
client, session_factory = build_client(monkeypatch)
client.close()
with session_factory() as db:
claim = seed_travel_claim(db)
item = claim.items[0]
receipt = ReceiptFolderService().save_receipt(
filename="2月20 武汉-上海.pdf",
content=b"%PDF-1.7 fake-ticket",
media_type="application/pdf",
current_user=current_user,
document=OcrRecognizeDocumentRead(
filename="2月20 武汉-上海.pdf",
media_type="application/pdf",
text=(
"电子发票(铁路电子客票) 武汉站 G458 上海虹桥站 "
"2026年02月20日 07:55开 二等座 票价 354.00"
),
summary="铁路电子客票,武汉-上海,票价 354 元。",
avg_score=0.96,
line_count=1,
page_count=1,
document_type="train_ticket",
document_type_label="火车/高铁票",
scene_code="travel",
scene_label="差旅票据",
preview_kind="image",
preview_data_url=preview_data_url,
document_fields=[
OcrRecognizeFieldRead(
key="date", label="列车出发时间", value="2026-02-20 07:55"
),
OcrRecognizeFieldRead(key="route", label="行程", value="武汉-上海"),
OcrRecognizeFieldRead(key="amount", label="金额", value="354元"),
],
),
)
attachment_dir = tmp_path / "attachments" / claim.id / item.id
attachment_dir.mkdir(parents=True)
file_path = attachment_dir / "2月20_武汉-上海.pdf"
file_path.write_bytes(b"%PDF-1.7 persisted-but-bad-meta")
storage = ExpenseClaimAttachmentStorage()
item.invoice_id = storage.to_storage_key(file_path)
storage.write_meta(
file_path,
{
"file_name": file_path.name,
"storage_key": storage.to_storage_key(file_path),
"media_type": "application/pdf",
"size_bytes": file_path.stat().st_size,
"previewable": True,
"preview_kind": "pdf",
"preview_storage_key": storage.to_storage_key(file_path),
"preview_media_type": "application/pdf",
"preview_file_name": file_path.name,
"document_info": {
"document_type": "other",
"document_type_label": "其他单据",
"scene_code": "other",
"scene_label": "其他票据",
"fields": [],
},
"source_receipt_id": receipt.id,
},
)
db.commit()
service = ExpenseClaimService(db)
metadata = service.get_claim_item_attachment_meta(
claim_id=claim.id,
item_id=item.id,
current_user=current_user,
)
assert metadata is not None
assert metadata["preview_kind"] == "image"
assert metadata["document_info"]["document_type"] == "train_ticket"
assert metadata["document_info"]["document_type_label"] == "火车/高铁票"
assert {
(field["label"], field["value"]) for field in metadata["document_info"]["fields"]
} >= {
("列车出发时间", "2026-02-20 07:55"),
("行程", "武汉-上海"),
("金额", "354元"),
}
preview_path, media_type, filename = service.get_claim_item_attachment_preview_content(
claim_id=claim.id,
item_id=item.id,
current_user=current_user,
)
assert media_type == "image/png"
assert filename.endswith(".png")
assert preview_path.read_bytes() == preview_bytes
finally:
get_settings.cache_clear()
def test_attachment_association_job_requests_confirmation_without_editable_claim(
monkeypatch,
tmp_path,
) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
clear_attachment_association_jobs_for_tests()
try:
client, _session_factory = build_client(monkeypatch)
current_user = CurrentUserContext(
tenant_id="default",
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
receipt_id = save_train_receipt(
service=ReceiptFolderService(),
current_user=current_user,
filename="2月20 武汉-上海.pdf",
route="武汉-上海",
trip_date="2026-02-20",
)
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
response = client.post(
"/api/v1/reimbursements/attachment-association-jobs",
headers=headers,
json={"receipt_ids": [receipt_id], "conversation_id": "inline-empty"},
)
assert response.status_code == 202
status_response = client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{response.json()['job_id']}",
headers=headers,
)
assert status_response.status_code == 200
payload = status_response.json()
assert payload["status"] == "succeeded"
assert payload["resolution"] == "requires_confirmation"
assert payload["requires_confirmation"] is True
assert payload["uploaded_count"] == 0
assert payload["error"] == ""
assert "没有找到" in payload["exceptions"][0]
finally:
clear_attachment_association_jobs_for_tests()
get_settings.cache_clear()
def test_confirmation_job_is_re_evaluated_after_draft_is_created(
monkeypatch,
tmp_path,
) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
clear_attachment_association_jobs_for_tests()
monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize)
monkeypatch.setattr(
ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments"
)
try:
client, session_factory = build_client(monkeypatch)
current_user = CurrentUserContext(
tenant_id="default",
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
receipt_id = save_train_receipt(
service=ReceiptFolderService(),
current_user=current_user,
filename="2月20 武汉-上海.pdf",
route="武汉-上海",
trip_date="2026-02-20",
)
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
first_job = client.post(
"/api/v1/reimbursements/attachment-association-jobs",
headers=headers,
json={"receipt_ids": [receipt_id]},
).json()
first_result = client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{first_job['job_id']}",
headers=headers,
).json()
assert first_result["resolution"] == "requires_confirmation"
with session_factory() as db:
seed_travel_claim(db)
second_job = client.post(
"/api/v1/reimbursements/attachment-association-jobs",
headers=headers,
json={"receipt_ids": [receipt_id]},
).json()
second_result = client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{second_job['job_id']}",
headers=headers,
).json()
third_job = client.post(
"/api/v1/reimbursements/attachment-association-jobs",
headers=headers,
json={"receipt_ids": [receipt_id]},
).json()
assert second_job["job_id"] != first_job["job_id"]
assert third_job["job_id"] == second_job["job_id"]
assert second_result["status"] == "succeeded"
assert second_result["resolution"] == "auto_associated"
assert second_result["uploaded_count"] == 1
with session_factory() as db:
jobs = list(
db.scalars(
select(AttachmentAssociationJob).order_by(AttachmentAssociationJob.generation)
).all()
)
assert [(job.generation, job.resolution) for job in jobs] == [
(1, "requires_confirmation"),
(2, "auto_associated"),
]
finally:
clear_attachment_association_jobs_for_tests()
get_settings.cache_clear()
def test_attachment_association_job_returns_application_candidate_without_creating_draft(
monkeypatch,
tmp_path,
) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
clear_attachment_association_jobs_for_tests()
try:
client, session_factory = build_client(monkeypatch)
current_user = CurrentUserContext(
tenant_id="default",
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
with session_factory() as db:
application = seed_standalone_approved_application(db)
application_id = application.id
receipt_id = save_train_receipt(
service=ReceiptFolderService(),
current_user=current_user,
filename="2月20 武汉-上海.pdf",
route="武汉-上海",
trip_date="2026-02-20",
)
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
created = client.post(
"/api/v1/reimbursements/attachment-association-jobs",
headers=headers,
json={"receipt_ids": [receipt_id]},
).json()
result = client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{created['job_id']}",
headers=headers,
).json()
assert result["status"] == "succeeded"
assert result["resolution"] == "requires_confirmation"
assert result["requires_confirmation"] is True
assert result["claim_id"] == ""
assert result["uploaded_count"] == 0
assert result["candidates"][0]["target_type"] == "approved_application"
assert result["candidates"][0]["application_claim_id"] == application_id
with session_factory() as db:
claims = list(db.scalars(select(ExpenseClaim)).all())
assert [claim.id for claim in claims] == [application_id]
finally:
clear_attachment_association_jobs_for_tests()
get_settings.cache_clear()
def test_attachment_association_job_is_idempotent_for_same_receipt(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
clear_attachment_association_jobs_for_tests()
monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize)
monkeypatch.setattr(
ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments"
)
try:
client, session_factory = build_client(monkeypatch)
current_user = CurrentUserContext(
tenant_id="default",
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
with session_factory() as db:
seed_travel_claim(db)
receipt_id = save_train_receipt(
service=ReceiptFolderService(),
current_user=current_user,
filename="2月20 武汉-上海.pdf",
route="武汉-上海",
trip_date="2026-02-20",
)
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
first_job = client.post(
"/api/v1/reimbursements/attachment-association-jobs",
headers=headers,
json={"receipt_ids": [receipt_id]},
).json()
first_result = client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{first_job['job_id']}",
headers=headers,
).json()
second_job = client.post(
"/api/v1/reimbursements/attachment-association-jobs",
headers=headers,
json={"receipt_ids": [receipt_id]},
).json()
second_result = client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{second_job['job_id']}",
headers=headers,
).json()
assert second_job["job_id"] == first_job["job_id"]
assert first_result["uploaded_count"] == 1
assert second_result["error"] == "", second_result["error"]
assert second_result["resolution"] == "auto_associated", second_result
assert second_result["uploaded_count"] == 1
assert second_result["skipped_count"] == 0
with session_factory() as db:
claim = db.scalar(
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.items))
.where(ExpenseClaim.id == "claim-bg-association")
)
assert claim is not None
assert len([item for item in claim.items if item.invoice_id]) == 1
events = list(
db.scalars(
select(BusinessEvent).where(BusinessEvent.aggregate_type == "receipt")
).all()
)
assert len(events) == 2
finally:
clear_attachment_association_jobs_for_tests()
get_settings.cache_clear()
def test_attachment_association_ambiguous_match_has_no_business_writes(
monkeypatch, tmp_path
) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
clear_attachment_association_jobs_for_tests()
try:
client, session_factory = build_client(monkeypatch)
current_user = CurrentUserContext(
tenant_id="default",
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
with session_factory() as db:
seed_travel_claim(db)
seed_second_matching_draft(db)
receipt_id = save_train_receipt(
service=ReceiptFolderService(),
current_user=current_user,
filename="2月20 武汉-上海.pdf",
route="武汉-上海",
trip_date="2026-02-20",
)
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
created = client.post(
"/api/v1/reimbursements/attachment-association-jobs",
headers=headers,
json={"receipt_ids": [receipt_id]},
).json()
result = client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{created['job_id']}",
headers=headers,
).json()
assert result["status"] == "succeeded"
assert result["resolution"] == "requires_confirmation"
assert result["requires_confirmation"] is True
assert len(result["candidates"]) == 2
assert result["uploaded_count"] == 0
receipt = ReceiptFolderService().get_receipt(receipt_id, current_user)
assert receipt.status == "unlinked"
with session_factory() as db:
assert (
db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_type == "receipt"))
is None
)
assert (
db.scalar(select(ExpenseCaseLink).where(ExpenseCaseLink.resource_type == "receipt"))
is None
)
claims = list(
db.scalars(select(ExpenseClaim).options(selectinload(ExpenseClaim.items))).all()
)
assert all(not item.invoice_id for claim in claims for item in claim.items)
finally:
clear_attachment_association_jobs_for_tests()
get_settings.cache_clear()
def test_attachment_association_rolls_back_when_event_write_fails(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
clear_attachment_association_jobs_for_tests()
monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize)
monkeypatch.setattr(
ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments"
)
original_record = ExpenseCaseService.record_resource_event
def fail_association_event(self, expense_case, **kwargs):
if kwargs.get("event_type") == "attachment_associated":
raise RuntimeError("simulated business event failure")
return original_record(self, expense_case, **kwargs)
monkeypatch.setattr(ExpenseCaseService, "record_resource_event", fail_association_event)
try:
client, session_factory = build_client(monkeypatch)
current_user = CurrentUserContext(
tenant_id="default",
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
with session_factory() as db:
seed_travel_claim(db)
receipt_id = save_train_receipt(
service=ReceiptFolderService(),
current_user=current_user,
filename="2月20 武汉-上海.pdf",
route="武汉-上海",
trip_date="2026-02-20",
)
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
created = client.post(
"/api/v1/reimbursements/attachment-association-jobs",
headers=headers,
json={"receipt_ids": [receipt_id]},
).json()
result = client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{created['job_id']}",
headers=headers,
).json()
assert result["status"] == "failed"
assert "simulated business event failure" in result["error"]
receipt = ReceiptFolderService().get_receipt(receipt_id, current_user)
assert receipt.status == "unlinked"
with session_factory() as db:
claim = db.scalar(
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.items))
.where(ExpenseClaim.id == "claim-bg-association")
)
assert claim is not None
assert all(not item.invoice_id for item in claim.items)
assert (
db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_type == "receipt"))
is None
)
finally:
clear_attachment_association_jobs_for_tests()
get_settings.cache_clear()
def test_attachment_association_failure_keeps_preexisting_application_case(
monkeypatch,
tmp_path,
) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
clear_attachment_association_jobs_for_tests()
monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize)
monkeypatch.setattr(
ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments"
)
original_record = ExpenseCaseService.record_resource_event
def fail_association_event(self, expense_case, **kwargs):
if kwargs.get("event_type") == "attachment_associated":
raise RuntimeError("simulated application case event failure")
return original_record(self, expense_case, **kwargs)
monkeypatch.setattr(ExpenseCaseService, "record_resource_event", fail_association_event)
try:
client, session_factory = build_client(monkeypatch)
current_user = CurrentUserContext(
tenant_id="default",
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
with session_factory() as db:
draft = seed_travel_claim(db)
application, expense_case = seed_approved_application_with_flag_only(db, draft)
application_id = application.id
expense_case_id = expense_case.id
receipt_id = save_train_receipt(
service=ReceiptFolderService(),
current_user=current_user,
filename="2月20 武汉-上海.pdf",
route="武汉-上海",
trip_date="2026-02-20",
)
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
created = client.post(
"/api/v1/reimbursements/attachment-association-jobs",
headers=headers,
json={"receipt_ids": [receipt_id]},
).json()
result = client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{created['job_id']}",
headers=headers,
).json()
assert result["status"] == "failed"
with session_factory() as db:
assert db.get(ExpenseCase, expense_case_id) is not None
application_link = db.scalar(
select(ExpenseCaseLink).where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id == application_id,
)
)
assert application_link is not None
assert application_link.expense_case_id == expense_case_id
assert (
db.scalar(
select(ExpenseCaseLink).where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id == "claim-bg-association",
)
)
is None
)
finally:
clear_attachment_association_jobs_for_tests()
get_settings.cache_clear()
def test_non_default_tenant_does_not_match_unscoped_legacy_claim(
monkeypatch,
tmp_path,
) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
clear_attachment_association_jobs_for_tests()
try:
client, session_factory = build_client(monkeypatch)
with session_factory() as db:
seed_travel_claim(db)
tenant_user = CurrentUserContext(
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
tenant_id="tenant-b",
employee_no="E10001",
)
receipt_id = save_train_receipt(
service=ReceiptFolderService(),
current_user=tenant_user,
filename="tenant-b-ticket.pdf",
route="武汉-上海",
trip_date="2026-02-20",
)
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-tenant-id": "tenant-b",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
created = client.post(
"/api/v1/reimbursements/attachment-association-jobs",
headers=headers,
json={"receipt_ids": [receipt_id]},
).json()
result = client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{created['job_id']}",
headers=headers,
).json()
assert result["status"] == "succeeded"
assert result["resolution"] == "requires_confirmation"
assert result["candidates"] == []
assert result["uploaded_count"] == 0
with session_factory() as db:
claim = db.scalar(
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.items))
.where(ExpenseClaim.id == "claim-bg-association")
)
assert claim is not None
assert all(not item.invoice_id for item in claim.items)
finally:
clear_attachment_association_jobs_for_tests()
get_settings.cache_clear()
def test_confirmation_match_does_not_repair_or_commit_unrelated_claim(
monkeypatch,
tmp_path,
) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
clear_attachment_association_jobs_for_tests()
try:
client, session_factory = build_client(monkeypatch)
with session_factory() as db:
employee = Employee(
id="emp-readonly-confirmation",
employee_no="E10001",
name="张三",
email="zhangsan@example.com",
)
claim = ExpenseClaim(
id="claim-readonly-confirmation",
claim_no="BX-20260101-READONLY",
employee_id=employee.id,
employee_name=employee.name,
department_name="交付部",
expense_type="travel",
reason="北京客户拜访",
location="北京",
amount=Decimal("100.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 1, 1, tzinfo=UTC),
status="submitted",
approval_stage="预算管理者审批",
risk_flags_json=[
{
"source": "manual_approval",
"event_type": "expense_claim_approval",
"previous_approval_stage": "直属领导审批",
"next_approval_stage": "预算管理者审批",
"operator": "同一审批人",
"next_approver_name": "同一审批人",
}
],
)
db.add_all([employee, claim])
db.commit()
current_user = CurrentUserContext(
tenant_id="default",
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
receipt_id = save_train_receipt(
service=ReceiptFolderService(),
current_user=current_user,
filename="4月20 武汉-上海.pdf",
route="武汉-上海",
trip_date="2026-04-20",
)
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
created = client.post(
"/api/v1/reimbursements/attachment-association-jobs",
headers=headers,
json={"receipt_ids": [receipt_id]},
).json()
result = client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{created['job_id']}",
headers=headers,
).json()
assert result["resolution"] == "requires_confirmation"
with session_factory() as db:
claim = db.get(ExpenseClaim, "claim-readonly-confirmation")
assert claim is not None
assert claim.approval_stage == "预算管理者审批"
assert len(claim.risk_flags_json) == 1
assert claim.risk_flags_json[0]["source"] == "manual_approval"
finally:
clear_attachment_association_jobs_for_tests()
get_settings.cache_clear()
def test_mixed_receipt_batch_requires_confirmation_without_writes(
monkeypatch,
tmp_path,
) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
clear_attachment_association_jobs_for_tests()
try:
client, session_factory = build_client(monkeypatch)
current_user = CurrentUserContext(
tenant_id="default",
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
with session_factory() as db:
draft = seed_travel_claim(db)
seed_approved_application_for_draft(db, draft)
matching_receipt_id = save_train_receipt(
service=ReceiptFolderService(),
current_user=current_user,
filename="2月20 武汉-上海.pdf",
route="武汉-上海",
trip_date="2026-02-20",
)
unrelated_receipt_id = save_train_receipt(
service=ReceiptFolderService(),
current_user=current_user,
filename="5月12 北京-广州.pdf",
route="北京-广州",
trip_date="2026-05-12",
)
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
created = client.post(
"/api/v1/reimbursements/attachment-association-jobs",
headers=headers,
json={"receipt_ids": [matching_receipt_id, unrelated_receipt_id]},
).json()
result = client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{created['job_id']}",
headers=headers,
).json()
assert result["resolution"] == "requires_confirmation"
assert "每一份" in result["exceptions"][0]
assert result["uploaded_count"] == 0
assert all(
ReceiptFolderService().get_receipt(receipt_id, current_user).status == "unlinked"
for receipt_id in (matching_receipt_id, unrelated_receipt_id)
)
with session_factory() as db:
assert (
db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_type == "receipt"))
is None
)
finally:
clear_attachment_association_jobs_for_tests()
get_settings.cache_clear()
def test_attachment_write_failure_restores_previous_directory_and_business_state(
monkeypatch,
tmp_path,
) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
clear_attachment_association_jobs_for_tests()
monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize)
monkeypatch.setattr(
ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments"
)
original_write_meta = ExpenseClaimAttachmentStorage.write_meta
def write_meta_then_fail(self, file_path, payload):
original_write_meta(self, file_path, payload)
raise OSError("simulated attachment metadata failure")
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "write_meta", write_meta_then_fail)
try:
client, session_factory = build_client(monkeypatch)
current_user = CurrentUserContext(
tenant_id="default",
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
with session_factory() as db:
draft = seed_travel_claim(db)
item_id = draft.items[0].id
item_dir = ExpenseClaimAttachmentStorage().build_item_dir(
"claim-bg-association",
item_id,
)
item_dir.mkdir(parents=True, exist_ok=True)
sentinel = item_dir / "existing-state.txt"
sentinel.write_text("keep-me", encoding="utf-8")
receipt_id = save_train_receipt(
service=ReceiptFolderService(),
current_user=current_user,
filename="2月20 武汉-上海.pdf",
route="武汉-上海",
trip_date="2026-02-20",
)
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
created = client.post(
"/api/v1/reimbursements/attachment-association-jobs",
headers=headers,
json={"receipt_ids": [receipt_id]},
).json()
result = client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{created['job_id']}",
headers=headers,
).json()
assert result["status"] == "failed"
assert "simulated attachment metadata failure" in result["error"]
assert sentinel.read_text(encoding="utf-8") == "keep-me"
assert [path.name for path in item_dir.iterdir()] == ["existing-state.txt"]
assert ReceiptFolderService().get_receipt(receipt_id, current_user).status == "unlinked"
with session_factory() as db:
claim = db.scalar(
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.items))
.where(ExpenseClaim.id == "claim-bg-association")
)
assert claim is not None
assert all(not item.invoice_id for item in claim.items)
assert (
db.scalar(select(BusinessEvent).where(BusinessEvent.aggregate_type == "receipt"))
is None
)
finally:
clear_attachment_association_jobs_for_tests()
get_settings.cache_clear()
def test_concurrent_jobs_for_same_receipt_are_serialized(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
clear_attachment_association_jobs_for_tests()
monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize)
monkeypatch.setattr(
ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments"
)
try:
_client, session_factory = build_client(monkeypatch)
current_user = CurrentUserContext(
tenant_id="default",
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
with session_factory() as db:
seed_travel_claim(db)
receipt_id = save_train_receipt(
service=ReceiptFolderService(),
current_user=current_user,
filename="2月20 武汉-上海.pdf",
route="武汉-上海",
trip_date="2026-02-20",
)
with session_factory() as db:
job_a = create_attachment_association_job(
AttachmentAssociationJobCreate(receipt_ids=[receipt_id]),
current_user,
db,
)
job_b = create_attachment_association_job(
AttachmentAssociationJobCreate(receipt_ids=[receipt_id]),
current_user,
db,
)
assert job_a.job_id == job_b.job_id
original_associate = ExpenseReceiptAssociationService.associate
active_lock = Lock()
active_count = 0
max_active_count = 0
def tracked_associate(self, **kwargs):
nonlocal active_count, max_active_count
with active_lock:
active_count += 1
max_active_count = max(max_active_count, active_count)
sleep(0.05)
try:
return original_associate(self, **kwargs)
finally:
with active_lock:
active_count -= 1
monkeypatch.setattr(ExpenseReceiptAssociationService, "associate", tracked_associate)
threads = [
Thread(
target=run_attachment_association_job,
args=(job_id, session_factory),
)
for job_id in (job_a.job_id, job_b.job_id)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=5)
assert not thread.is_alive()
with session_factory() as db:
result = get_attachment_association_job(job_a.job_id, current_user, db)
assert max_active_count == 1
assert result is not None and result.status == "succeeded"
assert result.uploaded_count == 1
assert result.skipped_count == 0
assert ReceiptFolderService().get_receipt(receipt_id, current_user).status == "linked"
with session_factory() as db:
claim = db.scalar(
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.items))
.where(ExpenseClaim.id == "claim-bg-association")
)
assert claim is not None
attached_items = [item for item in claim.items if item.invoice_id]
assert len(attached_items) == 1
attachment_path = ExpenseClaimAttachmentStorage().resolve_item_path(attached_items[0])
assert attachment_path is not None and attachment_path.exists()
events = list(
db.scalars(
select(BusinessEvent).where(BusinessEvent.aggregate_type == "receipt")
).all()
)
assert len(events) == 2
finally:
clear_attachment_association_jobs_for_tests()
get_settings.cache_clear()
def test_concurrent_receipts_for_same_claim_are_serialized(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
clear_attachment_association_jobs_for_tests()
monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize)
monkeypatch.setattr(
ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments"
)
try:
_client, session_factory = build_client(monkeypatch)
current_user = CurrentUserContext(
tenant_id="default",
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
with session_factory() as db:
seed_travel_claim(db)
receipt_ids = [
save_train_receipt(
service=ReceiptFolderService(),
current_user=current_user,
filename=filename,
route="武汉-上海",
trip_date="2026-02-20",
)
for filename in ("并发票据-A.pdf", "并发票据-B.pdf")
]
with session_factory() as db:
jobs = [
create_attachment_association_job(
AttachmentAssociationJobCreate(receipt_ids=[receipt_id]),
current_user,
db,
)
for receipt_id in receipt_ids
]
assert jobs[0].job_id != jobs[1].job_id
original_associate_matched = ExpenseReceiptAssociationService._associate_matched
active_lock = Lock()
active_count = 0
max_active_count = 0
def tracked_associate_matched(self, **kwargs):
nonlocal active_count, max_active_count
with active_lock:
active_count += 1
max_active_count = max(max_active_count, active_count)
sleep(0.05)
try:
return original_associate_matched(self, **kwargs)
finally:
with active_lock:
active_count -= 1
monkeypatch.setattr(
ExpenseReceiptAssociationService,
"_associate_matched",
tracked_associate_matched,
)
threads = [
Thread(
target=run_attachment_association_job,
args=(job.job_id, session_factory),
)
for job in jobs
]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=5)
assert not thread.is_alive()
with session_factory() as db:
results = [get_attachment_association_job(job.job_id, current_user, db) for job in jobs]
claim = db.scalar(
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.items))
.where(ExpenseClaim.id == "claim-bg-association")
)
events = list(
db.scalars(
select(BusinessEvent).where(BusinessEvent.aggregate_type == "receipt")
).all()
)
receipt_links = list(
db.scalars(
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_type == "receipt")
).all()
)
assert max_active_count == 1
assert all(result is not None and result.status == "succeeded" for result in results)
assert all(result.resolution == "auto_associated" for result in results if result)
assert all(result.uploaded_count == 1 for result in results if result)
assert claim is not None
attached_items = [item for item in claim.items if item.invoice_id]
assert len(attached_items) == 2
assert claim.invoice_count == 2
assert len({item.invoice_id for item in attached_items}) == 2
assert all(
(path := ExpenseClaimAttachmentStorage().resolve_item_path(item)) is not None
and path.exists()
for item in attached_items
)
assert len(events) == 4
assert len(receipt_links) == 2
assert {(event.aggregate_id, event.event_type) for event in events} == {
(receipt_id, event_type)
for receipt_id in receipt_ids
for event_type in ("receipt_received", "attachment_associated")
}
linked_receipts = [
ReceiptFolderService().get_receipt(receipt_id, current_user)
for receipt_id in receipt_ids
]
assert all(receipt.status == "linked" for receipt in linked_receipts)
assert all(receipt.linked_claim_id == "claim-bg-association" for receipt in linked_receipts)
assert (
len(
{
str((receipt.raw_meta or {}).get("linked_item_id") or "")
for receipt in linked_receipts
}
)
== 2
)
finally:
clear_attachment_association_jobs_for_tests()
get_settings.cache_clear()
def test_persistent_job_resumes_after_process_state_is_cleared(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
clear_attachment_association_jobs_for_tests()
monkeypatch.setattr(OcrService, "recognize_files", fake_ocr_recognize)
monkeypatch.setattr(
ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path / "attachments"
)
try:
client, session_factory = build_client(monkeypatch)
current_user = CurrentUserContext(
tenant_id="default",
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
with session_factory() as db:
seed_travel_claim(db)
receipt_id = save_train_receipt(
service=ReceiptFolderService(),
current_user=current_user,
filename="2月20 武汉-上海.pdf",
route="武汉-上海",
trip_date="2026-02-20",
)
with session_factory() as db:
queued_job = create_attachment_association_job(
AttachmentAssociationJobCreate(receipt_ids=[receipt_id]),
current_user,
db,
)
persisted_job = db.get(AttachmentAssociationJob, queued_job.job_id)
assert persisted_job is not None
persisted_job.status = "running"
persisted_job.attempt_count = 1
persisted_job.lease_expires_at = datetime.now(UTC) - timedelta(seconds=1)
db.commit()
clear_attachment_association_jobs_for_tests()
headers = {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
}
first_response = client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{queued_job.job_id}",
headers=headers,
)
assert first_response.status_code == 200
assert first_response.json()["status"] == "running"
second_response = client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{queued_job.job_id}",
headers=headers,
)
assert second_response.status_code == 200
assert second_response.json()["status"] == "succeeded"
assert second_response.json()["uploaded_count"] == 1
finally:
clear_attachment_association_jobs_for_tests()
get_settings.cache_clear()
def test_expired_worker_cannot_overwrite_newer_job_attempt(monkeypatch) -> None:
clear_attachment_association_jobs_for_tests()
try:
_client, session_factory = build_client(monkeypatch)
current_user = CurrentUserContext(
tenant_id="default",
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
with session_factory() as db:
created = create_attachment_association_job(
AttachmentAssociationJobCreate(receipt_ids=["lease-fencing-receipt"]),
current_user,
db,
)
first_attempt = claim_persistent_job(db, created.job_id)
assert first_attempt is not None and first_attempt.attempt_count == 1
job = db.get(AttachmentAssociationJob, created.job_id)
assert job is not None
job.lease_expires_at = datetime.now(UTC) - timedelta(seconds=1)
db.commit()
with session_factory() as db:
second_attempt = claim_persistent_job(db, created.job_id)
assert second_attempt is not None and second_attempt.attempt_count == 2
with session_factory() as db:
update_persistent_job(
db,
created.job_id,
expected_attempt_count=1,
status="failed",
resolution="failed",
error="stale worker must not win",
)
current = db.get(AttachmentAssociationJob, created.job_id)
assert current is not None
assert current.status == "running"
assert current.attempt_count == 2
assert current.error == ""
assert current.lease_expires_at is not None
with session_factory() as db:
update_persistent_job(
db,
created.job_id,
expected_attempt_count=2,
status="succeeded",
resolution="auto_associated",
message="new worker succeeded",
error="",
)
current = db.get(AttachmentAssociationJob, created.job_id)
assert current is not None
assert current.status == "succeeded"
assert current.attempt_count == 2
assert current.lease_expires_at is None
assert current.message == "new worker succeeded"
with session_factory() as db:
update_persistent_job(
db,
created.job_id,
expected_attempt_count=2,
status="failed",
resolution="failed",
message="late callback",
error="late callback",
)
current = db.get(AttachmentAssociationJob, created.job_id)
assert current is not None
assert current.status == "succeeded"
assert current.resolution == "auto_associated"
assert current.message == "new worker succeeded"
assert current.error == ""
finally:
clear_attachment_association_jobs_for_tests()
def test_failed_job_creates_new_generation_without_rewriting_history(monkeypatch) -> None:
clear_attachment_association_jobs_for_tests()
try:
_client, session_factory = build_client(monkeypatch)
current_user = CurrentUserContext(
tenant_id="default",
username="zhangsan@example.com",
name="张三",
role_codes=["user"],
is_admin=False,
employee_no="E10001",
)
payload = AttachmentAssociationJobCreate(receipt_ids=["failed-generation-receipt"])
with session_factory() as db:
first = create_attachment_association_job(payload, current_user, db)
claimed = claim_persistent_job(db, first.job_id)
assert claimed is not None
update_persistent_job(
db,
first.job_id,
expected_attempt_count=claimed.attempt_count,
status="failed",
resolution="failed",
message="generation one failed",
error="generation one failed",
)
with session_factory() as db:
second = create_attachment_association_job(payload, current_user, db)
jobs = list(
db.scalars(
select(AttachmentAssociationJob).order_by(AttachmentAssociationJob.generation)
).all()
)
assert second.job_id != first.job_id
assert second.status == "queued"
assert [(job.generation, job.status) for job in jobs] == [
(1, "failed"),
(2, "queued"),
]
assert jobs[0].message == "generation one failed"
assert jobs[0].error == "generation one failed"
finally:
clear_attachment_association_jobs_for_tests()
def test_attachment_association_job_and_receipts_are_tenant_scoped(monkeypatch, tmp_path) -> None:
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
clear_attachment_association_jobs_for_tests()
try:
client, _session_factory = build_client(monkeypatch)
tenant_a_user = CurrentUserContext(
username="shared@example.com",
name="同名用户",
role_codes=["user"],
is_admin=False,
tenant_id="tenant-a",
employee_no="EA001",
)
receipt_id = save_train_receipt(
service=ReceiptFolderService(),
current_user=tenant_a_user,
filename="tenant-a-ticket.pdf",
route="武汉-上海",
trip_date="2026-02-20",
)
tenant_a_headers = {
"x-auth-username": "shared@example.com",
"x-auth-name": "Same User",
"x-auth-tenant-id": "tenant-a",
"x-auth-employee-no": "EA001",
"x-auth-role-codes": "user",
}
tenant_b_headers = {**tenant_a_headers, "x-auth-tenant-id": "tenant-b"}
created = client.post(
"/api/v1/reimbursements/attachment-association-jobs",
headers=tenant_a_headers,
json={"receipt_ids": [receipt_id]},
)
assert created.status_code == 202
job_id = created.json()["job_id"]
assert (
client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{job_id}",
headers=tenant_b_headers,
).status_code
== 404
)
assert (
client.get(
f"/api/v1/reimbursements/attachment-association-jobs/{job_id}",
headers={**tenant_b_headers, "x-auth-is-admin": "true"},
).status_code
== 404
)
tenant_b_user = CurrentUserContext(
username="shared@example.com",
name="同名用户",
role_codes=["user"],
is_admin=False,
tenant_id="tenant-b",
employee_no="EA001",
)
try:
ReceiptFolderService().get_receipt(receipt_id, tenant_b_user)
except FileNotFoundError:
pass
else:
raise AssertionError("跨租户不应读取同用户名票据")
collision_a_user = CurrentUserContext(
username="collision@example.com",
name="命名空间碰撞用户",
role_codes=["user"],
is_admin=False,
tenant_id="tenant/a",
employee_no="EA002",
)
collision_receipt_id = save_train_receipt(
service=ReceiptFolderService(),
current_user=collision_a_user,
filename="collision-ticket.pdf",
route="武汉-上海",
trip_date="2026-02-20",
)
collision_b_user = CurrentUserContext(
username="collision@example.com",
name="命名空间碰撞用户",
role_codes=["user"],
is_admin=False,
tenant_id="tenant?a",
employee_no="EA002",
)
try:
ReceiptFolderService().get_receipt(collision_receipt_id, collision_b_user)
except FileNotFoundError:
pass
else:
raise AssertionError("规范化后同名的不同租户也不应共享票据目录")
finally:
clear_attachment_association_jobs_for_tests()
get_settings.cache_clear()