arc/docs/PDF_TEMPLATE_IMPROVEMENTS.md
2026-04-14 19:34:55 +03:30

19 KiB
Executable file
Raw Permalink Blame History

تحلیل و پیشنهادات بهبود سیستم قالب‌های PDF

📋 خلاصه وضعیت فعلی

سیستم فعلی شامل:

  • Backend: سرویس ReportTemplateService برای مدیریت قالب‌های ذخیره‌شده در دیتابیس
  • Template Engine: Jinja2 با پشتیبانی از Builder و HTML مستقیم
  • PDF Generator: WeasyPrint
  • Frontend: رابط کاربری Flutter برای مدیریت قالب‌ها

🔧 پیشنهادات بهبود فنی

1. مدیریت خطا و اعتبارسنجی

مشکل فعلی:

  • خطاهای قالب به صورت silent نادیده گرفته می‌شوند
  • اعتبارسنجی محدود برای قالب‌ها
  • عدم وجود لاگ مناسب برای خطاهای رندر

پیشنهادات:

# در report_template_service.py
@staticmethod
def render_with_template(
    template: ReportTemplate,
    context: Dict[str, Any],
) -> str:
    try:
        # ... کد فعلی
    except TemplateSyntaxError as e:
        logger.error(f"Template syntax error in {template.id}: {e}")
        raise ApiError("TEMPLATE_SYNTAX_ERROR", f"خطای دستور در قالب: {e}", http_status=400)
    except UndefinedError as e:
        logger.warning(f"Undefined variable in template {template.id}: {e}")
        # می‌توانیم مقدار پیش‌فرض برگردانیم یا خطا بدهیم
        raise ApiError("TEMPLATE_VARIABLE_ERROR", f"متغیر تعریف نشده: {e}", http_status=400)
    except Exception as e:
        logger.exception(f"Template rendering error: {e}")
        raise ApiError("TEMPLATE_RENDER_ERROR", "خطا در رندر قالب", http_status=500)

اعتبارسنجی قالب:

@staticmethod
def validate_template(content_html: str, context: Dict[str, Any] = None) -> List[str]:
    """اعتبارسنجی قالب و برگرداندن لیست خطاها"""
    errors = []
    try:
        env = SandboxedEnvironment(loader=BaseLoader(), autoescape=True)
        template_obj = env.from_string(content_html)
        # تست رندر با context خالی یا نمونه
        test_context = context or {}
        template_obj.render(**test_context)
    except TemplateSyntaxError as e:
        errors.append(f"خطای دستور: {e.message} در خط {e.lineno}")
    except Exception as e:
        errors.append(f"خطای رندر: {str(e)}")
    return errors

2. بهبود مدیریت فونت‌ها

مشکل فعلی:

  • استفاده از Tahoma به جای Vazirmatn (طبق README)
  • عدم پشتیبانی از فونت‌های سفارشی
  • FontConfiguration بدون تنظیمات خاص

پیشنهادات:

# در documents.py
from weasyprint.text.fonts import FontConfiguration

def get_font_config() -> FontConfiguration:
    """پیکربندی فونت برای PDF فارسی"""
    font_config = FontConfiguration()
    # می‌توان فونت‌های سفارشی را اضافه کرد
    return font_config

# در base.html - بهبود CSS
@font-face {
  font-family: 'Vazirmatn';
  src: url('/static/fonts/Vazirmatn-Regular.woff2') format('woff2');
  font-weight: normal;
  font-style: normal;
}

body {
  font-family: 'Vazirmatn', 'Tahoma', 'Arial', sans-serif;
  font-size: 12px;
  line-height: 1.6; /* بهبود خوانایی */
}

3. سیستم کش برای قالب‌ها

پیشنهاد:

from functools import lru_cache
from typing import Optional

