Skip to content

ТЗ — Инвойсы/документы (PDF) (cms/commerce-invoices)

Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: referendum (referendum/app/ — dompdf) Статус: ТЗ к разработке

Назначение и возможности

Генерация счетов, накладных и актов из заказа в PDF фоновой очередью, с шаблонами документов и нумерацией серий. Отдельно от фискальных чеков 54-ФЗ (см. /cms-v2/commerce-model) — это первичные документы для B2B/бухгалтерии.

  • Типы документов: счёт на оплату, накладная, акт выполненных работ — из данных заказа
  • PDF-генерация в очереди (dompdf, опционально browsershot для сложной вёрстки)
  • Шаблоны документов с реквизитами юрлица (данные из cms/commerce-b2b)
  • Нумерация серий документов (настраиваемый префикс и сброс нумерации по периоду)
  • Подпись и печать — изображения, накладываемые в шаблон PDF
  • Выдача документа по signed URL (временная ссылка без авторизации)
  • Повторная генерация документа не меняет уже выданный номер серии

Зависимости и выключение

requires: cms/commerce-orders · suggests: cms/commerce-b2b

Поведение при выключении: заказ оформляется и обрабатывается без сопроводительных PDF-документов — клиент не получает счёт/накладную автоматически, деградация документооборота без остановки продаж.

Модель данных

ТаблицаКлючевые поляПримечание
commerce_invoice_documentsid, order_id, type (invoice|waybill|act), number, series, legal_entity_id, version, previous_version_id (nullable), media_id, generated_atсгенерированный документ, номер неизменен после выдачи; type — PHP Enum, FK order_idconstrained()->index(); PDF хранится не file_path, а media_id (медиатека ядра, приватная коллекция — см. Безопасность); version/previous_version_id — новая версия при смене реквизитов юрлица (см. Крайние случаи), старая остаётся доступной
commerce_invoice_templatesid, type, legal_entity_id, blade_view, signature_image_path, stamp_image_pathшаблон документа с реквизитами и подписью/печатью
commerce_invoice_series_countersseries, legal_entity_id, period, last_numberсчётчик нумерации серии за период; уникальный индекс (series, legal_entity_id, period) — последовательность отдельна для каждого юрлица-продавца (см. ⚠️ Противоречие в «Крайние случаи»)

Входные и выходные данные

Входы (whitelist — всё не перечисленное отвергается FormRequest/валидатором):

ИсточникКаналПоляВалидация
Кнопка «сгенерировать» (админ)API POST /admin/commerce-invoices/orders/{id}/generatetype, legal_entity_id (опционально — иначе берётся из заказа/cms/commerce-b2b), Idempotency-Keyorder_id — существующий заказ с позициями; type — whitelist invoice|waybill|act; заказ без позиций отклоняется (см. Крайние случаи)
Автогенерация по событиюслушает OrderPlaced из cms/commerce-ordersorder_idвключена только при autogenerate_on_order_placed=true; та же валидация, что у ручной генерации
Правка шаблонаFilament (commerce_invoice_templates)blade_view, legal_entity_id, signature_image_path, stamp_image_pathфайлы — через медиатеку ядра (MIME-whitelist), не произвольная загрузка на диск
Скачиваниеsigned URL GET /commerce-invoices/{document}/downloadподпись + expiresбез FormRequest — валидность подписи и signed_url_ttl_hours (см. Безопасность)
Импорт историикоманда cms:commerce-invoices:import-legacyфайл/профиль источника → external_id, number, series, type, generated_at, файл PDF (опц.)идемпотентно по external_id (см. Донорский код)

Выходы:

ПолучательКаналФормат
Админ (постановка в очередь)ответ API POST .../generate{data: {document_id, status: "queued"}}
Покупатель/бухгалтерскачивание по signed URLбинарный поток PDF (application/pdf)
Подписчики (уведомления, CRM/1С)события InvoiceDocumentGenerated/InvoiceDocumentGenerationFailedpayload — см. «События и обмен»
Админ (список)GET /admin/commerce-invoices/orders/{id}конверт {data, meta} — документы заказа со статусом генерации

Настройки (группа commerce-invoices)

