604 lines
23 KiB
Python
604 lines
23 KiB
Python
#!/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])
|
||
]
|