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

Оплата, о которой никто не сообщил

Статья 16 · читать минут 30

Для кого. Ты прочитал статьи 0–15, и у тебя есть песочница game-day после статьи 15. Особенно пригодятся бронь и задача менеджеру с двумя сроками (статья 12), опрос по расписанию с окном (статья 15) и Instant с TIMESTAMPTZ (статья 3а).

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

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


Зачем: оплата, которой не видно

Понедельник, 5 октября. Человек увидел на сайте клуба набор в группу «Утренняя йога», первое занятие — 7 октября. В 11:00 по Москве он забронировал место. Бронь держится сутки, до 11:00 вторника. В 11:15 CRM опрашивает систему клуба, видит новую бронь и заводит менеджеру задачу:

Бронь в группе «Утренняя йога» держится до 06.10 11:00 +03:00 — довести до оплаты. Цена 4 800 ₽, без скидок

Это та самая бронь из списка на сегодня в статье 12. Срок задачи у неё 05.10 11:15, срок брони — 06.10 11:00.

В 14:00 человек платит на сайте клуба. Кто скажет об этом CRM? Никто. Оплата проходит в системе клуба, а та ничего CRM не присылает. CRM спрашивает сама, по расписанию (статья 12).

Поля «оплачено» в ответе нет: оплаченная бронь просто перестаёт приходить. В 14:15 очередной опрос строки этого человека уже не находит.

Не заметит CRM пропажи — завтра утром менеджер позвонит и станет торопить человека, который давно заплатил. Хуже другое: после пары таких звонков менеджер перестанет верить своему списку. А список, которому не верят, хуже пустого (статья 12).

Поэтому задачи по брони CRM закрывает и сама. Бронь сгорела или место заняли снова — это правило 1. Бронь оплачена — правило 2.

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

Куски учебной CRM клуба из статей 12–15 — для чтения, выброшенное в них помечено // …. Свой код статьи — модель сверки на чистой Java в пакете example.crm: строка ответа HeldPlace, открытая задача OpenFollowUp, сама сверка PaidSweep и тест PaidSweepTest.

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


Часть 1. Бронь: сутки на разговор

Два вида задач — два разговора

Бронь (статья 12) придерживает место в группе до оплаты, но не дольше суток: дальше место освобождается само. Эти сутки — время менеджера: связаться с человеком и довести до оплаты.

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

💡 Имя второго вида выбирают один раз: виды задач лежат в базе, и смена имени — миграция данных (статьи 13 и 15). HOLD_EXPIRED называет событие «бронь сгорела», а не состояние — как BECAME_MEMBER в статье 13 называет покупку.

Раз в полчаса, а не ночью#

Продления CRM проверяет раз в сутки, ночью. С бронями так нельзя: бронь живёт сутки, и ночной обход увидел бы её уже сгоревшей (статьи 12 и 15). Поэтому опрос броней идёт по расписанию 0 15/30 * * * * из статьи 15 — в :15 и :45 каждого часа по Москве.

Что отдаёт система клуба#

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

// учебный ответ системы клуба: строка опроса броней
public record HoldRow(
        UUID holdId,           // номер брони — о нём в части 3
        UUID personId,         // кто
        UUID groupId,          // в какую группу
        String groupName,      // название группы
        long priceMinor,       // цена группы в копейках (статья 14)
        Instant heldUntil,     // до когда держится место
        boolean expired        // сгорела ли
) {
}

personId и groupId вместе — место: человек в группе. heldUntil приходит моментом в UTC, и в текст задачи его печатают по Москве, как в статье 15.

Окно опроса (тоже статья 15) — какие брони попадут в ответ. Система клуба отбирает их по сроку: heldUntil от «сейчас минус 48 часов» до «сейчас плюс 48 часов».

строка попадает в ответ?
живая бронь, сгорает в пределах окна да, expired: false
сгоревшая не раньше, чем 48 часов назад да, expired: true
сгоревшая раньше нет
оплаченная никогда

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