КлючТипДефолтaffectsPageCacheОписание
commerce-invoices.pdf_enginestringdompdfнетДвижок генерации PDF (dompdf/browsershot)
commerce-invoices.series_reset_periodstringyearнетПериод сброса нумерации серии (year/month/never)
commerce-invoices.signed_url_ttl_hoursint72нетСрок жизни signed URL для выдачи документа
commerce-invoices.max_documents_per_orderint20нетЛимит документов на один заказ — защита от злоупотребления генерацией
commerce-invoices.generation_timeout_secondsint30нетТаймаут PDF-движка на один документ, после — retry
commerce-invoices.generation_max_attemptsint3нетЛимит попыток генерации перед InvoiceDocumentGenerationFailed
commerce-invoices.autogenerate_on_order_placedboolfalseнетАвтогенерация счёта по событию OrderPlaced
commerce-invoices.generation_enabledbooltrueнетKill-switch: аварийная остановка генерации новых документов (например, при массовой ошибке реквизитов) без выключения модуля — уже выданные документы остаются скачиваемыми

API

МетодПутьДоступНазначение
POST/api/v1/admin/commerce-invoices/orders/{id}/generateadmin (commerce-invoices.manage)Постановка генерации документа в очередь
GET/api/v1/commerce-invoices/{document}/downloadsigned URLСкачивание документа по временной подписанной ссылке
GET/api/v1/admin/commerce-invoices/orders/{id}admin (commerce-invoices.view)Список документов заказа

Компоненты

Filament: список документов заказа с кнопкой генерации, редактор шаблона с загрузкой подписи/печати. Команды: cms:commerce-invoices:generate --json, cms:commerce-invoices:import-legacy --source=<профиль> --json (см. Донорский код). Демо-сидер: заказ с комплектом документов всех типов (счёт/накладная/акт) и пример документа в двух версиях (наглядность иммутабельности при смене реквизитов) — для галереи и playground-профиля без ручного оформления заказов.

События и обмен

СобытиеКогдаPayload
InvoiceDocumentGeneratedдокумент сгенерирован и доступен для скачиванияorder_id, document_id, type, number, version
InvoiceDocumentGenerationFailedгенерация PDF завершилась ошибкойorder_id, type, error

Слушает: OrderPlaced из cms/commerce-orders (опциональная автогенерация счёта).

Provides-контракты: не предоставляет из канонического реестра. FilterBus: не использует; реквизиты юрлица читаются сервис-вызовом из cms/commerce-b2b по suggests.

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
cms/commerce-orders1 (событие OrderPlaced)входящееопциональный триггер автогенерации счёта (autogenerate_on_order_placed)
cms/commerce-orders4 (requires)входящеечтение позиций/сумм/ставок НДС заказа для рендера документа (снапшот на момент генерации)
cms/commerce-b2b4 (suggests → сервис-вызов при наличии)входящеереквизиты юрлица покупателя для B2B-документов
MediaService (cms/core-contracts)3 (сервисный контракт)исходящеесохранение сгенерированного PDF в приватную коллекцию медиатеки
Подписчики InvoiceDocumentGenerated1 (событие)исходящееуведомление со ссылкой, интеграция с CRM/1С (при включении)
cms/health1 (событие InvoiceDocumentGenerationFailed)исходящееалерт при сбое генерации

Фоновая работа

Очередь commerce-invoices: генерация PDF (generate) — асинхронно, не блокирует запрос оформления заказа; идемпотентна по (order_id, type) — повторный прогон не создаёт новый номер серии для уже выданного документа. Нумерация серии — атомарный инкремент счётчика в транзакции, в ключе счётчика — (series, legal_entity_id, period). Таймаут одного документа — generation_timeout_seconds, ретраи с backoff до generation_max_attempts, после исчерпания — InvoiceDocumentGenerationFailed + статус, видимый в админке (см. UX-требования), не тихий пропуск.

Эксплуатация (ранбук):

  • метрики: документов сгенерировано/час, доля неудачных генераций, среднее время генерации (заметная разница между dompdf и browsershot), отставание очереди commerce-invoices;
  • алерты: доля InvoiceDocumentGenerationFailed выше порога, очередь отстаёт дольше N минут, счётчик серии не инкрементируется (подозрение на блокировку транзакции);
  • типовые инциденты:
    1. Массовые сбои генерации → проверить pdf_engine и доступность шрифтов/шаблона → cms:commerce-invoices:generate --json вручную на одном заказе для диагностики;
    2. Коллизия номера серии → проверить блокировку транзакции счётчика; не править last_number руками в БД — только миграцией данных с журналом;
    3. Старые документы «потекли» новыми реквизитами → баг иммутабельности — проверить, что генерация читает снапшот шаблона на момент generated_at, а не текущий;
    4. Signed URL не открывается → проверить TTL/подпись, не путать с удалённым media_id (файл в медиатеке существует отдельно от записи документа);
    5. Автогенерация не сработала → проверить autogenerate_on_order_placed и что OrderPlaced действительно издан (лог событий), не полагаться на ручной повтор.
  • бэкап/рестор: commerce_invoice_documents/_templates/_series_counters — часть бэкапа БД; PDF-файлы — часть бэкапа медиатеки (приватная коллекция), не отдельный процесс; после рестора без бэкапа файлов PDF можно пересоздать командой cms:commerce-invoices:generate --json по записям документов при неизменном шаблоне — если реквизиты/шаблон с тех пор изменились, пересоздание невозможно без нарушения иммутабельности (риск фиксируется явно в ранбуке клиента).