class ReportTemplateService:
    _template_cache: Dict[int, Tuple[ReportTemplate, float]] = {}
    CACHE_TTL = 300  # 5 دقیقه
    
    @staticmethod
    def get_template_cached(
        db: Session, 
        template_id: int, 
        business_id: Optional[int] = None
    ) -> Optional[ReportTemplate]:
        """دریافت قالب با کش"""
        import time
        cache_key = template_id
        
        if cache_key in ReportTemplateService._template_cache:
            template, cached_time = ReportTemplateService._template_cache[cache_key]
            if time.time() - cached_time < ReportTemplateService.CACHE_TTL:
                return template
        
        template = ReportTemplateService.get_template(db, template_id, business_id)
        if template:
            ReportTemplateService._template_cache[cache_key] = (template, time.time())
        return template
    
    @staticmethod
    def invalidate_cache(template_id: int):
        """پاک کردن کش یک قالب"""
        ReportTemplateService._template_cache.pop(template_id, None)

4. بهبود فیلترهای Jinja2

پیشنهادات فیلترهای جدید:

# در report_template_service.py - render_with_template

# فیلتر فرمت اعداد فارسی
def _persian_number(v):
    """تبدیل اعداد انگلیسی به فارسی"""
    persian_digits = '۰۱۲۳۴۵۶۷۸۹'
    english_digits = '0123456789'
    s = str(v)
    for en, fa in zip(english_digits, persian_digits):
        s = s.replace(en, fa)
    return s
env.filters["persian"] = _persian_number

# فیلتر فرمت شماره حساب
def _account_number(v, format_type="standard"):
    """فرمت شماره حساب: 1234-567-890"""
    s = str(v).replace("-", "").replace(" ", "")
    if format_type == "standard" and len(s) >= 9:
        return f"{s[:4]}-{s[4:7]}-{s[7:]}"
    return s
env.filters["account"] = _account_number

# فیلتر خلاصه متن
def _truncate(v, length=50, suffix="..."):
    """کوتاه کردن متن"""
    s = str(v)
    if len(s) <= length:
        return s
    return s[:length] + suffix
env.filters["truncate"] = _truncate

# فیلتر شرطی برای نمایش/مخفی کردن
def _show_if(condition, true_val, false_val=""):
    """نمایش شرطی"""
    return true_val if condition else false_val
env.filters["show_if"] = _show_if

5. پشتیبانی از تصاویر و Assets

مشکل فعلی:

  • عدم پشتیبانی مناسب از تصاویر در قالب‌های document
  • Assets فقط در builder پشتیبانی می‌شود

پیشنهاد:

# در documents.py - get_document_pdf_endpoint
# اضافه کردن پشتیبانی از assets
business_logo = None
try:
    if hasattr(business, 'logo_url') and business.logo_url:
        business_logo = business.logo_url
except Exception:
    pass

template_context = {
    # ... موجود
    "assets": {
        "images": {
            "logo": business_logo or "",
            # می‌توان تصاویر دیگر را اضافه کرد
        }
    }
}

6. بهبود مدیریت تاریخ و تقویم

پیشنهاد:

# در documents.py
from app.core.calendar import CalendarConverter, CalendarType

# تبدیل تاریخ سند به تقویم شمسی
document_date_jalali = None
if doc.get("document_date"):
    try:
        dt = datetime.datetime.fromisoformat(str(doc.get("document_date")))
        formatted = CalendarConverter.format_datetime(dt, "jalali")
        document_date_jalali = formatted['formatted']
    except Exception:
        pass

template_context = {
    # ... موجود
    "document_date_jalali": document_date_jalali,
    "document_date_miladi": doc.get("document_date"),
}

7. بهبود ساختار کد endpoint

پیشنهاد:

# استخراج منطق به یک تابع جداگانه
async def _generate_document_pdf(
    db: Session,
    document_id: int,
    business_id: int,
    template_id: Optional[int] = None,
    locale: str = "fa",
    paper_size: Optional[str] = None,
    orientation: Optional[str] = None,
) -> bytes:
    """تولید PDF سند - منطق اصلی"""
    # ... تمام منطق تولید PDF
    return pdf_bytes