Часть 2. Пропажа — улика, а не доказательство

Четыре объяснения одной пропажи

Строки брони в ответе нет. Сама по себе пропажа значит одно из четырёх:

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

🔑 Вывод из отсутствия законен, только когда ты знаешь: строка обязана была быть — и её нет.

В учебной CRM этот вывод делает правило 2 — сверка closePaid. Её описание перечисляет условия, которые отсекают лишние объяснения:

// учебная служба броней: правило 2 — сокращено
/**
 * Правило 2. Оплаченная бронь из ответа просто пропадает, а пропажа сама по себе
 * неоднозначна. Три условия оставляют ей одно объяснение:
 * <ul>
 *   <li>сбой — сюда попадаем только после успешного опроса (см. опрос);</li>
 *   <li>выпала из окна — трогаем только срок внутри окна с запасом {@link #EDGE}:
 *       неоплаченная бронь с таким сроком обязана быть в ответе, живой или сгоревшей;</li>
 *   <li>ответ старше задачи — только задачи, заведённые до {@code askedAt}.</li>
 * </ul>
 * Ни одно условие не ослаблять без теста на него.
 */
public void closePaid(List<HoldRow> rows, Instant askedAt, int windowHours) {
    Set<Place> inAnswer = rows.stream()
            .map(row -> new Place(row.personId(), row.groupId()))
            .collect(Collectors.toSet());
    Duration window = Duration.ofHours(windowHours);
    Instant from = askedAt.minus(window).plus(EDGE);
    Instant to = askedAt.plus(window).minus(EDGE);
    for (Task task : tasks.findPaidCandidates(HOLD_KINDS, from, to, askedAt)) {
        if (!inAnswer.contains(new Place(task.getPersonId(), task.getGroupId()))) {
            close(task, "брони нет в ответе системы клуба — оплачена");
        }
    }
}

/** Место — человек в группе; сроком не различается: срок сверяет правило 1. */
private record Place(UUID personId, UUID groupId) {
}

Как читать:

Сверка закрывает задачи обоих видов: «место ушло» у заплатившего — такая же неправда, как «довести до оплаты». Теперь — три условия по очереди.

Условие 1. Только после ответа целиком

Сверку зовёт опрос:

// учебный опрос броней — сокращено
@Scheduled(cron = "${club.sales.holds.poll-cron:0 15/30 * * * *}",
        zone = "${club.sales.holds.poll-zone:Europe/Moscow}")
public void pollHolds() {
    int hours = props.getPollWindowHours();
    Instant askedAt = clock.instant();                  // «сейчас» — ДО запроса: это для условия 3
    List<HoldRow> rows;
    try {
        rows = club.holdsWithin(hours);                 // спросить систему клуба
    } catch (Exception e) {
        logger.error("Опрос броней не удался — следующий через полчаса", e);
        return;                                         // сверки не будет
    }
    // … каждую строку — в holds.track(row): правило 1, потом новая задача
    try {
        holds.closePaid(rows, askedAt, hours);
    } catch (Exception e) {
        logger.error("Сверка оплаченных броней не удалась — следующая через полчаса", e);
    }
}

В опросе: настройки броней props из статьи 15, клиент системы клуба club, служба броней со сверкой holds и часы бином clock (статья 1).

Главная строка здесь — return в первом catch. Система клуба не ответила — опрос пишет в лог и выходит, до сверки дело не доходит.

Представь, что кто-то «упростил» (так в учебной CRM не написано):

} catch (Exception e) {
    rows = List.of();                     // «система клуба молчит — значит, броней нет»
}

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

🔴 Нет ответа и пустой ответ — разные вещи. Пустой список говорит «броней нет». Сбой говорит «не знаю».

Это правило держит тест опроса с заглушками Mockito (статьи 4–5):

// учебный тест опроса броней — сокращено
when(club.holdsWithin(48)).thenThrow(new IllegalStateException("система клуба не ответила"));

