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,47 @@
# ПРОМТ для следующей сессии — корзина, фильтры по датам, счётчик через дробь, КП без даты
Скопировать текст ниже целиком в новую сессию.
---
Работаем в ветке `feat/prospects-manual-testing-kp` (не в main), каталог
`c:\моя\проекты\портал crm\Документация`.
**Первое действие — прочитать `docs/superpowers/2026-08-01-ZAPROS-korzina-filtry-schetchik-KP.md`
целиком.** Там дословная просьба владельца, описания трёх скринов, находки по коду и
четыре вопроса, на которые нужен его ответ до начала работы.
Задача — четыре куска:
1. Новая колонка **«Корзина»** — и у менеджера, и у начальника, по тому же принципу, что
«Тестирование ручное» и «Выслано КП» (они уже сделаны и выкачены 31.07). В её результате
разговора **нет даты созвона**, только причина.
2. В результате **«Выслано КП»** убрать обязательность поля «Следующий созвон».
3. В заголовке колонки **«Отказ»** писать через дробь: `69/1` — сколько всего отказов и
сколько из них пришло после ручного тестирования.
4. **Два фильтра по датам** на доске (владелец назвал это самым большим куском):
(а) выбрать дату или период → карточки, по которым в этот срок надо звонить/что-то делать,
**включая просроченные**; (б) выбрать дату или период → карточки, которые **менялись**
в этот период («что я сделал за день»).
Порядок работы: сначала задать владельцу четыре вопроса из §5 запроса (у каждого есть
рекомендованный ответ), потом спека → план → код по задачам. Кусок 4 большой — резать
на отдельные задачи, не пытаться одним куском.
Что важно не забыть (подробности — в §4 запроса):
- 🔴 У карточки **нет истории стадий** — ни `stage_changed_at`, ни `prev_stage`. Счётчик `69/1`
считать не из чего, нужны новые данные в базе.
- 🔴 `AdAudienceScheduler::decide()` на незнакомой стадии молча выключает рекламу-прогрев —
«Корзину» туда завести **осознанно**, а не пропустить.
- Список стадий живёт в **двух** местах: `PROSPECT_STAGES` и `SalesProspectController::STAGES`.
- Схема `sales_*` только в миграциях, `db/schema.sql` не трогать. Запись в
`db/CHANGELOG_schema.md` (последняя занятая — v9.30).
- 🪤 `git stash` в этом хранилище терял правки **дважды** — не пользоваться.
- 🪤 Тестовая база своя: `liderra_testing_prospects`. Команды фронта — из `app/`, не из корня.
- 🪤 Три сторожа (статанализ, журналы «мозга», орфография+разметка) **падают**, а не находят
проблемы: `LEFTHOOK_EXCLUDE=larastan,observer-coverage-checker,cspell,markdownlint`.
Сторож секретов `gitleaks` живой — не глушить.
- Обязательна **приёмка глазами** в браузере, включая «Воронку отдела» начальника.
«Готово» не писать, пока не проверено живьём.
- На боевой liderra.ru **ничего не выкатывать** без отдельной команды владельца.
@@ -0,0 +1,110 @@
# ЗАПРОС ВЛАДЕЛЬЦА 01.08.2026 — корзина, фильтры по датам, счётчик через дробь, КП без даты
Файл заведён, чтобы просьба не размылась при переносе между сессиями.
Работа **ещё не начата** — это фиксация задачи, а не отчёт.
## 1. Дословно, как сказал владелец
> смотри надо по тому же принципу добавить колонку корзина и к начальнику и к менеджеру
> убрав дату созвона и оставить только причину и в кп колонке убрать обязательное поле
> дава и время созвона на скрине смотри! и смотри надо сделать когда после ручного
> тестирования карточка попадает в поле отказ то надо писать через дробь количество
> такких те будет 69/1 примерно как на скрине!
> и самое больше наверно надо сделать 2 фильтра по датам 1 чтобы фильтровать что надо
> сдалать сегоня/завтра и т.д. и что просрочено те я выбираю дату или период и мне
> высвечиваются карточки по которым надо звонить или что-то делать и второй фильтр тоже
> по дате также выбираю дату или период и мне показываются карточки которые изменялись
> в этот период те я могу посмотреть в конце дня что я сделал или за какойто период
## 2. Четыре куска работы
| # | Что | Где видно на скринах |
|---|---|---|
| 1 | Новая колонка **«Корзина»** — и у менеджера, и у начальника, по тому же принципу, что «Тестирование ручное» и «Выслано КП». В её результате разговора **нет даты созвона**, только причина. | — |
| 2 | В результате **«Выслано КП»** убрать обязательное поле «Следующий созвон» (дата и время). | скрин 3 |
| 3 | Когда карточка попадает в **«Отказ» после ручного тестирования** — в заголовке колонки писать **через дробь**: `69/1`. | скрин 2 |
| 4 | **Два фильтра по датам** (владелец назвал самым большим куском): (а) выбрать дату или период → показать карточки, по которым в этот срок надо звонить/что-то делать, **включая просроченные**; (б) выбрать дату или период → показать карточки, которые **менялись** в этот период («что я сделал за день / за период»). | скрин 1 — рядом с существующим выбором «30 дней» |
## 3. Скриншоты
Владелец прислал три картинки прямо в чат. Файлами их у меня нет — положить их сюда:
`docs/superpowers/screens/2026-08-01-korzina-filtry/`
Ниже — описания, чтобы работа не встала, даже если картинки не сохранятся.
### Скрин 1 — `01-period-selector.png`
Боевой `lk.liderra.ru`, экран **«Воронка отдела»** (значок «НАЧАЛЬНИК» справа вверху).
Красным обведён выпадающий список **«30 дней»** в правом верхнем углу — рядом со значком
роли, выше фильтров «Менеджер» и «Происхождение». Это существующий выбор периода;
именно сюда просятся два новых фильтра по датам.
Колонки на экране: Зарегистрировался 0 · Тестирование 0 · Пополнил баланс 1 (ВитаДент,
Омск) · Пользователь 0 · Выслано КП 1 (Омдент, Омск) · Отказ 69 · Не смогли дозвониться.
### Скрин 2 — `02-otkaz-69-drob-1.png`
Та же «Воронка отдела». Владелец **от руки дорисовал `/1`** сразу после числа `69`
в заголовке колонки **«Отказ»**. То есть в заголовке должно стоять `69/1`:
общее число отказов и — через дробь — сколько из них пришло после ручного тестирования.
### Скрин 3 — `03-kp-lishnyaya-data.png`
Карточка **«Центр дентальной имплантации»** (Томск, ООО «ЦДИ», значок стадии «Переговоры»),
открыто окно карточки. В блоке «Результат разговора» выбрано **«Выслано КП»**.
Красным обведены два поля:
- пустой список **«Куда направили КП»**;
- пустое поле **«Следующий созвон»** (`дд.мм.гггг --:--`) — **вот его владелец и просит убрать
из обязательных**.
## 4. Что уже выяснено в коде (чтобы не искать заново)
- Счётчик в шапке колонки рисуется в
[SalesProspectBoard.vue:75](../../app/resources/js/components/sales/SalesProspectBoard.vue#L75):
`<span class="prospect-column-count">{{ col.cards.length }}</span>` — сюда пойдёт `69/1`.
- Фильтры доски («Менеджер», «Происхождение») живут в
[SalesProspectsBoardView.vue](../../app/resources/js/views/sales/SalesProspectsBoardView.vue),
примерно строки 95–112. Экран начальника отличается признаком `scope=department`.
- 🔴 **У карточки нет никакой истории стадий**: в `SalesProspect` нет ни `stage_changed_at`,
ни `prev_stage`. Значит счётчик «сколько отказов пришло после ручного тестирования»
**не из чего посчитать** — нужны новые данные. Склоняюсь к отдельному полю `prev_stage`,
которое пишется при каждой смене стадии, а не к разбору текста журнала разговоров.
- Список стадий по-прежнему живёт в **двух** местах: `PROSPECT_STAGES`
([prospectStages.ts](../../app/resources/js/utils/prospectStages.ts)) и
`SalesProspectController::STAGES`. Расхождение = пустая колонка на доске.
- 🔴 `AdAudienceScheduler::decide()` на незнакомой стадии возвращает `stopped('unknown_stage')`
реклама-прогрев **молча встанет**, если «Корзину» туда не завести осознанно.
Для «Корзины» это, скорее всего, и надо (карточку выбросили — греть незачем),
но написать это надо явно, а не получить случайно.
- Схема `sales_*` живёт **только в миграциях**, не в `db/schema.sql`. Нормативная правка одна —
запись в `db/CHANGELOG_schema.md` (последняя занятая — v9.30, см. коммит `72db586a`).
## 5. Четыре вопроса, которые надо задать владельцу до начала
1. **«Корзина» — что это по смыслу и где стоит?**
Рекомендую: карточки, с которыми больше не работаем (мусор, дубли, не наш профиль).
Место — в самом конце, после «Не смогли дозвониться». Поле одно — причина.
Реклама-прогрев останавливается сразу (в отличие от «Отказа», который греется
ещё `rejected_days`).
2. **`69/1` — что означает второе число?**
Рекомендую: сколько из отказов пришло из «Тестирования ручного».
3. **Фильтр «что я сделал» — что считать изменением?**
Рекомендую: записи в журнале разговоров (настоящие действия менеджера), а не любое
касание `updated_at` — денежная джоба тоже трогает карточки, и день будет «полон дел»
без единого звонка.
4. **Дата в КП — убрать поле совсем или оставить необязательным?**
Рекомендую: поле оставить, просто перестать требовать.
## 6. Состояние на момент фиксации
- Ветка `feat/prospects-manual-testing-kp`, HEAD `9165cbb0`.
- Предыдущая работа (две стадии «Тестирование ручное» и «Выслано КП») **сделана и выкачена
на боевой liderra.ru** 31.07.2026 точечным выкатом 12 файлов + `public/build`.
- 🪤 `git stash` в этом хранилище **терял правки дважды**. Не пользоваться.
- 🪤 Три сторожа (статанализ, журналы «мозга», орфография+разметка) **падают**, а не находят
проблемы — корневые `node_modules` обрезаны. Пропускать через
`LEFTHOOK_EXCLUDE=larastan,observer-coverage-checker,cspell,markdownlint`.
Сторож секретов `gitleaks` живой — его не глушить.
- 🪤 Тестовая база у этой работы своя: `liderra_testing_prospects` (общую кто-то чистит
параллельно). Команды фронта — из `app/`, не из корня.
@@ -0,0 +1,148 @@
# Корзина, фильтры по датам, счётчик через дробь — план работ
> **Для исполнителя:** задачи строго последовательны, каждая заканчивается коммитом.
> Спека: [2026-08-01-korzina-filtry-schetchik-design.md](../specs/2026-08-01-korzina-filtry-schetchik-design.md).
**Цель:** колонка «Корзина», необязательная дата в КП, счётчик `69/1` у «Отказа»
и фильтр по датам в двух режимах — на обоих экранах воронки.
**Устройство:** движения карточки пишутся событием модели в новую таблицу
`sales_prospect_moves` (один шов на всех, включая джобу); `prev_stage` на карточке
кормит счётчик; фильтр разбирает период существующим `SalesPeriodResolver`.
**Стек:** PHP 8.3 / Laravel 13 / Pest 4 / PostgreSQL 16; Vue 3 + Vuetify 3 / Vitest.
**Окружение (не забыть):**
- тесты — своя база: `cd app && DB_DATABASE=liderra_testing_prospects ./vendor/bin/pest …`
- фронт — из `app/`: `cd app && npm run test:vue`
- коммит — `LEFTHOOK_EXCLUDE=larastan,observer-coverage-checker,cspell,markdownlint`
- `git stash` в этом хранилище **не использовать** — терял правки дважды
---
### Task 1: База — `trash`, `prev_stage`, таблица движений
**Файлы:**
- Создать: `app/database/migrations/2026_08_01_100000_add_trash_stage_and_prospect_moves.php`
- Создать: `app/app/Models/SalesProspectMove.php`
- Изменить: `app/app/Models/SalesProspect.php`
- Тест: `app/tests/Feature/Sales/SalesProspectMoveTest.php`
- [ ] **Шаг 1.** Миграция: `ADD COLUMN IF NOT EXISTS prev_stage VARCHAR(16)`; пересборка
`sales_prospects_stage_check` на 12 значений (+`trash`); `CREATE TABLE IF NOT EXISTS
sales_prospect_moves` по §4.2 спеки; два индекса; блок GRANT для `crm_admin_user`
и `crm_supplier_worker` — копия блока из `create_sales_prospect_notes_table`.
`down()`: `UPDATE … SET stage='rejected' WHERE stage='trash'`, вернуть CHECK на 11,
`DROP COLUMN prev_stage`, `DROP TABLE sales_prospect_moves CASCADE`.
- [ ] **Шаг 2.** Модель `SalesProspectMove` (append-only, `UPDATED_AT = null`).
- [ ] **Шаг 3.** Тест: смена стадии через модель пишет строку движения с правильными
`from_stage`/`to_stage` и заполняет `prev_stage`; сохранение БЕЗ смены стадии
движения не пишет; создание карточки пишет движение с `from_stage = null`.
- [ ] **Шаг 4.** Прогнать — тест падает (движений нет).
- [ ] **Шаг 5.** `SalesProspect::booted()`: `saving``prev_stage`, `saved` → вставка
движения через `$this->getConnection()`; автор — `auth('sales')->user()?->id`.
Добавить `prev_stage` в `$fillable` и PHPDoc.
- [ ] **Шаг 6.** Прогнать — зелено. Коммит.
### Task 2: Джоба и контроллер — движения пишутся отовсюду
**Файлы:**
- Тест: `app/tests/Feature/Sales/SalesProspectMoveTest.php` (дополнить)
- [ ] **Шаг 1.** Тест: `SalesProspectsAdvanceJob` двигает карточку по деньгам → появилось
движение `registered → topped_up` с `sales_user_id = null`. Тест: результат разговора
через API → движение с `sales_user_id` = менеджер.
- [ ] **Шаг 2.** Прогнать. Ожидание: **уже зелено** — событие модели ловит обе стороны.
Если красно — чинить событие, а не добавлять записи в джобу.
- [ ] **Шаг 3.** Проверка вырезанием: временно убрать `static::saved(...)`, убедиться,
что оба теста краснеют. Вернуть. Коммит.
### Task 3: Стадия «Корзина» — бэкенд
**Файлы:**
- Изменить: `app/app/Http/Controllers/Api/Sales/SalesProspectController.php`
- Изменить: `app/app/Services/Sales/AdAudienceScheduler.php`
- Тест: `app/tests/Feature/Sales/SalesProspectApiTest.php`, `app/tests/Unit/AdAudienceSchedulerTest.php`
- [ ] **Шаг 1.** Тесты: `action=trash` с причиной → стадия `trash`, `reason` записан,
`next_call_at` обнулён, в журнале «В корзину: …»; без причины → 422; на `topped_up`
и `user` → 422; `trash` есть в списке стадий ответа. Юнит-тест: `decide()` на стадии
`trash``stopped('trashed')`.
- [ ] **Шаг 2.** Прогнать — красно.
- [ ] **Шаг 3.** `STAGES` += `trash` (последним); ветка `case 'trash'` в `update()`
(валидация причины, `assertNotPaying`, обнуление `next_call_at`); ветка `trash`
в `AdAudienceScheduler::decide()` **до** проверки `STAGE_LIMITS`.
- [ ] **Шаг 4.** Прогнать — зелено. Коммит.
### Task 4: КП без обязательной даты — бэкенд
**Файлы:**
- Изменить: `app/app/Http/Controllers/Api/Sales/SalesProspectController.php`
- Тест: `app/tests/Feature/Sales/SalesProspectApiTest.php`
- [ ] **Шаг 1.** Тест: `action=kp_sent` **без** `next_call_at` → 200, стадия `kp_sent`,
в журнале «Выслано КП: почта … » **без** хвоста «Следующий созвон». Прежний тест
с датой остаётся зелёным и хвост в журнале сохраняет.
- [ ] **Шаг 2.** Прогнать — красно (сейчас `required`).
- [ ] **Шаг 3.** `next_call_at``nullable`; ставить дату и добавлять хвост в журнал
только когда она пришла.
- [ ] **Шаг 4.** Прогнать — зелено. Коммит.
### Task 5: Фильтр по датам — бэкенд
**Файлы:**
- Изменить: `app/app/Services/Sales/SalesPeriodResolver.php` (+`tomorrow`)
- Изменить: `app/app/Http/Controllers/Api/Sales/SalesProspectController.php`
- Тест: `app/tests/Unit/SalesPeriodResolverTest.php`, `app/tests/Feature/Sales/SalesProspectDateFilterTest.php`
- [ ] **Шаг 1.** Юнит-тест: `resolve(['kind' => 'tomorrow'])` → завтрашние 00:00–23:59:59 МСК.
- [ ] **Шаг 2.** Тесты фильтра (время заморожено `CarbonImmutable::setTestNow`):
`date_mode=todo&period=today` → сегодняшний созвон **и** просроченный, но не завтрашний
и не карточка без даты; отказ и корзина не показываются даже с просроченной датой;
`date_mode=changed&period=today` → карточка с сегодняшним движением и карточка
с сегодняшней записью разговора, но не тронутая вчера; без `date_mode` — всё как раньше.
- [ ] **Шаг 3.** Прогнать — красно.
- [ ] **Шаг 4.** В `index()`: разбор `date_mode` (`todo|changed`) + период через
`ResolvesSalesPeriod`; две ветки условий по §8.2/§8.3 спеки; `prev_stage` в `row()`.
- [ ] **Шаг 5.** Прогнать — зелено. Коммит.
### Task 6: Фронт — стадия, счётчик, КП, типы
**Файлы:**
- Изменить: `app/resources/js/utils/prospectStages.ts`, `app/resources/js/api/sales.ts`,
`app/resources/js/components/sales/SalesProspectBoard.vue`,
`app/resources/js/components/sales/SalesProspectDialog.vue`
- Тест: `app/tests/Frontend/prospectStages.spec.ts`, `SalesProspectBoard.spec.ts`, `SalesProspectDialog.spec.ts`
- [ ] **Шаг 1.** Тесты: в `PROSPECT_STAGES` 12 стадий, последняя — «Корзина»; у колонки
«Отказ» с двумя карточками, одна из которых `prev_stage='manual_testing'`, подпись
`2/1`, а без таких карточек — просто `2`; в диалоге есть результат «В корзину»
с полем причины и без поля даты; «В корзину» не предлагается платящему; «Выслано КП»
отправляется без даты.
- [ ] **Шаг 2.** Прогнать — красно.
- [ ] **Шаг 3.** Правки: стадия в `prospectStages.ts`; `subCount` в `SalesProspectBoard.vue`
и вывод дроби в шапке; в диалоге — ветка `trash` (причина) и снятие обязательности даты
у `kp_sent`; типы и поле `prev_stage` в `api/sales.ts`.
- [ ] **Шаг 4.** Прогнать — зелено. Коммит.
### Task 7: Фронт — орган фильтра на обоих экранах
**Файлы:**
- Создать: `app/resources/js/components/sales/ProspectDateFilter.vue`
- Изменить: `app/resources/js/views/sales/SalesProspectsView.vue`,
`app/resources/js/views/sales/SalesProspectsBoardView.vue`, `app/resources/js/api/sales.ts`
- Тест: `app/tests/Frontend/ProspectDateFilter.spec.ts`
- [ ] **Шаг 1.** Тесты компонента: по умолчанию режим «Все карточки», выбор периода скрыт;
при выборе режима появляется выбор периода и вылетает событие с `{mode, period}`;
«Произвольный период» отдаёт событие только когда выбраны обе даты.
- [ ] **Шаг 2.** Прогнать — красно.
- [ ] **Шаг 3.** Написать компонент; врезать в оба экрана; параметры в `listProspects()`.
- [ ] **Шаг 4.** Прогнать — зелено. Коммит.
### Task 8: Приёмка и нормативка
- [ ] **Шаг 1.** Полный прогон Pest по `tests/Feature/Sales` + `tests/Unit` и весь Vitest.
- [ ] **Шаг 2.** Приёмка глазами в браузере — 7 пунктов §10 спеки, включая экран начальника.
- [ ] **Шаг 3.** Запись в `db/CHANGELOG_schema.md` (следующая свободная версия после v9.30).
- [ ] **Шаг 4.** Коммит. На боевой **не выкатывать** без отдельной команды владельца.
@@ -0,0 +1,10 @@
# Скриншоты к запросу 01.08.2026 (корзина, фильтры, счётчик, КП)
Сюда положить три картинки, которые владелец прислал в чат.
Описания — в [запросе](../../2026-08-01-ZAPROS-korzina-filtry-schetchik-KP.md) §3.
| Имя файла | Что на нём |
|---|---|
| `01-period-selector.png` | «Воронка отдела», обведён выбор периода «30 дней» в правом верхнем углу |
| `02-otkaz-69-drob-1.png` | Та же доска, от руки дорисовано `/1` после `69` в заголовке «Отказ» |
| `03-kp-lishnyaya-data.png` | Карточка «Центр дентальной имплантации», результат «Выслано КП», обведено лишнее обязательное поле «Следующий созвон» |
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

@@ -0,0 +1,204 @@
# Корзина, два фильтра по датам, счётчик через дробь, КП без обязательной даты
**Дата:** 01.08.2026
**Запрос владельца:** [2026-08-01-ZAPROS-korzina-filtry-schetchik-KP.md](../2026-08-01-ZAPROS-korzina-filtry-schetchik-KP.md)
**Ветка:** `feat/prospects-manual-testing-kp`
---
## 1. Что делаем
Четыре куска в воронке «Потенциальные клиенты» — и на экране менеджера, и в «Воронке отдела»:
1. Новая колонка **«Корзина»** (`trash`) — карточки, с которыми больше не работаем. Результат
разговора «В корзину» просит только причину, даты созвона у него нет.
2. У результата **«Выслано КП»** дата созвона перестаёт быть обязательной.
3. В шапке колонки **«Отказ»** — счётчик через дробь `69/1`: всего отказов и сколько из них
пришло прямо из «Тестирования ручного».
4. **Фильтр по датам** с двумя режимами: «Что надо сделать» и «Что менялось».
## 2. Решения владельца (01.08.2026)
| Вопрос | Ответ |
|---|---|
| Что такое «Корзина» | Как предложено: карточки, с которыми больше не работаем; колонка последняя, поле одно — причина; реклама-прогрев останавливается сразу |
| Что значит второе число в `69/1` | Сколько отказов пришло после ручного тестирования |
| Что считать изменением карточки | **Все движения**: потратил деньги, ушёл в тестирование, пополнил счёт, стал клиентом — любое движение, а не только разговоры |
| Дата в КП | Оставить как возможность, не требовать. Бывает «пришлите, созвонимся через пару дней» (дата нужна), бывает «пришлите на почту, если интересно — перезвоню» (даты нет) |
Третий ответ — поправка к моей рекомендации. Я предлагал считать изменением только записи
в журнале разговоров; владелец потребовал считать **любое** движение карточки, включая
автоматические (деньги, стадии от джобы). Это меняет устройство: нужен журнал движений,
а не выборка по разговорам.
## 3. Главная находка: движения карточки нигде не записываются
Сегодня стадия меняется в двух местах, и записи о ней ведутся по-разному:
- `SalesProspectController::update()` — ручные результаты, пишет запись в журнал разговоров
(`sales_prospect_notes`, `kind='stage'`) **человеческим текстом**;
- `SalesProspectsAdvanceJob` — автоматические стадии по деньгам (`testing` / `topped_up` /
`user`), пишет `$p->save()` и **не оставляет вообще ничего**.
То есть ровно те движения, которые владелец назвал первыми («потратил деньги, ушёл
в тестирование, пополнил счёт, стал клиентом»), сейчас невидимы. И у карточки нет ни
`prev_stage`, ни `stage_changed_at` — счётчик `69/1` тоже считать не из чего.
**Решение — один шов, а не два.** Заводим таблицу движений `sales_prospect_moves` и пишем
в неё из **события модели** `SalesProspect`, а не из контроллера и джобы по отдельности.
Любой код, который меняет стадию — сегодняшний, завтрашний, случайный — попадёт в журнал
автоматически. Две честные половинки с голым швом посередине — это ровно тот класс дыр,
на котором мы уже обжигались (см. память `feedback-shov-mezhdu-chestnymi-polovinkami`).
## 4. База
### 4.1. `sales_prospects` — две правки
- `+ prev_stage VARCHAR(16) NULL` — откуда карточка приехала на текущую стадию.
Пишется тем же событием модели. Нужен счётчику `69/1`.
- CHECK `sales_prospects_stage_check` пересобирается на **12** значений — добавляется `trash`.
### 4.2. Новая таблица `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) -- кто двигал; NULL = джоба
from_stage VARCHAR(16) -- NULL у самой первой записи
to_stage VARCHAR(16) NOT NULL
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
```
Индексы: `(prospect_id, created_at DESC)` — история карточки; `(created_at)` — фильтр по периоду.
Append-only, как и журнал разговоров: движение — это факт, его не правят.
🔴 **GRANT.** Новой таблице мало `CREATE TABLE`: без явных `GRANT SELECT, INSERT` ролям
`crm_admin_user` и `crm_supplier_worker` на бою будет отказ прав, а на dev (superuser)
это невидимо. Блок GRANT копируем один в один из `create_sales_prospect_notes_table`.
### 4.3. Чего не делаем
Задним числом историю не восстанавливаем: `prev_stage` у существующих карточек остаётся
пустым. Разбирать текст старых записей журнала («Ручное тестирование: …» перед «Отказ: …»)
— гадание на строках. Практического ущерба нет: ручное тестирование появилось только
31.07.2026, истории почти нет. **Следствие: первое время счётчик будет показывать просто
`69`, дробь появится с первым новым отказом после ручного тестирования.**
## 5. Событие модели — где пишется движение
В `SalesProspect::booted()`:
- `saving` — если `stage` изменился, кладём в `prev_stage` прежнее значение;
- `saved` — если стадия менялась, вставляем строку в `sales_prospect_moves`.
Автор движения — `auth('sales')->user()?->id`. В джобе авторизации нет → `null`, и это
правильно: движение по деньгам сделал не человек.
Вставка идёт через `$prospect->getConnection()` — джоба работает на `pgsql_admin`, портал
на дефолтном соединении; жёстко зашитое соединение сломало бы одну из сторон.
## 6. Стадия `trash`
- Ключ `trash`, заголовок **«Корзина»**, цвет `#57534E` (серый камень), `manual: true`.
- Место — **последнее**, после «Не смогли дозвониться».
- Результат разговора **«В корзину»**: одно поле — причина (обязательна). Даты созвона нет;
при переезде в корзину `next_call_at` **очищается** — карточка не должна больше всплывать
в фильтре «что надо сделать».
- Доступен на любой стадии, **кроме** `topped_up` и `user`: выбрасывать платящего клиента
нельзя, это тот же запрет, что уже стоит на «Отказе» и новых стадиях.
- Из корзины можно выбраться: «Зарегистрировался» и прочие результаты остаются доступны
(карточку могли выбросить по ошибке).
**Реклама:** `AdAudienceScheduler::decide()` получает явную ветку
`if ($stage === 'trash') return stopped('trashed')` — прогрев встаёт сразу, в отличие от
«Отказа», который догревается ещё `rejected_days`. Без явной ветки стадия попала бы
в `unknown_stage` и реклама встала бы **молча** — итог тот же, но по случайности, а не
по решению; на такой случайности нельзя строить.
## 7. Счётчик `69/1`
Считается на фронте из данных, которые уже приезжают: у колонки `rejected` второе число —
`cards.filter(c => c.prev_stage === 'manual_testing').length`.
- Дробь показывается **только когда второе число больше нуля**: «69/0» — шум.
- Подсказка при наведении: «из них 1 после ручного тестирования».
- Считается ровно «пришла прямо из ручного тестирования». Путь
`ручное тестирование → переговоры → отказ` в дробь не попадает — владелец сказал
«когда после ручного тестирования карточка попадает в поле отказ».
- При включённом фильтре по датам дробь считается по отфильтрованным карточкам — как
и основное число.
## 8. Фильтр по датам
### 8.1. Один орган управления, два режима
Владелец назвал это «двумя фильтрами». Делаем **один** орган с выбором режима, потому что
режимы взаимоисключающие по смыслу и никогда не нужны одновременно: утром смотрят «что
надо сделать сегодня», вечером — «что я сделал за день». Два независимых органа
пересекались бы друг с другом и путали.
```
[ Все карточки ▾ ] [ Сегодня ▾ ]
Все карточки
Что надо сделать
Что менялось
```
Второй список — период: **Сегодня / Завтра / Вчера / 7 дней / 30 дней / Произвольный
период** (календарь-диапазон). Он появляется, только когда выбран режим.
Одинаковый орган и у менеджера, и у начальника — компонент один.
### 8.2. Режим «Что надо сделать»
Карточки, по которым в выбранный срок надо звонить или что-то делать:
`next_call_at` попадает в период **ИЛИ** `next_call_at` уже просрочен (`< сейчас`).
Просроченные показываются **всегда**, каким бы период ни выбрали — владелец просил их
отдельно («и что просрочено»), и забытый звонок недельной давности важнее завтрашнего.
Они и так подсвечены красным на доске.
Из выборки исключены стадии `rejected` и `trash` — по ним делать нечего. Карточки без
даты созвона в этот режим не попадают: делать по ним нечего в конкретный срок.
### 8.3. Режим «Что менялось»
Карточки, у которых **есть хоть одно движение** в выбранный период. Движением считается:
- запись в `sales_prospect_moves` (смена стадии — любая, ручная и автоматическая);
- запись в `sales_prospect_notes` (менеджер записал разговор, даже не двигая карточку —
это тоже сделанная работа).
Так «что я сделал за день» покрывает и звонки, и автоматику по деньгам — ровно то, что
просил владелец.
### 8.4. Границы дат
Периоды считаются в МСК существующим `SalesPeriodResolver` — тем же, что «30 дней»
на других экранах портала. К нему добавляется вид `tomorrow` (владелец назвал «завтра»
прямо). Приложение живёт в UTC, менеджер думает в МСК — своей арифметики дат не заводим,
это уже решённая в проекте задача.
## 9. Что отрезано (осознанно)
- Восстановление истории движений задним числом — см. §4.3.
- Второе число в шапках других колонок. Владелец просил дробь только у «Отказа».
- Показ движений в истории карточки. Журнал движений пока служебный — для фильтра
и счётчика. Показать его человеку можно потом, это отдельная работа.
- Автоматическая чистка корзины по сроку. Корзина — это просто последняя колонка,
ничего само не удаляется.
- Сохранение выбранного фильтра между заходами. Каждый заход — чистая доска.
## 10. Проверка
Кроме тестов — **приёмка глазами** в браузере, обязательно на обоих экранах:
1. Карточка едет в «Корзину» с одной причиной, даты не просит.
2. Платящему клиенту результат «В корзину» не предлагается.
3. «Выслано КП» сохраняется **без** даты и **с** датой.
4. После «ручное тестирование → отказ» в шапке «Отказа» появляется дробь.
5. Фильтр «Что надо сделать» на «Сегодня» показывает сегодняшние созвоны и просроченные.
6. Фильтр «Что менялось» на «Сегодня» показывает карточку, которую только что подвинули.
7. Всё то же самое в «Воронке отдела» у начальника.