merge: подтянул main после выката починок модерации Яндекса

Мой коммит уехал в main отдельно — пересадкой на актуальную вершину
и оттуда на боевой. Возвращаю main к себе, чтобы ветка не разъезжалась.

Пришло 9 коммитов, из них главное — работа соседней сессии по воронке
продаж, уже влитая в main и запушенная.

Конфликта два, оба в общих машинных файлах:

- cspell-words.txt — сторону не выбирал, объединил. Все слова обеих
  сторон на месте; убрана ровно одна строка — мой же дубль «админский»,
  который прошлый коммит добавил дважды. Проверено сравнением с версией
  до слияния: другого отличия нет;
- docs/observer/STATUS.md — машинный файл наблюдателя со столбиком часов
  процессов, взята своя версия, его всё равно переписывает хук.

Замечание на будущее: патч «минимум площадки считается по длине периода»
живёт в двух коммитах — 948807507 у меня и в ветке робота, 9df7846fd
в main. В main уехала только вторая копия, main чист. Когда моя ветка
и ветка робота пойдут в main, git встретит этот патч второй раз.

Статанализ остановил слияние, и это была НЕ ложная тревога: файл-эталон
phpstan-baseline.neon склеился из двух версий и перестал соответствовать
коду. Все 16 ошибок — в чужих файлах воронки продаж, пришедших из main
байт в байт, известного ложного класса про Pest. Эталон из main один
не подошёл: всплыли записи, нужные этой ветке. Пересобрал по проектной
процедуре в два шага, phpstan.neon вернул на место. Итог 0 ошибок,
правка эталона мелкая: +43 -31. Моих файлов среди добавленных записей
нет ни одного.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-08-01 16:19:06 +03:00
39 changed files with 1902 additions and 73 deletions
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Воронка «Потенциальные клиенты»: корзина и журнал движений.
*
* 1) stage += 'trash' («Корзина» карточка, с которой больше не работаем).
* 2) prev_stage откуда карточка приехала на текущую стадию. Кормит счётчик
* «69/1» в шапке «Отказа»: сколько отказов пришло из ручного тестирования.
* 3) sales_prospect_moves журнал ВСЕХ движений карточки по стадиям, включая
* автоматические (деньги testing/topped_up/user). Раньше автодвижения не
* оставляли следа вообще, и фильтр «что менялось за день» было не из чего
* построить. Пишется событием модели SalesProspect один шов на всех.
*
* Схема sales_* живёт только в миграциях (в db/schema.sql её нет, см. CHANGELOG v8.72).
* Спека: docs/superpowers/specs/2026-08-01-korzina-filtry-schetchik-design.md §4.
* План: docs/superpowers/plans/2026-08-01-korzina-filtry-schetchik.md Task 1.
*/
return new class extends Migration
{
public function up(): void
{
$db = DB::connection('pgsql_supplier');
// Порядок значений в CHECK = порядок колонок канбана (для читаемости; БД порядок не хранит).
$db->statement('ALTER TABLE sales_prospects DROP CONSTRAINT IF EXISTS sales_prospects_stage_check');
$db->statement(<<<'SQL'
ALTER TABLE sales_prospects ADD CONSTRAINT sales_prospects_stage_check
CHECK (stage IN ('new','in_work','negotiation','manual_testing','registered',
'testing','topped_up','user','kp_sent','rejected','no_answer','trash'))
SQL);
$db->statement('ALTER TABLE sales_prospects ADD COLUMN IF NOT EXISTS prev_stage VARCHAR(16) NULL');
$db->statement(<<<'SQL'
CREATE TABLE IF NOT EXISTS sales_prospect_moves (
id BIGSERIAL PRIMARY KEY,
prospect_id BIGINT NOT NULL REFERENCES sales_prospects(id) ON DELETE CASCADE,
sales_user_id BIGINT REFERENCES sales_users(id),
from_stage VARCHAR(16),
to_stage VARCHAR(16) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
SQL);
// История одной карточки — свежие сверху.
$db->statement(<<<'SQL'
CREATE INDEX IF NOT EXISTS idx_prospect_move_card
ON sales_prospect_moves (prospect_id, created_at DESC)
SQL);
// Фильтр «что менялось за период» — выборка по всему отделу за диапазон дат.
$db->statement(<<<'SQL'
CREATE INDEX IF NOT EXISTS idx_prospect_move_when
ON sales_prospect_moves (created_at)
SQL);
// 🔴 GRANTs обеим ролям admin-db (как в create_sales_prospect_notes_table).
// Без них на бою будет отказ прав, а на dev под superuser это невидимо.
$db->statement(<<<'SQL'
DO $$
DECLARE target TEXT;
BEGIN
FOREACH target IN ARRAY ARRAY['crm_admin_user','crm_supplier_worker'] LOOP
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = target) THEN
EXECUTE format('GRANT SELECT, INSERT ON sales_prospect_moves TO %I', target);
EXECUTE format('GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO %I', target);
END IF;
END LOOP;
END $$
SQL);
}
public function down(): void
{
$db = DB::connection('pgsql_supplier');
$db->statement('DROP TABLE IF EXISTS sales_prospect_moves CASCADE');
$db->statement('ALTER TABLE sales_prospects DROP COLUMN IF EXISTS prev_stage');
// Карточки из корзины — в «Отказ», иначе прежний CHECK не встанет.
$db->statement("UPDATE sales_prospects SET stage = 'rejected' WHERE stage = 'trash'");
$db->statement('ALTER TABLE sales_prospects DROP CONSTRAINT IF EXISTS sales_prospects_stage_check');
$db->statement(<<<'SQL'
ALTER TABLE sales_prospects ADD CONSTRAINT sales_prospects_stage_check
CHECK (stage IN ('new','in_work','negotiation','manual_testing','registered',
'testing','topped_up','user','kp_sent','rejected','no_answer'))
SQL);
}
};