assertThatCode(poll::pollHolds).doesNotThrowAnyException();

verifyNoInteractions(holds);              // к службе броней опрос даже не обратился

poll — опрос, собранный в тесте на заглушках club и holds. thenThrow ты видел в статье 4, а assertThatCode(…).doesNotThrowAnyException() — в статье 15.

Условие 2. Срок внутри окна — с запасом

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

Кандидатов отбирает запрос к базе — @Query из статьи 3б:

// учебное хранилище задач — сокращено
@Query("""
        select t from Task t
        where t.kind in :kinds and t.doneAt is null
          and t.untilAt between :from and :to
          and t.createdAt < :createdBefore
        """)
List<Task> findPaidCandidates(@Param("kinds") Collection<String> kinds,
                              @Param("from") Instant from,
                              @Param("to") Instant to,
                              @Param("createdBefore") Instant createdBefore);

Только окно здесь не 48 часов: closePaid сужает его на час с каждой стороны. Это запас — полоса у краёв, где пропажу не трактуют никак:

// учебная служба броней
/**
 * Правило 2: задачи со сроком ближе EDGE к краю окна не трогаются. Система клуба отбирает окно
 * от СВОЕГО «сейчас», а оно позже askedAt на время запроса и может разойтись с нашими часами;
 * у края окна пропажа значит «выпала из окна», а не «оплачена».
 *
 * <p>Живым броням запас ничего не стоит: их срок — в середине окна. Сгоревшим стоит: оплаченная
 * позже чем через N − 1 ч после сгорания оставит «место ушло» открытой, и менеджер закроет её
 * сам. Ошибка в дешёвую сторону: ошибочное закрытие необратимо, лишнее открытое — нет.
 */
static final Duration EDGE = Duration.ofHours(1);

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

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

Это и есть решение: не «какое число поставить», а «что теряем и почему согласны».

Возьмём сверку 05.10 в 14:15 по Москве, первую после оплаты из начала статьи. Окно — 48 часов:

срок брони относительно askedAt что значит пропажа правило 2
срок < −48 ч строки не было бы и без оплаты не трогать
−48 ч ≤ срок < −47 ч могла выпасть по часам не трогать
−47 ч ≤ срок ≤ +47 ч неоплаченная бронь была бы в ответе закрыть
+47 ч < срок ≤ +48 ч могла выпасть по часам не трогать

По часам: закрыть можно задачу со сроком брони от 03.10 15:15 до 07.10 13:15 по Москве, края включительно. Бронь из начала статьи держится до 06.10 11:00, это внутри: её задачу сверка закроет.

Условие 3. Задача старше запроса

t.createdAt < :createdBefore: createdAt — когда задачу завели, а туда передают askedAt. Сверка трогает только задачи, заведённые раньше, чем ушёл запрос, — это отсекает «ответ старше задачи».

Такие задачи появляются, когда CRM запущена в двух экземплярах: задачу мог завести второй — по ответу, которого у первого нет.

Ошибаться — в дешёвую сторону#

🔴 Ошибочное закрытие необратимо. У таблицы задач учебной CRM есть ключ — вид, человек, группа и срок брони: ограничение UNIQUE, родственник первичного ключа из статьи 3а. Две строки с одинаковым ключом таблица не пустит, и закрытые строки тоже в счёт. Следующий опрос увидит бронь, попробует завести задачу снова — а база её не пустит.

Для менеджера итог один: человек, который не заплатил, пропал из списка, пока его место ещё держится, — и никто этого не заметит.

Лишняя открытая задача стоит одного неловкого звонка: «я уже оплатил» — «извините». Задачу менеджер закроет сам.

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

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

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

Три вопроса здесь не разбираем: почему два экземпляра CRM мешают друг другу, как поймать дубль ключа на вставке и почему метод, который заводит задачи, при этом обходится без @Transactional. Два последних — об устройстве транзакций. В рабочем проекте все три разбирает мидл.


