5dfe32dc91
Правило часов остаётся ОДНО и живёт в боевом 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>
236 lines
13 KiB
PHP
236 lines
13 KiB
PHP
<?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);
|
||
});
|