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, AgentAssetRead,
AgentAssetReviewCreate, AgentAssetReviewCreate,
AgentAssetReviewRead, AgentAssetReviewRead,
AgentAssetRuleJsonRead,
AgentAssetRuleJsonWrite,
AgentAssetSpreadsheetChangeRecordRead, AgentAssetSpreadsheetChangeRecordRead,
AgentAssetVersionCompareRead,
AgentAssetUpdate, AgentAssetUpdate,
AgentAssetVersionCompareRead,
AgentAssetVersionCreate, AgentAssetVersionCreate,
AgentAssetVersionRead, AgentAssetVersionRead,
AgentAssetVersionTimelineItemRead, AgentAssetVersionTimelineItemRead,
@@ -50,7 +52,7 @@ RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_u
def _handle_asset_error(exc: Exception) -> None: 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 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
if isinstance(exc, PermissionError): if isinstance(exc, PermissionError):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc 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 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( @router.get(
"/{asset_id}/spreadsheet/onlyoffice-config", "/{asset_id}/spreadsheet/onlyoffice-config",
response_model=AgentAssetOnlyOfficeConfigRead, response_model=AgentAssetOnlyOfficeConfigRead,

View File

@@ -56,6 +56,17 @@ class AgentAssetRepository:
stmt = stmt.limit(limit) stmt = stmt.limit(limit)
return list(self.db.scalars(stmt).all()) 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: def get_version(self, asset_id: str, version: str) -> AgentAssetVersion | None:
stmt = select(AgentAssetVersion).where( stmt = select(AgentAssetVersion).where(
AgentAssetVersion.asset_id == asset_id, AgentAssetVersion.asset_id == asset_id,

View File

@@ -28,6 +28,28 @@ class AuditLogRepository:
stmt = stmt.order_by(AuditLog.created_at.desc()).limit(limit) stmt = stmt.order_by(AuditLog.created_at.desc()).limit(limit)
return list(self.db.scalars(stmt).all()) 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: def create(self, log: AuditLog) -> AuditLog:
self.db.add(log) self.db.add(log)
self.db.commit() self.db.commit()

View File

@@ -93,6 +93,22 @@ class AgentAssetOnlyOfficeCallbackWrite(BaseModel):
users: list[str] = Field(default_factory=list, description="当前编辑用户列表。") 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): class AgentAssetVersionTimelineItemRead(BaseModel):
event_type: str event_type: str
version: str version: str
@@ -129,8 +145,10 @@ class AgentAssetVersionCompareRead(BaseModel):
class AgentAssetSpreadsheetChangeRecordRead(BaseModel): class AgentAssetSpreadsheetChangeRecordRead(BaseModel):
id: str
actor: str actor: str
changed_at: datetime changed_at: datetime
version: str | None = None
summary: str summary: str
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = Field(default_factory=list) sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = Field(default_factory=list)
cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = Field(default_factory=list) cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = Field(default_factory=list)
@@ -172,6 +190,8 @@ class AgentAssetListItem(BaseModel):
published_version: str | None published_version: str | None
working_version: str | None working_version: str | None
config_json: dict[str, Any] config_json: dict[str, Any]
change_count: int = 0
modified_by: str | None = None
created_at: datetime created_at: datetime
updated_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, actor_name: str,
source: str = "upload", source: str = "upload",
) -> RuleSpreadsheetMeta: ) -> RuleSpreadsheetMeta:
normalized_name = Path(str(file_name or "").strip()).name.strip() return self.store_rule_library_spreadsheet_snapshot(
if not normalized_name: library=FINANCE_RULES_LIBRARY,
raise ValueError("规则表文件名不能为空。") asset_id=asset_id,
if not content: version=version,
raise ValueError("规则表文件内容不能为空。") file_name=file_name,
content=content,
relative_path = Path("agent_assets") / asset_id / "rule_spreadsheets" / version / normalized_name actor_name=actor_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",
source=source, source=source,
) )
@@ -117,7 +104,74 @@ class AgentAssetSpreadsheetManager:
try: try:
target_path.relative_to(self.rule_root) target_path.relative_to(self.rule_root)
except ValueError: 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.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(content) target_path.write_bytes(content)
@@ -149,7 +203,7 @@ class AgentAssetSpreadsheetManager:
try: try:
resolved.relative_to(allowed_root) resolved.relative_to(allowed_root)
except ValueError: except ValueError:
raise FileNotFoundError("规则表文件不存在。") raise FileNotFoundError("规则表文件不存在。") from None
return resolved return resolved
@staticmethod @staticmethod
@@ -230,11 +284,46 @@ class AgentAssetSpreadsheetManager:
def build_company_travel_rule_template() -> bytes: def build_company_travel_rule_template() -> bytes:
standard_rows = [ standard_rows = [
["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"], ["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"],
["长途交通", "飞机、高铁、火车等跨城出行", "行程单、车票、发票", "据实报销", "超预算需直属领导审批", "优先选择公共交通"], [
["住宿费", "出差住宿", "酒店发票、入住清单", "一线城市 650/晚;二线城市 500/晚;其他城市 380/晚", "超标需总监审批", "协议酒店优先"], "长途交通",
["市内交通", "出租车、网约车、地铁、公交", "发票或电子行程单", "150/天", "超限需补充说明", "夜间或无公共交通场景可豁免"], "飞机、高铁、火车等跨城出行",
["餐补", "出差期间日常补助", "无需票据", "120/天", "系统自动核定", "当天往返默认不享受"], "行程单、车票、发票",
["招待餐费", "客户接待或项目宴请", "餐饮发票、参与人清单", "300/人", "需业务负责人审批", "需关联客户或项目"], "据实报销",
"超预算需直属领导审批",
"优先选择公共交通",
],
[
"住宿费",
"出差住宿",
"酒店发票、入住清单",
"一线城市 650/晚;二线城市 500/晚;其他城市 380/晚",
"超标需总监审批",
"协议酒店优先",
],
[
"市内交通",
"出租车、网约车、地铁、公交",
"发票或电子行程单",
"150/天",
"超限需补充说明",
"夜间或无公共交通场景可豁免",
],
[
"餐补",
"出差期间日常补助",
"无需票据",
"120/天",
"系统自动核定",
"当天往返默认不享受",
],
[
"招待餐费",
"客户接待或项目宴请",
"餐饮发票、参与人清单",
"300/人",
"需业务负责人审批",
"需关联客户或项目",
],
] ]
instruction_rows = [ 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: def _build_content_types_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
overrides = [ 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="/xl/workbook.xml" '
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>', 'ContentType="application/vnd.openxmlformats-officedocument.'
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>', '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( 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) for index, _ in enumerate(sheets, start=1)
] ]
) )
return ( return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' '<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"/>' '<Default Extension="xml" ContentType="application/xml"/>'
f'{"".join(overrides)}' f'{"".join(overrides)}'
"</Types>" "</Types>"
@@ -333,9 +442,15 @@ def _build_root_rels_xml() -> str:
return ( return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' '<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="rId1" '
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>' 'Type="http://schemas.openxmlformats.org/officeDocument/2006/'
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>' '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>" "</Relationships>"
) )
@@ -347,11 +462,16 @@ def _build_app_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
sheet_count = len(sheets) sheet_count = len(sheets)
return ( return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' '<?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">' 'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">'
'<Application>Microsoft Excel</Application>' '<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>" '<HeadingPairs><vt:vector size="2" baseType="variant">'
f"<TitlesOfParts><vt:vector size=\"{sheet_count}\" baseType=\"lpstr\">{titles}</vt:vector></TitlesOfParts>" "<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>" "</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: def _build_core_xml(created_at: str) -> str:
return ( return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' '<?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:dc="http://purl.org/dc/elements/1.1/" '
'xmlns:dcterms="http://purl.org/dc/terms/" ' 'xmlns:dcterms="http://purl.org/dc/terms/" '
'xmlns:dcmitype="http://purl.org/dc/dcmitype/" ' '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: def _build_workbook_rels_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
relationships = "".join( 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) 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"?>' '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>' '<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>' '<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' '<cellStyleXfs count="1">'
'<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>' '<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>' '<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
'</styleSheet>' '</styleSheet>'
) )

