# Отправить на подписание (КЭДО) — активити для бизнес-процессов Bitrix24

Локальное PHP-приложение Bitrix24, добавляющее в дизайнер бизнес-процессов
активити «Отправить на подписание (КЭДО)»: отправляет PDF-документ с Диска
на подписание сотруднику компании через раздел КЭДО (`sign.b2e.*`),
асинхронно дожидается результата и возвращает в БП подписанный файл и
статус.

Подписанты — только сотрудники компании (B2E). Публичного REST API для
подписания с внешними контрагентами в разделе `sign` нет, поэтому эта
активити для таких сценариев не подходит.

Спека и план разработки: `docs/superpowers/specs/2026-09-16-sign-activity-design.md`,
`docs/superpowers/plans/2026-09-16-sign-activity.md`.

## Как это работает

Никакой собственной OAuth-инфраструктуры нет — Bitrix24 передаёт свежий
`access_token`/`domain` в каждом запросе к приложению (`auth` в теле
запроса), поэтому приложению не нужно хранить и обновлять токены самому.

Поток выполнения — два независимых HTTP-вызова, разделённых во времени
(минуты/дни):

1. **Шаг БП запускается** → Bitrix24 вызывает `handler.php`. Он скачивает
   файл с Диска, отправляет его на подписание через
   `sign.b2e.document.send` и сохраняет пару `uid → event_token` в
   SQLite (`sign_requests.sqlite`). Шаг БП остаётся открытым — `handler.php`
   не завершает его.
2. **Сотрудник подписывает документ** в интерфейсе КЭДО (вне этого
   приложения).
3. **Bitrix24 шлёт событие** `OnSignB2eDocumentStatusChanged` на
   `sign_event.php`. Он находит запись по `uid`, при финальном статусе
   заливает подписанный файл обратно на Диск и завершает шаг БП через
   `bizproc.event.send` с сохранённым `event_token`.

```
Дизайнер БП ──▶ handler.php ──▶ sign.b2e.document.send ──▶ SQLite (uid → event_token)
                                                                 │
                                                     сотрудник подписывает в КЭДО
                                                                 │
Bitrix24 ──▶ OnSignB2eDocumentStatusChanged ──▶ sign_event.php ──▶ bizproc.event.send
                                                       │
                                                       └─▶ Диск (подписанный файл)
```

## Структура файлов

| Файл | Назначение |
|---|---|
| `install.php` | Страница установки (открывается в браузере в контексте Bitrix24). Регистрирует активити (`bizproc.activity.add`/`.update`) и подписывается на событие `OnSignB2eDocumentStatusChanged` (`event.bind`). |
| `handler.php` | Обработчик шага БП — отправляет документ на подписание. |
| `sign_event.php` | Обработчик события статуса подписания — завершает шаг БП. |
| `config.sample.php` | Шаблон конфигурации (company/провайдер подписи). Копируется в `config.php` (не версионируется, см. «Конфигурация»). |
| `lib/rest.php` | `callRest()` — вызов REST-методов Bitrix24, `writeToLog()` — логирование в `handler.log`. |
| `lib/disk.php` | `diskDownloadFile()` / `diskUploadFile()` — скачивание/загрузка файлов на Диск. |
| `lib/filename.php` | `buildUniqueFileName()` — генератор уникальных имён файлов (нужен для поиска подписанного файла позже). |
| `lib/members.php` | `normalizeUserIds()`, `buildMembers()`, `resolveResponsible()` — нормализация bizproc-полей типа «Пользователь» и построение `members`/`responsible` для `sign.b2e.document.send`. |
| `lib/Storage.php` | `SignRequestStorage` — SQLite-хранилище заявок на подписание между двумя HTTP-вызовами. |
| `bin/list_providers.php` | CLI: получить список провайдеров подписи компании (`sign.b2e.company.provider.list`) для заполнения `config.php`. |
| `bin/disk_smoke_test.php` | CLI: ручная проверка скачивания/загрузки файла на Диск. |
| `tests/FilenameTest.php`, `tests/StorageTest.php` | Автотесты для `lib/filename.php` и `lib/Storage.php` (без фреймворка, запускаются напрямую `php`). |
| `.htaccess` | Запрещает отдачу `*.log`/`*.sqlite` веб-сервером. |

Файлы `config.php`, `handler.log`, `*.sqlite` в `.gitignore` — они либо
содержат секреты/локальные данные, либо создаются в рантайме.

## Установка

### 1. Развернуть код

