Hammerhall EN Сколько стоит Войти

Правила продаж — в настройках

Статья 15 · читать минут 35

Для кого. Ты прочитал статьи 0–14, и у тебя есть песочница game-day после статьи 14. Особенно пригодятся настройки с @ConfigurationProperties и @Validated (статья 2), остановленные часы Clock (статья 1) и задача менеджеру с двумя сроками (статья 12).

Что будет. Как отличить правило дела от константы кода и почему первое живёт в настройках; как посчитать точку напоминания и проверить её на остановленных часах; как программа сама просыпается ночью — @Scheduled, cron из шести полей и часовой пояс; как ночной обход переживает сбой и одну плохую строку.

Откуда статья. Раздел «Буткемп / Лаборатория» — о том, как пишут код команды Hammerhall: буткемп работает в открытых репозиториях, лаборатория — в закрытых. Серия «От системы к делу» — следующий шаг после «От Spring к системе»: как правила дела — сроки, деньги, обещания людям — живут в коде. Примеры — учебные: их можно повторить у себя.


Зачем правила в настройках#

Представь: продажи клуба говорят, что напоминать о продлении за семь дней поздно. Люди не успевают собрать деньги — давайте за десять. Если семёрка зашита в код, смена одной цифры — это сборка, ревью и выкатка. А через месяц продажи передумают снова.

Сначала вопрос пошире: какой программе правило вообще принадлежит. Система клуба отдаёт факты: когда кончается абонемент, сколько раз человек был на неделе. А за сколько дней напомнить, кому не писать, как часто предлагать — работа менеджера, и она будет меняться.

Значит, «когда напомнить» и «что предложить» решает CRM: разговор ведёт она (статья 12). Оставь правило в системе клуба — и каждая правка продаж потребует выкатки чужой программы.

Внутри CRM тот же вопрос — на шаг глубже: правило в коде или в настройках? Об этом статья.

На дороге человека из статьи 12 есть три правила, по которым CRM сама, без менеджера, заводит ему задачи:

Все три работают по расписанию. Ночью CRM спрашивает систему клуба, у кого скоро кончается абонемент и кто как ходит; брони — каждые полчаса. Ответ она раскладывает в задачи менеджеру — FollowUp с двумя сроками из статьи 12.

Куски учебной CRM клуба из статей 12–14 — для чтения, выброшенное в них помечено // …. Свой код статьи — точка напоминания ReminderPoint на чистой Java и её тест ReminderPointTest в пакете example.crm.

В коротких примерах нет строк import — полные файлы своего кода в конце статьи.


Часть 1. Что в настройки, а что в код

Правила продаж в application.yml

Все правила продаж учебной CRM лежат в одном месте — в разделе club.sales её application.yml:

# учебная CRM клуба: application.yml, раздел club.sales
club:
  sales:
    renewals:                          # продление абонемента
      days-before: 7                   # за сколько дней до конца абонемента напомнить
      poll-window-days: 14             # на сколько дней вперёд спрашиваем систему клуба: 1–90
      poll-cron: "0 0 2 * * *"         # каждую ночь в 2:00; "-" — не обходить
      poll-zone: "Europe/Moscow"       # по чьим часам
    holds:                             # бронь места в группе
      poll-window-hours: 48            # окно опроса броней, часов: 24–48
      poll-cron: "0 15/30 * * * *"     # дважды в час: в :15 и в :45
      poll-zone: "Europe/Moscow"       # пояс расписания и текста задачи
    # Пороги предложений — решение продаж, а не константа кода. Это первая прикидка:
    # продажи поправят её по опыту, и новая сборка для этого не нужна.
    offers:                            # годовой абонемент или тренер
      year-min-visits-per-week: 3      # ходит не реже — предложить годовой
      coach-after-days-away: 14        # не был столько дней — предложить тренера
      cooldown-days: 14                # уже предлагали — столько дней молчим
      poll-pause-ms: 250               # передышка системе клуба между людьми
      poll-cron: "0 30 2 * * *"        # ночью, на полчаса позже продлений
      poll-zone: "Europe/Moscow"

Три группы — по одной на правило: числа правила и две строки расписания, poll-cron и poll-zone (о них — часть 3). Префикс club.sales — учебной CRM. У песочницы журнала свои настройки из статьи 2, и эти строки туда не кладут.

