feat(воронка продаж): корзина, дробь у «Отказа», фильтр по датам — экраны

- 12-я колонка «Корзина», результат «В корзину» просит только причину;
- в шапке «Отказа» дробь «4/2» с подсказкой «из них 2 после ручного
  тестирования»; при нуле дробь не рисуется — «69/0» это шум;
- у «Выслано КП» поле даты подписано «если договорились» и необязательно;
- орган фильтра по датам на ОБОИХ экранах, два режима + период.

Приёмка глазами пройдена живьём по всем семи пунктам, включая «Воронку отдела»
(снимки в docs/superpowers/screens/2026-08-01-korzina-filtry/priemka/).
Браузер поймал то, чего не видели тесты: при смене режима оставался прежний
период, и «что менялось за завтра» давало пустую доску — теперь период
возвращается к «Сегодня», на это заведён отдельный тест.

Прогон: сервер 1245/1245, фронт 1567/1567. Журнал схемы — v9.31.
This commit is contained in:
Дмитрий
2026-08-01 13:29:10 +03:00
parent 016416502a
commit 9ee4e9a79f
24 changed files with 605 additions and 33 deletions
@@ -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<SalesProspect> $query
* @param Builder<SalesProspect> $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)) {
+31
View File
@@ -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<string, number>;
}
/**
* Фильтр доски по датам:
* 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<ProspectsResponse> {
const params: Record<string, unknown> = {};
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<ProspectsResponse>('/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;
@@ -0,0 +1,180 @@
<script setup lang="ts">
/**
* Фильтр воронки по датам. Два режима, взаимоисключающие по смыслу:
*
* «Что надо сделать» — утром: по кому сегодня/завтра звонить, плюс всё
* просроченное (забытый звонок важнее завтрашнего).
* «Что менялось» — вечером: что я сделал за день или за период. Считается
* ЛЮБОЕ движение карточки, включая автоматическое
* (потратил деньги, пополнил счёт, стал клиентом).
*
* Владелец назвал это «двумя фильтрами», но сделан один орган с выбором режима:
* одновременно они не нужны никогда, а два независимых органа пересекались бы
* друг с другом и путали.
*
* Произвольный период выбирается КАЛЕНДАРЁМ и уходит наружу только когда
* отмечены ОБЕ даты: на половинчатом диапазоне сервер отвечает ошибкой.
* Тот же приём, что в PeriodPicker.
*/
import { ref, watch } from 'vue';
import type { ProspectDateFilterValue, ProspectDateMode, ProspectPeriodKind } from '../../api/sales';
const emit = defineEmits<{ change: [value: ProspectDateFilterValue] }>();
const mode = ref<ProspectDateMode | null>(null);
const period = ref<ProspectPeriodKind>('today');
/** Диапазон из v-date-picker (multiple="range"): массив дат, порядок не гарантирован. */
const customRange = ref<unknown[]>([]);
const from = ref<string | null>(null);
const to = ref<string | null>(null);
const calendarOpen = ref(false);
const modeItems: { value: ProspectDateMode | null; title: string }[] = [
{ value: null, title: 'Все карточки' },
{ value: 'todo', title: 'Что надо сделать' },
{ value: 'changed', title: 'Что менялось' },
];
const periodItems: { value: ProspectPeriodKind; title: string }[] = [
{ value: 'today', title: 'Сегодня' },
{ value: 'tomorrow', title: 'Завтра' },
{ value: 'yesterday', title: 'Вчера' },
{ value: 'd7', title: '7 дней' },
{ value: 'd30', title: '30 дней' },
{ value: 'custom', title: 'Произвольный период' },
];
/** Дата из календаря (Date или строка) → YYYY-MM-DD по местному времени. */
function toIsoDay(value: unknown): string | null {
const d = value instanceof Date ? value : new Date(String(value));
if (Number.isNaN(d.getTime())) return null;
const two = (n: number): string => String(n).padStart(2, '0');
return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`;
}
function announce(): void {
emit('change', { mode: mode.value, period: period.value, from: from.value, to: to.value });
}
/** Применяем произвольный период, только когда в календаре отмечены обе даты. */
function applyCustom(): void {
const days = customRange.value
.map(toIsoDay)
.filter((d): d is string => d !== null)
.sort();
if (days.length < 2) return;
from.value = days[0];
to.value = days[days.length - 1];
calendarOpen.value = false;
announce();
}
/** Пока чиним состояние сами — наружу не сообщаем, иначе доска грузится дважды. */
const syncing = ref(false);
watch(mode, () => {
// Смена режима ВСЕГДА возвращает период к «Сегодня». Иначе выбранное для
// звонков «Завтра» переезжает в «что менялось», где будущего быть не может,
// и доска оказывается пустой — выглядит поломкой, хотя всё верно.
syncing.value = true;
period.value = 'today';
from.value = null;
to.value = null;
customRange.value = [];
syncing.value = false;
announce();
});
watch(period, () => {
if (syncing.value) return;
if (period.value === 'custom') {
// Ждём календарь: до выбора обеих дат наружу ничего не сообщаем,
// иначе доска дёрнулась бы ошибкой на полпути.
calendarOpen.value = true;
return;
}
from.value = null;
to.value = null;
customRange.value = [];
announce();
});
defineExpose({ mode, period, customRange, applyCustom, modeItems, periodItems, calendarOpen });
</script>
<template>
<div class="d-flex align-center ga-3" role="group" aria-label="Фильтр по датам">
<v-select
v-model="mode"
:items="modeItems"
item-title="title"
item-value="value"
label="Фильтр по датам"
density="compact"
variant="outlined"
style="max-width: 220px"
hide-details
data-testid="prospect-date-mode"
/>
<template v-if="mode">
<v-select
v-model="period"
:items="periodItems"
item-title="title"
item-value="value"
label="Период"
density="compact"
variant="outlined"
style="max-width: 220px"
hide-details
data-testid="prospect-period"
/>
<!-- Календарь для произвольного периода: отмечаются начало и конец. -->
<v-menu v-model="calendarOpen" :close-on-content-click="false" location="bottom end">
<template #activator="{ props: activator }">
<v-btn
v-if="period === 'custom'"
v-bind="activator"
variant="outlined"
density="comfortable"
prepend-icon="mdi-calendar"
class="prospect-range-btn"
>
{{ from && to ? `${from}${to}` : 'выбрать даты' }}
</v-btn>
</template>
<v-card min-width="240">
<div class="pa-2">
<div class="text-caption text-medium-emphasis mb-1">
отметьте начало и конец периода
</div>
<v-date-picker
v-model="customRange"
multiple="range"
show-adjacent-months
hide-header
@update:model-value="applyCustom"
/>
</div>
</v-card>
</v-menu>
<span v-if="mode === 'todo'" class="text-caption text-medium-emphasis">
плюс всё просроченное
</span>
</template>
</div>
</template>
<style scoped>
.prospect-range-btn {
text-transform: none;
letter-spacing: normal;
}
</style>
@@ -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 };
}),
);
</script>
@@ -72,7 +78,12 @@ const columns = computed(() =>
<section v-for="col in columns" :key="col.key" class="prospect-column">
<header class="prospect-column-head" :style="{ borderColor: col.colorHex }">
<span>{{ col.title }}</span>
<span class="prospect-column-count">{{ col.cards.length }}</span>
<span
class="prospect-column-count"
:title="col.subCount ? `из них ${col.subCount} после ручного тестирования` : undefined"
>
{{ col.cards.length }}<template v-if="col.subCount">/{{ col.subCount }}</template>
</span>
</header>
<div class="prospect-column-body">
<article
@@ -23,7 +23,9 @@ const emit = defineEmits<{
save: [payload: ProspectResultPayload];
}>();
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<void> {
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<void> {
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"
/>
<v-textarea
v-if="action === 'no_answer' || action === 'rejected'"
v-if="action === 'no_answer' || action === 'rejected' || action === 'trash'"
v-model="reason"
label="Причина"
:placeholder="action === 'trash' ? 'не наш профиль / дубль / фирма закрылась' : undefined"
rows="2"
density="comfortable"
variant="outlined"
+3
View File
@@ -25,6 +25,9 @@ export const PROSPECT_STAGES: StageMeta[] = [
{ key: 'kp_sent', title: 'Выслано КП', colorHex: '#B45309', manual: true },
{ key: 'rejected', title: 'Отказ', colorHex: '#B91C1C', manual: true },
{ key: 'no_answer', title: 'Не смогли дозвониться', colorHex: '#6B7280', manual: true },
// Карточка, с которой больше не работаем: мусор, дубль, не наш профиль.
// Последняя колонка — дальше воронки нет.
{ key: 'trash', title: 'Корзина', colorHex: '#57534E', manual: true },
];
const BY_KEY: Record<string, StageMeta> = Object.fromEntries(PROSPECT_STAGES.map((s) => [s.key, s]));
@@ -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<Record<string, number>>({});
const managerId = ref<number | null>(null);
/** Фильтр происхождения: все / отдал начальник из поиска / инициатива менеджера. */
const source = ref<'search' | 'manager' | null>(null);
/** Фильтр по датам: «что надо сделать» / «что менялось». Пусто — вся доска. */
const dateFilter = ref<ProspectDateFilterValue | null>(null);
const sourceItems = [
{ value: null, title: 'Все карточки' },
{ value: 'search', title: 'От начальника (из поиска)' },
@@ -49,7 +53,12 @@ async function load(): Promise<void> {
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<void> {
dateFilter.value = value.mode ? value : null;
await load();
}
defineExpose({ managerId, source, load, managerItems, applyDateFilter, dateFilter });
</script>
<template>
@@ -118,6 +133,9 @@ defineExpose({ managerId, source, load, managerItems });
@update:model-value="load"
/>
</div>
<div class="d-flex align-center mb-4">
<ProspectDateFilter @change="applyDateFilter" />
</div>
<v-alert v-if="error" type="warning" density="compact" class="mb-3">{{ error }}</v-alert>
<v-progress-linear v-if="loading" indeterminate color="primary" class="mb-2" />
<SalesProspectBoard :prospects="prospects" @open="open" />
@@ -12,14 +12,18 @@ import {
listProspects,
updateProspect,
type ProspectCreatePayload,
type ProspectDateFilterValue,
type ProspectResultPayload,
type SalesProspect,
} from '../../api/sales';
import ProspectDateFilter from '../../components/sales/ProspectDateFilter.vue';
import SalesProspectBoard from '../../components/sales/SalesProspectBoard.vue';
import SalesProspectCreateDialog from '../../components/sales/SalesProspectCreateDialog.vue';
import SalesProspectDialog from '../../components/sales/SalesProspectDialog.vue';
const prospects = ref<SalesProspect[]>([]);
/** Фильтр по датам: «что надо сделать» / «что менялось». Пусто — вся доска. */
const dateFilter = ref<ProspectDateFilterValue | null>(null);
const loading = ref(false);
const error = ref('');
const dialog = ref(false);
@@ -30,7 +34,7 @@ async function load(): Promise<void> {
loading.value = true;
error.value = '';
try {
const res = await listProspects(undefined);
const res = await listProspects(undefined, undefined, undefined, dateFilter.value);
prospects.value = res.prospects;
} catch (e) {
error.value = extractSalesErrorMessage(e);
@@ -79,9 +83,15 @@ async function createCandidate(payload: ProspectCreatePayload): Promise<void> {
}
}
/** Фильтр сменился — перезапрашиваем доску: выборку делает сервер. */
async function applyDateFilter(value: ProspectDateFilterValue): Promise<void> {
dateFilter.value = value.mode ? value : null;
await load();
}
onMounted(load);
defineExpose({ open, createCandidate, load });
defineExpose({ open, createCandidate, load, applyDateFilter, dateFilter });
</script>
<template>
@@ -89,6 +99,7 @@ defineExpose({ open, createCandidate, load });
<div class="d-flex align-center mb-4">
<h1 class="text-h5">Потенциальные клиенты</h1>
<v-spacer />
<ProspectDateFilter class="mr-3" @change="applyDateFilter" />
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus" @click="createDialog = true">
Добавить кандидата
</v-btn>
@@ -8,6 +8,7 @@ use App\Models\SalesUser;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Testing\TestResponse;
uses(DatabaseTransactions::class);
@@ -42,7 +43,7 @@ function df_card(SalesUser $mgr, string $name, string $stage, ?string $callAt =
}
/** @return list<string> названия фирм в ответе */
function df_names(\Illuminate\Testing\TestResponse $res): array
function df_names(TestResponse $res): array
{
return collect($res->json('prospects'))->pluck('firm_name')->sort()->values()->all();
}
@@ -0,0 +1,123 @@
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { describe, expect, it } from 'vitest';
import ProspectDateFilter from '../../resources/js/components/sales/ProspectDateFilter.vue';
const vuetify = createVuetify();
interface FilterVm {
mode: 'todo' | 'changed' | null;
period: string;
customRange: unknown[];
applyCustom: () => void;
modeItems: { value: string | null; title: string }[];
periodItems: { value: string; title: string }[];
}
function factory() {
const w = mount(ProspectDateFilter, { global: { plugins: [vuetify] } });
return { w, vm: w.vm as unknown as FilterVm };
}
describe('ProspectDateFilter', () => {
it('по умолчанию режим не выбран и выбор периода спрятан', () => {
const { w, vm } = factory();
expect(vm.mode).toBeNull();
expect(w.find('[data-testid="prospect-period"]').exists()).toBe(false);
});
it('в списке режимов оба, которые просил владелец', () => {
const { vm } = factory();
const values = vm.modeItems.map((i) => i.value);
expect(values).toEqual([null, 'todo', 'changed']);
});
it('в периодах есть «завтра» — по нему смотрят, что делать назавтра', () => {
const { vm } = factory();
expect(vm.periodItems.map((i) => i.value)).toContain('tomorrow');
});
it('выбор режима показывает период и сообщает наружу', async () => {
const { w, vm } = factory();
vm.mode = 'todo';
await w.vm.$nextTick();
expect(w.find('[data-testid="prospect-period"]').exists()).toBe(true);
expect(w.emitted('change')?.at(-1)?.[0]).toEqual({
mode: 'todo',
period: 'today',
from: null,
to: null,
});
});
it('смена периода сообщает наружу новый период', async () => {
const { w, vm } = factory();
vm.mode = 'changed';
await w.vm.$nextTick();
vm.period = 'd7';
await w.vm.$nextTick();
expect(w.emitted('change')?.at(-1)?.[0]).toMatchObject({ mode: 'changed', period: 'd7' });
});
it('произвольный период: с одной датой наружу не уходит, с обеими — уходит', async () => {
const { w, vm } = factory();
vm.mode = 'todo';
await w.vm.$nextTick();
vm.period = 'custom';
await w.vm.$nextTick();
const before = w.emitted('change')?.length ?? 0;
vm.customRange = [new Date('2026-06-18T00:00:00')];
vm.applyCustom();
await w.vm.$nextTick();
expect(w.emitted('change')?.length ?? 0).toBe(before);
vm.customRange = [new Date('2026-06-18T00:00:00'), new Date('2026-06-21T00:00:00')];
vm.applyCustom();
await w.vm.$nextTick();
expect(w.emitted('change')?.at(-1)?.[0]).toEqual({
mode: 'todo',
period: 'custom',
from: '2026-06-18',
to: '2026-06-21',
});
});
it('смена режима возвращает период к «Сегодня» — «что менялось за завтра» бессмысленно', async () => {
const { w, vm } = factory();
vm.mode = 'todo';
await w.vm.$nextTick();
vm.period = 'tomorrow';
await w.vm.$nextTick();
vm.mode = 'changed';
await w.vm.$nextTick();
expect(vm.period).toBe('today');
// И наружу ушло ровно одно сообщение с новым режимом, а не два подряд.
expect(w.emitted('change')?.at(-1)?.[0]).toEqual({
mode: 'changed',
period: 'today',
from: null,
to: null,
});
});
it('возврат в «Все карточки» гасит фильтр и прячет период', async () => {
const { w, vm } = factory();
vm.mode = 'todo';
await w.vm.$nextTick();
vm.mode = null;
await w.vm.$nextTick();
expect(w.find('[data-testid="prospect-period"]').exists()).toBe(false);
expect(w.emitted('change')?.at(-1)?.[0]).toMatchObject({ mode: null });
});
});
+40 -2
View File
@@ -11,6 +11,7 @@ function p(id: number, stage: string, overrides: Partial<SalesProspect> = {}): S
id,
sales_user_id: 1,
stage,
prev_stage: null,
firm_name: `Фирма ${id}`,
city: 'Ростов',
phone: null,
@@ -34,19 +35,56 @@ function p(id: number, stage: string, overrides: Partial<SalesProspect> = {}): S
}
describe('SalesProspectBoard', () => {
it('рисует 11 колонок и раскладывает карточки по стадиям', () => {
it('рисует 12 колонок и раскладывает карточки по стадиям', () => {
const w = mount(SalesProspectBoard, {
props: { prospects: [p(1, 'new'), p(2, 'negotiation'), p(3, 'new')] },
global: { plugins: [vuetify] },
});
expect(w.findAll('.prospect-column').length).toBe(11);
expect(w.findAll('.prospect-column').length).toBe(12);
expect(w.text()).toContain('Новые');
expect(w.text()).toContain('Взят в работу');
expect(w.text()).toContain('Тестирование ручное');
expect(w.text()).toContain('Выслано КП');
expect(w.text()).toContain('Корзина');
expect(w.text()).toContain('Фирма 1');
});
/** Заголовок колонки по её названию — счётчик живёт рядом с ним. */
function head(w: ReturnType<typeof mount>, title: string): string {
const col = w.findAll('.prospect-column-head').find((h) => h.text().includes(title));
return col?.text() ?? '';
}
it('«Отказ»: через дробь сколько отказов пришло из ручного тестирования', () => {
const w = mount(SalesProspectBoard, {
props: {
prospects: [
p(1, 'rejected', { prev_stage: 'manual_testing' }),
p(2, 'rejected', { prev_stage: 'negotiation' }),
p(3, 'rejected', { prev_stage: null }),
],
},
global: { plugins: [vuetify] },
});
expect(head(w, 'Отказ')).toContain('3/1');
});
it('«Отказ» без таких карточек — просто число, без дроби', () => {
const w = mount(SalesProspectBoard, {
props: { prospects: [p(1, 'rejected', { prev_stage: 'negotiation' })] },
global: { plugins: [vuetify] },
});
expect(head(w, 'Отказ')).not.toContain('/');
});
it('дробь — только у «Отказа»: в других колонках приезд из ручного тестирования её не рисует', () => {
const w = mount(SalesProspectBoard, {
props: { prospects: [p(1, 'no_answer', { prev_stage: 'manual_testing' })] },
global: { plugins: [vuetify] },
});
expect(head(w, 'Не смогли дозвониться')).not.toContain('/');
});
it('бейдж происхождения: «своя» у менеджера, «от начальника» из поиска', () => {
const w = mount(SalesProspectBoard, {
props: {
+63 -2
View File
@@ -18,6 +18,7 @@ function prospect(overrides: Partial<SalesProspect> = {}): SalesProspect {
id: 1,
sales_user_id: 1,
stage: 'new',
prev_stage: null,
firm_name: 'ООО Тест',
city: 'Ростов',
phone: '+7800',
@@ -476,7 +477,7 @@ describe('SalesProspectDialog', () => {
});
});
it('выслано КП: без канала, без адреса и без даты — save не эмитится', async () => {
it('выслано КП: без канала и без адреса — save не эмитится', async () => {
const w = factory(prospect({ stage: 'negotiation' }));
const vm = w.vm as unknown as DialogVm;
@@ -487,10 +488,25 @@ describe('SalesProspectDialog', () => {
vm.kpChannel = 'email';
await vm.submit();
expect(w.emitted('save')).toBeFalsy();
});
it('выслано КП: без даты созвона всё равно сохраняется — дата необязательна', async () => {
const w = factory(prospect({ stage: 'negotiation' }));
const vm = w.vm as unknown as DialogVm;
vm.action = 'kp_sent';
vm.kpChannel = 'email';
vm.kpTarget = 'director@primer-firma.ru';
await vm.submit();
expect(w.emitted('save')).toBeFalsy();
await flushPromises();
// Поля next_call_at в письме на сервер нет вовсе — не пустая строка.
expect(w.emitted('save')?.[0][0]).toEqual({
action: 'kp_sent',
kp_channel: 'email',
kp_target: 'director@primer-firma.ru',
summary: '',
});
});
it('выслано КП: с каналом, адресом и датой эмитит save с нужными полями', async () => {
@@ -512,6 +528,51 @@ describe('SalesProspectDialog', () => {
summary: '',
});
});
it('корзина: результат есть в списке на обычной стадии, платящему — нет', () => {
const onWork = (factory(prospect({ stage: 'negotiation' })).vm as unknown as DialogVm)
.availableActions.map((a) => a.value);
expect(onWork).toContain('trash');
for (const stage of ['topped_up', 'user']) {
const values = (factory(prospect({ stage })).vm as unknown as DialogVm)
.availableActions.map((a) => a.value);
expect(values).not.toContain('trash');
}
});
it('корзина: без причины save не эмитится', async () => {
const w = factory(prospect({ stage: 'negotiation' }));
const vm = w.vm as unknown as DialogVm;
vm.action = 'trash';
await vm.submit();
expect(w.emitted('save')).toBeFalsy();
});
it('корзина: с причиной эмитит save без даты созвона', async () => {
const w = factory(prospect({ stage: 'negotiation' }));
const vm = w.vm as unknown as DialogVm;
vm.action = 'trash';
vm.reason = 'не наш профиль';
// Дату могли набрать до переключения результата — в корзину она уйти не должна.
vm.nextCallAt = '2026-08-05T12:00';
await vm.submit();
await flushPromises();
expect(w.emitted('save')?.[0][0]).toEqual({
action: 'trash',
reason: 'не наш профиль',
summary: '',
});
});
it('корзина: из корзины можно вернуться — «Зарегистрировался» остаётся доступен', () => {
const values = (factory(prospect({ stage: 'trash' })).vm as unknown as DialogVm)
.availableActions.map((a) => a.value);
expect(values).toContain('registered');
});
it('на карточке в ручном тестировании показывает, куда отправляли тест', () => {
const w = factory(prospect({ stage: 'manual_testing', test_channel: 'phone', test_target: '79990001122' }));
expect((w.vm as unknown as DialogVm).resultReminder).toBe('Тест отправлен: телефон 79990001122');
@@ -20,6 +20,7 @@ interface BoardVm {
managerId: number | null;
load: () => Promise<void>;
managerItems: { id: number | null; name: string }[];
applyDateFilter: (v: unknown) => Promise<void>;
}
describe('SalesProspectsBoardView', () => {
@@ -29,11 +30,24 @@ describe('SalesProspectsBoardView', () => {
const w = mount(SalesProspectsBoardView, { global: { plugins: [vuetify] } });
await flushPromises();
// 'department' — воронка отдела просит весь отдел явно (§23).
expect(listProspects).toHaveBeenCalledWith(undefined, undefined, 'department');
expect(listProspects).toHaveBeenCalledWith(undefined, undefined, 'department', null);
(w.vm as unknown as BoardVm).managerId = 7;
await (w.vm as unknown as BoardVm).load();
expect(listProspects).toHaveBeenLastCalledWith(7, undefined, 'department');
expect(listProspects).toHaveBeenLastCalledWith(7, undefined, 'department', null);
});
it('фильтр по датам уходит в запрос, а «Все карточки» его гасят', async () => {
const w = mount(SalesProspectsBoardView, { global: { plugins: [vuetify] } });
await flushPromises();
const vm = w.vm as unknown as BoardVm;
const todo = { mode: 'todo', period: 'today', from: null, to: null };
await vm.applyDateFilter(todo);
expect(listProspects).toHaveBeenLastCalledWith(undefined, undefined, 'department', todo);
await vm.applyDateFilter({ mode: null, period: 'today', from: null, to: null });
expect(listProspects).toHaveBeenLastCalledWith(undefined, undefined, 'department', null);
});
it('фильтр происхождения уходит в запрос вторым параметром', async () => {
@@ -43,7 +57,7 @@ describe('SalesProspectsBoardView', () => {
(w.vm as unknown as BoardVm & { source: string | null }).source = 'manager';
await (w.vm as unknown as BoardVm).load();
expect(listProspects).toHaveBeenLastCalledWith(undefined, 'manager', 'department');
expect(listProspects).toHaveBeenLastCalledWith(undefined, 'manager', 'department', null);
});
it('в фильтре у менеджера показывается число его карточек', async () => {
+12 -1
View File
@@ -38,6 +38,7 @@ const vuetify = createVuetify();
interface ViewVm {
open: (p: unknown) => Promise<void> | void;
createCandidate: (p: Record<string, unknown>) => Promise<void>;
applyDateFilter: (v: unknown) => Promise<void>;
}
describe('SalesProspectsView', () => {
@@ -53,10 +54,20 @@ describe('SalesProspectsView', () => {
it('загружает и показывает карточки менеджера', async () => {
const w = mount(SalesProspectsView, { global: { plugins: [vuetify] } });
await flushPromises();
expect(listProspects).toHaveBeenCalledWith(undefined);
expect(listProspects).toHaveBeenCalledWith(undefined, undefined, undefined, null);
expect(w.text()).toContain('ООО Демо');
});
it('фильтр по датам уходит в запрос и у менеджера тоже', async () => {
const w = mount(SalesProspectsView, { global: { plugins: [vuetify] } });
await flushPromises();
const changed = { mode: 'changed', period: 'today', from: null, to: null };
await (w.vm as unknown as ViewVm).applyDateFilter(changed);
expect(listProspects).toHaveBeenLastCalledWith(undefined, undefined, undefined, changed);
});
it('открытие карточки из «Новые» помечает её взятой в работу (action=opened)', async () => {
const w = mount(SalesProspectsView, { global: { plugins: [vuetify] } });
await flushPromises();
+8 -2
View File
@@ -2,13 +2,19 @@ import { describe, expect, it } from 'vitest';
import { PROSPECT_STAGES, isOverdue, stageMeta } from '../../resources/js/utils/prospectStages';
describe('prospectStages', () => {
it('11 стадий в правильном порядке: ручное тестирование после переговоров, КП после пользователя', () => {
it('12 стадий в правильном порядке: ручное тестирование после переговоров, КП после пользователя, корзина последняя', () => {
expect(PROSPECT_STAGES.map((s) => s.key)).toEqual([
'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',
]);
});
it('корзина — ручная стадия с русским именем', () => {
const byKey = Object.fromEntries(PROSPECT_STAGES.map((s) => [s.key, s]));
expect(byKey.trash.manual).toBe(true);
expect(stageMeta('trash').title).toBe('Корзина');
});
it('обе новые стадии — ручные: автомат по деньгам их не ставит', () => {
const byKey = Object.fromEntries(PROSPECT_STAGES.map((s) => [s.key, s]));
expect(byKey.manual_testing.manual).toBe(true);
+46
View File
@@ -10,6 +10,52 @@
> параллельно с боевым main. Их прежние номера (v8.86–v8.95) **столкнулись** с боевыми (рекламный
> кошелёк и Яндекс-канал), поэтому при сведении они перенумерованы. Содержание не менялось.
## v9.31 (2026-08-01) — Воронка продаж: корзина, `prev_stage` и журнал движений карточки
`sales_prospects.stage`: CHECK пересобран на **12** значений — добавлен `trash`
(«Корзина», последняя колонка канбана: карточка, с которой больше не работаем).
Стадия РУЧНАЯ, `SalesProspectsAdvanceJob` её не двигает.
`sales_prospects` +1 nullable-колонка `prev_stage VARCHAR(16)` — откуда карточка
приехала на текущую стадию. Из неё доска считает дробь «69/1» в шапке «Отказа»:
сколько отказов пришло прямо из ручного тестирования. У существующих карточек
остаётся `NULL` — историю задним числом не восстанавливаем (разбор текста старых
записей журнала = гадание на строках; ручное тестирование появилось только 31.07).
**+1 таблица `sales_prospect_moves`** — журнал ВСЕХ движений карточки по стадиям
(append-only): `id BIGSERIAL PK`, `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()`. **+2 индекса:** `idx_prospect_move_card (prospect_id,
created_at DESC)` — история карточки; `idx_prospect_move_when (created_at)` —
фильтр «что менялось за период».
Зачем таблица: стадию меняли ДВА места — результат разговора менеджера
(`SalesProspectController`) и автопереезд по деньгам (`SalesProspectsAdvanceJob`).
Джоба не оставляла следа вообще, поэтому фильтр «что менялось за день» было не из
чего построить. Запись движения перенесена в **событие модели** `SalesProspect`
(`saving``prev_stage`, `created`/`updated` → строка движения), соединение берётся
у самой карточки (`$this->getConnection()`) — джоба живёт на `pgsql_admin`, портал
на дефолтном.
🔴 **GRANT SELECT, INSERT** на `sales_prospect_moves` ролям `crm_admin_user` и
`crm_supplier_worker` — блок скопирован из `create_sales_prospect_notes_table`.
Без него на бою отказ прав, на dev под superuser невидимо.
Миграция `app/database/migrations/2026_08_01_100000_add_trash_stage_and_prospect_moves.php`
идемпотентна (`IF NOT EXISTS` / `DROP CONSTRAINT IF EXISTS`). `down()` перед возвратом
прежнего CHECK переводит карточки из корзины в `rejected`.
⚠️ DDL — в дельта-миграции, НЕ в теле `db/schema.sql`: семьи `sales_*` там нет
(зафиксировано в v8.72). Структурно: +1 таблица, +2 индекса, 0 RLS/функций/триггеров
(семья `sales_*` — SaaS-level, без RLS).
Спека: `docs/superpowers/specs/2026-08-01-korzina-filtry-schetchik-design.md` §4.
План: `docs/superpowers/plans/2026-08-01-korzina-filtry-schetchik.md` Task 1.
⚠️ номер версии взят на ветке `feat/prospects-manual-testing-kp` как следующий после
v9.30 — сверить при слиянии в `main`.
## v9.28 (2026-07-31) — Воронка продаж: стадии «Тестирование ручное» и «Выслано КП»
`sales_prospects.stage`: CHECK пересобран на 11 значений — добавлены `manual_testing`
+4 -4
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-08-01T09:05:12.752Z
Last updated: 2026-08-01T09:41:47.416Z
| Контролёр | Состояние | Детали |
|---|---|---|
@@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10
| PID | Имя | CPU-время | Возраст |
|---|---|---|---|
| 3544 | MsMpEng | 28.45ч | 12286017.1ч |
| 23936 | Code | 10.06ч | 0.0ч |
| 4 | System | 4.17ч | 0.0ч |
| 3544 | MsMpEng | 29.18ч | 1328745.7ч |
| 23936 | Code | 10.35ч | NaNч |
| 4 | System | 4.31ч | 12286017.7ч |
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB