str:
def _build_workbook_rels_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
relationships = "".join(
[
- f''
+ (
+ f''
+ )
for index, _ in enumerate(sheets, start=1)
]
)
@@ -414,10 +539,15 @@ def _build_styles_xml() -> str:
''
''
''
- ''
+ ''
+ ''
''
- ''
- ''
+ ''
+ ''
+ ""
+ ''
+ ''
+ ""
''
''
)
diff --git a/server/src/app/services/agent_assets.py b/server/src/app/services/agent_assets.py
index acbcf4b..78e2a5e 100644
--- a/server/src/app/services/agent_assets.py
+++ b/server/src/app/services/agent_assets.py
@@ -10,7 +10,6 @@ from urllib.parse import quote
from urllib.request import Request, urlopen
import jwt
-
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
@@ -44,7 +43,6 @@ from app.schemas.agent_asset import (
)
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import (
- AgentAssetSpreadsheetManager,
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
@@ -52,8 +50,9 @@ from app.services.agent_asset_spreadsheet import (
FINANCE_RULES_LIBRARY,
RISK_RULES_LIBRARY,
RULE_LIBRARY_NAMES,
- RuleSpreadsheetMeta,
SPREADSHEET_MIME_TYPE,
+ AgentAssetSpreadsheetManager,
+ RuleSpreadsheetMeta,
)
from app.services.agent_foundation import AgentFoundationService
from app.services.audit import AuditLogService
@@ -410,7 +409,8 @@ class AgentAssetService:
file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key)
if not file_path.exists():
raise FileNotFoundError(metadata.file_name)
- snapshot_meta = self.spreadsheet_manager.store_spreadsheet(
+ snapshot_meta = self.spreadsheet_manager.store_rule_library_spreadsheet_snapshot(
+ library=self._resolve_spreadsheet_rule_library(asset),
asset_id=asset.id,
version=target_version,
file_name=metadata.file_name,
@@ -542,7 +542,11 @@ class AgentAssetService:
return file_path, metadata.mime_type, metadata.file_name
asset = self._require_spreadsheet_rule(asset_id)
- _, metadata = self._resolve_current_spreadsheet_meta(asset)
+ requested_version = str(version or "").strip()
+ if requested_version and requested_version != "current":
+ _, metadata = self._resolve_spreadsheet_version_meta(asset, version=requested_version)
+ else:
+ _, metadata = self._resolve_current_spreadsheet_meta(asset)
file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key)
if not file_path.exists():
raise FileNotFoundError(metadata.file_name)
@@ -594,6 +598,14 @@ class AgentAssetService:
_, current_metadata = self._resolve_current_spreadsheet_meta(asset)
file_name = current_metadata.file_name or self._resolve_default_spreadsheet_file_name(asset)
+ sheet_changes, cell_changes = self._collect_workbook_changes_from_content(
+ current_metadata,
+ content,
+ )
+ changed_sheet_count = self._count_changed_sheets(sheet_changes, cell_changes)
+ changed_cell_count = len(cell_changes)
+ next_version = self._next_available_version(asset)
+
metadata = self._store_current_rule_spreadsheet(
asset,
file_name=file_name,
@@ -601,6 +613,45 @@ class AgentAssetService:
actor=actor,
source=source,
)
+ snapshot_metadata = self.spreadsheet_manager.store_rule_library_spreadsheet_snapshot(
+ library=self._resolve_spreadsheet_rule_library(asset),
+ asset_id=asset.id,
+ version=next_version,
+ file_name=file_name,
+ content=content,
+ actor_name=actor,
+ source=source,
+ )
+ operation_label = (
+ change_note
+ or (
+ "ONLYOFFICE 在线编辑"
+ if source == "onlyoffice"
+ else f"上传并覆盖当前规则表:{normalized_name}"
+ )
+ )
+ summary = self._build_spreadsheet_change_summary(
+ operation_label,
+ sheet_changes,
+ cell_changes,
+ )
+ version_content = self.spreadsheet_manager.build_version_markdown(
+ rule_name=asset.name,
+ version=next_version,
+ metadata=snapshot_metadata,
+ )
+ self.create_version(
+ asset.id,
+ AgentAssetVersionCreate(
+ version=next_version,
+ content=version_content,
+ content_type=AgentAssetContentType.MARKDOWN,
+ change_note=summary,
+ created_by=actor,
+ ),
+ actor=actor,
+ request_id=request_id,
+ )
self.audit_service.log_action(
actor=actor,
action="edit_rule_spreadsheet",
@@ -608,12 +659,14 @@ class AgentAssetService:
resource_id=asset.id,
before_json={"storage_key": current_metadata.storage_key},
after_json={
- "summary": change_note or f"上传并覆盖当前规则表:{normalized_name}",
- "changed_sheet_count": 0,
- "changed_cell_count": 0,
- "sheet_changes": [],
- "cell_changes": [],
+ "summary": summary,
+ "version": next_version,
+ "changed_sheet_count": changed_sheet_count,
+ "changed_cell_count": changed_cell_count,
+ "sheet_changes": [item.model_dump() for item in sheet_changes],
+ "cell_changes": [item.model_dump() for item in cell_changes[:500]],
"storage_key": metadata.storage_key,
+ "snapshot_storage_key": snapshot_metadata.storage_key,
},
request_id=request_id,
)
@@ -682,56 +735,16 @@ class AgentAssetService:
if current_metadata.checksum and current_metadata.checksum == self._hash_bytes(content):
return
- from io import BytesIO
- from openpyxl import load_workbook
- try:
- base_workbook = self._load_spreadsheet_for_compare(current_metadata)
- target_workbook = load_workbook(BytesIO(content), read_only=False, data_only=False)
- sheet_changes, cell_changes = self._collect_workbook_changes(
- base_workbook, target_workbook
- )
- changed_sheet_count = len(
- {item.sheet_name for item in sheet_changes}
- | {item.sheet_name for item in cell_changes}
- )
- changed_cell_count = len(cell_changes)
-
- if changed_cell_count > 0 or changed_sheet_count > 0:
- change_note = f"ONLYOFFICE 在线编辑:涉及 {changed_sheet_count} 个 Sheet,共 {changed_cell_count} 处改动。"
- else:
- change_note = "ONLYOFFICE 在线编辑保存。"
- except Exception:
- sheet_changes = []
- cell_changes = []
- changed_sheet_count = 0
- changed_cell_count = 0
- change_note = "ONLYOFFICE 在线编辑保存。"
-
resolved_actor_name = str(actor_name or "").strip() or (
callback.users[0] if callback.users else "ONLYOFFICE"
)
- self._store_current_rule_spreadsheet(
- asset,
- file_name=current_metadata.file_name,
+ self.upload_rule_spreadsheet(
+ asset.id,
+ filename=current_metadata.file_name,
content=content,
actor=resolved_actor_name,
source="onlyoffice",
)
- if changed_sheet_count > 0 or changed_cell_count > 0:
- self.audit_service.log_action(
- actor=resolved_actor_name,
- action="edit_rule_spreadsheet",
- resource_type=asset.asset_type,
- resource_id=asset.id,
- before_json={"storage_key": current_metadata.storage_key},
- after_json={
- "summary": change_note,
- "changed_sheet_count": changed_sheet_count,
- "changed_cell_count": changed_cell_count,
- "sheet_changes": [item.model_dump() for item in sheet_changes],
- "cell_changes": [item.model_dump() for item in cell_changes[:500]],
- },
- )
def _ensure_ready(self) -> None:
AgentFoundationService(self.db).ensure_foundation_ready()
@@ -782,7 +795,8 @@ class AgentAssetService:
if (
asset.asset_type == AgentAssetType.RULE.value
- and str((asset.config_json or {}).get("detail_mode") or "").strip().lower() == "spreadsheet"
+ and str((asset.config_json or {}).get("detail_mode") or "").strip().lower()
+ == "spreadsheet"
):
metadata = self.spreadsheet_manager.parse_version_markdown(str(source.content or ""))
if metadata is None:
@@ -919,60 +933,30 @@ class AgentAssetService:
) -> AgentAssetVersionCompareRead:
self._ensure_ready()
asset = self._require_spreadsheet_rule(asset_id)
- resolved_base, base_meta = self._resolve_spreadsheet_version_meta(asset, version=base_version)
- resolved_target, target_meta = self._resolve_spreadsheet_version_meta(asset, version=target_version)
+ resolved_base, base_meta = self._resolve_spreadsheet_version_meta(
+ asset,
+ version=base_version,
+ )
+ resolved_target, target_meta = self._resolve_spreadsheet_version_meta(
+ asset,
+ version=target_version,
+ )
base_workbook = self._load_spreadsheet_for_compare(base_meta)
target_workbook = self._load_spreadsheet_for_compare(target_meta)
- base_sheet_names = set(base_workbook.sheetnames)
- target_sheet_names = set(target_workbook.sheetnames)
-
- sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = []
- for sheet_name in sorted(target_sheet_names - base_sheet_names):
- sheet_changes.append(
- AgentAssetSpreadsheetDiffSheetRead(sheet_name=sheet_name, change_type="added")
- )
- for sheet_name in sorted(base_sheet_names - target_sheet_names):
- sheet_changes.append(
- AgentAssetSpreadsheetDiffSheetRead(sheet_name=sheet_name, change_type="removed")
- )
-
- cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = []
- changed_sheets: set[str] = set()
- for sheet_name in sorted(base_sheet_names & target_sheet_names):
- base_sheet = base_workbook[sheet_name]
- target_sheet = target_workbook[sheet_name]
- max_row = max(base_sheet.max_row, target_sheet.max_row)
- max_column = max(base_sheet.max_column, target_sheet.max_column)
- for row_index in range(1, max_row + 1):
- for column_index in range(1, max_column + 1):
- before_value = base_sheet.cell(row=row_index, column=column_index).value
- after_value = target_sheet.cell(row=row_index, column=column_index).value
- if before_value == after_value:
- continue
- changed_sheets.add(sheet_name)
- if before_value in (None, ""):
- change_type = "added"
- elif after_value in (None, ""):
- change_type = "removed"
- else:
- change_type = "modified"
- cell_changes.append(
- AgentAssetSpreadsheetDiffCellRead(
- sheet_name=sheet_name,
- cell=target_sheet.cell(row=row_index, column=column_index).coordinate,
- change_type=change_type,
- before_value=before_value,
- after_value=after_value,
- )
- )
+ sheet_changes, cell_changes = self._collect_workbook_changes(
+ base_workbook,
+ target_workbook,
+ )
+ added_sheet_count = sum(1 for item in sheet_changes if item.change_type == "added")
+ removed_sheet_count = sum(1 for item in sheet_changes if item.change_type == "removed")
return AgentAssetVersionCompareRead(
base_version=resolved_base,
target_version=resolved_target,
- added_sheet_count=len(target_sheet_names - base_sheet_names),
- removed_sheet_count=len(base_sheet_names - target_sheet_names),
- changed_sheet_count=len(changed_sheets),
+ added_sheet_count=added_sheet_count,
+ removed_sheet_count=removed_sheet_count,
+ changed_sheet_count=self._count_changed_sheets(sheet_changes, cell_changes),
changed_cell_count=len(cell_changes),
sheet_changes=sheet_changes,
cell_changes=cell_changes[:500],
@@ -997,6 +981,7 @@ class AgentAssetService:
id=log.id,
actor=log.actor,
changed_at=log.created_at,
+ version=str((log.after_json or {}).get("version") or "").strip() or None,
summary=str((log.after_json or {}).get("summary") or "ONLYOFFICE 在线编辑保存。"),
sheet_changes=[
AgentAssetSpreadsheetDiffSheetRead.model_validate(item)
@@ -1379,9 +1364,10 @@ class AgentAssetService:
file_name = PREVIEW_RULE_VERSION_FILENAMES[resolved_version]
storage_key = (
- Path("agent_assets")
+ Path("rules")
+ / FINANCE_RULES_LIBRARY
+ / ".versions"
/ PREVIEW_RULE_ASSET_ID
- / "rule_spreadsheets"
/ resolved_version
/ file_name
).as_posix()
@@ -1404,7 +1390,8 @@ class AgentAssetService:
source="preview",
)
- metadata = self.spreadsheet_manager.store_spreadsheet(
+ metadata = self.spreadsheet_manager.store_rule_library_spreadsheet_snapshot(
+ library=FINANCE_RULES_LIBRARY,
asset_id=PREVIEW_RULE_ASSET_ID,
version=resolved_version,
file_name=file_name,
@@ -1436,7 +1423,8 @@ class AgentAssetService:
return
actor_name = callback.users[0] if callback.users else "ONLYOFFICE"
- self.spreadsheet_manager.store_spreadsheet(
+ self.spreadsheet_manager.store_rule_library_spreadsheet_snapshot(
+ library=FINANCE_RULES_LIBRARY,
asset_id=PREVIEW_RULE_ASSET_ID,
version=resolved_version,
file_name=metadata.file_name,
@@ -1482,7 +1470,10 @@ class AgentAssetService:
) -> bool:
role_codes = {str(item).strip() for item in current_user.role_codes}
can_edit = current_user.is_admin or "manager" in role_codes or "finance" in role_codes
- return can_edit and AgentAssetService._resolve_working_version(asset) == str(version or "").strip()
+ return (
+ can_edit
+ and AgentAssetService._resolve_working_version(asset) == str(version or "").strip()
+ )
@staticmethod
def _can_edit_current_spreadsheet(current_user: CurrentUserContext) -> bool:
@@ -1495,7 +1486,8 @@ class AgentAssetService:
version: str,
metadata: RuleSpreadsheetMeta,
) -> str:
- raw_key = f"{asset_id}-{version}-{metadata.checksum or metadata.updated_at or metadata.file_name}"
+ fingerprint = metadata.checksum or metadata.updated_at or metadata.file_name
+ raw_key = f"{asset_id}-{version}-{fingerprint}"
return "".join(
character if character.isalnum() or character in {"-", "_", ".", "="} else "_"
for character in raw_key
@@ -1570,6 +1562,7 @@ class AgentAssetService:
def _load_spreadsheet_for_compare(self, metadata: RuleSpreadsheetMeta):
from io import BytesIO
+
from openpyxl import load_workbook
file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key)
@@ -1577,6 +1570,19 @@ class AgentAssetService:
raise FileNotFoundError(metadata.file_name)
return load_workbook(BytesIO(file_path.read_bytes()), read_only=False, data_only=False)
+ def _collect_workbook_changes_from_content(
+ self,
+ base_metadata: RuleSpreadsheetMeta,
+ target_content: bytes,
+ ) -> tuple[list[AgentAssetSpreadsheetDiffSheetRead], list[AgentAssetSpreadsheetDiffCellRead]]:
+ from io import BytesIO
+
+ from openpyxl import load_workbook
+
+ base_workbook = self._load_spreadsheet_for_compare(base_metadata)
+ target_workbook = load_workbook(BytesIO(target_content), read_only=False, data_only=False)
+ return self._collect_workbook_changes(base_workbook, target_workbook)
+
def _collect_workbook_changes(
self, base_workbook, target_workbook
) -> tuple[list[AgentAssetSpreadsheetDiffSheetRead], list[AgentAssetSpreadsheetDiffCellRead]]:
@@ -1621,8 +1627,50 @@ class AgentAssetService:
)
)
+ for sheet_name in sorted({item.sheet_name for item in cell_changes}):
+ sheet_changes.append(
+ AgentAssetSpreadsheetDiffSheetRead(sheet_name=sheet_name, change_type="modified")
+ )
+
return sheet_changes, cell_changes
+ @staticmethod
+ def _count_changed_sheets(
+ sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead],
+ cell_changes: list[AgentAssetSpreadsheetDiffCellRead],
+ ) -> int:
+ return len(
+ {item.sheet_name for item in sheet_changes}
+ | {item.sheet_name for item in cell_changes}
+ )
+
+ @staticmethod
+ def _build_spreadsheet_change_summary(
+ operation_label: str,
+ sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead],
+ cell_changes: list[AgentAssetSpreadsheetDiffCellRead],
+ ) -> str:
+ sheet_names = sorted(
+ {item.sheet_name for item in sheet_changes}
+ | {item.sheet_name for item in cell_changes}
+ )
+ if not sheet_names:
+ return f"{operation_label}:文件内容已保存,未发现单元格级差异。"
+
+ preview = "、".join(sheet_names[:3])
+ if len(sheet_names) > 3:
+ preview = f"{preview} 等"
+ sheet_text = f"涉及 {len(sheet_names)} 个工作表({preview})"
+ if cell_changes:
+ return f"{operation_label}:{sheet_text},共 {len(cell_changes)} 处单元格改动。"
+ return f"{operation_label}:{sheet_text},工作表结构发生变化。"
+
+ def _next_available_version(self, asset: AgentAsset) -> str:
+ candidate = self._increment_version(self._resolve_working_version(asset))
+ while self.repository.get_version(asset.id, candidate) is not None:
+ candidate = self._increment_version(candidate)
+ return candidate
+
@staticmethod
def _extract_restore_source_version(change_note: str | None) -> str | None:
normalized = str(change_note or "").strip()
diff --git a/server/src/app/services/risk_ontology_bridge.py b/server/src/app/services/risk_ontology_bridge.py
new file mode 100644
index 0000000..95b119c
--- /dev/null
+++ b/server/src/app/services/risk_ontology_bridge.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+from app.schemas.ontology import OntologyParseResult
+
+RISK_SIGNAL_TO_RULE_CODES: dict[str, list[str]] = {
+ "location_mismatch": ["risk.travel.destination_receipt_location"],
+ "base_location_overlap": ["risk.travel.base_location_overlap"],
+ "intracity_travel": ["risk.travel.intracity_travel_claim"],
+ "multi_city_itinerary": ["risk.travel.multi_city_reason_required"],
+ "hotel_itinerary_mismatch": ["risk.travel.hotel_without_itinerary"],
+ "duplicate_invoice": ["risk.invoice.duplicate_invoice"],
+ "buyer_name_mismatch": ["risk.invoice.claimant_buyer_name_match"],
+ "document_expense_mismatch": ["risk.invoice.document_expense_mismatch"],
+ "cross_year_invoice": ["risk.invoice.cross_year_invoice"],
+ "void_or_red_invoice": ["risk.invoice.void_or_red_invoice"],
+ "vague_goods_description": ["risk.invoice.vague_goods_description"],
+ "entertainment_missing_detail": ["risk.expense.entertainment_missing_detail"],
+ "meal_as_travel": ["risk.expense.meal_localized_as_travel"],
+ "consecutive_transport_receipts": ["risk.expense.consecutive_transport_receipts"],
+ "reason_too_brief": ["risk.expense.reason_too_brief"],
+}
+
+TEXT_SIGNAL_KEYWORDS: dict[str, tuple[str, ...]] = {
+ "location_mismatch": ("地点", "行程", "出差地", "票据地", "城市不一致"),
+ "duplicate_invoice": ("重复", "同一张票", "重复报销", "发票重复"),
+ "buyer_name_mismatch": ("购买方", "抬头", "开票单位"),
+ "document_expense_mismatch": ("附件", "票据", "单据", "材料不一致"),
+ "cross_year_invoice": ("跨年", "以前年度", "去年发票"),
+ "void_or_red_invoice": ("作废", "红冲", "红字"),
+ "vague_goods_description": ("商品名称", "品名", "笼统"),
+ "entertainment_missing_detail": ("招待", "宴请", "陪同", "客户餐"),
+ "meal_as_travel": ("餐费", "差旅餐", "本地餐"),
+ "consecutive_transport_receipts": ("连续交通", "多张车票", "打车"),
+ "reason_too_brief": ("事由", "说明太短", "理由不足"),
+}
+
+
+def list_all_platform_risk_rule_codes() -> list[str]:
+ return sorted({code for codes in RISK_SIGNAL_TO_RULE_CODES.values() for code in codes})
+
+
+def resolve_rule_codes_from_ontology(ontology: OntologyParseResult) -> list[str]:
+ resolved: list[str] = []
+ for signal in ontology.risk_flags:
+ for rule_code in RISK_SIGNAL_TO_RULE_CODES.get(str(signal or "").strip(), []):
+ if rule_code not in resolved:
+ resolved.append(rule_code)
+ return resolved
+
+
+def infer_risk_signals_from_text(text: str) -> list[str]:
+ normalized = str(text or "").strip().lower()
+ if not normalized:
+ return []
+
+ signals: list[str] = []
+ for signal, keywords in TEXT_SIGNAL_KEYWORDS.items():
+ if any(keyword.lower() in normalized for keyword in keywords):
+ signals.append(signal)
+ return signals
+
+
+def resolve_rule_codes_for_risk_check(
+ ontology: OntologyParseResult,
+ *,
+ query_text: str = "",
+) -> list[str]:
+ if ontology.intent != "risk_check":
+ return []
+
+ resolved = resolve_rule_codes_from_ontology(ontology)
+ for signal in infer_risk_signals_from_text(query_text):
+ for rule_code in RISK_SIGNAL_TO_RULE_CODES.get(signal, []):
+ if rule_code not in resolved:
+ resolved.append(rule_code)
+
+ return resolved or list_all_platform_risk_rule_codes()
diff --git a/server/tests/test_agent_asset_service.py b/server/tests/test_agent_asset_service.py
index aaa4793..83d0869 100644
--- a/server/tests/test_agent_asset_service.py
+++ b/server/tests/test_agent_asset_service.py
@@ -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(
diff --git a/web/src/services/agentAssets.js b/web/src/services/agentAssets.js
index 7ab2d75..e592553 100644
--- a/web/src/services/agentAssets.js
+++ b/web/src/services/agentAssets.js
@@ -136,6 +136,18 @@ export function fetchAgentAssetVersionTimeline(assetId) {
return apiRequest(`/agent-assets/${assetId}/version-timeline`)
}
+export function fetchAgentAssetRuleJson(assetId) {
+ return apiRequest(`/agent-assets/${assetId}/rule-json`)
+}
+
+export function saveAgentAssetRuleJson(assetId, payload, options = {}) {
+ return apiRequest(`/agent-assets/${assetId}/rule-json`, {
+ method: 'PUT',
+ body: JSON.stringify(payload),
+ headers: buildWriteHeaders(options)
+ })
+}
+
export function compareAgentAssetSpreadsheetVersions(assetId, baseVersion, targetVersion) {
const query = new URLSearchParams({
base_version: String(baseVersion || '').trim(),
diff --git a/web/src/views/AuditView.vue b/web/src/views/AuditView.vue
index fde0dad..93fe85e 100644
--- a/web/src/views/AuditView.vue
+++ b/web/src/views/AuditView.vue
@@ -165,7 +165,7 @@
- {{ item.changed_cell_count }} 处改动
+ {{ item.changeCountLabel }}
{{ item.summary }}
+ 关联版本:{{ item.version }}
+
+ 涉及工作表:{{ item.sheetPreview.join('、') }}
+ 等 {{ item.changedSheetNames.length }} 个
+
修改时间
{{ selectedSpreadsheetChangeRecord.time }}
+
+ 关联版本
+ {{ selectedSpreadsheetChangeRecord.version }}
+
修改工作表
{{ selectedSpreadsheetChangeRecord.changed_sheet_count }}
@@ -1154,7 +1163,7 @@
{{ item.sheet_name }} · {{ item.meta.label }}
- 本次没有新增或删除工作表。
+ 本次没有工作表级变化。
diff --git a/web/src/views/scripts/AuditView.js b/web/src/views/scripts/AuditView.js
index 1a2e381..175143c 100644
--- a/web/src/views/scripts/AuditView.js
+++ b/web/src/views/scripts/AuditView.js
@@ -1695,12 +1695,30 @@ export default {
}
return (spreadsheetChangeRecordsByAsset.value[selectedSkill.value.id] || [])
.filter((item) => item?.changed_at)
- .map((item) => ({
- ...item,
- time: formatDateTime(item.changed_at),
- previewChanges: Array.isArray(item.cell_changes) ? item.cell_changes.slice(0, 3) : [],
- remainingChangeCount: Math.max((item.changed_cell_count || 0) - 3, 0)
- }))
+ .map((item) => {
+ const sheetNames = [
+ ...(Array.isArray(item.sheet_changes)
+ ? item.sheet_changes.map((change) => normalizeText(change.sheet_name))
+ : []),
+ ...(Array.isArray(item.cell_changes)
+ ? item.cell_changes.map((change) => normalizeText(change.sheet_name))
+ : [])
+ ].filter(Boolean)
+ const changedSheetNames = [...new Set(sheetNames)]
+ const previewChanges = Array.isArray(item.cell_changes) ? item.cell_changes.slice(0, 3) : []
+ return {
+ ...item,
+ time: formatDateTime(item.changed_at),
+ changeCountLabel: item.changed_cell_count
+ ? `${item.changed_cell_count} 处改动`
+ : `${item.changed_sheet_count || changedSheetNames.length || 0} 个工作表`,
+ changedSheetNames,
+ sheetPreview: changedSheetNames.slice(0, 4),
+ remainingSheetCount: Math.max(changedSheetNames.length - 4, 0),
+ previewChanges,
+ remainingChangeCount: Math.max((item.changed_cell_count || 0) - previewChanges.length, 0)
+ }
+ })
})
const selectedSpreadsheetChangeSheetRows = computed(() =>
Array.isArray(selectedSpreadsheetChangeRecord.value?.sheet_changes)
@@ -2133,13 +2151,24 @@ export default {
)
.join('|')
: ''
+ const sheetSignature = Array.isArray(latest.sheet_changes)
+ ? latest.sheet_changes
+ .map((item) =>
+ [item?.sheet_name, item?.change_type]
+ .map((value) => normalizeText(value))
+ .join(':')
+ )
+ .join('|')
+ : ''
return [
latest.id,
latest.changed_at,
latest.actor,
+ latest.version,
latest.summary,
latest.changed_sheet_count,
latest.changed_cell_count,
+ sheetSignature,
previewSignature
]
.map((value) => normalizeText(value))
@@ -2538,7 +2567,21 @@ export default {
loadSpreadsheetChangeRecords(assetId).catch(() => {})
}
if (selectedSkill.value.usesJsonRiskRule) {
- await loadRiskRuleJson(assetId)
+ try {
+ await loadRiskRuleJson(assetId)
+ } catch (jsonError) {
+ console.warn('Failed to load risk rule JSON:', jsonError)
+ const jsonMessage =
+ jsonError?.message || '风险规则 JSON 文件缺失或无法读取,请同步规则库后重试。'
+ toast(jsonMessage)
+ selectedSkill.value = {
+ ...selectedSkill.value,
+ riskRuleJsonText: '{}',
+ riskRuleDescription:
+ selectedSkill.value.riskRuleDescription ||
+ '规则 JSON 尚未就绪,请联系管理员执行平台风险规则同步。'
+ }
+ }
}
}
} catch (error) {