Сторожевые правила оповещений (notifyRules)

Механизм для сценариев вида «объект слишком долго находится в некотором состоянии — напомнить ответственным»: документ завис в статусе «Новый», счёт оплачен, но поставка не оприходована, и т.п.

Правила описываются декларативно в config/params-local.php (ключ notifyRules) — без написания кода. Прогоняет их по расписанию команда yii notify/watch: каждый объект, попавший под условие правила, порождает письмо ответственным в очередь уведомлений; фактическую отправку делает yii notify/send. Механизм должен быть включён — см. параметры notify.* в настройке.

Формат правила

'notifyRules' => [
    //ключ массива - имя правила; оно входит в ключ дедупликации,
    //поэтому должно быть стабильным (переименование = "новое" правило,
    //письма по нему уйдут заново)
    'contract-stale-new' => [
        //класс отслеживаемой модели
        'class' => \app\models\Contracts::class,

        //условие выборки (см. "Условия" ниже)
        'condition' => ['state_id' => 1],

        //как долго объект не менялся, чтобы считаться "залежавшимся"
        'age' => '1 day',

        //тема письма; {name} и {id} подставляются из объекта
        'subject' => 'Документ «{name}» завис в статусе «Новый»',

        //тело письма; если не задано - тема + ссылки на объект
        'body' => null,

        //повторное напоминание не чаще, чем раз в этот интервал;
        //не указано - письмо по объекту уйдёт один раз
        'repeat' => '3 days',
    ],
],

Обязателен только class; всё остальное имеет разумные умолчания (без condition — все объекты класса, без age — без ограничения по давности, без subject — «<Название класса> «имя»: требует внимания»). Для признаков, которых нет в БД (вычисляемых моделью), есть ещё ключ filter — см. Вычисляемые признаки.

Условия (condition)

Два варианта:

  • массив — уходит в andWhere() как есть. Годится для простых случаев: ['state_id' => 1], ['state_id' => [1, 2]] (любой из), вложенные операторы Yii (['>', 'total', 100000]);
  • callable — функция, получающая ActiveQuery и возвращающая его же; внутри доступен весь арсенал Yii (join, подзапросы, произвольный SQL). Пример — статус по имени вместо «магического» id (id статусов в разных инсталляциях различаются):

    'condition' => fn($query) => $query
      ->joinWith('state')
      ->andWhere(['contracts_states.name' => 'Новый']),
    

Выборка каждого правила выполняется целиком (all()), поэтому условие должно отсекать разумное количество объектов — правило «все документы вообще» на большой базе будет тяжёлым.

Вычисляемые признаки (filter)

Часть признаков в ARMS не хранится в БД, а вычисляется моделью (например, состояние поставки документа — сопоставление заявленных количеств с реально привязанным оборудованием/материалами/лицензиями). В SQL-condition такой признак не выразить — для этого есть filter: PHP-функция, которая применяется к каждому объекту после SQL-выборки и решает, попадает ли он под правило (callable($model): bool).

Рабочее разделение труда: condition дёшево сужает выборку на стороне БД, filter доводит точную проверку на стороне PHP. Готовый пример — «документ оплачен, но поставка не оприходована; напоминать еженедельно»:

'contract-undelivered' => [
    'class' => \app\models\Contracts::class,
    //SQL-часть: только документы, по которым вообще заявлена поставка
    'condition' => ['or',
        ['>', 'techs_delivery', 0],
        ['>', 'materials_delivery', 0],
        ['>', 'lics_delivery', 0],
    ],
    //PHP-часть: вычисляемое состояние "оплачен, но недопоставка"
    'filter' => fn($doc) => $doc->deliveryState === \app\models\Contracts::DELIVERY_INCOMPLETE,
    'age' => '1 week',
    'subject' => 'Документ «{name}» оплачен, но поставка не оприходована',
    //в теле перечисляем, чего именно не хватает, и даём ссылки на документ
    'body' => function ($doc) {
        $undelivered = array_map(
            fn($line) => \yii\helpers\Html::encode($line),
            $doc->undeliveredDescription
        );
        return '<p>Документ <b>«' . \yii\helpers\Html::encode($doc->name) . '»</b> оплачен, '
            . 'но по нему оприходовано не всё:</p>'
            . '<ul><li>' . implode('</li><li>', $undelivered) . '</li></ul>'
            . '<p>Если поставка уже пришла — привяжите полученное к документу, '
            . 'и напоминания прекратятся.</p>'
            . \app\components\Notifier::modelLinksFooter($doc);
    },
    'repeat' => '1 week',
],

