fix(visitors): партиции нарезает только штатный механизм + сторож схемы на 80 таблиц

Полный прогон вскрыл реальный баг: миграция создавала партицию site_events_2026_07
своими границами (в местном времени), а MonthlyPartitionManager — site_events_y2026_m07
в UTC. Партиции перекрывались (42P17) и роняли 19 ЧУЖИХ тестов. Теперь партиции
нарезает только менеджер (ensureRange), как у всех остальных таблиц.

Плюс сторож схемы обновлён под +2 таблицы учёта (80 таблиц, 141 индекс) и
записаны готовые ссылки с метками для смс/hh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-14 05:22:46 +03:00
parent 12e8ba0cac
commit ff36e0c10e
3 changed files with 45 additions and 10 deletions
@@ -1,6 +1,7 @@
<?php
declare(strict_types=1);
use App\Services\MonthlyPartitionManager;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
@@ -60,13 +61,12 @@ return new class extends Migration
$c->statement('CREATE INDEX IF NOT EXISTS idx_site_events_visitor ON site_events (visitor_id, occurred_at DESC)');
$c->statement('CREATE INDEX IF NOT EXISTS idx_site_events_event ON site_events (event, occurred_at DESC)');
// Партиция текущего месяца — чтобы INSERT работал сразу после миграции.
// Остальные нарезает partitions:create-months (--behind=1 закрывает прошлый месяц).
$start = now()->startOfMonth()->format('Y-m-d');
$end = now()->startOfMonth()->addMonth()->format('Y-m-d');
$name = 'site_events_'.now()->format('Y_m');
$c->statement("CREATE TABLE IF NOT EXISTS {$name} PARTITION OF site_events
FOR VALUES FROM ('{$start}') TO ('{$end}')");
// Партиции нарезает ТОЛЬКО MonthlyPartitionManager — у него UTC-границы и своё
// именование (site_events_yYYYY_mMM). Своя «ручная» партиция здесь дала бы
// перекрытие с ним (42P17) и уронила бы чужие тесты. Прошлый и текущий месяц —
// сразу, чтобы INSERT работал с первой секунды после миграции.
$partitions = app(MonthlyPartitionManager::class);
$partitions->ensureRange('site_events', now()->startOfMonth()->subMonth(), now()->startOfMonth()->addMonths(2));
// Пишет supplier-worker, читают админ-роли. crm_app_user — НЕТ.
foreach (['crm_supplier_worker'] as $role) {
@@ -59,7 +59,7 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE
]))->toThrow(QueryException::class);
});
it('schema.sql v8.62 has correct metrics — 78 base tables, 137 indexes, 48 RLS policies', function () {
it('schema.sql v8.64 has correct metrics — 80 base tables, 141 indexes, 48 RLS policies', function () {
// Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные
// Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` —
// источник истины метрик.
@@ -81,6 +81,10 @@ it('schema.sql v8.62 has correct metrics — 78 base tables, 137 indexes, 48 RLS
// (autopodbor_runs/competitors/sources/merge_events) + 9 индексов (вкл. 2 UNIQUE dedup) +
// 4 RLS-политики tenant_isolation. Итог статического парса db/schema.sql (v8.62):
// 78 base tables, 137 индексов, 48 RLS-политик.
// v8.64 (учёт посетителей, spec 2026-07-13): +2 base таблицы (site_visitors, site_events)
// + 4 индекса (2 на гостей: first_seen/channel; 2 на события: visitor/event) + 0 RLS-политик
// (системные таблицы без tenant_id — доступ ролями, как supplier_order_checks).
// Итог статического парса db/schema.sql (v8.64): 80 base tables, 141 индекс, 48 RLS-политик.
// NB: бегущий счётчик в ШАПКЕ schema.sql несёт исторический дрейф — отдельный canon-sync,
// не предмет этого теста; тест сверяет фактический парс ФАЙЛА.
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
@@ -92,10 +96,10 @@ it('schema.sql v8.62 has correct metrics — 78 base tables, 137 indexes, 48 RLS
$createTables = preg_match_all('/^CREATE TABLE\b/m', $schema);
$partitionOf = preg_match_all('/CREATE TABLE\s+\w+\s+PARTITION OF\b/m', $schema);
$baseTables = $createTables - $partitionOf;
expect($baseTables)->toBe(78); // v8.62: +4 autopodbor таблицы к v8.55 (74)
expect($baseTables)->toBe(80); // v8.64: +2 таблицы учёта посетителей к v8.62 (78)
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
expect($createIndexes)->toBe(137); // v8.62: +9 autopodbor индексов к v8.55 (128)
expect($createIndexes)->toBe(141); // v8.64: +4 индекса учёта посетителей к v8.62 (137)
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
expect($createPolicies)->toBe(48); // v8.62: +4 autopodbor RLS-политики к v8.52 (44)
@@ -0,0 +1,31 @@
# Ссылки с метками для рассылок и объявлений
Дата: 13.07.2026. Относится к учёту посетителей (spec `2026-07-13-visitors-analytics-design.md`).
Метка в ссылке — единственный способ точно отделить смс от прямых заходов: переход
из смски браузер отдаёт **без источника**, и без метки он неотличим от «набрал адрес руками».
## Готовые ссылки
| Куда | Ссылка |
|---|---|
| Смс-рассылка | `https://liderra.ru/?utm_source=sms&utm_medium=sms&utm_campaign=<дата>` |
| Вакансия на hh.ru | `https://liderra.ru/?utm_source=hh&utm_medium=vacancy` |
| Телеграм | `https://liderra.ru/?utm_source=tg` |
| Визитки / QR | `https://liderra.ru/?utm_source=qr` |
`<дата>` в кампании — чтобы отличать рассылки друг от друга, например `rassylka-14-07`.
## Как это выглядит в отчёте
В админке → «Посетители» такие гости попадут в строки «Смс-рассылка», «Вакансия на hh.ru»
и т. д. Без метки они все свалятся в «Прямые заходы».
## Оговорки
- В смс ссылка длиннее на ~30 символов. Если это критично для стоимости — можно завести
короткий адрес-перенаправление (`liderra.ru/s`), который сам подставит метку. Это отдельная
небольшая задача, пока не сделана.
- Ссылку в вакансии на hh.ru соискатели чаще **копируют руками**, а не жмут (hh не делает
из текста кликабельные ссылки). Такой заход придёт без метки — как прямой. Поэтому в
вакансии лучше писать адрес с меткой прямо текстом: человек скопирует его целиком.