Files
portal/app/tests/Feature/Sales/ProspectWarmingBadgeTest.php
T
Дмитрий 80c9ca7e67 feat(витрина B1,B2,B4,B5): летопись эпизодов прогрева + значки в воронке; уборка мёртвых скоупов
Кусок B «витрина прогрева» (спека 2026-07-21 §6), решение владельца — полная летопись:
- B1: таблица sales_ad_audience_warming_episodes + модель + WarmingEpisodeRecorder
  (open/close/record идемпотентно). Схема v8.85, бэкфилл из firm_channels(warming)
  и боевых СМС, guard по источнику. rls-reviewer OK 8/8.
- B2: «Греть»/«Убрать» на площадке открывают/закрывают эпизоды канала.
- B4: warmingByProspect считает значки из летописи (live/count вместо массива каналов),
  тип WarmingBadgeState в sales.ts.
- B5: единый компонент WarmingBadges.vue (идёт/грели раньше/×N) в канбане;
  осиротевший WarmingChannelIcons удалён.
Уборка: убраны мёртвые скоупы forYandex/Vk/Mts + их импорт + тест (боевых вызовов нет).
cspell: +5 пре-существующих слов CHANGELOG в словарь (apk/cvtjpq/hgq/sar/sca).

Проверено: Sales 427/427, composer stan 0, pint/prettier чисто, весь Vue-набор зелёный.
B3 (СМС→эпизод) — отдельным коммитом (СМС-блок правит и параллельная сессия).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 13:54:09 +03:00

120 lines
4.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\SalesAdAudienceFirm;
use App\Models\SalesAdAudienceWarmingEpisode;
use App\Models\SalesProspect;
use App\Models\SalesUser;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
/**
* Витрина (кусок B, B4): карточка проспекта в GET /api/sales/prospects несёт блок
* `warming`, считанный из ЛЕТОПИСИ эпизодов (sales_ad_audience_warming_episodes):
*
* warming.active — есть ли хоть один открытый (идущий) эпизод
* warming.channels[code].live — открытый эпизод канала = «идёт сейчас»
* warming.channels[code].count — число эпизодов канала = «×N» (грели N раз)
*
* Фирма (pgsql_supplier) связывается с проспектом (default) через prospect_id —
* сопоставление в PHP (warmingByProspect). SharesSupplierPdo обязателен.
*
* План: docs/superpowers/plans/2026-07-23-progrev-vitrina-kusok-B.md Task B4.
*/
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
function pwb_manager(): SalesUser
{
return SalesUser::create([
'name' => 'Менеджер '.uniqid(),
'email' => 'pwb'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'manager',
'is_active' => true,
]);
}
/** @return array{0: SalesProspect, 1: SalesAdAudienceFirm} */
function pwb_firm(SalesUser $manager, string $name): array
{
$prospect = SalesProspect::create([
'sales_user_id' => $manager->id,
'stage' => 'new',
'source' => 'search',
'firm_name' => $name,
]);
$firm = SalesAdAudienceFirm::create([
'firm_name' => $name,
'warmup_started_at' => now()->subDays(2),
'prospect_id' => $prospect->id,
'assigned_at' => now(),
]);
return [$prospect, $firm];
}
it('открытый эпизод канала → значок «идёт сейчас» (live=true, active=true)', function () {
$manager = pwb_manager();
[, $firm] = pwb_firm($manager, 'Гарант');
foreach (['yandex', 'vk'] as $ch) {
SalesAdAudienceWarmingEpisode::create([
'firm_id' => $firm->id, 'channel' => $ch, 'started_at' => now()->subDays(2),
]);
}
$row = $this->actingAs($manager, 'sales')
->getJson('/api/sales/prospects')->assertOk()->json('prospects.0');
expect($row['warming']['active'])->toBeTrue()
->and($row['warming']['channels']['yandex']['live'])->toBeTrue()
->and($row['warming']['channels']['yandex']['count'])->toBe(1)
->and($row['warming']['channels']['vk']['live'])->toBeTrue();
});
it('только закрытый эпизод → «грели раньше» (live=false, active=false, count сохраняется)', function () {
$manager = pwb_manager();
[, $firm] = pwb_firm($manager, 'Былое');
SalesAdAudienceWarmingEpisode::create([
'firm_id' => $firm->id, 'channel' => 'yandex',
'started_at' => now()->subDays(10), 'ended_at' => now()->subDays(3),
]);
$row = $this->actingAs($manager, 'sales')
->getJson('/api/sales/prospects')->assertOk()->json('prospects.0');
expect($row['warming']['active'])->toBeFalse()
->and($row['warming']['channels']['yandex']['live'])->toBeFalse()
->and($row['warming']['channels']['yandex']['count'])->toBe(1);
});
it('два эпизода СМС → count=2, live=false (СМС ×2, разовая акция)', function () {
$manager = pwb_manager();
[, $firm] = pwb_firm($manager, 'Новодент');
foreach ([now()->subDays(5), now()->subDay()] as $t) {
SalesAdAudienceWarmingEpisode::create([
'firm_id' => $firm->id, 'channel' => 'sms', 'started_at' => $t, 'ended_at' => $t,
]);
}
$row = $this->actingAs($manager, 'sales')
->getJson('/api/sales/prospects')->assertOk()->json('prospects.0');
expect($row['warming']['channels']['sms']['count'])->toBe(2)
->and($row['warming']['channels']['sms']['live'])->toBeFalse();
});
it('нет эпизодов → пустой прогрев (active=false, каналов нет)', function () {
$manager = pwb_manager();
pwb_firm($manager, 'Пусто');
$row = $this->actingAs($manager, 'sales')
->getJson('/api/sales/prospects')->assertOk()->json('prospects.0');
expect($row['warming']['active'])->toBeFalse()
->and($row['warming']['channels'])->toBe([]);
});