Производительность и кеш

Объём — сотни-тысячи документов в день на крупном B2B-инсталле (пики — конец месяца/ квартала, когда бухгалтерия массово запрашивает акты). Горячий путь — не рендер (он в очереди), а отдача уже готового PDF по signed URL: чтение media_id и стрим с приватного диска, без пересчёта.

Индексы: commerce_invoice_documentsorder_id (constrained()->index()), number уникален в рамках (series, legal_entity_id); commerce_invoice_series_counters — составной уникальный (series, legal_entity_id, period) для атомарного инкремента без блокировок между юрлицами.

Кеш как таковой не нужен — документ и его PDF неизменны после выдачи (см. Крайние случаи, иммутабельность). Список документов заказа в админке — обычная выборка по индексированному order_id, не кешируется: частота обращения низкая, а персональные/ финансовые данные в общий page-cache в любом случае не попадают.

Безопасность

Скачивание — только по signed URL с ограниченным signed_url_ttl_hours, без авторизации по дизайну (замена — временная подпись); генерация — FormRequest-whitelist + Idempotency-Key (повторный вызов не плодит документы). Хранение PDF — через медиатеку ядра (MediaService, приватная коллекция commerce-invoices, не публичный диск и не голый file_path) — доступ только по signed URL или сервису модуля, в общий медиа-браузер файл не попадает. Подпись/печать шаблона — файлы через медиатеку ядра.

IDOR: прямой перебор id документа без валидной подписи ссылки ничего не даёт — скачивание не работает без корректной signed URL; список документов заказа в админке — только под commerce-invoices.view, не публичный эндпоинт.

ПДн-паспорт: документ несёт реквизиты покупателя (для юрлица/ИП — не ПДн физлица по 152-ФЗ; для покупателя-физлица — ФИО/адрес доставки в накладной). Срок хранения — как у первичного документа по требованиям к хранению бухгалтерских документов (обычно 5 лет) — это имеет приоритет над «забыть по запросу»: участие в выгрузке по субъекту — да; в «забыть» — мотивированный отказ (обязательное хранение первичных документов для бухгалтерии), решение фиксируется явно, а не молчаливым игнором запроса.

Матрица ролей:

Роль.view (скачать/список).manage (генерация/шаблоны)
Покупатель/B2B-клиентпо signed URL (без роли)
Менеджер✅ (без правки шаблонов юрлица)
Администратор
Studio✅ (реквизиты юрлиц)

Права: commerce-invoices.view, commerce-invoices.manage.

UX-требования

Покупатель/B2B-клиент:

  • скачивание документа — без авторизации (signed URL), но с понятной страницей «ссылка устарела, запросите новую» при истёкшем signed_url_ttl_hours, не голый 403;
  • если реквизиты юрлица поменялись после выдачи — клиент видит явную пометку «версия 2» и ссылку на актуальную версию; старая доступна по своей ссылке без путаницы.

Админ:

  • список документов заказа — статус генерации виден сразу (готов/в очереди/ошибка), без захода в лог очереди;
  • сбой генерации PDF (InvoiceDocumentGenerationFailed) — в списке видна причина (таймаут движка, битый шаблон, отсутствующие реквизиты) и кнопка «повторить генерацию», а не молчаливое отсутствие документа;
  • редактор шаблона — предупреждение при попытке изменить реквизиты юрлица, у которого уже есть выданные документы (старые документы не ломаются, но админ должен понимать последствия — создастся новая версия для новых генераций);
  • массовая генерация (несколько заказов сразу) — прогресс-бар очереди, не блокирующий UI.

