feat: 完善报销单审批流程及退回原因追踪
新增直属领导审批通过接口和审批待办列表查询,报销单退回 支持原因码分类和审批环节标记,优化票据附件去重和路径 回退查找,前端新增退回原因对话框、审批收件箱和工作台 图标组件,补充工具函数和单元测试覆盖。
This commit is contained in:
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
@@ -110,6 +111,19 @@ def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> N
|
||||
assert expense_type == "office"
|
||||
|
||||
|
||||
def test_resolve_expense_type_maps_riding_fare_review_value_to_transport() -> None:
|
||||
expense_type = ExpenseClaimService._resolve_expense_type(
|
||||
[],
|
||||
context_json={
|
||||
"review_form_values": {
|
||||
"expense_type": "乘车费用"
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert expense_type == "transport"
|
||||
|
||||
|
||||
def test_upsert_draft_from_ontology_defers_multi_document_association_choice() -> None:
|
||||
user_id = "zhangsan@example.com"
|
||||
|
||||
@@ -238,6 +252,48 @@ def test_upsert_draft_from_ontology_keeps_reason_missing_for_attachment_only_upl
|
||||
assert claim.reason == "待补充"
|
||||
|
||||
|
||||
def test_upsert_draft_from_ontology_strips_recognized_business_time_from_reason() -> None:
|
||||
user_id = "transport-time@example.com"
|
||||
message = "业务发生时间:2026-03-04,送客户去林萃小区办事,请报销乘车费用"
|
||||
|
||||
with build_session() as db:
|
||||
employee = Employee(
|
||||
employee_no="E5004",
|
||||
name="赵六",
|
||||
email=user_id,
|
||||
)
|
||||
db.add(employee)
|
||||
db.commit()
|
||||
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=message,
|
||||
user_id=user_id,
|
||||
)
|
||||
)
|
||||
result = ExpenseClaimService(db).upsert_draft_from_ontology(
|
||||
run_id=ontology.run_id,
|
||||
user_id=user_id,
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json={
|
||||
"name": "赵六",
|
||||
"user_input_text": message,
|
||||
},
|
||||
)
|
||||
|
||||
claim = db.get(ExpenseClaim, result["claim_id"])
|
||||
assert claim is not None
|
||||
assert claim.occurred_at.date() == date(2026, 3, 4)
|
||||
assert claim.reason == "送客户去林萃小区办事,请报销乘车费用"
|
||||
assert len(claim.items) == 1
|
||||
assert claim.items[0].item_date == date(2026, 3, 4)
|
||||
assert claim.items[0].item_reason == "送客户去林萃小区办事,请报销乘车费用"
|
||||
assert "客户单位" not in result["message"]
|
||||
assert "票据附件" not in result["message"]
|
||||
assert "费用明细" not in result["message"]
|
||||
|
||||
|
||||
def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents() -> None:
|
||||
user_id = "lisi@example.com"
|
||||
|
||||
@@ -348,6 +404,100 @@ def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents(
|
||||
assert float(new_claim.amount) == 50.5
|
||||
|
||||
|
||||
def test_upsert_draft_from_ontology_updates_returned_claim_and_preserves_return_events() -> None:
|
||||
user_id = "returned-owner@example.com"
|
||||
return_flag = {
|
||||
"source": "manual_return",
|
||||
"return_event_id": "return-event-1",
|
||||
"message": "第一次退回:附件缺失。",
|
||||
"reason": "附件缺失。",
|
||||
"return_count": 1,
|
||||
"return_stage": "直属领导审批",
|
||||
"return_stage_key": "direct_manager",
|
||||
"risk_points": ["附件缺失或不清晰"],
|
||||
}
|
||||
|
||||
with build_session() as db:
|
||||
employee = Employee(
|
||||
employee_no="E5004",
|
||||
name="赵六",
|
||||
email=user_id,
|
||||
)
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
existing_claim = ExpenseClaim(
|
||||
claim_no="EXP-202605-012",
|
||||
employee_id=employee.id,
|
||||
employee_name="赵六",
|
||||
department_name="市场部",
|
||||
project_code=None,
|
||||
expense_type="transport",
|
||||
reason="原有交通报销",
|
||||
location="上海",
|
||||
amount=Decimal("20.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
|
||||
status="returned",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[return_flag],
|
||||
)
|
||||
existing_claim.items = [
|
||||
ExpenseClaimItem(
|
||||
claim_id=existing_claim.id,
|
||||
item_date=date(2026, 5, 13),
|
||||
item_type="transport",
|
||||
item_reason="原有交通报销",
|
||||
item_location="上海",
|
||||
item_amount=Decimal("20.00"),
|
||||
invoice_id="old-trip.png",
|
||||
)
|
||||
]
|
||||
db.add(existing_claim)
|
||||
db.commit()
|
||||
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="我补充了交通票据,更新这张退回单据",
|
||||
user_id=user_id,
|
||||
)
|
||||
)
|
||||
ontology.risk_flags = ["系统识别:票据金额待人工核对。"]
|
||||
|
||||
result = ExpenseClaimService(db).upsert_draft_from_ontology(
|
||||
run_id=ontology.run_id,
|
||||
user_id=user_id,
|
||||
message="我补充了交通票据,更新这张退回单据",
|
||||
ontology=ontology,
|
||||
context_json={
|
||||
"name": "赵六",
|
||||
"draft_claim_id": existing_claim.id,
|
||||
"attachment_names": ["new-trip.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "new-trip.png",
|
||||
"summary": "滴滴出行 支付金额 32 元",
|
||||
"text": "滴滴出行 支付金额 32 元",
|
||||
"document_type": "taxi_receipt",
|
||||
"scene_code": "transport",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
db.refresh(existing_claim)
|
||||
assert result["claim_id"] == existing_claim.id
|
||||
assert existing_claim.status == "draft"
|
||||
assert "系统识别:票据金额待人工核对。" in existing_claim.risk_flags_json
|
||||
manual_returns = [
|
||||
flag
|
||||
for flag in list(existing_claim.risk_flags_json or [])
|
||||
if isinstance(flag, dict) and flag.get("source") == "manual_return"
|
||||
]
|
||||
assert manual_returns == [return_flag]
|
||||
|
||||
|
||||
def test_generate_claim_no_uses_max_suffix_instead_of_count() -> None:
|
||||
with build_session() as db:
|
||||
db.add_all(
|
||||
@@ -642,6 +792,44 @@ def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_pat
|
||||
assert not attachment_root.exists()
|
||||
|
||||
|
||||
def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-1",
|
||||
name="张三",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(ExpenseClaimService, "_get_attachment_storage_root", lambda self: tmp_path)
|
||||
|
||||
with build_session() as db:
|
||||
claim = build_claim(expense_type="transport", location="上海")
|
||||
claim.items[0].invoice_id = "legacy-ticket.pdf"
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
attachment_dir = tmp_path / claim.id / claim.items[0].id
|
||||
attachment_dir.mkdir(parents=True)
|
||||
file_path = attachment_dir / "legacy-ticket.pdf"
|
||||
file_path.write_bytes(b"legacy-pdf-bytes")
|
||||
(attachment_dir / "legacy-ticket.pdf.meta.json").write_text(
|
||||
'{"file_name":"legacy-ticket.pdf","media_type":"application/pdf","previewable":true}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
payload = ExpenseClaimService(db).get_claim_item_attachment_preview_content(
|
||||
claim_id=claim.id,
|
||||
item_id=claim.items[0].id,
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
assert payload is not None
|
||||
resolved_path, media_type, filename = payload
|
||||
assert resolved_path == file_path
|
||||
assert media_type == "application/pdf"
|
||||
assert filename == "legacy-ticket.pdf"
|
||||
|
||||
|
||||
def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-submit@example.com",
|
||||
@@ -677,6 +865,43 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
|
||||
assert submitted.submitted_at is not None
|
||||
|
||||
|
||||
def test_submit_claim_allows_returned_claim_to_be_resubmitted() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-submit@example.com",
|
||||
name="张三",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="E7100",
|
||||
name="李经理",
|
||||
email="manager-returned@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E7101",
|
||||
name="张三",
|
||||
email="emp-submit@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
claim = build_claim(expense_type="transport", location="上海")
|
||||
claim.employee = employee
|
||||
claim.employee_id = employee.id
|
||||
claim.status = "returned"
|
||||
claim.approval_stage = "待补充"
|
||||
claim.items[0].invoice_id = "taxi-ticket.png"
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
|
||||
|
||||
assert submitted is not None
|
||||
assert submitted.status == "submitted"
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
assert submitted.submitted_at is not None
|
||||
|
||||
|
||||
def test_submit_claim_backfills_department_from_current_employee() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-dept@example.com",
|
||||
@@ -1327,7 +1552,377 @@ def test_privileged_user_can_return_and_delete_submitted_claim() -> None:
|
||||
assert db.get(ExpenseClaim, claim_id) is None
|
||||
|
||||
|
||||
def test_list_claims_allows_direct_manager_to_view_pending_claims_for_approval() -> None:
|
||||
def test_direct_manager_can_return_subordinate_claim_to_pending_submission() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager-return@example.com",
|
||||
name="李经理",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="E8100",
|
||||
name="李经理",
|
||||
email="manager-return@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E8101",
|
||||
name="张三",
|
||||
email="zhangsan-return@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
db.add_all([manager, employee])
|
||||
db.flush()
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-RET-201",
|
||||
employee_id=employee.id,
|
||||
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
|
||||
|
||||
returned = ExpenseClaimService(db).return_claim(claim_id, current_user, reason="请补充行程说明")
|
||||
|
||||
assert returned is not None
|
||||
assert returned.status == "returned"
|
||||
assert returned.approval_stage == "待提交"
|
||||
assert returned.submitted_at is None
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_return"
|
||||
and flag.get("message") == "请补充行程说明"
|
||||
for flag in returned.risk_flags_json
|
||||
)
|
||||
|
||||
|
||||
def test_direct_manager_can_approve_subordinate_claim_to_finance_review() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager-approve@example.com",
|
||||
name="李经理",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="E8110",
|
||||
name="李经理",
|
||||
email="manager-approve@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E8111",
|
||||
name="张三",
|
||||
email="zhangsan-approve@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
db.add_all([manager, employee])
|
||||
db.flush()
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-APP-201",
|
||||
employee_id=employee.id,
|
||||
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 == "submitted"
|
||||
assert approved.approval_stage == "财务审批"
|
||||
assert approved.submitted_at is not None
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_approval"
|
||||
and flag.get("event_type") == "expense_claim_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",
|
||||
name="财务",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
return_flag = {
|
||||
"source": "manual_return",
|
||||
"return_event_id": "return-event-existing",
|
||||
"message": "请补充附件。",
|
||||
"return_count": 1,
|
||||
"return_stage": "直属领导审批",
|
||||
"return_stage_key": "direct_manager",
|
||||
}
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-RET-202",
|
||||
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=None,
|
||||
status="returned",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[return_flag],
|
||||
)
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
with pytest.raises(ValueError, match="无需重复退回"):
|
||||
ExpenseClaimService(db).return_claim(claim_id, current_user, reason="重复退回")
|
||||
|
||||
db.refresh(claim)
|
||||
manual_returns = [
|
||||
flag
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
if isinstance(flag, dict) and flag.get("source") == "manual_return"
|
||||
]
|
||||
assert manual_returns == [return_flag]
|
||||
|
||||
|
||||
def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-return@example.com",
|
||||
name="财务复核",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
claim = ExpenseClaim(
|
||||
claim_no="EXP-RET-301",
|
||||
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
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
first_returned = service.return_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
reason="发票金额与明细金额不一致,请重新核对。",
|
||||
reason_codes=["invoice_mismatch", "business_explanation"],
|
||||
)
|
||||
assert first_returned is not None
|
||||
|
||||
first_returned.status = "submitted"
|
||||
first_returned.approval_stage = "财务审批"
|
||||
first_returned.submitted_at = datetime(2026, 5, 12, 11, 0, tzinfo=UTC)
|
||||
db.commit()
|
||||
|
||||
second_returned = service.return_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
reason="超标说明仍不完整,请补充制度例外依据。",
|
||||
reason_codes=["over_policy"],
|
||||
)
|
||||
|
||||
assert second_returned is not None
|
||||
return_events = [
|
||||
flag
|
||||
for flag in list(second_returned.risk_flags_json or [])
|
||||
if isinstance(flag, dict) and flag.get("source") == "manual_return"
|
||||
]
|
||||
assert len(return_events) == 2
|
||||
assert return_events[0]["return_count"] == 1
|
||||
assert return_events[0]["stage_return_count"] == 1
|
||||
assert return_events[0]["return_stage"] == "直属领导审批"
|
||||
assert return_events[0]["reason_codes"] == ["invoice_mismatch", "business_explanation"]
|
||||
assert return_events[0]["risk_points"] == ["票据类型/金额与明细不一致", "业务事由/地点/人员信息不完整"]
|
||||
assert return_events[0]["reason"] == "发票金额与明细金额不一致,请重新核对。"
|
||||
assert return_events[0]["operator_role_codes"] == ["finance"]
|
||||
assert return_events[1]["return_count"] == 2
|
||||
assert return_events[1]["stage_return_count"] == 1
|
||||
assert return_events[1]["return_stage"] == "财务审批"
|
||||
assert return_events[1]["risk_points"] == ["超出制度标准或缺少超标说明"]
|
||||
|
||||
|
||||
def test_submit_returned_claim_preserves_manual_return_events() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="emp-submit-returned@example.com",
|
||||
name="张三",
|
||||
role_codes=[],
|
||||
is_admin=False,
|
||||
)
|
||||
return_flag = {
|
||||
"source": "manual_return",
|
||||
"return_event_id": "return-event-submit",
|
||||
"message": "第一次退回:业务说明不完整。",
|
||||
"reason": "业务说明不完整。",
|
||||
"return_count": 1,
|
||||
"return_stage": "直属领导审批",
|
||||
"return_stage_key": "direct_manager",
|
||||
"risk_points": ["业务事由/地点/人员信息不完整"],
|
||||
}
|
||||
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="E8200",
|
||||
name="李经理",
|
||||
email="manager-submit-returned@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E8201",
|
||||
name="张三",
|
||||
email="emp-submit-returned@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
claim = build_claim(expense_type="office", location="上海")
|
||||
claim.employee = employee
|
||||
claim.employee_id = employee.id
|
||||
claim.employee_name = "张三"
|
||||
claim.department_name = "市场部"
|
||||
claim.status = "returned"
|
||||
claim.approval_stage = "待提交"
|
||||
claim.risk_flags_json = [return_flag]
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
|
||||
|
||||
assert submitted is not None
|
||||
assert submitted.status == "submitted"
|
||||
assert submitted.approval_stage == "直属领导审批"
|
||||
assert any(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "manual_return"
|
||||
and flag.get("return_event_id") == "return-event-submit"
|
||||
for flag in list(submitted.risk_flags_json or [])
|
||||
)
|
||||
|
||||
|
||||
def test_manager_personal_claims_exclude_subordinate_pending_approval_claims() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager-personal@example.com",
|
||||
name="李经理",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
manager = Employee(
|
||||
employee_no="E8300",
|
||||
name="李经理",
|
||||
email="manager-personal@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E8301",
|
||||
name="张三",
|
||||
email="zhangsan-personal@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
db.add_all([manager, employee])
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-MGR-OWN",
|
||||
employee_id=manager.id,
|
||||
employee_name="李经理",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-MGR",
|
||||
expense_type="office",
|
||||
reason="本人报销",
|
||||
location="上海",
|
||||
amount=Decimal("88.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-MGR-SUB",
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-MGR",
|
||||
expense_type="transport",
|
||||
reason="下属待审批报销",
|
||||
location="上海",
|
||||
amount=Decimal("66.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 12, 11, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
personal_claims = service.list_claims(current_user)
|
||||
approval_claims = service.list_approval_claims(current_user)
|
||||
|
||||
assert [claim.claim_no for claim in personal_claims] == ["EXP-MGR-OWN"]
|
||||
assert [claim.claim_no for claim in approval_claims] == ["EXP-MGR-SUB"]
|
||||
|
||||
|
||||
def test_list_approval_claims_allows_direct_manager_to_view_pending_claims_for_approval() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager@example.com",
|
||||
name="李经理",
|
||||
@@ -1402,7 +1997,7 @@ def test_list_claims_allows_direct_manager_to_view_pending_claims_for_approval()
|
||||
)
|
||||
db.commit()
|
||||
|
||||
claims = ExpenseClaimService(db).list_claims(current_user)
|
||||
claims = ExpenseClaimService(db).list_approval_claims(current_user)
|
||||
|
||||
assert len(claims) == 1
|
||||
assert claims[0].claim_no == "EXP-MGR-201"
|
||||
|
||||
@@ -433,11 +433,11 @@ def test_semantic_ontology_service_extracts_day_before_yesterday_from_client_loc
|
||||
assert result.time_range.end_date == "2026-05-11"
|
||||
|
||||
|
||||
def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
result = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
result = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="我买了办公用品和文具,花了88元,帮我报销",
|
||||
user_id="pytest",
|
||||
)
|
||||
@@ -446,15 +446,33 @@ def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type()
|
||||
assert result.scenario == "expense"
|
||||
assert result.intent == "draft"
|
||||
assert any(
|
||||
item.type == "expense_type" and item.normalized_value == "office"
|
||||
for item in result.entities
|
||||
)
|
||||
|
||||
|
||||
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
service = SemanticOntologyService(db)
|
||||
item.type == "expense_type" and item.normalized_value == "office"
|
||||
for item in result.entities
|
||||
)
|
||||
|
||||
|
||||
def test_semantic_ontology_service_maps_riding_fare_to_transport_expense_type() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
result = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="业务发生时间:2026-03-04,送客户去林萃小区办事,请报销乘车费用",
|
||||
user_id="pytest",
|
||||
)
|
||||
)
|
||||
|
||||
assert result.scenario == "expense"
|
||||
assert result.intent == "draft"
|
||||
assert any(
|
||||
item.type == "expense_type" and item.normalized_value == "transport"
|
||||
for item in result.entities
|
||||
)
|
||||
|
||||
|
||||
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
service = SemanticOntologyService(db)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_parse_with_model",
|
||||
|
||||
@@ -289,6 +289,67 @@ def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk(monke
|
||||
assert any("附件内容" in point for point in analysis["points"])
|
||||
|
||||
|
||||
def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
manager = Employee(
|
||||
id="mgr-approve-1",
|
||||
employee_no="E21001",
|
||||
name="李经理",
|
||||
email="manager-approve-api@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
id="emp-approve-1",
|
||||
employee_no="E11001",
|
||||
name="张三",
|
||||
email="zhangsan-approve-api@example.com",
|
||||
manager=manager,
|
||||
)
|
||||
claim = ExpenseClaim(
|
||||
id="claim-approve-1",
|
||||
claim_no="EXP-APP-API-001",
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_id="dept-1",
|
||||
department_name="市场部",
|
||||
project_code=None,
|
||||
expense_type="transport",
|
||||
reason="交通报销",
|
||||
location="上海",
|
||||
amount=Decimal("88.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 13, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="直属领导审批",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
db.add_all([manager, employee, claim])
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/claims/claim-approve-1/approve",
|
||||
json={"opinion": "情况属实,同意报销。"},
|
||||
headers={
|
||||
"X-Auth-Username": "manager-approve-api@example.com",
|
||||
"X-Auth-Name": "manager-approve-api@example.com",
|
||||
"X-Auth-Role-Codes": "manager",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["status"] == "submitted"
|
||||
assert payload["approval_stage"] == "财务审批"
|
||||
assert any(
|
||||
item["source"] == "manual_approval"
|
||||
and item["opinion"] == "情况属实,同意报销。"
|
||||
and item["next_approval_stage"] == "财务审批"
|
||||
for item in payload["risk_flags_json"]
|
||||
)
|
||||
|
||||
|
||||
def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, tmp_path) -> None:
|
||||
preview_bytes = b"fake-preview-png"
|
||||
preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}"
|
||||
|
||||
@@ -546,11 +546,11 @@ def test_user_agent_guides_implicit_expense_draft_request() -> None:
|
||||
assert slot_map["amount"].value == "1000.00元"
|
||||
|
||||
|
||||
def test_user_agent_guides_narrative_with_day_before_yesterday() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
def test_user_agent_guides_narrative_with_day_before_yesterday() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="我前天请客户吃饭花了200元",
|
||||
user_id="pytest",
|
||||
context_json={
|
||||
@@ -571,15 +571,106 @@ def test_user_agent_guides_narrative_with_day_before_yesterday() -> None:
|
||||
|
||||
assert response.review_payload is not None
|
||||
slot_map = {item.key: item for item in response.review_payload.slot_cards}
|
||||
assert slot_map["time_range"].raw_value == "前天"
|
||||
assert slot_map["time_range"].value == "2026-05-11"
|
||||
assert "时间为 2026-05-11" in response.review_payload.intent_summary
|
||||
|
||||
|
||||
def test_user_agent_attachment_only_upload_uses_generic_scene_reason_without_fabrication() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
assert slot_map["time_range"].raw_value == "前天"
|
||||
assert slot_map["time_range"].value == "2026-05-11"
|
||||
assert "时间为 2026-05-11" in response.review_payload.intent_summary
|
||||
|
||||
|
||||
def test_user_agent_guides_riding_fare_as_transport_expense() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
message = "业务发生时间:2026-03-04,送客户去林萃小区办事,请报销乘车费用"
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=message,
|
||||
user_id="pytest",
|
||||
)
|
||||
)
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest",
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
tool_payload={"draft_only": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
slot_map = {item.key: item for item in response.review_payload.slot_cards}
|
||||
assert slot_map["expense_type"].value == "交通费"
|
||||
assert slot_map["expense_type"].normalized_value == "transport"
|
||||
assert slot_map["time_range"].value == "2026-03-04"
|
||||
assert slot_map["reason"].value == "送客户去林萃小区办事,请报销乘车费用"
|
||||
assert "业务发生时间" not in slot_map["reason"].raw_value
|
||||
assert "“交通费”" in response.review_payload.intent_summary
|
||||
|
||||
|
||||
def test_user_agent_does_not_treat_draft_saved_message_as_precheck_risk_for_transport() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
message = "业务发生时间:2026-03-04,送客户去林萃小区办事,打车花了32元,请报销乘车费用"
|
||||
context_json = {
|
||||
"name": "赵六",
|
||||
"attachment_names": ["didi-trip.png"],
|
||||
"attachment_count": 1,
|
||||
"ocr_documents": [
|
||||
{
|
||||
"filename": "didi-trip.png",
|
||||
"summary": "滴滴出行 支付金额 32 元",
|
||||
"text": "滴滴出行 支付金额 32 元",
|
||||
"document_type": "taxi_receipt",
|
||||
"scene_code": "transport",
|
||||
"document_fields": [
|
||||
{"key": "amount", "label": "支付金额", "value": "32.00"},
|
||||
],
|
||||
"warnings": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=message,
|
||||
user_id="pytest",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest",
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload={
|
||||
"draft_only": True,
|
||||
"claim_id": "claim-1",
|
||||
"claim_no": "EXP-202603-001",
|
||||
"status": "draft",
|
||||
"message": (
|
||||
"已创建报销草稿 EXP-202603-001,当前状态为 draft。"
|
||||
"你可以继续补充费用明细、客户单位和票据附件。"
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.review_payload is not None
|
||||
assert response.review_payload.can_proceed is True
|
||||
assert "客户名称" not in response.review_payload.missing_slots
|
||||
assert "参与人员" not in response.review_payload.missing_slots
|
||||
assert "票据附件" not in response.review_payload.missing_slots
|
||||
risk_text = "\n".join(
|
||||
f"{item.title}\n{item.content}" for item in response.review_payload.risk_briefs
|
||||
)
|
||||
assert "AI预审未通过" not in risk_text
|
||||
assert "已创建报销草稿" not in risk_text
|
||||
|
||||
|
||||
def test_user_agent_attachment_only_upload_uses_generic_scene_reason_without_fabrication() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="我上传了 1 份票据,请结合附件名称给出报销建议并尽量生成草稿。",
|
||||
user_id="pytest",
|
||||
|
||||
Reference in New Issue
Block a user