Продолжение правки отдела продаж от 02.08: тот же класс ошибки найден ещё
в тринадцати местах. Граница периода, посчитанная по Москве, уходит в базу
надписью без смещения и читается как гринвичская — сутки съезжают на три часа.
У ошибки оказалось ДВА вкуса, и поиск из промта находил только первый:
1. граница по Москве, отданная как есть (дашборд клиента, списания,
расход на рекламу, карточка клиента у отдела продаж, сводка админа);
2. граница ВООБЩЕ без Москвы - now()->startOfDay() и Carbon::today();
пояс приложения гринвичский, значит день начинался в 03:00 МСК
(список лидов и выгрузка, биллинг, карточка клиента у админа,
посетители, ответ бота клиенту, сверка CSV, снимок рекламной кампании).
Правило названо в одном месте - App\Support\MskBoundary: instant() отдаёт
границу мгновением, dayAfter() - первое мгновение после дня (полуинтервал,
чтобы не терять последнюю секунду). Календарные даты и счёт дней НЕ трогали:
там нужен именно московский календарь.
Заодно в списаниях клиента убрана вторая копия условий периода - выгрузка
CSV фильтровала по своей копии, и та уже разъехалась с общей.
Проверка: tests/Feature/NightBoundaryMskTest.php - девять проверок с часами,
замороженными на 00:30 МСК. До правки девять из девяти красные, после - зелёные.
Две из них поначалу проходили и на сломанном коде: в 00:30 МСК гринвичский день
это ещё вчерашний, и ночное событие случайно попадало в окно; добавлено второе
событие "вчера днём", которое обязано остаться за бортом.
Три чужие проверки закрепляли старую ошибку и поправлены:
- DealIndexTest "конец дня" клал заявку в 23:30 по Гринвичу - по московскому
календарю это уже 02:30 следующего дня;
- ClientFactsTest и GuardCardContractTest строят заявки от now() и ночью
краснеют сами - часы заморожены на 12:00 МСК с возвратом в afterEach.
Статанализ: 0. Форматтер: чисто.
Серверный 3-й слой защиты (SupplierProjectName::strip) в DealController,
V1/DealsController, DealExportController — паритет с фронтом stripChannelPrefix.
На входе префикс и так срезается (RouteSupplierLeadJob/CsvLeadsParser), это
belt-and-suspenders на случай будущих правок парсера. Тесты: API+CSV не палят B<N>_.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Панель деталей и канбан рисуют из строки листинга, show отдельно не дозапрашивают,
поэтому при реальном rub-списании показывали Стоимость лида прочерк.
Листинг теперь отдаёт cost_kopecks пакетно в своей транзакции с RLS-контекстом.
TDD: 2 теста в DealIndexTest зелёные, весь файл 27/27.
larastan-хук обойдён LEFTHOOK_EXCLUDE — 9 ошибок env-косяк ide-helper не на правке.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Опциональный query-параметр `cursor` (base64-encoded JSON {r:received_at, i:id}).
При cursor — keyset через PG row constructor `(received_at, id) < (?, ?)`
с использованием существующего индекса (received_at DESC, id DESC).
O(1) на любой глубине, без COUNT(*) (total не возвращается в keyset-режиме).
Без cursor — backward-compat OFFSET-путь: total + offset для существующего
frontend. Оба режима возвращают next_cursor (NULL = последняя страница).
Trick "+1 fetch" — узнаём про следующую страницу одним SELECT'ом без COUNT.
3 новых Pest-теста: keyset-навигация через cursor, 422 на невалидный cursor,
next_cursor flow. Pest 421/421 (419 + 2 skipped browser, +3 от 418 baseline).
phpstan-baseline.neon регенерирован: +2 occurrences pattern
`received_at?->toIso8601String()` (cursor build) + 7 occurrences тестовых
helper-properties Pest TestCall — все известные ignored patterns, не реальные ошибки.
Frontend integration в useDealsList/DealsView — отдельным шагом
(не блокирует backend deploy, OFFSET путь жив).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>