feat: 同步报销流程与工作台改动

This commit is contained in:
caoxiaozhu
2026-06-09 08:32:00 +00:00
parent e124e4bbcb
commit 25724c354f
64 changed files with 6518 additions and 687 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.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claims import ExpenseClaimService
from app.services.expense_claim_workflow_constants import (
@@ -119,6 +120,42 @@ def build_session() -> Session:
return session_factory()
def test_append_budget_flags_replaces_duplicate_budget_warning() -> None:
base_warning = {
"source": "budget_control",
"event_type": "budget_warning",
"severity": "medium",
"label": "预算接近预警线",
"message": "预算 SIM-BUD-2026-R0048 本次占用后使用率预计达到 98.00%,已达到预警线 80.00%",
"budget_no": "SIM-BUD-2026-R0048",
"allocation_id": "allocation-0048",
"subject_code": "travel",
"created_at": "2026-06-03T10:00:00+00:00",
}
latest_warning = {
**base_warning,
"message": "预算 SIM-BUD-2026-R0048 本次占用后使用率预计达到 99.27%,已达到预警线 80.00%",
"created_at": "2026-06-03T10:01:00+00:00",
}
flags = ExpenseClaimBudgetFlowMixin._append_budget_flags(
[base_warning],
latest_warning,
business_stage="reimbursement",
)
warnings = [
flag
for flag in flags
if isinstance(flag, dict)
and flag.get("source") == "budget_control"
and flag.get("event_type") == "budget_warning"
]
assert len(warnings) == 1
assert "99.27%" in warnings[0]["message"]
assert warnings[0]["business_stage"] == "reimbursement"
def _count_claims(db: Session) -> int:
return int(db.query(ExpenseClaim).count())
@@ -2360,6 +2397,112 @@ def test_upload_hotel_attachment_flags_amount_over_travel_policy(monkeypatch, tm
)
def test_delete_claim_item_attachment_removes_attachment_analysis_risk(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-hotel-risk@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="hotel-risk.png",
media_type="image/png",
text="北京全季酒店 住宿 1晚 金额800元 2026-05-13",
summary="北京全季酒店住宿发票,住宿 1 晚,金额 800 元。",
avg_score=0.98,
line_count=1,
page_count=1,
document_type="hotel_invoice",
document_type_label="酒店住宿票据",
scene_code="hotel",
scene_label="住宿票据",
document_fields=[
{"key": "merchant_name", "label": "商户", "value": "北京全季酒店"},
{"key": "amount", "label": "金额", "value": "800元"},
{"key": "date", "label": "日期", "value": "2026-05-13"},
],
)
],
)
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
with build_session() as db:
employee = Employee(
employee_no="E7402",
name="张三",
email="emp-hotel-risk@example.com",
grade="P4",
)
db.add(employee)
db.flush()
claim = build_claim(expense_type="travel", location="北京")
claim.employee = employee
claim.employee_id = employee.id
claim.reason = "北京客户现场出差"
claim.invoice_count = 0
claim.items[0].item_type = "hotel"
claim.items[0].item_reason = "北京住宿"
claim.items[0].item_location = "北京"
claim.items[0].item_amount = Decimal("0.00")
claim.items[0].invoice_id = None
db.add(claim)
db.commit()
service = ExpenseClaimService(db)
upload_payload = service.upload_claim_item_attachment(
claim_id=claim.id,
item_id=claim.items[0].id,
filename="hotel-risk.png",
content=b"fake-image-bytes",
media_type="image/png",
current_user=current_user,
)
assert upload_payload is not None
assert any(
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
for flag in upload_payload["claim_risk_flags"]
)
delete_payload = service.delete_claim_item_attachment(
claim_id=claim.id,
item_id=claim.items[0].id,
current_user=current_user,
)
assert delete_payload is not None
assert delete_payload["invoice_id"] is None
assert not any(
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
for flag in delete_payload["claim_risk_flags"]
)
assert not any(
"高风险附件" in str(flag.get("message") or "")
for flag in delete_payload["claim_risk_flags"]
if isinstance(flag, dict)
)
db.refresh(claim)
assert claim.invoice_count == 0
assert claim.items[0].invoice_id is None
assert not any(
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis"
for flag in list(claim.risk_flags_json or [])
)
def test_attachment_analysis_does_not_compare_business_purpose_with_ticket_scene() -> None:
with build_session() as db:
claim = build_claim(expense_type="travel", location="上海")
@@ -3741,6 +3884,101 @@ def test_list_claims_returns_company_reimbursements_for_finance_document_center(
assert "EXP-FIN-COMPANY-PAID" in archived_nos
def test_list_claims_returns_all_active_documents_for_admin_document_center() -> None:
current_user = CurrentUserContext(
username="admin",
name="admin",
role_codes=["admin"],
is_admin=True,
)
with build_session() as db:
db.add_all(
[
ExpenseClaim(
claim_no="AP-ADMIN-DRAFT",
employee_name="Applicant A",
department_name="Tech",
project_code="PRJ-APP",
expense_type="travel_application",
reason="Travel application draft",
location="Shanghai",
amount=Decimal("1200.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="draft",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="AP-ADMIN-LINKING",
employee_name="Applicant B",
department_name="Tech",
project_code="PRJ-APP",
expense_type="travel_application",
reason="Travel application approved",
location="Beijing",
amount=Decimal("2200.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
status="approved",
approval_stage=APPLICATION_LINK_STATUS_STAGE,
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-ADMIN-DRAFT",
employee_name="Applicant C",
department_name="Finance",
project_code="PRJ-EXP",
expense_type="office",
reason="Office draft",
location="Hangzhou",
amount=Decimal("300.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 13, 9, 0, tzinfo=UTC),
submitted_at=None,
status="draft",
approval_stage="draft",
risk_flags_json=[],
),
ExpenseClaim(
claim_no="EXP-ADMIN-ARCHIVED",
employee_name="Applicant D",
department_name="Finance",
project_code="PRJ-EXP",
expense_type="office",
reason="Archived reimbursement",
location="Hangzhou",
amount=Decimal("500.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 5, 14, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 14, 10, 0, tzinfo=UTC),
status="paid",
approval_stage="payment",
risk_flags_json=[],
),
]
)
db.commit()
claim_nos = {claim.claim_no for claim in ExpenseClaimService(db).list_claims(current_user)}
archived_nos = {
claim.claim_no for claim in ExpenseClaimService(db).list_archived_claims(current_user)
}
assert "AP-ADMIN-DRAFT" in claim_nos
assert "AP-ADMIN-LINKING" in claim_nos
assert "EXP-ADMIN-DRAFT" in claim_nos
assert "EXP-ADMIN-ARCHIVED" not in claim_nos
assert "EXP-ADMIN-ARCHIVED" in archived_nos
def test_list_claims_limits_executive_to_personal_records() -> None:
current_user = CurrentUserContext(
username="executive@example.com",