Fix: estimates geo v2

This commit is contained in:
Arsen
2026-02-04 00:11:19 +05:00
commit 3f0086f88e
22567 changed files with 4348823 additions and 0 deletions

31
backend/pdf_generator/README.md Executable file
View File

@@ -0,0 +1,31 @@
# Модуль генерации PDF сметы (Python)
Генерация PDF с корректной кириллицей через ReportLab.
## Установка
1. Установите Python 3.8+ (если ещё не установлен).
2. Установите зависимости:
```bash
cd backend/pdf_generator
pip install -r requirements.txt
```
или из корня backend:
```bash
pip install -r pdf_generator/requirements.txt
```
3. **Шрифты (кириллица):**
- На **Windows** скрипт по умолчанию использует `C:\Windows\Fonts\arial.ttf`, дополнительно ничего класть не нужно.
- Для других ОС или своего шрифта положите TTF в папку `pdf_generator/fonts/` (см. `fonts/README.md`).
## Использование
Backend при запросе «Скачать PDF» сначала вызывает этот скрипт. Если Python или скрипт недоступны, используется генерация на Node (PDFKit).
Ручной запуск (для проверки):
```bash
cd backend
echo "{\"number\":\"1\",\"direction\":{\"name\":\"Тест\"},\"objectName\":\"Объект\",\"customer\":\"Заказчик\",\"executor\":\"Исполнитель\",\"items\":[],\"totals\":[]}" | python pdf_generator/generate_estimate_pdf.py > test.pdf
```

View File

