2026-05-13 06:46:24 +00:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
import re
|
2026-05-26 17:29:35 +08:00
|
|
|
|
import uuid
|
2026-05-21 16:09:47 +08:00
|
|
|
|
from datetime import UTC, date, datetime, timedelta
|
2026-05-13 06:46:24 +00:00
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
import pytest
|
2026-05-13 06:46:24 +00:00
|
|
|
|
from sqlalchemy import create_engine
|
|
|
|
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
|
|
|
|
|
|
|
|
from app.api.deps import CurrentUserContext
|
|
|
|
|
|
from app.db.base import Base
|
2026-05-26 17:29:35 +08:00
|
|
|
|
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
2026-05-13 15:42:25 +00:00
|
|
|
|
from app.models.employee import Employee
|
2026-05-13 06:46:24 +00:00
|
|
|
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
2026-05-20 09:36:01 +08:00
|
|
|
|
from app.models.organization import OrganizationUnit
|
2026-05-27 17:31:27 +08:00
|
|
|
|
from app.models.role import Role
|
2026-05-14 15:42:45 +00:00
|
|
|
|
from app.schemas.ontology import OntologyParseRequest
|
2026-05-13 06:46:24 +00:00
|
|
|
|
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
|
2026-05-21 10:57:06 +08:00
|
|
|
|
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate, ExpenseClaimUpdate
|
2026-05-21 09:28:33 +08:00
|
|
|
|
from app.services.agent_conversations import AgentConversationService
|
2026-05-27 17:31:27 +08:00
|
|
|
|
from app.services.budget import BudgetService
|
2026-05-22 10:42:31 +08:00
|
|
|
|
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
2026-05-13 06:46:24 +00:00
|
|
|
|
from app.services.expense_claims import ExpenseClaimService
|
2026-06-01 17:07:14 +08:00
|
|
|
|
from app.services.expense_claim_workflow_constants import (
|
|
|
|
|
|
APPROVAL_DONE_STAGE,
|
|
|
|
|
|
BUDGET_MANAGER_APPROVAL_STAGE,
|
|
|
|
|
|
DIRECT_MANAGER_APPROVAL_STAGE,
|
|
|
|
|
|
)
|
2026-05-14 15:42:45 +00:00
|
|
|
|
from app.services.ontology import SemanticOntologyService
|
2026-05-13 06:46:24 +00:00
|
|
|
|
from app.services.ocr import OcrService
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_claim(*, expense_type: str, location: str) -> ExpenseClaim:
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
id="claim-1",
|
|
|
|
|
|
claim_no="EXP-202605-001",
|
|
|
|
|
|
employee_id="emp-1",
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_id="dept-1",
|
|
|
|
|
|
department_name="市场部",
|
|
|
|
|
|
project_code=None,
|
|
|
|
|
|
expense_type=expense_type,
|
|
|
|
|
|
reason="费用报销",
|
|
|
|
|
|
location=location,
|
|
|
|
|
|
amount=Decimal("88.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=1,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=None,
|
|
|
|
|
|
status="draft",
|
|
|
|
|
|
approval_stage="待提交",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
claim.items = [
|
|
|
|
|
|
ExpenseClaimItem(
|
|
|
|
|
|
id="item-1",
|
|
|
|
|
|
claim_id="claim-1",
|
|
|
|
|
|
item_date=date(2026, 5, 13),
|
|
|
|
|
|
item_type=expense_type,
|
|
|
|
|
|
item_reason="费用报销",
|
|
|
|
|
|
item_location=location,
|
|
|
|
|
|
item_amount=Decimal("88.00"),
|
|
|
|
|
|
invoice_id="invoice-1",
|
|
|
|
|
|
)
|
|
|
|
|
|
]
|
|
|
|
|
|
return claim
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 16:28:09 +08:00
|
|
|
|
def build_application_claim(
|
|
|
|
|
|
*,
|
|
|
|
|
|
id: str,
|
|
|
|
|
|
claim_no: str,
|
|
|
|
|
|
employee: Employee,
|
|
|
|
|
|
status: str = "approved",
|
|
|
|
|
|
amount: Decimal = Decimal("3000.00"),
|
|
|
|
|
|
) -> ExpenseClaim:
|
|
|
|
|
|
return ExpenseClaim(
|
|
|
|
|
|
id=id,
|
|
|
|
|
|
claim_no=claim_no,
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name=employee.name,
|
|
|
|
|
|
department_id=employee.organization_unit_id,
|
|
|
|
|
|
department_name="Tech",
|
|
|
|
|
|
project_code=None,
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="support deployment",
|
|
|
|
|
|
location="Shanghai",
|
|
|
|
|
|
amount=amount,
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 2, 18, tzinfo=UTC),
|
|
|
|
|
|
status=status,
|
|
|
|
|
|
approval_stage=APPROVAL_DONE_STAGE if status in {"approved", "completed"} else "Pending",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 06:46:24 +00:00
|
|
|
|
def build_session() -> Session:
|
|
|
|
|
|
engine = create_engine(
|
|
|
|
|
|
"sqlite+pysqlite:///:memory:",
|
|
|
|
|
|
connect_args={"check_same_thread": False},
|
|
|
|
|
|
poolclass=StaticPool,
|
|
|
|
|
|
)
|
|
|
|
|
|
Base.metadata.create_all(bind=engine)
|
|
|
|
|
|
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
|
|
|
|
|
return session_factory()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 16:09:47 +08:00
|
|
|
|
def _count_claims(db: Session) -> int:
|
|
|
|
|
|
return int(db.query(ExpenseClaim).count())
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-26 17:29:35 +08:00
|
|
|
|
def _seed_budget_allocation(
|
|
|
|
|
|
db: Session,
|
|
|
|
|
|
*,
|
|
|
|
|
|
department_id: str | None,
|
|
|
|
|
|
department_name: str,
|
|
|
|
|
|
subject_code: str = "travel",
|
|
|
|
|
|
amount: Decimal = Decimal("50000.00"),
|
|
|
|
|
|
period_key: str = "2026Q2",
|
|
|
|
|
|
) -> BudgetAllocation:
|
|
|
|
|
|
allocation = BudgetAllocation(
|
|
|
|
|
|
budget_no=f"BUD-TEST-{uuid.uuid4().hex[:8]}",
|
|
|
|
|
|
fiscal_year=2026,
|
|
|
|
|
|
period_type="quarter",
|
|
|
|
|
|
period_key=period_key,
|
|
|
|
|
|
department_id=department_id,
|
|
|
|
|
|
department_name=department_name,
|
|
|
|
|
|
cost_center=None,
|
|
|
|
|
|
project_code=None,
|
|
|
|
|
|
subject_code=subject_code,
|
|
|
|
|
|
subject_name=subject_code,
|
|
|
|
|
|
original_amount=amount,
|
|
|
|
|
|
adjusted_amount=Decimal("0.00"),
|
|
|
|
|
|
status="active",
|
|
|
|
|
|
warning_threshold=Decimal("80.00"),
|
|
|
|
|
|
control_action="block",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(allocation)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
return allocation
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-27 17:31:27 +08:00
|
|
|
|
def _seed_budget_monitor_role(db: Session) -> Role:
|
|
|
|
|
|
role = db.query(Role).filter(Role.role_code == "budget_monitor").one_or_none()
|
|
|
|
|
|
if role is not None:
|
|
|
|
|
|
return role
|
|
|
|
|
|
role = Role(role_code="budget_monitor", name="预算监控员")
|
|
|
|
|
|
db.add(role)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
return role
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-01 17:07:14 +08:00
|
|
|
|
def _seed_executive_role(db: Session) -> Role:
|
|
|
|
|
|
role = db.query(Role).filter(Role.role_code == "executive").one_or_none()
|
|
|
|
|
|
if role is not None:
|
|
|
|
|
|
return role
|
|
|
|
|
|
role = Role(role_code="executive", name="Senior finance")
|
|
|
|
|
|
db.add(role)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
return role
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 06:46:24 +00:00
|
|
|
|
def test_validate_claim_for_submission_allows_office_claim_without_location() -> None:
|
|
|
|
|
|
service = ExpenseClaimService.__new__(ExpenseClaimService)
|
|
|
|
|
|
claim = build_claim(expense_type="office", location="待补充")
|
|
|
|
|
|
|
|
|
|
|
|
issues = service._validate_claim_for_submission(claim)
|
|
|
|
|
|
|
|
|
|
|
|
assert "业务地点未完善" not in issues
|
|
|
|
|
|
assert not any("缺少地点" in item for item in issues)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 09:36:01 +08:00
|
|
|
|
def test_validate_claim_for_submission_allows_transport_claim_without_location() -> None:
|
|
|
|
|
|
service = ExpenseClaimService.__new__(ExpenseClaimService)
|
|
|
|
|
|
claim = build_claim(expense_type="transport", location="待补充")
|
|
|
|
|
|
|
|
|
|
|
|
issues = service._validate_claim_for_submission(claim)
|
|
|
|
|
|
|
|
|
|
|
|
assert "业务地点未完善" not in issues
|
|
|
|
|
|
assert not any("缺少地点" in item for item in issues)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 06:46:24 +00:00
|
|
|
|
def test_validate_claim_for_submission_still_requires_location_for_travel_claim() -> None:
|
|
|
|
|
|
service = ExpenseClaimService.__new__(ExpenseClaimService)
|
|
|
|
|
|
claim = build_claim(expense_type="travel", location="待补充")
|
|
|
|
|
|
|
|
|
|
|
|
issues = service._validate_claim_for_submission(claim)
|
|
|
|
|
|
|
|
|
|
|
|
assert "业务地点未完善" in issues
|
|
|
|
|
|
assert any("缺少地点" in item for item in issues)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
|
def test_validate_claim_for_submission_does_not_require_optional_ride_receipt() -> None:
|
|
|
|
|
|
service = ExpenseClaimService.__new__(ExpenseClaimService)
|
|
|
|
|
|
claim = build_claim(expense_type="transport", location="待补充")
|
|
|
|
|
|
claim.invoice_count = 0
|
|
|
|
|
|
claim.items[0].item_type = "ride_ticket"
|
|
|
|
|
|
claim.items[0].invoice_id = ""
|
|
|
|
|
|
|
|
|
|
|
|
issues = service._validate_claim_for_submission(claim)
|
|
|
|
|
|
|
|
|
|
|
|
assert "票据附件数量不足" not in issues
|
|
|
|
|
|
assert not any("缺少票据标识" in item for item in issues)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_claim_for_submission_still_requires_hotel_receipt() -> None:
|
|
|
|
|
|
service = ExpenseClaimService.__new__(ExpenseClaimService)
|
|
|
|
|
|
claim = build_claim(expense_type="hotel", location="北京")
|
|
|
|
|
|
claim.invoice_count = 0
|
|
|
|
|
|
claim.items[0].item_type = "hotel_ticket"
|
|
|
|
|
|
claim.items[0].invoice_id = ""
|
|
|
|
|
|
|
|
|
|
|
|
issues = service._validate_claim_for_submission(claim)
|
|
|
|
|
|
|
|
|
|
|
|
assert "票据附件数量不足" in issues
|
|
|
|
|
|
assert any("缺少票据标识" in item for item in issues)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 16:09:47 +08:00
|
|
|
|
def test_save_or_submit_preview_does_not_create_claim_without_explicit_action() -> None:
|
|
|
|
|
|
user_id = "preview-only@example.com"
|
|
|
|
|
|
message = "业务发生时间:2026-03-04,打车去客户现场,交通费32元,请帮我看看怎么报"
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E5100",
|
|
|
|
|
|
name="预览员工",
|
|
|
|
|
|
email=user_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
ontology = SemanticOntologyService(db).parse(
|
|
|
|
|
|
OntologyParseRequest(
|
|
|
|
|
|
query=message,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
before_count = _count_claims(db)
|
|
|
|
|
|
|
|
|
|
|
|
result = ExpenseClaimService(db).save_or_submit_from_ontology(
|
|
|
|
|
|
run_id=ontology.run_id,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
message=message,
|
|
|
|
|
|
ontology=ontology,
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"name": "预览员工",
|
|
|
|
|
|
"user_input_text": message,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert result["preview_only"] is True
|
|
|
|
|
|
assert result["status"] == "preview"
|
2026-05-22 08:58:59 +08:00
|
|
|
|
assert "报销测算参考:" in result["message"]
|
|
|
|
|
|
assert "| 项目 | 当前信息 | 复核口径 |" in result["message"]
|
|
|
|
|
|
assert "交通票据金额 + 住宿标准" not in result["message"]
|
2026-05-21 16:09:47 +08:00
|
|
|
|
assert _count_claims(db) == before_count
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_or_submit_persists_claim_only_after_save_draft_action() -> None:
|
|
|
|
|
|
user_id = "save-draft-explicit@example.com"
|
|
|
|
|
|
message = "业务发生时间:2026-03-04,打车去客户现场,交通费32元,请帮我看看怎么报"
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E5101",
|
|
|
|
|
|
name="保存员工",
|
|
|
|
|
|
email=user_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
ontology = SemanticOntologyService(db).parse(
|
|
|
|
|
|
OntologyParseRequest(
|
|
|
|
|
|
query=message,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
before_count = _count_claims(db)
|
|
|
|
|
|
|
|
|
|
|
|
result = ExpenseClaimService(db).save_or_submit_from_ontology(
|
|
|
|
|
|
run_id=ontology.run_id,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
message=message,
|
|
|
|
|
|
ontology=ontology,
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"name": "保存员工",
|
|
|
|
|
|
"user_input_text": message,
|
|
|
|
|
|
"review_action": "save_draft",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert result["draft_only"] is True
|
|
|
|
|
|
assert result["claim_id"]
|
|
|
|
|
|
assert result["status"] == "draft"
|
|
|
|
|
|
assert _count_claims(db) == before_count + 1
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 23:47:28 +08:00
|
|
|
|
def test_save_draft_persists_user_changed_expense_category() -> None:
|
|
|
|
|
|
user_id = "save-draft-category@example.com"
|
|
|
|
|
|
message = "业务发生时间:2026-03-04,打车去客户现场,交通费32元,请帮我看看怎么报"
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E5102",
|
|
|
|
|
|
name="分类员工",
|
|
|
|
|
|
email=user_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
ontology = SemanticOntologyService(db).parse(
|
|
|
|
|
|
OntologyParseRequest(
|
|
|
|
|
|
query=message,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result = ExpenseClaimService(db).save_or_submit_from_ontology(
|
|
|
|
|
|
run_id=ontology.run_id,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
message=message,
|
|
|
|
|
|
ontology=ontology,
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"name": "分类员工",
|
|
|
|
|
|
"user_input_text": message,
|
|
|
|
|
|
"review_action": "save_draft",
|
|
|
|
|
|
"review_form_values": {
|
|
|
|
|
|
"expense_type": "办公用品费",
|
|
|
|
|
|
"amount": "32元",
|
|
|
|
|
|
"occurred_date": "2026-03-04",
|
|
|
|
|
|
"reason": "右侧核对后改为办公用品费",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
claim = db.get(ExpenseClaim, result["claim_id"])
|
|
|
|
|
|
assert claim is not None
|
|
|
|
|
|
assert claim.expense_type == "office"
|
|
|
|
|
|
assert claim.items[0].item_type == "office"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-01 17:07:14 +08:00
|
|
|
|
def test_upsert_draft_from_ontology_persists_linked_application_context() -> None:
|
|
|
|
|
|
user_id = "linked-application-context@example.com"
|
|
|
|
|
|
message = "业务发生时间:2026-05-20,去北京支撑国网部署,火车票354元,申请差旅费报销"
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E5103",
|
|
|
|
|
|
name="关联员工",
|
|
|
|
|
|
email=user_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
2026-06-03 16:28:09 +08:00
|
|
|
|
db.flush()
|
|
|
|
|
|
db.add(build_application_claim(
|
|
|
|
|
|
id="application-linked-1",
|
|
|
|
|
|
claim_no="AP-202605-001",
|
|
|
|
|
|
employee=employee,
|
|
|
|
|
|
))
|
2026-06-01 17:07:14 +08:00
|
|
|
|
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,
|
|
|
|
|
|
"review_action": "save_draft",
|
|
|
|
|
|
"review_form_values": {
|
|
|
|
|
|
"expense_type": "差旅费",
|
|
|
|
|
|
"amount": "354元",
|
|
|
|
|
|
"application_claim_id": "application-linked-1",
|
|
|
|
|
|
"application_claim_no": "AP-202605-001",
|
|
|
|
|
|
"application_reason": "支撑国网仿生产环境部署",
|
|
|
|
|
|
"application_location": "北京",
|
|
|
|
|
|
"application_amount": "3000",
|
|
|
|
|
|
},
|
|
|
|
|
|
"expense_scene_selection": {
|
|
|
|
|
|
"expense_type": "travel",
|
|
|
|
|
|
"application_claim_id": "application-linked-1",
|
|
|
|
|
|
"application_claim_no": "AP-202605-001",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
claim = db.get(ExpenseClaim, result["claim_id"])
|
|
|
|
|
|
assert claim is not None
|
|
|
|
|
|
link_flag = next(
|
|
|
|
|
|
flag
|
|
|
|
|
|
for flag in claim.risk_flags_json
|
|
|
|
|
|
if isinstance(flag, dict) and flag.get("source") == "application_link"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert link_flag["application_claim_no"] == "AP-202605-001"
|
|
|
|
|
|
assert link_flag["application_claim_id"] == "application-linked-1"
|
|
|
|
|
|
assert link_flag["application_detail"]["application_reason"] == "支撑国网仿生产环境部署"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
|
def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_item() -> None:
|
|
|
|
|
|
user_id = "linked-application-no-receipt@example.com"
|
|
|
|
|
|
message = (
|
|
|
|
|
|
"报销类型:差旅费\n"
|
|
|
|
|
|
"关联申请单:AP-202606-001 / 支撑国网仿生产服务器部署 / 2026-02-20 至 2026-02-23 / 上海 / ¥3,000\n"
|
|
|
|
|
|
"报销票据:草稿生成后在详情中上传"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E5104",
|
|
|
|
|
|
name="关联员工",
|
|
|
|
|
|
email=user_id,
|
|
|
|
|
|
grade="P5",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
2026-06-03 16:28:09 +08:00
|
|
|
|
db.flush()
|
|
|
|
|
|
db.add(build_application_claim(
|
|
|
|
|
|
id="application-linked-no-receipt",
|
|
|
|
|
|
claim_no="AP-202606-001",
|
|
|
|
|
|
employee=employee,
|
|
|
|
|
|
))
|
2026-06-02 14:01:51 +08:00
|
|
|
|
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,
|
|
|
|
|
|
"review_action": "save_draft",
|
|
|
|
|
|
"review_form_values": {
|
|
|
|
|
|
"expense_type": "差旅费",
|
|
|
|
|
|
"amount": "¥3,000",
|
|
|
|
|
|
"reason": "支撑国网仿生产服务器部署",
|
|
|
|
|
|
"location": "上海",
|
|
|
|
|
|
"business_location": "上海",
|
|
|
|
|
|
"time_range": "2026-02-20 至 2026-02-23",
|
|
|
|
|
|
"business_time": "2026-02-20 至 2026-02-23",
|
|
|
|
|
|
"application_claim_id": "application-linked-no-receipt",
|
|
|
|
|
|
"application_claim_no": "AP-202606-001",
|
|
|
|
|
|
"application_reason": "支撑国网仿生产服务器部署",
|
|
|
|
|
|
"application_location": "上海",
|
|
|
|
|
|
"application_amount": "3000",
|
|
|
|
|
|
"application_amount_label": "¥3,000",
|
|
|
|
|
|
"application_business_time": "2026-02-20 至 2026-02-23",
|
2026-06-02 16:22:59 +08:00
|
|
|
|
"application_date": "2026-06-02T00:58:00Z",
|
|
|
|
|
|
"application_days": "4 天",
|
|
|
|
|
|
"application_transport_mode": "火车",
|
|
|
|
|
|
"application_lodging_daily_cap": "600元/天",
|
|
|
|
|
|
"application_subsidy_daily_cap": "120元/天",
|
|
|
|
|
|
"application_transport_policy": "按真实票据复核",
|
|
|
|
|
|
"application_policy_estimate": "交通 1,160元 + 住宿 2,400元 + 补贴 480元",
|
2026-06-02 14:01:51 +08:00
|
|
|
|
},
|
|
|
|
|
|
"expense_scene_selection": {
|
|
|
|
|
|
"expense_type": "travel",
|
|
|
|
|
|
"application_claim_id": "application-linked-no-receipt",
|
|
|
|
|
|
"application_claim_no": "AP-202606-001",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
claim = db.get(ExpenseClaim, result["claim_id"])
|
|
|
|
|
|
assert claim is not None
|
|
|
|
|
|
assert claim.expense_type == "travel"
|
|
|
|
|
|
assert claim.reason == "支撑国网仿生产服务器部署"
|
|
|
|
|
|
assert claim.location == "上海"
|
|
|
|
|
|
assert claim.amount == Decimal("0.00")
|
|
|
|
|
|
assert claim.invoice_count == 0
|
2026-06-02 16:22:59 +08:00
|
|
|
|
assert claim.occurred_at.date() == date(2026, 2, 20)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
assert claim.items == []
|
|
|
|
|
|
link_flag = next(
|
|
|
|
|
|
flag
|
|
|
|
|
|
for flag in claim.risk_flags_json
|
|
|
|
|
|
if isinstance(flag, dict) and flag.get("source") == "application_link"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert link_flag["application_claim_no"] == "AP-202606-001"
|
2026-06-02 16:22:59 +08:00
|
|
|
|
assert link_flag["application_detail"]["application_time"] == "2026-02-20 至 2026-02-23"
|
|
|
|
|
|
assert link_flag["application_detail"]["application_business_time"] == "2026-02-20 至 2026-02-23"
|
|
|
|
|
|
assert link_flag["application_detail"]["application_date"] == "2026-06-02T00:58:00Z"
|
2026-06-02 14:01:51 +08:00
|
|
|
|
assert link_flag["application_detail"]["application_amount"] == "3000"
|
2026-06-02 16:22:59 +08:00
|
|
|
|
assert link_flag["application_detail"]["application_days"] == "4 天"
|
|
|
|
|
|
assert link_flag["application_detail"]["application_transport_mode"] == "火车"
|
|
|
|
|
|
assert link_flag["application_detail"]["application_lodging_daily_cap"] == "600元/天"
|
|
|
|
|
|
assert link_flag["application_detail"]["application_subsidy_daily_cap"] == "120元/天"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> None:
|
|
|
|
|
|
user_id = "linked-application-existing-placeholder@example.com"
|
|
|
|
|
|
message = (
|
|
|
|
|
|
"报销类型:差旅费\n"
|
|
|
|
|
|
"关联申请单:AP-202606-002 / 支撑国网仿生产服务器部署 / 上海 / ¥3,000\n"
|
|
|
|
|
|
"报销票据:草稿生成后在详情中上传"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E5105",
|
|
|
|
|
|
name="关联员工",
|
|
|
|
|
|
email=user_id,
|
|
|
|
|
|
grade="P5",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.flush()
|
2026-06-03 16:28:09 +08:00
|
|
|
|
db.add(build_application_claim(
|
|
|
|
|
|
id="application-linked-existing-placeholder",
|
|
|
|
|
|
claim_no="AP-202606-002",
|
|
|
|
|
|
employee=employee,
|
|
|
|
|
|
))
|
2026-06-02 16:22:59 +08:00
|
|
|
|
existing_claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="RE-202606020001-PLACEHOLDER",
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="关联员工",
|
|
|
|
|
|
department_name="技术部",
|
|
|
|
|
|
project_code=None,
|
|
|
|
|
|
expense_type="travel",
|
|
|
|
|
|
reason="支撑国网仿生产服务器部署",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("3000.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
|
|
|
|
|
|
status="draft",
|
|
|
|
|
|
approval_stage="待提交",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
existing_claim.items = [
|
|
|
|
|
|
ExpenseClaimItem(
|
|
|
|
|
|
claim_id=existing_claim.id,
|
|
|
|
|
|
item_date=date(2026, 2, 20),
|
|
|
|
|
|
item_type="travel",
|
|
|
|
|
|
item_reason="支撑国网仿生产服务器部署",
|
|
|
|
|
|
item_location="上海",
|
|
|
|
|
|
item_amount=Decimal("3000.00"),
|
|
|
|
|
|
invoice_id=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
]
|
|
|
|
|
|
db.add(existing_claim)
|
|
|
|
|
|
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": "关联员工",
|
|
|
|
|
|
"draft_claim_id": existing_claim.id,
|
|
|
|
|
|
"user_input_text": message,
|
|
|
|
|
|
"review_action": "save_draft",
|
|
|
|
|
|
"review_form_values": {
|
|
|
|
|
|
"expense_type": "差旅费",
|
|
|
|
|
|
"amount": "¥3,000",
|
|
|
|
|
|
"reason": "支撑国网仿生产服务器部署",
|
|
|
|
|
|
"location": "上海",
|
|
|
|
|
|
"business_location": "上海",
|
|
|
|
|
|
"application_claim_id": "application-linked-existing-placeholder",
|
|
|
|
|
|
"application_claim_no": "AP-202606-002",
|
|
|
|
|
|
"application_reason": "支撑国网仿生产服务器部署",
|
|
|
|
|
|
"application_location": "上海",
|
|
|
|
|
|
"application_amount": "3000",
|
|
|
|
|
|
"application_amount_label": "¥3,000",
|
|
|
|
|
|
},
|
|
|
|
|
|
"expense_scene_selection": {
|
|
|
|
|
|
"expense_type": "travel",
|
|
|
|
|
|
"application_claim_id": "application-linked-existing-placeholder",
|
|
|
|
|
|
"application_claim_no": "AP-202606-002",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
claim = db.get(ExpenseClaim, result["claim_id"])
|
|
|
|
|
|
assert claim is not None
|
|
|
|
|
|
assert claim.id == existing_claim.id
|
|
|
|
|
|
assert claim.amount == Decimal("0.00")
|
|
|
|
|
|
assert claim.invoice_count == 0
|
|
|
|
|
|
assert claim.items == []
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 16:28:09 +08:00
|
|
|
|
def test_upsert_linked_application_requires_approved_application() -> None:
|
|
|
|
|
|
user_id = "linked-application-status-block@example.com"
|
|
|
|
|
|
message = "save reimbursement draft from linked travel application"
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(employee_no="E5108", name="Linked Employee", email=user_id)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
db.add(build_application_claim(
|
|
|
|
|
|
id="application-returned-blocked",
|
|
|
|
|
|
claim_no="AP-202606-STATUS",
|
|
|
|
|
|
employee=employee,
|
|
|
|
|
|
status="returned",
|
|
|
|
|
|
))
|
|
|
|
|
|
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": "Linked Employee",
|
|
|
|
|
|
"user_input_text": message,
|
|
|
|
|
|
"review_action": "save_draft",
|
|
|
|
|
|
"review_form_values": {
|
|
|
|
|
|
"expense_type": "travel",
|
|
|
|
|
|
"application_claim_id": "application-returned-blocked",
|
|
|
|
|
|
"application_claim_no": "AP-202606-STATUS",
|
|
|
|
|
|
},
|
|
|
|
|
|
"expense_scene_selection": {
|
|
|
|
|
|
"expense_type": "travel",
|
|
|
|
|
|
"application_claim_id": "application-returned-blocked",
|
|
|
|
|
|
"application_claim_no": "AP-202606-STATUS",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert result["status"] == "blocked"
|
|
|
|
|
|
assert result["application_link_blocked"] is True
|
|
|
|
|
|
assert result["application_claim_no"] == "AP-202606-STATUS"
|
|
|
|
|
|
assert _count_claims(db) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upsert_linked_application_rejects_duplicate_reimbursement_draft() -> None:
|
|
|
|
|
|
user_id = "linked-application-duplicate-block@example.com"
|
|
|
|
|
|
message = "save another reimbursement draft from linked travel application"
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(employee_no="E5109", name="Linked Employee", email=user_id)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
db.add(build_application_claim(
|
|
|
|
|
|
id="application-duplicate-blocked",
|
|
|
|
|
|
claim_no="AP-202606-DUP",
|
|
|
|
|
|
employee=employee,
|
|
|
|
|
|
))
|
|
|
|
|
|
existing_claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="RE-202606-DUP-DRAFT",
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name=employee.name,
|
|
|
|
|
|
department_name="Tech",
|
|
|
|
|
|
project_code=None,
|
|
|
|
|
|
expense_type="travel",
|
|
|
|
|
|
reason="support deployment",
|
|
|
|
|
|
location="Shanghai",
|
|
|
|
|
|
amount=Decimal("0.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
|
|
|
|
|
|
status="draft",
|
|
|
|
|
|
approval_stage="Pending",
|
|
|
|
|
|
risk_flags_json=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"source": "application_link",
|
|
|
|
|
|
"application_claim_id": "application-duplicate-blocked",
|
|
|
|
|
|
"application_claim_no": "AP-202606-DUP",
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(existing_claim)
|
|
|
|
|
|
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": "Linked Employee",
|
|
|
|
|
|
"user_input_text": message,
|
|
|
|
|
|
"review_action": "save_draft",
|
|
|
|
|
|
"review_form_values": {
|
|
|
|
|
|
"expense_type": "travel",
|
|
|
|
|
|
"application_claim_id": "application-duplicate-blocked",
|
|
|
|
|
|
"application_claim_no": "AP-202606-DUP",
|
|
|
|
|
|
},
|
|
|
|
|
|
"expense_scene_selection": {
|
|
|
|
|
|
"expense_type": "travel",
|
|
|
|
|
|
"application_claim_id": "application-duplicate-blocked",
|
|
|
|
|
|
"application_claim_no": "AP-202606-DUP",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert result["status"] == "blocked"
|
|
|
|
|
|
assert result["application_link_blocked"] is True
|
|
|
|
|
|
assert result["existing_claim_no"] == "RE-202606-DUP-DRAFT"
|
|
|
|
|
|
assert _count_claims(db) == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 16:22:59 +08:00
|
|
|
|
def test_sync_travel_allowance_uses_linked_application_range_days() -> None:
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E5106",
|
|
|
|
|
|
name="关联差旅员工",
|
|
|
|
|
|
email="linked-application-allowance@example.com",
|
|
|
|
|
|
grade="P4",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
|
|
|
|
|
|
claim = build_claim(expense_type="travel", location="上海")
|
|
|
|
|
|
claim.employee_id = employee.id
|
|
|
|
|
|
claim.employee_name = employee.name
|
|
|
|
|
|
claim.amount = Decimal("354.00")
|
|
|
|
|
|
claim.items[0].item_date = date(2026, 2, 20)
|
|
|
|
|
|
claim.items[0].item_type = "train_ticket"
|
|
|
|
|
|
claim.items[0].item_reason = "武汉-上海"
|
|
|
|
|
|
claim.items[0].item_location = "上海"
|
|
|
|
|
|
claim.items[0].item_amount = Decimal("354.00")
|
|
|
|
|
|
claim.risk_flags_json = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"source": "application_link",
|
|
|
|
|
|
"application_claim_no": "AP-202606-003",
|
|
|
|
|
|
"application_detail": {
|
|
|
|
|
|
"application_time": "2026-02-20 至 2026-02-23",
|
|
|
|
|
|
"application_days": "4 天",
|
|
|
|
|
|
"application_location": "上海",
|
|
|
|
|
|
"application_transport_mode": "火车",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
service._sync_claim_from_items(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
db.refresh(claim)
|
|
|
|
|
|
|
|
|
|
|
|
allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
|
|
|
|
|
|
assert "4天" in allowance_item.item_reason
|
|
|
|
|
|
assert allowance_item.item_date == date(2026, 2, 23)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sync_travel_allowance_backfills_range_from_linked_application_claim() -> None:
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E5107",
|
|
|
|
|
|
name="旧关联差旅员工",
|
|
|
|
|
|
email="linked-application-allowance-backfill@example.com",
|
|
|
|
|
|
grade="P4",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
application_claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="AP-202606-004",
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name=employee.name,
|
|
|
|
|
|
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),
|
|
|
|
|
|
status="approved",
|
|
|
|
|
|
approval_stage="审批完成",
|
|
|
|
|
|
risk_flags_json=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"source": "application_detail",
|
|
|
|
|
|
"application_detail": {
|
|
|
|
|
|
"time": "2026-02-20 至 2026-02-23",
|
|
|
|
|
|
"days": "4 天",
|
|
|
|
|
|
"location": "上海",
|
|
|
|
|
|
"reason": "支撑国网仿生产环境部署",
|
|
|
|
|
|
"transport_mode": "火车",
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(application_claim)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
|
|
|
|
|
|
claim = build_claim(expense_type="travel", location="上海")
|
|
|
|
|
|
claim.employee_id = employee.id
|
|
|
|
|
|
claim.employee_name = employee.name
|
|
|
|
|
|
claim.amount = Decimal("354.00")
|
|
|
|
|
|
claim.items[0].item_date = date(2026, 2, 20)
|
|
|
|
|
|
claim.items[0].item_type = "train_ticket"
|
|
|
|
|
|
claim.items[0].item_reason = "武汉-上海"
|
|
|
|
|
|
claim.items[0].item_location = "上海"
|
|
|
|
|
|
claim.items[0].item_amount = Decimal("354.00")
|
|
|
|
|
|
claim.risk_flags_json = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"source": "application_link",
|
|
|
|
|
|
"application_claim_no": "AP-202606-004",
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
service._sync_claim_from_items(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
db.refresh(claim)
|
|
|
|
|
|
|
|
|
|
|
|
allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
|
|
|
|
|
|
assert "4天" in allowance_item.item_reason
|
|
|
|
|
|
assert allowance_item.item_date == date(2026, 2, 23)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 16:09:47 +08:00
|
|
|
|
def test_unsaved_conversation_expires_after_retention_but_saved_conversation_stays() -> None:
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
service = AgentConversationService(db)
|
|
|
|
|
|
unsaved = service.get_or_create_conversation(
|
|
|
|
|
|
conversation_id="conv-unsaved-expire",
|
|
|
|
|
|
user_id="expire@example.com",
|
|
|
|
|
|
source="user_message",
|
|
|
|
|
|
context_json={"session_type": "expense"},
|
|
|
|
|
|
)
|
|
|
|
|
|
saved = service.get_or_create_conversation(
|
|
|
|
|
|
conversation_id="conv-saved-keep",
|
|
|
|
|
|
user_id="expire@example.com",
|
|
|
|
|
|
source="user_message",
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"session_type": "expense",
|
|
|
|
|
|
"draft_claim_id": "claim-saved",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
old_time = datetime.now(UTC) - timedelta(days=4)
|
|
|
|
|
|
unsaved.updated_at = old_time
|
|
|
|
|
|
saved.updated_at = old_time
|
|
|
|
|
|
db.add_all([unsaved, saved])
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
deleted_count = service.prune_expired_conversations(retention_days=3)
|
|
|
|
|
|
|
|
|
|
|
|
assert deleted_count == 1
|
|
|
|
|
|
assert service.get_conversation("conv-unsaved-expire") is None
|
|
|
|
|
|
assert service.get_conversation("conv-saved-keep") is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 06:46:24 +00:00
|
|
|
|
def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> None:
|
|
|
|
|
|
expense_type = ExpenseClaimService._resolve_expense_type(
|
|
|
|
|
|
[],
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"review_form_values": {
|
|
|
|
|
|
"expense_type": "办公用品"
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert expense_type == "office"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-14 15:42:45 +00:00
|
|
|
|
def test_upsert_draft_from_ontology_defers_multi_document_association_choice() -> None:
|
|
|
|
|
|
user_id = "zhangsan@example.com"
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E5001",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email=user_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
existing_claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-202605-010",
|
|
|
|
|
|
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="draft",
|
|
|
|
|
|
approval_stage="待提交",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
result = service.upsert_draft_from_ontology(
|
|
|
|
|
|
run_id=ontology.run_id,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
message="我上传了两张交通票据,帮我生成报销草稿",
|
|
|
|
|
|
ontology=ontology,
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"name": "张三",
|
|
|
|
|
|
"attachment_names": ["didi-trip.png", "parking-ticket.jpg"],
|
|
|
|
|
|
"attachment_count": 2,
|
|
|
|
|
|
"draft_claim_id": existing_claim.id,
|
|
|
|
|
|
"ocr_documents": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"filename": "didi-trip.png",
|
|
|
|
|
|
"summary": "滴滴出行 支付金额 32 元",
|
|
|
|
|
|
"text": "滴滴出行 支付金额 32 元",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"filename": "parking-ticket.jpg",
|
|
|
|
|
|
"summary": "停车费 合计 18 元",
|
|
|
|
|
|
"text": "停车费 合计 18 元",
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
db.refresh(existing_claim)
|
|
|
|
|
|
assert result["pending_association_decision"] is True
|
|
|
|
|
|
assert result["association_candidate_claim_id"] == existing_claim.id
|
|
|
|
|
|
assert existing_claim.invoice_count == 1
|
|
|
|
|
|
assert len(existing_claim.items) == 1
|
|
|
|
|
|
assert existing_claim.items[0].invoice_id == "old-trip.png"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 14:24:51 +08:00
|
|
|
|
def test_linked_document_supplement_keeps_existing_claim_expense_type() -> None:
|
|
|
|
|
|
user_id = "type-lock@example.com"
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E5010",
|
|
|
|
|
|
name="类型锁定员工",
|
|
|
|
|
|
email=user_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
existing_claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-202605-020",
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="类型锁定员工",
|
|
|
|
|
|
department_name="市场部",
|
|
|
|
|
|
project_code=None,
|
|
|
|
|
|
expense_type="transport",
|
|
|
|
|
|
reason="原有交通报销",
|
|
|
|
|
|
location="深圳",
|
|
|
|
|
|
amount=Decimal("32.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=1,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
|
|
|
|
|
|
status="draft",
|
|
|
|
|
|
approval_stage="待提交",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
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("32.00"),
|
|
|
|
|
|
invoice_id="old-trip.png",
|
|
|
|
|
|
)
|
|
|
|
|
|
]
|
|
|
|
|
|
db.add(existing_claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
context_json = {
|
|
|
|
|
|
"name": "类型锁定员工",
|
|
|
|
|
|
"review_action": "link_to_existing_draft",
|
|
|
|
|
|
"draft_claim_id": existing_claim.id,
|
|
|
|
|
|
"attachment_names": ["hotel-invoice.pdf"],
|
|
|
|
|
|
"attachment_count": 1,
|
|
|
|
|
|
"ocr_documents": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"filename": "hotel-invoice.pdf",
|
|
|
|
|
|
"document_type": "hotel_invoice",
|
|
|
|
|
|
"scene_code": "hotel",
|
|
|
|
|
|
"scene_label": "住宿票据",
|
|
|
|
|
|
"summary": "酒店住宿 发票金额 300 元",
|
|
|
|
|
|
"text": "酒店住宿 发票金额 ¥300.00",
|
|
|
|
|
|
"document_fields": [
|
|
|
|
|
|
{"key": "amount", "label": "金额", "value": "300"},
|
|
|
|
|
|
{"key": "merchant", "label": "酒店名称", "value": "上海酒店"},
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
ontology = SemanticOntologyService(db).parse(
|
|
|
|
|
|
OntologyParseRequest(
|
|
|
|
|
|
query="把酒店发票补充到现有草稿",
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
context_json=context_json,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
ExpenseClaimService(db).upsert_draft_from_ontology(
|
|
|
|
|
|
run_id=ontology.run_id,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
message="把酒店发票补充到现有草稿",
|
|
|
|
|
|
ontology=ontology,
|
|
|
|
|
|
context_json=context_json,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
db.refresh(existing_claim)
|
|
|
|
|
|
assert existing_claim.expense_type == "transport"
|
|
|
|
|
|
assert any(item.item_type == "hotel_ticket" for item in existing_claim.items)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-14 15:42:45 +00:00
|
|
|
|
def test_upsert_draft_from_ontology_keeps_reason_missing_for_attachment_only_upload() -> None:
|
|
|
|
|
|
user_id = "wangwu@example.com"
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E5003",
|
|
|
|
|
|
name="王五",
|
|
|
|
|
|
email=user_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
ontology = SemanticOntologyService(db).parse(
|
|
|
|
|
|
OntologyParseRequest(
|
|
|
|
|
|
query="我上传了 1 份票据,请结合附件名称给出报销建议并尽量生成草稿。",
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
result = service.upsert_draft_from_ontology(
|
|
|
|
|
|
run_id=ontology.run_id,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
message="我上传了 1 份票据,请结合附件名称给出报销建议并尽量生成草稿。\n附件名称:didi-trip.png",
|
|
|
|
|
|
ontology=ontology,
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"name": "王五",
|
|
|
|
|
|
"user_input_text": "",
|
|
|
|
|
|
"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",
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
claim = db.get(ExpenseClaim, result["claim_id"])
|
|
|
|
|
|
assert claim is not None
|
|
|
|
|
|
assert claim.reason == "待补充"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-14 15:42:45 +00:00
|
|
|
|
def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents() -> None:
|
|
|
|
|
|
user_id = "lisi@example.com"
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E5002",
|
|
|
|
|
|
name="李四",
|
|
|
|
|
|
email=user_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
existing_claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-202605-011",
|
|
|
|
|
|
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="draft",
|
|
|
|
|
|
approval_stage="待提交",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
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="existing.png",
|
|
|
|
|
|
)
|
|
|
|
|
|
]
|
|
|
|
|
|
db.add(existing_claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
ontology = SemanticOntologyService(db).parse(
|
|
|
|
|
|
OntologyParseRequest(
|
|
|
|
|
|
query="我上传了两张交通票据,帮我生成报销草稿",
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
context_json = {
|
|
|
|
|
|
"name": "李四",
|
|
|
|
|
|
"attachment_names": ["didi-trip.png", "parking-ticket.jpg"],
|
|
|
|
|
|
"attachment_count": 2,
|
|
|
|
|
|
"draft_claim_id": existing_claim.id,
|
|
|
|
|
|
"ocr_documents": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"filename": "didi-trip.png",
|
|
|
|
|
|
"summary": "滴滴出行",
|
|
|
|
|
|
"text": "滴滴出行 支付金额 32.50 元",
|
|
|
|
|
|
"document_type": "taxi_receipt",
|
|
|
|
|
|
"scene_code": "transport",
|
|
|
|
|
|
"document_fields": [{"key": "amount", "label": "支付金额", "value": "32.50"}],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"filename": "parking-ticket.jpg",
|
|
|
|
|
|
"summary": "停车票",
|
|
|
|
|
|
"text": "停车费 合计 18 元",
|
|
|
|
|
|
"document_type": "parking_toll_receipt",
|
|
|
|
|
|
"scene_code": "transport",
|
|
|
|
|
|
"document_fields": [{"key": "total_amount", "label": "合计金额", "value": "18"}],
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
link_result = service.upsert_draft_from_ontology(
|
|
|
|
|
|
run_id=ontology.run_id,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
message="把这两张票据关联到已有草稿",
|
|
|
|
|
|
ontology=ontology,
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
**context_json,
|
|
|
|
|
|
"review_action": "link_to_existing_draft",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
db.refresh(existing_claim)
|
|
|
|
|
|
assert link_result["claim_id"] == existing_claim.id
|
|
|
|
|
|
assert existing_claim.invoice_count == 3
|
|
|
|
|
|
assert len(existing_claim.items) == 3
|
|
|
|
|
|
assert float(existing_claim.amount) == 70.5
|
|
|
|
|
|
|
|
|
|
|
|
create_result = service.upsert_draft_from_ontology(
|
|
|
|
|
|
run_id=f"{ontology.run_id}-new",
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
message="单独新建一张报销单",
|
|
|
|
|
|
ontology=ontology,
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
**context_json,
|
|
|
|
|
|
"review_action": "create_new_claim_from_documents",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert create_result["claim_id"] != existing_claim.id
|
|
|
|
|
|
new_claim = db.get(ExpenseClaim, create_result["claim_id"])
|
|
|
|
|
|
assert new_claim is not None
|
|
|
|
|
|
assert new_claim.invoice_count == 2
|
|
|
|
|
|
assert len(new_claim.items) == 2
|
|
|
|
|
|
assert float(new_claim.amount) == 50.5
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 08:58:59 +08:00
|
|
|
|
def test_link_existing_draft_blocks_duplicate_uploaded_invoice() -> None:
|
|
|
|
|
|
user_id = "duplicate@example.com"
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E5010",
|
|
|
|
|
|
name="重复票据员工",
|
|
|
|
|
|
email=user_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
existing_claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-202605-021",
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="重复票据员工",
|
|
|
|
|
|
department_name="销售部",
|
|
|
|
|
|
project_code=None,
|
|
|
|
|
|
expense_type="transport",
|
|
|
|
|
|
reason="原有交通报销",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("32.50"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=1,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 13, tzinfo=UTC),
|
|
|
|
|
|
status="draft",
|
|
|
|
|
|
approval_stage="待提交",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
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("32.50"),
|
|
|
|
|
|
invoice_id="didi-trip.png",
|
|
|
|
|
|
)
|
|
|
|
|
|
]
|
|
|
|
|
|
db.add(existing_claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
context_json = {
|
|
|
|
|
|
"name": "重复票据员工",
|
|
|
|
|
|
"review_action": "link_to_existing_draft",
|
|
|
|
|
|
"draft_claim_id": existing_claim.id,
|
|
|
|
|
|
"attachment_names": ["didi-trip.png"],
|
|
|
|
|
|
"attachment_count": 1,
|
|
|
|
|
|
"ocr_documents": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"filename": "didi-trip.png",
|
|
|
|
|
|
"summary": "滴滴出行 支付金额 32.50 元",
|
|
|
|
|
|
"text": "滴滴出行 支付金额 32.50 元",
|
|
|
|
|
|
"document_type": "taxi_receipt",
|
|
|
|
|
|
"scene_code": "transport",
|
|
|
|
|
|
"document_fields": [{"key": "amount", "label": "支付金额", "value": "32.50"}],
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
ontology = SemanticOntologyService(db).parse(
|
|
|
|
|
|
OntologyParseRequest(
|
|
|
|
|
|
query="把这张票据关联到已有草稿",
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
context_json=context_json,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
result = ExpenseClaimService(db).upsert_draft_from_ontology(
|
|
|
|
|
|
run_id=ontology.run_id,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
message="把这张票据关联到已有草稿",
|
|
|
|
|
|
ontology=ontology,
|
|
|
|
|
|
context_json=context_json,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
db.refresh(existing_claim)
|
|
|
|
|
|
assert result["duplicate_attachment_blocked"] is True
|
|
|
|
|
|
assert result["submission_blocked"] is True
|
|
|
|
|
|
assert "重复" in result["message"]
|
|
|
|
|
|
assert "重新上传不同的票据" in result["message"]
|
|
|
|
|
|
assert len(existing_claim.items) == 1
|
|
|
|
|
|
assert existing_claim.invoice_count == 1
|
|
|
|
|
|
assert float(existing_claim.amount) == 32.5
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 10:57:06 +08:00
|
|
|
|
def test_upsert_travel_draft_uses_ticket_item_types_and_auto_allowance() -> None:
|
|
|
|
|
|
user_id = "travel-allowance@example.com"
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E5010",
|
|
|
|
|
|
name="差旅员工",
|
|
|
|
|
|
email=user_id,
|
|
|
|
|
|
grade="P4",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
ontology = SemanticOntologyService(db).parse(
|
|
|
|
|
|
OntologyParseRequest(
|
|
|
|
|
|
query="我去北京出差 3 天,上传了火车票,帮我生成差旅费报销草稿",
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
result = ExpenseClaimService(db).upsert_draft_from_ontology(
|
|
|
|
|
|
run_id=ontology.run_id,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
message="我去北京出差 3 天,上传了火车票,帮我生成差旅费报销草稿",
|
|
|
|
|
|
ontology=ontology,
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"name": "差旅员工",
|
|
|
|
|
|
"grade": "P4",
|
|
|
|
|
|
"attachment_names": ["train-ticket.png"],
|
|
|
|
|
|
"attachment_count": 1,
|
|
|
|
|
|
"review_form_values": {
|
|
|
|
|
|
"expense_type": "差旅费",
|
|
|
|
|
|
"location": "北京",
|
|
|
|
|
|
"time_range": "2026-05-13 至 2026-05-15",
|
|
|
|
|
|
},
|
|
|
|
|
|
"business_time_context": {
|
|
|
|
|
|
"mode": "range",
|
|
|
|
|
|
"start_date": "2026-05-13",
|
|
|
|
|
|
"end_date": "2026-05-15",
|
|
|
|
|
|
"display_value": "2026-05-13 至 2026-05-15",
|
|
|
|
|
|
},
|
|
|
|
|
|
"ocr_documents": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"filename": "train-ticket.png",
|
|
|
|
|
|
"document_type": "train_ticket",
|
|
|
|
|
|
"document_type_label": "火车/高铁票",
|
|
|
|
|
|
"scene_code": "travel",
|
|
|
|
|
|
"scene_label": "差旅费",
|
|
|
|
|
|
"summary": "中国铁路电子客票 广州南-北京南 二等座 票价 354 元",
|
|
|
|
|
|
"text": "中国铁路电子客票 广州南-北京南 二等座 票价:¥354.00",
|
|
|
|
|
|
"document_fields": [
|
|
|
|
|
|
{"key": "amount", "label": "票价", "value": "¥354.00"},
|
|
|
|
|
|
{"key": "route", "label": "行程", "value": "广州南-北京南"},
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
claim = db.get(ExpenseClaim, result["claim_id"])
|
|
|
|
|
|
assert claim is not None
|
|
|
|
|
|
assert claim.expense_type == "travel"
|
|
|
|
|
|
assert claim.invoice_count == 1
|
|
|
|
|
|
assert len(claim.items) == 2
|
|
|
|
|
|
train_item = next(item for item in claim.items if item.item_type == "train_ticket")
|
|
|
|
|
|
allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
|
|
|
|
|
|
assert train_item.item_amount == Decimal("354.00")
|
2026-05-21 14:24:51 +08:00
|
|
|
|
assert train_item.item_reason == "广州南-北京南"
|
2026-05-21 10:57:06 +08:00
|
|
|
|
assert allowance_item.item_amount == Decimal("300.00")
|
|
|
|
|
|
assert allowance_item.invoice_id is None
|
|
|
|
|
|
assert allowance_item.is_system_generated is True
|
|
|
|
|
|
assert claim.amount == Decimal("654.00")
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError, match="系统自动计算"):
|
|
|
|
|
|
ExpenseClaimService(db).update_claim_item(
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_id=allowance_item.id,
|
|
|
|
|
|
payload=ExpenseClaimItemUpdate(item_amount=Decimal("1.00")),
|
|
|
|
|
|
current_user=CurrentUserContext(
|
|
|
|
|
|
username=user_id,
|
|
|
|
|
|
name="差旅员工",
|
|
|
|
|
|
role_codes=[],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 23:52:34 +08:00
|
|
|
|
def test_upsert_travel_draft_uses_explicit_text_days_for_allowance() -> None:
|
|
|
|
|
|
user_id = "travel-explicit-days@example.com"
|
|
|
|
|
|
message = "业务发生时间:2026-05-20 至 2026-05-23,去上海支撑上海电力服务器部署,出差3天,申请差旅费报销"
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E5012",
|
|
|
|
|
|
name="文本差旅员工",
|
|
|
|
|
|
email=user_id,
|
|
|
|
|
|
grade="P4",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
ontology = SemanticOntologyService(db).parse(
|
|
|
|
|
|
OntologyParseRequest(
|
|
|
|
|
|
query=message,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
context_json={"name": "文本差旅员工", "grade": "P4"},
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
result = ExpenseClaimService(db).upsert_draft_from_ontology(
|
|
|
|
|
|
run_id=ontology.run_id,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
message=message,
|
|
|
|
|
|
ontology=ontology,
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"name": "文本差旅员工",
|
|
|
|
|
|
"grade": "P4",
|
|
|
|
|
|
"user_input_text": message,
|
|
|
|
|
|
"review_form_values": {
|
|
|
|
|
|
"expense_type": "差旅费",
|
|
|
|
|
|
"business_location": "上海",
|
|
|
|
|
|
"reason": "去上海支撑上海电力服务器部署,出差3天",
|
|
|
|
|
|
"time_range": "2026-05-20 至 2026-05-23",
|
|
|
|
|
|
"business_time": "2026-05-20 至 2026-05-23",
|
|
|
|
|
|
},
|
|
|
|
|
|
"business_time_context": {
|
|
|
|
|
|
"mode": "range",
|
|
|
|
|
|
"start_date": "2026-05-20",
|
|
|
|
|
|
"end_date": "2026-05-23",
|
|
|
|
|
|
"display_value": "2026-05-20 至 2026-05-23",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
claim = db.get(ExpenseClaim, result["claim_id"])
|
|
|
|
|
|
assert claim is not None
|
|
|
|
|
|
assert claim.expense_type == "travel"
|
|
|
|
|
|
allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
|
|
|
|
|
|
assert allowance_item.item_amount == Decimal("300.00")
|
|
|
|
|
|
assert "3天" in allowance_item.item_reason
|
|
|
|
|
|
assert allowance_item.item_date == date(2026, 5, 22)
|
|
|
|
|
|
assert claim.amount == Decimal("300.00")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 16:09:47 +08:00
|
|
|
|
def test_sync_travel_claim_adds_allowance_from_manual_ticket_dates() -> None:
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E5011",
|
|
|
|
|
|
name="手工差旅员工",
|
|
|
|
|
|
email="manual-travel-allowance@example.com",
|
|
|
|
|
|
grade="P4",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
|
|
|
|
|
|
claim = build_claim(expense_type="travel", location="北京")
|
|
|
|
|
|
claim.employee_id = employee.id
|
|
|
|
|
|
claim.employee_name = employee.name
|
|
|
|
|
|
claim.items[0].item_date = date(2026, 5, 13)
|
|
|
|
|
|
claim.items[0].item_type = "train_ticket"
|
|
|
|
|
|
claim.items[0].item_reason = "广州南-北京南"
|
|
|
|
|
|
claim.items[0].item_location = "北京"
|
|
|
|
|
|
claim.items[0].item_amount = Decimal("354.00")
|
|
|
|
|
|
claim.items.append(
|
|
|
|
|
|
ExpenseClaimItem(
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_date=date(2026, 5, 15),
|
|
|
|
|
|
item_type="train_ticket",
|
|
|
|
|
|
item_reason="北京南-广州南",
|
|
|
|
|
|
item_location="北京",
|
|
|
|
|
|
item_amount=Decimal("354.00"),
|
|
|
|
|
|
invoice_id="return-train.png",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
service._sync_claim_from_items(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
db.refresh(claim)
|
|
|
|
|
|
|
|
|
|
|
|
allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
|
|
|
|
|
|
assert allowance_item.item_amount == Decimal("300.00")
|
|
|
|
|
|
assert "3天" in allowance_item.item_reason
|
|
|
|
|
|
assert allowance_item.invoice_id is None
|
|
|
|
|
|
assert claim.amount == Decimal("1008.00")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_update_claim_item_allows_placeholder_date_reason_and_amount() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="emp-1",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=[],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
claim = build_claim(expense_type="office", location="深圳")
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
updated = ExpenseClaimService(db).update_claim_item(
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_id=claim.items[0].id,
|
|
|
|
|
|
payload=ExpenseClaimItemUpdate(
|
|
|
|
|
|
item_reason="",
|
|
|
|
|
|
item_location="",
|
2026-06-03 15:46:56 +08:00
|
|
|
|
item_note="票据行程存在改签,已核对业务真实发生。",
|
2026-05-21 16:09:47 +08:00
|
|
|
|
item_amount=Decimal("0.00"),
|
|
|
|
|
|
),
|
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert updated is not None
|
|
|
|
|
|
db.refresh(claim)
|
|
|
|
|
|
assert claim.items[0].item_date == date(2026, 5, 13)
|
|
|
|
|
|
assert claim.items[0].item_reason == ""
|
|
|
|
|
|
assert claim.items[0].item_location == ""
|
2026-06-03 15:46:56 +08:00
|
|
|
|
assert claim.items[0].item_note == "票据行程存在改签,已核对业务真实发生。"
|
2026-05-21 16:09:47 +08:00
|
|
|
|
assert claim.items[0].item_amount == Decimal("0.00")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
def test_generate_claim_no_uses_re_prefix_timestamp_and_random_suffix() -> None:
|
2026-05-14 15:42:45 +00:00
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
db.add_all(
|
|
|
|
|
|
[
|
|
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-202605-001",
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_name="市场部",
|
|
|
|
|
|
project_code=None,
|
|
|
|
|
|
expense_type="transport",
|
|
|
|
|
|
reason="交通报销",
|
|
|
|
|
|
location="深圳",
|
|
|
|
|
|
amount=Decimal("10.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=1,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 10, tzinfo=UTC),
|
|
|
|
|
|
status="draft",
|
|
|
|
|
|
approval_stage="待提交",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
),
|
|
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-202605-003",
|
|
|
|
|
|
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, 11, tzinfo=UTC),
|
|
|
|
|
|
status="submitted",
|
|
|
|
|
|
approval_stage="审批中",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
assert re.fullmatch(
|
|
|
|
|
|
r"RE-\d{14}-[A-HJ-NP-Z2-9]{8}",
|
|
|
|
|
|
service._generate_claim_no(datetime(2026, 5, 14, tzinfo=UTC)),
|
|
|
|
|
|
)
|
2026-05-14 15:42:45 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
|
|
|
|
|
|
user_id = "zhaoliu-claimno@example.com"
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E5006",
|
|
|
|
|
|
name="赵六",
|
|
|
|
|
|
email=user_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
db.add(
|
|
|
|
|
|
ExpenseClaim(
|
2026-05-26 09:15:14 +08:00
|
|
|
|
claim_no="RE-20260525101010-ABCDEFGH",
|
2026-05-14 15:42:45 +00:00
|
|
|
|
employee_name="历史单据",
|
|
|
|
|
|
department_name="财务部",
|
|
|
|
|
|
project_code=None,
|
|
|
|
|
|
expense_type="other",
|
|
|
|
|
|
reason="历史草稿",
|
|
|
|
|
|
location="北京",
|
|
|
|
|
|
amount=Decimal("0.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 12, tzinfo=UTC),
|
|
|
|
|
|
status="submitted",
|
|
|
|
|
|
approval_stage="审批中",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
ontology = SemanticOntologyService(db).parse(
|
|
|
|
|
|
OntologyParseRequest(
|
|
|
|
|
|
query="帮我生成报销草稿,我昨天交通费 13.4 元",
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
2026-05-26 09:15:14 +08:00
|
|
|
|
generated_claim_nos = iter(
|
|
|
|
|
|
["RE-20260525101010-ABCDEFGH", "RE-20260525101010-HGFEDCBA"]
|
|
|
|
|
|
)
|
2026-05-14 15:42:45 +00:00
|
|
|
|
service._generate_claim_no = lambda occurred_at: next(generated_claim_nos)
|
|
|
|
|
|
|
|
|
|
|
|
result = service.upsert_draft_from_ontology(
|
|
|
|
|
|
run_id=ontology.run_id,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
message="帮我生成报销草稿,我昨天交通费 13.4 元",
|
|
|
|
|
|
ontology=ontology,
|
|
|
|
|
|
context_json={
|
|
|
|
|
|
"name": "赵六",
|
|
|
|
|
|
"user_input_text": "帮我生成报销草稿,我昨天交通费 13.4 元",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
created_claim = db.get(ExpenseClaim, result["claim_id"])
|
|
|
|
|
|
assert created_claim is not None
|
2026-05-26 09:15:14 +08:00
|
|
|
|
assert created_claim.claim_no == "RE-20260525101010-HGFEDCBA"
|
|
|
|
|
|
assert result["claim_no"] == "RE-20260525101010-HGFEDCBA"
|
2026-05-14 15:42:45 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 06:46:24 +00:00
|
|
|
|
def test_create_claim_item_adds_blank_draft_row_without_forcing_attachment() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="emp-1",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=[],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
claim = build_claim(expense_type="office", location="深圳南山")
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
updated = service.create_claim_item(
|
|
|
|
|
|
claim_id=claim.id,
|
2026-06-03 15:46:56 +08:00
|
|
|
|
payload=ExpenseClaimItemCreate(item_note="待上传异常票据说明"),
|
2026-05-13 06:46:24 +00:00
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert updated is not None
|
|
|
|
|
|
assert len(updated.items) == 2
|
|
|
|
|
|
assert updated.amount == Decimal("88.00")
|
|
|
|
|
|
assert updated.invoice_count == 1
|
|
|
|
|
|
|
|
|
|
|
|
new_item = next(item for item in updated.items if item.id != "item-1")
|
|
|
|
|
|
assert new_item.item_type == "office"
|
|
|
|
|
|
assert new_item.item_reason == ""
|
|
|
|
|
|
assert new_item.item_location == ""
|
2026-06-03 15:46:56 +08:00
|
|
|
|
assert new_item.item_note == "待上传异常票据说明"
|
2026-05-13 06:46:24 +00:00
|
|
|
|
assert new_item.item_amount == Decimal("0.00")
|
|
|
|
|
|
assert new_item.invoice_id is None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 10:57:06 +08:00
|
|
|
|
def test_update_claim_reason_only_allows_draft_pending_submission() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="emp-1",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=[],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
claim = build_claim(expense_type="travel", location="北京")
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
updated = service.update_claim(
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
payload=ExpenseClaimUpdate(reason="去北京客户现场出差,处理项目验收事项"),
|
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert updated is not None
|
|
|
|
|
|
assert updated.reason == "去北京客户现场出差,处理项目验收事项"
|
|
|
|
|
|
|
|
|
|
|
|
claim.status = "submitted"
|
|
|
|
|
|
claim.submitted_at = datetime(2026, 5, 14, tzinfo=UTC)
|
|
|
|
|
|
claim.approval_stage = "直属领导审批"
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError, match="草稿待提交"):
|
|
|
|
|
|
service.update_claim(
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
payload=ExpenseClaimUpdate(reason="提交后不能改"),
|
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 06:46:24 +00:00
|
|
|
|
def test_update_claim_item_reanalyzes_existing_attachment(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="office-note.png",
|
|
|
|
|
|
media_type="image/png",
|
|
|
|
|
|
text="办公用品发票 金额88元 2026-05-13",
|
|
|
|
|
|
summary="识别到办公用品发票,金额 88 元。",
|
|
|
|
|
|
avg_score=0.98,
|
|
|
|
|
|
line_count=1,
|
|
|
|
|
|
page_count=1,
|
|
|
|
|
|
warnings=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
2026-05-22 10:42:31 +08:00
|
|
|
|
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
2026-05-13 06:46:24 +00:00
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
claim = build_claim(expense_type="office", location="深圳南山")
|
|
|
|
|
|
claim.invoice_count = 0
|
|
|
|
|
|
claim.items[0].invoice_id = None
|
|
|
|
|
|
claim.items[0].item_reason = "办公用品采购"
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
service.upload_claim_item_attachment(
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_id=claim.items[0].id,
|
|
|
|
|
|
filename="office-note.png",
|
|
|
|
|
|
content=b"fake-image-bytes",
|
|
|
|
|
|
media_type="image/png",
|
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-14 15:42:45 +00:00
|
|
|
|
assert uploaded_meta["preview_kind"] == "image"
|
|
|
|
|
|
assert uploaded_meta["preview_url"].endswith(
|
|
|
|
|
|
f"/reimbursements/claims/{claim.id}/items/{claim.items[0].id}/attachment/preview"
|
|
|
|
|
|
)
|
2026-05-13 06:46:24 +00:00
|
|
|
|
assert uploaded_meta["analysis"]["severity"] == "pass"
|
2026-05-14 09:32:36 +00:00
|
|
|
|
assert uploaded_meta["document_info"]["document_type"] == "office_invoice"
|
|
|
|
|
|
assert uploaded_meta["requirement_check"]["matches"] is True
|
2026-05-13 06:46:24 +00:00
|
|
|
|
|
|
|
|
|
|
updated = service.update_claim_item(
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_id=claim.items[0].id,
|
|
|
|
|
|
payload=ExpenseClaimItemUpdate(
|
|
|
|
|
|
item_type="transport",
|
|
|
|
|
|
item_reason="打车报销",
|
|
|
|
|
|
),
|
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert updated is not None
|
|
|
|
|
|
assert any(flag.get("source") == "attachment_analysis" for flag in updated.risk_flags_json)
|
|
|
|
|
|
|
|
|
|
|
|
refreshed_meta = service.get_claim_item_attachment_meta(
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_id=claim.items[0].id,
|
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert refreshed_meta is not None
|
2026-05-14 09:32:36 +00:00
|
|
|
|
assert refreshed_meta["analysis"]["severity"] == "high"
|
|
|
|
|
|
assert refreshed_meta["requirement_check"]["matches"] is False
|
|
|
|
|
|
assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"])
|
2026-05-13 06:46:24 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 09:28:33 +08:00
|
|
|
|
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",
|
2026-05-21 14:24:51 +08:00
|
|
|
|
text="中国铁路电子客票 广州南-北京南 二等座 2026-02-20 08:30开 票价:¥354.00",
|
|
|
|
|
|
summary="铁路电子客票,2026-02-20 08:30 广州南至北京南,票价 354 元。",
|
2026-05-21 09:28:33 +08:00
|
|
|
|
avg_score=0.98,
|
|
|
|
|
|
line_count=1,
|
|
|
|
|
|
page_count=1,
|
|
|
|
|
|
document_type="train_ticket",
|
|
|
|
|
|
document_type_label="火车/高铁票",
|
|
|
|
|
|
scene_code="travel",
|
|
|
|
|
|
scene_label="差旅费",
|
|
|
|
|
|
document_fields=[
|
2026-05-21 14:24:51 +08:00
|
|
|
|
{"key": "invoice_date", "label": "开票日期", "value": "2026-02-18"},
|
|
|
|
|
|
{"key": "trip_date", "label": "行程日期", "value": "2026-02-20 08:30"},
|
2026-05-21 09:28:33 +08:00
|
|
|
|
{"key": "fare", "label": "票价", "value": "¥354.00"},
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
2026-05-22 10:42:31 +08:00
|
|
|
|
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
2026-05-21 09:28:33 +08:00
|
|
|
|
|
|
|
|
|
|
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")
|
2026-05-21 16:09:47 +08:00
|
|
|
|
assert updated["item_date"] == "2026-02-20"
|
|
|
|
|
|
assert updated["item_type"] == "train_ticket"
|
|
|
|
|
|
assert updated["item_reason"] == "广州南-北京南"
|
2026-05-21 09:28:33 +08:00
|
|
|
|
assert updated["claim_amount"] == Decimal("354.00")
|
|
|
|
|
|
db.refresh(claim)
|
|
|
|
|
|
assert claim.items[0].item_amount == Decimal("354.00")
|
2026-05-21 10:57:06 +08:00
|
|
|
|
assert claim.items[0].item_type == "train_ticket"
|
2026-05-21 14:24:51 +08:00
|
|
|
|
assert claim.items[0].item_date == date(2026, 2, 20)
|
|
|
|
|
|
assert claim.items[0].item_reason == "广州南-北京南"
|
2026-05-21 09:28:33 +08: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"
|
2026-05-21 14:24:51 +08:00
|
|
|
|
assert any(
|
|
|
|
|
|
field["label"] == "列车出发时间" and field["value"] == "2026-02-20 08:30"
|
|
|
|
|
|
for field in uploaded_meta["document_info"]["fields"]
|
|
|
|
|
|
)
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
field["label"] == "开票日期" and field["value"] == "2026-02-18"
|
|
|
|
|
|
for field in uploaded_meta["document_info"]["fields"]
|
|
|
|
|
|
)
|
2026-05-21 09:28:33 +08:00
|
|
|
|
assert any(
|
|
|
|
|
|
field["label"] == "票价" and field["value"] == "¥354.00"
|
|
|
|
|
|
for field in uploaded_meta["document_info"]["fields"]
|
|
|
|
|
|
)
|
2026-05-21 14:24:51 +08:00
|
|
|
|
assert not any("用途字段" in point for point in uploaded_meta["analysis"]["points"])
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-01 17:07:14 +08:00
|
|
|
|
def test_upload_attachment_response_includes_refreshed_rule_center_risk_flags(
|
|
|
|
|
|
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="中国铁路电子客票 武汉-上海 2026-02-20 票价354元",
|
|
|
|
|
|
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": "route", "label": "行程", "value": "武汉-上海"},
|
|
|
|
|
|
{"key": "trip_date", "label": "行程日期", "value": "2026-02-20"},
|
|
|
|
|
|
{"key": "fare", "label": "票价", "value": "354元"},
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def fake_evaluate_platform_risk_rules(self, claim, **kwargs):
|
|
|
|
|
|
assert kwargs.get("business_stage") == "reimbursement"
|
|
|
|
|
|
return {
|
|
|
|
|
|
"flags": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"source": "submission_review",
|
|
|
|
|
|
"hit_source": "rule_center",
|
|
|
|
|
|
"rule_type": "risk",
|
|
|
|
|
|
"rule_code": "risk.test.upload_preview",
|
|
|
|
|
|
"severity": "high",
|
|
|
|
|
|
"message": "测试规则命中",
|
|
|
|
|
|
"business_stage": "reimbursement",
|
|
|
|
|
|
"risk_domain": "invoice",
|
|
|
|
|
|
"visibility_scope": "submitter",
|
|
|
|
|
|
"actionability": "fixable_by_submitter",
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
"blocking_reasons": ["测试规则命中"],
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
|
|
|
|
|
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
ExpenseClaimService,
|
|
|
|
|
|
"evaluate_platform_risk_rules",
|
|
|
|
|
|
fake_evaluate_platform_risk_rules,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
claim = build_claim(expense_type="travel", location="北京")
|
|
|
|
|
|
claim.items[0].invoice_id = None
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
payload = ExpenseClaimService(db).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 payload is not None
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
flag.get("rule_code") == "risk.test.upload_preview"
|
|
|
|
|
|
for flag in payload["claim_risk_flags"]
|
|
|
|
|
|
)
|
|
|
|
|
|
db.refresh(claim)
|
|
|
|
|
|
assert payload["claim_risk_flags"] == claim.risk_flags_json
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 16:09:47 +08:00
|
|
|
|
def test_upload_hotel_attachment_audits_date_like_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="hotel-invoice.png",
|
|
|
|
|
|
media_type="image/png",
|
|
|
|
|
|
text="北京中心酒店 总费用是828元 入住日期 2026-02-20 离店日期 2026-02-21",
|
|
|
|
|
|
summary="酒店住宿票据,住宿总费用 828 元。",
|
|
|
|
|
|
avg_score=0.96,
|
|
|
|
|
|
line_count=1,
|
|
|
|
|
|
page_count=1,
|
|
|
|
|
|
document_type="hotel_invoice",
|
|
|
|
|
|
document_type_label="酒店住宿票据",
|
|
|
|
|
|
scene_code="hotel",
|
|
|
|
|
|
scene_label="住宿票据",
|
|
|
|
|
|
document_fields=[
|
|
|
|
|
|
{"key": "amount", "label": "金额", "value": "2026元"},
|
|
|
|
|
|
{"key": "hotel_name", "label": "酒店", "value": "北京中心酒店"},
|
|
|
|
|
|
{"key": "check_in", "label": "入住日期", "value": "2026-02-20"},
|
|
|
|
|
|
{"key": "check_out", "label": "离店日期", "value": "2026-02-21"},
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
2026-05-22 10:42:31 +08:00
|
|
|
|
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
2026-05-21 16:09:47 +08:00
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
claim = build_claim(expense_type="hotel", location="北京")
|
|
|
|
|
|
claim.amount = Decimal("0.00")
|
|
|
|
|
|
claim.invoice_count = 0
|
|
|
|
|
|
claim.items[0].item_type = "hotel"
|
|
|
|
|
|
claim.items[0].item_reason = ""
|
|
|
|
|
|
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="hotel-invoice.png",
|
|
|
|
|
|
content=b"fake-image-bytes",
|
|
|
|
|
|
media_type="image/png",
|
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert updated is not None
|
|
|
|
|
|
assert updated["item_type"] == "hotel_ticket"
|
|
|
|
|
|
assert updated["item_amount"] == Decimal("828.00")
|
|
|
|
|
|
assert updated["claim_amount"] == Decimal("828.00")
|
|
|
|
|
|
db.refresh(claim)
|
|
|
|
|
|
assert claim.items[0].item_amount == Decimal("828.00")
|
|
|
|
|
|
assert claim.amount == Decimal("828.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["analysis"]["severity"] == "medium"
|
|
|
|
|
|
assert any("费用核算" in point and "828.00 元" in point for point in uploaded_meta["analysis"]["points"])
|
|
|
|
|
|
assert not any("2026.00 元与报销金额" in point for point in uploaded_meta["analysis"]["points"])
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 23:52:34 +08:00
|
|
|
|
def test_upload_hotel_attachment_flags_amount_over_travel_policy(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)
|
2026-05-22 10:42:31 +08:00
|
|
|
|
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
2026-05-21 23:52:34 +08:00
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E7401",
|
|
|
|
|
|
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.amount = Decimal("0.00")
|
|
|
|
|
|
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)
|
|
|
|
|
|
updated = 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 updated is not None
|
|
|
|
|
|
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
|
|
|
|
|
|
analysis = uploaded_meta["analysis"]
|
|
|
|
|
|
assert analysis["severity"] == "high"
|
|
|
|
|
|
assert analysis["headline"] == "AI提示:住宿金额超出报销标准"
|
2026-05-22 16:00:19 +08:00
|
|
|
|
assert "保留在单据中" in analysis["summary"]
|
|
|
|
|
|
assert "特殊情况" in analysis["summary"]
|
2026-05-21 23:52:34 +08:00
|
|
|
|
assert any("住宿标准" in point and "800.00 元" in point for point in analysis["points"])
|
|
|
|
|
|
assert any("住宿费按员工职级" in basis for basis in analysis["rule_basis"])
|
2026-05-22 16:00:19 +08:00
|
|
|
|
db.refresh(claim)
|
|
|
|
|
|
hotel_item = next(item for item in claim.items if str(item.invoice_id or "").strip())
|
|
|
|
|
|
assert hotel_item.item_amount == Decimal("800.00")
|
|
|
|
|
|
assert claim.invoice_count == 1
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and str(flag.get("source") or "").strip() == "attachment_analysis"
|
|
|
|
|
|
and flag.get("item_id") == hotel_item.id
|
|
|
|
|
|
and str(flag.get("severity") or "").strip() == "high"
|
|
|
|
|
|
for flag in list(claim.risk_flags_json or [])
|
|
|
|
|
|
)
|
2026-05-21 23:52:34 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 14:24:51 +08:00
|
|
|
|
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="上海")
|
|
|
|
|
|
claim.items[0].item_type = "train_ticket"
|
|
|
|
|
|
claim.items[0].item_reason = "2026-02-20 至 2026-02-23,支撑上海电力项目部署"
|
|
|
|
|
|
claim.items[0].item_amount = Decimal("354.00")
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
document = OcrRecognizeDocumentRead(
|
|
|
|
|
|
filename="train-ticket.png",
|
|
|
|
|
|
media_type="image/png",
|
|
|
|
|
|
text="中国铁路电子客票 上海虹桥-武汉 二等座 2026-02-20 票价:¥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": "amount", "label": "票价", "value": "¥354.00"},
|
|
|
|
|
|
{"key": "date", "label": "日期", "value": "2026-02-20"},
|
|
|
|
|
|
{"key": "route", "label": "行程", "value": "上海虹桥-武汉"},
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
analysis = ExpenseClaimService(db)._build_attachment_analysis(
|
|
|
|
|
|
document=document,
|
|
|
|
|
|
item=claim.items[0],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert analysis["severity"] == "medium"
|
|
|
|
|
|
assert not any("用途字段" in point for point in analysis["points"])
|
2026-05-21 16:09:47 +08:00
|
|
|
|
assert any("行程说明" in point and "起始地-目的地" in point for point in analysis["points"])
|
2026-05-21 14:24:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_attachment_risk_flag_message_uses_specific_points(monkeypatch, tmp_path) -> None:
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
claim = build_claim(expense_type="travel", location="上海")
|
|
|
|
|
|
claim.items[0].invoice_id = "invoice.png"
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
generic_summary = "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。"
|
|
|
|
|
|
file_path = tmp_path / "invoice.png"
|
|
|
|
|
|
file_path.write_bytes(b"fake")
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
monkeypatch.setattr(
|
2026-05-22 10:42:31 +08:00
|
|
|
|
ExpenseClaimAttachmentStorage,
|
|
|
|
|
|
"resolve_path",
|
|
|
|
|
|
lambda self, storage_key: file_path,
|
|
|
|
|
|
)
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
service._attachment_storage,
|
|
|
|
|
|
"read_meta",
|
2026-05-21 14:24:51 +08:00
|
|
|
|
lambda path: {
|
|
|
|
|
|
"analysis": {
|
|
|
|
|
|
"severity": "medium",
|
|
|
|
|
|
"label": "中风险",
|
|
|
|
|
|
"summary": generic_summary,
|
|
|
|
|
|
"points": [
|
|
|
|
|
|
"日期字段:未识别到开票日期或业务发生日期。",
|
|
|
|
|
|
"金额字段:附件识别金额 300.00 元与报销金额 88.00 元不一致。",
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
flags = service._build_claim_attachment_risk_flags([claim.items[0]])
|
|
|
|
|
|
|
|
|
|
|
|
assert len(flags) == 1
|
|
|
|
|
|
assert "日期字段:未识别到开票日期或业务发生日期。" in flags[0]["message"]
|
|
|
|
|
|
assert "当前附件可见部分内容" not in flags[0]["message"]
|
|
|
|
|
|
assert flags[0]["summary"] == generic_summary
|
|
|
|
|
|
assert flags[0]["points"] == [
|
|
|
|
|
|
"日期字段:未识别到开票日期或业务发生日期。",
|
|
|
|
|
|
"金额字段:附件识别金额 300.00 元与报销金额 88.00 元不一致。",
|
|
|
|
|
|
]
|
2026-05-21 09:28:33 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 10:57:06 +08:00
|
|
|
|
def test_upload_ride_receipt_backfills_item_reason_from_addresses(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="ride-receipt.png",
|
|
|
|
|
|
media_type="image/png",
|
|
|
|
|
|
text="滴滴出行订单 起点:深圳北站 终点:腾讯滨海大厦 实付金额:42.00元",
|
|
|
|
|
|
summary="滴滴出行乘车票据,深圳北站到腾讯滨海大厦,金额 42 元。",
|
|
|
|
|
|
avg_score=0.98,
|
|
|
|
|
|
line_count=1,
|
|
|
|
|
|
page_count=1,
|
|
|
|
|
|
document_type="taxi_receipt",
|
|
|
|
|
|
document_type_label="出租车/网约车票据",
|
|
|
|
|
|
scene_code="transport",
|
|
|
|
|
|
scene_label="交通票据",
|
|
|
|
|
|
document_fields=[
|
|
|
|
|
|
{"key": "start_location", "label": "起点", "value": "深圳北站"},
|
|
|
|
|
|
{"key": "end_location", "label": "终点", "value": "腾讯滨海大厦"},
|
|
|
|
|
|
{"key": "amount", "label": "实付金额", "value": "42.00元"},
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
2026-05-22 10:42:31 +08:00
|
|
|
|
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
2026-05-21 10:57:06 +08:00
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
claim = build_claim(expense_type="transport", location="深圳")
|
|
|
|
|
|
claim.amount = Decimal("0.00")
|
|
|
|
|
|
claim.invoice_count = 0
|
|
|
|
|
|
claim.items[0].item_type = "transport"
|
|
|
|
|
|
claim.items[0].item_reason = "打车报销"
|
|
|
|
|
|
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="ride-receipt.png",
|
|
|
|
|
|
content=b"fake-image-bytes",
|
|
|
|
|
|
media_type="image/png",
|
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert updated is not None
|
|
|
|
|
|
db.refresh(claim)
|
|
|
|
|
|
assert claim.items[0].item_type == "ride_ticket"
|
2026-05-21 14:24:51 +08:00
|
|
|
|
assert claim.items[0].item_reason == "深圳北站-腾讯滨海大厦"
|
2026-05-21 10:57:06 +08:00
|
|
|
|
assert claim.items[0].item_amount == Decimal("42.00")
|
|
|
|
|
|
assert claim.amount == Decimal("42.00")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 06:46:24 +00:00
|
|
|
|
def test_delete_claim_item_removes_row_and_attachment_files(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="office-note.png",
|
|
|
|
|
|
media_type="image/png",
|
|
|
|
|
|
text="办公用品发票 金额88元 2026-05-13",
|
|
|
|
|
|
summary="识别到办公用品发票,金额 88 元。",
|
|
|
|
|
|
avg_score=0.98,
|
|
|
|
|
|
line_count=1,
|
|
|
|
|
|
page_count=1,
|
|
|
|
|
|
warnings=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
2026-05-22 10:42:31 +08:00
|
|
|
|
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
2026-05-13 06:46:24 +00:00
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
claim = build_claim(expense_type="office", location="深圳南山")
|
|
|
|
|
|
claim.invoice_count = 0
|
|
|
|
|
|
claim.items[0].invoice_id = None
|
|
|
|
|
|
claim.items[0].item_reason = "办公用品采购"
|
|
|
|
|
|
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="office-note.png",
|
|
|
|
|
|
content=b"fake-image-bytes",
|
|
|
|
|
|
media_type="image/png",
|
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert upload_payload is not None
|
|
|
|
|
|
attachment_root = tmp_path / claim.id / claim.items[0].id
|
|
|
|
|
|
assert attachment_root.exists()
|
|
|
|
|
|
|
|
|
|
|
|
delete_payload = service.delete_claim_item(
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_id=claim.items[0].id,
|
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert delete_payload is not None
|
|
|
|
|
|
assert delete_payload["claim_id"] == claim.id
|
|
|
|
|
|
refreshed_claim = service.get_claim(claim.id, current_user)
|
|
|
|
|
|
assert refreshed_claim is not None
|
|
|
|
|
|
assert refreshed_claim.items == []
|
|
|
|
|
|
assert refreshed_claim.amount == Decimal("0.00")
|
|
|
|
|
|
assert refreshed_claim.invoice_count == 0
|
|
|
|
|
|
assert not attachment_root.exists()
|
2026-05-13 15:42:25 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 23:52:34 +08:00
|
|
|
|
def test_delete_claim_removes_all_claim_attachment_files(monkeypatch, tmp_path) -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="emp-1",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=[],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
2026-05-22 10:42:31 +08:00
|
|
|
|
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
2026-05-21 23:52:34 +08:00
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
claim = build_claim(expense_type="office", location="深圳南山")
|
|
|
|
|
|
attachment_dir = tmp_path / claim.id / claim.items[0].id
|
|
|
|
|
|
attachment_dir.mkdir(parents=True)
|
|
|
|
|
|
attachment_path = attachment_dir / "office-note.png"
|
|
|
|
|
|
attachment_path.write_bytes(b"fake-image-bytes")
|
|
|
|
|
|
(attachment_dir / "office-note.png.meta.json").write_text("{}", encoding="utf-8")
|
|
|
|
|
|
orphan_path = tmp_path / claim.id / "orphan-preview.png"
|
|
|
|
|
|
orphan_path.write_bytes(b"orphan-preview")
|
|
|
|
|
|
claim.items[0].invoice_id = f"{claim.id}/{claim.items[0].id}/office-note.png"
|
|
|
|
|
|
db.add(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,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
claim_id = claim.id
|
|
|
|
|
|
claim_root = tmp_path / claim.id
|
|
|
|
|
|
|
|
|
|
|
|
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
assert deleted is not None
|
|
|
|
|
|
assert db.get(ExpenseClaim, claim_id) is None
|
|
|
|
|
|
assert not claim_root.exists()
|
|
|
|
|
|
assert AgentConversationService(db).get_conversation(conversation.conversation_id) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-22 10:42:31 +08:00
|
|
|
|
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
2026-05-20 21:00:47 +08:00
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-15 06:56:00 +00:00
|
|
|
|
def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="emp-submit@example.com",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=[],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="E7000",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
email="manager@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E7001",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="emp-submit@example.com",
|
|
|
|
|
|
manager=manager,
|
|
|
|
|
|
)
|
|
|
|
|
|
claim = build_claim(expense_type="transport", location="上海")
|
|
|
|
|
|
claim.employee = employee
|
|
|
|
|
|
claim.employee_id = employee.id
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-01 17:07:14 +08:00
|
|
|
|
def test_pre_review_claim_records_ai_result_without_submitting() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="emp-pre-review@example.com",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=[],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="E7050",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
email="manager-pre-review@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E7051",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="emp-pre-review@example.com",
|
|
|
|
|
|
manager=manager,
|
|
|
|
|
|
)
|
|
|
|
|
|
claim = build_claim(expense_type="transport", location="上海")
|
|
|
|
|
|
claim.employee = employee
|
|
|
|
|
|
claim.employee_id = employee.id
|
|
|
|
|
|
claim.items[0].invoice_id = "taxi-ticket.png"
|
|
|
|
|
|
claim.risk_flags_json = [
|
|
|
|
|
|
{
|
|
|
|
|
|
"source": "manual_risk",
|
|
|
|
|
|
"severity": "high",
|
|
|
|
|
|
"label": "票据风险",
|
|
|
|
|
|
"message": "票据金额与行程不匹配。",
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
db.add_all([manager, employee, claim])
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
reviewed = ExpenseClaimService(db).pre_review_claim(claim.id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
assert reviewed is not None
|
|
|
|
|
|
assert reviewed.status == "draft"
|
2026-06-02 14:01:51 +08:00
|
|
|
|
assert reviewed.approval_stage == "待提交"
|
2026-06-01 17:07:14 +08:00
|
|
|
|
assert reviewed.submitted_at is None
|
|
|
|
|
|
pre_review_flag = next(
|
|
|
|
|
|
flag
|
|
|
|
|
|
for flag in reviewed.risk_flags_json
|
|
|
|
|
|
if isinstance(flag, dict) and flag.get("source") == "ai_pre_review"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert pre_review_flag["status"] == "failed"
|
|
|
|
|
|
assert pre_review_flag["next_action"] == "risk_explanation_required"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 09:36:01 +08:00
|
|
|
|
def test_submit_claim_backfills_department_from_current_employee() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="emp-dept@example.com",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=[],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
department = OrganizationUnit(
|
|
|
|
|
|
unit_code="D7200",
|
|
|
|
|
|
name="销售部",
|
|
|
|
|
|
)
|
|
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="E7200",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
email="manager-dept@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E7201",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="emp-dept@example.com",
|
|
|
|
|
|
organization_unit=department,
|
|
|
|
|
|
manager=manager,
|
|
|
|
|
|
)
|
|
|
|
|
|
claim = build_claim(expense_type="transport", location="待补充")
|
|
|
|
|
|
claim.employee = None
|
|
|
|
|
|
claim.employee_id = None
|
|
|
|
|
|
claim.employee_name = "张三"
|
|
|
|
|
|
claim.department_id = None
|
|
|
|
|
|
claim.department_name = "待补充"
|
|
|
|
|
|
claim.items[0].item_location = "待补充"
|
|
|
|
|
|
db.add_all([department, 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.department_id == department.id
|
|
|
|
|
|
assert submitted.department_name == "销售部"
|
|
|
|
|
|
assert submitted.approval_stage == "直属领导审批"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_submit_claim_routes_high_risk_attachment_to_approval_with_review_flag(
|
|
|
|
|
|
monkeypatch,
|
|
|
|
|
|
tmp_path,
|
|
|
|
|
|
) -> None:
|
2026-05-15 06:56:00 +00:00
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="emp-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="taxi-note.png",
|
|
|
|
|
|
media_type="image/png",
|
|
|
|
|
|
text="滴滴出行电子发票 金额120元 2026-05-13",
|
|
|
|
|
|
summary="识别到交通出行发票,金额 120 元。",
|
|
|
|
|
|
avg_score=0.97,
|
|
|
|
|
|
line_count=1,
|
|
|
|
|
|
page_count=1,
|
|
|
|
|
|
warnings=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
2026-05-22 10:42:31 +08:00
|
|
|
|
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
2026-05-15 06:56:00 +00:00
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="E7100",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
email="manager2@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E7101",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="emp-risk@example.com",
|
|
|
|
|
|
manager=manager,
|
|
|
|
|
|
)
|
|
|
|
|
|
claim = build_claim(expense_type="office", location="深圳南山")
|
|
|
|
|
|
claim.employee = employee
|
|
|
|
|
|
claim.employee_id = employee.id
|
|
|
|
|
|
claim.invoice_count = 0
|
|
|
|
|
|
claim.items[0].invoice_id = None
|
|
|
|
|
|
claim.items[0].item_reason = "办公用品采购"
|
|
|
|
|
|
db.add_all([manager, employee, claim])
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
service.upload_claim_item_attachment(
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_id=claim.items[0].id,
|
|
|
|
|
|
filename="taxi-note.png",
|
|
|
|
|
|
content=b"fake-image-bytes",
|
|
|
|
|
|
media_type="image/png",
|
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
submitted = service.submit_claim(claim.id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
assert submitted is not None
|
2026-05-20 09:36:01 +08:00
|
|
|
|
assert submitted.status == "submitted"
|
|
|
|
|
|
assert submitted.approval_stage == "直属领导审批"
|
|
|
|
|
|
assert submitted.submitted_at is not None
|
2026-05-15 06:56:00 +00:00
|
|
|
|
assert any(
|
|
|
|
|
|
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "submission_review"
|
|
|
|
|
|
for flag in list(submitted.risk_flags_json or [])
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 09:36:01 +08:00
|
|
|
|
def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag(
|
|
|
|
|
|
monkeypatch,
|
|
|
|
|
|
tmp_path,
|
|
|
|
|
|
) -> None:
|
2026-05-15 06:56:00 +00:00
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="emp-travel@example.com",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=[],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def fake_recognize(
|
|
|
|
|
|
self,
|
|
|
|
|
|
files: list[tuple[str, bytes, str | None]],
|
|
|
|
|
|
) -> OcrRecognizeBatchRead:
|
|
|
|
|
|
documents: list[OcrRecognizeDocumentRead] = []
|
|
|
|
|
|
for filename, _, media_type in files:
|
|
|
|
|
|
if filename == "outbound.png":
|
|
|
|
|
|
documents.append(
|
|
|
|
|
|
OcrRecognizeDocumentRead(
|
|
|
|
|
|
filename=filename,
|
|
|
|
|
|
media_type=media_type or "image/png",
|
|
|
|
|
|
text="电子行程单 2026-05-13 经济舱 武汉-上海 金额 480元 航班号 MU5101",
|
|
|
|
|
|
summary="武汉到上海机票",
|
|
|
|
|
|
avg_score=0.98,
|
|
|
|
|
|
line_count=1,
|
|
|
|
|
|
page_count=1,
|
|
|
|
|
|
document_type="flight_itinerary",
|
|
|
|
|
|
document_type_label="机票/航班行程单",
|
|
|
|
|
|
scene_code="travel",
|
|
|
|
|
|
scene_label="差旅票据",
|
|
|
|
|
|
document_fields=[
|
|
|
|
|
|
{"key": "route", "label": "行程", "value": "武汉-上海"},
|
|
|
|
|
|
{"key": "amount", "label": "金额", "value": "480元"},
|
|
|
|
|
|
{"key": "date", "label": "日期", "value": "2026-05-13"},
|
|
|
|
|
|
],
|
|
|
|
|
|
warnings=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
elif filename == "onward.png":
|
|
|
|
|
|
documents.append(
|
|
|
|
|
|
OcrRecognizeDocumentRead(
|
|
|
|
|
|
filename=filename,
|
|
|
|
|
|
media_type=media_type or "image/png",
|
|
|
|
|
|
text="电子行程单 2026-05-14 经济舱 上海-成都 金额 360元 航班号 MU5402",
|
|
|
|
|
|
summary="上海到成都机票",
|
|
|
|
|
|
avg_score=0.98,
|
|
|
|
|
|
line_count=1,
|
|
|
|
|
|
page_count=1,
|
|
|
|
|
|
document_type="flight_itinerary",
|
|
|
|
|
|
document_type_label="机票/航班行程单",
|
|
|
|
|
|
scene_code="travel",
|
|
|
|
|
|
scene_label="差旅票据",
|
|
|
|
|
|
document_fields=[
|
|
|
|
|
|
{"key": "route", "label": "行程", "value": "上海-成都"},
|
|
|
|
|
|
{"key": "amount", "label": "金额", "value": "360元"},
|
|
|
|
|
|
{"key": "date", "label": "日期", "value": "2026-05-14"},
|
|
|
|
|
|
],
|
|
|
|
|
|
warnings=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
return OcrRecognizeBatchRead(
|
|
|
|
|
|
total_file_count=len(files),
|
|
|
|
|
|
success_count=len(documents),
|
|
|
|
|
|
documents=documents,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
2026-05-22 10:42:31 +08:00
|
|
|
|
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
2026-05-15 06:56:00 +00:00
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="E7200",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
email="manager-travel@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E7201",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="emp-travel@example.com",
|
|
|
|
|
|
grade="P4",
|
|
|
|
|
|
manager=manager,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([manager, employee])
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
|
|
|
|
|
|
claim = build_claim(expense_type="travel", location="上海")
|
|
|
|
|
|
claim.reason = "上海客户现场出差"
|
|
|
|
|
|
claim.employee = employee
|
|
|
|
|
|
claim.employee_id = employee.id
|
|
|
|
|
|
claim.items = [
|
|
|
|
|
|
ExpenseClaimItem(
|
|
|
|
|
|
id="travel-item-1",
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_date=date(2026, 5, 13),
|
|
|
|
|
|
item_type="travel",
|
|
|
|
|
|
item_reason="赴上海客户现场",
|
|
|
|
|
|
item_location="上海",
|
|
|
|
|
|
item_amount=Decimal("480.00"),
|
|
|
|
|
|
invoice_id=None,
|
|
|
|
|
|
),
|
|
|
|
|
|
ExpenseClaimItem(
|
|
|
|
|
|
id="travel-item-2",
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_date=date(2026, 5, 14),
|
|
|
|
|
|
item_type="travel",
|
|
|
|
|
|
item_reason="赴上海客户现场",
|
|
|
|
|
|
item_location="上海",
|
|
|
|
|
|
item_amount=Decimal("360.00"),
|
|
|
|
|
|
invoice_id=None,
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
|
|
|
|
|
claim.amount = Decimal("840.00")
|
|
|
|
|
|
claim.invoice_count = 0
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
service.upload_claim_item_attachment(
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_id="travel-item-1",
|
|
|
|
|
|
filename="outbound.png",
|
|
|
|
|
|
content=b"outbound-image",
|
|
|
|
|
|
media_type="image/png",
|
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
)
|
|
|
|
|
|
service.upload_claim_item_attachment(
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_id="travel-item-2",
|
|
|
|
|
|
filename="onward.png",
|
|
|
|
|
|
content=b"onward-image",
|
|
|
|
|
|
media_type="image/png",
|
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
submitted = service.submit_claim(claim.id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
assert submitted is not None
|
2026-05-20 09:36:01 +08:00
|
|
|
|
assert submitted.status == "submitted"
|
|
|
|
|
|
assert submitted.approval_stage == "直属领导审批"
|
2026-05-15 06:56:00 +00:00
|
|
|
|
assert any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and str(flag.get("source") or "").strip() == "submission_review"
|
|
|
|
|
|
and (
|
|
|
|
|
|
"多城市" in str(flag.get("message") or "")
|
|
|
|
|
|
or "终点" in str(flag.get("message") or "")
|
|
|
|
|
|
)
|
|
|
|
|
|
for flag in list(submitted.risk_flags_json or [])
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 15:46:56 +08:00
|
|
|
|
def test_submit_claim_allows_round_trip_ticket_origin_inferred_from_route(
|
|
|
|
|
|
monkeypatch,
|
|
|
|
|
|
tmp_path,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="emp-round-trip@example.com",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=[],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def fake_recognize(
|
|
|
|
|
|
self,
|
|
|
|
|
|
files: list[tuple[str, bytes, str | None]],
|
|
|
|
|
|
) -> OcrRecognizeBatchRead:
|
|
|
|
|
|
documents: list[OcrRecognizeDocumentRead] = []
|
|
|
|
|
|
for filename, _, media_type in files:
|
|
|
|
|
|
if filename == "outbound.png":
|
|
|
|
|
|
documents.append(
|
|
|
|
|
|
OcrRecognizeDocumentRead(
|
|
|
|
|
|
filename=filename,
|
|
|
|
|
|
media_type=media_type or "image/png",
|
|
|
|
|
|
text="铁路电子客票 2026-02-20 武汉-上海 二等座 票价 ¥354.00",
|
|
|
|
|
|
summary="武汉到上海高铁票",
|
|
|
|
|
|
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": "route", "label": "行程", "value": "武汉-上海"},
|
|
|
|
|
|
{"key": "amount", "label": "金额", "value": "354元"},
|
|
|
|
|
|
{"key": "date", "label": "日期", "value": "2026-02-20"},
|
|
|
|
|
|
],
|
|
|
|
|
|
warnings=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
elif filename == "return.png":
|
|
|
|
|
|
documents.append(
|
|
|
|
|
|
OcrRecognizeDocumentRead(
|
|
|
|
|
|
filename=filename,
|
|
|
|
|
|
media_type=media_type or "image/png",
|
|
|
|
|
|
text="铁路电子客票 2026-02-23 上海-武汉 二等座 票价 ¥354.00",
|
|
|
|
|
|
summary="上海到武汉高铁票",
|
|
|
|
|
|
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": "route", "label": "行程", "value": "上海-武汉"},
|
|
|
|
|
|
{"key": "amount", "label": "金额", "value": "354元"},
|
|
|
|
|
|
{"key": "date", "label": "日期", "value": "2026-02-23"},
|
|
|
|
|
|
],
|
|
|
|
|
|
warnings=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
return OcrRecognizeBatchRead(
|
|
|
|
|
|
total_file_count=len(files),
|
|
|
|
|
|
success_count=len(documents),
|
|
|
|
|
|
documents=documents,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
|
|
|
|
|
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="E7210",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
email="manager-round-trip@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E7211",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="emp-round-trip@example.com",
|
|
|
|
|
|
grade="P4",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
manager=manager,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([manager, employee])
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
|
|
|
|
|
|
claim = build_claim(expense_type="travel", location="上海")
|
|
|
|
|
|
claim.reason = "支撑国网仿生产环境部署"
|
|
|
|
|
|
claim.employee = employee
|
|
|
|
|
|
claim.employee_id = employee.id
|
|
|
|
|
|
claim.items = [
|
|
|
|
|
|
ExpenseClaimItem(
|
|
|
|
|
|
id="round-trip-item-1",
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_date=date(2026, 2, 20),
|
|
|
|
|
|
item_type="travel",
|
|
|
|
|
|
item_reason="支撑国网仿生产环境部署",
|
|
|
|
|
|
item_location="上海",
|
|
|
|
|
|
item_amount=Decimal("354.00"),
|
|
|
|
|
|
invoice_id=None,
|
|
|
|
|
|
),
|
|
|
|
|
|
ExpenseClaimItem(
|
|
|
|
|
|
id="round-trip-item-2",
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_date=date(2026, 2, 23),
|
|
|
|
|
|
item_type="travel",
|
|
|
|
|
|
item_reason="支撑国网仿生产环境部署",
|
|
|
|
|
|
item_location="上海",
|
|
|
|
|
|
item_amount=Decimal("354.00"),
|
|
|
|
|
|
invoice_id=None,
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
|
|
|
|
|
claim.amount = Decimal("708.00")
|
|
|
|
|
|
claim.invoice_count = 0
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
service.upload_claim_item_attachment(
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_id="round-trip-item-1",
|
|
|
|
|
|
filename="outbound.png",
|
|
|
|
|
|
content=b"outbound-image",
|
|
|
|
|
|
media_type="image/png",
|
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
)
|
|
|
|
|
|
service.upload_claim_item_attachment(
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_id="round-trip-item-2",
|
|
|
|
|
|
filename="return.png",
|
|
|
|
|
|
content=b"return-image",
|
|
|
|
|
|
media_type="image/png",
|
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
submitted = service.submit_claim(claim.id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
assert submitted is not None
|
|
|
|
|
|
assert submitted.status == "submitted"
|
|
|
|
|
|
assert submitted.approval_stage == "直属领导审批"
|
|
|
|
|
|
assert not any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and str(flag.get("rule_code") or "").strip() == "risk.travel.high.city_mismatch"
|
|
|
|
|
|
for flag in list(submitted.risk_flags_json or [])
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 09:36:01 +08:00
|
|
|
|
def test_submit_claim_routes_hotel_amount_over_travel_policy_to_approval_with_review_flag(
|
|
|
|
|
|
monkeypatch,
|
|
|
|
|
|
tmp_path,
|
|
|
|
|
|
) -> None:
|
2026-05-15 06:56:00 +00:00
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="emp-hotel@example.com",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=[],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def fake_recognize(
|
|
|
|
|
|
self,
|
|
|
|
|
|
files: list[tuple[str, bytes, str | None]],
|
|
|
|
|
|
) -> OcrRecognizeBatchRead:
|
|
|
|
|
|
documents: list[OcrRecognizeDocumentRead] = []
|
|
|
|
|
|
for filename, _, media_type in files:
|
|
|
|
|
|
if filename == "beijing-trip.png":
|
|
|
|
|
|
documents.append(
|
|
|
|
|
|
OcrRecognizeDocumentRead(
|
|
|
|
|
|
filename=filename,
|
|
|
|
|
|
media_type=media_type or "image/png",
|
|
|
|
|
|
text="电子行程单 2026-05-13 经济舱 武汉-北京 金额 520元 航班号 MU6101",
|
|
|
|
|
|
summary="武汉到北京机票",
|
|
|
|
|
|
avg_score=0.97,
|
|
|
|
|
|
line_count=1,
|
|
|
|
|
|
page_count=1,
|
|
|
|
|
|
document_type="flight_itinerary",
|
|
|
|
|
|
document_type_label="机票/航班行程单",
|
|
|
|
|
|
scene_code="travel",
|
|
|
|
|
|
scene_label="差旅票据",
|
|
|
|
|
|
document_fields=[
|
|
|
|
|
|
{"key": "route", "label": "行程", "value": "武汉-北京"},
|
|
|
|
|
|
{"key": "amount", "label": "金额", "value": "520元"},
|
|
|
|
|
|
{"key": "date", "label": "日期", "value": "2026-05-13"},
|
|
|
|
|
|
],
|
|
|
|
|
|
warnings=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
elif filename == "beijing-hotel.png":
|
|
|
|
|
|
documents.append(
|
|
|
|
|
|
OcrRecognizeDocumentRead(
|
|
|
|
|
|
filename=filename,
|
|
|
|
|
|
media_type=media_type or "image/png",
|
|
|
|
|
|
text="北京全季酒店 1晚 金额 880元 2026-05-13",
|
|
|
|
|
|
summary="北京全季酒店住宿发票",
|
|
|
|
|
|
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": "880元"},
|
|
|
|
|
|
{"key": "date", "label": "日期", "value": "2026-05-13"},
|
|
|
|
|
|
],
|
|
|
|
|
|
warnings=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
return OcrRecognizeBatchRead(
|
|
|
|
|
|
total_file_count=len(files),
|
|
|
|
|
|
success_count=len(documents),
|
|
|
|
|
|
documents=documents,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
|
2026-05-22 10:42:31 +08:00
|
|
|
|
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
2026-05-15 06:56:00 +00:00
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="E7300",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
email="manager-hotel@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E7301",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="emp-hotel@example.com",
|
|
|
|
|
|
grade="P4",
|
|
|
|
|
|
manager=manager,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([manager, employee])
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
|
|
|
|
|
|
claim = build_claim(expense_type="travel", location="北京")
|
|
|
|
|
|
claim.reason = "北京客户现场出差"
|
|
|
|
|
|
claim.employee = employee
|
|
|
|
|
|
claim.employee_id = employee.id
|
|
|
|
|
|
claim.items = [
|
|
|
|
|
|
ExpenseClaimItem(
|
|
|
|
|
|
id="hotel-trip-item",
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_date=date(2026, 5, 13),
|
|
|
|
|
|
item_type="travel",
|
|
|
|
|
|
item_reason="赴北京客户现场",
|
|
|
|
|
|
item_location="北京",
|
|
|
|
|
|
item_amount=Decimal("520.00"),
|
|
|
|
|
|
invoice_id=None,
|
|
|
|
|
|
),
|
|
|
|
|
|
ExpenseClaimItem(
|
|
|
|
|
|
id="hotel-item",
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_date=date(2026, 5, 13),
|
|
|
|
|
|
item_type="hotel",
|
|
|
|
|
|
item_reason="北京住宿",
|
|
|
|
|
|
item_location="北京",
|
|
|
|
|
|
item_amount=Decimal("880.00"),
|
|
|
|
|
|
invoice_id=None,
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
|
|
|
|
|
claim.amount = Decimal("1400.00")
|
|
|
|
|
|
claim.invoice_count = 0
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
service.upload_claim_item_attachment(
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_id="hotel-trip-item",
|
|
|
|
|
|
filename="beijing-trip.png",
|
|
|
|
|
|
content=b"travel-image",
|
|
|
|
|
|
media_type="image/png",
|
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
)
|
|
|
|
|
|
service.upload_claim_item_attachment(
|
|
|
|
|
|
claim_id=claim.id,
|
|
|
|
|
|
item_id="hotel-item",
|
|
|
|
|
|
filename="beijing-hotel.png",
|
|
|
|
|
|
content=b"hotel-image",
|
|
|
|
|
|
media_type="image/png",
|
|
|
|
|
|
current_user=current_user,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
submitted = service.submit_claim(claim.id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
assert submitted is not None
|
2026-05-20 09:36:01 +08:00
|
|
|
|
assert submitted.status == "submitted"
|
|
|
|
|
|
assert submitted.approval_stage == "直属领导审批"
|
2026-05-15 06:56:00 +00:00
|
|
|
|
assert any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and str(flag.get("source") or "").strip() == "submission_review"
|
|
|
|
|
|
and "住宿标准" in str(flag.get("message") or "")
|
|
|
|
|
|
for flag in list(submitted.risk_flags_json or [])
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 15:42:25 +00:00
|
|
|
|
def test_list_claims_scopes_to_current_user_id_even_when_names_duplicate() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="zhangsan1@example.com",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=["manager"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee_a = Employee(
|
|
|
|
|
|
employee_no="E2001",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="zhangsan1@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
employee_b = Employee(
|
|
|
|
|
|
employee_no="E2002",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="zhangsan2@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([employee_a, employee_b])
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
db.add_all(
|
|
|
|
|
|
[
|
|
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-DUP-001",
|
|
|
|
|
|
employee_id=employee_a.id,
|
|
|
|
|
|
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, 12, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
|
|
|
|
|
|
status="submitted",
|
|
|
|
|
|
approval_stage="finance_review",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
),
|
|
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-DUP-002",
|
|
|
|
|
|
employee_id=employee_b.id,
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_name="销售部",
|
|
|
|
|
|
project_code="PRJ-B",
|
|
|
|
|
|
expense_type="meal",
|
|
|
|
|
|
reason="他人报销",
|
|
|
|
|
|
location="杭州",
|
|
|
|
|
|
amount=Decimal("300.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="approved",
|
|
|
|
|
|
approval_stage="completed",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
claims = ExpenseClaimService(db).list_claims(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
assert len(claims) == 1
|
|
|
|
|
|
assert claims[0].claim_no == "EXP-DUP-001"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-30 15:46:51 +08:00
|
|
|
|
def test_list_claims_limits_finance_to_personal_records() -> None:
|
2026-05-13 15:42:25 +00:00
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="finance@example.com",
|
|
|
|
|
|
name="财务",
|
|
|
|
|
|
role_codes=["finance"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
db.add_all(
|
|
|
|
|
|
[
|
|
|
|
|
|
ExpenseClaim(
|
2026-05-30 15:46:51 +08:00
|
|
|
|
claim_no="EXP-FIN-OWN",
|
|
|
|
|
|
employee_name="财务",
|
2026-05-13 15:42:25 +00:00
|
|
|
|
department_name="A部",
|
|
|
|
|
|
project_code="PRJ-A",
|
|
|
|
|
|
expense_type="travel",
|
|
|
|
|
|
reason="A 报销",
|
|
|
|
|
|
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="finance_review",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
),
|
|
|
|
|
|
ExpenseClaim(
|
2026-05-30 15:46:51 +08:00
|
|
|
|
claim_no="EXP-FIN-OTHER-DRAFT",
|
2026-05-13 15:42:25 +00:00
|
|
|
|
employee_name="乙",
|
|
|
|
|
|
department_name="B部",
|
|
|
|
|
|
project_code="PRJ-B",
|
|
|
|
|
|
expense_type="meal",
|
|
|
|
|
|
reason="B 报销",
|
|
|
|
|
|
location="杭州",
|
|
|
|
|
|
amount=Decimal("300.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=1,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
|
2026-05-30 15:46:51 +08:00
|
|
|
|
submitted_at=None,
|
|
|
|
|
|
status="draft",
|
|
|
|
|
|
approval_stage="待提交",
|
2026-05-13 15:42:25 +00:00
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
claims = ExpenseClaimService(db).list_claims(current_user)
|
|
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
|
assert len(claims) == 1
|
2026-05-30 15:46:51 +08:00
|
|
|
|
assert claims[0].claim_no == "EXP-FIN-OWN"
|
2026-05-15 06:56:00 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 16:22:59 +08:00
|
|
|
|
def test_list_claims_returns_company_reimbursements_for_finance_document_center() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="finance@example.com",
|
|
|
|
|
|
name="财务",
|
|
|
|
|
|
role_codes=["finance"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
db.add_all(
|
|
|
|
|
|
[
|
|
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-FIN-COMPANY-SUBMITTED",
|
|
|
|
|
|
employee_name="乙",
|
|
|
|
|
|
department_name="市场部",
|
|
|
|
|
|
project_code="PRJ-MKT",
|
|
|
|
|
|
expense_type="travel",
|
|
|
|
|
|
reason="客户拜访差旅",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("1200.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="finance_review",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
),
|
|
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-FIN-COMPANY-DRAFT",
|
|
|
|
|
|
employee_name="丙",
|
|
|
|
|
|
department_name="技术部",
|
|
|
|
|
|
project_code="PRJ-TECH",
|
|
|
|
|
|
expense_type="office",
|
|
|
|
|
|
reason="办公用品",
|
|
|
|
|
|
location="北京",
|
|
|
|
|
|
amount=Decimal("300.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=1,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=None,
|
|
|
|
|
|
status="draft",
|
|
|
|
|
|
approval_stage="待提交",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
),
|
|
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-FIN-COMPANY-PAID",
|
|
|
|
|
|
employee_name="丁",
|
|
|
|
|
|
department_name="财务部",
|
|
|
|
|
|
project_code="PRJ-FIN",
|
|
|
|
|
|
expense_type="meal",
|
|
|
|
|
|
reason="客户沟通",
|
|
|
|
|
|
location="杭州",
|
|
|
|
|
|
amount=Decimal("500.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="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 "EXP-FIN-COMPANY-SUBMITTED" in claim_nos
|
|
|
|
|
|
assert "EXP-FIN-COMPANY-DRAFT" not in claim_nos
|
|
|
|
|
|
assert "EXP-FIN-COMPANY-PAID" not in claim_nos
|
|
|
|
|
|
assert "EXP-FIN-COMPANY-PAID" in archived_nos
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-30 15:46:51 +08:00
|
|
|
|
def test_list_claims_limits_executive_to_personal_records() -> None:
|
2026-05-20 14:21:56 +08:00
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="executive@example.com",
|
|
|
|
|
|
name="高管",
|
|
|
|
|
|
role_codes=["executive"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
db.add_all(
|
|
|
|
|
|
[
|
|
|
|
|
|
ExpenseClaim(
|
2026-05-30 15:46:51 +08:00
|
|
|
|
claim_no="EXP-EXE-OWN",
|
|
|
|
|
|
employee_name="高管",
|
2026-05-20 14:21:56 +08:00
|
|
|
|
department_name="A部",
|
|
|
|
|
|
project_code="PRJ-A",
|
|
|
|
|
|
expense_type="travel",
|
|
|
|
|
|
reason="A 报销",
|
|
|
|
|
|
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=[],
|
|
|
|
|
|
),
|
|
|
|
|
|
ExpenseClaim(
|
2026-05-30 15:46:51 +08:00
|
|
|
|
claim_no="EXP-EXE-OTHER-DRAFT",
|
2026-05-20 14:21:56 +08:00
|
|
|
|
employee_name="乙",
|
|
|
|
|
|
department_name="B部",
|
|
|
|
|
|
project_code="PRJ-B",
|
|
|
|
|
|
expense_type="meal",
|
|
|
|
|
|
reason="B 报销",
|
|
|
|
|
|
location="杭州",
|
|
|
|
|
|
amount=Decimal("300.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=1,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
|
2026-05-30 15:46:51 +08:00
|
|
|
|
submitted_at=None,
|
|
|
|
|
|
status="draft",
|
|
|
|
|
|
approval_stage="待提交",
|
2026-05-20 14:21:56 +08:00
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
claims = ExpenseClaimService(db).list_claims(current_user)
|
|
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
|
assert len(claims) == 1
|
2026-05-30 15:46:51 +08:00
|
|
|
|
assert claims[0].claim_no == "EXP-EXE-OWN"
|
2026-05-22 16:00:19 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_list_claims_keeps_own_archived_claim_for_finance_applicant() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="finance@example.com",
|
|
|
|
|
|
name="财务",
|
|
|
|
|
|
role_codes=["finance"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
db.add(
|
|
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-FIN-OWN-ARCH",
|
|
|
|
|
|
employee_name="财务",
|
|
|
|
|
|
department_name="财务部",
|
|
|
|
|
|
project_code="PRJ-FIN",
|
|
|
|
|
|
expense_type="meal",
|
|
|
|
|
|
reason="本人报销",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("88.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="approved",
|
|
|
|
|
|
approval_stage="归档入账",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
claims = ExpenseClaimService(db).list_claims(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
assert len(claims) == 1
|
|
|
|
|
|
assert claims[0].claim_no == "EXP-FIN-OWN-ARCH"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_list_archived_claims_returns_company_archived_records_for_finance() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="finance@example.com",
|
|
|
|
|
|
name="财务",
|
|
|
|
|
|
role_codes=["finance"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
db.add_all(
|
|
|
|
|
|
[
|
|
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-ARCH-101",
|
|
|
|
|
|
employee_name="甲",
|
|
|
|
|
|
department_name="A部",
|
|
|
|
|
|
project_code="PRJ-A",
|
|
|
|
|
|
expense_type="travel",
|
|
|
|
|
|
reason="A 报销",
|
|
|
|
|
|
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="approved",
|
|
|
|
|
|
approval_stage="归档入账",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
),
|
|
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-ARCH-102",
|
|
|
|
|
|
employee_name="乙",
|
|
|
|
|
|
department_name="B部",
|
|
|
|
|
|
project_code="PRJ-B",
|
|
|
|
|
|
expense_type="meal",
|
|
|
|
|
|
reason="B 报销",
|
|
|
|
|
|
location="杭州",
|
|
|
|
|
|
amount=Decimal("300.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=1,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 5, 11, 13, 0, tzinfo=UTC),
|
|
|
|
|
|
status="submitted",
|
|
|
|
|
|
approval_stage="财务审批",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
),
|
2026-05-28 12:09:49 +08:00
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-ARCH-PAID",
|
|
|
|
|
|
employee_name="丙",
|
|
|
|
|
|
department_name="C部",
|
|
|
|
|
|
project_code="PRJ-C",
|
|
|
|
|
|
expense_type="office",
|
|
|
|
|
|
reason="C 报销",
|
|
|
|
|
|
location="深圳",
|
|
|
|
|
|
amount=Decimal("180.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=1,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 11, 14, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 5, 11, 15, 0, tzinfo=UTC),
|
|
|
|
|
|
status="paid",
|
|
|
|
|
|
approval_stage="已付款",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
),
|
2026-05-26 09:15:14 +08:00
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
claim_no="AP-20260525120000-ABCDEFGH",
|
|
|
|
|
|
employee_name="丙",
|
|
|
|
|
|
department_name="C部",
|
|
|
|
|
|
project_code="PRJ-C",
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="C 申请",
|
|
|
|
|
|
location="成都",
|
|
|
|
|
|
amount=Decimal("800.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 11, 14, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 5, 11, 15, 0, tzinfo=UTC),
|
|
|
|
|
|
status="approved",
|
|
|
|
|
|
approval_stage="审批完成",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
),
|
|
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
claim_no="AP-20260525123000-HGFEDCBA",
|
|
|
|
|
|
employee_name="丁",
|
|
|
|
|
|
department_name="D部",
|
|
|
|
|
|
project_code="PRJ-D",
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="D 申请",
|
|
|
|
|
|
location="北京",
|
|
|
|
|
|
amount=Decimal("500.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 11, 16, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 5, 11, 17, 0, tzinfo=UTC),
|
|
|
|
|
|
status="submitted",
|
|
|
|
|
|
approval_stage="直属领导审批",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
),
|
2026-05-22 16:00:19 +08:00
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
claims = ExpenseClaimService(db).list_archived_claims(current_user)
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
assert {claim.claim_no for claim in claims} == {
|
|
|
|
|
|
"EXP-ARCH-101",
|
2026-05-28 12:09:49 +08:00
|
|
|
|
"EXP-ARCH-PAID",
|
2026-05-26 09:15:14 +08:00
|
|
|
|
"AP-20260525120000-ABCDEFGH",
|
|
|
|
|
|
}
|
2026-05-22 16:00:19 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
def test_list_archived_claims_returns_only_own_records_for_regular_employee() -> None:
|
2026-05-22 16:00:19 +08:00
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="zhangsan@example.com",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=["employee"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
2026-05-26 09:15:14 +08:00
|
|
|
|
db.add_all(
|
|
|
|
|
|
[
|
|
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-ARCH-EMP",
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_name="研发部",
|
|
|
|
|
|
project_code="PRJ-EMP",
|
|
|
|
|
|
expense_type="travel",
|
|
|
|
|
|
reason="本人报销",
|
|
|
|
|
|
location="北京",
|
|
|
|
|
|
amount=Decimal("200.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=1,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 10, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 5, 10, 10, 0, tzinfo=UTC),
|
|
|
|
|
|
status="approved",
|
|
|
|
|
|
approval_stage="归档入账",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
),
|
|
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
claim_no="AP-20260525130000-ABCDEFGH",
|
|
|
|
|
|
employee_name="李四",
|
|
|
|
|
|
department_name="研发部",
|
|
|
|
|
|
project_code="PRJ-EMP",
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="他人申请",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("500.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 10, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 5, 10, 10, 0, tzinfo=UTC),
|
|
|
|
|
|
status="approved",
|
|
|
|
|
|
approval_stage="审批完成",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
2026-05-22 16:00:19 +08:00
|
|
|
|
)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
claims = ExpenseClaimService(db).list_archived_claims(current_user)
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
assert [claim.claim_no for claim in claims] == ["EXP-ARCH-EMP"]
|
2026-05-20 14:21:56 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 09:28:33 +08:00
|
|
|
|
def test_finance_can_return_but_cannot_delete_submitted_claim() -> None:
|
2026-05-20 14:21:56 +08:00
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="finance@example.com",
|
|
|
|
|
|
name="财务",
|
|
|
|
|
|
role_codes=["finance"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-RET-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",
|
2026-05-27 14:35:17 +08:00
|
|
|
|
approval_stage="财务审批",
|
2026-05-20 14:21:56 +08:00
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
claim_id = claim.id
|
|
|
|
|
|
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
returned = service.return_claim(claim_id, current_user, reason="资料不完整")
|
|
|
|
|
|
|
|
|
|
|
|
assert returned is not None
|
|
|
|
|
|
assert returned.status == "returned"
|
2026-05-20 14:32:35 +08:00
|
|
|
|
assert returned.approval_stage == "待提交"
|
2026-05-20 14:21:56 +08:00
|
|
|
|
assert any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and flag.get("source") == "manual_return"
|
|
|
|
|
|
and flag.get("message") == "资料不完整"
|
|
|
|
|
|
for flag in returned.risk_flags_json
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-26 17:29:35 +08:00
|
|
|
|
with pytest.raises(ValueError, match="只有高级财务人员可以删除"):
|
2026-05-21 09:28:33 +08:00
|
|
|
|
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)
|
2026-05-20 14:21:56 +08:00
|
|
|
|
|
|
|
|
|
|
assert deleted is not None
|
2026-05-21 09:28:33 +08:00
|
|
|
|
assert deleted.claim_no == "EXP-DEL-EXEC-101"
|
2026-05-20 14:21:56 +08:00
|
|
|
|
assert db.get(ExpenseClaim, claim_id) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
|
def test_direct_manager_cannot_delete_application_claim() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="manager-delete-application@example.com",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
role_codes=["manager"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="E-APP-DEL-MANAGER",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
email="manager-delete-application@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E-APP-DEL-EMP",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="zhangsan-application-delete@example.com",
|
|
|
|
|
|
manager=manager,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([manager, employee])
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="APP-DEL-MANAGER-101",
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_name="市场部",
|
|
|
|
|
|
project_code=None,
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="差旅申请",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("1200.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-06-03 09:25:23 +08:00
|
|
|
|
with pytest.raises(ValueError, match="只有草稿、待补充或退回待提交状态的单据"):
|
2026-06-02 14:01:51 +08:00
|
|
|
|
ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
assert db.get(ExpenseClaim, claim_id) is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 09:25:23 +08:00
|
|
|
|
def test_applicant_can_delete_returned_application_claim() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="zhangsan-application-return-delete@example.com",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=[],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E-APP-DEL-RETURN",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="zhangsan-application-return-delete@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="APP-DEL-RETURN-101",
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_name="市场部",
|
|
|
|
|
|
project_code=None,
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="差旅申请",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("1200.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
|
|
|
|
|
|
status="returned",
|
|
|
|
|
|
approval_stage="待提交",
|
|
|
|
|
|
risk_flags_json=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"source": "manual_return",
|
|
|
|
|
|
"event_type": "expense_application_return",
|
|
|
|
|
|
"message": "请补充出差事由",
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
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 == "APP-DEL-RETURN-101"
|
|
|
|
|
|
assert db.get(ExpenseClaim, claim_id) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
|
def test_admin_can_delete_application_claim() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="superadmin",
|
|
|
|
|
|
name="系统管理员",
|
|
|
|
|
|
role_codes=["manager"],
|
|
|
|
|
|
is_admin=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="APP-DEL-ADMIN-101",
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_name="市场部",
|
|
|
|
|
|
project_code=None,
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="差旅申请",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("1200.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
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 == "APP-DEL-ADMIN-101"
|
|
|
|
|
|
assert db.get(ExpenseClaim, claim_id) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-26 09:15:14 +08:00
|
|
|
|
def test_executive_cannot_delete_archived_claim() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="executive-archive-delete@example.com",
|
|
|
|
|
|
name="高管",
|
|
|
|
|
|
role_codes=["executive"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-DEL-ARCHIVE-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="approved",
|
|
|
|
|
|
approval_stage="归档入账",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
claim_id = claim.id
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError, match="已归档单据不能删除"):
|
|
|
|
|
|
ExpenseClaimService(db).delete_claim(claim_id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
assert db.get(ExpenseClaim, claim_id) is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_admin_can_delete_archived_claim() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="superadmin",
|
|
|
|
|
|
name="系统管理员",
|
|
|
|
|
|
role_codes=["manager"],
|
|
|
|
|
|
is_admin=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-DEL-ARCHIVE-102",
|
|
|
|
|
|
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="approved",
|
|
|
|
|
|
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-DEL-ARCHIVE-102"
|
|
|
|
|
|
assert db.get(ExpenseClaim, claim_id) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
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
|
2026-05-25 13:35:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-27 14:35:17 +08:00
|
|
|
|
def test_manager_cannot_operate_own_claim_submitted_to_direct_manager() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="manager-own-approval@example.com",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
role_codes=["manager"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
superior = Employee(
|
|
|
|
|
|
employee_no="E8112",
|
|
|
|
|
|
name="王总",
|
|
|
|
|
|
email="superior-own-approval@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="E8113",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
email="manager-own-approval@example.com",
|
|
|
|
|
|
manager=superior,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([superior, manager])
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-APP-SELF-201",
|
|
|
|
|
|
employee_id=manager.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
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError, match="当前直属领导审批人"):
|
|
|
|
|
|
service.approve_claim(claim_id, current_user, opinion="同意")
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError, match="当前审批人"):
|
|
|
|
|
|
service.return_claim(claim_id, current_user, reason="退回")
|
|
|
|
|
|
|
|
|
|
|
|
db.refresh(claim)
|
|
|
|
|
|
assert claim.status == "submitted"
|
|
|
|
|
|
assert claim.approval_stage == "直属领导审批"
|
|
|
|
|
|
assert claim.risk_flags_json == []
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
|
def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="application-owner@example.com",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=["employee"],
|
|
|
|
|
|
is_admin=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="APP-20260525-SUBMIT",
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
project_code="PRJ-A",
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="支撑国网服务器上线部署",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("12000.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=None,
|
|
|
|
|
|
status="draft",
|
|
|
|
|
|
approval_stage="待提交",
|
|
|
|
|
|
risk_flags_json=[
|
|
|
|
|
|
{
|
2026-06-01 17:07:14 +08:00
|
|
|
|
"source": "platform_risk",
|
2026-05-25 13:35:39 +08:00
|
|
|
|
"severity": "medium",
|
|
|
|
|
|
"message": "旧 AI 预审提示不应保留到申请单提交结果。",
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
claim_id = claim.id
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
|
|
|
|
|
|
def fail_ai_review(_claim: ExpenseClaim) -> dict[str, object]:
|
|
|
|
|
|
raise AssertionError("费用申请提交不应进入 AI 预审")
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_run_ai_submission_review", fail_ai_review)
|
|
|
|
|
|
|
|
|
|
|
|
submitted = service.submit_claim(claim_id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
assert submitted is not None
|
|
|
|
|
|
assert submitted.status == "submitted"
|
|
|
|
|
|
assert submitted.approval_stage == "直属领导审批"
|
|
|
|
|
|
assert submitted.invoice_count == 0
|
|
|
|
|
|
assert submitted.items == []
|
|
|
|
|
|
assert not any(
|
|
|
|
|
|
isinstance(flag, dict) and flag.get("source") == "submission_review"
|
|
|
|
|
|
for flag in submitted.risk_flags_json
|
|
|
|
|
|
)
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and flag.get("source") == "application_submission"
|
|
|
|
|
|
and flag.get("event_type") == "expense_application_submission"
|
|
|
|
|
|
and flag.get("next_approval_stage") == "直属领导审批"
|
|
|
|
|
|
for flag in submitted.risk_flags_json
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-26 17:29:35 +08:00
|
|
|
|
def test_application_submit_reserves_budget_once() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="application-budget-owner@example.com",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=["employee"],
|
|
|
|
|
|
is_admin=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
_seed_budget_allocation(
|
|
|
|
|
|
db,
|
|
|
|
|
|
department_id="dept-budget",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
amount=Decimal("50000.00"),
|
|
|
|
|
|
)
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="APP-20260525-BUDGET",
|
|
|
|
|
|
employee_id="emp-budget",
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_id="dept-budget",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
project_code=None,
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="客户现场交付",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("12000.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=None,
|
|
|
|
|
|
status="draft",
|
|
|
|
|
|
approval_stage="待提交",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
assert submitted is not None
|
|
|
|
|
|
reservations = db.query(BudgetReservation).all()
|
|
|
|
|
|
assert len(reservations) == 1
|
|
|
|
|
|
assert reservations[0].source_type == "application"
|
|
|
|
|
|
assert reservations[0].source_id == claim.id
|
|
|
|
|
|
assert reservations[0].amount == Decimal("12000.00")
|
|
|
|
|
|
transactions = db.query(BudgetTransaction).all()
|
|
|
|
|
|
assert any(item.transaction_type == "reserve" for item in transactions)
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and flag.get("source") == "budget_control"
|
|
|
|
|
|
and flag.get("event_type") == "budget_reserved"
|
|
|
|
|
|
for flag in submitted.risk_flags_json
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_application_submit_blocks_when_budget_insufficient_without_state_change() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="application-budget-block@example.com",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=["employee"],
|
|
|
|
|
|
is_admin=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
_seed_budget_allocation(
|
|
|
|
|
|
db,
|
|
|
|
|
|
department_id="dept-budget-block",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
amount=Decimal("1000.00"),
|
|
|
|
|
|
)
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="APP-20260525-BLOCK",
|
|
|
|
|
|
employee_id="emp-budget-block",
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_id="dept-budget-block",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
project_code=None,
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="客户现场交付",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("12000.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=None,
|
|
|
|
|
|
status="draft",
|
|
|
|
|
|
approval_stage="待提交",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
|
ExpenseClaimService(db).submit_claim(claim.id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
db.refresh(claim)
|
|
|
|
|
|
assert claim.status == "draft"
|
|
|
|
|
|
assert db.query(BudgetReservation).count() == 0
|
|
|
|
|
|
assert db.query(BudgetTransaction).count() == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 15:46:56 +08:00
|
|
|
|
def test_reimbursement_submit_keeps_budget_insufficient_as_review_risk() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="reimbursement-budget-risk@example.com",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=["employee"],
|
|
|
|
|
|
is_admin=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
_seed_budget_allocation(
|
|
|
|
|
|
db,
|
|
|
|
|
|
department_id="dept-1",
|
|
|
|
|
|
department_name="市场部",
|
|
|
|
|
|
subject_code="office",
|
|
|
|
|
|
amount=Decimal("1000.00"),
|
|
|
|
|
|
)
|
|
|
|
|
|
claim = build_claim(expense_type="office", location="待补充")
|
|
|
|
|
|
claim.amount = Decimal("1200.00")
|
|
|
|
|
|
claim.items[0].item_amount = Decimal("1200.00")
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
assert submitted is not None
|
|
|
|
|
|
assert submitted.status == "submitted"
|
|
|
|
|
|
assert submitted.submitted_at is not None
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and flag.get("source") == "budget_control"
|
|
|
|
|
|
and flag.get("event_type") == "budget_insufficient"
|
|
|
|
|
|
and flag.get("business_stage") == "reimbursement"
|
|
|
|
|
|
for flag in submitted.risk_flags_json
|
|
|
|
|
|
)
|
|
|
|
|
|
assert db.query(BudgetReservation).count() == 0
|
|
|
|
|
|
assert db.query(BudgetTransaction).count() == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-26 17:29:35 +08:00
|
|
|
|
def test_application_submit_skips_budget_for_non_demo_subject() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="application-budget-skip@example.com",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=["employee"],
|
|
|
|
|
|
is_admin=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
_seed_budget_allocation(
|
|
|
|
|
|
db,
|
|
|
|
|
|
department_id="dept-budget-skip",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
amount=Decimal("1000.00"),
|
|
|
|
|
|
)
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="APP-20260525-SKIP",
|
|
|
|
|
|
employee_id="emp-budget-skip",
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_id="dept-budget-skip",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
project_code=None,
|
|
|
|
|
|
expense_type="software_application",
|
|
|
|
|
|
reason="采购演示软件服务",
|
|
|
|
|
|
location="深圳",
|
|
|
|
|
|
amount=Decimal("12000.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=None,
|
|
|
|
|
|
status="draft",
|
|
|
|
|
|
approval_stage="待提交",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(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 db.query(BudgetReservation).count() == 0
|
|
|
|
|
|
assert db.query(BudgetTransaction).count() == 0
|
|
|
|
|
|
assert not any(
|
|
|
|
|
|
isinstance(flag, dict) and str(flag.get("source") or "").strip() == "budget_control"
|
|
|
|
|
|
for flag in submitted.risk_flags_json
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-27 17:31:27 +08:00
|
|
|
|
def test_direct_manager_can_route_application_claim_to_budget_approval_then_budget_manager_creates_draft() -> None:
|
|
|
|
|
|
manager_user = CurrentUserContext(
|
2026-05-25 13:35:39 +08:00
|
|
|
|
username="manager-application-approve@example.com",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
role_codes=["manager"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
2026-05-27 17:31:27 +08:00
|
|
|
|
budget_user = CurrentUserContext(
|
|
|
|
|
|
username="budget-p8-application-approve@example.com",
|
|
|
|
|
|
name="赵预算",
|
|
|
|
|
|
role_codes=["budget_monitor"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
2026-05-25 13:35:39 +08:00
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
2026-05-27 17:31:27 +08:00
|
|
|
|
budget_role = _seed_budget_monitor_role(db)
|
|
|
|
|
|
department = OrganizationUnit(
|
|
|
|
|
|
unit_code="DELIVERY-BUDGET-APPROVE",
|
|
|
|
|
|
name="交付部",
|
|
|
|
|
|
unit_type="department",
|
|
|
|
|
|
)
|
2026-05-25 13:35:39 +08:00
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="E8112",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
email="manager-application-approve@example.com",
|
2026-05-27 17:31:27 +08:00
|
|
|
|
organization_unit=department,
|
|
|
|
|
|
)
|
|
|
|
|
|
budget_manager = Employee(
|
|
|
|
|
|
employee_no="E8112-BUDGET",
|
|
|
|
|
|
name="赵预算",
|
|
|
|
|
|
email="budget-p8-application-approve@example.com",
|
|
|
|
|
|
grade="P8",
|
|
|
|
|
|
organization_unit=department,
|
|
|
|
|
|
roles=[budget_role],
|
2026-05-25 13:35:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E8113",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="zhangsan-application-approve@example.com",
|
|
|
|
|
|
manager=manager,
|
2026-05-27 17:31:27 +08:00
|
|
|
|
organization_unit=department,
|
2026-05-25 13:35:39 +08:00
|
|
|
|
)
|
2026-05-27 17:31:27 +08:00
|
|
|
|
db.add_all([department, manager, budget_manager, employee])
|
2026-05-25 13:35:39 +08:00
|
|
|
|
db.flush()
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="APP-20260525-APPROVE",
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="张三",
|
2026-05-27 17:31:27 +08:00
|
|
|
|
department_id=department.id,
|
2026-05-25 13:35:39 +08:00
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
project_code="PRJ-A",
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="支撑国网服务器上线部署",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("12000.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
|
|
|
|
|
|
status="submitted",
|
|
|
|
|
|
approval_stage="直属领导审批",
|
2026-05-30 15:46:51 +08:00
|
|
|
|
risk_flags_json=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"source": "application_detail",
|
|
|
|
|
|
"application_detail": {
|
|
|
|
|
|
"application_type": "差旅费用申请",
|
|
|
|
|
|
"time": "2026-05-25 至 2026-05-27",
|
|
|
|
|
|
"location": "上海",
|
|
|
|
|
|
"reason": "支撑国网服务器上线部署",
|
|
|
|
|
|
"days": "3 天",
|
|
|
|
|
|
"transport_mode": "高铁",
|
2026-06-02 16:22:59 +08:00
|
|
|
|
"lodging_daily_cap": "600元/天",
|
|
|
|
|
|
"subsidy_daily_cap": "120元/天",
|
|
|
|
|
|
"transport_policy": "按真实票据复核",
|
|
|
|
|
|
"policy_estimate": "交通按真实票据 + 住宿 1,800元 + 补贴 360元",
|
|
|
|
|
|
"rule_name": "差旅标准规则",
|
|
|
|
|
|
"rule_version": "2026.05",
|
2026-05-30 15:46:51 +08:00
|
|
|
|
"amount": "12000.00",
|
|
|
|
|
|
},
|
2026-06-01 17:07:14 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"source": "submission_review",
|
|
|
|
|
|
"severity": "high",
|
|
|
|
|
|
"label": "申请风险复核",
|
|
|
|
|
|
"message": "申请金额和行程安排需要预算管理者二次确认。",
|
2026-05-30 15:46:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
],
|
2026-05-25 13:35:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
claim_id = claim.id
|
|
|
|
|
|
|
2026-05-27 17:31:27 +08:00
|
|
|
|
leader_approved = ExpenseClaimService(db).approve_claim(
|
2026-05-25 13:35:39 +08:00
|
|
|
|
claim_id,
|
2026-05-27 17:31:27 +08:00
|
|
|
|
manager_user,
|
2026-05-25 13:35:39 +08:00
|
|
|
|
opinion="业务必要,同意申请。",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-27 17:31:27 +08:00
|
|
|
|
assert leader_approved is not None
|
|
|
|
|
|
assert leader_approved.status == "submitted"
|
|
|
|
|
|
assert leader_approved.approval_stage == "预算管理者审批"
|
|
|
|
|
|
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and flag.get("source") == "manual_approval"
|
|
|
|
|
|
and flag.get("event_type") == "expense_application_approval"
|
|
|
|
|
|
and flag.get("opinion") == "业务必要,同意申请。"
|
|
|
|
|
|
and flag.get("previous_approval_stage") == "直属领导审批"
|
|
|
|
|
|
and flag.get("next_status") == "submitted"
|
|
|
|
|
|
and flag.get("next_approval_stage") == "预算管理者审批"
|
|
|
|
|
|
and flag.get("next_approver_name") == "赵预算"
|
|
|
|
|
|
and flag.get("next_approver_grade") == "P8"
|
|
|
|
|
|
for flag in leader_approved.risk_flags_json
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
approved = ExpenseClaimService(db).approve_claim(
|
|
|
|
|
|
claim_id,
|
|
|
|
|
|
budget_user,
|
|
|
|
|
|
opinion="预算额度可承接,同意。",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
|
assert approved is not None
|
|
|
|
|
|
assert approved.status == "approved"
|
|
|
|
|
|
assert approved.approval_stage == "审批完成"
|
2026-05-26 09:15:14 +08:00
|
|
|
|
archived_claims = ExpenseClaimService(db).list_archived_claims(
|
|
|
|
|
|
CurrentUserContext(
|
|
|
|
|
|
username="finance-archive@example.com",
|
|
|
|
|
|
name="财务归档员",
|
|
|
|
|
|
role_codes=["finance"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
assert any(claim.claim_no == "APP-20260525-APPROVE" for claim in archived_claims)
|
|
|
|
|
|
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
|
|
|
|
|
|
assert generated_draft.status == "draft"
|
|
|
|
|
|
assert generated_draft.approval_stage == "待提交"
|
|
|
|
|
|
assert generated_draft.expense_type == "travel"
|
|
|
|
|
|
assert generated_draft.employee_id == employee.id
|
|
|
|
|
|
assert generated_draft.employee_name == "张三"
|
|
|
|
|
|
assert generated_draft.department_name == "交付部"
|
|
|
|
|
|
assert generated_draft.reason == "支撑国网服务器上线部署"
|
|
|
|
|
|
assert generated_draft.location == "上海"
|
|
|
|
|
|
assert generated_draft.amount == Decimal("12000.00")
|
|
|
|
|
|
assert generated_draft.invoice_count == 0
|
|
|
|
|
|
assert generated_draft.items == []
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and flag.get("source") == "application_handoff"
|
|
|
|
|
|
and flag.get("event_type") == "expense_application_to_reimbursement_draft"
|
|
|
|
|
|
and flag.get("application_claim_no") == "APP-20260525-APPROVE"
|
2026-05-30 15:46:51 +08:00
|
|
|
|
and flag.get("application_detail", {}).get("application_content") == "差旅费用申请 / 上海"
|
|
|
|
|
|
and flag.get("application_detail", {}).get("application_reason") == "支撑国网服务器上线部署"
|
|
|
|
|
|
and flag.get("application_detail", {}).get("application_days") == "3 天"
|
|
|
|
|
|
and flag.get("application_detail", {}).get("application_transport_mode") == "高铁"
|
2026-06-02 16:22:59 +08:00
|
|
|
|
and flag.get("application_detail", {}).get("application_lodging_daily_cap") == "600元/天"
|
|
|
|
|
|
and flag.get("application_detail", {}).get("application_subsidy_daily_cap") == "120元/天"
|
|
|
|
|
|
and flag.get("application_detail", {}).get("application_transport_policy") == "按真实票据复核"
|
|
|
|
|
|
and flag.get("application_detail", {}).get("application_policy_estimate")
|
|
|
|
|
|
== "交通按真实票据 + 住宿 1,800元 + 补贴 360元"
|
|
|
|
|
|
and flag.get("application_detail", {}).get("application_rule_name") == "差旅标准规则"
|
|
|
|
|
|
and flag.get("application_detail", {}).get("application_rule_version") == "2026.05"
|
2026-05-26 09:15:14 +08:00
|
|
|
|
and flag.get("leader_opinion") == "业务必要,同意申请。"
|
2026-05-27 17:31:27 +08:00
|
|
|
|
and flag.get("budget_opinion") == "预算额度可承接,同意。"
|
2026-05-26 09:15:14 +08:00
|
|
|
|
for flag in generated_draft.risk_flags_json
|
|
|
|
|
|
)
|
2026-05-25 13:35:39 +08:00
|
|
|
|
assert any(
|
|
|
|
|
|
isinstance(flag, dict)
|
2026-05-27 17:31:27 +08:00
|
|
|
|
and flag.get("source") == "budget_approval"
|
|
|
|
|
|
and flag.get("event_type") == "expense_application_budget_approval"
|
|
|
|
|
|
and flag.get("opinion") == "预算额度可承接,同意。"
|
|
|
|
|
|
and flag.get("previous_approval_stage") == "预算管理者审批"
|
2026-05-25 13:35:39 +08:00
|
|
|
|
and flag.get("next_status") == "approved"
|
|
|
|
|
|
and flag.get("next_approval_stage") == "审批完成"
|
2026-05-26 09:15:14 +08:00
|
|
|
|
and flag.get("generated_draft_claim_id") == generated_draft.id
|
|
|
|
|
|
and flag.get("generated_draft_claim_no") == generated_draft.claim_no
|
2026-05-25 13:35:39 +08:00
|
|
|
|
for flag in approved.risk_flags_json
|
2026-05-30 15:46:51 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-01 17:07:14 +08:00
|
|
|
|
def test_application_routes_to_department_p8_executive_with_approver_name() -> None:
|
|
|
|
|
|
manager_user = CurrentUserContext(
|
|
|
|
|
|
username="manager-executive-route@example.com",
|
|
|
|
|
|
name="Manager",
|
|
|
|
|
|
role_codes=["manager"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
budget_user = CurrentUserContext(
|
|
|
|
|
|
username="p8-executive-route@example.com",
|
|
|
|
|
|
name="P8 Executive",
|
|
|
|
|
|
role_codes=["executive"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
executive_role = _seed_executive_role(db)
|
|
|
|
|
|
department = OrganizationUnit(
|
|
|
|
|
|
unit_code="DELIVERY-EXECUTIVE-ROUTE",
|
|
|
|
|
|
name="Engineering",
|
|
|
|
|
|
unit_type="department",
|
|
|
|
|
|
)
|
|
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="E-EXEC-ROUTE-MGR",
|
|
|
|
|
|
name="Manager",
|
|
|
|
|
|
email="manager-executive-route@example.com",
|
|
|
|
|
|
organization_unit=department,
|
|
|
|
|
|
)
|
|
|
|
|
|
budget_manager = Employee(
|
|
|
|
|
|
employee_no="E-EXEC-ROUTE-P8",
|
|
|
|
|
|
name="P8 Executive",
|
|
|
|
|
|
email="p8-executive-route@example.com",
|
|
|
|
|
|
grade="P8",
|
|
|
|
|
|
organization_unit=department,
|
|
|
|
|
|
roles=[executive_role],
|
|
|
|
|
|
)
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E-EXEC-ROUTE-APP",
|
|
|
|
|
|
name="Applicant",
|
|
|
|
|
|
email="applicant-executive-route@example.com",
|
|
|
|
|
|
manager=manager,
|
|
|
|
|
|
organization_unit=department,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([department, manager, budget_manager, employee])
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="APP-20260531-EXEC-ROUTE",
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name=employee.name,
|
|
|
|
|
|
department_id=department.id,
|
|
|
|
|
|
department_name=department.name,
|
|
|
|
|
|
project_code="PRJ-A",
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="Production deployment support",
|
|
|
|
|
|
location="Beijing",
|
|
|
|
|
|
amount=Decimal("12000.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 5, 31, 10, 0, tzinfo=UTC),
|
|
|
|
|
|
status="submitted",
|
|
|
|
|
|
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
|
|
|
|
|
risk_flags_json=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"source": "submission_review",
|
|
|
|
|
|
"severity": "high",
|
|
|
|
|
|
"label": "Route risk",
|
|
|
|
|
|
"message": "Application requires budget confirmation.",
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
claim_id = claim.id
|
|
|
|
|
|
|
|
|
|
|
|
routed = ExpenseClaimService(db).approve_claim(
|
|
|
|
|
|
claim_id,
|
|
|
|
|
|
manager_user,
|
|
|
|
|
|
opinion="Approved by direct manager.",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert routed is not None
|
|
|
|
|
|
assert routed.status == "submitted"
|
|
|
|
|
|
assert routed.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE
|
|
|
|
|
|
assert getattr(routed, "budget_approver_name", "") == "P8 Executive"
|
|
|
|
|
|
assert getattr(routed, "budget_approver_grade", "") == "P8"
|
|
|
|
|
|
assert getattr(routed, "budget_approver_role_code", "") == "executive"
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and flag.get("source") == "manual_approval"
|
|
|
|
|
|
and flag.get("next_approval_stage") == BUDGET_MANAGER_APPROVAL_STAGE
|
|
|
|
|
|
and flag.get("next_approver_name") == "P8 Executive"
|
|
|
|
|
|
and flag.get("next_approver_grade") == "P8"
|
|
|
|
|
|
and flag.get("next_approver_role_code") == "executive"
|
|
|
|
|
|
for flag in routed.risk_flags_json
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
approved = ExpenseClaimService(db).approve_claim(
|
|
|
|
|
|
claim_id,
|
|
|
|
|
|
budget_user,
|
|
|
|
|
|
opinion="Budget confirmed.",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert approved is not None
|
|
|
|
|
|
assert approved.status == "approved"
|
|
|
|
|
|
assert approved.approval_stage == APPROVAL_DONE_STAGE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_direct_manager_cannot_route_application_to_missing_budget_approver() -> None:
|
|
|
|
|
|
manager_user = CurrentUserContext(
|
|
|
|
|
|
username="manager-missing-budget@example.com",
|
|
|
|
|
|
name="Manager",
|
|
|
|
|
|
role_codes=["manager"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
department = OrganizationUnit(
|
|
|
|
|
|
unit_code="DELIVERY-MISSING-BUDGET",
|
|
|
|
|
|
name="Engineering",
|
|
|
|
|
|
unit_type="department",
|
|
|
|
|
|
)
|
|
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="E-MISSING-BUDGET-MGR",
|
|
|
|
|
|
name="Manager",
|
|
|
|
|
|
email="manager-missing-budget@example.com",
|
|
|
|
|
|
organization_unit=department,
|
|
|
|
|
|
)
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E-MISSING-BUDGET-APP",
|
|
|
|
|
|
name="Applicant",
|
|
|
|
|
|
email="applicant-missing-budget@example.com",
|
|
|
|
|
|
manager=manager,
|
|
|
|
|
|
organization_unit=department,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([department, manager, employee])
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="APP-20260531-MISSING-BUDGET",
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name=employee.name,
|
|
|
|
|
|
department_id=department.id,
|
|
|
|
|
|
department_name=department.name,
|
|
|
|
|
|
project_code="PRJ-A",
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="Production deployment support",
|
|
|
|
|
|
location="Beijing",
|
|
|
|
|
|
amount=Decimal("12000.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 5, 31, 10, 0, tzinfo=UTC),
|
|
|
|
|
|
status="submitted",
|
|
|
|
|
|
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
|
|
|
|
|
risk_flags_json=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"source": "submission_review",
|
|
|
|
|
|
"severity": "high",
|
|
|
|
|
|
"label": "Route risk",
|
|
|
|
|
|
"message": "Application requires budget confirmation.",
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
claim_id = claim.id
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError, match="未找到同部门 P8 预算审批人"):
|
|
|
|
|
|
ExpenseClaimService(db).approve_claim(
|
|
|
|
|
|
claim_id,
|
|
|
|
|
|
manager_user,
|
|
|
|
|
|
opinion="Approved by direct manager.",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
db.refresh(claim)
|
|
|
|
|
|
assert claim.status == "submitted"
|
|
|
|
|
|
assert claim.approval_stage == DIRECT_MANAGER_APPROVAL_STAGE
|
|
|
|
|
|
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_direct_manager_p8_executive_completes_application_without_duplicate_budget_approval() -> None:
|
|
|
|
|
|
manager_user = CurrentUserContext(
|
|
|
|
|
|
username="manager-executive-merged@example.com",
|
|
|
|
|
|
name="P8 Manager",
|
|
|
|
|
|
role_codes=["manager"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
executive_role = _seed_executive_role(db)
|
|
|
|
|
|
department = OrganizationUnit(
|
|
|
|
|
|
unit_code="DELIVERY-EXECUTIVE-MERGED",
|
|
|
|
|
|
name="Engineering",
|
|
|
|
|
|
unit_type="department",
|
|
|
|
|
|
)
|
|
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="E-EXEC-MERGED-MGR",
|
|
|
|
|
|
name="P8 Manager",
|
|
|
|
|
|
email="manager-executive-merged@example.com",
|
|
|
|
|
|
grade="P8",
|
|
|
|
|
|
organization_unit=department,
|
|
|
|
|
|
roles=[executive_role],
|
|
|
|
|
|
)
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E-EXEC-MERGED-APP",
|
|
|
|
|
|
name="Applicant",
|
|
|
|
|
|
email="applicant-executive-merged@example.com",
|
|
|
|
|
|
manager=manager,
|
|
|
|
|
|
organization_unit=department,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([department, manager, employee])
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="APP-20260531-EXEC-MERGED",
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name=employee.name,
|
|
|
|
|
|
department_id=department.id,
|
|
|
|
|
|
department_name=department.name,
|
|
|
|
|
|
project_code="PRJ-A",
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="Production deployment support",
|
|
|
|
|
|
location="Beijing",
|
|
|
|
|
|
amount=Decimal("12000.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 5, 31, 10, 0, tzinfo=UTC),
|
|
|
|
|
|
status="submitted",
|
|
|
|
|
|
approval_stage=DIRECT_MANAGER_APPROVAL_STAGE,
|
|
|
|
|
|
risk_flags_json=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"source": "submission_review",
|
|
|
|
|
|
"severity": "high",
|
|
|
|
|
|
"label": "Route risk",
|
|
|
|
|
|
"message": "Application requires budget confirmation.",
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
claim_id = claim.id
|
|
|
|
|
|
|
|
|
|
|
|
approved = ExpenseClaimService(db).approve_claim(
|
|
|
|
|
|
claim_id,
|
|
|
|
|
|
manager_user,
|
|
|
|
|
|
opinion="Approved by direct manager and budget owner.",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert approved is not None
|
|
|
|
|
|
assert approved.status == "approved"
|
|
|
|
|
|
assert approved.approval_stage == APPROVAL_DONE_STAGE
|
|
|
|
|
|
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
|
|
|
|
|
|
assert not any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and flag.get("next_approval_stage") == BUDGET_MANAGER_APPROVAL_STAGE
|
|
|
|
|
|
for flag in approved.risk_flags_json
|
|
|
|
|
|
)
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and flag.get("source") == "manual_approval"
|
|
|
|
|
|
and flag.get("next_status") == "approved"
|
|
|
|
|
|
and flag.get("next_approval_stage") == APPROVAL_DONE_STAGE
|
|
|
|
|
|
and flag.get("budget_approval_merged") is True
|
|
|
|
|
|
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
|
|
|
|
|
|
for flag in approved.risk_flags_json
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-30 15:46:51 +08:00
|
|
|
|
def test_direct_manager_budget_monitor_completes_application_claim_without_duplicate_budget_approval() -> None:
|
|
|
|
|
|
manager_user = CurrentUserContext(
|
|
|
|
|
|
username="manager-budget-monitor-application@example.com",
|
|
|
|
|
|
name="李预算经理",
|
|
|
|
|
|
role_codes=["manager", "budget_monitor", "executive"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
budget_role = _seed_budget_monitor_role(db)
|
|
|
|
|
|
department = OrganizationUnit(
|
|
|
|
|
|
unit_code="DELIVERY-BUDGET-MERGED",
|
|
|
|
|
|
name="交付部",
|
|
|
|
|
|
unit_type="department",
|
|
|
|
|
|
)
|
|
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="E8112-MERGED",
|
|
|
|
|
|
name="李预算经理",
|
|
|
|
|
|
email="manager-budget-monitor-application@example.com",
|
|
|
|
|
|
grade="P8",
|
|
|
|
|
|
organization_unit=department,
|
|
|
|
|
|
roles=[budget_role],
|
|
|
|
|
|
)
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E8113-MERGED",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="zhangsan-budget-monitor-application@example.com",
|
|
|
|
|
|
manager=manager,
|
|
|
|
|
|
organization_unit=department,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([department, manager, employee])
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="APP-20260525-MERGED",
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_id=department.id,
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
project_code="PRJ-A",
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="支撑国网服务器上线部署",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("12000.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
|
|
|
|
|
|
status="submitted",
|
|
|
|
|
|
approval_stage="直属领导审批",
|
2026-06-01 17:07:14 +08:00
|
|
|
|
risk_flags_json=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"source": "submission_review",
|
|
|
|
|
|
"severity": "high",
|
|
|
|
|
|
"label": "申请风险复核",
|
|
|
|
|
|
"message": "申请金额和行程安排需要预算管理者二次确认。",
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
2026-05-30 15:46:51 +08:00
|
|
|
|
)
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
claim_id = claim.id
|
|
|
|
|
|
|
|
|
|
|
|
approved = ExpenseClaimService(db).approve_claim(
|
|
|
|
|
|
claim_id,
|
|
|
|
|
|
manager_user,
|
|
|
|
|
|
opinion="业务必要且预算可承接,同意申请。",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert approved is not None
|
|
|
|
|
|
assert approved.status == "approved"
|
|
|
|
|
|
assert approved.approval_stage == "审批完成"
|
|
|
|
|
|
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1
|
|
|
|
|
|
assert not any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and flag.get("next_approval_stage") == "预算管理者审批"
|
|
|
|
|
|
for flag in approved.risk_flags_json
|
|
|
|
|
|
)
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and flag.get("source") == "manual_approval"
|
|
|
|
|
|
and flag.get("event_type") == "expense_application_approval"
|
|
|
|
|
|
and flag.get("label") == "领导及预算审核通过"
|
|
|
|
|
|
and flag.get("opinion") == "业务必要且预算可承接,同意申请。"
|
|
|
|
|
|
and flag.get("previous_approval_stage") == "直属领导审批"
|
|
|
|
|
|
and flag.get("next_status") == "approved"
|
|
|
|
|
|
and flag.get("next_approval_stage") == "审批完成"
|
|
|
|
|
|
and flag.get("budget_approval_merged") is True
|
2026-06-01 17:07:14 +08:00
|
|
|
|
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
|
2026-05-30 15:46:51 +08:00
|
|
|
|
for flag in approved.risk_flags_json
|
|
|
|
|
|
)
|
|
|
|
|
|
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
|
|
|
|
|
|
assert generated_draft.status == "draft"
|
|
|
|
|
|
assert generated_draft.expense_type == "travel"
|
|
|
|
|
|
reviewer_claims = ExpenseClaimService(db).list_claims(manager_user)
|
|
|
|
|
|
assert all(claim.claim_no != generated_draft.claim_no for claim in reviewer_claims)
|
|
|
|
|
|
applicant_claims = ExpenseClaimService(db).list_claims(
|
|
|
|
|
|
CurrentUserContext(
|
|
|
|
|
|
username="zhangsan-budget-monitor-application@example.com",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=[],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
assert any(claim.claim_no == generated_draft.claim_no for claim in applicant_claims)
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and flag.get("source") == "application_handoff"
|
|
|
|
|
|
and flag.get("event_type") == "expense_application_to_reimbursement_draft"
|
|
|
|
|
|
and flag.get("application_claim_no") == "APP-20260525-MERGED"
|
|
|
|
|
|
and flag.get("leader_opinion") == "业务必要且预算可承接,同意申请。"
|
|
|
|
|
|
and flag.get("budget_opinion") == "业务必要且预算可承接,同意申请。"
|
|
|
|
|
|
for flag in generated_draft.risk_flags_json
|
2026-05-20 21:00:47 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-27 14:35:17 +08:00
|
|
|
|
def test_direct_manager_return_application_claim_records_return_node_and_opinion() -> None:
|
|
|
|
|
|
manager_user = CurrentUserContext(
|
|
|
|
|
|
username="manager-application-return@example.com",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
role_codes=["manager"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="E8114",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
email="manager-application-return@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E8115",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="zhangsan-application-return@example.com",
|
|
|
|
|
|
manager=manager,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([manager, employee])
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="APP-20260525-RETURN",
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
project_code="PRJ-A",
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="支撑国网服务器上线部署",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("12000.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
|
|
|
|
|
|
status="submitted",
|
|
|
|
|
|
approval_stage="直属领导审批",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError, match="退单类型"):
|
|
|
|
|
|
ExpenseClaimService(db).return_claim(
|
|
|
|
|
|
claim.id,
|
|
|
|
|
|
manager_user,
|
|
|
|
|
|
reason="预算说明不够清楚,请补充项目必要性。",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.refresh(claim)
|
|
|
|
|
|
assert claim.status == "submitted"
|
|
|
|
|
|
assert claim.risk_flags_json == []
|
|
|
|
|
|
|
|
|
|
|
|
returned = ExpenseClaimService(db).return_claim(
|
|
|
|
|
|
claim.id,
|
|
|
|
|
|
manager_user,
|
|
|
|
|
|
reason="预算说明不够清楚,请补充项目必要性。",
|
2026-05-27 17:31:27 +08:00
|
|
|
|
reason_codes=["application_budget_basis_missing"],
|
2026-05-27 14:35:17 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
assert returned is not None
|
|
|
|
|
|
assert returned.status == "returned"
|
|
|
|
|
|
assert returned.approval_stage == "待提交"
|
|
|
|
|
|
return_event = next(
|
|
|
|
|
|
flag
|
|
|
|
|
|
for flag in returned.risk_flags_json
|
|
|
|
|
|
if isinstance(flag, dict) and flag.get("event_type") == "expense_application_return"
|
|
|
|
|
|
)
|
|
|
|
|
|
assert return_event["label"] == "领导退回"
|
|
|
|
|
|
assert return_event["node_key"] == "returned"
|
|
|
|
|
|
assert return_event["node_label"] == "退回"
|
|
|
|
|
|
assert return_event["approval_node"] == "退回"
|
|
|
|
|
|
assert return_event["operator"] == "李经理"
|
|
|
|
|
|
assert return_event["opinion"] == "预算说明不够清楚,请补充项目必要性。"
|
|
|
|
|
|
assert return_event["leader_opinion"] == "预算说明不够清楚,请补充项目必要性。"
|
|
|
|
|
|
assert return_event["return_stage"] == "直属领导审批"
|
|
|
|
|
|
assert return_event["return_stage_key"] == "direct_manager"
|
2026-05-27 17:31:27 +08:00
|
|
|
|
assert return_event["reason_codes"] == ["application_budget_basis_missing"]
|
|
|
|
|
|
assert return_event["risk_points"] == ["预算测算依据不足"]
|
2026-05-27 14:35:17 +08:00
|
|
|
|
assert return_event["next_status"] == "returned"
|
|
|
|
|
|
assert return_event["next_approval_stage"] == "待提交"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-26 17:29:35 +08:00
|
|
|
|
def test_application_approval_transfers_budget_reservation_to_reimbursement_draft() -> None:
|
|
|
|
|
|
owner = CurrentUserContext(
|
|
|
|
|
|
username="application-budget-owner-approve@example.com",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=["employee"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
manager_user = CurrentUserContext(
|
|
|
|
|
|
username="manager-application-budget@example.com",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
role_codes=["manager"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
2026-05-27 17:31:27 +08:00
|
|
|
|
budget_user = CurrentUserContext(
|
|
|
|
|
|
username="budget-p8-transfer@example.com",
|
|
|
|
|
|
name="赵预算",
|
|
|
|
|
|
role_codes=["budget_monitor"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
2026-05-26 17:29:35 +08:00
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
2026-05-27 17:31:27 +08:00
|
|
|
|
budget_role = _seed_budget_monitor_role(db)
|
|
|
|
|
|
department = OrganizationUnit(
|
|
|
|
|
|
id="dept-budget-transfer",
|
|
|
|
|
|
unit_code="DELIVERY-BUDGET-TRANSFER",
|
|
|
|
|
|
name="交付部",
|
|
|
|
|
|
unit_type="department",
|
|
|
|
|
|
)
|
2026-05-26 17:29:35 +08:00
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="M-BUDGET-APP",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
email="manager-application-budget@example.com",
|
2026-05-27 17:31:27 +08:00
|
|
|
|
organization_unit=department,
|
|
|
|
|
|
)
|
|
|
|
|
|
budget_manager = Employee(
|
|
|
|
|
|
employee_no="P8-BUDGET-APP",
|
|
|
|
|
|
name="赵预算",
|
|
|
|
|
|
email="budget-p8-transfer@example.com",
|
|
|
|
|
|
grade="P8",
|
|
|
|
|
|
organization_unit=department,
|
|
|
|
|
|
roles=[budget_role],
|
2026-05-26 17:29:35 +08:00
|
|
|
|
)
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E-BUDGET-APP",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="application-budget-owner-approve@example.com",
|
|
|
|
|
|
manager=manager,
|
2026-05-27 17:31:27 +08:00
|
|
|
|
organization_unit=department,
|
2026-05-26 17:29:35 +08:00
|
|
|
|
)
|
2026-05-27 17:31:27 +08:00
|
|
|
|
db.add_all([department, manager, budget_manager, employee])
|
2026-05-26 17:29:35 +08:00
|
|
|
|
db.flush()
|
|
|
|
|
|
_seed_budget_allocation(
|
|
|
|
|
|
db,
|
|
|
|
|
|
department_id="dept-budget-transfer",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
amount=Decimal("50000.00"),
|
|
|
|
|
|
)
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="APP-20260525-TRANSFER",
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_id="dept-budget-transfer",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
project_code=None,
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="客户现场交付",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("12000.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=None,
|
|
|
|
|
|
status="draft",
|
|
|
|
|
|
approval_stage="待提交",
|
2026-06-01 17:07:14 +08:00
|
|
|
|
risk_flags_json=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"source": "platform_risk",
|
|
|
|
|
|
"severity": "high",
|
|
|
|
|
|
"label": "申请风险复核",
|
|
|
|
|
|
"message": "申请金额和行程安排需要预算管理者二次确认。",
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
2026-05-26 17:29:35 +08:00
|
|
|
|
)
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
|
|
|
|
|
|
service.submit_claim(claim.id, owner)
|
2026-05-27 17:31:27 +08:00
|
|
|
|
leader_approved = service.approve_claim(claim.id, manager_user, opinion="同意申请")
|
2026-05-26 17:29:35 +08:00
|
|
|
|
reservation = db.query(BudgetReservation).one()
|
|
|
|
|
|
|
2026-05-27 17:31:27 +08:00
|
|
|
|
assert leader_approved is not None
|
|
|
|
|
|
assert leader_approved.approval_stage == "预算管理者审批"
|
|
|
|
|
|
assert reservation.source_type == "application"
|
|
|
|
|
|
assert reservation.source_id == claim.id
|
|
|
|
|
|
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
|
|
|
|
|
|
|
|
|
|
|
|
approved = service.approve_claim(claim.id, budget_user, opinion="预算通过")
|
|
|
|
|
|
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
|
|
|
|
|
|
db.refresh(reservation)
|
|
|
|
|
|
|
2026-05-26 17:29:35 +08:00
|
|
|
|
assert approved is not None
|
|
|
|
|
|
assert reservation.source_type == "claim"
|
|
|
|
|
|
assert reservation.source_id == generated_draft.id
|
|
|
|
|
|
assert reservation.source_no == generated_draft.claim_no
|
|
|
|
|
|
assert any(item.transaction_type == "transfer" for item in db.query(BudgetTransaction).all())
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and flag.get("event_type") == "budget_reservation_transferred"
|
|
|
|
|
|
for flag in generated_draft.risk_flags_json
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-01 17:07:14 +08:00
|
|
|
|
deleted = service.delete_claim(
|
|
|
|
|
|
generated_draft.id,
|
|
|
|
|
|
CurrentUserContext(
|
|
|
|
|
|
username="browser-session-user",
|
|
|
|
|
|
name="",
|
|
|
|
|
|
role_codes=["user"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
employee_no="E-BUDGET-APP",
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
db.refresh(reservation)
|
|
|
|
|
|
|
|
|
|
|
|
assert deleted is not None
|
|
|
|
|
|
assert db.get(ExpenseClaim, generated_draft.id) is None
|
|
|
|
|
|
assert reservation.source_status == "released"
|
|
|
|
|
|
assert reservation.released_amount == Decimal("12000.00")
|
|
|
|
|
|
|
2026-05-26 17:29:35 +08:00
|
|
|
|
|
2026-05-27 17:31:27 +08:00
|
|
|
|
def test_direct_manager_approval_defaults_blank_opinion_to_agree() -> None:
|
2026-05-26 09:15:14 +08:00
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="manager-application-required-opinion@example.com",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
role_codes=["manager"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="E8122",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
email="manager-application-required-opinion@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E8123",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="zhangsan-application-required-opinion@example.com",
|
|
|
|
|
|
manager=manager,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([manager, employee])
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="APP-20260525-REQUIRE-OPINION",
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
project_code="PRJ-A",
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="支撑国网服务器上线部署",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("12000.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
|
|
|
|
|
|
status="submitted",
|
|
|
|
|
|
approval_stage="直属领导审批",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
claim_id = claim.id
|
|
|
|
|
|
|
2026-05-27 17:31:27 +08:00
|
|
|
|
approved = ExpenseClaimService(db).approve_claim(
|
|
|
|
|
|
claim_id,
|
|
|
|
|
|
current_user,
|
|
|
|
|
|
opinion=" ",
|
|
|
|
|
|
)
|
2026-05-26 09:15:14 +08:00
|
|
|
|
|
2026-05-27 17:31:27 +08:00
|
|
|
|
assert approved is not None
|
|
|
|
|
|
assert approved.status == "submitted"
|
|
|
|
|
|
assert approved.approval_stage == "预算管理者审批"
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and flag.get("event_type") == "expense_application_approval"
|
|
|
|
|
|
and flag.get("opinion") == "同意"
|
|
|
|
|
|
and flag.get("next_approval_stage") == "预算管理者审批"
|
|
|
|
|
|
for flag in approved.risk_flags_json
|
|
|
|
|
|
)
|
2026-05-26 09:15:14 +08:00
|
|
|
|
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-27 17:31:27 +08:00
|
|
|
|
def test_budget_analysis_uses_current_application_reservation_without_double_counting() -> None:
|
|
|
|
|
|
owner = CurrentUserContext(
|
|
|
|
|
|
username="application-budget-analysis-owner@example.com",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
role_codes=["employee"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E-BUDGET-ANALYSIS",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="application-budget-analysis-owner@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
_seed_budget_allocation(
|
|
|
|
|
|
db,
|
|
|
|
|
|
department_id="dept-budget-analysis",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
amount=Decimal("50000.00"),
|
|
|
|
|
|
)
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="APP-20260525-ANALYSIS",
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_id="dept-budget-analysis",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
project_code="PRJ-A",
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="客户现场交付",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("12000.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=0,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=None,
|
|
|
|
|
|
status="draft",
|
|
|
|
|
|
approval_stage="待提交",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
ExpenseClaimService(db).submit_claim(claim.id, owner)
|
|
|
|
|
|
analysis = BudgetService(db).analyze_claim_budget(claim)
|
|
|
|
|
|
|
|
|
|
|
|
assert analysis["metrics"]["claim_amount_ratio"] == "24.00"
|
|
|
|
|
|
assert analysis["metrics"]["after_usage_rate"] == "24.00"
|
|
|
|
|
|
assert analysis["budget_context"]["current_reserved_amount"] == "12000.00"
|
|
|
|
|
|
assert analysis["score"] >= 70
|
|
|
|
|
|
assert any("本次申请金额 12000.00 元,占预算 24.00%" in item for item in analysis["basis"])
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-26 17:29:35 +08:00
|
|
|
|
def test_finance_approve_reimbursement_consumes_budget_reservation() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="finance-budget-approve@example.com",
|
|
|
|
|
|
name="财务",
|
|
|
|
|
|
role_codes=["finance"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
allocation = _seed_budget_allocation(
|
|
|
|
|
|
db,
|
|
|
|
|
|
department_id="dept-finance-budget",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
amount=Decimal("50000.00"),
|
|
|
|
|
|
)
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="RE-20260525-BUDGET",
|
|
|
|
|
|
employee_id="emp-finance-budget",
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_id="dept-finance-budget",
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
project_code=None,
|
|
|
|
|
|
expense_type="travel",
|
|
|
|
|
|
reason="客户现场交付",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("12000.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=1,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
|
|
|
|
|
|
status="submitted",
|
|
|
|
|
|
approval_stage="财务审批",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
reservation = BudgetReservation(
|
|
|
|
|
|
reservation_no=f"BRS-TEST-{uuid.uuid4().hex[:8]}",
|
|
|
|
|
|
allocation_id=allocation.id,
|
|
|
|
|
|
source_type="claim",
|
|
|
|
|
|
source_id=claim.id,
|
|
|
|
|
|
source_no=claim.claim_no,
|
|
|
|
|
|
source_status="active",
|
|
|
|
|
|
amount=Decimal("12000.00"),
|
|
|
|
|
|
context_json={},
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(reservation)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
approved = ExpenseClaimService(db).approve_claim(claim.id, current_user, opinion="同意入账")
|
|
|
|
|
|
|
|
|
|
|
|
assert approved is not None
|
|
|
|
|
|
db.refresh(reservation)
|
|
|
|
|
|
assert reservation.source_status == "consumed"
|
|
|
|
|
|
assert reservation.consumed_amount == Decimal("12000.00")
|
|
|
|
|
|
assert db.query(BudgetTransaction).filter(BudgetTransaction.transaction_type == "consume").count() == 1
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and flag.get("source") == "budget_control"
|
|
|
|
|
|
and flag.get("event_type") == "budget_consumed"
|
|
|
|
|
|
for flag in approved.risk_flags_json
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-27 14:35:17 +08:00
|
|
|
|
def test_finance_cannot_operate_own_claim_in_finance_stage() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="finance-own-approval@example.com",
|
|
|
|
|
|
name="财务",
|
|
|
|
|
|
role_codes=["finance"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E8124",
|
|
|
|
|
|
name="财务",
|
|
|
|
|
|
email="finance-own-approval@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(employee)
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="RE-20260525-FINANCE-SELF",
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="财务",
|
|
|
|
|
|
department_name="财务部",
|
|
|
|
|
|
project_code=None,
|
|
|
|
|
|
expense_type="travel",
|
|
|
|
|
|
reason="差旅报销",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("800.00"),
|
|
|
|
|
|
currency="CNY",
|
|
|
|
|
|
invoice_count=1,
|
|
|
|
|
|
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
|
|
|
|
|
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
|
|
|
|
|
|
status="submitted",
|
|
|
|
|
|
approval_stage="财务审批",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
service = ExpenseClaimService(db)
|
|
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError, match="财务终审"):
|
|
|
|
|
|
service.approve_claim(claim.id, current_user, opinion="同意入账")
|
|
|
|
|
|
with pytest.raises(ValueError, match="可以退回"):
|
|
|
|
|
|
service.return_claim(claim.id, current_user, reason="退回")
|
|
|
|
|
|
|
|
|
|
|
|
db.refresh(claim)
|
|
|
|
|
|
assert claim.status == "submitted"
|
|
|
|
|
|
assert claim.approval_stage == "财务审批"
|
|
|
|
|
|
assert claim.risk_flags_json == []
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-28 12:09:49 +08:00
|
|
|
|
def test_finance_can_approve_claim_to_pending_payment_stage() -> None:
|
2026-05-21 09:28:33 +08:00
|
|
|
|
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
|
2026-05-28 12:09:49 +08:00
|
|
|
|
assert approved.status == "pending_payment"
|
|
|
|
|
|
assert approved.approval_stage == "待付款"
|
2026-05-21 09:28:33 +08:00
|
|
|
|
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") == "财务审批"
|
2026-05-28 12:09:49 +08:00
|
|
|
|
and flag.get("next_status") == "pending_payment"
|
|
|
|
|
|
and flag.get("next_approval_stage") == "待付款"
|
2026-05-21 09:28:33 +08:00
|
|
|
|
for flag in approved.risk_flags_json
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-28 12:09:49 +08:00
|
|
|
|
def test_finance_can_mark_pending_payment_claim_as_paid() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="finance-pay@example.com",
|
|
|
|
|
|
name="财务付款",
|
|
|
|
|
|
role_codes=["finance"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-FIN-PAY-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="pending_payment",
|
|
|
|
|
|
approval_stage="待付款",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add(claim)
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
paid = ExpenseClaimService(db).mark_claim_paid(claim.id, current_user)
|
|
|
|
|
|
|
|
|
|
|
|
assert paid is not None
|
|
|
|
|
|
assert paid.status == "paid"
|
|
|
|
|
|
assert paid.approval_stage == "已付款"
|
|
|
|
|
|
assert any(
|
|
|
|
|
|
isinstance(flag, dict)
|
|
|
|
|
|
and flag.get("source") == "payment"
|
|
|
|
|
|
and flag.get("event_type") == "expense_claim_payment_completed"
|
|
|
|
|
|
and flag.get("previous_status") == "pending_payment"
|
|
|
|
|
|
and flag.get("next_status") == "paid"
|
|
|
|
|
|
and flag.get("next_approval_stage") == "已付款"
|
|
|
|
|
|
for flag in paid.risk_flags_json
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
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:
|
2026-05-27 14:35:17 +08:00
|
|
|
|
manager_user = CurrentUserContext(
|
|
|
|
|
|
username="manager-return-count@example.com",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
role_codes=["manager"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
finance_user = CurrentUserContext(
|
2026-05-20 21:00:47 +08:00
|
|
|
|
username="finance-return@example.com",
|
|
|
|
|
|
name="财务复核",
|
|
|
|
|
|
role_codes=["finance"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
2026-05-27 14:35:17 +08:00
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="E8130",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
email="manager-return-count@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E8131",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="zhangsan-return-count@example.com",
|
|
|
|
|
|
manager=manager,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([manager, employee])
|
|
|
|
|
|
db.flush()
|
2026-05-20 21:00:47 +08:00
|
|
|
|
claim = ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-RET-301",
|
2026-05-27 14:35:17 +08:00
|
|
|
|
employee_id=employee.id,
|
2026-05-20 21:00:47 +08:00
|
|
|
|
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,
|
2026-05-27 14:35:17 +08:00
|
|
|
|
manager_user,
|
2026-05-20 21:00:47 +08:00
|
|
|
|
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,
|
2026-05-27 14:35:17 +08:00
|
|
|
|
finance_user,
|
2026-05-20 21:00:47 +08:00
|
|
|
|
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"] == "发票金额与明细金额不一致,请重新核对。"
|
2026-05-27 14:35:17 +08:00
|
|
|
|
assert return_events[0]["operator_role_codes"] == ["manager"]
|
2026-05-20 21:00:47 +08:00
|
|
|
|
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()
|
2026-05-21 09:28:33 +08:00
|
|
|
|
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
|
2026-05-20 21:00:47 +08:00
|
|
|
|
|
|
|
|
|
|
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 [])
|
|
|
|
|
|
)
|
2026-05-21 09:28:33 +08:00
|
|
|
|
assert AgentConversationService(db).get_conversation(conversation_id) is None
|
2026-05-20 21:00:47 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-05-15 06:56:00 +00:00
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="manager@example.com",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
role_codes=["manager"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
manager = Employee(
|
|
|
|
|
|
employee_no="E8000",
|
|
|
|
|
|
name="李经理",
|
|
|
|
|
|
email="manager@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E8001",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="zhangsan@example.com",
|
|
|
|
|
|
manager=manager,
|
|
|
|
|
|
)
|
|
|
|
|
|
outsider_manager = Employee(
|
|
|
|
|
|
employee_no="E8002",
|
|
|
|
|
|
name="王经理",
|
|
|
|
|
|
email="other-manager@example.com",
|
|
|
|
|
|
)
|
|
|
|
|
|
outsider = Employee(
|
|
|
|
|
|
employee_no="E8003",
|
|
|
|
|
|
name="李四",
|
|
|
|
|
|
email="lisi@example.com",
|
|
|
|
|
|
manager=outsider_manager,
|
|
|
|
|
|
)
|
|
|
|
|
|
db.add_all([manager, employee, outsider_manager, outsider])
|
|
|
|
|
|
db.flush()
|
|
|
|
|
|
db.add_all(
|
|
|
|
|
|
[
|
|
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-MGR-201",
|
|
|
|
|
|
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, 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-MGR-202",
|
|
|
|
|
|
employee_id=outsider.id,
|
|
|
|
|
|
employee_name="李四",
|
|
|
|
|
|
department_name="销售部",
|
|
|
|
|
|
project_code="PRJ-OTHER",
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
claims = ExpenseClaimService(db).list_approval_claims(current_user)
|
2026-05-15 06:56:00 +00:00
|
|
|
|
|
|
|
|
|
|
assert len(claims) == 1
|
|
|
|
|
|
assert claims[0].claim_no == "EXP-MGR-201"
|
2026-05-21 09:28:33 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"]
|
2026-05-27 17:31:27 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applications() -> None:
|
|
|
|
|
|
current_user = CurrentUserContext(
|
|
|
|
|
|
username="budget-p8-list@example.com",
|
|
|
|
|
|
name="赵预算",
|
|
|
|
|
|
role_codes=["budget_monitor"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
p8_without_budget_role = CurrentUserContext(
|
2026-06-01 17:07:14 +08:00
|
|
|
|
username="p8-without-budget-list@example.com",
|
2026-05-27 17:31:27 +08:00
|
|
|
|
name="budget manager",
|
|
|
|
|
|
role_codes=["manager"],
|
|
|
|
|
|
is_admin=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with build_session() as db:
|
|
|
|
|
|
budget_role = _seed_budget_monitor_role(db)
|
|
|
|
|
|
delivery_department = OrganizationUnit(
|
|
|
|
|
|
unit_code="DELIVERY-BUDGET-LIST",
|
|
|
|
|
|
name="交付部",
|
|
|
|
|
|
unit_type="department",
|
|
|
|
|
|
)
|
|
|
|
|
|
market_department = OrganizationUnit(
|
|
|
|
|
|
unit_code="MARKET-BUDGET-LIST",
|
|
|
|
|
|
name="市场部",
|
|
|
|
|
|
unit_type="department",
|
|
|
|
|
|
)
|
|
|
|
|
|
budget_manager = Employee(
|
|
|
|
|
|
employee_no="E-P8-BUDGET-LIST",
|
|
|
|
|
|
name="赵预算",
|
|
|
|
|
|
email="budget-p8-list@example.com",
|
|
|
|
|
|
grade="P8",
|
|
|
|
|
|
organization_unit=delivery_department,
|
|
|
|
|
|
roles=[budget_role],
|
|
|
|
|
|
)
|
2026-06-01 17:07:14 +08:00
|
|
|
|
p8_without_budget_employee = Employee(
|
|
|
|
|
|
employee_no="E-P8-NO-BUDGET-LIST",
|
|
|
|
|
|
name="P8 No Budget Role",
|
|
|
|
|
|
email="p8-without-budget-list@example.com",
|
|
|
|
|
|
grade="P8",
|
|
|
|
|
|
organization_unit=delivery_department,
|
|
|
|
|
|
)
|
2026-05-27 17:31:27 +08:00
|
|
|
|
employee = Employee(
|
|
|
|
|
|
employee_no="E-BUDGET-LIST-OWNER",
|
|
|
|
|
|
name="张三",
|
|
|
|
|
|
email="budget-list-owner@example.com",
|
|
|
|
|
|
organization_unit=delivery_department,
|
|
|
|
|
|
)
|
|
|
|
|
|
market_employee = Employee(
|
|
|
|
|
|
employee_no="E-BUDGET-LIST-MARKET",
|
|
|
|
|
|
name="王五",
|
|
|
|
|
|
email="budget-list-market@example.com",
|
|
|
|
|
|
organization_unit=market_department,
|
|
|
|
|
|
)
|
2026-06-01 17:07:14 +08:00
|
|
|
|
db.add_all([
|
|
|
|
|
|
delivery_department,
|
|
|
|
|
|
market_department,
|
|
|
|
|
|
budget_manager,
|
|
|
|
|
|
p8_without_budget_employee,
|
|
|
|
|
|
employee,
|
|
|
|
|
|
market_employee,
|
|
|
|
|
|
])
|
2026-05-27 17:31:27 +08:00
|
|
|
|
db.flush()
|
|
|
|
|
|
db.add_all(
|
|
|
|
|
|
[
|
|
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
claim_no="APP-BUDGET-LIST-201",
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_id=delivery_department.id,
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
project_code="PRJ-BUDGET",
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="预算待审申请",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("12000.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="submitted",
|
|
|
|
|
|
approval_stage="预算管理者审批",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
),
|
|
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
claim_no="APP-BUDGET-LIST-OTHER-DEPT",
|
|
|
|
|
|
employee_id=market_employee.id,
|
|
|
|
|
|
employee_name="王五",
|
|
|
|
|
|
department_id=market_department.id,
|
|
|
|
|
|
department_name="市场部",
|
|
|
|
|
|
project_code="PRJ-BUDGET",
|
|
|
|
|
|
expense_type="travel_application",
|
|
|
|
|
|
reason="其他部门预算待审申请",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("13000.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="submitted",
|
|
|
|
|
|
approval_stage="预算管理者审批",
|
|
|
|
|
|
risk_flags_json=[],
|
|
|
|
|
|
),
|
|
|
|
|
|
ExpenseClaim(
|
|
|
|
|
|
claim_no="EXP-BUDGET-LIST-202",
|
|
|
|
|
|
employee_id=employee.id,
|
|
|
|
|
|
employee_name="张三",
|
|
|
|
|
|
department_id=delivery_department.id,
|
|
|
|
|
|
department_name="交付部",
|
|
|
|
|
|
project_code="PRJ-BUDGET",
|
|
|
|
|
|
expense_type="transport",
|
|
|
|
|
|
reason="财务待审报销",
|
|
|
|
|
|
location="上海",
|
|
|
|
|
|
amount=Decimal("88.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.commit()
|
|
|
|
|
|
|
|
|
|
|
|
claims = ExpenseClaimService(db).list_approval_claims(current_user)
|
|
|
|
|
|
|
|
|
|
|
|
assert [claim.claim_no for claim in claims] == ["APP-BUDGET-LIST-201"]
|
2026-06-01 17:07:14 +08:00
|
|
|
|
assert getattr(claims[0], "budget_approver_name", "") == "赵预算"
|
|
|
|
|
|
assert getattr(claims[0], "budget_approver_grade", "") == "P8"
|
|
|
|
|
|
assert getattr(claims[0], "budget_approver_role_code", "") == "budget_monitor"
|
2026-05-27 17:31:27 +08:00
|
|
|
|
claims_without_budget_role = ExpenseClaimService(db).list_approval_claims(p8_without_budget_role)
|
|
|
|
|
|
assert [claim.claim_no for claim in claims_without_budget_role] == []
|