Пороги в комментарии над offers — «первая прикидка»: их никто не измерял. Это нормально: догадка, которую можно подкрутить без выкатки, дешевле точной цифры в коде.

«Без новой сборки» — это как? application.yml едет внутри .jar, но его строку перекрывает переменная окружения на машине (помнишь статью 2): CLUB_SALES_RENEWALS_DAYSBEFORE=10 — и после перезапуска CRM напоминает за десять дней.

Правило дела или константа#

Почему пороги предложений вообще у CRM, а не у системы клуба? Сколько раз человек был на неделе — измеримый факт, и считает его система клуба: посещения у неё (статья 13). А что при трёх разах в неделю пора предлагать годовой — мнение продаж. Факт отдаёт тот, у кого данные; порог держит тот, кто решает.

🔑 В настройки идёт то, что меняют быстрее, чем выходит новая версия: сроки, пороги, паузы, расписание. Проверка простая: кто попросит поменять и как скоро. Просит менеджер продаж, и «к пятнице» — это настройка.

Что в учебной CRM осталось в коде — и почему:

💡 Каждая настройка — регулятор, который можно повернуть не туда. «На всякий случай» в настройки не выносят.

Настройки — класс, а не запись

Так выглядит класс настроек продления в учебной CRM:

// учебная CRM клуба: настройки продления
/**
 * Правило продления абонемента. Хозяин правила — продажи: число дней меняют по их просьбе,
 * без новой сборки.
 *
 * <p>Когда обходить — не здесь: строку {@code club.sales.renewals.poll-cron} подставляет
 * в {@code @Scheduled} сам Spring; «-» в ней выключает обход.
 */
@Getter
@Setter
@Validated
@ConfigurationProperties(prefix = "club.sales.renewals")
public class RenewalsProperties {

    /** За сколько дней до конца абонемента напоминаем: {@code remindAt = until − daysBefore}. */
    @Min(1)
    private int daysBefore = 7;

    /**
     * На сколько дней вперёд спрашиваем систему клуба. Больше правила ({@link #daysBefore}) —
     * запас на ночи, когда обход не удался (часть 2). Больше 90 система клуба не отдаёт.
     */
    @Min(1)
    @Max(90)
    private int pollWindowDays = 14;
}

В статье 2 настройки были записью с @DefaultValue. Здесь — обычный класс: умолчание — начальное значение поля, а Boot заполняет поля через сеттеры. Boot умеет оба способа, и в чужом коде ты встретишь и записи, и классы.

Самих геттеров и сеттеров в классе не видно: их при компиляции дописывает Lombok по аннотациям @Getter и @Setter. Это библиотека, которая генерирует такой код сама; её имя мелькнуло в 3б. Как она это делает и чего это стоит — в продолжении серии.

Находит такие классы та же @ConfigurationPropertiesScan, что и запись журнала в статье 2: учебная CRM устроена как журнал. Помнишь запрет из статьи 2 — не ставить @Component на запись настроек? Он про записи, которые Boot заполняет через конструктор. Класс с сеттерами Boot заполнит и как обычный бин с @Component: в чужом коде встретится и такое, и это не ошибка.

Цена ошибки: сломанная настройка роняет старт

Окно шире 90 дней система клуба не отдаёт, и в учебном клубе так решили не из-за нагрузки. «Все, у кого абонемент кончается в ближайший год», — это уже выгрузка почти всех, кто платит. А в ней личные данные. Попросишь шире — система клуба откажет.

Пусть кто-то поставил poll-window-days: 120, а проверки нет. CRM стартует. В 2:00 система клуба откажет, и ночной обход запишет в лог «Обход продлений не удался — следующий по расписанию». Следующей ночью — то же самое. Напоминания перестанут заводиться, и никто не заметит, пока продажи не потеряют клиента.

С @Max(90) и @Validated приложение не поднимется, а в логе будет, какая настройка и с каким значением (статья 2). Узнает тот, кто выкатывал, — сразу.

Сломанная настройка должна ронять старт, а не ночной обход — то же правило, что в статье 2. В учебной CRM окно опроса броней зажато с двух сторон:

// учебная CRM клуба: настройки броней — сокращено
    /**
     * Окно опроса броней, в часах. Не больше 48 — шире система клуба не отдаёт. Не меньше 24 —
     * бронь живёт сутки, и при окне короче свежую бронь CRM заметит поздно. Обе границы
     * проверяются при старте: ошибка в настройке не должна всплывать в каждом опросе.
     */
    @Min(24)
    @Max(48)
    private int pollWindowHours = 48;

