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,
|
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
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,
|
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>'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
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
|
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(
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user