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