feat: restore source parity and harden agent runtime

This commit is contained in:
2026-06-22 11:17:26 +08:00
parent e33f08277b
commit 0793eb82d6
596 changed files with 168879 additions and 290 deletions
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""报表管理模块"""
@@ -0,0 +1,74 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
报表模块端到端验收自检(无需启动浏览器)。
运行:cd backend-fastapi && python -m online_dev.report_manager.acceptance_e2e
"""
from __future__ import annotations
import sys
from pathlib import Path
from online_dev.report_manager.engine.test_golden import test_all_golden_fixtures
def _check_routes_registered() -> None:
root = Path(__file__).parent
sources = [
(root / "api.py").read_text(encoding="utf-8"),
(root / "data_api.py").read_text(encoding="utf-8"),
]
blob = "\n".join(sources)
required = [
'"/list"',
'"/save"',
'"/{version_id}/preview"',
'"/preview-template"',
'"/export-excel/template"',
'"/export-pdf/template"',
'"/import-excel"',
'"/query-list/{template_id}"',
'"/{template_id}/publish"',
]
missing = [p for p in required if p not in blob]
if missing:
raise AssertionError(f"missing route decorators for: {missing}")
def _check_jnpf_db_fixtures() -> None:
fixtures_dir = Path(__file__).parent / "engine" / "fixtures"
names = [
"golden_jnpf_db_user_list.json",
"golden_jnpf_db_user_group.json",
"golden_jnpf_db_user_matrix.json",
]
for name in names:
path = fixtures_dir / name
if not path.is_file():
raise AssertionError(f"missing JNPF DB fixture: {name}")
def main() -> int:
print("1/3 route registration …")
_check_routes_registered()
print(" ok")
print("2/3 JNPF DB golden fixtures …")
_check_jnpf_db_fixtures()
print(" ok (3 fixtures)")
print("3/3 golden transform regression …")
test_all_golden_fixtures()
count = len(list((Path(__file__).parent / "engine" / "fixtures").glob("golden_*.json")))
print(f" ok ({count} fixtures)")
print("\nManual E2E (browser):")
print(" - 新建报表 → 设计 → 保存 → 发布版本")
print(" - 发布菜单 → 运行时填写查询 → 预览")
print(" - Excel / PDF 导出、浏览器打印(>100 张图告警)")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,388 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""报表管理 API"""
import json
import logging
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from core.application.model import Application
from core.menu.model import Menu
from app.base_schema import PaginatedResponse
from online_dev.report_manager.exceptions import ReportServiceException
from online_dev.report_manager.schema import (
ReportImportCheckIn,
ReportImportCheckOut,
ReportImportIn,
ReportPublishIn,
ReportSaveIn,
ReportSaveOut,
ReportTemplateCreateIn,
ReportTemplateListOut,
ReportTemplateOut,
ReportTemplateUpdateIn,
ReportVersionListOut,
ReportVersionOut,
)
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
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/report", tags=["报表管理"])
def _fmt_dt(dt) -> str:
return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else ""
def _build_template_out(tpl, active_version_id: str = None) -> dict:
return {
"id": str(tpl.id),
"application_id": tpl.application_id,
"name": tpl.name,
"code": tpl.code,
"category": tpl.category or "",
"description": tpl.description or "",
"status": tpl.status,
"allow_export": bool(tpl.allow_export),
"allow_print": bool(tpl.allow_print),
"allow_watermark": bool(tpl.allow_watermark),
"watermark_config": tpl.watermark_config or {},
"sort": tpl.sort or 0,
"active_version_id": active_version_id,
"sys_create_datetime": _fmt_dt(tpl.sys_create_datetime),
"sys_update_datetime": _fmt_dt(tpl.sys_update_datetime),
}
def _build_version_out(version, datasets: list = None) -> dict:
return {
"id": str(version.id),
"template_id": version.template_id,
"version": version.version,
"state": int(version.state),
"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 or [],
"sys_create_datetime": _fmt_dt(version.sys_create_datetime),
"sys_update_datetime": _fmt_dt(version.sys_update_datetime),
}
@router.get("/list", response_model=PaginatedResponse[ReportTemplateListOut], summary="报表列表")
async def list_reports(
application_id: str = Query(None, alias="applicationId"),
name: str = Query(None),
code: str = Query(None),
category: str = Query(None),
status: str = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500, alias="pageSize"),
db: AsyncSession = Depends(get_db),
):
result = await ReportService.list_with_data_scope(
db, page=page, page_size=page_size,
application_id=application_id, name=name, code=code,
category=category, status=status,
)
items = result["items"]
app_ids = list({t.application_id for t in items if t.application_id})
app_map = {}
if app_ids:
rows = await db.execute(
select(Application.id, Application.name, Application.code).where(Application.id.in_(app_ids))
)
app_map = {r.id: {"name": r.name, "code": r.code} for r in rows}
menu_paths: set[str] = set()
if items:
paths = [f"/report-render/{t.code}" for t in items]
menu_rows = await db.execute(select(Menu.path).where(Menu.path.in_(paths)))
menu_paths = set(menu_rows.scalars().all())
out = []
for t in items:
info = app_map.get(t.application_id, {})
out.append({
"id": str(t.id),
"application_id": t.application_id,
"application_name": info.get("name", ""),
"application_code": info.get("code", ""),
"name": t.name,
"code": t.code,
"category": t.category or "",
"description": t.description or "",
"status": t.status,
"has_release_menu": f"/report-render/{t.code}" in menu_paths,
"sort": t.sort or 0,
"sys_create_datetime": _fmt_dt(t.sys_create_datetime),
"sys_update_datetime": _fmt_dt(t.sys_update_datetime),
})
return PaginatedResponse(items=out, total=result["total"])
@router.get("/categories", response_model=List[str], summary="分类列表")
async def get_categories(
application_id: str = Query(None, alias="applicationId"),
db: AsyncSession = Depends(get_db),
):
return await ReportService.get_categories(db, application_id)
@router.get("/code/{code}", response_model=ReportTemplateOut, summary="按编码获取模板")
async def get_by_code(code: str, db: AsyncSession = Depends(get_db)):
try:
tpl = await ReportService.get_by_code(db, code)
vid = await ReportService.get_active_version_id(db, tpl.id)
return _build_template_out(tpl, vid)
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/{template_id}", response_model=ReportTemplateOut, summary="模板详情")
async def get_template(template_id: str, db: AsyncSession = Depends(get_db)):
try:
tpl = await ReportService.get(db, template_id)
vid = await ReportService.get_active_version_id(db, template_id)
return _build_template_out(tpl, vid)
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("", response_model=ReportTemplateOut, summary="创建报表")
async def create_template(
request: Request,
data: ReportTemplateCreateIn,
db: AsyncSession = Depends(get_db),
):
try:
tpl = await ReportService.create(db, data.model_dump(), request.state.user_id)
ver = await ReportVersionService.get_designing_or_latest(db, tpl.id)
return _build_template_out(tpl, ver.id if ver else None)
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{template_id}", response_model=ReportTemplateOut, summary="更新模板")
async def update_template(
request: Request,
template_id: str,
data: ReportTemplateUpdateIn,
db: AsyncSession = Depends(get_db),
):
try:
tpl = await ReportService.update(
db, template_id, data.model_dump(exclude_none=True), request.state.user_id
)
vid = await ReportService.get_active_version_id(db, template_id)
return _build_template_out(tpl, vid)
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/batch", summary="批量删除")
async def batch_delete(ids: List[str] = Query(...), db: AsyncSession = Depends(get_db)):
count = await ReportService.batch_delete(db, ids)
return {"count": count}
@router.delete("/{template_id}", response_model=ReportTemplateOut, summary="删除报表")
async def delete_template(template_id: str, db: AsyncSession = Depends(get_db)):
try:
tpl = await ReportService.get(db, template_id)
out = _build_template_out(tpl)
await ReportService.delete(db, template_id)
return out
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/{template_id}/versions", response_model=List[ReportVersionListOut], summary="版本列表")
async def list_versions(template_id: str, db: AsyncSession = Depends(get_db)):
try:
await ReportService.get(db, template_id)
versions = await ReportVersionService.list_by_template(db, template_id)
return [
{
"id": str(v.id),
"template_id": v.template_id,
"version": v.version,
"state": int(v.state),
"sys_create_datetime": _fmt_dt(v.sys_create_datetime),
"sys_update_datetime": _fmt_dt(v.sys_update_datetime),
}
for v in versions
]
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/version/{version_id}", response_model=ReportVersionOut, summary="版本详情")
async def get_version(version_id: str, db: AsyncSession = Depends(get_db)):
try:
detail = await ReportVersionService.get_version_detail(db, version_id)
return _build_version_out(detail["version"], detail["datasets"])
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/save", response_model=ReportSaveOut, summary="保存版本")
async def save_version(
request: Request,
data: ReportSaveIn,
db: AsyncSession = Depends(get_db),
):
try:
payload = data.model_dump(by_alias=False)
result = await ReportVersionService.save(db, payload, request.state.user_id)
return result
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/version/{version_id}", summary="删除版本")
async def delete_version(version_id: str, db: AsyncSession = Depends(get_db)):
try:
await ReportVersionService.delete_version(db, version_id)
return {"success": True}
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/version/{version_id}/copy", response_model=ReportSaveOut, summary="复制版本")
async def copy_version(
request: Request,
version_id: str,
db: AsyncSession = Depends(get_db),
):
"""复制版本为新的「设计中」版本(对标 JNPF POST /Report/Info/{versionId}"""
try:
new_ver = await ReportVersionService.duplicate_version(
db, version_id, request.state.user_id
)
return {
"template_id": new_ver.template_id,
"version_id": str(new_ver.id),
"state": int(new_ver.state),
}
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{template_id}/copy", response_model=ReportTemplateOut, summary="复制报表")
async def copy_template(
request: Request,
template_id: str,
new_code: str = Query(..., alias="newCode"),
new_name: str = Query(None, alias="newName"),
db: AsyncSession = Depends(get_db),
):
try:
tpl = await ReportService.copy(db, template_id, new_code, new_name, request.state.user_id)
ver = await ReportVersionService.get_designing_or_latest(db, tpl.id)
return _build_template_out(tpl, ver.id if ver else None)
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/{template_id}/export", summary="导出报表配置")
async def export_report_config(
template_id: str,
db: AsyncSession = Depends(get_db),
):
try:
config = await ReportService.export_config(db, template_id)
content = json.dumps(config, ensure_ascii=False, indent=2)
return StreamingResponse(
iter([content]),
media_type="application/json",
headers={
"Content-Disposition": f'attachment; filename="{config["code"]}.json"',
},
)
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/import/check", response_model=ReportImportCheckOut, summary="导入预检查")
async def check_import(data: ReportImportCheckIn, db: AsyncSession = Depends(get_db)):
return await ReportService.check_import(db, data.code)
@router.post("/import", response_model=ReportTemplateOut, summary="导入报表配置")
async def import_report_config(
request: Request,
data: ReportImportIn,
db: AsyncSession = Depends(get_db),
):
try:
tpl = await ReportService.import_config(
db,
data.model_dump(),
request.state.user_id,
)
ver = await ReportVersionService.get_designing_or_latest(db, tpl.id)
return _build_template_out(tpl, ver.id if ver else None)
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/selector", summary="分类树形选择器")
async def report_selector(
application_id: str = Query(None, alias="applicationId"),
db: AsyncSession = Depends(get_db),
):
return await ReportService.get_selector(db, application_id)
@router.get("/{template_id}/release-menu", summary="已发布菜单信息")
async def get_release_menu(template_id: str, db: AsyncSession = Depends(get_db)):
try:
return await ReportService.get_release_menu(db, template_id)
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{template_id}/publish", response_model=ReportTemplateOut, summary="发布到菜单")
async def publish_template(
template_id: str,
data: ReportPublishIn,
db: AsyncSession = Depends(get_db),
):
try:
tpl = await ReportService.publish(
db,
template_id,
{
"menu_name": data.menu_name,
"menu_parent_id": data.menu_parent_id,
"menu_icon": data.menu_icon,
"menu_order": data.menu_order,
},
)
ver = await ReportVersionService.get_active(db, tpl.id)
return _build_template_out(tpl, ver.id if ver else None)
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{template_id}/unpublish", response_model=ReportTemplateOut, summary="取消发布")
async def unpublish_template(template_id: str, db: AsyncSession = Depends(get_db)):
try:
tpl = await ReportService.unpublish(db, template_id)
ver = await ReportVersionService.get_designing_or_latest(db, tpl.id)
return _build_template_out(tpl, ver.id if ver else None)
except ReportServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -0,0 +1,29 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""报表常量与默认值"""
from typing import Any, Dict
def default_snapshot() -> Dict[str, Any]:
return {
"id": "workbook",
"sheetOrder": ["sheet1"],
"sheets": {
"sheet1": {
"id": "sheet1",
"name": "Sheet1",
"cellData": {},
"rowCount": 100,
"columnCount": 26,
},
},
}
def default_cells() -> Dict[str, Any]:
return {
"cells": [],
"floatEcharts": {},
"cellEcharts": {},
"floatImages": {},
}
@@ -0,0 +1,626 @@
#!/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))
@@ -0,0 +1,168 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""报表数据集桥接:版本 ↔ core_data_source"""
import asyncio
import logging
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from core.data_source.model import DataSource
from core.data_source.service import DataSourceService
from online_dev.report_manager.engine.convert_lookup import ConvertLookupCache
from online_dev.report_manager.engine.dataset_transform import transform_dataset_rows
from online_dev.report_manager.engine.sort_apply import (
apply_sort_to_rows,
get_sort_rules_for_alias,
)
from online_dev.report_manager.model import ReportDataset
logger = logging.getLogger(__name__)
class ReportDatasetBridge:
@staticmethod
async def list_by_version(db: AsyncSession, version_id: str) -> List[Dict[str, Any]]:
stmt = (
select(ReportDataset, DataSource.code, DataSource.name)
.join(DataSource, DataSource.id == ReportDataset.data_source_id)
.where(
ReportDataset.version_id == version_id,
ReportDataset.is_deleted == False,
)
.order_by(ReportDataset.sort)
)
rows = await db.execute(stmt)
result = []
for ds, code, name in rows:
result.append({
"id": ds.id,
"version_id": ds.version_id,
"data_source_id": ds.data_source_id,
"data_source_code": code,
"data_source_name": name,
"alias": ds.alias,
"field_mapping": ds.field_mapping or {},
"convert_config": ds.convert_config or {},
"sort": ds.sort or 0,
})
return result
@staticmethod
async def sync_datasets(
db: AsyncSession,
version_id: str,
data_set_list: List[Dict[str, Any]],
) -> None:
stmt = update(ReportDataset).where(
ReportDataset.version_id == version_id,
ReportDataset.is_deleted == False,
).values(is_deleted=True)
await db.execute(stmt)
for idx, item in enumerate(data_set_list):
ds_id = item.get("data_source_id") or item.get("dataSourceId")
if not ds_id:
continue
alias = item.get("alias") or item.get("name") or f"ds_{idx}"
record = ReportDataset(
version_id=version_id,
data_source_id=ds_id,
alias=alias,
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),
)
db.add(record)
await db.flush()
@staticmethod
async def delete_by_version(db: AsyncSession, version_id: str) -> None:
stmt = update(ReportDataset).where(
ReportDataset.version_id == version_id,
).values(is_deleted=True)
await db.execute(stmt)
@staticmethod
async def copy_datasets(db: AsyncSession, src_version_id: str, dst_version_id: str) -> None:
stmt = select(ReportDataset).where(
ReportDataset.version_id == src_version_id,
ReportDataset.is_deleted == False,
)
for src in (await db.execute(stmt)).scalars().all():
db.add(ReportDataset(
version_id=dst_version_id,
data_source_id=src.data_source_id,
alias=src.alias,
field_mapping=src.field_mapping,
convert_config=src.convert_config,
sort=src.sort,
))
@staticmethod
async def _fetch_one_dataset(
ds: Dict[str, Any],
params: Dict[str, Any],
max_rows: int,
sort_list: List[Any],
version_convert: Any,
lookup: Optional[ConvertLookupCache] = None,
) -> Tuple[str, List[Any]]:
async with AsyncSessionLocal() as session:
source = await DataSourceService.get_by_id(session, ds["data_source_id"])
if not source:
return ds["alias"], []
data = await DataSourceService.execute_by_id(
session, ds["data_source_id"], params, max_rows=max_rows
)
rows: List[Any]
if isinstance(data, list):
rows = data
elif isinstance(data, dict):
rows = [data]
else:
rows = [{"value": data}] if data is not None else []
rules = get_sort_rules_for_alias(sort_list or [], ds["alias"])
if rules and rows and isinstance(rows[0], dict):
rows = apply_sort_to_rows(rows, rules)
rows = transform_dataset_rows(
rows,
field_mapping=ds.get("field_mapping"),
dataset_convert=ds.get("convert_config"),
version_convert=version_convert,
alias=ds["alias"],
lookup=lookup,
)
return ds["alias"], rows
@staticmethod
async def fetch_all(
db: AsyncSession,
version_id: str,
params: Dict[str, Any] = None,
max_rows: int = 50000,
sort_list: List[Any] = None,
version_convert: Any = None,
lookup: Optional[ConvertLookupCache] = None,
) -> Dict[str, List[Any]]:
"""拉取版本关联的全部数据集(含 field_mapping / convertConfig 变换)"""
datasets = await ReportDatasetBridge.list_by_version(db, version_id)
params = params or {}
if not datasets:
return {}
if len(datasets) == 1:
alias, rows = await ReportDatasetBridge._fetch_one_dataset(
datasets[0], params, max_rows, sort_list, version_convert, lookup
)
return {alias: rows}
pairs = await asyncio.gather(
*[
ReportDatasetBridge._fetch_one_dataset(
ds, params, max_rows, sort_list, version_convert, lookup
)
for ds in datasets
]
)
return dict(pairs)
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""报表渲染引擎"""
@@ -0,0 +1,172 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""报表图表数据聚合(对齐 JNPF ChartUtil"""
from __future__ import annotations
from collections import defaultdict
from decimal import Decimal
from typing import Any, Dict, List, Optional, Set, Tuple
def _parse_field(field: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
"""alias.field -> (alias, field_name)"""
if not field or not isinstance(field, str):
return None, None
parts = field.split(".", 1)
if len(parts) == 2:
return parts[0], parts[1]
return None, field
def _collect_rows(datasets: Dict[str, List[Dict[str, Any]]], dataset_names: Set[str]) -> List[Dict[str, Any]]:
rows: List[Dict[str, Any]] = []
for name in dataset_names:
rows.extend(datasets.get(name) or [])
return rows
def _aggregate(values: List[Any], summary_type: str) -> str:
if not values:
return ""
st = (summary_type or "none").lower()
nums: List[Decimal] = []
for v in values:
try:
nums.append(Decimal(str(v)))
except Exception:
pass
if st == "sum" and nums:
return str(sum(nums))
if st == "avg" and nums:
return str(sum(nums) / len(nums))
if st == "max" and nums:
return str(max(nums))
if st == "min" and nums:
return str(min(nums))
if st == "count":
return str(len(values))
return str(values[-1]) if values else ""
def _build_field(
data_list: List[Dict[str, Any]],
classify_key: Optional[str],
series_name_key: Optional[str],
series_data_key: Optional[str],
max_key: Optional[str],
summary_type: str,
) -> Dict[str, Any]:
chart_map: Dict[Any, Dict[Any, List[Any]]] = defaultdict(lambda: defaultdict(list))
max_map: Dict[Any, List[Any]] = defaultdict(list)
for row in data_list:
if not classify_key:
continue
classify = row.get(classify_key)
if classify is None:
continue
value = row.get(series_data_key) if series_data_key else None
if value is None:
continue
series = row.get(series_name_key) if series_name_key else ""
chart_map[series][classify].append(value)
if max_key:
mx = row.get(max_key)
if mx is not None:
max_map[classify].append(mx)
series_name_list: List[str] = []
classify_map: Dict[Any, List[List[str]]] = defaultdict(list)
max_counts = [0]
for series, classify_name_map in chart_map.items():
series_name_list.append(str(series))
for classify, value_list in classify_name_map.items():
agg = _aggregate(value_list, summary_type)
classify_map[classify].append([agg])
max_counts.append(len(classify_map[classify]))
classify_name_list = sorted(str(k) for k in classify_map.keys())
max_field_list: List[str] = []
for classify in classify_name_list:
objects = max_map.get(classify) or [0]
max_field_list.append(_aggregate(objects, "max"))
max_len = max(max_counts) if max_counts else 0
series_data_list: List[List[str]] = []
for i in range(max_len):
row_data: List[str] = []
for category in classify_name_list:
category_list = classify_map.get(category) or []
category_data = category_list[i] if i < len(category_list) else []
row_data.append(category_data[0] if category_data else "")
series_data_list.append(row_data)
result: Dict[str, Any] = {
"classifyNameField": classify_name_list,
"seriesDataField": series_data_list,
}
if series_name_key:
result["seriesNameField"] = series_name_list
if max_key:
result["maxField"] = max_field_list
return result
def _echart_configs_from_cells(cells: Dict[str, Any]) -> List[Dict[str, Any]]:
configs: List[Dict[str, Any]] = []
if not cells:
return configs
for key, store in (
("floatEcharts", cells.get("floatEcharts")),
("cellEcharts", cells.get("cellEcharts")),
):
if not isinstance(store, dict):
continue
for drawing_id, item in store.items():
if not isinstance(item, dict):
continue
option = item.get("option") or {}
configs.append(
{
"drawingId": item.get("drawingId") or drawing_id,
"option": option,
"source": key,
}
)
return configs
def build_chart_data(
cells: Dict[str, Any],
datasets: Dict[str, List[Dict[str, Any]]],
) -> List[Dict[str, Any]]:
"""
生成预览用 chartData 列表。
每项: { drawingId, field: { classifyNameField, seriesNameField, seriesDataField, maxField? } }
"""
result: List[Dict[str, Any]] = []
for cfg in _echart_configs_from_cells(cells):
drawing_id = cfg.get("drawingId")
option = cfg.get("option") or {}
dataset_names: Set[str] = set()
classify_alias, classify_field = _parse_field(option.get("classifyNameField"))
series_alias, series_name_field = _parse_field(option.get("seriesNameField"))
data_alias, series_data_field = _parse_field(option.get("seriesDataField"))
max_alias, max_field = _parse_field(option.get("maxField"))
for alias in (classify_alias, series_alias, data_alias, max_alias):
if alias:
dataset_names.add(alias)
if not dataset_names:
continue
rows = _collect_rows(datasets, dataset_names)
field = _build_field(
rows,
classify_field,
series_name_field,
series_data_field,
max_field,
option.get("summaryType") or "none",
)
result.append({"drawingId": drawing_id, "field": field})
return result
@@ -0,0 +1,41 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""二维码/条形码单元格:预览时解析静态值或 #{参数}"""
import copy
from typing import Any, Dict
from online_dev.report_manager.engine.preview_mvp import _replace_params_in_value
def apply_code_cells(
snapshot: Dict[str, Any],
params: Dict[str, Any],
) -> Dict[str, Any]:
"""扫描 snapshot 中 qrCode/jsbarcode 单元格,将 field 解析后写入 v"""
if not snapshot:
return snapshot or {}
result = copy.deepcopy(snapshot)
sheets = result.get("sheets") or {}
for sheet in sheets.values():
if not isinstance(sheet, dict):
continue
cell_data = sheet.get("cellData") or {}
for row_key, row in cell_data.items():
if not isinstance(row, dict):
continue
for col_key, cell in row.items():
if not isinstance(cell, dict):
continue
custom = cell.get("custom") or {}
code_type = custom.get("type")
if code_type not in ("qrCode", "jsbarcode"):
continue
raw = custom.get("field") or cell.get("v") or ""
if raw is None:
continue
resolved = _replace_params_in_value(str(raw), params)
cell["v"] = resolved
custom["field"] = resolved
cell["custom"] = custom
result["sheets"] = sheets
return result
@@ -0,0 +1,315 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
分栏布局(MVP
- 行分栏 colcolumnType=1 超过 maxCol 行分列 / columnType=2 分 N 栏
- 列分栏 rowcolumnType=1 超过 maxRow 列分行 / columnType=2 分 N 行
- fillEmptyRows:每栏数据不足时补空行/列
"""
from __future__ import annotations
import copy
import math
import re
from typing import Any, Dict, List, Optional, Set, Tuple
def _col_letter_to_index(col: str) -> int:
col = col.upper()
n = 0
for ch in col:
n = n * 26 + (ord(ch) - ord("A") + 1)
return n - 1
def parse_cell_range(addr: str) -> Optional[Tuple[int, int, int, int]]:
"""A2:D10 -> (r0, r1, c0, c1) 0-based 闭区间"""
if not addr or not isinstance(addr, str):
return None
m = re.match(r"^([A-Za-z]+)(\d+):([A-Za-z]+)(\d+)$", addr.strip())
if not m:
return None
c0 = _col_letter_to_index(m.group(1))
r0 = int(m.group(2)) - 1
c1 = _col_letter_to_index(m.group(3))
r1 = int(m.group(4)) - 1
if r0 > r1:
r0, r1 = r1, r0
if c0 > c1:
c0, c1 = c1, c0
return r0, r1, c0, c1
def parse_index_list(spec: Optional[str]) -> Set[int]:
"""1,2-3,6 -> 0-based 索引集合"""
result: Set[int] = set()
if not spec:
return result
for part in str(spec).split(","):
part = part.strip()
if not part:
continue
if "-" in part:
a, b = part.split("-", 1)
try:
start, end = int(a) - 1, int(b) - 1
for i in range(min(start, end), max(start, end) + 1):
result.add(i)
except ValueError:
pass
else:
try:
result.add(int(part) - 1)
except ValueError:
pass
return result
def _get_column_config_for_sheet(
layout_list: List[Any],
sheet_id: str,
) -> Optional[Dict[str, Any]]:
for item in layout_list or []:
if isinstance(item, dict) and str(item.get("sheet")) == str(sheet_id):
cfg = item.get("columnList")
return cfg if isinstance(cfg, dict) else None
return None
def _extract_region_cells(
cell_data: Dict[str, Any],
r0: int,
r1: int,
c0: int,
c1: int,
) -> Dict[Tuple[int, int], Dict[str, Any]]:
region: Dict[Tuple[int, int], Dict[str, Any]] = {}
for rk in range(r0, r1 + 1):
row = cell_data.get(str(rk))
if not isinstance(row, dict):
continue
for ck in range(c0, c1 + 1):
cell = row.get(str(ck))
if isinstance(cell, dict):
region[(rk, ck)] = copy.deepcopy(cell)
return region
def _write_cell(cell_data: Dict[str, Any], row: int, col: int, cell: Dict[str, Any]) -> None:
cell_data.setdefault(str(row), {})[str(col)] = cell
def _empty_cell() -> Dict[str, Any]:
return {"v": ""}
def _resolve_col_fence(
cfg: Dict[str, Any],
data_size: int,
) -> Optional[Tuple[int, int]]:
"""返回 (fence_num, fence_data_size)"""
if data_size < 1:
return None
column_type = str(cfg.get("columnType") or "2")
if column_type == "1":
per_fence = int(cfg.get("maxCol") or 0)
if per_fence < 1:
return None
return math.ceil(data_size / per_fence), per_fence
fence_num = int(cfg.get("rowCount") or 0)
if fence_num < 2:
return None
return fence_num, math.ceil(data_size / fence_num)
def _resolve_row_fence(
cfg: Dict[str, Any],
data_size: int,
) -> Optional[Tuple[int, int]]:
if data_size < 1:
return None
column_type = str(cfg.get("columnType") or "2")
if column_type == "1":
per_fence = int(cfg.get("maxRow") or 0)
if per_fence < 1:
return None
return math.ceil(data_size / per_fence), per_fence
fence_num = int(cfg.get("colCount") or 0)
if fence_num < 2:
return None
return fence_num, math.ceil(data_size / fence_num)
def _apply_col_split(sheet: Dict[str, Any], cfg: Dict[str, Any]) -> None:
if not cfg.get("columnState") or cfg.get("columnStyle") != "col":
return
bounds = parse_cell_range(cfg.get("columnData") or "")
if not bounds:
return
r0, r1, c0, c1 = bounds
width = c1 - c0 + 1
cell_data = sheet.setdefault("cellData", {})
region = _extract_region_cells(cell_data, r0, r1, c0, c1)
data_row_indices = sorted({r for (r, _) in region.keys()}) or list(range(r0, r1 + 1))
resolved = _resolve_col_fence(cfg, len(data_row_indices))
if not resolved:
return
fence_num, fence_data_size = resolved
fill_empty = bool(cfg.get("fillEmptyRows"))
copy_rows = parse_index_list(cfg.get("copyCol"))
for r in range(r0, r1 + 1):
row_obj = cell_data.get(str(r))
if not isinstance(row_obj, dict):
continue
for c in range(c0, c1 + 1):
if (r, c) in region and str(c) in row_obj:
del row_obj[str(c)]
for block in range(fence_num):
block_rows = data_row_indices[block * fence_data_size : (block + 1) * fence_data_size]
target_c_base = c0 + block * width
out_row = r0
for copy_r in sorted(copy_rows):
if copy_r < r0 or copy_r > r1:
continue
for dc in range(width):
src = region.get((copy_r, c0 + dc))
if src:
_write_cell(cell_data, out_row, target_c_base + dc, copy.deepcopy(src))
out_row += 1
header_offset = out_row - r0
data_written = 0
for local_i, src_r in enumerate(block_rows):
if src_r in copy_rows:
continue
dst_r = r0 + header_offset + local_i
data_written += 1
for dc in range(width):
src = region.get((src_r, c0 + dc))
if src:
_write_cell(cell_data, dst_r, target_c_base + dc, copy.deepcopy(src))
if fill_empty and data_written < fence_data_size:
for pad_i in range(data_written, fence_data_size):
dst_r = r0 + header_offset + pad_i
for dc in range(width):
col_idx = target_c_base + dc
row_obj = cell_data.get(str(dst_r)) or {}
if str(col_idx) not in row_obj:
_write_cell(cell_data, dst_r, col_idx, _empty_cell())
max_r, max_c = r1, c1
for rk, row in cell_data.items():
if not str(rk).isdigit() or not isinstance(row, dict):
continue
max_r = max(max_r, int(rk))
for ck in row.keys():
if str(ck).isdigit():
max_c = max(max_c, int(ck))
sheet["rowCount"] = max(int(sheet.get("rowCount") or 0), max_r + 5)
sheet["columnCount"] = max(int(sheet.get("columnCount") or 0), max_c + 5)
def _apply_row_split(sheet: Dict[str, Any], cfg: Dict[str, Any]) -> None:
"""列分栏:将区域内列拆成多块,纵向堆叠"""
if not cfg.get("columnState") or cfg.get("columnStyle") != "row":
return
bounds = parse_cell_range(cfg.get("columnData") or "")
if not bounds:
return
r0, r1, c0, c1 = bounds
height = r1 - r0 + 1
cell_data = sheet.setdefault("cellData", {})
region = _extract_region_cells(cell_data, r0, r1, c0, c1)
data_col_indices = sorted({c for (_, c) in region.keys()}) or list(range(c0, c1 + 1))
resolved = _resolve_row_fence(cfg, len(data_col_indices))
if not resolved:
return
fence_num, fence_data_size = resolved
fill_empty = bool(cfg.get("fillEmptyRows"))
copy_cols = parse_index_list(cfg.get("copyRow"))
for r in range(r0, r1 + 1):
row_obj = cell_data.get(str(r))
if not isinstance(row_obj, dict):
continue
for c in range(c0, c1 + 1):
if (r, c) in region and str(c) in row_obj:
del row_obj[str(c)]
for block in range(fence_num):
block_cols = data_col_indices[block * fence_data_size : (block + 1) * fence_data_size]
target_r_base = r0 + block * height
for copy_c in sorted(copy_cols):
if copy_c < c0 or copy_c > c1:
continue
for dr in range(height):
src_r = r0 + dr
src = region.get((src_r, copy_c))
if src:
_write_cell(cell_data, target_r_base + dr, copy_c, copy.deepcopy(src))
written_cols = [c for c in block_cols if c not in copy_cols]
for src_c in written_cols:
for dr in range(height):
src_r = r0 + dr
dst_r = target_r_base + dr
src = region.get((src_r, src_c))
if src:
_write_cell(cell_data, dst_r, src_c, copy.deepcopy(src))
if fill_empty and len(written_cols) < fence_data_size:
pad_need = fence_data_size - len(written_cols)
pad_candidates = [
c
for c in range(c0, c1 + 1)
if c not in copy_cols and c not in written_cols
]
for pad_c in pad_candidates[:pad_need]:
for dr in range(height):
dst_r = target_r_base + dr
row_obj = cell_data.get(str(dst_r)) or {}
if str(pad_c) not in row_obj:
_write_cell(cell_data, dst_r, pad_c, _empty_cell())
max_r = r0 + fence_num * height
max_c = c1
for rk, row in cell_data.items():
if not str(rk).isdigit() or not isinstance(row, dict):
continue
max_r = max(max_r, int(rk))
for ck in row.keys():
if str(ck).isdigit():
max_c = max(max_c, int(ck))
sheet["rowCount"] = max(int(sheet.get("rowCount") or 0), max_r + 5)
sheet["columnCount"] = max(int(sheet.get("columnCount") or 0), max_c + 5)
def apply_column_layout(
snapshot: Dict[str, Any],
layout_list: List[Any],
) -> Dict[str, Any]:
if not snapshot or not layout_list:
return snapshot or {}
result = copy.deepcopy(snapshot)
sheets = result.get("sheets") or {}
for sheet_id in result.get("sheetOrder") or list(sheets.keys()):
sheet = sheets.get(sheet_id)
if not isinstance(sheet, dict):
continue
cfg = _get_column_config_for_sheet(layout_list, sheet_id)
if not cfg or not cfg.get("columnState"):
continue
style = cfg.get("columnStyle")
if style == "col":
_apply_col_split(sheet, cfg)
elif style == "row":
_apply_row_split(sheet, cfg)
result["sheets"] = sheets
return result
@@ -0,0 +1,61 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
报表渲染引擎(Phase 3
- 参数单元格替换
- dataSource 单格填充
- dataSource 列表向下扩展(list / down
"""
from typing import Any, Dict, List, Optional
from online_dev.report_manager.engine.column_layout import apply_column_layout
from online_dev.report_manager.engine.code_cells import apply_code_cells
from online_dev.report_manager.engine.expression_eval import apply_expression_cells
from online_dev.report_manager.engine.merge_recalc import apply_merge_recalc_after_expand
from online_dev.report_manager.engine.preview_mvp import (
apply_parameter_cells,
apply_snapshot_placeholders,
)
def _apply_data_source_cells(
snapshot: Dict[str, Any],
cells_meta: Dict[str, Any],
datasets: Dict[str, List[Any]],
) -> tuple[Dict[str, Any], list[tuple[str, int, int, int]]]:
from online_dev.report_manager.engine.data_expand import apply_data_source_cells
return apply_data_source_cells(snapshot, cells_meta, datasets)
def transform(
snapshot: Dict[str, Any],
cells: Dict[str, Any],
datasets: Dict[str, List[Any]],
params: Optional[Dict[str, Any]] = None,
column_list: Optional[List[Any]] = None,
fence_list: Optional[List[Any]] = None,
) -> Dict[str, Any]:
"""
将数据集填充到 Univer snapshot。
"""
if not snapshot:
return snapshot or {}
cells_meta = cells or {}
params = params or {}
layout_list = fence_list if fence_list else column_list
original = snapshot
filled = apply_parameter_cells(snapshot, cells_meta, params)
filled = apply_snapshot_placeholders(filled, params)
filled = apply_code_cells(filled, params)
filled, pending_group_merges = _apply_data_source_cells(filled, cells_meta, datasets)
filled = apply_merge_recalc_after_expand(original, filled)
if pending_group_merges:
from online_dev.report_manager.engine.data_expand import apply_pending_group_merges
filled = apply_pending_group_merges(filled, pending_group_merges)
filled = apply_expression_cells(filled, cells_meta, datasets, params)
if layout_list:
filled = apply_column_layout(filled, layout_list)
return filled
@@ -0,0 +1,101 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""convertConfig 运行时 ID→名称查找缓存(对标 JNPF DataSetSwapUtil"""
from __future__ import annotations
from typing import Any, Dict, Optional
class ConvertLookupCache:
"""同步查找表;Golden 测试用 config.names,运行时可预填充。"""
def __init__(self) -> None:
self._users: Dict[str, str] = {}
self._depts: Dict[str, str] = {}
self._orgs: Dict[str, str] = {}
self._roles: Dict[str, str] = {}
self._groups: Dict[str, str] = {}
self._dicts: Dict[str, Dict[str, str]] = {}
def resolve(
self,
rtype: str,
value: Any,
config: Optional[Dict[str, Any]] = None,
) -> Any:
if value is None or value == "":
return value
config = config or {}
inline = config.get("names") or config.get("optionsMap") or {}
if isinstance(inline, dict):
key = str(value)
if key in inline:
return inline[key]
rtype = (rtype or "").lower()
key = str(value)
if rtype in ("user", "users"):
return self._users.get(key, value)
if rtype in ("department", "dep", "dept"):
return self._depts.get(key, value)
if rtype in ("organize", "org", "company"):
return self._orgs.get(key, value)
if rtype == "role":
return self._roles.get(key, value)
if rtype == "group":
return self._groups.get(key, value)
if rtype in ("dictionary", "dict", "select"):
dict_type = config.get("dictionaryType") or config.get("dictType") or ""
if dict_type and dict_type in self._dicts:
return self._dicts[dict_type].get(key, value)
return value
def put_dict(self, dict_type: str, mapping: Dict[str, str]) -> None:
self._dicts[dict_type] = mapping
def put_users(self, mapping: Dict[str, str]) -> None:
self._users.update(mapping)
def put_depts(self, mapping: Dict[str, str]) -> None:
self._depts.update(mapping)
def put_orgs(self, mapping: Dict[str, str]) -> None:
self._orgs.update(mapping)
async def build_lookup_cache_from_db(db) -> ConvertLookupCache:
"""从 core 模块批量加载常用 ID 映射(best-effort)。"""
cache = ConvertLookupCache()
try:
from sqlalchemy import select
from core.user.model import User
from core.dept.model import Dept
users = (await db.execute(select(User.id, User.name).where(User.is_deleted == False))).all()
cache.put_users({str(uid): name or "" for uid, name in users if uid})
depts = (await db.execute(select(Dept.id, Dept.name).where(Dept.is_deleted == False))).all()
cache.put_depts({str(did): name or "" for did, name in depts if did})
except Exception:
pass
try:
from sqlalchemy import select
from core.dict_item.model import DictItem
rows = (
await db.execute(
select(DictItem.dict_id, DictItem.value, DictItem.label).where(
DictItem.is_deleted == False
)
)
).all()
by_dict: Dict[str, Dict[str, str]] = {}
for dict_id, val, label in rows:
if not dict_id:
continue
by_dict.setdefault(str(dict_id), {})[str(val)] = label or str(val)
for dict_id, mapping in by_dict.items():
cache.put_dict(dict_id, mapping)
except Exception:
pass
return cache
@@ -0,0 +1,943 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""父格 + 聚合驱动的数据源扩展(全表拓扑,支持跨行上父格 / 横向父格树)。"""
from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from online_dev.report_manager.engine.parent_cells import (
cell_key,
filter_rows_by_parents,
resolve_field_path,
resolve_parents,
_index_data_sources,
_get_nested_value,
_int_coord,
)
from online_dev.report_manager.engine.polymerize import BindData, build_bind_list, expand_span
CellKey = Tuple[str, int, int]
@dataclass
class ExpandedSlot:
"""单元格一次绑定扩展占用的行/列区间(左闭右开)。"""
value: Any
data_list: List[Dict[str, Any]]
start: int
end: int
def _expand_direction(custom: Dict[str, Any]) -> str:
expand = (custom.get("expand") or custom.get("expandDirection") or "").lower()
if expand in ("down", "list", "vertical"):
return "down"
if expand in ("right", "horizontal", "across"):
return "right"
fill = (custom.get("fillDirection") or "").lower()
if fill in ("portrait", "vertical", "down"):
return "down"
if fill in ("landscape", "horizontal", "right", "across"):
return "right"
return "none"
def _dataset_name(cell: Dict[str, Any]) -> str:
custom = cell.get("custom") or {}
name = str(
custom.get("dataSetName") or custom.get("dataSet") or custom.get("alias") or ""
)
if name:
return name
field = str(custom.get("field") or custom.get("bindField") or "")
if "." in field:
return field.split(".", 1)[0]
return ""
def _field_name(cell: Dict[str, Any]) -> str:
custom = cell.get("custom") or {}
return str(custom.get("field") or custom.get("bindField") or "")
def _poly_list(cell: Dict[str, Any]) -> bool:
return str((cell.get("custom") or {}).get("polymerizationType") or "1") == "1"
def _poly_summary(cell: Dict[str, Any]) -> bool:
return str((cell.get("custom") or {}).get("polymerizationType") or "1") == "3"
def _poly_group(cell: Dict[str, Any]) -> bool:
return str((cell.get("custom") or {}).get("polymerizationType") or "1") == "2"
def _should_merge_group(cell: Dict[str, Any]) -> bool:
"""分组列是否合并单元格(custom.mergeCell,默认开启)。"""
if not _poly_group(cell):
return False
merge = (cell.get("custom") or {}).get("mergeCell")
if merge is None:
return True
if isinstance(merge, str):
return merge.strip().lower() not in ("0", "false", "no", "")
return bool(merge)
def _add_vertical_merge_region(
sheets: Dict[str, Any],
sheet_id: str,
start_row: int,
end_row: int,
col: int,
) -> None:
"""分组列:同组多行合并为一个单元格(预览/导出)。"""
if end_row <= start_row:
return
sheet = sheets.get(sheet_id)
if not sheet:
return
region = {
"startRow": start_row,
"endRow": end_row,
"startColumn": col,
"endColumn": col,
}
merges = sheet.setdefault("mergeData", [])
if region not in merges:
merges.append(region)
def _compute_down_band_lengths(
down_by_sheet: Dict[str, List[Dict[str, Any]]],
datasets: Dict[str, List[Any]],
) -> Dict[Tuple[str, int], int]:
lengths: Dict[Tuple[str, int], int] = {}
for sheet_id, down_cells in down_by_sheet.items():
bands: Dict[int, List[Dict[str, Any]]] = {}
for cell in down_cells:
bands.setdefault(_int_coord(cell.get("row")), []).append(cell)
for start_row, band in bands.items():
max_len = max(
(len(datasets.get(_dataset_name(item)) or []) for item in band),
default=0,
)
lengths[(sheet_id, start_row)] = max_len
return lengths
def _shift_sheet_rows_down(
sheets: Dict[str, Any],
sheet_id: str,
from_row: int,
delta: int,
) -> None:
"""将 from_row 及以下的 cellData 整体下移 delta 行(列表扩展前保留汇总/static 行)。"""
if delta <= 0:
return
sheet = sheets.get(sheet_id)
if not sheet:
return
cell_data = sheet.get("cellData") or {}
shifted: Dict[str, Any] = {}
for row_key, row_obj in cell_data.items():
row = _int_coord(row_key, 0)
target_key = str(row + delta) if row >= from_row else row_key
if target_key in shifted and row >= from_row:
existing = shifted[target_key]
if isinstance(existing, dict) and isinstance(row_obj, dict):
merged = {**existing, **row_obj}
shifted[target_key] = merged
else:
shifted[target_key] = row_obj
else:
shifted[target_key] = row_obj
sheet["cellData"] = shifted
def _summary_filter_rows(
cell: Dict[str, Any],
rows: List[Dict[str, Any]],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
) -> List[Dict[str, Any]]:
"""汇总格:仅 custom 父格按切片统计;default/none 对整表数据集聚合(合计行)。"""
custom = cell.get("custom") or {}
left_pt = custom.get("leftParentCellType") or "default"
top_pt = custom.get("topParentCellType") or "default"
if left_pt != "custom" and top_pt != "custom":
return rows
left, top = resolve_parents(cell, by_pos, by_sheet)
left_bind = top_bind = None
if left:
lb = build_bind_list(left, rows)
left_bind = lb[0].data_list if lb else None
if top:
tb = build_bind_list(top, rows)
top_bind = tb[0].data_list if tb else None
return filter_rows_by_parents(
rows,
cell,
left_parent=left,
top_parent=top,
left_bind=left_bind,
top_bind=top_bind,
)
def _summary_output_row(
sheet_id: str,
template_row: int,
band_lengths: Dict[Tuple[str, int], int],
) -> int:
"""汇总格位于列表模板行下方时,输出到扩展后的末行(对齐 JNPF 合计行)。"""
anchor: Optional[Tuple[int, int]] = None
for (sid, start_row), max_len in band_lengths.items():
if sid != sheet_id or max_len <= 0 or template_row <= start_row:
continue
if anchor is None or start_row > anchor[0]:
anchor = (start_row, max_len)
if anchor is None:
return template_row
start_row, max_len = anchor
return template_row + (max_len - 1)
def _template_style_ref(
sheets: Dict[str, Any],
sheet_id: str,
row: int,
col: int,
) -> Optional[Any]:
sheet = sheets.get(sheet_id)
if not sheet:
return None
cell_data = sheet.get("cellData") or {}
cell = (cell_data.get(str(row)) or {}).get(str(col))
if not isinstance(cell, dict):
return None
return cell.get("s")
def _set_cell_value(
sheets: Dict[str, Any],
sheet_id: str,
row: int,
col: int,
value: Any,
*,
style_template_row: Optional[int] = None,
style_template_col: Optional[int] = None,
) -> None:
sheet = sheets.get(sheet_id)
if not sheet:
return
cell_data = sheet.setdefault("cellData", {})
row_data = cell_data.setdefault(str(row), {})
cell_obj = row_data.setdefault(str(col), {})
style_ref = None
if style_template_row is not None:
style_ref = _template_style_ref(sheets, sheet_id, style_template_row, col)
elif style_template_col is not None:
style_ref = _template_style_ref(sheets, sheet_id, row, style_template_col)
if style_ref is not None:
cell_obj["s"] = style_ref
if value is None:
cell_obj["v"] = ""
elif isinstance(value, (dict, list)):
cell_obj["v"] = str(value)
else:
cell_obj["v"] = value
def _apply_fill_empty(
sheets: Dict[str, Any],
sheet_id: str,
custom: Dict[str, Any],
*,
direction: str,
anchor_row: int,
anchor_col: int,
data_len: int,
) -> None:
if not custom.get("fillEmptyRows"):
return
try:
fill_n = int(custom.get("fillEmptyNum") or 1)
except (TypeError, ValueError):
fill_n = 1
if fill_n < 1:
return
for offset in range(fill_n):
if direction == "down":
_set_cell_value(
sheets, sheet_id, anchor_row + data_len + offset, anchor_col, ""
)
else:
_set_cell_value(
sheets, sheet_id, anchor_row, anchor_col + data_len + offset, ""
)
def _slot_at_index(slots: List[ExpandedSlot], index: int) -> Optional[ExpandedSlot]:
if 0 <= index < len(slots):
return slots[index]
return None
def _slot_covering(slots: List[ExpandedSlot], pos: int) -> Optional[ExpandedSlot]:
for s in slots:
if s.start <= pos < s.end:
return s
return None
def _children_of(
parent: Dict[str, Any],
candidates: List[Dict[str, Any]],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
) -> List[Dict[str, Any]]:
pk = cell_key(parent)
out: List[Dict[str, Any]] = []
for c in candidates:
left, top = resolve_parents(c, by_pos, by_sheet)
if (left and cell_key(left) == pk) or (top and cell_key(top) == pk):
out.append(c)
return sorted(out, key=lambda x: (_int_coord(x.get("row")), _int_coord(x.get("col"))))
def _is_down_root(
cell: Dict[str, Any],
down_keys: set[CellKey],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
) -> bool:
left, top = resolve_parents(cell, by_pos, by_sheet)
if left and cell_key(left) in down_keys:
return False
if top and cell_key(top) in down_keys:
return False
return True
def _rows_for_bind(
rows: List[Dict[str, Any]],
cell: Dict[str, Any],
*,
left_parent: Optional[Dict[str, Any]],
top_parent: Optional[Dict[str, Any]],
left_slot: Optional[ExpandedSlot],
top_slot: Optional[ExpandedSlot],
) -> List[Dict[str, Any]]:
left_bind = left_slot.data_list if left_slot else None
top_bind = top_slot.data_list if top_slot else None
return filter_rows_by_parents(
rows,
cell,
left_parent=left_parent,
top_parent=top_parent,
left_bind=left_bind,
top_bind=top_bind,
)
def apply_pending_group_merges(
snapshot: Dict[str, Any],
pending: List[Tuple[str, int, int, int]],
) -> Dict[str, Any]:
sheets = snapshot.get("sheets") or {}
for sheet_id, col, start_row, end_row in pending:
_add_vertical_merge_region(sheets, sheet_id, start_row, end_row, col)
return snapshot
def _visit_down(
cell: Dict[str, Any],
rows: List[Dict[str, Any]],
row_cursor: int,
*,
sheets: Dict[str, Any],
sheet_id: str,
down_cells: List[Dict[str, Any]],
datasets: Dict[str, List[Any]],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
row_registry: Dict[CellKey, List[ExpandedSlot]],
bind_index: int = 0,
left_slot: Optional[ExpandedSlot] = None,
top_slot: Optional[ExpandedSlot] = None,
parent_scoped: bool = False,
pending_group_merges: Optional[List[Tuple[str, int, int, int]]] = None,
) -> int:
custom = cell.get("custom") or {}
field = resolve_field_path(_field_name(cell), _dataset_name(cell))
col = _int_coord(cell.get("col"))
anchor_row = _int_coord(cell.get("row"))
left_p, top_p = resolve_parents(cell, by_pos, by_sheet)
if not parent_scoped:
if left_slot is None and left_p:
left_slot = _slot_at_index(
row_registry.get(cell_key(left_p), []), bind_index
)
if top_slot is None and top_p:
top_slot = _slot_at_index(
row_registry.get(cell_key(top_p), []), bind_index
)
filtered = _rows_for_bind(
rows,
cell,
left_parent=left_p,
top_parent=top_p,
left_slot=left_slot,
top_slot=top_slot,
)
else:
filtered = [r if isinstance(r, dict) else {} for r in rows]
binds = build_bind_list(cell, filtered)
children = _children_of(cell, down_cells, by_pos, by_sheet)
child_cols = {_int_coord(ch.get("col")) for ch in children}
slots: List[ExpandedSlot] = []
end_row = row_cursor
for bi, bind in enumerate(binds):
block_start = end_row
if children:
child_end = block_start
for ch in children:
child_end = _visit_down(
ch,
bind.data_list,
block_start,
sheets=sheets,
sheet_id=sheet_id,
down_cells=down_cells,
datasets=datasets,
by_pos=by_pos,
by_sheet=by_sheet,
row_registry=row_registry,
bind_index=bi,
parent_scoped=True,
pending_group_merges=pending_group_merges,
)
block_len = max(1, child_end - block_start)
else:
block_len = expand_span(bind, cell)
if col not in child_cols:
style_row = anchor_row
if _poly_list(cell):
for i in range(block_len):
r = block_start + i
val = (
_get_nested_value(bind.data_list[i], field)
if i < len(bind.data_list)
else ""
)
_set_cell_value(
sheets,
sheet_id,
r,
col,
val,
style_template_row=style_row,
)
elif _poly_group(cell):
for i in range(block_len):
r = block_start + i
_set_cell_value(
sheets,
sheet_id,
r,
col,
bind.value,
style_template_row=style_row,
)
if (
pending_group_merges is not None
and block_len > 1
and _should_merge_group(cell)
):
pending_group_merges.append(
(sheet_id, col, block_start, block_start + block_len - 1)
)
else:
for i in range(block_len):
r = block_start + i
_set_cell_value(
sheets,
sheet_id,
r,
col,
bind.value,
style_template_row=style_row,
)
slots.append(
ExpandedSlot(bind.value, bind.data_list, block_start, block_start + block_len)
)
end_row = block_start + block_len
key = cell_key(cell)
row_registry[key] = row_registry.get(key, []) + slots
if custom.get("fillEmptyRows"):
_apply_fill_empty(
sheets,
sheet_id,
custom,
direction="down",
anchor_row=anchor_row,
anchor_col=col,
data_len=end_row - row_cursor,
)
return end_row
def _expand_sheet_down(
sheets: Dict[str, Any],
sheet_id: str,
down_cells: List[Dict[str, Any]],
datasets: Dict[str, List[Any]],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
pending_group_merges: Optional[List[Tuple[str, int, int, int]]] = None,
) -> None:
down_keys = {cell_key(c) for c in down_cells}
row_registry: Dict[CellKey, List[ExpandedSlot]] = {}
roots = [c for c in down_cells if _is_down_root(c, down_keys, by_pos, by_sheet)]
if not roots:
roots = down_cells
bands: Dict[int, List[Dict[str, Any]]] = {}
for c in roots:
bands.setdefault(_int_coord(c.get("row")), []).append(c)
for start_row in sorted(bands.keys()):
cursor = start_row
for root in sorted(bands[start_row], key=lambda x: _int_coord(x.get("col"))):
ds = _dataset_name(root)
raw = datasets.get(ds) or []
rows = [r if isinstance(r, dict) else {} for r in raw]
cursor = max(
cursor,
_visit_down(
root,
rows,
cursor,
sheets=sheets,
sheet_id=sheet_id,
down_cells=down_cells,
datasets=datasets,
by_pos=by_pos,
by_sheet=by_sheet,
row_registry=row_registry,
pending_group_merges=pending_group_merges,
),
)
def _legacy_down_band(
sheets: Dict[str, Any],
sheet_id: str,
start_row: int,
band: List[Dict[str, Any]],
datasets: Dict[str, List[Any]],
pending_group_merges: Optional[List[Tuple[str, int, int, int]]] = None,
) -> None:
max_len = 0
for item in band:
max_len = max(max_len, len(datasets.get(_dataset_name(item)) or []))
for item in sorted(band, key=lambda x: _int_coord(x.get("col"))):
ds = _dataset_name(item)
field = resolve_field_path(_field_name(item), ds)
col = _int_coord(item.get("col"))
data_rows = [r if isinstance(r, dict) else {} for r in datasets.get(ds) or []]
custom = item.get("custom") or {}
if _poly_group(item):
binds = build_bind_list(item, data_rows)
row_cursor = start_row
for bind in binds:
span = max(1, len(bind.data_list))
for i in range(span):
_set_cell_value(
sheets,
sheet_id,
row_cursor + i,
col,
bind.value,
style_template_row=start_row,
)
if (
span > 1
and pending_group_merges is not None
and _should_merge_group(item)
):
pending_group_merges.append(
(sheet_id, col, row_cursor, row_cursor + span - 1)
)
row_cursor += span
if custom.get("fillEmptyRows"):
_apply_fill_empty(
sheets,
sheet_id,
custom,
direction="down",
anchor_row=start_row,
anchor_col=col,
data_len=row_cursor - start_row,
)
continue
for i in range(max_len):
if i < len(data_rows):
val = _get_nested_value(data_rows[i], field)
else:
val = ""
_set_cell_value(
sheets,
sheet_id,
start_row + i,
col,
val,
style_template_row=start_row,
)
if custom.get("fillEmptyRows"):
_apply_fill_empty(
sheets,
sheet_id,
custom,
direction="down",
anchor_row=start_row,
anchor_col=col,
data_len=max_len,
)
def _has_down_parent_link(
band: List[Dict[str, Any]],
down_keys: set[CellKey],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
) -> bool:
for c in band:
left, top = resolve_parents(c, by_pos, by_sheet)
if left and cell_key(left) in down_keys:
return True
if top and cell_key(top) in down_keys:
return True
return False
def _visit_right(
cell: Dict[str, Any],
rows: List[Dict[str, Any]],
col_cursor: int,
*,
sheets: Dict[str, Any],
sheet_id: str,
row: int,
right_cells: List[Dict[str, Any]],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
col_registry: Dict[CellKey, List[ExpandedSlot]],
bind_index: int = 0,
left_slot: Optional[ExpandedSlot] = None,
top_slot: Optional[ExpandedSlot] = None,
parent_scoped: bool = False,
) -> int:
custom = cell.get("custom") or {}
field = resolve_field_path(_field_name(cell), _dataset_name(cell))
col = _int_coord(cell.get("col"))
anchor_col = col
left_p, top_p = resolve_parents(cell, by_pos, by_sheet)
if not parent_scoped:
if left_slot is None and left_p:
left_slot = _slot_at_index(
col_registry.get(cell_key(left_p), []), bind_index
)
if top_slot is None and top_p:
top_slot = _slot_at_index(
col_registry.get(cell_key(top_p), []), bind_index
)
filtered = _rows_for_bind(
rows,
cell,
left_parent=left_p,
top_parent=top_p,
left_slot=left_slot,
top_slot=top_slot,
)
else:
filtered = [r if isinstance(r, dict) else {} for r in rows]
binds = build_bind_list(cell, filtered)
children = _children_of(cell, right_cells, by_pos, by_sheet)
child_cols = {_int_coord(ch.get("col")) for ch in children}
slots: List[ExpandedSlot] = []
end_col = col_cursor
for bi, bind in enumerate(binds):
block_start = end_col
if children:
child_end = block_start
for ch in children:
child_end = _visit_right(
ch,
bind.data_list,
block_start,
sheets=sheets,
sheet_id=sheet_id,
row=row,
right_cells=right_cells,
by_pos=by_pos,
by_sheet=by_sheet,
col_registry=col_registry,
bind_index=bi,
parent_scoped=True,
)
block_len = max(1, child_end - block_start)
else:
block_len = expand_span(bind, cell)
if col not in child_cols:
for i in range(block_len):
c = block_start + i
if _poly_list(cell):
val = (
_get_nested_value(bind.data_list[i], field)
if i < len(bind.data_list)
else ""
)
else:
val = bind.value
_set_cell_value(
sheets,
sheet_id,
row,
c,
val,
style_template_col=anchor_col,
)
slots.append(
ExpandedSlot(bind.value, bind.data_list, block_start, block_start + block_len)
)
end_col = block_start + block_len
key = cell_key(cell)
col_registry[key] = col_registry.get(key, []) + slots
if custom.get("fillEmptyRows"):
_apply_fill_empty(
sheets,
sheet_id,
custom,
direction="right",
anchor_row=row,
anchor_col=col,
data_len=end_col - col_cursor,
)
return end_col
def _is_right_root(
cell: Dict[str, Any],
right_keys: set[CellKey],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
) -> bool:
left, top = resolve_parents(cell, by_pos, by_sheet)
if left and cell_key(left) in right_keys:
return False
if top and cell_key(top) in right_keys:
return False
return True
def _expand_sheet_right(
sheets: Dict[str, Any],
sheet_id: str,
row: int,
right_cells: List[Dict[str, Any]],
datasets: Dict[str, List[Any]],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
) -> None:
right_keys = {cell_key(c) for c in right_cells}
col_registry: Dict[CellKey, List[ExpandedSlot]] = {}
roots = [c for c in right_cells if _is_right_root(c, right_keys, by_pos, by_sheet)]
if not roots:
roots = right_cells
cursor = min(_int_coord(c.get("col")) for c in roots)
for root in sorted(roots, key=lambda x: _int_coord(x.get("col"))):
ds = _dataset_name(root)
raw = datasets.get(ds) or []
rows_data = [r if isinstance(r, dict) else {} for r in raw]
cursor = max(
cursor,
_visit_right(
root,
rows_data,
cursor,
sheets=sheets,
sheet_id=sheet_id,
row=row,
right_cells=right_cells,
by_pos=by_pos,
by_sheet=by_sheet,
col_registry=col_registry,
),
)
def _legacy_right_band(
sheets: Dict[str, Any],
sheet_id: str,
row: int,
band: List[Dict[str, Any]],
datasets: Dict[str, List[Any]],
) -> None:
max_len = max((len(datasets.get(_dataset_name(c)) or []) for c in band), default=0)
for item in sorted(band, key=lambda x: _int_coord(x.get("col"))):
ds = _dataset_name(item)
field = resolve_field_path(_field_name(item), ds)
start_col = _int_coord(item.get("col"))
data_rows = datasets.get(ds) or []
for i in range(max_len):
if i < len(data_rows) and isinstance(data_rows[i], dict):
val = _get_nested_value(data_rows[i], field)
else:
val = ""
_set_cell_value(
sheets,
sheet_id,
row,
start_col + i,
val,
style_template_col=start_col,
)
def apply_data_source_cells(
snapshot: Dict[str, Any],
cells_meta: Dict[str, Any],
datasets: Dict[str, List[Any]],
) -> Tuple[Dict[str, Any], List[Tuple[str, int, int, int]]]:
result = copy.deepcopy(snapshot)
sheets = result.get("sheets") or {}
pending_group_merges: List[Tuple[str, int, int, int]] = []
cell_list = cells_meta.get("cells") or []
by_pos, by_sheet = _index_data_sources(cell_list)
down_by_sheet: Dict[str, List[Dict[str, Any]]] = {}
right_by_sheet_row: Dict[Tuple[str, int], List[Dict[str, Any]]] = {}
singles: List[dict] = []
for cell in cell_list:
if cell.get("type") != "dataSource":
continue
custom = cell.get("custom") or {}
if not _dataset_name(cell) or not _field_name(cell):
continue
direction = _expand_direction(custom)
sheet_id = str(cell.get("sheet", "sheet1"))
if direction == "down":
down_by_sheet.setdefault(sheet_id, []).append(cell)
elif direction == "right":
row = _int_coord(cell.get("row"), 0)
right_by_sheet_row.setdefault((sheet_id, row), []).append(cell)
else:
singles.append(cell)
band_lengths = _compute_down_band_lengths(down_by_sheet, datasets)
for (sheet_id, start_row), max_len in sorted(
band_lengths.items(), key=lambda x: x[0][1], reverse=True
):
delta = max(0, max_len - 1)
if delta > 0:
_shift_sheet_rows_down(sheets, sheet_id, start_row + 1, delta)
for sheet_id, down_cells in down_by_sheet.items():
down_keys = {cell_key(c) for c in down_cells}
bands: Dict[int, List[Dict[str, Any]]] = {}
for c in down_cells:
bands.setdefault(_int_coord(c.get("row")), []).append(c)
uses_tree = False
for band in bands.values():
if _has_down_parent_link(band, down_keys, by_pos, by_sheet):
uses_tree = True
break
if uses_tree:
_expand_sheet_down(
sheets,
sheet_id,
down_cells,
datasets,
by_pos,
by_sheet,
pending_group_merges,
)
else:
for start_row, band in bands.items():
_legacy_down_band(
sheets,
sheet_id,
start_row,
band,
datasets,
pending_group_merges,
)
for (sheet_id, row), band in right_by_sheet_row.items():
right_keys = {cell_key(c) for c in band}
if _has_down_parent_link(band, right_keys, by_pos, by_sheet):
_expand_sheet_right(
sheets, sheet_id, row, band, datasets, by_pos, by_sheet
)
else:
_legacy_right_band(sheets, sheet_id, row, band, datasets)
for cell in singles:
sheet_id = str(cell.get("sheet", "sheet1"))
template_row = _int_coord(cell.get("row"), 0)
col = _int_coord(cell.get("col"), 0)
row = (
_summary_output_row(sheet_id, template_row, band_lengths)
if _poly_summary(cell)
else template_row
)
raw = datasets.get(_dataset_name(cell)) or []
rows = [r if isinstance(r, dict) else {} for r in raw]
if _poly_summary(cell):
filtered = _summary_filter_rows(cell, rows, by_pos, by_sheet)
else:
left, top = resolve_parents(cell, by_pos, by_sheet)
left_bind = top_bind = None
if left:
lb = build_bind_list(left, rows)
left_bind = lb[0].data_list if lb else None
if top:
tb = build_bind_list(top, rows)
top_bind = tb[0].data_list if tb else None
filtered = filter_rows_by_parents(
rows,
cell,
left_parent=left,
top_parent=top,
left_bind=left_bind,
top_bind=top_bind,
)
binds = build_bind_list(cell, filtered)
val = binds[0].value if binds else ""
_set_cell_value(sheets, sheet_id, row, col, val)
result["sheets"] = sheets
return result, pending_group_merges
@@ -0,0 +1,222 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
数据集行级变换(对齐 JNPF convertConfig + field_mapping
在 fetch_all 之后、transform 之前执行。
"""
from __future__ import annotations
import copy
from datetime import date, datetime
from typing import Any, Dict, List, Optional
from online_dev.report_manager.engine.convert_lookup import ConvertLookupCache
def _prop_name(field: str, alias: str = "") -> str:
if not field:
return ""
if "." in field:
parts = field.split(".", 1)
if alias and parts[0] == alias:
return parts[1]
return parts[-1]
return field
def apply_field_mapping(
rows: List[Any],
mapping: Optional[Dict[str, Any]],
) -> List[Any]:
"""字段重命名:{ 源字段: 目标字段 }"""
if not mapping or not rows:
return rows
out: List[Any] = []
for row in rows:
if not isinstance(row, dict):
out.append(row)
continue
new_row = copy.deepcopy(row)
for src, dst in mapping.items():
if not src or not dst or src == dst:
continue
src_s, dst_s = str(src), str(dst)
if src_s in new_row:
new_row[dst_s] = new_row.pop(src_s)
out.append(new_row)
return out
def _jnpf_date_format_to_strftime(fmt: str) -> str:
s = (fmt or "yyyy-MM-dd").replace("YYYY", "%Y").replace("yyyy", "%Y")
s = s.replace("MM", "%m").replace("DD", "%d").replace("dd", "%d")
s = s.replace("HH", "%H").replace("mm", "%M").replace("ss", "%S")
return s
def _format_date_value(value: Any, fmt: str) -> Any:
if value is None or value == "":
return value
py_fmt = _jnpf_date_format_to_strftime(fmt)
if isinstance(value, datetime):
return value.strftime(py_fmt)
if isinstance(value, date):
return value.strftime(py_fmt)
if isinstance(value, (int, float)):
try:
return datetime.fromtimestamp(value / 1000 if value > 1e12 else value).strftime(
py_fmt
)
except (OSError, ValueError, OverflowError):
return value
s = str(value).strip()
for parser in (
lambda x: datetime.fromisoformat(x.replace("Z", "+00:00")),
lambda x: datetime.strptime(x[:10], "%Y-%m-%d"),
):
try:
return parser(s).strftime(py_fmt)
except (ValueError, TypeError):
continue
return value
def _format_number_value(value: Any, config: Dict[str, Any]) -> Any:
if value is None or value == "":
return value
try:
num = float(value)
except (TypeError, ValueError):
return value
precision = config.get("precision")
prec_int: Optional[int] = None
if precision is not None:
try:
prec_int = int(precision)
num = round(num, prec_int)
except (TypeError, ValueError):
pass
if config.get("thousands"):
if prec_int is not None:
return f"{num:,.{prec_int}f}"
if isinstance(num, float) and num.is_integer():
return f"{int(num):,}"
return f"{num:,}"
if prec_int is not None and float(num).is_integer():
return int(num)
return num
def _apply_select_rule(value: Any, config: Dict[str, Any]) -> Any:
options = config.get("options") or []
if not options:
return value
for opt in options:
if not isinstance(opt, dict):
continue
oid = opt.get("id")
if oid is None:
oid = opt.get("value")
if oid == value or str(oid) == str(value):
return opt.get("fullName") or opt.get("label") or value
return value
def _rule_applies_to_alias(field: str, alias: str = "") -> bool:
if not field or "." not in field:
return True
prefix = field.split(".", 1)[0]
return not alias or prefix == alias
def _apply_rule_to_row(
row: Dict[str, Any],
rule: Dict[str, Any],
alias: str = "",
lookup: Optional[ConvertLookupCache] = None,
) -> None:
field = str(rule.get("field") or "")
if not _rule_applies_to_alias(field, alias):
return
prop = _prop_name(field, alias)
if not prop or prop not in row:
return
rtype = str(rule.get("type") or "").lower()
config = rule.get("config") or {}
val = row[prop]
if rtype == "select":
row[prop] = _apply_select_rule(val, config)
elif rtype == "date":
row[prop] = _format_date_value(val, config.get("format") or "yyyy-MM-dd")
elif rtype == "time":
row[prop] = _format_date_value(val, config.get("format") or "HH:mm:ss")
elif rtype == "number":
row[prop] = _format_number_value(val, config)
elif rtype in (
"user",
"users",
"department",
"dep",
"dept",
"organize",
"org",
"company",
"role",
"group",
"dictionary",
"dict",
):
cache = lookup or ConvertLookupCache()
row[prop] = cache.resolve(rtype, val, config)
elif lookup:
row[prop] = lookup.resolve(rtype, val, config)
def apply_convert_rules(
rows: List[Any],
rules: Any,
*,
alias: str = "",
lookup: Optional[ConvertLookupCache] = None,
) -> List[Any]:
"""
JNPF convertConfig 列表:[{ field, type, config }, ...]
也支持 { "list": [...] } 包装。
"""
rule_list: List[Dict[str, Any]] = []
if isinstance(rules, list):
rule_list = [r for r in rules if isinstance(r, dict)]
elif isinstance(rules, dict):
inner = rules.get("list") or rules.get("rules") or rules.get("items")
if isinstance(inner, list):
rule_list = [r for r in inner if isinstance(r, dict)]
if not rule_list or not rows:
return rows
out: List[Any] = []
for row in rows:
if not isinstance(row, dict):
out.append(row)
continue
new_row = copy.deepcopy(row)
for rule in rule_list:
_apply_rule_to_row(new_row, rule, alias, lookup)
out.append(new_row)
return out
def transform_dataset_rows(
rows: List[Any],
*,
field_mapping: Optional[Dict[str, Any]] = None,
dataset_convert: Any = None,
version_convert: Any = None,
alias: str = "",
lookup: Optional[ConvertLookupCache] = None,
) -> List[Any]:
"""单数据集完整变换链:mapping → dataset rules → version rules"""
data = apply_field_mapping(rows, field_mapping)
data = apply_convert_rules(data, dataset_convert, alias=alias, lookup=lookup)
data = apply_convert_rules(data, version_convert, alias=alias, lookup=lookup)
return data
@@ -0,0 +1,271 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""将 Univer snapshot 导出为 Excel(xlsx),支持合并单元格、行列尺寸与基础样式。"""
from __future__ import annotations
import io
import re
from typing import Any, Callable, Dict, List, Optional, Tuple
from openpyxl import Workbook
from online_dev.report_manager.engine.export_excel_extras import (
apply_conditional_formatting,
apply_sheet_hyperlinks,
apply_sheet_images,
)
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
# Univer CellValueType: 1 string, 2 number, 3 boolean, 4 force string
_CELL_TYPE_NUMBER = 2
_CELL_TYPE_BOOLEAN = 3
def _parse_rgb(color: Any) -> Optional[str]:
if not color:
return None
if isinstance(color, str):
s = color.strip()
if s.startswith("#") and len(s) >= 7:
return s[1:7].upper()
match = re.search(r"rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)", s, re.I)
if match:
r, g, b = (int(match.group(i)) for i in range(1, 4))
return f"{r:02X}{g:02X}{b:02X}"
if isinstance(color, dict):
return _parse_rgb(color.get("rgb"))
return None
def _style_lookup(styles: Any, style_id: Any) -> Optional[Dict[str, Any]]:
if style_id is None or styles is None:
return None
if isinstance(styles, list):
try:
idx = int(style_id)
return styles[idx] if 0 <= idx < len(styles) else None
except (TypeError, ValueError):
return None
if isinstance(styles, dict):
key = str(style_id)
return styles.get(key) or styles.get(style_id)
return None
def _build_openpyxl_style(style: Dict[str, Any]) -> Tuple[Font, PatternFill, Alignment, Border]:
font_kwargs: Dict[str, Any] = {}
if style.get("fs"):
try:
font_kwargs["size"] = float(style["fs"])
except (TypeError, ValueError):
pass
if style.get("ff"):
font_kwargs["name"] = str(style["ff"])
if style.get("bl") == 1:
font_kwargs["bold"] = True
if style.get("it") == 1:
font_kwargs["italic"] = True
font_color = _parse_rgb(style.get("cl"))
if font_color:
font_kwargs["color"] = font_color
fill = PatternFill()
bg = _parse_rgb(style.get("bg"))
if bg:
fill = PatternFill(fill_type="solid", fgColor=bg)
ht_map = {1: "left", 2: "center", 3: "right"}
vt_map = {1: "top", 2: "center", 3: "bottom"}
alignment = Alignment(
horizontal=ht_map.get(style.get("ht"), "general"),
vertical=vt_map.get(style.get("vt"), "bottom"),
wrap_text=style.get("tb") == 3,
)
thin = Side(style="thin", color="000000")
border = Border()
bd = style.get("bd") or {}
if isinstance(bd, dict):
if bd.get("t"):
border.top = thin
if bd.get("b"):
border.bottom = thin
if bd.get("l"):
border.left = thin
if bd.get("r"):
border.right = thin
return Font(**font_kwargs), fill, alignment, border
def _cell_display_value(cell: Dict[str, Any]) -> Any:
if not cell:
return ""
v = cell.get("v")
if v is None:
return ""
return v
def _write_cell(
ws,
row: int,
col: int,
cell: Dict[str, Any],
styles: Any,
style_cache: Dict[str, Any],
) -> None:
excel_row = row + 1
excel_col = col + 1
target = ws.cell(row=excel_row, column=excel_col)
formula = cell.get("f")
if formula:
text = str(formula)
target.value = text[1:] if text.startswith("=") else text
target.data_type = "f"
else:
value = _cell_display_value(cell)
cell_type = cell.get("t")
if cell_type == _CELL_TYPE_NUMBER:
try:
target.value = float(value)
except (TypeError, ValueError):
target.value = value
elif cell_type == _CELL_TYPE_BOOLEAN:
target.value = bool(value) if not isinstance(value, bool) else value
else:
target.value = value
style_id = cell.get("s")
style_def = _style_lookup(styles, style_id)
if not style_def:
return
cache_key = str(style_id)
if cache_key not in style_cache:
font, fill, alignment, border = _build_openpyxl_style(style_def)
style_cache[cache_key] = (font, fill, alignment, border)
font, fill, alignment, border = style_cache[cache_key]
target.font = font
if fill.fgColor and fill.fgColor.rgb and fill.fgColor.rgb != "00000000":
target.fill = fill
target.alignment = alignment
if border.left or border.right or border.top or border.bottom:
target.border = border
def _apply_row_col_dimensions(ws, sheet: Dict[str, Any]) -> None:
default_row_h = sheet.get("defaultRowHeight") or 24
default_col_w = sheet.get("defaultColumnWidth") or 88
row_data = sheet.get("rowData") or {}
col_data = sheet.get("columnData") or {}
for row_key, meta in row_data.items():
try:
r = int(row_key)
except (TypeError, ValueError):
continue
if not isinstance(meta, dict):
continue
height = meta.get("h") or meta.get("ah") or default_row_h
try:
ws.row_dimensions[r + 1].height = float(height) * 0.75
except (TypeError, ValueError):
pass
for col_key, meta in col_data.items():
try:
c = int(col_key)
except (TypeError, ValueError):
continue
if not isinstance(meta, dict):
continue
width = meta.get("w") or default_col_w
try:
ws.column_dimensions[get_column_letter(c + 1)].width = max(8, float(width) / 7)
except (TypeError, ValueError):
pass
def _apply_merge_regions(ws, merge_data: List[Any]) -> None:
for region in merge_data or []:
if not isinstance(region, dict):
continue
try:
sr = int(region.get("startRow", region.get("start_row", 0)))
er = int(region.get("endRow", region.get("end_row", sr)))
sc = int(region.get("startColumn", region.get("start_column", 0)))
ec = int(region.get("endColumn", region.get("end_column", sc)))
except (TypeError, ValueError):
continue
if er <= sr and ec <= sc:
continue
ws.merge_cells(
start_row=sr + 1,
end_row=er + 1,
start_column=sc + 1,
end_column=ec + 1,
)
def snapshot_to_xlsx_bytes(
snapshot: Dict[str, Any],
*,
watermark_text: str = "",
fetch_url: Optional[Callable[[str], Optional[bytes]]] = None,
) -> bytes:
"""按 sheetOrder 将 cellData 写入 xlsx(含 merge / 尺寸 / 样式 / 条件格式 / 图片)。"""
wb = Workbook()
default_ws = wb.active
wb.remove(default_ws)
sheets = snapshot.get("sheets") or {}
order = snapshot.get("sheetOrder") or list(sheets.keys())
if not order:
order = list(sheets.keys())
styles = snapshot.get("styles")
if not order:
ws = wb.create_sheet("Sheet1")
ws.append([])
else:
for idx, sheet_id in enumerate(order):
sheet = sheets.get(sheet_id) or {}
name = (sheet.get("name") or sheet_id or "Sheet")[:31]
ws = wb.create_sheet(name)
style_cache: Dict[str, Any] = {}
cell_data = sheet.get("cellData") or {}
for row_key, row_obj in cell_data.items():
try:
r = int(row_key)
except (TypeError, ValueError):
continue
if not isinstance(row_obj, dict):
continue
for col_key, cell in row_obj.items():
try:
c = int(col_key)
except (TypeError, ValueError):
continue
if isinstance(cell, dict):
_write_cell(ws, r, c, cell, styles, style_cache)
_apply_row_col_dimensions(ws, sheet)
_apply_merge_regions(ws, sheet.get("mergeData") or [])
apply_conditional_formatting(ws, sheet_id, snapshot)
apply_sheet_hyperlinks(ws, sheet_id, snapshot, sheet)
apply_sheet_images(
ws,
sheet_id,
snapshot,
sheet,
fetch_url=fetch_url,
)
if watermark_text and idx == 0:
ws.oddHeader.center.text = watermark_text
ws.evenHeader.center.text = watermark_text
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
@@ -0,0 +1,913 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Excel 导出扩展:条件格式、超链接与嵌入/浮动图片。"""
from __future__ import annotations
import base64
import io
import logging
import re
from typing import Any, Callable, Dict, List, Optional, Tuple
from openpyxl.drawing.image import Image as XLImage
from openpyxl.drawing.spreadsheet_drawing import AnchorMarker, TwoCellAnchor
from openpyxl.formatting.rule import CellIsRule, ColorScaleRule, DataBarRule, FormulaRule, IconSetRule, Rule
from openpyxl.styles import Font, PatternFill
from openpyxl.styles.differential import DifferentialStyle
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.hyperlink import Hyperlink
logger = logging.getLogger(__name__)
_CF_PLUGIN = "SHEET_CONDITIONAL_FORMATTING_PLUGIN"
_DRAWING_PLUGIN = "SHEET_DRAWING_PLUGIN"
_HYPER_LINK_PLUGIN = "SHEET_HYPER_LINK_PLUGIN"
_DEFINED_NAME_PLUGIN = "SHEET_DEFINED_NAME_PLUGIN"
_BASE64_PREFIX = re.compile(r"^data:image/[\w+.-]+;base64,", re.I)
_CELL_IS_OPERATORS = {
"greaterthan": "greaterThan",
"lessthan": "lessThan",
"equal": "equal",
"notequal": "notEqual",
"greaterthanorequal": "greaterThanOrEqual",
"lessthanorequal": "lessThanOrEqual",
"between": "between",
"notbetween": "notBetween",
}
_CFVO_TYPE_MAP = {
"min": "min",
"max": "max",
"num": "num",
"number": "num",
"percent": "percent",
"percentile": "percentile",
"formula": "formula",
"expression": "formula",
"auto": "percentile",
}
_ADVANCED_CF_SUBTYPES = {
"top10",
"rank",
"aboveaverage",
"average",
"timeperiod",
"uniquevalues",
"duplicatevalues",
"containstext",
"notcontainstext",
"beginswith",
"endswith",
"containsblanks",
"notcontainsblanks",
"containserrors",
"notcontainserrors",
}
_TEXT_CF_TYPES = {
"containstext": "containsText",
"notcontainstext": "notContainsText",
"beginswith": "beginsWith",
"endswith": "endsWith",
"containsblanks": "containsBlanks",
"notcontainsblanks": "notContainsBlanks",
"containserrors": "containsErrors",
"notcontainserrors": "notContainsErrors",
}
_HYPERLINK_FONT = Font(color="0563C1", underline="single")
def _parse_rgb(color: Any) -> Optional[str]:
if not color:
return None
if isinstance(color, str):
s = color.strip()
if s.startswith("#") and len(s) >= 7:
return s[1:7].upper()
match = re.search(r"rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)", s, re.I)
if match:
r, g, b = (int(match.group(i)) for i in range(1, 4))
return f"{r:02X}{g:02X}{b:02X}"
if isinstance(color, dict):
return _parse_rgb(color.get("rgb"))
return None
def _parse_resource_map(snapshot: Dict[str, Any], plugin_name: str) -> Dict[str, Any]:
resources = snapshot.get("resources") or []
for resource in resources:
if not isinstance(resource, dict) or resource.get("name") != plugin_name:
continue
raw = resource.get("data")
if not raw:
return {}
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
import json
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {}
except (TypeError, ValueError):
return {}
return {}
def _range_to_ref(region: Dict[str, Any]) -> Optional[str]:
if not isinstance(region, dict):
return None
try:
sr = int(region.get("startRow", region.get("start_row", 0)))
er = int(region.get("endRow", region.get("end_row", sr)))
sc = int(region.get("startColumn", region.get("start_column", 0)))
ec = int(region.get("endColumn", region.get("end_column", sc)))
except (TypeError, ValueError):
return None
start = f"{get_column_letter(sc + 1)}{sr + 1}"
end = f"{get_column_letter(ec + 1)}{er + 1}"
return start if start == end else f"{start}:{end}"
def _normalize_cfvo_type(value_type: Any) -> str:
key = str(value_type or "num").strip().lower()
return _CFVO_TYPE_MAP.get(key, key)
def _cfvo_value(univer_value: Any) -> Tuple[str, Any]:
if not isinstance(univer_value, dict):
return "num", univer_value
value_type = _normalize_cfvo_type(univer_value.get("type"))
raw = univer_value.get("value")
if value_type == "formula" and raw is not None:
text = str(raw)
if text.startswith("="):
text = text[1:]
return value_type, text
return value_type, raw
def _config_list(rule: Dict[str, Any]) -> List[Dict[str, Any]]:
config = rule.get("config")
if isinstance(config, list):
return [x for x in config if isinstance(x, dict)]
if isinstance(config, dict):
return [config]
return []
def _build_rule_style(rule: Dict[str, Any]) -> Tuple[Optional[Font], Optional[PatternFill]]:
style = rule.get("style")
if not isinstance(style, dict):
return None, None
font_kwargs: Dict[str, Any] = {}
if style.get("bl") == 1:
font_kwargs["bold"] = True
if style.get("it") == 1:
font_kwargs["italic"] = True
font_color = _parse_rgb(style.get("cl"))
if font_color:
font_kwargs["color"] = font_color
font = Font(**font_kwargs) if font_kwargs else None
fill = None
bg = _parse_rgb(style.get("bg"))
if bg:
fill = PatternFill(fill_type="solid", fgColor=bg, start_color=bg, end_color=bg)
return font, fill
def _build_rule_dxf(rule: Dict[str, Any]) -> Optional[DifferentialStyle]:
font, fill = _build_rule_style(rule)
if font is None and fill is None:
return None
return DifferentialStyle(font=font, fill=fill)
def _normalize_operator(operator: Any) -> Optional[str]:
if operator is None:
return None
key = str(operator).strip()
mapped = _CELL_IS_OPERATORS.get(key.lower())
return mapped or key
def _normalize_sub_type(rule: Dict[str, Any]) -> str:
return str(rule.get("subType") or rule.get("subtype") or "").strip().lower()
def _build_advanced_highlight_rule(rule: Dict[str, Any], stop_if_true: Any) -> Optional[Any]:
sub_type = _normalize_sub_type(rule)
rule_type = str(rule.get("type") or "").strip().lower()
key = sub_type or rule_type
if key not in _ADVANCED_CF_SUBTYPES and rule_type not in _ADVANCED_CF_SUBTYPES:
return None
normalized = key if key in _ADVANCED_CF_SUBTYPES else rule_type
dxf = _build_rule_dxf(rule)
operator = str(rule.get("operator") or "").strip()
value = rule.get("value")
try:
if normalized in ("top10", "rank"):
rank = 10
if value is not None:
try:
rank = int(value)
except (TypeError, ValueError):
rank = 10
cf_rule = Rule(
type="top10",
rank=rank,
percent=bool(rule.get("isPercent")),
bottom=bool(rule.get("isBottom")),
stopIfTrue=stop_if_true,
dxf=dxf,
)
return cf_rule
if normalized in ("aboveaverage", "average"):
above = operator.lower() != "lessthan"
cf_rule = Rule(
type="aboveAverage",
aboveAverage=above,
stopIfTrue=stop_if_true,
dxf=dxf,
)
return cf_rule
if normalized == "timeperiod":
period = operator or "today"
cf_rule = Rule(
type="timePeriod",
timePeriod=period,
stopIfTrue=stop_if_true,
dxf=dxf,
)
return cf_rule
if normalized in ("uniquevalues", "duplicatevalues"):
cf_type = "uniqueValues" if normalized == "uniquevalues" else "duplicateValues"
cf_rule = Rule(
type=cf_type,
stopIfTrue=stop_if_true,
dxf=dxf,
)
return cf_rule
if normalized in _TEXT_CF_TYPES:
cf_type = _TEXT_CF_TYPES[normalized]
text = str(value) if value is not None else ""
cf_rule = Rule(
type=cf_type,
operator=cf_type,
text=text,
stopIfTrue=stop_if_true,
dxf=dxf,
)
return cf_rule
except (TypeError, ValueError) as exc:
logger.debug("advanced cf rule failed, fallback to formula: %s", exc)
return _build_advanced_cf_formula_fallback(rule, stop_if_true)
def _build_advanced_cf_formula_fallback(rule: Dict[str, Any], stop_if_true: Any) -> Optional[Any]:
"""openpyxl 不直接支持的规则,用 FormulaRule 近似兜底。"""
sub_type = _normalize_sub_type(rule)
operator = str(rule.get("operator") or "").strip().lower()
value = rule.get("value")
font, fill = _build_rule_style(rule)
formula: Optional[str] = None
if sub_type in ("top10", "rank"):
formula = "TRUE"
elif sub_type in ("aboveaverage", "average"):
ref = "INDIRECT(ADDRESS(ROW(),COLUMN()))"
if operator == "lessthan":
formula = f"{ref}<AVERAGE($A:$ZZ)"
else:
formula = f"{ref}>AVERAGE($A:$ZZ)"
elif sub_type == "timeperiod":
ref = "INDIRECT(ADDRESS(ROW(),COLUMN()))"
period_map = {
"today": f"INT({ref})=TODAY()",
"yesterday": f"INT({ref})=TODAY()-1",
"tomorrow": f"INT({ref})=TODAY()+1",
"last7days": f"AND({ref}>=TODAY()-7,{ref}<=TODAY())",
"thismonth": f"AND(MONTH({ref})=MONTH(TODAY()),YEAR({ref})=YEAR(TODAY()))",
"lastmonth": f"AND(MONTH({ref})=MONTH(EDATE(TODAY(),-1)),YEAR({ref})=YEAR(EDATE(TODAY(),-1)))",
}
formula = period_map.get(operator.lower(), f"INT({ref})=TODAY()")
elif sub_type in ("uniquevalues",):
ref = "INDIRECT(ADDRESS(ROW(),COLUMN()))"
formula = f"COUNTIF($A:$ZZ,{ref})=1"
elif sub_type in ("duplicatevalues",):
ref = "INDIRECT(ADDRESS(ROW(),COLUMN()))"
formula = f"COUNTIF($A:$ZZ,{ref})>1"
if not formula:
return None
return FormulaRule(formula=[formula], stopIfTrue=stop_if_true, font=font, fill=fill)
def _build_highlight_rule(rule: Dict[str, Any], stop_if_true: Any) -> Optional[Any]:
advanced = _build_advanced_highlight_rule(rule, stop_if_true)
if advanced is not None:
return advanced
sub_type = _normalize_sub_type(rule)
operator = _normalize_operator(rule.get("operator"))
value = rule.get("value")
formulas: List[str] = []
if operator in ("between", "notBetween") and isinstance(value, list):
for item in value[:2]:
if item is not None:
formulas.append(str(item))
elif sub_type == "expression" or rule.get("type") == "expression":
if value is not None:
text = str(value)
formulas.append(text[1:] if text.startswith("=") else text)
font, fill = _build_rule_style(rule)
if not formulas:
return None
return FormulaRule(formula=formulas, stopIfTrue=stop_if_true, font=font, fill=fill)
elif value is not None:
if isinstance(value, list):
for item in value[:2]:
if item is not None:
formulas.append(str(item))
else:
formulas.append(str(value))
if not operator:
if sub_type in _CELL_IS_OPERATORS:
operator = _normalize_operator(sub_type)
elif sub_type:
operator = "equal"
formulas = [str(value)] if value is not None else []
if not operator or not formulas:
return None
font, fill = _build_rule_style(rule)
return CellIsRule(
operator=operator,
formula=formulas,
stopIfTrue=stop_if_true,
font=font,
fill=fill,
)
def _build_color_scale_rule(rule: Dict[str, Any], stop_if_true: Any) -> Optional[Any]:
configs = _config_list(rule)
if len(configs) < 2:
return None
kwargs: Dict[str, Any] = {"stopIfTrue": stop_if_true}
slots = ("start", "mid", "end")
for idx, cfg in enumerate(configs[:3]):
slot = slots[idx] if len(configs) == 3 else ("start", "end")[idx]
value_type, value = _cfvo_value(cfg.get("value"))
kwargs[f"{slot}_type"] = value_type
if value is not None and value_type not in ("min", "max"):
kwargs[f"{slot}_value"] = value
color = _parse_rgb(cfg.get("color"))
if color:
kwargs[f"{slot}_color"] = color
try:
cf_rule = ColorScaleRule(**{k: v for k, v in kwargs.items() if k != "stopIfTrue"})
if stop_if_true is not None:
cf_rule.stopIfTrue = stop_if_true
return cf_rule
except (TypeError, ValueError):
return None
def _build_data_bar_rule(rule: Dict[str, Any], stop_if_true: Any) -> Optional[Any]:
configs = _config_list(rule)
if not configs:
return None
cfg = configs[0]
min_value = cfg.get("min") or {}
max_value = cfg.get("max") or {}
start_type, start_val = _cfvo_value(min_value.get("value") if isinstance(min_value, dict) else min_value)
end_type, end_val = _cfvo_value(max_value.get("value") if isinstance(max_value, dict) else max_value)
color = _parse_rgb(cfg.get("positiveColor") or cfg.get("nativeColor") or cfg.get("color"))
kwargs: Dict[str, Any] = {
"start_type": start_type or "min",
"end_type": end_type or "max",
"showValue": rule.get("isShowValue", True),
"stopIfTrue": stop_if_true,
}
if start_val is not None and start_type not in ("min", "max"):
kwargs["start_value"] = start_val
if end_val is not None and end_type not in ("min", "max"):
kwargs["end_value"] = end_val
if color:
kwargs["color"] = color
try:
stop = kwargs.pop("stopIfTrue", None)
cf_rule = DataBarRule(**kwargs)
if stop is not None:
cf_rule.stopIfTrue = stop
return cf_rule
except (TypeError, ValueError):
return None
def _build_icon_set_rule(rule: Dict[str, Any], stop_if_true: Any) -> Optional[Any]:
configs = _config_list(rule)
if len(configs) < 2:
return None
values: List[Any] = []
value_type = "percentile"
for cfg in configs:
value_obj = cfg.get("value") or {}
if isinstance(value_obj, dict):
value_type = _normalize_cfvo_type(value_obj.get("type") or value_type)
raw = value_obj.get("value")
if raw is not None:
values.append(raw)
elif value_obj is not None:
values.append(value_obj)
icon_style = str(configs[0].get("iconType") or rule.get("iconSet") or "3TrafficLights1")
try:
cf_rule = IconSetRule(
icon_style=icon_style,
type=value_type,
values=values,
showValue=rule.get("isShowValue", True),
)
if stop_if_true is not None:
cf_rule.stopIfTrue = stop_if_true
return cf_rule
except (TypeError, ValueError):
return None
def _build_cf_rule(entry: Dict[str, Any]) -> Optional[Any]:
rule = entry.get("rule")
if not isinstance(rule, dict):
return None
stop_if_true = entry.get("stopIfTrue")
rule_type = str(rule.get("type") or "highlight").lower()
if rule_type == "colorscale":
return _build_color_scale_rule(rule, stop_if_true)
if rule_type == "databar":
return _build_data_bar_rule(rule, stop_if_true)
if rule_type == "iconset":
return _build_icon_set_rule(rule, stop_if_true)
if rule_type in ("expression", "formula"):
return _build_highlight_rule({**rule, "subType": "expression"}, stop_if_true)
return _build_highlight_rule(rule, stop_if_true)
def build_fetch_url(base_url: str = "") -> Callable[[str], Optional[bytes]]:
"""构造相对/绝对 URL 图片拉取函数,供 Excel 导出使用。"""
def _fetch(source: str) -> Optional[bytes]:
return _default_fetch_url(source, base_url)
return _fetch
def apply_conditional_formatting(ws, sheet_id: str, snapshot: Dict[str, Any]) -> None:
cf_map = _parse_resource_map(snapshot, _CF_PLUGIN)
entries = cf_map.get(sheet_id) or []
if not isinstance(entries, list):
return
for entry in entries:
if not isinstance(entry, dict):
continue
ranges = entry.get("ranges") or []
refs = [_range_to_ref(r) for r in ranges]
refs = [r for r in refs if r]
if not refs:
continue
cf_rule = _build_cf_rule(entry)
if cf_rule is None:
continue
for ref in refs:
try:
ws.conditional_formatting.add(ref, cf_rule)
except Exception as exc:
logger.debug("skip conditional formatting %s: %s", ref, exc)
def _decode_base64_image(source: str) -> Optional[bytes]:
if not source:
return None
payload = _BASE64_PREFIX.sub("", source.strip())
try:
return base64.b64decode(payload, validate=False)
except (TypeError, ValueError):
return None
def _default_fetch_url(source: str, base_url: str = "") -> Optional[bytes]:
url = source.strip()
if not url:
return None
if url.startswith("/") and base_url:
url = f"{base_url.rstrip('/')}{url}"
if not url.lower().startswith(("http://", "https://")):
return None
try:
import httpx
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
resp = client.get(url)
resp.raise_for_status()
return resp.content
except Exception as exc:
logger.debug("fetch image failed %s: %s", url, exc)
return None
def resolve_image_bytes(
source: str,
image_source_type: str = "",
*,
fetch_url: Optional[Callable[[str], Optional[bytes]]] = None,
) -> Optional[bytes]:
if not source:
return None
source_type = str(image_source_type or "").upper()
if source_type == "BASE64" or source.strip().startswith("data:image/"):
return _decode_base64_image(source)
if source_type == "URL" or source.startswith(("http://", "https://", "/")):
fetcher = fetch_url or (lambda u: _default_fetch_url(u))
return fetcher(source)
if source_type in ("", "BASE64"):
decoded = _decode_base64_image(source)
if decoded:
return decoded
return None
def _offset_value(offset: Any) -> int:
try:
return int(offset or 0)
except (TypeError, ValueError):
return 0
def _anchor_from_transform(sheet_transform: Dict[str, Any]) -> Optional[TwoCellAnchor]:
if not isinstance(sheet_transform, dict):
return None
start = sheet_transform.get("from") or {}
end = sheet_transform.get("to") or {}
try:
from_row = int(start.get("row", 0))
from_col = int(start.get("column", start.get("col", 0)))
to_row = int(end.get("row", from_row + 4))
to_col = int(end.get("column", end.get("col", from_col + 2)))
except (TypeError, ValueError):
return None
if from_col == to_col:
to_col = from_col + 2
if from_row == to_row:
to_row = from_row + 4
return TwoCellAnchor(
editAs="oneCell",
_from=AnchorMarker(
col=from_col,
colOff=_offset_value(start.get("columnOffset")),
row=from_row,
rowOff=_offset_value(start.get("rowOffset")),
),
to=AnchorMarker(
col=to_col,
colOff=_offset_value(end.get("columnOffset")),
row=to_row,
rowOff=_offset_value(end.get("rowOffset")),
),
)
def _add_image_to_sheet(ws, image_bytes: bytes, sheet_transform: Optional[Dict[str, Any]] = None) -> None:
if not image_bytes:
return
try:
img = XLImage(io.BytesIO(image_bytes))
except Exception as exc:
logger.debug("create image failed: %s", exc)
return
anchor = _anchor_from_transform(sheet_transform or {})
if anchor is not None:
img.anchor = anchor
ws.add_image(img)
return
ws.add_image(img, "A1")
def _iter_sheet_drawings(snapshot: Dict[str, Any], sheet_id: str) -> List[Dict[str, Any]]:
drawing_map = _parse_resource_map(snapshot, _DRAWING_PLUGIN)
block = drawing_map.get(sheet_id) or {}
if not isinstance(block, dict):
return []
data = block.get("data") or {}
order = block.get("order") or list(data.keys())
items: List[Dict[str, Any]] = []
if isinstance(order, list):
for key in order:
drawing = data.get(key)
if isinstance(drawing, dict):
items.append(drawing)
for key, drawing in data.items():
if isinstance(drawing, dict) and drawing not in items:
items.append(drawing)
return items
def _iter_cell_drawings(sheet: Dict[str, Any]) -> List[Tuple[Dict[str, Any], Dict[str, Any]]]:
results: List[Tuple[Dict[str, Any], Dict[str, Any]]] = []
cell_data = sheet.get("cellData") or {}
for row_key, row_obj in cell_data.items():
if not isinstance(row_obj, dict):
continue
try:
row = int(row_key)
except (TypeError, ValueError):
continue
for col_key, cell in row_obj.items():
if not isinstance(cell, dict):
continue
try:
col = int(col_key)
except (TypeError, ValueError):
continue
drawings = (cell.get("p") or {}).get("drawings") or {}
if not isinstance(drawings, dict):
continue
for drawing in drawings.values():
if not isinstance(drawing, dict):
continue
transform = drawing.get("sheetTransform") or {
"from": {"row": row, "column": col, "rowOffset": 0, "columnOffset": 0},
"to": {"row": row + 4, "column": col + 2, "rowOffset": 0, "columnOffset": 0},
}
results.append((drawing, transform))
return results
def apply_sheet_images(
ws,
sheet_id: str,
snapshot: Dict[str, Any],
sheet: Dict[str, Any],
*,
fetch_url: Optional[Callable[[str], Optional[bytes]]] = None,
) -> None:
for drawing in _iter_sheet_drawings(snapshot, sheet_id):
component_key = str(drawing.get("componentKey") or "")
if component_key and "echart" in component_key.lower():
continue
image_bytes = resolve_image_bytes(
str(drawing.get("source") or ""),
str(drawing.get("imageSourceType") or ""),
fetch_url=fetch_url,
)
if image_bytes:
_add_image_to_sheet(ws, image_bytes, drawing.get("sheetTransform"))
for drawing, transform in _iter_cell_drawings(sheet):
image_bytes = resolve_image_bytes(
str(drawing.get("source") or ""),
str(drawing.get("imageSourceType") or ""),
fetch_url=fetch_url,
)
if image_bytes:
_add_image_to_sheet(ws, image_bytes, transform)
def _parse_defined_names(snapshot: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
raw = _parse_resource_map(snapshot, _DEFINED_NAME_PLUGIN)
if not isinstance(raw, dict):
return {}
result: Dict[str, Dict[str, Any]] = {}
for key, value in raw.items():
if isinstance(value, dict):
result[str(key)] = value
return result
def _sheet_name_lookup(snapshot: Dict[str, Any]) -> Dict[str, str]:
lookup: Dict[str, str] = {}
for sheet_id, sheet in (snapshot.get("sheets") or {}).items():
if isinstance(sheet, dict):
lookup[str(sheet_id)] = str(sheet.get("name") or sheet_id)
return lookup
def _quote_sheet_name(name: str) -> str:
escaped = name.replace("'", "''")
return f"'{escaped}'"
def _resolve_hyperlink_target(
url: str,
*,
snapshot: Dict[str, Any],
defined_names: Dict[str, Dict[str, Any]],
) -> Tuple[Optional[str], Optional[str]]:
"""返回 (external_target, internal_location)。"""
if not url:
return None, None
raw = url.strip()
sheet_names = _sheet_name_lookup(snapshot)
if raw.startswith("#gid="):
payload = raw[len("#gid="):]
parts = payload.split("&range=")
sheet_id = parts[0]
cell_ref = parts[1] if len(parts) > 1 else "A1"
sheet_name = sheet_names.get(sheet_id, sheet_id)
location = f"{_quote_sheet_name(sheet_name)}!{cell_ref}"
return None, location
if raw.startswith("#rangeid="):
range_id = raw[len("#rangeid="):]
defined = defined_names.get(range_id) or {}
name = defined.get("name")
if name:
return None, str(name)
return None, None
if raw.startswith("#"):
return None, raw[1:]
return raw, None
def _extract_url_from_link_obj(obj: Dict[str, Any]) -> Optional[str]:
if not isinstance(obj, dict):
return None
for key in ("url", "address", "link", "payload", "hyperlink"):
val = obj.get(key)
if isinstance(val, str) and val.strip():
return val.strip()
if isinstance(val, dict):
nested = _extract_url_from_link_obj(val)
if nested:
return nested
props = obj.get("properties")
if isinstance(props, dict):
return _extract_url_from_link_obj(props)
return None
def _extract_cell_hyperlink_url(cell: Dict[str, Any]) -> Optional[str]:
if not isinstance(cell, dict):
return None
direct = _extract_url_from_link_obj(cell)
if direct:
return direct
p = cell.get("p") or {}
link = p.get("link")
if isinstance(link, dict):
url = _extract_url_from_link_obj(link)
if url:
return url
body = p.get("body") or {}
if isinstance(body, dict):
for item in body.get("customRanges") or []:
if not isinstance(item, dict):
continue
url = _extract_url_from_link_obj(item.get("properties") or item)
if url:
return url
return None
def _cell_body_text(cell: Dict[str, Any]) -> Optional[str]:
body = ((cell.get("p") or {}).get("body") or {})
if isinstance(body, dict):
data_stream = body.get("dataStream")
if isinstance(data_stream, str) and data_stream.strip():
return data_stream.strip()
return None
def _iter_plugin_hyperlinks(snapshot: Dict[str, Any], sheet_id: str) -> List[Tuple[int, int, str]]:
links: List[Tuple[int, int, str]] = []
plugin_map = _parse_resource_map(snapshot, _HYPER_LINK_PLUGIN)
block = plugin_map.get(sheet_id)
if block is None:
return links
def _append(row: Any, col: Any, url: Optional[str]) -> None:
if url is None:
return
try:
links.append((int(row), int(col), url))
except (TypeError, ValueError):
return
if isinstance(block, list):
for item in block:
if not isinstance(item, dict):
continue
url = _extract_url_from_link_obj(item)
row = item.get("row", item.get("startRow", item.get("r")))
col = item.get("column", item.get("startColumn", item.get("c")))
_append(row, col, url)
elif isinstance(block, dict):
data = block.get("data") if isinstance(block.get("data"), dict) else block
if isinstance(data, dict):
for item in data.values():
if not isinstance(item, dict):
continue
url = _extract_url_from_link_obj(item)
row = item.get("row", item.get("startRow", item.get("r")))
col = item.get("column", item.get("startColumn", item.get("c")))
_append(row, col, url)
return links
def _apply_hyperlink_to_cell(
cell,
url: str,
*,
snapshot: Dict[str, Any],
defined_names: Dict[str, Dict[str, Any]],
) -> None:
target, location = _resolve_hyperlink_target(
url,
snapshot=snapshot,
defined_names=defined_names,
)
ref = cell.coordinate
if location:
cell.hyperlink = Hyperlink(ref=ref, location=location)
elif target:
cell.hyperlink = Hyperlink(ref=ref, target=target)
else:
return
cell.font = _HYPERLINK_FONT
def apply_sheet_hyperlinks(
ws,
sheet_id: str,
snapshot: Dict[str, Any],
sheet: Dict[str, Any],
) -> None:
defined_names = _parse_defined_names(snapshot)
seen: set = set()
for row_key, row_obj in (sheet.get("cellData") or {}).items():
if not isinstance(row_obj, dict):
continue
try:
row = int(row_key)
except (TypeError, ValueError):
continue
for col_key, cell in row_obj.items():
if not isinstance(cell, dict):
continue
try:
col = int(col_key)
except (TypeError, ValueError):
continue
url = _extract_cell_hyperlink_url(cell)
if not url:
continue
excel_row = row + 1
excel_col = col + 1
target_cell = ws.cell(row=excel_row, column=excel_col)
body_text = _cell_body_text(cell)
if body_text and not target_cell.value:
target_cell.value = body_text
_apply_hyperlink_to_cell(
target_cell,
url,
snapshot=snapshot,
defined_names=defined_names,
)
seen.add((row, col))
for row, col, url in _iter_plugin_hyperlinks(snapshot, sheet_id):
if (row, col) in seen:
continue
target_cell = ws.cell(row=row + 1, column=col + 1)
_apply_hyperlink_to_cell(
target_cell,
url,
snapshot=snapshot,
defined_names=defined_names,
)
@@ -0,0 +1,93 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""将 filled snapshot 导出为 PDF(对标 JNPF 打印/PDF 子集)"""
from __future__ import annotations
import io
from typing import Any, Dict, List, Optional, Tuple
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
from reportlab.lib.styles import getSampleStyleSheet
def _cell_text(cell: Dict[str, Any]) -> str:
if not cell:
return ""
v = cell.get("v")
if v is None:
v = cell.get("m")
return "" if v is None else str(v)
def _sheet_grid(snapshot: Dict[str, Any], sheet_id: str) -> Tuple[List[List[str]], int, int]:
sheets = snapshot.get("sheets") or {}
sheet = sheets.get(sheet_id) or {}
cell_data = sheet.get("cellData") or {}
if not cell_data:
return [[""]], 1, 1
rows = sorted(int(r) for r in cell_data.keys())
max_col = 0
for r in rows:
cols = cell_data.get(str(r)) or {}
if cols:
max_col = max(max_col, max(int(c) for c in cols.keys()))
max_col = max(max_col, 0)
grid: List[List[str]] = []
for r in rows:
row_cells = cell_data.get(str(r)) or {}
grid.append([_cell_text(row_cells.get(str(c)) or {}) for c in range(max_col + 1)])
return grid, len(rows), max_col + 1
def snapshot_to_pdf_bytes(
snapshot: Dict[str, Any],
*,
title: str = "",
watermark_text: str = "",
landscape_mode: bool = False,
) -> bytes:
buf = io.BytesIO()
page_size = landscape(A4) if landscape_mode else A4
doc = SimpleDocTemplate(
buf,
pagesize=page_size,
leftMargin=12 * mm,
rightMargin=12 * mm,
topMargin=14 * mm,
bottomMargin=14 * mm,
)
styles = getSampleStyleSheet()
story: List[Any] = []
if title:
story.append(Paragraph(title, styles["Title"]))
story.append(Spacer(1, 6 * mm))
if watermark_text:
story.append(Paragraph(f"<font color='#cccccc'>{watermark_text}</font>", styles["Normal"]))
story.append(Spacer(1, 4 * mm))
sheet_order = snapshot.get("sheetOrder") or list((snapshot.get("sheets") or {}).keys())
for idx, sheet_id in enumerate(sheet_order):
grid, _, _ = _sheet_grid(snapshot, sheet_id)
if not grid:
continue
if idx > 0:
story.append(Spacer(1, 8 * mm))
table = Table(grid, repeatRows=1)
table.setStyle(
TableStyle(
[
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
("FONTSIZE", (0, 0), (-1, -1), 8),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]
)
)
story.append(table)
if not story:
story.append(Paragraph("(empty)", styles["Normal"]))
doc.build(story)
return buf.getvalue()
@@ -0,0 +1,491 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
表达式求值 MVP(对齐 JNPF expression 子集)
- #{param} 参数占位
- sum/avg/max/min/count(数据集别名.字段)
- sum/avg/max/min/count(A1:B2) 单元格区域
- A1、$B2 单元格引用
- 四则运算(仅数字)
"""
from __future__ import annotations
import ast
import copy
import operator
import re
from typing import Any, Dict, List, Optional, Tuple
from online_dev.report_manager.engine.column_layout import parse_cell_range
from online_dev.report_manager.engine.preview_mvp import _replace_params_in_value
def _get_nested_value(row: Dict[str, Any], field: str) -> Any:
if not field:
return None
if field in row:
return row[field]
parts = field.split(".")
cur: Any = row
for p in parts:
if isinstance(cur, dict) and p in cur:
cur = cur[p]
else:
return None
return cur
_AGG_FUNCS = ("sum", "avg", "max", "min", "count")
_AGG_PATTERN = re.compile(
r"(sum|avg|max|min|count)\s*\(\s*([a-zA-Z_][\w.]*)\s*\)",
re.IGNORECASE,
)
_CELL_RANGE_AGG_PATTERN = re.compile(
r"(sum|avg|max|min|count)\s*\(\s*([A-Za-z]+\d+)\s*:\s*([A-Za-z]+\d+)\s*\)",
re.IGNORECASE,
)
_CELL_REF_PATTERN = re.compile(
r"(?<![A-Za-z0-9.])(\$?)([A-Za-z]{1,3})(\d+)(?![:\w])",
re.IGNORECASE,
)
def _col_letter_to_index(col: str) -> int:
col = col.upper()
n = 0
for ch in col:
n = n * 26 + (ord(ch) - ord("A") + 1)
return n - 1
def _parse_a1(addr: str) -> Optional[Tuple[int, int]]:
"""A1 / $B2 -> (row, col) 0-based"""
if not addr:
return None
m = re.match(r"^\$?([A-Za-z]+)(\d+)$", addr.strip())
if not m:
return None
return int(m.group(2)) - 1, _col_letter_to_index(m.group(1))
def _cell_to_numeric(value: Any) -> Optional[float]:
if value is None or value == "":
return None
if isinstance(value, (int, float)):
return float(value)
try:
return float(str(value).strip())
except (TypeError, ValueError):
return None
def _get_cell_value(
snapshot: Dict[str, Any],
sheet_id: str,
row: int,
col: int,
) -> Any:
sheets = snapshot.get("sheets") or {}
sheet = sheets.get(sheet_id) or {}
cell_data = sheet.get("cellData") or {}
row_obj = cell_data.get(str(row)) or {}
cell = row_obj.get(str(col)) or {}
return cell.get("v")
def _collect_cells_in_range(
snapshot: Dict[str, Any],
sheet_id: str,
start: str,
end: str,
) -> List[float]:
bounds = parse_cell_range(f"{start}:{end}")
if not bounds:
return []
r0, r1, c0, c1 = bounds
values: List[float] = []
for r in range(r0, r1 + 1):
for c in range(c0, c1 + 1):
num = _cell_to_numeric(_get_cell_value(snapshot, sheet_id, r, c))
if num is not None:
values.append(num)
return values
def _aggregate_cell_range(
func: str,
snapshot: Dict[str, Any],
sheet_id: str,
start: str,
end: str,
) -> float:
values = _collect_cells_in_range(snapshot, sheet_id, start, end)
if not values:
return 0
f = func.lower()
if f == "sum":
return sum(values)
if f == "avg":
return sum(values) / len(values)
if f == "max":
return max(values)
if f == "min":
return min(values)
if f == "count":
return float(len(values))
return 0
def _replace_cell_range_aggregates(
expr: str,
snapshot: Dict[str, Any],
sheet_id: str,
) -> str:
def repl(m: re.Match) -> str:
val = _aggregate_cell_range(
m.group(1), snapshot, sheet_id, m.group(2), m.group(3)
)
if val == int(val):
return str(int(val))
return str(round(val, 8))
return _CELL_RANGE_AGG_PATTERN.sub(repl, expr)
def _replace_cell_refs(
expr: str,
snapshot: Dict[str, Any],
sheet_id: str,
) -> str:
def repl(m: re.Match) -> str:
pos = _parse_a1(f"{m.group(2)}{m.group(3)}")
if not pos:
return m.group(0)
row, col = pos
num = _cell_to_numeric(_get_cell_value(snapshot, sheet_id, row, col))
if num is None:
return "0"
if num == int(num):
return str(int(num))
return str(num)
return _CELL_REF_PATTERN.sub(repl, expr)
_SAFE_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.USub: operator.neg,
}
def _parse_dataset_field(ref: str) -> Tuple[Optional[str], str]:
if "." in ref:
parts = ref.split(".", 1)
return parts[0], parts[1]
return None, ref
def _aggregate(func: str, datasets: Dict[str, List[Any]], ref: str) -> float:
alias, field = _parse_dataset_field(ref)
if not alias or not field:
return 0
rows = datasets.get(alias) or []
values: List[float] = []
for row in rows:
if not isinstance(row, dict):
continue
v = _get_nested_value(row, field)
if v is None or v == "":
continue
try:
values.append(float(v))
except (TypeError, ValueError):
if func.lower() == "count":
values.append(1.0)
if not values:
return 0
f = func.lower()
if f == "sum":
return sum(values)
if f == "avg":
return sum(values) / len(values)
if f == "max":
return max(values)
if f == "min":
return min(values)
if f == "count":
return float(len(values))
return 0
def _replace_aggregates(expr: str, datasets: Dict[str, List[Any]]) -> str:
def repl(m: re.Match) -> str:
val = _aggregate(m.group(1), datasets, m.group(2))
if val == int(val):
return str(int(val))
return str(round(val, 8))
return _AGG_PATTERN.sub(repl, expr)
def _safe_eval_numeric(expr: str) -> Any:
expr = (expr or "").strip()
if not expr:
return ""
node = ast.parse(expr, mode="eval")
return _eval_node(node.body)
def _eval_node(node: ast.AST) -> float:
if isinstance(node, ast.Constant):
if isinstance(node.value, (int, float)):
return float(node.value)
raise ValueError("non-numeric constant")
if isinstance(node, ast.BinOp):
op = _SAFE_OPS.get(type(node.op))
if not op:
raise ValueError("unsupported operator")
return op(_eval_node(node.left), _eval_node(node.right))
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
return -_eval_node(node.operand)
raise ValueError("unsupported expression")
def evaluate_formula(
formula: str,
params: Dict[str, Any],
datasets: Dict[str, List[Any]],
snapshot: Optional[Dict[str, Any]] = None,
sheet_id: str = "sheet1",
) -> str:
"""
求值表达式,返回可写入单元格的字符串结果。
支持前缀 '=' 或裸公式。
"""
raw = (formula or "").strip()
if not raw:
return ""
if raw.startswith("="):
raw = raw[1:].strip()
text = _replace_params_in_value(raw, params)
if snapshot:
text = _replace_cell_range_aggregates(text, snapshot, sheet_id)
text = _replace_aggregates(text, datasets)
if snapshot:
text = _replace_cell_refs(text, snapshot, sheet_id)
try:
result = _safe_eval_numeric(text)
if result == int(result):
return str(int(result))
return str(result)
except Exception:
return text
def _extract_formula_cell_refs(formula: str) -> List[Tuple[int, int]]:
"""从公式中提取 A1 风格单元格引用(0-based row, col"""
raw = (formula or "").strip()
if raw.startswith("="):
raw = raw[1:].strip()
refs: List[Tuple[int, int]] = []
seen: set = set()
for m in _CELL_REF_PATTERN.finditer(raw):
pos = _parse_a1(f"{m.group(2)}{m.group(3)}")
if pos and pos not in seen:
seen.add(pos)
refs.append(pos)
return refs
def _sort_expression_targets(
targets: List[Tuple[str, int, int, str]],
) -> Tuple[List[Tuple[str, int, int, str]], bool]:
"""
按单元格引用依赖拓扑排序表达式目标。
返回 (排序后列表, 是否存在环)。
"""
if len(targets) <= 1:
return targets, False
expr_keys = {(s, r, c) for s, r, c, _ in targets}
deps: Dict[Tuple[str, int, int], Set[Tuple[str, int, int]]] = {
k: set() for k in expr_keys
}
for sheet_id, row, col, formula in targets:
key = (sheet_id, row, col)
for ref_row, ref_col in _extract_formula_cell_refs(formula):
dep_key = (sheet_id, ref_row, ref_col)
if dep_key in expr_keys and dep_key != key:
deps[key].add(dep_key)
in_degree = {k: len(deps[k]) for k in expr_keys}
children: Dict[Tuple[str, int, int], Set[Tuple[str, int, int]]] = {
k: set() for k in expr_keys
}
for key, dep_set in deps.items():
for dep in dep_set:
children[dep].add(key)
queue = sorted(k for k in expr_keys if in_degree[k] == 0)
order: List[Tuple[str, int, int]] = []
while queue:
key = queue.pop(0)
order.append(key)
for child in sorted(children[key]):
in_degree[child] -= 1
if in_degree[child] == 0:
queue.append(child)
has_cycle = len(order) != len(expr_keys)
if has_cycle:
return targets, True
key_to_target = {(s, r, c): (s, r, c, f) for s, r, c, f in targets}
return [key_to_target[k] for k in order], False
def _write_expression_cell(
sheets: Dict[str, Any],
sheet_id: str,
row: int,
col: int,
formula: str,
value: str,
) -> None:
sheet = sheets.get(sheet_id)
if not sheet:
return
cell_data = sheet.setdefault("cellData", {})
row_data = cell_data.setdefault(str(row), {})
cell_obj = row_data.setdefault(str(col), {})
cell_obj["v"] = value
display_formula = formula.strip()
if display_formula and not display_formula.startswith("="):
display_formula = f"={display_formula}"
if display_formula:
cell_obj["f"] = display_formula
cell_obj["t"] = 4
custom = cell_obj.get("custom") or {}
custom["type"] = "expression"
custom["field"] = formula
custom["formula"] = display_formula or formula
cell_obj["custom"] = custom
def _collect_expression_targets(
cells_meta: Dict[str, Any],
snapshot: Dict[str, Any],
) -> List[Tuple[str, int, int, str]]:
"""返回 (sheet_id, row, col, formula)"""
targets: List[Tuple[str, int, int, str]] = []
seen: set = set()
for cell in cells_meta.get("cells") or []:
if cell.get("type") != "expression":
continue
sheet_id = cell.get("sheet", "sheet1")
row = int(cell.get("row", 0))
col = int(cell.get("col", 0))
custom = cell.get("custom") or {}
formula = (
custom.get("field")
or custom.get("value")
or custom.get("formula")
or ""
)
key = (sheet_id, row, col)
if key not in seen:
seen.add(key)
targets.append((sheet_id, row, col, str(formula)))
sheets = snapshot.get("sheets") or {}
for sheet_id, sheet in sheets.items():
if not isinstance(sheet, dict):
continue
cell_data = sheet.get("cellData") or {}
for rk, row in cell_data.items():
if not isinstance(row, dict):
continue
try:
row_i = int(rk)
except ValueError:
continue
for ck, cell in row.items():
if not isinstance(cell, dict):
continue
custom = cell.get("custom") or {}
if custom.get("type") != "expression":
continue
try:
col_i = int(ck)
except ValueError:
continue
formula = (
custom.get("field")
or custom.get("value")
or custom.get("formula")
or cell.get("v")
or ""
)
key = (sheet_id, row_i, col_i)
if key not in seen:
seen.add(key)
targets.append((sheet_id, row_i, col_i, str(formula)))
return targets
def detect_expression_cycles(
cells_meta: Dict[str, Any],
snapshot: Optional[Dict[str, Any]] = None,
) -> List[str]:
"""
检测表达式单元格引用环。
返回警告码列表(供预览 API warnings 字段使用)。
"""
snap = snapshot if snapshot is not None else {"sheets": {}}
targets = _collect_expression_targets(cells_meta, snap)
if len(targets) <= 1:
return []
_, has_cycle = _sort_expression_targets(targets)
if has_cycle:
return ["expression_cycle"]
return []
def apply_expression_cells(
snapshot: Dict[str, Any],
cells_meta: Dict[str, Any],
datasets: Dict[str, List[Any]],
params: Dict[str, Any],
) -> Dict[str, Any]:
if not snapshot:
return snapshot or {}
result = copy.deepcopy(snapshot)
sheets = result.get("sheets") or {}
targets = _collect_expression_targets(cells_meta, result)
if not targets:
return result
ordered, has_cycle = _sort_expression_targets(targets)
max_passes = min(len(targets) + 1, 32)
def _eval_all(batch: List[Tuple[str, int, int, str]]) -> bool:
changed = False
for sheet_id, row, col, formula in batch:
prev = _get_cell_value(result, sheet_id, row, col)
value = evaluate_formula(formula, params, datasets, result, sheet_id)
if str(prev) != str(value):
changed = True
_write_expression_cell(sheets, sheet_id, row, col, formula, value)
return changed
if not has_cycle:
_eval_all(ordered)
else:
for _ in range(max_passes):
if not _eval_all(targets):
break
result["sheets"] = sheets
return result
@@ -0,0 +1,51 @@
# JNPF Univer 报表对标差异说明
## 已对齐
| 能力 | JNPF | ZQ |
|------|------|-----|
| 列表向下扩展 | `polymerizationType=1` | `polymerize._poly_list` |
| 分组 / 相邻分组 | `2` + `groupType` | `polymerize._poly_group` |
| 汇总 | `3` + `summaryType` | `polymerize._poly_summary` |
| 左/上父格 | `leftParentType` / `topParentType` | `parent_cells.py` |
| 字段映射 | `fieldMapping` | `dataset_transform.apply_field_mapping` |
| 转换规则 | `convertConfig` select/date/number | `dataset_transform.apply_convert_rules` |
| convert 全类型 | user/dept/org/role/dict | `convert_lookup.py` |
| 分栏布局 | `f_fence_list` | `column_layout`(优先 `fence_list` |
| 服务端水印 | preview 解析 showTime | `watermark.py` + `watermark` 响应字段 |
| Excel 导出 | merge/样式/尺寸/页眉水印/条件格式/超链接/图片 | `export_excel.py` + `export_excel_extras.py` |
| 导出权限 | `allow_export` | `export-excel` 接口强制校验 |
| parameterData | 系统变量 HTTP | `parameter_resolver.py` 本地合并 |
| PDF 导出 | 部分用打印 | `export_pdf.py` |
| 扩展合并单元格 | merge 重算 | `merge_recalc.py` |
| fillDirection | portrait/landscape | `data_expand._expand_direction` |
| displayType | qrCode/jsbarcode | 设计器保存 + 引擎识别 |
## JNPF DB 真实样例 Golden2026-05-23
`jnpf-database-v6x/MySQL/jnpf_db_init.sql` 提取,脚本:
```bash
cd backend-fastapi
python -m online_dev.report_manager.engine.fixtures.extract_jnpf_fixtures
```
| Fixture | JNPF 模板 | 场景 |
|---------|-----------|------|
| `golden_jnpf_db_user_list.json` | 人员花名册(列表) | 列表 portrait 扩展 |
| `golden_jnpf_db_user_group.json` | 人员花名册(分组) | 分组 polymerizationType=2 |
| `golden_jnpf_db_user_matrix.json` | 人员花名册(行列) | landscape + portrait 混合 |
## 已知差异 / 待补
| 项 | 说明 | 优先级 |
|----|------|--------|
| report_run_log | 未实现 | P3 |
| App 菜单发布 | 范围外 | — |
| 独立报表微服务 | JNPF :32000,ZQ 单体 | 架构差异,保持 |
## Golden 样例来源
- 手写对标:`golden_jnpf_*.json`(引擎单元场景)
- 生产 DB 提取:`golden_jnpf_db_*.json`(真实 snapshot/cells 结构)
- 转换规则占位:`golden_jnpf_prod_*.json`
@@ -0,0 +1,61 @@
# Golden Test Fixtures
运行:`python -m online_dev.report_manager.engine.test_golden`
## 格式
```json
{
"name": "用例名",
"snapshot": { "sheets": { ... } },
"cells": { "cells": [ ... ] },
"datasets": { "别名": [ { ... } ] },
"params": {},
"column_list": [],
"expect": {
"sheet1": { "行,列": "期望值" }
}
}
```
## Golden 样例(23 fixtures
运行后应输出 `ok (23 fixtures)`
### JNPF DB 真实样例
从 JNPF `jnpf_db_init.sql` 提取(人员花名册 列表/分组/行列):
```bash
cd backend-fastapi
python -m online_dev.report_manager.engine.fixtures.extract_jnpf_fixtures
```
| 文件 | 场景 |
|------|------|
| `golden_jnpf_db_user_list.json` | 列表 portrait |
| `golden_jnpf_db_user_group.json` | 分组 |
| `golden_jnpf_db_user_matrix.json` | 行列 landscape |
手写对标样例仍放在 `golden_jnpf_*.json` / `golden_*.json`
`expect` 中填写本引擎 `transform()` 后应对的单元格值。
参考样例:`golden_jnpf_style.json`(参数 + 双列列表 + 占位符)。
## 阶段说明
| 阶段 | 能力 |
|------|------|
| 已完成 | transform 流水线、分栏、表达式、图表 chartData、fillEmptyRows |
| Phase 8 | 父格拓扑扩展、`polymerizationType`、JNPF golden、图表拾色器 |
| Phase 8+ | 跨行上父格、全表 `row_registry``parent_scoped` 子格数据切片 |
## JNPF 父格 Golden
| 文件 | 场景 |
|------|------|
| `golden_jnpf_parent_group.json` | 左父格 + 分组/列表 |
| `golden_jnpf_top_parent.json` | 同行上父格链(年→月→金额) |
| `golden_jnpf_cross_row.json` | 跨行上父格(同列子格优先显示) |
| `golden_jnpf_poly_summary.json` | 汇总格 |
| `golden_jnpf_export_list.json` | 字符串行列 + fillDirection |
@@ -0,0 +1,327 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
从 JNPF jnpf_db_init.sql 提取 report_version 真实样例,生成 ZQ Golden fixture。
用法(在 backend-fastapi 目录):
python -m online_dev.report_manager.engine.fixtures.extract_jnpf_fixtures
python -m online_dev.report_manager.engine.fixtures.extract_jnpf_fixtures --sql /path/to/jnpf_db_init.sql
"""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from online_dev.report_manager.engine.convert import transform
DEFAULT_SQL = Path(
"/Users/zcl/Project/JZKJ/lowcode6.2.x/6.2.x/jnpf-database-v6x/MySQL/jnpf_db_init.sql"
)
OUT_DIR = Path(__file__).parent
# version_id -> (slug, template_name, dataset_alias, max_rows)
TARGET_VERSIONS: List[Tuple[str, str, str, str, int]] = [
("623183857306304837", "jnpf_db_user_list", "人员花名册(列表)", "user", 8),
("623200369010278981", "jnpf_db_user_group", "人员花名册(分组)", "user", 8),
("623204233562292805", "jnpf_db_user_matrix", "人员花名册(行列)", "report_user", 6),
]
def parse_sql_values(line: str) -> List[Any]:
start = line.index("VALUES (") + len("VALUES (")
fields: List[Any] = []
i, n = start, len(line)
while i < n:
c = line[i]
if c == "'":
i += 1
buf: List[str] = []
while i < n:
if line[i] == "\\" and i + 1 < n:
nxt = line[i + 1]
if nxt == "\\":
buf.append("\\")
i += 2
elif nxt == "'":
buf.append("'")
i += 2
elif nxt == '"':
buf.append('"')
i += 2
elif nxt == "n":
buf.append("\n")
i += 2
elif nxt == "r":
buf.append("\r")
i += 2
elif nxt == "t":
buf.append("\t")
i += 2
else:
buf.append(nxt)
i += 2
elif line[i] == "'" and i + 1 < n and line[i + 1] == "'":
buf.append("'")
i += 2
elif line[i] == "'":
i += 1
break
else:
buf.append(line[i])
i += 1
fields.append("".join(buf))
elif c in " \t\n\r":
i += 1
elif c == ",":
i += 1
elif c.isdigit() or c == "-":
j = i
while j < n and line[j] not in ",)":
j += 1
fields.append(line[i:j].strip())
i = j
elif c == "N" and line[i : i + 4] == "NULL":
fields.append(None)
i += 4
elif c == ")":
break
else:
i += 1
return fields
def _load_sql_lines(sql_path: Path) -> List[str]:
return sql_path.read_text(encoding="utf-8").splitlines()
def _parse_users(lines: List[str]) -> List[dict]:
users: List[dict] = []
for line in lines:
if not line.startswith("INSERT INTO `report_user`"):
continue
f = parse_sql_values(line)
users.append(
{
"username": f[1],
"education": f[2],
"sex": f[3],
"salary": float(f[4]),
"departmentnum": f[5],
}
)
return users
def _parse_departments(lines: List[str]) -> Dict[str, dict]:
by_num: Dict[str, dict] = {}
for line in lines:
if not line.startswith("INSERT INTO `report_department`"):
continue
f = parse_sql_values(line)
by_num[str(f[2])] = {
"organizationName": f[3],
"departmentName": f[1],
"departmentNum": f[2],
}
return by_num
def _build_user_dataset(users: List[dict], depts: Dict[str, dict], limit: int) -> List[dict]:
rows: List[dict] = []
for u in users[:limit]:
d = depts.get(u["departmentnum"], {})
rows.append(
{
"orgname": d.get("organizationName", ""),
"depName": d.get("departmentName", ""),
"education": u["education"],
"sex": u["sex"],
"username": u["username"],
"salary": u["salary"],
}
)
return rows
def _build_report_user_dataset(users: List[dict], depts: Dict[str, dict], limit: int) -> List[dict]:
rows: List[dict] = []
for u in users[:limit]:
d = depts.get(u["departmentnum"], {})
rows.append(
{
**u,
"organizationName": d.get("organizationName", ""),
"departmentName": d.get("departmentName", ""),
}
)
return rows
def _find_version_line(lines: List[str], version_id: str) -> Optional[str]:
for line in lines:
if f"'{version_id}'" in line and "INSERT INTO `report_version`" in line:
return line
return None
def _trim_snapshot(snapshot: dict, cells: dict, keep_rows: int = 30) -> dict:
"""保留绑定相关行,剥离 styles/resources 等大字段,减小 fixture 体积。"""
binding_rows: set[int] = set()
for cell in cells.get("cells") or []:
try:
binding_rows.add(int(cell.get("row", 0)))
except (TypeError, ValueError):
pass
max_row = max(binding_rows) if binding_rows else 10
max_row = min(max_row + len(binding_rows) + 5, keep_rows)
sheet_order = snapshot.get("sheetOrder") or []
sheets_out: Dict[str, Any] = {}
for sid in sheet_order:
sheet = (snapshot.get("sheets") or {}).get(sid) or {}
cell_data = sheet.get("cellData") or {}
trimmed: Dict[str, Any] = {}
for rk, row in cell_data.items():
if int(rk) <= max_row:
trimmed[rk] = row
sheets_out[sid] = {
"id": sid,
"cellData": trimmed,
}
if sheet.get("mergeData"):
sheets_out[sid]["mergeData"] = sheet["mergeData"]
return {
"id": snapshot.get("id") or "_workbook",
"sheetOrder": list(sheet_order),
"sheets": sheets_out,
}
def _collect_expect(out_snapshot: dict, cells: dict, max_rows: int = 30) -> Dict[str, Dict[str, str]]:
data_cells = [c for c in (cells.get("cells") or []) if c.get("type") == "dataSource"]
if not data_cells:
return {}
sheet_ids = {str(c.get("sheet")) for c in data_cells}
cols: set[int] = set()
start_row = 9999
for c in data_cells:
cols.add(int(c.get("col", 0)))
start_row = min(start_row, int(c.get("row", 0)))
expect: Dict[str, Dict[str, str]] = {}
for sid in sheet_ids:
sheet = (out_snapshot.get("sheets") or {}).get(sid) or {}
cell_data = sheet.get("cellData") or {}
expect[sid] = {}
for rk, row in cell_data.items():
ri = int(rk)
if ri < start_row or ri > max_rows:
continue
for ck, cell in row.items():
ci = int(ck)
if ci not in cols:
continue
v = cell.get("v")
if v is None or v == "":
continue
expect[sid][f"{ri},{ci}"] = str(v)
return expect
def _parse_json_field(raw: Any) -> Any:
if raw is None or raw == "NULL":
return None
if isinstance(raw, (dict, list)):
return raw
s = str(raw).strip()
if not s:
return None
return json.loads(s)
def build_fixture(
lines: List[str],
version_id: str,
slug: str,
template_name: str,
dataset_alias: str,
row_limit: int,
) -> dict:
line = _find_version_line(lines, version_id)
if not line:
raise ValueError(f"report_version {version_id} not found in SQL")
fields = parse_sql_values(line)
cells = json.loads(fields[4])
snapshot = json.loads(fields[5])
convert_config = _parse_json_field(fields[7])
sort_list = _parse_json_field(fields[19])
fence_list = _parse_json_field(fields[21]) or _parse_json_field(fields[20])
users = _parse_users(lines)
depts = _parse_departments(lines)
if dataset_alias == "report_user":
dataset_rows = _build_report_user_dataset(users, depts, row_limit)
else:
dataset_rows = _build_user_dataset(users, depts, row_limit)
trimmed_snapshot = _trim_snapshot(snapshot, cells)
datasets = {dataset_alias: dataset_rows}
out = transform(
trimmed_snapshot,
cells,
datasets,
{},
column_list=None,
fence_list=fence_list,
)
expect = _collect_expect(out, cells)
fixture: dict = {
"name": slug,
"comment": f"JNPF DB 真实样例: {template_name} (version {version_id})",
"jnpf_version_id": version_id,
"snapshot": trimmed_snapshot,
"cells": cells,
"datasets": datasets,
"params": {},
"expect": expect,
}
if convert_config:
fixture["convert_config"] = convert_config
if fence_list:
fixture["fence_list"] = fence_list
if sort_list:
fixture["sort_list"] = sort_list
return fixture
def main() -> None:
parser = argparse.ArgumentParser(description="Extract JNPF report_version golden fixtures")
parser.add_argument("--sql", type=Path, default=DEFAULT_SQL, help="jnpf_db_init.sql path")
parser.add_argument("--out-dir", type=Path, default=OUT_DIR, help="output directory")
args = parser.parse_args()
if not args.sql.is_file():
raise SystemExit(f"SQL file not found: {args.sql}")
lines = _load_sql_lines(args.sql)
written: List[str] = []
for version_id, slug, template_name, alias, limit in TARGET_VERSIONS:
fixture = build_fixture(lines, version_id, slug, template_name, alias, limit)
out_path = args.out_dir / f"golden_{slug}.json"
with open(out_path, "w", encoding="utf-8") as f:
json.dump(fixture, f, ensure_ascii=False, indent=2)
written.append(out_path.name)
print(f"wrote {out_path.name} ({len(fixture['expect'].get(list(fixture['expect'])[0], {}))} expect cells)")
print(f"done: {len(written)} fixtures")
if __name__ == "__main__":
main()
@@ -0,0 +1,42 @@
{
"name": "column_col_type1_max_col",
"snapshot": {
"sheetOrder": ["sheet1"],
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": { "0": { "v": "1" } },
"1": { "0": { "v": "2" } },
"2": { "0": { "v": "3" } },
"3": { "0": { "v": "4" } },
"4": { "0": { "v": "5" } }
}
}
}
},
"cells": { "cells": [] },
"datasets": {},
"params": {},
"column_list": [
{
"sheet": "sheet1",
"columnList": {
"columnState": true,
"columnStyle": "col",
"columnType": "1",
"maxCol": 2,
"columnData": "A1:A5"
}
}
],
"expect": {
"sheet1": {
"0,0": "1",
"1,0": "2",
"0,1": "3",
"1,1": "4",
"0,2": "5"
}
}
}
@@ -0,0 +1,40 @@
{
"name": "column_col_type2",
"snapshot": {
"sheetOrder": ["sheet1"],
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": { "0": { "v": "a" } },
"2": { "0": { "v": "b" } },
"3": { "0": { "v": "c" } },
"4": { "0": { "v": "d" } }
}
}
}
},
"cells": { "cells": [] },
"datasets": {},
"params": {},
"column_list": [
{
"sheet": "sheet1",
"columnList": {
"columnState": true,
"columnStyle": "col",
"columnType": "2",
"rowCount": 2,
"columnData": "A2:A5"
}
}
],
"expect": {
"sheet1": {
"1,0": "a",
"2,0": "b",
"1,1": "c",
"2,1": "d"
}
}
}
@@ -0,0 +1,39 @@
{
"name": "expression_chain_b1_c1",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": { "0": { "v": "100" }, "1": { "v": "" }, "2": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "expression",
"sheet": "sheet1",
"row": 0,
"col": 1,
"custom": { "field": "=A1+1" }
},
{
"type": "expression",
"sheet": "sheet1",
"row": 0,
"col": 2,
"custom": { "field": "=B1+1" }
}
]
},
"datasets": {},
"params": {},
"expect": {
"sheet1": {
"0,1": "101",
"0,2": "102"
}
}
}
@@ -0,0 +1,43 @@
{
"name": "datasource_fill_empty_rows",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": { "0": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"type": "dataSource",
"dataSetName": "items",
"field": "name",
"expand": "down",
"fillEmptyRows": true,
"fillEmptyNum": 2
}
}
]
},
"datasets": {
"items": [{ "name": "A" }, { "name": "B" }]
},
"params": {},
"expect": {
"sheet1": {
"1,0": "A",
"2,0": "B",
"3,0": "",
"4,0": ""
}
}
}
@@ -0,0 +1,76 @@
{
"name": "jnpf_cross_row_top_left",
"comment": "跨行:年(0,0)分组 → 月(1,0)上父年 → 金额(1,1)左父月;同列时子格覆盖父格显示",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": { "0": { "v": "" }, "1": { "v": "" } },
"1": { "0": { "v": "" }, "1": { "v": "" } },
"2": { "0": { "v": "" }, "1": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 0,
"col": 0,
"custom": {
"dataSetName": "sales",
"field": "year",
"polymerizationType": "2",
"expand": "down"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"dataSetName": "sales",
"field": "month",
"polymerizationType": "1",
"expand": "down",
"topParentCellType": "default"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 1,
"custom": {
"dataSetName": "sales",
"field": "amount",
"polymerizationType": "1",
"expand": "down",
"leftParentCellType": "default"
}
}
]
},
"datasets": {
"sales": [
{ "year": 2023, "month": 1, "amount": 10 },
{ "year": 2023, "month": 2, "amount": 20 },
{ "year": 2024, "month": 1, "amount": 30 }
]
},
"params": {},
"expect": {
"sheet1": {
"0,0": "1",
"1,0": "2",
"2,0": "1",
"0,1": "10",
"1,1": "20",
"2,1": "30"
}
}
}
@@ -0,0 +1,686 @@
{
"name": "jnpf_db_user_group",
"comment": "JNPF DB 真实样例: 人员花名册(分组) (version 623200369010278981)",
"jnpf_version_id": "623200369010278981",
"snapshot": {
"id": "_cMcfw",
"sheetOrder": [
"Eh_Jx6bicu3SB2VKA8XcS"
],
"sheets": {
"Eh_Jx6bicu3SB2VKA8XcS": {
"id": "Eh_Jx6bicu3SB2VKA8XcS",
"cellData": {
"0": {
"0": {
"v": "人员花名册",
"s": "VLEizt",
"custom": {
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": ""
},
"t": 1
},
"1": {
"s": "KZ1C-t"
},
"2": {
"s": "KZ1C-t"
},
"3": {
"s": "KZ1C-t"
},
"4": {
"s": "KZ1C-t"
},
"5": {
"s": "lYauvg"
},
"6": {
"s": "-56Kck"
}
},
"1": {
"0": {
"v": "组织",
"t": 1,
"s": "vHs82n"
},
"1": {
"v": "部门",
"t": 1,
"s": "vHs82n"
},
"2": {
"v": "学历",
"t": 1,
"s": "vHs82n"
},
"3": {
"v": "性别",
"t": 1,
"s": "vHs82n"
},
"4": {
"v": "姓名",
"t": 1,
"s": "vHs82n"
},
"5": {
"v": "薪资",
"s": "vHs82n",
"custom": {
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": ""
},
"t": 1
},
"6": {
"s": "-56Kck"
}
},
"2": {
"0": {
"v": "${user.orgname}",
"t": 1,
"s": "Xk2Rw5",
"custom": {
"field": "user.orgname",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
"1": {
"v": "${user.depName}",
"t": 1,
"s": "hdQ2ih",
"custom": {
"field": "user.depName",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
"2": {
"v": "${user.education}",
"t": 1,
"s": "2KKOQW",
"custom": {
"field": "user.education",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
"3": {
"v": "${user.sex}",
"t": 1,
"s": "4n6jyh",
"custom": {
"field": "user.sex",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
"4": {
"v": "${user.username}",
"t": 1,
"s": "hdQ2ih",
"custom": {
"field": "user.username",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"5": {
"v": "${user.salary}",
"t": 1,
"s": "d0FA0C",
"custom": {
"field": "user.salary",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"6": {
"s": "-56Kck"
}
},
"3": {
"0": {
"v": "合计:",
"t": 1,
"s": "-l-j-h"
},
"1": {
"s": "3sqGgY"
},
"2": {
"s": "3sqGgY"
},
"3": {
"s": "3sqGgY"
},
"4": {
"v": "${user.username}",
"t": 1,
"s": "L9T7Dl",
"custom": {
"field": "user.username",
"polymerizationType": "3",
"summaryType": "count",
"fillDirection": "portrait",
"leftParentCellType": "none",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "none",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"5": {
"v": "${user.salary}",
"t": 1,
"s": "HPHgPE",
"custom": {
"field": "user.salary",
"polymerizationType": "3",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "none",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "none",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"6": {
"s": "-56Kck"
}
},
"4": {
"0": {
"v": "",
"t": 1,
"s": "T9ZVIu"
},
"1": {
"v": "",
"t": 1,
"s": "T9ZVIu"
},
"2": {
"v": "",
"t": 1,
"s": "T9ZVIu"
},
"3": {
"v": "",
"t": 1,
"s": "T9ZVIu"
},
"4": {
"v": "",
"t": 1,
"s": "T9ZVIu"
},
"5": {
"v": "",
"t": 1,
"s": "T9ZVIu"
}
},
"5": {
"0": {
"v": "",
"t": 1,
"s": "FgxYUY"
},
"1": {
"v": "",
"t": 1,
"s": "FgxYUY"
},
"2": {
"v": "",
"t": 1,
"s": "FgxYUY"
},
"3": {
"v": "",
"t": 1,
"s": "FgxYUY"
},
"4": {
"v": "制表日期:",
"t": 1,
"s": "UWFE6A"
},
"5": {
"s": "ekE1b4",
"f": "=NOW()",
"v": 45848.65962962963,
"t": 2
}
}
},
"mergeData": [
{
"startRow": 0,
"endRow": 0,
"startColumn": 0,
"endColumn": 5
},
{
"startRow": 3,
"endRow": 3,
"startColumn": 0,
"endColumn": 3
}
]
}
}
},
"cells": {
"cells": [
{
"col": "0",
"row": "0",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"custom": {
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": ""
}
},
{
"col": "5",
"row": "1",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"custom": {
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": ""
}
},
{
"col": "0",
"row": "2",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"type": "dataSource",
"custom": {
"field": "user.orgname",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
{
"col": "1",
"row": "2",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"type": "dataSource",
"custom": {
"field": "user.depName",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
{
"col": "2",
"row": "2",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"type": "dataSource",
"custom": {
"field": "user.education",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
{
"col": "3",
"row": "2",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"type": "dataSource",
"custom": {
"field": "user.sex",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
{
"col": "4",
"row": "2",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"type": "dataSource",
"custom": {
"field": "user.username",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
{
"col": "5",
"row": "2",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"type": "dataSource",
"custom": {
"field": "user.salary",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
{
"col": "4",
"row": "3",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"type": "dataSource",
"custom": {
"field": "user.username",
"polymerizationType": "3",
"summaryType": "count",
"fillDirection": "portrait",
"leftParentCellType": "none",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "none",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
{
"col": "5",
"row": "3",
"sheet": "Eh_Jx6bicu3SB2VKA8XcS",
"type": "dataSource",
"custom": {
"field": "user.salary",
"polymerizationType": "3",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "none",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "none",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
}
],
"floatEcharts": {},
"cellEcharts": {},
"floatImages": {}
},
"datasets": {
"user": [
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "博士后",
"sex": "1",
"username": "曦晨",
"salary": 2410.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "本科",
"sex": "1",
"username": "昊明",
"salary": 3639.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "本科",
"sex": "1",
"username": "昊硕",
"salary": 2101.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "本科",
"sex": "1",
"username": "欧阳",
"salary": 5863.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "高中",
"sex": "2",
"username": "王忠亮",
"salary": 6128.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "本科",
"sex": "1",
"username": "吴忠民",
"salary": 3839.0
},
{
"orgname": "上海",
"depName": "上海-软件产品支持部",
"education": "博士",
"sex": "2",
"username": "张秀恩",
"salary": 3943.0
},
{
"orgname": "上海",
"depName": "上海-软件产品支持部",
"education": "本科",
"sex": "1",
"username": "姜磊",
"salary": 1474.0
}
]
},
"params": {},
"expect": {
"Eh_Jx6bicu3SB2VKA8XcS": {
"2,0": "广东",
"2,1": "深圳-软件技术支持部",
"2,2": "博士后",
"2,3": "1",
"2,4": "曦晨",
"2,5": "2410.0",
"3,0": "广东",
"3,1": "深圳-软件技术支持部",
"3,2": "本科",
"3,3": "1",
"3,4": "8",
"3,5": "3639.0",
"4,0": "广东",
"4,1": "深圳-软件技术支持部",
"4,2": "本科",
"4,3": "1",
"4,4": "昊硕",
"4,5": "29397.0",
"5,0": "广东",
"5,1": "深圳-软件技术支持部",
"5,2": "本科",
"5,3": "1",
"5,4": "欧阳",
"5,5": "5863.0",
"6,5": "3839.0",
"6,4": "吴忠民",
"6,3": "1",
"6,2": "本科",
"6,1": "深圳-软件技术支持部",
"6,0": "广东",
"7,5": "6128.0",
"7,4": "王忠亮",
"7,3": "2",
"7,2": "高中",
"7,1": "深圳-软件技术支持部",
"7,0": "广东",
"8,5": "3943.0",
"8,4": "张秀恩",
"8,3": "2",
"8,2": "博士",
"8,1": "上海-软件产品支持部",
"8,0": "上海",
"9,5": "1474.0",
"9,4": "姜磊",
"9,3": "1",
"9,2": "本科",
"9,1": "上海-软件产品支持部",
"9,0": "上海"
}
},
"convert_config": [
{
"field": "user.sex",
"type": "select",
"config": {
"dataType": "dictionary",
"options": [],
"dictionaryType": "963255a34ea64a2584c5d1ba269c1fe6",
"propsValue": "enCode",
"format": "yyyy-MM-dd",
"precision": 0,
"thousands": false
}
}
]
}
@@ -0,0 +1,592 @@
{
"name": "jnpf_db_user_list",
"comment": "JNPF DB 真实样例: 人员花名册(列表) (version 623183857306304837)",
"jnpf_version_id": "623183857306304837",
"snapshot": {
"id": "PlhIEz",
"sheetOrder": [
"E-ZBgdonv3JP-AKiPx-Dz"
],
"sheets": {
"E-ZBgdonv3JP-AKiPx-Dz": {
"id": "E-ZBgdonv3JP-AKiPx-Dz",
"cellData": {
"0": {
"0": {
"v": "人员花名册",
"s": "brTn0f",
"custom": {
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": ""
},
"t": 1
},
"1": {
"s": "O-e1uN"
},
"2": {
"s": "O-e1uN"
},
"3": {
"s": "O-e1uN"
},
"4": {
"s": "O-e1uN"
},
"5": {
"s": "quTX_s"
},
"6": {
"s": "w3oh7s"
}
},
"1": {
"0": {
"v": "组织",
"t": 1,
"s": "ZeHsQI"
},
"1": {
"v": "部门",
"t": 1,
"s": "ZeHsQI"
},
"2": {
"v": "学历",
"t": 1,
"s": "ZeHsQI"
},
"3": {
"v": "性别",
"t": 1,
"s": "ZeHsQI"
},
"4": {
"v": "姓名",
"t": 1,
"s": "ZeHsQI"
},
"5": {
"v": "薪资",
"t": 1,
"s": "ZeHsQI"
},
"6": {
"s": "w3oh7s"
}
},
"2": {
"0": {
"v": "${user.orgname}",
"t": 1,
"s": "VGNL-Q",
"custom": {
"field": "user.orgname",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"1": {
"v": "${user.depName}",
"t": 1,
"s": "l8Zma3",
"custom": {
"field": "user.depName",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"2": {
"v": "${user.education}",
"t": 1,
"s": "wtFaaw",
"custom": {
"field": "user.education",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"3": {
"v": "${user.sex}",
"t": 1,
"s": "FG5eVb",
"custom": {
"field": "user.sex",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"4": {
"v": "${user.username}",
"t": 1,
"s": "VGNL-Q",
"custom": {
"field": "user.username",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"5": {
"v": "${user.salary}",
"t": 1,
"s": "o-8LER",
"custom": {
"field": "user.salary",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"6": {
"s": "w3oh7s"
}
},
"3": {
"0": {
"v": "合计:",
"t": 1,
"s": "2so-W9"
},
"1": {
"s": "h1agbN"
},
"2": {
"s": "h1agbN"
},
"3": {
"s": "h1agbN"
},
"4": {
"v": "${user.username}",
"t": 1,
"s": "7k_VtD",
"custom": {
"field": "user.username",
"polymerizationType": "3",
"summaryType": "count",
"fillDirection": "portrait",
"leftParentCellType": "none",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "none",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
"5": {
"s": "2dGCXz",
"f": "=SUM(F3)",
"v": 0,
"t": 2
},
"6": {
"s": "w3oh7s"
}
},
"4": {
"0": {
"s": "w3oh7s"
},
"1": {
"s": "w3oh7s"
},
"2": {
"s": "w3oh7s"
},
"3": {
"s": "w3oh7s"
},
"4": {
"s": "w3oh7s"
},
"5": {
"s": "w3oh7s"
}
},
"5": {
"4": {
"v": "制表日期:",
"t": 1,
"s": "etLyRt"
},
"5": {
"f": "=NOW()",
"v": 45848.65981481481,
"t": 2,
"s": "qWukzd"
}
}
},
"mergeData": [
{
"startRow": 0,
"endRow": 0,
"startColumn": 0,
"endColumn": 5
},
{
"startRow": 3,
"endRow": 3,
"startColumn": 0,
"endColumn": 3
}
]
}
}
},
"cells": {
"cells": [
{
"col": "0",
"row": "0",
"sheet": "E-ZBgdonv3JP-AKiPx-Dz",
"custom": {
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": ""
}
},
{
"col": "0",
"row": "2",
"sheet": "E-ZBgdonv3JP-AKiPx-Dz",
"type": "dataSource",
"custom": {
"field": "user.orgname",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
{
"col": "1",
"row": "2",
"sheet": "E-ZBgdonv3JP-AKiPx-Dz",
"type": "dataSource",
"custom": {
"field": "user.depName",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
{
"col": "2",
"row": "2",
"sheet": "E-ZBgdonv3JP-AKiPx-Dz",
"type": "dataSource",
"custom": {
"field": "user.education",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
{
"col": "3",
"row": "2",
"sheet": "E-ZBgdonv3JP-AKiPx-Dz",
"type": "dataSource",
"custom": {
"field": "user.sex",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
{
"col": "4",
"row": "2",
"sheet": "E-ZBgdonv3JP-AKiPx-Dz",
"type": "dataSource",
"custom": {
"field": "user.username",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
{
"col": "5",
"row": "2",
"sheet": "E-ZBgdonv3JP-AKiPx-Dz",
"type": "dataSource",
"custom": {
"field": "user.salary",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
},
{
"col": "4",
"row": "3",
"sheet": "E-ZBgdonv3JP-AKiPx-Dz",
"type": "dataSource",
"custom": {
"field": "user.username",
"polymerizationType": "3",
"summaryType": "count",
"fillDirection": "portrait",
"leftParentCellType": "none",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "none",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"displayType": "default"
}
}
],
"floatEcharts": {},
"cellEcharts": {},
"floatImages": {}
},
"datasets": {
"user": [
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "博士后",
"sex": "1",
"username": "曦晨",
"salary": 2410.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "本科",
"sex": "1",
"username": "昊明",
"salary": 3639.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "本科",
"sex": "1",
"username": "昊硕",
"salary": 2101.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "本科",
"sex": "1",
"username": "欧阳",
"salary": 5863.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "高中",
"sex": "2",
"username": "王忠亮",
"salary": 6128.0
},
{
"orgname": "广东",
"depName": "深圳-软件技术支持部",
"education": "本科",
"sex": "1",
"username": "吴忠民",
"salary": 3839.0
},
{
"orgname": "上海",
"depName": "上海-软件产品支持部",
"education": "博士",
"sex": "2",
"username": "张秀恩",
"salary": 3943.0
},
{
"orgname": "上海",
"depName": "上海-软件产品支持部",
"education": "本科",
"sex": "1",
"username": "姜磊",
"salary": 1474.0
}
]
},
"params": {},
"expect": {
"E-ZBgdonv3JP-AKiPx-Dz": {
"2,0": "广东",
"2,1": "深圳-软件技术支持部",
"2,2": "博士后",
"2,3": "1",
"2,4": "曦晨",
"2,5": "2410.0",
"3,0": "广东",
"3,1": "深圳-软件技术支持部",
"3,2": "本科",
"3,3": "1",
"3,4": "8",
"3,5": "3639.0",
"4,0": "广东",
"4,1": "深圳-软件技术支持部",
"4,2": "本科",
"4,3": "1",
"4,4": "昊硕",
"4,5": "2101.0",
"5,4": "欧阳",
"5,5": "5863.0",
"5,3": "1",
"5,2": "本科",
"5,1": "深圳-软件技术支持部",
"5,0": "广东",
"6,5": "6128.0",
"6,4": "王忠亮",
"6,3": "2",
"6,2": "高中",
"6,1": "深圳-软件技术支持部",
"6,0": "广东",
"7,5": "3839.0",
"7,4": "吴忠民",
"7,3": "1",
"7,2": "本科",
"7,1": "深圳-软件技术支持部",
"7,0": "广东",
"8,5": "3943.0",
"8,4": "张秀恩",
"8,3": "2",
"8,2": "博士",
"8,1": "上海-软件产品支持部",
"8,0": "上海",
"9,5": "1474.0",
"9,4": "姜磊",
"9,3": "1",
"9,2": "本科",
"9,1": "上海-软件产品支持部",
"9,0": "上海"
}
},
"convert_config": [
{
"field": "user.sex",
"type": "select",
"config": {
"dataType": "dictionary",
"options": [],
"dictionaryType": "963255a34ea64a2584c5d1ba269c1fe6",
"propsValue": "enCode",
"format": "yyyy-MM-dd",
"precision": 0,
"thousands": false
}
}
]
}
@@ -0,0 +1,295 @@
{
"name": "jnpf_db_user_matrix",
"comment": "JNPF DB 真实样例: 人员花名册(行列) (version 623204233562292805)",
"jnpf_version_id": "623204233562292805",
"snapshot": {
"id": "GVaLEc",
"sheetOrder": [
"VkjbtPpyX8TggOO4aHSuO"
],
"sheets": {
"VkjbtPpyX8TggOO4aHSuO": {
"id": "VkjbtPpyX8TggOO4aHSuO",
"cellData": {
"0": {
"0": {
"v": "人员花名册",
"s": "7zkWyq",
"custom": {
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": ""
},
"t": 1
}
},
"1": {
"0": {
"v": "${report_user.organizationName}",
"t": 1,
"s": "oLh33o",
"custom": {
"field": "report_user.organizationName",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "landscape",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
}
},
"2": {
"0": {
"v": "${report_user.departmentName}",
"t": 1,
"s": "pe2oi6",
"custom": {
"field": "report_user.departmentName",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "landscape",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
}
},
"3": {
"0": {
"v": "${report_user.username}",
"t": 1,
"s": "LNCLmO",
"custom": {
"field": "report_user.username",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
}
},
"4": {
"0": {
"v": "${report_user.username}",
"t": 1,
"s": "17yxoW",
"custom": {
"field": "report_user.username",
"polymerizationType": "3",
"summaryType": "count",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "custom",
"topParentCellCustomRowName": "A",
"topParentCellCustomColName": "3",
"type": "dataSource",
"displayType": "default"
}
}
}
}
}
}
},
"cells": {
"cells": [
{
"col": "0",
"row": "0",
"sheet": "VkjbtPpyX8TggOO4aHSuO",
"custom": {
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": ""
}
},
{
"col": "0",
"row": "1",
"sheet": "VkjbtPpyX8TggOO4aHSuO",
"type": "dataSource",
"custom": {
"field": "report_user.organizationName",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "landscape",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
{
"col": "0",
"row": "2",
"sheet": "VkjbtPpyX8TggOO4aHSuO",
"type": "dataSource",
"custom": {
"field": "report_user.departmentName",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "landscape",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
{
"col": "0",
"row": "3",
"sheet": "VkjbtPpyX8TggOO4aHSuO",
"type": "dataSource",
"custom": {
"field": "report_user.username",
"polymerizationType": "2",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": "",
"type": "dataSource",
"groupType": "default",
"displayType": "default"
}
},
{
"col": "0",
"row": "4",
"sheet": "VkjbtPpyX8TggOO4aHSuO",
"type": "dataSource",
"custom": {
"field": "report_user.username",
"polymerizationType": "3",
"summaryType": "count",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "custom",
"topParentCellCustomRowName": "A",
"topParentCellCustomColName": "3",
"type": "dataSource",
"displayType": "default"
}
}
],
"floatEcharts": {},
"cellEcharts": {},
"floatImages": {}
},
"datasets": {
"report_user": [
{
"username": "曦晨",
"education": "博士后",
"sex": "1",
"salary": 2410.0,
"departmentnum": "II_6",
"organizationName": "广东",
"departmentName": "深圳-软件技术支持部"
},
{
"username": "昊明",
"education": "本科",
"sex": "1",
"salary": 3639.0,
"departmentnum": "II_6",
"organizationName": "广东",
"departmentName": "深圳-软件技术支持部"
},
{
"username": "昊硕",
"education": "本科",
"sex": "1",
"salary": 2101.0,
"departmentnum": "II_6",
"organizationName": "广东",
"departmentName": "深圳-软件技术支持部"
},
{
"username": "欧阳",
"education": "本科",
"sex": "1",
"salary": 5863.0,
"departmentnum": "II_6",
"organizationName": "广东",
"departmentName": "深圳-软件技术支持部"
},
{
"username": "王忠亮",
"education": "高中",
"sex": "2",
"salary": 6128.0,
"departmentnum": "II_6",
"organizationName": "广东",
"departmentName": "深圳-软件技术支持部"
},
{
"username": "吴忠民",
"education": "本科",
"sex": "1",
"salary": 3839.0,
"departmentnum": "II_6",
"organizationName": "广东",
"departmentName": "深圳-软件技术支持部"
}
]
},
"params": {},
"expect": {
"VkjbtPpyX8TggOO4aHSuO": {
"1,0": "广东",
"2,0": "深圳-软件技术支持部",
"3,0": "曦晨",
"4,0": "曦晨",
"5,0": "昊明",
"6,0": "昊硕",
"7,0": "欧阳",
"8,0": "王忠亮",
"9,0": "吴忠民"
}
}
}
@@ -0,0 +1,49 @@
{
"name": "jnpf_export_list_portrait",
"comment": "真实 JNPF 导出字段风格:字符串行列、fillDirection、polymerizationType",
"snapshot": {
"sheets": {
"E-ZBgdonv3JP-AKiPx-Dz": {
"id": "E-ZBgdonv3JP-AKiPx-Dz",
"cellData": {
"2": { "0": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"col": "0",
"row": "2",
"sheet": "E-ZBgdonv3JP-AKiPx-Dz",
"type": "dataSource",
"custom": {
"field": "user.name",
"polymerizationType": "1",
"summaryType": "sum",
"fillDirection": "portrait",
"leftParentCellType": "default",
"leftParentCellCustomRowName": "",
"leftParentCellCustomColName": "",
"topParentCellType": "default",
"topParentCellCustomRowName": "",
"topParentCellCustomColName": ""
}
}
]
},
"datasets": {
"user": [
{ "name": "Alice" },
{ "name": "Bob" }
]
},
"params": {},
"expect": {
"E-ZBgdonv3JP-AKiPx-Dz": {
"2,0": "Alice",
"3,0": "Bob"
}
}
}
@@ -0,0 +1,60 @@
{
"name": "jnpf_header_merge_list",
"source": "jnpf",
"comment": "表头 merge + 列表扩展(merge 行在扩展区上方保持不变)",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"mergeData": [
{ "startRow": 0, "endRow": 0, "startColumn": 0, "endColumn": 1 }
],
"cellData": {
"0": { "0": { "v": "销售明细" }, "1": { "v": "" } },
"1": { "0": { "v": "" }, "1": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"dataSetName": "items",
"field": "name",
"polymerizationType": "1",
"expand": "down"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 1,
"custom": {
"dataSetName": "items",
"field": "qty",
"polymerizationType": "1",
"expand": "down"
}
}
]
},
"datasets": {
"items": [{ "name": "A", "qty": 1 }, { "name": "B", "qty": 2 }]
},
"params": {},
"expect": {
"sheet1": {
"0,0": "销售明细",
"1,0": "A",
"2,0": "B",
"1,1": "1",
"2,1": "2"
}
}
}
@@ -0,0 +1,66 @@
{
"name": "jnpf_parent_group_down",
"comment": "左父格默认:分组列 + 列表子列按父格 dataList 扩展",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": { "0": { "v": "" }, "1": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"col": "0",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "order.category",
"dataSetName": "order",
"polymerizationType": "2",
"fillDirection": "portrait",
"expand": "down",
"leftParentCellType": "default",
"topParentCellType": "default"
}
},
{
"col": "1",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "order.product",
"dataSetName": "order",
"polymerizationType": "1",
"fillDirection": "portrait",
"expand": "down",
"leftParentCellType": "default",
"topParentCellType": "default"
}
}
]
},
"datasets": {
"order": [
{ "category": "A", "product": "p1" },
{ "category": "A", "product": "p2" },
{ "category": "B", "product": "p3" }
]
},
"params": {},
"expect": {
"sheet1": {
"1,0": "A",
"2,0": "A",
"3,0": "B",
"1,1": "p1",
"2,1": "p2",
"3,1": "p3"
}
}
}
@@ -0,0 +1,43 @@
{
"name": "jnpf_poly_summary_sum",
"comment": "polymerizationType=3 汇总格,summaryType=sum",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": { "0": { "1": { "v": "" } } }
}
}
},
"cells": {
"cells": [
{
"col": "1",
"row": "0",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "order.amount",
"dataSetName": "order",
"polymerizationType": "3",
"summaryType": "sum",
"leftParentCellType": "none",
"topParentCellType": "none"
}
}
]
},
"datasets": {
"order": [
{ "amount": 10 },
{ "amount": 20 },
{ "amount": 5 }
]
},
"params": {},
"expect": {
"sheet1": {
"0,1": "35.0"
}
}
}
@@ -0,0 +1,51 @@
{
"name": "jnpf_prod_convert_date",
"source": "jnpf",
"comment": "convertConfig date 格式转换后列表扩展",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": { "0": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"dataSetName": "order",
"field": "createdAt",
"polymerizationType": "1",
"expand": "down"
}
}
]
},
"convert_config": [
{
"field": "order.createdAt",
"type": "date",
"config": { "format": "yyyy-MM-dd" }
}
],
"datasets": {
"order": [
{ "createdAt": "2026-05-20T10:00:00" },
{ "createdAt": "2026-05-21T15:30:00" }
]
},
"params": {},
"expect": {
"sheet1": {
"1,0": "2026-05-20",
"2,0": "2026-05-21"
}
}
}
@@ -0,0 +1,48 @@
{
"name": "jnpf_prod_convert_number",
"source": "jnpf",
"comment": "convertConfig number 千分位与精度",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": { "0": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"dataSetName": "sales",
"field": "amount",
"polymerizationType": "1",
"expand": "down"
}
}
]
},
"convert_config": [
{
"field": "sales.amount",
"type": "number",
"config": { "precision": 2, "thousands": true }
}
],
"datasets": {
"sales": [{ "amount": 1234.5 }, { "amount": 1000000 }]
},
"params": {},
"expect": {
"sheet1": {
"1,0": "1,234.50",
"2,0": "1,000,000.00"
}
}
}
@@ -0,0 +1,69 @@
{
"name": "jnpf_prod_convert_select",
"comment": "convertConfig select 枚举转换后再列表扩展",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": { "0": { "v": "" }, "1": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"dataSetName": "order",
"field": "status",
"polymerizationType": "1",
"expand": "down"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 1,
"custom": {
"dataSetName": "order",
"field": "amount",
"polymerizationType": "1",
"expand": "down"
}
}
]
},
"convert_config": [
{
"field": "order.status",
"type": "select",
"config": {
"options": [
{ "id": 1, "fullName": "待审" },
{ "id": 2, "fullName": "完成" }
]
}
}
],
"datasets": {
"order": [
{ "status": 1, "amount": 100 },
{ "status": 2, "amount": 200 }
]
},
"params": {},
"expect": {
"sheet1": {
"1,0": "待审",
"2,0": "完成",
"1,1": "100",
"2,1": "200"
}
}
}
@@ -0,0 +1,74 @@
{
"name": "jnpf_prod_convert_user_inline",
"source": "jnpf",
"comment": "convertConfig user 类型(inline names 映射,对标 DataSetSwapUtil",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": { "0": { "v": "" }, "1": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"dataSetName": "task",
"field": "ownerId",
"polymerizationType": "1",
"expand": "down"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 1,
"custom": {
"dataSetName": "task",
"field": "deptId",
"polymerizationType": "1",
"expand": "down"
}
}
]
},
"convert_config": [
{
"field": "task.ownerId",
"type": "user",
"config": {
"names": { "u1": "张三", "u2": "李四" }
}
},
{
"field": "task.deptId",
"type": "department",
"config": {
"names": { "d1": "销售部", "d2": "研发部" }
}
}
],
"datasets": {
"task": [
{ "ownerId": "u1", "deptId": "d1" },
{ "ownerId": "u2", "deptId": "d2" }
]
},
"params": {},
"expect": {
"sheet1": {
"1,0": "张三",
"2,0": "李四",
"1,1": "销售部",
"2,1": "研发部"
}
}
}
@@ -0,0 +1,48 @@
{
"name": "jnpf_prod_field_mapping",
"comment": "field_mapping 重命名后绑定字段可正确列表扩展",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": { "0": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"dataSetName": "order",
"field": "product",
"polymerizationType": "1",
"expand": "down"
}
}
]
},
"field_mapping": {
"order": {
"product_code": "product"
}
},
"datasets": {
"order": [
{ "product_code": "P1" },
{ "product_code": "P2" }
]
},
"params": {},
"expect": {
"sheet1": {
"1,0": "P1",
"2,0": "P2"
}
}
}
@@ -0,0 +1,80 @@
{
"name": "jnpf_export_style_mvp",
"comment": "对齐 JNPF 导出 cells 结构:parameter #{x}、dataSource expand、全表占位",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {
"0": { "v": "Report: #{title}" },
"1": { "v": "" }
},
"1": {
"0": { "v": "" },
"1": { "v": "" }
}
}
}
}
},
"cells": {
"cells": [
{
"type": "parameter",
"sheet": "sheet1",
"row": 0,
"col": 1,
"custom": {
"type": "parameter",
"value": "#{dept}",
"field": "dept"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"type": "dataSource",
"dataSetName": "order",
"field": "product",
"expand": "down"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 1,
"custom": {
"type": "dataSource",
"dataSetName": "order",
"field": "amount",
"expand": "down"
}
}
]
},
"datasets": {
"order": [
{ "product": "P1", "amount": 100 },
{ "product": "P2", "amount": 200 }
]
},
"params": {
"title": "Sales",
"dept": "East"
},
"expect": {
"sheet1": {
"0,0": "Report: Sales",
"0,1": "East",
"1,0": "P1",
"2,0": "P2",
"1,1": "100",
"2,1": "200"
}
}
}
@@ -0,0 +1,49 @@
{
"name": "jnpf_system_params",
"source": "jnpf",
"comment": "系统参数 + 查询参数合并(parameter_resolver",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": { "0": { "v": "报表人: #{currentUserName}" }, "1": { "v": "" } },
"1": { "0": { "v": "日期: #{currentDate}" }, "1": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "parameter",
"sheet": "sheet1",
"row": 0,
"col": 1,
"custom": { "value": "#{deptName}" }
},
{
"type": "parameter",
"sheet": "sheet1",
"row": 1,
"col": 1,
"custom": { "value": "#{keyword}" }
}
]
},
"datasets": {},
"params": {
"currentUserName": "Admin",
"currentDate": "2026-05-22",
"deptName": "总部",
"keyword": "测试"
},
"expect": {
"sheet1": {
"0,0": "报表人: Admin",
"0,1": "总部",
"1,0": "日期: 2026-05-22",
"1,1": "测试"
}
}
}
@@ -0,0 +1,83 @@
{
"name": "jnpf_top_parent_same_row",
"comment": "上父格:年(分组,行0) → 月(列表,行0,上父年) → 金额(列表,行0,左父月)",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": { "0": { "v": "" }, "1": { "v": "" }, "2": { "v": "" } },
"1": { "0": { "v": "" }, "1": { "v": "" }, "2": { "v": "" } },
"2": { "0": { "v": "" }, "1": { "v": "" }, "2": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 0,
"col": 0,
"custom": {
"dataSetName": "sales",
"field": "year",
"polymerizationType": "2",
"expand": "down",
"topParentCellType": "default",
"leftParentCellType": "default"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 0,
"col": 1,
"custom": {
"dataSetName": "sales",
"field": "month",
"polymerizationType": "1",
"expand": "down",
"topParentCellType": "default",
"leftParentCellType": "default"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 0,
"col": 2,
"custom": {
"dataSetName": "sales",
"field": "amount",
"polymerizationType": "1",
"expand": "down",
"topParentCellType": "default",
"leftParentCellType": "default"
}
}
]
},
"datasets": {
"sales": [
{ "year": 2023, "month": 1, "amount": 10 },
{ "year": 2023, "month": 2, "amount": 20 },
{ "year": 2024, "month": 1, "amount": 30 }
]
},
"params": {},
"expect": {
"sheet1": {
"0,0": "2023",
"1,0": "2023",
"2,0": "2024",
"0,1": "1",
"1,1": "2",
"2,1": "1",
"0,2": "10",
"1,2": "20",
"2,2": "30"
}
}
}
@@ -0,0 +1,63 @@
{
"name": "list_expand_down",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {
"0": { "v": "Name" },
"1": { "v": "" }
},
"1": {
"0": { "v": "" },
"1": { "v": "" }
}
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"type": "dataSource",
"dataSetName": "items",
"field": "name",
"expand": "down"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 1,
"custom": {
"type": "dataSource",
"dataSetName": "items",
"field": "qty",
"expand": "down"
}
}
]
},
"datasets": {
"items": [
{ "name": "Apple", "qty": 1 },
{ "name": "Banana", "qty": 2 }
]
},
"params": {},
"expect": {
"sheet1": {
"1,0": "Apple",
"2,0": "Banana",
"1,1": "1",
"2,1": "2"
}
}
}
@@ -0,0 +1,60 @@
{
"name": "multi_col_down_align",
"comment": "同行多列向下扩展:列数不同,按 max_len 对齐",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": { "0": { "v": "" }, "1": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"type": "dataSource",
"dataSetName": "items",
"field": "name",
"expand": "down"
}
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 1,
"custom": {
"type": "dataSource",
"dataSetName": "items",
"field": "qty",
"expand": "down"
}
}
]
},
"datasets": {
"items": [
{ "name": "A", "qty": 1 },
{ "name": "B", "qty": 2 },
{ "name": "C", "qty": 3 }
]
},
"params": {},
"expect": {
"sheet1": {
"1,0": "A",
"2,0": "B",
"3,0": "C",
"1,1": "1",
"2,1": "2",
"3,1": "3"
}
}
}
@@ -0,0 +1,32 @@
{
"name": "param_and_placeholder",
"snapshot": {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": { "0": { "v": "Hello #{userName}" }, "1": { "v": "" } }
}
}
}
},
"cells": {
"cells": [
{
"type": "parameter",
"sheet": "sheet1",
"row": 0,
"col": 1,
"custom": { "value": "#{dept}" }
}
]
},
"datasets": {},
"params": { "userName": "Alice", "dept": "Sales" },
"expect": {
"sheet1": {
"0,0": "Hello Alice",
"0,1": "Sales"
}
}
}
@@ -0,0 +1,45 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""解析 Excel 为 Univer 单元格网格数据"""
import io
from typing import Any, Dict, List
from openpyxl import load_workbook
def parse_excel_to_grid(file_content: bytes) -> Dict[str, Any]:
"""将 xlsx/xls 解析为设计器可写入的网格结构"""
wb = load_workbook(io.BytesIO(file_content), read_only=True, data_only=True)
ws = wb.active
if ws is None:
return {"rowsCount": 0, "colsCount": 0, "data": []}
rows_data: List[List[Dict[str, Any]]] = []
max_col = 0
for row in ws.iter_rows(values_only=True):
row_cells = []
for cell in row:
val = cell
if val is None:
row_cells.append({"v": ""})
else:
row_cells.append({"v": val})
if any(c.get("v") not in ("", None) for c in row_cells):
rows_data.append(row_cells)
max_col = max(max_col, len(row_cells))
# 去除尾部全空行已在上面处理;补齐列宽
for row in rows_data:
while len(row) < max_col:
row.append({"v": ""})
wb.close()
rows_count = len(rows_data)
cols_count = max_col
return {
"rowsCount": rows_count,
"colsCount": cols_count,
"data": rows_data,
}
@@ -0,0 +1,83 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""扩展后 mergeData 行偏移重算"""
from __future__ import annotations
import copy
from typing import Any, Dict, List, Tuple
def _int(v: Any, default: int = 0) -> int:
try:
return int(v)
except (TypeError, ValueError):
return default
def compute_row_insertions(
before: Dict[str, Any],
after: Dict[str, Any],
) -> Dict[str, List[Tuple[int, int]]]:
"""
对比扩展前后 cellData 行数,推断每个 sheet 在各行插入的额外行数。
返回 sheet_id -> [(anchor_row, rows_added), ...](按 anchor_row 升序)。
"""
insertions: Dict[str, List[Tuple[int, int]]] = {}
before_sheets = (before or {}).get("sheets") or {}
after_sheets = (after or {}).get("sheets") or {}
for sheet_id, after_sheet in after_sheets.items():
before_sheet = before_sheets.get(sheet_id) or {}
before_rows = sorted(int(k) for k in (before_sheet.get("cellData") or {}).keys())
after_rows = sorted(int(k) for k in (after_sheet.get("cellData") or {}).keys())
if len(after_rows) <= len(before_rows):
continue
added = len(after_rows) - len(before_rows)
# 默认在最后一个原数据行之后插入(legacy down band 常见模式)
anchor = before_rows[-1] if before_rows else 0
insertions.setdefault(sheet_id, []).append((anchor, added))
return insertions
def recalculate_merge_data(
snapshot: Dict[str, Any],
row_insertions: Dict[str, List[Tuple[int, int]]],
) -> Dict[str, Any]:
"""按行插入量下移 merge 区域(仅处理 startRow/endRow)。"""
if not row_insertions:
return snapshot
result = copy.deepcopy(snapshot)
sheets = result.get("sheets") or {}
for sheet_id, inserts in row_insertions.items():
sheet = sheets.get(sheet_id)
if not sheet:
continue
merge_list = sheet.get("mergeData") or []
if not merge_list:
continue
sorted_inserts = sorted(inserts, key=lambda x: x[0])
new_merges: List[Any] = []
for region in merge_list:
if not isinstance(region, dict):
new_merges.append(region)
continue
start_row = _int(region.get("startRow"), 0)
end_row = _int(region.get("endRow"), start_row)
shift = 0
for anchor, delta in sorted_inserts:
if start_row > anchor:
shift += delta
if shift:
region = {**region, "startRow": start_row + shift, "endRow": end_row + shift}
new_merges.append(region)
sheet["mergeData"] = new_merges
return result
def apply_merge_recalc_after_expand(
original_snapshot: Dict[str, Any],
expanded_snapshot: Dict[str, Any],
) -> Dict[str, Any]:
insertions = compute_row_insertions(original_snapshot, expanded_snapshot)
return recalculate_merge_data(expanded_snapshot, insertions)
@@ -0,0 +1,55 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""预览参数解析(对标 JNPF parameterData 子集)"""
from __future__ import annotations
from datetime import date, datetime
from typing import Any, Dict, Optional
def _today_str() -> str:
return date.today().isoformat()
def _now_str() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def build_system_params(
*,
user_id: Optional[str] = None,
user_name: Optional[str] = None,
dept_id: Optional[str] = None,
dept_name: Optional[str] = None,
tenant_id: Optional[str] = None,
) -> Dict[str, Any]:
"""系统变量,与 JNPF parameterData 常用键对齐。"""
params: Dict[str, Any] = {
"currentDate": _today_str(),
"currentTime": _now_str(),
"currentUserId": user_id or "",
"currentUserName": user_name or "",
"currentDeptId": dept_id or "",
"currentDeptName": dept_name or "",
"currentTenantId": tenant_id or "",
}
# 兼容 #{userName} / #{deptName} 简写
if user_name:
params.setdefault("userName", user_name)
if dept_name:
params.setdefault("deptName", dept_name)
return params
def merge_preview_params(
query_defaults: Optional[Dict[str, Any]] = None,
request_params: Optional[Dict[str, Any]] = None,
system_params: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""合并顺序:query 默认 → 系统变量 → 请求参数(请求优先)。"""
merged: Dict[str, Any] = {}
for src in (query_defaults or {}, system_params or {}, request_params or {}):
for k, v in src.items():
if v is not None and v != "":
merged[k] = v
return merged
@@ -0,0 +1,221 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""解析 JNPF 风格左/上父格(none / default / custom)。"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
CellKey = Tuple[str, int, int]
def col_letter_to_index(letters: str) -> int:
s = (letters or "A").upper()
n = 0
for ch in s:
if not ("A" <= ch <= "Z"):
continue
n = n * 26 + (ord(ch) - 64)
return max(0, n - 1)
def col_index_to_letter(col: int) -> str:
n = col
s = ""
while n >= 0:
s = chr(65 + (n % 26)) + s
n = n // 26 - 1
return s or "A"
def _int_coord(v: Any, default: int = 0) -> int:
try:
return int(v)
except (TypeError, ValueError):
return default
def cell_key(cell: Dict[str, Any]) -> CellKey:
return (
str(cell.get("sheet") or "sheet1"),
_int_coord(cell.get("row"), 0),
_int_coord(cell.get("col"), 0),
)
def _is_data_source(cell: Dict[str, Any]) -> bool:
return cell.get("type") == "dataSource"
def _index_data_sources(cells: List[Dict[str, Any]]) -> Tuple[Dict[CellKey, Dict[str, Any]], Dict[str, List[Dict[str, Any]]]]:
by_pos: Dict[CellKey, Dict[str, Any]] = {}
by_sheet: Dict[str, List[Dict[str, Any]]] = {}
for c in cells:
if not _is_data_source(c):
continue
k = cell_key(c)
by_pos[k] = c
by_sheet.setdefault(k[0], []).append(c)
return by_pos, by_sheet
def resolve_parent(
cell: Dict[str, Any],
*,
is_left: bool,
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
) -> Optional[Dict[str, Any]]:
"""返回父格 dataSource 元数据;none 为 Nonedefault 为最近左/上数据源格。"""
custom = cell.get("custom") or {}
ptype = (
custom.get("leftParentCellType") if is_left else custom.get("topParentCellType")
) or "default"
sheet, row, col = cell_key(cell)
if ptype == "none":
return None
if ptype == "custom":
if is_left:
letters = custom.get("leftParentCellCustomRowName") or "A"
row_num = custom.get("leftParentCellCustomColName")
else:
letters = custom.get("topParentCellCustomRowName") or "A"
row_num = custom.get("topParentCellCustomColName")
if row_num is None:
return None
try:
parent_row = int(row_num) - 1
except (TypeError, ValueError):
return None
parent_col = col_letter_to_index(str(letters))
return by_pos.get((sheet, parent_row, parent_col))
# default:同行向左 / 同列向上找最近 dataSource
candidates = by_sheet.get(sheet) or []
best: Optional[Dict[str, Any]] = None
if is_left:
for c in candidates:
cr, cc = _int_coord(c.get("row")), _int_coord(c.get("col"))
if cr == row and cc < col:
if best is None or _int_coord(best.get("col")) < cc:
best = c
else:
for c in candidates:
cr, cc = _int_coord(c.get("row")), _int_coord(c.get("col"))
if cc == col and cr < row:
if best is None or _int_coord(best.get("row")) < cr:
best = c
return best
def resolve_parents(
cell: Dict[str, Any],
by_pos: Dict[CellKey, Dict[str, Any]],
by_sheet: Dict[str, List[Dict[str, Any]]],
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
left = resolve_parent(cell, is_left=True, by_pos=by_pos, by_sheet=by_sheet)
top = resolve_parent(cell, is_left=False, by_pos=by_pos, by_sheet=by_sheet)
custom = cell.get("custom") or {}
ds_name = _dataset_name(cell)
# 汇总格且无扩展时,JNPF 在 none+none 时清除父格
poly = str(custom.get("polymerizationType") or "1")
if poly == "3" and custom.get("leftParentCellType") == "none" and custom.get("topParentCellType") == "none":
return None, None
if left and _dataset_name(left) != ds_name:
left = None
if top and _dataset_name(top) != ds_name:
top = None
return left, top
def _dataset_name(cell: Dict[str, Any]) -> str:
custom = cell.get("custom") or {}
name = str(
custom.get("dataSetName") or custom.get("dataSet") or custom.get("alias") or ""
)
if name:
return name
field = str(custom.get("field") or custom.get("bindField") or "")
if "." in field:
return field.split(".", 1)[0]
return ""
def filter_rows_by_parents(
rows: List[Any],
cell: Dict[str, Any],
*,
left_parent: Optional[Dict[str, Any]],
top_parent: Optional[Dict[str, Any]],
left_bind: Optional[List[Dict[str, Any]]],
top_bind: Optional[List[Dict[str, Any]]],
) -> List[Dict[str, Any]]:
"""对齐 JNPF DataUtils.fetchData:按父格 bindData 切片过滤。"""
data = [r if isinstance(r, dict) else {} for r in rows]
if not left_bind and not top_bind:
return data
left_rows = left_bind
top_rows = top_bind
if left_rows is None and top_rows is not None:
return top_rows
if top_rows is None and left_rows is not None:
return left_rows
if left_rows is None or top_rows is None:
return data
left_field = _bind_field(left_parent) if left_parent else ""
top_field = _bind_field(top_parent) if top_parent else ""
left_val = _first_field_value(left_rows, left_field) if left_rows else None
top_val = _first_field_value(top_rows, top_field) if top_rows else None
from_top: List[Dict[str, Any]] = []
for row in top_rows:
if left_field and _get_nested_value(row, left_field) == left_val:
from_top.append(row)
from_left: List[Dict[str, Any]] = []
for row in left_rows:
if top_field and _get_nested_value(row, top_field) == top_val:
from_left.append(row)
return from_top if len(from_top) <= len(from_left) else from_left
def _bind_field(parent: Optional[Dict[str, Any]]) -> str:
if not parent:
return ""
custom = parent.get("custom") or {}
field = custom.get("field") or custom.get("bindField") or ""
if "." in field:
return field.split(".", 1)[1]
return field
def resolve_field_path(field: str, dataset_alias: str = "") -> str:
"""JNPF 字段常为 alias.prop,行数据一般为扁平 prop 或嵌套 prop。"""
field = str(field or "")
alias = str(dataset_alias or "")
if alias and field.startswith(f"{alias}."):
return field[len(alias) + 1 :]
return field
def _get_nested_value(row: Dict[str, Any], field: str) -> Any:
if not field:
return None
if field in row:
return row[field]
parts = field.split(".")
cur: Any = row
for p in parts:
if isinstance(cur, dict) and p in cur:
cur = cur[p]
else:
return None
return cur
def _first_field_value(rows: List[Dict[str, Any]], field: str) -> Any:
if not rows or not field:
return None
return _get_nested_value(rows[0], field)
@@ -0,0 +1,149 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
数据源聚合(polymerizationType
1 / select — 列表
2 / group — 分组
3 / summary — 汇总(summaryType: sum|avg|max|min|count
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Any, Dict, List, Optional
from online_dev.report_manager.engine.parent_cells import _get_nested_value, resolve_field_path
@dataclass
class BindData:
value: Any
data_list: List[Dict[str, Any]]
def _field_name(cell: Dict[str, Any]) -> str:
custom = cell.get("custom") or {}
return str(custom.get("field") or custom.get("bindField") or "")
def _poly_type(cell: Dict[str, Any]) -> str:
custom = cell.get("custom") or {}
raw = custom.get("polymerizationType")
if raw is None:
return "1"
return str(raw)
def _summary_type(cell: Dict[str, Any]) -> str:
custom = cell.get("custom") or {}
return str(custom.get("summaryType") or "sum").lower()
def _group_type(cell: Dict[str, Any]) -> str:
custom = cell.get("custom") or {}
return str(custom.get("groupType") or "default")
def _dataset_name(cell: Dict[str, Any]) -> str:
custom = cell.get("custom") or {}
name = str(
custom.get("dataSetName") or custom.get("dataSet") or custom.get("alias") or ""
)
if name:
return name
field = str(custom.get("field") or custom.get("bindField") or "")
if "." in field:
return field.split(".", 1)[0]
return ""
def build_bind_list(cell: Dict[str, Any], rows: List[Dict[str, Any]]) -> List[BindData]:
poly = _poly_type(cell)
field = resolve_field_path(_field_name(cell), _dataset_name(cell))
prop = field.split(".")[-1] if "." in field else field
if poly == "3":
return [_summary_bind(cell, rows, field)]
if poly == "2":
return _group_bind(rows, prop, _group_type(cell))
return _list_bind(rows, field)
def _list_bind(rows: List[Dict[str, Any]], field: str) -> List[BindData]:
out: List[BindData] = []
for row in rows:
val = _get_nested_value(row, field)
out.append(BindData(value=val, data_list=[row]))
if not out:
out.append(BindData(value="", data_list=[{}]))
return out
def _group_bind(
rows: List[Dict[str, Any]], prop: str, group_type: str
) -> List[BindData]:
if group_type == "adjacent":
return _group_adjacent(rows, prop)
ordered: Dict[Any, List[Dict[str, Any]]] = {}
for row in rows:
key = _get_nested_value(row, prop)
if key is None:
key = ""
ordered.setdefault(key, []).append(row)
return [
BindData(value=k, data_list=ordered[k]) for k in ordered.keys()
]
def _group_adjacent(rows: List[Dict[str, Any]], prop: str) -> List[BindData]:
out: List[BindData] = []
bucket: List[Dict[str, Any]] = []
last_key: Any = object()
for row in rows:
key = _get_nested_value(row, prop)
if key is None:
key = ""
if bucket and key != last_key:
out.append(BindData(value=last_key, data_list=bucket))
bucket = []
bucket.append(row)
last_key = key
if bucket:
out.append(BindData(value=last_key, data_list=bucket))
if not out:
out.append(BindData(value="", data_list=[{}]))
return out
def _summary_bind(cell: Dict[str, Any], rows: List[Dict[str, Any]], field: str) -> BindData:
st = _summary_type(cell)
nums: List[Decimal] = []
for row in rows:
v = _get_nested_value(row, field)
try:
nums.append(Decimal(str(v)))
except Exception:
pass
if st == "count":
val: Any = len(rows)
elif st == "avg":
val = float(sum(nums) / len(nums)) if nums else 0
elif st == "max":
val = float(max(nums)) if nums else 0
elif st == "min":
val = float(min(nums)) if nums else 0
else:
val = float(sum(nums)) if nums else 0
return BindData(value=val, data_list=rows)
def expand_span(bind: BindData, cell: Dict[str, Any]) -> int:
"""该 bind 在向下/向右扩展时占用的行/列数。"""
poly = _poly_type(cell)
if poly == "3":
return 1
if poly == "2":
return 1
return max(1, len(bind.data_list))
@@ -0,0 +1,43 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""预览/导出非功能性护栏(阶段 G"""
from __future__ import annotations
from typing import Any, Dict, List
# 与 dataset_bridge.fetch_all max_rows 对齐
DEFAULT_MAX_DATASET_ROWS = 50000
WARN_DATASET_ROWS = 10000
WARN_SNAPSHOT_CELL_COUNT = 200_000
def estimate_snapshot_cell_count(snapshot: Dict[str, Any]) -> int:
total = 0
for sheet in (snapshot.get("sheets") or {}).values():
if not isinstance(sheet, dict):
continue
for row in (sheet.get("cellData") or {}).values():
if isinstance(row, dict):
total += len(row)
return total
def collect_preview_warnings(
*,
datasets: Dict[str, List[Any]],
snapshot: Dict[str, Any],
max_rows: int = DEFAULT_MAX_DATASET_ROWS,
warn_rows: int = WARN_DATASET_ROWS,
) -> List[str]:
"""返回 warning 码列表,供前端 i18n 映射。"""
warnings: List[str] = []
for alias, rows in (datasets or {}).items():
count = len(rows) if isinstance(rows, list) else 0
if count >= max_rows:
warnings.append(f"dataset_row_limit:{alias}")
elif count >= warn_rows:
warnings.append(f"dataset_row_warn:{alias}")
cell_count = estimate_snapshot_cell_count(snapshot or {})
if cell_count >= WARN_SNAPSHOT_CELL_COUNT:
warnings.append("snapshot_large")
return warnings
@@ -0,0 +1,91 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
预览 MVP:参数单元格替换(Phase 2)
完整 dataSource 扩展在 Phase 3 convert 引擎实现
"""
import copy
import re
from typing import Any, Dict, List
_PARAM_PATTERN = re.compile(r"#\{([^}]+)\}")
def _replace_params_in_value(value: Any, params: Dict[str, Any]) -> Any:
if not isinstance(value, str):
return value
def repl(m):
key = m.group(1).strip()
if key in params:
return str(params[key])
return m.group(0)
return _PARAM_PATTERN.sub(repl, value)
def apply_parameter_cells(
snapshot: Dict[str, Any],
cells_meta: Dict[str, Any],
params: Dict[str, Any],
) -> Dict[str, Any]:
"""将 parameter 类型绑定写入 snapshotMVP"""
result = copy.deepcopy(snapshot)
if not result or not cells_meta:
return result
cell_list = cells_meta.get("cells") or []
sheets = result.get("sheets") or {}
for cell in cell_list:
if cell.get("type") != "parameter":
continue
sheet_id = cell.get("sheet")
row = cell.get("row", 0)
col = cell.get("col", 0)
custom = cell.get("custom") or {}
text = custom.get("value") or custom.get("text") or ""
if isinstance(text, str):
text = _replace_params_in_value(text, params)
sheet = sheets.get(sheet_id)
if not sheet:
continue
cell_data = sheet.setdefault("cellData", {})
row_data = cell_data.setdefault(str(row), {})
cell_obj = row_data.setdefault(str(col), {})
cell_obj["v"] = text
if "custom" in cell_obj:
cell_obj["custom"] = {**cell_obj.get("custom", {}), "value": text}
return result
def apply_snapshot_placeholders(
snapshot: Dict[str, Any],
params: Dict[str, Any],
) -> Dict[str, Any]:
"""扫描 snapshot 所有单元格,将 v / custom 字符串中的 #{param} 替换为查询参数"""
if not snapshot or not params:
return snapshot or {}
result = copy.deepcopy(snapshot)
sheets = result.get("sheets") or {}
for sheet in sheets.values():
if not isinstance(sheet, dict):
continue
cell_data = sheet.get("cellData") or {}
for row in cell_data.values():
if not isinstance(row, dict):
continue
for cell in row.values():
if not isinstance(cell, dict):
continue
v = cell.get("v")
if isinstance(v, str) and "#{" in v:
cell["v"] = _replace_params_in_value(v, params)
custom = cell.get("custom")
if isinstance(custom, dict):
for key, val in list(custom.items()):
if isinstance(val, str) and "#{" in val:
custom[key] = _replace_params_in_value(val, params)
cell["custom"] = custom
result["sheets"] = sheets
return result
@@ -0,0 +1,47 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""数据集排序规则(对齐 JNPF sortList"""
from typing import Any, Dict, List, Optional
def _field_name_from_vmodel(vmodel: str) -> str:
if not vmodel:
return ""
parts = vmodel.split(".", 1)
return parts[1] if len(parts) == 2 else vmodel
def get_sort_rules_for_alias(sort_list: List[Any], alias: str) -> List[Dict[str, Any]]:
rules: List[Dict[str, Any]] = []
for sheet_cfg in sort_list or []:
if not isinstance(sheet_cfg, dict):
continue
for item in sheet_cfg.get("sortList") or []:
if not isinstance(item, dict):
continue
vmodel = item.get("vModel") or item.get("field") or ""
ds_prefix = vmodel.split(".")[0] if "." in vmodel else ""
if ds_prefix == alias or vmodel.startswith(f"{alias}."):
rules.append(item)
return rules
def apply_sort_to_rows(
rows: List[Dict[str, Any]],
sort_rules: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
if not rows or not sort_rules:
return rows
result = list(rows)
for rule in sort_rules:
field = _field_name_from_vmodel(rule.get("vModel") or rule.get("field") or "")
if not field:
continue
reverse = (rule.get("type") or "asc").lower() == "desc"
def key_fn(row: Dict[str, Any], f: str = field) -> Any:
val = row.get(f)
return (val is None, val)
result.sort(key=key_fn, reverse=reverse)
return result
@@ -0,0 +1,33 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.chart_data import build_chart_data
def test_bar_chart_from_float_echarts():
cells = {
"floatEcharts": {
"draw1": {
"drawingId": "draw1",
"echartType": "bar",
"option": {
"classifyNameField": "sales.month",
"seriesNameField": "sales.region",
"seriesDataField": "sales.amount",
"summaryType": "sum",
},
},
},
}
datasets = {
"sales": [
{"month": "Jan", "region": "A", "amount": 10},
{"month": "Jan", "region": "B", "amount": 20},
{"month": "Feb", "region": "A", "amount": 15},
],
}
chart_data = build_chart_data(cells, datasets)
assert len(chart_data) == 1
field = chart_data[0]["field"]
assert "Jan" in field["classifyNameField"]
assert field["seriesNameField"]
assert field["seriesDataField"]
@@ -0,0 +1,29 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.convert import transform
def test_qrcode_param_replace():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {
"0": {
"v": "placeholder",
"custom": {
"type": "qrCode",
"field": "#{orderNo}",
"qrCodeOption": {"type": "static"},
},
}
}
},
}
}
}
filled = transform(snapshot, {"cells": []}, {}, {"orderNo": "ORD-001"})
cell = filled["sheets"]["sheet1"]["cellData"]["0"]["0"]
assert cell["v"] == "ORD-001"
assert cell["custom"]["field"] == "ORD-001"
@@ -0,0 +1,148 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.column_layout import apply_column_layout, parse_cell_range
def test_parse_range():
assert parse_cell_range("A2:D10") == (1, 9, 0, 3)
def test_col_split_two_columns():
snapshot = {
"sheetOrder": ["sheet1"],
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": {"0": {"v": "a"}},
"2": {"0": {"v": "b"}},
"3": {"0": {"v": "c"}},
"4": {"0": {"v": "d"}},
},
}
},
}
layout = [
{
"sheet": "sheet1",
"columnList": {
"columnState": True,
"columnStyle": "col",
"columnType": "2",
"rowCount": 2,
"columnData": "A2:A5",
},
}
]
out = apply_column_layout(snapshot, layout)
cells = out["sheets"]["sheet1"]["cellData"]
assert cells["1"]["0"]["v"] == "a"
assert cells["2"]["0"]["v"] == "b"
assert cells["1"]["1"]["v"] == "c"
assert cells["2"]["1"]["v"] == "d"
def test_row_split_two_rows():
"""A2:C2 三列横向,分栏成 2 行块:上行 A,B 下行 C"""
snapshot = {
"sheetOrder": ["sheet1"],
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": {
"0": {"v": "A"},
"1": {"v": "B"},
"2": {"v": "C"},
}
},
}
},
}
layout = [
{
"sheet": "sheet1",
"columnList": {
"columnState": True,
"columnStyle": "row",
"columnType": "2",
"colCount": 2,
"columnData": "A2:C2",
},
}
]
out = apply_column_layout(snapshot, layout)
cells = out["sheets"]["sheet1"]["cellData"]
assert cells["1"]["0"]["v"] == "A"
assert cells["1"]["1"]["v"] == "B"
assert cells["2"]["2"]["v"] == "C"
def test_col_split_type1_max_col():
"""5 行数据,超过 2 行分列 -> 3 栏"""
snapshot = {
"sheetOrder": ["sheet1"],
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
str(i): {"0": {"v": str(i)}} for i in range(1, 6)
},
}
},
}
layout = [
{
"sheet": "sheet1",
"columnList": {
"columnState": True,
"columnStyle": "col",
"columnType": "1",
"maxCol": 2,
"columnData": "A1:A5",
},
}
]
out = apply_column_layout(snapshot, layout)
cells = out["sheets"]["sheet1"]["cellData"]
assert cells["1"]["0"]["v"] == "1"
assert cells["2"]["0"]["v"] == "2"
assert cells["1"]["1"]["v"] == "3"
assert cells["2"]["1"]["v"] == "4"
assert cells["1"]["2"]["v"] == "5"
def test_col_split_fill_empty_rows():
"""3 行分 2 栏,第二栏仅 1 行数据时补空行"""
snapshot = {
"sheetOrder": ["sheet1"],
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": {"0": {"v": "a"}},
"2": {"0": {"v": "b"}},
"3": {"0": {"v": "c"}},
},
}
},
}
layout = [
{
"sheet": "sheet1",
"columnList": {
"columnState": True,
"columnStyle": "col",
"columnType": "2",
"rowCount": 2,
"columnData": "A1:A3",
"fillEmptyRows": True,
},
}
]
out = apply_column_layout(snapshot, layout)
cells = out["sheets"]["sheet1"]["cellData"]
assert cells["1"]["0"]["v"] == "a"
assert cells["2"]["0"]["v"] == "b"
assert cells["1"]["1"]["v"] == "c"
assert cells["2"]["1"]["v"] == ""
@@ -0,0 +1,119 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""convert 引擎单元测试(可直接 python -m 运行)"""
from online_dev.report_manager.engine.convert import transform
def test_parameter_and_list_down():
snapshot = {
"sheetOrder": ["sheet1"],
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {"0": {"0": {"v": "Title"}}},
},
},
}
cells = {
"cells": [
{
"type": "parameter",
"sheet": "sheet1",
"row": 0,
"col": 1,
"custom": {"value": "#{userName}"},
},
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"dataSetName": "users",
"field": "name",
"expand": "down",
},
},
],
}
datasets = {
"users": [{"name": "Alice"}, {"name": "Bob"}],
}
result = transform(snapshot, cells, datasets, {"userName": "Admin"})
cd = result["sheets"]["sheet1"]["cellData"]
assert cd["0"]["1"]["v"] == "Admin"
assert cd["1"]["0"]["v"] == "Alice"
assert cd["2"]["0"]["v"] == "Bob"
print("ok")
def test_list_right_expand():
snapshot = {
"sheetOrder": ["sheet1"],
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {"0": {"0": {"v": "H"}}},
},
},
}
cells = {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 0,
"col": 1,
"custom": {
"dataSetName": "items",
"field": "name",
"expand": "right",
},
},
],
}
datasets = {"items": [{"name": "A"}, {"name": "B"}]}
result = transform(snapshot, cells, datasets, {})
cd = result["sheets"]["sheet1"]["cellData"]
assert cd["0"]["1"]["v"] == "A"
assert cd["0"]["2"]["v"] == "B"
print("right ok")
def test_fill_empty_rows_after_list_down():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {"1": {"0": {"v": ""}}},
}
}
}
cells = {
"cells": [
{
"type": "dataSource",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {
"dataSetName": "items",
"field": "name",
"expand": "down",
"fillEmptyRows": True,
"fillEmptyNum": 1,
},
}
]
}
result = transform(
snapshot, cells, {"items": [{"name": "X"}]}, {}
)
cd = result["sheets"]["sheet1"]["cellData"]
assert cd["1"]["0"]["v"] == "X"
assert cd["2"]["0"]["v"] == ""
if __name__ == "__main__":
test_parameter_and_list_down()
test_list_right_expand()
@@ -0,0 +1,84 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.dataset_transform import (
apply_convert_rules,
apply_field_mapping,
transform_dataset_rows,
)
def test_field_mapping_rename():
rows = [{"product_code": "A", "qty": 1}]
out = apply_field_mapping(rows, {"product_code": "product"})
assert out[0]["product"] == "A"
assert "product_code" not in out[0]
def test_convert_select():
rows = [{"status": 1}]
rules = [
{
"field": "order.status",
"type": "select",
"config": {
"options": [
{"id": 1, "fullName": "启用"},
{"id": 2, "fullName": "停用"},
]
},
}
]
out = apply_convert_rules(rows, rules, alias="order")
assert out[0]["status"] == "启用"
def test_transform_chain():
rows = [{"code": "X", "state": "1"}]
out = transform_dataset_rows(
rows,
field_mapping={"code": "product"},
dataset_convert=[
{
"field": "state",
"type": "select",
"config": {"options": [{"id": "1", "fullName": "OK"}]},
}
],
)
assert out[0]["product"] == "X"
assert out[0]["state"] == "OK"
def test_convert_select_respects_alias():
rows = [{"status": 1}]
rules = [
{
"field": "order.status",
"type": "select",
"config": {"options": [{"id": 1, "fullName": "启用"}]},
}
]
out = apply_convert_rules(rows, rules, alias="other")
assert out[0]["status"] == 1
def test_convert_user_inline():
from online_dev.report_manager.engine.convert_lookup import ConvertLookupCache
rows = [{"ownerId": "u1"}]
cache = ConvertLookupCache()
out = apply_convert_rules(
rows,
[{"field": "ownerId", "type": "user", "config": {"names": {"u1": "张三"}}}],
lookup=cache,
)
assert out[0]["ownerId"] == "张三"
if __name__ == "__main__":
test_field_mapping_rename()
test_convert_select()
test_convert_user_inline()
test_convert_select_respects_alias()
test_transform_chain()
print("ok")
@@ -0,0 +1,248 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import base64
import importlib.util
from online_dev.report_manager.engine.export_excel import snapshot_to_xlsx_bytes
_TINY_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
_TINY_PNG_B64 = (
"data:image/png;base64,"
+ base64.b64encode(_TINY_PNG).decode("ascii")
)
def test_export_produces_xlsx():
snapshot = {
"styles": [{"fs": 14, "bl": 1, "bg": {"rgb": "#FFFF00"}, "ht": 2}],
"sheetOrder": ["s1"],
"sheets": {
"s1": {
"name": "Report",
"cellData": {
"0": {
"0": {"v": "Title", "s": 0},
"1": {"v": "ignored"},
},
"1": {"0": {"v": 100, "t": 2}},
},
"mergeData": [
{"startRow": 0, "endRow": 0, "startColumn": 0, "endColumn": 1},
],
"rowData": {"0": {"h": 30}},
"columnData": {"0": {"w": 140}},
}
},
}
raw = snapshot_to_xlsx_bytes(snapshot, watermark_text="机密")
assert raw[:2] == b"PK"
assert len(raw) > 200
if importlib.util.find_spec("openpyxl") is None:
return
import io
from openpyxl import load_workbook
wb = load_workbook(io.BytesIO(raw))
ws = wb["Report"]
assert ws["A1"].value == "Title"
assert ws["A2"].value == 100
assert ws["A1"].font.bold is True
merged = list(ws.merged_cells.ranges)
assert len(merged) == 1
assert str(merged[0]) == "A1:B1"
assert ws.oddHeader.center.text == "机密"
def test_export_conditional_formatting_and_images():
if importlib.util.find_spec("openpyxl") is None:
return
if importlib.util.find_spec("PIL") is None:
return
import io
from openpyxl import load_workbook
snapshot = {
"sheetOrder": ["s1"],
"resources": [
{
"name": "SHEET_CONDITIONAL_FORMATTING_PLUGIN",
"data": (
'{"s1":[{"ranges":[{"startRow":0,"endRow":2,"startColumn":0,"endColumn":0}],'
'"rule":{"type":"highlight","operator":"greaterThan","value":50,'
'"style":{"bg":{"rgb":"#FFCCCC"}}},"stopIfTrue":false},'
'{"ranges":[{"startRow":0,"endRow":2,"startColumn":1,"endColumn":1}],'
'"rule":{"type":"colorScale","config":['
'{"value":{"type":"min"},"color":"#FF0000"},'
'{"value":{"type":"max"},"color":"#00FF00"}'
']},"stopIfTrue":false}]}'
),
},
{
"name": "SHEET_DRAWING_PLUGIN",
"data": (
'{"s1":{"order":["img1"],"data":{"img1":{'
'"source":"'
+ _TINY_PNG_B64
+ '","imageSourceType":"BASE64",'
'"sheetTransform":{"from":{"row":0,"column":2},'
'"to":{"row":4,"column":4}}}}}}'
),
},
],
"sheets": {
"s1": {
"name": "CF",
"cellData": {
"0": {
"0": {"v": 80, "t": 2},
"1": {"v": 20, "t": 2},
"3": {
"v": "",
"p": {
"drawings": {
"cellImg": {
"source": _TINY_PNG_B64,
"imageSourceType": "BASE64",
}
}
},
},
}
},
}
},
}
raw = snapshot_to_xlsx_bytes(snapshot)
wb = load_workbook(io.BytesIO(raw))
ws = wb["CF"]
assert len(ws.conditional_formatting._cf_rules) >= 2
assert len(ws._images) >= 2
def test_export_hyperlinks_and_advanced_conditional_formatting():
if importlib.util.find_spec("openpyxl") is None:
return
import io
import json
from openpyxl import load_workbook
cf_entries = [
{
"ranges": [{"startRow": 0, "endRow": 5, "startColumn": 0, "endColumn": 0}],
"rule": {
"type": "highlight",
"subType": "top10",
"value": 3,
"style": {"bg": {"rgb": "#FFCCCC"}},
},
"stopIfTrue": False,
},
{
"ranges": [{"startRow": 0, "endRow": 5, "startColumn": 1, "endColumn": 1}],
"rule": {
"type": "highlight",
"subType": "aboveAverage",
"operator": "greaterThan",
"style": {"bg": {"rgb": "#CCCCFF"}},
},
"stopIfTrue": False,
},
{
"ranges": [{"startRow": 0, "endRow": 5, "startColumn": 2, "endColumn": 2}],
"rule": {
"type": "highlight",
"subType": "timePeriod",
"operator": "today",
"style": {"bg": {"rgb": "#CCFFCC"}},
},
"stopIfTrue": False,
},
]
snapshot = {
"sheetOrder": ["s1", "s2"],
"resources": [
{
"name": "SHEET_DEFINED_NAME_PLUGIN",
"data": json.dumps(
{
"range1": {
"name": "MyRange",
"formulaOrRefString": "=s2!$A$1",
}
}
),
},
{
"name": "SHEET_CONDITIONAL_FORMATTING_PLUGIN",
"data": json.dumps({"s1": cf_entries}),
},
],
"sheets": {
"s1": {
"name": "Links",
"cellData": {
"0": {
"0": {
"p": {
"body": {
"dataStream": "Open Example",
"customRanges": [
{
"properties": {
"url": "https://example.com",
}
}
],
}
}
},
"1": {
"p": {
"body": {
"dataStream": "Go Sheet2",
"customRanges": [
{
"properties": {
"url": "#gid=s2&range=A1",
}
}
],
}
}
},
}
},
},
"s2": {
"name": "Target",
"cellData": {"0": {"0": {"v": "Target Cell"}}},
},
},
}
raw = snapshot_to_xlsx_bytes(snapshot)
wb = load_workbook(io.BytesIO(raw))
ws = wb["Links"]
assert ws["A1"].hyperlink is not None
assert ws["A1"].hyperlink.target == "https://example.com"
assert ws["B1"].hyperlink is not None
assert ws["B1"].hyperlink.location == "'Target'!A1"
assert len(ws.conditional_formatting._cf_rules) >= 3
if __name__ == "__main__":
test_export_produces_xlsx()
test_export_conditional_formatting_and_images()
test_export_hyperlinks_and_advanced_conditional_formatting()
print("ok")
@@ -0,0 +1,22 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.expression_eval import detect_expression_cycles
def test_no_cycle():
cells = {
"cells": [
{"type": "expression", "sheet": "s1", "row": 0, "col": 1, "custom": {"field": "=A1+1"}},
]
}
assert detect_expression_cycles(cells) == []
def test_cycle_a_b():
cells = {
"cells": [
{"type": "expression", "sheet": "s1", "row": 0, "col": 0, "custom": {"field": "=B1+1"}},
{"type": "expression", "sheet": "s1", "row": 0, "col": 1, "custom": {"field": "=A1+1"}},
]
}
assert detect_expression_cycles(cells) == ["expression_cycle"]
@@ -0,0 +1,183 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.convert import transform
def test_sum_expression():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {"0": {"0": {"v": ""}}},
}
}
}
cells = {
"cells": [
{
"type": "expression",
"sheet": "sheet1",
"row": 0,
"col": 0,
"custom": {"field": "=sum(sales.amount)+10"},
}
]
}
datasets = {
"sales": [{"amount": 100}, {"amount": 200}],
}
filled = transform(snapshot, cells, datasets, {})
assert filled["sheets"]["sheet1"]["cellData"]["0"]["0"]["v"] == "310"
def test_expression_with_param():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {"1": {"1": {"v": ""}}},
}
}
}
cells = {
"cells": [
{
"type": "expression",
"sheet": "sheet1",
"row": 1,
"col": 1,
"custom": {"field": "=#{bonus}+sum(items.qty)"},
}
]
}
datasets = {"items": [{"qty": 5}, {"qty": 3}]}
filled = transform(snapshot, cells, datasets, {"bonus": 2})
assert filled["sheets"]["sheet1"]["cellData"]["1"]["1"]["v"] == "10"
def test_cell_ref_addition():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {"0": {"v": "10"}, "1": {"v": "20"}},
"2": {"0": {"v": ""}},
},
}
}
}
cells = {
"cells": [
{
"type": "expression",
"sheet": "sheet1",
"row": 2,
"col": 0,
"custom": {"field": "=A1+B2"},
}
]
}
filled = transform(snapshot, cells, {}, {})
assert filled["sheets"]["sheet1"]["cellData"]["2"]["0"]["v"] == "30"
def test_sum_cell_range():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {"0": {"v": 5}, "1": {"v": 15}},
"1": {"0": {"v": ""}},
},
}
}
}
cells = {
"cells": [
{
"type": "expression",
"sheet": "sheet1",
"row": 1,
"col": 0,
"custom": {"field": "=sum(A1:B1)+1"},
}
]
}
filled = transform(snapshot, cells, {}, {})
assert filled["sheets"]["sheet1"]["cellData"]["1"]["0"]["v"] == "21"
def test_expression_chain_b1_c1():
"""B1=A1+1, C1=B1+1:表达式间依赖需拓扑/多轮求值"""
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {
"0": {"v": "100"},
"1": {"v": ""},
"2": {"v": ""},
}
},
}
}
}
cells = {
"cells": [
{
"type": "expression",
"sheet": "sheet1",
"row": 0,
"col": 1,
"custom": {"field": "=A1+1"},
},
{
"type": "expression",
"sheet": "sheet1",
"row": 0,
"col": 2,
"custom": {"field": "=B1+1"},
},
]
}
filled = transform(snapshot, cells, {}, {})
assert filled["sheets"]["sheet1"]["cellData"]["0"]["1"]["v"] == "101"
assert filled["sheets"]["sheet1"]["cellData"]["0"]["2"]["v"] == "102"
def test_expression_chain_reverse_meta_order():
"""cells 元数据顺序为 C1 先于 B1 时仍应正确求值"""
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {"0": {"v": 10}, "1": {"v": ""}, "2": {"v": ""}},
},
}
}
}
cells = {
"cells": [
{
"type": "expression",
"sheet": "sheet1",
"row": 0,
"col": 2,
"custom": {"formula": "=B1*2"},
},
{
"type": "expression",
"sheet": "sheet1",
"row": 0,
"col": 1,
"custom": {"formula": "=A1+5"},
},
]
}
filled = transform(snapshot, cells, {}, {})
assert filled["sheets"]["sheet1"]["cellData"]["0"]["1"]["v"] == "15"
assert filled["sheets"]["sheet1"]["cellData"]["0"]["2"]["v"] == "30"
@@ -0,0 +1,97 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Golden Test:用 JSON fixture 回归报表 transform 流水线关键输出。
后续可追加 JNPF 导出的样例 fixture 做对比。
"""
from __future__ import annotations
import json
import math
from pathlib import Path
from typing import Any, Dict, List
from online_dev.report_manager.engine.convert import transform
from online_dev.report_manager.engine.dataset_transform import transform_dataset_rows
_FIXTURES_DIR = Path(__file__).parent / "fixtures"
def _load_fixture(name: str) -> dict:
path = _FIXTURES_DIR / name
with open(path, encoding="utf-8") as f:
return json.load(f)
def _cell_v(snapshot: dict, sheet_id: str, row: int, col: int) -> str:
sheet = (snapshot.get("sheets") or {}).get(sheet_id) or {}
cell = (sheet.get("cellData") or {}).get(str(row), {}).get(str(col), {})
v = cell.get("v")
return "" if v is None else str(v)
def _prepare_datasets(fixture: dict) -> Dict[str, List[Any]]:
raw = fixture.get("datasets") or {}
field_mapping_root = fixture.get("field_mapping") or {}
version_convert = fixture.get("convert_config")
out: Dict[str, List[Any]] = {}
for alias, rows in raw.items():
mapping = field_mapping_root.get(alias) if isinstance(field_mapping_root, dict) else field_mapping_root
out[alias] = transform_dataset_rows(
rows,
field_mapping=mapping,
version_convert=version_convert,
alias=alias,
)
return out
def _values_match(expected: Any, actual: str, *, tolerance: float = 1e-6) -> bool:
if expected == actual:
return True
try:
exp_f = float(expected)
act_f = float(actual)
return math.isclose(exp_f, act_f, rel_tol=tolerance, abs_tol=tolerance)
except (TypeError, ValueError):
return False
def _run_golden_fixture(fixture: dict) -> None:
datasets = _prepare_datasets(fixture)
out = transform(
fixture["snapshot"],
fixture.get("cells") or {},
datasets,
fixture.get("params") or {},
column_list=fixture.get("column_list"),
fence_list=fixture.get("fence_list"),
)
failures: List[str] = []
for sheet_id, cells in (fixture.get("expect") or {}).items():
for addr, expected in cells.items():
row_s, col_s = addr.split(",", 1)
actual = _cell_v(out, sheet_id, int(row_s), int(col_s))
exp_str = "" if expected is None else str(expected)
if not _values_match(exp_str, actual):
failures.append(
f" {sheet_id}!{addr}: expected {exp_str!r}, got {actual!r}"
)
if failures:
name = fixture.get("name") or "unnamed"
msg = f"{name} failed ({len(failures)} cell(s)):\n" + "\n".join(failures)
raise AssertionError(msg)
def test_golden_list_down():
_run_golden_fixture(_load_fixture("golden_list_down.json"))
def test_all_golden_fixtures():
for path in sorted(_FIXTURES_DIR.glob("golden_*.json")):
_run_golden_fixture(_load_fixture(path.name))
if __name__ == "__main__":
test_all_golden_fixtures()
print(f"ok ({len(list(_FIXTURES_DIR.glob('golden_*.json')))} fixtures)")
@@ -0,0 +1,32 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.parameter_resolver import (
build_system_params,
merge_preview_params,
)
def test_build_system_params():
p = build_system_params(user_name="Alice", dept_name="Sales")
assert p["currentUserName"] == "Alice"
assert p["userName"] == "Alice"
assert p["deptName"] == "Sales"
assert p["currentDate"]
def test_merge_preview_params_priority():
merged = merge_preview_params(
{"a": 1, "b": 2},
{"b": 99, "c": 3},
{"c": 0, "d": 4},
)
assert merged["a"] == 1
assert merged["b"] == 99
assert merged["c"] == 3
assert merged["d"] == 4
if __name__ == "__main__":
test_build_system_params()
test_merge_preview_params_priority()
print("ok")
@@ -0,0 +1,18 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.convert import transform
def test_snapshot_placeholder_in_text_cell():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {"0": {"v": "Hello #{userName}"}},
},
}
}
}
filled = transform(snapshot, {"cells": []}, {}, {"userName": "Alice"})
assert filled["sheets"]["sheet1"]["cellData"]["0"]["0"]["v"] == "Hello Alice"
@@ -0,0 +1,27 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.polymerize import build_bind_list
def test_group_bind():
cell = {
"custom": {
"field": "category",
"polymerizationType": "2",
"groupType": "default",
}
}
rows = [
{"category": "A", "x": 1},
{"category": "B", "x": 2},
{"category": "A", "x": 3},
]
binds = build_bind_list(cell, rows)
assert len(binds) == 2
assert binds[0].value == "A"
assert len(binds[0].data_list) == 2
if __name__ == "__main__":
test_group_bind()
print("ok")
@@ -0,0 +1,59 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.preview_guard import (
collect_preview_warnings,
estimate_snapshot_cell_count,
WARN_SNAPSHOT_CELL_COUNT,
)
def test_estimate_snapshot_cell_count():
snapshot = {
"sheets": {
"s1": {
"cellData": {
"0": {"0": {"v": 1}, "1": {"v": 2}},
"1": {"0": {"v": 3}},
}
}
}
}
assert estimate_snapshot_cell_count(snapshot) == 3
def test_collect_dataset_row_warn():
warnings = collect_preview_warnings(
datasets={"sales": [{"id": i} for i in range(10001)]},
snapshot={},
)
assert "dataset_row_warn:sales" in warnings
assert "snapshot_large" not in warnings
def test_collect_dataset_row_limit():
warnings = collect_preview_warnings(
datasets={"sales": [{"id": i} for i in range(50000)]},
snapshot={},
)
assert "dataset_row_limit:sales" in warnings
def test_collect_snapshot_large():
cell_data = {"0": {str(c): {"v": c} for c in range(1000)}}
snapshot = {
"sheets": {
f"s{i}": {"cellData": cell_data}
for i in range(201)
}
}
assert estimate_snapshot_cell_count(snapshot) >= WARN_SNAPSHOT_CELL_COUNT
warnings = collect_preview_warnings(datasets={}, snapshot=snapshot)
assert "snapshot_large" in warnings
if __name__ == "__main__":
test_estimate_snapshot_cell_count()
test_collect_dataset_row_warn()
test_collect_dataset_row_limit()
test_collect_snapshot_large()
print("ok")
@@ -0,0 +1,10 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.sort_apply import apply_sort_to_rows
def test_apply_sort_desc():
rows = [{"n": 3}, {"n": 1}, {"n": 2}]
rules = [{"vModel": "ds.n", "type": "desc"}]
out = apply_sort_to_rows(rows, rules)
assert [r["n"] for r in out] == [3, 2, 1]
@@ -0,0 +1,305 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from online_dev.report_manager.engine.convert import transform
def test_summary_moves_below_list():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {"0": {"v": "姓名"}, "1": {"v": "年龄"}},
"1": {"0": {"v": ""}, "1": {"v": ""}},
"2": {"0": {"v": "合计"}, "1": {"v": ""}},
},
}
}
}
cells = {
"cells": [
{
"col": "0",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "name",
"dataSetName": "test",
"polymerizationType": "1",
"expand": "down",
},
},
{
"col": "1",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "age",
"dataSetName": "test",
"polymerizationType": "1",
"expand": "down",
},
},
{
"col": "1",
"row": "2",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "age",
"dataSetName": "test",
"polymerizationType": "3",
"summaryType": "max",
"leftParentCellType": "none",
"topParentCellType": "none",
},
},
]
}
datasets = {
"test": [
{"name": "A", "age": 20},
{"name": "B", "age": 35},
{"name": "C", "age": 28},
],
}
filled = transform(snapshot, cells, datasets, {})
cell_data = filled["sheets"]["sheet1"]["cellData"]
assert cell_data["1"]["1"]["v"] == 20
assert cell_data["2"]["1"]["v"] == 35
assert cell_data["3"]["1"]["v"] == 28
assert cell_data["4"]["1"]["v"] == 35
assert cell_data["4"]["0"]["v"] == "合计"
def test_summary_count_with_default_parents():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"0": {"0": {"v": "姓名"}, "1": {"v": ""}},
"1": {"0": {"v": ""}, "1": {"v": ""}},
"2": {"0": {"v": "计数"}, "1": {"v": ""}},
},
}
}
}
cells = {
"cells": [
{
"col": "0",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "name",
"dataSetName": "test",
"polymerizationType": "1",
"expand": "down",
"leftParentCellType": "default",
"topParentCellType": "default",
},
},
{
"col": "1",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "gender",
"dataSetName": "test",
"polymerizationType": "1",
"expand": "down",
"leftParentCellType": "default",
"topParentCellType": "default",
},
},
{
"col": "1",
"row": "2",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "gender",
"dataSetName": "test",
"polymerizationType": "3",
"summaryType": "count",
"leftParentCellType": "default",
"topParentCellType": "default",
},
},
]
}
datasets = {
"test": [
{"name": "A", "gender": 2},
{"name": "B", "gender": 2},
{"name": "C", "gender": 1},
{"name": "D", "gender": 2},
],
}
filled = transform(snapshot, cells, datasets, {})
cell_data = filled["sheets"]["sheet1"]["cellData"]
assert cell_data["5"]["1"]["v"] == 4
def test_group_column_merge():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {"1": {"0": {"v": ""}, "1": {"v": ""}}},
}
}
}
cells = {
"cells": [
{
"col": "0",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "gender",
"dataSetName": "test",
"polymerizationType": "2",
"groupType": "default",
"expand": "down",
"leftParentCellType": "none",
"topParentCellType": "none",
},
},
{
"col": "1",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "name",
"dataSetName": "test",
"polymerizationType": "1",
"expand": "down",
"leftParentCellType": "default",
"topParentCellType": "default",
},
},
]
}
datasets = {
"test": [
{"gender": 0, "name": "A"},
{"gender": 0, "name": "B"},
{"gender": 1, "name": "C"},
{"gender": 1, "name": "D"},
],
}
filled = transform(snapshot, cells, datasets, {})
sheet = filled["sheets"]["sheet1"]
merges = sheet.get("mergeData") or []
assert {"startRow": 1, "endRow": 2, "startColumn": 0, "endColumn": 0} in merges
assert {"startRow": 3, "endRow": 4, "startColumn": 0, "endColumn": 0} in merges
assert sheet["cellData"]["1"]["0"]["v"] in (0, "0")
assert sheet["cellData"]["3"]["0"]["v"] in (1, "1")
def test_group_column_no_merge_when_disabled():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {"1": {"0": {"v": ""}, "1": {"v": ""}}},
}
}
}
cells = {
"cells": [
{
"col": "0",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "gender",
"dataSetName": "test",
"polymerizationType": "2",
"groupType": "default",
"mergeCell": False,
"expand": "down",
"leftParentCellType": "none",
"topParentCellType": "none",
},
},
{
"col": "1",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "name",
"dataSetName": "test",
"polymerizationType": "1",
"expand": "down",
"leftParentCellType": "default",
"topParentCellType": "default",
},
},
]
}
datasets = {
"test": [
{"gender": 0, "name": "A"},
{"gender": 0, "name": "B"},
{"gender": 1, "name": "C"},
],
}
filled = transform(snapshot, cells, datasets, {})
merges = filled["sheets"]["sheet1"].get("mergeData") or []
group_merges = [m for m in merges if m.get("startColumn") == 0]
assert group_merges == []
def test_data_source_style_copied_to_expanded_rows():
snapshot = {
"sheets": {
"sheet1": {
"id": "sheet1",
"cellData": {
"1": {"0": {"v": "", "s": "T1"}},
},
}
}
}
cells = {
"cells": [
{
"col": "0",
"row": "1",
"sheet": "sheet1",
"type": "dataSource",
"custom": {
"field": "name",
"dataSetName": "test",
"polymerizationType": "1",
"expand": "down",
},
}
]
}
datasets = {"test": [{"name": "A"}, {"name": "B"}, {"name": "C"}]}
filled = transform(snapshot, cells, datasets, {})
cd = filled["sheets"]["sheet1"]["cellData"]
assert cd["1"]["0"].get("s") == "T1"
assert cd["2"]["0"].get("s") == "T1"
assert cd["3"]["0"].get("s") == "T1"
if __name__ == "__main__":
test_summary_moves_below_list()
test_summary_count_with_default_parents()
test_group_column_merge()
test_group_column_no_merge_when_disabled()
test_data_source_style_copied_to_expanded_rows()
print("ok")
@@ -0,0 +1,36 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from datetime import datetime
from online_dev.report_manager.engine.watermark import (
build_watermark_payload,
resolve_watermark_config,
)
def test_resolve_show_time():
fixed = datetime(2026, 5, 22, 15, 30, 0)
cfg = resolve_watermark_config(
{"content": "机密", "showTime": True, "timeFormat": "yyyy-MM-dd"},
now=fixed,
)
assert cfg["content"] == "机密 2026-05-22"
def test_build_payload_disabled():
payload = build_watermark_payload(False, {"content": "x"})
assert payload["show"] is False
assert payload["config"] == {}
def test_build_payload_enabled():
payload = build_watermark_payload(True, {"content": "ZQ"}, template_name="报表A")
assert payload["show"] is True
assert payload["config"]["content"] == "ZQ"
if __name__ == "__main__":
test_resolve_show_time()
test_build_payload_disabled()
test_build_payload_enabled()
print("ok")
@@ -0,0 +1,79 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""报表水印配置解析(对齐 JNPF preview 行为)"""
from __future__ import annotations
import copy
from datetime import datetime
from typing import Any, Dict, Optional
DEFAULT_WATERMARK_CONFIG: Dict[str, Any] = {
"content": "内部使用",
"fontSize": 32,
"color": "#B8B8B8",
"bold": False,
"italic": False,
"direction": "ltr",
"x": 80,
"y": 200,
"repeat": True,
"spacingX": 200,
"spacingY": 200,
"rotate": -45,
"opacity": 0.2,
"showTime": False,
"timeFormat": "yyyy-MM-dd",
}
def _format_watermark_time(fmt: str, now: Optional[datetime] = None) -> str:
dt = now or datetime.now()
mapping = {
"yyyy": "%Y",
"yyyy-MM": "%Y-%m",
"yyyy-MM-dd": "%Y-%m-%d",
"yyyy-MM-dd HH:mm": "%Y-%m-%d %H:%M",
"yyyy-MM-dd HH:mm:ss": "%Y-%m-%d %H:%M:%S",
}
py_fmt = mapping.get(fmt or "yyyy-MM-dd", "%Y-%m-%d")
return dt.strftime(py_fmt)
def resolve_watermark_config(
raw_config: Any,
*,
template_name: str = "",
now: Optional[datetime] = None,
) -> Dict[str, Any]:
"""合并默认项、填充 content,并按 showTime 追加时间文本。"""
base = copy.deepcopy(DEFAULT_WATERMARK_CONFIG)
if isinstance(raw_config, dict):
base.update({k: v for k, v in raw_config.items() if v is not None})
if not str(base.get("content") or "").strip():
base["content"] = template_name or DEFAULT_WATERMARK_CONFIG["content"]
if base.get("showTime"):
time_text = _format_watermark_time(str(base.get("timeFormat") or "yyyy-MM-dd"), now)
content = str(base.get("content") or "").strip()
base["content"] = f"{content} {time_text}".strip()
return base
def build_watermark_payload(
allow_watermark: bool,
raw_config: Any,
*,
template_name: str = "",
now: Optional[datetime] = None,
) -> Dict[str, Any]:
"""返回前端 Univer / 打印共用的 { show, config }。"""
if not allow_watermark:
return {"show": False, "config": {}}
return {
"show": True,
"config": resolve_watermark_config(
raw_config,
template_name=template_name,
now=now,
),
}
@@ -0,0 +1,22 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""报表枚举"""
from enum import IntEnum, Enum
class ReportVersionState(IntEnum):
"""版本状态"""
DESIGNING = 0
ACTIVE = 1
ARCHIVED = 2
class CellTypeEnum(str, Enum):
"""单元格类型(与 JNPF CellDataEnum 对齐)"""
TEXT = "text"
DATA_SOURCE = "dataSource"
PARAMETER = "parameter"
EXPRESSION = "expression"
CELL_CHART = "cellChart"
JSBARCODE = "jsbarcode"
QRCODE = "qrcode"
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
class ReportServiceException(Exception):
"""报表服务异常"""
pass
@@ -0,0 +1,49 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""报表管理数据模型"""
from sqlalchemy import Column, String, Text, Integer, Boolean, JSON, SmallInteger
from app.base_model import BaseModel
class ReportTemplate(BaseModel):
"""报表模板主表"""
__tablename__ = "report_template"
application_id = Column(String(21), nullable=True, index=True, comment="所属应用ID")
name = Column(String(100), nullable=False, comment="报表名称")
code = Column(String(100), unique=True, nullable=False, index=True, comment="报表编码")
category = Column(String(50), default="", comment="分类")
description = Column(Text, default="", comment="描述")
status = Column(String(20), default="draft", index=True, comment="状态: draft/published")
allow_export = Column(Boolean, default=True, comment="允许导出")
allow_print = Column(Boolean, default=True, comment="允许打印")
allow_watermark = Column(Boolean, default=False, comment="允许水印")
watermark_config = Column(JSON, default=dict, comment="水印配置")
class ReportVersion(BaseModel):
"""报表版本表"""
__tablename__ = "report_version"
template_id = Column(String(21), nullable=False, index=True, comment="模板ID")
version = Column(Integer, default=1, comment="版本号")
state = Column(SmallInteger, default=0, index=True, comment="0设计中 1启用 2归档")
snapshot = Column(JSON, default=dict, comment="Univer工作簿JSON")
cells = Column(JSON, default=dict, comment="单元格绑定元数据")
query_list = Column(JSON, default=list, comment="查询条件")
sort_list = Column(JSON, default=list, comment="排序配置")
column_list = Column(JSON, default=list, comment="分栏配置")
fence_list = Column(JSON, default=list, comment="围栏配置")
convert_config = Column(JSON, default=dict, comment="数据转换规则")
class ReportDataset(BaseModel):
"""报表版本与数据源关联"""
__tablename__ = "report_dataset"
version_id = Column(String(21), nullable=False, index=True, comment="版本ID")
data_source_id = Column(String(21), nullable=False, index=True, comment="数据源ID")
alias = Column(String(100), nullable=False, comment="设计器数据集别名")
field_mapping = Column(JSON, default=dict, comment="字段映射")
convert_config = Column(JSON, default=dict, comment="转换配置")
@@ -0,0 +1,35 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""报表发布时创建的 API 权限模板"""
from __future__ import annotations
from typing import Any, Dict, List, Tuple
REPORT_ACTIONS: List[Tuple[str, str, bool, int, str, str]] = [
("preview", "预览", True, 1, "POST", "/api/online_dev/report/data/preview-template"),
("export", "导出", True, 1, "POST", "/api/online_dev/report/data/export-excel/template"),
("export_pdf", "导出PDF", True, 1, "POST", "/api/online_dev/report/data/export-pdf/template"),
("design", "设计", True, 1, "POST", "/api/online_dev/report/save"),
("publish", "发布", True, 1, "POST", "/api/online_dev/report/{template_id}/publish"),
]
REPORT_ADMIN_PERMISSIONS: List[Dict[str, Any]] = [
{
"code": "report:admin:list",
"name": "报表管理-列表",
"api_path": "/api/online_dev/report/list",
"http_method": 0,
},
{
"code": "report:admin:save",
"name": "报表管理-保存",
"api_path": "/api/online_dev/report/save",
"http_method": 1,
},
{
"code": "report:admin:import",
"name": "报表管理-导入",
"api_path": "/api/online_dev/report/import",
"http_method": 1,
},
]
@@ -0,0 +1,208 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""报表管理 Schema"""
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, ConfigDict
def _to_camel(string: str) -> str:
parts = string.split("_")
return parts[0] + "".join(p.capitalize() for p in parts[1:])
class CamelModel(BaseModel):
"""支持 camelCase 别名(与 JNPF 前端对齐)"""
model_config = ConfigDict(
populate_by_name=True,
alias_generator=_to_camel,
)
# ============ 模板 ============
class ReportTemplateBase(BaseModel):
application_id: Optional[str] = Field(None, description="所属应用ID")
name: str = Field(..., description="报表名称")
code: str = Field(..., pattern=r"^[a-zA-Z][a-zA-Z0-9_]*$", description="报表编码")
category: str = Field("", description="分类")
description: str = Field("", description="描述")
sort: int = Field(0, description="排序")
class ReportTemplateCreateIn(ReportTemplateBase):
pass
class ReportTemplateUpdateIn(BaseModel):
name: Optional[str] = None
category: Optional[str] = None
description: Optional[str] = None
sort: Optional[int] = None
allow_export: Optional[bool] = None
allow_print: Optional[bool] = None
allow_watermark: Optional[bool] = None
watermark_config: Optional[Dict[str, Any]] = None
class ReportTemplateOut(BaseModel):
id: str
application_id: Optional[str] = None
name: str
code: str
category: str
description: str
status: str
allow_export: bool
allow_print: bool
allow_watermark: bool
watermark_config: Dict[str, Any]
sort: int
active_version_id: Optional[str] = None
sys_create_datetime: str
sys_update_datetime: str
class ReportTemplateListOut(BaseModel):
id: str
application_id: Optional[str] = None
application_name: str = ""
application_code: str = ""
name: str
code: str
category: str
description: str
status: str
has_release_menu: bool = False
sort: int
sys_create_datetime: str
sys_update_datetime: str
# ============ 版本 ============
class ReportDatasetIn(CamelModel):
data_source_id: str = Field(..., description="数据源ID")
alias: str = Field(..., description="别名")
field_mapping: Dict[str, Any] = Field(default_factory=dict)
convert_config: Dict[str, Any] = Field(default_factory=dict)
sort: int = 0
class ReportVersionOut(BaseModel):
id: str
template_id: str
version: int
state: int
snapshot: Dict[str, Any]
cells: Dict[str, Any]
query_list: List[Any]
sort_list: List[Any]
column_list: List[Any]
fence_list: List[Any]
convert_config: Dict[str, Any]
datasets: List[Dict[str, Any]] = Field(default_factory=list)
sys_create_datetime: str
sys_update_datetime: str
class ReportVersionListOut(BaseModel):
id: str
template_id: str
version: int
state: int
sys_create_datetime: str
sys_update_datetime: str
class ReportSaveIn(CamelModel):
"""保存版本(对标 JNPF POST /Report/Save"""
id: str = Field(..., description="模板ID")
version_id: Optional[str] = Field(None, description="版本ID,空则新建设计中版本")
type: int = Field(0, description="0仅保存 1发布")
snapshot: Any = Field(default_factory=dict, description="Univer snapshot")
cells: Any = Field(default_factory=dict)
query_list: List[Any] = Field(default_factory=list)
sort_list: List[Any] = Field(default_factory=list)
column_list: List[Any] = Field(default_factory=list)
fence_list: List[Any] = Field(default_factory=list)
convert_config: Any = Field(default_factory=dict)
data_set_list: List[ReportDatasetIn] = Field(default_factory=list, description="数据集列表")
class ReportSaveOut(BaseModel):
template_id: str
version_id: str
state: int
# ============ 预览 ============
class ReportPreviewIn(CamelModel):
params: Dict[str, Any] = Field(default_factory=dict, description="查询参数")
snapshot: Any = Field(None, description="设计态预览:当前编辑器 snapshot")
cells: Any = Field(None, description="设计态预览:当前编辑器 cells")
query_list: Any = Field(None, description="设计态预览:当前查询条件")
sort_list: Any = Field(None, description="设计态预览:当前排序")
column_list: Any = Field(None, description="设计态预览:当前分栏")
fence_list: Any = Field(None, description="设计态预览:当前分栏(fence)")
convert_config: Any = Field(None, description="设计态预览:当前转换配置")
class ReportDownImgIn(BaseModel):
"""远端/ Base64 图片转存"""
model_config = ConfigDict(populate_by_name=True)
img_value: str = Field("", alias="imgValue")
img_type: str = Field("", alias="imgType", description="BASE64 或 URL")
class ReportUploadOut(BaseModel):
name: str = ""
url: str = ""
class ReportImportExcelOut(BaseModel):
rowsCount: int = 0
colsCount: int = 0
data: List[List[Dict[str, Any]]] = Field(default_factory=list)
class ReportPreviewOut(CamelModel):
snapshot: Dict[str, Any]
cells: Dict[str, Any]
query_list: List[Any] = Field(default_factory=list)
chart_data: List[Any] = Field(default_factory=list)
allow_export: bool = True
allow_print: bool = True
allow_watermark: bool = False
watermark_config: Dict[str, Any] = Field(default_factory=dict)
full_name: str = ""
# ============ 发布 / 导入导出 ============
class ReportPublishIn(BaseModel):
menu_name: str = Field(..., description="菜单名称")
menu_parent_id: Optional[str] = Field(None, description="上级菜单ID")
menu_icon: str = Field("lucide:file-spreadsheet", description="菜单图标")
menu_order: int = Field(0, description="菜单排序")
class ReportImportCheckIn(BaseModel):
code: str
class ReportImportCheckOut(BaseModel):
code_exists: bool
can_import: bool
class ReportImportIn(ReportTemplateBase):
schema_version: int = Field(1, description="包格式版本")
allow_export: Optional[bool] = None
allow_print: Optional[bool] = None
allow_watermark: Optional[bool] = None
watermark_config: Optional[Dict[str, Any]] = None
version: Optional[Dict[str, Any]] = Field(None, description="设计中版本内容")
versions: List[Dict[str, Any]] = Field(default_factory=list, description="兼容旧字段")
datasets: List[Dict[str, Any]] = Field(default_factory=list)
@@ -0,0 +1,603 @@
#!/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])
]
@@ -0,0 +1,289 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""报表版本管理服务"""
import copy
import json
import logging
from typing import Any, Dict, List, Optional
from sqlalchemy import select, update, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from online_dev.report_manager.model import ReportTemplate, ReportVersion, ReportDataset
from online_dev.report_manager.enums import ReportVersionState
from online_dev.report_manager.exceptions import ReportServiceException
from online_dev.report_manager.constants import default_snapshot, default_cells
from online_dev.report_manager.dataset_bridge import ReportDatasetBridge
logger = logging.getLogger(__name__)
class ReportVersionService:
@staticmethod
async def get(db: AsyncSession, version_id: str) -> ReportVersion:
stmt = select(ReportVersion).where(
ReportVersion.id == version_id,
ReportVersion.is_deleted == False,
)
ver = (await db.execute(stmt)).scalar_one_or_none()
if not ver:
raise ReportServiceException(f"版本不存在: {version_id}")
return ver
@staticmethod
async def list_by_template(db: AsyncSession, template_id: str) -> List[ReportVersion]:
stmt = (
select(ReportVersion)
.where(
ReportVersion.template_id == template_id,
ReportVersion.is_deleted == False,
)
.order_by(ReportVersion.version.desc())
)
return list((await db.execute(stmt)).scalars().all())
@staticmethod
async def get_active(db: AsyncSession, template_id: str) -> Optional[ReportVersion]:
stmt = select(ReportVersion).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 get_designing_or_latest(db: AsyncSession, template_id: str) -> Optional[ReportVersion]:
stmt = select(ReportVersion).where(
ReportVersion.template_id == template_id,
ReportVersion.is_deleted == False,
).order_by(
ReportVersion.state.asc(),
ReportVersion.version.desc(),
)
versions = list((await db.execute(stmt)).scalars().all())
for v in versions:
if v.state == ReportVersionState.DESIGNING:
return v
return versions[0] if versions else None
@staticmethod
def _parse_json_field(value: Any, default: Any) -> Any:
if value is None:
return default
if isinstance(value, str):
try:
return json.loads(value)
except json.JSONDecodeError:
return default
return value
@staticmethod
async def save(db: AsyncSession, data: Dict[str, Any], user_id: str = None) -> Dict[str, Any]:
from online_dev.report_manager.service import ReportService
template_id = data.get("id")
tpl = await ReportService.get(db, template_id)
version_id = data.get("version_id")
save_type = data.get("type", 0)
snapshot = ReportVersionService._parse_json_field(data.get("snapshot"), default_snapshot())
cells = ReportVersionService._parse_json_field(data.get("cells"), default_cells())
query_list = ReportVersionService._parse_json_field(data.get("query_list"), [])
sort_list = ReportVersionService._parse_json_field(data.get("sort_list"), [])
column_list = ReportVersionService._parse_json_field(data.get("column_list"), [])
fence_list = ReportVersionService._parse_json_field(data.get("fence_list"), [])
convert_config = ReportVersionService._parse_json_field(data.get("convert_config"), {})
data_set_list = data.get("data_set_list") or []
if version_id:
version = await ReportVersionService.get(db, version_id)
if version.template_id != template_id:
raise ReportServiceException("版本与模板不匹配")
if version.state != ReportVersionState.DESIGNING:
raise ReportServiceException("只能编辑设计中版本")
else:
designing = await ReportVersionService.get_designing_or_latest(db, template_id)
if designing and designing.state == ReportVersionState.DESIGNING:
version = designing
else:
max_ver = (
await db.execute(
select(func.max(ReportVersion.version)).where(
ReportVersion.template_id == template_id,
ReportVersion.is_deleted == False,
)
)
).scalar() or 0
version = ReportVersion(
template_id=template_id,
version=max_ver + 1,
state=ReportVersionState.DESIGNING,
snapshot=default_snapshot(),
cells=default_cells(),
sys_creator_id=user_id,
sys_modifier_id=user_id,
)
db.add(version)
await db.flush()
version.snapshot = snapshot
version.cells = cells
version.query_list = query_list
version.sort_list = sort_list
version.column_list = column_list
version.fence_list = fence_list
version.convert_config = convert_config
version.sys_modifier_id = user_id
await ReportDatasetBridge.sync_datasets(db, version.id, data_set_list)
if save_type == 1:
stmt = update(ReportVersion).where(
ReportVersion.template_id == template_id,
ReportVersion.state == ReportVersionState.ACTIVE,
ReportVersion.is_deleted == False,
).values(state=ReportVersionState.ARCHIVED)
await db.execute(stmt)
version.state = ReportVersionState.ACTIVE
await db.flush()
max_ver = (
await db.execute(
select(func.max(ReportVersion.version)).where(
ReportVersion.template_id == template_id,
ReportVersion.is_deleted == False,
)
)
).scalar() or version.version
new_designing = ReportVersion(
template_id=template_id,
version=max_ver + 1,
state=ReportVersionState.DESIGNING,
snapshot=copy.deepcopy(version.snapshot),
cells=copy.deepcopy(version.cells),
query_list=copy.deepcopy(version.query_list),
sort_list=copy.deepcopy(version.sort_list),
column_list=copy.deepcopy(version.column_list),
fence_list=copy.deepcopy(version.fence_list),
convert_config=copy.deepcopy(version.convert_config),
sys_creator_id=user_id,
sys_modifier_id=user_id,
)
db.add(new_designing)
await db.flush()
await ReportDatasetBridge.copy_datasets(db, version.id, new_designing.id)
version = new_designing
else:
version.state = ReportVersionState.DESIGNING
await db.commit()
await db.refresh(version)
return {
"template_id": template_id,
"version_id": version.id,
"state": int(version.state),
}
@staticmethod
async def get_version_detail(db: AsyncSession, version_id: str) -> Dict[str, Any]:
version = await ReportVersionService.get(db, version_id)
datasets = await ReportDatasetBridge.list_by_version(db, version_id)
return {
"version": version,
"datasets": datasets,
}
@staticmethod
async def delete_version(db: AsyncSession, version_id: str) -> bool:
version = await ReportVersionService.get(db, version_id)
if version.state in (ReportVersionState.ACTIVE, ReportVersionState.ARCHIVED):
raise ReportServiceException("不能删除启用中或已归档版本")
count_stmt = select(func.count(ReportVersion.id)).where(
ReportVersion.template_id == version.template_id,
ReportVersion.is_deleted == False,
)
if (await db.execute(count_stmt)).scalar() <= 1:
raise ReportServiceException("不能删除最后一个版本")
version.is_deleted = True
await ReportDatasetBridge.delete_by_version(db, version_id)
await db.commit()
return True
@staticmethod
async def copy_version_content(
db: AsyncSession,
src_version_id: str,
new_template_id: str,
user_id: str = None,
) -> None:
src = await ReportVersionService.get(db, src_version_id)
new_ver = (
await db.execute(
select(ReportVersion).where(
ReportVersion.template_id == new_template_id,
ReportVersion.is_deleted == False,
)
)
).scalar_one_or_none()
if not new_ver:
return
new_ver.snapshot = src.snapshot
new_ver.cells = src.cells
new_ver.query_list = src.query_list
new_ver.sort_list = src.sort_list
new_ver.column_list = src.column_list
new_ver.fence_list = src.fence_list
new_ver.convert_config = src.convert_config
await ReportDatasetBridge.copy_datasets(db, src_version_id, new_ver.id)
await db.commit()
@staticmethod
async def duplicate_version(
db: AsyncSession,
src_version_id: str,
user_id: str = None,
) -> ReportVersion:
"""复制版本为新的「设计中」版本(同模板)。"""
src = await ReportVersionService.get(db, src_version_id)
template_id = src.template_id
await db.execute(
update(ReportVersion).where(
ReportVersion.template_id == template_id,
ReportVersion.state == ReportVersionState.DESIGNING,
ReportVersion.is_deleted == False,
).values(state=ReportVersionState.ARCHIVED)
)
max_ver = (
await db.execute(
select(func.max(ReportVersion.version)).where(
ReportVersion.template_id == template_id,
ReportVersion.is_deleted == False,
)
)
).scalar() or 0
new_ver = ReportVersion(
template_id=template_id,
version=max_ver + 1,
state=ReportVersionState.DESIGNING,
snapshot=copy.deepcopy(src.snapshot),
cells=copy.deepcopy(src.cells),
query_list=copy.deepcopy(src.query_list),
sort_list=copy.deepcopy(src.sort_list),
column_list=copy.deepcopy(src.column_list),
fence_list=copy.deepcopy(src.fence_list),
convert_config=copy.deepcopy(src.convert_config),
sys_creator_id=user_id,
sys_modifier_id=user_id,
)
db.add(new_ver)
await db.flush()
await ReportDatasetBridge.copy_datasets(db, src.id, new_ver.id)
await db.commit()
await db.refresh(new_ver)
return new_ver