@router.get("/documents/{document_id}/pdf")
async def get_document_pdf_endpoint(...):
    """Endpoint wrapper"""
    # بررسی دسترسی
    doc = get_document(db, document_id)
    # ...
    
    # تولید PDF
    pdf_bytes = await _generate_document_pdf(
        db=db,
        document_id=document_id,
        business_id=business_id,
        template_id=template_id,
        locale=locale,
        paper_size=paper_size,
        orientation=orientation,
    )
    
    return Response(...)

🎨 پیشنهادات بهبود ظاهری

1. بهبود قالب base.html

پیشنهادات CSS:

/* بهبود رنگ‌بندی و تم */
:root {
  --primary: #366092;
  --primary-dark: #2a4a6f;
  --secondary: #6c757d;
  --success: #28a745;
  --danger: #dc3545;
  --border: #dee2e6;
  --text: #212529;
  --muted: #6c757d;
  --zebra: #f8f9fa;
  --background: #ffffff;
}

/* بهبود جدول */
table {
  width: 100%;
  border-collapse: separate;
  border-spacing: 0;
  box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}

thead {
  background: linear-gradient(to bottom, var(--primary), var(--primary-dark));
  color: #fff;
}

th {
  padding: 12px 8px;
  font-weight: 600;
  text-transform: uppercase;
  font-size: 10px;
  letter-spacing: 0.5px;
}

tbody tr {
  transition: background-color 0.2s;
}

tbody tr:hover {
  background-color: #e9ecef;
}