Часть 3. Сроки, тексты, адреса#

Правило 1: строка системы клуба — последнее слово о месте

Есть и другая дорога: человек не успел, бронь сгорела, он вернулся и забронировал место снова. По человеку и группе система клуба отдаёт одну строку: последнюю бронь, с новым сроком. Строки «сгорела» по старому сроку в ответе может уже не быть. У менеджера две задачи на одно место, и одна из них уже неправда. Её закрывает правило 1 — на каждой строке ответа, до того как служба заведёт новую задачу:

// учебная служба броней: правило 1 — сокращено
private void closeOutdated(HoldRow row) {
    for (Task task : tasks.findByPersonIdAndGroupIdAndKindInAndDoneAtIsNull(
            row.personId(), row.groupId(), HOLD_KINDS)) {
        if (isEarlier(task.getUntilAt(), row.heldUntil())) {
            close(task, "место занято снова, задача по прежнему сроку устарела");
        } else if (row.expired() && HOLD.equals(task.getKind())
                && !isEarlier(row.heldUntil(), task.getUntilAt())) {
            close(task, "бронь сгорела");
        }
    }
}

// …

private static boolean isEarlier(Instant a, Instant b) {
    return a.plus(SAME_TERM).isBefore(b);
}

Хранилище находит открытые задачи этого места — метод по имени (статья 3б): человек, группа, вид из набора, не закрыта. Срок задачи раньше срока в строке — задача устарела. Строка сгоревшая, а живая задача HOLD того же срока ещё открыта — её закрывают с причиной «бронь сгорела». Следом служба заведёт «место ушло».

Миллисекунда допуска#

Почему isEarlier прибавляет SAME_TERM, а не сравнивает строго:

// учебная служба броней
/**
 * Сроки, отличающиеся меньше чем на это, — один срок. timestamptz хранит микросекунды,
 * а Java может дать больше знаков: строгое сравнение сочло бы только что заведённую задачу
 * «прежней» и закрыло бы её. Два настоящих срока одного места различаются минимум
 * на минуты — миллисекунда их не спутает.
 */
private static final Duration SAME_TERM = Duration.ofMillis(1);

Проверим обе половины фразы.

Java. Instant хранит время до наносекунды — девять знаков после точки. Сколько из них настоящих, решают системные часы. На Linux начиная с JDK 15 Clock.systemUTC() и Instant.now() читают их с наносекундами (заявка JDK-8242504 «Enhance the system clock to nanosecond precision»). В JDK 11, например, читали ещё с микросекундами. Прогон на JDK 21, Linux:

Instant.now() = 2026-10-21T20:11:41.438092947Z

Из 100 000 замеров Clock.systemUTC().instant() подряд в 99 910 последние три из девяти знаков — не нули.

Postgres. TIMESTAMPTZ (статья 3а) хранит время до микросекунды — шесть знаков: так в документации, в таблице типов времени. Лишние знаки при записи округляются. Проверь в psql — в Postgres на минуту из статьи 3а:

SET TIME ZONE 'UTC';
SELECT '2026-10-06 08:00:00.123456789+00'::timestamptz;   -- 2026-10-06 08:00:00.123457+00
SELECT '2026-10-06 08:00:00.123456289+00'::timestamptz;   -- 2026-10-06 08:00:00.123456+00

SET TIME ZONE 'UTC' ты видел в статье 3а, а ::timestamptz значит «прочитай эту строку как TIMESTAMPTZ». Первый срок округлился вверх, второй — вниз: в столбце остаётся столько знаков, сколько он хранит.

Теперь вместе. Пусть система клуба тоже написана на Java и отдаёт срок брони со всеми знаками, какие дали её часы. Сколько знаков будет в сроке, никто не обещал. Задачу завели, срок лёг в базу уже с шестью. Следующий опрос сравнивает срок из базы со сроком из ответа:

