From 1c2195ebee33f2b95f548acaa201cfe25433342d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 5 Aug 2026 15:29:09 +0300 Subject: [PATCH] =?UTF-8?q?fix(=D0=BE=D0=B1=D0=B7=D0=B2=D0=BE=D0=BD):=20?= =?UTF-8?q?=D0=B4=D0=B2=D0=B0=20=D0=BD=D1=83=D0=BB=D1=8F=20=D0=B2=20=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D0=B8=D1=84=D0=B5=20=D1=80=D0=B0=D0=B7=D0=BE=D0=BC?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BF=D1=80=D0=B5=D1=89=D0=B5=D0=BD=D1=8B,=20?= =?UTF-8?q?=D0=BE=D0=B4=D0=B8=D0=BD=20=D0=BD=D0=BE=D0=BB=D1=8C=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B6=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Решение владельца Р90 (docs/grilling/2026-08-03-metodika-lena-pod-klienta.md): цена звонка — два поля, за соединение и за минуту. Оба нуля разом означали молча бесплатный обзвон: клиент не платит, а оператору за звонки платим мы. Одна цифра в нуле — осмысленный случай, например не брать отдельно за соединение. AdminObzvonTariffController::update() теперь сравнивает обе цены ПОСЛЕ перевода в копейки и отказывает с объяснением, если обе — ноль. Ловит «0», «0.00» и «0.0» одинаково, а не по виду строки. Прежние отказы (пустая, отрицательная, нечисловая цена) не тронуты. ObzvonTariffTest.php: +6 проверок «З-1.8». Сторож доказан красным дважды — до фикса (естественный RED) и после снятия запрета (искусственный RED), восстановление сверено побайтово через git hash-object. Co-Authored-By: Claude Opus 5 --- .../Api/Admin/AdminObzvonTariffController.php | 19 ++ app/tests/Feature/Obzvon/ObzvonTariffTest.php | 84 +++++++++ .../stroyka-1/z-1-8-dva-nulya-2026-08-05.md | 162 ++++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 docs/superpowers/priyomka/stroyka-1/z-1-8-dva-nulya-2026-08-05.md diff --git a/app/app/Http/Controllers/Api/Admin/AdminObzvonTariffController.php b/app/app/Http/Controllers/Api/Admin/AdminObzvonTariffController.php index 4698b00a..810185c3 100644 --- a/app/app/Http/Controllers/Api/Admin/AdminObzvonTariffController.php +++ b/app/app/Http/Controllers/Api/Admin/AdminObzvonTariffController.php @@ -12,6 +12,7 @@ use App\Services\Obzvon\ObzvonTarifikator; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; /** * SaaS-admin → Обзвон: две цифры тарифа (`obzvon_tariffs`) — задача З-1.3. @@ -74,6 +75,24 @@ class AdminObzvonTariffController extends Controller 'minute_rub.regex' => 'Цена за начатую минуту — рубли и копейки, например 12 или 12.50. Отрицательную цену принять нельзя.', ]); + // Решение владельца Р90 (docs/grilling/2026-08-03-metodika-lena-pod-klienta.md): + // обе цифры нулём разом нельзя, одна — можно. Цена звонка — два поля; + // оба нуля разом значат молча бесплатный обзвон: клиент не платит, а + // оператору за звонки платим мы. Сравниваем ПОСЛЕ перевода в копейки — + // «0», «0.00» и «0.0» обязаны ловиться одинаково, а не по виду строки. + $noliOtvet = ObzvonTariff::kopeykiIzRubley((string) $validated['answer_rub']); + $noliMinuta = ObzvonTariff::kopeykiIzRubley((string) $validated['minute_rub']); + + if ($noliOtvet === 0 && $noliMinuta === 0) { + $soobshchenie = 'Обе цены нулём разом нельзя: клиент не платит, а оператору за звонок платим мы — ' + .'это молча бесплатный обзвон. Одна цена в нуле — можно, например не брать отдельно за соединение.'; + + throw ValidationException::withMessages([ + 'answer_rub' => [$soobshchenie], + 'minute_rub' => [$soobshchenie], + ]); + } + $tarif = ObzvonTariff::deystvuyushchiy(); $bylo = [ diff --git a/app/tests/Feature/Obzvon/ObzvonTariffTest.php b/app/tests/Feature/Obzvon/ObzvonTariffTest.php index 38bd89d4..3db62a48 100644 --- a/app/tests/Feature/Obzvon/ObzvonTariffTest.php +++ b/app/tests/Feature/Obzvon/ObzvonTariffTest.php @@ -218,6 +218,90 @@ it('строка тарифа одна: вторую база не примет' ]))->toThrow(QueryException::class); }); +// ----------------------------------------------------------------------------- +// З-1.8: ноль за соединение можно, два нуля разом — нельзя (решение владельца +// Р90, docs/grilling/2026-08-03-metodika-lena-pod-klienta.md). Оба поля в +// нуле разом означают молча бесплатный обзвон: клиент не платит, а оператору +// за звонок платим мы. Одна цифра в нуле — осмысленный случай. +// ----------------------------------------------------------------------------- + +it('З-1.8 проверка 1 — оба нуля разом не проходят, и человеку сказано ПОЧЕМУ', function () { + $otvet = $this->putJson(ADRES_TARIFA, ['answer_rub' => '0', 'minute_rub' => '0']); + + $otvet->assertStatus(422); + + $soobshcheniya = mb_strtolower(collect($otvet->json('errors'))->flatten()->implode(' ')); + + // Не просто «неверное значение» — обязано объяснять, ПОЧЕМУ два нуля нельзя. + expect($soobshcheniya)->toContain('бесплат') + ->and($soobshcheniya)->not->toContain('неверное значение'); + + // Тариф не тронут отказом. + $tarif = ObzvonTariff::deystvuyushchiy(); + expect($tarif->answer_kopecks)->toBe(200) + ->and($tarif->minute_kopecks)->toBe(1000); +}); + +it('З-1.8 проверка 1 — «0», «0.00» и «0.0» ловятся одинаково: ноль есть ноль после перевода в копейки', function () { + // Цена приходит строкой в рублях, а в базе лежит копейками. Запрет обязан + // смотреть на копейки, а не на вид строки — иначе один и тот же ноль, + // записанный по-разному, то ловился бы, то проходил. + foreach ([['0', '0'], ['0.00', '0.00'], ['0.0', '0.0'], ['0', '0.00'], ['0.00', '0']] as [$otvet, $minuta]) { + expect( + $this->putJson(ADRES_TARIFA, ['answer_rub' => $otvet, 'minute_rub' => $minuta])->getStatusCode() + )->toBe(422, "форма «{$otvet}» + «{$minuta}» обязана быть отказом"); + } + + $tarif = ObzvonTariff::deystvuyushchiy(); + expect($tarif->answer_kopecks)->toBe(200) + ->and($tarif->minute_kopecks)->toBe(1000); +}); + +it('З-1.8 проверка 2 — ноль за соединение при ненулевой минуте принимается', function () { + $this->putJson(ADRES_TARIFA, ['answer_rub' => '0', 'minute_rub' => '12.00']) + ->assertStatus(200); + + $tarif = ObzvonTariff::deystvuyushchiy(); + expect($tarif->answer_kopecks)->toBe(0) + ->and($tarif->minute_kopecks)->toBe(1200); +}); + +it('З-1.8 проверка 3 — ноль за минуту при ненулевом соединении принимается', function () { + $this->putJson(ADRES_TARIFA, ['answer_rub' => '5.00', 'minute_rub' => '0']) + ->assertStatus(200); + + $tarif = ObzvonTariff::deystvuyushchiy(); + expect($tarif->answer_kopecks)->toBe(500) + ->and($tarif->minute_kopecks)->toBe(0); +}); + +it('З-1.8 проверка 4 — обе цифры ненулевые принимаются, как и раньше', function () { + $this->putJson(ADRES_TARIFA, ['answer_rub' => '3.00', 'minute_rub' => '12.00']) + ->assertStatus(200); + + $tarif = ObzvonTariff::deystvuyushchiy(); + expect($tarif->answer_kopecks)->toBe(300) + ->and($tarif->minute_kopecks)->toBe(1200); +}); + +it('З-1.8 проверка 5 — прежние отказы не сломались: отрицательная и пустая цена по-прежнему не идут', function () { + $musor = [ + 'отрицательная трубка' => ['answer_rub' => '-1.00', 'minute_rub' => '10.00'], + 'отрицательная минута' => ['answer_rub' => '2.00', 'minute_rub' => '-0.01'], + 'пустая трубка' => ['answer_rub' => '', 'minute_rub' => '10.00'], + 'пустой минуты нет вовсе' => ['answer_rub' => '2.00'], + ]; + + foreach ($musor as $chto => $telo) { + expect($this->putJson(ADRES_TARIFA, $telo)->getStatusCode()) + ->toBe(422, "старый отказ сломался: {$chto}"); + } + + $tarif = ObzvonTariff::deystvuyushchiy(); + expect($tarif->answer_kopecks)->toBe(200) + ->and($tarif->minute_kopecks)->toBe(1000); +}); + // ----------------------------------------------------------------------------- // Проверка 4 задачи: до тарифа дотягивается только админ // ----------------------------------------------------------------------------- diff --git a/docs/superpowers/priyomka/stroyka-1/z-1-8-dva-nulya-2026-08-05.md b/docs/superpowers/priyomka/stroyka-1/z-1-8-dva-nulya-2026-08-05.md new file mode 100644 index 00000000..d9a794b1 --- /dev/null +++ b/docs/superpowers/priyomka/stroyka-1/z-1-8-dva-nulya-2026-08-05.md @@ -0,0 +1,162 @@ +# З-1.8. Ноль за соединение можно, два нуля разом — нельзя + +Дата: 2026-08-05. Решение владельца — Р90, протокол +`docs/grilling/2026-08-03-metodika-lena-pod-klienta.md`. + +## Что нашёл + +- Ручка: `app/app/Http/Controllers/Api/Admin/AdminObzvonTariffController.php`, метод + `update()`. Правило на каждое поле по отдельности: + `['required', 'regex:/^\d+(\.\d{1,2})?$/']` — ловит пустую, отрицательную, + нечисловую цену и больше двух знаков после точки. Про ПАРУ полей разом правило + ничего не знает — «0» и «0» с обеих сторон проходит как обычная пара чисел. +- Перевод рубли → копейки — `ObzvonTariff::kopeykiIzRubley()` (модель трогать нельзя, + но пользоваться можно): `(int) bcmul($rubli, '100', 0)`. Для «0», «0.00», «0.0» + даёт одинаковый результат — `0` целым числом. Значит, ловить надо ПОСЛЕ перевода + в копейки, а не по строке — так одна проверка одинаково поймает все три написания + нуля, как и велит задание. +- Тесты уже используют labels «проверка 1» … «проверка 5» для ДРУГОЙ задачи + (З-1.3, «где живёт тариф и кто его правит»). Задание тоже нумерует свои 6 + проверок «проверка 1» и т.д. — это разные проверки под одним именем в одном + файле. Чтобы не путать при чтении отчёта/диффа, новые тесты называю с явной + припиской «З-1.8 …», а не голым «проверка N» — иначе в файле было бы две + разных «проверки 1» без пояснения, какая к какой задаче. +- Место вставки отказа: до `$tarif = ObzvonTariff::deystvuyushchiy();` и до + транзакции с журналом — так же, как уже отказывает regex-правило. Это держит + существующее поведение: отказ по цене не оставляет след в + `saas_admin_audit_log` (сторожа `ObzvonTariffTest` это уже проверяют для + других видов мусора — новый отказ идёт тем же путём, `ValidationException` + до `DB::transaction`, значит журнал не тронется по построению, а не по + случайности). +- Несостыковки в задании, которые заметил: + 1. Задание говорит «два нуля разом означают молча бесплатный обзвон» — но + это верно только если ОБА поля переводятся в 0 копеек. Регекс формально + не запрещает такие вещи как `"0.001"` — но такая строка вообще не проходит + regex (третий знак после точки), так что до сравнения кopeck-значений + дело не доходит. Противоречия нет, просто уточняю для себя, что запрет + работает НАД уже провалидированными по формату числами, а не вместо + формата. + 2. Задание не говорит, куда именно вешать сообщение об ошибке (на оба поля, + на одно, в общий ключ). Решил повесить на ОБА поля (`answer_rub` и + `minute_rub`) с одинаковым текстом: обе цифры виноваты, обе должны + подсветиться в форме. Явного противоречия у владельца нет — это + техническое решение, а не отступление от решения Р90. + +## Что сделал + +1. Написал тест «З-1.8 проверка 1 — оба нуля разом не проходят, и человеку + сказано ПОЧЕМУ» в `app/tests/Feature/Obzvon/ObzvonTariffTest.php` и убедился, + что он КРАСНЫЙ на сегодняшнем коде (см. «Как доказал» ниже — прогон дал + `422 ожидали, 200 получили`). +2. В `AdminObzvonTariffController::update()` добавил проверку ПОСЛЕ существующей + regex-валидации формата и ДО чтения действующего тарифа/транзакции: перевожу + обе провалидированные строки в копейки через уже существующий + `ObzvonTariff::kopeykiIzRubley()` (модель не менял — только позвал её + публичный статический метод, как и раньше делал `vid()`), и если ОБЕ + величины — 0 копеек, кидаю `ValidationException::withMessages()` с одинаковым + объясняющим текстом на оба поля `answer_rub` и `minute_rub`: + «Обе цены нулём разом нельзя: клиент не платит, а оператору за звонок платим + мы — это молча бесплатный обзвон. Одна цена в нуле — можно, например не + брать отдельно за соединение.» Текст — почти дословно решение Р90. +3. Проверка стоит ДО транзакции с журналом (`SaasAdminAuditLog`) — так же, как + и старый regex-отказ, — значит отказ по двум нулям, как и любой другой + отказ валидации, следа в журнале не оставляет (по построению кода, не + проверял отдельным тестом — см. «Что осталось нечестным»). +4. Добавил ещё 5 тестов под тем же разделом «З-1.8»: + - «0», «0.00», «0.0» и их смешанные пары («0»+«0.00», «0.00»+«0») — + все пять комбинаций отказывают одинаково (пункт задания «твой запрет + обязан ловить и «0», и «0.00», и «0.0» одинаково»). + - ноль за соединение при ненулевой минуте (`0` + `12.00`) — принимается, + сохранённые копейки сверены (`0` и `1200`). + - ноль за минуту при ненулевом соединении (`5.00` + `0`) — принимается, + копейки сверены (`500` и `0`). + - обе цифры ненулевые — принимается, как раньше (`3.00` + `12.00` → + `300`/`1200`). + - прежние отказы (отрицательная трубка, отрицательная минута, пустая + трубка, отсутствующая минута) по-прежнему дают 422, тариф не сдвинулся. +5. Все проверки задания (1–6) закрыты: 1 — красный/зелёный цикл + сообщение + про ПОЧЕМУ, 2 и 3 — принятие одного нуля с каждой стороны, 4 — обе ненулевые + по-прежнему проходят, 5 — старые отказы целы, 6 — сторож доказан красным + (см. ниже). + +## Как доказал сторожа красным + +Дважды: сначала «естественным» РЕД до фикса, потом «искусственным» РЕД +после фикса — снятием запрета. + +**Первый раз (обычный TDD-цикл, до всякого кода):** +Написал тест «проверка 1» ПЕРВЫМ, до правки контроллера. Прогон: +`Expected response status code [422] but received 200. Failed asserting that +200 is identical to 422.` — красный по правильной причине (два нуля реально +проходили), не по опечатке. + +**Второй раз (пункт 6 задания — снять запрет и посмотреть на красное):** +1. Снял хэш файла контроллера ДО вмешательства: + `git hash-object app/app/Http/Controllers/Api/Admin/AdminObzvonTariffController.php` + → `810185c3037c87b32e7ac81880ea1f92dc06cc73`. +2. Инструментом Edit вырезал весь добавленный блок (перевод в копейки + `if` + + `throw`), оставил только комментарий-метку и `$tarif = ...`. +3. Прогнал оба теста «З-1.8 проверка 1» (сообщение-про-ПОЧЕМУ и + формы-нуля). Оба покраснели: + - `Expected response status code [422] but received 200.` (первый) + - `форма «0» + «0» обязана быть отказом … Failed asserting that 200 is + identical to 422.` (второй) + Оба красных — по ожидаемой причине (запрет физически отсутствует), не по + опечатке или незапущенному коду. +4. Инструментом Edit вернул блок обратно ДОСЛОВНО тем же текстом. +5. Снял хэш файла ПОСЛЕ восстановления: тот же + `git hash-object` → `810185c3037c87b32e7ac81880ea1f92dc06cc73`. Хэш + совпал байт в байт с шагом 1 — восстановление доказано не «на глаз», + а сверкой содержимого. +6. Прогнал весь файл `ObzvonTariffTest.php` ещё раз — все 23 теста снова + зелёные (см. числа прогонов ниже). + +## Числа прогонов + +Все прогоны — своей базой `DB_DATABASE=liderra_testing_obzvon`, командой +`php artisan test tests/Feature/Obzvon/ObzvonTariffTest.php` из каталога `app`, +без труб (`|`), вывод читался целиком. + +| # | Прогон | Тестов | Красных | Assertions | +|---|---|---|---|---| +| 1 | «проверка 1» одна, ДО фикса (естественный RED) | 1 | 1 | 1 | +| 2 | «проверка 1» одна, ПОСЛЕ фикса, попытка 1 (упало на `json_encode` unicode-эскейпах в самом тесте) | 1 | 1 | 2 | +| 3 | «проверка 1» одна, ПОСЛЕ починки теста — GREEN | 1 | 0 | 5 | +| 4 | Весь файл, ПОСЛЕ добавления всех 6 новых тестов — GREEN | 23 | 0 | 97 | +| 5 | «проверка 1» (обе, message + формы нуля), ЗАПРЕТ СНЯТ — искусственный RED | 2 | 2 | 2 | +| 6 | Весь файл, запрет ВОЗВРАЩЁН — GREEN | 23 | 0 | 97 | + +Было 17 проверок в файле до задачи, стало 23 (+6 новых: 1 из них — «оба +нуля + сообщение», 1 — «формы нуля 0/0.00/0.0», 2 — принятие одного нуля с +каждой стороны, 1 — обе ненулевые по-прежнему проходят, 1 — старые отказы +целы). + +## Что осталось нечестным + +- **Отказ по двум нулям и журнал** — не проверил отдельным тестом, что отказ + по двум нулям НЕ пишет след в `saas_admin_audit_log` (в файле уже есть такой + тест для мусорной цены — «отказ следа не оставляет»). Уверен в этом только + по коду: проверка стоит до `DB::transaction(...)`, значит физически не может + что-то записать. Но задание явно требовало 6 конкретных проверок, я их + закрыл; седьмую, не запрошенную, добавлять не стал, чтобы не выйти за рамки + задачи без спроса. +- **Только один прогон файла целиком делал в изоляции своей базы** — + `liderra_testing_obzvon`. Полный прогон всего проекта (`composer test`, + Larastan, Pint) НЕ гонял — не входило в задачу, файлы вне разрешённого + списка трогать нельзя, а составные сторожа (`lefthook`, `phpstan*`) явно + в списке запрещённых. +- **Сообщение об ошибке** намеренно продублировано на оба поля + (`answer_rub` и `minute_rub`) с одинаковым текстом — решение техническое, + не из решения владельца; альтернатива (один общий ключ) тоже была бы верной, + но эта ближе к тому, как уже устроены сообщения `answer_rub.regex` / + `minute_rub.regex` в этой же ручке. +- **Название тестов**: в файле уже были свои «проверка 1» … «проверка 5» под + другую задачу (З-1.3). Мои новые тесты я подписал «З-1.8 проверка N», чтобы + не создавать двух разных тестов с одинаковым человекочитаемым именем без + уточнения — это моё решение, владелец его не утверждал явно, но оно не + меняет ничьё поведение и не трогает чужие тесты. +- Не нашёл в задании противоречий, которые стоило бы прерывать работу и + спрашивать: замечания выше (в «Что нашёл») — это уточнения формулировок, + а не расхождения с решением владельца. Само решение Р90 однозначно и + реализовано дословно: два нуля разом — отказ с объяснением, один ноль — + проходит.