feat(deals): sweep 14->5 funnel slugs — controllers, mocks, stories, tests

This commit is contained in:
Дмитрий
2026-05-17 18:18:00 +03:00
parent 80c1cfd9e4
commit b4138bbc82
33 changed files with 140 additions and 155 deletions
@@ -63,10 +63,10 @@ class DashboardController extends Controller
$curLeads = (clone $base())->whereBetween('received_at', [$windowStart, $now])->count();
$prevLeads = (clone $base())->whereBetween('received_at', [$prevStart, $windowStart])->count();
// --- conversion: % статуса 'paid' в окне ---
$curPaid = (clone $base())->where('status', 'paid')
// --- conversion: % статуса 'won' в окне ---
$curPaid = (clone $base())->where('status', 'won')
->whereBetween('received_at', [$windowStart, $now])->count();
$prevPaid = (clone $base())->where('status', 'paid')
$prevPaid = (clone $base())->where('status', 'won')
->whereBetween('received_at', [$prevStart, $windowStart])->count();
$curConv = $curLeads > 0 ? round($curPaid / $curLeads * 100, 1) : 0.0;
$prevConv = $prevLeads > 0 ? round($prevPaid / $prevLeads * 100, 1) : 0.0;
@@ -12,8 +12,8 @@ use Illuminate\Support\Facades\DB;
* managers_summary агрегат сделок по менеджерам за период (audit F1).
*
* Группировка по deals.manager_id; неназначенные (manager_id IS NULL) сводятся
* в строку «Не назначен». «Оплачено» = status='paid' (won-статус воронки, как
* в DashboardController). Конверсия = paid / total * 100, округление до 0.1.
* в строку «Не назначен». «Оплачено» = status='won' (won-статус воронки, как
* в DashboardController). Конверсия = won / total * 100, округление до 0.1.
*
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted
* (deleted_at IS NULL) и тестовые (is_test=false) сделки. RLS-обёртка
@@ -48,7 +48,7 @@ class ManagersSummaryProvider implements ReportDataProvider
"deals.manager_id,
users.first_name, users.last_name, users.email,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE deals.status = 'paid') AS paid"
COUNT(*) FILTER (WHERE deals.status = 'won') AS paid"
)
->get();
@@ -12,8 +12,8 @@ use Illuminate\Support\Facades\DB;
* sources_summary агрегат сделок по источнику (utm_source) за период (audit F1).
*
* Группировка по deals.utm_source; сделки без метки (NULL/пусто) сводятся в
* строку «Прямые / без метки». «Оплачено» = status='paid'. Конверсия =
* paid / total * 100, округление до 0.1.
* строку «Прямые / без метки». «Оплачено» = status='won'. Конверсия =
* won / total * 100, округление до 0.1.
*
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted и тестовые
* сделки. RLS-обёртка SET LOCAL app.current_tenant_id паттерн DealsExportProvider.
@@ -45,7 +45,7 @@ class SourcesSummaryProvider implements ReportDataProvider
->selectRaw(
"utm_source,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'paid') AS paid"
COUNT(*) FILTER (WHERE status = 'won') AS paid"
)
->get();
@@ -24,11 +24,11 @@ import FunnelChart from './FunnelChart.vue';
</v-app>
</Variant>
<Variant title="концентрация на 'Оплачено'">
<Variant title="концентрация на 'Сделка'">
<v-app>
<v-main class="story-pane">
<v-container>
<FunnelChart :counts="{ paid: 100, new: 5, viewed: 5, worked: 5 }" />
<FunnelChart :counts="{ won: 100, new: 5, viewed: 5, in_progress: 5 }" />
</v-container>
</v-main>
</v-app>
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* Воронка распределения лидов по 14 статусам.
* Воронка распределения лидов по 5 статусам воронки.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_dashboard.html секция .panel
* с #funnel-title (segmented bar + funnel-list).
@@ -13,7 +13,7 @@
* Рендер:
* 1. Segmented horizontal bar — каждый сегмент пропорционален count'у статуса
* и закрашен colorHex из lead_statuses.
* 2. funnel-list — 14 строк с цветным dot + name + count, отсортированы по
* 2. funnel-list — 5 строк с цветным dot + name + count, отсортированы по
* убыванию count'а (как в handoff).
*/
import { computed } from 'vue';
@@ -26,23 +26,14 @@ interface Props {
// Default counts инлайнятся в withDefaults — Vue SFC compiler требует чтобы
// factory-функция в withDefaults не реферировала модуль-уровневые const'ы
// (checkInvalidScopeReference). Mock-распределение ~247 лидов по 14 статусам.
// (checkInvalidScopeReference). Mock-распределение ~190 лидов по 5 статусам.
const props = withDefaults(defineProps<Props>(), {
counts: () => ({
new: 18,
viewed: 14,
worked: 22,
base: 9,
missed: 16,
negotiations: 11,
waiting_payment: 7,
partnership: 4,
paid: 45,
closed: 3,
test_drive: 38,
hot: 5,
replacement: 5,
final_missed: 39,
new: 24,
viewed: 18,
in_progress: 96,
won: 41,
lost: 11,
}),
title: 'Воронка',
});
@@ -7,7 +7,7 @@ const open1 = ref(true);
const open2 = ref(true);
const dealNew = MOCK_DEALS.find((d) => d.statusSlug === 'new')!;
const dealPaid = MOCK_DEALS.find((d) => d.statusSlug === 'paid')!;
const dealWon = MOCK_DEALS.find((d) => d.statusSlug === 'won')!;
</script>
<template>
@@ -20,10 +20,10 @@ const dealPaid = MOCK_DEALS.find((d) => d.statusSlug === 'paid')!;
</v-app>
</Variant>
<Variant title="paid status">
<Variant title="won status">
<v-app>
<v-main class="story-main">
<DealDetailDrawer v-model:open="open2" :deal="dealPaid" />
<DealDetailDrawer v-model:open="open2" :deal="dealWon" />
</v-main>
</v-app>
</Variant>
@@ -3,7 +3,7 @@
* Wizard маппинга неизвестных статусов воронки из CSV-импорта (ТЗ §6.4/§6.6).
*
* Для каждого незамапленного русского статуса пользователь выбирает один из
* 14 канонических slug'ов. Сохранение → POST /api/imports/unknown-statuses/resolve.
* 5 slug'ов воронки. Сохранение → POST /api/imports/unknown-statuses/resolve.
*/
import { computed, reactive, ref } from 'vue';
import { resolveUnknownStatuses, type StatusMapping, type UnknownStatus } from '../../api/imports';
@@ -18,22 +18,13 @@ const emit = defineEmits<{
resolved: [];
}>();
/** 14 канонических статусов воронки (ТЗ §6.4). */
/** 5 статусов воронки (редизайн 2026-05-17). */
const STATUS_OPTIONS: { value: string; title: string }[] = [
{ value: 'new', title: 'Новые' },
{ value: 'new', title: 'Новая сделка' },
{ value: 'viewed', title: 'Просмотрено' },
{ value: 'worked', title: 'Проработан' },
{ value: 'base', title: 'База' },
{ value: 'missed', title: 'Недозвон' },
{ value: 'negotiations', title: 'Переговоры' },
{ value: 'waiting_payment', title: 'Ожидаем оплаты' },
{ value: 'partnership', title: 'Партнерка' },
{ value: 'paid', title: 'Оплачено' },
{ value: 'closed', title: 'Закрыто и не реализовано' },
{ value: 'test_drive', title: 'Тест драйв' },
{ value: 'hot', title: 'Горячий' },
{ value: 'replacement', title: 'На замену' },
{ value: 'final_missed', title: 'Конечный недозвон' },
{ value: 'in_progress', title: 'В работе' },
{ value: 'won', title: 'Сделка' },
{ value: 'lost', title: 'Не реализовано' },
];
const selection = reactive<Record<string, string | null>>({});
@@ -4,9 +4,9 @@ import { LEAD_STATUSES } from '../../composables/leadStatuses';
import { MOCK_DEALS } from '../../composables/mockDeals';
const newStatus = LEAD_STATUSES.find((s) => s.slug === 'new')!;
const paidStatus = LEAD_STATUSES.find((s) => s.slug === 'paid')!;
const wonStatus = LEAD_STATUSES.find((s) => s.slug === 'won')!;
const newDeals = MOCK_DEALS.filter((d) => d.statusSlug === 'new');
const paidDeals = MOCK_DEALS.filter((d) => d.statusSlug === 'paid');
const wonDeals = MOCK_DEALS.filter((d) => d.statusSlug === 'won');
</script>
<template>
@@ -19,10 +19,10 @@ const paidDeals = MOCK_DEALS.filter((d) => d.statusSlug === 'paid');
</v-app>
</Variant>
<Variant title=Оплачено» (2 сделки)">
<Variant title=Сделка» (2 сделки)">
<v-app>
<v-main class="story-pane">
<KanbanColumn :status="paidStatus" :deals="paidDeals" />
<KanbanColumn :status="wonStatus" :deals="wonDeals" />
</v-main>
</v-app>
</Variant>
+10 -10
View File
@@ -39,7 +39,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 2,
name: 'Дмитрий Кузнецов',
phone: '+7 (903) 412-58-90',
statusSlug: 'worked',
statusSlug: 'in_progress',
project: 'Окна Москва',
manager: { initials: 'ОР', name: 'Ольга Р.' },
cost: 2400,
@@ -49,7 +49,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 3,
name: 'Светлана Иванова',
phone: '+7 (925) 309-44-12',
statusSlug: 'negotiations',
statusSlug: 'in_progress',
project: 'Окна Москва',
manager: { initials: 'ИП', name: 'Иван П.' },
cost: 2100,
@@ -59,7 +59,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 4,
name: 'Марина Лебедева',
phone: '+7 (915) 778-90-32',
statusSlug: 'paid',
statusSlug: 'won',
project: 'Натяжные потолки',
manager: { initials: 'ОР', name: 'Ольга Р.' },
cost: 2350,
@@ -69,7 +69,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 5,
name: 'Алексей Петров',
phone: '+7 (905) 132-46-87',
statusSlug: 'missed',
statusSlug: 'in_progress',
project: 'Окна Москва',
manager: { initials: 'ИП', name: 'Иван П.' },
cost: 2400,
@@ -79,7 +79,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 6,
name: 'Екатерина Морозова',
phone: '+7 (926) 554-21-09',
statusSlug: 'waiting_payment',
statusSlug: 'in_progress',
project: 'Натяжные потолки',
manager: { initials: 'ОР', name: 'Ольга Р.' },
cost: 1950,
@@ -99,7 +99,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 8,
name: 'Тимур Алиев',
phone: '+7 (903) 765-09-21',
statusSlug: 'hot',
statusSlug: 'in_progress',
project: 'Натяжные потолки',
manager: { initials: 'ОР', name: 'Ольга Р.' },
cost: 1850,
@@ -109,7 +109,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 9,
name: 'Наталья Семёнова',
phone: '+7 (910) 244-67-83',
statusSlug: 'closed',
statusSlug: 'lost',
project: 'Окна Москва',
manager: { initials: 'ИП', name: 'Иван П.' },
cost: 2400,
@@ -119,7 +119,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 10,
name: 'Олег Григорьев',
phone: '+7 (909) 411-52-76',
statusSlug: 'partnership',
statusSlug: 'in_progress',
project: 'Натяжные потолки',
manager: { initials: 'ОР', name: 'Ольга Р.' },
cost: 1850,
@@ -129,7 +129,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 11,
name: 'Ирина Зайцева',
phone: '+7 (916) 671-98-04',
statusSlug: 'final_missed',
statusSlug: 'in_progress',
project: 'Окна Москва',
manager: { initials: 'ИП', name: 'Иван П.' },
cost: 2400,
@@ -139,7 +139,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 12,
name: 'Сергей Никитин',
phone: '+7 (925) 198-43-58',
statusSlug: 'paid',
statusSlug: 'won',
project: 'Натяжные потолки',
manager: { initials: 'ОР', name: 'Ольга Р.' },
cost: 1850,
+1 -1
View File
@@ -166,7 +166,7 @@ test('GET show: activity возвращает с actor_email из users LEFT JOI
'user_id' => $user->id,
'deal_id' => 999,
'event' => 'deal.status_changed',
'context' => json_encode(['from' => 'new', 'to' => 'worked']),
'context' => json_encode(['from' => 'new', 'to' => 'in_progress']),
'created_at' => Carbon::now(),
]);
DB::table('activity_log')->insert([
+6 -6
View File
@@ -71,7 +71,7 @@ it('leads_received считает только сделки окна, без del
// 3 живые сделки в окне 7d + 1 deleted + 1 is_test + 1 вне окна (8 дней назад)
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(2));
makeDashboardDeal($tenant, $project, 'paid', now()->subDays(3));
makeDashboardDeal($tenant, $project, 'won', now()->subDays(3));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), deletedAt: now());
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), isTest: true);
makeDashboardDeal($tenant, $project, 'new', now()->subDays(8));
@@ -81,14 +81,14 @@ it('leads_received считает только сделки окна, без del
->assertJsonPath('leads_received.value', 3);
});
it('conversion = доля статуса paid в окне', function () {
it('conversion = доля статуса won в окне', function () {
$tenant = Tenant::factory()->create();
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
makeDashboardDeal($tenant, $project, 'paid', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'won', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
// 1 paid из 4 → 25.0%; PHP json_encode кодирует 25.0 как 25 (без дроби)
// 1 won из 4 → 25.0%; PHP json_encode кодирует 25.0 как 25 (без дроби)
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
->assertOk()
->assertJsonPath('conversion.value', 25);
@@ -111,11 +111,11 @@ it('funnel группирует живые сделки по статусу', fu
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'paid', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'won', now()->subDays(1));
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
->assertOk()
->assertJsonPath('funnel.new', 2)
->assertJsonPath('funnel.paid', 1);
->assertJsonPath('funnel.won', 1);
});
it('activity возвращает 7 точек и 7 меток', function () {
+6 -6
View File
@@ -105,14 +105,14 @@ test('GET /api/deals сортирует по received_at DESC', function () {
test('GET /api/deals фильтрует по status_in[]', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'closed']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'lost']);
$r = $this->getJson('/api/deals?status_in[]=new&status_in[]=paid');
$r = $this->getJson('/api/deals?status_in[]=new&status_in[]=won');
expect($r->json('total'))->toBe(2);
$statuses = collect($r->json('deals'))->pluck('status')->sort()->values()->all();
expect($statuses)->toBe(['new', 'paid']);
expect($statuses)->toBe(['new', 'won']);
});
test('GET /api/deals фильтрует по project_id', function () {
@@ -292,7 +292,7 @@ test('GET /api/deals возвращает next_cursor когда есть ещё
test('GET /api/deals?count_only=1 возвращает только total без массива deals', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
$r = $this->getJson('/api/deals?count_only=1');
@@ -304,7 +304,7 @@ test('GET /api/deals?count_only=1 возвращает только total без
test('GET /api/deals?count_only=1 учитывает фильтры (status_in)', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
expect($this->getJson('/api/deals?count_only=1&status_in[]=new')->json('total'))->toBe(2);
});
+2 -2
View File
@@ -95,7 +95,7 @@ test('GET /api/deals/{id} возвращает activity events отсортир
'user_id' => $this->manager->id,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
'context' => ['from' => 'new', 'to' => 'paid', 'source' => 'manual'],
'context' => ['from' => 'new', 'to' => 'won', 'source' => 'manual'],
'created_at' => now()->subMinutes(5),
]);
@@ -106,7 +106,7 @@ test('GET /api/deals/{id} возвращает activity events отсортир
expect($events)->toHaveCount(2);
// ORDER BY created_at DESC — свежее (status_changed) сверху.
expect($events[0]['event'])->toBe('deal.status_changed');
expect($events[0]['context'])->toMatchArray(['from' => 'new', 'to' => 'paid']);
expect($events[0]['context'])->toMatchArray(['from' => 'new', 'to' => 'won']);
expect($events[0]['actor']['name'])->toBe('Иван П.');
expect($events[0]['actor']['initials'])->toBe('ИП');
+9 -9
View File
@@ -61,18 +61,18 @@ test('POST /api/deals/transition — обновляет статус и пише
$r = $this->postJson('/api/deals/transition', [
'ids' => $deals->pluck('id')->all(),
'status' => 'paid',
'status' => 'won',
]);
$r->assertStatus(200)->assertJson([
'updated' => 3,
'requested' => 3,
'status' => 'paid',
'status' => 'won',
]);
foreach ($deals as $d) {
$d->refresh();
expect($d->status)->toBe('paid');
expect($d->status)->toBe('won');
}
$activity = ActivityLog::where('tenant_id', $this->tenant->id)
@@ -81,17 +81,17 @@ test('POST /api/deals/transition — обновляет статус и пише
expect($activity)->toHaveCount(3);
expect($activity->first()->context)->toMatchArray([
'from' => 'new',
'to' => 'paid',
'to' => 'won',
'source' => 'bulk',
]);
});
test('POST /api/deals/transition — NO-OP не пишет ActivityLog', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
$r = $this->postJson('/api/deals/transition', [
'ids' => [$deal->id],
'status' => 'paid',
'status' => 'won',
]);
$r->assertStatus(200)->assertJson(['updated' => 0, 'requested' => 1]);
@@ -111,7 +111,7 @@ test('POST /api/deals/transition — defense-in-depth не апдейтит чу
// Передаём оба id — чужой не должен обновиться.
$r = $this->postJson('/api/deals/transition', [
'ids' => [$own->id, $foreign->id],
'status' => 'paid',
'status' => 'won',
]);
$r->assertStatus(200)->assertJson([
@@ -121,7 +121,7 @@ test('POST /api/deals/transition — defense-in-depth не апдейтит чу
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$own->refresh();
expect($own->status)->toBe('paid');
expect($own->status)->toBe('won');
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
$foreign->refresh();
@@ -131,6 +131,6 @@ test('POST /api/deals/transition — defense-in-depth не апдейтит чу
test('POST /api/deals/transition — 422 если ids пустой массив', function () {
$this->postJson('/api/deals/transition', [
'ids' => [],
'status' => 'paid',
'status' => 'won',
])->assertStatus(422);
});
+6 -6
View File
@@ -83,17 +83,17 @@ test('PATCH /api/deals/{id} обновляет status + пишет deal.status_c
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
$r = $this->patchJson('/api/deals/'.$deal->id, [
'status' => 'paid',
'status' => 'won',
]);
$r->assertStatus(200);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$deal->refresh();
expect($deal->status)->toBe('paid');
expect($deal->status)->toBe('won');
$log = ActivityLog::where('deal_id', $deal->id)->where('event', 'deal.status_changed')->first();
expect($log)->not->toBeNull();
expect($log->context)->toMatchArray(['from' => 'new', 'to' => 'paid', 'source' => 'manual']);
expect($log->context)->toMatchArray(['from' => 'new', 'to' => 'won', 'source' => 'manual']);
});
test('PATCH /api/deals/{id} 422 на неизвестный status slug', function () {
@@ -123,12 +123,12 @@ test('PATCH /api/deals/{id} 422 на manager_id чужого tenant\'а', functi
test('PATCH /api/deals/{id} NO-OP не пишет ActivityLog', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create([
'status' => 'paid',
'status' => 'won',
'comment' => 'same',
]);
$r = $this->patchJson('/api/deals/'.$deal->id, [
'status' => 'paid', // не меняем
'status' => 'won', // не меняем
'comment' => 'same', // не меняем
]);
$r->assertStatus(200);
@@ -145,7 +145,7 @@ test('PATCH /api/deals/{id} комбинированно — comment + status о
$r = $this->patchJson('/api/deals/'.$deal->id, [
'comment' => 'Заметка',
'status' => 'worked',
'status' => 'in_progress',
]);
$r->assertStatus(200);
@@ -51,7 +51,7 @@ test('импортирует исторические лиды, создавая
->and($result->updated)->toBe(0);
$deal = Deal::query()->where('source_crm_id', 5001)->firstOrFail();
expect($deal->status)->toBe('negotiations')
expect($deal->status)->toBe('in_progress')
->and($deal->phone)->toBe('79161112233')
->and($deal->received_at->format('Y-m-d'))->toBe('2023-07-10');
});
@@ -89,7 +89,7 @@ test('повторный импорт того же файла не создаё
->and(Deal::query()->where('source_crm_id', 5003)->count())->toBe(1);
$deal = Deal::query()->where('source_crm_id', 5003)->firstOrFail();
expect($deal->status)->toBe('paid') // §6.5 стадия 3a: status перезаписан
expect($deal->status)->toBe('won') // §6.5 стадия 3a: status перезаписан
->and($deal->contact_name)->toBe('Пётр')
->and($deal->comment)->toBe('Обновлённый');
});
@@ -127,7 +127,7 @@ test('resolved-маппинг tenant-а применяется к ранее н
'tenant_id' => $this->tenant->id,
'status_ru' => 'Архив',
'occurrences' => 1,
'mapped_to_slug' => 'closed',
'mapped_to_slug' => 'lost',
'resolved_at' => now(),
]);
$rows = parseFixture(
@@ -135,7 +135,7 @@ test('resolved-маппинг tenant-а применяется к ранее н
);
$this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
expect(Deal::query()->where('source_crm_id', 5007)->firstOrFail()->status)->toBe('closed');
expect(Deal::query()->where('source_crm_id', 5007)->firstOrFail()->status)->toBe('lost');
});
test('dry_run не пишет сделки, но считает проекцию', function (): void {
@@ -155,7 +155,7 @@ test('неизвестные статусы и resolved-маппинг изол
'tenant_id' => $otherTenant->id,
'status_ru' => 'Архив',
'occurrences' => 9,
'mapped_to_slug' => 'closed',
'mapped_to_slug' => 'lost',
'resolved_at' => now(),
]);
@@ -97,7 +97,7 @@ test('GET /api/imports/unknown-statuses возвращает незамапле
]);
ImportUnknownStatus::create([
'tenant_id' => $this->tenant->id, 'status_ru' => 'Спам', 'occurrences' => 1,
'mapped_to_slug' => 'closed', 'resolved_at' => now(),
'mapped_to_slug' => 'lost', 'resolved_at' => now(),
]);
$this->getJson('/api/imports/unknown-statuses')
@@ -113,10 +113,10 @@ test('POST /api/imports/unknown-statuses/resolve проставляет мапп
]);
$this->postJson('/api/imports/unknown-statuses/resolve', [
'mappings' => [['status_ru' => 'Архив', 'slug' => 'closed']],
'mappings' => [['status_ru' => 'Архив', 'slug' => 'lost']],
])->assertStatus(200);
expect($unknown->refresh()->mapped_to_slug)->toBe('closed')
expect($unknown->refresh()->mapped_to_slug)->toBe('lost')
->and($unknown->resolved_at)->not->toBeNull();
});
@@ -43,7 +43,7 @@ test('ImportUnknownStatus хранит маппинг и фильтруется
'tenant_id' => $this->tenant->id,
'status_ru' => 'Спам',
'occurrences' => 1,
'mapped_to_slug' => 'closed',
'mapped_to_slug' => 'lost',
'resolved_at' => now(),
]);
+5 -10
View File
@@ -8,9 +8,8 @@ use Illuminate\Support\Facades\DB;
/**
* Тесты GET /api/lead-statuses глобальный lookup статусов воронки.
*
* Таблица lead_statuses не tenant-aware, seeded в schema.sql:2130 (14 системных
* статусов: new/viewed/worked/base/missed/negotiations/waiting_payment/
* partnership/paid/closed/test_drive/hot/replacement/final_missed).
* Таблица lead_statuses не tenant-aware, seeded в schema.sql (5 системных
* статусов воронки: new/viewed/in_progress/won/lost).
*/
uses(DatabaseTransactions::class);
@@ -19,18 +18,14 @@ test('GET /api/lead-statuses возвращает 200 и не пустой сп
$r->assertStatus(200);
expect($r->json('lead_statuses'))->toBeArray();
expect(count($r->json('lead_statuses')))->toBeGreaterThanOrEqual(14);
expect(count($r->json('lead_statuses')))->toBeGreaterThanOrEqual(5);
});
test('GET /api/lead-statuses возвращает все 14 системных статусов из seed', function () {
test('GET /api/lead-statuses возвращает все 5 системных статусов из seed', function () {
$r = $this->getJson('/api/lead-statuses');
$slugs = collect($r->json('lead_statuses'))->pluck('slug')->all();
$expected = [
'new', 'viewed', 'worked', 'base', 'missed', 'negotiations',
'waiting_payment', 'partnership', 'paid', 'closed',
'test_drive', 'hot', 'replacement', 'final_missed',
];
$expected = ['new', 'viewed', 'in_progress', 'won', 'lost'];
foreach ($expected as $slug) {
expect($slugs)->toContain($slug);
}
@@ -55,12 +55,12 @@ test('slug = managers', function () {
expect((new ManagersSummaryProvider)->slug())->toBe('managers');
});
test('агрегирует сделки по менеджеру: total, paid, конверсия', function () {
test('агрегирует сделки по менеджеру: total, won, конверсия', function () {
$manager = User::factory()->create([
'tenant_id' => $this->tenant->id, 'first_name' => 'Иван', 'last_name' => 'Петров',
]);
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'paid']);
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'paid']);
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'won']);
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'won']);
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'new']);
$rows = (new ManagersSummaryProvider)->rows(managersJob($this->tenant->id));
@@ -350,7 +350,7 @@ test('POST /api/reports/jobs (sync queue): managers_summary → done с CSV', fu
'project_id' => $project->id,
'manager_id' => $manager->id,
'phone' => '+79990001122',
'status' => 'paid',
'status' => 'won',
'received_at' => $now,
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
@@ -383,7 +383,7 @@ test('POST /api/reports/jobs (sync queue): sources_summary → done с CSV', fun
'tenant_id' => $this->tenant->id,
'project_id' => $project->id,
'phone' => '+79990002233',
'status' => 'paid',
'status' => 'won',
'utm_source' => 'yandex',
'received_at' => $now,
'created_at' => Carbon::now(),
@@ -53,9 +53,9 @@ test('slug = sources', function () {
});
test('агрегирует сделки по utm_source', function () {
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'paid']);
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'won']);
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'new']);
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'vk', 'status' => 'paid']);
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'vk', 'status' => 'won']);
$rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id));
@@ -105,7 +105,7 @@ function makeApiDetail(overrides: Partial<AdminTenantDetailResponse> = {}): Admi
event: 'deal.status_changed',
deal_id: 4470,
actor_email: 'ivan@okna-moscow.ru',
context: { from: 'viewed', to: 'worked' },
context: { from: 'viewed', to: 'in_progress' },
created_at: '2026-05-09T07:18:00+00:00',
},
],
@@ -221,7 +221,7 @@ describe('AdminTenantDetailView.vue (API integration)', () => {
expect(text).toContain('webhook.received');
expect(text).toContain('deal.status_changed');
expect(text).toContain('ivan@okna-moscow.ru'); // actor_email
expect(text).toContain('viewed → worked'); // summary из context
expect(text).toContain('viewed → in_progress'); // summary из context
});
it('кнопка «Войти как клиент» open impersonationDialog', async () => {
+1 -1
View File
@@ -31,7 +31,7 @@ function makeSummary(overrides: Partial<DashboardSummary> = {}): DashboardSummar
active_projects: { active: 8, limit: 10 },
balance: { amount_rub: '14250.00', runway_days: 4, runway_leads: 285 },
activity: { points: [3, 5, 2, 8, 6, 9, 4], labels: ['сб', 'вс', 'пн', 'вт', 'ср', 'чт', 'сегодня'], max: 10 },
funnel: { new: 18, paid: 45 },
funnel: { new: 18, won: 45 },
...overrides,
};
}
@@ -76,7 +76,7 @@ describe('DealDetailBody ↔ GET /api/deals/{id} integration', () => {
makeApiEvent({
id: 2,
event: 'deal.status_changed',
context: { from: 'new', to: 'paid' },
context: { from: 'new', to: 'won' },
actor: { id: 1, name: 'Иван П.', initials: 'ИП' },
}),
],
@@ -89,8 +89,8 @@ describe('DealDetailBody ↔ GET /api/deals/{id} integration', () => {
expect(dealsApi.getDeal).toHaveBeenCalledWith(MOCK_DEALS[0].id, 1);
const items = wrapper.findAll('.timeline-item');
expect(items).toHaveLength(2);
// status_changed event имеет detail "new → paid".
expect(wrapper.text()).toContain('new → paid');
// status_changed event имеет detail "new → won".
expect(wrapper.text()).toContain('new → won');
});
it('getDeal reject → eventsFetchError=true, alert виден, events пуст (I3)', async () => {
+11 -11
View File
@@ -99,7 +99,7 @@ describe('DealsView ↔ GET /api/deals integration', () => {
setupAuth(1);
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
deals: [
makeApiDeal({ id: 200, contact_name: 'Из API #1', status: 'paid' }),
makeApiDeal({ id: 200, contact_name: 'Из API #1', status: 'won' }),
makeApiDeal({ id: 201, contact_name: 'Из API #2', status: 'new' }),
],
total: 2,
@@ -162,7 +162,7 @@ describe('DealsView ↔ GET /api/deals integration', () => {
vi.mocked(dealsApi.transitionDeals).mockResolvedValueOnce({
updated: 2,
requested: 2,
status: 'paid',
status: 'won',
});
const wrapper = await mountDealsView();
@@ -178,16 +178,16 @@ describe('DealsView ↔ GET /api/deals integration', () => {
vm.selected = [500, 501];
await flushPromises();
await vm.applyBulkStatus('paid');
await vm.applyBulkStatus('won');
await flushPromises();
// Optimistic local-update применился до завершения API-вызова.
expect(vm.dealsState.find((d) => d.id === 500)?.statusSlug).toBe('paid');
expect(vm.dealsState.find((d) => d.id === 501)?.statusSlug).toBe('paid');
expect(vm.dealsState.find((d) => d.id === 500)?.statusSlug).toBe('won');
expect(vm.dealsState.find((d) => d.id === 501)?.statusSlug).toBe('won');
expect(dealsApi.transitionDeals).toHaveBeenCalledWith({
tenant_id: 1,
ids: [500, 501],
status: 'paid',
status: 'won',
});
expect(vm.statusToastOpen).toBe(true);
expect(vm.statusToastText).toContain('Обновлено 2');
@@ -214,10 +214,10 @@ describe('DealsView ↔ GET /api/deals integration', () => {
};
vm.selected = [600];
await flushPromises();
await vm.applyBulkStatus('paid');
await vm.applyBulkStatus('won');
await flushPromises();
expect(vm.dealsState.find((d) => d.id === 600)?.statusSlug).toBe('paid');
expect(vm.dealsState.find((d) => d.id === 600)?.statusSlug).toBe('won');
expect(vm.statusToastText).toContain('Не удалось');
});
});
@@ -235,8 +235,8 @@ describe('KanbanView ↔ GET /api/deals integration', () => {
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
deals: [
makeApiDeal({ id: 300, status: 'new' }),
makeApiDeal({ id: 301, status: 'paid' }),
makeApiDeal({ id: 302, status: 'paid' }),
makeApiDeal({ id: 301, status: 'won' }),
makeApiDeal({ id: 302, status: 'won' }),
],
total: 3,
limit: 500,
@@ -252,7 +252,7 @@ describe('KanbanView ↔ GET /api/deals integration', () => {
fetchError: boolean;
};
expect(vm.dealsByStatus.new.map((d) => d.id)).toEqual([300]);
expect(vm.dealsByStatus.paid.map((d) => d.id).sort()).toEqual([301, 302]);
expect(vm.dealsByStatus.won.map((d) => d.id).sort()).toEqual([301, 302]);
expect(vm.totalDeals).toBe(3);
expect(vm.fetchError).toBe(false);
});
+5 -1
View File
@@ -64,7 +64,11 @@ describe('DealsView.vue — реестр лидов', () => {
it('НЕТ кнопки «Новая сделка» и режима «Корзина»', async () => {
const w = await mountDeals();
expect(w.text()).not.toContain('Новая сделка');
// «Новая сделка» присутствует как статус-пилюля в таблице (slug `new` —
// редизайн воронки 2026-05-17), поэтому проверяем отсутствие именно
// КНОПКИ создания сделки: ручное создание убрано в реестре лидов.
const buttons = w.findAll('button');
expect(buttons.some((b) => b.text().includes('Новая сделка'))).toBe(false);
expect(w.text()).not.toContain('Корзина');
});
+8 -8
View File
@@ -16,22 +16,22 @@ describe('FunnelChart.vue', () => {
expect(wrapper.text()).toContain('Воронка');
});
it('содержит ровно 14 сегментов в bar (по числу lead_statuses)', () => {
it('содержит ровно 5 сегментов в bar (по числу lead_statuses)', () => {
const wrapper = factory();
const segs = wrapper.findAll('.funnel-seg');
expect(segs).toHaveLength(14);
expect(segs).toHaveLength(5);
});
it('содержит ровно 14 list-items', () => {
it('содержит ровно 5 list-items', () => {
const wrapper = factory();
const items = wrapper.findAll('.funnel-list-item');
expect(items).toHaveLength(14);
expect(items).toHaveLength(5);
});
it('использует правильные slug-имена из schema (НЕ из BRANDBOOK)', () => {
const wrapper = factory();
const text = wrapper.text();
// Проверка что все 14 имён из lead_statuses присутствуют.
// Проверка что все 5 имён из lead_statuses присутствуют.
LEAD_STATUSES.forEach((s) => {
expect(text).toContain(s.nameRu);
});
@@ -41,10 +41,10 @@ describe('FunnelChart.vue', () => {
expect(text).not.toContain('Спам');
});
it('сортирует список по убыванию count (paid 45 — первый)', () => {
it('сортирует список по убыванию count (in_progress 96 — первый)', () => {
const wrapper = factory();
const names = wrapper.findAll('.funnel-list-item .name').map((n) => n.text());
expect(names[0]).toBe('Оплачено'); // count=45 — самый большой в DEFAULT_COUNTS.
expect(names[0]).toBe('В работе'); // count=96 — самый большой в DEFAULT_COUNTS.
});
it('применяет colorHex из lead_statuses к dots и сегментам', () => {
@@ -55,7 +55,7 @@ describe('FunnelChart.vue', () => {
});
it('считает total как сумму counts', () => {
const wrapper = factory({ counts: { new: 10, paid: 20 } });
const wrapper = factory({ counts: { new: 10, won: 20 } });
const text = wrapper.text();
// total = 10 + 20 = 30 (остальные слаги с counts={} → 0).
expect(text).toContain('30 лидов');
+2 -2
View File
@@ -125,10 +125,10 @@ describe('NewDealDialog.vue', () => {
});
it('presetStatus → statusSlug дефолтит на пресет (для KanbanView)', async () => {
const wrapper = factory({ modelValue: true, presetStatus: 'paid' });
const wrapper = factory({ modelValue: true, presetStatus: 'won' });
await flushPromises();
const vm = wrapper.vm as unknown as { statusSlug: string };
expect(vm.statusSlug).toBe('paid');
expect(vm.statusSlug).toBe('won');
});
it('без tenantId — submit НЕ вызывает API (local-only mode)', async () => {
@@ -66,15 +66,15 @@ describe('UnknownStatusesDialog', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.selection['Архив'] = 'closed';
vm.selection['Спам'] = 'closed';
vm.selection['Архив'] = 'lost';
vm.selection['Спам'] = 'lost';
await flushPromises();
await vm.save();
await flushPromises();
expect(spy).toHaveBeenCalledWith([
{ status_ru: 'Архив', slug: 'closed' },
{ status_ru: 'Спам', slug: 'closed' },
{ status_ru: 'Архив', slug: 'lost' },
{ status_ru: 'Спам', slug: 'lost' },
]);
expect(wrapper.emitted('resolved')).toBeTruthy();
wrapper.unmount();
@@ -221,7 +221,7 @@ describe('adminTenantDetailMapper', () => {
event: 'deal.status_changed',
deal_id: 200,
actor_email: 'user@test.io',
context: { from: 'new', to: 'worked' },
context: { from: 'new', to: 'in_progress' },
created_at: '2026-01-01T00:00:00Z',
},
],
@@ -229,7 +229,7 @@ describe('adminTenantDetailMapper', () => {
);
expect(ui.activity[0]!.actor).toBe('system');
expect(ui.activity[1]!.actor).toBe('user@test.io');
expect(ui.activity[1]!.summary).toBe('Сделка #200: new → worked');
expect(ui.activity[1]!.summary).toBe('Сделка #200: new → in_progress');
});
it('metrics: leadsThisMonth/Week/avgLeadCost/runwayDays', () => {
+3 -3
View File
@@ -22,11 +22,11 @@ describe('useLeadStatusesStore', () => {
expect(store.fetchError).toBe(false);
});
it('findBySlug возвращает статус из snapshot до load', () => {
it('findBySlug возвращает статус из snapshot до load (won)', () => {
const store = useLeadStatusesStore();
const found = store.findBySlug('paid');
const found = store.findBySlug('won');
expect(found).not.toBeNull();
expect(found!.nameRu).toBe('Оплачено');
expect(found!.nameRu).toBe('Сделка');
});
it('findBySlug возвращает null для неизвестного slug', () => {
+6 -2
View File
@@ -2,16 +2,18 @@ import { describe, it, expect } from 'vitest';
import { useStatusPill, STATUS_PILL_SLUGS } from '../../resources/js/composables/useStatusPill';
describe('useStatusPill', () => {
it('exposes exactly 14 known slugs', () => {
expect(STATUS_PILL_SLUGS).toHaveLength(14);
it('exposes exactly 16 known slugs', () => {
expect(STATUS_PILL_SLUGS).toHaveLength(16);
expect(STATUS_PILL_SLUGS).toEqual(
expect.arrayContaining([
'new',
'viewed',
'in_progress',
'callback',
'quality',
'meeting_set',
'won',
'lost',
'refund',
'duplicate',
'junk',
@@ -26,11 +28,13 @@ describe('useStatusPill', () => {
it.each([
['new', { bg: 'rgba(15,110,86,0.12)', color: '#0F6E56' }],
['viewed', { bg: 'rgba(122,91,163,0.15)', color: '#7A5BA3' }],
['in_progress', { bg: 'rgba(63,124,149,0.12)', color: '#2A5A6E' }],
['callback', { bg: 'rgba(217,164,65,0.18)', color: '#A07820' }],
['quality', { bg: 'rgba(46,139,87,0.15)', color: '#2E8B57' }],
['meeting_set', { bg: 'rgba(122,91,163,0.15)', color: '#7A5BA3' }],
['won', { bg: 'rgba(46,139,87,0.22)', color: '#1F6940', fontWeight: 600 }],
['lost', { bg: 'rgba(107,99,86,0.18)', color: '#6B6356' }],
['refund', { bg: 'rgba(204,110,80,0.15)', color: '#B0563D' }],
['duplicate', { bg: 'rgba(1,32,25,0.08)', color: '#3A3A3A' }],
['junk', { bg: 'rgba(184,58,58,0.10)', color: '#B83A3A' }],