Правила продаж — в настройках
Для кого. Ты прочитал статьи 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 осталось в коде — и почему:
- виды задач
RENEWAL,OFFER,HOLDиз статьи 12: по ним задачи лежат в базе, и смена имени потребует миграции данных, как в статье 13; - кому вообще предлагать — лидам в статусе
BECAME_MEMBERиз статьи 13, тем, кто уже купил абонемент. Это ссылка на перечисление, и новый статус всё равно потребует кода; - форма правила предложений: что с чем сравнить и в каком порядке. Недавно уже предлагали — молчим; ходит часто — годовой; пропал — тренер. Это тоже правило дела, но форму, в отличие от чисел, меняют кодом — с тестом и ревью;
- пределы чужой программы. CRM спрашивает систему клуба, у кого абонемент кончается в ближайшие N дней, — это окно опроса; шире 90 дней система клуба не отдаёт. Сам предел не настраивают: это граница для настройки, о ней ниже.
💡 Каждая настройка — регулятор, который можно повернуть не туда. «На всякий случай» в настройки не выносят.
Настройки — класс, а не запись
Так выглядит класс настроек продления в учебной 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; // прошла — значит сейчас
}
}Как читать:
Duration— длительность из статьи 2:Duration.ofDays(7)— семь суток по 24 часа, иuntil.minus(before)даёт момент на неделю раньше конца абонемента;- проверка
daysBefore < 1повторяет@Min(1)из настроек: без Boot объект защищает себя сам (статья 0); Objects.requireNonNull— как в статье 10:NullPointerExceptionс нашим текстом. Забыли передать часы — ошибка сразу, в конструкторе, а не ночью, при первомremindAt;- «сейчас» — только из часов
Clock, как в статье 1. Пояс часов модели не важен: она берёт у них только момент,instant().
В учебной 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). Три поломки — три разных красных:
- без «не раньше сейчас» —
return point;. Покраснеют строки 3 и 5: срок задачи остался в прошлом; - «сейчас» из настоящих часов —
Instant.now()вместоclock.instant(). Покраснеют все пять строк: по настоящему календарю 12 октября 2026 года уже прошло, и модель всегда отвечает сегодняшним «сейчас». А сторож из статьи 11 промолчит: он смотрит только в папку журнала; - сравнение наоборот —
isAfterвместоisBefore. Покраснеют четыре строки из пяти; зелёной останется граница: на ней обе ветки дают одно и то же.
Верни всё как было.
Почему спрашиваем шире, чем напоминаем
Система клуба отдаёт абонементы, которые кончаются в ближайшие 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 пришлось бы держать отдельную программу-будильник, которая зовёт CRM.
- Откуда. Часть самого Spring, модуль
spring-context: приезжает с любым стартером Boot, отдельной зависимости не нужно. Включают расписания явно —@EnableSchedulingна классе приложения:
// учебная CRM клуба: класс приложения
@SpringBootApplication
@ConfigurationPropertiesScan
@EnableScheduling
public class ClubCrmApplication {- Как работает. При старте Spring находит в бинах
методы с
@Scheduledи считает время следующего запуска. В этот момент он вызывает метод сам. Эту работу делает планировщик — часть Spring, которая следит за часами. Метод — без параметров: передать их некому. - Где смотреть. В логе — строки, которые пишет сам обход: учебный пишет, сколько строк пришло и сколько задач заведено. Тесты расписание не трогают — зовут метод напрямую (часть 4).
⚠️ Забудешь @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())); // тот же момент — на часах поясаatZone(пояс)— «тот же момент, но на часах этого пояса»: так в статье 3аTIMESTAMPTZпоказывал время в поясе того, кто спрашивает;DateTimeFormatter.ofPattern— шаблон текста: день и месяц (dd.MM), часы и минуты (HH:mm), сдвиг от UTC вида+03:00(xxx).
Бронь из статьи 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()
— «ни разу»), флаг на месте.
Проверь себя#
Ответь своими словами — вслух или на бумаге. Не получается — перечитай раздел.
- По какому признаку правило попадает в настройки, а не в код? Приведи из учебной CRM пример того и другого.
- Почему
poll-window-days: 120лучше уронить при старте, чем принять? Что происходило бы каждую ночь? - Абонемент кончается 12 октября в 00:00, напоминаем за 7 дней, спрашиваем на 14. В какую ночь заведётся задача и когда менеджер её увидит? А при окне 7, если ночь на 5 октября не удалась? Почему срок такой задачи не ставят на прошедшую полночь?
- Прочитай
0 15/30 * * * *. Почему0 2 * * *из туториала Spring не примет и чем «починка»0 2 * * * *хуже ошибки? - Что будет, если убрать
zoneиз@Scheduled? Почему ответ зависит от машины? - Что значит
-вpoll-cronи почему в YAML его пишут в кавычках? - Почему обход ловит исключение на каждой строке отдельно, а сбой
самого запроса — один раз, с
return? - Что потеряется, если в
catch (InterruptedException e)только написать в лог?
Что дальше#
Дальше в серии — бронь места изнутри: как CRM понимает, что место оплачено, если система клуба об этом молчит.
Первоисточники#
- Spring
Framework: Task Execution and Scheduling —
@EnableScheduling,zoneи раздел Cron Expressions с примерами; - Javadoc
@Scheduled— точный смыслcron,zone,CRON_DISABLED; - Spring Boot: Externalized Configuration — JavaBean Properties Binding и Validation: настройки-классы с сеттерами и их проверка при старте;
- IANA Time Zone
Database — откуда Java знает
Europe/Moscow; - Java
25:
Thread.sleep— что происходит с флагом прерывания.
Пример целиком — с импортами#
Учебный пример. Запускай в песочнице 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("часы не заданы");
}
}Порядок проверки — из папки песочницы:
./mvnw test -Dtest=ReminderPointTest— 8 запусков, все зелёные: пять строк таблицы, две строки «правило без дней» и тест про часы;./mvnw verify— зелёный вместе с тестами прошлых статей,TodayListTest,FunnelNumbersTest,CheckoutTestиRublesTest, у сторожа статьи 11 по-прежнему 43 запуска: в папку журнала новый класс не попал, миграций не прибавилось;- что будет, если: без «не раньше сейчас» —
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; - 💡 из части 2: та же модель в поясе Парижа — за 168 часов до
полуночи 1 ноября 2026 года выходит 01:00 25 октября, а
until.atZone(пояс).minusDays(7)даёт полночь.