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

Что делает CRM: следующий шаг с датой

Статья 12 · читать минут 25

Для кого. Ты прочитал статьи 0–11, и у тебя есть песочница game-day после статьи 11. Особенно пригодятся остановленные часы Clock (статья 1), выборка по времени с границей (статья 9), filter и sorted (статья 11). CRM мог не видеть ни разу — с неё статья и начинается.

Что будет. Что такое CRM и почему её центр — не стадия, а задача менеджеру с двумя сроками; что сломается, если сроки перепутать; чего CRM не хранит и почему это правило дела, а не техники.

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


Зачем джуну правила дела#

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

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

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

Учебный пример: клуб и его CRM

Пример серии — CRM, система отдела продаж. Она есть почти в каждой компании, которая что-то продаёт, а правила дела в ней на виду: кому позвонить, когда и о чём. Чужие CRM мы не разбираем — у каждой своя история. Берём учебную: спортивный клуб с абонементами, пробными тренировками и группами, куда записываются заранее.

У клуба две программы. Система клуба знает, что человек купил и как ходит: абонементы, посещения, расписание групп. CRM ведёт разговор с тем, кто ещё не купил или вот-вот перестанет платить. Код серии — модель правил на чистой Java в песочнице game-day, рядом с журналом.

Дорога человека через CRM#

Серия идёт по одной дороге, статьи — остановки на ней:

лид: оставил заявку на пробную тренировку или пришёл сам
  → менеджер ведёт разговор
  → абонемент кончается — напомнить о продлении
  → ходит часто — предложить годовой абонемент или тренера
  → сколько он заплатит
  → придержал место в группе — довести до оплаты
  → оплатил — через месяц снова на дороге: продление

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

Слово «задача» здесь двоится. У программиста задача — работа: «добавить напоминания о продлении». Задача менеджеру — строка в CRM: «напомни такому-то о продлении, срок — пятница». Дальше в статье «задача» без уточнения — вторая.


Часть 1. Что такое CRM#

CRM в двух словах#

CRM — система, в которой отдел продаж ведёт людей от первого интереса до оплаты: кто пришёл, о чём с ним говорили, что и когда сделать дальше. Название — от Customer Relationship Management, «управление отношениями с клиентами». Готовых CRM десятки — Salesforce, HubSpot, в России чаще amoCRM и Битрикс24. Их берут готовыми и настраивают под себя.

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

Три слова, которые встретятся во всей серии:

Где CRM кончается#

Когда CRM пишут сами, первым делом решают, где её граница. Удобное правило: CRM ведёт человека до оплаты. После оплаты его ведёт система клуба: расписание, посещения, сколько дней осталось у абонемента. CRM помнит разговор, система клуба знает, что человек купил.

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


Часть 2. Центр CRM — следующий шаг с датой

Стадия говорит «где», действие — «что дальше»

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

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

Стадия отвечает «где человек», действие — «что сделать и когда». Утренний список менеджера строится из действий, а не из стадий.

Как это записать: FollowUp#

Следующее действие в учебной модели — запись FollowUp, задача менеджеру. Где она живёт в песочнице — в части 3. Строк import в коротких примерах нет, полные файлы — в конце статьи.

public record FollowUp(String kind, String who, Instant dueAt, Instant untilAt, boolean done) {

    public FollowUp {
        if (dueAt == null) {     // задача без срока ни о чём не напомнит — такой не бывает
            throw new IllegalArgumentException("у задачи нет срока: " + kind + " — " + who);
        }
    }
}

Поля по смыслу:

поле что
kind вид задачи — о чём разговор: продление, предложение, бронь
who о ком
dueAt когда менеджеру сделать — срок задачи
untilAt когда станет поздно — срок предмета: абонемента, брони
done закрыта ли

untilAt может быть null — у предложения срока нет. dueAt — не может: неверный объект не должен существовать (статья 0). В базе такой столбец объявили бы NOT NULL, как detected_at журнала в статье 3а.

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

