feat(прогрев): разъезд API «Реклама на кандидатов» по площадкам {platform}

GET/PATCH /api/sales/warming/{platform}, GET .../firms, POST .../toggle
и .../assign заменяют общую /api/sales/ad-audience*; настройки и статус
площадки читаются из sales_ad_audience_platforms, членство фирмы —
по ch_yandex/ch_vk/ch_mts. Метод channels() (массовый мультивыбор)
убран — заменён точечным toggle. Неизвестная площадка → 404 через
platformOr404() в контроллере (не через route whereIn — иначе
непойманный сегмент проваливается в Route::fallback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-21 11:11:39 +03:00
parent a04662898e
commit ec6bdbfb19
3 changed files with 244 additions and 98 deletions
@@ -7,62 +7,77 @@ namespace App\Http\Controllers\Api\Sales;
use App\Http\Controllers\Controller;
use App\Models\SalesAdAudienceFirm;
use App\Models\SalesAdAudiencePhone;
use App\Models\SalesAdAudiencePlatform;
use App\Models\SalesAdAudienceState;
use App\Models\SalesProspect;
use App\Models\SalesUser;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Экран «Реклама на кандидатов» в кабинете начальника: состояние, рубильник,
* список фирм на прогреве и назначение менеджера.
* Экран «Реклама на кандидатов» в кабинете начальника по площадкам
* (yandex | vk | mts): состояние, рубильник, список фирм на прогреве,
* переключатель членства фирмы и назначение менеджера.
*
* GET /api/sales/ad-audience три числа + настройки (11 сроков) + метка синхронизации
* PATCH /api/sales/ad-audience рубильник (enabled) и сроки (days + 11 настраиваемых)
* GET /api/sales/ad-audience/firms список фирм на прогреве и в рекламе
* POST /api/sales/ad-audience/firms/{id}/assign начальник назначает менеджера, рождается карточка воронки
* GET /api/sales/warming/{platform} числа + настройки площадки + метка синхронизации
* PATCH /api/sales/warming/{platform} рубильник площадки (enabled) и сроки (days + 11 настраиваемых, пока общие)
* GET /api/sales/warming/{platform}/firms список фирм, подписанных именно на эту площадку
* POST /api/sales/warming/{platform}/firms/{id}/toggle включить/выключить фирму на площадке
* POST /api/sales/warming/{platform}/firms/{id}/assign начальник назначает менеджера, рождается карточка воронки
* GET /api/sales/warming/mts/file выгрузка номеров для ручной загрузки в МТС
*
* Все методы ТОЛЬКО начальник (role=head), гейт зеркалит SalesManagersController.
* Менеджер 403.
* Менеджер 403. Неизвестная площадка (не yandex/vk/mts) 404.
*
* Номера сюда не добавляются только кнопкой «Отправить в рекламу» в поиске клиентов
* (сервис-канал SalesIntegrationController::ingestAdAudience). Сам список не растёт.
* Членство фирмы в площадке булевы ch_yandex/ch_vk/ch_mts на sales_ad_audience_firms;
* настройки и статус площадки построчно в sales_ad_audience_platforms
* (App\Models\SalesAdAudiencePlatform). days и 11 сроков воронки в этом шаге
* остаются ОБЩИМИ на sales_ad_audience_state.
*
* Модели прибиты к соединению pgsql_supplier, поэтому маршрут работает независимо
* от admin-db-соединения остального кабинета. SalesProspect живёт на дефолтном
* соединении JOIN между ними невозможен, сопоставление по prospect_id в PHP.
*
* План: docs/superpowers/plans/2026-07-19-reklamnaya-auditoriya-v2.md (Task 7)
* План: docs/superpowers/plans/2026-07-19-reklamnaya-auditoriya-v2.md (Task 7);
* разъезд на площадки план трёх площадок (Task 4+5).
*/
class SalesAdAudienceController extends Controller
{
/**
* GET /api/sales/ad-audience состояние рекламной аудитории (только head).
* GET /api/sales/warming/{platform} состояние площадки (только head).
*/
public function show(Request $request): JsonResponse
public function show(Request $request, string $platform): JsonResponse
{
if (($resp = $this->denyIfNotHead($request)) !== null) {
return $resp;
}
return response()->json(['data' => $this->state()]);
$p = $this->platformOr404($platform);
return response()->json(['data' => $this->platformState($p)]);
}
/**
* PATCH /api/sales/ad-audience рубильник и срок хранения (только head).
* PATCH /api/sales/warming/{platform} рубильник площадки и срок хранения (только head).
*
* Срок меняется только для НОВЫХ номеров: у уже принятых expires_at
* зафиксирован на момент приёма (иначе непонятно, когда что выйдет).
* days и 11 настраиваемых сроков в этом шаге ОБЩИЕ на все площадки.
*/
public function update(Request $request): JsonResponse
public function update(Request $request, string $platform): JsonResponse
{
if (($resp = $this->denyIfNotHead($request)) !== null) {
return $resp;
}
$p = $this->platformOr404($platform);
$rules = [
'enabled' => ['sometimes', 'boolean'],
'days' => ['sometimes', 'integer', 'between:1,365'],
@@ -74,25 +89,36 @@ class SalesAdAudienceController extends Controller
$data = $request->validate($rules);
$state = SalesAdAudienceState::current();
$state->fill($data);
$state->updated_at = Carbon::now();
$state->save();
if (array_key_exists('enabled', $data)) {
$p->update(['enabled' => (bool) $data['enabled'], 'updated_at' => Carbon::now()]);
unset($data['enabled']);
}
return response()->json(['data' => $this->state()]);
if ($data !== []) {
$state = SalesAdAudienceState::current();
$state->fill($data);
$state->updated_at = Carbon::now();
$state->save();
}
return response()->json(['data' => $this->platformState($p)]);
}
/**
* GET /api/sales/ad-audience/firms список фирм на прогреве и в рекламе
* (только head). Прогретые и ждущие менеджера наверх списка.
* GET /api/sales/warming/{platform}/firms список фирм, подписанных именно
* на эту площадку (только head). Прогретые и ждущие менеджера наверх списка.
*/
public function firms(Request $request): JsonResponse
public function firms(Request $request, string $platform): JsonResponse
{
if (($resp = $this->denyIfNotHead($request)) !== null) {
return $resp;
}
$this->platformOr404($platform);
$column = $this->channelColumn($platform);
$firms = SalesAdAudienceFirm::query()
->where($column, true)
->with('phones')
->orderByRaw('ready_at IS NULL')
->orderByDesc('warmup_started_at')
@@ -105,35 +131,34 @@ class SalesAdAudienceController extends Controller
->keyBy('id');
return response()->json([
'firms' => $firms->map(function (SalesAdAudienceFirm $firm) use ($prospects): array {
$prospect = $firm->prospect_id !== null ? $prospects->get($firm->prospect_id) : null;
$phone = $firm->phones->first();
return [
'id' => $firm->id,
'firm_name' => $firm->firm_name,
'ch_yandex' => $firm->ch_yandex,
'ch_vk' => $firm->ch_vk,
'ch_mts' => $firm->ch_mts,
'city' => $firm->city,
'phones_count' => $firm->phones->count(),
'state' => $phone === null ? 'stopped' : $phone->state,
'stop_reason' => $firm->stop_reason,
'resume_at' => $phone?->resume_at,
'days_in_ads' => (int) $firm->warmup_started_at->diffInDays(now()),
'prospect_id' => $firm->prospect_id,
'stage' => $prospect?->stage,
'assigned' => $firm->assigned_at !== null,
];
})->all(),
'firms' => $firms->map(fn (SalesAdAudienceFirm $firm): array => $this->firmRow($firm, $prospects))->all(),
]);
}
/**
* POST /api/sales/ad-audience/firms/{firm}/assign начальник решил, что
* POST /api/sales/warming/{platform}/firms/{firm}/toggle включить/выключить
* фирму на площадке (только head).
*/
public function toggle(Request $request, string $platform, int $firm): JsonResponse
{
if (($resp = $this->denyIfNotHead($request)) !== null) {
return $resp;
}
$this->platformOr404($platform);
$data = $request->validate(['on' => ['required', 'boolean']]);
SalesAdAudienceFirm::whereKey($firm)->update([$this->channelColumn($platform) => (bool) $data['on']]);
return response()->json(['ok' => true]);
}
/**
* POST /api/sales/warming/{platform}/firms/{firm}/assign начальник решил, что
* человек созрел: заводим карточку в воронке из снимка фирмы (только head).
*/
public function assign(Request $request, int $firm): JsonResponse
public function assign(Request $request, string $platform, int $firm): JsonResponse
{
if (($resp = $this->denyIfNotHead($request)) !== null) {
return $resp;
@@ -174,38 +199,7 @@ class SalesAdAudienceController extends Controller
}
/**
* POST /api/sales/ad-audience/channels массовая простановка площадок (только head).
*
* Три независимые галочки. Состав в Яндексе и ВК подтянется сам ближайшей
* ночной заливкой джобы фильтруют номера по этим полям. Для МТС
* программного доступа нет: там начальник скачивает файл и грузит руками.
*/
public function channels(Request $request): JsonResponse
{
if (($resp = $this->denyIfNotHead($request)) !== null) {
return $resp;
}
/** @var array{firm_ids: list<int>, ch_yandex: bool, ch_vk: bool, ch_mts: bool} $data */
$data = $request->validate([
'firm_ids' => ['required', 'array', 'min:1'],
'firm_ids.*' => ['required', 'integer'],
'ch_yandex' => ['required', 'boolean'],
'ch_vk' => ['required', 'boolean'],
'ch_mts' => ['required', 'boolean'],
]);
$updated = SalesAdAudienceFirm::whereIn('id', $data['firm_ids'])->update([
'ch_yandex' => $data['ch_yandex'],
'ch_vk' => $data['ch_vk'],
'ch_mts' => $data['ch_mts'],
]);
return response()->json(['updated' => $updated]);
}
/**
* GET /api/sales/ad-audience/mts-file список номеров для загрузки в МТС (только head).
* GET /api/sales/warming/mts/file список номеров для загрузки в МТС (только head).
*
* У МТС Маркетолога нет программного доступа к рекламе в Telegram: их REST API
* умеет только SMS. Поэтому портал отдаёт готовый файл, а начальник загружает
@@ -245,39 +239,89 @@ class SalesAdAudienceController extends Controller
// ── private ──────────────────────────────────────────────────────────────
/**
* Снимок состояния для экрана: настройки + три числа по живым номерам.
* Строка площадки по коду или 404, если код не yandex/vk/mts.
*/
private function platformOr404(string $platform): SalesAdAudiencePlatform
{
abort_unless(in_array($platform, SalesAdAudiencePlatform::CODES, true), 404);
return SalesAdAudiencePlatform::forCode($platform);
}
/**
* Булева колонка членства фирмы в площадке на sales_ad_audience_firms.
*/
private function channelColumn(string $platform): string
{
return ['yandex' => 'ch_yandex', 'vk' => 'ch_vk', 'mts' => 'ch_mts'][$platform];
}
/**
* Снимок состояния площадки для экрана: настройки площадки + сроки воронки
* (пока общие) + три числа по живым номерам подписанных на неё фирм.
*
* @return array<string, mixed>
*/
private function state(): array
private function platformState(SalesAdAudiencePlatform $p): array
{
$state = SalesAdAudienceState::current();
$now = Carbon::now();
$column = $this->channelColumn($p->platform);
// «Живой» номер: ещё не убран из сегмента и срок не вышел.
$live = static fn (): Builder => SalesAdAudiencePhone::query()
// «Живой» номер: ещё не убран из сегмента, срок не вышел, фирма подписана на площадку.
$live = fn (): Builder => SalesAdAudiencePhone::query()
->whereNull('removed_at')
->where('expires_at', '>', $now);
->where('expires_at', '>', $now)
->whereIn('firm_id', SalesAdAudienceFirm::query()->where($column, true)->select('id'));
return [
'enabled' => $state->enabled,
'platform' => $p->platform,
'enabled' => $p->enabled,
'min_phones' => $p->min_phones,
'external_id' => $p->external_id,
'last_synced_at' => $p->last_synced_at,
'last_error' => $p->last_error,
'status' => $p->status,
'days' => $state->days,
'durations' => $state->durations(),
'segment_id' => $state->yandex_segment_id,
'last_synced_at' => $state->last_synced_at,
'last_error' => $state->last_error,
'vk_status' => $state->vk_status,
'vk_last_synced_at' => $state->vk_last_synced_at,
'vk_last_error' => $state->vk_last_error,
// В рекламе сейчас.
'in_ads' => $live()->count(),
// Выходят на этой неделе — срок кончается в ближайшие 7 дней.
'expiring_week' => $live()->where('expires_at', '<', $now->copy()->addWeek())->count(),
// Ждут отправки — приняты, но ночной джоб ещё не отдал их Яндексу.
// Ждут отправки — приняты, но ночной джоб ещё не отдал их площадке.
'waiting_sync' => $live()->whereNull('synced_at')->count(),
];
}
/**
* Одна строка списка фирм на прогреве как отдаём в JSON.
*
* @param Collection<int, SalesProspect> $prospects
* @return array<string, mixed>
*/
private function firmRow(SalesAdAudienceFirm $firm, Collection $prospects): array
{
$prospect = $firm->prospect_id !== null ? $prospects->get($firm->prospect_id) : null;
$phone = $firm->phones->first();
return [
'id' => $firm->id,
'firm_name' => $firm->firm_name,
'ch_yandex' => $firm->ch_yandex,
'ch_vk' => $firm->ch_vk,
'ch_mts' => $firm->ch_mts,
'city' => $firm->city,
'phones_count' => $firm->phones->count(),
'state' => $phone === null ? 'stopped' : $phone->state,
'stop_reason' => $firm->stop_reason,
'resume_at' => $phone?->resume_at,
'days_in_ads' => (int) $firm->warmup_started_at->diffInDays(now()),
'prospect_id' => $firm->prospect_id,
'stage' => $prospect?->stage,
'assigned' => $firm->assigned_at !== null,
];
}
/**
* Гейт «только начальник». Возвращает 403-ответ, либо null если доступ есть.
*/
+12 -9
View File
@@ -303,16 +303,19 @@ Route::middleware(['admin-db', 'auth:sales', 'sales-portal'])->prefix('api/sales
// Task 7.1a: заведение менеджеров начальником + список аккаунтов (только head).
Route::get('/managers', [SalesManagersController::class, 'index']);
Route::post('/managers', [SalesManagersController::class, 'store']);
// Task 8: «Реклама на кандидатов» — состояние и рубильник (только head).
Route::get('/ad-audience', [SalesAdAudienceController::class, 'show']);
Route::patch('/ad-audience', [SalesAdAudienceController::class, 'update']);
// Task 7 (план v2): список фирм на прогреве + назначение менеджера (только head).
Route::get('/ad-audience/firms', [SalesAdAudienceController::class, 'firms']);
Route::post('/ad-audience/firms/{firm}/assign', [SalesAdAudienceController::class, 'assign'])->whereNumber('firm');
// Task 5 (план выбора площадки прогрева): массовая смена площадки (только head).
Route::post('/ad-audience/channels', [SalesAdAudienceController::class, 'channels']);
// Task 4+5 (план трёх площадок): «Реклама на кандидатов» по площадкам yandex|vk|mts
// (только head). Неизвестную площадку контроллер отвергает сам (platformOr404 → 404
// JSON) — без whereIn на маршруте, иначе непойманный сегмент проваливается
// в Route::fallback (SPA welcome-view) вместо чистого 404-ответа API.
Route::get('/warming/{platform}', [SalesAdAudienceController::class, 'show']);
Route::patch('/warming/{platform}', [SalesAdAudienceController::class, 'update']);
Route::get('/warming/{platform}/firms', [SalesAdAudienceController::class, 'firms']);
Route::post('/warming/{platform}/firms/{firm}/toggle', [SalesAdAudienceController::class, 'toggle'])->whereNumber('firm');
Route::post('/warming/{platform}/firms/{firm}/assign', [SalesAdAudienceController::class, 'assign'])->whereNumber('firm');
// Task 3 (план трёх площадок): выгрузка списка номеров файлом для МТС (только head).
Route::get('/ad-audience/mts-file', [SalesAdAudienceController::class, 'mtsFile']);
// Регистрируем ДО /warming/{platform}/firms — иначе .../firms не перепутается,
// но mts/file имеет другое число сегментов, поэтому порядок не критичен.
Route::get('/warming/mts/file', [SalesAdAudienceController::class, 'mtsFile']);
// Task 9 (план «Прогрев СМС»): список рассылок, предпросмотр цены, запуск (только head).
Route::get('/sms/campaigns', [SalesSmsController::class, 'index']);
Route::post('/sms/campaigns', [SalesSmsController::class, 'store']);
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
use App\Models\SalesAdAudienceFirm;
use App\Models\SalesAdAudiencePlatform;
use App\Models\SalesUser;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
/**
* API «Реклама на кандидатов» по площадкам (Task 4+5 плана трёх площадок).
*
* Одна общая ручка /api/sales/ad-audience разъезжается на три:
* /api/sales/warming/{yandex|vk|mts}. Настройки и статус построчно
* в sales_ad_audience_platforms; days/сроки воронки пока общие
* (sales_ad_audience_state). Членство фирмы в площадке булевы
* ch_yandex/ch_vk/ch_mts на sales_ad_audience_firms.
*
* Аутентификация как в AdAudienceScreenTest: SalesUser::create() + actingAs('sales')
* (у SalesUser нет HasFactory/factory()).
*/
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
function actingHead(): SalesUser
{
$head = SalesUser::create([
'name' => 'Начальник '.uniqid(),
'email' => 'warminghead'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'head',
'is_active' => true,
]);
test()->actingAs($head, 'sales');
return $head;
}
it('отдаёт настройки именно запрошенной площадки', function () {
actingHead();
SalesAdAudiencePlatform::forCode('vk')->update(['status' => 'waiting_volume']);
test()->getJson('/api/sales/warming/vk')
->assertOk()
->assertJsonPath('data.platform', 'vk')
->assertJsonPath('data.min_phones', 2000)
->assertJsonPath('data.status', 'waiting_volume');
});
it('неизвестную площадку отвергает', function () {
actingHead();
test()->getJson('/api/sales/warming/facebook')->assertNotFound();
});
it('рубильник пишется в строку площадки', function () {
actingHead();
test()->patchJson('/api/sales/warming/vk', ['enabled' => true])->assertOk();
expect(SalesAdAudiencePlatform::forCode('vk')->enabled)->toBeTrue();
});
it('менеджеру доступ закрыт', function () {
$m = SalesUser::create([
'name' => 'Менеджер '.uniqid(),
'email' => 'warmingmgr'.uniqid().'@sales.local',
'password' => bcrypt('secret'),
'role' => 'manager',
'is_active' => true,
]);
test()->actingAs($m, 'sales');
test()->getJson('/api/sales/warming/yandex')->assertForbidden();
});
it('в список площадки попадают только её подписанные фирмы', function () {
actingHead();
SalesAdAudienceFirm::create(['firm_name' => 'Только Яндекс', 'ch_yandex' => true]);
SalesAdAudienceFirm::create(['firm_name' => 'Только ВК', 'ch_vk' => true]);
test()->getJson('/api/sales/warming/vk/firms')
->assertOk()
->assertJsonCount(1, 'firms')
->assertJsonPath('firms.0.firm_name', 'Только ВК');
});
it('переключатель добавляет и убирает фирму с площадки', function () {
actingHead();
$firm = SalesAdAudienceFirm::create(['firm_name' => 'Плавающая', 'ch_vk' => false]);
test()->postJson("/api/sales/warming/vk/firms/{$firm->id}/toggle", ['on' => true])->assertOk();
expect($firm->fresh()->ch_vk)->toBeTrue();
test()->postJson("/api/sales/warming/vk/firms/{$firm->id}/toggle", ['on' => false])->assertOk();
expect($firm->fresh()->ch_vk)->toBeFalse();
});