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

@@ -23,9 +23,11 @@ from app.schemas.agent_asset import (
AgentAssetRead,
AgentAssetReviewCreate,
AgentAssetReviewRead,
AgentAssetRuleJsonRead,
AgentAssetRuleJsonWrite,
AgentAssetSpreadsheetChangeRecordRead,
AgentAssetVersionCompareRead,
AgentAssetUpdate,
AgentAssetVersionCompareRead,
AgentAssetVersionCreate,
AgentAssetVersionRead,
AgentAssetVersionTimelineItemRead,
@@ -50,7 +52,7 @@ RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_u
def _handle_asset_error(exc: Exception) -> None:
if isinstance(exc, LookupError):
if isinstance(exc, (LookupError, FileNotFoundError)):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
if isinstance(exc, PermissionError):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@@ -111,6 +113,48 @@ def get_agent_asset(asset_id: str, db: DbSession) -> AgentAssetRead:
return asset
@router.get(
"/{asset_id}/rule-json",
response_model=AgentAssetRuleJsonRead,
summary="读取风险规则 JSON",
description="读取 JSON 风险规则资产绑定的规则文件内容。",
)
def get_agent_asset_rule_json(
asset_id: str,
_: CurrentUser,
db: DbSession,
) -> AgentAssetRuleJsonRead:
try:
return AgentAssetService(db).read_rule_json(asset_id)
except Exception as exc:
_handle_asset_error(exc)
@router.put(
"/{asset_id}/rule-json",
response_model=AgentAssetRuleJsonRead,
summary="保存风险规则 JSON",
description="保存 JSON 风险规则资产绑定的规则文件内容,并写入审计日志。",
)
def save_agent_asset_rule_json(
asset_id: str,
payload: AgentAssetRuleJsonWrite,
current_user: RuleEditorUser,
db: DbSession,
x_actor: ActorHeader = None,
x_request_id: RequestIdHeader = None,
) -> AgentAssetRuleJsonRead:
try:
return AgentAssetService(db).write_rule_json(
asset_id,
body=payload,
actor=(x_actor or current_user.name or "system").strip() or "system",
request_id=x_request_id,
)
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/spreadsheet/onlyoffice-config",
response_model=AgentAssetOnlyOfficeConfigRead,

View File

@@ -56,6 +56,17 @@ class AgentAssetRepository:
stmt = stmt.limit(limit)
return list(self.db.scalars(stmt).all())
def list_versions_for_assets(self, asset_ids: list[str]) -> list[AgentAssetVersion]:
if not asset_ids:
return []
stmt = (
select(AgentAssetVersion)
.where(AgentAssetVersion.asset_id.in_(asset_ids))
.order_by(AgentAssetVersion.asset_id, AgentAssetVersion.created_at.desc())
)
return list(self.db.scalars(stmt).all())
def get_version(self, asset_id: str, version: str) -> AgentAssetVersion | None:
stmt = select(AgentAssetVersion).where(
AgentAssetVersion.asset_id == asset_id,

View File

@@ -28,6 +28,28 @@ class AuditLogRepository:
stmt = stmt.order_by(AuditLog.created_at.desc()).limit(limit)
return list(self.db.scalars(stmt).all())
def list_for_resources(
self,
*,
resource_type: str,
resource_ids: list[str],
action: str | None = None,
limit: int | None = None,
) -> list[AuditLog]:
if not resource_ids:
return []
stmt = select(AuditLog).where(
AuditLog.resource_type == resource_type,
AuditLog.resource_id.in_(resource_ids),
)
if action:
stmt = stmt.where(AuditLog.action == action)
stmt = stmt.order_by(AuditLog.created_at.desc())
if limit is not None:
stmt = stmt.limit(limit)
return list(self.db.scalars(stmt).all())
def create(self, log: AuditLog) -> AuditLog:
self.db.add(log)
self.db.commit()

View File

@@ -93,6 +93,22 @@ class AgentAssetOnlyOfficeCallbackWrite(BaseModel):
users: list[str] = Field(default_factory=list, description="当前编辑用户列表。")
class AgentAssetRuleJsonWrite(BaseModel):
payload: dict[str, Any] = Field(default_factory=dict)
class AgentAssetRuleJsonRead(BaseModel):
file_name: str
rule_code: str
name: str
description: str = ""
evaluator: str = ""
ontology_signal: str | None = None
inputs: dict[str, Any] = Field(default_factory=dict)
outcomes: dict[str, Any] = Field(default_factory=dict)
payload: dict[str, Any] = Field(default_factory=dict)
class AgentAssetVersionTimelineItemRead(BaseModel):
event_type: str
version: str
@@ -129,8 +145,10 @@ class AgentAssetVersionCompareRead(BaseModel):
class AgentAssetSpreadsheetChangeRecordRead(BaseModel):
id: str
actor: str
changed_at: datetime
version: str | None = None
summary: str
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = Field(default_factory=list)
cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = Field(default_factory=list)
@@ -172,6 +190,8 @@ class AgentAssetListItem(BaseModel):
published_version: str | None
working_version: str | None
config_json: dict[str, Any]
change_count: int = 0
modified_by: str | None = None
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from app.core.config import SERVER_DIR
from app.services.agent_asset_spreadsheet import RULE_LIBRARY_NAMES
JSON_RULE_MIME_TYPE = "application/json"
class AgentAssetRuleLibraryManager:
def __init__(self, rule_root: Path | None = None) -> None:
self.rule_root = Path(rule_root or (SERVER_DIR / "rules")).resolve()
def ensure_rule_library_dirs(self) -> None:
for library in sorted(RULE_LIBRARY_NAMES):
(self.rule_root / library).mkdir(parents=True, exist_ok=True)
def resolve_rule_library_path(self, *, library: str, file_name: str) -> Path:
normalized_library = str(library or "").strip()
if normalized_library not in RULE_LIBRARY_NAMES:
raise ValueError("Invalid rule library.")
normalized_name = Path(str(file_name or "").strip()).name.strip()
if not normalized_name or not normalized_name.endswith(".json"):
raise ValueError("Rule JSON file name must end with .json.")
library_dir = (self.rule_root / normalized_library).resolve()
target_path = (library_dir / normalized_name).resolve()
try:
target_path.relative_to(library_dir)
except ValueError:
raise ValueError("Invalid rule JSON path.") from None
return target_path
def read_rule_library_json(self, *, library: str, file_name: str) -> dict[str, Any]:
target_path = self.resolve_rule_library_path(library=library, file_name=file_name)
if not target_path.exists():
raise FileNotFoundError("Rule JSON file not found.")
try:
payload = json.loads(target_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError("Rule JSON file is invalid.") from exc
if not isinstance(payload, dict):
raise ValueError("Rule JSON payload must be an object.")
return payload
def write_rule_library_json(
self,
*,
library: str,
file_name: str,
payload: dict[str, Any],
) -> dict[str, Any]:
if not isinstance(payload, dict):
raise ValueError("Rule JSON payload must be an object.")
rule_code = str(payload.get("rule_code") or "").strip()
if not rule_code:
raise ValueError("Rule JSON must include rule_code.")
evaluator = str(payload.get("evaluator") or "").strip()
if not evaluator:
raise ValueError("Rule JSON must include evaluator.")
target_path = self.resolve_rule_library_path(library=library, file_name=file_name)
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(
f"{json.dumps(payload, ensure_ascii=False, indent=2)}\n",
encoding="utf-8",
)
return payload
def list_rule_library_json_files(self, *, library: str) -> list[str]:
library_dir = self.resolve_rule_library_path(
library=library,
file_name="placeholder.json",
).parent
library_dir.mkdir(parents=True, exist_ok=True)
return sorted(path.name for path in library_dir.glob("*.json") if path.is_file())

View File

@@ -69,26 +69,13 @@ class AgentAssetSpreadsheetManager:
actor_name: str,
source: str = "upload",
) -> RuleSpreadsheetMeta:
normalized_name = Path(str(file_name or "").strip()).name.strip()
if not normalized_name:
raise ValueError("规则表文件名不能为空。")
if not content:
raise ValueError("规则表文件内容不能为空。")
relative_path = Path("agent_assets") / asset_id / "rule_spreadsheets" / version / normalized_name
target_path = (self.storage_root / relative_path).resolve()
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(content)
mime_type = mimetypes.guess_type(normalized_name)[0] or SPREADSHEET_MIME_TYPE
return RuleSpreadsheetMeta(
file_name=normalized_name,
storage_key=relative_path.as_posix(),
mime_type=mime_type,
size_bytes=len(content),
checksum=hashlib.sha256(content).hexdigest(),
updated_at=datetime.now(UTC).isoformat(),
updated_by=str(actor_name or "system").strip() or "system",
return self.store_rule_library_spreadsheet_snapshot(
library=FINANCE_RULES_LIBRARY,
asset_id=asset_id,
version=version,
file_name=file_name,
content=content,
actor_name=actor_name,
source=source,
)
@@ -117,7 +104,74 @@ class AgentAssetSpreadsheetManager:
try:
target_path.relative_to(self.rule_root)
except ValueError:
raise ValueError("规则库文件路径不合法。")
raise ValueError("规则库文件路径不合法。") from None
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(content)
mime_type = mimetypes.guess_type(normalized_name)[0] or SPREADSHEET_MIME_TYPE
return RuleSpreadsheetMeta(
file_name=normalized_name,
storage_key=relative_path.as_posix(),
mime_type=mime_type,
size_bytes=len(content),
checksum=hashlib.sha256(content).hexdigest(),
updated_at=datetime.now(UTC).isoformat(),
updated_by=str(actor_name or "system").strip() or "system",
source=source,
)
def store_rule_library_spreadsheet_snapshot(
self,
*,
library: str,
asset_id: str,
version: str,
file_name: str,
content: bytes,
actor_name: str,
source: str = "rule-library-version",
) -> RuleSpreadsheetMeta:
normalized_library = str(library or "").strip()
if normalized_library not in RULE_LIBRARY_NAMES:
raise ValueError("规则库目录不合法。")
raw_asset_id = str(asset_id or "").strip()
raw_version = str(version or "").strip()
normalized_asset_id = Path(raw_asset_id).name.strip()
normalized_version = Path(raw_version).name.strip()
normalized_name = Path(str(file_name or "").strip()).name.strip()
if (
not normalized_asset_id
or normalized_asset_id in {".", ".."}
or normalized_asset_id != raw_asset_id
):
raise ValueError("规则资产 ID 不合法。")
if (
not normalized_version
or normalized_version in {".", ".."}
or normalized_version != raw_version
):
raise ValueError("规则表版本号不合法。")
if not normalized_name:
raise ValueError("规则表文件名不能为空。")
if not content:
raise ValueError("规则表文件内容不能为空。")
self.ensure_rule_library_dirs()
relative_path = (
Path("rules")
/ normalized_library
/ ".versions"
/ normalized_asset_id
/ normalized_version
/ normalized_name
)
target_path = (SERVER_DIR / relative_path).resolve()
try:
target_path.relative_to(self.rule_root)
except ValueError:
raise ValueError("规则库版本文件路径不合法。") from None
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(content)
@@ -149,7 +203,7 @@ class AgentAssetSpreadsheetManager:
try:
resolved.relative_to(allowed_root)
except ValueError:
raise FileNotFoundError("规则表文件不存在。")
raise FileNotFoundError("规则表文件不存在。") from None
return resolved
@staticmethod
@@ -230,11 +284,46 @@ class AgentAssetSpreadsheetManager:
def build_company_travel_rule_template() -> bytes:
standard_rows = [
["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"],
["长途交通", "飞机、高铁、火车等跨城出行", "行程单、车票、发票", "据实报销", "超预算需直属领导审批", "优先选择公共交通"],
["住宿费", "出差住宿", "酒店发票、入住清单", "一线城市 650/晚;二线城市 500/晚;其他城市 380/晚", "超标需总监审批", "协议酒店优先"],
["市内交通", "出租车、网约车、地铁、公交", "发票或电子行程单", "150/天", "超限需补充说明", "夜间或无公共交通场景可豁免"],
["餐补", "出差期间日常补助", "无需票据", "120/天", "系统自动核定", "当天往返默认不享受"],
["招待餐费", "客户接待或项目宴请", "餐饮发票、参与人清单", "300/人", "需业务负责人审批", "需关联客户或项目"],
[
"长途交通",
"飞机、高铁、火车等跨城出行",
"行程单、车票、发票",
"据实报销",
"超预算需直属领导审批",
"优先选择公共交通",
],
[
"住宿费",
"出差住宿",
"酒店发票、入住清单",
"一线城市 650/晚;二线城市 500/晚;其他城市 380/晚",
"超标需总监审批",
"协议酒店优先",
],
[
"市内交通",
"出租车、网约车、地铁、公交",
"发票或电子行程单",
"150/天",
"超限需补充说明",
"夜间或无公共交通场景可豁免",
],
[
"餐补",
"出差期间日常补助",
"无需票据",
"120/天",
"系统自动核定",
"当天往返默认不享受",
],
[
"招待餐费",
"客户接待或项目宴请",
"餐饮发票、参与人清单",
"300/人",
"需业务负责人审批",
"需关联客户或项目",
],
]
instruction_rows = [
["字段", "填写说明"],
@@ -308,21 +397,41 @@ def _build_xlsx_bytes(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
def _build_content_types_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
overrides = [
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>',
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>',
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>',
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>',
(
'<Override PartName="/xl/workbook.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.'
'spreadsheetml.sheet.main+xml"/>'
),
(
'<Override PartName="/xl/styles.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.'
'spreadsheetml.styles+xml"/>'
),
(
'<Override PartName="/docProps/core.xml" '
'ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
),
(
'<Override PartName="/docProps/app.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.'
'extended-properties+xml"/>'
),
]
overrides.extend(
[
f'<Override PartName="/xl/worksheets/sheet{index}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
(
f'<Override PartName="/xl/worksheets/sheet{index}.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.'
'spreadsheetml.worksheet+xml"/>'
)
for index, _ in enumerate(sheets, start=1)
]
)
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="rels" '
'ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
f'{"".join(overrides)}'
"</Types>"
@@ -333,9 +442,15 @@ def _build_root_rels_xml() -> str:
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>'
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/'
'relationships/officeDocument" Target="xl/workbook.xml"/>'
'<Relationship Id="rId2" '
'Type="http://schemas.openxmlformats.org/package/2006/relationships/'
'metadata/core-properties" Target="docProps/core.xml"/>'
'<Relationship Id="rId3" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/'
'relationships/extended-properties" Target="docProps/app.xml"/>'
"</Relationships>"
)
@@ -347,11 +462,16 @@ def _build_app_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
sheet_count = len(sheets)
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" '
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/'
'extended-properties" '
'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">'
'<Application>Microsoft Excel</Application>'
f"<HeadingPairs><vt:vector size=\"2\" baseType=\"variant\"><vt:variant><vt:lpstr>Worksheets</vt:lpstr></vt:variant><vt:variant><vt:i4>{sheet_count}</vt:i4></vt:variant></vt:vector></HeadingPairs>"
f"<TitlesOfParts><vt:vector size=\"{sheet_count}\" baseType=\"lpstr\">{titles}</vt:vector></TitlesOfParts>"
'<HeadingPairs><vt:vector size="2" baseType="variant">'
"<vt:variant><vt:lpstr>Worksheets</vt:lpstr></vt:variant>"
f"<vt:variant><vt:i4>{sheet_count}</vt:i4></vt:variant>"
"</vt:vector></HeadingPairs>"
f'<TitlesOfParts><vt:vector size="{sheet_count}" baseType="lpstr">'
f"{titles}</vt:vector></TitlesOfParts>"
"</Properties>"
)
@@ -359,7 +479,8 @@ def _build_app_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
def _build_core_xml(created_at: str) -> str:
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" '
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/'
'2006/metadata/core-properties" '
'xmlns:dc="http://purl.org/dc/elements/1.1/" '
'xmlns:dcterms="http://purl.org/dc/terms/" '
'xmlns:dcmitype="http://purl.org/dc/dcmitype/" '
@@ -392,7 +513,11 @@ def _build_workbook_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
def _build_workbook_rels_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
relationships = "".join(
[
f'<Relationship Id="rId{index}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{index}.xml"/>'
(
f'<Relationship Id="rId{index}" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/'
f'relationships/worksheet" Target="worksheets/sheet{index}.xml"/>'
)
for index, _ in enumerate(sheets, start=1)
]
)
@@ -414,10 +539,15 @@ def _build_styles_xml() -> str:
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>'
'<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>'
'<fills count="2"><fill><patternFill patternType="none"/></fill>'
'<fill><patternFill patternType="gray125"/></fill></fills>'
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
'<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>'
'<cellStyleXfs count="1">'
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0"/>'
"</cellStyleXfs>"
'<cellXfs count="1">'
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
"</cellXfs>"
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
'</styleSheet>'
)

View File

@@ -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()

View File

@@ -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()

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(