feat(platform): close AI expense value loop

Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
caoxiaozhu
2026-07-17 14:14:08 +08:00
parent 242d68c36f
commit 787bc3a481
507 changed files with 82072 additions and 6344 deletions

View File

@@ -4,6 +4,7 @@ import shutil
import uuid
from io import BytesIO
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
import pytest
from openpyxl import Workbook, load_workbook
@@ -26,6 +27,7 @@ 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.models.tenant import Tenant
from app.schemas.agent_asset import (
AgentAssetCreate,
AgentAssetReviewCreate,
@@ -33,23 +35,28 @@ from app.schemas.agent_asset import (
)
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
from app.services import agent_foundation as agent_foundation_module
from app.services.agent_asset_onlyoffice_security import (
AGENT_ASSET_ONLYOFFICE_CALLBACK_SCOPE,
AgentAssetOnlyOfficeReplayError,
AgentAssetOnlyOfficeSecurityError,
)
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_GRADE_MAPPING_RULE_CODE,
COMPANY_TRAVEL_GRADE_MAPPING_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_foundation_constants import COMPANY_PREAPPROVAL_RULE_SCENARIO_JSON
from app.services.agent_runs import AgentRunService
from app.services.audit import AuditLogService
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService
@@ -194,7 +201,9 @@ def test_finance_rules_use_risk_rule_scenario_categories() -> None:
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)
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 {}
@@ -288,7 +297,9 @@ def test_existing_budget_risk_assets_are_hidden_from_rule_lists() -> None:
config_json={
"detail_mode": "json_risk",
"finance_rule_code": "budget.execution.policy",
"rule_document": {"file_name": "risk.budget.available_balance_insufficient.json"},
"rule_document": {
"file_name": "risk.budget.available_balance_insufficient.json"
},
},
)
)
@@ -422,7 +433,7 @@ def test_pending_review_can_name_new_working_version_before_submission() -> None
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"
assert detail.latest_review.reviewer == "finance_user"
def test_expense_rule_runtime_uses_published_version_instead_of_working_version() -> None:
@@ -438,10 +449,7 @@ def test_expense_rule_runtime_uses_published_version_instead_of_working_version(
rule.id,
AgentAssetVersionCreate(
version="v1.1.1",
content=(
"# 工作稿\n\n"
'```expense-rule\n{"kind":"travel_policy","version":1}\n```'
),
content=('# 工作稿\n\n```expense-rule\n{"kind":"travel_policy","version":1}\n```'),
content_type=AgentAssetContentType.MARKDOWN,
change_note="未上线草稿",
created_by="finance_user",
@@ -488,7 +496,7 @@ def test_spreadsheet_upload_records_sheet_and_cell_changes_without_versions() ->
service.upload_rule_spreadsheet(
rule.id,
filename="公司差旅费报销规则.xlsx",
content=build_workbook_bytes([["城市", "住宿"], ["北京", 500]]),
content=build_workbook_bytes([["城市", "住宿"], ["北京", 500]]),
actor="finance_user",
)
service.upload_rule_spreadsheet(
@@ -504,12 +512,10 @@ def test_spreadsheet_upload_records_sheet_and_cell_changes_without_versions() ->
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
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
item.cell == "A3" and item.change_type == "added" for item in latest.cell_changes
)
assert not hasattr(latest, "version")
@@ -634,7 +640,7 @@ def test_spreadsheet_change_records_include_all_modified_sheets() -> None:
assert "填表说明" in latest.summary
def test_editable_spreadsheet_onlyoffice_config_enables_forcesave(monkeypatch) -> None:
def test_platform_spreadsheet_onlyoffice_requires_platform_admin_to_edit(monkeypatch) -> None:
with build_session() as db:
monkeypatch.setattr(
"app.services.agent_asset_onlyoffice.resolve_onlyoffice_settings",
@@ -642,7 +648,7 @@ def test_editable_spreadsheet_onlyoffice_config_enables_forcesave(monkeypatch) -
enabled=True,
public_url="http://onlyoffice.example.com",
backend_url="http://backend.example.com",
jwt_secret="secret",
jwt_secret="onlyoffice-test-secret-at-least-32-bytes",
),
)
@@ -653,21 +659,83 @@ def test_editable_spreadsheet_onlyoffice_config_enables_forcesave(monkeypatch) -
if item.code == "rule.expense.company_travel_expense_reimbursement"
)
config = service.build_rule_spreadsheet_onlyoffice_config(
finance_config = service.build_rule_spreadsheet_onlyoffice_config(
rule.id,
CurrentUserContext(
username="finance_user",
name="财务人员",
role_codes=["finance"],
is_admin=False,
tenant_id="default",
),
)
admin_config = service.build_rule_spreadsheet_onlyoffice_config(
rule.id,
CurrentUserContext(
username="platform_admin",
name="平台管理员",
role_codes=["manager"],
is_admin=True,
tenant_id="default",
),
)
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"]
assert finance_config.config["editorConfig"]["mode"] == "view"
assert finance_config.config["editorConfig"]["customization"]["forcesave"] is False
assert admin_config.config["editorConfig"]["mode"] == "edit"
assert admin_config.config["editorConfig"]["customization"]["forcesave"] is True
assert "version=" not in admin_config.config["document"]["url"]
assert "version=" not in admin_config.config["editorConfig"]["callbackUrl"]
finance_callback_token = parse_qs(
urlsplit(finance_config.config["editorConfig"]["callbackUrl"]).query
)["access_token"][0]
admin_callback_token = parse_qs(
urlsplit(admin_config.config["editorConfig"]["callbackUrl"]).query
)["access_token"][0]
finance_session = service.validate_rule_spreadsheet_access_token(
rule.id,
finance_callback_token,
expected_scope=AGENT_ASSET_ONLYOFFICE_CALLBACK_SCOPE,
)
admin_session = service.validate_rule_spreadsheet_access_token(
rule.id,
admin_callback_token,
expected_scope=AGENT_ASSET_ONLYOFFICE_CALLBACK_SCOPE,
)
assert finance_session.writable is False
assert admin_session.writable is True
assert admin_session.tenant_id == "platform"
assert admin_session.resource_scope == "platform"
assert admin_session.actor == "username:platform_admin"
with pytest.raises(AgentAssetOnlyOfficeSecurityError, match="不匹配"):
service.validate_rule_spreadsheet_access_token(
"different-asset",
admin_callback_token,
expected_scope=AGENT_ASSET_ONLYOFFICE_CALLBACK_SCOPE,
)
with pytest.raises(AgentAssetOnlyOfficeSecurityError, match="只读"):
service._onlyoffice_session_service().claim_callback(
asset_id=rule.id,
token=finance_callback_token,
payload_document_key=finance_session.document_key,
)
claimed = service._onlyoffice_session_service().claim_callback(
asset_id=rule.id,
token=admin_callback_token,
payload_document_key=admin_session.document_key,
)
with pytest.raises(AgentAssetOnlyOfficeReplayError, match="已被使用"):
service._onlyoffice_session_service().claim_callback(
asset_id=rule.id,
token=admin_callback_token,
payload_document_key=admin_session.document_key,
)
service._onlyoffice_session_service().finish_callback(
claimed.jti,
succeeded=True,
)
def test_version_timeline_contains_created_review_and_publish_events() -> None:
@@ -789,6 +857,9 @@ def test_expense_rule_runtime_reads_amount_standards_from_travel_spreadsheet() -
def test_travel_reimbursement_calculator_uses_finance_spreadsheet_amounts() -> None:
with build_session() as db:
# 生产环境在应用启动阶段初始化规则资产;计算阶段必须保持只读,
# 测试需显式模拟该启动前置条件,不能依赖计算器隐式提交事务。
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
db.add(
Employee(
employee_no="E9001",
@@ -803,6 +874,7 @@ def test_travel_reimbursement_calculator_uses_finance_spreadsheet_amounts() -> N
result = TravelReimbursementCalculatorService(db).calculate(
TravelReimbursementCalculatorRequest(days=3, location="北京市朝阳区"),
CurrentUserContext(
tenant_id="default",
username="traveler@example.com",
name="测试员工",
role_codes=[],
@@ -829,8 +901,58 @@ def test_travel_reimbursement_calculator_uses_finance_spreadsheet_amounts() -> N
assert "申请预算占用参考总金额为 2690.00 元" in result.summary_text
def test_travel_reimbursement_calculator_resolves_employee_inside_authenticated_tenant() -> None:
with build_session() as db:
db.add(
Tenant(
tenant_id="tenant-travel-other",
tenant_code="tenant-travel-other",
name="差旅隔离对照企业",
)
)
other_employee = Employee(
id="travel-employee-other",
tenant_id="tenant-travel-other",
employee_no="TRAVEL-SAME-001",
name="同名差旅员工",
email="same-traveler@example.com",
position="其他企业员工",
grade="P8",
location="北京",
)
current_employee = Employee(
id="travel-employee-current",
tenant_id="default",
employee_no="TRAVEL-SAME-001",
name="同名差旅员工",
email="same-traveler@example.com",
position="当前企业员工",
grade="P4",
location="上海",
)
db.add_all([other_employee, current_employee])
db.commit()
resolved = TravelReimbursementCalculatorService(db)._resolve_current_employee(
CurrentUserContext(
tenant_id="default",
employee_id=current_employee.id,
employee_no=current_employee.employee_no,
username=current_employee.email,
name=current_employee.name,
role_codes=[],
is_admin=False,
)
)
assert resolved is not None
assert resolved.id == current_employee.id
assert resolved.tenant_id == "default"
def test_travel_reimbursement_calculator_uses_other_region_for_known_unlisted_location() -> None:
with build_session() as db:
AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value)
db.add(
Employee(
employee_no="E9002",
@@ -845,6 +967,7 @@ def test_travel_reimbursement_calculator_uses_other_region_for_known_unlisted_lo
result = TravelReimbursementCalculatorService(db).calculate(
TravelReimbursementCalculatorRequest(days=2, location="吉林延边"),
CurrentUserContext(
tenant_id="default",
username="other-region@example.com",
name="其他地区员工",
role_codes=[],
@@ -880,6 +1003,7 @@ def test_travel_reimbursement_calculator_rejects_unrecognized_location() -> None
TravelReimbursementCalculatorService(db).calculate(
TravelReimbursementCalculatorRequest(days=2, location="背景"),
CurrentUserContext(
tenant_id="default",
username="invalid-location@example.com",
name="无效地点员工",
role_codes=[],
@@ -904,6 +1028,7 @@ def test_travel_reimbursement_calculator_normalizes_location_mixed_with_business
result = TravelReimbursementCalculatorService(db).calculate(
TravelReimbursementCalculatorRequest(days=4, location="上海辅助国网仿生产服务器"),
CurrentUserContext(
tenant_id="default",
username="mixed-location@example.com",
name="混合地点员工",
role_codes=[],