From ec6bdbfb1978ec753196ad3f618f00ab5664aaa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 21 Jul 2026 11:11:39 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B5=D0=B2?= =?UTF-8?q?):=20=D1=80=D0=B0=D0=B7=D1=8A=D0=B5=D0=B7=D0=B4=20API=20=C2=AB?= =?UTF-8?q?=D0=A0=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC=D0=B0=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BD=D0=B4=D0=B8=D0=B4=D0=B0=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=C2=BB=20=D0=BF=D0=BE=20=D0=BF=D0=BB=D0=BE=D1=89=D0=B0=D0=B4?= =?UTF-8?q?=D0=BA=D0=B0=D0=BC=20{platform}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Api/Sales/SalesAdAudienceController.php | 222 +++++++++++------- app/routes/web.php | 21 +- .../Sales/AdAudiencePlatformApiTest.php | 99 ++++++++ 3 files changed, 244 insertions(+), 98 deletions(-) create mode 100644 app/tests/Feature/Sales/AdAudiencePlatformApiTest.php diff --git a/app/app/Http/Controllers/Api/Sales/SalesAdAudienceController.php b/app/app/Http/Controllers/Api/Sales/SalesAdAudienceController.php index fc2da7e0..b79f6692 100644 --- a/app/app/Http/Controllers/Api/Sales/SalesAdAudienceController.php +++ b/app/app/Http/Controllers/Api/Sales/SalesAdAudienceController.php @@ -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, 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 */ - 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 $prospects + * @return array + */ + 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 если доступ есть. */ diff --git a/app/routes/web.php b/app/routes/web.php index 179c9616..65ca4afc 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -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']); diff --git a/app/tests/Feature/Sales/AdAudiencePlatformApiTest.php b/app/tests/Feature/Sales/AdAudiencePlatformApiTest.php new file mode 100644 index 00000000..4ea1ec67 --- /dev/null +++ b/app/tests/Feature/Sales/AdAudiencePlatformApiTest.php @@ -0,0 +1,99 @@ + 'Начальник '.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(); +});