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>
@@ -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` | Карточка «Центр дентальной имплантации», результат «Выслано КП», обведено лишнее обязательное поле «Следующий созвон» |
|
||||
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
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. Всё то же самое в «Воронке отдела» у начальника.
|
||||