feat: restore source parity and harden agent runtime

This commit is contained in:
2026-06-22 11:17:26 +08:00
parent e33f08277b
commit 0793eb82d6
596 changed files with 168879 additions and 290 deletions
@@ -0,0 +1,5 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
文档生成模块
"""
@@ -0,0 +1,819 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
文档生成API接口
"""
from typing import Optional, List
from datetime import datetime
import json
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
import io
from app.database import get_db
from app.config import settings
from app.base_schema import PaginatedResponse, ResponseModel
from online_dev.document_generator.schema import (
DocumentTemplateCreate, DocumentTemplateUpdate, DocumentTemplateOut, DocumentTemplateListOut,
DocumentTemplateImportCheckIn, DocumentTemplateImportCheckOut, DocumentTemplateImportIn,
GeneratedDocumentOut, GenerateDocumentIn, BatchGenerateDocumentIn, PreviewDocumentIn,
TemplateCategory,
)
from online_dev.document_generator.service import (
DocumentTemplateService, GeneratedDocumentService
)
from online_dev.document_generator.generator import pdf_generator
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/document-generator", tags=["文档生成"])
# ==================== 文档模板 ====================
@router.get("/templates", response_model=PaginatedResponse[DocumentTemplateListOut], summary="获取模板列表")
async def get_template_list(
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=settings.PAGE_SIZE, ge=1, le=settings.PAGE_MAX_SIZE, alias="pageSize"),
application_id: Optional[str] = Query(default=None, alias="applicationId", description="应用ID"),
category: Optional[str] = Query(default=None, description="分类"),
status: Optional[str] = Query(default=None, description="状态"),
form_code: Optional[str] = Query(default=None, alias="formCode", description="表单编码"),
workflow_code: Optional[str] = Query(default=None, alias="workflowCode", description="流程编码"),
keyword: Optional[str] = Query(default=None, description="关键词"),
is_builtin: Optional[bool] = Query(default=None, alias="isBuiltin", description="是否内置"),
db: AsyncSession = Depends(get_db),
):
"""获取文档模板列表"""
items, total = await DocumentTemplateService.get_list(
db,
page=page,
page_size=page_size,
application_id=application_id,
category=category,
status=status,
form_code=form_code,
workflow_code=workflow_code,
keyword=keyword,
is_builtin=is_builtin,
)
return PaginatedResponse(items=items, total=total)
@router.get("/templates/categories", response_model=List[TemplateCategory], summary="获取模板分类")
async def get_template_categories(
application_id: Optional[str] = Query(default=None, alias="applicationId"),
db: AsyncSession = Depends(get_db),
):
"""获取模板分类统计"""
return await DocumentTemplateService.get_categories(db, application_id)
@router.get("/templates/builtin", response_model=List[DocumentTemplateListOut], summary="获取内置模板")
async def get_builtin_templates(
db: AsyncSession = Depends(get_db),
):
"""获取内置模板列表"""
items, _ = await DocumentTemplateService.get_list(db, page=1, page_size=100, is_builtin=True)
return items
@router.get("/templates/by-form/{form_code}", response_model=List[DocumentTemplateListOut], summary="根据表单编码获取模板")
async def get_templates_by_form_code(
form_code: str,
db: AsyncSession = Depends(get_db),
):
"""根据表单编码获取绑定的已发布单据模板列表"""
templates = await DocumentTemplateService.get_by_form_code(db, form_code)
return templates
@router.get("/templates/check-form/{form_code}", response_model=ResponseModel, summary="检查表单是否绑定模板")
async def check_form_has_templates(
form_code: str,
db: AsyncSession = Depends(get_db),
):
"""检查表单是否绑定了已发布的单据模板"""
has_templates = await DocumentTemplateService.has_templates_by_form_code(db, form_code)
return ResponseModel(message="success", data={"hasTemplates": has_templates})
@router.post("/templates", response_model=DocumentTemplateOut, summary="创建模板")
async def create_template(
data: DocumentTemplateCreate,
db: AsyncSession = Depends(get_db),
):
"""创建文档模板"""
# 检查编码唯一性
existing = await DocumentTemplateService.get_by_code(db, data.code)
if existing:
raise HTTPException(status_code=400, detail="模板编码已存在")
template = await DocumentTemplateService.create(db, data)
return template
@router.get("/templates/{template_id}", response_model=DocumentTemplateOut, summary="获取模板详情")
async def get_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取模板详情"""
template = await DocumentTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return template
@router.get("/templates/code/{code}", response_model=DocumentTemplateOut, summary="根据编码获取模板")
async def get_template_by_code(
code: str,
db: AsyncSession = Depends(get_db),
):
"""根据编码获取模板"""
template = await DocumentTemplateService.get_by_code(db, code)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return template
@router.put("/templates/{template_id}", response_model=DocumentTemplateOut, summary="更新模板")
async def update_template(
template_id: str,
data: DocumentTemplateUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新模板"""
template = await DocumentTemplateService.update(db, template_id, data)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return template
@router.delete("/templates/{template_id}", response_model=ResponseModel, summary="删除模板")
async def delete_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""删除模板"""
template = await DocumentTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
if template.is_builtin:
raise HTTPException(status_code=400, detail="内置模板不能删除")
await DocumentTemplateService.delete(db, template_id)
return ResponseModel(message="删除成功")
@router.post("/templates/{template_id}/publish", response_model=DocumentTemplateOut, summary="发布模板")
async def publish_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""发布模板"""
template = await DocumentTemplateService.publish(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return template
@router.post("/templates/{template_id}/unpublish", response_model=DocumentTemplateOut, summary="取消发布")
async def unpublish_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""取消发布模板"""
template = await DocumentTemplateService.unpublish(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return template
@router.post("/templates/{template_id}/copy", response_model=DocumentTemplateOut, summary="复制模板")
async def copy_template(
template_id: str,
new_code: str = Query(..., alias="newCode", description="新编码"),
new_name: str = Query(..., alias="newName", description="新名称"),
db: AsyncSession = Depends(get_db),
):
"""复制模板"""
# 检查新编码唯一性
existing = await DocumentTemplateService.get_by_code(db, new_code)
if existing:
raise HTTPException(status_code=400, detail="模板编码已存在")
template = await DocumentTemplateService.copy(db, template_id, new_code, new_name)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return template
@router.get("/templates/{template_id}/export", summary="导出单据模板配置")
async def export_template_config(
template_id: str,
db: AsyncSession = Depends(get_db),
):
"""导出单据模板配置为 JSON 文件"""
config = await DocumentTemplateService.export_config(db, template_id)
if not config:
raise HTTPException(status_code=404, detail="模板不存在")
content = json.dumps(config, ensure_ascii=False, indent=2)
return StreamingResponse(
iter([content]),
media_type="application/json",
headers={
"Content-Disposition": f'attachment; filename="{config["code"]}.json"'
},
)
@router.post(
"/templates/import/check",
response_model=DocumentTemplateImportCheckOut,
summary="单据模板导入预检查",
)
async def check_import_template_config(
data: DocumentTemplateImportCheckIn,
db: AsyncSession = Depends(get_db),
):
"""导入预检查:检查模板编码是否冲突"""
return await DocumentTemplateService.check_import(db, data.code)
@router.post(
"/templates/import",
response_model=DocumentTemplateOut,
summary="导入单据模板配置",
)
async def import_template_config(
data: DocumentTemplateImportIn,
db: AsyncSession = Depends(get_db),
):
"""导入单据模板配置"""
try:
return await DocumentTemplateService.import_config(db, data.model_dump())
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# ==================== 文档生成 ====================
@router.post("/generate", response_model=GeneratedDocumentOut, summary="生成文档")
async def generate_document(
data: GenerateDocumentIn,
db: AsyncSession = Depends(get_db),
):
"""生成文档"""
from core.file_manager.service import FileManagerService
# 获取模板
template = await DocumentTemplateService.get_by_id(db, data.template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
if template.status != "published":
raise HTTPException(status_code=400, detail="模板未发布")
# 删除同模板+同表单数据的旧单据(每个模板只保留一个单据)
if data.form_data_id:
existing_docs = await GeneratedDocumentService.get_by_template_and_form_data(
db, data.template_id, data.form_data_id
)
for old_doc in existing_docs:
# 删除关联的文件
if old_doc.file_id:
try:
await FileManagerService.delete_item(db, old_doc.file_id, hard=True, is_superuser=True)
except Exception as e:
logger.warning(f"删除旧单据文件失败: {e}")
# 删除单据记录
await GeneratedDocumentService.delete(db, old_doc.id, hard=True, auto_commit=False)
# 加载表单数据
form_data = {}
if data.form_data_id and template.form_code:
form_data = await _load_form_data(db, template.form_code, data.form_data_id)
# 加载流程实例数据
if data.instance_id:
instance_data = await _load_instance_data(db, data.instance_id)
form_data.update(instance_data)
# 添加签名文件URL
if data.signature_file_ids:
for field_name, file_id in data.signature_file_ids.items():
# 获取文件URL
file_info = await FileManagerService.get_by_id(db, file_id)
if file_info:
form_data[field_name] = file_info.url or ""
# 执行计算规则(聚合字段和计算字段)
template_data = dict(form_data)
if template.calculation_rules:
try:
from online_dev.document_generator.calculation_engine import CalculationEngine
logger.info(f"开始执行计算规则: {template.calculation_rules}")
calculated_values = await CalculationEngine.calculate_all(
template.calculation_rules,
form_data
)
# 将计算结果合并到模板数据中
template_data.update(calculated_values)
logger.info(f"生成文档计算完成,计算结果: {calculated_values}")
except Exception as calc_error:
logger.error(f"生成文档计算规则执行失败: {calc_error}", exc_info=True)
# 生成 PDF
try:
page_config = {
"size": template.page_size,
"orientation": template.page_orientation,
"margin": template.page_margin or {"top": 20, "right": 20, "bottom": 20, "left": 20},
}
logger.info(
f"[生成文档] 开始生成PDF | template_id={template.id}, "
f"template_type={template.template_type}, data_keys={list(template_data.keys())}, "
f"page_config={page_config}"
)
pdf_bytes = pdf_generator.generate(
template_type=template.template_type,
template_content=template.template_content or "",
data=template_data,
css=template.template_css,
page_config=page_config,
)
logger.info(f"[生成文档] PDF生成成功 | template_id={template.id}, 大小={len(pdf_bytes)} bytes")
except Exception as e:
logger.error(
f"[生成文档] PDF生成失败 | template_id={template.id}, "
f"template_type={template.template_type}, error={type(e).__name__}: {e}",
exc_info=True,
)
raise HTTPException(status_code=500, detail=f"PDF生成失败: {str(e)}")
# 获取页数
page_count = pdf_generator.get_page_count(pdf_bytes)
# 保存文件
document_name = data.document_name or f"{template.name}_{datetime.now().strftime('%Y%m%d%H%M%S')}"
filename = f"{document_name}.pdf"
file_record = await FileManagerService.upload_file(
db=db,
file_content=pdf_bytes,
filename=filename,
file_size=len(pdf_bytes),
parent_id=None,
is_public=False,
source="document",
)
# 创建文档记录
document = await GeneratedDocumentService.create_document(
db=db,
template=template,
file_id=file_record.id,
file_size=len(pdf_bytes),
page_count=page_count,
document_name=document_name,
form_data_id=data.form_data_id,
instance_id=data.instance_id,
generate_type="manual",
)
return document
@router.post("/preview", summary="预览文档")
async def preview_document(
data: PreviewDocumentIn,
db: AsyncSession = Depends(get_db),
):
"""预览文档(返回 PDF 流)
支持两种方式:
1. 传入 template_id,从数据库读取模板
2. 传入 template_content,直接使用传入的 JSON 配置(无需保存)
"""
template_content = ""
template_type = "designer"
template_css = None
page_config = {"top": 20, "right": 20, "bottom": 20, "left": 20}
if data.template_content:
# 直接使用传入的 JSON 配置
template_content = data.template_content
# 从 JSON 中解析页面配置
try:
import json
config = json.loads(data.template_content)
page_config = {
"size": config.get("pageSize", "A4"),
"orientation": config.get("pageOrientation", "portrait"),
"margin": config.get("pageMargin", {"top": 20, "right": 20, "bottom": 20, "left": 20}),
"customPageWidth": config.get("customPageWidth"),
"customPageHeight": config.get("customPageHeight"),
"showPageNumber": config.get("showPageNumber"),
"pageNumberPosition": config.get("pageNumberPosition"),
"pageNumberAlign": config.get("pageNumberAlign"),
"pageNumberFormat": config.get("pageNumberFormat"),
"pageNumberFontSize": config.get("pageNumberFontSize"),
"pageNumberColor": config.get("pageNumberColor"),
}
except json.JSONDecodeError:
pass
elif data.template_id:
# 从数据库读取模板
template = await DocumentTemplateService.get_by_id(db, data.template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
template_content = template.template_content or ""
template_type = template.template_type
template_css = template.template_css
page_config = {
"size": template.page_size,
"orientation": template.page_orientation,
"margin": template.page_margin or {"top": 20, "right": 20, "bottom": 20, "left": 20},
}
else:
raise HTTPException(status_code=400, detail="请提供 template_id 或 template_content")
# 加载数据
form_data = data.test_data or {}
if data.template_id:
template = await DocumentTemplateService.get_by_id(db, data.template_id)
if not form_data and data.form_data_id and template and template.form_code:
form_data = await _load_form_data(db, template.form_code, data.form_data_id)
if data.instance_id:
instance_data = await _load_instance_data(db, data.instance_id)
form_data.update(instance_data)
# 执行计算规则
template_data = dict(form_data)
calculation_rules = None
# 优先使用请求中的 calculation_rules(用于未保存的模板预览)
if data.calculation_rules:
calculation_rules = data.calculation_rules
logger.info(f"使用请求中的计算规则: {calculation_rules}")
# 否则从模板中获取
elif data.template_id:
template = await DocumentTemplateService.get_by_id(db, data.template_id)
if template and template.calculation_rules:
calculation_rules = template.calculation_rules
logger.info(f"使用模板中的计算规则: {calculation_rules}")
# 执行计算
if calculation_rules:
try:
from online_dev.document_generator.calculation_engine import CalculationEngine
logger.info(f"开始执行计算规则")
calculated_values = await CalculationEngine.calculate_all(
calculation_rules,
form_data
)
# 将计算结果合并到模板数据中
template_data.update(calculated_values)
logger.info(f"预览计算完成,计算结果: {calculated_values}")
except Exception as calc_error:
logger.error(f"预览计算规则执行失败: {calc_error}", exc_info=True)
else:
logger.info(f"没有计算规则需要执行")
# 生成 PDF
try:
logger.info(
f"[预览文档] 开始生成PDF | template_id={data.template_id}, "
f"template_type={template_type}, has_template_content={bool(data.template_content)}, "
f"data_keys={list(template_data.keys())}, has_css={template_css is not None}, "
f"page_config={page_config}"
)
pdf_bytes = pdf_generator.generate(
template_type=template_type,
template_content=template_content,
data=template_data,
css=template_css,
page_config=page_config,
)
logger.info(f"[预览文档] PDF生成成功 | 大小={len(pdf_bytes)} bytes")
except Exception as e:
logger.error(
f"[预览文档] PDF生成失败 | template_id={data.template_id}, "
f"template_type={template_type}, error={type(e).__name__}: {e}",
exc_info=True,
)
raise HTTPException(status_code=500, detail=f"PDF生成失败: {str(e)}")
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f"inline; filename=preview.pdf"}
)
@router.post("/preview-html", summary="预览HTML")
async def preview_html(
data: PreviewDocumentIn,
db: AsyncSession = Depends(get_db),
):
"""预览文档HTML(返回 HTML 字符串)
支持两种方式:
1. 传入 template_id,从数据库读取模板
2. 传入 template_content,直接使用传入的 JSON 配置(无需保存)
"""
template_content = ""
template_type = "designer"
template_css = None
if data.template_content:
# 直接使用传入的 JSON 配置
template_content = data.template_content
elif data.template_id:
# 从数据库读取模板
template = await DocumentTemplateService.get_by_id(db, data.template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
template_content = template.template_content or ""
template_type = template.template_type
template_css = template.template_css
else:
raise HTTPException(status_code=400, detail="请提供 template_id 或 template_content")
# 加载数据
form_data = data.test_data or {}
if data.template_id:
template = await DocumentTemplateService.get_by_id(db, data.template_id)
if not form_data and data.form_data_id and template and template.form_code:
form_data = await _load_form_data(db, template.form_code, data.form_data_id)
if data.instance_id:
instance_data = await _load_instance_data(db, data.instance_id)
form_data.update(instance_data)
# 执行计算规则
template_data = dict(form_data)
calculation_rules = None
# 优先使用请求中的 calculation_rules(用于未保存的模板预览)
if data.calculation_rules:
calculation_rules = data.calculation_rules
# 否则从模板中获取
elif data.template_id:
template = await DocumentTemplateService.get_by_id(db, data.template_id)
if template and template.calculation_rules:
calculation_rules = template.calculation_rules
# 执行计算
if calculation_rules:
try:
from online_dev.document_generator.calculation_engine import CalculationEngine
calculated_values = await CalculationEngine.calculate_all(
calculation_rules,
form_data
)
# 将计算结果合并到模板数据中
template_data.update(calculated_values)
logger.info(f"预览HTML计算完成,计算结果: {calculated_values}")
except Exception as calc_error:
logger.error(f"预览HTML计算规则执行失败: {calc_error}", exc_info=True)
# 生成 HTML
try:
from online_dev.document_generator.generator import DesignerTemplateRenderer
if template_type == "designer":
html_content = DesignerTemplateRenderer.render(
template_content,
template_data,
template_css,
)
else:
from online_dev.document_generator.generator import TemplateEngine
engine = TemplateEngine()
html_content = engine.render_html(template_content, template_data)
return {"html": html_content}
except Exception as e:
raise HTTPException(status_code=500, detail=f"HTML生成失败: {str(e)}")
@router.post("/batch-generate", response_model=ResponseModel, summary="批量生成文档")
async def batch_generate_documents(
data: BatchGenerateDocumentIn,
db: AsyncSession = Depends(get_db),
):
"""批量生成文档"""
template = await DocumentTemplateService.get_by_id(db, data.template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
if template.status != "published":
raise HTTPException(status_code=400, detail="模板未发布")
success_count = 0
fail_count = 0
for form_data_id in data.form_data_ids:
try:
await generate_document(
GenerateDocumentIn(template_id=data.template_id, form_data_id=form_data_id),
db=db,
)
success_count += 1
except Exception:
fail_count += 1
return ResponseModel(
message=f"批量生成完成: 成功 {success_count} 个, 失败 {fail_count}",
data={"success": success_count, "fail": fail_count}
)
# ==================== 生成的文档 ====================
@router.get("/documents", response_model=PaginatedResponse[GeneratedDocumentOut], summary="获取文档列表")
async def get_document_list(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=settings.PAGE_SIZE, ge=1, le=settings.PAGE_MAX_SIZE, alias="pageSize"),
template_id: Optional[str] = Query(default=None, alias="templateId"),
form_code: Optional[str] = Query(default=None, alias="formCode"),
form_data_id: Optional[str] = Query(default=None, alias="formDataId"),
instance_id: Optional[str] = Query(default=None, alias="instanceId"),
keyword: Optional[str] = Query(default=None),
db: AsyncSession = Depends(get_db),
):
"""获取生成的文档列表"""
items, total = await GeneratedDocumentService.get_list(
db,
page=page,
page_size=page_size,
template_id=template_id,
form_code=form_code,
form_data_id=form_data_id,
instance_id=instance_id,
keyword=keyword,
)
return PaginatedResponse(items=items, total=total)
@router.get("/documents/{document_id}", response_model=GeneratedDocumentOut, summary="获取文档详情")
async def get_document(
document_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取文档详情"""
document = await GeneratedDocumentService.get_by_id(db, document_id)
if not document:
raise HTTPException(status_code=404, detail="文档不存在")
return document
@router.get("/documents/{document_id}/download", summary="下载文档")
async def download_document(
document_id: str,
db: AsyncSession = Depends(get_db),
):
"""下载文档"""
from core.file_manager.service import FileManagerService
document = await GeneratedDocumentService.get_by_id(db, document_id)
if not document:
raise HTTPException(status_code=404, detail="文档不存在")
# 获取文件内容
file_content = await FileManagerService.get_file_content(db, document.file_id)
if not file_content:
raise HTTPException(status_code=404, detail="文件不存在")
# 增加下载次数
await GeneratedDocumentService.increment_download_count(db, document_id)
filename = f"{document.document_name}.pdf"
return StreamingResponse(
io.BytesIO(file_content),
media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
@router.delete("/documents/{document_id}", response_model=ResponseModel, summary="删除文档")
async def delete_document(
document_id: str,
db: AsyncSession = Depends(get_db),
):
"""删除文档"""
document = await GeneratedDocumentService.get_by_id(db, document_id)
if not document:
raise HTTPException(status_code=404, detail="文档不存在")
await GeneratedDocumentService.delete(db, document_id)
return ResponseModel(message="删除成功")
@router.post("/documents/{document_id}/regenerate", response_model=GeneratedDocumentOut, summary="重新生成文档")
async def regenerate_document(
document_id: str,
db: AsyncSession = Depends(get_db),
):
"""重新生成文档"""
document = await GeneratedDocumentService.get_by_id(db, document_id)
if not document:
raise HTTPException(status_code=404, detail="文档不存在")
# 使用原文档的参数重新生成
new_document = await generate_document(
GenerateDocumentIn(
template_id=document.template_id,
form_data_id=document.form_data_id,
instance_id=document.instance_id,
document_name=document.document_name,
),
db=db,
)
# 删除旧文档
await GeneratedDocumentService.delete(db, document_id)
return new_document
# ==================== 辅助函数 ====================
async def _load_form_data(db: AsyncSession, form_code: str, form_data_id: str) -> dict:
"""加载表单数据(含关联选择器的显示名称)"""
try:
from online_dev.form_data_manager.service import FormDataService
service = await FormDataService.create_service(db, form_code)
data = await service.get(db, form_data_id)
return data or {}
except Exception as e:
logger.error(f"加载表单数据失败 form_code={form_code}, id={form_data_id}: {e}")
return {}
async def _load_instance_data(db: AsyncSession, instance_id: str) -> dict:
"""加载流程实例数据"""
try:
from online_dev.workflow.model import WorkflowInstance, WorkflowLog
from sqlalchemy import select
# 获取实例
stmt = select(WorkflowInstance).where(WorkflowInstance.id == instance_id)
result = await db.execute(stmt)
instance = result.scalar_one_or_none()
if not instance:
return {}
# 获取审批日志
log_stmt = select(WorkflowLog).where(
WorkflowLog.instance_id == instance_id
).order_by(WorkflowLog.sys_create_datetime)
log_result = await db.execute(log_stmt)
logs = list(log_result.scalars().all())
return {
"_instance": {
"id": instance.id,
"instance_no": instance.instance_no,
"title": instance.title,
"status": instance.status,
"started_at": instance.started_at,
"completed_at": instance.completed_at,
},
"_logs": [
{
"node_name": log.node_name,
"action": log.action,
"comment": log.comment,
"created_at": log.sys_create_datetime,
}
for log in logs
],
}
except Exception:
return {}
# ==================== 系统字体 ====================
@router.get("/fonts", summary="获取服务器支持的字体列表")
async def get_available_fonts():
"""获取服务器上可用的字体列表"""
from online_dev.document_generator.font_service import FontService
fonts = FontService.get_available_fonts()
return fonts
@@ -0,0 +1,546 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
单据计算引擎
支持:
- 基本运算:+, -, *, /, %, **
- 聚合函数:sum, avg, max, min, count
- 内置函数:round, abs, ceil, floor, numberToChinese
- 条件表达式:if-else (三元运算符)
- 安全执行:使用 simpleeval 库防止代码注入
"""
import logging
import math
import re
from decimal import Decimal, ROUND_HALF_UP
from typing import Any, Dict, List, Optional, Union
logger = logging.getLogger(__name__)
# 中文数字映射
CHINESE_DIGITS = ['', '', '', '', '', '', '', '', '', '']
CHINESE_UNITS = ['', '', '', '']
CHINESE_GROUP_UNITS = ['', '', '亿', '']
CHINESE_DECIMAL_UNITS = ['', '', '', '']
def number_to_chinese(num: Union[int, float, Decimal, str]) -> str:
"""
将数字转换为中文大写金额
Args:
num: 数字(支持整数、浮点数、Decimal、字符串)
Returns:
中文大写金额字符串
Examples:
>>> number_to_chinese(1234.56)
'壹仟贰佰叁拾肆元伍角陆分'
>>> number_to_chinese(0)
'零元整'
"""
if num is None:
return ''
try:
# 转换为 Decimal 以保证精度
if isinstance(num, str):
num = Decimal(num.replace(',', ''))
elif isinstance(num, float):
num = Decimal(str(num))
elif isinstance(num, int):
num = Decimal(num)
elif not isinstance(num, Decimal):
num = Decimal(str(num))
except Exception:
return str(num)
# 处理负数
if num < 0:
return '' + number_to_chinese(-num)
# 处理零
if num == 0:
return '零元整'
# 分离整数和小数部分
num = num.quantize(Decimal('0.0001'), rounding=ROUND_HALF_UP)
str_num = str(num)
if '.' in str_num:
int_part, dec_part = str_num.split('.')
else:
int_part, dec_part = str_num, ''
result = ''
# 处理整数部分
if int_part and int(int_part) > 0:
int_part = int_part.lstrip('0') or '0'
length = len(int_part)
# 按4位分组处理
groups = []
while int_part:
groups.insert(0, int_part[-4:])
int_part = int_part[:-4]
for i, group in enumerate(groups):
group_result = ''
group = group.zfill(4)
for j, digit in enumerate(group):
d = int(digit)
unit_index = 3 - j
if d != 0:
group_result += CHINESE_DIGITS[d] + CHINESE_UNITS[unit_index]
else:
# 处理连续零
if group_result and not group_result.endswith(''):
group_result += ''
# 移除末尾的零
group_result = group_result.rstrip('')
if group_result:
group_unit_index = len(groups) - 1 - i
group_result += CHINESE_GROUP_UNITS[group_unit_index] if group_unit_index < len(CHINESE_GROUP_UNITS) else ''
result += group_result
result += ''
else:
result = '零元'
# 处理小数部分
if dec_part:
dec_part = dec_part[:4] # 最多4位小数
has_decimal = False
for i, digit in enumerate(dec_part):
d = int(digit)
if d != 0:
result += CHINESE_DIGITS[d] + CHINESE_DECIMAL_UNITS[i]
has_decimal = True
if not has_decimal:
result += ''
else:
result += ''
return result
def safe_get_value(data: Dict[str, Any], key: str, default: Any = 0) -> Any:
"""
安全地从字典中获取值,支持点号路径
Args:
data: 数据字典
key: 键名(支持点号路径,如 'order.total'
default: 默认值
Returns:
获取的值或默认值
"""
if not key:
return default
parts = key.split('.')
value = data
for part in parts:
if isinstance(value, dict):
value = value.get(part)
elif isinstance(value, list):
# 支持数组索引
if part.lstrip('-').isdigit():
index = int(part)
if -len(value) <= index < len(value):
value = value[index]
else:
return default
else:
return default
else:
return default
if value is None:
return default
return value if value is not None else default
class CalculationEngine:
"""单据计算引擎"""
# 支持的聚合函数
AGGREGATE_FUNCTIONS = {
'sum': lambda values: sum(v for v in values if v is not None),
'avg': lambda values: sum(v for v in values if v is not None) / len([v for v in values if v is not None]) if values else 0,
'max': lambda values: max((v for v in values if v is not None), default=0),
'min': lambda values: min((v for v in values if v is not None), default=0),
'count': lambda values: len([v for v in values if v is not None]),
}
# 安全的内置函数
SAFE_FUNCTIONS = {
'abs': abs,
'round': round,
'ceil': math.ceil,
'floor': math.floor,
'max': max,
'min': min,
'sum': sum,
'len': len,
'float': float,
'int': int,
'str': str,
'numberToChinese': number_to_chinese,
'toChineseAmount': number_to_chinese,
}
# 安全的运算符
SAFE_OPERATORS = {
'+', '-', '*', '/', '//', '%', '**',
'==', '!=', '<', '>', '<=', '>=',
'and', 'or', 'not',
'(', ')', ',', '.',
}
@classmethod
def evaluate_formula(cls, formula: str, context: Dict[str, Any]) -> Any:
"""
安全地执行计算公式
Args:
formula: 计算公式,如 "quantity * unit_price * (1 - discount_rate)"
context: 上下文数据
Returns:
计算结果
"""
if not formula:
return None
try:
# 替换公式中的变量
evaluated_formula = cls._replace_variables(formula, context)
logger.info(f"公式: {formula} -> 替换后: {evaluated_formula}")
# 使用 eval 执行(在受限环境中)
# 注意:这里使用了安全的方式,只允许特定的函数和运算
result = cls._safe_eval(evaluated_formula, context)
logger.info(f"公式执行结果: {result}")
return result
except Exception as e:
logger.error(f"公式计算失败: {formula}, 错误: {e}", exc_info=True)
return None
@classmethod
def _replace_variables(cls, formula: str, context: Dict[str, Any]) -> str:
"""
替换公式中的变量为实际值
支持的变量格式:
- 简单变量:quantity, unit_price
- 点号路径:order.total, items[0].price
"""
# 匹配变量名(字母开头,可包含字母、数字、下划线、点号、方括号)
pattern = r'\b([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*|\[\d+\])*)\b'
def replace_var(match):
var_name = match.group(1)
# 跳过函数名
if var_name in cls.SAFE_FUNCTIONS:
return var_name
# 跳过 Python 关键字
if var_name in ('and', 'or', 'not', 'if', 'else', 'True', 'False', 'None'):
return var_name
# 获取变量值
value = safe_get_value(context, var_name, 0)
# 转换为字符串表示
if value is None:
return '0'
elif isinstance(value, str):
# 尝试转换为数字
try:
return str(float(value))
except ValueError:
return f'"{value}"'
elif isinstance(value, bool):
return str(value)
elif isinstance(value, (int, float, Decimal)):
return str(float(value))
else:
return '0'
return re.sub(pattern, replace_var, formula)
@classmethod
def _safe_eval(cls, expression: str, context: Dict[str, Any]) -> Any:
"""
安全地执行表达式
使用受限的 eval 环境,只允许特定的函数和运算
"""
# 构建安全的执行环境
safe_globals = {
'__builtins__': {},
**cls.SAFE_FUNCTIONS,
}
# 添加上下文数据
safe_locals = dict(context)
try:
result = eval(expression, safe_globals, safe_locals)
return result
except Exception as e:
logger.warning(f"表达式执行失败: {expression}, 错误: {e}")
raise
@classmethod
def calculate_aggregation(
cls,
data: Dict[str, Any],
source: str,
field: str,
function: str
) -> Any:
"""
计算聚合值
Args:
data: 数据字典
source: 数据源(子表名)
field: 聚合字段
function: 聚合函数名
Returns:
聚合结果
"""
logger.info(f"聚合计算开始: source={source}, field={field}, function={function}")
logger.info(f"数据中的顶层键: {list(data.keys())}")
# 获取子表数据
sub_table_data = None
# 1. 优先从 sub_tables 中查找(表单数据的标准结构)
if 'sub_tables' in data and isinstance(data['sub_tables'], dict):
sub_tables = data['sub_tables']
logger.info(f"sub_tables 中的键: {list(sub_tables.keys())}")
# 直接匹配
if source in sub_tables:
sub_table_data = sub_tables[source]
logger.info(f"从 sub_tables 中直接匹配到 ({source}): 找到 {len(sub_table_data) if isinstance(sub_table_data, list) else 0}")
else:
# 尝试模糊匹配(source 可能是字段名,sub_tables 的键可能是表名)
# 例如:source='product_details'sub_tables 键可能是 'fd_product_details' 或 'contract_product_details'
for key in sub_tables.keys():
if source in key or key in source or key.endswith(f'_{source}') or key.endswith(source):
sub_table_data = sub_tables[key]
logger.info(f"从 sub_tables 中模糊匹配到 ({source} -> {key}): 找到 {len(sub_table_data) if isinstance(sub_table_data, list) else 0}")
break
# 2. 如果 sub_tables 中没有,尝试直接从顶层获取
if not sub_table_data:
sub_table_data = safe_get_value(data, source, [])
logger.info(f"从顶层获取子表数据 ({source}): {type(sub_table_data)}")
if not isinstance(sub_table_data, list):
logger.warning(f"聚合数据源不是数组: {source}, 实际类型: {type(sub_table_data)}")
return 0
if len(sub_table_data) == 0:
logger.warning(f"聚合数据源为空数组: {source}")
return 0
# 提取字段值
values = []
for i, item in enumerate(sub_table_data):
if isinstance(item, dict):
logger.info(f"子表第{i}行的键: {list(item.keys())}")
value = safe_get_value(item, field, None)
logger.info(f"子表第{i}行的 {field} 值: {value}")
if value is not None:
try:
values.append(float(value))
except (ValueError, TypeError) as e:
logger.warning(f"无法转换为数字: {value}, 错误: {e}")
logger.info(f"提取到的数值列表: {values}")
# 执行聚合函数
agg_func = cls.AGGREGATE_FUNCTIONS.get(function.lower())
if not agg_func:
logger.warning(f"不支持的聚合函数: {function}")
return 0
try:
result = agg_func(values)
logger.info(f"聚合计算结果: {result}")
return result
except Exception as e:
logger.warning(f"聚合计算失败: {e}")
return 0
@classmethod
def format_value(
cls,
value: Any,
format_type: str = 'number',
decimal_places: int = 2
) -> Any:
"""
格式化计算结果
Args:
value: 原始值
format_type: 格式化类型 (number/money/percent/chinese)
decimal_places: 小数位数
Returns:
格式化后的值(字符串,保留指定小数位数)
"""
if value is None:
return None
try:
num_value = float(value)
except (ValueError, TypeError):
return value
if format_type == 'chinese':
return number_to_chinese(num_value)
elif format_type == 'percent':
# 百分比:乘以100后保留指定小数位,返回格式化字符串
percent_value = num_value * 100
if decimal_places == 0:
return str(int(round(percent_value)))
return f"{percent_value:.{decimal_places}f}"
else:
# number 和 money:保留指定小数位,返回格式化字符串
if decimal_places == 0:
return str(int(round(num_value)))
return f"{num_value:.{decimal_places}f}"
@classmethod
async def calculate_all(
cls,
calculation_rules: Optional[Dict[str, Any]],
form_data: Dict[str, Any]
) -> Dict[str, Any]:
"""
执行所有计算规则
Args:
calculation_rules: 计算规则配置
form_data: 表单数据
Returns:
计算结果字典
"""
if not calculation_rules:
return {}
results = {}
# 创建计算上下文(包含原始数据和已计算的结果)
context = dict(form_data)
# 1. 先执行聚合计算(因为计算字段可能依赖聚合结果)
aggregations = calculation_rules.get('aggregations', [])
logger.info(f"开始执行聚合计算,共 {len(aggregations)}")
for agg in aggregations:
try:
logger.info(f"聚合配置原始数据: {agg}")
name = agg.get('name')
source = agg.get('source')
field = agg.get('field')
function = agg.get('function') or 'sum'
format_type = agg.get('format') or 'number'
# 确保 decimal_places 是整数,处理 None 和非数字情况
decimal_places_raw = agg.get('decimal_places')
decimal_places = int(decimal_places_raw) if decimal_places_raw is not None else 2
logger.info(f"聚合字段解析: name={name}, source={source}, field={field}, function={function}, format={format_type}, decimal_places={decimal_places}")
if not all([name, source, field]):
logger.warning(f"聚合字段配置不完整,跳过: name={name}, source={source}, field={field}")
continue
# 计算聚合值
raw_value = cls.calculate_aggregation(context, source, field, function)
logger.info(f"聚合原始值: {raw_value}, 类型: {type(raw_value)}")
# 格式化
formatted_value = cls.format_value(raw_value, format_type, decimal_places)
logger.info(f"格式化后: {formatted_value}, decimal_places={decimal_places}")
results[name] = formatted_value
context[name] = raw_value # 使用原始值用于后续计算
# 如果是中文格式,同时保存原始数值
if format_type == 'chinese':
results[f'{name}_raw'] = raw_value
logger.info(f"聚合计算完成: {name} = {formatted_value}")
except Exception as e:
logger.warning(f"聚合计算失败: {agg}, 错误: {e}")
# 2. 执行计算字段(按顺序,支持依赖)
fields = calculation_rules.get('fields', [])
logger.info(f"开始执行计算字段,共 {len(fields)}")
for field_config in fields:
try:
name = field_config.get('name')
formula = field_config.get('formula')
format_type = field_config.get('format') or 'number'
# 确保 decimal_places 是整数,处理 None 和非数字情况
decimal_places_raw = field_config.get('decimal_places')
decimal_places = int(decimal_places_raw) if decimal_places_raw is not None else 2
logger.info(f"处理计算字段: name={name}, formula={formula}, format={format_type}, decimal_places={decimal_places}")
if not all([name, formula]):
logger.warning(f"计算字段配置不完整,跳过: {field_config}")
continue
# 计算公式
raw_value = cls.evaluate_formula(formula, context)
logger.info(f"计算字段 {name} 原始值: {raw_value}")
if raw_value is not None:
# 格式化
formatted_value = cls.format_value(raw_value, format_type, decimal_places)
logger.info(f"计算字段 {name} 格式化后: {formatted_value}")
results[name] = formatted_value
context[name] = raw_value # 使用原始值用于后续计算
# 如果是中文格式,同时保存原始数值
if format_type == 'chinese':
results[f'{name}_raw'] = raw_value
logger.info(f"公式计算完成: {name} = {formatted_value}")
else:
logger.warning(f"计算字段 {name} 返回 None")
except Exception as e:
logger.error(f"公式计算失败: {field_config}, 错误: {e}", exc_info=True)
return results
# 导出
__all__ = ['CalculationEngine', 'number_to_chinese', 'safe_get_value']
@@ -0,0 +1,157 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
字体服务 - 获取服务器上可用的字体列表
"""
import logging
import os
import platform
import subprocess
from functools import lru_cache
from typing import Dict, List, Optional, Set
logger = logging.getLogger(__name__)
class FontService:
"""字体服务类"""
CHINESE_FONTS: List[Dict[str, str]] = [
# 宋体系列
{"label": "宋体 (SimSun)", "value": "SimSun, STSong, Songti SC, Noto Serif CJK SC, serif", "family": "SimSun"},
{"label": "华文宋体 (STSong)", "value": "STSong, SimSun, Songti SC, Noto Serif CJK SC, serif", "family": "STSong"},
{"label": "Noto 宋体 (Noto Serif CJK)", "value": "Noto Serif CJK SC, SimSun, STSong, serif", "family": "Noto Serif CJK SC"},
# 黑体系列
{"label": "黑体 (SimHei)", "value": "SimHei, STHeiti, Heiti SC, Noto Sans CJK SC, sans-serif", "family": "SimHei"},
{"label": "Noto 黑体 (Noto Sans CJK)", "value": "Noto Sans CJK SC, SimHei, STHeiti, sans-serif", "family": "Noto Sans CJK SC"},
{"label": "思源黑体 (Source Han Sans)", "value": "Source Han Sans CN, Noto Sans CJK SC, SimHei, sans-serif", "family": "Source Han Sans CN"},
{"label": "微软雅黑 (Microsoft YaHei)", "value": "Microsoft YaHei, Noto Sans CJK SC, SimHei, sans-serif", "family": "Microsoft YaHei"},
{"label": "苹方 (PingFang SC)", "value": "PingFang SC, Noto Sans CJK SC, SimHei, sans-serif", "family": "PingFang SC"},
{"label": "文泉驿微米黑", "value": "WenQuanYi Micro Hei, Noto Sans CJK SC, sans-serif", "family": "WenQuanYi Micro Hei"},
{"label": "文泉驿正黑", "value": "WenQuanYi Zen Hei, Noto Sans CJK SC, sans-serif", "family": "WenQuanYi Zen Hei"},
# 楷体系列
{"label": "楷体 (KaiTi)", "value": "KaiTi, STKaiti, Kaiti SC, serif", "family": "KaiTi"},
{"label": "华文楷体 (STKaiti)", "value": "STKaiti, KaiTi, Kaiti SC, serif", "family": "STKaiti"},
# 仿宋系列
{"label": "仿宋 (FangSong)", "value": "FangSong, STFangsong, serif", "family": "FangSong"},
{"label": "华文仿宋 (STFangsong)", "value": "STFangsong, FangSong, serif", "family": "STFangsong"},
# 其他
{"label": "华文细黑 (STXihei)", "value": "STXihei, Noto Sans CJK SC, sans-serif", "family": "STXihei"},
{"label": "冬青黑体 (Hiragino Sans GB)", "value": "Hiragino Sans GB, Noto Sans CJK SC, sans-serif", "family": "Hiragino Sans GB"},
]
ENGLISH_FONTS: List[Dict[str, str]] = [
{"label": "Arial", "value": "Arial, sans-serif", "family": "Arial"},
{"label": "Helvetica", "value": "Helvetica, Arial, sans-serif", "family": "Helvetica"},
{"label": "Times New Roman", "value": "Times New Roman, serif", "family": "Times New Roman"},
{"label": "Georgia", "value": "Georgia, serif", "family": "Georgia"},
{"label": "Verdana", "value": "Verdana, sans-serif", "family": "Verdana"},
{"label": "Courier New", "value": "Courier New, monospace", "family": "Courier New"},
]
@classmethod
def _get_fc_list_families(cls) -> Optional[Set[str]]:
"""通过 fc-list 获取系统已安装的字体族名"""
try:
result = subprocess.run(
["fc-list", "--format", "%{family}\n"],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return None
families: Set[str] = set()
for line in result.stdout.splitlines():
for part in line.split(","):
families.add(part.strip())
return families
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
return None
@classmethod
def _scan_font_files(cls) -> Set[str]:
"""扫描字体目录,返回字体文件名集合(备用方案)"""
system = platform.system()
dirs: List[str] = []
if system == "Darwin":
dirs = ["/System/Library/Fonts", "/Library/Fonts", os.path.expanduser("~/Library/Fonts")]
elif system == "Linux":
dirs = ["/usr/share/fonts", "/usr/local/share/fonts", os.path.expanduser("~/.fonts")]
elif system == "Windows":
dirs = [os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts")]
font_names: Set[str] = set()
extensions = {".ttf", ".ttc", ".otf", ".woff", ".woff2"}
for font_dir in dirs:
if not os.path.exists(font_dir):
continue
try:
for root, _, files in os.walk(font_dir):
for file in files:
if os.path.splitext(file)[1].lower() in extensions:
font_names.add(os.path.splitext(file)[0])
except (PermissionError, OSError):
continue
return font_names
@classmethod
def _is_font_available(cls, family: str, fc_families: Optional[Set[str]], file_names: Set[str]) -> bool:
"""判断字体是否在系统中可用"""
if fc_families is not None:
family_lower = family.lower()
for fc_fam in fc_families:
if family_lower == fc_fam.lower():
return True
return False
family_normalized = family.lower().replace(" ", "")
for name in file_names:
name_normalized = name.lower().replace(" ", "").replace("-", "").replace("_", "")
if family_normalized in name_normalized or name_normalized in family_normalized:
return True
return False
@classmethod
@lru_cache(maxsize=1)
def get_available_fonts(cls) -> List[Dict[str, str]]:
"""获取服务器上可用的字体列表"""
result: List[Dict[str, str]] = [{"label": "默认字体", "value": ""}]
fc_families = cls._get_fc_list_families()
file_names = cls._scan_font_files() if fc_families is None else set()
if fc_families is not None:
logger.info(f"[字体服务] 通过 fc-list 检测到 {len(fc_families)} 个字体族")
else:
logger.info(f"[字体服务] fc-list 不可用,使用文件名扫描检测到 {len(file_names)} 个字体文件")
added_labels: Set[str] = set()
for font in cls.CHINESE_FONTS:
if cls._is_font_available(font["family"], fc_families, file_names):
if font["label"] not in added_labels:
result.append({"label": font["label"], "value": font["value"]})
added_labels.add(font["label"])
if len(result) <= 1:
logger.warning("[字体服务] 未检测到中文字体,使用平台默认列表")
system = platform.system()
if system == "Darwin":
defaults = ["PingFang SC", "STSong", "STKaiti", "Hiragino Sans GB"]
elif system == "Linux":
defaults = ["Noto Sans CJK SC", "Noto Serif CJK SC", "WenQuanYi Zen Hei", "WenQuanYi Micro Hei"]
else:
defaults = ["Microsoft YaHei", "SimSun", "SimHei", "KaiTi", "FangSong"]
for font in cls.CHINESE_FONTS:
if font["family"] in defaults and font["label"] not in added_labels:
result.append({"label": font["label"], "value": font["value"]})
added_labels.add(font["label"])
for font in cls.ENGLISH_FONTS:
result.append({"label": font["label"], "value": font["value"]})
logger.info(f"[字体服务] 返回 {len(result)} 个字体选项")
return result
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,117 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
文档生成数据模型
"""
from sqlalchemy import Column, String, Text, Integer, Boolean, BigInteger, Index, JSON
from app.base_model import BaseModel
class DocumentTemplate(BaseModel):
"""文档模板配置"""
__tablename__ = "document_template"
# 所属应用(逻辑外键关联 core_application
application_id = Column(String(21), nullable=True, index=True, comment="所属应用ID")
# 基础信息
name = Column(String(100), nullable=False, comment="模板名称")
code = Column(String(100), unique=True, nullable=False, comment="模板编码")
category = Column(String(50), default="other", comment="分类: leave/expense/purchase/contract/certificate/other")
description = Column(Text, default="", comment="描述")
# 关联配置
form_code = Column(String(100), nullable=True, index=True, comment="关联表单编码")
workflow_code = Column(String(100), nullable=True, index=True, comment="关联流程编码")
# 模板内容
template_type = Column(String(20), default="designer", comment="模板类型: designer/html")
template_content = Column(Text, nullable=True, comment="模板内容(JSON或HTML)")
template_css = Column(Text, nullable=True, comment="自定义CSS")
# 页面设置
page_size = Column(String(20), default="A4", comment="页面大小: A4/A3/Letter/Legal")
page_orientation = Column(String(20), default="portrait", comment="页面方向: portrait/landscape")
page_margin = Column(JSON, default=lambda: {"top": 20, "right": 20, "bottom": 20, "left": 20}, comment="页边距(mm)")
# 水印配置
watermark_enabled = Column(Boolean, default=False, comment="是否启用水印")
watermark_text = Column(String(100), nullable=True, comment="水印文字")
watermark_type = Column(String(20), default="text", comment="水印类型: text/image")
watermark_image_id = Column(String(36), nullable=True, comment="水印图片文件ID")
watermark_opacity = Column(Integer, default=30, comment="水印透明度(0-100)")
watermark_angle = Column(Integer, default=-45, comment="水印角度")
# 签章配置
seal_enabled = Column(Boolean, default=False, comment="是否启用签章")
seal_positions = Column(JSON, nullable=True, comment="签章位置配置")
# 页眉页脚
header_enabled = Column(Boolean, default=False, comment="是否启用页眉")
header_template = Column(Text, nullable=True, comment="页眉模板")
footer_enabled = Column(Boolean, default=False, comment="是否启用页脚")
footer_template = Column(Text, nullable=True, comment="页脚模板")
show_page_number = Column(Boolean, default=True, comment="是否显示页码")
# 计算规则配置
calculation_rules = Column(JSON, nullable=True, comment="计算规则配置")
# 状态
status = Column(String(20), default="draft", index=True, comment="状态: draft/published")
is_builtin = Column(Boolean, default=False, comment="是否内置模板")
version = Column(Integer, default=1, comment="版本号")
__table_args__ = (
Index("ix_document_template_code", "code"),
Index("ix_document_template_form_code", "form_code"),
Index("ix_document_template_workflow_code", "workflow_code"),
)
class GeneratedDocument(BaseModel):
"""生成的文档记录"""
__tablename__ = "generated_document"
# 关联模板
template_id = Column(String(36), nullable=False, index=True, comment="模板ID")
template_code = Column(String(100), nullable=True, comment="模板编码")
template_name = Column(String(100), nullable=True, comment="模板名称")
# 关联表单数据
form_code = Column(String(100), nullable=True, index=True, comment="表单编码")
form_data_id = Column(String(36), nullable=True, index=True, comment="表单数据ID")
# 关联流程实例
workflow_code = Column(String(100), nullable=True, comment="流程编码")
instance_id = Column(String(36), nullable=True, index=True, comment="流程实例ID")
# 文档信息
document_name = Column(String(200), nullable=False, comment="文档名称")
document_no = Column(String(100), nullable=True, comment="文档编号")
# 文件信息
file_id = Column(String(36), nullable=False, comment="文件ID(关联file_manager)")
file_size = Column(BigInteger, default=0, comment="文件大小(字节)")
page_count = Column(Integer, default=1, comment="页数")
# 生成信息
generate_type = Column(String(20), default="manual", comment="生成方式: auto/manual")
generator_id = Column(String(36), nullable=True, comment="生成人ID")
generator_name = Column(String(100), nullable=True, comment="生成人姓名")
# 状态
status = Column(String(20), default="generated", comment="状态: generated/sealed/downloaded/printed")
download_count = Column(Integer, default=0, comment="下载次数")
# 签章信息
sealed = Column(Boolean, default=False, comment="是否已盖章")
seal_info = Column(JSON, nullable=True, comment="签章信息")
__table_args__ = (
Index("ix_generated_document_template_id", "template_id"),
Index("ix_generated_document_form_data_id", "form_data_id"),
Index("ix_generated_document_instance_id", "instance_id"),
)
@@ -0,0 +1,44 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""页码 CSS 生成"""
from typing import Any, Dict
def _resolve_page_number_config(config: Dict[str, Any]) -> Dict[str, Any]:
"""从模板 JSON 解析页码配置"""
show = config.get("showPageNumber")
if show is None:
show = config.get("show_page_number", True)
return {
"show": bool(show),
"position": config.get("pageNumberPosition", "footer"),
"align": config.get("pageNumberAlign", "center"),
"format": config.get("pageNumberFormat", "chinese"),
"font_size": config.get("pageNumberFontSize", 10),
"color": config.get("pageNumberColor", "#666666"),
}
def build_page_number_css(config: Dict[str, Any]) -> str:
"""生成 WeasyPrint @page 页码 margin 规则"""
cfg = _resolve_page_number_config(config)
if not cfg["show"]:
return ""
edge = "top" if cfg["position"] == "header" else "bottom"
margin_box = f"{edge}-{cfg['align']}"
fmt = cfg["format"]
if fmt == "fraction":
content = 'counter(page) " / " counter(pages)'
elif fmt == "english":
content = '"Page " counter(page) " of " counter(pages)'
else:
content = '"" counter(page) " 页 / 共 " counter(pages) ""'
return f"""
@{margin_box} {{
content: {content};
font-size: {float(cfg['font_size'])}pt;
color: {cfg['color']};
}}"""
@@ -0,0 +1,267 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
文档生成Schema定义
"""
from datetime import datetime
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, ConfigDict, Field
# ==================== 文档模板 ====================
class PageMargin(BaseModel):
"""页边距配置"""
top: int = 20
right: int = 20
bottom: int = 20
left: int = 20
class SealPosition(BaseModel):
"""签章位置配置"""
seal_type: str = Field(..., description="签章类型: company/department/personal/signature")
position_type: str = Field(default="fixed", description="定位方式: fixed/relative")
x: int = Field(default=0, description="X坐标(mm)")
y: int = Field(default=0, description="Y坐标(mm)")
page: int = Field(default=-1, description="页码(-1表示最后一页)")
width: int = Field(default=40, description="宽度(mm)")
height: int = Field(default=40, description="高度(mm)")
field_name: Optional[str] = Field(default=None, description="关联字段名(用于手写签名)")
class CalculationField(BaseModel):
"""计算字段配置"""
name: str = Field(..., description="字段名")
label: str = Field(default="", description="字段标签")
formula: str = Field(..., description="计算公式,如: quantity * unit_price")
format: str = Field(default="number", description="格式化类型: number/money/percent/chinese")
decimal_places: int = Field(default=2, description="小数位数")
class AggregationField(BaseModel):
"""聚合字段配置"""
name: str = Field(..., description="字段名")
label: str = Field(default="", description="字段标签")
source: str = Field(..., description="数据源(子表名)")
field: str = Field(..., description="聚合字段")
function: str = Field(default="sum", description="聚合函数: sum/avg/max/min/count")
format: str = Field(default="number", description="格式化类型: number/money/percent/chinese")
decimal_places: int = Field(default=2, description="小数位数")
class CalculationRules(BaseModel):
"""计算规则配置"""
fields: List[CalculationField] = Field(default_factory=list, description="计算字段列表")
aggregations: List[AggregationField] = Field(default_factory=list, description="聚合字段列表")
class DocumentTemplateBase(BaseModel):
"""文档模板基础Schema"""
name: str = Field(..., min_length=1, max_length=100, description="模板名称")
code: str = Field(..., min_length=1, max_length=100, pattern=r"^[a-zA-Z][a-zA-Z0-9_]*$", description="模板编码(字母开头,只能包含字母、数字和下划线)")
category: str = Field(default="other", description="分类")
description: Optional[str] = Field(default="", description="描述")
form_code: Optional[str] = Field(default=None, description="关联表单编码")
workflow_code: Optional[str] = Field(default=None, description="关联流程编码")
template_type: str = Field(default="designer", description="模板类型: designer/html")
template_content: Optional[str] = Field(default=None, description="模板内容")
template_css: Optional[str] = Field(default=None, description="自定义CSS")
page_size: str = Field(default="A4", description="页面大小")
page_orientation: str = Field(default="portrait", description="页面方向")
page_margin: Optional[PageMargin] = Field(default_factory=PageMargin, description="页边距")
watermark_enabled: bool = Field(default=False, description="是否启用水印")
watermark_text: Optional[str] = Field(default=None, description="水印文字")
watermark_type: str = Field(default="text", description="水印类型")
watermark_image_id: Optional[str] = Field(default=None, description="水印图片ID")
watermark_opacity: int = Field(default=30, ge=0, le=100, description="水印透明度")
watermark_angle: int = Field(default=-45, description="水印角度")
seal_enabled: bool = Field(default=False, description="是否启用签章")
seal_positions: Optional[List[SealPosition]] = Field(default=None, description="签章位置配置")
header_enabled: bool = Field(default=False, description="是否启用页眉")
header_template: Optional[str] = Field(default=None, description="页眉模板")
footer_enabled: bool = Field(default=False, description="是否启用页脚")
footer_template: Optional[str] = Field(default=None, description="页脚模板")
show_page_number: bool = Field(default=True, description="是否显示页码")
calculation_rules: Optional[CalculationRules] = Field(default=None, description="计算规则配置")
class DocumentTemplateCreate(DocumentTemplateBase):
"""创建文档模板"""
application_id: Optional[str] = Field(default=None, description="所属应用ID")
class DocumentTemplateUpdate(BaseModel):
"""更新文档模板"""
name: Optional[str] = Field(default=None, max_length=100, description="模板名称")
category: Optional[str] = Field(default=None, description="分类")
description: Optional[str] = Field(default=None, description="描述")
form_code: Optional[str] = Field(default=None, description="关联表单编码")
workflow_code: Optional[str] = Field(default=None, description="关联流程编码")
template_type: Optional[str] = Field(default=None, description="模板类型")
template_content: Optional[str] = Field(default=None, description="模板内容")
template_css: Optional[str] = Field(default=None, description="自定义CSS")
page_size: Optional[str] = Field(default=None, description="页面大小")
page_orientation: Optional[str] = Field(default=None, description="页面方向")
page_margin: Optional[PageMargin] = Field(default=None, description="页边距")
watermark_enabled: Optional[bool] = Field(default=None, description="是否启用水印")
watermark_text: Optional[str] = Field(default=None, description="水印文字")
watermark_type: Optional[str] = Field(default=None, description="水印类型")
watermark_image_id: Optional[str] = Field(default=None, description="水印图片ID")
watermark_opacity: Optional[int] = Field(default=None, description="水印透明度")
watermark_angle: Optional[int] = Field(default=None, description="水印角度")
seal_enabled: Optional[bool] = Field(default=None, description="是否启用签章")
seal_positions: Optional[List[SealPosition]] = Field(default=None, description="签章位置配置")
header_enabled: Optional[bool] = Field(default=None, description="是否启用页眉")
header_template: Optional[str] = Field(default=None, description="页眉模板")
footer_enabled: Optional[bool] = Field(default=None, description="是否启用页脚")
footer_template: Optional[str] = Field(default=None, description="页脚模板")
show_page_number: Optional[bool] = Field(default=None, description="是否显示页码")
calculation_rules: Optional[CalculationRules] = Field(default=None, description="计算规则配置")
status: Optional[str] = Field(default=None, description="状态")
class DocumentTemplateOut(DocumentTemplateBase):
"""文档模板输出"""
id: str
application_id: Optional[str] = None
calculation_rules: Optional[Dict[str, Any]] = None
status: str = "draft"
is_builtin: bool = False
version: int = 1
sys_create_datetime: Optional[datetime] = None
sys_update_datetime: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class DocumentTemplateListOut(BaseModel):
"""文档模板列表输出"""
id: str
name: str
code: str
category: str
description: Optional[str] = None
form_code: Optional[str] = None
workflow_code: Optional[str] = None
status: str = "draft"
is_builtin: bool = False
version: int = 1
sys_create_datetime: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
# ==================== 导入导出 ====================
class DocumentTemplateImportCheckIn(BaseModel):
"""单据模板导入预检查"""
code: str = Field(..., description="模板编码")
class DocumentTemplateImportCheckOut(BaseModel):
"""单据模板导入预检查结果"""
code_exists: bool = Field(..., description="模板编码是否已存在")
can_import: bool = Field(..., description="是否可以直接导入")
class DocumentTemplateImportIn(DocumentTemplateBase):
"""单据模板配置导入"""
application_id: Optional[str] = Field(None, description="所属应用ID")
# ==================== 生成的文档 ====================
class GeneratedDocumentOut(BaseModel):
"""生成的文档输出"""
id: str
template_id: str
template_code: Optional[str] = None
template_name: Optional[str] = None
form_code: Optional[str] = None
form_data_id: Optional[str] = None
workflow_code: Optional[str] = None
instance_id: Optional[str] = None
document_name: str
document_no: Optional[str] = None
file_id: str
file_size: int = 0
page_count: int = 1
generate_type: str = "manual"
generator_id: Optional[str] = None
generator_name: Optional[str] = None
status: str = "generated"
download_count: int = 0
sealed: bool = False
seal_info: Optional[Dict[str, Any]] = None
sys_create_datetime: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class GenerateDocumentIn(BaseModel):
"""生成文档请求"""
template_id: str = Field(..., description="模板ID")
form_data_id: Optional[str] = Field(default=None, description="表单数据ID")
instance_id: Optional[str] = Field(default=None, description="流程实例ID")
document_name: Optional[str] = Field(default=None, description="文档名称(可选)")
seal_ids: Optional[List[str]] = Field(default=None, description="要使用的签章ID列表")
signature_file_ids: Optional[Dict[str, str]] = Field(default=None, description="手写签名文件ID映射")
class BatchGenerateDocumentIn(BaseModel):
"""批量生成文档请求"""
template_id: str = Field(..., description="模板ID")
form_data_ids: List[str] = Field(..., description="表单数据ID列表")
# ==================== 预览 ====================
class PreviewDocumentIn(BaseModel):
"""预览文档请求"""
template_id: Optional[str] = Field(default=None, description="模板ID(与template_content二选一)")
template_content: Optional[str] = Field(default=None, description="模板内容JSON(与template_id二选一)")
calculation_rules: Optional[Dict[str, Any]] = Field(default=None, description="计算规则配置(用于未保存的模板预览)")
form_data_id: Optional[str] = Field(default=None, description="表单数据ID")
instance_id: Optional[str] = Field(default=None, description="流程实例ID")
test_data: Optional[Dict[str, Any]] = Field(default=None, description="测试数据(优先使用)")
# ==================== 通用响应 ====================
class TemplateCategory(BaseModel):
"""模板分类"""
value: str
label: str
count: int = 0
class DocumentStats(BaseModel):
"""文档统计"""
total_templates: int = 0
published_templates: int = 0
total_documents: int = 0
today_documents: int = 0
@@ -0,0 +1,496 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
文档生成服务层
"""
from typing import Optional, List, Tuple, Dict, Any
from datetime import datetime, date
from sqlalchemy import select, func, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.base_service import BaseService
from online_dev.document_generator.model import DocumentTemplate, GeneratedDocument
from online_dev.document_generator.schema import (
DocumentTemplateCreate, DocumentTemplateUpdate
)
class DocumentTemplateService(BaseService[DocumentTemplate, DocumentTemplateCreate, DocumentTemplateUpdate]):
"""文档模板服务"""
model = DocumentTemplate
@classmethod
async def get_list(
cls,
db: AsyncSession,
page: int = 1,
page_size: int = 20,
application_id: Optional[str] = None,
category: Optional[str] = None,
status: Optional[str] = None,
form_code: Optional[str] = None,
workflow_code: Optional[str] = None,
keyword: Optional[str] = None,
is_builtin: Optional[bool] = None,
) -> Tuple[List[DocumentTemplate], int]:
"""获取模板列表"""
conditions = [cls.model.is_deleted == False]
# 子应用过滤:有 application_id 时过滤该应用,无则只返回主应用数据
if application_id:
conditions.append(cls.model.application_id == application_id)
else:
conditions.append(cls.model.application_id.is_(None))
if category:
conditions.append(cls.model.category == category)
if status:
conditions.append(cls.model.status == status)
if form_code:
conditions.append(cls.model.form_code == form_code)
if workflow_code:
conditions.append(cls.model.workflow_code == workflow_code)
if is_builtin is not None:
conditions.append(cls.model.is_builtin == is_builtin)
if keyword:
conditions.append(
or_(
cls.model.name.ilike(f"%{keyword}%"),
cls.model.code.ilike(f"%{keyword}%"),
cls.model.description.ilike(f"%{keyword}%"),
)
)
# 查询总数
count_stmt = select(func.count()).select_from(cls.model).where(and_(*conditions))
total = (await db.execute(count_stmt)).scalar() or 0
# 查询列表
stmt = (
select(cls.model)
.where(and_(*conditions))
.order_by(cls.model.sys_create_datetime.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await db.execute(stmt)
items = list(result.scalars().all())
return items, total
@classmethod
async def get_by_code(cls, db: AsyncSession, code: str) -> Optional[DocumentTemplate]:
"""根据编码获取模板"""
stmt = select(cls.model).where(
and_(cls.model.code == code, cls.model.is_deleted == False)
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
@classmethod
async def get_by_workflow_code(cls, db: AsyncSession, workflow_code: str) -> List[DocumentTemplate]:
"""根据工作流编码获取绑定的已发布模板列表"""
stmt = select(cls.model).where(
and_(
cls.model.workflow_code == workflow_code,
cls.model.status == "published",
cls.model.is_deleted == False
)
).order_by(cls.model.sort, cls.model.sys_create_datetime)
result = await db.execute(stmt)
return list(result.scalars().all())
@classmethod
async def get_by_form_code(cls, db: AsyncSession, form_code: str) -> List[DocumentTemplate]:
"""根据表单编码获取绑定的已发布模板列表"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"Querying templates for form_code: {form_code}")
# 先查询所有绑定该表单的模板(不限状态)用于调试
debug_stmt = select(cls.model).where(
and_(
cls.model.form_code == form_code,
cls.model.is_deleted == False
)
)
debug_result = await db.execute(debug_stmt)
all_templates = list(debug_result.scalars().all())
logger.info(f"Found {len(all_templates)} templates (all statuses) for form_code {form_code}")
for t in all_templates:
logger.info(f" - Template: {t.name} (code={t.code}, status={t.status}, form_code={t.form_code})")
# 正式查询:只返回已发布的模板
stmt = select(cls.model).where(
and_(
cls.model.form_code == form_code,
cls.model.status == "published",
cls.model.is_deleted == False
)
).order_by(cls.model.sort, cls.model.sys_create_datetime)
result = await db.execute(stmt)
published_templates = list(result.scalars().all())
logger.info(f"Returning {len(published_templates)} published templates")
return published_templates
@classmethod
async def has_templates_by_form_code(cls, db: AsyncSession, form_code: str) -> bool:
"""检查表单是否绑定了已发布的单据模板"""
stmt = select(func.count()).select_from(cls.model).where(
and_(
cls.model.form_code == form_code,
cls.model.status == "published",
cls.model.is_deleted == False
)
)
result = await db.execute(stmt)
count = result.scalar() or 0
return count > 0
@classmethod
async def publish(cls, db: AsyncSession, template_id: str) -> Optional[DocumentTemplate]:
"""发布模板"""
template = await cls.get_by_id(db, template_id)
if not template:
return None
template.status = "published"
template.version += 1
await db.commit()
await db.refresh(template)
return template
@classmethod
async def unpublish(cls, db: AsyncSession, template_id: str) -> Optional[DocumentTemplate]:
"""取消发布"""
template = await cls.get_by_id(db, template_id)
if not template:
return None
template.status = "draft"
await db.commit()
await db.refresh(template)
return template
@classmethod
async def copy(cls, db: AsyncSession, template_id: str, new_code: str, new_name: str) -> Optional[DocumentTemplate]:
"""复制模板"""
template = await cls.get_by_id(db, template_id)
if not template:
return None
# 创建新模板
new_template = DocumentTemplate(
application_id=template.application_id,
name=new_name,
code=new_code,
category=template.category,
description=template.description,
form_code=template.form_code,
workflow_code=template.workflow_code,
template_type=template.template_type,
template_content=template.template_content,
template_css=template.template_css,
page_size=template.page_size,
page_orientation=template.page_orientation,
page_margin=template.page_margin,
watermark_enabled=template.watermark_enabled,
watermark_text=template.watermark_text,
watermark_type=template.watermark_type,
watermark_image_id=template.watermark_image_id,
watermark_opacity=template.watermark_opacity,
watermark_angle=template.watermark_angle,
seal_enabled=template.seal_enabled,
seal_positions=template.seal_positions,
header_enabled=template.header_enabled,
header_template=template.header_template,
footer_enabled=template.footer_enabled,
footer_template=template.footer_template,
show_page_number=template.show_page_number,
status="draft",
is_builtin=False,
version=1,
)
db.add(new_template)
await db.commit()
await db.refresh(new_template)
return new_template
@classmethod
async def get_categories(cls, db: AsyncSession, application_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""获取模板分类统计"""
conditions = [cls.model.is_deleted == False]
# 子应用过滤
if application_id:
conditions.append(cls.model.application_id == application_id)
else:
conditions.append(cls.model.application_id.is_(None))
stmt = (
select(cls.model.category, func.count(cls.model.id).label("count"))
.where(and_(*conditions))
.group_by(cls.model.category)
)
result = await db.execute(stmt)
rows = result.all()
category_labels = {
"leave": "请假单",
"expense": "报销单",
"purchase": "采购单",
"contract": "合同",
"certificate": "证明",
"other": "其他",
}
return [
{
"value": row.category,
"label": category_labels.get(row.category, row.category),
"count": row.count,
}
for row in rows
]
@staticmethod
def _template_to_export_dict(template: DocumentTemplate) -> Dict[str, Any]:
"""将模板转为可导出的 JSON 结构"""
return {
"name": template.name,
"code": template.code,
"category": template.category or "other",
"description": template.description or "",
"form_code": template.form_code,
"workflow_code": template.workflow_code,
"template_type": template.template_type or "designer",
"template_content": template.template_content,
"template_css": template.template_css,
"page_size": template.page_size or "A4",
"page_orientation": template.page_orientation or "portrait",
"page_margin": template.page_margin,
"watermark_enabled": template.watermark_enabled or False,
"watermark_text": template.watermark_text,
"watermark_type": template.watermark_type or "text",
"watermark_image_id": template.watermark_image_id,
"watermark_opacity": template.watermark_opacity if template.watermark_opacity is not None else 30,
"watermark_angle": template.watermark_angle if template.watermark_angle is not None else -45,
"seal_enabled": template.seal_enabled or False,
"seal_positions": template.seal_positions,
"header_enabled": template.header_enabled or False,
"header_template": template.header_template,
"footer_enabled": template.footer_enabled or False,
"footer_template": template.footer_template,
"show_page_number": template.show_page_number if template.show_page_number is not None else True,
"calculation_rules": template.calculation_rules,
}
@classmethod
async def export_config(cls, db: AsyncSession, template_id: str) -> Dict[str, Any]:
"""导出单据模板配置"""
template = await cls.get_by_id(db, template_id)
if not template:
return None
return cls._template_to_export_dict(template)
@classmethod
async def check_import(cls, db: AsyncSession, code: str) -> Dict[str, Any]:
"""导入预检查:编码是否冲突"""
existing = await cls.get_by_code(db, code) if code else None
code_exists = existing is not None
return {
"code_exists": code_exists,
"can_import": not code_exists,
}
@classmethod
async def import_config(
cls,
db: AsyncSession,
data: Dict[str, Any],
) -> DocumentTemplate:
"""导入单据模板配置(创建新草稿模板)"""
if not data.get("name") or not data.get("code"):
raise ValueError("缺少必要字段: name 或 code")
existing = await cls.get_by_code(db, data["code"])
if existing:
raise ValueError(f"模板编码已存在: {data['code']}")
create_data = DocumentTemplateCreate(**data)
return await cls.create(db, create_data)
class GeneratedDocumentService(BaseService[GeneratedDocument, None, None]):
"""生成的文档服务"""
model = GeneratedDocument
@classmethod
async def get_list(
cls,
db: AsyncSession,
page: int = 1,
page_size: int = 20,
template_id: Optional[str] = None,
form_code: Optional[str] = None,
form_data_id: Optional[str] = None,
instance_id: Optional[str] = None,
generator_id: Optional[str] = None,
keyword: Optional[str] = None,
) -> Tuple[List[GeneratedDocument], int]:
"""获取文档列表"""
conditions = [cls.model.is_deleted == False]
if template_id:
conditions.append(cls.model.template_id == template_id)
if form_code:
conditions.append(cls.model.form_code == form_code)
if form_data_id:
conditions.append(cls.model.form_data_id == form_data_id)
if instance_id:
conditions.append(cls.model.instance_id == instance_id)
if generator_id:
conditions.append(cls.model.generator_id == generator_id)
if keyword:
conditions.append(
or_(
cls.model.document_name.ilike(f"%{keyword}%"),
cls.model.document_no.ilike(f"%{keyword}%"),
)
)
# 查询总数
count_stmt = select(func.count()).select_from(cls.model).where(and_(*conditions))
total = (await db.execute(count_stmt)).scalar() or 0
# 查询列表
stmt = (
select(cls.model)
.where(and_(*conditions))
.order_by(cls.model.sys_create_datetime.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await db.execute(stmt)
items = list(result.scalars().all())
return items, total
@classmethod
async def get_by_instance_id(
cls,
db: AsyncSession,
instance_id: str,
) -> List[GeneratedDocument]:
"""根据流程实例ID获取文档列表"""
stmt = (
select(cls.model)
.where(
and_(
cls.model.instance_id == instance_id,
cls.model.is_deleted == False,
)
)
.order_by(cls.model.sys_create_datetime.desc())
)
result = await db.execute(stmt)
return list(result.scalars().all())
@classmethod
async def get_by_template_and_form_data(
cls,
db: AsyncSession,
template_id: str,
form_data_id: str,
) -> List[GeneratedDocument]:
"""根据模板ID和表单数据ID获取文档列表"""
stmt = (
select(cls.model)
.where(
and_(
cls.model.template_id == template_id,
cls.model.form_data_id == form_data_id,
cls.model.is_deleted == False,
)
)
.order_by(cls.model.sys_create_datetime.desc())
)
result = await db.execute(stmt)
return list(result.scalars().all())
@classmethod
async def create_document(
cls,
db: AsyncSession,
template: DocumentTemplate,
file_id: str,
file_size: int,
page_count: int,
document_name: str,
form_data_id: Optional[str] = None,
instance_id: Optional[str] = None,
generator_id: Optional[str] = None,
generator_name: Optional[str] = None,
generate_type: str = "manual",
) -> GeneratedDocument:
"""创建文档记录"""
# 生成文档编号
today = date.today()
document_no = f"DOC{today.strftime('%Y%m%d')}{datetime.now().strftime('%H%M%S%f')[:10]}"
document = GeneratedDocument(
template_id=template.id,
template_code=template.code,
template_name=template.name,
form_code=template.form_code,
form_data_id=form_data_id,
workflow_code=template.workflow_code,
instance_id=instance_id,
document_name=document_name,
document_no=document_no,
file_id=file_id,
file_size=file_size,
page_count=page_count,
generate_type=generate_type,
generator_id=generator_id,
generator_name=generator_name,
status="generated",
)
db.add(document)
await db.commit()
await db.refresh(document)
return document
@classmethod
async def increment_download_count(cls, db: AsyncSession, document_id: str) -> None:
"""增加下载次数"""
document = await cls.get_by_id(db, document_id)
if document:
document.download_count += 1
document.status = "downloaded"
await db.commit()
@classmethod
async def mark_sealed(
cls,
db: AsyncSession,
document_id: str,
seal_info: Dict[str, Any]
) -> Optional[GeneratedDocument]:
"""标记已盖章"""
document = await cls.get_by_id(db, document_id)
if not document:
return None
document.sealed = True
document.seal_info = seal_info
document.status = "sealed"
await db.commit()
await db.refresh(document)
return document
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,386 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
内置文档模板
"""
import json
from typing import Dict, Any, List
# 请假申请单模板
LEAVE_APPLICATION_TEMPLATE = {
"version": "1.0",
"pageConfig": {
"size": "A4",
"orientation": "portrait",
"margin": {"top": 25, "right": 20, "bottom": 25, "left": 20}
},
"elements": [
{
"id": "title",
"type": "text",
"content": "请假申请单",
"position": {"x": 0, "y": 10, "width": 170},
"style": {"fontSize": 22, "fontWeight": "bold", "textAlign": "center"}
},
{
"id": "doc_no",
"type": "field",
"fieldName": "_instance.instance_no",
"label": "单据编号:",
"position": {"x": 120, "y": 25},
"style": {"fontSize": 10, "color": "#666"}
},
{
"id": "applicant_section",
"type": "container",
"position": {"x": 0, "y": 35, "width": 170},
"children": [
{
"id": "applicant_name",
"type": "field",
"fieldName": "applicant_name",
"label": "申请人:",
"position": {"x": 0, "y": 0},
"style": {"fontSize": 12}
},
{
"id": "department",
"type": "field",
"fieldName": "department_name",
"label": "部门:",
"position": {"x": 60, "y": 0},
"style": {"fontSize": 12}
},
{
"id": "apply_date",
"type": "field",
"fieldName": "apply_date",
"label": "申请日期:",
"format": "date",
"position": {"x": 120, "y": 0},
"style": {"fontSize": 12}
}
]
},
{
"id": "leave_info",
"type": "container",
"position": {"x": 0, "y": 50, "width": 170},
"style": {"border": "1px solid #333", "padding": "10"},
"children": [
{
"id": "leave_type",
"type": "field",
"fieldName": "leave_type_name",
"label": "请假类型:",
"position": {"x": 5, "y": 5},
"style": {"fontSize": 12}
},
{
"id": "start_time",
"type": "field",
"fieldName": "start_time",
"label": "开始时间:",
"format": "datetime",
"position": {"x": 5, "y": 15},
"style": {"fontSize": 12}
},
{
"id": "end_time",
"type": "field",
"fieldName": "end_time",
"label": "结束时间:",
"format": "datetime",
"position": {"x": 90, "y": 15},
"style": {"fontSize": 12}
},
{
"id": "duration",
"type": "field",
"fieldName": "duration",
"label": "请假时长:",
"position": {"x": 5, "y": 25},
"style": {"fontSize": 12}
},
{
"id": "reason_label",
"type": "text",
"content": "请假事由:",
"position": {"x": 5, "y": 40},
"style": {"fontSize": 12}
},
{
"id": "reason",
"type": "field",
"fieldName": "reason",
"position": {"x": 5, "y": 50, "width": 160, "height": 30},
"style": {"fontSize": 12}
}
]
},
{
"id": "approval_section",
"type": "text",
"content": "审批记录",
"position": {"x": 0, "y": 145},
"style": {"fontSize": 14, "fontWeight": "bold"}
},
{
"id": "approval_table",
"type": "table",
"dataSource": "_logs",
"columns": [
{"field": "node_name", "label": "审批节点", "width": "60"},
{"field": "action", "label": "操作", "width": "30"},
{"field": "comment", "label": "审批意见", "width": "60"},
{"field": "created_at", "label": "时间", "width": "40"}
],
"position": {"x": 0, "y": 155, "width": 170}
},
{
"id": "signature_section",
"type": "container",
"position": {"x": 0, "y": 220, "width": 170},
"children": [
{
"id": "applicant_sign_label",
"type": "text",
"content": "申请人签名:",
"position": {"x": 0, "y": 0},
"style": {"fontSize": 12}
},
{
"id": "applicant_signature",
"type": "signature",
"fieldName": "applicant_signature",
"position": {"x": 30, "y": 0},
"size": {"width": 40, "height": 20}
},
{
"id": "approver_sign_label",
"type": "text",
"content": "审批人签名:",
"position": {"x": 100, "y": 0},
"style": {"fontSize": 12}
},
{
"id": "approver_signature",
"type": "signature",
"fieldName": "approver_signature",
"position": {"x": 130, "y": 0},
"size": {"width": 40, "height": 20}
}
]
},
{
"id": "seal_placeholder",
"type": "seal",
"sealType": "company",
"position": {"x": 130, "y": 200},
"size": {"width": 40, "height": 40}
}
]
}
# 报销单模板
EXPENSE_REPORT_TEMPLATE = {
"version": "1.0",
"pageConfig": {
"size": "A4",
"orientation": "portrait",
"margin": {"top": 25, "right": 20, "bottom": 25, "left": 20}
},
"elements": [
{
"id": "title",
"type": "text",
"content": "费用报销单",
"position": {"x": 0, "y": 10, "width": 170},
"style": {"fontSize": 22, "fontWeight": "bold", "textAlign": "center"}
},
{
"id": "doc_no",
"type": "field",
"fieldName": "_instance.instance_no",
"label": "单据编号:",
"position": {"x": 120, "y": 25},
"style": {"fontSize": 10, "color": "#666"}
},
{
"id": "basic_info",
"type": "container",
"position": {"x": 0, "y": 35, "width": 170},
"children": [
{
"id": "applicant",
"type": "field",
"fieldName": "applicant_name",
"label": "报销人:",
"position": {"x": 0, "y": 0},
"style": {"fontSize": 12}
},
{
"id": "department",
"type": "field",
"fieldName": "department_name",
"label": "部门:",
"position": {"x": 60, "y": 0},
"style": {"fontSize": 12}
},
{
"id": "apply_date",
"type": "field",
"fieldName": "apply_date",
"label": "申请日期:",
"format": "date",
"position": {"x": 120, "y": 0},
"style": {"fontSize": 12}
}
]
},
{
"id": "expense_table_label",
"type": "text",
"content": "报销明细",
"position": {"x": 0, "y": 50},
"style": {"fontSize": 14, "fontWeight": "bold"}
},
{
"id": "expense_table",
"type": "table",
"dataSource": "expense_items",
"columns": [
{"field": "expense_date", "label": "日期", "width": "30"},
{"field": "expense_type", "label": "费用类型", "width": "35"},
{"field": "description", "label": "说明", "width": "60"},
{"field": "amount", "label": "金额(元)", "width": "30"}
],
"position": {"x": 0, "y": 60, "width": 170}
},
{
"id": "total_section",
"type": "container",
"position": {"x": 0, "y": 130, "width": 170},
"children": [
{
"id": "total_label",
"type": "text",
"content": "合计金额:",
"position": {"x": 100, "y": 0},
"style": {"fontSize": 14, "fontWeight": "bold"}
},
{
"id": "total_amount",
"type": "field",
"fieldName": "total_amount",
"format": "money",
"position": {"x": 130, "y": 0},
"style": {"fontSize": 14, "fontWeight": "bold", "color": "#c00"}
}
]
},
{
"id": "approval_section",
"type": "text",
"content": "审批记录",
"position": {"x": 0, "y": 150},
"style": {"fontSize": 14, "fontWeight": "bold"}
},
{
"id": "approval_table",
"type": "table",
"dataSource": "_logs",
"columns": [
{"field": "node_name", "label": "审批节点", "width": "60"},
{"field": "action", "label": "操作", "width": "30"},
{"field": "comment", "label": "审批意见", "width": "60"},
{"field": "created_at", "label": "时间", "width": "40"}
],
"position": {"x": 0, "y": 160, "width": 170}
},
{
"id": "seal_placeholder",
"type": "seal",
"sealType": "finance",
"position": {"x": 130, "y": 220},
"size": {"width": 40, "height": 40}
}
]
}
# 通用模板
GENERAL_TEMPLATE = {
"version": "1.0",
"pageConfig": {
"size": "A4",
"orientation": "portrait",
"margin": {"top": 25, "right": 20, "bottom": 25, "left": 20}
},
"elements": [
{
"id": "title",
"type": "text",
"content": "{{_instance.title}}",
"position": {"x": 0, "y": 10, "width": 170},
"style": {"fontSize": 22, "fontWeight": "bold", "textAlign": "center"}
},
{
"id": "doc_no",
"type": "field",
"fieldName": "_instance.instance_no",
"label": "单据编号:",
"position": {"x": 120, "y": 25},
"style": {"fontSize": 10, "color": "#666"}
},
{
"id": "content_placeholder",
"type": "text",
"content": "(请在模板设计器中添加表单字段)",
"position": {"x": 0, "y": 50, "width": 170},
"style": {"fontSize": 12, "textAlign": "center", "color": "#999"}
}
]
}
# 内置模板列表
BUILTIN_TEMPLATES: List[Dict[str, Any]] = [
{
"code": "builtin_leave_application",
"name": "请假申请单",
"category": "leave",
"description": "标准请假申请单模板,包含请假信息、审批记录和签章位置",
"template_type": "designer",
"template_content": json.dumps(LEAVE_APPLICATION_TEMPLATE, ensure_ascii=False),
"is_builtin": True,
"status": "published",
},
{
"code": "builtin_expense_report",
"name": "费用报销单",
"category": "expense",
"description": "标准费用报销单模板,包含报销明细、合计金额和审批记录",
"template_type": "designer",
"template_content": json.dumps(EXPENSE_REPORT_TEMPLATE, ensure_ascii=False),
"is_builtin": True,
"status": "published",
},
{
"code": "builtin_general",
"name": "通用模板",
"category": "other",
"description": "通用文档模板,可根据需要自定义内容",
"template_type": "designer",
"template_content": json.dumps(GENERAL_TEMPLATE, ensure_ascii=False),
"is_builtin": True,
"status": "published",
},
]
def get_builtin_templates() -> List[Dict[str, Any]]:
"""获取内置模板列表"""
return BUILTIN_TEMPLATES
@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
@page {
size: {{ page_width }} {{ page_height }};
margin: {{ margin_top }}px {{ margin_right }}px {{ margin_bottom }}px {{ margin_left }}px;
{{ page_number_css|safe }}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: {{ font_family|safe }};
font-size: 12pt;
line-height: 1.6;
color: #333;
}
.document-body {
width: 100%;
position: relative;
}
.element {
margin-bottom: 5px;
}
.element-text {
white-space: pre-wrap;
}
.element-field {
display: inline-block;
}
.element-field .label {
font-weight: normal;
}
.element-field .value {
border-bottom: 1px solid #333;
min-width: 100px;
display: inline-block;
padding: 0 5px;
}
.element-table, .info-table, .detail-table, .info-row {
width: 100%;
border-collapse: collapse;
}
.element-table th,
.element-table td {
border: 1px solid #333;
padding: 8px;
text-align: left;
}
.element-table th {
background-color: #f5f5f5;
font-weight: bold;
}
.element-image {
max-width: 100%;
}
.element-signature {
max-width: 150px;
max-height: 60px;
}
.element-qrcode,
.element-barcode,
.element-image {
width: 100%;
}
.element-divider {
border-top: 1px solid #333;
margin: 10px 0;
}
h1 {
margin: 15px 0;
}
.doc-header {
margin-bottom: 10px;
}
.doc-footer {
margin-top: 30px;
padding-top: 10px;
border-top: 1px solid #ddd;
}
.approval-area {
margin: 20px 0;
}
{{ custom_css|safe }}
</style>
</head>
<body>
<div class="document-body">
{{ content }}
</div>
</body>
</html>
@@ -0,0 +1,28 @@
{% set label_font_size = labelFontSize|default(14) %}
{% set label_color = labelColor|default('#333333') %}
{% set label_font_weight = labelFontWeight|default('normal') %}
{% set label_position = labelPosition|default('bottom') %}
{% set align_map = {'left': 'flex-start', 'center': 'center', 'right': 'flex-end'} %}
{% set justify = align_map.get(textAlign|default('center'), 'center') %}
<div style="display: flex; justify-content: {{ justify }}; width: 100%; box-sizing: border-box; line-height: 0;">
<div style="display: inline-flex; flex-direction: {% if label_position == 'top' %}column-reverse{% elif label_position == 'left' %}row-reverse{% elif label_position == 'right' %}row{% else %}column{% endif %}; align-items: center; gap: 4px; line-height: normal;">
{% if barcode_image %}
<img src="{{ barcode_image }}" style="width: {{ width }}; height: {{ height }}; display: block;" />
{% else %}
<div style="width: {{ width }}; height: {{ height }}; display: inline-flex; border: 2px dashed #dcdfe6; background-color: #f5f7fa; align-items: center; justify-content: center; flex-direction: column; gap: 8px; color: #909399; font-size: 12px;">
<div style="display: flex; align-items: center; gap: 2px;">
<div style="width: 2px; height: 16px; background-color: #909399;"></div>
<div style="width: 2px; height: 16px; background-color: #909399;"></div>
<div style="width: 2px; height: 16px; background-color: #909399;"></div>
<div style="width: 2px; height: 16px; background-color: #909399;"></div>
<div style="width: 2px; height: 16px; background-color: #909399;"></div>
<div style="width: 2px; height: 16px; background-color: #909399;"></div>
<div style="width: 2px; height: 16px; background-color: #909399;"></div>
<div style="width: 2px; height: 16px; background-color: #909399;"></div>
</div>
<span>{{ width }} × {{ height }}</span>
</div>
{% endif %}
{% if label %}<span style="font-size: {{ label_font_size }}px; color: {{ label_color }}; font-weight: {{ label_font_weight }};">{{ label }}</span>{% endif %}
</div>
</div>
@@ -0,0 +1 @@
{% for child in children %}{{ child|safe }}{% endfor %}
@@ -0,0 +1,33 @@
{% set header_font_size = header_font_size|default(14) %}
{% set header_color = header_color|default('#333333') %}
{% set header_font_weight = header_font_weight|default('bold') %}
{% set content_font_size = content_font_size|default(14) %}
{% set content_color = content_color|default('#333333') %}
<table class="detail-table" style="width: 100%; border-collapse: collapse; margin: 10px 0;">
{% if show_header %}
<thead><tr>
{% if show_index %}<th style="border: 1px solid #333; padding: 8px; font-size: {{ header_font_size }}px; color: {{ header_color }}; font-weight: {{ header_font_weight }};{% if header_bg_color %} background-color: {{ header_bg_color }};{% endif %} width: {{ index_width|default('40') }}px;">序号</th>{% endif %}
{% for col in columns %}
<th style="border: 1px solid #333; padding: 8px; font-size: {{ header_font_size }}px; color: {{ header_color }}; font-weight: {{ header_font_weight }};{% if header_bg_color %} background-color: {{ header_bg_color }};{% endif %}{% if col.width %} width: {{ col.width }};{% endif %} text-align: {{ col.align|default('left') }};">{{ col.label }}</th>
{% endfor %}
</tr></thead>
{% endif %}
<tbody>
{% for row in rows %}
<tr>
{% if show_index %}<td style="border: 1px solid #333; padding: 8px; font-size: {{ content_font_size }}px; color: {{ content_color }}; text-align: center;">{{ loop.index }}</td>{% endif %}
{% for col in columns %}
<td style="border: 1px solid #333; padding: 8px; font-size: {{ content_font_size }}px; color: {{ content_color }}; text-align: {{ col.align|default('left') }};">{{ row[col.field]|default('') }}</td>
{% endfor %}
</tr>
{% endfor %}
{% if show_summary and rows %}
<tr>
{% if show_index %}<td style="border: 1px solid #333; padding: 8px; font-size: {{ content_font_size }}px; color: {{ content_color }}; text-align: center; font-weight: bold;">合计</td>{% endif %}
{% for col in columns %}
<td style="border: 1px solid #333; padding: 8px; font-size: {{ content_font_size }}px; color: {{ content_color }}; text-align: {{ col.align|default('left') }}; font-weight: bold;">{{ summary_values[col.field]|default('') }}</td>
{% endfor %}
</tr>
{% endif %}
</tbody>
</table>
@@ -0,0 +1 @@
<hr style="border: none; border-top: {{ line_width }}px {{ line_style }} {{ line_color }}; margin: 10px 0;" />
@@ -0,0 +1,8 @@
{% set label_font_size = label_font_size|default(font_size|default(12)) %}
{% set label_color = label_color|default('#333333') %}
{% set label_font_weight = label_font_weight|default('normal') %}
{% set value_font_size = value_font_size|default(font_size|default(12)) %}
{% set value_color = value_color|default('#333333') %}
<div style="text-align: {{ text_align }}; margin: 10px 0;">
{% for field in fields %}{% if not loop.first %}&nbsp;&nbsp;&nbsp;&nbsp;{% endif %}<span style="font-size: {{ label_font_size }}pt; color: {{ label_color }}; font-weight: {{ label_font_weight }};">{{ field.label }}</span><span style="font-size: {{ value_font_size }}pt; color: {{ value_color }};{% if show_underline %} border-bottom: 1px solid #333; padding-bottom: 2px; display: inline-block; min-width: 50px;{% endif %}">{{ field.value if field.value else '' }}</span>{% endfor %}
</div>
@@ -0,0 +1 @@
{% if label %}<span class="label">{{ label }}</span>{% endif %}<span class="value">{{ value }}</span>
@@ -0,0 +1,5 @@
{% set color = color|default('#666666') %}
<div class="doc-footer" style="text-align: {{ text_align }}; font-size: {{ font_size }}pt; color: {{ color }}; margin-top: 30px; padding-top: 10px; border-top: 1px solid #ddd;">
{% if content %}<div>{{ content }}</div>{% endif %}
{% if footer_parts %}<div style="margin-top: 5px;">{{ footer_parts|join(' | ') }}</div>{% endif %}
</div>
@@ -0,0 +1,13 @@
{% set header_font_size = header_font_size|default(14) %}
{% set header_color = header_color|default('#333333') %}
{% set header_font_weight = header_font_weight|default('bold') %}
<table class="doc-header" style="width: 100%; border: none;">
<tr>
{% if header_type in ['logo', 'logo-text'] and logo_src %}
<td style="width: 80px; border: none;"><img src="{{ logo_src }}" style="max-height: 50px;" /></td>
{% endif %}
{% if header_type in ['text', 'logo-text'] %}
<td style="text-align: {{ text_align }}; border: none; font-size: {{ header_font_size }}pt; font-weight: {{ header_font_weight }}; color: {{ header_color }};">{{ company_name }}</td>
{% endif %}
</tr>
</table>
@@ -0,0 +1,27 @@
{% set label_font_size = labelFontSize|default(14) %}
{% set label_color = labelColor|default('#333333') %}
{% set label_font_weight = labelFontWeight|default('normal') %}
{% set align_map = {'left': 'flex-start', 'center': 'center', 'right': 'flex-end'} %}
{% set justify = align_map.get(textAlign|default('center'), 'center') %}
{% set is_float = position_mode|default('inline') == 'float' %}
{% if is_float %}
<div style="position: absolute; left: {{ float_x|default(0) }}px; top: {{ float_y|default(0) }}px; z-index: {{ float_z_index|default(100) }};">
{% else %}
<div style="display: flex; justify-content: {{ justify }}; width: 100%; box-sizing: border-box;">
{% endif %}
<div style="display: inline-flex; flex-direction: {% if labelPosition == 'top' %}column-reverse{% elif labelPosition == 'left' %}row-reverse{% elif labelPosition == 'right' %}row{% else %}column{% endif %}; align-items: center; gap: 4px;">
{% if src %}
<img src="{{ src }}" style="width: {{ width }}; height: {{ height }}; display: block;" />
{% else %}
<div style="width: {{ width }}; height: {{ height }}; display: flex; border: 2px dashed #dcdfe6; background-color: #f5f7fa; align-items: center; justify-content: center; flex-direction: column; gap: 8px; color: #909399; font-size: 12px;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
<span>{{ width }} × {{ height }}</span>
</div>
{% endif %}
{% if label %}<span style="font-size: {{ label_font_size }}px; color: {{ label_color }}; font-weight: {{ label_font_weight }};">{{ label }}</span>{% endif %}
</div>
</div>
@@ -0,0 +1,12 @@
{% set label_font_size = label_font_size|default(font_size|default(12)) %}
{% set label_color = label_color|default('#333333') %}
{% set label_font_weight = label_font_weight|default('normal') %}
{% set value_font_size = value_font_size|default(font_size|default(12)) %}
{% set value_color = value_color|default('#333333') %}
<table class="info-row" style="width: 100%; border: none; margin: 8px 0;">
<tr>
{% for field in fields %}
<td style="width: {{ field.width|default('auto') }}; border: none; padding: 4px 0;">{% if show_label %}<span style="font-size: {{ label_font_size }}pt; color: {{ label_color }}; font-weight: {{ label_font_weight }};">{{ field.label }}</span>{% endif %}<span style="font-size: {{ value_font_size }}pt; color: {{ value_color }};{% if show_underline %} border-bottom: 1px solid #333; padding-bottom: 2px; display: inline-block; min-width: 50px;{% endif %}">{{ field.value if field.value else '' }}</span></td>
{% endfor %}
</tr>
</table>
@@ -0,0 +1,14 @@
{% set label_font_size = label_font_size|default(14) %}
{% set label_color = label_color|default('#333333') %}
{% set label_font_weight = label_font_weight|default('normal') %}
{% set field_font_size = field_font_size|default(14) %}
{% set field_color = field_color|default('#333333') %}
<table class="info-table" style="width: 100%; border-collapse: collapse; margin: 10px 0;">
{% for row in rows %}
<tr>
{% for cell in row.cells %}
<td colspan="{{ cell.colspan|default(1) }}" rowspan="{{ cell.rowspan|default(1) }}" style="border: {{ border }}; padding: 8px; text-align: {{ cell.align|default('left') }};{% if cell.type == 'label' %} font-size: {{ label_font_size }}px; color: {{ label_color }}; font-weight: {{ label_font_weight }};{% if label_width %} width: {{ label_width }}px;{% endif %}{% if label_bg_color %} background-color: {{ label_bg_color }};{% elif cell.backgroundColor %} background-color: {{ cell.backgroundColor }};{% endif %}{% else %} font-size: {{ field_font_size }}px; color: {{ field_color }};{% if cell.bold %} font-weight: bold;{% endif %}{% if cell.backgroundColor %} background-color: {{ cell.backgroundColor }};{% endif %}{% endif %}">{% if cell.type == 'field' and show_underline %}<span style="border-bottom: 1px solid #333; padding-bottom: 2px; display: inline-block; min-width: 50px;">{{ cell.content if cell.content else '' }}</span>{% else %}{{ cell.content }}{% endif %}</td>
{% endfor %}
</tr>
{% endfor %}
</table>
@@ -0,0 +1,10 @@
{% set label_font_size = label_font_size|default(font_size|default(12)) %}
{% set label_color = label_color|default('#333333') %}
{% set label_font_weight = label_font_weight|default('normal') %}
{% set value_font_size = value_font_size|default(font_size|default(12)) %}
{% set value_color = value_color|default('#333333') %}
{% if show_label %}
<p style="margin: 5px 0;"><span style="font-size: {{ label_font_size }}pt; color: {{ label_color }}; font-weight: {{ label_font_weight }};">{{ label }}</span><span style="font-size: {{ value_font_size }}pt; color: {{ value_color }};{% if show_underline %} border-bottom: 1px solid #333; padding-bottom: 2px; display: inline-block; min-width: 50px;{% endif %}">{{ value if value else '' }}</span></p>
{% else %}
<p style="margin: 5px 0;"><span style="font-size: {{ value_font_size }}pt; color: {{ value_color }};{% if show_underline %} border-bottom: 1px solid #333; padding-bottom: 2px; display: inline-block; min-width: 50px;{% endif %}">{{ value if value else '' }}</span></p>
{% endif %}
@@ -0,0 +1,13 @@
{% set color = color|default('#333333') %}
<div style="font-size: {{ font_size }}pt; line-height: {{ line_height }}; text-align: {{ text_align }}; color: {{ color }}; margin: 10px 0; white-space: pre-wrap; word-wrap: break-word;">
<style>
.paragraph-content p { margin: 0.5em 0; }
.paragraph-content p:first-child { margin-top: 0; }
.paragraph-content p:last-child { margin-bottom: 0; }
.paragraph-content br { display: block; content: ''; margin: 0.5em 0; }
.paragraph-content table { border-collapse: collapse; width: 100%; margin: 1em 0; }
.paragraph-content table td, .paragraph-content table th { border: 1px solid #ddd; padding: 8px; text-align: left; }
.paragraph-content table th { font-weight: 500; }
</style>
<div class="paragraph-content">{{ content|safe }}</div>
</div>
@@ -0,0 +1,27 @@
{% set label_font_size = labelFontSize|default(14) %}
{% set label_color = labelColor|default('#333333') %}
{% set label_font_weight = labelFontWeight|default('normal') %}
{% set label_position = labelPosition|default('bottom') %}
{% set align_map = {'left': 'flex-start', 'center': 'center', 'right': 'flex-end'} %}
{% set justify = align_map.get(textAlign|default('center'), 'center') %}
<div style="display: flex; justify-content: {{ justify }}; width: 100%; box-sizing: border-box; line-height: 0;">
<div style="display: inline-flex; flex-direction: {% if label_position == 'top' %}column-reverse{% elif label_position == 'left' %}row-reverse{% elif label_position == 'right' %}row{% else %}column{% endif %}; align-items: center; gap: 4px; line-height: normal;">
{% if qrcode_image %}
<img src="{{ qrcode_image }}" style="width: {{ width }}; height: {{ height }}; display: block;" />
{% else %}
<div style="width: {{ width }}; height: {{ height }}; display: inline-flex; border: 2px dashed #dcdfe6; background-color: #f5f7fa; align-items: center; justify-content: center; flex-direction: column; gap: 8px; color: #909399; font-size: 12px;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
<rect x="14" y="14" width="3" height="3"></rect>
<rect x="18" y="14" width="3" height="3"></rect>
<rect x="14" y="18" width="3" height="3"></rect>
<rect x="18" y="18" width="3" height="3"></rect>
</svg>
<span>{{ width }} × {{ height }}</span>
</div>
{% endif %}
{% if label %}<span style="font-size: {{ label_font_size }}px; color: {{ label_color }}; font-weight: {{ label_font_weight }};">{{ label }}</span>{% endif %}
</div>
</div>
@@ -0,0 +1 @@
<div class="rich-text" style="margin: 10px 0;">{{ content|safe }}</div>
@@ -0,0 +1,7 @@
<table style="width: 100%; border: none; border-collapse: separate; border-spacing: {{ gap }}px 0;">
<tr>
{% for child in children %}
<td style="{% if child.width %}width: {{ child.width }};{% endif %} vertical-align: top; border: none; padding: 0;">{{ child.content|safe }}</td>
{% endfor %}
</tr>
</table>
@@ -0,0 +1,42 @@
{% set align_map = {'left': 'flex-start', 'center': 'center', 'right': 'flex-end'} %}
{% set justify = align_map.get(text_align, 'flex-end') %}
{% set flex_direction = 'column' if label_position in ['top', 'bottom'] else 'row' %}
{% set label_font_size = label_font_size|default(14) %}
{% set label_color = label_color|default('#333333') %}
{% set label_font_weight = label_font_weight|default('normal') %}
{% set is_float = position_mode|default('inline') == 'float' %}
{% set float_style = 'position: absolute; left: %spx; top: %spx; z-index: %s;'|format(float_x|default(0), float_y|default(0), float_z_index|default(100)) if is_float else 'display: flex; justify-content: %s; margin: 10px 0; width: 100%%; box-sizing: border-box;'|format(justify) %}
{% if seal_image_data %}
<div class="seal-container" style="{{ float_style }}">
<div style="display: flex; flex-direction: {{ flex_direction }}; align-items: center; gap: 8px;">
{% if label and label_position in ['top', 'left'] %}
<span class="seal-label" style="font-size: {{ label_font_size }}px; color: {{ label_color }}; font-weight: {{ label_font_weight }}; white-space: nowrap;">{{ label }}</span>
{% endif %}
<img class="seal-image"
src="{{ seal_image_data }}"
data-seal-id="{{ seal_id }}"
data-seal-image-id="{{ seal_image_id }}"
style="width: {{ width }}px; height: {{ height }}px; object-fit: contain; flex-shrink: 0;"
alt="{{ seal_name }}" />
{% if label and label_position in ['bottom', 'right'] %}
<span class="seal-label" style="font-size: {{ label_font_size }}px; color: {{ label_color }}; font-weight: {{ label_font_weight }}; white-space: nowrap;">{{ label }}</span>
{% endif %}
</div>
</div>
{% else %}
<div class="seal-placeholder" data-seal-type="{{ seal_type }}"
style="{{ float_style }}">
<div style="display: flex; flex-direction: {{ flex_direction }}; align-items: center; gap: 8px;">
{% if label and label_position in ['top', 'left'] %}
<span class="seal-label" style="font-size: {{ label_font_size }}px; color: {{ label_color }}; font-weight: {{ label_font_weight }}; white-space: nowrap;">{{ label }}</span>
{% endif %}
<div style="width: {{ width }}px; height: {{ height }}px; border: 2px dashed #ccc; border-radius: 50%;
display: flex; align-items: center; justify-content: center; color: #999; flex-shrink: 0;">
签章
</div>
{% if label and label_position in ['bottom', 'right'] %}
<span class="seal-label" style="font-size: {{ label_font_size }}px; color: {{ label_color }}; font-weight: {{ label_font_weight }}; white-space: nowrap;">{{ label }}</span>
{% endif %}
</div>
</div>
{% endif %}
@@ -0,0 +1,5 @@
{% if src %}
<img class="element-signature" src="{{ src }}" />
{% else %}
<div class="signature-placeholder"></div>
{% endif %}
@@ -0,0 +1,52 @@
{% set table_font_size = table_font_size|default(14) %}
{% set border_mode = border_mode|default('all') %}
{% set border_color = border_color|default('#333') %}
<table class="smart-table" style="width: 100%; border-collapse: collapse; margin: 10px 0; table-layout: fixed; font-size: {{ table_font_size }}px;">
{% if col_widths %}
<colgroup>
{% for w in col_widths %}
<col style="width: {{ w }}%;" />
{% endfor %}
</colgroup>
{% endif %}
{% for row in rows %}
{% set row_idx = loop.index0 %}
{% set is_first_row = loop.first %}
{% set is_last_row = loop.last %}
<tr>
{% for cell in row.cells %}
{% set col_idx = loop.index0 %}
{% set is_first_col = loop.first %}
{% set is_last_col = loop.last %}
{% set solid_border = '1px solid ' ~ border_color %}
{% set no_border = 'none' %}
{% if border_mode == 'none' %}
{% set b_top = no_border %}{% set b_right = no_border %}{% set b_bottom = no_border %}{% set b_left = no_border %}
{% elif border_mode == 'outer' %}
{% set b_top = solid_border if is_first_row else no_border %}
{% set b_right = solid_border if is_last_col else no_border %}
{% set b_bottom = solid_border if is_last_row else no_border %}
{% set b_left = solid_border if is_first_col else no_border %}
{% elif border_mode == 'inner' %}
{% set b_top = no_border if is_first_row else solid_border %}
{% set b_right = no_border if is_last_col else solid_border %}
{% set b_bottom = no_border if is_last_row else solid_border %}
{% set b_left = no_border if is_first_col else solid_border %}
{% elif border_mode == 'horizontal' %}
{% set b_top = no_border if is_first_row else solid_border %}
{% set b_right = no_border %}
{% set b_bottom = no_border if is_last_row else solid_border %}
{% set b_left = no_border %}
{% elif border_mode == 'vertical' %}
{% set b_top = no_border %}
{% set b_right = no_border if is_last_col else solid_border %}
{% set b_bottom = no_border %}
{% set b_left = no_border if is_first_col else solid_border %}
{% else %}
{% set b_top = solid_border %}{% set b_right = solid_border %}{% set b_bottom = solid_border %}{% set b_left = solid_border %}
{% endif %}
<td colspan="{{ cell.colspan|default(1) }}" rowspan="{{ cell.rowspan|default(1) }}" style="border-top: {{ b_top }}; border-right: {{ b_right }}; border-bottom: {{ b_bottom }}; border-left: {{ b_left }}; padding: 6px 8px; text-align: {{ cell.align|default('left') }};{% if cell.bold %} font-weight: bold;{% endif %}{% if cell.fontSize %} font-size: {{ cell.fontSize }}px;{% endif %}{% if cell.color %} color: {{ cell.color }};{% endif %}{% if cell.backgroundColor %} background-color: {{ cell.backgroundColor }};{% endif %} vertical-align: top; overflow-wrap: break-word;">{{ cell.content }}</td>
{% endfor %}
</tr>
{% endfor %}
</table>
@@ -0,0 +1,4 @@
<div class="smart-text" style="margin: 8px 0; font-size: {{ font_size }}px; line-height: {{ line_height }}; text-align: {{ text_align }}; overflow-wrap: break-word;">{{ content|safe }}</div>
{% if table_html %}
<div class="smart-text-table" style="margin: 4px 0 8px 0;">{{ table_html|safe }}</div>
{% endif %}
@@ -0,0 +1 @@
<div style="height: {{ height }}px;"></div>
@@ -0,0 +1,16 @@
<table class="element-table">
<thead><tr>
{% for col in columns %}
<th style="width: {{ col.width|default('auto') }}">{{ col.label }}</th>
{% endfor %}
</tr></thead>
<tbody>
{% for row in rows %}
<tr>
{% for col in columns %}
<td>{{ row[col.field]|default('') }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
@@ -0,0 +1 @@
<span class="text-content">{{ content }}</span>
@@ -0,0 +1,2 @@
{% set color = color|default('#333333') %}
<h1 style="font-size: {{ font_size }}pt; font-weight: {{ font_weight }}; text-align: {{ text_align }}; color: {{ color }}; margin: 15px 0;">{{ content }}</h1>
@@ -0,0 +1,11 @@
<div class="wysiwyg-content" style="font-size: 14px; line-height: 1.6;">
{{ content }}
</div>
<style>
.wysiwyg-content h1 { font-size: 28px; font-weight: bold; margin: 16px 0 8px; line-height: 1.3; }
.wysiwyg-content h2 { font-size: 22px; font-weight: bold; margin: 14px 0 6px; line-height: 1.3; }
.wysiwyg-content h3 { font-size: 18px; font-weight: bold; margin: 12px 0 4px; line-height: 1.4; }
.wysiwyg-content p { margin: 4px 0; }
.wysiwyg-content ul, .wysiwyg-content ol { padding-left: 24px; margin: 4px 0; }
.wysiwyg-content img { max-width: 100%; height: auto; }
</style>