Выложить директорию `sign-activity/` на сервер с публичным HTTPS-доступом.
В `install.php` заменить `your_domain` в двух местах (`HANDLER` активити и
`handler` для `event.bind`) на реальный домен, например:

```
https://example.com/sign-activity/handler.php
https://example.com/sign-activity/sign_event.php
```

Убедиться, что PHP-расширение `pdo_sqlite` доступно (`php -m | grep sqlite`).

### 2. Установить как локальное приложение Bitrix24

Зарегистрировать локальное приложение на портале, указав `install.php` как
URL установки. При открытии страницы установки сработает `BX24.init` и
зарегистрирует активити и подписку на событие — в браузере появятся алерты
об успехе.

### 3. Настроить config.php

```bash
cp sign-activity/config.sample.php sign-activity/config.php
```

Получить список провайдеров подписи компании:

```bash
php sign-activity/bin/list_providers.php your-portal.bitrix24.ru <access_token> <company_crm_id>
```

Вписать реальные `company.crmId` и `companyProviderUid` в `config.php`
(значения из шаблона — заглушки; `handler.php` откажется работать, пока
там `crmId === 12` и `companyProviderUid === 'ses-ru'`, и явно сообщит об
этом в логе БП).

### 4. Проверить работу Диска (опционально)

```bash
php sign-activity/bin/disk_smoke_test.php your-portal.bitrix24.ru <access_token> <source_file_id> <destination_folder_id>
```

## Использование в бизнес-процессе

В дизайнере БП добавить действие «Отправить на подписание (КЭДО)»
(`send_for_kedo_signing`) и заполнить параметры:

**Вход:**
| Параметр | Обязателен | Описание |
|---|---|---|
| `FileId` | да | ID файла на Диске с документом для подписания |
| `SignerUserIds` | нет* | Сотрудники-подписанты, роль `signer` (множественное поле «Пользователь») |
| `AssigneeUserIds` | нет* | Представители компании, роль `assignee` (множественное поле «Пользователь») |
| `EditorUserIds` | нет | Заполняющие документ, роль `editor` (множественное поле «Пользователь») |
| `ReviewerUserIds` | нет | Согласующие документ, роль `reviewer` (множественное поле «Пользователь») |
| `ResponsibleUserId` | нет | Ответственный за документ (`responsible`). Если не указано — берётся первый из `AssigneeUserIds`, иначе первый из `SignerUserIds` |
| `DestinationFolderId` | нет | Папка на Диске для подписанного файла; если не указано — рядом с исходным файлом |
| `RegionDocumentType` | нет | Тип документа для региона (по умолчанию `12.999`) |
| `ExternalId` | нет | Идентификатор документа во внешней системе (`externalSettings.externalId`). `externalSettings` отправляется в API всегда (проверено на практике — без него `sign.b2e.document.send` отклоняет запрос с `[externalSettings.externalDateCreate] field is required`, вопреки описанию в документации). Если поле не задано — используется `workflow_id` бизнес-процесса; `externalDateCreate` всегда подставляется автоматически (`date('c')`) |
| `Language` | нет | Язык документа (`ru`/`en`, по умолчанию `ru`) |

\* Поля не отмечены обязательными на уровне дизайнера БП, но `handler.php`
проверяет по факту: должен быть указан хотя бы один `SignerUserIds` и
хотя бы один `AssigneeUserIds` — это собственное требование API
`sign.b2e.document.send` («At least one signing party with role signer/
assignee is required»). При нарушении шаг БП сразу завершается с ошибкой.

Подписание нескольких файлов за один вызов API не поддерживается
(`BAD_REQUEST: Signing multiple files is currently not supported`),
поэтому `FileId` остаётся одиночным.

**Выход:**
| Параметр | Описание |
|---|---|
| `SignedFileId` | ID подписанного файла на Диске |
| `SignStatus` | Итоговый статус (`signed`, `declined`, `cancelled` или иной, присланный Bitrix24) |
| `SignedAt` | Дата и время завершения |
| `DocumentUid` | uid документа в sign.b2e (для отладки) |

Шаг БП остаётся в состоянии ожидания до тех пор, пока сотрудник не
подпишет или не отклонит документ.

## Тестирование

```bash
php sign-activity/tests/FilenameTest.php
php sign-activity/tests/StorageTest.php
php sign-activity/tests/MembersTest.php
```

Оба скрипта — самостоятельные, без внешнего тест-фреймворка; при ошибке
выводят `FAIL: ...` в stderr и завершаются с кодом 1.

