Compare commits

14 Commits

633 changed files with 169889 additions and 655 deletions
+3 -108
View File
@@ -181,123 +181,18 @@ dmypy.json
/go-view/
node_modules
# ai-agent-admin: exclude generated bundles and unused product entries.
# Keep design/runtime source used by AI workflow panels tracked; lightness is
# enforced by route/menu filtering and lazy imports, not by missing source.
# ai-agent-admin: exclude generated bundles and runtime artifacts.
# Source code stays tracked; lightness is enforced by route/menu filtering,
# lazy imports, and build-time entry selection.
.ops-backups/
docker/uniapp/
docker/web/index/*
!docker/web/index/.gitkeep
uniapp-zq/
web/apps/web-ele/src/components/import-export-manager/
web/apps/web-ele/src/components/wiki/
web/apps/web-ele/src/components/zq-draw/
web/apps/web-ele/src/components/zq-editor/
web/apps/web-ele/src/components/zq-smart-table/
web/apps/web-ele/src/components/zq-whiteboard/
web/apps/web-ele/src/components/whiteboard/
web/apps/web-ele/src/components/workflow/
web/apps/web-ele/src/views/online-dev/
!web/apps/web-ele/src/views/online-dev/
web/apps/web-ele/src/views/online-dev/*
!web/apps/web-ele/src/views/online-dev/page-render/
!web/apps/web-ele/src/views/online-dev/page-render/**
!web/apps/web-ele/src/views/online-dev/form-manager/
web/apps/web-ele/src/views/online-dev/form-manager/*
!web/apps/web-ele/src/views/online-dev/form-manager/composables/
web/apps/web-ele/src/views/online-dev/form-manager/composables/*
!web/apps/web-ele/src/views/online-dev/form-manager/composables/useFormData.ts
!web/apps/web-ele/src/views/online-dev/form-manager/modules/
web/apps/web-ele/src/views/online-dev/form-manager/modules/*
!web/apps/web-ele/src/views/online-dev/form-manager/modules/ListTypePicker.vue
!web/apps/web-ele/src/views/online-dev/form-manager/modules/data-source-config.vue
!web/apps/web-ele/src/views/online-dev/form-manager/modules/list-design.vue
!web/apps/web-ele/src/views/online-dev/form-manager/utils/
web/apps/web-ele/src/views/online-dev/form-manager/utils/*
!web/apps/web-ele/src/views/online-dev/form-manager/utils/formValidator.ts
!web/apps/web-ele/src/views/online-dev/form-render/
web/apps/web-ele/src/views/online-dev/form-render/*
!web/apps/web-ele/src/views/online-dev/form-render/components/
!web/apps/web-ele/src/views/online-dev/form-render/components/**
!web/apps/web-ele/src/views/online-dev/form-render/utils/
web/apps/web-ele/src/views/online-dev/form-render/utils/*
!web/apps/web-ele/src/views/online-dev/form-render/utils/list-filter-fields.ts
web/apps/web-ele/src/views/dashboard/
!web/apps/web-ele/src/views/dashboard/
web/apps/web-ele/src/views/dashboard/*
!web/apps/web-ele/src/views/dashboard/analytics/
!web/apps/web-ele/src/views/dashboard/analytics/**
!web/apps/web-ele/src/views/dashboard/workspace/
!web/apps/web-ele/src/views/dashboard/workspace/**
web/apps/web-ele/src/views/demos/
web/apps/web-ele/src/views/zq-smart-table/
web/apps/web-ele/src/components/document-designer/
web/apps/web-ele/src/components/workflow/designer/
web/docs/src/public/guide/
web/apps/web-ele/src/locales/langs/*/*.json_new
web/apps/web-ele/src/locales/langs/*/*.json_sync
web/apps/web-ele/src/locales/langs/*/*.json.tmp
web/apps/web-ele/src/locales/langs/*/draw.json
web/apps/web-ele/src/locales/langs/*/whiteboard.json
web/apps/web-ele/src/locales/langs/*/wiki.json
web/apps/web-ele/src/locales/langs/*/zq-editor.json
web/apps/web-ele/src/locales/langs/*/zq-smart-table.json
web/apps/web-ele/src/router/routes/modules/form-layout.ts
web/apps/web-ele/src/router/routes/modules/online-dev.ts
web/apps/web-ele/src/router/routes/modules/chat.ts
web/apps/web-ele/src/router/routes/modules/demos.ts
web/apps/web-ele/src/router/routes/modules/zq-smart-table.ts
web/apps/web-ele/src/api/smart-table.ts
web/apps/web-ele/src/composables/use-offline-cache.ts
web/apps/web-ele/src/store/wiki.ts
web/apps/web-ele/src/store/zq-smart-table.ts
web/apps/web-ele/src/styles/zq-smart-table/
web/apps/web-ele/src/components/report-design/
backend-fastapi/online_dev/
!backend-fastapi/online_dev/
backend-fastapi/online_dev/*
!backend-fastapi/online_dev/__init__.py
!backend-fastapi/online_dev/router.py
!backend-fastapi/online_dev/page_manager/
!backend-fastapi/online_dev/page_manager/**
backend-fastapi/online_dev/page_manager/*
!backend-fastapi/online_dev/page_manager/__init__.py
!backend-fastapi/online_dev/page_manager/api.py
!backend-fastapi/online_dev/page_manager/model.py
!backend-fastapi/online_dev/page_manager/service.py
!backend-fastapi/online_dev/form_manager/
!backend-fastapi/online_dev/form_manager/**
backend-fastapi/online_dev/form_manager/*
!backend-fastapi/online_dev/form_manager/__init__.py
!backend-fastapi/online_dev/form_manager/model.py
!backend-fastapi/online_dev/form_manager/service.py
!backend-fastapi/online_dev/
backend-fastapi/online_dev/*
!backend-fastapi/online_dev/__init__.py
!backend-fastapi/online_dev/router.py
!backend-fastapi/online_dev/form_manager/
!backend-fastapi/online_dev/form_manager/**
!backend-fastapi/online_dev/form_data_manager/
!backend-fastapi/online_dev/form_data_manager/**
!backend-fastapi/online_dev/page_manager/
!backend-fastapi/online_dev/page_manager/**
backend-fastapi/zq_demo/
backend-fastapi/zq_smart_table/
backend-fastapi/ai_platform/nodes/builtin/app_*_node.py
backend-fastapi/ai_platform/nodes/builtin/dashboard_*_node.py
backend-fastapi/ai_platform/nodes/builtin/form_*_node.py
backend-fastapi/ai_platform/nodes/builtin/system_summary_node.py
!backend-fastapi/ai_platform/nodes/builtin/app_create_node.py
!backend-fastapi/ai_platform/nodes/builtin/app_design_node.py
!backend-fastapi/ai_platform/nodes/builtin/app_settings_node.py
!backend-fastapi/ai_platform/nodes/builtin/app_update_node.py
!backend-fastapi/ai_platform/nodes/builtin/dashboard_basic_info_node.py
!backend-fastapi/ai_platform/nodes/builtin/dashboard_create_node.py
!backend-fastapi/ai_platform/nodes/builtin/dashboard_design_node.py
!backend-fastapi/ai_platform/nodes/builtin/dashboard_publish_node.py
!backend-fastapi/ai_platform/nodes/builtin/form_basic_info_node.py
!backend-fastapi/ai_platform/nodes/builtin/form_create_node.py
!backend-fastapi/ai_platform/nodes/builtin/form_data_node.py
!backend-fastapi/ai_platform/nodes/builtin/form_database_create_node.py
!backend-fastapi/ai_platform/nodes/builtin/form_database_design_node.py
!backend-fastapi/ai_platform/nodes/builtin/form_list_design_node.py
@@ -570,7 +570,7 @@ class ConfirmNode(BaseNode):
return False, False
def _llm_intent_recognition(self, user_input: str, context_content: str, model_id: str) -> bool:
def _llm_intent_recognition(self, user_input: str, context_content: str, model_id: str, db_session=None) -> bool:
"""
使用 LLM 进行意图识别
@@ -595,7 +595,7 @@ class ConfirmNode(BaseNode):
请判断用户意图:"""
llm_service = LLMService()
llm_service = LLMService(db_session)
response = llm_service.chat(
model_id=model_id,
messages=[
@@ -639,7 +639,9 @@ class ConfirmNode(BaseNode):
if not matched and use_llm_intent and llm_model_id:
# 关键词未匹配,使用 LLM 意图识别
logger.info(f'关键词未匹配,使用 LLM 意图识别: {user_input}')
confirmed = self._llm_intent_recognition(str(user_input), content, llm_model_id)
confirmed = self._llm_intent_recognition(
str(user_input), content, llm_model_id, context.db_session
)
elif not matched:
# 关键词未匹配且未启用 LLM,默认为取消
logger.info(f'关键词未匹配,默认取消: {user_input}')
@@ -313,6 +313,7 @@ class LLMNode(BaseNode):
messages.append({'role': 'user', 'content': user_prompt})
llm_service = LLMService(context.db_session)
model_id = llm_service.resolve_chat_model_id_sync(model_id)
output_var = self.config.get('output_variable', 'llm_response')
# 根据输出模式选择执行方式
@@ -64,6 +64,26 @@ class LLMService:
logger.info("No model_id supplied, fallback to default chat model %s", model.id)
return str(model.id)
@staticmethod
def _run_async_sync(coro):
import asyncio
import concurrent.futures
try:
loop = asyncio.get_event_loop()
except RuntimeError:
return asyncio.run(coro)
if loop.is_running():
with concurrent.futures.ThreadPoolExecutor() as executor:
return executor.submit(asyncio.run, coro).result()
return loop.run_until_complete(coro)
def resolve_chat_model_id_sync(self, model_id: Optional[str]) -> str:
"""同步解析 chat 模型,供同步节点和流式生成器复用。"""
return self._run_async_sync(self.resolve_chat_model_id(model_id))
async def _get_provider_async(self, model_id: str) -> tuple:
"""
@@ -272,6 +292,34 @@ class LLMService:
return provider.chat(llm_messages, config)
except Exception as exc:
raise RuntimeError(self._format_provider_error(exc, provider, model_name)) from exc
def chat(
self,
model_id: Optional[str],
messages: List[Dict[str, str]],
temperature: float = 0.7,
max_tokens: int = 2048,
tools: List[Dict] = None,
tool_choice: str = 'auto',
**kwargs
) -> LLMResponse:
"""
同步对话入口。
旧节点仍会调用该方法;这里统一走 provider 解析和默认 chat 模型兜底,
避免不同节点各自处理 model_id 为空的情况。
"""
provider, model_name = self._run_async_sync(self._get_provider_async(model_id))
return self.chat_with_provider(
provider=provider,
model_name=model_name,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
tools=tools,
tool_choice=tool_choice,
**kwargs,
)
def _convert_messages(self, messages: List[Dict]) -> List[LLMMessage]:
"""转换消息格式,支持 tool 消息"""
@@ -195,6 +195,37 @@ def _build_communication_metadata(
return communication
def _copy_observability_metadata(
metadata: Dict[str, Any],
collaboration: Dict[str, Any],
communication: Optional[Dict[str, Any]] = None,
) -> None:
actor = (communication or {}).get('actor') or {}
for key in (
'agent_code',
'agent_name',
'branch_id',
'branch_label',
'collaboration_mode',
'collaboration_role',
'model',
'model_id',
'model_name',
'node_label',
'node_type',
'provider_name',
'provider_type',
'subflow_name',
):
value = collaboration.get(key) or actor.get(key)
if value not in (None, '', {}, []):
metadata.setdefault(key, value)
channel = (communication or {}).get('channel')
if channel:
metadata.setdefault('channel', channel)
def _resolve_handoff_target(
current_node_id: str,
result: Optional[NodeResult],
@@ -237,7 +268,7 @@ def _make_execution_log_entry(
collaboration['branch_label'] = branch_label or branch_id
if collaboration:
metadata['collaboration'] = collaboration
metadata['communication'] = _build_communication_metadata(
communication = _build_communication_metadata(
node_map,
node_id,
node_type,
@@ -249,8 +280,14 @@ def _make_execution_log_entry(
output=extra.get('output'),
error=extra.get('error'),
)
metadata['communication'] = communication
_copy_observability_metadata(metadata, collaboration, communication)
metadata['communication']['timestamp'] = extra.get('timestamp')
waiting_config = extra.get('waiting_config') or extra.get('config')
if waiting_config:
metadata.setdefault('waiting_config', waiting_config)
entry = {
'type': event_type,
'node_id': node_id,
'node_type': node_type,
'node_label': _node_label_from_map(node_map, node_id),
@@ -306,7 +343,7 @@ def _make_stream_event_log_entry(event: Dict[str, Any]) -> Optional[dict]:
output = event_data.get('content') or event_data.get('message')
error = event_data.get('error_message') or event_data.get('error')
return {
entry = {
'type': event_type,
'node_id': node_id,
'node_type': node_type,
@@ -325,6 +362,8 @@ def _make_stream_event_log_entry(event: Dict[str, Any]) -> Optional[dict]:
'communication': communication,
},
}
_copy_observability_metadata(entry['metadata'], collaboration, communication)
return entry
def _log_timestamp(log: Dict[str, Any]) -> str:
@@ -1705,6 +1744,7 @@ class AIWorkflowService:
elapsed_time=elapsed,
metadata=_node_result_metadata(result),
events=_node_result_events(result),
waiting_config=result.waiting_config,
loop_iteration=iteration,
))
@@ -2149,6 +2189,7 @@ class AIWorkflowService:
elapsed_time=elapsed,
metadata=_node_result_metadata(result),
events=_node_result_events(result),
waiting_config=result.waiting_config,
inputs=copy.deepcopy(node_inputs),
target_node_id=target_node_id,
)
@@ -2682,6 +2723,7 @@ class AIWorkflowService:
elapsed_time=elapsed,
metadata=_node_result_metadata(result),
events=_node_result_events(result),
waiting_config=result.waiting_config,
target_node_id=target_node_id,
)
logs.append(log_entry)
@@ -0,0 +1,34 @@
"""add form_meta globally_visible column
Revision ID: f6g7h8i9j0k1
Revises: e5f6g7h8i9j0
Create Date: 2026-06-04 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'f6g7h8i9j0k1'
down_revision: Union[str, None] = 'e5f6g7h8i9j0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'form_meta',
sa.Column(
'globally_visible',
sa.Boolean(),
nullable=True,
server_default=sa.text('false'),
comment='是否全局可见(供其他应用引用)',
),
)
op.execute("UPDATE form_meta SET globally_visible = false WHERE globally_visible IS NULL")
def downgrade() -> None:
op.drop_column('form_meta', 'globally_visible')
@@ -0,0 +1,34 @@
"""add data source db_database and db_schema columns
Revision ID: g7h8i9j0k1l2
Revises: f6g7h8i9j0k1
Create Date: 2026-06-04 14:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'g7h8i9j0k1l2'
down_revision: Union[str, None] = 'f6g7h8i9j0k1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'core_data_source',
sa.Column('db_database', sa.String(length=100), nullable=True, server_default='', comment='SQL执行目标库/catalog'),
)
op.add_column(
'core_data_source',
sa.Column('db_schema', sa.String(length=100), nullable=True, server_default='', comment='SQL执行目标模式/schema'),
)
op.execute("UPDATE core_data_source SET db_database = '' WHERE db_database IS NULL")
op.execute("UPDATE core_data_source SET db_schema = '' WHERE db_schema IS NULL")
def downgrade() -> None:
op.drop_column('core_data_source', 'db_schema')
op.drop_column('core_data_source', 'db_database')
@@ -0,0 +1,33 @@
"""add data source list_config column
Revision ID: h8i9j0k1l2m3
Revises: g7h8i9j0k1l2
Create Date: 2026-06-05 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'h8i9j0k1l2m3'
down_revision: Union[str, None] = 'g7h8i9j0k1l2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'core_data_source',
sa.Column(
'list_config',
sa.JSON(),
nullable=True,
comment='列表后处理: sort_field/sort_order/limit',
),
)
op.execute("UPDATE core_data_source SET list_config = '{}' WHERE list_config IS NULL")
def downgrade() -> None:
op.drop_column('core_data_source', 'list_config')
@@ -17,6 +17,8 @@ from utils.security import verify_access_token
logger = logging.getLogger(__name__)
WS_AUTH_PROTOCOL = "access_token"
class ConnectionManager:
"""WebSocket 连接管理器"""
@@ -27,9 +29,14 @@ class ConnectionManager:
# 组连接: {group_name: {websocket1, websocket2, ...}}
self.groups: Dict[str, Set[WebSocket]] = {}
async def connect(self, websocket: WebSocket, user_id: str):
async def connect(
self,
websocket: WebSocket,
user_id: str,
subprotocol: Optional[str] = None,
):
"""添加连接"""
await websocket.accept()
await websocket.accept(subprotocol=subprotocol)
if user_id not in self.active_connections:
self.active_connections[user_id] = set()
self.active_connections[user_id].add(websocket)
@@ -101,27 +108,45 @@ class TokenAuthWebSocketConsumer:
self.user_id: Optional[str] = None
self.is_authenticated = False
self._token: Optional[str] = None # 保存原始token用于心跳校验
self._accept_subprotocol: Optional[str] = None
def _get_token_from_protocol(self) -> Optional[str]:
protocol_header = self.websocket.headers.get("sec-websocket-protocol") or ""
protocols = [
item.strip()
for item in protocol_header.split(",")
if item.strip()
]
for index, protocol in enumerate(protocols):
if protocol == WS_AUTH_PROTOCOL and index + 1 < len(protocols):
self._accept_subprotocol = WS_AUTH_PROTOCOL
return protocols[index + 1]
return None
def _get_token_from_query(self) -> Optional[str]:
query_string = self.websocket.scope.get('query_string', b'').decode('utf-8')
if not query_string:
return None
query_params = parse_qs(query_string)
token_list = query_params.get('token', [])
return token_list[0] if token_list else None
async def _accept_then_close(self, code: int):
await self.websocket.accept(subprotocol=self._accept_subprotocol)
await self.websocket.close(code=code)
async def authenticate(self) -> bool:
"""
进行Token认证
从查询参数中获取token并验证
优先从 WebSocket 子协议中获取 token兼容旧 query 参数方式
"""
# 获取查询参数中的token
query_string = self.websocket.scope.get('query_string', b'').decode('utf-8')
token = None
if query_string:
query_params = parse_qs(query_string)
token_list = query_params.get('token', [])
if token_list:
token = token_list[0]
token = self._get_token_from_protocol() or self._get_token_from_query()
if not token:
logger.warning("WebSocket connection rejected: No token provided")
# 必须先accept才能close
await self.websocket.accept()
await self.websocket.close(code=4001)
await self._accept_then_close(code=4001)
return False
# 验证token
@@ -130,15 +155,13 @@ class TokenAuthWebSocketConsumer:
if not payload:
logger.warning("WebSocket connection rejected: Invalid token")
await self.websocket.accept()
await self.websocket.close(code=4001)
await self._accept_then_close(code=4001)
return False
user_id = payload.get('sub')
if not user_id:
logger.warning("WebSocket connection rejected: Invalid token payload")
await self.websocket.accept()
await self.websocket.close(code=4001)
await self._accept_then_close(code=4001)
return False
self.user_id = user_id
@@ -149,14 +172,17 @@ class TokenAuthWebSocketConsumer:
except Exception as e:
logger.error(f"WebSocket authentication failed: {str(e)}")
await self.websocket.accept()
await self.websocket.close(code=4001)
await self._accept_then_close(code=4001)
return False
async def connect(self):
"""连接时进行Token认证"""
if await self.authenticate():
await manager.connect(self.websocket, self.user_id)
await manager.connect(
self.websocket,
self.user_id,
subprotocol=self._accept_subprotocol,
)
async def disconnect(self, close_code: int = 1000):
"""断开连接"""
@@ -0,0 +1,5 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
合同管理模块
"""
+773
View File
@@ -0,0 +1,773 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
合同管理 API异步版本
"""
import logging
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.base_schema import PaginatedResponse
from online_dev.contract.schema import (
ContractInstanceCreate,
ContractInstanceListItem,
ContractInstanceOut,
ContractInstanceUpdate,
ContractLogOut,
ContractSignatureCreate,
ContractSignatureOut,
ContractTemplateCreate,
ContractTemplateListItem,
ContractTemplateOut,
ContractTemplateUpdate,
MobileContractInfo,
MobileSignRequest,
MobileSignTokenCreate,
MobileSignTokenOut,
)
from online_dev.contract.service import (
ContractInstanceService,
ContractSignatureService,
ContractTemplateService,
MobileSignService,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/contract", tags=["合同管理"])
# ============ 辅助函数 ============
def _format_datetime(dt) -> str:
"""格式化日期时间"""
if dt:
return dt.strftime("%Y-%m-%d %H:%M:%S")
return ""
async def _build_template_out(template) -> dict:
"""构建模板输出"""
return {
"id": str(template.id),
"name": template.name,
"code": template.code,
"category": template.category or "",
"description": template.description or "",
"status": template.status,
"version": template.version,
"template_config": template.template_config or {},
"thumbnail": template.thumbnail or "",
"sys_create_datetime": template.sys_create_datetime,
"sys_update_datetime": template.sys_update_datetime,
}
async def _build_template_list_item(template) -> dict:
"""构建模板列表项"""
return {
"id": str(template.id),
"name": template.name,
"code": template.code,
"category": template.category or "",
"description": template.description or "",
"status": template.status,
"version": template.version,
"thumbnail": template.thumbnail or "",
"sys_create_datetime": template.sys_create_datetime,
"sys_update_datetime": template.sys_update_datetime,
}
async def _build_instance_out(db: AsyncSession, instance) -> dict:
"""构建合同实例输出"""
# 获取关联的模板信息
template = await ContractTemplateService.get_by_id(db, instance.template_id)
template_name = template.name if template else ""
template_code = template.code if template else ""
# 获取创建人信息
creator_name = ""
if instance.creator_id:
from core.user.model import User
from sqlalchemy import select
stmt = select(User).where(User.id == instance.creator_id)
result = await db.execute(stmt)
creator = result.scalar_one_or_none()
if creator:
creator_name = creator.name or creator.username or ""
return {
"id": str(instance.id),
"contract_no": instance.contract_no,
"title": instance.title,
"status": instance.status,
"template_id": str(instance.template_id),
"contract_config": instance.contract_config or {},
"variable_data": instance.variable_data or {},
"signature_data": instance.signature_data or {},
"created_at": instance.created_at,
"completed_at": instance.completed_at,
"expired_at": instance.expired_at,
"pdf_file": instance.pdf_file or "",
"template_name": template_name,
"template_code": template_code,
"creator_id": str(instance.creator_id) if instance.creator_id else "",
"creator_name": creator_name,
}
async def _build_instance_list_item(db: AsyncSession, instance) -> dict:
"""构建合同实例列表项"""
# 获取关联的模板信息
template = await ContractTemplateService.get_by_id(db, instance.template_id)
template_name = template.name if template else ""
# 获取创建人信息
creator_name = ""
if instance.creator_id:
from core.user.model import User
from sqlalchemy import select
stmt = select(User).where(User.id == instance.creator_id)
result = await db.execute(stmt)
creator = result.scalar_one_or_none()
if creator:
creator_name = creator.name or creator.username or ""
return {
"id": str(instance.id),
"contract_no": instance.contract_no,
"title": instance.title,
"status": instance.status,
"created_at": instance.created_at,
"completed_at": instance.completed_at,
"template_name": template_name,
"creator_name": creator_name,
}
def _build_signature_out(signature) -> dict:
"""构建签署记录输出"""
return {
"id": str(signature.id),
"element_id": signature.element_id,
"party_type": signature.party_type,
"party_label": signature.party_label or "",
"sign_type": signature.sign_type,
"signer_id": str(signature.signer_id) if signature.signer_id else None,
"signer_name": signature.signer_name or "",
"signature_image": signature.signature_image or "",
"status": signature.status,
"signed_at": signature.signed_at,
}
async def _build_log_out(db: AsyncSession, log) -> dict:
"""构建日志输出"""
# 获取操作人信息
operator_name = ""
if log.operator_id:
from core.user.model import User
from sqlalchemy import select
stmt = select(User).where(User.id == log.operator_id)
result = await db.execute(stmt)
operator = result.scalar_one_or_none()
if operator:
operator_name = operator.name or operator.username or ""
return {
"id": str(log.id),
"action": log.action,
"operator_id": str(log.operator_id) if log.operator_id else "",
"comment": log.comment or "",
"sys_create_datetime": log.sys_create_datetime,
"operator_name": operator_name,
}
# ============ 合同模板 API ============
@router.get("/template/list", response_model=PaginatedResponse[ContractTemplateListItem], summary="模板列表")
async def list_templates(
name: str = Query(None, description="模板名称"),
code: str = Query(None, description="模板编码"),
category: str = Query(None, description="分类"),
status: str = Query(None, description="状态"),
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""分页查询合同模板列表"""
result = await ContractTemplateService.get_list(
db=db,
page=page,
page_size=page_size,
name=name,
code=code,
category=category,
status=status,
)
items = [await _build_template_list_item(item) for item in result["items"]]
return PaginatedResponse(
items=items,
total=result["total"],
)
@router.get("/template/categories", response_model=List[str], summary="获取模板分类")
async def get_template_categories(db: AsyncSession = Depends(get_db)):
"""获取所有模板分类"""
return await ContractTemplateService.get_categories(db)
@router.get("/template/{template_id}", response_model=ContractTemplateOut, summary="模板详情")
async def get_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取模板详情"""
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return await _build_template_out(template)
@router.get("/template/code/{code}", response_model=ContractTemplateOut, summary="根据编码获取模板")
async def get_template_by_code(
code: str,
db: AsyncSession = Depends(get_db),
):
"""根据编码获取模板"""
template = await ContractTemplateService.get_by_code(db, code)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return await _build_template_out(template)
@router.post("/template", response_model=ContractTemplateOut, summary="创建模板")
async def create_template(
request: Request,
data: ContractTemplateCreate,
db: AsyncSession = Depends(get_db),
):
"""创建合同模板"""
user_id = request.state.user_id
# 检查编码是否已存在
if await ContractTemplateService.get_by_code(db, data.code):
raise HTTPException(status_code=400, detail=f"模板编码 {data.code} 已存在")
try:
template = await ContractTemplateService.create(
db=db,
name=data.name,
code=data.code,
category=data.category,
description=data.description,
template_config=data.template_config,
creator_id=user_id,
)
return await _build_template_out(template)
except Exception as e:
logger.exception("创建模板失败")
raise HTTPException(status_code=400, detail=str(e))
@router.put("/template/{template_id}", response_model=ContractTemplateOut, summary="更新模板")
async def update_template(
template_id: str,
data: ContractTemplateUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新合同模板"""
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
try:
template = await ContractTemplateService.update(
db=db,
template=template,
name=data.name,
category=data.category,
description=data.description,
template_config=data.template_config,
thumbnail=data.thumbnail,
)
return await _build_template_out(template)
except Exception as e:
logger.exception("更新模板失败")
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/template/{template_id}", summary="删除模板")
async def delete_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""删除合同模板"""
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
await ContractTemplateService.delete(db, template)
return {"message": "删除成功"}
@router.delete("/template/batch", summary="批量删除模板")
async def batch_delete_templates(
ids: List[str] = Query(..., description="模板ID列表"),
db: AsyncSession = Depends(get_db),
):
"""批量删除合同模板"""
count = 0
for template_id in ids:
template = await ContractTemplateService.get_by_id(db, template_id)
if template:
await ContractTemplateService.delete(db, template)
count += 1
return {"count": count}
@router.post("/template/{template_id}/publish", response_model=ContractTemplateOut, summary="发布模板")
async def publish_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""发布合同模板"""
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
template = await ContractTemplateService.publish(db, template)
return await _build_template_out(template)
@router.post("/template/{template_id}/disable", response_model=ContractTemplateOut, summary="停用模板")
async def disable_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""停用合同模板"""
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
template = await ContractTemplateService.disable(db, template)
return await _build_template_out(template)
@router.post("/template/{template_id}/copy", response_model=ContractTemplateOut, summary="复制模板")
async def copy_template(
request: Request,
template_id: str,
new_code: str = Query(..., alias="newCode", description="新模板编码"),
new_name: str = Query(None, alias="newName", description="新模板名称"),
db: AsyncSession = Depends(get_db),
):
"""复制合同模板"""
user_id = request.state.user_id
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
# 检查新编码是否已存在
if await ContractTemplateService.get_by_code(db, new_code):
raise HTTPException(status_code=400, detail=f"模板编码 {new_code} 已存在")
new_template = await ContractTemplateService.copy(
db=db,
template=template,
new_code=new_code,
new_name=new_name,
creator_id=user_id,
)
return await _build_template_out(new_template)
# ============ 合同实例 API ============
@router.get("/instance/generate-no", summary="生成合同编号")
async def generate_contract_no():
"""生成一个新的合同编号"""
return {"contract_no": ContractInstanceService.generate_contract_no()}
@router.get("/instance/check-no", summary="检查合同编号唯一性")
async def check_contract_no(
contract_no: str = Query(..., alias="contractNo", description="合同编号"),
exclude_id: str = Query(None, alias="excludeId", description="排除的合同ID"),
db: AsyncSession = Depends(get_db),
):
"""检查合同编号是否已存在"""
exists = await ContractInstanceService.check_contract_no_exists(db, contract_no, exclude_id)
return {"exists": exists, "valid": not exists}
@router.get("/instance/list", response_model=PaginatedResponse[ContractInstanceListItem], summary="合同列表")
async def list_instances(
title: str = Query(None, description="合同标题"),
contract_no: str = Query(None, alias="contractNo", description="合同编号"),
status: str = Query(None, description="状态"),
template_id: str = Query(None, alias="templateId", description="模板ID"),
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""分页查询合同实例列表"""
result = await ContractInstanceService.get_list(
db=db,
page=page,
page_size=page_size,
title=title,
contract_no=contract_no,
status=status,
template_id=template_id,
)
items = [await _build_instance_list_item(db, item) for item in result["items"]]
return PaginatedResponse(
items=items,
total=result["total"],
)
@router.get("/instance/my", response_model=PaginatedResponse[ContractInstanceListItem], summary="我的合同")
async def list_my_instances(
request: Request,
title: str = Query(None, description="合同标题"),
status: str = Query(None, description="状态"),
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""获取我创建的合同"""
user_id = request.state.user_id
result = await ContractInstanceService.get_list(
db=db,
page=page,
page_size=page_size,
title=title,
status=status,
creator_id=user_id,
)
items = [await _build_instance_list_item(db, item) for item in result["items"]]
return PaginatedResponse(
items=items,
total=result["total"],
)
@router.get("/instance/{instance_id}", response_model=ContractInstanceOut, summary="合同详情")
async def get_instance(
instance_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取合同详情"""
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
return await _build_instance_out(db, instance)
@router.post("/instance", response_model=ContractInstanceOut, summary="创建合同")
async def create_instance(
request: Request,
data: ContractInstanceCreate,
db: AsyncSession = Depends(get_db),
):
"""创建合同实例"""
user_id = request.state.user_id
try:
instance = await ContractInstanceService.create(
db=db,
template_id=data.template_id,
title=data.title,
creator_id=user_id,
variable_data=data.variable_data,
contract_no=data.contract_no,
)
return await _build_instance_out(db, instance)
except Exception as e:
logger.exception("创建合同失败")
raise HTTPException(status_code=400, detail=str(e))
@router.put("/instance/{instance_id}", response_model=ContractInstanceOut, summary="更新合同")
async def update_instance(
request: Request,
instance_id: str,
data: ContractInstanceUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新合同"""
user_id = request.state.user_id
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
try:
instance = await ContractInstanceService.update(
db=db,
instance=instance,
title=data.title,
variable_data=data.variable_data,
signature_data=data.signature_data,
operator_id=user_id,
)
return await _build_instance_out(db, instance)
except Exception as e:
logger.exception("更新合同失败")
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/instance/{instance_id}", summary="删除合同")
async def delete_instance(
instance_id: str,
db: AsyncSession = Depends(get_db),
):
"""删除合同"""
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
await ContractInstanceService.delete(db, instance)
return {"message": "删除成功"}
@router.post("/instance/{instance_id}/submit", response_model=ContractInstanceOut, summary="提交合同")
async def submit_instance(
request: Request,
instance_id: str,
db: AsyncSession = Depends(get_db),
):
"""提交合同(进入待签署状态)"""
user_id = request.state.user_id
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
instance = await ContractInstanceService.submit(db, instance, user_id)
return await _build_instance_out(db, instance)
@router.post("/instance/{instance_id}/complete", response_model=ContractInstanceOut, summary="完成合同")
async def complete_instance(
request: Request,
instance_id: str,
db: AsyncSession = Depends(get_db),
):
"""完成合同"""
user_id = request.state.user_id
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
instance = await ContractInstanceService.complete(db, instance, user_id)
return await _build_instance_out(db, instance)
@router.post("/instance/{instance_id}/cancel", summary="取消合同")
async def cancel_instance(
request: Request,
instance_id: str,
comment: str = Query("", description="取消原因"),
db: AsyncSession = Depends(get_db),
):
"""取消合同"""
user_id = request.state.user_id
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
await ContractInstanceService.cancel(db, instance, user_id, comment)
return {"message": "取消成功"}
@router.get("/instance/{instance_id}/logs", response_model=List[ContractLogOut], summary="合同日志")
async def get_instance_logs(
instance_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取合同操作日志"""
logs = await ContractInstanceService.get_logs(db, instance_id)
return [await _build_log_out(db, log) for log in logs]
# ============ 签署 API ============
@router.post("/instance/{instance_id}/sign", response_model=ContractSignatureOut, summary="签署合同")
async def sign_instance(
request: Request,
instance_id: str,
data: ContractSignatureCreate,
db: AsyncSession = Depends(get_db),
):
"""签署合同"""
user_id = request.state.user_id
user_name = getattr(request.state, "username", "")
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
# 获取客户端信息
sign_ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "")
sign_device = request.headers.get("User-Agent", "")
try:
signature = await ContractSignatureService.sign(
db=db,
instance=instance,
element_id=data.element_id,
signature_image=data.signature_image,
signer_id=user_id,
signer_name=data.signer_name or user_name,
sign_ip=sign_ip,
sign_device=sign_device,
)
return _build_signature_out(signature)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/instance/{instance_id}/signatures", response_model=List[ContractSignatureOut], summary="签署记录")
async def get_instance_signatures(
instance_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取合同签署记录"""
signatures = await ContractSignatureService.get_signatures(db, instance_id)
return [_build_signature_out(sig) for sig in signatures]
# ============ 手机签署 API ============
@router.post("/instance/{instance_id}/mobile-sign-token", response_model=MobileSignTokenOut, summary="生成手机签署二维码")
async def create_mobile_sign_token(
instance_id: str,
data: MobileSignTokenCreate,
db: AsyncSession = Depends(get_db),
):
"""生成手机签署令牌和二维码"""
instance = await ContractInstanceService.get_by_id(db, instance_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
if instance.status not in ["pending", "signing"]:
raise HTTPException(status_code=400, detail="合同状态不允许签署")
try:
sign_token = await MobileSignService.create_sign_token(
db=db,
instance=instance,
party_type=data.party_type,
signer_name=data.signer_name,
expire_minutes=data.expire_minutes,
)
# 只返回 token,由前端构建完整 URL
return {
"token": sign_token.token,
"sign_url": "", # 由前端构建
"expired_at": sign_token.expired_at,
"qrcode_data": "", # 由前端构建
}
except Exception as e:
logger.exception("生成签署令牌失败")
raise HTTPException(status_code=400, detail=str(e))
@router.get("/mobile/contract/{token}", response_model=MobileContractInfo, summary="获取移动端合同信息")
async def get_mobile_contract(
token: str,
db: AsyncSession = Depends(get_db),
):
"""根据令牌获取合同信息(无需登录)"""
sign_token = await MobileSignService.get_by_token(db, token)
if not sign_token:
raise HTTPException(status_code=404, detail="签署链接无效或已过期")
# 验证令牌
is_valid, error_msg = MobileSignService.validate_token(sign_token)
if not is_valid:
raise HTTPException(status_code=400, detail=error_msg)
# 获取合同
contract = await ContractInstanceService.get_by_id(db, sign_token.contract_id)
if not contract:
raise HTTPException(status_code=404, detail="合同不存在")
# 获取模板名称
template = await ContractTemplateService.get_by_id(db, contract.template_id)
template_name = template.name if template else ""
return {
"id": str(contract.id),
"contract_no": contract.contract_no,
"title": contract.title,
"status": contract.status,
"contract_config": contract.contract_config or {},
"variable_data": contract.variable_data or {},
"signature_data": contract.signature_data or {},
"party_type": sign_token.party_type,
"signer_name": sign_token.signer_name or "",
"template_name": template_name,
}
@router.post("/mobile/sign/{token}", response_model=ContractSignatureOut, summary="移动端签署")
async def mobile_sign(
request: Request,
token: str,
data: MobileSignRequest,
db: AsyncSession = Depends(get_db),
):
"""移动端签署合同(无需登录)"""
sign_token = await MobileSignService.get_by_token(db, token)
if not sign_token:
raise HTTPException(status_code=404, detail="签署链接无效或已过期")
# 验证令牌
is_valid, error_msg = MobileSignService.validate_token(sign_token)
if not is_valid:
raise HTTPException(status_code=400, detail=error_msg)
# 获取合同
instance = await ContractInstanceService.get_by_id(db, sign_token.contract_id)
if not instance:
raise HTTPException(status_code=404, detail="合同不存在")
# 获取客户端信息
sign_ip = request.headers.get("X-Forwarded-For", request.client.host if request.client else "")
sign_device = request.headers.get("User-Agent", "")
try:
signature = await MobileSignService.mobile_sign(
db=db,
sign_token=sign_token,
instance=instance,
element_id=data.element_id,
signature_image=data.signature_image,
signer_name=data.signer_name,
sign_ip=sign_ip,
sign_device=sign_device,
)
return _build_signature_out(signature)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -0,0 +1,129 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
合同管理数据模型
"""
from sqlalchemy import Column, String, Text, Integer, DateTime, Boolean, JSON
from app.base_model import BaseModel
class ContractTemplate(BaseModel):
"""合同模板"""
__tablename__ = "contract_template"
name = Column(String(100), nullable=False, comment="模板名称")
code = Column(String(100), unique=True, nullable=False, comment="模板编码")
category = Column(String(50), default="", comment="分类")
description = Column(Text, default="", comment="描述")
status = Column(String(20), default="draft", index=True, comment="状态: draft/published/disabled")
version = Column(Integer, default=1, comment="版本号")
# 模板配置(JSON格式存储页面设置、元素、变量等)
template_config = Column(JSON, default=dict, comment="模板配置")
# 缩略图
thumbnail = Column(Text, default="", comment="缩略图(base64)")
class ContractInstance(BaseModel):
"""合同实例(基于模板创建的合同)"""
__tablename__ = "contract_instance"
# 关联模板(逻辑外键)
template_id = Column(String(21), nullable=False, index=True, comment="合同模板ID")
# 合同信息
contract_no = Column(String(50), unique=True, nullable=False, comment="合同编号")
title = Column(String(200), nullable=False, comment="合同标题")
status = Column(String(20), default="draft", index=True, comment="状态: draft/pending/signing/completed/expired/canceled")
# 创建人(逻辑外键)
creator_id = Column(String(21), nullable=False, index=True, comment="创建人ID")
# 合同配置(继承自模板,可修改)
contract_config = Column(JSON, default=dict, comment="合同配置")
# 变量数据
variable_data = Column(JSON, default=dict, comment="变量数据")
# 签署数据
signature_data = Column(JSON, default=dict, comment="签署数据")
# 时间记录
created_at = Column(DateTime, nullable=True, comment="创建时间")
completed_at = Column(DateTime, nullable=True, comment="完成时间")
expired_at = Column(DateTime, nullable=True, comment="过期时间")
# PDF 文件
pdf_file = Column(Text, default="", comment="PDF文件路径")
class ContractSignature(BaseModel):
"""合同签署记录"""
__tablename__ = "contract_signature"
# 关联合同(逻辑外键)
contract_id = Column(String(21), nullable=False, index=True, comment="合同实例ID")
# 签署区信息
element_id = Column(String(50), nullable=False, comment="元素ID")
party_type = Column(String(20), default="party_a", comment="签署方类型: party_a/party_b/party_c/witness")
party_label = Column(String(50), default="", comment="签署方标签")
sign_type = Column(String(20), default="signature", comment="签署类型: signature/seal")
# 签署人(逻辑外键)
signer_id = Column(String(21), nullable=True, comment="签署人ID")
signer_name = Column(String(50), default="", comment="签署人姓名")
# 签署数据
signature_image = Column(Text, default="", comment="签名/印章图片(base64)")
status = Column(String(20), default="pending", comment="状态: pending/signed/rejected")
signed_at = Column(DateTime, nullable=True, comment="签署时间")
# IP 和设备信息
sign_ip = Column(String(50), default="", comment="签署IP")
sign_device = Column(String(200), default="", comment="签署设备")
class ContractSignToken(BaseModel):
"""合同签署令牌(用于手机扫码签署)"""
__tablename__ = "contract_sign_token"
# 关联合同(逻辑外键)
contract_id = Column(String(21), nullable=False, index=True, comment="合同实例ID")
# 令牌
token = Column(String(64), unique=True, nullable=False, comment="签署令牌")
# 签署方信息
party_type = Column(String(20), default="party_a", comment="签署方类型")
signer_name = Column(String(50), default="", comment="签署人姓名")
# 有效期
expired_at = Column(DateTime, nullable=False, comment="过期时间")
# 是否已使用
is_used = Column(Boolean, default=False, comment="是否已使用")
used_at = Column(DateTime, nullable=True, comment="使用时间")
class ContractLog(BaseModel):
"""合同操作日志"""
__tablename__ = "contract_log"
# 关联合同(逻辑外键)
contract_id = Column(String(21), nullable=False, index=True, comment="合同实例ID")
# 操作信息
action = Column(String(20), nullable=False, comment="操作类型: create/update/submit/sign/reject/cancel/complete/export/view")
# 操作人(逻辑外键)
operator_id = Column(String(21), nullable=False, comment="操作人ID")
# 操作详情
comment = Column(Text, default="", comment="备注")
extra_data = Column(JSON, default=dict, comment="额外数据")
# IP 信息
ip_address = Column(String(50), default="", comment="IP地址")
@@ -0,0 +1,212 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
合同管理 Schema 定义
"""
from datetime import datetime
from typing import Any, Dict, Optional
from pydantic import BaseModel, Field, ConfigDict
from app.base_schema import CSTDatetime
# ============ 合同模板 Schema ============
class ContractTemplateCreate(BaseModel):
"""创建合同模板"""
name: str = Field(..., max_length=100, description="模板名称")
code: str = Field(..., max_length=100, description="模板编码")
category: str = Field(default="", max_length=50, description="分类")
description: str = Field(default="", description="描述")
template_config: Dict[str, Any] = Field(default_factory=dict, description="模板配置")
class ContractTemplateUpdate(BaseModel):
"""更新合同模板"""
name: Optional[str] = Field(None, max_length=100, description="模板名称")
category: Optional[str] = Field(None, max_length=50, description="分类")
description: Optional[str] = Field(None, description="描述")
template_config: Optional[Dict[str, Any]] = Field(None, description="模板配置")
thumbnail: Optional[str] = Field(None, description="缩略图")
class ContractTemplateOut(BaseModel):
"""合同模板输出"""
id: str
name: str
code: str
category: str
description: str
status: str
version: int
template_config: Dict[str, Any]
thumbnail: str
sys_create_datetime: CSTDatetime
sys_update_datetime: CSTDatetime
model_config = ConfigDict(from_attributes=True)
class ContractTemplateListItem(BaseModel):
"""合同模板列表项"""
id: str
name: str
code: str
category: str
description: str
status: str
version: int
thumbnail: str
sys_create_datetime: CSTDatetime
sys_update_datetime: CSTDatetime
model_config = ConfigDict(from_attributes=True)
# ============ 合同实例 Schema ============
class ContractInstanceCreate(BaseModel):
"""创建合同实例"""
template_id: str = Field(..., description="模板ID")
title: str = Field(..., max_length=200, description="合同标题")
contract_no: Optional[str] = Field(None, max_length=50, description="合同编号(可选,不填则自动生成)")
variable_data: Dict[str, Any] = Field(default_factory=dict, description="变量数据")
class ContractInstanceUpdate(BaseModel):
"""更新合同实例"""
title: Optional[str] = Field(None, max_length=200, description="合同标题")
variable_data: Optional[Dict[str, Any]] = Field(None, description="变量数据")
signature_data: Optional[Dict[str, Any]] = Field(None, description="签署数据")
class ContractInstanceOut(BaseModel):
"""合同实例输出"""
id: str
contract_no: str
title: str
status: str
template_id: str
contract_config: Dict[str, Any]
variable_data: Dict[str, Any]
signature_data: Dict[str, Any]
created_at: Optional[CSTDatetime] = None
completed_at: Optional[CSTDatetime] = None
expired_at: Optional[CSTDatetime] = None
pdf_file: str
# 关联字段
template_name: str = ""
template_code: str = ""
creator_id: str = ""
creator_name: str = ""
model_config = ConfigDict(from_attributes=True)
class ContractInstanceListItem(BaseModel):
"""合同实例列表项"""
id: str
contract_no: str
title: str
status: str
created_at: Optional[CSTDatetime] = None
completed_at: Optional[CSTDatetime] = None
# 关联字段
template_name: str = ""
creator_name: str = ""
model_config = ConfigDict(from_attributes=True)
# ============ 签署记录 Schema ============
class ContractSignatureCreate(BaseModel):
"""创建签署记录"""
element_id: str = Field(..., description="元素ID")
signature_image: str = Field(..., description="签名图片(base64)")
signer_name: str = Field(default="", description="签署人姓名")
class ContractSignatureOut(BaseModel):
"""签署记录输出"""
id: str
element_id: str
party_type: str
party_label: str
sign_type: str
signer_id: Optional[str] = None
signer_name: str
signature_image: str
status: str
signed_at: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True)
# ============ 操作日志 Schema ============
class ContractLogOut(BaseModel):
"""操作日志输出"""
id: str
action: str
operator_id: str
comment: str
sys_create_datetime: CSTDatetime
# 关联字段
operator_name: str = ""
model_config = ConfigDict(from_attributes=True)
# ============ 手机签署 Schema ============
class MobileSignTokenCreate(BaseModel):
"""创建手机签署令牌"""
party_type: str = Field(default="party_a", description="签署方类型")
signer_name: str = Field(default="", description="签署人姓名")
expire_minutes: int = Field(default=30, description="有效期(分钟)")
class MobileSignTokenOut(BaseModel):
"""手机签署令牌输出"""
token: str
sign_url: str
expired_at: CSTDatetime
qrcode_data: str # 二维码内容
class MobileContractInfo(BaseModel):
"""移动端合同信息(简化版)"""
id: str
contract_no: str
title: str
status: str
contract_config: Dict[str, Any]
variable_data: Dict[str, Any]
signature_data: Dict[str, Any]
party_type: str = ""
signer_name: str = ""
template_name: str = ""
class MobileSignRequest(BaseModel):
"""移动端签署请求"""
element_id: str = Field(..., description="元素ID")
signature_image: str = Field(..., description="签名图片(base64)")
signer_name: str = Field(default="", description="签署人姓名")
# ============ 通用响应 ============
class MessageResponse(BaseModel):
"""消息响应"""
message: str
class CountResponse(BaseModel):
"""计数响应"""
count: int
@@ -0,0 +1,736 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
合同管理服务异步版本
"""
import secrets
import uuid
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from online_dev.contract.model import (
ContractInstance,
ContractLog,
ContractSignature,
ContractSignToken,
ContractTemplate,
)
logger = logging.getLogger(__name__)
class ContractTemplateService:
"""合同模板服务"""
@staticmethod
async def get_list(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
name: str = None,
code: str = None,
category: str = None,
status: str = None,
) -> Dict[str, Any]:
"""获取模板列表"""
conditions = [ContractTemplate.is_deleted == False]
if name:
conditions.append(ContractTemplate.name.ilike(f"%{name}%"))
if code:
conditions.append(ContractTemplate.code.ilike(f"%{code}%"))
if category:
conditions.append(ContractTemplate.category == category)
if status:
conditions.append(ContractTemplate.status == status)
# 获取总数
count_stmt = select(func.count(ContractTemplate.id)).where(and_(*conditions))
total_result = await db.execute(count_stmt)
total = total_result.scalar() or 0
# 获取列表
offset = (page - 1) * page_size
stmt = select(ContractTemplate).where(and_(*conditions)).order_by(
ContractTemplate.sort, ContractTemplate.sys_create_datetime.desc()
).offset(offset).limit(page_size)
result = await db.execute(stmt)
items = list(result.scalars().all())
return {
"items": items,
"total": total,
"page": page,
"pageSize": page_size,
}
@staticmethod
async def get_by_id(db: AsyncSession, template_id: str) -> Optional[ContractTemplate]:
"""根据ID获取模板"""
stmt = select(ContractTemplate).where(
ContractTemplate.id == template_id,
ContractTemplate.is_deleted == False
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
@staticmethod
async def get_by_code(db: AsyncSession, code: str) -> Optional[ContractTemplate]:
"""根据编码获取模板"""
stmt = select(ContractTemplate).where(
ContractTemplate.code == code,
ContractTemplate.is_deleted == False
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
@staticmethod
async def create(
db: AsyncSession,
name: str,
code: str,
category: str = "",
description: str = "",
template_config: Dict = None,
creator_id: str = None,
) -> ContractTemplate:
"""创建模板"""
template = ContractTemplate(
name=name,
code=code,
category=category,
description=description,
template_config=template_config or {},
sys_creator_id=creator_id,
sys_modifier_id=creator_id,
)
db.add(template)
await db.commit()
await db.refresh(template)
return template
@staticmethod
async def update(
db: AsyncSession,
template: ContractTemplate,
name: str = None,
category: str = None,
description: str = None,
template_config: Dict = None,
thumbnail: str = None,
) -> ContractTemplate:
"""更新模板"""
if name is not None:
template.name = name
if category is not None:
template.category = category
if description is not None:
template.description = description
if template_config is not None:
template.template_config = template_config
if thumbnail is not None:
template.thumbnail = thumbnail
await db.commit()
await db.refresh(template)
return template
@staticmethod
async def delete(db: AsyncSession, template: ContractTemplate):
"""删除模板(软删除)"""
template.is_deleted = True
await db.commit()
@staticmethod
async def publish(db: AsyncSession, template: ContractTemplate) -> ContractTemplate:
"""发布模板"""
template.status = "published"
template.version += 1
await db.commit()
await db.refresh(template)
return template
@staticmethod
async def disable(db: AsyncSession, template: ContractTemplate) -> ContractTemplate:
"""停用模板"""
template.status = "disabled"
await db.commit()
await db.refresh(template)
return template
@staticmethod
async def copy(
db: AsyncSession,
template: ContractTemplate,
new_code: str,
new_name: str = None,
creator_id: str = None,
) -> ContractTemplate:
"""复制模板"""
new_template = ContractTemplate(
name=new_name or f"{template.name}_副本",
code=new_code,
category=template.category,
description=template.description,
template_config=template.template_config,
thumbnail=template.thumbnail,
status="draft",
version=1,
sys_creator_id=creator_id,
sys_modifier_id=creator_id,
)
db.add(new_template)
await db.commit()
await db.refresh(new_template)
return new_template
@staticmethod
async def get_categories(db: AsyncSession) -> List[str]:
"""获取所有分类"""
stmt = select(ContractTemplate.category).where(
ContractTemplate.is_deleted == False,
ContractTemplate.category != ""
).distinct()
result = await db.execute(stmt)
return [row[0] for row in result.fetchall()]
class ContractInstanceService:
"""合同实例服务"""
@staticmethod
def generate_contract_no() -> str:
"""生成合同编号"""
now = datetime.now()
prefix = now.strftime("JZ%Y%m%d")
suffix = uuid.uuid4().hex[:6].upper()
return f"{prefix}{suffix}"
@staticmethod
async def check_contract_no_exists(
db: AsyncSession,
contract_no: str,
exclude_id: str = None
) -> bool:
"""检查合同编号是否已存在"""
conditions = [
ContractInstance.contract_no == contract_no,
ContractInstance.is_deleted == False
]
if exclude_id:
conditions.append(ContractInstance.id != exclude_id)
stmt = select(func.count(ContractInstance.id)).where(and_(*conditions))
result = await db.execute(stmt)
count = result.scalar() or 0
return count > 0
@staticmethod
async def get_list(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
title: str = None,
contract_no: str = None,
status: str = None,
template_id: str = None,
creator_id: str = None,
) -> Dict[str, Any]:
"""获取合同列表"""
conditions = [ContractInstance.is_deleted == False]
if title:
conditions.append(ContractInstance.title.ilike(f"%{title}%"))
if contract_no:
conditions.append(ContractInstance.contract_no.ilike(f"%{contract_no}%"))
if status:
conditions.append(ContractInstance.status == status)
if template_id:
conditions.append(ContractInstance.template_id == template_id)
if creator_id:
conditions.append(ContractInstance.creator_id == creator_id)
# 获取总数
count_stmt = select(func.count(ContractInstance.id)).where(and_(*conditions))
total_result = await db.execute(count_stmt)
total = total_result.scalar() or 0
# 获取列表
offset = (page - 1) * page_size
stmt = select(ContractInstance).where(and_(*conditions)).order_by(
ContractInstance.sys_create_datetime.desc()
).offset(offset).limit(page_size)
result = await db.execute(stmt)
items = list(result.scalars().all())
return {
"items": items,
"total": total,
"page": page,
"pageSize": page_size,
}
@staticmethod
async def get_by_id(db: AsyncSession, instance_id: str) -> Optional[ContractInstance]:
"""根据ID获取合同"""
stmt = select(ContractInstance).where(
ContractInstance.id == instance_id,
ContractInstance.is_deleted == False
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
@staticmethod
async def create(
db: AsyncSession,
template_id: str,
title: str,
creator_id: str,
variable_data: Dict = None,
contract_no: str = None,
) -> ContractInstance:
"""创建合同实例"""
# 获取模板
template = await ContractTemplateService.get_by_id(db, template_id)
if not template:
raise ValueError(f"模板不存在: {template_id}")
# 如果没有提供合同编号,自动生成
if not contract_no:
contract_no = ContractInstanceService.generate_contract_no()
# 检查合同编号唯一性
if await ContractInstanceService.check_contract_no_exists(db, contract_no):
raise ValueError(f"合同编号 {contract_no} 已存在")
instance = ContractInstance(
template_id=template_id,
contract_no=contract_no,
title=title,
creator_id=creator_id,
contract_config=template.template_config,
variable_data=variable_data or {},
created_at=datetime.now(),
sys_creator_id=creator_id,
sys_modifier_id=creator_id,
)
db.add(instance)
await db.flush()
# 记录日志
log = ContractLog(
contract_id=instance.id,
action="create",
operator_id=creator_id,
sys_creator_id=creator_id,
)
db.add(log)
await db.commit()
await db.refresh(instance)
return instance
@staticmethod
async def update(
db: AsyncSession,
instance: ContractInstance,
title: str = None,
variable_data: Dict = None,
signature_data: Dict = None,
operator_id: str = None,
) -> ContractInstance:
"""更新合同"""
if title is not None:
instance.title = title
if variable_data is not None:
instance.variable_data = variable_data
if signature_data is not None:
instance.signature_data = signature_data
instance.sys_modifier_id = operator_id
# 记录日志
if operator_id:
log = ContractLog(
contract_id=instance.id,
action="update",
operator_id=operator_id,
sys_creator_id=operator_id,
)
db.add(log)
await db.commit()
await db.refresh(instance)
return instance
@staticmethod
async def submit(
db: AsyncSession,
instance: ContractInstance,
operator_id: str
) -> ContractInstance:
"""提交合同(进入待签署状态)"""
instance.status = "pending"
instance.sys_modifier_id = operator_id
log = ContractLog(
contract_id=instance.id,
action="submit",
operator_id=operator_id,
sys_creator_id=operator_id,
)
db.add(log)
await db.commit()
await db.refresh(instance)
return instance
@staticmethod
async def complete(
db: AsyncSession,
instance: ContractInstance,
operator_id: str
) -> ContractInstance:
"""完成合同"""
instance.status = "completed"
instance.completed_at = datetime.now()
instance.sys_modifier_id = operator_id
log = ContractLog(
contract_id=instance.id,
action="complete",
operator_id=operator_id,
sys_creator_id=operator_id,
)
db.add(log)
await db.commit()
await db.refresh(instance)
return instance
@staticmethod
async def cancel(
db: AsyncSession,
instance: ContractInstance,
operator_id: str,
comment: str = ""
) -> ContractInstance:
"""取消合同"""
instance.status = "canceled"
instance.sys_modifier_id = operator_id
log = ContractLog(
contract_id=instance.id,
action="cancel",
operator_id=operator_id,
comment=comment,
sys_creator_id=operator_id,
)
db.add(log)
await db.commit()
await db.refresh(instance)
return instance
@staticmethod
async def delete(db: AsyncSession, instance: ContractInstance):
"""删除合同(软删除)"""
instance.is_deleted = True
await db.commit()
@staticmethod
async def get_logs(db: AsyncSession, instance_id: str) -> List[ContractLog]:
"""获取合同日志"""
stmt = select(ContractLog).where(
ContractLog.contract_id == instance_id
).order_by(ContractLog.sys_create_datetime)
result = await db.execute(stmt)
return list(result.scalars().all())
class ContractSignatureService:
"""合同签署服务"""
@staticmethod
async def sign(
db: AsyncSession,
instance: ContractInstance,
element_id: str,
signature_image: str,
signer_id: str,
signer_name: str = "",
sign_ip: str = "",
sign_device: str = "",
) -> ContractSignature:
"""签署合同"""
# 查找元素信息
element = None
for el in instance.contract_config.get("elements", []):
if el.get("id") == element_id:
element = el
break
if not element:
raise ValueError("签署区域不存在")
# 获取签署方信息
signature_config = element.get("signature", {})
party_type = signature_config.get("partyType", "party_a")
party_label = signature_config.get("partyLabel", "")
sign_type = "seal" if element.get("type") == "seal-zone" else "signature"
# 查找是否已存在签署记录
stmt = select(ContractSignature).where(
ContractSignature.contract_id == instance.id,
ContractSignature.element_id == element_id
)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
# 更新现有记录
existing.party_type = party_type
existing.party_label = party_label
existing.sign_type = sign_type
existing.signer_id = signer_id
existing.signer_name = signer_name
existing.signature_image = signature_image
existing.status = "signed"
existing.signed_at = datetime.now()
existing.sign_ip = sign_ip
existing.sign_device = sign_device
signature = existing
else:
# 创建新记录
signature = ContractSignature(
contract_id=instance.id,
element_id=element_id,
party_type=party_type,
party_label=party_label,
sign_type=sign_type,
signer_id=signer_id,
signer_name=signer_name,
signature_image=signature_image,
status="signed",
signed_at=datetime.now(),
sign_ip=sign_ip,
sign_device=sign_device,
sys_creator_id=signer_id,
)
db.add(signature)
# 更新合同签署数据
signature_data = instance.signature_data or {}
signature_data[element_id] = signature_image
instance.signature_data = signature_data
# 检查是否所有必须签署区域都已签署
all_signed = await ContractSignatureService.check_all_signed(instance)
if all_signed and instance.status == "pending":
instance.status = "signing"
# 记录日志
log = ContractLog(
contract_id=instance.id,
action="sign",
operator_id=signer_id,
comment=f"{party_label}签署",
extra_data={"element_id": element_id},
ip_address=sign_ip,
sys_creator_id=signer_id,
)
db.add(log)
await db.commit()
await db.refresh(signature)
return signature
@staticmethod
async def check_all_signed(instance: ContractInstance) -> bool:
"""检查是否所有必须签署区域都已签署"""
elements = instance.contract_config.get("elements", [])
signature_data = instance.signature_data or {}
for element in elements:
if element.get("type") in ["signature-zone", "seal-zone"]:
signature_config = element.get("signature", {})
if signature_config.get("required", True):
if element.get("id") not in signature_data:
return False
return True
@staticmethod
async def get_signatures(db: AsyncSession, instance_id: str) -> List[ContractSignature]:
"""获取合同签署记录"""
stmt = select(ContractSignature).where(
ContractSignature.contract_id == instance_id
).order_by(ContractSignature.sys_create_datetime)
result = await db.execute(stmt)
return list(result.scalars().all())
class MobileSignService:
"""移动端签署服务"""
@staticmethod
async def create_sign_token(
db: AsyncSession,
instance: ContractInstance,
party_type: str = "party_a",
signer_name: str = "",
expire_minutes: int = 30,
) -> ContractSignToken:
"""创建签署令牌"""
# 生成随机令牌
token = secrets.token_urlsafe(32)
# 计算过期时间
expired_at = datetime.now() + timedelta(minutes=expire_minutes)
# 创建令牌记录
sign_token = ContractSignToken(
contract_id=instance.id,
token=token,
party_type=party_type,
signer_name=signer_name,
expired_at=expired_at,
)
db.add(sign_token)
await db.commit()
await db.refresh(sign_token)
return sign_token
@staticmethod
async def get_by_token(db: AsyncSession, token: str) -> Optional[ContractSignToken]:
"""根据令牌获取签署令牌"""
stmt = select(ContractSignToken).where(
ContractSignToken.token == token,
ContractSignToken.is_used == False
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
@staticmethod
def validate_token(sign_token: ContractSignToken) -> Tuple[bool, str]:
"""验证令牌有效性"""
if sign_token.is_used:
return False, "该签署链接已被使用"
if sign_token.expired_at < datetime.now():
return False, "该签署链接已过期"
return True, ""
@staticmethod
async def mobile_sign(
db: AsyncSession,
sign_token: ContractSignToken,
instance: ContractInstance,
element_id: str,
signature_image: str,
signer_name: str = "",
sign_ip: str = "",
sign_device: str = "",
) -> ContractSignature:
"""移动端签署"""
# 查找元素信息
element = None
for el in instance.contract_config.get("elements", []):
if el.get("id") == element_id:
element = el
break
if not element:
raise ValueError("签署区域不存在")
# 获取签署方信息
signature_config = element.get("signature", {})
party_type = signature_config.get("partyType", "party_a")
party_label = signature_config.get("partyLabel", "")
sign_type = "seal" if element.get("type") == "seal-zone" else "signature"
# 使用令牌中的签署人姓名(如果提供)
final_signer_name = signer_name or sign_token.signer_name
# 截断设备信息,避免超出数据库字段长度
device_info = (sign_device + " (Mobile)")[:200] if sign_device else "Mobile"
# 查找是否已存在签署记录
stmt = select(ContractSignature).where(
ContractSignature.contract_id == instance.id,
ContractSignature.element_id == element_id
)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
# 更新现有记录
existing.party_type = party_type
existing.party_label = party_label
existing.sign_type = sign_type
existing.signer_id = None
existing.signer_name = final_signer_name
existing.signature_image = signature_image
existing.status = "signed"
existing.signed_at = datetime.now()
existing.sign_ip = sign_ip
existing.sign_device = device_info
signature = existing
else:
# 创建新记录
signature = ContractSignature(
contract_id=instance.id,
element_id=element_id,
party_type=party_type,
party_label=party_label,
sign_type=sign_type,
signer_id=None,
signer_name=final_signer_name,
signature_image=signature_image,
status="signed",
signed_at=datetime.now(),
sign_ip=sign_ip,
sign_device=device_info,
)
db.add(signature)
# 更新合同签署数据
signature_data = instance.signature_data or {}
signature_data[element_id] = signature_image
instance.signature_data = signature_data
# 检查是否所有必须签署区域都已签署
all_signed = await ContractSignatureService.check_all_signed(instance)
if all_signed and instance.status == "pending":
instance.status = "signing"
# 标记令牌已使用
sign_token.is_used = True
sign_token.used_at = datetime.now()
# 记录日志
log = ContractLog(
contract_id=instance.id,
action="sign",
operator_id=instance.creator_id,
comment=f"{party_label}通过手机签署 (签署人: {final_signer_name})",
extra_data={"element_id": element_id, "mobile": True},
ip_address=sign_ip,
sys_creator_id=instance.creator_id,
)
db.add(log)
await db.commit()
await db.refresh(signature)
return signature
@@ -0,0 +1,5 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
文档生成模块
"""
@@ -0,0 +1,819 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
文档生成API接口
"""
from typing import Optional, List
from datetime import datetime
import json
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
import io
from app.database import get_db
from app.config import settings
from app.base_schema import PaginatedResponse, ResponseModel
from online_dev.document_generator.schema import (
DocumentTemplateCreate, DocumentTemplateUpdate, DocumentTemplateOut, DocumentTemplateListOut,
DocumentTemplateImportCheckIn, DocumentTemplateImportCheckOut, DocumentTemplateImportIn,
GeneratedDocumentOut, GenerateDocumentIn, BatchGenerateDocumentIn, PreviewDocumentIn,
TemplateCategory,
)
from online_dev.document_generator.service import (
DocumentTemplateService, GeneratedDocumentService
)
from online_dev.document_generator.generator import pdf_generator
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/document-generator", tags=["文档生成"])
# ==================== 文档模板 ====================
@router.get("/templates", response_model=PaginatedResponse[DocumentTemplateListOut], summary="获取模板列表")
async def get_template_list(
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=settings.PAGE_SIZE, ge=1, le=settings.PAGE_MAX_SIZE, alias="pageSize"),
application_id: Optional[str] = Query(default=None, alias="applicationId", description="应用ID"),
category: Optional[str] = Query(default=None, description="分类"),
status: Optional[str] = Query(default=None, description="状态"),
form_code: Optional[str] = Query(default=None, alias="formCode", description="表单编码"),
workflow_code: Optional[str] = Query(default=None, alias="workflowCode", description="流程编码"),
keyword: Optional[str] = Query(default=None, description="关键词"),
is_builtin: Optional[bool] = Query(default=None, alias="isBuiltin", description="是否内置"),
db: AsyncSession = Depends(get_db),
):
"""获取文档模板列表"""
items, total = await DocumentTemplateService.get_list(
db,
page=page,
page_size=page_size,
application_id=application_id,
category=category,
status=status,
form_code=form_code,
workflow_code=workflow_code,
keyword=keyword,
is_builtin=is_builtin,
)
return PaginatedResponse(items=items, total=total)
@router.get("/templates/categories", response_model=List[TemplateCategory], summary="获取模板分类")
async def get_template_categories(
application_id: Optional[str] = Query(default=None, alias="applicationId"),
db: AsyncSession = Depends(get_db),
):
"""获取模板分类统计"""
return await DocumentTemplateService.get_categories(db, application_id)
@router.get("/templates/builtin", response_model=List[DocumentTemplateListOut], summary="获取内置模板")
async def get_builtin_templates(
db: AsyncSession = Depends(get_db),
):
"""获取内置模板列表"""
items, _ = await DocumentTemplateService.get_list(db, page=1, page_size=100, is_builtin=True)
return items
@router.get("/templates/by-form/{form_code}", response_model=List[DocumentTemplateListOut], summary="根据表单编码获取模板")
async def get_templates_by_form_code(
form_code: str,
db: AsyncSession = Depends(get_db),
):
"""根据表单编码获取绑定的已发布单据模板列表"""
templates = await DocumentTemplateService.get_by_form_code(db, form_code)
return templates
@router.get("/templates/check-form/{form_code}", response_model=ResponseModel, summary="检查表单是否绑定模板")
async def check_form_has_templates(
form_code: str,
db: AsyncSession = Depends(get_db),
):
"""检查表单是否绑定了已发布的单据模板"""
has_templates = await DocumentTemplateService.has_templates_by_form_code(db, form_code)
return ResponseModel(message="success", data={"hasTemplates": has_templates})
@router.post("/templates", response_model=DocumentTemplateOut, summary="创建模板")
async def create_template(
data: DocumentTemplateCreate,
db: AsyncSession = Depends(get_db),
):
"""创建文档模板"""
# 检查编码唯一性
existing = await DocumentTemplateService.get_by_code(db, data.code)
if existing:
raise HTTPException(status_code=400, detail="模板编码已存在")
template = await DocumentTemplateService.create(db, data)
return template
@router.get("/templates/{template_id}", response_model=DocumentTemplateOut, summary="获取模板详情")
async def get_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取模板详情"""
template = await DocumentTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return template
@router.get("/templates/code/{code}", response_model=DocumentTemplateOut, summary="根据编码获取模板")
async def get_template_by_code(
code: str,
db: AsyncSession = Depends(get_db),
):
"""根据编码获取模板"""
template = await DocumentTemplateService.get_by_code(db, code)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return template
@router.put("/templates/{template_id}", response_model=DocumentTemplateOut, summary="更新模板")
async def update_template(
template_id: str,
data: DocumentTemplateUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新模板"""
template = await DocumentTemplateService.update(db, template_id, data)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return template
@router.delete("/templates/{template_id}", response_model=ResponseModel, summary="删除模板")
async def delete_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""删除模板"""
template = await DocumentTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
if template.is_builtin:
raise HTTPException(status_code=400, detail="内置模板不能删除")
await DocumentTemplateService.delete(db, template_id)
return ResponseModel(message="删除成功")
@router.post("/templates/{template_id}/publish", response_model=DocumentTemplateOut, summary="发布模板")
async def publish_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""发布模板"""
template = await DocumentTemplateService.publish(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return template
@router.post("/templates/{template_id}/unpublish", response_model=DocumentTemplateOut, summary="取消发布")
async def unpublish_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""取消发布模板"""
template = await DocumentTemplateService.unpublish(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return template
@router.post("/templates/{template_id}/copy", response_model=DocumentTemplateOut, summary="复制模板")
async def copy_template(
template_id: str,
new_code: str = Query(..., alias="newCode", description="新编码"),
new_name: str = Query(..., alias="newName", description="新名称"),
db: AsyncSession = Depends(get_db),
):
"""复制模板"""
# 检查新编码唯一性
existing = await DocumentTemplateService.get_by_code(db, new_code)
if existing:
raise HTTPException(status_code=400, detail="模板编码已存在")
template = await DocumentTemplateService.copy(db, template_id, new_code, new_name)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return template
@router.get("/templates/{template_id}/export", summary="导出单据模板配置")
async def export_template_config(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""导出单据模板配置为 JSON 文件"""
config = await DocumentTemplateService.export_config(db, template_id)
if not config:
raise HTTPException(status_code=404, detail="模板不存在")
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"'
},
)
@router.post(
"/templates/import/check",
response_model=DocumentTemplateImportCheckOut,
summary="单据模板导入预检查",
)
async def check_import_template_config(
data: DocumentTemplateImportCheckIn,
db: AsyncSession = Depends(get_db),
):
"""导入预检查:检查模板编码是否冲突"""
return await DocumentTemplateService.check_import(db, data.code)
@router.post(
"/templates/import",
response_model=DocumentTemplateOut,
summary="导入单据模板配置",
)
async def import_template_config(
data: DocumentTemplateImportIn,
db: AsyncSession = Depends(get_db),
):
"""导入单据模板配置"""
try:
return await DocumentTemplateService.import_config(db, data.model_dump())
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# ==================== 文档生成 ====================
@router.post("/generate", response_model=GeneratedDocumentOut, summary="生成文档")
async def generate_document(
data: GenerateDocumentIn,
db: AsyncSession = Depends(get_db),
):
"""生成文档"""
from core.file_manager.service import FileManagerService
# 获取模板
template = await DocumentTemplateService.get_by_id(db, data.template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
if template.status != "published":
raise HTTPException(status_code=400, detail="模板未发布")
# 删除同模板+同表单数据的旧单据(每个模板只保留一个单据)
if data.form_data_id:
existing_docs = await GeneratedDocumentService.get_by_template_and_form_data(
db, data.template_id, data.form_data_id
)
for old_doc in existing_docs:
# 删除关联的文件
if old_doc.file_id:
try:
await FileManagerService.delete_item(db, old_doc.file_id, hard=True, is_superuser=True)
except Exception as e:
logger.warning(f"删除旧单据文件失败: {e}")
# 删除单据记录
await GeneratedDocumentService.delete(db, old_doc.id, hard=True, auto_commit=False)
# 加载表单数据
form_data = {}
if data.form_data_id and template.form_code:
form_data = await _load_form_data(db, template.form_code, data.form_data_id)
# 加载流程实例数据
if data.instance_id:
instance_data = await _load_instance_data(db, data.instance_id)
form_data.update(instance_data)
# 添加签名文件URL
if data.signature_file_ids:
for field_name, file_id in data.signature_file_ids.items():
# 获取文件URL
file_info = await FileManagerService.get_by_id(db, file_id)
if file_info:
form_data[field_name] = file_info.url or ""
# 执行计算规则(聚合字段和计算字段)
template_data = dict(form_data)
if template.calculation_rules:
try:
from online_dev.document_generator.calculation_engine import CalculationEngine
logger.info(f"开始执行计算规则: {template.calculation_rules}")
calculated_values = await CalculationEngine.calculate_all(
template.calculation_rules,
form_data
)
# 将计算结果合并到模板数据中
template_data.update(calculated_values)
logger.info(f"生成文档计算完成,计算结果: {calculated_values}")
except Exception as calc_error:
logger.error(f"生成文档计算规则执行失败: {calc_error}", exc_info=True)
# 生成 PDF
try:
page_config = {
"size": template.page_size,
"orientation": template.page_orientation,
"margin": template.page_margin or {"top": 20, "right": 20, "bottom": 20, "left": 20},
}
logger.info(
f"[生成文档] 开始生成PDF | template_id={template.id}, "
f"template_type={template.template_type}, data_keys={list(template_data.keys())}, "
f"page_config={page_config}"
)
pdf_bytes = pdf_generator.generate(
template_type=template.template_type,
template_content=template.template_content or "",
data=template_data,
css=template.template_css,
page_config=page_config,
)
logger.info(f"[生成文档] PDF生成成功 | template_id={template.id}, 大小={len(pdf_bytes)} bytes")
except Exception as e:
logger.error(
f"[生成文档] PDF生成失败 | template_id={template.id}, "
f"template_type={template.template_type}, error={type(e).__name__}: {e}",
exc_info=True,
)
raise HTTPException(status_code=500, detail=f"PDF生成失败: {str(e)}")
# 获取页数
page_count = pdf_generator.get_page_count(pdf_bytes)
# 保存文件
document_name = data.document_name or f"{template.name}_{datetime.now().strftime('%Y%m%d%H%M%S')}"
filename = f"{document_name}.pdf"
file_record = await FileManagerService.upload_file(
db=db,
file_content=pdf_bytes,
filename=filename,
file_size=len(pdf_bytes),
parent_id=None,
is_public=False,
source="document",
)
# 创建文档记录
document = await GeneratedDocumentService.create_document(
db=db,
template=template,
file_id=file_record.id,
file_size=len(pdf_bytes),
page_count=page_count,
document_name=document_name,
form_data_id=data.form_data_id,
instance_id=data.instance_id,
generate_type="manual",
)
return document
@router.post("/preview", summary="预览文档")
async def preview_document(
data: PreviewDocumentIn,
db: AsyncSession = Depends(get_db),
):
"""预览文档(返回 PDF 流)
支持两种方式
1. 传入 template_id从数据库读取模板
2. 传入 template_content直接使用传入的 JSON 配置无需保存
"""
template_content = ""
template_type = "designer"
template_css = None
page_config = {"top": 20, "right": 20, "bottom": 20, "left": 20}
if data.template_content:
# 直接使用传入的 JSON 配置
template_content = data.template_content
# 从 JSON 中解析页面配置
try:
import json
config = json.loads(data.template_content)
page_config = {
"size": config.get("pageSize", "A4"),
"orientation": config.get("pageOrientation", "portrait"),
"margin": config.get("pageMargin", {"top": 20, "right": 20, "bottom": 20, "left": 20}),
"customPageWidth": config.get("customPageWidth"),
"customPageHeight": config.get("customPageHeight"),
"showPageNumber": config.get("showPageNumber"),
"pageNumberPosition": config.get("pageNumberPosition"),
"pageNumberAlign": config.get("pageNumberAlign"),
"pageNumberFormat": config.get("pageNumberFormat"),
"pageNumberFontSize": config.get("pageNumberFontSize"),
"pageNumberColor": config.get("pageNumberColor"),
}
except json.JSONDecodeError:
pass
elif data.template_id:
# 从数据库读取模板
template = await DocumentTemplateService.get_by_id(db, data.template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
template_content = template.template_content or ""
template_type = template.template_type
template_css = template.template_css
page_config = {
"size": template.page_size,
"orientation": template.page_orientation,
"margin": template.page_margin or {"top": 20, "right": 20, "bottom": 20, "left": 20},
}
else:
raise HTTPException(status_code=400, detail="请提供 template_id 或 template_content")
# 加载数据
form_data = data.test_data or {}
if data.template_id:
template = await DocumentTemplateService.get_by_id(db, data.template_id)
if not form_data and data.form_data_id and template and template.form_code:
form_data = await _load_form_data(db, template.form_code, data.form_data_id)
if data.instance_id:
instance_data = await _load_instance_data(db, data.instance_id)
form_data.update(instance_data)
# 执行计算规则
template_data = dict(form_data)
calculation_rules = None
# 优先使用请求中的 calculation_rules(用于未保存的模板预览)
if data.calculation_rules:
calculation_rules = data.calculation_rules
logger.info(f"使用请求中的计算规则: {calculation_rules}")
# 否则从模板中获取
elif data.template_id:
template = await DocumentTemplateService.get_by_id(db, data.template_id)
if template and template.calculation_rules:
calculation_rules = template.calculation_rules
logger.info(f"使用模板中的计算规则: {calculation_rules}")
# 执行计算
if calculation_rules:
try:
from online_dev.document_generator.calculation_engine import CalculationEngine
logger.info(f"开始执行计算规则")
calculated_values = await CalculationEngine.calculate_all(
calculation_rules,
form_data
)
# 将计算结果合并到模板数据中
template_data.update(calculated_values)
logger.info(f"预览计算完成,计算结果: {calculated_values}")
except Exception as calc_error:
logger.error(f"预览计算规则执行失败: {calc_error}", exc_info=True)
else:
logger.info(f"没有计算规则需要执行")
# 生成 PDF
try:
logger.info(
f"[预览文档] 开始生成PDF | template_id={data.template_id}, "
f"template_type={template_type}, has_template_content={bool(data.template_content)}, "
f"data_keys={list(template_data.keys())}, has_css={template_css is not None}, "
f"page_config={page_config}"
)
pdf_bytes = pdf_generator.generate(
template_type=template_type,
template_content=template_content,
data=template_data,
css=template_css,
page_config=page_config,
)
logger.info(f"[预览文档] PDF生成成功 | 大小={len(pdf_bytes)} bytes")
except Exception as e:
logger.error(
f"[预览文档] PDF生成失败 | template_id={data.template_id}, "
f"template_type={template_type}, error={type(e).__name__}: {e}",
exc_info=True,
)
raise HTTPException(status_code=500, detail=f"PDF生成失败: {str(e)}")
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f"inline; filename=preview.pdf"}
)
@router.post("/preview-html", summary="预览HTML")
async def preview_html(
data: PreviewDocumentIn,
db: AsyncSession = Depends(get_db),
):
"""预览文档HTML(返回 HTML 字符串)
支持两种方式
1. 传入 template_id从数据库读取模板
2. 传入 template_content直接使用传入的 JSON 配置无需保存
"""
template_content = ""
template_type = "designer"
template_css = None
if data.template_content:
# 直接使用传入的 JSON 配置
template_content = data.template_content
elif data.template_id:
# 从数据库读取模板
template = await DocumentTemplateService.get_by_id(db, data.template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
template_content = template.template_content or ""
template_type = template.template_type
template_css = template.template_css
else:
raise HTTPException(status_code=400, detail="请提供 template_id 或 template_content")
# 加载数据
form_data = data.test_data or {}
if data.template_id:
template = await DocumentTemplateService.get_by_id(db, data.template_id)
if not form_data and data.form_data_id and template and template.form_code:
form_data = await _load_form_data(db, template.form_code, data.form_data_id)
if data.instance_id:
instance_data = await _load_instance_data(db, data.instance_id)
form_data.update(instance_data)
# 执行计算规则
template_data = dict(form_data)
calculation_rules = None
# 优先使用请求中的 calculation_rules(用于未保存的模板预览)
if data.calculation_rules:
calculation_rules = data.calculation_rules
# 否则从模板中获取
elif data.template_id:
template = await DocumentTemplateService.get_by_id(db, data.template_id)
if template and template.calculation_rules:
calculation_rules = template.calculation_rules
# 执行计算
if calculation_rules:
try:
from online_dev.document_generator.calculation_engine import CalculationEngine
calculated_values = await CalculationEngine.calculate_all(
calculation_rules,
form_data
)
# 将计算结果合并到模板数据中
template_data.update(calculated_values)
logger.info(f"预览HTML计算完成,计算结果: {calculated_values}")
except Exception as calc_error:
logger.error(f"预览HTML计算规则执行失败: {calc_error}", exc_info=True)
# 生成 HTML
try:
from online_dev.document_generator.generator import DesignerTemplateRenderer
if template_type == "designer":
html_content = DesignerTemplateRenderer.render(
template_content,
template_data,
template_css,
)
else:
from online_dev.document_generator.generator import TemplateEngine
engine = TemplateEngine()
html_content = engine.render_html(template_content, template_data)
return {"html": html_content}
except Exception as e:
raise HTTPException(status_code=500, detail=f"HTML生成失败: {str(e)}")
@router.post("/batch-generate", response_model=ResponseModel, summary="批量生成文档")
async def batch_generate_documents(
data: BatchGenerateDocumentIn,
db: AsyncSession = Depends(get_db),
):
"""批量生成文档"""
template = await DocumentTemplateService.get_by_id(db, data.template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
if template.status != "published":
raise HTTPException(status_code=400, detail="模板未发布")
success_count = 0
fail_count = 0
for form_data_id in data.form_data_ids:
try:
await generate_document(
GenerateDocumentIn(template_id=data.template_id, form_data_id=form_data_id),
db=db,
)
success_count += 1
except Exception:
fail_count += 1
return ResponseModel(
message=f"批量生成完成: 成功 {success_count} 个, 失败 {fail_count}",
data={"success": success_count, "fail": fail_count}
)
# ==================== 生成的文档 ====================
@router.get("/documents", response_model=PaginatedResponse[GeneratedDocumentOut], summary="获取文档列表")
async def get_document_list(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=settings.PAGE_SIZE, ge=1, le=settings.PAGE_MAX_SIZE, alias="pageSize"),
template_id: Optional[str] = Query(default=None, alias="templateId"),
form_code: Optional[str] = Query(default=None, alias="formCode"),
form_data_id: Optional[str] = Query(default=None, alias="formDataId"),
instance_id: Optional[str] = Query(default=None, alias="instanceId"),
keyword: Optional[str] = Query(default=None),
db: AsyncSession = Depends(get_db),
):
"""获取生成的文档列表"""
items, total = await GeneratedDocumentService.get_list(
db,
page=page,
page_size=page_size,
template_id=template_id,
form_code=form_code,
form_data_id=form_data_id,
instance_id=instance_id,
keyword=keyword,
)
return PaginatedResponse(items=items, total=total)
@router.get("/documents/{document_id}", response_model=GeneratedDocumentOut, summary="获取文档详情")
async def get_document(
document_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取文档详情"""
document = await GeneratedDocumentService.get_by_id(db, document_id)
if not document:
raise HTTPException(status_code=404, detail="文档不存在")
return document
@router.get("/documents/{document_id}/download", summary="下载文档")
async def download_document(
document_id: str,
db: AsyncSession = Depends(get_db),
):
"""下载文档"""
from core.file_manager.service import FileManagerService
document = await GeneratedDocumentService.get_by_id(db, document_id)
if not document:
raise HTTPException(status_code=404, detail="文档不存在")
# 获取文件内容
file_content = await FileManagerService.get_file_content(db, document.file_id)
if not file_content:
raise HTTPException(status_code=404, detail="文件不存在")
# 增加下载次数
await GeneratedDocumentService.increment_download_count(db, document_id)
filename = f"{document.document_name}.pdf"
return StreamingResponse(
io.BytesIO(file_content),
media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
@router.delete("/documents/{document_id}", response_model=ResponseModel, summary="删除文档")
async def delete_document(
document_id: str,
db: AsyncSession = Depends(get_db),
):
"""删除文档"""
document = await GeneratedDocumentService.get_by_id(db, document_id)
if not document:
raise HTTPException(status_code=404, detail="文档不存在")
await GeneratedDocumentService.delete(db, document_id)
return ResponseModel(message="删除成功")
@router.post("/documents/{document_id}/regenerate", response_model=GeneratedDocumentOut, summary="重新生成文档")
async def regenerate_document(
document_id: str,
db: AsyncSession = Depends(get_db),
):
"""重新生成文档"""
document = await GeneratedDocumentService.get_by_id(db, document_id)
if not document:
raise HTTPException(status_code=404, detail="文档不存在")
# 使用原文档的参数重新生成
new_document = await generate_document(
GenerateDocumentIn(
template_id=document.template_id,
form_data_id=document.form_data_id,
instance_id=document.instance_id,
document_name=document.document_name,
),
db=db,
)
# 删除旧文档
await GeneratedDocumentService.delete(db, document_id)
return new_document
# ==================== 辅助函数 ====================
async def _load_form_data(db: AsyncSession, form_code: str, form_data_id: str) -> dict:
"""加载表单数据(含关联选择器的显示名称)"""
try:
from online_dev.form_data_manager.service import FormDataService
service = await FormDataService.create_service(db, form_code)
data = await service.get(db, form_data_id)
return data or {}
except Exception as e:
logger.error(f"加载表单数据失败 form_code={form_code}, id={form_data_id}: {e}")
return {}
async def _load_instance_data(db: AsyncSession, instance_id: str) -> dict:
"""加载流程实例数据"""
try:
from online_dev.workflow.model import WorkflowInstance, WorkflowLog
from sqlalchemy import select
# 获取实例
stmt = select(WorkflowInstance).where(WorkflowInstance.id == instance_id)
result = await db.execute(stmt)
instance = result.scalar_one_or_none()
if not instance:
return {}
# 获取审批日志
log_stmt = select(WorkflowLog).where(
WorkflowLog.instance_id == instance_id
).order_by(WorkflowLog.sys_create_datetime)
log_result = await db.execute(log_stmt)
logs = list(log_result.scalars().all())
return {
"_instance": {
"id": instance.id,
"instance_no": instance.instance_no,
"title": instance.title,
"status": instance.status,
"started_at": instance.started_at,
"completed_at": instance.completed_at,
},
"_logs": [
{
"node_name": log.node_name,
"action": log.action,
"comment": log.comment,
"created_at": log.sys_create_datetime,
}
for log in logs
],
}
except Exception:
return {}
# ==================== 系统字体 ====================
@router.get("/fonts", summary="获取服务器支持的字体列表")
async def get_available_fonts():
"""获取服务器上可用的字体列表"""
from online_dev.document_generator.font_service import FontService
fonts = FontService.get_available_fonts()
return fonts
@@ -0,0 +1,546 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
单据计算引擎
支持
- 基本运算+, -, *, /, %, **
- 聚合函数sum, avg, max, min, count
- 内置函数round, abs, ceil, floor, numberToChinese
- 条件表达式if-else (三元运算符)
- 安全执行使用 simpleeval 库防止代码注入
"""
import logging
import math
import re
from decimal import Decimal, ROUND_HALF_UP
from typing import Any, Dict, List, Optional, Union
logger = logging.getLogger(__name__)
# 中文数字映射
CHINESE_DIGITS = ['', '', '', '', '', '', '', '', '', '']
CHINESE_UNITS = ['', '', '', '']
CHINESE_GROUP_UNITS = ['', '', '亿', '']
CHINESE_DECIMAL_UNITS = ['', '', '', '']
def number_to_chinese(num: Union[int, float, Decimal, str]) -> str:
"""
将数字转换为中文大写金额
Args:
num: 数字支持整数浮点数Decimal字符串
Returns:
中文大写金额字符串
Examples:
>>> number_to_chinese(1234.56)
'壹仟贰佰叁拾肆元伍角陆分'
>>> number_to_chinese(0)
'零元整'
"""
if num is None:
return ''
try:
# 转换为 Decimal 以保证精度
if isinstance(num, str):
num = Decimal(num.replace(',', ''))
elif isinstance(num, float):
num = Decimal(str(num))
elif isinstance(num, int):
num = Decimal(num)
elif not isinstance(num, Decimal):
num = Decimal(str(num))
except Exception:
return str(num)
# 处理负数
if num < 0:
return '' + number_to_chinese(-num)
# 处理零
if num == 0:
return '零元整'
# 分离整数和小数部分
num = num.quantize(Decimal('0.0001'), rounding=ROUND_HALF_UP)
str_num = str(num)
if '.' in str_num:
int_part, dec_part = str_num.split('.')
else:
int_part, dec_part = str_num, ''
result = ''
# 处理整数部分
if int_part and int(int_part) > 0:
int_part = int_part.lstrip('0') or '0'
length = len(int_part)
# 按4位分组处理
groups = []
while int_part:
groups.insert(0, int_part[-4:])
int_part = int_part[:-4]
for i, group in enumerate(groups):
group_result = ''
group = group.zfill(4)
for j, digit in enumerate(group):
d = int(digit)
unit_index = 3 - j
if d != 0:
group_result += CHINESE_DIGITS[d] + CHINESE_UNITS[unit_index]
else:
# 处理连续零
if group_result and not group_result.endswith(''):
group_result += ''
# 移除末尾的零
group_result = group_result.rstrip('')
if group_result:
group_unit_index = len(groups) - 1 - i
group_result += CHINESE_GROUP_UNITS[group_unit_index] if group_unit_index < len(CHINESE_GROUP_UNITS) else ''
result += group_result
result += ''
else:
result = '零元'
# 处理小数部分
if dec_part:
dec_part = dec_part[:4] # 最多4位小数
has_decimal = False
for i, digit in enumerate(dec_part):
d = int(digit)
if d != 0:
result += CHINESE_DIGITS[d] + CHINESE_DECIMAL_UNITS[i]
has_decimal = True
if not has_decimal:
result += ''
else:
result += ''
return result
def safe_get_value(data: Dict[str, Any], key: str, default: Any = 0) -> Any:
"""
安全地从字典中获取值支持点号路径
Args:
data: 数据字典
key: 键名支持点号路径 'order.total'
default: 默认值
Returns:
获取的值或默认值
"""
if not key:
return default
parts = key.split('.')
value = data
for part in parts:
if isinstance(value, dict):
value = value.get(part)
elif isinstance(value, list):
# 支持数组索引
if part.lstrip('-').isdigit():
index = int(part)
if -len(value) <= index < len(value):
value = value[index]
else:
return default
else:
return default
else:
return default
if value is None:
return default
return value if value is not None else default
class CalculationEngine:
"""单据计算引擎"""
# 支持的聚合函数
AGGREGATE_FUNCTIONS = {
'sum': lambda values: sum(v for v in values if v is not None),
'avg': lambda values: sum(v for v in values if v is not None) / len([v for v in values if v is not None]) if values else 0,
'max': lambda values: max((v for v in values if v is not None), default=0),
'min': lambda values: min((v for v in values if v is not None), default=0),
'count': lambda values: len([v for v in values if v is not None]),
}
# 安全的内置函数
SAFE_FUNCTIONS = {
'abs': abs,
'round': round,
'ceil': math.ceil,
'floor': math.floor,
'max': max,
'min': min,
'sum': sum,
'len': len,
'float': float,
'int': int,
'str': str,
'numberToChinese': number_to_chinese,
'toChineseAmount': number_to_chinese,
}
# 安全的运算符
SAFE_OPERATORS = {
'+', '-', '*', '/', '//', '%', '**',
'==', '!=', '<', '>', '<=', '>=',
'and', 'or', 'not',
'(', ')', ',', '.',
}
@classmethod
def evaluate_formula(cls, formula: str, context: Dict[str, Any]) -> Any:
"""
安全地执行计算公式
Args:
formula: 计算公式 "quantity * unit_price * (1 - discount_rate)"
context: 上下文数据
Returns:
计算结果
"""
if not formula:
return None
try:
# 替换公式中的变量
evaluated_formula = cls._replace_variables(formula, context)
logger.info(f"公式: {formula} -> 替换后: {evaluated_formula}")
# 使用 eval 执行(在受限环境中)
# 注意:这里使用了安全的方式,只允许特定的函数和运算
result = cls._safe_eval(evaluated_formula, context)
logger.info(f"公式执行结果: {result}")
return result
except Exception as e:
logger.error(f"公式计算失败: {formula}, 错误: {e}", exc_info=True)
return None
@classmethod
def _replace_variables(cls, formula: str, context: Dict[str, Any]) -> str:
"""
替换公式中的变量为实际值
支持的变量格式
- 简单变量quantity, unit_price
- 点号路径order.total, items[0].price
"""
# 匹配变量名(字母开头,可包含字母、数字、下划线、点号、方括号)
pattern = r'\b([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*|\[\d+\])*)\b'
def replace_var(match):
var_name = match.group(1)
# 跳过函数名
if var_name in cls.SAFE_FUNCTIONS:
return var_name
# 跳过 Python 关键字
if var_name in ('and', 'or', 'not', 'if', 'else', 'True', 'False', 'None'):
return var_name
# 获取变量值
value = safe_get_value(context, var_name, 0)
# 转换为字符串表示
if value is None:
return '0'
elif isinstance(value, str):
# 尝试转换为数字
try:
return str(float(value))
except ValueError:
return f'"{value}"'
elif isinstance(value, bool):
return str(value)
elif isinstance(value, (int, float, Decimal)):
return str(float(value))
else:
return '0'
return re.sub(pattern, replace_var, formula)
@classmethod
def _safe_eval(cls, expression: str, context: Dict[str, Any]) -> Any:
"""
安全地执行表达式
使用受限的 eval 环境只允许特定的函数和运算
"""
# 构建安全的执行环境
safe_globals = {
'__builtins__': {},
**cls.SAFE_FUNCTIONS,
}
# 添加上下文数据
safe_locals = dict(context)
try:
result = eval(expression, safe_globals, safe_locals)
return result
except Exception as e:
logger.warning(f"表达式执行失败: {expression}, 错误: {e}")
raise
@classmethod
def calculate_aggregation(
cls,
data: Dict[str, Any],
source: str,
field: str,
function: str
) -> Any:
"""
计算聚合值
Args:
data: 数据字典
source: 数据源子表名
field: 聚合字段
function: 聚合函数名
Returns:
聚合结果
"""
logger.info(f"聚合计算开始: source={source}, field={field}, function={function}")
logger.info(f"数据中的顶层键: {list(data.keys())}")
# 获取子表数据
sub_table_data = None
# 1. 优先从 sub_tables 中查找(表单数据的标准结构)
if 'sub_tables' in data and isinstance(data['sub_tables'], dict):
sub_tables = data['sub_tables']
logger.info(f"sub_tables 中的键: {list(sub_tables.keys())}")
# 直接匹配
if source in sub_tables:
sub_table_data = sub_tables[source]
logger.info(f"从 sub_tables 中直接匹配到 ({source}): 找到 {len(sub_table_data) if isinstance(sub_table_data, list) else 0}")
else:
# 尝试模糊匹配(source 可能是字段名,sub_tables 的键可能是表名)
# 例如:source='product_details'sub_tables 键可能是 'fd_product_details' 或 'contract_product_details'
for key in sub_tables.keys():
if source in key or key in source or key.endswith(f'_{source}') or key.endswith(source):
sub_table_data = sub_tables[key]
logger.info(f"从 sub_tables 中模糊匹配到 ({source} -> {key}): 找到 {len(sub_table_data) if isinstance(sub_table_data, list) else 0}")
break
# 2. 如果 sub_tables 中没有,尝试直接从顶层获取
if not sub_table_data:
sub_table_data = safe_get_value(data, source, [])
logger.info(f"从顶层获取子表数据 ({source}): {type(sub_table_data)}")
if not isinstance(sub_table_data, list):
logger.warning(f"聚合数据源不是数组: {source}, 实际类型: {type(sub_table_data)}")
return 0
if len(sub_table_data) == 0:
logger.warning(f"聚合数据源为空数组: {source}")
return 0
# 提取字段值
values = []
for i, item in enumerate(sub_table_data):
if isinstance(item, dict):
logger.info(f"子表第{i}行的键: {list(item.keys())}")
value = safe_get_value(item, field, None)
logger.info(f"子表第{i}行的 {field} 值: {value}")
if value is not None:
try:
values.append(float(value))
except (ValueError, TypeError) as e:
logger.warning(f"无法转换为数字: {value}, 错误: {e}")
logger.info(f"提取到的数值列表: {values}")
# 执行聚合函数
agg_func = cls.AGGREGATE_FUNCTIONS.get(function.lower())
if not agg_func:
logger.warning(f"不支持的聚合函数: {function}")
return 0
try:
result = agg_func(values)
logger.info(f"聚合计算结果: {result}")
return result
except Exception as e:
logger.warning(f"聚合计算失败: {e}")
return 0
@classmethod
def format_value(
cls,
value: Any,
format_type: str = 'number',
decimal_places: int = 2
) -> Any:
"""
格式化计算结果
Args:
value: 原始值
format_type: 格式化类型 (number/money/percent/chinese)
decimal_places: 小数位数
Returns:
格式化后的值字符串保留指定小数位数
"""
if value is None:
return None
try:
num_value = float(value)
except (ValueError, TypeError):
return value
if format_type == 'chinese':
return number_to_chinese(num_value)
elif format_type == 'percent':
# 百分比:乘以100后保留指定小数位,返回格式化字符串
percent_value = num_value * 100
if decimal_places == 0:
return str(int(round(percent_value)))
return f"{percent_value:.{decimal_places}f}"
else:
# number 和 money:保留指定小数位,返回格式化字符串
if decimal_places == 0:
return str(int(round(num_value)))
return f"{num_value:.{decimal_places}f}"
@classmethod
async def calculate_all(
cls,
calculation_rules: Optional[Dict[str, Any]],
form_data: Dict[str, Any]
) -> Dict[str, Any]:
"""
执行所有计算规则
Args:
calculation_rules: 计算规则配置
form_data: 表单数据
Returns:
计算结果字典
"""
if not calculation_rules:
return {}
results = {}
# 创建计算上下文(包含原始数据和已计算的结果)
context = dict(form_data)
# 1. 先执行聚合计算(因为计算字段可能依赖聚合结果)
aggregations = calculation_rules.get('aggregations', [])
logger.info(f"开始执行聚合计算,共 {len(aggregations)}")
for agg in aggregations:
try:
logger.info(f"聚合配置原始数据: {agg}")
name = agg.get('name')
source = agg.get('source')
field = agg.get('field')
function = agg.get('function') or 'sum'
format_type = agg.get('format') or 'number'
# 确保 decimal_places 是整数,处理 None 和非数字情况
decimal_places_raw = agg.get('decimal_places')
decimal_places = int(decimal_places_raw) if decimal_places_raw is not None else 2
logger.info(f"聚合字段解析: name={name}, source={source}, field={field}, function={function}, format={format_type}, decimal_places={decimal_places}")
if not all([name, source, field]):
logger.warning(f"聚合字段配置不完整,跳过: name={name}, source={source}, field={field}")
continue
# 计算聚合值
raw_value = cls.calculate_aggregation(context, source, field, function)
logger.info(f"聚合原始值: {raw_value}, 类型: {type(raw_value)}")
# 格式化
formatted_value = cls.format_value(raw_value, format_type, decimal_places)
logger.info(f"格式化后: {formatted_value}, decimal_places={decimal_places}")
results[name] = formatted_value
context[name] = raw_value # 使用原始值用于后续计算
# 如果是中文格式,同时保存原始数值
if format_type == 'chinese':
results[f'{name}_raw'] = raw_value
logger.info(f"聚合计算完成: {name} = {formatted_value}")
except Exception as e:
logger.warning(f"聚合计算失败: {agg}, 错误: {e}")
# 2. 执行计算字段(按顺序,支持依赖)
fields = calculation_rules.get('fields', [])
logger.info(f"开始执行计算字段,共 {len(fields)}")
for field_config in fields:
try:
name = field_config.get('name')
formula = field_config.get('formula')
format_type = field_config.get('format') or 'number'
# 确保 decimal_places 是整数,处理 None 和非数字情况
decimal_places_raw = field_config.get('decimal_places')
decimal_places = int(decimal_places_raw) if decimal_places_raw is not None else 2
logger.info(f"处理计算字段: name={name}, formula={formula}, format={format_type}, decimal_places={decimal_places}")
if not all([name, formula]):
logger.warning(f"计算字段配置不完整,跳过: {field_config}")
continue
# 计算公式
raw_value = cls.evaluate_formula(formula, context)
logger.info(f"计算字段 {name} 原始值: {raw_value}")
if raw_value is not None:
# 格式化
formatted_value = cls.format_value(raw_value, format_type, decimal_places)
logger.info(f"计算字段 {name} 格式化后: {formatted_value}")
results[name] = formatted_value
context[name] = raw_value # 使用原始值用于后续计算
# 如果是中文格式,同时保存原始数值
if format_type == 'chinese':
results[f'{name}_raw'] = raw_value
logger.info(f"公式计算完成: {name} = {formatted_value}")
else:
logger.warning(f"计算字段 {name} 返回 None")
except Exception as e:
logger.error(f"公式计算失败: {field_config}, 错误: {e}", exc_info=True)
return results
# 导出
__all__ = ['CalculationEngine', 'number_to_chinese', 'safe_get_value']
@@ -0,0 +1,157 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
字体服务 - 获取服务器上可用的字体列表
"""
import logging
import os
import platform
import subprocess
from functools import lru_cache
from typing import Dict, List, Optional, Set
logger = logging.getLogger(__name__)
class FontService:
"""字体服务类"""
CHINESE_FONTS: List[Dict[str, str]] = [
# 宋体系列
{"label": "宋体 (SimSun)", "value": "SimSun, STSong, Songti SC, Noto Serif CJK SC, serif", "family": "SimSun"},
{"label": "华文宋体 (STSong)", "value": "STSong, SimSun, Songti SC, Noto Serif CJK SC, serif", "family": "STSong"},
{"label": "Noto 宋体 (Noto Serif CJK)", "value": "Noto Serif CJK SC, SimSun, STSong, serif", "family": "Noto Serif CJK SC"},
# 黑体系列
{"label": "黑体 (SimHei)", "value": "SimHei, STHeiti, Heiti SC, Noto Sans CJK SC, sans-serif", "family": "SimHei"},
{"label": "Noto 黑体 (Noto Sans CJK)", "value": "Noto Sans CJK SC, SimHei, STHeiti, sans-serif", "family": "Noto Sans CJK SC"},
{"label": "思源黑体 (Source Han Sans)", "value": "Source Han Sans CN, Noto Sans CJK SC, SimHei, sans-serif", "family": "Source Han Sans CN"},
{"label": "微软雅黑 (Microsoft YaHei)", "value": "Microsoft YaHei, Noto Sans CJK SC, SimHei, sans-serif", "family": "Microsoft YaHei"},
{"label": "苹方 (PingFang SC)", "value": "PingFang SC, Noto Sans CJK SC, SimHei, sans-serif", "family": "PingFang SC"},
{"label": "文泉驿微米黑", "value": "WenQuanYi Micro Hei, Noto Sans CJK SC, sans-serif", "family": "WenQuanYi Micro Hei"},
{"label": "文泉驿正黑", "value": "WenQuanYi Zen Hei, Noto Sans CJK SC, sans-serif", "family": "WenQuanYi Zen Hei"},
# 楷体系列
{"label": "楷体 (KaiTi)", "value": "KaiTi, STKaiti, Kaiti SC, serif", "family": "KaiTi"},
{"label": "华文楷体 (STKaiti)", "value": "STKaiti, KaiTi, Kaiti SC, serif", "family": "STKaiti"},
# 仿宋系列
{"label": "仿宋 (FangSong)", "value": "FangSong, STFangsong, serif", "family": "FangSong"},
{"label": "华文仿宋 (STFangsong)", "value": "STFangsong, FangSong, serif", "family": "STFangsong"},
# 其他
{"label": "华文细黑 (STXihei)", "value": "STXihei, Noto Sans CJK SC, sans-serif", "family": "STXihei"},
{"label": "冬青黑体 (Hiragino Sans GB)", "value": "Hiragino Sans GB, Noto Sans CJK SC, sans-serif", "family": "Hiragino Sans GB"},
]
ENGLISH_FONTS: List[Dict[str, str]] = [
{"label": "Arial", "value": "Arial, sans-serif", "family": "Arial"},
{"label": "Helvetica", "value": "Helvetica, Arial, sans-serif", "family": "Helvetica"},
{"label": "Times New Roman", "value": "Times New Roman, serif", "family": "Times New Roman"},
{"label": "Georgia", "value": "Georgia, serif", "family": "Georgia"},
{"label": "Verdana", "value": "Verdana, sans-serif", "family": "Verdana"},
{"label": "Courier New", "value": "Courier New, monospace", "family": "Courier New"},
]
@classmethod
def _get_fc_list_families(cls) -> Optional[Set[str]]:
"""通过 fc-list 获取系统已安装的字体族名"""
try:
result = subprocess.run(
["fc-list", "--format", "%{family}\n"],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return None
families: Set[str] = set()
for line in result.stdout.splitlines():
for part in line.split(","):
families.add(part.strip())
return families
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return None
@classmethod
def _scan_font_files(cls) -> Set[str]:
"""扫描字体目录,返回字体文件名集合(备用方案)"""
system = platform.system()
dirs: List[str] = []
if system == "Darwin":
dirs = ["/System/Library/Fonts", "/Library/Fonts", os.path.expanduser("~/Library/Fonts")]
elif system == "Linux":
dirs = ["/usr/share/fonts", "/usr/local/share/fonts", os.path.expanduser("~/.fonts")]
elif system == "Windows":
dirs = [os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts")]
font_names: Set[str] = set()
extensions = {".ttf", ".ttc", ".otf", ".woff", ".woff2"}
for font_dir in dirs:
if not os.path.exists(font_dir):
continue
try:
for root, _, files in os.walk(font_dir):
for file in files:
if os.path.splitext(file)[1].lower() in extensions:
font_names.add(os.path.splitext(file)[0])
except (PermissionError, OSError):
continue
return font_names
@classmethod
def _is_font_available(cls, family: str, fc_families: Optional[Set[str]], file_names: Set[str]) -> bool:
"""判断字体是否在系统中可用"""
if fc_families is not None:
family_lower = family.lower()
for fc_fam in fc_families:
if family_lower == fc_fam.lower():
return True
return False
family_normalized = family.lower().replace(" ", "")
for name in file_names:
name_normalized = name.lower().replace(" ", "").replace("-", "").replace("_", "")
if family_normalized in name_normalized or name_normalized in family_normalized:
return True
return False
@classmethod
@lru_cache(maxsize=1)
def get_available_fonts(cls) -> List[Dict[str, str]]:
"""获取服务器上可用的字体列表"""
result: List[Dict[str, str]] = [{"label": "默认字体", "value": ""}]
fc_families = cls._get_fc_list_families()
file_names = cls._scan_font_files() if fc_families is None else set()
if fc_families is not None:
logger.info(f"[字体服务] 通过 fc-list 检测到 {len(fc_families)} 个字体族")
else:
logger.info(f"[字体服务] fc-list 不可用,使用文件名扫描检测到 {len(file_names)} 个字体文件")
added_labels: Set[str] = set()
for font in cls.CHINESE_FONTS:
if cls._is_font_available(font["family"], fc_families, file_names):
if font["label"] not in added_labels:
result.append({"label": font["label"], "value": font["value"]})
added_labels.add(font["label"])
if len(result) <= 1:
logger.warning("[字体服务] 未检测到中文字体,使用平台默认列表")
system = platform.system()
if system == "Darwin":
defaults = ["PingFang SC", "STSong", "STKaiti", "Hiragino Sans GB"]
elif system == "Linux":
defaults = ["Noto Sans CJK SC", "Noto Serif CJK SC", "WenQuanYi Zen Hei", "WenQuanYi Micro Hei"]
else:
defaults = ["Microsoft YaHei", "SimSun", "SimHei", "KaiTi", "FangSong"]
for font in cls.CHINESE_FONTS:
if font["family"] in defaults and font["label"] not in added_labels:
result.append({"label": font["label"], "value": font["value"]})
added_labels.add(font["label"])
for font in cls.ENGLISH_FONTS:
result.append({"label": font["label"], "value": font["value"]})
logger.info(f"[字体服务] 返回 {len(result)} 个字体选项")
return result
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,117 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
文档生成数据模型
"""
from sqlalchemy import Column, String, Text, Integer, Boolean, BigInteger, Index, JSON
from app.base_model import BaseModel
class DocumentTemplate(BaseModel):
"""文档模板配置"""
__tablename__ = "document_template"
# 所属应用(逻辑外键关联 core_application
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, comment="模板编码")
category = Column(String(50), default="other", comment="分类: leave/expense/purchase/contract/certificate/other")
description = Column(Text, default="", comment="描述")
# 关联配置
form_code = Column(String(100), nullable=True, index=True, comment="关联表单编码")
workflow_code = Column(String(100), nullable=True, index=True, comment="关联流程编码")
# 模板内容
template_type = Column(String(20), default="designer", comment="模板类型: designer/html")
template_content = Column(Text, nullable=True, comment="模板内容(JSON或HTML)")
template_css = Column(Text, nullable=True, comment="自定义CSS")
# 页面设置
page_size = Column(String(20), default="A4", comment="页面大小: A4/A3/Letter/Legal")
page_orientation = Column(String(20), default="portrait", comment="页面方向: portrait/landscape")
page_margin = Column(JSON, default=lambda: {"top": 20, "right": 20, "bottom": 20, "left": 20}, comment="页边距(mm)")
# 水印配置
watermark_enabled = Column(Boolean, default=False, comment="是否启用水印")
watermark_text = Column(String(100), nullable=True, comment="水印文字")
watermark_type = Column(String(20), default="text", comment="水印类型: text/image")
watermark_image_id = Column(String(36), nullable=True, comment="水印图片文件ID")
watermark_opacity = Column(Integer, default=30, comment="水印透明度(0-100)")
watermark_angle = Column(Integer, default=-45, comment="水印角度")
# 签章配置
seal_enabled = Column(Boolean, default=False, comment="是否启用签章")
seal_positions = Column(JSON, nullable=True, comment="签章位置配置")
# 页眉页脚
header_enabled = Column(Boolean, default=False, comment="是否启用页眉")
header_template = Column(Text, nullable=True, comment="页眉模板")
footer_enabled = Column(Boolean, default=False, comment="是否启用页脚")
footer_template = Column(Text, nullable=True, comment="页脚模板")
show_page_number = Column(Boolean, default=True, comment="是否显示页码")
# 计算规则配置
calculation_rules = Column(JSON, nullable=True, comment="计算规则配置")
# 状态
status = Column(String(20), default="draft", index=True, comment="状态: draft/published")
is_builtin = Column(Boolean, default=False, comment="是否内置模板")
version = Column(Integer, default=1, comment="版本号")
__table_args__ = (
Index("ix_document_template_code", "code"),
Index("ix_document_template_form_code", "form_code"),
Index("ix_document_template_workflow_code", "workflow_code"),
)
class GeneratedDocument(BaseModel):
"""生成的文档记录"""
__tablename__ = "generated_document"
# 关联模板
template_id = Column(String(36), nullable=False, index=True, comment="模板ID")
template_code = Column(String(100), nullable=True, comment="模板编码")
template_name = Column(String(100), nullable=True, comment="模板名称")
# 关联表单数据
form_code = Column(String(100), nullable=True, index=True, comment="表单编码")
form_data_id = Column(String(36), nullable=True, index=True, comment="表单数据ID")
# 关联流程实例
workflow_code = Column(String(100), nullable=True, comment="流程编码")
instance_id = Column(String(36), nullable=True, index=True, comment="流程实例ID")
# 文档信息
document_name = Column(String(200), nullable=False, comment="文档名称")
document_no = Column(String(100), nullable=True, comment="文档编号")
# 文件信息
file_id = Column(String(36), nullable=False, comment="文件ID(关联file_manager)")
file_size = Column(BigInteger, default=0, comment="文件大小(字节)")
page_count = Column(Integer, default=1, comment="页数")
# 生成信息
generate_type = Column(String(20), default="manual", comment="生成方式: auto/manual")
generator_id = Column(String(36), nullable=True, comment="生成人ID")
generator_name = Column(String(100), nullable=True, comment="生成人姓名")
# 状态
status = Column(String(20), default="generated", comment="状态: generated/sealed/downloaded/printed")
download_count = Column(Integer, default=0, comment="下载次数")
# 签章信息
sealed = Column(Boolean, default=False, comment="是否已盖章")
seal_info = Column(JSON, nullable=True, comment="签章信息")
__table_args__ = (
Index("ix_generated_document_template_id", "template_id"),
Index("ix_generated_document_form_data_id", "form_data_id"),
Index("ix_generated_document_instance_id", "instance_id"),
)
@@ -0,0 +1,44 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""页码 CSS 生成"""
from typing import Any, Dict
def _resolve_page_number_config(config: Dict[str, Any]) -> Dict[str, Any]:
"""从模板 JSON 解析页码配置"""
show = config.get("showPageNumber")
if show is None:
show = config.get("show_page_number", True)
return {
"show": bool(show),
"position": config.get("pageNumberPosition", "footer"),
"align": config.get("pageNumberAlign", "center"),
"format": config.get("pageNumberFormat", "chinese"),
"font_size": config.get("pageNumberFontSize", 10),
"color": config.get("pageNumberColor", "#666666"),
}
def build_page_number_css(config: Dict[str, Any]) -> str:
"""生成 WeasyPrint @page 页码 margin 规则"""
cfg = _resolve_page_number_config(config)
if not cfg["show"]:
return ""
edge = "top" if cfg["position"] == "header" else "bottom"
margin_box = f"{edge}-{cfg['align']}"
fmt = cfg["format"]
if fmt == "fraction":
content = 'counter(page) " / " counter(pages)'
elif fmt == "english":
content = '"Page " counter(page) " of " counter(pages)'
else:
content = '"" counter(page) " 页 / 共 " counter(pages) ""'
return f"""
@{margin_box} {{
content: {content};
font-size: {float(cfg['font_size'])}pt;
color: {cfg['color']};
}}"""
@@ -0,0 +1,267 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
文档生成Schema定义
"""
from datetime import datetime
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, ConfigDict, Field
# ==================== 文档模板 ====================
class PageMargin(BaseModel):
"""页边距配置"""
top: int = 20
right: int = 20
bottom: int = 20
left: int = 20
class SealPosition(BaseModel):
"""签章位置配置"""
seal_type: str = Field(..., description="签章类型: company/department/personal/signature")
position_type: str = Field(default="fixed", description="定位方式: fixed/relative")
x: int = Field(default=0, description="X坐标(mm)")
y: int = Field(default=0, description="Y坐标(mm)")
page: int = Field(default=-1, description="页码(-1表示最后一页)")
width: int = Field(default=40, description="宽度(mm)")
height: int = Field(default=40, description="高度(mm)")
field_name: Optional[str] = Field(default=None, description="关联字段名(用于手写签名)")
class CalculationField(BaseModel):
"""计算字段配置"""
name: str = Field(..., description="字段名")
label: str = Field(default="", description="字段标签")
formula: str = Field(..., description="计算公式,如: quantity * unit_price")
format: str = Field(default="number", description="格式化类型: number/money/percent/chinese")
decimal_places: int = Field(default=2, description="小数位数")
class AggregationField(BaseModel):
"""聚合字段配置"""
name: str = Field(..., description="字段名")
label: str = Field(default="", description="字段标签")
source: str = Field(..., description="数据源(子表名)")
field: str = Field(..., description="聚合字段")
function: str = Field(default="sum", description="聚合函数: sum/avg/max/min/count")
format: str = Field(default="number", description="格式化类型: number/money/percent/chinese")
decimal_places: int = Field(default=2, description="小数位数")
class CalculationRules(BaseModel):
"""计算规则配置"""
fields: List[CalculationField] = Field(default_factory=list, description="计算字段列表")
aggregations: List[AggregationField] = Field(default_factory=list, description="聚合字段列表")
class DocumentTemplateBase(BaseModel):
"""文档模板基础Schema"""
name: str = Field(..., min_length=1, max_length=100, description="模板名称")
code: str = Field(..., min_length=1, max_length=100, pattern=r"^[a-zA-Z][a-zA-Z0-9_]*$", description="模板编码(字母开头,只能包含字母、数字和下划线)")
category: str = Field(default="other", description="分类")
description: Optional[str] = Field(default="", description="描述")
form_code: Optional[str] = Field(default=None, description="关联表单编码")
workflow_code: Optional[str] = Field(default=None, description="关联流程编码")
template_type: str = Field(default="designer", description="模板类型: designer/html")
template_content: Optional[str] = Field(default=None, description="模板内容")
template_css: Optional[str] = Field(default=None, description="自定义CSS")
page_size: str = Field(default="A4", description="页面大小")
page_orientation: str = Field(default="portrait", description="页面方向")
page_margin: Optional[PageMargin] = Field(default_factory=PageMargin, description="页边距")
watermark_enabled: bool = Field(default=False, description="是否启用水印")
watermark_text: Optional[str] = Field(default=None, description="水印文字")
watermark_type: str = Field(default="text", description="水印类型")
watermark_image_id: Optional[str] = Field(default=None, description="水印图片ID")
watermark_opacity: int = Field(default=30, ge=0, le=100, description="水印透明度")
watermark_angle: int = Field(default=-45, description="水印角度")
seal_enabled: bool = Field(default=False, description="是否启用签章")
seal_positions: Optional[List[SealPosition]] = Field(default=None, description="签章位置配置")
header_enabled: bool = Field(default=False, description="是否启用页眉")
header_template: Optional[str] = Field(default=None, description="页眉模板")
footer_enabled: bool = Field(default=False, description="是否启用页脚")
footer_template: Optional[str] = Field(default=None, description="页脚模板")
show_page_number: bool = Field(default=True, description="是否显示页码")
calculation_rules: Optional[CalculationRules] = Field(default=None, description="计算规则配置")
class DocumentTemplateCreate(DocumentTemplateBase):
"""创建文档模板"""
application_id: Optional[str] = Field(default=None, description="所属应用ID")
class DocumentTemplateUpdate(BaseModel):
"""更新文档模板"""
name: Optional[str] = Field(default=None, max_length=100, description="模板名称")
category: Optional[str] = Field(default=None, description="分类")
description: Optional[str] = Field(default=None, description="描述")
form_code: Optional[str] = Field(default=None, description="关联表单编码")
workflow_code: Optional[str] = Field(default=None, description="关联流程编码")
template_type: Optional[str] = Field(default=None, description="模板类型")
template_content: Optional[str] = Field(default=None, description="模板内容")
template_css: Optional[str] = Field(default=None, description="自定义CSS")
page_size: Optional[str] = Field(default=None, description="页面大小")
page_orientation: Optional[str] = Field(default=None, description="页面方向")
page_margin: Optional[PageMargin] = Field(default=None, description="页边距")
watermark_enabled: Optional[bool] = Field(default=None, description="是否启用水印")
watermark_text: Optional[str] = Field(default=None, description="水印文字")
watermark_type: Optional[str] = Field(default=None, description="水印类型")
watermark_image_id: Optional[str] = Field(default=None, description="水印图片ID")
watermark_opacity: Optional[int] = Field(default=None, description="水印透明度")
watermark_angle: Optional[int] = Field(default=None, description="水印角度")
seal_enabled: Optional[bool] = Field(default=None, description="是否启用签章")
seal_positions: Optional[List[SealPosition]] = Field(default=None, description="签章位置配置")
header_enabled: Optional[bool] = Field(default=None, description="是否启用页眉")
header_template: Optional[str] = Field(default=None, description="页眉模板")
footer_enabled: Optional[bool] = Field(default=None, description="是否启用页脚")
footer_template: Optional[str] = Field(default=None, description="页脚模板")
show_page_number: Optional[bool] = Field(default=None, description="是否显示页码")
calculation_rules: Optional[CalculationRules] = Field(default=None, description="计算规则配置")
status: Optional[str] = Field(default=None, description="状态")
class DocumentTemplateOut(DocumentTemplateBase):
"""文档模板输出"""
id: str
application_id: Optional[str] = None
calculation_rules: Optional[Dict[str, Any]] = None
status: str = "draft"
is_builtin: bool = False
version: int = 1
sys_create_datetime: Optional[datetime] = None
sys_update_datetime: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class DocumentTemplateListOut(BaseModel):
"""文档模板列表输出"""
id: str
name: str
code: str
category: str
description: Optional[str] = None
form_code: Optional[str] = None
workflow_code: Optional[str] = None
status: str = "draft"
is_builtin: bool = False
version: int = 1
sys_create_datetime: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
# ==================== 导入导出 ====================
class DocumentTemplateImportCheckIn(BaseModel):
"""单据模板导入预检查"""
code: str = Field(..., description="模板编码")
class DocumentTemplateImportCheckOut(BaseModel):
"""单据模板导入预检查结果"""
code_exists: bool = Field(..., description="模板编码是否已存在")
can_import: bool = Field(..., description="是否可以直接导入")
class DocumentTemplateImportIn(DocumentTemplateBase):
"""单据模板配置导入"""
application_id: Optional[str] = Field(None, description="所属应用ID")
# ==================== 生成的文档 ====================
class GeneratedDocumentOut(BaseModel):
"""生成的文档输出"""
id: str
template_id: str
template_code: Optional[str] = None
template_name: Optional[str] = None
form_code: Optional[str] = None
form_data_id: Optional[str] = None
workflow_code: Optional[str] = None
instance_id: Optional[str] = None
document_name: str
document_no: Optional[str] = None
file_id: str
file_size: int = 0
page_count: int = 1
generate_type: str = "manual"
generator_id: Optional[str] = None
generator_name: Optional[str] = None
status: str = "generated"
download_count: int = 0
sealed: bool = False
seal_info: Optional[Dict[str, Any]] = None
sys_create_datetime: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class GenerateDocumentIn(BaseModel):
"""生成文档请求"""
template_id: str = Field(..., description="模板ID")
form_data_id: Optional[str] = Field(default=None, description="表单数据ID")
instance_id: Optional[str] = Field(default=None, description="流程实例ID")
document_name: Optional[str] = Field(default=None, description="文档名称(可选)")
seal_ids: Optional[List[str]] = Field(default=None, description="要使用的签章ID列表")
signature_file_ids: Optional[Dict[str, str]] = Field(default=None, description="手写签名文件ID映射")
class BatchGenerateDocumentIn(BaseModel):
"""批量生成文档请求"""
template_id: str = Field(..., description="模板ID")
form_data_ids: List[str] = Field(..., description="表单数据ID列表")
# ==================== 预览 ====================
class PreviewDocumentIn(BaseModel):
"""预览文档请求"""
template_id: Optional[str] = Field(default=None, description="模板ID(与template_content二选一)")
template_content: Optional[str] = Field(default=None, description="模板内容JSON(与template_id二选一)")
calculation_rules: Optional[Dict[str, Any]] = Field(default=None, description="计算规则配置(用于未保存的模板预览)")
form_data_id: Optional[str] = Field(default=None, description="表单数据ID")
instance_id: Optional[str] = Field(default=None, description="流程实例ID")
test_data: Optional[Dict[str, Any]] = Field(default=None, description="测试数据(优先使用)")
# ==================== 通用响应 ====================
class TemplateCategory(BaseModel):
"""模板分类"""
value: str
label: str
count: int = 0
class DocumentStats(BaseModel):
"""文档统计"""
total_templates: int = 0
published_templates: int = 0
total_documents: int = 0
today_documents: int = 0
@@ -0,0 +1,496 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
文档生成服务层
"""
from typing import Optional, List, Tuple, Dict, Any
from datetime import datetime, date
from sqlalchemy import select, func, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.base_service import BaseService
from online_dev.document_generator.model import DocumentTemplate, GeneratedDocument
from online_dev.document_generator.schema import (
DocumentTemplateCreate, DocumentTemplateUpdate
)
class DocumentTemplateService(BaseService[DocumentTemplate, DocumentTemplateCreate, DocumentTemplateUpdate]):
"""文档模板服务"""
model = DocumentTemplate
@classmethod
async def get_list(
cls,
db: AsyncSession,
page: int = 1,
page_size: int = 20,
application_id: Optional[str] = None,
category: Optional[str] = None,
status: Optional[str] = None,
form_code: Optional[str] = None,
workflow_code: Optional[str] = None,
keyword: Optional[str] = None,
is_builtin: Optional[bool] = None,
) -> Tuple[List[DocumentTemplate], int]:
"""获取模板列表"""
conditions = [cls.model.is_deleted == False]
# 子应用过滤:有 application_id 时过滤该应用,无则只返回主应用数据
if application_id:
conditions.append(cls.model.application_id == application_id)
else:
conditions.append(cls.model.application_id.is_(None))
if category:
conditions.append(cls.model.category == category)
if status:
conditions.append(cls.model.status == status)
if form_code:
conditions.append(cls.model.form_code == form_code)
if workflow_code:
conditions.append(cls.model.workflow_code == workflow_code)
if is_builtin is not None:
conditions.append(cls.model.is_builtin == is_builtin)
if keyword:
conditions.append(
or_(
cls.model.name.ilike(f"%{keyword}%"),
cls.model.code.ilike(f"%{keyword}%"),
cls.model.description.ilike(f"%{keyword}%"),
)
)
# 查询总数
count_stmt = select(func.count()).select_from(cls.model).where(and_(*conditions))
total = (await db.execute(count_stmt)).scalar() or 0
# 查询列表
stmt = (
select(cls.model)
.where(and_(*conditions))
.order_by(cls.model.sys_create_datetime.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await db.execute(stmt)
items = list(result.scalars().all())
return items, total
@classmethod
async def get_by_code(cls, db: AsyncSession, code: str) -> Optional[DocumentTemplate]:
"""根据编码获取模板"""
stmt = select(cls.model).where(
and_(cls.model.code == code, cls.model.is_deleted == False)
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
@classmethod
async def get_by_workflow_code(cls, db: AsyncSession, workflow_code: str) -> List[DocumentTemplate]:
"""根据工作流编码获取绑定的已发布模板列表"""
stmt = select(cls.model).where(
and_(
cls.model.workflow_code == workflow_code,
cls.model.status == "published",
cls.model.is_deleted == False
)
).order_by(cls.model.sort, cls.model.sys_create_datetime)
result = await db.execute(stmt)
return list(result.scalars().all())
@classmethod
async def get_by_form_code(cls, db: AsyncSession, form_code: str) -> List[DocumentTemplate]:
"""根据表单编码获取绑定的已发布模板列表"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"Querying templates for form_code: {form_code}")
# 先查询所有绑定该表单的模板(不限状态)用于调试
debug_stmt = select(cls.model).where(
and_(
cls.model.form_code == form_code,
cls.model.is_deleted == False
)
)
debug_result = await db.execute(debug_stmt)
all_templates = list(debug_result.scalars().all())
logger.info(f"Found {len(all_templates)} templates (all statuses) for form_code {form_code}")
for t in all_templates:
logger.info(f" - Template: {t.name} (code={t.code}, status={t.status}, form_code={t.form_code})")
# 正式查询:只返回已发布的模板
stmt = select(cls.model).where(
and_(
cls.model.form_code == form_code,
cls.model.status == "published",
cls.model.is_deleted == False
)
).order_by(cls.model.sort, cls.model.sys_create_datetime)
result = await db.execute(stmt)
published_templates = list(result.scalars().all())
logger.info(f"Returning {len(published_templates)} published templates")
return published_templates
@classmethod
async def has_templates_by_form_code(cls, db: AsyncSession, form_code: str) -> bool:
"""检查表单是否绑定了已发布的单据模板"""
stmt = select(func.count()).select_from(cls.model).where(
and_(
cls.model.form_code == form_code,
cls.model.status == "published",
cls.model.is_deleted == False
)
)
result = await db.execute(stmt)
count = result.scalar() or 0
return count > 0
@classmethod
async def publish(cls, db: AsyncSession, template_id: str) -> Optional[DocumentTemplate]:
"""发布模板"""
template = await cls.get_by_id(db, template_id)
if not template:
return None
template.status = "published"
template.version += 1
await db.commit()
await db.refresh(template)
return template
@classmethod
async def unpublish(cls, db: AsyncSession, template_id: str) -> Optional[DocumentTemplate]:
"""取消发布"""
template = await cls.get_by_id(db, template_id)
if not template:
return None
template.status = "draft"
await db.commit()
await db.refresh(template)
return template
@classmethod
async def copy(cls, db: AsyncSession, template_id: str, new_code: str, new_name: str) -> Optional[DocumentTemplate]:
"""复制模板"""
template = await cls.get_by_id(db, template_id)
if not template:
return None
# 创建新模板
new_template = DocumentTemplate(
application_id=template.application_id,
name=new_name,
code=new_code,
category=template.category,
description=template.description,
form_code=template.form_code,
workflow_code=template.workflow_code,
template_type=template.template_type,
template_content=template.template_content,
template_css=template.template_css,
page_size=template.page_size,
page_orientation=template.page_orientation,
page_margin=template.page_margin,
watermark_enabled=template.watermark_enabled,
watermark_text=template.watermark_text,
watermark_type=template.watermark_type,
watermark_image_id=template.watermark_image_id,
watermark_opacity=template.watermark_opacity,
watermark_angle=template.watermark_angle,
seal_enabled=template.seal_enabled,
seal_positions=template.seal_positions,
header_enabled=template.header_enabled,
header_template=template.header_template,
footer_enabled=template.footer_enabled,
footer_template=template.footer_template,
show_page_number=template.show_page_number,
status="draft",
is_builtin=False,
version=1,
)
db.add(new_template)
await db.commit()
await db.refresh(new_template)
return new_template
@classmethod
async def get_categories(cls, db: AsyncSession, application_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""获取模板分类统计"""
conditions = [cls.model.is_deleted == False]
# 子应用过滤
if application_id:
conditions.append(cls.model.application_id == application_id)
else:
conditions.append(cls.model.application_id.is_(None))
stmt = (
select(cls.model.category, func.count(cls.model.id).label("count"))
.where(and_(*conditions))
.group_by(cls.model.category)
)
result = await db.execute(stmt)
rows = result.all()
category_labels = {
"leave": "请假单",
"expense": "报销单",
"purchase": "采购单",
"contract": "合同",
"certificate": "证明",
"other": "其他",
}
return [
{
"value": row.category,
"label": category_labels.get(row.category, row.category),
"count": row.count,
}
for row in rows
]
@staticmethod
def _template_to_export_dict(template: DocumentTemplate) -> Dict[str, Any]:
"""将模板转为可导出的 JSON 结构"""
return {
"name": template.name,
"code": template.code,
"category": template.category or "other",
"description": template.description or "",
"form_code": template.form_code,
"workflow_code": template.workflow_code,
"template_type": template.template_type or "designer",
"template_content": template.template_content,
"template_css": template.template_css,
"page_size": template.page_size or "A4",
"page_orientation": template.page_orientation or "portrait",
"page_margin": template.page_margin,
"watermark_enabled": template.watermark_enabled or False,
"watermark_text": template.watermark_text,
"watermark_type": template.watermark_type or "text",
"watermark_image_id": template.watermark_image_id,
"watermark_opacity": template.watermark_opacity if template.watermark_opacity is not None else 30,
"watermark_angle": template.watermark_angle if template.watermark_angle is not None else -45,
"seal_enabled": template.seal_enabled or False,
"seal_positions": template.seal_positions,
"header_enabled": template.header_enabled or False,
"header_template": template.header_template,
"footer_enabled": template.footer_enabled or False,
"footer_template": template.footer_template,
"show_page_number": template.show_page_number if template.show_page_number is not None else True,
"calculation_rules": template.calculation_rules,
}
@classmethod
async def export_config(cls, db: AsyncSession, template_id: str) -> Dict[str, Any]:
"""导出单据模板配置"""
template = await cls.get_by_id(db, template_id)
if not template:
return None
return cls._template_to_export_dict(template)
@classmethod
async def check_import(cls, db: AsyncSession, code: str) -> Dict[str, Any]:
"""导入预检查:编码是否冲突"""
existing = await cls.get_by_code(db, code) if code else None
code_exists = existing is not None
return {
"code_exists": code_exists,
"can_import": not code_exists,
}
@classmethod
async def import_config(
cls,
db: AsyncSession,
data: Dict[str, Any],
) -> DocumentTemplate:
"""导入单据模板配置(创建新草稿模板)"""
if not data.get("name") or not data.get("code"):
raise ValueError("缺少必要字段: name 或 code")
existing = await cls.get_by_code(db, data["code"])
if existing:
raise ValueError(f"模板编码已存在: {data['code']}")
create_data = DocumentTemplateCreate(**data)
return await cls.create(db, create_data)
class GeneratedDocumentService(BaseService[GeneratedDocument, None, None]):
"""生成的文档服务"""
model = GeneratedDocument
@classmethod
async def get_list(
cls,
db: AsyncSession,
page: int = 1,
page_size: int = 20,
template_id: Optional[str] = None,
form_code: Optional[str] = None,
form_data_id: Optional[str] = None,
instance_id: Optional[str] = None,
generator_id: Optional[str] = None,
keyword: Optional[str] = None,
) -> Tuple[List[GeneratedDocument], int]:
"""获取文档列表"""
conditions = [cls.model.is_deleted == False]
if template_id:
conditions.append(cls.model.template_id == template_id)
if form_code:
conditions.append(cls.model.form_code == form_code)
if form_data_id:
conditions.append(cls.model.form_data_id == form_data_id)
if instance_id:
conditions.append(cls.model.instance_id == instance_id)
if generator_id:
conditions.append(cls.model.generator_id == generator_id)
if keyword:
conditions.append(
or_(
cls.model.document_name.ilike(f"%{keyword}%"),
cls.model.document_no.ilike(f"%{keyword}%"),
)
)
# 查询总数
count_stmt = select(func.count()).select_from(cls.model).where(and_(*conditions))
total = (await db.execute(count_stmt)).scalar() or 0
# 查询列表
stmt = (
select(cls.model)
.where(and_(*conditions))
.order_by(cls.model.sys_create_datetime.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await db.execute(stmt)
items = list(result.scalars().all())
return items, total
@classmethod
async def get_by_instance_id(
cls,
db: AsyncSession,
instance_id: str,
) -> List[GeneratedDocument]:
"""根据流程实例ID获取文档列表"""
stmt = (
select(cls.model)
.where(
and_(
cls.model.instance_id == instance_id,
cls.model.is_deleted == False,
)
)
.order_by(cls.model.sys_create_datetime.desc())
)
result = await db.execute(stmt)
return list(result.scalars().all())
@classmethod
async def get_by_template_and_form_data(
cls,
db: AsyncSession,
template_id: str,
form_data_id: str,
) -> List[GeneratedDocument]:
"""根据模板ID和表单数据ID获取文档列表"""
stmt = (
select(cls.model)
.where(
and_(
cls.model.template_id == template_id,
cls.model.form_data_id == form_data_id,
cls.model.is_deleted == False,
)
)
.order_by(cls.model.sys_create_datetime.desc())
)
result = await db.execute(stmt)
return list(result.scalars().all())
@classmethod
async def create_document(
cls,
db: AsyncSession,
template: DocumentTemplate,
file_id: str,
file_size: int,
page_count: int,
document_name: str,
form_data_id: Optional[str] = None,
instance_id: Optional[str] = None,
generator_id: Optional[str] = None,
generator_name: Optional[str] = None,
generate_type: str = "manual",
) -> GeneratedDocument:
"""创建文档记录"""
# 生成文档编号
today = date.today()
document_no = f"DOC{today.strftime('%Y%m%d')}{datetime.now().strftime('%H%M%S%f')[:10]}"
document = GeneratedDocument(
template_id=template.id,
template_code=template.code,
template_name=template.name,
form_code=template.form_code,
form_data_id=form_data_id,
workflow_code=template.workflow_code,
instance_id=instance_id,
document_name=document_name,
document_no=document_no,
file_id=file_id,
file_size=file_size,
page_count=page_count,
generate_type=generate_type,
generator_id=generator_id,
generator_name=generator_name,
status="generated",
)
db.add(document)
await db.commit()
await db.refresh(document)
return document
@classmethod
async def increment_download_count(cls, db: AsyncSession, document_id: str) -> None:
"""增加下载次数"""
document = await cls.get_by_id(db, document_id)
if document:
document.download_count += 1
document.status = "downloaded"
await db.commit()
@classmethod
async def mark_sealed(
cls,
db: AsyncSession,
document_id: str,
seal_info: Dict[str, Any]
) -> Optional[GeneratedDocument]:
"""标记已盖章"""
document = await cls.get_by_id(db, document_id)
if not document:
return None
document.sealed = True
document.seal_info = seal_info
document.status = "sealed"
await db.commit()
await db.refresh(document)
return document
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,386 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
内置文档模板
"""
import json
from typing import Dict, Any, List
# 请假申请单模板
LEAVE_APPLICATION_TEMPLATE = {
"version": "1.0",
"pageConfig": {
"size": "A4",
"orientation": "portrait",
"margin": {"top": 25, "right": 20, "bottom": 25, "left": 20}
},
"elements": [
{
"id": "title",
"type": "text",
"content": "请假申请单",
"position": {"x": 0, "y": 10, "width": 170},
"style": {"fontSize": 22, "fontWeight": "bold", "textAlign": "center"}
},
{
"id": "doc_no",
"type": "field",
"fieldName": "_instance.instance_no",
"label": "单据编号:",
"position": {"x": 120, "y": 25},
"style": {"fontSize": 10, "color": "#666"}
},
{
"id": "applicant_section",
"type": "container",
"position": {"x": 0, "y": 35, "width": 170},
"children": [
{
"id": "applicant_name",
"type": "field",
"fieldName": "applicant_name",
"label": "申请人:",
"position": {"x": 0, "y": 0},
"style": {"fontSize": 12}
},
{
"id": "department",
"type": "field",
"fieldName": "department_name",
"label": "部门:",
"position": {"x": 60, "y": 0},
"style": {"fontSize": 12}
},
{
"id": "apply_date",
"type": "field",
"fieldName": "apply_date",
"label": "申请日期:",
"format": "date",
"position": {"x": 120, "y": 0},
"style": {"fontSize": 12}
}
]
},
{
"id": "leave_info",
"type": "container",
"position": {"x": 0, "y": 50, "width": 170},
"style": {"border": "1px solid #333", "padding": "10"},
"children": [
{
"id": "leave_type",
"type": "field",
"fieldName": "leave_type_name",
"label": "请假类型:",
"position": {"x": 5, "y": 5},
"style": {"fontSize": 12}
},
{
"id": "start_time",
"type": "field",
"fieldName": "start_time",
"label": "开始时间:",
"format": "datetime",
"position": {"x": 5, "y": 15},
"style": {"fontSize": 12}
},
{
"id": "end_time",
"type": "field",
"fieldName": "end_time",
"label": "结束时间:",
"format": "datetime",
"position": {"x": 90, "y": 15},
"style": {"fontSize": 12}
},
{
"id": "duration",
"type": "field",
"fieldName": "duration",
"label": "请假时长:",
"position": {"x": 5, "y": 25},
"style": {"fontSize": 12}
},
{
"id": "reason_label",
"type": "text",
"content": "请假事由:",
"position": {"x": 5, "y": 40},
"style": {"fontSize": 12}
},
{
"id": "reason",
"type": "field",
"fieldName": "reason",
"position": {"x": 5, "y": 50, "width": 160, "height": 30},
"style": {"fontSize": 12}
}
]
},
{
"id": "approval_section",
"type": "text",
"content": "审批记录",
"position": {"x": 0, "y": 145},
"style": {"fontSize": 14, "fontWeight": "bold"}
},
{
"id": "approval_table",
"type": "table",
"dataSource": "_logs",
"columns": [
{"field": "node_name", "label": "审批节点", "width": "60"},
{"field": "action", "label": "操作", "width": "30"},
{"field": "comment", "label": "审批意见", "width": "60"},
{"field": "created_at", "label": "时间", "width": "40"}
],
"position": {"x": 0, "y": 155, "width": 170}
},
{
"id": "signature_section",
"type": "container",
"position": {"x": 0, "y": 220, "width": 170},
"children": [
{
"id": "applicant_sign_label",
"type": "text",
"content": "申请人签名:",
"position": {"x": 0, "y": 0},
"style": {"fontSize": 12}
},
{
"id": "applicant_signature",
"type": "signature",
"fieldName": "applicant_signature",
"position": {"x": 30, "y": 0},
"size": {"width": 40, "height": 20}
},
{
"id": "approver_sign_label",
"type": "text",
"content": "审批人签名:",
"position": {"x": 100, "y": 0},
"style": {"fontSize": 12}
},
{
"id": "approver_signature",
"type": "signature",
"fieldName": "approver_signature",
"position": {"x": 130, "y": 0},
"size": {"width": 40, "height": 20}
}
]
},
{
"id": "seal_placeholder",
"type": "seal",
"sealType": "company",
"position": {"x": 130, "y": 200},
"size": {"width": 40, "height": 40}
}
]
}
# 报销单模板
EXPENSE_REPORT_TEMPLATE = {
"version": "1.0",
"pageConfig": {
"size": "A4",
"orientation": "portrait",
"margin": {"top": 25, "right": 20, "bottom": 25, "left": 20}
},
"elements": [
{
"id": "title",
"type": "text",
"content": "费用报销单",
"position": {"x": 0, "y": 10, "width": 170},
"style": {"fontSize": 22, "fontWeight": "bold", "textAlign": "center"}
},
{
"id": "doc_no",
"type": "field",
"fieldName": "_instance.instance_no",
"label": "单据编号:",
"position": {"x": 120, "y": 25},
"style": {"fontSize": 10, "color": "#666"}
},
{
"id": "basic_info",
"type": "container",
"position": {"x": 0, "y": 35, "width": 170},
"children": [
{
"id": "applicant",
"type": "field",
"fieldName": "applicant_name",
"label": "报销人:",
"position": {"x": 0, "y": 0},
"style": {"fontSize": 12}
},
{
"id": "department",
"type": "field",
"fieldName": "department_name",
"label": "部门:",
"position": {"x": 60, "y": 0},
"style": {"fontSize": 12}
},
{
"id": "apply_date",
"type": "field",
"fieldName": "apply_date",
"label": "申请日期:",
"format": "date",
"position": {"x": 120, "y": 0},
"style": {"fontSize": 12}
}
]
},
{
"id": "expense_table_label",
"type": "text",
"content": "报销明细",
"position": {"x": 0, "y": 50},
"style": {"fontSize": 14, "fontWeight": "bold"}
},
{
"id": "expense_table",
"type": "table",
"dataSource": "expense_items",
"columns": [
{"field": "expense_date", "label": "日期", "width": "30"},
{"field": "expense_type", "label": "费用类型", "width": "35"},
{"field": "description", "label": "说明", "width": "60"},
{"field": "amount", "label": "金额(元)", "width": "30"}
],
"position": {"x": 0, "y": 60, "width": 170}
},
{
"id": "total_section",
"type": "container",
"position": {"x": 0, "y": 130, "width": 170},
"children": [
{
"id": "total_label",
"type": "text",
"content": "合计金额:",
"position": {"x": 100, "y": 0},
"style": {"fontSize": 14, "fontWeight": "bold"}
},
{
"id": "total_amount",
"type": "field",
"fieldName": "total_amount",
"format": "money",
"position": {"x": 130, "y": 0},
"style": {"fontSize": 14, "fontWeight": "bold", "color": "#c00"}
}
]
},
{
"id": "approval_section",
"type": "text",
"content": "审批记录",
"position": {"x": 0, "y": 150},
"style": {"fontSize": 14, "fontWeight": "bold"}
},
{
"id": "approval_table",
"type": "table",
"dataSource": "_logs",
"columns": [
{"field": "node_name", "label": "审批节点", "width": "60"},
{"field": "action", "label": "操作", "width": "30"},
{"field": "comment", "label": "审批意见", "width": "60"},
{"field": "created_at", "label": "时间", "width": "40"}
],
"position": {"x": 0, "y": 160, "width": 170}
},
{
"id": "seal_placeholder",
"type": "seal",
"sealType": "finance",
"position": {"x": 130, "y": 220},
"size": {"width": 40, "height": 40}
}
]
}
# 通用模板
GENERAL_TEMPLATE = {
"version": "1.0",
"pageConfig": {
"size": "A4",
"orientation": "portrait",
"margin": {"top": 25, "right": 20, "bottom": 25, "left": 20}
},
"elements": [
{
"id": "title",
"type": "text",
"content": "{{_instance.title}}",
"position": {"x": 0, "y": 10, "width": 170},
"style": {"fontSize": 22, "fontWeight": "bold", "textAlign": "center"}
},
{
"id": "doc_no",
"type": "field",
"fieldName": "_instance.instance_no",
"label": "单据编号:",
"position": {"x": 120, "y": 25},
"style": {"fontSize": 10, "color": "#666"}
},
{
"id": "content_placeholder",
"type": "text",
"content": "(请在模板设计器中添加表单字段)",
"position": {"x": 0, "y": 50, "width": 170},
"style": {"fontSize": 12, "textAlign": "center", "color": "#999"}
}
]
}
# 内置模板列表
BUILTIN_TEMPLATES: List[Dict[str, Any]] = [
{
"code": "builtin_leave_application",
"name": "请假申请单",
"category": "leave",
"description": "标准请假申请单模板,包含请假信息、审批记录和签章位置",
"template_type": "designer",
"template_content": json.dumps(LEAVE_APPLICATION_TEMPLATE, ensure_ascii=False),
"is_builtin": True,
"status": "published",
},
{
"code": "builtin_expense_report",
"name": "费用报销单",
"category": "expense",
"description": "标准费用报销单模板,包含报销明细、合计金额和审批记录",
"template_type": "designer",
"template_content": json.dumps(EXPENSE_REPORT_TEMPLATE, ensure_ascii=False),
"is_builtin": True,
"status": "published",
},
{
"code": "builtin_general",
"name": "通用模板",
"category": "other",
"description": "通用文档模板,可根据需要自定义内容",
"template_type": "designer",
"template_content": json.dumps(GENERAL_TEMPLATE, ensure_ascii=False),
"is_builtin": True,
"status": "published",
},
]
def get_builtin_templates() -> List[Dict[str, Any]]:
"""获取内置模板列表"""
return BUILTIN_TEMPLATES
@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
@page {
size: {{ page_width }} {{ page_height }};
margin: {{ margin_top }}px {{ margin_right }}px {{ margin_bottom }}px {{ margin_left }}px;
{{ page_number_css|safe }}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: {{ font_family|safe }};
font-size: 12pt;
line-height: 1.6;
color: #333;
}
.document-body {
width: 100%;
position: relative;
}
.element {
margin-bottom: 5px;
}
.element-text {
white-space: pre-wrap;
}
.element-field {
display: inline-block;
}
.element-field .label {
font-weight: normal;
}
.element-field .value {
border-bottom: 1px solid #333;
min-width: 100px;
display: inline-block;
padding: 0 5px;
}
.element-table, .info-table, .detail-table, .info-row {
width: 100%;
border-collapse: collapse;
}
.element-table th,
.element-table td {
border: 1px solid #333;
padding: 8px;
text-align: left;
}
.element-table th {
background-color: #f5f5f5;
font-weight: bold;
}
.element-image {
max-width: 100%;
}
.element-signature {
max-width: 150px;
max-height: 60px;
}
.element-qrcode,
.element-barcode,
.element-image {
width: 100%;
}
.element-divider {
border-top: 1px solid #333;
margin: 10px 0;
}
h1 {
margin: 15px 0;
}
.doc-header {
margin-bottom: 10px;
}
.doc-footer {
margin-top: 30px;
padding-top: 10px;
border-top: 1px solid #ddd;
}
.approval-area {
margin: 20px 0;
}
{{ custom_css|safe }}
</style>
</head>
<body>
<div class="document-body">
{{ content }}
</div>
</body>
</html>
@@ -0,0 +1,28 @@
{% set label_font_size = labelFontSize|default(14) %}
{% set label_color = labelColor|default('#333333') %}
{% set label_font_weight = labelFontWeight|default('normal') %}
{% set label_position = labelPosition|default('bottom') %}
{% set align_map = {'left': 'flex-start', 'center': 'center', 'right': 'flex-end'} %}
{% set justify = align_map.get(textAlign|default('center'), 'center') %}
<div style="display: flex; justify-content: {{ justify }}; width: 100%; box-sizing: border-box; line-height: 0;">
<div style="display: inline-flex; flex-direction: {% if label_position == 'top' %}column-reverse{% elif label_position == 'left' %}row-reverse{% elif label_position == 'right' %}row{% else %}column{% endif %}; align-items: center; gap: 4px; line-height: normal;">
{% if barcode_image %}
<img src="{{ barcode_image }}" style="width: {{ width }}; height: {{ height }}; display: block;" />
{% else %}
<div style="width: {{ width }}; height: {{ height }}; display: inline-flex; border: 2px dashed #dcdfe6; background-color: #f5f7fa; align-items: center; justify-content: center; flex-direction: column; gap: 8px; color: #909399; font-size: 12px;">
<div style="display: flex; align-items: center; gap: 2px;">
<div style="width: 2px; height: 16px; background-color: #909399;"></div>
<div style="width: 2px; height: 16px; background-color: #909399;"></div>
<div style="width: 2px; height: 16px; background-color: #909399;"></div>
<div style="width: 2px; height: 16px; background-color: #909399;"></div>
<div style="width: 2px; height: 16px; background-color: #909399;"></div>
<div style="width: 2px; height: 16px; background-color: #909399;"></div>
<div style="width: 2px; height: 16px; background-color: #909399;"></div>
<div style="width: 2px; height: 16px; background-color: #909399;"></div>
</div>
<span>{{ width }} × {{ height }}</span>
</div>
{% endif %}
{% if label %}<span style="font-size: {{ label_font_size }}px; color: {{ label_color }}; font-weight: {{ label_font_weight }};">{{ label }}</span>{% endif %}
</div>
</div>
@@ -0,0 +1 @@
{% for child in children %}{{ child|safe }}{% endfor %}
@@ -0,0 +1,33 @@
{% set header_font_size = header_font_size|default(14) %}
{% set header_color = header_color|default('#333333') %}
{% set header_font_weight = header_font_weight|default('bold') %}
{% set content_font_size = content_font_size|default(14) %}
{% set content_color = content_color|default('#333333') %}
<table class="detail-table" style="width: 100%; border-collapse: collapse; margin: 10px 0;">
{% if show_header %}
<thead><tr>
{% if show_index %}<th style="border: 1px solid #333; padding: 8px; font-size: {{ header_font_size }}px; color: {{ header_color }}; font-weight: {{ header_font_weight }};{% if header_bg_color %} background-color: {{ header_bg_color }};{% endif %} width: {{ index_width|default('40') }}px;">序号</th>{% endif %}
{% for col in columns %}
<th style="border: 1px solid #333; padding: 8px; font-size: {{ header_font_size }}px; color: {{ header_color }}; font-weight: {{ header_font_weight }};{% if header_bg_color %} background-color: {{ header_bg_color }};{% endif %}{% if col.width %} width: {{ col.width }};{% endif %} text-align: {{ col.align|default('left') }};">{{ col.label }}</th>
{% endfor %}
</tr></thead>
{% endif %}
<tbody>
{% for row in rows %}
<tr>
{% if show_index %}<td style="border: 1px solid #333; padding: 8px; font-size: {{ content_font_size }}px; color: {{ content_color }}; text-align: center;">{{ loop.index }}</td>{% endif %}
{% for col in columns %}
<td style="border: 1px solid #333; padding: 8px; font-size: {{ content_font_size }}px; color: {{ content_color }}; text-align: {{ col.align|default('left') }};">{{ row[col.field]|default('') }}</td>
{% endfor %}
</tr>
{% endfor %}
{% if show_summary and rows %}
<tr>
{% if show_index %}<td style="border: 1px solid #333; padding: 8px; font-size: {{ content_font_size }}px; color: {{ content_color }}; text-align: center; font-weight: bold;">合计</td>{% endif %}
{% for col in columns %}
<td style="border: 1px solid #333; padding: 8px; font-size: {{ content_font_size }}px; color: {{ content_color }}; text-align: {{ col.align|default('left') }}; font-weight: bold;">{{ summary_values[col.field]|default('') }}</td>
{% endfor %}
</tr>
{% endif %}
</tbody>
</table>
@@ -0,0 +1 @@
<hr style="border: none; border-top: {{ line_width }}px {{ line_style }} {{ line_color }}; margin: 10px 0;" />
@@ -0,0 +1,8 @@
{% set label_font_size = label_font_size|default(font_size|default(12)) %}
{% set label_color = label_color|default('#333333') %}
{% set label_font_weight = label_font_weight|default('normal') %}
{% set value_font_size = value_font_size|default(font_size|default(12)) %}
{% set value_color = value_color|default('#333333') %}
<div style="text-align: {{ text_align }}; margin: 10px 0;">
{% for field in fields %}{% if not loop.first %}&nbsp;&nbsp;&nbsp;&nbsp;{% endif %}<span style="font-size: {{ label_font_size }}pt; color: {{ label_color }}; font-weight: {{ label_font_weight }};">{{ field.label }}</span><span style="font-size: {{ value_font_size }}pt; color: {{ value_color }};{% if show_underline %} border-bottom: 1px solid #333; padding-bottom: 2px; display: inline-block; min-width: 50px;{% endif %}">{{ field.value if field.value else '' }}</span>{% endfor %}
</div>
@@ -0,0 +1 @@
{% if label %}<span class="label">{{ label }}</span>{% endif %}<span class="value">{{ value }}</span>
@@ -0,0 +1,5 @@
{% set color = color|default('#666666') %}
<div class="doc-footer" style="text-align: {{ text_align }}; font-size: {{ font_size }}pt; color: {{ color }}; margin-top: 30px; padding-top: 10px; border-top: 1px solid #ddd;">
{% if content %}<div>{{ content }}</div>{% endif %}
{% if footer_parts %}<div style="margin-top: 5px;">{{ footer_parts|join(' | ') }}</div>{% endif %}
</div>
@@ -0,0 +1,13 @@
{% set header_font_size = header_font_size|default(14) %}
{% set header_color = header_color|default('#333333') %}
{% set header_font_weight = header_font_weight|default('bold') %}
<table class="doc-header" style="width: 100%; border: none;">
<tr>
{% if header_type in ['logo', 'logo-text'] and logo_src %}
<td style="width: 80px; border: none;"><img src="{{ logo_src }}" style="max-height: 50px;" /></td>
{% endif %}
{% if header_type in ['text', 'logo-text'] %}
<td style="text-align: {{ text_align }}; border: none; font-size: {{ header_font_size }}pt; font-weight: {{ header_font_weight }}; color: {{ header_color }};">{{ company_name }}</td>
{% endif %}
</tr>
</table>
@@ -0,0 +1,27 @@
{% set label_font_size = labelFontSize|default(14) %}
{% set label_color = labelColor|default('#333333') %}
{% set label_font_weight = labelFontWeight|default('normal') %}
{% set align_map = {'left': 'flex-start', 'center': 'center', 'right': 'flex-end'} %}
{% set justify = align_map.get(textAlign|default('center'), 'center') %}
{% set is_float = position_mode|default('inline') == 'float' %}
{% if is_float %}
<div style="position: absolute; left: {{ float_x|default(0) }}px; top: {{ float_y|default(0) }}px; z-index: {{ float_z_index|default(100) }};">
{% else %}
<div style="display: flex; justify-content: {{ justify }}; width: 100%; box-sizing: border-box;">
{% endif %}
<div style="display: inline-flex; flex-direction: {% if labelPosition == 'top' %}column-reverse{% elif labelPosition == 'left' %}row-reverse{% elif labelPosition == 'right' %}row{% else %}column{% endif %}; align-items: center; gap: 4px;">
{% if src %}
<img src="{{ src }}" style="width: {{ width }}; height: {{ height }}; display: block;" />
{% else %}
<div style="width: {{ width }}; height: {{ height }}; display: flex; border: 2px dashed #dcdfe6; background-color: #f5f7fa; align-items: center; justify-content: center; flex-direction: column; gap: 8px; color: #909399; font-size: 12px;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
<span>{{ width }} × {{ height }}</span>
</div>
{% endif %}
{% if label %}<span style="font-size: {{ label_font_size }}px; color: {{ label_color }}; font-weight: {{ label_font_weight }};">{{ label }}</span>{% endif %}
</div>
</div>
@@ -0,0 +1,12 @@
{% set label_font_size = label_font_size|default(font_size|default(12)) %}
{% set label_color = label_color|default('#333333') %}
{% set label_font_weight = label_font_weight|default('normal') %}
{% set value_font_size = value_font_size|default(font_size|default(12)) %}
{% set value_color = value_color|default('#333333') %}
<table class="info-row" style="width: 100%; border: none; margin: 8px 0;">
<tr>
{% for field in fields %}
<td style="width: {{ field.width|default('auto') }}; border: none; padding: 4px 0;">{% if show_label %}<span style="font-size: {{ label_font_size }}pt; color: {{ label_color }}; font-weight: {{ label_font_weight }};">{{ field.label }}</span>{% endif %}<span style="font-size: {{ value_font_size }}pt; color: {{ value_color }};{% if show_underline %} border-bottom: 1px solid #333; padding-bottom: 2px; display: inline-block; min-width: 50px;{% endif %}">{{ field.value if field.value else '' }}</span></td>
{% endfor %}
</tr>
</table>
@@ -0,0 +1,14 @@
{% set label_font_size = label_font_size|default(14) %}
{% set label_color = label_color|default('#333333') %}
{% set label_font_weight = label_font_weight|default('normal') %}
{% set field_font_size = field_font_size|default(14) %}
{% set field_color = field_color|default('#333333') %}
<table class="info-table" style="width: 100%; border-collapse: collapse; margin: 10px 0;">
{% for row in rows %}
<tr>
{% for cell in row.cells %}
<td colspan="{{ cell.colspan|default(1) }}" rowspan="{{ cell.rowspan|default(1) }}" style="border: {{ border }}; padding: 8px; text-align: {{ cell.align|default('left') }};{% if cell.type == 'label' %} font-size: {{ label_font_size }}px; color: {{ label_color }}; font-weight: {{ label_font_weight }};{% if label_width %} width: {{ label_width }}px;{% endif %}{% if label_bg_color %} background-color: {{ label_bg_color }};{% elif cell.backgroundColor %} background-color: {{ cell.backgroundColor }};{% endif %}{% else %} font-size: {{ field_font_size }}px; color: {{ field_color }};{% if cell.bold %} font-weight: bold;{% endif %}{% if cell.backgroundColor %} background-color: {{ cell.backgroundColor }};{% endif %}{% endif %}">{% if cell.type == 'field' and show_underline %}<span style="border-bottom: 1px solid #333; padding-bottom: 2px; display: inline-block; min-width: 50px;">{{ cell.content if cell.content else '' }}</span>{% else %}{{ cell.content }}{% endif %}</td>
{% endfor %}
</tr>
{% endfor %}
</table>
@@ -0,0 +1,10 @@
{% set label_font_size = label_font_size|default(font_size|default(12)) %}
{% set label_color = label_color|default('#333333') %}
{% set label_font_weight = label_font_weight|default('normal') %}
{% set value_font_size = value_font_size|default(font_size|default(12)) %}
{% set value_color = value_color|default('#333333') %}
{% if show_label %}
<p style="margin: 5px 0;"><span style="font-size: {{ label_font_size }}pt; color: {{ label_color }}; font-weight: {{ label_font_weight }};">{{ label }}</span><span style="font-size: {{ value_font_size }}pt; color: {{ value_color }};{% if show_underline %} border-bottom: 1px solid #333; padding-bottom: 2px; display: inline-block; min-width: 50px;{% endif %}">{{ value if value else '' }}</span></p>
{% else %}
<p style="margin: 5px 0;"><span style="font-size: {{ value_font_size }}pt; color: {{ value_color }};{% if show_underline %} border-bottom: 1px solid #333; padding-bottom: 2px; display: inline-block; min-width: 50px;{% endif %}">{{ value if value else '' }}</span></p>
{% endif %}
@@ -0,0 +1,13 @@
{% set color = color|default('#333333') %}
<div style="font-size: {{ font_size }}pt; line-height: {{ line_height }}; text-align: {{ text_align }}; color: {{ color }}; margin: 10px 0; white-space: pre-wrap; word-wrap: break-word;">
<style>
.paragraph-content p { margin: 0.5em 0; }
.paragraph-content p:first-child { margin-top: 0; }
.paragraph-content p:last-child { margin-bottom: 0; }
.paragraph-content br { display: block; content: ''; margin: 0.5em 0; }
.paragraph-content table { border-collapse: collapse; width: 100%; margin: 1em 0; }
.paragraph-content table td, .paragraph-content table th { border: 1px solid #ddd; padding: 8px; text-align: left; }
.paragraph-content table th { font-weight: 500; }
</style>
<div class="paragraph-content">{{ content|safe }}</div>
</div>
@@ -0,0 +1,27 @@
{% set label_font_size = labelFontSize|default(14) %}
{% set label_color = labelColor|default('#333333') %}
{% set label_font_weight = labelFontWeight|default('normal') %}
{% set label_position = labelPosition|default('bottom') %}
{% set align_map = {'left': 'flex-start', 'center': 'center', 'right': 'flex-end'} %}
{% set justify = align_map.get(textAlign|default('center'), 'center') %}
<div style="display: flex; justify-content: {{ justify }}; width: 100%; box-sizing: border-box; line-height: 0;">
<div style="display: inline-flex; flex-direction: {% if label_position == 'top' %}column-reverse{% elif label_position == 'left' %}row-reverse{% elif label_position == 'right' %}row{% else %}column{% endif %}; align-items: center; gap: 4px; line-height: normal;">
{% if qrcode_image %}
<img src="{{ qrcode_image }}" style="width: {{ width }}; height: {{ height }}; display: block;" />
{% else %}
<div style="width: {{ width }}; height: {{ height }}; display: inline-flex; border: 2px dashed #dcdfe6; background-color: #f5f7fa; align-items: center; justify-content: center; flex-direction: column; gap: 8px; color: #909399; font-size: 12px;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
<rect x="14" y="14" width="3" height="3"></rect>
<rect x="18" y="14" width="3" height="3"></rect>
<rect x="14" y="18" width="3" height="3"></rect>
<rect x="18" y="18" width="3" height="3"></rect>
</svg>
<span>{{ width }} × {{ height }}</span>
</div>
{% endif %}
{% if label %}<span style="font-size: {{ label_font_size }}px; color: {{ label_color }}; font-weight: {{ label_font_weight }};">{{ label }}</span>{% endif %}
</div>
</div>
@@ -0,0 +1 @@
<div class="rich-text" style="margin: 10px 0;">{{ content|safe }}</div>
@@ -0,0 +1,7 @@
<table style="width: 100%; border: none; border-collapse: separate; border-spacing: {{ gap }}px 0;">
<tr>
{% for child in children %}
<td style="{% if child.width %}width: {{ child.width }};{% endif %} vertical-align: top; border: none; padding: 0;">{{ child.content|safe }}</td>
{% endfor %}
</tr>
</table>
@@ -0,0 +1,42 @@
{% set align_map = {'left': 'flex-start', 'center': 'center', 'right': 'flex-end'} %}
{% set justify = align_map.get(text_align, 'flex-end') %}
{% set flex_direction = 'column' if label_position in ['top', 'bottom'] else 'row' %}
{% set label_font_size = label_font_size|default(14) %}
{% set label_color = label_color|default('#333333') %}
{% set label_font_weight = label_font_weight|default('normal') %}
{% set is_float = position_mode|default('inline') == 'float' %}
{% set float_style = 'position: absolute; left: %spx; top: %spx; z-index: %s;'|format(float_x|default(0), float_y|default(0), float_z_index|default(100)) if is_float else 'display: flex; justify-content: %s; margin: 10px 0; width: 100%%; box-sizing: border-box;'|format(justify) %}
{% if seal_image_data %}
<div class="seal-container" style="{{ float_style }}">
<div style="display: flex; flex-direction: {{ flex_direction }}; align-items: center; gap: 8px;">
{% if label and label_position in ['top', 'left'] %}
<span class="seal-label" style="font-size: {{ label_font_size }}px; color: {{ label_color }}; font-weight: {{ label_font_weight }}; white-space: nowrap;">{{ label }}</span>
{% endif %}
<img class="seal-image"
src="{{ seal_image_data }}"
data-seal-id="{{ seal_id }}"
data-seal-image-id="{{ seal_image_id }}"
style="width: {{ width }}px; height: {{ height }}px; object-fit: contain; flex-shrink: 0;"
alt="{{ seal_name }}" />
{% if label and label_position in ['bottom', 'right'] %}
<span class="seal-label" style="font-size: {{ label_font_size }}px; color: {{ label_color }}; font-weight: {{ label_font_weight }}; white-space: nowrap;">{{ label }}</span>
{% endif %}
</div>
</div>
{% else %}
<div class="seal-placeholder" data-seal-type="{{ seal_type }}"
style="{{ float_style }}">
<div style="display: flex; flex-direction: {{ flex_direction }}; align-items: center; gap: 8px;">
{% if label and label_position in ['top', 'left'] %}
<span class="seal-label" style="font-size: {{ label_font_size }}px; color: {{ label_color }}; font-weight: {{ label_font_weight }}; white-space: nowrap;">{{ label }}</span>
{% endif %}
<div style="width: {{ width }}px; height: {{ height }}px; border: 2px dashed #ccc; border-radius: 50%;
display: flex; align-items: center; justify-content: center; color: #999; flex-shrink: 0;">
签章
</div>
{% if label and label_position in ['bottom', 'right'] %}
<span class="seal-label" style="font-size: {{ label_font_size }}px; color: {{ label_color }}; font-weight: {{ label_font_weight }}; white-space: nowrap;">{{ label }}</span>
{% endif %}
</div>
</div>
{% endif %}
@@ -0,0 +1,5 @@
{% if src %}
<img class="element-signature" src="{{ src }}" />
{% else %}
<div class="signature-placeholder"></div>
{% endif %}
@@ -0,0 +1,52 @@
{% set table_font_size = table_font_size|default(14) %}
{% set border_mode = border_mode|default('all') %}
{% set border_color = border_color|default('#333') %}
<table class="smart-table" style="width: 100%; border-collapse: collapse; margin: 10px 0; table-layout: fixed; font-size: {{ table_font_size }}px;">
{% if col_widths %}
<colgroup>
{% for w in col_widths %}
<col style="width: {{ w }}%;" />
{% endfor %}
</colgroup>
{% endif %}
{% for row in rows %}
{% set row_idx = loop.index0 %}
{% set is_first_row = loop.first %}
{% set is_last_row = loop.last %}
<tr>
{% for cell in row.cells %}
{% set col_idx = loop.index0 %}
{% set is_first_col = loop.first %}
{% set is_last_col = loop.last %}
{% set solid_border = '1px solid ' ~ border_color %}
{% set no_border = 'none' %}
{% if border_mode == 'none' %}
{% set b_top = no_border %}{% set b_right = no_border %}{% set b_bottom = no_border %}{% set b_left = no_border %}
{% elif border_mode == 'outer' %}
{% set b_top = solid_border if is_first_row else no_border %}
{% set b_right = solid_border if is_last_col else no_border %}
{% set b_bottom = solid_border if is_last_row else no_border %}
{% set b_left = solid_border if is_first_col else no_border %}
{% elif border_mode == 'inner' %}
{% set b_top = no_border if is_first_row else solid_border %}
{% set b_right = no_border if is_last_col else solid_border %}
{% set b_bottom = no_border if is_last_row else solid_border %}
{% set b_left = no_border if is_first_col else solid_border %}
{% elif border_mode == 'horizontal' %}
{% set b_top = no_border if is_first_row else solid_border %}
{% set b_right = no_border %}
{% set b_bottom = no_border if is_last_row else solid_border %}
{% set b_left = no_border %}
{% elif border_mode == 'vertical' %}
{% set b_top = no_border %}
{% set b_right = no_border if is_last_col else solid_border %}
{% set b_bottom = no_border %}
{% set b_left = no_border if is_first_col else solid_border %}
{% else %}
{% set b_top = solid_border %}{% set b_right = solid_border %}{% set b_bottom = solid_border %}{% set b_left = solid_border %}
{% endif %}
<td colspan="{{ cell.colspan|default(1) }}" rowspan="{{ cell.rowspan|default(1) }}" style="border-top: {{ b_top }}; border-right: {{ b_right }}; border-bottom: {{ b_bottom }}; border-left: {{ b_left }}; padding: 6px 8px; text-align: {{ cell.align|default('left') }};{% if cell.bold %} font-weight: bold;{% endif %}{% if cell.fontSize %} font-size: {{ cell.fontSize }}px;{% endif %}{% if cell.color %} color: {{ cell.color }};{% endif %}{% if cell.backgroundColor %} background-color: {{ cell.backgroundColor }};{% endif %} vertical-align: top; overflow-wrap: break-word;">{{ cell.content }}</td>
{% endfor %}
</tr>
{% endfor %}
</table>
@@ -0,0 +1,4 @@
<div class="smart-text" style="margin: 8px 0; font-size: {{ font_size }}px; line-height: {{ line_height }}; text-align: {{ text_align }}; overflow-wrap: break-word;">{{ content|safe }}</div>
{% if table_html %}
<div class="smart-text-table" style="margin: 4px 0 8px 0;">{{ table_html|safe }}</div>
{% endif %}
@@ -0,0 +1 @@
<div style="height: {{ height }}px;"></div>
@@ -0,0 +1,16 @@
<table class="element-table">
<thead><tr>
{% for col in columns %}
<th style="width: {{ col.width|default('auto') }}">{{ col.label }}</th>
{% endfor %}
</tr></thead>
<tbody>
{% for row in rows %}
<tr>
{% for col in columns %}
<td>{{ row[col.field]|default('') }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
@@ -0,0 +1 @@
<span class="text-content">{{ content }}</span>
@@ -0,0 +1,2 @@
{% set color = color|default('#333333') %}
<h1 style="font-size: {{ font_size }}pt; font-weight: {{ font_weight }}; text-align: {{ text_align }}; color: {{ color }}; margin: 15px 0;">{{ content }}</h1>
@@ -0,0 +1,11 @@
<div class="wysiwyg-content" style="font-size: 14px; line-height: 1.6;">
{{ content }}
</div>
<style>
.wysiwyg-content h1 { font-size: 28px; font-weight: bold; margin: 16px 0 8px; line-height: 1.3; }
.wysiwyg-content h2 { font-size: 22px; font-weight: bold; margin: 14px 0 6px; line-height: 1.3; }
.wysiwyg-content h3 { font-size: 18px; font-weight: bold; margin: 12px 0 4px; line-height: 1.4; }
.wysiwyg-content p { margin: 4px 0; }
.wysiwyg-content ul, .wysiwyg-content ol { padding-left: 24px; margin: 4px 0; }
.wysiwyg-content img { max-width: 100%; height: auto; }
</style>
@@ -0,0 +1,5 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
电子签章模块
"""
@@ -0,0 +1,162 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
电子签章API接口
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.config import settings
from app.base_schema import PaginatedResponse, ResponseModel
from online_dev.electronic_seal.schema import (
ElectronicSealCreate, ElectronicSealUpdate, ElectronicSealOut, SealUsageLogOut
)
from online_dev.electronic_seal.service import ElectronicSealService, SealUsageLogService
router = APIRouter(prefix="/electronic-seal", tags=["电子签章"])
@router.get("", response_model=PaginatedResponse[ElectronicSealOut], summary="获取签章列表")
async def get_seal_list(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=settings.PAGE_SIZE, ge=1, le=settings.PAGE_MAX_SIZE, alias="pageSize"),
seal_type: Optional[str] = Query(default=None, alias="sealType"),
status: Optional[str] = Query(default=None),
keyword: Optional[str] = Query(default=None),
db: AsyncSession = Depends(get_db),
):
"""获取签章列表"""
items, total = await ElectronicSealService.get_list(
db,
page=page,
page_size=page_size,
seal_type=seal_type,
status=status,
keyword=keyword,
)
return PaginatedResponse(items=items, total=total)
@router.get("/available", response_model=List[ElectronicSealOut], summary="获取可用签章")
async def get_available_seals(
template_id: Optional[str] = Query(default=None, alias="templateId"),
db: AsyncSession = Depends(get_db),
request: Request = None,
):
"""获取当前用户可用的签章"""
# 从请求中获取用户信息
user_id = getattr(request.state, "user_id", "") if request else ""
dept_id = getattr(request.state, "dept_id", None) if request else None
role_ids = getattr(request.state, "role_ids", None) if request else None
seals = await ElectronicSealService.get_available_seals(
db,
user_id=user_id,
dept_id=dept_id,
role_ids=role_ids,
template_id=template_id,
)
return seals
@router.post("", response_model=ElectronicSealOut, summary="创建签章")
async def create_seal(
data: ElectronicSealCreate,
db: AsyncSession = Depends(get_db),
):
"""创建电子签章"""
seal = await ElectronicSealService.create(db, data)
return seal
@router.get("/{seal_id}", response_model=ElectronicSealOut, summary="获取签章详情")
async def get_seal(
seal_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取签章详情"""
seal = await ElectronicSealService.get_by_id(db, seal_id)
if not seal:
raise HTTPException(status_code=404, detail="签章不存在")
return seal
@router.put("/{seal_id}", response_model=ElectronicSealOut, summary="更新签章")
async def update_seal(
seal_id: str,
data: ElectronicSealUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新签章"""
seal = await ElectronicSealService.update(db, seal_id, data)
if not seal:
raise HTTPException(status_code=404, detail="签章不存在")
return seal
@router.delete("/{seal_id}", response_model=ResponseModel, summary="删除签章")
async def delete_seal(
seal_id: str,
db: AsyncSession = Depends(get_db),
):
"""删除签章"""
seal = await ElectronicSealService.get_by_id(db, seal_id)
if not seal:
raise HTTPException(status_code=404, detail="签章不存在")
await ElectronicSealService.delete(db, seal_id)
return ResponseModel(message="删除成功")
@router.post("/{seal_id}/enable", response_model=ElectronicSealOut, summary="启用签章")
async def enable_seal(
seal_id: str,
db: AsyncSession = Depends(get_db),
):
"""启用签章"""
seal = await ElectronicSealService.get_by_id(db, seal_id)
if not seal:
raise HTTPException(status_code=404, detail="签章不存在")
seal = await ElectronicSealService.update(
db, seal_id, ElectronicSealUpdate(status="active")
)
return seal
@router.post("/{seal_id}/disable", response_model=ElectronicSealOut, summary="禁用签章")
async def disable_seal(
seal_id: str,
db: AsyncSession = Depends(get_db),
):
"""禁用签章"""
seal = await ElectronicSealService.get_by_id(db, seal_id)
if not seal:
raise HTTPException(status_code=404, detail="签章不存在")
seal = await ElectronicSealService.update(
db, seal_id, ElectronicSealUpdate(status="disabled")
)
return seal
@router.get("/{seal_id}/logs", response_model=PaginatedResponse[SealUsageLogOut], summary="获取签章使用记录")
async def get_seal_usage_logs(
seal_id: str,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=settings.PAGE_SIZE, ge=1, le=settings.PAGE_MAX_SIZE, alias="pageSize"),
db: AsyncSession = Depends(get_db),
):
"""获取签章的使用记录"""
seal = await ElectronicSealService.get_by_id(db, seal_id)
if not seal:
raise HTTPException(status_code=404, detail="签章不存在")
items, total = await SealUsageLogService.get_logs_by_seal(
db, seal_id, page=page, page_size=page_size
)
return PaginatedResponse(items=items, total=total)
@@ -0,0 +1,68 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
电子签章数据模型
"""
from sqlalchemy import Column, String, Text, Integer, Boolean, Index, JSON
from app.base_model import BaseModel
class ElectronicSeal(BaseModel):
"""电子签章配置"""
__tablename__ = "electronic_seal"
# 基础信息
name = Column(String(100), nullable=False, comment="签章名称")
seal_type = Column(String(20), nullable=False, comment="类型: company/department/personal/contract/finance")
description = Column(Text, default="", comment="描述")
# 签章图片
seal_image_id = Column(String(36), nullable=False, comment="签章图片文件ID")
# 使用权限
owner_type = Column(String(20), default="all", comment="所有者类型: all/dept/role/user")
owner_ids = Column(JSON, nullable=True, comment="所有者ID列表")
# 使用范围
scope = Column(String(20), default="all", comment="使用范围: all/specific")
allowed_template_ids = Column(JSON, nullable=True, comment="允许使用的模板ID列表")
# 签章属性
width = Column(Integer, default=120, comment="宽度(像素)")
height = Column(Integer, default=120, comment="高度(像素)")
# 状态
status = Column(String(20), default="active", comment="状态: active/disabled")
__table_args__ = (
Index("ix_electronic_seal_seal_type", "seal_type"),
Index("ix_electronic_seal_status", "status"),
)
class SealUsageLog(BaseModel):
"""签章使用记录"""
__tablename__ = "seal_usage_log"
# 关联签章
seal_id = Column(String(36), nullable=False, index=True, comment="签章ID")
seal_name = Column(String(100), nullable=True, comment="签章名称")
# 关联文档
document_id = Column(String(36), nullable=False, index=True, comment="文档ID")
document_name = Column(String(200), nullable=True, comment="文档名称")
# 使用信息
user_id = Column(String(36), nullable=False, comment="使用人ID")
user_name = Column(String(100), nullable=True, comment="使用人姓名")
# 位置信息
position_x = Column(Integer, default=0, comment="X坐标")
position_y = Column(Integer, default=0, comment="Y坐标")
page_number = Column(Integer, default=1, comment="页码")
__table_args__ = (
Index("ix_seal_usage_log_seal_id", "seal_id"),
Index("ix_seal_usage_log_document_id", "document_id"),
)
@@ -0,0 +1,79 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
电子签章Schema定义
"""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, ConfigDict, Field
class ElectronicSealBase(BaseModel):
"""电子签章基础Schema"""
name: str = Field(..., min_length=1, max_length=100, description="签章名称")
seal_type: str = Field(..., description="类型: company/department/personal/contract/finance")
description: Optional[str] = Field(default="", description="描述")
seal_image_id: str = Field(..., description="签章图片文件ID")
owner_type: str = Field(default="all", description="所有者类型: all/dept/role/user")
owner_ids: Optional[List[str]] = Field(default=None, description="所有者ID列表")
scope: str = Field(default="all", description="使用范围: all/specific")
allowed_template_ids: Optional[List[str]] = Field(default=None, description="允许使用的模板ID列表")
width: int = Field(default=120, ge=20, le=500, description="宽度(像素)")
height: int = Field(default=120, ge=20, le=500, description="高度(像素)")
class ElectronicSealCreate(ElectronicSealBase):
"""创建电子签章"""
pass
class ElectronicSealUpdate(BaseModel):
"""更新电子签章"""
name: Optional[str] = Field(default=None, max_length=100, description="签章名称")
description: Optional[str] = Field(default=None, description="描述")
seal_image_id: Optional[str] = Field(default=None, description="签章图片文件ID")
owner_type: Optional[str] = Field(default=None, description="所有者类型")
owner_ids: Optional[List[str]] = Field(default=None, description="所有者ID列表")
scope: Optional[str] = Field(default=None, description="使用范围")
allowed_template_ids: Optional[List[str]] = Field(default=None, description="允许使用的模板ID列表")
width: Optional[int] = Field(default=None, description="宽度")
height: Optional[int] = Field(default=None, description="高度")
status: Optional[str] = Field(default=None, description="状态")
class ElectronicSealOut(ElectronicSealBase):
"""电子签章输出"""
id: str
status: str = "active"
seal_image_url: Optional[str] = None
sys_create_datetime: Optional[datetime] = None
sys_update_datetime: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class SealUsageLogOut(BaseModel):
"""签章使用记录输出"""
id: str
seal_id: str
seal_name: Optional[str] = None
document_id: str
document_name: Optional[str] = None
user_id: str
user_name: Optional[str] = None
position_x: int = 0
position_y: int = 0
page_number: int = 1
sys_create_datetime: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
@@ -0,0 +1,195 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
电子签章服务层
"""
from typing import Optional, List, Tuple
from sqlalchemy import select, func, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.base_service import BaseService
from online_dev.electronic_seal.model import ElectronicSeal, SealUsageLog
from online_dev.electronic_seal.schema import ElectronicSealCreate, ElectronicSealUpdate
class ElectronicSealService(BaseService[ElectronicSeal, ElectronicSealCreate, ElectronicSealUpdate]):
"""电子签章服务"""
model = ElectronicSeal
@classmethod
async def get_list(
cls,
db: AsyncSession,
page: int = 1,
page_size: int = 20,
seal_type: Optional[str] = None,
status: Optional[str] = None,
keyword: Optional[str] = None,
) -> Tuple[List[ElectronicSeal], int]:
"""获取签章列表"""
conditions = [cls.model.is_deleted == False]
if seal_type:
conditions.append(cls.model.seal_type == seal_type)
if status:
conditions.append(cls.model.status == status)
if keyword:
conditions.append(
or_(
cls.model.name.ilike(f"%{keyword}%"),
cls.model.description.ilike(f"%{keyword}%"),
)
)
# 查询总数
count_stmt = select(func.count()).select_from(cls.model).where(and_(*conditions))
total = (await db.execute(count_stmt)).scalar() or 0
# 查询列表
stmt = (
select(cls.model)
.where(and_(*conditions))
.order_by(cls.model.sys_create_datetime.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await db.execute(stmt)
items = list(result.scalars().all())
return items, total
@classmethod
async def get_available_seals(
cls,
db: AsyncSession,
user_id: str,
dept_id: Optional[str] = None,
role_ids: Optional[List[str]] = None,
template_id: Optional[str] = None,
) -> List[ElectronicSeal]:
"""获取用户可用的签章"""
conditions = [
cls.model.is_deleted == False,
cls.model.status == "active",
]
stmt = select(cls.model).where(and_(*conditions))
result = await db.execute(stmt)
all_seals = list(result.scalars().all())
available_seals = []
for seal in all_seals:
# 检查所有者权限
if seal.owner_type == "all":
pass
elif seal.owner_type == "user":
if not seal.owner_ids or user_id not in seal.owner_ids:
continue
elif seal.owner_type == "dept":
if not seal.owner_ids or not dept_id or dept_id not in seal.owner_ids:
continue
elif seal.owner_type == "role":
if not seal.owner_ids or not role_ids:
continue
if not any(r in seal.owner_ids for r in role_ids):
continue
# 检查模板范围
if seal.scope == "specific" and template_id:
if not seal.allowed_template_ids or template_id not in seal.allowed_template_ids:
continue
available_seals.append(seal)
return available_seals
class SealUsageLogService(BaseService[SealUsageLog, None, None]):
"""签章使用记录服务"""
model = SealUsageLog
@classmethod
async def log_usage(
cls,
db: AsyncSession,
seal_id: str,
seal_name: str,
document_id: str,
document_name: str,
user_id: str,
user_name: str,
position_x: int = 0,
position_y: int = 0,
page_number: int = 1,
) -> SealUsageLog:
"""记录签章使用"""
log = SealUsageLog(
seal_id=seal_id,
seal_name=seal_name,
document_id=document_id,
document_name=document_name,
user_id=user_id,
user_name=user_name,
position_x=position_x,
position_y=position_y,
page_number=page_number,
)
db.add(log)
await db.commit()
await db.refresh(log)
return log
@classmethod
async def get_logs_by_seal(
cls,
db: AsyncSession,
seal_id: str,
page: int = 1,
page_size: int = 20,
) -> Tuple[List[SealUsageLog], int]:
"""获取签章的使用记录"""
conditions = [
cls.model.is_deleted == False,
cls.model.seal_id == seal_id,
]
# 查询总数
count_stmt = select(func.count()).select_from(cls.model).where(and_(*conditions))
total = (await db.execute(count_stmt)).scalar() or 0
# 查询列表
stmt = (
select(cls.model)
.where(and_(*conditions))
.order_by(cls.model.sys_create_datetime.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await db.execute(stmt)
items = list(result.scalars().all())
return items, total
@classmethod
async def get_logs_by_document(
cls,
db: AsyncSession,
document_id: str,
) -> List[SealUsageLog]:
"""获取文档的签章记录"""
stmt = (
select(cls.model)
.where(
and_(
cls.model.is_deleted == False,
cls.model.document_id == document_id,
)
)
.order_by(cls.model.sys_create_datetime.desc())
)
result = await db.execute(stmt)
return list(result.scalars().all())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,242 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
表单数据权限校验依赖
"""
import logging
from typing import Dict, Any, List, Optional
from fastapi import Depends, HTTPException, Request
from sqlalchemy import exists, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
logger = logging.getLogger(__name__)
async def get_user_info(request: Request) -> Dict[str, Any]:
"""从请求中获取用户信息"""
return {
"user_id": getattr(request.state, 'user_id', None),
"dept_id": getattr(request.state, 'dept_id', None),
"role_ids": getattr(request.state, 'role_ids', []),
"is_superuser": getattr(request.state, 'is_superuser', False),
}
async def check_form_permission(
form_code: str,
action: str,
request: Request,
db: AsyncSession
) -> bool:
"""
检查用户是否有表单操作权限
:param form_code: 表单编码
:param action: 操作类型 (view/add/edit/delete/export/import)
:param request: 请求对象
:param db: 数据库会话
:return: True 表示有权限
:raises HTTPException: 无权限时抛出 403 异常
"""
user_id = getattr(request.state, 'user_id', None)
is_superuser = getattr(request.state, 'is_superuser', False)
# 超级管理员跳过权限检查
if is_superuser:
return True
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
# 权限编码
perm_code = f"form:{form_code}:{action}"
# 查询用户角色的权限
from core.user.model import User
from core.role.model import Role, role_permission
from core.permission.model import Permission
# 获取用户的角色ID列表
role_ids = getattr(request.state, 'role_ids', [])
if not role_ids:
# 如果 request.state 中没有角色信息,从数据库查询
user_stmt = select(User).where(User.id == user_id)
user_result = await db.execute(user_stmt)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="用户不存在")
role_ids = [role.id for role in user.roles] if hasattr(user, 'roles') else []
if not role_ids:
raise HTTPException(status_code=403, detail=f"没有{action}权限")
# 多个角色可能同时拥有同一权限,用 EXISTS 判断即可
has_perm_stmt = select(
exists(
select(Permission.id)
.join(
role_permission,
Permission.id == role_permission.c.permission_id,
)
.where(
role_permission.c.role_id.in_(role_ids),
Permission.code == perm_code,
Permission.is_active == True,
Permission.is_deleted == False,
)
)
)
has_permission = await db.scalar(has_perm_stmt)
if has_permission:
return True
# 操作名称映射
action_names = {
"view": "查看",
"add": "新增",
"edit": "编辑",
"delete": "删除",
"export": "导出",
"import": "导入",
}
action_name = action_names.get(action, action)
raise HTTPException(status_code=403, detail=f"没有{action_name}权限")
async def get_user_form_permissions(
form_code: str,
request: Request,
db: AsyncSession
) -> Dict[str, bool]:
"""
获取用户对表单的所有操作权限
:param form_code: 表单编码
:param request: 请求对象
:param db: 数据库会话
:return: 权限字典
"""
user_id = getattr(request.state, 'user_id', None)
is_superuser = getattr(request.state, 'is_superuser', False)
# 默认权限
permissions = {
"view": False,
"add": False,
"edit": False,
"delete": False,
"export": False,
"import": False,
}
# 超级管理员拥有所有权限
if is_superuser:
return {k: True for k in permissions.keys()}
if not user_id:
return permissions
# 获取用户角色ID列表
role_ids = getattr(request.state, 'role_ids', [])
if not role_ids:
from core.user.model import User
user_stmt = select(User).where(User.id == user_id)
user_result = await db.execute(user_stmt)
user = user_result.scalar_one_or_none()
if user and hasattr(user, 'roles'):
role_ids = [role.id for role in user.roles]
if not role_ids:
return permissions
# 查询用户拥有的表单权限
from core.role.model import role_permission
from core.permission.model import Permission
perm_stmt = select(Permission.code).join(
role_permission,
Permission.id == role_permission.c.permission_id
).where(
role_permission.c.role_id.in_(role_ids),
Permission.code.like(f"form:{form_code}:%"),
Permission.is_active == True,
Permission.is_deleted == False
)
perm_result = await db.execute(perm_stmt)
perm_codes = [row[0] for row in perm_result.fetchall()]
# 解析权限
for perm_code in perm_codes:
# 格式: form:{form_code}:{action}
parts = perm_code.split(":")
if len(parts) == 3 and parts[2] in permissions:
permissions[parts[2]] = True
return permissions
async def get_data_scope_filter(
form_code: str,
request: Request,
db: AsyncSession
) -> Dict[str, Any]:
"""
获取数据权限过滤条件
:param form_code: 表单编码
:param request: 请求对象
:param db: 数据库会话
:return: 数据权限过滤配置
"""
user_id = getattr(request.state, 'user_id', None)
dept_id = getattr(request.state, 'dept_id', None)
role_ids = getattr(request.state, 'role_ids', [])
is_superuser = getattr(request.state, 'is_superuser', False)
# 超级管理员:全部数据
if is_superuser:
return {
'filter_type': 'all',
'scope': 0,
'user_id': None,
'dept_id': None,
'dept_ids': None
}
# 资源类型
resource_type = f"form:{form_code}"
# 查询数据权限配置
from core.resource_scope.scope_permission.service import ResourceDataScopeConfigService
config = await ResourceDataScopeConfigService.get_resource_data_scope(
db=db,
role_ids=role_ids,
resource_type=resource_type,
is_superuser=False
)
# 填充用户信息
if config['filter_type'] == 'self':
config['user_id'] = user_id
elif config['filter_type'] == 'dept':
config['dept_id'] = dept_id
elif config['filter_type'] == 'dept_and_children':
if dept_id:
from core.dept.service import DeptService
descendants = await DeptService.get_descendants(db, dept_id)
config['dept_ids'] = [dept_id] + [d.id for d in descendants]
else:
config['dept_ids'] = []
return config
@@ -0,0 +1,44 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
表单数据操作 Schema 定义
"""
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class FormDataCreateIn(BaseModel):
"""表单数据新增请求"""
main: Dict[str, Any] = Field(..., description="主表数据")
sub_tables: Dict[str, List[Dict[str, Any]]] = Field(default_factory=dict, description="子表数据")
class FormDataUpdateIn(BaseModel):
"""表单数据更新请求"""
main: Dict[str, Any] = Field(..., description="主表数据")
sub_tables: Dict[str, List[Dict[str, Any]]] = Field(default_factory=dict, description="子表数据")
class FormDataListOut(BaseModel):
"""表单数据列表输出"""
items: List[Dict[str, Any]]
total: int
page: int
page_size: int
class FormDataCursorListOut(BaseModel):
"""游标分页列表输出"""
items: List[Dict[str, Any]]
has_more: bool
next_cursor: Optional[str] = None
prev_cursor: Optional[str] = None
page_size: int
class FormDataImportResult(BaseModel):
"""导入结果"""
success: int = Field(..., description="成功数量")
failed: int = Field(..., description="失败数量")
errors: List[Dict[str, Any]] = Field(default_factory=list, description="错误详情")
@@ -0,0 +1,393 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
表单管理 API异步版本
表单元数据的 CRUD发布复制导入导出
"""
import json
import logging
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.base_schema import PaginatedResponse, ResponseModel
from online_dev.form_manager.schema import (
FormImportCheckIn,
FormImportCheckOut,
FormImportIn,
FormValidateTablesIn,
FormValidateTablesOut,
FormMetaCreateIn,
FormMetaListOut,
FormMetaOut,
FormMetaUpdateIn,
FormPublishIn,
FormSubTableOut,
)
from online_dev.form_manager.service import FormService, FormServiceException
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/form", tags=["表单管理"])
# ============ 辅助函数 ============
def _format_datetime(dt) -> str:
"""格式化日期时间"""
if dt:
return dt.strftime("%Y-%m-%d %H:%M:%S")
return ""
async def _build_form_out(db: AsyncSession, form) -> dict:
"""构建表单详情输出"""
sub_tables = await FormService.get_sub_tables(db, form.id)
return {
"id": str(form.id),
"application_id": form.application_id,
"name": form.name,
"code": form.code,
"form_type": form.form_type,
"description": form.description or "",
"status": form.status,
"version": form.version,
"db_config": form.db_config,
"main_table": form.main_table,
"main_table_schema": form.main_table_schema or "",
"main_table_database": form.main_table_database or "",
"form_config": form.form_config or {},
"list_config": form.list_config or {},
"sort": form.sort or 0,
"show_in_mobile": form.show_in_mobile or False,
"globally_visible": form.globally_visible or False,
"icon": form.icon or "",
"icon_bg_color": form.icon_bg_color or "",
"sys_create_datetime": _format_datetime(form.sys_create_datetime),
"sys_update_datetime": _format_datetime(form.sys_update_datetime),
"sub_tables": [
{
"id": str(sub.id),
"table_name": sub.table_name,
"table_schema": sub.table_schema or "",
"table_database": sub.table_database or "",
"alias": sub.alias or "",
"foreign_key": sub.foreign_key,
"related_field": sub.related_field or "id",
"relation_type": sub.relation_type or "one-to-many",
"sort": sub.sort or 0,
}
for sub in sub_tables
],
}
def _build_form_list_out(form, application_name: str = None, application_code: str = "") -> dict:
"""构建表单列表输出"""
return {
"id": str(form.id),
"application_id": form.application_id,
"application_name": application_name or "主应用",
"application_code": application_code or "",
"name": form.name,
"code": form.code,
"form_type": form.form_type,
"description": form.description or "",
"status": form.status,
"version": form.version,
"main_table": form.main_table,
"sort": form.sort or 0,
"show_in_mobile": form.show_in_mobile or False,
"globally_visible": form.globally_visible or False,
"icon": form.icon or "",
"icon_bg_color": form.icon_bg_color or "",
"sys_create_datetime": _format_datetime(form.sys_create_datetime),
"sys_update_datetime": _format_datetime(form.sys_update_datetime),
}
# ============ 表单元数据 CRUD ============
@router.get("/list", response_model=PaginatedResponse[FormMetaListOut], summary="表单列表")
async def list_forms(
application_id: str = Query(None, alias="applicationId", description="所属应用ID"),
name: str = Query(None, description="表单名称"),
code: str = Query(None, description="表单编码"),
form_type: str = Query(None, alias="formType", description="表单类型"),
status: str = Query(None, description="状态"),
include_globally_visible: bool = Query(
False, alias="includeGloballyVisible", description="是否包含其他应用全局可见的表单"
),
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db),
):
"""分页查询表单列表(自动应用数据权限)"""
result = await FormService.list_with_data_scope(
db=db,
page=page,
page_size=page_size,
application_id=application_id,
name=name,
code=code,
form_type=form_type,
status=status,
include_globally_visible=include_globally_visible,
)
return PaginatedResponse(
items=[_build_form_list_out(
item,
getattr(item, 'application_name', '主应用'),
getattr(item, 'application_code', ''),
) for item in result["items"]],
total=result["total"],
)
@router.get("/form-types", summary="获取表单类型列表")
async def get_form_types():
"""获取所有表单类型"""
return FormService.get_form_types()
@router.get("/published/simple", summary="获取已发布表单简单列表")
async def get_published_forms_simple(
application_id: str = Query(None, alias="applicationId", description="所属应用ID"),
all_apps: bool = Query(False, alias="allApps", description="是否返回所有应用的表单(移动端工作台使用)"),
db: AsyncSession = Depends(get_db),
):
"""
获取已发布表单的简单列表用于下拉选择
返回格式: [{code, name, mainTable, application_id, application_name, fields: [{field, label, type}]}]
"""
try:
return await FormService.get_published_forms_simple(db, application_id=application_id, all_apps=all_apps)
except FormServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/{form_id}", response_model=FormMetaOut, summary="表单详情")
async def get_form(
form_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取表单详情"""
try:
form = await FormService.get(db, form_id)
return await _build_form_out(db, form)
except FormServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/code/{code}", response_model=FormMetaOut, summary="根据编码获取表单")
async def get_form_by_code(
code: str,
db: AsyncSession = Depends(get_db),
):
"""根据编码获取表单详情"""
try:
form = await FormService.get_by_code(db, code)
return await _build_form_out(db, form)
except FormServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("", response_model=FormMetaOut, summary="创建表单")
async def create_form(
request: Request,
data: FormMetaCreateIn,
db: AsyncSession = Depends(get_db),
):
"""创建表单"""
user_id = request.state.user_id
try:
form = await FormService.create(db, data.model_dump(), user_id)
return await _build_form_out(db, form)
except FormServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{form_id}", response_model=FormMetaOut, summary="更新表单")
async def update_form(
request: Request,
form_id: str,
data: FormMetaUpdateIn,
db: AsyncSession = Depends(get_db),
):
"""更新表单"""
user_id = request.state.user_id
try:
form = await FormService.update(db, form_id, data.model_dump(exclude_none=True), user_id)
return await _build_form_out(db, form)
except FormServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/batch/delete", response_model=dict, summary="批量删除表单")
async def batch_delete_forms(
ids: List[str] = Query(..., description="表单ID列表"),
db: AsyncSession = Depends(get_db),
):
"""批量删除表单"""
count = await FormService.batch_delete(db, ids)
return {"count": count}
@router.delete("/{form_id}", response_model=FormMetaOut, summary="删除表单")
async def delete_form(
form_id: str,
db: AsyncSession = Depends(get_db),
):
"""删除表单"""
try:
form = await FormService.get(db, form_id)
form_out = await _build_form_out(db, form)
await FormService.delete(db, form_id)
return form_out
except FormServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
# ============ 发布/取消发布 ============
@router.post("/{form_id}/publish", response_model=FormMetaOut, summary="发布表单")
async def publish_form(
form_id: str,
data: FormPublishIn,
db: AsyncSession = Depends(get_db),
):
"""发布表单并创建菜单"""
try:
form = await FormService.publish(db, form_id, data.model_dump())
return await _build_form_out(db, form)
except FormServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{form_id}/unpublish", response_model=FormMetaOut, summary="取消发布")
async def unpublish_form(
form_id: str,
db: AsyncSession = Depends(get_db),
):
"""取消发布表单并删除菜单"""
try:
form = await FormService.unpublish(db, form_id)
return await _build_form_out(db, form)
except FormServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
# ============ 复制 ============
@router.post("/{form_id}/copy", response_model=FormMetaOut, summary="复制表单")
async def copy_form(
request: Request,
form_id: str,
new_code: str = Query(..., alias="new_code", description="新表单编码"),
new_name: str = Query(None, alias="new_name", description="新表单名称"),
db: AsyncSession = Depends(get_db),
):
"""复制表单"""
user_id = request.state.user_id
try:
form = await FormService.copy(db, form_id, new_code, new_name, user_id)
return await _build_form_out(db, form)
except FormServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
# ============ 导入/导出配置 ============
@router.get("/{form_id}/export", summary="导出表单配置")
async def export_form_config(
form_id: str,
db: AsyncSession = Depends(get_db),
):
"""导出表单配置为 JSON"""
try:
config = await FormService.export_config(db, form_id)
# 返回 JSON 文件
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 FormServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/batch/export", summary="批量导出表单配置")
async def batch_export_form_config(
ids: List[str],
db: AsyncSession = Depends(get_db),
):
"""批量导出多个表单配置为 JSON 数组"""
try:
configs = []
for form_id in ids:
config = await FormService.export_config(db, form_id)
configs.append(config)
content = json.dumps(configs, ensure_ascii=False, indent=2)
return StreamingResponse(
iter([content]),
media_type="application/json",
headers={
"Content-Disposition": 'attachment; filename="forms_export.json"'
}
)
except FormServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/validate-tables", response_model=FormValidateTablesOut, summary="校验表单物理表")
async def validate_form_tables(
data: FormValidateTablesIn,
db: AsyncSession = Depends(get_db),
):
"""校验目标连接可用且主/子表存在(保存/发布前)"""
try:
return await FormService.validate_form_tables(db, data.model_dump())
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/import/check", response_model=FormImportCheckOut, summary="导入预检查")
async def check_import_form_config(
data: FormImportCheckIn,
db: AsyncSession = Depends(get_db),
):
"""检查导入配置:编码冲突、目标表是否存在"""
try:
result = await FormService.check_import(db, data.model_dump())
return result
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/import", response_model=FormMetaOut, summary="导入表单配置")
async def import_form_config(
request: Request,
data: FormImportIn,
db: AsyncSession = Depends(get_db),
):
"""导入表单配置(支持自动建表)"""
user_id = request.state.user_id
try:
form = await FormService.import_config(db, data.model_dump(), user_id)
return await _build_form_out(db, form)
except FormServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -0,0 +1,259 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
表单管理 Schema 定义
"""
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, ConfigDict
# ============ 表单元数据 Schema ============
class FormMetaBase(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="表单编码(字母开头,只能包含字母、数字和下划线)")
form_type: str = Field("normal", description="表单类型: normal-普通表单, workflow-流程表单")
description: str = Field("", description="描述")
sort: int = Field(0, description="排序")
show_in_mobile: bool = Field(False, description="是否在移动端显示")
globally_visible: bool = Field(False, description="是否全局可见(供其他应用引用)")
icon: str = Field("", description="图标")
icon_bg_color: str = Field("", description="图标背景色")
class FormSubTableSchema(BaseModel):
"""子表关联配置"""
table_name: str = Field(..., description="从表名")
table_schema: str = Field("", description="从表Schema")
table_database: str = Field("", description="从表数据库")
alias: str = Field("", description="别名")
foreign_key: str = Field(..., description="外键字段")
related_field: str = Field("id", description="关联主表字段")
relation_type: str = Field("one-to-many", description="关联类型")
sort: int = Field(0, description="排序")
class FormMetaCreateIn(FormMetaBase):
"""创建表单请求"""
db_config: str = Field(..., description="数据库配置名")
main_table: str = Field(..., description="主表名")
main_table_schema: str = Field("", description="主表Schema")
main_table_database: str = Field("", description="主表数据库")
form_config: Dict[str, Any] = Field(default_factory=dict, description="表单设计配置")
list_config: Dict[str, Any] = Field(default_factory=dict, description="列表设计配置")
sub_tables: List[FormSubTableSchema] = Field(default_factory=list, description="子表配置")
class FormMetaUpdateIn(BaseModel):
"""更新表单请求"""
name: Optional[str] = Field(None, description="表单名称")
form_type: Optional[str] = Field(None, description="表单类型")
description: Optional[str] = Field(None, description="描述")
sort: Optional[int] = Field(None, description="排序")
show_in_mobile: Optional[bool] = Field(None, description="是否在移动端显示")
globally_visible: Optional[bool] = Field(None, description="是否全局可见(供其他应用引用)")
icon: Optional[str] = Field(None, description="图标")
icon_bg_color: Optional[str] = Field(None, description="图标背景色")
db_config: Optional[str] = Field(None, description="数据库配置名")
main_table: Optional[str] = Field(None, description="主表名")
main_table_schema: Optional[str] = Field(None, description="主表Schema")
main_table_database: Optional[str] = Field(None, description="主表数据库")
form_config: Optional[Dict[str, Any]] = Field(None, description="表单设计配置")
list_config: Optional[Dict[str, Any]] = Field(None, description="列表设计配置")
sub_tables: Optional[List[FormSubTableSchema]] = Field(None, description="子表配置")
class FormSubTableOut(BaseModel):
"""子表关联输出"""
id: str
table_name: str
table_schema: str = ""
table_database: str = ""
alias: str = ""
foreign_key: str
related_field: str = "id"
relation_type: str = "one-to-many"
sort: int = 0
model_config = ConfigDict(from_attributes=True)
class FormMetaOut(BaseModel):
"""表单详情输出"""
id: str
name: str
code: str
form_type: str
description: str = ""
status: str
version: int
db_config: str
main_table: str
main_table_schema: str = ""
main_table_database: str = ""
show_in_mobile: bool = False
globally_visible: bool = False
icon: str = ""
icon_bg_color: str = ""
form_config: Dict[str, Any] = {}
list_config: Dict[str, Any] = {}
sort: int = 0
sys_create_datetime: Optional[str] = None
sys_update_datetime: Optional[str] = None
sub_tables: List[FormSubTableOut] = []
model_config = ConfigDict(from_attributes=True)
class FormMetaListOut(BaseModel):
"""表单列表输出"""
id: str
application_id: Optional[str] = None
application_name: str = "主应用"
application_code: str = ""
name: str
code: str
form_type: str
description: str = ""
status: str
version: int
main_table: str
show_in_mobile: bool = False
globally_visible: bool = False
icon: str = ""
icon_bg_color: str = ""
sort: int = 0
sys_create_datetime: Optional[str] = None
sys_update_datetime: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
# ============ 导入导出 Schema ============
class TableDDLSchema(BaseModel):
"""表 DDL 信息"""
main_table: str = Field("", description="主表 DDL")
sub_tables: Dict[str, str] = Field(default_factory=dict, description="子表 DDLkey 为表名")
class FormExportOut(BaseModel):
"""表单配置导出"""
name: str
code: str
form_type: str
description: str = ""
globally_visible: bool = False
show_in_mobile: bool = False
db_config: str
main_table: str
main_table_schema: str = ""
main_table_database: str = ""
form_config: Dict[str, Any] = {}
list_config: Dict[str, Any] = {}
sub_tables: List[FormSubTableSchema] = []
table_ddl: Optional[TableDDLSchema] = None
class TableRenameMapping(BaseModel):
"""表重命名映射"""
original_name: str = Field(..., description="原始表名")
new_name: str = Field(..., description="新表名")
new_schema: Optional[str] = Field(None, description="新Schema")
class FormImportIn(BaseModel):
"""表单配置导入"""
application_id: Optional[str] = Field(None, description="所属应用ID")
name: str = Field(..., description="表单名称")
code: str = Field(..., description="表单编码")
form_type: str = Field("normal", description="表单类型")
description: str = Field("", description="描述")
db_config: str = Field(..., description="数据库配置名")
main_table: str = Field(..., description="主表名")
main_table_schema: str = Field("", description="主表Schema")
main_table_database: str = Field("", description="主表数据库")
form_config: Dict[str, Any] = Field(default_factory=dict, description="表单设计配置")
list_config: Dict[str, Any] = Field(default_factory=dict, description="列表设计配置")
show_in_mobile: bool = Field(False, description="是否在移动端显示")
globally_visible: bool = Field(False, description="是否全局可见(供其他应用引用)")
sub_tables: List[FormSubTableSchema] = Field(default_factory=list, description="子表配置")
table_ddl: Optional[TableDDLSchema] = Field(None, description="表 DDL(用于自动建表)")
auto_create_tables: bool = Field(False, description="是否自动创建不存在的表")
create_schema_if_not_exists: bool = Field(
False, description="目标 Schema 不存在时是否自动创建(PostgreSQL/SQL Server"
)
table_rename_mappings: List[TableRenameMapping] = Field(default_factory=list, description="表重命名映射列表")
class TableCheckResult(BaseModel):
"""单表检查结果"""
table_name: str = Field(..., description="表名")
schema_name: str = Field("", description="Schema名")
exists: bool = Field(..., description="表是否存在")
has_ddl: bool = Field(False, description="导入数据中是否包含该表的 DDL")
class FormImportCheckIn(BaseModel):
"""导入预检查请求"""
code: str = Field(..., description="表单编码")
db_config: str = Field("default", description="数据库配置名")
main_table: str = Field(..., description="主表名")
main_table_schema: str = Field("", description="主表 Schema")
main_table_database: str = Field("", description="主表数据库")
sub_tables: List[FormSubTableSchema] = Field(default_factory=list, description="子表配置")
table_ddl: Optional[TableDDLSchema] = Field(None, description="表 DDL")
class FormImportCheckOut(BaseModel):
"""导入预检查结果"""
code_exists: bool = Field(..., description="表单编码是否已存在")
main_table_check: TableCheckResult = Field(..., description="主表检查结果")
sub_table_checks: List[TableCheckResult] = Field(default_factory=list, description="子表检查结果")
can_import: bool = Field(..., description="是否可以直接导入(所有表都存在且编码不冲突)")
available_schemas: List[str] = Field(default_factory=list, description="可用的Schema列表")
target_db_type: str = Field("", description="目标连接数据库类型")
class FormValidateTablesIn(BaseModel):
"""表单物理表校验请求"""
db_config: str = Field("default", description="数据库连接 code")
main_table: str = Field("", description="主表名")
main_table_schema: str = Field("", description="主表 Schema")
main_table_database: str = Field("", description="主表数据库")
sub_tables: List[FormSubTableSchema] = Field(default_factory=list, description="子表配置")
class FormValidateTablesOut(BaseModel):
"""表单物理表校验结果"""
valid: bool = Field(..., description="是否全部通过")
connection_ok: bool = Field(True, description="连接是否可用")
connection_message: str = Field("", description="连接错误信息")
db_config: str = Field("default", description="数据库连接 code")
main_table_exists: bool = Field(False, description="主表是否存在")
sub_table_checks: List[TableCheckResult] = Field(default_factory=list, description="子表检查")
database_warnings: List[str] = Field(
default_factory=list,
description="库名/连接配置风险提示(不阻断 valid,供前端展示)",
)
# ============ 发布配置 Schema ============
class FormPublishIn(BaseModel):
"""发布表单请求(含菜单配置)"""
menu_name: str = Field(..., description="菜单名称")
menu_parent_id: Optional[str] = Field(None, description="上级菜单ID")
menu_icon: str = Field("lucide:file-text", description="菜单图标")
menu_order: int = Field(0, description="菜单排序")
# 功能开关
allow_add: bool = Field(True, description="允许新增")
allow_edit: bool = Field(True, description="允许编辑")
allow_delete: bool = Field(True, description="允许删除")
allow_export: bool = Field(True, description="允许导出")
allow_import: bool = Field(False, description="允许导入")
@@ -0,0 +1,112 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
页面管理 Schema 定义
"""
from typing import Any, Dict, Optional
from pydantic import BaseModel, Field, ConfigDict
# ============ 页面元数据 Schema ============
class PageMetaBase(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 PageMetaCreateIn(PageMetaBase):
"""创建页面请求"""
page_config: Dict[str, Any] = Field(default_factory=dict, description="页面设计配置")
class PageMetaUpdateIn(BaseModel):
"""更新页面请求"""
name: Optional[str] = Field(None, description="页面名称")
category: Optional[str] = Field(None, description="分类")
description: Optional[str] = Field(None, description="描述")
sort: Optional[int] = Field(None, description="排序")
page_config: Optional[Dict[str, Any]] = Field(None, description="页面设计配置")
class PageMetaOut(BaseModel):
"""页面详情输出"""
id: str
name: str
code: str
category: str
description: str
status: str
version: int
page_config: Dict[str, Any]
sort: int
sys_create_datetime: str
sys_update_datetime: str
model_config = ConfigDict(from_attributes=True)
class PageMetaListOut(BaseModel):
"""页面列表输出"""
id: str
application_id: Optional[str] = None
application_name: str = ""
application_code: str = ""
name: str
code: str
category: str
description: str
status: str
version: int
sort: int
sys_create_datetime: str
sys_update_datetime: str
model_config = ConfigDict(from_attributes=True)
# ============ 导入导出 Schema ============
class PageExportOut(BaseModel):
"""页面配置导出"""
name: str
code: str
category: str
description: str
page_config: Dict[str, Any]
class PageImportCheckIn(BaseModel):
"""页面导入预检查请求"""
code: str = Field(..., description="页面编码")
class PageImportCheckOut(BaseModel):
"""页面导入预检查结果"""
code_exists: bool = Field(..., description="页面编码是否已存在")
can_import: bool = Field(..., description="是否可以直接导入(编码不冲突)")
class PageImportIn(BaseModel):
"""页面配置导入"""
application_id: Optional[str] = Field(None, description="所属应用ID")
name: str = Field(..., description="页面名称")
code: str = Field(..., description="页面编码")
category: str = Field("", description="分类")
description: str = Field("", description="描述")
page_config: Dict[str, Any] = Field(default_factory=dict, description="页面设计配置")
# ============ 发布配置 Schema ============
class PagePublishIn(BaseModel):
"""发布页面请求(含菜单配置)"""
menu_name: str = Field(..., description="菜单名称")
menu_parent_id: Optional[str] = Field(None, description="上级菜单ID")
menu_icon: str = Field("lucide:layout-dashboard", description="菜单图标")
menu_order: int = Field(0, description="菜单排序")
@@ -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"
}
}
}

Some files were not shown because too many files have changed in this diff Show More