Кто заводит задачи#

Задачи заводит не менеджер, а сама CRM — по правилам продаж. В учебном клубе их три, и сроки каждое ставит по-своему:

Как посчитать такую точку напоминания и проверить её на часах — в продолжении серии.

Два срока, которые нельзя путать

⚠️ Два поля одного типа, оба «срок» — и отвечают они на разные вопросы:

Абонемент кончается 12 октября — это untilAt. Напомнить надо 5-го — это dueAt. Между ними неделя, ради которой правило продления и написано: успеть поговорить до срока, а не после.

💡 В «Примере целиком» у FollowUp над записью стоит описание полей. /** … */ — javadoc: комментарий-описание к классу, полю или методу, из него строят HTML-документацию. @param описывает один параметр, а у записи — одно поле. Такое описание пишут, когда поле появляется.

Представь: сначала задачи были только о продлении, и у untilAt написали «срок абонемента». Потом в то же поле лёг срок брони, а у предложений его не стало вовсе, но описание осталось прежним. Комментарий никто не компилирует, и он молча отстаёт от кода. Поэтому смысл поля проверяй по местам, где его заполняют: все их найдёт поиск по проекту new FollowUp(, в IDE или через grep -rn.

Кто закрывает#

Менеджер — сам: позвонил — отметил. Но часть задач CRM закрывает и сама. Бронь оплатили или она сгорела — задача «довести до оплаты» больше не правда, и ждать, пока менеджер это заметит, нельзя.

За этим — общее правило: факт, который держится на том, что человек не забудет его отметить, рано или поздно соврёт. Человек оплатил, а менеджер узнал не сразу — задача висит и врёт. Список, которому не верят, хуже пустого: в него перестают смотреть, и вместе с неправдой пропадает правда. Как CRM узнаёт об оплате, о которой ей никто не сообщил, — в продолжении серии.


Часть 3. Список на сегодня — своя модель

Что горит сегодня#

Утром менеджер открывает список «на сегодня». В CRM с базой это та же выборка по времени, что openLongerThan журнала в статье 9: задача не закрыта (done_at is null), и её срок раньше границы (due_at < ?). Только граница другая: не «сейчас минус N минут», а начало завтрашнего дня. Просроченные с прошлых дней тоже горят, и молчать о них нельзя. Граница — тоже решение (статья 9), и здесь его диктует дело.

Правило отбора проверим своей моделью — на чистой Java, без базы. Задачи — записи FollowUp в обычном List, отбирает их stream() из статьи 11.

Модель живёт в песочнице game-day, в пакете example.crm: папки src/main/java/example/crm и src/test/java/example/crm. Пакет лежит рядом с корнем example.gameday, а не внутри него, и сканирование Spring его не увидит (ловушка из статьи 2). Нам это и нужно: модели Spring не нужен, а приложение журнала о ней не знает.

TodayList#

public final class TodayList {

    private final Clock clock;

    public TodayList(Clock clock) {
        this.clock = clock;
    }

    /** Открытые задачи со сроком до конца сегодняшнего дня, просроченные тоже; ранний срок — первым. */
    public List<FollowUp> select(List<FollowUp> all) {
        Instant tomorrow = LocalDate.now(clock)                     // какой сегодня день — в поясе часов
                .plusDays(1)                                        // завтрашний
                .atStartOfDay(clock.getZone())                      // его полночь в том же поясе
                .toInstant();                                       // и как момент
        return all.stream()
                .filter(task -> !task.done())                       // закрытые не показываем
                .filter(task -> task.dueAt().isBefore(tomorrow))    // срок ЗАДАЧИ, а не предмета
                .sorted(Comparator.comparing(FollowUp::dueAt))      // ранний срок — первым
                .toList();
    }
}

Как читать:

⚠️ LocalDate.now() без часов — та же ошибка, что Instant.now(): «сегодня» возьмётся из настоящих часов машины и её пояса, а не из часов теста. Тест это заметит — первый и второй покраснеют: для них «сегодня» уже не 5 октября. А сторож из статьи 11 промолчит: он смотрит только в папку журнала и ищет только Instant. Хочешь, чтобы он стерёг и модель, — добавь папку и расширь выражение сам.

Почему не untilAt#

Понедельник, 5 октября, полдень по Москве. Шесть задач:

задача dueAt, МСК untilAt, МСК в списке?
напомнить о продлении 02.10 12:00 09.10 12:00 да — просрочено, молчать нельзя
предложить годовой абонемент 05.10 02:30 — да
напомнить о продлении 05.10 09:00 12.10 09:00 да
довести бронь до оплаты 05.10 11:15 06.10 11:00 да
напомнить о продлении 08.10 03:00 15.10 03:00 нет — через три дня
напомнить, уже закрыта 05.10 09:00 12.10 09:00 нет — сделано

Теперь замени в отборе dueAt на untilAt: «предмет кончается до конца сегодняшнего дня». Из четырёх нужных задач в список не попадёт ни одна:

С проверкой на null — пустой список в полдень понедельника и ни одной ошибки в логе. Код работает, правило дела — нет.

Ещё две поломки того же отбора. «Строго сегодня», то есть dueAt от полуночи до полуночи, теряет просроченное. Задача, которую вчера не сделали, сегодня важнее, а не наоборот. Без !task.done() в списке висит сделанное — и список снова врёт.

Тест#

Полный тест — в «Примере целиком», здесь главное:

// Понедельник, 5 октября 2026, 12:00 по Москве (09:00 по UTC).
private static final Clock NOON =
        Clock.fixed(Instant.parse("2026-10-05T09:00:00Z"), ZoneId.of("Europe/Moscow"));
// …
assertThat(shown).containsExactly(overdue, offer, remindToday, hold);

ZoneId — пояс по имени из базы поясов: Europe/Moscow. У Москвы с 2014 года круглый год +03:00, без перехода на летнее время. Задачи теста — строки таблицы выше, а containsExactly проверяет и состав, и порядок. Второй тест — граница дня: задача на 23:59 по Москве в списке, на 00:00 — уже нет. Третий — задача без dueAt не создаётся.

Проверь, что тест умеет краснеть (статья 11): подмени в отборе task.dueAt() на task.untilAt(). Первый тест упадёт на предложении с NullPointerException, второй покраснеет с Expecting actual: и пустым [] — список пуст. Верни как было.

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

Поэтому список полезно уметь упорядочить и по untilAt — «что сгорит раньше». Попробуй сам: Comparator.comparing(FollowUp::untilAt, Comparator.nullsLast(Comparator.naturalOrder())) поставит бронь первой, а предложение — в конец. naturalOrder() — обычный порядок моментов, ранний первым; nullsLast ставит null после всех. База делает так же без подсказки: Postgres при сортировке по возрастанию ставит NULL последними.

🔑 Два срока — две работы. dueAt отвечает «пора ли действовать» — по нему отбирают. untilAt отвечает «что сгорит раньше» — по нему можно упорядочить, но не отбирать.


Часть 4. Чего CRM не хранит#

Вычислимое — спрашивают у источника

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

Почему так строго? Копия факта начинает устаревать в ту минуту, когда её сняли. Разойдётся она с источником обязательно — вопрос только, кто и когда это заметит.

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

Перед тем как завести в CRM новое поле или таблицу, полезно задать три вопроса:

  1. Знает ли ответ система клуба? Знает — спрашивать, а не хранить.
  2. Это про разговор или про то, что человек купил и как ходит? Разговор ведёт CRM, остальное — у системы клуба.
  3. Что будет, если два места разойдутся? Ответ «никто не заметит» значит, что хранить нельзя: незаметное расхождение и есть самое опасное.

Переписка — в мессенджере#

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

А untilAt — не копия?#

Внимательный читатель спросит: untilAt — срок абонемента, а абонементы CRM не хранит. Противоречие?

Нет — разница в том, о чём факт. untilAt в задаче — не «до какого числа абонемент сейчас», а «о каком сроке был разговор». Продлит человек абонемент — у старой задачи untilAt не переписывают: новый срок — это новое напоминание. Копия состояния устаревает; запись о разговоре — нет, разговор был именно о том сроке.

🔑 Что с человеком сейчас — спрашивают у системы клуба. О чём с ним говорили и что обещали — помнит CRM.

Спрашивает сама, по расписанию

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

Почему не ждать, пока система клуба сообщит? Потому что «абонемент кончается через неделю» — не событие. В эту минуту ничего не случается: никто не нажал кнопку, ничего не записалось — сообщать не о чем. Такой факт можно только заметить, посмотрев на календарь, — и смотрит обход по расписанию. Как расписание записывают и почему его выносят в настройки — в продолжении серии.

У выбора «спрашивать самому или ждать сообщений» есть и доводы потоньше — про надёжность доставки между системами. В рабочем проекте их взвешивает мидл.


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

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

  1. Чем стадия отличается от следующего действия? Почему в центре CRM — действие, а не стадия?
  2. У задачи два срока. Что значит каждый и кто его ставит? Объясни на продлении абонемента.
  3. Отбор «на сегодня» написали по untilAt. Каких задач менеджер утром не увидит? Объясни на предложении, продлении и брони.
  4. Почему просроченная задача попадает в список «на сегодня»?
  5. Где в TodayList кончается «сегодня»? Зачем для этого пояс и откуда он берётся?
  6. Почему задачи по брони CRM закрывает сама, а не ждёт менеджера?
  7. CRM не хранит, до какого числа у человека абонемент, но хранит untilAt. Почему это не противоречие?
  8. Описание поля говорит одно, а заполняют его иначе. Как узнать, что поле значит на самом деле?

Что дальше#

Дальше в серии — первая остановка на дороге: лид. Откуда он берётся, что значат его статусы и почему один из них руками поставить нельзя.

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


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

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

Что добавить: в src/main/java/example/crm/ — FollowUp (задача менеджеру) и TodayList (что показать сегодня), в src/test/java/example/crm/ — TodayListTest. Пакет example.crm — рядом с example.gameday, не внутри: сканирование Spring его не видит, а сторож статьи 11 смотрит только в папку журнала.

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

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

import java.time.Instant;

/**
 * Задача менеджеру: что сделать, о ком и к какому сроку. Учебная модель.
 *
 * @param kind    вид: RENEWAL — продление, OFFER — предложение, HOLD — бронь места
 * @param who     о ком задача
 * @param dueAt   срок задачи: когда менеджеру сделать
 * @param untilAt срок предмета: когда кончится абонемент или сгорит бронь; у предложения его нет — null
 * @param done    закрыта ли
 */
public record FollowUp(String kind, String who, Instant dueAt, Instant untilAt, boolean done) {

    public FollowUp {
        if (dueAt == null) {     // задача без срока ни о чём не напомнит — такой не бывает
            throw new IllegalArgumentException("у задачи нет срока: " + kind + " — " + who);
        }
    }
}
// src/main/java/example/crm/TodayList.java
package example.crm;

import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;

/**
 * Что показать менеджеру сегодня. Учебная модель.
 */
public final class TodayList {

    private final Clock clock;

    public TodayList(Clock clock) {
        this.clock = clock;
    }

    /** Открытые задачи со сроком до конца сегодняшнего дня, просроченные тоже; ранний срок — первым. */
    public List<FollowUp> select(List<FollowUp> all) {
        Instant tomorrow = LocalDate.now(clock)                     // какой сегодня день — в поясе часов
                .plusDays(1)                                        // завтрашний
                .atStartOfDay(clock.getZone())                      // его полночь в том же поясе
                .toInstant();                                       // и как момент
        return all.stream()
                .filter(task -> !task.done())                       // закрытые не показываем
                .filter(task -> task.dueAt().isBefore(tomorrow))    // срок ЗАДАЧИ, а не предмета
                .sorted(Comparator.comparing(FollowUp::dueAt))      // ранний срок — первым
                .toList();
    }
}
// src/test/java/example/crm/TodayListTest.java
package example.crm;

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List;

import org.junit.jupiter.api.Test;

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

class TodayListTest {

    // Понедельник, 5 октября 2026, 12:00 по Москве (09:00 по UTC).
    private static final Clock NOON =
            Clock.fixed(Instant.parse("2026-10-05T09:00:00Z"), ZoneId.of("Europe/Moscow"));

    private final TodayList today = new TodayList(NOON);

    @Test
    void showsOpenTasksDueTodayAndOverdue() {
        FollowUp overdue = new FollowUp("RENEWAL", "человек-1",
                Instant.parse("2026-10-02T09:00:00Z"), Instant.parse("2026-10-09T09:00:00Z"), false);
        FollowUp remindToday = new FollowUp("RENEWAL", "человек-2",         // по untilAt — только 12.10
                Instant.parse("2026-10-05T06:00:00Z"), Instant.parse("2026-10-12T06:00:00Z"), false);
        FollowUp remindLater = new FollowUp("RENEWAL", "человек-3",
                Instant.parse("2026-10-08T00:00:00Z"), Instant.parse("2026-10-15T00:00:00Z"), false);
        FollowUp offer = new FollowUp("OFFER", "человек-4",                 // по untilAt — никогда
                Instant.parse("2026-10-04T23:30:00Z"), null, false);
        FollowUp hold = new FollowUp("HOLD", "человек-5",                   // по untilAt — завтра, поздно
                Instant.parse("2026-10-05T08:15:00Z"), Instant.parse("2026-10-06T08:00:00Z"), false);
        FollowUp closed = new FollowUp("RENEWAL", "человек-6",
                Instant.parse("2026-10-05T06:00:00Z"), Instant.parse("2026-10-12T06:00:00Z"), true);

        List<FollowUp> shown = today.select(List.of(closed, hold, remindLater, offer, remindToday, overdue));

        assertThat(shown).containsExactly(overdue, offer, remindToday, hold);
    }

    @Test
    void dayEndsAtMidnightInTheClockZone() {
        FollowUp lastMinute = new FollowUp("RENEWAL", "человек-7",          // 23:59 по Москве
                Instant.parse("2026-10-05T20:59:00Z"), Instant.parse("2026-10-12T20:59:00Z"), false);
        FollowUp midnight = new FollowUp("RENEWAL", "человек-8",            // 00:00 по Москве — уже завтра
                Instant.parse("2026-10-05T21:00:00Z"), Instant.parse("2026-10-12T21:00:00Z"), false);

        assertThat(today.select(List.of(midnight, lastMinute))).containsExactly(lastMinute);
    }

    @Test
    void taskWithoutDueDateDoesNotExist() {
        assertThatThrownBy(() -> new FollowUp("OFFER", "человек-9", null, null, false))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("нет срока");
    }
}

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

  1. ./mvnw test -Dtest=TodayListTest — три теста зелёные; ./mvnw verify — зелёный вместе с тестами прошлых статей, у сторожа статьи 11 по-прежнему 43 запуска: в папку журнала модель не попала;
  2. что будет, если: task.untilAt() вместо task.dueAt() в отборе — первый тест падает с NullPointerException, второй — с Expecting actual: и []; та же подмена с проверкой на null — красные оба, список пуст; без !task.done() — красный первый; «строго сегодня» — красный первый, просроченное пропало; полночь по UTC вместо clock.getZone() — красный только второй; LocalDate.now() без часов — красные первый и второй; без проверки в компактном конструкторе — красный третий;
  3. 💡 из части 3: nullsLast по untilAt — бронь первой, предложение последним.

← Все статьи

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

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