Статусы лида: какие ставит человек, а какой — только система
Для кого. Ты прочитал статьи 0–12, и у тебя есть песочница
game-dayпосле статьи 12. Особенно пригодятся@Enumeratedи чем ломаетсяORDINAL(статья 9), коды ответа и общий обработчик ошибок (статья 4), лид, стадия и воронка (статья 12).Что будет. Почему один статус лида человек поставить не может, а остальные — любые. Что стало бы со статусами в базе, храни их CRM номером. И почему число на панели — не арифметика, а определение.
Откуда статья. Раздел «Буткемп / Лаборатория» — о том, как пишут код команды Hammerhall: буткемп работает в открытых репозиториях, лаборатория — в закрытых. Серия «От системы к делу» — следующий шаг после «От Spring к системе»: как правила дела — сроки, деньги, обещания людям — живут в коде. Примеры — учебные: их можно повторить у себя.
Зачем: стадия — где человек сейчас
Лид из статьи 12 — человек, который заинтересовался клубом, но ещё ничего не купил: оставил заявку на пробную тренировку, позвонил или пришёл сам. С лида в CRM начинается всё остальное.
У лида есть статус — стадия на пути: заявка пришла, менеджер взялся, поговорили, отказался. Статья 12 объяснила, что центр CRM — не стадия, а следующее действие с датой, и развела их одной фразой:
Стадия описывает, где человек; следующее действие — единственное, что двигает его дальше.
Стадия не напоминает и не двигает. Зачем она тогда? Чтобы менеджер видел, с кем говорить, а руководитель — воронку (статья 12). Доля тех, кто дошёл до цели и купил абонемент, — конверсия.
И сразу вопрос, ради которого статья: кто меняет статус? Почти все статусы — менеджер, кнопкой, по итогам разговора. А один — только система. Разберём почему и что из этого следует для базы и для числа на панели.
Пример — учебная CRM клуба из статьи 12. Её правила покажем короткими
кусками: так они выглядели бы в CRM на Spring, устроенной как журнал
статей 3б–5. Выброшенное помечено // …; эти куски — для
чтения, собирать их не нужно. Свой код статьи — два маленьких класса на
чистой Java, OrdinalDemo и FunnelNumbers, и
тест ко второму. Они живут в песочнице game-day, в пакете
example.crm, рядом с FollowUp и
TodayList из статьи 12.
В коротких примерах нет строк import — полные файлы
своего кода в конце статьи.
Часть 1. Лид#
Что CRM знает о лиде#
Карточка лида в учебной CRM:
| поле | что |
|---|---|
| имя | как к человеку обращаться |
| телефон, мессенджер, почта | как связаться — хотя бы что-то одно |
| источник | откуда пришёл: заявка с сайта, звонок, пришёл сам; обязателен |
| канал | что привело: какая реклама, совет друга; если известно |
| интерес | что ищет: группу, тренера, утренние часы |
| статус | стадия на пути |
| менеджер | кто ведёт лида |
| когда пришёл | момент, когда лида завели |
Главное о полях — и об одном, которого в карточке нет.
Источник и канал — откуда лид пришёл. Источник обязателен: заявку с сайта CRM записывает сама и источник ставит сама, звонок или гостя у стойки менеджер заводит руками и указывает его сам. Зачем? Клуб платит за рекламу и хочет знать, какая приводит людей. Через месяц уже не вспомнишь, кто пришёл с какой, — поэтому источник и канал фиксируют при создании, а не потом.
Статус — стадия. Кто его ставит, расскажет часть 2, а как он лежит в базе — часть 3.
Менеджер — кто ведёт лида. В задаче менеджеру из статьи 12 ответственного нет: кто ведёт человека, записано здесь, у лида.
Абонемента в карточке нет. Лид живёт в CRM, а в системе клуба человека пока нет: он ничего не купил. Что человек купил и как ходит — хозяйство системы клуба (статья 12).
Без контакта лида не завести#
Лида заводит служба учебной CRM, и первым делом её метод создания проверяет контакт:
// учебная служба лидов, создание — первая проверка
if (isBlank(form.phone()) && isBlank(form.messenger()) && isBlank(form.email())) {
throw new InvalidInputException("lead_contact_missing",
"Нужен хотя бы один контакт: телефон, мессенджер или почта");
}form — то, что прислали на создание.
isBlank(s) — маленькая функция той же службы,
s == null || s.isBlank(), как проверка пустого в статье 8:
строки нет вовсе или в ней одни пробелы. Нет ни телефона, ни
мессенджера, ни почты — исключение «на входе негодное», как
InvalidInputException журнала из статьи 4. В ответ уйдёт
400.
Это правило продаж. Лид, с которым нечем связаться, — пустая строка в списке менеджера: ему не позвонить и не напомнить. Неверный объект не должен существовать вовсе (статья 0).
Первый статус ставит код#
Дальше служба переносит поля из формы в нового лида и ставит статус:
lead.setStatus(LeadStatus.NEW);В форме создания поля статуса нет вовсе. Каждый лид начинается с
NEW, кто бы его ни завёл — сайт или менеджер. Завести лида
«сразу клиентом» нельзя: такого входа просто нет. Будь у формы поле
статуса, запрет из части 2 обходили бы с другой стороны: завёл лида — и
он уже клиент.
Часть 2. Кто ставит статус#
Пять статусов#
Перечисление статусов учебной CRM — целиком:
public enum LeadStatus {
NEW,
IN_PROGRESS,
CONTACTED,
BECAME_MEMBER,
REJECTED
}| статус | по-русски | кто может поставить |
|---|---|---|
NEW |
новый | код — при создании; менеджер |
IN_PROGRESS |
в работе | менеджер |
CONTACTED |
связались | менеджер |
BECAME_MEMBER |
стал клиентом | только система |
REJECTED |
отказался | менеджер |
«Стал клиентом» значит: человек купил первый абонемент, и система клуба завела его как своего клиента. Это конец воронки — цель той дороги до оплаты, которую CRM ведёт (статья 12).
Один статус руками не поставить
Статус меняет отдельная ручка —
PATCH /leads/{id}/status. PATCH — ещё один
метод HTTP рядом с GET, POST, PUT
и DELETE из статьи 4: PUT заменяет ресурс
целиком, а PATCH меняет его часть. Здесь часть — одно поле,
статус; остальные данные лида правит PUT /leads/{id} (о нём
ниже).
Правило держит служба, а не ручка:
// учебная служба лидов: смена статуса руками — сокращено
public Lead changeStatus(UUID id, LeadStatus next) {
Lead lead = find(id); // нет такого — 404, как в статье 4
if (next == LeadStatus.BECAME_MEMBER) {
throw new ConflictException("lead_status_by_system",
"Статус BECAME_MEMBER нельзя поставить вручную");
}
LeadStatus old = lead.getStatus();
lead.setStatus(next);
notifier.statusChanged(lead, old); // оповестить: было → стало
return lead;
}ConflictException — исключение «противоречит тому, что
уже есть». Общий обработчик (статья 4) отвечает на него 409
с той же формой тела ошибки. Ручка только передаёт службе запрос, а
запрет живёт в службе: его не обойти, даже если позвать службу не из
ручки.
Второй запрет — на правку самого лида. PUT /leads/{id}
лиду, который стал клиентом, отказывает:
if (lead.getStatus() == LeadStatus.BECAME_MEMBER) {
throw new ConflictException("lead_is_member", "Карточку лида, который стал клиентом, не правят");
}В ответ — снова 409. Дорога от заявки до покупки
пройдена, и по ней считают воронку (часть 4). Что с человеком дальше,
знает система клуба: телефон, абонемент, посещения.
Такие запреты держат тесты двух слоёв, как в статье 5: тест службы с
заглушкой хранилища и тест ручки через HTTP, с ожиданием
409.
Почему этот статус — не кнопка
🔑 Есть факты, которые человек не вводит: их устанавливает событие. «Человек купил абонемент» случилось не в CRM, а в системе клуба — на кассе или при оплате на сайте. Менеджер может верить, что человек купил: тот сказал по телефону «да, оплатил вчера». Но вера — не факт. Кнопка «стал клиентом» превратила бы надежду менеджера в запись, а запись дальше живёт как правда.
Помнишь правило из статьи 12: факт, который держится на том, что человек не забудет его отметить, рано или поздно соврёт. С кнопкой хуже, чем с забывчивостью: соврать можно сразу — промахнуться строкой в списке, поверить на слово, поторопиться к отчёту в конце месяца.
А на этот статус опирается остальное: панель считает по нему конверсию (часть 4), карточка запирается от правки. Будь такая кнопка, один неверный клик — и панель показала бы покупку, которой не было. Ручной ввод такого факта — источник неправды.
Тот же принцип в учебной CRM работает и шире. От того, сколько раз в неделю человек ходит, зависит, предлагать ли ему годовой абонемент (статья 12). Считает это система клуба, а не CRM: посещения у неё. Считает тот, у кого данные, а CRM показывает ответ и действует по нему.
Кто же ставит BECAME_MEMBER? Код самой CRM, который
узнаёт о покупке от системы клуба, — а не ручка менеджера. Как CRM
вообще узнаёт о том, что случилось в системе клуба и о чём ей никто не
сообщил, — в продолжении серии, на оплате брони.
Остальные — любой в любой#
Кроме этого запрета, правил у статусов нет. Руками можно поставить
любой другой: из NEW сразу в CONTACTED, из
REJECTED обратно в IN_PROGRESS.
Хорошо это или плохо, решают продажи. За свободу два довода. Менеджер
ошибся кнопкой — должен вернуть сам, не дожидаясь разработчика. Человек
отказался весной и пришёл снова осенью — REJECTED сменится
на IN_PROGRESS, и это не ошибка, а обычная жизнь продаж.
Статус здесь — суждение менеджера о разговоре, а суждение меняется.
Цена свободы: статус не говорит, как лид к нему пришёл, и ничто не мешает поставить бессмысленный. Учебная CRM выбрала свободу и один жёсткий запрет — там, где речь о факте, а не о суждении.
💡 Попробуй сам. Представь, что продажи попросили порядок: из
NEW можно только в IN_PROGRESS или
REJECTED, а из CONTACTED нельзя назад в
NEW. Где бы ты записал такие правила, как проверил бы их
тестом и что стало бы с менеджером, который ошибся кнопкой? И
присмотрись к changeStatus выше: поставить
BECAME_MEMBER руками нельзя, а увести лида из него — можно.
Дыра это или так и надо?
Смена статуса оповещает#
После сохранения служба зовёт
notifier.statusChanged(lead, old): в общий чат отдела
продаж уходит строка «Было: NEW → Стало: IN_PROGRESS». Оповещатель —
интерфейс, как Notifier из статьи 1, и реализаций у него
две. Настоящая шлёт сообщение в мессенджер. Пустая (no operation,
«ничего не делать») только пишет строку в лог: «отправил бы вот
это».
Какую из двух получит служба, решает настройка. В Spring Boot для
такого выбора есть @ConditionalOnProperty
— условие на бин: метод с @Bean сработает, только если
настройка имеет нужное значение. Ставить ничего не нужно: условие живёт
там же, где автонастройка (статья 2), и приезжает с любым стартером.
Над настоящей реализацией стоит условие «настройка равна
true», над пустой — «равна false» и
matchIfMissing = true: пустая получится и тогда, когда
настройки нет вовсе. В учебной CRM оповещения по умолчанию выключены: на
машине разработчика сообщения в общий чат никому не нужны. Включает их
переменная окружения (${ИМЯ:значение} из статьи 2).
Служба о выборе не знает: она просит интерфейс. А настоящая реализация ловит ошибки отправки и пишет их в лог — мессенджер лёг, статус всё равно сменится.
Часть 3. Статус в базе: имя, а не номер
STRING — помнишь статью 9#
В CRM с базой лид — сущность (статья 3б), и над полем статуса стоит
@Enumerated(EnumType.STRING):
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 32)
private LeadStatus status;В миграции таблицы лидов — столбец под имя:
status VARCHAR(32) NOT NULL DEFAULT 'NEW',В столбце лежит имя константы: NEW,
REJECTED. В статье 9 ты видел оба способа JPA и чем
ломается каждый. Посмотрим на поломку ORDINAL на статусах
лида.
Что было бы с номером#
ordinal() — метод любого перечисления:
номер константы по порядку объявления, с нуля. Его и кладёт в базу
ORDINAL. Документация Java предупреждает прямо: «Most
programmers will have no use for this method» — большинству
программистов он не нужен, он для особых структур вроде
EnumMap.
Представь: продажи просят новый статус «не дозвонились»,
NO_ANSWER. Логично поставить его рядом с
IN_PROGRESS — в середину. OrdinalDemo держит
два перечисления, «было» и «стало», и показывает, что прочтётся из
старых строк:
enum Before { NEW, IN_PROGRESS, CONTACTED, BECAME_MEMBER, REJECTED }
enum After { NEW, IN_PROGRESS, NO_ANSWER, CONTACTED, BECAME_MEMBER, REJECTED }
static After readByOrdinal(Before written) {
int stored = written.ordinal(); // так ORDINAL пишет в столбец
return After.values()[stored]; // так прочтёт тот же столбец после правки
}
static After readByName(Before written) {
String stored = written.name(); // так STRING пишет в столбец
return After.valueOf(stored); // ищет константу с таким же именем
}name() — имя константы строкой, ровно как в коде:
REJECTED.name() — это "REJECTED";
valueOf — обратный путь: ищет константу по имени.
main печатает строку на каждый старый статус:
записано номер ORDINAL прочтёт STRING прочтёт
NEW 0 NEW NEW
IN_PROGRESS 1 IN_PROGRESS IN_PROGRESS
CONTACTED 2 NO_ANSWER CONTACTED
BECAME_MEMBER 3 CONTACTED BECAME_MEMBER
REJECTED 4 BECAME_MEMBER REJECTED🔴 Посмотри на две последние строки. Все, кто отказался, при
ORDINAL стали клиентами, а настоящие клиенты — «связались».
Как в статье 9 — ни ошибки, ни красного теста. Панель покажет конверсией
долю отказавшихся.
И заметь: отказавшиеся получили тот самый статус, который руками поставить нельзя. Запрет из части 2 обошли, не написав ни строки в обход.
STRING читает всё как было: вставка в середину
безопасна.
Цена имени#
⚠️ При STRING имя константы стало частью данных — и не
только базы. В учебной CRM то же имя живёт:
- в столбце
statusтаблицы лидов; - в JSON: ручка отвечает
"status": "NEW", а смена статуса принимает имя — незнакомое даёт400; - в тексте оповещения —
old.name(); - в ключах панели (часть 4): имя статуса строкой.
Переименуешь константу — придётся менять всё это разом, а в базе нужна миграция данных.
Зачем вообще переименовывать? Представь: в первой версии конечный
статус назвали MEMBER — «клиент». Коротко и понятно. Но
посмотри, что это имя обещает.
«Клиент» — состояние: сегодня клиент, кончился абонемент — уже нет.
Прочитав такое имя, кто-нибудь через месяц заведёт рядом
EX_MEMBER, «бывший клиент»: название разрешает. И CRM
начнёт хранить, есть ли у человека абонемент сейчас, — копию того, что
знает система клуба, ту самую вторую правду из статьи 12.
А CRM знает о покупке одно: она случилась. BECAME_MEMBER
значит «стал клиентом»: имя называет именно это событие, и оно остаётся
правдой, даже когда абонемент давно кончился. Поэтому статус
переименовали, и старым строкам нужна миграция данных:
UPDATE lead SET status = 'BECAME_MEMBER'
WHERE status = 'MEMBER';Применённую миграцию не правят (статья 3а): UPDATE идёт
новой миграцией. Забудь её — и старую строку Java не прочтёт. Последняя
строка вывода OrdinalDemo:
переименование при STRING: No enum constant example.crm.OrdinalDemo.After.MEMBERЭто громче, чем ORDINAL: ошибка, а не молчаливая
подмена. Но и ошибка на чтении старой строки — уже беда в работающей
CRM.
💡 Имя статуса выбирают всерьёз: оно говорит, какой факт записан, — и подсказывает, что можно завести рядом. В рабочем проекте команды конечный статус лида однажды переименовали ровно по этой причине: старое имя обещало то, чего CRM не ведёт.
Часть 4. Панель: число — это определение
Как панель считает конверсию#
На панели учебной CRM три числа: сколько лидов всего, сколько в
каждом статусе и конверсия — доля ставших клиентами среди всех лидов, в
процентах, до десятых. Числа по статусам дала бы база: в CRM с базой это
метод хранилища по имени (статья 3б), countByStatus, — по
разу на каждую константу. Получается карта «имя статуса → число».
Сердце расчёта — одна формула. Запишем её сразу в свой класс
FunnelNumbers (он целиком — ниже), закрытым методом
percent:
private static double percent(long part, long whole) {
if (whole == 0) {
return 0.0; // делить не на что — как на панели CRM
}
double raw = (part * 100.0) / whole; // 100.0, а не 100: деление дробное
return Math.round(raw * 10.0) / 10.0; // до десятых, половина — вверх
}Конверсия — часть делить на целое и умножить на 100. Именно
100.0, а не 100: с целыми числами деление было
бы целым, и 1 из 3 дало бы 33, а не 33,3. part * 100.0 —
уже дробное число, double, и деление за ним тоже
дробное.
Округление до десятых — приём
Math.round(x * 10.0) / 10.0: умножить, округлить до целого,
разделить. Math.round половину округляет вверх: 1 из 16 —
это 6,25 %, на панели будет 6,3. Лидов нет — 0.0: делить не
на что, и метод говорит это первой строкой.
Для денег double не годится — об этом в продолжении
серии. Для доли на панели его точности хватает.
Что в знаменателе#
🔑 Формула короткая, а вопросов за ней много. Число на панели — это определение, и выбирает его дело, а не код.
Пусть в CRM 100 лидов. Двадцать пришли вчера, с ними ещё никто не говорил. Тридцать отказались. Десять стали клиентами. Сорок в работе.
- Как на панели — среди всех: 10 из 100, 10 %.
- Среди тех, чей исход уже известен — купил или отказался: 10 из 40, 25 %.
Оба числа честные — и отвечают на разные вопросы. Первое — «какая
доля всех обращений доходит до покупки». Второе — «как часто законченный
разговор кончается „да“». Вчерашние двадцать тянут первое вниз, хотя
ничего ещё не решено. А лид, забытый в NEW год назад, сидит
в знаменателе наравне с ними: панель берёт всех лидов за всё время.
Есть и третий путь — считать по месяцу прихода: из пришедших в сентябре сколько стали клиентами к сегодняшнему дню. Тогда вчерашний лид не смешан с годовалым; дата прихода у лида есть — поле «когда пришёл» из части 1.
Числитель — тоже определение. BECAME_MEMBER значит
«купил абонемент», а не «остался в клубе»: кто купил месяц и больше не
пришёл, в числителе наравне с тем, кто ходит третий год. Панель меряет
дорогу до покупки, а не до привычки.
Какую бы конверсию ни выбрали, код обязан сказать, что он считает. На панели видно одно слово — «конверсия», а определение спрятано в формуле. Сказать можно именем метода, комментарием, подписью на панели. Главное — сказать.
FunnelNumbers: определение в имени метода
FunnelNumbers — запись: сколько лидов всего и сколько в
каждом статусе; ключи — имена статусов, как в ответе панели. Долю она
считает двумя методами, и имя каждого говорит, что в знаменателе:
public record FunnelNumbers(long total, Map<String, Long> byStatus) {
public FunnelNumbers {
byStatus = Map.copyOf(byStatus); // своя копия: снаружи её не поменяют
// … ни одно число не меньше нуля, вместе дают total — иначе исключение
}
/** Доля статуса среди ВСЕХ лидов, в процентах, до десятых. Так считает панель CRM. */
public double shareOfAll(String status) {
return percent(count(status), total);
}
/** Доля статуса среди лидов в статусах base — например, среди тех, чей исход известен. */
public double shareAmong(String status, Set<String> base) {
// … status обязан входить в base; знаменатель — сумма лидов по статусам base
}
// … count(status) — число из карты или 0; percent(часть, целое) — формула выше
}count(status) берёт число из карты методом
getOrDefault(ключ, 0L): он отдаёт число по ключу, а если
ключа нет — ноль. Map.copyOf — неизменяемая копия, как у
details в статье 9. Компактный конструктор (статья 0) не
пустит неверные числа: если по статусам 4 лида, а всего 10, объекта не
будет — исключение с текстом
лидов по статусам 4, а всего 10.
Тест — по таблице, как в статье 0. Начало:
// десять лидов: трое стали клиентами, двое отказались,
// у пятерых исхода ещё нет — четверо новых, один в работе
private final FunnelNumbers september = new FunnelNumbers(10, Map.of(
"NEW", 4L,
"IN_PROGRESS", 1L,
"BECAME_MEMBER", 3L,
"REJECTED", 2L));
@Test
void shareOfAllCountsEveryLead() {
assertThat(september.shareOfAll("BECAME_MEMBER")).isEqualTo(30.0);
}
@Test
void shareAmongDecidedLeavesOutLeadsInProgress() {
Set<String> decided = Set.of("BECAME_MEMBER", "REJECTED");
assertThat(september.shareAmong("BECAME_MEMBER", decided)).isEqualTo(60.0);
}
@ParameterizedTest(name = "{0} из {1} → {2}")
@CsvSource(delimiter = '|', textBlock = """
3 | 10 | 30.0
1 | 3 | 33.3
2 | 3 | 66.7
1 | 16 | 6.3
0 | 0 | 0.0
""")
void roundsToTenths(long members, long total, double expected) { … }4L, а не 4: карта ждёт Long.
Одни и те же данные — 30 % и 60 %. Первая проверка держит определение
панели, вторая — другое. В таблице округления есть половина (6,25 → 6,3)
и ноль лидов. Полный тест с проверками конструктора и копии карты — в
конце.
💡 «0 из 0» и «0 из 100» на панели выглядят одинаково —
0.0. Первое значит «считать не из чего», второе — «никто не
дошёл». FunnelNumbers повторяет решение панели; захочешь их
различать — нужен ответ «нет данных», а не число.
Проверь себя#
Ответь своими словами — вслух или на бумаге. Не получается — перечитай раздел.
- Почему в форме создания лида нет поля статуса? Что могло бы случиться, будь оно?
- Чем
BECAME_MEMBERотличается от остальных статусов? Объясни, почему ручной ввод такого факта — источник неправды. - Менеджер по ошибке поставил
REJECTED. Что он может сделать и почему правил переходов нет? - Как служба получает оповещатель — настоящий или пустой? Что она получит, если настройки нет вовсе?
- В перечисление статусов вставили константу в середину. Что станет со
старыми строками при
ORDINALи приSTRING? Почему случай с отказавшимися — самый неприятный? - Где, кроме базы, живёт имя статуса? Что придётся сделать, чтобы его
переименовать, и чем
BECAME_MEMBERлучшеMEMBER? - 100 лидов: 20 новых, 30 отказались, 10 стали клиентами, 40 в работе. Посчитай конверсию двумя способами. На какой вопрос отвечает каждое число?
- Почему в формуле стоит
100.0, а не100? Что покажет панель для одного клиента из 16 лидов и почему?
Что дальше#
Дальше в серии — следующая остановка на дороге: лид дошёл до
разговора о цене. Сколько он заплатит, почему деньги не хранят в
double и в какую сторону округлять.
Первоисточники#
- Java
SE 25: Enum —
ordinal(),name(),valueOfи предупреждение, чтоordinal()большинству не нужен; - Hibernate
ORM 7: User Guide — раздел Enums:
ORDINALиSTRING(подробно — статья 9); - Spring
Boot: ConditionalOnProperty — таблица «значение настройки →
создаётся ли бин» и
matchIfMissing; - Java SE 25: Math.round — округление к ближайшему, где половина идёт к плюс бесконечности.
Пример целиком — с импортами#
Учебный пример. Запускай в песочнице game-day после
статьи 12: журнал в example.gameday.journal со всем, что
накопили статьи 1–10, сторожа статьи 11, модель статьи 12 в
example.crm и её тест, Postgres в контейнере, охрана,
Actuator и стенд — всё остаётся, менять ничего не нужно. В
pom.xml ничего не добавляй: коду статьи Spring не нужен,
JUnit и AssertJ — со spring-boot-starter-test.
Что добавить: в src/main/java/example/crm/ —
OrdinalDemo (что прочтётся из старых строк после правки
перечисления) и FunnelNumbers (числа воронки и доля с
определением в имени метода), в src/test/java/example/crm/
— FunnelNumbersTest. Пакет example.crm — рядом
с example.gameday, не внутри: сканирование Spring его не
видит, а сторож статьи 11 смотрит только в папку журнала.
Запуск: OrdinalDemo — обычная программа, треугольником у
main в IDE. Тест — треугольником у класса или
./mvnw test -Dtest=FunnelNumbersTest; весь
./mvnw verify гоняет и *IT — нужен запущенный
Docker. Путь к файлу — комментарием в первой строке.
// src/main/java/example/crm/OrdinalDemo.java
package example.crm;
public class OrdinalDemo {
/** Как было: пять статусов лида, как в учебной CRM клуба. */
enum Before { NEW, IN_PROGRESS, CONTACTED, BECAME_MEMBER, REJECTED }
/** Как стало: кто-то вставил NO_ANSWER («не дозвонились») в середину. */
enum After { NEW, IN_PROGRESS, NO_ANSWER, CONTACTED, BECAME_MEMBER, REJECTED }
/** Что прочтётся после правки, если в базе лежит номер (EnumType.ORDINAL). */
static After readByOrdinal(Before written) {
int stored = written.ordinal(); // так ORDINAL пишет в столбец
return After.values()[stored]; // так прочтёт тот же столбец после правки
}
/** Что прочтётся после правки, если в базе лежит имя (EnumType.STRING). */
static After readByName(Before written) {
String stored = written.name(); // так STRING пишет в столбец
return After.valueOf(stored); // ищет константу с таким же именем
}
public static void main(String[] args) {
System.out.printf("%-13s %-5s %-15s %s%n", "записано", "номер", "ORDINAL прочтёт", "STRING прочтёт");
for (Before written : Before.values()) { // values() — по порядку объявления
System.out.printf("%-13s %-5d %-15s %s%n",
written, written.ordinal(), readByOrdinal(written), readByName(written));
}
try { // старое имя конечного статуса, до переименования
After.valueOf("MEMBER");
} catch (IllegalArgumentException e) {
System.out.println("переименование при STRING: " + e.getMessage());
}
}
}// src/main/java/example/crm/FunnelNumbers.java
package example.crm;
import java.util.Map;
import java.util.Set;
/**
* Числа воронки: сколько лидов всего и сколько в каждом статусе.
* Ключи — имена статусов, как в ответе панели CRM.
*/
public record FunnelNumbers(long total, Map<String, Long> byStatus) {
public FunnelNumbers {
byStatus = Map.copyOf(byStatus); // своя копия: снаружи её не поменяют
long sum = 0;
for (long count : byStatus.values()) {
if (count < 0) {
throw new IllegalArgumentException("лидов в статусе не бывает меньше нуля: " + byStatus);
}
sum += count;
}
if (sum != total) {
throw new IllegalArgumentException("лидов по статусам " + sum + ", а всего " + total);
}
}
/** Сколько лидов в статусе; статус, которого нет в карте, — ноль. */
public long count(String status) {
return byStatus.getOrDefault(status, 0L);
}
/** Доля статуса среди ВСЕХ лидов, в процентах, до десятых. Так считает панель CRM. */
public double shareOfAll(String status) {
return percent(count(status), total);
}
/** Доля статуса среди лидов в статусах base — например, среди тех, чей исход известен. */
public double shareAmong(String status, Set<String> base) {
if (!base.contains(status)) {
throw new IllegalArgumentException(status + " не входит в базу " + base);
}
long whole = 0;
for (String s : base) {
whole += count(s);
}
return percent(count(status), whole);
}
private static double percent(long part, long whole) {
if (whole == 0) {
return 0.0; // делить не на что — как на панели CRM
}
double raw = (part * 100.0) / whole; // 100.0, а не 100: деление дробное
return Math.round(raw * 10.0) / 10.0; // до десятых, половина — вверх
}
}// src/test/java/example/crm/FunnelNumbersTest.java
package example.crm;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FunnelNumbersTest {
// десять лидов: трое стали клиентами, двое отказались,
// у пятерых исхода ещё нет — четверо новых, один в работе
private final FunnelNumbers september = new FunnelNumbers(10, Map.of(
"NEW", 4L,
"IN_PROGRESS", 1L,
"BECAME_MEMBER", 3L,
"REJECTED", 2L));
@Test
void shareOfAllCountsEveryLead() {
assertThat(september.shareOfAll("BECAME_MEMBER")).isEqualTo(30.0);
}
@Test
void shareAmongDecidedLeavesOutLeadsInProgress() {
Set<String> decided = Set.of("BECAME_MEMBER", "REJECTED");
assertThat(september.shareAmong("BECAME_MEMBER", decided)).isEqualTo(60.0);
}
@Test
void missingStatusCountsAsZero() {
assertThat(september.count("CONTACTED")).isZero();
assertThat(september.shareOfAll("CONTACTED")).isZero();
}
@ParameterizedTest(name = "{0} из {1} → {2}")
@CsvSource(delimiter = '|', textBlock = """
3 | 10 | 30.0
1 | 3 | 33.3
2 | 3 | 66.7
1 | 16 | 6.3
0 | 0 | 0.0
""")
void roundsToTenths(long members, long total, double expected) {
FunnelNumbers numbers = new FunnelNumbers(total, Map.of(
"BECAME_MEMBER", members,
"NEW", total - members));
assertThat(numbers.shareOfAll("BECAME_MEMBER")).isEqualTo(expected);
}
@Test
void nobodyDecidedYetIsZeroNotError() {
FunnelNumbers fresh = new FunnelNumbers(2, Map.of("NEW", 2L));
assertThat(fresh.shareAmong("BECAME_MEMBER", Set.of("BECAME_MEMBER", "REJECTED"))).isZero();
}
@Test
void rejectsCountsThatDoNotAddUp() {
assertThatThrownBy(() -> new FunnelNumbers(10, Map.of("NEW", 4L)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("всего 10");
}
@Test
void rejectsNegativeCount() {
assertThatThrownBy(() -> new FunnelNumbers(0, Map.of("NEW", 1L, "REJECTED", -1L)))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void rejectsStatusOutsideBase() {
assertThatThrownBy(() -> september.shareAmong("NEW", Set.of("REJECTED")))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void keepsOwnCopyOfCounts() {
Map<String, Long> counts = new HashMap<>(Map.of("NEW", 1L));
FunnelNumbers numbers = new FunnelNumbers(1, counts);
counts.put("NEW", 5L); // поменяли карту снаружи
assertThat(numbers.count("NEW")).isEqualTo(1);
}
}Порядок проверки — из папки песочницы:
./mvnw test -Dtest=FunnelNumbersTest— 13 запусков, все зелёные: восемь тестов и пять строк таблицы округления;./mvnw verify— зелёный вместе с тестами прошлых статей иTodayListTest, у сторожа статьи 11 по-прежнему 43 запуска: в папку журнала новые классы не попали;OrdinalDemoпечатает таблицу из части 3 — заголовок и пять старых статусов — и строку проMEMBER;- что будет, если:
part * 100вместоpart * 100.0вpercent— красные три строки таблицы, «1 из 3», «2 из 3» и «1 из 16»: вместо33.3,66.7и6.3пришли33.0,66.0и6.0;Math.floorвместоMath.round— красные «2 из 3» и «1 из 16»,66.6и6.2: половина больше не идёт вверх; без проверки суммы в конструкторе — красныйrejectsCountsThatDoNotAddUp; безMap.copyOf— красныйkeepsOwnCopyOfCounts; - 💡 без
if (whole == 0)вpercent— все 13 зелёные. Делениеdoubleна ноль в Java не бросает исключения:0.0 / 0— этоNaN(«не число»), аMath.round(NaN)—0. Проверка нужна не тесту, а читателю: ноль здесь — решение, а не случайность.