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>
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ function p(id: number, stage: string, overrides: Partial<SalesProspect> = {}): S
|
||||
id,
|
||||
sales_user_id: 1,
|
||||
stage,
|
||||
prev_stage: null,
|
||||
firm_name: `Фирма ${id}`,
|
||||
city: 'Ростов',
|
||||
phone: null,
|
||||
@@ -34,19 +35,56 @@ function p(id: number, stage: string, overrides: Partial<SalesProspect> = {}): S
|
||||
}
|
||||
|
||||
describe('SalesProspectBoard', () => {
|
||||
it('рисует 11 колонок и раскладывает карточки по стадиям', () => {
|
||||
it('рисует 12 колонок и раскладывает карточки по стадиям', () => {
|
||||
const w = mount(SalesProspectBoard, {
|
||||
props: { prospects: [p(1, 'new'), p(2, 'negotiation'), p(3, 'new')] },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
expect(w.findAll('.prospect-column').length).toBe(11);
|
||||
expect(w.findAll('.prospect-column').length).toBe(12);
|
||||
expect(w.text()).toContain('Новые');
|
||||
expect(w.text()).toContain('Взят в работу');
|
||||
expect(w.text()).toContain('Тестирование ручное');
|
||||
expect(w.text()).toContain('Выслано КП');
|
||||
expect(w.text()).toContain('Корзина');
|
||||
expect(w.text()).toContain('Фирма 1');
|
||||
});
|
||||
|
||||
/** Заголовок колонки по её названию — счётчик живёт рядом с ним. */
|
||||
function head(w: ReturnType<typeof mount>, title: string): string {
|
||||
const col = w.findAll('.prospect-column-head').find((h) => h.text().includes(title));
|
||||
return col?.text() ?? '';
|
||||
}
|
||||
|
||||
it('«Отказ»: через дробь сколько отказов пришло из ручного тестирования', () => {
|
||||
const w = mount(SalesProspectBoard, {
|
||||
props: {
|
||||
prospects: [
|
||||
p(1, 'rejected', { prev_stage: 'manual_testing' }),
|
||||
p(2, 'rejected', { prev_stage: 'negotiation' }),
|
||||
p(3, 'rejected', { prev_stage: null }),
|
||||
],
|
||||
},
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
expect(head(w, 'Отказ')).toContain('3/1');
|
||||
});
|
||||
|
||||
it('«Отказ» без таких карточек — просто число, без дроби', () => {
|
||||
const w = mount(SalesProspectBoard, {
|
||||
props: { prospects: [p(1, 'rejected', { prev_stage: 'negotiation' })] },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
expect(head(w, 'Отказ')).not.toContain('/');
|
||||
});
|
||||
|
||||
it('дробь — только у «Отказа»: в других колонках приезд из ручного тестирования её не рисует', () => {
|
||||
const w = mount(SalesProspectBoard, {
|
||||
props: { prospects: [p(1, 'no_answer', { prev_stage: 'manual_testing' })] },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
expect(head(w, 'Не смогли дозвониться')).not.toContain('/');
|
||||
});
|
||||
|
||||
it('бейдж происхождения: «своя» у менеджера, «от начальника» из поиска', () => {
|
||||
const w = mount(SalesProspectBoard, {
|
||||
props: {
|
||||
|
||||
@@ -18,6 +18,7 @@ function prospect(overrides: Partial<SalesProspect> = {}): SalesProspect {
|
||||
id: 1,
|
||||
sales_user_id: 1,
|
||||
stage: 'new',
|
||||
prev_stage: null,
|
||||
firm_name: 'ООО Тест',
|
||||
city: 'Ростов',
|
||||
phone: '+7800',
|
||||
@@ -476,7 +477,7 @@ describe('SalesProspectDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('выслано КП: без канала, без адреса и без даты — save не эмитится', async () => {
|
||||
it('выслано КП: без канала и без адреса — save не эмитится', async () => {
|
||||
const w = factory(prospect({ stage: 'negotiation' }));
|
||||
const vm = w.vm as unknown as DialogVm;
|
||||
|
||||
@@ -487,10 +488,25 @@ describe('SalesProspectDialog', () => {
|
||||
vm.kpChannel = 'email';
|
||||
await vm.submit();
|
||||
expect(w.emitted('save')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('выслано КП: без даты созвона всё равно сохраняется — дата необязательна', async () => {
|
||||
const w = factory(prospect({ stage: 'negotiation' }));
|
||||
const vm = w.vm as unknown as DialogVm;
|
||||
|
||||
vm.action = 'kp_sent';
|
||||
vm.kpChannel = 'email';
|
||||
vm.kpTarget = 'director@primer-firma.ru';
|
||||
await vm.submit();
|
||||
expect(w.emitted('save')).toBeFalsy();
|
||||
await flushPromises();
|
||||
|
||||
// Поля next_call_at в письме на сервер нет вовсе — не пустая строка.
|
||||
expect(w.emitted('save')?.[0][0]).toEqual({
|
||||
action: 'kp_sent',
|
||||
kp_channel: 'email',
|
||||
kp_target: 'director@primer-firma.ru',
|
||||
summary: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('выслано КП: с каналом, адресом и датой эмитит save с нужными полями', async () => {
|
||||
@@ -512,6 +528,51 @@ describe('SalesProspectDialog', () => {
|
||||
summary: '',
|
||||
});
|
||||
});
|
||||
it('корзина: результат есть в списке на обычной стадии, платящему — нет', () => {
|
||||
const onWork = (factory(prospect({ stage: 'negotiation' })).vm as unknown as DialogVm)
|
||||
.availableActions.map((a) => a.value);
|
||||
expect(onWork).toContain('trash');
|
||||
|
||||
for (const stage of ['topped_up', 'user']) {
|
||||
const values = (factory(prospect({ stage })).vm as unknown as DialogVm)
|
||||
.availableActions.map((a) => a.value);
|
||||
expect(values).not.toContain('trash');
|
||||
}
|
||||
});
|
||||
|
||||
it('корзина: без причины save не эмитится', async () => {
|
||||
const w = factory(prospect({ stage: 'negotiation' }));
|
||||
const vm = w.vm as unknown as DialogVm;
|
||||
|
||||
vm.action = 'trash';
|
||||
await vm.submit();
|
||||
expect(w.emitted('save')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('корзина: с причиной эмитит save без даты созвона', async () => {
|
||||
const w = factory(prospect({ stage: 'negotiation' }));
|
||||
const vm = w.vm as unknown as DialogVm;
|
||||
|
||||
vm.action = 'trash';
|
||||
vm.reason = 'не наш профиль';
|
||||
// Дату могли набрать до переключения результата — в корзину она уйти не должна.
|
||||
vm.nextCallAt = '2026-08-05T12:00';
|
||||
await vm.submit();
|
||||
await flushPromises();
|
||||
|
||||
expect(w.emitted('save')?.[0][0]).toEqual({
|
||||
action: 'trash',
|
||||
reason: 'не наш профиль',
|
||||
summary: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('корзина: из корзины можно вернуться — «Зарегистрировался» остаётся доступен', () => {
|
||||
const values = (factory(prospect({ stage: 'trash' })).vm as unknown as DialogVm)
|
||||
.availableActions.map((a) => a.value);
|
||||
expect(values).toContain('registered');
|
||||
});
|
||||
|
||||
it('на карточке в ручном тестировании показывает, куда отправляли тест', () => {
|
||||
const w = factory(prospect({ stage: 'manual_testing', test_channel: 'phone', test_target: '79990001122' }));
|
||||
expect((w.vm as unknown as DialogVm).resultReminder).toBe('Тест отправлен: телефон 79990001122');
|
||||
|
||||
@@ -20,6 +20,7 @@ interface BoardVm {
|
||||
managerId: number | null;
|
||||
load: () => Promise<void>;
|
||||
managerItems: { id: number | null; name: string }[];
|
||||
applyDateFilter: (v: unknown) => Promise<void>;
|
||||
}
|
||||
|
||||
describe('SalesProspectsBoardView', () => {
|
||||
@@ -29,11 +30,24 @@ describe('SalesProspectsBoardView', () => {
|
||||
const w = mount(SalesProspectsBoardView, { global: { plugins: [vuetify] } });
|
||||
await flushPromises();
|
||||
// 'department' — воронка отдела просит весь отдел явно (§23).
|
||||
expect(listProspects).toHaveBeenCalledWith(undefined, undefined, 'department');
|
||||
expect(listProspects).toHaveBeenCalledWith(undefined, undefined, 'department', null);
|
||||
|
||||
(w.vm as unknown as BoardVm).managerId = 7;
|
||||
await (w.vm as unknown as BoardVm).load();
|
||||
expect(listProspects).toHaveBeenLastCalledWith(7, undefined, 'department');
|
||||
expect(listProspects).toHaveBeenLastCalledWith(7, undefined, 'department', null);
|
||||
});
|
||||
|
||||
it('фильтр по датам уходит в запрос, а «Все карточки» его гасят', async () => {
|
||||
const w = mount(SalesProspectsBoardView, { global: { plugins: [vuetify] } });
|
||||
await flushPromises();
|
||||
const vm = w.vm as unknown as BoardVm;
|
||||
|
||||
const todo = { mode: 'todo', period: 'today', from: null, to: null };
|
||||
await vm.applyDateFilter(todo);
|
||||
expect(listProspects).toHaveBeenLastCalledWith(undefined, undefined, 'department', todo);
|
||||
|
||||
await vm.applyDateFilter({ mode: null, period: 'today', from: null, to: null });
|
||||
expect(listProspects).toHaveBeenLastCalledWith(undefined, undefined, 'department', null);
|
||||
});
|
||||
|
||||
it('фильтр происхождения уходит в запрос вторым параметром', async () => {
|
||||
@@ -43,7 +57,7 @@ describe('SalesProspectsBoardView', () => {
|
||||
(w.vm as unknown as BoardVm & { source: string | null }).source = 'manager';
|
||||
await (w.vm as unknown as BoardVm).load();
|
||||
|
||||
expect(listProspects).toHaveBeenLastCalledWith(undefined, 'manager', 'department');
|
||||
expect(listProspects).toHaveBeenLastCalledWith(undefined, 'manager', 'department', null);
|
||||
});
|
||||
|
||||
it('в фильтре у менеджера показывается число его карточек', async () => {
|
||||
|
||||
@@ -38,6 +38,7 @@ const vuetify = createVuetify();
|
||||
interface ViewVm {
|
||||
open: (p: unknown) => Promise<void> | void;
|
||||
createCandidate: (p: Record<string, unknown>) => Promise<void>;
|
||||
applyDateFilter: (v: unknown) => Promise<void>;
|
||||
}
|
||||
|
||||
describe('SalesProspectsView', () => {
|
||||
@@ -53,10 +54,20 @@ describe('SalesProspectsView', () => {
|
||||
it('загружает и показывает карточки менеджера', async () => {
|
||||
const w = mount(SalesProspectsView, { global: { plugins: [vuetify] } });
|
||||
await flushPromises();
|
||||
expect(listProspects).toHaveBeenCalledWith(undefined);
|
||||
expect(listProspects).toHaveBeenCalledWith(undefined, undefined, undefined, null);
|
||||
expect(w.text()).toContain('ООО Демо');
|
||||
});
|
||||
|
||||
it('фильтр по датам уходит в запрос и у менеджера тоже', async () => {
|
||||
const w = mount(SalesProspectsView, { global: { plugins: [vuetify] } });
|
||||
await flushPromises();
|
||||
|
||||
const changed = { mode: 'changed', period: 'today', from: null, to: null };
|
||||
await (w.vm as unknown as ViewVm).applyDateFilter(changed);
|
||||
|
||||
expect(listProspects).toHaveBeenLastCalledWith(undefined, undefined, undefined, changed);
|
||||
});
|
||||
|
||||
it('открытие карточки из «Новые» помечает её взятой в работу (action=opened)', async () => {
|
||||
const w = mount(SalesProspectsView, { global: { plugins: [vuetify] } });
|
||||
await flushPromises();
|
||||
|
||||
@@ -2,13 +2,19 @@ import { describe, expect, it } from 'vitest';
|
||||
import { PROSPECT_STAGES, isOverdue, stageMeta } from '../../resources/js/utils/prospectStages';
|
||||
|
||||
describe('prospectStages', () => {
|
||||
it('11 стадий в правильном порядке: ручное тестирование после переговоров, КП после пользователя', () => {
|
||||
it('12 стадий в правильном порядке: ручное тестирование после переговоров, КП после пользователя, корзина последняя', () => {
|
||||
expect(PROSPECT_STAGES.map((s) => s.key)).toEqual([
|
||||
'new', 'in_work', 'negotiation', 'manual_testing', 'registered', 'testing',
|
||||
'topped_up', 'user', 'kp_sent', 'rejected', 'no_answer',
|
||||
'topped_up', 'user', 'kp_sent', 'rejected', 'no_answer', 'trash',
|
||||
]);
|
||||
});
|
||||
|
||||
it('корзина — ручная стадия с русским именем', () => {
|
||||
const byKey = Object.fromEntries(PROSPECT_STAGES.map((s) => [s.key, s]));
|
||||
expect(byKey.trash.manual).toBe(true);
|
||||
expect(stageMeta('trash').title).toBe('Корзина');
|
||||
});
|
||||
|
||||
it('обе новые стадии — ручные: автомат по деньгам их не ставит', () => {
|
||||
const byKey = Object.fromEntries(PROSPECT_STAGES.map((s) => [s.key, s]));
|
||||
expect(byKey.manual_testing.manual).toBe(true);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -2524,4 +2524,3 @@ zakryt
|
||||
МТТ
|
||||
Скартел
|
||||
админский
|
||||
админский
|
||||
|
||||
@@ -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` | Карточка «Центр дентальной имплантации», результат «Выслано КП», обведено лишнее обязательное поле «Следующий созвон» |
|
||||
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 88 KiB |
@@ -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. Всё то же самое в «Воронке отдела» у начальника.
|
||||