Содержание статьи
Счёт на оплату, договор, акт, отчёт о продажах за месяц — без документов CRM остаётся записной книжкой. В FARA CRM мы закрыли эту задачу отчётами на DOCX. Шаблон — обычный файл Word с тегами Jinja, данные — функция на Python, результат — DOCX или PDF во вложении к записи.
Поверх этого выросли редактор DOCX прямо в браузере, конструктор шаблонов с каталогом полей и превью PDF на живой записи, а также рассылка отчётов по расписанию. PDF по умолчанию собирает LibreOffice в контейнере, а без него работает запасной движок на чистом Python.
Ниже — почему мы выбрали DOCX, как устроены шаблоны и данные, как получаем PDF, какой редактор встроили в браузер и на какие грабли наступили по дороге.
Почему DOCX
Главный аргумент — документы в бизнесе уже живут в Word. У бухгалтера и юриста есть готовые счёт, договор и допсоглашение, и правят их они сами. Если шаблон — это DOCX, новая формулировка в договоре не требует разработчика и релиза.
Второй аргумент — результат тоже DOCX. Менеджер скачивает договор, поправляет пункт под клиента и только потом отправляет. Когда нужен неизменяемый документ, тот же шаблон отдаёт PDF.
Шаблонизатор — docxtpl, то есть Jinja2 внутри DOCX. Теги {{ }}, {% if %} и {% for %} пишутся прямо в тексте документа, а {%tr for %} размножает строку таблицы. Разработчику не нужно учить новый язык, а пользователь видит в Word обычный текст с фигурными скобками.
Альтернативы есть, но они дороже в поддержке. В схеме HTML → PDF каждую правку шаблона делает разработчик, а дизайнеры отчётов вроде JasperReports требуют отдельной программы и своего рантайма.
Цена выбора — PDF. DOCX не хранит вёрстку страниц: переносы строк и страниц вычисляет тот, кто документ открывает. Значит, на сервере нужен движок, который верстает как Word, — про него отдельный раздел ниже.
Как это устроено
Отчёт в FARA CRM — это три вещи: DOCX-шаблон с тегами, функция на Python, которая собирает данные, и запись с настройками. В настройках указаны модель, формат по умолчанию и тип: документ по записи, как счёт по заказу, или сводный отчёт, как продажи за неделю.
Кнопка «Печать» на форме, рассылка по расписанию и превью в конструкторе вызывают один и тот же метод. Он собирает данные, подставляет их в шаблон и при необходимости делает PDF, поэтому документ везде выглядит одинаково.

Новый канал доставки — это ещё один вход: сборку писать заново не нужно.
Код разложен по трём модулям. Ядро report_docx — это движок и кнопка «Печать», sales_report_docx — функции данных и образцы документов по продажам, report_docx_design — конструктор. Без конструктора всё работает, просто шаблоны правят в Word и загружают файлом.
Шаблоны: Jinja внутри Word
Шаблон — обычный DOCX, а теги в нём — просто текст. Вот начало образца «Отчёт по продажам за период» так, как его видно в Word:
Отчёт по продажам за период Период: с {{ date_from }} по {{ date_to }} Сделок: {{ count }}, на сумму {{ total }} руб.
Таблица сделок в том же шаблоне:
№
Заказ
Дата
Клиент
Менеджер
Сумма
{%tr for row in rows %}
{{ loop.index }}
{{ row.name }}
{{ row.date }}
{{ row.partner }}
{{ row.user }}
{{ row.amount }}
{%tr endfor %}
Префикс tr — расширение docxtpl: тег управляет всей строкой таблицы, а не абзацем в ячейке. Строки с for и endfor исчезают из результата, а строка между ними повторяется для каждой сделки. В договорах тем же способом скрываются пустые реквизиты: {% if c_ogrn %}…{% endif %}.
Даты и деньги форматируют фильтры: {{ amount_total|money }} превращается в «1 234 567,80», а {{ date_order|date }} — в «30.09.2026». Печати и подписи — это картинки-заглушки в шаблоне, которые при сборке подменяются настоящими.
Данные: поля записи плюс функция на Python
Для документа по записи большая часть данных берётся сама: в шаблон попадают все поля записи, связи на один уровень вглубь ({{ partner_id.name }}) и строки таблиц вроде позиций заказа. Поля, закрытые правами доступа, в шаблон не попадают.
Функция данных нужна для вычисляемого: НДС, суммы прописью, реквизитов и сводок. Это @staticmethod вида async def name(env, **params) -> dict на классе модели, и ключи словаря, который она возвращает, — это теги шаблона. Предметный модуль добавляет её к чужой модели через @extend(Sale), не трогая модуль продаж.
Вот упрощённая функция сводного отчёта за период. Записи у неё нет, есть параметр days из cron-задачи или из запроса:
@extend(Sale) class SaleReportMixin: @staticmethod async def sales_period_data(env, days: int = 30) -> dict: date_from = datetime.now(timezone.utc) — timedelta(days=days) sales = await env.models.sale.search( filter=[(«active», «=», True), («date_order», «>=», date_from)], fields=[«id», «name», «date_order», «amount_total», «partner_id», «user_id»], ) rows = […] # строка на каждую сделку by_user = […] # итоги по менеджерам return {«count»: len(rows), «total»: …, «rows»: rows, «by_user»: by_user}
Ключи словаря снаружи не видны, пока функцию не запустишь. Поэтому их описывает декоратор @report_fields: подпись, тип вроде «деньги» или «дата» и поля списков. По этим ключам конструктор показывает поля в каталоге, а движок сам решает, какую функцию вызвать: если в шаблоне стоит {{ bik }}, вызывается функция, которая объявила bik.
PDF: LibreOffice и запасной движок на чистом Python
Первая версия умела PDF только через LibreOffice: нет его на сервере — нет и PDF. А LibreOffice Writer добавляет к образу около 400 МБ. Поэтому мы написали запасной конвертер на python-docx и fpdf2, а потом всё равно включили LibreOffice в Docker-образ по умолчанию. Движок выбирается сам:
Режим
Когда включается
Что даёт
LibreOffice headless
В системе нашлись libreoffice или soffice
Вёрстку как в Word; образ тяжелее примерно на 400 МБ
Встроенный: python-docx + fpdf2
LibreOffice не найден
Текст, списки, таблицы, картинки, печати; без колонтитулов, цветов, фигур и обтекания
В Docker-образе LibreOffice включён по умолчанию, а выключается аргументом сборки WITH_LIBREOFFICE=0. Вместе с ним ставится пакет fonts-liberation: у этих шрифтов те же ширины букв, что у Times New Roman и Arial. Без них строки переносились бы не так, как в Word.
Самая интересная ловушка — параллельные конверсии. Два soffice с общим профилем мешают друг другу: второй молча передаёт документ первому и выходит без PDF. Свежий профиль на каждый вызов лечит это, но стоит около двух секунд на инициализацию. Мы выбрали середину: свой профиль на каждый воркер и одна конверсия за раз внутри процесса.
Какой движок сейчас работает, администратор видит прямо в списке шаблонов: зелёный бейдж «PDF: LibreOffice» или жёлтый «PDF: встроенный конвертер» с подсказкой, как включить точную вёрстку.