Почему 24: только что поставленная бронь сгорит через сутки. Окно в 12 часов покажет её CRM, лишь когда до конца останется 12 часов. Половину суток на разговор менеджер потеряет. Об опросе броней подробно — в продолжении серии.

Такие границы проверяют тестом на ApplicationContextRunner, как в статье 2: при 23 и 49 контекст не поднимается, при 24 и 48 — поднимается.


Часть 2. Продление на календаре

Точка напоминания#

Правило продления формулой: точка напоминания remindAt = until − 7 дней, но не раньше «сейчас». until — конец абонемента, его сообщает система клуба. В задаче FollowUp точка напоминания станет сроком задачи dueAt, а until — сроком предмета untilAt.

Главное — вторая половина правила: если точка уже прошла, задача ставится на «сейчас». Почему не оставить точку в прошлом? В список на сегодня (статья 12) просроченная задача попала бы и так. Но срок в прошлом — неправда.

Скажем, CRM узнала об абонементе за три дня до конца. Точка — четыре дня назад, и задача выглядела бы так, будто менеджер четыре дня её не делал. Срок задачи — когда действовать, а действовать раньше, чем CRM узнала о поводе, нельзя. Опоздали — но напомнить надо, и честно: с этой минуты.

Как точка оказывается в прошлом, покажет календарь ниже.

Свой код: ReminderPoint#

Правило — в маленьком классе на чистой Java:

public final class ReminderPoint {

    private final Duration before;                   // за сколько до конца абонемента
    private final Clock clock;                       // откуда берём «сейчас»

    public ReminderPoint(int daysBefore, Clock clock) {
        if (daysBefore < 1) {                        // как @Min(1) в настройках учебной CRM
            throw new IllegalArgumentException("напоминаем хотя бы за день, а пришло " + daysBefore);
        }
        this.before = Duration.ofDays(daysBefore);   // N суток по 24 часа
        this.clock = Objects.requireNonNull(clock, "часы не заданы");   // null — исключение с этим текстом
    }

    public Instant remindAt(Instant until) {
        Instant point = until.minus(before);         // точка по правилу
        Instant now = clock.instant();               // «сейчас» — только из часов
        return point.isBefore(now) ? now : point;    // прошла — значит сейчас
    }
}

Как читать:

В учебной CRM модель зовёт служба продлений — на каждую строку ответа системы клуба:

// учебная служба продлений: задача по строке ответа системы клуба — сокращено
ReminderPoint point = new ReminderPoint(props.getDaysBefore(), clock);   // правило — из настроек, часы — бин
FollowUp task = new FollowUp("RENEWAL", row.who(), point.remindAt(row.until()), row.until(), false);

Число дней приходит из RenewalsProperties, часы — бин из статьи 1. Так два срока задачи заполняются из разных мест: срок задачи считает модель, срок предмета берётся прямо из ответа системы клуба.

💡 «Неделя» здесь — ровно 168 часов. У Москвы летнего времени нет, и за семь суток до полуночи — снова полночь. В поясе с переходом на летнее время неделя, через которую идёт переход, на час длиннее или короче.

В Париже 25 октября 2026 года часы переводят назад. За 168 часов до полуночи 1 ноября там окажется 01:00, а не 00:00. Нужны календарные дни — считай в поясе: until.atZone(пояс).minusDays(7).toInstant().

Тест на остановленных часах#

Без часов случай «точка прошла» проверяют так: замеряют настоящее время до вызова и после и смотрят, что срок задачи попал между замерами. Это работает, но на границу так не встать: «сейчас» каждый раз своё. Остановленные часы точнее — можно встать ровно на границу:

    @ParameterizedTest(name = "за {0} дн., сейчас {2} → {3}")
    @CsvSource(delimiter = '|', textBlock = """
            7  | 2026-10-11T21:00:00Z | 2026-09-27T23:00:00Z | 2026-10-04T21:00:00Z
            10 | 2026-10-11T21:00:00Z | 2026-09-27T23:00:00Z | 2026-10-01T21:00:00Z
            7  | 2026-10-11T21:00:00Z | 2026-10-04T23:00:00Z | 2026-10-04T23:00:00Z
            7  | 2026-10-11T21:00:00Z | 2026-10-04T21:00:00Z | 2026-10-04T21:00:00Z
            7  | 2026-10-11T21:00:00Z | 2026-10-04T21:00:01Z | 2026-10-04T21:00:01Z
            """)
    void remindsBeforeUntilButNotInThePast(int daysBefore, Instant until, Instant now, Instant expected) {
        Clock stopped = Clock.fixed(now, ZoneOffset.UTC);              // часы стоят на «сейчас» из таблицы
        ReminderPoint point = new ReminderPoint(daysBefore, stopped);

        assertThat(point.remindAt(until)).isEqualTo(expected);
    }