/* بهبود header */
.header {
  background: linear-gradient(to right, #f8f9fa, #ffffff);
  padding: 20px;
  border-radius: 8px;
  margin-bottom: 20px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.title {
  font-size: 24px;
  font-weight: 700;
  margin-bottom: 8px;
}

2. بهبود قالب detail.html برای اسناد

پیشنهادات:

{% extends "pdf/base.html" %}
{% block content %}
  <div class="document-header">
    <div class="header-left">
      <div class="document-type-badge">
        {{ document.document_type_name or ('سند' if is_fa else 'Document') }}
      </div>
      <div class="document-info">
        <div class="info-row">
          <span class="info-label">{{ 'کد سند' if is_fa else 'Code' }}:</span>
          <span class="info-value">{{ code or '-' }}</span>
        </div>
        <div class="info-row">
          <span class="info-label">{{ 'تاریخ' if is_fa else 'Date' }}:</span>
          <span class="info-value">{{ document_date_jalali or document_date or '-' }}</span>
        </div>
        {% if description %}
        <div class="info-row">
          <span class="info-label">{{ 'توضیحات' if is_fa else 'Description' }}:</span>
          <span class="info-value">{{ description }}</span>
        </div>
        {% endif %}
      </div>
    </div>
    <div class="header-right">
      <div class="business-info">
        <div class="business-name">{{ business_name or '-' }}</div>
        <div class="generated-at">
          {{ 'تولید شده در' if is_fa else 'Generated at' }}: {{ generated_at }}
        </div>
      </div>
    </div>
  </div>

  <div class="table-container">
    <table class="document-lines">
      <thead>
        <tr>
          <th style="width: 50%;">{{ 'شرح' if is_fa else 'Description' }}</th>
          <th style="width: 25%;">{{ 'بدهکار' if is_fa else 'Debit' }}</th>
          <th style="width: 25%;">{{ 'بستانکار' if is_fa else 'Credit' }}</th>
        </tr>
      </thead>
      <tbody>
        {% for line in lines or [] %}
          <tr>
            <td>{{ line.description or '-' }}</td>
            <td class="amount">{{ line.debit | money(0) | ltr | safe if line.debit else '-' }}</td>
            <td class="amount">{{ line.credit | money(0) | ltr | safe if line.credit else '-' }}</td>
          </tr>
        {% endfor %}
      </tbody>
      <tfoot>
        <tr class="totals-row">
          <td><strong>{{ 'جمع' if is_fa else 'Total' }}</strong></td>
          <td class="amount">
            <strong>{{ lines | sum(attribute='debit') | money(0) | ltr | safe }}</strong>
          </td>
          <td class="amount">
            <strong>{{ lines | sum(attribute='credit') | money(0) | ltr | safe }}</strong>
          </td>
        </tr>
      </tfoot>
    </table>
  </div>

  {% if document.notes %}
  <div class="notes-section">
    <h4>{{ 'یادداشت‌ها' if is_fa else 'Notes' }}</h4>
    <p>{{ document.notes }}</p>
  </div>
  {% endif %}
{% endblock %}

CSS اضافی:

.document-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  margin-bottom: 24px;
  padding: 20px;
  background: #f8f9fa;
  border-radius: 8px;
}

.document-type-badge {
  display: inline-block;
  padding: 6px 12px;
  background: var(--primary);
  color: white;
  border-radius: 4px;
  font-weight: 600;
  font-size: 14px;
  margin-bottom: 12px;
}

.info-row {
  margin: 6px 0;
}

.info-label {
  font-weight: 600;
  color: var(--muted);
  margin-inline-end: 8px;
}

.info-value {
  color: var(--text);
}

.table-container {
  margin: 20px 0;
  overflow-x: auto;
}

.totals-row {
  background: #e9ecef;
  font-weight: 600;
  border-top: 2px solid var(--primary);
}

.notes-section {
  margin-top: 24px;
  padding: 16px;
  background: #fff3cd;
  border-right: 4px solid #ffc107;
  border-radius: 4px;
}

3. پشتیبانی از تم‌های مختلف

پیشنهاد:

# اضافه کردن فیلد theme به ReportTemplate
# themes: 'default', 'minimal', 'professional', 'colorful'

# در render_with_template
theme = getattr(template, 'theme', 'default') or 'default'
context['theme'] = theme

# در قالب
{% if theme == 'minimal' %}
  <!-- استایل مینیمال -->
{% elif theme == 'professional' %}
  <!-- استایل حرفه‌ای -->
{% endif %}

4. بهبود صفحه‌بندی و Break Points

پیشنهادات CSS:

/* جلوگیری از شکستن ردیف‌های جدول */
tbody tr {
  page-break-inside: avoid;
  break-inside: avoid;
}

/* Header و Footer در هر صفحه */
thead {
  display: table-header-group;
}

tfoot {
  display: table-footer-group;
}

/* بهبود فاصله‌گذاری صفحات */
@page {
  margin: 2cm 1.5cm;
  size: A4 portrait;
  
  @top-center {
    content: "{{ business_name }}";
    font-size: 10px;
    color: #666;
  }
  
  @bottom-center {
    content: "صفحه " counter(page) " از " counter(pages);
    font-size: 10px;
    color: #666;
  }
}

📱 پیشنهادات بهبود Frontend (Flutter)

1. بهبود DocumentService

پیشنهاد:

// در document_service.dart
Future<Uint8List> downloadPdf({
  required int documentId,
  int? templateId,
  String? paperSize,
  String? orientation,
}) async {
  try {
    final queryParams = <String, dynamic>{};
    if (templateId != null) queryParams['template_id'] = templateId;
    if (paperSize != null) queryParams['paper_size'] = paperSize;
    if (orientation != null) queryParams['orientation'] = orientation;
    
    final response = await _apiClient.get(
      '/documents/$documentId/pdf',
      queryParameters: queryParams,
      options: Options(
        responseType: ResponseType.bytes,
      ),
    );
    
    return response.data as Uint8List;
  } catch (e) {
    if (e is DioException) {
      throw Exception(e.response?.data['message'] ?? 'خطا در دریافت فایل PDF');
    }
    rethrow;
  }
}

// ذخیره فایل
Future<String> savePdfToFile(Uint8List pdfBytes, String filename) async {
  final directory = await getApplicationDocumentsDirectory();
  final file = File('${directory.path}/$filename');
  await file.writeAsBytes(pdfBytes);
  return file.path;
}

2. UI برای انتخاب قالب

پیشنهاد:

// Dialog برای انتخاب قالب قبل از دانلود PDF
Future<int?> showTemplateSelectorDialog(
  BuildContext context,
  int businessId,
  String moduleKey,
  String? subtype,
) async {
  final service = ReportTemplateService(apiClient);
  final templates = await service.listTemplates(
    businessId: businessId,
    moduleKey: moduleKey,
    subtype: subtype,
    status: 'published',
  );
  
  return showDialog<int>(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('انتخاب قالب'),
      content: SizedBox(
        width: double.maxFinite,
        child: ListView.builder(
          shrinkWrap: true,
          itemCount: templates.length + 1,
          itemBuilder: (context, index) {
            if (index == 0) {
              return ListTile(
                title: const Text('قالب پیش‌فرض'),
                leading: const Icon(Icons.description),
                onTap: () => Navigator.pop(context, null),
              );
            }
            final template = templates[index - 1];
            return ListTile(
              title: Text(template['name'] ?? ''),
              subtitle: Text(template['description'] ?? ''),
              leading: template['is_default'] == true
                  ? const Icon(Icons.star, color: Colors.amber)
                  : const Icon(Icons.description_outlined),
              onTap: () => Navigator.pop(context, template['id'] as int),
            );
          },
        ),
      ),
    ),
  );
}