Instant fromClub = Instant.parse("2026-10-06T08:00:00.123456289Z");   // девять знаков — как дала Java
Instant fromBase = Instant.parse("2026-10-06T08:00:00.123456Z");      // шесть — как вернул Postgres
fromBase.isBefore(fromClub);                                          // true: «раньше» на 289 нс
fromBase.plus(Duration.ofMillis(1)).isBefore(fromClub);               // false: с допуском — один срок

Строгое сравнение сочло бы задачу «прежней». Правило 1 закрыло бы её как «место занято снова» — ту самую, которую завели полчаса назад. А закрытие необратимо.

Округлись срок вверх, и .123456789 лёг бы в базу как .123457. Тогда не сработала бы вторая ветка: при сгорании живая задача осталась бы открытой рядом с «место ушло». Допуск в миллисекунду гасит обе беды. Худшая ошибка округления — полмикросекунды: допуск больше её в 2 000 раз и в шестьдесят тысяч раз меньше минуты.

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

⚠️ Время, побывавшее в базе, — уже не то время, что дала Java. Сравниваешь одно с другим — сравнивай с допуском.

💡 Запусти у себя System.out.println(Instant.now()) — сколько знаков у тебя?

Текст задачи пишется один раз

// учебная служба броней: текст задачи «довести до оплаты»
/**
 * Срок — абсолютным временем, без «осталось N ч»: текст записывается один раз, и через час
 * «осталось 5 ч» уже неправда. Цена — группы, без скидок: скидки считают от тарифа, а в строке
 * брони тарифа нет, и менеджер не должен назвать эту сумму окончательной.
 */
private String holdNote(HoldRow row) {
    return "Бронь в группе «%s» держится до %s — довести до оплаты. Цена %s, без скидок"
            .formatted(row.groupName(), moment(row.heldUntil()), Rubles.format(row.priceMinor()));
}

Две мысли. Текст задачи — снимок: его записали в 11:15 и больше не пересчитывают. «Осталось 23 ч» к вечеру стало бы враньём, а «держится до 06.10 11:00 +03:00» верно всегда. Время — по Москве и с поясом в строке: без пересчёта менеджер прочёл бы «до восьми утра» (статья 15).

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

Храним адрес, а не копию#

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

-- учебная CRM клуба: миграция — номер брони у задачи
-- Хранится ради ссылки на оплату: её строят по номеру брони, а бронь, сгоревшую больше
-- окна опроса назад, система клуба уже не отдаст — взять номер было бы негде.
ALTER TABLE task
    ADD COLUMN hold_id UUID;

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

А через двое суток после сгорания номер спросить будет негде: строку брони система клуба уже не отдаст, а отдельного вопроса «брони этого человека» у неё нет. Храни то, что потом негде будет взять, — пока видишь строку.


Часть 4. Правило 2 своими руками

В учебной CRM правило 2 разнесено по трём местам. Условие 1 проверяет опрос, условия 2 и 3 — запрос к базе, саму пропажу — цикл в closePaid. Соберём его в одном методе на чистой Java, без Spring и базы. Тогда его можно проверить таблицей.

Три класса. HeldPlace — строка ответа: кто, в какую группу, до когда. OpenFollowUp — открытая задача: номер, кто, группа, срок брони, когда заведена. У FollowUp из статьи 12 нет ни группы, ни момента, когда задачу завели. Сверке нужны оба, поэтому у неё своя запись. PaidSweep — сама сверка:

    public List<OpenFollowUp> paid(List<HeldPlace> answer, Instant askedAt, List<OpenFollowUp> open) {
        Set<Place> inAnswer = answer.stream()                               // места из ответа
                .map(row -> new Place(row.personId(), row.groupId()))
                .collect(Collectors.toSet());
        Instant from = askedAt.minus(window).plus(EDGE);                    // окно, суженное запасом
        Instant to = askedAt.plus(window).minus(EDGE);
        return open.stream()
                // срок внутри окна с запасом, края включительно
                .filter(task -> !task.untilAt().isBefore(from) && !task.untilAt().isAfter(to))
                // задача заведена до запроса
                .filter(task -> task.createdAt().isBefore(askedAt))
                // места нет в ответе
                .filter(task -> !inAnswer.contains(new Place(task.personId(), task.groupId())))
                .toList();
    }

