feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
@@ -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=[],
|
||||
|
||||
Reference in New Issue
Block a user