feat обзвон: закрыта запись исхода через связь — третий пол и якорь по столбцу

Нож надзирателя пробил обе прежние половины. Случай: запись через связь,
одной строкой, где нет ни имени класса, ни имени таблицы. Пол в модели молчал,
потому что это построитель, а не объект; обход кода не начинался, потому что
искал имена, которых в выражении нет. Проверил своим ножом: сторож дал восемь
из восьми зелёных при живой дыре.

Закрыто двумя вещами, и первая — не то, о чём просили.

Свой построитель Eloquent, подставляемый моделью. Он ловит массовую правку на
самой записи, разом все три формы: через модель, через связь и через новый
запрос от строки. Имён переменных не читает вовсе. Проверяет по факту, а не по
намерению: спрашивает базу, есть ли среди строк под правку хоть одна с
состоявшимся разговором. Массовая правка тем и опасна, что пишущий не знает,
что лежит в каждой строке, — значит знать обязан пол.

Третий якорь в обходе кода — сам столбец. Замер надзирателя перемерил своей
рукой и подтвердил: столбец ровно outcome есть во всём каноне схемы у одной
таблицы, в миграциях его нет вовсе, у соседней таблицы он зовётся иначе.

Но якорь в предложенном виде дал бы ложную красноту, и я его поправил дважды.
Подстрока без кавычек совпала бы с именем столбца соседней таблицы — беру ключ
в кавычках. И главное: ключ уникален в схеме, но НЕ уникален в коде — он есть
ещё в пяти местах, и одно из них пояснение в шапке самой двери, где дословно
написан образец запрещённого. Обход по тексту покраснел бы на самой двери.
Поэтому комментарии убираются разбором PHP, а не вычёркиванием по образцу:
разборщик отличает комментарий от такой же строки внутри кавычек. Ложная
краснота хуже дыры: она приучает не смотреть.

Список исходов про номер считается вычитанием из восьми, а не вторым списком:
допишут девятый и забудут разложить — он окажется под защитой, а не мимо неё.

Белого списка нет ни в одной из трёх половин. Исключений в обходе ровно два и
оба по устройству: файлы, где полы и стоят.

Чего не ловит и этот якорь, названо и в отчёте, и в подписи к сторожу: правку
прямым SQL мимо портала, ключ и имя таблицы, собранные из кусков, столбец,
переименованный в будущем, и код вне папки приложения. Полную защиту даст
только замок в самой базе — это вопрос владельцу, схему не трогал.

Новая половина показана красной ножом надзирателя: сторож назвал файл, строку
и само выражение. Возврат доказан пустым состоянием дерева и слепками.

