80c9ca7e67
Кусок 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>
120 lines
4.6 KiB
PHP
120 lines
4.6 KiB
PHP
<?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([]);
|
||
});
|