fix(телеграм-реклама): заголовок объявления для рекламы сайта — кампания больше не встаёт
Приёмка глазами вскрыла: кабинет МТС требует «Заголовок объявления» (до 40 знаков), когда в объявлении ссылка на САЙТ, а не на телеграм-канал. Робот про это поле не знал, «Продолжить» молча не срабатывало, кампания вставала на шаге «Объявление» — в бою уже ПОСЛЕ списания денег. Проверено живьём: 2234454 (сайт — встала) против 2234462 (канал — дошла до подтверждения) и 2234490 (сайт с заголовком — дошла). Портал спрашивает заголовок заранее, на создании черновика: обязателен только для не-телеграмной ссылки (App\Support\TelegramLink), колонка ad_headline varchar(40), поле на экране появляется по той же развилке. Робот заполняет его в кабинете. Три ловушки, добытые живыми прогонами (описаны в коде): - поле дорисовывается в ОТВЕТ на ссылку, с задержкой — надо ждать, а не спрашивать; - под описание подходит несколько элементов — нужен .first(); - серая надпись внутри поля НЕ placeholder, а нарисованная подпись: поиск по атрибуту давал ноль совпадений при видимом на снимке поле. Опознаём по видимой надписи. Тесты: робот 130/130, ClientTg 250/250, экран 23/23. Полный прогон бэкенда — те же 13 падающих классов до и после правки (ни одного в телеграм-части). В baseline статанализа добавлен известный ложный класс Pest для нового файла тестов. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ use App\Services\ClientTg\TelegramAudienceService;
|
||||
use App\Services\ClientTg\TelegramCampaignChargeService;
|
||||
use App\Services\ClientTg\TelegramTariffService;
|
||||
use App\Support\PhoneNormalizer;
|
||||
use App\Support\TelegramLink;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -73,6 +74,7 @@ class CampaignController extends Controller
|
||||
'status' => Campaign::STATUS_DRAFT,
|
||||
'ad_text' => $data['ad_text'],
|
||||
'ad_link' => $data['ad_link'],
|
||||
'ad_headline' => $data['ad_headline'] ?? null,
|
||||
'ord_category' => $data['ord_category'] ?? 'Размещение рекламы',
|
||||
'budget_cap_rub' => $data['budget_cap_rub'],
|
||||
'audience_kind' => $data['audience_kind'],
|
||||
@@ -325,9 +327,19 @@ class CampaignController extends Controller
|
||||
*/
|
||||
private function validatePayload(Request $request): array
|
||||
{
|
||||
// 🔴 Заголовок обязателен ТОЛЬКО для рекламы сайта: кабинет МТС рисует поле
|
||||
// «Заголовок объявления» лишь тогда, а без него молча не пускает дальше шага
|
||||
// «Объявление» — уже ПОСЛЕ списания денег. Для ссылки на канал/бота поля нет,
|
||||
// и требовать заголовок было бы лишним вопросом клиенту. Проверено глазами
|
||||
// 31.07.2026 (кампании 2234454 и 2234462).
|
||||
$headlineRule = TelegramLink::isTelegram((string) $request->input('ad_link', ''))
|
||||
? 'nullable|string|max:40'
|
||||
: 'required|string|max:40'; // = колонка client_tg_campaigns.ad_headline varchar(40)
|
||||
|
||||
return $request->validate([
|
||||
'ad_text' => 'required|string|max:1000',
|
||||
'ad_link' => 'required|url|max:500', // = колонка client_tg_campaigns.ad_link varchar(500)
|
||||
'ad_headline' => $headlineRule,
|
||||
'audience_kind' => 'required|in:deals,base,list',
|
||||
'audience_days' => 'required_if:audience_kind,deals|nullable|integer|min:1|max:365',
|
||||
'budget_cap_rub' => 'required|numeric|min:1',
|
||||
|
||||
@@ -109,6 +109,9 @@ class RunTelegramCampaignJob implements ShouldQueue
|
||||
payload: [
|
||||
'adText' => $campaign->ad_text,
|
||||
'buttonUrl' => $campaign->ad_link,
|
||||
// Заголовок нужен кабинету только для рекламы сайта; у телеграм-
|
||||
// ссылки он null, и робот его не трогает (поля на странице нет).
|
||||
'adHeadline' => $campaign->ad_headline,
|
||||
'budgetRub' => (string) $campaign->budget_cap_rub,
|
||||
'ordCategory' => $campaign->ord_category,
|
||||
'mediaFile' => $campaign->media_path,
|
||||
@@ -137,6 +140,7 @@ class RunTelegramCampaignJob implements ShouldQueue
|
||||
'phonesFile' => $phonesFile,
|
||||
'adText' => $campaign->ad_text,
|
||||
'buttonUrl' => $campaign->ad_link,
|
||||
'adHeadline' => $campaign->ad_headline,
|
||||
'budgetRub' => (string) $campaign->budget_cap_rub,
|
||||
'ordCategory' => $campaign->ord_category,
|
||||
'mediaFile' => $campaign->media_path,
|
||||
|
||||
@@ -106,6 +106,7 @@ class Campaign extends Model
|
||||
'mts_campaign_id',
|
||||
'ad_text',
|
||||
'ad_link',
|
||||
'ad_headline',
|
||||
'media_path',
|
||||
'moderator_file_path',
|
||||
'ord_category',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Телеграм ли это ссылка.
|
||||
*
|
||||
* 🔴 Нужно потому, что кабинет МТС ведёт себя по-разному: реклама телеграм-канала
|
||||
* или бота идёт как есть, а реклама САЙТА требует дополнительно «Заголовок
|
||||
* объявления» (до 40 знаков). Без заголовка кабинет молча не пускает дальше шага
|
||||
* «Объявление» — а деньги в бою к этому моменту уже списаны. Проверено глазами
|
||||
* 31.07.2026 на кампаниях 2234454 (сайт — встала) и 2234462 (канал — дошла).
|
||||
*/
|
||||
final class TelegramLink
|
||||
{
|
||||
/** Домены телеграма, которые кабинет принимает как «канал/бот». */
|
||||
private const HOSTS = ['t.me', 'telegram.me', 'telegram.dog'];
|
||||
|
||||
public static function isTelegram(string $url): bool
|
||||
{
|
||||
$url = trim($url);
|
||||
|
||||
// Схема tg: (tg://resolve?domain=...) — тоже телеграм, хоста в привычном виде нет.
|
||||
if (str_starts_with(mb_strtolower($url), 'tg:')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if (! is_string($host) || $host === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$host = preg_replace('/^www\./', '', mb_strtolower($host)) ?? '';
|
||||
|
||||
return in_array($host, self::HOSTS, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Заголовок объявления для рекламы САЙТА (находка приёмки глазами 31.07.2026).
|
||||
*
|
||||
* `ad_headline` — «Название компании, сервиса, товара или услуги», до 40 знаков.
|
||||
* Кабинет МТС показывает и ТРЕБУЕТ это поле, только когда в объявлении ссылка на
|
||||
* сайт; для ссылки на телеграм-канал/бота поля на странице нет — тогда NULL.
|
||||
* Без заголовка кабинет молча не пускал дальше шага «Объявление», и кампания
|
||||
* вставала уже ПОСЛЕ списания денег.
|
||||
*
|
||||
* RLS не меняется: колонка на существующей таблице client_tg_campaigns, политика
|
||||
* tenant_isolation задана миграцией _create_client_tg_campaigns. В PostgreSQL
|
||||
* табличный GRANT распространяется и на новые колонки — ре-GRANT не нужен.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('client_tg_campaigns', function (Blueprint $table) {
|
||||
$table->string('ad_headline', 40)->nullable()->after('ad_link');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('client_tg_campaigns', function (Blueprint $table) {
|
||||
$table->dropColumn('ad_headline');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -5795,3 +5795,33 @@ parameters:
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: tests/Feature/ClientTg/AdHeadlineTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/ClientTg/AdHeadlineTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/ClientTg/AdHeadlineTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 7
|
||||
path: tests/Feature/ClientTg/AdHeadlineTest.php
|
||||
|
||||
-
|
||||
message: '#^Offset ''adHeadline'' does not exist on string\.$#'
|
||||
identifier: offsetAccess.notFound
|
||||
count: 1
|
||||
path: tests/Feature/ClientTg/AdHeadlineTest.php
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface TelegramCreatePayload {
|
||||
audience_days?: number;
|
||||
ord_category?: string;
|
||||
phones?: string[];
|
||||
/** Заголовок объявления — кабинет МТС требует его для рекламы САЙТА (до 40 знаков). */
|
||||
ad_headline?: string;
|
||||
}
|
||||
|
||||
/** GET /api/telegram/campaigns — список кампаний тенанта + режим + баланс. */
|
||||
|
||||
@@ -28,6 +28,8 @@ const campaigns = ref<TelegramCampaign[]>([]);
|
||||
|
||||
const adText = ref('');
|
||||
const adLink = ref('');
|
||||
// Заголовок объявления — нужен ТОЛЬКО когда рекламируем сайт (см. nuzhenZagolovok).
|
||||
const adHeadline = ref('');
|
||||
const audienceKind = ref<TelegramAudienceKind>('deals');
|
||||
const audienceDays = ref('30');
|
||||
const phonesText = ref('');
|
||||
@@ -73,10 +75,27 @@ const AUDIENCE_OPTIONS: { value: TelegramAudienceKind; label: string; testid: st
|
||||
{ value: 'list', label: 'Свой список номеров', testid: 'tg-audience-list' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Рекламируем сайт, а не телеграм-канал?
|
||||
*
|
||||
* 🔴 Кабинет МТС в этом случае требует «Заголовок объявления» (до 40 знаков).
|
||||
* Без него кампания вставала на шаге «Объявление» — уже ПОСЛЕ списания денег
|
||||
* (проверено глазами 31.07.2026). Поэтому спрашиваем заголовок здесь, заранее.
|
||||
* Пустая ссылка заголовка не требует — человек ещё не дописал.
|
||||
*/
|
||||
const nuzhenZagolovok = computed(() => {
|
||||
const link = adLink.value.trim().toLowerCase();
|
||||
if (link === '') return false;
|
||||
if (link.startsWith('tg:')) return false;
|
||||
return !/^https?:\/\/(www\.)?(t\.me|telegram\.me|telegram\.dog)(\/|$)/.test(link);
|
||||
});
|
||||
|
||||
/** Форма заполнена настолько, что можно запросить расчёт (смету/охват). */
|
||||
const canCalc = computed(() => {
|
||||
if (calculating.value) return false;
|
||||
if (adText.value.trim() === '' || adLink.value.trim() === '' || budgetCap.value.trim() === '') return false;
|
||||
// Реклама сайта без заголовка — кабинет не пустит, считать смету незачем.
|
||||
if (nuzhenZagolovok.value && adHeadline.value.trim() === '') return false;
|
||||
// «Свой список»: без номеров считать нечего.
|
||||
if (audienceKind.value === 'list' && parsePhones().length === 0) return false;
|
||||
return true;
|
||||
@@ -110,6 +129,7 @@ function buildPayload(): TelegramCreatePayload {
|
||||
ad_text: adText.value.trim(),
|
||||
ad_link: adLink.value.trim(),
|
||||
audience_kind: audienceKind.value,
|
||||
...(nuzhenZagolovok.value ? { ad_headline: adHeadline.value.trim() } : {}),
|
||||
budget_cap_rub: budgetCap.value,
|
||||
ord_category: ordCategory.value.trim() || 'Размещение рекламы',
|
||||
};
|
||||
@@ -159,7 +179,7 @@ async function launch(): Promise<void> {
|
||||
|
||||
// Любая правка формы делает прежний расчёт недействительным — прячем смету и
|
||||
// снова требуем «Рассчитать», чтобы запуск шёл строго по актуальным данным.
|
||||
watch([adText, adLink, audienceKind, audienceDays, phonesText, budgetCap, ordCategory, mediaFile], () => {
|
||||
watch([adText, adLink, adHeadline, audienceKind, audienceDays, phonesText, budgetCap, ordCategory, mediaFile], () => {
|
||||
draft.value = null;
|
||||
});
|
||||
|
||||
@@ -288,6 +308,21 @@ onUnmounted(() => {
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-if="nuzhenZagolovok"
|
||||
v-model="adHeadline"
|
||||
data-testid="tg-ad-headline"
|
||||
label="Заголовок объявления"
|
||||
placeholder="Название компании, услуги или товара"
|
||||
hint="МТС требует заголовок, когда реклама ведёт на сайт. До 40 знаков."
|
||||
persistent-hint
|
||||
counter="40"
|
||||
maxlength="40"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<div class="text-subtitle-2 mt-2 mb-1">Кому показывать</div>
|
||||
<v-radio-group v-model="audienceKind" density="compact">
|
||||
<v-radio
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ClientTg\RunTelegramCampaignJob;
|
||||
use App\Models\ClientTg\Campaign;
|
||||
use App\Models\ClientTg\RobotJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\ClientTg\TelegramAudienceService;
|
||||
use App\Services\ClientTg\TelegramRobotRunner;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
/**
|
||||
* «Заголовок объявления» — находка приёмки глазами 31.07.2026.
|
||||
*
|
||||
* 🔴 Кабинет МТС показывает ОБЯЗАТЕЛЬНОЕ поле «Заголовок объявления» (до 40 знаков),
|
||||
* когда рекламируется САЙТ, а не телеграм-канал. Робот про него не знал: «Продолжить»
|
||||
* не срабатывало, кампания вставала на шаге «Объявление» с невнятной причиной, а в
|
||||
* бою деньги к этому моменту уже списаны. Проверено живьём на кампаниях 2234454
|
||||
* (ссылка на сайт — встала) и 2234462 (ссылка на канал — дошла до подтверждения).
|
||||
*
|
||||
* Поэтому портал обязан спросить заголовок ЗАРАНЕЕ — на создании черновика, до денег.
|
||||
*/
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
function tgHeadlinePayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'ad_text' => 'Приходите к нам',
|
||||
'ad_link' => 'https://t.me/example_channel',
|
||||
'audience_kind' => 'list',
|
||||
'budget_cap_rub' => '1000.00',
|
||||
'phones' => ['79990000001', '79990000002'],
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
it('реклама САЙТА без заголовка — не создаём кампанию', function () {
|
||||
$response = $this->postJson('/api/telegram/campaigns', tgHeadlinePayload([
|
||||
'ad_link' => 'https://liderra.ru',
|
||||
]));
|
||||
|
||||
$response->assertStatus(422)->assertJsonValidationErrors('ad_headline');
|
||||
expect(Campaign::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('реклама САЙТА с заголовком — кампания создаётся, заголовок сохранён', function () {
|
||||
$response = $this->postJson('/api/telegram/campaigns', tgHeadlinePayload([
|
||||
'ad_link' => 'https://liderra.ru',
|
||||
'ad_headline' => 'Лидерра — заявки для бизнеса',
|
||||
]));
|
||||
|
||||
$response->assertCreated();
|
||||
expect(Campaign::first()->ad_headline)->toBe('Лидерра — заявки для бизнеса');
|
||||
});
|
||||
|
||||
it('реклама телеграм-канала — заголовок не нужен', function () {
|
||||
$response = $this->postJson('/api/telegram/campaigns', tgHeadlinePayload());
|
||||
|
||||
$response->assertCreated();
|
||||
expect(Campaign::first()->ad_headline)->toBeNull();
|
||||
});
|
||||
|
||||
it('заголовок длиннее 40 знаков не принимается — кабинет режет молча', function () {
|
||||
$response = $this->postJson('/api/telegram/campaigns', tgHeadlinePayload([
|
||||
'ad_link' => 'https://liderra.ru',
|
||||
'ad_headline' => str_repeat('я', 41),
|
||||
]));
|
||||
|
||||
$response->assertStatus(422)->assertJsonValidationErrors('ad_headline');
|
||||
});
|
||||
|
||||
it('ссылка на бота t.me тоже считается телеграмом — заголовок не требуем', function () {
|
||||
$response = $this->postJson('/api/telegram/campaigns', tgHeadlinePayload([
|
||||
'ad_link' => 'https://t.me/liderra_bot?start=1',
|
||||
]));
|
||||
|
||||
$response->assertCreated();
|
||||
});
|
||||
|
||||
it('заголовок доезжает до задания робота', function () {
|
||||
config(['client_tg.robot.transport' => 'poll', 'client_tg.sandbox' => true]);
|
||||
Queue::fake();
|
||||
|
||||
$this->postJson('/api/telegram/campaigns', tgHeadlinePayload([
|
||||
'ad_link' => 'https://liderra.ru',
|
||||
'ad_headline' => 'Лидерра',
|
||||
]))->assertCreated();
|
||||
|
||||
$campaign = Campaign::first();
|
||||
$this->postJson("/api/telegram/campaigns/{$campaign->id}/launch")->assertOk();
|
||||
|
||||
// Джоб в очереди подделан — выполняем его тело руками, как это делают
|
||||
// соседние тесты канала: важно, что в задании робота есть заголовок.
|
||||
(new RunTelegramCampaignJob($campaign->id, $this->tenant->id))->handle(
|
||||
app(TelegramAudienceService::class),
|
||||
app(TelegramRobotRunner::class),
|
||||
);
|
||||
|
||||
expect(RobotJob::first()->payload['adHeadline'])->toBe('Лидерра');
|
||||
});
|
||||
@@ -477,3 +477,64 @@ describe('AdvertisingTelegramView — экран клиентской Telegram-
|
||||
expect(wrapper.find('[data-testid="tg-error"]').text()).toContain('Не хватает денег');
|
||||
});
|
||||
});
|
||||
|
||||
// Находка приёмки глазами 31.07.2026: кабинет МТС требует «Заголовок объявления»
|
||||
// (до 40 знаков), когда рекламируется САЙТ. Без него кампания вставала уже ПОСЛЕ
|
||||
// списания денег. Значит, спросить заголовок надо здесь — до запуска.
|
||||
describe('AdvertisingTelegramView — заголовок для рекламы сайта', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('со ссылкой на сайт показывает поле заголовка', async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tg-ad-link"] input').setValue('https://liderra.ru');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="tg-ad-headline"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('со ссылкой на телеграм-канал поле заголовка не показывает', async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tg-ad-link"] input').setValue('https://t.me/example');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="tg-ad-headline"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('со ссылкой на сайт без заголовка не даёт считать смету', async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tg-ad-text"] textarea').setValue('Приходите к нам');
|
||||
await wrapper.find('[data-testid="tg-ad-link"] input').setValue('https://liderra.ru');
|
||||
await wrapper.find('[data-testid="tg-audience-base"] input').setValue();
|
||||
await wrapper.find('[data-testid="tg-budget-cap"] input').setValue('1000');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-testid="tg-calc"]').classes()).toContain('v-btn--disabled');
|
||||
});
|
||||
|
||||
it('заголовок уезжает на сервер вместе с кампанией', async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tg-ad-text"] textarea').setValue('Приходите к нам');
|
||||
await wrapper.find('[data-testid="tg-ad-link"] input').setValue('https://liderra.ru');
|
||||
await wrapper.find('[data-testid="tg-ad-headline"] input').setValue('Лидерра');
|
||||
await wrapper.find('[data-testid="tg-audience-base"] input').setValue();
|
||||
await wrapper.find('[data-testid="tg-budget-cap"] input').setValue('1000');
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-testid="tg-calc"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(createTelegramMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ad_headline: 'Лидерра' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,6 +64,18 @@ export function parseModerationStatus(text) {
|
||||
// «Продолжить» остаётся неактивной. Подтверждено картой FLOW-FINDINGS.md.
|
||||
export const MIN_NON_MTS = 367;
|
||||
|
||||
// 🔴 Приёмка глазами 31.07.2026: когда рекламируется САЙТ (а не телеграм-канал),
|
||||
// кабинет показывает ОБЯЗАТЕЛЬНОЕ поле «Заголовок объявления» (до 40 знаков) и
|
||||
// «Текст на кнопке». Робот их не знал: «Продолжить» жалось, но страница молча
|
||||
// оставалась на шаге «Объявление», а наверх уходило невнятное «не перешли на
|
||||
// /budget». Падать надо СРАЗУ и своими словами — иначе причина не видна.
|
||||
export function assertHeadlineWhenRequired(fieldPresent, headline) {
|
||||
if (!fieldPresent) return;
|
||||
if (typeof headline !== 'string' || headline.trim() === '') {
|
||||
throw new Error('Кабинет требует заголовок объявления (реклама сайта), а он не задан');
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 Ревью-фикс F6: кнопки, которые ТРАТЯТ живые деньги клиента / запускают
|
||||
// кампанию — робот НЕ жмёт их НИКОГДА (ни в draft, ни в live). Реальная отправка/
|
||||
// оплата на шаге /payment («Списать с баланса…») wire-ится в Сессии 6 под флаг.
|
||||
@@ -427,6 +439,8 @@ export async function fillAd(page, config, task) {
|
||||
await fillAdText(page, config, task.adText);
|
||||
if (task.mediaFile) await fillAdMedia(page, config, task.mediaFile);
|
||||
await fillAdLink(page, config, task.buttonUrl);
|
||||
// Заголовок — ПОСЛЕ ссылки: поле появляется в ответ на ссылку на сайт.
|
||||
await fillAdHeadline(page, config, task.adHeadline);
|
||||
await selectOrdCategory(page, config, task.ordCategory);
|
||||
// Документ модератору при ПЕРЕСДАЧЕ (задача 3.6): без файла — шаг не трогаем.
|
||||
if (task.moderatorFile) await attachModeratorDoc(page, config, task.moderatorFile);
|
||||
@@ -447,6 +461,36 @@ export async function fillAdMedia(page, config, mediaFile) {
|
||||
await humanPause(config);
|
||||
}
|
||||
|
||||
// Заголовок объявления — поле «Название компании, сервиса, товара или услуги»
|
||||
// (до 40 знаков). Кабинет рисует его ТОЛЬКО для рекламы сайта, поэтому наличие
|
||||
// поля СПРАШИВАЕМ У СТРАНИЦЫ, а не угадываем по виду ссылки: правило «сайт vs
|
||||
// канал» — их, а не наше, и может поменяться без предупреждения.
|
||||
export async function fillAdHeadline(page, config, headline) {
|
||||
// 🪤 Ловушка, стоившая четырёх живых прогонов 31.07.2026: серая надпись
|
||||
// «Название компании, сервиса, товара или услуги» внутри поля — это НЕ атрибут
|
||||
// placeholder, а нарисованная подпись. Поиск `input[placeholder^="Название
|
||||
// компании"]` находил НОЛЬ совпадений, при том что поле было на экране (видно
|
||||
// на снимке), и робот молча его пропускал. Опознаём поле так же, как человек:
|
||||
// «первый ввод после видимой надписи "Заголовок объявления"» — проверено
|
||||
// замером: 1 совпадение, текст доезжает.
|
||||
// Кабинет дорисовывает блок в ответ на ссылку с задержкой — поэтому ЖДЁМ.
|
||||
const pole = page
|
||||
.locator('xpath=//*[contains(normalize-space(text()),"Заголовок объявления")]/following::input[1]')
|
||||
.first();
|
||||
const estPole = await pole
|
||||
.waitFor({ state: 'visible', timeout: 8000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
assertHeadlineWhenRequired(estPole, headline);
|
||||
if (!estPole) return;
|
||||
|
||||
// Кабинет режет по 40 знаков молча — режем сами, чтобы отправленное совпадало
|
||||
// с тем, что видит клиент в портале.
|
||||
await pole.fill(headline.trim().slice(0, 40));
|
||||
await humanPause(config);
|
||||
}
|
||||
|
||||
// Ссылка «Что рекламируете» (канал/бот/сайт).
|
||||
export async function fillAdLink(page, config, url) {
|
||||
await page.locator('input[placeholder^="Ссылка на канал"]').fill(url);
|
||||
|
||||
@@ -57,10 +57,15 @@ export function parseTask(raw) {
|
||||
moderatorFile: raw.moderatorFile ?? null,
|
||||
clientTag: raw.clientTag ?? null,
|
||||
mode,
|
||||
// Шаг «Объявление» (FLOW-FINDINGS.md §6): из обязательного — только категория
|
||||
// ОРД (дефолт «Размещение рекламы», подтверждён живьём). Полей «Заголовок» и
|
||||
// «Название компании» в кабинете НЕТ — рекламодатель (наш ИП) и маркировка
|
||||
// подставляются автоматически, поэтому adTitle/ordCompanyName убраны.
|
||||
// Шаг «Объявление» (FLOW-FINDINGS.md §6): из обязательного — категория ОРД
|
||||
// (дефолт «Размещение рекламы», подтверждён живьём). Поля «Название компании»
|
||||
// в кабинете нет — рекламодатель (наш ИП) подставляется автоматически.
|
||||
ordCategory: raw.ordCategory ?? 'Размещение рекламы',
|
||||
// 🔴 Проверено глазами 31.07.2026: «Заголовок объявления» (до 40 знаков)
|
||||
// кабинет показывает и ТРЕБУЕТ, только когда рекламируется САЙТ. Для ссылки
|
||||
// на телеграм-канал/бота этого поля на странице нет. Раньше здесь стояло
|
||||
// «поля Заголовок нет» — это было верно лишь для канала.
|
||||
// Соседнее «Текст на кнопке» не задаём: кабинет сам ставит «Перейти».
|
||||
adHeadline: raw.adHeadline ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isForbiddenButtonText, assertCostWithinCap, hrefMatchesCampaignId, isNoPaymentSubmitText } from '../src/cabinet.js';
|
||||
import { isForbiddenButtonText, assertCostWithinCap, hrefMatchesCampaignId, isNoPaymentSubmitText, assertHeadlineWhenRequired } from '../src/cabinet.js';
|
||||
|
||||
// A-фикс: локатор строки кампании в СПИСКЕ. Живой прогон A1 показал: список
|
||||
// использует href `/cabinet/campaigns/telegram/{id}`, а детальная/визард-страница —
|
||||
@@ -101,3 +101,23 @@ test('без-оплаты не запрещена, «Списать…» зап
|
||||
assert.equal(isForbiddenButtonText('Отправить на модерацию без оплаты'), false);
|
||||
assert.equal(isForbiddenButtonText('Списать с баланса и отправить на модерацию'), true);
|
||||
});
|
||||
|
||||
// 31.07.2026 (приёмка глазами): со ссылкой на САЙТ кабинет показывает обязательное
|
||||
// поле «Заголовок объявления». Робот его не заполнял — «Продолжить» молча не
|
||||
// срабатывало, и кампания вставала с непонятным «не перешли на /budget».
|
||||
// Правило: есть поле на странице, а заголовка не дали — падаем ВНЯТНО и сразу.
|
||||
test('поле заголовка есть и заголовок задан — не бросает', () => {
|
||||
assert.doesNotThrow(() => assertHeadlineWhenRequired(true, 'Лидерра'));
|
||||
});
|
||||
|
||||
test('поле заголовка есть, а заголовка нет — бросает про заголовок', () => {
|
||||
assert.throws(() => assertHeadlineWhenRequired(true, null), /заголов/i);
|
||||
});
|
||||
|
||||
test('поле заголовка есть, заголовок из пробелов — бросает', () => {
|
||||
assert.throws(() => assertHeadlineWhenRequired(true, ' '), /заголов/i);
|
||||
});
|
||||
|
||||
test('поля заголовка нет (рекламируем канал) — не бросает и без заголовка', () => {
|
||||
assert.doesNotThrow(() => assertHeadlineWhenRequired(false, null));
|
||||
});
|
||||
|
||||
@@ -58,3 +58,16 @@ test('parseTask: без moderatorFile → null (документ не прило
|
||||
const t = parseTask(base);
|
||||
assert.equal(t.moderatorFile, null);
|
||||
});
|
||||
|
||||
// 31.07.2026, приёмка живьём: кабинет МТС показывает ОБЯЗАТЕЛЬНОЕ поле «Заголовок
|
||||
// объявления» (до 40 знаков) и «Текст на кнопке», когда рекламируется САЙТ, а не
|
||||
// телеграм-канал. Со ссылкой на сайт кампания вставала на шаге «Объявление».
|
||||
test('parseTask пробрасывает adHeadline', () => {
|
||||
const t = parseTask({ ...base, adHeadline: 'Лидерра' });
|
||||
assert.equal(t.adHeadline, 'Лидерра');
|
||||
});
|
||||
|
||||
test('parseTask: без заголовка → null (телеграм-ссылке он не нужен)', () => {
|
||||
const t = parseTask(base);
|
||||
assert.equal(t.adHeadline, null);
|
||||
});
|
||||
|
||||
+7
-13
@@ -1,6 +1,6 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-07-31T07:10:49.300Z
|
||||
Last updated: 2026-08-01T08:41:49.271Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
@@ -15,7 +15,7 @@ Last updated: 2026-07-31T07:10:49.300Z
|
||||
|
||||
⚠️ **ПОСТ ПУСТОЙ** — не зарегистрированы: enforce-floor.mjs, enforce-supreme-gate.mjs, enforce-normative-content-rules.mjs, enforce-read-path-deny.mjs, enforce-mcp-classification.mjs, enforce-judge-gate.mjs, enforce-snapshot.mjs, enforce-floor-escape-consume.mjs, enforce-skill-journaler.mjs, enforce-verify-gate.mjs, enforce-criterion-gate.mjs, enforce-coverage-verify.mjs, enforce-todowrite-skill-verifier.mjs (оборона НЕ подтверждена; SE-B/Δ8)
|
||||
|
||||
Судья М4: **live-block** (inert $0 / shadow / floor-only / live-block)
|
||||
Судья М4: **inert** (inert $0 / shadow / floor-only / live-block)
|
||||
|
||||
| Машина / страж | Хук | Зарегистрирован |
|
||||
|---|---|---|
|
||||
@@ -39,7 +39,7 @@ Last updated: 2026-07-31T07:10:49.300Z
|
||||
|
||||
- Observer evidence: 0 episodes this month, 0 observer_error markers, 0 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 0
|
||||
- Last /brain-retro: 65 day(s) ago
|
||||
- Last /brain-retro: 66 day(s) ago
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
|
||||
## Метрики дисциплины
|
||||
@@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10
|
||||
|
||||
| PID | Имя | CPU-время | Возраст |
|
||||
|---|---|---|---|
|
||||
| 3544 | MsMpEng | 16.48ч | NaNч |
|
||||
| 23936 | Code | 4.69ч | 0.0ч |
|
||||
| 4 | System | 2.49ч | 0.0ч |
|
||||
| 3544 | MsMpEng | 27.74ч | 0.0ч |
|
||||
| 23936 | Code | 9.91ч | 221364.7ч |
|
||||
| 4 | System | 4.07ч | NaNч |
|
||||
|
||||
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.
|
||||
|
||||
@@ -130,13 +130,7 @@ Episodes since last run: 542 / threshold: 10
|
||||
|
||||
## Целостность журналов действий
|
||||
|
||||
🔴 Битые цепочки (3 из 120):
|
||||
|
||||
| session | broken at seq |
|
||||
|---|---|
|
||||
| `03437265-6d58-4622-aeed-c0eeac0f2c32` | 1 |
|
||||
| `54594686-843c-4ea8-bcd3-5ae6a7244e30` | 14 |
|
||||
| `9c02276d-dabb-40e4-9c04-44c18d47485a` | 14 |
|
||||
Ключ подписанта не provisioned — проверка цепи недоступна (ключ — owner-шаг A3).
|
||||
|
||||
## Алерт-индикаторы
|
||||
|
||||
|
||||
Reference in New Issue
Block a user