File size: 6,565 Bytes
b4f2238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
"""
文书生成器基类

定义所有文书生成器的统一接口和通用方法.
"""

from __future__ import annotations

import logging
import time
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any

from django.utils.translation import gettext_lazy as _

from apps.core.exceptions import ValidationException
from apps.core.utils.path import Path
from apps.documents.services.placeholders.fallback import (
    PLACEHOLDER_FALLBACK_VALUE,
    build_docx_render_context,
)

from .result import GenerationResult

if TYPE_CHECKING:
    from .context_builder import ContextBuilder

logger = logging.getLogger(__name__)


class BaseGenerator(ABC):
    """
    文书生成器基类

    所有具体生成器必须继承此类并实现抽象方法.
    提供统一的生成接口和通用的模板渲染功能.
    """

    # 生成器元信息(子类必须定义)
    name: str = ""  # 生成器名称(唯一标识)
    display_name: str = ""  # 显示名称
    description: str = ""  # 描述
    category: str = "general"  # 分类:litigation, non_litigation, general
    template_type: str = "contract"  # 模板类型:contract, case, authorization

    def __init__(self, context_builder: ContextBuilder | None = None) -> None:
        """
        初始化生成器

        Args:
            context_builder: 上下文构建器,支持依赖注入
        """
        self._context_builder = context_builder

    @property
    def context_builder(self) -> ContextBuilder:
        """获取上下文构建器,支持延迟加载"""
        if self._context_builder is None:
            from .context_builder import ContextBuilder

            self._context_builder = ContextBuilder()
        return self._context_builder

    @abstractmethod
    def get_required_placeholders(self) -> list[str]:
        """
        返回此生成器需要的占位符列表

        Returns:
            占位符名称列表
        """
        pass

    @abstractmethod
    def generate(self, context: dict[str, Any], template_path: str, output_dir: str) -> GenerationResult:
        """
        执行文书生成

        Args:
            context: 替换词上下文字典
            template_path: 模板文件路径
            output_dir: 输出目录

        Returns:
            GenerationResult 生成结果
        """
        pass

    def validate_context(self, context: dict[str, Any]) -> tuple[bool, list[str]]:
        """
        验证上下文是否包含所有必需的占位符

        Args:
            context: 替换词上下文字典

        Returns:
            (是否有效, 缺失的占位符列表)
        """
        missing: list[Any] = []
        required_placeholders = self.get_required_placeholders()

        for key in required_placeholders:
            if key not in context or context[key] is None:
                missing.append(key)

        is_valid = not missing
        return is_valid, missing

    def render_template(self, template_path: str, context: dict[str, Any]) -> bytes:
        """
        使用 docxtpl 渲染模板

        Args:
            template_path: 模板文件路径
            context: 替换词上下文字典

        Returns:
            渲染后的文档内容(字节)

        Raises:
            ValidationException: 模板文件不存在或渲染失败
        """
        try:
            import io
            from datetime import date

            from docxtpl import DocxTemplate

            # 验证模板文件存在
            if not Path(template_path).exists():
                raise ValidationException(
                    message=_("模板文件不存在: %(p)s") % {"p": template_path},
                    code="TEMPLATE_NOT_FOUND",
                    errors={"template_path": f"文件不存在: {template_path}"},
                )

            # 加载并渲染模板
            doc = DocxTemplate(template_path)
            render_context = dict(context)
            render_context.setdefault("年份", str(date.today().year))
            render_context = build_docx_render_context(doc=doc, context=render_context)
            render_context.setdefault("年份", PLACEHOLDER_FALLBACK_VALUE)
            doc.render(render_context)

            # 保存到内存缓冲区
            buffer = io.BytesIO()
            doc.save(buffer)
            return buffer.getvalue()

        except ImportError as e:
            raise ValidationException(
                message=_("docxtpl 库未安装"),
                code="DOCXTPL_NOT_INSTALLED",
                errors={"dependency": "请安装 docxtpl 库: uv add docxtpl"},
            ) from e
        except Exception as e:
            raise ValidationException(
                message=_("模板渲染失败: %(e)s") % {"e": e},
                code="TEMPLATE_RENDER_ERROR",
                errors={"template_path": template_path, "error": str(e)},
            ) from e

    def get_output_filename(self, context: dict[str, Any], template_name: str) -> str:
        """
        生成输出文件名

        Args:
            context: 替换词上下文字典
            template_name: 模板名称(不含扩展名)

        Returns:
            输出文件名
        """
        # 尝试从上下文获取合同名称,否则使用默认名称
        contract_name = context.get("contract_name", "未命名合同")

        # 清理文件名中的非法字符
        safe_contract_name = self._sanitize_filename(contract_name)
        safe_template_name = self._sanitize_filename(template_name)

        return f"{safe_contract_name}_{safe_template_name}.docx"

    def _sanitize_filename(self, filename: str) -> str:
        """
        清理文件名中的非法字符

        Args:
            filename: 原始文件名

        Returns:
            清理后的安全文件名
        """
        # 移除或替换非法字符
        illegal_chars = ["<", ">", ":", '"', "/", "\\", "|", "?", "*"]
        safe_name = filename

        for char in illegal_chars:
            safe_name = safe_name.replace(char, "_")

        # 移除首尾空格和点号
        safe_name = safe_name.strip(" .")

        # 如果文件名为空,使用默认名称
        if not safe_name:
            safe_name = "document"

        return safe_name

    def __str__(self) -> str:
        """字符串表示"""
        return f"{self.display_name or self.name} ({self.category})"

    def __repr__(self) -> str:
        """调试表示"""
        return f"<{self.__class__.__name__}: {self.name}>"