@@ -0,0 +1,17 @@
# Шрифты для PDF сметы
Для корректного отображения кириллицы в PDF положите сюда TTF-шрифты с поддержкой русского языка.
**Варианты:**
1. **PT Sans** (рекомендуется, лицензия OFL)
Скачайте с [Google Fonts](https://fonts.google.com/specimen/PT+Sans) и поместите в эту папку:
- `PTSans-Regular.ttf`
- `PTSans-Bold.ttf`
2. **Arial**
На Windows можно скопировать из `C:\Windows\Fonts\arial.ttf` (и при необходимости arialbd.ttf для жирного).
3. Любой другой TTF с кириллицей (например, DejaVu Sans, Open Sans).
После добавления шрифтов перезапустите backend.

View File

@@ -0,0 +1,249 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Генерация PDF сметы с корректной кириллицей.
Читает JSON со структурой сметы из stdin, пишет PDF в stdout.
Использование:
echo '<json>' | python generate_estimate_pdf.py > smeta.pdf
или вызов из Node.js с pipe.
"""
import json
import sys
import os
import html
from io import BytesIO
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak
def find_cyrillic_font():
"""Найти TTF с поддержкой кириллицы: fonts/ рядом со скриптом или Windows Arial."""
script_dir = os.path.dirname(os.path.abspath(__file__))
candidates_regular = [
os.path.join(script_dir, "fonts", "PTSans-Regular.ttf"),
os.path.join(script_dir, "fonts", "pt-sans.regular.ttf"),
os.path.join(script_dir, "fonts", "arial.ttf"),
"C:\\Windows\\Fonts\\arial.ttf",
"C:\\Windows\\Fonts\\times.ttf",
]
candidates_bold = [
os.path.join(script_dir, "fonts", "PTSans-Bold.ttf"),
os.path.join(script_dir, "fonts", "arialbd.ttf"),
"C:\\Windows\\Fonts\\arialbd.ttf",
]
regular = None
bold = None
for p in candidates_regular:
if os.path.isfile(p):
regular = p
break
for p in candidates_bold:
if os.path.isfile(p):
bold = p
break
fonts_dir = os.path.join(script_dir, "fonts")
if not regular and os.path.isdir(fonts_dir):
for f in os.listdir(fonts_dir):
if not f.lower().endswith(".ttf"):
continue
p = os.path.join(fonts_dir, f)
if "bold" in f.lower() or "700" in f or "bold" in p.lower():
bold = bold or p
else:
regular = regular or p
return regular, bold
def register_fonts():
regular, bold = find_cyrillic_font()
if not regular:
raise FileNotFoundError(
"Шрифт с кириллицей не найден. Положите TTF (например PTSans-Regular.ttf, Arial) в папку pdf_generator/fonts/"
)
pdfmetrics.registerFont(TTFont("Cyrillic", regular))
if bold and bold != regular:
pdfmetrics.registerFont(TTFont("CyrillicBold", bold))
else:
pdfmetrics.registerFont(TTFont("CyrillicBold", regular))
return "Cyrillic", "CyrillicBold"
def num_fmt(value):
try:
return f"{float(value):,.2f}".replace(",", " ").replace(".", ",")
except (TypeError, ValueError):
return str(value)
def currency_fmt(value):
try:
return f"{float(value):,.2f} руб.".replace(",", " ").replace(".", ",")
except (TypeError, ValueError):
return str(value)
def build_pdf(data, font_name, font_bold):
buffer = BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
leftMargin=15 * mm,
rightMargin=15 * mm,
topMargin=15 * mm,
bottomMargin=15 * mm,
)
story = []
# Стили
style_center = ParagraphStyle(
name="Center",
fontName=font_bold,
fontSize=14,
alignment=1,
spaceAfter=6,
)
style_center_small = ParagraphStyle(
name="CenterSmall",
fontName=font_name,
fontSize=11,
alignment=1,
spaceAfter=4,
)
style_normal = ParagraphStyle(
name="Normal",
fontName=font_name,
fontSize=10,
spaceAfter=4,
)
style_cell = ParagraphStyle(
name="TableCell",
fontName=font_name,
fontSize=8,
leading=9,
leftIndent=0,
rightIndent=0,
wordWrap="CJK",
)
number = data.get("number", "")
direction_name = (data.get("direction") or {}).get("name", "")
object_name = data.get("objectName", "")
customer = data.get("customer", "")
executor = data.get("executor", "")
story.append(Paragraph(f"Исполнительная смета №{number}", style_center))
story.append(Paragraph(f"{direction_name} на объекте:", style_center_small))
story.append(Paragraph(f"«{object_name}»", style_center_small))
story.append(Spacer(1, 6 * mm))
story.append(Paragraph(f"Наименование организации Заказчика: «{customer}»", style_normal))
story.append(Paragraph(f"Наименование организации Исполнителя: «{executor}»", style_normal))
story.append(Spacer(1, 6 * mm))
# Таблица: позиции по разделам
col_widths = [25, 180, 90, 45, 45, 75]
headers = ["", "Наименование работ", "Обоснование", "Цена", "Объем", "Стоимость"]
section_names = {
"field": "Полевые работы",
"office": "Камеральные работы",
"laboratory": "Лабораторные работы",
"other": "",
}
items = data.get("items") or []
by_section = {}
for it in items:
sec = it.get("sectionType") or "other"
by_section.setdefault(sec, []).append(it)
for sec_key in ("field", "office", "laboratory", "other"):
sec_items = by_section.get(sec_key, [])
if not sec_items:
continue
title = section_names.get(sec_key)
if title:
story.append(Paragraph(title, ParagraphStyle(name="Sec", fontName=font_bold, fontSize=9, spaceAfter=2)))
table_data = [headers]
for it in sec_items:
raw_name = (it.get("workName") or "").strip()
safe_name = html.escape(raw_name)
wname_para = Paragraph(safe_name, style_cell)
just_text = (it.get("justification") or "")[:25]
table_data.append([
str(it.get("orderNumber", "")),
wname_para,
just_text,
num_fmt(it.get("basePrice")),
num_fmt(it.get("quantity")),
num_fmt(it.get("totalPrice")),
])
t = Table(table_data, colWidths=col_widths)
t.setStyle(TableStyle([
("FONTNAME", (0, 0), (-1, 0), font_bold),
("FONTSIZE", (0, 0), (-1, -1), 8),
("FONTNAME", (0, 1), (-1, -1), font_name),
("ALIGN", (0, 0), (0, -1), "CENTER"),
("ALIGN", (3, 0), (-1, -1), "RIGHT"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("GRID", (0, 0), (-1, -1), 0.5, colors.black),
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
]))
story.append(t)
story.append(Spacer(1, 4 * mm))
# Итоги
totals = data.get("totals") or []
if totals:
story.append(Spacer(1, 4 * mm))
for tot in totals:
label = tot.get("label", "")
desc = tot.get("description")
if desc:
label = f"{label} ({desc})"
val = currency_fmt(tot.get("resultValue"))
story.append(Paragraph(f"{label}: {val}", style_normal))
story.append(Spacer(1, 10 * mm))
story.append(Paragraph("Выполнил: _____________________", style_normal))
doc.build(story)
return buffer.getvalue()
def main():
try:
font_name, font_bold = register_fonts()
except FileNotFoundError as e:
sys.stderr.write(str(e) + "\n")
sys.exit(1)
try:
if len(sys.argv) >= 2:
with open(sys.argv[1], "r", encoding="utf-8") as f:
data = json.load(f)
else:
raw = sys.stdin.buffer.read()
if hasattr(raw, "decode"):
raw = raw.decode("utf-8")
data = json.loads(raw)
except Exception as e:
sys.stderr.write(f"JSON error: {e}\n")
sys.exit(2)
try:
pdf_bytes = build_pdf(data, font_name, font_bold)
except Exception as e:
sys.stderr.write(f"PDF error: {e}\n")
sys.exit(3)
sys.stdout.buffer.write(pdf_bytes)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1 @@
reportlab>=4.0.0