Files

627 lines
23 KiB
Python
Raw Permalink 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 -*-
"""报表数据与预览 API"""
import base64
import logging
import re
from typing import Any, Dict
import httpx
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.config import settings
from core.file_manager.service import FileManagerService
from online_dev.report_manager.exceptions import ReportServiceException
from online_dev.report_manager.schema import (
ReportDownImgIn,
ReportImportExcelOut,
ReportPreviewIn,
ReportPreviewOut,
ReportUploadOut,
)
from online_dev.report_manager.service import ReportService
from online_dev.report_manager.version_service import ReportVersionService
from online_dev.report_manager.dataset_bridge import ReportDatasetBridge
from online_dev.report_manager.engine.chart_data import build_chart_data
from online_dev.report_manager.engine.convert import transform
from online_dev.report_manager.engine.expression_eval import detect_expression_cycles
from online_dev.report_manager.engine.export_excel import snapshot_to_xlsx_bytes
from online_dev.report_manager.engine.export_pdf import snapshot_to_pdf_bytes
from online_dev.report_manager.engine.import_excel import parse_excel_to_grid
from online_dev.report_manager.engine.watermark import build_watermark_payload
from online_dev.report_manager.engine.convert_lookup import build_lookup_cache_from_db
from online_dev.report_manager.engine.parameter_resolver import (
build_system_params,
merge_preview_params,
)
from online_dev.report_manager.engine.preview_guard import collect_preview_warnings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/report/data", tags=["报表数据"])
def _build_file_access_url(file_obj) -> str:
if file_obj.url:
return file_obj.url
base_url = getattr(settings, "BASE_URL", "http://localhost:8000")
if file_obj.storage_type == "local" and file_obj.storage_path:
return f"{base_url}/api/file_manager/file/download?path={file_obj.storage_path}"
return f"{base_url}/api/file_manager/url/{file_obj.id}"
def _flatten_query_list(query_list: list) -> list:
"""兼容 JNPF 按 sheet 包装与 ZQ 扁平 queryList。"""
if not query_list:
return []
if isinstance(query_list[0], dict) and query_list[0].get("queryList") is not None:
flat: list = []
for block in query_list:
if not isinstance(block, dict):
continue
for item in block.get("queryList") or []:
if isinstance(item, dict):
flat.append(item)
return flat
return [x for x in query_list if isinstance(x, dict)]
def _default_params_from_query_list(query_list: list) -> Dict[str, Any]:
"""从 query_list 提取默认参数值"""
params: Dict[str, Any] = {}
for item in _flatten_query_list(query_list):
field = item.get("field") or item.get("vModel") or item.get("prop")
if not field:
continue
default_val = item.get("defaultValue")
if default_val is None and "value" in item:
default_val = item.get("value")
if default_val is not None:
params[field] = default_val
return params
def _query_list_for_sheet(query_list: list, sheet_id: str) -> list:
"""按 sheet 过滤 query 项;扁平结构返回全部。"""
if not query_list:
return []
if isinstance(query_list[0], dict) and query_list[0].get("queryList") is not None:
for block in query_list:
if str(block.get("sheet") or "") == str(sheet_id):
return block.get("queryList") or []
return _flatten_query_list(query_list)
return query_list
def _parse_preview_draft_field(value: Any) -> Any:
if value is None:
return None
return ReportVersionService._parse_json_field(value, None)
async def _build_preview(
db: AsyncSession,
version,
template,
params: Dict[str, Any],
request=None,
*,
snapshot_override: Any = None,
cells_override: Any = None,
query_list_override: Any = None,
sort_list_override: Any = None,
column_list_override: Any = None,
fence_list_override: Any = None,
convert_config_override: Any = None,
) -> Dict[str, Any]:
snapshot = (
snapshot_override
if snapshot_override is not None
else (version.snapshot or {})
)
cells = cells_override if cells_override is not None else (version.cells or {})
query_list = (
query_list_override
if query_list_override is not None
else (version.query_list or [])
)
sort_list = (
sort_list_override
if sort_list_override is not None
else (version.sort_list or [])
)
column_list = (
column_list_override
if column_list_override is not None
else (version.column_list or [])
)
fence_list = (
fence_list_override
if fence_list_override is not None
else (version.fence_list or [])
)
convert_config = (
convert_config_override
if convert_config_override is not None
else (version.convert_config or {})
)
query_defaults = _default_params_from_query_list(query_list)
user_id = getattr(getattr(request, "state", None), "user_id", None) if request else None
user_name = ""
dept_name = ""
if user_id:
try:
from core.user.service import UserService
from core.dept.service import DeptService
user = await UserService.get(db, user_id)
if user:
user_name = user.name or user.username or ""
if user.dept_id:
dept = await DeptService.get(db, user.dept_id)
if dept:
dept_name = dept.name or ""
except Exception:
pass
system_params = build_system_params(
user_id=str(user_id) if user_id else None,
user_name=user_name,
dept_name=dept_name,
)
merged_params = merge_preview_params(query_defaults, params, system_params)
lookup = await build_lookup_cache_from_db(db)
datasets = await ReportDatasetBridge.fetch_all(
db,
version.id,
merged_params,
sort_list=sort_list,
version_convert=convert_config,
lookup=lookup,
)
filled = transform(
snapshot,
cells,
datasets,
merged_params,
column_list=column_list,
fence_list=fence_list,
)
chart_data = build_chart_data(cells, datasets)
watermark = build_watermark_payload(
bool(template.allow_watermark),
template.watermark_config or {},
template_name=template.name or "",
)
warnings = list(detect_expression_cycles(cells, snapshot) or [])
warnings.extend(collect_preview_warnings(datasets=datasets, snapshot=filled))
return {
"snapshot": filled,
"cells": cells,
"queryList": query_list,
"chartData": chart_data,
"allowExport": bool(template.allow_export),
"allowPrint": bool(template.allow_print),
"allowWatermark": watermark["show"],
"watermarkConfig": watermark["config"],
"watermark": watermark,
"fullName": template.name,
"warnings": warnings,
}
@router.post("/{version_id}/preview", summary="预览(设计/运行)")
async def preview_version(
request: Request,
version_id: str,
body: ReportPreviewIn,
db: AsyncSession = Depends(get_db),
):
try:
version = await ReportVersionService.get(db, version_id)
template = await ReportService.get(db, version.template_id)
data = await _build_preview(db, version, template, body.params or {}, request)
return data
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/preview-template", summary="按模板预览(启用中版本)")
async def preview_template(
request: Request,
body: ReportPreviewIn,
template_id: str = Query(None, alias="templateId"),
template_code: str = Query(None, alias="templateCode"),
db: AsyncSession = Depends(get_db),
):
try:
if template_code:
template = await ReportService.get_by_code(db, template_code)
elif template_id:
template = await ReportService.get(db, template_id)
else:
raise ReportServiceException("需要 template_id 或 template_code")
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("没有可用版本")
data = await _build_preview(db, version, template, body.params or {}, request)
return data
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/query-list/{template_id}", summary="查询条件列表")
async def get_query_list(template_id: str, db: AsyncSession = Depends(get_db)):
try:
version = await ReportVersionService.get_active(db, template_id)
if not version:
version = await ReportVersionService.get_designing_or_latest(db, template_id)
if not version:
return {"queryList": []}
return {"queryList": version.query_list or []}
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/query-list/code/{code}", summary="按编码获取查询条件")
async def get_query_list_by_code(code: str, db: AsyncSession = Depends(get_db)):
try:
template = await ReportService.get_by_code(db, code)
version = await ReportVersionService.get_active(db, template.id)
if not version:
version = await ReportVersionService.get_designing_or_latest(db, template.id)
if not version:
return {"queryList": [], "templateId": template.id}
return {
"queryList": version.query_list or [],
"templateId": template.id,
"fullName": template.name,
}
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{version_id}/preview-design", summary="设计态预览")
async def preview_design(
request: Request,
version_id: str,
body: ReportPreviewIn,
db: AsyncSession = Depends(get_db),
):
"""设计器内预览;若传入 snapshot/cells 则使用当前编辑器草稿"""
try:
version = await ReportVersionService.get(db, version_id)
template = await ReportService.get(db, version.template_id)
draft_snapshot = _parse_preview_draft_field(body.snapshot)
draft_cells = _parse_preview_draft_field(body.cells)
draft_query_list = _parse_preview_draft_field(body.query_list)
draft_sort_list = _parse_preview_draft_field(body.sort_list)
draft_column_list = _parse_preview_draft_field(body.column_list)
draft_fence_list = _parse_preview_draft_field(body.fence_list)
draft_convert_config = _parse_preview_draft_field(body.convert_config)
return await _build_preview(
db,
version,
template,
body.params or {},
request,
snapshot_override=draft_snapshot,
cells_override=draft_cells,
query_list_override=draft_query_list,
sort_list_override=draft_sort_list,
column_list_override=draft_column_list,
fence_list_override=draft_fence_list,
convert_config_override=draft_convert_config,
)
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{version_id}/export-excel-design", summary="设计态导出 Excel")
async def export_excel_design(
request: Request,
version_id: str,
body: ReportPreviewIn,
db: AsyncSession = Depends(get_db),
):
"""设计器内导出;若传入 snapshot/cells 则使用当前编辑器草稿"""
try:
version = await ReportVersionService.get(db, version_id)
template = await ReportService.get(db, version.template_id)
draft_snapshot = _parse_preview_draft_field(body.snapshot)
draft_cells = _parse_preview_draft_field(body.cells)
draft_query_list = _parse_preview_draft_field(body.query_list)
draft_sort_list = _parse_preview_draft_field(body.sort_list)
draft_column_list = _parse_preview_draft_field(body.column_list)
draft_fence_list = _parse_preview_draft_field(body.fence_list)
draft_convert_config = _parse_preview_draft_field(body.convert_config)
return await _export_excel_response(
db,
version,
template,
body.params or {},
request,
snapshot_override=draft_snapshot,
cells_override=draft_cells,
query_list_override=draft_query_list,
sort_list_override=draft_sort_list,
column_list_override=draft_column_list,
fence_list_override=draft_fence_list,
convert_config_override=draft_convert_config,
)
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
async def _export_excel_response(
db: AsyncSession,
version,
template,
params: Dict[str, Any],
request=None,
*,
snapshot_override: Any = None,
cells_override: Any = None,
query_list_override: Any = None,
sort_list_override: Any = None,
column_list_override: Any = None,
fence_list_override: Any = None,
convert_config_override: Any = None,
):
if not template.allow_export:
raise ReportServiceException("该报表不允许导出")
preview = await _build_preview(
db,
version,
template,
params,
request,
snapshot_override=snapshot_override,
cells_override=cells_override,
query_list_override=query_list_override,
sort_list_override=sort_list_override,
column_list_override=column_list_override,
fence_list_override=fence_list_override,
convert_config_override=convert_config_override,
)
watermark = preview.get("watermark") or {}
wm_text = ""
if watermark.get("show"):
wm_text = str((watermark.get("config") or {}).get("content") or "")
base_url = str(request.base_url).rstrip("/") if request else getattr(settings, "BASE_URL", "")
from online_dev.report_manager.engine.export_excel_extras import build_fetch_url
content = snapshot_to_xlsx_bytes(
preview.get("snapshot") or {},
watermark_text=wm_text,
fetch_url=build_fetch_url(base_url),
)
filename = f"{template.code or 'report'}.xlsx"
return StreamingResponse(
iter([content]),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
async def _export_pdf_response(
db: AsyncSession,
version,
template,
params: Dict[str, Any],
request=None,
):
if not template.allow_export:
raise ReportServiceException("该报表不允许导出")
preview = await _build_preview(db, version, template, params, request)
watermark = preview.get("watermark") or {}
wm_text = ""
if watermark.get("show"):
wm_text = str((watermark.get("config") or {}).get("content") or "")
content = snapshot_to_pdf_bytes(
preview.get("snapshot") or {},
title=template.name or "",
watermark_text=wm_text,
)
filename = f"{template.code or 'report'}.pdf"
return StreamingResponse(
iter([content]),
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/{version_id}/export-excel", summary="导出 Excel(按版本)")
async def export_excel_version(
request: Request,
version_id: str,
body: ReportPreviewIn,
db: AsyncSession = Depends(get_db),
):
try:
version = await ReportVersionService.get(db, version_id)
template = await ReportService.get(db, version.template_id)
return await _export_excel_response(db, version, template, body.params or {}, request)
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/upload/file", response_model=ReportUploadOut, summary="上传文件(图片等)")
async def upload_report_file(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
):
try:
content = await file.read()
if not content:
raise ReportServiceException("文件为空")
filename = file.filename or "upload.bin"
file_obj = await FileManagerService.upload_file(
db=db,
file_content=content,
filename=filename,
file_size=len(content),
is_public=True,
source="report",
)
return ReportUploadOut(name=file_obj.name, url=_build_file_access_url(file_obj))
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.exception("报表文件上传失败")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/downImg", response_model=ReportUploadOut, summary="远端/Base64 图片转存")
async def download_remote_image(
body: ReportDownImgIn,
db: AsyncSession = Depends(get_db),
):
try:
img_value = (body.img_value or "").strip()
if not img_value:
raise ReportServiceException("图片内容为空")
img_type = (body.img_type or "").upper()
content: bytes
ext = ".jpeg"
if img_type == "BASE64":
base64_img = img_value
match = re.search(r"data:image/(\w+);base64,", img_value)
if match:
ext = f".{match.group(1)}"
base64_img = img_value.split(",", 1)[-1]
content = base64.b64decode(base64_img)
else:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
resp = await client.get(img_value)
resp.raise_for_status()
content = resp.content
ctype = resp.headers.get("content-type", "")
if "png" in ctype:
ext = ".png"
elif "gif" in ctype:
ext = ".gif"
elif "webp" in ctype:
ext = ".webp"
if not content:
raise ReportServiceException("无法获取图片数据")
file_obj = await FileManagerService.upload_file(
db=db,
file_content=content,
filename=f"report_img{ext}",
file_size=len(content),
is_public=True,
source="report",
)
return ReportUploadOut(name=file_obj.name, url=_build_file_access_url(file_obj))
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.exception("报表图片转存失败")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/import-excel", response_model=ReportImportExcelOut, summary="导入 Excel 到网格")
@router.post("/ImportExcel", response_model=ReportImportExcelOut, summary="导入 ExcelJNPF 兼容路径)")
async def import_excel_file(
file: UploadFile = File(...),
):
try:
content = await file.read()
if not content:
raise ReportServiceException("文件为空")
grid = parse_excel_to_grid(content)
return ReportImportExcelOut(
rowsCount=grid["rowsCount"],
colsCount=grid["colsCount"],
data=grid["data"],
)
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.exception("Excel 解析失败")
raise HTTPException(status_code=400, detail=f"Excel 解析失败: {e}")
@router.post("/export-excel/template", summary="导出 Excel(按模板启用版本)")
async def export_excel_template(
request: Request,
body: ReportPreviewIn,
template_id: str = Query(None, alias="templateId"),
template_code: str = Query(None, alias="templateCode"),
db: AsyncSession = Depends(get_db),
):
try:
if template_code:
template = await ReportService.get_by_code(db, template_code)
elif template_id:
template = await ReportService.get(db, template_id)
else:
raise ReportServiceException("需要 template_id 或 template_code")
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("没有可用版本")
return await _export_excel_response(db, version, template, body.params or {}, request)
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/export-pdf/template", summary="导出 PDF(按模板启用版本)")
async def export_pdf_template(
request: Request,
body: ReportPreviewIn,
template_id: str = Query(None, alias="templateId"),
template_code: str = Query(None, alias="templateCode"),
db: AsyncSession = Depends(get_db),
):
try:
if template_code:
template = await ReportService.get_by_code(db, template_code)
elif template_id:
template = await ReportService.get(db, template_id)
else:
raise ReportServiceException("需要 template_id 或 template_code")
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("没有可用版本")
return await _export_pdf_response(db, version, template, body.params or {}, request)
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/export-pdf/template", summary="导出 PDF(按模板启用版本)")
async def export_pdf_template(
request: Request,
body: ReportPreviewIn,
template_id: str = Query(None, alias="templateId"),
template_code: str = Query(None, alias="templateCode"),
db: AsyncSession = Depends(get_db),
):
try:
if template_code:
template = await ReportService.get_by_code(db, template_code)
elif template_id:
template = await ReportService.get(db, template_id)
else:
raise ReportServiceException("需要 template_id 或 template_code")
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("没有可用版本")
return await _export_pdf_response(db, version, template, body.params or {}, request)
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))