Крайние случаи и типовые баги

  • Реквизиты юрлица изменились после выдачи документа → инвойс иммутабелен: уже выданный PDF не перегенерируется с новыми реквизитами; правка создаёт новую версию документа (новый номер серии либо явная пометка «версия 2», конфиг), старая версия остаётся доступна по своей signed URL.
  • Повторный вызов generate для уже сгенерированного документа → идемпотентно по (order_id, type) и Idempotency-Key — номер не меняется, PDF не плодится, второй файл в медиатеке не появляется.
  • Таймаут PDF-движка (сложная вёрстка/большой заказ) → retry с backoff до generation_max_attempts, после исчерпания — InvoiceDocumentGenerationFailed, админ видит причину и кнопку «повторить» (см. UX-требования).
  • Два заказа одновременно запрашивают следующий номер серии одного юрлица → атомарный инкремент счётчика в транзакции (без гонки), коллизий номеров нет.
  • Несколько юрлиц-продавцов выставляют документы одновременно → нумерация раздельна по legal_entity_id — см. ⚠️ Противоречие ниже.
  • Стык периода сброса нумерации (series_reset_period=year на границе года) → первый документ нового периода начинает last_number заново — новая запись счётчика (series, legal_entity_id, period=новый), не переиспользование старой.
  • Заказ отменён/возвращён после выдачи акта → документ не удаляется (исторический экземпляр остаётся частью документооборота); кредит-нота/сторно-документ — отдельным типом по потребности (за пределами MVP, см. открытые вопросы).
  • Выключен cms/commerce-b2b (suggests) → реквизиты юрлица берутся из дефолтного шаблона модуля либо генерация отклоняется понятной ошибкой «нет реквизитов продавца» — не 500.
  • Пустой заказ (0 позиций, тестовый) → генерация отклоняется валидацией, документ на пустые данные не создаётся.
  • Signed URL передан третьему лицу до истечения TTL → документ доступен — дизайн осознанно заменяет авторизацию временной подписью; риск ограничивается коротким signed_url_ttl_hours (72 ч по умолчанию).
  • ⚠️ Противоречие: исходная модель commerce_invoice_documents хранила PDF в file_path — прямом пути на диске без приватного доступа, что нарушает §11 стандарта («файлы — только через медиатеку»). Разрешение: заменено на media_id (MediaService, приватная коллекция, доступ только через signed URL/сервис модуля).
  • ⚠️ Противоречие: commerce_invoice_series_counters имел уникальный индекс (series, period) без legal_entity_id, хотя шаблон документа уже содержит legal_entity_id — при нескольких юрлицах-продавцах последовательности номеров пересекались бы между юрлицами (общий счётчик). Разрешение: legal_entity_id включён в ключ и уникальный индекс счётчика (см. «Модель данных»).

Донорский код

Что взятьПуть
PDF-генерация документов через dompdfreferendum/app/

Импорт истории со старой площадки: cms:commerce-invoices:import-legacy --source=<профиль> [--dry-run] — перенос ранее выданных документов (номера, серии, даты) в commerce_invoice_documents без повторной генерации PDF (если исходный файл доступен — загружается в медиатеку как есть; если нет — документ помечается «архивный, PDF недоступен»); идемпотентность по external_id донора, --dry-run — отчёт расхождений без записи. Критично не сбить нумерацию текущей серии импортом: счётчик синхронизируется на максимум перенесённого number по каждому (series, legal_entity_id, period).

Тесты и приёмка

  • [ ] Контрактный тест: номер документа выдаётся один раз и не меняется при повторной генерации PDF
  • [ ] Генерация PDF идёт в очереди, не блокирует запрос оформления заказа
  • [ ] Signed URL скачивания невалиден после истечения signed_url_ttl_hours
  • [ ] Нумерация серии сбрасывается согласно series_reset_period, без коллизий номеров
  • [ ] Нумерация не пересекается между разными legal_entity_id при одинаковой series/period
  • [ ] Правка реквизитов юрлица после выдачи не меняет уже сгенерированный PDF; создаёт новую версию документа, старая версия доступна по своей ссылке
  • [ ] PDF хранится через MediaService (приватная коллекция), не голым file_path
  • [ ] Таймаут/сбой генерации даёт InvoiceDocumentGenerationFailed; повтор через админку не плодит документ с новым номером
  • [ ] Генерация на пустой заказ (0 позиций) отклоняется валидацией
  • [ ] При выключении модуля заказ оформляется и обрабатывается без ошибок, документы не создаются
  • [ ] Kill-switch generation_enabled=false останавливает генерацию новых документов, ранее выданные остаются скачиваемыми
  • [ ] Права commerce-invoices.view/.manage разграничивают скачивание списка и генерацию/шаблоны
  • [ ] cms:commerce-invoices:import-legacy --dry-run не пишет данные; обычный прогон идемпотентен по external_id и синхронизирует счётчик серии
  • [ ] Контрактный набор cms-testing и testbench-изоляция зелёные, feature-тест на каждый роут
  • [ ] Тестовая БД только commerce-invoices_test; migrate:fresh/refresh/reset запрещены

Внутренняя база знаний студии