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:
@@ -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' => []],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 плана «кусок B»: карточка проспекта в ответе 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');
|
||||
});
|
||||
Reference in New Issue
Block a user