Place и EDGE — как в учебной CRM; window — окно, его проверяет конструктор: от суток до двух, как границы окна в настройках броней из статьи 15. Время в модели — Instant: нужны только моменты. «Сейчас» (askedAt) приходит параметром, и тест задаёт его сам — как остановленные часы Clock.fixed в статье 1.

!task.untilAt().isBefore(from) — «не раньше начала», то есть начало или позже: так на Java пишут «край включительно», как between в запросе. Условие 1 в модели — договорённость: paid зовут только с ответом, который пришёл целиком.

Главный тест — таблица из части 2, по строке на край. ASKED_AT — 05.10 14:15 по Москве, сверка из начала статьи; CREATED — когда задачу завели, 11:15 того же дня; task(…) — помощник, который собирает задачу:

    @ParameterizedTest(name = "срок {0} мин от запроса → закрыть: {1}")
    @CsvSource(delimiter = '|', textBlock = """
            -2940 | false
            -2850 | false
            -2820 | true
             -600 | true
             1245 | true
             2820 | true
             2850 | false
            """)
    void closesOnlyInsideWindowWithMargin(long minutes, boolean closed) {
        OpenFollowUp task = task(1, PAYER, ASKED_AT.plus(Duration.ofMinutes(minutes)), CREATED);

        assertThat(sweep.paid(List.of(), ASKED_AT, List.of(task))).hasSize(closed ? 1 : 0);
    }

−2940 минут — 49 часов назад, за окном; −2850 — внутри окна, но в полосе запаса; −2820 — ровно −47 часов, край запаса, и он включительно. −600 — бронь, сгоревшая 10 часов назад. 1245 — 20 часов 45 минут вперёд, 06.10 11:00 по Москве: бронь из начала статьи. hasSize и выбор closed ? 1 : 0 ты видел в статьях 11 и 14.

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

Попробуй убрать из paid любое условие: запас, «до запроса», «нет в ответе», края включительно. Каждый раз покраснеет свой тест. Тест, который не умеет краснеть, ничего не сторожит (статья 11).


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

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

  1. Почему у брони два вида задач, а не один с пометкой «сгорела»?
  2. Система клуба не ответила. Почему опрос выходит, не дойдя до сверки, — ведь строк в руках ноль, как и в пустом ответе? Что сломалось бы, если сбой превратить в пустой список?
  3. Что случилось бы с задачами «место ушло» без условия «срок внутри окна»?
  4. Окно и так известно — зачем запас в час от краёв? Кто платит за этот запас и чем?
  5. Почему ошибочно закрытая задача хуже лишней открытой? Что не даст завести её снова?
  6. Java отдала срок 08:00:00.123456289Z. Что вернёт Postgres и чем это опасно для строгого isBefore? Почему миллисекунда допуска не спутает два настоящих срока?
  7. Почему в тексте задачи «держится до 06.10 11:00 +03:00», а не «осталось 23 ч»? Что значат слова «без скидок»?
  8. CRM не хранит копий того, что знает система клуба. Почему номер брони — исключение, и исключение ли?

Что дальше#

Дальше в серии — ещё одна история об опросах по расписанию. Задания CRM ходят в систему клуба сами, без человека. С каким пропуском? В следующей статье ночной опрос получил бы 401 — и ни стенд, ни тесты этого бы не заметили.

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


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

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

