From 5dfe32dc9155d501ed398eed55a6d3de11de9bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 7 Aug 2026 05:58:59 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BE=D0=BA=D0=BD=D0=BE=20=D1=87=D0=B0?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=20=D0=BE=D0=B1=D0=B7=D0=B2=D0=BE=D0=BD=D0=B0?= =?UTF-8?q?=20=E2=80=94=20=D0=BE=D0=B1=D1=89=D0=B0=D1=8F=20=D1=80=D0=B0?= =?UTF-8?q?=D0=BC=D0=BA=D0=B0=20=D0=B8=20=D1=81=D0=B2=D0=BE=D1=91=20=D0=BE?= =?UTF-8?q?=D0=BA=D0=BD=D0=BE=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?,=20=D0=97-3.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правило часов остаётся ОДНО и живёт в боевом 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 --- .../Services/ClientSms/LocalHoursWindow.php | 59 +++ app/app/Services/ClientSms/SmsQuietHours.php | 207 ++++++++- app/app/Services/Obzvon/ObzvonOknoChasov.php | 182 ++++++++ .../ClientSms/QuietHoursVtoroeOknoTest.php | 235 ++++++++++ .../Feature/Obzvon/ObzvonOknoChasovTest.php | 177 ++++++++ ...moshchnika-z-3-1-okno-chasov-2026-08-07.md | 412 ++++++++++++++++++ 6 files changed, 1267 insertions(+), 5 deletions(-) create mode 100644 app/app/Services/ClientSms/LocalHoursWindow.php create mode 100644 app/app/Services/Obzvon/ObzvonOknoChasov.php create mode 100644 app/tests/Feature/ClientSms/QuietHoursVtoroeOknoTest.php create mode 100644 app/tests/Feature/Obzvon/ObzvonOknoChasovTest.php create mode 100644 docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-z-3-1-okno-chasov-2026-08-07.md diff --git a/app/app/Services/ClientSms/LocalHoursWindow.php b/app/app/Services/ClientSms/LocalHoursWindow.php new file mode 100644 index 00000000..881d77fb --- /dev/null +++ b/app/app/Services/ClientSms/LocalHoursWindow.php @@ -0,0 +1,59 @@ +fromHour, $this->toHour); + } +} diff --git a/app/app/Services/ClientSms/SmsQuietHours.php b/app/app/Services/ClientSms/SmsQuietHours.php index 4f25b79e..5861dfb6 100644 --- a/app/app/Services/ClientSms/SmsQuietHours.php +++ b/app/app/Services/ClientSms/SmsQuietHours.php @@ -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; } diff --git a/app/app/Services/Obzvon/ObzvonOknoChasov.php b/app/app/Services/Obzvon/ObzvonOknoChasov.php new file mode 100644 index 00000000..f38c08b1 --- /dev/null +++ b/app/app/Services/Obzvon/ObzvonOknoChasov.php @@ -0,0 +1,182 @@ +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; + } +} diff --git a/app/tests/Feature/ClientSms/QuietHoursVtoroeOknoTest.php b/app/tests/Feature/ClientSms/QuietHoursVtoroeOknoTest.php new file mode 100644 index 00000000..a055954d --- /dev/null +++ b/app/tests/Feature/ClientSms/QuietHoursVtoroeOknoTest.php @@ -0,0 +1,235 @@ +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); +}); diff --git a/app/tests/Feature/Obzvon/ObzvonOknoChasovTest.php b/app/tests/Feature/Obzvon/ObzvonOknoChasovTest.php new file mode 100644 index 00000000..21d0d3e8 --- /dev/null +++ b/app/tests/Feature/Obzvon/ObzvonOknoChasovTest.php @@ -0,0 +1,177 @@ +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); +}); diff --git a/docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-z-3-1-okno-chasov-2026-08-07.md b/docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-z-3-1-okno-chasov-2026-08-07.md new file mode 100644 index 00000000..9c896202 --- /dev/null +++ b/docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-z-3-1-okno-chasov-2026-08-07.md @@ -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. Записал здесь именно поэтому.