feat(deals): sweep 14->5 funnel slugs — controllers, mocks, stories, tests
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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('ИП');
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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('Корзина');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 лидов');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
Reference in New Issue
Block a user