Files
ai-agent-admin/backend-fastapi/online_dev/report_manager/service.py
T

604 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""报表模板管理服务"""
import logging
from typing import Any, Dict, List, Optional
from sqlalchemy import select, update, delete, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from online_dev.report_manager.model import ReportTemplate, ReportVersion
from online_dev.report_manager.enums import ReportVersionState
from online_dev.report_manager.constants import default_snapshot, default_cells
from online_dev.report_manager.exceptions import ReportServiceException
from app.data_scope_utils import get_data_scope_filter, apply_data_scope_to_conditions
logger = logging.getLogger(__name__)
RESOURCE_TYPE = "report"
RESOURCE_DISPLAY_NAME = "报表管理"
class ReportService:
@staticmethod
def _format_dt(dt) -> str:
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else ""
@staticmethod
async def list_with_data_scope(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
application_id: str = None,
name: str = None,
code: str = None,
category: str = None,
status: str = None,
) -> Dict[str, Any]:
conditions = [ReportTemplate.is_deleted == False]
if application_id:
conditions.append(ReportTemplate.application_id == application_id)
else:
conditions.append(ReportTemplate.application_id.is_(None))
if name:
conditions.append(ReportTemplate.name.ilike(f"%{name}%"))
if code:
conditions.append(ReportTemplate.code.ilike(f"%{code}%"))
if category:
conditions.append(ReportTemplate.category == category)
if status:
conditions.append(ReportTemplate.status == status)
data_scope_filter = await get_data_scope_filter(db, RESOURCE_TYPE)
scope_conditions = apply_data_scope_to_conditions(ReportTemplate, data_scope_filter)
conditions.extend(scope_conditions)
count_stmt = select(func.count(ReportTemplate.id)).where(and_(*conditions))
total = (await db.execute(count_stmt)).scalar() or 0
offset = (page - 1) * page_size
stmt = (
select(ReportTemplate)
.where(and_(*conditions))
.order_by(ReportTemplate.sort, ReportTemplate.sys_create_datetime.desc())
.offset(offset)
.limit(page_size)
)
items = list((await db.execute(stmt)).scalars().all())
return {"items": items, "total": total}
@staticmethod
async def get(db: AsyncSession, template_id: str) -> ReportTemplate:
stmt = select(ReportTemplate).where(
ReportTemplate.id == template_id,
ReportTemplate.is_deleted == False,
)
tpl = (await db.execute(stmt)).scalar_one_or_none()
if not tpl:
raise ReportServiceException(f"报表不存在: {template_id}")
return tpl
@staticmethod
async def get_by_code(db: AsyncSession, code: str) -> ReportTemplate:
stmt = select(ReportTemplate).where(
ReportTemplate.code == code,
ReportTemplate.is_deleted == False,
)
tpl = (await db.execute(stmt)).scalar_one_or_none()
if not tpl:
raise ReportServiceException(f"报表不存在: {code}")
return tpl
@staticmethod
async def get_active_version_id(db: AsyncSession, template_id: str) -> Optional[str]:
stmt = select(ReportVersion.id).where(
ReportVersion.template_id == template_id,
ReportVersion.state == ReportVersionState.ACTIVE,
ReportVersion.is_deleted == False,
)
return (await db.execute(stmt)).scalar_one_or_none()
@staticmethod
async def create(db: AsyncSession, data: Dict[str, Any], user_id: str = None) -> ReportTemplate:
code = data.get("code")
stmt = select(ReportTemplate).where(
ReportTemplate.code == code,
ReportTemplate.is_deleted == False,
)
if (await db.execute(stmt)).scalar_one_or_none():
raise ReportServiceException(f"报表编码已存在: {code}")
from utils.context import get_current_user_info_from_context
user_info = get_current_user_info_from_context()
tpl = ReportTemplate(
application_id=data.get("application_id"),
name=data.get("name"),
code=code,
category=data.get("category", ""),
description=data.get("description", ""),
sort=data.get("sort", 0),
sys_creator_id=user_id or (user_info.get("user_id") if user_info else None),
sys_modifier_id=user_id or (user_info.get("user_id") if user_info else None),
)
if user_info and user_info.get("dept_id"):
tpl.sys_dept_id = user_info.get("dept_id")
db.add(tpl)
await db.flush()
version = ReportVersion(
template_id=tpl.id,
version=1,
state=ReportVersionState.DESIGNING,
snapshot=default_snapshot(),
cells=default_cells(),
sys_creator_id=tpl.sys_creator_id,
sys_modifier_id=tpl.sys_modifier_id,
sys_dept_id=tpl.sys_dept_id,
)
db.add(version)
await db.commit()
await db.refresh(tpl)
logger.info("报表创建成功: %s", tpl.code)
return tpl
@staticmethod
async def update(
db: AsyncSession,
template_id: str,
data: Dict[str, Any],
user_id: str = None,
) -> ReportTemplate:
tpl = await ReportService.get(db, template_id)
for key in ("name", "category", "description", "sort", "allow_export", "allow_print",
"allow_watermark", "watermark_config"):
if key in data and data[key] is not None:
setattr(tpl, key, data[key])
tpl.sys_modifier_id = user_id
await db.commit()
await db.refresh(tpl)
return tpl
@staticmethod
async def delete(db: AsyncSession, template_id: str) -> bool:
from core.menu.service import MenuService
tpl = await ReportService.get(db, template_id)
await ReportService._cleanup_report_publish_resources(db, tpl)
tpl.is_deleted = True
tpl.status = "draft"
stmt = update(ReportVersion).where(
ReportVersion.template_id == template_id,
ReportVersion.is_deleted == False,
).values(is_deleted=True)
await db.execute(stmt)
await db.commit()
await MenuService.invalidate_cache()
logger.info("报表删除成功: %s", tpl.code)
return True
@staticmethod
async def batch_delete(db: AsyncSession, template_ids: List[str]) -> int:
count = 0
for tid in template_ids:
if await ReportService.delete(db, tid):
count += 1
return count
@staticmethod
async def get_categories(db: AsyncSession, application_id: str = None) -> List[str]:
conditions = [ReportTemplate.is_deleted == False, ReportTemplate.category != ""]
if application_id:
conditions.append(ReportTemplate.application_id == application_id)
else:
conditions.append(ReportTemplate.application_id.is_(None))
stmt = select(ReportTemplate.category).where(and_(*conditions)).distinct()
rows = (await db.execute(stmt)).scalars().all()
return sorted({r for r in rows if r})
@staticmethod
async def copy(
db: AsyncSession,
template_id: str,
new_code: str,
new_name: str = None,
user_id: str = None,
) -> ReportTemplate:
src = await ReportService.get(db, template_id)
stmt = select(ReportTemplate).where(
ReportTemplate.code == new_code,
ReportTemplate.is_deleted == False,
)
if (await db.execute(stmt)).scalar_one_or_none():
raise ReportServiceException(f"报表编码已存在: {new_code}")
new_tpl = await ReportService.create(
db,
{
"application_id": src.application_id,
"name": new_name or f"{src.name}_copy",
"code": new_code,
"category": src.category,
"description": src.description,
"sort": src.sort,
},
user_id,
)
from online_dev.report_manager.version_service import ReportVersionService
src_version = await ReportVersionService.get_designing_or_latest(db, template_id)
if src_version:
await ReportVersionService.copy_version_content(
db, src_version.id, new_tpl.id, user_id
)
return new_tpl
@staticmethod
async def check_import(db: AsyncSession, code: str) -> Dict[str, bool]:
stmt = select(ReportTemplate).where(
ReportTemplate.code == code,
ReportTemplate.is_deleted == False,
)
exists = (await db.execute(stmt)).scalar_one_or_none() is not None
return {"code_exists": exists, "can_import": not exists}
@staticmethod
async def export_config(db: AsyncSession, template_id: str) -> Dict[str, Any]:
"""导出报表配置包(模板 + 设计中/最新版本 + 数据集)"""
from online_dev.report_manager.version_service import ReportVersionService
from online_dev.report_manager.dataset_bridge import ReportDatasetBridge
tpl = await ReportService.get(db, template_id)
version = await ReportVersionService.get_designing_or_latest(db, template_id)
if not version:
raise ReportServiceException("没有可导出的版本")
datasets_raw = await ReportDatasetBridge.list_by_version(db, version.id)
datasets = [
{
"data_source_id": d.get("data_source_id"),
"data_source_code": d.get("data_source_code"),
"data_source_name": d.get("data_source_name"),
"alias": d.get("alias"),
"field_mapping": d.get("field_mapping") or {},
"convert_config": d.get("convert_config") or {},
"sort": d.get("sort", 0),
}
for d in datasets_raw
]
return {
"schema_version": 1,
"name": tpl.name,
"code": tpl.code,
"category": tpl.category or "",
"description": tpl.description or "",
"sort": tpl.sort or 0,
"allow_export": bool(tpl.allow_export),
"allow_print": bool(tpl.allow_print),
"allow_watermark": bool(tpl.allow_watermark),
"watermark_config": tpl.watermark_config or {},
"version": {
"snapshot": version.snapshot or {},
"cells": version.cells or {},
"query_list": version.query_list or [],
"sort_list": version.sort_list or [],
"column_list": version.column_list or [],
"fence_list": version.fence_list or [],
"convert_config": version.convert_config or {},
},
"datasets": datasets,
}
@staticmethod
async def import_config(
db: AsyncSession,
data: Dict[str, Any],
user_id: str = None,
) -> ReportTemplate:
"""导入报表配置包"""
from core.data_source.service import DataSourceService
from online_dev.report_manager.version_service import ReportVersionService
from online_dev.report_manager.dataset_bridge import ReportDatasetBridge
for field in ("name", "code"):
if not data.get(field):
raise ReportServiceException(f"缺少必要字段: {field}")
code = data["code"]
check = await ReportService.check_import(db, code)
if check["code_exists"]:
raise ReportServiceException(f"报表编码已存在: {code}")
tpl = await ReportService.create(
db,
{
"application_id": data.get("application_id"),
"name": data["name"],
"code": code,
"category": data.get("category", ""),
"description": data.get("description", ""),
"sort": data.get("sort", 0),
},
user_id,
)
for key in ("allow_export", "allow_print", "allow_watermark", "watermark_config"):
if key in data and data[key] is not None:
setattr(tpl, key, data[key])
version = await ReportVersionService.get_designing_or_latest(db, tpl.id)
if not version:
raise ReportServiceException("导入后未找到设计版本")
version_data = data.get("version")
if not version_data and data.get("versions"):
version_data = data["versions"][0] if data["versions"] else None
if version_data:
version.snapshot = version_data.get("snapshot") or version.snapshot
version.cells = version_data.get("cells") or version.cells
version.query_list = version_data.get("query_list") or version.query_list
version.sort_list = version_data.get("sort_list") or version.sort_list
version.column_list = version_data.get("column_list") or version.column_list
version.fence_list = version_data.get("fence_list") or version.fence_list
version.convert_config = version_data.get("convert_config") or version.convert_config
version.sys_modifier_id = user_id
data_set_list = []
skipped_aliases = []
for idx, item in enumerate(data.get("datasets") or []):
ds_id = item.get("data_source_id") or item.get("dataSourceId")
ds_code = item.get("data_source_code") or item.get("dataSourceCode")
if not ds_id and ds_code:
source = await DataSourceService.get_by_code(db, ds_code)
if source:
ds_id = source.id
if not ds_id:
alias = item.get("alias") or f"ds_{idx}"
skipped_aliases.append(alias)
continue
data_set_list.append({
"data_source_id": ds_id,
"alias": item.get("alias") or f"ds_{idx}",
"field_mapping": item.get("field_mapping") or item.get("fieldMapping") or {},
"convert_config": item.get("convert_config") or item.get("convertConfig") or {},
"sort": item.get("sort", idx),
})
await ReportDatasetBridge.sync_datasets(db, version.id, data_set_list)
await db.commit()
await db.refresh(tpl)
if skipped_aliases:
logger.warning(
"导入报表 %s 时跳过未匹配的数据集: %s",
code,
", ".join(skipped_aliases),
)
logger.info("报表配置导入成功: %s", code)
return tpl
# ============ 发布到菜单 ============
@staticmethod
def _report_menu_query(template: ReportTemplate) -> Dict[str, Any]:
return {
"relationId": str(template.id),
"moduleId": str(template.id),
"templateId": str(template.id),
"reportCode": template.code,
}
@staticmethod
async def _create_report_permissions(
db: AsyncSession,
template: ReportTemplate,
menu_id: str,
) -> None:
from core.permission.model import Permission
from online_dev.report_manager.permission_templates import REPORT_ACTIONS
http_method_map = {"GET": 0, "POST": 1, "PUT": 2, "DELETE": 3, "PATCH": 4, "ALL": 5}
for idx, (action, name, enabled, http_method_int, http_method_str, api_path) in enumerate(
REPORT_ACTIONS
):
perm_code = f"report:{template.code}:{action}"
path = api_path.replace("{template_id}", str(template.id))
existing = (
await db.execute(
select(Permission).where(
Permission.menu_id == menu_id,
Permission.code == perm_code,
Permission.is_deleted == False,
)
)
).scalar_one_or_none()
if existing:
existing.is_active = enabled and (
action not in ("export", "export_pdf")
or bool(template.allow_export)
)
existing.name = f"{template.name}-{name}"
existing.api_path = path
existing.http_method = http_method_map.get(http_method_str, http_method_int)
continue
db.add(
Permission(
menu_id=menu_id,
name=f"{template.name}-{name}",
code=perm_code,
permission_type=1,
api_path=path,
http_method=http_method_map.get(http_method_str, http_method_int),
is_active=enabled
and (
action not in ("export", "export_pdf")
or bool(template.allow_export)
),
sort=idx,
)
)
@staticmethod
async def _cleanup_report_publish_resources(db: AsyncSession, template: ReportTemplate) -> None:
"""清理报表发布产生的菜单与 API 权限(取消发布、删除时共用)"""
from core.menu.model import Menu
from core.permission.model import Permission
menu_path = f"/report-render/{template.code}"
delete_menu_stmt = delete(Menu).where(Menu.path == menu_path)
menu_result = await db.execute(delete_menu_stmt)
if menu_result.rowcount > 0:
logger.info(
"物理删除报表菜单: %s, 删除数量: %s",
template.code,
menu_result.rowcount,
)
delete_perm_stmt = delete(Permission).where(
Permission.code.like(f"report:{template.code}:%")
)
perm_result = await db.execute(delete_perm_stmt)
if perm_result.rowcount > 0:
logger.info(
"物理删除报表 API 权限: %s, 删除数量: %s",
template.code,
perm_result.rowcount,
)
@staticmethod
async def publish(
db: AsyncSession,
template_id: str,
publish_config: Dict[str, Any] = None,
) -> ReportTemplate:
"""发布报表并创建 online_report 菜单"""
from core.menu.model import Menu
from core.menu.service import MenuService
from online_dev.report_manager.version_service import ReportVersionService
tpl = await ReportService.get(db, template_id)
version = await ReportVersionService.get_active(db, template_id)
if not version:
version = await ReportVersionService.get_designing_or_latest(db, template_id)
if not version:
raise ReportServiceException("没有可发布的版本,请先在设计器中保存报表")
tpl.status = "published"
if publish_config:
menu_parent_id = publish_config.get("menu_parent_id")
menu_path = f"/report-render/{tpl.code}"
menu_internal_name = f"report_{tpl.code}"
menu_stmt = select(Menu).where(Menu.path == menu_path)
existing_menu = (await db.execute(menu_stmt)).scalar_one_or_none()
title = publish_config.get("menu_name", tpl.name)
icon = publish_config.get("menu_icon", "lucide:file-spreadsheet")
order = publish_config.get("menu_order", 0)
menu_query = ReportService._report_menu_query(tpl)
if existing_menu:
existing_menu.name = menu_internal_name
existing_menu.title = title
existing_menu.parent_id = menu_parent_id
existing_menu.icon = icon
existing_menu.order = order
existing_menu.type = "online_report"
existing_menu.application_id = tpl.application_id
existing_menu.component = "online-dev/report-render/index"
existing_menu.query = menu_query
existing_menu.authCode = f"report:{tpl.code}:preview"
menu_record = existing_menu
logger.info("更新报表菜单: %s", tpl.code)
else:
menu_record = Menu(
application_id=tpl.application_id,
name=menu_internal_name,
title=title,
path=menu_path,
component="online-dev/report-render/index",
type="online_report",
parent_id=menu_parent_id,
icon=icon,
order=order,
query=menu_query,
authCode=f"report:{tpl.code}:preview",
)
db.add(menu_record)
await db.flush()
logger.info("创建报表菜单: %s", tpl.code)
await ReportService._create_report_permissions(db, tpl, menu_record.id)
await db.commit()
await db.refresh(tpl)
await MenuService.invalidate_cache()
logger.info("报表发布成功: %s", tpl.code)
return tpl
@staticmethod
async def unpublish(db: AsyncSession, template_id: str) -> ReportTemplate:
"""取消发布并删除对应菜单"""
from core.menu.service import MenuService
tpl = await ReportService.get(db, template_id)
tpl.status = "draft"
await ReportService._cleanup_report_publish_resources(db, tpl)
await db.commit()
await db.refresh(tpl)
await MenuService.invalidate_cache()
logger.info("报表取消发布: %s", tpl.code)
return tpl
@staticmethod
async def get_release_menu(db: AsyncSession, template_id: str) -> Dict[str, Any]:
from core.menu.model import Menu
tpl = await ReportService.get(db, template_id)
menu_path = f"/report-render/{tpl.code}"
menu = (await db.execute(select(Menu).where(Menu.path == menu_path))).scalar_one_or_none()
if not menu:
return {"published": False, "menu": None}
return {
"published": True,
"menu": {
"id": str(menu.id),
"title": menu.title,
"path": menu.path,
"parent_id": menu.parent_id,
"icon": menu.icon,
"order": menu.order,
},
}
@staticmethod
async def get_selector(
db: AsyncSession,
application_id: str = None,
) -> List[Dict[str, Any]]:
"""分类树形选择器(对标 JNPF GET /Report/Selector"""
result = await ReportService.list_with_data_scope(
db, page=1, page_size=500, application_id=application_id
)
by_cat: Dict[str, List[Dict[str, Any]]] = {}
for tpl in result["items"]:
cat = tpl.category or "未分类"
by_cat.setdefault(cat, []).append(
{
"id": str(tpl.id),
"name": tpl.name,
"code": tpl.code,
"status": tpl.status,
}
)
return [
{"category": cat, "children": items}
for cat, items in sorted(by_cat.items(), key=lambda x: x[0])
]