feat: окно часов обзвона — общая рамка и своё окно клиента, З-3.1

Правило часов остаётся ОДНО и живёт в боевом SmsQuietHours — решение
владельца Р76. Обзвон получает не копию правила, а тонкую обёртку
ObzvonOknoChasov со своими числами: Р76 отдал в общее пользование
ПРАВИЛО, а не ГРАНИЦЫ. Админ, поправив окно рассылок, окно обзвона
не двигает.

Два окна живут в разных часах, и рамка — их пересечение, Р115. Часы
менеджера приходят вместе со СВОИМ смещением, LocalHoursWindow, и
меряются им, а не смещением получателя. Иначе московские 14:00-18:00
были бы прочитаны как владивостокские, робот перевёл бы звонок на
спящего менеджера, а на экране и в журнале всё бы сошлось.

Окно клиента только сужает рамку, Р64: 09:00-22:00 принимается как
10:00-20:00. Часы целиком вне рамки и непересекающиеся из-за поясов
окна дают пустой ответ «звонков не будет» — это ответ, а не молчание.

Входящие окном не ограничены, Р35: спросить обёртку про приём звонка
физически нечем, и на это стоит сторож.

Живая рассылка СМС не изменилась ни на один час — доказано вычитанием
против старого правила из git HEAD: 144 816 сверок на 13 поясах, всех
1440 минутах суток и 7 наборах границ, расхождений 0. Прибор показан
красным: подложенная поломка в один знак дала 6049 расхождений.

Подпись canSendNow не менялась — новое условие вошло третьим
необязательным доводом с умолчанием. nextWindowOpensAt, earliestOpening
и isValidWindow не тронуты вовсе. Границы читаются один раз на объект:
20 000 номеров — ровно один запрос к базе.