Текст 2026-10-11T21:00:00Z JUnit сам превращает в Instant, как в статье 0 — в int. Конец абонемента везде один — 12 октября, 00:00 по Москве; по UTC это 21:00 накануне (у Москвы круглый год +03:00). Те же строки по Москве:

строка правило сейчас, МСК срок задачи, МСК что проверяет
1 7 дней 28.09 02:00 05.10 00:00 точка впереди
2 10 дней 28.09 02:00 02.10 00:00 правило длиннее — точка сдвинулась
3 7 дней 05.10 02:00 05.10 02:00 — сейчас точка прошла два часа назад
4 7 дней 05.10 00:00 05.10 00:00 ровно на границе
5 7 дней 05.10 00:00:01 05.10 00:00:01 — сейчас секундой позже

На границе обе ветки дают один момент, и строка проверяет, что точка не съехала вперёд ни на секунду; секундой позже точка уже прошла. Ещё два теста — в «Примере целиком»: правило «за 0 дней» и «за −7» не создаётся, без часов — тоже, и оба раза с понятным текстом.

Тест, который не краснел, ничего не доказал (статья 11). Три поломки — три разных красных:

Верни всё как было.

Почему спрашиваем шире, чем напоминаем

Система клуба отдаёт абонементы, которые кончаются в ближайшие N дней; уже кончившиеся она не отдаёт. CRM спрашивает на 14 дней, хотя напоминает за 7. Описание pollWindowDays выше обещало: это запас на ночи, когда обход не удался. Разберём на календаре: абонемент кончается 12 октября в 00:00 по Москве, обход идёт каждую ночь в 2:00 по Москве, правило — 7 дней.

ночь, 2:00 окно 14 дней окно 7 дней
28.09 строка впервые в ответе → задача со сроком 05.10, 0:00 — ждёт строки нет
29.09–04.10 задача уже есть — вторую на тот же абонемент CRM не заводит строки нет
05.10 срок задачи наступил в полночь — она в списке менеджера на сегодня строка впервые в ответе, точка прошла два часа назад → задача «на сейчас»
06–11.10 строка в ответе строка в ответе
12.10 абонемент кончился — строки нет строки нет

Пока всё гладко, разница — два часа. Но обход иногда не удаётся: система клуба не отвечает, CRM перезапускают.

С окном 14 задача заводится в первую удачную ночь из 28.09–04.10, и срок у неё всегда один — 05.10, 0:00. Шесть неудачных ночей подряд не стоят ничего, семь — двух часов.

С окном 7 неудачная ночь 05.10 сдвигает напоминание на сутки, каждая следующая — ещё на сутки. А не удались все семь — строка выпадет из ответа: 12 октября абонемент кончится, система клуба её больше не отдаст, и напоминания не будет вовсе. С окном 14 для этого нужны четырнадцать неудачных ночей подряд.

🔑 Окно шире правила — запас на ночи, когда обход не удался. Сколько ночей подряд можно потерять, не сдвинув напоминание на другой день? Разница окна и правила: 14 − 7 = 7.

💡 А если окно уже правила — 5 дней при правиле 7? Строка впервые придёт ночью 07.10, и напоминание каждый раз будет на два дня позже правила.


Часть 3. Работа по расписанию#

Кто будит CRM ночью#

Программу из курса ты запускаешь сам. Сервис живёт неделями, и часть его работы — посмотреть на часы. Помнишь из статьи 12: «абонемент кончается через неделю» — не событие, его можно только заметить, посмотрев на календарь. Кто-то должен смотреть — каждую ночь, без человека.

@Scheduled — аннотация Spring над методом бина: «вызывай этот метод по расписанию».

