diff --git a/.gitignore b/.gitignore index ad78ae5..f5c180d 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/backend-fastapi/ai_platform/nodes/builtin/dialog_nodes.py b/backend-fastapi/ai_platform/nodes/builtin/dialog_nodes.py index 9f67e25..46616e6 100644 --- a/backend-fastapi/ai_platform/nodes/builtin/dialog_nodes.py +++ b/backend-fastapi/ai_platform/nodes/builtin/dialog_nodes.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}') diff --git a/backend-fastapi/ai_platform/nodes/builtin/llm_node.py b/backend-fastapi/ai_platform/nodes/builtin/llm_node.py index bb04d15..a771af9 100644 --- a/backend-fastapi/ai_platform/nodes/builtin/llm_node.py +++ b/backend-fastapi/ai_platform/nodes/builtin/llm_node.py @@ -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') # 根据输出模式选择执行方式 diff --git a/backend-fastapi/ai_platform/services/llm_service.py b/backend-fastapi/ai_platform/services/llm_service.py index 3c0d90c..2e6e07d 100644 --- a/backend-fastapi/ai_platform/services/llm_service.py +++ b/backend-fastapi/ai_platform/services/llm_service.py @@ -64,6 +64,26 @@ class LLMService: logger.info("No model_id supplied, fallback to default chat model %s", model.id) return str(model.id) + + @staticmethod + def _run_async_sync(coro): + import asyncio + import concurrent.futures + + try: + loop = asyncio.get_event_loop() + except RuntimeError: + return asyncio.run(coro) + + if loop.is_running(): + with concurrent.futures.ThreadPoolExecutor() as executor: + return executor.submit(asyncio.run, coro).result() + + return loop.run_until_complete(coro) + + def resolve_chat_model_id_sync(self, model_id: Optional[str]) -> str: + """同步解析 chat 模型,供同步节点和流式生成器复用。""" + return self._run_async_sync(self.resolve_chat_model_id(model_id)) async def _get_provider_async(self, model_id: str) -> tuple: """ @@ -272,6 +292,34 @@ class LLMService: return provider.chat(llm_messages, config) except Exception as exc: raise RuntimeError(self._format_provider_error(exc, provider, model_name)) from exc + + def chat( + self, + model_id: Optional[str], + messages: List[Dict[str, str]], + temperature: float = 0.7, + max_tokens: int = 2048, + tools: List[Dict] = None, + tool_choice: str = 'auto', + **kwargs + ) -> LLMResponse: + """ + 同步对话入口。 + + 旧节点仍会调用该方法;这里统一走 provider 解析和默认 chat 模型兜底, + 避免不同节点各自处理 model_id 为空的情况。 + """ + provider, model_name = self._run_async_sync(self._get_provider_async(model_id)) + return self.chat_with_provider( + provider=provider, + model_name=model_name, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + tools=tools, + tool_choice=tool_choice, + **kwargs, + ) def _convert_messages(self, messages: List[Dict]) -> List[LLMMessage]: """转换消息格式,支持 tool 消息""" diff --git a/backend-fastapi/alembic/versions/f6g7h8i9j0k1_add_form_globally_visible.py b/backend-fastapi/alembic/versions/f6g7h8i9j0k1_add_form_globally_visible.py new file mode 100644 index 0000000..e98f41b --- /dev/null +++ b/backend-fastapi/alembic/versions/f6g7h8i9j0k1_add_form_globally_visible.py @@ -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') diff --git a/backend-fastapi/alembic/versions/g7h8i9j0k1l2_add_data_source_db_context.py b/backend-fastapi/alembic/versions/g7h8i9j0k1l2_add_data_source_db_context.py new file mode 100644 index 0000000..c8f4582 --- /dev/null +++ b/backend-fastapi/alembic/versions/g7h8i9j0k1l2_add_data_source_db_context.py @@ -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') diff --git a/backend-fastapi/alembic/versions/h8i9j0k1l2m3_add_data_source_list_config.py b/backend-fastapi/alembic/versions/h8i9j0k1l2m3_add_data_source_list_config.py new file mode 100644 index 0000000..ee65881 --- /dev/null +++ b/backend-fastapi/alembic/versions/h8i9j0k1l2m3_add_data_source_list_config.py @@ -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') diff --git a/backend-fastapi/online_dev/contract/__init__.py b/backend-fastapi/online_dev/contract/__init__.py new file mode 100644 index 0000000..e8e3723 --- /dev/null +++ b/backend-fastapi/online_dev/contract/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +合同管理模块 +""" diff --git a/backend-fastapi/online_dev/contract/api.py b/backend-fastapi/online_dev/contract/api.py new file mode 100644 index 0000000..d1b60fa --- /dev/null +++ b/backend-fastapi/online_dev/contract/api.py @@ -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)) diff --git a/backend-fastapi/online_dev/contract/model.py b/backend-fastapi/online_dev/contract/model.py new file mode 100644 index 0000000..4ff1b73 --- /dev/null +++ b/backend-fastapi/online_dev/contract/model.py @@ -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地址") diff --git a/backend-fastapi/online_dev/contract/schema.py b/backend-fastapi/online_dev/contract/schema.py new file mode 100644 index 0000000..8241a04 --- /dev/null +++ b/backend-fastapi/online_dev/contract/schema.py @@ -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 diff --git a/backend-fastapi/online_dev/contract/service.py b/backend-fastapi/online_dev/contract/service.py new file mode 100644 index 0000000..fd8d124 --- /dev/null +++ b/backend-fastapi/online_dev/contract/service.py @@ -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 diff --git a/backend-fastapi/online_dev/document_generator/__init__.py b/backend-fastapi/online_dev/document_generator/__init__.py new file mode 100644 index 0000000..5437bd0 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +文档生成模块 +""" diff --git a/backend-fastapi/online_dev/document_generator/api.py b/backend-fastapi/online_dev/document_generator/api.py new file mode 100644 index 0000000..243fdbe --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/api.py @@ -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 diff --git a/backend-fastapi/online_dev/document_generator/calculation_engine.py b/backend-fastapi/online_dev/document_generator/calculation_engine.py new file mode 100644 index 0000000..15fce22 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/calculation_engine.py @@ -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'] diff --git a/backend-fastapi/online_dev/document_generator/font_service.py b/backend-fastapi/online_dev/document_generator/font_service.py new file mode 100644 index 0000000..3dc4a2c --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/font_service.py @@ -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 diff --git a/backend-fastapi/online_dev/document_generator/generator.py b/backend-fastapi/online_dev/document_generator/generator.py new file mode 100644 index 0000000..4a11679 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/generator.py @@ -0,0 +1,1259 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +PDF生成引擎 +使用 WeasyPrint 将 HTML 转换为 PDF(对中文字体支持更好) +""" +import io +import json +import re +from typing import Optional, Dict, Any +from datetime import datetime + +from jinja2 import Environment, BaseLoader, select_autoescape + +# WeasyPrint 导入 +import logging +logger = logging.getLogger(__name__) + +try: + from weasyprint import HTML, CSS + from weasyprint.text.fonts import FontConfiguration + WEASYPRINT_AVAILABLE = True +except (ImportError, OSError) as e: + WEASYPRINT_AVAILABLE = False + HTML = None + CSS = None + FontConfiguration = None + logger.warning( + f"WeasyPrint 不可用,PDF 生成功能将被禁用。" + f"如需使用 PDF 生成功能,请安装系统依赖: brew install pango gdk-pixbuf libffi " + f"(错误: {e})" + ) + + +# 默认中文字体列表 +DEFAULT_CHINESE_FONTS = '"Noto Sans CJK SC", "Noto Serif CJK SC", "WenQuanYi Zen Hei", "WenQuanYi Micro Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "SimSun", "SimHei", "STSong", "STHeiti", "Source Han Sans CN", sans-serif' + + +class TemplateEngine: + """模板渲染引擎""" + + def __init__(self): + self.env = Environment( + loader=BaseLoader(), + autoescape=select_autoescape(['html', 'xml']), + ) + # 注册自定义过滤器 + self.env.filters['date'] = self._format_date + self.env.filters['datetime'] = self._format_datetime + self.env.filters['money'] = self._format_money + self.env.filters['number'] = self._format_number + self.env.filters['default'] = self._default_value + + def _format_date(self, value, format_str: str = "%Y-%m-%d") -> str: + """日期格式化""" + if not value: + return "" + if isinstance(value, str): + try: + value = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return value + if isinstance(value, datetime): + return value.strftime(format_str) + return str(value) + + def _format_datetime(self, value, format_str: str = "%Y-%m-%d %H:%M:%S") -> str: + """日期时间格式化""" + return self._format_date(value, format_str) + + def _format_money(self, value, decimal_places: int = 2) -> str: + """金额格式化""" + if value is None: + return "0.00" + try: + return f"{float(value):,.{decimal_places}f}" + except (ValueError, TypeError): + return str(value) + + def _format_number(self, value, decimal_places: int = 0) -> str: + """数字格式化""" + if value is None: + return "0" + try: + if decimal_places > 0: + return f"{float(value):,.{decimal_places}f}" + return f"{int(value):,}" + except (ValueError, TypeError): + return str(value) + + def _default_value(self, value, default: str = "") -> str: + """默认值""" + return value if value else default + + def render_html(self, template_content: str, data: Dict[str, Any]) -> str: + """渲染 HTML 模板""" + template = self.env.from_string(template_content) + return template.render(**data) + + +class DesignerTemplateRenderer: + """设计器模板渲染器 - 将设计器 JSON 转换为 HTML""" + + @classmethod + def render(cls, template_json: str, data: Dict[str, Any], css: Optional[str] = None) -> str: + """将设计器模板渲染为 HTML""" + try: + config = json.loads(template_json) if isinstance(template_json, str) else template_json + except json.JSONDecodeError: + raise ValueError("Invalid template JSON") + + # 支持两种格式:嵌套的 pageConfig 或扁平的 pageSize/pageOrientation/pageMargin + page_config = config.get("pageConfig", {}) + if not page_config: + 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"), + } + elements = config.get("elements", []) + + # 构建 HTML + html_parts = [cls._build_html_header(page_config, css, config)] + html_parts.append('
') + + for element in elements: + html_parts.append(cls._render_element(element, data)) + + html_parts.append('
') + html_parts.append('') + + return '\n'.join(html_parts) + + @classmethod + def _build_html_header( + cls, + page_config: Dict[str, Any], + custom_css: Optional[str] = None, + template_config: Optional[Dict[str, Any]] = None, + ) -> str: + """构建 HTML 头部""" + from online_dev.document_generator.page_number import build_page_number_css + + size = page_config.get("size", "A4") + orientation = page_config.get("orientation", "portrait") + margin = page_config.get("margin", {"top": 20, "right": 20, "bottom": 20, "left": 20}) + tpl = template_config or {} + + # 页面尺寸 + if size == "custom": + width_mm = page_config.get("customPageWidth") or page_config.get("customWidth", 210) + height_mm = page_config.get("customPageHeight") or page_config.get("customHeight", 297) + page_size = { + "width": f"{float(width_mm)}mm", + "height": f"{float(height_mm)}mm", + } + else: + page_sizes = { + "A4": {"width": "210mm", "height": "297mm"}, + "A5": {"width": "148mm", "height": "210mm"}, + "A3": {"width": "297mm", "height": "420mm"}, + "Letter": {"width": "216mm", "height": "279mm"}, + "Legal": {"width": "216mm", "height": "356mm"}, + } + page_size = dict(page_sizes.get(size, page_sizes["A4"])) + + if orientation == "landscape": + page_size["width"], page_size["height"] = page_size["height"], page_size["width"] + + page_number_css = build_page_number_css(tpl) + + # WeasyPrint 使用 Pango 渲染,会自动使用系统字体 + css = f""" + @page {{ + size: {page_size['width']} {page_size['height']}; + margin: {margin.get('top', 20)}mm {margin.get('right', 20)}mm {margin.get('bottom', 20)}mm {margin.get('left', 20)}mm; + {page_number_css} + }} + * {{ + margin: 0; + padding: 0; + box-sizing: border-box; + }} + body {{ + font-family: {DEFAULT_CHINESE_FONTS}; + font-size: 12pt; + line-height: 1.6; + color: #333; + }} + .document-body {{ + width: 100%; + }} + .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; + }} + """ + + if custom_css: + css += f"\n{custom_css}" + + return f""" + + + + + +""" + + @classmethod + def _render_element(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染单个元素""" + element_type = element.get("type", "text") + position = element.get("position", {}) + style = element.get("style", {}) + + # 构建样式 + style_str = cls._build_style(position, style) + + # 条件显示 + condition = element.get("condition") + if condition and not cls._evaluate_condition(condition, data): + return "" + + # 根据类型渲染 + renderers = { + # 基础元素 + "text": cls._render_text, + "field": cls._render_field, + "table": cls._render_table, + "image": cls._render_image, + "seal": cls._render_seal, + "signature": cls._render_signature, + "qrcode": cls._render_qrcode, + "divider": cls._render_divider, + "container": cls._render_container, + # 布局容器 + "row": cls._render_row, + # 新增元素类型 + "header": cls._render_header, + "title": cls._render_title, + "doc-info": cls._render_doc_info, + "info-row": cls._render_info_row, + "info-table": cls._render_info_table, + "label-field": cls._render_label_field, + "detail-table": cls._render_detail_table, + "amount": cls._render_amount, + "paragraph": cls._render_paragraph, + "rich-text": cls._render_rich_text, + "approval-area": cls._render_approval_area, + "barcode": cls._render_barcode, + "spacer": cls._render_spacer, + "footer": cls._render_footer, + } + + renderer = renderers.get(element_type, cls._render_text) + content = renderer(element, data) + + # 使用 table 布局而非 absolute 定位,兼容 xhtml2pdf + return f'
{content}
' + + @classmethod + def _build_style(cls, position: Dict[str, Any], style: Dict[str, Any]) -> str: + """构建 CSS 样式字符串""" + styles = [] + + # 位置 + if "x" in position: + styles.append(f"left: {position['x']}mm") + if "y" in position: + styles.append(f"top: {position['y']}mm") + if "width" in position: + styles.append(f"width: {position['width']}mm") + if "height" in position: + styles.append(f"height: {position['height']}mm") + + # 样式 + if style.get("fontSize"): + styles.append(f"font-size: {style['fontSize']}pt") + if style.get("fontWeight"): + styles.append(f"font-weight: {style['fontWeight']}") + if style.get("fontStyle"): + styles.append(f"font-style: {style['fontStyle']}") + if style.get("textAlign"): + styles.append(f"text-align: {style['textAlign']}") + if style.get("color"): + styles.append(f"color: {style['color']}") + if style.get("backgroundColor"): + styles.append(f"background-color: {style['backgroundColor']}") + if style.get("border"): + styles.append(f"border: {style['border']}") + if style.get("padding"): + styles.append(f"padding: {style['padding']}") + + return "; ".join(styles) + + @classmethod + def _evaluate_condition(cls, condition: Dict[str, Any], data: Dict[str, Any]) -> bool: + """评估条件表达式""" + field = condition.get("field") + operator = condition.get("operator", "eq") + value = condition.get("value") + + field_value = cls._get_field_value(data, field) + + if operator == "eq": + return field_value == value + elif operator == "ne": + return field_value != value + elif operator == "gt": + return field_value > value + elif operator == "gte": + return field_value >= value + elif operator == "lt": + return field_value < value + elif operator == "lte": + return field_value <= value + elif operator == "empty": + return not field_value + elif operator == "not_empty": + return bool(field_value) + elif operator == "contains": + return value in str(field_value) + + return True + + @classmethod + def _get_field_value(cls, data: Dict[str, Any], field_path: str) -> Any: + """获取字段值(支持点号路径和负数索引)""" + if not field_path: + return None + + parts = field_path.split(".") + value = data + + for part in parts: + if isinstance(value, dict): + value = value.get(part) + elif isinstance(value, list): + # 支持正数索引和负数索引(如 -1 表示最后一个元素) + if part.lstrip('-').isdigit(): + index = int(part) + if -len(value) <= index < len(value): + value = value[index] + else: + return None + else: + return None + else: + return None + + return value + + @classmethod + def _render_text(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染文本元素""" + content = element.get("content", "") + # 替换变量 + content = cls._replace_variables(content, data) + return f'{content}' + + @classmethod + def _render_field(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染字段元素""" + field_name = element.get("fieldName", "") + label = element.get("label", "") + value = cls._get_field_value(data, field_name) + + # 格式化 + format_type = element.get("format") + if format_type == "date": + value = TemplateEngine()._format_date(value) + elif format_type == "datetime": + value = TemplateEngine()._format_datetime(value) + elif format_type == "money": + value = TemplateEngine()._format_money(value) + elif format_type == "number": + value = TemplateEngine()._format_number(value) + + value = value if value is not None else "" + + if label: + return f'{label}{value}' + return f'{value}' + + @classmethod + def _render_table(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染表格元素""" + data_source = element.get("dataSource", "") + columns = element.get("columns", []) + rows = cls._get_field_value(data, data_source) or [] + + if not isinstance(rows, list): + rows = [] + + html = [''] + + # 表头 + html.append('') + for col in columns: + html.append(f'') + html.append('') + + # 表体 + html.append('') + for row in rows: + html.append('') + for col in columns: + value = row.get(col.get("field", ""), "") + html.append(f'') + html.append('') + html.append('') + + html.append('
{col.get("label", "")}
{value}
') + return '\n'.join(html) + + @classmethod + def _render_image(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染图片元素""" + src = element.get("src", "") + field_name = element.get("fieldName") + + if field_name: + src = cls._get_field_value(data, field_name) or src + + if not src: + return "" + + width = element.get("width", "auto") + height = element.get("height", "auto") + + return f'' + + @classmethod + def _render_seal(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染签章""" + seal_id = element.get("sealId", "") + seal_image_id = element.get("sealImageId", "") + seal_type = element.get("sealType", "company") + seal_name = element.get("sealName", "") + width = element.get("width", 120) + height = element.get("height", 120) + text_align = element.get("textAlign", "right") + + # 对齐方式映射 + align_map = {"left": "flex-start", "center": "center", "right": "flex-end"} + justify = align_map.get(text_align, "flex-end") + + # 如果有签章图片ID,渲染实际签章图片 + if seal_image_id: + return f'''
+ {seal_name} +
''' + + # 没有选择签章时,渲染占位符 + return f'''
+
+ 签章 +
+
''' + + @classmethod + def _render_signature(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染手写签名""" + field_name = element.get("fieldName", "") + src = cls._get_field_value(data, field_name) + + if not src: + return '
' + + return f'' + + @classmethod + def _render_qrcode(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染二维码""" + content = element.get("content", "") + field_name = element.get("fieldName") + + if field_name: + content = cls._get_field_value(data, field_name) or content + + # 二维码生成需要额外处理 + return f'
' + + @classmethod + def _render_divider(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染分割线""" + return '
' + + @classmethod + def _render_container(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染容器""" + children = element.get("children", []) + html_parts = [] + + for child in children: + html_parts.append(cls._render_element(child, data)) + + return '\n'.join(html_parts) + + @classmethod + def _replace_variables(cls, content: str, data: Dict[str, Any]) -> str: + """替换变量占位符 {{field_name}}""" + pattern = r'\{\{(\w+(?:\.\w+)*)\}\}' + + def replacer(match): + field_path = match.group(1) + value = cls._get_field_value(data, field_path) + return str(value) if value is not None else "" + + return re.sub(pattern, replacer, content) + + @classmethod + def _render_row(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染行容器(使用 table 布局以兼容 xhtml2pdf)""" + children = element.get("children", []) + column_widths = element.get("columnWidths", []) + gap = element.get("gap", 16) + + if not children: + return "" + + # 使用 table 布局 + html = [''.format(gap // 2)] + html.append('') + + for idx, child in enumerate(children): + # 获取列宽 + width = column_widths[idx] if idx < len(column_widths) else "auto" + width_style = f'width: {width};' if width != "auto" else "" + + html.append(f'') + + html.append('
') + # 递归渲染子元素 + html.append(cls._render_element(child, data)) + html.append('
') + return '\n'.join(html) + + # ========== 新增元素渲染方法(xhtml2pdf 兼容)========== + + @classmethod + def _render_header(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染文档页眉""" + header_type = element.get("headerType", "text") + logo_src = element.get("logoSrc", "") + company_name = element.get("companyName", "") + text_align = element.get("textAlign", "center") + + html = [''] + html.append('') + + if header_type in ["logo", "logo-text"] and logo_src: + html.append(f'') + + if header_type in ["text", "logo-text"]: + html.append(f'') + + html.append('
{company_name}
') + return '\n'.join(html) + + @classmethod + def _render_title(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染文档标题""" + content = element.get("content", "") + content = cls._replace_variables(content, data) + font_size = element.get("fontSize", 20) + font_weight = element.get("fontWeight", "bold") + text_align = element.get("textAlign", "center") + + return f'

{content}

' + + @classmethod + def _render_doc_info(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染单据信息(编号、日期等)""" + fields = element.get("fields", []) + text_align = element.get("textAlign", "right") + font_size = element.get("fontSize", 12) + + html = [f'
'] + + for i, field in enumerate(fields): + label = field.get("label", "") + field_name = field.get("fieldName", "") + format_type = field.get("format", "") + + value = cls._get_field_value(data, field_name) + value = cls._format_value(value, format_type) + + if i > 0: + html.append('    ') + html.append(f'{label}:{value}') + + html.append('
') + return '\n'.join(html) + + @classmethod + def _render_info_row(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染信息行(多字段并排)- 使用 table 布局""" + fields = element.get("fields", []) + font_size = element.get("fontSize", 12) + show_label = element.get("showLabel", True) + + html = [f''] + html.append('') + + for field in fields: + label = field.get("label", "") + field_name = field.get("fieldName", "") + width = field.get("width", "auto") + format_type = field.get("format", "") + + value = cls._get_field_value(data, field_name) + value = cls._format_value(value, format_type) + + cell_content = f'{label}:{value}' if show_label else value + html.append(f'') + + html.append('
{cell_content}
') + return '\n'.join(html) + + @classmethod + def _render_info_table(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染信息表格(带边框的表格布局)""" + rows = element.get("rows", []) + border_style = element.get("borderStyle", "solid") + border = "1px solid #333" if border_style != "none" else "none" + label_width = element.get("labelWidth", 80) + label_bg_color = element.get("labelBgColor", "") + + html = [f''] + + for row in rows: + cells = row.get("cells", []) + html.append('') + + # 计算当前行的单元格数量,用于自动调整colspan + # 标准行有4个单元格(2对标签+字段),如果只有2个单元格,字段应占3列 + cell_count = len(cells) + + for idx, cell in enumerate(cells): + cell_type = cell.get("type", "text") + colspan = cell.get("colspan", 1) + rowspan = cell.get("rowspan", 1) + + # 如果只有2个单元格(1个标签+1个字段),字段单元格自动占3列 + if cell_count == 2 and cell_type == "field" and colspan == 1: + colspan = 3 + align = cell.get("align", "left") + bold = cell.get("bold", False) + bg_color = cell.get("backgroundColor", "") + + style = f'border: {border}; padding: 8px; text-align: {align};' + if bold or cell_type == "label": + style += ' font-weight: bold;' + + # 标签单元格样式 + if cell_type == "label": + style += f' width: {label_width}px;' + if label_bg_color: + style += f' background-color: {label_bg_color};' + elif bg_color: + style += f' background-color: {bg_color};' + elif bg_color: + style += f' background-color: {bg_color};' + + if cell_type == "label": + content = cell.get("content", "") + elif cell_type == "field": + field_name = cell.get("fieldName", "") + format_type = cell.get("format", "") + value = cls._get_field_value(data, field_name) + content = cls._format_value(value, format_type) + else: + content = cell.get("content", "") + content = cls._replace_variables(content, data) + + html.append(f'') + + html.append('') + + html.append('
{content}
') + return '\n'.join(html) + + @classmethod + def _render_label_field(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染标签+字段组合""" + label = element.get("label", "") + field_name = element.get("fieldName", "") + format_type = element.get("format", "") + show_label = element.get("showLabel", True) + font_size = element.get("fontSize", 12) + + value = cls._get_field_value(data, field_name) + value = cls._format_value(value, format_type) + + if show_label: + return f'

{label}:{value}

' + return f'

{value}

' + + @classmethod + def _render_detail_table(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染明细表格(带表头、序号、合计)""" + data_source = element.get("dataSource", "") + columns = element.get("columns", []) + show_header = element.get("showHeader", True) + show_index = element.get("showIndex", True) + index_width = element.get("indexWidth", "40") + show_summary = element.get("showSummary", False) + header_bg_color = element.get("headerBgColor", "") + + rows = cls._get_field_value(data, data_source) or [] + if not isinstance(rows, list): + rows = [] + + html = [''] + + # 表头背景色样式(只有设置了颜色才添加) + header_bg_style = f'background-color: {header_bg_color};' if header_bg_color else '' + + # 表头 + if show_header: + html.append('') + if show_index: + html.append(f'') + for col in columns: + width = col.get("width", "") + width_style = f'width: {width};' if width else '' + align = col.get("align", "left") + html.append(f'') + html.append('') + + # 表体 + html.append('') + summary_values = {col.get("field"): 0 for col in columns if col.get("summary") in ["sum", "avg"]} + + for idx, row in enumerate(rows): + html.append('') + if show_index: + html.append(f'') + + for col in columns: + field = col.get("field", "") + align = col.get("align", "left") + format_type = col.get("format", "") + summary_type = col.get("summary", "") + + value = row.get(field, "") + + # 累计汇总值 + if summary_type in ["sum", "avg"] and value: + try: + summary_values[field] += float(value) + except (ValueError, TypeError): + pass + + formatted_value = cls._format_value(value, format_type) + html.append(f'') + + html.append('') + + # 合计行 + if show_summary and rows: + html.append('') + if show_index: + html.append('') + + for col in columns: + field = col.get("field", "") + align = col.get("align", "left") + format_type = col.get("format", "") + summary_type = col.get("summary", "") + + if summary_type == "sum": + value = summary_values.get(field, 0) + formatted_value = cls._format_value(value, format_type) + elif summary_type == "avg": + value = summary_values.get(field, 0) / len(rows) if rows else 0 + formatted_value = cls._format_value(value, format_type) + elif summary_type == "count": + formatted_value = str(len(rows)) + else: + formatted_value = "" + + html.append(f'') + + html.append('') + + html.append('
序号{col.get("label", "")}
{idx + 1}{formatted_value}
合计{formatted_value}
') + return '\n'.join(html) + + @classmethod + def _render_amount(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染金额字段(含大写)""" + label = element.get("label", "合计金额") + amount_field = element.get("amountField", "") + show_uppercase = element.get("showUppercase", True) + font_size = element.get("fontSize", 12) + + value = cls._get_field_value(data, amount_field) + formatted_value = cls._format_value(value, "money") + + html = [f'
'] + html.append(f'{label}:') + html.append(f'¥{formatted_value}') + + if show_uppercase and value: + uppercase = cls._number_to_chinese(value) + html.append(f'(大写:{uppercase})') + + html.append('
') + return '\n'.join(html) + + @classmethod + def _render_paragraph(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染段落文本""" + content = element.get("content", "") + content = cls._replace_variables(content, data) + font_size = element.get("fontSize", 12) + line_height = element.get("lineHeight", 1.6) + text_align = element.get("textAlign", "left") + + return f'

{content}

' + + @classmethod + def _render_rich_text(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染富文本""" + content = element.get("content", "") + field_name = element.get("fieldName", "") + + if field_name: + content = cls._get_field_value(data, field_name) or content + + content = cls._replace_variables(content, data) + return f'
{content}
' + + @classmethod + def _render_approval_area(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染审批区域""" + approval_nodes = element.get("approvalNodes", []) + layout = element.get("approvalLayout", "horizontal") + + if layout == "horizontal": + html = [''] + html.append('') + + for node in approval_nodes: + label = node.get("label", "") + field_name = node.get("fieldName", "") + show_date = node.get("showDate", True) + show_comment = node.get("showComment", False) + + approver = cls._get_field_value(data, field_name) if field_name else "" + date_value = cls._get_field_value(data, f"{field_name}_date") if field_name else "" + + html.append('') + + html.append('
') + html.append(f'
{label}
') + + if show_comment: + comment = cls._get_field_value(data, f"{field_name}_comment") if field_name else "" + html.append(f'
{comment}
') + + html.append(f'
签名:{approver}
') + + if show_date: + html.append(f'
日期:{date_value}
') + + html.append('
') + else: + html = ['
'] + + for node in approval_nodes: + label = node.get("label", "") + field_name = node.get("fieldName", "") + show_date = node.get("showDate", True) + + approver = cls._get_field_value(data, field_name) if field_name else "" + date_value = cls._get_field_value(data, f"{field_name}_date") if field_name else "" + + html.append(f'
') + html.append(f'{label}:') + html.append(f'{approver}') + if show_date: + html.append(f'    日期:{date_value}') + html.append('
') + + html.append('
') + + return '\n'.join(html) + + @classmethod + def _render_barcode(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染条形码占位符""" + code_content = element.get("codeContent", "") + field_name = element.get("fieldName") + width = element.get("width", 150) + height = element.get("height", 40) + + if field_name: + code_content = cls._get_field_value(data, field_name) or code_content + + return f'
[条形码: {code_content}]
' + + @classmethod + def _render_spacer(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染空白间距""" + height = element.get("height", 20) + return f'
' + + @classmethod + def _render_footer(cls, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染文档页脚""" + content = element.get("content", "") + show_page_number = element.get("showPageNumber", False) + show_print_date = element.get("showPrintDate", True) + text_align = element.get("textAlign", "center") + font_size = element.get("fontSize", 10) + + content = cls._replace_variables(content, data) + + html = [f'') + return '\n'.join(html) + + @classmethod + def _format_value(cls, value: Any, format_type: str) -> str: + """格式化值""" + if value is None: + return "" + + engine = TemplateEngine() + if format_type == "date": + return engine._format_date(value) + elif format_type == "datetime": + return engine._format_datetime(value) + elif format_type == "money": + return engine._format_money(value) + elif format_type == "number": + return engine._format_number(value) + + return str(value) if value is not None else "" + + @classmethod + def _number_to_chinese(cls, num: Any) -> str: + """数字转中文大写金额""" + try: + num = float(num) + except (ValueError, TypeError): + return "" + + if num == 0: + return "零元整" + + chinese_digits = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'] + chinese_units = ['', '拾', '佰', '仟'] + chinese_group_units = ['', '万', '亿'] + + # 分离整数和小数部分 + integer_part = int(num) + decimal_part = round((num - integer_part) * 100) + + result = "" + + # 处理整数部分 + if integer_part > 0: + str_int = str(integer_part) + length = len(str_int) + + for i, digit in enumerate(str_int): + d = int(digit) + pos = length - i - 1 + unit_pos = pos % 4 + group_pos = pos // 4 + + if d != 0: + result += chinese_digits[d] + chinese_units[unit_pos] + else: + if result and not result.endswith('零'): + result += '零' + + if unit_pos == 0 and group_pos > 0: + result = result.rstrip('零') + chinese_group_units[group_pos] + + result = result.rstrip('零') + '元' + else: + result = "" + + # 处理小数部分 + if decimal_part > 0: + jiao = decimal_part // 10 + fen = decimal_part % 10 + + if jiao > 0: + result += chinese_digits[jiao] + '角' + elif integer_part > 0: + result += '零' + + if fen > 0: + result += chinese_digits[fen] + '分' + else: + result += '整' + + return result or "零元整" + + +class PDFGenerator: + """PDF 生成器(使用 WeasyPrint,对中文字体支持更好)""" + + def __init__(self): + self.template_engine = TemplateEngine() + self.designer_renderer = DesignerTemplateRenderer() + self.font_config = FontConfiguration() if FontConfiguration else None + # 新的 Jinja2 渲染器 + self._jinja2_renderer = None + + @property + def jinja2_renderer(self): + """延迟加载 Jinja2 渲染器""" + if self._jinja2_renderer is None: + from online_dev.document_generator.template_renderer import jinja2_renderer + self._jinja2_renderer = jinja2_renderer + return self._jinja2_renderer + + def generate( + self, + template_type: str, + template_content: str, + data: Dict[str, Any], + css: Optional[str] = None, + page_config: Optional[Dict[str, Any]] = None, + use_jinja2: bool = True, + ) -> bytes: + """生成 PDF + + Args: + template_type: 模板类型 ("designer" 或其他) + template_content: 模板内容 (JSON 或 HTML) + data: 数据字典 + css: 自定义 CSS + page_config: 页面配置 + use_jinja2: 是否使用 Jinja2 渲染器 (默认 True) + """ + import traceback + import time + + if not WEASYPRINT_AVAILABLE: + raise RuntimeError( + "WeasyPrint 不可用,无法生成 PDF。" + "请安装系统依赖: brew install pango gdk-pixbuf libffi (macOS) " + "或 apt-get install libpango-1.0-0 libpangocairo-1.0-0 (Linux)" + ) + + logger.info( + f"[PDF生成] 开始 | template_type={template_type}, " + f"use_jinja2={use_jinja2}, data_keys={list(data.keys()) if data else []}, " + f"has_css={css is not None}, has_page_config={page_config is not None}" + ) + start_time = time.time() + + try: + # 渲染 HTML + render_start = time.time() + if template_type == "designer": + if use_jinja2: + html_content = self.jinja2_renderer.render(template_content, data, css) + else: + html_content = self.designer_renderer.render(template_content, data, css) + else: + html_content = self.template_engine.render_html(template_content, data) + html_content = self._wrap_html(html_content, css, page_config) + render_elapsed = time.time() - render_start + logger.info(f"[PDF生成] HTML渲染完成 | 耗时={render_elapsed:.3f}s, HTML长度={len(html_content)}") + except Exception as e: + logger.error( + f"[PDF生成] HTML渲染失败 | template_type={template_type}, " + f"error={type(e).__name__}: {e}\n{traceback.format_exc()}" + ) + raise + + try: + # 使用 WeasyPrint 生成 PDF + wp_start = time.time() + html_doc = HTML(string=html_content) + pdf_bytes = html_doc.write_pdf(font_config=self.font_config) + wp_elapsed = time.time() - wp_start + total_elapsed = time.time() - start_time + logger.info( + f"[PDF生成] 成功 | WeasyPrint耗时={wp_elapsed:.3f}s, " + f"总耗时={total_elapsed:.3f}s, PDF大小={len(pdf_bytes)} bytes" + ) + return pdf_bytes + except Exception as e: + total_elapsed = time.time() - start_time + html_snippet = html_content[:2000] if html_content else "(empty)" + logger.error( + f"[PDF生成] WeasyPrint生成失败 | 总耗时={total_elapsed:.3f}s, " + f"error={type(e).__name__}: {e}\n" + f"HTML前2000字符:\n{html_snippet}\n" + f"{traceback.format_exc()}" + ) + raise + + def _wrap_html( + self, + content: str, + css: Optional[str] = None, + page_config: Optional[Dict[str, Any]] = None + ) -> str: + """包装 HTML 内容""" + page_config = page_config or {} + size = page_config.get("size", "A4") + orientation = page_config.get("orientation", "portrait") + margin = page_config.get("margin", {"top": 20, "right": 20, "bottom": 20, "left": 20}) + + if size == "custom": + width_mm = page_config.get("customPageWidth") or page_config.get("customWidth", 210) + height_mm = page_config.get("customPageHeight") or page_config.get("customHeight", 297) + page_size = f"{float(width_mm)}mm {float(height_mm)}mm" + else: + page_sizes = { + "A4": "210mm 297mm", + "A5": "148mm 210mm", + "A3": "297mm 420mm", + "Letter": "216mm 279mm", + "Legal": "216mm 356mm", + } + page_size = page_sizes.get(size, "210mm 297mm") + + if orientation == "landscape": + parts = page_size.split() + page_size = f"{parts[1]} {parts[0]}" + + from online_dev.document_generator.page_number import build_page_number_css + + page_number_css = build_page_number_css(page_config) + + # WeasyPrint 使用 Pango 渲染,会自动使用系统字体 + default_css = f""" + @page {{ + size: {page_size}; + margin: {margin.get('top', 20)}mm {margin.get('right', 20)}mm {margin.get('bottom', 20)}mm {margin.get('left', 20)}mm; + {page_number_css} + }} + body {{ + font-family: {DEFAULT_CHINESE_FONTS}; + font-size: 12pt; + line-height: 1.6; + color: #333; + }} + """ + + if css: + default_css += f"\n{css}" + + return f""" + + + + + + +{content} + +""" + + def get_page_count(self, pdf_bytes: bytes) -> int: + """获取 PDF 页数""" + try: + from PyPDF2 import PdfReader + reader = PdfReader(io.BytesIO(pdf_bytes)) + return len(reader.pages) + except ImportError: + return 1 + except Exception: + return 1 + + +# 单例 +pdf_generator = PDFGenerator() diff --git a/backend-fastapi/online_dev/document_generator/model.py b/backend-fastapi/online_dev/document_generator/model.py new file mode 100644 index 0000000..7a126c0 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/model.py @@ -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"), + ) + + diff --git a/backend-fastapi/online_dev/document_generator/page_number.py b/backend-fastapi/online_dev/document_generator/page_number.py new file mode 100644 index 0000000..39baf72 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/page_number.py @@ -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']}; + }}""" diff --git a/backend-fastapi/online_dev/document_generator/schema.py b/backend-fastapi/online_dev/document_generator/schema.py new file mode 100644 index 0000000..c542435 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/schema.py @@ -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 diff --git a/backend-fastapi/online_dev/document_generator/service.py b/backend-fastapi/online_dev/document_generator/service.py new file mode 100644 index 0000000..10cd032 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/service.py @@ -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 + + diff --git a/backend-fastapi/online_dev/document_generator/template_renderer.py b/backend-fastapi/online_dev/document_generator/template_renderer.py new file mode 100644 index 0000000..e4e8973 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/template_renderer.py @@ -0,0 +1,1026 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Jinja2 模板渲染器 +将设计器 JSON 转换为 HTML,使用 Jinja2 模板实现 +""" +import base64 +import io +import json +import os +import re +from datetime import datetime +from typing import Optional, Dict, Any, List + +import qrcode +from jinja2 import Environment, FileSystemLoader, select_autoescape +from markupsafe import Markup + +try: + import barcode + from barcode.writer import ImageWriter + BARCODE_AVAILABLE = True +except ImportError: + BARCODE_AVAILABLE = False + +# 模板目录 +TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), 'templates') + +# 默认中文字体列表 +DEFAULT_CHINESE_FONTS = '"Noto Sans CJK SC", "Noto Serif CJK SC", "WenQuanYi Zen Hei", "WenQuanYi Micro Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "SimSun", "SimHei", "STSong", "STHeiti", "Source Han Sans CN", sans-serif' + + +class Jinja2TemplateRenderer: + """基于 Jinja2 的模板渲染器""" + + def __init__(self): + self.env = Environment( + loader=FileSystemLoader(TEMPLATE_DIR), + autoescape=select_autoescape(['html', 'xml']), + ) + # 注册自定义过滤器 + self.env.filters['date'] = self._format_date + self.env.filters['datetime'] = self._format_datetime + self.env.filters['money'] = self._format_money + self.env.filters['number'] = self._format_number + + def render(self, template_json: str, data: Dict[str, Any], css: Optional[str] = None) -> str: + """将设计器模板渲染为 HTML""" + try: + config = json.loads(template_json) if isinstance(template_json, str) else template_json + except json.JSONDecodeError: + raise ValueError("Invalid template JSON") + + # 解析页面配置(兼容两种格式) + page_config = config.get("pageConfig", {}) + if not page_config or not page_config.get("size"): + 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"), + } + + # 获取字体配置 + font_family = config.get("fontFamily", "") + if not font_family: + font_family = DEFAULT_CHINESE_FONTS + + # 计算页面尺寸 + page_size = self._get_page_size(page_config) + margin = page_config.get("margin", {"top": 20, "right": 20, "bottom": 20, "left": 20}) + + editor_mode = config.get("editorMode", "component") + + if editor_mode == "wysiwyg": + return self._render_wysiwyg(config, data, page_size, margin, font_family, css) + + elements = config.get("elements", []) + + # 渲染所有元素 + rendered_elements = [] + for element in elements: + rendered = self._render_element(element, data) + if rendered: + rendered_elements.append(rendered) + + # 渲染基础模板 + base_template = self.env.get_template('base.html') + + from online_dev.document_generator.page_number import build_page_number_css + + html = base_template.render( + page_width=page_size['width'], + page_height=page_size['height'], + margin_top=margin.get('top', 20), + margin_right=margin.get('right', 20), + margin_bottom=margin.get('bottom', 20), + margin_left=margin.get('left', 20), + font_family=font_family, + page_number_css=build_page_number_css(config), + custom_css=css or '', + content=Markup('\n'.join(rendered_elements)), + ) + + return html + + def _render_wysiwyg(self, config: Dict[str, Any], data: Dict[str, Any], + page_size: Dict[str, str], margin: Dict[str, Any], + font_family: str, css: Optional[str] = None) -> str: + """渲染 WYSIWYG 模式的文档""" + wysiwyg_content = config.get("wysiwygContent", "") + + # 替换变量标签(前端生成的 ) + wysiwyg_content = re.sub( + r']*class="wysiwyg-variable"[^>]*data-variable="([^"]*)"[^>]*>.*?', + lambda m: str(self._get_field_value(data, m.group(1).strip()) or ''), + wysiwyg_content, + ) + # 替换 {{variable}} 语法 + wysiwyg_content = self._replace_variables(wysiwyg_content, data) + + # 渲染 wysiwyg 内容模板 + try: + wysiwyg_template = self.env.get_template('elements/wysiwyg.html') + content_html = wysiwyg_template.render(content=Markup(wysiwyg_content)) + except Exception: + content_html = f'
{wysiwyg_content}
' + + base_template = self.env.get_template('base.html') + from online_dev.document_generator.page_number import build_page_number_css + + return base_template.render( + page_width=page_size['width'], + page_height=page_size['height'], + margin_top=margin.get('top', 20), + margin_right=margin.get('right', 20), + margin_bottom=margin.get('bottom', 20), + margin_left=margin.get('left', 20), + font_family=font_family, + page_number_css=build_page_number_css(config), + custom_css=css or '', + content=Markup(content_html), + ) + + def _get_page_size(self, page_config: Dict[str, Any]) -> Dict[str, str]: + """获取页面尺寸""" + size = page_config.get("size", "A4") + orientation = page_config.get("orientation", "portrait") + + if size == "custom": + width_mm = page_config.get("customPageWidth") or page_config.get("customWidth", 210) + height_mm = page_config.get("customPageHeight") or page_config.get("customHeight", 297) + page_size = { + "width": f"{float(width_mm)}mm", + "height": f"{float(height_mm)}mm", + } + else: + page_sizes = { + "A4": {"width": "210mm", "height": "297mm"}, + "A5": {"width": "148mm", "height": "210mm"}, + "A3": {"width": "297mm", "height": "420mm"}, + "Letter": {"width": "216mm", "height": "279mm"}, + "Legal": {"width": "216mm", "height": "356mm"}, + } + page_size = dict(page_sizes.get(size, page_sizes["A4"])) + + if orientation == "landscape": + page_size["width"], page_size["height"] = page_size["height"], page_size["width"] + + return page_size + + def _render_element(self, element: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染单个元素""" + element_type = element.get("type", "text") + position = element.get("position", {}) + style = element.get("style", {}) + + # 条件显示 + condition = element.get("condition") + if condition and not self._evaluate_condition(condition, data): + return "" + + # 构建样式 + style_str = self._build_style(position, style) + + # 准备渲染上下文 + context = self._prepare_context(element, data) + + # 渲染元素内容 + content = self._render_element_content(element_type, context, data) + + # 包装元素 + return f'
{content}
' + + def _prepare_context(self, element: Dict[str, Any], data: Dict[str, Any]) -> Dict[str, Any]: + """准备渲染上下文""" + context = dict(element) + + # 处理字段值 + field_name = element.get("fieldName", "") + if field_name: + context['value'] = self._get_field_value(data, field_name) + # 格式化 + format_type = element.get("format") + if format_type: + context['value'] = self._format_value(context.get('value'), format_type) + else: + context['value'] = context.get('value', '') + + # 处理内容中的变量 + content = element.get("content", "") + if content: + content = self._replace_variables(content, data) + content = self._convert_font_tags(content) + context['content'] = content + + return context + + def _render_element_content(self, element_type: str, context: Dict[str, Any], data: Dict[str, Any]) -> str: + """渲染元素内容""" + # 元素类型到模板文件的映射 + template_map = { + "text": "elements/text.html", + "field": "elements/field.html", + "table": "elements/table.html", + "image": "elements/image.html", + "seal": "elements/seal.html", + "signature": "elements/signature.html", + "qrcode": "elements/qrcode.html", + "divider": "elements/divider.html", + "container": "elements/container.html", + "row": "elements/row.html", + "header": "elements/header.html", + "title": "elements/title.html", + "doc-info": "elements/doc_info.html", + "info-row": "elements/info_row.html", + "info-table": "elements/info_table.html", + "smart-table": "elements/smart_table.html", + "label-field": "elements/label_field.html", + "detail-table": "elements/detail_table.html", + "amount": "elements/amount.html", + "paragraph": "elements/paragraph.html", + "rich-text": "elements/rich_text.html", + "smart-text": "elements/smart_text.html", + "barcode": "elements/barcode.html", + "spacer": "elements/spacer.html", + "footer": "elements/footer.html", + } + + template_file = template_map.get(element_type, "elements/text.html") + + try: + template = self.env.get_template(template_file) + except Exception: + # 如果模板不存在,使用 text 模板 + template = self.env.get_template("elements/text.html") + + # 根据元素类型准备特定上下文 + render_context = self._prepare_type_context(element_type, context, data) + + return template.render(**render_context) + + def _prepare_type_context(self, element_type: str, context: Dict[str, Any], data: Dict[str, Any]) -> Dict[str, Any]: + """根据元素类型准备特定上下文""" + render_context = dict(context) + + if element_type == "title": + render_context.setdefault('font_size', context.get('fontSize', 20)) + render_context.setdefault('font_weight', context.get('fontWeight', 'bold')) + render_context.setdefault('text_align', context.get('textAlign', 'center')) + render_context['color'] = context.get('color', '#333333') + + elif element_type == "paragraph": + render_context.setdefault('font_size', context.get('fontSize', 12)) + render_context.setdefault('line_height', context.get('lineHeight', 1.6)) + render_context.setdefault('text_align', context.get('textAlign', 'left')) + render_context['color'] = context.get('color', '#333333') + + elif element_type == "header": + render_context.setdefault('header_type', context.get('headerType', 'text')) + render_context.setdefault('logo_src', context.get('logoSrc', '')) + render_context.setdefault('company_name', context.get('companyName', '')) + render_context.setdefault('text_align', context.get('textAlign', 'center')) + render_context['header_font_size'] = context.get('headerFontSize', 14) + render_context['header_color'] = context.get('headerColor', '#333333') + render_context['header_font_weight'] = context.get('headerFontWeight', 'bold') + + elif element_type == "footer": + render_context.setdefault('text_align', context.get('textAlign', 'center')) + render_context.setdefault('font_size', context.get('fontSize', 10)) + footer_parts = [] + if context.get('showPrintDate', True): + footer_parts.append(f'打印日期:{datetime.now().strftime("%Y-%m-%d")}') + render_context['footer_parts'] = footer_parts + render_context['color'] = context.get('color', '#666666') + + elif element_type == "doc-info": + render_context.setdefault('text_align', context.get('textAlign', 'right')) + render_context.setdefault('font_size', context.get('fontSize', 12)) + fields = [] + for field in context.get('fields', []): + field_name = field.get('fieldName', '') + value = self._get_field_value(data, field_name) + value = self._format_value(value, field.get('format', '')) + fields.append({ + 'label': field.get('label', ''), + 'value': value, + }) + render_context['fields'] = fields + render_context['show_underline'] = context.get('showUnderline', False) + render_context['label_font_size'] = context.get('labelFontSize', context.get('fontSize', 12)) + render_context['label_color'] = context.get('labelColor', '#333333') + render_context['label_font_weight'] = context.get('labelFontWeight', 'normal') + render_context['value_font_size'] = context.get('valueFontSize', context.get('fontSize', 12)) + render_context['value_color'] = context.get('valueColor', '#333333') + + elif element_type == "info-row": + render_context.setdefault('font_size', context.get('fontSize', 12)) + render_context.setdefault('show_label', context.get('showLabel', True)) + fields = [] + for field in context.get('fields', []): + field_name = field.get('fieldName', '') + value = self._get_field_value(data, field_name) + value = self._format_value(value, field.get('format', '')) + fields.append({ + 'label': field.get('label', ''), + 'value': value, + 'width': field.get('width', 'auto'), + }) + render_context['fields'] = fields + render_context['show_underline'] = context.get('showUnderline', False) + render_context['label_font_size'] = context.get('labelFontSize', context.get('fontSize', 12)) + render_context['label_color'] = context.get('labelColor', '#333333') + render_context['label_font_weight'] = context.get('labelFontWeight', 'normal') + render_context['value_font_size'] = context.get('valueFontSize', context.get('fontSize', 12)) + render_context['value_color'] = context.get('valueColor', '#333333') + + elif element_type == "label-field": + render_context.setdefault('font_size', context.get('fontSize', 12)) + render_context.setdefault('show_label', context.get('showLabel', True)) + render_context.setdefault('label', context.get('label', '')) + field_name = context.get('fieldName', '') + value = self._get_field_value(data, field_name) + render_context['value'] = self._format_value(value, context.get('format', '')) + render_context['show_underline'] = context.get('showUnderline', False) + render_context['label_font_size'] = context.get('labelFontSize', context.get('fontSize', 12)) + render_context['label_color'] = context.get('labelColor', '#333333') + render_context['label_font_weight'] = context.get('labelFontWeight', 'normal') + render_context['value_font_size'] = context.get('valueFontSize', context.get('fontSize', 12)) + render_context['value_color'] = context.get('valueColor', '#333333') + + elif element_type == "table": + data_source = context.get('dataSource', '') + rows = self._get_field_value(data, data_source) or [] + render_context['rows'] = rows if isinstance(rows, list) else [] + render_context['columns'] = context.get('columns', []) + + elif element_type == "detail-table": + data_source = context.get('dataSource', '') + rows = self._get_field_value(data, data_source) or [] + render_context['rows'] = rows if isinstance(rows, list) else [] + render_context['columns'] = context.get('columns', []) + render_context['show_header'] = context.get('showHeader', True) + render_context['show_index'] = context.get('showIndex', True) + render_context['index_width'] = context.get('indexWidth', '40') + render_context['show_summary'] = context.get('showSummary', False) + render_context['header_bg_color'] = context.get('headerBgColor', '') + render_context['header_font_size'] = context.get('headerFontSize', 14) + render_context['header_color'] = context.get('headerColor', '#333333') + render_context['header_font_weight'] = context.get('headerFontWeight', 'bold') + render_context['content_font_size'] = context.get('contentFontSize', 14) + render_context['content_color'] = context.get('contentColor', '#333333') + # 计算汇总值 + if render_context['show_summary']: + summary_values = {} + for col in render_context['columns']: + field = col.get('field', '') + summary_type = col.get('summary', '') + if summary_type in ['sum', 'avg']: + total = 0 + for row in render_context['rows']: + try: + total += float(row.get(field, 0) or 0) + except (ValueError, TypeError): + pass + if summary_type == 'avg' and render_context['rows']: + total = total / len(render_context['rows']) + summary_values[field] = self._format_value(total, col.get('format', '')) + elif summary_type == 'count': + summary_values[field] = str(len(render_context['rows'])) + render_context['summary_values'] = summary_values + + elif element_type == "info-table": + border_style = context.get('borderStyle', 'solid') + render_context['border'] = "1px solid #333" if border_style != "none" else "none" + render_context['show_underline'] = context.get('showUnderline', False) + render_context['label_width'] = context.get('labelWidth', 80) + render_context['label_bg_color'] = context.get('labelBgColor', '') + render_context['label_font_size'] = context.get('labelFontSize', 14) + render_context['label_color'] = context.get('labelColor', '#333333') + render_context['label_font_weight'] = context.get('labelFontWeight', 'normal') + render_context['field_font_size'] = context.get('fieldFontSize', 14) + render_context['field_color'] = context.get('fieldColor', '#333333') + rows = [] + for row in context.get('rows', []): + row_cells = row.get('cells', []) + cell_count = len(row_cells) + cells = [] + for cell in row_cells: + cell_type = cell.get('type', 'text') + if cell_type == 'field': + field_name = cell.get('fieldName', '') + value = self._get_field_value(data, field_name) + content = self._format_value(value, cell.get('format', '')) + else: + content = cell.get('content', '') + content = self._replace_variables(content, data) + # 如果只有2个单元格(1个标签+1个字段),字段单元格自动占3列 + cell_data = {**cell, 'content': content} + if cell_count == 2 and cell_type == 'field' and cell.get('colspan', 1) == 1: + cell_data['colspan'] = 3 + cells.append(cell_data) + rows.append({'cells': cells}) + render_context['rows'] = rows + + elif element_type == "smart-table": + border_mode = context.get('borderMode', 'all') + border_color = context.get('borderColor', '#333') + border_style = context.get('borderStyle', 'solid') + show_border = context.get('showBorder', True) + if not show_border or border_style == "none" or border_mode == "none": + render_context['border'] = "none" + else: + render_context['border'] = f"1px solid {border_color}" + render_context['border_style'] = border_style + render_context['border_mode'] = border_mode + render_context['border_color'] = border_color + render_context['table_font_size'] = context.get('tableFontSize', 14) + col_widths = context.get('tableColumnWidths', []) + render_context['col_widths'] = col_widths + rows = [] + for row in context.get('rows', []): + row_cells = row.get('cells', []) + cells = [] + for cell in row_cells: + if cell.get('merged'): + continue + cell_type = cell.get('type', 'text') + if cell_type == 'field': + field_name = cell.get('fieldName', '') + value = self._get_field_value(data, field_name) + field_content = self._format_value(value, cell.get('format', '')) + text_content = cell.get('content', '') + if text_content: + text_content = self._replace_variables(text_content, data) + content = f"{text_content} {field_content}" + else: + content = field_content + else: + content = cell.get('content', '') + content = self._replace_variables(content, data) + cells.append({**cell, 'content': content}) + rows.append({'cells': cells}) + render_context['rows'] = rows + + elif element_type == "image": + src = context.get('src', '') + field_name = context.get('fieldName') + if field_name: + src = self._get_field_value(data, field_name) or src + # 替换 src 中的变量(支持 {{variable}} 语法) + src = self._replace_variables(src, data) + render_context['src'] = src + # 处理宽度和高度,自动添加 px 单位 + width = context.get('width', 'auto') + height = context.get('height', 'auto') + render_context['width'] = f'{width}px' if isinstance(width, (int, float)) else width + render_context['height'] = f'{height}px' if isinstance(height, (int, float)) else height + # 对齐方式 + render_context['textAlign'] = context.get('textAlign', 'center') + # 标签和标签位置 + render_context['label'] = context.get('label', '') + render_context['labelPosition'] = context.get('labelPosition', 'bottom') + # 标签字体大小 + render_context['labelFontSize'] = context.get('labelFontSize', 14) + render_context['labelColor'] = context.get('labelColor', '#333333') + render_context['labelFontWeight'] = context.get('labelFontWeight', 'normal') + # 悬浮定位属性 + render_context['position_mode'] = context.get('positionMode', 'inline') + render_context['float_x'] = context.get('floatX', 0) + render_context['float_y'] = context.get('floatY', 0) + render_context['float_z_index'] = context.get('floatZIndex', 100) + + elif element_type == "signature": + field_name = context.get('fieldName', '') + src = self._get_field_value(data, field_name) or '' + # 替换 fieldName 中的变量(支持 {{variable}} 语法) + if not src and field_name: + src = self._replace_variables(field_name, data) + render_context['src'] = src + + elif element_type == "seal": + render_context['seal_id'] = context.get('sealId', '') + render_context['seal_image_id'] = context.get('sealImageId', '') + render_context['seal_type'] = context.get('sealType', 'company') + render_context['seal_name'] = context.get('sealName', '') + render_context['label'] = context.get('label', '') + render_context['label_position'] = context.get('labelPosition', 'bottom') + render_context['label_font_size'] = context.get('labelFontSize', 14) + render_context['label_color'] = context.get('labelColor', '#333333') + render_context['label_font_weight'] = context.get('labelFontWeight', 'normal') + render_context['width'] = context.get('width', 120) + render_context['height'] = context.get('height', 120) + render_context['text_align'] = context.get('textAlign', 'right') + # 直接使用前端传入的 base64 图片数据 + seal_image_data = context.get('sealImageData', '') + if seal_image_data: + render_context['seal_image_data'] = seal_image_data + else: + # 兼容旧数据:如果没有 sealImageData 但有 sealImageId,尝试加载 + seal_image_id = context.get('sealImageId', '') + if seal_image_id: + seal_image_data = self._get_seal_image_base64(seal_image_id) + render_context['seal_image_data'] = seal_image_data + # 悬浮定位属性 + render_context['position_mode'] = context.get('positionMode', 'inline') + render_context['float_x'] = context.get('floatX', 0) + render_context['float_y'] = context.get('floatY', 0) + render_context['float_z_index'] = context.get('floatZIndex', 100) + + elif element_type == "qrcode": + content = context.get('codeContent', '') or context.get('content', '') + field_name = context.get('fieldName') + if field_name: + content = self._get_field_value(data, field_name) or content + # 替换内容中的变量 + content = self._replace_variables(content, data) + # 如果内容为空或仍包含变量占位符,使用默认内容 + if not content or content.startswith('{{'): + content = 'zq-platform' + render_context['content'] = content + # 二维码类型 + qrcode_type = context.get('qrcodeType', 'text') + render_context['qrcodeType'] = qrcode_type + # 处理宽度和高度,自动添加 px 单位 + width = context.get('width', 80) + height = context.get('height', 80) + render_context['width'] = f'{width}px' if isinstance(width, (int, float)) else width + render_context['height'] = f'{height}px' if isinstance(height, (int, float)) else height + # 对齐方式 + render_context['textAlign'] = context.get('textAlign', 'center') + # 标签相关属性 + render_context['label'] = context.get('label', '') + render_context['labelPosition'] = context.get('labelPosition', 'bottom') + render_context['labelFontSize'] = context.get('labelFontSize', 14) + render_context['labelColor'] = context.get('labelColor', '#333333') + render_context['labelFontWeight'] = context.get('labelFontWeight', 'normal') + # 生成二维码图片 + qrcode_image = self._generate_qrcode_base64(content, qrcode_type) + render_context['qrcode_image'] = qrcode_image + + elif element_type == "barcode": + code_content = context.get('codeContent', '') + field_name = context.get('fieldName') + if field_name: + code_content = self._get_field_value(data, field_name) or code_content + # 替换内容中的变量 + code_content = self._replace_variables(code_content, data) + # 如果内容为空或仍包含变量占位符,使用默认内容 + if not code_content or code_content.startswith('{{'): + code_content = 'ZQ-PLATFORM' + render_context['code_content'] = code_content + # 处理宽度和高度,自动添加 px 单位 + width = context.get('width', 150) + height = context.get('height', 40) + render_context['width'] = f'{width}px' if isinstance(width, (int, float)) else width + render_context['height'] = f'{height}px' if isinstance(height, (int, float)) else height + # 对齐方式 + render_context['textAlign'] = context.get('textAlign', 'center') + # 标签相关属性 + render_context['label'] = context.get('label', '') + render_context['labelPosition'] = context.get('labelPosition', 'bottom') + render_context['labelFontSize'] = context.get('labelFontSize', 14) + render_context['labelColor'] = context.get('labelColor', '#333333') + render_context['labelFontWeight'] = context.get('labelFontWeight', 'normal') + # 生成条形码图片 + barcode_image = self._generate_barcode_base64(code_content) + render_context['barcode_image'] = barcode_image + + elif element_type == "divider": + render_context['line_style'] = context.get('lineStyle', 'solid') + render_context['line_color'] = context.get('lineColor', '#000000') + render_context['line_width'] = context.get('lineWidth', 1) + + elif element_type == "spacer": + render_context['height'] = context.get('height', 20) + + elif element_type == "row": + children = context.get('children', []) + column_widths = context.get('columnWidths', []) + gap = context.get('gap', 16) // 2 + rendered_children = [] + for idx, child in enumerate(children): + width = column_widths[idx] if idx < len(column_widths) else "auto" + child_content = self._render_element(child, data) + rendered_children.append({ + 'width': width, + 'content': child_content, + }) + render_context['children'] = rendered_children + render_context['gap'] = gap + + elif element_type == "container": + children = context.get('children', []) + rendered_children = [] + for child in children: + rendered_children.append(self._render_element(child, data)) + render_context['children'] = rendered_children + + elif element_type == "rich-text": + content = context.get('content', '') + field_name = context.get('fieldName', '') + if field_name: + content = self._get_field_value(data, field_name) or content + content = self._replace_variables(content, data) + render_context['content'] = self._convert_font_tags(content) + + elif element_type == "smart-text": + content = context.get('content', '') + # 将变量标签 span 替换为实际变量值 + content = re.sub( + r']*data-variable="([^"]*)"[^>]*>.*?', + lambda m: str(self._get_field_value(data, m.group(1).strip()) or ''), + content, + ) + content = self._replace_variables(content, data) + content = self._convert_font_tags(content) + + # 移除编辑器中的表格占位 block(预览用的 HTML table) + content = re.sub( + r']*class="st-table-block"[^>]*>.*?', + '', + content, + flags=re.DOTALL, + ) + + # 嵌套表格:将 TABLE_BLOCK 标记替换为渲染后的表格 HTML + rows_data = context.get('rows') + table_html = '' + if rows_data: + table_element = {**context, 'type': 'smart-table'} + table_html = self._render_element(table_element, data) + + if '' in content and table_html: + content = content.replace('', table_html) + render_context['table_html'] = '' + else: + render_context['table_html'] = table_html + + render_context['content'] = content + render_context['font_size'] = context.get('fontSize', 14) + render_context['line_height'] = context.get('lineHeight', 1.6) + render_context['text_align'] = context.get('textAlign', 'left') + + return render_context + + def _build_style(self, position: Dict[str, Any], style: Dict[str, Any]) -> str: + """构建 CSS 样式字符串""" + styles = [] + + if "x" in position: + styles.append(f"left: {position['x']}mm") + if "y" in position: + styles.append(f"top: {position['y']}mm") + if "width" in position: + styles.append(f"width: {position['width']}mm") + if "height" in position: + styles.append(f"height: {position['height']}mm") + + if style.get("fontSize"): + styles.append(f"font-size: {style['fontSize']}pt") + if style.get("fontWeight"): + styles.append(f"font-weight: {style['fontWeight']}") + if style.get("fontStyle"): + styles.append(f"font-style: {style['fontStyle']}") + if style.get("textAlign"): + styles.append(f"text-align: {style['textAlign']}") + if style.get("color"): + styles.append(f"color: {style['color']}") + if style.get("backgroundColor"): + styles.append(f"background-color: {style['backgroundColor']}") + if style.get("border"): + styles.append(f"border: {style['border']}") + if style.get("padding"): + styles.append(f"padding: {style['padding']}") + + return "; ".join(styles) + + def _evaluate_condition(self, condition: Dict[str, Any], data: Dict[str, Any]) -> bool: + """评估条件表达式""" + field = condition.get("field") + operator = condition.get("operator", "eq") + value = condition.get("value") + + field_value = self._get_field_value(data, field) + + if operator == "eq": + return field_value == value + elif operator == "ne": + return field_value != value + elif operator == "gt": + return field_value > value + elif operator == "gte": + return field_value >= value + elif operator == "lt": + return field_value < value + elif operator == "lte": + return field_value <= value + elif operator == "empty": + return not field_value + elif operator == "not_empty": + return bool(field_value) + elif operator == "contains": + return value in str(field_value) + + return True + + def _get_field_value(self, data: Dict[str, Any], field_path: str) -> Any: + """获取字段值(支持点号路径和负数索引)""" + if not field_path: + return None + + parts = field_path.split(".") + value = data + + for part in parts: + if isinstance(value, dict): + value = value.get(part) + elif isinstance(value, list): + # 支持正数索引和负数索引(如 -1 表示最后一个元素) + if part.lstrip('-').isdigit(): + index = int(part) + if -len(value) <= index < len(value): + value = value[index] + else: + return None + else: + return None + else: + return None + + return value + + @staticmethod + def _convert_font_tags(html: str) -> str: + """将 标签转换为 (WeasyPrint 不支持已废弃的 font 标签)""" + if '(.*?)', + r'\2', + html, flags=re.DOTALL, + ) + html = re.sub( + r'(.*?)', + r'\2', + html, flags=re.DOTALL, + ) + html = re.sub( + r']*>(.*?)', + r'\1', + html, flags=re.DOTALL, + ) + return html + + def _replace_variables(self, content: str, data: Dict[str, Any]) -> str: + """替换变量占位符 {{field_name}} + 支持中文字符和复杂路径,如: {{_approvals_by_node.部门负责人.signature_url}} + """ + # 修改正则表达式以支持中文和其他 Unicode 字符 + # [^\{\}]+ 匹配除了大括号外的任何字符(包括中文、点号等) + pattern = r'\{\{([^\{\}]+)\}\}' + + def replacer(match): + field_path = match.group(1).strip() + value = self._get_field_value(data, field_path) + return str(value) if value is not None else "" + + return re.sub(pattern, replacer, content) + + def _generate_qrcode_base64(self, content: str, qrcode_type: str = 'text') -> Optional[str]: + """生成二维码并返回 base64 编码的图片数据""" + if not content: + return None + + try: + # 根据类型处理内容 + if qrcode_type == 'url' and not content.startswith(('http://', 'https://')): + content = f'https://{content}' + elif qrcode_type == 'phone': + content = f'tel:{content}' + elif qrcode_type == 'email': + content = f'mailto:{content}' + + # 生成二维码 + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=10, + border=2, + ) + qr.add_data(content) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + + # 转换为 base64 + buffer = io.BytesIO() + img.save(buffer, format='PNG') + buffer.seek(0) + img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8') + + return f'data:image/png;base64,{img_base64}' + except Exception as e: + print(f"生成二维码失败: {e}") + return None + + def _generate_barcode_base64(self, content: str) -> Optional[str]: + """生成条形码并返回 base64 编码的图片数据""" + if not content or not BARCODE_AVAILABLE: + return None + + try: + # 使用 Code128 格式,支持任意字符 + code128 = barcode.get_barcode_class('code128') + + # 创建条形码 + buffer = io.BytesIO() + code = code128(content, writer=ImageWriter()) + code.write(buffer, options={ + 'module_width': 0.3, + 'module_height': 10, + 'font_size': 8, + 'text_distance': 3, + 'quiet_zone': 2, + }) + buffer.seek(0) + + img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8') + return f'data:image/png;base64,{img_base64}' + except Exception as e: + print(f"生成条形码失败: {e}") + return None + + def _get_seal_image_base64(self, file_id: str) -> Optional[str]: + """获取签章图片并转换为 base64 数据 URL(同步方式)""" + if not file_id: + return None + + try: + from sqlalchemy import select + from sqlalchemy.orm import Session + from app.database import engine + from core.file_manager.model import FileManager + from core.file_manager.storage_backends import get_storage_backend, LocalStorageBackend, MinioStorageBackend + + # 使用同步数据库连接 + with Session(engine.sync_engine) as db: + # 查询文件记录 + stmt = select(FileManager).where( + FileManager.id == file_id, + FileManager.is_deleted == False + ) + result = db.execute(stmt) + file_obj = result.scalar_one_or_none() + + if not file_obj or file_obj.type != 'file': + print(f"签章文件不存在或类型错误: {file_id}") + return None + + # 获取存储后端 + storage = get_storage_backend() + content = None + + if isinstance(storage, LocalStorageBackend): + # 本地存储:直接读取文件 + full_path = storage.get_full_path(file_obj.storage_path) + if os.path.exists(full_path): + with open(full_path, 'rb') as f: + content = f.read() + elif isinstance(storage, MinioStorageBackend): + # Minio 存储:通过 API 获取 + response = storage.get_file_content(file_obj.storage_path) + content = response.read() + response.close() + response.release_conn() + + if content: + base64_content = base64.b64encode(content).decode('utf-8') + mime_type = file_obj.mime_type or 'image/png' + return f"data:{mime_type};base64,{base64_content}" + + print(f"无法读取签章文件内容: {file_id}") + return None + + except Exception as e: + print(f"获取签章图片失败: {e}") + import traceback + traceback.print_exc() + return None + + def _format_value(self, value: Any, format_type: str) -> str: + """格式化值""" + if value is None: + return "" + + if format_type == "date": + return self._format_date(value) + elif format_type == "datetime": + return self._format_datetime(value) + elif format_type == "money": + return self._format_money(value) + elif format_type == "number": + return self._format_number(value) + + return str(value) if value is not None else "" + + def _format_date(self, value, format_str: str = "%Y-%m-%d") -> str: + """日期格式化""" + if not value: + return "" + if isinstance(value, str): + try: + value = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return value + if isinstance(value, datetime): + return value.strftime(format_str) + return str(value) + + def _format_datetime(self, value, format_str: str = "%Y-%m-%d %H:%M:%S") -> str: + """日期时间格式化""" + return self._format_date(value, format_str) + + def _format_money(self, value, decimal_places: int = 2) -> str: + """金额格式化""" + if value is None: + return "0.00" + try: + return f"{float(value):,.{decimal_places}f}" + except (ValueError, TypeError): + return str(value) + + def _format_number(self, value, decimal_places: int = 0) -> str: + """数字格式化""" + if value is None: + return "0" + try: + if decimal_places > 0: + return f"{float(value):,.{decimal_places}f}" + return f"{int(value):,}" + except (ValueError, TypeError): + return str(value) + + def _number_to_chinese(self, num: Any) -> str: + """数字转中文大写金额""" + try: + num = float(num) + except (ValueError, TypeError): + return "" + + if num == 0: + return "零元整" + + chinese_digits = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'] + chinese_units = ['', '拾', '佰', '仟'] + chinese_group_units = ['', '万', '亿'] + + integer_part = int(num) + decimal_part = round((num - integer_part) * 100) + + result = "" + + if integer_part > 0: + str_int = str(integer_part) + length = len(str_int) + + for i, digit in enumerate(str_int): + d = int(digit) + pos = length - i - 1 + unit_pos = pos % 4 + group_pos = pos // 4 + + if d != 0: + result += chinese_digits[d] + chinese_units[unit_pos] + else: + if result and not result.endswith('零'): + result += '零' + + if unit_pos == 0 and group_pos > 0: + result = result.rstrip('零') + chinese_group_units[group_pos] + + result = result.rstrip('零') + '元' + else: + result = "" + + if decimal_part > 0: + jiao = decimal_part // 10 + fen = decimal_part % 10 + + if jiao > 0: + result += chinese_digits[jiao] + '角' + elif integer_part > 0: + result += '零' + + if fen > 0: + result += chinese_digits[fen] + '分' + else: + result += '整' + + return result or "零元整" + + +# 单例 +jinja2_renderer = Jinja2TemplateRenderer() diff --git a/backend-fastapi/online_dev/document_generator/templates/__init__.py b/backend-fastapi/online_dev/document_generator/templates/__init__.py new file mode 100644 index 0000000..f17869b --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/__init__.py @@ -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 diff --git a/backend-fastapi/online_dev/document_generator/templates/base.html b/backend-fastapi/online_dev/document_generator/templates/base.html new file mode 100644 index 0000000..617fb1f --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/base.html @@ -0,0 +1,96 @@ + + + + + + + +
+{{ content }} +
+ + diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/barcode.html b/backend-fastapi/online_dev/document_generator/templates/elements/barcode.html new file mode 100644 index 0000000..8e5a533 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/barcode.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') %} +
+
+ {% if barcode_image %} + + {% else %} +
+
+
+
+
+
+
+
+
+
+
+ {{ width }} × {{ height }} +
+ {% endif %} + {% if label %}{{ label }}{% endif %} +
+
diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/container.html b/backend-fastapi/online_dev/document_generator/templates/elements/container.html new file mode 100644 index 0000000..34a1452 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/container.html @@ -0,0 +1 @@ +{% for child in children %}{{ child|safe }}{% endfor %} diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/detail_table.html b/backend-fastapi/online_dev/document_generator/templates/elements/detail_table.html new file mode 100644 index 0000000..9ae307b --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/detail_table.html @@ -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') %} + +{% if show_header %} + +{% if show_index %}{% endif %} +{% for col in columns %} + +{% endfor %} + +{% endif %} + +{% for row in rows %} + +{% if show_index %}{% endif %} +{% for col in columns %} + +{% endfor %} + +{% endfor %} +{% if show_summary and rows %} + +{% if show_index %}{% endif %} +{% for col in columns %} + +{% endfor %} + +{% endif %} + +
序号{{ col.label }}
{{ loop.index }}{{ row[col.field]|default('') }}
合计{{ summary_values[col.field]|default('') }}
diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/divider.html b/backend-fastapi/online_dev/document_generator/templates/elements/divider.html new file mode 100644 index 0000000..2b17769 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/divider.html @@ -0,0 +1 @@ +
diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/doc_info.html b/backend-fastapi/online_dev/document_generator/templates/elements/doc_info.html new file mode 100644 index 0000000..a96145a --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/doc_info.html @@ -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') %} +
+{% for field in fields %}{% if not loop.first %}    {% endif %}{{ field.label }}:{{ field.value if field.value else '' }}{% endfor %} +
diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/field.html b/backend-fastapi/online_dev/document_generator/templates/elements/field.html new file mode 100644 index 0000000..53d2f62 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/field.html @@ -0,0 +1 @@ +{% if label %}{{ label }}{% endif %}{{ value }} diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/footer.html b/backend-fastapi/online_dev/document_generator/templates/elements/footer.html new file mode 100644 index 0000000..412c462 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/footer.html @@ -0,0 +1,5 @@ +{% set color = color|default('#666666') %} + diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/header.html b/backend-fastapi/online_dev/document_generator/templates/elements/header.html new file mode 100644 index 0000000..36be003 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/header.html @@ -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') %} + + +{% if header_type in ['logo', 'logo-text'] and logo_src %} + +{% endif %} +{% if header_type in ['text', 'logo-text'] %} + +{% endif %} + +
{{ company_name }}
diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/image.html b/backend-fastapi/online_dev/document_generator/templates/elements/image.html new file mode 100644 index 0000000..4ea6c32 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/image.html @@ -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 %} +
+{% else %} +
+{% endif %} +
+ {% if src %} + + {% else %} +
+ + + + + + {{ width }} × {{ height }} +
+ {% endif %} + {% if label %}{{ label }}{% endif %} +
+
diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/info_row.html b/backend-fastapi/online_dev/document_generator/templates/elements/info_row.html new file mode 100644 index 0000000..754c2c9 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/info_row.html @@ -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') %} + + +{% for field in fields %} + +{% endfor %} + +
{% if show_label %}{{ field.label }}:{% endif %}{{ field.value if field.value else '' }}
diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/info_table.html b/backend-fastapi/online_dev/document_generator/templates/elements/info_table.html new file mode 100644 index 0000000..1559d11 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/info_table.html @@ -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') %} + +{% for row in rows %} + +{% for cell in row.cells %} + +{% endfor %} + +{% endfor %} +
{% if cell.type == 'field' and show_underline %}{{ cell.content if cell.content else '' }}{% else %}{{ cell.content }}{% endif %}
diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/label_field.html b/backend-fastapi/online_dev/document_generator/templates/elements/label_field.html new file mode 100644 index 0000000..4f29bb1 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/label_field.html @@ -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 %} +

{{ label }}:{{ value if value else '' }}

+{% else %} +

{{ value if value else '' }}

+{% endif %} diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/paragraph.html b/backend-fastapi/online_dev/document_generator/templates/elements/paragraph.html new file mode 100644 index 0000000..2414cf4 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/paragraph.html @@ -0,0 +1,13 @@ +{% set color = color|default('#333333') %} +
+ +
{{ content|safe }}
+
diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/qrcode.html b/backend-fastapi/online_dev/document_generator/templates/elements/qrcode.html new file mode 100644 index 0000000..45091e7 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/qrcode.html @@ -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') %} +
+
+ {% if qrcode_image %} + + {% else %} +
+ + + + + + + + + + {{ width }} × {{ height }} +
+ {% endif %} + {% if label %}{{ label }}{% endif %} +
+
diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/rich_text.html b/backend-fastapi/online_dev/document_generator/templates/elements/rich_text.html new file mode 100644 index 0000000..4adf096 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/rich_text.html @@ -0,0 +1 @@ +
{{ content|safe }}
diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/row.html b/backend-fastapi/online_dev/document_generator/templates/elements/row.html new file mode 100644 index 0000000..ccf83c6 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/row.html @@ -0,0 +1,7 @@ + + +{% for child in children %} + +{% endfor %} + +
{{ child.content|safe }}
diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/seal.html b/backend-fastapi/online_dev/document_generator/templates/elements/seal.html new file mode 100644 index 0000000..7b1a93d --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/seal.html @@ -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 %} +
+
+ {% if label and label_position in ['top', 'left'] %} + {{ label }} + {% endif %} + {{ seal_name }} + {% if label and label_position in ['bottom', 'right'] %} + {{ label }} + {% endif %} +
+
+{% else %} +
+
+ {% if label and label_position in ['top', 'left'] %} + {{ label }} + {% endif %} +
+ 签章 +
+ {% if label and label_position in ['bottom', 'right'] %} + {{ label }} + {% endif %} +
+
+{% endif %} diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/signature.html b/backend-fastapi/online_dev/document_generator/templates/elements/signature.html new file mode 100644 index 0000000..abf89b7 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/signature.html @@ -0,0 +1,5 @@ +{% if src %} + +{% else %} +
+{% endif %} diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/smart_table.html b/backend-fastapi/online_dev/document_generator/templates/elements/smart_table.html new file mode 100644 index 0000000..11a2bc1 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/smart_table.html @@ -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') %} + +{% if col_widths %} ++{% for w in col_widths %} ++{% endfor %} + +{% endif %} +{% for row in rows %} +{% set row_idx = loop.index0 %} +{% set is_first_row = loop.first %} +{% set is_last_row = loop.last %} + +{% 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 %} + +{% endfor %} + +{% endfor %} +
{{ cell.content }}
diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/smart_text.html b/backend-fastapi/online_dev/document_generator/templates/elements/smart_text.html new file mode 100644 index 0000000..8b2e9fe --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/smart_text.html @@ -0,0 +1,4 @@ +
{{ content|safe }}
+{% if table_html %} +
{{ table_html|safe }}
+{% endif %} diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/spacer.html b/backend-fastapi/online_dev/document_generator/templates/elements/spacer.html new file mode 100644 index 0000000..0cd6b04 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/spacer.html @@ -0,0 +1 @@ +
diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/table.html b/backend-fastapi/online_dev/document_generator/templates/elements/table.html new file mode 100644 index 0000000..191f3a6 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/table.html @@ -0,0 +1,16 @@ + + +{% for col in columns %} + +{% endfor %} + + +{% for row in rows %} + +{% for col in columns %} + +{% endfor %} + +{% endfor %} + +
{{ col.label }}
{{ row[col.field]|default('') }}
diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/text.html b/backend-fastapi/online_dev/document_generator/templates/elements/text.html new file mode 100644 index 0000000..7e43864 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/text.html @@ -0,0 +1 @@ +{{ content }} diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/title.html b/backend-fastapi/online_dev/document_generator/templates/elements/title.html new file mode 100644 index 0000000..ba76553 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/title.html @@ -0,0 +1,2 @@ +{% set color = color|default('#333333') %} +

{{ content }}

diff --git a/backend-fastapi/online_dev/document_generator/templates/elements/wysiwyg.html b/backend-fastapi/online_dev/document_generator/templates/elements/wysiwyg.html new file mode 100644 index 0000000..18f93d8 --- /dev/null +++ b/backend-fastapi/online_dev/document_generator/templates/elements/wysiwyg.html @@ -0,0 +1,11 @@ +
+ {{ content }} +
+ diff --git a/backend-fastapi/online_dev/electronic_seal/__init__.py b/backend-fastapi/online_dev/electronic_seal/__init__.py new file mode 100644 index 0000000..4de91b6 --- /dev/null +++ b/backend-fastapi/online_dev/electronic_seal/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +电子签章模块 +""" diff --git a/backend-fastapi/online_dev/electronic_seal/api.py b/backend-fastapi/online_dev/electronic_seal/api.py new file mode 100644 index 0000000..299b3ae --- /dev/null +++ b/backend-fastapi/online_dev/electronic_seal/api.py @@ -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) diff --git a/backend-fastapi/online_dev/electronic_seal/model.py b/backend-fastapi/online_dev/electronic_seal/model.py new file mode 100644 index 0000000..22037da --- /dev/null +++ b/backend-fastapi/online_dev/electronic_seal/model.py @@ -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"), + ) diff --git a/backend-fastapi/online_dev/electronic_seal/schema.py b/backend-fastapi/online_dev/electronic_seal/schema.py new file mode 100644 index 0000000..5cc3df2 --- /dev/null +++ b/backend-fastapi/online_dev/electronic_seal/schema.py @@ -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) diff --git a/backend-fastapi/online_dev/electronic_seal/service.py b/backend-fastapi/online_dev/electronic_seal/service.py new file mode 100644 index 0000000..676db7f --- /dev/null +++ b/backend-fastapi/online_dev/electronic_seal/service.py @@ -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()) diff --git a/backend-fastapi/online_dev/form_data_manager/api.py b/backend-fastapi/online_dev/form_data_manager/api.py new file mode 100644 index 0000000..ab2b009 --- /dev/null +++ b/backend-fastapi/online_dev/form_data_manager/api.py @@ -0,0 +1,1120 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +表单数据操作 API(异步版本) +动态操作表单数据,支持主表和子表的 CRUD +支持操作权限和数据权限控制 +""" +import asyncio +import json +import logging +import tempfile +import uuid +from io import BytesIO +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, File, Form +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from online_dev.form_data_manager.schema import ( + FormDataCreateIn, + FormDataCursorListOut, + FormDataListOut, + FormDataUpdateIn, +) +from online_dev.form_data_manager.db_error_handler import format_error_message, handle_db_error +from online_dev.form_data_manager.service import ( + FormDataService, + MAX_IMPORT_EXPORT_ROWS, SERVER_MEMORY_GB, +) +from online_dev.form_data_manager.dependencies import ( + check_form_permission, + get_user_form_permissions, + get_data_scope_filter, + get_user_info, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/form-data", tags=["表单数据"]) + + +# ============ 权限查询 ============ + +@router.get("/{form_code}/permissions", summary="获取当前用户的表单权限") +async def get_form_permissions( + form_code: str, + request: Request, + db: AsyncSession = Depends(get_db), +): + """ + 获取当前用户对该表单的操作权限 + + 返回格式: + { + "view": true, + "add": true, + "edit": true, + "delete": false, + "export": true, + "import": false + } + """ + return await get_user_form_permissions(form_code, request, db) + + +@router.get("/{form_code}/field-permissions", summary="获取当前用户的字段权限") +async def get_field_permissions( + form_code: str, + request: Request, + db: AsyncSession = Depends(get_db), +): + """ + 获取当前用户对该表单的字段权限 + + 返回格式: + { + "field_name": { + "permission_type": "read" | "write" | "hidden" | "masked", + "mask_rule": "phone" | "email" | "id_card" | "name" | null + }, + ... + } + """ + from core.resource_scope.field_permission.service import ResourceFieldPermissionService + + # 获取用户信息 + user_info = await get_user_info(request) + if not user_info or not user_info.get('role_ids'): + return {} + + role_ids = user_info['role_ids'] + resource_type = f"form:{form_code}" + + # 获取字段权限配置 + configs = await ResourceFieldPermissionService.get_by_roles_and_resource( + db, role_ids, resource_type + ) + + if not configs: + return {} + + # 合并权限 + merged_perms = await ResourceFieldPermissionService.merge_field_permissions( + configs, "most_permissive" + ) + + return merged_perms or {} + + +# ============ 表单数据 CRUD ============ + +@router.get("/{form_code}/list", response_model=FormDataListOut, summary="查询表单数据列表") +async def list_form_data( + request: Request, + form_code: str, + page: int = Query(1, description="页码"), + page_size: int = Query(20, alias="pageSize", description="每页条数"), + sort_fields: str = Query(None, alias="sortFields", description="排序字段(多个用逗号分隔)"), + sort_orders: str = Query(None, alias="sortOrders", description="排序方向(多个用逗号分隔)"), + search: str = Query(None, description="搜索关键词"), + search_fields: str = Query(None, alias="search_fields", description="搜索字段(多个用逗号分隔)"), + db: AsyncSession = Depends(get_db), +): + """查询表单数据列表(带数据权限过滤)""" + import time + _api_t0 = time.perf_counter() + + # 权限校验:查看权限 + await check_form_permission(form_code, "view", request, db) + + _api_t1 = time.perf_counter() + logger.info(f"[API list 耗时] 权限校验: {(_api_t1 - _api_t0) * 1000:.1f}ms") + + # 从查询参数中提取过滤条件 + filters = {} + # 临时存储 gte 和 lte 条件,用于后续合并 + range_conditions = {} + + for key, value in request.query_params.items(): + if key not in ("page", "pageSize", "sortFields", "sortOrders", "search", "search_fields") and value: + # 支持 filter_field 格式(多选过滤,值用逗号分隔) + if key.startswith("filter_"): + field = key[7:] # 去掉 "filter_" 前缀 + filter_values = [v.strip() for v in value.split(',') if v.strip()] + if filter_values: + filters[field] = {"type": "in", "value": filter_values} + # 支持 field__type 格式,如 name__like, name__gte, name__lte + elif "__" in key: + field, filter_type = key.rsplit("__", 1) + # case_sensitive 是字段过滤的修饰符,合并到已有过滤条件中 + if filter_type == "case_sensitive": + if field in filters and isinstance(filters[field], dict): + filters[field]["case_sensitive"] = value.lower() in ("true", "1") + continue + # 收集 gte 和 lte 条件,稍后合并 + if filter_type in ("gte", "lte"): + if field not in range_conditions: + range_conditions[field] = {} + range_conditions[field][filter_type] = value + else: + filters[field] = {"type": filter_type, "value": value} + else: + filters[key] = value + + # 合并 gte 和 lte 条件为 range 查询 + for field, conditions in range_conditions.items(): + if "gte" in conditions and "lte" in conditions: + # 同时有 gte 和 lte,合并为 range + filters[field] = {"type": "range", "value": [conditions["gte"], conditions["lte"]]} + elif "gte" in conditions: + filters[field] = {"type": "gte", "value": conditions["gte"]} + elif "lte" in conditions: + filters[field] = {"type": "lte", "value": conditions["lte"]} + + # 解析多字段排序 + sort_list = [] + if sort_fields: + fields = [f.strip() for f in sort_fields.split(',') if f.strip()] + orders = [o.strip() for o in sort_orders.split(',')] if sort_orders else [] + + for i, field in enumerate(fields): + order = orders[i] if i < len(orders) else 'desc' + sort_list.append({'field': field, 'order': order}) + + # 获取数据权限过滤条件 + data_scope = await get_data_scope_filter(form_code, request, db) + + _api_t2 = time.perf_counter() + logger.info(f"[API list 耗时] 数据权限配置获取: {(_api_t2 - _api_t1) * 1000:.1f}ms") + + # 获取用户信息 + user_info = await get_user_info(request) + + # 解析搜索字段 + search_field_list = None + if search and search_fields: + search_field_list = [f.strip() for f in search_fields.split(',') if f.strip()] + + try: + service = await FormDataService.create_service(db, form_code) + + _api_t3 = time.perf_counter() + logger.info(f"[API list 耗时] create_service: {(_api_t3 - _api_t2) * 1000:.1f}ms") + + result = await service.list( + db=db, + page=page, + page_size=page_size, + filters=filters if filters else None, + sort_list=sort_list if sort_list else None, + data_scope=data_scope, + search=search, + search_fields=search_field_list + ) + + _api_t4 = time.perf_counter() + logger.info(f"[API list 耗时] ===== API 总耗时: {(_api_t4 - _api_t0) * 1000:.1f}ms (form={form_code}, page={page}) =====") + + return result + except Exception as e: + raise handle_db_error(e) + + +@router.get("/{form_code}/list/cursor", response_model=FormDataCursorListOut, summary="游标分页查询表单数据列表") +async def list_form_data_cursor( + request: Request, + form_code: str, + cursor: str = Query(None, description="游标值"), + page_size: int = Query(20, alias="pageSize", description="每页条数"), + cursor_direction: str = Query("next", alias="cursorDirection", description="翻页方向: next/prev"), + sort_fields: str = Query(None, alias="sortFields", description="排序字段(多个用逗号分隔)"), + sort_orders: str = Query(None, alias="sortOrders", description="排序方向(多个用逗号分隔)"), + search: str = Query(None, description="搜索关键词"), + search_fields: str = Query(None, alias="search_fields", description="搜索字段(多个用逗号分隔)"), + db: AsyncSession = Depends(get_db), +): + """游标分页查询表单数据列表(跳过 COUNT,适用于大数据量场景)""" + await check_form_permission(form_code, "view", request, db) + + # 提取过滤条件(复用已有逻辑) + filters = {} + range_conditions = {} + excluded_keys = {"page", "pageSize", "sortFields", "sortOrders", "search", + "search_fields", "cursor", "cursorDirection"} + + for key, value in request.query_params.items(): + if key not in excluded_keys and value: + if key.startswith("filter_"): + field = key[7:] + filter_values = [v.strip() for v in value.split(',') if v.strip()] + if filter_values: + filters[field] = {"type": "in", "value": filter_values} + elif "__" in key: + field, filter_type = key.rsplit("__", 1) + if filter_type == "case_sensitive": + if field in filters and isinstance(filters[field], dict): + filters[field]["case_sensitive"] = value.lower() in ("true", "1") + continue + if filter_type in ("gte", "lte"): + if field not in range_conditions: + range_conditions[field] = {} + range_conditions[field][filter_type] = value + else: + filters[field] = {"type": filter_type, "value": value} + else: + filters[key] = value + + for field, conditions in range_conditions.items(): + if "gte" in conditions and "lte" in conditions: + filters[field] = {"type": "range", "value": [conditions["gte"], conditions["lte"]]} + elif "gte" in conditions: + filters[field] = {"type": "gte", "value": conditions["gte"]} + elif "lte" in conditions: + filters[field] = {"type": "lte", "value": conditions["lte"]} + + sort_list = [] + if sort_fields: + fields = [f.strip() for f in sort_fields.split(',') if f.strip()] + orders = [o.strip() for o in sort_orders.split(',')] if sort_orders else [] + for i, field in enumerate(fields): + order = orders[i] if i < len(orders) else 'desc' + sort_list.append({'field': field, 'order': order}) + + data_scope = await get_data_scope_filter(form_code, request, db) + + search_field_list = None + if search and search_fields: + search_field_list = [f.strip() for f in search_fields.split(',') if f.strip()] + + try: + service = await FormDataService.create_service(db, form_code) + result = await service.list_cursor( + db=db, + page_size=page_size, + cursor=cursor, + cursor_direction=cursor_direction, + filters=filters if filters else None, + sort_list=sort_list if sort_list else None, + data_scope=data_scope, + search=search, + search_fields=search_field_list + ) + return result + except Exception as e: + raise handle_db_error(e) + + +@router.get("/{form_code}/tree/children", summary="获取树形子节点(懒加载)") +async def get_tree_children( + form_code: str, + request: Request, + parent_id: str = Query(None, alias="parentId", description="父节点ID,为空获取根节点"), + parent_field: str = Query("parent_id", alias="parentField", description="父节点字段名"), + db: AsyncSession = Depends(get_db), +): + """ + 获取树形数据的子节点(用于懒加载模式) + - parent_id 为空时获取根节点(parent_field 为空或 NULL 的记录) + - parent_id 有值时获取指定父节点的子节点 + """ + # 权限校验:查看权限 + await check_form_permission(form_code, "view", request, db) + + # 获取数据权限 + data_scope = await get_data_scope_filter(form_code, request, db) + + try: + service = await FormDataService.create_service(db, form_code) + return await service.get_tree_children( + db=db, + parent_id=parent_id, + parent_field=parent_field, + data_scope=data_scope + ) + except Exception as e: + raise handle_db_error(e) + + +@router.get("/{form_code}/field-values/{field_name}", summary="获取字段唯一值列表") +async def get_field_values( + form_code: str, + field_name: str, + page: int = Query(1, description="页码"), + page_size: int = Query(20, alias="pageSize", description="每页条数"), + search: str = Query(None, description="搜索关键词"), + db: AsyncSession = Depends(get_db), +): + """获取指定字段的唯一值列表(用于过滤选项)""" + try: + service = await FormDataService.create_service(db, form_code) + return await service.get_field_values( + db=db, + field_name=field_name, + page=page, + page_size=page_size, + search=search + ) + except Exception as e: + raise handle_db_error(e) + + +@router.get("/{form_code}/check-unique", summary="检查字段值唯一性") +async def check_unique( + form_code: str, + field: str = Query(..., description="字段名"), + value: str = Query(..., description="字段值"), + exclude_id: str = Query(None, alias="excludeId", description="排除的记录ID(编辑时排除自身)"), + db: AsyncSession = Depends(get_db), +): + """检查指定字段的值在数据库中是否唯一""" + try: + service = await FormDataService.create_service(db, form_code) + is_unique = await service.check_unique( + db=db, + field_name=field, + value=value, + exclude_id=exclude_id + ) + return {"unique": is_unique} + except Exception as e: + raise handle_db_error(e) + + +@router.get("/{form_code}/detail/{pk}", summary="获取表单数据详情") +async def get_form_data( + form_code: str, + pk: str, + request: Request, + db: AsyncSession = Depends(get_db), +): + """获取单条表单数据(含子表)""" + # 权限校验:查看权限 + await check_form_permission(form_code, "view", request, db) + + try: + service = await FormDataService.create_service(db, form_code) + return await service.get(db, pk) + except Exception as e: + raise handle_db_error(e) + + +@router.post("/{form_code}", summary="新增表单数据") +async def create_form_data( + form_code: str, + data: FormDataCreateIn, + request: Request, + db: AsyncSession = Depends(get_db), +): + """新增表单数据(含子表)""" + # 权限校验:新增权限 + await check_form_permission(form_code, "add", request, db) + + try: + service = await FormDataService.create_service(db, form_code) + return await service.create(db, data.model_dump()) + except Exception as e: + raise handle_db_error(e) + + +@router.put("/{form_code}/{pk}", summary="更新表单数据") +async def update_form_data( + form_code: str, + pk: str, + data: FormDataUpdateIn, + request: Request, + db: AsyncSession = Depends(get_db), +): + """更新表单数据(含子表)""" + # 权限校验:编辑权限 + await check_form_permission(form_code, "edit", request, db) + + try: + service = await FormDataService.create_service(db, form_code) + return await service.update(db, pk, data.model_dump()) + except Exception as e: + raise handle_db_error(e) + + +@router.delete("/{form_code}/{pk}", summary="删除表单数据") +async def delete_form_data( + form_code: str, + pk: str, + request: Request, + db: AsyncSession = Depends(get_db), +): + """删除表单数据(含子表)""" + # 权限校验:删除权限 + await check_form_permission(form_code, "delete", request, db) + + try: + service = await FormDataService.create_service(db, form_code) + await service.delete(db, pk) + return {"success": True} + except Exception as e: + raise handle_db_error(e) + + +@router.delete("/{form_code}/batch/delete", summary="批量删除表单数据") +async def batch_delete_form_data( + form_code: str, + request: Request, + ids: List[str] = Query(..., description="ID列表"), + db: AsyncSession = Depends(get_db), +): + """批量删除表单数据""" + # 权限校验:删除权限 + await check_form_permission(form_code, "delete", request, db) + + try: + service = await FormDataService.create_service(db, form_code) + count = await service.batch_delete(db, ids) + return {"count": count} + except Exception as e: + raise handle_db_error(e) + + +# ============ 导入导出 ============ + +# 临时导出文件存储(file_id -> 文件路径,仅当前 worker 进程可见) +_export_temp_files: Dict[str, Path] = {} + + +def _get_export_temp_dir() -> Path: + return Path(tempfile.gettempdir()) / "form_export" + + +def _is_valid_export_file_id(file_id: str) -> bool: + if not file_id or "/" in file_id or "\\" in file_id or ".." in file_id: + return False + try: + uuid.UUID(file_id) + except ValueError: + return False + return True + + +def _resolve_export_file_path(file_id: str) -> Optional[Path]: + """解析导出文件路径。多 worker 时内存索引不共享,需回退到共享临时目录。""" + if not _is_valid_export_file_id(file_id): + return None + file_path = _export_temp_files.get(file_id) + if file_path and file_path.exists(): + return file_path + temp_dir = _get_export_temp_dir().resolve() + disk_path = (temp_dir / f"{file_id}.xlsx").resolve() + try: + disk_path.relative_to(temp_dir) + except ValueError: + return None + if disk_path.exists(): + return disk_path + return None + + +def _parse_export_query_params(query_params: dict) -> Tuple[ + Dict[str, Any], List[Dict[str, str]], Optional[str], Optional[List[str]] +]: + """从前端传来的 queryParams 解析出 filters, sort_list, search, search_fields""" + filters: Dict[str, Any] = {} + range_conditions: Dict[str, dict] = {} + sort_list: List[Dict[str, str]] = [] + search: Optional[str] = None + search_fields: Optional[List[str]] = None + + for key, value in query_params.items(): + if not value and value != 0: + continue + + if key == "sortFields": + sort_fields_str = str(value) + sort_orders_str = str(query_params.get("sortOrders", "")) + fields = [f.strip() for f in sort_fields_str.split(',') if f.strip()] + orders = [o.strip() for o in sort_orders_str.split(',')] if sort_orders_str else [] + for i, field in enumerate(fields): + order = orders[i] if i < len(orders) else 'desc' + sort_list.append({'field': field, 'order': order}) + continue + + if key in ("sortOrders",): + continue + + if key == "search": + search = str(value) + continue + + if key == "search_fields": + search_fields = [f.strip() for f in str(value).split(',') if f.strip()] + continue + + if key.startswith("filter_"): + field = key[7:] + filter_values = [v.strip() for v in str(value).split(',') if v.strip()] + if filter_values: + filters[field] = {"type": "in", "value": filter_values} + elif "__" in key: + field, filter_type = key.rsplit("__", 1) + # case_sensitive 是修饰符,合并到已有过滤条件中 + if filter_type == "case_sensitive": + if field in filters and isinstance(filters[field], dict): + filters[field]["case_sensitive"] = str(value).lower() in ("true", "1") + continue + if filter_type in ("gte", "lte"): + if field not in range_conditions: + range_conditions[field] = {} + range_conditions[field][filter_type] = value + else: + filters[field] = {"type": filter_type, "value": value} + else: + filters[key] = value + + for field, conditions in range_conditions.items(): + if "gte" in conditions and "lte" in conditions: + filters[field] = {"type": "range", "value": [conditions["gte"], conditions["lte"]]} + elif "gte" in conditions: + filters[field] = {"type": "gte", "value": conditions["gte"]} + elif "lte" in conditions: + filters[field] = {"type": "lte", "value": conditions["lte"]} + + return filters, sort_list, search, search_fields + + +@router.get("/import-export/config", summary="获取导入导出配置") +async def get_import_export_config(): + """返回服务器内存决定的导入导出行数上限""" + return { + "maxRows": MAX_IMPORT_EXPORT_ROWS, + "serverMemoryGB": SERVER_MEMORY_GB, + } + + +@router.post("/{form_code}/export/task", summary="导出表单数据到 Excel") +async def export_form_data( + form_code: str, + request: Request, + db: AsyncSession = Depends(get_db), +): + """ + 导出表单数据到 Excel(流式导出,支持大数据量) + + - 使用分批查询避免内存溢出 + - 支持字段选择和子表导出 + - 支持查询条件过滤导出(与列表查询保持一致) + - 最大导出行数根据服务器内存动态计算 + """ + # 权限校验:导出权限 + await check_form_permission(form_code, "export", request, db) + + try: + body = await request.json() + include_sub_tables = body.get("includeSubTables", False) + selected_fields = body.get("selectedFields", []) + query_params = body.get("queryParams", {}) + + if include_sub_tables and selected_fields and "id" not in selected_fields: + selected_fields = ["id"] + selected_fields + + filters, sort_list, search, search_fields = _parse_export_query_params(query_params) + + service = await FormDataService.create_service(db, form_code) + + from online_dev.form_data_manager.dependencies import get_data_scope_filter + data_scope = await get_data_scope_filter(form_code, request, db) + + excel_buffer = await service.export_to_excel_streaming( + db=db, + selected_fields=selected_fields if selected_fields else None, + include_sub_tables=include_sub_tables, + batch_size=1000, + data_scope=data_scope, + filters=filters if filters else None, + sort_list=sort_list if sort_list else None, + search=search, + search_fields=search_fields + ) + + return StreamingResponse( + excel_buffer, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename={form_code}_export.xlsx" + } + ) + except Exception as e: + raise handle_db_error(e) + + +@router.post("/{form_code}/export/sse", summary="SSE 导出表单数据(带进度)") +async def export_form_data_sse( + form_code: str, + request: Request, + db: AsyncSession = Depends(get_db), +): + """ + 通过 SSE 导出表单数据,实时推送导出进度。 + + SSE 事件类型: + - progress: {processed, total, percent} 每批处理后推送 + - completed: {fileId, total} 导出完成,返回临时文件 ID 用于下载 + - error: {message} 导出失败 + """ + await check_form_permission(form_code, "export", request, db) + + body = await request.json() + include_sub_tables = body.get("includeSubTables", False) + selected_fields = body.get("selectedFields", []) + query_params = body.get("queryParams", {}) + + if include_sub_tables and selected_fields and "id" not in selected_fields: + selected_fields = ["id"] + selected_fields + + filters, sort_list, search, search_fields = _parse_export_query_params(query_params) + + from online_dev.form_data_manager.dependencies import get_data_scope_filter + data_scope = await get_data_scope_filter(form_code, request, db) + + service = await FormDataService.create_service(db, form_code) + + progress_queue: asyncio.Queue = asyncio.Queue() + + async def on_progress(processed: int, total: int, stage: str = "querying"): + if stage == "generating": + percent = 99.0 + elif total > 0: + percent = round(processed / total * 90, 1) + else: + percent = 0 + await progress_queue.put( + f"event: progress\ndata: {json.dumps({'processed': processed, 'total': total, 'percent': percent, 'stage': stage}, ensure_ascii=False)}\n\n" + ) + + async def run_export(): + try: + logger.debug("SSE run_export 开始执行") + excel_buffer = await service.export_to_excel_streaming( + db=db, + selected_fields=selected_fields if selected_fields else None, + include_sub_tables=include_sub_tables, + batch_size=1000, + data_scope=data_scope, + filters=filters if filters else None, + sort_list=sort_list if sort_list else None, + search=search, + search_fields=search_fields, + on_progress=on_progress + ) + logger.debug("SSE export_to_excel_streaming 完成,开始写文件") + + file_id = str(uuid.uuid4()) + temp_dir = _get_export_temp_dir() + temp_dir.mkdir(parents=True, exist_ok=True) + file_path = temp_dir / f"{file_id}.xlsx" + buffer_bytes = excel_buffer.getvalue() + file_path.write_bytes(buffer_bytes) + _export_temp_files[file_id] = file_path + logger.debug(f"SSE 导出文件已保存: {file_path}, 大小: {len(buffer_bytes)} bytes") + + async def cleanup(): + await asyncio.sleep(300) + _export_temp_files.pop(file_id, None) + try: + file_path.unlink(missing_ok=True) + except Exception: + pass + asyncio.create_task(cleanup()) + + completed_msg = f"event: completed\ndata: {json.dumps({'fileId': file_id, 'total': len(buffer_bytes)}, ensure_ascii=False)}\n\n" + logger.debug(f"SSE 即将推送 completed 事件: fileId={file_id}") + await progress_queue.put(completed_msg) + logger.debug("SSE completed 事件已推送到队列") + except Exception as e: + logger.error(f"SSE 导出失败: {e}", exc_info=True) + try: + await progress_queue.put( + f"event: error\ndata: {json.dumps({'message': format_error_message(e, max_length=500)}, ensure_ascii=False)}\n\n" + ) + except Exception as e2: + logger.error(f"SSE 推送 error 事件也失败: {e2}", exc_info=True) + finally: + logger.debug("SSE run_export finally: 推送 None 结束信号") + await progress_queue.put(None) + + async def event_generator(): + export_task = asyncio.ensure_future(run_export()) + try: + while True: + msg = await progress_queue.get() + if msg is None: + break + yield msg + except (asyncio.CancelledError, GeneratorExit): + export_task.cancel() + finally: + if not export_task.done(): + export_task.cancel() + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + } + ) + + +@router.get("/{form_code}/export/download/{file_id}", summary="下载导出的临时文件") +async def download_export_file( + form_code: str, + file_id: str, + request: Request, + db: AsyncSession = Depends(get_db), +): + """下载 SSE 导出生成的临时 Excel 文件""" + await check_form_permission(form_code, "export", request, db) + + file_path = _resolve_export_file_path(file_id) + if not file_path: + raise HTTPException(status_code=404, detail="导出文件不存在或已过期,请重新导出") + + def file_stream(): + with open(file_path, "rb") as f: + while chunk := f.read(8192): + yield chunk + # 下载完成后清理 + _export_temp_files.pop(file_id, None) + try: + file_path.unlink(missing_ok=True) + except Exception: + pass + + return StreamingResponse( + file_stream(), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename={form_code}_export.xlsx" + } + ) + + +@router.get("/{form_code}/import/template", summary="下载导入模板") +async def download_import_template( + form_code: str, + db: AsyncSession = Depends(get_db), +): + """下载导入模板""" + try: + service = await FormDataService.create_service(db, form_code) + template_buffer = await service.get_import_template() + + return StreamingResponse( + template_buffer, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename={form_code}_template.xlsx" + } + ) + except Exception as e: + raise handle_db_error(e) + + +@router.post("/{form_code}/import/excel", summary="从 Excel 导入数据") +async def import_form_data( + form_code: str, + request: Request, + file: UploadFile = File(..., description="Excel 文件 (.xlsx)"), + mode: str = Form(default="append", description="导入模式:append(追加)或 overwrite(覆盖)"), + data_handling: str = Form(default="insert_only", description="数据处理方式:insert_only / update_only / upsert"), + match_field: str = Form(default="", description="更新模式下用于匹配已有数据的字段名"), + validate_only: bool = Form(default=False, description="是否仅验证数据,不执行实际导入"), + db: AsyncSession = Depends(get_db), +): + """ + 从 Excel 导入数据 + + - mode: 导入模式 + - append: 追加模式(默认),保留现有数据 + - overwrite: 覆盖模式,先清空表再导入 + - data_handling: 数据处理方式(追加模式下有效) + - insert_only: 仅新增(默认) + - update_only: 仅更新已有数据,不新增 + - upsert: 更新已有数据,不存在则新增 + - match_field: 更新/upsert 模式下用于匹配已有数据的字段名 + - validate_only: 仅验证模式,只检查数据是否合规,不执行实际导入 + """ + await check_form_permission(form_code, "import", request, db) + + if not file.filename.endswith(".xlsx"): + raise HTTPException(status_code=400, detail="只支持 .xlsx 格式") + + if mode not in ("append", "overwrite"): + raise HTTPException(status_code=400, detail="mode 参数只能是 append 或 overwrite") + + if data_handling not in ("insert_only", "update_only", "upsert"): + raise HTTPException(status_code=400, detail="data_handling 参数只能是 insert_only、update_only 或 upsert") + + if mode == "append" and data_handling in ("update_only", "upsert") and not match_field: + raise HTTPException(status_code=400, detail="更新模式下必须指定 match_field") + + try: + service = await FormDataService.create_service(db, form_code) + content = await file.read() + success, fail, errors = await service.import_from_excel( + db, content, mode=mode, validate_only=validate_only, + data_handling=data_handling, match_field=match_field or None + ) + + if validate_only: + meta = {} + real_errors = errors + if errors and isinstance(errors[-1], dict) and errors[-1].get("_meta"): + meta = errors[-1] + real_errors = errors[:-1] + return { + "success": success, + "fail": fail, + "message": f"数据验证完成:{success} 条通过,{fail} 条失败", + "errors": real_errors, + "validated": True, + "will_insert": meta.get("will_insert", success), + "will_update": meta.get("will_update", 0), + "action": meta.get("action", ""), + } + + return { + "success": success, + "fail": fail, + "message": f"成功导入 {success} 条,失败 {fail} 条", + "errors": errors + } + except Exception as e: + raise handle_db_error(e) + + +@router.post("/{form_code}/import/validate/sse", summary="SSE 验证导入数据(带进度)") +async def validate_import_data_sse( + form_code: str, + request: Request, + file: UploadFile = File(..., description="Excel 文件 (.xlsx)"), + mode: str = Form(default="append", description="导入模式:append(追加)或 overwrite(覆盖)"), + data_handling: str = Form(default="insert_only", description="数据处理方式:insert_only / update_only / upsert"), + match_field: str = Form(default="", description="更新模式下用于匹配已有数据的字段名"), + db: AsyncSession = Depends(get_db), +): + """ + 通过 SSE 验证导入数据,实时推送解析进度。 + + SSE 事件类型: + - progress: {processed, total, percent, stage} 解析进度 + - completed: {success, fail, errors, validated} 验证完成 + - error: {message} 验证失败 + """ + await check_form_permission(form_code, "import", request, db) + + if not file.filename or not file.filename.endswith(".xlsx"): + raise HTTPException(status_code=400, detail="只支持 .xlsx 格式") + + if mode not in ("append", "overwrite"): + raise HTTPException(status_code=400, detail="mode 参数只能是 append 或 overwrite") + + if data_handling not in ("insert_only", "update_only", "upsert"): + raise HTTPException(status_code=400, detail="data_handling 参数只能是 insert_only、update_only 或 upsert") + + if mode == "append" and data_handling in ("update_only", "upsert") and not match_field: + raise HTTPException(status_code=400, detail="更新模式下必须指定 match_field") + + content = await file.read() + service = await FormDataService.create_service(db, form_code) + + progress_queue: asyncio.Queue = asyncio.Queue() + + async def on_progress(processed: int, total: int, stage: str, success: int, fail: int): + if total > 0: + if stage == "parsing": + percent = round(processed / total * 60, 1) + elif stage == "validating": + percent = 60 + round(processed / total * 39, 1) + else: + percent = round(processed / total * 99, 1) + else: + percent = 0 + await progress_queue.put( + f"event: progress\ndata: {json.dumps({'processed': processed, 'total': total, 'percent': percent, 'stage': stage}, ensure_ascii=False)}\n\n" + ) + + async def run_validate(): + try: + logger.debug(f"SSE validate 开始: form_code={form_code}") + success, fail, errors = await service.import_from_excel( + db, content, mode=mode, validate_only=True, + data_handling=data_handling, + match_field=match_field or None, + on_progress=on_progress + ) + # 提取 _meta 信息(errors 列表末尾可能含 _meta 字典) + meta = {} + real_errors = errors + if errors and isinstance(errors[-1], dict) and errors[-1].get("_meta"): + meta = errors[-1] + real_errors = errors[:-1] + + completed_data = { + "success": success, + "fail": fail, + "errors": real_errors, + "message": f"数据验证完成:{success} 条通过,{fail} 条失败", + "validated": True, + "will_insert": meta.get("will_insert", success), + "will_update": meta.get("will_update", 0), + "action": meta.get("action", ""), + } + logger.debug(f"SSE validate 完成: success={success}, fail={fail}") + await progress_queue.put( + f"event: completed\ndata: {json.dumps(completed_data, ensure_ascii=False)}\n\n" + ) + except Exception as e: + logger.error(f"SSE 验证失败: {e}", exc_info=True) + try: + await progress_queue.put( + f"event: error\ndata: {json.dumps({'message': format_error_message(e, max_length=500)}, ensure_ascii=False)}\n\n" + ) + except Exception as e2: + logger.error(f"SSE 推送 error 事件也失败: {e2}", exc_info=True) + finally: + await progress_queue.put(None) + + async def event_generator(): + validate_task = asyncio.ensure_future(run_validate()) + try: + while True: + msg = await progress_queue.get() + if msg is None: + break + yield msg + except (asyncio.CancelledError, GeneratorExit): + validate_task.cancel() + finally: + if not validate_task.done(): + validate_task.cancel() + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + } + ) + + +@router.post("/{form_code}/import/sse", summary="SSE 导入表单数据(带进度)") +async def import_form_data_sse( + form_code: str, + request: Request, + file: UploadFile = File(..., description="Excel 文件 (.xlsx)"), + mode: str = Form(default="append", description="导入模式:append(追加)或 overwrite(覆盖)"), + data_handling: str = Form(default="insert_only", description="数据处理方式:insert_only / update_only / upsert"), + match_field: str = Form(default="", description="更新模式下用于匹配已有数据的字段名"), + db: AsyncSession = Depends(get_db), +): + """ + 通过 SSE 导入表单数据,实时推送导入进度。 + + SSE 事件类型: + - progress: {processed, total, percent, stage, success, fail} + - stage="parsing" 解析 Excel 阶段 + - stage="importing" 写入数据库阶段 + - completed: {success, fail, errors, message} 导入完成 + - error: {message} 导入失败 + """ + await check_form_permission(form_code, "import", request, db) + + if not file.filename or not file.filename.endswith(".xlsx"): + raise HTTPException(status_code=400, detail="只支持 .xlsx 格式") + + if mode not in ("append", "overwrite"): + raise HTTPException(status_code=400, detail="mode 参数只能是 append 或 overwrite") + + if data_handling not in ("insert_only", "update_only", "upsert"): + raise HTTPException(status_code=400, detail="data_handling 参数只能是 insert_only、update_only 或 upsert") + + if mode == "append" and data_handling in ("update_only", "upsert") and not match_field: + raise HTTPException(status_code=400, detail="更新模式下必须指定 match_field") + + content = await file.read() + service = await FormDataService.create_service(db, form_code) + + progress_queue: asyncio.Queue = asyncio.Queue() + + async def on_progress(processed: int, total: int, stage: str, success: int, fail: int): + if total > 0: + if stage == "parsing": + percent = round(processed / total * 20, 1) + elif stage == "validating": + percent = 20 + round(processed / total * 10, 1) + else: + percent = 30 + round(processed / total * 69, 1) + else: + percent = 0 + await progress_queue.put( + f"event: progress\ndata: {json.dumps({'processed': processed, 'total': total, 'percent': percent, 'stage': stage, 'success': success, 'fail': fail}, ensure_ascii=False)}\n\n" + ) + + async def run_import(): + try: + logger.debug(f"SSE import 开始: form_code={form_code}, mode={mode}, data_handling={data_handling}") + success, fail, errors = await service.import_from_excel( + db, content, mode=mode, validate_only=False, + data_handling=data_handling, + match_field=match_field or None, + on_progress=on_progress + ) + completed_data = { + "success": success, + "fail": fail, + "errors": errors, + "message": f"成功导入 {success} 条,失败 {fail} 条" + } + logger.debug(f"SSE import 完成: success={success}, fail={fail}") + await progress_queue.put( + f"event: completed\ndata: {json.dumps(completed_data, ensure_ascii=False)}\n\n" + ) + except Exception as e: + logger.error(f"SSE 导入失败: {e}", exc_info=True) + try: + await progress_queue.put( + f"event: error\ndata: {json.dumps({'message': format_error_message(e, max_length=500)}, ensure_ascii=False)}\n\n" + ) + except Exception as e2: + logger.error(f"SSE 推送 error 事件也失败: {e2}", exc_info=True) + finally: + await progress_queue.put(None) + + async def event_generator(): + import_task = asyncio.ensure_future(run_import()) + try: + while True: + msg = await progress_queue.get() + if msg is None: + break + yield msg + except (asyncio.CancelledError, GeneratorExit): + import_task.cancel() + finally: + if not import_task.done(): + import_task.cancel() + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + } + ) diff --git a/backend-fastapi/online_dev/form_data_manager/dependencies.py b/backend-fastapi/online_dev/form_data_manager/dependencies.py new file mode 100644 index 0000000..183e5ae --- /dev/null +++ b/backend-fastapi/online_dev/form_data_manager/dependencies.py @@ -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 diff --git a/backend-fastapi/online_dev/form_data_manager/schema.py b/backend-fastapi/online_dev/form_data_manager/schema.py new file mode 100644 index 0000000..eb528b7 --- /dev/null +++ b/backend-fastapi/online_dev/form_data_manager/schema.py @@ -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="错误详情") diff --git a/backend-fastapi/online_dev/form_manager/api.py b/backend-fastapi/online_dev/form_manager/api.py new file mode 100644 index 0000000..3b2233f --- /dev/null +++ b/backend-fastapi/online_dev/form_manager/api.py @@ -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)) diff --git a/backend-fastapi/online_dev/form_manager/schema.py b/backend-fastapi/online_dev/form_manager/schema.py new file mode 100644 index 0000000..9b4d671 --- /dev/null +++ b/backend-fastapi/online_dev/form_manager/schema.py @@ -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="允许导入") diff --git a/backend-fastapi/online_dev/page_manager/schema.py b/backend-fastapi/online_dev/page_manager/schema.py new file mode 100644 index 0000000..2e5ffba --- /dev/null +++ b/backend-fastapi/online_dev/page_manager/schema.py @@ -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="菜单排序") diff --git a/backend-fastapi/online_dev/report_manager/__init__.py b/backend-fastapi/online_dev/report_manager/__init__.py new file mode 100644 index 0000000..a298e99 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""报表管理模块""" diff --git a/backend-fastapi/online_dev/report_manager/acceptance_e2e.py b/backend-fastapi/online_dev/report_manager/acceptance_e2e.py new file mode 100644 index 0000000..2358b00 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/acceptance_e2e.py @@ -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()) diff --git a/backend-fastapi/online_dev/report_manager/api.py b/backend-fastapi/online_dev/report_manager/api.py new file mode 100644 index 0000000..43fbdac --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/api.py @@ -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)) diff --git a/backend-fastapi/online_dev/report_manager/constants.py b/backend-fastapi/online_dev/report_manager/constants.py new file mode 100644 index 0000000..935f594 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/constants.py @@ -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": {}, + } diff --git a/backend-fastapi/online_dev/report_manager/data_api.py b/backend-fastapi/online_dev/report_manager/data_api.py new file mode 100644 index 0000000..c4de342 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/data_api.py @@ -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)) diff --git a/backend-fastapi/online_dev/report_manager/dataset_bridge.py b/backend-fastapi/online_dev/report_manager/dataset_bridge.py new file mode 100644 index 0000000..e0a6597 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/dataset_bridge.py @@ -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) diff --git a/backend-fastapi/online_dev/report_manager/engine/__init__.py b/backend-fastapi/online_dev/report_manager/engine/__init__.py new file mode 100644 index 0000000..3ad7806 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""报表渲染引擎""" diff --git a/backend-fastapi/online_dev/report_manager/engine/chart_data.py b/backend-fastapi/online_dev/report_manager/engine/chart_data.py new file mode 100644 index 0000000..b77fa8c --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/chart_data.py @@ -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 diff --git a/backend-fastapi/online_dev/report_manager/engine/code_cells.py b/backend-fastapi/online_dev/report_manager/engine/code_cells.py new file mode 100644 index 0000000..569b2d0 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/code_cells.py @@ -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 diff --git a/backend-fastapi/online_dev/report_manager/engine/column_layout.py b/backend-fastapi/online_dev/report_manager/engine/column_layout.py new file mode 100644 index 0000000..b71f1ff --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/column_layout.py @@ -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 diff --git a/backend-fastapi/online_dev/report_manager/engine/convert.py b/backend-fastapi/online_dev/report_manager/engine/convert.py new file mode 100644 index 0000000..aedffb7 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/convert.py @@ -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 diff --git a/backend-fastapi/online_dev/report_manager/engine/convert_lookup.py b/backend-fastapi/online_dev/report_manager/engine/convert_lookup.py new file mode 100644 index 0000000..8b2982a --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/convert_lookup.py @@ -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 diff --git a/backend-fastapi/online_dev/report_manager/engine/data_expand.py b/backend-fastapi/online_dev/report_manager/engine/data_expand.py new file mode 100644 index 0000000..7439e64 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/data_expand.py @@ -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 diff --git a/backend-fastapi/online_dev/report_manager/engine/dataset_transform.py b/backend-fastapi/online_dev/report_manager/engine/dataset_transform.py new file mode 100644 index 0000000..2deea1e --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/dataset_transform.py @@ -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 diff --git a/backend-fastapi/online_dev/report_manager/engine/export_excel.py b/backend-fastapi/online_dev/report_manager/engine/export_excel.py new file mode 100644 index 0000000..9f28b0c --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/export_excel.py @@ -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() diff --git a/backend-fastapi/online_dev/report_manager/engine/export_excel_extras.py b/backend-fastapi/online_dev/report_manager/engine/export_excel_extras.py new file mode 100644 index 0000000..a2bdff4 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/export_excel_extras.py @@ -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)" + 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, + ) diff --git a/backend-fastapi/online_dev/report_manager/engine/export_pdf.py b/backend-fastapi/online_dev/report_manager/engine/export_pdf.py new file mode 100644 index 0000000..caa3c39 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/export_pdf.py @@ -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"{watermark_text}", 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() diff --git a/backend-fastapi/online_dev/report_manager/engine/expression_eval.py b/backend-fastapi/online_dev/report_manager/engine/expression_eval.py new file mode 100644 index 0000000..63ac7c0 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/expression_eval.py @@ -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"(? 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 diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/JNPF_DIFF.md b/backend-fastapi/online_dev/report_manager/engine/fixtures/JNPF_DIFF.md new file mode 100644 index 0000000..8d2f965 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/JNPF_DIFF.md @@ -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` diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/README.md b/backend-fastapi/online_dev/report_manager/engine/fixtures/README.md new file mode 100644 index 0000000..2be1ac5 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/README.md @@ -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 | diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/extract_jnpf_fixtures.py b/backend-fastapi/online_dev/report_manager/engine/fixtures/extract_jnpf_fixtures.py new file mode 100644 index 0000000..af10847 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/extract_jnpf_fixtures.py @@ -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() diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_column_col_type1.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_column_col_type1.json new file mode 100644 index 0000000..fb2004b --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_column_col_type1.json @@ -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" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_column_col_type2.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_column_col_type2.json new file mode 100644 index 0000000..50dde95 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_column_col_type2.json @@ -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" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_expression_chain.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_expression_chain.json new file mode 100644 index 0000000..d3500de --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_expression_chain.json @@ -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" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_fill_empty_rows.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_fill_empty_rows.json new file mode 100644 index 0000000..04ca54b --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_fill_empty_rows.json @@ -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": "" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_cross_row.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_cross_row.json new file mode 100644 index 0000000..021a308 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_cross_row.json @@ -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" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_db_user_group.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_db_user_group.json new file mode 100644 index 0000000..05f9672 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_db_user_group.json @@ -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 + } + } + ] +} \ No newline at end of file diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_db_user_list.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_db_user_list.json new file mode 100644 index 0000000..5df5b80 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_db_user_list.json @@ -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 + } + } + ] +} \ No newline at end of file diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_db_user_matrix.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_db_user_matrix.json new file mode 100644 index 0000000..8a9add3 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_db_user_matrix.json @@ -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": "吴忠民" + } + } +} \ No newline at end of file diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_export_list.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_export_list.json new file mode 100644 index 0000000..6aa5967 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_export_list.json @@ -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" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_header_merge_list.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_header_merge_list.json new file mode 100644 index 0000000..abcf540 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_header_merge_list.json @@ -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" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_parent_group.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_parent_group.json new file mode 100644 index 0000000..00a2217 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_parent_group.json @@ -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" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_poly_summary.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_poly_summary.json new file mode 100644 index 0000000..d7cffd0 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_poly_summary.json @@ -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" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_convert_date.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_convert_date.json new file mode 100644 index 0000000..8609eee --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_convert_date.json @@ -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" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_convert_number.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_convert_number.json new file mode 100644 index 0000000..01bd9a7 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_convert_number.json @@ -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" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_convert_select.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_convert_select.json new file mode 100644 index 0000000..bebf0bd --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_convert_select.json @@ -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" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_convert_user_inline.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_convert_user_inline.json new file mode 100644 index 0000000..cb45cd5 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_convert_user_inline.json @@ -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": "研发部" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_field_mapping.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_field_mapping.json new file mode 100644 index 0000000..8b2a9c7 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_prod_field_mapping.json @@ -0,0 +1,48 @@ +{ + "name": "jnpf_prod_field_mapping", + "comment": "field_mapping 重命名后绑定字段可正确列表扩展", + "snapshot": { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + "1": { "0": { "v": "" } } + } + } + } + }, + "cells": { + "cells": [ + { + "type": "dataSource", + "sheet": "sheet1", + "row": 1, + "col": 0, + "custom": { + "dataSetName": "order", + "field": "product", + "polymerizationType": "1", + "expand": "down" + } + } + ] + }, + "field_mapping": { + "order": { + "product_code": "product" + } + }, + "datasets": { + "order": [ + { "product_code": "P1" }, + { "product_code": "P2" } + ] + }, + "params": {}, + "expect": { + "sheet1": { + "1,0": "P1", + "2,0": "P2" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_style.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_style.json new file mode 100644 index 0000000..5a78088 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_style.json @@ -0,0 +1,80 @@ +{ + "name": "jnpf_export_style_mvp", + "comment": "对齐 JNPF 导出 cells 结构:parameter #{x}、dataSource expand、全表占位", + "snapshot": { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + "0": { + "0": { "v": "Report: #{title}" }, + "1": { "v": "" } + }, + "1": { + "0": { "v": "" }, + "1": { "v": "" } + } + } + } + } + }, + "cells": { + "cells": [ + { + "type": "parameter", + "sheet": "sheet1", + "row": 0, + "col": 1, + "custom": { + "type": "parameter", + "value": "#{dept}", + "field": "dept" + } + }, + { + "type": "dataSource", + "sheet": "sheet1", + "row": 1, + "col": 0, + "custom": { + "type": "dataSource", + "dataSetName": "order", + "field": "product", + "expand": "down" + } + }, + { + "type": "dataSource", + "sheet": "sheet1", + "row": 1, + "col": 1, + "custom": { + "type": "dataSource", + "dataSetName": "order", + "field": "amount", + "expand": "down" + } + } + ] + }, + "datasets": { + "order": [ + { "product": "P1", "amount": 100 }, + { "product": "P2", "amount": 200 } + ] + }, + "params": { + "title": "Sales", + "dept": "East" + }, + "expect": { + "sheet1": { + "0,0": "Report: Sales", + "0,1": "East", + "1,0": "P1", + "2,0": "P2", + "1,1": "100", + "2,1": "200" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_system_params.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_system_params.json new file mode 100644 index 0000000..c6b2520 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_system_params.json @@ -0,0 +1,49 @@ +{ + "name": "jnpf_system_params", + "source": "jnpf", + "comment": "系统参数 + 查询参数合并(parameter_resolver)", + "snapshot": { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + "0": { "0": { "v": "报表人: #{currentUserName}" }, "1": { "v": "" } }, + "1": { "0": { "v": "日期: #{currentDate}" }, "1": { "v": "" } } + } + } + } + }, + "cells": { + "cells": [ + { + "type": "parameter", + "sheet": "sheet1", + "row": 0, + "col": 1, + "custom": { "value": "#{deptName}" } + }, + { + "type": "parameter", + "sheet": "sheet1", + "row": 1, + "col": 1, + "custom": { "value": "#{keyword}" } + } + ] + }, + "datasets": {}, + "params": { + "currentUserName": "Admin", + "currentDate": "2026-05-22", + "deptName": "总部", + "keyword": "测试" + }, + "expect": { + "sheet1": { + "0,0": "报表人: Admin", + "0,1": "总部", + "1,0": "日期: 2026-05-22", + "1,1": "测试" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_top_parent.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_top_parent.json new file mode 100644 index 0000000..e61e70a --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_jnpf_top_parent.json @@ -0,0 +1,83 @@ +{ + "name": "jnpf_top_parent_same_row", + "comment": "上父格:年(分组,行0) → 月(列表,行0,上父年) → 金额(列表,行0,左父月)", + "snapshot": { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + "0": { "0": { "v": "" }, "1": { "v": "" }, "2": { "v": "" } }, + "1": { "0": { "v": "" }, "1": { "v": "" }, "2": { "v": "" } }, + "2": { "0": { "v": "" }, "1": { "v": "" }, "2": { "v": "" } } + } + } + } + }, + "cells": { + "cells": [ + { + "type": "dataSource", + "sheet": "sheet1", + "row": 0, + "col": 0, + "custom": { + "dataSetName": "sales", + "field": "year", + "polymerizationType": "2", + "expand": "down", + "topParentCellType": "default", + "leftParentCellType": "default" + } + }, + { + "type": "dataSource", + "sheet": "sheet1", + "row": 0, + "col": 1, + "custom": { + "dataSetName": "sales", + "field": "month", + "polymerizationType": "1", + "expand": "down", + "topParentCellType": "default", + "leftParentCellType": "default" + } + }, + { + "type": "dataSource", + "sheet": "sheet1", + "row": 0, + "col": 2, + "custom": { + "dataSetName": "sales", + "field": "amount", + "polymerizationType": "1", + "expand": "down", + "topParentCellType": "default", + "leftParentCellType": "default" + } + } + ] + }, + "datasets": { + "sales": [ + { "year": 2023, "month": 1, "amount": 10 }, + { "year": 2023, "month": 2, "amount": 20 }, + { "year": 2024, "month": 1, "amount": 30 } + ] + }, + "params": {}, + "expect": { + "sheet1": { + "0,0": "2023", + "1,0": "2023", + "2,0": "2024", + "0,1": "1", + "1,1": "2", + "2,1": "1", + "0,2": "10", + "1,2": "20", + "2,2": "30" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_list_down.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_list_down.json new file mode 100644 index 0000000..7e7034e --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_list_down.json @@ -0,0 +1,63 @@ +{ + "name": "list_expand_down", + "snapshot": { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + "0": { + "0": { "v": "Name" }, + "1": { "v": "" } + }, + "1": { + "0": { "v": "" }, + "1": { "v": "" } + } + } + } + } + }, + "cells": { + "cells": [ + { + "type": "dataSource", + "sheet": "sheet1", + "row": 1, + "col": 0, + "custom": { + "type": "dataSource", + "dataSetName": "items", + "field": "name", + "expand": "down" + } + }, + { + "type": "dataSource", + "sheet": "sheet1", + "row": 1, + "col": 1, + "custom": { + "type": "dataSource", + "dataSetName": "items", + "field": "qty", + "expand": "down" + } + } + ] + }, + "datasets": { + "items": [ + { "name": "Apple", "qty": 1 }, + { "name": "Banana", "qty": 2 } + ] + }, + "params": {}, + "expect": { + "sheet1": { + "1,0": "Apple", + "2,0": "Banana", + "1,1": "1", + "2,1": "2" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_multi_col_down.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_multi_col_down.json new file mode 100644 index 0000000..30f8c2f --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_multi_col_down.json @@ -0,0 +1,60 @@ +{ + "name": "multi_col_down_align", + "comment": "同行多列向下扩展:列数不同,按 max_len 对齐", + "snapshot": { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + "1": { "0": { "v": "" }, "1": { "v": "" } } + } + } + } + }, + "cells": { + "cells": [ + { + "type": "dataSource", + "sheet": "sheet1", + "row": 1, + "col": 0, + "custom": { + "type": "dataSource", + "dataSetName": "items", + "field": "name", + "expand": "down" + } + }, + { + "type": "dataSource", + "sheet": "sheet1", + "row": 1, + "col": 1, + "custom": { + "type": "dataSource", + "dataSetName": "items", + "field": "qty", + "expand": "down" + } + } + ] + }, + "datasets": { + "items": [ + { "name": "A", "qty": 1 }, + { "name": "B", "qty": 2 }, + { "name": "C", "qty": 3 } + ] + }, + "params": {}, + "expect": { + "sheet1": { + "1,0": "A", + "2,0": "B", + "3,0": "C", + "1,1": "1", + "2,1": "2", + "3,1": "3" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_param_placeholder.json b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_param_placeholder.json new file mode 100644 index 0000000..5c2a29a --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/fixtures/golden_param_placeholder.json @@ -0,0 +1,32 @@ +{ + "name": "param_and_placeholder", + "snapshot": { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + "0": { "0": { "v": "Hello #{userName}" }, "1": { "v": "" } } + } + } + } + }, + "cells": { + "cells": [ + { + "type": "parameter", + "sheet": "sheet1", + "row": 0, + "col": 1, + "custom": { "value": "#{dept}" } + } + ] + }, + "datasets": {}, + "params": { "userName": "Alice", "dept": "Sales" }, + "expect": { + "sheet1": { + "0,0": "Hello Alice", + "0,1": "Sales" + } + } +} diff --git a/backend-fastapi/online_dev/report_manager/engine/import_excel.py b/backend-fastapi/online_dev/report_manager/engine/import_excel.py new file mode 100644 index 0000000..024eacc --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/import_excel.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""解析 Excel 为 Univer 单元格网格数据""" +import io +from typing import Any, Dict, List + +from openpyxl import load_workbook + + +def parse_excel_to_grid(file_content: bytes) -> Dict[str, Any]: + """将 xlsx/xls 解析为设计器可写入的网格结构""" + wb = load_workbook(io.BytesIO(file_content), read_only=True, data_only=True) + ws = wb.active + if ws is None: + return {"rowsCount": 0, "colsCount": 0, "data": []} + + rows_data: List[List[Dict[str, Any]]] = [] + max_col = 0 + + for row in ws.iter_rows(values_only=True): + row_cells = [] + for cell in row: + val = cell + if val is None: + row_cells.append({"v": ""}) + else: + row_cells.append({"v": val}) + if any(c.get("v") not in ("", None) for c in row_cells): + rows_data.append(row_cells) + max_col = max(max_col, len(row_cells)) + + # 去除尾部全空行已在上面处理;补齐列宽 + for row in rows_data: + while len(row) < max_col: + row.append({"v": ""}) + + wb.close() + + rows_count = len(rows_data) + cols_count = max_col + return { + "rowsCount": rows_count, + "colsCount": cols_count, + "data": rows_data, + } diff --git a/backend-fastapi/online_dev/report_manager/engine/merge_recalc.py b/backend-fastapi/online_dev/report_manager/engine/merge_recalc.py new file mode 100644 index 0000000..5c94c8f --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/merge_recalc.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""扩展后 mergeData 行偏移重算""" +from __future__ import annotations + +import copy +from typing import Any, Dict, List, Tuple + + +def _int(v: Any, default: int = 0) -> int: + try: + return int(v) + except (TypeError, ValueError): + return default + + +def compute_row_insertions( + before: Dict[str, Any], + after: Dict[str, Any], +) -> Dict[str, List[Tuple[int, int]]]: + """ + 对比扩展前后 cellData 行数,推断每个 sheet 在各行插入的额外行数。 + 返回 sheet_id -> [(anchor_row, rows_added), ...](按 anchor_row 升序)。 + """ + insertions: Dict[str, List[Tuple[int, int]]] = {} + before_sheets = (before or {}).get("sheets") or {} + after_sheets = (after or {}).get("sheets") or {} + + for sheet_id, after_sheet in after_sheets.items(): + before_sheet = before_sheets.get(sheet_id) or {} + before_rows = sorted(int(k) for k in (before_sheet.get("cellData") or {}).keys()) + after_rows = sorted(int(k) for k in (after_sheet.get("cellData") or {}).keys()) + if len(after_rows) <= len(before_rows): + continue + added = len(after_rows) - len(before_rows) + # 默认在最后一个原数据行之后插入(legacy down band 常见模式) + anchor = before_rows[-1] if before_rows else 0 + insertions.setdefault(sheet_id, []).append((anchor, added)) + return insertions + + +def recalculate_merge_data( + snapshot: Dict[str, Any], + row_insertions: Dict[str, List[Tuple[int, int]]], +) -> Dict[str, Any]: + """按行插入量下移 merge 区域(仅处理 startRow/endRow)。""" + if not row_insertions: + return snapshot + result = copy.deepcopy(snapshot) + sheets = result.get("sheets") or {} + + for sheet_id, inserts in row_insertions.items(): + sheet = sheets.get(sheet_id) + if not sheet: + continue + merge_list = sheet.get("mergeData") or [] + if not merge_list: + continue + sorted_inserts = sorted(inserts, key=lambda x: x[0]) + new_merges: List[Any] = [] + for region in merge_list: + if not isinstance(region, dict): + new_merges.append(region) + continue + start_row = _int(region.get("startRow"), 0) + end_row = _int(region.get("endRow"), start_row) + shift = 0 + for anchor, delta in sorted_inserts: + if start_row > anchor: + shift += delta + if shift: + region = {**region, "startRow": start_row + shift, "endRow": end_row + shift} + new_merges.append(region) + sheet["mergeData"] = new_merges + return result + + +def apply_merge_recalc_after_expand( + original_snapshot: Dict[str, Any], + expanded_snapshot: Dict[str, Any], +) -> Dict[str, Any]: + insertions = compute_row_insertions(original_snapshot, expanded_snapshot) + return recalculate_merge_data(expanded_snapshot, insertions) diff --git a/backend-fastapi/online_dev/report_manager/engine/parameter_resolver.py b/backend-fastapi/online_dev/report_manager/engine/parameter_resolver.py new file mode 100644 index 0000000..c837cd5 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/parameter_resolver.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""预览参数解析(对标 JNPF parameterData 子集)""" +from __future__ import annotations + +from datetime import date, datetime +from typing import Any, Dict, Optional + + +def _today_str() -> str: + return date.today().isoformat() + + +def _now_str() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def build_system_params( + *, + user_id: Optional[str] = None, + user_name: Optional[str] = None, + dept_id: Optional[str] = None, + dept_name: Optional[str] = None, + tenant_id: Optional[str] = None, +) -> Dict[str, Any]: + """系统变量,与 JNPF parameterData 常用键对齐。""" + params: Dict[str, Any] = { + "currentDate": _today_str(), + "currentTime": _now_str(), + "currentUserId": user_id or "", + "currentUserName": user_name or "", + "currentDeptId": dept_id or "", + "currentDeptName": dept_name or "", + "currentTenantId": tenant_id or "", + } + # 兼容 #{userName} / #{deptName} 简写 + if user_name: + params.setdefault("userName", user_name) + if dept_name: + params.setdefault("deptName", dept_name) + return params + + +def merge_preview_params( + query_defaults: Optional[Dict[str, Any]] = None, + request_params: Optional[Dict[str, Any]] = None, + system_params: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """合并顺序:query 默认 → 系统变量 → 请求参数(请求优先)。""" + merged: Dict[str, Any] = {} + for src in (query_defaults or {}, system_params or {}, request_params or {}): + for k, v in src.items(): + if v is not None and v != "": + merged[k] = v + return merged diff --git a/backend-fastapi/online_dev/report_manager/engine/parent_cells.py b/backend-fastapi/online_dev/report_manager/engine/parent_cells.py new file mode 100644 index 0000000..26f6a4f --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/parent_cells.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""解析 JNPF 风格左/上父格(none / default / custom)。""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple + +CellKey = Tuple[str, int, int] + + +def col_letter_to_index(letters: str) -> int: + s = (letters or "A").upper() + n = 0 + for ch in s: + if not ("A" <= ch <= "Z"): + continue + n = n * 26 + (ord(ch) - 64) + return max(0, n - 1) + + +def col_index_to_letter(col: int) -> str: + n = col + s = "" + while n >= 0: + s = chr(65 + (n % 26)) + s + n = n // 26 - 1 + return s or "A" + + +def _int_coord(v: Any, default: int = 0) -> int: + try: + return int(v) + except (TypeError, ValueError): + return default + + +def cell_key(cell: Dict[str, Any]) -> CellKey: + return ( + str(cell.get("sheet") or "sheet1"), + _int_coord(cell.get("row"), 0), + _int_coord(cell.get("col"), 0), + ) + + +def _is_data_source(cell: Dict[str, Any]) -> bool: + return cell.get("type") == "dataSource" + + +def _index_data_sources(cells: List[Dict[str, Any]]) -> Tuple[Dict[CellKey, Dict[str, Any]], Dict[str, List[Dict[str, Any]]]]: + by_pos: Dict[CellKey, Dict[str, Any]] = {} + by_sheet: Dict[str, List[Dict[str, Any]]] = {} + for c in cells: + if not _is_data_source(c): + continue + k = cell_key(c) + by_pos[k] = c + by_sheet.setdefault(k[0], []).append(c) + return by_pos, by_sheet + + +def resolve_parent( + cell: Dict[str, Any], + *, + is_left: bool, + by_pos: Dict[CellKey, Dict[str, Any]], + by_sheet: Dict[str, List[Dict[str, Any]]], +) -> Optional[Dict[str, Any]]: + """返回父格 dataSource 元数据;none 为 None,default 为最近左/上数据源格。""" + custom = cell.get("custom") or {} + ptype = ( + custom.get("leftParentCellType") if is_left else custom.get("topParentCellType") + ) or "default" + sheet, row, col = cell_key(cell) + + if ptype == "none": + return None + + if ptype == "custom": + if is_left: + letters = custom.get("leftParentCellCustomRowName") or "A" + row_num = custom.get("leftParentCellCustomColName") + else: + letters = custom.get("topParentCellCustomRowName") or "A" + row_num = custom.get("topParentCellCustomColName") + if row_num is None: + return None + try: + parent_row = int(row_num) - 1 + except (TypeError, ValueError): + return None + parent_col = col_letter_to_index(str(letters)) + return by_pos.get((sheet, parent_row, parent_col)) + + # default:同行向左 / 同列向上找最近 dataSource + candidates = by_sheet.get(sheet) or [] + best: Optional[Dict[str, Any]] = None + if is_left: + for c in candidates: + cr, cc = _int_coord(c.get("row")), _int_coord(c.get("col")) + if cr == row and cc < col: + if best is None or _int_coord(best.get("col")) < cc: + best = c + else: + for c in candidates: + cr, cc = _int_coord(c.get("row")), _int_coord(c.get("col")) + if cc == col and cr < row: + if best is None or _int_coord(best.get("row")) < cr: + best = c + return best + + +def resolve_parents( + cell: Dict[str, Any], + by_pos: Dict[CellKey, Dict[str, Any]], + by_sheet: Dict[str, List[Dict[str, Any]]], +) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: + left = resolve_parent(cell, is_left=True, by_pos=by_pos, by_sheet=by_sheet) + top = resolve_parent(cell, is_left=False, by_pos=by_pos, by_sheet=by_sheet) + custom = cell.get("custom") or {} + ds_name = _dataset_name(cell) + # 汇总格且无扩展时,JNPF 在 none+none 时清除父格 + poly = str(custom.get("polymerizationType") or "1") + if poly == "3" and custom.get("leftParentCellType") == "none" and custom.get("topParentCellType") == "none": + return None, None + if left and _dataset_name(left) != ds_name: + left = None + if top and _dataset_name(top) != ds_name: + top = None + return left, top + + +def _dataset_name(cell: Dict[str, Any]) -> str: + custom = cell.get("custom") or {} + name = str( + custom.get("dataSetName") or custom.get("dataSet") or custom.get("alias") or "" + ) + if name: + return name + field = str(custom.get("field") or custom.get("bindField") or "") + if "." in field: + return field.split(".", 1)[0] + return "" + + +def filter_rows_by_parents( + rows: List[Any], + cell: Dict[str, Any], + *, + left_parent: Optional[Dict[str, Any]], + top_parent: Optional[Dict[str, Any]], + left_bind: Optional[List[Dict[str, Any]]], + top_bind: Optional[List[Dict[str, Any]]], +) -> List[Dict[str, Any]]: + """对齐 JNPF DataUtils.fetchData:按父格 bindData 切片过滤。""" + data = [r if isinstance(r, dict) else {} for r in rows] + if not left_bind and not top_bind: + return data + + left_rows = left_bind + top_rows = top_bind + if left_rows is None and top_rows is not None: + return top_rows + if top_rows is None and left_rows is not None: + return left_rows + if left_rows is None or top_rows is None: + return data + + left_field = _bind_field(left_parent) if left_parent else "" + top_field = _bind_field(top_parent) if top_parent else "" + left_val = _first_field_value(left_rows, left_field) if left_rows else None + top_val = _first_field_value(top_rows, top_field) if top_rows else None + + from_top: List[Dict[str, Any]] = [] + for row in top_rows: + if left_field and _get_nested_value(row, left_field) == left_val: + from_top.append(row) + from_left: List[Dict[str, Any]] = [] + for row in left_rows: + if top_field and _get_nested_value(row, top_field) == top_val: + from_left.append(row) + return from_top if len(from_top) <= len(from_left) else from_left + + +def _bind_field(parent: Optional[Dict[str, Any]]) -> str: + if not parent: + return "" + custom = parent.get("custom") or {} + field = custom.get("field") or custom.get("bindField") or "" + if "." in field: + return field.split(".", 1)[1] + return field + + +def resolve_field_path(field: str, dataset_alias: str = "") -> str: + """JNPF 字段常为 alias.prop,行数据一般为扁平 prop 或嵌套 prop。""" + field = str(field or "") + alias = str(dataset_alias or "") + if alias and field.startswith(f"{alias}."): + return field[len(alias) + 1 :] + return field + + +def _get_nested_value(row: Dict[str, Any], field: str) -> Any: + if not field: + return None + if field in row: + return row[field] + parts = field.split(".") + cur: Any = row + for p in parts: + if isinstance(cur, dict) and p in cur: + cur = cur[p] + else: + return None + return cur + + +def _first_field_value(rows: List[Dict[str, Any]], field: str) -> Any: + if not rows or not field: + return None + return _get_nested_value(rows[0], field) diff --git a/backend-fastapi/online_dev/report_manager/engine/polymerize.py b/backend-fastapi/online_dev/report_manager/engine/polymerize.py new file mode 100644 index 0000000..20b7a5c --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/polymerize.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +数据源聚合(polymerizationType) +1 / select — 列表 +2 / group — 分组 +3 / summary — 汇总(summaryType: sum|avg|max|min|count) +""" +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from typing import Any, Dict, List, Optional + +from online_dev.report_manager.engine.parent_cells import _get_nested_value, resolve_field_path + + +@dataclass +class BindData: + value: Any + data_list: List[Dict[str, Any]] + + +def _field_name(cell: Dict[str, Any]) -> str: + custom = cell.get("custom") or {} + return str(custom.get("field") or custom.get("bindField") or "") + + +def _poly_type(cell: Dict[str, Any]) -> str: + custom = cell.get("custom") or {} + raw = custom.get("polymerizationType") + if raw is None: + return "1" + return str(raw) + + +def _summary_type(cell: Dict[str, Any]) -> str: + custom = cell.get("custom") or {} + return str(custom.get("summaryType") or "sum").lower() + + +def _group_type(cell: Dict[str, Any]) -> str: + custom = cell.get("custom") or {} + return str(custom.get("groupType") or "default") + + +def _dataset_name(cell: Dict[str, Any]) -> str: + custom = cell.get("custom") or {} + name = str( + custom.get("dataSetName") or custom.get("dataSet") or custom.get("alias") or "" + ) + if name: + return name + field = str(custom.get("field") or custom.get("bindField") or "") + if "." in field: + return field.split(".", 1)[0] + return "" + + +def build_bind_list(cell: Dict[str, Any], rows: List[Dict[str, Any]]) -> List[BindData]: + poly = _poly_type(cell) + field = resolve_field_path(_field_name(cell), _dataset_name(cell)) + prop = field.split(".")[-1] if "." in field else field + + if poly == "3": + return [_summary_bind(cell, rows, field)] + + if poly == "2": + return _group_bind(rows, prop, _group_type(cell)) + + return _list_bind(rows, field) + + +def _list_bind(rows: List[Dict[str, Any]], field: str) -> List[BindData]: + out: List[BindData] = [] + for row in rows: + val = _get_nested_value(row, field) + out.append(BindData(value=val, data_list=[row])) + if not out: + out.append(BindData(value="", data_list=[{}])) + return out + + +def _group_bind( + rows: List[Dict[str, Any]], prop: str, group_type: str +) -> List[BindData]: + if group_type == "adjacent": + return _group_adjacent(rows, prop) + ordered: Dict[Any, List[Dict[str, Any]]] = {} + for row in rows: + key = _get_nested_value(row, prop) + if key is None: + key = "" + ordered.setdefault(key, []).append(row) + return [ + BindData(value=k, data_list=ordered[k]) for k in ordered.keys() + ] + + +def _group_adjacent(rows: List[Dict[str, Any]], prop: str) -> List[BindData]: + out: List[BindData] = [] + bucket: List[Dict[str, Any]] = [] + last_key: Any = object() + for row in rows: + key = _get_nested_value(row, prop) + if key is None: + key = "" + if bucket and key != last_key: + out.append(BindData(value=last_key, data_list=bucket)) + bucket = [] + bucket.append(row) + last_key = key + if bucket: + out.append(BindData(value=last_key, data_list=bucket)) + if not out: + out.append(BindData(value="", data_list=[{}])) + return out + + +def _summary_bind(cell: Dict[str, Any], rows: List[Dict[str, Any]], field: str) -> BindData: + st = _summary_type(cell) + nums: List[Decimal] = [] + for row in rows: + v = _get_nested_value(row, field) + try: + nums.append(Decimal(str(v))) + except Exception: + pass + if st == "count": + val: Any = len(rows) + elif st == "avg": + val = float(sum(nums) / len(nums)) if nums else 0 + elif st == "max": + val = float(max(nums)) if nums else 0 + elif st == "min": + val = float(min(nums)) if nums else 0 + else: + val = float(sum(nums)) if nums else 0 + return BindData(value=val, data_list=rows) + + +def expand_span(bind: BindData, cell: Dict[str, Any]) -> int: + """该 bind 在向下/向右扩展时占用的行/列数。""" + poly = _poly_type(cell) + if poly == "3": + return 1 + if poly == "2": + return 1 + return max(1, len(bind.data_list)) diff --git a/backend-fastapi/online_dev/report_manager/engine/preview_guard.py b/backend-fastapi/online_dev/report_manager/engine/preview_guard.py new file mode 100644 index 0000000..6bd6332 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/preview_guard.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""预览/导出非功能性护栏(阶段 G)""" +from __future__ import annotations + +from typing import Any, Dict, List + +# 与 dataset_bridge.fetch_all max_rows 对齐 +DEFAULT_MAX_DATASET_ROWS = 50000 +WARN_DATASET_ROWS = 10000 +WARN_SNAPSHOT_CELL_COUNT = 200_000 + + +def estimate_snapshot_cell_count(snapshot: Dict[str, Any]) -> int: + total = 0 + for sheet in (snapshot.get("sheets") or {}).values(): + if not isinstance(sheet, dict): + continue + for row in (sheet.get("cellData") or {}).values(): + if isinstance(row, dict): + total += len(row) + return total + + +def collect_preview_warnings( + *, + datasets: Dict[str, List[Any]], + snapshot: Dict[str, Any], + max_rows: int = DEFAULT_MAX_DATASET_ROWS, + warn_rows: int = WARN_DATASET_ROWS, +) -> List[str]: + """返回 warning 码列表,供前端 i18n 映射。""" + warnings: List[str] = [] + for alias, rows in (datasets or {}).items(): + count = len(rows) if isinstance(rows, list) else 0 + if count >= max_rows: + warnings.append(f"dataset_row_limit:{alias}") + elif count >= warn_rows: + warnings.append(f"dataset_row_warn:{alias}") + cell_count = estimate_snapshot_cell_count(snapshot or {}) + if cell_count >= WARN_SNAPSHOT_CELL_COUNT: + warnings.append("snapshot_large") + return warnings diff --git a/backend-fastapi/online_dev/report_manager/engine/preview_mvp.py b/backend-fastapi/online_dev/report_manager/engine/preview_mvp.py new file mode 100644 index 0000000..9216f23 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/preview_mvp.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +预览 MVP:参数单元格替换(Phase 2) +完整 dataSource 扩展在 Phase 3 convert 引擎实现 +""" +import copy +import re +from typing import Any, Dict, List + + +_PARAM_PATTERN = re.compile(r"#\{([^}]+)\}") + + +def _replace_params_in_value(value: Any, params: Dict[str, Any]) -> Any: + if not isinstance(value, str): + return value + def repl(m): + key = m.group(1).strip() + if key in params: + return str(params[key]) + return m.group(0) + return _PARAM_PATTERN.sub(repl, value) + + +def apply_parameter_cells( + snapshot: Dict[str, Any], + cells_meta: Dict[str, Any], + params: Dict[str, Any], +) -> Dict[str, Any]: + """将 parameter 类型绑定写入 snapshot(MVP)""" + result = copy.deepcopy(snapshot) + if not result or not cells_meta: + return result + + cell_list = cells_meta.get("cells") or [] + sheets = result.get("sheets") or {} + + for cell in cell_list: + if cell.get("type") != "parameter": + continue + sheet_id = cell.get("sheet") + row = cell.get("row", 0) + col = cell.get("col", 0) + custom = cell.get("custom") or {} + text = custom.get("value") or custom.get("text") or "" + if isinstance(text, str): + text = _replace_params_in_value(text, params) + sheet = sheets.get(sheet_id) + if not sheet: + continue + cell_data = sheet.setdefault("cellData", {}) + row_data = cell_data.setdefault(str(row), {}) + cell_obj = row_data.setdefault(str(col), {}) + cell_obj["v"] = text + if "custom" in cell_obj: + cell_obj["custom"] = {**cell_obj.get("custom", {}), "value": text} + + return result + + +def apply_snapshot_placeholders( + snapshot: Dict[str, Any], + params: Dict[str, Any], +) -> Dict[str, Any]: + """扫描 snapshot 所有单元格,将 v / custom 字符串中的 #{param} 替换为查询参数""" + if not snapshot or not params: + return snapshot or {} + result = copy.deepcopy(snapshot) + sheets = result.get("sheets") or {} + for sheet in sheets.values(): + if not isinstance(sheet, dict): + continue + cell_data = sheet.get("cellData") or {} + for row in cell_data.values(): + if not isinstance(row, dict): + continue + for cell in row.values(): + if not isinstance(cell, dict): + continue + v = cell.get("v") + if isinstance(v, str) and "#{" in v: + cell["v"] = _replace_params_in_value(v, params) + custom = cell.get("custom") + if isinstance(custom, dict): + for key, val in list(custom.items()): + if isinstance(val, str) and "#{" in val: + custom[key] = _replace_params_in_value(val, params) + cell["custom"] = custom + result["sheets"] = sheets + return result diff --git a/backend-fastapi/online_dev/report_manager/engine/sort_apply.py b/backend-fastapi/online_dev/report_manager/engine/sort_apply.py new file mode 100644 index 0000000..e998391 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/sort_apply.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""数据集排序规则(对齐 JNPF sortList)""" +from typing import Any, Dict, List, Optional + + +def _field_name_from_vmodel(vmodel: str) -> str: + if not vmodel: + return "" + parts = vmodel.split(".", 1) + return parts[1] if len(parts) == 2 else vmodel + + +def get_sort_rules_for_alias(sort_list: List[Any], alias: str) -> List[Dict[str, Any]]: + rules: List[Dict[str, Any]] = [] + for sheet_cfg in sort_list or []: + if not isinstance(sheet_cfg, dict): + continue + for item in sheet_cfg.get("sortList") or []: + if not isinstance(item, dict): + continue + vmodel = item.get("vModel") or item.get("field") or "" + ds_prefix = vmodel.split(".")[0] if "." in vmodel else "" + if ds_prefix == alias or vmodel.startswith(f"{alias}."): + rules.append(item) + return rules + + +def apply_sort_to_rows( + rows: List[Dict[str, Any]], + sort_rules: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + if not rows or not sort_rules: + return rows + result = list(rows) + for rule in sort_rules: + field = _field_name_from_vmodel(rule.get("vModel") or rule.get("field") or "") + if not field: + continue + reverse = (rule.get("type") or "asc").lower() == "desc" + + def key_fn(row: Dict[str, Any], f: str = field) -> Any: + val = row.get(f) + return (val is None, val) + + result.sort(key=key_fn, reverse=reverse) + return result diff --git a/backend-fastapi/online_dev/report_manager/engine/test_chart_data.py b/backend-fastapi/online_dev/report_manager/engine/test_chart_data.py new file mode 100644 index 0000000..38c8bc7 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/test_chart_data.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from online_dev.report_manager.engine.chart_data import build_chart_data + + +def test_bar_chart_from_float_echarts(): + cells = { + "floatEcharts": { + "draw1": { + "drawingId": "draw1", + "echartType": "bar", + "option": { + "classifyNameField": "sales.month", + "seriesNameField": "sales.region", + "seriesDataField": "sales.amount", + "summaryType": "sum", + }, + }, + }, + } + datasets = { + "sales": [ + {"month": "Jan", "region": "A", "amount": 10}, + {"month": "Jan", "region": "B", "amount": 20}, + {"month": "Feb", "region": "A", "amount": 15}, + ], + } + chart_data = build_chart_data(cells, datasets) + assert len(chart_data) == 1 + field = chart_data[0]["field"] + assert "Jan" in field["classifyNameField"] + assert field["seriesNameField"] + assert field["seriesDataField"] diff --git a/backend-fastapi/online_dev/report_manager/engine/test_code_cells.py b/backend-fastapi/online_dev/report_manager/engine/test_code_cells.py new file mode 100644 index 0000000..af14334 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/test_code_cells.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from online_dev.report_manager.engine.convert import transform + + +def test_qrcode_param_replace(): + snapshot = { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + "0": { + "0": { + "v": "placeholder", + "custom": { + "type": "qrCode", + "field": "#{orderNo}", + "qrCodeOption": {"type": "static"}, + }, + } + } + }, + } + } + } + filled = transform(snapshot, {"cells": []}, {}, {"orderNo": "ORD-001"}) + cell = filled["sheets"]["sheet1"]["cellData"]["0"]["0"] + assert cell["v"] == "ORD-001" + assert cell["custom"]["field"] == "ORD-001" diff --git a/backend-fastapi/online_dev/report_manager/engine/test_column_layout.py b/backend-fastapi/online_dev/report_manager/engine/test_column_layout.py new file mode 100644 index 0000000..7a0d023 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/test_column_layout.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from online_dev.report_manager.engine.column_layout import apply_column_layout, parse_cell_range + + +def test_parse_range(): + assert parse_cell_range("A2:D10") == (1, 9, 0, 3) + + +def test_col_split_two_columns(): + snapshot = { + "sheetOrder": ["sheet1"], + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + "1": {"0": {"v": "a"}}, + "2": {"0": {"v": "b"}}, + "3": {"0": {"v": "c"}}, + "4": {"0": {"v": "d"}}, + }, + } + }, + } + layout = [ + { + "sheet": "sheet1", + "columnList": { + "columnState": True, + "columnStyle": "col", + "columnType": "2", + "rowCount": 2, + "columnData": "A2:A5", + }, + } + ] + out = apply_column_layout(snapshot, layout) + cells = out["sheets"]["sheet1"]["cellData"] + assert cells["1"]["0"]["v"] == "a" + assert cells["2"]["0"]["v"] == "b" + assert cells["1"]["1"]["v"] == "c" + assert cells["2"]["1"]["v"] == "d" + + +def test_row_split_two_rows(): + """A2:C2 三列横向,分栏成 2 行块:上行 A,B 下行 C""" + snapshot = { + "sheetOrder": ["sheet1"], + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + "1": { + "0": {"v": "A"}, + "1": {"v": "B"}, + "2": {"v": "C"}, + } + }, + } + }, + } + layout = [ + { + "sheet": "sheet1", + "columnList": { + "columnState": True, + "columnStyle": "row", + "columnType": "2", + "colCount": 2, + "columnData": "A2:C2", + }, + } + ] + out = apply_column_layout(snapshot, layout) + cells = out["sheets"]["sheet1"]["cellData"] + assert cells["1"]["0"]["v"] == "A" + assert cells["1"]["1"]["v"] == "B" + assert cells["2"]["2"]["v"] == "C" + + +def test_col_split_type1_max_col(): + """5 行数据,超过 2 行分列 -> 3 栏""" + snapshot = { + "sheetOrder": ["sheet1"], + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + str(i): {"0": {"v": str(i)}} for i in range(1, 6) + }, + } + }, + } + layout = [ + { + "sheet": "sheet1", + "columnList": { + "columnState": True, + "columnStyle": "col", + "columnType": "1", + "maxCol": 2, + "columnData": "A1:A5", + }, + } + ] + out = apply_column_layout(snapshot, layout) + cells = out["sheets"]["sheet1"]["cellData"] + assert cells["1"]["0"]["v"] == "1" + assert cells["2"]["0"]["v"] == "2" + assert cells["1"]["1"]["v"] == "3" + assert cells["2"]["1"]["v"] == "4" + assert cells["1"]["2"]["v"] == "5" + + +def test_col_split_fill_empty_rows(): + """3 行分 2 栏,第二栏仅 1 行数据时补空行""" + snapshot = { + "sheetOrder": ["sheet1"], + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + "1": {"0": {"v": "a"}}, + "2": {"0": {"v": "b"}}, + "3": {"0": {"v": "c"}}, + }, + } + }, + } + layout = [ + { + "sheet": "sheet1", + "columnList": { + "columnState": True, + "columnStyle": "col", + "columnType": "2", + "rowCount": 2, + "columnData": "A1:A3", + "fillEmptyRows": True, + }, + } + ] + out = apply_column_layout(snapshot, layout) + cells = out["sheets"]["sheet1"]["cellData"] + assert cells["1"]["0"]["v"] == "a" + assert cells["2"]["0"]["v"] == "b" + assert cells["1"]["1"]["v"] == "c" + assert cells["2"]["1"]["v"] == "" diff --git a/backend-fastapi/online_dev/report_manager/engine/test_convert.py b/backend-fastapi/online_dev/report_manager/engine/test_convert.py new file mode 100644 index 0000000..fbfc50a --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/test_convert.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""convert 引擎单元测试(可直接 python -m 运行)""" +from online_dev.report_manager.engine.convert import transform + + +def test_parameter_and_list_down(): + snapshot = { + "sheetOrder": ["sheet1"], + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": {"0": {"0": {"v": "Title"}}}, + }, + }, + } + cells = { + "cells": [ + { + "type": "parameter", + "sheet": "sheet1", + "row": 0, + "col": 1, + "custom": {"value": "#{userName}"}, + }, + { + "type": "dataSource", + "sheet": "sheet1", + "row": 1, + "col": 0, + "custom": { + "dataSetName": "users", + "field": "name", + "expand": "down", + }, + }, + ], + } + datasets = { + "users": [{"name": "Alice"}, {"name": "Bob"}], + } + result = transform(snapshot, cells, datasets, {"userName": "Admin"}) + cd = result["sheets"]["sheet1"]["cellData"] + assert cd["0"]["1"]["v"] == "Admin" + assert cd["1"]["0"]["v"] == "Alice" + assert cd["2"]["0"]["v"] == "Bob" + print("ok") + + +def test_list_right_expand(): + snapshot = { + "sheetOrder": ["sheet1"], + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": {"0": {"0": {"v": "H"}}}, + }, + }, + } + cells = { + "cells": [ + { + "type": "dataSource", + "sheet": "sheet1", + "row": 0, + "col": 1, + "custom": { + "dataSetName": "items", + "field": "name", + "expand": "right", + }, + }, + ], + } + datasets = {"items": [{"name": "A"}, {"name": "B"}]} + result = transform(snapshot, cells, datasets, {}) + cd = result["sheets"]["sheet1"]["cellData"] + assert cd["0"]["1"]["v"] == "A" + assert cd["0"]["2"]["v"] == "B" + print("right ok") + + +def test_fill_empty_rows_after_list_down(): + snapshot = { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": {"1": {"0": {"v": ""}}}, + } + } + } + cells = { + "cells": [ + { + "type": "dataSource", + "sheet": "sheet1", + "row": 1, + "col": 0, + "custom": { + "dataSetName": "items", + "field": "name", + "expand": "down", + "fillEmptyRows": True, + "fillEmptyNum": 1, + }, + } + ] + } + result = transform( + snapshot, cells, {"items": [{"name": "X"}]}, {} + ) + cd = result["sheets"]["sheet1"]["cellData"] + assert cd["1"]["0"]["v"] == "X" + assert cd["2"]["0"]["v"] == "" + + +if __name__ == "__main__": + test_parameter_and_list_down() + test_list_right_expand() diff --git a/backend-fastapi/online_dev/report_manager/engine/test_dataset_transform.py b/backend-fastapi/online_dev/report_manager/engine/test_dataset_transform.py new file mode 100644 index 0000000..59a6a42 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/test_dataset_transform.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from online_dev.report_manager.engine.dataset_transform import ( + apply_convert_rules, + apply_field_mapping, + transform_dataset_rows, +) + + +def test_field_mapping_rename(): + rows = [{"product_code": "A", "qty": 1}] + out = apply_field_mapping(rows, {"product_code": "product"}) + assert out[0]["product"] == "A" + assert "product_code" not in out[0] + + +def test_convert_select(): + rows = [{"status": 1}] + rules = [ + { + "field": "order.status", + "type": "select", + "config": { + "options": [ + {"id": 1, "fullName": "启用"}, + {"id": 2, "fullName": "停用"}, + ] + }, + } + ] + out = apply_convert_rules(rows, rules, alias="order") + assert out[0]["status"] == "启用" + + +def test_transform_chain(): + rows = [{"code": "X", "state": "1"}] + out = transform_dataset_rows( + rows, + field_mapping={"code": "product"}, + dataset_convert=[ + { + "field": "state", + "type": "select", + "config": {"options": [{"id": "1", "fullName": "OK"}]}, + } + ], + ) + assert out[0]["product"] == "X" + assert out[0]["state"] == "OK" + + +def test_convert_select_respects_alias(): + rows = [{"status": 1}] + rules = [ + { + "field": "order.status", + "type": "select", + "config": {"options": [{"id": 1, "fullName": "启用"}]}, + } + ] + out = apply_convert_rules(rows, rules, alias="other") + assert out[0]["status"] == 1 + + +def test_convert_user_inline(): + from online_dev.report_manager.engine.convert_lookup import ConvertLookupCache + + rows = [{"ownerId": "u1"}] + cache = ConvertLookupCache() + out = apply_convert_rules( + rows, + [{"field": "ownerId", "type": "user", "config": {"names": {"u1": "张三"}}}], + lookup=cache, + ) + assert out[0]["ownerId"] == "张三" + + +if __name__ == "__main__": + test_field_mapping_rename() + test_convert_select() + test_convert_user_inline() + test_convert_select_respects_alias() + test_transform_chain() + print("ok") diff --git a/backend-fastapi/online_dev/report_manager/engine/test_export_excel.py b/backend-fastapi/online_dev/report_manager/engine/test_export_excel.py new file mode 100644 index 0000000..a7d610f --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/test_export_excel.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import base64 +import importlib.util + +from online_dev.report_manager.engine.export_excel import snapshot_to_xlsx_bytes + +_TINY_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" +) +_TINY_PNG_B64 = ( + "data:image/png;base64," + + base64.b64encode(_TINY_PNG).decode("ascii") +) + + +def test_export_produces_xlsx(): + snapshot = { + "styles": [{"fs": 14, "bl": 1, "bg": {"rgb": "#FFFF00"}, "ht": 2}], + "sheetOrder": ["s1"], + "sheets": { + "s1": { + "name": "Report", + "cellData": { + "0": { + "0": {"v": "Title", "s": 0}, + "1": {"v": "ignored"}, + }, + "1": {"0": {"v": 100, "t": 2}}, + }, + "mergeData": [ + {"startRow": 0, "endRow": 0, "startColumn": 0, "endColumn": 1}, + ], + "rowData": {"0": {"h": 30}}, + "columnData": {"0": {"w": 140}}, + } + }, + } + raw = snapshot_to_xlsx_bytes(snapshot, watermark_text="机密") + assert raw[:2] == b"PK" + assert len(raw) > 200 + + if importlib.util.find_spec("openpyxl") is None: + return + + import io + + from openpyxl import load_workbook + + wb = load_workbook(io.BytesIO(raw)) + ws = wb["Report"] + assert ws["A1"].value == "Title" + assert ws["A2"].value == 100 + assert ws["A1"].font.bold is True + merged = list(ws.merged_cells.ranges) + assert len(merged) == 1 + assert str(merged[0]) == "A1:B1" + assert ws.oddHeader.center.text == "机密" + + +def test_export_conditional_formatting_and_images(): + if importlib.util.find_spec("openpyxl") is None: + return + if importlib.util.find_spec("PIL") is None: + return + + import io + + from openpyxl import load_workbook + + snapshot = { + "sheetOrder": ["s1"], + "resources": [ + { + "name": "SHEET_CONDITIONAL_FORMATTING_PLUGIN", + "data": ( + '{"s1":[{"ranges":[{"startRow":0,"endRow":2,"startColumn":0,"endColumn":0}],' + '"rule":{"type":"highlight","operator":"greaterThan","value":50,' + '"style":{"bg":{"rgb":"#FFCCCC"}}},"stopIfTrue":false},' + '{"ranges":[{"startRow":0,"endRow":2,"startColumn":1,"endColumn":1}],' + '"rule":{"type":"colorScale","config":[' + '{"value":{"type":"min"},"color":"#FF0000"},' + '{"value":{"type":"max"},"color":"#00FF00"}' + ']},"stopIfTrue":false}]}' + ), + }, + { + "name": "SHEET_DRAWING_PLUGIN", + "data": ( + '{"s1":{"order":["img1"],"data":{"img1":{' + '"source":"' + + _TINY_PNG_B64 + + '","imageSourceType":"BASE64",' + '"sheetTransform":{"from":{"row":0,"column":2},' + '"to":{"row":4,"column":4}}}}}}' + ), + }, + ], + "sheets": { + "s1": { + "name": "CF", + "cellData": { + "0": { + "0": {"v": 80, "t": 2}, + "1": {"v": 20, "t": 2}, + "3": { + "v": "", + "p": { + "drawings": { + "cellImg": { + "source": _TINY_PNG_B64, + "imageSourceType": "BASE64", + } + } + }, + }, + } + }, + } + }, + } + + raw = snapshot_to_xlsx_bytes(snapshot) + wb = load_workbook(io.BytesIO(raw)) + ws = wb["CF"] + assert len(ws.conditional_formatting._cf_rules) >= 2 + assert len(ws._images) >= 2 + + +def test_export_hyperlinks_and_advanced_conditional_formatting(): + if importlib.util.find_spec("openpyxl") is None: + return + + import io + import json + + from openpyxl import load_workbook + + cf_entries = [ + { + "ranges": [{"startRow": 0, "endRow": 5, "startColumn": 0, "endColumn": 0}], + "rule": { + "type": "highlight", + "subType": "top10", + "value": 3, + "style": {"bg": {"rgb": "#FFCCCC"}}, + }, + "stopIfTrue": False, + }, + { + "ranges": [{"startRow": 0, "endRow": 5, "startColumn": 1, "endColumn": 1}], + "rule": { + "type": "highlight", + "subType": "aboveAverage", + "operator": "greaterThan", + "style": {"bg": {"rgb": "#CCCCFF"}}, + }, + "stopIfTrue": False, + }, + { + "ranges": [{"startRow": 0, "endRow": 5, "startColumn": 2, "endColumn": 2}], + "rule": { + "type": "highlight", + "subType": "timePeriod", + "operator": "today", + "style": {"bg": {"rgb": "#CCFFCC"}}, + }, + "stopIfTrue": False, + }, + ] + + snapshot = { + "sheetOrder": ["s1", "s2"], + "resources": [ + { + "name": "SHEET_DEFINED_NAME_PLUGIN", + "data": json.dumps( + { + "range1": { + "name": "MyRange", + "formulaOrRefString": "=s2!$A$1", + } + } + ), + }, + { + "name": "SHEET_CONDITIONAL_FORMATTING_PLUGIN", + "data": json.dumps({"s1": cf_entries}), + }, + ], + "sheets": { + "s1": { + "name": "Links", + "cellData": { + "0": { + "0": { + "p": { + "body": { + "dataStream": "Open Example", + "customRanges": [ + { + "properties": { + "url": "https://example.com", + } + } + ], + } + } + }, + "1": { + "p": { + "body": { + "dataStream": "Go Sheet2", + "customRanges": [ + { + "properties": { + "url": "#gid=s2&range=A1", + } + } + ], + } + } + }, + } + }, + }, + "s2": { + "name": "Target", + "cellData": {"0": {"0": {"v": "Target Cell"}}}, + }, + }, + } + + raw = snapshot_to_xlsx_bytes(snapshot) + wb = load_workbook(io.BytesIO(raw)) + ws = wb["Links"] + assert ws["A1"].hyperlink is not None + assert ws["A1"].hyperlink.target == "https://example.com" + assert ws["B1"].hyperlink is not None + assert ws["B1"].hyperlink.location == "'Target'!A1" + assert len(ws.conditional_formatting._cf_rules) >= 3 + + +if __name__ == "__main__": + test_export_produces_xlsx() + test_export_conditional_formatting_and_images() + test_export_hyperlinks_and_advanced_conditional_formatting() + print("ok") diff --git a/backend-fastapi/online_dev/report_manager/engine/test_expression_cycles.py b/backend-fastapi/online_dev/report_manager/engine/test_expression_cycles.py new file mode 100644 index 0000000..5cea16c --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/test_expression_cycles.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from online_dev.report_manager.engine.expression_eval import detect_expression_cycles + + +def test_no_cycle(): + cells = { + "cells": [ + {"type": "expression", "sheet": "s1", "row": 0, "col": 1, "custom": {"field": "=A1+1"}}, + ] + } + assert detect_expression_cycles(cells) == [] + + +def test_cycle_a_b(): + cells = { + "cells": [ + {"type": "expression", "sheet": "s1", "row": 0, "col": 0, "custom": {"field": "=B1+1"}}, + {"type": "expression", "sheet": "s1", "row": 0, "col": 1, "custom": {"field": "=A1+1"}}, + ] + } + assert detect_expression_cycles(cells) == ["expression_cycle"] diff --git a/backend-fastapi/online_dev/report_manager/engine/test_expression_eval.py b/backend-fastapi/online_dev/report_manager/engine/test_expression_eval.py new file mode 100644 index 0000000..12ffb1b --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/test_expression_eval.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from online_dev.report_manager.engine.convert import transform + + +def test_sum_expression(): + snapshot = { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": {"0": {"0": {"v": ""}}}, + } + } + } + cells = { + "cells": [ + { + "type": "expression", + "sheet": "sheet1", + "row": 0, + "col": 0, + "custom": {"field": "=sum(sales.amount)+10"}, + } + ] + } + datasets = { + "sales": [{"amount": 100}, {"amount": 200}], + } + filled = transform(snapshot, cells, datasets, {}) + assert filled["sheets"]["sheet1"]["cellData"]["0"]["0"]["v"] == "310" + + +def test_expression_with_param(): + snapshot = { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": {"1": {"1": {"v": ""}}}, + } + } + } + cells = { + "cells": [ + { + "type": "expression", + "sheet": "sheet1", + "row": 1, + "col": 1, + "custom": {"field": "=#{bonus}+sum(items.qty)"}, + } + ] + } + datasets = {"items": [{"qty": 5}, {"qty": 3}]} + filled = transform(snapshot, cells, datasets, {"bonus": 2}) + assert filled["sheets"]["sheet1"]["cellData"]["1"]["1"]["v"] == "10" + + +def test_cell_ref_addition(): + snapshot = { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + "0": {"0": {"v": "10"}, "1": {"v": "20"}}, + "2": {"0": {"v": ""}}, + }, + } + } + } + cells = { + "cells": [ + { + "type": "expression", + "sheet": "sheet1", + "row": 2, + "col": 0, + "custom": {"field": "=A1+B2"}, + } + ] + } + filled = transform(snapshot, cells, {}, {}) + assert filled["sheets"]["sheet1"]["cellData"]["2"]["0"]["v"] == "30" + + +def test_sum_cell_range(): + snapshot = { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + "0": {"0": {"v": 5}, "1": {"v": 15}}, + "1": {"0": {"v": ""}}, + }, + } + } + } + cells = { + "cells": [ + { + "type": "expression", + "sheet": "sheet1", + "row": 1, + "col": 0, + "custom": {"field": "=sum(A1:B1)+1"}, + } + ] + } + filled = transform(snapshot, cells, {}, {}) + assert filled["sheets"]["sheet1"]["cellData"]["1"]["0"]["v"] == "21" + + +def test_expression_chain_b1_c1(): + """B1=A1+1, C1=B1+1:表达式间依赖需拓扑/多轮求值""" + snapshot = { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + "0": { + "0": {"v": "100"}, + "1": {"v": ""}, + "2": {"v": ""}, + } + }, + } + } + } + cells = { + "cells": [ + { + "type": "expression", + "sheet": "sheet1", + "row": 0, + "col": 1, + "custom": {"field": "=A1+1"}, + }, + { + "type": "expression", + "sheet": "sheet1", + "row": 0, + "col": 2, + "custom": {"field": "=B1+1"}, + }, + ] + } + filled = transform(snapshot, cells, {}, {}) + assert filled["sheets"]["sheet1"]["cellData"]["0"]["1"]["v"] == "101" + assert filled["sheets"]["sheet1"]["cellData"]["0"]["2"]["v"] == "102" + + +def test_expression_chain_reverse_meta_order(): + """cells 元数据顺序为 C1 先于 B1 时仍应正确求值""" + snapshot = { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + "0": {"0": {"v": 10}, "1": {"v": ""}, "2": {"v": ""}}, + }, + } + } + } + cells = { + "cells": [ + { + "type": "expression", + "sheet": "sheet1", + "row": 0, + "col": 2, + "custom": {"formula": "=B1*2"}, + }, + { + "type": "expression", + "sheet": "sheet1", + "row": 0, + "col": 1, + "custom": {"formula": "=A1+5"}, + }, + ] + } + filled = transform(snapshot, cells, {}, {}) + assert filled["sheets"]["sheet1"]["cellData"]["0"]["1"]["v"] == "15" + assert filled["sheets"]["sheet1"]["cellData"]["0"]["2"]["v"] == "30" diff --git a/backend-fastapi/online_dev/report_manager/engine/test_golden.py b/backend-fastapi/online_dev/report_manager/engine/test_golden.py new file mode 100644 index 0000000..e150cc0 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/test_golden.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Golden Test:用 JSON fixture 回归报表 transform 流水线关键输出。 +后续可追加 JNPF 导出的样例 fixture 做对比。 +""" +from __future__ import annotations + +import json +import math +from pathlib import Path +from typing import Any, Dict, List + +from online_dev.report_manager.engine.convert import transform +from online_dev.report_manager.engine.dataset_transform import transform_dataset_rows + +_FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +def _load_fixture(name: str) -> dict: + path = _FIXTURES_DIR / name + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _cell_v(snapshot: dict, sheet_id: str, row: int, col: int) -> str: + sheet = (snapshot.get("sheets") or {}).get(sheet_id) or {} + cell = (sheet.get("cellData") or {}).get(str(row), {}).get(str(col), {}) + v = cell.get("v") + return "" if v is None else str(v) + + +def _prepare_datasets(fixture: dict) -> Dict[str, List[Any]]: + raw = fixture.get("datasets") or {} + field_mapping_root = fixture.get("field_mapping") or {} + version_convert = fixture.get("convert_config") + out: Dict[str, List[Any]] = {} + for alias, rows in raw.items(): + mapping = field_mapping_root.get(alias) if isinstance(field_mapping_root, dict) else field_mapping_root + out[alias] = transform_dataset_rows( + rows, + field_mapping=mapping, + version_convert=version_convert, + alias=alias, + ) + return out + + +def _values_match(expected: Any, actual: str, *, tolerance: float = 1e-6) -> bool: + if expected == actual: + return True + try: + exp_f = float(expected) + act_f = float(actual) + return math.isclose(exp_f, act_f, rel_tol=tolerance, abs_tol=tolerance) + except (TypeError, ValueError): + return False + + +def _run_golden_fixture(fixture: dict) -> None: + datasets = _prepare_datasets(fixture) + out = transform( + fixture["snapshot"], + fixture.get("cells") or {}, + datasets, + fixture.get("params") or {}, + column_list=fixture.get("column_list"), + fence_list=fixture.get("fence_list"), + ) + failures: List[str] = [] + for sheet_id, cells in (fixture.get("expect") or {}).items(): + for addr, expected in cells.items(): + row_s, col_s = addr.split(",", 1) + actual = _cell_v(out, sheet_id, int(row_s), int(col_s)) + exp_str = "" if expected is None else str(expected) + if not _values_match(exp_str, actual): + failures.append( + f" {sheet_id}!{addr}: expected {exp_str!r}, got {actual!r}" + ) + if failures: + name = fixture.get("name") or "unnamed" + msg = f"{name} failed ({len(failures)} cell(s)):\n" + "\n".join(failures) + raise AssertionError(msg) + + +def test_golden_list_down(): + _run_golden_fixture(_load_fixture("golden_list_down.json")) + + +def test_all_golden_fixtures(): + for path in sorted(_FIXTURES_DIR.glob("golden_*.json")): + _run_golden_fixture(_load_fixture(path.name)) + + +if __name__ == "__main__": + test_all_golden_fixtures() + print(f"ok ({len(list(_FIXTURES_DIR.glob('golden_*.json')))} fixtures)") diff --git a/backend-fastapi/online_dev/report_manager/engine/test_parameter_resolver.py b/backend-fastapi/online_dev/report_manager/engine/test_parameter_resolver.py new file mode 100644 index 0000000..61ce30a --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/test_parameter_resolver.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from online_dev.report_manager.engine.parameter_resolver import ( + build_system_params, + merge_preview_params, +) + + +def test_build_system_params(): + p = build_system_params(user_name="Alice", dept_name="Sales") + assert p["currentUserName"] == "Alice" + assert p["userName"] == "Alice" + assert p["deptName"] == "Sales" + assert p["currentDate"] + + +def test_merge_preview_params_priority(): + merged = merge_preview_params( + {"a": 1, "b": 2}, + {"b": 99, "c": 3}, + {"c": 0, "d": 4}, + ) + assert merged["a"] == 1 + assert merged["b"] == 99 + assert merged["c"] == 3 + assert merged["d"] == 4 + + +if __name__ == "__main__": + test_build_system_params() + test_merge_preview_params_priority() + print("ok") diff --git a/backend-fastapi/online_dev/report_manager/engine/test_placeholders.py b/backend-fastapi/online_dev/report_manager/engine/test_placeholders.py new file mode 100644 index 0000000..f7a998c --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/test_placeholders.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from online_dev.report_manager.engine.convert import transform + + +def test_snapshot_placeholder_in_text_cell(): + snapshot = { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + "0": {"0": {"v": "Hello #{userName}"}}, + }, + } + } + } + filled = transform(snapshot, {"cells": []}, {}, {"userName": "Alice"}) + assert filled["sheets"]["sheet1"]["cellData"]["0"]["0"]["v"] == "Hello Alice" diff --git a/backend-fastapi/online_dev/report_manager/engine/test_polymerize.py b/backend-fastapi/online_dev/report_manager/engine/test_polymerize.py new file mode 100644 index 0000000..d746f07 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/test_polymerize.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from online_dev.report_manager.engine.polymerize import build_bind_list + + +def test_group_bind(): + cell = { + "custom": { + "field": "category", + "polymerizationType": "2", + "groupType": "default", + } + } + rows = [ + {"category": "A", "x": 1}, + {"category": "B", "x": 2}, + {"category": "A", "x": 3}, + ] + binds = build_bind_list(cell, rows) + assert len(binds) == 2 + assert binds[0].value == "A" + assert len(binds[0].data_list) == 2 + + +if __name__ == "__main__": + test_group_bind() + print("ok") diff --git a/backend-fastapi/online_dev/report_manager/engine/test_preview_guard.py b/backend-fastapi/online_dev/report_manager/engine/test_preview_guard.py new file mode 100644 index 0000000..92a7af5 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/test_preview_guard.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from online_dev.report_manager.engine.preview_guard import ( + collect_preview_warnings, + estimate_snapshot_cell_count, + WARN_SNAPSHOT_CELL_COUNT, +) + + +def test_estimate_snapshot_cell_count(): + snapshot = { + "sheets": { + "s1": { + "cellData": { + "0": {"0": {"v": 1}, "1": {"v": 2}}, + "1": {"0": {"v": 3}}, + } + } + } + } + assert estimate_snapshot_cell_count(snapshot) == 3 + + +def test_collect_dataset_row_warn(): + warnings = collect_preview_warnings( + datasets={"sales": [{"id": i} for i in range(10001)]}, + snapshot={}, + ) + assert "dataset_row_warn:sales" in warnings + assert "snapshot_large" not in warnings + + +def test_collect_dataset_row_limit(): + warnings = collect_preview_warnings( + datasets={"sales": [{"id": i} for i in range(50000)]}, + snapshot={}, + ) + assert "dataset_row_limit:sales" in warnings + + +def test_collect_snapshot_large(): + cell_data = {"0": {str(c): {"v": c} for c in range(1000)}} + snapshot = { + "sheets": { + f"s{i}": {"cellData": cell_data} + for i in range(201) + } + } + assert estimate_snapshot_cell_count(snapshot) >= WARN_SNAPSHOT_CELL_COUNT + warnings = collect_preview_warnings(datasets={}, snapshot=snapshot) + assert "snapshot_large" in warnings + + +if __name__ == "__main__": + test_estimate_snapshot_cell_count() + test_collect_dataset_row_warn() + test_collect_dataset_row_limit() + test_collect_snapshot_large() + print("ok") diff --git a/backend-fastapi/online_dev/report_manager/engine/test_sort_apply.py b/backend-fastapi/online_dev/report_manager/engine/test_sort_apply.py new file mode 100644 index 0000000..143a221 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/test_sort_apply.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from online_dev.report_manager.engine.sort_apply import apply_sort_to_rows + + +def test_apply_sort_desc(): + rows = [{"n": 3}, {"n": 1}, {"n": 2}] + rules = [{"vModel": "ds.n", "type": "desc"}] + out = apply_sort_to_rows(rows, rules) + assert [r["n"] for r in out] == [3, 2, 1] diff --git a/backend-fastapi/online_dev/report_manager/engine/test_summary_row_shift.py b/backend-fastapi/online_dev/report_manager/engine/test_summary_row_shift.py new file mode 100644 index 0000000..8fe92d8 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/test_summary_row_shift.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from online_dev.report_manager.engine.convert import transform + + +def test_summary_moves_below_list(): + snapshot = { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + "0": {"0": {"v": "姓名"}, "1": {"v": "年龄"}}, + "1": {"0": {"v": ""}, "1": {"v": ""}}, + "2": {"0": {"v": "合计"}, "1": {"v": ""}}, + }, + } + } + } + cells = { + "cells": [ + { + "col": "0", + "row": "1", + "sheet": "sheet1", + "type": "dataSource", + "custom": { + "field": "name", + "dataSetName": "test", + "polymerizationType": "1", + "expand": "down", + }, + }, + { + "col": "1", + "row": "1", + "sheet": "sheet1", + "type": "dataSource", + "custom": { + "field": "age", + "dataSetName": "test", + "polymerizationType": "1", + "expand": "down", + }, + }, + { + "col": "1", + "row": "2", + "sheet": "sheet1", + "type": "dataSource", + "custom": { + "field": "age", + "dataSetName": "test", + "polymerizationType": "3", + "summaryType": "max", + "leftParentCellType": "none", + "topParentCellType": "none", + }, + }, + ] + } + datasets = { + "test": [ + {"name": "A", "age": 20}, + {"name": "B", "age": 35}, + {"name": "C", "age": 28}, + ], + } + filled = transform(snapshot, cells, datasets, {}) + cell_data = filled["sheets"]["sheet1"]["cellData"] + assert cell_data["1"]["1"]["v"] == 20 + assert cell_data["2"]["1"]["v"] == 35 + assert cell_data["3"]["1"]["v"] == 28 + assert cell_data["4"]["1"]["v"] == 35 + assert cell_data["4"]["0"]["v"] == "合计" + + +def test_summary_count_with_default_parents(): + snapshot = { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + "0": {"0": {"v": "姓名"}, "1": {"v": "值"}}, + "1": {"0": {"v": ""}, "1": {"v": ""}}, + "2": {"0": {"v": "计数"}, "1": {"v": ""}}, + }, + } + } + } + cells = { + "cells": [ + { + "col": "0", + "row": "1", + "sheet": "sheet1", + "type": "dataSource", + "custom": { + "field": "name", + "dataSetName": "test", + "polymerizationType": "1", + "expand": "down", + "leftParentCellType": "default", + "topParentCellType": "default", + }, + }, + { + "col": "1", + "row": "1", + "sheet": "sheet1", + "type": "dataSource", + "custom": { + "field": "gender", + "dataSetName": "test", + "polymerizationType": "1", + "expand": "down", + "leftParentCellType": "default", + "topParentCellType": "default", + }, + }, + { + "col": "1", + "row": "2", + "sheet": "sheet1", + "type": "dataSource", + "custom": { + "field": "gender", + "dataSetName": "test", + "polymerizationType": "3", + "summaryType": "count", + "leftParentCellType": "default", + "topParentCellType": "default", + }, + }, + ] + } + datasets = { + "test": [ + {"name": "A", "gender": 2}, + {"name": "B", "gender": 2}, + {"name": "C", "gender": 1}, + {"name": "D", "gender": 2}, + ], + } + filled = transform(snapshot, cells, datasets, {}) + cell_data = filled["sheets"]["sheet1"]["cellData"] + assert cell_data["5"]["1"]["v"] == 4 + + +def test_group_column_merge(): + snapshot = { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": {"1": {"0": {"v": ""}, "1": {"v": ""}}}, + } + } + } + cells = { + "cells": [ + { + "col": "0", + "row": "1", + "sheet": "sheet1", + "type": "dataSource", + "custom": { + "field": "gender", + "dataSetName": "test", + "polymerizationType": "2", + "groupType": "default", + "expand": "down", + "leftParentCellType": "none", + "topParentCellType": "none", + }, + }, + { + "col": "1", + "row": "1", + "sheet": "sheet1", + "type": "dataSource", + "custom": { + "field": "name", + "dataSetName": "test", + "polymerizationType": "1", + "expand": "down", + "leftParentCellType": "default", + "topParentCellType": "default", + }, + }, + ] + } + datasets = { + "test": [ + {"gender": 0, "name": "A"}, + {"gender": 0, "name": "B"}, + {"gender": 1, "name": "C"}, + {"gender": 1, "name": "D"}, + ], + } + filled = transform(snapshot, cells, datasets, {}) + sheet = filled["sheets"]["sheet1"] + merges = sheet.get("mergeData") or [] + assert {"startRow": 1, "endRow": 2, "startColumn": 0, "endColumn": 0} in merges + assert {"startRow": 3, "endRow": 4, "startColumn": 0, "endColumn": 0} in merges + assert sheet["cellData"]["1"]["0"]["v"] in (0, "0") + assert sheet["cellData"]["3"]["0"]["v"] in (1, "1") + + +def test_group_column_no_merge_when_disabled(): + snapshot = { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": {"1": {"0": {"v": ""}, "1": {"v": ""}}}, + } + } + } + cells = { + "cells": [ + { + "col": "0", + "row": "1", + "sheet": "sheet1", + "type": "dataSource", + "custom": { + "field": "gender", + "dataSetName": "test", + "polymerizationType": "2", + "groupType": "default", + "mergeCell": False, + "expand": "down", + "leftParentCellType": "none", + "topParentCellType": "none", + }, + }, + { + "col": "1", + "row": "1", + "sheet": "sheet1", + "type": "dataSource", + "custom": { + "field": "name", + "dataSetName": "test", + "polymerizationType": "1", + "expand": "down", + "leftParentCellType": "default", + "topParentCellType": "default", + }, + }, + ] + } + datasets = { + "test": [ + {"gender": 0, "name": "A"}, + {"gender": 0, "name": "B"}, + {"gender": 1, "name": "C"}, + ], + } + filled = transform(snapshot, cells, datasets, {}) + merges = filled["sheets"]["sheet1"].get("mergeData") or [] + group_merges = [m for m in merges if m.get("startColumn") == 0] + assert group_merges == [] + + +def test_data_source_style_copied_to_expanded_rows(): + snapshot = { + "sheets": { + "sheet1": { + "id": "sheet1", + "cellData": { + "1": {"0": {"v": "", "s": "T1"}}, + }, + } + } + } + cells = { + "cells": [ + { + "col": "0", + "row": "1", + "sheet": "sheet1", + "type": "dataSource", + "custom": { + "field": "name", + "dataSetName": "test", + "polymerizationType": "1", + "expand": "down", + }, + } + ] + } + datasets = {"test": [{"name": "A"}, {"name": "B"}, {"name": "C"}]} + filled = transform(snapshot, cells, datasets, {}) + cd = filled["sheets"]["sheet1"]["cellData"] + assert cd["1"]["0"].get("s") == "T1" + assert cd["2"]["0"].get("s") == "T1" + assert cd["3"]["0"].get("s") == "T1" + + +if __name__ == "__main__": + test_summary_moves_below_list() + test_summary_count_with_default_parents() + test_group_column_merge() + test_group_column_no_merge_when_disabled() + test_data_source_style_copied_to_expanded_rows() + print("ok") diff --git a/backend-fastapi/online_dev/report_manager/engine/test_watermark.py b/backend-fastapi/online_dev/report_manager/engine/test_watermark.py new file mode 100644 index 0000000..cf9d534 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/test_watermark.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from datetime import datetime + +from online_dev.report_manager.engine.watermark import ( + build_watermark_payload, + resolve_watermark_config, +) + + +def test_resolve_show_time(): + fixed = datetime(2026, 5, 22, 15, 30, 0) + cfg = resolve_watermark_config( + {"content": "机密", "showTime": True, "timeFormat": "yyyy-MM-dd"}, + now=fixed, + ) + assert cfg["content"] == "机密 2026-05-22" + + +def test_build_payload_disabled(): + payload = build_watermark_payload(False, {"content": "x"}) + assert payload["show"] is False + assert payload["config"] == {} + + +def test_build_payload_enabled(): + payload = build_watermark_payload(True, {"content": "ZQ"}, template_name="报表A") + assert payload["show"] is True + assert payload["config"]["content"] == "ZQ" + + +if __name__ == "__main__": + test_resolve_show_time() + test_build_payload_disabled() + test_build_payload_enabled() + print("ok") diff --git a/backend-fastapi/online_dev/report_manager/engine/watermark.py b/backend-fastapi/online_dev/report_manager/engine/watermark.py new file mode 100644 index 0000000..6c10e57 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/engine/watermark.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""报表水印配置解析(对齐 JNPF preview 行为)""" +from __future__ import annotations + +import copy +from datetime import datetime +from typing import Any, Dict, Optional + + +DEFAULT_WATERMARK_CONFIG: Dict[str, Any] = { + "content": "内部使用", + "fontSize": 32, + "color": "#B8B8B8", + "bold": False, + "italic": False, + "direction": "ltr", + "x": 80, + "y": 200, + "repeat": True, + "spacingX": 200, + "spacingY": 200, + "rotate": -45, + "opacity": 0.2, + "showTime": False, + "timeFormat": "yyyy-MM-dd", +} + + +def _format_watermark_time(fmt: str, now: Optional[datetime] = None) -> str: + dt = now or datetime.now() + mapping = { + "yyyy": "%Y", + "yyyy-MM": "%Y-%m", + "yyyy-MM-dd": "%Y-%m-%d", + "yyyy-MM-dd HH:mm": "%Y-%m-%d %H:%M", + "yyyy-MM-dd HH:mm:ss": "%Y-%m-%d %H:%M:%S", + } + py_fmt = mapping.get(fmt or "yyyy-MM-dd", "%Y-%m-%d") + return dt.strftime(py_fmt) + + +def resolve_watermark_config( + raw_config: Any, + *, + template_name: str = "", + now: Optional[datetime] = None, +) -> Dict[str, Any]: + """合并默认项、填充 content,并按 showTime 追加时间文本。""" + base = copy.deepcopy(DEFAULT_WATERMARK_CONFIG) + if isinstance(raw_config, dict): + base.update({k: v for k, v in raw_config.items() if v is not None}) + if not str(base.get("content") or "").strip(): + base["content"] = template_name or DEFAULT_WATERMARK_CONFIG["content"] + if base.get("showTime"): + time_text = _format_watermark_time(str(base.get("timeFormat") or "yyyy-MM-dd"), now) + content = str(base.get("content") or "").strip() + base["content"] = f"{content} {time_text}".strip() + return base + + +def build_watermark_payload( + allow_watermark: bool, + raw_config: Any, + *, + template_name: str = "", + now: Optional[datetime] = None, +) -> Dict[str, Any]: + """返回前端 Univer / 打印共用的 { show, config }。""" + if not allow_watermark: + return {"show": False, "config": {}} + return { + "show": True, + "config": resolve_watermark_config( + raw_config, + template_name=template_name, + now=now, + ), + } diff --git a/backend-fastapi/online_dev/report_manager/enums.py b/backend-fastapi/online_dev/report_manager/enums.py new file mode 100644 index 0000000..23b050c --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/enums.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""报表枚举""" +from enum import IntEnum, Enum + + +class ReportVersionState(IntEnum): + """版本状态""" + DESIGNING = 0 + ACTIVE = 1 + ARCHIVED = 2 + + +class CellTypeEnum(str, Enum): + """单元格类型(与 JNPF CellDataEnum 对齐)""" + TEXT = "text" + DATA_SOURCE = "dataSource" + PARAMETER = "parameter" + EXPRESSION = "expression" + CELL_CHART = "cellChart" + JSBARCODE = "jsbarcode" + QRCODE = "qrcode" diff --git a/backend-fastapi/online_dev/report_manager/exceptions.py b/backend-fastapi/online_dev/report_manager/exceptions.py new file mode 100644 index 0000000..36f7dce --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/exceptions.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + + +class ReportServiceException(Exception): + """报表服务异常""" + pass diff --git a/backend-fastapi/online_dev/report_manager/model.py b/backend-fastapi/online_dev/report_manager/model.py new file mode 100644 index 0000000..525531c --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/model.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""报表管理数据模型""" +from sqlalchemy import Column, String, Text, Integer, Boolean, JSON, SmallInteger + +from app.base_model import BaseModel + + +class ReportTemplate(BaseModel): + """报表模板主表""" + __tablename__ = "report_template" + + application_id = Column(String(21), nullable=True, index=True, comment="所属应用ID") + name = Column(String(100), nullable=False, comment="报表名称") + code = Column(String(100), unique=True, nullable=False, index=True, comment="报表编码") + category = Column(String(50), default="", comment="分类") + description = Column(Text, default="", comment="描述") + status = Column(String(20), default="draft", index=True, comment="状态: draft/published") + allow_export = Column(Boolean, default=True, comment="允许导出") + allow_print = Column(Boolean, default=True, comment="允许打印") + allow_watermark = Column(Boolean, default=False, comment="允许水印") + watermark_config = Column(JSON, default=dict, comment="水印配置") + + +class ReportVersion(BaseModel): + """报表版本表""" + __tablename__ = "report_version" + + template_id = Column(String(21), nullable=False, index=True, comment="模板ID") + version = Column(Integer, default=1, comment="版本号") + state = Column(SmallInteger, default=0, index=True, comment="0设计中 1启用 2归档") + snapshot = Column(JSON, default=dict, comment="Univer工作簿JSON") + cells = Column(JSON, default=dict, comment="单元格绑定元数据") + query_list = Column(JSON, default=list, comment="查询条件") + sort_list = Column(JSON, default=list, comment="排序配置") + column_list = Column(JSON, default=list, comment="分栏配置") + fence_list = Column(JSON, default=list, comment="围栏配置") + convert_config = Column(JSON, default=dict, comment="数据转换规则") + + +class ReportDataset(BaseModel): + """报表版本与数据源关联""" + __tablename__ = "report_dataset" + + version_id = Column(String(21), nullable=False, index=True, comment="版本ID") + data_source_id = Column(String(21), nullable=False, index=True, comment="数据源ID") + alias = Column(String(100), nullable=False, comment="设计器数据集别名") + field_mapping = Column(JSON, default=dict, comment="字段映射") + convert_config = Column(JSON, default=dict, comment="转换配置") diff --git a/backend-fastapi/online_dev/report_manager/permission_templates.py b/backend-fastapi/online_dev/report_manager/permission_templates.py new file mode 100644 index 0000000..93fe2b4 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/permission_templates.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""报表发布时创建的 API 权限模板""" +from __future__ import annotations + +from typing import Any, Dict, List, Tuple + +REPORT_ACTIONS: List[Tuple[str, str, bool, int, str, str]] = [ + ("preview", "预览", True, 1, "POST", "/api/online_dev/report/data/preview-template"), + ("export", "导出", True, 1, "POST", "/api/online_dev/report/data/export-excel/template"), + ("export_pdf", "导出PDF", True, 1, "POST", "/api/online_dev/report/data/export-pdf/template"), + ("design", "设计", True, 1, "POST", "/api/online_dev/report/save"), + ("publish", "发布", True, 1, "POST", "/api/online_dev/report/{template_id}/publish"), +] + +REPORT_ADMIN_PERMISSIONS: List[Dict[str, Any]] = [ + { + "code": "report:admin:list", + "name": "报表管理-列表", + "api_path": "/api/online_dev/report/list", + "http_method": 0, + }, + { + "code": "report:admin:save", + "name": "报表管理-保存", + "api_path": "/api/online_dev/report/save", + "http_method": 1, + }, + { + "code": "report:admin:import", + "name": "报表管理-导入", + "api_path": "/api/online_dev/report/import", + "http_method": 1, + }, +] diff --git a/backend-fastapi/online_dev/report_manager/schema.py b/backend-fastapi/online_dev/report_manager/schema.py new file mode 100644 index 0000000..f7f9809 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/schema.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""报表管理 Schema""" +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field, ConfigDict + + +def _to_camel(string: str) -> str: + parts = string.split("_") + return parts[0] + "".join(p.capitalize() for p in parts[1:]) + + +class CamelModel(BaseModel): + """支持 camelCase 别名(与 JNPF 前端对齐)""" + model_config = ConfigDict( + populate_by_name=True, + alias_generator=_to_camel, + ) + + +# ============ 模板 ============ + +class ReportTemplateBase(BaseModel): + application_id: Optional[str] = Field(None, description="所属应用ID") + name: str = Field(..., description="报表名称") + code: str = Field(..., pattern=r"^[a-zA-Z][a-zA-Z0-9_]*$", description="报表编码") + category: str = Field("", description="分类") + description: str = Field("", description="描述") + sort: int = Field(0, description="排序") + + +class ReportTemplateCreateIn(ReportTemplateBase): + pass + + +class ReportTemplateUpdateIn(BaseModel): + name: Optional[str] = None + category: Optional[str] = None + description: Optional[str] = None + sort: Optional[int] = None + allow_export: Optional[bool] = None + allow_print: Optional[bool] = None + allow_watermark: Optional[bool] = None + watermark_config: Optional[Dict[str, Any]] = None + + +class ReportTemplateOut(BaseModel): + id: str + application_id: Optional[str] = None + name: str + code: str + category: str + description: str + status: str + allow_export: bool + allow_print: bool + allow_watermark: bool + watermark_config: Dict[str, Any] + sort: int + active_version_id: Optional[str] = None + sys_create_datetime: str + sys_update_datetime: str + + +class ReportTemplateListOut(BaseModel): + id: str + application_id: Optional[str] = None + application_name: str = "" + application_code: str = "" + name: str + code: str + category: str + description: str + status: str + has_release_menu: bool = False + sort: int + sys_create_datetime: str + sys_update_datetime: str + + +# ============ 版本 ============ + +class ReportDatasetIn(CamelModel): + data_source_id: str = Field(..., description="数据源ID") + alias: str = Field(..., description="别名") + field_mapping: Dict[str, Any] = Field(default_factory=dict) + convert_config: Dict[str, Any] = Field(default_factory=dict) + sort: int = 0 + + +class ReportVersionOut(BaseModel): + id: str + template_id: str + version: int + state: int + snapshot: Dict[str, Any] + cells: Dict[str, Any] + query_list: List[Any] + sort_list: List[Any] + column_list: List[Any] + fence_list: List[Any] + convert_config: Dict[str, Any] + datasets: List[Dict[str, Any]] = Field(default_factory=list) + sys_create_datetime: str + sys_update_datetime: str + + +class ReportVersionListOut(BaseModel): + id: str + template_id: str + version: int + state: int + sys_create_datetime: str + sys_update_datetime: str + + +class ReportSaveIn(CamelModel): + """保存版本(对标 JNPF POST /Report/Save)""" + id: str = Field(..., description="模板ID") + version_id: Optional[str] = Field(None, description="版本ID,空则新建设计中版本") + type: int = Field(0, description="0仅保存 1发布") + snapshot: Any = Field(default_factory=dict, description="Univer snapshot") + cells: Any = Field(default_factory=dict) + query_list: List[Any] = Field(default_factory=list) + sort_list: List[Any] = Field(default_factory=list) + column_list: List[Any] = Field(default_factory=list) + fence_list: List[Any] = Field(default_factory=list) + convert_config: Any = Field(default_factory=dict) + data_set_list: List[ReportDatasetIn] = Field(default_factory=list, description="数据集列表") + + +class ReportSaveOut(BaseModel): + template_id: str + version_id: str + state: int + + +# ============ 预览 ============ + +class ReportPreviewIn(CamelModel): + params: Dict[str, Any] = Field(default_factory=dict, description="查询参数") + snapshot: Any = Field(None, description="设计态预览:当前编辑器 snapshot") + cells: Any = Field(None, description="设计态预览:当前编辑器 cells") + query_list: Any = Field(None, description="设计态预览:当前查询条件") + sort_list: Any = Field(None, description="设计态预览:当前排序") + column_list: Any = Field(None, description="设计态预览:当前分栏") + fence_list: Any = Field(None, description="设计态预览:当前分栏(fence)") + convert_config: Any = Field(None, description="设计态预览:当前转换配置") + + +class ReportDownImgIn(BaseModel): + """远端/ Base64 图片转存""" + model_config = ConfigDict(populate_by_name=True) + img_value: str = Field("", alias="imgValue") + img_type: str = Field("", alias="imgType", description="BASE64 或 URL") + + +class ReportUploadOut(BaseModel): + name: str = "" + url: str = "" + + +class ReportImportExcelOut(BaseModel): + rowsCount: int = 0 + colsCount: int = 0 + data: List[List[Dict[str, Any]]] = Field(default_factory=list) + + +class ReportPreviewOut(CamelModel): + snapshot: Dict[str, Any] + cells: Dict[str, Any] + query_list: List[Any] = Field(default_factory=list) + chart_data: List[Any] = Field(default_factory=list) + allow_export: bool = True + allow_print: bool = True + allow_watermark: bool = False + watermark_config: Dict[str, Any] = Field(default_factory=dict) + full_name: str = "" + + +# ============ 发布 / 导入导出 ============ + +class ReportPublishIn(BaseModel): + menu_name: str = Field(..., description="菜单名称") + menu_parent_id: Optional[str] = Field(None, description="上级菜单ID") + menu_icon: str = Field("lucide:file-spreadsheet", description="菜单图标") + menu_order: int = Field(0, description="菜单排序") + + +class ReportImportCheckIn(BaseModel): + code: str + + +class ReportImportCheckOut(BaseModel): + code_exists: bool + can_import: bool + + +class ReportImportIn(ReportTemplateBase): + schema_version: int = Field(1, description="包格式版本") + allow_export: Optional[bool] = None + allow_print: Optional[bool] = None + allow_watermark: Optional[bool] = None + watermark_config: Optional[Dict[str, Any]] = None + version: Optional[Dict[str, Any]] = Field(None, description="设计中版本内容") + versions: List[Dict[str, Any]] = Field(default_factory=list, description="兼容旧字段") + datasets: List[Dict[str, Any]] = Field(default_factory=list) diff --git a/backend-fastapi/online_dev/report_manager/service.py b/backend-fastapi/online_dev/report_manager/service.py new file mode 100644 index 0000000..651adb6 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/service.py @@ -0,0 +1,603 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""报表模板管理服务""" +import logging +from typing import Any, Dict, List, Optional + +from sqlalchemy import select, update, delete, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from online_dev.report_manager.model import ReportTemplate, ReportVersion +from online_dev.report_manager.enums import ReportVersionState +from online_dev.report_manager.constants import default_snapshot, default_cells +from online_dev.report_manager.exceptions import ReportServiceException +from app.data_scope_utils import get_data_scope_filter, apply_data_scope_to_conditions + +logger = logging.getLogger(__name__) + +RESOURCE_TYPE = "report" +RESOURCE_DISPLAY_NAME = "报表管理" + + +class ReportService: + @staticmethod + def _format_dt(dt) -> str: + return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else "" + + @staticmethod + async def list_with_data_scope( + db: AsyncSession, + page: int = 1, + page_size: int = 20, + application_id: str = None, + name: str = None, + code: str = None, + category: str = None, + status: str = None, + ) -> Dict[str, Any]: + conditions = [ReportTemplate.is_deleted == False] + if application_id: + conditions.append(ReportTemplate.application_id == application_id) + else: + conditions.append(ReportTemplate.application_id.is_(None)) + if name: + conditions.append(ReportTemplate.name.ilike(f"%{name}%")) + if code: + conditions.append(ReportTemplate.code.ilike(f"%{code}%")) + if category: + conditions.append(ReportTemplate.category == category) + if status: + conditions.append(ReportTemplate.status == status) + + data_scope_filter = await get_data_scope_filter(db, RESOURCE_TYPE) + scope_conditions = apply_data_scope_to_conditions(ReportTemplate, data_scope_filter) + conditions.extend(scope_conditions) + + count_stmt = select(func.count(ReportTemplate.id)).where(and_(*conditions)) + total = (await db.execute(count_stmt)).scalar() or 0 + + offset = (page - 1) * page_size + stmt = ( + select(ReportTemplate) + .where(and_(*conditions)) + .order_by(ReportTemplate.sort, ReportTemplate.sys_create_datetime.desc()) + .offset(offset) + .limit(page_size) + ) + items = list((await db.execute(stmt)).scalars().all()) + return {"items": items, "total": total} + + @staticmethod + async def get(db: AsyncSession, template_id: str) -> ReportTemplate: + stmt = select(ReportTemplate).where( + ReportTemplate.id == template_id, + ReportTemplate.is_deleted == False, + ) + tpl = (await db.execute(stmt)).scalar_one_or_none() + if not tpl: + raise ReportServiceException(f"报表不存在: {template_id}") + return tpl + + @staticmethod + async def get_by_code(db: AsyncSession, code: str) -> ReportTemplate: + stmt = select(ReportTemplate).where( + ReportTemplate.code == code, + ReportTemplate.is_deleted == False, + ) + tpl = (await db.execute(stmt)).scalar_one_or_none() + if not tpl: + raise ReportServiceException(f"报表不存在: {code}") + return tpl + + @staticmethod + async def get_active_version_id(db: AsyncSession, template_id: str) -> Optional[str]: + stmt = select(ReportVersion.id).where( + ReportVersion.template_id == template_id, + ReportVersion.state == ReportVersionState.ACTIVE, + ReportVersion.is_deleted == False, + ) + return (await db.execute(stmt)).scalar_one_or_none() + + @staticmethod + async def create(db: AsyncSession, data: Dict[str, Any], user_id: str = None) -> ReportTemplate: + code = data.get("code") + stmt = select(ReportTemplate).where( + ReportTemplate.code == code, + ReportTemplate.is_deleted == False, + ) + if (await db.execute(stmt)).scalar_one_or_none(): + raise ReportServiceException(f"报表编码已存在: {code}") + + from utils.context import get_current_user_info_from_context + user_info = get_current_user_info_from_context() + + tpl = ReportTemplate( + application_id=data.get("application_id"), + name=data.get("name"), + code=code, + category=data.get("category", ""), + description=data.get("description", ""), + sort=data.get("sort", 0), + sys_creator_id=user_id or (user_info.get("user_id") if user_info else None), + sys_modifier_id=user_id or (user_info.get("user_id") if user_info else None), + ) + if user_info and user_info.get("dept_id"): + tpl.sys_dept_id = user_info.get("dept_id") + + db.add(tpl) + await db.flush() + + version = ReportVersion( + template_id=tpl.id, + version=1, + state=ReportVersionState.DESIGNING, + snapshot=default_snapshot(), + cells=default_cells(), + sys_creator_id=tpl.sys_creator_id, + sys_modifier_id=tpl.sys_modifier_id, + sys_dept_id=tpl.sys_dept_id, + ) + db.add(version) + await db.commit() + await db.refresh(tpl) + logger.info("报表创建成功: %s", tpl.code) + return tpl + + @staticmethod + async def update( + db: AsyncSession, + template_id: str, + data: Dict[str, Any], + user_id: str = None, + ) -> ReportTemplate: + tpl = await ReportService.get(db, template_id) + for key in ("name", "category", "description", "sort", "allow_export", "allow_print", + "allow_watermark", "watermark_config"): + if key in data and data[key] is not None: + setattr(tpl, key, data[key]) + tpl.sys_modifier_id = user_id + await db.commit() + await db.refresh(tpl) + return tpl + + @staticmethod + async def delete(db: AsyncSession, template_id: str) -> bool: + from core.menu.service import MenuService + + tpl = await ReportService.get(db, template_id) + await ReportService._cleanup_report_publish_resources(db, tpl) + tpl.is_deleted = True + tpl.status = "draft" + stmt = update(ReportVersion).where( + ReportVersion.template_id == template_id, + ReportVersion.is_deleted == False, + ).values(is_deleted=True) + await db.execute(stmt) + await db.commit() + await MenuService.invalidate_cache() + logger.info("报表删除成功: %s", tpl.code) + return True + + @staticmethod + async def batch_delete(db: AsyncSession, template_ids: List[str]) -> int: + count = 0 + for tid in template_ids: + if await ReportService.delete(db, tid): + count += 1 + return count + + @staticmethod + async def get_categories(db: AsyncSession, application_id: str = None) -> List[str]: + conditions = [ReportTemplate.is_deleted == False, ReportTemplate.category != ""] + if application_id: + conditions.append(ReportTemplate.application_id == application_id) + else: + conditions.append(ReportTemplate.application_id.is_(None)) + stmt = select(ReportTemplate.category).where(and_(*conditions)).distinct() + rows = (await db.execute(stmt)).scalars().all() + return sorted({r for r in rows if r}) + + @staticmethod + async def copy( + db: AsyncSession, + template_id: str, + new_code: str, + new_name: str = None, + user_id: str = None, + ) -> ReportTemplate: + src = await ReportService.get(db, template_id) + stmt = select(ReportTemplate).where( + ReportTemplate.code == new_code, + ReportTemplate.is_deleted == False, + ) + if (await db.execute(stmt)).scalar_one_or_none(): + raise ReportServiceException(f"报表编码已存在: {new_code}") + + new_tpl = await ReportService.create( + db, + { + "application_id": src.application_id, + "name": new_name or f"{src.name}_copy", + "code": new_code, + "category": src.category, + "description": src.description, + "sort": src.sort, + }, + user_id, + ) + from online_dev.report_manager.version_service import ReportVersionService + + src_version = await ReportVersionService.get_designing_or_latest(db, template_id) + if src_version: + await ReportVersionService.copy_version_content( + db, src_version.id, new_tpl.id, user_id + ) + return new_tpl + + @staticmethod + async def check_import(db: AsyncSession, code: str) -> Dict[str, bool]: + stmt = select(ReportTemplate).where( + ReportTemplate.code == code, + ReportTemplate.is_deleted == False, + ) + exists = (await db.execute(stmt)).scalar_one_or_none() is not None + return {"code_exists": exists, "can_import": not exists} + + @staticmethod + async def export_config(db: AsyncSession, template_id: str) -> Dict[str, Any]: + """导出报表配置包(模板 + 设计中/最新版本 + 数据集)""" + from online_dev.report_manager.version_service import ReportVersionService + from online_dev.report_manager.dataset_bridge import ReportDatasetBridge + + tpl = await ReportService.get(db, template_id) + version = await ReportVersionService.get_designing_or_latest(db, template_id) + if not version: + raise ReportServiceException("没有可导出的版本") + + datasets_raw = await ReportDatasetBridge.list_by_version(db, version.id) + datasets = [ + { + "data_source_id": d.get("data_source_id"), + "data_source_code": d.get("data_source_code"), + "data_source_name": d.get("data_source_name"), + "alias": d.get("alias"), + "field_mapping": d.get("field_mapping") or {}, + "convert_config": d.get("convert_config") or {}, + "sort": d.get("sort", 0), + } + for d in datasets_raw + ] + + return { + "schema_version": 1, + "name": tpl.name, + "code": tpl.code, + "category": tpl.category or "", + "description": tpl.description or "", + "sort": tpl.sort or 0, + "allow_export": bool(tpl.allow_export), + "allow_print": bool(tpl.allow_print), + "allow_watermark": bool(tpl.allow_watermark), + "watermark_config": tpl.watermark_config or {}, + "version": { + "snapshot": version.snapshot or {}, + "cells": version.cells or {}, + "query_list": version.query_list or [], + "sort_list": version.sort_list or [], + "column_list": version.column_list or [], + "fence_list": version.fence_list or [], + "convert_config": version.convert_config or {}, + }, + "datasets": datasets, + } + + @staticmethod + async def import_config( + db: AsyncSession, + data: Dict[str, Any], + user_id: str = None, + ) -> ReportTemplate: + """导入报表配置包""" + from core.data_source.service import DataSourceService + from online_dev.report_manager.version_service import ReportVersionService + from online_dev.report_manager.dataset_bridge import ReportDatasetBridge + + for field in ("name", "code"): + if not data.get(field): + raise ReportServiceException(f"缺少必要字段: {field}") + + code = data["code"] + check = await ReportService.check_import(db, code) + if check["code_exists"]: + raise ReportServiceException(f"报表编码已存在: {code}") + + tpl = await ReportService.create( + db, + { + "application_id": data.get("application_id"), + "name": data["name"], + "code": code, + "category": data.get("category", ""), + "description": data.get("description", ""), + "sort": data.get("sort", 0), + }, + user_id, + ) + + for key in ("allow_export", "allow_print", "allow_watermark", "watermark_config"): + if key in data and data[key] is not None: + setattr(tpl, key, data[key]) + + version = await ReportVersionService.get_designing_or_latest(db, tpl.id) + if not version: + raise ReportServiceException("导入后未找到设计版本") + + version_data = data.get("version") + if not version_data and data.get("versions"): + version_data = data["versions"][0] if data["versions"] else None + + if version_data: + version.snapshot = version_data.get("snapshot") or version.snapshot + version.cells = version_data.get("cells") or version.cells + version.query_list = version_data.get("query_list") or version.query_list + version.sort_list = version_data.get("sort_list") or version.sort_list + version.column_list = version_data.get("column_list") or version.column_list + version.fence_list = version_data.get("fence_list") or version.fence_list + version.convert_config = version_data.get("convert_config") or version.convert_config + version.sys_modifier_id = user_id + + data_set_list = [] + skipped_aliases = [] + for idx, item in enumerate(data.get("datasets") or []): + ds_id = item.get("data_source_id") or item.get("dataSourceId") + ds_code = item.get("data_source_code") or item.get("dataSourceCode") + if not ds_id and ds_code: + source = await DataSourceService.get_by_code(db, ds_code) + if source: + ds_id = source.id + if not ds_id: + alias = item.get("alias") or f"ds_{idx}" + skipped_aliases.append(alias) + continue + data_set_list.append({ + "data_source_id": ds_id, + "alias": item.get("alias") or f"ds_{idx}", + "field_mapping": item.get("field_mapping") or item.get("fieldMapping") or {}, + "convert_config": item.get("convert_config") or item.get("convertConfig") or {}, + "sort": item.get("sort", idx), + }) + + await ReportDatasetBridge.sync_datasets(db, version.id, data_set_list) + await db.commit() + await db.refresh(tpl) + + if skipped_aliases: + logger.warning( + "导入报表 %s 时跳过未匹配的数据集: %s", + code, + ", ".join(skipped_aliases), + ) + + logger.info("报表配置导入成功: %s", code) + return tpl + + # ============ 发布到菜单 ============ + + @staticmethod + def _report_menu_query(template: ReportTemplate) -> Dict[str, Any]: + return { + "relationId": str(template.id), + "moduleId": str(template.id), + "templateId": str(template.id), + "reportCode": template.code, + } + + @staticmethod + async def _create_report_permissions( + db: AsyncSession, + template: ReportTemplate, + menu_id: str, + ) -> None: + from core.permission.model import Permission + from online_dev.report_manager.permission_templates import REPORT_ACTIONS + + http_method_map = {"GET": 0, "POST": 1, "PUT": 2, "DELETE": 3, "PATCH": 4, "ALL": 5} + for idx, (action, name, enabled, http_method_int, http_method_str, api_path) in enumerate( + REPORT_ACTIONS + ): + perm_code = f"report:{template.code}:{action}" + path = api_path.replace("{template_id}", str(template.id)) + existing = ( + await db.execute( + select(Permission).where( + Permission.menu_id == menu_id, + Permission.code == perm_code, + Permission.is_deleted == False, + ) + ) + ).scalar_one_or_none() + if existing: + existing.is_active = enabled and ( + action not in ("export", "export_pdf") + or bool(template.allow_export) + ) + existing.name = f"{template.name}-{name}" + existing.api_path = path + existing.http_method = http_method_map.get(http_method_str, http_method_int) + continue + db.add( + Permission( + menu_id=menu_id, + name=f"{template.name}-{name}", + code=perm_code, + permission_type=1, + api_path=path, + http_method=http_method_map.get(http_method_str, http_method_int), + is_active=enabled + and ( + action not in ("export", "export_pdf") + or bool(template.allow_export) + ), + sort=idx, + ) + ) + + @staticmethod + async def _cleanup_report_publish_resources(db: AsyncSession, template: ReportTemplate) -> None: + """清理报表发布产生的菜单与 API 权限(取消发布、删除时共用)""" + from core.menu.model import Menu + from core.permission.model import Permission + + menu_path = f"/report-render/{template.code}" + + delete_menu_stmt = delete(Menu).where(Menu.path == menu_path) + menu_result = await db.execute(delete_menu_stmt) + if menu_result.rowcount > 0: + logger.info( + "物理删除报表菜单: %s, 删除数量: %s", + template.code, + menu_result.rowcount, + ) + + delete_perm_stmt = delete(Permission).where( + Permission.code.like(f"report:{template.code}:%") + ) + perm_result = await db.execute(delete_perm_stmt) + if perm_result.rowcount > 0: + logger.info( + "物理删除报表 API 权限: %s, 删除数量: %s", + template.code, + perm_result.rowcount, + ) + + @staticmethod + async def publish( + db: AsyncSession, + template_id: str, + publish_config: Dict[str, Any] = None, + ) -> ReportTemplate: + """发布报表并创建 online_report 菜单""" + from core.menu.model import Menu + from core.menu.service import MenuService + from online_dev.report_manager.version_service import ReportVersionService + + tpl = await ReportService.get(db, template_id) + version = await ReportVersionService.get_active(db, template_id) + if not version: + version = await ReportVersionService.get_designing_or_latest(db, template_id) + if not version: + raise ReportServiceException("没有可发布的版本,请先在设计器中保存报表") + + tpl.status = "published" + + if publish_config: + menu_parent_id = publish_config.get("menu_parent_id") + menu_path = f"/report-render/{tpl.code}" + menu_internal_name = f"report_{tpl.code}" + + menu_stmt = select(Menu).where(Menu.path == menu_path) + existing_menu = (await db.execute(menu_stmt)).scalar_one_or_none() + + title = publish_config.get("menu_name", tpl.name) + icon = publish_config.get("menu_icon", "lucide:file-spreadsheet") + order = publish_config.get("menu_order", 0) + menu_query = ReportService._report_menu_query(tpl) + + if existing_menu: + existing_menu.name = menu_internal_name + existing_menu.title = title + existing_menu.parent_id = menu_parent_id + existing_menu.icon = icon + existing_menu.order = order + existing_menu.type = "online_report" + existing_menu.application_id = tpl.application_id + existing_menu.component = "online-dev/report-render/index" + existing_menu.query = menu_query + existing_menu.authCode = f"report:{tpl.code}:preview" + menu_record = existing_menu + logger.info("更新报表菜单: %s", tpl.code) + else: + menu_record = Menu( + application_id=tpl.application_id, + name=menu_internal_name, + title=title, + path=menu_path, + component="online-dev/report-render/index", + type="online_report", + parent_id=menu_parent_id, + icon=icon, + order=order, + query=menu_query, + authCode=f"report:{tpl.code}:preview", + ) + db.add(menu_record) + await db.flush() + logger.info("创建报表菜单: %s", tpl.code) + + await ReportService._create_report_permissions(db, tpl, menu_record.id) + + await db.commit() + await db.refresh(tpl) + await MenuService.invalidate_cache() + logger.info("报表发布成功: %s", tpl.code) + return tpl + + @staticmethod + async def unpublish(db: AsyncSession, template_id: str) -> ReportTemplate: + """取消发布并删除对应菜单""" + from core.menu.service import MenuService + + tpl = await ReportService.get(db, template_id) + tpl.status = "draft" + await ReportService._cleanup_report_publish_resources(db, tpl) + await db.commit() + await db.refresh(tpl) + await MenuService.invalidate_cache() + logger.info("报表取消发布: %s", tpl.code) + return tpl + + @staticmethod + async def get_release_menu(db: AsyncSession, template_id: str) -> Dict[str, Any]: + from core.menu.model import Menu + + tpl = await ReportService.get(db, template_id) + menu_path = f"/report-render/{tpl.code}" + menu = (await db.execute(select(Menu).where(Menu.path == menu_path))).scalar_one_or_none() + if not menu: + return {"published": False, "menu": None} + return { + "published": True, + "menu": { + "id": str(menu.id), + "title": menu.title, + "path": menu.path, + "parent_id": menu.parent_id, + "icon": menu.icon, + "order": menu.order, + }, + } + + @staticmethod + async def get_selector( + db: AsyncSession, + application_id: str = None, + ) -> List[Dict[str, Any]]: + """分类树形选择器(对标 JNPF GET /Report/Selector)""" + result = await ReportService.list_with_data_scope( + db, page=1, page_size=500, application_id=application_id + ) + by_cat: Dict[str, List[Dict[str, Any]]] = {} + for tpl in result["items"]: + cat = tpl.category or "未分类" + by_cat.setdefault(cat, []).append( + { + "id": str(tpl.id), + "name": tpl.name, + "code": tpl.code, + "status": tpl.status, + } + ) + return [ + {"category": cat, "children": items} + for cat, items in sorted(by_cat.items(), key=lambda x: x[0]) + ] diff --git a/backend-fastapi/online_dev/report_manager/version_service.py b/backend-fastapi/online_dev/report_manager/version_service.py new file mode 100644 index 0000000..0745fd9 --- /dev/null +++ b/backend-fastapi/online_dev/report_manager/version_service.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""报表版本管理服务""" +import copy +import json +import logging +from typing import Any, Dict, List, Optional + +from sqlalchemy import select, update, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from online_dev.report_manager.model import ReportTemplate, ReportVersion, ReportDataset +from online_dev.report_manager.enums import ReportVersionState +from online_dev.report_manager.exceptions import ReportServiceException +from online_dev.report_manager.constants import default_snapshot, default_cells +from online_dev.report_manager.dataset_bridge import ReportDatasetBridge + +logger = logging.getLogger(__name__) + + +class ReportVersionService: + @staticmethod + async def get(db: AsyncSession, version_id: str) -> ReportVersion: + stmt = select(ReportVersion).where( + ReportVersion.id == version_id, + ReportVersion.is_deleted == False, + ) + ver = (await db.execute(stmt)).scalar_one_or_none() + if not ver: + raise ReportServiceException(f"版本不存在: {version_id}") + return ver + + @staticmethod + async def list_by_template(db: AsyncSession, template_id: str) -> List[ReportVersion]: + stmt = ( + select(ReportVersion) + .where( + ReportVersion.template_id == template_id, + ReportVersion.is_deleted == False, + ) + .order_by(ReportVersion.version.desc()) + ) + return list((await db.execute(stmt)).scalars().all()) + + @staticmethod + async def get_active(db: AsyncSession, template_id: str) -> Optional[ReportVersion]: + stmt = select(ReportVersion).where( + ReportVersion.template_id == template_id, + ReportVersion.state == ReportVersionState.ACTIVE, + ReportVersion.is_deleted == False, + ) + return (await db.execute(stmt)).scalar_one_or_none() + + @staticmethod + async def get_designing_or_latest(db: AsyncSession, template_id: str) -> Optional[ReportVersion]: + stmt = select(ReportVersion).where( + ReportVersion.template_id == template_id, + ReportVersion.is_deleted == False, + ).order_by( + ReportVersion.state.asc(), + ReportVersion.version.desc(), + ) + versions = list((await db.execute(stmt)).scalars().all()) + for v in versions: + if v.state == ReportVersionState.DESIGNING: + return v + return versions[0] if versions else None + + @staticmethod + def _parse_json_field(value: Any, default: Any) -> Any: + if value is None: + return default + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError: + return default + return value + + @staticmethod + async def save(db: AsyncSession, data: Dict[str, Any], user_id: str = None) -> Dict[str, Any]: + from online_dev.report_manager.service import ReportService + + template_id = data.get("id") + tpl = await ReportService.get(db, template_id) + + version_id = data.get("version_id") + save_type = data.get("type", 0) + + snapshot = ReportVersionService._parse_json_field(data.get("snapshot"), default_snapshot()) + cells = ReportVersionService._parse_json_field(data.get("cells"), default_cells()) + query_list = ReportVersionService._parse_json_field(data.get("query_list"), []) + sort_list = ReportVersionService._parse_json_field(data.get("sort_list"), []) + column_list = ReportVersionService._parse_json_field(data.get("column_list"), []) + fence_list = ReportVersionService._parse_json_field(data.get("fence_list"), []) + convert_config = ReportVersionService._parse_json_field(data.get("convert_config"), {}) + data_set_list = data.get("data_set_list") or [] + + if version_id: + version = await ReportVersionService.get(db, version_id) + if version.template_id != template_id: + raise ReportServiceException("版本与模板不匹配") + if version.state != ReportVersionState.DESIGNING: + raise ReportServiceException("只能编辑设计中版本") + else: + designing = await ReportVersionService.get_designing_or_latest(db, template_id) + if designing and designing.state == ReportVersionState.DESIGNING: + version = designing + else: + max_ver = ( + await db.execute( + select(func.max(ReportVersion.version)).where( + ReportVersion.template_id == template_id, + ReportVersion.is_deleted == False, + ) + ) + ).scalar() or 0 + version = ReportVersion( + template_id=template_id, + version=max_ver + 1, + state=ReportVersionState.DESIGNING, + snapshot=default_snapshot(), + cells=default_cells(), + sys_creator_id=user_id, + sys_modifier_id=user_id, + ) + db.add(version) + await db.flush() + + version.snapshot = snapshot + version.cells = cells + version.query_list = query_list + version.sort_list = sort_list + version.column_list = column_list + version.fence_list = fence_list + version.convert_config = convert_config + version.sys_modifier_id = user_id + + await ReportDatasetBridge.sync_datasets(db, version.id, data_set_list) + + if save_type == 1: + stmt = update(ReportVersion).where( + ReportVersion.template_id == template_id, + ReportVersion.state == ReportVersionState.ACTIVE, + ReportVersion.is_deleted == False, + ).values(state=ReportVersionState.ARCHIVED) + await db.execute(stmt) + version.state = ReportVersionState.ACTIVE + await db.flush() + + max_ver = ( + await db.execute( + select(func.max(ReportVersion.version)).where( + ReportVersion.template_id == template_id, + ReportVersion.is_deleted == False, + ) + ) + ).scalar() or version.version + new_designing = ReportVersion( + template_id=template_id, + version=max_ver + 1, + state=ReportVersionState.DESIGNING, + snapshot=copy.deepcopy(version.snapshot), + cells=copy.deepcopy(version.cells), + query_list=copy.deepcopy(version.query_list), + sort_list=copy.deepcopy(version.sort_list), + column_list=copy.deepcopy(version.column_list), + fence_list=copy.deepcopy(version.fence_list), + convert_config=copy.deepcopy(version.convert_config), + sys_creator_id=user_id, + sys_modifier_id=user_id, + ) + db.add(new_designing) + await db.flush() + await ReportDatasetBridge.copy_datasets(db, version.id, new_designing.id) + version = new_designing + else: + version.state = ReportVersionState.DESIGNING + + await db.commit() + await db.refresh(version) + return { + "template_id": template_id, + "version_id": version.id, + "state": int(version.state), + } + + @staticmethod + async def get_version_detail(db: AsyncSession, version_id: str) -> Dict[str, Any]: + version = await ReportVersionService.get(db, version_id) + datasets = await ReportDatasetBridge.list_by_version(db, version_id) + return { + "version": version, + "datasets": datasets, + } + + @staticmethod + async def delete_version(db: AsyncSession, version_id: str) -> bool: + version = await ReportVersionService.get(db, version_id) + if version.state in (ReportVersionState.ACTIVE, ReportVersionState.ARCHIVED): + raise ReportServiceException("不能删除启用中或已归档版本") + + count_stmt = select(func.count(ReportVersion.id)).where( + ReportVersion.template_id == version.template_id, + ReportVersion.is_deleted == False, + ) + if (await db.execute(count_stmt)).scalar() <= 1: + raise ReportServiceException("不能删除最后一个版本") + + version.is_deleted = True + await ReportDatasetBridge.delete_by_version(db, version_id) + await db.commit() + return True + + @staticmethod + async def copy_version_content( + db: AsyncSession, + src_version_id: str, + new_template_id: str, + user_id: str = None, + ) -> None: + src = await ReportVersionService.get(db, src_version_id) + new_ver = ( + await db.execute( + select(ReportVersion).where( + ReportVersion.template_id == new_template_id, + ReportVersion.is_deleted == False, + ) + ) + ).scalar_one_or_none() + if not new_ver: + return + new_ver.snapshot = src.snapshot + new_ver.cells = src.cells + new_ver.query_list = src.query_list + new_ver.sort_list = src.sort_list + new_ver.column_list = src.column_list + new_ver.fence_list = src.fence_list + new_ver.convert_config = src.convert_config + await ReportDatasetBridge.copy_datasets(db, src_version_id, new_ver.id) + await db.commit() + + @staticmethod + async def duplicate_version( + db: AsyncSession, + src_version_id: str, + user_id: str = None, + ) -> ReportVersion: + """复制版本为新的「设计中」版本(同模板)。""" + src = await ReportVersionService.get(db, src_version_id) + template_id = src.template_id + + await db.execute( + update(ReportVersion).where( + ReportVersion.template_id == template_id, + ReportVersion.state == ReportVersionState.DESIGNING, + ReportVersion.is_deleted == False, + ).values(state=ReportVersionState.ARCHIVED) + ) + + max_ver = ( + await db.execute( + select(func.max(ReportVersion.version)).where( + ReportVersion.template_id == template_id, + ReportVersion.is_deleted == False, + ) + ) + ).scalar() or 0 + + new_ver = ReportVersion( + template_id=template_id, + version=max_ver + 1, + state=ReportVersionState.DESIGNING, + snapshot=copy.deepcopy(src.snapshot), + cells=copy.deepcopy(src.cells), + query_list=copy.deepcopy(src.query_list), + sort_list=copy.deepcopy(src.sort_list), + column_list=copy.deepcopy(src.column_list), + fence_list=copy.deepcopy(src.fence_list), + convert_config=copy.deepcopy(src.convert_config), + sys_creator_id=user_id, + sys_modifier_id=user_id, + ) + db.add(new_ver) + await db.flush() + await ReportDatasetBridge.copy_datasets(db, src.id, new_ver.id) + await db.commit() + await db.refresh(new_ver) + return new_ver diff --git a/backend-fastapi/online_dev/screen_design/__init__.py b/backend-fastapi/online_dev/screen_design/__init__.py new file mode 100644 index 0000000..d321348 --- /dev/null +++ b/backend-fastapi/online_dev/screen_design/__init__.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +大屏设计器模块 +""" +from online_dev.screen_design.screen_api import router as screen_router +from online_dev.screen_design.material_api import router as material_router + +__all__ = ['screen_router', 'material_router'] diff --git a/backend-fastapi/online_dev/screen_design/material_api.py b/backend-fastapi/online_dev/screen_design/material_api.py new file mode 100644 index 0000000..f761b23 --- /dev/null +++ b/backend-fastapi/online_dev/screen_design/material_api.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +大屏素材 API +素材分类和素材管理 +""" +import logging +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from online_dev.screen_design.material_schema import ( + MaterialCategoryCreateIn, + MaterialCategoryUpdateIn, + MaterialCategoryOut, + MaterialCreateIn, + MaterialUpdateIn, + MaterialOut, +) +from online_dev.screen_design.material_service import MaterialCategoryService, MaterialService + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix='/screen/material', tags=['大屏素材']) + + +# ============ 素材分类 API ============ + +@router.get('/categories', response_model=List[MaterialCategoryOut], summary='素材分类列表') +async def list_material_categories(db: AsyncSession = Depends(get_db)): + """获取所有素材分类""" + categories = await MaterialCategoryService.get_all(db) + + result = [] + for cat in categories: + count = await MaterialCategoryService.get_material_count(db, cat.id) + result.append(MaterialCategoryOut.from_orm_model(cat, material_count=count)) + return result + + +@router.post('/categories', response_model=MaterialCategoryOut, summary='创建素材分类') +async def create_material_category( + data: MaterialCategoryCreateIn, + db: AsyncSession = Depends(get_db), +): + """创建素材分类""" + # 检查编码是否已存在 + if await MaterialCategoryService.check_code_exists(db, data.code): + raise HTTPException(status_code=400, detail=f"分类编码 {data.code} 已存在") + + category = await MaterialCategoryService.create(db, data) + await db.commit() + + return MaterialCategoryOut.from_orm_model(category, material_count=0) + + +@router.put('/categories/{category_id}', response_model=MaterialCategoryOut, summary='更新素材分类') +async def update_material_category( + category_id: str, + data: MaterialCategoryUpdateIn, + db: AsyncSession = Depends(get_db), +): + """更新素材分类""" + category = await MaterialCategoryService.get_by_id(db, category_id) + if not category: + raise HTTPException(status_code=404, detail="分类不存在") + + category = await MaterialCategoryService.update(db, category_id, data) + await db.commit() + + count = await MaterialCategoryService.get_material_count(db, category.id) + return MaterialCategoryOut.from_orm_model(category, material_count=count) + + +@router.delete('/categories/{category_id}', summary='删除素材分类') +async def delete_material_category( + category_id: str, + db: AsyncSession = Depends(get_db), +): + """删除素材分类(同时删除分类下的所有素材)""" + category = await MaterialCategoryService.get_by_id(db, category_id) + if not category: + raise HTTPException(status_code=404, detail="分类不存在") + + await MaterialCategoryService.delete_with_materials(db, category_id) + await db.commit() + return {'success': True} + + +# ============ 素材 API ============ + +async def _get_file_info(db: AsyncSession, file_id: str): + """获取文件信息""" + file_url = '' + file_type = 'image' + file_size = 0 + + if file_id: + try: + from core.file_manager.model import FileManager + stmt = select(FileManager).where(FileManager.id == file_id) + file_result = await db.execute(stmt) + file_obj = file_result.scalar_one_or_none() + if file_obj: + file_url = file_obj.url or '' + file_size = file_obj.size or 0 + file_ext = (file_obj.file_ext or '').lower() + file_type = 'svg' if file_ext == 'svg' or file_ext == '.svg' else 'image' + except Exception as e: + logger.warning(f"获取文件信息失败: {e}") + + return file_url, file_type, file_size + + +@router.get('/list', response_model=List[MaterialOut], summary='素材列表') +async def list_materials( + category_id: str = Query(default=None, alias="categoryId", description="分类ID"), + db: AsyncSession = Depends(get_db), +): + """获取素材列表""" + materials = await MaterialService.get_list_by_category(db, category_id) + + result = [] + for material in materials: + # 获取分类名称 + category_name = '' + if material.category_id: + category = await MaterialCategoryService.get_by_id(db, material.category_id) + if category: + category_name = category.name + + # 获取文件信息 + file_url, file_type, file_size = await _get_file_info(db, material.file_id) + + result.append(MaterialOut.from_orm_model( + material, + category_name=category_name, + file_url=file_url, + file_type=file_type, + file_size=file_size, + )) + return result + + +@router.post('/create', response_model=MaterialOut, summary='创建素材') +async def create_material( + data: MaterialCreateIn, + db: AsyncSession = Depends(get_db), +): + """创建素材(关联已上传的文件)""" + # 验证分类(category_id 为空时表示无分类素材) + category = None + if data.category_id: + category = await MaterialCategoryService.get_by_id(db, data.category_id) + if not category: + raise HTTPException(status_code=400, detail="分类不存在") + + # 验证文件 + try: + from core.file_manager.model import FileManager + stmt = select(FileManager).where(FileManager.id == data.file_id) + file_result = await db.execute(stmt) + file_obj = file_result.scalar_one_or_none() + if not file_obj: + raise HTTPException(status_code=400, detail="文件不存在") + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=400, detail=f"验证文件失败: {e}") + + # 创建素材 + material = await MaterialService.create(db, data) + await db.commit() + + # 获取文件信息 + file_url = file_obj.url or '' + file_size = file_obj.size or 0 + file_ext = (file_obj.file_ext or '').lower() + file_type = 'svg' if file_ext == 'svg' or file_ext == '.svg' else 'image' + + return MaterialOut.from_orm_model( + material, + category_name=category.name if category else '', + file_url=file_url, + file_type=file_type, + file_size=file_size, + ) + + +@router.put('/{material_id}', response_model=MaterialOut, summary='更新素材') +async def update_material( + material_id: str, + data: MaterialUpdateIn, + db: AsyncSession = Depends(get_db), +): + """更新素材信息""" + material = await MaterialService.get_by_id(db, material_id) + if not material: + raise HTTPException(status_code=404, detail="素材不存在") + + # 如果更新分类,验证分类存在 + if data.category_id: + category = await MaterialCategoryService.get_by_id(db, data.category_id) + if not category: + raise HTTPException(status_code=400, detail="分类不存在") + + material = await MaterialService.update(db, material_id, data) + await db.commit() + + # 获取分类名称 + category_name = '' + if material.category_id: + category = await MaterialCategoryService.get_by_id(db, material.category_id) + if category: + category_name = category.name + + # 获取文件信息 + file_url, file_type, file_size = await _get_file_info(db, material.file_id) + + return MaterialOut.from_orm_model( + material, + category_name=category_name, + file_url=file_url, + file_type=file_type, + file_size=file_size, + ) + + +@router.delete('/{material_id}', summary='删除素材') +async def delete_material( + material_id: str, + db: AsyncSession = Depends(get_db), +): + """删除素材""" + material = await MaterialService.get_by_id(db, material_id) + if not material: + raise HTTPException(status_code=404, detail="素材不存在") + + await MaterialService.delete(db, material_id) + await db.commit() + return {'success': True} + + +@router.delete('/batch', summary='批量删除素材') +async def batch_delete_materials( + ids: List[str] = Query(..., description="素材ID列表"), + db: AsyncSession = Depends(get_db), +): + """批量删除素材""" + count = await MaterialService.batch_delete(db, ids) + await db.commit() + return {'count': count} diff --git a/backend-fastapi/online_dev/screen_design/material_model.py b/backend-fastapi/online_dev/screen_design/material_model.py new file mode 100644 index 0000000..d35c3e8 --- /dev/null +++ b/backend-fastapi/online_dev/screen_design/material_model.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +大屏素材数据模型 +""" +from sqlalchemy import Column, String + +from app.base_model import BaseModel + + +class ScreenMaterialCategory(BaseModel): + """大屏素材分类""" + __tablename__ = "screen_material_category" + + name = Column(String(50), nullable=False, comment="分类名称") + code = Column(String(50), unique=True, index=True, nullable=False, comment="分类编码") + icon = Column(String(50), default='Image', comment="图标名称") + + def __repr__(self): + return f"" + + +class ScreenMaterial(BaseModel): + """大屏素材""" + __tablename__ = "screen_material" + + category_id = Column(String(32), nullable=True, index=True, comment="分类ID") + name = Column(String(100), nullable=False, comment="素材名称") + file_id = Column(String(32), nullable=True, comment="文件ID(关联文件管理器)") + + def __repr__(self): + return f"" diff --git a/backend-fastapi/online_dev/screen_design/material_schema.py b/backend-fastapi/online_dev/screen_design/material_schema.py new file mode 100644 index 0000000..50dbfb2 --- /dev/null +++ b/backend-fastapi/online_dev/screen_design/material_schema.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +大屏素材 Schema 定义 +""" +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +# ============ 素材分类 Schema ============ + +class MaterialCategoryCreateIn(BaseModel): + """创建素材分类请求""" + name: str = Field(..., description="分类名称") + code: str = Field(..., description="分类编码") + icon: str = Field(default='Image', description="图标名称") + sort: int = Field(default=0, description="排序") + + +class MaterialCategoryUpdateIn(BaseModel): + """更新素材分类请求""" + name: Optional[str] = Field(default=None, description="分类名称") + icon: Optional[str] = Field(default=None, description="图标名称") + sort: Optional[int] = Field(default=None, description="排序") + + +class MaterialCategoryOut(BaseModel): + """素材分类输出""" + id: str + name: str + code: str + icon: str + sort: int + material_count: int = Field(default=0, description="素材数量") + + model_config = ConfigDict(from_attributes=True) + + @classmethod + def from_orm_model(cls, obj, material_count: int = 0): + """从ORM模型转换""" + return cls( + id=str(obj.id), + name=obj.name, + code=obj.code, + icon=obj.icon or 'Image', + sort=obj.sort or 0, + material_count=material_count, + ) + + +# ============ 素材 Schema ============ + +class MaterialCreateIn(BaseModel): + """创建素材请求""" + category_id: Optional[str] = Field(default=None, description="分类ID(为空表示无分类)") + name: str = Field(..., description="素材名称") + file_id: str = Field(..., description="文件ID(来自文件管理器)") + + +class MaterialUpdateIn(BaseModel): + """更新素材请求""" + name: Optional[str] = Field(default=None, description="素材名称") + category_id: Optional[str] = Field(default=None, description="分类ID") + sort: Optional[int] = Field(default=None, description="排序") + + +class MaterialOut(BaseModel): + """素材输出""" + id: str + name: str + category_id: str + category_name: str = Field(default='', description="分类名称") + file_id: Optional[str] = Field(default=None, description="文件ID") + file_url: str = Field(default='', description="文件URL") + file_type: str = Field(default='image', description="文件类型") + file_size: int = Field(default=0, description="文件大小") + sort: int = 0 + sys_create_datetime: str + + model_config = ConfigDict(from_attributes=True) + + @classmethod + def from_orm_model( + cls, + obj, + category_name: str = '', + file_url: str = '', + file_type: str = 'image', + file_size: int = 0, + ): + """从ORM模型转换""" + return cls( + id=str(obj.id), + name=obj.name, + category_id=str(obj.category_id) if obj.category_id else '', + category_name=category_name, + file_id=str(obj.file_id) if obj.file_id else None, + file_url=file_url, + file_type=file_type, + file_size=file_size, + sort=obj.sort or 0, + sys_create_datetime=obj.sys_create_datetime.strftime('%Y-%m-%d %H:%M:%S') if obj.sys_create_datetime else '', + ) diff --git a/backend-fastapi/online_dev/screen_design/material_service.py b/backend-fastapi/online_dev/screen_design/material_service.py new file mode 100644 index 0000000..83e1536 --- /dev/null +++ b/backend-fastapi/online_dev/screen_design/material_service.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +大屏素材服务 +""" +import logging +from typing import List + +from sqlalchemy import select, func, delete +from sqlalchemy.ext.asyncio import AsyncSession + +from app.base_service import BaseService +from online_dev.screen_design.material_model import ScreenMaterialCategory, ScreenMaterial +from online_dev.screen_design.material_schema import ( + MaterialCategoryCreateIn, MaterialCategoryUpdateIn, + MaterialCreateIn, MaterialUpdateIn, +) + +logger = logging.getLogger(__name__) + + +class MaterialCategoryService(BaseService[ScreenMaterialCategory, MaterialCategoryCreateIn, MaterialCategoryUpdateIn]): + """素材分类服务""" + + model = ScreenMaterialCategory + + @classmethod + async def get_all(cls, db: AsyncSession) -> List[ScreenMaterialCategory]: + """获取所有素材分类""" + stmt = select(ScreenMaterialCategory).where( + ScreenMaterialCategory.is_deleted == False + ).order_by(ScreenMaterialCategory.sort.asc(), ScreenMaterialCategory.sys_create_datetime.desc()) + result = await db.execute(stmt) + return list(result.scalars().all()) + + @classmethod + async def check_code_exists(cls, db: AsyncSession, code: str, exclude_id: str = None) -> bool: + """检查编码是否存在""" + stmt = select(ScreenMaterialCategory).where( + ScreenMaterialCategory.code == code, + ScreenMaterialCategory.is_deleted == False, + ) + if exclude_id: + stmt = stmt.where(ScreenMaterialCategory.id != exclude_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() is not None + + @classmethod + async def get_material_count(cls, db: AsyncSession, category_id: str) -> int: + """获取分类下的素材数量""" + stmt = select(func.count()).where( + ScreenMaterial.category_id == category_id, + ScreenMaterial.is_deleted == False, + ) + result = await db.execute(stmt) + return result.scalar() or 0 + + @classmethod + async def delete_with_materials(cls, db: AsyncSession, category_id: str) -> bool: + """删除分类及其下的所有素材""" + # 删除分类下的所有素材(硬删除) + await db.execute( + delete(ScreenMaterial).where(ScreenMaterial.category_id == category_id) + ) + + # 删除分类(硬删除) + await db.execute( + delete(ScreenMaterialCategory).where(ScreenMaterialCategory.id == category_id) + ) + + return True + + +class MaterialService(BaseService[ScreenMaterial, MaterialCreateIn, MaterialUpdateIn]): + """素材服务""" + + model = ScreenMaterial + + @classmethod + async def get_list_by_category( + cls, + db: AsyncSession, + category_id: str = None, + ) -> List[ScreenMaterial]: + """获取素材列表""" + stmt = select(ScreenMaterial).where(ScreenMaterial.is_deleted == False) + + if category_id: + stmt = stmt.where(ScreenMaterial.category_id == category_id) + + stmt = stmt.order_by(ScreenMaterial.sort.asc(), ScreenMaterial.sys_create_datetime.desc()) + result = await db.execute(stmt) + return list(result.scalars().all()) + + @classmethod + async def batch_delete(cls, db: AsyncSession, material_ids: List[str]) -> int: + """批量删除素材(硬删除)""" + stmt = delete(ScreenMaterial).where(ScreenMaterial.id.in_(material_ids)) + result = await db.execute(stmt) + return result.rowcount diff --git a/backend-fastapi/online_dev/screen_design/screen_api.py b/backend-fastapi/online_dev/screen_design/screen_api.py new file mode 100644 index 0000000..dfa654e --- /dev/null +++ b/backend-fastapi/online_dev/screen_design/screen_api.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +大屏设计器 API +大屏项目的 CRUD、发布、复制、导入导出 +""" +import json +import logging +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.config import settings +from app.base_schema import PaginatedResponse +from online_dev.screen_design.screen_model import ScreenProject +from online_dev.screen_design.screen_schema import ( + ScreenProjectCreateIn, + ScreenProjectUpdateIn, + ScreenProjectOut, + ScreenProjectListOut, + ScreenPublishIn, + ScreenSetPasswordIn, + ScreenImportCheckIn, + ScreenImportCheckOut, + ScreenImportIn, +) +from online_dev.screen_design.screen_service import ScreenProjectService, ScreenServiceException + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix='/screen', tags=['大屏设计器']) + + +@router.get('/list', response_model=PaginatedResponse[ScreenProjectListOut], summary='大屏项目列表') +async def list_projects( + 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", description="每页数量"), + name: str = Query(default=None, description="项目名称"), + code: str = Query(default=None, description="项目编码"), + status: str = Query(default=None, description="状态"), + application_id: str = Query(default=None, alias="applicationId", description="应用ID"), + db: AsyncSession = Depends(get_db), +): + """分页查询大屏项目列表(自动应用数据权限)""" + # 构建过滤条件 + filters = [] + + # 应用过滤 + if application_id is not None: + if application_id == "": + filters.append(ScreenProject.application_id.is_(None)) + else: + filters.append(ScreenProject.application_id == application_id) + + if name: + filters.append(ScreenProject.name.contains(name)) + if code: + filters.append(ScreenProject.code.contains(code)) + if status: + filters.append(ScreenProject.status == status) + + # 使用带数据权限的列表查询 + items, total = await ScreenProjectService.get_list_with_data_scope( + db, page=page, page_size=page_size, filters=filters + ) + # 转换为输出格式 + result_items = [ScreenProjectListOut.from_orm_model(item) for item in items] + return PaginatedResponse(items=result_items, total=total) + + +@router.get('/code/{code}', response_model=ScreenProjectOut, summary='根据编码获取大屏项目') +async def get_project_by_code( + code: str, + db: AsyncSession = Depends(get_db), +): + """根据编码获取大屏项目详情""" + project = await ScreenProjectService.get_by_code(db, code) + if not project: + raise HTTPException(status_code=400, detail=f"大屏项目不存在: {code}") + return ScreenProjectOut.from_orm_model(project) + + +@router.get('/{project_id}', response_model=ScreenProjectOut, summary='大屏项目详情') +async def get_project( + project_id: str, + db: AsyncSession = Depends(get_db), +): + """获取大屏项目详情""" + project = await ScreenProjectService.get_by_id(db, project_id) + if not project: + raise HTTPException(status_code=400, detail="大屏项目不存在") + return ScreenProjectOut.from_orm_model(project) + + +@router.post('', response_model=ScreenProjectOut, summary='创建大屏项目') +async def create_project( + data: ScreenProjectCreateIn, + db: AsyncSession = Depends(get_db), +): + """创建大屏项目""" + # 检查编码唯一性 + if await ScreenProjectService.check_code_exists(db, data.code): + raise HTTPException(status_code=400, detail=f"项目编码已存在: {data.code}") + + project = await ScreenProjectService.create(db, data) + await db.commit() + logger.info(f"大屏项目创建成功: {project.code}") + return ScreenProjectOut.from_orm_model(project) + + +@router.put('/{project_id}', response_model=ScreenProjectOut, summary='更新大屏项目') +async def update_project( + project_id: str, + data: ScreenProjectUpdateIn, + db: AsyncSession = Depends(get_db), +): + """更新大屏项目""" + project = await ScreenProjectService.update(db, project_id, data) + if not project: + raise HTTPException(status_code=400, detail="大屏项目不存在") + await db.commit() + logger.info(f"大屏项目更新成功: {project.code}") + return ScreenProjectOut.from_orm_model(project) + + +@router.delete('/{project_id}', response_model=ScreenProjectOut, summary='删除大屏项目') +async def delete_project( + project_id: str, + db: AsyncSession = Depends(get_db), +): + """删除大屏项目""" + project = await ScreenProjectService.get_by_id(db, project_id) + if not project: + raise HTTPException(status_code=400, detail="大屏项目不存在") + + result = ScreenProjectOut.from_orm_model(project) + await ScreenProjectService.delete(db, project_id) + await db.commit() + return result + + +@router.delete('/batch', summary='批量删除大屏项目') +async def batch_delete_projects( + ids: List[str] = Query(..., description="项目ID列表"), + db: AsyncSession = Depends(get_db), +): + """批量删除大屏项目""" + count = await ScreenProjectService.batch_delete(db, ids) + await db.commit() + return {'count': count} + + +@router.post('/{project_id}/publish', response_model=ScreenProjectOut, summary='发布大屏') +async def publish_project( + project_id: str, + data: ScreenPublishIn = None, + db: AsyncSession = Depends(get_db), +): + """发布大屏""" + try: + access_password = data.access_password if data else None + project = await ScreenProjectService.publish(db, project_id, access_password) + await db.commit() + return ScreenProjectOut.from_orm_model(project) + except ScreenServiceException as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post('/{project_id}/unpublish', response_model=ScreenProjectOut, summary='取消发布') +async def unpublish_project( + project_id: str, + db: AsyncSession = Depends(get_db), +): + """取消发布大屏""" + try: + project = await ScreenProjectService.unpublish(db, project_id) + await db.commit() + return ScreenProjectOut.from_orm_model(project) + except ScreenServiceException as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post('/{project_id}/set-password', response_model=ScreenProjectOut, summary='设置访问密码') +async def set_access_password( + project_id: str, + data: ScreenSetPasswordIn, + db: AsyncSession = Depends(get_db), +): + """设置或清除大屏访问密码""" + try: + project = await ScreenProjectService.set_password(db, project_id, data.access_password) + await db.commit() + return ScreenProjectOut.from_orm_model(project) + except ScreenServiceException as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post('/{project_id}/verify-password', summary='验证访问密码') +async def verify_access_password( + project_id: str, + data: ScreenSetPasswordIn, + db: AsyncSession = Depends(get_db), +): + """验证大屏访问密码""" + try: + is_valid = await ScreenProjectService.verify_password(db, project_id, data.access_password) + return {'valid': is_valid} + except ScreenServiceException as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post('/{project_id}/copy', response_model=ScreenProjectOut, summary='复制大屏项目') +async def copy_project( + project_id: str, + new_code: str = Query(..., alias="newCode", description="新项目编码"), + new_name: str = Query(default=None, alias="newName", description="新项目名称"), + db: AsyncSession = Depends(get_db), +): + """复制大屏项目""" + try: + project = await ScreenProjectService.copy(db, project_id, new_code, new_name) + await db.commit() + return ScreenProjectOut.from_orm_model(project) + except ScreenServiceException as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get('/{project_id}/export', summary='导出大屏配置') +async def export_project_config( + project_id: str, + db: AsyncSession = Depends(get_db), +): + """导出大屏配置为 JSON""" + try: + config = await ScreenProjectService.export_config(db, project_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 ScreenServiceException as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post('/import/check', response_model=ScreenImportCheckOut, summary='导入预检查') +async def check_import_project_config( + data: ScreenImportCheckIn, + db: AsyncSession = Depends(get_db), +): + """导入预检查:检查项目编码是否冲突""" + try: + return await ScreenProjectService.check_import(db, data.code) + except ScreenServiceException as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post('/import', response_model=ScreenProjectOut, summary='导入大屏配置') +async def import_project_config( + data: ScreenImportIn, + db: AsyncSession = Depends(get_db), +): + """导入大屏配置""" + try: + project = await ScreenProjectService.import_config(db, data.model_dump()) + await db.commit() + return ScreenProjectOut.from_orm_model(project) + except ScreenServiceException as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend-fastapi/online_dev/screen_design/screen_model.py b/backend-fastapi/online_dev/screen_design/screen_model.py new file mode 100644 index 0000000..38cd7a4 --- /dev/null +++ b/backend-fastapi/online_dev/screen_design/screen_model.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +大屏设计器数据模型 +""" +from sqlalchemy import Column, String, Text, Integer, JSON, DateTime + +from app.base_model import BaseModel + + +class ScreenProject(BaseModel): + """大屏项目""" + __tablename__ = "screen_project" + + # 所属应用(逻辑外键关联 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, index=True, nullable=False, comment="项目编码") + description = Column(Text, default='', comment="项目描述") + status = Column(String(20), default='draft', comment="状态: draft/published") + version = Column(Integer, default=1, comment="版本号") + + # 缩略图 + thumbnail = Column(Text, default='', comment="缩略图(Base64或URL)") + + # 大屏配置(存储完整的设计器配置) + screen_config = Column(JSON, default=dict, comment="大屏设计配置") + + # 发布相关 + access_password = Column(String(100), default='', comment="访问密码") + published_at = Column(DateTime, nullable=True, comment="发布时间") + + def __repr__(self): + return f"" diff --git a/backend-fastapi/online_dev/screen_design/screen_schema.py b/backend-fastapi/online_dev/screen_design/screen_schema.py new file mode 100644 index 0000000..dad9a0f --- /dev/null +++ b/backend-fastapi/online_dev/screen_design/screen_schema.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +大屏设计器 Schema 定义 +""" +from datetime import datetime +from typing import Any, Dict, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class ScreenProjectBase(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="项目编码(字母开头,只能包含字母、数字和下划线)") + description: str = Field(default='', description="项目描述") + sort: int = Field(default=0, description="排序") + + +class ScreenProjectCreateIn(ScreenProjectBase): + """创建大屏项目请求""" + screen_config: Dict[str, Any] = Field(default_factory=dict, description="大屏设计配置") + thumbnail: str = Field(default='', description="缩略图") + + +class ScreenProjectUpdateIn(BaseModel): + """更新大屏项目请求""" + name: Optional[str] = Field(default=None, description="项目名称") + description: Optional[str] = Field(default=None, description="项目描述") + sort: Optional[int] = Field(default=None, description="排序") + screen_config: Optional[Dict[str, Any]] = Field(default=None, description="大屏设计配置") + thumbnail: Optional[str] = Field(default=None, description="缩略图") + + +class ScreenProjectOut(BaseModel): + """大屏项目详情输出""" + id: str + application_id: Optional[str] = None + name: str + code: str + description: str + status: str + version: int + thumbnail: str + screen_config: Dict[str, Any] + sort: int + access_password: str = Field(default='', description="访问密码") + published_at: Optional[str] = Field(default=None, description="发布时间") + sys_create_datetime: str + sys_update_datetime: str + + model_config = ConfigDict(from_attributes=True) + + @classmethod + def from_orm_model(cls, obj): + """从ORM模型转换""" + return cls( + id=str(obj.id), + application_id=obj.application_id, + name=obj.name, + code=obj.code, + description=obj.description or '', + status=obj.status or 'draft', + version=obj.version or 1, + thumbnail=obj.thumbnail or '', + screen_config=obj.screen_config or {}, + sort=obj.sort or 0, + access_password=obj.access_password or '', + published_at=obj.published_at.strftime('%Y-%m-%d %H:%M:%S') if obj.published_at else None, + sys_create_datetime=obj.sys_create_datetime.strftime('%Y-%m-%d %H:%M:%S') if obj.sys_create_datetime else '', + sys_update_datetime=obj.sys_update_datetime.strftime('%Y-%m-%d %H:%M:%S') if obj.sys_update_datetime else '', + ) + + +class ScreenProjectListOut(BaseModel): + """大屏项目列表输出(不含screen_config)""" + id: str + application_id: Optional[str] = None + name: str + code: str + description: str + status: str + version: int + thumbnail: str + sort: int + access_password: str = Field(default='', description="访问密码") + published_at: Optional[str] = Field(default=None, description="发布时间") + sys_create_datetime: str + sys_update_datetime: str + + model_config = ConfigDict(from_attributes=True) + + @classmethod + def from_orm_model(cls, obj): + """从ORM模型转换""" + return cls( + id=str(obj.id), + application_id=obj.application_id, + name=obj.name, + code=obj.code, + description=obj.description or '', + status=obj.status or 'draft', + version=obj.version or 1, + thumbnail=obj.thumbnail or '', + sort=obj.sort or 0, + access_password=obj.access_password or '', + published_at=obj.published_at.strftime('%Y-%m-%d %H:%M:%S') if obj.published_at else None, + sys_create_datetime=obj.sys_create_datetime.strftime('%Y-%m-%d %H:%M:%S') if obj.sys_create_datetime else '', + sys_update_datetime=obj.sys_update_datetime.strftime('%Y-%m-%d %H:%M:%S') if obj.sys_update_datetime else '', + ) + + +class ScreenExportOut(BaseModel): + """大屏配置导出""" + name: str + code: str + description: str + screen_config: Dict[str, Any] + + +class ScreenImportCheckIn(BaseModel): + """大屏导入预检查请求""" + code: str = Field(..., description="项目编码") + + +class ScreenImportCheckOut(BaseModel): + """大屏导入预检查结果""" + code_exists: bool = Field(..., description="项目编码是否已存在") + can_import: bool = Field(..., description="是否可以直接导入(编码不冲突)") + + +class ScreenImportIn(BaseModel): + """大屏配置导入""" + application_id: Optional[str] = Field(None, description="所属应用ID") + name: str = Field(..., description="项目名称") + code: str = Field(..., description="项目编码") + description: str = Field(default='', description="项目描述") + screen_config: Dict[str, Any] = Field(default_factory=dict, description="大屏设计配置") + + +class ScreenPublishIn(BaseModel): + """发布大屏请求""" + access_password: Optional[str] = Field(default=None, description="访问密码") + + +class ScreenSetPasswordIn(BaseModel): + """设置访问密码请求""" + access_password: str = Field(default='', description="访问密码,空字符串表示清除密码") diff --git a/backend-fastapi/online_dev/screen_design/screen_service.py b/backend-fastapi/online_dev/screen_design/screen_service.py new file mode 100644 index 0000000..2a5eac6 --- /dev/null +++ b/backend-fastapi/online_dev/screen_design/screen_service.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +大屏设计器服务 +""" +import logging +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy import select, func, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.base_service import BaseService +from online_dev.screen_design.screen_model import ScreenProject +from online_dev.screen_design.screen_schema import ScreenProjectCreateIn, ScreenProjectUpdateIn + +logger = logging.getLogger(__name__) + + +class ScreenServiceException(Exception): + """大屏服务异常""" + pass + + +class ScreenProjectService(BaseService[ScreenProject, ScreenProjectCreateIn, ScreenProjectUpdateIn]): + """ + 大屏项目服务 + + 数据权限: + - 使用 get_list_with_data_scope() 自动应用数据权限 + - 支持本人、本部门、本部门及下级、全部等数据范围 + """ + + model = ScreenProject + + # 资源类型(用于数据权限配置) + RESOURCE_TYPE = "screen_project" + RESOURCE_DISPLAY_NAME = "大屏管理" + + @classmethod + async def get_list( + cls, + db: AsyncSession, + page: int = 1, + page_size: int = 20, + name: str = None, + code: str = None, + status: str = None, + application_id: str = None, + ) -> Tuple[List[ScreenProject], int]: + """获取大屏项目列表""" + stmt = select(ScreenProject).where(ScreenProject.is_deleted == False) + + # 应用过滤 + if application_id is not None: + if application_id == "": + stmt = stmt.where(ScreenProject.application_id.is_(None)) + else: + stmt = stmt.where(ScreenProject.application_id == application_id) + + if name: + stmt = stmt.where(ScreenProject.name.contains(name)) + if code: + stmt = stmt.where(ScreenProject.code.contains(code)) + if status: + stmt = stmt.where(ScreenProject.status == status) + + # 计算总数 + count_stmt = select(func.count()).select_from(stmt.subquery()) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # 分页 + stmt = stmt.order_by(ScreenProject.sort.asc(), ScreenProject.sys_create_datetime.desc()) + stmt = stmt.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[ScreenProject]: + """根据编码获取大屏项目""" + stmt = select(ScreenProject).where( + ScreenProject.code == code, + ScreenProject.is_deleted == False, + ) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + @classmethod + async def check_code_exists(cls, db: AsyncSession, code: str, exclude_id: str = None) -> bool: + """检查编码是否存在""" + stmt = select(ScreenProject).where( + ScreenProject.code == code, + ScreenProject.is_deleted == False, + ) + if exclude_id: + stmt = stmt.where(ScreenProject.id != exclude_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() is not None + + @classmethod + async def publish(cls, db: AsyncSession, project_id: str, access_password: str = None) -> ScreenProject: + """发布大屏""" + project = await cls.get_by_id(db, project_id) + if not project: + raise ScreenServiceException(f"大屏项目不存在: {project_id}") + + if project.status == 'published': + raise ScreenServiceException("大屏已发布") + + project.status = 'published' + project.version += 1 + project.published_at = datetime.now() + + if access_password is not None: + project.access_password = access_password + + db.add(project) + await db.flush() + await db.refresh(project) + + logger.info(f"大屏发布成功: {project.code}, version={project.version}") + return project + + @classmethod + async def unpublish(cls, db: AsyncSession, project_id: str) -> ScreenProject: + """取消发布大屏""" + project = await cls.get_by_id(db, project_id) + if not project: + raise ScreenServiceException(f"大屏项目不存在: {project_id}") + + if project.status == 'draft': + raise ScreenServiceException("大屏未发布") + + project.status = 'draft' + db.add(project) + await db.flush() + await db.refresh(project) + + logger.info(f"大屏取消发布: {project.code}") + return project + + @classmethod + async def set_password(cls, db: AsyncSession, project_id: str, password: str) -> ScreenProject: + """设置或清除访问密码""" + project = await cls.get_by_id(db, project_id) + if not project: + raise ScreenServiceException(f"大屏项目不存在: {project_id}") + + project.access_password = password + db.add(project) + await db.flush() + await db.refresh(project) + + logger.info(f"大屏密码已{'设置' if password else '清除'}: {project.code}") + return project + + @classmethod + async def verify_password(cls, db: AsyncSession, project_id: str, password: str) -> bool: + """验证访问密码""" + project = await cls.get_by_id(db, project_id) + if not project: + raise ScreenServiceException(f"大屏项目不存在: {project_id}") + + # 如果没有设置密码,直接返回 True + if not project.access_password: + return True + + return project.access_password == password + + @classmethod + async def copy( + cls, + db: AsyncSession, + project_id: str, + new_code: str, + new_name: str = None, + ) -> ScreenProject: + """复制大屏项目""" + source = await cls.get_by_id(db, project_id) + if not source: + raise ScreenServiceException(f"大屏项目不存在: {project_id}") + + # 检查新编码唯一性 + if await cls.check_code_exists(db, new_code): + raise ScreenServiceException(f"项目编码已存在: {new_code}") + + new_project = ScreenProject( + application_id=source.application_id, + name=new_name or f"{source.name}_副本", + code=new_code, + description=source.description, + status='draft', + version=1, + thumbnail=source.thumbnail, + screen_config=source.screen_config, + sort=source.sort, + ) + db.add(new_project) + await db.flush() + await db.refresh(new_project) + + logger.info(f"大屏项目复制成功: {source.code} -> {new_code}") + return new_project + + @classmethod + async def export_config(cls, db: AsyncSession, project_id: str) -> Dict[str, Any]: + """导出大屏配置""" + project = await cls.get_by_id(db, project_id) + if not project: + raise ScreenServiceException(f"大屏项目不存在: {project_id}") + + return { + 'name': project.name, + 'code': project.code, + 'description': project.description, + 'screen_config': project.screen_config, + } + + @classmethod + async def check_import(cls, db: AsyncSession, code: str) -> Dict[str, Any]: + """导入预检查:编码是否冲突""" + code_exists = False + if code: + code_exists = await cls.check_code_exists(db, code) + + return { + 'code_exists': code_exists, + 'can_import': not code_exists, + } + + @classmethod + async def import_config(cls, db: AsyncSession, data: Dict[str, Any]) -> ScreenProject: + """导入大屏配置""" + required_fields = ['name', 'code'] + for field in required_fields: + if not data.get(field): + raise ScreenServiceException(f"缺少必要字段: {field}") + + if await cls.check_code_exists(db, data['code']): + raise ScreenServiceException(f"项目编码已存在: {data['code']}") + + project = ScreenProject( + application_id=data.get('application_id'), + name=data['name'], + code=data['code'], + description=data.get('description', ''), + screen_config=data.get('screen_config', {}), + ) + db.add(project) + await db.flush() + await db.refresh(project) + + logger.info(f"大屏项目导入成功: {project.code}") + return project + + @classmethod + async def batch_delete(cls, db: AsyncSession, project_ids: List[str]) -> int: + """批量删除大屏项目""" + stmt = ( + update(ScreenProject) + .where(ScreenProject.id.in_(project_ids), ScreenProject.is_deleted == False) + .values(is_deleted=True) + ) + result = await db.execute(stmt) + count = result.rowcount + + logger.info(f"批量删除大屏项目成功: {count} 个") + return count diff --git a/backend-fastapi/online_dev/workflow/__init__.py b/backend-fastapi/online_dev/workflow/__init__.py new file mode 100644 index 0000000..17d519e --- /dev/null +++ b/backend-fastapi/online_dev/workflow/__init__.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +工作流模块 +""" +from online_dev.workflow.model import ( + WorkflowDefinition, + WorkflowInstance, + WorkflowTask, + WorkflowLog, +) + +__all__ = [ + 'WorkflowDefinition', + 'WorkflowInstance', + 'WorkflowTask', + 'WorkflowLog', +] diff --git a/backend-fastapi/online_dev/workflow/api.py b/backend-fastapi/online_dev/workflow/api.py new file mode 100644 index 0000000..ebce486 --- /dev/null +++ b/backend-fastapi/online_dev/workflow/api.py @@ -0,0 +1,1498 @@ +#!/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 + +logger = logging.getLogger(__name__) +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from core.application.model import Application +from app.config import settings +from app.base_schema import PaginatedResponse, ResponseModel +from online_dev.workflow.schema import ( + WorkflowDefinitionCreate, + WorkflowDefinitionUpdate, + WorkflowDefinitionOut, + WorkflowDefinitionListOut, + WorkflowImportCheckIn, + WorkflowImportCheckOut, + WorkflowImportIn, + WorkflowInstanceCreate, + WorkflowInstanceOut, + WorkflowInstanceListOut, + WorkflowTaskOut, + WorkflowTaskListOut, + WorkflowLogOut, + TaskApproveInput, + TaskHandleInput, + TaskReviseInput, + TaskTransferInput, + TaskDelegateInput, + TaskAddSignInput, + MessageResponse, + CountResponse, +) +from online_dev.workflow.service import ( + WorkflowDefinitionService, + WorkflowInstanceService, + WorkflowTaskService, + WorkflowLogService, +) + +router = APIRouter(prefix="/workflow", tags=["工作流管理"]) + + +# ==================== 辅助函数 ==================== + +def _get_action_label(action: str) -> str: + """获取操作标签""" + labels = { + 'approve': '通过', + 'reject': '拒绝', + 'return': '驳回', + 'transfer': '转办', + 'delegate': '委派', + 'add_sign': '加签', + 'reduce_sign': '减签', + } + return labels.get(action, action) + +async def _build_definition_out(db: AsyncSession, workflow) -> dict: + """构建流程定义输出""" + return { + "id": str(workflow.id), + "application_id": workflow.application_id, + "name": workflow.name, + "code": workflow.code, + "workflow_type": workflow.workflow_type or "other", + "icon": workflow.icon or "", + "icon_bg_color": workflow.icon_bg_color or "", + "category": workflow.category or "", + "description": workflow.description or "", + "status": workflow.status, + "version": workflow.version, + "form_code": workflow.form_code, + "form_name": workflow.form_name or "", + "flow_definition": workflow.flow_definition or {}, + "sort": workflow.sort or 0, + "created_at": workflow.sys_create_datetime, + "updated_at": workflow.sys_update_datetime, + } + + +def _build_definition_list_out(workflow, application_name: str = "") -> dict: + """构建流程定义列表输出""" + return { + "id": str(workflow.id), + "application_id": workflow.application_id, + "application_name": application_name, + "name": workflow.name, + "code": workflow.code, + "workflow_type": workflow.workflow_type or "other", + "icon": workflow.icon or "", + "icon_bg_color": workflow.icon_bg_color or "", + "category": workflow.category or "", + "description": workflow.description or "", + "status": workflow.status, + "version": workflow.version, + "form_code": workflow.form_code, + "form_name": workflow.form_name or "", + "sort": workflow.sort or 0, + "created_at": workflow.sys_create_datetime, + "updated_at": workflow.sys_update_datetime, + } + + +async def _build_instance_out(db: AsyncSession, instance) -> dict: + """构建流程实例输出""" + from online_dev.workflow.model import WorkflowDefinition + from core.user.model import User + from sqlalchemy import select + + # 获取流程定义 + stmt = select(WorkflowDefinition).where(WorkflowDefinition.id == instance.workflow_id) + result = await db.execute(stmt) + workflow = result.scalar_one_or_none() + + # 获取发起人 + stmt = select(User).where(User.id == instance.initiator_id) + result = await db.execute(stmt) + initiator = result.scalar_one_or_none() + + # 判断流程是否绑定了单据模板(通过 DocumentTemplate.workflow_code 查找) + has_document_templates = False + if workflow: + from online_dev.document_generator.service import DocumentTemplateService + bound_templates = await DocumentTemplateService.get_by_workflow_code(db, workflow.code) + has_document_templates = len(bound_templates) > 0 + + return { + "id": str(instance.id), + "instance_no": instance.instance_no, + "title": instance.title, + "status": instance.status, + "workflow_id": str(instance.workflow_id), + "workflow_name": workflow.name if workflow else "", + "workflow_code": workflow.code if workflow else "", + "form_code": instance.form_code, + "form_data_id": instance.form_data_id, + "current_node_id": instance.current_node_id or "", + "current_node_name": instance.current_node_name or "", + "initiator_id": str(instance.initiator_id), + "initiator_name": initiator.name if initiator else "", + "started_at": instance.started_at, + "completed_at": instance.completed_at, + "has_document_templates": has_document_templates, + } + + +async def _build_instance_list_out(db: AsyncSession, instance) -> dict: + """构建流程实例列表输出""" + from online_dev.workflow.model import WorkflowDefinition + from core.user.model import User + from sqlalchemy import select + + # 获取流程定义 + stmt = select(WorkflowDefinition).where(WorkflowDefinition.id == instance.workflow_id) + result = await db.execute(stmt) + workflow = result.scalar_one_or_none() + + # 获取发起人 + stmt = select(User).where(User.id == instance.initiator_id) + result = await db.execute(stmt) + initiator = result.scalar_one_or_none() + + # 判断流程是否绑定了单据模板(通过 DocumentTemplate.workflow_code 查找) + has_document_templates = False + application_name = "" + if workflow: + from online_dev.document_generator.service import DocumentTemplateService + bound_templates = await DocumentTemplateService.get_by_workflow_code(db, workflow.code) + has_document_templates = len(bound_templates) > 0 + if workflow.application_id: + from core.application.model import Application + app_stmt = select(Application).where(Application.id == workflow.application_id) + app_result = await db.execute(app_stmt) + app = app_result.scalar_one_or_none() + application_name = app.name if app else "" + + return { + "id": str(instance.id), + "instance_no": instance.instance_no, + "title": instance.title, + "status": instance.status, + "workflow_name": workflow.name if workflow else "", + "application_name": application_name, + "current_node_name": instance.current_node_name or "", + "initiator_name": initiator.name if initiator else "", + "started_at": instance.started_at, + "completed_at": instance.completed_at, + "has_document_templates": has_document_templates, + } + + +async def _build_task_out(db: AsyncSession, task, include_form: bool = False) -> dict: + """构建任务输出""" + from online_dev.workflow.model import WorkflowInstance, WorkflowDefinition, WorkflowLog + from core.user.model import User + from sqlalchemy import select + + # 获取实例 + stmt = select(WorkflowInstance).where(WorkflowInstance.id == task.instance_id) + result = await db.execute(stmt) + instance = result.scalar_one_or_none() + + # 获取流程定义 + workflow = None + if instance: + stmt = select(WorkflowDefinition).where(WorkflowDefinition.id == instance.workflow_id) + result = await db.execute(stmt) + workflow = result.scalar_one_or_none() + + # 缓存 workflow 属性,避免后续 service.get() 内部 db.rollback() 导致 session 对象过期 + _workflow_name = workflow.name if workflow else "" + _flow_definition = workflow.flow_definition if workflow else {} + + # 获取处理人 + stmt = select(User).where(User.id == task.assignee_id) + result = await db.execute(stmt) + assignee = result.scalar_one_or_none() + + # 获取发起人 + initiator = None + if instance: + stmt = select(User).where(User.id == instance.initiator_id) + result = await db.execute(stmt) + initiator = result.scalar_one_or_none() + + # 判断取消原因 + cancel_reason = "" + if task.status == 'canceled': + if (task.comment or '') == '__or_sign_canceled__': + cancel_reason = "or_sign" + else: + cancel_reason = "revoked" + + # 判断流程是否绑定了单据模板 + has_document_templates = False + if workflow: + from online_dev.document_generator.service import DocumentTemplateService + bound_templates = await DocumentTemplateService.get_by_workflow_code(db, workflow.code) + has_document_templates = len(bound_templates) > 0 + + out = { + "id": str(task.id), + "instance_id": str(task.instance_id), + "instance_no": instance.instance_no if instance else "", + "instance_title": instance.title if instance else "", + "instance_status": instance.status if instance else "", + "node_id": task.node_id, + "node_name": task.node_name, + "task_type": task.task_type, + "status": task.status, + "assignee_id": str(task.assignee_id), + "assignee_name": assignee.name if assignee else "", + "comment": "" if cancel_reason == "or_sign" else (task.comment or ""), + "cancel_reason": cancel_reason, + "handled_at": task.handled_at, + "sys_create_datetime": task.sys_create_datetime, + "workflow_name": _workflow_name, + "initiator_name": initiator.name if initiator else "", + "started_at": instance.started_at if instance else None, + "has_document_templates": has_document_templates, + } + + if include_form: + form_code = instance.form_code if instance else None + form_data_id = instance.form_data_id if instance else None + out["form_code"] = form_code + + # 获取表单元数据(form_config) + form_config = None + if form_code: + try: + from online_dev.form_manager.model import FormMeta + stmt = select(FormMeta).where( + FormMeta.code == form_code, + FormMeta.is_deleted == False, + ) + result = await db.execute(stmt) + form_meta = result.scalar_one_or_none() + if form_meta: + form_config = form_meta.form_config + except Exception as e: + import logging + logging.warning(f"获取表单配置失败: {e}") + out["form_config"] = form_config + + # 获取表单数据(form_data) + form_data = {} + if form_code and form_data_id: + try: + from online_dev.form_data_manager.service import FormDataService + service = await FormDataService.create_service(db, form_code) + form_data = await service.get(db, form_data_id) + except Exception as e: + import logging + logging.warning(f"获取表单数据失败: {e}") + out["form_data"] = form_data + + # 获取当前节点的字段权限和操作权限 + form_permissions = [] + action_permissions = [] + flow_def = _flow_definition + nodes = flow_def.get('nodes', {}) + + # 递归查找节点 + def find_node(node, target_id): + if not node: + return None + if isinstance(node, dict): + if node.get('id') == target_id: + return node + children = node.get('children') + if children: + found = find_node(children, target_id) + if found: + return found + branches = node.get('branches', []) + for branch in branches: + if branch.get('id') == target_id: + return branch + branch_children = branch.get('children') + if branch_children: + found = find_node(branch_children, target_id) + if found: + return found + elif isinstance(node, list): + for item in node: + found = find_node(item, target_id) + if found: + return found + return None + + current_node = find_node(nodes, task.node_id) + + if current_node: + node_config = current_node.get('config', {}) + form_permissions = node_config.get('formPermissions', []) + action_permissions = node_config.get('actionPermissions', []) + # 如果没有新格式的配置,从旧的 actions 数组生成 + if not action_permissions: + actions = node_config.get('actions', ['approve', 'reject']) + action_permissions = [ + {'action': a, 'enabled': True, 'label': _get_action_label(a)} + for a in actions + ] + + # 默认操作权限 + if not action_permissions: + action_permissions = [ + {'action': 'approve', 'enabled': True, 'label': '通过'}, + {'action': 'reject', 'enabled': True, 'label': '拒绝'}, + ] + + out["form_permissions"] = form_permissions + out["action_permissions"] = action_permissions + + # 获取签名配置 + require_signature = False + if current_node: + node_config = current_node.get('config', {}) + require_signature = node_config.get('requireSignature', False) + out["require_signature"] = require_signature + + # 获取最后一条驳回日志 + last_return_log = None + if task.task_type == 'revise': + stmt = select(WorkflowLog).where( + WorkflowLog.instance_id == task.instance_id, + WorkflowLog.action == 'return', + ).order_by(WorkflowLog.sys_create_datetime.desc()).limit(1) + result = await db.execute(stmt) + log = result.scalar_one_or_none() + if log: + # 获取操作人 + stmt = select(User).where(User.id == log.operator_id) + result = await db.execute(stmt) + operator = result.scalar_one_or_none() + last_return_log = { + "operator_name": operator.name if operator else "", + "comment": log.comment or "", + "created_at": log.sys_create_datetime, + } + out["last_return_log"] = last_return_log + + return out + + +async def _build_task_list_out(db: AsyncSession, task) -> dict: + """构建任务列表输出""" + from online_dev.workflow.model import WorkflowInstance, WorkflowDefinition + from core.user.model import User + from sqlalchemy import select + from datetime import datetime + + # 获取实例 + stmt = select(WorkflowInstance).where(WorkflowInstance.id == task.instance_id) + result = await db.execute(stmt) + instance = result.scalar_one_or_none() + + # 获取流程定义 + workflow = None + if instance: + stmt = select(WorkflowDefinition).where(WorkflowDefinition.id == instance.workflow_id) + result = await db.execute(stmt) + workflow = result.scalar_one_or_none() + + # 获取处理人 + stmt = select(User).where(User.id == task.assignee_id) + result = await db.execute(stmt) + assignee = result.scalar_one_or_none() + + # 获取发起人 + initiator = None + if instance: + stmt = select(User).where(User.id == instance.initiator_id) + result = await db.execute(stmt) + initiator = result.scalar_one_or_none() + + # 计算是否超时 + is_timeout = False + if task.timeout_at and task.status == 'pending': + is_timeout = datetime.now() > task.timeout_at + + # 判断流程是否绑定了单据模板 + has_document_templates = False + if workflow: + from online_dev.document_generator.service import DocumentTemplateService + bound_templates = await DocumentTemplateService.get_by_workflow_code(db, workflow.code) + has_document_templates = len(bound_templates) > 0 + + return { + "id": str(task.id), + "instance_id": str(task.instance_id), + "instance_no": instance.instance_no if instance else "", + "instance_title": instance.title if instance else "", + "instance_status": instance.status if instance else "", + "node_name": task.node_name, + "task_type": task.task_type, + "status": task.status, + "assignee_id": str(task.assignee_id), + "assignee_name": assignee.name if assignee else "", + "workflow_name": workflow.name if workflow else "", + "initiator_name": initiator.name if initiator else "", + "started_at": instance.started_at if instance else None, + "sys_create_datetime": task.sys_create_datetime, + "has_document_templates": has_document_templates, + "timeout_at": task.timeout_at, + "timeout_action": task.timeout_action or "", + "is_timeout": is_timeout, + "workflow_icon": workflow.icon if workflow else "", + "workflow_icon_bg_color": workflow.icon_bg_color if workflow else "", + } + + +async def _build_log_out(db: AsyncSession, log) -> dict: + """构建日志输出""" + 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() + + return { + "id": str(log.id), + "node_id": log.node_id or "", + "node_name": log.node_name or "", + "action": log.action, + "operator_id": str(log.operator_id), + "operator_name": operator.name if operator else "", + "comment": log.comment or "", + "extra_data": log.extra_data or {}, + "sys_create_datetime": log.sys_create_datetime, + } + + +# ==================== 流程定义 API ==================== + +@router.get("/list", response_model=PaginatedResponse[WorkflowDefinitionListOut], summary="流程定义列表") +async def get_definitions( + page: int = Query(default=1, ge=1, description="页码"), + page_size: int = Query(default=settings.PAGE_SIZE, ge=1, le=1000, alias="pageSize", description="每页数量"), + application_id: str = Query(default=None, alias="applicationId", description="所属应用ID"), + all_apps: bool = Query(default=False, alias="allApps", description="是否返回所有应用的流程"), + name: str = Query(default=None, description="流程名称"), + code: str = Query(default=None, description="流程编码"), + workflow_type: str = Query(default=None, alias="workflowType", description="流程类型"), + category: str = Query(default=None, description="分类"), + status: str = Query(default=None, description="状态"), + form_code: str = Query(default=None, alias="formCode", description="表单编码"), + db: AsyncSession = Depends(get_db), +): + """获取流程定义列表(自动应用数据权限)""" + items, total = await WorkflowDefinitionService.get_list_with_data_scope( + db, page=page, page_size=page_size, + application_id=application_id, all_apps=all_apps, name=name, code=code, workflow_type=workflow_type, category=category, status=status, form_code=form_code, + ) + + # 批量查询应用名称 + app_ids = list({item.application_id for item in items if item.application_id}) + app_name_map = {} + if app_ids: + app_result = await db.execute( + select(Application.id, Application.name).where(Application.id.in_(app_ids)) + ) + app_name_map = {row.id: row.name for row in app_result} + + result_items = [_build_definition_list_out(item, app_name_map.get(item.application_id, "")) for item in items] + return PaginatedResponse(items=result_items, total=total) + + +@router.post("", response_model=WorkflowDefinitionOut, summary="创建流程") +async def create_definition( + request: Request, + data: WorkflowDefinitionCreate, + db: AsyncSession = Depends(get_db), +): + """创建流程定义""" + # 检查编码唯一性 + existing = await WorkflowDefinitionService.get_by_code(db, data.code) + if existing: + raise HTTPException(status_code=400, detail="流程编码已存在") + + user_id = request.state.user_id + workflow = await WorkflowDefinitionService.create(db, data.model_dump(), user_id) + await db.commit() + return await _build_definition_out(db, workflow) + + +@router.get("/categories", summary="获取分类列表") +async def get_definition_categories(db: AsyncSession = Depends(get_db)): + """获取所有流程分类""" + categories = await WorkflowDefinitionService.get_categories(db) + return {"items": categories} + + +@router.get("/available-forms", summary="获取可关联的表单列表") +async def get_available_forms( + form_type: str = Query(default="workflow", description="表单类型,默认只获取流程表单"), + application_id: str = Query(None, alias="applicationId", description="所属应用ID"), + db: AsyncSession = Depends(get_db), +): + """获取已发布的表单列表供流程关联(按应用隔离,含全局可见跨应用表单)""" + from online_dev.form_manager.model import FormMeta + from online_dev.form_manager.service import FormService + + conditions = [ + FormMeta.status == 'published', + FormMeta.is_deleted == False, + ] + conditions.append( + FormService.build_app_scope_condition( + application_id, + for_selection=True, + ) + ) + + if form_type: + conditions.append(FormMeta.form_type == form_type) + + stmt = ( + select( + FormMeta.code, + FormMeta.name, + FormMeta.application_id, + Application.name.label("application_name"), + ) + .outerjoin(Application, Application.id == FormMeta.application_id) + .where(*conditions) + .order_by(FormMeta.name) + ) + result = await db.execute(stmt) + forms = [ + { + "code": row[0], + "name": row[1], + "application_id": row[2], + "application_name": row[3] or "主应用", + } + for row in result.all() + ] + return forms + + +@router.get("/by-form/{form_code}", response_model=List[WorkflowDefinitionListOut], summary="获取表单关联的流程") +async def get_workflows_by_form( + form_code: str, + db: AsyncSession = Depends(get_db), +): + """获取关联指定表单的已发布流程列表""" + from online_dev.workflow.model import WorkflowDefinition + from sqlalchemy import select + + stmt = select(WorkflowDefinition).where( + WorkflowDefinition.form_code == form_code, + WorkflowDefinition.status == 'published', + WorkflowDefinition.is_deleted == False, + ) + result = await db.execute(stmt) + workflows = result.scalars().all() + return [_build_definition_list_out(w) for w in workflows] + + +@router.get("/code/{code}", response_model=WorkflowDefinitionOut, summary="根据编码获取流程") +async def get_definition_by_code( + code: str, + db: AsyncSession = Depends(get_db), +): + """根据编码获取流程定义""" + workflow = await WorkflowDefinitionService.get_by_code(db, code) + if not workflow: + raise HTTPException(status_code=404, detail="流程不存在") + return await _build_definition_out(db, workflow) + + +@router.get("/{workflow_id}", response_model=WorkflowDefinitionOut, summary="流程定义详情") +async def get_definition( + workflow_id: str, + db: AsyncSession = Depends(get_db), +): + """获取流程定义详情""" + workflow = await WorkflowDefinitionService.get_by_id(db, workflow_id) + if not workflow: + raise HTTPException(status_code=404, detail="流程定义不存在") + return await _build_definition_out(db, workflow) + + +@router.put("/{workflow_id}", response_model=WorkflowDefinitionOut, summary="更新流程") +async def update_definition( + request: Request, + workflow_id: str, + data: WorkflowDefinitionUpdate, + db: AsyncSession = Depends(get_db), +): + """更新流程定义""" + user_id = request.state.user_id + workflow = await WorkflowDefinitionService.update(db, workflow_id, data.model_dump(exclude_unset=True), user_id) + if not workflow: + raise HTTPException(status_code=404, detail="流程定义不存在") + await db.commit() + return await _build_definition_out(db, workflow) + + +@router.delete("/{workflow_id}", response_model=ResponseModel, summary="删除流程") +async def delete_definition( + workflow_id: str, + db: AsyncSession = Depends(get_db), +): + """删除流程定义""" + success = await WorkflowDefinitionService.delete(db, workflow_id) + if not success: + raise HTTPException(status_code=404, detail="流程定义不存在") + await db.commit() + return ResponseModel(message="删除成功") + + +@router.delete("/batch/delete", response_model=CountResponse, summary="批量删除流程") +async def batch_delete_definitions( + ids: List[str] = Query(..., description="要删除的ID列表"), + db: AsyncSession = Depends(get_db), +): + """批量删除流程定义""" + count = await WorkflowDefinitionService.batch_delete(db, ids) + await db.commit() + return CountResponse(count=count) + + +@router.post("/{workflow_id}/publish", response_model=WorkflowDefinitionOut, summary="发布流程") +async def publish_definition( + request: Request, + workflow_id: str, + db: AsyncSession = Depends(get_db), +): + """发布流程""" + user_id = request.state.user_id + try: + workflow = await WorkflowDefinitionService.publish(db, workflow_id, user_id) + if not workflow: + raise HTTPException(status_code=404, detail="流程定义不存在") + await db.commit() + return await _build_definition_out(db, workflow) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/{workflow_id}/disable", response_model=WorkflowDefinitionOut, summary="停用流程") +async def disable_definition( + request: Request, + workflow_id: str, + db: AsyncSession = Depends(get_db), +): + """停用流程""" + user_id = request.state.user_id + workflow = await WorkflowDefinitionService.disable(db, workflow_id, user_id) + if not workflow: + raise HTTPException(status_code=404, detail="流程定义不存在") + await db.commit() + return await _build_definition_out(db, workflow) + + +@router.post("/{workflow_id}/copy", response_model=WorkflowDefinitionOut, summary="复制流程") +async def copy_definition( + request: Request, + workflow_id: str, + new_code: str = Query(..., alias="newCode", description="新流程编码"), + new_name: str = Query(default=None, alias="newName", description="新流程名称"), + db: AsyncSession = Depends(get_db), +): + """复制流程""" + # 检查新编码唯一性 + existing = await WorkflowDefinitionService.get_by_code(db, new_code) + if existing: + raise HTTPException(status_code=400, detail="新流程编码已存在") + + user_id = request.state.user_id + workflow = await WorkflowDefinitionService.copy(db, workflow_id, new_code, new_name, user_id) + if not workflow: + raise HTTPException(status_code=404, detail="流程定义不存在") + await db.commit() + return await _build_definition_out(db, workflow) + + +@router.get("/{workflow_id}/export", summary="导出流程配置") +async def export_workflow_config( + workflow_id: str, + db: AsyncSession = Depends(get_db), +): + """导出流程配置为 JSON 文件""" + config = await WorkflowDefinitionService.export_config(db, workflow_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("/import/check", response_model=WorkflowImportCheckOut, summary="流程导入预检查") +async def check_import_workflow_config( + data: WorkflowImportCheckIn, + db: AsyncSession = Depends(get_db), +): + """导入预检查:检查流程编码是否冲突""" + return await WorkflowDefinitionService.check_import(db, data.code) + + +@router.post("/import", response_model=WorkflowDefinitionOut, summary="导入流程配置") +async def import_workflow_config( + request: Request, + data: WorkflowImportIn, + db: AsyncSession = Depends(get_db), +): + """导入流程配置""" + user_id = request.state.user_id + try: + workflow = await WorkflowDefinitionService.import_config( + db, data.model_dump(), user_id + ) + await db.commit() + return await _build_definition_out(db, workflow) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +# ==================== 流程实例 API ==================== + +@router.post("/instance/start", response_model=WorkflowInstanceOut, summary="发起流程") +async def start_instance( + request: Request, + data: WorkflowInstanceCreate, + db: AsyncSession = Depends(get_db), +): + """发起流程""" + user_id = request.state.user_id + try: + instance = await WorkflowInstanceService.start( + db, + workflow_code=data.workflow_code, + title=data.title, + form_data=data.form_data, + user_id=user_id, + ) + await db.commit() + return await _build_instance_out(db, instance) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/instance/list", response_model=PaginatedResponse[WorkflowInstanceListOut], summary="流程实例列表") +async def get_instances( + 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", description="每页数量"), + status: str = Query(default=None, description="状态"), + title: str = Query(default=None, description="标题"), + workflow_name: str = Query(default=None, alias="workflowName", description="流程名称"), + instance_no: str = Query(default=None, alias="instanceNo", description="流程编号"), + initiator_name: str = Query(default=None, alias="initiatorName", description="发起人姓名"), + workflow_id: str = Query(default=None, alias="workflowId", description="流程定义ID"), + application_id: str = Query(default=None, alias="applicationId", description="所属应用ID"), + db: AsyncSession = Depends(get_db), +): + """获取流程实例列表(需 workflow:instance:list 权限)""" + items, total = await WorkflowInstanceService.get_list( + db, page=page, page_size=page_size, + status=status, title=title, workflow_name=workflow_name, + instance_no=instance_no, initiator_name=initiator_name, + workflow_id=workflow_id, application_id=application_id, + ) + result_items = [await _build_instance_list_out(db, item) for item in items] + return PaginatedResponse(items=result_items, total=total) + + +@router.get("/instance/my-initiated", response_model=PaginatedResponse[WorkflowInstanceListOut], summary="我发起的流程") +async def get_my_instances( + request: Request, + 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", description="每页数量"), + status: str = Query(default=None, description="状态"), + title: str = Query(default=None, description="标题"), + workflow_name: str = Query(default=None, alias="workflowName", description="流程名称"), + db: AsyncSession = Depends(get_db), +): + """获取我发起的流程""" + user_id = request.state.user_id + items, total = await WorkflowInstanceService.get_list( + db, page=page, page_size=page_size, + status=status, initiator_id=user_id, title=title, workflow_name=workflow_name, + ) + result_items = [await _build_instance_list_out(db, item) for item in items] + return PaginatedResponse(items=result_items, total=total) + + +@router.get("/instance/{instance_id}", response_model=WorkflowInstanceOut, summary="流程实例详情") +async def get_instance( + instance_id: str, + db: AsyncSession = Depends(get_db), +): + """获取流程实例详情""" + instance = await WorkflowInstanceService.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/{instance_id}/cancel", response_model=WorkflowInstanceOut, summary="撤回流程") +async def cancel_instance( + request: Request, + instance_id: str, + db: AsyncSession = Depends(get_db), +): + """撤回流程""" + user_id = request.state.user_id + try: + instance = await WorkflowInstanceService.cancel(db, instance_id, user_id) + if not instance: + raise HTTPException(status_code=404, detail="流程实例不存在") + await db.commit() + return await _build_instance_out(db, instance) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/instance/{instance_id}/urge", response_model=MessageResponse, summary="催办") +async def urge_instance( + request: Request, + instance_id: str, + db: AsyncSession = Depends(get_db), +): + """催办流程""" + user_id = request.state.user_id + try: + await WorkflowInstanceService.urge(db, instance_id, user_id) + await db.commit() + return MessageResponse(message="催办成功") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/instance/{instance_id}/logs", response_model=List[WorkflowLogOut], summary="流程日志") +async def get_instance_logs( + instance_id: str, + db: AsyncSession = Depends(get_db), +): + """获取流程实例的操作日志""" + logs = await WorkflowLogService.get_by_instance(db, instance_id) + return [await _build_log_out(db, log) for log in logs] + + +@router.get("/instance/{instance_id}/pending-tasks", response_model=List[WorkflowTaskListOut], summary="流程当前待办") +async def get_instance_pending_tasks( + instance_id: str, + db: AsyncSession = Depends(get_db), +): + """获取流程实例当前待处理的任务""" + tasks = await WorkflowTaskService.get_instance_pending_tasks(db, instance_id) + return [await _build_task_list_out(db, task) for task in tasks] + + +@router.get("/instance/{instance_id}/form-data", summary="获取流程表单数据") +async def get_instance_form_data( + instance_id: str, + db: AsyncSession = Depends(get_db), +): + """获取流程实例的表单数据""" + instance = await WorkflowInstanceService.get_by_id(db, instance_id) + if not instance: + raise HTTPException(status_code=404, detail="流程实例不存在") + + if not instance.form_data_id: + return {} + + try: + from online_dev.form_data_manager.service import FormDataService + + # 创建表单数据服务 + service = await FormDataService.create_service(db, instance.form_code) + # 获取表单数据 + form_data = await service.get(db, instance.form_data_id) + return form_data + except Exception as e: + # 如果获取失败,返回空对象 + return {"error": str(e)} + + +@router.get("/instance/{instance_id}/progress", summary="流程执行进度") +async def get_instance_progress( + instance_id: str, + db: AsyncSession = Depends(get_db), +): + """ + 获取流程实例的执行进度 + + 返回完整的流程节点列表及其状态,包括: + - 节点基本信息(ID、名称、类型) + - 节点状态(completed/active/pending/skipped/rejected) + - 处理人信息(姓名、操作、意见、时间) + - 额外操作(加签、转交、委托) + - 驳回记录 + """ + from online_dev.workflow.progress_service import FlowProgressService + + progress = await FlowProgressService.get_progress(db, instance_id) + if not progress: + raise HTTPException(status_code=404, detail="流程实例不存在") + return progress + + +@router.get("/instance/{instance_id}/documents", summary="获取流程实例关联的单据") +async def get_instance_documents( + instance_id: str, + db: AsyncSession = Depends(get_db), +): + """ + 获取流程实例关联的已生成单据列表 + """ + from online_dev.document_generator.service import GeneratedDocumentService + + # 获取流程实例 + instance = await WorkflowInstanceService.get_by_id(db, instance_id) + if not instance: + raise HTTPException(status_code=404, detail="流程实例不存在") + + # 获取关联的单据 + documents = await GeneratedDocumentService.get_by_instance_id(db, instance_id) + return documents + + +@router.post("/instance/{instance_id}/generate-documents", summary="生成流程实例单据") +async def generate_instance_documents( + request: Request, + instance_id: str, + db: AsyncSession = Depends(get_db), +): + """ + 手动生成流程实例绑定的单据 + """ + from online_dev.document_generator.service import DocumentTemplateService, GeneratedDocumentService + from online_dev.document_generator.generator import pdf_generator + from online_dev.document_generator.calculation_engine import CalculationEngine + from core.file_manager.service import FileManagerService + from online_dev.workflow.engine.utils import FormDataUtils + from online_dev.workflow.model import WorkflowLog + + # 获取流程实例 + instance = await WorkflowInstanceService.get_by_id(db, instance_id) + if not instance: + raise HTTPException(status_code=404, detail="流程实例不存在") + + # 检查流程状态 + if instance.status != 'approved': + raise HTTPException(status_code=400, detail="只有审批通过的流程才能生成单据") + + # 获取流程定义 + workflow = await WorkflowDefinitionService.get_by_id(db, str(instance.workflow_id)) + if not workflow: + raise HTTPException(status_code=404, detail="流程定义不存在") + + # 根据 workflow_code 查找绑定的单据模板 + bound_templates = await DocumentTemplateService.get_by_workflow_code(db, workflow.code) + if not bound_templates: + raise HTTPException(status_code=400, detail="该流程未绑定文档模板") + + # 加载表单数据 + form_data = await FormDataUtils.load_form_data(db, instance.form_code, instance.form_data_id) or {} + + # 加载流程实例数据 + form_data["_instance"] = { + "id": str(instance.id), + "instance_no": instance.instance_no, + "title": instance.title, + "status": instance.status, + "started_at": str(instance.started_at) if instance.started_at else None, + "completed_at": str(instance.completed_at) if instance.completed_at else None, + } + + # 加载审批日志 + 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()) + + form_data["_logs"] = [ + { + "node_name": log.node_name, + "action": log.action, + "comment": log.comment or "", + "created_at": str(log.sys_create_datetime) if log.sys_create_datetime else "", + } + for log in logs + ] + + # 加载审批任务(包含审批人、审批意见、签名) + from online_dev.workflow.model import WorkflowTask + from core.user.model import User + + task_stmt = select(WorkflowTask).where( + WorkflowTask.instance_id == instance_id, + WorkflowTask.task_type.in_(['approval', 'handle']), + WorkflowTask.status.in_(['approved', 'rejected', 'handled']) + ).order_by(WorkflowTask.handled_at) + task_result = await db.execute(task_stmt) + tasks = list(task_result.scalars().all()) + + # 构建审批任务数据 + approval_tasks = [] + for task in tasks: + # 获取审批人信息 + user_stmt = select(User).where(User.id == task.assignee_id) + user_result = await db.execute(user_stmt) + user = user_result.scalar_one_or_none() + + task_data = { + "node_name": task.node_name, + "assignee_name": user.name if user else "", + "assignee_username": user.username if user else "", + "comment": task.comment or "", + "status": task.status, + "handled_at": str(task.handled_at) if task.handled_at else "", + "signature_url": None, + } + + # 如果有签名,获取签名图片的 base64 或 URL + if task.signature_file_id: + try: + signature_data = await FileManagerService.get_file_as_data_url(db, task.signature_file_id) + task_data["signature_url"] = signature_data + except Exception as e: + logger.warning(f"获取签名图片失败: task_id={task.id}, error={e}") + + approval_tasks.append(task_data) + + form_data["_approvals"] = approval_tasks + + # 按节点名称索引审批数据(方便模板按节点名称访问) + approvals_by_node = {} + for task_data in approval_tasks: + node_name = task_data.get("node_name", "") + if node_name: + approvals_by_node[node_name] = task_data + form_data["_approvals_by_node"] = approvals_by_node + + # 加载子表数据(用于聚合计算) + # 子表数据在 form_data 中以 sub_tables 形式存在,需要展开到顶层 + if "sub_tables" in form_data: + for sub_table_name, sub_table_data in form_data["sub_tables"].items(): + form_data[sub_table_name] = sub_table_data + + generated_docs = [] + user_id = request.state.user_id + + # 删除该实例已有的单据(重新生成时替换旧单据) + existing_docs = await GeneratedDocumentService.get_by_instance_id(db, instance_id) + for old_doc in existing_docs: + # 删除关联的文件 + if old_doc.file_id: + await FileManagerService.delete_item(db, old_doc.file_id, hard=True, is_superuser=True) + # 删除单据记录 + await GeneratedDocumentService.delete(db, old_doc.id, hard=True, auto_commit=False) + + # 为每个绑定的模板生成文档 + for template in bound_templates: + try: + # 检查模板内容 + if not template.template_content: + logger.error(f"模板内容为空: {template.code}") + raise HTTPException(status_code=400, detail=f"模板内容为空: {template.code}") + + logger.info(f"生成单据 - 模板: {template.code}, 类型: {template.template_type}, 内容长度: {len(template.template_content)}") + + # 执行计算规则(如果配置了) + template_data = dict(form_data) + if template.calculation_rules: + try: + calculated_values = await CalculationEngine.calculate_all( + template.calculation_rules, + form_data + ) + # 将计算结果合并到模板数据中 + template_data.update(calculated_values) + # 同时添加到 _calculated 命名空间,方便模板引用 + template_data["_calculated"] = calculated_values + logger.info(f"计算完成 - 模板: {template.code}, 计算结果: {calculated_values}") + except Exception as calc_error: + logger.warning(f"计算规则执行失败: {template.code}, 错误: {calc_error}") + + page_config = { + "size": template.page_size, + "orientation": template.page_orientation, + "margin": template.page_margin or {"top": 20, "right": 20, "bottom": 20, "left": 20}, + } + + pdf_bytes = pdf_generator.generate( + template_type=template.template_type, + template_content=template.template_content, + data=template_data, + css=template.template_css, + page_config=page_config, + ) + + # 验证 PDF 文件头 + pdf_header = pdf_bytes[:8] if len(pdf_bytes) >= 8 else pdf_bytes + logger.info(f"PDF 生成成功 - 大小: {len(pdf_bytes)} bytes, 文件头: {pdf_header}") + + if not pdf_bytes.startswith(b'%PDF'): + logger.error(f"生成的 PDF 文件头无效: {pdf_header}") + raise HTTPException(status_code=500, detail="生成的 PDF 文件无效") + + page_count = pdf_generator.get_page_count(pdf_bytes) + + # 保存文件 + document_name = f"{template.name}_{instance.instance_no}" + 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="workflow", + ) + + logger.info(f"文件保存成功 - ID: {file_record.id}, 存储路径: {file_record.storage_path}") + + # 创建文档记录 + doc = 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=instance.form_data_id, + instance_id=instance_id, + generator_id=user_id, + generate_type="manual", + ) + generated_docs.append(doc) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"生成单据失败: {str(e)}") + + await db.commit() + return {"message": f"成功生成 {len(generated_docs)} 个单据", "count": len(generated_docs)} + + +# ==================== 任务 API ==================== + +@router.get("/task/pending", response_model=PaginatedResponse[WorkflowTaskListOut], summary="获取待处理任务") +async def get_pending_tasks( + request: Request, + 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", description="每页数量"), + task_type: str = Query(default=None, alias="taskType", description="任务类型"), + instance_title: str = Query(default=None, alias="instanceTitle", description="流程标题"), + workflow_name: str = Query(default=None, alias="workflowName", description="流程名称"), + db: AsyncSession = Depends(get_db), +): + """获取当前用户的待处理任务""" + user_id = request.state.user_id + items, total = await WorkflowTaskService.get_pending_tasks( + db, user_id=user_id, page=page, page_size=page_size, + task_type=task_type, instance_title=instance_title, workflow_name=workflow_name, + ) + result_items = [await _build_task_list_out(db, item) for item in items] + return PaginatedResponse(items=result_items, total=total) + + +@router.get("/task/pending/count", response_model=CountResponse, summary="获取待处理任务数量") +async def get_pending_tasks_count( + request: Request, + db: AsyncSession = Depends(get_db), +): + """获取当前用户的待处理任务数量""" + user_id = request.state.user_id + count = await WorkflowTaskService.get_pending_count(db, user_id) + return CountResponse(count=count) + + +@router.get("/task/handled", response_model=PaginatedResponse[WorkflowTaskListOut], summary="获取已处理任务") +async def get_handled_tasks( + request: Request, + 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", description="每页数量"), + instance_title: str = Query(default=None, alias="instanceTitle", description="流程标题"), + workflow_name: str = Query(default=None, alias="workflowName", description="流程名称"), + db: AsyncSession = Depends(get_db), +): + """获取当前用户的已处理任务""" + user_id = request.state.user_id + items, total = await WorkflowTaskService.get_handled_tasks( + db, user_id=user_id, page=page, page_size=page_size, + instance_title=instance_title, workflow_name=workflow_name, + ) + result_items = [await _build_task_list_out(db, item) for item in items] + return PaginatedResponse(items=result_items, total=total) + + +@router.get("/task/cc", response_model=PaginatedResponse[WorkflowTaskListOut], summary="抄送给我") +async def get_copy_tasks( + request: Request, + 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", description="每页数量"), + instance_title: str = Query(default=None, alias="instanceTitle", description="流程标题"), + workflow_name: str = Query(default=None, alias="workflowName", description="流程名称"), + is_read: bool = Query(default=None, alias="isRead", description="是否已读,false=仅未读"), + db: AsyncSession = Depends(get_db), +): + """获取抄送给当前用户的任务""" + user_id = request.state.user_id + items, total = await WorkflowTaskService.get_copy_tasks( + db, user_id=user_id, page=page, page_size=page_size, + instance_title=instance_title, workflow_name=workflow_name, is_read=is_read, + ) + result_items = [await _build_task_list_out(db, item) for item in items] + return PaginatedResponse(items=result_items, total=total) + + +@router.get("/task/{task_id}", response_model=WorkflowTaskOut, summary="获取任务详情") +async def get_task( + task_id: str, + db: AsyncSession = Depends(get_db), +): + """获取任务详情""" + task = await WorkflowTaskService.get_by_id(db, task_id) + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + return await _build_task_out(db, task, include_form=True) + + +@router.post("/task/{task_id}/approve", response_model=WorkflowTaskOut, summary="审批任务") +async def approve_task( + request: Request, + task_id: str, + data: TaskApproveInput, + db: AsyncSession = Depends(get_db), +): + """审批任务(通过/拒绝/驳回)""" + user_id = request.state.user_id + try: + task = await WorkflowTaskService.approve( + db, task_id, + action=data.action, + comment=data.comment, + user_id=user_id, + return_to=data.return_to, + form_data=data.form_data, + signature=data.signature, + ) + await db.commit() + return await _build_task_out(db, task) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/task/{task_id}/handle", response_model=WorkflowTaskOut, summary="办理任务") +async def handle_task( + request: Request, + task_id: str, + data: TaskHandleInput, + db: AsyncSession = Depends(get_db), +): + """办理任务""" + user_id = request.state.user_id + try: + task = await WorkflowTaskService.handle(db, task_id, data.comment, user_id, form_data=data.form_data) + await db.commit() + return await _build_task_out(db, task) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/task/{task_id}/transfer", response_model=WorkflowTaskOut, summary="转交任务") +async def transfer_task( + request: Request, + task_id: str, + data: TaskTransferInput, + db: AsyncSession = Depends(get_db), +): + """转交任务""" + user_id = request.state.user_id + try: + task = await WorkflowTaskService.transfer(db, task_id, data.to_user_id, data.comment, user_id) + await db.commit() + return await _build_task_out(db, task) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/task/{task_id}/delegate", response_model=WorkflowTaskOut, summary="委派任务") +async def delegate_task( + request: Request, + task_id: str, + data: TaskDelegateInput, + db: AsyncSession = Depends(get_db), +): + """委派任务""" + user_id = request.state.user_id + try: + task = await WorkflowTaskService.delegate(db, task_id, data.to_user_id, data.comment, user_id) + await db.commit() + return await _build_task_out(db, task) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/task/{task_id}/add-sign", response_model=List[WorkflowTaskOut], summary="加签任务") +async def add_sign_task( + request: Request, + task_id: str, + data: TaskAddSignInput, + db: AsyncSession = Depends(get_db), +): + """加签任务""" + user_id = request.state.user_id + try: + tasks = await WorkflowTaskService.add_sign( + db, task_id, + sign_type=data.sign_type, + to_user_ids=data.to_user_ids, + comment=data.comment, + user_id=user_id, + ) + await db.commit() + return [await _build_task_out(db, task) for task in tasks] + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/task/{task_id}/read", response_model=WorkflowTaskOut, summary="标记抄送已读") +async def mark_task_read( + request: Request, + task_id: str, + db: AsyncSession = Depends(get_db), +): + """标记抄送任务为已读""" + user_id = request.state.user_id + try: + task = await WorkflowTaskService.mark_read(db, task_id, user_id) + await db.commit() + return await _build_task_out(db, task) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/task/{task_id}/revise", response_model=WorkflowTaskOut, summary="修改重提") +async def revise_task( + request: Request, + task_id: str, + data: TaskReviseInput, + db: AsyncSession = Depends(get_db), +): + """修改任务(驳回后重新提交)""" + user_id = request.state.user_id + try: + task = await WorkflowTaskService.revise(db, task_id, data.form_data, data.comment, user_id) + await db.commit() + return await _build_task_out(db, task) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +# ==================== 管理员 API ==================== + +@router.post("/admin/check-timeouts", response_model=ResponseModel, summary="手动触发超时检查") +async def check_timeouts( + request: Request, + db: AsyncSession = Depends(get_db), +): + """ + 手动触发任务超时检查(管理员使用) + 正常情况下由定时任务自动执行,此接口用于调试或紧急处理 + """ + if not getattr(request.state, 'is_superuser', False): + raise HTTPException(status_code=403, detail='需要管理员权限') + + from online_dev.workflow.engine.task_timeout_process import check_and_handle_timeouts + + await check_and_handle_timeouts(db) + await db.commit() + return ResponseModel(message='超时检查完成') + + +@router.get("/admin/timeout-tasks", summary="获取即将超时的任务") +async def get_timeout_tasks( + request: Request, + hours: int = Query(default=24, description="查询未来N小时内超时的任务"), + db: AsyncSession = Depends(get_db), +): + """获取即将超时的任务列表(管理员使用)""" + if not getattr(request.state, 'is_superuser', False): + raise HTTPException(status_code=403, detail='需要管理员权限') + + from datetime import datetime, timedelta + from online_dev.workflow.model import WorkflowTask, WorkflowInstance + from core.user.model import User + from sqlalchemy import select + + now = datetime.now() + deadline = now + timedelta(hours=hours) + + stmt = select(WorkflowTask).where( + WorkflowTask.status == 'pending', + WorkflowTask.timeout_at.isnot(None), + WorkflowTask.timeout_at <= deadline, + WorkflowTask.is_deleted == False, + ).order_by(WorkflowTask.timeout_at) + + result = await db.execute(stmt) + tasks = result.scalars().all() + + result_list = [] + for task in tasks: + # 获取实例 + stmt = select(WorkflowInstance).where(WorkflowInstance.id == task.instance_id) + inst_result = await db.execute(stmt) + instance = inst_result.scalar_one_or_none() + + # 获取处理人 + stmt = select(User).where(User.id == task.assignee_id) + user_result = await db.execute(stmt) + assignee = user_result.scalar_one_or_none() + + is_overdue = task.timeout_at <= now if task.timeout_at else False + + result_list.append({ + 'id': str(task.id), + 'instance_id': str(task.instance_id), + 'instance_title': instance.title if instance else '', + 'node_name': task.node_name, + 'assignee_id': str(task.assignee_id) if task.assignee_id else '', + 'assignee_name': assignee.name if assignee else '', + 'timeout_at': task.timeout_at, + 'timeout_action': task.timeout_action, + 'timeout_notified': task.timeout_notified, + 'is_overdue': is_overdue, + 'created_at': task.sys_create_datetime, + }) + + return result_list diff --git a/backend-fastapi/online_dev/workflow/engine/__init__.py b/backend-fastapi/online_dev/workflow/engine/__init__.py new file mode 100644 index 0000000..440f2b1 --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/__init__.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +工作流引擎模块 +""" +from online_dev.workflow.engine.workflow_engine import WorkflowEngine +from online_dev.workflow.engine.base import ( + ExecutionContext, + TaskAction, + NodeType, + MultiApprovalType, + InstanceStatus, + TaskStatus, + generate_instance_no, +) + +__all__ = [ + 'WorkflowEngine', + 'ExecutionContext', + 'TaskAction', + 'NodeType', + 'MultiApprovalType', + 'InstanceStatus', + 'TaskStatus', + 'generate_instance_no', +] diff --git a/backend-fastapi/online_dev/workflow/engine/assignee_resolver.py b/backend-fastapi/online_dev/workflow/engine/assignee_resolver.py new file mode 100644 index 0000000..b0c3542 --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/assignee_resolver.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +审批人解析器 +负责根据节点配置解析实际的审批人列表 +""" +import logging +from typing import List, Dict, Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + + +class AssigneeResolver: + """ + 审批人解析器 + + 支持的审批人类型: + - user: 指定用户 + - role: 指定角色(获取角色下所有用户) + - department: 指定部门(获取部门下所有用户) + - superior: 上级主管(根据发起人的部门层级) + - manager: 直属经理(发起人的直属上级) + - initiator: 发起人自己 + - form_field: 表单字段(从表单数据中获取用户) + """ + + async def resolve( + self, + db: AsyncSession, + node_config: Dict, + instance: Any, + form_data: Dict, + ) -> List[str]: + """ + 解析审批人 + + Args: + db: 数据库会话 + node_config: 节点配置(包含 assigneeType, assignees 等) + instance: 流程实例 + form_data: 表单数据 + + Returns: + List[str]: 用户ID列表 + """ + assignee_type = node_config.get('assigneeType', 'user') + + resolvers = { + 'user': self._resolve_users, + 'role': self._resolve_by_role, + 'department': self._resolve_by_department, + 'superior': self._resolve_superior, + 'manager': self._resolve_manager, + 'initiator': self._resolve_initiator, + 'form_field': self._resolve_from_form, + } + + resolver = resolvers.get(assignee_type) + if not resolver: + logger.warning(f"未知的审批人类型: {assignee_type}") + return [] + + try: + return await resolver(db, node_config, instance, form_data) + except Exception as e: + logger.exception(f"解析审批人失败: {e}") + return [] + + async def _resolve_users( + self, + db: AsyncSession, + node_config: Dict, + instance: Any, + form_data: Dict, + ) -> List[str]: + """指定用户""" + assignees = node_config.get('assignees', []) + logger.info(f"[_resolve_users] assignees from config: {assignees}") + result = [str(uid) for uid in assignees] if assignees else [] + logger.info(f"[_resolve_users] result: {result}") + return result + + async def _resolve_by_role( + self, + db: AsyncSession, + node_config: Dict, + instance: Any, + form_data: Dict, + ) -> List[str]: + """根据角色获取用户(通过用户角色关联表)""" + from core.user.model import User + from core.user.user_role_model import UserRole + + role_ids = node_config.get('assignees', []) + if not role_ids: + return [] + + # 通过 UserRole 关联表查询拥有指定角色的用户 + stmt = select(User.id).join( + UserRole, + User.id == UserRole.user_id + ).where( + UserRole.role_id.in_(role_ids), + User.is_deleted == False, + User.is_active == True, + ).distinct() + + result = await db.execute(stmt) + users = result.scalars().all() + + logger.info(f"[_resolve_by_role] role_ids: {role_ids}, found users: {users}") + return [str(uid) for uid in users] + + async def _resolve_by_department( + self, + db: AsyncSession, + node_config: Dict, + instance: Any, + form_data: Dict, + ) -> List[str]: + """根据部门获取用户""" + from core.user.model import User + + dept_ids = node_config.get('assignees', []) + if not dept_ids: + return [] + + stmt = select(User.id).where( + User.dept_id.in_(dept_ids), + User.is_deleted == False, + User.is_active == True, + ).distinct() + + result = await db.execute(stmt) + users = result.scalars().all() + + return [str(uid) for uid in users] + + async def _resolve_superior( + self, + db: AsyncSession, + node_config: Dict, + instance: Any, + form_data: Dict, + ) -> List[str]: + """ + 获取上级主管 + + assigneeLevel 含义: + - 1 = 直接上级(当前部门的负责人) + - 2 = 上级的上级(父部门的负责人) + - 以此类推 + """ + from core.user.model import User + from core.dept.model import Dept + + level = node_config.get('assigneeLevel', 1) + initiator_id = instance.initiator_id + + # 获取发起人信息 + stmt = select(User).where(User.id == initiator_id) + result = await db.execute(stmt) + initiator = result.scalar_one_or_none() + + if not initiator or not initiator.dept_id: + logger.warning(f"发起人 {initiator_id} 没有部门信息") + return [] + + # 获取部门信息 + current_dept_id = initiator.dept_id + for _ in range(level - 1): + stmt = select(Dept).where(Dept.id == current_dept_id) + result = await db.execute(stmt) + dept = result.scalar_one_or_none() + if dept and dept.parent_id: + current_dept_id = dept.parent_id + else: + break + + # 获取部门负责人 + stmt = select(Dept).where(Dept.id == current_dept_id) + result = await db.execute(stmt) + current_dept = result.scalar_one_or_none() + + if current_dept and current_dept.lead_id: + # 如果负责人是发起人自己,尝试向上找 + if str(current_dept.lead_id) == str(initiator_id): + logger.info(f"部门负责人是发起人自己,尝试向上查找") + if current_dept.parent_id: + stmt = select(Dept).where(Dept.id == current_dept.parent_id) + result = await db.execute(stmt) + parent_dept = result.scalar_one_or_none() + if parent_dept and parent_dept.lead_id: + return [str(parent_dept.lead_id)] + return [str(current_dept.lead_id)] + + logger.warning(f"部门没有负责人") + return [] + + async def _resolve_manager( + self, + db: AsyncSession, + node_config: Dict, + instance: Any, + form_data: Dict, + ) -> List[str]: + """ + 获取直属经理 + + assigneeLevel 含义: + - 1 = 直属经理(发起人的 manager) + - 2 = 经理的经理(发起人的 manager 的 manager) + - 以此类推 + """ + from core.user.model import User + + level = node_config.get('assigneeLevel', 1) + initiator_id = instance.initiator_id + + current_user_id = initiator_id + for _ in range(level): + stmt = select(User).where(User.id == current_user_id) + result = await db.execute(stmt) + user = result.scalar_one_or_none() + + if user and user.manager_id: + current_user_id = user.manager_id + else: + logger.warning(f"用户没有设置直属经理") + return [] + + return [str(current_user_id)] + + async def _resolve_initiator( + self, + db: AsyncSession, + node_config: Dict, + instance: Any, + form_data: Dict, + ) -> List[str]: + """发起人自己""" + if instance.initiator_id: + return [str(instance.initiator_id)] + return [] + + async def _resolve_from_form( + self, + db: AsyncSession, + node_config: Dict, + instance: Any, + form_data: Dict, + ) -> List[str]: + """从表单字段获取用户,支持多字段收集,支持 form-selector 间接查询""" + field_names = node_config.get('assigneeFields') or node_config.get('recipientFields') + if not field_names: + single = node_config.get('assigneeField') or node_config.get('recipientField', '') + field_names = [single] if single else [] + + if not field_names: + return [] + + # 构建 form-selector 字段映射表: fieldName -> {formCode, userField} + field_mappings = {} + for mapping in (node_config.get('assigneeFieldMappings') or node_config.get('recipientFieldMappings') or []): + field_mappings[mapping['fieldName']] = mapping + + result = [] + seen = set() + for field_name in field_names: + value = form_data.get(field_name) + if not value: + continue + + mapping = field_mappings.get(field_name) + if mapping: + # form-selector 字段: value 是引用表单记录的 ID,需要间接查询用户字段 + user_ids = await self._resolve_form_selector_field( + db, value, mapping.get('formCode', ''), mapping.get('userField', '') + ) + for uid_str in user_ids: + if uid_str and uid_str not in seen: + seen.add(uid_str) + result.append(uid_str) + else: + # user-selector 字段: value 直接就是用户 ID + values = value if isinstance(value, list) else [value] + for uid in values: + uid_str = str(uid) + if uid_str and uid_str not in seen: + seen.add(uid_str) + result.append(uid_str) + + return result + + async def _resolve_form_selector_field( + self, + db: AsyncSession, + record_ids: Any, + form_code: str, + user_field: str, + ) -> List[str]: + """ + 从 form-selector 引用的表单记录中提取用户字段值 + + Args: + db: 数据库会话 + record_ids: 引用的表单记录 ID(单个或列表) + form_code: 引用的表单编码 + user_field: 引用表单中的用户字段名 + """ + if not form_code or not user_field: + logger.warning(f"form-selector 映射配置不完整: form_code={form_code}, user_field={user_field}") + return [] + + ids = record_ids if isinstance(record_ids, list) else [record_ids] + if not ids: + return [] + + result = [] + try: + from online_dev.form_data_manager.service import FormDataService + + service = await FormDataService.create_service(db, form_code) + + for record_id in ids: + try: + record = await service.get(db, str(record_id)) + if not record: + logger.warning(f"form-selector 引用记录不存在: form_code={form_code}, id={record_id}") + continue + + user_value = record.get(user_field) + if not user_value: + continue + + user_values = user_value if isinstance(user_value, list) else [user_value] + for uid in user_values: + result.append(str(uid)) + except Exception as e: + logger.warning(f"查询 form-selector 引用记录失败: form_code={form_code}, id={record_id}, error={e}") + continue + + except Exception as e: + logger.exception(f"form-selector 字段解析失败: {e}") + + return result + + +# 全局实例 +assignee_resolver = AssigneeResolver() diff --git a/backend-fastapi/online_dev/workflow/engine/base.py b/backend-fastapi/online_dev/workflow/engine/base.py new file mode 100644 index 0000000..cd02cdf --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/base.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +工作流引擎基础模块 +定义核心类型、枚举和数据类 +""" +import logging +import uuid +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from typing import Dict, Any + +logger = logging.getLogger(__name__) + + +class NodeType(Enum): + """节点类型""" + START = 'start' + END = 'end' + APPROVAL = 'approval' + HANDLE = 'handle' + COPY = 'copy' + CONDITION = 'condition' + PARALLEL = 'parallel' + DELAY = 'delay' + NOTIFY = 'notify' + SERVICE = 'service' + SUBFLOW = 'subflow' + DATA_UPDATE = 'data_update' + + +class TaskAction(Enum): + """任务操作""" + APPROVE = 'approve' + REJECT = 'reject' + TRANSFER = 'transfer' + RETURN = 'return' + DELEGATE = 'delegate' + ADD_SIGN = 'add_sign' + + +class MultiApprovalType(Enum): + """多人审批类型""" + SEQUENTIAL = 'sequential' + PARALLEL = 'parallel' + ANY = 'any' + + +class InstanceStatus(Enum): + """流程实例状态""" + PENDING = 'pending' + APPROVED = 'approved' + REJECTED = 'rejected' + CANCELED = 'canceled' + + +class TaskStatus(Enum): + """任务状态""" + PENDING = 'pending' + WAITING = 'waiting' + APPROVED = 'approved' + REJECTED = 'rejected' + RETURNED = 'returned' + TRANSFERRED = 'transferred' + DELEGATED = 'delegated' + HANDLED = 'handled' + CANCELED = 'canceled' + READ = 'read' + + +@dataclass +class ExecutionContext: + """ + 执行上下文 + 在流程执行过程中传递的上下文信息 + """ + instance: Any + form_data: Dict + current_user_id: str + flow_definition: Dict + db: Any = None + + def get_initiator_id(self) -> str: + """获取发起人ID""" + return self.instance.initiator_id if self.instance else '' + + def get_instance_id(self) -> str: + """获取实例ID""" + return str(self.instance.id) if self.instance else '' + + def get_current_node_id(self) -> str: + """获取当前节点ID""" + return self.instance.current_node_id if self.instance else '' + + +def generate_instance_no() -> str: + """生成流程实例编号""" + now = datetime.now() + return f"WF{now.strftime('%Y%m%d%H%M%S')}{str(uuid.uuid4())[:8].upper()}" diff --git a/backend-fastapi/online_dev/workflow/engine/condition_evaluator.py b/backend-fastapi/online_dev/workflow/engine/condition_evaluator.py new file mode 100644 index 0000000..f4bb715 --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/condition_evaluator.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +条件表达式求值器 +负责解析和执行条件分支的表达式 +""" +import logging +import operator +from decimal import Decimal +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +class ConditionEvaluator: + """ + 条件求值器 + + 支持的操作符: + - eq: 等于 + - ne: 不等于 + - gt: 大于 + - gte: 大于等于 + - lt: 小于 + - lte: 小于等于 + - contains: 包含 + - not_contains: 不包含 + - in: 在...中 + - not_in: 不在...中 + - empty: 为空 + - not_empty: 不为空 + """ + + # 操作符映射 + OPERATORS = { + 'eq': operator.eq, + 'ne': operator.ne, + 'gt': operator.gt, + 'gte': operator.ge, + 'lt': operator.lt, + 'lte': operator.le, + } + + def evaluate_groups(self, groups: List[Dict], form_data: Dict) -> bool: + """ + 求值条件组列表(组之间是 OR 关系) + + Args: + groups: 条件组列表,每个组包含 conditions 数组 + form_data: 表单数据 + + Returns: + bool: 任一组满足则返回 True + """ + if not groups: + return True + + for group in groups: + if self.evaluate_group(group, form_data): + return True + + return False + + def evaluate_group(self, group: Dict, form_data: Dict) -> bool: + """ + 求值单个条件组(组内条件是 AND 关系) + + Args: + group: 条件组,包含 conditions 数组 + form_data: 表单数据 + + Returns: + bool: 所有条件都满足才返回 True + """ + conditions = group.get('conditions', []) + if not conditions: + return True + + for condition in conditions: + if not self.evaluate_condition(condition, form_data): + return False + + return True + + def evaluate_condition(self, condition: Dict, form_data: Dict) -> bool: + """ + 求值单个条件 + + Args: + condition: 条件定义 {field, operator, value} + form_data: 表单数据 + + Returns: + bool: 条件是否满足 + """ + field = condition.get('field', '') + op = condition.get('operator', 'eq') + expected_value = condition.get('value') + + actual_value = self._get_field_value(form_data, field) + + logger.info( + f"条件求值: field={field}, operator={op}, " + f"expected={expected_value!r}(type={type(expected_value).__name__}), " + f"actual={actual_value!r}(type={type(actual_value).__name__})" + ) + + try: + result = self._compare(actual_value, op, expected_value) + logger.info(f"条件求值结果: {result}") + return result + except Exception as e: + logger.warning(f"条件求值失败: {condition}, 错误: {e}") + return False + + def _get_field_value(self, data: Dict, field: str) -> Any: + """ + 获取字段值,支持嵌套路径(如 user.dept.name) + """ + if not field: + return None + + parts = field.split('.') + value = data + + for part in parts: + if isinstance(value, dict): + value = value.get(part) + else: + return None + + return value + + def _compare(self, actual: Any, op: str, expected: Any) -> bool: + """ + 执行比较操作 + """ + # 空值检查 + if op == 'empty': + return self._is_empty(actual) + if op == 'not_empty': + return not self._is_empty(actual) + + # 包含检查 + if op == 'contains': + return self._contains(actual, expected) + if op == 'not_contains': + return not self._contains(actual, expected) + + # 集合检查 + if op == 'in': + return self._in_list(actual, expected) + if op == 'not_in': + return not self._in_list(actual, expected) + + # 数值比较(需要类型转换) + if op in ('gt', 'gte', 'lt', 'lte'): + actual = self._to_number(actual) + expected = self._to_number(expected) + if actual is None or expected is None: + return False + + # 等于/不等于:先尝试原始比较,如果类型不同则统一转字符串再比较 + if op in ('eq', 'ne'): + if type(actual) != type(expected) and actual is not None and expected is not None: + result = self.OPERATORS[op](str(actual).strip(), str(expected).strip()) + logger.debug(f"类型不一致,转字符串比较: {str(actual)!r} {op} {str(expected)!r} = {result}") + return result + return self.OPERATORS[op](actual, expected) + + # 其他标准比较 + if op in self.OPERATORS: + return self.OPERATORS[op](actual, expected) + + return actual == expected + + def _is_empty(self, value: Any) -> bool: + """检查值是否为空""" + if value is None: + return True + if isinstance(value, str) and value.strip() == '': + return True + if isinstance(value, (list, dict)) and len(value) == 0: + return True + return False + + def _contains(self, actual: Any, expected: Any) -> bool: + """检查是否包含""" + if actual is None: + return False + if isinstance(actual, str): + return str(expected) in actual + if isinstance(actual, (list, tuple)): + return expected in actual + return False + + def _in_list(self, actual: Any, expected: Any) -> bool: + """检查是否在列表中""" + if not isinstance(expected, (list, tuple)): + expected = [expected] + return actual in expected + + def _to_number(self, value: Any) -> Optional[float]: + """转换为数字""" + if value is None: + return None + if isinstance(value, (int, float, Decimal)): + return float(value) + if isinstance(value, str): + try: + return float(value) + except ValueError: + return None + return None + + +# 全局实例 +condition_evaluator = ConditionEvaluator() diff --git a/backend-fastapi/online_dev/workflow/engine/delay_callback.py b/backend-fastapi/online_dev/workflow/engine/delay_callback.py new file mode 100644 index 0000000..5a74e7e --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/delay_callback.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +延时节点回调函数 +当延时到期后,由调度器调用此函数推进工作流 +""" +import logging +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + + +def get_delay_job_id(instance_id: str, node_id: str) -> str: + """生成延时任务的唯一 job_id""" + return f"wf_delay_{instance_id}_{node_id}" + + +async def workflow_delay_callback(instance_id: str, node_id: str): + """ + 延时到期回调 - 推进工作流 + + 由调度器在延时到期后调用,负责: + 1. 加载流程实例,校验状态 + 2. 清除延时状态 + 3. 记录延时完成日志 + 4. 推进流程到下一节点 + + Args: + instance_id: 流程实例ID + node_id: 延时节点ID + """ + from app.database import AsyncSessionLocal + + logger.info(f"延时回调触发: instance_id={instance_id}, node_id={node_id}") + + async with AsyncSessionLocal() as db: + try: + await _execute_delay_resume(db, instance_id, node_id) + await db.commit() + logger.info(f"延时回调完成: instance_id={instance_id}, node_id={node_id}") + except Exception as e: + await db.rollback() + logger.error(f"延时回调失败: instance_id={instance_id}, node_id={node_id}, error={e}", exc_info=True) + raise + + +async def _execute_delay_resume(db: AsyncSession, instance_id: str, node_id: str): + """执行延时恢复逻辑""" + from online_dev.workflow.model import WorkflowInstance, WorkflowDefinition, WorkflowLog + from online_dev.workflow.engine.base import ExecutionContext + from online_dev.workflow.engine.utils import FlowUtils + + # 1. 加载流程实例 + stmt = select(WorkflowInstance).where( + WorkflowInstance.id == instance_id, + WorkflowInstance.is_deleted == False, + ) + result = await db.execute(stmt) + instance = result.scalar_one_or_none() + + if not instance: + logger.warning(f"延时回调: 流程实例不存在 {instance_id}") + return + + # 2. 校验实例状态 + if instance.status != 'pending': + logger.info(f"延时回调: 流程实例状态非 pending ({instance.status}),跳过 {instance_id}") + return + + # 3. 校验延时节点匹配 + if instance.delay_node_id != node_id: + logger.warning( + f"延时回调: 节点不匹配,期望 {node_id},实际 {instance.delay_node_id},跳过" + ) + return + + # 4. 加载流程定义 + stmt = select(WorkflowDefinition).where( + WorkflowDefinition.id == instance.workflow_id, + WorkflowDefinition.is_deleted == False, + ) + result = await db.execute(stmt) + definition = result.scalar_one_or_none() + + if not definition or not definition.flow_definition: + logger.error(f"延时回调: 流程定义不存在或无效 workflow_id={instance.workflow_id}") + return + + flow_def = definition.flow_definition + + # 5. 查找延时节点 + delay_node = FlowUtils.find_node_by_id(flow_def.get('nodes'), node_id) + if not delay_node: + logger.error(f"延时回调: 延时节点不存在 node_id={node_id}") + return + + # 6. 清除实例的延时状态 + instance.delay_node_id = "" + instance.delay_until = None + db.add(instance) + await db.flush() + + # 7. 记录延时完成日志 + log = WorkflowLog( + instance_id=str(instance.id), + node_id=node_id, + node_name=delay_node.get('name', '延时等待'), + action='delay_complete', + operator_id='', + comment='延时等待结束,流程继续', + ) + db.add(log) + await db.flush() + + # 8. 构建执行上下文并推进流程 + from online_dev.workflow.engine.utils import FormDataUtils + + form_data = {} + if instance.form_code and instance.form_data_id: + try: + form_data = await FormDataUtils.load_form_data(db, instance.form_code, instance.form_data_id) + except Exception as e: + logger.warning(f"延时回调: 加载表单数据失败: {e}") + + context = ExecutionContext( + instance=instance, + form_data=form_data, + current_user_id='', + flow_definition=flow_def, + db=db, + ) + + # 9. 推进到下一节点 + from online_dev.workflow.engine.workflow_engine import WorkflowEngine + + engine = WorkflowEngine() + await engine._advance_to_next(context, delay_node) + + logger.info(f"延时回调: 流程已推进 instance_id={instance_id}") + + +async def recover_pending_delay_tasks(): + """ + 应用启动时恢复未完成的延时任务 + + 查询所有处于延时等待状态的流程实例,重新注册定时任务: + - delay_until > now: 注册定时任务等待到期 + - delay_until <= now: 直接执行回调推进流程 + """ + from app.database import AsyncSessionLocal + from online_dev.workflow.model import WorkflowInstance + + logger.info("开始恢复未完成的延时任务...") + + try: + async with AsyncSessionLocal() as db: + # 查询所有有延时状态的 pending 实例 + stmt = select(WorkflowInstance).where( + WorkflowInstance.status == 'pending', + WorkflowInstance.delay_node_id != '', + WorkflowInstance.delay_node_id.isnot(None), + WorkflowInstance.delay_until.isnot(None), + WorkflowInstance.is_deleted == False, + ) + result = await db.execute(stmt) + instances = list(result.scalars().all()) + + if not instances: + logger.info("没有需要恢复的延时任务") + return + + now = datetime.now() + recovered = 0 + expired = 0 + + for instance in instances: + instance_id = str(instance.id) + node_id = instance.delay_node_id + delay_until = instance.delay_until + + if delay_until > now: + # 未到期,重新注册定时任务 + success = await _register_delay_job(instance_id, node_id, delay_until) + if success: + recovered += 1 + logger.info( + f"恢复延时任务: instance={instance_id}, node={node_id}, " + f"到期时间={delay_until}" + ) + else: + # 已过期,直接执行回调 + expired += 1 + logger.info( + f"延时任务已过期,立即执行: instance={instance_id}, node={node_id}" + ) + try: + await workflow_delay_callback(instance_id, node_id) + except Exception as e: + logger.error(f"执行过期延时回调失败: {e}", exc_info=True) + + logger.info(f"延时任务恢复完成: 重新注册 {recovered} 个, 立即执行 {expired} 个") + + except Exception as e: + logger.error(f"恢复延时任务失败: {e}", exc_info=True) + + +async def _register_delay_job(instance_id: str, node_id: str, delay_until: datetime) -> bool: + """ + 注册延时定时任务到调度器 + + Args: + instance_id: 流程实例ID + node_id: 延时节点ID + delay_until: 到期时间 + + Returns: + 是否注册成功 + """ + try: + from scheduler.service import scheduler_service + + scheduler = scheduler_service.get_scheduler() + if not scheduler: + logger.warning("调度器未初始化,无法注册延时任务") + return False + + job_id = get_delay_job_id(instance_id, node_id) + + # 创建回调包装函数(闭包捕获参数) + _instance_id = instance_id + _node_id = node_id + + async def delay_wrapper(): + await workflow_delay_callback(_instance_id, _node_id) + + # 注册任务 + await scheduler.configure_task(job_id, func=delay_wrapper) + + # 添加一次性调度 + from apscheduler.triggers.date import DateTrigger + + await scheduler.add_schedule( + func_or_task_id=job_id, + trigger=DateTrigger(run_time=delay_until), + id=job_id, + ) + + logger.info(f"延时任务已注册: job_id={job_id}, 到期时间={delay_until}") + return True + + except Exception as e: + logger.error(f"注册延时任务失败: {e}", exc_info=True) + return False + + +async def remove_delay_job(instance_id: str, node_id: str) -> bool: + """ + 从调度器移除延时任务 + + Args: + instance_id: 流程实例ID + node_id: 延时节点ID + + Returns: + 是否移除成功 + """ + try: + from scheduler.service import scheduler_service + + scheduler = scheduler_service.get_scheduler() + if not scheduler: + return False + + job_id = get_delay_job_id(instance_id, node_id) + + try: + await scheduler.remove_schedule(job_id) + logger.info(f"延时任务已移除: job_id={job_id}") + except Exception: + # 任务可能不存在(已执行或已清理) + pass + + return True + + except Exception as e: + logger.error(f"移除延时任务失败: {e}", exc_info=True) + return False diff --git a/backend-fastapi/online_dev/workflow/engine/delay_process.py b/backend-fastapi/online_dev/workflow/engine/delay_process.py new file mode 100644 index 0000000..358af7f --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/delay_process.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +工作流延时节点处理器 +使用 APScheduler 实现延时等待功能 +""" +import json +import logging +from datetime import datetime, timedelta +from typing import Optional + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + + +def calculate_delay_datetime(duration: int, unit: str) -> datetime: + """ + 计算延时后的执行时间 + + Args: + duration: 延时时长 + unit: 延时单位 - minute/hour/day/workday + + Returns: + datetime: 延时后的执行时间 + """ + now = datetime.now() + + if unit == 'minute': + return now + timedelta(minutes=duration) + elif unit == 'hour': + return now + timedelta(hours=duration) + elif unit == 'day': + return now + timedelta(days=duration) + elif unit == 'workday': + # 工作日计算(跳过周末) + result = now + days_added = 0 + while days_added < duration: + result += timedelta(days=1) + # 周一到周五是工作日 (0-4) + if result.weekday() < 5: + days_added += 1 + return result + else: + # 默认按小时 + return now + timedelta(hours=duration) + + +async def create_delay_job( + db: AsyncSession, + instance_id: str, + node_id: str, + duration: int, + unit: str, + user_id: str, +) -> Optional[str]: + """ + 创建延时任务 + + Args: + db: 数据库会话 + instance_id: 流程实例ID + node_id: 延时节点ID + duration: 延时时长 + unit: 延时单位 + user_id: 操作用户ID + + Returns: + str: 任务编码,失败返回 None + """ + try: + from scheduler.model import SchedulerJob + from scheduler.service import SchedulerService + + # 计算执行时间 + run_date = calculate_delay_datetime(duration, unit) + + # 生成唯一的任务编码 + job_code = f"workflow_delay_{instance_id}_{node_id}" + + # 检查是否已存在 + stmt = select(SchedulerJob).where(SchedulerJob.code == job_code) + result = await db.execute(stmt) + existing = result.scalar_one_or_none() + + if existing: + # 更新执行时间 + existing.run_date = run_date + existing.status = 1 # 启用 + db.add(existing) + await db.flush() + + # 更新调度器中的任务 + scheduler_service = SchedulerService() + if scheduler_service.is_running(): + await scheduler_service.modify_job(existing) + + logger.info(f"更新延时任务: {job_code}, 执行时间: {run_date}") + return job_code + + # 创建新任务 + job = SchedulerJob( + name=f"工作流延时-{instance_id[:8]}", + code=job_code, + description=f"流程实例 {instance_id} 的延时节点 {node_id}", + group='workflow_delay', + trigger_type='date', + run_date=run_date, + task_func='core.workflow.engine.delay_process.execute_delay_complete', + task_kwargs=json.dumps({ + 'instance_id': instance_id, + 'node_id': node_id, + 'user_id': user_id, + }), + status=1, # 启用 + max_instances=1, + coalesce=True, + ) + db.add(job) + await db.flush() + + # 添加到调度器 + scheduler_service = SchedulerService() + if scheduler_service.is_running(): + await scheduler_service.add_job(job) + + logger.info(f"创建延时任务: {job_code}, 执行时间: {run_date}") + return job_code + + except Exception as e: + logger.error(f"创建延时任务失败: {e}") + return None + + +async def cancel_delay_job(db: AsyncSession, instance_id: str, node_id: str) -> bool: + """ + 取消延时任务 + + Args: + db: 数据库会话 + instance_id: 流程实例ID + node_id: 延时节点ID + + Returns: + bool: 是否成功 + """ + try: + from scheduler.model import SchedulerJob + from scheduler.service import SchedulerService + + job_code = f"workflow_delay_{instance_id}_{node_id}" + + # 从调度器移除 + scheduler_service = SchedulerService() + if scheduler_service.is_running(): + await scheduler_service.remove_job(job_code) + + # 禁用数据库记录 + stmt = update(SchedulerJob).where( + SchedulerJob.code == job_code + ).values(status=0) + await db.execute(stmt) + await db.flush() + + logger.info(f"取消延时任务: {job_code}") + return True + + except Exception as e: + logger.error(f"取消延时任务失败: {e}") + return False + + +async def execute_delay_complete(db: AsyncSession, instance_id: str, node_id: str, user_id: str): + """ + 延时完成后执行的任务 + + Args: + db: 数据库会话 + instance_id: 流程实例ID + node_id: 延时节点ID + user_id: 操作用户ID + """ + from online_dev.workflow.model import WorkflowInstance, WorkflowDefinition, WorkflowLog + from online_dev.workflow.engine.workflow_engine import WorkflowEngine + from online_dev.workflow.engine.base import ExecutionContext + from online_dev.workflow.engine.utils import FlowUtils + from core.user.model import User + + logger.info(f"延时任务执行: instance={instance_id}, node={node_id}") + + try: + # 获取流程实例 + stmt = select(WorkflowInstance).where( + WorkflowInstance.id == instance_id, + WorkflowInstance.is_deleted == False, + ) + result = await db.execute(stmt) + instance = result.scalar_one_or_none() + + if not instance: + logger.error(f"流程实例不存在: {instance_id}") + return f"流程实例不存在: {instance_id}" + + # 检查流程状态 + if instance.status != 'pending': + logger.warning(f"流程实例 {instance_id} 状态不是 pending,跳过延时完成") + return f"流程状态不是 pending: {instance.status}" + + # 检查当前节点 + if instance.current_node_id != node_id: + logger.warning(f"流程实例 {instance_id} 当前节点不是 {node_id},跳过延时完成") + return f"当前节点不匹配: {instance.current_node_id}" + + # 获取流程定义 + stmt = select(WorkflowDefinition).where(WorkflowDefinition.id == instance.workflow_id) + result = await db.execute(stmt) + workflow = result.scalar_one_or_none() + + if not workflow: + logger.error(f"流程定义不存在: {instance.workflow_id}") + return f"流程定义不存在" + + # 获取用户 + stmt = select(User).where(User.id == user_id) + result = await db.execute(stmt) + user = result.scalar_one_or_none() + + if not user: + # 使用发起人 + stmt = select(User).where(User.id == instance.initiator_id) + result = await db.execute(stmt) + user = result.scalar_one_or_none() + + # 记录延时完成日志 + log = WorkflowLog( + instance_id=str(instance.id), + node_id=node_id, + node_name=instance.current_node_name, + action='delay_complete', + operator_id=str(user.id) if user else '', + comment='延时等待完成', + ) + db.add(log) + await db.flush() + + # 创建执行上下文 + engine = WorkflowEngine() + + context = ExecutionContext( + instance=instance, + form_data={}, + current_user=user, + flow_definition=workflow.flow_definition, + db=db, + ) + + # 查找延时节点并推进 + delay_node = FlowUtils.find_node_by_id(context.flow_definition, node_id) + if delay_node: + await engine._advance_to_next(context, delay_node) + logger.info(f"延时节点 {node_id} 完成,流程继续推进") + return "延时完成,流程已推进" + else: + logger.error(f"找不到延时节点: {node_id}") + return f"找不到延时节点: {node_id}" + + except Exception as e: + logger.exception(f"延时任务执行失败: {e}") + raise diff --git a/backend-fastapi/online_dev/workflow/engine/handlers/__init__.py b/backend-fastapi/online_dev/workflow/engine/handlers/__init__.py new file mode 100644 index 0000000..fd348c6 --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/handlers/__init__.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +节点处理器模块 +每种节点类型对应一个处理器 +""" +from online_dev.workflow.engine.handlers.approval_handler import ApprovalHandler +from online_dev.workflow.engine.handlers.base_handler import BaseNodeHandler +from online_dev.workflow.engine.handlers.condition_handler import ConditionHandler +from online_dev.workflow.engine.handlers.copy_handler import CopyHandler +from online_dev.workflow.engine.handlers.delay_handler import DelayNodeHandler +from online_dev.workflow.engine.handlers.handle_handler import HandleHandler +from online_dev.workflow.engine.handlers.notify_handler import NotifyHandler +from online_dev.workflow.engine.handlers.parallel_handler import ParallelHandler +from online_dev.workflow.engine.handlers.service_handler import ServiceHandler +from online_dev.workflow.engine.handlers.data_update_handler import DataUpdateHandler +from online_dev.workflow.engine.handlers.subflow_handler import SubflowHandler + +__all__ = [ + 'BaseNodeHandler', + 'ApprovalHandler', + 'HandleHandler', + 'CopyHandler', + 'ConditionHandler', + 'ParallelHandler', + 'DelayNodeHandler', + 'NotifyHandler', + 'ServiceHandler', + 'SubflowHandler', + 'DataUpdateHandler', +] diff --git a/backend-fastapi/online_dev/workflow/engine/handlers/approval_handler.py b/backend-fastapi/online_dev/workflow/engine/handlers/approval_handler.py new file mode 100644 index 0000000..33c23b3 --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/handlers/approval_handler.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +审批节点处理器 +处理审批任务的创建和完成逻辑 +""" +import logging +from typing import Dict, Any, Optional, TYPE_CHECKING + +from sqlalchemy import select + +from online_dev.workflow.engine.handlers.base_handler import BaseNodeHandler + +if TYPE_CHECKING: + from online_dev.workflow.engine.base import ExecutionContext + +logger = logging.getLogger(__name__) + + +class ApprovalHandler(BaseNodeHandler): + """ + 审批节点处理器 + + 支持: + - 或签(any):一人通过即可 + - 会签(parallel):所有人都要通过 + - 依次审批(sequential):按顺序审批 + """ + + async def execute(self, context: 'ExecutionContext', node: Dict) -> None: + """ + 执行审批节点:创建审批任务 + """ + from online_dev.workflow.engine.assignee_resolver import assignee_resolver + + node_id = self.get_node_id(node) + node_name = self.get_node_name(node) + node_config = self.get_node_config(node) + + logger.info(f"创建审批任务 - 节点: {node_id}, 名称: {node_name}") + + # 解析审批人 + assignee_ids = await assignee_resolver.resolve( + context.db, + node_config, + context.instance, + context.form_data, + ) + + logger.info(f"解析到的审批人: {assignee_ids}") + + if not assignee_ids: + # 空审批人处理 + empty_action = node_config.get('emptyAssignee', 'error') + if empty_action == 'skip': + logger.warning(f"节点 {node_id} 没有审批人,自动跳过") + await self.advance_to_next(context, node) + return + elif empty_action == 'admin': + logger.warning(f"节点 {node_id} 没有审批人,转交管理员(未实现)") + await self.advance_to_next(context, node) + return + else: + logger.warning(f"节点 {node_id} 没有审批人,自动跳过") + await self.advance_to_next(context, node) + return + + # 更新实例当前节点 + await self.update_instance_node(context, node) + + # 创建任务 + multi_approval = node_config.get('multiApproval', 'any') + + if multi_approval == 'sequential': + # 依次审批:只创建第一个人的任务 + await self.create_task(context, node, assignee_ids[0], 'approval') + else: + # 或签/会签:创建所有人的任务 + for assignee_id in assignee_ids: + await self.create_task(context, node, assignee_id, 'approval') + + async def handle_approval(self, context: 'ExecutionContext', task: Any) -> None: + """ + 处理审批通过后的流程推进 + + Args: + context: 执行上下文 + task: 已完成的任务 + """ + from online_dev.workflow.model import WorkflowTask + from online_dev.workflow.engine.utils import FlowUtils + + # 检查是否是加签任务 + if task.sign_type: + await self._handle_sign_task_completion(context, task) + return + + node = FlowUtils.find_node_by_id(context.flow_definition, task.node_id) + if not node: + logger.error(f"找不到节点: {task.node_id}") + return + + node_config = self.get_node_config(node) + multi_approval = node_config.get('multiApproval', 'any') + + # 检查多人审批逻辑 + if multi_approval == 'parallel': + # 会签:检查是否所有人都已审批 + stmt = select(WorkflowTask).where( + WorkflowTask.instance_id == str(context.instance.id), + WorkflowTask.node_id == task.node_id, + WorkflowTask.status == 'pending', + WorkflowTask.sign_type == '', + ) + result = await context.db.execute(stmt) + pending_tasks = result.scalars().all() + + if len(pending_tasks) > 0: + logger.info(f"会签模式,还有 {len(pending_tasks)} 人未审批") + return + + elif multi_approval == 'sequential': + # 依次审批:检查是否还有下一个人 + next_assignee = await self._get_next_sequential_assignee(context, task) + if next_assignee: + await self.create_task(context, node, next_assignee, 'approval') + return + + # 或签模式:取消该节点其他 pending 的普通任务 + if multi_approval == 'any': + stmt = select(WorkflowTask).where( + WorkflowTask.instance_id == str(context.instance.id), + WorkflowTask.node_id == task.node_id, + WorkflowTask.status == 'pending', + WorkflowTask.id != task.id, + ) + result = await context.db.execute(stmt) + other_pending = result.scalars().all() + if other_pending: + logger.info(f"或签模式,取消其他 {len(other_pending)} 个待审批任务") + canceled_task_ids = [] + for t in other_pending: + canceled_task_ids.append(str(t.id)) + t.status = 'canceled' + t.comment = '__or_sign_canceled__' + context.db.add(t) + await context.db.flush() + + for tid in canceled_task_ids: + try: + from core.message.service import NotifyService + await NotifyService.complete_dingtalk_todo(context.db, "workflow_task", tid) + except Exception as e: + logger.warning(f"或签取消-清理钉钉待办失败 task={tid}: {e}") + + # 检查是否还有未完成的加签任务 + # 1. waiting 状态的任务(前加签产生的原任务) + stmt = select(WorkflowTask).where( + WorkflowTask.instance_id == str(context.instance.id), + WorkflowTask.node_id == task.node_id, + WorkflowTask.status == 'waiting', + ) + result = await context.db.execute(stmt) + waiting_tasks = result.scalars().all() + + # 2. 后加签任务 + stmt = select(WorkflowTask).where( + WorkflowTask.instance_id == str(context.instance.id), + WorkflowTask.node_id == task.node_id, + WorkflowTask.sign_type == 'after', + WorkflowTask.status == 'pending', + ) + result = await context.db.execute(stmt) + after_sign_tasks = result.scalars().all() + + if len(waiting_tasks) > 0: + logger.info(f"还有 {len(waiting_tasks)} 个任务在等待前加签完成") + if multi_approval == 'any': + canceled_ids = [] + for wt in waiting_tasks: + canceled_ids.append(str(wt.id)) + wt.status = 'canceled' + wt.comment = '__or_sign_canceled__' + context.db.add(wt) + await context.db.flush() + for tid in canceled_ids: + try: + from core.message.service import NotifyService + await NotifyService.complete_dingtalk_todo(context.db, "workflow_task", tid) + except Exception as e: + logger.warning(f"或签取消waiting任务-清理钉钉待办失败 task={tid}: {e}") + + if len(after_sign_tasks) > 0: + logger.info(f"还有 {len(after_sign_tasks)} 个后加签任务未完成") + if multi_approval == 'any': + canceled_ids = [] + for ast in after_sign_tasks: + canceled_ids.append(str(ast.id)) + ast.status = 'canceled' + ast.comment = '__or_sign_canceled__' + context.db.add(ast) + await context.db.flush() + for tid in canceled_ids: + try: + from core.message.service import NotifyService + await NotifyService.complete_dingtalk_todo(context.db, "workflow_task", tid) + except Exception as e: + logger.warning(f"或签取消后加签任务-清理钉钉待办失败 task={tid}: {e}") + + # 推进到下一节点 + await self.advance_to_next(context, node) + + async def _handle_sign_task_completion(self, context: 'ExecutionContext', task: Any) -> None: + """ + 处理加签任务完成后的逻辑 + """ + from online_dev.workflow.model import WorkflowTask + from online_dev.workflow.engine.utils import FlowUtils + + sign_type = task.sign_type + parent_task_id = task.parent_task_id + + logger.info(f"加签任务完成 - 类型: {sign_type}, 父任务ID: {parent_task_id}") + + if sign_type == 'before': + # 前加签完成:检查是否所有前加签任务都完成了 + stmt = select(WorkflowTask).where( + WorkflowTask.instance_id == str(context.instance.id), + WorkflowTask.node_id == task.node_id, + WorkflowTask.sign_type == 'before', + WorkflowTask.parent_task_id == parent_task_id, + WorkflowTask.status == 'pending', + ) + result = await context.db.execute(stmt) + pending_before_signs = result.scalars().all() + + if len(pending_before_signs) > 0: + logger.info(f"还有 {len(pending_before_signs)} 个前加签任务未完成") + return + + # 所有前加签任务完成,恢复原任务 + if parent_task_id: + stmt = select(WorkflowTask).where(WorkflowTask.id == parent_task_id) + result = await context.db.execute(stmt) + parent_task = result.scalar_one_or_none() + if parent_task and parent_task.status == 'waiting': + parent_task.status = 'pending' + context.db.add(parent_task) + await context.db.flush() + logger.info(f"前加签完成,恢复原任务: {parent_task_id}") + + elif sign_type == 'after': + # 后加签完成:检查是否所有后加签任务都完成了 + stmt = select(WorkflowTask).where( + WorkflowTask.instance_id == str(context.instance.id), + WorkflowTask.node_id == task.node_id, + WorkflowTask.sign_type == 'after', + WorkflowTask.parent_task_id == parent_task_id, + WorkflowTask.status == 'pending', + ) + result = await context.db.execute(stmt) + pending_after_signs = result.scalars().all() + + if len(pending_after_signs) > 0: + logger.info(f"还有 {len(pending_after_signs)} 个后加签任务未完成") + return + + # 所有后加签任务完成,推进流程 + node = FlowUtils.find_node_by_id(context.flow_definition, task.node_id) + if node: + await self.advance_to_next(context, node) + + elif sign_type == 'parallel': + # 并行加签完成 + node = FlowUtils.find_node_by_id(context.flow_definition, task.node_id) + if not node: + return + + node_config = self.get_node_config(node) + multi_approval = node_config.get('multiApproval', 'any') + + stmt = select(WorkflowTask).where( + WorkflowTask.instance_id == str(context.instance.id), + WorkflowTask.node_id == task.node_id, + WorkflowTask.status == 'pending', + ) + result = await context.db.execute(stmt) + pending_tasks = result.scalars().all() + + if multi_approval == 'any': + await self.advance_to_next(context, node) + elif multi_approval == 'parallel' and len(pending_tasks) == 0: + await self.advance_to_next(context, node) + + elif sign_type == 'delegate': + # 委托任务完成 + if parent_task_id: + stmt = select(WorkflowTask).where(WorkflowTask.id == parent_task_id) + result = await context.db.execute(stmt) + parent_task = result.scalar_one_or_none() + if parent_task and parent_task.status == 'delegated': + parent_task.status = 'pending' + parent_task.comment = f'委托人已审批通过,请确认' + context.db.add(parent_task) + await context.db.flush() + logger.info(f"委托任务完成,恢复原任务: {parent_task_id}") + + elif sign_type == 'transfer': + # 转交任务完成 + node = FlowUtils.find_node_by_id(context.flow_definition, task.node_id) + if node: + node_config = self.get_node_config(node) + multi_approval = node_config.get('multiApproval', 'any') + + if multi_approval == 'any': + await self.advance_to_next(context, node) + elif multi_approval == 'parallel': + stmt = select(WorkflowTask).where( + WorkflowTask.instance_id == str(context.instance.id), + WorkflowTask.node_id == task.node_id, + WorkflowTask.status == 'pending', + ) + result = await context.db.execute(stmt) + pending_tasks = result.scalars().all() + if len(pending_tasks) == 0: + await self.advance_to_next(context, node) + else: + await self.advance_to_next(context, node) + + async def _get_next_sequential_assignee( + self, + context: 'ExecutionContext', + current_task: Any, + ) -> Optional[str]: + """ + 获取依次审批的下一个审批人 + """ + from online_dev.workflow.model import WorkflowTask + from online_dev.workflow.engine.assignee_resolver import assignee_resolver + from online_dev.workflow.engine.utils import FlowUtils + + node = FlowUtils.find_node_by_id(context.flow_definition, current_task.node_id) + if not node: + return None + + node_config = self.get_node_config(node) + + # 获取所有审批人列表 + all_assignees = await assignee_resolver.resolve( + context.db, + node_config, + context.instance, + context.form_data, + ) + + if not all_assignees: + return None + + # 获取已处理的任务 + stmt = select(WorkflowTask.assignee_id).where( + WorkflowTask.instance_id == str(context.instance.id), + WorkflowTask.node_id == current_task.node_id, + WorkflowTask.status.in_(['approved', 'transferred']), + ).order_by(WorkflowTask.sys_create_datetime) + result = await context.db.execute(stmt) + handled_ids = [str(uid) for uid in result.scalars().all()] + + handled_set = set(handled_ids) + + # 找到下一个未处理的审批人 + for assignee_id in all_assignees: + if assignee_id not in handled_set: + return assignee_id + + return None diff --git a/backend-fastapi/online_dev/workflow/engine/handlers/base_handler.py b/backend-fastapi/online_dev/workflow/engine/handlers/base_handler.py new file mode 100644 index 0000000..9de1daf --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/handlers/base_handler.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +节点处理器基类 +定义所有节点处理器的通用接口和方法 +""" +import logging +from abc import ABC, abstractmethod +from datetime import datetime, timedelta +from typing import Dict, Any, TYPE_CHECKING + +from sqlalchemy import select + +if TYPE_CHECKING: + from online_dev.workflow.engine.base import ExecutionContext + +logger = logging.getLogger(__name__) + +# 与前端 NODE_TYPE_CONFIGS 默认名称保持一致 +DEFAULT_NODE_NAMES = { + 'start': '发起人', + 'approval': '审批人', + 'handle': '办理人', + 'copy': '抄送人', + 'delay': '延时等待', + 'notify': '发送通知', + 'service': '服务调用', + 'subflow': '子流程', + 'data_update': '字段更新', + 'condition': '条件分支', + 'parallel': '并行分支', + 'route': '路由', + 'end': '结束', +} + + +class BaseNodeHandler(ABC): + """ + 节点处理器基类 + + 所有节点处理器都应继承此类并实现 execute 方法 + """ + + def __init__(self, engine: Any): + """ + 初始化处理器 + + Args: + engine: 工作流引擎实例,用于调用引擎方法 + """ + self.engine = engine + + @abstractmethod + async def execute(self, context: 'ExecutionContext', node: Dict) -> None: + """ + 执行节点逻辑 + + Args: + context: 执行上下文 + node: 节点配置 + """ + pass + + def get_node_config(self, node: Dict) -> Dict: + """获取节点配置""" + return node.get('config', {}) + + def get_node_id(self, node: Dict) -> str: + """获取节点ID""" + return node.get('id', '') + + def get_node_name(self, node: Dict) -> str: + """获取节点名称(空名时回退为类型默认名)""" + name = (node.get('name') or '').strip() + if name: + return name + node_type = node.get('type', '') + return DEFAULT_NODE_NAMES.get(node_type, node_type or '节点') + + def get_node_type(self, node: Dict) -> str: + """获取节点类型""" + return node.get('type', '') + + async def update_instance_node(self, context: 'ExecutionContext', node: Dict) -> None: + """更新实例当前节点""" + context.instance.current_node_id = self.get_node_id(node) + context.instance.current_node_name = self.get_node_name(node) + context.db.add(context.instance) + await context.db.flush() + + async def create_log( + self, + context: 'ExecutionContext', + node: Dict, + action: str, + comment: str = '', + extra_data: Dict = None, + operator_id: str = None, + ) -> None: + """ + 创建流程日志 + + Args: + context: 执行上下文 + node: 节点配置 + action: 操作类型 + comment: 备注 + extra_data: 额外数据 + operator_id: 操作人ID,None时使用context.current_user_id,传空字符串表示系统自动执行 + """ + from online_dev.workflow.model import WorkflowLog + + log = WorkflowLog( + instance_id=str(context.instance.id), + node_id=self.get_node_id(node), + node_name=self.get_node_name(node), + action=action, + operator_id=operator_id if operator_id is not None else (context.current_user_id or ''), + comment=comment, + extra_data=extra_data or {}, + ) + context.db.add(log) + await context.db.flush() + + async def create_task( + self, + context: 'ExecutionContext', + node: Dict, + assignee_id: str, + task_type: str, + ) -> Any: + """ + 创建任务 + + Args: + context: 执行上下文 + node: 节点配置 + assignee_id: 处理人ID + task_type: 任务类型 + + Returns: + 创建的任务对象 + """ + from online_dev.workflow.model import WorkflowTask + from core.user.model import User + + # 验证用户存在 + stmt = select(User).where(User.id == assignee_id) + result = await context.db.execute(stmt) + assignee = result.scalar_one_or_none() + + if not assignee: + logger.warning(f"用户不存在: {assignee_id}") + return None + + # 计算超时时间 + node_config = self.get_node_config(node) + timeout_config = node_config.get('timeout', {}) + timeout_at = None + timeout_action = '' + + if timeout_config.get('enabled'): + duration = timeout_config.get('duration', 24) + unit = timeout_config.get('unit', 'hour') + timeout_action = timeout_config.get('action', 'notify') + timeout_at = self._calculate_timeout(duration, unit) + + task = WorkflowTask( + instance_id=str(context.instance.id), + node_id=self.get_node_id(node), + node_name=self.get_node_name(node), + task_type=task_type, + status='pending', + assignee_id=assignee_id, + timeout_at=timeout_at, + timeout_action=timeout_action, + ) + context.db.add(task) + await context.db.flush() + + # 发送通知 + await self._send_task_notification(context, task, assignee_id, task_type, node) + + return task + + def _calculate_timeout(self, duration: int, unit: str) -> datetime: + """计算超时时间""" + now = datetime.now() + if unit == 'minute': + return now + timedelta(minutes=duration) + elif unit == 'hour': + return now + timedelta(hours=duration) + elif unit == 'day': + return now + timedelta(days=duration) + return now + timedelta(hours=duration) + + async def _send_task_notification( + self, + context: 'ExecutionContext', + task: Any, + assignee_id: str, + task_type: str, + node: Dict, + ) -> None: + """发送任务通知""" + from online_dev.workflow.engine.handlers.notification_service import WorkflowNotificationService + + try: + await WorkflowNotificationService.send_task_notification( + context=context, + task=task, + assignee_id=assignee_id, + task_type=task_type, + node=node, + ) + except Exception as e: + logger.error(f"发送任务通知失败: {e}") + + async def advance_to_next(self, context: 'ExecutionContext', current_node: Dict) -> None: + """推进到下一节点(委托给引擎)""" + await self.engine._advance_to_next(context, current_node) diff --git a/backend-fastapi/online_dev/workflow/engine/handlers/condition_handler.py b/backend-fastapi/online_dev/workflow/engine/handlers/condition_handler.py new file mode 100644 index 0000000..2d44562 --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/handlers/condition_handler.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +条件分支节点处理器 +处理条件判断和分支选择 +""" +import logging +from typing import Dict, TYPE_CHECKING + +from online_dev.workflow.engine.handlers.base_handler import BaseNodeHandler + +if TYPE_CHECKING: + from online_dev.workflow.engine.base import ExecutionContext + +logger = logging.getLogger(__name__) + + +class ConditionHandler(BaseNodeHandler): + """ + 条件分支节点处理器 + + 根据条件表达式选择执行的分支 + - 条件组之间是 OR 关系 + - 组内条件是 AND 关系 + - 支持默认分支 + """ + + async def execute(self, context: 'ExecutionContext', node: Dict) -> None: + """ + 执行条件节点:评估条件并选择分支 + """ + from online_dev.workflow.engine.condition_evaluator import condition_evaluator + + branches = node.get('branches', []) + node_id = self.get_node_id(node) + + logger.info(f"处理条件分支 - 节点: {node_id}, 分支数: {len(branches)}") + + # 按优先级评估每个分支 + for branch in branches: + config = branch.get('config', {}) + + # 默认分支 + if config.get('isDefault'): + logger.info(f"进入默认分支: {branch.get('name')}") + await self._enter_branch(context, node, branch) + return + + # 评估条件 + groups = config.get('groups', []) + if condition_evaluator.evaluate_groups(groups, context.form_data): + logger.info(f"条件满足,进入分支: {branch.get('name')}") + await self._enter_branch(context, node, branch) + return + + # 没有分支满足条件 + logger.warning(f"条件节点 {node_id} 没有满足的分支,结束流程") + await self.engine._end_instance(context, 'rejected') + + async def _enter_branch( + self, + context: 'ExecutionContext', + condition_node: Dict, + branch: Dict, + ) -> None: + """ + 进入分支 + + Args: + context: 执行上下文 + condition_node: 条件节点 + branch: 选中的分支 + """ + # 记录条件分支选择日志(系统自动执行,不关联用户) + await self.create_log( + context, condition_node, 'condition', + comment=f'进入分支: {branch.get("name", "")}', + extra_data={ + 'branch_name': branch.get('name', ''), + 'branch_id': branch.get('id', ''), + }, + operator_id='', + ) + + branch_children = branch.get('children') + + if branch_children: + # 分支有子节点,推进到分支内的第一个节点 + await self.advance_to_next(context, {'children': branch_children}) + else: + # 分支无子节点,继续推进条件节点的下一节点 + await self.advance_to_next(context, condition_node) diff --git a/backend-fastapi/online_dev/workflow/engine/handlers/copy_handler.py b/backend-fastapi/online_dev/workflow/engine/handlers/copy_handler.py new file mode 100644 index 0000000..b84a1a2 --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/handlers/copy_handler.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +抄送节点处理器 +处理抄送任务的创建 +""" +import logging +from typing import Dict, TYPE_CHECKING + +from online_dev.workflow.engine.handlers.base_handler import BaseNodeHandler + +if TYPE_CHECKING: + from online_dev.workflow.engine.base import ExecutionContext + +logger = logging.getLogger(__name__) + + +class CopyHandler(BaseNodeHandler): + """ + 抄送节点处理器 + + 抄送节点创建抄送任务后自动推进到下一节点 + """ + + async def execute(self, context: 'ExecutionContext', node: Dict) -> None: + """ + 执行抄送节点:创建抄送任务并继续推进 + """ + from online_dev.workflow.engine.assignee_resolver import assignee_resolver + + node_config = self.get_node_config(node) + + # 解析抄送人 + assignee_ids = await assignee_resolver.resolve( + context.db, + node_config, + context.instance, + context.form_data, + ) + + logger.info(f"创建抄送任务 - 节点: {self.get_node_id(node)}, 抄送人数: {len(assignee_ids)}") + + # 创建抄送任务 + for assignee_id in assignee_ids: + await self.create_task(context, node, assignee_id, 'copy') + + # 记录抄送日志(系统自动执行,不关联用户) + await self.create_log( + context, node, 'copy', + comment=f'抄送给 {len(assignee_ids)} 人', + extra_data={ + 'assignee_ids': assignee_ids, + }, + operator_id='', + ) + + # 抄送后继续推进(不等待) + await self.advance_to_next(context, node) diff --git a/backend-fastapi/online_dev/workflow/engine/handlers/data_update_handler.py b/backend-fastapi/online_dev/workflow/engine/handlers/data_update_handler.py new file mode 100644 index 0000000..1584971 --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/handlers/data_update_handler.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +字段更新节点处理器 +在流程执行过程中自动修改表单字段值 +""" +import logging +import re +from datetime import datetime +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +from online_dev.workflow.engine.handlers.base_handler import BaseNodeHandler +from app.timezone import APP_TIMEZONE + +if TYPE_CHECKING: + from online_dev.workflow.engine.base import ExecutionContext + +logger = logging.getLogger(__name__) + + +class DataUpdateHandler(BaseNodeHandler): + """ + 字段更新节点处理器 + + 支持的值类型: + - constant: 常量值(直接赋值) + - field: 引用其他表单字段的值 + - formula: 简单表达式(支持变量替换和四则运算) + - system: 系统变量(当前时间、当前用户、流程编号等) + """ + + # 支持的系统变量 + SYSTEM_VARIABLES = { + 'current_time': '当前时间', + 'current_date': '当前日期', + 'current_user': '当前操作人', + 'initiator': '流程发起人', + 'instance_no': '流程编号', + 'instance_title': '流程标题', + } + + async def execute(self, context: 'ExecutionContext', node: Dict) -> None: + """ + 执行字段更新节点:根据规则修改表单字段值并回写数据库。 + 支持更新当前表单或跨表单(同应用下的其他表单)。 + """ + node_id = self.get_node_id(node) + node_name = self.get_node_name(node) + node_config = self.get_node_config(node) + + rules = node_config.get('rules', []) + target_form_code = node_config.get('targetFormCode', '') or '' + is_cross_form = bool(target_form_code) + + logger.info( + f"字段更新节点 - 节点: {node_id}, 名称: {node_name}, " + f"规则数: {len(rules)}, 跨表单: {is_cross_form}, 目标: {target_form_code or '当前表单'}" + ) + + await self.update_instance_node(context, node) + + if is_cross_form: + updated_count = await self._update_other_form(context, node_config) + log_comment = f'跨表单更新({target_form_code}): 更新了 {updated_count} 条记录' + else: + updated_count = await self._update_current_form(context, rules) + log_comment = f'字段更新: 更新了 {updated_count} 个字段' + + await self.create_log( + context, node, 'data_update', + comment=log_comment, + extra_data={'updated_count': updated_count, 'target_form_code': target_form_code or context.instance.form_code}, + operator_id='', + ) + + await self.advance_to_next(context, node) + + async def _update_current_form(self, context: 'ExecutionContext', rules: List[Dict]) -> int: + """更新当前表单字段(原有逻辑)""" + updated_fields: Dict[str, Any] = {} + for rule in rules: + field = rule.get('field', '') + if not field: + continue + try: + value = self._resolve_value(rule, context) + context.form_data[field] = value + updated_fields[field] = value + logger.info(f"字段更新: {field} = {value}") + except Exception as e: + logger.error(f"字段更新失败 - 字段: {field}, 错误: {e}") + + if updated_fields: + try: + from online_dev.workflow.engine.utils import FormDataUtils + await FormDataUtils.update_form_data( + context.db, + context.instance.form_code, + context.instance.form_data_id, + context.form_data, + ) + logger.info(f"表单数据已回写数据库,更新了 {len(updated_fields)} 个字段") + except Exception as e: + logger.error(f"回写表单数据失败: {e}") + + return len(updated_fields) + + async def _update_other_form(self, context: 'ExecutionContext', node_config: Dict) -> int: + """ + 跨表单更新:根据匹配条件查找目标表单的记录,并批量更新字段。 + """ + from online_dev.form_data_manager.service import FormDataService + + target_form_code: str = node_config['targetFormCode'] + match_condition: Optional[Dict] = node_config.get('matchCondition') + update_scope: str = node_config.get('updateScope', 'first') + rules: List[Dict] = node_config.get('rules', []) + + if not match_condition or not match_condition.get('sourceField') or not match_condition.get('targetField'): + logger.warning("跨表单更新缺少匹配条件,跳过") + return 0 + + source_field = match_condition['sourceField'] + target_field = match_condition['targetField'] + match_value = context.form_data.get(source_field) + + if match_value is None: + logger.warning(f"当前表单字段 {source_field} 值为空,跳过跨表单更新") + return 0 + + # 构建筛选条件查询目标表单数据 + filters = {target_field: match_value} + service = await FormDataService.create_service(context.db, target_form_code) + result = await service.list(context.db, page=1, page_size=1000, filters=filters) + records = result.get('items', []) + + if not records: + logger.info(f"跨表单更新: 未找到匹配的记录 ({target_field}={match_value})") + return 0 + + if update_scope == 'first': + records = records[:1] + + logger.info(f"跨表单更新: 匹配到 {len(records)} 条记录, 更新范围: {update_scope}") + + # 计算更新值 + update_values: Dict[str, Any] = {} + for rule in rules: + field = rule.get('field', '') + if not field: + continue + try: + value = self._resolve_value(rule, context) + update_values[field] = value + except Exception as e: + logger.error(f"跨表单字段值解析失败 - 字段: {field}, 错误: {e}") + + if not update_values: + return 0 + + updated_count = 0 + for record in records: + record_id = record.get('id') + if not record_id: + continue + try: + merged = {**record, **update_values} + data = {"main": merged, "sub_tables": {}} + await service.update(context.db, record_id, data) + updated_count += 1 + logger.info(f"跨表单更新记录 {record_id}: {update_values}") + except Exception as e: + logger.error(f"跨表单更新记录 {record_id} 失败: {e}") + + return updated_count + + def _resolve_value(self, rule: Dict, context: 'ExecutionContext') -> Any: + """ + 根据规则解析值 + + Args: + rule: 更新规则配置 + context: 执行上下文 + + Returns: + 解析后的值 + """ + value_type = rule.get('valueType', 'constant') + value = rule.get('value') + + if value_type == 'constant': + return value + + elif value_type == 'field': + # 引用其他字段的值 + source_field = str(value) if value else '' + return context.form_data.get(source_field) + + elif value_type == 'formula': + # 表达式计算 + return self._evaluate_formula(str(value) if value else '', context.form_data) + + elif value_type == 'system': + # 系统变量 + return self._get_system_variable(str(value) if value else '', context) + + else: + logger.warning(f"未知的值类型: {value_type}") + return value + + def _get_system_variable(self, var_name: str, context: 'ExecutionContext') -> Any: + """ + 获取系统变量值 + + Args: + var_name: 变量名 + context: 执行上下文 + + Returns: + 系统变量值 + """ + now = datetime.now(APP_TIMEZONE) + + if var_name == 'current_time': + return now.strftime('%Y-%m-%d %H:%M:%S') + elif var_name == 'current_date': + return now.strftime('%Y-%m-%d') + elif var_name == 'current_user': + return context.current_user_id or '' + elif var_name == 'initiator': + return context.get_initiator_id() + elif var_name == 'instance_no': + return context.instance.instance_no if context.instance else '' + elif var_name == 'instance_title': + return context.instance.title if context.instance else '' + else: + logger.warning(f"未知的系统变量: {var_name}") + return '' + + def _evaluate_formula(self, formula: str, form_data: Dict) -> Any: + """ + 计算表达式 + + 支持: + - 变量引用: {{field_name}} + - 四则运算: +, -, *, / + - 字符串拼接: 含非数字变量时自动拼接 + + Args: + formula: 表达式字符串 + form_data: 表单数据 + + Returns: + 计算结果 + """ + if not formula: + return '' + + # 替换变量 + def replace_var(match): + var_name = match.group(1).strip() + val = form_data.get(var_name, '') + return str(val) if val is not None else '' + + replaced = re.sub(r'\{\{(\w+)\}\}', replace_var, formula) + + # 尝试数学运算 + try: + # 安全地评估简单数学表达式 + if re.match(r'^[\d\s\+\-\*\/\.\(\)]+$', replaced.strip()): + result = eval(replaced.strip(), {"__builtins__": {}}, {}) + # 如果结果是整数,返回整数类型 + if isinstance(result, float) and result == int(result): + return int(result) + return result + except Exception: + pass + + # 非数学表达式,返回替换后的字符串 + return replaced diff --git a/backend-fastapi/online_dev/workflow/engine/handlers/delay_handler.py b/backend-fastapi/online_dev/workflow/engine/handlers/delay_handler.py new file mode 100644 index 0000000..4d6fcfd --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/handlers/delay_handler.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +延时节点处理器 +处理流程延时等待 +""" +import logging +from datetime import datetime, timedelta +from typing import Dict, TYPE_CHECKING + +from online_dev.workflow.engine.handlers.base_handler import BaseNodeHandler + +if TYPE_CHECKING: + from online_dev.workflow.engine.base import ExecutionContext + +logger = logging.getLogger(__name__) + + +class DelayNodeHandler(BaseNodeHandler): + """ + 延时节点处理器 + + 支持延时单位: + - minute: 分钟 + - hour: 小时 + - day: 天 + - workday: 工作日(跳过周六日) + """ + + async def execute(self, context: 'ExecutionContext', node: Dict) -> None: + """ + 执行延时节点:计算到期时间,注册定时任务 + """ + node_id = self.get_node_id(node) + node_name = self.get_node_name(node) + node_config = self.get_node_config(node) + + duration = node_config.get('duration', 1) + unit = node_config.get('unit', 'hour') + + unit_names = { + 'minute': '分钟', + 'hour': '小时', + 'day': '天', + 'workday': '工作日', + } + unit_name = unit_names.get(unit, unit) + + logger.info(f"延时节点 - 等待 {duration} {unit_name}") + + # 更新实例当前节点 + await self.update_instance_node(context, node) + + # 计算到期时间 + delay_until = self._calculate_delay_until(duration, unit) + + # 更新实例延时状态 + context.instance.delay_node_id = node_id + context.instance.delay_until = delay_until + context.db.add(context.instance) + await context.db.flush() + + # 记录延时开始日志 + await self.create_log( + context, node, 'delay_start', + comment=f'开始延时等待 {duration} {unit_name},预计 {delay_until.strftime("%Y-%m-%d %H:%M:%S")} 恢复', + extra_data={ + 'duration': duration, + 'unit': unit, + 'delay_until': delay_until.isoformat(), + }, + operator_id='', + ) + + # 注册定时任务 + from online_dev.workflow.engine.delay_callback import _register_delay_job + + instance_id = str(context.instance.id) + success = await _register_delay_job(instance_id, node_id, delay_until) + + if not success: + # 调度器不可用时,直接跳过延时继续推进(降级处理) + logger.warning(f"调度器不可用,延时节点直接跳过: instance={instance_id}, node={node_id}") + + # 清除延时状态 + context.instance.delay_node_id = "" + context.instance.delay_until = None + context.db.add(context.instance) + await context.db.flush() + + await self.create_log( + context, node, 'delay_skip', + comment='调度器不可用,延时跳过', + operator_id='', + ) + await self.advance_to_next(context, node) + + @staticmethod + def _calculate_delay_until(duration: int, unit: str) -> datetime: + """ + 计算延时到期时间 + + Args: + duration: 延时时长 + unit: 延时单位 (minute/hour/day/workday) + + Returns: + 到期时间 + """ + now = datetime.now() + + if unit == 'minute': + return now + timedelta(minutes=duration) + elif unit == 'hour': + return now + timedelta(hours=duration) + elif unit == 'day': + return now + timedelta(days=duration) + elif unit == 'workday': + return DelayNodeHandler._add_workdays(now, duration) + else: + # 默认按小时处理 + return now + timedelta(hours=duration) + + @staticmethod + def _add_workdays(start: datetime, days: int) -> datetime: + """ + 添加工作日(跳过周六日) + + Args: + start: 起始时间 + days: 工作日天数 + + Returns: + 目标时间(保持原始时分秒) + """ + current = start + added = 0 + while added < days: + current += timedelta(days=1) + # weekday(): 0=周一, 5=周六, 6=周日 + if current.weekday() < 5: + added += 1 + return current diff --git a/backend-fastapi/online_dev/workflow/engine/handlers/handle_handler.py b/backend-fastapi/online_dev/workflow/engine/handlers/handle_handler.py new file mode 100644 index 0000000..54dfbd0 --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/handlers/handle_handler.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +办理节点处理器 +处理办理任务的创建和完成逻辑 +""" +import logging +from typing import Dict, Any, Optional, TYPE_CHECKING + +from sqlalchemy import select + +from online_dev.workflow.engine.handlers.base_handler import BaseNodeHandler + +if TYPE_CHECKING: + from online_dev.workflow.engine.base import ExecutionContext + +logger = logging.getLogger(__name__) + + +class HandleHandler(BaseNodeHandler): + """ + 办理节点处理器 + + 办理节点类似审批节点,但办理完成后自动推进流程 + 支持: + - 任一办理(any) + - 全部办理(all) + - 依次办理(sequential) + """ + + async def execute(self, context: 'ExecutionContext', node: Dict) -> None: + """ + 执行办理节点:创建办理任务 + """ + from online_dev.workflow.engine.assignee_resolver import assignee_resolver + + node_id = self.get_node_id(node) + node_name = self.get_node_name(node) + node_config = self.get_node_config(node) + + logger.info(f"创建办理任务 - 节点: {node_id}, 名称: {node_name}") + + # 解析办理人 + assignee_ids = await assignee_resolver.resolve( + context.db, + node_config, + context.instance, + context.form_data, + ) + + if not assignee_ids: + logger.warning(f"节点 {node_id} 没有办理人,自动跳过") + await self.advance_to_next(context, node) + return + + # 更新实例当前节点 + await self.update_instance_node(context, node) + + # 创建任务 + multi_handle = node_config.get('multiHandle', 'any') + + if multi_handle == 'sequential': + # 依次办理:只创建第一个人的任务 + await self.create_task(context, node, assignee_ids[0], 'handle') + else: + # 任一办理/全部办理:创建所有人的任务 + for assignee_id in assignee_ids: + await self.create_task(context, node, assignee_id, 'handle') + + async def handle_completion(self, context: 'ExecutionContext', task: Any) -> None: + """ + 处理办理完成后的流程推进 + + Args: + context: 执行上下文 + task: 已完成的任务 + """ + from online_dev.workflow.model import WorkflowTask + from online_dev.workflow.engine.utils import FlowUtils + + node = FlowUtils.find_node_by_id(context.flow_definition, task.node_id) + if not node: + logger.error(f"找不到节点: {task.node_id}") + return + + node_config = self.get_node_config(node) + multi_handle = node_config.get('multiHandle', 'any') + + # 检查多人办理逻辑 + if multi_handle == 'all': + # 全部办理:检查是否所有人都已办理 + stmt = select(WorkflowTask).where( + WorkflowTask.instance_id == str(context.instance.id), + WorkflowTask.node_id == task.node_id, + WorkflowTask.status == 'pending', + ) + result = await context.db.execute(stmt) + pending_tasks = result.scalars().all() + + if len(pending_tasks) > 0: + logger.info(f"全部办理模式,还有 {len(pending_tasks)} 人未办理") + return + + elif multi_handle == 'sequential': + # 依次办理:检查是否还有下一个人 + next_assignee = await self._get_next_sequential_assignee(context, task) + if next_assignee: + await self.create_task(context, node, next_assignee, 'handle') + return + + # 办理完成,推进到下一节点 + await self.advance_to_next(context, node) + + async def _get_next_sequential_assignee( + self, + context: 'ExecutionContext', + current_task: Any, + ) -> Optional[str]: + """获取依次办理的下一个办理人""" + from online_dev.workflow.model import WorkflowTask + from online_dev.workflow.engine.assignee_resolver import assignee_resolver + from online_dev.workflow.engine.utils import FlowUtils + + node = FlowUtils.find_node_by_id(context.flow_definition, current_task.node_id) + if not node: + return None + + node_config = self.get_node_config(node) + + all_assignees = await assignee_resolver.resolve( + context.db, + node_config, + context.instance, + context.form_data, + ) + + if not all_assignees: + return None + + stmt = select(WorkflowTask.assignee_id).where( + WorkflowTask.instance_id == str(context.instance.id), + WorkflowTask.node_id == current_task.node_id, + WorkflowTask.status == 'handled', + ).order_by(WorkflowTask.sys_create_datetime) + result = await context.db.execute(stmt) + handled_ids = [str(uid) for uid in result.scalars().all()] + + handled_set = set(handled_ids) + + for assignee_id in all_assignees: + if assignee_id not in handled_set: + return assignee_id + + return None diff --git a/backend-fastapi/online_dev/workflow/engine/handlers/notification_service.py b/backend-fastapi/online_dev/workflow/engine/handlers/notification_service.py new file mode 100644 index 0000000..0327f69 --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/handlers/notification_service.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +通知服务 +处理流程中各种通知的发送 +集成 core.message 消息服务 +""" +import logging +from typing import Any, Dict, List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from online_dev.workflow.engine.base import ExecutionContext + +logger = logging.getLogger(__name__) + + +def _resolve_task_notify_channels(node: Dict) -> Optional[List[str]]: + """ + 解析节点 taskNotify 渠道。 + 返回 None 表示明确关闭任务通知。 + """ + node_config = node.get('config', {}) if node else {} + task_notify = node_config.get('taskNotify') or {} + if task_notify.get('enabled') is False: + return None + return task_notify.get('channels') or ['site'] + + +class WorkflowNotificationService: + """ + 工作流通知服务 + + 统一处理流程中的各种通知: + - 任务通知(待审批/待办理/抄送) + - 发起人通知(通过/拒绝/完成) + - 超时通知 + - 流程完成通知 + """ + + @staticmethod + async def send_task_notification( + context: 'ExecutionContext', + task: Any, + assignee_id: str, + task_type: str, + node: Dict, + ) -> None: + """ + 发送任务通知 + + Args: + context: 执行上下文 + task: 任务对象 + assignee_id: 处理人ID + task_type: 任务类型 + node: 节点配置 + """ + from core.message.service import NotifyService + + channels = _resolve_task_notify_channels(node) + if channels is None: + return + + type_labels = { + 'approval': '审批', + 'handle': '办理', + 'copy': '抄送', + } + type_label = type_labels.get(task_type, '处理') + + # 获取发起人名称 + initiator_name = '未知' + if context.instance.initiator_id: + from core.user.model import User + from sqlalchemy import select + stmt = select(User).where(User.id == context.instance.initiator_id) + result = await context.db.execute(stmt) + initiator = result.scalar_one_or_none() + if initiator: + initiator_name = initiator.name or initiator.username + + instance_title = context.instance.title + title = f"【{instance_title}】待{type_label}" + content = f"{initiator_name} 发起的【{instance_title}】需要您{type_label},请及时处理。" + + try: + await NotifyService.send( + db=context.db, + recipient_ids=[assignee_id], + title=title, + content=content, + channels=channels, + msg_type='workflow', + link_type='workflow_task', + link_id=str(task.id), + sender_id=context.current_user_id or None, + ) + logger.info(f"任务通知已发送: {assignee_id}, 类型: {task_type}, 渠道: {channels}") + except Exception as e: + logger.error(f"发送任务通知失败: {e}") + + @staticmethod + async def send_initiator_notification( + context: 'ExecutionContext', + node: Dict, + action: str, + ) -> None: + """ + 发送发起人通知 + + Args: + context: 执行上下文 + node: 当前节点 + action: 操作类型 (approve/reject/complete) + """ + from core.message.service import NotifyService + + node_config = node.get('config', {}) if node else {} + initiator_notify = node_config.get('initiatorNotify', {}) + + # 检查是否需要通知 + should_notify = False + if action == 'approve': + should_notify = initiator_notify.get('onApprove', False) + elif action == 'reject': + should_notify = initiator_notify.get('onReject', True) + elif action == 'complete': + should_notify = initiator_notify.get('onComplete', True) + + if not should_notify: + return + + initiator_id = context.instance.initiator_id + if not initiator_id: + return + + # 读取节点配置的通知渠道,默认站内信 + channels = initiator_notify.get('channels') or ['site'] + + node_name = node.get('name', '节点') + instance_title = context.instance.title + + action_labels = { + 'approve': '已通过', + 'reject': '已拒绝', + 'complete': '已完成', + } + action_label = action_labels.get(action, '已处理') + + title = f"【{instance_title}】{action_label}" + content = f"您发起的【{instance_title}】在【{node_name}】{action_label}。" + + if action == 'reject': + content += "请查看详情了解原因。" + + try: + await NotifyService.send( + db=context.db, + recipient_ids=[str(initiator_id)], + title=title, + content=content, + channels=channels, + msg_type='workflow', + link_type='workflow_instance', + link_id=str(context.instance.id), + sender_id=context.current_user_id or None, + ) + logger.info(f"发起人通知已发送: {initiator_id}, 操作: {action}, 渠道: {channels}") + except Exception as e: + logger.error(f"发送发起人通知失败: {e}") + + @staticmethod + async def send_instance_complete_notification( + context: 'ExecutionContext', + status: str, + ) -> None: + """ + 发送流程完成通知给发起人 + + Args: + context: 执行上下文 + status: 流程状态 (approved/rejected) + """ + from core.message.service import NotifyService + + initiator_id = context.instance.initiator_id + if not initiator_id: + return + + instance_title = context.instance.title + + if status == 'approved': + title = f"【{instance_title}】已通过" + content = f"您发起的【{instance_title}】已全部审批通过。" + elif status == 'rejected': + title = f"【{instance_title}】已被拒绝" + content = f"您发起的【{instance_title}】已被拒绝,请查看详情了解原因。" + else: + title = f"【{instance_title}】已结束" + content = f"您发起的【{instance_title}】已结束。" + + # 流程完成通知使用站内信(无节点配置可读取) + try: + await NotifyService.send( + db=context.db, + recipient_ids=[str(initiator_id)], + title=title, + content=content, + channels=['site'], + msg_type='workflow', + link_type='workflow_instance', + link_id=str(context.instance.id), + ) + logger.info(f"流程完成通知已发送: {initiator_id}, 状态: {status}") + except Exception as e: + logger.error(f"发送流程完成通知失败: {e}") + + @staticmethod + async def send_timeout_notification( + db, + task, + instance, + assignee, + ) -> None: + """ + 发送任务超时通知 + + Args: + db: 数据库会话 + task: 任务对象 + instance: 流程实例 + assignee: 处理人 + """ + from core.message.service import NotifyService + + if not assignee: + return + + # 获取发起人名称 + initiator_name = '' + if instance.initiator_id: + from core.user.model import User + from sqlalchemy import select + stmt = select(User).where(User.id == instance.initiator_id) + result = await db.execute(stmt) + initiator = result.scalar_one_or_none() + if initiator: + initiator_name = initiator.name or initiator.username + + title = '任务超时提醒' + content = ( + f'您有一个待办任务已超时,请尽快处理。\n' + f'流程标题:{instance.title}\n' + f'当前节点:{task.node_name}\n' + f'发起人:{initiator_name}' + ) + + # 超时通知使用站内信 + try: + await NotifyService.send( + db=db, + recipient_ids=[str(assignee.id)], + title=title, + content=content, + channels=['site'], + msg_type='workflow', + link_type='workflow_task', + link_id=str(task.id), + sender_id=str(instance.initiator_id) if instance.initiator_id else None, + ) + logger.info(f"超时通知已发送: task={task.id}, assignee={assignee.id}") + except Exception as e: + logger.error(f"发送超时通知失败: {e}") + + @staticmethod + async def load_instance_and_flow(db, task: Any): + """加载任务关联的实例与流程定义。""" + from online_dev.workflow.model import WorkflowDefinition, WorkflowInstance + from sqlalchemy import select + + stmt = select(WorkflowInstance).where( + WorkflowInstance.id == task.instance_id, + WorkflowInstance.is_deleted == False, + ) + result = await db.execute(stmt) + instance = result.scalar_one_or_none() + if not instance: + return None, {} + + stmt = select(WorkflowDefinition).where( + WorkflowDefinition.id == instance.workflow_id, + WorkflowDefinition.is_deleted == False, + ) + result = await db.execute(stmt) + workflow = result.scalar_one_or_none() + flow_definition = workflow.flow_definition if workflow else {} + return instance, flow_definition or {} + + @staticmethod + async def notify_pending_task( + db, + task: Any, + instance: Any, + flow_definition: Dict, + operator_id: str = None, + ) -> None: + """ + 为已创建的任务发送通知(转办/委派等场景复用 create_task 逻辑)。 + """ + from online_dev.workflow.engine.base import ExecutionContext + from online_dev.workflow.engine.utils import FlowUtils + + node = FlowUtils.find_node_by_id(flow_definition or {}, task.node_id) + if not node: + node = { + 'id': task.node_id, + 'name': task.node_name, + 'type': task.task_type, + 'config': {}, + } + + context = ExecutionContext( + instance=instance, + form_data={}, + current_user_id=operator_id or '', + flow_definition=flow_definition or {}, + db=db, + ) + await WorkflowNotificationService.send_task_notification( + context=context, + task=task, + assignee_id=str(task.assignee_id), + task_type=task.task_type, + node=node, + ) + + @staticmethod + async def send_urge_notifications( + db, + instance: Any, + pending_tasks: list, + flow_definition: Dict, + operator_id: str, + ) -> int: + """ + 催办:向待办人发送通知,渠道与节点 taskNotify 一致。 + + Returns: + 成功发送通知的去重处理人数量 + """ + from core.message.service import NotifyService + from core.user.model import User + from online_dev.workflow.engine.utils import FlowUtils + from sqlalchemy import select + + if not pending_tasks: + return 0 + + stmt = select(User).where(User.id == operator_id) + result = await db.execute(stmt) + initiator = result.scalar_one_or_none() + initiator_name = ( + (initiator.name or initiator.username) if initiator else '发起人' + ) + + instance_title = instance.title or '流程' + notified_assignees: set[str] = set() + sent_count = 0 + + for task in pending_tasks: + assignee_id = str(task.assignee_id) + if assignee_id in notified_assignees: + continue + + node = FlowUtils.find_node_by_id(flow_definition or {}, task.node_id) + channels = _resolve_task_notify_channels(node or {}) + if channels is None: + continue + + type_labels = { + 'approval': '审批', + 'handle': '办理', + } + type_label = type_labels.get(task.task_type, '处理') + title = f'【{instance_title}】催办提醒' + content = ( + f'{initiator_name} 催办您尽快完成【{instance_title}】的{type_label},' + f'当前节点:{task.node_name or "待处理"}。' + ) + + try: + await NotifyService.send( + db=db, + recipient_ids=[assignee_id], + title=title, + content=content, + channels=channels, + msg_type='workflow', + link_type='workflow_task', + link_id=str(task.id), + sender_id=operator_id, + ) + notified_assignees.add(assignee_id) + sent_count += 1 + logger.info( + f'催办通知已发送: instance={instance.id}, assignee={assignee_id}, ' + f'channels={channels}' + ) + except Exception as e: + logger.error(f'发送催办通知失败 assignee={assignee_id}: {e}') + + return sent_count diff --git a/backend-fastapi/online_dev/workflow/engine/handlers/notify_handler.py b/backend-fastapi/online_dev/workflow/engine/handlers/notify_handler.py new file mode 100644 index 0000000..b152d34 --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/handlers/notify_handler.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +通知节点处理器 +处理流程中的通知发送 +""" +import logging +from typing import Dict, TYPE_CHECKING + +from online_dev.workflow.engine.handlers.base_handler import BaseNodeHandler + +if TYPE_CHECKING: + from online_dev.workflow.engine.base import ExecutionContext + +logger = logging.getLogger(__name__) + + +class NotifyHandler(BaseNodeHandler): + """ + 通知节点处理器 + + 支持多种通知渠道: + - site: 站内信 + - email: 邮件 + - sms: 短信 + - wechat: 微信 + - dingtalk: 钉钉 + - feishu: 飞书 + """ + + async def execute(self, context: 'ExecutionContext', node: Dict) -> None: + """ + 执行通知节点:发送通知 + """ + from online_dev.workflow.engine.assignee_resolver import assignee_resolver + + node_id = self.get_node_id(node) + node_name = self.get_node_name(node) + node_config = self.get_node_config(node) + + # 解析通知对象 + recipient_ids = await assignee_resolver.resolve( + context.db, + { + 'assigneeType': node_config.get('recipientType', 'user'), + 'assignees': node_config.get('recipients', []), + 'assigneeLevel': node_config.get('recipientLevel'), + 'assigneeField': node_config.get('recipientField'), + 'assigneeFields': node_config.get('recipientFields'), + 'assigneeFieldMappings': node_config.get('recipientFieldMappings'), + }, + context.instance, + context.form_data, + ) + + channels = node_config.get('channels', ['site']) + title = node_config.get('title', '') + content = node_config.get('content', '') + + logger.info(f"通知节点 - 发送给 {len(recipient_ids)} 人,渠道: {channels}") + + # 构建模板变量上下文 + notify_context = { + 'initiator': '', + 'title': context.instance.title, + 'instance_no': context.instance.instance_no, + 'node_name': node_name, + 'form': context.form_data, + } + + # 调用通知服务发送 + if recipient_ids: + from core.message.service import NotifyService + await NotifyService.send( + db=context.db, + recipient_ids=recipient_ids, + title=title, + content=content, + channels=channels, + msg_type="workflow", + context=notify_context, + sender_id=context.instance.initiator_id, + ) + + # 记录通知日志 + await self.create_log( + context, node, 'notify', + comment=f'发送通知: {title}', + extra_data={ + 'recipients': recipient_ids, + 'channels': channels, + 'title': title, + 'content': content, + }, + ) + + logger.info(f"通知发送完成") + + # 通知发送后继续推进 + await self.advance_to_next(context, node) diff --git a/backend-fastapi/online_dev/workflow/engine/handlers/parallel_handler.py b/backend-fastapi/online_dev/workflow/engine/handlers/parallel_handler.py new file mode 100644 index 0000000..70e21a6 --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/handlers/parallel_handler.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +并行分支节点处理器 +处理并行分支的执行和汇聚 +""" +import copy +import logging +from typing import Dict, TYPE_CHECKING + +from sqlalchemy.orm.attributes import flag_modified + +from online_dev.workflow.engine.handlers.base_handler import BaseNodeHandler + +if TYPE_CHECKING: + from online_dev.workflow.engine.base import ExecutionContext + +logger = logging.getLogger(__name__) + + +class ParallelHandler(BaseNodeHandler): + """ + 并行分支节点处理器 + + 同时执行所有分支,所有分支完成后汇聚继续 + """ + + async def execute(self, context: 'ExecutionContext', node: Dict) -> None: + """ + 执行并行节点:同时启动所有分支 + """ + node_id = self.get_node_id(node) + node_name = self.get_node_name(node) + branches = node.get('branches', []) + + logger.info(f"处理并行分支 - 节点: {node_id}, 分支数: {len(branches)}") + + if not branches: + logger.warning(f"并行节点 {node_id} 没有分支,自动跳过") + await self.advance_to_next(context, node) + return + + # 更新实例当前节点 + await self.update_instance_node(context, node) + + # 初始化并行分支状态(深拷贝确保 SQLAlchemy 检测到变更) + parallel_status = copy.deepcopy(context.instance.parallel_branch_status or {}) + parallel_status[node_id] = { + branch.get('id', ''): 'pending' for branch in branches + } + context.instance.parallel_branch_status = parallel_status + flag_modified(context.instance, 'parallel_branch_status') + context.db.add(context.instance) + await context.db.flush() + + # 记录进入并行分支 + await self.create_log( + context, node, 'parallel_start', + comment=f'进入并行分支,共 {len(branches)} 个分支', + extra_data={ + 'branch_count': len(branches), + 'branch_ids': [b.get('id') for b in branches], + }, + ) + + # 同时执行所有分支 + for branch in branches: + branch_children = branch.get('children') + if branch_children: + await self._execute_branch(context, node, branch, branch_children) + else: + # 空分支,标记为完成 + await self.mark_branch_complete(context, node, branch) + + # 检查是否所有分支都已完成 + await self.check_completion(context, node) + + async def _execute_branch( + self, + context: 'ExecutionContext', + parallel_node: Dict, + branch: Dict, + first_node: Dict, + ) -> None: + """ + 执行并行分支中的第一个节点。 + 统一通过引擎 handler 分发,支持所有节点类型(approval, handle, copy, notify, + condition, delay, service, subflow, data_update, parallel 等)。 + 非阻塞节点执行后会自动调用 advance_to_next 推进;阻塞节点(approval/handle) + 在任务完成时由引擎推进。当分支走到末尾时,引擎的 _advance_to_next 会通过 + find_parallel_branch_for_node 找到所属并行分支并 mark_branch_complete。 + """ + node_type = first_node.get('type') + handler = self.engine._get_handler(node_type) + + if handler: + await handler.execute(context, first_node) + else: + logger.warning(f"并行分支内未知节点类型: {node_type},标记分支完成") + await self.mark_branch_complete(context, parallel_node, branch) + + async def mark_branch_complete( + self, + context: 'ExecutionContext', + parallel_node: Dict, + branch: Dict, + ) -> None: + """ + 标记并行分支完成 + """ + node_id = self.get_node_id(parallel_node) + branch_id = branch.get('id', '') + branch_name = branch.get('name', '') + + # 更新分支状态(深拷贝确保 SQLAlchemy 检测到 JSON 字段变更) + await context.db.refresh(context.instance) + parallel_status = copy.deepcopy(context.instance.parallel_branch_status or {}) + + if node_id in parallel_status: + parallel_status[node_id][branch_id] = 'completed' + context.instance.parallel_branch_status = parallel_status + flag_modified(context.instance, 'parallel_branch_status') + context.db.add(context.instance) + await context.db.flush() + + logger.info(f"并行分支完成 - 节点: {node_id}, 分支: {branch_id}") + + # 记录分支完成 + await self.create_log( + context, parallel_node, 'parallel_branch_complete', + comment=f'并行分支 {branch_name} 完成', + extra_data={'branch_id': branch_id}, + ) + + # 检查是否所有分支都已完成 + await self.check_completion(context, parallel_node) + + async def check_completion(self, context: 'ExecutionContext', parallel_node: Dict) -> None: + """ + 检查并行分支是否全部完成 + """ + node_id = self.get_node_id(parallel_node) + + # 刷新实例获取最新状态 + await context.db.refresh(context.instance) + parallel_status = copy.deepcopy(context.instance.parallel_branch_status or {}) + + branch_statuses = parallel_status.get(node_id, {}) + + if not branch_statuses: + logger.warning(f"并行节点 {node_id} 没有分支状态记录") + return + + # 检查是否所有分支都已完成 + all_completed = all(status == 'completed' for status in branch_statuses.values()) + pending_branches = [bid for bid, status in branch_statuses.items() if status != 'completed'] + + logger.info(f"并行节点 {node_id} 状态检查: 全部完成={all_completed}, 待完成={pending_branches}") + + if all_completed: + logger.info(f"并行节点 {node_id} 所有分支完成,继续推进") + + # 清理已完成的并行节点状态(深拷贝确保变更检测) + parallel_status = copy.deepcopy(parallel_status) + del parallel_status[node_id] + context.instance.parallel_branch_status = parallel_status + flag_modified(context.instance, 'parallel_branch_status') + context.db.add(context.instance) + await context.db.flush() + + # 记录日志 + await self.create_log( + context, parallel_node, 'parallel_complete', + comment='所有并行分支完成', + ) + + # 推进到下一节点 + await self.advance_to_next(context, parallel_node) diff --git a/backend-fastapi/online_dev/workflow/engine/handlers/service_handler.py b/backend-fastapi/online_dev/workflow/engine/handlers/service_handler.py new file mode 100644 index 0000000..a9734b8 --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/handlers/service_handler.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +服务调用节点处理器 +处理外部 HTTP 服务调用 +""" +import json +import logging +import re +from typing import Dict, Optional, TYPE_CHECKING + +import httpx + +from online_dev.workflow.engine.handlers.base_handler import BaseNodeHandler + +if TYPE_CHECKING: + from online_dev.workflow.engine.base import ExecutionContext + +logger = logging.getLogger(__name__) + + +class ServiceHandler(BaseNodeHandler): + """ + 服务调用节点处理器 + + 支持: + - HTTP 方法: GET/POST/PUT/DELETE/PATCH + - 请求头配置 + - 参数变量替换 + - 重试机制 + - 失败处理策略 + """ + + async def execute(self, context: 'ExecutionContext', node: Dict) -> None: + """ + 执行服务调用节点 + """ + node_id = self.get_node_id(node) + node_name = self.get_node_name(node) + node_config = self.get_node_config(node) + + url = node_config.get('url', '') + method = node_config.get('method', 'POST') + headers = {h['key']: h['value'] for h in node_config.get('headers', []) if h.get('key')} + params = node_config.get('params', '') + body = node_config.get('body', '') + timeout = node_config.get('timeout', 30) + retry_count = node_config.get('retryCount', 0) + fail_action = node_config.get('failAction', 'stop') + result_variable = node_config.get('resultVariable', '') + + logger.info(f"服务调用节点 - {method} {url}") + + # 更新实例当前节点 + await self.update_instance_node(context, node) + + success = False + response_data = None + error_message = '' + + # 尝试调用服务 + async with httpx.AsyncClient() as client: + for attempt in range(retry_count + 1): + try: + # 解析参数和请求体(支持变量替换) + parsed_params = self._parse_params(params, context.form_data) + parsed_body = self._parse_params(body, context.form_data) + + response = await client.request( + method=method, + url=url, + headers=headers, + params=parsed_params if method == 'GET' else None, + json=parsed_body if method != 'GET' and parsed_body else None, + timeout=timeout, + ) + + response.raise_for_status() + response_data = response.json() if response.text else {} + success = True + break + + except Exception as e: + error_message = str(e) + logger.warning(f"服务调用失败 (尝试 {attempt + 1}/{retry_count + 1}): {e}") + + # 记录日志 + await self.create_log( + context, node, 'service_call', + comment=f"{'成功' if success else '失败'}: {method} {url}", + extra_data={ + 'url': url, + 'method': method, + 'success': success, + 'response': response_data, + 'error': error_message, + }, + ) + + if success: + # 存储结果到流程变量 + if result_variable and response_data: + context.form_data[result_variable] = response_data + await self.advance_to_next(context, node) + else: + # 处理失败 + if fail_action == 'continue': + logger.warning(f"服务调用失败,继续流程") + await self.advance_to_next(context, node) + elif fail_action == 'stop': + logger.error(f"服务调用失败,终止流程") + await self.engine._end_instance(context, 'rejected') + + def _parse_params(self, params_str: str, form_data: Dict) -> Optional[Dict]: + """ + 解析服务调用参数,支持变量替换 + + 变量格式: ${field_name} + """ + if not params_str: + return None + + # 替换变量 + def replace_var(match): + var_name = match.group(1) + return str(form_data.get(var_name, '')) + + replaced = re.sub(r'\$\{(\w+)\}', replace_var, params_str) + + try: + return json.loads(replaced) + except json.JSONDecodeError: + return None diff --git a/backend-fastapi/online_dev/workflow/engine/handlers/subflow_handler.py b/backend-fastapi/online_dev/workflow/engine/handlers/subflow_handler.py new file mode 100644 index 0000000..8bc077a --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/handlers/subflow_handler.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +子流程节点处理器 +处理子流程的启动和完成 +""" +import logging +import uuid +from typing import Dict, TYPE_CHECKING + +from sqlalchemy import select + +from online_dev.workflow.engine.handlers.base_handler import BaseNodeHandler + +if TYPE_CHECKING: + from online_dev.workflow.engine.base import ExecutionContext + +logger = logging.getLogger(__name__) + + +class SubflowHandler(BaseNodeHandler): + """ + 子流程节点处理器 + + 支持: + - 变量传递(全部/选择/无) + - 等待子流程完成 + - 超时处理 + - 结果回传 + """ + + async def execute(self, context: 'ExecutionContext', node: Dict) -> None: + """ + 执行子流程节点:启动子流程 + """ + from online_dev.workflow.model import WorkflowDefinition, WorkflowInstance + from online_dev.workflow.engine.base import ExecutionContext as EC + + node_id = self.get_node_id(node) + node_name = self.get_node_name(node) + node_config = self.get_node_config(node) + + subflow_id = node_config.get('subflowId', '') + subflow_name = node_config.get('subflowName', '') + var_pass_mode = node_config.get('varPassMode', 'all') + selected_vars = node_config.get('selectedVars', []) + wait_for_completion = node_config.get('waitForCompletion', True) + + logger.info(f"子流程节点 - 启动子流程: {subflow_name} ({subflow_id})") + + if not subflow_id: + logger.warning(f"子流程节点 {node_id} 未配置子流程,自动跳过") + await self.advance_to_next(context, node) + return + + # 获取子流程定义 + stmt = select(WorkflowDefinition).where( + WorkflowDefinition.id == subflow_id, + WorkflowDefinition.status == 'published', + WorkflowDefinition.is_deleted == False, + ) + result = await context.db.execute(stmt) + subflow_definition = result.scalar_one_or_none() + + if not subflow_definition: + logger.error(f"子流程定义不存在或未发布: {subflow_id}") + await self.advance_to_next(context, node) + return + + # 更新实例当前节点 + await self.update_instance_node(context, node) + + # 准备传递给子流程的变量 + if var_pass_mode == 'all': + subflow_data = context.form_data.copy() + elif var_pass_mode == 'selected': + subflow_data = {k: v for k, v in context.form_data.items() if k in selected_vars} + else: + subflow_data = {} + + # 生成子流程实例编号 + sub_instance_no = f"SUB-{context.instance.instance_no}-{uuid.uuid4().hex[:6].upper()}" + + # 获取超时配置 + timeout = self._calculate_timeout(node_config) + timeout_action = node_config.get('timeoutAction', 'skip') + + # 创建子流程实例 + sub_instance = WorkflowInstance( + workflow_id=str(subflow_definition.id), + instance_no=sub_instance_no, + title=f"[子流程] {subflow_name} - {context.instance.title}", + status='pending', + initiator_id=context.current_user_id or '', + form_code=subflow_definition.form_code, + form_data_id=context.instance.form_data_id, + is_subflow=True, + parent_instance_id=str(context.instance.id), + parent_node_id=node_id, + subflow_timeout=timeout, + subflow_timeout_action=timeout_action, + ) + context.db.add(sub_instance) + await context.db.flush() + + # 记录日志 + await self.create_log( + context, node, 'subflow_start', + comment=f'启动子流程: {subflow_name}', + extra_data={ + 'subflow_id': subflow_id, + 'subflow_name': subflow_name, + 'sub_instance_id': str(sub_instance.id), + 'sub_instance_no': sub_instance_no, + 'var_pass_mode': var_pass_mode, + }, + ) + + logger.info(f"子流程实例已创建: {sub_instance_no}") + + # 启动子流程 + sub_flow_def = subflow_definition.flow_definition + sub_context = EC( + instance=sub_instance, + form_data=subflow_data, + current_user_id=context.current_user_id, + flow_definition=sub_flow_def, + db=context.db, + ) + + # 找到子流程的开始节点并执行 + start_node = sub_flow_def.get('nodes') + + if start_node and start_node.get('type') == 'start': + await self.engine._advance_to_next(sub_context, start_node) + else: + logger.error(f"子流程没有开始节点: {subflow_id}") + + # 如果不等待完成,主流程继续推进 + if not wait_for_completion: + logger.info(f"子流程不等待完成,主流程继续推进") + await self.advance_to_next(context, node) + else: + logger.info(f"主流程等待子流程完成: {sub_instance_no}") + + def _calculate_timeout(self, node_config: Dict) -> int: + """计算超时秒数""" + timeout_enabled = node_config.get('timeoutEnabled', False) + timeout_value = node_config.get('timeout', 24) + timeout_unit = node_config.get('timeoutUnit', 'hour') + + if not timeout_enabled or not timeout_value: + return None + + if timeout_unit == 'minute': + return timeout_value * 60 + elif timeout_unit == 'hour': + return timeout_value * 3600 + elif timeout_unit == 'day': + return timeout_value * 86400 + return timeout_value * 3600 + + async def resume_parent(self, db, sub_instance) -> None: + """ + 子流程完成后恢复父流程 + """ + from online_dev.workflow.model import WorkflowLog, WorkflowInstance, WorkflowDefinition + from online_dev.workflow.engine.base import ExecutionContext as EC + from online_dev.workflow.engine.utils import FlowUtils, FormDataUtils + + if not sub_instance.parent_instance_id: + return + + parent_node_id = sub_instance.parent_node_id + if not parent_node_id: + return + + # 获取父流程实例 + stmt = select(WorkflowInstance).where(WorkflowInstance.id == sub_instance.parent_instance_id) + result = await db.execute(stmt) + parent_instance = result.scalar_one_or_none() + + if not parent_instance: + return + + logger.info(f"子流程完成,恢复父流程: {parent_instance.instance_no}") + + # 获取父流程定义 + stmt = select(WorkflowDefinition).where(WorkflowDefinition.id == parent_instance.workflow_id) + result = await db.execute(stmt) + parent_workflow = result.scalar_one_or_none() + + if not parent_workflow: + return + + parent_flow_def = parent_workflow.flow_definition + + # 查找父流程等待的节点 + parent_node = FlowUtils.find_node_by_id(parent_flow_def, parent_node_id) + if not parent_node: + logger.error(f"父流程节点不存在: {parent_node_id}") + return + + # 获取表单数据 + form_data = await FormDataUtils.load_form_data( + db, + parent_instance.form_code, + parent_instance.form_data_id, + ) + + # 结果回传 + node_config = parent_node.get('config', {}) + result_pass_mode = node_config.get('resultPassMode', 'none') + result_vars = node_config.get('resultVars', []) + + if result_pass_mode != 'none': + sub_form_data = await FormDataUtils.load_form_data( + db, + sub_instance.form_code, + sub_instance.form_data_id, + ) + if sub_form_data: + if result_pass_mode == 'all': + form_data.update(sub_form_data) + elif result_pass_mode == 'selected': + for var in result_vars: + if var in sub_form_data: + form_data[var] = sub_form_data[var] + + # 记录日志 + log = WorkflowLog( + instance_id=str(parent_instance.id), + node_id=parent_node_id, + node_name=parent_node.get('name', '子流程'), + action='subflow_complete', + operator_id=sub_instance.initiator_id, + comment=f'子流程完成: {sub_instance.instance_no}, 状态: {sub_instance.status}', + extra_data={ + 'sub_instance_id': str(sub_instance.id), + 'sub_instance_no': sub_instance.instance_no, + 'sub_status': sub_instance.status, + }, + ) + db.add(log) + await db.flush() + + # 构建上下文并推进父流程 + parent_context = EC( + instance=parent_instance, + form_data=form_data, + current_user_id=sub_instance.initiator_id or '', + flow_definition=parent_flow_def, + db=db, + ) + + # 根据子流程结果决定父流程走向 + if sub_instance.status == 'approved': + await self.engine._advance_to_next(parent_context, parent_node) + elif sub_instance.status == 'rejected': + await self.engine._end_instance(parent_context, 'rejected') + else: + await self.engine._advance_to_next(parent_context, parent_node) diff --git a/backend-fastapi/online_dev/workflow/engine/task_timeout_process.py b/backend-fastapi/online_dev/workflow/engine/task_timeout_process.py new file mode 100644 index 0000000..a14b1cc --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/task_timeout_process.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +工作流任务超时处理器 +负责任务超时检测、提醒、自动处理等功能 +使用 APScheduler 实现定时检查 +""" +import logging +from datetime import datetime, timedelta + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + +TIMEOUT_CHECK_TASK_FUNC = ( + 'online_dev.workflow.engine.task_timeout_process.execute_timeout_check' +) +TIMEOUT_CHECK_JOB_CODE = 'workflow_task_timeout_check' + + +def calculate_timeout_datetime(duration: int, unit: str) -> datetime: + """ + 计算超时时间点 + + Args: + duration: 超时时长 + unit: 超时单位 - minute/hour/day + + Returns: + datetime: 超时时间点 + """ + now = datetime.now() + + if unit == 'minute': + return now + timedelta(minutes=duration) + elif unit == 'hour': + return now + timedelta(hours=duration) + elif unit == 'day': + return now + timedelta(days=duration) + else: + # 默认按小时 + return now + timedelta(hours=duration) + + +async def set_task_timeout(db: AsyncSession, task, timeout_config: dict) -> bool: + """ + 为任务设置超时时间 + + Args: + db: 数据库会话 + task: WorkflowTask 实例 + timeout_config: 超时配置 {enabled, duration, unit, action} + + Returns: + bool: 是否设置成功 + """ + if not timeout_config or not timeout_config.get('enabled'): + return False + + try: + duration = timeout_config.get('duration', 24) + unit = timeout_config.get('unit', 'hour') + action = timeout_config.get('action', 'notify') + + # 计算超时时间 + timeout_at = calculate_timeout_datetime(duration, unit) + + # 更新任务 + task.timeout_at = timeout_at + task.timeout_action = action + task.timeout_notified = False + db.add(task) + await db.flush() + + logger.info(f"任务 {task.id} 设置超时: {timeout_at}, 操作: {action}") + return True + + except Exception as e: + logger.error(f"设置任务超时失败: {e}") + return False + + +async def check_and_handle_timeouts(db: AsyncSession): + """ + 检查并处理超时任务 + 此函数应该由定时任务每分钟调用一次 + """ + from online_dev.workflow.model import WorkflowTask + + now = datetime.now() + + # 查找所有已超时但未处理的待办任务 + stmt = select(WorkflowTask).where( + WorkflowTask.status == 'pending', + WorkflowTask.timeout_at <= now, + WorkflowTask.timeout_at.isnot(None), + WorkflowTask.is_deleted == False, + ) + result = await db.execute(stmt) + timeout_tasks = result.scalars().all() + + logger.info(f"检查超时任务,发现 {len(timeout_tasks)} 个超时任务") + + for task in timeout_tasks: + try: + await _handle_timeout_task(db, task) + except Exception as e: + logger.exception(f"处理超时任务 {task.id} 失败: {e}") + + +async def _handle_timeout_task(db: AsyncSession, task): + """ + 处理单个超时任务 + """ + from online_dev.workflow.model import WorkflowInstance + + action = task.timeout_action or 'notify' + + # 获取实例 + stmt = select(WorkflowInstance).where(WorkflowInstance.id == task.instance_id) + result = await db.execute(stmt) + instance = result.scalar_one_or_none() + + if not instance or instance.status != 'pending': + # 流程已结束,跳过 + return + + logger.info(f"处理超时任务: task={task.id}, action={action}") + + if action == 'notify': + # 发送超时提醒通知 + await _send_timeout_notification(db, task, instance) + + elif action == 'auto_approve': + # 自动通过 + await _auto_complete_task(db, task, instance, 'approve') + + elif action == 'auto_reject': + # 自动拒绝 + await _auto_complete_task(db, task, instance, 'reject') + + +async def _send_timeout_notification(db: AsyncSession, task, instance): + """ + 发送超时提醒通知 + """ + from online_dev.workflow.model import WorkflowLog + from online_dev.workflow.engine.handlers.notification_service import WorkflowNotificationService + from core.user.model import User + + # 检查是否已发送过通知 + if task.timeout_notified: + return + + # 获取处理人 + stmt = select(User).where(User.id == task.assignee_id) + result = await db.execute(stmt) + assignee = result.scalar_one_or_none() + + if not assignee: + return + + # 获取发起人 + stmt = select(User).where(User.id == instance.initiator_id) + result = await db.execute(stmt) + initiator = result.scalar_one_or_none() + + try: + # 发送超时通知 + await WorkflowNotificationService.send_timeout_notification( + db=db, + task=task, + instance=instance, + assignee=assignee, + ) + + # 标记已通知 + task.timeout_notified = True + db.add(task) + + # 记录日志 + log = WorkflowLog( + instance_id=str(instance.id), + node_id=task.node_id, + node_name=task.node_name, + action='task_timeout_notify', + operator_id=str(initiator.id) if initiator else '', + comment=f'任务超时提醒已发送给 {assignee.name or assignee.username}', + extra_data={ + 'task_id': str(task.id), + 'assignee_id': str(assignee.id), + 'assignee_name': assignee.name or assignee.username, + }, + ) + db.add(log) + await db.flush() + + logger.info(f"已发送超时提醒: task={task.id}, assignee={assignee.id}") + + except Exception as e: + logger.error(f"发送超时提醒失败: {e}") + + +async def _auto_complete_task(db: AsyncSession, task, instance, action_type: str): + """ + 自动完成超时任务 + + Args: + db: 数据库会话 + task: 任务 + instance: 流程实例 + action_type: 'approve' 或 'reject' + """ + from online_dev.workflow.model import WorkflowLog, WorkflowDefinition + from online_dev.workflow.engine.workflow_engine import WorkflowEngine + from online_dev.workflow.engine.base import ExecutionContext + from core.user.model import User + + # 获取发起人作为操作者 + stmt = select(User).where(User.id == instance.initiator_id) + result = await db.execute(stmt) + operator = result.scalar_one_or_none() + + # 确定操作类型和日志动作 + if action_type == 'approve': + log_action = 'task_auto_approve' + status = 'approved' + comment = '任务超时,系统自动通过' + else: + log_action = 'task_auto_reject' + status = 'rejected' + comment = '任务超时,系统自动拒绝' + + # 更新任务状态 + task.status = status + task.comment = comment + task.handled_at = datetime.now() + db.add(task) + + # 记录日志 + log = WorkflowLog( + instance_id=str(instance.id), + node_id=task.node_id, + node_name=task.node_name, + action=log_action, + operator_id=str(operator.id) if operator else '', + comment=comment, + extra_data={ + 'task_id': str(task.id), + 'assignee_id': str(task.assignee_id), + 'timeout_at': task.timeout_at.isoformat() if task.timeout_at else None, + }, + ) + db.add(log) + await db.flush() + + logger.info(f"任务自动{action_type}: task={task.id}") + + # 完成该任务对应的钉钉待办 + try: + from core.message.service import NotifyService + await NotifyService.complete_dingtalk_todo(db, "workflow_task", str(task.id)) + except Exception as e: + logger.warning(f"超时自动处理-清理钉钉待办失败 task={task.id}: {e}") + + # 获取流程定义 + stmt = select(WorkflowDefinition).where(WorkflowDefinition.id == instance.workflow_id) + result = await db.execute(stmt) + workflow = result.scalar_one_or_none() + + if not workflow: + return + + # 使用流程引擎推进流程 + engine = WorkflowEngine() + + operator_id = str(operator.id) if operator else '' + context = ExecutionContext( + instance=instance, + form_data={}, + current_user_id=operator_id, + flow_definition=workflow.flow_definition, + db=db, + ) + + if action_type == 'approve': + # 通过:按任务类型推进(办理节点走办理完成逻辑) + if task.task_type == 'handle': + await engine._handle_handle_completion(context, task) + else: + await engine._handle_approval(context, task) + else: + # 拒绝:结束流程 + await engine._end_instance(context, 'rejected') + + +async def execute_timeout_check(**kwargs): + """ + 定时任务入口函数 + 由调度器每分钟调用 + """ + from app.database import AsyncSessionLocal + + logger.info("开始执行任务超时检查...") + try: + async with AsyncSessionLocal() as db: + await check_and_handle_timeouts(db) + await db.commit() + return "任务超时检查完成" + except Exception as e: + logger.exception(f"任务超时检查失败: {e}") + raise + + +async def create_timeout_check_job(db: AsyncSession) -> bool: + """ + 创建任务超时检查的定时任务 + 每分钟执行一次 + + Returns: + bool: 是否创建成功 + """ + try: + from scheduler.model import SchedulerJob + from scheduler.service import SchedulerService + + job_code = TIMEOUT_CHECK_JOB_CODE + + # 检查是否已存在 + stmt = select(SchedulerJob).where(SchedulerJob.code == job_code) + result = await db.execute(stmt) + existing = result.scalar_one_or_none() + + scheduler_service = SchedulerService() + + if existing: + updated = False + if existing.task_func != TIMEOUT_CHECK_TASK_FUNC: + existing.task_func = TIMEOUT_CHECK_TASK_FUNC + updated = True + logger.warning( + f'已修正任务超时检查 task_func: {TIMEOUT_CHECK_TASK_FUNC}' + ) + if existing.status != 1: + existing.status = 1 + updated = True + if updated: + db.add(existing) + await db.flush() + if scheduler_service.is_running(): + try: + await scheduler_service.remove_job(job_code) + except Exception: + pass + await scheduler_service.add_job(existing) + logger.info(f'任务超时检查定时任务已就绪: {job_code}') + return True + + # 创建定时任务(每分钟执行) + job = SchedulerJob( + name='工作流任务超时检查', + code=job_code, + description='每分钟检查一次超时的工作流任务,执行超时提醒或自动处理', + group='workflow', + trigger_type='interval', + interval_seconds=60, # 每60秒执行一次 + task_func=TIMEOUT_CHECK_TASK_FUNC, + task_kwargs='{}', + status=1, # 启用 + max_instances=1, + coalesce=True, + ) + db.add(job) + await db.flush() + + # 添加到调度器 + if scheduler_service.is_running(): + await scheduler_service.add_job(job) + + logger.info(f"创建任务超时检查定时任务成功: {job_code}") + return True + + except Exception as e: + logger.error(f"创建任务超时检查定时任务失败: {e}") + return False + + +async def remove_timeout_check_job(db: AsyncSession) -> bool: + """ + 移除任务超时检查的定时任务 + + Returns: + bool: 是否移除成功 + """ + try: + from scheduler.model import SchedulerJob + from scheduler.service import SchedulerService + + job_code = TIMEOUT_CHECK_JOB_CODE + + # 从调度器移除 + scheduler_service = SchedulerService() + if scheduler_service.is_running(): + await scheduler_service.remove_job(job_code) + + # 删除数据库记录 + stmt = select(SchedulerJob).where(SchedulerJob.code == job_code) + result = await db.execute(stmt) + job = result.scalar_one_or_none() + if job: + await db.delete(job) + await db.flush() + + logger.info(f"移除任务超时检查定时任务成功: {job_code}") + return True + + except Exception as e: + logger.error(f"移除任务超时检查定时任务失败: {e}") + return False diff --git a/backend-fastapi/online_dev/workflow/engine/utils.py b/backend-fastapi/online_dev/workflow/engine/utils.py new file mode 100644 index 0000000..9aba23d --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/utils.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +工作流引擎辅助工具 +包含节点查找、表单数据处理等通用方法 +""" +import logging +from typing import Dict, Optional, Tuple + +logger = logging.getLogger(__name__) + + +class FlowUtils: + """流程工具类""" + + @staticmethod + def find_node_by_id(flow_def: Dict, node_id: str) -> Optional[Dict]: + """ + 在流程定义中查找节点 + + Args: + flow_def: 流程定义 + node_id: 节点ID + + Returns: + 节点配置字典,未找到返回 None + """ + nodes = flow_def.get('nodes') + if not nodes: + return None + + return FlowUtils._find_node_recursive(nodes, node_id) + + @staticmethod + def _find_node_recursive(node: Dict, target_id: str) -> Optional[Dict]: + """递归查找节点""" + if node.get('id') == target_id: + return node + + # 在子节点中查找 + children = node.get('children') + if children: + result = FlowUtils._find_node_recursive(children, target_id) + if result: + return result + + # 在分支中查找 + branches = node.get('branches', []) + for branch in branches: + branch_children = branch.get('children') + if branch_children: + result = FlowUtils._find_node_recursive(branch_children, target_id) + if result: + return result + + return None + + @staticmethod + def find_parallel_branch_for_node(flow_def: Dict, node_id: str) -> Optional[Tuple[Dict, Dict]]: + """ + 查找节点所在的并行分支 + + Args: + flow_def: 流程定义 + node_id: 节点ID + + Returns: + (parallel_node, branch) 元组,如果节点不在并行分支内则返回 None + """ + nodes = flow_def.get('nodes') + if not nodes: + return None + + return FlowUtils._find_parallel_branch_recursive(nodes, node_id, None, None) + + @staticmethod + def _find_parallel_branch_recursive( + node: Dict, + target_id: str, + current_parallel: Optional[Dict], + current_branch: Optional[Dict] + ) -> Optional[Tuple[Dict, Dict]]: + """递归查找节点所在的并行分支""" + if not node or not isinstance(node, dict): + return None + + # 检查当前节点 + if node.get('id') == target_id: + if current_parallel and current_branch: + return (current_parallel, current_branch) + return None + + node_type = node.get('type') + + # 如果是并行节点,在其分支中查找 + if node_type == 'parallel': + branches = node.get('branches', []) + for branch in branches: + branch_children = branch.get('children') + if branch_children: + result = FlowUtils._find_parallel_branch_recursive( + branch_children, target_id, node, branch + ) + if result: + return result + + # 检查子节点 + children = node.get('children') + if children: + result = FlowUtils._find_parallel_branch_recursive( + children, target_id, current_parallel, current_branch + ) + if result: + return result + + # 检查条件分支 + if node_type == 'condition': + branches = node.get('branches', []) + for branch in branches: + branch_children = branch.get('children') + if branch_children: + result = FlowUtils._find_parallel_branch_recursive( + branch_children, target_id, current_parallel, current_branch + ) + if result: + return result + + return None + + @staticmethod + def find_condition_branch_for_node(flow_def: Dict, node_id: str) -> Optional[Dict]: + """ + 查找节点所在的条件分支,返回条件节点 + + Args: + flow_def: 流程定义 + node_id: 节点ID + + Returns: + 条件节点字典,如果节点不在条件分支内则返回 None + """ + nodes = flow_def.get('nodes') + if not nodes: + return None + + return FlowUtils._find_condition_branch_recursive(nodes, node_id, None) + + @staticmethod + def _find_condition_branch_recursive( + node: Dict, + target_id: str, + current_condition: Optional[Dict], + ) -> Optional[Dict]: + """递归查找节点所在的条件分支""" + if not node or not isinstance(node, dict): + return None + + if node.get('id') == target_id: + return current_condition + + node_type = node.get('type') + + # 如果是条件节点,在其分支中查找(标记当前条件节点) + if node_type == 'condition': + branches = node.get('branches', []) + for branch in branches: + branch_children = branch.get('children') + if branch_children: + result = FlowUtils._find_condition_branch_recursive( + branch_children, target_id, node + ) + if result is not None: + return result + + # 如果是并行节点,在其分支中查找(保持当前条件上下文) + if node_type == 'parallel': + branches = node.get('branches', []) + for branch in branches: + branch_children = branch.get('children') + if branch_children: + result = FlowUtils._find_condition_branch_recursive( + branch_children, target_id, current_condition + ) + if result is not None: + return result + + # 检查子节点 + children = node.get('children') + if children: + result = FlowUtils._find_condition_branch_recursive( + children, target_id, current_condition + ) + if result is not None: + return result + + return None + + @staticmethod + def find_innermost_branch_for_node( + flow_def: Dict, node_id: str + ) -> Optional[Tuple[str, Dict, Optional[Dict]]]: + """ + 查找节点所在的最内层分支归属(可能是条件分支或并行分支)。 + 用于 _advance_to_next 中正确处理嵌套场景(如并行内嵌条件、条件内嵌并行)。 + + Returns: + ('condition', condition_node, None) 或 + ('parallel', parallel_node, branch) 或 + None + """ + nodes = flow_def.get('nodes') + if not nodes: + return None + + return FlowUtils._find_innermost_branch_recursive(nodes, node_id, None) + + @staticmethod + def _find_innermost_branch_recursive( + node: Dict, + target_id: str, + innermost: Optional[Tuple[str, Dict, Optional[Dict]]], + ) -> Optional[Tuple[str, Dict, Optional[Dict]]]: + if not node or not isinstance(node, dict): + return None + + if node.get('id') == target_id: + return innermost + + node_type = node.get('type') + + if node_type == 'condition': + for branch in node.get('branches', []): + branch_children = branch.get('children') + if branch_children: + result = FlowUtils._find_innermost_branch_recursive( + branch_children, target_id, ('condition', node, None) + ) + if result is not None: + return result + + if node_type == 'parallel': + for branch in node.get('branches', []): + branch_children = branch.get('children') + if branch_children: + result = FlowUtils._find_innermost_branch_recursive( + branch_children, target_id, ('parallel', node, branch) + ) + if result is not None: + return result + + children = node.get('children') + if children: + result = FlowUtils._find_innermost_branch_recursive( + children, target_id, innermost + ) + if result is not None: + return result + + return None + + @staticmethod + def find_parent_node_id(flow_definition: Dict, target_node_id: str) -> Optional[str]: + """ + 从流程定义中查找目标节点的父节点ID(仅审批/办理节点) + """ + nodes = flow_definition.get('nodes', {}) + + def find_parent(node: Dict, parent_id: Optional[str] = None) -> Optional[str]: + if not node or not isinstance(node, dict): + return None + + node_id = node.get('id') + node_type = node.get('type') + + # 检查子节点 + children = node.get('children') + if children: + if isinstance(children, dict): + if children.get('id') == target_node_id: + if node_type in ('approval', 'handle'): + return node_id + return parent_id + result = find_parent(children, node_id if node_type in ('approval', 'handle') else parent_id) + if result: + return result + + # 检查分支 + branches = node.get('branches', []) + for branch in branches: + branch_children = branch.get('children') + if branch_children: + if isinstance(branch_children, dict) and branch_children.get('id') == target_node_id: + if node_type in ('approval', 'handle'): + return node_id + return parent_id + result = find_parent(branch_children, node_id if node_type in ('approval', 'handle') else parent_id) + if result: + return result + + return None + + return find_parent(nodes) + + +class FormDataUtils: + """表单数据工具类""" + + @staticmethod + async def save_form_data(db, form_code: str, form_data: Dict) -> str: + """ + 保存表单数据 + + Args: + db: 数据库会话 + form_code: 表单编码 + form_data: 表单数据(可能是扁平结构或 {main, sub_tables} 结构) + + Returns: + str: 数据ID + """ + from online_dev.form_data_manager.service import FormDataService + + # 创建表单数据服务 + service = await FormDataService.create_service(db, form_code) + + # 判断 form_data 是否已经是结构化数据 + if "main" in form_data: + # 已经是 {main, sub_tables} 结构,直接使用 + data = form_data + else: + # 扁平结构,需要包装 + data = {"main": form_data, "sub_tables": {}} + + # 保存数据 + try: + result = await service.create(db, data) + return result.get("id", "") + except Exception as e: + logger.error(f"保存表单数据失败: {e}") + await db.rollback() + raise ValueError(f"保存表单数据失败: {e}") + + @staticmethod + async def load_form_data(db, form_code: str, form_data_id: str) -> Dict: + """ + 加载表单数据 + + Args: + db: 数据库会话 + form_code: 表单编码 + form_data_id: 表单数据ID + + Returns: + Dict: 表单数据(扁平结构,包含关联字段的 _name 后缀) + """ + if not form_code or not form_data_id: + return {} + + try: + from online_dev.form_data_manager.service import FormDataService + + # 创建表单数据服务 + service = await FormDataService.create_service(db, form_code) + + # 获取数据 + result = await service.get(db, form_data_id) + + # 提取主表数据,转为扁平结构 + form_data = {} + if result: + # 主表字段 + for key, value in result.items(): + if key not in ('sub_tables', 'id'): + form_data[key] = value + + # 填充关联字段的显示名称(_name 后缀) + if form_data: + # 从 form_config 中提取所有关联字段 + relation_fields = {} + form_config = service._form_config + + def extract_relation_fields(items): + """递归提取关联字段""" + if not items: + return + for item in items: + item_type = item.get("type", "") + field = item.get("field", "") + + # 容器类型,递归处理 + if item_type == "grid": + for col in item.get("columns", []): + extract_relation_fields(col.get("children", [])) + elif item_type in ["collapse", "tabs"]: + for panel in item.get("children", []) or item.get("items", []): + extract_relation_fields(panel.get("children", [])) + elif item_type == "sub-table": + extract_relation_fields(item.get("children", [])) + elif field and item_type in ["user-selector", "dept-selector", "post-selector", "role-selector", "region-selector"]: + # 关联字段类型映射 + type_mapping = { + "user-selector": "core_user", + "dept-selector": "core_dept", + "post-selector": "core_post", + "role-selector": "core_role", + "region-selector": "core_region", + } + relation_table = type_mapping.get(item_type) + if relation_table: + relation_fields[field] = { + "display_field": f"{field}_name", + "relation_table": relation_table, + "relation_key": "id", + "display_column": "name", + } + + extract_relation_fields(form_config.get("items", [])) + + if relation_fields: + filled = await service._fill_relation_display_names(db, [form_data], relation_fields) + if filled: + form_data = filled[0] + + return form_data + + except Exception as e: + logger.error(f"加载表单数据失败: {e}") + return {} + + @staticmethod + async def update_form_data(db, form_code: str, form_data_id: str, form_data: Dict) -> None: + """更新表单数据""" + if not form_code or not form_data_id: + return + + try: + from online_dev.form_data_manager.service import FormDataService + + # 创建表单数据服务 + service = await FormDataService.create_service(db, form_code) + + # 构建数据结构 + data = {"main": form_data, "sub_tables": {}} + + # 更新数据 + await service.update(db, form_data_id, data) + + except Exception as e: + logger.error(f"更新表单数据失败: {e}") diff --git a/backend-fastapi/online_dev/workflow/engine/workflow_engine.py b/backend-fastapi/online_dev/workflow/engine/workflow_engine.py new file mode 100644 index 0000000..350ce8a --- /dev/null +++ b/backend-fastapi/online_dev/workflow/engine/workflow_engine.py @@ -0,0 +1,644 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +工作流引擎(异步版本) +负责流程的执行、推进、状态管理等核心逻辑 +""" +import logging +from datetime import datetime +from typing import Dict, Optional, Any + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from online_dev.workflow.engine.assignee_resolver import assignee_resolver +from online_dev.workflow.engine.base import ( + ExecutionContext, + TaskAction, + generate_instance_no, +) +from online_dev.workflow.engine.condition_evaluator import condition_evaluator +from online_dev.workflow.engine.utils import FlowUtils, FormDataUtils + +logger = logging.getLogger(__name__) + + +class WorkflowEngine: + """ + 工作流引擎 + + 职责: + 1. 流程启动 - 创建实例,执行第一个节点 + 2. 任务处理 - 审批/拒绝/转交 + 3. 流程推进 - 根据条件判断下一节点 + 4. 状态管理 - 更新实例和任务状态 + """ + + def __init__(self): + self.condition_evaluator = condition_evaluator + self.assignee_resolver = assignee_resolver + self._handlers = {} + + def _get_handler(self, node_type: str): + """获取节点处理器(懒加载)""" + if node_type not in self._handlers: + from online_dev.workflow.engine.handlers import ( + ApprovalHandler, + HandleHandler, + CopyHandler, + ConditionHandler, + ParallelHandler, + DelayNodeHandler, + NotifyHandler, + ServiceHandler, + SubflowHandler, + DataUpdateHandler, + ) + handler_map = { + 'approval': ApprovalHandler, + 'handle': HandleHandler, + 'copy': CopyHandler, + 'condition': ConditionHandler, + 'parallel': ParallelHandler, + 'delay': DelayNodeHandler, + 'notify': NotifyHandler, + 'service': ServiceHandler, + 'subflow': SubflowHandler, + 'data_update': DataUpdateHandler, + } + handler_class = handler_map.get(node_type) + if handler_class: + self._handlers[node_type] = handler_class(self) + return self._handlers.get(node_type) + + # ==================== 流程启动 ==================== + + async def start( + self, + db: AsyncSession, + workflow: Any, + title: str, + form_data: Dict, + initiator_id: str, + ) -> Any: + """ + 启动流程 + + Args: + db: 数据库会话 + workflow: 流程定义 + title: 流程标题 + form_data: 表单数据 + initiator_id: 发起人ID + + Returns: + WorkflowInstance: 流程实例 + """ + from online_dev.workflow.model import WorkflowInstance, WorkflowLog + + # 缓存 workflow 属性,避免 save_form_data 中 db.commit() 导致 session 对象过期 + _workflow_id = str(workflow.id) + _form_code = workflow.form_code + _flow_definition = workflow.flow_definition + + # 1. 保存表单数据 + form_data_id = await FormDataUtils.save_form_data(db, _form_code, form_data) + + # 2. 重新加载展平后的表单数据用于条件评估等引擎逻辑 + # 前端传入的 form_data 可能是 {main: {...}, sub_tables: {...}} 嵌套结构, + # 而引擎的条件评估器需要展平的 {field: value} 结构 + flat_form_data = await FormDataUtils.load_form_data(db, _form_code, form_data_id) + + # 3. 创建流程实例 + instance = WorkflowInstance( + workflow_id=_workflow_id, + instance_no=generate_instance_no(), + title=title, + status='pending', + initiator_id=initiator_id, + form_code=_form_code, + form_data_id=form_data_id, + current_node_id='start', + current_node_name='开始', + ) + db.add(instance) + await db.flush() + + # 记录启动日志 + log = WorkflowLog( + instance_id=str(instance.id), + node_id='start', + node_name='开始', + action='start', + operator_id=initiator_id, + comment='发起流程', + ) + db.add(log) + await db.flush() + + # 创建执行上下文 + context = ExecutionContext( + instance=instance, + form_data=flat_form_data or form_data, + current_user_id=initiator_id, + flow_definition=_flow_definition, + db=db, + ) + + # 执行开始节点,推进到下一节点 + start_node = context.flow_definition.get('nodes') + if start_node and start_node.get('type') == 'start': + await self._advance_to_next(context, start_node) + else: + logger.error("找不到开始节点!") + + await db.refresh(instance) + return instance + + async def restart_instance(self, db: AsyncSession, instance: Any, user_id: str) -> Any: + """ + 重新启动流程实例(驳回修改后重新提交) + """ + from online_dev.workflow.model import WorkflowDefinition + + stmt = select(WorkflowDefinition).where(WorkflowDefinition.id == instance.workflow_id) + result = await db.execute(stmt) + workflow = result.scalar_one_or_none() + + if not workflow: + raise ValueError("流程定义不存在") + + flow_definition = workflow.flow_definition + if not flow_definition: + raise ValueError("流程定义为空") + + # 加载表单数据 + form_data = {} + if instance.form_code and instance.form_data_id: + try: + form_data = await FormDataUtils.load_form_data(db, instance.form_code, instance.form_data_id) + except Exception as e: + logger.warning(f"restart_instance: 加载表单数据失败: {e}") + + context = ExecutionContext( + instance=instance, + form_data=form_data, + flow_definition=flow_definition, + current_user_id=user_id, + db=db, + ) + + start_node = flow_definition.get('nodes', {}) + if start_node and start_node.get('type') == 'start': + instance.status = 'pending' + db.add(instance) + await db.flush() + await self._advance_to_next(context, start_node) + + await db.refresh(instance) + return instance + + # ==================== 任务处理 ==================== + + async def complete_task( + self, + db: AsyncSession, + task: Any, + action: TaskAction, + comment: str, + user_id: str, + form_data: Optional[Dict] = None, + return_to: str = None, + ) -> Any: + """ + 完成任务 + + Args: + db: 数据库会话 + task: 任务 + action: 操作(approve/reject/return) + comment: 审批意见 + user_id: 操作用户ID + form_data: 表单数据(可能被修改) + return_to: 驳回目标(当 action=RETURN 时使用) + + Returns: + WorkflowTask: 更新后的任务 + """ + from online_dev.workflow.model import WorkflowLog, WorkflowInstance, WorkflowDefinition + + # 获取实例 + stmt = select(WorkflowInstance).where(WorkflowInstance.id == task.instance_id) + result = await db.execute(stmt) + instance = result.scalar_one_or_none() + + if not instance: + raise ValueError("流程实例不存在") + + # 获取流程定义 + stmt = select(WorkflowDefinition).where(WorkflowDefinition.id == instance.workflow_id) + result = await db.execute(stmt) + workflow = result.scalar_one_or_none() + + if not workflow: + raise ValueError("流程定义不存在") + + # 更新任务状态 + status_map = { + TaskAction.APPROVE: 'approved', + TaskAction.REJECT: 'rejected', + TaskAction.RETURN: 'returned', + TaskAction.DELEGATE: 'delegated', + } + task.status = status_map.get(action, action.value + 'd') + task.comment = comment + task.handled_at = datetime.now() + db.add(task) + await db.flush() + + # 记录日志 + log_extra_data = {} + # 如果有签名,记录到日志中 + if task.signature_file_id: + log_extra_data['signature_file_id'] = task.signature_file_id + if action == TaskAction.RETURN: + log_extra_data['return_to'] = return_to or 'initiator' + # 解析实际驳回目标节点名称 + if return_to == 'initiator' or not return_to: + log_extra_data['return_to_name'] = '发起人' + elif return_to == 'previous': + prev_node_id = await self._find_previous_node_id( + ExecutionContext( + instance=instance, + form_data={}, + current_user_id=user_id, + flow_definition=workflow.flow_definition, + db=db, + ), + task.node_id, + ) + if prev_node_id: + prev_node = FlowUtils.find_node_by_id(workflow.flow_definition, prev_node_id) + log_extra_data['return_to_name'] = prev_node.get('name', prev_node_id) if prev_node else prev_node_id + else: + log_extra_data['return_to_name'] = '发起人' + else: + target_node = FlowUtils.find_node_by_id(workflow.flow_definition, return_to) + log_extra_data['return_to_name'] = target_node.get('name', return_to) if target_node else return_to + log = WorkflowLog( + instance_id=str(instance.id), + node_id=task.node_id, + node_name=task.node_name, + action=action.value, + operator_id=user_id, + comment=comment, + extra_data=log_extra_data, + ) + db.add(log) + await db.flush() + + # 获取表单数据 + if form_data is None: + form_data = await FormDataUtils.load_form_data(db, instance.form_code, instance.form_data_id) + + # 创建执行上下文 + context = ExecutionContext( + instance=instance, + form_data=form_data, + current_user_id=user_id, + flow_definition=workflow.flow_definition, + db=db, + ) + + # 获取当前节点配置 + current_node = FlowUtils.find_node_by_id(context.flow_definition, task.node_id) + + # 完成该任务对应的钉钉待办 + if action in (TaskAction.APPROVE, TaskAction.REJECT, TaskAction.RETURN): + try: + from core.message.service import NotifyService + await NotifyService.complete_dingtalk_todo(db, "workflow_task", str(task.id)) + except Exception as e: + logger.warning(f"完成钉钉待办失败: {e}") + + # 根据操作推进流程 + if action == TaskAction.REJECT: + # 发送发起人通知(拒绝) + try: + from online_dev.workflow.engine.handlers.notification_service import WorkflowNotificationService + await WorkflowNotificationService.send_initiator_notification(context, current_node or {}, 'reject') + except Exception as e: + logger.error(f"发送发起人通知失败: {e}") + await self._end_instance(context, 'rejected') + elif action == TaskAction.APPROVE: + # 发送发起人通知(通过) + try: + from online_dev.workflow.engine.handlers.notification_service import WorkflowNotificationService + await WorkflowNotificationService.send_initiator_notification(context, current_node or {}, 'approve') + except Exception as e: + logger.error(f"发送发起人通知失败: {e}") + await self._handle_approval(context, task) + elif action == TaskAction.RETURN: + await self._handle_return(context, task, return_to) + + return task + + async def transfer_task( + self, + db: AsyncSession, + task: Any, + to_user: Any, + comment: str, + user_id: str, + ) -> Any: + """转交任务""" + from online_dev.workflow.model import WorkflowTask, WorkflowLog + + task.status = 'transferred' + task.comment = comment + task.handled_at = datetime.now() + task.transferred_to_id = str(to_user.id) + db.add(task) + await db.flush() + + # 完成原任务对应的钉钉待办 + try: + from core.message.service import NotifyService + await NotifyService.complete_dingtalk_todo(db, "workflow_task", str(task.id)) + except Exception as e: + logger.warning(f"转交-清理钉钉待办失败 task={task.id}: {e}") + + new_task = WorkflowTask( + instance_id=task.instance_id, + node_id=task.node_id, + node_name=task.node_name, + task_type=task.task_type, + status='pending', + assignee_id=str(to_user.id), + parent_task_id=str(task.id), + sign_type='transfer', + timeout_at=task.timeout_at, + timeout_action=task.timeout_action or '', + timeout_notified=False, + ) + db.add(new_task) + await db.flush() + + try: + from online_dev.workflow.engine.handlers.notification_service import ( + WorkflowNotificationService, + ) + + instance, flow_definition = await WorkflowNotificationService.load_instance_and_flow( + db, new_task + ) + if instance: + await WorkflowNotificationService.notify_pending_task( + db=db, + task=new_task, + instance=instance, + flow_definition=flow_definition, + operator_id=user_id, + ) + except Exception as e: + logger.warning(f'转办后发送任务通知失败 task={new_task.id}: {e}') + + log = WorkflowLog( + instance_id=task.instance_id, + node_id=task.node_id, + node_name=task.node_name, + action='transfer', + operator_id=user_id, + comment=f"转交给 {to_user.name or to_user.username}: {comment}", + extra_data={'to_user_id': str(to_user.id)}, + ) + db.add(log) + await db.flush() + + return new_task + + # ==================== 流程推进 ==================== + + async def _handle_approval(self, context: ExecutionContext, task: Any) -> None: + """处理审批通过后的流程推进""" + handler = self._get_handler('approval') + if handler: + await handler.handle_approval(context, task) + + async def _handle_handle_completion(self, context: ExecutionContext, task: Any) -> None: + """处理办理任务完成后的流程推进""" + handler = self._get_handler('handle') + if handler: + await handler.handle_completion(context, task) + + async def _handle_return(self, context: ExecutionContext, task: Any, return_to: str = None) -> None: + """处理驳回操作""" + from online_dev.workflow.model import WorkflowTask + + # 先收集待取消任务的ID,用于清理钉钉待办 + pending_stmt = select(WorkflowTask.id).where( + WorkflowTask.instance_id == str(context.instance.id), + WorkflowTask.node_id == task.node_id, + WorkflowTask.status == 'pending', + ) + pending_result = await context.db.execute(pending_stmt) + canceled_task_ids = [str(row[0]) for row in pending_result.all()] + + # 取消当前节点的所有待处理任务 + stmt = update(WorkflowTask).where( + WorkflowTask.instance_id == str(context.instance.id), + WorkflowTask.node_id == task.node_id, + WorkflowTask.status == 'pending', + ).values(status='canceled') + await context.db.execute(stmt) + + # 清理被取消任务的钉钉待办 + for tid in canceled_task_ids: + try: + from core.message.service import NotifyService + await NotifyService.complete_dingtalk_todo(context.db, "workflow_task", tid) + except Exception as e: + logger.warning(f"驳回-清理钉钉待办失败 task={tid}: {e}") + + if return_to == 'initiator' or not return_to: + await self._return_to_initiator(context, task) + elif return_to == 'previous': + previous_node_id = await self._find_previous_node_id(context, task.node_id) + if previous_node_id: + await self._return_to_node(context, task, previous_node_id) + else: + await self._return_to_initiator(context, task) + else: + await self._return_to_node(context, task, return_to) + + async def _return_to_initiator(self, context: ExecutionContext, task: Any) -> None: + """驳回给发起人""" + from online_dev.workflow.model import WorkflowTask + + instance = context.instance + + instance.current_node_id = 'start' + instance.current_node_name = '待修改' + context.db.add(instance) + await context.db.flush() + + # 创建发起人的修改任务 + revise_task = WorkflowTask( + instance_id=str(instance.id), + node_id='start', + node_name='待修改', + task_type='revise', + status='pending', + assignee_id=instance.initiator_id, + ) + context.db.add(revise_task) + await context.db.flush() + + + async def _return_to_node(self, context: ExecutionContext, task: Any, node_id: str) -> None: + """驳回到指定节点""" + target_node = FlowUtils.find_node_by_id(context.flow_definition, node_id) + if not target_node: + logger.warning(f"找不到驳回目标节点: {node_id}") + await self._return_to_initiator(context, task) + return + + context.instance.current_node_id = node_id + context.instance.current_node_name = target_node.get('name', '') + context.db.add(context.instance) + await context.db.flush() + + node_type = target_node.get('type') + handler = self._get_handler(node_type) + if handler: + await handler.execute(context, target_node) + + + async def _find_previous_node_id(self, context: ExecutionContext, current_node_id: str) -> Optional[str]: + """查找上一个节点的ID""" + from online_dev.workflow.model import WorkflowLog + + stmt = select(WorkflowLog).where( + WorkflowLog.instance_id == str(context.instance.id), + WorkflowLog.action.in_(['approve', 'return']), + WorkflowLog.node_id != current_node_id, + ).order_by(WorkflowLog.sys_create_datetime.desc()).limit(1) + + result = await context.db.execute(stmt) + previous_log = result.scalar_one_or_none() + + if previous_log and previous_log.node_id: + return previous_log.node_id + + return FlowUtils.find_parent_node_id(context.flow_definition, current_node_id) + + async def _advance_to_next(self, context: ExecutionContext, current_node: Dict) -> None: + """推进到下一节点""" + logger.info(f"_advance_to_next - 当前节点: {current_node.get('id')}, {current_node.get('type')}") + + next_node = self._get_next_node(context, current_node) + logger.info(f"_advance_to_next - 下一节点: {next_node}") + + if not next_node: + current_id = current_node.get('id', '') + + # 查找最内层的分支归属(正确处理嵌套:并行内嵌条件、条件内嵌并行等) + branch_info = FlowUtils.find_innermost_branch_for_node( + context.flow_definition, current_id + ) + if branch_info: + branch_type, branch_node, branch = branch_info + if branch_type == 'parallel' and branch is not None: + handler = self._get_handler('parallel') + if handler: + await handler.mark_branch_complete(context, branch_node, branch) + return + elif branch_type == 'condition': + logger.info(f"条件分支内节点 {current_id} 完成,从条件节点 {branch_node.get('id')} 继续推进") + await self._advance_to_next(context, branch_node) + return + + logger.warning(f"找不到下一节点,当前节点: {current_id}") + return + + node_type = next_node.get('type') + + if node_type == 'end': + await self._end_instance(context, 'approved') + else: + handler = self._get_handler(node_type) + if handler: + await handler.execute(context, next_node) + else: + logger.warning(f"未知节点类型: {node_type},自动跳过") + await self._advance_to_next(context, next_node) + + def _get_next_node(self, context: ExecutionContext, current_node: Dict) -> Optional[Dict]: + """获取下一节点""" + children = current_node.get('children') + if children: + return children + return None + + # ==================== 流程结束 ==================== + + async def _end_instance(self, context: ExecutionContext, status: str, current_node: Dict = None) -> None: + """结束流程实例""" + from online_dev.workflow.model import WorkflowTask + + instance = context.instance + instance.status = status + instance.completed_at = datetime.now() + instance.current_node_id = 'end' + instance.current_node_name = '结束' + context.db.add(instance) + await context.db.flush() + + # 查找即将被取消的待处理任务ID(用于清理钉钉待办) + pending_task_stmt = select(WorkflowTask.id).where( + WorkflowTask.instance_id == str(instance.id), + WorkflowTask.status == 'pending', + WorkflowTask.task_type != 'copy', + ) + pending_result = await context.db.execute(pending_task_stmt) + pending_task_ids = [str(row[0]) for row in pending_result.all()] + + # 取消所有待处理任务(排除抄送任务,抄送任务保留供用户查阅) + stmt = update(WorkflowTask).where( + WorkflowTask.instance_id == str(instance.id), + WorkflowTask.status == 'pending', + WorkflowTask.task_type != 'copy', + ).values(status='canceled') + await context.db.execute(stmt) + + # 删除/完成被取消任务对应的钉钉待办 + for task_id in pending_task_ids: + try: + from core.message.service import NotifyService + if status in ('approved', 'rejected'): + await NotifyService.complete_dingtalk_todo(context.db, "workflow_task", task_id) + else: + await NotifyService.delete_dingtalk_todo(context.db, "workflow_task", task_id) + except Exception as e: + logger.warning(f"清理钉钉待办失败 task={task_id}: {e}") + + # 发送流程完成通知给发起人 + try: + from online_dev.workflow.engine.handlers.notification_service import WorkflowNotificationService + await WorkflowNotificationService.send_instance_complete_notification(context, status) + except Exception as e: + logger.error(f"发送流程完成通知失败: {e}") + + # 如果是子流程,通知父流程继续 + if instance.parent_instance_id: + logger.info(f"子流程结束,通知父流程: {instance.parent_instance_id}") + await self._resume_parent_after_subflow(context.db, instance) + + async def _resume_parent_after_subflow(self, db: AsyncSession, sub_instance) -> None: + """子流程完成后恢复父流程""" + handler = self._get_handler('subflow') + if handler: + await handler.resume_parent(db, sub_instance) + +# 全局引擎实例 +workflow_engine = WorkflowEngine() diff --git a/backend-fastapi/online_dev/workflow/flow_definition_validator.py b/backend-fastapi/online_dev/workflow/flow_definition_validator.py new file mode 100644 index 0000000..5ede4ed --- /dev/null +++ b/backend-fastapi/online_dev/workflow/flow_definition_validator.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""流程定义结构校验(名称长度、发布前结构校验等)""" + +from typing import Dict, List, Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +MAX_NODE_NAME_LENGTH = 100 + +_ASSIGNEE_TYPES_NEED_LIST = ('user', 'role', 'department') + + +def validate_flow_definition_names(flow_definition: dict) -> None: + """ + 校验 flow_definition 中节点与分支名称长度。 + + Raises: + ValueError: 名称超过限制时 + """ + if not flow_definition or not isinstance(flow_definition, dict): + return + + nodes = flow_definition.get('nodes') + if not nodes or not isinstance(nodes, dict): + return + + def check_name(name: str, label: str) -> None: + if name and len(name) > MAX_NODE_NAME_LENGTH: + raise ValueError( + f'{label}名称不能超过 {MAX_NODE_NAME_LENGTH} 个字符' + ) + + def walk(node: dict) -> None: + if not node or not isinstance(node, dict): + return + + node_label = node.get('name') or node.get('id') or node.get('type') or '节点' + check_name(node.get('name') or '', node_label) + + for branch in node.get('branches') or []: + if not isinstance(branch, dict): + continue + branch_label = branch.get('name') or branch.get('id') or '分支' + check_name(branch.get('name') or '', branch_label) + config = branch.get('config') or {} + if isinstance(config, dict): + config_name = config.get('name') or '' + check_name(config_name, branch_label) + + child = branch.get('children') + if child: + walk(child) + + child = node.get('children') + if child: + walk(child) + + walk(nodes) + + +def _node_label(node: dict) -> str: + return node.get('name') or node.get('id') or node.get('type') or '节点' + + +def _find_nodes_of_type(node: Optional[dict], node_type: str) -> List[dict]: + if not node or not isinstance(node, dict): + return [] + + result: List[dict] = [] + if node.get('type') == node_type: + result.append(node) + + children = node.get('children') + if children: + result.extend(_find_nodes_of_type(children, node_type)) + + for branch in node.get('branches') or []: + if isinstance(branch, dict) and branch.get('children'): + result.extend(_find_nodes_of_type(branch['children'], node_type)) + + return result + + +def _assignees_configured(config: dict) -> bool: + if not isinstance(config, dict): + return False + + assignee_type = config.get('assigneeType') or 'user' + assignees = config.get('assignees') or [] + assignee_fields = config.get('assigneeFields') or [] + assignee_field = config.get('assigneeField') or '' + + if assignee_type in _ASSIGNEE_TYPES_NEED_LIST: + return len(assignees) > 0 + if assignee_type == 'form_field': + return len(assignee_fields) > 0 or bool(assignee_field) + # superior / manager / initiator 等无需显式配置列表 + return True + + +def validate_flow_definition_for_publish(flow_definition: dict) -> None: + """ + 发布前校验流程定义结构(与设计器 useFlowValidation 核心规则对齐)。 + + Raises: + ValueError: 不满足发布条件时 + """ + if not flow_definition or not isinstance(flow_definition, dict): + raise ValueError('流程定义为空') + + root = flow_definition.get('nodes') + if not root or not isinstance(root, dict): + raise ValueError('流程定义缺少有效节点') + + approval_nodes = _find_nodes_of_type(root, 'approval') + if not approval_nodes: + raise ValueError('流程必须包含至少一个审批节点') + + for node in approval_nodes: + label = _node_label(node) + if not _assignees_configured(node.get('config') or {}): + raise ValueError(f'审批节点「{label}」未配置审批人') + + for node in _find_nodes_of_type(root, 'handle'): + label = _node_label(node) + if not _assignees_configured(node.get('config') or {}): + raise ValueError(f'办理节点「{label}」未配置办理人') + + for node in _find_nodes_of_type(root, 'condition'): + label = _node_label(node) + branches = node.get('branches') or [] + if not branches: + raise ValueError(f'条件节点「{label}」未配置分支') + + has_default = False + for branch in branches: + if not isinstance(branch, dict): + continue + config = branch.get('config') or {} + if config.get('isDefault'): + has_default = True + continue + groups = config.get('groups') or [] + branch_label = branch.get('name') or branch.get('id') or '分支' + if not groups: + raise ValueError( + f'条件节点「{label}」的分支「{branch_label}」未配置条件' + ) + + if not has_default: + raise ValueError(f'条件节点「{label}」缺少默认分支') + + +async def validate_flow_definition_subflows_for_publish( + db: AsyncSession, + flow_definition: dict, +) -> None: + """ + 校验子流程节点引用的流程已发布。 + + Raises: + ValueError: 子流程未配置或未发布时 + """ + from online_dev.workflow.model import WorkflowDefinition + + root = (flow_definition or {}).get('nodes') + if not root: + return + + for node in _find_nodes_of_type(root, 'subflow'): + label = _node_label(node) + config = node.get('config') or {} + subflow_id = (config.get('subflowId') or '').strip() + if not subflow_id: + raise ValueError(f'子流程节点「{label}」未选择子流程') + + stmt = select(WorkflowDefinition).where( + WorkflowDefinition.id == subflow_id, + WorkflowDefinition.is_deleted == False, + ) + result = await db.execute(stmt) + child = result.scalar_one_or_none() + if not child: + raise ValueError(f'子流程节点「{label}」引用的流程不存在') + if child.status != 'published': + child_name = child.name or subflow_id + raise ValueError( + f'子流程节点「{label}」引用的流程「{child_name}」尚未发布' + ) diff --git a/backend-fastapi/online_dev/workflow/model.py b/backend-fastapi/online_dev/workflow/model.py new file mode 100644 index 0000000..862a4be --- /dev/null +++ b/backend-fastapi/online_dev/workflow/model.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +工作流数据模型 +""" +from sqlalchemy import Column, String, Text, Integer, Boolean, DateTime, Index, JSON +from sqlalchemy.sql import func + +from app.base_model import BaseModel + + +class WorkflowDefinition(BaseModel): + """工作流定义""" + __tablename__ = "workflow_definition" + + # 所属应用(逻辑外键关联 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="流程编码") + workflow_type = Column(String(20), default="other", comment="流程类型: approval/application/business/hr/finance/admin/other") + icon = Column(String(100), default="", comment="流程图标") + icon_bg_color = Column(String(200), default="", comment="图标背景色(支持渐变色)") + category = Column(String(50), default="", comment="分类") + description = Column(Text, default="", comment="描述") + status = Column(String(20), default="draft", comment="状态: draft/published/disabled") + version = Column(Integer, default=1, comment="版本号") + + # 关联表单 + form_code = Column(String(100), nullable=False, comment="关联表单编码") + form_name = Column(String(100), default="", comment="关联表单名称") + + # 关联文档模板(审批通过后自动生成) + document_template_codes = Column(JSON, default=list, comment="关联文档模板编码列表") + + # 流程定义(JSON格式存储节点和连线) + flow_definition = Column(JSON, default=dict, comment="流程定义") + + __table_args__ = ( + Index("ix_workflow_definition_code", "code"), + Index("ix_workflow_definition_status", "status"), + Index("ix_workflow_definition_form_code", "form_code"), + Index("ix_workflow_definition_workflow_type", "workflow_type"), + ) + + +class WorkflowInstance(BaseModel): + """工作流实例(流程发起后的实例)""" + __tablename__ = "workflow_instance" + + # 关联流程定义(逻辑外键) + workflow_id = Column(String(36), nullable=False, comment="流程定义ID") + + # 流程实例信息 + instance_no = Column(String(50), unique=True, nullable=False, comment="流程实例编号") + title = Column(String(200), nullable=False, comment="流程标题") + status = Column(String(20), default="pending", comment="状态: pending/approved/rejected/canceled") + + # 发起人(逻辑外键) + initiator_id = Column(String(36), nullable=False, comment="发起人ID") + + # 表单数据 + form_code = Column(String(100), nullable=False, comment="表单编码") + form_data_id = Column(String(36), nullable=False, comment="表单数据ID") + + # 当前节点 + current_node_id = Column(String(50), default="", comment="当前节点ID") + current_node_name = Column(String(100), default="", comment="当前节点名称") + + # 并行分支状态跟踪 + # 格式: {"parallel_node_id": {"branch_id1": "completed", "branch_id2": "pending"}} + parallel_branch_status = Column(JSON, default=dict, comment="并行分支状态") + + # 子流程关联 + is_subflow = Column(Boolean, default=False, comment="是否为子流程") + parent_instance_id = Column(String(36), nullable=True, comment="父流程实例ID") + parent_node_id = Column(String(50), default="", comment="父流程等待节点ID") + subflow_timeout = Column(Integer, nullable=True, comment="子流程超时时间(秒)") + subflow_timeout_action = Column(String(20), default="", comment="超时操作: skip/reject") + + # 延时节点状态 + delay_node_id = Column(String(50), default="", comment="延时等待的节点ID") + delay_until = Column(DateTime, nullable=True, comment="延时到期时间") + + # 时间记录 + started_at = Column(DateTime, server_default=func.now(), comment="发起时间") + completed_at = Column(DateTime, nullable=True, comment="完成时间") + + __table_args__ = ( + Index("ix_workflow_instance_workflow_id", "workflow_id"), + Index("ix_workflow_instance_initiator_id", "initiator_id"), + Index("ix_workflow_instance_status", "status"), + Index("ix_workflow_instance_instance_no", "instance_no"), + ) + + +class WorkflowTask(BaseModel): + """工作流任务(待办任务)""" + __tablename__ = "workflow_task" + + # 关联流程实例(逻辑外键) + instance_id = Column(String(36), nullable=False, comment="流程实例ID") + + # 任务信息 + node_id = Column(String(50), nullable=False, comment="节点ID") + node_name = Column(String(100), nullable=False, comment="节点名称") + task_type = Column(String(20), default="approval", comment="任务类型: approval/handle/copy/revise") + status = Column(String(20), default="pending", comment="状态: pending/waiting/approved/rejected/returned/transferred/delegated/handled/canceled/read") + + # 处理人(逻辑外键) + assignee_id = Column(String(36), nullable=False, comment="处理人ID") + + # 处理信息 + comment = Column(Text, default="", comment="审批意见") + signature_file_id = Column(String(36), nullable=True, comment="签名文件ID") + handled_at = Column(DateTime, nullable=True, comment="处理时间") + + # 转交信息(逻辑外键) + transferred_to_id = Column(String(36), nullable=True, comment="转交给用户ID") + + # 委派信息 + parent_task_id = Column(String(50), default="", comment="父任务ID(委派/加签时记录原任务)") + + # 加签信息 + sign_type = Column(String(20), default="", comment="加签类型: before/after/parallel/delegate/transfer") + + # 超时配置 + timeout_at = Column(DateTime, nullable=True, comment="超时时间") + timeout_action = Column(String(20), default="", comment="超时操作: notify/auto_approve/auto_reject") + timeout_notified = Column(Boolean, default=False, comment="是否已发送超时通知") + + __table_args__ = ( + Index("ix_workflow_task_instance_id", "instance_id"), + Index("ix_workflow_task_assignee_id", "assignee_id"), + Index("ix_workflow_task_status", "status"), + Index("ix_workflow_task_assignee_status", "assignee_id", "status"), + Index("ix_workflow_task_instance_status", "instance_id", "status"), + ) + + +class WorkflowLog(BaseModel): + """工作流操作日志""" + __tablename__ = "workflow_log" + + # 关联流程实例(逻辑外键) + instance_id = Column(String(36), nullable=False, comment="流程实例ID") + + # 操作信息 + node_id = Column(String(50), default="", comment="节点ID") + node_name = Column(String(100), default="", comment="节点名称") + action = Column(String(30), nullable=False, comment="操作类型") + + # 操作人(逻辑外键) + operator_id = Column(String(36), nullable=False, comment="操作人ID") + + # 操作详情 + comment = Column(Text, default="", comment="备注/意见") + extra_data = Column(JSON, default=dict, comment="额外数据") + + __table_args__ = ( + Index("ix_workflow_log_instance_id", "instance_id"), + Index("ix_workflow_log_operator_id", "operator_id"), + ) diff --git a/backend-fastapi/online_dev/workflow/progress_service.py b/backend-fastapi/online_dev/workflow/progress_service.py new file mode 100644 index 0000000..4a29271 --- /dev/null +++ b/backend-fastapi/online_dev/workflow/progress_service.py @@ -0,0 +1,730 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +流程进度聚合服务 +负责聚合流程执行路径、节点状态、处理人信息等 +""" +import logging +from dataclasses import dataclass, field, asdict +from typing import Dict, List, Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + + +@dataclass +class HandlerInfo: + """处理人信息""" + user_id: str + user_name: str + status: str # pending/approved/rejected/transferred/delegated/waiting + action: str = '' # approve/reject/transfer/delegate/add_sign + comment: str = '' + handled_at: str = '' + signature_file_id: str = '' # 签名文件ID + + def to_dict(self): + return asdict(self) + + +@dataclass +class ExtraAction: + """额外操作(加签、转交等)""" + type: str # add_sign/transfer/delegate/return + from_user_id: str = '' + from_user_name: str = '' + to_user_id: str = '' + to_user_name: str = '' + sign_type: str = '' # before/after/parallel(加签类型) + status: str = '' + time: str = '' + comment: str = '' # 操作说明/驳回原因 + + def to_dict(self): + return asdict(self) + + +@dataclass +class ProgressNode: + """进度节点""" + id: str + name: str + type: str # start/approval/handle/copy/condition/parallel/delay/notify/service/subflow/end + status: str # completed/active/pending/skipped/rejected + completed_at: str = '' + handlers: List[HandlerInfo] = field(default_factory=list) + extra_actions: List[ExtraAction] = field(default_factory=list) + # 条件分支信息 + condition_result: str = '' # 条件分支选中的分支名称 + # 并行分支信息 + branches: List[Dict] = field(default_factory=list) + # 延时节点信息 + delay_until: str = '' # 延时到期时间 + + def to_dict(self): + result = { + 'id': self.id, + 'name': self.name, + 'type': self.type, + 'status': self.status, + 'completed_at': self.completed_at, + 'handlers': [h.to_dict() for h in self.handlers], + 'extra_actions': [a.to_dict() for a in self.extra_actions], + } + if self.condition_result: + result['condition_result'] = self.condition_result + if self.branches: + result['branches'] = self.branches + if self.delay_until: + result['delay_until'] = self.delay_until + return result + + +@dataclass +class ReturnRecord: + """驳回记录""" + from_node_id: str + from_node_name: str + to_node_id: str + to_node_name: str + operator_name: str + reason: str + time: str + + def to_dict(self): + return asdict(self) + + +@dataclass +class FlowProgress: + """流程进度""" + instance_id: str + instance_status: str + current_node_id: str + nodes: List[ProgressNode] = field(default_factory=list) + returns: List[ReturnRecord] = field(default_factory=list) + + def to_dict(self): + return { + 'instance_id': self.instance_id, + 'instance_status': self.instance_status, + 'current_node_id': self.current_node_id, + 'nodes': [n.to_dict() for n in self.nodes], + 'returns': [r.to_dict() for r in self.returns], + } + + +class FlowProgressService: + """流程进度服务""" + + @staticmethod + async def get_progress(db: AsyncSession, instance_id: str) -> Optional[Dict]: + """ + 获取流程实例的执行进度 + + Args: + db: 数据库会话 + instance_id: 流程实例ID + + Returns: + FlowProgress 字典 + """ + from online_dev.workflow.model import WorkflowInstance, WorkflowDefinition, WorkflowTask, WorkflowLog + from core.user.model import User + + # 获取实例 + stmt = select(WorkflowInstance).where( + WorkflowInstance.id == instance_id, + WorkflowInstance.is_deleted == False, + ) + result = await db.execute(stmt) + instance = result.scalar_one_or_none() + + if not instance: + return None + + # 获取流程定义 + stmt = select(WorkflowDefinition).where(WorkflowDefinition.id == instance.workflow_id) + result = await db.execute(stmt) + workflow = result.scalar_one_or_none() + + if not workflow or not workflow.flow_definition: + return None + + flow_definition = workflow.flow_definition + + # 获取所有日志 + stmt = select(WorkflowLog).where( + WorkflowLog.instance_id == instance_id, + WorkflowLog.is_deleted == False, + ).order_by(WorkflowLog.sys_create_datetime) + result = await db.execute(stmt) + logs = list(result.scalars().all()) + + # 获取所有任务 + stmt = select(WorkflowTask).where( + WorkflowTask.instance_id == instance_id, + WorkflowTask.is_deleted == False, + ).order_by(WorkflowTask.sys_create_datetime) + result = await db.execute(stmt) + tasks = list(result.scalars().all()) + + # 获取用户信息 + user_ids = set() + if instance.initiator_id: + user_ids.add(instance.initiator_id) + for log in logs: + if log.operator_id: + user_ids.add(log.operator_id) + for task in tasks: + if task.assignee_id: + user_ids.add(task.assignee_id) + if task.transferred_to_id: + user_ids.add(task.transferred_to_id) + + users_map = {} + if user_ids: + stmt = select(User).where(User.id.in_(list(user_ids))) + result = await db.execute(stmt) + for user in result.scalars().all(): + users_map[str(user.id)] = user + + # 加载表单数据(用于解析表单字段类型的审批人) + from online_dev.workflow.engine.utils import FormDataUtils + form_data = {} + if instance.form_code and instance.form_data_id: + try: + form_data = await FormDataUtils.load_form_data(db, instance.form_code, instance.form_data_id) + except Exception as e: + logger.warning(f"加载表单数据失败: {e}") + + # 构建进度 + progress = FlowProgress( + instance_id=str(instance.id), + instance_status=instance.status, + current_node_id=instance.current_node_id or '', + ) + + # 构建日志索引(按节点ID分组) + logs_by_node = {} + for log in logs: + node_id = log.node_id or 'start' + if node_id not in logs_by_node: + logs_by_node[node_id] = [] + logs_by_node[node_id].append(log) + + # 构建任务索引(按节点ID分组) + tasks_by_node = {} + for task in tasks: + node_id = task.node_id + if node_id not in tasks_by_node: + tasks_by_node[node_id] = [] + tasks_by_node[node_id].append(task) + + # 确定每个节点的状态 + executed_nodes = set() + for log in logs: + if log.node_id: + executed_nodes.add(log.node_id) + # 有任务的节点也算已触达 + touched_nodes = executed_nodes | set(tasks_by_node.keys()) + + # 解析流程定义,提取节点列表(根据表单数据评估条件分支,展示完整审批路径) + nodes_list = FlowProgressService._extract_nodes(flow_definition, form_data) + + # 提取驳回记录 + progress.returns = FlowProgressService._extract_returns(logs, users_map, flow_definition) + + # 处理每个节点 + for node_info in nodes_list: + node_id = node_info['id'] + node_type = node_info['type'] + node_name = node_info['name'] + node_config = node_info.get('config', {}) + + # 确定节点状态 + status = FlowProgressService._determine_node_status( + node_id=node_id, + node_type=node_type, + instance=instance, + executed_nodes=executed_nodes, + logs_by_node=logs_by_node, + tasks_by_node=tasks_by_node, + ) + + progress_node = ProgressNode( + id=node_id, + name=node_name, + type=node_type, + status=status, + ) + + # 开始节点显示发起人 + if node_type == 'start': + initiator = users_map.get(str(instance.initiator_id)) if instance.initiator_id else None + progress_node.handlers = [HandlerInfo( + user_id=str(instance.initiator_id) if instance.initiator_id else '', + user_name=initiator.name if initiator else '', + status='completed', + action='start', + handled_at=instance.sys_create_datetime.strftime('%Y-%m-%d %H:%M:%S') if instance.sys_create_datetime else '', + )] + + # 获取节点的处理人信息 + if node_type in ['approval', 'handle', 'copy']: + if status in ['completed', 'active', 'rejected']: + progress_node.handlers = FlowProgressService._get_handlers( + node_id, tasks_by_node, logs_by_node, users_map, status + ) + progress_node.extra_actions = FlowProgressService._get_extra_actions( + node_id, tasks_by_node, logs_by_node, users_map, flow_definition + ) + elif status == 'pending': + progress_node.handlers = await FlowProgressService._get_pending_handlers( + db, node_config, instance, form_data, users_map + ) + + # 获取完成时间 + if status == 'completed': + progress_node.completed_at = FlowProgressService._get_completed_time( + node_id, logs_by_node + ) + + # 延时节点信息 + if node_type == 'delay' and status == 'active': + if instance.delay_node_id == node_id and instance.delay_until: + progress_node.delay_until = instance.delay_until.strftime('%Y-%m-%d %H:%M:%S') + + # 条件分支信息 + if node_type == 'condition' and node_id in executed_nodes: + progress_node.condition_result = FlowProgressService._get_condition_result( + node_id, node_info, logs_by_node + ) + + progress.nodes.append(progress_node) + + return progress.to_dict() + + @staticmethod + def _extract_nodes(flow_definition: Dict, form_data: Dict = None) -> List[Dict]: + """ + 从流程定义中提取线性化的节点列表 + + 对于条件分支: + - 根据表单数据评估条件,展示将要执行的分支 + - 这样可以在审批前就显示完整的审批路径 + """ + from online_dev.workflow.engine.condition_evaluator import condition_evaluator + + nodes = [] + visited = set() + form = form_data or {} + root = flow_definition.get('nodes') + if not root: + return nodes + + def _evaluate_branch(branch: Dict) -> bool: + """评估分支条件是否满足""" + config = branch.get('config', {}) + # 默认分支总是作为兜底 + if config.get('isDefault'): + return False # 默认分支不主动匹配,只在没有其他分支匹配时使用 + + # 获取条件组(字段名是 groups,与 condition_handler.py 保持一致) + groups = config.get('groups', []) + if not groups: + # 没有条件组,视为不匹配 + return False + + # 评估条件组(组之间是 OR 关系) + return condition_evaluator.evaluate_groups(groups, form) + + def _find_matching_branch(branches: List[Dict]) -> Dict: + """找到匹配的分支,如果没有匹配则返回默认分支""" + default_branch = None + for branch in branches: + config = branch.get('config', {}) + if config.get('isDefault'): + default_branch = branch + elif _evaluate_branch(branch): + return branch + # 没有匹配的分支,返回默认分支 + return default_branch + + def traverse(node: Dict, depth: int = 0, in_branch: bool = False): + if not node or not isinstance(node, dict): + return + + node_id = node.get('id', '') + node_type = node.get('type', '') + node_name = node.get('name', '') + + if node_id and node_id not in visited: + visited.add(node_id) + nodes.append({ + 'id': node_id, + 'type': node_type, + 'name': node_name, + 'depth': depth, + 'config': node.get('config', {}), + 'branches': node.get('branches', []), + 'in_branch': in_branch, + }) + + if node_type == 'condition': + branches = node.get('branches', []) + # 根据表单数据评估条件,找到将要执行的分支 + matching_branch = _find_matching_branch(branches) + + if matching_branch: + branch_children = matching_branch.get('children') + if branch_children: + traverse(branch_children, depth, in_branch=in_branch) + + if node_type == 'parallel': + branches = node.get('branches', []) + for branch in branches: + branch_children = branch.get('children') + if branch_children: + traverse(branch_children, depth + 1, in_branch=True) + + children = node.get('children') + if children: + traverse(children, depth, in_branch=in_branch) + + traverse(root) + return nodes + + @staticmethod + def _determine_node_status( + node_id: str, + node_type: str, + instance, + executed_nodes: set, + logs_by_node: Dict, + tasks_by_node: Dict, + ) -> str: + """确定节点状态""" + if node_type == 'start': + return 'completed' + + if instance.status == 'approved': + if node_type == 'end': + return 'completed' + if node_id in executed_nodes: + return 'completed' + return 'skipped' + + if instance.status == 'rejected': + node_logs = logs_by_node.get(node_id, []) + for log in node_logs: + if log.action == 'reject': + return 'rejected' + if node_id in executed_nodes: + return 'completed' + return 'skipped' + + if instance.status == 'canceled': + if node_id in executed_nodes: + return 'completed' + return 'skipped' + + if node_id == instance.current_node_id: + # 检查该节点是否还有未完成的任务 + node_tasks = tasks_by_node.get(node_id, []) + has_pending = any(t.status in ['pending', 'waiting'] for t in node_tasks) + if has_pending: + return 'active' + # 所有任务已完成但 current_node_id 未更新(条件分支推进 bug 的遗留数据) + if node_tasks and not has_pending: + return 'completed' + return 'active' + + if node_id in executed_nodes: + return 'completed' + + node_tasks = tasks_by_node.get(node_id, []) + for task in node_tasks: + if task.status in ['pending', 'waiting']: + return 'active' + + return 'pending' + + @staticmethod + def _get_handlers( + node_id: str, + tasks_by_node: Dict, + logs_by_node: Dict, + users_map: Dict, + node_status: str = '', + ) -> List[HandlerInfo]: + """获取节点的处理人信息""" + handlers = [] + tasks = tasks_by_node.get(node_id, []) + + before_sign_tasks = [] + normal_tasks = [] + after_sign_tasks = [] + delegate_tasks = [] + transfer_tasks = [] + parallel_sign_tasks = [] + + for task in tasks: + if task.sign_type == 'before': + before_sign_tasks.append(task) + elif task.sign_type == 'after': + after_sign_tasks.append(task) + elif task.sign_type == 'delegate': + delegate_tasks.append(task) + elif task.sign_type == 'transfer': + transfer_tasks.append(task) + elif task.sign_type == 'parallel': + parallel_sign_tasks.append(task) + else: + normal_tasks.append(task) + + # 节点已完成时,将未处理的任务标记为已跳过(或签场景) + node_completed = node_status == 'completed' + + def task_to_handler(task, sign_label: str = '') -> HandlerInfo: + user = users_map.get(str(task.assignee_id)) if task.assignee_id else None + task_status = task.status + # 抄送任务显示"已抄送"而非"已跳过" + if task.task_type == 'copy': + task_status = 'copied' + elif task_status == 'canceled' or (node_completed and task_status in ['pending', 'waiting']): + task_status = 'skipped' + handler = HandlerInfo( + user_id=str(task.assignee_id) if task.assignee_id else '', + user_name=user.name if user else '', + status=task_status, + ) + + if sign_label and task_status == 'pending': + handler.user_name = f"[{sign_label}] {handler.user_name}" + + if task.status == 'approved': + handler.action = 'approve' + handler.comment = task.comment or '' + handler.handled_at = task.handled_at.strftime('%Y-%m-%d %H:%M:%S') if task.handled_at else '' + handler.signature_file_id = task.signature_file_id or '' + elif task.status == 'rejected': + handler.action = 'reject' + handler.comment = task.comment or '' + handler.handled_at = task.handled_at.strftime('%Y-%m-%d %H:%M:%S') if task.handled_at else '' + elif task.status == 'returned': + handler.action = 'return' + handler.comment = task.comment or '' + handler.handled_at = task.handled_at.strftime('%Y-%m-%d %H:%M:%S') if task.handled_at else '' + elif task.status == 'transferred': + handler.action = 'transfer' + handler.comment = task.comment or '' + handler.handled_at = task.handled_at.strftime('%Y-%m-%d %H:%M:%S') if task.handled_at else '' + elif task.status == 'delegated': + handler.action = 'delegate' + handler.comment = task.comment or '' + handler.handled_at = task.handled_at.strftime('%Y-%m-%d %H:%M:%S') if task.handled_at else '' + elif task.status == 'handled': + handler.action = 'handle' + handler.comment = task.comment or '' + handler.handled_at = task.handled_at.strftime('%Y-%m-%d %H:%M:%S') if task.handled_at else '' + + return handler + + for task in before_sign_tasks: + handlers.append(task_to_handler(task, '前加签')) + for task in normal_tasks: + handlers.append(task_to_handler(task)) + for task in delegate_tasks: + handlers.append(task_to_handler(task, '委托')) + for task in transfer_tasks: + handlers.append(task_to_handler(task, '转交')) + for task in after_sign_tasks: + handlers.append(task_to_handler(task, '后加签')) + for task in parallel_sign_tasks: + handlers.append(task_to_handler(task, '并行加签')) + + return handlers + + @staticmethod + async def _get_pending_handlers( + db: AsyncSession, + node_config: Dict, + instance, + form_data: Dict, + users_map: Dict, + ) -> List[HandlerInfo]: + """获取待执行节点的预设处理人(通过 AssigneeResolver 解析具体用户)""" + from online_dev.workflow.engine.assignee_resolver import assignee_resolver + from core.user.model import User + + handlers = [] + assignee_type = node_config.get('assigneeType', 'user') + + try: + user_ids = await assignee_resolver.resolve(db, node_config, instance, form_data) + except Exception as e: + logger.warning(f"解析待执行节点审批人失败: {e}") + user_ids = [] + + if user_ids: + # 查询不在 users_map 中的用户 + missing_ids = [uid for uid in user_ids if uid not in users_map] + if missing_ids: + stmt = select(User).where(User.id.in_(missing_ids)) + result = await db.execute(stmt) + for user in result.scalars().all(): + users_map[str(user.id)] = user + + for uid in user_ids: + user = users_map.get(uid) + handlers.append(HandlerInfo( + user_id=uid, + user_name=user.name if user else '', + status='pending', + )) + else: + # 解析失败时的降级显示 + fallback_labels = { + 'role': '角色', + 'department': '部门', + 'superior': '上级主管', + 'manager': '直属经理', + 'form_field': '表单字段', + 'initiator': '发起人', + } + if assignee_type in fallback_labels: + handlers.append(HandlerInfo( + user_id='', + user_name=f'[{fallback_labels[assignee_type]}]', + status='pending', + )) + + return handlers + + @staticmethod + def _get_extra_actions( + node_id: str, + tasks_by_node: Dict, + logs_by_node: Dict, + users_map: Dict, + flow_definition: Dict = None, + ) -> List[ExtraAction]: + """获取节点的额外操作""" + actions = [] + logs = logs_by_node.get(node_id, []) + + for log in logs: + extra_data = log.extra_data or {} + time_str = log.sys_create_datetime.strftime('%Y-%m-%d %H:%M:%S') if log.sys_create_datetime else '' + operator = users_map.get(str(log.operator_id)) if log.operator_id else None + + if log.action == 'transfer': + action = ExtraAction( + type='transfer', + from_user_id=str(log.operator_id) if log.operator_id else '', + from_user_name=operator.name if operator else '', + to_user_id=str(extra_data.get('to_user_id', '')), + to_user_name=extra_data.get('to_user_name', ''), + time=time_str, + comment=log.comment or '', + ) + actions.append(action) + elif log.action == 'delegate': + action = ExtraAction( + type='delegate', + from_user_id=str(log.operator_id) if log.operator_id else '', + from_user_name=operator.name if operator else '', + to_user_id=str(extra_data.get('to_user_id', '')), + to_user_name=extra_data.get('to_user_name', ''), + time=time_str, + comment=log.comment or '', + ) + actions.append(action) + elif log.action == 'add_sign': + action = ExtraAction( + type='add_sign', + from_user_id=str(log.operator_id) if log.operator_id else '', + from_user_name=operator.name if operator else '', + to_user_id=str(extra_data.get('to_user_id', '')), + to_user_name=extra_data.get('to_user_name', ''), + sign_type=extra_data.get('sign_type', ''), + time=time_str, + comment=log.comment or '', + ) + actions.append(action) + elif log.action == 'return': + # 解析驳回目标节点名称 + to_name = extra_data.get('return_to_name', '') + if not to_name: + return_to = extra_data.get('return_to', 'initiator') + if return_to == 'initiator' or not return_to: + to_name = '发起人' + elif flow_definition: + from online_dev.workflow.engine.utils import FlowUtils + target_node = FlowUtils.find_node_by_id(flow_definition, return_to) + to_name = target_node.get('name', return_to) if target_node else return_to + else: + to_name = return_to + action = ExtraAction( + type='return', + from_user_id=str(log.operator_id) if log.operator_id else '', + from_user_name=operator.name if operator else '', + to_user_id='', + to_user_name=to_name, + time=time_str, + comment=log.comment or '', + ) + actions.append(action) + + return actions + + @staticmethod + def _get_completed_time(node_id: str, logs_by_node: Dict) -> str: + """获取节点完成时间""" + logs = logs_by_node.get(node_id, []) + for log in reversed(logs): + if log.action in ['approve', 'handle', 'start', 'parallel_complete', 'delay_complete', 'notify', + 'service_call', 'subflow_complete', 'condition', 'copy']: + return log.sys_create_datetime.strftime('%Y-%m-%d %H:%M:%S') if log.sys_create_datetime else '' + return '' + + @staticmethod + def _get_condition_result(node_id: str, node_info: Dict, logs_by_node: Dict) -> str: + """获取条件分支的选择结果""" + return '' + + @staticmethod + def _extract_returns(logs: List, users_map: Dict, flow_definition: Dict = None) -> List[ReturnRecord]: + """提取驳回记录""" + from online_dev.workflow.engine.utils import FlowUtils + + returns = [] + for log in logs: + if log.action == 'return': + extra_data = log.extra_data or {} + operator = users_map.get(str(log.operator_id)) if log.operator_id else None + # 解析驳回目标节点名称 + to_node_name = extra_data.get('return_to_name', '') + if not to_node_name: + return_to = extra_data.get('return_to', 'initiator') + if return_to == 'initiator' or not return_to: + to_node_name = '发起人' + elif flow_definition: + target_node = FlowUtils.find_node_by_id(flow_definition, return_to) + to_node_name = target_node.get('name', return_to) if target_node else return_to + else: + to_node_name = return_to + record = ReturnRecord( + from_node_id=log.node_id or '', + from_node_name=log.node_name or '', + to_node_id=extra_data.get('return_to', ''), + to_node_name=to_node_name, + operator_name=operator.name if operator else '', + reason=log.comment or '', + time=log.sys_create_datetime.strftime('%Y-%m-%d %H:%M:%S') if log.sys_create_datetime else '', + ) + returns.append(record) + return returns diff --git a/backend-fastapi/online_dev/workflow/schema.py b/backend-fastapi/online_dev/workflow/schema.py new file mode 100644 index 0000000..2224427 --- /dev/null +++ b/backend-fastapi/online_dev/workflow/schema.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +工作流 Schema 定义 +""" +from datetime import datetime +from typing import Optional, List, Any + +from pydantic import BaseModel, ConfigDict, Field + +from app.base_schema import CSTDatetime + + +# ============ 流程定义 Schema ============ + +class WorkflowDefinitionCreate(BaseModel): + """创建流程定义""" + application_id: Optional[str] = Field(None, description="所属应用ID") + name: str = Field(..., max_length=100, description="流程名称") + code: str = Field(..., max_length=100, pattern=r"^[a-zA-Z][a-zA-Z0-9_]*$", description="流程编码(字母开头,只能包含字母、数字和下划线)") + workflow_type: str = Field(default="other", max_length=20, description="流程类型: approval/application/business/hr/finance/admin/other") + icon: str = Field(default="", max_length=100, description="流程图标") + icon_bg_color: str = Field(default="", max_length=200, description="图标背景色(支持渐变色)") + category: str = Field(default="", max_length=50, description="分类") + description: str = Field(default="", description="描述") + form_code: str = Field(..., max_length=100, description="关联表单编码") + document_template_codes: List[str] = Field(default_factory=list, description="关联文档模板编码列表") + flow_definition: dict = Field(default_factory=dict, description="流程定义") + sort: int = Field(default=0, description="排序(数值越小越靠前)") + + +class WorkflowDefinitionUpdate(BaseModel): + """更新流程定义""" + name: Optional[str] = Field(None, max_length=100, description="流程名称") + workflow_type: Optional[str] = Field(None, max_length=20, description="流程类型") + icon: Optional[str] = Field(None, max_length=100, description="流程图标") + icon_bg_color: Optional[str] = Field(None, max_length=200, description="图标背景色(支持渐变色)") + category: Optional[str] = Field(None, max_length=50, description="分类") + description: Optional[str] = Field(None, description="描述") + form_code: Optional[str] = Field(None, max_length=100, description="关联表单编码") + document_template_codes: Optional[List[str]] = Field(None, description="关联文档模板编码列表") + flow_definition: Optional[dict] = Field(None, description="流程定义") + sort: Optional[int] = Field(None, description="排序(数值越小越靠前)") + + +class WorkflowDefinitionOut(BaseModel): + """流程定义输出""" + id: str + application_id: Optional[str] = None + name: str + code: str + workflow_type: str = "other" + icon: str = "" + icon_bg_color: str = "" + category: str + description: str + status: str + version: int + form_code: str + form_name: str + document_template_codes: List[str] = [] + flow_definition: dict + created_at: Optional[CSTDatetime] = Field(None, alias="sys_create_datetime") + updated_at: Optional[CSTDatetime] = Field(None, alias="sys_update_datetime") + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + +class WorkflowDefinitionListOut(BaseModel): + """流程定义列表输出""" + id: str + application_id: Optional[str] = None + application_name: Optional[str] = "" + name: str + code: str + workflow_type: str = "other" + icon: str = "" + icon_bg_color: str = "" + category: str + description: str + status: str + version: int + form_code: str + form_name: str + sort: int = 0 + created_at: Optional[CSTDatetime] = Field(None, alias="sys_create_datetime") + updated_at: Optional[CSTDatetime] = Field(None, alias="sys_update_datetime") + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + +# ============ 导入导出 Schema ============ + +class WorkflowImportCheckIn(BaseModel): + """流程导入预检查""" + code: str = Field(..., description="流程编码") + + +class WorkflowImportCheckOut(BaseModel): + """流程导入预检查结果""" + code_exists: bool = Field(..., description="流程编码是否已存在") + can_import: bool = Field(..., description="是否可以直接导入") + + +class WorkflowImportIn(BaseModel): + """流程配置导入""" + application_id: Optional[str] = Field(None, description="所属应用ID") + name: str = Field(..., max_length=100, description="流程名称") + code: str = Field(..., max_length=100, pattern=r"^[a-zA-Z][a-zA-Z0-9_]*$", description="流程编码") + workflow_type: str = Field(default="other", max_length=20, description="流程类型") + icon: str = Field(default="", max_length=100, description="流程图标") + icon_bg_color: str = Field(default="", max_length=200, description="图标背景色") + category: str = Field(default="", max_length=50, description="分类") + description: str = Field(default="", description="描述") + form_code: str = Field(..., max_length=100, description="关联表单编码") + document_template_codes: List[str] = Field(default_factory=list, description="关联文档模板编码列表") + flow_definition: dict = Field(default_factory=dict, description="流程定义") + sort: int = Field(default=0, description="排序") + + +# ============ 流程实例 Schema ============ + +class WorkflowInstanceCreate(BaseModel): + """发起流程""" + workflow_code: str = Field(..., description="流程编码") + title: str = Field(..., max_length=200, description="流程标题") + form_data: dict = Field(..., description="表单数据") + + +class WorkflowInstanceOut(BaseModel): + """流程实例输出""" + id: str + instance_no: str + title: str + status: str + workflow_id: str + workflow_name: str = "" + workflow_code: str = "" + form_code: str + form_data_id: str + current_node_id: str + current_node_name: str + initiator_id: str + initiator_name: str = "" + started_at: Optional[CSTDatetime] = None + completed_at: Optional[CSTDatetime] = None + has_document_templates: bool = False + + model_config = ConfigDict(from_attributes=True) + + +class WorkflowInstanceListOut(BaseModel): + """流程实例列表输出""" + id: str + instance_no: str + title: str + status: str + workflow_name: str = "" + application_name: str = "" + current_node_name: str + has_document_templates: bool + initiator_name: str = "" + started_at: Optional[CSTDatetime] = None + completed_at: Optional[CSTDatetime] = None + + model_config = ConfigDict(from_attributes=True) + + +# ============ 任务 Schema ============ + +class TaskApproveInput(BaseModel): + """审批任务""" + action: str = Field(..., description="操作: approve/reject/return") + comment: str = Field(default="", description="审批意见") + signature: Optional[str] = Field(None, description="签名数据(base64格式)") + return_to: Optional[str] = Field(None, description="驳回目标: initiator(发起人) 或 节点ID") + form_data: Optional[dict] = Field(None, description="修改后的表单数据(审批人编辑了可编辑字段时传入)") + + +class TaskHandleInput(BaseModel): + """办理任务""" + comment: str = Field(default="", description="办理意见") + form_data: Optional[dict] = Field(None, description="修改后的表单数据(办理人编辑了可编辑字段时传入)") + + +class TaskReviseInput(BaseModel): + """修改任务(驳回后重新提交)""" + form_data: dict = Field(..., description="修改后的表单数据") + comment: str = Field(default="", description="修改说明") + + +class TaskTransferInput(BaseModel): + """转交任务""" + to_user_id: str = Field(..., description="转交给用户ID") + comment: str = Field(default="", description="转交说明") + + +class TaskDelegateInput(BaseModel): + """委派任务""" + to_user_id: str = Field(..., description="委派给用户ID") + comment: str = Field(default="", description="委派说明") + + +class TaskAddSignInput(BaseModel): + """加签任务""" + sign_type: str = Field(..., description="加签类型: before(前加签)/after(后加签)/parallel(并行加签)") + to_user_ids: List[str] = Field(..., description="加签人ID列表") + comment: str = Field(default="", description="加签说明") + + +class WorkflowTaskOut(BaseModel): + """任务输出""" + id: str + instance_id: str + instance_no: str = "" + instance_title: str = "" + node_id: str + node_name: str + task_type: str + status: str + assignee_id: str + assignee_name: str = "" + comment: str + signature_file_id: Optional[str] = None + handled_at: Optional[CSTDatetime] = None + sys_create_datetime: Optional[CSTDatetime] = None + + # 取消原因(or_sign=或签被他人审批自动取消, revoked=发起人撤回, 空=其他) + cancel_reason: str = "" + + # 流程信息 + workflow_name: str = "" + initiator_name: str = "" + started_at: Optional[CSTDatetime] = None + + # 表单信息(任务详情时返回) + form_code: Optional[str] = None + form_config: Optional[dict] = None + form_data: Optional[dict] = None + form_permissions: Optional[list] = None + action_permissions: Optional[list] = None + require_signature: Optional[bool] = False + last_return_log: Optional[dict] = None + + model_config = ConfigDict(from_attributes=True) + + +class WorkflowTaskListOut(BaseModel): + """任务列表输出""" + id: str + instance_id: str + instance_no: str = "" + instance_title: str = "" + instance_status: str = "" + node_name: str + task_type: str + status: str + assignee_id: str = "" + assignee_name: str = "" + workflow_name: str = "" + initiator_name: str = "" + started_at: Optional[CSTDatetime] = None + sys_create_datetime: Optional[CSTDatetime] = None + has_document_templates: bool = False + # 超时相关字段 + timeout_at: Optional[CSTDatetime] = None + timeout_action: str = "" + is_timeout: bool = False + # 流程图标 + workflow_icon: str = "" + workflow_icon_bg_color: str = "" + + model_config = ConfigDict(from_attributes=True) + + +# ============ 日志 Schema ============ + +class WorkflowLogOut(BaseModel): + """日志输出""" + id: str + node_id: str + node_name: str + action: str + operator_id: str + operator_name: str = "" + comment: str + extra_data: Optional[dict] = None + sys_create_datetime: Optional[CSTDatetime] = None + + model_config = ConfigDict(from_attributes=True) + + +# ============ 通用 Schema ============ + +class MessageResponse(BaseModel): + """消息响应""" + message: str + + +class CountResponse(BaseModel): + """计数响应""" + count: int diff --git a/backend-fastapi/online_dev/workflow/service.py b/backend-fastapi/online_dev/workflow/service.py new file mode 100644 index 0000000..962e093 --- /dev/null +++ b/backend-fastapi/online_dev/workflow/service.py @@ -0,0 +1,1389 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +工作流服务层 + +数据权限: +- 使用 get_list_with_data_scope() 自动应用数据权限 +- 支持本人、本部门、本部门及下级、全部等数据范围 +""" +from datetime import datetime +from typing import Optional, List, Tuple + +from sqlalchemy import select, update, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from online_dev.workflow.model import ( + WorkflowDefinition, + WorkflowInstance, + WorkflowTask, + WorkflowLog, +) +from app.data_scope_utils import get_data_scope_filter, apply_data_scope_to_conditions +from utils.context import get_current_user_info_from_context + +# 资源类型(用于数据权限配置) +RESOURCE_TYPE = "workflow" +RESOURCE_DISPLAY_NAME = "工作流管理" + + +class WorkflowDefinitionService: + """流程定义服务""" + + @staticmethod + async def get_list( + db: AsyncSession, + page: int = 1, + page_size: int = 20, + application_id: str = None, + all_apps: bool = False, + name: str = None, + code: str = None, + workflow_type: str = None, + category: str = None, + status: str = None, + form_code: str = None, + ) -> Tuple[List[WorkflowDefinition], int]: + """获取流程定义列表""" + stmt = select(WorkflowDefinition).where(WorkflowDefinition.is_deleted == False) + + # 应用过滤 + if all_apps: + pass # 不过滤,返回所有应用的流程 + elif application_id: + stmt = stmt.where(WorkflowDefinition.application_id == application_id) + else: + # 如果没有指定 application_id,只返回主应用的工作流(application_id 为 NULL) + stmt = stmt.where(WorkflowDefinition.application_id.is_(None)) + + if name: + stmt = stmt.where(WorkflowDefinition.name.ilike(f"%{name}%")) + if code: + stmt = stmt.where(WorkflowDefinition.code.ilike(f"%{code}%")) + if workflow_type: + stmt = stmt.where(WorkflowDefinition.workflow_type == workflow_type) + if category: + stmt = stmt.where(WorkflowDefinition.category == category) + if status: + stmt = stmt.where(WorkflowDefinition.status == status) + if form_code: + stmt = stmt.where(WorkflowDefinition.form_code == form_code) + + # 计算总数 + count_stmt = select(func.count()).select_from(stmt.subquery()) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # 分页 + stmt = stmt.order_by(WorkflowDefinition.sort, WorkflowDefinition.sys_create_datetime.desc()) + stmt = stmt.offset((page - 1) * page_size).limit(page_size) + + result = await db.execute(stmt) + items = list(result.scalars().all()) + + return items, total + + @staticmethod + async def get_list_with_data_scope( + db: AsyncSession, + page: int = 1, + page_size: int = 20, + application_id: str = None, + all_apps: bool = False, + name: str = None, + code: str = None, + workflow_type: str = None, + category: str = None, + status: str = None, + form_code: str = None, + ) -> Tuple[List[WorkflowDefinition], int]: + """ + 获取流程定义列表(带数据权限过滤) + + 自动从上下文获取当前用户信息,应用数据权限过滤 + """ + conditions = [WorkflowDefinition.is_deleted == False] + + # 应用过滤 + if all_apps: + pass # 不过滤,返回所有应用的流程 + elif application_id: + conditions.append(WorkflowDefinition.application_id == application_id) + else: + conditions.append(WorkflowDefinition.application_id.is_(None)) + + if name: + conditions.append(WorkflowDefinition.name.ilike(f"%{name}%")) + if code: + conditions.append(WorkflowDefinition.code.ilike(f"%{code}%")) + if workflow_type: + conditions.append(WorkflowDefinition.workflow_type == workflow_type) + if category: + conditions.append(WorkflowDefinition.category == category) + if status: + conditions.append(WorkflowDefinition.status == status) + if form_code: + conditions.append(WorkflowDefinition.form_code == form_code) + + # 获取数据权限过滤条件并应用 + data_scope_filter = await get_data_scope_filter(db, RESOURCE_TYPE) + scope_conditions = apply_data_scope_to_conditions(WorkflowDefinition, data_scope_filter) + conditions.extend(scope_conditions) + + # 计算总数 + stmt = select(WorkflowDefinition).where(and_(*conditions)) + count_stmt = select(func.count()).select_from(stmt.subquery()) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # 分页 + stmt = stmt.order_by(WorkflowDefinition.sort, WorkflowDefinition.sys_create_datetime.desc()) + stmt = stmt.offset((page - 1) * page_size).limit(page_size) + + result = await db.execute(stmt) + items = list(result.scalars().all()) + + return items, total + + @staticmethod + async def get_by_id(db: AsyncSession, workflow_id: str) -> Optional[WorkflowDefinition]: + """根据ID获取流程定义""" + stmt = select(WorkflowDefinition).where( + WorkflowDefinition.id == workflow_id, + WorkflowDefinition.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[WorkflowDefinition]: + """根据编码获取流程定义""" + stmt = select(WorkflowDefinition).where( + WorkflowDefinition.code == code, + WorkflowDefinition.is_deleted == False, + ) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + @staticmethod + async def create(db: AsyncSession, data: dict, user_id: str) -> WorkflowDefinition: + """创建流程定义""" + from online_dev.workflow.flow_definition_validator import validate_flow_definition_names + + flow_def = data.get('flow_definition') + if flow_def: + validate_flow_definition_names(flow_def) + + # TODO: 获取表单名称 + form_name = "" + + # 从上下文获取用户信息 + user_info = get_current_user_info_from_context() + + workflow = WorkflowDefinition( + application_id=data.get('application_id'), + name=data['name'], + code=data['code'], + workflow_type=data.get('workflow_type', 'other'), + icon=data.get('icon', ''), + icon_bg_color=data.get('icon_bg_color', ''), + category=data.get('category', ''), + description=data.get('description', ''), + form_code=data['form_code'], + form_name=form_name, + document_template_codes=data.get('document_template_codes', []), + flow_definition=data.get('flow_definition', {}), + sort=data.get('sort', 0), + ) + + # 自动填充创建人和部门 + workflow.sys_creator_id = user_id or (user_info.get('user_id') if user_info else None) + if user_info and user_info.get('dept_id'): + workflow.sys_dept_id = user_info.get('dept_id') + + db.add(workflow) + await db.flush() + await db.refresh(workflow) + return workflow + + @staticmethod + async def update(db: AsyncSession, workflow_id: str, data: dict, user_id: str) -> Optional[WorkflowDefinition]: + """更新流程定义""" + workflow = await WorkflowDefinitionService.get_by_id(db, workflow_id) + if not workflow: + return None + + if 'name' in data and data['name']: + workflow.name = data['name'] + if 'workflow_type' in data and data['workflow_type']: + workflow.workflow_type = data['workflow_type'] + if 'icon' in data: + workflow.icon = data['icon'] or '' + if 'icon_bg_color' in data: + workflow.icon_bg_color = data['icon_bg_color'] or '' + if 'category' in data: + workflow.category = data['category'] or '' + if 'description' in data: + workflow.description = data['description'] or '' + if 'form_code' in data and data['form_code']: + workflow.form_code = data['form_code'] + # TODO: 更新表单名称 + if 'document_template_codes' in data: + workflow.document_template_codes = data['document_template_codes'] or [] + if 'flow_definition' in data and data['flow_definition']: + from online_dev.workflow.flow_definition_validator import validate_flow_definition_names + + validate_flow_definition_names(data['flow_definition']) + workflow.flow_definition = data['flow_definition'] + if 'sort' in data: + workflow.sort = data['sort'] if data['sort'] is not None else 0 + + db.add(workflow) + await db.flush() + await db.refresh(workflow) + return workflow + + @staticmethod + async def delete(db: AsyncSession, workflow_id: str) -> bool: + """删除流程定义(软删除)""" + workflow = await WorkflowDefinitionService.get_by_id(db, workflow_id) + if not workflow: + return False + + workflow.is_deleted = True + db.add(workflow) + await db.flush() + return True + + @staticmethod + async def batch_delete(db: AsyncSession, ids: List[str]) -> int: + """批量删除""" + stmt = update(WorkflowDefinition).where( + WorkflowDefinition.id.in_(ids), + WorkflowDefinition.is_deleted == False, + ).values(is_deleted=True) + result = await db.execute(stmt) + return result.rowcount + + @staticmethod + async def publish(db: AsyncSession, workflow_id: str, user_id: str) -> Optional[WorkflowDefinition]: + """发布流程""" + from online_dev.workflow.flow_definition_validator import ( + validate_flow_definition_for_publish, + validate_flow_definition_names, + validate_flow_definition_subflows_for_publish, + ) + + workflow = await WorkflowDefinitionService.get_by_id(db, workflow_id) + if not workflow: + return None + + flow_def = workflow.flow_definition + if not flow_def: + raise ValueError('流程定义为空,无法发布') + + validate_flow_definition_names(flow_def) + validate_flow_definition_for_publish(flow_def) + await validate_flow_definition_subflows_for_publish(db, flow_def) + + workflow.status = 'published' + workflow.version += 1 + db.add(workflow) + await db.flush() + await db.refresh(workflow) + return workflow + + @staticmethod + async def disable(db: AsyncSession, workflow_id: str, user_id: str) -> Optional[WorkflowDefinition]: + """停用流程""" + workflow = await WorkflowDefinitionService.get_by_id(db, workflow_id) + if not workflow: + return None + + workflow.status = 'disabled' + db.add(workflow) + await db.flush() + await db.refresh(workflow) + return workflow + + @staticmethod + async def copy(db: AsyncSession, workflow_id: str, new_code: str, new_name: str, user_id: str) -> Optional[WorkflowDefinition]: + """复制流程""" + workflow = await WorkflowDefinitionService.get_by_id(db, workflow_id) + if not workflow: + return None + + new_workflow = WorkflowDefinition( + application_id=workflow.application_id, + name=new_name or f"{workflow.name}_副本", + code=new_code, + category=workflow.category, + description=workflow.description, + form_code=workflow.form_code, + form_name=workflow.form_name, + flow_definition=workflow.flow_definition, + status='draft', + ) + db.add(new_workflow) + await db.flush() + await db.refresh(new_workflow) + return new_workflow + + @staticmethod + def _definition_to_export_dict(workflow: WorkflowDefinition) -> dict: + """将流程定义转为可导出的 JSON 结构""" + return { + "name": workflow.name, + "code": workflow.code, + "workflow_type": workflow.workflow_type or "other", + "icon": workflow.icon or "", + "icon_bg_color": workflow.icon_bg_color or "", + "category": workflow.category or "", + "description": workflow.description or "", + "form_code": workflow.form_code, + "document_template_codes": workflow.document_template_codes or [], + "flow_definition": workflow.flow_definition or {}, + "sort": workflow.sort or 0, + } + + @classmethod + async def export_config(cls, db: AsyncSession, workflow_id: str) -> Optional[dict]: + """导出流程配置""" + workflow = await cls.get_by_id(db, workflow_id) + if not workflow: + return None + return cls._definition_to_export_dict(workflow) + + @classmethod + async def check_import(cls, db: AsyncSession, code: str) -> dict: + """导入预检查:编码是否冲突""" + existing = await cls.get_by_code(db, code) if code else None + return { + "code_exists": existing is not None, + "can_import": existing is None, + } + + @classmethod + async def _resolve_form_name(cls, db: AsyncSession, form_code: str) -> str: + """根据表单编码解析表单名称""" + if not form_code: + return "" + from online_dev.form_manager.model import FormMeta + + stmt = select(FormMeta.name).where( + FormMeta.code == form_code, + FormMeta.is_deleted == False, + ) + result = await db.execute(stmt) + return result.scalar_one_or_none() or "" + + @classmethod + async def import_config( + cls, + db: AsyncSession, + data: dict, + user_id: str, + ) -> WorkflowDefinition: + """导入流程配置(创建新草稿流程)""" + if not data.get("name") or not data.get("code"): + raise ValueError("缺少必要字段: name 或 code") + if not data.get("form_code"): + raise ValueError("缺少必要字段: form_code") + + existing = await cls.get_by_code(db, data["code"]) + if existing: + raise ValueError(f"流程编码已存在: {data['code']}") + + data = dict(data) + data["form_name"] = await cls._resolve_form_name(db, data["form_code"]) + return await cls.create(db, data, user_id) + + @staticmethod + async def get_categories(db: AsyncSession) -> List[str]: + """获取所有分类""" + stmt = select(WorkflowDefinition.category).where( + WorkflowDefinition.is_deleted == False, + WorkflowDefinition.category != '', + ).distinct() + result = await db.execute(stmt) + return [row[0] for row in result.all()] + + +class WorkflowInstanceService: + """流程实例服务""" + + @staticmethod + async def start( + db: AsyncSession, + workflow_code: str, + title: str, + form_data: dict, + user_id: str, + ) -> WorkflowInstance: + """发起流程""" + from online_dev.workflow.engine import WorkflowEngine + + # 获取流程定义 + workflow = await WorkflowDefinitionService.get_by_code(db, workflow_code) + if not workflow: + raise ValueError(f"流程 {workflow_code} 不存在") + + if workflow.status != 'published': + raise ValueError(f"流程 {workflow_code} 未发布") + + # 使用流程引擎启动流程 + engine = WorkflowEngine() + instance = await engine.start( + db=db, + workflow=workflow, + title=title, + form_data=form_data, + initiator_id=user_id, + ) + + return instance + + @staticmethod + async def get_list( + db: AsyncSession, + page: int = 1, + page_size: int = 20, + status: str = None, + initiator_id: str = None, + workflow_id: str = None, + title: str = None, + workflow_name: str = None, + instance_no: str = None, + initiator_name: str = None, + application_id: str = None, + ) -> Tuple[List[WorkflowInstance], int]: + """获取流程实例列表""" + from core.user.model import User + + joined_workflow = False + joined_user = False + + stmt = select(WorkflowInstance).where(WorkflowInstance.is_deleted == False) + + if status: + stmt = stmt.where(WorkflowInstance.status == status) + if initiator_id: + stmt = stmt.where(WorkflowInstance.initiator_id == initiator_id) + if workflow_id: + stmt = stmt.where(WorkflowInstance.workflow_id == workflow_id) + if title: + stmt = stmt.where(WorkflowInstance.title.ilike(f"%{title}%")) + if instance_no: + stmt = stmt.where(WorkflowInstance.instance_no.ilike(f"%{instance_no}%")) + + if workflow_name or application_id: + stmt = stmt.join(WorkflowDefinition, WorkflowInstance.workflow_id == WorkflowDefinition.id) + joined_workflow = True + if workflow_name: + stmt = stmt.where(WorkflowDefinition.name.ilike(f"%{workflow_name}%")) + if application_id: + stmt = stmt.where(WorkflowDefinition.application_id == application_id) + + if initiator_name: + if not joined_user: + stmt = stmt.join(User, WorkflowInstance.initiator_id == User.id) + joined_user = True + stmt = stmt.where(User.name.ilike(f"%{initiator_name}%")) + + # 计算总数 + count_stmt = select(func.count()).select_from(stmt.subquery()) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # 分页 + stmt = stmt.order_by(WorkflowInstance.started_at.desc()) + stmt = stmt.offset((page - 1) * page_size).limit(page_size) + + result = await db.execute(stmt) + items = list(result.scalars().all()) + + return items, total + + @staticmethod + async def get_by_id(db: AsyncSession, instance_id: str) -> Optional[WorkflowInstance]: + """根据ID获取流程实例""" + stmt = select(WorkflowInstance).where( + WorkflowInstance.id == instance_id, + WorkflowInstance.is_deleted == False, + ) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + @staticmethod + async def cancel(db: AsyncSession, instance_id: str, user_id: str) -> Optional[WorkflowInstance]: + """撤回流程""" + instance = await WorkflowInstanceService.get_by_id(db, instance_id) + if not instance: + return None + + if instance.initiator_id != user_id: + raise ValueError("只能撤回自己发起的流程") + + if instance.status != 'pending': + raise ValueError("只能撤回审批中的流程") + + # 如果实例处于延时等待状态,清理延时定时任务 + if instance.delay_node_id: + try: + from online_dev.workflow.engine.delay_callback import remove_delay_job + await remove_delay_job(str(instance.id), instance.delay_node_id) + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"清理延时任务失败: {e}") + instance.delay_node_id = "" + instance.delay_until = None + + instance.status = 'canceled' + instance.completed_at = datetime.now() + db.add(instance) + + # 先收集待取消任务的ID,用于清理钉钉待办 + pending_stmt = select(WorkflowTask.id).where( + WorkflowTask.instance_id == str(instance.id), + WorkflowTask.status == 'pending', + ) + pending_result = await db.execute(pending_stmt) + canceled_task_ids = [str(row[0]) for row in pending_result.all()] + + # 取消所有待处理任务 + stmt = update(WorkflowTask).where( + WorkflowTask.instance_id == str(instance.id), + WorkflowTask.status == 'pending', + ).values(status='canceled') + await db.execute(stmt) + + # 清理被取消任务的钉钉待办 + for tid in canceled_task_ids: + try: + from core.message.service import NotifyService + await NotifyService.delete_dingtalk_todo(db, "workflow_task", tid) + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"撤回-清理钉钉待办失败 task={tid}: {e}") + + # 记录日志 + log = WorkflowLog( + instance_id=str(instance.id), + action='cancel', + operator_id=user_id, + comment='撤回流程', + ) + db.add(log) + await db.flush() + await db.refresh(instance) + + return instance + + @staticmethod + async def urge(db: AsyncSession, instance_id: str, user_id: str) -> None: + """催办流程""" + instance = await WorkflowInstanceService.get_by_id(db, instance_id) + if not instance: + raise ValueError("流程实例不存在") + + if instance.initiator_id != user_id: + raise ValueError("只能催办自己发起的流程") + + if instance.status != 'pending': + raise ValueError("只能催办审批中的流程") + + # 获取当前待处理的任务(审批 + 办理) + stmt = select(WorkflowTask).where( + WorkflowTask.instance_id == str(instance.id), + WorkflowTask.status == 'pending', + WorkflowTask.task_type.in_(('approval', 'handle')), + WorkflowTask.is_deleted == False, + ) + result = await db.execute(stmt) + pending_tasks = result.scalars().all() + + if not pending_tasks: + raise ValueError('当前没有待处理的任务') + + from online_dev.workflow.model import WorkflowDefinition + from online_dev.workflow.engine.handlers.notification_service import ( + WorkflowNotificationService, + ) + + stmt = select(WorkflowDefinition).where( + WorkflowDefinition.id == instance.workflow_id, + WorkflowDefinition.is_deleted == False, + ) + wf_result = await db.execute(stmt) + workflow = wf_result.scalar_one_or_none() + flow_definition = workflow.flow_definition if workflow else {} + + sent_count = await WorkflowNotificationService.send_urge_notifications( + db=db, + instance=instance, + pending_tasks=pending_tasks, + flow_definition=flow_definition, + operator_id=user_id, + ) + + if sent_count == 0: + raise ValueError('当前节点未开启任务通知,无法发送催办') + + # 记录催办日志 + log = WorkflowLog( + instance_id=str(instance.id), + node_id=instance.current_node_id, + node_name=instance.current_node_name, + action='urge', + operator_id=user_id, + comment=f'已向 {sent_count} 位处理人发送催办通知', + extra_data={'notified_count': sent_count}, + ) + db.add(log) + await db.flush() + + +class WorkflowTaskService: + """任务服务""" + + @staticmethod + async def get_pending_tasks( + db: AsyncSession, + user_id: str, + page: int = 1, + page_size: int = 20, + task_type: str = None, + instance_title: str = None, + workflow_name: str = None, + ) -> Tuple[List[WorkflowTask], int]: + """获取待处理任务""" + stmt = select(WorkflowTask).where( + WorkflowTask.assignee_id == user_id, + WorkflowTask.status == 'pending', + WorkflowTask.is_deleted == False, + ) + + if task_type: + stmt = stmt.where(WorkflowTask.task_type == task_type) + if instance_title: + stmt = stmt.join(WorkflowInstance, WorkflowTask.instance_id == WorkflowInstance.id) + stmt = stmt.where(WorkflowInstance.title.ilike(f"%{instance_title}%")) + if workflow_name: + if 'WorkflowInstance' not in str(stmt): + stmt = stmt.join(WorkflowInstance, WorkflowTask.instance_id == WorkflowInstance.id) + stmt = stmt.join(WorkflowDefinition, WorkflowInstance.workflow_id == WorkflowDefinition.id) + stmt = stmt.where(WorkflowDefinition.name.ilike(f"%{workflow_name}%")) + + # 计算总数 + count_stmt = select(func.count()).select_from(stmt.subquery()) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # 分页 + stmt = stmt.order_by(WorkflowTask.sys_create_datetime.desc()) + stmt = stmt.offset((page - 1) * page_size).limit(page_size) + + result = await db.execute(stmt) + items = list(result.scalars().all()) + + return items, total + + @staticmethod + async def get_handled_tasks( + db: AsyncSession, + user_id: str, + page: int = 1, + page_size: int = 20, + instance_title: str = None, + workflow_name: str = None, + ) -> Tuple[List[WorkflowTask], int]: + """获取已处理任务""" + # 只查询用户实际处理过的任务(排除 canceled/waiting/read/pending) + handled_statuses = ['approved', 'rejected', 'returned', 'transferred', 'delegated', 'handled'] + stmt = select(WorkflowTask).where( + WorkflowTask.assignee_id == user_id, + WorkflowTask.is_deleted == False, + WorkflowTask.status.in_(handled_statuses), + ) + + if instance_title: + stmt = stmt.join(WorkflowInstance, WorkflowTask.instance_id == WorkflowInstance.id) + stmt = stmt.where(WorkflowInstance.title.ilike(f"%{instance_title}%")) + if workflow_name: + if 'WorkflowInstance' not in str(stmt): + stmt = stmt.join(WorkflowInstance, WorkflowTask.instance_id == WorkflowInstance.id) + stmt = stmt.join(WorkflowDefinition, WorkflowInstance.workflow_id == WorkflowDefinition.id) + stmt = stmt.where(WorkflowDefinition.name.ilike(f"%{workflow_name}%")) + + # 计算总数 + count_stmt = select(func.count()).select_from(stmt.subquery()) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # 分页 + stmt = stmt.order_by(WorkflowTask.handled_at.desc()) + stmt = stmt.offset((page - 1) * page_size).limit(page_size) + + result = await db.execute(stmt) + items = list(result.scalars().all()) + + return items, total + + @staticmethod + async def get_instance_pending_tasks(db: AsyncSession, instance_id: str) -> List[WorkflowTask]: + """获取流程实例当前待处理的任务""" + instance = await WorkflowInstanceService.get_by_id(db, instance_id) + if not instance: + return [] + + if instance.status in ['approved', 'rejected', 'canceled']: + return [] + + stmt = select(WorkflowTask).where( + WorkflowTask.instance_id == instance_id, + WorkflowTask.status.in_(['pending', 'waiting']), + WorkflowTask.is_deleted == False, + ).order_by(WorkflowTask.sys_create_datetime) + + result = await db.execute(stmt) + return list(result.scalars().all()) + + @staticmethod + async def get_copy_tasks( + db: AsyncSession, + user_id: str, + page: int = 1, + page_size: int = 20, + instance_title: str = None, + workflow_name: str = None, + is_read: bool = None, + ) -> Tuple[List[WorkflowTask], int]: + """获取抄送给我的任务""" + stmt = select(WorkflowTask).where( + WorkflowTask.assignee_id == user_id, + WorkflowTask.task_type == 'copy', + WorkflowTask.is_deleted == False, + ) + + if is_read is False: + stmt = stmt.where(WorkflowTask.status == 'pending') + elif is_read is True: + stmt = stmt.where(WorkflowTask.status == 'read') + + if instance_title: + stmt = stmt.join(WorkflowInstance, WorkflowTask.instance_id == WorkflowInstance.id) + stmt = stmt.where(WorkflowInstance.title.ilike(f"%{instance_title}%")) + if workflow_name: + if 'WorkflowInstance' not in str(stmt): + stmt = stmt.join(WorkflowInstance, WorkflowTask.instance_id == WorkflowInstance.id) + stmt = stmt.join(WorkflowDefinition, WorkflowInstance.workflow_id == WorkflowDefinition.id) + stmt = stmt.where(WorkflowDefinition.name.ilike(f"%{workflow_name}%")) + + # 计算总数 + count_stmt = select(func.count()).select_from(stmt.subquery()) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # 分页 + stmt = stmt.order_by(WorkflowTask.sys_create_datetime.desc()) + stmt = stmt.offset((page - 1) * page_size).limit(page_size) + + result = await db.execute(stmt) + items = list(result.scalars().all()) + + return items, total + + @staticmethod + async def get_by_id(db: AsyncSession, task_id: str) -> Optional[WorkflowTask]: + """根据ID获取任务""" + stmt = select(WorkflowTask).where( + WorkflowTask.id == task_id, + WorkflowTask.is_deleted == False, + ) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + @staticmethod + async def _upload_signature(db: AsyncSession, signature_base64: str, task_id: str, user_id: str) -> Optional[str]: + """ + 上传签名图片到文件系统 + + Args: + db: 数据库会话 + signature_base64: base64 格式的签名数据 + task_id: 任务ID + user_id: 用户ID + + Returns: + 文件ID,如果上传失败返回 None + """ + import base64 + import logging + from io import BytesIO + from datetime import datetime + + logger = logging.getLogger(__name__) + + try: + # 解析 base64 数据 + if ',' in signature_base64: + # 格式: data:image/png;base64,xxxxx + signature_base64 = signature_base64.split(',')[1] + + signature_data = base64.b64decode(signature_base64) + + # 生成文件名 + timestamp = datetime.now().strftime('%Y%m%d%H%M%S') + filename = f"signature_{task_id}_{timestamp}.png" + + # 上传到文件系统 + from core.file_manager.service import FileManagerService + file_record = await FileManagerService.upload_file( + db=db, + file=BytesIO(signature_data), + filename=filename, + content_type="image/png", + user_id=user_id, + source="workflow", # 自动归类到工作流附件文件夹 + is_public=False, # 私有文件 + ) + + logger.info(f"签名上传成功: task_id={task_id}, file_id={file_record.id}") + return str(file_record.id) + + except Exception as e: + logger.error(f"签名上传失败: task_id={task_id}, error={e}") + return None + + @staticmethod + async def approve( + db: AsyncSession, + task_id: str, + action: str, + comment: str, + user_id: str, + return_to: str = None, + form_data: dict = None, + signature: str = None, + ) -> WorkflowTask: + """审批任务""" + from online_dev.workflow.engine import WorkflowEngine + from online_dev.workflow.engine.base import TaskAction + + task = await WorkflowTaskService.get_by_id(db, task_id) + if not task: + raise ValueError("任务不存在") + + if task.assignee_id != user_id: + raise ValueError("无权处理此任务") + + if task.status != 'pending': + raise ValueError("任务已处理") + + # 处理签名 + # signature 可能是文件ID(前端已上传)或 base64 数据(需要后端上传) + signature_file_id = None + if signature and action == 'approve': + # 判断是文件ID还是base64数据 + # 文件ID通常是 nanoid 格式(21位字母数字加下划线/横线),base64数据通常以 data: 开头或很长 + if signature.startswith('data:') or len(signature) > 100: + # base64 数据,需要上传 + signature_file_id = await WorkflowTaskService._upload_signature(db, signature, task_id, user_id) + else: + # 已经是文件ID + signature_file_id = signature + task.signature_file_id = signature_file_id + + # 如果审批人修改了表单数据,先更新 + if form_data: + instance = await WorkflowInstanceService.get_by_id(db, task.instance_id) + if instance and instance.form_code and instance.form_data_id: + try: + from online_dev.form_data_manager.service import FormDataService + service = await FormDataService.create_service(db, instance.form_code) + await service.update(db, instance.form_data_id, form_data) + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"审批时更新表单数据失败: {e}") + + # 映射操作类型 + action_map = { + 'approve': TaskAction.APPROVE, + 'reject': TaskAction.REJECT, + 'return': TaskAction.RETURN, + } + task_action = action_map.get(action) + if not task_action: + raise ValueError(f"无效的操作类型: {action}") + + # 使用流程引擎处理任务 + engine = WorkflowEngine() + return await engine.complete_task( + db=db, + task=task, + action=task_action, + comment=comment, + user_id=user_id, + return_to=return_to, + ) + + @staticmethod + async def handle(db: AsyncSession, task_id: str, comment: str, user_id: str, form_data: dict = None) -> WorkflowTask: + """办理任务""" + from online_dev.workflow.engine import WorkflowEngine + from online_dev.workflow.engine.base import ExecutionContext + + task = await WorkflowTaskService.get_by_id(db, task_id) + if not task: + raise ValueError("任务不存在") + + if task.assignee_id != user_id: + raise ValueError("无权处理此任务") + + if task.status != 'pending': + raise ValueError("任务已处理") + + if task.task_type != 'handle': + raise ValueError("此任务不是办理任务") + + # 如果办理人修改了表单数据,先更新 + if form_data: + instance = await WorkflowInstanceService.get_by_id(db, task.instance_id) + if instance and instance.form_code and instance.form_data_id: + try: + from online_dev.form_data_manager.service import FormDataService + service = await FormDataService.create_service(db, instance.form_code) + await service.update(db, instance.form_data_id, form_data) + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"办理时更新表单数据失败: {e}") + + # 更新任务状态 + task.status = 'handled' + task.comment = comment + task.handled_at = datetime.now() + db.add(task) + + # 完成该任务对应的钉钉待办 + try: + from core.message.service import NotifyService + await NotifyService.complete_dingtalk_todo(db, "workflow_task", str(task.id)) + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"办理-清理钉钉待办失败 task={task.id}: {e}") + + # 记录日志 + log = WorkflowLog( + instance_id=task.instance_id, + node_id=task.node_id, + node_name=task.node_name, + action='handle', + operator_id=user_id, + comment=comment, + ) + db.add(log) + await db.flush() + + # 获取实例和流程定义 + instance = await WorkflowInstanceService.get_by_id(db, task.instance_id) + workflow = await WorkflowDefinitionService.get_by_id(db, instance.workflow_id) + + # 使用流程引擎处理办理完成后的推进 + engine = WorkflowEngine() + + context = ExecutionContext( + instance=instance, + form_data=form_data or {}, + current_user_id=user_id, + flow_definition=workflow.flow_definition, + db=db, + ) + + await engine._handle_handle_completion(context, task) + + return task + + @staticmethod + async def transfer(db: AsyncSession, task_id: str, to_user_id: str, comment: str, user_id: str) -> WorkflowTask: + """转交任务""" + from online_dev.workflow.engine import WorkflowEngine + from core.user.model import User + + task = await WorkflowTaskService.get_by_id(db, task_id) + if not task: + raise ValueError("任务不存在") + + if task.assignee_id != user_id: + raise ValueError("无权转交此任务") + + if task.status != 'pending': + raise ValueError("任务已处理") + + # 获取目标用户 + stmt = select(User).where(User.id == to_user_id) + result = await db.execute(stmt) + to_user = result.scalar_one_or_none() + + if not to_user: + raise ValueError("目标用户不存在") + + # 使用流程引擎转交任务 + engine = WorkflowEngine() + return await engine.transfer_task( + db=db, + task=task, + to_user=to_user, + comment=comment, + user_id=user_id, + ) + + @staticmethod + async def get_pending_count(db: AsyncSession, user_id: str) -> int: + """获取待处理任务数量""" + stmt = select(func.count()).where( + WorkflowTask.assignee_id == user_id, + WorkflowTask.status == 'pending', + WorkflowTask.is_deleted == False, + ) + result = await db.execute(stmt) + return result.scalar() or 0 + + @staticmethod + async def delegate(db: AsyncSession, task_id: str, to_user_id: str, comment: str, user_id: str) -> WorkflowTask: + """委派任务""" + from core.user.model import User + + task = await WorkflowTaskService.get_by_id(db, task_id) + if not task: + raise ValueError("任务不存在") + + if task.assignee_id != user_id: + raise ValueError("无权委派此任务") + + if task.status != 'pending': + raise ValueError("任务已处理") + + if task.task_type not in ('approval', 'handle'): + raise ValueError("此类型任务不支持委派") + + # 获取被委派人 + stmt = select(User).where(User.id == to_user_id) + result = await db.execute(stmt) + to_user = result.scalar_one_or_none() + + if not to_user: + raise ValueError("被委派人不存在") + + if str(to_user.id) == user_id: + raise ValueError("不能委派给自己") + + # 更新原任务状态为已委派 + task.status = 'delegated' + task.comment = comment or f'委派给 {to_user.name or to_user.username}' + task.handled_at = datetime.now() + db.add(task) + await db.flush() + + # 完成原任务对应的钉钉待办 + try: + from core.message.service import NotifyService + await NotifyService.complete_dingtalk_todo(db, 'workflow_task', str(task.id)) + except Exception as e: + logger.warning(f'委派-清理钉钉待办失败 task={task.id}: {e}') + + # 创建新任务给被委派人 + new_task = WorkflowTask( + instance_id=task.instance_id, + node_id=task.node_id, + node_name=task.node_name, + task_type=task.task_type, + status='pending', + assignee_id=str(to_user.id), + parent_task_id=str(task.id), + sign_type='delegate', + timeout_at=task.timeout_at, + timeout_action=task.timeout_action or '', + timeout_notified=False, + ) + db.add(new_task) + await db.flush() + + try: + from online_dev.workflow.engine.handlers.notification_service import ( + WorkflowNotificationService, + ) + + instance, flow_definition = await WorkflowNotificationService.load_instance_and_flow( + db, new_task + ) + if instance: + await WorkflowNotificationService.notify_pending_task( + db=db, + task=new_task, + instance=instance, + flow_definition=flow_definition, + operator_id=user_id, + ) + except Exception as notify_err: + import logging + logging.getLogger('online_dev.workflow.service').warning( + f'委派后发送任务通知失败 task={new_task.id}: {notify_err}' + ) + + # 记录日志 + log = WorkflowLog( + instance_id=task.instance_id, + node_id=task.node_id, + node_name=task.node_name, + action='delegate', + operator_id=user_id, + comment=f'委派给 {to_user.name or to_user.username}' + (f':{comment}' if comment else ''), + extra_data={ + 'to_user_id': str(to_user.id), + 'to_user_name': to_user.name or to_user.username, + 'original_task_id': str(task.id), + }, + ) + db.add(log) + await db.flush() + await db.refresh(new_task) + + return new_task + + @staticmethod + async def mark_read(db: AsyncSession, task_id: str, user_id: str) -> WorkflowTask: + """标记抄送任务为已读""" + task = await WorkflowTaskService.get_by_id(db, task_id) + if not task: + raise ValueError("任务不存在") + + if task.assignee_id != user_id: + raise ValueError("无权操作此任务") + + if task.task_type != 'copy': + raise ValueError("只能标记抄送任务为已读") + + if task.status != 'pending': + raise ValueError("任务已处理") + + task.status = 'read' + task.handled_at = datetime.now() + db.add(task) + await db.flush() + await db.refresh(task) + + return task + + @staticmethod + async def add_sign( + db: AsyncSession, + task_id: str, + sign_type: str, + to_user_ids: list, + comment: str, + user_id: str, + ) -> List[WorkflowTask]: + """加签任务""" + from core.user.model import User + + if sign_type not in ('before', 'after', 'parallel'): + raise ValueError("无效的加签类型") + + if not to_user_ids: + raise ValueError("请选择加签人") + + task = await WorkflowTaskService.get_by_id(db, task_id) + if not task: + raise ValueError("任务不存在") + + if task.assignee_id != user_id: + raise ValueError("无权操作此任务") + + if task.status != 'pending': + raise ValueError("任务已处理") + + if task.task_type not in ('approval', 'handle'): + raise ValueError("此类型任务不支持加签") + + # 获取加签人 + stmt = select(User).where(User.id.in_(to_user_ids)) + result = await db.execute(stmt) + to_users = list(result.scalars().all()) + + if len(to_users) != len(to_user_ids): + raise ValueError("部分加签人不存在") + + # 检查是否包含自己 + if user_id in to_user_ids: + raise ValueError("不能加签给自己") + + created_tasks = [] + to_user_names = [u.name or u.username for u in to_users] + + if sign_type == 'before': + # 前加签:当前任务暂停 + task.status = 'waiting' + task.comment = f'前加签给 {", ".join(to_user_names)}' + db.add(task) + + for to_user in to_users: + new_task = WorkflowTask( + instance_id=task.instance_id, + node_id=task.node_id, + node_name=task.node_name, + task_type='approval', + status='pending', + assignee_id=str(to_user.id), + parent_task_id=str(task.id), + sign_type='before', + ) + db.add(new_task) + created_tasks.append(new_task) + + elif sign_type == 'after': + # 后加签:当前处理人先审批 + task.status = 'approved' + task.comment = f'审批通过,后加签给 {", ".join(to_user_names)}' + task.handled_at = datetime.now() + db.add(task) + + for to_user in to_users: + new_task = WorkflowTask( + instance_id=task.instance_id, + node_id=task.node_id, + node_name=task.node_name, + task_type='approval', + status='pending', + assignee_id=str(to_user.id), + parent_task_id=str(task.id), + sign_type='after', + ) + db.add(new_task) + created_tasks.append(new_task) + + else: # parallel + # 并行加签 + for to_user in to_users: + new_task = WorkflowTask( + instance_id=task.instance_id, + node_id=task.node_id, + node_name=task.node_name, + task_type='approval', + status='pending', + assignee_id=str(to_user.id), + parent_task_id=str(task.id), + sign_type='parallel', + ) + db.add(new_task) + created_tasks.append(new_task) + + # 记录日志 + sign_type_labels = { + 'before': '前加签', + 'after': '后加签', + 'parallel': '并行加签', + } + log = WorkflowLog( + instance_id=task.instance_id, + node_id=task.node_id, + node_name=task.node_name, + action='add_sign', + operator_id=user_id, + comment=f'{sign_type_labels[sign_type]}给 {", ".join(to_user_names)}' + (f':{comment}' if comment else ''), + extra_data={ + 'sign_type': sign_type, + 'to_user_ids': to_user_ids, + 'to_user_names': to_user_names, + 'original_task_id': str(task.id), + }, + ) + db.add(log) + await db.flush() + + for t in created_tasks: + await db.refresh(t) + + return created_tasks + + @staticmethod + async def revise(db: AsyncSession, task_id: str, form_data: dict, comment: str, user_id: str) -> WorkflowTask: + """修改任务(驳回后发起人重新提交)""" + from online_dev.workflow.engine import WorkflowEngine + + task = await WorkflowTaskService.get_by_id(db, task_id) + if not task: + raise ValueError("任务不存在") + + if task.assignee_id != user_id: + raise ValueError("无权操作此任务") + + if task.task_type != 'revise': + raise ValueError("此任务不是修改任务") + + if task.status != 'pending': + raise ValueError("任务已处理") + + instance = await WorkflowInstanceService.get_by_id(db, task.instance_id) + if not instance: + raise ValueError("流程实例不存在") + + # 完成当前任务 + task.status = 'approved' + task.comment = comment or '已修改并重新提交' + task.handled_at = datetime.now() + db.add(task) + + # 记录日志 + log = WorkflowLog( + instance_id=str(instance.id), + node_id=task.node_id, + node_name='修改重提', + action='revise', + operator_id=user_id, + comment=comment or '已修改并重新提交', + ) + db.add(log) + await db.flush() + + # 更新表单数据(前端传入格式: {main: {...}, sub_tables: {...}}) + # 注意: FormDataService.update 内部会 db.commit(),所以放在所有 ORM 操作之后 + import logging + _logger = logging.getLogger(__name__) + _logger.info(f"revise: form_code={instance.form_code}, form_data_id={instance.form_data_id}") + _logger.info(f"revise: form_data keys={list(form_data.keys()) if form_data else 'None'}") + if form_data and form_data.get('main'): + _logger.info(f"revise: main data keys={list(form_data['main'].keys())}") + if form_data and instance.form_code and instance.form_data_id: + try: + from online_dev.form_data_manager.service import FormDataService + service = await FormDataService.create_service(db, instance.form_code) + _logger.info(f"revise: calling service.update with pk={instance.form_data_id}") + await service.update(db, instance.form_data_id, form_data) + _logger.info(f"revise: form data update success") + except Exception as e: + _logger.error(f"更新表单数据失败: {e}", exc_info=True) + + # 重新刷新 instance(因为 FormDataService.update 内部 commit 可能导致 ORM 对象 detached) + instance = await WorkflowInstanceService.get_by_id(db, task.instance_id) + + # 重新启动流程 + engine = WorkflowEngine() + await engine.restart_instance(db, instance, user_id) + + task = await WorkflowTaskService.get_by_id(db, task_id) + return task + + +class WorkflowLogService: + """日志服务""" + + @staticmethod + async def get_by_instance(db: AsyncSession, instance_id: str) -> List[WorkflowLog]: + """获取流程实例的日志""" + stmt = select(WorkflowLog).where( + WorkflowLog.instance_id == instance_id, + WorkflowLog.is_deleted == False, + ).order_by(WorkflowLog.sys_create_datetime) + + result = await db.execute(stmt) + return list(result.scalars().all()) diff --git a/backend-fastapi/zq_demo/__init__.py b/backend-fastapi/zq_demo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend-fastapi/zq_demo/api.py b/backend-fastapi/zq_demo/api.py new file mode 100644 index 0000000..1038265 --- /dev/null +++ b/backend-fastapi/zq_demo/api.py @@ -0,0 +1,229 @@ +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File +from fastapi.responses import StreamingResponse +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 zq_demo.schema import DemoCreate, DemoUpdate, DemoResponse +from zq_demo.service import DemoService +from zq_demo.model import Demo + +router = APIRouter(prefix="/demos", tags=["Demo管理"]) + + +@router.post("", response_model=DemoResponse, summary="创建Demo") +async def create_demo(demo: DemoCreate, db: AsyncSession = Depends(get_db)): + """ + 创建新Demo(自动记录创建人和部门) + - **title**: 标题 + - **content**: 内容(可选) + - **status**: 状态(0=草稿,1=发布,2=归档) + - **priority**: 优先级(0=低,1=中,2=高) + - **is_active**: 是否激活(默认true) + """ + # 检查标题唯一性 + if not await DemoService.check_unique(db, field="title", value=demo.title): + raise HTTPException(status_code=400, detail="标题已存在") + + return await DemoService.create(db=db, data=demo) + + +@router.get("", response_model=PaginatedResponse[DemoResponse], summary="获取Demo列表") +async def get_demos( + 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", description="每页数量"), + title: Optional[str] = Query(None, description="标题(模糊搜索)"), + status: Optional[int] = Query(None, description="状态筛选"), + priority: Optional[int] = Query(None, description="优先级筛选"), + db: AsyncSession = Depends(get_db) +): + """ + 获取Demo列表(分页,自动应用数据权限和字段权限) + + 数据权限:根据用户角色自动过滤数据 + - 超级管理员:查看所有数据 + - 普通用户:根据角色配置的数据范围查看(本人/本部门/本部门及下级/全部) + + 字段权限:根据角色配置隐藏/脱敏字段 + - 必填字段(id、title、status)不会被隐藏 + - 可选字段(content、priority等)可以根据配置隐藏 + """ + # 构建过滤条件 + filters = [] + if title: + filters.append(Demo.title.ilike(f"%{title}%")) + if status is not None: + filters.append(Demo.status == status) + if priority is not None: + filters.append(Demo.priority == priority) + + # 应用行权限(数据权限)- 自动根据用户角色过滤数据 + items, total = await DemoService.get_list_with_data_scope( + db=db, + page=page, + page_size=page_size, + filters=filters + ) + + # 转换为响应格式 + response_items = [DemoResponse.model_validate(item) for item in items] + + # 应用列权限(字段权限)- 自动从上下文获取角色并过滤字段 + response_dicts = [item.model_dump() for item in response_items] + filtered_items = await DemoService.apply_field_permissions_auto( + data=response_dicts, + db=db + ) + + return PaginatedResponse(items=filtered_items, total=total) + + +@router.get("/export/excel", summary="导出Excel") +async def export_excel(db: AsyncSession = Depends(get_db)): + """ + 导出Demo数据到Excel(自动应用数据权限) + + 只导出当前用户有权限查看的数据 + """ + output = await DemoService.export_to_excel_with_data_scope( + db=db, + data_converter=DemoService._export_converter + ) + return StreamingResponse( + output, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=demo_export.xlsx"} + ) + + +@router.get("/import/template", summary="下载导入模板") +async def download_template(): + """ + 下载Excel导入模板 + """ + output = DemoService.get_import_template() + return StreamingResponse( + output, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=demo_template.xlsx"} + ) + + +@router.post("/import/excel", response_model=ResponseModel, summary="导入Excel") +async def import_excel( + file: UploadFile = File(..., description="Excel文件(.xlsx)"), + db: AsyncSession = Depends(get_db) +): + """ + 从Excel导入Demo数据 + """ + if not file.filename.endswith(".xlsx"): + raise HTTPException(status_code=400, detail="只支持.xlsx格式的Excel文件") + + content = await file.read() + success_count, fail_count = await DemoService.import_from_excel(db, content) + + return ResponseModel( + message=f"导入完成,成功{success_count}条,失败{fail_count}条", + data={"success": success_count, "fail": fail_count} + ) + + +@router.get("/check/unique", response_model=ResponseModel, summary="检查字段唯一性") +async def check_unique( + field: str = Query(..., description="字段名,如title"), + value: str = Query(..., description="字段值"), + exclude_id: str = Query(default=None, alias="excludeId", description="排除的记录ID(更新时使用)"), + db: AsyncSession = Depends(get_db) +): + """ + 检查字段值是否唯一 + - **field**: 字段名(如 title) + - **value**: 要检查的值 + - **excludeId**: 更新时排除自身的ID + + 返回: {"unique": true/false} + """ + # 限制可检查的字段,防止恶意查询 + allowed_fields = ["title"] + if field not in allowed_fields: + raise HTTPException(status_code=400, detail=f"不支持检查字段: {field},允许的字段: {allowed_fields}") + + is_unique = await DemoService.check_unique(db, field=field, value=value, exclude_id=exclude_id) + return ResponseModel( + message="字段值可用" if is_unique else "字段值已存在", + data={"unique": is_unique} + ) + + +@router.get("/{demo_id}", response_model=DemoResponse, summary="获取单个Demo") +async def get_demo(demo_id: str, db: AsyncSession = Depends(get_db)): + """ + 根据Demo ID获取Demo详情(应用字段权限) + + 字段权限:根据角色配置隐藏/脱敏字段 + """ + db_demo = await DemoService.get_by_id(db, record_id=demo_id) + if db_demo is None: + raise HTTPException(status_code=404, detail="Demo不存在") + + # 转换为响应格式 + response = DemoResponse.model_validate(db_demo) + + # 应用列权限(字段权限) + response_dict = response.model_dump() + filtered_data = await DemoService.apply_field_permissions_auto( + data=response_dict, + db=db + ) + + return filtered_data + + +@router.put("/{demo_id}", response_model=DemoResponse, summary="全量更新Demo") +async def update_demo(demo_id: str, demo: DemoUpdate, db: AsyncSession = Depends(get_db)): + """ + 全量更新Demo信息 + """ + # 检查标题唯一性(排除自身) + if demo.title and not await DemoService.check_unique(db, field="title", value=demo.title, exclude_id=demo_id): + raise HTTPException(status_code=400, detail="标题已存在") + + db_demo = await DemoService.update(db, record_id=demo_id, data=demo) + if db_demo is None: + raise HTTPException(status_code=404, detail="Demo不存在") + return db_demo + + +@router.patch("/{demo_id}", response_model=DemoResponse, summary="部分更新Demo") +async def patch_demo(demo_id: str, demo: DemoUpdate, db: AsyncSession = Depends(get_db)): + """ + 部分更新Demo信息(只更新传入的字段) + """ + # 检查标题唯一性(排除自身) + if demo.title and not await DemoService.check_unique(db, field="title", value=demo.title, exclude_id=demo_id): + raise HTTPException(status_code=400, detail="标题已存在") + + db_demo = await DemoService.update(db, record_id=demo_id, data=demo) + if db_demo is None: + raise HTTPException(status_code=404, detail="Demo不存在") + return db_demo + + +@router.delete("/{demo_id}", response_model=ResponseModel, summary="删除Demo") +async def delete_demo( + demo_id: str, + hard: bool = Query(default=False, description="是否物理删除,False为逻辑删除,True为物理删除"), + db: AsyncSession = Depends(get_db) +): + """ + 删除Demo + - **hard=false**: 逻辑删除(默认) + - **hard=true**: 物理删除 + """ + success = await DemoService.delete(db, record_id=demo_id, hard=hard) + if not success: + raise HTTPException(status_code=404, detail="Demo不存在") + return ResponseModel(message="删除成功") diff --git a/backend-fastapi/zq_demo/model.py b/backend-fastapi/zq_demo/model.py new file mode 100644 index 0000000..5f86340 --- /dev/null +++ b/backend-fastapi/zq_demo/model.py @@ -0,0 +1,27 @@ +from sqlalchemy import Column, String, Text, Boolean, Integer + +from app.base_model import BaseModel + + +class Demo(BaseModel): + """ + Demo模型 - 演示数据权限和字段权限 + + 数据权限字段: + - sys_creator_id: 创建人ID(继承自BaseModel) + - sys_dept_id: 部门ID(继承自BaseModel) + + 业务字段: + - title: 标题(必填,不可隐藏) + - content: 内容(可选,可隐藏) + - status: 状态(必填,不可隐藏) + - priority: 优先级(可选,可隐藏) + - is_active: 是否激活(可选,可隐藏) + """ + __tablename__ = "demos" + + title = Column(String(100), nullable=False, comment="标题") + content = Column(Text, nullable=True, comment="内容") + status = Column(Integer, default=1, comment="状态:0=草稿,1=发布,2=归档") + priority = Column(Integer, default=0, comment="优先级:0=低,1=中,2=高") + is_active = Column(Boolean, default=True, comment="是否激活") diff --git a/backend-fastapi/zq_demo/router.py b/backend-fastapi/zq_demo/router.py new file mode 100644 index 0000000..d247ccc --- /dev/null +++ b/backend-fastapi/zq_demo/router.py @@ -0,0 +1,11 @@ +""" +zq_demo模块统一路由 +""" +from fastapi import APIRouter + +from zq_demo.api import router as demo_router + +router = APIRouter() + +# 注册子模块路由 +router.include_router(demo_router, prefix="", tags=["Demo"]) diff --git a/backend-fastapi/zq_demo/schema.py b/backend-fastapi/zq_demo/schema.py new file mode 100644 index 0000000..b0cad06 --- /dev/null +++ b/backend-fastapi/zq_demo/schema.py @@ -0,0 +1,100 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, field_validator + +from app.base_schema import CSTDatetime + + +class DemoBase(BaseModel): + """Demo基础Schema""" + title: str + content: Optional[str] = None + status: int = 1 + priority: int = 0 + is_active: bool = True + + @field_validator("status") + @classmethod + def validate_status(cls, v): + """验证状态""" + if v not in [0, 1, 2]: + raise ValueError("状态必须为 0(草稿)、1(发布) 或 2(归档)") + return v + + @field_validator("priority") + @classmethod + def validate_priority(cls, v): + """验证优先级""" + if v not in [0, 1, 2]: + raise ValueError("优先级必须为 0(低)、1(中) 或 2(高)") + return v + + +class DemoCreate(DemoBase): + """创建Demo的Schema""" + pass + + +class DemoUpdate(BaseModel): + """更新Demo的Schema - 所有字段可选""" + title: Optional[str] = None + content: Optional[str] = None + status: Optional[int] = None + priority: Optional[int] = None + is_active: Optional[bool] = None + + @field_validator("status") + @classmethod + def validate_status(cls, v): + """验证状态""" + if v is not None and v not in [0, 1, 2]: + raise ValueError("状态必须为 0(草稿)、1(发布) 或 2(归档)") + return v + + @field_validator("priority") + @classmethod + def validate_priority(cls, v): + """验证优先级""" + if v is not None and v not in [0, 1, 2]: + raise ValueError("优先级必须为 0(低)、1(中) 或 2(高)") + return v + + +class DemoResponse(BaseModel): + """ + Demo响应Schema - 演示字段权限 + + 必填字段(不可隐藏): + - id: 记录ID + - title: 标题 + - status: 状态 + + 可选字段(可隐藏): + - content: 内容 + - priority: 优先级 + - is_active: 是否激活 + - sort: 排序 + - is_deleted: 是否删除 + - sys_create_datetime: 创建时间 + - sys_update_datetime: 更新时间 + - sys_creator_id: 创建人ID + - sys_dept_id: 部门ID + """ + # 必填字段 - 前端不可隐藏 + id: str + status: int + + # 可选字段 - 前端可以隐藏 + title: Optional[str] = None + content: Optional[str] = None + priority: int = 0 + is_active: bool = True + sort: Optional[int] = 0 + is_deleted: Optional[bool] = False + sys_create_datetime: Optional[CSTDatetime] = None + sys_update_datetime: Optional[CSTDatetime] = None + sys_creator_id: Optional[str] = None + sys_dept_id: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) diff --git a/backend-fastapi/zq_demo/service.py b/backend-fastapi/zq_demo/service.py new file mode 100644 index 0000000..abd819f --- /dev/null +++ b/backend-fastapi/zq_demo/service.py @@ -0,0 +1,109 @@ +from io import BytesIO +from typing import Tuple, Dict, Any, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.base_service import BaseService +from app.field_metadata_generator import generate_field_metadata_from_schema +from zq_demo.model import Demo +from zq_demo.schema import DemoCreate, DemoUpdate, DemoResponse + + +class DemoService(BaseService[Demo, DemoCreate, DemoUpdate]): + """ + Demo服务层 - 演示数据权限和字段权限 + + 功能: + 1. 数据权限(行权限):根据用户角色过滤数据 + - 使用 get_list_with_data_scope() 自动应用数据权限 + - 支持本人、本部门、本部门及下级、全部等数据范围 + + 2. 字段权限(列权限):根据角色配置隐藏/脱敏字段 + - 使用 apply_field_permissions_auto() 自动应用字段权限 + - 必填字段(id、title、status)不可隐藏 + - 可选字段(content、priority等)可以隐藏 + + 3. Excel导入导出:支持带数据权限的导出 + - 使用 export_to_excel_with_data_scope() 导出 + """ + + model = Demo + + # 资源显示名称(用于前端显示) + RESOURCE_DISPLAY_NAME = "Demo示例" + + # 从 Response Schema 生成字段元数据 + # 必填字段会被标记为 required=True,前端禁止隐藏 + FIELD_METADATA = generate_field_metadata_from_schema(DemoResponse, Demo) + + # Excel导入导出配置 + excel_columns = { + "title": "标题", + "content": "内容", + "status": "状态", + "priority": "优先级", + "is_active": "是否激活", + } + excel_sheet_name = "Demo列表" + + @classmethod + def _export_converter(cls, item: Any) -> Dict[str, Any]: + """导出数据转换器""" + status_map = {0: "草稿", 1: "发布", 2: "归档"} + priority_map = {0: "低", 1: "中", 2: "高"} + + return { + "title": item.title, + "content": item.content or "", + "status": status_map.get(item.status, "未知"), + "priority": priority_map.get(item.priority, "未知"), + "is_active": "是" if item.is_active else "否", + } + + @classmethod + def _import_processor(cls, row: Dict[str, Any]) -> Optional[Demo]: + """导入数据处理器""" + title = row.get("title") + if not title: + return None + + # 状态转换 + status_str = row.get("status", "发布") + status_map = {"草稿": 0, "发布": 1, "归档": 2} + status = status_map.get(status_str, 1) + + # 优先级转换 + priority_str = row.get("priority", "低") + priority_map = {"低": 0, "中": 1, "高": 2} + priority = priority_map.get(priority_str, 0) + + # 是否激活转换 + is_active_str = row.get("is_active", "是") + is_active = is_active_str in ("是", "true", "True", "1", True) + + return Demo( + title=str(title), + content=str(row.get("content") or ""), + status=status, + priority=priority, + is_active=is_active + ) + + @classmethod + async def export_to_excel( + cls, + db: AsyncSession, + data_converter: Any = None + ) -> BytesIO: + """导出所有Demo到Excel""" + return await super().export_to_excel(db, cls._export_converter) + + @classmethod + async def import_from_excel( + cls, + db: AsyncSession, + file_content: bytes, + row_processor: Any = None + ) -> Tuple[int, int]: + """从Excel导入Demo""" + return await super().import_from_excel(db, file_content, cls._import_processor) diff --git a/backend-fastapi/zq_smart_table/__init__.py b/backend-fastapi/zq_smart_table/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend-fastapi/zq_smart_table/api.py b/backend-fastapi/zq_smart_table/api.py new file mode 100644 index 0000000..227998f --- /dev/null +++ b/backend-fastapi/zq_smart_table/api.py @@ -0,0 +1,1645 @@ +import json as _json +from typing import List, Optional, Union + +from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File +from fastapi.responses import StreamingResponse +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 zq_smart_table.schema import ( + SmartTableCreate, SmartTableUpdate, SmartTableResponse, SmartTableSimple, SmartTableFull, + SmartTableMove, + SmartFieldCreate, SmartFieldUpdate, SmartFieldResponse, SmartFieldReorder, + SmartRecordCreate, SmartRecordUpdate, SmartRecordResponse, + SmartRecordCellUpdate, SmartRecordBatchCellUpdate, SmartRecordBatchDelete, + SmartRecordBatchRestore, TrashListResponse, + MultiRecordBatchUpdate, + CursorPaginatedRecords, + SmartViewCreate, SmartViewUpdate, SmartViewResponse, + RecordQuery, RecordFilterRule, RecordSortRule, + SmartRecordReorder, GroupedRecordsResponse, RecordGroupItem, + RecordSearchResult, RecordSearchQuery, + SummaryRequest, SummaryResponse, + CommentCreate, CommentUpdate, CommentResponse, + DocumentContentUpdate, + DocumentVersionCreate, DocumentVersionResponse, DocumentVersionDetail, DocumentVersionCompare, + DocumentTemplateCreate, DocumentTemplateUpdate, DocumentTemplateResponse, DocumentTemplateDetail, + WikiSpaceCreate, WikiSpaceUpdate, WikiSpaceResponse, WikiSpaceListItem, WikiSpaceDetail, +) +from zq_smart_table.service import ( + SmartTableService, SmartFieldService, SmartRecordService, SmartViewService, + SmartExportService, SmartImportService, SmartLinkService, SmartSummaryService, + SmartFormulaService, SmartValidationService, SmartCommentService, + SmartDocumentVersionService, SmartDocumentTemplateService, + WikiSpaceService, +) +from zq_smart_table.permission.service import PermissionService +from utils.context import get_current_user_id_from_context, get_current_user_info_from_context +from core.user.service import UserService + +router = APIRouter(tags=["多维表格"]) + +INITIAL_RECORD_LIMIT = 500 + + +# ==================== Table ==================== + +@router.post("/tables", response_model=SmartTableResponse, summary="创建表") +async def create_table(data: SmartTableCreate, db: AsyncSession = Depends(get_db)): + table = await SmartTableService.create(db, data) + user_id = get_current_user_id_from_context() + if user_id: + await PermissionService.init_table_owner(db, table.id, user_id) + return table + + +@router.get("/tables", response_model=List[SmartTableSimple], summary="获取表列表") +async def get_table_list( + wiki_space_id: Optional[str] = Query(default=None, description="按文档库ID过滤"), + db: AsyncSession = Depends(get_db), +): + user_info = get_current_user_info_from_context() + user_id = user_info.get("user_id", "") if user_info else None + dept_id = user_info.get("dept_id") if user_info else None + role_ids = user_info.get("role_ids", []) if user_info else [] + is_superuser = user_info.get("is_superuser", False) if user_info else False + + items, _ = await SmartTableService.get_user_tables( + db, wiki_space_id=wiki_space_id, + user_id=user_id, dept_id=dept_id, + role_ids=role_ids, is_superuser=is_superuser, + ) + return items + + +@router.get("/tables/{table_id}", response_model=SmartTableResponse, summary="获取表详情") +async def get_table(table_id: str, db: AsyncSession = Depends(get_db)): + table = await SmartTableService.get_by_id(db, table_id) + if not table: + raise HTTPException(status_code=404, detail="表不存在") + await PermissionService.require_table_access(db, table_id) + return table + + +@router.get("/tables/{table_id}/full", response_model=SmartTableFull, summary="获取完整表数据") +async def get_table_full( + table_id: str, + limit: int = Query(default=INITIAL_RECORD_LIMIT, ge=1, le=10000, description="首次加载记录数"), + filters: Optional[str] = Query(default=None, description="筛选条件 JSON 数组"), + filter_logic: str = Query(default="and", description="筛选逻辑: and / or"), + sorts: Optional[str] = Query(default=None, description="排序规则 JSON 数组"), + search: Optional[str] = Query(default=None, description="搜索关键词"), + db: AsyncSession = Depends(get_db), +): + """返回表的 fields + views + 首批 records(游标分页),前端按需加载更多""" + role_info = await PermissionService.require_table_access(db, table_id) + + table = await SmartTableService.get_by_id(db, table_id) + if not table: + raise HTTPException(status_code=404, detail="表不存在") + + creator_name = None + creator_avatar = None + if table.sys_creator_id: + creator = await UserService.get_by_id(db, table.sys_creator_id) + if creator: + creator_name = getattr(creator, "name", None) or getattr(creator, "username", None) + creator_avatar = getattr(creator, "avatar", None) + + if getattr(table, "type", "table") == "document": + return SmartTableFull( + id=table.id, + name=table.name, + icon=table.icon, + description=table.description, + active_view_id=None, + type="document", + parent_id=getattr(table, "parent_id", None), + content=table.content, + fields=[], + records=[], + views=[], + record_total=0, + next_cursor=None, + has_more=False, + sys_create_datetime=table.sys_create_datetime, + sys_update_datetime=table.sys_update_datetime, + sys_creator_id=table.sys_creator_id, + creator_name=creator_name, + creator_avatar=creator_avatar, + ) + + all_fields = await SmartFieldService.get_by_table(db, table_id) + + accessible = await PermissionService.get_accessible_field_ids( + db, table_id, role_info.get("role_id"), mode="read" + ) + fields = [f for f in all_fields if f.id in accessible] if accessible is not None else all_fields + + parsed_filters = None + if filters: + try: + parsed_filters = [RecordFilterRule(**f) for f in _json.loads(filters)] + except Exception: + raise HTTPException(status_code=400, detail="筛选条件格式错误") + + parsed_sorts = None + if sorts: + try: + parsed_sorts = [RecordSortRule(**s) for s in _json.loads(sorts)] + except Exception: + raise HTTPException(status_code=400, detail="排序规则格式错误") + + user_info = get_current_user_info_from_context() or {} + row_conditions = await PermissionService.build_row_filter_conditions( + db, table_id, role_info.get("role_id"), user_info.get("user_id", ""), rule_type="view" + ) + + records_list, next_cursor, total = await SmartRecordService.get_by_table_cursor_filtered( + db, table_id, + filters=parsed_filters, + filter_logic=filter_logic, + sorts=parsed_sorts, + search=search, + extra_conditions=row_conditions or None, + cursor=None, + limit=limit, + ) + + await SmartLinkService.resolve_link_values(db, all_fields, records_list) + await SmartLinkService.resolve_lookup_values(db, all_fields, records_list) + await SmartLinkService.resolve_rollup_values(db, all_fields, records_list) + SmartFormulaService.resolve_formula_values(all_fields, records_list) + + if accessible is not None: + for r in records_list: + r.values = PermissionService.filter_record_values(r.values or {}, accessible) + + views = await SmartViewService.get_by_table(db, table_id) + + return SmartTableFull( + id=table.id, + name=table.name, + icon=table.icon, + description=table.description, + active_view_id=table.active_view_id, + type=getattr(table, "type", "table"), + parent_id=getattr(table, "parent_id", None), + fields=fields, + records=records_list, + views=views, + record_total=total, + next_cursor=next_cursor, + has_more=next_cursor is not None, + sys_create_datetime=table.sys_create_datetime, + sys_update_datetime=table.sys_update_datetime, + sys_creator_id=table.sys_creator_id, + creator_name=creator_name, + creator_avatar=creator_avatar, + ) + + +@router.put("/tables/{table_id}", response_model=SmartTableResponse, summary="更新表") +async def update_table(table_id: str, data: SmartTableUpdate, db: AsyncSession = Depends(get_db)): + await PermissionService.require_capability(db, table_id, "manage_table") + table = await SmartTableService.update(db, record_id=table_id, data=data) + if not table: + raise HTTPException(status_code=404, detail="表不存在") + return table + + +@router.delete("/tables/{table_id}", response_model=ResponseModel, summary="删除表") +async def delete_table(table_id: str, db: AsyncSession = Depends(get_db)): + await PermissionService.require_capability(db, table_id, "manage_table") + success = await SmartTableService.delete(db, record_id=table_id) + if not success: + raise HTTPException(status_code=404, detail="表不存在") + return ResponseModel(message="删除成功") + + +@router.put("/tables/{table_id}/move", response_model=SmartTableResponse, summary="移动表/文档到新的父级") +async def move_table(table_id: str, data: SmartTableMove, db: AsyncSession = Depends(get_db)): + from sqlalchemy import select as sa_select + + await PermissionService.require_capability(db, table_id, "manage_table") + if data.parent_id and data.parent_id == table_id: + raise HTTPException(status_code=400, detail="不能将页面移动到自身下") + if data.parent_id: + parent = await SmartTableService.get_by_id(db, data.parent_id) + if not parent: + raise HTTPException(status_code=404, detail="目标父页面不存在") + ancestor_id = getattr(parent, "parent_id", None) + while ancestor_id: + if ancestor_id == table_id: + raise HTTPException(status_code=400, detail="不能将页面移动到其子页面下") + ancestor = await SmartTableService.get_by_id(db, ancestor_id) + ancestor_id = getattr(ancestor, "parent_id", None) if ancestor else None + + table = await SmartTableService.update( + db, record_id=table_id, data=SmartTableUpdate(parent_id=data.parent_id) + ) + if not table: + raise HTTPException(status_code=404, detail="表不存在") + + # Reorder siblings at the target parent level + from zq_smart_table.model import SmartTable + if data.parent_id: + q = sa_select(SmartTable).where( + SmartTable.parent_id == data.parent_id, + SmartTable.is_deleted == False, # noqa: E712 + ).order_by(SmartTable.sort) + else: + q = sa_select(SmartTable).where( + SmartTable.parent_id.is_(None), + SmartTable.is_deleted == False, # noqa: E712 + ).order_by(SmartTable.sort) + result = await db.execute(q) + siblings = list(result.scalars().all()) + + ordered_ids = [s.id for s in siblings if s.id != table_id] + if data.after_id and data.after_id in ordered_ids: + idx = ordered_ids.index(data.after_id) + 1 + ordered_ids.insert(idx, table_id) + else: + ordered_ids.insert(0, table_id) + + id_to_obj = {s.id: s for s in siblings} + for i, sid in enumerate(ordered_ids): + obj = id_to_obj.get(sid) + if obj and obj.sort != i: + obj.sort = i + await db.commit() + await db.refresh(table) + + return table + + +# ==================== Document Content ==================== + +@router.patch("/tables/{table_id}/content", response_model=ResponseModel, summary="更新文档内容") +async def update_document_content( + table_id: str, + data: DocumentContentUpdate, + db: AsyncSession = Depends(get_db), +): + await PermissionService.require_capability(db, table_id, "edit_record") + table = await SmartTableService.get_by_id(db, table_id) + if not table: + raise HTTPException(status_code=404, detail="文档不存在") + if getattr(table, "type", "table") != "document": + raise HTTPException(status_code=400, detail="该项目不是文档类型") + + user_id = get_current_user_id_from_context() + if table.content: + try: + await SmartDocumentVersionService.create_version( + db, table_id, table.content, + title=table.name, + change_summary="自动保存", + user_id=user_id, + ) + except Exception: + pass + + await SmartTableService.update(db, record_id=table_id, data=SmartTableUpdate(content=data.content)) + return ResponseModel(message="保存成功") + + +@router.post("/tables/{table_id}/export-pdf", summary="导出文档为 PDF") +async def export_document_pdf( + table_id: str, + data: dict, + db: AsyncSession = Depends(get_db), +): + """接收编辑器 HTML 内容,使用 WeasyPrint 生成 PDF 并返回下载流""" + import io + from online_dev.document_generator.generator import pdf_generator, WEASYPRINT_AVAILABLE, HTML + + await PermissionService.require_table_access(db, table_id) + table = await SmartTableService.get_by_id(db, table_id) + if not table: + raise HTTPException(status_code=404, detail="文档不存在") + + html_content = data.get("html", "") + title = data.get("title", table.name or "文档") + + if not html_content: + raise HTTPException(status_code=400, detail="导出内容不能为空") + + if not WEASYPRINT_AVAILABLE: + raise HTTPException(status_code=500, detail="WeasyPrint 不可用,无法生成 PDF") + + doc_css = """ + h1 { font-size: 22pt; font-weight: 700; margin: 0 0 14pt; padding-bottom: 8pt; border-bottom: 1px solid #e5e5e5; } + h2 { font-size: 17pt; font-weight: 600; margin: 18pt 0 8pt; } + h3 { font-size: 13pt; font-weight: 600; margin: 14pt 0 6pt; } + p { margin: 5pt 0; } + ul, ol { padding-left: 20pt; margin: 5pt 0; } + li { margin: 2pt 0; } + blockquote { + border-left: 3pt solid #d0d0d0; padding-left: 10pt; + margin: 6pt 0; color: #555; font-style: italic; + } + pre { + background: #f6f8fa; padding: 8pt; border-radius: 4pt; + border: 1px solid #e1e4e8; font-size: 9pt; line-height: 1.5; + } + code { background: #f0f0f0; padding: 1pt 3pt; border-radius: 3pt; font-size: 9.5pt; } + pre code { background: none; padding: 0; font-size: inherit; } + table { border-collapse: collapse; width: 100%; margin: 8pt 0; } + th, td { border: 1px solid #d0d0d0; padding: 5pt 8pt; text-align: left; font-size: 10pt; } + th { background: #f5f5f5; font-weight: 600; } + img { max-width: 100%; } + a { color: #1a73e8; text-decoration: none; } + hr { border: none; border-top: 1px solid #e5e5e5; margin: 10pt 0; } + """ + + body_html = f"

{title}

\n{html_content}" + full_html = pdf_generator._wrap_html( + body_html, + css=doc_css, + page_config={"size": "A4", "orientation": "portrait", "margin": {"top": 20, "right": 15, "bottom": 20, "left": 15}}, + ) + + try: + html_doc = HTML(string=full_html) + pdf_bytes = html_doc.write_pdf(font_config=pdf_generator.font_config) + except Exception as e: + raise HTTPException(status_code=500, detail=f"PDF 生成失败: {str(e)}") + + from urllib.parse import quote + safe_name = table.name.replace("/", "_").replace("\\", "_") + encoded_name = quote(f"{safe_name}.pdf") + return StreamingResponse( + io.BytesIO(pdf_bytes), + media_type="application/pdf", + headers={ + "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_name}", + }, + ) + + +# ==================== Field ==================== + +@router.get("/tables/{table_id}/fields", response_model=List[SmartFieldResponse], summary="获取字段列表") +async def get_field_list(table_id: str, db: AsyncSession = Depends(get_db)): + await PermissionService.require_table_access(db, table_id) + return await SmartFieldService.get_by_table(db, table_id) + + +@router.post("/tables/{table_id}/fields", response_model=SmartFieldResponse, summary="添加字段") +async def create_field(table_id: str, data: SmartFieldCreate, db: AsyncSession = Depends(get_db)): + await PermissionService.require_capability(db, table_id, "manage_field") + data.table_id = table_id + + if data.type == "link": + linked_table_id = (data.config or {}).get("linkedTableId") + if not linked_table_id: + raise HTTPException(status_code=400, detail="Link 字段必须指定 linkedTableId") + source_field, _ = await SmartLinkService.create_link_field( + db, table_id, data.name, linked_table_id, sort=data.sort, + ) + return source_field + + if data.sort == 0: + data.sort = await SmartFieldService.get_next_sort(db, table_id) + field = await SmartFieldService.create(db, data) + return field + + +@router.put("/fields/{field_id}", response_model=SmartFieldResponse, summary="更新字段") +async def update_field(field_id: str, data: SmartFieldUpdate, db: AsyncSession = Depends(get_db)): + field = await SmartFieldService.get_by_id(db, field_id) + if not field: + raise HTTPException(status_code=404, detail="字段不存在") + await PermissionService.require_capability(db, field.table_id, "manage_field") + field = await SmartFieldService.update(db, record_id=field_id, data=data) + return field + + +@router.delete("/fields/{field_id}", response_model=ResponseModel, summary="删除字段") +async def delete_field(field_id: str, db: AsyncSession = Depends(get_db)): + field = await SmartFieldService.get_by_id(db, field_id) + if not field: + raise HTTPException(status_code=404, detail="字段不存在") + await PermissionService.require_capability(db, field.table_id, "manage_field") + + if field.type == "link": + success = await SmartLinkService.delete_link_field(db, field_id) + else: + success = await SmartFieldService.delete_and_clean(db, field_id) + if not success: + raise HTTPException(status_code=404, detail="字段不存在") + return ResponseModel(message="删除成功") + + +@router.put("/tables/{table_id}/fields/reorder", response_model=ResponseModel, summary="字段排序") +async def reorder_fields(table_id: str, data: SmartFieldReorder, db: AsyncSession = Depends(get_db)): + await PermissionService.require_capability(db, table_id, "manage_field") + await SmartFieldService.reorder(db, table_id, data.field_ids) + return ResponseModel(message="排序成功") + + +# ==================== Record ==================== + +@router.get( + "/tables/{table_id}/records", + response_model=CursorPaginatedRecords, + summary="获取记录列表(游标分页,支持筛选/排序/搜索)", +) +async def get_record_list( + table_id: str, + cursor: Optional[str] = Query(default=None, description="游标(上一页最后一条记录的 ID)"), + limit: int = Query(default=200, ge=1, le=5000, description="每页数量"), + filters: Optional[str] = Query(default=None, description="筛选条件 JSON 数组"), + filter_logic: str = Query(default="and", description="筛选逻辑: and / or"), + sorts: Optional[str] = Query(default=None, description="排序规则 JSON 数组"), + search: Optional[str] = Query(default=None, description="搜索关键词"), + db: AsyncSession = Depends(get_db), +): + role_info = await PermissionService.require_table_access(db, table_id) + + parsed_filters = None + if filters: + try: + parsed_filters = [RecordFilterRule(**f) for f in _json.loads(filters)] + except Exception: + raise HTTPException(status_code=400, detail="筛选条件格式错误") + + parsed_sorts = None + if sorts: + try: + parsed_sorts = [RecordSortRule(**s) for s in _json.loads(sorts)] + except Exception: + raise HTTPException(status_code=400, detail="排序规则格式错误") + + user_info = get_current_user_info_from_context() or {} + row_conditions = await PermissionService.build_row_filter_conditions( + db, table_id, role_info.get("role_id"), user_info.get("user_id", ""), rule_type="view" + ) + + skip_count = cursor is not None + items, next_cursor, total = await SmartRecordService.get_by_table_cursor_filtered( + db, table_id, + filters=parsed_filters, + filter_logic=filter_logic, + sorts=parsed_sorts, + search=search, + extra_conditions=row_conditions or None, + cursor=cursor, + limit=limit, + skip_count=skip_count, + ) + + all_fields = await SmartFieldService.get_by_table(db, table_id) + await SmartLinkService.resolve_link_values(db, all_fields, items) + await SmartLinkService.resolve_lookup_values(db, all_fields, items) + await SmartLinkService.resolve_rollup_values(db, all_fields, items) + SmartFormulaService.resolve_formula_values(all_fields, items) + + accessible = await PermissionService.get_accessible_field_ids( + db, table_id, role_info.get("role_id"), mode="read" + ) + if accessible is not None: + for r in items: + r.values = PermissionService.filter_record_values(r.values or {}, accessible) + + return CursorPaginatedRecords( + items=items, + total=total, + next_cursor=next_cursor, + has_more=next_cursor is not None, + ) + + +@router.post( + "/tables/{table_id}/records/query", + response_model=Union[GroupedRecordsResponse, CursorPaginatedRecords], + summary="复杂查询记录(POST Body 传递筛选/排序/搜索条件)", +) +async def query_records( + table_id: str, + query: RecordQuery, + db: AsyncSession = Depends(get_db), +): + role_info = await PermissionService.require_table_access(db, table_id) + + user_info = get_current_user_info_from_context() or {} + row_conditions = await PermissionService.build_row_filter_conditions( + db, table_id, role_info.get("role_id"), user_info.get("user_id", ""), rule_type="view" + ) + + skip_count = query.cursor is not None + items, next_cursor, total = await SmartRecordService.get_by_table_cursor_filtered( + db, table_id, + filters=query.filters or None, + filter_logic=query.filter_logic, + sorts=query.sorts or None, + search=query.search, + search_field_ids=query.search_field_ids, + extra_conditions=row_conditions or None, + cursor=query.cursor, + limit=query.limit, + skip_count=skip_count, + ) + + all_fields = await SmartFieldService.get_by_table(db, table_id) + await SmartLinkService.resolve_link_values(db, all_fields, items) + await SmartLinkService.resolve_lookup_values(db, all_fields, items) + await SmartLinkService.resolve_rollup_values(db, all_fields, items) + SmartFormulaService.resolve_formula_values(all_fields, items) + + accessible = await PermissionService.get_accessible_field_ids( + db, table_id, role_info.get("role_id"), mode="read" + ) + if accessible is not None: + for r in items: + r.values = PermissionService.filter_record_values(r.values or {}, accessible) + + if query.group_field_id: + field = next((f for f in all_fields if f.id == query.group_field_id), None) + groups_map: dict[str, list] = {} + for r in items: + val = str((r.values or {}).get(query.group_field_id, "") or "") + groups_map.setdefault(val, []).append(r) + + group_items = [] + for key, recs in groups_map.items(): + label = key or "(空)" + if field and field.config and field.config.get("options"): + opt = next((o for o in field.config["options"] if o.get("id") == key), None) + if opt: + label = opt.get("label", key) + group_items.append(RecordGroupItem(key=key, label=label, records=recs)) + + return GroupedRecordsResponse(groups=group_items, total=total) + + return CursorPaginatedRecords( + items=items, + total=total, + next_cursor=next_cursor, + has_more=next_cursor is not None, + ) + + +@router.post("/tables/{table_id}/records", response_model=SmartRecordResponse, summary="添加记录") +async def create_record(table_id: str, data: SmartRecordCreate, db: AsyncSession = Depends(get_db)): + await PermissionService.require_capability(db, table_id, "add_record") + data.table_id = table_id + + all_fields = await SmartFieldService.get_by_table(db, table_id) + auto_number_fields = [f for f in all_fields if f.type == "autoNumber"] + if auto_number_fields: + if not data.values: + data.values = {} + for af in auto_number_fields: + if af.id not in data.values: + next_val = await SmartRecordService.get_next_auto_number(db, table_id, af.id) + data.values[af.id] = next_val + + record = await SmartRecordService.create(db, data) + return record + + +@router.put("/records/{record_id}", response_model=SmartRecordResponse, summary="更新记录") +async def update_record(record_id: str, data: SmartRecordUpdate, db: AsyncSession = Depends(get_db)): + record = await SmartRecordService.get_by_id(db, record_id) + if not record: + raise HTTPException(status_code=404, detail="记录不存在") + role_info = await PermissionService.require_capability(db, record.table_id, "edit_record") + user_info = get_current_user_info_from_context() or {} + can_edit = await PermissionService.check_row_edit_permission( + db, record.table_id, record, role_info.get("role_id"), user_info.get("user_id", "") + ) + if not can_edit: + raise HTTPException(status_code=403, detail="无权编辑此记录") + record = await SmartRecordService.update(db, record_id=record_id, data=data) + return record + + +@router.patch("/records/{record_id}/cells", response_model=SmartRecordResponse, summary="更新单元格") +async def update_cell(record_id: str, data: SmartRecordCellUpdate, db: AsyncSession = Depends(get_db)): + record = await SmartRecordService.get_by_id(db, record_id) + if not record: + raise HTTPException(status_code=404, detail="记录不存在") + role_info = await PermissionService.require_capability(db, record.table_id, "edit_record") + user_info = get_current_user_info_from_context() or {} + can_edit = await PermissionService.check_row_edit_permission( + db, record.table_id, record, role_info.get("role_id"), user_info.get("user_id", "") + ) + if not can_edit: + raise HTTPException(status_code=403, detail="无权编辑此记录") + + writable = await PermissionService.get_accessible_field_ids( + db, record.table_id, role_info.get("role_id"), mode="write" + ) + if writable is not None and data.field_id not in writable: + raise HTTPException(status_code=403, detail="无权编辑此字段") + + field = await SmartFieldService.get_by_id(db, data.field_id) + if not field: + raise HTTPException(status_code=404, detail="字段不存在") + + validation_error = await SmartValidationService.validate_cell(db, field, data.value, record_id) + if validation_error: + raise HTTPException(status_code=422, detail=validation_error) + + if field.type == "link": + target_ids = data.value if isinstance(data.value, list) else [] + await SmartLinkService.set_linked_records(db, data.field_id, record_id, target_ids) + record = await SmartRecordService.get_by_id(db, record_id) + all_fields = await SmartFieldService.get_by_table(db, record.table_id) + await SmartLinkService.resolve_link_values(db, all_fields, [record]) + await SmartLinkService.resolve_lookup_values(db, all_fields, [record]) + await SmartLinkService.resolve_rollup_values(db, all_fields, [record]) + return record + + record = await SmartRecordService.update_cell(db, record_id, data.field_id, data.value) + return record + + +@router.patch("/records/{record_id}/cells/batch", response_model=SmartRecordResponse, summary="批量更新单元格") +async def batch_update_cells(record_id: str, data: SmartRecordBatchCellUpdate, db: AsyncSession = Depends(get_db)): + record = await SmartRecordService.get_by_id(db, record_id) + if not record: + raise HTTPException(status_code=404, detail="记录不存在") + role_info = await PermissionService.require_capability(db, record.table_id, "edit_record") + user_info = get_current_user_info_from_context() or {} + can_edit = await PermissionService.check_row_edit_permission( + db, record.table_id, record, role_info.get("role_id"), user_info.get("user_id", "") + ) + if not can_edit: + raise HTTPException(status_code=403, detail="无权编辑此记录") + + writable = await PermissionService.get_accessible_field_ids( + db, record.table_id, role_info.get("role_id"), mode="write" + ) + filtered_cells = data.cells + if writable is not None: + filtered_cells = {k: v for k, v in data.cells.items() if k in writable} + if not filtered_cells: + raise HTTPException(status_code=403, detail="无权编辑任何提交的字段") + + link_cells = {} + normal_cells = {} + fields_cache = {} + for field_id, val in filtered_cells.items(): + if field_id not in fields_cache: + f = await SmartFieldService.get_by_id(db, field_id) + fields_cache[field_id] = f + f = fields_cache[field_id] + if f and f.type == "link": + link_cells[field_id] = val + else: + normal_cells[field_id] = val + + validation_errors = await SmartValidationService.validate_cells_batch( + db, record.table_id, normal_cells, record_id + ) + if validation_errors: + first_err = next(iter(validation_errors.values())) + raise HTTPException(status_code=422, detail=first_err) + + if normal_cells: + record = await SmartRecordService.batch_update_cells(db, record_id, normal_cells) + + for fid, val in link_cells.items(): + target_ids = val if isinstance(val, list) else [] + await SmartLinkService.set_linked_records(db, fid, record_id, target_ids) + + record = await SmartRecordService.get_by_id(db, record_id) + if link_cells: + all_fields = await SmartFieldService.get_by_table(db, record.table_id) + await SmartLinkService.resolve_link_values(db, all_fields, [record]) + await SmartLinkService.resolve_lookup_values(db, all_fields, [record]) + await SmartLinkService.resolve_rollup_values(db, all_fields, [record]) + return record + + +@router.delete("/records/{record_id}", response_model=ResponseModel, summary="删除记录") +async def delete_record(record_id: str, db: AsyncSession = Depends(get_db)): + record = await SmartRecordService.get_by_id(db, record_id) + if not record: + raise HTTPException(status_code=404, detail="记录不存在") + await PermissionService.require_capability(db, record.table_id, "delete_record") + await SmartLinkService.cleanup_record_links(db, record_id) + success = await SmartRecordService.delete(db, record_id=record_id) + if not success: + raise HTTPException(status_code=404, detail="记录不存在") + return ResponseModel(message="删除成功") + + +@router.post("/tables/{table_id}/records/batch-delete", response_model=ResponseModel, summary="批量删除记录") +async def batch_delete_records(table_id: str, data: SmartRecordBatchDelete, db: AsyncSession = Depends(get_db)): + await PermissionService.require_capability(db, table_id, "delete_record") + for rid in data.ids: + await SmartLinkService.cleanup_record_links(db, rid) + success, fail = await SmartRecordService.batch_delete(db, data.ids) + return ResponseModel(message=f"成功删除{success}条", data={"success": success, "fail": fail}) + + +# ==================== Trash / Recycle Bin ==================== + +@router.get("/tables/{table_id}/trash", response_model=TrashListResponse, summary="获取回收站记录列表") +async def get_trash_records( + table_id: str, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=200), + db: AsyncSession = Depends(get_db), +): + await PermissionService.require_capability(db, table_id, "delete_record") + items, total = await SmartRecordService.get_deleted_records(db, table_id, page, page_size) + return TrashListResponse(items=items, total=total) + + +@router.post("/tables/{table_id}/trash/restore", response_model=ResponseModel, summary="批量恢复回收站记录") +async def restore_trash_records( + table_id: str, + data: SmartRecordBatchRestore, + db: AsyncSession = Depends(get_db), +): + await PermissionService.require_capability(db, table_id, "delete_record") + count = await SmartRecordService.batch_restore_records(db, table_id, data.ids) + return ResponseModel(message=f"成功恢复{count}条记录", data={"restored": count}) + + +@router.delete("/tables/{table_id}/trash/{record_id}", response_model=ResponseModel, summary="永久删除回收站记录") +async def permanent_delete_record( + table_id: str, + record_id: str, + db: AsyncSession = Depends(get_db), +): + await PermissionService.require_capability(db, table_id, "delete_record") + success = await SmartRecordService.permanent_delete(db, table_id, record_id) + if not success: + raise HTTPException(status_code=404, detail="记录不存在或未被删除") + return ResponseModel(message="永久删除成功") + + +@router.delete("/tables/{table_id}/trash", response_model=ResponseModel, summary="清空回收站") +async def empty_trash( + table_id: str, + db: AsyncSession = Depends(get_db), +): + await PermissionService.require_capability(db, table_id, "delete_record") + count = await SmartRecordService.empty_trash(db, table_id) + return ResponseModel(message=f"已清空{count}条记录", data={"deleted": count}) + + +@router.patch( + "/tables/{table_id}/records/batch-cells", + response_model=ResponseModel, + summary="批量更新多条记录的单元格(合并为单次事务)", +) +async def batch_update_multi_record_cells( + table_id: str, + data: MultiRecordBatchUpdate, + db: AsyncSession = Depends(get_db), +): + role_info = await PermissionService.require_capability(db, table_id, "edit_record") + user_info = get_current_user_info_from_context() or {} + user_id = user_info.get("user_id", "") + + writable_fields = await PermissionService.get_accessible_field_ids( + db, table_id, role_info.get("role_id"), mode="write" + ) + + fields_cache: dict = {} + valid_updates = [] + for u in data.updates: + record = await SmartRecordService.get_by_id(db, u.record_id) + if not record or record.table_id != table_id: + continue + + can_edit = await PermissionService.check_row_edit_permission( + db, table_id, record, role_info.get("role_id"), user_id + ) + if not can_edit: + continue + + filtered_cells = {} + for field_id, val in u.cells.items(): + if writable_fields is not None and field_id not in writable_fields: + continue + if field_id not in fields_cache: + fields_cache[field_id] = await SmartFieldService.get_by_id(db, field_id) + field = fields_cache[field_id] + if not field: + continue + err = await SmartValidationService.validate_cell(db, field, val, u.record_id) + if err: + continue + filtered_cells[field_id] = val + + if filtered_cells: + valid_updates.append({"record_id": u.record_id, "cells": filtered_cells}) + + count = await SmartRecordService.batch_update_multi_records(db, valid_updates) + return ResponseModel(message=f"成功更新{count}条记录", data={"updated": count}) + + +@router.put("/tables/{table_id}/records/reorder", response_model=ResponseModel, summary="记录排序") +async def reorder_records(table_id: str, data: SmartRecordReorder, db: AsyncSession = Depends(get_db)): + await SmartRecordService.reorder(db, table_id, data.record_ids) + return ResponseModel(message="排序成功") + + +@router.post( + "/tables/{table_id}/records/search", + response_model=List[RecordSearchResult], + summary="搜索表记录(供 Link 字段选择器使用)", +) +async def search_records( + table_id: str, + query: RecordSearchQuery, + db: AsyncSession = Depends(get_db), +): + items = await SmartLinkService.search_records( + db, table_id, keyword=query.keyword, limit=query.limit, + ) + return [RecordSearchResult(**i) for i in items] + + +# ==================== Summary ==================== + +@router.post( + "/tables/{table_id}/summary", + response_model=SummaryResponse, + summary="获取字段汇总统计", +) +async def get_table_summary( + table_id: str, + data: SummaryRequest, + db: AsyncSession = Depends(get_db), +): + role_info = await PermissionService.require_table_access(db, table_id) + + parsed_filters = data.filters or None + + user_info = get_current_user_info_from_context() or {} + row_conditions = await PermissionService.build_row_filter_conditions( + db, table_id, role_info.get("role_id"), user_info.get("user_id", ""), rule_type="view" + ) + + summaries, total = await SmartSummaryService.compute( + db, table_id, + field_agg_map=data.aggregations, + filters=parsed_filters, + filter_logic=data.filter_logic, + search=data.search, + extra_conditions=row_conditions or None, + ) + + return SummaryResponse(summaries=summaries, total_count=total) + + +# ==================== View ==================== + +@router.get("/tables/{table_id}/views", response_model=List[SmartViewResponse], summary="获取视图列表") +async def get_view_list(table_id: str, db: AsyncSession = Depends(get_db)): + await PermissionService.require_table_access(db, table_id) + return await SmartViewService.get_by_table(db, table_id) + + +@router.post("/tables/{table_id}/views", response_model=SmartViewResponse, summary="创建视图") +async def create_view(table_id: str, data: SmartViewCreate, db: AsyncSession = Depends(get_db)): + await PermissionService.require_capability(db, table_id, "manage_view") + data.table_id = table_id + view = await SmartViewService.create(db, data) + return view + + +@router.put("/views/{view_id}", response_model=SmartViewResponse, summary="更新视图") +async def update_view(view_id: str, data: SmartViewUpdate, db: AsyncSession = Depends(get_db)): + existing_view = await SmartViewService.get_by_id(db, view_id) + if not existing_view: + raise HTTPException(status_code=404, detail="视图不存在") + await PermissionService.require_capability(db, existing_view.table_id, "manage_view") + view = await SmartViewService.update(db, record_id=view_id, data=data) + return view + + +@router.delete("/views/{view_id}", response_model=ResponseModel, summary="删除视图") +async def delete_view(view_id: str, db: AsyncSession = Depends(get_db)): + existing_view = await SmartViewService.get_by_id(db, view_id) + if not existing_view: + raise HTTPException(status_code=404, detail="视图不存在") + await PermissionService.require_capability(db, existing_view.table_id, "manage_view") + success = await SmartViewService.delete(db, record_id=view_id) + if not success: + raise HTTPException(status_code=404, detail="视图不存在") + return ResponseModel(message="删除成功") + + +# ==================== Export / Import ==================== + +@router.get("/tables/{table_id}/export", summary="导出表数据") +async def export_table( + table_id: str, + format: str = Query(default="csv", description="导出格式: csv / xlsx"), + db: AsyncSession = Depends(get_db), +): + role_info = await PermissionService.require_capability(db, table_id, "export_data") + + table = await SmartTableService.get_by_id(db, table_id) + if not table: + raise HTTPException(status_code=404, detail="表不存在") + + fields = await SmartFieldService.get_by_table(db, table_id) + accessible = await PermissionService.get_accessible_field_ids( + db, table_id, role_info.get("role_id"), mode="read" + ) + if accessible is not None: + fields = [f for f in fields if f.id in accessible] + + user_info = get_current_user_info_from_context() or {} + row_conditions = await PermissionService.build_row_filter_conditions( + db, table_id, role_info.get("role_id"), user_info.get("user_id", ""), rule_type="view" + ) + + records, _, _ = await SmartRecordService.get_by_table_cursor_filtered( + db, table_id, extra_conditions=row_conditions or None, limit=50000 + ) + + all_fields = await SmartFieldService.get_by_table(db, table_id) + await SmartLinkService.resolve_link_values(db, all_fields, records) + await SmartLinkService.resolve_lookup_values(db, all_fields, records) + await SmartLinkService.resolve_rollup_values(db, all_fields, records) + + import io + from urllib.parse import quote + + safe_name = table.name.replace("/", "_").replace("\\", "_") + + def _content_disposition(filename: str) -> str: + """Build Content-Disposition with RFC 5987 filename* for non-ASCII names.""" + ascii_name = filename.encode("ascii", "ignore").decode("ascii") or "export" + encoded_name = quote(filename) + return ( + f'attachment; filename="{ascii_name}"; ' + f"filename*=UTF-8''{encoded_name}" + ) + + if format == "xlsx": + content = await SmartExportService.export_xlsx(db, table_id, fields, records) + return StreamingResponse( + io.BytesIO(content), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": _content_disposition(f"{safe_name}.xlsx")}, + ) + else: + content = await SmartExportService.export_csv(db, table_id, fields, records) + return StreamingResponse( + io.BytesIO(content.encode("utf-8-sig")), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": _content_disposition(f"{safe_name}.csv")}, + ) + + +@router.post("/tables/{table_id}/import", response_model=ResponseModel, summary="导入数据") +async def import_table( + table_id: str, + file: UploadFile = File(..., description="CSV 或 Excel 文件"), + db: AsyncSession = Depends(get_db), +): + await PermissionService.require_capability(db, table_id, "import_data") + + table = await SmartTableService.get_by_id(db, table_id) + if not table: + raise HTTPException(status_code=404, detail="表不存在") + + fields = await SmartFieldService.get_by_table(db, table_id) + file_content = await file.read() + filename = file.filename or "" + + if filename.endswith(".xlsx") or filename.endswith(".xls"): + result = await SmartImportService.import_xlsx(db, table_id, file_content, fields) + elif filename.endswith(".csv"): + result = await SmartImportService.import_csv(db, table_id, file_content, fields) + else: + raise HTTPException(status_code=400, detail="仅支持 CSV 和 Excel (.xlsx) 文件") + + return ResponseModel( + message=f"导入完成:成功 {result['success']} 条,失败 {result['fail']} 条", + data=result, + ) + + +# ==================== Comment ==================== + +@router.get("/records/{record_id}/comments", response_model=List[CommentResponse], summary="获取记录评论") +async def get_comments(record_id: str, db: AsyncSession = Depends(get_db)): + from sqlalchemy import select as sa_select + + comments = await SmartCommentService.get_by_record(db, record_id) + + user_ids = list({c.user_id for c in comments}) + user_map: dict = {} + if user_ids: + try: + from core.model import CoreUser + result = await db.execute( + sa_select(CoreUser.id, CoreUser.name, CoreUser.avatar) + .where(CoreUser.id.in_(user_ids)) + ) + for row in result.all(): + user_map[row[0]] = {"name": row[1], "avatar": row[2]} + except Exception: + pass + + top_level: list[CommentResponse] = [] + reply_map: dict[str, list[CommentResponse]] = {} + + for c in comments: + u = user_map.get(c.user_id, {}) + item = CommentResponse( + id=c.id, + record_id=c.record_id, + user_id=c.user_id, + content=c.content, + mentions=c.mentions or [], + parent_id=c.parent_id, + sys_create_datetime=c.sys_create_datetime, + sys_update_datetime=c.sys_update_datetime, + user_name=u.get("name"), + user_avatar=u.get("avatar"), + ) + if c.parent_id: + reply_map.setdefault(c.parent_id, []).append(item) + else: + top_level.append(item) + + for item in top_level: + item.replies = reply_map.get(item.id, []) + + return top_level + + +@router.post("/records/{record_id}/comments", response_model=CommentResponse, summary="创建评论") +async def create_comment(record_id: str, data: CommentCreate, db: AsyncSession = Depends(get_db)): + user_id = get_current_user_id_from_context() + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + + comment = await SmartCommentService.create( + db, record_id, user_id, data.content, data.mentions, data.parent_id, + ) + + user_map: dict = {} + try: + from sqlalchemy import select as sa_select + from core.model import CoreUser + result = await db.execute( + sa_select(CoreUser.id, CoreUser.name, CoreUser.avatar).where(CoreUser.id == user_id) + ) + row = result.first() + if row: + user_map[row[0]] = {"name": row[1], "avatar": row[2]} + except Exception: + pass + + u = user_map.get(user_id, {}) + return CommentResponse( + id=comment.id, + record_id=comment.record_id, + user_id=comment.user_id, + content=comment.content, + mentions=comment.mentions or [], + parent_id=comment.parent_id, + sys_create_datetime=comment.sys_create_datetime, + sys_update_datetime=comment.sys_update_datetime, + user_name=u.get("name"), + user_avatar=u.get("avatar"), + ) + + +@router.put("/comments/{comment_id}", response_model=CommentResponse, summary="更新评论") +async def update_comment(comment_id: str, data: CommentUpdate, db: AsyncSession = Depends(get_db)): + user_id = get_current_user_id_from_context() + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + + comment = await SmartCommentService.update(db, comment_id, user_id, data.content, data.mentions) + if not comment: + raise HTTPException(status_code=404, detail="评论不存在或无权编辑") + + return CommentResponse( + id=comment.id, + record_id=comment.record_id, + user_id=comment.user_id, + content=comment.content, + mentions=comment.mentions or [], + parent_id=comment.parent_id, + sys_create_datetime=comment.sys_create_datetime, + sys_update_datetime=comment.sys_update_datetime, + ) + + +@router.delete("/comments/{comment_id}", response_model=ResponseModel, summary="删除评论") +async def delete_comment(comment_id: str, db: AsyncSession = Depends(get_db)): + user_id = get_current_user_id_from_context() + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + + ok = await SmartCommentService.delete(db, comment_id, user_id) + if not ok: + raise HTTPException(status_code=404, detail="评论不存在或无权删除") + + return ResponseModel(message="删除成功") + + +# ==================== Document Version ==================== + +@router.get("/tables/{table_id}/versions", summary="获取版本列表") +async def get_document_versions( + table_id: str, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100, alias="pageSize"), + db: AsyncSession = Depends(get_db), +): + await PermissionService.require_table_access(db, table_id) + items, total = await SmartDocumentVersionService.get_versions(db, table_id, page, page_size) + + user_ids = list({v.sys_creator_id for v in items if v.sys_creator_id}) + user_map: dict = {} + if user_ids: + users = await UserService.get_by_ids(db, user_ids) + for u in users: + user_map[u.id] = u + + result = [] + for v in items: + resp = DocumentVersionResponse( + id=v.id, document_id=v.document_id, version=v.version, + title=v.title, change_summary=v.change_summary, + content_size=v.content_size, + sys_create_datetime=v.sys_create_datetime, + sys_creator_id=v.sys_creator_id, + ) + u = user_map.get(v.sys_creator_id) if v.sys_creator_id else None + if u: + resp.creator_name = getattr(u, "name", None) or getattr(u, "username", None) + resp.creator_avatar = getattr(u, "avatar", None) + result.append(resp) + + return {"items": result, "total": total} + + +@router.get("/versions/{version_id}", response_model=DocumentVersionDetail, summary="获取版本详情") +async def get_document_version_detail( + version_id: str, + db: AsyncSession = Depends(get_db), +): + version = await SmartDocumentVersionService.get_version_detail(db, version_id) + if not version: + raise HTTPException(status_code=404, detail="版本不存在") + await PermissionService.require_table_access(db, version.document_id) + + u = None + if version.sys_creator_id: + u = await UserService.get_by_id(db, version.sys_creator_id) + + return DocumentVersionDetail( + id=version.id, document_id=version.document_id, version=version.version, + title=version.title, change_summary=version.change_summary, + content_size=version.content_size, content=version.content or {}, + sys_create_datetime=version.sys_create_datetime, + sys_creator_id=version.sys_creator_id, + creator_name=(getattr(u, "name", None) or getattr(u, "username", None)) if u else None, + creator_avatar=getattr(u, "avatar", None) if u else None, + ) + + +@router.post("/tables/{table_id}/versions", response_model=DocumentVersionResponse, summary="手动创建版本快照") +async def create_document_version( + table_id: str, + data: DocumentVersionCreate, + db: AsyncSession = Depends(get_db), +): + await PermissionService.require_capability(db, table_id, "edit_record") + table = await SmartTableService.get_by_id(db, table_id) + if not table: + raise HTTPException(status_code=404, detail="文档不存在") + if getattr(table, "type", "table") != "document": + raise HTTPException(status_code=400, detail="该项目不是文档类型") + if not table.content: + raise HTTPException(status_code=400, detail="文档内容为空,无法创建版本") + + user_id = get_current_user_id_from_context() + version = await SmartDocumentVersionService.create_version( + db, table_id, table.content, + title=table.name, + change_summary=data.change_summary, + user_id=user_id, + ) + return DocumentVersionResponse( + id=version.id, document_id=version.document_id, version=version.version, + title=version.title, change_summary=version.change_summary, + content_size=version.content_size, + sys_create_datetime=version.sys_create_datetime, + sys_creator_id=version.sys_creator_id, + ) + + +@router.post("/tables/{table_id}/versions/{version_id}/restore", response_model=ResponseModel, summary="恢复到指定版本") +async def restore_document_version( + table_id: str, + version_id: str, + db: AsyncSession = Depends(get_db), +): + await PermissionService.require_capability(db, table_id, "edit_record") + user_id = get_current_user_id_from_context() + result = await SmartDocumentVersionService.restore_version(db, table_id, version_id, user_id) + if not result: + raise HTTPException(status_code=404, detail="版本不存在或文档不存在") + return ResponseModel(message="版本恢复成功") + + +@router.get("/tables/{table_id}/versions/compare", response_model=DocumentVersionCompare, summary="版本对比") +async def compare_document_versions( + table_id: str, + version_from: str = Query(..., alias="from", description="源版本ID"), + version_to: str = Query(..., alias="to", description="目标版本ID"), + db: AsyncSession = Depends(get_db), +): + await PermissionService.require_table_access(db, table_id) + v_from = await SmartDocumentVersionService.get_version_detail(db, version_from) + v_to = await SmartDocumentVersionService.get_version_detail(db, version_to) + if not v_from or not v_to: + raise HTTPException(status_code=404, detail="版本不存在") + if v_from.document_id != table_id or v_to.document_id != table_id: + raise HTTPException(status_code=400, detail="版本不属于该文档") + + return DocumentVersionCompare( + version_from=DocumentVersionDetail( + id=v_from.id, document_id=v_from.document_id, version=v_from.version, + title=v_from.title, change_summary=v_from.change_summary, + content_size=v_from.content_size, content=v_from.content or {}, + sys_create_datetime=v_from.sys_create_datetime, sys_creator_id=v_from.sys_creator_id, + ), + version_to=DocumentVersionDetail( + id=v_to.id, document_id=v_to.document_id, version=v_to.version, + title=v_to.title, change_summary=v_to.change_summary, + content_size=v_to.content_size, content=v_to.content or {}, + sys_create_datetime=v_to.sys_create_datetime, sys_creator_id=v_to.sys_creator_id, + ), + ) + + +@router.delete("/versions/{version_id}", response_model=ResponseModel, summary="删除版本") +async def delete_document_version( + version_id: str, + db: AsyncSession = Depends(get_db), +): + version = await SmartDocumentVersionService.get_version_detail(db, version_id) + if not version: + raise HTTPException(status_code=404, detail="版本不存在") + await PermissionService.require_capability(db, version.document_id, "edit_record") + + ok = await SmartDocumentVersionService.delete_version(db, version_id) + if not ok: + raise HTTPException(status_code=404, detail="版本不存在") + return ResponseModel(message="删除成功") + + +# ==================== Document Template ==================== + +@router.get("/document-templates", summary="获取模板列表") +async def get_document_templates( + category: Optional[str] = Query(default=None, description="分类筛选"), + keyword: Optional[str] = Query(default=None, description="关键词搜索"), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=100, alias="pageSize"), + db: AsyncSession = Depends(get_db), +): + items, total = await SmartDocumentTemplateService.get_list( + db, category=category, keyword=keyword, page=page, page_size=page_size, + ) + + user_ids = list({t.sys_creator_id for t in items if t.sys_creator_id}) + user_map: dict = {} + if user_ids: + users = await UserService.get_by_ids(db, user_ids) + for u in users: + user_map[u.id] = u + + result = [] + for t in items: + resp = DocumentTemplateResponse( + id=t.id, name=t.name, description=t.description, + icon=t.icon, category=t.category, + preview_image=t.preview_image, + is_system=t.is_system, use_count=t.use_count, + sys_create_datetime=t.sys_create_datetime, + sys_update_datetime=t.sys_update_datetime, + sys_creator_id=t.sys_creator_id, + ) + u = user_map.get(t.sys_creator_id) if t.sys_creator_id else None + if u: + resp.creator_name = getattr(u, "name", None) or getattr(u, "username", None) + result.append(resp) + + return {"items": result, "total": total} + + +@router.get("/document-templates/categories", summary="获取模板分类") +async def get_template_categories(db: AsyncSession = Depends(get_db)): + return await SmartDocumentTemplateService.get_categories(db) + + +@router.get("/document-templates/{template_id}", response_model=DocumentTemplateDetail, summary="获取模板详情") +async def get_document_template_detail( + template_id: str, + db: AsyncSession = Depends(get_db), +): + template = await SmartDocumentTemplateService.get_by_id(db, template_id) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + + u = None + if template.sys_creator_id: + u = await UserService.get_by_id(db, template.sys_creator_id) + + return DocumentTemplateDetail( + id=template.id, name=template.name, description=template.description, + icon=template.icon, category=template.category, + preview_image=template.preview_image, + is_system=template.is_system, use_count=template.use_count, + content=template.content or {}, + sys_create_datetime=template.sys_create_datetime, + sys_update_datetime=template.sys_update_datetime, + sys_creator_id=template.sys_creator_id, + creator_name=(getattr(u, "name", None) or getattr(u, "username", None)) if u else None, + ) + + +@router.post("/document-templates", response_model=DocumentTemplateResponse, summary="创建模板") +async def create_document_template( + data: DocumentTemplateCreate, + db: AsyncSession = Depends(get_db), +): + user_id = get_current_user_id_from_context() + template = await SmartDocumentTemplateService.create( + db, name=data.name, content=data.content, + description=data.description, icon=data.icon, + category=data.category, preview_image=data.preview_image, + user_id=user_id, + ) + return DocumentTemplateResponse( + id=template.id, name=template.name, description=template.description, + icon=template.icon, category=template.category, + preview_image=template.preview_image, + is_system=template.is_system, use_count=template.use_count, + sys_create_datetime=template.sys_create_datetime, + sys_update_datetime=template.sys_update_datetime, + sys_creator_id=template.sys_creator_id, + ) + + +@router.post("/document-templates/from-document/{document_id}", response_model=DocumentTemplateResponse, summary="从文档创建模板") +async def create_template_from_document( + document_id: str, + data: DocumentTemplateCreate, + db: AsyncSession = Depends(get_db), +): + await PermissionService.require_table_access(db, document_id) + user_id = get_current_user_id_from_context() + template = await SmartDocumentTemplateService.create_from_document( + db, document_id=document_id, name=data.name, + description=data.description, category=data.category, + user_id=user_id, + ) + if not template: + raise HTTPException(status_code=400, detail="文档不存在或内容为空") + return DocumentTemplateResponse( + id=template.id, name=template.name, description=template.description, + icon=template.icon, category=template.category, + preview_image=template.preview_image, + is_system=template.is_system, use_count=template.use_count, + sys_create_datetime=template.sys_create_datetime, + sys_update_datetime=template.sys_update_datetime, + sys_creator_id=template.sys_creator_id, + ) + + +@router.put("/document-templates/{template_id}", response_model=DocumentTemplateResponse, summary="更新模板") +async def update_document_template( + template_id: str, + data: DocumentTemplateUpdate, + db: AsyncSession = Depends(get_db), +): + user_id = get_current_user_id_from_context() + update_data = data.model_dump(exclude_unset=True) + template = await SmartDocumentTemplateService.update(db, template_id, update_data, user_id) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + return DocumentTemplateResponse( + id=template.id, name=template.name, description=template.description, + icon=template.icon, category=template.category, + preview_image=template.preview_image, + is_system=template.is_system, use_count=template.use_count, + sys_create_datetime=template.sys_create_datetime, + sys_update_datetime=template.sys_update_datetime, + sys_creator_id=template.sys_creator_id, + ) + + +@router.delete("/document-templates/{template_id}", response_model=ResponseModel, summary="删除模板") +async def delete_document_template( + template_id: str, + db: AsyncSession = Depends(get_db), +): + ok = await SmartDocumentTemplateService.delete(db, template_id) + if not ok: + raise HTTPException(status_code=404, detail="模板不存在或为系统模板不可删除") + return ResponseModel(message="删除成功") + + +@router.post("/document-templates/{template_id}/use", response_model=ResponseModel, summary="使用模板(增加使用计数)") +async def use_document_template( + template_id: str, + db: AsyncSession = Depends(get_db), +): + template = await SmartDocumentTemplateService.get_by_id(db, template_id) + if not template: + raise HTTPException(status_code=404, detail="模板不存在") + await SmartDocumentTemplateService.increment_use_count(db, template_id) + return ResponseModel(message="OK") + + +# ==================== Wiki Space ==================== + +@router.post("/wiki-spaces", response_model=WikiSpaceResponse, summary="创建文档库") +async def create_wiki_space(data: WikiSpaceCreate, db: AsyncSession = Depends(get_db)): + user_id = get_current_user_id_from_context() + space = await WikiSpaceService.create(db, data, current_user_id=user_id) + creator_name = None + if user_id: + user = await UserService.get_by_id(db, user_id) + if user: + creator_name = getattr(user, "real_name", None) or getattr(user, "username", None) + resp = WikiSpaceResponse.model_validate(space) + resp.creator_name = creator_name + return resp + + +@router.get("/wiki-spaces", response_model=List[WikiSpaceListItem], summary="获取文档库列表") +async def get_wiki_spaces(db: AsyncSession = Depends(get_db)): + user_info = get_current_user_info_from_context() + user_id = user_info.get("user_id", "") if user_info else None + is_superuser = user_info.get("is_superuser", False) if user_info else False + + items, _ = await WikiSpaceService.get_list_with_count( + db, user_id=user_id, is_superuser=is_superuser, + ) + result = [] + for item in items: + space = item["space"] + creator_name = None + if space.sys_creator_id: + user = await UserService.get_by_id(db, space.sys_creator_id) + if user: + creator_name = getattr(user, "real_name", None) or getattr(user, "username", None) + resp = WikiSpaceListItem( + **WikiSpaceResponse.model_validate(space).model_dump(), + document_count=item["document_count"], + ) + resp.creator_name = creator_name + result.append(resp) + return result + + +@router.get("/wiki-spaces/{space_id}", response_model=WikiSpaceDetail, summary="获取文档库详情") +async def get_wiki_space_detail(space_id: str, db: AsyncSession = Depends(get_db)): + space = await WikiSpaceService.get_by_id(db, space_id) + if not space: + raise HTTPException(status_code=404, detail="文档库不存在") + + user_info = get_current_user_info_from_context() + user_id = user_info.get("user_id", "") if user_info else None + dept_id = user_info.get("dept_id") if user_info else None + role_ids = user_info.get("role_ids", []) if user_info else [] + is_superuser = user_info.get("is_superuser", False) if user_info else False + + has_access = await WikiSpaceService.check_space_access( + space, user_id=user_id, is_superuser=is_superuser, + ) + if not has_access: + raise HTTPException(status_code=403, detail="无权访问此文档库") + + documents = await WikiSpaceService.get_documents( + db, space_id, + user_id=user_id, dept_id=dept_id, + role_ids=role_ids, is_superuser=is_superuser, + ) + from zq_smart_table.schema import SmartTableSimple + doc_list = [SmartTableSimple.model_validate(d) for d in documents] + creator_name = None + if space.sys_creator_id: + user = await UserService.get_by_id(db, space.sys_creator_id) + if user: + creator_name = getattr(user, "real_name", None) or getattr(user, "username", None) + resp = WikiSpaceDetail( + **WikiSpaceResponse.model_validate(space).model_dump(), + documents=doc_list, + ) + resp.creator_name = creator_name + return resp + + +@router.put("/wiki-spaces/{space_id}", response_model=WikiSpaceResponse, summary="更新文档库") +async def update_wiki_space( + space_id: str, + data: WikiSpaceUpdate, + db: AsyncSession = Depends(get_db), +): + user_info = get_current_user_info_from_context() + user_id = user_info.get("user_id", "") if user_info else None + is_superuser = user_info.get("is_superuser", False) if user_info else False + + space = await WikiSpaceService.get_by_id(db, space_id) + if not space: + raise HTTPException(status_code=404, detail="文档库不存在") + if not is_superuser and space.sys_creator_id != user_id: + raise HTTPException(status_code=403, detail="仅创建者可修改文档库") + + space = await WikiSpaceService.update(db, space_id, data, current_user_id=user_id) + return WikiSpaceResponse.model_validate(space) + + +@router.delete("/wiki-spaces/{space_id}", response_model=ResponseModel, summary="删除文档库") +async def delete_wiki_space(space_id: str, db: AsyncSession = Depends(get_db)): + user_info = get_current_user_info_from_context() + user_id = user_info.get("user_id", "") if user_info else None + is_superuser = user_info.get("is_superuser", False) if user_info else False + + space = await WikiSpaceService.get_by_id(db, space_id) + if not space: + raise HTTPException(status_code=404, detail="文档库不存在") + if not is_superuser and space.sys_creator_id != user_id: + raise HTTPException(status_code=403, detail="仅创建者可删除文档库") + + ok = await WikiSpaceService.delete_with_documents(db, space_id) + if not ok: + raise HTTPException(status_code=404, detail="文档库不存在") + return ResponseModel(message="删除成功") + + +@router.get("/wiki-spaces/{space_id}/documents", response_model=List[SmartTableSimple], summary="获取文档库文档列表") +async def get_wiki_space_documents(space_id: str, db: AsyncSession = Depends(get_db)): + space = await WikiSpaceService.get_by_id(db, space_id) + if not space: + raise HTTPException(status_code=404, detail="文档库不存在") + + user_info = get_current_user_info_from_context() + user_id = user_info.get("user_id", "") if user_info else None + dept_id = user_info.get("dept_id") if user_info else None + role_ids = user_info.get("role_ids", []) if user_info else [] + is_superuser = user_info.get("is_superuser", False) if user_info else False + + has_access = await WikiSpaceService.check_space_access( + space, user_id=user_id, is_superuser=is_superuser, + ) + if not has_access: + raise HTTPException(status_code=403, detail="无权访问此文档库") + + documents = await WikiSpaceService.get_documents( + db, space_id, + user_id=user_id, dept_id=dept_id, + role_ids=role_ids, is_superuser=is_superuser, + ) + return [SmartTableSimple.model_validate(d) for d in documents] + + +@router.post("/wiki-spaces/{space_id}/documents", response_model=SmartTableResponse, summary="在文档库中创建文档") +async def create_wiki_document( + space_id: str, + data: SmartTableCreate, + db: AsyncSession = Depends(get_db), +): + space = await WikiSpaceService.get_by_id(db, space_id) + if not space: + raise HTTPException(status_code=404, detail="文档库不存在") + + user_info = get_current_user_info_from_context() + user_id = user_info.get("user_id", "") if user_info else None + is_superuser = user_info.get("is_superuser", False) if user_info else False + + has_access = await WikiSpaceService.check_space_access( + space, user_id=user_id, is_superuser=is_superuser, + ) + if not has_access: + raise HTTPException(status_code=403, detail="无权在此文档库中创建文档") + + doc = await WikiSpaceService.add_document( + db, space_id, + name=data.name, + parent_id=data.parent_id, + content=data.content, + user_id=user_id, + ) + if user_id: + await PermissionService.init_table_owner(db, doc.id, user_id) + return SmartTableResponse.model_validate(doc) diff --git a/backend-fastapi/zq_smart_table/formula.py b/backend-fastapi/zq_smart_table/formula.py new file mode 100644 index 0000000..588eb14 --- /dev/null +++ b/backend-fastapi/zq_smart_table/formula.py @@ -0,0 +1,612 @@ +""" +Safe formula engine for smart table. +Supports field references {FieldName}, arithmetic, comparisons, and built-in functions. +No eval/exec - all evaluation done via AST traversal. +""" + +import math +from datetime import date, datetime, timedelta +from enum import Enum +from typing import Any, Dict, List, Optional + + +# ==================== Tokenizer ==================== + +class TokenType(Enum): + NUMBER = "NUMBER" + STRING = "STRING" + FIELD_REF = "FIELD_REF" + FUNCTION = "FUNCTION" + OPERATOR = "OPERATOR" + LPAREN = "LPAREN" + RPAREN = "RPAREN" + COMMA = "COMMA" + BOOLEAN = "BOOLEAN" + EOF = "EOF" + + +class Token: + __slots__ = ("type", "value") + + def __init__(self, type_: TokenType, value: Any): + self.type = type_ + self.value = value + + def __repr__(self): + return f"Token({self.type.name}, {self.value!r})" + + +_FUNC_NAMES = { + "IF", "AND", "OR", "NOT", + "CONCATENATE", "CONCAT", + "ABS", "ROUND", "CEIL", "FLOOR", "INT", "MOD", "POWER", "SQRT", + "UPPER", "LOWER", "LEN", "LEFT", "RIGHT", "MID", "TRIM", "SUBSTITUTE", + "NOW", "TODAY", "DATEDIFF", "DATEADD", "YEAR", "MONTH", "DAY", + "MIN", "MAX", "SUM", "AVERAGE", + "ISNULL", "VALUE", "TEXT", "FIXED", +} + +_TWO_CHAR_OPS = {"!=", ">=", "<=", "<>", "&&", "||"} +_ONE_CHAR_OPS = {"+", "-", "*", "/", "%", "=", ">", "<", "&"} + + +def tokenize(formula: str) -> List[Token]: + tokens: List[Token] = [] + i = 0 + n = len(formula) + + while i < n: + ch = formula[i] + + if ch in (" ", "\t", "\n", "\r"): + i += 1 + continue + + if ch == "{": + end = formula.find("}", i + 1) + if end == -1: + raise FormulaError(f"未闭合的字段引用 '{{' 在位置 {i}") + tokens.append(Token(TokenType.FIELD_REF, formula[i + 1:end])) + i = end + 1 + continue + + if ch == '"' or ch == "'": + quote = ch + j = i + 1 + parts = [] + while j < n: + if formula[j] == "\\" and j + 1 < n: + parts.append(formula[j + 1]) + j += 2 + elif formula[j] == quote: + break + else: + parts.append(formula[j]) + j += 1 + if j >= n: + raise FormulaError(f"未闭合的字符串在位置 {i}") + tokens.append(Token(TokenType.STRING, "".join(parts))) + i = j + 1 + continue + + if ch.isdigit() or (ch == "." and i + 1 < n and formula[i + 1].isdigit()): + j = i + has_dot = False + while j < n and (formula[j].isdigit() or (formula[j] == "." and not has_dot)): + if formula[j] == ".": + has_dot = True + j += 1 + tokens.append(Token(TokenType.NUMBER, float(formula[i:j]))) + i = j + continue + + if ch == "(": + tokens.append(Token(TokenType.LPAREN, "(")) + i += 1 + continue + if ch == ")": + tokens.append(Token(TokenType.RPAREN, ")")) + i += 1 + continue + if ch == ",": + tokens.append(Token(TokenType.COMMA, ",")) + i += 1 + continue + + two = formula[i:i + 2] if i + 1 < n else "" + if two in _TWO_CHAR_OPS: + tokens.append(Token(TokenType.OPERATOR, two)) + i += 2 + continue + if ch in _ONE_CHAR_OPS: + tokens.append(Token(TokenType.OPERATOR, ch)) + i += 1 + continue + + if ch.isalpha() or ch == "_": + j = i + while j < n and (formula[j].isalnum() or formula[j] == "_"): + j += 1 + word = formula[i:j] + upper = word.upper() + if upper in ("TRUE", "FALSE"): + tokens.append(Token(TokenType.BOOLEAN, upper == "TRUE")) + elif upper in _FUNC_NAMES: + tokens.append(Token(TokenType.FUNCTION, upper)) + else: + tokens.append(Token(TokenType.FIELD_REF, word)) + i = j + continue + + raise FormulaError(f"无法识别的字符 '{ch}' 在位置 {i}") + + tokens.append(Token(TokenType.EOF, None)) + return tokens + + +# ==================== AST Nodes ==================== + +class ASTNode: + pass + + +class NumberLiteral(ASTNode): + __slots__ = ("value",) + def __init__(self, value: float): + self.value = value + + +class StringLiteral(ASTNode): + __slots__ = ("value",) + def __init__(self, value: str): + self.value = value + + +class BooleanLiteral(ASTNode): + __slots__ = ("value",) + def __init__(self, value: bool): + self.value = value + + +class FieldReference(ASTNode): + __slots__ = ("name",) + def __init__(self, name: str): + self.name = name + + +class BinaryOp(ASTNode): + __slots__ = ("op", "left", "right") + def __init__(self, op: str, left: ASTNode, right: ASTNode): + self.op = op + self.left = left + self.right = right + + +class UnaryOp(ASTNode): + __slots__ = ("op", "operand") + def __init__(self, op: str, operand: ASTNode): + self.op = op + self.operand = operand + + +class FunctionCall(ASTNode): + __slots__ = ("name", "args") + def __init__(self, name: str, args: List[ASTNode]): + self.name = name + self.args = args + + +# ==================== Parser ==================== + +class FormulaError(Exception): + pass + + +class Parser: + def __init__(self, tokens: List[Token]): + self.tokens = tokens + self.pos = 0 + + def _current(self) -> Token: + return self.tokens[self.pos] + + def _eat(self, expected_type: Optional[TokenType] = None) -> Token: + tok = self._current() + if expected_type and tok.type != expected_type: + raise FormulaError(f"期望 {expected_type.name},实际 {tok.type.name}({tok.value!r})") + self.pos += 1 + return tok + + def parse(self) -> ASTNode: + node = self._expr() + if self._current().type != TokenType.EOF: + raise FormulaError(f"意外的 token: {self._current()}") + return node + + def _expr(self) -> ASTNode: + return self._logic_or() + + def _logic_or(self) -> ASTNode: + node = self._logic_and() + while self._current().type == TokenType.OPERATOR and self._current().value in ("||",): + op = self._eat().value + right = self._logic_and() + node = BinaryOp(op, node, right) + return node + + def _logic_and(self) -> ASTNode: + node = self._comparison() + while self._current().type == TokenType.OPERATOR and self._current().value in ("&&",): + op = self._eat().value + right = self._comparison() + node = BinaryOp(op, node, right) + return node + + def _comparison(self) -> ASTNode: + node = self._concat() + while self._current().type == TokenType.OPERATOR and self._current().value in ("=", "!=", "<>", ">", "<", ">=", "<="): + op = self._eat().value + right = self._concat() + node = BinaryOp(op, node, right) + return node + + def _concat(self) -> ASTNode: + node = self._addition() + while self._current().type == TokenType.OPERATOR and self._current().value == "&": + self._eat() + right = self._addition() + node = BinaryOp("&", node, right) + return node + + def _addition(self) -> ASTNode: + node = self._multiplication() + while self._current().type == TokenType.OPERATOR and self._current().value in ("+", "-"): + op = self._eat().value + right = self._multiplication() + node = BinaryOp(op, node, right) + return node + + def _multiplication(self) -> ASTNode: + node = self._unary() + while self._current().type == TokenType.OPERATOR and self._current().value in ("*", "/", "%"): + op = self._eat().value + right = self._unary() + node = BinaryOp(op, node, right) + return node + + def _unary(self) -> ASTNode: + if self._current().type == TokenType.OPERATOR and self._current().value == "-": + self._eat() + operand = self._unary() + return UnaryOp("-", operand) + return self._primary() + + def _primary(self) -> ASTNode: + tok = self._current() + + if tok.type == TokenType.NUMBER: + self._eat() + return NumberLiteral(tok.value) + + if tok.type == TokenType.STRING: + self._eat() + return StringLiteral(tok.value) + + if tok.type == TokenType.BOOLEAN: + self._eat() + return BooleanLiteral(tok.value) + + if tok.type == TokenType.FIELD_REF: + self._eat() + return FieldReference(tok.value) + + if tok.type == TokenType.FUNCTION: + return self._function_call() + + if tok.type == TokenType.LPAREN: + self._eat() + node = self._expr() + self._eat(TokenType.RPAREN) + return node + + raise FormulaError(f"意外的 token: {tok}") + + def _function_call(self) -> ASTNode: + name = self._eat(TokenType.FUNCTION).value + self._eat(TokenType.LPAREN) + args: List[ASTNode] = [] + if self._current().type != TokenType.RPAREN: + args.append(self._expr()) + while self._current().type == TokenType.COMMA: + self._eat() + args.append(self._expr()) + self._eat(TokenType.RPAREN) + return FunctionCall(name, args) + + +# ==================== Evaluator ==================== + +def _to_number(v: Any) -> float: + if v is None or v == "": + return 0.0 + try: + return float(v) + except (ValueError, TypeError): + return 0.0 + + +def _to_string(v: Any) -> str: + if v is None: + return "" + if isinstance(v, bool): + return "TRUE" if v else "FALSE" + if isinstance(v, float) and v == int(v): + return str(int(v)) + return str(v) + + +def _to_bool(v: Any) -> bool: + if isinstance(v, bool): + return v + if isinstance(v, (int, float)): + return v != 0 + if isinstance(v, str): + return v.upper() not in ("", "FALSE", "0") + return bool(v) + + +def _parse_date(v: Any) -> Optional[datetime]: + if isinstance(v, datetime): + return v + if isinstance(v, date): + return datetime.combine(v, datetime.min.time()) + if isinstance(v, str): + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d", "%Y/%m/%d"): + try: + return datetime.strptime(v.strip()[:19], fmt) + except ValueError: + continue + return None + + +def _evaluate_function(name: str, args: List[Any]) -> Any: + n = len(args) + + if name == "IF": + if n < 2: + raise FormulaError("IF 需要至少 2 个参数") + cond = _to_bool(args[0]) + return args[1] if cond else (args[2] if n > 2 else "") + + if name == "AND": + return all(_to_bool(a) for a in args) + if name == "OR": + return any(_to_bool(a) for a in args) + if name == "NOT": + return not _to_bool(args[0]) if n > 0 else True + + if name in ("CONCATENATE", "CONCAT"): + return "".join(_to_string(a) for a in args) + + if name == "ABS": + return abs(_to_number(args[0])) if n > 0 else 0 + if name == "ROUND": + digits = int(_to_number(args[1])) if n > 1 else 0 + return round(_to_number(args[0]), digits) if n > 0 else 0 + if name == "CEIL": + return math.ceil(_to_number(args[0])) if n > 0 else 0 + if name == "FLOOR": + return math.floor(_to_number(args[0])) if n > 0 else 0 + if name == "INT": + return int(_to_number(args[0])) if n > 0 else 0 + if name == "MOD": + if n < 2: + return 0 + divisor = _to_number(args[1]) + return _to_number(args[0]) % divisor if divisor != 0 else 0 + if name == "POWER": + return _to_number(args[0]) ** _to_number(args[1]) if n >= 2 else 0 + if name == "SQRT": + val = _to_number(args[0]) if n > 0 else 0 + return math.sqrt(val) if val >= 0 else None + + if name == "UPPER": + return _to_string(args[0]).upper() if n > 0 else "" + if name == "LOWER": + return _to_string(args[0]).lower() if n > 0 else "" + if name == "LEN": + return len(_to_string(args[0])) if n > 0 else 0 + if name == "LEFT": + s = _to_string(args[0]) if n > 0 else "" + count = int(_to_number(args[1])) if n > 1 else 1 + return s[:count] + if name == "RIGHT": + s = _to_string(args[0]) if n > 0 else "" + count = int(_to_number(args[1])) if n > 1 else 1 + return s[-count:] if count > 0 else "" + if name == "MID": + s = _to_string(args[0]) if n > 0 else "" + start = max(1, int(_to_number(args[1]))) if n > 1 else 1 + length = int(_to_number(args[2])) if n > 2 else 1 + return s[start - 1:start - 1 + length] + if name == "TRIM": + return _to_string(args[0]).strip() if n > 0 else "" + if name == "SUBSTITUTE": + if n < 3: + return _to_string(args[0]) if n > 0 else "" + s = _to_string(args[0]) + old = _to_string(args[1]) + new = _to_string(args[2]) + return s.replace(old, new) + + if name == "NOW": + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + if name == "TODAY": + return date.today().isoformat() + if name == "YEAR": + d = _parse_date(args[0]) if n > 0 else None + return d.year if d else None + if name == "MONTH": + d = _parse_date(args[0]) if n > 0 else None + return d.month if d else None + if name == "DAY": + d = _parse_date(args[0]) if n > 0 else None + return d.day if d else None + if name == "DATEDIFF": + if n < 2: + return None + d1 = _parse_date(args[0]) + d2 = _parse_date(args[1]) + if d1 and d2: + unit = _to_string(args[2]).upper() if n > 2 else "DAYS" + diff = d2 - d1 + if unit in ("DAYS", "D"): + return diff.days + if unit in ("HOURS", "H"): + return diff.total_seconds() / 3600 + if unit in ("MONTHS", "M"): + return (d2.year - d1.year) * 12 + (d2.month - d1.month) + if unit in ("YEARS", "Y"): + return d2.year - d1.year + return diff.days + return None + if name == "DATEADD": + if n < 2: + return None + d = _parse_date(args[0]) + amount = int(_to_number(args[1])) + unit = _to_string(args[2]).upper() if n > 2 else "DAYS" + if d: + if unit in ("DAYS", "D"): + return (d + timedelta(days=amount)).strftime("%Y-%m-%d") + if unit in ("HOURS", "H"): + return (d + timedelta(hours=amount)).strftime("%Y-%m-%d %H:%M:%S") + if unit in ("MONTHS", "M"): + month = d.month + amount + year = d.year + (month - 1) // 12 + month = (month - 1) % 12 + 1 + day = min(d.day, 28) + return date(year, month, day).isoformat() + return None + + if name == "MIN": + nums = [_to_number(a) for a in args if a is not None and a != ""] + return min(nums) if nums else None + if name == "MAX": + nums = [_to_number(a) for a in args if a is not None and a != ""] + return max(nums) if nums else None + if name == "SUM": + return sum(_to_number(a) for a in args if a is not None and a != "") + if name == "AVERAGE": + nums = [_to_number(a) for a in args if a is not None and a != ""] + return sum(nums) / len(nums) if nums else None + + if name == "ISNULL": + return args[0] is None or args[0] == "" if n > 0 else True + if name == "VALUE": + return _to_number(args[0]) if n > 0 else 0 + if name == "TEXT": + return _to_string(args[0]) if n > 0 else "" + if name == "FIXED": + val = _to_number(args[0]) if n > 0 else 0 + digits = int(_to_number(args[1])) if n > 1 else 2 + return f"{val:.{digits}f}" + + raise FormulaError(f"未知函数: {name}") + + +def evaluate(node: ASTNode, context: Dict[str, Any], field_name_map: Dict[str, str]) -> Any: + if isinstance(node, NumberLiteral): + return node.value + + if isinstance(node, StringLiteral): + return node.value + + if isinstance(node, BooleanLiteral): + return node.value + + if isinstance(node, FieldReference): + field_id = field_name_map.get(node.name) + if field_id is None: + field_id = node.name + return context.get(field_id) + + if isinstance(node, UnaryOp): + val = evaluate(node.operand, context, field_name_map) + if node.op == "-": + return -_to_number(val) + return val + + if isinstance(node, BinaryOp): + left = evaluate(node.left, context, field_name_map) + right = evaluate(node.right, context, field_name_map) + op = node.op + + if op == "+": + return _to_number(left) + _to_number(right) + if op == "-": + return _to_number(left) - _to_number(right) + if op == "*": + return _to_number(left) * _to_number(right) + if op == "/": + r = _to_number(right) + return _to_number(left) / r if r != 0 else None + if op == "%": + r = _to_number(right) + return _to_number(left) % r if r != 0 else None + if op == "&": + return _to_string(left) + _to_string(right) + if op in ("=", "=="): + return left == right + if op in ("!=", "<>"): + return left != right + if op == ">": + return _to_number(left) > _to_number(right) + if op == "<": + return _to_number(left) < _to_number(right) + if op == ">=": + return _to_number(left) >= _to_number(right) + if op == "<=": + return _to_number(left) <= _to_number(right) + if op == "&&": + return _to_bool(left) and _to_bool(right) + if op == "||": + return _to_bool(left) or _to_bool(right) + + raise FormulaError(f"未知运算符: {op}") + + if isinstance(node, FunctionCall): + evaluated_args = [evaluate(a, context, field_name_map) for a in node.args] + return _evaluate_function(node.name, evaluated_args) + + raise FormulaError(f"未知 AST 节点: {type(node)}") + + +# ==================== Public API ==================== + +def compute_formula( + formula: str, + record_values: Dict[str, Any], + field_name_map: Dict[str, str], +) -> Any: + """ + 计算公式。 + formula: 公式字符串,如 "IF({状态}=\"完成\", {金额} * 1.1, {金额})" + record_values: {fieldId: value} + field_name_map: {fieldName: fieldId} + 返回计算结果;出错时返回 '#ERROR' + """ + if not formula or not formula.strip(): + return "" + try: + tokens = tokenize(formula) + parser = Parser(tokens) + ast = parser.parse() + result = evaluate(ast, record_values, field_name_map) + if isinstance(result, float): + if result == int(result) and abs(result) < 1e15: + return int(result) + return round(result, 10) + return result + except Exception: + return "#ERROR" diff --git a/backend-fastapi/zq_smart_table/model.py b/backend-fastapi/zq_smart_table/model.py new file mode 100644 index 0000000..c06a03c --- /dev/null +++ b/backend-fastapi/zq_smart_table/model.py @@ -0,0 +1,138 @@ +from sqlalchemy import Column, String, Text, Boolean, Integer, JSON, Index +from sqlalchemy.dialects.postgresql import JSONB + +from app.base_model import BaseModel + +SmartJSON = JSON().with_variant(JSONB(), "postgresql") + + +class SmartTable(BaseModel): + """多维表格 / 文档""" + __tablename__ = "smart_table" + + name = Column(String(200), nullable=False, comment="表名") + icon = Column(String(50), default="Grid", comment="图标") + description = Column(Text, nullable=True, comment="描述") + active_view_id = Column(String(21), nullable=True, comment="当前激活视图ID") + type = Column(String(20), default="table", nullable=False, comment="类型: table / document") + content = Column(SmartJSON, nullable=True, comment="文档内容(Tiptap JSON), 仅 type=document 时使用") + parent_id = Column(String(21), nullable=True, index=True, comment="父页面ID(自引用,用于子页面层级嵌套)") + wiki_space_id = Column(String(21), nullable=True, index=True, comment="所属文档库ID") + + +class SmartField(BaseModel): + """多维表格字段""" + __tablename__ = "smart_field" + + table_id = Column(String(21), nullable=False, index=True, comment="所属表ID") + name = Column(String(200), nullable=False, comment="字段名") + type = Column(String(30), nullable=False, comment="字段类型") + width = Column(Integer, default=150, comment="列宽") + visible = Column(Boolean, default=True, comment="是否可见") + required = Column(Boolean, default=False, comment="是否必填") + description = Column(Text, nullable=True, comment="描述") + config = Column(SmartJSON, default=dict, comment="扩展配置(options/format/precision等)") + + __table_args__ = ( + Index("ix_smart_field_table_sort", "table_id", "sort"), + ) + + +class SmartRecord(BaseModel): + """多维表格记录""" + __tablename__ = "smart_record" + + table_id = Column(String(21), nullable=False, index=True, comment="所属表ID") + values = Column(SmartJSON, default=dict, comment="字段值映射 {fieldId: cellValue}") + + __table_args__ = ( + Index("ix_smart_record_table_sort", "table_id", "sort"), + ) + + +class SmartTableLink(BaseModel): + """记录关联关系(多对多)""" + __tablename__ = "smart_table_link" + + field_id = Column(String(21), nullable=False, index=True, comment="Link字段ID") + source_record_id = Column(String(21), nullable=False, index=True, comment="源记录ID") + target_record_id = Column(String(21), nullable=False, index=True, comment="目标记录ID") + + __table_args__ = ( + Index("ix_link_field_source", "field_id", "source_record_id"), + Index("ix_link_field_target", "field_id", "target_record_id"), + Index("uq_link_pair", "field_id", "source_record_id", "target_record_id", unique=True), + ) + + +class SmartView(BaseModel): + """多维表格视图""" + __tablename__ = "smart_view" + + table_id = Column(String(21), nullable=False, index=True, comment="所属表ID") + name = Column(String(200), nullable=False, comment="视图名") + type = Column(String(30), default="grid", comment="视图类型") + config = Column(SmartJSON, default=dict, comment="视图配置(filters/sorts/groups/visibleFieldIds等)") + + __table_args__ = ( + Index("ix_smart_view_table_sort", "table_id", "sort"), + ) + + +class SmartTableComment(BaseModel): + """记录评论""" + __tablename__ = "smart_table_comment" + + record_id = Column(String(21), nullable=False, index=True, comment="所属记录ID") + user_id = Column(String(21), nullable=False, index=True, comment="评论者用户ID") + content = Column(Text, nullable=False, comment="评论内容(纯文本,含@提及标记)") + mentions = Column(SmartJSON, default=list, comment="被@提及的用户ID列表") + parent_id = Column(String(21), nullable=True, index=True, comment="父评论ID(用于回复)") + + __table_args__ = ( + Index("ix_comment_record_created", "record_id", "sys_create_datetime"), + ) + + +class SmartDocumentVersion(BaseModel): + """文档版本历史""" + __tablename__ = "smart_document_version" + + document_id = Column(String(21), nullable=False, index=True, comment="文档ID(逻辑外键关联smart_table)") + version = Column(Integer, nullable=False, comment="版本号") + content = Column(SmartJSON, nullable=False, comment="文档内容快照(Tiptap JSON)") + title = Column(String(200), nullable=True, comment="版本标题/文档名称快照") + change_summary = Column(String(500), nullable=True, comment="变更摘要") + content_size = Column(Integer, default=0, comment="内容大小(字节)") + + __table_args__ = ( + Index("ix_doc_version_doc_ver", "document_id", "version"), + Index("ix_doc_version_doc_created", "document_id", "sys_create_datetime"), + ) + + +class SmartDocumentTemplate(BaseModel): + """文档模板""" + __tablename__ = "smart_document_template" + + name = Column(String(200), nullable=False, comment="模板名称") + description = Column(Text, nullable=True, comment="模板描述") + icon = Column(String(50), default="FileText", comment="模板图标") + category = Column(String(50), default="custom", comment="分类: system / custom") + content = Column(SmartJSON, nullable=False, comment="模板内容(Tiptap JSON)") + preview_image = Column(String(500), nullable=True, comment="预览图URL") + is_system = Column(Boolean, default=False, comment="是否系统预设模板") + use_count = Column(Integer, default=0, comment="使用次数") + + +class SmartWikiSpace(BaseModel): + """文档库/知识空间""" + __tablename__ = "smart_wiki_space" + + name = Column(String(200), nullable=False, comment="文档库名称") + icon = Column(String(50), default="BookOpen", comment="图标") + avatar = Column(String(500), nullable=True, comment="头像文件ID") + description = Column(Text, nullable=True, comment="描述") + cover = Column(String(500), nullable=True, comment="封面图URL") + category = Column(String(50), default="default", comment="分类标签") + visibility = Column(String(20), default="private", comment="可见性: private/team/public") diff --git a/backend-fastapi/zq_smart_table/permission/__init__.py b/backend-fastapi/zq_smart_table/permission/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend-fastapi/zq_smart_table/permission/api.py b/backend-fastapi/zq_smart_table/permission/api.py new file mode 100644 index 0000000..f141917 --- /dev/null +++ b/backend-fastapi/zq_smart_table/permission/api.py @@ -0,0 +1,299 @@ +from typing import List + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.base_schema import ResponseModel +from zq_smart_table.permission.schema import ( + SmartTableRoleCreate, SmartTableRoleUpdate, SmartTableRoleResponse, + CollaboratorCreate, CollaboratorUpdate, CollaboratorResponse, + FieldPermBatchUpdate, FieldPermMatrix, FieldPermItem, + RowRuleUpdate, RowRuleResponse, + MyPermissionResponse, +) +from zq_smart_table.permission.service import PermissionService +from zq_smart_table.permission.model import SmartTableRole + +router = APIRouter(tags=["多维表格-权限"]) + + +# ==================== My Permission ==================== + +@router.get( + "/tables/{table_id}/my-permission", + response_model=MyPermissionResponse, + summary="获取当前用户对该表的有效权限", +) +async def get_my_permission(table_id: str, db: AsyncSession = Depends(get_db)): + perm = await PermissionService.get_my_permission(db, table_id) + return MyPermissionResponse(**perm) + + +# ==================== Role ==================== + +@router.get( + "/tables/{table_id}/roles", + response_model=List[SmartTableRoleResponse], + summary="获取表角色列表", +) +async def get_roles(table_id: str, db: AsyncSession = Depends(get_db)): + await PermissionService.ensure_system_roles(db, table_id) + roles = await PermissionService.get_roles(db, table_id) + return roles + + +@router.post( + "/tables/{table_id}/roles", + response_model=SmartTableRoleResponse, + summary="创建自定义角色", +) +async def create_role( + table_id: str, + data: SmartTableRoleCreate, + db: AsyncSession = Depends(get_db), +): + await PermissionService.require_capability(db, table_id, "manage_permission") + role = await PermissionService.create_custom_role( + db, table_id, data.name, data.capabilities + ) + return role + + +@router.put( + "/tables/{table_id}/roles/{role_id}", + response_model=SmartTableRoleResponse, + summary="更新角色", +) +async def update_role( + table_id: str, role_id: str, + data: SmartTableRoleUpdate, + db: AsyncSession = Depends(get_db), +): + await PermissionService.require_capability(db, table_id, "manage_permission") + role = await PermissionService.update_role( + db, role_id, name=data.name, capabilities=data.capabilities + ) + if not role: + raise HTTPException(status_code=404, detail="角色不存在") + return role + + +@router.delete( + "/tables/{table_id}/roles/{role_id}", + response_model=ResponseModel, + summary="删除自定义角色", +) +async def delete_role( + table_id: str, role_id: str, + db: AsyncSession = Depends(get_db), +): + await PermissionService.require_capability(db, table_id, "manage_permission") + success = await PermissionService.delete_role(db, role_id) + if not success: + raise HTTPException(status_code=404, detail="角色不存在或为系统预置角色") + return ResponseModel(message="删除成功") + + +# ==================== Collaborator ==================== + +@router.get( + "/tables/{table_id}/collaborators", + response_model=List[CollaboratorResponse], + summary="获取协作者列表", +) +async def get_collaborators(table_id: str, db: AsyncSession = Depends(get_db)): + await PermissionService.require_table_access(db, table_id) + collabs = await PermissionService.get_collaborators(db, table_id) + + role_cache: dict = {} + results = [] + for c in collabs: + if c.role_id not in role_cache: + from sqlalchemy import select + r = await db.execute( + select(SmartTableRole).where(SmartTableRole.id == c.role_id) + ) + role_cache[c.role_id] = r.scalar_one_or_none() + + role = role_cache.get(c.role_id) + subject_name = None + subject_avatar = None + + if c.subject_type == "user": + from core.user.model import User + from sqlalchemy import select as sel + u = await db.execute(sel(User).where(User.id == c.subject_id)) + user = u.scalar_one_or_none() + if user: + subject_name = user.name or user.username + subject_avatar = user.avatar + elif c.subject_type == "dept": + from core.dept.model import Dept + from sqlalchemy import select as sel2 + d = await db.execute(sel2(Dept).where(Dept.id == c.subject_id)) + dept = d.scalar_one_or_none() + if dept: + subject_name = dept.name + + results.append(CollaboratorResponse( + id=c.id, + table_id=c.table_id, + subject_type=c.subject_type, + subject_id=c.subject_id, + role_id=c.role_id, + role_name=role.name if role else None, + role_type=role.role_type if role else None, + subject_name=subject_name, + subject_avatar=subject_avatar, + sys_create_datetime=c.sys_create_datetime, + )) + + return results + + +@router.post( + "/tables/{table_id}/collaborators", + response_model=CollaboratorResponse, + summary="添加协作者", +) +async def add_collaborator( + table_id: str, + data: CollaboratorCreate, + db: AsyncSession = Depends(get_db), +): + await PermissionService.require_capability(db, table_id, "manage_permission") + collab = await PermissionService.add_collaborator( + db, table_id, data.subject_type, data.subject_id, data.role_id, + ) + return CollaboratorResponse( + id=collab.id, + table_id=collab.table_id, + subject_type=collab.subject_type, + subject_id=collab.subject_id, + role_id=collab.role_id, + sys_create_datetime=collab.sys_create_datetime, + ) + + +@router.put( + "/tables/{table_id}/collaborators/{collab_id}", + response_model=CollaboratorResponse, + summary="更新协作者角色", +) +async def update_collaborator( + table_id: str, collab_id: str, + data: CollaboratorUpdate, + db: AsyncSession = Depends(get_db), +): + await PermissionService.require_capability(db, table_id, "manage_permission") + collab = await PermissionService.update_collaborator_role(db, collab_id, data.role_id) + if not collab: + raise HTTPException(status_code=404, detail="协作者不存在") + return CollaboratorResponse( + id=collab.id, + table_id=collab.table_id, + subject_type=collab.subject_type, + subject_id=collab.subject_id, + role_id=collab.role_id, + sys_create_datetime=collab.sys_create_datetime, + ) + + +@router.delete( + "/tables/{table_id}/collaborators/{collab_id}", + response_model=ResponseModel, + summary="移除协作者", +) +async def remove_collaborator( + table_id: str, collab_id: str, + db: AsyncSession = Depends(get_db), +): + await PermissionService.require_capability(db, table_id, "manage_permission") + success = await PermissionService.remove_collaborator(db, collab_id) + if not success: + raise HTTPException(status_code=404, detail="协作者不存在") + return ResponseModel(message="移除成功") + + +# ==================== Field Permission ==================== + +@router.get( + "/tables/{table_id}/field-permissions", + response_model=List[FieldPermMatrix], + summary="获取列权限矩阵", +) +async def get_field_permissions(table_id: str, db: AsyncSession = Depends(get_db)): + await PermissionService.require_capability(db, table_id, "manage_permission") + await PermissionService.require_multidimensional_table(db, table_id) + roles = await PermissionService.get_roles(db, table_id) + + result = [] + for role in roles: + perms = await PermissionService.get_field_permissions(db, table_id, role.id) + fields = [FieldPermItem(field_id=fid, access=acc) for fid, acc in perms.items()] + result.append(FieldPermMatrix( + role_id=role.id, + role_name=role.name, + role_type=role.role_type, + fields=fields, + )) + + return result + + +@router.put( + "/tables/{table_id}/field-permissions", + response_model=ResponseModel, + summary="批量更新列权限", +) +async def update_field_permissions( + table_id: str, + data: FieldPermBatchUpdate, + db: AsyncSession = Depends(get_db), +): + await PermissionService.require_capability(db, table_id, "manage_permission") + await PermissionService.require_multidimensional_table(db, table_id) + permissions = [{"field_id": p.field_id, "access": p.access} for p in data.permissions] + await PermissionService.batch_set_field_permissions(db, table_id, data.role_id, permissions) + return ResponseModel(message="更新成功") + + +# ==================== Row Rule ==================== + +@router.get( + "/tables/{table_id}/row-rules", + response_model=List[RowRuleResponse], + summary="获取行权限规则", +) +async def get_row_rules(table_id: str, db: AsyncSession = Depends(get_db)): + await PermissionService.require_capability(db, table_id, "manage_permission") + await PermissionService.require_multidimensional_table(db, table_id) + + from sqlalchemy import select + from zq_smart_table.permission.model import SmartTableRowRule + result = await db.execute( + select(SmartTableRowRule).where( + SmartTableRowRule.table_id == table_id, + SmartTableRowRule.is_deleted == False, # noqa: E712 + ) + ) + return list(result.scalars().all()) + + +@router.put( + "/tables/{table_id}/row-rules", + response_model=RowRuleResponse, + summary="更新行权限规则", +) +async def update_row_rule( + table_id: str, + data: RowRuleUpdate, + db: AsyncSession = Depends(get_db), +): + await PermissionService.require_capability(db, table_id, "manage_permission") + await PermissionService.require_multidimensional_table(db, table_id) + rule = await PermissionService.upsert_row_rule( + db, table_id, data.role_id, data.rule_type, data.mode, data.conditions, + ) + return rule diff --git a/backend-fastapi/zq_smart_table/permission/model.py b/backend-fastapi/zq_smart_table/permission/model.py new file mode 100644 index 0000000..8d486c4 --- /dev/null +++ b/backend-fastapi/zq_smart_table/permission/model.py @@ -0,0 +1,115 @@ +from sqlalchemy import Column, String, Boolean, Index +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy import JSON + +from app.base_model import BaseModel + +PermJSON = JSON().with_variant(JSONB(), "postgresql") + + +SYSTEM_CAPABILITIES = { + "owner": { + "manage_table": True, + "manage_permission": True, + "manage_field": True, + "manage_view": True, + "add_record": True, + "edit_record": True, + "delete_record": True, + "export_data": True, + "import_data": True, + }, + "manager": { + "manage_table": False, + "manage_permission": True, + "manage_field": True, + "manage_view": True, + "add_record": True, + "edit_record": True, + "delete_record": True, + "export_data": True, + "import_data": True, + }, + "editor": { + "manage_table": False, + "manage_permission": False, + "manage_field": False, + "manage_view": False, + "add_record": True, + "edit_record": True, + "delete_record": False, + "export_data": True, + "import_data": False, + }, + "viewer": { + "manage_table": False, + "manage_permission": False, + "manage_field": False, + "manage_view": False, + "add_record": False, + "edit_record": False, + "delete_record": False, + "export_data": False, + "import_data": False, + }, +} + +ROLE_PRIORITY = {"owner": 100, "manager": 80, "editor": 60, "viewer": 40, "custom": 50} + + +class SmartTableRole(BaseModel): + """多维表格 - 表角色定义""" + __tablename__ = "smart_table_role" + + table_id = Column(String(21), nullable=True, index=True, comment="所属表ID(null=系统预置)") + name = Column(String(64), nullable=False, comment="角色名称") + role_type = Column(String(20), nullable=False, default="custom", comment="owner/manager/editor/viewer/custom") + capabilities = Column(PermJSON, default=dict, comment="能力配置JSON") + is_system = Column(Boolean, default=False, comment="是否系统预置(不可删除)") + + __table_args__ = ( + Index("ix_smart_table_role_table", "table_id", "role_type"), + ) + + +class SmartTableCollaborator(BaseModel): + """多维表格 - 表协作者""" + __tablename__ = "smart_table_collaborator" + + table_id = Column(String(21), nullable=False, index=True, comment="所属表ID") + subject_type = Column(String(20), nullable=False, comment="授权对象类型: user/dept/role") + subject_id = Column(String(21), nullable=False, index=True, comment="授权对象ID(用户/部门/角色)") + role_id = Column(String(21), nullable=False, index=True, comment="表角色ID") + + __table_args__ = ( + Index("ix_smart_collab_table_subject", "table_id", "subject_type", "subject_id", unique=True), + ) + + +class SmartTableFieldPerm(BaseModel): + """多维表格 - 列权限""" + __tablename__ = "smart_table_field_perm" + + table_id = Column(String(21), nullable=False, index=True, comment="所属表ID") + role_id = Column(String(21), nullable=False, index=True, comment="表角色ID") + field_id = Column(String(21), nullable=False, index=True, comment="字段ID") + access = Column(String(10), default="write", comment="访问级别: write/read/hidden") + + __table_args__ = ( + Index("ix_smart_fperm_role_field", "table_id", "role_id", "field_id", unique=True), + ) + + +class SmartTableRowRule(BaseModel): + """多维表格 - 行权限规则""" + __tablename__ = "smart_table_row_rule" + + table_id = Column(String(21), nullable=False, index=True, comment="所属表ID") + role_id = Column(String(21), nullable=False, index=True, comment="表角色ID") + rule_type = Column(String(10), nullable=False, comment="规则类型: view/edit") + mode = Column(String(20), default="all", comment="模式: all/conditions/creator_only") + conditions = Column(PermJSON, default=list, comment="过滤条件(复用视图filter结构)") + + __table_args__ = ( + Index("ix_smart_row_rule_role_type", "table_id", "role_id", "rule_type", unique=True), + ) diff --git a/backend-fastapi/zq_smart_table/permission/schema.py b/backend-fastapi/zq_smart_table/permission/schema.py new file mode 100644 index 0000000..384a3d0 --- /dev/null +++ b/backend-fastapi/zq_smart_table/permission/schema.py @@ -0,0 +1,122 @@ +from typing import Optional, List, Any, Dict + +from pydantic import BaseModel, ConfigDict, Field + +from app.base_schema import CSTDatetime + + +# ==================== Role ==================== + +class SmartTableRoleCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=64, description="角色名称") + role_type: str = Field(default="custom", description="角色类型") + capabilities: Dict[str, bool] = Field(default_factory=dict, description="能力配置") + + +class SmartTableRoleUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=64, description="角色名称") + capabilities: Optional[Dict[str, bool]] = Field(None, description="能力配置") + + +class SmartTableRoleResponse(BaseModel): + id: str + table_id: Optional[str] = None + name: str + role_type: str + capabilities: Dict[str, bool] = {} + is_system: bool = False + sys_create_datetime: Optional[CSTDatetime] = None + + model_config = ConfigDict(from_attributes=True) + + +# ==================== Collaborator ==================== + +class CollaboratorCreate(BaseModel): + subject_type: str = Field(..., description="授权对象类型: user/dept/role") + subject_id: str = Field(..., description="授权对象ID") + role_id: str = Field(..., description="表角色ID") + + +class CollaboratorUpdate(BaseModel): + role_id: str = Field(..., description="表角色ID") + + +class CollaboratorResponse(BaseModel): + id: str + table_id: str + subject_type: str + subject_id: str + role_id: str + role_name: Optional[str] = None + role_type: Optional[str] = None + subject_name: Optional[str] = None + subject_avatar: Optional[str] = None + sys_create_datetime: Optional[CSTDatetime] = None + + model_config = ConfigDict(from_attributes=True) + + +# ==================== Field Permission ==================== + +class FieldPermItem(BaseModel): + field_id: str = Field(..., description="字段ID") + access: str = Field(default="write", description="访问级别: write/read/hidden") + + +class FieldPermBatchUpdate(BaseModel): + role_id: str = Field(..., description="表角色ID") + permissions: List[FieldPermItem] = Field(..., description="列权限列表") + + +class FieldPermResponse(BaseModel): + id: str + table_id: str + role_id: str + field_id: str + access: str = "write" + + model_config = ConfigDict(from_attributes=True) + + +class FieldPermMatrix(BaseModel): + """列权限矩阵:按角色分组""" + role_id: str + role_name: str + role_type: str + fields: List[FieldPermItem] = [] + + +# ==================== Row Rule ==================== + +class RowRuleUpdate(BaseModel): + role_id: str = Field(..., description="表角色ID") + rule_type: str = Field(..., description="规则类型: view/edit") + mode: str = Field(default="all", description="模式: all/conditions/creator_only") + conditions: List[Dict[str, Any]] = Field(default_factory=list, description="过滤条件") + + +class RowRuleResponse(BaseModel): + id: str + table_id: str + role_id: str + rule_type: str + mode: str = "all" + conditions: List[Dict[str, Any]] = [] + + model_config = ConfigDict(from_attributes=True) + + +# ==================== My Permission ==================== + +class MyPermissionResponse(BaseModel): + """当前用户对某表的有效权限""" + role_type: str = Field(description="角色类型: owner/manager/editor/viewer/custom/superadmin") + role_name: str = Field(description="角色名称") + capabilities: Dict[str, bool] = Field(default_factory=dict, description="能力配置") + field_permissions: Dict[str, str] = Field( + default_factory=dict, + description="列权限映射: fieldId -> write/read/hidden" + ) + row_view_mode: str = Field(default="all", description="行查看模式") + row_edit_mode: str = Field(default="all", description="行编辑模式") diff --git a/backend-fastapi/zq_smart_table/permission/service.py b/backend-fastapi/zq_smart_table/permission/service.py new file mode 100644 index 0000000..0b5ff37 --- /dev/null +++ b/backend-fastapi/zq_smart_table/permission/service.py @@ -0,0 +1,709 @@ +from typing import Optional, List, Dict, Any, Tuple + +from sqlalchemy import select, delete as sa_delete +from sqlalchemy.ext.asyncio import AsyncSession + +from zq_smart_table.permission.model import ( + SmartTableRole, SmartTableCollaborator, SmartTableFieldPerm, SmartTableRowRule, + SYSTEM_CAPABILITIES, ROLE_PRIORITY, +) +from zq_smart_table.model import SmartTable +from utils.context import get_current_user_info_from_context + + +SUPERADMIN_CAPABILITIES = {k: True for k in SYSTEM_CAPABILITIES["owner"]} + + +class PermissionService: + """多维表格权限判定服务""" + + # ─── 系统预置角色 ─── + + @classmethod + async def ensure_system_roles(cls, db: AsyncSession, table_id: str) -> Dict[str, str]: + """确保表拥有 4 个系统预置角色,返回 {role_type: role_id}""" + result = await db.execute( + select(SmartTableRole).where( + SmartTableRole.table_id == table_id, + SmartTableRole.is_system == True, # noqa: E712 + SmartTableRole.is_deleted == False, # noqa: E712 + ) + ) + existing = {r.role_type: r for r in result.scalars().all()} + + role_map: Dict[str, str] = {} + names = {"owner": "所有者", "manager": "管理者", "editor": "编辑者", "viewer": "只读者"} + + for rt, caps in SYSTEM_CAPABILITIES.items(): + if rt in existing: + role_map[rt] = existing[rt].id + else: + role = SmartTableRole( + table_id=table_id, + name=names[rt], + role_type=rt, + capabilities=caps, + is_system=True, + ) + db.add(role) + await db.flush() + role_map[rt] = role.id + + await db.commit() + return role_map + + # ─── 初始化所有者 ─── + + @classmethod + async def init_table_owner(cls, db: AsyncSession, table_id: str, user_id: str) -> None: + """创建表时自动将创建者设为所有者""" + role_map = await cls.ensure_system_roles(db, table_id) + owner_role_id = role_map["owner"] + + existing = await db.execute( + select(SmartTableCollaborator).where( + SmartTableCollaborator.table_id == table_id, + SmartTableCollaborator.subject_type == "user", + SmartTableCollaborator.subject_id == user_id, + SmartTableCollaborator.is_deleted == False, # noqa: E712 + ) + ) + if existing.scalar_one_or_none(): + return + + collab = SmartTableCollaborator( + table_id=table_id, + subject_type="user", + subject_id=user_id, + role_id=owner_role_id, + ) + db.add(collab) + await db.commit() + + # ─── 获取有效角色 ─── + + @classmethod + async def get_effective_role( + cls, db: AsyncSession, table_id: str, + user_id: str, dept_id: Optional[str] = None, + role_ids: Optional[List[str]] = None, + is_superuser: bool = False, + ) -> Optional[Dict[str, Any]]: + """ + 获取用户对某表的有效角色。 + 优先级: superadmin > user直接 > dept > system_role + 多个匹配时取权限最高的角色。 + """ + if is_superuser: + return { + "role_type": "superadmin", + "role_name": "超级管理员", + "capabilities": SUPERADMIN_CAPABILITIES, + "role_id": None, + } + + # 查同表创建者(兜底:如果 collaborator 记录缺失但用户是表的创建者) + table = await db.execute( + select(SmartTable).where(SmartTable.id == table_id, SmartTable.is_deleted == False) # noqa: E712 + ) + table_obj = table.scalar_one_or_none() + if table_obj and table_obj.sys_creator_id == user_id: + role_map = await cls.ensure_system_roles(db, table_id) + owner_role_id = role_map["owner"] + return { + "role_type": "owner", + "role_name": "所有者", + "capabilities": dict(SYSTEM_CAPABILITIES["owner"]), + "role_id": owner_role_id, + } + + # 查询所有匹配的协作者记录 + conditions = [ + SmartTableCollaborator.table_id == table_id, + SmartTableCollaborator.is_deleted == False, # noqa: E712 + ] + + subject_conditions = [ + (SmartTableCollaborator.subject_type == "user") & (SmartTableCollaborator.subject_id == user_id) + ] + if dept_id: + subject_conditions.append( + (SmartTableCollaborator.subject_type == "dept") & (SmartTableCollaborator.subject_id == dept_id) + ) + if role_ids: + for rid in role_ids: + subject_conditions.append( + (SmartTableCollaborator.subject_type == "role") & (SmartTableCollaborator.subject_id == rid) + ) + + from sqlalchemy import or_ + conditions.append(or_(*subject_conditions)) + + result = await db.execute( + select(SmartTableCollaborator).where(*conditions) + ) + collabs = list(result.scalars().all()) + + if not collabs: + return None + + # 加载对应角色,取优先级最高的 + best_collab = None + best_role = None + best_priority = -1 + + for c in collabs: + role_result = await db.execute( + select(SmartTableRole).where( + SmartTableRole.id == c.role_id, + SmartTableRole.is_deleted == False, # noqa: E712 + ) + ) + role = role_result.scalar_one_or_none() + if not role: + continue + + # user 直接授权优先级额外 +10 + priority = ROLE_PRIORITY.get(role.role_type, 50) + if c.subject_type == "user": + priority += 10 + + if priority > best_priority: + best_priority = priority + best_collab = c + best_role = role + + if not best_role: + return None + + return { + "role_type": best_role.role_type, + "role_name": best_role.name, + "capabilities": dict(best_role.capabilities) if best_role.capabilities else {}, + "role_id": best_role.id, + } + + # ─── 能力检查 ─── + + @classmethod + async def check_capability( + cls, db: AsyncSession, table_id: str, capability: str, + ) -> bool: + """检查当前用户是否具有某项能力""" + user_info = get_current_user_info_from_context() + if not user_info: + return False + + role_info = await cls.get_effective_role( + db, table_id, + user_id=user_info.get("user_id", ""), + dept_id=user_info.get("dept_id"), + role_ids=user_info.get("role_ids", []), + is_superuser=user_info.get("is_superuser", False), + ) + if not role_info: + return False + + return role_info["capabilities"].get(capability, False) + + @classmethod + async def require_capability( + cls, db: AsyncSession, table_id: str, capability: str, + ) -> Dict[str, Any]: + """检查能力,无权限则抛 403""" + from fastapi import HTTPException + + user_info = get_current_user_info_from_context() + if not user_info: + raise HTTPException(status_code=401, detail="未登录") + + role_info = await cls.get_effective_role( + db, table_id, + user_id=user_info.get("user_id", ""), + dept_id=user_info.get("dept_id"), + role_ids=user_info.get("role_ids", []), + is_superuser=user_info.get("is_superuser", False), + ) + if not role_info: + raise HTTPException(status_code=403, detail="无权访问此表") + + if not role_info["capabilities"].get(capability, False): + raise HTTPException(status_code=403, detail=f"无权执行此操作({capability})") + + return role_info + + @classmethod + async def require_table_access( + cls, db: AsyncSession, table_id: str, + ) -> Dict[str, Any]: + """检查表访问权限(至少 viewer),无权限则抛 403""" + from fastapi import HTTPException + + user_info = get_current_user_info_from_context() + if not user_info: + raise HTTPException(status_code=401, detail="未登录") + + role_info = await cls.get_effective_role( + db, table_id, + user_id=user_info.get("user_id", ""), + dept_id=user_info.get("dept_id"), + role_ids=user_info.get("role_ids", []), + is_superuser=user_info.get("is_superuser", False), + ) + if not role_info: + raise HTTPException(status_code=403, detail="无权访问此表") + + return role_info + + # ─── 列权限 ─── + + @classmethod + async def get_field_permissions( + cls, db: AsyncSession, table_id: str, role_id: str, + ) -> Dict[str, str]: + """获取某角色的列权限映射: {field_id: access}""" + result = await db.execute( + select(SmartTableFieldPerm).where( + SmartTableFieldPerm.table_id == table_id, + SmartTableFieldPerm.role_id == role_id, + SmartTableFieldPerm.is_deleted == False, # noqa: E712 + ) + ) + return {fp.field_id: fp.access for fp in result.scalars().all()} + + @classmethod + async def get_accessible_field_ids( + cls, db: AsyncSession, table_id: str, role_id: Optional[str], + mode: str = "read", + ) -> Optional[List[str]]: + """ + 获取角色可访问的字段ID列表。 + mode="read" 返回 access != "hidden" 的字段 + mode="write" 返回 access == "write" 的字段 + 如果没有配置列权限,返回 None 表示不限制。 + """ + if not role_id: + return None + + perms = await cls.get_field_permissions(db, table_id, role_id) + if not perms: + return None + + if mode == "write": + return [fid for fid, acc in perms.items() if acc == "write"] + else: + return [fid for fid, acc in perms.items() if acc != "hidden"] + + @classmethod + async def batch_set_field_permissions( + cls, db: AsyncSession, table_id: str, role_id: str, + permissions: List[Dict[str, str]], + ) -> None: + """批量设置列权限""" + await db.execute( + sa_delete(SmartTableFieldPerm).where( + SmartTableFieldPerm.table_id == table_id, + SmartTableFieldPerm.role_id == role_id, + ) + ) + + for p in permissions: + fp = SmartTableFieldPerm( + table_id=table_id, + role_id=role_id, + field_id=p["field_id"], + access=p.get("access", "write"), + ) + db.add(fp) + + await db.commit() + + # ─── 行权限 ─── + + @classmethod + async def get_row_rules( + cls, db: AsyncSession, table_id: str, role_id: str, + ) -> List[SmartTableRowRule]: + result = await db.execute( + select(SmartTableRowRule).where( + SmartTableRowRule.table_id == table_id, + SmartTableRowRule.role_id == role_id, + SmartTableRowRule.is_deleted == False, # noqa: E712 + ) + ) + return list(result.scalars().all()) + + @classmethod + async def upsert_row_rule( + cls, db: AsyncSession, table_id: str, role_id: str, + rule_type: str, mode: str, conditions: List[Dict[str, Any]], + ) -> SmartTableRowRule: + """创建或更新行权限规则""" + result = await db.execute( + select(SmartTableRowRule).where( + SmartTableRowRule.table_id == table_id, + SmartTableRowRule.role_id == role_id, + SmartTableRowRule.rule_type == rule_type, + SmartTableRowRule.is_deleted == False, # noqa: E712 + ) + ) + rule = result.scalar_one_or_none() + + if rule: + rule.mode = mode + rule.conditions = conditions + else: + rule = SmartTableRowRule( + table_id=table_id, + role_id=role_id, + rule_type=rule_type, + mode=mode, + conditions=conditions, + ) + db.add(rule) + + await db.commit() + await db.refresh(rule) + return rule + + # ─── 协作者管理 ─── + + @classmethod + async def get_collaborators( + cls, db: AsyncSession, table_id: str, + ) -> List[SmartTableCollaborator]: + result = await db.execute( + select(SmartTableCollaborator).where( + SmartTableCollaborator.table_id == table_id, + SmartTableCollaborator.is_deleted == False, # noqa: E712 + ).order_by(SmartTableCollaborator.sys_create_datetime) + ) + return list(result.scalars().all()) + + @classmethod + async def add_collaborator( + cls, db: AsyncSession, table_id: str, + subject_type: str, subject_id: str, role_id: str, + ) -> SmartTableCollaborator: + existing = await db.execute( + select(SmartTableCollaborator).where( + SmartTableCollaborator.table_id == table_id, + SmartTableCollaborator.subject_type == subject_type, + SmartTableCollaborator.subject_id == subject_id, + SmartTableCollaborator.is_deleted == False, # noqa: E712 + ) + ) + collab = existing.scalar_one_or_none() + if collab: + collab.role_id = role_id + else: + collab = SmartTableCollaborator( + table_id=table_id, + subject_type=subject_type, + subject_id=subject_id, + role_id=role_id, + ) + db.add(collab) + + await db.commit() + await db.refresh(collab) + return collab + + @classmethod + async def update_collaborator_role( + cls, db: AsyncSession, collab_id: str, role_id: str, + ) -> Optional[SmartTableCollaborator]: + result = await db.execute( + select(SmartTableCollaborator).where( + SmartTableCollaborator.id == collab_id, + SmartTableCollaborator.is_deleted == False, # noqa: E712 + ) + ) + collab = result.scalar_one_or_none() + if not collab: + return None + collab.role_id = role_id + await db.commit() + await db.refresh(collab) + return collab + + @classmethod + async def remove_collaborator( + cls, db: AsyncSession, collab_id: str, + ) -> bool: + result = await db.execute( + select(SmartTableCollaborator).where( + SmartTableCollaborator.id == collab_id, + SmartTableCollaborator.is_deleted == False, # noqa: E712 + ) + ) + collab = result.scalar_one_or_none() + if not collab: + return False + collab.is_deleted = True + await db.commit() + return True + + # ─── 角色管理 ─── + + @classmethod + async def get_roles( + cls, db: AsyncSession, table_id: str, + ) -> List[SmartTableRole]: + result = await db.execute( + select(SmartTableRole).where( + SmartTableRole.table_id == table_id, + SmartTableRole.is_deleted == False, # noqa: E712 + ).order_by(SmartTableRole.sys_create_datetime) + ) + return list(result.scalars().all()) + + @classmethod + async def create_custom_role( + cls, db: AsyncSession, table_id: str, + name: str, capabilities: Dict[str, bool], + ) -> SmartTableRole: + role = SmartTableRole( + table_id=table_id, + name=name, + role_type="custom", + capabilities=capabilities, + is_system=False, + ) + db.add(role) + await db.commit() + await db.refresh(role) + return role + + @classmethod + async def update_role( + cls, db: AsyncSession, role_id: str, + name: Optional[str] = None, capabilities: Optional[Dict[str, bool]] = None, + ) -> Optional[SmartTableRole]: + result = await db.execute( + select(SmartTableRole).where( + SmartTableRole.id == role_id, + SmartTableRole.is_deleted == False, # noqa: E712 + ) + ) + role = result.scalar_one_or_none() + if not role: + return None + if name is not None: + role.name = name + if capabilities is not None: + role.capabilities = capabilities + await db.commit() + await db.refresh(role) + return role + + @classmethod + async def delete_role( + cls, db: AsyncSession, role_id: str, + ) -> bool: + result = await db.execute( + select(SmartTableRole).where( + SmartTableRole.id == role_id, + SmartTableRole.is_system == False, # noqa: E712 + SmartTableRole.is_deleted == False, # noqa: E712 + ) + ) + role = result.scalar_one_or_none() + if not role: + return False + role.is_deleted = True + await db.commit() + return True + + # ─── 获取 my-permission ─── + + @classmethod + async def get_my_permission( + cls, db: AsyncSession, table_id: str, + ) -> Dict[str, Any]: + """获取当前用户对某表的完整权限信息""" + user_info = get_current_user_info_from_context() + if not user_info: + return { + "role_type": "none", + "role_name": "无权限", + "capabilities": {}, + "field_permissions": {}, + "row_view_mode": "none", + "row_edit_mode": "none", + } + + role_info = await cls.get_effective_role( + db, table_id, + user_id=user_info.get("user_id", ""), + dept_id=user_info.get("dept_id"), + role_ids=user_info.get("role_ids", []), + is_superuser=user_info.get("is_superuser", False), + ) + + if not role_info: + return { + "role_type": "none", + "role_name": "无权限", + "capabilities": {}, + "field_permissions": {}, + "row_view_mode": "none", + "row_edit_mode": "none", + } + + role_id = role_info.get("role_id") + + field_perms: Dict[str, str] = {} + row_view_mode = "all" + row_edit_mode = "all" + + if role_id: + field_perms = await cls.get_field_permissions(db, table_id, role_id) + + row_rules = await cls.get_row_rules(db, table_id, role_id) + for rr in row_rules: + if rr.rule_type == "view": + row_view_mode = rr.mode + elif rr.rule_type == "edit": + row_edit_mode = rr.mode + + out: Dict[str, Any] = { + "role_type": role_info["role_type"], + "role_name": role_info["role_name"], + "capabilities": role_info["capabilities"], + "field_permissions": field_perms, + "row_view_mode": row_view_mode, + "row_edit_mode": row_edit_mode, + } + + # 文档无列/行数据维度,列权限与行规则不对文档内容生效;归一化返回值以免与多维表格混淆 + tbl_row = await db.execute( + select(SmartTable).where(SmartTable.id == table_id, SmartTable.is_deleted == False) # noqa: E712 + ) + tbl_obj = tbl_row.scalar_one_or_none() + if tbl_obj and getattr(tbl_obj, "type", "table") == "document": + out["field_permissions"] = {} + out["row_view_mode"] = "all" + out["row_edit_mode"] = "all" + + return out + + @classmethod + async def require_multidimensional_table(cls, db: AsyncSession, table_id: str) -> None: + """列权限、行权限仅适用于多维表格(type=table),文档页应使用协作者角色与能力位。""" + from fastapi import HTTPException + + result = await db.execute( + select(SmartTable).where(SmartTable.id == table_id, SmartTable.is_deleted == False) # noqa: E712 + ) + tbl = result.scalar_one_or_none() + if not tbl: + raise HTTPException(status_code=404, detail="表不存在") + if getattr(tbl, "type", "table") == "document": + raise HTTPException( + status_code=400, + detail="文档不支持列权限与行权限配置,请通过协作者角色控制访问", + ) + + # ─── 行权限过滤 ─── + + @classmethod + async def build_row_filter_conditions( + cls, db: AsyncSession, table_id: str, role_id: Optional[str], + user_id: str, rule_type: str = "view", + ) -> list: + """ + 根据行权限规则构建 SQLAlchemy 过滤条件列表。 + rule_type: "view" 或 "edit" + 返回空列表表示不限制。 + """ + from zq_smart_table.model import SmartRecord + from app.db_compat import json_extract + + if not role_id: + return [] + + row_rules = await cls.get_row_rules(db, table_id, role_id) + rule = next((r for r in row_rules if r.rule_type == rule_type), None) + if not rule or rule.mode == "all": + return [] + + if rule.mode == "creator_only": + return [SmartRecord.sys_creator_id == user_id] + + if rule.mode == "conditions" and rule.conditions: + from sqlalchemy import or_, and_ + conds = [] + for c in rule.conditions: + fid = c.get("field_id") + op = c.get("operator", "equals") + val = c.get("value") + if not fid: + continue + col = json_extract(SmartRecord.values, fid) + if op == "equals": + conds.append(col == str(val) if val is not None else col == None) # noqa: E711 + elif op == "contains": + conds.append(col.ilike(f"%{val}%") if val else col == col) + elif op == "isEmpty": + conds.append(or_(col == None, col == "")) # noqa: E711 + elif op == "isNotEmpty": + conds.append(and_(col != None, col != "")) # noqa: E711 + elif op == "greaterThan": + from sqlalchemy import cast, String + conds.append(cast(col, String) > str(val)) + elif op == "lessThan": + from sqlalchemy import cast, String + conds.append(cast(col, String) < str(val)) + return conds + + return [] + + @classmethod + async def check_row_edit_permission( + cls, db: AsyncSession, table_id: str, record, role_id: Optional[str], user_id: str, + ) -> bool: + """检查当前用户是否有权编辑指定记录(基于行编辑权限规则)""" + if not role_id: + return True + + row_rules = await cls.get_row_rules(db, table_id, role_id) + rule = next((r for r in row_rules if r.rule_type == "edit"), None) + if not rule or rule.mode == "all": + return True + + if rule.mode == "creator_only": + return getattr(record, "sys_creator_id", None) == user_id + + if rule.mode == "conditions" and rule.conditions: + from app.db_compat import json_extract + values = record.values or {} + for c in rule.conditions: + fid = c.get("field_id") + op = c.get("operator", "equals") + val = c.get("value") + if not fid: + continue + cell_val = str(values.get(fid, "")) + if op == "equals" and cell_val != str(val): + return False + if op == "contains" and str(val or "") not in cell_val: + return False + if op == "isEmpty" and cell_val != "": + return False + if op == "isNotEmpty" and cell_val == "": + return False + return True + + return True + + # ─── 过滤记录值(去掉不可见字段) ─── + + @classmethod + def filter_record_values( + cls, values: Dict[str, Any], accessible_fields: Optional[List[str]], + ) -> Dict[str, Any]: + """过滤记录中不可见的字段值""" + if accessible_fields is None: + return values + return {k: v for k, v in values.items() if k in accessible_fields} diff --git a/backend-fastapi/zq_smart_table/router.py b/backend-fastapi/zq_smart_table/router.py new file mode 100644 index 0000000..3ed79ad --- /dev/null +++ b/backend-fastapi/zq_smart_table/router.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from zq_smart_table.api import router as smart_table_api_router +from zq_smart_table.permission.api import router as permission_api_router + +router = APIRouter() +router.include_router(smart_table_api_router) +router.include_router(permission_api_router) diff --git a/backend-fastapi/zq_smart_table/schema.py b/backend-fastapi/zq_smart_table/schema.py new file mode 100644 index 0000000..cec70fa --- /dev/null +++ b/backend-fastapi/zq_smart_table/schema.py @@ -0,0 +1,495 @@ +from typing import Optional, List, Any, Dict + +from pydantic import BaseModel, ConfigDict, Field + +from app.base_schema import CSTDatetime + + +# ==================== SmartTable ==================== + +class SmartTableCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=200, description="表名") + icon: str = Field(default="Grid", max_length=50, description="图标") + description: Optional[str] = Field(None, description="描述") + type: str = Field(default="table", description="类型: table / document") + content: Optional[Dict[str, Any]] = Field(None, description="文档内容(Tiptap JSON), 仅 type=document 时使用") + parent_id: Optional[str] = Field(None, description="父页面ID") + wiki_space_id: Optional[str] = Field(None, description="所属文档库ID") + + +class SmartTableUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=200, description="表名") + icon: Optional[str] = Field(None, max_length=50, description="图标") + description: Optional[str] = Field(None, description="描述") + active_view_id: Optional[str] = Field(None, description="当前激活视图ID") + content: Optional[Dict[str, Any]] = Field(None, description="文档内容(Tiptap JSON)") + parent_id: Optional[str] = Field(None, description="父页面ID") + + +class SmartTableMove(BaseModel): + parent_id: Optional[str] = Field(None, description="目标父页面ID,null 表示移到根级") + after_id: Optional[str] = Field(None, description="排在此项之后,null 表示排在同级首位") + + +class SmartTableResponse(BaseModel): + id: str + name: str + icon: str = "Grid" + description: Optional[str] = None + active_view_id: Optional[str] = None + type: str = "table" + parent_id: Optional[str] = None + wiki_space_id: Optional[str] = None + sort: int = 0 + sys_create_datetime: Optional[CSTDatetime] = None + sys_update_datetime: Optional[CSTDatetime] = None + + model_config = ConfigDict(from_attributes=True) + + +class SmartTableSimple(BaseModel): + """列表简要信息(含 parent_id 以便前端构建树)""" + id: str + name: str + icon: str = "Grid" + type: str = "table" + parent_id: Optional[str] = None + wiki_space_id: Optional[str] = None + sort: int = 0 + + model_config = ConfigDict(from_attributes=True) + + +# ==================== SmartField ==================== + +class SmartFieldCreate(BaseModel): + table_id: Optional[str] = Field(None, description="所属表ID(从URL路径注入)") + name: str = Field(..., min_length=1, max_length=200, description="字段名") + type: str = Field(..., description="字段类型") + width: int = Field(default=150, description="列宽") + visible: bool = Field(default=True, description="是否可见") + required: bool = Field(default=False, description="是否必填") + description: Optional[str] = Field(None, description="描述") + config: Optional[Dict[str, Any]] = Field(default_factory=dict, description="扩展配置") + sort: int = Field(default=0, description="排序") + + +class SmartFieldUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=200, description="字段名") + type: Optional[str] = Field(None, description="字段类型") + width: Optional[int] = Field(None, description="列宽") + visible: Optional[bool] = Field(None, description="是否可见") + required: Optional[bool] = Field(None, description="是否必填") + description: Optional[str] = Field(None, description="描述") + config: Optional[Dict[str, Any]] = Field(None, description="扩展配置") + sort: Optional[int] = Field(None, description="排序") + + +class SmartFieldResponse(BaseModel): + id: str + table_id: str + name: str + type: str + width: int = 150 + visible: bool = True + required: bool = False + description: Optional[str] = None + config: Optional[Dict[str, Any]] = None + sort: int = 0 + sys_create_datetime: Optional[CSTDatetime] = None + sys_update_datetime: Optional[CSTDatetime] = None + + model_config = ConfigDict(from_attributes=True) + + +class SmartFieldReorder(BaseModel): + field_ids: List[str] = Field(..., description="按顺序排列的字段ID列表") + + +# ==================== SmartRecord ==================== + +class SmartRecordCreate(BaseModel): + table_id: Optional[str] = Field(None, description="所属表ID(从URL路径注入)") + values: Dict[str, Any] = Field(default_factory=dict, description="字段值映射") + + +class SmartRecordUpdate(BaseModel): + values: Optional[Dict[str, Any]] = Field(None, description="字段值映射") + + +class SmartRecordCellUpdate(BaseModel): + """更新单个单元格""" + field_id: str = Field(..., description="字段ID") + value: Any = Field(None, description="单元格值") + + +class SmartRecordResponse(BaseModel): + id: str + table_id: str + values: Dict[str, Any] = {} + sys_create_datetime: Optional[CSTDatetime] = None + sys_update_datetime: Optional[CSTDatetime] = None + sys_creator_id: Optional[str] = None + sys_modifier_id: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + + +class SmartRecordBatchDelete(BaseModel): + ids: List[str] = Field(..., description="要删除的记录ID列表") + + +class SmartRecordBatchRestore(BaseModel): + ids: List[str] = Field(..., description="要恢复的记录ID列表") + + +class TrashRecordResponse(BaseModel): + id: str + table_id: str + values: Dict[str, Any] = {} + sys_create_datetime: Optional[CSTDatetime] = None + sys_update_datetime: Optional[CSTDatetime] = None + sys_creator_id: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + + +class TrashListResponse(BaseModel): + items: List[TrashRecordResponse] + total: int + + +# ==================== SmartView ==================== + +class SmartViewCreate(BaseModel): + table_id: Optional[str] = Field(None, description="所属表ID(从URL路径注入)") + name: str = Field(..., min_length=1, max_length=200, description="视图名") + type: str = Field(default="grid", description="视图类型") + config: Optional[Dict[str, Any]] = Field(default_factory=dict, description="视图配置") + sort: int = Field(default=0, description="排序") + + +class SmartViewUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=200, description="视图名") + type: Optional[str] = Field(None, description="视图类型") + config: Optional[Dict[str, Any]] = Field(None, description="视图配置") + sort: Optional[int] = Field(None, description="排序") + + +class SmartViewResponse(BaseModel): + id: str + table_id: str + name: str + type: str = "grid" + config: Optional[Dict[str, Any]] = None + sort: int = 0 + sys_create_datetime: Optional[CSTDatetime] = None + sys_update_datetime: Optional[CSTDatetime] = None + + model_config = ConfigDict(from_attributes=True) + + +# ==================== Record Query (Server-side filter/sort/search) ==================== + +class RecordFilterRule(BaseModel): + field_id: str = Field(..., description="字段ID") + operator: str = Field(..., description="操作符: equals, notEquals, contains, notContains, isEmpty, isNotEmpty, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual") + value: Any = Field(None, description="筛选值") + + +class RecordSortRule(BaseModel): + field_id: str = Field(..., description="字段ID") + direction: str = Field(default="asc", description="排序方向: asc / desc") + + +class RecordQuery(BaseModel): + filters: List[RecordFilterRule] = Field(default_factory=list, description="筛选条件") + filter_logic: str = Field(default="and", description="筛选逻辑: and / or") + sorts: List[RecordSortRule] = Field(default_factory=list, description="排序规则") + search: Optional[str] = Field(None, description="全文搜索关键词") + search_field_ids: Optional[List[str]] = Field(None, description="搜索范围字段ID列表,为空搜索所有文本字段") + group_field_id: Optional[str] = Field(None, description="分组字段ID") + cursor: Optional[str] = Field(None, description="游标(上一页最后一条记录的ID)") + limit: int = Field(default=200, ge=1, le=5000, description="每页数量") + + +class RecordGroupItem(BaseModel): + key: str + label: str + records: List[SmartRecordResponse] = [] + + +class GroupedRecordsResponse(BaseModel): + groups: List[RecordGroupItem] + total: int + + +class SmartRecordReorder(BaseModel): + record_ids: List[str] = Field(..., description="按顺序排列的记录ID列表") + + +# ==================== Batch Cell Update ==================== + +class SmartRecordBatchCellUpdate(BaseModel): + """批量更新多个单元格""" + cells: Dict[str, Any] = Field(..., description="fieldId->value 映射") + + +class MultiRecordCellUpdate(BaseModel): + """单条记录的更新项""" + record_id: str = Field(..., description="记录ID") + cells: Dict[str, Any] = Field(..., description="fieldId->value 映射") + + +class MultiRecordBatchUpdate(BaseModel): + """批量更新多条记录的单元格 - 合并为单次事务""" + updates: List[MultiRecordCellUpdate] = Field(..., description="更新列表", max_length=200) + + +# ==================== Cursor Pagination ==================== + +class CursorPaginatedRecords(BaseModel): + items: List[SmartRecordResponse] + total: int + next_cursor: Optional[str] = None + has_more: bool = False + + +# ==================== Full Table Load ==================== + +# ==================== Link / Lookup / Rollup ==================== + +class LinkCellUpdate(BaseModel): + """更新 Link 字段的关联记录""" + record_ids: List[str] = Field(..., description="目标记录ID列表") + + +class LinkedRecordItem(BaseModel): + """关联记录摘要""" + id: str + title: str = "" + + +class RecordSearchResult(BaseModel): + """搜索目标表记录结果""" + id: str + title: str = "" + + +class RecordSearchQuery(BaseModel): + """搜索请求""" + keyword: str = Field(default="", description="搜索关键词") + limit: int = Field(default=20, ge=1, le=100, description="返回数量") + + +# ==================== Full Table Load ==================== + +class SmartTableFull(BaseModel): + """完整表数据(含字段、首页记录、视图)""" + id: str + name: str + icon: str = "Grid" + description: Optional[str] = None + active_view_id: Optional[str] = None + type: str = "table" + parent_id: Optional[str] = None + wiki_space_id: Optional[str] = None + content: Optional[Dict[str, Any]] = None + fields: List[SmartFieldResponse] = [] + records: List[SmartRecordResponse] = [] + views: List[SmartViewResponse] = [] + record_total: int = 0 + next_cursor: Optional[str] = None + has_more: bool = False + sys_create_datetime: Optional[CSTDatetime] = None + sys_update_datetime: Optional[CSTDatetime] = None + sys_creator_id: Optional[str] = None + creator_name: Optional[str] = None + creator_avatar: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + + +# ==================== Comment ==================== + +class CommentCreate(BaseModel): + content: str = Field(..., min_length=1, max_length=5000, description="评论内容") + mentions: List[str] = Field(default_factory=list, description="被@提及的用户ID列表") + parent_id: Optional[str] = Field(None, description="父评论ID(回复)") + + +class CommentUpdate(BaseModel): + content: str = Field(..., min_length=1, max_length=5000, description="评论内容") + mentions: List[str] = Field(default_factory=list, description="被@提及的用户ID列表") + + +class CommentResponse(BaseModel): + id: str + record_id: str + user_id: str + content: str + mentions: List[str] = [] + parent_id: Optional[str] = None + sys_create_datetime: Optional[CSTDatetime] = None + sys_update_datetime: Optional[CSTDatetime] = None + user_name: Optional[str] = None + user_avatar: Optional[str] = None + replies: List["CommentResponse"] = [] + + model_config = ConfigDict(from_attributes=True) + + +CommentResponse.model_rebuild() + + +# ==================== Summary / Aggregation ==================== + +class SummaryRequest(BaseModel): + """汇总请求:指定字段及聚合方式""" + aggregations: Dict[str, str] = Field( + ..., description="fieldId -> aggregation_type 映射,如 {'fld_abc': 'SUM'}" + ) + filters: List[RecordFilterRule] = Field(default_factory=list, description="筛选条件") + filter_logic: str = Field(default="and", description="筛选逻辑: and / or") + search: Optional[str] = Field(None, description="搜索关键词") + + +class SummaryResponse(BaseModel): + """汇总响应""" + summaries: Dict[str, Any] = Field( + default_factory=dict, + description="fieldId -> 聚合结果,如 {'fld_abc': 12345.67}" + ) + total_count: int = 0 + + +# ==================== Document ==================== + +class DocumentContentUpdate(BaseModel): + """更新文档内容""" + content: Dict[str, Any] = Field(..., description="Tiptap JSON 文档内容") + + +# ==================== Document Version ==================== + +class DocumentVersionCreate(BaseModel): + """手动创建版本""" + change_summary: Optional[str] = Field(None, max_length=500, description="变更摘要") + + +class DocumentVersionResponse(BaseModel): + id: str + document_id: str + version: int + title: Optional[str] = None + change_summary: Optional[str] = None + content_size: int = 0 + sys_create_datetime: Optional[CSTDatetime] = None + sys_creator_id: Optional[str] = None + creator_name: Optional[str] = None + creator_avatar: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + + +class DocumentVersionDetail(DocumentVersionResponse): + """版本详情(含完整内容)""" + content: Dict[str, Any] = {} + + +class DocumentVersionCompare(BaseModel): + """版本对比结果""" + version_from: DocumentVersionDetail + version_to: DocumentVersionDetail + + +# ==================== Document Template ==================== + +class DocumentTemplateCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=200, description="模板名称") + description: Optional[str] = Field(None, description="模板描述") + icon: str = Field(default="FileText", max_length=50, description="模板图标") + category: str = Field(default="custom", max_length=50, description="分类") + content: Dict[str, Any] = Field(..., description="模板内容(Tiptap JSON)") + preview_image: Optional[str] = Field(None, description="预览图URL") + + +class DocumentTemplateUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=200, description="模板名称") + description: Optional[str] = Field(None, description="模板描述") + icon: Optional[str] = Field(None, max_length=50, description="模板图标") + category: Optional[str] = Field(None, max_length=50, description="分类") + content: Optional[Dict[str, Any]] = Field(None, description="模板内容(Tiptap JSON)") + preview_image: Optional[str] = Field(None, description="预览图URL") + + +class DocumentTemplateResponse(BaseModel): + id: str + name: str + description: Optional[str] = None + icon: str = "FileText" + category: str = "custom" + preview_image: Optional[str] = None + is_system: bool = False + use_count: int = 0 + sys_create_datetime: Optional[CSTDatetime] = None + sys_update_datetime: Optional[CSTDatetime] = None + sys_creator_id: Optional[str] = None + creator_name: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + + +class DocumentTemplateDetail(DocumentTemplateResponse): + """模板详情(含完整内容)""" + content: Dict[str, Any] = {} + + +# ==================== Wiki Space ==================== + +class WikiSpaceCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=200, description="文档库名称") + icon: str = Field(default="BookOpen", max_length=50, description="图标") + avatar: Optional[str] = Field(None, description="头像文件ID") + description: Optional[str] = Field(None, description="描述") + cover: Optional[str] = Field(None, description="封面图URL") + category: str = Field(default="default", max_length=50, description="分类标签") + visibility: str = Field(default="private", description="可见性: private/team/public") + + +class WikiSpaceUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=200, description="文档库名称") + icon: Optional[str] = Field(None, max_length=50, description="图标") + avatar: Optional[str] = Field(None, description="头像文件ID") + description: Optional[str] = Field(None, description="描述") + cover: Optional[str] = Field(None, description="封面图URL") + category: Optional[str] = Field(None, max_length=50, description="分类标签") + visibility: Optional[str] = Field(None, description="可见性: private/team/public") + + +class WikiSpaceResponse(BaseModel): + id: str + name: str + icon: str = "BookOpen" + avatar: Optional[str] = None + description: Optional[str] = None + cover: Optional[str] = None + category: str = "default" + visibility: str = "private" + sort: int = 0 + sys_create_datetime: Optional[CSTDatetime] = None + sys_update_datetime: Optional[CSTDatetime] = None + sys_creator_id: Optional[str] = None + creator_name: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + + +class WikiSpaceListItem(WikiSpaceResponse): + """列表项,含文档数量""" + document_count: int = 0 + + +class WikiSpaceDetail(WikiSpaceResponse): + """详情,含文档列表""" + documents: List[SmartTableSimple] = [] diff --git a/backend-fastapi/zq_smart_table/service.py b/backend-fastapi/zq_smart_table/service.py new file mode 100644 index 0000000..9f61307 --- /dev/null +++ b/backend-fastapi/zq_smart_table/service.py @@ -0,0 +1,2074 @@ +from typing import Optional, List, Tuple, Any, Dict + +from sqlalchemy import select, func, update as sa_update, desc, asc, text, case, literal_column, cast, String, or_, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.base_service import BaseService +from app.db_compat import get_db_type, json_extract, json_has_key +from zq_smart_table.model import SmartTable, SmartField, SmartRecord, SmartView, SmartTableLink, SmartTableComment, SmartDocumentVersion, SmartDocumentTemplate, SmartWikiSpace +from zq_smart_table.schema import ( + SmartTableCreate, SmartTableUpdate, + SmartFieldCreate, SmartFieldUpdate, + SmartRecordCreate, SmartRecordUpdate, + SmartViewCreate, SmartViewUpdate, + RecordFilterRule, RecordSortRule, + LinkedRecordItem, + WikiSpaceCreate, WikiSpaceUpdate, +) + + +class SmartTableService(BaseService[SmartTable, SmartTableCreate, SmartTableUpdate]): + model = SmartTable + RESOURCE_TYPE = "smart_table" + + @classmethod + async def get_user_tables( + cls, db: AsyncSession, page: int = 1, page_size: int = 1000, + wiki_space_id: Optional[str] = None, + user_id: Optional[str] = None, + dept_id: Optional[str] = None, + role_ids: Optional[List[str]] = None, + is_superuser: bool = False, + ) -> Tuple[List[SmartTable], int]: + from zq_smart_table.permission.model import SmartTableCollaborator + + base_query = select(SmartTable).where(SmartTable.is_deleted == False) # noqa: E712 + + if wiki_space_id is not None: + base_query = base_query.where(SmartTable.wiki_space_id == wiki_space_id) + else: + base_query = base_query.where(SmartTable.wiki_space_id.is_(None)) + + if not is_superuser and user_id: + subject_conds = [ + and_( + SmartTableCollaborator.subject_type == "user", + SmartTableCollaborator.subject_id == user_id, + ) + ] + if dept_id: + subject_conds.append( + and_( + SmartTableCollaborator.subject_type == "dept", + SmartTableCollaborator.subject_id == dept_id, + ) + ) + if role_ids: + for rid in role_ids: + subject_conds.append( + and_( + SmartTableCollaborator.subject_type == "role", + SmartTableCollaborator.subject_id == rid, + ) + ) + + collab_table_ids = ( + select(SmartTableCollaborator.table_id) + .where( + SmartTableCollaborator.is_deleted == False, # noqa: E712 + or_(*subject_conds), + ) + .distinct() + ) + + base_query = base_query.where( + or_( + SmartTable.sys_creator_id == user_id, + SmartTable.id.in_(collab_table_ids), + ) + ) + + count_result = await db.execute( + select(func.count()).select_from(base_query.subquery()) + ) + total = count_result.scalar() or 0 + + offset = (page - 1) * page_size + result = await db.execute( + base_query.order_by(desc(SmartTable.sort), desc(SmartTable.sys_create_datetime)) + .offset(offset) + .limit(page_size) + ) + items = list(result.scalars().all()) + return items, total + + +class SmartFieldService(BaseService[SmartField, SmartFieldCreate, SmartFieldUpdate]): + model = SmartField + RESOURCE_TYPE = "smart_field" + + @classmethod + async def get_by_table(cls, db: AsyncSession, table_id: str) -> List[SmartField]: + result = await db.execute( + select(SmartField) + .where(SmartField.table_id == table_id, SmartField.is_deleted == False) # noqa: E712 + .order_by(SmartField.sort, SmartField.sys_create_datetime) + ) + return list(result.scalars().all()) + + @classmethod + async def reorder(cls, db: AsyncSession, table_id: str, field_ids: List[str]) -> None: + """批量排序:一条 CASE WHEN SQL 替代 N 次 UPDATE""" + if not field_ids: + return + whens = [(SmartField.id == fid, idx) for idx, fid in enumerate(field_ids)] + await db.execute( + sa_update(SmartField) + .where(SmartField.table_id == table_id, SmartField.id.in_(field_ids)) + .values(sort=case(*whens, else_=SmartField.sort)) + ) + await db.commit() + + @classmethod + async def delete_and_clean(cls, db: AsyncSession, field_id: str) -> bool: + """删除字段并用一条 SQL 清理所有 record 的 values""" + field = await cls.get_by_id(db, field_id) + if not field: + return False + + db_type = get_db_type() + if db_type == "postgresql": + await db.execute( + text( + "UPDATE smart_record SET \"values\" = \"values\" - :field_id " + "WHERE table_id = :table_id AND is_deleted = false " + "AND \"values\" \\? :field_id" + ), + {"field_id": field_id, "table_id": field.table_id}, + ) + else: + await db.execute( + text( + "UPDATE smart_record SET `values` = JSON_REMOVE(`values`, CONCAT('$.', :field_id)) " + "WHERE table_id = :table_id AND is_deleted = 0 " + "AND JSON_CONTAINS_PATH(`values`, 'one', CONCAT('$.', :field_id))" + ), + {"field_id": field_id, "table_id": field.table_id}, + ) + + field.is_deleted = True + await db.commit() + return True + + @classmethod + async def get_next_sort(cls, db: AsyncSession, table_id: str) -> int: + result = await db.execute( + select(func.coalesce(func.max(SmartField.sort), -1)) + .where(SmartField.table_id == table_id, SmartField.is_deleted == False) # noqa: E712 + ) + return (result.scalar() or 0) + 1 + + +class SmartRecordService(BaseService[SmartRecord, SmartRecordCreate, SmartRecordUpdate]): + model = SmartRecord + RESOURCE_TYPE = "smart_record" + + @classmethod + async def get_by_table_cursor( + cls, db: AsyncSession, table_id: str, + cursor: Optional[str] = None, limit: int = 200, + ) -> Tuple[List[SmartRecord], Optional[str], int]: + """ + 游标分页查询(适用于大数据量滚动加载)。 + 返回 (records, next_cursor, total) + """ + base = select(SmartRecord).where( + SmartRecord.table_id == table_id, + SmartRecord.is_deleted == False, # noqa: E712 + ) + count_result = await db.execute( + select(func.count()).select_from(base.subquery()) + ) + total = count_result.scalar() or 0 + + query = base.order_by(SmartRecord.sort, SmartRecord.id) + if cursor: + query = query.where(SmartRecord.id > cursor) + query = query.limit(limit + 1) + + result = await db.execute(query) + items = list(result.scalars().all()) + + next_cursor = None + if len(items) > limit: + items = items[:limit] + next_cursor = items[-1].id + + return items, next_cursor, total + + @classmethod + async def get_by_table( + cls, db: AsyncSession, table_id: str, + page: int = 1, page_size: int = 200 + ) -> Tuple[List[SmartRecord], int]: + filters = [SmartRecord.table_id == table_id] + return await cls.get_list(db, page=page, page_size=page_size, filters=filters) + + @classmethod + async def update_cell( + cls, db: AsyncSession, record_id: str, field_id: str, value: Any + ) -> Optional[SmartRecord]: + """使用数据库原生 JSON 原子更新,避免读-改-写""" + import json + db_type = get_db_type() + json_value = json.dumps(value) + + if db_type == "postgresql": + await db.execute( + text( + "UPDATE smart_record SET \"values\" = jsonb_set(" + "COALESCE(\"values\", CAST('{}' AS jsonb)), " + ":path, CAST(:val AS jsonb)), " + "sys_update_datetime = now() " + "WHERE id = :id AND is_deleted = false" + ), + {"path": [field_id], "val": json_value, "id": record_id}, + ) + else: + await db.execute( + text( + "UPDATE smart_record SET `values` = JSON_SET(" + "COALESCE(`values`, '{}'), " + "CONCAT('$.', :field_id), CAST(:val AS JSON)), " + "sys_update_datetime = NOW() " + "WHERE id = :id AND is_deleted = 0" + ), + {"field_id": field_id, "val": json_value, "id": record_id}, + ) + + await db.commit() + + record = await cls.get_by_id(db, record_id) + return record + + @classmethod + async def batch_update_cells( + cls, db: AsyncSession, record_id: str, cells: Dict[str, Any] + ) -> Optional[SmartRecord]: + """批量更新多个单元格(一次 SQL)""" + import json + record = await cls.get_by_id(db, record_id) + if not record: + return None + + current_values = dict(record.values) if record.values else {} + current_values.update(cells) + record.values = current_values + await db.commit() + await db.refresh(record) + return record + + @classmethod + async def batch_update_multi_records( + cls, db: AsyncSession, updates: List[Dict[str, Any]] + ) -> int: + """ + 批量更新多条记录的单元格,合并为单次事务。 + updates: [{ "record_id": "...", "cells": { fieldId: value, ... } }, ...] + 返回成功更新的记录数。 + """ + import json as _json + db_type = get_db_type() + count = 0 + + for item in updates: + record_id = item.get("record_id") + cells = item.get("cells", {}) + if not record_id or not cells: + continue + + if db_type == "postgresql": + merge_obj = _json.dumps(cells) + await db.execute( + text( + 'UPDATE smart_record SET "values" = ' + 'COALESCE("values", \'{}\'::jsonb) || CAST(:merge AS jsonb), ' + 'sys_update_datetime = now() ' + 'WHERE id = :id AND is_deleted = false' + ), + {"merge": merge_obj, "id": record_id}, + ) + else: + record = await cls.get_by_id(db, record_id) + if not record: + continue + current = dict(record.values) if record.values else {} + current.update(cells) + record.values = current + + count += 1 + + await db.commit() + return count + + @classmethod + async def batch_remove_field(cls, db: AsyncSession, table_id: str, field_id: str) -> None: + """用一条 SQL 从所有 record 中移除某个 field 的值""" + db_type = get_db_type() + if db_type == "postgresql": + await db.execute( + text( + "UPDATE smart_record SET \"values\" = \"values\" - :field_id " + "WHERE table_id = :table_id AND is_deleted = false " + "AND \"values\" \\? :field_id" + ), + {"field_id": field_id, "table_id": table_id}, + ) + else: + await db.execute( + text( + "UPDATE smart_record SET `values` = JSON_REMOVE(`values`, CONCAT('$.', :field_id)) " + "WHERE table_id = :table_id AND is_deleted = 0" + ), + {"field_id": field_id, "table_id": table_id}, + ) + await db.commit() + + @classmethod + async def get_deleted_records( + cls, db: AsyncSession, table_id: str, + page: int = 1, page_size: int = 50, + ) -> Tuple[List[SmartRecord], int]: + """查询已删除的记录(回收站)""" + base = select(SmartRecord).where( + SmartRecord.table_id == table_id, + SmartRecord.is_deleted == True, # noqa: E712 + ) + count_result = await db.execute( + select(func.count()).select_from(base.subquery()) + ) + total = count_result.scalar() or 0 + + query = base.order_by(desc(SmartRecord.sys_update_datetime)).offset( + (page - 1) * page_size + ).limit(page_size) + result = await db.execute(query) + return list(result.scalars().all()), total + + @classmethod + async def batch_restore_records( + cls, db: AsyncSession, table_id: str, record_ids: List[str] + ) -> int: + """批量恢复已删除的记录(仅限指定表)""" + result = await db.execute( + sa_update(SmartRecord) + .where( + SmartRecord.id.in_(record_ids), + SmartRecord.table_id == table_id, + SmartRecord.is_deleted == True, # noqa: E712 + ) + .values(is_deleted=False) + ) + await db.commit() + return result.rowcount # type: ignore + + @classmethod + async def permanent_delete(cls, db: AsyncSession, table_id: str, record_id: str) -> bool: + """永久删除记录(仅限指定表的已删除记录)""" + from sqlalchemy import delete as sa_del + result = await db.execute( + sa_del(SmartRecord).where( + SmartRecord.id == record_id, + SmartRecord.table_id == table_id, + SmartRecord.is_deleted == True, # noqa: E712 + ) + ) + await db.commit() + return result.rowcount > 0 # type: ignore + + @classmethod + async def empty_trash(cls, db: AsyncSession, table_id: str) -> int: + """清空回收站""" + from sqlalchemy import delete as sa_del + result = await db.execute( + sa_del(SmartRecord).where( + SmartRecord.table_id == table_id, + SmartRecord.is_deleted == True, # noqa: E712 + ) + ) + await db.commit() + return result.rowcount # type: ignore + + @classmethod + async def get_next_auto_number(cls, db: AsyncSession, table_id: str, field_id: str) -> int: + """用 SQL 聚合查询获取 AutoNumber 最大值,避免全表加载到 Python""" + db_type = get_db_type() + if db_type == "postgresql": + result = await db.execute( + text( + "SELECT COALESCE(MAX(CAST(\"values\" ->> :field_id AS int)), 0) " + "FROM smart_record " + "WHERE table_id = :table_id AND is_deleted = false " + "AND \"values\" \\? :field_id " + "AND (\"values\" ->> :field_id) ~ '^[0-9]+$'" + ), + {"field_id": field_id, "table_id": table_id}, + ) + else: + result = await db.execute( + text( + "SELECT COALESCE(MAX(CAST(JSON_UNQUOTE(JSON_EXTRACT(`values`, CONCAT('$.', :field_id))) AS SIGNED)), 0) " + "FROM smart_record " + "WHERE table_id = :table_id AND is_deleted = 0 " + "AND JSON_CONTAINS_PATH(`values`, 'one', CONCAT('$.', :field_id))" + ), + {"field_id": field_id, "table_id": table_id}, + ) + return (result.scalar() or 0) + 1 + + @classmethod + async def get_record_count(cls, db: AsyncSession, table_id: str) -> int: + result = await db.execute( + select(func.count()).where( + SmartRecord.table_id == table_id, + SmartRecord.is_deleted == False, # noqa: E712 + ) + ) + return result.scalar() or 0 + + # ==================== Server-side filter / sort / search ==================== + + @classmethod + def _build_filter_condition(cls, rule: RecordFilterRule): + """将单条 RecordFilterRule 转换为 SQLAlchemy 条件表达式""" + col = json_extract(SmartRecord.values, rule.field_id) + op = rule.operator + val = rule.value + + if op == "isEmpty": + return or_( + ~json_has_key(SmartRecord.values, rule.field_id), + col == None, # noqa: E711 + col == "", + ) + if op == "isNotEmpty": + return and_( + json_has_key(SmartRecord.values, rule.field_id), + col != None, # noqa: E711 + col != "", + ) + if op == "equals": + return col == str(val) if val is not None else col == None # noqa: E711 + if op == "notEquals": + return col != str(val) if val is not None else col != None # noqa: E711 + if op == "contains": + return col.ilike(f"%{val}%") if val else col == col + if op == "notContains": + return ~col.ilike(f"%{val}%") if val else col == col + if op == "greaterThan": + return cast(col, String) > str(val) + if op == "lessThan": + return cast(col, String) < str(val) + if op == "greaterThanOrEqual": + return cast(col, String) >= str(val) + if op == "lessThanOrEqual": + return cast(col, String) <= str(val) + return True # noqa: fallback + + @classmethod + def _build_search_conditions(cls, keyword: str, search_field_ids: Optional[List[str]] = None): + """构建全文搜索条件:在指定字段或所有字段中匹配关键词""" + if not keyword: + return None + like_pattern = f"%{keyword}%" + if search_field_ids: + conds = [json_extract(SmartRecord.values, fid).ilike(like_pattern) for fid in search_field_ids] + else: + # 对 JSONB 整列做 cast(text) ILIKE,兼容所有字段 + conds = [cast(SmartRecord.values, String).ilike(like_pattern)] + return or_(*conds) if conds else None + + @classmethod + async def get_by_table_cursor_filtered( + cls, + db: AsyncSession, + table_id: str, + filters: Optional[List[RecordFilterRule]] = None, + filter_logic: str = "and", + sorts: Optional[List[RecordSortRule]] = None, + search: Optional[str] = None, + search_field_ids: Optional[List[str]] = None, + extra_conditions: Optional[list] = None, + cursor: Optional[str] = None, + limit: int = 200, + skip_count: bool = False, + ) -> Tuple[List[SmartRecord], Optional[str], int]: + """ + 服务端筛选 + 排序 + 搜索 + 游标分页。 + extra_conditions: 外部传入的额外 SQLAlchemy 条件(如行权限) + skip_count: 为True时跳过count查询(用于加载更多时提升性能) + """ + base = select(SmartRecord).where( + SmartRecord.table_id == table_id, + SmartRecord.is_deleted == False, # noqa: E712 + ) + + has_complex_filter = False + if filters: + filter_conds = [cls._build_filter_condition(f) for f in filters] + if filter_logic == "or": + base = base.where(or_(*filter_conds)) + else: + base = base.where(and_(*filter_conds)) + has_complex_filter = True + + if search and search.strip(): + search_cond = cls._build_search_conditions(search.strip(), search_field_ids) + if search_cond is not None: + base = base.where(search_cond) + has_complex_filter = True + + if extra_conditions: + for cond in extra_conditions: + base = base.where(cond) + has_complex_filter = True + + total = -1 + if not skip_count: + if has_complex_filter: + count_result = await db.execute( + select(func.count()).select_from(base.subquery()) + ) + else: + count_result = await db.execute( + select(func.count()).where( + SmartRecord.table_id == table_id, + SmartRecord.is_deleted == False, # noqa: E712 + ) + ) + total = count_result.scalar() or 0 + + query = base + if sorts: + for s in sorts: + col = json_extract(SmartRecord.values, s.field_id) + query = query.order_by(desc(col) if s.direction == "desc" else asc(col)) + query = query.order_by(SmartRecord.sort, SmartRecord.id) + + if cursor: + query = query.where(SmartRecord.id > cursor) + query = query.limit(limit + 1) + + result = await db.execute(query) + items = list(result.scalars().all()) + + next_cursor = None + if len(items) > limit: + items = items[:limit] + next_cursor = items[-1].id + + return items, next_cursor, total + + @classmethod + async def reorder(cls, db: AsyncSession, table_id: str, record_ids: List[str]) -> None: + """批量更新记录排序""" + if not record_ids: + return + whens = [(SmartRecord.id == rid, idx) for idx, rid in enumerate(record_ids)] + await db.execute( + sa_update(SmartRecord) + .where(SmartRecord.table_id == table_id, SmartRecord.id.in_(record_ids)) + .values(sort=case(*whens, else_=SmartRecord.sort)) + ) + await db.commit() + + +class SmartViewService(BaseService[SmartView, SmartViewCreate, SmartViewUpdate]): + model = SmartView + RESOURCE_TYPE = "smart_view" + + @classmethod + async def get_by_table(cls, db: AsyncSession, table_id: str) -> List[SmartView]: + result = await db.execute( + select(SmartView) + .where(SmartView.table_id == table_id, SmartView.is_deleted == False) # noqa: E712 + .order_by(SmartView.sort, SmartView.sys_create_datetime) + ) + return list(result.scalars().all()) + + +# ==================== Import / Export ==================== + +class SmartExportService: + """CSV / Excel 导出""" + + @classmethod + def _format_cell(cls, val: Any, field_type: str) -> str: + if val is None: + return "" + if field_type == "link" and isinstance(val, list): + return ", ".join( + (item.get("title", "") if isinstance(item, dict) else str(item)) + for item in val + ) + if isinstance(val, list): + return ", ".join(str(v) for v in val) + return str(val) + + @classmethod + async def export_csv( + cls, db: AsyncSession, table_id: str, + fields: List[SmartField], records: List[SmartRecord], + ) -> str: + import csv + import io + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow([f.name for f in fields]) + + for r in records: + row = [] + for f in fields: + val = (r.values or {}).get(f.id, "") + row.append(cls._format_cell(val, f.type)) + writer.writerow(row) + + return output.getvalue() + + @classmethod + async def export_xlsx( + cls, db: AsyncSession, table_id: str, + fields: List[SmartField], records: List[SmartRecord], + ) -> bytes: + from openpyxl import Workbook + import io + + wb = Workbook() + ws = wb.active + ws.title = "Sheet1" + ws.append([f.name for f in fields]) + + for r in records: + row = [] + for f in fields: + val = (r.values or {}).get(f.id, "") + row.append(cls._format_cell(val, f.type)) + ws.append(row) + + buf = io.BytesIO() + wb.save(buf) + return buf.getvalue() + + +class SmartImportService: + """CSV / Excel 导入""" + + BATCH_SIZE = 500 + + @classmethod + async def import_csv( + cls, db: AsyncSession, table_id: str, + file_content: bytes, fields: List[SmartField], + ) -> Dict[str, Any]: + import csv + import io + + text = file_content.decode("utf-8-sig") + reader = csv.reader(io.StringIO(text)) + headers = next(reader, None) + if not headers: + return {"success": 0, "fail": 0, "errors": ["文件为空或无表头"]} + + return await cls._import_rows(db, table_id, headers, list(reader), fields) + + @classmethod + async def import_xlsx( + cls, db: AsyncSession, table_id: str, + file_content: bytes, fields: List[SmartField], + ) -> Dict[str, Any]: + from openpyxl import load_workbook + import io + + wb = load_workbook(io.BytesIO(file_content), read_only=True) + ws = wb.active + rows_iter = ws.iter_rows(values_only=True) + header_row = next(rows_iter, None) + if not header_row: + return {"success": 0, "fail": 0, "errors": ["文件为空或无表头"]} + + headers = [str(h) if h else "" for h in header_row] + data_rows = [[str(c) if c is not None else "" for c in row] for row in rows_iter] + + return await cls._import_rows(db, table_id, headers, data_rows, fields) + + @classmethod + async def _import_rows( + cls, db: AsyncSession, table_id: str, + headers: List[str], rows: List[List[str]], + fields: List[SmartField], + ) -> Dict[str, Any]: + field_map: Dict[str, SmartField] = {f.name: f for f in fields} + col_to_field: List[Optional[SmartField]] = [] + new_fields_created = 0 + + for h in headers: + h = h.strip() + if h in field_map: + col_to_field.append(field_map[h]) + else: + new_field = SmartField( + table_id=table_id, + name=h, + type="text", + width=150, + visible=True, + required=False, + config={}, + sort=len(fields) + new_fields_created, + ) + db.add(new_field) + await db.flush() + field_map[h] = new_field + col_to_field.append(new_field) + new_fields_created += 1 + + success = 0 + errors: List[str] = [] + batch: List[SmartRecord] = [] + + for row_idx, row in enumerate(rows): + try: + values: Dict[str, Any] = {} + for col_idx, cell_val in enumerate(row): + if col_idx < len(col_to_field) and col_to_field[col_idx]: + field = col_to_field[col_idx] + if field.type in ("link", "lookup", "rollup"): + continue + values[field.id] = cls._parse_cell(cell_val, field.type) + + record = SmartRecord(table_id=table_id, values=values) + batch.append(record) + success += 1 + + if len(batch) >= cls.BATCH_SIZE: + db.add_all(batch) + await db.commit() + batch = [] + except Exception as e: + errors.append(f"第{row_idx + 2}行: {str(e)}") + + if batch: + db.add_all(batch) + await db.commit() + + return { + "success": success, + "fail": len(errors), + "errors": errors[:50], + "new_fields": new_fields_created, + } + + @classmethod + def _parse_cell(cls, val: str, field_type: str) -> Any: + if not val: + return None + if field_type == "number": + try: + return float(val) if "." in val else int(val) + except ValueError: + return val + if field_type == "checkbox": + return val.lower() in ("true", "1", "yes", "是") + if field_type == "rating": + try: + return int(float(val)) + except ValueError: + return 0 + return val + + +# ==================== Link / Lookup / Rollup ==================== + +class SmartLinkService: + """管理 Link / Lookup / Rollup 字段的创建、删除、关联值解析""" + + # ---------- Link 字段创建 / 删除 ---------- + + @classmethod + async def create_link_field( + cls, + db: AsyncSession, + table_id: str, + field_name: str, + target_table_id: str, + sort: int = 0, + ) -> Tuple[SmartField, SmartField]: + """ + 创建 Link 字段及目标表的对称 Link 字段。 + 返回 (source_field, symmetric_field)。 + """ + source_table = await SmartTableService.get_by_id(db, table_id) + target_table = await SmartTableService.get_by_id(db, target_table_id) + if not source_table or not target_table: + raise ValueError("源表或目标表不存在") + + source_field_data = SmartFieldCreate( + table_id=table_id, + name=field_name, + type="link", + sort=sort or await SmartFieldService.get_next_sort(db, table_id), + config={ + "linkedTableId": target_table_id, + }, + ) + source_field = await SmartFieldService.create(db, source_field_data) + + sym_field_data = SmartFieldCreate( + table_id=target_table_id, + name=source_table.name, + type="link", + sort=await SmartFieldService.get_next_sort(db, target_table_id), + config={ + "linkedTableId": table_id, + "symmetricFieldId": source_field.id, + }, + ) + sym_field = await SmartFieldService.create(db, sym_field_data) + + source_field.config = { + **(source_field.config or {}), + "symmetricFieldId": sym_field.id, + } + await db.commit() + await db.refresh(source_field) + + return source_field, sym_field + + @classmethod + async def delete_link_field(cls, db: AsyncSession, field_id: str) -> bool: + """删除 Link 字段、对称字段和所有关联记录""" + field = await SmartFieldService.get_by_id(db, field_id) + if not field or field.type != "link": + return False + + sym_field_id = (field.config or {}).get("symmetricFieldId") + + await db.execute( + sa_update(SmartTableLink) + .where(SmartTableLink.field_id == field_id) + .values(is_deleted=True) + ) + + if sym_field_id: + await db.execute( + sa_update(SmartTableLink) + .where(SmartTableLink.field_id == sym_field_id) + .values(is_deleted=True) + ) + sym_field = await SmartFieldService.get_by_id(db, sym_field_id) + if sym_field: + sym_field.is_deleted = True + + field.is_deleted = True + await db.commit() + return True + + # ---------- 关联记录操作 ---------- + + @classmethod + async def set_linked_records( + cls, + db: AsyncSession, + field_id: str, + source_record_id: str, + target_record_ids: List[str], + ) -> None: + """ + 全量设置关联:先移除旧关联,再插入新关联。 + 同时维护对称方向。 + """ + field = await SmartFieldService.get_by_id(db, field_id) + if not field or field.type != "link": + return + sym_field_id = (field.config or {}).get("symmetricFieldId") + + existing = await db.execute( + select(SmartTableLink).where( + SmartTableLink.field_id == field_id, + SmartTableLink.source_record_id == source_record_id, + SmartTableLink.is_deleted == False, # noqa: E712 + ) + ) + existing_links = list(existing.scalars().all()) + existing_target_ids = {l.target_record_id for l in existing_links} + + to_add = set(target_record_ids) - existing_target_ids + to_remove = existing_target_ids - set(target_record_ids) + + for link in existing_links: + if link.target_record_id in to_remove: + link.is_deleted = True + + if sym_field_id and to_remove: + sym_result = await db.execute( + select(SmartTableLink).where( + SmartTableLink.field_id == sym_field_id, + SmartTableLink.target_record_id == source_record_id, + SmartTableLink.source_record_id.in_(to_remove), + SmartTableLink.is_deleted == False, # noqa: E712 + ) + ) + for sym_link in sym_result.scalars().all(): + sym_link.is_deleted = True + + for tid in to_add: + db.add(SmartTableLink( + field_id=field_id, + source_record_id=source_record_id, + target_record_id=tid, + )) + if sym_field_id: + db.add(SmartTableLink( + field_id=sym_field_id, + source_record_id=tid, + target_record_id=source_record_id, + )) + + await db.commit() + + @classmethod + async def get_linked_record_ids( + cls, db: AsyncSession, field_id: str, source_record_id: str + ) -> List[str]: + result = await db.execute( + select(SmartTableLink.target_record_id).where( + SmartTableLink.field_id == field_id, + SmartTableLink.source_record_id == source_record_id, + SmartTableLink.is_deleted == False, # noqa: E712 + ) + ) + return list(result.scalars().all()) + + # ---------- 解析关联值(批量注入到 records) ---------- + + @classmethod + async def resolve_link_values( + cls, + db: AsyncSession, + fields: List[SmartField], + records: List[SmartRecord], + ) -> None: + """批量解析 Link 字段值,注入 [{id, title}] 到 record.values""" + link_fields = [f for f in fields if f.type == "link"] + if not link_fields or not records: + return + + record_ids = [r.id for r in records] + + for lf in link_fields: + result = await db.execute( + select(SmartTableLink.source_record_id, SmartTableLink.target_record_id) + .where( + SmartTableLink.field_id == lf.id, + SmartTableLink.source_record_id.in_(record_ids), + SmartTableLink.is_deleted == False, # noqa: E712 + ) + ) + links = result.all() + + target_ids = list({row[1] for row in links}) + title_map: Dict[str, str] = {} + if target_ids: + target_table_id = (lf.config or {}).get("linkedTableId") + if target_table_id: + title_map = await cls._get_record_titles(db, target_table_id, target_ids) + + src_map: Dict[str, List[LinkedRecordItem]] = {} + for src_id, tgt_id in links: + src_map.setdefault(src_id, []).append( + LinkedRecordItem(id=tgt_id, title=title_map.get(tgt_id, "")) + ) + + for r in records: + vals = dict(r.values) if r.values else {} + items = src_map.get(r.id, []) + vals[lf.id] = [item.model_dump() for item in items] + r.values = vals + + @classmethod + async def resolve_lookup_values( + cls, + db: AsyncSession, + fields: List[SmartField], + records: List[SmartRecord], + ) -> None: + """解析 Lookup 字段值:通过 linkFieldId 找到关联记录,取 lookupFieldId 的值""" + lookup_fields = [f for f in fields if f.type == "lookup"] + if not lookup_fields or not records: + return + + record_ids = [r.id for r in records] + + for lkf in lookup_fields: + cfg = lkf.config or {} + link_field_id = cfg.get("linkFieldId") + lookup_field_id = cfg.get("lookupFieldId") + if not link_field_id or not lookup_field_id: + continue + + result = await db.execute( + select(SmartTableLink.source_record_id, SmartTableLink.target_record_id) + .where( + SmartTableLink.field_id == link_field_id, + SmartTableLink.source_record_id.in_(record_ids), + SmartTableLink.is_deleted == False, # noqa: E712 + ) + ) + links = result.all() + target_ids = list({row[1] for row in links}) + + target_values: Dict[str, Any] = {} + if target_ids: + tgt_result = await db.execute( + select(SmartRecord.id, SmartRecord.values).where( + SmartRecord.id.in_(target_ids), + SmartRecord.is_deleted == False, # noqa: E712 + ) + ) + for rid, vals in tgt_result.all(): + target_values[rid] = (vals or {}).get(lookup_field_id) + + src_map: Dict[str, list] = {} + for src_id, tgt_id in links: + v = target_values.get(tgt_id) + if v is not None: + src_map.setdefault(src_id, []).append(v) + + for r in records: + vals = dict(r.values) if r.values else {} + vals[lkf.id] = src_map.get(r.id, []) + r.values = vals + + @classmethod + async def resolve_rollup_values( + cls, + db: AsyncSession, + fields: List[SmartField], + records: List[SmartRecord], + ) -> None: + """解析 Rollup 字段值:聚合关联记录的某个字段""" + rollup_fields = [f for f in fields if f.type == "rollup"] + if not rollup_fields or not records: + return + + record_ids = [r.id for r in records] + + for rf in rollup_fields: + cfg = rf.config or {} + link_field_id = cfg.get("linkFieldId") + rollup_field_id = cfg.get("rollupFieldId") + aggregation = cfg.get("aggregation", "COUNT") + if not link_field_id or not rollup_field_id: + continue + + result = await db.execute( + select(SmartTableLink.source_record_id, SmartTableLink.target_record_id) + .where( + SmartTableLink.field_id == link_field_id, + SmartTableLink.source_record_id.in_(record_ids), + SmartTableLink.is_deleted == False, # noqa: E712 + ) + ) + links = result.all() + target_ids = list({row[1] for row in links}) + + target_values: Dict[str, Any] = {} + if target_ids: + tgt_result = await db.execute( + select(SmartRecord.id, SmartRecord.values).where( + SmartRecord.id.in_(target_ids), + SmartRecord.is_deleted == False, # noqa: E712 + ) + ) + for rid, vals in tgt_result.all(): + target_values[rid] = (vals or {}).get(rollup_field_id) + + src_groups: Dict[str, list] = {} + for src_id, tgt_id in links: + v = target_values.get(tgt_id) + src_groups.setdefault(src_id, []).append(v) + + for r in records: + vals = dict(r.values) if r.values else {} + group = src_groups.get(r.id, []) + vals[rf.id] = cls._aggregate(group, aggregation) + r.values = vals + + @classmethod + def _aggregate(cls, values: List[Any], aggregation: str) -> Any: + agg = aggregation.upper() + if agg == "COUNT": + return len(values) + if agg == "COUNTA": + return len([v for v in values if v is not None and v != ""]) + + nums = [] + for v in values: + if v is None: + continue + try: + nums.append(float(v)) + except (TypeError, ValueError): + pass + + if agg == "SUM": + return sum(nums) if nums else 0 + if agg == "AVG": + return sum(nums) / len(nums) if nums else None + if agg == "MIN": + return min(nums) if nums else None + if agg == "MAX": + return max(nums) if nums else None + return len(values) + + # ---------- 记录删除时清理关联 ---------- + + @classmethod + async def cleanup_record_links(cls, db: AsyncSession, record_id: str) -> None: + """删除记录时,软删除所有涉及该记录的关联""" + await db.execute( + sa_update(SmartTableLink) + .where( + or_( + SmartTableLink.source_record_id == record_id, + SmartTableLink.target_record_id == record_id, + ), + SmartTableLink.is_deleted == False, # noqa: E712 + ) + .values(is_deleted=True) + ) + + # ---------- 搜索目标表记录 ---------- + + @classmethod + async def search_records( + cls, + db: AsyncSession, + table_id: str, + keyword: str = "", + limit: int = 20, + ) -> List[Dict[str, Any]]: + """搜索指定表的记录,返回 [{id, title}]""" + fields = await SmartFieldService.get_by_table(db, table_id) + primary_field = fields[0] if fields else None + + base = select(SmartRecord).where( + SmartRecord.table_id == table_id, + SmartRecord.is_deleted == False, # noqa: E712 + ) + + if keyword.strip() and primary_field: + col = json_extract(SmartRecord.values, primary_field.id) + base = base.where(col.ilike(f"%{keyword.strip()}%")) + + base = base.order_by(SmartRecord.sort, SmartRecord.id).limit(limit) + result = await db.execute(base) + rows = list(result.scalars().all()) + + items = [] + for r in rows: + title = "" + if primary_field: + title = str((r.values or {}).get(primary_field.id, "") or "") + items.append({"id": r.id, "title": title}) + return items + + # ---------- 内部工具 ---------- + + @classmethod + async def _get_record_titles( + cls, db: AsyncSession, table_id: str, record_ids: List[str] + ) -> Dict[str, str]: + """批量获取记录的标题(取第一个字段的值)""" + fields = await SmartFieldService.get_by_table(db, table_id) + primary_field = fields[0] if fields else None + if not primary_field: + return {} + + result = await db.execute( + select(SmartRecord.id, SmartRecord.values).where( + SmartRecord.id.in_(record_ids), + SmartRecord.is_deleted == False, # noqa: E712 + ) + ) + title_map: Dict[str, str] = {} + for rid, vals in result.all(): + title_map[rid] = str((vals or {}).get(primary_field.id, "") or "") + return title_map + + +class SmartSummaryService: + """字段汇总聚合计算 - 合并多字段聚合为尽量少的SQL""" + + SUPPORTED = {"SUM", "AVG", "MIN", "MAX", "COUNT", "COUNTA", "COUNT_EMPTY", "PERCENT_EMPTY", "PERCENT_FILLED"} + + @classmethod + async def compute( + cls, + db: AsyncSession, + table_id: str, + field_agg_map: Dict[str, str], + filters: Optional[List[RecordFilterRule]] = None, + filter_logic: str = "and", + search: Optional[str] = None, + extra_conditions: Optional[list] = None, + ) -> Tuple[Dict[str, Any], int]: + """ + 对指定字段执行聚合计算,将同类聚合合并为单条SQL减少查询次数。 + field_agg_map: {fieldId: aggregation_type} + 返回 ({fieldId: result_value}, total_count) + """ + base = select(SmartRecord).where( + SmartRecord.table_id == table_id, + SmartRecord.is_deleted == False, # noqa: E712 + ) + + if filters: + filter_conds = [SmartRecordService._build_filter_condition(f) for f in filters] + if filter_logic == "or": + base = base.where(or_(*filter_conds)) + else: + base = base.where(and_(*filter_conds)) + + if search and search.strip(): + search_cond = SmartRecordService._build_search_conditions(search.strip()) + if search_cond is not None: + base = base.where(search_cond) + + if extra_conditions: + for cond in extra_conditions: + base = base.where(cond) + + subq = base.subquery() + + count_result = await db.execute(select(func.count()).select_from(subq)) + total = count_result.scalar() or 0 + + if total == 0: + return {fid: None for fid in field_agg_map}, 0 + + summaries: Dict[str, Any] = {} + db_type = get_db_type() + + numeric_fields: Dict[str, str] = {} + count_needed_fields: Dict[str, str] = {} + + for field_id, agg_type in field_agg_map.items(): + agg_upper = agg_type.upper() + if agg_upper not in cls.SUPPORTED: + summaries[field_id] = None + elif agg_upper == "COUNT": + summaries[field_id] = total + elif agg_upper in ("SUM", "AVG", "MIN", "MAX"): + numeric_fields[field_id] = agg_upper + else: + count_needed_fields[field_id] = agg_upper + + alias = subq.alias("sub") + agg_fn_map = {"SUM": func.sum, "AVG": func.avg, "MIN": func.min, "MAX": func.max} + all_columns = [] + all_labels: List[Tuple[str, str, str]] = [] + + for field_id, agg_type in numeric_fields.items(): + if db_type == "postgresql": + num_expr = text(f"CAST(sub.\"values\" ->> '{field_id}' AS NUMERIC)") + cond = text(f"(sub.\"values\" ->> '{field_id}') ~ '^-?[0-9]+(\\.[0-9]+)?$'") + else: + num_expr = text( + f"CAST(JSON_UNQUOTE(JSON_EXTRACT(sub.`values`, '$.{field_id}')) AS DECIMAL(20,6))" + ) + cond = text( + f"JSON_UNQUOTE(JSON_EXTRACT(sub.`values`, '$.{field_id}')) REGEXP '^-?[0-9]+(\\\\.[0-9]+)?$'" + ) + agg_fn = agg_fn_map[agg_type] + label = f"num_{field_id}" + all_columns.append(agg_fn(case((cond, num_expr), else_=None)).label(label)) + all_labels.append((field_id, label, "numeric")) + + unique_count_fields = set(count_needed_fields.keys()) + for field_id in unique_count_fields: + if db_type == "postgresql": + not_null_cond = text( + f"sub.\"values\" ->> '{field_id}' IS NOT NULL " + f"AND sub.\"values\" ->> '{field_id}' != ''" + ) + else: + not_null_cond = text( + f"JSON_EXTRACT(sub.`values`, '$.{field_id}') IS NOT NULL " + f"AND JSON_UNQUOTE(JSON_EXTRACT(sub.`values`, '$.{field_id}')) != ''" + ) + label = f"cnt_{field_id}" + all_columns.append(func.count(case((not_null_cond, 1), else_=None)).label(label)) + all_labels.append((field_id, label, "count")) + + if all_columns: + stmt = select(*all_columns).select_from(alias) + row = (await db.execute(stmt)).first() + if row: + for field_id, label, col_type in all_labels: + val = getattr(row, label, None) + if col_type == "numeric": + summaries[field_id] = round(float(val), 6) if val is not None else None + else: + non_empty = val or 0 + agg_type = count_needed_fields[field_id] + if agg_type == "COUNTA": + summaries[field_id] = non_empty + elif agg_type == "COUNT_EMPTY": + summaries[field_id] = total - non_empty + elif agg_type == "PERCENT_EMPTY": + summaries[field_id] = round((total - non_empty) / total * 100, 1) if total > 0 else 0 + elif agg_type == "PERCENT_FILLED": + summaries[field_id] = round(non_empty / total * 100, 1) if total > 0 else 0 + + return summaries, total + + +class SmartFormulaService: + """公式字段计算服务:在返回记录时动态计算公式字段的值""" + + @classmethod + def resolve_formula_values( + cls, fields: List[SmartField], records: list + ) -> None: + """遍历公式字段,对每条记录计算公式值并注入 record.values(同步,不需要 DB)""" + from zq_smart_table.formula import compute_formula + + formula_fields = [ + f for f in fields + if f.type == "formula" and f.config and f.config.get("formula") + ] + if not formula_fields: + return + + field_name_map = {f.name: f.id for f in fields} + + for record in records: + values = record.values if record.values else {} + for ff in formula_fields: + formula_str = ff.config["formula"] + result = compute_formula(formula_str, values, field_name_map) + result_type = ff.config.get("formulaResultType", "text") + values[ff.id] = cls._cast_result(result, result_type) + record.values = values + + @staticmethod + def _cast_result(value: Any, result_type: str) -> Any: + if value == "#ERROR": + return value + if result_type == "number": + try: + return float(value) if value is not None else None + except (ValueError, TypeError): + return "#ERROR" + if result_type == "boolean": + if isinstance(value, bool): + return value + return bool(value) if value is not None else False + if result_type == "date": + return str(value) if value is not None else None + return str(value) if value is not None else "" + + +class SmartValidationService: + """字段数据校验服务""" + + @classmethod + async def validate_cell( + cls, + db: AsyncSession, + field: SmartField, + value: Any, + record_id: Optional[str] = None, + ) -> Optional[str]: + """ + 校验单个单元格值,返回错误消息或 None(通过)。 + 校验规则存储在 field.config["validation"] 中。 + """ + import re as _re + + config = field.config or {} + validation = config.get("validation") + if not validation: + return None + + if field.required and (value is None or value == "" or value == []): + custom = validation.get("message") + return custom or f"「{field.name}」不能为空" + + if value is None or value == "" or value == []: + return None + + if "min" in validation and validation["min"] is not None: + try: + num_val = float(value) + if num_val < float(validation["min"]): + return validation.get("message") or f"「{field.name}」不能小于 {validation['min']}" + except (ValueError, TypeError): + pass + + if "max" in validation and validation["max"] is not None: + try: + num_val = float(value) + if num_val > float(validation["max"]): + return validation.get("message") or f"「{field.name}」不能大于 {validation['max']}" + except (ValueError, TypeError): + pass + + if "minLength" in validation and validation["minLength"] is not None: + str_val = str(value) + if len(str_val) < int(validation["minLength"]): + return validation.get("message") or f"「{field.name}」长度不能少于 {validation['minLength']} 个字符" + + if "maxLength" in validation and validation["maxLength"] is not None: + str_val = str(value) + if len(str_val) > int(validation["maxLength"]): + return validation.get("message") or f"「{field.name}」长度不能超过 {validation['maxLength']} 个字符" + + if "pattern" in validation and validation["pattern"]: + str_val = str(value) + try: + if not _re.fullmatch(validation["pattern"], str_val): + return validation.get("message") or f"「{field.name}」格式不正确" + except _re.error: + pass + + if validation.get("unique"): + is_dup = await cls._check_unique(db, field, value, record_id) + if is_dup: + return validation.get("message") or f"「{field.name}」的值已存在,不能重复" + + return None + + @classmethod + async def _check_unique( + cls, + db: AsyncSession, + field: SmartField, + value: Any, + exclude_record_id: Optional[str] = None, + ) -> bool: + db_type = get_db_type() + str_val = str(value) + + if db_type == "postgresql": + cond = text(f"\"values\" ->> '{field.id}' = :v") + else: + cond = text(f"JSON_UNQUOTE(JSON_EXTRACT(`values`, '$.{field.id}')) = :v") + + stmt = ( + select(func.count()) + .select_from(SmartRecord.__table__) + .where( + SmartRecord.table_id == field.table_id, + SmartRecord.is_deleted == False, # noqa: E712 + cond.bindparams(v=str_val), + ) + ) + if exclude_record_id: + stmt = stmt.where(SmartRecord.id != exclude_record_id) + + result = await db.execute(stmt) + return (result.scalar() or 0) > 0 + + @classmethod + async def validate_cells_batch( + cls, + db: AsyncSession, + table_id: str, + cells: Dict[str, Any], + record_id: Optional[str] = None, + ) -> Dict[str, str]: + """批量校验多个单元格,返回 {fieldId: errorMessage}""" + fields = await SmartFieldService.get_by_table(db, table_id) + field_map = {f.id: f for f in fields} + errors: Dict[str, str] = {} + + for field_id, value in cells.items(): + field = field_map.get(field_id) + if not field: + continue + err = await cls.validate_cell(db, field, value, record_id) + if err: + errors[field_id] = err + + return errors + + +class SmartCommentService: + """记录评论服务""" + + @classmethod + async def get_by_record(cls, db: AsyncSession, record_id: str) -> List[SmartTableComment]: + result = await db.execute( + select(SmartTableComment) + .where(SmartTableComment.record_id == record_id, SmartTableComment.is_deleted == False) # noqa: E712 + .order_by(SmartTableComment.sys_create_datetime.asc()) + ) + return list(result.scalars().all()) + + @classmethod + async def create(cls, db: AsyncSession, record_id: str, user_id: str, content: str, + mentions: List[str] = None, parent_id: str = None) -> SmartTableComment: + comment = SmartTableComment( + record_id=record_id, + user_id=user_id, + content=content, + mentions=mentions or [], + parent_id=parent_id, + sys_creator_id=user_id, + ) + db.add(comment) + await db.commit() + await db.refresh(comment) + return comment + + @classmethod + async def update(cls, db: AsyncSession, comment_id: str, user_id: str, + content: str, mentions: List[str] = None) -> Optional[SmartTableComment]: + result = await db.execute( + select(SmartTableComment) + .where(SmartTableComment.id == comment_id, SmartTableComment.is_deleted == False) # noqa: E712 + ) + comment = result.scalar_one_or_none() + if not comment or comment.user_id != user_id: + return None + comment.content = content + comment.mentions = mentions or [] + comment.sys_modifier_id = user_id + await db.commit() + await db.refresh(comment) + return comment + + @classmethod + async def delete(cls, db: AsyncSession, comment_id: str, user_id: str) -> bool: + result = await db.execute( + select(SmartTableComment) + .where(SmartTableComment.id == comment_id, SmartTableComment.is_deleted == False) # noqa: E712 + ) + comment = result.scalar_one_or_none() + if not comment or comment.user_id != user_id: + return False + comment.is_deleted = True + comment.sys_modifier_id = user_id + await db.commit() + return True + + +class SmartDocumentVersionService: + """文档版本历史服务""" + + MAX_VERSIONS_PER_DOC = 100 + + @classmethod + async def get_next_version(cls, db: AsyncSession, document_id: str) -> int: + result = await db.execute( + select(func.coalesce(func.max(SmartDocumentVersion.version), 0)) + .where( + SmartDocumentVersion.document_id == document_id, + SmartDocumentVersion.is_deleted == False, # noqa: E712 + ) + ) + return (result.scalar() or 0) + 1 + + @classmethod + async def create_version( + cls, + db: AsyncSession, + document_id: str, + content: Dict[str, Any], + title: Optional[str] = None, + change_summary: Optional[str] = None, + user_id: Optional[str] = None, + ) -> SmartDocumentVersion: + import json + version_num = await cls.get_next_version(db, document_id) + content_size = len(json.dumps(content, ensure_ascii=False)) + + version = SmartDocumentVersion( + document_id=document_id, + version=version_num, + content=content, + title=title, + change_summary=change_summary, + content_size=content_size, + sys_creator_id=user_id, + ) + db.add(version) + await db.commit() + await db.refresh(version) + + await cls._cleanup_old_versions(db, document_id) + return version + + @classmethod + async def _cleanup_old_versions(cls, db: AsyncSession, document_id: str) -> None: + """保留最近 MAX_VERSIONS_PER_DOC 个版本,软删除更早的""" + result = await db.execute( + select(SmartDocumentVersion.id) + .where( + SmartDocumentVersion.document_id == document_id, + SmartDocumentVersion.is_deleted == False, # noqa: E712 + ) + .order_by(SmartDocumentVersion.version.desc()) + .offset(cls.MAX_VERSIONS_PER_DOC) + ) + old_ids = [row[0] for row in result.all()] + if old_ids: + await db.execute( + sa_update(SmartDocumentVersion) + .where(SmartDocumentVersion.id.in_(old_ids)) + .values(is_deleted=True) + ) + await db.commit() + + @classmethod + async def get_versions( + cls, + db: AsyncSession, + document_id: str, + page: int = 1, + page_size: int = 20, + ) -> Tuple[List[SmartDocumentVersion], int]: + base = ( + select(SmartDocumentVersion) + .where( + SmartDocumentVersion.document_id == document_id, + SmartDocumentVersion.is_deleted == False, # noqa: E712 + ) + ) + count_result = await db.execute( + select(func.count()).select_from(base.subquery()) + ) + total = count_result.scalar() or 0 + + result = await db.execute( + base.order_by(SmartDocumentVersion.version.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + return list(result.scalars().all()), total + + @classmethod + async def get_version_detail( + cls, db: AsyncSession, version_id: str + ) -> Optional[SmartDocumentVersion]: + result = await db.execute( + select(SmartDocumentVersion) + .where( + SmartDocumentVersion.id == version_id, + SmartDocumentVersion.is_deleted == False, # noqa: E712 + ) + ) + return result.scalar_one_or_none() + + @classmethod + async def get_version_by_number( + cls, db: AsyncSession, document_id: str, version: int + ) -> Optional[SmartDocumentVersion]: + result = await db.execute( + select(SmartDocumentVersion) + .where( + SmartDocumentVersion.document_id == document_id, + SmartDocumentVersion.version == version, + SmartDocumentVersion.is_deleted == False, # noqa: E712 + ) + ) + return result.scalar_one_or_none() + + @classmethod + async def restore_version( + cls, db: AsyncSession, document_id: str, version_id: str, user_id: Optional[str] = None + ) -> Optional[SmartDocumentVersion]: + """恢复到指定版本:先快照当前内容,再将目标版本内容写回文档""" + version = await cls.get_version_detail(db, version_id) + if not version or version.document_id != document_id: + return None + + table = await db.execute( + select(SmartTable) + .where(SmartTable.id == document_id, SmartTable.is_deleted == False) # noqa: E712 + ) + doc = table.scalar_one_or_none() + if not doc: + return None + + if doc.content: + await cls.create_version( + db, document_id, doc.content, + title=doc.name, + change_summary=f"恢复版本 {version.version} 前的自动快照", + user_id=user_id, + ) + + doc.content = version.content + doc.sys_modifier_id = user_id + await db.commit() + + new_version = await cls.create_version( + db, document_id, version.content, + title=doc.name, + change_summary=f"恢复至版本 {version.version}", + user_id=user_id, + ) + return new_version + + @classmethod + async def delete_version(cls, db: AsyncSession, version_id: str) -> bool: + result = await db.execute( + select(SmartDocumentVersion) + .where( + SmartDocumentVersion.id == version_id, + SmartDocumentVersion.is_deleted == False, # noqa: E712 + ) + ) + version = result.scalar_one_or_none() + if not version: + return False + version.is_deleted = True + await db.commit() + return True + + +class SmartDocumentTemplateService: + """文档模板服务""" + + @classmethod + async def get_list( + cls, + db: AsyncSession, + category: Optional[str] = None, + keyword: Optional[str] = None, + page: int = 1, + page_size: int = 50, + ) -> Tuple[List[SmartDocumentTemplate], int]: + base = select(SmartDocumentTemplate).where( + SmartDocumentTemplate.is_deleted == False # noqa: E712 + ) + if category: + base = base.where(SmartDocumentTemplate.category == category) + if keyword: + base = base.where( + or_( + SmartDocumentTemplate.name.ilike(f"%{keyword}%"), + SmartDocumentTemplate.description.ilike(f"%{keyword}%"), + ) + ) + + count_result = await db.execute( + select(func.count()).select_from(base.subquery()) + ) + total = count_result.scalar() or 0 + + result = await db.execute( + base.order_by( + SmartDocumentTemplate.is_system.desc(), + SmartDocumentTemplate.use_count.desc(), + SmartDocumentTemplate.sys_create_datetime.desc(), + ) + .offset((page - 1) * page_size) + .limit(page_size) + ) + return list(result.scalars().all()), total + + @classmethod + async def get_by_id( + cls, db: AsyncSession, template_id: str + ) -> Optional[SmartDocumentTemplate]: + result = await db.execute( + select(SmartDocumentTemplate) + .where( + SmartDocumentTemplate.id == template_id, + SmartDocumentTemplate.is_deleted == False, # noqa: E712 + ) + ) + return result.scalar_one_or_none() + + @classmethod + async def create( + cls, + db: AsyncSession, + name: str, + content: Dict[str, Any], + description: Optional[str] = None, + icon: str = "FileText", + category: str = "custom", + preview_image: Optional[str] = None, + user_id: Optional[str] = None, + ) -> SmartDocumentTemplate: + template = SmartDocumentTemplate( + name=name, + description=description, + icon=icon, + category=category, + content=content, + preview_image=preview_image, + is_system=False, + sys_creator_id=user_id, + ) + db.add(template) + await db.commit() + await db.refresh(template) + return template + + @classmethod + async def update( + cls, + db: AsyncSession, + template_id: str, + data: Dict[str, Any], + user_id: Optional[str] = None, + ) -> Optional[SmartDocumentTemplate]: + template = await cls.get_by_id(db, template_id) + if not template: + return None + for key, value in data.items(): + if value is not None and hasattr(template, key): + setattr(template, key, value) + template.sys_modifier_id = user_id + await db.commit() + await db.refresh(template) + return template + + @classmethod + async def delete(cls, db: AsyncSession, template_id: str) -> bool: + template = await cls.get_by_id(db, template_id) + if not template: + return False + if template.is_system: + return False + template.is_deleted = True + await db.commit() + return True + + @classmethod + async def increment_use_count(cls, db: AsyncSession, template_id: str) -> None: + await db.execute( + sa_update(SmartDocumentTemplate) + .where(SmartDocumentTemplate.id == template_id) + .values(use_count=SmartDocumentTemplate.use_count + 1) + ) + await db.commit() + + @classmethod + async def get_categories(cls, db: AsyncSession) -> List[str]: + result = await db.execute( + select(SmartDocumentTemplate.category) + .where(SmartDocumentTemplate.is_deleted == False) # noqa: E712 + .distinct() + ) + return [row[0] for row in result.all()] + + @classmethod + async def create_from_document( + cls, + db: AsyncSession, + document_id: str, + name: str, + description: Optional[str] = None, + category: str = "custom", + user_id: Optional[str] = None, + ) -> Optional[SmartDocumentTemplate]: + """从现有文档创建模板""" + doc_result = await db.execute( + select(SmartTable) + .where(SmartTable.id == document_id, SmartTable.is_deleted == False) # noqa: E712 + ) + doc = doc_result.scalar_one_or_none() + if not doc or getattr(doc, "type", "table") != "document" or not doc.content: + return None + + return await cls.create( + db, name=name, content=doc.content, + description=description, category=category, user_id=user_id, + ) + + +class WikiSpaceService(BaseService[SmartWikiSpace, WikiSpaceCreate, WikiSpaceUpdate]): + model = SmartWikiSpace + RESOURCE_TYPE = "wiki_space" + + @classmethod + async def get_list_with_count( + cls, db: AsyncSession, page: int = 1, page_size: int = 100, + user_id: Optional[str] = None, + is_superuser: bool = False, + ) -> Tuple[List[dict], int]: + """获取文档库列表,附带每个库的文档数量。非超管只能看到自己创建的或 public/team 的""" + base_query = select(SmartWikiSpace).where(SmartWikiSpace.is_deleted == False) # noqa: E712 + + if not is_superuser and user_id: + base_query = base_query.where( + or_( + SmartWikiSpace.sys_creator_id == user_id, + SmartWikiSpace.visibility.in_(["public", "team"]), + ) + ) + elif not is_superuser: + base_query = base_query.where( + SmartWikiSpace.visibility.in_(["public", "team"]) + ) + + count_result = await db.execute( + select(func.count()).select_from(base_query.subquery()) + ) + total = count_result.scalar() or 0 + + offset = (page - 1) * page_size + spaces_result = await db.execute( + base_query.order_by(desc(SmartWikiSpace.sort), desc(SmartWikiSpace.sys_create_datetime)) + .offset(offset) + .limit(page_size) + ) + spaces = list(spaces_result.scalars().all()) + + result = [] + for space in spaces: + count_q = await db.execute( + select(func.count(SmartTable.id)) + .where( + SmartTable.wiki_space_id == space.id, + SmartTable.is_deleted == False, # noqa: E712 + ) + ) + doc_count = count_q.scalar() or 0 + result.append({ + "space": space, + "document_count": doc_count, + }) + return result, total + + @classmethod + async def get_documents( + cls, db: AsyncSession, space_id: str, + user_id: Optional[str] = None, + dept_id: Optional[str] = None, + role_ids: Optional[List[str]] = None, + is_superuser: bool = False, + ) -> List[SmartTable]: + """获取文档库内的文档(按 sort 排序),非超管只能看到自己有权限的""" + from zq_smart_table.permission.model import SmartTableCollaborator + + base_query = ( + select(SmartTable) + .where( + SmartTable.wiki_space_id == space_id, + SmartTable.is_deleted == False, # noqa: E712 + ) + ) + + if not is_superuser and user_id: + subject_conds = [ + and_( + SmartTableCollaborator.subject_type == "user", + SmartTableCollaborator.subject_id == user_id, + ) + ] + if dept_id: + subject_conds.append( + and_( + SmartTableCollaborator.subject_type == "dept", + SmartTableCollaborator.subject_id == dept_id, + ) + ) + if role_ids: + for rid in role_ids: + subject_conds.append( + and_( + SmartTableCollaborator.subject_type == "role", + SmartTableCollaborator.subject_id == rid, + ) + ) + + collab_table_ids = ( + select(SmartTableCollaborator.table_id) + .where( + SmartTableCollaborator.is_deleted == False, # noqa: E712 + or_(*subject_conds), + ) + .distinct() + ) + + base_query = base_query.where( + or_( + SmartTable.sys_creator_id == user_id, + SmartTable.id.in_(collab_table_ids), + ) + ) + + result = await db.execute( + base_query.order_by(SmartTable.sort, SmartTable.sys_create_datetime) + ) + return list(result.scalars().all()) + + @classmethod + async def check_space_access( + cls, space: SmartWikiSpace, + user_id: Optional[str] = None, + is_superuser: bool = False, + ) -> bool: + """检查用户是否有权访问该文档库""" + if is_superuser: + return True + if space.visibility in ("public", "team"): + return True + if user_id and space.sys_creator_id == user_id: + return True + return False + + @classmethod + async def add_document( + cls, + db: AsyncSession, + space_id: str, + name: str, + parent_id: Optional[str] = None, + content: Optional[dict] = None, + user_id: Optional[str] = None, + ) -> SmartTable: + """在文档库中创建文档""" + max_sort_q = await db.execute( + select(func.coalesce(func.max(SmartTable.sort), 0)) + .where( + SmartTable.wiki_space_id == space_id, + SmartTable.is_deleted == False, # noqa: E712 + ) + ) + next_sort = (max_sort_q.scalar() or 0) + 1 + + doc = SmartTable( + name=name, + icon="FileText", + type="document", + wiki_space_id=space_id, + parent_id=parent_id, + content=content, + sort=next_sort, + ) + if user_id: + doc.sys_creator_id = user_id + doc.sys_modifier_id = user_id + db.add(doc) + await db.commit() + await db.refresh(doc) + return doc + + @classmethod + async def delete_with_documents(cls, db: AsyncSession, space_id: str) -> bool: + """软删除文档库及其所有文档""" + space = await cls.get_by_id(db, space_id) + if not space: + return False + + await db.execute( + sa_update(SmartTable) + .where( + SmartTable.wiki_space_id == space_id, + SmartTable.is_deleted == False, # noqa: E712 + ) + .values(is_deleted=True) + ) + + await cls.delete(db, space_id) + return True diff --git a/web/apps/web-ele/.env.development b/web/apps/web-ele/.env.development index aecd79a..ee383f3 100644 --- a/web/apps/web-ele/.env.development +++ b/web/apps/web-ele/.env.development @@ -16,5 +16,3 @@ VITE_DEVTOOLS=false # 是否注入全局loading VITE_INJECT_APP_LOADING=true -VITE_ENABLE_ONLINE_DEV_DESIGN=false -VITE_ENABLE_DASHBOARD_ADVANCED_WIDGETS=false diff --git a/web/apps/web-ele/.env.production b/web/apps/web-ele/.env.production index 55045f9..a5b255e 100644 --- a/web/apps/web-ele/.env.production +++ b/web/apps/web-ele/.env.production @@ -7,5 +7,3 @@ VITE_PWA=false VITE_ROUTER_HISTORY=history VITE_INJECT_APP_LOADING=true VITE_ARCHIVER=false -VITE_ENABLE_ONLINE_DEV_DESIGN=false -VITE_ENABLE_DASHBOARD_ADVANCED_WIDGETS=false diff --git a/web/apps/web-ele/src/api/smart-table.ts b/web/apps/web-ele/src/api/smart-table.ts new file mode 100644 index 0000000..887c15a --- /dev/null +++ b/web/apps/web-ele/src/api/smart-table.ts @@ -0,0 +1,675 @@ +import { requestClient } from '#/api/request'; + +const BASE = '/api/smart-table'; + +// ==================== Types ==================== + +export interface SmartTableItem { + id: string; + name: string; + icon: string; + type?: string; + parent_id?: string | null; + sort?: number; + description?: string; + active_view_id?: string; + sys_create_datetime?: string; + sys_update_datetime?: string; +} + +export interface SmartFieldItem { + id: string; + table_id: string; + name: string; + type: string; + width: number; + visible: boolean; + required: boolean; + description?: string; + config: Record; + sort: number; + sys_create_datetime?: string; +} + +export interface SmartRecordItem { + id: string; + table_id: string; + values: Record; + sys_create_datetime?: string; + sys_update_datetime?: string; + sys_creator_id?: string; + sys_modifier_id?: string; +} + +export interface SmartViewItem { + id: string; + table_id: string; + name: string; + type: string; + config: Record; + sort: number; + sys_create_datetime?: string; +} + +export interface SmartTableFull extends SmartTableItem { + type?: string; + content?: any; + fields: SmartFieldItem[]; + records: SmartRecordItem[]; + views: SmartViewItem[]; + record_total: number; + next_cursor: string | null; + has_more: boolean; + sys_creator_id?: string; + creator_name?: string; + creator_avatar?: string; +} + +export interface CursorPaginatedRecords { + items: SmartRecordItem[]; + total: number; + next_cursor: string | null; + has_more: boolean; +} + +export interface RecordFilterParam { + field_id: string; + operator: string; + value?: any; +} + +export interface RecordSortParam { + field_id: string; + direction: 'asc' | 'desc'; +} + +export interface RecordQueryParam { + filters?: RecordFilterParam[]; + filter_logic?: 'and' | 'or'; + sorts?: RecordSortParam[]; + search?: string; + search_field_ids?: string[]; + group_field_id?: string; + cursor?: string | null; + limit?: number; +} + +export interface RecordGroupItem { + key: string; + label: string; + records: SmartRecordItem[]; +} + +export interface GroupedRecordsResponse { + groups: RecordGroupItem[]; + total: number; +} + +// ==================== Table API ==================== + +export function getTableListApi(wikiSpaceId?: string | null) { + const params: Record = {}; + if (wikiSpaceId) params.wiki_space_id = wikiSpaceId; + return requestClient.get(`${BASE}/tables`, { params }); +} + +export function getTableFullApi( + tableId: string, + opts?: { filters?: RecordFilterParam[]; sorts?: RecordSortParam[]; search?: string; filter_logic?: string }, +) { + const params: Record = {}; + if (opts?.filters?.length) params.filters = JSON.stringify(opts.filters); + if (opts?.sorts?.length) params.sorts = JSON.stringify(opts.sorts); + if (opts?.search) params.search = opts.search; + if (opts?.filter_logic) params.filter_logic = opts.filter_logic; + return requestClient.get(`${BASE}/tables/${tableId}/full`, { params }); +} + +export function createTableApi(data: { name: string; icon?: string; description?: string; type?: string; content?: any; parent_id?: string | null; wiki_space_id?: string | null }) { + return requestClient.post(`${BASE}/tables`, data); +} + +export function updateTableApi(tableId: string, data: Partial) { + return requestClient.put(`${BASE}/tables/${tableId}`, data); +} + +export function updateDocumentContentApi(tableId: string, content: any) { + return requestClient.patch(`${BASE}/tables/${tableId}/content`, { content }); +} + +export function exportDocumentPdfApi(tableId: string, html: string, title: string) { + return requestClient.post(`${BASE}/tables/${tableId}/export-pdf`, { html, title }, { + responseType: 'blob', + }); +} + +export function deleteTableApi(tableId: string) { + return requestClient.delete(`${BASE}/tables/${tableId}`); +} + +export function moveTableApi(tableId: string, parentId: string | null, afterId?: string | null) { + return requestClient.put(`${BASE}/tables/${tableId}/move`, { + parent_id: parentId, + after_id: afterId ?? null, + }); +} + +// ==================== Field API ==================== + +export function getFieldListApi(tableId: string) { + return requestClient.get(`${BASE}/tables/${tableId}/fields`); +} + +export function createFieldApi(tableId: string, data: Omit) { + return requestClient.post(`${BASE}/tables/${tableId}/fields`, data); +} + +export function updateFieldApi(fieldId: string, data: Partial) { + return requestClient.put(`${BASE}/fields/${fieldId}`, data); +} + +export function deleteFieldApi(fieldId: string) { + return requestClient.delete(`${BASE}/fields/${fieldId}`); +} + +export function reorderFieldsApi(tableId: string, fieldIds: string[]) { + return requestClient.put(`${BASE}/tables/${tableId}/fields/reorder`, { field_ids: fieldIds }); +} + +// ==================== Record API ==================== + +export function getRecordListApi( + tableId: string, + cursor?: string | null, + limit = 200, + opts?: { filters?: RecordFilterParam[]; sorts?: RecordSortParam[]; search?: string; filter_logic?: string }, +) { + const params: Record = { cursor: cursor ?? undefined, limit }; + if (opts?.filters?.length) params.filters = JSON.stringify(opts.filters); + if (opts?.sorts?.length) params.sorts = JSON.stringify(opts.sorts); + if (opts?.search) params.search = opts.search; + if (opts?.filter_logic) params.filter_logic = opts.filter_logic; + return requestClient.get( + `${BASE}/tables/${tableId}/records`, + { params }, + ); +} + +export function queryRecordsApi(tableId: string, query: RecordQueryParam) { + return requestClient.post( + `${BASE}/tables/${tableId}/records/query`, + query, + ); +} + +export function reorderRecordsApi(tableId: string, recordIds: string[]) { + return requestClient.put(`${BASE}/tables/${tableId}/records/reorder`, { record_ids: recordIds }); +} + +export function createRecordApi(tableId: string, values: Record = {}) { + return requestClient.post(`${BASE}/tables/${tableId}/records`, { table_id: tableId, values }); +} + +export function updateRecordApi(recordId: string, values: Record) { + return requestClient.put(`${BASE}/records/${recordId}`, { values }); +} + +export function updateCellApi(recordId: string, fieldId: string, value: any) { + return requestClient.patch(`${BASE}/records/${recordId}/cells`, { + field_id: fieldId, + value, + }); +} + +export function batchUpdateCellsApi(recordId: string, cells: Record) { + return requestClient.patch(`${BASE}/records/${recordId}/cells/batch`, { cells }); +} + +export function batchUpdateMultiRecordCellsApi( + tableId: string, + updates: Array<{ record_id: string; cells: Record }>, +) { + return requestClient.patch(`${BASE}/tables/${tableId}/records/batch-cells`, { updates }); +} + +export function deleteRecordApi(recordId: string) { + return requestClient.delete(`${BASE}/records/${recordId}`); +} + +export function batchDeleteRecordsApi(tableId: string, ids: string[]) { + return requestClient.post(`${BASE}/tables/${tableId}/records/batch-delete`, { ids }); +} + +// ==================== Trash / Recycle Bin ==================== + +export interface TrashRecordItem { + id: string; + table_id: string; + values: Record; + sys_create_datetime?: string; + sys_update_datetime?: string; + sys_creator_id?: string; +} + +export function getTrashRecordsApi(tableId: string, page = 1, pageSize = 50) { + return requestClient.get<{ items: TrashRecordItem[]; total: number }>( + `${BASE}/tables/${tableId}/trash`, + { params: { page, page_size: pageSize } }, + ); +} + +export function restoreTrashRecordsApi(tableId: string, ids: string[]) { + return requestClient.post(`${BASE}/tables/${tableId}/trash/restore`, { ids }); +} + +export function permanentDeleteTrashRecordApi(tableId: string, recordId: string) { + return requestClient.delete(`${BASE}/tables/${tableId}/trash/${recordId}`); +} + +export function emptyTrashApi(tableId: string) { + return requestClient.delete(`${BASE}/tables/${tableId}/trash`); +} + +export function exportTableApi(tableId: string, format: 'csv' | 'xlsx' = 'csv') { + return requestClient.get(`${BASE}/tables/${tableId}/export`, { + params: { format }, + responseType: 'blob', + }); +} + +export function importTableApi(tableId: string, file: File) { + const formData = new FormData(); + formData.append('file', file); + return requestClient.post(`${BASE}/tables/${tableId}/import`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); +} + +export interface RecordSearchResultItem { + id: string; + title: string; +} + +export function searchRecordsApi(tableId: string, keyword: string = '', limit: number = 20) { + return requestClient.post( + `${BASE}/tables/${tableId}/records/search`, + { keyword, limit }, + ); +} + +// ==================== Summary API ==================== + +export interface SummaryResult { + summaries: Record; + total_count: number; +} + +export function getSummaryApi( + tableId: string, + data: { + aggregations: Record; + filters?: RecordFilterParam[]; + filter_logic?: string; + search?: string; + }, +) { + return requestClient.post(`${BASE}/tables/${tableId}/summary`, data); +} + +// ==================== Comment API ==================== + +export interface CommentItem { + id: string; + record_id: string; + user_id: string; + content: string; + mentions: string[]; + parent_id: string | null; + sys_create_datetime?: string; + sys_update_datetime?: string; + user_name?: string; + user_avatar?: string; + replies: CommentItem[]; +} + +export function getCommentsApi(recordId: string) { + return requestClient.get(`${BASE}/records/${recordId}/comments`); +} + +export function createCommentApi(recordId: string, data: { content: string; mentions?: string[]; parent_id?: string }) { + return requestClient.post(`${BASE}/records/${recordId}/comments`, data); +} + +export function updateCommentApi(commentId: string, data: { content: string; mentions?: string[] }) { + return requestClient.put(`${BASE}/comments/${commentId}`, data); +} + +export function deleteCommentApi(commentId: string) { + return requestClient.delete(`${BASE}/comments/${commentId}`); +} + +// ==================== View API ==================== + +export function getViewListApi(tableId: string) { + return requestClient.get(`${BASE}/tables/${tableId}/views`); +} + +export function createViewApi(tableId: string, data: { name: string; type: string; config?: Record }) { + return requestClient.post(`${BASE}/tables/${tableId}/views`, { ...data, table_id: tableId }); +} + +export function updateViewApi(viewId: string, data: Partial) { + return requestClient.put(`${BASE}/views/${viewId}`, data); +} + +export function deleteViewApi(viewId: string) { + return requestClient.delete(`${BASE}/views/${viewId}`); +} + +// ==================== Permission API ==================== + +export interface MyPermission { + role_type: string; + role_name: string; + capabilities: Record; + field_permissions: Record; + row_view_mode: string; + row_edit_mode: string; +} + +export interface TableRole { + id: string; + table_id: string | null; + name: string; + role_type: string; + capabilities: Record; + is_system: boolean; + sys_create_datetime?: string; +} + +export interface Collaborator { + id: string; + table_id: string; + subject_type: string; + subject_id: string; + role_id: string; + role_name?: string; + role_type?: string; + subject_name?: string; + subject_avatar?: string; + sys_create_datetime?: string; +} + +export interface FieldPermItem { + field_id: string; + access: string; +} + +export interface FieldPermMatrix { + role_id: string; + role_name: string; + role_type: string; + fields: FieldPermItem[]; +} + +export interface RowRule { + id: string; + table_id: string; + role_id: string; + rule_type: string; + mode: string; + conditions: Record[]; +} + +export function getMyPermissionApi(tableId: string) { + return requestClient.get(`${BASE}/tables/${tableId}/my-permission`); +} + +export function getRolesApi(tableId: string) { + return requestClient.get(`${BASE}/tables/${tableId}/roles`); +} + +export function createRoleApi(tableId: string, data: { name: string; capabilities: Record }) { + return requestClient.post(`${BASE}/tables/${tableId}/roles`, data); +} + +export function updateRoleApi(tableId: string, roleId: string, data: { name?: string; capabilities?: Record }) { + return requestClient.put(`${BASE}/tables/${tableId}/roles/${roleId}`, data); +} + +export function deleteRoleApi(tableId: string, roleId: string) { + return requestClient.delete(`${BASE}/tables/${tableId}/roles/${roleId}`); +} + +export function getCollaboratorsApi(tableId: string) { + return requestClient.get(`${BASE}/tables/${tableId}/collaborators`); +} + +export function addCollaboratorApi(tableId: string, data: { subject_type: string; subject_id: string; role_id: string }) { + return requestClient.post(`${BASE}/tables/${tableId}/collaborators`, data); +} + +export function updateCollaboratorApi(tableId: string, collabId: string, data: { role_id: string }) { + return requestClient.put(`${BASE}/tables/${tableId}/collaborators/${collabId}`, data); +} + +export function removeCollaboratorApi(tableId: string, collabId: string) { + return requestClient.delete(`${BASE}/tables/${tableId}/collaborators/${collabId}`); +} + +export function getFieldPermissionsApi(tableId: string) { + return requestClient.get(`${BASE}/tables/${tableId}/field-permissions`); +} + +export function updateFieldPermissionsApi(tableId: string, data: { role_id: string; permissions: FieldPermItem[] }) { + return requestClient.put(`${BASE}/tables/${tableId}/field-permissions`, data); +} + +export function getRowRulesApi(tableId: string) { + return requestClient.get(`${BASE}/tables/${tableId}/row-rules`); +} + +export function updateRowRuleApi(tableId: string, data: { role_id: string; rule_type: string; mode: string; conditions: Record[] }) { + return requestClient.put(`${BASE}/tables/${tableId}/row-rules`, data); +} + +// ==================== Document Version API ==================== + +export interface DocumentVersionItem { + id: string; + document_id: string; + version: number; + title?: string; + change_summary?: string; + content_size: number; + sys_create_datetime?: string; + sys_creator_id?: string; + creator_name?: string; + creator_avatar?: string; +} + +export interface DocumentVersionDetail extends DocumentVersionItem { + content: Record; +} + +export interface DocumentVersionCompare { + version_from: DocumentVersionDetail; + version_to: DocumentVersionDetail; +} + +export function getDocumentVersionsApi(tableId: string, page = 1, pageSize = 20) { + return requestClient.get<{ items: DocumentVersionItem[]; total: number }>( + `${BASE}/tables/${tableId}/versions`, + { params: { page, pageSize } }, + ); +} + +export function getDocumentVersionDetailApi(versionId: string) { + return requestClient.get(`${BASE}/versions/${versionId}`); +} + +export function createDocumentVersionApi(tableId: string, changeSummary?: string) { + return requestClient.post( + `${BASE}/tables/${tableId}/versions`, + { change_summary: changeSummary }, + ); +} + +export function restoreDocumentVersionApi(tableId: string, versionId: string) { + return requestClient.post(`${BASE}/tables/${tableId}/versions/${versionId}/restore`); +} + +export function compareDocumentVersionsApi(tableId: string, fromId: string, toId: string) { + return requestClient.get( + `${BASE}/tables/${tableId}/versions/compare`, + { params: { from: fromId, to: toId } }, + ); +} + +export function deleteDocumentVersionApi(versionId: string) { + return requestClient.delete(`${BASE}/versions/${versionId}`); +} + +// ==================== Document Template API ==================== + +export interface DocumentTemplateItem { + id: string; + name: string; + description?: string; + icon: string; + category: string; + preview_image?: string; + is_system: boolean; + use_count: number; + sys_create_datetime?: string; + sys_update_datetime?: string; + sys_creator_id?: string; + creator_name?: string; +} + +export interface DocumentTemplateDetail extends DocumentTemplateItem { + content: Record; +} + +export function getDocumentTemplatesApi(params?: { category?: string; keyword?: string; page?: number; pageSize?: number }) { + return requestClient.get<{ items: DocumentTemplateItem[]; total: number }>( + `${BASE}/document-templates`, + { params }, + ); +} + +export function getDocumentTemplateCategoriesApi() { + return requestClient.get(`${BASE}/document-templates/categories`); +} + +export function getDocumentTemplateDetailApi(templateId: string) { + return requestClient.get(`${BASE}/document-templates/${templateId}`); +} + +export function createDocumentTemplateApi(data: { + name: string; + description?: string; + icon?: string; + category?: string; + content: Record; + preview_image?: string; +}) { + return requestClient.post(`${BASE}/document-templates`, data); +} + +export function createTemplateFromDocumentApi(documentId: string, data: { + name: string; + description?: string; + category?: string; + content: Record; +}) { + return requestClient.post( + `${BASE}/document-templates/from-document/${documentId}`, + data, + ); +} + +export function updateDocumentTemplateApi(templateId: string, data: { + name?: string; + description?: string; + icon?: string; + category?: string; + content?: Record; + preview_image?: string; +}) { + return requestClient.put(`${BASE}/document-templates/${templateId}`, data); +} + +export function deleteDocumentTemplateApi(templateId: string) { + return requestClient.delete(`${BASE}/document-templates/${templateId}`); +} + +export function useDocumentTemplateApi(templateId: string) { + return requestClient.post(`${BASE}/document-templates/${templateId}/use`); +} + +// ==================== Wiki Space API ==================== + +export interface WikiSpaceItem { + id: string; + name: string; + icon: string; + avatar?: string | null; + description?: string; + cover?: string; + category: string; + visibility: string; + sort: number; + document_count?: number; + sys_create_datetime?: string; + sys_update_datetime?: string; + sys_creator_id?: string; + creator_name?: string; +} + +export interface WikiSpaceDetail extends WikiSpaceItem { + documents: SmartTableItem[]; +} + +export function getWikiSpacesApi() { + return requestClient.get(`${BASE}/wiki-spaces`); +} + +export function getWikiSpaceDetailApi(spaceId: string) { + return requestClient.get(`${BASE}/wiki-spaces/${spaceId}`); +} + +export function createWikiSpaceApi(data: { + name: string; + icon?: string; + avatar?: string | null; + description?: string; + cover?: string; + category?: string; + visibility?: string; +}) { + return requestClient.post(`${BASE}/wiki-spaces`, data); +} + +export function updateWikiSpaceApi(spaceId: string, data: Partial) { + return requestClient.put(`${BASE}/wiki-spaces/${spaceId}`, data); +} + +export function deleteWikiSpaceApi(spaceId: string) { + return requestClient.delete(`${BASE}/wiki-spaces/${spaceId}`); +} + +export function getWikiSpaceDocumentsApi(spaceId: string) { + return requestClient.get(`${BASE}/wiki-spaces/${spaceId}/documents`); +} + +export function createWikiDocumentApi(spaceId: string, data: { + name: string; + parent_id?: string | null; + content?: any; +}) { + return requestClient.post( + `${BASE}/wiki-spaces/${spaceId}/documents`, + { ...data, type: 'document', icon: 'FileText' }, + ); +} diff --git a/web/apps/web-ele/src/components/ai-chat-panel/AiChatPanel.vue b/web/apps/web-ele/src/components/ai-chat-panel/AiChatPanel.vue index 4680d96..f1458d2 100644 --- a/web/apps/web-ele/src/components/ai-chat-panel/AiChatPanel.vue +++ b/web/apps/web-ele/src/components/ai-chat-panel/AiChatPanel.vue @@ -35,44 +35,27 @@ import { ChatBox } from '#/components/ChatBox'; import { useChatApi } from './composables/useChatApi'; import { useEventHandler } from './composables/useEventHandler'; -const ENABLE_ONLINE_DEV_DESIGN = - import.meta.env.VITE_ENABLE_ONLINE_DEV_DESIGN === 'true'; - -const AppDesignPanel = ENABLE_ONLINE_DEV_DESIGN - ? defineAsyncComponent(() => - import('#/components/form-editor/AppDesignPanel.vue'), - ) - : undefined; -const AppSettingsPanel = ENABLE_ONLINE_DEV_DESIGN - ? defineAsyncComponent(() => - import('#/components/form-editor/AppSettingsPanel.vue'), - ) - : undefined; -const DashboardBasicInfoConfirmPanel = ENABLE_ONLINE_DEV_DESIGN - ? defineAsyncComponent(() => - import('#/components/form-editor/DashboardBasicInfoConfirmPanel.vue'), - ) - : undefined; -const DashboardDesignConfirmPanel = ENABLE_ONLINE_DEV_DESIGN - ? defineAsyncComponent(() => - import('#/components/form-editor/DashboardDesignConfirmPanel.vue'), - ) - : undefined; -const DashboardPublishConfirmPanel = ENABLE_ONLINE_DEV_DESIGN - ? defineAsyncComponent(() => - import('#/components/form-editor/DashboardPublishConfirmPanel.vue'), - ) - : undefined; -const DesignEditorPanel = ENABLE_ONLINE_DEV_DESIGN - ? defineAsyncComponent(() => - import('#/components/form-editor/DesignEditorPanel.vue'), - ) - : undefined; -const SystemSummaryConfirmPanel = ENABLE_ONLINE_DEV_DESIGN - ? defineAsyncComponent(() => - import('#/components/form-editor/SystemSummaryConfirmPanel.vue'), - ) - : undefined; +const AppDesignPanel = defineAsyncComponent(() => + import('#/components/form-editor/AppDesignPanel.vue'), +); +const AppSettingsPanel = defineAsyncComponent(() => + import('#/components/form-editor/AppSettingsPanel.vue'), +); +const DashboardBasicInfoConfirmPanel = defineAsyncComponent(() => + import('#/components/form-editor/DashboardBasicInfoConfirmPanel.vue'), +); +const DashboardDesignConfirmPanel = defineAsyncComponent(() => + import('#/components/form-editor/DashboardDesignConfirmPanel.vue'), +); +const DashboardPublishConfirmPanel = defineAsyncComponent(() => + import('#/components/form-editor/DashboardPublishConfirmPanel.vue'), +); +const DesignEditorPanel = defineAsyncComponent(() => + import('#/components/form-editor/DesignEditorPanel.vue'), +); +const SystemSummaryConfirmPanel = defineAsyncComponent(() => + import('#/components/form-editor/SystemSummaryConfirmPanel.vue'), +); // ==================== Props ==================== const props = withDefaults(defineProps(), { @@ -252,14 +235,13 @@ const loadingAnimationTitle = ref('AI 正在开发...'); // 是否有任何设计面板打开 const hasAnyDesignPanelOpen = computed( () => - (ENABLE_ONLINE_DEV_DESIGN && - (showDesignPanel.value || - showAppDesignPanel.value || - showAppSettingsPanel.value || - showDashboardBasicInfoPanel.value || - showDashboardDesignPanel.value || - showDashboardPublishPanel.value || - showSystemSummaryPanel.value)) || + showDesignPanel.value || + showAppDesignPanel.value || + showAppSettingsPanel.value || + showDashboardBasicInfoPanel.value || + showDashboardDesignPanel.value || + showDashboardPublishPanel.value || + showSystemSummaryPanel.value || showLoadingAnimation.value, ); @@ -308,8 +290,6 @@ const handleDesignPreview = (data: DesignPreviewData) => { // 关闭加载动画 showLoadingAnimation.value = false; - if (!ENABLE_ONLINE_DEV_DESIGN) return; - switch (data.type) { case 'app_design': { currentAppDesign.value = data; @@ -363,7 +343,7 @@ const { handleStreamEvent, updateAssistantMessage } = useEventHandler({ waitingConfig, running, conversationId: safeConversationId, - onDesignPreview: ENABLE_ONLINE_DEV_DESIGN ? handleDesignPreview : undefined, + onDesignPreview: handleDesignPreview, }); // ==================== 加载历史消息 ==================== @@ -482,7 +462,6 @@ const runMessageWithExistingUserMsg = ( ) => { // 如果是 application 类型工作流且是全屏布局,显示加载动画 if ( - ENABLE_ONLINE_DEV_DESIGN && props.layout === 'fullscreen' && props.agent?.workflow_type === 'application' ) { @@ -554,7 +533,6 @@ const runMessage = ( // 如果是 application 类型工作流且是全屏布局,显示加载动画 if ( - ENABLE_ONLINE_DEV_DESIGN && props.layout === 'fullscreen' && props.agent?.workflow_type === 'application' ) { @@ -651,7 +629,6 @@ const handleInteractionSubmit = (messageId: string, value: any) => { // 全屏布局且是 application 类型工作流时,显示加载动画 if ( - ENABLE_ONLINE_DEV_DESIGN && props.layout === 'fullscreen' && props.agent?.workflow_type === 'application' ) { @@ -690,7 +667,6 @@ const handleInteractionCancel = (messageId: string) => { // 全屏布局且是 application 类型工作流时,显示加载动画 if ( - ENABLE_ONLINE_DEV_DESIGN && props.layout === 'fullscreen' && props.agent?.workflow_type === 'application' ) { @@ -802,7 +778,6 @@ const createDesignConfirmHandler = ( // 全屏布局且是 application 类型工作流时,显示加载动画 if ( - ENABLE_ONLINE_DEV_DESIGN && props.layout === 'fullscreen' && props.agent?.workflow_type === 'application' ) { @@ -1044,7 +1019,7 @@ onUnmounted(() => { { { { { { { {
-