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:
@@ -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 однозначно и
|
||||
реализовано дословно: два нуля разом — отказ с объяснением, один ноль —
|
||||
проходит.
|
||||
Reference in New Issue
Block a user