feat(прогрев): блок warming (active + каналы) в ответе по проспектам

Task 4 плана «три площадки прогрева» кусок B: GET /api/sales/prospects
теперь отдаёт warming.active/warming.channels на каждой карточке —
сопоставление SalesAdAudienceFirm.prospect_id в PHP (pgsql_supplier
против default connection), без cross-connection JOIN. Канал sms —
если номер фирмы попал в отправленную (status=sent) рассылку.
This commit is contained in:
Дмитрий
2026-07-21 16:16:41 +03:00
parent 71c93477b8
commit fc16500fea
3 changed files with 203 additions and 3 deletions
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\Sales;
use App\Http\Controllers\Controller;
use App\Models\SalesAdAudienceFirm;
use App\Models\SalesProspect;
use App\Models\SalesProspectNote;
use App\Models\SalesUser;
@@ -62,7 +63,9 @@ class SalesProspectController extends Controller
$query->where('source', (string) $request->query('source'));
}
$prospects = $query->get()->map(fn (SalesProspect $p) => $this->row($p))->all();
$all = $query->get();
$warmingByProspect = $this->warmingByProspect($all->pluck('id')->all());
$prospects = $all->map(fn (SalesProspect $p) => $this->row($p, $warmingByProspect))->all();
// Группировка по стадии (для колонок), все ключи присутствуют.
$byStage = array_fill_keys(self::STAGES, []);
@@ -520,8 +523,86 @@ class SalesProspectController extends Controller
$prospect->reason = null;
}
/** @return array<string,mixed> */
private function row(SalesProspect $p): array
/**
* Состояние прогрева по проспектам: одна выборка фирм по prospect_id.
* Фирмы на pgsql_supplier, проспекты на дефолтном, сопоставление в PHP
* (JOIN между соединениями невозможен). Используется только в index()
* канбан воронки; show/store/update отдают безобидный дефолт через row().
*
* @param array<int, int> $prospectIds
* @return array<int, array{active: bool, channels: array<int, string>}>
*/
private function warmingByProspect(array $prospectIds): array
{
if ($prospectIds === []) {
return [];
}
$firms = SalesAdAudienceFirm::query()
->with('phones')
->whereIn('prospect_id', $prospectIds)
->get();
// sms: номера фирм, попавшие в реально отправленную рассылку (status='sent';
// 'fake_sent' — песочница, в счёт не идёт).
$phoneToProspect = [];
foreach ($firms as $firm) {
foreach ($firm->phones as $ph) {
$phoneToProspect[$ph->phone] = $firm->prospect_id;
}
}
$smsProspectIds = [];
if ($phoneToProspect !== []) {
$sent = SalesAdAudienceFirm::query()->getConnection()
->table('sales_sms_messages')
->where('status', 'sent')
->whereIn('phone', array_keys($phoneToProspect))
->distinct()
->pluck('phone');
foreach ($sent as $phone) {
if (isset($phoneToProspect[$phone])) {
$smsProspectIds[$phoneToProspect[$phone]] = true;
}
}
}
$out = [];
foreach ($firms as $firm) {
if ($firm->prospect_id === null) {
continue;
}
$onAny = $firm->ch_yandex || $firm->ch_vk || $firm->ch_mts;
$hasActivePhone = $firm->phones->contains(fn ($p): bool => $p->state === 'active');
$channels = [];
if ($firm->ch_yandex) {
$channels[] = 'yandex';
}
if ($firm->ch_vk) {
$channels[] = 'vk';
}
if ($firm->ch_mts) {
$channels[] = 'mts';
}
if (isset($smsProspectIds[$firm->prospect_id])) {
$channels[] = 'sms';
}
$out[$firm->prospect_id] = [
'active' => $onAny && $firm->stopped_at === null && $hasActivePhone,
'channels' => $channels,
];
}
return $out;
}
/**
* @param array<int, array{active: bool, channels: array<int, string>}> $warmingByProspect
* @return array<string,mixed>
*/
private function row(SalesProspect $p, array $warmingByProspect = []): array
{
return [
'id' => $p->id,
@@ -541,6 +622,7 @@ class SalesProspectController extends Controller
'reason' => $p->reason,
'registered_email' => $p->registered_email,
'notes' => $p->notes,
'warming' => $warmingByProspect[$p->id] ?? ['active' => false, 'channels' => []],
];
}
}
+6
View File
@@ -3954,6 +3954,12 @@ parameters:
count: 6
path: tests/Feature/Sales/SalesIntegrationApiTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Sales/ProspectWarmingBadgeTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
use App\Models\SalesAdAudienceFirm;
use App\Models\SalesAdAudiencePhone;
use App\Models\SalesProspect;
use App\Models\SalesUser;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
/**
* Task 4 плана «кусок : карточка проспекта в ответе GET /api/sales/prospects
* несёт блок `warming` (активен ли прогрев + список каналов), чтобы канбан
* воронки мог показать «греется/прогрет». Фирма (pgsql_supplier) связывается
* с проспектом (default connection) через SalesAdAudienceFirm.prospect_id
* JOIN между соединениями невозможен, сопоставление в PHP (SalesProspectController::warmingByProspect).
*
* SharesSupplierPdo обязателен см. AdAudienceWarmingFieldsTest.
*
* План: docs/superpowers/plans/2026-07-21-progrev-4-stranicy-portal.md (кусок B, Task 4).
*/
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,
]);
}
it('карточка проспекта несёт состояние прогрева и каналы', function () {
$manager = pwb_manager();
$prospect = SalesProspect::create([
'sales_user_id' => $manager->id,
'stage' => 'new',
'source' => 'search',
'firm_name' => 'Гарант',
]);
$firm = SalesAdAudienceFirm::create([
'firm_inn' => '6678048177',
'firm_name' => 'Гарант',
'warmup_started_at' => now()->subDays(2),
'ch_yandex' => true,
'ch_vk' => true,
'prospect_id' => $prospect->id,
'assigned_at' => now(),
]);
SalesAdAudiencePhone::create([
'phone' => '79000000002',
'firm_id' => $firm->id,
'firm_inn' => '6678048177',
'state' => 'active',
'expires_at' => now()->addDays(5),
'added_at' => now(),
]);
$row = $this->actingAs($manager, 'sales')
->getJson('/api/sales/prospects')
->assertOk()
->json('prospects.0');
expect($row['warming']['active'])->toBeTrue();
expect($row['warming']['channels'])->toContain('yandex');
expect($row['warming']['channels'])->toContain('vk');
});
it('карточка проспекта показывает канал sms, если номер попал в отправленную рассылку', function () {
$manager = pwb_manager();
$prospect = SalesProspect::create([
'sales_user_id' => $manager->id,
'stage' => 'new',
'source' => 'search',
'firm_name' => 'Новодент',
]);
$firm = SalesAdAudienceFirm::create([
'firm_inn' => '6673240656',
'firm_name' => 'Новодент',
'warmup_started_at' => now()->subDay(),
'ch_yandex' => true,
'prospect_id' => $prospect->id,
'assigned_at' => now(),
]);
SalesAdAudiencePhone::create([
'phone' => '79220216061',
'firm_id' => $firm->id,
'firm_inn' => '6673240656',
'state' => 'active',
'expires_at' => now()->addDays(10),
'added_at' => now(),
]);
$campaignId = DB::connection('pgsql_supplier')->table('sales_sms_campaigns')->insertGetId([
'title' => 'т', 'body' => 'т', 'status' => 'done', 'created_by' => $manager->id,
'created_at' => now(), 'updated_at' => now(),
]);
DB::connection('pgsql_supplier')->table('sales_sms_messages')->insert([
'campaign_id' => $campaignId, 'phone' => '79220216061', 'status' => 'sent',
]);
$row = $this->actingAs($manager, 'sales')
->getJson('/api/sales/prospects')
->assertOk()
->json('prospects.0');
expect($row['warming']['channels'])->toContain('sms');
});