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:
Дмитрий
2026-08-06 21:10:53 +03:00
parent 5d33b0ceea
commit df8c4720cb
4 changed files with 544 additions and 36 deletions
+97
View File
@@ -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
View File
@@ -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. 🔴 **За один круг мою работу снесли ДВАЖДЫ, разными способами** — сперва откатили правки в
дереве, потом сняли готовый коммит с ветки. Ни одно не сопровождалось ни словом. При
такой работе «сделано» не значит «сохранено», и проверять надо не только прогон, но и
то, что твой коммит всё ещё в ветке.