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

Статусы лида: какие ставит человек, а какой — только система

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

Для кого. Ты прочитал статьи 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 то же имя живёт:

Переименуешь константу — придётся менять всё это разом, а в базе нужна миграция данных.

Зачем вообще переименовывать? Представь: в первой версии конечный статус назвали 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 лидов. Двадцать пришли вчера, с ними ещё никто не говорил. Тридцать отказались. Десять стали клиентами. Сорок в работе.

Оба числа честные — и отвечают на разные вопросы. Первое — «какая доля всех обращений доходит до покупки». Второе — «как часто законченный разговор кончается „да“». Вчерашние двадцать тянут первое вниз, хотя ничего ещё не решено. А лид, забытый в 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 повторяет решение панели; захочешь их различать — нужен ответ «нет данных», а не число.


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

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

  1. Почему в форме создания лида нет поля статуса? Что могло бы случиться, будь оно?
  2. Чем BECAME_MEMBER отличается от остальных статусов? Объясни, почему ручной ввод такого факта — источник неправды.
  3. Менеджер по ошибке поставил REJECTED. Что он может сделать и почему правил переходов нет?
  4. Как служба получает оповещатель — настоящий или пустой? Что она получит, если настройки нет вовсе?
  5. В перечисление статусов вставили константу в середину. Что станет со старыми строками при ORDINAL и при STRING? Почему случай с отказавшимися — самый неприятный?
  6. Где, кроме базы, живёт имя статуса? Что придётся сделать, чтобы его переименовать, и чем BECAME_MEMBER лучше MEMBER?
  7. 100 лидов: 20 новых, 30 отказались, 10 стали клиентами, 40 в работе. Посчитай конверсию двумя способами. На какой вопрос отвечает каждое число?
  8. Почему в формуле стоит 100.0, а не 100? Что покажет панель для одного клиента из 16 лидов и почему?

Что дальше#

Дальше в серии — следующая остановка на дороге: лид дошёл до разговора о цене. Сколько он заплатит, почему деньги не хранят в double и в какую сторону округлять.

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


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

Учебный пример. Запускай в песочнице 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);
    }
}

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

  1. ./mvnw test -Dtest=FunnelNumbersTest — 13 запусков, все зелёные: восемь тестов и пять строк таблицы округления; ./mvnw verify — зелёный вместе с тестами прошлых статей и TodayListTest, у сторожа статьи 11 по-прежнему 43 запуска: в папку журнала новые классы не попали;
  2. OrdinalDemo печатает таблицу из части 3 — заголовок и пять старых статусов — и строку про MEMBER;
  3. что будет, если: 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;
  4. 💡 без if (whole == 0) в percent — все 13 зелёные. Деление double на ноль в Java не бросает исключения: 0.0 / 0 — это NaN («не число»), а Math.round(NaN) — 0. Проверка нужна не тесту, а читателю: ноль здесь — решение, а не случайность.

← Все статьи

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

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