🚀 پیشنهادات بهبود عملکرد

1. تولید PDF به صورت Async

پیشنهاد:

from celery import shared_task

@shared_task
def generate_document_pdf_async(
    document_id: int,
    template_id: Optional[int] = None,
    user_id: int = None,
) -> str:
    """تولید PDF در پس‌زمینه و ذخیره در storage"""
    # تولید PDF
    # ذخیره در S3 یا local storage
    # برگرداندن URL
    pass

# در endpoint
@router.post("/documents/{document_id}/pdf/async")
async def generate_pdf_async(...):
    """درخواست تولید PDF در پس‌زمینه"""
    task = generate_document_pdf_async.delay(document_id, template_id)
    return {"task_id": task.id, "status": "processing"}

2. فشرده‌سازی PDF

پیشنهاد:

from weasyprint import HTML
import io

def optimize_pdf(pdf_bytes: bytes) -> bytes:
    """فشرده‌سازی PDF (اختیاری)"""
    # می‌توان از PyPDF2 یا pypdf استفاده کرد
    try:
        from pypdf import PdfWriter, PdfReader
        reader = PdfReader(io.BytesIO(pdf_bytes))
        writer = PdfWriter()
        for page in reader.pages:
            writer.add_page(page)
        # فشرده‌سازی
        output = io.BytesIO()
        writer.write(output)
        return output.getvalue()
    except Exception:
        return pdf_bytes  # در صورت خطا، فایل اصلی را برگردان

📝 خلاصه اولویت‌ها

اولویت بالا (فوری):

  1. ✅ بهبود مدیریت خطا و لاگ‌گذاری
  2. ✅ اضافه کردن فیلترهای مفید Jinja2
  3. ✅ بهبود CSS و ظاهر قالب‌ها
  4. ✅ پشتیبانی از تقویم شمسی در قالب

اولویت متوسط:

  1. ✅ سیستم کش برای قالب‌ها
  2. ✅ بهبود DocumentService در Flutter
  3. ✅ UI انتخاب قالب
  4. ✅ پشتیبانی از Assets و تصاویر

اولویت پایین (آینده):

  1. ⏳ تولید PDF به صورت Async
  2. ⏳ پشتیبانی از تم‌های مختلف
  3. ⏳ فشرده‌سازی PDF
  4. ⏳ Preview قالب در UI

📚 منابع و مراجع