From 55e0591a5e028603f086fadbd270f96b3d5039e0 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Mon, 18 May 2026 02:48:51 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20agent=5Fassets=20?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E6=9B=B4=E5=A4=9A?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/api/v1/endpoints/agent_assets.py | 277 +++++- server/src/app/models/agent_asset.py | 2 + server/src/app/schemas/agent_asset.py | 59 ++ server/src/app/services/agent_assets.py | 854 +++++++++++++++++- server/src/app/services/agent_foundation.py | 543 ++++++++--- web/src/services/agentAssets.js | 67 ++ 6 files changed, 1654 insertions(+), 148 deletions(-) diff --git a/server/src/app/api/v1/endpoints/agent_assets.py b/server/src/app/api/v1/endpoints/agent_assets.py index 0217e49..07f49db 100644 --- a/server/src/app/api/v1/endpoints/agent_assets.py +++ b/server/src/app/api/v1/endpoints/agent_assets.py @@ -2,19 +2,32 @@ from __future__ import annotations from typing import Annotated -from fastapi import APIRouter, Depends, Header, HTTPException, Query, status +from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, status +from fastapi.responses import FileResponse from sqlalchemy.orm import Session -from app.api.deps import get_db +from app.api.deps import ( + CurrentUserContext, + get_current_user, + get_db, + require_admin_user, + require_rule_editor_user, + require_rule_reviewer_user, +) from app.schemas.agent_asset import ( AgentAssetCreate, AgentAssetListItem, + AgentAssetOnlyOfficeCallbackRead, + AgentAssetOnlyOfficeCallbackWrite, + AgentAssetOnlyOfficeConfigRead, AgentAssetRead, AgentAssetReviewCreate, AgentAssetReviewRead, + AgentAssetVersionCompareRead, AgentAssetUpdate, AgentAssetVersionCreate, AgentAssetVersionRead, + AgentAssetVersionTimelineItemRead, ) from app.schemas.common import ErrorResponse from app.services.agent_assets import AgentAssetService @@ -29,6 +42,10 @@ RequestIdHeader = Annotated[ str | None, Header(description="外部请求 ID,用于串联审计日志和上游调用链。"), ] +CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)] +AdminUser = Annotated[CurrentUserContext, Depends(require_admin_user)] +RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)] +RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)] def _handle_asset_error(exc: Exception) -> None: @@ -93,6 +110,185 @@ def get_agent_asset(asset_id: str, db: DbSession) -> AgentAssetRead: return asset +@router.get( + "/{asset_id}/spreadsheet/onlyoffice-config", + response_model=AgentAssetOnlyOfficeConfigRead, + summary="读取规则 Excel 的 ONLYOFFICE 配置", + description="为规则详情页中的 Excel 规则表生成 ONLYOFFICE 配置。", +) +def get_agent_asset_spreadsheet_onlyoffice_config( + asset_id: str, + current_user: CurrentUser, + db: DbSession, + version: Annotated[ + str | None, + Query(description="可选的规则版本号;不传时默认当前版本。"), + ] = None, +) -> AgentAssetOnlyOfficeConfigRead: + try: + return AgentAssetService(db).build_rule_spreadsheet_onlyoffice_config( + asset_id, + current_user, + version=version, + ) + except Exception as exc: + _handle_asset_error(exc) + + +@router.get( + "/{asset_id}/spreadsheet/content", + response_class=FileResponse, + summary="下载或预览规则 Excel 文件", + description="按版本返回规则的 Excel 快照,用于浏览器预览或下载。", +) +def get_agent_asset_spreadsheet_content( + asset_id: str, + _: CurrentUser, + db: DbSession, + version: Annotated[ + str | None, + Query(description="可选的规则版本号;不传时默认当前版本。"), + ] = None, +) -> FileResponse: + try: + file_path, media_type, filename = AgentAssetService(db).get_rule_spreadsheet_content( + asset_id, + version=version, + ) + except Exception as exc: + _handle_asset_error(exc) + + return FileResponse(file_path, media_type=media_type, filename=filename) + + +@router.get( + "/{asset_id}/spreadsheet/onlyoffice/content", + response_class=FileResponse, + summary="供 ONLYOFFICE 读取规则 Excel 源文件", + description="使用短时令牌供 ONLYOFFICE 拉取规则表源文件。", +) +def get_agent_asset_spreadsheet_onlyoffice_content( + asset_id: str, + db: DbSession, + version: Annotated[ + str, + Query(min_length=1, description="规则版本号。"), + ], + access_token: Annotated[ + str, + Query(min_length=1, description="ONLYOFFICE 临时访问令牌。"), + ], +) -> FileResponse: + try: + service = AgentAssetService(db) + service.validate_rule_spreadsheet_access_token(asset_id, version, access_token) + file_path, media_type, filename = service.get_rule_spreadsheet_content( + asset_id, + version=version, + ) + except FileNotFoundError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc + except Exception as exc: + _handle_asset_error(exc) + + return FileResponse(file_path, media_type=media_type, filename=filename) + + +@router.post( + "/{asset_id}/spreadsheet/upload", + response_model=AgentAssetRead, + status_code=status.HTTP_201_CREATED, + summary="上传规则 Excel 文件", + description="为指定规则上传新的 Excel 快照,并自动生成新规则版本。", +) +def upload_agent_asset_spreadsheet( + asset_id: str, + content: Annotated[ + bytes, + Body( + media_type="application/octet-stream", + description="待上传的 Excel 文件二进制内容。", + ), + ], + filename: Annotated[str, Query(min_length=1, description="原始文件名。")], + current_user: RuleEditorUser, + db: DbSession, + x_request_id: RequestIdHeader = None, +) -> AgentAssetRead: + try: + return AgentAssetService(db).upload_rule_spreadsheet( + asset_id, + filename=filename, + content=content, + actor=current_user.name, + request_id=x_request_id, + ) + except Exception as exc: + _handle_asset_error(exc) + + +@router.post( + "/{asset_id}/spreadsheet/import-content", + response_model=AgentAssetRead, + status_code=status.HTTP_201_CREATED, + summary="导入规则 Excel 表格内容", + description="读取上传 Excel 中的工作表内容,写回当前规则表;保留当前规则文件名与规则身份。", +) +def import_agent_asset_spreadsheet_content( + asset_id: str, + content: Annotated[ + bytes, + Body( + media_type="application/octet-stream", + description="待导入的 Excel 文件二进制内容。", + ), + ], + filename: Annotated[str, Query(min_length=1, description="上传文件原始文件名。")], + current_user: RuleEditorUser, + db: DbSession, + x_request_id: RequestIdHeader = None, +) -> AgentAssetRead: + try: + return AgentAssetService(db).import_rule_spreadsheet_content( + asset_id, + filename=filename, + content=content, + actor=current_user.name, + request_id=x_request_id, + ) + except Exception as exc: + _handle_asset_error(exc) + + +@router.post( + "/{asset_id}/spreadsheet/onlyoffice/callback", + response_model=AgentAssetOnlyOfficeCallbackRead, + summary="接收规则 Excel 的 ONLYOFFICE 回调", + description="接收 ONLYOFFICE 回写内容,并自动生成新的规则版本。", +) +def handle_agent_asset_spreadsheet_onlyoffice_callback( + asset_id: str, + payload: AgentAssetOnlyOfficeCallbackWrite, + db: DbSession, + version: Annotated[ + str, + Query(min_length=1, description="打开编辑器时对应的规则版本号。"), + ], +) -> AgentAssetOnlyOfficeCallbackRead: + try: + AgentAssetService(db).handle_rule_spreadsheet_onlyoffice_callback( + asset_id, + version=version, + payload=payload.model_dump(), + ) + except Exception as exc: + _handle_asset_error(exc) + + return AgentAssetOnlyOfficeCallbackRead() + + @router.post( "", response_model=AgentAssetRead, @@ -237,11 +433,22 @@ def create_agent_asset_version( def create_agent_asset_review( asset_id: str, payload: AgentAssetReviewCreate, + current_user: CurrentUser, db: DbSession, x_actor: ActorHeader = None, x_request_id: RequestIdHeader = None, ) -> AgentAssetReviewRead: try: + role_codes = {item.strip() for item in current_user.role_codes} + if payload.review_status.value == "pending": + if not ( + current_user.is_admin + or "manager" in role_codes + or "finance" in role_codes + ): + raise PermissionError("只有财务人员或高级管理人员可以提交审核。") + elif not (current_user.is_admin or "manager" in role_codes): + raise PermissionError("只有高级管理人员可以审核规则。") return AgentAssetService(db).create_review( asset_id, payload, @@ -270,6 +477,7 @@ def create_agent_asset_review( ) def activate_agent_asset( asset_id: str, + _: RuleReviewerUser, db: DbSession, x_actor: ActorHeader = None, x_request_id: RequestIdHeader = None, @@ -282,3 +490,68 @@ def activate_agent_asset( ) except Exception as exc: _handle_asset_error(exc) + + +@router.post( + "/{asset_id}/versions/{version}/restore", + response_model=AgentAssetRead, + summary="基于历史版本恢复工作稿", + description="复制指定历史版本内容生成新的工作版本,用于误上线后的快速恢复与重新审核。", +) +def restore_agent_asset_version( + asset_id: str, + version: str, + current_user: RuleReviewerUser, + db: DbSession, + x_actor: ActorHeader = None, + x_request_id: RequestIdHeader = None, +) -> AgentAssetRead: + try: + return AgentAssetService(db).restore_version_as_working_copy( + asset_id, + version, + 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}/version-timeline", + response_model=list[AgentAssetVersionTimelineItemRead], + summary="读取规则版本流转时间线", + description="返回规则版本创建、提交审核、审核结果和正式上线等流转事件。", +) +def get_agent_asset_version_timeline( + asset_id: str, + _: CurrentUser, + db: DbSession, +) -> list[AgentAssetVersionTimelineItemRead]: + try: + return AgentAssetService(db).list_version_timeline(asset_id) + except Exception as exc: + _handle_asset_error(exc) + + +@router.get( + "/{asset_id}/versions/compare", + response_model=AgentAssetVersionCompareRead, + summary="比较两个规则表版本", + description="对比两个 Excel 规则表版本的工作表变化与单元格级差异。", +) +def compare_agent_asset_spreadsheet_versions( + asset_id: str, + _: CurrentUser, + db: DbSession, + base_version: Annotated[str, Query(min_length=1, description="基准版本号")], + target_version: Annotated[str, Query(min_length=1, description="对比版本号")], +) -> AgentAssetVersionCompareRead: + try: + return AgentAssetService(db).compare_spreadsheet_versions( + asset_id, + base_version=base_version, + target_version=target_version, + ) + except Exception as exc: + _handle_asset_error(exc) diff --git a/server/src/app/models/agent_asset.py b/server/src/app/models/agent_asset.py index 8002ff1..4ac1f45 100644 --- a/server/src/app/models/agent_asset.py +++ b/server/src/app/models/agent_asset.py @@ -25,6 +25,8 @@ class AgentAsset(Base): reviewer: Mapped[str | None] = mapped_column(String(100), nullable=True) status: Mapped[str] = mapped_column(String(20), index=True, default="draft") current_version: Mapped[str | None] = mapped_column(String(30), nullable=True) + published_version: Mapped[str | None] = mapped_column(String(30), nullable=True) + working_version: Mapped[str | None] = mapped_column(String(30), nullable=True) config_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( diff --git a/server/src/app/schemas/agent_asset.py b/server/src/app/schemas/agent_asset.py index 8f31b1e..c7b5366 100644 --- a/server/src/app/schemas/agent_asset.py +++ b/server/src/app/schemas/agent_asset.py @@ -36,6 +36,8 @@ class AgentAssetUpdate(BaseModel): reviewer: str | None = Field(default=None, max_length=100) status: AgentAssetStatus | None = None current_version: str | None = Field(default=None, max_length=30) + published_version: str | None = Field(default=None, max_length=30) + working_version: str | None = Field(default=None, max_length=30) config_json: dict[str, Any] | None = None @@ -74,6 +76,58 @@ class AgentAssetReviewRead(BaseModel): created_at: datetime +class AgentAssetOnlyOfficeConfigRead(BaseModel): + documentServerUrl: str + config: dict[str, Any] = Field(default_factory=dict) + + +class AgentAssetOnlyOfficeCallbackRead(BaseModel): + error: int = 0 + + +class AgentAssetOnlyOfficeCallbackWrite(BaseModel): + model_config = ConfigDict(extra="allow") + + status: int = Field(description="ONLYOFFICE 回调状态码。") + url: str | None = Field(default=None, description="文档下载地址,状态为 2 或 6 时使用。") + users: list[str] = Field(default_factory=list, description="当前编辑用户列表。") + + +class AgentAssetVersionTimelineItemRead(BaseModel): + event_type: str + version: str + actor: str + event_time: datetime + title: str + description: str = "" + note: str | None = None + source_version: str | None = None + + +class AgentAssetSpreadsheetDiffCellRead(BaseModel): + sheet_name: str + cell: str + change_type: str + before_value: Any | None = None + after_value: Any | None = None + + +class AgentAssetSpreadsheetDiffSheetRead(BaseModel): + sheet_name: str + change_type: str + + +class AgentAssetVersionCompareRead(BaseModel): + base_version: str + target_version: str + added_sheet_count: int = 0 + removed_sheet_count: int = 0 + changed_sheet_count: int = 0 + changed_cell_count: int = 0 + sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = Field(default_factory=list) + cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = Field(default_factory=list) + + class AgentAssetVersionRead(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -86,6 +140,9 @@ class AgentAssetVersionRead(BaseModel): created_by: str created_at: datetime is_current: bool = False + is_published: bool = False + is_working: bool = False + lifecycle_state: str = "history" class AgentAssetListItem(BaseModel): @@ -102,6 +159,8 @@ class AgentAssetListItem(BaseModel): reviewer: str | None status: str current_version: str | None + published_version: str | None + working_version: str | None config_json: dict[str, Any] created_at: datetime updated_at: datetime diff --git a/server/src/app/services/agent_assets.py b/server/src/app/services/agent_assets.py index a2a320f..eca7aa9 100644 --- a/server/src/app/services/agent_assets.py +++ b/server/src/app/services/agent_assets.py @@ -1,41 +1,77 @@ from __future__ import annotations import json +from dataclasses import dataclass from datetime import UTC, datetime +from pathlib import Path from typing import Any +from urllib.request import Request, urlopen + +import jwt from sqlalchemy.orm import Session +from app.api.deps import CurrentUserContext from app.core.agent_enums import ( AgentAssetContentType, AgentAssetStatus, AgentAssetType, AgentReviewStatus, ) +from app.core.config import get_settings from app.core.logging import get_logger from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion from app.repositories.agent_asset import AgentAssetRepository from app.schemas.agent_asset import ( AgentAssetCreate, AgentAssetListItem, + AgentAssetOnlyOfficeConfigRead, AgentAssetRead, AgentAssetReviewCreate, AgentAssetReviewRead, + AgentAssetSpreadsheetDiffCellRead, + AgentAssetSpreadsheetDiffSheetRead, AgentAssetUpdate, + AgentAssetVersionCompareRead, AgentAssetVersionCreate, AgentAssetVersionRead, + AgentAssetVersionTimelineItemRead, +) +from app.services.agent_asset_spreadsheet import ( + AgentAssetSpreadsheetManager, + COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, + RULE_LIBRARY_NAMES, + RuleSpreadsheetMeta, + SPREADSHEET_MIME_TYPE, ) from app.services.agent_foundation import AgentFoundationService from app.services.audit import AuditLogService +from app.services.settings import resolve_onlyoffice_settings logger = get_logger("app.services.agent_assets") +PREVIEW_RULE_ASSET_ID = "preview-rule-expense-company-travel-expense" +PREVIEW_RULE_CURRENT_VERSION = "v1.2.0" +PREVIEW_RULE_VERSION_FILENAMES = { + PREVIEW_RULE_CURRENT_VERSION: COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, + "v1.1.0": "公司差旅费报销规则-v1.1.0.xlsx", + "v1.0.0": "公司差旅费报销规则-v1.0.0.xlsx", +} + + +@dataclass(slots=True) +class OnlyOfficeCallbackPayload: + status: int + download_url: str + users: list[str] + class AgentAssetService: def __init__(self, db: Session) -> None: self.db = db self.repository = AgentAssetRepository(db) self.audit_service = AuditLogService(db) + self.spreadsheet_manager = AgentAssetSpreadsheetManager() def list_assets( self, @@ -57,14 +93,19 @@ class AgentAssetService: if asset is None: return None + working_version = self._resolve_working_version(asset) recent_versions = self._sort_versions( self.repository.list_versions(asset_id, limit=5), - asset.current_version, + working_version, + ) + latest_review = ( + self.repository.get_review(asset_id, working_version) + if working_version + else next(iter(self.repository.list_reviews(asset_id, limit=1)), None) ) - latest_review = next(iter(self.repository.list_reviews(asset_id, limit=1)), None) current_version = ( - self.repository.get_version(asset_id, asset.current_version) - if asset.current_version + self.repository.get_version(asset_id, working_version) + if working_version else None ) return AgentAssetRead( @@ -75,7 +116,7 @@ class AgentAssetService: current_version_content_type=current_version.content_type if current_version else None, current_version_change_note=current_version.change_note if current_version else None, recent_versions=[ - self._serialize_version(item, asset.current_version) for item in recent_versions + self._serialize_version(item, asset) for item in recent_versions ], latest_review=AgentAssetReviewRead.model_validate(latest_review) if latest_review @@ -144,6 +185,8 @@ class AgentAssetService: "owner", "reviewer", "current_version", + "published_version", + "working_version", "config_json", "scenario_json", ): @@ -159,6 +202,18 @@ class AgentAssetService: asset_id, payload.current_version ): raise LookupError(f"版本 {payload.current_version} 不存在") + if payload.published_version is not None and not self.repository.get_version( + asset_id, payload.published_version + ): + raise LookupError(f"版本 {payload.published_version} 不存在") + if payload.working_version is not None and not self.repository.get_version( + asset_id, payload.working_version + ): + raise LookupError(f"版本 {payload.working_version} 不存在") + if payload.current_version is not None and payload.working_version is None: + asset.working_version = payload.current_version + if payload.working_version is not None: + asset.current_version = payload.working_version updated = self.repository.save_asset(asset) self.audit_service.log_action( @@ -180,9 +235,9 @@ class AgentAssetService: raise LookupError("Asset not found") versions = self._sort_versions( self.repository.list_versions(asset_id, limit=limit), - asset.current_version, + self._resolve_working_version(asset), ) - return [self._serialize_version(item, asset.current_version) for item in versions] + return [self._serialize_version(item, asset) for item in versions] def create_version( self, @@ -212,12 +267,8 @@ class AgentAssetService: created = self.repository.create_version(version) before = self._asset_snapshot(asset) + asset.working_version = payload.version asset.current_version = payload.version - if ( - asset.asset_type == AgentAssetType.RULE.value - and asset.status == AgentAssetStatus.ACTIVE.value - ): - asset.status = AgentAssetStatus.REVIEW.value updated_asset = self.repository.save_asset(asset) self.audit_service.log_action( @@ -228,12 +279,14 @@ class AgentAssetService: before_json=before, after_json={ "current_version": updated_asset.current_version, + "working_version": updated_asset.working_version, + "published_version": updated_asset.published_version, "status": updated_asset.status, }, request_id=request_id, ) logger.info("Created agent asset version asset_id=%s version=%s", asset_id, payload.version) - return self._serialize_version(created, updated_asset.current_version) + return self._serialize_version(created, updated_asset) def create_review( self, @@ -249,6 +302,10 @@ class AgentAssetService: raise LookupError("Asset not found") if self.repository.get_version(asset_id, payload.version) is None: raise LookupError(f"版本 {payload.version} 不存在") + if asset.asset_type == AgentAssetType.RULE.value: + working_version = self._resolve_working_version(asset) + if payload.version != working_version: + raise ValueError("只能对当前工作版本发起审核。") review = AgentAssetReview( asset_id=asset_id, @@ -265,10 +322,12 @@ class AgentAssetService: before = self._asset_snapshot(asset) asset.reviewer = payload.reviewer if payload.review_status == AgentReviewStatus.PENDING: - asset.status = AgentAssetStatus.REVIEW.value + if not asset.published_version: + asset.status = AgentAssetStatus.REVIEW.value elif payload.review_status == AgentReviewStatus.REJECTED: - asset.status = AgentAssetStatus.DRAFT.value - elif asset.status != AgentAssetStatus.ACTIVE.value: + if not asset.published_version: + asset.status = AgentAssetStatus.DRAFT.value + elif not asset.published_version: asset.status = AgentAssetStatus.REVIEW.value self.repository.save_asset(asset) @@ -304,17 +363,19 @@ class AgentAssetService: asset = self.repository.get(asset_id) if asset is None: raise LookupError("Asset not found") - if not asset.current_version: - raise ValueError("资产尚未设置当前版本,无法上线。") + candidate_version = self._resolve_working_version(asset) + if not candidate_version: + raise ValueError("资产尚未设置工作版本,无法上线。") if asset.asset_type == AgentAssetType.RULE.value: review = self.repository.get_review( - asset.id, asset.current_version, AgentReviewStatus.APPROVED.value + asset.id, candidate_version, AgentReviewStatus.APPROVED.value ) if review is None: - raise PermissionError("规则当前版本尚未审核通过,不能上线。") + raise PermissionError("规则工作版本尚未审核通过,不能上线。") before = self._asset_snapshot(asset) + asset.published_version = candidate_version asset.status = AgentAssetStatus.ACTIVE.value updated = self.repository.save_asset(asset) self.audit_service.log_action( @@ -329,6 +390,229 @@ class AgentAssetService: logger.info("Activated agent asset id=%s code=%s", updated.id, updated.code) return self.get_asset(updated.id) # type: ignore[return-value] + def build_rule_spreadsheet_onlyoffice_config( + self, + asset_id: str, + current_user: CurrentUserContext, + *, + version: str | None = None, + ) -> AgentAssetOnlyOfficeConfigRead: + self._ensure_ready() + if asset_id == PREVIEW_RULE_ASSET_ID: + resolved_version, metadata = self._ensure_preview_rule_spreadsheet(version=version) + return self._build_onlyoffice_spreadsheet_config( + asset_id=asset_id, + current_user=current_user, + resolved_version=resolved_version, + metadata=metadata, + editable=resolved_version == PREVIEW_RULE_CURRENT_VERSION, + ) + + asset = self._require_spreadsheet_rule(asset_id) + resolved_version, metadata = self._resolve_spreadsheet_version_meta(asset, version=version) + editable = self._can_edit_spreadsheet_version(asset, current_user, resolved_version) + return self._build_onlyoffice_spreadsheet_config( + asset_id=asset.id, + current_user=current_user, + resolved_version=resolved_version, + metadata=metadata, + editable=editable, + ) + + def get_rule_spreadsheet_content( + self, + asset_id: str, + *, + version: str | None = None, + ) -> tuple[Path, str, str]: + self._ensure_ready() + if asset_id == PREVIEW_RULE_ASSET_ID: + _, metadata = self._ensure_preview_rule_spreadsheet(version=version) + file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key) + if not file_path.exists(): + raise FileNotFoundError(metadata.file_name) + return file_path, metadata.mime_type, metadata.file_name + + asset = self._require_spreadsheet_rule(asset_id) + _, metadata = self._resolve_spreadsheet_version_meta(asset, version=version) + file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key) + if not file_path.exists(): + raise FileNotFoundError(metadata.file_name) + return file_path, metadata.mime_type, metadata.file_name + + def validate_rule_spreadsheet_access_token( + self, + asset_id: str, + version: str, + access_token: str, + ) -> None: + onlyoffice_settings = resolve_onlyoffice_settings() + try: + payload = jwt.decode( + access_token, + onlyoffice_settings.jwt_secret, + algorithms=["HS256"], + ) + except jwt.PyJWTError as exc: + raise ValueError("ONLYOFFICE 文件访问令牌无效。") from exc + + if ( + payload.get("scope") != "agent-asset-spreadsheet" + or payload.get("asset_id") != asset_id + or payload.get("version") != version + ): + raise ValueError("ONLYOFFICE 文件访问令牌无效。") + + def upload_rule_spreadsheet( + self, + asset_id: str, + *, + filename: str, + content: bytes, + actor: str, + request_id: str | None = None, + change_note: str | None = None, + source: str = "upload", + ) -> AgentAssetRead: + self._ensure_ready() + asset = self._require_spreadsheet_rule(asset_id) + normalized_name = Path(str(filename or "").strip()).name.strip() + if not normalized_name: + raise ValueError("规则表文件名不能为空。") + if Path(normalized_name).suffix.lower() != ".xlsx": + raise ValueError("当前仅支持上传 .xlsx 格式的规则表。") + if not content: + raise ValueError("规则表文件内容不能为空。") + + next_version = self._increment_version(self._resolve_working_version(asset)) + metadata = self.spreadsheet_manager.store_spreadsheet( + asset_id=asset.id, + version=next_version, + file_name=normalized_name, + content=content, + actor_name=actor, + source=source, + ) + markdown = self.spreadsheet_manager.build_version_markdown( + rule_name=asset.name, + version=next_version, + metadata=metadata, + ) + self.create_version( + asset.id, + AgentAssetVersionCreate( + version=next_version, + content=markdown, + content_type=AgentAssetContentType.MARKDOWN, + change_note=change_note or f"上传 Excel 规则表:{normalized_name}", + created_by=actor, + ), + actor=actor, + request_id=request_id, + ) + + refreshed = self.repository.get(asset.id) + if refreshed is None: + raise LookupError("Asset not found") + + config_json = dict(refreshed.config_json or {}) + config_json["detail_mode"] = "spreadsheet" + config_json["tag"] = str(config_json.get("tag") or "财务规则").strip() or "财务规则" + current_document_meta = metadata + rule_library = str(config_json.get("rule_library") or "").strip() + if rule_library in RULE_LIBRARY_NAMES: + current_document_meta = self.spreadsheet_manager.store_rule_library_spreadsheet( + library=rule_library, + file_name=normalized_name, + content=content, + actor_name=actor, + source=source, + ) + rule_document = self.spreadsheet_manager.build_rule_document_config( + current_document_meta, + asset_version=next_version, + ) + rule_document["storage_key"] = current_document_meta.storage_key + config_json["rule_document"] = rule_document + refreshed.config_json = config_json + self.repository.save_asset(refreshed) + return self.get_asset(asset.id) # type: ignore[return-value] + + def import_rule_spreadsheet_content( + self, + asset_id: str, + *, + filename: str, + content: bytes, + actor: str, + request_id: str | None = None, + ) -> AgentAssetRead: + self._ensure_ready() + asset = self._require_spreadsheet_rule(asset_id) + normalized_name = Path(str(filename or "").strip()).name.strip() + if not normalized_name: + raise ValueError("待导入表格文件名不能为空。") + if Path(normalized_name).suffix.lower() != ".xlsx": + raise ValueError("当前仅支持导入 .xlsx 格式的规则表。") + + _, current_metadata = self._resolve_spreadsheet_version_meta( + asset, + version=self._resolve_working_version(asset), + ) + imported_content = self.spreadsheet_manager.rebuild_from_uploaded_content(content) + return self.upload_rule_spreadsheet( + asset.id, + filename=current_metadata.file_name, + content=imported_content, + actor=actor, + request_id=request_id, + change_note=f"导入 Excel 表格内容:{normalized_name}", + source="content-import", + ) + + def handle_rule_spreadsheet_onlyoffice_callback( + self, + asset_id: str, + *, + version: str, + payload: dict[str, Any], + ) -> None: + self._ensure_ready() + if asset_id == PREVIEW_RULE_ASSET_ID: + self._handle_preview_rule_spreadsheet_onlyoffice_callback( + version=version, + payload=payload, + ) + return + + asset = self._require_spreadsheet_rule(asset_id) + callback = self._parse_onlyoffice_callback(payload) + if callback.status not in {2, 6} or not callback.download_url: + return + if self._resolve_working_version(asset) != str(version or "").strip(): + return + + _, current_metadata = self._resolve_spreadsheet_version_meta(asset, version=version) + request = Request( + callback.download_url, + headers={"User-Agent": "x-financial-onlyoffice-agent-asset"}, + ) + with urlopen(request, timeout=30) as response: # noqa: S310 + content = response.read() + + if current_metadata.checksum and current_metadata.checksum == self._hash_bytes(content): + return + + actor_name = callback.users[0] if callback.users else "ONLYOFFICE" + self.upload_rule_spreadsheet( + asset.id, + filename=current_metadata.file_name, + content=content, + actor=actor_name, + change_note="ONLYOFFICE 编辑保存规则表。", + source="onlyoffice", + ) + def _ensure_ready(self) -> None: AgentFoundationService(self.db).ensure_foundation_ready() @@ -354,9 +638,227 @@ class AgentAssetService: ): raise ValueError("JSON 内容必须是对象或数组。") + def restore_version_as_working_copy( + self, + asset_id: str, + source_version: str, + *, + actor: str, + request_id: str | None = None, + ) -> AgentAssetRead: + self._ensure_ready() + asset = self.repository.get(asset_id) + if asset is None: + raise LookupError("Asset not found") + + source = self.repository.get_version(asset_id, source_version) + if source is None: + raise LookupError(f"版本 {source_version} 不存在") + + if ( + asset.asset_type == AgentAssetType.RULE.value + 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: + raise FileNotFoundError("历史规则表快照不存在,无法恢复。") + file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key) + if not file_path.exists(): + raise FileNotFoundError(metadata.file_name) + restored = self.upload_rule_spreadsheet( + asset.id, + filename=metadata.file_name, + content=file_path.read_bytes(), + actor=actor, + request_id=request_id, + change_note=f"基于历史版本 {source_version} 恢复生成工作稿", + source="restore", + ) + self.audit_service.log_action( + actor=actor, + action="restore_agent_asset_version", + resource_type=asset.asset_type, + resource_id=asset.id, + before_json={"source_version": source_version}, + after_json={"working_version": restored.working_version}, + request_id=request_id, + ) + return restored + + next_version = self._increment_version(self._resolve_working_version(asset)) + self.create_version( + asset.id, + AgentAssetVersionCreate( + version=next_version, + content=self._deserialize_content(source), + content_type=AgentAssetContentType(source.content_type), + change_note=f"基于历史版本 {source_version} 恢复生成工作稿", + created_by=actor, + ), + actor=actor, + request_id=request_id, + ) + restored = self.get_asset(asset.id) + self.audit_service.log_action( + actor=actor, + action="restore_agent_asset_version", + resource_type=asset.asset_type, + resource_id=asset.id, + before_json={"source_version": source_version}, + after_json={"working_version": next_version}, + request_id=request_id, + ) + return restored # type: ignore[return-value] + + def list_version_timeline(self, asset_id: str) -> list[AgentAssetVersionTimelineItemRead]: + self._ensure_ready() + asset = self.repository.get(asset_id) + if asset is None: + raise LookupError("Asset not found") + + events: list[AgentAssetVersionTimelineItemRead] = [] + versions = self.repository.list_versions(asset_id) + for version in versions: + source_version = self._extract_restore_source_version(version.change_note) + events.append( + AgentAssetVersionTimelineItemRead( + event_type="restored" if source_version else "created", + version=version.version, + actor=version.created_by, + event_time=version.created_at, + title="恢复生成工作稿" if source_version else "创建工作版本", + description=version.change_note or "生成新版本", + note=version.change_note, + source_version=source_version, + ) + ) + + for review in self.repository.list_reviews(asset_id): + event_type = { + AgentReviewStatus.PENDING.value: "submitted", + AgentReviewStatus.APPROVED.value: "approved", + AgentReviewStatus.REJECTED.value: "rejected", + }.get(review.review_status, "reviewed") + title = { + "submitted": "提交审核", + "approved": "审核通过", + "rejected": "审核驳回", + }.get(event_type, "审核处理") + events.append( + AgentAssetVersionTimelineItemRead( + event_type=event_type, + version=review.version, + actor=review.reviewer, + event_time=review.reviewed_at or review.created_at, + title=title, + description=review.review_note or "", + note=review.review_note, + ) + ) + + audit_logs = self.audit_service.repository.list( + resource_type=asset.asset_type, + resource_id=asset.id, + limit=200, + ) + for log in audit_logs: + if log.action != "activate_agent_asset": + continue + after_json = log.after_json or {} + version = str( + after_json.get("published_version") + or after_json.get("current_version") + or "" + ).strip() + if not version: + continue + events.append( + AgentAssetVersionTimelineItemRead( + event_type="published", + version=version, + actor=log.actor, + event_time=log.created_at, + title="正式上线", + description="该版本已切换为线上正式版本。", + ) + ) + + return sorted(events, key=lambda item: item.event_time) + + def compare_spreadsheet_versions( + self, + asset_id: str, + *, + base_version: str, + target_version: str, + ) -> 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) + + 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, + ) + ) + + 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), + changed_cell_count=len(cell_changes), + sheet_changes=sheet_changes, + cell_changes=cell_changes[:500], + ) + def _serialize_version( - self, version: AgentAssetVersion, current_version: str | None + self, version: AgentAssetVersion, asset: AgentAsset ) -> AgentAssetVersionRead: + latest_review = self.repository.get_review(asset.id, version.version) + working_version = self._resolve_working_version(asset) + published_version = self._resolve_published_version(asset) return AgentAssetVersionRead( id=version.id, asset_id=version.asset_id, @@ -366,7 +868,15 @@ class AgentAssetService: change_note=version.change_note, created_by=version.created_by, created_at=version.created_at, - is_current=version.version == current_version, + is_current=version.version == working_version, + is_published=version.version == published_version, + is_working=version.version == working_version, + lifecycle_state=self._resolve_version_lifecycle_state( + version.version, + working_version=working_version, + published_version=published_version, + latest_review_status=latest_review.review_status if latest_review else "", + ), ) @staticmethod @@ -393,6 +903,258 @@ class AgentAssetService: return version.content return json.loads(version.content) + def _require_spreadsheet_rule(self, asset_id: str) -> AgentAsset: + asset = self.repository.get(asset_id) + if asset is None: + raise LookupError("Asset not found") + if asset.asset_type != AgentAssetType.RULE.value: + raise ValueError("仅规则资产支持 Excel 规则表。") + detail_mode = str((asset.config_json or {}).get("detail_mode") or "").strip().lower() + if detail_mode != "spreadsheet": + raise ValueError("当前规则未配置 Excel 规则表。") + return asset + + def _resolve_spreadsheet_version_meta( + self, + asset: AgentAsset, + *, + version: str | None = None, + ) -> tuple[str, RuleSpreadsheetMeta]: + resolved_version = str(version or self._resolve_working_version(asset) or "").strip() + if not resolved_version: + raise ValueError("当前规则尚未配置表格版本。") + + version_row = self.repository.get_version(asset.id, resolved_version) + if version_row is None: + raise LookupError(f"版本 {resolved_version} 不存在") + + # 版本记录中的快照才是不变的事实来源。`/rules` 下的工作簿只是当前 + # 可编辑副本,后续写入不应该反向污染某个已存在版本的内容。 + metadata = self.spreadsheet_manager.parse_version_markdown(str(version_row.content or "")) + if metadata is None and self._resolve_working_version(asset) == resolved_version: + metadata = self._read_current_rule_document_meta(asset) + if metadata is None: + raise FileNotFoundError("规则表版本快照不存在。") + return resolved_version, metadata + + def _build_onlyoffice_spreadsheet_config( + self, + *, + asset_id: str, + current_user: CurrentUserContext, + resolved_version: str, + metadata: RuleSpreadsheetMeta, + editable: bool, + ) -> AgentAssetOnlyOfficeConfigRead: + onlyoffice_settings = resolve_onlyoffice_settings() + settings = get_settings() + if not onlyoffice_settings.enabled: + raise ValueError("ONLYOFFICE 预览未启用。") + if not onlyoffice_settings.public_url or not onlyoffice_settings.backend_url: + raise ValueError("ONLYOFFICE 地址配置不完整。") + if not onlyoffice_settings.jwt_secret: + raise ValueError("ONLYOFFICE JWT 密钥未配置。") + + backend_base_url = onlyoffice_settings.backend_url.rstrip("/") + public_url = onlyoffice_settings.public_url.rstrip("/") + access_token = self._build_onlyoffice_access_token(asset_id, resolved_version) + document_url = ( + f"{backend_base_url}{settings.api_v1_prefix}/agent-assets/{asset_id}/spreadsheet/onlyoffice/content" + f"?version={resolved_version}&access_token={access_token}" + ) + callback_url = ( + f"{backend_base_url}{settings.api_v1_prefix}/agent-assets/{asset_id}/spreadsheet/onlyoffice/callback" + f"?version={resolved_version}" + ) + + config: dict[str, Any] = { + "documentType": "cell", + "document": { + "fileType": Path(metadata.file_name).suffix.lstrip(".").lower() or "xlsx", + "key": self._build_onlyoffice_document_key(asset_id, resolved_version, metadata), + "title": metadata.file_name, + "url": document_url, + "permissions": { + "download": True, + "edit": editable, + "print": True, + "copy": True, + }, + }, + "editorConfig": { + "mode": "edit" if editable else "view", + "lang": "zh-CN", + "callbackUrl": callback_url, + "user": { + "id": current_user.username, + "name": current_user.name, + }, + "customization": { + "compactHeader": True, + "compactToolbar": False, + "toolbarNoTabs": False, + "autosave": False, + "forcesave": False, + }, + }, + "width": "100%", + "height": "100%", + } + config["token"] = jwt.encode(config, onlyoffice_settings.jwt_secret, algorithm="HS256") + return AgentAssetOnlyOfficeConfigRead(documentServerUrl=public_url, config=config) + + def _ensure_preview_rule_spreadsheet( + self, + *, + version: str | None = None, + ) -> tuple[str, RuleSpreadsheetMeta]: + resolved_version = str(version or PREVIEW_RULE_CURRENT_VERSION).strip() + if resolved_version not in PREVIEW_RULE_VERSION_FILENAMES: + raise LookupError(f"版本 {resolved_version} 不存在") + + file_name = PREVIEW_RULE_VERSION_FILENAMES[resolved_version] + storage_key = ( + Path("agent_assets") + / PREVIEW_RULE_ASSET_ID + / "rule_spreadsheets" + / resolved_version + / file_name + ).as_posix() + try: + file_path = self.spreadsheet_manager.resolve_storage_path(storage_key) + except FileNotFoundError: + file_path = None + + if file_path is not None and file_path.exists(): + content = file_path.read_bytes() + updated_at = datetime.fromtimestamp(file_path.stat().st_mtime, UTC).isoformat() + return resolved_version, RuleSpreadsheetMeta( + file_name=file_name, + storage_key=storage_key, + mime_type=SPREADSHEET_MIME_TYPE, + size_bytes=file_path.stat().st_size, + checksum=self._hash_bytes(content), + updated_at=updated_at, + updated_by="ONLYOFFICE 预览", + source="preview", + ) + + metadata = self.spreadsheet_manager.store_spreadsheet( + asset_id=PREVIEW_RULE_ASSET_ID, + version=resolved_version, + file_name=file_name, + content=AgentAssetSpreadsheetManager.build_company_travel_rule_template(), + actor_name="ONLYOFFICE 预览", + source="preview", + ) + return resolved_version, metadata + + def _handle_preview_rule_spreadsheet_onlyoffice_callback( + self, + *, + version: str, + payload: dict[str, Any], + ) -> None: + callback = self._parse_onlyoffice_callback(payload) + if callback.status not in {2, 6} or not callback.download_url: + return + + resolved_version, metadata = self._ensure_preview_rule_spreadsheet(version=version) + request = Request( + callback.download_url, + headers={"User-Agent": "x-financial-onlyoffice-agent-asset-preview"}, + ) + with urlopen(request, timeout=30) as response: # noqa: S310 + content = response.read() + + if metadata.checksum and metadata.checksum == self._hash_bytes(content): + return + + actor_name = callback.users[0] if callback.users else "ONLYOFFICE" + self.spreadsheet_manager.store_spreadsheet( + asset_id=PREVIEW_RULE_ASSET_ID, + version=resolved_version, + file_name=metadata.file_name, + content=content, + actor_name=actor_name, + source="onlyoffice-preview", + ) + + @staticmethod + def _read_current_rule_document_meta(asset: AgentAsset) -> RuleSpreadsheetMeta | None: + payload = (asset.config_json or {}).get("rule_document") + if not isinstance(payload, dict): + return None + + return RuleSpreadsheetMeta( + file_name=str(payload.get("file_name") or "").strip(), + storage_key=str(payload.get("storage_key") or "").strip(), + mime_type=( + str(payload.get("mime_type") or "").strip() + or "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + size_bytes=int(payload.get("size_bytes") or 0), + checksum=str(payload.get("checksum") or "").strip(), + updated_at=str(payload.get("updated_at") or "").strip(), + updated_by=str(payload.get("updated_by") or "system").strip() or "system", + source=str(payload.get("source") or "upload").strip() or "upload", + ) + + @staticmethod + def _increment_version(version: str | None) -> str: + normalized = str(version or "").strip().removeprefix("v") + parts = normalized.split(".") + if len(parts) != 3 or not all(item.isdigit() for item in parts): + return "v1.0.0" + major, minor, patch = [int(item) for item in parts] + return f"v{major}.{minor}.{patch + 1}" + + @staticmethod + def _can_edit_spreadsheet_version( + asset: AgentAsset, + current_user: CurrentUserContext, + version: str, + ) -> 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() + + @staticmethod + def _build_onlyoffice_document_key( + asset_id: str, + version: str, + metadata: RuleSpreadsheetMeta, + ) -> str: + raw_key = f"{asset_id}-{version}-{metadata.checksum or metadata.updated_at or metadata.file_name}" + return "".join( + character if character.isalnum() or character in {"-", "_", ".", "="} else "_" + for character in raw_key + ) + + @staticmethod + def _build_onlyoffice_access_token(asset_id: str, version: str) -> str: + onlyoffice_settings = resolve_onlyoffice_settings() + payload = { + "scope": "agent-asset-spreadsheet", + "asset_id": asset_id, + "version": version, + } + return jwt.encode(payload, onlyoffice_settings.jwt_secret, algorithm="HS256") + + @staticmethod + def _parse_onlyoffice_callback(payload: dict[str, Any]) -> OnlyOfficeCallbackPayload: + return OnlyOfficeCallbackPayload( + status=int(payload.get("status") or 0), + download_url=str(payload.get("url") or "").strip(), + users=[str(item).strip() for item in payload.get("users") or [] if str(item).strip()], + ) + + @staticmethod + def _hash_bytes(content: bytes) -> str: + import hashlib + + return hashlib.sha256(content).hexdigest() + @staticmethod def _asset_snapshot(asset: AgentAsset) -> dict[str, Any]: return { @@ -401,7 +1163,55 @@ class AgentAssetService: "name": asset.name, "status": asset.status, "current_version": asset.current_version, + "published_version": asset.published_version, + "working_version": asset.working_version, "domain": asset.domain, "owner": asset.owner, "reviewer": asset.reviewer, } + + @staticmethod + def _resolve_working_version(asset: AgentAsset) -> str: + return str(asset.working_version or asset.current_version or "").strip() + + @staticmethod + def _resolve_published_version(asset: AgentAsset) -> str: + return str(asset.published_version or "").strip() + + @staticmethod + def _resolve_version_lifecycle_state( + version: str, + *, + working_version: str, + published_version: str, + latest_review_status: str, + ) -> str: + if version == published_version: + return "published" + if version != working_version: + return "history" + if latest_review_status == AgentReviewStatus.PENDING.value: + return "pending_review" + if latest_review_status == AgentReviewStatus.APPROVED.value: + return "approved" + if latest_review_status == AgentReviewStatus.REJECTED.value: + return "rejected" + return "draft" + + 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) + if not file_path.exists(): + raise FileNotFoundError(metadata.file_name) + return load_workbook(BytesIO(file_path.read_bytes()), read_only=False, data_only=False) + + @staticmethod + def _extract_restore_source_version(change_note: str | None) -> str | None: + normalized = str(change_note or "").strip() + prefix = "基于历史版本 " + suffix = " 恢复生成工作稿" + if not normalized.startswith(prefix) or suffix not in normalized: + return None + return normalized.removeprefix(prefix).split(suffix, 1)[0].strip() or None diff --git a/server/src/app/services/agent_foundation.py b/server/src/app/services/agent_foundation.py index 87ea3f1..88875cf 100644 --- a/server/src/app/services/agent_foundation.py +++ b/server/src/app/services/agent_foundation.py @@ -1,10 +1,12 @@ -from __future__ import annotations +from __future__ import annotations + +import hashlib +import json +from datetime import UTC, date, datetime +from decimal import Decimal +from pathlib import Path -import json -from datetime import UTC, date, datetime -from decimal import Decimal - -from sqlalchemy import select +from sqlalchemy import inspect, select, text from sqlalchemy.orm import Session from app.core.agent_enums import ( @@ -26,16 +28,23 @@ from app.db.session import get_session_factory from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog from app.models.audit_log import AuditLog -from app.models.financial_record import ( - AccountsPayableRecord, - AccountsReceivableRecord, - ExpenseClaim, - ExpenseClaimItem, -) -from app.services.expense_rule_runtime import ( - build_scene_submission_standard_markdown, - build_travel_risk_control_standard_markdown, -) +from app.models.financial_record import ( + AccountsPayableRecord, + AccountsReceivableRecord, + ExpenseClaim, + ExpenseClaimItem, +) +from app.services.agent_asset_spreadsheet import ( + AgentAssetSpreadsheetManager, + COMPANY_TRAVEL_EXPENSE_RULE_CODE, + COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, + FINANCE_RULES_LIBRARY, + RuleSpreadsheetMeta, +) +from app.services.expense_rule_runtime import ( + build_scene_submission_standard_markdown, + build_travel_risk_control_standard_markdown, +) logger = get_logger("app.services.agent_foundation") @@ -77,7 +86,8 @@ LEGACY_RULE_CODES = ( "rule.ap.payment_dual_review", ) -ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements" +ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements" +COMPANY_TRAVEL_RULE_VERSION = "v1.0.0" ATTACHMENT_RULE_RUNTIME_CONFIG = { "kind": "policy_rule_draft", @@ -156,10 +166,11 @@ class AgentFoundationService: def __init__(self, db: Session) -> None: self.db = db - def ensure_foundation_ready(self) -> None: - try: - Base.metadata.create_all(bind=self.db.get_bind()) - self._seed_agent_assets() + def ensure_foundation_ready(self) -> None: + try: + Base.metadata.create_all(bind=self.db.get_bind()) + self._ensure_agent_asset_schema() + self._seed_agent_assets() self._sync_demo_financial_records() self._seed_runs_and_logs() self.db.commit() @@ -174,7 +185,7 @@ class AgentFoundationService: return self._purge_demo_financial_records() - def _seed_agent_assets(self) -> None: + def _seed_agent_assets(self) -> None: existing_codes = set(self.db.scalars(select(AgentAsset.code)).all()) if existing_codes: self._top_up_agent_assets(existing_codes) @@ -189,8 +200,10 @@ class AgentFoundationService: scenario_json=["expense", "risk_check", "attachment_policy", "invoice_anomaly"], owner="财务制度管理组", reviewer="高嘉禾", - status=AgentAssetStatus.REVIEW.value, - current_version="v1.0.0", + status=AgentAssetStatus.REVIEW.value, + current_version="v1.0.0", + published_version=None, + working_version="v1.0.0", config_json={ "severity": "high", "enabled": False, @@ -209,8 +222,10 @@ class AgentFoundationService: scenario_json=["expense", "risk_check", "scene_policy", "attachment_policy"], owner="费用运营组", reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", config_json={ "severity": "high", "enabled": True, @@ -218,17 +233,19 @@ class AgentFoundationService: "rule_template_label": "系统内置场景矩阵规则", }, ) - travel_policy_rule = AgentAsset( - asset_type=AgentAssetType.RULE.value, - code="rule.expense.travel_risk_control_standard", - name="差旅报销风险管控制度", + travel_policy_rule = AgentAsset( + asset_type=AgentAssetType.RULE.value, + code="rule.expense.travel_risk_control_standard", + name="差旅报销风险管控制度", description="统一定义差旅报销的行程闭环、酒店地点一致性、职级差标和风险处置口径。", domain=AgentAssetDomain.EXPENSE.value, scenario_json=["expense", "risk_check", "travel_policy", "travel_standard"], owner="风控与审计部", reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.1.0", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.1.0", + published_version="v1.1.0", + working_version="v1.1.0", config_json={ "severity": "high", "enabled": True, @@ -236,21 +253,45 @@ class AgentFoundationService: "warning_on_medium_risk": True, "source_doc": "document/development/risks/travel-risk-control-standard.md", "runtime_kind": "travel_policy", - "rule_template_key": "travel_standard_v1", - "rule_template_label": "差旅标准模板", - }, - ) - skill_expense_asset = AgentAsset( - asset_type=AgentAssetType.SKILL.value, - code="skill.expense.summary_lookup", + "rule_template_key": "travel_standard_v1", + "rule_template_label": "差旅标准模板", + }, + ) + company_travel_rule = AgentAsset( + asset_type=AgentAssetType.RULE.value, + code=COMPANY_TRAVEL_EXPENSE_RULE_CODE, + name="公司差旅费报销规则", + description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=["expense", "travel_policy", "travel_standard"], + owner="财务制度管理组", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + current_version=COMPANY_TRAVEL_RULE_VERSION, + published_version=COMPANY_TRAVEL_RULE_VERSION, + working_version=COMPANY_TRAVEL_RULE_VERSION, + config_json={ + "severity": "medium", + "enabled": True, + "tag": "财务规则", + "detail_mode": "spreadsheet", + "rule_library": FINANCE_RULES_LIBRARY, + "rule_template_label": "差旅报销 Excel 模板", + }, + ) + skill_expense_asset = AgentAsset( + asset_type=AgentAssetType.SKILL.value, + code="skill.expense.summary_lookup", name="报销汇总查询技能", description="根据时间、员工和部门汇总报销金额与单据数量。", domain=AgentAssetDomain.EXPENSE.value, scenario_json=["expense", "query", "summary"], owner="平台研发组", reviewer="陈硕", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", config_json={"input_schema": ["time_range", "employee", "department"]}, ) skill_ar_asset = AgentAsset( @@ -262,8 +303,10 @@ class AgentFoundationService: scenario_json=["accounts_receivable", "query", "aging_summary"], owner="平台研发组", reviewer="陈硕", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", config_json={"input_schema": ["customer", "aging_bucket", "status"]}, ) invoice_mcp_asset = AgentAsset( @@ -275,8 +318,10 @@ class AgentFoundationService: scenario_json=["expense", "invoice_validation"], owner="平台研发组", reviewer="周悦宁", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", config_json={"endpoint": "mock://invoice/verify", "timeout_ms": 1200}, ) ledger_mcp_asset = AgentAsset( @@ -288,8 +333,10 @@ class AgentFoundationService: scenario_json=["expense", "accounts_receivable", "accounts_payable"], owner="平台研发组", reviewer="周悦宁", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", config_json={"endpoint": "mock://ledger/snapshot", "timeout_ms": 1500}, ) task_asset = AgentAsset( @@ -301,8 +348,10 @@ class AgentFoundationService: scenario_json=["schedule", "risk_check"], owner="风控与审计部", reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", config_json={"cron": "0 9 * * *", "agent": AgentName.HERMES.value}, ) ar_summary_task = AgentAsset( @@ -314,8 +363,10 @@ class AgentFoundationService: scenario_json=["schedule", "accounts_receivable", "summary"], owner="风控与审计部", reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", config_json={"cron": "0 10 * * 1", "agent": AgentName.HERMES.value}, ) rule_digest_task = AgentAsset( @@ -327,8 +378,10 @@ class AgentFoundationService: scenario_json=["schedule", "rule_center", "review_digest"], owner="风控与审计部", reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", config_json={"cron": "0 18 * * *", "agent": AgentName.HERMES.value}, ) knowledge_index_task = AgentAsset( @@ -340,30 +393,39 @@ class AgentFoundationService: scenario_json=["schedule", "knowledge", "rule_center"], owner="财务制度管理组", reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, - current_version="v1.0.0", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + working_version="v1.0.0", config_json={"cron": "0 0 * * *", "agent": AgentName.HERMES.value}, ) self.db.add_all( [ - attachment_rule, - scene_submission_rule, - travel_policy_rule, - skill_expense_asset, - skill_ar_asset, - invoice_mcp_asset, + attachment_rule, + scene_submission_rule, + travel_policy_rule, + company_travel_rule, + skill_expense_asset, + skill_ar_asset, + invoice_mcp_asset, ledger_mcp_asset, task_asset, ar_summary_task, rule_digest_task, knowledge_index_task, ] - ) - self.db.flush() - - self.db.add_all( - [ + ) + self.db.flush() + + company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed( + company_travel_rule, + version=COMPANY_TRAVEL_RULE_VERSION, + actor_name="系统初始化", + ) + + self.db.add_all( + [ AgentAssetVersion( asset=attachment_rule, version="v0.9.0", @@ -402,17 +464,29 @@ class AgentFoundationService: change_note="首版差旅制度执行规则,覆盖行程闭环与基础差标校验。", created_by="系统初始化", ), - AgentAssetVersion( - asset=travel_policy_rule, - version="v1.1.0", - content=self._travel_risk_control_standard_markdown(version="v1.1.0"), - content_type=AgentAssetContentType.MARKDOWN.value, - change_note="补充可执行规则块,供审核引擎直接消费差旅制度标准。", - created_by="系统初始化", - ), - AgentAssetVersion( - asset=skill_expense_asset, - version="v1.0.0", + AgentAssetVersion( + asset=travel_policy_rule, + version="v1.1.0", + content=self._travel_risk_control_standard_markdown(version="v1.1.0"), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note="补充可执行规则块,供审核引擎直接消费差旅制度标准。", + created_by="系统初始化", + ), + AgentAssetVersion( + asset=company_travel_rule, + version=COMPANY_TRAVEL_RULE_VERSION, + content=AgentAssetSpreadsheetManager.build_version_markdown( + rule_name=company_travel_rule.name, + version=COMPANY_TRAVEL_RULE_VERSION, + metadata=company_travel_rule_meta, + ), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note="初始化差旅费报销 Excel 规则表。", + created_by="系统初始化", + ), + AgentAssetVersion( + asset=skill_expense_asset, + version="v1.0.0", content=self._json_content( { "inputs": ["time_range", "employee", "department"], @@ -545,16 +619,24 @@ class AgentFoundationService: review_note="可作为报销场景统一审核标准正式执行。", reviewed_at=datetime.now(UTC), ), - AgentAssetReview( - asset=travel_policy_rule, - version="v1.1.0", - reviewer="顾承宇", - review_status=AgentReviewStatus.APPROVED.value, - review_note="制度口径已确认,并已补充可执行配置供审核引擎读取。", - reviewed_at=datetime.now(UTC), - ), - ] - ) + AgentAssetReview( + asset=travel_policy_rule, + version="v1.1.0", + reviewer="顾承宇", + review_status=AgentReviewStatus.APPROVED.value, + review_note="制度口径已确认,并已补充可执行配置供审核引擎读取。", + reviewed_at=datetime.now(UTC), + ), + AgentAssetReview( + asset=company_travel_rule, + version=COMPANY_TRAVEL_RULE_VERSION, + reviewer="顾承宇", + review_status=AgentReviewStatus.APPROVED.value, + review_note="首版 Excel 规则表已确认,可作为财务规则使用。", + reviewed_at=datetime.now(UTC), + ), + ] + ) def _seed_financial_records(self) -> None: if self.db.scalar(select(ExpenseClaim.id).limit(1)) is not None: @@ -913,9 +995,12 @@ class AgentFoundationService: scene_submission_rule = self.db.scalar( select(AgentAsset).where(AgentAsset.code == "rule.expense.scene_submission_standard") ) - travel_policy_rule = self.db.scalar( - select(AgentAsset).where(AgentAsset.code == "rule.expense.travel_risk_control_standard") - ) + travel_policy_rule = self.db.scalar( + select(AgentAsset).where(AgentAsset.code == "rule.expense.travel_risk_control_standard") + ) + company_travel_rule = self.db.scalar( + select(AgentAsset).where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE) + ) if ATTACHMENT_RULE_ASSET_CODE not in existing_codes: attachment_rule = self._create_seed_asset( @@ -939,9 +1024,12 @@ class AgentFoundationService: }, ) - if attachment_rule is not None: - attachment_rule.current_version = "v1.0.0" - attachment_rule.status = AgentAssetStatus.REVIEW.value + if attachment_rule is not None: + if not str(attachment_rule.current_version or "").strip(): + attachment_rule.current_version = "v1.0.0" + if not str(attachment_rule.working_version or "").strip(): + attachment_rule.working_version = attachment_rule.current_version + attachment_rule.status = attachment_rule.status or AgentAssetStatus.REVIEW.value attachment_rule.description = "统一定义报销提交时的附件数量、票据类型和补件处理口径,作为上线前待审核规则。" attachment_rule.config_json = { "severity": "high", @@ -1002,9 +1090,14 @@ class AgentFoundationService: }, ) - if scene_submission_rule is not None: - scene_submission_rule.current_version = "v1.0.0" - scene_submission_rule.status = AgentAssetStatus.ACTIVE.value + if scene_submission_rule is not None: + if not str(scene_submission_rule.current_version or "").strip(): + scene_submission_rule.current_version = "v1.0.0" + if not str(scene_submission_rule.working_version or "").strip(): + scene_submission_rule.working_version = scene_submission_rule.current_version + if not str(scene_submission_rule.published_version or "").strip(): + scene_submission_rule.published_version = scene_submission_rule.current_version + scene_submission_rule.status = scene_submission_rule.status or AgentAssetStatus.ACTIVE.value scene_submission_rule.description = "统一定义各报销场景的必填字段、附件类型要求和金额阈值。" scene_submission_rule.config_json = { "severity": "high", @@ -1053,9 +1146,14 @@ class AgentFoundationService: }, ) - if travel_policy_rule is not None: - travel_policy_rule.current_version = "v1.1.0" - travel_policy_rule.status = AgentAssetStatus.ACTIVE.value + if travel_policy_rule is not None: + if not str(travel_policy_rule.current_version or "").strip(): + travel_policy_rule.current_version = "v1.1.0" + if not str(travel_policy_rule.working_version or "").strip(): + travel_policy_rule.working_version = travel_policy_rule.current_version + if not str(travel_policy_rule.published_version or "").strip(): + travel_policy_rule.published_version = travel_policy_rule.current_version + travel_policy_rule.status = travel_policy_rule.status or AgentAssetStatus.ACTIVE.value travel_policy_rule.config_json = { "severity": "high", "enabled": True, @@ -1087,12 +1185,79 @@ class AgentFoundationService: version="v1.1.0", reviewer="顾承宇", review_status=AgentReviewStatus.APPROVED.value, - review_note="制度口径已确认,并已补充可执行配置供审核引擎读取。", - reviewed_at=datetime.now(UTC), - ) - - if "skill.ar.aging_summary" not in existing_codes: - asset = self._create_seed_asset( + review_note="制度口径已确认,并已补充可执行配置供审核引擎读取。", + reviewed_at=datetime.now(UTC), + ) + + if COMPANY_TRAVEL_EXPENSE_RULE_CODE not in existing_codes: + company_travel_rule = self._create_seed_asset( + asset_type=AgentAssetType.RULE.value, + code=COMPANY_TRAVEL_EXPENSE_RULE_CODE, + name="公司差旅费报销规则", + description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=["expense", "travel_policy", "travel_standard"], + owner="财务制度管理组", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, + current_version=COMPANY_TRAVEL_RULE_VERSION, + config_json={ + "severity": "medium", + "enabled": True, + "tag": "财务规则", + "detail_mode": "spreadsheet", + "rule_template_label": "差旅报销 Excel 模板", + }, + ) + + if company_travel_rule is not None: + if not str(company_travel_rule.current_version or "").strip(): + company_travel_rule.current_version = COMPANY_TRAVEL_RULE_VERSION + if not str(company_travel_rule.working_version or "").strip(): + company_travel_rule.working_version = company_travel_rule.current_version + if not str(company_travel_rule.published_version or "").strip(): + company_travel_rule.published_version = company_travel_rule.current_version + if not str(company_travel_rule.status or "").strip(): + company_travel_rule.status = AgentAssetStatus.ACTIVE.value + company_travel_rule.description = "通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。" + company_travel_rule.config_json = { + **(company_travel_rule.config_json or {}), + "severity": "medium", + "enabled": True, + "tag": "财务规则", + "detail_mode": "spreadsheet", + "rule_library": FINANCE_RULES_LIBRARY, + "rule_template_label": "差旅报销 Excel 模板", + } + company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed( + company_travel_rule, + version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION), + actor_name="系统初始化", + ) + self._ensure_asset_version( + company_travel_rule, + version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION), + content=AgentAssetSpreadsheetManager.build_version_markdown( + rule_name=company_travel_rule.name, + version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION), + metadata=company_travel_rule_meta, + ), + content_type=AgentAssetContentType.MARKDOWN.value, + change_note="初始化差旅费报销 Excel 规则表。", + created_by="系统初始化", + ) + if str(company_travel_rule.current_version or "").strip() == COMPANY_TRAVEL_RULE_VERSION: + self._ensure_asset_review( + company_travel_rule, + version=COMPANY_TRAVEL_RULE_VERSION, + reviewer="顾承宇", + review_status=AgentReviewStatus.APPROVED.value, + review_note="首版 Excel 规则表已确认,可作为财务规则使用。", + reviewed_at=datetime.now(UTC), + ) + + if "skill.ar.aging_summary" not in existing_codes: + asset = self._create_seed_asset( asset_type=AgentAssetType.SKILL.value, code="skill.ar.aging_summary", name="应收账龄汇总技能", @@ -1207,10 +1372,10 @@ class AgentFoundationService: created_by="系统初始化", ) - if "task.hermes.knowledge_index_sync" not in existing_codes: - asset = self._create_seed_asset( - asset_type=AgentAssetType.TASK.value, - code="task.hermes.knowledge_index_sync", + if "task.hermes.knowledge_index_sync" not in existing_codes: + asset = self._create_seed_asset( + asset_type=AgentAssetType.TASK.value, + code="task.hermes.knowledge_index_sync", name="Hermes ??????", description="?????????? LightRAG ???????", domain=AgentAssetDomain.SYSTEM.value, @@ -1234,14 +1399,112 @@ class AgentFoundationService: } ), content_type=AgentAssetContentType.JSON.value, - change_note="初始化制度知识与规则草稿形成任务。", - created_by="系统初始化", - ) - - def _create_seed_asset( - self, - *, - asset_type: str, + change_note="初始化制度知识与规则草稿形成任务。", + created_by="系统初始化", + ) + + def _ensure_company_travel_rule_spreadsheet_seed( + self, + asset: AgentAsset, + *, + version: str, + actor_name: str, + ): + manager = AgentAssetSpreadsheetManager() + manager.ensure_rule_library_dirs() + live_document = manager.store_rule_library_spreadsheet( + library=FINANCE_RULES_LIBRARY, + file_name=COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, + content=self._read_or_build_company_travel_rule_file(manager), + actor_name=actor_name, + source="rule-library", + ) + existing_document = ( + asset.config_json.get("rule_document") + if isinstance(asset.config_json, dict) + else None + ) + storage_key = ( + str(existing_document.get("storage_key") or "").strip() + if isinstance(existing_document, dict) + else "" + ) + if storage_key: + try: + existing_path = manager.resolve_storage_path(storage_key) + except FileNotFoundError: + existing_path = None + if existing_path is not None and existing_path.exists(): + metadata = RuleSpreadsheetMeta( + file_name=str(existing_document.get("file_name") or COMPANY_TRAVEL_EXPENSE_RULE_FILENAME), + storage_key=storage_key, + mime_type=str(existing_document.get("mime_type") or "").strip() + or "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + size_bytes=int(existing_document.get("size_bytes") or existing_path.stat().st_size), + checksum=hashlib.sha256(existing_path.read_bytes()).hexdigest(), + updated_at=str(existing_document.get("updated_at") or "").strip() + or datetime.now(UTC).isoformat(), + updated_by=str(existing_document.get("updated_by") or actor_name).strip() + or actor_name, + source=str(existing_document.get("source") or "seed").strip() or "seed", + ) + asset.config_json = { + **(asset.config_json or {}), + "detail_mode": "spreadsheet", + "tag": "财务规则", + "rule_library": FINANCE_RULES_LIBRARY, + "rule_document": { + **AgentAssetSpreadsheetManager.build_rule_document_config( + live_document, + asset_version=version, + ), + "storage_key": live_document.storage_key, + }, + } + return metadata + + live_content = manager.resolve_storage_path(live_document.storage_key).read_bytes() + metadata = manager.store_spreadsheet( + asset_id=asset.id, + version=version, + file_name=COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, + content=live_content, + actor_name=actor_name, + source="seed", + ) + asset.config_json = { + **(asset.config_json or {}), + "detail_mode": "spreadsheet", + "tag": "财务规则", + "rule_library": FINANCE_RULES_LIBRARY, + "rule_document": { + **AgentAssetSpreadsheetManager.build_rule_document_config( + live_document, + asset_version=version, + ), + "storage_key": live_document.storage_key, + }, + } + return metadata + + @staticmethod + def _read_or_build_company_travel_rule_file( + manager: AgentAssetSpreadsheetManager, + ) -> bytes: + live_key = ( + Path("rules") + / FINANCE_RULES_LIBRARY + / COMPANY_TRAVEL_EXPENSE_RULE_FILENAME + ).as_posix() + live_path = manager.resolve_storage_path(live_key) + if live_path.exists(): + return live_path.read_bytes() + return AgentAssetSpreadsheetManager.build_blank_rule_workbook("差旅费报销规则") + + def _create_seed_asset( + self, + *, + asset_type: str, code: str, name: str, description: str, @@ -1262,10 +1525,12 @@ class AgentFoundationService: scenario_json=scenario_json, owner=owner, reviewer=reviewer, - status=status, - current_version=current_version, - config_json=config_json, - ) + status=status, + current_version=current_version, + published_version=current_version if status == AgentAssetStatus.ACTIVE.value else None, + working_version=current_version, + config_json=config_json, + ) self.db.add(asset) self.db.flush() return asset @@ -1331,7 +1596,7 @@ class AgentFoundationService: ) ) - def _remove_legacy_rule_assets(self) -> None: + def _remove_legacy_rule_assets(self) -> None: assets = list( self.db.scalars( select(AgentAsset).where(AgentAsset.code.in_(LEGACY_RULE_CODES)) @@ -1345,8 +1610,38 @@ class AgentFoundationService: select(AuditLog).where(AuditLog.resource_id.in_(LEGACY_RULE_CODES)) ).all() ) - for log in obsolete_logs: - self.db.delete(log) + for log in obsolete_logs: + self.db.delete(log) + + def _ensure_agent_asset_schema(self) -> None: + bind = self.db.get_bind() + inspector = inspect(bind) + if "agent_assets" not in inspector.get_table_names(): + return + + column_names = {column["name"] for column in inspector.get_columns("agent_assets")} + migration_statements: list[str] = [] + if "published_version" not in column_names: + migration_statements.append("ALTER TABLE agent_assets ADD COLUMN published_version VARCHAR(30)") + if "working_version" not in column_names: + migration_statements.append("ALTER TABLE agent_assets ADD COLUMN working_version VARCHAR(30)") + + for statement in migration_statements: + self.db.execute(text(statement)) + + self.db.execute( + text( + "UPDATE agent_assets " + "SET working_version = COALESCE(working_version, current_version), " + "published_version = CASE " + "WHEN published_version IS NOT NULL THEN published_version " + "WHEN status = 'active' THEN current_version " + "ELSE published_version END" + ) + ) + + if migration_statements: + self.db.commit() def _attachment_submission_requirement_markdown( self, diff --git a/web/src/services/agentAssets.js b/web/src/services/agentAssets.js index ce8b00f..ac00232 100644 --- a/web/src/services/agentAssets.js +++ b/web/src/services/agentAssets.js @@ -60,6 +60,10 @@ function buildQuery(params = {}) { search.set('limit', String(params.limit)) } + if (params.version) { + search.set('version', String(params.version)) + } + if (params.agent) { search.set('agent', params.agent) } @@ -80,10 +84,66 @@ export function fetchAgentAssetDetail(assetId) { return apiRequest(`/agent-assets/${assetId}`) } +export function fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId, version = '') { + const query = buildQuery({ version }) + return apiRequest(`/agent-assets/${assetId}/spreadsheet/onlyoffice-config${query}`) +} + +export function fetchAgentAssetSpreadsheetBlob(assetId, version = '', disposition = 'inline') { + const search = new URLSearchParams() + if (version) { + search.set('version', String(version).trim()) + } + if (disposition) { + search.set('disposition', String(disposition).trim()) + } + const query = search.toString() + return apiRequest(`/agent-assets/${assetId}/spreadsheet/content${query ? `?${query}` : ''}`, { + responseType: 'blob', + contentType: null + }) +} + +export function uploadAgentAssetSpreadsheet(assetId, file, options = {}) { + return apiRequest( + `/agent-assets/${assetId}/spreadsheet/upload?filename=${encodeURIComponent(file.name)}`, + { + method: 'POST', + body: file, + contentType: file.type || 'application/octet-stream', + headers: buildWriteHeaders(options) + } + ) +} + +export function importAgentAssetSpreadsheetContent(assetId, file, options = {}) { + return apiRequest( + `/agent-assets/${assetId}/spreadsheet/import-content?filename=${encodeURIComponent(file.name)}`, + { + method: 'POST', + body: file, + contentType: file.type || 'application/octet-stream', + headers: buildWriteHeaders(options) + } + ) +} + export function fetchAgentAssetVersions(assetId, limit = 5) { return apiRequest(`/agent-assets/${assetId}/versions${buildQuery({ limit })}`) } +export function fetchAgentAssetVersionTimeline(assetId) { + return apiRequest(`/agent-assets/${assetId}/version-timeline`) +} + +export function compareAgentAssetSpreadsheetVersions(assetId, baseVersion, targetVersion) { + const query = new URLSearchParams({ + base_version: String(baseVersion || '').trim(), + target_version: String(targetVersion || '').trim() + }) + return apiRequest(`/agent-assets/${assetId}/versions/compare?${query.toString()}`) +} + export function updateAgentAsset(assetId, payload, options = {}) { return apiRequest(`/agent-assets/${assetId}`, { method: 'PATCH', @@ -115,6 +175,13 @@ export function activateAgentAsset(assetId, options = {}) { }) } +export function restoreAgentAssetVersion(assetId, version, options = {}) { + return apiRequest(`/agent-assets/${assetId}/versions/${encodeURIComponent(version)}/restore`, { + method: 'POST', + headers: buildWriteHeaders(options) + }) +} + export function fetchAgentRuns(params = {}) { return apiRequest(`/agent-runs${buildQuery(params)}`) }