`handler.php`, `sign_event.php`, `disk.php`, `rest.php` не имеют
автотестов — они зависят от живого портала и live API `sign.b2e`.
Проверяются вручную на тестовом портале (см. ниже).

## Ручная проверка на живом портале

1. Прогнать БП с шагом активити, подписать документ тестовым сотрудником —
   проверить: файл ушёл на подписание, статус дошёл через событие,
   подписанный файл лёг на Диск, шаг БП завершился с корректными
   `RETURN_VALUES`.
2. Прогнать сценарий отказа от подписания — шаг БП должен завершиться со
   статусом `declined`, без файла.
3. Проверить устойчивость к повторным/чужим событиям: повторно отправить
   POST с тем же `documentUid` на `sign_event.php` — должно тихо
   игнорироваться (`SIGN_EVENT_IGNORED` в логе), без повторного вызова
   `bizproc.event.send`.

Полный чек-лист — в плане: `docs/superpowers/plans/2026-09-16-sign-activity.md`,
Task 9.

## Логи и диагностика

Все входящие запросы и ключевые события пишутся в `sign-activity/handler.log`
(`writeToLog()`). Поле `auth` (токены) в логе редактируется — сохраняется
только `domain`, полные токены никогда не попадают в лог-файл.

Логирование покрывает весь путь запроса — что пришло на вход, что было
распознано, какое решение принято на каждом ветвлении, что отправлено во
внешний API и что из этого получилось. Содержимое файла (base64 PDF)
в лог не пишется — только его размер в байтах.

### `handler.php` (отправка на подписание)

| Метка | Значение |
|---|---|
| `HANDLER_REQUEST` | Входящий вызов шага БП (сырые properties, `auth` отредактирован) |
| `HANDLER_INIT_ERROR` | Нет `access_token`/`domain`/`event_token` — дальше не идём |
| `HANDLER_CONFIG_PLACEHOLDER` | `config.php` всё ещё содержит заглушки из `config.sample.php` |
| `HANDLER_PARSED_PROPERTIES` | Все входные параметры после разбора и нормализации (`fileId`, списки ID по ролям, `responsibleId` и т.д.) |
| `HANDLER_VALIDATION_ERROR` | Не хватает `FileId`, `SignerUserIds` или `AssigneeUserIds` — с указанием, чего именно |
| `HANDLER_DOWNLOAD_ERROR` | Не удалось скачать файл с Диска |
| `HANDLER_DOWNLOADED` | Файл скачан: имя, размер, ID родительской папки |
| `HANDLER_SEND_REQUEST` | Тело запроса к `sign.b2e.document.send` (содержимое файла заменено на его размер) |
| `HANDLER_SEND_RESPONSE` | Полный ответ `sign.b2e.document.send` |
| `HANDLER_SEND_ERROR` | Ответ без `result.uid` — API отклонил отправку, с текстом ошибки |
| `HANDLER_STORAGE_ERROR` | Документ отправлен, но не удалось сохранить запись в SQLite |
| `HANDLER_STORED` | Заявка сохранена, шаг БП переходит в ожидание |
| `HANDLER_FAILED_STEP` | Шаг БП завершён с ошибкой (пишется из `failStep()` при любом отказе выше) |

### `sign_event.php` (обработка события статуса подписания)