Как только поставка закрывается (всё привязано), объект перестаёт проходить filter — напоминания прекращаются сами.

Учтите: filter выполняется на каждом объекте SQL-выборки и может дёргать связи (счётчики поставки — это запросы к привязкам), поэтому condition рядом с ним — не украшение, а способ не гонять PHP-проверку по всей таблице.

Давность (age) — важный нюанс

age сравнивается с updated_at объекта — временем последнего изменения, а не временем входа в текущее состояние. То есть правило читается как «подходит под условие И не менялся дольше N»: любое редактирование объекта (даже правка комментария) сбрасывает отсчёт. Для типового сценария «завис — никто не трогает» это ровно то, что нужно; отсчитывать именно время пребывания в статусе механизм не умеет.

Объекты с пустым updated_at (никогда не менялись после импорта) считаются залежавшимися и под правило попадают.

Форматы интервалов (age, repeat) — любые, понятные strtotime: '30 minutes', '2 hours', '1 day', '2 weeks'.

Кто получит письмо

Получатели определяются самим объектом — методом getNotifyRecipients() (интерфейс NotifyRecipientsInterface). Сейчас его реализуют:

  • Документы — привязанные к документу сотрудники.

Уволенные и сотрудники без e-mail отбрасываются автоматически. Чтобы сторожить другую сущность, её модель должна реализовать этот интерфейс (одна связь + одна строка кода) — это уже доработка, а не конфигурация.

Дополнительные получатели (extraRecipients)

Если письма правила должен получать кто-то сверх автосписка ответственных (типовой случай — руководитель хочет копии всех подобных напоминаний), добавьте в правило ключ extraRecipients:

'extraRecipients' => ['ivanov-ii'],          //логин...
'extraRecipients' => ['boss@example.com'],   //...или e-mail...
'extraRecipients' => [42],                   //...или id сотрудника; можно вперемешку
'extraRecipients' => fn($doc) => [...],      //callable($model): Users[] - если состав зависит от объекта

Дополнительные получатели добавляются к ответственным, дубли схлопываются, фильтр «уволен/без почты» и интервал repeat действуют на них так же (повтор отсчитывается каждому получателю отдельно). Адресат с опечаткой не роняет прогон — он пропускается с записью в warning-лог приложения.

Тексты (subject, body)

  • строка — с подстановками {name} (имя объекта) и {id};
  • callable — function($model): string, если нужно собрать текст из произвольных полей объекта;
  • body не задан — письмо соберётся из темы и стандартного футера со ссылками «Открыть» / «История изменений». Чтобы ссылки работали из консольной команды, в params должен быть задан web.hostInfo (см. настройку).

Дедупликация и повторы

Каждая пара «правило + объект» имеет свой ключ события (watch:<имя-правила>:<id>), и по нему механизм гарантирует:

  • пока письмо не отправлено, повторные прогоны notify/watch не плодят дубли — просто освежают содержимое письма в очереди;
  • после отправки объект молчит, пока не пройдёт repeat (или навсегда, если repeat не задан). Память об отправленном хранится в самой очереди уведомлений — поэтому её чистка отложенная (notify/cleanup удаляет отправленное старше 90 дней), и поэтому ручное удаление отправленной записи означает «можно напомнить снова».

Повтор отсчитывается на каждого получателя отдельно: сотрудник, добавленный к документу позже, получит своё письмо, даже если остальным оно уже уходило.

Как отладить правило

  1. Впишите правило с коротким age (например '1 minute') и без repeat.
  2. Прогоните вручную: php yii notify/watch — команда напишет, сколько писем поставлено в очередь.
  3. Посмотрите результат в разделе Уведомления: тема, тело, получатели.
  4. Отправьте: php yii notify/send (или дождитесь cron); при включённом mailer.useFileTransport письма падают в runtime/mail — удобно для проверки без реального SMTP.
  5. Верните боевые age/repeat.

Правило с ошибкой (несуществующий класс, класс без ответственных) не останавливает прогон — notify/watch сообщает о нём и продолжает со следующими правилами.