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

731 lines
19 KiB
Markdown
Executable file
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# تحلیل و پیشنهادات بهبود سیستم قالب‌های PDF
## 📋 خلاصه وضعیت فعلی
سیستم فعلی شامل:
- **Backend**: سرویس `ReportTemplateService` برای مدیریت قالب‌های ذخیره‌شده در دیتابیس
- **Template Engine**: Jinja2 با پشتیبانی از Builder و HTML مستقیم
- **PDF Generator**: WeasyPrint
- **Frontend**: رابط کاربری Flutter برای مدیریت قالب‌ها
---
## 🔧 پیشنهادات بهبود فنی
### 1. مدیریت خطا و اعتبارسنجی
**مشکل فعلی:**
- خطاهای قالب به صورت silent نادیده گرفته می‌شوند
- اعتبارسنجی محدود برای قالب‌ها
- عدم وجود لاگ مناسب برای خطاهای رندر
**پیشنهادات:**
```python
# در 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)
```
**اعتبارسنجی قالب:**
```python
@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 بدون تنظیمات خاص
**پیشنهادات:**
```python
# در 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. سیستم کش برای قالب‌ها
**پیشنهاد:**
```python
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
**پیشنهادات فیلترهای جدید:**
```python
# در 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 پشتیبانی می‌شود
**پیشنهاد:**
```python
# در 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. بهبود مدیریت تاریخ و تقویم
**پیشنهاد:**
```python
# در 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
**پیشنهاد:**
```python
# استخراج منطق به یک تابع جداگانه
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:**
```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 برای اسناد
**پیشنهادات:**
```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 اضافی:**
```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. پشتیبانی از تم‌های مختلف
**پیشنهاد:**
```python
# اضافه کردن فیلد 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:**
```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
**پیشنهاد:**
```dart
// در 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 برای انتخاب قالب
**پیشنهاد:**
```dart
// 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
**پیشنهاد:**
```python
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
**پیشنهاد:**
```python
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. ✅ پشتیبانی از تقویم شمسی در قالب
### اولویت متوسط:
5. ✅ سیستم کش برای قالب‌ها
6. ✅ بهبود DocumentService در Flutter
7. ✅ UI انتخاب قالب
8. ✅ پشتیبانی از Assets و تصاویر
### اولویت پایین (آینده):
9. ⏳ تولید PDF به صورت Async
10. ⏳ پشتیبانی از تم‌های مختلف
11. ⏳ فشرده‌سازی PDF
12. ⏳ Preview قالب در UI
---
## 📚 منابع و مراجع
- [WeasyPrint Documentation](https://weasyprint.org/)
- [Jinja2 Template Designer Documentation](https://jinja.palletsprojects.com/)
- [CSS Paged Media](https://www.w3.org/TR/css-page-3/)