From e1374fd9678cef498262ea3f19b712adc1115935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 1 Aug 2026 11:40:18 +0300 Subject: [PATCH 1/8] =?UTF-8?q?docs(=D0=B2=D0=BE=D1=80=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B4=D0=B0=D0=B6):=20=D0=B7=D0=B0?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=20=D0=B2=D0=BB=D0=B0=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D1=86=D0=B0=20=E2=80=94=20=D0=BA=D0=BE=D1=80?= =?UTF-8?q?=D0=B7=D0=B8=D0=BD=D0=B0,=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=D1=8B=20=D0=BF=D0=BE=20=D0=B4=D0=B0=D1=82=D0=B0=D0=BC,?= =?UTF-8?q?=20=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20=D0=B4=D1=80=D0=BE=D0=B1=D1=8C,=20=D0=9A?= =?UTF-8?q?=D0=9F=20=D0=B1=D0=B5=D0=B7=20=D0=B4=D0=B0=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Просьба записана дословно, три скрина описаны словами, заведена папка docs/superpowers/screens/2026-08-01-korzina-filtry/ под сами картинки. Собраны находки по коду (счётчик колонки, фильтры доски, отсутствие истории стадий) и четыре вопроса владельцу с рекомендациями. --- docs/observer/STATUS.md | 20 ++-- ...8-01-ZAPROS-korzina-filtry-schetchik-KP.md | 110 ++++++++++++++++++ .../2026-08-01-korzina-filtry/README.md | 10 ++ 3 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 docs/superpowers/2026-08-01-ZAPROS-korzina-filtry-schetchik-KP.md create mode 100644 docs/superpowers/screens/2026-08-01-korzina-filtry/README.md diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index bb8569c5..bf3cbc4b 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-07-31T08:13:53.842Z +Last updated: 2026-08-01T08:40:24.582Z | Контролёр | Состояние | Детали | |---|---|---| @@ -15,7 +15,7 @@ Last updated: 2026-07-31T08:13:53.842Z ⚠️ **ПОСТ ПУСТОЙ** — не зарегистрированы: enforce-floor.mjs, enforce-supreme-gate.mjs, enforce-normative-content-rules.mjs, enforce-read-path-deny.mjs, enforce-mcp-classification.mjs, enforce-judge-gate.mjs, enforce-snapshot.mjs, enforce-floor-escape-consume.mjs, enforce-skill-journaler.mjs, enforce-verify-gate.mjs, enforce-criterion-gate.mjs, enforce-coverage-verify.mjs, enforce-todowrite-skill-verifier.mjs (оборона НЕ подтверждена; SE-B/Δ8) -Судья М4: **live-block** (inert $0 / shadow / floor-only / live-block) +Судья М4: **inert** (inert $0 / shadow / floor-only / live-block) | Машина / страж | Хук | Зарегистрирован | |---|---|---| @@ -39,7 +39,7 @@ Last updated: 2026-07-31T08:13:53.842Z - Observer evidence: 0 episodes this month, 0 observer_error markers, 0 PII matches before filter - Legacy v1 episodes (not in factor analysis): 0 -- Last /brain-retro: 65 day(s) ago +- Last /brain-retro: 66 day(s) ago - Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store). ## Метрики дисциплины @@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3544 | MsMpEng | 17.50ч | 0.0ч | -| 23936 | Code | 5.15ч | NaNч | -| 4 | System | 2.66ч | NaNч | +| 3544 | MsMpEng | 27.66ч | NaNч | +| 23936 | Code | 9.90ч | 16668923.2ч | +| 4 | System | 4.07ч | 0.0ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. @@ -130,13 +130,7 @@ Episodes since last run: 542 / threshold: 10 ## Целостность журналов действий -🔴 Битые цепочки (3 из 120): - -| session | broken at seq | -|---|---| -| `03437265-6d58-4622-aeed-c0eeac0f2c32` | 1 | -| `54594686-843c-4ea8-bcd3-5ae6a7244e30` | 14 | -| `9c02276d-dabb-40e4-9c04-44c18d47485a` | 14 | +Ключ подписанта не provisioned — проверка цепи недоступна (ключ — owner-шаг A3). ## Алерт-индикаторы diff --git a/docs/superpowers/2026-08-01-ZAPROS-korzina-filtry-schetchik-KP.md b/docs/superpowers/2026-08-01-ZAPROS-korzina-filtry-schetchik-KP.md new file mode 100644 index 00000000..24e6e598 --- /dev/null +++ b/docs/superpowers/2026-08-01-ZAPROS-korzina-filtry-schetchik-KP.md @@ -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): + `{{ col.cards.length }}` — сюда пойдёт `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/`, не из корня. diff --git a/docs/superpowers/screens/2026-08-01-korzina-filtry/README.md b/docs/superpowers/screens/2026-08-01-korzina-filtry/README.md new file mode 100644 index 00000000..15cf1e5f --- /dev/null +++ b/docs/superpowers/screens/2026-08-01-korzina-filtry/README.md @@ -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` | Карточка «Центр дентальной имплантации», результат «Выслано КП», обведено лишнее обязательное поле «Следующий созвон» | From 2248cb7b79c663450cc9b9d1bd735a12a8c4eee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 1 Aug 2026 11:41:41 +0300 Subject: [PATCH 2/8] =?UTF-8?q?docs(=D0=B2=D0=BE=D1=80=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B4=D0=B0=D0=B6):=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=BC=D1=82=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=D0=B4=D1=83=D1=8E=D1=89=D0=B5=D0=B9=20=D1=81=D0=B5=D1=81=D1=81?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=BF=D0=BE=20=D0=BA=D0=BE=D1=80=D0=B7=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=B8=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Готовый текст, который владелец копирует в новую сессию: четыре куска работы, порядок (вопросы → спека → план → код), мины и грабли окружения. --- docs/observer/STATUS.md | 8 ++-- ...26-08-01-PROMT-korzina-filtry-schetchik.md | 47 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/2026-08-01-PROMT-korzina-filtry-schetchik.md diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index bf3cbc4b..791bbb59 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-08-01T08:40:24.582Z +Last updated: 2026-08-01T08:40:47.936Z | Контролёр | Состояние | Детали | |---|---|---| @@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3544 | MsMpEng | 27.66ч | NaNч | -| 23936 | Code | 9.90ч | 16668923.2ч | -| 4 | System | 4.07ч | 0.0ч | +| 3544 | MsMpEng | 27.68ч | 0.0ч | +| 23936 | Code | 9.91ч | 16668923.2ч | +| 4 | System | 4.07ч | NaNч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. diff --git a/docs/superpowers/2026-08-01-PROMT-korzina-filtry-schetchik.md b/docs/superpowers/2026-08-01-PROMT-korzina-filtry-schetchik.md new file mode 100644 index 00000000..9d1f3c71 --- /dev/null +++ b/docs/superpowers/2026-08-01-PROMT-korzina-filtry-schetchik.md @@ -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 **ничего не выкатывать** без отдельной команды владельца. From 53ebcd9fe02139118f356d3f9ae6458605551707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 1 Aug 2026 12:05:04 +0300 Subject: [PATCH 3/8] =?UTF-8?q?feat(=D0=B2=D0=BE=D1=80=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B4=D0=B0=D0=B6):=20=D0=B6=D1=83?= =?UTF-8?q?=D1=80=D0=BD=D0=B0=D0=BB=20=D0=B4=D0=B2=D0=B8=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8?= =?UTF-8?q?=20+=20=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=BF=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BA=D0=BE=D1=80=D0=B7=D0=B8=D0=BD=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Стадию меняют два разных места — результат разговора менеджера и автопереезд по деньгам. Джоба не оставляла следа вообще, поэтому «что менялось за день» было не из чего построить. Запись движения перенесена в событие модели: один шов на всех, включая любой будущий третий источник. - sales_prospect_moves — журнал всех движений (append-only, с GRANT'ами ролям); - prev_stage на карточке — откуда приехала (кормит счётчик «69/1» у «Отказа»); - stage += 'trash' — место под колонку «Корзина». Сторож проверен вырезанием: без записи движения краснеют 4 теста, в том числе тест джобы и тест API. --- app/app/Models/SalesProspect.php | 53 ++++- app/app/Models/SalesProspectMove.php | 48 +++++ ...000_add_trash_stage_and_prospect_moves.php | 94 ++++++++ .../Feature/Sales/SalesProspectMoveTest.php | 121 +++++++++++ docs/observer/STATUS.md | 8 +- .../2026-08-01-korzina-filtry-schetchik.md | 148 +++++++++++++ ...6-08-01-korzina-filtry-schetchik-design.md | 204 ++++++++++++++++++ 7 files changed, 671 insertions(+), 5 deletions(-) create mode 100644 app/app/Models/SalesProspectMove.php create mode 100644 app/database/migrations/2026_08_01_100000_add_trash_stage_and_prospect_moves.php create mode 100644 app/tests/Feature/Sales/SalesProspectMoveTest.php create mode 100644 docs/superpowers/plans/2026-08-01-korzina-filtry-schetchik.md create mode 100644 docs/superpowers/specs/2026-08-01-korzina-filtry-schetchik-design.md diff --git a/app/app/Models/SalesProspect.php b/app/app/Models/SalesProspect.php index 012eafe3..3503ff56 100644 --- a/app/app/Models/SalesProspect.php +++ b/app/app/Models/SalesProspect.php @@ -34,6 +34,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property string|null $rating_label * @property array $payload * @property Carbon|null $next_call_at + * @property string|null $prev_stage * @property string|null $reason * @property string|null $registered_email * @property string|null $test_channel @@ -56,7 +57,7 @@ class SalesProspect extends Model 'sales_user_id', 'assigned_by', 'stage', 'source', 'firm_name', 'legal_name', 'contacts', 'city', 'phone', 'site', 'inn', 'rating_label', 'payload', 'next_call_at', 'reason', 'registered_email', 'linked_tenant_id', 'notes', - 'test_channel', 'test_target', 'kp_channel', 'kp_target', + 'test_channel', 'test_target', 'kp_channel', 'kp_target', 'prev_stage', ]; /** Дефолты in-memory (совпадают с DB DEFAULT), чтобы поля были доступны до refresh. */ @@ -82,4 +83,54 @@ class SalesProspect extends Model { return $this->belongsTo(SalesUser::class, 'sales_user_id'); } + + /** + * Журнал движений пишется ЗДЕСЬ, а не в контроллере и джобе по отдельности. + * + * Стадию меняют два разных места: результат разговора менеджера + * (SalesProspectController) и автопереезд по деньгам (SalesProspectsAdvanceJob). + * Джоба раньше не оставляла следа вообще — половина движений была невидима. + * Событие модели ловит обе стороны и любую будущую третью: забыть его нельзя, + * потому что стадия без save() не меняется. + */ + protected static function booted(): void + { + // prev_stage снимаем ДО записи: после save() getOriginal('stage') уже + // равен новой стадии — прежнюю в событии `updated` взять было бы неоткуда. + static::saving(function (self $prospect): void { + if ($prospect->exists && $prospect->isDirty('stage')) { + $prospect->prev_stage = (string) $prospect->getOriginal('stage'); + } + }); + + // Заведение карточки — тоже движение: из ниоткуда в первую стадию. + static::created(fn (self $prospect) => $prospect->logMove(null)); + + // Именно `updated`, а не `saved`: у только что созданного объекта + // wasRecentlyCreated остаётся true до конца его жизни, и второе + // сохранение (даже без смены стадии) писало бы лишнее движение. + static::updated(function (self $prospect): void { + if ($prospect->wasChanged('stage')) { + $prospect->logMove($prospect->prev_stage); + } + }); + } + + /** + * Строка в журнале движений. + * + * Соединение берём у самой карточки: джоба живёт на pgsql_admin, портал — + * на дефолтном. Жёстко зашитое соединение сломало бы одну из сторон. + */ + private function logMove(?string $fromStage): void + { + $this->getConnection()->table('sales_prospect_moves')->insert([ + 'prospect_id' => $this->id, + // Двигал не человек (джоба по деньгам аккаунта) → автора нет. + 'sales_user_id' => auth('sales')->id(), + 'from_stage' => $fromStage, + 'to_stage' => $this->stage, + 'created_at' => now(), + ]); + } } diff --git a/app/app/Models/SalesProspectMove.php b/app/app/Models/SalesProspectMove.php new file mode 100644 index 00000000..d83c84b9 --- /dev/null +++ b/app/app/Models/SalesProspectMove.php @@ -0,0 +1,48 @@ + 'datetime']; + } + + /** @return BelongsTo */ + public function prospect(): BelongsTo + { + return $this->belongsTo(SalesProspect::class, 'prospect_id'); + } +} diff --git a/app/database/migrations/2026_08_01_100000_add_trash_stage_and_prospect_moves.php b/app/database/migrations/2026_08_01_100000_add_trash_stage_and_prospect_moves.php new file mode 100644 index 00000000..6f4f2cd6 --- /dev/null +++ b/app/database/migrations/2026_08_01_100000_add_trash_stage_and_prospect_moves.php @@ -0,0 +1,94 @@ +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); + } +}; diff --git a/app/tests/Feature/Sales/SalesProspectMoveTest.php b/app/tests/Feature/Sales/SalesProspectMoveTest.php new file mode 100644 index 00000000..43623dc7 --- /dev/null +++ b/app/tests/Feature/Sales/SalesProspectMoveTest.php @@ -0,0 +1,121 @@ + 'MV '.uniqid(), 'email' => 'mv'.uniqid().'@s.local', + 'password' => bcrypt('secret'), 'role' => $role, 'is_active' => true, + ]); +} + +/** @return list */ +function mv_moves(SalesProspect $p): array +{ + return SalesProspectMove::query() + ->where('prospect_id', $p->id) + ->orderBy('id') + ->get() + ->map(fn (SalesProspectMove $m) => [ + 'from' => $m->from_stage, + 'to' => $m->to_stage, + 'by' => $m->sales_user_id, + ]) + ->all(); +} + +// ── событие модели: журнал движений ─────────────────────────────────────────── + +test('заведение карточки пишет первое движение — из ниоткуда в стадию', function () { + $mgr = mv_user(); + $p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'new']); + + expect(mv_moves($p))->toBe([['from' => null, 'to' => 'new', 'by' => null]]); +}); + +test('смена стадии пишет движение и заполняет prev_stage', function () { + $mgr = mv_user(); + $p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'new']); + + $p->stage = 'in_work'; + $p->save(); + + expect($p->prev_stage)->toBe('new'); + expect(mv_moves($p))->toBe([ + ['from' => null, 'to' => 'new', 'by' => null], + ['from' => 'new', 'to' => 'in_work', 'by' => null], + ]); +}); + +test('сохранение БЕЗ смены стадии движения не пишет', function () { + $mgr = mv_user(); + $p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'new']); + + $p->city = 'Томск'; + $p->save(); + + expect(mv_moves($p))->toHaveCount(1); +}); + +test('несколько переездов подряд ложатся цепочкой, prev_stage — только последний', function () { + $mgr = mv_user(); + $p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'new']); + + foreach (['in_work', 'negotiation', 'manual_testing'] as $stage) { + $p->stage = $stage; + $p->save(); + } + + expect($p->prev_stage)->toBe('negotiation'); + expect(array_column(mv_moves($p), 'to'))->toBe(['new', 'in_work', 'negotiation', 'manual_testing']); +}); + +// ── движения пишутся ОТОВСЮДУ: и от менеджера, и от джобы ───────────────────── + +test('результат разговора через API пишет движение с автором-менеджером', function () { + $mgr = mv_user(); + $p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']); + + $this->actingAs($mgr, 'sales') + ->patchJson("/api/sales/prospects/{$p->id}", [ + 'action' => 'negotiation', + 'next_call_at' => '2026-08-05T12:00:00', + ]) + ->assertOk(); + + $moves = mv_moves($p); + expect(end($moves))->toBe(['from' => 'in_work', 'to' => 'negotiation', 'by' => $mgr->id]); +}); + +test('автопереезд по деньгам пишет движение без автора', function () { + $mgr = mv_user(); + $tenant = Tenant::factory()->create(); + $p = SalesProspect::factory()->for($mgr, 'salesUser')->create([ + 'stage' => 'registered', + 'linked_tenant_id' => $tenant->id, + ]); + + DB::table('balance_transactions')->insert([ + 'tenant_id' => $tenant->id, + 'type' => 'topup', + 'amount_rub' => 5000, + 'description' => 'пополнение', + 'created_at' => now(), + ]); + + (new SalesProspectsAdvanceJob)->handle(); + + $moves = mv_moves($p); + expect(end($moves))->toBe(['from' => 'registered', 'to' => 'topped_up', 'by' => null]); +}); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 791bbb59..5d1d6bfe 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-08-01T08:40:47.936Z +Last updated: 2026-08-01T08:41:44.677Z | Контролёр | Состояние | Детали | |---|---|---| @@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3544 | MsMpEng | 27.68ч | 0.0ч | -| 23936 | Code | 9.91ч | 16668923.2ч | -| 4 | System | 4.07ч | NaNч | +| 3544 | MsMpEng | 27.74ч | NaNч | +| 23936 | Code | 9.91ч | 0.0ч | +| 4 | System | 4.07ч | 0.0ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. diff --git a/docs/superpowers/plans/2026-08-01-korzina-filtry-schetchik.md b/docs/superpowers/plans/2026-08-01-korzina-filtry-schetchik.md new file mode 100644 index 00000000..81ebfd35 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-korzina-filtry-schetchik.md @@ -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.** Коммит. На боевой **не выкатывать** без отдельной команды владельца. diff --git a/docs/superpowers/specs/2026-08-01-korzina-filtry-schetchik-design.md b/docs/superpowers/specs/2026-08-01-korzina-filtry-schetchik-design.md new file mode 100644 index 00000000..708ed1f1 --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-korzina-filtry-schetchik-design.md @@ -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. Всё то же самое в «Воронке отдела» у начальника. From 016416502aec5c5e9a4b4b10f2ae257e6f99ee63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 1 Aug 2026 12:41:24 +0300 Subject: [PATCH 4/8] =?UTF-8?q?feat(=D0=B2=D0=BE=D1=80=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B4=D0=B0=D0=B6):=20=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=D0=B7=D0=B8=D0=BD=D0=B0,=20=D0=9A=D0=9F=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=BE=D0=B1=D1=8F=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D0=B4=D0=B0=D1=82=D1=8B,=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D1=80=20=D0=BF=D0=BE=20=D0=B4=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=D0=BC=20=E2=80=94=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - результат «В корзину»: одна причина, дата созвона стирается, платящего не выбросить; - реклама на корзине встаёт СРАЗУ — явной веткой, а не случайно через unknown_stage; - у «Выслано КП» дата созвона стала необязательной: бывает «пришлите на почту, если интересно — перезвоню». Врущий старый тест на 422 без даты поправлен; - фильтр доски date_mode=todo|changed + период (добавлен вид «завтра»). «Надо сделать» — созвон в периоде ИЛИ просрочен, кроме отказа и корзины. «Менялось» — есть движение стадии ИЛИ запись разговора за период. Прогон: 1245/1245 (отдел продаж + все юнит-тесты). --- .../Api/Sales/SalesProspectController.php | 91 ++++++++- .../Services/Sales/AdAudienceScheduler.php | 9 + .../Services/Sales/SalesPeriodResolver.php | 2 + .../Feature/Sales/SalesProspectApiTest.php | 87 +++++++- .../Sales/SalesProspectDateFilterTest.php | 193 ++++++++++++++++++ app/tests/Unit/AdAudienceSchedulerTest.php | 17 ++ .../Unit/Sales/SalesPeriodResolverTest.php | 7 + docs/observer/STATUS.md | 8 +- 8 files changed, 401 insertions(+), 13 deletions(-) create mode 100644 app/tests/Feature/Sales/SalesProspectDateFilterTest.php diff --git a/app/app/Http/Controllers/Api/Sales/SalesProspectController.php b/app/app/Http/Controllers/Api/Sales/SalesProspectController.php index 7d64f56a..980f7026 100644 --- a/app/app/Http/Controllers/Api/Sales/SalesProspectController.php +++ b/app/app/Http/Controllers/Api/Sales/SalesProspectController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\Sales; +use App\Http\Controllers\Concerns\ResolvesSalesPeriod; use App\Http\Controllers\Controller; use App\Models\SalesAdAudienceFirm; use App\Models\SalesAdAudienceWarmingEpisode; @@ -30,10 +31,15 @@ use Illuminate\Validation\ValidationException; */ class SalesProspectController extends Controller { + use ResolvesSalesPeriod; + + /** Стадии, по которым делать уже нечего — в фильтр «что надо сделать» не попадают. */ + private const DEAD_STAGES = ['rejected', 'trash']; + /** Все стадии в порядке колонок канбана. Должно совпадать с PROSPECT_STAGES (фронт). */ private const STAGES = [ 'new', 'in_work', 'negotiation', 'manual_testing', 'registered', 'testing', - 'topped_up', 'user', 'kp_sent', 'rejected', 'no_answer', + 'topped_up', 'user', 'kp_sent', 'rejected', 'no_answer', 'trash', ]; /** @@ -78,6 +84,8 @@ class SalesProspectController extends Controller $query->where('source', (string) $request->query('source')); } + $this->applyDateFilter($query, $request); + $all = $query->get(); $warmingByProspect = $this->warmingByProspect($all->pluck('id')->all()); $prospects = $all->map(fn (SalesProspect $p) => $this->row($p, $warmingByProspect))->all(); @@ -108,6 +116,57 @@ class SalesProspectController extends Controller ]); } + /** + * Фильтр доски по датам. Два режима, они взаимоисключающие по смыслу: + * + * todo — «что надо сделать»: созвон попадает в период ИЛИ уже просрочен. + * Просроченное показывается ВСЕГДА, какой бы период ни выбрали: + * забытый звонок недельной давности важнее завтрашнего. Отказ и + * корзина не показываются — по ним делать нечего. + * changed — «что менялось»: у карточки есть движение по стадиям ИЛИ запись + * в журнале разговоров за период. Движения включают автоматические + * (деньги → тестирование/пополнил/пользователь) — владелец просил + * считать ЛЮБОЕ движение, а не только разговоры. + * + * Без date_mode доска отдаёт всё, как раньше. + * + * @param \Illuminate\Database\Eloquent\Builder $query + */ + private function applyDateFilter(\Illuminate\Database\Eloquent\Builder $query, Request $request): void + { + $mode = (string) $request->query('date_mode', ''); + if (! in_array($mode, ['todo', 'changed'], true)) { + return; + } + + $range = $this->resolvePeriod($request); + + if ($mode === 'todo') { + $query->whereNotIn('stage', self::DEAD_STAGES) + ->whereNotNull('next_call_at') + ->where(function ($q) use ($range) { + $q->whereBetween('next_call_at', [$range->start, $range->end]) + ->orWhere('next_call_at', '<', now()); + }); + + return; + } + + $query->where(function ($q) use ($range) { + $q->whereExists(function ($sub) use ($range) { + $sub->selectRaw('1') + ->from('sales_prospect_moves') + ->whereColumn('sales_prospect_moves.prospect_id', 'sales_prospects.id') + ->whereBetween('sales_prospect_moves.created_at', [$range->start, $range->end]); + })->orWhereExists(function ($sub) use ($range) { + $sub->selectRaw('1') + ->from('sales_prospect_notes') + ->whereColumn('sales_prospect_notes.prospect_id', 'sales_prospects.id') + ->whereBetween('sales_prospect_notes.created_at', [$range->start, $range->end]); + }); + }); + } + /** * Менеджер заводит СВОЕГО кандидата (инициатива, не из поиска). * Карточка всегда создаётся автору (sales_user_id из тела игнорируется), @@ -416,19 +475,39 @@ class SalesProspectController extends Controller case 'kp_sent': $this->assertNotPaying($prospect); $data = $request->validate([ - 'next_call_at' => ['required', 'date'], + // Дата созвона НЕобязательна (правка 01.08.2026): бывает «пришлите, + // созвонимся через пару дней» — дата есть, и бывает «пришлите на + // почту, если интересно — перезвоню» — договорённости о созвоне нет. + 'next_call_at' => ['nullable', 'date'], 'kp_channel' => ['required', 'string', Rule::in(array_keys(self::KP_CHANNEL_TITLES))], // Обязательно и при «другое»: запись «КП ушло куда-то» бесполезна. 'kp_target' => ['required', 'string', 'max:500'], ]); $prospect->stage = 'kp_sent'; - $prospect->next_call_at = $data['next_call_at']; $prospect->kp_channel = $data['kp_channel']; // Телеграм-ник, почта и номер — разные вещи, к одному виду не приводим. $prospect->kp_target = trim($data['kp_target']); $prospect->reason = null; - $logEntry = 'Выслано КП: '.self::KP_CHANNEL_TITLES[$data['kp_channel']].' '.$prospect->kp_target - .'. Следующий созвон '.$prospect->next_call_at->format('d.m.Y H:i'); + $logEntry = 'Выслано КП: '.self::KP_CHANNEL_TITLES[$data['kp_channel']].' '.$prospect->kp_target; + if (! empty($data['next_call_at'])) { + $prospect->next_call_at = $data['next_call_at']; + $logEntry .= '. Следующий созвон '.$prospect->next_call_at->format('d.m.Y H:i'); + } + break; + + case 'trash': + // Карточка, с которой больше не работаем: мусор, дубль, не наш профиль. + // Платящего выбросить нельзя — тот же запрет, что у «Отказа». + $this->assertNotPaying($prospect); + $data = $request->validate([ + 'reason' => ['required', 'string', 'max:2000'], + ]); + $prospect->stage = 'trash'; + $prospect->reason = $data['reason']; + // Дату созвона стираем: иначе выброшенная карточка продолжала бы + // всплывать в фильтре «что надо сделать» и красным на доске. + $prospect->next_call_at = null; + $logEntry = 'В корзину: '.$data['reason']; break; case 'no_answer': @@ -697,6 +776,8 @@ class SalesProspectController extends Controller 'id' => $p->id, 'sales_user_id' => $p->sales_user_id, 'stage' => $p->stage, + // Откуда карточка приехала — из него доска считает дробь «69/1» у «Отказа». + 'prev_stage' => $p->prev_stage, 'source' => $p->source, 'firm_name' => $p->firm_name, 'legal_name' => $p->legal_name, diff --git a/app/app/Services/Sales/AdAudienceScheduler.php b/app/app/Services/Sales/AdAudienceScheduler.php index 2e0af4cd..81ea9cba 100644 --- a/app/app/Services/Sales/AdAudienceScheduler.php +++ b/app/app/Services/Sales/AdAudienceScheduler.php @@ -54,6 +54,15 @@ class AdAudienceScheduler return $this->stopped('client_won'); } + // Корзина — карточку выбросили, греть некого. Стоп СРАЗУ, в отличие от + // «Отказа», который догревается ещё rejected_days: отказавший может + // передумать, а выброшенный мусор — нет. Ветка явная не для красоты: + // без неё стадия попала бы в 'unknown_stage' и реклама встала бы молча, + // по случайности, а не по решению. + if ($stage === 'trash') { + return $this->stopped('trashed'); + } + // Три стадии с обязательной датой созвона живут по одному правилу: крутим // до созвона, потом ещё negotiation_overdue_days, дальше стоп. Без этой // строки новые стадии попали бы в 'unknown_stage' и реклама МОЛЧА встала бы. diff --git a/app/app/Services/Sales/SalesPeriodResolver.php b/app/app/Services/Sales/SalesPeriodResolver.php index cee97054..fa70371a 100644 --- a/app/app/Services/Sales/SalesPeriodResolver.php +++ b/app/app/Services/Sales/SalesPeriodResolver.php @@ -12,6 +12,7 @@ use InvalidArgumentException; * * Поддерживаемые kind: * 'today' — сегодня. + * 'tomorrow' — завтра (нужен фильтру воронки «что надо сделать»). * 'yesterday' — вчера. * 'd7' — последние 7 дней, включая сегодня. * 'd30' — последние 30 дней, включая сегодня. @@ -40,6 +41,7 @@ final class SalesPeriodResolver return match ($kind) { 'today' => $this->lastDays(1), + 'tomorrow' => $this->dayRange(1), 'yesterday' => $this->dayRange(-1), 'd7' => $this->lastDays(7), 'd30' => $this->lastDays(30), diff --git a/app/tests/Feature/Sales/SalesProspectApiTest.php b/app/tests/Feature/Sales/SalesProspectApiTest.php index 7aa9ed17..d198371a 100644 --- a/app/tests/Feature/Sales/SalesProspectApiTest.php +++ b/app/tests/Feature/Sales/SalesProspectApiTest.php @@ -109,9 +109,9 @@ test('by_stage содержит обе новые колонки — иначе $res->assertOk(); expect($res->json('stages'))->toBe([ 'new', 'in_work', 'negotiation', 'manual_testing', 'registered', 'testing', - 'topped_up', 'user', 'kp_sent', 'rejected', 'no_answer', + 'topped_up', 'user', 'kp_sent', 'rejected', 'no_answer', 'trash', ]); - expect($res->json('by_stage'))->toHaveKeys(['manual_testing', 'kp_sent']); + expect($res->json('by_stage'))->toHaveKeys(['manual_testing', 'kp_sent', 'trash']); }); test('менеджер не может через manager_id увидеть чужие', function () { @@ -293,7 +293,7 @@ test('выслано КП: телеграм-ник сохраняется как ->assertJsonPath('prospect.kp_target', '@primer_director'); }); -test('выслано КП: без канала, без адреса, без даты созвона и с чужим каналом → 422', function () { +test('выслано КП: без канала, без адреса и с чужим каналом → 422', function () { $mgr = pr_user('manager'); $p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']); $body = [ @@ -303,7 +303,9 @@ test('выслано КП: без канала, без адреса, без да 'next_call_at' => '2026-08-05T12:00:00', ]; - foreach (['kp_channel', 'kp_target', 'next_call_at'] as $missing) { + // next_call_at здесь НЕ проверяется: с 01.08.2026 дата созвона у КП необязательна + // (клиент говорит «пришлите на почту, если интересно — перезвоню»). + foreach (['kp_channel', 'kp_target'] as $missing) { $without = $body; unset($without[$missing]); $this->actingAs($mgr, 'sales') @@ -318,6 +320,25 @@ test('выслано КП: без канала, без адреса, без да expect(SalesProspect::find($p->id)->stage)->toBe('negotiation'); }); +test('выслано КП: дата созвона необязательна — «пришлите, если интересно перезвоню»', function () { + $mgr = pr_user('manager'); + $p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']); + + $this->actingAs($mgr, 'sales') + ->patchJson("/api/sales/prospects/{$p->id}", [ + 'action' => 'kp_sent', + 'kp_channel' => 'email', + 'kp_target' => 'director@primer-firma.ru', + ]) + ->assertOk() + ->assertJsonPath('prospect.stage', 'kp_sent'); + + // Даты нет — и в журнале нет хвоста про созвон, иначе запись врала бы. + $note = SalesProspectNote::where('prospect_id', $p->id)->first(); + expect($note->body)->toContain('Выслано КП: почта director@primer-firma.ru'); + expect($note->body)->not->toContain('Следующий созвон'); +}); + test('выслано КП: «другое» тоже требует, куда именно отправили', function () { $mgr = pr_user('manager'); $p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']); @@ -352,6 +373,64 @@ test('выслано КП: в журнале одна запись с челов expect($notes[0]->body)->toBe('просил КП с ценами на 500 лидов'); }); +// ── корзина ─────────────────────────────────────────────────────────────────── + +test('в корзину: стадия, причина и запись в журнале', function () { + $mgr = pr_user('manager'); + $p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']); + + $this->actingAs($mgr, 'sales') + ->patchJson("/api/sales/prospects/{$p->id}", [ + 'action' => 'trash', + 'reason' => 'не наш профиль, торгуют оптом стройматериалами', + ]) + ->assertOk() + ->assertJsonPath('prospect.stage', 'trash') + ->assertJsonPath('prospect.reason', 'не наш профиль, торгуют оптом стройматериалами'); + + $note = SalesProspectNote::where('prospect_id', $p->id)->first(); + expect($note->body)->toContain('В корзину: не наш профиль'); +}); + +test('в корзину: дата созвона стирается — карточка не всплывает в «что надо сделать»', function () { + $mgr = pr_user('manager'); + $p = SalesProspect::factory()->for($mgr, 'salesUser')->create([ + 'stage' => 'negotiation', + 'next_call_at' => '2026-08-05T12:00:00', + ]); + + $this->actingAs($mgr, 'sales') + ->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'trash', 'reason' => 'дубль']) + ->assertOk(); + + expect(SalesProspect::find($p->id)->next_call_at)->toBeNull(); +}); + +test('в корзину без причины → 422', function () { + $mgr = pr_user('manager'); + $p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']); + + $this->actingAs($mgr, 'sales') + ->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'trash']) + ->assertStatus(422); + + expect(SalesProspect::find($p->id)->stage)->toBe('in_work'); +}); + +test('платящего клиента в корзину не выбросить', function () { + $mgr = pr_user('manager'); + + foreach (['topped_up', 'user'] as $paying) { + $p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => $paying]); + + $this->actingAs($mgr, 'sales') + ->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'trash', 'reason' => 'надоел']) + ->assertStatus(422); + + expect(SalesProspect::find($p->id)->stage)->toBe($paying); + } +}); + test('платящему клиенту оба новых результата запрещены — стадия не откатывается', function () { $mgr = pr_user('manager'); diff --git a/app/tests/Feature/Sales/SalesProspectDateFilterTest.php b/app/tests/Feature/Sales/SalesProspectDateFilterTest.php new file mode 100644 index 00000000..1ec52bdd --- /dev/null +++ b/app/tests/Feature/Sales/SalesProspectDateFilterTest.php @@ -0,0 +1,193 @@ + 'DF '.uniqid(), 'email' => 'df'.uniqid().'@s.local', + 'password' => bcrypt('secret'), 'role' => $role, 'is_active' => true, + ]); +} + +function df_card(SalesUser $mgr, string $name, string $stage, ?string $callAt = null): SalesProspect +{ + return SalesProspect::factory()->for($mgr, 'salesUser')->create([ + 'firm_name' => $name, + 'stage' => $stage, + 'next_call_at' => $callAt, + ]); +} + +/** @return list названия фирм в ответе */ +function df_names(\Illuminate\Testing\TestResponse $res): array +{ + return collect($res->json('prospects'))->pluck('firm_name')->sort()->values()->all(); +} + +// ── режим «что надо сделать» ────────────────────────────────────────────────── + +test('что надо сделать на сегодня: сегодняшний созвон и просроченный, но не завтрашний', function () { + $mgr = df_user(); + df_card($mgr, 'СЕГОДНЯ', 'negotiation', '2026-06-15T14:00:00'); + df_card($mgr, 'ПРОСРОЧЕН', 'negotiation', '2026-06-10T14:00:00'); + df_card($mgr, 'ЗАВТРА', 'negotiation', '2026-06-16T14:00:00'); + df_card($mgr, 'БЕЗ ДАТЫ', 'in_work'); + + $res = $this->actingAs($mgr, 'sales') + ->getJson('/api/sales/prospects?date_mode=todo&period=today'); + + $res->assertOk(); + expect(df_names($res))->toBe(['ПРОСРОЧЕН', 'СЕГОДНЯ']); +}); + +test('что надо сделать на завтра: завтрашний созвон И просроченное — забытое важнее', function () { + $mgr = df_user(); + df_card($mgr, 'ЗАВТРА', 'negotiation', '2026-06-16T14:00:00'); + df_card($mgr, 'ПРОСРОЧЕН', 'negotiation', '2026-06-10T14:00:00'); + df_card($mgr, 'СЕГОДНЯ', 'negotiation', '2026-06-15T14:00:00'); + + $res = $this->actingAs($mgr, 'sales') + ->getJson('/api/sales/prospects?date_mode=todo&period=tomorrow'); + + $res->assertOk(); + expect(df_names($res))->toBe(['ЗАВТРА', 'ПРОСРОЧЕН']); +}); + +test('что надо сделать: отказ и корзина не показываются даже с просроченной датой', function () { + $mgr = df_user(); + // Дату ставим в обход результата разговора — важно проверить сам фильтр, + // а не то, что «В корзину» её стирает. + df_card($mgr, 'ОТКАЗ', 'rejected', '2026-06-10T14:00:00'); + df_card($mgr, 'КОРЗИНА', 'trash', '2026-06-10T14:00:00'); + df_card($mgr, 'ЖИВАЯ', 'negotiation', '2026-06-10T14:00:00'); + + $res = $this->actingAs($mgr, 'sales') + ->getJson('/api/sales/prospects?date_mode=todo&period=today'); + + $res->assertOk(); + expect(df_names($res))->toBe(['ЖИВАЯ']); +}); + +test('что надо сделать за произвольный период', function () { + $mgr = df_user(); + df_card($mgr, 'В ПЕРИОДЕ', 'negotiation', '2026-06-20T14:00:00'); + df_card($mgr, 'ЗА ПЕРИОДОМ', 'negotiation', '2026-06-25T14:00:00'); + + $res = $this->actingAs($mgr, 'sales') + ->getJson('/api/sales/prospects?date_mode=todo&period=custom&from=2026-06-18&to=2026-06-21'); + + $res->assertOk(); + expect(df_names($res))->toBe(['В ПЕРИОДЕ']); +}); + +// ── режим «что менялось» ────────────────────────────────────────────────────── + +test('что менялось сегодня: движение стадии, запись разговора — да; вчерашнее — нет', function () { + $mgr = df_user(); + $moved = df_card($mgr, 'ПОДВИНУЛ', 'in_work'); + $talked = df_card($mgr, 'ЗАПИСАЛ РАЗГОВОР', 'in_work'); + $old = df_card($mgr, 'ВЧЕРАШНЯЯ', 'in_work'); + + // Все три карточки только что заведены → у всех есть движение «сегодня». + // Обнуляем журнал и расставляем события руками, иначе проверять нечего. + DB::table('sales_prospect_moves')->whereIn('prospect_id', [$moved->id, $talked->id, $old->id])->delete(); + + DB::table('sales_prospect_moves')->insert([ + 'prospect_id' => $moved->id, 'sales_user_id' => $mgr->id, + 'from_stage' => 'new', 'to_stage' => 'in_work', + 'created_at' => '2026-06-15 08:00:00', + ]); + SalesProspectNote::create([ + 'prospect_id' => $talked->id, 'sales_user_id' => $mgr->id, + 'kind' => 'note', 'body' => 'поговорили', + ]); + DB::table('sales_prospect_moves')->insert([ + 'prospect_id' => $old->id, 'sales_user_id' => $mgr->id, + 'from_stage' => 'new', 'to_stage' => 'in_work', + 'created_at' => '2026-06-14 08:00:00', + ]); + + $res = $this->actingAs($mgr, 'sales') + ->getJson('/api/sales/prospects?date_mode=changed&period=today'); + + $res->assertOk(); + expect(df_names($res))->toBe(['ЗАПИСАЛ РАЗГОВОР', 'ПОДВИНУЛ']); +}); + +test('что менялось: автопереезд по деньгам тоже считается движением', function () { + $mgr = df_user(); + $auto = df_card($mgr, 'САМ УЕХАЛ', 'registered'); + DB::table('sales_prospect_moves')->where('prospect_id', $auto->id)->delete(); + + // Автор пустой — двигала джоба, а не человек. + DB::table('sales_prospect_moves')->insert([ + 'prospect_id' => $auto->id, 'sales_user_id' => null, + 'from_stage' => 'registered', 'to_stage' => 'topped_up', + 'created_at' => '2026-06-15 03:00:00', + ]); + + $res = $this->actingAs($mgr, 'sales') + ->getJson('/api/sales/prospects?date_mode=changed&period=today'); + + $res->assertOk(); + expect(df_names($res))->toBe(['САМ УЕХАЛ']); +}); + +// ── фильтр не ломает обычную доску ──────────────────────────────────────────── + +test('без date_mode доска отдаёт всё, как раньше', function () { + $mgr = df_user(); + df_card($mgr, 'ОДНА', 'negotiation', '2026-01-01T14:00:00'); + df_card($mgr, 'ДВЕ', 'in_work'); + + $res = $this->actingAs($mgr, 'sales')->getJson('/api/sales/prospects'); + + $res->assertOk(); + expect(df_names($res))->toBe(['ДВЕ', 'ОДНА']); +}); + +test('фильтр работает и на экране начальника', function () { + $head = df_user('head'); + $m1 = df_user('manager'); + df_card($m1, 'ЕГО СЕГОДНЯ', 'negotiation', '2026-06-15T14:00:00'); + df_card($m1, 'ЕГО ЗАВТРА', 'negotiation', '2026-06-16T14:00:00'); + + $res = $this->actingAs($head, 'sales') + ->getJson('/api/sales/prospects?scope=department&date_mode=todo&period=today'); + + $res->assertOk(); + expect(df_names($res))->toBe(['ЕГО СЕГОДНЯ']); +}); + +test('карточка отдаёт prev_stage — из него считается дробь у «Отказа»', function () { + $mgr = df_user(); + $p = df_card($mgr, 'ОТКАЗНИК', 'manual_testing', '2026-06-15T14:00:00'); + + $this->actingAs($mgr, 'sales') + ->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'rejected', 'reason' => 'дорого']) + ->assertOk() + ->assertJsonPath('prospect.prev_stage', 'manual_testing'); +}); diff --git a/app/tests/Unit/AdAudienceSchedulerTest.php b/app/tests/Unit/AdAudienceSchedulerTest.php index 2a1d5af3..37d02be6 100644 --- a/app/tests/Unit/AdAudienceSchedulerTest.php +++ b/app/tests/Unit/AdAudienceSchedulerTest.php @@ -185,6 +185,23 @@ it('за пополнивших баланс и пользователей мы } }); +it('за карточку в корзине не платим ни дня — в отличие от отказа, который догревается', function () { + $trash = decide([ + 'stage' => 'trash', + 'stage_changed_at' => CarbonImmutable::parse('2026-07-31 12:00:00'), + ]); + + expect($trash['state'])->toBe('stopped') + ->and($trash['reason'])->toBe('trashed'); + + // Свежий отказ на ту же дату ещё греется — значит стоп у корзины именно свой, + // а не общее «всё старое выключено». + expect(decide([ + 'stage' => 'rejected', + 'stage_changed_at' => CarbonImmutable::parse('2026-07-31 12:00:00'), + ])['state'])->toBe('active'); +}); + it('слушается изменённых настроек', function () { expect(decide([ 'stage' => null, diff --git a/app/tests/Unit/Sales/SalesPeriodResolverTest.php b/app/tests/Unit/Sales/SalesPeriodResolverTest.php index 8e9ca05a..f4e710e9 100644 --- a/app/tests/Unit/Sales/SalesPeriodResolverTest.php +++ b/app/tests/Unit/Sales/SalesPeriodResolverTest.php @@ -140,6 +140,13 @@ it('kind=yesterday — только вчерашний день', function (): v expect($range->end->format('Y-m-d H:i:s'))->toBe('2026-06-14 23:59:59'); }); +it('kind=tomorrow — только завтрашний день', function (): void { + $range = (new SalesPeriodResolver)->resolve(['kind' => 'tomorrow']); + + expect($range->start->format('Y-m-d H:i:s'))->toBe('2026-06-16 00:00:00'); + expect($range->end->format('Y-m-d H:i:s'))->toBe('2026-06-16 23:59:59'); +}); + it('kind=d7 — последние 7 дней, включая сегодня', function (): void { $range = (new SalesPeriodResolver)->resolve(['kind' => 'd7']); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 5d1d6bfe..8e525d62 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-08-01T08:41:44.677Z +Last updated: 2026-08-01T09:05:12.752Z | Контролёр | Состояние | Детали | |---|---|---| @@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3544 | MsMpEng | 27.74ч | NaNч | -| 23936 | Code | 9.91ч | 0.0ч | -| 4 | System | 4.07ч | 0.0ч | +| 3544 | MsMpEng | 28.45ч | 12286017.1ч | +| 23936 | Code | 10.06ч | 0.0ч | +| 4 | System | 4.17ч | 0.0ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. From 9ee4e9a79f830a76556827ad8d3d733f0366fe8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 1 Aug 2026 13:29:10 +0300 Subject: [PATCH 5/8] =?UTF-8?q?feat(=D0=B2=D0=BE=D1=80=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B4=D0=B0=D0=B6):=20=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=D0=B7=D0=B8=D0=BD=D0=B0,=20=D0=B4=D1=80=D0=BE=D0=B1?= =?UTF-8?q?=D1=8C=20=D1=83=20=C2=AB=D0=9E=D1=82=D0=BA=D0=B0=D0=B7=D0=B0?= =?UTF-8?q?=C2=BB,=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D0=B4=D0=B0=D1=82=D0=B0=D0=BC=20=E2=80=94=20=D1=8D=D0=BA?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 12-я колонка «Корзина», результат «В корзину» просит только причину; - в шапке «Отказа» дробь «4/2» с подсказкой «из них 2 после ручного тестирования»; при нуле дробь не рисуется — «69/0» это шум; - у «Выслано КП» поле даты подписано «если договорились» и необязательно; - орган фильтра по датам на ОБОИХ экранах, два режима + период. Приёмка глазами пройдена живьём по всем семи пунктам, включая «Воронку отдела» (снимки в docs/superpowers/screens/2026-08-01-korzina-filtry/priemka/). Браузер поймал то, чего не видели тесты: при смене режима оставался прежний период, и «что менялось за завтра» давало пустую доску — теперь период возвращается к «Сегодня», на это заведён отдельный тест. Прогон: сервер 1245/1245, фронт 1567/1567. Журнал схемы — v9.31. --- .../Api/Sales/SalesProspectController.php | 5 +- app/resources/js/api/sales.ts | 31 +++ .../components/sales/ProspectDateFilter.vue | 180 ++++++++++++++++++ .../components/sales/SalesProspectBoard.vue | 15 +- .../components/sales/SalesProspectDialog.vue | 37 +++- app/resources/js/utils/prospectStages.ts | 3 + .../views/sales/SalesProspectsBoardView.vue | 22 ++- .../js/views/sales/SalesProspectsView.vue | 15 +- .../Sales/SalesProspectDateFilterTest.php | 3 +- app/tests/Frontend/ProspectDateFilter.spec.ts | 123 ++++++++++++ app/tests/Frontend/SalesProspectBoard.spec.ts | 42 +++- .../Frontend/SalesProspectDialog.spec.ts | 65 ++++++- .../Frontend/SalesProspectsBoardView.spec.ts | 20 +- app/tests/Frontend/SalesProspectsView.spec.ts | 13 +- app/tests/Frontend/prospectStages.spec.ts | 10 +- db/CHANGELOG_schema.md | 46 +++++ docs/observer/STATUS.md | 8 +- .../priemka/priemka-01-doska.png | Bin 0 -> 110744 bytes .../priemka/priemka-02-otkaz-korzina.png | Bin 0 -> 61200 bytes .../priemka/priemka-03-v-korzinu.png | Bin 0 -> 50649 bytes .../priemka-04-kp-data-neobyazatelna.png | Bin 0 -> 62520 bytes .../priemka/priemka-05-filtr-nado-sdelat.png | Bin 0 -> 77083 bytes .../priemka-06-filtr-menyalos-vchera.png | Bin 0 -> 54546 bytes .../priemka/priemka-07-nachalnik-doska.png | Bin 0 -> 89810 bytes 24 files changed, 605 insertions(+), 33 deletions(-) create mode 100644 app/resources/js/components/sales/ProspectDateFilter.vue create mode 100644 app/tests/Frontend/ProspectDateFilter.spec.ts create mode 100644 docs/superpowers/screens/2026-08-01-korzina-filtry/priemka/priemka-01-doska.png create mode 100644 docs/superpowers/screens/2026-08-01-korzina-filtry/priemka/priemka-02-otkaz-korzina.png create mode 100644 docs/superpowers/screens/2026-08-01-korzina-filtry/priemka/priemka-03-v-korzinu.png create mode 100644 docs/superpowers/screens/2026-08-01-korzina-filtry/priemka/priemka-04-kp-data-neobyazatelna.png create mode 100644 docs/superpowers/screens/2026-08-01-korzina-filtry/priemka/priemka-05-filtr-nado-sdelat.png create mode 100644 docs/superpowers/screens/2026-08-01-korzina-filtry/priemka/priemka-06-filtr-menyalos-vchera.png create mode 100644 docs/superpowers/screens/2026-08-01-korzina-filtry/priemka/priemka-07-nachalnik-doska.png diff --git a/app/app/Http/Controllers/Api/Sales/SalesProspectController.php b/app/app/Http/Controllers/Api/Sales/SalesProspectController.php index 980f7026..6c4ecd80 100644 --- a/app/app/Http/Controllers/Api/Sales/SalesProspectController.php +++ b/app/app/Http/Controllers/Api/Sales/SalesProspectController.php @@ -16,6 +16,7 @@ use App\Services\DaData\PartyLookup; use App\Services\Sales\SalesAttachmentService; use App\Support\InnValidator; use App\Support\PhoneNormalizer; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\Rule; @@ -130,9 +131,9 @@ class SalesProspectController extends Controller * * Без date_mode доска отдаёт всё, как раньше. * - * @param \Illuminate\Database\Eloquent\Builder $query + * @param Builder $query */ - private function applyDateFilter(\Illuminate\Database\Eloquent\Builder $query, Request $request): void + private function applyDateFilter(Builder $query, Request $request): void { $mode = (string) $request->query('date_mode', ''); if (! in_array($mode, ['todo', 'changed'], true)) { diff --git a/app/resources/js/api/sales.ts b/app/resources/js/api/sales.ts index ac4d5166..13100ae3 100644 --- a/app/resources/js/api/sales.ts +++ b/app/resources/js/api/sales.ts @@ -1251,6 +1251,8 @@ export interface SalesProspect { id: number; sales_user_id: number; stage: string; + /** Откуда карточка приехала на текущую стадию — из него считается дробь «69/1». */ + prev_stage: string | null; source: ProspectSource; /** Бренд/вывеска — его показываем на карточке канбана. */ firm_name: string; @@ -1298,15 +1300,43 @@ export interface ProspectsResponse { manager_counts?: Record; } +/** + * Фильтр доски по датам: + * todo — что надо сделать в этот срок (плюс всё просроченное); + * changed — что менялось в этот срок (любое движение карточки). + */ +export type ProspectDateMode = 'todo' | 'changed'; + +/** Вид периода — те же, что понимает SalesPeriodResolver на сервере. */ +export type ProspectPeriodKind = 'today' | 'tomorrow' | 'yesterday' | 'd7' | 'd30' | 'custom'; + +export interface ProspectDateFilterValue { + mode: ProspectDateMode | null; + period: ProspectPeriodKind; + from?: string | null; + to?: string | null; +} + export async function listProspects( managerId?: number, source?: ProspectSource, scope?: 'department', + dateFilter?: ProspectDateFilterValue | null, ): Promise { const params: Record = {}; if (scope) params.scope = scope; if (managerId) params.manager_id = managerId; if (source) params.source = source; + if (dateFilter?.mode) { + params.date_mode = dateFilter.mode; + params.period = dateFilter.period; + // Произвольный период уходит только парой дат — сервер на половинчатом + // ответит 422, и доска дёрнулась бы ошибкой на полпути выбора. + if (dateFilter.period === 'custom' && dateFilter.from && dateFilter.to) { + params.from = dateFilter.from; + params.to = dateFilter.to; + } + } const { data } = await axios.get('/api/sales/prospects', { headers: authHeaders(), params, @@ -1326,6 +1356,7 @@ export interface ProspectResultPayload { | 'registered' | 'manual_testing' | 'kp_sent' + | 'trash' | 'opened' | 'back_to_new'; next_call_at?: string; diff --git a/app/resources/js/components/sales/ProspectDateFilter.vue b/app/resources/js/components/sales/ProspectDateFilter.vue new file mode 100644 index 00000000..0bda6851 --- /dev/null +++ b/app/resources/js/components/sales/ProspectDateFilter.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/app/resources/js/components/sales/SalesProspectBoard.vue b/app/resources/js/components/sales/SalesProspectBoard.vue index 1c839412..419cab77 100644 --- a/app/resources/js/components/sales/SalesProspectBoard.vue +++ b/app/resources/js/components/sales/SalesProspectBoard.vue @@ -56,7 +56,13 @@ const columns = computed(() => if (stage.key === 'negotiation') { cards.sort((a, b) => callTime(a) - callTime(b)); } - return { ...stage, cards }; + // «Отказ» показывает второе число через дробь: сколько отказов пришло + // ПРЯМО из ручного тестирования — «69/1». Ноль не рисуем: «69/0» — шум. + const fromTesting = + stage.key === 'rejected' + ? cards.filter((c) => c.prev_stage === 'manual_testing').length + : 0; + return { ...stage, cards, subCount: fromTesting > 0 ? fromTesting : null }; }), ); @@ -72,7 +78,12 @@ const columns = computed(() =>
{{ col.title }} - {{ col.cards.length }} + + {{ col.cards.length }} +
(); -const action = ref<'' | 'negotiation' | 'no_answer' | 'rejected' | 'registered' | 'manual_testing' | 'kp_sent'>(''); +const action = ref< + '' | 'negotiation' | 'no_answer' | 'rejected' | 'registered' | 'manual_testing' | 'kp_sent' | 'trash' +>(''); const nextCallAt = ref(''); const reason = ref(''); /** Краткое содержание разговора — уходит вместе с результатом и ложится в журнал (§20). */ @@ -232,14 +234,20 @@ const availableActions = computed(() => { base.push({ value: 'kp_sent', title: 'Выслано КП' }); } // «Зарегистрировался» — пока клиент ещё не привязан (до денег): new/переговоры/недозвон/отказ - // плюс обе новые ручные стадии, иначе из них не выбраться дальше по воронке. - if (['new', 'negotiation', 'manual_testing', 'kp_sent', 'no_answer', 'rejected'].includes(props.prospect.stage)) { + // плюс обе новые ручные стадии и корзина, иначе из них не выбраться дальше по воронке + // (карточку могли выбросить по ошибке — путь назад должен остаться). + if (['new', 'negotiation', 'manual_testing', 'kp_sent', 'no_answer', 'rejected', 'trash'] + .includes(props.prospect.stage)) { base.push({ value: 'registered', title: 'Зарегистрировался' }); } // Отказ — на любой стадии, кроме user. if (props.prospect.stage !== 'user') { base.push({ value: 'rejected', title: 'Отказ' }); } + // Корзина — как и отказ, но платящего клиента выбросить нельзя. + if (! ['topped_up', 'user'].includes(props.prospect.stage)) { + base.push({ value: 'trash', title: 'В корзину' }); + } return base; }); @@ -285,6 +293,14 @@ async function submit(): Promise { return; } emit('save', { action: 'rejected', reason: reason.value, summary: summary.value }); + } else if (action.value === 'trash') { + if (!reason.value.trim()) { + error.value = 'Укажите причину.'; + return; + } + // Дату созвона намеренно не шлём, даже если менеджер её набрал до + // переключения результата: у выброшенной карточки её быть не должно. + emit('save', { action: 'trash', reason: reason.value, summary: summary.value }); } else if (action.value === 'registered') { if (!email.value.trim()) { error.value = 'Укажите e-mail аккаунта клиента.'; @@ -320,15 +336,13 @@ async function submit(): Promise { error.value = 'Укажите адрес или номер, куда ушло КП.'; return; } - if (!nextCallAt.value) { - error.value = 'Укажите дату и время следующего созвона.'; - return; - } + // Дата созвона НЕобязательна: бывает «пришлите, созвонимся через пару + // дней», а бывает «пришлите на почту, если интересно — перезвоню». emit('save', { action: 'kp_sent', kp_channel: kpChannel.value, kp_target: kpTarget.value.trim(), - next_call_at: nextCallAt.value, + ...(nextCallAt.value ? { next_call_at: nextCallAt.value } : {}), summary: summary.value, }); } else { @@ -485,15 +499,18 @@ defineExpose({ v-if="action === 'negotiation' || action === 'manual_testing' || action === 'kp_sent'" v-model="nextCallAt" type="datetime-local" - label="Следующий созвон" + :label="action === 'kp_sent' ? 'Следующий созвон (если договорились)' : 'Следующий созвон'" + :hint="action === 'kp_sent' ? 'можно оставить пустым — клиент перезвонит сам' : undefined" + :persistent-hint="action === 'kp_sent'" density="comfortable" variant="outlined" hide-details="auto" /> = Object.fromEntries(PROSPECT_STAGES.map((s) => [s.key, s])); diff --git a/app/resources/js/views/sales/SalesProspectsBoardView.vue b/app/resources/js/views/sales/SalesProspectsBoardView.vue index a0735a9c..3f635599 100644 --- a/app/resources/js/views/sales/SalesProspectsBoardView.vue +++ b/app/resources/js/views/sales/SalesProspectsBoardView.vue @@ -9,10 +9,12 @@ import { listProspects, listSalesManagers, updateProspect, + type ProspectDateFilterValue, type ProspectResultPayload, type SalesManagerRow, type SalesProspect, } from '../../api/sales'; +import ProspectDateFilter from '../../components/sales/ProspectDateFilter.vue'; import SalesProspectBoard from '../../components/sales/SalesProspectBoard.vue'; import SalesProspectDialog from '../../components/sales/SalesProspectDialog.vue'; @@ -22,6 +24,8 @@ const managerCounts = ref>({}); const managerId = ref(null); /** Фильтр происхождения: все / отдал начальник из поиска / инициатива менеджера. */ const source = ref<'search' | 'manager' | null>(null); +/** Фильтр по датам: «что надо сделать» / «что менялось». Пусто — вся доска. */ +const dateFilter = ref(null); const sourceItems = [ { value: null, title: 'Все карточки' }, { value: 'search', title: 'От начальника (из поиска)' }, @@ -49,7 +53,12 @@ async function load(): Promise { error.value = ''; try { // scope=department — это экран НАЧАЛЬНИКА: весь отдел, а не личные карточки. - const res = await listProspects(managerId.value ?? undefined, source.value ?? undefined, 'department'); + const res = await listProspects( + managerId.value ?? undefined, + source.value ?? undefined, + 'department', + dateFilter.value, + ); prospects.value = res.prospects; if (res.manager_counts) managerCounts.value = res.manager_counts; } catch (e) { @@ -84,7 +93,13 @@ onMounted(async () => { await load(); }); -defineExpose({ managerId, source, load, managerItems }); +/** Фильтр сменился — перезапрашиваем доску: выборку делает сервер. */ +async function applyDateFilter(value: ProspectDateFilterValue): Promise { + dateFilter.value = value.mode ? value : null; + await load(); +} + +defineExpose({ managerId, source, load, managerItems, applyDateFilter, dateFilter });