feat: 增加差旅报销标准测算和财务终审流程
新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分 直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层 缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流 交互并补充单元测试。
This commit is contained in:
@@ -16,6 +16,7 @@ from app.models.organization import OrganizationUnit
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
|
||||
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
from app.services.ocr import OcrService
|
||||
@@ -722,6 +723,82 @@ def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path)
|
||||
assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"])
|
||||
|
||||
|
||||
def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_path) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
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="train-ticket.png",
|
||||
media_type="image/png",
|
||||
text="中国铁路电子客票 广州南-北京南 二等座 票价:¥354.00",
|
||||
summary="铁路电子客票,票价 354 元。",
|
||||
avg_score=0.98,
|
||||
line_count=1,
|
||||
page_count=1,
|
||||
document_type="train_ticket",
|
||||
document_type_label="火车/高铁票",
|
||||
scene_code="travel",
|
||||
scene_label="差旅费",
|
||||
document_fields=[
|
||||
{"key": "fare", "label": "票价", "value": "¥354.00"},
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
||||
monkeypatch.setattr(ExpenseClaimService, "_get_attachment_storage_root", lambda self: tmp_path)
|
||||
|
||||
with build_session() as db:
|
||||
claim = build_claim(expense_type="travel", location="北京")
|
||||
claim.amount = Decimal("0.00")
|
||||
claim.invoice_count = 0
|
||||
claim.items[0].item_amount = Decimal("0.00")
|
||||
claim.items[0].invoice_id = None
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
updated = service.upload_claim_item_attachment(
|
||||
claim_id=claim.id,
|
||||
item_id=claim.items[0].id,
|
||||
filename="train-ticket.png",
|
||||
content=b"fake-image-bytes",
|
||||
media_type="image/png",
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
assert updated is not None
|
||||
assert updated["item_amount"] == Decimal("354.00")
|
||||
assert updated["claim_amount"] == Decimal("354.00")
|
||||
db.refresh(claim)
|
||||
assert claim.items[0].item_amount == Decimal("354.00")
|
||||
assert claim.amount == Decimal("354.00")
|
||||
uploaded_meta = service.get_claim_item_attachment_meta(
|
||||
claim_id=claim.id,
|
||||
item_id=claim.items[0].id,
|
||||
current_user=current_user,
|
||||
)
|
||||
assert uploaded_meta is not None
|
||||
assert uploaded_meta["document_info"]["document_type"] == "train_ticket"
|
||||
assert any(
|
||||
field["label"] == "票价" and field["value"] == "¥354.00"
|
||||
for field in uploaded_meta["document_info"]["fields"]
|
||||
)
|
||||
|
||||
|
||||
def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_path) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
@@ -1502,7 +1579,7 @@ def test_list_claims_allows_executive_to_view_all_records() -> None:
|
||||
assert {claim.claim_no for claim in claims} == {"EXP-EXE-101", "EXP-EXE-102"}
|
||||
|
||||
|
||||
def test_privileged_user_can_return_and_delete_submitted_claim() -> None:
|
||||
def test_finance_can_return_but_cannot_delete_submitted_claim() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance@example.com",
|
||||
name="财务",
|
||||
@@ -1545,10 +1622,46 @@ def test_privileged_user_can_return_and_delete_submitted_claim() -> None:
|
||||
for flag in returned.risk_flags_json
|
||||
)
|
||||
|
||||
deleted = service.delete_claim(claim_id, current_user)
|
||||
with pytest.raises(ValueError, match="只有高级管理人员可以删除"):
|
||||
service.delete_claim(claim_id, current_user)
|
||||
|
||||
assert db.get(ExpenseClaim, claim_id) is not None
|
||||
|
||||
|
||||
def test_executive_can_delete_submitted_claim() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="executive-delete@example.com",
|
||||
name="高管",
|
||||
role_codes=["executive"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-DEL-EXEC-101",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel",
|
||||
reason="差旅报销",
|
||||
location="上海",
|
||||
amount=Decimal("120.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="财务审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
||||
|
||||
assert deleted is not None
|
||||
assert deleted.claim_no == "EXP-RET-101"
|
||||
assert deleted.claim_no == "EXP-DEL-EXEC-101"
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
|
||||
@@ -1675,6 +1788,56 @@ def test_direct_manager_can_approve_subordinate_claim_to_finance_review() -> Non
|
||||
)
|
||||
|
||||
|
||||
def test_finance_can_approve_claim_to_archive_stage() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-approve@example.com",
|
||||
name="财务复核",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-FIN-APP-201",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="transport",
|
||||
reason="交通报销",
|
||||
location="上海",
|
||||
amount=Decimal("66.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="财务审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
opinion="票据与明细一致,同意入账。",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
assert approved.approval_stage == "归档入账"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "finance_approval"
|
||||
and flag.get("event_type") == "expense_claim_finance_approval"
|
||||
and flag.get("opinion") == "票据与明细一致,同意入账。"
|
||||
and flag.get("previous_approval_stage") == "财务审批"
|
||||
and flag.get("next_approval_stage") == "归档入账"
|
||||
for flag in approved.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_return_claim_rejects_already_returned_claim_without_adding_event() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-returned@example.com",
|
||||
@@ -1836,6 +1999,16 @@ def test_submit_returned_claim_preserves_manual_return_events() -> None:
|
||||
claim.risk_flags_json = [return_flag]
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
conversation = AgentConversationService(db).get_or_create_conversation(
|
||||
conversation_id=None,
|
||||
user_id=current_user.username,
|
||||
source="user_message",
|
||||
context_json={
|
||||
"session_type": "expense",
|
||||
"draft_claim_id": claim.id,
|
||||
},
|
||||
)
|
||||
conversation_id = conversation.conversation_id
|
||||
|
||||
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
|
||||
|
||||
@@ -1848,6 +2021,7 @@ def test_submit_returned_claim_preserves_manual_return_events() -> None:
|
||||
and flag.get("return_event_id") == "return-event-submit"
|
||||
for flag in list(submitted.risk_flags_json or [])
|
||||
)
|
||||
assert AgentConversationService(db).get_conversation(conversation_id) is None
|
||||
|
||||
|
||||
def test_manager_personal_claims_exclude_subordinate_pending_approval_claims() -> None:
|
||||
@@ -2001,3 +2175,57 @@ def test_list_approval_claims_allows_direct_manager_to_view_pending_claims_for_a
|
||||
|
||||
assert len(claims) == 1
|
||||
assert claims[0].claim_no == "EXP-MGR-201"
|
||||
|
||||
|
||||
def test_list_approval_claims_limits_finance_to_finance_stage_claims() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-approval-list@example.com",
|
||||
name="财务",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
db.add_all(
|
||||
[
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-FIN-LIST-201",
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-FIN",
|
||||
expense_type="transport",
|
||||
reason="直属领导待审",
|
||||
location="上海",
|
||||
amount=Decimal("66.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-FIN-LIST-202",
|
||||
employee_name="李四",
|
||||
department_name="销售部",
|
||||
project_code="PRJ-FIN",
|
||||
expense_type="meal",
|
||||
reason="财务待审",
|
||||
location="杭州",
|
||||
amount=Decimal("188.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 12, 12, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 12, 13, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="财务审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
claims = ExpenseClaimService(db).list_approval_claims(current_user)
|
||||
|
||||
assert [claim.claim_no for claim in claims] == ["EXP-FIN-LIST-202"]
|
||||
|
||||
Reference in New Issue
Block a user