diff --git a/app/app/Http/Controllers/Api/AdvertisingCampaignMessageController.php b/app/app/Http/Controllers/Api/AdvertisingCampaignMessageController.php index c8f1b256..4e78c942 100644 --- a/app/app/Http/Controllers/Api/AdvertisingCampaignMessageController.php +++ b/app/app/Http/Controllers/Api/AdvertisingCampaignMessageController.php @@ -5,13 +5,18 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Mail\AdDocumentAttachedMail; use App\Models\AdCampaign; use App\Models\AdCampaignMessage; +use App\Services\Advertising\CampaignMessageService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpFoundation\StreamedResponse; +use Throwable; /** * Лента сообщений по рекламной кампании — окно передачи между Яндексом и клиентом. @@ -95,9 +100,56 @@ class AdvertisingCampaignMessageController extends Controller $message = AdCampaignMessage::create($attributes); + if ($file instanceof UploadedFile) { + $this->tellTruthAboutDocument($campaign, $message, $body); + } + return response()->json(['id' => (int) $message->id], 201); } + /** + * Правда про приложенный документ — клиенту в ленту, владельцу письмом. + * + * 🔴 Замысел предполагал, что документ отвезёт робот прямо в кабинет Яндекса. **Такой + * дороги нет** — проверено двумя нарочными отказами 28.07.2026, обычной тематикой + * и лицензируемой: в окне отказа ноль полей для файла, документы Яндекс принимает + * только снаружи кабинета (чат поддержки, форма обратной связи). + * + * Молчать про это нельзя. Клиент, приложивший лицензию, будет ждать ответа Яндекса, + * которого не будет: файл просто ляжет на диск. Поэтому говорим прямо и зовём живого + * человека — иначе «разберёмся вручную» было бы пустым обещанием. + * + * Всё внутри под Throwable: отметка и письмо — дело второстепенное, а принятый документ + * клиента нет. Беда с почтой не должна возвращать клиенту отказ на успешно принятый файл. + */ + private function tellTruthAboutDocument(AdCampaign $campaign, AdCampaignMessage $message, string $comment): void + { + try { + app(CampaignMessageService::class)->postSystem( + $campaign, + 'Документ получен и сохранён у нас. Передать его Яндексу автоматически нельзя — ' + .'он принимает документы только от человека. Если по вашему отказу документ нужен, ' + .'мы отнесём его сами и напишем здесь.', + ); + + $to = (string) config('services.monitoring.alert_email'); + + if ($to !== '') { + Mail::to($to)->queue(new AdDocumentAttachedMail( + (string) $campaign->name, + (int) $campaign->id, + (int) $campaign->tenant_id, + (string) $message->file_name, + $comment, + )); + } + } catch (Throwable $e) { + Log::warning('Не смогли отметить приложенный документ: '.$e->getMessage(), [ + 'campaign' => $campaign->id, 'message' => $message->id, + ]); + } + } + /** Файл отдаём только своему тенанту и только через портал — диск закрытый. */ public function file(Request $request, int $id, int $messageId): StreamedResponse { diff --git a/app/app/Http/Controllers/Api/CreativeRobotController.php b/app/app/Http/Controllers/Api/CreativeRobotController.php index ea233605..7c584aeb 100644 --- a/app/app/Http/Controllers/Api/CreativeRobotController.php +++ b/app/app/Http/Controllers/Api/CreativeRobotController.php @@ -193,7 +193,11 @@ class CreativeRobotController extends Controller ]; } - app(CampaignMessageService::class)->postFromYandex( + // 🔴 Именно postFromRobot, а НЕ postFromYandex: у второго стоит защита от дублей + // по последнему сообщению, и повторный отказ с той же формулировкой после починки + // она бы съела — клиент не узнал бы, что его опять не пустили. Уникальность + // разведки обеспечена на входе: одно задание на номер объявления. + app(CampaignMessageService::class)->postFromRobot( $campaign, $banner === null ? null : (int) $banner->id, $data['report'], diff --git a/app/app/Jobs/SyncCampaignModerationJob.php b/app/app/Jobs/SyncCampaignModerationJob.php index d0de200b..fe71cd99 100644 --- a/app/app/Jobs/SyncCampaignModerationJob.php +++ b/app/app/Jobs/SyncCampaignModerationJob.php @@ -119,9 +119,19 @@ class SyncCampaignModerationJob implements ShouldQueue // невозвращённых денег. if ($reason !== null) { try { - app(CampaignMessageService::class)->postFromYandex( - $campaign, (int) $banner->id, $reason, - ); + $messages = app(CampaignMessageService::class); + + // 🪤 Заглушку «Яндекс причину не назвал» показываем ОДИН раз — + // пока настоящей причины нет. После доклада разведки она уже + // не новость, а шаг назад: клиент, прочитавший настоящую + // причину, получил бы поверх неё «причину не назвали» и ещё + // одно письмо. Обычная защита от дублей тут не спасает — она + // смотрит на ПОСЛЕДНЕЕ сообщение, а последним лежит доклад робота. + $zaglushka = $reason === ModerationReason::PRICHINA_NEIZVESTNA; + + if (! $zaglushka || ! $messages->hasFromYandex($campaign, (int) $banner->id)) { + $messages->postFromYandex($campaign, (int) $banner->id, $reason); + } } catch (Throwable $e) { Log::warning('Не смогли положить пояснение Яндекса в ленту: '.$e->getMessage(), [ 'campaign' => $campaign->id, 'banner' => $banner->id, diff --git a/app/app/Mail/AdDocumentAttachedMail.php b/app/app/Mail/AdDocumentAttachedMail.php new file mode 100644 index 00000000..1409b2d7 --- /dev/null +++ b/app/app/Mail/AdDocumentAttachedMail.php @@ -0,0 +1,59 @@ +campaignName.'»', + ); + } + + public function content(): Content + { + return new Content( + view: 'mail.ad-document-attached', + with: [ + 'campaignName' => $this->campaignName, + 'campaignId' => $this->campaignId, + 'tenantId' => $this->tenantId, + 'fileName' => $this->fileName, + 'comment' => $this->comment, + ], + ); + } +} diff --git a/app/app/Services/Advertising/CampaignMessageService.php b/app/app/Services/Advertising/CampaignMessageService.php index 40a3ac16..5c4a2e6d 100644 --- a/app/app/Services/Advertising/CampaignMessageService.php +++ b/app/app/Services/Advertising/CampaignMessageService.php @@ -9,6 +9,7 @@ use App\Models\AdCampaign; use App\Models\AdCampaignMessage; use App\Models\User; use App\Services\NotificationService; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; use Throwable; @@ -28,47 +29,80 @@ class CampaignMessageService * обходе — иначе за сутки лента превратится в сотню одинаковых строк, а клиент * получит сотню писем. * - * 🔴 Сравниваем со ВСЕЙ прошлой перепиской по этому баннеру, а не с последним сообщением. - * Разница вылезла с появлением разведки: обход кладёт «причину выясняем» → робот приносит - * настоящую причину → следующий обход снова кладёт «причину выясняем», ведь последним - * теперь лежит доклад робота. Клиент, уже прочитавший настоящую причину, получал бы - * поверх неё письмо «Яндекс отклонил, но причину не назвал». - * - * Плата за это — если Яндекс скажет A, потом B, потом снова ровно A, второе A мы - * пропустим. Лента это история, и повтор уже прочитанного клиенту ничего не добавляет. + * Сравниваем с ПОСЛЕДНИМ сообщением Яндекса по этому баннеру, а не со всей перепиской. + * 🪤 Сравнение «со всей историей» кажется надёжнее, но ломает главный случай: клиент + * починил рекламу, отправил заново и получил ТОТ ЖЕ отказ — и такое сообщение + * не показалось бы вовсе. Клиент остался бы без объяснения, почему опять не пустили. * * Работаем на том же соединении, что и переданная кампания: джоб модерации ходит * через `pgsql_supplier`, и сообщение обязано лечь тем же каналом, иначе на бою * RLS без tenant-контекста молча не даст ни прочитать, ни записать. - * - * @param array{path: string, name: string, size: int, mime: string}|null $file - * вложение — снимок экрана кабинета, который принёс робот-разведчик */ - public function postFromYandex( + public function postFromYandex(AdCampaign $campaign, ?int $bannerId, string $body): ?AdCampaignMessage + { + $body = trim($body); + if ($body === '') { + return null; + } + + $last = $this->fromYandexQuery($campaign, $bannerId) + ->orderByDesc('id') + ->first(); + + if ($last !== null && $last->body === $body) { + return null; + } + + return $this->write($campaign, $bannerId, $body, null); + } + + /** + * Доклад робота-разведчика: что он прочитал на экране кабинета, плюс снимок этого окна. + * Автор тот же — `yandex`: это слова Яндекса, откуда бы мы их ни взяли. + * + * 🔴 Защиты от дублей здесь НЕТ намеренно. Разведка ставится ровно один раз на номер + * объявления (`CreativeJobService::enqueueInspection`) — уникальность уже обеспечена + * там, на входе. Повесь мы дедуп ещё и здесь, повторный отказ с той же формулировкой + * после починки был бы съеден, и клиент не узнал бы, что его опять не пустили. + * + * @param array{path: string, name: string, size: int, mime: string}|null $file снимок экрана + */ + public function postFromRobot( AdCampaign $campaign, ?int $bannerId, string $body, ?array $file = null, ): ?AdCampaignMessage { $body = trim($body); - if ($body === '') { - return null; - } - $connection = $campaign->getConnectionName(); + return $body === '' ? null : $this->write($campaign, $bannerId, $body, $file); + } - $seen = AdCampaignMessage::on($connection) + /** + * Есть ли по этому баннеру хоть одно слово от Яндекса. + * + * Нужно джобу модерации: заглушку «причину выясняем» имеет смысл показать ОДИН раз, + * пока настоящей причины нет. После доклада разведки она уже не новость, а шаг назад — + * клиент, прочитавший настоящую причину, получил бы поверх неё «причину не назвали». + */ + public function hasFromYandex(AdCampaign $campaign, ?int $bannerId): bool + { + return $this->fromYandexQuery($campaign, $bannerId)->exists(); + } + + /** @return Builder */ + private function fromYandexQuery(AdCampaign $campaign, ?int $bannerId) + { + return AdCampaignMessage::on($campaign->getConnectionName()) ->where('tenant_id', $campaign->tenant_id) ->where('campaign_id', $campaign->id) ->where('author', AdCampaignMessage::AUTHOR_YANDEX) - ->where(fn ($q) => $bannerId === null ? $q->whereNull('banner_id') : $q->where('banner_id', $bannerId)) - ->where('body', $body) - ->exists(); - - if ($seen) { - return null; - } + ->where(fn ($q) => $bannerId === null ? $q->whereNull('banner_id') : $q->where('banner_id', $bannerId)); + } + /** Запись сообщения от имени Яндекса + уведомление клиента. Общее для обоих входов. */ + private function write(AdCampaign $campaign, ?int $bannerId, string $body, ?array $file): AdCampaignMessage + { $message = new AdCampaignMessage([ 'tenant_id' => (int) $campaign->tenant_id, 'campaign_id' => (int) $campaign->id, @@ -80,7 +114,7 @@ class CampaignMessageService 'file_size' => $file['size'] ?? null, 'file_mime' => $file['mime'] ?? null, ]); - $message->setConnection($connection); + $message->setConnection($campaign->getConnectionName()); $message->save(); $this->notify($campaign, $body); diff --git a/app/database/migrations/2026_07_29_120000_grant_admin_insert_ad_campaign_messages.php b/app/database/migrations/2026_07_29_120000_grant_admin_insert_ad_campaign_messages.php new file mode 100644 index 00000000..62722bb5 --- /dev/null +++ b/app/database/migrations/2026_07_29_120000_grant_admin_insert_ad_campaign_messages.php @@ -0,0 +1,77 @@ + { + const { data } = await apiClient.get<{ data: AdminStuckRobotJob[] }>('/api/admin/advertising/robot-stuck'); + return data.data ?? []; +} + export async function setAdCampaignCreative( id: number, yandexCreativeId: number, diff --git a/app/resources/js/views/admin/AdminAdvertisingView.vue b/app/resources/js/views/admin/AdminAdvertisingView.vue index 2c81d94a..e15de483 100644 --- a/app/resources/js/views/admin/AdminAdvertisingView.vue +++ b/app/resources/js/views/admin/AdminAdvertisingView.vue @@ -8,7 +8,7 @@ */ import { computed, onMounted, reactive, ref, watch } from 'vue'; import * as adminApi from '../../api/admin'; -import type { AdminAwaitingCampaign, AdvertisingSpendPeriod } from '../../api/admin'; +import type { AdminAwaitingCampaign, AdminStuckRobotJob, AdvertisingSpendPeriod } from '../../api/admin'; import { extractErrorMessage } from '../../api/client'; import { formatPlain } from '../../composables/billingFormatters'; @@ -149,7 +149,49 @@ async function saveCreative(campaignId: number) { onMounted(loadAwaiting); +// === «Ждёт разбора»: сбойные задания робота (задача 15) === +// +// 🔴 Это НЕ список обычных отказов модерации — их клиент разбирает сам, по причине +// в переписке. Это места, где ЦЕПОЧКА ВСТАЛА: робот сходил в кабинет и не понял, что +// видит, или не смог отвезти картинки. Без этого экрана обрыв тихий: клиент ждёт ответа, +// которого не будет, и человек об этом узнаёт только из письма — а письмо теряется. + +const stuckJobs = reactive([]); +const stuckLoading = ref(false); +const stuckError = ref(''); + +const stuckIsEmpty = computed(() => !stuckLoading.value && !stuckError.value && stuckJobs.length === 0); + +const KIND_LABEL: Record = { + upload: 'Отвезти картинки', + inspect: 'Узнать причину отказа', + deliver: 'Отвезти документ', +}; + +function kindLabel(kind: string): string { + return KIND_LABEL[kind] ?? kind; +} + +async function loadStuck() { + stuckLoading.value = true; + stuckError.value = ''; + try { + const list = await adminApi.fetchAdRobotStuck(); + stuckJobs.splice(0, stuckJobs.length, ...list); + } catch (e) { + stuckError.value = extractErrorMessage(e); + } finally { + stuckLoading.value = false; + } +} + +onMounted(loadStuck); + defineExpose({ + stuckJobs, + stuckLoading, + stuckError, + loadStuck, rows, totals, adMarginPercent, @@ -436,6 +478,58 @@ defineExpose({ + + + +
+

Ждёт разбора

+ + Обновить + +
+ +

+ Здесь только сбои робота: он сходил в кабинет и не разобрался, что там написано, + либо не смог отвезти картинки. Обычные отказы модерации сюда не попадают — + их клиент разбирает сам, причина лежит у него в переписке по кампании. +

+ + + {{ stuckError }} + + +
+ +
+ +

+ Разбирать нечего — робот со всеми заданиями справился. +

+ + + + + Клиент + Кампания + Что делал робот + Объявление в Яндексе + На чём споткнулся + + + + + {{ j.tenant_name }} + {{ j.campaign_name }} + {{ kindLabel(j.kind) }} + {{ j.yandex_ad_id !== null ? j.yandex_ad_id : '—' }} + {{ j.failure_reason || 'Причина не записана' }} + + + +
diff --git a/app/resources/views/mail/ad-document-attached.blade.php b/app/resources/views/mail/ad-document-attached.blade.php new file mode 100644 index 00000000..4f207cf1 --- /dev/null +++ b/app/resources/views/mail/ad-document-attached.blade.php @@ -0,0 +1,22 @@ +

Клиент приложил документ по рекламной кампании «{{ $campaignName }}».

+ +
    +
  • Кампания: № {{ $campaignId }}
  • +
  • Клиент: № {{ $tenantId }}
  • +
  • Файл: {{ $fileName }}
  • +
+ +@if ($comment !== '') +

Клиент написал:

+
+ {{ $comment }} +
+@endif + +

+ Яндекс не принимает документы от программы — отнести файл придётся руками: + чат поддержки в кабинете Директа либо форма обратной связи модерации. + Сам документ лежит в портале, в переписке по кампании. +

+ +

Это письмо отправлено автоматически.

diff --git a/app/tests/Feature/Advertising/CampaignMessageEndpointsTest.php b/app/tests/Feature/Advertising/CampaignMessageEndpointsTest.php index efb1f283..f051829c 100644 --- a/app/tests/Feature/Advertising/CampaignMessageEndpointsTest.php +++ b/app/tests/Feature/Advertising/CampaignMessageEndpointsTest.php @@ -2,11 +2,13 @@ declare(strict_types=1); +use App\Mail\AdDocumentAttachedMail; use App\Models\AdCampaign; use App\Models\AdCampaignMessage; use App\Models\Tenant; use App\Models\User; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; function messageEndpointCampaign(): array @@ -178,3 +180,74 @@ it('свой файл клиент скачивает, чужой — нет', f ->get("/api/advertising/campaigns/{$campaign->id}/messages/{$message->id}/file") ->assertStatus(404); }); + +/** + * 🔴 Задача 16 закрыта по факту: везти документ роботом НЕКУДА. Проверено двумя нарочными + * отказами — в окне отказа Яндекса ноль полей для файла, документы он принимает только + * снаружи кабинета. Значит портал обязан сказать клиенту правду: файл у нас, сам он + * никуда не уедет. Молчать нельзя — клиент будет ждать ответа, которого не будет. + */ +it('приложил документ — портал честно говорит, что сам он к Яндексу не уедет', function () { + Storage::fake('local'); + Mail::fake(); + [, $user, $campaign] = messageEndpointCampaign(); + + $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/messages", [ + 'body' => 'Прикладываю лицензию', + 'file' => UploadedFile::fake()->create('licence.pdf', 200, 'application/pdf'), + ])->assertStatus(201); + + $note = AdCampaignMessage::where('campaign_id', $campaign->id) + ->where('author', AdCampaignMessage::AUTHOR_SYSTEM) + ->latest('id')->first(); + + expect($note)->not->toBeNull() + ->and($note->body)->toContain('Яндекс'); +}); + +/** И зовём владельца письмом: без этого «разберёмся вручную» было бы пустым обещанием. */ +it('о приложенном документе владелец узнаёт письмом', function () { + Storage::fake('local'); + Mail::fake(); + [, $user, $campaign] = messageEndpointCampaign(); + + $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/messages", [ + 'file' => UploadedFile::fake()->create('licence.pdf', 200, 'application/pdf'), + ])->assertStatus(201); + + Mail::assertQueued(AdDocumentAttachedMail::class); +}); + +/** Обычный ответ без файла — обычный ответ. Ни отметки, ни письма владельцу. */ +it('ответ без документа отметки не порождает и владельца не дёргает', function () { + Storage::fake('local'); + Mail::fake(); + [, $user, $campaign] = messageEndpointCampaign(); + + $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/messages", [ + 'body' => 'Переделал картинку', + ])->assertStatus(201); + + expect(AdCampaignMessage::where('campaign_id', $campaign->id) + ->where('author', AdCampaignMessage::AUTHOR_SYSTEM)->count())->toBe(0); + + Mail::assertNothingQueued(); +}); + +/** + * Отметка и письмо — дело второстепенное, а сообщение клиента с документом нет. Беда + * с почтой не должна возвращать клиенту отказ на успешно принятый файл. + */ +it('упавшая почта не мешает принять документ клиента', function () { + Storage::fake('local'); + [, $user, $campaign] = messageEndpointCampaign(); + + Mail::shouldReceive('to')->andThrow(new RuntimeException('почта легла')); + + $this->actingAs($user)->postJson("/api/advertising/campaigns/{$campaign->id}/messages", [ + 'file' => UploadedFile::fake()->create('licence.pdf', 200, 'application/pdf'), + ])->assertStatus(201); + + expect(AdCampaignMessage::where('campaign_id', $campaign->id) + ->where('author', AdCampaignMessage::AUTHOR_CLIENT)->count())->toBe(1); +}); diff --git a/app/tests/Feature/Advertising/CampaignMessageServiceTest.php b/app/tests/Feature/Advertising/CampaignMessageServiceTest.php index 36a20479..fd525037 100644 --- a/app/tests/Feature/Advertising/CampaignMessageServiceTest.php +++ b/app/tests/Feature/Advertising/CampaignMessageServiceTest.php @@ -108,13 +108,16 @@ it('пустой текст в ленту не кладём', function () { }); /** - * 🪤 Ловушка, которую открыла разведка. Дедуп сравнивал текст только с ПОСЛЕДНИМ сообщением - * Яндекса. Порядок в жизни такой: обход модерации кладёт «причину выясняем» → робот приносит - * настоящую причину → следующий обход снова кладёт «причину выясняем», потому что последним - * теперь лежит доклад робота. Клиент, уже прочитавший настоящую причину, получал бы поверх - * неё письмо «Яндекс отклонил рекламу, но причину не назвал». + * 🪤 Ловушка, которую открыла разведка, и которую нельзя чинить дедупом по всей истории. + * + * Клиент починил рекламу, отправил заново — и получил ТОТ ЖЕ отказ. Это новость: клиент + * должен узнать, что его опять не пустили, и по той же причине. Дедуп «такой текст уже + * когда-то был» съел бы это сообщение, и клиент остался бы без объяснения. + * + * Поэтому защита от дублей смотрит только на ПОСЛЕДНЕЕ сообщение, а «заглушку» держит + * не она, а джоб модерации — см. SyncCampaignModerationJobTest. */ -it('прежний текст Яндекса не всплывает в ленте второй раз поверх свежего', function () { +it('тот же отказ после доклада разведки клиент увидит снова', function () { [$tenant, $campaign] = messageCampaign(); $banner = AdCampaignBanner::create([ 'tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, @@ -123,10 +126,48 @@ it('прежний текст Яндекса не всплывает в лент ]); $service = app(CampaignMessageService::class); - $service->postFromYandex($campaign, $banner->id, 'Причину выясняем'); - $service->postFromYandex($campaign, $banner->id, 'Нет предупреждения: финансовые услуги'); - $again = $service->postFromYandex($campaign, $banner->id, 'Причину выясняем'); + $service->postFromRobot($campaign, $banner->id, 'Нет предупреждения: финансовые услуги'); + $service->postFromYandex($campaign, $banner->id, 'Отклонено'); + $again = $service->postFromRobot($campaign, $banner->id, 'Нет предупреждения: финансовые услуги'); - expect($again)->toBeNull() - ->and(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(2); + expect($again)->not->toBeNull() + ->and(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(3); +}); + +/** Доклад разведки умеет нести снимок экрана — он и есть доказательство прочитанного. */ +it('доклад разведки кладётся со снимком экрана', function () { + [$tenant, $campaign] = messageCampaign(); + + $message = app(CampaignMessageService::class)->postFromRobot($campaign, null, 'Причина', [ + 'path' => 'ad-messages/1/1/okno.png', + 'name' => 'okno.png', + 'size' => 1024, + 'mime' => 'image/png', + ]); + + expect($message->file_name)->toBe('okno.png') + ->and($message->file_path)->toBe('ad-messages/1/1/okno.png') + ->and($message->author)->toBe(AdCampaignMessage::AUTHOR_YANDEX); +}); + +/** Пустой доклад — не доклад. В ленту не кладём. */ +it('пустой доклад разведки в ленту не попадает', function () { + [, $campaign] = messageCampaign(); + + expect(app(CampaignMessageService::class)->postFromRobot($campaign, null, ' '))->toBeNull() + ->and(AdCampaignMessage::where('campaign_id', $campaign->id)->count())->toBe(0); +}); + +/** Признак «Яндекс уже что-то сказал» — на нём держится защита от повторной заглушки. */ +it('признак «слова Яндекса уже есть» считается по баннеру, а не по кампании целиком', function () { + [$tenant, $campaign] = messageCampaign(); + $slot = ['tenant_id' => $tenant->id, 'campaign_id' => $campaign->id, 'bytes' => 1000, 'included' => true]; + $a = AdCampaignBanner::create($slot + ['width' => 300, 'height' => 250, 'path' => 'ad-banners/a/300x250.jpg']); + $b = AdCampaignBanner::create($slot + ['width' => 728, 'height' => 90, 'path' => 'ad-banners/a/728x90.jpg']); + + $service = app(CampaignMessageService::class); + $service->postFromYandex($campaign, $a->id, 'Отклонено'); + + expect($service->hasFromYandex($campaign, $a->id))->toBeTrue() + ->and($service->hasFromYandex($campaign, $b->id))->toBeFalse(); }); diff --git a/app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php b/app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php index a75bc695..22407699 100644 --- a/app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php +++ b/app/tests/Feature/Advertising/SyncCampaignModerationJobTest.php @@ -452,3 +452,73 @@ it('рубильник выключен — ни обращения к Янде expect(AdCreativeJob::where('campaign_id', $campaign->id)->count())->toBe(0) ->and($campaign->banners()->first()->moderation_status)->toBe(AdCampaignBanner::MOD_MODERATION); }); + +/** + * 🪤 Ловушка, вскрытая разведкой. Порядок в жизни такой: обход кладёт клиенту «Яндекс + * причину не назвал, выясняем» → робот приносит НАСТОЯЩУЮ причину → следующий обход + * снова кладёт заглушку, ведь последним в ленте лежит уже доклад робота. + * + * Клиент, только что прочитавший настоящую причину, получил бы поверх неё «причину + * не назвали» и ещё одно письмо. Заглушку показываем ОДИН раз — пока сказать нечего. + * + * 🔴 Кампания тут с ДВУМЯ объявлениями: одно приняли, одно отклонили. Иначе сценария + * не существует вовсе — при отказе ВСЕХ объявлений кампания уходит в «отклонена», + * и обход её больше не берёт. Первая версия этого теста была пустышкой ровно поэтому: + * вырезание защиты её не роняло. + */ +it('после доклада разведки заглушка «причину выясняем» в ленту не возвращается', function () { + configureYandexForModeration(); + + [$campaign, $banners] = makeModeratedCampaignWithBanners([9106, 9108]); + $otkloneny = $banners->first(); + + $otvet = ['result' => ['Ads' => [ + ['Id' => 9106, 'State' => 'OFF', 'Status' => 'REJECTED', 'StatusClarification' => ' +Отклонено на модерации.'], + ['Id' => 9108, 'State' => 'ON', 'Status' => 'ACCEPTED', 'StatusClarification' => 'Принято'], + ]]]; + + Http::fake(['*' => Http::response($otvet)]); + + // Первый обход — заглушка. Кампания остаётся работающей: одно объявление принято. + (new SyncCampaignModerationJob)->handle(); + expect($campaign->refresh()->status)->toBe(AdCampaign::STATUS_RUNNING); + + // Робот сходил и принёс настоящую причину. + app(CampaignMessageService::class)->postFromRobot( + $campaign, (int) $otkloneny->id, 'Нет предупреждения: финансовые услуги', + ); + + // Второй обход — заглушке в ленте больше не место. + (new SyncCampaignModerationJob)->handle(); + + $bodies = AdCampaignMessage::where('campaign_id', $campaign->id) + ->where('banner_id', $otkloneny->id) + ->orderBy('id')->pluck('body')->all(); + + expect($bodies)->toHaveCount(2) + ->and($bodies[1])->toContain('финансовые услуги'); +}); + +/** + * Обратная сторона: пока Яндекс молчит, а робот ещё не сходил, заглушка нужна — + * иначе под ярлыком «Отклонено» у клиента будет пустота. + */ +it('пока разведка не сходила, заглушка клиенту всё-таки показывается', function () { + configureYandexForModeration(); + + [$campaign] = makeModeratedCampaignWithBanners([9107]); + + Http::fake(['*' => Http::response(['result' => ['Ads' => [[ + 'Id' => 9107, 'State' => 'OFF', 'Status' => 'REJECTED', + 'StatusClarification' => ' +Отклонено на модерации.', + ]]]])]); + + (new SyncCampaignModerationJob)->handle(); + + $first = AdCampaignMessage::where('campaign_id', $campaign->id)->first(); + + expect($first)->not->toBeNull() + ->and($first->body)->toContain('причину не назвал'); +}); diff --git a/app/tests/Frontend/admin-advertising-view.spec.ts b/app/tests/Frontend/admin-advertising-view.spec.ts index c9eb02d9..9c310ea9 100644 --- a/app/tests/Frontend/admin-advertising-view.spec.ts +++ b/app/tests/Frontend/admin-advertising-view.spec.ts @@ -13,6 +13,7 @@ vi.mock('../../resources/js/api/admin', async (importOriginal) => { updateAdSettings: vi.fn(), fetchAdAwaitingCampaigns: vi.fn(), setAdCampaignCreative: vi.fn(), + fetchAdRobotStuck: vi.fn(), }; }); @@ -22,6 +23,7 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(adminApi.fetchAdSettings).mockResolvedValue({ client_cpm_rub: '150.00', ad_margin_percent: '40.00' }); vi.mocked(adminApi.fetchAdAwaitingCampaigns).mockResolvedValue([]); + vi.mocked(adminApi.fetchAdRobotStuck).mockResolvedValue([]); }); function makeAwaitingCampaign(overrides: Partial = {}): AdminAwaitingCampaign { @@ -269,3 +271,48 @@ describe('AdminAdvertisingView ↔ кампании, ждущие номер к expect(wrapper.find('[data-testid="ad-creative-saved"]').exists()).toBe(false); }); }); + +/** + * 🔴 «Ждёт разбора» — места, где ЦЕПОЧКА ВСТАЛА: робот сходил в кабинет и не понял, что + * видит. Это не обычные отказы (их клиент разбирает сам по причине в переписке), а обрыв, + * о котором иначе никто не узнает: клиент будет ждать ответа, которого не будет. + */ +describe('AdminAdvertisingView ↔ GET /api/admin/advertising/robot-stuck', () => { + it('показывает сбойные задания робота с причиной и номером объявления', async () => { + vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse()); + vi.mocked(adminApi.fetchAdRobotStuck).mockResolvedValueOnce([ + { + id: 9, + campaign_id: 42, + tenant_id: 1, + tenant_name: 'Окна Москва', + campaign_name: 'Кампания на показы', + campaign_status: 'rejected', + kind: 'inspect', + yandex_ad_id: 17787055204, + failure_reason: 'разметка не совпала: окна BannerStatusCell.Popup нет', + attempts: 1, + finished_at: '2026-07-28T18:00:00+00:00', + }, + ]); + + const wrapper = mountView(); + await flushPromises(); + + const rows = wrapper.findAll('[data-testid="ad-stuck-row"]'); + expect(rows).toHaveLength(1); + expect(rows[0].text()).toContain('Окна Москва'); + expect(rows[0].text()).toContain('разметка не совпала'); + expect(rows[0].text()).toContain('17787055204'); + }); + + it('когда разбирать нечего — говорит об этом прямо, а не показывает пустую таблицу', async () => { + vi.mocked(adminApi.fetchAdvertisingSpend).mockResolvedValueOnce(makeResponse()); + + const wrapper = mountView(); + await flushPromises(); + + expect(wrapper.find('[data-testid="ad-stuck-empty"]').exists()).toBe(true); + expect(wrapper.findAll('[data-testid="ad-stuck-row"]')).toHaveLength(0); + }); +}); diff --git a/bots/yandex-creatives/src/cabinet.js b/bots/yandex-creatives/src/cabinet.js index 0935864e..ae3ad6ad 100644 --- a/bots/yandex-creatives/src/cabinet.js +++ b/bots/yandex-creatives/src/cabinet.js @@ -29,13 +29,6 @@ const MODAL = '[data-testid="CanvasModal.html5"]'; const FILE_INPUT = `${MODAL} input[type=file]`; const SUBMIT = '[data-testid="CanvasFooter.SubmitButton"]'; const MODAL_CLOSE = '[data-testid="CanvasModal.html5.CloseButton"]'; -/** - * Список наборов на вкладке «Мои креативы». Живьём окно после «Создать» НЕ закрывается, - * а переключается на эту вкладку — появление списка и есть признак «набор создан» - * (cabinet-flow.md §5.2). Роботу дальше ничего выбирать не надо: креативы уже в библиотеке - * аккаунта, а номера портал добывает слепком `creatives.get`. - */ -const BATCHES = '[data-testid="BatchesList"]'; /** * Адрес «прихожей» — форма НОВОГО объявления существующей группы. @@ -61,7 +54,8 @@ export async function uploadCreatives(page, config, files, options = {}) { menuTimeoutMs = 5000, modalTimeoutMs = 10000, acceptTimeoutMs = 60000, - modalCloseTimeoutMs = 120000, + // Живьём окно не закрывается вовсе — держать робота две минуты впустую незачем. + modalCloseTimeoutMs = 20000, pollMs = 250, } = options; @@ -96,31 +90,35 @@ export async function uploadCreatives(page, config, files, options = {}) { await humanPause(config); await submit.click(); - // Шаг 6 — дожидаемся, чем кончилась загрузка. + // Шаг 6 — окно после «Создать». // - // 🔑 Снято живьём 28.07.2026: окно НЕ закрывается. После «Создать» оно переключается - // на вкладку «Мои креативы» со списком наборов — вот это и есть «набор создан». - // Раньше здесь ждали закрытия и на КАЖДОЙ удачной загрузке звали человека письмом - // «окно не закрылось». Закрытие оставлено как второй допустимый исход: кабинет живой, - // завтра может повести себя иначе. + // 🔑 Снято живьём 28.07.2026: окно НЕ закрывается — оно переключается на вкладку + // «Мои креативы». Поэтому «окно не закрылось» это НОРМА, а не беда. Раньше робот на + // каждой удачной загрузке звал человека письмом-алярмом; письмо, которое приходит + // всегда, перестают читать вовсе. // - // 🔴 Ни один из исходов провалом НЕ считается. Правда об успехе живёт не на экране, - // а в слепке creatives.get — её знает портал. Объяви робот провал, портал пометил бы - // задание сбойным, хотя креативы уже лежат в кабинете: получили бы и враньё, и мусор. - // Робот только докладывает, что видел. - const outcome = await waitForUploadOutcome(page, modalCloseTimeoutMs, pollMs); + // 🔴 Признак «набор создан» с экрана мы НЕ читаем. Подтверждённой метки у нас нет: + // список наборов в разметке — скрытая галочка `BatchesList`, по видимости её проверять + // нельзя, а гадать по экрану — ровно тот способ, которым уже дважды за этот день + // получалась мёртвая программа при зелёных тестах. + // + // Правда об успехе живёт не на экране, а в слепке `creatives.get`: портал сверяет + // «до/после» и, если креатив не появился, закрывает задание сбоем с внятной причиной — + // она видна в админке «ждёт разбора». Этого сторожа достаточно, экранный не нужен. + // + // Ждём закрытия недолго: обычно его не будет вовсе, и держать робота две минуты + // впустую незачем. + let modalClosed = true; + let cabinetSaid = ''; - const batchesShown = outcome === 'batches'; - const modalClosed = outcome === 'closed'; - // Слова кабинета снимаем только в непонятном случае: когда набор создан, в окне лежит - // список наборов, и тащить его наружу незачем. - const cabinetSaid = outcome === 'none' - ? (await page.locator(MODAL).innerText().catch(() => '')).trim() - : ''; - - if (!modalClosed) { + try { + await page.locator(MODAL).first().waitFor({ state: 'detached', timeout: modalCloseTimeoutMs }); + } catch { + modalClosed = false; + // Слова кабинета уносим в отчёт — по ним разбирают сбой, если он всё-таки случился. + cabinetSaid = (await page.locator(MODAL).first().innerText().catch(() => '')).trim(); // Закрываем крестиком — так предписывает разметка, §2 шаг 6. - await page.locator(MODAL_CLOSE).click().catch(() => {}); + await page.locator(MODAL_CLOSE).first().click().catch(() => {}); } // Шаг 7 — уходим со страницы. Кнопку «Сохранить изменения» (SaveBannerButton) не @@ -132,31 +130,7 @@ export async function uploadCreatives(page, config, files, options = {}) { // иначе портал пометит задание сбойным, а креативы останутся лежать в кабинете. await page.goto(overviewUrl(config), { waitUntil: 'domcontentloaded' }).catch(() => {}); - return { modalClosed, batchesShown, cabinetSaid }; -} - -/** - * Чем кончилась загрузка: `batches` — окно показало список наборов, `closed` — окно ушло - * само, `none` — ни то, ни другое за отведённое время. - * - * Опрашиваем по очереди, а не ждём одного признака: исход заранее неизвестен, а ждать - * сначала один целый срок, потом второй — значит на удачной загрузке простоять лишние - * две минуты. - */ -async function waitForUploadOutcome(page, timeoutMs, pollMs) { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - if (await page.locator(BATCHES).isVisible().catch(() => false)) { - return 'batches'; - } - if (!(await page.locator(MODAL).isVisible().catch(() => false))) { - return 'closed'; - } - await new Promise((resolve) => setTimeout(resolve, pollMs)); - } - - return 'none'; + return { modalClosed, cabinetSaid }; } // ── Разведка: прочитать, за что Яндекс отклонил объявление ──────────────────────────── @@ -217,8 +191,12 @@ export async function readRejection(page, config, job, screenshotPath, options = } // 🪤 Окно открывается КЛИКОМ. Наведение мышью не работает — проверено живьём. + // + // Везде `.first()`: если на странице почему-то окажется два таких элемента, Playwright + // в строгом режиме бросит ошибку про «несколько совпадений», и робот сдастся не потому, + // что кабинет поменялся, а потому, что мы неаккуратно выбрали элемент. await cell.locator(STATUS_CONTENT).first().click(); - await page.locator(POPUP).waitFor({ timeout: popupTimeoutMs }); + await page.locator(POPUP).first().waitFor({ timeout: popupTimeoutMs }); await humanPause(config); // 🔑 Строку причины надо РАСКРЫТЬ: до этого виден только короткий заголовок @@ -229,7 +207,7 @@ export async function readRejection(page, config, job, screenshotPath, options = await humanPause(config); } - const report = (await page.locator(POPUP).innerText()).trim(); + const report = (await page.locator(POPUP).first().innerText()).trim(); if (report === '') { throw new Error(`Окно причины по объявлению ${adId} пустое — докладывать нечего`); } @@ -238,7 +216,7 @@ export async function readRejection(page, config, job, screenshotPath, options = // а не страницу целиком: на полном снимке видна боковая панель с логином и остатком счёта. let shot = screenshotPath; try { - await page.locator(POPUP).screenshot({ path: screenshotPath }); + await page.locator(POPUP).first().screenshot({ path: screenshotPath }); } catch { shot = null; } diff --git a/bots/yandex-creatives/src/runner.js b/bots/yandex-creatives/src/runner.js index 6127391c..06cc684c 100644 --- a/bots/yandex-creatives/src/runner.js +++ b/bots/yandex-creatives/src/runner.js @@ -172,25 +172,24 @@ export async function runOnce(config, portal, browser, mailer, { timestamp, work } catch { /* письмо — не повод считать работу проваленной */ } } - // Зовём человека, только когда исход НЕПОНЯТЕН: ни список наборов не появился, ни окно - // не закрылось. 🔴 Само по себе «окно не закрылось» бедой не является — живьём оно - // и не закрывается, а переключается на список наборов (cabinet-flow.md §5.2). Пока - // условие было только по закрытию, письмо уходило на КАЖДОЙ удачной загрузке, а письмо, - // которое приходит всегда, перестают читать вовсе. - if (said && said.modalClosed === false && said.batchesShown === false) { - try { - await mailer.alarm({ - step: 'загрузка креативов', - reason: `Окно загрузки повело себя незнакомо: ни список наборов не появился, ни окно не закрылось. ` - + `Кабинет сказал: ${said.cabinetSaid || 'ничего не написал'}`, - campaignId: job.campaign_id, - screenshotPath: null, - uploaded: true, - }); - } catch { /* см. выше */ } - } - - return { ok: true, jobId: job.id, count: files.length, reportedToPortal: true }; + // 🔴 По виду окна загрузки человека НЕ зовём вовсе. + // + // Раньше письмо-алярм уходило на каждое «окно не закрылось» — а живьём оно и не + // закрывается, оно переключается на вкладку «Мои креативы» (снято 28.07.2026). То есть + // письмо приходило на КАЖДОЙ удачной загрузке, а письмо, которое приходит всегда, + // перестают читать вовсе. + // + // Настоящего сбоя это не прячет: правда об успехе живёт в слепке `creatives.get`. + // Не появился креатив — портал закрывает задание сбоем с внятной причиной, и оно видно + // в админке «ждёт разбора». Слова кабинета отдаём наружу в результате прохода — они + // попадают в журнал робота, если разбирать всё-таки придётся. + return { + ok: true, + jobId: job.id, + count: files.length, + reportedToPortal: true, + cabinetSaid: said?.cabinetSaid || '', + }; } /** diff --git a/bots/yandex-creatives/test/cabinet.test.js b/bots/yandex-creatives/test/cabinet.test.js index e13b591d..ffe623b7 100644 --- a/bots/yandex-creatives/test/cabinet.test.js +++ b/bots/yandex-creatives/test/cabinet.test.js @@ -20,9 +20,8 @@ const FAST = { acceptTimeoutMs: 60, modalCloseTimeoutMs: 60, pollMs: 5 }; function fakePage({ enabledAfter = 1, // Умолчание — то, что происходит ЖИВЬЁМ: окно после «Создать» не закрывается, - // а переключается на вкладку «Мои креативы» со списком наборов (cabinet-flow.md §5.2). + // а переключается на вкладку «Мои креативы» (cabinet-flow.md §5.2). modalCloses = false, - batchesShown = true, modalText = '', leavingThrows = false, } = {}) { @@ -39,7 +38,10 @@ function fakePage({ if (leavingThrows && !url.includes('banners-edit')) throw new Error('сеть икнула на уходе'); }, locator(selector) { - return { + const self = { + // Настоящий Playwright в строгом режиме падает на нескольких совпадениях — + // поэтому боевой код везде зовёт .first(). Подделка обязана это уметь. + first: () => self, async waitFor(options = {}) { actions.push({ type: 'waitFor', selector, state: options.state }); if (options.state === 'detached' && !modalCloses) throw new Error('окно не закрылось'); @@ -47,13 +49,10 @@ function fakePage({ async click() { actions.push({ type: 'click', selector }); }, async setInputFiles(files) { actions.push({ type: 'files', selector, files }); }, async isDisabled() { disabledAsked += 1; return disabledAsked < enabledAfter; }, - async isVisible() { - if (selector.includes('BatchesList')) return batchesShown; - if (selector === '[data-testid="CanvasModal.html5"]') return !modalCloses; - return true; - }, async innerText() { return modalText; }, }; + + return self; }, }; } @@ -132,23 +131,28 @@ test('бросает понятную ошибку, если кабинет та /** * 🔑 Как оно на самом деле, снято живьём 28.07.2026: окно после «Создать» НЕ закрывается, - * а переключается на вкладку «Мои креативы» со списком наборов. Раньше робот считал это - * «окно не закрылось» и звал человека письмом — на КАЖДОЙ удачной загрузке. + * оно переключается на вкладку «Мои креативы». Значит «не закрылось» — это НОРМА. + * Робот закрывает окно крестиком и уходит, ничего не объявляя провалом. + * + * 🔴 Признак «набор создан» с экрана мы намеренно НЕ читаем: подтверждённой метки у нас + * нет, а гадать по экрану — способ получить мёртвую программу при зелёных тестах. + * Правду об успехе знает портал по слепку creatives.get. */ -test('после «Создать» окно переключается на список наборов — это удача, человека не зовём', async () => { - const page = fakePage(); +test('окно после «Создать» не закрылось — это норма: закрываем крестиком и уходим', async () => { + const page = fakePage({ modalText: 'Мои креативы 300×250' }); const result = await uploadCreatives(page, config, FILES, FAST); - assert.equal(result.batchesShown, true); - assert.equal(result.cabinetSaid, ''); + assert.equal(result.modalClosed, false); + // Слова кабинета уносим наружу — по ним разбирают сбой, если он всё-таки был. + assert.equal(result.cabinetSaid, 'Мои креативы 300×250'); // Крестик тут нажать НАДО: окно само не уйдёт, а робот обязан уйти со страницы чистым. assert.ok(page.clicked().includes('[data-testid="CanvasModal.html5.CloseButton"]')); assert.equal(page.clicked().includes('[data-testid="SaveBannerButton"]'), false); }); -test('окно закрылось само — тоже удача, и крестик тогда не трогаем', async () => { - const page = fakePage({ modalCloses: true, batchesShown: false }); +test('окно закрылось само — тоже нормальный исход, крестик тогда не трогаем', async () => { + const page = fakePage({ modalCloses: true }); const result = await uploadCreatives(page, config, FILES, FAST); @@ -157,20 +161,6 @@ test('окно закрылось само — тоже удача, и крес assert.equal(page.clicked().includes('[data-testid="CanvasModal.html5.CloseButton"]'), false); }); -test('ни списка наборов, ни закрытия — НЕ объявляем провал, но слова кабинета отдаём наружу', async () => { - const page = fakePage({ modalCloses: false, batchesShown: false, modalText: 'Файл слишком большой' }); - - // Провал тут объявлять нельзя: правду об успехе знает портал по слепку креативов, - // а не робот по экрану. Но это и есть тот единственный случай, когда зовём человека. - const result = await uploadCreatives(page, config, FILES, FAST); - - assert.equal(result.modalClosed, false); - assert.equal(result.batchesShown, false); - assert.equal(result.cabinetSaid, 'Файл слишком большой'); - assert.ok(page.clicked().includes('[data-testid="CanvasModal.html5.CloseButton"]')); - assert.equal(page.clicked().includes('[data-testid="SaveBannerButton"]'), false); -}); - test('сетевая икота на уходе со страницы не считается провалом загрузки', async () => { // Файлы уже приняты, «Создать» нажата — работа сделана. Уход со страницы это уборка // за собой, и её осечка не должна выдаваться за «креативы не загрузились»: портал @@ -179,7 +169,7 @@ test('сетевая икота на уходе со страницы не сч const result = await uploadCreatives(page, config, FILES, FAST); - assert.equal(result.batchesShown, true); + assert.equal(result.modalClosed, false); assert.equal(page.clicked().includes('[data-testid="SaveBannerButton"]'), false); }); diff --git a/bots/yandex-creatives/test/runner.test.js b/bots/yandex-creatives/test/runner.test.js index adf0fa10..6ae0a936 100644 --- a/bots/yandex-creatives/test/runner.test.js +++ b/bots/yandex-creatives/test/runner.test.js @@ -28,7 +28,7 @@ async function withWorkDir(fn) { function stubs({ job = null, uploadThrows = null, - uploadResult = { modalClosed: true, cabinetSaid: '' }, + uploadResult = { modalClosed: false, cabinetSaid: '' }, loggedIn = true, inspectThrows = null, inspectResult = { report: 'Нет предупреждения: финансовые услуги', screenshotPath: '/tmp/shot.png' }, @@ -241,15 +241,19 @@ test('слетевший вход не пытается грузить и отч }); /** - * 🔴 Живьём окно загрузки НЕ закрывается: после «Создать» оно показывает список наборов - * (cabinet-flow.md §5.2). Пока роботу это казалось бедой, он слал письмо-алярм на КАЖДОЙ - * удачной загрузке — а письмо, которое приходит всегда, перестают читать вовсе. + * 🔴 Живьём окно загрузки НЕ закрывается: после «Создать» оно переключается на вкладку + * «Мои креативы» (cabinet-flow.md §5.2). Пока роботу это казалось бедой, он слал + * письмо-алярм на КАЖДОЙ удачной загрузке — а письмо, которое приходит всегда, + * перестают читать вовсе. + * + * 🔴 Настоящий сбой это не прячет: если креатив не появился, портал ловит это слепком + * creatives.get и закрывает задание сбоем — оно видно в админке «ждёт разбора». */ test('обычная удачная загрузка человека письмом НЕ дёргает', async () => { await withWorkDir(async (workDir) => { const s = stubs({ job: JOB, - uploadResult: { modalClosed: false, batchesShown: true, cabinetSaid: '' }, + uploadResult: { modalClosed: false, cabinetSaid: 'Мои креативы 300×250' }, }); const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); @@ -264,20 +268,24 @@ test('обычная удачная загрузка человека письм }); }); -test('окно не закрылось — докладываем «готово» порталу, но письмом зовём человека', async () => { +/** + * Слова кабинета не теряем: они уезжают в результат прохода и попадают в журнал робота. + * Но письмом по ним человека НЕ зовём — приговор об успехе выносит портал по слепку + * креативов, а не робот по экрану. + */ +test('слова кабинета уносим в результат прохода, но письмом по ним не зовём', async () => { await withWorkDir(async (workDir) => { - // Приговор выносит портал по слепку креативов, а не робот по экрану: окно может - // остаться открытым и после удачной загрузки. Но человек должен это увидеть. const s = stubs({ job: JOB, - uploadResult: { modalClosed: false, batchesShown: false, cabinetSaid: 'Файл слишком большой' }, + uploadResult: { modalClosed: false, cabinetSaid: 'Файл слишком большой' }, }); const res = await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); assert.equal(res.ok, true); + assert.equal(res.cabinetSaid, 'Файл слишком большой'); assert.deepEqual(s.reports, [{ ok: true, id: 7 }]); - assert.equal(s.sent.some((m) => m.kind === 'alarm' && /Файл слишком большой/.test(m.reason)), true); + assert.equal(s.sent.some((m) => m.kind === 'alarm'), false); }); }); @@ -329,10 +337,7 @@ test('доклад «готово» повторяется, если с перв // оставит дубли, вычистить которые можно только руками. test('письма после заливки помечены «креативы уже в кабинете»', async () => { await withWorkDir(async (workDir) => { - const s = stubs({ - job: JOB, - uploadResult: { modalClosed: false, batchesShown: false, cabinetSaid: 'что-то не так' }, - }); + const s = stubs({ job: JOB, uploadResult: { modalClosed: false, cabinetSaid: 'что-то не так' } }); s.portal.reportDone = async () => { throw new Error('Портал ответил 502 на /done'); }; await runOnce(FAST_CONFIG, s.portal, s.browser, s.mailer, opts(workDir)); diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 31890d4f..f3db81c4 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -6,6 +6,32 @@ ⚠️ **Рекламный модуль в `schema.sql` не консолидирован.** Записи v9.x ведут таблицы `ad_*`, которых в теле `schema.sql` нет вовсе — их DDL живёт только в дельта-миграциях. Идти за версией v9.x в header `schema.sql` бесполезно: там своя нумерация v8.x. Правка шапки от 29.07.2026, к содержанию записей отношения не имеет. +## v9.17 — 28.07.2026 — служебная роль получает право ПИСАТЬ в ленту: INSERT + нумератор + +```sql +GRANT INSERT ON ad_campaign_messages TO crm_admin_user; +GRANT USAGE, SELECT ON SEQUENCE public.ad_campaign_messages_id_seq TO crm_admin_user; +``` + +Миграция: `app/database/migrations/2026_07_29_120000_grant_admin_insert_ad_campaign_messages.php`. + +🔴 **Без этой записи разведка на бою уронила бы очередь целиком.** Доклад робота приходит +на `POST /api/creative-robot/jobs/{id}/inspection`; маршруты робота идут через посредник +`admin-db`, который подменяет подключение по умолчанию на `pgsql_admin` = `crm_admin_user`. +Значит INSERT в ленту уходит под этой ролью, а записью v9.14 ей выдали **только SELECT**. +На бою доклад упал бы с «permission denied for table ad_campaign_messages», робот получил +бы 500, трижды повторил и сдался. **Задание осталось бы навсегда «в работе», а пока хоть +одно задание в работе, выдача отвечает «работы нет» ВСЕМ клиентам.** + +Нумератор — отдельный объект: GRANT INSERT на таблицу НЕ даёт права взять следующий номер. +Общий `GRANT ... ON ALL SEQUENCES` из `db/02_grants.sql` разовый и покрыл только те +нумераторы, что существовали при создании ролей; `ad_campaign_messages_id_seq` появился +позже. Тот же класс ошибки уже ловили в v9.05. + +🪤 На dev/тестах дырка невидима — там ходит суперпользователь `postgres`, которому права +не проверяются. Поймал её не тест, а агент `rls-reviewer` по коду. UPDATE и DELETE +не выдаём: лента только пополняется. + ## v9.16 — 28.07.2026 — какое объявление Яндекса смотрит разведка: ad_creative_jobs.yandex_ad_id ```sql diff --git a/docs/superpowers/2026-07-27-PROGRESS-pochinka-v12.md b/docs/superpowers/2026-07-27-PROGRESS-pochinka-v12.md index c28d7867..1255b16b 100644 --- a/docs/superpowers/2026-07-27-PROGRESS-pochinka-v12.md +++ b/docs/superpowers/2026-07-27-PROGRESS-pochinka-v12.md @@ -1327,3 +1327,109 @@ for sequence». Поэтому сторож поставлен не на рез перед тем, как писать рядом, — правило, которое я нарушил второй раз за два дня. Числа после починки: портал **382/382, 1222 проверки**. + +--- + +## Задача 16 закрыта решением владельца: говорим клиенту правду (28.07.2026) + +Дороги «робот везёт документ в кабинет» не существует — доказано двумя нарочными отказами. +Владелец выбрал: приём документа оставить, но сказать клиенту как есть. + +Что сделано: + +- клиент приложил файл → в ленту сразу ложится служебная отметка: **документ у нас, + передать его Яндексу автоматически нельзя, при необходимости отнесём сами и напишем здесь**; +- владельцу уходит письмо `AdDocumentAttachedMail` на `services.monitoring.alert_email`. + 🔑 Без письма «отнесём сами» было бы **пустым обещанием**: файл просто лёг бы на диск, + и о нём никто бы не узнал; +- сам файл письмом НЕ отправляем — это чужие бумаги (лицензии, свидетельства). В письме + только номер кампании, имя файла и комментарий клиента; +- отметка и письмо под `try/catch`: беда с почтой не должна возвращать клиенту отказ + на успешно принятый документ. Есть тест-сторож. + +Обычный ответ без файла ни отметки, ни письма не порождает — иначе владельца дёргали бы +на каждую реплику клиента. + +## Экран «ждёт разбора» в админке (28.07.2026) + +Ручка была, экрана не было — нарисован третьей карточкой на странице «Реклама» в админке. +Показывает клиента, кампанию, что робот делал (человеческими словами, не `inspect`), +номер объявления в Яндексе и на чём споткнулся. Пусто — говорит об этом прямо, +а не показывает пустую таблицу. + +🔑 В подписи к экрану прямо сказано, чего там НЕ будет: обычных отказов модерации. +Их клиент разбирает сам, причина лежит у него в переписке. Экран — только про обрывы +цепочки, иначе он превратится в свалку и его перестанут смотреть. + +### Числа на конец захода 4 + +- портал: **386/386, 1232 проверки** +- экраны рекламы и админки: **35/35** на затронутых наборах +- робот: **75/75** +- мест снятия заморозки денег — **четыре** + +--- + +## 🔴 Разбор собственной работы: четыре ошибки, найденные перечитыванием (28.07.2026, ночь) + +Владелец сказал остановиться и посмотреть, что сделано. Посмотрел — нашёл четыре штуки, +одна тяжёлая. Все четыре относятся к коду, написанному в этот же день. + +### 1. Доклад разведки уронил бы очередь целиком + +Робот пишет в ленту под ролью `crm_admin_user` — маршруты робота идут через посредник +`admin-db`. У этой роли на `ad_campaign_messages` было **только чтение** (v9.14). +На бою: `permission denied` → 500 роботу → три повтора → сдался. **Задание навсегда +«в работе», а пока хоть одно задание в работе, выдача отвечает «работы нет» ВСЕМ +клиентам — очередь встала бы колом для всего продукта.** + +Обиднее всего, что в плане это было записано прямым текстом как «хвост задачи 16», и я +прошёл мимо. Лечение — запись **v9.17**: INSERT + нумератор. + +🪤 Тесты не поймали и не могли: ходят суперпользователем, права не проверяются. + +### 2. Признак «набор создан» я выдумал + +Взял метку `BatchesList` как признак удачной загрузки. А в нашей же разметке (§5.2) +написано: это **скрытая галочка**, её жмут через `force`. Проверка «видно ли её» +не сработала бы никогда — вернулся бы ровно тот алярм на каждой загрузке, который +я этой правкой и чинил. Плюс строгий режим Playwright упал бы на нескольких совпадениях. + +**Признак с экрана убран совсем.** «Окно не закрылось» объявлено нормой (так и есть +живьём), человека по виду окна не зовём. Настоящий сбой ловит портал слепком +`creatives.get` — не появился креатив, задание закрывается сбоем с внятной причиной, +и оно видно в админке «ждёт разбора». Экранный сторож не нужен. + +🪤 Урок ровно тот же, что я сам записал утром: **не гадать по экрану**. Записал — и через +несколько часов сделал. + +### 3. Сломал ленту для повторного отказа + +Поменял защиту от дублей на «такой текст уже когда-либо был». Выглядит надёжнее, а ломает +главный случай: клиент починил рекламу, отправил заново, получил ТОТ ЖЕ отказ — и такое +сообщение не показалось бы вовсе. Клиент остался бы без объяснения, почему опять не пустили. + +Вернул сравнение с последним сообщением. Заглушку «причину выясняем» держит теперь не дедуп, +а сам джоб: показываем её один раз, пока сказать нечего. + +### 4. Мой собственный тест оказался ПУСТЫШКОЙ + +Тест «после доклада разведки заглушка не возвращается» был зелёным и **оставался зелёным +при вырезанной защите**. Причина: в тесте отклонялись ВСЕ объявления кампании, а тогда +кампания уходит в «отклонена», и обход её больше не берёт — второго прохода просто +не случалось. + +Сценарий существует только при ЧАСТИЧНОМ отказе: одно объявление принято, одно отклонено, +кампания продолжает работать. Тест переписан на две штуки — теперь вырезание защиты +его роняет. + +🪤 Это второй раз за неделю: тест проверял состояние, в которое продукт не попадает. +**Вырезание защиты — единственный способ отличить сторожа от украшения.** + +### Числа после разбора + +- портал: **391/391, 1244 проверки** +- админские экраны: **9/9** +- фронт на затронутых наборах: **52/52** +- робот: **74/74** +- мест снятия заморозки денег — **четыре** diff --git a/docs/superpowers/2026-07-28-STATE-okno-peredachi.md b/docs/superpowers/2026-07-28-STATE-okno-peredachi.md index e4e360ee..69e6db34 100644 --- a/docs/superpowers/2026-07-28-STATE-okno-peredachi.md +++ b/docs/superpowers/2026-07-28-STATE-okno-peredachi.md @@ -3,15 +3,20 @@ Файл пишется в конце каждого захода. Читать его **первым делом** после компакта: всё, что нужно следующей сессии, лежит здесь, а не в переписке. -Обновлено: 28.07.2026, поздний вечер — **задачи 13, 14 и 15 закрыты**. Разведка роботом +Обновлено: 28.07.2026, ночь — **заход 4 закрыт целиком: задачи 13, 14, 15 и 16**. Разведка роботом написана целиком: портал ставит задание на отказ, робот читает экран кабинета, доклад -ложится клиенту в ленту со снимком. 🔴 **Задача 16 упёрлась окончательно: второй вердикт -пришёл, поля для документа нет и у лицензируемой тематики** — решение за владельцем, +ложится клиенту в ленту со снимком. 🔴 **Задача 16 закрыта по факту: везти документ роботом +некуда** — второй вердикт это подтвердил. Владелец выбрал говорить клиенту правду, сделано; подробности ниже и в `cabinet-flow.md` §7.6. 🔴 **Попутно починен дефект в роботе:** он грузил картинки в поле, которое Яндекс не принимает — живьём не залил бы ничего. Подробности ниже. +🔴 **В конце захода перечитал свою же работу и нашёл четыре ошибки, все исправлены.** +Тяжёлая: доклад разведки на бою уронил бы очередь заданий целиком — служебной роли +не хватало права писать в ленту (лечение — запись схемы **v9.17**). Разбор — в PROGRESS, +раздел «Разбор собственной работы». + 🪤 Записи прошлого захода помечены датой «29.07.2026» — это моя ошибка в календаре, вся та работа сделана **28.07**. Имена миграций `2026_07_29_*` менять не стали: они уже закоммичены, а дата в имени — только порядок применения. @@ -24,9 +29,9 @@ - Коммиты 28.07, вечер: `23db59bd` экран отказа снят · `22203465` вторая проба заведена · `898f9d57` Яндекс не сообщает машине причину · `865bd211` починка дефекта · `0b0a4705` снимок состояния · `7a446132` починка поля файлов у робота. -- **Заходы 1, 2 и 3 закрыты целиком** (задачи 1–12). **Куски 1 «переписка» и 2 - «оживление» замысла готовы.** Заход 4: задачи 13, 14 и 15 сделаны, - 16 упёрлась в кабинет Яндекса окончательно. +- **Все четыре захода закрыты** (задачи 1–16). **Куски 1 «переписка», 2 «оживление» + и 3 «разведка» готовы.** Кусок 3 в части доставки документа отменён по факту: + такой дороги у Яндекса нет. - Никуда не пушили, на боевой не выкатывали, рубильник Директа выключен, в живой кабинет Яндекса никто не заходил. @@ -48,14 +53,15 @@ abb3941e feat реклама за показы: удаление отклонё ## Зелёные числа на этом коммите -- портал: **382/382, 1222 проверки, ~65 с** — `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=Advertising` - (было 361 — двадцать один тест добавила разведка) +- портал: **391/391, 1244 проверки, ~69 с** — `cd app && DB_DATABASE=liderra_testing_reklama php artisan test --filter=Advertising` + (было 361 — разведка, честность про документ и разбор собственных ошибок) - админский список «ждёт разбора»: **9/9** — `--filter="AdminRobotStuck|AdminAdCampaignCreative"` +- фронт на затронутых наборах: **52/52** — `npx vitest run tests/Frontend/admin-advertising-view.spec.ts tests/Frontend/advertising-campaign-messages.spec.ts tests/Frontend/advertising-campaign-list.spec.ts tests/Frontend/advertising-api.spec.ts` - фронт: **227 файлов, 1671 тест** — `cd app && npx vitest run` 🪤 единственная посторонняя ошибка в выводе — `autopodbor.ts::fetchProposalGroups`, чужая и давняя, к этой работе отношения не имеет. - `npm run type-check` — **6 чужих** ошибок, ни одной новой. Чужие не чинить. -- робот: **75/75** — `cd bots/yandex-creatives && node --test` (было 60) +- робот: **74/74** — `cd bots/yandex-creatives && node --test` (было 60) - мест снятия заморозки денег — **четыре**: `AdvertisingCampaignController`, `SyncCampaignModerationJob`, `PauseCampaignsOnAdStop`, `CampaignImpressionCharger`. Остальные находки `->release(` — замки `Cache::lock`, не деньги. @@ -65,22 +71,24 @@ abb3941e feat реклама за показы: удаление отклонё ## Что теперь работает -Круг «Яндекс → клиент → Яндекс» замкнут в обе стороны, кроме доставки документов: +Круг «Яндекс → клиент → Яндекс» замкнут: 1. Яндекс отклоняет объявление, джоб кладёт пояснение **целиком** в переписку; -2. клиенту письмо и колокольчик, в списке под ярлыком «Отклонено» — первая строка причины; -3. клиент читает переписку в карточке кампании, отвечает и может приложить документ; -4. **клиент жмёт «Исправить»** — отклонённые объявления удаляются из кабинета, кампания +2. **робот идёт в кабинет и приносит настоящую причину** со снимком экрана — без него + портал знал бы только факт отказа; +3. клиенту письмо и колокольчик, в списке под ярлыком «Отклонено» — первая строка причины; +4. клиент читает переписку в карточке кампании, отвечает и может приложить документ; +5. **клиент жмёт «Исправить»** — отклонённые объявления удаляются из кабинета, кампания возвращается в черновик с отметкой «отдана на починку», номера кампании, группы и сегмента Яндекса целы; -5. клиент меняет картинку и настройки привычными экранами; -6. обычная кнопка «Запустить» доводит кампанию до модерации, ничего не заводя в Яндексе +6. клиент меняет картинку и настройки привычными экранами; +7. обычная кнопка «Запустить» доводит кампанию до модерации, ничего не заводя в Яндексе заново; отметка о починке гаснет, замок закрывается сам. -**Чего пока нет:** документ клиента никуда не едет — робот его не носит, и причину -из кабинета робот не читает (кусок 3, заход 4). +**Документ клиента роботом никуда не едет — и не поедет:** такой дороги у Яндекса нет. +Портал говорит об этом клиенту прямо и зовёт владельца письмом. -## Что дальше — заход 4, задачи 13–16 +## Заход 4 — задачи 13–16, все закрыты ### Задача 13 — ✅ ЗАКРЫТА 28.07.2026, вердикт пришёл @@ -118,12 +126,15 @@ abb3941e feat реклама за показы: удаление отклонё `yandex` **слово в слово**, клиенту письмо и колокольчик; 4. робот не понял, что видит — задание сбойное, владельцу письмо, **в ленту клиенту ничего не сочиняем**; -5. в админке `/api/admin/advertising/robot-stuck` — «ждёт разбора»: ВСЕ сбойные задания - робота, не только разведка (сорванная заливка — тоже вставшая кампания); +5. в админке, на странице «Реклама», третьей карточкой — экран **«Ждёт разбора»** + (`/api/admin/advertising/robot-stuck`): ВСЕ сбойные задания робота, не только разведка + (сорванная заливка — тоже вставшая кампания). В подписи прямо сказано, чего там НЕ будет: + обычных отказов модерации, их клиент разбирает сам; 6. рубильник Директа выключен — задания не ставятся и роботу не выдаются. -Новое в базе — **v9.16**, колонка `ad_creative_jobs.yandex_ad_id` + индекс. Грантов -не требует, перезапуска `03_service_bypass_policies.sql` не требует (новых таблиц нет). +Новое в базе — **v9.16** (колонка `ad_creative_jobs.yandex_ad_id` + индекс) и **v9.17** +(право `crm_admin_user` ПИСАТЬ в ленту: INSERT + нумератор). Перезапуска +`03_service_bypass_policies.sql` обе не требуют — новых таблиц нет. 🔴 **Четыре ловушки, пойманные в этой задаче** (все проверены вырезанием): @@ -157,7 +168,20 @@ abb3941e feat реклама за показы: удаление отклонё 🪤 **Урок:** поправка живой разметки обязана доезжать до КОДА в тот же заход. -### Задача 16 — 🔴 УПЁРЛАСЬ ОКОНЧАТЕЛЬНО, решение за владельцем +### ✅ Задача 16 — ЗАКРЫТА 28.07.2026 решением владельца: говорим клиенту правду + +Дороги «робот везёт документ в кабинет» не существует. Вместо неё сделано: + +- приём документа в переписке **остаётся** — файл клиента сохраняется как раньше; +- сразу после приёма в ленту ложится служебная отметка: документ у нас, передать его + Яндексу автоматически нельзя, при необходимости отнесём сами и напишем здесь; +- владельцу уходит письмо `AdDocumentAttachedMail` на `services.monitoring.alert_email` — + иначе «отнесём сами» было бы пустым обещанием, а файл просто лёг бы на диск; +- сам файл письмом НЕ отправляем: это чужие бумаги, в письме только адрес кампании; +- отметка и письмо под `try/catch` — беда с почтой не возвращает клиенту отказ + на успешно принятый документ. + +Ниже — как мы к этому пришли. **Вердикт по второй пробе пришёл 28.07 вечером: окно отказа то же самое.** Объявление № 17787102785 отклонено — «Нет предупреждений: медицина». Разметка совпала @@ -355,17 +379,42 @@ Playwright на **копии** профиля браузера MCP, вход в строк молча. ✅ Грант выдан записью **v9.14**; список «ждёт разбора» сделан 28.07 и читает `ad_creative_jobs`, а не ленту. -## Что осталось незакрытым в заходе 4 +## Что осталось незакрытым -- **Задача 16** — решение за владельцем (см. выше). Кода под неё не написано. -- **Экрана в админке для списка «ждёт разбора» нет** — сделана только ручка - `/api/admin/advertising/robot-stuck`. Экран рисовать отдельно. +- 🔴 **Живьём разведка не гонялась ни разу** — рубильник Директа выключен, боевой прогон + только вместе с владельцем. Разметка §7 снята по натуре на двух отказах, но робот по ней + ещё ни разу не ходил сам. **Это главный незакрытый риск куска 3**, и опыт этого дня + говорит прямо: между «разметка записана» и «программа по ней ходит» уже дважды + обнаруживался разрыв. Первый живой прогон делать при владельце, на пустышке + № 713110757 — там лежат два отклонённых объявления, годных для проверки. +- **Признак удачной загрузки с экрана намеренно не читаем** — подтверждённой метки + «набор создан» у нас нет. Успех определяет портал слепком `creatives.get`. Если однажды + такую метку снимут живьём, это можно упростить; выдумывать её нельзя. - **Экран ленты снимок не показывает картинкой** — файл отдаётся ссылкой через уже существующую ручку `/api/advertising/campaigns/{id}/messages/{messageId}/file`. Клиент снимок скачает, но не увидит прямо в переписке. -- **Живьём разведка не гонялась** — рубильник Директа выключен, боевой прогон только - вместе с владельцем. Разметка §7 снята по натуре на двух отказах, но робот по ней - ещё ни разу не ходил сам. +- **Частичный отказ не чиним** — записано в замысле §5 как следующий кусок, не как забытое. +- **Открытые вопросы владельца** — Д4 (проводка при нехватке денег) и срок хранения чужих + документов. Сам не закрывал. + +## Перед выкатом на боевой + +- [ ] `prod-deploy-validator` → GO +- [ ] 🔴 **перезапустить `db/03_service_bypass_policies.sql`** — в ветке новые RLS-таблицы + `ad_campaign_messages` и `ad_creative_jobs`. Файл сам обходит все таблицы с RLS, + править его не надо — надо **прогнать заново**. Без этого служебные роли увидят + ноль строк **молча**, при зелёном журнале +- [ ] записи схемы **v9.10 – v9.17** едут вместе; v9.13, v9.14 и v9.15 — обязательно вместе. + 🔴 **v9.17 обязательна вместе с разведкой**: без неё доклад робота падает на правах, + задание застревает «в работе», и очередь встаёт колом для ВСЕХ клиентов +- [ ] после выката проверить руками, что `crm_admin_user` может писать в `in_app_notifications` + (колокольчик по докладу разведки). Право должно быть с общей раздачи `db/02_grants.sql`, + но она разовая — если нет, колокольчик молча не появится, письмо при этом уйдёт +- [ ] проверить, что мест снятия заморозки денег по-прежнему четыре +- [ ] `YANDEX_DIRECT_ENABLED` держать выключенным до отдельного решения владельца +- [ ] задать в `.env` `MONITORING_ALERT_EMAIL` — на него уходит письмо о приложенном + документе (умолчание `ops@liderra.ru`) +- [ ] выкат — только с явного «go» владельца ## Красные линии, которые действуют всегда diff --git a/docs/superpowers/plans/2026-07-28-yandex-otkazy-okno-peredachi.md b/docs/superpowers/plans/2026-07-28-yandex-otkazy-okno-peredachi.md index 0ea7644f..0786d521 100644 --- a/docs/superpowers/plans/2026-07-28-yandex-otkazy-okno-peredachi.md +++ b/docs/superpowers/plans/2026-07-28-yandex-otkazy-okno-peredachi.md @@ -2093,7 +2093,26 @@ Schema::table('ad_creative_jobs', function (Blueprint $table) { --- -## Задача 16: доставка документа — 🔴 ЗАБЛОКИРОВАНА НАХОДКОЙ 28.07.2026 +## Задача 16: доставка документа — ✅ ЗАКРЫТА ПО ФАКТУ 28.07.2026, дороги нет + +> 🔴 **Вторая проба дала ответ: поля для документа нет и у лицензируемой тематики.** +> Объявление № 17787102785 («Стоматология») отклонено — «Нет предупреждений: медицина», +> окно отказа совпало с первым до метки, список полей пуст. Обоим отказам лечение одно — +> **переделать картинку**, документ не помог бы ни в одном случае. +> +> **Решение владельца 28.07.2026: закрыть задачу и сказать клиенту правду.** Что сделано +> вместо доставки роботом: +> +> - [x] приём документа в переписке **остаётся** — файл клиента сохраняется как раньше; +> - [x] сразу после приёма в ленту ложится служебная отметка: документ у нас, передать +> его Яндексу автоматически нельзя, при необходимости отнесём сами и напишем здесь; +> - [x] владельцу уходит письмо `AdDocumentAttachedMail` на адрес +> `services.monitoring.alert_email` — иначе «отнесём сами» было бы пустым обещанием, +> а файл просто лёг бы на диск; +> - [x] отметка и письмо под `try/catch`: беда с почтой не возвращает клиенту отказ +> на успешно принятый документ. +> +> Ниже — прежний текст задачи, оставлен для истории. > 🔴🔴 **Задача 13 показала, что везти документ роботом НЕКУДА.** В окне отказа Яндекса > нет ни одного поля для файла (проверено программно, список полей пуст), а кнопки diff --git a/docs/superpowers/specs/2026-07-28-yandex-otkazy-okno-peredachi-design.md b/docs/superpowers/specs/2026-07-28-yandex-otkazy-okno-peredachi-design.md index 5dbb8d40..8e59250f 100644 --- a/docs/superpowers/specs/2026-07-28-yandex-otkazy-okno-peredachi-design.md +++ b/docs/superpowers/specs/2026-07-28-yandex-otkazy-okno-peredachi-design.md @@ -284,11 +284,14 @@ PREACCEPTED | REJECTED | UNKNOWN` — и одна текстовая строк - [x] Доклад разведки попадает в переписку вместе со снимком экрана - [x] Робот не понял, что видит, — задание сбойное, письмо владельцу, клиенту ничего не сочиняем -- [ ] ~~Документ клиента доезжает до кабинета и уходит на модерацию~~ 🔴 **ОТМЕНЯЕТСЯ +- [x] ~~Документ клиента доезжает до кабинета и уходит на модерацию~~ 🔴 **ОТМЕНЕНО ПО ФАКТУ: такой дороги в кабинете Яндекса нет.** Проверено двумя нарочными отказами (обычная и лицензируемая тематика) — в окне отказа ноль полей для файла, документы Яндекс принимает только снаружи: чат поддержки или `forms.yandex.ru`. - Решение, что делать вместо этого, — за владельцем, см. `cabinet-flow.md` §7.5–7.6 + См. `cabinet-flow.md` §7.5–7.6. + **Заменено решением владельца 28.07.2026:** документ принимаем как прежде, но портал + сразу говорит клиенту правду («передать автоматически нельзя, отнесём сами»), + а владельцу уходит письмо — иначе обещание было бы пустым - [x] Рубильник Директа выключен — ни одного задания и ни одного обращения к Яндексу ### Общее