Оплата, о которой никто не сообщил
Для кого. Ты прочитал статьи 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. Пропажа — улика, а не доказательство
Четыре объяснения одной пропажи
Строки брони в ответе нет. Сама по себе пропажа значит одно из четырёх:
- Оплатил — то, что хотим узнать. Оплатить можно и сгоревшую бронь: пока в группе есть свободное место, система клуба примет оплату и после срока.
- Выпала из окна — бронь сгорела больше 48 часов назад, и её строка перестала приходить сама.
- Сбой — система клуба не ответила или ответила ошибкой: строк нет, потому что нет ответа.
- Ответ старше задачи — задачу завели уже после того, как ушёл запрос: о ней этот ответ знать не мог.
Так проверяют банковскую выписку: перевода в ней нет — значит, его не было? Только при трёх условиях. Выписка скачалась целиком. Перевод должен был попасть именно в её период, а не на соседний день. И сделан он до того, как выписку скачали.
🔑 Вывод из отсутствия законен, только когда ты знаешь: строка обязана была быть — и её нет.
В учебной 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) {
}Как читать:
<ul>и<li>— список в javadoc, прочую разметку ты видел в статье 12;Task— задача в учебной CRM с базой, сущность (статья 3б). У неё те же два срока, что уFollowUp, плюс человек, группа, когда задачу завели и когда закрыли. Её хранилище —tasks;HOLD_KINDS— оба вида задач по брони,closeставит задаче время закрытия и пишет причину в лог;Place— запись «человек в группе». ДвеPlaceс одинаковыми полями равны (статья 8). Поэтому наборSetнаходит место задачи среди мест ответа;collect(Collectors.toSet())складывает итогstream()в набор, какtoList()— в список.
Сверка закрывает задачи обоих видов: «место ушло» у заплатившего — такая же неправда, как «довести до оплаты». Теперь — три условия по очереди.
Условие 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);between :from and :to— от и до, оба края включительно;:from— параметр по имени. В статье 3б был номер,?1, а здесь@Param("from")связывает имя в запросе с параметром метода;in :kinds— вид задачи из набора, у нас это оба вида задач по брони.
Только окно здесь не 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+00SET 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()));
}formatted(…)и места%s— из статьи 5;moment(…)— срок по Москве с поясом, как в статье 15:06.10 11:00 +03:00;Rubles.formatиз статьи 14 превращает480_000копеек изpriceMinorв «4 800 ₽».
Две мысли. Текст задачи — снимок: его записали в 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).
Проверь себя#
Ответь своими словами — вслух или на бумаге. Не получается — перечитай раздел.
- Почему у брони два вида задач, а не один с пометкой «сгорела»?
- Система клуба не ответила. Почему опрос выходит, не дойдя до сверки, — ведь строк в руках ноль, как и в пустом ответе? Что сломалось бы, если сбой превратить в пустой список?
- Что случилось бы с задачами «место ушло» без условия «срок внутри окна»?
- Окно и так известно — зачем запас в час от краёв? Кто платит за этот запас и чем?
- Почему ошибочно закрытая задача хуже лишней открытой? Что не даст завести её снова?
- Java отдала срок
08:00:00.123456289Z. Что вернёт Postgres и чем это опасно для строгогоisBefore? Почему миллисекунда допуска не спутает два настоящих срока? - Почему в тексте задачи «держится до 06.10 11:00 +03:00», а не «осталось 23 ч»? Что значат слова «без скидок»?
- CRM не хранит копий того, что знает система клуба. Почему номер брони — исключение, и исключение ли?
Что дальше#
Дальше в серии — ещё одна история об опросах по расписанию. Задания
CRM ходят в систему клуба сами, без человека. С каким пропуском? В
следующей статье ночной опрос получил бы 401 — и ни стенд,
ни тесты этого бы не заметили.
Первоисточники#
- PostgreSQL:
Date/Time Types — таблица типов времени, столбец Resolution: у
timestamp with time zone— 1 микросекунда; - JDK-8242504: Enhance the system clock to nanosecond precision — когда системные часы Java на Linux стали отдавать наносекунды;
- Java
SE 25: Clock —
systemUTC(): «the best available system clock»; - Java
SE 25: Instant — момент с точностью до наносекунды,
isBefore,plus,minus.
Пример целиком — с импортами#
Учебный пример. Запускай в песочнице 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("от суток до двух");
}
}Порядок проверки — из папки песочницы:
./mvnw test -Dtest=PaidSweepTest— 13 запусков, все зелёные: два теста, семь строк таблицы, две строки «заведена в момент запроса или позже» и две строки «окно — ошибка»;./mvnw verify— зелёный вместе с тестами прошлых статей —TodayListTest,FunnelNumbersTest,CheckoutTest,RublesTestиReminderPointTest, у сторожа статьи 11 по-прежнему 43 запуска: в папку журнала новые классы не попали, миграций не прибавилось;- что будет, если: без фильтра «места нет в ответе» — красный
keepsTaskWhosePlaceIsInAnswer, закрыта и задача того, чьё место держится; без фильтра окна — красные строки-2940,-2850и2850;EDGE = Duration.ZERO— красные-2850и2850: запаса нет; края строго,isAfter(from) && isBefore(to), — красные-2820и2820; без фильтра «до запроса» — красные обе строкиkeepsTaskCreatedNotBeforeAsk;!isAfter(askedAt)вместоisBefore(askedAt)— красная только строка «через 0 с»: задача, заведённая в момент запроса, уже не моложе; без проверки окна в конструкторе — красные обе строки «окно — ошибка»; - 💡 условие 1 на модели:
paid(List.of(), …)— пустой ответ вместо сбоя — закрывает все задачи в окне; поэтому звать его после сбоя нельзя; - SQL из части 3 — в
psql, в Postgres на минуту из статьи 3а:.123456789вернётся как.123457,.123456289— как.123456.