feat обзвон: запись исхода мимо двери больше не проходит — пол в модели и обход кода
Голый шов. Правило Т84 «недозвон не затирает состоявшийся разговор» жило в двери ItogPoNomeru, и восемь сторожей доказывали, что дверь работает. Но все восемь ходили ЧЕРЕЗ дверь, а дверью можно не воспользоваться: запрета писать МИМО не было ни одного. Итог по номеру сегодня не пишет никто, значит первым пишущим будет следующая смена — она увидит у модели поле outcome раньше, чем найдёт дверь, напишет в него напрямую, и правило рухнет молча. Дверь осталась бы целой и никем не использованной. Защита разделена по УСТРОЙСТВУ, а не по тексту, и белого списка «кому можно» нет ни в одной половине — такой список сам стал бы дырой. Пол в самой модели ловит любую запись через объект: присваивание полю, fill, update у строки, save. Имя переменной он не читает вовсе — он стоит на самой записи, поэтому главная слабость текстового поиска закрыта не текстом, а местом. Проверяется смысл перехода, а не автор: дверь поблажек не имеет и проходит лишь потому, что запрещённого перехода не делает. Обход кода ловит то, чего пол не видит физически: массовую правку и запросы построителем — Eloquent на них событий не шлёт. Запрет действует на всех без исключений, включая саму дверь. Раскладка исходов НЕ задвоена: обе половины читают одну настройку. Повторено только применение правила — модели запрещено зависеть от служб, у слоя Model в настройке слоёв разрешённых зависимостей ноль. Два места связаны сторожем, который обходит все 64 пары исходов и требует одинакового ответа. Чего не ловит НИ ОДНА из половин, названо в бумагах: правку прямым SQL мимо портала, имя таблицы или столбца, собранное из кусков, и код вне app. Первое лечится только замком в самой базе — это правка канона схемы и вопрос владельцу, поэтому названо, а не сделано молча. Сторожа показаны красными. Настоящая запись мимо двери положена в САМ обходимый код, а не в песочницу: сторож назвал файл, строку 14 и само выражение. Снятие пола дало три красных, включая связку — пара transferred и no_answer. Возврат доказан слепком со снятием невидимых знаков конца строки. Заодно подписи двух значений в настройке: владелец закрыл обе развилки решениями Р120 и Р121 от 06.08.2026, оба совпали с поставленным. Значения не менялись, переписаны только пояснения, и рядом с каждым названо то, чего решение НЕ закрывает. Прогоны: обзвон и сделки 442, зелёных 441, красных ноль. Мои сторожа 32 из 32. Статанализ ноль, deptrac ноль нарушений. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Carbon;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Итог работы с ОДНИМ номером — единственный на всю кампанию (Т84).
|
||||
@@ -93,6 +94,39 @@ class ObzvonNumberResult extends Model
|
||||
|
||||
protected $table = 'obzvon_number_results';
|
||||
|
||||
/**
|
||||
* 🔴 ПОЛ ПОД Т84, а не вторая дверь.
|
||||
*
|
||||
* ОТ КАКОГО ВРЕДА. Правило «недозвон не затирает состоявшийся разговор»
|
||||
* живёт в двери `App\Services\Obzvon\ItogPoNomeru`. Но дверью можно не
|
||||
* воспользоваться: у модели есть поле `outcome`, и написать в него напрямую
|
||||
* — `$itog->outcome = 'no_answer'; $itog->save();` — проще, чем найти дверь.
|
||||
* Написавший так не сделает ничего внешне неправильного: столбец один,
|
||||
* значение из восьми разрешённых, база примет. Разговор затрётся МОЛЧА.
|
||||
*
|
||||
* 🔑 Это «голый шов»: две честные половины, между которыми ничто не
|
||||
* заставляет ходить. Сегодня итог по номеру не пишет НИКТО — модуль ещё не
|
||||
* звонит, — и значит первым пишущим будет следующая смена, которая станет
|
||||
* принимать отчёт робота. Она увидит поле раньше, чем дверь.
|
||||
*
|
||||
* Поэтому запрет стоит там, где его нельзя обойти незнанием: на самой
|
||||
* записи. Это не белый список «кому можно» — списка нет вовсе, и дверь
|
||||
* никаких поблажек не имеет. Проверяется СМЫСЛ перехода, а не автор.
|
||||
*
|
||||
* 🪤 ЧЕГО ЭТОТ ПОЛ НЕ ЛОВИТ, называю сразу: массовую правку
|
||||
* `...->update([...])` и запросы `DB::table(...)` — Eloquent на них событий
|
||||
* НЕ шлёт. Их стережёт отдельная проверка
|
||||
* `tests/Feature/Obzvon/ObzvonZapisMimoDveriTest.php`. Ни то, ни другое не
|
||||
* ловит правку прямым SQL мимо портала — от неё спасёт только замок в самой
|
||||
* базе, а это правка канона схемы и решение владельца.
|
||||
*/
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $itog): void {
|
||||
$itog->proveritPolT84();
|
||||
});
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'campaign_id',
|
||||
@@ -143,4 +177,67 @@ class ObzvonNumberResult extends Model
|
||||
{
|
||||
return $this->hasMany(ObzvonCall::class, 'number_result_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Единственное, что запрещено безусловно: положить исход «про попытку»
|
||||
* поверх исхода «про номер». Всё остальное — дело двери.
|
||||
*
|
||||
* Разбор случаев, и каждый нарочно пропускается:
|
||||
* • исход не менялся — не наш случай;
|
||||
* • новый исход пуст — это обезличивание, стирать право есть;
|
||||
* • прежний исход пуст — первая запись, затирать нечего. Двенадцать
|
||||
* недозвонов подряд обязаны дать «не дозвонились», и они его дают;
|
||||
* • прежний исход тоже «про попытку» — недозвон поверх недозвона, обычный
|
||||
* ход счётчика попыток.
|
||||
* Остаётся ровно один запрещённый переход, и на нём — падение с именем.
|
||||
*
|
||||
* 🔴 ПОЧЕМУ ПРАВИЛО ЗДЕСЬ ПОВТОРЕНО, а не позвано у RaskladkaIskhodov.
|
||||
* `App\Models\*` не вправе зависеть от `App\Services\*`: в `deptrac.yaml`
|
||||
* у слоя Model разрешённых зависимостей НОЛЬ, и правка этого файла не в
|
||||
* моей власти. Тот же случай уже описан у `ObzvonCall::tsenaRubli`, и
|
||||
* лечится он там же тем же способом: **два места связаны проверкой**, а не
|
||||
* оставлены на удачу. Разойдутся — покраснеет
|
||||
* `tests/Feature/Obzvon/ObzvonZapisMimoDveriTest.php`, который обходит все
|
||||
* 64 пары исходов и требует, чтобы пол и дверь отвечали одинаково.
|
||||
*
|
||||
* Сама раскладка НЕ дублируется: и пол, и дверь читают один и тот же
|
||||
* `config('obzvon.raskladka')`. Повторено только применение правила.
|
||||
*/
|
||||
private function proveritPolT84(): void
|
||||
{
|
||||
if (! $this->isDirty('outcome')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$novyy = $this->outcome;
|
||||
$staryy = $this->getOriginal('outcome');
|
||||
|
||||
if (! is_string($novyy) || ! is_string($staryy)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! self::iskhodProPopytku($novyy) || self::iskhodProPopytku($staryy)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RuntimeException(
|
||||
'Запрещено требованием Т84: исход «'.$novyy.'» это исход попытки, '
|
||||
.'и он не может лечь поверх состоявшегося разговора «'.$staryy.'» '
|
||||
.'у номера '.$this->phone.'. Записывать исход можно только через '
|
||||
.'App\Services\Obzvon\ItogPoNomeru::zapisat — он сам решит, '
|
||||
.'перебивает новый исход прежний или проходит бесследно.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Раскладка берётся из настройки — того же места, откуда её берёт дверь.
|
||||
* Своего списка у модели нет: два списка однажды разъедутся.
|
||||
*/
|
||||
private static function iskhodProPopytku(string $outcome): bool
|
||||
{
|
||||
/** @var list<string> $proPopytku */
|
||||
$proPopytku = (array) config('obzvon.raskladka.pro_popytku', []);
|
||||
|
||||
return in_array($outcome, $proPopytku, true);
|
||||
}
|
||||
}
|
||||
|
||||
+33
-17
@@ -59,11 +59,14 @@ return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Карточка сделки — два вопроса, которые владелец ещё НЕ закрыл (З-2.4)
|
||||
| Карточка сделки — два вопроса, ЗАКРЫТЫЕ владельцем 06.08.2026 (З-2.4)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 🟡 Значения ниже — ВРЕМЕННЫЕ, поставлены помощником, чтобы код не решал
|
||||
| вопрос владельца молча. Переиграть каждый = поправить одно число здесь.
|
||||
| 🟢 Оба значения ниже — не «пока так», а **решения владельца**: Р120 и Р121
|
||||
| от 06.08.2026. Переспрашивать закрытое не нужно.
|
||||
|
|
||||
| Менять их можно, но только новым решением владельца: числа стоят в одном
|
||||
| месте нарочно, чтобы правка стоила минуту, а не чтобы её делали походя.
|
||||
|
|
||||
*/
|
||||
'kartochka' => [
|
||||
@@ -71,29 +74,42 @@ return [
|
||||
/*
|
||||
* Где кончается «хоть секунда разговора» (Т84а).
|
||||
*
|
||||
* Разговор попадает в ленту, когда трубку сняли (`answered`) И первое
|
||||
* плечо длилось не меньше этого числа секунд. Длительность в базе —
|
||||
* целые секунды (`leg1_duration_sec INTEGER`), так что 0,4 секунды
|
||||
* физически приедет либо нулём, либо единицей — этим числом решается,
|
||||
* что с ней делать.
|
||||
* 🟢 РЕШЕНИЕ ВЛАДЕЛЬЦА Р120 от 06.08.2026: разговором считается контакт
|
||||
* от ОДНОЙ секунды. Закрыто, переспрашивать не нужно.
|
||||
*
|
||||
* 🟡 Временно 1: «ноль секунд — не разговор, одна секунда — разговор».
|
||||
* Владелец вправе поставить 3, 5 или 0.
|
||||
* Разговор попадает в ленту, когда трубку сняли (`answered`) И первое
|
||||
* плечо длилось не меньше этого числа секунд.
|
||||
*
|
||||
* Развилки «0,4 секунды» не существовало вовсе: длительность в базе —
|
||||
* целые секунды (`leg1_duration_sec INTEGER`). Владелец выбирал из
|
||||
* «одна, три или пять» и отрезал три и пять по доводу: короче порога
|
||||
* сброс сливается с недозвоном и становится невидимым, а сброс — это
|
||||
* ОТВЕТ человека, тогда как недозвон — его отсутствие.
|
||||
*
|
||||
* 🪤 ЧЕГО РЕШЕНИЕ НЕ ЗАКРЫВАЕТ: при пороге в секунду лента длиннее, чем
|
||||
* при любом другом, — короткие сбросы тоже строки, и сворачивать их
|
||||
* никто не назначал; случай «соединилось и оборвалось до первого звука»
|
||||
* ни разу не замерен на живых звонках.
|
||||
*/
|
||||
'minimalnaya_dlitelnost_sec' => 1,
|
||||
|
||||
/*
|
||||
* Сколько ПРОШЛЫХ кампаний показывать в карточке (Р113).
|
||||
* Сколько ПРОШЛЫХ списков показывать в карточке (Р113).
|
||||
*
|
||||
* 🟢 РЕШЕНИЕ ВЛАДЕЛЬЦА Р121 от 06.08.2026: показываем ВСЕ прошлые
|
||||
* списки. Закрыто, переспрашивать не нужно. Довод: Р113 требует, чтобы
|
||||
* прошлое было ВИДНО, и прятать часть истории по своему усмотрению мы
|
||||
* не вправе.
|
||||
*
|
||||
* Тот же номер новым списком заводит НОВЫЙ итог (замок
|
||||
* `UNIQUE (tenant_id, campaign_id, phone)`), прежний остаётся историей
|
||||
* и по Р113 в карточке виден.
|
||||
* `UNIQUE (tenant_id, campaign_id, phone)`), прежний остаётся историей.
|
||||
*
|
||||
* null — показывать все. Число N — показывать N самых свежих кампаний.
|
||||
* null — показывать все. Число N — показывать N самых свежих списков.
|
||||
*
|
||||
* 🟡 Временно null: прятать историю по своему усмотрению помощник не
|
||||
* вправе — Р113 требует, чтобы прошлое было ВИДНО. Владелец вправе
|
||||
* поставить 1.
|
||||
* 🪤 ЧЕГО РЕШЕНИЕ НЕ ЗАКРЫВАЕТ: карточка растёт без предела — у номера
|
||||
* в десятке списков лента вытянется. И отдельно: итог привязан к
|
||||
* СПИСКУ, а не к человеку, поэтому при смене владельца номера в
|
||||
* карточке смешается история двух РАЗНЫХ людей. Это не обсуждали вовсе.
|
||||
*/
|
||||
'kampaniy_v_kartochke' => null,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* З-2.4 — «голый шов»: запись исхода МИМО двери.
|
||||
*
|
||||
* 🔴 ОТ КАКОГО ВРЕДА. Правило Т84 «недозвон не затирает состоявшийся разговор»
|
||||
* живёт в двери `ItogPoNomeru`. Восемь сторожей доказывают, что дверь работает.
|
||||
* Но все восемь ходят ЧЕРЕЗ дверь, а дверью можно не воспользоваться — и тогда
|
||||
* они все останутся зелёными, пока разговор затирается молча.
|
||||
*
|
||||
* Это не выдуманная опасность, а самая вероятная из всех: итог по номеру
|
||||
* сегодня не пишет НИКТО, значит первым пишущим будет следующая смена,
|
||||
* строящая приём отчёта робота. Она увидит у модели поле `outcome` раньше, чем
|
||||
* найдёт дверь, напишет в него напрямую — и Т84 рухнет, а дверь останется
|
||||
* целой и никем не использованной.
|
||||
*
|
||||
* 🔴 ЗАЩИТА РАЗДЕЛЕНА ПО УСТРОЙСТВУ, А НЕ ПО СПИСКУ ИМЁН. Белого списка «кому
|
||||
* можно писать» здесь нет нигде — он сам стал бы дырой: сегодня в нём одна
|
||||
* дверь, завтра допишут вторую.
|
||||
*
|
||||
* 1. **Пол в самой модели** (`ObzvonNumberResult::booted`) ловит ЛЮБУЮ запись
|
||||
* через объект — присваивание полю, `fill`, `update` у строки, `save` —
|
||||
* независимо от того, как назвали переменную и где собрали массив. Автор
|
||||
* записи не проверяется вовсе, проверяется СМЫСЛ перехода;
|
||||
* 2. **проверка ниже** ловит то, чего пол не видит физически: массовую правку
|
||||
* и запросы построителем — на них Eloquent событий не шлёт.
|
||||
*
|
||||
* 🪤 ЧЕГО НЕ ЛОВИТ НИ ОДИН ИЗ ДВУХ, называю вслух, потому что это важнее того,
|
||||
* что они ловят:
|
||||
* • правку прямым SQL мимо портала — из `psql`, из миграции, из чужой службы.
|
||||
* От неё спасёт только замок в самой базе. Это правка канона схемы и
|
||||
* решение владельца, поэтому названо, а не сделано молча;
|
||||
* • имя таблицы, собранное из кусков в переменную: `$t = 'obzvon_'.'number_results'`;
|
||||
* • столбец, собранный из кусков: `->update([$pole => 'no_answer'])`;
|
||||
* • правку из кода вне `app/app` — например из отдельного скрипта в `tools/`.
|
||||
* Первое лечится только базой, остальные три — недостижимы для чтения текста
|
||||
* в принципе, и честнее это назвать, чем делать вид, что сторож полон.
|
||||
*/
|
||||
|
||||
use App\Models\ObzvonNumberResult;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Obzvon\ItogPoNomeru;
|
||||
use App\Services\Obzvon\RaskladkaIskhodov;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. ПОЛ В МОДЕЛИ — ловит запись через объект, кто бы её ни делал
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('🔴 прямое присваивание полю мимо двери НЕ проходит', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$itog = ObzvonNumberResult::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'campaign_id' => 1,
|
||||
'phone' => '79001110001',
|
||||
]);
|
||||
(new ItogPoNomeru)->zapisat($itog, ObzvonNumberResult::OUTCOME_TRANSFERRED, Carbon::parse('2026-08-06 10:00:00'));
|
||||
|
||||
// Ровно то, что напишет следующая смена, не найдя двери.
|
||||
$itog->outcome = ObzvonNumberResult::OUTCOME_NO_ANSWER;
|
||||
$itog->outcome_at = Carbon::parse('2026-08-06 18:00:00');
|
||||
|
||||
expect(fn () => $itog->save())->toThrow(RuntimeException::class, 'Т84');
|
||||
|
||||
expect(DB::table('obzvon_number_results')->where('id', $itog->id)->value('outcome'))
|
||||
->toBe(ObzvonNumberResult::OUTCOME_TRANSFERRED);
|
||||
});
|
||||
|
||||
test('🔴 update по строке мимо двери НЕ проходит', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$itog = ObzvonNumberResult::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'campaign_id' => 1,
|
||||
'phone' => '79001110002',
|
||||
]);
|
||||
(new ItogPoNomeru)->zapisat($itog, ObzvonNumberResult::OUTCOME_CALLBACK, Carbon::parse('2026-08-06 10:00:00'));
|
||||
|
||||
expect(fn () => $itog->update(['outcome' => ObzvonNumberResult::OUTCOME_NO_ANSWER]))
|
||||
->toThrow(RuntimeException::class);
|
||||
|
||||
expect(DB::table('obzvon_number_results')->where('id', $itog->id)->value('outcome'))
|
||||
->toBe(ObzvonNumberResult::OUTCOME_CALLBACK);
|
||||
});
|
||||
|
||||
test('пол пропускает то, что обязан пропускать', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
// 1. Первая запись поверх пустоты — двенадцать недозвонов обязаны дать итог.
|
||||
$pervyy = ObzvonNumberResult::create(['tenant_id' => $tenant->id, 'campaign_id' => 1, 'phone' => '79001110003']);
|
||||
$pervyy->outcome = ObzvonNumberResult::OUTCOME_NO_ANSWER;
|
||||
$pervyy->save();
|
||||
expect($pervyy->fresh()->outcome)->toBe(ObzvonNumberResult::OUTCOME_NO_ANSWER);
|
||||
|
||||
// 2. Недозвон поверх недозвона — обычный ход.
|
||||
$pervyy->outcome_at = Carbon::parse('2026-08-06 12:00:00');
|
||||
$pervyy->outcome = ObzvonNumberResult::OUTCOME_NO_ANSWER;
|
||||
$pervyy->save();
|
||||
|
||||
// 3. Разговор поверх недозвона — разрешено и обязано пройти.
|
||||
$pervyy->outcome = ObzvonNumberResult::OUTCOME_REFUSED;
|
||||
$pervyy->save();
|
||||
expect($pervyy->fresh()->outcome)->toBe(ObzvonNumberResult::OUTCOME_REFUSED);
|
||||
|
||||
// 4. Очистка исхода — право на обезличивание сильнее Т84.
|
||||
$pervyy->outcome = null;
|
||||
$pervyy->save();
|
||||
expect($pervyy->fresh()->outcome)->toBeNull();
|
||||
|
||||
// 5. Правка соседнего поля не трогает исход и не должна падать.
|
||||
$vtoroy = ObzvonNumberResult::create(['tenant_id' => $tenant->id, 'campaign_id' => 1, 'phone' => '79001110004']);
|
||||
(new ItogPoNomeru)->zapisat($vtoroy, ObzvonNumberResult::OUTCOME_TRANSFERRED);
|
||||
$vtoroy->update(['attempts_count' => 5]);
|
||||
expect($vtoroy->fresh()->attempts_count)->toBe(5);
|
||||
});
|
||||
|
||||
test('дверь пола не задевает — она никогда не делает запрещённого перехода', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$itog = ObzvonNumberResult::create(['tenant_id' => $tenant->id, 'campaign_id' => 1, 'phone' => '79001110005']);
|
||||
$dver = new ItogPoNomeru;
|
||||
|
||||
$dver->zapisat($itog, ObzvonNumberResult::OUTCOME_TRANSFERRED, Carbon::parse('2026-08-06 10:00:00'));
|
||||
|
||||
// Дверь обязана ОТБИТЬ недозвон, а не упасть: отбитие это штатный ход.
|
||||
expect($dver->zapisat($itog, ObzvonNumberResult::OUTCOME_NO_ANSWER, Carbon::parse('2026-08-06 18:00:00')))
|
||||
->toBeFalse();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. СВЯЗЬ ПОЛА И ДВЕРИ — два места применения правила не должны разъехаться
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('пол и дверь отвечают одинаково на всех 64 парах исходов', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$nomer = 0;
|
||||
foreach (ObzvonNumberResult::OUTCOMES as $staryy) {
|
||||
foreach (ObzvonNumberResult::OUTCOMES as $novyy) {
|
||||
$nomer++;
|
||||
$itog = ObzvonNumberResult::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'campaign_id' => 900,
|
||||
'phone' => '7901'.str_pad((string) $nomer, 7, '0', STR_PAD_LEFT),
|
||||
]);
|
||||
|
||||
// Ставим прежний исход в обход двери — через базу, чтобы пол не
|
||||
// мешал готовить заготовку.
|
||||
DB::table('obzvon_number_results')->where('id', $itog->id)
|
||||
->update(['outcome' => $staryy, 'outcome_at' => now()]);
|
||||
$itog->refresh();
|
||||
|
||||
$dverPropustit = RaskladkaIskhodov::perebivaet($staryy, $novyy);
|
||||
|
||||
$itog->outcome = $novyy;
|
||||
$polUpal = false;
|
||||
try {
|
||||
$itog->save();
|
||||
} catch (RuntimeException) {
|
||||
$polUpal = true;
|
||||
}
|
||||
|
||||
// Пол падает РОВНО на запрещённом Т84 переходе — и ровно тогда же
|
||||
// дверь отвечает «не перебивает» по правилу 1.
|
||||
$zapreshcheno = RaskladkaIskhodov::proPopytku($novyy) && RaskladkaIskhodov::proNomer($staryy);
|
||||
|
||||
expect($polUpal)->toBe(
|
||||
$zapreshcheno,
|
||||
"пара {$staryy} → {$novyy}: пол ответил не так, как требует Т84"
|
||||
);
|
||||
|
||||
if ($zapreshcheno) {
|
||||
expect($dverPropustit)->toBeFalse("пара {$staryy} → {$novyy}: дверь разошлась с полом");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. ЧТО ПОЛУ НЕ ВИДНО — массовая правка и построитель запросов
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('🔴 в боевом коде нет ни одной записи исхода массовой правкой или построителем', function () {
|
||||
$najdeno = mimoDveriNajti(base_path('app'));
|
||||
|
||||
expect($najdeno)->toBe(
|
||||
[],
|
||||
"Запись исхода мимо двери и мимо пола:\n".implode("\n", $najdeno)
|
||||
."\nПисать исход можно только через App\\Services\\Obzvon\\ItogPoNomeru::zapisat."
|
||||
);
|
||||
});
|
||||
|
||||
test('сторож мимо-двери видит подсунутую ему запись — доказано, а не обещано', function () {
|
||||
// 🔴 Сторож, которого не видели красным, не сторожит ничего. Здесь он
|
||||
// краснеет на подкладке НЕ вырезанием лечения, а подсунутой уликой: так
|
||||
// проверяется сам прибор, а не то, что вокруг.
|
||||
$vremennaya = base_path('storage/framework/testing/mimo-dveri-proba');
|
||||
@mkdir($vremennaya, 0777, true);
|
||||
$fayl = $vremennaya.'/PodstavnoyPisatel.php';
|
||||
|
||||
file_put_contents($fayl, <<<'PHP'
|
||||
<?php
|
||||
|
||||
class PodstavnoyPisatel
|
||||
{
|
||||
public function pisat(): void
|
||||
{
|
||||
DB::table('obzvon_number_results')
|
||||
->where('id', 1)
|
||||
->update(['outcome' => 'no_answer']);
|
||||
}
|
||||
}
|
||||
PHP);
|
||||
|
||||
try {
|
||||
$najdeno = mimoDveriNajti($vremennaya);
|
||||
|
||||
expect($najdeno)->toHaveCount(1)
|
||||
->and($najdeno[0])->toContain('PodstavnoyPisatel.php')
|
||||
// Имя файла мало — сторож обязан назвать СТРОКУ.
|
||||
->and($najdeno[0])->toMatch('/:\d+/');
|
||||
} finally {
|
||||
@unlink($fayl);
|
||||
@rmdir($vremennaya);
|
||||
}
|
||||
});
|
||||
|
||||
test('сторож мимо-двери не поднимает ложной тревоги на честном коде', function () {
|
||||
// Обезличивание правит `phone` массово и обязано проходить: сторож смотрит
|
||||
// на исход, а не на любую правку таблицы.
|
||||
$vremennaya = base_path('storage/framework/testing/mimo-dveri-chestnyy');
|
||||
@mkdir($vremennaya, 0777, true);
|
||||
$fayl = $vremennaya.'/ChestnyyPisatel.php';
|
||||
|
||||
file_put_contents($fayl, <<<'PHP'
|
||||
<?php
|
||||
|
||||
class ChestnyyPisatel
|
||||
{
|
||||
public function obezlichit(): void
|
||||
{
|
||||
DB::table('obzvon_number_results')
|
||||
->where('id', 1)
|
||||
->update(['phone' => '+70000000000']);
|
||||
}
|
||||
}
|
||||
PHP);
|
||||
|
||||
try {
|
||||
expect(mimoDveriNajti($vremennaya))->toBe([]);
|
||||
} finally {
|
||||
@unlink($fayl);
|
||||
@rmdir($vremennaya);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Обход кода в поисках записи исхода мимо объекта модели.
|
||||
*
|
||||
* 🔴 Белого списка «кому можно» здесь НЕТ и быть не должно. Дверь пишет через
|
||||
* объект модели, а не массовой правкой, поэтому запрет действует на ВСЕХ без
|
||||
* исключений — включая саму дверь. Список разрешённых имён сам стал бы дырой.
|
||||
*
|
||||
* @return list<string> найденные места в виде «файл:строка — кусок»
|
||||
*/
|
||||
function mimoDveriNajti(string $koren): array
|
||||
{
|
||||
if (! is_dir($koren)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$najdeno = [];
|
||||
$obhod = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($koren, FilesystemIterator::SKIP_DOTS));
|
||||
|
||||
/** @var SplFileInfo $fayl */
|
||||
foreach ($obhod as $fayl) {
|
||||
if ($fayl->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tekst = (string) file_get_contents($fayl->getPathname());
|
||||
|
||||
// Файл самой модели пропускаем: в нём поле `outcome` упоминается по
|
||||
// делу — там стоит пол. Это НЕ разрешение писать, а отказ читать
|
||||
// объявление столбца как его запись.
|
||||
if (str_contains($tekst, 'class ObzvonNumberResult extends Model')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (mimoDveriUlikiVTekste($tekst) as [$stroka, $kusok]) {
|
||||
$najdeno[] = $fayl->getPathname().':'.$stroka.' — '.$kusok;
|
||||
}
|
||||
}
|
||||
|
||||
sort($najdeno);
|
||||
|
||||
return $najdeno;
|
||||
}
|
||||
|
||||
/**
|
||||
* Улики внутри одного файла: выражение, которое ОДНОВРЕМЕННО называет таблицу
|
||||
* итогов и пишет в столбец исхода, минуя объект модели.
|
||||
*
|
||||
* @return list<array{0: int, 1: string}>
|
||||
*/
|
||||
function mimoDveriUlikiVTekste(string $tekst): array
|
||||
{
|
||||
$uliki = [];
|
||||
|
||||
// Обращения к таблице итогов: и построителем, и через модель.
|
||||
$nachala = [];
|
||||
foreach (['obzvon_number_results', 'ObzvonNumberResult'] as $imya) {
|
||||
$smeshchenie = 0;
|
||||
while (($poziciya = strpos($tekst, $imya, $smeshchenie)) !== false) {
|
||||
$nachala[] = $poziciya;
|
||||
$smeshchenie = $poziciya + 1;
|
||||
}
|
||||
}
|
||||
sort($nachala);
|
||||
|
||||
foreach ($nachala as $nachalo) {
|
||||
// Выражение — до ближайшей точки с запятой. Цепочка `->where(...)
|
||||
// ->update([...])` переносится на несколько строк, и построчный поиск
|
||||
// её бы не увидел.
|
||||
$konec = strpos($tekst, ';', $nachalo);
|
||||
$vyrazhenie = $konec === false ? substr($tekst, $nachalo) : substr($tekst, $nachalo, $konec - $nachalo);
|
||||
|
||||
$pishet = str_contains($vyrazhenie, '->update(')
|
||||
|| str_contains($vyrazhenie, '->upsert(')
|
||||
|| str_contains($vyrazhenie, '->updateOrInsert(');
|
||||
|
||||
if (! $pishet || ! str_contains($vyrazhenie, 'outcome')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$uliki[] = [substr_count($tekst, "\n", 0, $nachalo) + 1, trim(preg_replace('/\s+/', ' ', $vyrazhenie) ?? '')];
|
||||
}
|
||||
|
||||
// Сырой SQL по той же таблице.
|
||||
if (preg_match_all('/update\s+obzvon_number_results/i', $tekst, $sovpadeniya, PREG_OFFSET_CAPTURE)) {
|
||||
foreach ($sovpadeniya[0] as [$kusok, $poziciya]) {
|
||||
$uliki[] = [substr_count($tekst, "\n", 0, (int) $poziciya) + 1, 'сырой SQL: '.$kusok];
|
||||
}
|
||||
}
|
||||
|
||||
return $uliki;
|
||||
}
|
||||
@@ -340,17 +340,32 @@
|
||||
|
||||
---
|
||||
|
||||
## Временные значения для двух вопросов владельца
|
||||
## Два вопроса владельца — ЗАКРЫТЫ 06.08.2026, оба совпали с поставленным
|
||||
|
||||
Оба живут в `app/config/obzvon.php`, в разделе `kartochka`. Переиграть — поправить одно
|
||||
число, минута.
|
||||
Владелец закрыл обе развилки, и оба решения совпали с тем, что я поставил временно.
|
||||
**Кода не менял ни строки** — только подписи в `app/config/obzvon.php`, чтобы следующая
|
||||
смена не пошла переспрашивать закрытое. Значения перечитаны после правки командой:
|
||||
порог `1`, глубина истории `NULL` — те же.
|
||||
|
||||
| вопрос | что поставил временно | почему так и что стоит спросить |
|
||||
| решение | значение | что закрыто |
|
||||
|---|---|---|
|
||||
| **Где кончается «хоть секунда разговора»** | `minimalnaya_dlitelnost_sec = 1` | «Ноль секунд — не разговор, одна секунда — разговор». 🔴 Про 0,4 секунды: длительность в базе — **целые секунды** (`leg1_duration_sec INTEGER`), так что 0,4 приедет либо нулём, либо единицей — как округлит робот. Значит вопрос владельцу правильнее звучит так: **«сколько целых секунд считать разговором — 1, 3 или 5?»** Порог 3–5 отсёк бы «снял и сразу бросил» |
|
||||
| **Сколько прошлых кампаний показывать** | `kampaniy_v_kartochke = null` (все) | Р113 требует, чтобы прошлое было **видно**; прятать историю по своему усмотрению помощник не вправе. Если владелец скажет «одну» — поставить `1`, и карточка сама скажет вслух «Прошлых списков скрыто: N», а не потеряет их молча |
|
||||
| **Р120** от 06.08.2026 | `minimalnaya_dlitelnost_sec = 1` | разговором считается контакт от ОДНОЙ секунды |
|
||||
| **Р121** от 06.08.2026 | `kampaniy_v_kartochke = null` | показываем ВСЕ прошлые списки |
|
||||
|
||||
---
|
||||
🔑 Про Р120 отдельно: развилки «0,4 секунды» **не существовало вовсе** — длительность в
|
||||
базе целыми секундами. Мой замер переписал сам вопрос, владелец выбирал из «одна, три
|
||||
или пять» и отрезал три и пять по доводу: короче порога сброс сливается с недозвоном и
|
||||
становится невидимым, а сброс — это **ответ** человека, тогда как недозвон — его
|
||||
отсутствие.
|
||||
|
||||
🪤 **Чего решения НЕ закрывают — вписано рядом с каждым значением в самой настройке:**
|
||||
|
||||
- у порога в секунду: лента станет **длиннее**, чем при любом другом пороге, — короткие
|
||||
сбросы тоже строки, и сворачивать их никто не назначал; случай «соединилось и
|
||||
оборвалось до первого звука» ни разу не замерен на живых звонках;
|
||||
- у глубины истории: карточка **растёт без предела** — у номера в десятке списков лента
|
||||
вытянется. И отдельно: итог привязан к **списку, а не к человеку**, поэтому при смене
|
||||
владельца номера в карточке смешается история двух РАЗНЫХ людей. Это не обсуждали вовсе.
|
||||
|
||||
## Что осталось незакрытым
|
||||
|
||||
@@ -360,24 +375,45 @@
|
||||
2. 🔴 **Живой работы модуля нет.** Робот не звонит, итог по номеру не пишет никто. Всё
|
||||
проверено на данных, **заведённых руками**. Приёмкой на боевых данных это не является
|
||||
и за неё не выдаётся.
|
||||
3. 🟡 **Толкование Т84 про пустую карточку** (см. «Как решена главная ловушка») — назвал
|
||||
3. 🔴 **Итог привязан к СПИСКУ, а не к человеку** (открылось при закрытии Р121). Номер
|
||||
может сменить владельца, и тогда карточка сольёт историю двух разных людей в одну
|
||||
ленту. Это не обсуждали вовсе, и решения на это нет. Замок
|
||||
`UNIQUE (tenant_id, campaign_id, phone)` тут не помощник — он про список, а вопрос
|
||||
про человека.
|
||||
4. 🟡 **Толкование Т84 про пустую карточку** (см. «Как решена главная ловушка») — назвал
|
||||
допущение вслух; если владелец имел в виду строже, это правка одной строки настройки.
|
||||
4. 🟡 **`obzvon_calls.attempt_outcome`** — второе место того же класса ловушки. Не чинил,
|
||||
чужой круг.
|
||||
5. 🟡 **Надпись «Backend недоступен — показаны mock-события»** врёт при пустом ответе.
|
||||
5. 🟡 **`obzvon_calls.attempt_outcome`** — второе место того же класса ловушки. Не чинил,
|
||||
чужой круг: там четыре значения и свой пишущий, и лечить это надо вместе с ним.
|
||||
Надзиратель записал в бумаги как незакрытое — подтверждаю, согласен.
|
||||
6. 🟡 **Надпись «Backend недоступен — показаны mock-события»** врёт при пустом ответе.
|
||||
Чужая беда, не трогал.
|
||||
6. 🔴 **Чужой красный сторож `telegram-media-cena.spec.ts`** после соседского коммита
|
||||
`337f6b989`. Не мой круг, но смене телеграма о нём надо сказать.
|
||||
7. 🔴 **Две смены правят одно дерево, и одна молча стирает другую.** Сегодня стёрли меня;
|
||||
7. 🟢 **Чужой красный сторож `telegram-media-cena.spec.ts` — закрыт соседями.** Был
|
||||
красным после их коммита `337f6b989`; в последнем полном прогоне интерфейса зелёный.
|
||||
Оставляю строкой как след, а не как долг.
|
||||
8. 🔴 **Две смены правят одно дерево, и одна молча стирает другую.** Сегодня стёрли меня;
|
||||
завтра сотрут кого угодно. Уцелевает только то, чего git ещё не знает, а сделанная работа в
|
||||
отслеживаемых файлах уходит без следа и без заначки. Это не мой круг чинить, но это
|
||||
уже **второй** случай затирания между сменами в этой ветке.
|
||||
8. 🟡 **Обёртка фонового прогона печатала `EXIT=0` при настоящем выходе 255.** Числа из
|
||||
9. 🟡 **Обёртка фонового прогона печатала `EXIT=0` при настоящем выходе 255.** Числа из
|
||||
фоновых задач надо читать из самого файла вывода, а не из сводки задачи.
|
||||
9. 🔴 **Работа смены рекламы `0f3e9170d` сейчас НЕ в ветке** — её сняло то же двойное
|
||||
отступление назад. Девять файлов, среди них два новых сторожа. Чужое я не трогаю,
|
||||
но об этом надо сказать им сегодня же.
|
||||
10. 🔴 **За один круг мою работу снесли ДВАЖДЫ, разными способами** — сперва откатили правки в
|
||||
10. 🟢 **ТРЕВОГА СНЯТА ВТОРЫМ ЗАМЕРОМ: работа смены рекламы НЕ потеряна.** Я поднял
|
||||
тревогу, что коммит `0f3e9170d` ушёл с ветки. Он вправду вне ветки — но соседи
|
||||
переложили ту же работу заново коммитом `07a17b276`. Перемерил своей рукой:
|
||||
`07a17b276` — предок HEAD, все девять файлов на месте. Будить никого не надо.
|
||||
🔑 Урок мой, не чужой: **я видел правду СВОЕГО мгновения**. Дерево при работающих
|
||||
рядом помощниках — вещь движущаяся, и приговор о чужой потере берётся ВТОРЫМ
|
||||
замером, после конца работы. Сказать громко было правильно; оставить без второго
|
||||
замера было бы неправильно.
|
||||
11. 🔴 **ВОПРОС ВЛАДЕЛЬЦУ: замок на переход исхода в самой базе.** Пол в модели и обход
|
||||
кода защищают только код портала. Правку прямым SQL — из `psql`, из миграции, из
|
||||
чужой службы — не поймает ни один из них. Полная защита ровно одна: триггер в
|
||||
PostgreSQL, запрещающий переход исхода «про номер» → «про попытку». Это правка канона
|
||||
схемы, поэтому названо и **не сделано молча**.
|
||||
12. 🔴 **Общая тестовая база на две смены роняет чужой прогон.** Не «датчик врёт», а
|
||||
прямая порча: мой прогон начал сносить таблицы под работающим прогоном надзирателя.
|
||||
Завёл свою `liderra_testing_z24`. Порядок «у каждой смены своя база» стоит записать
|
||||
в правила, а не держать в памяти.
|
||||
13. 🔴 **За один круг мою работу снесли ДВАЖДЫ, разными способами** — сперва откатили правки в
|
||||
дереве, потом сняли готовый коммит с ветки. Ни одно не сопровождалось ни словом. При
|
||||
такой работе «сделано» не значит «сохранено», и проверять надо не только прогон, но и
|
||||
то, что твой коммит всё ещё в ветке.
|
||||
|
||||
Reference in New Issue
Block a user