#!/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]) ]