Что добавить: в src/main/java/example/crm/ — HeldPlace (строка ответа о брони), OpenFollowUp (открытая задача по брони) и PaidSweep (сверка), в src/test/java/example/crm/ — PaidSweepTest. Пакет example.crm — рядом с example.gameday, не внутри: сканирование Spring его не видит, а сторож статьи 11 смотрит только в папку журнала. Куски учебной CRM — для чтения, в песочницу их не клади: HoldRow, опрос, сверка с @Query, правило 1, текст задачи, миграция. SQL с округлением — для psql в Postgres на минуту, а не миграция журнала.

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

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

import java.time.Instant;
import java.util.UUID;

/** Строка ответа системы клуба о брони: кто, в какую группу, до когда держится место. Учебная модель. */
public record HeldPlace(UUID personId, UUID groupId, Instant heldUntil) {
}
// src/main/java/example/crm/OpenFollowUp.java
package example.crm;

import java.time.Instant;
import java.util.UUID;

/** Открытая задача по брони: номер, о ком, какая группа, срок брони, когда заведена. Учебная модель. */
public record OpenFollowUp(long id, UUID personId, UUID groupId, Instant untilAt, Instant createdAt) {
}
// src/main/java/example/crm/PaidSweep.java
package example.crm;

import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;

/** Какие задачи по броням закрыть как оплаченные после опроса системы клуба. Учебная модель. */
public final class PaidSweep {

    /** Запас от краёв окна: «сейчас» системы клуба позже нашего, и часы могут разойтись. */
    static final Duration EDGE = Duration.ofHours(1);

    private final Duration window;

    public PaidSweep(Duration window) {
        if (window.compareTo(Duration.ofHours(24)) < 0 || window.compareTo(Duration.ofHours(48)) > 0) {
            throw new IllegalArgumentException("окно — от суток до двух, а пришло " + window);
        }
        this.window = window;
    }

    /**
     * Звать только с ответом, который пришёл целиком: сбой — не пустой ответ.
     *
     * @param answer  весь ответ системы клуба на этот опрос
     * @param askedAt момент, взятый ДО запроса
     * @param open    открытые задачи по броням
     */
    public List<OpenFollowUp> paid(List<HeldPlace> answer, Instant askedAt, List<OpenFollowUp> open) {
        Set<Place> inAnswer = answer.stream()                               // места из ответа
                .map(row -> new Place(row.personId(), row.groupId()))
                .collect(Collectors.toSet());
        Instant from = askedAt.minus(window).plus(EDGE);                    // окно, суженное запасом
        Instant to = askedAt.plus(window).minus(EDGE);
        return open.stream()
                // срок внутри окна с запасом, края включительно
                .filter(task -> !task.untilAt().isBefore(from) && !task.untilAt().isAfter(to))
                // задача заведена до запроса
                .filter(task -> task.createdAt().isBefore(askedAt))
                // места нет в ответе
                .filter(task -> !inAnswer.contains(new Place(task.personId(), task.groupId())))
                .toList();
    }

    /** Место — человек в группе; сроком не различается. */
    private record Place(UUID personId, UUID groupId) {
    }
}
// src/test/java/example/crm/PaidSweepTest.java
package example.crm;

