- user_agent 拆分 application/locations/knowledge/response/review 四个子模块,接入申请位置语义与关联草稿分支 - steward planner/runtime/slot/plan_builder 决策链路重构,travel_reimbursement_calculator/orchestrator_expense_query 适配 - ocr/document_preview/document_intelligence/receipt_folder 复用预览与资产缓存,expense_claim_draft_flow/application_handoff 适配 - pyproject.toml 新增依赖,paddleocr bootstrap 脚本与 server_start.sh 调整 - 更新差旅/交通/通信等财务规则表,同步 document_intelligence/ocr/receipt_folder/user_agent 等测试
972 lines
37 KiB
Python
972 lines
37 KiB
Python
from __future__ import annotations
|
||
|
||
import shutil
|
||
import uuid
|
||
from io import BytesIO
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from openpyxl import Workbook, load_workbook
|
||
from sqlalchemy import create_engine, select
|
||
from sqlalchemy.orm import Session, sessionmaker
|
||
from sqlalchemy.pool import StaticPool
|
||
|
||
from app.api.deps import CurrentUserContext
|
||
from app.core.agent_enums import (
|
||
AgentAssetContentType,
|
||
AgentAssetDomain,
|
||
AgentAssetStatus,
|
||
AgentAssetType,
|
||
AgentName,
|
||
AgentReviewStatus,
|
||
AgentRunSource,
|
||
AgentRunStatus,
|
||
)
|
||
from app.core.config import SERVER_DIR
|
||
from app.db.base import Base
|
||
from app.models.agent_asset import AgentAsset
|
||
from app.models.employee import Employee
|
||
from app.schemas.agent_asset import (
|
||
AgentAssetCreate,
|
||
AgentAssetReviewCreate,
|
||
AgentAssetVersionCreate,
|
||
)
|
||
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
||
from app.services import agent_foundation as agent_foundation_module
|
||
from app.services.agent_asset_spreadsheet import (
|
||
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
|
||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||
COMPANY_PREAPPROVAL_RULE_CODE,
|
||
COMPANY_PREAPPROVAL_RULE_FILENAME,
|
||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE,
|
||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
|
||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE,
|
||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
|
||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE,
|
||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
|
||
FINANCE_RULES_LIBRARY,
|
||
)
|
||
from app.services.agent_foundation_constants import COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON
|
||
from app.services.agent_assets import AgentAssetService
|
||
from app.services.agent_runs import AgentRunService
|
||
from app.services.audit import AuditLogService
|
||
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService
|
||
from app.services.finance_rule_catalog import (
|
||
DEPRECATED_FINANCE_RULE_CODES,
|
||
)
|
||
from app.services.settings import OnlyOfficeRuntimeConfig
|
||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def isolate_rule_file_storage(tmp_path, monkeypatch) -> None:
|
||
temp_server_dir = tmp_path / "server"
|
||
temp_rules_root = temp_server_dir / "rules"
|
||
temp_finance_rules = temp_rules_root / FINANCE_RULES_LIBRARY
|
||
temp_finance_rules.mkdir(parents=True, exist_ok=True)
|
||
|
||
real_finance_rules = SERVER_DIR / "rules" / FINANCE_RULES_LIBRARY
|
||
for file_name in (
|
||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME,
|
||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME,
|
||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME,
|
||
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
|
||
COMPANY_PREAPPROVAL_RULE_FILENAME,
|
||
):
|
||
source_path = real_finance_rules / file_name
|
||
if source_path.exists():
|
||
shutil.copy2(source_path, temp_finance_rules / file_name)
|
||
|
||
monkeypatch.setattr(
|
||
"app.services.agent_asset_spreadsheet.SERVER_DIR",
|
||
temp_server_dir,
|
||
)
|
||
|
||
def init_manager(self, storage_root=None, rule_root=None) -> None:
|
||
self.storage_root = Path(storage_root or tmp_path / "storage").resolve()
|
||
self.asset_root = (self.storage_root / "agent_assets").resolve()
|
||
self.rule_root = Path(rule_root or temp_rules_root).resolve()
|
||
|
||
monkeypatch.setattr(
|
||
"app.services.agent_asset_spreadsheet.AgentAssetSpreadsheetManager.__init__",
|
||
init_manager,
|
||
)
|
||
|
||
|
||
def build_session() -> Session:
|
||
agent_foundation_module._foundation_ready_keys.clear()
|
||
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 build_workbook_bytes(rows: list[list[object]], *, sheet_name: str = "规则表") -> bytes:
|
||
workbook = Workbook()
|
||
sheet = workbook.active
|
||
sheet.title = sheet_name
|
||
for row in rows:
|
||
sheet.append(row)
|
||
buffer = BytesIO()
|
||
workbook.save(buffer)
|
||
return buffer.getvalue()
|
||
|
||
|
||
def build_multi_sheet_workbook_bytes(sheets: dict[str, list[list[object]]]) -> bytes:
|
||
workbook = Workbook()
|
||
default_sheet = workbook.active
|
||
workbook.remove(default_sheet)
|
||
for sheet_name, rows in sheets.items():
|
||
sheet = workbook.create_sheet(sheet_name)
|
||
for row in rows:
|
||
sheet.append(row)
|
||
buffer = BytesIO()
|
||
workbook.save(buffer)
|
||
return buffer.getvalue()
|
||
|
||
|
||
def test_agent_asset_service_seeds_assets_and_enforces_review_before_activation() -> None:
|
||
with build_session() as db:
|
||
service = AgentAssetService(db)
|
||
|
||
rules = service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||
assert len(rules) >= 3
|
||
assert any(
|
||
item.code == "rule.expense.travel_risk_control_standard"
|
||
and item.status == AgentAssetStatus.ACTIVE.value
|
||
for item in rules
|
||
)
|
||
assert all(
|
||
item.code
|
||
not in {
|
||
"rule.expense.duplicate_expense_check",
|
||
"rule.expense.travel_receipt_requirements",
|
||
"rule.ap.payment_dual_review",
|
||
}
|
||
for item in rules
|
||
)
|
||
|
||
pending_rule = next(item for item in rules if item.status == AgentAssetStatus.REVIEW.value)
|
||
|
||
with pytest.raises(PermissionError):
|
||
service.activate_asset(pending_rule.id, actor="pytest")
|
||
|
||
|
||
def test_agent_asset_service_seeds_all_foundation_asset_types() -> None:
|
||
with build_session() as db:
|
||
service = AgentAssetService(db)
|
||
|
||
assert len(service.list_assets(asset_type=AgentAssetType.RULE.value)) >= 3
|
||
assert len(service.list_assets(asset_type=AgentAssetType.SKILL.value)) >= 2
|
||
assert len(service.list_assets(asset_type=AgentAssetType.MCP.value)) >= 2
|
||
assert len(service.list_assets(asset_type=AgentAssetType.TASK.value)) >= 3
|
||
|
||
|
||
def test_agent_asset_service_supports_backend_pagination() -> None:
|
||
with build_session() as db:
|
||
service = AgentAssetService(db)
|
||
|
||
page = service.list_assets_page(
|
||
asset_type=AgentAssetType.RULE.value,
|
||
page=1,
|
||
page_size=2,
|
||
)
|
||
|
||
assert len(page.items) <= 2
|
||
assert page.total >= len(page.items)
|
||
assert page.page == 1
|
||
assert page.page_size == 2
|
||
|
||
|
||
def test_finance_rules_use_risk_rule_scenario_categories() -> None:
|
||
with build_session() as db:
|
||
service = AgentAssetService(db)
|
||
|
||
rules = service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||
travel_rule = next(item for item in rules if item.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
||
communication_rule = next(
|
||
item for item in rules if item.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE
|
||
)
|
||
preapproval_rule = next(item for item in rules if item.code == COMPANY_PREAPPROVAL_RULE_CODE)
|
||
travel_config = travel_rule.config_json or {}
|
||
communication_config = communication_rule.config_json or {}
|
||
preapproval_config = preapproval_rule.config_json or {}
|
||
|
||
assert travel_rule.scenario_json == ["差旅费"]
|
||
assert travel_config["scenario_category"] == "差旅费"
|
||
assert travel_config["ai_review_category"] == "差旅费"
|
||
assert communication_rule.scenario_json == ["通信费"]
|
||
assert communication_config["scenario_category"] == "通信费"
|
||
assert communication_config["ai_review_category"] == "通信费"
|
||
assert preapproval_rule.scenario_json == list(COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON)
|
||
assert travel_config["tag"] == "基础规则"
|
||
assert communication_config["tag"] == "基础规则"
|
||
assert preapproval_config["tag"] == "申请规则"
|
||
assert preapproval_config["finance_rule_code"] == "expense.preapproval.policy"
|
||
assert preapproval_config["finance_rule_sheet"] == "费用申请审批规则"
|
||
assert preapproval_config["expense_types"] == ["meal", "entertainment", "office", "all"]
|
||
assert preapproval_config["rule_document"]["file_name"] == COMPANY_PREAPPROVAL_RULE_FILENAME
|
||
|
||
grade_mapping_rule = next(
|
||
item for item in rules if item.code == COMPANY_TRAVEL_GRADE_MAPPING_RULE_CODE
|
||
)
|
||
season_mapping_rule = next(
|
||
item for item in rules if item.code == COMPANY_TRAVEL_SEASON_MAPPING_RULE_CODE
|
||
)
|
||
transport_estimate_rule = next(
|
||
item for item in rules if item.code == COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_CODE
|
||
)
|
||
assert grade_mapping_rule.config_json["tag"] == "基础规则"
|
||
assert grade_mapping_rule.config_json["rule_document"]["file_name"] == (
|
||
COMPANY_TRAVEL_GRADE_MAPPING_RULE_FILENAME
|
||
)
|
||
assert season_mapping_rule.config_json["tag"] == "基础规则"
|
||
assert season_mapping_rule.config_json["rule_document"]["file_name"] == (
|
||
COMPANY_TRAVEL_SEASON_MAPPING_RULE_FILENAME
|
||
)
|
||
assert transport_estimate_rule.config_json["tag"] == "基础规则"
|
||
assert transport_estimate_rule.config_json["rule_document"]["file_name"] == (
|
||
COMPANY_TRAVEL_TRANSPORT_ESTIMATE_RULE_FILENAME
|
||
)
|
||
|
||
|
||
def test_non_standard_finance_rule_spreadsheets_are_not_seeded() -> None:
|
||
with build_session() as db:
|
||
service = AgentAssetService(db)
|
||
service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||
|
||
for code in DEPRECATED_FINANCE_RULE_CODES:
|
||
asset = db.scalar(select(AgentAsset).where(AgentAsset.code == code))
|
||
assert asset is None or asset.config_json["tag"] == "废弃规则"
|
||
|
||
|
||
def test_demo_budget_risk_rules_are_excluded_from_risk_rule_center() -> None:
|
||
with build_session() as db:
|
||
service = AgentAssetService(db)
|
||
service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||
|
||
budget_rule = db.scalar(
|
||
select(AgentAsset).where(
|
||
AgentAsset.code == "risk.budget.available_balance_insufficient"
|
||
)
|
||
)
|
||
communication_rule = db.scalar(
|
||
select(AgentAsset).where(
|
||
AgentAsset.code == "risk.standard.communication_amount_over_policy"
|
||
)
|
||
)
|
||
|
||
assert budget_rule is None
|
||
|
||
assert communication_rule is not None
|
||
assert communication_rule.scenario_json == ["通信费"]
|
||
assert communication_rule.config_json["finance_rule_code"] == "expense.communication.policy"
|
||
assert communication_rule.config_json["finance_rule_sheet"] == "通信费报销规则"
|
||
assert communication_rule.config_json["expense_types"] == ["communication"]
|
||
assert communication_rule.config_json["budget_required"] is True
|
||
|
||
|
||
def test_existing_budget_risk_assets_are_hidden_from_rule_lists() -> None:
|
||
with build_session() as db:
|
||
db.add(
|
||
AgentAsset(
|
||
asset_type=AgentAssetType.RULE.value,
|
||
code="risk.budget.legacy.visible",
|
||
name="历史预算风险",
|
||
description="旧数据中已经存在的预算风险规则。",
|
||
domain=AgentAssetDomain.EXPENSE.value,
|
||
scenario_json=["全部"],
|
||
owner="pytest",
|
||
status=AgentAssetStatus.ACTIVE.value,
|
||
config_json={
|
||
"detail_mode": "json_risk",
|
||
"finance_rule_code": "budget.execution.policy",
|
||
"rule_document": {"file_name": "risk.budget.available_balance_insufficient.json"},
|
||
},
|
||
)
|
||
)
|
||
db.commit()
|
||
|
||
service = AgentAssetService(db)
|
||
listed_codes = {
|
||
item.code for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||
}
|
||
page = service.list_assets_page(
|
||
asset_type=AgentAssetType.RULE.value,
|
||
status=None,
|
||
domain=None,
|
||
keyword=None,
|
||
page=1,
|
||
page_size=100,
|
||
)
|
||
|
||
assert "risk.budget.legacy.visible" not in listed_codes
|
||
assert "risk.budget.legacy.visible" not in {item.code for item in page.items}
|
||
|
||
|
||
def test_agent_asset_service_can_activate_rule_after_review() -> None:
|
||
with build_session() as db:
|
||
service = AgentAssetService(db)
|
||
|
||
created = service.create_asset(
|
||
AgentAssetCreate(
|
||
asset_type=AgentAssetType.RULE,
|
||
code=f"rule.test.{uuid.uuid4().hex[:8]}",
|
||
name="测试规则",
|
||
description="用于测试审核和上线流程。",
|
||
domain=AgentAssetDomain.EXPENSE,
|
||
scenario_json=["expense", "risk_check"],
|
||
owner="pytest",
|
||
reviewer="reviewer",
|
||
status=AgentAssetStatus.DRAFT,
|
||
config_json={"enabled": False},
|
||
),
|
||
actor="pytest",
|
||
)
|
||
service.create_version(
|
||
created.id,
|
||
AgentAssetVersionCreate(
|
||
version="v1.0.0",
|
||
content="# 测试规则\n\n- 仅用于测试。",
|
||
content_type=AgentAssetContentType.MARKDOWN,
|
||
change_note="初始化版本",
|
||
created_by="pytest",
|
||
),
|
||
actor="pytest",
|
||
)
|
||
service.create_review(
|
||
created.id,
|
||
AgentAssetReviewCreate(
|
||
version="v1.0.0",
|
||
reviewer="reviewer",
|
||
review_status=AgentReviewStatus.APPROVED,
|
||
review_note="可以上线",
|
||
),
|
||
actor="reviewer",
|
||
)
|
||
|
||
activated = service.activate_asset(created.id, actor="reviewer")
|
||
|
||
assert activated.status == AgentAssetStatus.ACTIVE.value
|
||
assert activated.current_version == "v1.0.0"
|
||
assert activated.working_version == "v1.0.0"
|
||
assert activated.published_version == "v1.0.0"
|
||
assert activated.latest_review is not None
|
||
assert activated.latest_review.review_status == AgentReviewStatus.APPROVED.value
|
||
|
||
|
||
def test_rule_working_version_does_not_replace_published_version_until_activation() -> None:
|
||
with build_session() as db:
|
||
service = AgentAssetService(db)
|
||
rule = next(
|
||
item
|
||
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||
if item.code == "rule.expense.travel_risk_control_standard"
|
||
)
|
||
|
||
created = service.create_version(
|
||
rule.id,
|
||
AgentAssetVersionCreate(
|
||
version="v1.1.1",
|
||
content="# 差旅报销风险管控制度\n\n- 工作稿",
|
||
content_type=AgentAssetContentType.MARKDOWN,
|
||
change_note="新增工作稿",
|
||
created_by="finance_user",
|
||
),
|
||
actor="finance_user",
|
||
)
|
||
detail = service.get_asset(rule.id)
|
||
|
||
assert created.is_working is True
|
||
assert created.is_published is False
|
||
assert created.lifecycle_state == "draft"
|
||
assert detail is not None
|
||
assert detail.status == AgentAssetStatus.ACTIVE.value
|
||
assert detail.current_version == "v1.1.1"
|
||
assert detail.working_version == "v1.1.1"
|
||
assert detail.published_version == "v1.1.0"
|
||
assert detail.latest_review is None
|
||
|
||
|
||
def test_pending_review_can_name_new_working_version_before_submission() -> None:
|
||
with build_session() as db:
|
||
service = AgentAssetService(db)
|
||
rule = next(
|
||
item
|
||
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||
if item.code == "rule.expense.travel_risk_control_standard"
|
||
)
|
||
|
||
review = service.create_review(
|
||
rule.id,
|
||
AgentAssetReviewCreate(
|
||
version="v1.2.0",
|
||
reviewer="manager_user",
|
||
review_status=AgentReviewStatus.PENDING,
|
||
review_note="请审核",
|
||
),
|
||
actor="finance_user",
|
||
)
|
||
detail = service.get_asset(rule.id)
|
||
|
||
assert review.version == "v1.2.0"
|
||
assert detail is not None
|
||
assert detail.current_version == "v1.2.0"
|
||
assert detail.working_version == "v1.2.0"
|
||
assert detail.published_version == "v1.1.0"
|
||
assert detail.latest_review is not None
|
||
assert detail.latest_review.reviewer == "manager_user"
|
||
|
||
|
||
def test_expense_rule_runtime_uses_published_version_instead_of_working_version() -> None:
|
||
with build_session() as db:
|
||
service = AgentAssetService(db)
|
||
rule = next(
|
||
item
|
||
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||
if item.code == "rule.expense.travel_risk_control_standard"
|
||
)
|
||
|
||
service.create_version(
|
||
rule.id,
|
||
AgentAssetVersionCreate(
|
||
version="v1.1.1",
|
||
content=(
|
||
"# 工作稿\n\n"
|
||
'```expense-rule\n{"kind":"travel_policy","version":1}\n```'
|
||
),
|
||
content_type=AgentAssetContentType.MARKDOWN,
|
||
change_note="未上线草稿",
|
||
created_by="finance_user",
|
||
),
|
||
actor="finance_user",
|
||
)
|
||
|
||
catalog = ExpenseRuleRuntimeService(db).load_catalog()
|
||
|
||
assert catalog.travel_policy is not None
|
||
assert catalog.travel_policy.rule_version == "v1.1.0"
|
||
|
||
|
||
def test_restore_version_creates_new_working_copy_without_rewriting_published_version() -> None:
|
||
with build_session() as db:
|
||
service = AgentAssetService(db)
|
||
rule = next(
|
||
item
|
||
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||
if item.code == "rule.expense.travel_risk_control_standard"
|
||
)
|
||
|
||
restored = service.restore_version_as_working_copy(
|
||
rule.id,
|
||
"v1.0.0",
|
||
actor="manager_user",
|
||
)
|
||
|
||
assert restored.working_version == "v1.1.1"
|
||
assert restored.current_version == "v1.1.1"
|
||
assert restored.published_version == "v1.1.0"
|
||
assert restored.current_version_change_note == "基于历史版本 v1.0.0 恢复生成工作稿"
|
||
|
||
|
||
def test_spreadsheet_upload_records_sheet_and_cell_changes_without_versions() -> None:
|
||
with build_session() as db:
|
||
service = AgentAssetService(db)
|
||
rule = next(
|
||
item
|
||
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||
if item.code == "rule.expense.company_travel_expense_reimbursement"
|
||
)
|
||
|
||
service.upload_rule_spreadsheet(
|
||
rule.id,
|
||
filename="公司差旅费报销规则.xlsx",
|
||
content=build_workbook_bytes([["城市", "住宿"], ["北京", 500]]),
|
||
actor="finance_user",
|
||
)
|
||
service.upload_rule_spreadsheet(
|
||
rule.id,
|
||
filename="公司差旅费报销规则.xlsx",
|
||
content=build_workbook_bytes([["城市", "住宿"], ["北京", 550], ["武汉", 450]]),
|
||
actor="finance_user",
|
||
)
|
||
|
||
records = service.list_spreadsheet_change_records(rule.id)
|
||
latest = records[0]
|
||
|
||
assert latest.changed_sheet_count == 1
|
||
assert latest.changed_cell_count == 3
|
||
assert any(
|
||
item.cell == "B2" and item.change_type == "modified"
|
||
for item in latest.cell_changes
|
||
)
|
||
assert any(
|
||
item.cell == "A3" and item.change_type == "added"
|
||
for item in latest.cell_changes
|
||
)
|
||
assert not hasattr(latest, "version")
|
||
|
||
|
||
def test_spreadsheet_content_reads_current_rule_file_without_version_snapshot() -> None:
|
||
with build_session() as db:
|
||
service = AgentAssetService(db)
|
||
rule = next(
|
||
item
|
||
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||
if item.code == "rule.expense.company_travel_expense_reimbursement"
|
||
)
|
||
|
||
service.upload_rule_spreadsheet(
|
||
rule.id,
|
||
filename="公司差旅费报销规则.xlsx",
|
||
content=build_workbook_bytes([["城市", "住宿"], ["北京", 500]]),
|
||
actor="finance_user",
|
||
)
|
||
detail = service.get_asset(rule.id)
|
||
assert detail is not None
|
||
|
||
current_asset = service.repository.get(rule.id)
|
||
assert current_asset is not None
|
||
live_storage_key = str((current_asset.config_json or {})["rule_document"]["storage_key"])
|
||
assert live_storage_key.startswith(f"rules/{FINANCE_RULES_LIBRARY}/")
|
||
assert "agent_assets" not in live_storage_key
|
||
live_path = service.spreadsheet_manager.resolve_storage_path(live_storage_key)
|
||
assert not service.spreadsheet_manager.asset_root.exists()
|
||
|
||
current_path, _, _ = service.get_rule_spreadsheet_content(rule.id)
|
||
|
||
assert current_path == live_path
|
||
assert ".versions" not in current_path.parts
|
||
workbook = load_workbook(current_path, data_only=False)
|
||
assert workbook.active["B2"].value == 500
|
||
|
||
|
||
def test_spreadsheet_change_records_return_recent_edit_details() -> None:
|
||
with build_session() as db:
|
||
service = AgentAssetService(db)
|
||
rule = next(
|
||
item
|
||
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||
if item.code == "rule.expense.company_travel_expense_reimbursement"
|
||
)
|
||
service.audit_service.log_action(
|
||
actor="manager_user",
|
||
action="edit_rule_spreadsheet",
|
||
resource_type=rule.asset_type,
|
||
resource_id=rule.id,
|
||
after_json={
|
||
"summary": "在线编辑:共 1 处改动。",
|
||
"changed_sheet_count": 1,
|
||
"changed_cell_count": 1,
|
||
"sheet_changes": [],
|
||
"cell_changes": [
|
||
{
|
||
"sheet_name": "规则表",
|
||
"cell": "B2",
|
||
"change_type": "modified",
|
||
"before_value": 500,
|
||
"after_value": 550,
|
||
}
|
||
],
|
||
},
|
||
)
|
||
|
||
records = service.list_spreadsheet_change_records(rule.id)
|
||
|
||
assert len(records) == 1
|
||
assert records[0].actor == "manager_user"
|
||
assert records[0].changed_cell_count == 1
|
||
assert records[0].cell_changes[0].cell == "B2"
|
||
|
||
|
||
def test_spreadsheet_change_records_include_all_modified_sheets() -> None:
|
||
with build_session() as db:
|
||
service = AgentAssetService(db)
|
||
rule = next(
|
||
item
|
||
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||
if item.code == "rule.expense.company_travel_expense_reimbursement"
|
||
)
|
||
|
||
service.upload_rule_spreadsheet(
|
||
rule.id,
|
||
filename="公司差旅费报销规则.xlsx",
|
||
content=build_multi_sheet_workbook_bytes(
|
||
{
|
||
"差旅标准": [["城市", "住宿"], ["北京", 500]],
|
||
"填表说明": [["字段", "说明"], ["住宿", "按城市标准"]],
|
||
}
|
||
),
|
||
actor="finance_user",
|
||
)
|
||
detail = service.get_asset(rule.id)
|
||
assert detail is not None
|
||
|
||
service.upload_rule_spreadsheet(
|
||
rule.id,
|
||
filename="公司差旅费报销规则.xlsx",
|
||
content=build_multi_sheet_workbook_bytes(
|
||
{
|
||
"差旅标准": [["城市", "住宿"], ["北京", 550]],
|
||
"填表说明": [["字段", "说明"], ["住宿", "按城市等级标准"]],
|
||
}
|
||
),
|
||
actor="finance_user",
|
||
)
|
||
|
||
records = service.list_spreadsheet_change_records(rule.id)
|
||
latest = records[0]
|
||
changed_sheets = {item.sheet_name for item in latest.sheet_changes}
|
||
changed_cell_sheets = {item.sheet_name for item in latest.cell_changes}
|
||
|
||
assert not hasattr(latest, "version")
|
||
assert latest.changed_sheet_count == 2
|
||
assert {"差旅标准", "填表说明"}.issubset(changed_sheets)
|
||
assert {"差旅标准", "填表说明"}.issubset(changed_cell_sheets)
|
||
assert "差旅标准" in latest.summary
|
||
assert "填表说明" in latest.summary
|
||
|
||
|
||
def test_editable_spreadsheet_onlyoffice_config_enables_forcesave(monkeypatch) -> None:
|
||
with build_session() as db:
|
||
monkeypatch.setattr(
|
||
"app.services.agent_asset_onlyoffice.resolve_onlyoffice_settings",
|
||
lambda: OnlyOfficeRuntimeConfig(
|
||
enabled=True,
|
||
public_url="http://onlyoffice.example.com",
|
||
backend_url="http://backend.example.com",
|
||
jwt_secret="secret",
|
||
),
|
||
)
|
||
|
||
service = AgentAssetService(db)
|
||
rule = next(
|
||
item
|
||
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||
if item.code == "rule.expense.company_travel_expense_reimbursement"
|
||
)
|
||
|
||
config = service.build_rule_spreadsheet_onlyoffice_config(
|
||
rule.id,
|
||
CurrentUserContext(
|
||
username="finance_user",
|
||
name="财务人员",
|
||
role_codes=["finance"],
|
||
is_admin=False,
|
||
),
|
||
)
|
||
|
||
customization = config.config["editorConfig"]["customization"]
|
||
assert config.config["editorConfig"]["mode"] == "edit"
|
||
assert customization["forcesave"] is True
|
||
assert "version=" not in config.config["document"]["url"]
|
||
assert "version=" not in config.config["editorConfig"]["callbackUrl"]
|
||
|
||
|
||
def test_version_timeline_contains_created_review_and_publish_events() -> None:
|
||
with build_session() as db:
|
||
service = AgentAssetService(db)
|
||
rule = next(
|
||
item
|
||
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||
if item.code == "rule.expense.travel_risk_control_standard"
|
||
)
|
||
service.create_version(
|
||
rule.id,
|
||
AgentAssetVersionCreate(
|
||
version="v1.1.1",
|
||
content="# 工作稿",
|
||
content_type=AgentAssetContentType.MARKDOWN,
|
||
change_note="补充口径",
|
||
created_by="finance_user",
|
||
),
|
||
actor="finance_user",
|
||
)
|
||
service.create_review(
|
||
rule.id,
|
||
AgentAssetReviewCreate(
|
||
version="v1.1.1",
|
||
reviewer="finance_user",
|
||
review_status=AgentReviewStatus.PENDING,
|
||
review_note="请审核",
|
||
),
|
||
actor="finance_user",
|
||
)
|
||
service.create_review(
|
||
rule.id,
|
||
AgentAssetReviewCreate(
|
||
version="v1.1.1",
|
||
reviewer="manager_user",
|
||
review_status=AgentReviewStatus.APPROVED,
|
||
review_note="可以上线",
|
||
),
|
||
actor="manager_user",
|
||
)
|
||
service.activate_asset(rule.id, actor="manager_user")
|
||
|
||
timeline = service.list_version_timeline(rule.id)
|
||
event_types = [item.event_type for item in timeline if item.version == "v1.1.1"]
|
||
|
||
assert "created" in event_types
|
||
assert "submitted" in event_types
|
||
assert "approved" in event_types
|
||
assert "published" in event_types
|
||
|
||
|
||
def test_agent_asset_service_returns_recent_versions_for_rule_detail() -> None:
|
||
with build_session() as db:
|
||
service = AgentAssetService(db)
|
||
|
||
rule = next(
|
||
item
|
||
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||
if item.code == "rule.expense.attachment_submission_requirements"
|
||
)
|
||
detail = service.get_asset(rule.id)
|
||
|
||
assert detail is not None
|
||
assert detail.current_version == "v1.0.0"
|
||
assert detail.current_version_content_type == AgentAssetContentType.MARKDOWN.value
|
||
assert isinstance(detail.current_version_content, str)
|
||
assert len(detail.recent_versions) >= 2
|
||
assert any(item.is_current for item in detail.recent_versions)
|
||
assert {item.version for item in detail.recent_versions} >= {"v0.9.0", "v1.0.0"}
|
||
assert detail.config_json["rule_template_key"] == "attachment_requirement_v1"
|
||
assert "附件或单据不完整" in str(detail.current_version_content)
|
||
|
||
|
||
def test_agent_asset_service_returns_travel_policy_rule_detail() -> None:
|
||
with build_session() as db:
|
||
service = AgentAssetService(db)
|
||
|
||
rule = next(
|
||
item
|
||
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||
if item.code == "rule.expense.travel_risk_control_standard"
|
||
)
|
||
detail = service.get_asset(rule.id)
|
||
|
||
assert detail is not None
|
||
assert detail.status == AgentAssetStatus.ACTIVE.value
|
||
assert detail.current_version == "v1.1.0"
|
||
assert detail.latest_review is not None
|
||
assert detail.latest_review.review_status == AgentReviewStatus.APPROVED.value
|
||
assert "行程闭环" in str(detail.current_version_content)
|
||
assert "住宿标准、飞机舱位和火车席别" in str(detail.current_version_content)
|
||
|
||
|
||
def test_expense_rule_runtime_reads_amount_standards_from_travel_spreadsheet() -> None:
|
||
with build_session() as db:
|
||
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
|
||
travel_spreadsheet_rule = db.scalar(
|
||
select(AgentAsset).where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
||
)
|
||
assert travel_spreadsheet_rule is not None
|
||
travel_spreadsheet_rule.status = AgentAssetStatus.REVIEW.value
|
||
db.commit()
|
||
|
||
catalog = ExpenseRuleRuntimeService(db).load_catalog()
|
||
|
||
assert catalog.travel_policy is not None
|
||
assert catalog.travel_policy.standard_rule_code == COMPANY_TRAVEL_EXPENSE_RULE_CODE
|
||
assert catalog.travel_policy.standard_rule_name == "差旅住宿报销标准"
|
||
assert catalog.travel_policy.hotel_city_limits["北京"]["P0"] == 450
|
||
assert catalog.travel_policy.hotel_city_limits["北京"]["P4"] == 450
|
||
assert catalog.travel_policy.hotel_city_limits["北京"]["P8"] == 500
|
||
assert catalog.travel_policy.allowance_limits["meal"]["直辖市/特区"] == 65
|
||
assert catalog.travel_policy.allowance_limits["meal"]["其他地区"] == 55
|
||
assert catalog.travel_policy.allowance_limits["total"]["其他地区"] == 90
|
||
assert catalog.travel_policy.transport_limits["P7"]["flight"] == 1
|
||
assert catalog.travel_policy.transport_limits["P8"]["train"] == 2
|
||
|
||
|
||
def test_travel_reimbursement_calculator_uses_finance_spreadsheet_amounts() -> None:
|
||
with build_session() as db:
|
||
db.add(
|
||
Employee(
|
||
employee_no="E9001",
|
||
name="测试员工",
|
||
email="traveler@example.com",
|
||
position="产品经理",
|
||
grade="P4",
|
||
)
|
||
)
|
||
db.commit()
|
||
|
||
result = TravelReimbursementCalculatorService(db).calculate(
|
||
TravelReimbursementCalculatorRequest(days=3, location="北京市朝阳区"),
|
||
CurrentUserContext(
|
||
username="traveler@example.com",
|
||
name="测试员工",
|
||
role_codes=[],
|
||
is_admin=False,
|
||
),
|
||
)
|
||
|
||
assert result.rule_name == "差旅住宿报销标准"
|
||
assert result.grade == "P4"
|
||
assert result.grade_band == "P4"
|
||
assert result.matched_city == "北京"
|
||
assert result.hotel_rate == 450
|
||
assert result.hotel_amount == 1350
|
||
assert result.allowance_region == "直辖市/特区"
|
||
assert result.total_allowance_rate == 100
|
||
assert result.allowance_amount == 300
|
||
assert result.transport_estimated_amount == 1040
|
||
assert result.transport_estimate_source == "basic_rule_transport_estimate"
|
||
assert result.total_amount == 2690
|
||
assert (
|
||
"交通 1040.00 + 住宿 450.00 × 3 天 + 补贴 100.00 × 3 天 = 2690.00"
|
||
== result.formula_text
|
||
)
|
||
assert "申请预算占用参考总金额为 2690.00 元" in result.summary_text
|
||
|
||
|
||
def test_travel_reimbursement_calculator_uses_other_region_for_known_unlisted_location() -> None:
|
||
with build_session() as db:
|
||
db.add(
|
||
Employee(
|
||
employee_no="E9002",
|
||
name="其他地区员工",
|
||
email="other-region@example.com",
|
||
position="产品经理",
|
||
grade="P4",
|
||
)
|
||
)
|
||
db.commit()
|
||
|
||
result = TravelReimbursementCalculatorService(db).calculate(
|
||
TravelReimbursementCalculatorRequest(days=2, location="吉林延边"),
|
||
CurrentUserContext(
|
||
username="other-region@example.com",
|
||
name="其他地区员工",
|
||
role_codes=[],
|
||
is_admin=False,
|
||
),
|
||
)
|
||
|
||
assert result.matched_city == "延边(其他地区)"
|
||
assert result.city_tier == "tier_3"
|
||
assert result.hotel_rate == 380
|
||
assert result.hotel_amount == 760
|
||
assert result.allowance_region == "其他地区"
|
||
assert result.total_allowance_rate == 90
|
||
assert result.allowance_amount == 180
|
||
assert result.transport_estimated_amount == 720
|
||
assert result.total_amount == 1660
|
||
|
||
|
||
def test_travel_reimbursement_calculator_rejects_unrecognized_location() -> None:
|
||
with build_session() as db:
|
||
db.add(
|
||
Employee(
|
||
employee_no="E9003",
|
||
name="无效地点员工",
|
||
email="invalid-location@example.com",
|
||
position="产品经理",
|
||
grade="P4",
|
||
)
|
||
)
|
||
db.commit()
|
||
|
||
with pytest.raises(ValueError, match="未识别为有效出差地区"):
|
||
TravelReimbursementCalculatorService(db).calculate(
|
||
TravelReimbursementCalculatorRequest(days=2, location="背景"),
|
||
CurrentUserContext(
|
||
username="invalid-location@example.com",
|
||
name="无效地点员工",
|
||
role_codes=[],
|
||
is_admin=False,
|
||
),
|
||
)
|
||
|
||
|
||
def test_travel_reimbursement_calculator_normalizes_location_mixed_with_business_content() -> None:
|
||
with build_session() as db:
|
||
db.add(
|
||
Employee(
|
||
employee_no="E9004",
|
||
name="混合地点员工",
|
||
email="mixed-location@example.com",
|
||
position="产品经理",
|
||
grade="P4",
|
||
)
|
||
)
|
||
db.commit()
|
||
|
||
result = TravelReimbursementCalculatorService(db).calculate(
|
||
TravelReimbursementCalculatorRequest(days=4, location="上海辅助国网仿生产服务器"),
|
||
CurrentUserContext(
|
||
username="mixed-location@example.com",
|
||
name="混合地点员工",
|
||
role_codes=[],
|
||
is_admin=False,
|
||
),
|
||
)
|
||
|
||
assert result.location == "上海市"
|
||
assert result.matched_city == "上海"
|
||
assert result.hotel_amount > 0
|
||
|
||
|
||
def test_agent_run_service_lists_seeded_trace_data() -> None:
|
||
with build_session() as db:
|
||
service = AgentRunService(db)
|
||
|
||
runs = service.list_runs()
|
||
|
||
assert len(runs) >= 3
|
||
assert any(item.tool_calls for item in runs)
|
||
assert any(item.semantic_parse is not None for item in runs)
|
||
|
||
|
||
def test_agent_run_service_creates_run_and_persists_error_message() -> None:
|
||
with build_session() as db:
|
||
service = AgentRunService(db)
|
||
|
||
created = service.create_run(
|
||
agent=AgentName.ORCHESTRATOR.value,
|
||
source=AgentRunSource.SYSTEM_EVENT.value,
|
||
status=AgentRunStatus.FAILED.value,
|
||
error_message="simulated failure",
|
||
result_summary="failed to route request",
|
||
)
|
||
fetched = service.get_run(created.run_id)
|
||
|
||
assert fetched is not None
|
||
assert fetched.run_id.startswith("run_")
|
||
assert fetched.status == AgentRunStatus.FAILED.value
|
||
assert fetched.error_message == "simulated failure"
|
||
assert fetched.result_summary == "failed to route request"
|
||
|
||
|
||
def test_agent_asset_creation_writes_audit_log() -> None:
|
||
with build_session() as db:
|
||
service = AgentAssetService(db)
|
||
|
||
created = service.create_asset(
|
||
AgentAssetCreate(
|
||
asset_type=AgentAssetType.SKILL,
|
||
code=f"skill.test.{uuid.uuid4().hex[:8]}",
|
||
name="测试技能",
|
||
description="用于测试审计日志写入。",
|
||
domain=AgentAssetDomain.KNOWLEDGE,
|
||
scenario_json=["knowledge", "query"],
|
||
owner="pytest",
|
||
reviewer="reviewer",
|
||
status=AgentAssetStatus.DRAFT,
|
||
config_json={"enabled": True},
|
||
),
|
||
actor="pytest",
|
||
)
|
||
logs = AuditLogService(db).list_logs(resource_id=created.id)
|
||
|
||
assert any(item.action == "create_agent_asset" for item in logs)
|