fix(agent): 修复规则中心表格版本和修改记录

补齐规则资产 JSON 读写接口和前端调用,修复 AuditView 导入缺失。

Excel 在线编辑改为比对所有页签并生成最近修改记录,版本快照统一保存到 rules/finance-rules/.versions。

隔离规则表测试存储,避免测试或旧入口写入真实规则目录与 storage/agent_assets。
This commit is contained in:
caoxiaozhu
2026-05-19 15:41:53 +00:00
parent 9472813739
commit d460ee0fe7
13 changed files with 782 additions and 167 deletions

View File

@@ -1,7 +1,9 @@
from __future__ import annotations
import shutil
import uuid
from io import BytesIO
from pathlib import Path
import pytest
from openpyxl import Workbook, load_workbook
@@ -9,6 +11,7 @@ from sqlalchemy import create_engine
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,
@@ -19,13 +22,18 @@ from app.core.agent_enums import (
AgentRunSource,
AgentRunStatus,
)
from app.core.config import SERVER_DIR
from app.db.base import Base
from app.schemas.agent_asset import (
AgentAssetCreate,
AgentAssetReviewCreate,
AgentAssetVersionCreate,
)
from app.api.deps import CurrentUserContext
from app.services.agent_asset_spreadsheet import (
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
FINANCE_RULES_LIBRARY,
)
from app.services.agent_assets import AgentAssetService
from app.services.agent_runs import AgentRunService
from app.services.audit import AuditLogService
@@ -33,6 +41,38 @@ from app.services.expense_rule_runtime import ExpenseRuleRuntimeService
from app.services.settings import OnlyOfficeRuntimeConfig
@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_COMMUNICATION_EXPENSE_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:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
@@ -55,6 +95,19 @@ def build_workbook_bytes(rows: list[list[object]], *, sheet_name: str = "规则
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)
@@ -62,7 +115,8 @@ def test_agent_asset_service_seeds_assets_and_enforces_review_before_activation(
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
item.code == "rule.expense.travel_risk_control_standard"
and item.status == AgentAssetStatus.ACTIVE.value
for item in rules
)
assert all(
@@ -218,7 +272,10 @@ 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",
@@ -285,7 +342,10 @@ def test_spreadsheet_version_compare_returns_sheet_and_cell_changes() -> None:
assert diff.changed_sheet_count == 1
assert diff.changed_cell_count == 3
assert any(item.cell == "B2" and item.change_type == "modified" for item in diff.cell_changes)
assert any(
item.cell == "B2" and item.change_type == "modified"
for item in diff.cell_changes
)
assert any(item.cell == "A3" and item.change_type == "added" for item in diff.cell_changes)
@@ -311,7 +371,10 @@ def test_working_spreadsheet_version_reads_immutable_snapshot_instead_of_live_co
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()
original_live_bytes = live_path.read_bytes()
try:
live_path.write_bytes(build_workbook_bytes([["城市", "住宿"], ["北京", 999]]))
@@ -322,6 +385,9 @@ def test_working_spreadsheet_version_reads_immutable_snapshot_instead_of_live_co
)
assert snapshot_path != live_path
assert FINANCE_RULES_LIBRARY in snapshot_path.parts
assert ".versions" in snapshot_path.parts
assert "agent_assets" not in snapshot_path.parts
workbook = load_workbook(snapshot_path, data_only=False)
assert workbook.active["B2"].value == 500
finally:
@@ -366,6 +432,55 @@ def test_spreadsheet_change_records_return_recent_edit_details() -> None:
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
first_version = detail.working_version
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 latest.version != first_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(