import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.UUID;

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 PaidSweepTest {

    // Сверка 5 октября 2026 в 14:15 по Москве; задачу завёл опрос в 11:15; бронь — до 6 октября, 11:00.
    private static final Instant ASKED_AT = Instant.parse("2026-10-05T11:15:00Z");
    private static final Instant CREATED = Instant.parse("2026-10-05T08:15:00Z");
    private static final Instant HELD_UNTIL = Instant.parse("2026-10-06T08:00:00Z");
    private static final UUID PAYER = UUID.fromString("00000000-0000-4000-8000-000000000001");
    private static final UUID WAITER = UUID.fromString("00000000-0000-4000-8000-000000000002");
    private static final UUID GROUP = UUID.fromString("00000000-0000-4000-8000-000000000100");

    private final PaidSweep sweep = new PaidSweep(Duration.ofHours(48));

    private static OpenFollowUp task(long id, UUID personId, Instant untilAt, Instant createdAt) {
        return new OpenFollowUp(id, personId, GROUP, untilAt, createdAt);
    }

    @Test
    void closesTaskWhosePlaceIsMissingFromAnswer() {
        OpenFollowUp payer = task(1, PAYER, HELD_UNTIL, CREATED);

        assertThat(sweep.paid(List.of(), ASKED_AT, List.of(payer))).containsExactly(payer);
    }

    @Test
    void keepsTaskWhosePlaceIsInAnswer() {
        OpenFollowUp payer = task(1, PAYER, HELD_UNTIL, CREATED);
        OpenFollowUp waiter = task(2, WAITER, HELD_UNTIL, CREATED);
        HeldPlace stillHeld = new HeldPlace(WAITER, GROUP, HELD_UNTIL);

        assertThat(sweep.paid(List.of(stillHeld), ASKED_AT, List.of(payer, waiter)))
                .containsExactly(payer);
    }

    @ParameterizedTest(name = "срок {0} мин от запроса → закрыть: {1}")
    @CsvSource(delimiter = '|', textBlock = """
            -2940 | false
            -2850 | false
            -2820 | true
             -600 | true
             1245 | true
             2820 | true
             2850 | false
            """)
    void closesOnlyInsideWindowWithMargin(long minutes, boolean closed) {
        OpenFollowUp task = task(1, PAYER, ASKED_AT.plus(Duration.ofMinutes(minutes)), CREATED);

        assertThat(sweep.paid(List.of(), ASKED_AT, List.of(task))).hasSize(closed ? 1 : 0);
    }

    @ParameterizedTest(name = "заведена через {0} с после запроса — не трогать")
    @ValueSource(longs = {0, 2})
    void keepsTaskCreatedNotBeforeAsk(long seconds) {
        OpenFollowUp young = task(1, PAYER, HELD_UNTIL, ASKED_AT.plusSeconds(seconds));

        assertThat(sweep.paid(List.of(), ASKED_AT, List.of(young))).isEmpty();
    }

    @ParameterizedTest(name = "окно {0} ч — ошибка")
    @ValueSource(longs = {23, 49})
    void rejectsWindowOutsideDayToTwoDays(long hours) {
        assertThatThrownBy(() -> new PaidSweep(Duration.ofHours(hours)))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("от суток до двух");
    }
}

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

  1. ./mvnw test -Dtest=PaidSweepTest — 13 запусков, все зелёные: два теста, семь строк таблицы, две строки «заведена в момент запроса или позже» и две строки «окно — ошибка»; ./mvnw verify — зелёный вместе с тестами прошлых статей — TodayListTest, FunnelNumbersTest, CheckoutTest, RublesTest и ReminderPointTest, у сторожа статьи 11 по-прежнему 43 запуска: в папку журнала новые классы не попали, миграций не прибавилось;
  2. что будет, если: без фильтра «места нет в ответе» — красный keepsTaskWhosePlaceIsInAnswer, закрыта и задача того, чьё место держится; без фильтра окна — красные строки -2940, -2850 и 2850; EDGE = Duration.ZERO — красные -2850 и 2850: запаса нет; края строго, isAfter(from) && isBefore(to), — красные -2820 и 2820; без фильтра «до запроса» — красные обе строки keepsTaskCreatedNotBeforeAsk; !isAfter(askedAt) вместо isBefore(askedAt) — красная только строка «через 0 с»: задача, заведённая в момент запроса, уже не моложе; без проверки окна в конструкторе — красные обе строки «окно — ошибка»;
  3. 💡 условие 1 на модели: paid(List.of(), …) — пустой ответ вместо сбоя — закрывает все задачи в окне; поэтому звать его после сбоя нельзя;
  4. SQL из части 3 — в psql, в Postgres на минуту из статьи 3а: .123456789 вернётся как .123457, .123456289 — как .123456.

← Все статьи

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

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