View File

@@ -10,7 +10,6 @@ from urllib.parse import quote
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
import jwt import jwt
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext 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_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import ( from app.services.agent_asset_spreadsheet import (
AgentAssetSpreadsheetManager,
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_CODE,
@@ -52,8 +50,9 @@ from app.services.agent_asset_spreadsheet import (
FINANCE_RULES_LIBRARY, FINANCE_RULES_LIBRARY,
RISK_RULES_LIBRARY, RISK_RULES_LIBRARY,
RULE_LIBRARY_NAMES, RULE_LIBRARY_NAMES,
RuleSpreadsheetMeta,
SPREADSHEET_MIME_TYPE, SPREADSHEET_MIME_TYPE,
AgentAssetSpreadsheetManager,
RuleSpreadsheetMeta,
) )
from app.services.agent_foundation import AgentFoundationService from app.services.agent_foundation import AgentFoundationService
from app.services.audit import AuditLogService from app.services.audit import AuditLogService
@@ -410,7 +409,8 @@ class AgentAssetService:
file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key) file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key)
if not file_path.exists(): if not file_path.exists():
raise FileNotFoundError(metadata.file_name) 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, asset_id=asset.id,
version=target_version, version=target_version,
file_name=metadata.file_name, file_name=metadata.file_name,
@@ -542,7 +542,11 @@ class AgentAssetService:
return file_path, metadata.mime_type, metadata.file_name return file_path, metadata.mime_type, metadata.file_name
asset = self._require_spreadsheet_rule(asset_id) 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) file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key)
if not file_path.exists(): if not file_path.exists():
raise FileNotFoundError(metadata.file_name) raise FileNotFoundError(metadata.file_name)
@@ -594,6 +598,14 @@ class AgentAssetService:
_, current_metadata = self._resolve_current_spreadsheet_meta(asset) _, current_metadata = self._resolve_current_spreadsheet_meta(asset)
file_name = current_metadata.file_name or self._resolve_default_spreadsheet_file_name(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( metadata = self._store_current_rule_spreadsheet(
asset, asset,
file_name=file_name, file_name=file_name,
@@ -601,6 +613,45 @@ class AgentAssetService:
actor=actor, actor=actor,
source=source, 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( self.audit_service.log_action(
actor=actor, actor=actor,
action="edit_rule_spreadsheet", action="edit_rule_spreadsheet",
@@ -608,12 +659,14 @@ class AgentAssetService:
resource_id=asset.id, resource_id=asset.id,
before_json={"storage_key": current_metadata.storage_key}, before_json={"storage_key": current_metadata.storage_key},
after_json={ after_json={
"summary": change_note or f"上传并覆盖当前规则表:{normalized_name}", "summary": summary,
"changed_sheet_count": 0, "version": next_version,
"changed_cell_count": 0, "changed_sheet_count": changed_sheet_count,
"sheet_changes": [], "changed_cell_count": changed_cell_count,
"cell_changes": [], "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, "storage_key": metadata.storage_key,
"snapshot_storage_key": snapshot_metadata.storage_key,
}, },
request_id=request_id, request_id=request_id,
) )
@@ -682,56 +735,16 @@ class AgentAssetService:
if current_metadata.checksum and current_metadata.checksum == self._hash_bytes(content): if current_metadata.checksum and current_metadata.checksum == self._hash_bytes(content):
return 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 ( resolved_actor_name = str(actor_name or "").strip() or (
callback.users[0] if callback.users else "ONLYOFFICE" callback.users[0] if callback.users else "ONLYOFFICE"
) )
self._store_current_rule_spreadsheet( self.upload_rule_spreadsheet(
asset, asset.id,
file_name=current_metadata.file_name, filename=current_metadata.file_name,
content=content, content=content,
actor=resolved_actor_name, actor=resolved_actor_name,
source="onlyoffice", 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: def _ensure_ready(self) -> None:
AgentFoundationService(self.db).ensure_foundation_ready() AgentFoundationService(self.db).ensure_foundation_ready()
@@ -782,7 +795,8 @@ class AgentAssetService:
if ( if (
asset.asset_type == AgentAssetType.RULE.value 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 "")) metadata = self.spreadsheet_manager.parse_version_markdown(str(source.content or ""))
if metadata is None: if metadata is None:
@@ -919,60 +933,30 @@ class AgentAssetService:
) -> AgentAssetVersionCompareRead: ) -> AgentAssetVersionCompareRead:
self._ensure_ready() self._ensure_ready()
asset = self._require_spreadsheet_rule(asset_id) asset = self._require_spreadsheet_rule(asset_id)
resolved_base, base_meta = self._resolve_spreadsheet_version_meta(asset, version=base_version) resolved_base, base_meta = self._resolve_spreadsheet_version_meta(
resolved_target, target_meta = self._resolve_spreadsheet_version_meta(asset, version=target_version) 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) base_workbook = self._load_spreadsheet_for_compare(base_meta)
target_workbook = self._load_spreadsheet_for_compare(target_meta) target_workbook = self._load_spreadsheet_for_compare(target_meta)
base_sheet_names = set(base_workbook.sheetnames) sheet_changes, cell_changes = self._collect_workbook_changes(
target_sheet_names = set(target_workbook.sheetnames) base_workbook,
target_workbook,
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = [] )
for sheet_name in sorted(target_sheet_names - base_sheet_names): added_sheet_count = sum(1 for item in sheet_changes if item.change_type == "added")
sheet_changes.append( removed_sheet_count = sum(1 for item in sheet_changes if item.change_type == "removed")
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,
)
)
return AgentAssetVersionCompareRead( return AgentAssetVersionCompareRead(
base_version=resolved_base, base_version=resolved_base,
target_version=resolved_target, target_version=resolved_target,
added_sheet_count=len(target_sheet_names - base_sheet_names), added_sheet_count=added_sheet_count,
removed_sheet_count=len(base_sheet_names - target_sheet_names), removed_sheet_count=removed_sheet_count,
changed_sheet_count=len(changed_sheets), changed_sheet_count=self._count_changed_sheets(sheet_changes, cell_changes),
changed_cell_count=len(cell_changes), changed_cell_count=len(cell_changes),
sheet_changes=sheet_changes, sheet_changes=sheet_changes,
cell_changes=cell_changes[:500], cell_changes=cell_changes[:500],
@@ -997,6 +981,7 @@ class AgentAssetService:
id=log.id, id=log.id,
actor=log.actor, actor=log.actor,
changed_at=log.created_at, 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 在线编辑保存。"), summary=str((log.after_json or {}).get("summary") or "ONLYOFFICE 在线编辑保存。"),
sheet_changes=[ sheet_changes=[
AgentAssetSpreadsheetDiffSheetRead.model_validate(item) AgentAssetSpreadsheetDiffSheetRead.model_validate(item)
@@ -1379,9 +1364,10 @@ class AgentAssetService:
file_name = PREVIEW_RULE_VERSION_FILENAMES[resolved_version] file_name = PREVIEW_RULE_VERSION_FILENAMES[resolved_version]
storage_key = ( storage_key = (
Path("agent_assets") Path("rules")
/ FINANCE_RULES_LIBRARY
/ ".versions"
/ PREVIEW_RULE_ASSET_ID / PREVIEW_RULE_ASSET_ID
/ "rule_spreadsheets"
/ resolved_version / resolved_version
/ file_name / file_name
).as_posix() ).as_posix()
@@ -1404,7 +1390,8 @@ class AgentAssetService:
source="preview", 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, asset_id=PREVIEW_RULE_ASSET_ID,
version=resolved_version, version=resolved_version,
file_name=file_name, file_name=file_name,
@@ -1436,7 +1423,8 @@ class AgentAssetService:
return return
actor_name = callback.users[0] if callback.users else "ONLYOFFICE" 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, asset_id=PREVIEW_RULE_ASSET_ID,
version=resolved_version, version=resolved_version,
file_name=metadata.file_name, file_name=metadata.file_name,
@@ -1482,7 +1470,10 @@ class AgentAssetService:
) -> bool: ) -> bool:
role_codes = {str(item).strip() for item in current_user.role_codes} 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 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 @staticmethod
def _can_edit_current_spreadsheet(current_user: CurrentUserContext) -> bool: def _can_edit_current_spreadsheet(current_user: CurrentUserContext) -> bool:
@@ -1495,7 +1486,8 @@ class AgentAssetService:
version: str, version: str,
metadata: RuleSpreadsheetMeta, metadata: RuleSpreadsheetMeta,
) -> str: ) -> 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( return "".join(
character if character.isalnum() or character in {"-", "_", ".", "="} else "_" character if character.isalnum() or character in {"-", "_", ".", "="} else "_"
for character in raw_key for character in raw_key
@@ -1570,6 +1562,7 @@ class AgentAssetService:
def _load_spreadsheet_for_compare(self, metadata: RuleSpreadsheetMeta): def _load_spreadsheet_for_compare(self, metadata: RuleSpreadsheetMeta):
from io import BytesIO from io import BytesIO
from openpyxl import load_workbook from openpyxl import load_workbook
file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key) file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key)
@@ -1577,6 +1570,19 @@ class AgentAssetService:
raise FileNotFoundError(metadata.file_name) raise FileNotFoundError(metadata.file_name)
return load_workbook(BytesIO(file_path.read_bytes()), read_only=False, data_only=False) 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( def _collect_workbook_changes(
self, base_workbook, target_workbook self, base_workbook, target_workbook
) -> tuple[list[AgentAssetSpreadsheetDiffSheetRead], list[AgentAssetSpreadsheetDiffCellRead]]: ) -> 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 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 @staticmethod
def _extract_restore_source_version(change_note: str | None) -> str | None: def _extract_restore_source_version(change_note: str | None) -> str | None:
normalized = str(change_note or "").strip() 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 from __future__ import annotations
import shutil
import uuid import uuid
from io import BytesIO from io import BytesIO
from pathlib import Path
import pytest import pytest
from openpyxl import Workbook, load_workbook from openpyxl import Workbook, load_workbook
@@ -9,6 +11,7 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext
from app.core.agent_enums import ( from app.core.agent_enums import (
AgentAssetContentType, AgentAssetContentType,
AgentAssetDomain, AgentAssetDomain,
@@ -19,13 +22,18 @@ from app.core.agent_enums import (
AgentRunSource, AgentRunSource,
AgentRunStatus, AgentRunStatus,
) )
from app.core.config import SERVER_DIR
from app.db.base import Base from app.db.base import Base
from app.schemas.agent_asset import ( from app.schemas.agent_asset import (
AgentAssetCreate, AgentAssetCreate,
AgentAssetReviewCreate, AgentAssetReviewCreate,
AgentAssetVersionCreate, 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_assets import AgentAssetService
from app.services.agent_runs import AgentRunService from app.services.agent_runs import AgentRunService
from app.services.audit import AuditLogService from app.services.audit import AuditLogService
@@ -33,6 +41,38 @@ from app.services.expense_rule_runtime import ExpenseRuleRuntimeService
from app.services.settings import OnlyOfficeRuntimeConfig 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: def build_session() -> Session:
engine = create_engine( engine = create_engine(
"sqlite+pysqlite:///:memory:", "sqlite+pysqlite:///:memory:",
@@ -55,6 +95,19 @@ def build_workbook_bytes(rows: list[list[object]], *, sheet_name: str = "规则
return buffer.getvalue() 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: def test_agent_asset_service_seeds_assets_and_enforces_review_before_activation() -> None:
with build_session() as db: with build_session() as db:
service = AgentAssetService(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) rules = service.list_assets(asset_type=AgentAssetType.RULE.value)
assert len(rules) >= 3 assert len(rules) >= 3
assert any( 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 for item in rules
) )
assert all( assert all(
@@ -218,7 +272,10 @@ def test_expense_rule_runtime_uses_published_version_instead_of_working_version(
rule.id, rule.id,
AgentAssetVersionCreate( AgentAssetVersionCreate(
version="v1.1.1", 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, content_type=AgentAssetContentType.MARKDOWN,
change_note="未上线草稿", change_note="未上线草稿",
created_by="finance_user", 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_sheet_count == 1
assert diff.changed_cell_count == 3 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) 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) current_asset = service.repository.get(rule.id)
assert current_asset is not None assert current_asset is not None
live_storage_key = str((current_asset.config_json or {})["rule_document"]["storage_key"]) 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) 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() original_live_bytes = live_path.read_bytes()
try: try:
live_path.write_bytes(build_workbook_bytes([["城市", "住宿"], ["北京", 999]])) 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 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) workbook = load_workbook(snapshot_path, data_only=False)
assert workbook.active["B2"].value == 500 assert workbook.active["B2"].value == 500
finally: finally:
@@ -366,6 +432,55 @@ def test_spreadsheet_change_records_return_recent_edit_details() -> None:
assert records[0].cell_changes[0].cell == "B2" 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: def test_editable_spreadsheet_onlyoffice_config_enables_forcesave(monkeypatch) -> None:
with build_session() as db: with build_session() as db:
monkeypatch.setattr( monkeypatch.setattr(

View File

@@ -136,6 +136,18 @@ export function fetchAgentAssetVersionTimeline(assetId) {
return apiRequest(`/agent-assets/${assetId}/version-timeline`) 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) { export function compareAgentAssetSpreadsheetVersions(assetId, baseVersion, targetVersion) {
const query = new URLSearchParams({ const query = new URLSearchParams({
base_version: String(baseVersion || '').trim(), base_version: String(baseVersion || '').trim(),

View File

@@ -165,7 +165,7 @@
<div v-if="selectedSpreadsheetChangeRecords.length" class="version-center-list"> <div v-if="selectedSpreadsheetChangeRecords.length" class="version-center-list">
<button <button
v-for="item in selectedSpreadsheetChangeRecords" v-for="item in selectedSpreadsheetChangeRecords"
:key="`spreadsheet-change-${item.changed_at}-${item.actor}`" :key="`spreadsheet-change-${item.id || item.changed_at}-${item.actor}`"
type="button" type="button"
class="version-center-item change-record-item" class="version-center-item change-record-item"
@click="openSpreadsheetChangeDetail(item)" @click="openSpreadsheetChangeDetail(item)"
@@ -175,9 +175,14 @@
<strong>{{ item.actor }}</strong> <strong>{{ item.actor }}</strong>
<span>{{ item.time }}</span> <span>{{ item.time }}</span>
</div> </div>
<b>{{ item.changed_cell_count }} 处改动</b> <b>{{ item.changeCountLabel }}</b>
</div> </div>
<p>{{ item.summary }}</p> <p>{{ item.summary }}</p>
<small v-if="item.version">关联版本{{ item.version }}</small>
<small v-if="item.sheetPreview.length">
涉及工作表{{ item.sheetPreview.join('') }}
<template v-if="item.remainingSheetCount"> {{ item.changedSheetNames.length }} </template>
</small>
<div v-if="item.previewChanges.length" class="change-record-preview"> <div v-if="item.previewChanges.length" class="change-record-preview">
<span <span
v-for="change in item.previewChanges" v-for="change in item.previewChanges"
@@ -1124,6 +1129,10 @@
<span>修改时间</span> <span>修改时间</span>
<strong>{{ selectedSpreadsheetChangeRecord.time }}</strong> <strong>{{ selectedSpreadsheetChangeRecord.time }}</strong>
</article> </article>
<article v-if="selectedSpreadsheetChangeRecord.version">
<span>关联版本</span>
<strong>{{ selectedSpreadsheetChangeRecord.version }}</strong>
</article>
<article> <article>
<span>修改工作表</span> <span>修改工作表</span>
<strong>{{ selectedSpreadsheetChangeRecord.changed_sheet_count }}</strong> <strong>{{ selectedSpreadsheetChangeRecord.changed_sheet_count }}</strong>
@@ -1154,7 +1163,7 @@
{{ item.sheet_name }} · {{ item.meta.label }} {{ item.sheet_name }} · {{ item.meta.label }}
</span> </span>
</div> </div>
<p v-else>本次没有新增或删除工作表</p> <p v-else>本次没有工作表级变化</p>
</section> </section>
<section class="compare-panel compare-cell-panel"> <section class="compare-panel compare-cell-panel">

View File

@@ -1695,12 +1695,30 @@ export default {
} }
return (spreadsheetChangeRecordsByAsset.value[selectedSkill.value.id] || []) return (spreadsheetChangeRecordsByAsset.value[selectedSkill.value.id] || [])
.filter((item) => item?.changed_at) .filter((item) => item?.changed_at)
.map((item) => ({ .map((item) => {
...item, const sheetNames = [
time: formatDateTime(item.changed_at), ...(Array.isArray(item.sheet_changes)
previewChanges: Array.isArray(item.cell_changes) ? item.cell_changes.slice(0, 3) : [], ? item.sheet_changes.map((change) => normalizeText(change.sheet_name))
remainingChangeCount: Math.max((item.changed_cell_count || 0) - 3, 0) : []),
})) ...(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(() => const selectedSpreadsheetChangeSheetRows = computed(() =>
Array.isArray(selectedSpreadsheetChangeRecord.value?.sheet_changes) Array.isArray(selectedSpreadsheetChangeRecord.value?.sheet_changes)
@@ -2133,13 +2151,24 @@ export default {
) )
.join('|') .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 [ return [
latest.id, latest.id,
latest.changed_at, latest.changed_at,
latest.actor, latest.actor,
latest.version,
latest.summary, latest.summary,
latest.changed_sheet_count, latest.changed_sheet_count,
latest.changed_cell_count, latest.changed_cell_count,
sheetSignature,
previewSignature previewSignature
] ]
.map((value) => normalizeText(value)) .map((value) => normalizeText(value))
@@ -2538,7 +2567,21 @@ export default {
loadSpreadsheetChangeRecords(assetId).catch(() => {}) loadSpreadsheetChangeRecords(assetId).catch(() => {})
} }
if (selectedSkill.value.usesJsonRiskRule) { 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) { } catch (error) {