Запасной движок — 720 строк на python-docx и fpdf2. Он переносит текст, нумерованные списки, таблицы с объединёнными ячейками, печати и подписи. Колонтитулов, цветов и обтекания текстом нет, но для счетов, договоров и сводок этого хватает.
Больше всего времени ушло на то, что Word делает незаметно. Номера пунктов он хранит не в тексте, а в отдельном numbering.xml, и считать их приходится самим. А в формах вроде счёта часть границ таблицы спрятана белыми линиями, и их нужно не рисовать.
Редактор DOCX прямо в браузере
Без редактора правка шаблона выглядит так: скачать файл, открыть Word, загрузить обратно. Мы встроили в CRM редактор docx-editor и обернули его React-компонент в свой DocxEditorFrame. Наружу он отдаёт три действия: сохранить документ, вставить текст и вставить поле.
Открытой части редактора под Apache-2.0 нам хватило. Платные модули, например конвертация в PDF, не понадобились: PDF мы и так делаем на сервере.
Редактор весит около 3,5 МБ, поэтому грузится отдельным чанком через React.lazy — только когда пользователь открыл документ. Пригодился он не только для отчётов: у любого DOCX-вложения в CRM появилась кнопка «Редактировать».
[Скриншот: DOCX-вложение во встроенном редакторе]
Конструктор шаблона
Конструктор — это страница из трёх колонок: слева шаблон в редакторе, посередине каталог полей, справа превью с данными настоящей записи.
-
Клик по полю вставляет его в место курсора. В документе оно выглядит как поле Word с подписью, например «БИК», а внутри лежит готовый тег {{ bik }}. Для денег и дат фильтр подставляется сам.
-
У списков есть кнопки «начало цикла» и «конец цикла» для строки таблицы, так что писать {%tr for %} руками не нужно.
-
Когда пользователь делает паузу в правках на 1,2 секунды, редактор отдаёт документ серверу, а тот собирает его с данными выбранной записи. Результат можно смотреть как DOCX или как PDF.
Для сводного отчёта вместо записи задаются параметры, например {«days»: 7}. Администратор может открыть конструктор прямо из меню «Печать» на форме записи — тогда эта запись сразу попадёт в превью.

Отчёт по расписанию
Сводные отчёты удобно получать, не заходя в CRM. Cron у нас исполняет только пару «модель + метод»: произвольный Python-код в задачах мы отключили ради безопасности. Поэтому рассылка — это два метода модели шаблона, cron_send_report_email и cron_send_report_telegram, а задача хранит только аргументы:
{«template_id»: 1, «to»: «manager@example.com», «subject»: «Продажи за неделю», «params»: {«days»: 7}}
params уходят в функцию данных, поэтому отчёт при каждом запуске собирается заново. Письмо уходит через email-коннектор, файл в Telegram — через бота. Две задачи-примера создаются выключенными: достаточно поправить адрес и включить.
Что получилось
Основной коммит — 63 файла и около 4,9 тысячи новых строк вместе с документацией. В него вошли редактор, конструктор, запасной PDF-конвертер, рассылка и семь образцов документов: счёт, отчёт за период и пять документов по договору.
Три урока, которые пригодятся и вам:
-
Снимайте служебные обёртки перед рендером. Конструктор вставляет поля как элементы управления Word: в редакторе это удобно, а в готовом документе и PDF они мешают.
-
Запускайте LibreOffice с отдельным профилем на процесс. Иначе параллельные конвертации молча теряют PDF.
-
Проверьте, какой fpdf стоит в окружении. Старый PyFPDF и fpdf2 ставятся под одним именем модуля, и мы на этом споткнулись.
Дальше хотим научить конструктор спискам и агрегациям, чтобы простые сводные отчёты собирались без функции на Python.
Исходники — на GitHub, демо — demo.faracrm.com, документация — docs.faracrm.com.
