Тема
ТЗ — Инвойсы/документы (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_documents | id, 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_id — constrained()->index(); PDF хранится не file_path, а media_id (медиатека ядра, приватная коллекция — см. Безопасность); version/previous_version_id — новая версия при смене реквизитов юрлица (см. Крайние случаи), старая остаётся доступной |
commerce_invoice_templates | id, type, legal_entity_id, blade_view, signature_image_path, stamp_image_path | шаблон документа с реквизитами и подписью/печатью |
commerce_invoice_series_counters | series, legal_entity_id, period, last_number | счётчик нумерации серии за период; уникальный индекс (series, legal_entity_id, period) — последовательность отдельна для каждого юрлица-продавца (см. ⚠️ Противоречие в «Крайние случаи») |
Входные и выходные данные
Входы (whitelist — всё не перечисленное отвергается FormRequest/валидатором):
| Источник | Канал | Поля | Валидация |
|---|---|---|---|
| Кнопка «сгенерировать» (админ) | API POST /admin/commerce-invoices/orders/{id}/generate | type, legal_entity_id (опционально — иначе берётся из заказа/cms/commerce-b2b), Idempotency-Key | order_id — существующий заказ с позициями; type — whitelist invoice|waybill|act; заказ без позиций отклоняется (см. Крайние случаи) |
| Автогенерация по событию | слушает OrderPlaced из cms/commerce-orders | order_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/InvoiceDocumentGenerationFailed | payload — см. «События и обмен» |
| Админ (список) | GET /admin/commerce-invoices/orders/{id} | конверт {data, meta} — документы заказа со статусом генерации |
Настройки (группа commerce-invoices)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
commerce-invoices.pdf_engine | string | dompdf | нет | Движок генерации PDF (dompdf/browsershot) |
commerce-invoices.series_reset_period | string | year | нет | Период сброса нумерации серии (year/month/never) |
commerce-invoices.signed_url_ttl_hours | int | 72 | нет | Срок жизни signed URL для выдачи документа |
commerce-invoices.max_documents_per_order | int | 20 | нет | Лимит документов на один заказ — защита от злоупотребления генерацией |
commerce-invoices.generation_timeout_seconds | int | 30 | нет | Таймаут PDF-движка на один документ, после — retry |
commerce-invoices.generation_max_attempts | int | 3 | нет | Лимит попыток генерации перед InvoiceDocumentGenerationFailed |
commerce-invoices.autogenerate_on_order_placed | bool | false | нет | Автогенерация счёта по событию OrderPlaced |
commerce-invoices.generation_enabled | bool | true | нет | Kill-switch: аварийная остановка генерации новых документов (например, при массовой ошибке реквизитов) без выключения модуля — уже выданные документы остаются скачиваемыми |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/admin/commerce-invoices/orders/{id}/generate | admin (commerce-invoices.manage) | Постановка генерации документа в очередь |
| GET | /api/v1/commerce-invoices/{document}/download | signed 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-orders | 1 (событие OrderPlaced) | входящее | опциональный триггер автогенерации счёта (autogenerate_on_order_placed) |
cms/commerce-orders | 4 (requires) | входящее | чтение позиций/сумм/ставок НДС заказа для рендера документа (снапшот на момент генерации) |
cms/commerce-b2b | 4 (suggests → сервис-вызов при наличии) | входящее | реквизиты юрлица покупателя для B2B-документов |
MediaService (cms/core-contracts) | 3 (сервисный контракт) | исходящее | сохранение сгенерированного PDF в приватную коллекцию медиатеки |
Подписчики InvoiceDocumentGenerated | 1 (событие) | исходящее | уведомление со ссылкой, интеграция с CRM/1С (при включении) |
cms/health | 1 (событие 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 минут, счётчик серии не инкрементируется (подозрение на блокировку транзакции); - типовые инциденты:
- Массовые сбои генерации → проверить
pdf_engineи доступность шрифтов/шаблона →cms:commerce-invoices:generate --jsonвручную на одном заказе для диагностики; - Коллизия номера серии → проверить блокировку транзакции счётчика; не править
last_numberруками в БД — только миграцией данных с журналом; - Старые документы «потекли» новыми реквизитами → баг иммутабельности — проверить, что генерация читает снапшот шаблона на момент
generated_at, а не текущий; - Signed URL не открывается → проверить TTL/подпись, не путать с удалённым
media_id(файл в медиатеке существует отдельно от записи документа); - Автогенерация не сработала → проверить
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_documents — order_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-генерация документов через dompdf | referendum/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запрещены