merge: подтянул main после выката починок модерации Яндекса

Мой коммит уехал в main отдельно — пересадкой на актуальную вершину
и оттуда на боевой. Возвращаю main к себе, чтобы ветка не разъезжалась.

Пришло 9 коммитов, из них главное — работа соседней сессии по воронке
продаж, уже влитая в main и запушенная.

Конфликта два, оба в общих машинных файлах:

- cspell-words.txt — сторону не выбирал, объединил. Все слова обеих
  сторон на месте; убрана ровно одна строка — мой же дубль «админский»,
  который прошлый коммит добавил дважды. Проверено сравнением с версией
  до слияния: другого отличия нет;
- docs/observer/STATUS.md — машинный файл наблюдателя со столбиком часов
  процессов, взята своя версия, его всё равно переписывает хук.

Замечание на будущее: патч «минимум площадки считается по длине периода»
живёт в двух коммитах — 948807507 у меня и в ветке робота, 9df7846fd
в main. В main уехала только вторая копия, main чист. Когда моя ветка
и ветка робота пойдут в main, git встретит этот патч второй раз.

Статанализ остановил слияние, и это была НЕ ложная тревога: файл-эталон
phpstan-baseline.neon склеился из двух версий и перестал соответствовать
коду. Все 16 ошибок — в чужих файлах воронки продаж, пришедших из main
байт в байт, известного ложного класса про Pest. Эталон из main один
не подошёл: всплыли записи, нужные этой ветке. Пересобрал по проектной
процедуре в два шага, phpstan.neon вернул на место. Итог 0 ошибок,
правка эталона мелкая: +43 -31. Моих файлов среди добавленных записей
нет ни одного.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-08-01 16:19:06 +03:00
39 changed files with 1902 additions and 73 deletions
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\Sales;
use App\Http\Controllers\Concerns\ResolvesSalesPeriod;
use App\Http\Controllers\Controller;
use App\Models\SalesAdAudienceFirm;
use App\Models\SalesAdAudienceWarmingEpisode;
@@ -15,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;
@@ -30,10 +32,15 @@ use Illuminate\Validation\ValidationException;
*/
class SalesProspectController extends Controller
{
use ResolvesSalesPeriod;
/** Стадии, по которым делать уже нечего — в фильтр «что надо сделать» не попадают. */
private const DEAD_STAGES = ['rejected', 'trash'];
/** Все стадии в порядке колонок канбана. Должно совпадать с PROSPECT_STAGES (фронт). */
private const STAGES = [
'new', 'in_work', 'negotiation', 'manual_testing', 'registered', 'testing',
'topped_up', 'user', 'kp_sent', 'rejected', 'no_answer',
'topped_up', 'user', 'kp_sent', 'rejected', 'no_answer', 'trash',
];
/**
@@ -78,6 +85,8 @@ class SalesProspectController extends Controller
$query->where('source', (string) $request->query('source'));
}
$this->applyDateFilter($query, $request);
$all = $query->get();
$warmingByProspect = $this->warmingByProspect($all->pluck('id')->all());
$prospects = $all->map(fn (SalesProspect $p) => $this->row($p, $warmingByProspect))->all();
@@ -108,6 +117,57 @@ class SalesProspectController extends Controller
]);
}
/**
* Фильтр доски по датам. Два режима, они взаимоисключающие по смыслу:
*
* todo «что надо сделать»: созвон попадает в период ИЛИ уже просрочен.
* Просроченное показывается ВСЕГДА, какой бы период ни выбрали:
* забытый звонок недельной давности важнее завтрашнего. Отказ и
* корзина не показываются по ним делать нечего.
* changed «что менялось»: у карточки есть движение по стадиям ИЛИ запись
* в журнале разговоров за период. Движения включают автоматические
* (деньги тестирование/пополнил/пользователь) владелец просил
* считать ЛЮБОЕ движение, а не только разговоры.
*
* Без date_mode доска отдаёт всё, как раньше.
*
* @param Builder<SalesProspect> $query
*/
private function applyDateFilter(Builder $query, Request $request): void
{
$mode = (string) $request->query('date_mode', '');
if (! in_array($mode, ['todo', 'changed'], true)) {
return;
}
$range = $this->resolvePeriod($request);
if ($mode === 'todo') {
$query->whereNotIn('stage', self::DEAD_STAGES)
->whereNotNull('next_call_at')
->where(function ($q) use ($range) {
$q->whereBetween('next_call_at', [$range->start, $range->end])
->orWhere('next_call_at', '<', now());
});
return;
}
$query->where(function ($q) use ($range) {
$q->whereExists(function ($sub) use ($range) {
$sub->selectRaw('1')
->from('sales_prospect_moves')
->whereColumn('sales_prospect_moves.prospect_id', 'sales_prospects.id')
->whereBetween('sales_prospect_moves.created_at', [$range->start, $range->end]);
})->orWhereExists(function ($sub) use ($range) {
$sub->selectRaw('1')
->from('sales_prospect_notes')
->whereColumn('sales_prospect_notes.prospect_id', 'sales_prospects.id')
->whereBetween('sales_prospect_notes.created_at', [$range->start, $range->end]);
});
});
}
/**
* Менеджер заводит СВОЕГО кандидата (инициатива, не из поиска).
* Карточка всегда создаётся автору (sales_user_id из тела игнорируется),
@@ -416,19 +476,39 @@ class SalesProspectController extends Controller
case 'kp_sent':
$this->assertNotPaying($prospect);
$data = $request->validate([
'next_call_at' => ['required', 'date'],
// Дата созвона НЕобязательна (правка 01.08.2026): бывает «пришлите,
// созвонимся через пару дней» — дата есть, и бывает «пришлите на
// почту, если интересно — перезвоню» — договорённости о созвоне нет.
'next_call_at' => ['nullable', 'date'],
'kp_channel' => ['required', 'string', Rule::in(array_keys(self::KP_CHANNEL_TITLES))],
// Обязательно и при «другое»: запись «КП ушло куда-то» бесполезна.
'kp_target' => ['required', 'string', 'max:500'],
]);
$prospect->stage = 'kp_sent';
$prospect->next_call_at = $data['next_call_at'];
$prospect->kp_channel = $data['kp_channel'];
// Телеграм-ник, почта и номер — разные вещи, к одному виду не приводим.
$prospect->kp_target = trim($data['kp_target']);
$prospect->reason = null;
$logEntry = 'Выслано КП: '.self::KP_CHANNEL_TITLES[$data['kp_channel']].' '.$prospect->kp_target
.'. Следующий созвон '.$prospect->next_call_at->format('d.m.Y H:i');
$logEntry = 'Выслано КП: '.self::KP_CHANNEL_TITLES[$data['kp_channel']].' '.$prospect->kp_target;
if (! empty($data['next_call_at'])) {
$prospect->next_call_at = $data['next_call_at'];
$logEntry .= '. Следующий созвон '.$prospect->next_call_at->format('d.m.Y H:i');
}
break;
case 'trash':
// Карточка, с которой больше не работаем: мусор, дубль, не наш профиль.
// Платящего выбросить нельзя — тот же запрет, что у «Отказа».
$this->assertNotPaying($prospect);
$data = $request->validate([
'reason' => ['required', 'string', 'max:2000'],
]);
$prospect->stage = 'trash';
$prospect->reason = $data['reason'];
// Дату созвона стираем: иначе выброшенная карточка продолжала бы
// всплывать в фильтре «что надо сделать» и красным на доске.
$prospect->next_call_at = null;
$logEntry = 'В корзину: '.$data['reason'];
break;
case 'no_answer':
@@ -697,6 +777,8 @@ class SalesProspectController extends Controller
'id' => $p->id,
'sales_user_id' => $p->sales_user_id,
'stage' => $p->stage,
// Откуда карточка приехала — из него доска считает дробь «69/1» у «Отказа».
'prev_stage' => $p->prev_stage,
'source' => $p->source,
'firm_name' => $p->firm_name,
'legal_name' => $p->legal_name,
+52 -1
View File
@@ -34,6 +34,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string|null $rating_label
* @property array<string,mixed> $payload
* @property Carbon|null $next_call_at
* @property string|null $prev_stage
* @property string|null $reason
* @property string|null $registered_email
* @property string|null $test_channel
@@ -56,7 +57,7 @@ class SalesProspect extends Model
'sales_user_id', 'assigned_by', 'stage', 'source', 'firm_name', 'legal_name',
'contacts', 'city', 'phone', 'site', 'inn', 'rating_label', 'payload',
'next_call_at', 'reason', 'registered_email', 'linked_tenant_id', 'notes',
'test_channel', 'test_target', 'kp_channel', 'kp_target',
'test_channel', 'test_target', 'kp_channel', 'kp_target', 'prev_stage',
];
/** Дефолты in-memory (совпадают с DB DEFAULT), чтобы поля были доступны до refresh. */
@@ -82,4 +83,54 @@ class SalesProspect extends Model
{
return $this->belongsTo(SalesUser::class, 'sales_user_id');
}
/**
* Журнал движений пишется ЗДЕСЬ, а не в контроллере и джобе по отдельности.
*
* Стадию меняют два разных места: результат разговора менеджера
* (SalesProspectController) и автопереезд по деньгам (SalesProspectsAdvanceJob).
* Джоба раньше не оставляла следа вообще половина движений была невидима.
* Событие модели ловит обе стороны и любую будущую третью: забыть его нельзя,
* потому что стадия без save() не меняется.
*/
protected static function booted(): void
{
// prev_stage снимаем ДО записи: после save() getOriginal('stage') уже
// равен новой стадии — прежнюю в событии `updated` взять было бы неоткуда.
static::saving(function (self $prospect): void {
if ($prospect->exists && $prospect->isDirty('stage')) {
$prospect->prev_stage = (string) $prospect->getOriginal('stage');
}
});
// Заведение карточки — тоже движение: из ниоткуда в первую стадию.
static::created(fn (self $prospect) => $prospect->logMove(null));
// Именно `updated`, а не `saved`: у только что созданного объекта
// wasRecentlyCreated остаётся true до конца его жизни, и второе
// сохранение (даже без смены стадии) писало бы лишнее движение.
static::updated(function (self $prospect): void {
if ($prospect->wasChanged('stage')) {
$prospect->logMove($prospect->prev_stage);
}
});
}
/**
* Строка в журнале движений.
*
* Соединение берём у самой карточки: джоба живёт на pgsql_admin, портал
* на дефолтном. Жёстко зашитое соединение сломало бы одну из сторон.
*/
private function logMove(?string $fromStage): void
{
$this->getConnection()->table('sales_prospect_moves')->insert([
'prospect_id' => $this->id,
// Двигал не человек (джоба по деньгам аккаунта) → автора нет.
'sales_user_id' => auth('sales')->id(),
'from_stage' => $fromStage,
'to_stage' => $this->stage,
'created_at' => now(),
]);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Движение карточки по стадиям воронки (append-only).
*
* Пишется событием модели SalesProspect не контроллером и не джобой по
* отдельности. Так в журнал попадает ЛЮБАЯ смена стадии: и результат разговора
* менеджера, и автоматический переезд по деньгам (SalesProspectsAdvanceJob),
* который раньше не оставлял следа вообще.
*
* sales_user_id = null двигал не человек (джоба по деньгам аккаунта).
* from_stage = null самая первая запись, карточку только что завели.
*
* Спека: docs/superpowers/specs/2026-08-01-korzina-filtry-schetchik-design.md §4.2.
*
* @property int $id
* @property int $prospect_id
* @property int|null $sales_user_id
* @property string|null $from_stage
* @property string $to_stage
* @property Carbon $created_at
*/
class SalesProspectMove extends Model
{
/** Движение пишется один раз — updated_at не нужен. */
public const UPDATED_AT = null;
protected $fillable = ['prospect_id', 'sales_user_id', 'from_stage', 'to_stage'];
protected function casts(): array
{
return ['created_at' => 'datetime'];
}
/** @return BelongsTo<SalesProspect, $this> */
public function prospect(): BelongsTo
{
return $this->belongsTo(SalesProspect::class, 'prospect_id');
}
}
@@ -54,6 +54,15 @@ class AdAudienceScheduler
return $this->stopped('client_won');
}
// Корзина — карточку выбросили, греть некого. Стоп СРАЗУ, в отличие от
// «Отказа», который догревается ещё rejected_days: отказавший может
// передумать, а выброшенный мусор — нет. Ветка явная не для красоты:
// без неё стадия попала бы в 'unknown_stage' и реклама встала бы молча,
// по случайности, а не по решению.
if ($stage === 'trash') {
return $this->stopped('trashed');
}
// Три стадии с обязательной датой созвона живут по одному правилу: крутим
// до созвона, потом ещё negotiation_overdue_days, дальше стоп. Без этой
// строки новые стадии попали бы в 'unknown_stage' и реклама МОЛЧА встала бы.
@@ -12,6 +12,7 @@ use InvalidArgumentException;
*
* Поддерживаемые kind:
* 'today' сегодня.
* 'tomorrow' завтра (нужен фильтру воронки «что надо сделать»).
* 'yesterday' вчера.
* 'd7' последние 7 дней, включая сегодня.
* 'd30' последние 30 дней, включая сегодня.
@@ -40,6 +41,7 @@ final class SalesPeriodResolver
return match ($kind) {
'today' => $this->lastDays(1),
'tomorrow' => $this->dayRange(1),
'yesterday' => $this->dayRange(-1),
'd7' => $this->lastDays(7),
'd30' => $this->lastDays(30),
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Воронка «Потенциальные клиенты»: корзина и журнал движений.
*
* 1) stage += 'trash' («Корзина» карточка, с которой больше не работаем).
* 2) prev_stage откуда карточка приехала на текущую стадию. Кормит счётчик
* «69/1» в шапке «Отказа»: сколько отказов пришло из ручного тестирования.
* 3) sales_prospect_moves журнал ВСЕХ движений карточки по стадиям, включая
* автоматические (деньги testing/topped_up/user). Раньше автодвижения не
* оставляли следа вообще, и фильтр «что менялось за день» было не из чего
* построить. Пишется событием модели SalesProspect один шов на всех.
*
* Схема sales_* живёт только в миграциях (в db/schema.sql её нет, см. CHANGELOG v8.72).
* Спека: docs/superpowers/specs/2026-08-01-korzina-filtry-schetchik-design.md §4.
* План: docs/superpowers/plans/2026-08-01-korzina-filtry-schetchik.md Task 1.
*/
return new class extends Migration
{
public function up(): void
{
$db = DB::connection('pgsql_supplier');
// Порядок значений в CHECK = порядок колонок канбана (для читаемости; БД порядок не хранит).
$db->statement('ALTER TABLE sales_prospects DROP CONSTRAINT IF EXISTS sales_prospects_stage_check');
$db->statement(<<<'SQL'
ALTER TABLE sales_prospects ADD CONSTRAINT sales_prospects_stage_check
CHECK (stage IN ('new','in_work','negotiation','manual_testing','registered',
'testing','topped_up','user','kp_sent','rejected','no_answer','trash'))
SQL);
$db->statement('ALTER TABLE sales_prospects ADD COLUMN IF NOT EXISTS prev_stage VARCHAR(16) NULL');
$db->statement(<<<'SQL'
CREATE TABLE IF NOT EXISTS sales_prospect_moves (
id BIGSERIAL PRIMARY KEY,
prospect_id BIGINT NOT NULL REFERENCES sales_prospects(id) ON DELETE CASCADE,
sales_user_id BIGINT REFERENCES sales_users(id),
from_stage VARCHAR(16),
to_stage VARCHAR(16) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
SQL);
// История одной карточки — свежие сверху.
$db->statement(<<<'SQL'
CREATE INDEX IF NOT EXISTS idx_prospect_move_card
ON sales_prospect_moves (prospect_id, created_at DESC)
SQL);
// Фильтр «что менялось за период» — выборка по всему отделу за диапазон дат.
$db->statement(<<<'SQL'
CREATE INDEX IF NOT EXISTS idx_prospect_move_when
ON sales_prospect_moves (created_at)
SQL);
// 🔴 GRANTs обеим ролям admin-db (как в create_sales_prospect_notes_table).
// Без них на бою будет отказ прав, а на dev под superuser это невидимо.
$db->statement(<<<'SQL'
DO $$
DECLARE target TEXT;
BEGIN
FOREACH target IN ARRAY ARRAY['crm_admin_user','crm_supplier_worker'] LOOP
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = target) THEN
EXECUTE format('GRANT SELECT, INSERT ON sales_prospect_moves TO %I', target);
EXECUTE format('GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO %I', target);
END IF;
END LOOP;
END $$
SQL);
}
public function down(): void
{
$db = DB::connection('pgsql_supplier');
$db->statement('DROP TABLE IF EXISTS sales_prospect_moves CASCADE');
$db->statement('ALTER TABLE sales_prospects DROP COLUMN IF EXISTS prev_stage');
// Карточки из корзины — в «Отказ», иначе прежний CHECK не встанет.
$db->statement("UPDATE sales_prospects SET stage = 'rejected' WHERE stage = 'trash'");
$db->statement('ALTER TABLE sales_prospects DROP CONSTRAINT IF EXISTS sales_prospects_stage_check');
$db->statement(<<<'SQL'
ALTER TABLE sales_prospects ADD CONSTRAINT sales_prospects_stage_check
CHECK (stage IN ('new','in_work','negotiation','manual_testing','registered',
'testing','topped_up','user','kp_sent','rejected','no_answer'))
SQL);
}
};
+43 -31
View File
@@ -2682,6 +2682,36 @@ parameters:
count: 1
path: tests/Feature/Bot/ProcessChatMessageJobTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/ClientTg/AdHeadlineTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/ClientTg/AdHeadlineTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/ClientTg/AdHeadlineTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 7
path: tests/Feature/ClientTg/AdHeadlineTest.php
-
message: '#^Offset ''adHeadline'' does not exist on string\.$#'
identifier: offsetAccess.notFound
count: 1
path: tests/Feature/ClientTg/AdHeadlineTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
@@ -5019,7 +5049,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 90
count: 95
path: tests/Feature/Sales/SalesProspectApiTest.php
-
@@ -5034,6 +5064,18 @@ parameters:
count: 1
path: tests/Feature/Sales/SalesProspectApiTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 9
path: tests/Feature/Sales/SalesProspectDateFilterTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Sales/SalesProspectMoveTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
@@ -5795,33 +5837,3 @@ parameters:
identifier: argument.type
count: 1
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/ClientTg/AdHeadlineTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/ClientTg/AdHeadlineTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/ClientTg/AdHeadlineTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 7
path: tests/Feature/ClientTg/AdHeadlineTest.php
-
message: '#^Offset ''adHeadline'' does not exist on string\.$#'
identifier: offsetAccess.notFound
count: 1
path: tests/Feature/ClientTg/AdHeadlineTest.php
+31
View File
@@ -1251,6 +1251,8 @@ export interface SalesProspect {
id: number;
sales_user_id: number;
stage: string;
/** Откуда карточка приехала на текущую стадию — из него считается дробь «69/1». */
prev_stage: string | null;
source: ProspectSource;
/** Бренд/вывеска — его показываем на карточке канбана. */
firm_name: string;
@@ -1298,15 +1300,43 @@ export interface ProspectsResponse {
manager_counts?: Record<string, number>;
}
/**
* Фильтр доски по датам:
* todo — что надо сделать в этот срок (плюс всё просроченное);
* changed — что менялось в этот срок (любое движение карточки).
*/
export type ProspectDateMode = 'todo' | 'changed';
/** Вид периода — те же, что понимает SalesPeriodResolver на сервере. */
export type ProspectPeriodKind = 'today' | 'tomorrow' | 'yesterday' | 'd7' | 'd30' | 'custom';
export interface ProspectDateFilterValue {
mode: ProspectDateMode | null;
period: ProspectPeriodKind;
from?: string | null;
to?: string | null;
}
export async function listProspects(
managerId?: number,
source?: ProspectSource,
scope?: 'department',
dateFilter?: ProspectDateFilterValue | null,
): Promise<ProspectsResponse> {
const params: Record<string, unknown> = {};
if (scope) params.scope = scope;
if (managerId) params.manager_id = managerId;
if (source) params.source = source;
if (dateFilter?.mode) {
params.date_mode = dateFilter.mode;
params.period = dateFilter.period;
// Произвольный период уходит только парой дат — сервер на половинчатом
// ответит 422, и доска дёрнулась бы ошибкой на полпути выбора.
if (dateFilter.period === 'custom' && dateFilter.from && dateFilter.to) {
params.from = dateFilter.from;
params.to = dateFilter.to;
}
}
const { data } = await axios.get<ProspectsResponse>('/api/sales/prospects', {
headers: authHeaders(),
params,
@@ -1326,6 +1356,7 @@ export interface ProspectResultPayload {
| 'registered'
| 'manual_testing'
| 'kp_sent'
| 'trash'
| 'opened'
| 'back_to_new';
next_call_at?: string;
@@ -0,0 +1,186 @@
<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>
<!--
Порядок по требованию владельца (01.08.2026): САМ ФИЛЬТР крайний справа,
срок левее него. Читается справа налево: «что менялось» «вчера».
-->
<div class="d-flex align-center ga-3" role="group" aria-label="Фильтр по датам">
<template v-if="mode">
<span v-if="mode === 'todo'" class="text-caption text-medium-emphasis text-no-wrap">
плюс всё просроченное
</span>
<!-- Календарь для произвольного периода: отмечаются начало и конец. -->
<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>
<!-- Срок ЛЕВЕЕ самого фильтра. -->
<v-select
v-model="period"
:items="periodItems"
item-title="title"
item-value="value"
label="Период"
density="compact"
variant="outlined"
style="width: 200px; max-width: 200px"
hide-details
data-testid="prospect-period"
/>
</template>
<!-- Сам фильтр КРАЙНИЙ СПРАВА. -->
<v-select
v-model="mode"
:items="modeItems"
item-title="title"
item-value="value"
label="Фильтр по датам"
density="compact"
variant="outlined"
style="width: 220px; max-width: 220px"
hide-details
data-testid="prospect-date-mode"
/>
</div>
</template>
<style scoped>
.prospect-range-btn {
text-transform: none;
letter-spacing: normal;
}
</style>
@@ -56,7 +56,13 @@ const columns = computed(() =>
if (stage.key === 'negotiation') {
cards.sort((a, b) => callTime(a) - callTime(b));
}
return { ...stage, cards };
// «Отказ» показывает второе число через дробь: сколько отказов пришло
// ПРЯМО из ручного тестирования — «69/1». Ноль не рисуем: «69/0» — шум.
const fromTesting =
stage.key === 'rejected'
? cards.filter((c) => c.prev_stage === 'manual_testing').length
: 0;
return { ...stage, cards, subCount: fromTesting > 0 ? fromTesting : null };
}),
);
</script>
@@ -72,7 +78,12 @@ const columns = computed(() =>
<section v-for="col in columns" :key="col.key" class="prospect-column">
<header class="prospect-column-head" :style="{ borderColor: col.colorHex }">
<span>{{ col.title }}</span>
<span class="prospect-column-count">{{ col.cards.length }}</span>
<span
class="prospect-column-count"
:title="col.subCount ? `из них ${col.subCount} после ручного тестирования` : undefined"
>
{{ col.cards.length }}<template v-if="col.subCount">/{{ col.subCount }}</template>
</span>
</header>
<div class="prospect-column-body">
<article
@@ -23,7 +23,9 @@ const emit = defineEmits<{
save: [payload: ProspectResultPayload];
}>();
const action = ref<'' | 'negotiation' | 'no_answer' | 'rejected' | 'registered' | 'manual_testing' | 'kp_sent'>('');
const action = ref<
'' | 'negotiation' | 'no_answer' | 'rejected' | 'registered' | 'manual_testing' | 'kp_sent' | 'trash'
>('');
const nextCallAt = ref('');
const reason = ref('');
/** Краткое содержание разговора — уходит вместе с результатом и ложится в журнал (§20). */
@@ -232,14 +234,20 @@ const availableActions = computed(() => {
base.push({ value: 'kp_sent', title: 'Выслано КП' });
}
// «Зарегистрировался» — пока клиент ещё не привязан (до денег): new/переговоры/недозвон/отказ
// плюс обе новые ручные стадии, иначе из них не выбраться дальше по воронке.
if (['new', 'negotiation', 'manual_testing', 'kp_sent', 'no_answer', 'rejected'].includes(props.prospect.stage)) {
// плюс обе новые ручные стадии и корзина, иначе из них не выбраться дальше по воронке
// (карточку могли выбросить по ошибке — путь назад должен остаться).
if (['new', 'negotiation', 'manual_testing', 'kp_sent', 'no_answer', 'rejected', 'trash']
.includes(props.prospect.stage)) {
base.push({ value: 'registered', title: 'Зарегистрировался' });
}
// Отказ — на любой стадии, кроме user.
if (props.prospect.stage !== 'user') {
base.push({ value: 'rejected', title: 'Отказ' });
}
// Корзина — как и отказ, но платящего клиента выбросить нельзя.
if (! ['topped_up', 'user'].includes(props.prospect.stage)) {
base.push({ value: 'trash', title: 'В корзину' });
}
return base;
});
@@ -285,6 +293,14 @@ async function submit(): Promise<void> {
return;
}
emit('save', { action: 'rejected', reason: reason.value, summary: summary.value });
} else if (action.value === 'trash') {
if (!reason.value.trim()) {
error.value = 'Укажите причину.';
return;
}
// Дату созвона намеренно не шлём, даже если менеджер её набрал до
// переключения результата: у выброшенной карточки её быть не должно.
emit('save', { action: 'trash', reason: reason.value, summary: summary.value });
} else if (action.value === 'registered') {
if (!email.value.trim()) {
error.value = 'Укажите e-mail аккаунта клиента.';
@@ -320,15 +336,13 @@ async function submit(): Promise<void> {
error.value = 'Укажите адрес или номер, куда ушло КП.';
return;
}
if (!nextCallAt.value) {
error.value = 'Укажите дату и время следующего созвона.';
return;
}
// Дата созвона НЕобязательна: бывает «пришлите, созвонимся через пару
// дней», а бывает «пришлите на почту, если интересно — перезвоню».
emit('save', {
action: 'kp_sent',
kp_channel: kpChannel.value,
kp_target: kpTarget.value.trim(),
next_call_at: nextCallAt.value,
...(nextCallAt.value ? { next_call_at: nextCallAt.value } : {}),
summary: summary.value,
});
} else {
@@ -485,15 +499,18 @@ defineExpose({
v-if="action === 'negotiation' || action === 'manual_testing' || action === 'kp_sent'"
v-model="nextCallAt"
type="datetime-local"
label="Следующий созвон"
:label="action === 'kp_sent' ? 'Следующий созвон (если договорились)' : 'Следующий созвон'"
:hint="action === 'kp_sent' ? 'можно оставить пустым — клиент перезвонит сам' : undefined"
:persistent-hint="action === 'kp_sent'"
density="comfortable"
variant="outlined"
hide-details="auto"
/>
<v-textarea
v-if="action === 'no_answer' || action === 'rejected'"
v-if="action === 'no_answer' || action === 'rejected' || action === 'trash'"
v-model="reason"
label="Причина"
:placeholder="action === 'trash' ? 'не наш профиль / дубль / фирма закрылась' : undefined"
rows="2"
density="comfortable"
variant="outlined"
+3
View File
@@ -25,6 +25,9 @@ export const PROSPECT_STAGES: StageMeta[] = [
{ key: 'kp_sent', title: 'Выслано КП', colorHex: '#B45309', manual: true },
{ key: 'rejected', title: 'Отказ', colorHex: '#B91C1C', manual: true },
{ key: 'no_answer', title: 'Не смогли дозвониться', colorHex: '#6B7280', manual: true },
// Карточка, с которой больше не работаем: мусор, дубль, не наш профиль.
// Последняя колонка — дальше воронки нет.
{ key: 'trash', title: 'Корзина', colorHex: '#57534E', manual: true },
];
const BY_KEY: Record<string, StageMeta> = Object.fromEntries(PROSPECT_STAGES.map((s) => [s.key, s]));
@@ -9,10 +9,12 @@ import {
listProspects,
listSalesManagers,
updateProspect,
type ProspectDateFilterValue,
type ProspectResultPayload,
type SalesManagerRow,
type SalesProspect,
} from '../../api/sales';
import ProspectDateFilter from '../../components/sales/ProspectDateFilter.vue';
import SalesProspectBoard from '../../components/sales/SalesProspectBoard.vue';
import SalesProspectDialog from '../../components/sales/SalesProspectDialog.vue';
@@ -22,6 +24,8 @@ const managerCounts = ref<Record<string, number>>({});
const managerId = ref<number | null>(null);
/** Фильтр происхождения: все / отдал начальник из поиска / инициатива менеджера. */
const source = ref<'search' | 'manager' | null>(null);
/** Фильтр по датам: «что надо сделать» / «что менялось». Пусто — вся доска. */
const dateFilter = ref<ProspectDateFilterValue | null>(null);
const sourceItems = [
{ value: null, title: 'Все карточки' },
{ value: 'search', title: 'От начальника (из поиска)' },
@@ -49,7 +53,12 @@ async function load(): Promise<void> {
error.value = '';
try {
// scope=department — это экран НАЧАЛЬНИКА: весь отдел, а не личные карточки.
const res = await listProspects(managerId.value ?? undefined, source.value ?? undefined, 'department');
const res = await listProspects(
managerId.value ?? undefined,
source.value ?? undefined,
'department',
dateFilter.value,
);
prospects.value = res.prospects;
if (res.manager_counts) managerCounts.value = res.manager_counts;
} catch (e) {
@@ -84,12 +93,22 @@ 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>
<v-container fluid class="pa-6">
<div class="d-flex align-center mb-4">
<!--
Все фильтры одним рядом справа (требование владельца 01.08.2026).
flex-wrap: на узком экране ряд переносится, а не уезжает за край.
-->
<div class="d-flex align-center flex-wrap ga-3 mb-4">
<h1 class="text-h5">Воронка отдела</h1>
<v-spacer />
<v-select
@@ -100,7 +119,7 @@ defineExpose({ managerId, source, load, managerItems });
label="Менеджер"
density="compact"
variant="outlined"
style="max-width: 260px"
style="width: 240px; max-width: 240px"
hide-details
@update:model-value="load"
/>
@@ -112,11 +131,11 @@ defineExpose({ managerId, source, load, managerItems });
label="Происхождение"
density="compact"
variant="outlined"
style="max-width: 260px"
class="ml-3"
style="width: 240px; max-width: 240px"
hide-details
@update:model-value="load"
/>
<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" />
@@ -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,16 +83,24 @@ 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>
<v-container fluid class="pa-6">
<div class="d-flex align-center mb-4">
<!-- Фильтр справа, как на «Воронке отдела» экраны не должны разъезжаться. -->
<div class="d-flex align-center flex-wrap ga-3 mb-4">
<h1 class="text-h5">Потенциальные клиенты</h1>
<v-spacer />
<ProspectDateFilter @change="applyDateFilter" />
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus" @click="createDialog = true">
Добавить кандидата
</v-btn>
@@ -109,9 +109,9 @@ test('by_stage содержит обе новые колонки — иначе
$res->assertOk();
expect($res->json('stages'))->toBe([
'new', 'in_work', 'negotiation', 'manual_testing', 'registered', 'testing',
'topped_up', 'user', 'kp_sent', 'rejected', 'no_answer',
'topped_up', 'user', 'kp_sent', 'rejected', 'no_answer', 'trash',
]);
expect($res->json('by_stage'))->toHaveKeys(['manual_testing', 'kp_sent']);
expect($res->json('by_stage'))->toHaveKeys(['manual_testing', 'kp_sent', 'trash']);
});
test('менеджер не может через manager_id увидеть чужие', function () {
@@ -293,7 +293,7 @@ test('выслано КП: телеграм-ник сохраняется как
->assertJsonPath('prospect.kp_target', '@primer_director');
});
test('выслано КП: без канала, без адреса, без даты созвона и с чужим каналом → 422', function () {
test('выслано КП: без канала, без адреса и с чужим каналом → 422', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$body = [
@@ -303,7 +303,9 @@ test('выслано КП: без канала, без адреса, без да
'next_call_at' => '2026-08-05T12:00:00',
];
foreach (['kp_channel', 'kp_target', 'next_call_at'] as $missing) {
// next_call_at здесь НЕ проверяется: с 01.08.2026 дата созвона у КП необязательна
// (клиент говорит «пришлите на почту, если интересно — перезвоню»).
foreach (['kp_channel', 'kp_target'] as $missing) {
$without = $body;
unset($without[$missing]);
$this->actingAs($mgr, 'sales')
@@ -318,6 +320,25 @@ test('выслано КП: без канала, без адреса, без да
expect(SalesProspect::find($p->id)->stage)->toBe('negotiation');
});
test('выслано КП: дата созвона необязательна — «пришлите, если интересно перезвоню»', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'kp_sent',
'kp_channel' => 'email',
'kp_target' => 'director@primer-firma.ru',
])
->assertOk()
->assertJsonPath('prospect.stage', 'kp_sent');
// Даты нет — и в журнале нет хвоста про созвон, иначе запись врала бы.
$note = SalesProspectNote::where('prospect_id', $p->id)->first();
expect($note->body)->toContain('Выслано КП: почта director@primer-firma.ru');
expect($note->body)->not->toContain('Следующий созвон');
});
test('выслано КП: «другое» тоже требует, куда именно отправили', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'negotiation']);
@@ -352,6 +373,64 @@ test('выслано КП: в журнале одна запись с челов
expect($notes[0]->body)->toBe('просил КП с ценами на 500 лидов');
});
// ── корзина ───────────────────────────────────────────────────────────────────
test('в корзину: стадия, причина и запись в журнале', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'trash',
'reason' => 'не наш профиль, торгуют оптом стройматериалами',
])
->assertOk()
->assertJsonPath('prospect.stage', 'trash')
->assertJsonPath('prospect.reason', 'не наш профиль, торгуют оптом стройматериалами');
$note = SalesProspectNote::where('prospect_id', $p->id)->first();
expect($note->body)->toContain('В корзину: не наш профиль');
});
test('в корзину: дата созвона стирается — карточка не всплывает в «что надо сделать»', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create([
'stage' => 'negotiation',
'next_call_at' => '2026-08-05T12:00:00',
]);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'trash', 'reason' => 'дубль'])
->assertOk();
expect(SalesProspect::find($p->id)->next_call_at)->toBeNull();
});
test('в корзину без причины → 422', function () {
$mgr = pr_user('manager');
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'trash'])
->assertStatus(422);
expect(SalesProspect::find($p->id)->stage)->toBe('in_work');
});
test('платящего клиента в корзину не выбросить', function () {
$mgr = pr_user('manager');
foreach (['topped_up', 'user'] as $paying) {
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => $paying]);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'trash', 'reason' => 'надоел'])
->assertStatus(422);
expect(SalesProspect::find($p->id)->stage)->toBe($paying);
}
});
test('платящему клиенту оба новых результата запрещены — стадия не откатывается', function () {
$mgr = pr_user('manager');
@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
use App\Models\SalesProspect;
use App\Models\SalesProspectNote;
use App\Models\SalesUser;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Testing\TestResponse;
uses(DatabaseTransactions::class);
/**
* Время заморожено на 15.06.2026 12:00 МСК (09:00 UTC): «сегодня» 15 июня,
* «завтра» 16-е, «вчера» 14-е. Приложение живёт в UTC, менеджер думает в МСК
* поэтому даты в ожиданиях считаем от МСК-дня, а не от UTC.
*/
beforeEach(function () {
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-06-15 09:00:00', 'UTC'));
});
afterEach(function () {
CarbonImmutable::setTestNow();
});
function df_user(string $role = 'manager'): SalesUser
{
return SalesUser::create([
'name' => 'DF '.uniqid(), 'email' => 'df'.uniqid().'@s.local',
'password' => bcrypt('secret'), 'role' => $role, 'is_active' => true,
]);
}
function df_card(SalesUser $mgr, string $name, string $stage, ?string $callAt = null): SalesProspect
{
return SalesProspect::factory()->for($mgr, 'salesUser')->create([
'firm_name' => $name,
'stage' => $stage,
'next_call_at' => $callAt,
]);
}
/** @return list<string> названия фирм в ответе */
function df_names(TestResponse $res): array
{
return collect($res->json('prospects'))->pluck('firm_name')->sort()->values()->all();
}
// ── режим «что надо сделать» ──────────────────────────────────────────────────
test('что надо сделать на сегодня: сегодняшний созвон и просроченный, но не завтрашний', function () {
$mgr = df_user();
df_card($mgr, 'СЕГОДНЯ', 'negotiation', '2026-06-15T14:00:00');
df_card($mgr, 'ПРОСРОЧЕН', 'negotiation', '2026-06-10T14:00:00');
df_card($mgr, 'ЗАВТРА', 'negotiation', '2026-06-16T14:00:00');
df_card($mgr, 'БЕЗ ДАТЫ', 'in_work');
$res = $this->actingAs($mgr, 'sales')
->getJson('/api/sales/prospects?date_mode=todo&period=today');
$res->assertOk();
expect(df_names($res))->toBe(['ПРОСРОЧЕН', 'СЕГОДНЯ']);
});
test('что надо сделать на завтра: завтрашний созвон И просроченное — забытое важнее', function () {
$mgr = df_user();
df_card($mgr, 'ЗАВТРА', 'negotiation', '2026-06-16T14:00:00');
df_card($mgr, 'ПРОСРОЧЕН', 'negotiation', '2026-06-10T14:00:00');
df_card($mgr, 'СЕГОДНЯ', 'negotiation', '2026-06-15T14:00:00');
$res = $this->actingAs($mgr, 'sales')
->getJson('/api/sales/prospects?date_mode=todo&period=tomorrow');
$res->assertOk();
expect(df_names($res))->toBe(['ЗАВТРА', 'ПРОСРОЧЕН']);
});
test('что надо сделать: отказ и корзина не показываются даже с просроченной датой', function () {
$mgr = df_user();
// Дату ставим в обход результата разговора — важно проверить сам фильтр,
// а не то, что «В корзину» её стирает.
df_card($mgr, 'ОТКАЗ', 'rejected', '2026-06-10T14:00:00');
df_card($mgr, 'КОРЗИНА', 'trash', '2026-06-10T14:00:00');
df_card($mgr, 'ЖИВАЯ', 'negotiation', '2026-06-10T14:00:00');
$res = $this->actingAs($mgr, 'sales')
->getJson('/api/sales/prospects?date_mode=todo&period=today');
$res->assertOk();
expect(df_names($res))->toBe(['ЖИВАЯ']);
});
test('что надо сделать за произвольный период', function () {
$mgr = df_user();
df_card($mgr, 'В ПЕРИОДЕ', 'negotiation', '2026-06-20T14:00:00');
df_card($mgr, 'ЗА ПЕРИОДОМ', 'negotiation', '2026-06-25T14:00:00');
$res = $this->actingAs($mgr, 'sales')
->getJson('/api/sales/prospects?date_mode=todo&period=custom&from=2026-06-18&to=2026-06-21');
$res->assertOk();
expect(df_names($res))->toBe(['В ПЕРИОДЕ']);
});
// ── режим «что менялось» ──────────────────────────────────────────────────────
test('что менялось сегодня: движение стадии, запись разговора — да; вчерашнее — нет', function () {
$mgr = df_user();
$moved = df_card($mgr, 'ПОДВИНУЛ', 'in_work');
$talked = df_card($mgr, 'ЗАПИСАЛ РАЗГОВОР', 'in_work');
$old = df_card($mgr, 'ВЧЕРАШНЯЯ', 'in_work');
// Все три карточки только что заведены → у всех есть движение «сегодня».
// Обнуляем журнал и расставляем события руками, иначе проверять нечего.
DB::table('sales_prospect_moves')->whereIn('prospect_id', [$moved->id, $talked->id, $old->id])->delete();
DB::table('sales_prospect_moves')->insert([
'prospect_id' => $moved->id, 'sales_user_id' => $mgr->id,
'from_stage' => 'new', 'to_stage' => 'in_work',
'created_at' => '2026-06-15 08:00:00',
]);
SalesProspectNote::create([
'prospect_id' => $talked->id, 'sales_user_id' => $mgr->id,
'kind' => 'note', 'body' => 'поговорили',
]);
DB::table('sales_prospect_moves')->insert([
'prospect_id' => $old->id, 'sales_user_id' => $mgr->id,
'from_stage' => 'new', 'to_stage' => 'in_work',
'created_at' => '2026-06-14 08:00:00',
]);
$res = $this->actingAs($mgr, 'sales')
->getJson('/api/sales/prospects?date_mode=changed&period=today');
$res->assertOk();
expect(df_names($res))->toBe(['ЗАПИСАЛ РАЗГОВОР', 'ПОДВИНУЛ']);
});
test('что менялось: автопереезд по деньгам тоже считается движением', function () {
$mgr = df_user();
$auto = df_card($mgr, 'САМ УЕХАЛ', 'registered');
DB::table('sales_prospect_moves')->where('prospect_id', $auto->id)->delete();
// Автор пустой — двигала джоба, а не человек.
DB::table('sales_prospect_moves')->insert([
'prospect_id' => $auto->id, 'sales_user_id' => null,
'from_stage' => 'registered', 'to_stage' => 'topped_up',
'created_at' => '2026-06-15 03:00:00',
]);
$res = $this->actingAs($mgr, 'sales')
->getJson('/api/sales/prospects?date_mode=changed&period=today');
$res->assertOk();
expect(df_names($res))->toBe(['САМ УЕХАЛ']);
});
// ── фильтр не ломает обычную доску ────────────────────────────────────────────
test('без date_mode доска отдаёт всё, как раньше', function () {
$mgr = df_user();
df_card($mgr, 'ОДНА', 'negotiation', '2026-01-01T14:00:00');
df_card($mgr, 'ДВЕ', 'in_work');
$res = $this->actingAs($mgr, 'sales')->getJson('/api/sales/prospects');
$res->assertOk();
expect(df_names($res))->toBe(['ДВЕ', 'ОДНА']);
});
test('фильтр работает и на экране начальника', function () {
$head = df_user('head');
$m1 = df_user('manager');
df_card($m1, 'ЕГО СЕГОДНЯ', 'negotiation', '2026-06-15T14:00:00');
df_card($m1, 'ЕГО ЗАВТРА', 'negotiation', '2026-06-16T14:00:00');
$res = $this->actingAs($head, 'sales')
->getJson('/api/sales/prospects?scope=department&date_mode=todo&period=today');
$res->assertOk();
expect(df_names($res))->toBe(['ЕГО СЕГОДНЯ']);
});
test('карточка отдаёт prev_stage — из него считается дробь у «Отказа»', function () {
$mgr = df_user();
$p = df_card($mgr, 'ОТКАЗНИК', 'manual_testing', '2026-06-15T14:00:00');
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", ['action' => 'rejected', 'reason' => 'дорого'])
->assertOk()
->assertJsonPath('prospect.prev_stage', 'manual_testing');
});
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
use App\Jobs\Sales\SalesProspectsAdvanceJob;
use App\Models\SalesProspect;
use App\Models\SalesProspectMove;
use App\Models\SalesUser;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
function mv_user(string $role = 'manager'): SalesUser
{
return SalesUser::create([
'name' => 'MV '.uniqid(), 'email' => 'mv'.uniqid().'@s.local',
'password' => bcrypt('secret'), 'role' => $role, 'is_active' => true,
]);
}
/** @return list<array{from: ?string, to: string, by: ?int}> */
function mv_moves(SalesProspect $p): array
{
return SalesProspectMove::query()
->where('prospect_id', $p->id)
->orderBy('id')
->get()
->map(fn (SalesProspectMove $m) => [
'from' => $m->from_stage,
'to' => $m->to_stage,
'by' => $m->sales_user_id,
])
->all();
}
// ── событие модели: журнал движений ───────────────────────────────────────────
test('заведение карточки пишет первое движение — из ниоткуда в стадию', function () {
$mgr = mv_user();
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'new']);
expect(mv_moves($p))->toBe([['from' => null, 'to' => 'new', 'by' => null]]);
});
test('смена стадии пишет движение и заполняет prev_stage', function () {
$mgr = mv_user();
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'new']);
$p->stage = 'in_work';
$p->save();
expect($p->prev_stage)->toBe('new');
expect(mv_moves($p))->toBe([
['from' => null, 'to' => 'new', 'by' => null],
['from' => 'new', 'to' => 'in_work', 'by' => null],
]);
});
test('сохранение БЕЗ смены стадии движения не пишет', function () {
$mgr = mv_user();
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'new']);
$p->city = 'Томск';
$p->save();
expect(mv_moves($p))->toHaveCount(1);
});
test('несколько переездов подряд ложатся цепочкой, prev_stage — только последний', function () {
$mgr = mv_user();
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'new']);
foreach (['in_work', 'negotiation', 'manual_testing'] as $stage) {
$p->stage = $stage;
$p->save();
}
expect($p->prev_stage)->toBe('negotiation');
expect(array_column(mv_moves($p), 'to'))->toBe(['new', 'in_work', 'negotiation', 'manual_testing']);
});
// ── движения пишутся ОТОВСЮДУ: и от менеджера, и от джобы ─────────────────────
test('результат разговора через API пишет движение с автором-менеджером', function () {
$mgr = mv_user();
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create(['stage' => 'in_work']);
$this->actingAs($mgr, 'sales')
->patchJson("/api/sales/prospects/{$p->id}", [
'action' => 'negotiation',
'next_call_at' => '2026-08-05T12:00:00',
])
->assertOk();
$moves = mv_moves($p);
expect(end($moves))->toBe(['from' => 'in_work', 'to' => 'negotiation', 'by' => $mgr->id]);
});
test('автопереезд по деньгам пишет движение без автора', function () {
$mgr = mv_user();
$tenant = Tenant::factory()->create();
$p = SalesProspect::factory()->for($mgr, 'salesUser')->create([
'stage' => 'registered',
'linked_tenant_id' => $tenant->id,
]);
DB::table('balance_transactions')->insert([
'tenant_id' => $tenant->id,
'type' => 'topup',
'amount_rub' => 5000,
'description' => 'пополнение',
'created_at' => now(),
]);
(new SalesProspectsAdvanceJob)->handle();
$moves = mv_moves($p);
expect(end($moves))->toBe(['from' => 'registered', 'to' => 'topped_up', 'by' => null]);
});
@@ -0,0 +1,138 @@
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 = 'todo';
await w.vm.$nextTick();
const html = w.html();
const iPeriod = html.indexOf('prospect-period');
const iMode = html.indexOf('prospect-date-mode');
expect(iPeriod).toBeGreaterThan(-1);
expect(iMode).toBeGreaterThan(-1);
// В строке ряда левее = раньше в разметке.
expect(iPeriod).toBeLessThan(iMode);
});
it('смена периода сообщает наружу новый период', async () => {
const { w, vm } = factory();
vm.mode = 'changed';
await w.vm.$nextTick();
vm.period = 'd7';
await w.vm.$nextTick();
expect(w.emitted('change')?.at(-1)?.[0]).toMatchObject({ mode: 'changed', period: 'd7' });
});
it('произвольный период: с одной датой наружу не уходит, с обеими — уходит', async () => {
const { w, vm } = factory();
vm.mode = 'todo';
await w.vm.$nextTick();
vm.period = 'custom';
await w.vm.$nextTick();
const before = w.emitted('change')?.length ?? 0;
vm.customRange = [new Date('2026-06-18T00:00:00')];
vm.applyCustom();
await w.vm.$nextTick();
expect(w.emitted('change')?.length ?? 0).toBe(before);
vm.customRange = [new Date('2026-06-18T00:00:00'), new Date('2026-06-21T00:00:00')];
vm.applyCustom();
await w.vm.$nextTick();
expect(w.emitted('change')?.at(-1)?.[0]).toEqual({
mode: 'todo',
period: 'custom',
from: '2026-06-18',
to: '2026-06-21',
});
});
it('смена режима возвращает период к «Сегодня» — «что менялось за завтра» бессмысленно', async () => {
const { w, vm } = factory();
vm.mode = 'todo';
await w.vm.$nextTick();
vm.period = 'tomorrow';
await w.vm.$nextTick();
vm.mode = 'changed';
await w.vm.$nextTick();
expect(vm.period).toBe('today');
// И наружу ушло ровно одно сообщение с новым режимом, а не два подряд.
expect(w.emitted('change')?.at(-1)?.[0]).toEqual({
mode: 'changed',
period: 'today',
from: null,
to: null,
});
});
it('возврат в «Все карточки» гасит фильтр и прячет период', async () => {
const { w, vm } = factory();
vm.mode = 'todo';
await w.vm.$nextTick();
vm.mode = null;
await w.vm.$nextTick();
expect(w.find('[data-testid="prospect-period"]').exists()).toBe(false);
expect(w.emitted('change')?.at(-1)?.[0]).toMatchObject({ mode: null });
});
});
+40 -2
View File
@@ -11,6 +11,7 @@ function p(id: number, stage: string, overrides: Partial<SalesProspect> = {}): S
id,
sales_user_id: 1,
stage,
prev_stage: null,
firm_name: `Фирма ${id}`,
city: 'Ростов',
phone: null,
@@ -34,19 +35,56 @@ function p(id: number, stage: string, overrides: Partial<SalesProspect> = {}): S
}
describe('SalesProspectBoard', () => {
it('рисует 11 колонок и раскладывает карточки по стадиям', () => {
it('рисует 12 колонок и раскладывает карточки по стадиям', () => {
const w = mount(SalesProspectBoard, {
props: { prospects: [p(1, 'new'), p(2, 'negotiation'), p(3, 'new')] },
global: { plugins: [vuetify] },
});
expect(w.findAll('.prospect-column').length).toBe(11);
expect(w.findAll('.prospect-column').length).toBe(12);
expect(w.text()).toContain('Новые');
expect(w.text()).toContain('Взят в работу');
expect(w.text()).toContain('Тестирование ручное');
expect(w.text()).toContain('Выслано КП');
expect(w.text()).toContain('Корзина');
expect(w.text()).toContain('Фирма 1');
});
/** Заголовок колонки по её названию — счётчик живёт рядом с ним. */
function head(w: ReturnType<typeof mount>, title: string): string {
const col = w.findAll('.prospect-column-head').find((h) => h.text().includes(title));
return col?.text() ?? '';
}
it('«Отказ»: через дробь сколько отказов пришло из ручного тестирования', () => {
const w = mount(SalesProspectBoard, {
props: {
prospects: [
p(1, 'rejected', { prev_stage: 'manual_testing' }),
p(2, 'rejected', { prev_stage: 'negotiation' }),
p(3, 'rejected', { prev_stage: null }),
],
},
global: { plugins: [vuetify] },
});
expect(head(w, 'Отказ')).toContain('3/1');
});
it('«Отказ» без таких карточек — просто число, без дроби', () => {
const w = mount(SalesProspectBoard, {
props: { prospects: [p(1, 'rejected', { prev_stage: 'negotiation' })] },
global: { plugins: [vuetify] },
});
expect(head(w, 'Отказ')).not.toContain('/');
});
it('дробь — только у «Отказа»: в других колонках приезд из ручного тестирования её не рисует', () => {
const w = mount(SalesProspectBoard, {
props: { prospects: [p(1, 'no_answer', { prev_stage: 'manual_testing' })] },
global: { plugins: [vuetify] },
});
expect(head(w, 'Не смогли дозвониться')).not.toContain('/');
});
it('бейдж происхождения: «своя» у менеджера, «от начальника» из поиска', () => {
const w = mount(SalesProspectBoard, {
props: {
+63 -2
View File
@@ -18,6 +18,7 @@ function prospect(overrides: Partial<SalesProspect> = {}): SalesProspect {
id: 1,
sales_user_id: 1,
stage: 'new',
prev_stage: null,
firm_name: 'ООО Тест',
city: 'Ростов',
phone: '+7800',
@@ -476,7 +477,7 @@ describe('SalesProspectDialog', () => {
});
});
it('выслано КП: без канала, без адреса и без даты — save не эмитится', async () => {
it('выслано КП: без канала и без адреса — save не эмитится', async () => {
const w = factory(prospect({ stage: 'negotiation' }));
const vm = w.vm as unknown as DialogVm;
@@ -487,10 +488,25 @@ describe('SalesProspectDialog', () => {
vm.kpChannel = 'email';
await vm.submit();
expect(w.emitted('save')).toBeFalsy();
});
it('выслано КП: без даты созвона всё равно сохраняется — дата необязательна', async () => {
const w = factory(prospect({ stage: 'negotiation' }));
const vm = w.vm as unknown as DialogVm;
vm.action = 'kp_sent';
vm.kpChannel = 'email';
vm.kpTarget = 'director@primer-firma.ru';
await vm.submit();
expect(w.emitted('save')).toBeFalsy();
await flushPromises();
// Поля next_call_at в письме на сервер нет вовсе — не пустая строка.
expect(w.emitted('save')?.[0][0]).toEqual({
action: 'kp_sent',
kp_channel: 'email',
kp_target: 'director@primer-firma.ru',
summary: '',
});
});
it('выслано КП: с каналом, адресом и датой эмитит save с нужными полями', async () => {
@@ -512,6 +528,51 @@ describe('SalesProspectDialog', () => {
summary: '',
});
});
it('корзина: результат есть в списке на обычной стадии, платящему — нет', () => {
const onWork = (factory(prospect({ stage: 'negotiation' })).vm as unknown as DialogVm)
.availableActions.map((a) => a.value);
expect(onWork).toContain('trash');
for (const stage of ['topped_up', 'user']) {
const values = (factory(prospect({ stage })).vm as unknown as DialogVm)
.availableActions.map((a) => a.value);
expect(values).not.toContain('trash');
}
});
it('корзина: без причины save не эмитится', async () => {
const w = factory(prospect({ stage: 'negotiation' }));
const vm = w.vm as unknown as DialogVm;
vm.action = 'trash';
await vm.submit();
expect(w.emitted('save')).toBeFalsy();
});
it('корзина: с причиной эмитит save без даты созвона', async () => {
const w = factory(prospect({ stage: 'negotiation' }));
const vm = w.vm as unknown as DialogVm;
vm.action = 'trash';
vm.reason = 'не наш профиль';
// Дату могли набрать до переключения результата — в корзину она уйти не должна.
vm.nextCallAt = '2026-08-05T12:00';
await vm.submit();
await flushPromises();
expect(w.emitted('save')?.[0][0]).toEqual({
action: 'trash',
reason: 'не наш профиль',
summary: '',
});
});
it('корзина: из корзины можно вернуться — «Зарегистрировался» остаётся доступен', () => {
const values = (factory(prospect({ stage: 'trash' })).vm as unknown as DialogVm)
.availableActions.map((a) => a.value);
expect(values).toContain('registered');
});
it('на карточке в ручном тестировании показывает, куда отправляли тест', () => {
const w = factory(prospect({ stage: 'manual_testing', test_channel: 'phone', test_target: '79990001122' }));
expect((w.vm as unknown as DialogVm).resultReminder).toBe('Тест отправлен: телефон 79990001122');
@@ -20,6 +20,7 @@ interface BoardVm {
managerId: number | null;
load: () => Promise<void>;
managerItems: { id: number | null; name: string }[];
applyDateFilter: (v: unknown) => Promise<void>;
}
describe('SalesProspectsBoardView', () => {
@@ -29,11 +30,24 @@ describe('SalesProspectsBoardView', () => {
const w = mount(SalesProspectsBoardView, { global: { plugins: [vuetify] } });
await flushPromises();
// 'department' — воронка отдела просит весь отдел явно (§23).
expect(listProspects).toHaveBeenCalledWith(undefined, undefined, 'department');
expect(listProspects).toHaveBeenCalledWith(undefined, undefined, 'department', null);
(w.vm as unknown as BoardVm).managerId = 7;
await (w.vm as unknown as BoardVm).load();
expect(listProspects).toHaveBeenLastCalledWith(7, undefined, 'department');
expect(listProspects).toHaveBeenLastCalledWith(7, undefined, 'department', null);
});
it('фильтр по датам уходит в запрос, а «Все карточки» его гасят', async () => {
const w = mount(SalesProspectsBoardView, { global: { plugins: [vuetify] } });
await flushPromises();
const vm = w.vm as unknown as BoardVm;
const todo = { mode: 'todo', period: 'today', from: null, to: null };
await vm.applyDateFilter(todo);
expect(listProspects).toHaveBeenLastCalledWith(undefined, undefined, 'department', todo);
await vm.applyDateFilter({ mode: null, period: 'today', from: null, to: null });
expect(listProspects).toHaveBeenLastCalledWith(undefined, undefined, 'department', null);
});
it('фильтр происхождения уходит в запрос вторым параметром', async () => {
@@ -43,7 +57,7 @@ describe('SalesProspectsBoardView', () => {
(w.vm as unknown as BoardVm & { source: string | null }).source = 'manager';
await (w.vm as unknown as BoardVm).load();
expect(listProspects).toHaveBeenLastCalledWith(undefined, 'manager', 'department');
expect(listProspects).toHaveBeenLastCalledWith(undefined, 'manager', 'department', null);
});
it('в фильтре у менеджера показывается число его карточек', async () => {
+12 -1
View File
@@ -38,6 +38,7 @@ const vuetify = createVuetify();
interface ViewVm {
open: (p: unknown) => Promise<void> | void;
createCandidate: (p: Record<string, unknown>) => Promise<void>;
applyDateFilter: (v: unknown) => Promise<void>;
}
describe('SalesProspectsView', () => {
@@ -53,10 +54,20 @@ describe('SalesProspectsView', () => {
it('загружает и показывает карточки менеджера', async () => {
const w = mount(SalesProspectsView, { global: { plugins: [vuetify] } });
await flushPromises();
expect(listProspects).toHaveBeenCalledWith(undefined);
expect(listProspects).toHaveBeenCalledWith(undefined, undefined, undefined, null);
expect(w.text()).toContain('ООО Демо');
});
it('фильтр по датам уходит в запрос и у менеджера тоже', async () => {
const w = mount(SalesProspectsView, { global: { plugins: [vuetify] } });
await flushPromises();
const changed = { mode: 'changed', period: 'today', from: null, to: null };
await (w.vm as unknown as ViewVm).applyDateFilter(changed);
expect(listProspects).toHaveBeenLastCalledWith(undefined, undefined, undefined, changed);
});
it('открытие карточки из «Новые» помечает её взятой в работу (action=opened)', async () => {
const w = mount(SalesProspectsView, { global: { plugins: [vuetify] } });
await flushPromises();
+8 -2
View File
@@ -2,13 +2,19 @@ import { describe, expect, it } from 'vitest';
import { PROSPECT_STAGES, isOverdue, stageMeta } from '../../resources/js/utils/prospectStages';
describe('prospectStages', () => {
it('11 стадий в правильном порядке: ручное тестирование после переговоров, КП после пользователя', () => {
it('12 стадий в правильном порядке: ручное тестирование после переговоров, КП после пользователя, корзина последняя', () => {
expect(PROSPECT_STAGES.map((s) => s.key)).toEqual([
'new', 'in_work', 'negotiation', 'manual_testing', 'registered', 'testing',
'topped_up', 'user', 'kp_sent', 'rejected', 'no_answer',
'topped_up', 'user', 'kp_sent', 'rejected', 'no_answer', 'trash',
]);
});
it('корзина — ручная стадия с русским именем', () => {
const byKey = Object.fromEntries(PROSPECT_STAGES.map((s) => [s.key, s]));
expect(byKey.trash.manual).toBe(true);
expect(stageMeta('trash').title).toBe('Корзина');
});
it('обе новые стадии — ручные: автомат по деньгам их не ставит', () => {
const byKey = Object.fromEntries(PROSPECT_STAGES.map((s) => [s.key, s]));
expect(byKey.manual_testing.manual).toBe(true);
@@ -185,6 +185,23 @@ it('за пополнивших баланс и пользователей мы
}
});
it('за карточку в корзине не платим ни дня — в отличие от отказа, который догревается', function () {
$trash = decide([
'stage' => 'trash',
'stage_changed_at' => CarbonImmutable::parse('2026-07-31 12:00:00'),
]);
expect($trash['state'])->toBe('stopped')
->and($trash['reason'])->toBe('trashed');
// Свежий отказ на ту же дату ещё греется — значит стоп у корзины именно свой,
// а не общее «всё старое выключено».
expect(decide([
'stage' => 'rejected',
'stage_changed_at' => CarbonImmutable::parse('2026-07-31 12:00:00'),
])['state'])->toBe('active');
});
it('слушается изменённых настроек', function () {
expect(decide([
'stage' => null,
@@ -140,6 +140,13 @@ it('kind=yesterday — только вчерашний день', function (): v
expect($range->end->format('Y-m-d H:i:s'))->toBe('2026-06-14 23:59:59');
});
it('kind=tomorrow — только завтрашний день', function (): void {
$range = (new SalesPeriodResolver)->resolve(['kind' => 'tomorrow']);
expect($range->start->format('Y-m-d H:i:s'))->toBe('2026-06-16 00:00:00');
expect($range->end->format('Y-m-d H:i:s'))->toBe('2026-06-16 23:59:59');
});
it('kind=d7 — последние 7 дней, включая сегодня', function (): void {
$range = (new SalesPeriodResolver)->resolve(['kind' => 'd7']);
-1
View File
@@ -2524,4 +2524,3 @@ zakryt
МТТ
Скартел
админский
админский
+48
View File
@@ -71,6 +71,54 @@
у `crm_admin_user` на `client_tg_campaigns` был только SELECT, а канал робота пишет туда итог — закрыто
миграцией `2026_07_31_100100_grant_admin_update_client_tg_campaigns.php` (см. v9.30).
## 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.
⚠️ Номер **v9.31**, хотя верхняя запись в `main` — v9.28. Промежуток намеренный:
v9.29 и v9.30 заняты параллельной веткой `fix/robot-yandex-zamok` (телеграм-робот
на опросе), которая в `main` ещё не влита. Взять v9.29 значило бы столкнуться
с ней при слиянии — ровно то, что уже случилось 01.08.2026 с v9.28.
## v9.28 (2026-07-31) — Воронка продаж: стадии «Тестирование ручное» и «Выслано КП»
`sales_prospects.stage`: CHECK пересобран на 11 значений — добавлены `manual_testing`
@@ -0,0 +1,47 @@
# ПРОМТ для следующей сессии — корзина, фильтры по датам, счётчик через дробь, КП без даты
Скопировать текст ниже целиком в новую сессию.
---
Работаем в ветке `feat/prospects-manual-testing-kp` (не в main), каталог
`c:\моя\проекты\портал crm\Документация`.
**Первое действие — прочитать `docs/superpowers/2026-08-01-ZAPROS-korzina-filtry-schetchik-KP.md`
целиком.** Там дословная просьба владельца, описания трёх скринов, находки по коду и
четыре вопроса, на которые нужен его ответ до начала работы.
Задача — четыре куска:
1. Новая колонка **«Корзина»** — и у менеджера, и у начальника, по тому же принципу, что
«Тестирование ручное» и «Выслано КП» (они уже сделаны и выкачены 31.07). В её результате
разговора **нет даты созвона**, только причина.
2. В результате **«Выслано КП»** убрать обязательность поля «Следующий созвон».
3. В заголовке колонки **«Отказ»** писать через дробь: `69/1` — сколько всего отказов и
сколько из них пришло после ручного тестирования.
4. **Два фильтра по датам** на доске (владелец назвал это самым большим куском):
(а) выбрать дату или период → карточки, по которым в этот срок надо звонить/что-то делать,
**включая просроченные**; (б) выбрать дату или период → карточки, которые **менялись**
в этот период («что я сделал за день»).
Порядок работы: сначала задать владельцу четыре вопроса из §5 запроса (у каждого есть
рекомендованный ответ), потом спека → план → код по задачам. Кусок 4 большой — резать
на отдельные задачи, не пытаться одним куском.
Что важно не забыть (подробности — в §4 запроса):
- 🔴 У карточки **нет истории стадий** — ни `stage_changed_at`, ни `prev_stage`. Счётчик `69/1`
считать не из чего, нужны новые данные в базе.
- 🔴 `AdAudienceScheduler::decide()` на незнакомой стадии молча выключает рекламу-прогрев —
«Корзину» туда завести **осознанно**, а не пропустить.
- Список стадий живёт в **двух** местах: `PROSPECT_STAGES` и `SalesProspectController::STAGES`.
- Схема `sales_*` только в миграциях, `db/schema.sql` не трогать. Запись в
`db/CHANGELOG_schema.md` (последняя занятая — v9.30).
- 🪤 `git stash` в этом хранилище терял правки **дважды** — не пользоваться.
- 🪤 Тестовая база своя: `liderra_testing_prospects`. Команды фронта — из `app/`, не из корня.
- 🪤 Три сторожа (статанализ, журналы «мозга», орфография+разметка) **падают**, а не находят
проблемы: `LEFTHOOK_EXCLUDE=larastan,observer-coverage-checker,cspell,markdownlint`.
Сторож секретов `gitleaks` живой — не глушить.
- Обязательна **приёмка глазами** в браузере, включая «Воронку отдела» начальника.
«Готово» не писать, пока не проверено живьём.
- На боевой liderra.ru **ничего не выкатывать** без отдельной команды владельца.
@@ -0,0 +1,110 @@
# ЗАПРОС ВЛАДЕЛЬЦА 01.08.2026 — корзина, фильтры по датам, счётчик через дробь, КП без даты
Файл заведён, чтобы просьба не размылась при переносе между сессиями.
Работа **ещё не начата** — это фиксация задачи, а не отчёт.
## 1. Дословно, как сказал владелец
> смотри надо по тому же принципу добавить колонку корзина и к начальнику и к менеджеру
> убрав дату созвона и оставить только причину и в кп колонке убрать обязательное поле
> дава и время созвона на скрине смотри! и смотри надо сделать когда после ручного
> тестирования карточка попадает в поле отказ то надо писать через дробь количество
> такких те будет 69/1 примерно как на скрине!
> и самое больше наверно надо сделать 2 фильтра по датам 1 чтобы фильтровать что надо
> сдалать сегоня/завтра и т.д. и что просрочено те я выбираю дату или период и мне
> высвечиваются карточки по которым надо звонить или что-то делать и второй фильтр тоже
> по дате также выбираю дату или период и мне показываются карточки которые изменялись
> в этот период те я могу посмотреть в конце дня что я сделал или за какойто период
## 2. Четыре куска работы
| # | Что | Где видно на скринах |
|---|---|---|
| 1 | Новая колонка **«Корзина»** — и у менеджера, и у начальника, по тому же принципу, что «Тестирование ручное» и «Выслано КП». В её результате разговора **нет даты созвона**, только причина. | — |
| 2 | В результате **«Выслано КП»** убрать обязательное поле «Следующий созвон» (дата и время). | скрин 3 |
| 3 | Когда карточка попадает в **«Отказ» после ручного тестирования** — в заголовке колонки писать **через дробь**: `69/1`. | скрин 2 |
| 4 | **Два фильтра по датам** (владелец назвал самым большим куском): (а) выбрать дату или период → показать карточки, по которым в этот срок надо звонить/что-то делать, **включая просроченные**; (б) выбрать дату или период → показать карточки, которые **менялись** в этот период («что я сделал за день / за период»). | скрин 1 — рядом с существующим выбором «30 дней» |
## 3. Скриншоты
Владелец прислал три картинки прямо в чат. Файлами их у меня нет — положить их сюда:
`docs/superpowers/screens/2026-08-01-korzina-filtry/`
Ниже — описания, чтобы работа не встала, даже если картинки не сохранятся.
### Скрин 1 — `01-period-selector.png`
Боевой `lk.liderra.ru`, экран **«Воронка отдела»** (значок «НАЧАЛЬНИК» справа вверху).
Красным обведён выпадающий список **«30 дней»** в правом верхнем углу — рядом со значком
роли, выше фильтров «Менеджер» и «Происхождение». Это существующий выбор периода;
именно сюда просятся два новых фильтра по датам.
Колонки на экране: Зарегистрировался 0 · Тестирование 0 · Пополнил баланс 1 (ВитаДент,
Омск) · Пользователь 0 · Выслано КП 1 (Омдент, Омск) · Отказ 69 · Не смогли дозвониться.
### Скрин 2 — `02-otkaz-69-drob-1.png`
Та же «Воронка отдела». Владелец **от руки дорисовал `/1`** сразу после числа `69`
в заголовке колонки **«Отказ»**. То есть в заголовке должно стоять `69/1`:
общее число отказов и — через дробь — сколько из них пришло после ручного тестирования.
### Скрин 3 — `03-kp-lishnyaya-data.png`
Карточка **«Центр дентальной имплантации»** (Томск, ООО «ЦДИ», значок стадии «Переговоры»),
открыто окно карточки. В блоке «Результат разговора» выбрано **«Выслано КП»**.
Красным обведены два поля:
- пустой список **«Куда направили КП»**;
- пустое поле **«Следующий созвон»** (`дд.мм.гггг --:--`) — **вот его владелец и просит убрать
из обязательных**.
## 4. Что уже выяснено в коде (чтобы не искать заново)
- Счётчик в шапке колонки рисуется в
[SalesProspectBoard.vue:75](../../app/resources/js/components/sales/SalesProspectBoard.vue#L75):
`<span class="prospect-column-count">{{ col.cards.length }}</span>` — сюда пойдёт `69/1`.
- Фильтры доски («Менеджер», «Происхождение») живут в
[SalesProspectsBoardView.vue](../../app/resources/js/views/sales/SalesProspectsBoardView.vue),
примерно строки 95–112. Экран начальника отличается признаком `scope=department`.
- 🔴 **У карточки нет никакой истории стадий**: в `SalesProspect` нет ни `stage_changed_at`,
ни `prev_stage`. Значит счётчик «сколько отказов пришло после ручного тестирования»
**не из чего посчитать** — нужны новые данные. Склоняюсь к отдельному полю `prev_stage`,
которое пишется при каждой смене стадии, а не к разбору текста журнала разговоров.
- Список стадий по-прежнему живёт в **двух** местах: `PROSPECT_STAGES`
([prospectStages.ts](../../app/resources/js/utils/prospectStages.ts)) и
`SalesProspectController::STAGES`. Расхождение = пустая колонка на доске.
- 🔴 `AdAudienceScheduler::decide()` на незнакомой стадии возвращает `stopped('unknown_stage')`
реклама-прогрев **молча встанет**, если «Корзину» туда не завести осознанно.
Для «Корзины» это, скорее всего, и надо (карточку выбросили — греть незачем),
но написать это надо явно, а не получить случайно.
- Схема `sales_*` живёт **только в миграциях**, не в `db/schema.sql`. Нормативная правка одна —
запись в `db/CHANGELOG_schema.md` (последняя занятая — v9.30, см. коммит `72db586a`).
## 5. Четыре вопроса, которые надо задать владельцу до начала
1. **«Корзина» — что это по смыслу и где стоит?**
Рекомендую: карточки, с которыми больше не работаем (мусор, дубли, не наш профиль).
Место — в самом конце, после «Не смогли дозвониться». Поле одно — причина.
Реклама-прогрев останавливается сразу (в отличие от «Отказа», который греется
ещё `rejected_days`).
2. **`69/1` — что означает второе число?**
Рекомендую: сколько из отказов пришло из «Тестирования ручного».
3. **Фильтр «что я сделал» — что считать изменением?**
Рекомендую: записи в журнале разговоров (настоящие действия менеджера), а не любое
касание `updated_at` — денежная джоба тоже трогает карточки, и день будет «полон дел»
без единого звонка.
4. **Дата в КП — убрать поле совсем или оставить необязательным?**
Рекомендую: поле оставить, просто перестать требовать.
## 6. Состояние на момент фиксации
- Ветка `feat/prospects-manual-testing-kp`, HEAD `9165cbb0`.
- Предыдущая работа (две стадии «Тестирование ручное» и «Выслано КП») **сделана и выкачена
на боевой liderra.ru** 31.07.2026 точечным выкатом 12 файлов + `public/build`.
- 🪤 `git stash` в этом хранилище **терял правки дважды**. Не пользоваться.
- 🪤 Три сторожа (статанализ, журналы «мозга», орфография+разметка) **падают**, а не находят
проблемы — корневые `node_modules` обрезаны. Пропускать через
`LEFTHOOK_EXCLUDE=larastan,observer-coverage-checker,cspell,markdownlint`.
Сторож секретов `gitleaks` живой — его не глушить.
- 🪤 Тестовая база у этой работы своя: `liderra_testing_prospects` (общую кто-то чистит
параллельно). Команды фронта — из `app/`, не из корня.
@@ -0,0 +1,148 @@
# Корзина, фильтры по датам, счётчик через дробь — план работ
> **Для исполнителя:** задачи строго последовательны, каждая заканчивается коммитом.
> Спека: [2026-08-01-korzina-filtry-schetchik-design.md](../specs/2026-08-01-korzina-filtry-schetchik-design.md).
**Цель:** колонка «Корзина», необязательная дата в КП, счётчик `69/1` у «Отказа»
и фильтр по датам в двух режимах — на обоих экранах воронки.
**Устройство:** движения карточки пишутся событием модели в новую таблицу
`sales_prospect_moves` (один шов на всех, включая джобу); `prev_stage` на карточке
кормит счётчик; фильтр разбирает период существующим `SalesPeriodResolver`.
**Стек:** PHP 8.3 / Laravel 13 / Pest 4 / PostgreSQL 16; Vue 3 + Vuetify 3 / Vitest.
**Окружение (не забыть):**
- тесты — своя база: `cd app && DB_DATABASE=liderra_testing_prospects ./vendor/bin/pest …`
- фронт — из `app/`: `cd app && npm run test:vue`
- коммит — `LEFTHOOK_EXCLUDE=larastan,observer-coverage-checker,cspell,markdownlint`
- `git stash` в этом хранилище **не использовать** — терял правки дважды
---
### Task 1: База — `trash`, `prev_stage`, таблица движений
**Файлы:**
- Создать: `app/database/migrations/2026_08_01_100000_add_trash_stage_and_prospect_moves.php`
- Создать: `app/app/Models/SalesProspectMove.php`
- Изменить: `app/app/Models/SalesProspect.php`
- Тест: `app/tests/Feature/Sales/SalesProspectMoveTest.php`
- [ ] **Шаг 1.** Миграция: `ADD COLUMN IF NOT EXISTS prev_stage VARCHAR(16)`; пересборка
`sales_prospects_stage_check` на 12 значений (+`trash`); `CREATE TABLE IF NOT EXISTS
sales_prospect_moves` по §4.2 спеки; два индекса; блок GRANT для `crm_admin_user`
и `crm_supplier_worker` — копия блока из `create_sales_prospect_notes_table`.
`down()`: `UPDATE … SET stage='rejected' WHERE stage='trash'`, вернуть CHECK на 11,
`DROP COLUMN prev_stage`, `DROP TABLE sales_prospect_moves CASCADE`.
- [ ] **Шаг 2.** Модель `SalesProspectMove` (append-only, `UPDATED_AT = null`).
- [ ] **Шаг 3.** Тест: смена стадии через модель пишет строку движения с правильными
`from_stage`/`to_stage` и заполняет `prev_stage`; сохранение БЕЗ смены стадии
движения не пишет; создание карточки пишет движение с `from_stage = null`.
- [ ] **Шаг 4.** Прогнать — тест падает (движений нет).
- [ ] **Шаг 5.** `SalesProspect::booted()`: `saving``prev_stage`, `saved` → вставка
движения через `$this->getConnection()`; автор — `auth('sales')->user()?->id`.
Добавить `prev_stage` в `$fillable` и PHPDoc.
- [ ] **Шаг 6.** Прогнать — зелено. Коммит.
### Task 2: Джоба и контроллер — движения пишутся отовсюду
**Файлы:**
- Тест: `app/tests/Feature/Sales/SalesProspectMoveTest.php` (дополнить)
- [ ] **Шаг 1.** Тест: `SalesProspectsAdvanceJob` двигает карточку по деньгам → появилось
движение `registered → topped_up` с `sales_user_id = null`. Тест: результат разговора
через API → движение с `sales_user_id` = менеджер.
- [ ] **Шаг 2.** Прогнать. Ожидание: **уже зелено** — событие модели ловит обе стороны.
Если красно — чинить событие, а не добавлять записи в джобу.
- [ ] **Шаг 3.** Проверка вырезанием: временно убрать `static::saved(...)`, убедиться,
что оба теста краснеют. Вернуть. Коммит.
### Task 3: Стадия «Корзина» — бэкенд
**Файлы:**
- Изменить: `app/app/Http/Controllers/Api/Sales/SalesProspectController.php`
- Изменить: `app/app/Services/Sales/AdAudienceScheduler.php`
- Тест: `app/tests/Feature/Sales/SalesProspectApiTest.php`, `app/tests/Unit/AdAudienceSchedulerTest.php`
- [ ] **Шаг 1.** Тесты: `action=trash` с причиной → стадия `trash`, `reason` записан,
`next_call_at` обнулён, в журнале «В корзину: …»; без причины → 422; на `topped_up`
и `user` → 422; `trash` есть в списке стадий ответа. Юнит-тест: `decide()` на стадии
`trash``stopped('trashed')`.
- [ ] **Шаг 2.** Прогнать — красно.
- [ ] **Шаг 3.** `STAGES` += `trash` (последним); ветка `case 'trash'` в `update()`
(валидация причины, `assertNotPaying`, обнуление `next_call_at`); ветка `trash`
в `AdAudienceScheduler::decide()` **до** проверки `STAGE_LIMITS`.
- [ ] **Шаг 4.** Прогнать — зелено. Коммит.
### Task 4: КП без обязательной даты — бэкенд
**Файлы:**
- Изменить: `app/app/Http/Controllers/Api/Sales/SalesProspectController.php`
- Тест: `app/tests/Feature/Sales/SalesProspectApiTest.php`
- [ ] **Шаг 1.** Тест: `action=kp_sent` **без** `next_call_at` → 200, стадия `kp_sent`,
в журнале «Выслано КП: почта … » **без** хвоста «Следующий созвон». Прежний тест
с датой остаётся зелёным и хвост в журнале сохраняет.
- [ ] **Шаг 2.** Прогнать — красно (сейчас `required`).
- [ ] **Шаг 3.** `next_call_at``nullable`; ставить дату и добавлять хвост в журнал
только когда она пришла.
- [ ] **Шаг 4.** Прогнать — зелено. Коммит.
### Task 5: Фильтр по датам — бэкенд
**Файлы:**
- Изменить: `app/app/Services/Sales/SalesPeriodResolver.php` (+`tomorrow`)
- Изменить: `app/app/Http/Controllers/Api/Sales/SalesProspectController.php`
- Тест: `app/tests/Unit/SalesPeriodResolverTest.php`, `app/tests/Feature/Sales/SalesProspectDateFilterTest.php`
- [ ] **Шаг 1.** Юнит-тест: `resolve(['kind' => 'tomorrow'])` → завтрашние 00:00–23:59:59 МСК.
- [ ] **Шаг 2.** Тесты фильтра (время заморожено `CarbonImmutable::setTestNow`):
`date_mode=todo&period=today` → сегодняшний созвон **и** просроченный, но не завтрашний
и не карточка без даты; отказ и корзина не показываются даже с просроченной датой;
`date_mode=changed&period=today` → карточка с сегодняшним движением и карточка
с сегодняшней записью разговора, но не тронутая вчера; без `date_mode` — всё как раньше.
- [ ] **Шаг 3.** Прогнать — красно.
- [ ] **Шаг 4.** В `index()`: разбор `date_mode` (`todo|changed`) + период через
`ResolvesSalesPeriod`; две ветки условий по §8.2/§8.3 спеки; `prev_stage` в `row()`.
- [ ] **Шаг 5.** Прогнать — зелено. Коммит.
### Task 6: Фронт — стадия, счётчик, КП, типы
**Файлы:**
- Изменить: `app/resources/js/utils/prospectStages.ts`, `app/resources/js/api/sales.ts`,
`app/resources/js/components/sales/SalesProspectBoard.vue`,
`app/resources/js/components/sales/SalesProspectDialog.vue`
- Тест: `app/tests/Frontend/prospectStages.spec.ts`, `SalesProspectBoard.spec.ts`, `SalesProspectDialog.spec.ts`
- [ ] **Шаг 1.** Тесты: в `PROSPECT_STAGES` 12 стадий, последняя — «Корзина»; у колонки
«Отказ» с двумя карточками, одна из которых `prev_stage='manual_testing'`, подпись
`2/1`, а без таких карточек — просто `2`; в диалоге есть результат «В корзину»
с полем причины и без поля даты; «В корзину» не предлагается платящему; «Выслано КП»
отправляется без даты.
- [ ] **Шаг 2.** Прогнать — красно.
- [ ] **Шаг 3.** Правки: стадия в `prospectStages.ts`; `subCount` в `SalesProspectBoard.vue`
и вывод дроби в шапке; в диалоге — ветка `trash` (причина) и снятие обязательности даты
у `kp_sent`; типы и поле `prev_stage` в `api/sales.ts`.
- [ ] **Шаг 4.** Прогнать — зелено. Коммит.
### Task 7: Фронт — орган фильтра на обоих экранах
**Файлы:**
- Создать: `app/resources/js/components/sales/ProspectDateFilter.vue`
- Изменить: `app/resources/js/views/sales/SalesProspectsView.vue`,
`app/resources/js/views/sales/SalesProspectsBoardView.vue`, `app/resources/js/api/sales.ts`
- Тест: `app/tests/Frontend/ProspectDateFilter.spec.ts`
- [ ] **Шаг 1.** Тесты компонента: по умолчанию режим «Все карточки», выбор периода скрыт;
при выборе режима появляется выбор периода и вылетает событие с `{mode, period}`;
«Произвольный период» отдаёт событие только когда выбраны обе даты.
- [ ] **Шаг 2.** Прогнать — красно.
- [ ] **Шаг 3.** Написать компонент; врезать в оба экрана; параметры в `listProspects()`.
- [ ] **Шаг 4.** Прогнать — зелено. Коммит.
### Task 8: Приёмка и нормативка
- [ ] **Шаг 1.** Полный прогон Pest по `tests/Feature/Sales` + `tests/Unit` и весь Vitest.
- [ ] **Шаг 2.** Приёмка глазами в браузере — 7 пунктов §10 спеки, включая экран начальника.
- [ ] **Шаг 3.** Запись в `db/CHANGELOG_schema.md` (следующая свободная версия после v9.30).
- [ ] **Шаг 4.** Коммит. На боевой **не выкатывать** без отдельной команды владельца.
@@ -0,0 +1,10 @@
# Скриншоты к запросу 01.08.2026 (корзина, фильтры, счётчик, КП)
Сюда положить три картинки, которые владелец прислал в чат.
Описания — в [запросе](../../2026-08-01-ZAPROS-korzina-filtry-schetchik-KP.md) §3.
| Имя файла | Что на нём |
|---|---|
| `01-period-selector.png` | «Воронка отдела», обведён выбор периода «30 дней» в правом верхнем углу |
| `02-otkaz-69-drob-1.png` | Та же доска, от руки дорисовано `/1` после `69` в заголовке «Отказ» |
| `03-kp-lishnyaya-data.png` | Карточка «Центр дентальной имплантации», результат «Выслано КП», обведено лишнее обязательное поле «Следующий созвон» |
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

@@ -0,0 +1,204 @@
# Корзина, два фильтра по датам, счётчик через дробь, КП без обязательной даты
**Дата:** 01.08.2026
**Запрос владельца:** [2026-08-01-ZAPROS-korzina-filtry-schetchik-KP.md](../2026-08-01-ZAPROS-korzina-filtry-schetchik-KP.md)
**Ветка:** `feat/prospects-manual-testing-kp`
---
## 1. Что делаем
Четыре куска в воронке «Потенциальные клиенты» — и на экране менеджера, и в «Воронке отдела»:
1. Новая колонка **«Корзина»** (`trash`) — карточки, с которыми больше не работаем. Результат
разговора «В корзину» просит только причину, даты созвона у него нет.
2. У результата **«Выслано КП»** дата созвона перестаёт быть обязательной.
3. В шапке колонки **«Отказ»** — счётчик через дробь `69/1`: всего отказов и сколько из них
пришло прямо из «Тестирования ручного».
4. **Фильтр по датам** с двумя режимами: «Что надо сделать» и «Что менялось».
## 2. Решения владельца (01.08.2026)
| Вопрос | Ответ |
|---|---|
| Что такое «Корзина» | Как предложено: карточки, с которыми больше не работаем; колонка последняя, поле одно — причина; реклама-прогрев останавливается сразу |
| Что значит второе число в `69/1` | Сколько отказов пришло после ручного тестирования |
| Что считать изменением карточки | **Все движения**: потратил деньги, ушёл в тестирование, пополнил счёт, стал клиентом — любое движение, а не только разговоры |
| Дата в КП | Оставить как возможность, не требовать. Бывает «пришлите, созвонимся через пару дней» (дата нужна), бывает «пришлите на почту, если интересно — перезвоню» (даты нет) |
Третий ответ — поправка к моей рекомендации. Я предлагал считать изменением только записи
в журнале разговоров; владелец потребовал считать **любое** движение карточки, включая
автоматические (деньги, стадии от джобы). Это меняет устройство: нужен журнал движений,
а не выборка по разговорам.
## 3. Главная находка: движения карточки нигде не записываются
Сегодня стадия меняется в двух местах, и записи о ней ведутся по-разному:
- `SalesProspectController::update()` — ручные результаты, пишет запись в журнал разговоров
(`sales_prospect_notes`, `kind='stage'`) **человеческим текстом**;
- `SalesProspectsAdvanceJob` — автоматические стадии по деньгам (`testing` / `topped_up` /
`user`), пишет `$p->save()` и **не оставляет вообще ничего**.
То есть ровно те движения, которые владелец назвал первыми («потратил деньги, ушёл
в тестирование, пополнил счёт, стал клиентом»), сейчас невидимы. И у карточки нет ни
`prev_stage`, ни `stage_changed_at` — счётчик `69/1` тоже считать не из чего.
**Решение — один шов, а не два.** Заводим таблицу движений `sales_prospect_moves` и пишем
в неё из **события модели** `SalesProspect`, а не из контроллера и джобы по отдельности.
Любой код, который меняет стадию — сегодняшний, завтрашний, случайный — попадёт в журнал
автоматически. Две честные половинки с голым швом посередине — это ровно тот класс дыр,
на котором мы уже обжигались (см. память `feedback-shov-mezhdu-chestnymi-polovinkami`).
## 4. База
### 4.1. `sales_prospects` — две правки
- `+ prev_stage VARCHAR(16) NULL` — откуда карточка приехала на текущую стадию.
Пишется тем же событием модели. Нужен счётчику `69/1`.
- CHECK `sales_prospects_stage_check` пересобирается на **12** значений — добавляется `trash`.
### 4.2. Новая таблица `sales_prospect_moves`
```
id BIGSERIAL PRIMARY KEY
prospect_id BIGINT NOT NULL REFERENCES sales_prospects(id) ON DELETE CASCADE
sales_user_id BIGINT REFERENCES sales_users(id) -- кто двигал; NULL = джоба
from_stage VARCHAR(16) -- NULL у самой первой записи
to_stage VARCHAR(16) NOT NULL
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
```
Индексы: `(prospect_id, created_at DESC)` — история карточки; `(created_at)` — фильтр по периоду.
Append-only, как и журнал разговоров: движение — это факт, его не правят.
🔴 **GRANT.** Новой таблице мало `CREATE TABLE`: без явных `GRANT SELECT, INSERT` ролям
`crm_admin_user` и `crm_supplier_worker` на бою будет отказ прав, а на dev (superuser)
это невидимо. Блок GRANT копируем один в один из `create_sales_prospect_notes_table`.
### 4.3. Чего не делаем
Задним числом историю не восстанавливаем: `prev_stage` у существующих карточек остаётся
пустым. Разбирать текст старых записей журнала («Ручное тестирование: …» перед «Отказ: …»)
— гадание на строках. Практического ущерба нет: ручное тестирование появилось только
31.07.2026, истории почти нет. **Следствие: первое время счётчик будет показывать просто
`69`, дробь появится с первым новым отказом после ручного тестирования.**
## 5. Событие модели — где пишется движение
В `SalesProspect::booted()`:
- `saving` — если `stage` изменился, кладём в `prev_stage` прежнее значение;
- `saved` — если стадия менялась, вставляем строку в `sales_prospect_moves`.
Автор движения — `auth('sales')->user()?->id`. В джобе авторизации нет → `null`, и это
правильно: движение по деньгам сделал не человек.
Вставка идёт через `$prospect->getConnection()` — джоба работает на `pgsql_admin`, портал
на дефолтном соединении; жёстко зашитое соединение сломало бы одну из сторон.
## 6. Стадия `trash`
- Ключ `trash`, заголовок **«Корзина»**, цвет `#57534E` (серый камень), `manual: true`.
- Место — **последнее**, после «Не смогли дозвониться».
- Результат разговора **«В корзину»**: одно поле — причина (обязательна). Даты созвона нет;
при переезде в корзину `next_call_at` **очищается** — карточка не должна больше всплывать
в фильтре «что надо сделать».
- Доступен на любой стадии, **кроме** `topped_up` и `user`: выбрасывать платящего клиента
нельзя, это тот же запрет, что уже стоит на «Отказе» и новых стадиях.
- Из корзины можно выбраться: «Зарегистрировался» и прочие результаты остаются доступны
(карточку могли выбросить по ошибке).
**Реклама:** `AdAudienceScheduler::decide()` получает явную ветку
`if ($stage === 'trash') return stopped('trashed')` — прогрев встаёт сразу, в отличие от
«Отказа», который догревается ещё `rejected_days`. Без явной ветки стадия попала бы
в `unknown_stage` и реклама встала бы **молча** — итог тот же, но по случайности, а не
по решению; на такой случайности нельзя строить.
## 7. Счётчик `69/1`
Считается на фронте из данных, которые уже приезжают: у колонки `rejected` второе число —
`cards.filter(c => c.prev_stage === 'manual_testing').length`.
- Дробь показывается **только когда второе число больше нуля**: «69/0» — шум.
- Подсказка при наведении: «из них 1 после ручного тестирования».
- Считается ровно «пришла прямо из ручного тестирования». Путь
`ручное тестирование → переговоры → отказ` в дробь не попадает — владелец сказал
«когда после ручного тестирования карточка попадает в поле отказ».
- При включённом фильтре по датам дробь считается по отфильтрованным карточкам — как
и основное число.
## 8. Фильтр по датам
### 8.1. Один орган управления, два режима
Владелец назвал это «двумя фильтрами». Делаем **один** орган с выбором режима, потому что
режимы взаимоисключающие по смыслу и никогда не нужны одновременно: утром смотрят «что
надо сделать сегодня», вечером — «что я сделал за день». Два независимых органа
пересекались бы друг с другом и путали.
```
[ Все карточки ▾ ] [ Сегодня ▾ ]
Все карточки
Что надо сделать
Что менялось
```
Второй список — период: **Сегодня / Завтра / Вчера / 7 дней / 30 дней / Произвольный
период** (календарь-диапазон). Он появляется, только когда выбран режим.
Одинаковый орган и у менеджера, и у начальника — компонент один.
### 8.2. Режим «Что надо сделать»
Карточки, по которым в выбранный срок надо звонить или что-то делать:
`next_call_at` попадает в период **ИЛИ** `next_call_at` уже просрочен (`< сейчас`).
Просроченные показываются **всегда**, каким бы период ни выбрали — владелец просил их
отдельно («и что просрочено»), и забытый звонок недельной давности важнее завтрашнего.
Они и так подсвечены красным на доске.
Из выборки исключены стадии `rejected` и `trash` — по ним делать нечего. Карточки без
даты созвона в этот режим не попадают: делать по ним нечего в конкретный срок.
### 8.3. Режим «Что менялось»
Карточки, у которых **есть хоть одно движение** в выбранный период. Движением считается:
- запись в `sales_prospect_moves` (смена стадии — любая, ручная и автоматическая);
- запись в `sales_prospect_notes` (менеджер записал разговор, даже не двигая карточку —
это тоже сделанная работа).
Так «что я сделал за день» покрывает и звонки, и автоматику по деньгам — ровно то, что
просил владелец.
### 8.4. Границы дат
Периоды считаются в МСК существующим `SalesPeriodResolver` — тем же, что «30 дней»
на других экранах портала. К нему добавляется вид `tomorrow` (владелец назвал «завтра»
прямо). Приложение живёт в UTC, менеджер думает в МСК — своей арифметики дат не заводим,
это уже решённая в проекте задача.
## 9. Что отрезано (осознанно)
- Восстановление истории движений задним числом — см. §4.3.
- Второе число в шапках других колонок. Владелец просил дробь только у «Отказа».
- Показ движений в истории карточки. Журнал движений пока служебный — для фильтра
и счётчика. Показать его человеку можно потом, это отдельная работа.
- Автоматическая чистка корзины по сроку. Корзина — это просто последняя колонка,
ничего само не удаляется.
- Сохранение выбранного фильтра между заходами. Каждый заход — чистая доска.
## 10. Проверка
Кроме тестов — **приёмка глазами** в браузере, обязательно на обоих экранах:
1. Карточка едет в «Корзину» с одной причиной, даты не просит.
2. Платящему клиенту результат «В корзину» не предлагается.
3. «Выслано КП» сохраняется **без** даты и **с** датой.
4. После «ручное тестирование → отказ» в шапке «Отказа» появляется дробь.
5. Фильтр «Что надо сделать» на «Сегодня» показывает сегодняшние созвоны и просроченные.
6. Фильтр «Что менялось» на «Сегодня» показывает карточку, которую только что подвинули.
7. Всё то же самое в «Воронке отдела» у начальника.