fix(обзвон): два нуля в тарифе разом запрещены, один ноль можно

Решение владельца Р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 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-08-05 15:29:09 +03:00
parent a9de42ccbc
commit 1c2195ebee
3 changed files with 265 additions and 0 deletions
@@ -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 = [
@@ -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 задачи: до тарифа дотягивается только админ
// -----------------------------------------------------------------------------
@@ -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 однозначно и
реализовано дословно: два нуля разом — отказ с объяснением, один ноль —
проходит.