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:
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\ClientSms;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Окно часов, названное В СВОИХ ЧАСАХ какого-то одного места (задача З-3.1, Р115).
|
||||
*
|
||||
* 🔴 ЗАЧЕМ ЭТО ВООБЩЕ ЕСТЬ. Правило окна {@see SmsQuietHours} до сих пор знало
|
||||
* РОВНО ОДНО смещение — смещение получателя. Часы менеджера клиента живут в
|
||||
* ДРУГИХ часах: клиент называет их у себя, а получателя мы будим у него. Пока
|
||||
* смещение было одно, «добавить часы менеджера» означало бы сузить окно
|
||||
* получателя — и менеджер из Москвы, работающий 14:00–18:00, был бы прочитан как
|
||||
* «14:00–18: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);
|
||||
}
|
||||
}
|
||||
@@ -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:00–18:00 как 14:00–18:00 ПО
|
||||
* ВЛАДИВОСТОКУ и перевели бы звонок на спящего менеджера. Экран показал бы
|
||||
* «звоним 14–18», журнал — «звонок в 14:07», и всё бы сошлось. Второе окно
|
||||
* поэтому приходит вместе со своим смещением — {@see LocalHoursWindow}.
|
||||
*
|
||||
* 🔴 Второе окно только СУЖАЕТ рамку и никогда не расширяет (Р64): 09:00–22:00
|
||||
* принимается как 10:00–20: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:00–17:00 остаётся собой, 09:00–22:00 принимается как 10:00–20:00.
|
||||
*
|
||||
* Пусто означает «окна нет вовсе»: клиент задал часы целиком вне рамки —
|
||||
* например 20:00–23: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:00–23:00) — {@see narrowedWindow()};
|
||||
* 2. окна не пересекаются из-за поясов: менеджер в Москве 14:00–18: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:00–20:00 по месту ПОЛУЧАТЕЛЯ — админская, потолок для
|
||||
* всех. За неё не выходим никогда.
|
||||
* 2. **Своё окно клиента** — часы его менеджера, названные клиентом в СВОИХ
|
||||
* часах. Рамку они только сужают и никогда не расширяют (Р64): 09:00–22:00
|
||||
* принимается как 10:00–20:00.
|
||||
* 3. 🔴 **Два окна живут в разных часах, и рамка — их пересечение** (Р115).
|
||||
* Набираем только там, где открыты оба ОДНОВРЕМЕННО. Менеджер из Москвы,
|
||||
* работающий 14:00–18: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:00–20: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:00–22:00, а звонить будем 10:00–20: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:00–15:00 UTC.
|
||||
// Получатель во Владивостоке: его рамка 10:00–20:00 — это 00:00–10:00 UTC.
|
||||
// Общее окно у них — 07:00–10:00 UTC (10–13 МСК = 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:00–17:00');
|
||||
|
||||
// Шире рамки — принимается как рамка (Р64).
|
||||
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 9, 22))->podpis())->toBe('10:00–20:00');
|
||||
|
||||
// Одним краем наружу — подрезается этот край.
|
||||
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 8, 15))->podpis())->toBe('10:00–15:00');
|
||||
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 15, 23))->podpis())->toBe('15:00–20: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:00–18:00 = 11:00–15:00 UTC — часы сами по себе годные,
|
||||
// они целиком внутри рамки, сужать нечего.
|
||||
// Камчатка: рамка 10:00–20:00 = 22:00–08:00 UTC. Общего часа НЕТ ни одного.
|
||||
// Владивосток: рамка 10:00–20:00 = 00:00–10: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:00–10: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:00–18:00');
|
||||
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 10, 20))->podpis())->toBe('12:00–18: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:00–20: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:00–20:00');
|
||||
expect($okno->oknoMenedzheraPosleSuzheniya(new LocalHoursWindow($MSK, 11, 17))->podpis())->toBe('11:00–17:00');
|
||||
expect($okno->oknoMenedzheraPosleSuzheniya(new LocalHoursWindow($MSK, 20, 23)))->toBeNull();
|
||||
});
|
||||
|
||||
it('когда общего окна нет вовсе — отвечает пустотой, а не «прямо сейчас»', function () use ($MSK) {
|
||||
$okno = new ObzvonOknoChasov;
|
||||
$kamchatka = 12 * 60;
|
||||
|
||||
// Москва 14:00–18:00 = 11:00–15:00 UTC; Камчатка 10:00–20:00 = 22:00–08: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);
|
||||
});
|
||||
+412
@@ -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:00–10:00 UTC. **Общего окна у них нет вовсе:**
|
||||
московские 14:00–18:00 это 11:00–15:00 UTC, а владивостокская рамка 10:00–20:00
|
||||
это 00:00–10:00 UTC — арки на круге суток не пересекаются ни в одной минуте.
|
||||
Три сторожа покраснели, и покраснели **правильно**: ошибка была в моей
|
||||
арифметике, а не в коде. Поправил, разведя на два случая:
|
||||
|
||||
- менеджер в Москве **10:00–18:00** + Владивосток → общее окно есть, 07:00–10:00 UTC;
|
||||
- менеджер в Москве **14:00–18: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` (стр. 119–122) |
|
||||
| 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, 11–19,
|
||||
9–21, 0–24, 12–18, 0–1, 23–24;
|
||||
- сверялись `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:00–17:00' / Actual '10:00–20: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:00–18:00' / Actual '09:00–22:00'` |
|
||||
| часы шире рамки принимаются как рамка (обзвон), стр. 113 | `Expected '10:00–20:00' / Actual '09:00–22: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. Записал здесь именно поэтому.
|
||||
Reference in New Issue
Block a user