feat: 新增预算费控模型与报销审批流引擎
后端新增预算费控服务和报销单审批流模块,引入申请人费用画像 算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常 量和明细同步,更新差旅报销规则电子表格,前端新增预算分析 组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧 边栏和顶栏样式,补充单元测试。
This commit is contained in:
@@ -49,6 +49,27 @@ def test_agent_run_service_marks_stale_knowledge_sync_run_failed_on_read() -> No
|
||||
assert all(item.run_id != created.run_id for item in running_runs)
|
||||
|
||||
|
||||
def test_agent_run_service_marks_stale_llm_wiki_run_failed_on_read() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentRunService(db)
|
||||
created = service.create_run(
|
||||
agent=AgentName.HERMES.value,
|
||||
source=AgentRunSource.SCHEDULE.value,
|
||||
status=AgentRunStatus.RUNNING.value,
|
||||
route_json={
|
||||
"job_type": "llm_wiki_sync",
|
||||
"heartbeat_at": (datetime.now(UTC) - timedelta(minutes=31)).isoformat(),
|
||||
"requested_document_ids": [],
|
||||
},
|
||||
)
|
||||
|
||||
fetched = service.get_run(created.run_id)
|
||||
|
||||
assert fetched is not None
|
||||
assert fetched.status == AgentRunStatus.FAILED.value
|
||||
assert fetched.error_message == "Knowledge index heartbeat timed out."
|
||||
|
||||
|
||||
def test_agent_run_service_updates_existing_tool_call() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentRunService(db)
|
||||
|
||||
74
server/tests/test_applicant_expense_profile_algorithm.py
Normal file
74
server/tests/test_applicant_expense_profile_algorithm.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from app.algorithem import ApplicantExpenseProfileInput, evaluate_applicant_expense_profile
|
||||
|
||||
|
||||
def test_applicant_profile_recommends_review_and_days_cap() -> None:
|
||||
result = evaluate_applicant_expense_profile(
|
||||
ApplicantExpenseProfileInput(
|
||||
applicant_claim_count_90d=6,
|
||||
peer_claim_count_p75_90d=4,
|
||||
applicant_amount_90d=Decimal("42800"),
|
||||
available_peer_budget_90d=Decimal("150000"),
|
||||
amount_percentile=Decimal("88"),
|
||||
peer_amount_median_90d=Decimal("25000"),
|
||||
adjusted_or_returned_count_180d=3,
|
||||
approved_claim_count_180d=12,
|
||||
requested_days=Decimal("5"),
|
||||
peer_travel_days_p75=Decimal("3"),
|
||||
business_buffer_days=Decimal("1"),
|
||||
claim_amount=Decimal("8000"),
|
||||
peer_daily_cost_baseline=Decimal("1400"),
|
||||
tolerance_factor=Decimal("1.20"),
|
||||
)
|
||||
)
|
||||
|
||||
assert result.profile_score == 68
|
||||
assert result.profile_level == "review"
|
||||
assert result.recommendation_level == "review"
|
||||
assert result.travel_days_ratio == Decimal("1.6667")
|
||||
assert result.suggested_days == Decimal("4.0000")
|
||||
assert result.suggested_amount_cap == Decimal("6720.00")
|
||||
assert "applicant.amount_percentile.p85" in result.basis_codes
|
||||
assert "travel.days_ratio.review" in result.basis_codes
|
||||
|
||||
|
||||
def test_applicant_profile_handles_missing_baselines_without_false_risk() -> None:
|
||||
result = evaluate_applicant_expense_profile(
|
||||
ApplicantExpenseProfileInput(
|
||||
applicant_claim_count_90d=1,
|
||||
applicant_amount_90d=Decimal("800"),
|
||||
requested_days=Decimal("2"),
|
||||
claim_amount=Decimal("800"),
|
||||
)
|
||||
)
|
||||
|
||||
assert result.profile_score == 0
|
||||
assert result.profile_level == "normal"
|
||||
assert result.recommendation_level == "normal"
|
||||
assert result.frequency_ratio == Decimal("0")
|
||||
assert result.daily_cost_ratio == Decimal("0")
|
||||
assert result.suggested_days == Decimal("2.0000")
|
||||
assert result.suggested_amount_cap is None
|
||||
assert result.basis_codes == []
|
||||
|
||||
|
||||
def test_entertainment_deviation_can_escalate_recommendation() -> None:
|
||||
result = evaluate_applicant_expense_profile(
|
||||
ApplicantExpenseProfileInput(
|
||||
entertainment_amount=Decimal("3000"),
|
||||
attendee_count=3,
|
||||
entertainment_standard_cap=Decimal("600"),
|
||||
same_customer_frequency_90d=3,
|
||||
applicant_entertainment_percentile=Decimal("96"),
|
||||
)
|
||||
)
|
||||
|
||||
assert result.entertainment_deviation_score == 100
|
||||
assert result.current_claim_deviation_score == 100
|
||||
assert result.profile_score == 15
|
||||
assert result.profile_level == "normal"
|
||||
assert result.recommendation_level == "escalation"
|
||||
assert "entertainment.per_capita.escalation" in result.basis_codes
|
||||
assert "entertainment.same_customer.frequency_review" in result.basis_codes
|
||||
assert "entertainment.amount_percentile.p95" in result.basis_codes
|
||||
@@ -13,6 +13,10 @@ from app.api.deps import get_db
|
||||
from app.db.base import Base
|
||||
from app.main import create_app
|
||||
from app.models.budget import BudgetAllocation, BudgetTransaction
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.role import Role
|
||||
|
||||
|
||||
def build_session_factory() -> sessionmaker[Session]:
|
||||
@@ -109,6 +113,27 @@ def seed_budget_allocations(db: Session) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def seed_market_budget_monitor(db: Session) -> tuple[Role, OrganizationUnit]:
|
||||
role = Role(role_code="budget_monitor", name="预算监控员")
|
||||
department = OrganizationUnit(
|
||||
id="dept-market",
|
||||
unit_code="MARKET-DEPT",
|
||||
name="市场部",
|
||||
unit_type="department",
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E-BUDGET-MARKET-P8",
|
||||
name="赵预算",
|
||||
email="budget-monitor@example.com",
|
||||
grade="P8",
|
||||
organization_unit=department,
|
||||
roles=[role],
|
||||
)
|
||||
db.add_all([role, department, employee])
|
||||
db.flush()
|
||||
return role, department
|
||||
|
||||
|
||||
def test_admin_can_view_all_budget_allocations_without_is_admin_header() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
@@ -281,3 +306,69 @@ def test_budget_monitor_cannot_edit_and_admin_can_edit() -> None:
|
||||
)
|
||||
assert admin_response.status_code == 201
|
||||
assert admin_response.json()["department_name"] == "销售部"
|
||||
|
||||
|
||||
def test_budget_analysis_endpoint_is_limited_to_budget_roles() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_budget_allocations(db)
|
||||
budget_role, market_department = seed_market_budget_monitor(db)
|
||||
p6_budget_monitor = Employee(
|
||||
employee_no="E-BUDGET-MARKET-P6",
|
||||
name="低级预算",
|
||||
email="p6-budget-monitor@example.com",
|
||||
grade="P6",
|
||||
organization_unit=market_department,
|
||||
roles=[budget_role],
|
||||
)
|
||||
db.add(p6_budget_monitor)
|
||||
db.flush()
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-BUDGET-ANALYSIS-001",
|
||||
employee_id=p6_budget_monitor.id,
|
||||
employee_name="低级预算",
|
||||
department_id="dept-market",
|
||||
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, 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
|
||||
|
||||
ordinary_response = client.get(
|
||||
f"/api/v1/reimbursements/claims/{claim_id}/budget-analysis",
|
||||
headers={
|
||||
"x-auth-username": "zhangsan@example.com",
|
||||
"x-auth-role-codes": "employee",
|
||||
},
|
||||
)
|
||||
monitor_response = client.get(
|
||||
f"/api/v1/reimbursements/claims/{claim_id}/budget-analysis",
|
||||
headers={
|
||||
"x-auth-username": "budget-monitor@example.com",
|
||||
"x-auth-role-codes": "budget_monitor",
|
||||
},
|
||||
)
|
||||
p6_monitor_response = client.get(
|
||||
f"/api/v1/reimbursements/claims/{claim_id}/budget-analysis",
|
||||
headers={
|
||||
"x-auth-username": "p6-budget-monitor@example.com",
|
||||
"x-auth-role-codes": "budget_monitor",
|
||||
},
|
||||
)
|
||||
|
||||
assert ordinary_response.status_code == 403
|
||||
assert p6_monitor_response.status_code == 403
|
||||
assert monitor_response.status_code == 200
|
||||
assert Decimal(monitor_response.json()["metrics"]["claim_amount_ratio"]) == Decimal("24.00")
|
||||
|
||||
@@ -16,10 +16,12 @@ from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransac
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.role import Role
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
|
||||
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate, ExpenseClaimUpdate
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.budget import BudgetService
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
@@ -108,6 +110,16 @@ def _seed_budget_allocation(
|
||||
return allocation
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_validate_claim_for_submission_allows_office_claim_without_location() -> None:
|
||||
service = ExpenseClaimService.__new__(ExpenseClaimService)
|
||||
claim = build_claim(expense_type="office", location="待补充")
|
||||
@@ -3320,32 +3332,55 @@ def test_application_submit_skips_budget_for_non_demo_subject() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
def test_direct_manager_can_route_application_claim_to_budget_approval_then_budget_manager_creates_draft() -> None:
|
||||
manager_user = CurrentUserContext(
|
||||
username="manager-application-approve@example.com",
|
||||
name="李经理",
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
budget_user = CurrentUserContext(
|
||||
username="budget-p8-application-approve@example.com",
|
||||
name="赵预算",
|
||||
role_codes=["budget_monitor"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
budget_role = _seed_budget_monitor_role(db)
|
||||
department = OrganizationUnit(
|
||||
unit_code="DELIVERY-BUDGET-APPROVE",
|
||||
name="交付部",
|
||||
unit_type="department",
|
||||
)
|
||||
manager = Employee(
|
||||
employee_no="E8112",
|
||||
name="李经理",
|
||||
email="manager-application-approve@example.com",
|
||||
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],
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E8113",
|
||||
name="张三",
|
||||
email="zhangsan-application-approve@example.com",
|
||||
manager=manager,
|
||||
organization_unit=department,
|
||||
)
|
||||
db.add_all([manager, employee])
|
||||
db.add_all([department, manager, budget_manager, employee])
|
||||
db.flush()
|
||||
claim = ExpenseClaim(
|
||||
claim_no="APP-20260525-APPROVE",
|
||||
employee_id=employee.id,
|
||||
employee_name="张三",
|
||||
department_id=department.id,
|
||||
department_name="交付部",
|
||||
project_code="PRJ-A",
|
||||
expense_type="travel_application",
|
||||
@@ -3364,10 +3399,33 @@ def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
leader_approved = ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
manager_user,
|
||||
opinion="业务必要,同意申请。",
|
||||
)
|
||||
|
||||
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,
|
||||
current_user,
|
||||
opinion="业务必要,同意申请。",
|
||||
budget_user,
|
||||
opinion="预算额度可承接,同意。",
|
||||
)
|
||||
|
||||
assert approved is not None
|
||||
@@ -3400,14 +3458,15 @@ def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -
|
||||
and flag.get("event_type") == "expense_application_to_reimbursement_draft"
|
||||
and flag.get("application_claim_no") == "APP-20260525-APPROVE"
|
||||
and flag.get("leader_opinion") == "业务必要,同意申请。"
|
||||
and flag.get("budget_opinion") == "预算额度可承接,同意。"
|
||||
for flag in generated_draft.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("opinion") == "业务必要,同意申请。"
|
||||
and flag.get("previous_approval_stage") == "直属领导审批"
|
||||
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") == "预算管理者审批"
|
||||
and flag.get("next_status") == "approved"
|
||||
and flag.get("next_approval_stage") == "审批完成"
|
||||
and flag.get("generated_draft_claim_id") == generated_draft.id
|
||||
@@ -3473,7 +3532,7 @@ def test_direct_manager_return_application_claim_records_return_node_and_opinion
|
||||
claim.id,
|
||||
manager_user,
|
||||
reason="预算说明不够清楚,请补充项目必要性。",
|
||||
reason_codes=["application_business_need_unclear", "application_budget_basis_missing"],
|
||||
reason_codes=["application_budget_basis_missing"],
|
||||
)
|
||||
|
||||
assert returned is not None
|
||||
@@ -3493,11 +3552,8 @@ def test_direct_manager_return_application_claim_records_return_node_and_opinion
|
||||
assert return_event["leader_opinion"] == "预算说明不够清楚,请补充项目必要性。"
|
||||
assert return_event["return_stage"] == "直属领导审批"
|
||||
assert return_event["return_stage_key"] == "direct_manager"
|
||||
assert return_event["reason_codes"] == [
|
||||
"application_business_need_unclear",
|
||||
"application_budget_basis_missing",
|
||||
]
|
||||
assert return_event["risk_points"] == ["业务必要性说明不足", "预算测算依据不足"]
|
||||
assert return_event["reason_codes"] == ["application_budget_basis_missing"]
|
||||
assert return_event["risk_points"] == ["预算测算依据不足"]
|
||||
assert return_event["next_status"] == "returned"
|
||||
assert return_event["next_approval_stage"] == "待提交"
|
||||
|
||||
@@ -3515,20 +3571,43 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
|
||||
role_codes=["manager"],
|
||||
is_admin=False,
|
||||
)
|
||||
budget_user = CurrentUserContext(
|
||||
username="budget-p8-transfer@example.com",
|
||||
name="赵预算",
|
||||
role_codes=["budget_monitor"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
budget_role = _seed_budget_monitor_role(db)
|
||||
department = OrganizationUnit(
|
||||
id="dept-budget-transfer",
|
||||
unit_code="DELIVERY-BUDGET-TRANSFER",
|
||||
name="交付部",
|
||||
unit_type="department",
|
||||
)
|
||||
manager = Employee(
|
||||
employee_no="M-BUDGET-APP",
|
||||
name="李经理",
|
||||
email="manager-application-budget@example.com",
|
||||
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],
|
||||
)
|
||||
employee = Employee(
|
||||
employee_no="E-BUDGET-APP",
|
||||
name="张三",
|
||||
email="application-budget-owner-approve@example.com",
|
||||
manager=manager,
|
||||
organization_unit=department,
|
||||
)
|
||||
db.add_all([manager, employee])
|
||||
db.add_all([department, manager, budget_manager, employee])
|
||||
db.flush()
|
||||
_seed_budget_allocation(
|
||||
db,
|
||||
@@ -3560,10 +3639,19 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
|
||||
service = ExpenseClaimService(db)
|
||||
|
||||
service.submit_claim(claim.id, owner)
|
||||
approved = service.approve_claim(claim.id, manager_user, opinion="同意申请")
|
||||
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
|
||||
leader_approved = service.approve_claim(claim.id, manager_user, opinion="同意申请")
|
||||
reservation = db.query(BudgetReservation).one()
|
||||
|
||||
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)
|
||||
|
||||
assert approved is not None
|
||||
assert reservation.source_type == "claim"
|
||||
assert reservation.source_id == generated_draft.id
|
||||
@@ -3576,7 +3664,7 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
|
||||
)
|
||||
|
||||
|
||||
def test_direct_manager_approval_requires_leader_opinion() -> None:
|
||||
def test_direct_manager_approval_defaults_blank_opinion_to_agree() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="manager-application-required-opinion@example.com",
|
||||
name="李经理",
|
||||
@@ -3620,19 +3708,79 @@ def test_direct_manager_approval_requires_leader_opinion() -> None:
|
||||
db.commit()
|
||||
claim_id = claim.id
|
||||
|
||||
with pytest.raises(ValueError, match="领导审核意见不能为空"):
|
||||
ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
opinion=" ",
|
||||
)
|
||||
approved = ExpenseClaimService(db).approve_claim(
|
||||
claim_id,
|
||||
current_user,
|
||||
opinion=" ",
|
||||
)
|
||||
|
||||
db.refresh(claim)
|
||||
assert claim.status == "submitted"
|
||||
assert claim.approval_stage == "直属领导审批"
|
||||
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
|
||||
)
|
||||
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0
|
||||
|
||||
|
||||
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"])
|
||||
|
||||
|
||||
def test_finance_approve_reimbursement_consumes_budget_reservation() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance-budget-approve@example.com",
|
||||
@@ -4207,3 +4355,121 @@ def test_list_approval_claims_limits_finance_to_finance_stage_claims() -> None:
|
||||
claims = ExpenseClaimService(db).list_approval_claims(current_user)
|
||||
|
||||
assert [claim.claim_no for claim in claims] == ["EXP-FIN-LIST-202"]
|
||||
|
||||
|
||||
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(
|
||||
username="budget-p8-list@example.com",
|
||||
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],
|
||||
)
|
||||
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,
|
||||
)
|
||||
db.add_all([delivery_department, market_department, budget_manager, employee, market_employee])
|
||||
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"]
|
||||
claims_without_budget_role = ExpenseClaimService(db).list_approval_claims(p8_without_budget_role)
|
||||
assert [claim.claim_no for claim in claims_without_budget_role] == []
|
||||
|
||||
35
server/tests/test_knowledge_rag_runtime.py
Normal file
35
server/tests/test_knowledge_rag_runtime.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.knowledge_rag_runtime import (
|
||||
KnowledgeRagError,
|
||||
RuntimeModelConfig,
|
||||
_LightRagRuntime,
|
||||
)
|
||||
|
||||
|
||||
def test_embedding_probe_error_includes_model_context(monkeypatch) -> None:
|
||||
runtime = _LightRagRuntime.__new__(_LightRagRuntime)
|
||||
config = RuntimeModelConfig(
|
||||
slot="embedding",
|
||||
provider="GLM",
|
||||
model="Embedding-3",
|
||||
endpoint="https://open.bigmodel.cn/api/paas/v4/",
|
||||
api_key="token",
|
||||
capability="embedding",
|
||||
)
|
||||
|
||||
def fail_embeddings(*_args, **_kwargs):
|
||||
raise KnowledgeRagError("token expired")
|
||||
|
||||
monkeypatch.setattr(runtime, "_request_embeddings", fail_embeddings)
|
||||
|
||||
with pytest.raises(KnowledgeRagError) as exc_info:
|
||||
runtime._probe_embedding_dimension(config)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "slot=embedding" in message
|
||||
assert "provider=GLM" in message
|
||||
assert "model=Embedding-3" in message
|
||||
assert "token expired" in message
|
||||
@@ -238,7 +238,7 @@ def test_resolve_default_qdrant_url_falls_back_to_loopback(monkeypatch) -> None:
|
||||
assert knowledge_rag_module._resolve_default_qdrant_url() == "http://127.0.0.1:6333"
|
||||
|
||||
|
||||
def test_runtime_cache_is_isolated_by_thread(monkeypatch) -> None:
|
||||
def test_runtime_cache_uses_dedicated_instance_across_calling_threads(monkeypatch) -> None:
|
||||
knowledge_rag_module.shutdown_knowledge_rag_runtime()
|
||||
created_runtimes = []
|
||||
|
||||
@@ -270,8 +270,8 @@ def test_runtime_cache_is_isolated_by_thread(monkeypatch) -> None:
|
||||
thread.start()
|
||||
thread.join(timeout=5)
|
||||
|
||||
assert len(created_runtimes) == 2
|
||||
assert worker_runtimes[0] is not main_runtime
|
||||
assert len(created_runtimes) == 1
|
||||
assert worker_runtimes[0] is main_runtime
|
||||
|
||||
knowledge_rag_module.shutdown_knowledge_rag_runtime()
|
||||
assert all(runtime.finalized for runtime in created_runtimes)
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.db.base import Base
|
||||
from app.services.agent_runs import AgentRunService
|
||||
from app.services.knowledge import (
|
||||
KNOWLEDGE_INGEST_STATUS_FAILED,
|
||||
KNOWLEDGE_INGEST_STATUS_INGESTED,
|
||||
KNOWLEDGE_INGEST_STATUS_SYNCING,
|
||||
KnowledgeService,
|
||||
)
|
||||
@@ -88,3 +89,41 @@ def test_reconcile_document_ingest_status_keeps_failed_when_linked_run_failed(
|
||||
entry = next(item for item in index["documents"] if item["id"] == uploaded.id)
|
||||
assert changed is True
|
||||
assert entry["ingest_status"] == KNOWLEDGE_INGEST_STATUS_FAILED
|
||||
|
||||
|
||||
def test_reconcile_document_ingest_status_preserves_ingested_when_status_map_missing(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
service = KnowledgeService(storage_root=tmp_path)
|
||||
uploaded = service.upload_document(
|
||||
"报销制度",
|
||||
"demo.txt",
|
||||
b"hello",
|
||||
CurrentUserContext(
|
||||
username="admin",
|
||||
name="管理员",
|
||||
role_codes=["manager"],
|
||||
is_admin=True,
|
||||
),
|
||||
)
|
||||
service.set_document_ingest_statuses(
|
||||
[uploaded.id],
|
||||
KNOWLEDGE_INGEST_STATUS_INGESTED,
|
||||
agent_run_id="run_missing_status_map",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.knowledge_rag.KnowledgeRagService.get_document_status_map",
|
||||
lambda self, _document_ids: {},
|
||||
)
|
||||
|
||||
index = service._load_index()
|
||||
changed = service._reconcile_document_ingest_statuses(
|
||||
index,
|
||||
document_ids=[uploaded.id],
|
||||
preserve_syncing=False,
|
||||
)
|
||||
|
||||
entry = next(item for item in index["documents"] if item["id"] == uploaded.id)
|
||||
assert changed is False
|
||||
assert entry["ingest_status"] == KNOWLEDGE_INGEST_STATUS_INGESTED
|
||||
|
||||
65
server/tests/test_knowledge_sync.py
Normal file
65
server/tests/test_knowledge_sync.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
from app.services.knowledge import KNOWLEDGE_INGEST_STATUS_INGESTED, KnowledgeService
|
||||
from app.services.knowledge_sync import KnowledgeSyncDispatchService
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def test_force_sync_queues_ingested_documents_and_creates_hermes_run(tmp_path, monkeypatch) -> None:
|
||||
submitted: list[dict[str, object]] = []
|
||||
user = CurrentUserContext(
|
||||
username="admin",
|
||||
name="管理员",
|
||||
role_codes=["manager"],
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
knowledge_service = KnowledgeService(storage_root=tmp_path, db=db)
|
||||
uploaded = knowledge_service.upload_document("报销制度", "demo.txt", b"hello", user)
|
||||
document_id = uploaded.id
|
||||
knowledge_service.set_document_ingest_statuses(
|
||||
[document_id],
|
||||
KNOWLEDGE_INGEST_STATUS_INGESTED,
|
||||
agent_run_id="run_previous",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.knowledge_rag.KnowledgeRagService.get_document_status_map",
|
||||
lambda self, _document_ids: {},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.knowledge_sync.knowledge_index_task_manager.submit_sync",
|
||||
lambda **kwargs: submitted.append(kwargs),
|
||||
)
|
||||
|
||||
dispatch_service = KnowledgeSyncDispatchService(db)
|
||||
dispatch_service.knowledge_service = knowledge_service
|
||||
|
||||
result = dispatch_service.queue_sync(
|
||||
current_user=user,
|
||||
folder=None,
|
||||
document_ids=[document_id],
|
||||
force=True,
|
||||
changed_only=True,
|
||||
)
|
||||
|
||||
assert result.agent_run_id.startswith("run_")
|
||||
assert document_id in result.document_ids
|
||||
assert submitted
|
||||
assert submitted[0]["agent_run_id"] == result.agent_run_id
|
||||
@@ -14,6 +14,7 @@ from app.core import admin_secret
|
||||
from app.core import secret_box
|
||||
from app.core.secret_box import encrypt_secret
|
||||
from app.db.base import Base
|
||||
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
|
||||
from app.models.system_model_setting import SystemModelSetting
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.models.system_setting_secret import SystemSettingSecret
|
||||
@@ -27,9 +28,11 @@ def build_session(db_file: Path) -> Session:
|
||||
f"sqlite+pysqlite:///{db_file.as_posix()}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
SystemSetting.__table__.create(bind=engine)
|
||||
SystemSettingSecret.__table__.create(bind=engine)
|
||||
SystemModelSetting.__table__.create(bind=engine)
|
||||
SystemSetting.__table__.create(bind=engine)
|
||||
SystemSettingSecret.__table__.create(bind=engine)
|
||||
SystemModelSetting.__table__.create(bind=engine)
|
||||
HermesTaskConfig.__table__.create(bind=engine)
|
||||
HermesTaskExecutionLog.__table__.create(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
return session_factory()
|
||||
|
||||
@@ -45,9 +48,12 @@ def test_settings_service_persists_non_secret_and_secret_fields(monkeypatch) ->
|
||||
monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes"))
|
||||
|
||||
with build_session(temp_dir / "settings.db") as db:
|
||||
service = SettingsService(db)
|
||||
initial_snapshot = service.get_settings_snapshot()
|
||||
payload = initial_snapshot.model_dump()
|
||||
service = SettingsService(db)
|
||||
initial_snapshot = service.get_settings_snapshot()
|
||||
assert initial_snapshot.llmForm.mainModel == "codex-mini-latest"
|
||||
assert initial_snapshot.llmForm.mainEndpoint == "https://api.openai.com/v1"
|
||||
assert initial_snapshot.llmForm.mainApiKey == ""
|
||||
payload = initial_snapshot.model_dump()
|
||||
|
||||
payload["companyForm"]["companyName"] = "YGSOFT"
|
||||
payload["companyForm"]["displayName"] = "云广软件"
|
||||
|
||||
@@ -167,7 +167,9 @@ def test_user_agent_knowledge_prompt_enforces_knowledge_boundary() -> None:
|
||||
assert "不能用常识、外部知识或主观推断补齐缺失条件" in messages[0]["content"]
|
||||
assert "不能只依赖排在最前面的片段" in messages[0]["content"]
|
||||
assert "不能把第一列的数值直接套给后面的列名" in messages[0]["content"]
|
||||
assert "最终答复必须像助手在认真回答问题" in messages[0]["content"]
|
||||
assert "最终答复必须使用 Markdown" in messages[0]["content"]
|
||||
assert "## 结论" in messages[0]["content"]
|
||||
assert "如果不能,必须明确说当前知识库没有找到直接依据" in messages[0]["content"]
|
||||
assert "禁止使用“已命中”“答案整理阶段”“稍后重试”" in messages[0]["content"]
|
||||
assert "knowledge_evidence_blocks" in messages[0]["content"]
|
||||
assert '"knowledge_answer_evidence": []' in messages[1]["content"]
|
||||
@@ -435,6 +437,9 @@ def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None:
|
||||
)
|
||||
|
||||
assert answer.startswith("张三,您好。")
|
||||
assert "## 结论" in answer
|
||||
assert "## 依据" in answer
|
||||
assert "## 说明" in answer
|
||||
assert "我先根据当前制度依据给出可以确认的部分" in answer
|
||||
assert "已命中" not in answer
|
||||
assert "答案整理阶段本轮没有及时返回" not in answer
|
||||
@@ -477,8 +482,8 @@ def test_user_agent_knowledge_answer_generation_uses_fast_timeouts(monkeypatch)
|
||||
)
|
||||
|
||||
assert answer == "测试回答"
|
||||
assert captured["timeout_seconds"] == 5
|
||||
assert captured["slot_timeouts"] == {"main": 3, "backup": 5}
|
||||
assert captured["timeout_seconds"] == 30
|
||||
assert captured["slot_timeouts"] == {"main": 20, "backup": 30}
|
||||
assert captured["max_attempts"] == 1
|
||||
|
||||
|
||||
@@ -549,7 +554,7 @@ def test_user_agent_knowledge_terms_keep_accounting_subject_in_long_query() -> N
|
||||
assert "会计科目" in terms
|
||||
|
||||
|
||||
def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> None:
|
||||
def test_user_agent_knowledge_answer_uses_model_after_retrieval(monkeypatch) -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
@@ -560,11 +565,14 @@ def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> Non
|
||||
)
|
||||
)
|
||||
service = UserAgentService(db)
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_generate_answer_with_model",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("model should not be called")),
|
||||
)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_generate_answer(payload, **kwargs):
|
||||
captured["payload"] = payload
|
||||
captured.update(kwargs)
|
||||
return "## 结论\n\n员工应在费用发生后 30 日内提交报销申请。"
|
||||
|
||||
monkeypatch.setattr(service, "_generate_answer_with_model", fake_generate_answer)
|
||||
|
||||
response = service.respond(
|
||||
UserAgentRequest(
|
||||
@@ -593,10 +601,11 @@ def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> Non
|
||||
)
|
||||
)
|
||||
|
||||
assert response.answer.startswith("张三,您好。")
|
||||
assert "**结论**" in response.answer
|
||||
assert captured["payload"].ontology.scenario == "knowledge"
|
||||
assert "费用报销制度" in captured["fallback_answer"]
|
||||
assert "## 依据" in captured["fallback_answer"]
|
||||
assert response.answer.startswith("## 结论")
|
||||
assert "30 日内提交报销申请" in response.answer
|
||||
assert "## 依据" not in response.answer
|
||||
assert "答案整理阶段本轮没有及时返回" not in response.answer
|
||||
|
||||
|
||||
@@ -804,7 +813,8 @@ def test_user_agent_fast_knowledge_answer_renders_relevant_table_preview() -> No
|
||||
assert "| 项目 | 港澳台 | 其他地区 | 国外 |" in answer
|
||||
assert "| 餐补 | 75 | 55 | 140 |" in answer
|
||||
assert "餐补的标准为" in answer
|
||||
assert "## 依据" not in answer
|
||||
assert "## 结论" in answer
|
||||
assert "## 依据" in answer
|
||||
|
||||
|
||||
def test_user_agent_fast_knowledge_answer_uses_user_grade_for_table_row() -> None:
|
||||
@@ -906,8 +916,8 @@ def test_user_agent_fast_knowledge_answer_notes_missing_location_grounding() ->
|
||||
|
||||
assert answer is not None
|
||||
assert "没有直接写出“北京”对应的地区档位或映射关系" in answer
|
||||
assert "**说明**" in answer
|
||||
assert "## 依据" not in answer
|
||||
assert "## 说明" in answer
|
||||
assert "## 依据" in answer
|
||||
|
||||
|
||||
def test_user_agent_fast_knowledge_answer_expands_lead_in_list_items() -> None:
|
||||
@@ -952,12 +962,12 @@ def test_user_agent_fast_knowledge_answer_expands_lead_in_list_items() -> None:
|
||||
)
|
||||
|
||||
assert answer is not None
|
||||
assert "**结论**" in answer
|
||||
assert "## 结论" in answer
|
||||
assert "登机牌、高速道路通行记录" in answer
|
||||
assert "支付记录" in answer
|
||||
assert "出差审批邮件、短信、微信等" in answer
|
||||
assert "(3)" not in answer
|
||||
assert "## 依据" not in answer
|
||||
assert "## 依据" in answer
|
||||
|
||||
|
||||
def test_user_agent_model_prompt_supports_contextual_personalization() -> None:
|
||||
|
||||
Reference in New Issue
Block a user