Прогоны: полный 5166, зелёных 5162, красных ноль, ошибок ноль, арифметика
сходится. Обзвон и сделки 447. Сторож мимо-двери 13 из 13. Статанализ ноль,
deptrac ноль.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-08-06 22:24:16 +03:00
parent 6d97a9691a
commit 6e775055d9
4 changed files with 517 additions and 21 deletions
+42 -4
View File
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use RuntimeException;
@@ -232,12 +233,49 @@ class ObzvonNumberResult extends Model
/**
* Раскладка берётся из настройки того же места, откуда её берёт дверь.
* Своего списка у модели нет: два списка однажды разъедутся.
*
* Открыт наружу, потому что тем же ответом пользуется пол для массовой
* правки `ObzvonNumberResultBuilder`. Два пола, одно чтение настройки.
*/
private static function iskhodProPopytku(string $outcome): bool
public static function iskhodProPopytku(string $outcome): bool
{
/** @var list<string> $proPopytku */
$proPopytku = (array) config('obzvon.raskladka.pro_popytku', []);
return in_array($outcome, self::spisokIskhodov('pro_popytku'), true);
}
return in_array($outcome, $proPopytku, true);
/**
* Исходы «про номер» те, что означают состоявшийся разговор.
*
* Считается ВЫЧИТАНИЕМ из восьми, а не вторым списком в настройке: если
* кто-то допишет девятый исход и забудет разложить его по родам, он
* окажется «про номер», то есть под защитой, а не мимо неё. Ошибаться
* следует в сторону охраны.
*
* @return list<string>
*/
public static function iskhodyProNomer(): array
{
$proPopytku = self::spisokIskhodov('pro_popytku');
return array_values(array_diff(self::OUTCOMES, $proPopytku));
}
/** @return list<string> */
private static function spisokIskhodov(string $rod): array
{
/** @var array<int, mixed> $znacheniya */
$znacheniya = (array) config('obzvon.raskladka.'.$rod, []);
return array_values(array_filter($znacheniya, 'is_string'));
}
/**
* Все запросы к итогам идут через свой построитель в нём стоит пол под
* массовую правку, включая запись через связь.
*
* @param Builder $query
*/
public function newEloquentBuilder($query): ObzvonNumberResultBuilder
{
return new ObzvonNumberResultBuilder($query);
}
}
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use RuntimeException;
/**
* Пол под Т84 для МАССОВОЙ правки итогов по номеру.
*
* 🔴 ОТ КАКОГО ВРЕДА, и почему пола в самой модели оказалось мало. Пол в
* `ObzvonNumberResult::booted` стоит на событии сохранения ОБЪЕКТА. А запись
* можно сделать, объекта не создавая:
*
* $sdelka->obzvonItogi()->update(['outcome' => 'no_answer']);
*
* Это построитель запросов, событий сохранения он не шлёт, и пол молчал бы.
* Хуже того в этом выражении **нет ни имени класса, ни имени таблицы**,
* поэтому и обход кода по этим именам его не находил: замерено ножом
* надзирателя 06.08.2026, сторож дал 8 из 8 зелёных при живой дыре.
*
* 🔑 И это самый вероятный способ записи из всех. Связи `Deal → итоги обзвона`
* сегодня нет, но первое, что сделает смена, доводящая обзвон до сервера,
* заведёт её. Тогда самый естественный способ записи оказался бы единственным,
* которого никто не сторожит.
*
* Здесь запрет стоит на самом построителе Eloquent. Он ловит все три формы
* сразу, потому что все они идут через `newEloquentBuilder`:
* `ObzvonNumberResult::where(...)->update([...])`;
* `$sdelka->obzvonItogi()->update([...])` через связь;
* `$itog->newQuery()->update([...])`.
* Имён переменных он не читает вовсе стоит на самой записи.
*
* 🪤 ЧЕГО НЕ ЛОВИТ И ЭТОТ ПОЛ: `DB::table('obzvon_number_results')->update(...)`
* это построитель запросов Laravel, а не Eloquent, и он проходит мимо. Его
* стережёт обход кода `tests/Feature/Obzvon/ObzvonZapisMimoDveriTest.php`.
* Полного покрытия не даёт ни один из трёх см. перечень в шапке того файла.
*
* Белого списка «кому можно» здесь нет: проверяется СМЫСЛ перехода, а не автор.
*
* @extends Builder<ObzvonNumberResult>
*/
class ObzvonNumberResultBuilder extends Builder
{
/**
* @param array<string, mixed> $values
*/
public function update(array $values): int
{
$this->proveritPolT84($values);
return parent::update($values);
}
/**
* Запрещён ровно один переход: исход «про попытку» поверх строки, которая
* уже держит исход «про номер». Всё остальное пропускается.
*
* 🔴 Считаем ПО ФАКТУ, а не по намерению: спрашиваем базу, есть ли среди
* строк под правку хоть одна с состоявшимся разговором. Массовая правка
* тем и опасна, что пишущий не знает, что лежит в каждой строке, значит
* знать обязан пол.
*
* @param array<string, mixed> $values
*/
private function proveritPolT84(array $values): void
{
if (! array_key_exists('outcome', $values)) {
return;
}
$novyy = $values['outcome'];
// Очистка исхода — право на обезличивание сильнее Т84.
if (! is_string($novyy)) {
return;
}
if (! ObzvonNumberResult::iskhodProPopytku($novyy)) {
return;
}
$proNomer = ObzvonNumberResult::iskhodyProNomer();
if ($proNomer === []) {
return;
}
$zatryot = (clone $this)->whereIn('outcome', $proNomer)->count();
if ($zatryot === 0) {
return;
}
throw new RuntimeException(
'Запрещено требованием Т84: массовая правка ставит исход попытки «'.$novyy
.'» поверх состоявшегося разговора у '.$zatryot.' строк. Записывать исход '
.'можно только через App\Services\Obzvon\ItogPoNomeru::zapisat — он решает '
.'по каждому номеру отдельно, перебивает новый исход прежний или проходит '
.'бесследно.'
);
}
}
@@ -92,6 +92,58 @@ test('🔴 update по строке мимо двери НЕ проходит',
->toBe(ObzvonNumberResult::OUTCOME_CALLBACK);
});
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' => '79001110010',
]);
(new ItogPoNomeru)->zapisat($itog, ObzvonNumberResult::OUTCOME_TRANSFERRED, Carbon::parse('2026-08-06 10:00:00'));
// 1. Массовая правка через модель.
expect(fn () => ObzvonNumberResult::query()->whereKey($itog->id)
->update(['outcome' => ObzvonNumberResult::OUTCOME_NO_ANSWER]))
->toThrow(RuntimeException::class, 'Т84');
// 2. 🔴 Через СВЯЗЬ — ножевой случай надзирателя. Связи `Deal → итоги` в
// портале ещё нет, поэтому берём ту же дорогу, какой пойдёт она:
// построитель, полученный от модели, с внешним условием.
$svyaz = $itog->newQuery()->where('tenant_id', $tenant->id);
expect(fn () => $svyaz->update(['outcome' => ObzvonNumberResult::OUTCOME_NO_ANSWER]))
->toThrow(RuntimeException::class, 'Т84');
expect(DB::table('obzvon_number_results')->where('id', $itog->id)->value('outcome'))
->toBe(ObzvonNumberResult::OUTCOME_TRANSFERRED);
});
test('массовая правка пропускает то, что обязана пропускать', function () {
$tenant = Tenant::factory()->create();
DB::statement('SET app.current_tenant_id = '.$tenant->id);
$pustoy = ObzvonNumberResult::create(['tenant_id' => $tenant->id, 'campaign_id' => 1, 'phone' => '79001110011']);
// 1. Недозвон поверх пустоты — двенадцать попыток обязаны дать итог.
ObzvonNumberResult::query()->whereKey($pustoy->id)
->update(['outcome' => ObzvonNumberResult::OUTCOME_NO_ANSWER]);
expect($pustoy->fresh()->outcome)->toBe(ObzvonNumberResult::OUTCOME_NO_ANSWER);
// 2. Разговор поверх недозвона — разрешено.
ObzvonNumberResult::query()->whereKey($pustoy->id)
->update(['outcome' => ObzvonNumberResult::OUTCOME_REFUSED]);
expect($pustoy->fresh()->outcome)->toBe(ObzvonNumberResult::OUTCOME_REFUSED);
// 3. Очистка — право на обезличивание сильнее Т84.
ObzvonNumberResult::query()->whereKey($pustoy->id)->update(['outcome' => null]);
expect($pustoy->fresh()->outcome)->toBeNull();
// 4. Правка соседнего поля не задевает исход.
ObzvonNumberResult::query()->whereKey($pustoy->id)->update(['attempts_count' => 7]);
expect($pustoy->fresh()->attempts_count)->toBe(7);
});
test('пол пропускает то, что обязан пропускать', function () {
$tenant = Tenant::factory()->create();
DB::statement('SET app.current_tenant_id = '.$tenant->id);
@@ -237,6 +289,99 @@ PHP);
}
});
test('🔴 запись через СВЯЗЬ — ни имени класса, ни имени таблицы — сторож ловит', function () {
// 🔴 Нож надзирателя 06.08.2026. До третьего якоря этот случай проходил
// мимо ОБЕИХ половин: пол молчал, потому что это построитель, а не объект;
// обход не начинался, потому что искал имена, которых в выражении нет.
$vremennaya = base_path('storage/framework/testing/mimo-dveri-svyaz');
@mkdir($vremennaya, 0777, true);
$fayl = $vremennaya.'/PisatelCherezSvyaz.php';
file_put_contents($fayl, <<<'PHP'
<?php
class PisatelCherezSvyaz
{
public function pisat($sdelka): void
{
$sdelka->obzvonItogi()->update(['outcome' => 'no_answer']);
}
}
PHP);
try {
$najdeno = mimoDveriNajti($vremennaya);
expect($najdeno)->toHaveCount(1)
->and($najdeno[0])->toContain('PisatelCherezSvyaz.php')
->and($najdeno[0])->toMatch('/:\d+/')
->and($najdeno[0])->toContain('obzvonItogi');
} finally {
@unlink($fayl);
@rmdir($vremennaya);
}
});
test('🔴 пояснение в шапке двери НЕ поднимает ложной тревоги', function () {
// Ложная краснота хуже дыры: она приучает не смотреть. В шапке
// `ItogPoNomeru` дословно написано `$itog->update(['outcome' => ...])` —
// как образец запрещённого. Обход обязан читать это как комментарий.
$vremennaya = base_path('storage/framework/testing/mimo-dveri-kommentariy');
@mkdir($vremennaya, 0777, true);
$fayl = $vremennaya.'/PisatelSPoyasneniem.php';
file_put_contents($fayl, <<<'PHP'
<?php
/**
* Зачем дверь, а не просто $itog->update(['outcome' => 'no_answer']).
*/
class PisatelSPoyasneniem
{
// И строчный тоже: ->update(['outcome' => 'refused'])
public function nichegoNePishet(): string
{
return 'здесь записи нет';
}
}
PHP);
try {
expect(mimoDveriNajti($vremennaya))->toBe([]);
} finally {
@unlink($fayl);
@rmdir($vremennaya);
}
});
test('🔴 правка СОСЕДНЕЙ таблицы по attempt_outcome не даёт ложной тревоги', function () {
// Замер, на котором стоит третий якорь: столбец ровно `outcome` есть во
// всём каноне только у `obzvon_number_results`, а у `obzvon_calls` он
// зовётся `attempt_outcome`. Подстрока без кавычек совпала бы и здесь.
$vremennaya = base_path('storage/framework/testing/mimo-dveri-sosed');
@mkdir($vremennaya, 0777, true);
$fayl = $vremennaya.'/PisatelSosedney.php';
file_put_contents($fayl, <<<'PHP'
<?php
class PisatelSosedney
{
public function pisat($zvonki): void
{
$zvonki->update(['attempt_outcome' => 'no_answer']);
}
}
PHP);
try {
expect(mimoDveriNajti($vremennaya))->toBe([]);
} finally {
@unlink($fayl);
@rmdir($vremennaya);
}
});
test('сторож мимо-двери не поднимает ложной тревоги на честном коде', function () {
// Обезличивание правит `phone` массово и обязано проходить: сторож смотрит
// на исход, а не на любую правку таблицы.
@@ -290,12 +435,13 @@ function mimoDveriNajti(string $koren): array
continue;
}
$tekst = (string) file_get_contents($fayl->getPathname());
$tekst = mimoDveriBezKommentariev((string) file_get_contents($fayl->getPathname()));
// Файл самой модели пропускаем: в нём поле `outcome` упоминается по
// делу — там стоит пол. Это НЕ разрешение писать, а отказ читать
// объявление столбца как его запись.
if (str_contains($tekst, 'class ObzvonNumberResult extends Model')) {
// Файл самой модели и её построителя пропускаем: в них поле `outcome`
// упоминается по делу — там стоят полы. Это НЕ разрешение писать, а
// отказ читать объявление столбца как его запись.
if (str_contains($tekst, 'class ObzvonNumberResult extends Model')
|| str_contains($tekst, 'class ObzvonNumberResultBuilder extends Builder')) {
continue;
}
@@ -310,8 +456,44 @@ function mimoDveriNajti(string $koren): array
}
/**
* Улики внутри одного файла: выражение, которое ОДНОВРЕМЕННО называет таблицу
* итогов и пишет в столбец исхода, минуя объект модели.
* Убрать из кода комментарии, оставив строки и сам код на своих местах.
*
* 🔴 ЗАЧЕМ. В шапке двери `ItogPoNomeru` стоит пояснение, которое ДОСЛОВНО
* содержит `$itog->update(['outcome' => ...])` как образец того, чего делать
* нельзя. Обход, читающий текст как есть, поднял бы на этом ложную тревогу и
* покраснел бы на самой двери. Ложная краснота хуже дыры: она приучает не
* смотреть.
*
* Комментарии убираются разбором PHP, а не вычёркиванием по образцу: разборщик
* отличает комментарий от такой же строки внутри кавычек, а образец нет.
* Длина текста сохраняется пробелами вместо вырезанного, иначе номера строк
* в отчёте сторожа поехали бы.
*/
function mimoDveriBezKommentariev(string $tekst): string
{
$chisto = '';
foreach (token_get_all($tekst) as $token) {
if (is_array($token)) {
if ($token[0] === T_COMMENT || $token[0] === T_DOC_COMMENT) {
// Переводы строк оставляем, всё прочее заменяем пробелом.
$chisto .= preg_replace('/[^\n]/', ' ', $token[1]);
continue;
}
$chisto .= $token[1];
continue;
}
$chisto .= $token;
}
return $chisto;
}
/**
* Улики внутри одного файла: выражение, которое ОДНОВРЕМЕННО пишет и относится
* к таблице итогов по имени таблицы, по имени класса либо по самому столбцу.
*
* @return list<array{0: int, 1: string}>
*/
@@ -319,9 +501,23 @@ function mimoDveriUlikiVTekste(string $tekst): array
{
$uliki = [];
// Обращения к таблице итогов: и построителем, и через модель.
// Три якоря, а не два.
//
// 🔴 Третий добавлен ножом надзирателя 06.08.2026. Запись через СВЯЗЬ —
// `$sdelka->obzvonItogi()->update(['outcome' => ...])` — не содержит ни
// имени класса, ни имени таблицы, и первые два якоря её не находили вовсе.
//
// Якорем служит сам столбец, и это безопасно, потому что ЗАМЕРЕНО:
// столбец ровно `outcome` есть во всём каноне схемы РОВНО У ОДНОЙ таблицы —
// `db/schema_modules.sql:1351`, `obzvon_number_results`. В миграциях его нет
// вовсе. У соседней `obzvon_calls` столбец зовётся `attempt_outcome`.
//
// 🪤 Ключ берётся В КАВЫЧКАХ, и это не украшение: подстрока `outcome` без
// кавычек совпала бы с `'attempt_outcome'` и дала бы ложную красноту на
// правках соседней таблицы. Ложная краснота хуже дыры — она приучает не
// смотреть.
$nachala = [];
foreach (['obzvon_number_results', 'ObzvonNumberResult'] as $imya) {
foreach (['obzvon_number_results', 'ObzvonNumberResult', "'outcome'", '"outcome"'] as $imya) {
$smeshchenie = 0;
while (($poziciya = strpos($tekst, $imya, $smeshchenie)) !== false) {
$nachala[] = $poziciya;
@@ -330,21 +526,45 @@ function mimoDveriUlikiVTekste(string $tekst): array
}
sort($nachala);
$vidennye = [];
foreach ($nachala as $nachalo) {
// Выражение — до ближайшей точки с запятой. Цепочка `->where(...)
// ->update([...])` переносится на несколько строк, и построчный поиск
// её бы не увидел.
// 🔴 Выражение берётся ЦЕЛИКОМ — от конца предыдущего до конца этого,
// а не «вперёд от якоря». Ключ `'outcome'` стоит ВНУТРИ вызова, то есть
// ПОСЛЕ `->update(`, и поиск только вперёд его бы не связал с записью.
// Цепочка `->where(...)->update([...])` вдобавок переносится на
// несколько строк, и построчный поиск не увидел бы её тоже.
$granica = 0;
foreach ([';', '{', '}'] as $znak) {
$poziciya = strrpos(substr($tekst, 0, $nachalo), $znak);
if ($poziciya !== false && $poziciya + 1 > $granica) {
$granica = $poziciya + 1;
}
}
$konec = strpos($tekst, ';', $nachalo);
$vyrazhenie = $konec === false ? substr($tekst, $nachalo) : substr($tekst, $nachalo, $konec - $nachalo);
$konec = $konec === false ? strlen($tekst) : $konec;
$vyrazhenie = substr($tekst, $granica, $konec - $granica);
// Один и тот же оператор находится по нескольким якорям сразу —
// показываем его один раз.
if (isset($vidennye[$granica])) {
continue;
}
$pishet = str_contains($vyrazhenie, '->update(')
|| str_contains($vyrazhenie, '->upsert(')
|| str_contains($vyrazhenie, '->updateOrInsert(');
if (! $pishet || ! str_contains($vyrazhenie, 'outcome')) {
$pro_nash_stolbec = str_contains($vyrazhenie, "'outcome'")
|| str_contains($vyrazhenie, '"outcome"');
if (! $pishet || ! $pro_nash_stolbec) {
continue;
}
$vidennye[$granica] = true;
$uliki[] = [substr_count($tekst, "\n", 0, $nachalo) + 1, trim(preg_replace('/\s+/', ' ', $vyrazhenie) ?? '')];
}
@@ -461,6 +461,135 @@
Круг 2 фронтенда не касался, поэтому проверки интерфейса не перегонял — числа круга 1
остаются в силе.
## Круг 3 — нож надзирателя пробил защиту: запись через связь
Надзиратель назвал допущение, на котором стояли обе мои половины: **«у всякой записи мимо
двери в выражении есть либо имя класса, либо имя таблицы»**. Случай мимо:
Случай записывается одной строкой: `$sdelka->obzvonItogi()->update(['outcome' => 'no_answer']);`
Ни имени класса, ни имени таблицы. Пол молчит — это построитель, а не объект. Обход не
начинается — искать нечего.
**Проверил его замер своим ножом, а не поверил на слово:** положил такой файл в
`app/app/Services/Obzvon/` и прогнал сторожа — **8 из 8 зелёных при живой дыре**. Дыра
подтверждена.
### 🔴 Проверка его якоря — и найденная в нём ошибка
Надзиратель предложил якорем сам столбец и подкрепил замером: `outcome` в чистом виде есть
ровно у одной таблицы канона. **Перемерил сам — замер верен:**
- `db/schema.sql` + `db/schema_modules.sql`: столбец ровно `outcome`**одно вхождение**,
`schema_modules.sql:1351`, таблица `obzvon_number_results`;
- в миграциях столбца `outcome` **нет вовсе**;
- у соседней `obzvon_calls` он зовётся `attempt_outcome`.
🔴 **Но якорь в предложенном виде дал бы ложную красноту, и я его поправил — два раза.**
1. **Подстрока `outcome` совпадает с `attempt_outcome`.** Правка соседней таблицы
покраснела бы ни за что. Лечение: якорь берётся **в кавычках**`'outcome'`. Сторож
на это заведён отдельный: правка `['attempt_outcome' => ...]` обязана молчать.
2. 🔴 **Ключ `'outcome'` уникален в СХЕМЕ, но НЕ уникален в КОДЕ.** Замерено: он есть ещё
в пяти местах, и одно из них — **пояснение в шапке моей же двери**, где дословно
написано `$itog->update(['outcome' => ...])` как образец запрещённого. Обход по тексту
покраснел бы **на самой двери**. Лечение: комментарии убираются **разбором PHP**
(`token_get_all`), а не вычёркиванием по образцу — разборщик отличает комментарий от
такой же строки внутри кавычек. Длина текста сохраняется пробелами, иначе номера строк
в отчёте поехали бы. На это тоже заведён отдельный сторож.
Прочие четыре места — записи в журнал и в письмо (`ImportCompletedNotification`,
`TelegramModerationVerdictApplier`) — не содержат `->update(` в том же выражении, поэтому
не срабатывают: якорь требует **обоих** признаков сразу.
### С чем не согласился: текстового якоря мало, нужен третий пол
Якорь по столбцу закрывает случай надзирателя **в обходе кода**. Но правка через связь —
это **запись, а не текст**, и ловить её текстом значит ловить не там, где она происходит.
Поэтому добавил то, чего он не просил:
**`ObzvonNumberResultBuilder`** — свой построитель Eloquent, подставляемый моделью через
`newEloquentBuilder`. Он ловит массовую правку **на самой записи**, разом все три формы:
- `ObzvonNumberResult::where(...)->update([...])`;
- `$sdelka->obzvonItogi()->update([...])`**тот самый случай надзирателя**;
- `$itog->newQuery()->update([...])`.
🔑 Имён переменных он не читает вовсе. И проверяет **по факту, а не по намерению**:
спрашивает базу, есть ли среди строк под правку хоть одна с состоявшимся разговором.
Массовая правка тем и опасна, что пишущий не знает, что лежит в каждой строке, — значит
знать обязан пол.
🔑 Мелочь, но важная: список «про номер» считается **вычитанием** из восьми, а не вторым
списком в настройке. Допишут девятый исход и забудут разложить — он окажется **под
защитой**, а не мимо неё. Ошибаться следует в сторону охраны.
### Три половины и что каждая не может
| ловит | чем | чего не может по устройству |
|---|---|---|
| запись через **объект** | пол в модели `booted` | массовую правку — событий нет |
| **массовую правку Eloquent**, включая через связь | пол в построителе `ObzvonNumberResultBuilder` | `DB::table(...)` — это не Eloquent |
| `DB::table(...)`, сырой SQL в коде | обход кода, три якоря | имя, собранное из кусков |
**Белого списка нет ни в одной из трёх.** Исключений в обходе ровно два, и оба по
устройству: файл модели и файл построителя — там, где полы и стоят. Это не разрешение
писать, а отказ читать объявление столбца как его запись.
### 🪤 Чего не ловит и якорь по столбцу — перечень полный
1. 🔴 **Правку прямым SQL мимо портала** — из `psql`, из миграции, из чужой службы.
Лечится **только замком в базе**. Вопрос владельцу остаётся открытым.
2. **Ключ, собранный из кусков:** `->update([$pole => 'no_answer'])` — якоря нет.
3. **Имя таблицы из кусков:** `$t = 'obzvon_'.'number_results'`.
4. **Столбец, переименованный в будущем.** Якорь держится на замере «в каноне `outcome`
один». Переименуют или заведут `outcome` второй таблице — якорь начнёт врать. Замер
записан рядом с якорем в коде, чтобы это было видно тому, кто станет менять.
5. **Код вне `app/app`** — например скрипт в `tools/`.
6. **Массовую правку через `DB::table`** — её видит только обход, и только пока имена
написаны буквами.
Пункты 2, 3 и 5 недостижимы для чтения текста в принципе. Перечень того, чего защита **не**
умеет, лежит и здесь, и в подписи к самому сторожу.
### 🪤 Ещё один врущий датчик — правописание
Проверка правописания дала **26 ошибок** на словах, которые в этом же отчёте стояли и
раньше и молчали. Разобрал вырезанием, а не догадкой: убрал из своего куска **обрамлённый
блок кода** — стало **ноль**; вернул один только обрамляющий значок без самой строки кода —
снова **22**. ⇒ Дело не в словах, а в самом блоке: пока в разметке есть обрамлённый кусок
кода, проверка перестаёт пропускать имена в одиночных значках по всему файлу. Переписал
случай строкой в значках — стало ноль.
🔑 И отдельно, к предупреждению надзирателя про исключённую папку: прямой вызов по пути
отчёта отвечает **«файлов: 0»** и выглядит успехом. Я всё время мерил временной копией в
корне — иначе не увидел бы ни одной из этих 26.
### Чем мерил — новая половина показана красной ножом надзирателя
| # | чем ронял | что сказал сторож |
|---|---|---|
| 1 | 🔴 положил **его случай**`$sdelka->obzvonItogi()->update(['outcome' => 'no_answer'])` — в `app/app/Services/Obzvon/NozhCherezSvyaz.php` | назвал **файл, строку 14 и само выражение**: `NozhCherezSvyaz.php:14 — $sdelka->obzvonItogi()->update(['outcome' => 'no_answer'])`. До третьего якоря на том же ноже было 8 из 8 зелёных |
Возврат доказан: нож удалён, `git status` по `app/app/Services/Obzvon/` **пуст**; сторож
снова 13 из 13. Слепки со снятием невидимых знаков конца строки:
`app/app/Models/ObzvonNumberResult.php``70ddeeaf…` до правки `pint`,
`ObzvonNumberResultBuilder.php``c732073813e60da39b7ddcd515c011948f4cf818`.
### Прогоны круга 3
- сторож мимо-двери: **13 из 13**, 101 утверждение — было 8, стало 13 на пять новых:
запись через связь в обходе, запись через связь и массовая правка в полу, пояснение
двери не даёт ложной тревоги, правка соседней таблицы не даёт ложной тревоги;
- обзвон и сделки целиком: **447, зелёных 446, 1 пропущен, красных 0**;
- статанализ **0**, deptrac **0 нарушений** — свой построитель не нарушил `Model: []`;
`pint` чист, разметка чиста, правописание чисто;
- **полный прогон: всего 5166 · зелёных 5162 · красных 0 · ошибок 0 · пропущено 4 ·
утверждений 16253 · выход 0.** Арифметика сходится: `5162 + 0 + 0 + 4 = 5166`.
Было 5140 — рост на 26: мои пять плюс то, что добавили соседи.
🔴 Прогон был один на базе за раз — сегодняшний урок усвоен и мной, и надзирателем.
## Два вопроса владельца — ЗАКРЫТЫ 06.08.2026, оба совпали с поставленным
Владелец закрыл обе развилки, и оба решения совпали с тем, что я поставил временно.
@@ -525,16 +654,20 @@
рядом помощниках — вещь движущаяся, и приговор о чужой потере берётся ВТОРЫМ
замером, после конца работы. Сказать громко было правильно; оставить без второго
замера было бы неправильно.
11. 🔴 **ВОПРОС ВЛАДЕЛЬЦУ: замок на переход исхода в самой базе.** Пол в модели и обход
11. 🔴 **Якорь по столбцу держится на замере, а не на устройстве.** Сегодня `outcome` в
каноне один. Переименуют столбец или заведут второй такой же другой таблице — сторож
начнёт врать: либо пропустит, либо покраснеет зря. Замер записан рядом с якорем в
коде, но это долг, а не решение.
12. 🔴 **ВОПРОС ВЛАДЕЛЬЦУ: замок на переход исхода в самой базе.** Пол в модели и обход
кода защищают только код портала. Правку прямым SQL — из `psql`, из миграции, из
чужой службы — не поймает ни один из них. Полная защита ровно одна: триггер в
PostgreSQL, запрещающий переход исхода «про номер» → «про попытку». Это правка канона
схемы, поэтому названо и **не сделано молча**.
12. 🔴 **Общая тестовая база на две смены роняет чужой прогон.** Не «датчик врёт», а
13. 🔴 **Общая тестовая база на две смены роняет чужой прогон.** Не «датчик врёт», а
прямая порча: мой прогон начал сносить таблицы под работающим прогоном надзирателя.
Завёл свою `liderra_testing_z24`. Порядок «у каждой смены своя база» стоит записать
в правила, а не держать в памяти.
13. 🔴 **За один круг мою работу снесли ДВАЖДЫ, разными способами** — сперва откатили правки в
14. 🔴 **За один круг мою работу снесли ДВАЖДЫ, разными способами** — сперва откатили правки в
дереве, потом сняли готовый коммит с ветки. Ни одно не сопровождалось ни словом. При
такой работе «сделано» не значит «сохранено», и проверять надо не только прогон, но и
то, что твой коммит всё ещё в ветке.