| Метка | Значение |
|---|---|
| `SIGN_EVENT_REQUEST` | Входящее событие (`auth` отредактирован) |
| `SIGN_EVENT_MISSING_FIELDS` | В событии нет `documentUid` или `statusCode` |
| `SIGN_EVENT_PARSED` | `documentUid`/`statusCode` успешно извлечены |
| `SIGN_EVENT_STATUS_IN_PROGRESS` | Статус промежуточный (`sent`/`signing`/`in_progress`) — ждём следующего события |
| `SIGN_EVENT_UNKNOWN_UID` | `uid` не найден в SQLite — не наш документ или уже обработан |
| `SIGN_EVENT_MATCHED` | Заявка найдена в SQLite (домен, имя файла, папка назначения, текущий статус) |
| `SIGN_EVENT_NO_AUTH` | В событии нет токена — запись НЕ помечена обработанной, ждём повторного события |
| `SIGN_EVENT_DOMAIN_MISMATCH` | Домен в событии не совпадает с сохранённым — вероятная подделка, событие отброшено |
| `SIGN_EVENT_DUPLICATE` | Заявка уже обработана — повторное событие проигнорировано |
| `SIGN_EVENT_LOOKING_UP_FILE` | Статус `signed` — начат поиск подписанного файла в сейфе компании |
| `SIGN_EVENT_SAFE_SEARCH_START` | Начат поиск по `sign.b2e.mysafe.tail` (искомый уникальный суффикс имени) |
| `SIGN_EVENT_SAFE_PAGE` | Прочитана очередная страница сейфа (номер страницы, offset, число записей) |
| `SIGN_EVENT_SAFE_MATCH` | Найдена запись с совпадающим суффиксом имени |
| `SIGN_EVENT_FILE_NOT_FOUND` | Файл не найден за все страницы поиска |
| `SIGN_EVENT_UNTRUSTED_URL` | `file_url` из ответа API указывает не на домен портала — SSRF-защита отклонила скачивание |
| `SIGN_EVENT_DOWNLOAD_ERROR` | Не удалось скачать подписанный файл по `file_url` |
| `SIGN_EVENT_DOWNLOADED_SIGNED_FILE` | Подписанный файл скачан (размер в байтах) |
| `SIGN_EVENT_USING_SOURCE_FOLDER` | `DestinationFolderId` не задан — используется папка исходного файла |
| `SIGN_EVENT_NO_DESTINATION` | Нет ни `DestinationFolderId`, ни папки исходного файла — загрузка пропущена |
| `SIGN_EVENT_UPLOAD_ERROR` | Не удалось загрузить подписанный файл на Диск |
| `SIGN_EVENT_UPLOADED` | Подписанный файл загружен на Диск (ID нового файла, папка, имя) |
| `SIGN_EVENT_FILE_READY` / `SIGN_EVENT_FILE_MISSING` | Итог поиска файла для статуса `signed`: найден/загружен, либо нет |
| `SIGN_EVENT_NON_SIGNED_TERMINAL` | Финальный статус не `signed` (например, `declined`/`cancelled`) — завершаем без файла |
| `SIGN_EVENT_COMPLETING` | Собраны данные для завершения шага БП, готовимся вызвать `bizproc.event.send` |
| `SIGN_EVENT_COMPLETE_FAILED` | `bizproc.event.send` не подтвердил завершение — запись возвращена в статус `sent` для повтора |
| `SIGN_EVENT_COMPLETED` | Шаг БП успешно завершён |

## Известные ограничения

- `sign.b2e.document.send` требует `externalSettings.externalDateCreate` всегда,
  несмотря на то, что документация описывает `externalSettings` как
  необязательный блок (проверено эмпирически на реальном портале — без него
  запрос отклоняется с `BAD_REQUEST: [externalSettings.externalDateCreate]
  field is required`). `handler.php` поэтому всегда формирует этот блок.
- `sign.b2e.mysafe.tail` не фильтрует по `uid` документа — подписанный
  файл ищется постранично по уникальному суффиксу имени файла
  (см. `buildUniqueFileName()`). При очень большом «сейфе» компании поиск
  ограничен 10 страницами по 50 записей.
- Список финальных статусов подписания официально не задокументирован.
  Код трактует известные статусы `sent`/`signing`/`in_progress` как «в
  процессе», а всё остальное — как финальное (fail-safe: лучше завершить
  шаг раньше, чем оставить БП висеть навсегда). Если при реальной
  эксплуатации обнаружатся дополнительные промежуточные статусы — их
  нужно добавить в `$IN_PROGRESS_STATUSES` в `sign_event.php`.
  Статусы, реально встреченные на практике (портал meshalkin.bitrix24.ru):
  `signing` — промежуточный («Signing in progress»); `stopped` —
  финальный, пользователь остановил/отменил подписание (шаг БП
  завершается с `SignStatus = stopped`, без файла — как `declined`).
- Токены авторизации в событии `OnSignB2eDocumentStatusChanged` передаются
  не всегда (это задокументированное поведение Bitrix24). В этом случае
  событие игнорируется без пометки записи как обработанной — обработка
  довершится на одном из следующих событий по тому же документу.
- При смене SQLite-схемы (`lib/Storage.php`) существующий файл
  `sign_requests.sqlite` не мигрируется автоматически
  (`CREATE TABLE IF NOT EXISTS` не добавляет новые колонки в старую
  таблицу) — при обновлении на портале с уже накопленными записями нужно
  либо дождаться их естественного завершения перед обновлением кода, либо
  накатить миграцию вручную.
