Compare commits
16 Commits
6f872bbbc6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 24f6f41338 | |||
| 8138bc4d57 | |||
| 935b001656 | |||
| 991f1ae0c4 | |||
| 84d6791b3c | |||
| 75a7671a27 | |||
| 30ee1551c7 | |||
| 357f48ac37 | |||
| 1dc4abf063 | |||
| 01b801bebc | |||
| c8756be02e | |||
| c5d9bac0ed | |||
| 0793eb82d6 | |||
| e33f08277b | |||
| d954279c9c | |||
| 9515a4d121 |
+3
-108
@@ -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')
|
||||
|
||||
# 根据输出模式选择执行方式
|
||||
|
||||
@@ -225,11 +225,10 @@ class TextToSqlNode(BaseNode):
|
||||
error='请输入要查询的问题',
|
||||
)
|
||||
|
||||
if not model_id:
|
||||
return NodeResult(
|
||||
success=False,
|
||||
error='请选择 LLM 模型',
|
||||
)
|
||||
from ai_platform.services.llm_service import LLMService
|
||||
|
||||
llm_service = LLMService(context.db_session)
|
||||
model_id = await llm_service.resolve_chat_model_id(model_id)
|
||||
|
||||
# Step 1: 获取数据库 Schema
|
||||
|
||||
@@ -248,10 +247,7 @@ class TextToSqlNode(BaseNode):
|
||||
|
||||
# Step 2: 调用 LLM 生成 SQL
|
||||
from datetime import datetime
|
||||
from ai_platform.services.llm_service import LLMService
|
||||
|
||||
db_type = await self._get_db_type(db_connection)
|
||||
llm_service = LLMService(context.db_session)
|
||||
llm_result = None
|
||||
use_function_calling = self.config.get('use_function_calling', True)
|
||||
|
||||
@@ -354,6 +350,7 @@ class TextToSqlNode(BaseNode):
|
||||
tokens_used=response.total_tokens,
|
||||
elapsed_time=elapsed_time,
|
||||
metadata={
|
||||
'model_id': str(model_id),
|
||||
'model': response.model,
|
||||
'thought': thought,
|
||||
'suggested_next_node': 'db_sql',
|
||||
@@ -404,13 +401,22 @@ class TextToSqlNode(BaseNode):
|
||||
)
|
||||
return NodeResult(success=False, error='请输入要查询的问题')
|
||||
|
||||
if not model_id:
|
||||
import asyncio
|
||||
from ai_platform.services.llm_service import LLMService
|
||||
|
||||
llm_service = LLMService(context.db_session)
|
||||
try:
|
||||
model_id = asyncio.get_event_loop().run_until_complete(
|
||||
llm_service.resolve_chat_model_id(model_id)
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
yield TextToSqlStreamEvent(
|
||||
event_type='error',
|
||||
content='请选择 LLM 模型',
|
||||
content=error_message,
|
||||
is_finished=True,
|
||||
)
|
||||
return NodeResult(success=False, error='请选择 LLM 模型')
|
||||
return NodeResult(success=False, error=error_message)
|
||||
|
||||
# Step 1: 获取数据库 Schema
|
||||
yield TextToSqlStreamEvent(
|
||||
@@ -420,7 +426,6 @@ class TextToSqlNode(BaseNode):
|
||||
|
||||
table_relations = self.config.get('table_relations', []) # 手动指定的表关系
|
||||
|
||||
import asyncio
|
||||
schema_context = asyncio.get_event_loop().run_until_complete(
|
||||
self._get_schema_context(
|
||||
db_connection,
|
||||
@@ -449,13 +454,10 @@ class TextToSqlNode(BaseNode):
|
||||
)
|
||||
|
||||
from datetime import datetime
|
||||
from ai_platform.services.llm_service import LLMService
|
||||
|
||||
db_type = asyncio.get_event_loop().run_until_complete(
|
||||
self._get_db_type(db_connection)
|
||||
)
|
||||
|
||||
llm_service = LLMService()
|
||||
accumulated_content = ''
|
||||
total_tokens = 0
|
||||
llm_result = None
|
||||
@@ -566,6 +568,7 @@ class TextToSqlNode(BaseNode):
|
||||
tokens_used=total_tokens,
|
||||
elapsed_time=elapsed_time,
|
||||
metadata={
|
||||
'model_id': str(model_id),
|
||||
'thought': thought,
|
||||
'suggested_next_node': 'db_sql',
|
||||
},
|
||||
|
||||
@@ -65,6 +65,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:
|
||||
"""
|
||||
异步获取模型对应的提供商实例
|
||||
@@ -273,6 +293,34 @@ class LLMService:
|
||||
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 消息"""
|
||||
llm_messages = []
|
||||
|
||||
@@ -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 -*-
|
||||
"""
|
||||
合同管理模块
|
||||
"""
|
||||
@@ -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 %} {% 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="子表 DDL,key 为表名")
|
||||
|
||||
|
||||
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="导入 Excel(JNPF 兼容路径)")
|
||||
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)
|
||||
- 行分栏 col:columnType=1 超过 maxCol 行分列 / columnType=2 分 N 栏
|
||||
- 列分栏 row:columnType=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 真实样例 Golden(2026-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()
|
||||
+42
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+686
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+592
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+295
@@ -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": "吴忠民"
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -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": "研发部"
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user