feat(воронка продаж): корзина, дробь у «Отказа», фильтр по датам — экраны
- 12-я колонка «Корзина», результат «В корзину» просит только причину; - в шапке «Отказа» дробь «4/2» с подсказкой «из них 2 после ручного тестирования»; при нуле дробь не рисуется — «69/0» это шум; - у «Выслано КП» поле даты подписано «если договорились» и необязательно; - орган фильтра по датам на ОБОИХ экранах, два режима + период. Приёмка глазами пройдена живьём по всем семи пунктам, включая «Воронку отдела» (снимки в docs/superpowers/screens/2026-08-01-korzina-filtry/priemka/). Браузер поймал то, чего не видели тесты: при смене режима оставался прежний период, и «что менялось за завтра» давало пустую доску — теперь период возвращается к «Сегодня», на это заведён отдельный тест. Прогон: сервер 1245/1245, фронт 1567/1567. Журнал схемы — v9.31.
@@ -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)) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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-сессий.
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 88 KiB |