Сторожа показаны красными пятью врезами; дословные сообщения, разбор
задания и незакрытые хвосты — в отчёте
docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-z-3-1-okno-chasov-2026-08-07.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-08-07 05:58:59 +03:00
parent f409f8746b
commit 5dfe32dc91
6 changed files with 1267 additions and 5 deletions
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Services\ClientSms;
use InvalidArgumentException;
/**
* Окно часов, названное В СВОИХ ЧАСАХ какого-то одного места (задача З-3.1, Р115).
*
* 🔴 ЗАЧЕМ ЭТО ВООБЩЕ ЕСТЬ. Правило окна {@see SmsQuietHours} до сих пор знало
* РОВНО ОДНО смещение смещение получателя. Часы менеджера клиента живут в
* ДРУГИХ часах: клиент называет их у себя, а получателя мы будим у него. Пока
* смещение было одно, «добавить часы менеджера» означало бы сузить окно
* получателя и менеджер из Москвы, работающий 14:0018:00, был бы прочитан как
* «14:0018:00 по Владивостоку». Робот перевёл бы звонок на спящего человека, а
* на экране и в журнале всё бы сошлось. Поэтому второе окно приходит вместе со
* СВОИМ смещением, а не одними часами.
*
* Здесь нет никакого расчёта только три числа и проверка, что окно осмысленно.
* Проверку делает то же самое {@see SmsQuietHours::isValidWindow()}: второго
* ответа на вопрос «осмысленно ли окно» в портале быть не должно (строка Н.3).
*
* 🔴 Лежит рядом с правилом, а не в модуле обзвона, нарочно: это слово из
* словаря самого правила. Обзвон его только заполняет (решение владельца Р76
* правило одно, живой модуль правим, а не заводим правилу второй дом).
*
* Смещение в МИНУТАХ от UTC, как во всём портале (`RegionTimezoneMap`): пояса
* бывают и получасовыми, и час тут ничего не сэкономил бы.
*/
final class LocalHoursWindow
{
/**
* @param int $offsetMinutes смещение места, в чьих часах названы `fromHour`/`toHour`
* @param int $fromHour с какого часа окно открыто
* @param int $toHour до какого часа окно открыто (сам этот час уже закрыт)
*
* @throws InvalidArgumentException если окно бессмысленно например «с 20 до 10»
*/
public function __construct(
public readonly int $offsetMinutes,
public readonly int $fromHour,
public readonly int $toHour,
) {
if (! SmsQuietHours::isValidWindow($fromHour, $toHour)) {
throw new InvalidArgumentException(
"Окно часов бессмысленно: с {$fromHour} до {$toHour}. "
.'Окно «с 20 до 10» означало бы работу всю ночь, а не короткий день.'
);
}
}
/** Для подписи на экране: «11:00–17:00». Часы, а не минуты — окно задаётся часами. */
public function podpis(): string
{
return sprintf('%02d:00%02d:00', $this->fromHour, $this->toHour);
}
}
+202 -5
View File
@@ -6,6 +6,7 @@ namespace App\Services\ClientSms;
use App\Models\ClientSmsSettings;
use Carbon\CarbonImmutable;
use InvalidArgumentException;
/**
* Правило «отдаём сообщение оператору только с 10:00 до 20:00 по местному времени
@@ -19,6 +20,38 @@ use Carbon\CarbonImmutable;
*
* Смещение пояса приходит в МИНУТАХ и снаружи из снимка получателей. Сервис ничего
* не знает ни про номера, ни про регионы: он про часы.
*
* ─────────────────────────────────────────────────────────────────────────────
* 🔴 ВТОРОЕ ОКНО ЧАСЫ МЕНЕДЖЕРА КЛИЕНТА (задача З-3.1, решения владельца Р64,
* Р76, Р115). Добавлено 07.08.2026.
*
* Обзвон спрашивает то же самое правило, но у него ДВА окна сразу: рамка по месту
* ПОЛУЧАТЕЛЯ (не будить людей ночью) и график менеджера, названный клиентом в
* СВОИХ часах. Набирать можно только там, где открыты оба и «оба» считается в
* общих часах, а не в часах одной стороны (Р115).
*
* 🔴 Почему так, а не «просто окно поуже»: сузив окно получателя часами
* менеджера, мы прочитали бы московские 14:0018:00 как 14:0018:00 ПО
* ВЛАДИВОСТОКУ и перевели бы звонок на спящего менеджера. Экран показал бы
* «звоним 1418», журнал «звонок в 14:07», и всё бы сошлось. Второе окно
* поэтому приходит вместе со своим смещением {@see LocalHoursWindow}.
*
* 🔴 Второе окно только СУЖАЕТ рамку и никогда не расширяет (Р64): 09:0022:00
* принимается как 10:0020:00. Сужение {@see narrowedWindow()}.
*
* 🔴 Довод необязательный и по умолчанию пустой. Пустой довод это сегодняшнее
* поведение слово в слово: живой рассылке СМС никакого второго окна не задают, и
* ни один её час не сдвигается. Доказано вычитанием, отчёт
* `docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-z-3-1-okno-chasov-2026-08-07.md`.
*
* 🔴 Р76 отдал в общее пользование ПРАВИЛО, а не ЧИСЛА. Границы окна обзвона
* свои, из своей настройки (класс `ObzvonOknoChasov` модуля обзвона); сюда они
* попадают через {@see withWindow()}, а не через `client_sms_settings`. Админ,
* поправив окно рассылок, окно обзвона не двигает.
*
* Имя того класса здесь нарочно текстом, а не ссылкой: правило рассылки не должно
* знать про модуль обзвона даже строкой импорта зависимость идёт в одну сторону.
* ─────────────────────────────────────────────────────────────────────────────
*/
final class SmsQuietHours
{
@@ -41,13 +74,69 @@ final class SmsQuietHours
return $fromHour >= 0 && $toHour <= 24 && $fromHour < $toHour;
}
/** Можно ли отдавать сообщение прямо сейчас номеру с этим смещением. */
public function canSendNow(int $offsetMinutes, ?CarbonImmutable $now = null): bool
/**
* Границы не из `client_sms_settings`, а названные снаружи для тех, у кого
* окно своё (обзвон, Р76: правило общее, числа разные).
*
* Запроса к базе такой объект не делает ни одного: обе границы уже известны,
* и {@see load()} на них останавливается.
*
* @throws InvalidArgumentException если окно бессмысленно например «с 20 до 10»
*/
public static function withWindow(int $fromHour, int $toHour): self
{
$local = ($now ?? CarbonImmutable::now('UTC'))->addMinutes($offsetMinutes);
$hour = (int) $local->format('G');
if (! self::isValidWindow($fromHour, $toHour)) {
throw new InvalidArgumentException(
"Границы окна бессмысленны: с {$fromHour} до {$toHour}. "
.'Окно «с 20 до 10» означало бы работу всю ночь.'
);
}
return $hour >= $this->fromHour() && $hour < $this->toHour();
$rule = new self;
$rule->from = $fromHour;
$rule->to = $toHour;
return $rule;
}
/**
* Можно ли отдавать сообщение (звонить) прямо сейчас номеру с этим смещением.
*
* 🔴 Третий довод по умолчанию ПУСТ, и при пустом ответ этого правила ровно тот
* же, что был до 07.08.2026: живая рассылка СМС второго окна не задаёт.
*
* @param int $offsetMinutes смещение ПОЛУЧАТЕЛЯ, минут от UTC
* @param LocalHoursWindow|null $secondWindow часы менеджера клиента в ЕГО часах (Р115).
* Пусто рамка получателя и больше ничего.
*/
public function canSendNow(
int $offsetMinutes,
?CarbonImmutable $now = null,
?LocalHoursWindow $secondWindow = null,
): bool {
$now = $now ?? CarbonImmutable::now('UTC');
// Рамка по месту получателя — она же потолок, за неё не выходим никогда.
if (! $this->hourIsInside($now, $offsetMinutes, $this->fromHour(), $this->toHour())) {
return false;
}
if ($secondWindow === null) {
return true;
}
$narrowed = $this->narrowedWindow($secondWindow);
// Окно клиента целиком вне рамки (например 20:00–23:00): звонков не будет
// вовсе. Молчать об этом нельзя — экран спрашивает {@see narrowedWindow()}.
if ($narrowed === null) {
return false;
}
// 🔴 Второе окно меряется СВОИМ смещением, а не смещением получателя.
// Здесь и есть пересечение в общих часах: оба вопроса заданы про один и
// тот же миг `$now`, каждый — в часах своей стороны.
return $this->hourIsInside($now, $narrowed->offsetMinutes, $narrowed->fromHour, $narrowed->toHour);
}
/**
@@ -73,6 +162,96 @@ final class SmsQuietHours
return $open->subMinutes($offsetMinutes);
}
/**
* Второе окно, СУЖЕННОЕ рамкой (решение владельца Р64): клиент задаёт часы
* своего менеджера, и они рамку только сужают, никогда не расширяют.
* 11:0017:00 остаётся собой, 09:0022:00 принимается как 10:0020:00.
*
* Пусто означает «окна нет вовсе»: клиент задал часы целиком вне рамки
* например 20:0023:00. Это не «сегодня не выйдет», а «так звонков не будет
* никогда», и экран обязан сказать это человеку словами, а не молчать.
*
* 🔴 Сужение живёт здесь, а не в модуле обзвона: это часть правила окна
* (строка Н.3). Второе такое место дало бы клиенту одни часы на экране и
* другие в наборе.
*/
public function narrowedWindow(LocalHoursWindow $secondWindow): ?LocalHoursWindow
{
$from = max($secondWindow->fromHour, $this->fromHour());
$to = min($secondWindow->toHour, $this->toHour());
if (! self::isValidWindow($from, $to)) {
return null;
}
return new LocalHoursWindow($secondWindow->offsetMinutes, $from, $to);
}
/**
* Ближайший момент (в UTC), когда откроются ОБА окна разом: и рамка по месту
* получателя, и суженные часы менеджера в его собственных часах (Р115).
*
* Пусто означает «общего окна нет вовсе» и это ответ, а не молчание. Причин
* ровно две, и обе надо уметь сказать человеку:
* 1. часы клиента целиком вне рамки (20:0023:00) {@see narrowedWindow()};
* 2. окна не пересекаются из-за поясов: менеджер в Москве 14:0018:00,
* получатель во Владивостоке у одного всегда закрыто, когда у другого
* открыто. Общего окна нет ни сегодня, ни завтра, ни когда-либо.
*
* 🔴 Почему довольно суток. Оба окна повторяются каждые 24 часа по UTC, и
* смещения не двигаются (в России переводов часов нет). Значит если общего
* часа нет в ближайшие сутки его нет никогда, и искать дальше некуда.
*
* Мгновений проверяем не 1440, а горстку: общее окно может начаться только
* тогда, когда открывается одно из двух окон, либо прямо сейчас. Между этими
* мигами ничего не меняется.
*
* Отдельным именем, а не третьим доводом к {@see nextWindowOpensAt()},
* нарочно: у вопроса про два окна ответ бывает пустым, а у вопроса про одно
* никогда. Смешав их, мы получили бы пустоту, которую зовущий прочтёт как
* «начинаем прямо сейчас».
*/
public function nextCommonWindowOpensAt(
int $offsetMinutes,
LocalHoursWindow $secondWindow,
?CarbonImmutable $now = null,
): ?CarbonImmutable {
$now = $now ?? CarbonImmutable::now('UTC');
$narrowed = $this->narrowedWindow($secondWindow);
if ($narrowed === null) {
return null;
}
$limit = $now->addDay();
$candidates = [$now];
foreach ([[$offsetMinutes, $this->fromHour()], [$narrowed->offsetMinutes, $narrowed->fromHour]] as [$offset, $fromHour]) {
$midnight = $now->addMinutes($offset)->startOfDay();
// Сегодняшнее открытие может быть уже позади, завтрашнее — уже за сутками;
// лишнее отсеет условие ниже, а пропустить ничего нельзя.
foreach ([0, 1] as $day) {
$candidates[] = $midnight->addDays($day)->addHours($fromHour)->subMinutes($offset);
}
}
$candidates = array_values(array_filter(
$candidates,
static fn (CarbonImmutable $moment): bool => ! $moment->lessThan($now) && $moment->lessThan($limit),
));
usort($candidates, static fn (CarbonImmutable $a, CarbonImmutable $b): int => $a <=> $b);
foreach ($candidates as $moment) {
if ($this->canSendNow($offsetMinutes, $moment, $secondWindow)) {
return $moment;
}
}
return null;
}
/**
* Когда рассылка реально начнётся (строка листа 3.15): самый ранний момент, в
* который у кого-нибудь из получателей откроется окно.
@@ -124,8 +303,26 @@ final class SmsQuietHours
return (int) $this->to;
}
/**
* Попадает ли миг `$nowUtc` в окно `[$fromHour, $toHour)` по часам места со
* смещением `$offsetMinutes`.
*
* 🔴 Единственное место в портале, где час вообще берётся из мига. Оба окна
* и рамка получателя, и часы менеджера спрашивают отсюда, каждое со своим
* смещением. Разъедься эти два счёта, и получилось бы ровно то, от чего
* охраняет строка Н.3: по экрану ждём утра, по факту уже звоним.
*/
private function hourIsInside(CarbonImmutable $nowUtc, int $offsetMinutes, int $fromHour, int $toHour): bool
{
$hour = (int) $nowUtc->addMinutes($offsetMinutes)->format('G');
return $hour >= $fromHour && $hour < $toHour;
}
private function load(): void
{
// Границы уже названы снаружи ({@see withWindow()}) либо прочитаны раньше —
// второго запроса не будет ни на каком числе номеров.
if ($this->from !== null) {
return;
}
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Services\Obzvon;
use App\Models\SystemSetting;
use App\Services\ClientSms\LocalHoursWindow;
use App\Services\ClientSms\SmsQuietHours;
use Carbon\CarbonImmutable;
/**
* В какие часы обзвон вправе НАБИРАТЬ номер задача З-3.1.
*
* 🔴🔴 ЗДЕСЬ НЕТ И НЕ ДОЛЖНО БЫТЬ НИ ОДНОГО РАСЧЁТА ЧАСА. Правило окна живёт в
* {@see SmsQuietHours} и больше нигде (строка Н.3, решение владельца Р76: из двух
* путей «править живой СМС-модуль» или «завести правилу второй дом» выбран
* первый). Этот класс только приносит правилу СВОИ числа и передаёт вопрос
* дальше. Скопируй сюда хоть одно сравнение часов и заведёшь правилу второй
* дом, ровно то, что Р76 запретило: разъехавшись, два места дадут звонок,
* который по экрану ждёт утра, а по факту уже идёт.
*
* 🔴 Р76 отдал в общее пользование ПРАВИЛО, А НЕ ЧИСЛА. Границы обзвона
* отдельная настройка портала, а не `client_sms_settings`. Иначе админ, поправив
* окно рассылок, молча поправил бы и обзвон.
*
* Из чего складывается ответ:
*
* 1. **Общая рамка** 10:0020:00 по месту ПОЛУЧАТЕЛЯ админская, потолок для
* всех. За неё не выходим никогда.
* 2. **Своё окно клиента** часы его менеджера, названные клиентом в СВОИХ
* часах. Рамку они только сужают и никогда не расширяют (Р64): 09:0022:00
* принимается как 10:0020:00.
* 3. 🔴 **Два окна живут в разных часах, и рамка их пересечение** (Р115).
* Набираем только там, где открыты оба ОДНОВРЕМЕННО. Менеджер из Москвы,
* работающий 14:0018:00, и получатель во Владивостоке общего окна не имеют
* вовсе и портал обязан это сказать, а не звонить молча.
*
* 🔴 **ВХОДЯЩИЕ ЗВОНКИ ОКНОМ НЕ ОГРАНИЧЕНЫ** (решение владельца Р35). Окно задаёт
* часы НАШЕГО набора; обратный звонок человека принимается круглосуточно. Поэтому
* у этого класса нет и не должно появиться ни одного метода про приём звонка:
* спросить его про входящий физически нечем. Сторож на это
* `tests/Feature/Obzvon/ObzvonOknoChasovTest.php`.
*
* 🔴 Номер с НЕИЗВЕСТНЫМ поясом сюда не доходит: у него нет смещения, и порядок
* с ним прежний сутки ждём уточнения региона, потом не трогаем вовсе (В-116).
* Новое условие этого не меняет.
*
* Границы читаются ОДИН раз на объект и одним запросом как в
* {@see SmsQuietHours}: на 20 000 номеров запрос внутри перебора стал бы
* 20 000-м. (Так делает и {@see SrokiZvonka}, но там запрос идёт на каждый
* вопрос здесь нарочно иначе, потому что здесь перебор.)
*/
final class ObzvonOknoChasov
{
/** С какого часа обзвон вправе набирать. Ключ настройки портала. */
public const KLYUCH_OT_CHASA = 'obzvon_okno_ot_chasa';
/** До какого часа обзвон вправе набирать (сам этот час уже закрыт). */
public const KLYUCH_DO_CHASA = 'obzvon_okno_do_chasa';
/**
* Умолчания та же общая рамка 10:0020:00. Это НЕ «значения на всякий
* случай»: удали строку настройки кто угодно портал обязан вернуться к
* рамке, а не к «звоним круглосуточно».
*/
public const OT_CHASA_PO_UMOLCHANIYU = 10;
public const DO_CHASA_PO_UMOLCHANIYU = 20;
private ?SmsQuietHours $pravilo = null;
/**
* Можно ли НАБИРАТЬ этот номер прямо сейчас.
*
* @param int $poyasPoluchatelyaMinut смещение получателя от UTC, минут
* @param LocalHoursWindow|null $oknoMenedzhera часы менеджера клиента в ЕГО часах.
* Пусто клиент их не задал, остаётся общая рамка
*/
public function mozhnoNabirat(
int $poyasPoluchatelyaMinut,
?LocalHoursWindow $oknoMenedzhera = null,
?CarbonImmutable $seychas = null,
): bool {
return $this->pravilo()->canSendNow($poyasPoluchatelyaMinut, $seychas, $oknoMenedzhera);
}
/**
* Когда откроется ближайшее окно для набора. Открыто сейчас возвращает
* текущий миг.
*
* Пусто означает «звонков не будет вовсе» и бывает только при заданных часах
* менеджера: либо они целиком вне рамки, либо не пересекаются с рамкой
* получателя из-за поясов. Экран обязан сказать это человеку словами.
*/
public function kogdaOtkroetsya(
int $poyasPoluchatelyaMinut,
?LocalHoursWindow $oknoMenedzhera = null,
?CarbonImmutable $seychas = null,
): ?CarbonImmutable {
if ($oknoMenedzhera === null) {
return $this->pravilo()->nextWindowOpensAt($poyasPoluchatelyaMinut, $seychas);
}
return $this->pravilo()->nextCommonWindowOpensAt($poyasPoluchatelyaMinut, $oknoMenedzhera, $seychas);
}
/**
* Часы менеджера после сужения рамкой (Р64) для подписи на экране: клиент
* задал 09:0022:00, а звонить будем 10:0020:00, и человек должен это видеть.
*
* Пусто часы заданы целиком вне рамки, звонков не будет.
*/
public function oknoMenedzheraPosleSuzheniya(LocalHoursWindow $oknoMenedzhera): ?LocalHoursWindow
{
return $this->pravilo()->narrowedWindow($oknoMenedzhera);
}
/** С какого часа открыта общая рамка обзвона — для подписей на экране. */
public function otChasa(): int
{
return $this->pravilo()->fromHour();
}
/** До какого часа открыта общая рамка обзвона (сам этот час уже закрыт). */
public function doChasa(): int
{
return $this->pravilo()->toHour();
}
/**
* То же правило, но со СВОИМИ границами. Собирается один раз на объект;
* дальше правило к базе не ходит вовсе.
*/
private function pravilo(): SmsQuietHours
{
if ($this->pravilo === null) {
[$ot, $do] = $this->granicy();
$this->pravilo = SmsQuietHours::withWindow($ot, $do);
}
return $this->pravilo;
}
/**
* Границы из настроек портала, одним запросом. Негодное значение молча
* заменяется рамкой, а не принимается как есть: пустая строка, буквы или
* вывернутое окно «с 20 до 10» это опечатка в поле ввода, а не решение
* владельца, и вести себя по опечатке нельзя, когда цена ошибки звонок
* человеку ночью.
*
* Осмысленность окна проверяет то же самое правило, а не своя проверка.
*
* @return array{int, int}
*/
private function granicy(): array
{
$stroki = SystemSetting::query()
->whereIn('key', [self::KLYUCH_OT_CHASA, self::KLYUCH_DO_CHASA])
->pluck('value', 'key');
$ot = $this->chas($stroki[self::KLYUCH_OT_CHASA] ?? null, self::OT_CHASA_PO_UMOLCHANIYU);
$do = $this->chas($stroki[self::KLYUCH_DO_CHASA] ?? null, self::DO_CHASA_PO_UMOLCHANIYU);
if (! SmsQuietHours::isValidWindow($ot, $do)) {
return [self::OT_CHASA_PO_UMOLCHANIYU, self::DO_CHASA_PO_UMOLCHANIYU];
}
return [$ot, $do];
}
private function chas(?string $znachenie, int $umolchanie): int
{
$znachenie = trim((string) $znachenie);
if ($znachenie === '' || ! ctype_digit($znachenie)) {
return $umolchanie;
}
return (int) $znachenie;
}
}
@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
use App\Models\ClientSmsSettings;
use App\Services\ClientSms\LocalHoursWindow;
use App\Services\ClientSms\SmsQuietHours;
use App\Support\RegionTimezoneMap;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
/**
* Второе окно часы менеджера клиента (задача З-3.1, решения владельца Р64, Р76, Р115).
*
* 🔴 Здесь сторожат САМО ПРАВИЛО. Проверки обёртки обзвона в
* `tests/Feature/Obzvon/ObzvonOknoChasovTest.php`.
*
* Почему это надо сторожить отдельно и заранее: ошибка на час здесь не видна ни в
* одном прогоне она видна только менеджеру, которого робот разбудил переводом
* звонка в четыре утра, и разбуженному человеку на том конце.
*/
uses(RefreshDatabase::class);
/** Момент UTC по человеческой записи — чтобы в тесте было видно, который час. */
function vtoroeOknoUtc(string $iso): CarbonImmutable
{
return CarbonImmutable::parse($iso, 'UTC');
}
$MSK = 3 * 60;
$VLADIVOSTOK = 10 * 60;
$KAMCHATKA = 12 * 60;
it('🔴 ЛОВУШКА: часы менеджера меряются ЕГО поясом, а не поясом получателя', function () use ($MSK, $VLADIVOSTOK) {
// Менеджер в Москве работает 10:00–18:00 — это 07:0015:00 UTC.
// Получатель во Владивостоке: его рамка 10:00–20:00 — это 00:0010:00 UTC.
// Общее окно у них — 07:0010:00 UTC (1013 МСК = 17–20 по Владивостоку).
$rule = app(SmsQuietHours::class);
$menedzher = new LocalHoursWindow($MSK, 10, 18);
// 07:30 UTC = 10:30 МСК (менеджер на месте) = 17:30 Владивосток (рамка открыта).
expect($rule->canSendNow($VLADIVOSTOK, vtoroeOknoUtc('2026-08-03 07:30'), $menedzher))->toBeTrue();
// 🔴 Вот на чём краснеет «просто сузили окно». 04:30 UTC = 14:30 ВЛАДИВОСТОКА:
// сузив окно получателя часами менеджера, портал прочёл бы «10:00–18:00» как
// владивостокские и набрал бы номер. А в Москве в этот миг 07:30 — менеджер спит.
expect($rule->canSendNow($VLADIVOSTOK, vtoroeOknoUtc('2026-08-03 04:30'), $menedzher))->toBeFalse();
// И наоборот: 10:30 UTC = 13:30 МСК (менеджер на месте), но во Владивостоке
// уже 20:30 — рамка получателя закрыта. Набора нет.
expect($rule->canSendNow($VLADIVOSTOK, vtoroeOknoUtc('2026-08-03 10:30'), $menedzher))->toBeFalse();
});
it('пустое второе окно не меняет сегодняшнего ответа ни на один час', function () use ($MSK) {
$rule = app(SmsQuietHours::class);
for ($chas = 0; $chas < 24; $chas++) {
$mig = vtoroeOknoUtc('2026-08-03 00:00')->addHours($chas);
expect($rule->canSendNow($MSK, $mig, null))
->toBe($rule->canSendNow($MSK, $mig), "час UTC {$chas}");
}
});
it('окно клиента только СУЖАЕТ рамку и никогда не расширяет', function () use ($MSK) {
$rule = app(SmsQuietHours::class);
// Внутри рамки — остаётся собой.
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 11, 17))->podpis())->toBe('11:0017:00');
// Шире рамки — принимается как рамка (Р64).
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 9, 22))->podpis())->toBe('10:0020:00');
// Одним краем наружу — подрезается этот край.
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 8, 15))->podpis())->toBe('10:0015:00');
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 15, 23))->podpis())->toBe('15:0020:00');
});
it('окно 09:00–22:00 не даёт набрать ни в 09 часов, ни в 21', function () use ($MSK) {
$rule = app(SmsQuietHours::class);
$menedzher = new LocalHoursWindow($MSK, 9, 22);
// Получатель тоже в Москве — тогда весь спор только про сужение.
expect($rule->canSendNow($MSK, vtoroeOknoUtc('2026-08-03 06:30'), $menedzher))->toBeFalse(); // 09:30 МСК
expect($rule->canSendNow($MSK, vtoroeOknoUtc('2026-08-03 07:30'), $menedzher))->toBeTrue(); // 10:30 МСК
expect($rule->canSendNow($MSK, vtoroeOknoUtc('2026-08-03 16:30'), $menedzher))->toBeTrue(); // 19:30 МСК
expect($rule->canSendNow($MSK, vtoroeOknoUtc('2026-08-03 17:30'), $menedzher))->toBeFalse(); // 20:30 МСК
});
it('окно целиком вне рамки говорит «звонков не будет», а не молчит', function () use ($MSK) {
$rule = app(SmsQuietHours::class);
$menedzher = new LocalHoursWindow($MSK, 20, 23);
// Сужение пусто — экрану есть что сказать человеку.
expect($rule->narrowedWindow($menedzher))->toBeNull();
// И набора нет ни в один час суток.
for ($chas = 0; $chas < 24; $chas++) {
expect($rule->canSendNow($MSK, vtoroeOknoUtc('2026-08-03 00:00')->addHours($chas), $menedzher))
->toBeFalse("час UTC {$chas}");
}
// «Когда откроется» отвечает пустотой — это ответ «никогда», а не «прямо сейчас».
expect($rule->nextCommonWindowOpensAt($MSK, $menedzher, vtoroeOknoUtc('2026-08-03 09:00')))->toBeNull();
});
it('🔴 общее окно может не существовать вовсе из-за поясов, и это сказано вслух', function () use ($MSK, $KAMCHATKA, $VLADIVOSTOK) {
// Менеджер в Москве 14:0018:00 = 11:0015:00 UTC — часы сами по себе годные,
// они целиком внутри рамки, сужать нечего.
// Камчатка: рамка 10:0020:00 = 22:0008:00 UTC. Общего часа НЕТ ни одного.
// Владивосток: рамка 10:0020:00 = 00:0010:00 UTC. Тоже ни одного.
$rule = app(SmsQuietHours::class);
$menedzher = new LocalHoursWindow($MSK, 14, 18);
foreach ([$KAMCHATKA, $VLADIVOSTOK] as $poyas) {
for ($chas = 0; $chas < 24; $chas++) {
expect($rule->canSendNow($poyas, vtoroeOknoUtc('2026-08-03 00:00')->addHours($chas), $menedzher))
->toBeFalse("пояс {$poyas}, час UTC {$chas}");
}
// 🔴 Пустота здесь означает «никогда», и молчать об этом нельзя.
expect($rule->nextCommonWindowOpensAt($poyas, $menedzher, vtoroeOknoUtc('2026-08-03 09:00')))->toBeNull();
}
// А сужение при этом НЕ пусто: часы клиента сами по себе годные — беда в поясах.
// Две причины «звонков не будет» портал различает, потому что говорит о них разное.
expect($rule->narrowedWindow($menedzher))->not->toBeNull();
});
it('говорит, когда откроется ОБЩЕЕ окно, и не промахивается мимо него', function () use ($MSK, $VLADIVOSTOK) {
$rule = app(SmsQuietHours::class);
$menedzher = new LocalHoursWindow($MSK, 10, 18);
// Общее окно у Москвы-менеджера и Владивостока-получателя: 07:0010:00 UTC.
// Спрашиваем в 00:00 UTC — ждём до 07:00 UTC.
expect($rule->nextCommonWindowOpensAt($VLADIVOSTOK, $menedzher, vtoroeOknoUtc('2026-08-03 00:00'))?->toIso8601String())
->toBe(vtoroeOknoUtc('2026-08-03 07:00')->toIso8601String());
// Открыто прямо сейчас — отдаёт этот же миг, а не завтрашний.
expect($rule->nextCommonWindowOpensAt($VLADIVOSTOK, $menedzher, vtoroeOknoUtc('2026-08-03 08:00'))?->toIso8601String())
->toBe(vtoroeOknoUtc('2026-08-03 08:00')->toIso8601String());
// Окно уже закрылось — ждём ЗАВТРАШНЕГО, а не сегодняшнего.
expect($rule->nextCommonWindowOpensAt($VLADIVOSTOK, $menedzher, vtoroeOknoUtc('2026-08-03 12:00'))?->toIso8601String())
->toBe(vtoroeOknoUtc('2026-08-04 07:00')->toIso8601String());
});
it('🔴 обещанный миг открытия действительно открыт, а до него закрыто — на всех поясах', function () use ($MSK) {
// Сторож на промах «когда откроется»: обещание сверяется с самим правилом.
// Ошибка на час тут иначе не видна — она видна разбуженному человеку.
$rule = app(SmsQuietHours::class);
$menedzher = new LocalHoursWindow($MSK, 11, 17);
$poyasa = array_values(array_unique(array_map(
static fn (int $ch): int => $ch * 60,
RegionTimezoneMap::CODE_TO_UTC_HOURS
)));
$proverok = 0;
foreach ($poyasa as $poyas) {
for ($chas = 0; $chas < 24; $chas++) {
$seychas = vtoroeOknoUtc('2026-08-03 00:00')->addHours($chas);
$otkroetsya = $rule->nextCommonWindowOpensAt($poyas, $menedzher, $seychas);
$proverok++;
if ($otkroetsya === null) {
// «Никогда» обязано означать, что и правда никогда — ни в одну минуту суток.
for ($m = 0; $m < 1440; $m += 7) {
expect($rule->canSendNow($poyas, $seychas->addMinutes($m), $menedzher))
->toBeFalse("пояс {$poyas}: обещано «никогда», а в +{$m} мин открыто");
}
continue;
}
expect($rule->canSendNow($poyas, $otkroetsya, $menedzher))
->toBeTrue("пояс {$poyas}, спросили в {$seychas->toIso8601String()}: обещан {$otkroetsya->toIso8601String()}, а там закрыто");
// И ни одной минуты между «сейчас» и обещанным мигом открытым быть не должно.
$minut = (int) $seychas->diffInMinutes($otkroetsya);
for ($m = 0; $m < $minut; $m++) {
if ($rule->canSendNow($poyas, $seychas->addMinutes($m), $menedzher)) {
throw new RuntimeException(
"пояс {$poyas}: открыто уже в +{$m} мин, а обещано только через {$minut} мин"
);
}
}
}
}
expect($proverok)->toBe(count($poyasa) * 24);
});
it('бессмысленное окно менеджера отвергается тем же правилом, что и окно рассылки', function () use ($MSK) {
// «С 20 до 10» означало бы работу всю ночь. Второй проверки на это в портале нет.
expect(fn () => new LocalHoursWindow($MSK, 20, 10))->toThrow(InvalidArgumentException::class);
expect(fn () => new LocalHoursWindow($MSK, 12, 12))->toThrow(InvalidArgumentException::class);
expect(fn () => new LocalHoursWindow($MSK, -1, 10))->toThrow(InvalidArgumentException::class);
expect(fn () => new LocalHoursWindow($MSK, 10, 25))->toThrow(InvalidArgumentException::class);
});
it('второе окно слушается границ, выставленных владельцем в админке', function () use ($MSK) {
// Рамку двигает админ — и сужение обязано двигаться вместе с ней.
ClientSmsSettings::query()->update(['quiet_from_hour' => 12, 'quiet_to_hour' => 18]);
$rule = app(SmsQuietHours::class);
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 9, 22))->podpis())->toBe('12:0018:00');
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 10, 20))->podpis())->toBe('12:0018:00');
});
it('правило со своими границами не ходит в базу ни разу', function () {
// Границы названы снаружи — значит на 20 000 номеров не будет ни одного запроса.
$rule = SmsQuietHours::withWindow(10, 20);
$zaprosov = 0;
DB::listen(function () use (&$zaprosov): void {
$zaprosov++;
});
for ($i = 0; $i < 5000; $i++) {
$rule->canSendNow(180, vtoroeOknoUtc('2026-08-03 09:00'));
}
expect($rule->fromHour())->toBe(10);
expect($rule->toHour())->toBe(20);
expect($zaprosov)->toBe(0);
});
it('бессмысленные границы снаружи правило не принимает', function () {
expect(fn () => SmsQuietHours::withWindow(20, 10))->toThrow(InvalidArgumentException::class);
});
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
use App\Models\ClientSmsSettings;
use App\Models\SystemSetting;
use App\Services\ClientSms\LocalHoursWindow;
use App\Services\Obzvon\ObzvonOknoChasov;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
/**
* Обёртка обзвона над правилом часов задача З-3.1.
*
* Решения владельца: Р76 (правило одно, живой СМС-модуль правим; в общее
* пользование отдано ПРАВИЛО, а не ЧИСЛА), Р64 (окно клиента только сужает),
* Р115 (два окна в разных часах, рамка пересечение), Р35 (входящие окном не
* ограничены).
*/
uses(RefreshDatabase::class);
$MSK = 3 * 60;
$VLADIVOSTOK = 10 * 60;
it('без своей настройки держит общую рамку 10:0020:00', function () use ($MSK) {
$okno = new ObzvonOknoChasov;
expect($okno->otChasa())->toBe(10);
expect($okno->doChasa())->toBe(20);
expect($okno->mozhnoNabirat($MSK, null, CarbonImmutable::parse('2026-08-03 06:59', 'UTC')))->toBeFalse(); // 09:59 МСК
expect($okno->mozhnoNabirat($MSK, null, CarbonImmutable::parse('2026-08-03 07:00', 'UTC')))->toBeTrue(); // 10:00 МСК
expect($okno->mozhnoNabirat($MSK, null, CarbonImmutable::parse('2026-08-03 16:59', 'UTC')))->toBeTrue(); // 19:59 МСК
expect($okno->mozhnoNabirat($MSK, null, CarbonImmutable::parse('2026-08-03 17:00', 'UTC')))->toBeFalse(); // 20:00 МСК
});
it('🔴 админ поправил окно РАССЫЛОК — окно обзвона не сдвинулось', function () use ($MSK) {
// Р76 отдал в общее пользование правило, а не числа. Прочитавший его как
// «одни часы на двоих» сломает вот это.
ClientSmsSettings::query()->update(['quiet_from_hour' => 12, 'quiet_to_hour' => 14]);
$okno = new ObzvonOknoChasov;
expect($okno->otChasa())->toBe(10);
expect($okno->doChasa())->toBe(20);
// 07:30 UTC = 10:30 МСК: для рассылок теперь рано, для обзвона — можно.
expect($okno->mozhnoNabirat($MSK, null, CarbonImmutable::parse('2026-08-03 07:30', 'UTC')))->toBeTrue();
});
it('свою настройку слушается, и чужую при этом не трогает', function () use ($MSK) {
SystemSetting::query()->insert([
['key' => ObzvonOknoChasov::KLYUCH_OT_CHASA, 'value' => '11', 'type' => 'int', 'updated_at' => now()],
['key' => ObzvonOknoChasov::KLYUCH_DO_CHASA, 'value' => '17', 'type' => 'int', 'updated_at' => now()],
]);
$okno = new ObzvonOknoChasov;
expect($okno->otChasa())->toBe(11);
expect($okno->doChasa())->toBe(17);
expect($okno->mozhnoNabirat($MSK, null, CarbonImmutable::parse('2026-08-03 07:30', 'UTC')))->toBeFalse(); // 10:30 МСК
expect($okno->mozhnoNabirat($MSK, null, CarbonImmutable::parse('2026-08-03 08:30', 'UTC')))->toBeTrue(); // 11:30 МСК
// Настройки рассылок остались нетронутыми.
expect(ClientSmsSettings::first()->quiet_from_hour)->toBe(10);
expect(ClientSmsSettings::first()->quiet_to_hour)->toBe(20);
});
it('опечатка в настройке не превращается в круглосуточный обзвон', function () {
SystemSetting::query()->insert([
['key' => ObzvonOknoChasov::KLYUCH_OT_CHASA, 'value' => '', 'type' => 'int', 'updated_at' => now()],
['key' => ObzvonOknoChasov::KLYUCH_DO_CHASA, 'value' => 'вечером', 'type' => 'int', 'updated_at' => now()],
]);
$okno = new ObzvonOknoChasov;
expect($okno->otChasa())->toBe(10);
expect($okno->doChasa())->toBe(20);
});
it('вывернутое окно «с 20 до 10» из настройки заменяется рамкой, а не принимается', function () {
SystemSetting::query()->insert([
['key' => ObzvonOknoChasov::KLYUCH_OT_CHASA, 'value' => '20', 'type' => 'int', 'updated_at' => now()],
['key' => ObzvonOknoChasov::KLYUCH_DO_CHASA, 'value' => '10', 'type' => 'int', 'updated_at' => now()],
]);
$okno = new ObzvonOknoChasov;
expect($okno->otChasa())->toBe(10);
expect($okno->doChasa())->toBe(20);
});
it('🔴 ЛОВУШКА: менеджер в Москве 10:00–18:00, получатель во Владивостоке', function () use ($MSK, $VLADIVOSTOK) {
$okno = new ObzvonOknoChasov;
$menedzher = new LocalHoursWindow($MSK, 10, 18);
// 04:30 UTC = 14:30 по ВЛАДИВОСТОКУ. Сузив одно окно вместо пересечения двух,
// портал набрал бы номер здесь — а в Москве 07:30, менеджер спит.
expect($okno->mozhnoNabirat($VLADIVOSTOK, $menedzher, CarbonImmutable::parse('2026-08-03 04:30', 'UTC')))->toBeFalse();
// 07:30 UTC = 10:30 МСК и 17:30 Владивосток — открыты оба.
expect($okno->mozhnoNabirat($VLADIVOSTOK, $menedzher, CarbonImmutable::parse('2026-08-03 07:30', 'UTC')))->toBeTrue();
// 10:30 UTC = 13:30 МСК (менеджер на месте), но 20:30 Владивосток — рамка закрыта.
expect($okno->mozhnoNabirat($VLADIVOSTOK, $menedzher, CarbonImmutable::parse('2026-08-03 10:30', 'UTC')))->toBeFalse();
});
it('часы клиента шире рамки принимаются как рамка и показываются человеку', function () use ($MSK) {
$okno = new ObzvonOknoChasov;
expect($okno->oknoMenedzheraPosleSuzheniya(new LocalHoursWindow($MSK, 9, 22))->podpis())->toBe('10:0020:00');
expect($okno->oknoMenedzheraPosleSuzheniya(new LocalHoursWindow($MSK, 11, 17))->podpis())->toBe('11:0017:00');
expect($okno->oknoMenedzheraPosleSuzheniya(new LocalHoursWindow($MSK, 20, 23)))->toBeNull();
});
it('когда общего окна нет вовсе — отвечает пустотой, а не «прямо сейчас»', function () use ($MSK) {
$okno = new ObzvonOknoChasov;
$kamchatka = 12 * 60;
// Москва 14:0018:00 = 11:0015:00 UTC; Камчатка 10:0020:00 = 22:0008:00 UTC.
expect($okno->kogdaOtkroetsya($kamchatka, new LocalHoursWindow($MSK, 14, 18), CarbonImmutable::parse('2026-08-03 09:00', 'UTC')))
->toBeNull();
// А без часов менеджера ответ есть всегда — пустоты быть не может.
expect($okno->kogdaOtkroetsya($kamchatka, null, CarbonImmutable::parse('2026-08-03 09:00', 'UTC')))
->not->toBeNull();
});
it('🔴 Р35: спросить обзвонное окно про ВХОДЯЩИЙ звонок физически нечем', function () {
// Входящие принимаются круглосуточно. Окно задаёт часы НАШЕГО набора.
// Сторож краснеет, если кто-нибудь заведёт здесь метод про приём звонка.
$metody = get_class_methods(ObzvonOknoChasov::class);
sort($metody);
expect($metody)->toBe([
'doChasa',
'kogdaOtkroetsya',
'mozhnoNabirat',
'oknoMenedzheraPosleSuzheniya',
'otChasa',
]);
});
it('🔴 обёртка не считает часы сама — второго дома у правила нет', function () {
// Р76: скопированное сюда сравнение часов и есть тот второй дом, который
// запрещён. Сторож краснеет, если он появится.
$tekst = file_get_contents(base_path('app/Services/Obzvon/ObzvonOknoChasov.php'));
// Тело класса без шапки-докблока: в шапке ловушка описана словами.
$telo = substr($tekst, strpos($tekst, 'final class'));
expect($telo)->not->toContain('addMinutes');
expect($telo)->not->toContain('startOfDay');
expect($telo)->not->toContain("format('G')");
expect($telo)->not->toContain('CarbonImmutable::now');
});
it('🔴 на большом списке номеров запрос к базе ровно один', function () use ($MSK) {
// Беду «вопрос к базе внутри перебора номеров» в этом проекте ловили дважды
// за одну неделю: отбор 20 000 номеров вырос с 20 до 29 секунд именно так.
$okno = new ObzvonOknoChasov;
$menedzher = new LocalHoursWindow($MSK, 11, 17);
$mig = CarbonImmutable::parse('2026-08-03 09:00', 'UTC');
$zaprosov = 0;
DB::listen(function () use (&$zaprosov): void {
$zaprosov++;
});
for ($i = 0; $i < 20000; $i++) {
$okno->mozhnoNabirat(($i % 11 + 2) * 60, $menedzher, $mig);
}
expect($zaprosov)->toBe(1);
});
@@ -0,0 +1,412 @@
# Отчёт помощника — З-3.1. Окно часов: общая рамка и своё окно клиента
**Смена 6, круг 5. 07.08.2026.** Приёмочный лист надзирателя —
[z-3-1-okno-chasov-2026-08-07.md](z-3-1-okno-chasov-2026-08-07.md).
Решения владельца в основании: **Р64** (окно клиента только сужает рамку),
**Р76** (правило остаётся одно, живой СМС-модуль правим), **Р115** (два окна
живут в разных часах, рамка — их пересечение), **Р35** (входящие окном не
ограничены).
🔴🔴 Правка боевого файла `app/app/Services/ClientSms/SmsQuietHours.php`,
которым прямо сейчас пользуются живые клиенты.
---
## 1. Мои ошибки — называю первыми
**Ошибка 1. Я неверно посчитал общее окно в собственном же стороже.**
Написал в проверке, что у московского менеджера 14:00–18:00 и получателя во
Владивостоке общее окно — 07:0010:00 UTC. **Общего окна у них нет вовсе:**
московские 14:0018:00 это 11:0015:00 UTC, а владивостокская рамка 10:00–20:00
это 00:0010:00 UTC — арки на круге суток не пересекаются ни в одной минуте.
Три сторожа покраснели, и покраснели **правильно**: ошибка была в моей
арифметике, а не в коде. Поправил, разведя на два случая:
- менеджер в Москве **10:0018:00** + Владивосток → общее окно есть, 07:0010:00 UTC;
- менеджер в Москве **14:0018:00** + Владивосток (и Камчатка) → общего окна **нет вовсе**.
Оба случая теперь сторожат отдельно, и второй ценнее: он сторожит именно ту
пустоту, о которой портал обязан сказать вслух.
**Ошибка 2.** Первая версия шапки правила ссылалась на класс обзвона через
`{@see \App\Services\Obzvon\ObzvonOknoChasov}`. Форматчик от этой ссылки захотел
вписать в правило рассылки **настоящий импорт модуля обзвона** — то есть развернуть
зависимость задом наперёд. Заметил только на проверке стиля, а не при написании.
Убрал ссылку, оставил имя класса текстом, и написал в файле, почему именно текстом.
**Ошибка 3, мелкая, но того же рода.** Ждал полного прогона по условию «файл вывода
перестал быть пустым». Прогон в середине написал в файл замер про 20 000 номеров —
и я решил, что он кончился, хотя он шёл дальше. Датчик был не тот: годный датчик —
итоговая строка прогона, а не признак жизни файла. Тот же класс ошибки, что «зелёный
код возврата вместо числа проверок», только с другой стороны.
---
## 2. Разбор задания — три замера надзирателя не сошлись
Задание просило разобрать его по существу. Разобрал; три числа в приёмочном листе
не подтвердились.
### 2.1. 🔴 «Зовущих двенадцать» — зовущих ПЯТЬ
Двенадцать — это число **файлов, где встречается строка `SmsQuietHours`**. Шесть
из них — упоминания в шапках-докблоках («единственный дом правила — как
SmsQuietHours…»), двенадцатый — сам класс. Счётчик соврал не нулём, а **завышением**.
| # | файл | зовёт? | чем именно |
|---|---|---|---|
| 1 | `app/Http/Controllers/Api/Admin/AdminSmsTariffController.php` | ✅ зовёт | `::isValidWindow` + `::DEFAULT_FROM_HOUR` / `::DEFAULT_TO_HOUR` (стр. 119122) |
| 2 | `app/Http/Controllers/Api/Client/ClientSmsController.php` | ✅ зовёт | впрыск в конструктор (стр. 70) + `earliestOpening` (стр. 174) |
| 3 | `app/Services/ClientSms/ClientSmsSnapshotWriter.php` | ✅ зовёт | впрыск (стр. 29) + `nextWindowOpensAt` (стр. 48) |
| 4 | `app/Jobs/SendAutoSmsForDealJob.php` | ✅ зовёт | передача значением через 3 подписи (78, 106, 319) + `canSendNow` (362) и `nextWindowOpensAt` (364) |
| 5 | `app/Jobs/EnrichClientSmsSnapshotRegionJob.php` | ✅ зовёт | передача через 2 подписи (68, 223) + `nextWindowOpensAt` (226) |
| 6 | `app/Services/ClientSms/AllowedSmsOperators.php` | ❌ только слово в шапке | — |
| 7 | `app/Services/ClientSms/ClientSmsDeliveryCounter.php` | ❌ только слово в шапке | — |
| 8 | `app/Services/ClientSms/ClientSmsUploadLimit.php` | ❌ только слово в шапке | — |
| 9 | `app/Services/ClientSms/ClientSmsVolumeCounter.php` | ❌ только слово в шапке | — |
| 10 | `app/Services/Sms/SmsDeliveryState.php` | ❌ только слово в шапке | — |
| 11 | `app/Services/Obzvon/ObzvonTarifikator.php` | ❌ только слово в шапке | — |
| 12 | `app/Services/ClientSms/SmsQuietHours.php` | — сам класс | — |
Замер: `grep -rnE '(canSendNow|nextWindowOpensAt|earliestOpening|isValidWindow)\(' app/`
плюс `grep -rn 'SmsQuietHours' app/` глазами по каждой строке.
### 2.2. 🔴 «Обзвон уже читает это правило через ObzvonTarifikator» — не читает
В `ObzvonTarifikator.php` **нет ни импорта `SmsQuietHours`, ни единого вызова**.
Есть одна строка в шапке (стр. 12–13), где правило часов названо **примером для
подражания**: «правило цены живёт ЗДЕСЬ и больше нигде — так же, как правило часов
рассылки живёт только в `SmsQuietHours`». Это аналогия в тексте, а не зависимость
в коде.
**Узкого горла, названного в приёмочном листе, не существует.** Правка правила
часов сегодня не задевает в обзвоне ничего: до неё обзвон правило не читал вовсе.
Читать начинает ровно с этой задачи и ровно через новую обёртку.
### 2.3. 🔴 «Любое изменение подписи `canSendNow` — это двенадцать мест живого кода»
У `canSendNow` **ровно ОДИН боевой зовущий**: `SendAutoSmsForDealJob.php:362`.
(В проверках — ещё 5 мест.) У `nextWindowOpensAt` — три, у `earliestOpening`
один, у `isValidWindow` — один.
### 2.4. Ответ на прямой вопрос: «правда ли подпись менять нельзя?»
**Вывод верный, обоснование — нет.** Подпись менять не стоит, но не потому, что
зовущих много (их один), а потому, что предложенная замена не даёт того, ради
чего затевалась.
Замысел был: «пусть каждое место явно скажет, чьи часы оно имеет в виду». Но у
модуля рассылки **менеджера нет вовсе** — ни человека, ни настройки его часов, ни
экрана, где их задать. Все пять мест до конца дней передавали бы «менеджера нет».
Это не ясность, а шум, который через месяц начнут копировать не глядя. Плюс той же
операции потребовал бы `nextWindowOpensAt` — а у него вопрос про два окна имеет
**другой тип ответа** (бывает пустой), и слить их в одну подпись значило бы отдать
зовущему пустоту, которую он прочтёт как «начинаем прямо сейчас».
Поэтому сделано так:
- `canSendNow` получил **третий необязательный довод** с умолчанием «пусто».
При пустом ответ правила совпадает со вчерашним — доказано вычитанием (§4);
- вопрос «когда откроется общее окно» вынесен **отдельным именем**
`nextCommonWindowOpensAt`, потому что у него бывает пустой ответ, а у старого
`nextWindowOpensAt` — никогда. `nextWindowOpensAt` не тронут вовсе.
### 2.5. Что я нашёл сверх заданного
**а) Второе место того же класса, о котором задание не сказало: откладывание
работы.** `SendAutoSmsForDealJob` при закрытом окне откладывает себя на
`nextWindowOpensAt` — то есть **всегда на какую-то дату**. Для обзвона так нельзя:
там ответ бывает «никогда», и звонок, отложенный на «никогда», повис бы в очереди
навсегда, ничего не сказав человеку. Поэтому `kogdaOtkroetsya` отдаёт **пустоту**,
а не далёкую дату: зовущий обязан на неё посмотреть. Это стык, который придётся
честно пройти той задаче, которая будет ставить звонки в очередь.
**б) Р35 сегодня нечем нарушить и нечем проверить по-настоящему.** Приёмника
входящих звонков в портале ещё нет — он строится в `bots/` у соседней смены.
Сторож я поставил **на форму**: у обёртки нет и не должно появиться ни одного
метода про приём звонка, и проверка краснеет, если такой метод завести (показал
красным, §5, врез E). Но это сторож на замысел, а не на работу. Настоящая проверка
«входящий приняли в 03:00» станет возможна только вместе с приёмником.
**в) Настроек в базе нет — ни рамки обзвона, ни часов менеджера.** Подробно в §10.
**г) Проверил, правда ли у правила один дом — правда.** Шапка правила утверждает,
что любая другая проверка часа в модуле — ошибка. Проверил это командой, а не на
слово: искал по всему `app/` сложение смещения пояса с моментом
(`addMinutes` с поясом) — **не нашлось ни одного места, кроме самого правила**.
Отдельно искал сравнения часа в модулях рассылки, обзвона и в работах: нашлись
только `RouteSupplierLeadJob` и `SyncSupplierProjectJob` с порогом «21:00 МСК» —
это **другое** правило про другое: вечерняя заморозка обмена с поставщиком, всегда
по московскому времени, к «будить ли человека» отношения не имеет. Второго дома у
правила часов нет, и моя правка его не завела.
---
## 3. Что построено
| файл | что это |
|---|---|
| `app/app/Services/ClientSms/SmsQuietHours.php` | 🔴 **боевой.** Правлен: третий необязательный довод у `canSendNow`, сужение `narrowedWindow` (Р64), пересечение `nextCommonWindowOpensAt` (Р115), `withWindow` — свои границы без запроса к базе (Р76) |
| `app/app/Services/ClientSms/LocalHoursWindow.php` | новый. Окно часов, названное **в своих часах** какого-то места: смещение + от + до. Осмысленность проверяет то же `SmsQuietHours::isValidWindow` — второго ответа на этот вопрос в портале нет |
| `app/app/Services/Obzvon/ObzvonOknoChasov.php` | новый. **Тонкая обёртка, ни одного расчёта часа.** Приносит правилу свои числа и передаёт вопрос дальше |
| `app/tests/Feature/ClientSms/QuietHoursVtoroeOknoTest.php` | новый. Сторожа самого правила — 12 проверок |
| `app/tests/Feature/Obzvon/ObzvonOknoChasovTest.php` | новый. Сторожа обёртки — 11 проверок |
**Почему обёртка не копия правила.** Единственное, что она умеет сама, — прочитать
свои две границы из настроек портала. Всё остальное — переспрос у
`SmsQuietHours`. На это стоит отдельный сторож: он читает исходный текст обёртки и
краснеет, если в теле класса появятся `addMinutes`, `startOfDay`, `format('G')`
или `CarbonImmutable::now` — то есть если кто-нибудь начнёт считать часы здесь.
**Где именно живёт пересечение.** Час из мига берётся ровно в одном месте —
приватный `hourIsInside`. Оба окна спрашивают оттуда, **каждое со своим
смещением**. Пересечение — это два вопроса про один и тот же миг `$now`, заданные
в часах своей стороны. Никакого «привести к часам получателя» в коде нет.
---
## 4. Мерка 0 — вычитание: живая рассылка не изменилась ни на один час
«Тесты рассылки зелены» этого не доказывает, поэтому доказано иначе.
**Прибор.** Из `git HEAD` вынут старый файл правила (объект
`4f25b79e6cf338735f8111b95f198fe7725c2772`) и положен как временный класс
`Tests\Temp\SmsQuietHoursEtalon`. Изменены **ровно две строки** — namespace и имя
класса; дословность остального доказана командой (нормализация обеих копий и
сравнение → совпало). Дальше ответы старого и правленого правила сверялись
побитно.
**Сетка замера:**
- **все 13 смещений**, какие вообще доходят до правила: 11 разных поясов из
справочника 89 субъектов (UTC+2…UTC+12) плюс получасовой UTC+5:30 и западный
UTC−3, какие отдаёт ДаДата;
- **каждая минута суток** — все 1440;
- **семь наборов границ**, какие владелец может выставить в админке: 10–20, 1119,
921, 024, 1218, 01, 2324;
- сверялись `canSendNow` (каждая минута), `nextWindowOpensAt` (каждая десятая
минута) и `earliestOpening` (каждый час, на четырёх наборах поясов), а также обе
границы, прочитанные из базы.
```text
[VYCHITANIE] sverok=144816 rashozhdeniy=0
```
**144 816 сверок, расхождений 0.**
🔴 **Зелёный прибор не доказывает ничего, пока не показан красным.** Подложил в
правленое правило поломку в один знак — `>=` вместо `>` в `hourIsInside`:
```text
[VYCHITANIE] sverok=144816 rashozhdeniy=6049
[VYCHITANIE] canSendNow окно 10-20 пояс -180 миг 2026-08-03T13:00:00+00:00: старое true, новое false
[VYCHITANIE] nextWindowOpensAt окно 10-20 пояс -180 миг 2026-08-03T13:00:00+00:00:
старое 2026-08-03T13:00:00+00:00, новое 2026-08-04T13:00:00+00:00
```
Поломка возвращена, прибор снова дал 0. Прибор **убран**, и это доказано командой:
`find . -name "*Vychitanie*" -o -name "*Etalon*"` вне `vendor` не находит ничего,
`tests/Temp` не существует.
---
## 5. Мерка 1 — сторожа, показанные красными
Каждый врез делался **моим ножом в моё же лечение**, потом возвращался.
🪤 Возврат доказан слепком со снятием невидимых знаков конца строки (`tr -d '\r'`).
### Врез A — главная ловушка: часы менеджера меряются поясом ПОЛУЧАТЕЛЯ
Заменил в `canSendNow` смещение второго окна на смещение получателя
(`$narrowed->offsetMinutes``$offsetMinutes`) — то самое «просто сузили окно».
Итог: **всего 23, зелёных 18, провалов 4, в поле ошибок 1** (18+4+1=23 — арифметику
проверил, красный в поле «ошибок» не потерялся).
| сторож | что сказал дословно |
|---|---|
| ЛОВУШКА: часы менеджера меряются ЕГО поясом (правило), стр. 48 | `Failed asserting that true is false.` |
| ЛОВУШКА: менеджер в Москве, получатель во Владивостоке (обзвон), стр. 101 | `Failed asserting that true is false.` |
| общее окно может не существовать вовсе, стр. 119 | `пояс 720, час UTC 2 / Failed asserting that true is false.` |
| когда откроется ОБЩЕЕ окно, стр. 138 | `Expected '2026-08-03T07:00:00+00:00' / Actual '2026-08-03T00:00:00+00:00'` |
| обещанный миг открытия действительно открыт, стр. 186 | `пояс 420: открыто уже в +240 мин, а обещано только через 480 мин` |
### Врез B — сужение наоборот: окно клиента РАСШИРЯЕТ рамку (против Р64)
Поменял `max`/`min` местами в `narrowedWindow`. **Всего 23, зелёных 17, провалов 6.**
| сторож | что сказал дословно |
|---|---|
| окно клиента только сужает, стр. 70 | `Expected '11:0017:00' / Actual '10:0020:00'` |
| окно целиком вне рамки говорит «звонков не будет», стр. 96 | `Failed asserting that LocalHoursWindow (offsetMinutes 180, fromHour 10, toHour 23) is null.` |
| общего окна нет из-за поясов, стр. 119 | `пояс 720, час UTC 7 / Failed asserting that true is false.` |
| слушается границ владельца, стр. 211 | `Expected '12:0018:00' / Actual '09:0022:00'` |
| часы шире рамки принимаются как рамка (обзвон), стр. 113 | `Expected '10:0020:00' / Actual '09:0022:00'` |
| общего окна нет — отвечает пустотой (обзвон), стр. 124 | `Failed asserting that CarbonImmutable … is null.` |
### Врез C — «общего окна нет» превращается в «звоним прямо сейчас»
Заменил `return null` на `return $now` в `nextCommonWindowOpensAt` — беда, ради
которой пустота вообще заведена. **Всего 23, зелёных 20, провалов 3.**
| сторож | что сказал дословно |
|---|---|
| окно целиком вне рамки, стр. 123 | `Failed asserting that CarbonImmutable … is null.` |
| обещанный миг действительно открыт, стр. 179 | `пояс 720, спросили в 2026-08-03T00:00:00+00:00: обещан 2026-08-03T00:00:00+00:00, а там закрыто` |
| общего окна нет — отвечает пустотой (обзвон), стр. 124 | `Failed asserting that CarbonImmutable … is null.` |
### Врез D — вопрос к базе внутри перебора номеров
Убрал запоминание правила в обёртке — ровно та беда, которую в проекте ловили
дважды за неделю. **Всего 11, зелёных 10, провалов 1.**
| сторож | что сказал дословно |
|---|---|
| на большом списке номеров запрос ровно один, стр. 176 | `Failed asserting that 20000 is identical to 1.` |
### Врез E — второй дом правила и Р35 разом
Вписал в обёртку метод `mozhnoPrinimat` со своим счётом часа: он и заводит правилу
второй дом, и даёт ограничить входящие. **Всего 11, зелёных 9, провалов 2.**
| сторож | что сказал дословно |
|---|---|
| Р35: спросить окно про входящий физически нечем, стр. 137 | `Failed asserting that two arrays are identical … -3 => 'oknoMenedzheraPosleSuzheniya', -4 => 'otChasa'…` |
| обёртка не считает часы сама, стр. 154 | `Expecting 'final class ObzvonOknoChasov…}}' not to contain 'addMinutes'.` |
**Возврат доказан:** после всех пяти врезов слепки со снятым `\r`
`SmsQuietHours.php``916a621a1acaeb6c69a801d2da45ff8e0a4b56f0`,
`ObzvonOknoChasov.php``f38c08b19e6ed173d64ba11b36ff5c09e5b5a2b4`;
вычитание снова дало 0 расхождений, все 39 проверок зелены.
---
## 6. Мерка 2 — полный прогон
Своя база `liderra_testing_z31`, поднята с нуля через `migrate --force`, вывод не
через трубу, запуск `php -d memory_limit=-1 vendor/bin/pest` — не `php artisan test`
и не `composer test`.
```text
всего 5242 · зелёных 5238 · провалов 0 · ошибок 0 · пропущено 4
проверок-утверждений 21 718 · 19 мин 26 с
```
🔴 **Датчик — число проверок, а не код возврата.** Арифметику проверил:
**5238 + 0 + 0 + 4 = 5242**, сходится — красных, спрятавшихся в поле «ошибок», нет.
Поле `risky` = 4 (проверки без утверждений) — подмножество зелёных, сумма от него не
меняется; это не мои проверки и не новое.
**Откуда взялась разница со входом смены.** На входе было 5207. Моих новых
проверок — **23** (12 в `QuietHoursVtoroeOknoTest`, 11 в `ObzvonOknoChasovTest`),
итого ожидалось 5230. Пришло 5242 — **лишние 12 не мои**: за время работы соседняя
смена дописала свои проверки в `ObzvonClientShovTest.php` (+188 строк, файл мне
запрещён и я его не касался). Это не расхождение, а работа соседа в том же дереве.
Прочее:
- статанализ `composer stan`**0 замечаний**;
- стиль `vendor/bin/pint --test` по всем моим файлам — **чисто**;
- слои `vendor/bin/deptrac analyse`**0 нарушений**.
---
## 7. Мерка 3 — соседи
### 7.1. 🔴 Что могло сломаться у живой рассылки СМС и почему не сломалось
Сломаться могло **всё**: правило часов — единственная дверь, через которую живой
модуль рассылки решает, отдавать сообщение оператору или ждать утра. Ошибка в один
час здесь не видна ни в одном прогоне рассылки — она видна человеку, которому СМС
придёт в четыре утра.
Три конкретных пути поломки и чем каждый закрыт **замером, а не словом**:
1. **Сдвиг ответа на границе окна.** Старый код брал час одним выражением; я вынес
это в `hourIsInside` и позвал оттуда дважды. Одна перепутанная строгость знака —
и вся страна получает сообщения на час позже или на час раньше. Замер: вычитание
на 144 816 сверках, 0 расхождений; тот же прибор на подложенной поломке в один
знак дал 6049 расхождений, то есть он эту беду видит.
2. **Появление лишнего запроса к базе.** Границы читаются один раз на объект
нарочно. Замер: сторож «правило со своими границами не ходит в базу ни разу»
(5000 вопросов → 0 запросов) и сторож обёртки (20 000 номеров → ровно 1 запрос);
второй показан красным — без запоминания он даёт 20 000.
3. **Изменение поведения зовущих.** Подписи `nextWindowOpensAt`, `earliestOpening`,
`isValidWindow`, `fromHour`, `toHour` не тронуты ни одним знаком; у `canSendNow`
довод добавлен третьим и необязательным. Ни один из пяти зовущих не правлен —
`git diff` по живому модулю показывает изменения **только** в самом файле
правила. Прежние проверки окна (`QuietHoursTest.php`, 15 штук) не правились и
зелены.
### 7.2. Чужие файлы в дереве не тронуты
В дереве шевелятся файлы двух соседних смен: `ObzvonClient.php`,
`config/services.php`, `.env.example`, `ObzvonClientShovTest.php`,
`bots/lena-golos/README.md`. **Я их не касался.** Замер: в их правках нет ни одного
упоминания `QuietHours`, `OknoChasov`, `LocalHoursWindow`, `canSendNow`,
`narrowedWindow`, `nextCommonWindow` — ни одной строки. Мой коммит собран
поимённо, `git add .` не применялся.
---
## 8. Мерка 4 — зовущие поимённо
Таблица всех двенадцати файлов — в §2.1. Пятеро зовут, шестеро только упоминают в
шапке, двенадцатый — сам класс. Считал глазами по каждой строке, а не числом
файлов: именно счётчик по числу файлов и дал приёмочному листу «двенадцать».
---
## 9. Мерка 5 — число запросов к базе
| замер | ответ |
|---|---|
| правило со своими границами, 5 000 вопросов | **0 запросов** |
| обёртка обзвона, 20 000 номеров с часами менеджера | **1 запрос** |
| та же обёртка без запоминания (врез D) | **20 000 запросов** — сторож красный |
Границы обзвона читаются **одним** запросом на оба ключа (`whereIn`), а не двумя.
---
## 10. Что осталось незакрытым
1. 🔴 **Часам менеджера негде храниться.** Ни таблицы, ни колонки в базе нет —
искал по `app/Models` и по миграциям обзвона. Миграции мне запрещены заданием,
поэтому окно менеджера приходит **доводом снаружи**: правило и обёртка умеют с
ним работать, но записать его клиенту сегодня некуда и экрана для этого нет.
Нужна отдельная задача: колонки (или строки настроек) на клиента + экран + ручка.
2. 🔴 **Рамка обзвона тоже пока не управляема.** Обёртка читает ключи
`obzvon_okno_ot_chasa` / `obzvon_okno_do_chasa` из `system_settings`, но
**строк этих в базе нет** — заводит их миграция, а миграции мне запрещены.
Пока их нет, действует умолчание 10:00–20:00 (страховка, как у `SrokiZvonka`).
⚠️ Важно: экран настроек портала правит только **уже заведённые** ключи и
отвечает 404 на незнакомый — значит **до миграции админ окно обзвона поменять не
сможет вовсе**. При этом разделение источников уже настоящее и засторожено:
админ, двигающий окно рассылок, окно обзвона не двигает (проверено врезом).
Нужна миграция на две строки в `system_settings` — по образцу
`2026_08_05_140000_add_obzvon_zvonki_sroki_hraneniya.php`.
3. **Р35 засторожен по форме, а не по работе.** Приёмника входящих ещё нет (он в
`bots/` у соседней смены). Сторож краснеет, если у обёртки заведут метод про
приём звонка, но проверить «входящий принят в 03:00» станет возможно только
вместе с приёмником.
4. **Никто ещё не зовёт `ObzvonOknoChasov`.** Обёртка построена и засторожена, но
звонилки, которая спрашивала бы её перед набором, в портале нет. Пока это
заряженное правило без стрелка — и это нормально для З-3.1, но приёмке волны со
звонилкой надо будет проверить, что она спрашивает **именно** эту дверь.
5. **Стык с очередью не пройден.** `kogdaOtkroetsya` умеет ответить «никогда»
(пустотой). Что делать со звонком, у которого общего окна нет вовсе — сказать
клиенту на экране, отменить, оставить висеть — **решения владельца нет**, и я
его не выдумывал. Правило отвечает честно; решение за владельцем.
6. **Граница «час целиком» не обсуждалась.** Окно задаётся целыми часами, как и у
рассылки. Если менеджер работает с 9:30 — сказать этого сегодня нельзя. Вопрос
владельцу, менять — в одном месте.
7. **«Когда начнётся кампания» по списку номеров для обзвона не построено.**
У рассылки это `earliestOpening` — самый ранний миг по всем поясам списка.
Для обзвона такого ответа я не делал: задача З-3.1 про один номер, а у списка
ответ сложнее — часть номеров может не иметь общего окна **никогда**, и «пусто»
у `earliestOpening` уже занято смыслом «начинаем прямо сейчас». Строить это надо
**в том же правиле** и отдельным именем — иначе следующая смена посчитает
самый ранний миг у себя, и у правила появится второй дом, ровно то, что
запретило Р76. Записал здесь именно поэтому.