fix(agent): 修复规则中心表格版本和修改记录
补齐规则资产 JSON 读写接口和前端调用,修复 AuditView 导入缺失。 Excel 在线编辑改为比对所有页签并生成最近修改记录,版本快照统一保存到 rules/finance-rules/.versions。 隔离规则表测试存储,避免测试或旧入口写入真实规则目录与 storage/agent_assets。
This commit is contained in:
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
84
server/src/app/services/agent_asset_rule_library.py
Normal file
84
server/src/app/services/agent_asset_rule_library.py
Normal 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())
|
||||
@@ -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>'
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
77
server/src/app/services/risk_ontology_bridge.py
Normal file
77
server/src/app/services/risk_ontology_bridge.py
Normal 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()
|
||||
@@ -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(
|
||||
|
||||
@@ -136,6 +136,18 @@ export function fetchAgentAssetVersionTimeline(assetId) {
|
||||
return apiRequest(`/agent-assets/${assetId}/version-timeline`)
|
||||
}
|
||||
|
||||
export function fetchAgentAssetRuleJson(assetId) {
|
||||
return apiRequest(`/agent-assets/${assetId}/rule-json`)
|
||||
}
|
||||
|
||||
export function saveAgentAssetRuleJson(assetId, payload, options = {}) {
|
||||
return apiRequest(`/agent-assets/${assetId}/rule-json`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
headers: buildWriteHeaders(options)
|
||||
})
|
||||
}
|
||||
|
||||
export function compareAgentAssetSpreadsheetVersions(assetId, baseVersion, targetVersion) {
|
||||
const query = new URLSearchParams({
|
||||
base_version: String(baseVersion || '').trim(),
|
||||
|
||||
@@ -165,7 +165,7 @@
|
||||
<div v-if="selectedSpreadsheetChangeRecords.length" class="version-center-list">
|
||||
<button
|
||||
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"
|
||||
class="version-center-item change-record-item"
|
||||
@click="openSpreadsheetChangeDetail(item)"
|
||||
@@ -175,9 +175,14 @@
|
||||
<strong>{{ item.actor }}</strong>
|
||||
<span>{{ item.time }}</span>
|
||||
</div>
|
||||
<b>{{ item.changed_cell_count }} 处改动</b>
|
||||
<b>{{ item.changeCountLabel }}</b>
|
||||
</div>
|
||||
<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">
|
||||
<span
|
||||
v-for="change in item.previewChanges"
|
||||
@@ -1124,6 +1129,10 @@
|
||||
<span>修改时间</span>
|
||||
<strong>{{ selectedSpreadsheetChangeRecord.time }}</strong>
|
||||
</article>
|
||||
<article v-if="selectedSpreadsheetChangeRecord.version">
|
||||
<span>关联版本</span>
|
||||
<strong>{{ selectedSpreadsheetChangeRecord.version }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>修改工作表</span>
|
||||
<strong>{{ selectedSpreadsheetChangeRecord.changed_sheet_count }}</strong>
|
||||
@@ -1154,7 +1163,7 @@
|
||||
{{ item.sheet_name }} · {{ item.meta.label }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-else>本次没有新增或删除工作表。</p>
|
||||
<p v-else>本次没有工作表级变化。</p>
|
||||
</section>
|
||||
|
||||
<section class="compare-panel compare-cell-panel">
|
||||
|
||||
@@ -1695,12 +1695,30 @@ export default {
|
||||
}
|
||||
return (spreadsheetChangeRecordsByAsset.value[selectedSkill.value.id] || [])
|
||||
.filter((item) => item?.changed_at)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
time: formatDateTime(item.changed_at),
|
||||
previewChanges: Array.isArray(item.cell_changes) ? item.cell_changes.slice(0, 3) : [],
|
||||
remainingChangeCount: Math.max((item.changed_cell_count || 0) - 3, 0)
|
||||
}))
|
||||
.map((item) => {
|
||||
const sheetNames = [
|
||||
...(Array.isArray(item.sheet_changes)
|
||||
? item.sheet_changes.map((change) => normalizeText(change.sheet_name))
|
||||
: []),
|
||||
...(Array.isArray(item.cell_changes)
|
||||
? item.cell_changes.map((change) => normalizeText(change.sheet_name))
|
||||
: [])
|
||||
].filter(Boolean)
|
||||
const changedSheetNames = [...new Set(sheetNames)]
|
||||
const previewChanges = Array.isArray(item.cell_changes) ? item.cell_changes.slice(0, 3) : []
|
||||
return {
|
||||
...item,
|
||||
time: formatDateTime(item.changed_at),
|
||||
changeCountLabel: item.changed_cell_count
|
||||
? `${item.changed_cell_count} 处改动`
|
||||
: `${item.changed_sheet_count || changedSheetNames.length || 0} 个工作表`,
|
||||
changedSheetNames,
|
||||
sheetPreview: changedSheetNames.slice(0, 4),
|
||||
remainingSheetCount: Math.max(changedSheetNames.length - 4, 0),
|
||||
previewChanges,
|
||||
remainingChangeCount: Math.max((item.changed_cell_count || 0) - previewChanges.length, 0)
|
||||
}
|
||||
})
|
||||
})
|
||||
const selectedSpreadsheetChangeSheetRows = computed(() =>
|
||||
Array.isArray(selectedSpreadsheetChangeRecord.value?.sheet_changes)
|
||||
@@ -2133,13 +2151,24 @@ export default {
|
||||
)
|
||||
.join('|')
|
||||
: ''
|
||||
const sheetSignature = Array.isArray(latest.sheet_changes)
|
||||
? latest.sheet_changes
|
||||
.map((item) =>
|
||||
[item?.sheet_name, item?.change_type]
|
||||
.map((value) => normalizeText(value))
|
||||
.join(':')
|
||||
)
|
||||
.join('|')
|
||||
: ''
|
||||
return [
|
||||
latest.id,
|
||||
latest.changed_at,
|
||||
latest.actor,
|
||||
latest.version,
|
||||
latest.summary,
|
||||
latest.changed_sheet_count,
|
||||
latest.changed_cell_count,
|
||||
sheetSignature,
|
||||
previewSignature
|
||||
]
|
||||
.map((value) => normalizeText(value))
|
||||
@@ -2538,7 +2567,21 @@ export default {
|
||||
loadSpreadsheetChangeRecords(assetId).catch(() => {})
|
||||
}
|
||||
if (selectedSkill.value.usesJsonRiskRule) {
|
||||
await loadRiskRuleJson(assetId)
|
||||
try {
|
||||
await loadRiskRuleJson(assetId)
|
||||
} catch (jsonError) {
|
||||
console.warn('Failed to load risk rule JSON:', jsonError)
|
||||
const jsonMessage =
|
||||
jsonError?.message || '风险规则 JSON 文件缺失或无法读取,请同步规则库后重试。'
|
||||
toast(jsonMessage)
|
||||
selectedSkill.value = {
|
||||
...selectedSkill.value,
|
||||
riskRuleJsonText: '{}',
|
||||
riskRuleDescription:
|
||||
selectedSkill.value.riskRuleDescription ||
|
||||
'规则 JSON 尚未就绪,请联系管理员执行平台风险规则同步。'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user