// учебная CRM клуба: класс приложения
@SpringBootApplication
@ConfigurationPropertiesScan
@EnableScheduling
public class ClubCrmApplication {

⚠️ Забудешь @EnableScheduling — методы с @Scheduled просто не будут вызываться. Без ошибки: приложение поднимется и будет молчать.

Обход продлений в учебной CRM:

// учебная служба обхода продлений — сокращено
    /** Когда и по чьим часам — решают настройки; «-» в poll-cron выключает обход. */
    @Scheduled(cron = "${club.sales.renewals.poll-cron:0 0 2 * * *}",
            zone = "${club.sales.renewals.poll-zone:Europe/Moscow}")
    public void pollEndingMemberships() {

cron — когда, zone — по чьим часам.

cron — шесть полей#

cron — расписание одной строкой: поля через пробел, каждое говорит, при каких значениях времени срабатывать. Название — от cron, программы-будильника в Unix; её запись Spring взял, добавив секунды.

поле значения в 0 0 2 * * *
секунда 0–59 0
минута 0–59 0
час 0–23 2
день месяца 1–31 * — любой
месяц 1–12 *
день недели 0–7, воскресенье — 0 и 7 *

0 0 2 * * * — каждый день в 2:00:00, обход продлений. 0 30 2 * * * — в 2:30:00, обход предложений.

0 15/30 * * * * — опрос броней. 15/30 в поле минут читается «с 15-й, шагом 30, до конца часа» — минуты 15 и 45. Час — звёздочка, значит, каждый час: 0:15, 0:45, 1:15… По той же логике 0/30 — минуты 0 и 30.

⚠️ В интернете и в crontab Linux полей пять, без секунд: 0 2 * * * — «в 2:00». В Spring — шесть, секунды первыми. Пятипольное выражение в @Scheduled приложение не пропустит при старте: Cron expression must consist of 6 fields (found 5 in "0 2 * * *"). Хуже «починить», дописав звёздочку в конец: 0 2 * * * * — ошибки нет, но это «на второй минуте каждого часа», 24 раза в сутки.

Расписание — тоже настройка#

${club.sales.renewals.poll-cron:0 0 2 * * *} — запись ${имя:умолчание} из статьи 2, только в атрибуте аннотации.

Почему расписания нет в RenewalsProperties и строку poll-cron «подставляет в @Scheduled сам Spring»? Значение атрибута аннотации Java требует знать уже при компиляции: написать cron = props.getPollCron() нельзя. А строку со ссылкой ${…} Spring раскроет сам, при старте.

@Validated эти ключи не видит, но правило «сломанная настройка роняет старт» держит сам Spring: выражение разбирается при старте. 0 0 25 * * * даст «Invalid value for HourOfDay (valid values 0 - 23): 25», пояс с опечаткой Europe/Moskow — «Invalid time zone specification».

Выключить задание — значение -: для Spring это «не запускать» (Scheduled.CRON_DISABLED), придуманное как раз для настроек через ${…}. Сам разбор cron минуса не знает: «не запускать» — договорённость @Scheduled. Пригодится на своей машине: CRM, запущенная на ноутбуке, не будет стучаться ночью в систему клуба.

⚠️ В YAML минус и звёздочку в начале значения пиши в кавычках: poll-cron: - YAML прочтёт как начало списка, а */10 * * * * * (каждые 10 секунд) — как свою разметку, и файл не разберётся. В учебной CRM все расписания в кавычках — хорошая привычка.

Пояс: два часа ночи — где?#

Без zone Spring считает расписание в поясе, который Java берёт с машины. На сервере это может оказаться UTC — тогда «2:00» наступит в 5:00 по Москве. На ноутбуке с поясом Парижа «2:00» — это 3:00 по Москве летом и 4:00 зимой. Код один, часы разные.

zone = "…Europe/Moscow" закрепляет расписание за Москвой, где бы ни стояла машина. Имя — то же, что у ZoneId в статье 12: из базы часовых поясов IANA, где для каждого места записано, какой сдвиг от UTC там был в какую дату. Пиши полное имя: сокращение MSK Spring не примет. С 2014 года у Москвы круглый год +03:00, без перехода на летнее время: 2:00 по Москве — всегда 23:00 по UTC накануне.

У пояса в учебной CRM и вторая работа — время в тексте задачи о брони:

// учебная CRM клуба: настройки броней — сокращено
    /**
     * По чьим часам опрашивать брони и печатать время в задаче — ключ один на оба дела.
     * Срок брони система клуба отдаёт моментом в UTC: без пересчёта менеджер увидел бы
     * время на три часа раньше настоящего.
     */
    @NotNull
    private ZoneId pollZone = ZoneId.of("Europe/Moscow");

Текст задачи служба броней печатает так:

// учебная служба броней: время в тексте задачи — сокращено
static final DateTimeFormatter MOMENT = DateTimeFormatter.ofPattern("dd.MM HH:mm xxx");
// …
String until = MOMENT.format(heldUntil.atZone(props.getPollZone()));   // тот же момент — на часах пояса

Бронь из статьи 12 держится до 11:00 6 октября по Москве; из системы клуба этот срок приходит моментом 2026-10-06T08:00:00Z. Напечатай его как есть — и менеджер прочтёт «до восьми утра». С пересчётом — 06.10 11:00 +03:00. Ключ один — поменяй пояс, и расписание с текстом поедут вместе.


Часть 4. Обход, который не падает целиком

Бережно к системе клуба#

Ночью клуб закрыт, а система клуба — нет: через неё покупают абонементы на сайте и записываются в группы. CRM для неё — ещё один клиент, и не главный. Поэтому два ночных обхода разведены на полчаса, чтобы не спрашивать систему клуба разом: продления в 2:00, предложения в 2:30.

А в обходе предложений между людьми пауза poll-pause-ms: на каждого — запрос к системе клуба, как часто он ходит. Тысяча человек — тысяча запросов. Без паузы они идут залпом, один за другим; с паузой 250 мс растянутся минимум на четыре минуты, и между ними система клуба отвечает сайту.

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

Одна плохая строка не роняет остальных

Тело обхода продлений в учебной CRM:

// учебная служба обхода продлений — тело метода, сокращено
    public void pollEndingMemberships() {
        List<EndingMembership> ending;
        try {
            ending = club.endingWithin(props.getPollWindowDays());       // спросить систему клуба
        } catch (Exception e) {
            logger.error("Обход продлений не удался — следующий по расписанию", e);
            return;                                                      // работать не с чем
        }
        for (EndingMembership row : ending) {
            try {
                renewals.remindIfNeeded(row);                            // строка → задача менеджеру
            } catch (Exception e) {
                logger.error("Строка не разобрана, идём дальше: {}", row.who(), e);
            }
        }
    }

club — клиент системы клуба, renewals — служба продлений с моделью выше. logger.error(текст, …) — строка лога уровня ERROR из статьи 4; {} в тексте — место, куда SLF4J подставит следующий аргумент, row.who(). Исключение последним аргументом ляжет стеком под строку.

Два уровня сбоя — два ответа.

Не удался сам запрос: система клуба не ответила. Работать не с чем — запись в лог и return. Задач нет, и это правильно: следующей ночью обход повторится, а окно из части 2 сделает пропущенную ночь бесплатной.

Упала одна строка: у одного человека что-то не так в данных. try/catch вокруг каждой строки: ошибка уходит в лог с именем того, о ком строка, цикл идёт дальше. Без него первая плохая строка обрывала бы цикл каждую ночь. Все, кто в списке после неё, остались бы без напоминаний.

Ловят любое Exception, а не конкретные: что бы ни сломалось у одной строки, обход должен дойти до конца списка.

Проверяют это тестом с заглушками Mockito (статьи 4–5): заглушка службы бросает исключение на первой строке.

// учебный тест обхода продлений — сокращено
        when(club.endingWithin(14)).thenReturn(List.of(broken, fine));
        doThrow(new IllegalStateException("сломанная строка")).when(renewals).remindIfNeeded(broken);

        assertThatCode(sweep::pollEndingMemberships).doesNotThrowAnyException();

        verify(renewals).remindIfNeeded(fine);

doThrow(…).when(…) — «когда позовут с этой строкой, брось исключение»; assertThatCode(…).doesNotThrowAnyException() — «вызов не бросит исключения». Расписания в тесте нет: метод вызван напрямую.

InterruptedException: флаг не глотают

В обходе предложений пауза между людьми — Thread.sleep:

// учебная служба обхода предложений — цикл, сокращено
        for (Lead member : members) {
            offers.consider(member);                             // спросить систему клуба и решить
            try {
                Thread.sleep(props.getPollPauseMs());            // пауза: система клуба отвечает и сайту
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();              // вернуть флаг: о просьбе узнают выше
                logger.warn("Обход предложений прерван на лиде {}", member.getId());
                return;                                          // и выйти
            }
        }

Пока поток-исполнитель (статья 1) спит, его могут прервать: попросить остановиться. Например, приложение останавливают посреди обхода. Просьба хранится как флаг прерывания у потока, и sleep отвечает на неё исключением InterruptedException. Оно проверяемое (статья 0), поэтому sleep стоит в try/catch.

Подвох — в документации Java: когда sleep бросает это исключение, флаг сбрасывается. Поймавший — теперь единственный, кто знает о просьбе. Запишешь в лог и пойдёшь дальше — просьба потеряется: обход будет спать и звать систему клуба по всем оставшимся людям. Код выше не узнает, что его просили остановиться. Учебный обход делает правильно: возвращает флаг через Thread.currentThread().interrupt() и выходит.

⚠️ Частая ошибка — catch (InterruptedException e) { e.printStackTrace(); }: флаг потерян, а с ним и просьба. Правило: поймал InterruptedException — верни флаг и заканчивай работу.

Тест прерывает сам себя до вызова — тогда первый же sleep бросит исключение:

// учебный тест обхода предложений — сокращено
        Thread.currentThread().interrupt();      // флаг уже стоит: первый же sleep бросит исключение

        sweep.pollOffers();

        verify(offers).consider(first);
        verify(offers, never()).consider(second);
        assertThat(Thread.currentThread().isInterrupted()).isTrue();

Первый лид обработан, до второго дело не дошло (never() — «ни разу»), флаг на месте.


Проверь себя#

Ответь своими словами — вслух или на бумаге. Не получается — перечитай раздел.

  1. По какому признаку правило попадает в настройки, а не в код? Приведи из учебной CRM пример того и другого.
  2. Почему poll-window-days: 120 лучше уронить при старте, чем принять? Что происходило бы каждую ночь?
  3. Абонемент кончается 12 октября в 00:00, напоминаем за 7 дней, спрашиваем на 14. В какую ночь заведётся задача и когда менеджер её увидит? А при окне 7, если ночь на 5 октября не удалась? Почему срок такой задачи не ставят на прошедшую полночь?
  4. Прочитай 0 15/30 * * * *. Почему 0 2 * * * из туториала Spring не примет и чем «починка» 0 2 * * * * хуже ошибки?
  5. Что будет, если убрать zone из @Scheduled? Почему ответ зависит от машины?
  6. Что значит - в poll-cron и почему в YAML его пишут в кавычках?
  7. Почему обход ловит исключение на каждой строке отдельно, а сбой самого запроса — один раз, с return?
  8. Что потеряется, если в catch (InterruptedException e) только написать в лог?

Что дальше#

Дальше в серии — бронь места изнутри: как CRM понимает, что место оплачено, если система клуба об этом молчит.

Первоисточники#


Пример целиком — с импортами#

Учебный пример. Запускай в песочнице game-day после статьи 14: журнал в example.gameday.journal со всем, что накопили статьи 1–10, сторожа статьи 11, модель статей 12–14 в example.crm и её тесты, Postgres в контейнере, охрана, Actuator и стенд — всё остаётся, менять ничего не нужно. В pom.xml ничего не добавляй: коду статьи Spring не нужен, JUnit и AssertJ — со spring-boot-starter-test.

Что добавить: в src/main/java/example/crm/ — ReminderPoint (точка напоминания), в src/test/java/example/crm/ — ReminderPointTest. Пакет example.crm — рядом с example.gameday, не внутри: сканирование Spring его не видит, а сторож статьи 11 смотрит только в папку журнала. Куски учебной CRM — для чтения, в песочницу их не клади: раздел club.sales, классы настроек, обходы с @Scheduled. У журнала свои настройки из статьи 2, а расписаний у него нет — и @EnableScheduling ему не нужен.

Запуск: треугольником у класса в IDE или ./mvnw test -Dtest=ReminderPointTest; весь ./mvnw verify гоняет и *IT — нужен запущенный Docker. Путь к файлу — комментарием в первой строке.

// src/main/java/example/crm/ReminderPoint.java
package example.crm;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;

/** Точка напоминания о продлении: конец абонемента минус N дней, но не раньше «сейчас». Учебная модель. */
public final class ReminderPoint {

    private final Duration before;                   // за сколько до конца абонемента
    private final Clock clock;                       // откуда берём «сейчас»

    public ReminderPoint(int daysBefore, Clock clock) {
        if (daysBefore < 1) {                        // как @Min(1) в настройках учебной CRM
            throw new IllegalArgumentException("напоминаем хотя бы за день, а пришло " + daysBefore);
        }
        this.before = Duration.ofDays(daysBefore);   // N суток по 24 часа
        this.clock = Objects.requireNonNull(clock, "часы не заданы");   // null — исключение с этим текстом
    }

    public Instant remindAt(Instant until) {
        Instant point = until.minus(before);         // точка по правилу
        Instant now = clock.instant();               // «сейчас» — только из часов
        return point.isBefore(now) ? now : point;    // прошла — значит сейчас
    }
}
// src/test/java/example/crm/ReminderPointTest.java
package example.crm;

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class ReminderPointTest {

    // Конец абонемента — 12 октября 2026, 00:00 по Москве (21:00 по UTC накануне).
    @ParameterizedTest(name = "за {0} дн., сейчас {2} → {3}")
    @CsvSource(delimiter = '|', textBlock = """
            7  | 2026-10-11T21:00:00Z | 2026-09-27T23:00:00Z | 2026-10-04T21:00:00Z
            10 | 2026-10-11T21:00:00Z | 2026-09-27T23:00:00Z | 2026-10-01T21:00:00Z
            7  | 2026-10-11T21:00:00Z | 2026-10-04T23:00:00Z | 2026-10-04T23:00:00Z
            7  | 2026-10-11T21:00:00Z | 2026-10-04T21:00:00Z | 2026-10-04T21:00:00Z
            7  | 2026-10-11T21:00:00Z | 2026-10-04T21:00:01Z | 2026-10-04T21:00:01Z
            """)
    void remindsBeforeUntilButNotInThePast(int daysBefore, Instant until, Instant now, Instant expected) {
        Clock stopped = Clock.fixed(now, ZoneOffset.UTC);              // часы стоят на «сейчас» из таблицы
        ReminderPoint point = new ReminderPoint(daysBefore, stopped);

        assertThat(point.remindAt(until)).isEqualTo(expected);
    }

    @ParameterizedTest(name = "за {0} дн. — ошибка")
    @ValueSource(ints = {0, -7})
    void rejectsRuleWithoutDays(int daysBefore) {
        Clock clock = Clock.systemUTC();
        assertThatThrownBy(() -> new ReminderPoint(daysBefore, clock))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("хотя бы за день");
    }

    @Test
    void rejectsMissingClock() {
        assertThatThrownBy(() -> new ReminderPoint(7, null))
                .isInstanceOf(NullPointerException.class)
                .hasMessageContaining("часы не заданы");
    }
}

Порядок проверки — из папки песочницы:

  1. ./mvnw test -Dtest=ReminderPointTest — 8 запусков, все зелёные: пять строк таблицы, две строки «правило без дней» и тест про часы; ./mvnw verify — зелёный вместе с тестами прошлых статей, TodayListTest, FunnelNumbersTest, CheckoutTest и RublesTest, у сторожа статьи 11 по-прежнему 43 запуска: в папку журнала новый класс не попал, миграций не прибавилось;
  2. что будет, если: без «не раньше сейчас» — return point; — красные строки 3 и 5; Instant.now() вместо clock.instant() — красные все пять строк таблицы; isAfter вместо isBefore — красные четыре строки, зелёная только граница; точка на секунду позже — until.minus(before).plusSeconds(1) — красные строки 1, 2 и граница; на секунду раньше — красные только 1 и 2: граница ловит сдвиг вперёд, но не назад; daysBefore < 0 вместо daysBefore < 1 — красная строка «за 0 дн.»; без Objects.requireNonNull — красный rejectsMissingClock;
  3. 💡 из части 2: та же модель в поясе Парижа — за 168 часов до полуночи 1 ноября 2026 года выходит 01:00 25 октября, а until.atZone(пояс).minusDays(7) даёт полночь.

← Все статьи

Попробовать руками

В кузницах Hammerhall — задачи по Java, которые проверяет сервер: решаешь в своей IDE, отправляешь одной командой, проверка запускает твои тесты и закрытые. Сложность растёт вместе с решённым, первые задачи бесплатны.