From 5211048bce6d362ce2a94c9bcab8f741663999db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 28 Jul 2026 07:52:56 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D1=82=D0=B5=D0=BB=D0=B5=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BC-=D1=80=D0=B5=D0=BA=D0=BB=D0=B0=D0=BC=D0=B0):=20=D0=AD?= =?UTF-8?q?=D1=82=D0=B0=D0=BF=202=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=20=E2=80=94=20=D1=80=D0=BE=D0=B1=D0=BE=D1=82=20=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=91=D1=82=20=D1=84=D0=B0=D0=BA=D1=82=D0=B8=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D0=BA=D1=83=D1=8E=20=D1=81=D1=82=D0=BE=D0=B8=D0=BC=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=8C=20(2.2),=20=D0=B1=D0=B8=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20=D0=9C=D0=A2=D0=A1=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B4=D1=91=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Задача 2.0 (разведка) — РЕШЕНА без входа в кабинет: ответ уже был в находках Фазы 2. Билинг МТС за «показы своей базе» — НАКОПИТЕЛЬНЫЙ (резерв → списание по факту показов → возврат остатка, экран /payment). Следствие: списание «по факту» (была задача 2.4) на успехе отправки честно сделать нельзя — показов ещё нет; перенесено в Этап 3 (опрос завершения). Этап 2 закрыт составом 2.1 (отмена) + 2.3 (возврат брони) + 2.2. - 2.2 PHP: RobotResult несёт `actualCostRub` (nullable string) — заготовка, чтобы позже (Этап 3) прочитать фактическую стоимость из кабинета и списать её с кошелька клиента. Проброшено в fromRobotJson (робот начнёт класть поле позже; нет поля → null). Мёртвых фабрик launched()/draftReady() не добавлял. - 2.2 Node: чистая утилита `parseCost(text)` в cabinet.js — «Стоимость кампании от 201,6 ₽» → «201.60» (запятая→точка, разделители тысяч включая неразрывный пробел код 160, дробь до 2 знаков без округления, нет числа → null). Вынесена отдельной покрытой функцией; к DOM-потоку НЕ подключена (селектор строки стоимости подтвердим живьём в Этапе 3). - Разведка билинга и решение по 2.4 зафиксированы в FLOW-FINDINGS.md; план Этапа 2 обновлён (2.0 решён, 2.4 → Этап 3, Этап 2 закрыт). TDD, робот замокан, тесты на liderra_testing. Приёмка: Node 40/40 (npm test), Pest RobotResultTest 5/5 + RobotRunnerTest (потребитель) зелёный, регрессия ClientTg 120/120, phpstan RobotResult.php 0, pint чисто, deptrac 0. Co-Authored-By: Claude Opus 4.8 --- app/app/Services/ClientTg/RobotResult.php | 7 ++- .../Feature/ClientTg/RobotResultTest.php | 51 +++++++++++++++++++ bots/mts-telegram-ads/FLOW-FINDINGS.md | 23 +++++++++ bots/mts-telegram-ads/src/cabinet.js | 24 +++++++++ .../test/cabinet-cost.test.js | 51 +++++++++++++++++++ .../2026-07-27-telegram-module-hardening.md | 5 ++ 6 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 app/tests/Feature/ClientTg/RobotResultTest.php create mode 100644 bots/mts-telegram-ads/test/cabinet-cost.test.js diff --git a/app/app/Services/ClientTg/RobotResult.php b/app/app/Services/ClientTg/RobotResult.php index 249634f0..407d84ba 100644 --- a/app/app/Services/ClientTg/RobotResult.php +++ b/app/app/Services/ClientTg/RobotResult.php @@ -6,10 +6,13 @@ namespace App\Services\ClientTg; /** * Итог запуска Node-робота (bin/run.js). Зеркалит контракт runner.js: - * успех → {ok:true, matched, launched?, campaignId?} + * успех → {ok:true, matched, launched?, campaignId?, actualCostRub?} * отказ → {ok:false, step, reason} * А также «обёрточные» отказы (таймаут/непарсабельный вывод) — тоже ok:false с * причиной, чтобы воркер не падал. + * + * actualCostRub — фактическая стоимость кампании из кабинета, строка вида + * «201.60» (или отсутствует → null; робот начнёт класть это поле позже). */ final class RobotResult { @@ -20,6 +23,7 @@ final class RobotResult public readonly ?string $campaignId = null, public readonly ?string $step = null, public readonly ?string $reason = null, + public readonly ?string $actualCostRub = null, ) {} /** @@ -38,6 +42,7 @@ final class RobotResult campaignId: isset($json['campaignId']) ? (string) $json['campaignId'] : null, step: isset($json['step']) ? (string) $json['step'] : null, reason: isset($json['reason']) ? (string) $json['reason'] : null, + actualCostRub: isset($json['actualCostRub']) ? (string) $json['actualCostRub'] : null, ); } diff --git a/app/tests/Feature/ClientTg/RobotResultTest.php b/app/tests/Feature/ClientTg/RobotResultTest.php new file mode 100644 index 00000000..f9e9703a --- /dev/null +++ b/app/tests/Feature/ClientTg/RobotResultTest.php @@ -0,0 +1,51 @@ + true, + 'matched' => 437, + 'actualCostRub' => '201.60', + ]); + + expect($r->actualCostRub)->toBe('201.60'); + expect($r->ok)->toBeTrue(); +}); + +it('приводит числовой actualCostRub к строке', function () { + $r = RobotResult::fromRobotJson([ + 'ok' => true, + 'actualCostRub' => 201.6, + ]); + + expect($r->actualCostRub)->toBe('201.6'); + expect($r->actualCostRub)->not->toBeNull(); +}); + +it('без поля actualCostRub оставляет null и не ломает остальные поля', function () { + $r = RobotResult::fromRobotJson([ + 'ok' => true, + 'matched' => 12, + 'campaignId' => '555', + ]); + + expect($r->actualCostRub)->toBeNull(); + expect($r->ok)->toBeTrue(); + expect($r->matched)->toBe(12); + expect($r->campaignId)->toBe('555'); +}); + +it('failed() не выставляет actualCostRub', function () { + $r = RobotResult::failed('таймаут'); + + expect($r->actualCostRub)->toBeNull(); + expect($r->ok)->toBeFalse(); + expect($r->reason)->toBe('таймаут'); +}); + +it('конструктор без actualCostRub даёт null по умолчанию', function () { + $r = new RobotResult(ok: true); + + expect($r->actualCostRub)->toBeNull(); +}); diff --git a/bots/mts-telegram-ads/FLOW-FINDINGS.md b/bots/mts-telegram-ads/FLOW-FINDINGS.md index 6b620253..6ff0b44d 100644 --- a/bots/mts-telegram-ads/FLOW-FINDINGS.md +++ b/bots/mts-telegram-ads/FLOW-FINDINGS.md @@ -145,6 +145,29 @@ Резерв — 2231132 (мёртвая ссылка). Снять: ГДЕ и КАК показана причина (селектор/текст), где/как приложить документ, кнопка пересдачи. Плюс: убрать тестовые кампании/черновики из списка. +## Задача 2.0 — билинг-модель МТС (РЕШЕНО из разведки Фазы 2, 28.07.2026) + +Вопрос плана: МТС списывает за «показы своей базе» РАЗОВО или НАКОПИТЕЛЬНО по показам? + +🔑 **Ответ: НАКОПИТЕЛЬНО (резерв → списание по факту показов → возврат остатка).** +Источник — экран `/payment` «Подтверждение оплаты» (см. раздел «Фаза 2» выше): +кнопка «Списать с баланса и отправить на модерацию» **резервирует** сумму, а +**окончательно списывается по факту показов**; при отмене/остановке **остаток +возвращается** на баланс кабинета. Стоимость на старте — «Стоимость кампании **от** +X ₽» (нижняя граница = аудитория «не МТС» × ≈400 ₽/1000 показов). + +**Что это значит для нашего портала (Этап 2 → Этап 3):** +- В момент успешной отправки кампании показов ещё НЕТ → фактической суммы нет. + Поэтому «списание по факту» (была задача 2.4) на успехе отправки сделать честно + нельзя — оно **переезжает в Этап 3**, к опросу завершения/статуса кампании + (когда кабинет покажет реально открученную стоимость). +- Наш портал уже зеркалит МТС: при запуске **бронирует** потолок (`freeze` в + `launch`), при отказе/сбое/отмене **возвращает** (`release`, Этап 2.3). Это ровно + соответствует «резерв → возврат остатка». +- Задача 2.2 (сделана): робот получит чистую `parseCost()` и поле + `RobotResult.actualCostRub` — заготовка, чтобы позже (Этап 3) прочитать + фактическую стоимость из кабинета и списать её с кошелька клиента. + ## Осталось (боевой запуск — только с разрешения владельца) - КОНТРОЛЬНЫЙ живой запуск (mode:'live', Задача 16): снять точный селектор чекбокса оферты, diff --git a/bots/mts-telegram-ads/src/cabinet.js b/bots/mts-telegram-ads/src/cabinet.js index cf40407c..1b2ff6e6 100644 --- a/bots/mts-telegram-ads/src/cabinet.js +++ b/bots/mts-telegram-ads/src/cabinet.js @@ -1,6 +1,30 @@ import { humanPause, gotoStable } from './browser.js'; import { debugShotsEnabled } from './shots.js'; +// Чистая утилита: вытаскивает денежную сумму из текста кабинета вида +// «Стоимость кампании от 201,6 ₽» и нормализует её в строку-десятичную дробь с +// двумя знаками после точки («201.60»). Запятая→точка, разделители тысяч (в т.ч. +// НЕРАЗРЫВНЫЙ пробел, код 160) убираются, дробная часть дополняется/обрезается до +// 2 знаков БЕЗ округления. Нет числа / пустой / null / undefined → null. +// +// Сознательно вынесена ОТДЕЛЬНОЙ чистой функцией (без DOM/page), покрытой +// юнит-тестами: её будет вызывать будущий шаг чтения фактической стоимости из +// кабинета (Этап 3 / живой прогон), когда селектор строки стоимости подтвердят +// живьём. К DOM-потоку (submitBudget/finalize) НЕ подключена намеренно. +export function parseCost(text) { + if (typeof text !== 'string') return null; + // nbsp (код 160) → обычный пробел, чтобы разделители тысяч ловились единообразно. + const cleaned = text.replace(/ /g, ' '); + // Первое число: цифры с внутренними пробелами-разделителями + опц. дробь через , или . + const m = cleaned.match(/\d[\d ]*(?:[.,]\d+)?/); + if (!m) return null; + const noSpace = m[0].replace(/ /g, ''); + const [intPart, fracRaw = ''] = noSpace.split(/[.,]/); + // Дробную часть строково дополняем/обрезаем до 2 знаков (без некорректного округления). + const frac = (fracRaw + '00').slice(0, 2); + return `${intPart}.${frac}`; +} + // Порог «не МТС» из UI кабинета (шаг «Аудитория»): ниже этого числа кнопка // «Продолжить» остаётся неактивной. Подтверждено картой FLOW-FINDINGS.md. export const MIN_NON_MTS = 367; diff --git a/bots/mts-telegram-ads/test/cabinet-cost.test.js b/bots/mts-telegram-ads/test/cabinet-cost.test.js new file mode 100644 index 00000000..47a7baef --- /dev/null +++ b/bots/mts-telegram-ads/test/cabinet-cost.test.js @@ -0,0 +1,51 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseCost } from '../src/cabinet.js'; + +test('запятая-десятичная дробь дополняется до 2 знаков', () => { + assert.equal(parseCost('Стоимость кампании от 201,6 ₽'), '201.60'); +}); + +test('тысячи с обычным пробелом-разделителем убираются', () => { + assert.equal(parseCost('Стоимость кампании от 1 209,76 ₽'), '1209.76'); +}); + +test('тысячи с НЕразрывным пробелом (код 160) убираются', () => { + assert.equal(parseCost('Стоимость кампании от 1 209,76 ₽'), '1209.76'); +}); + +test('целое число получает .00', () => { + assert.equal(parseCost('от 200 ₽'), '200.00'); +}); + +test('без пробела перед знаком рубля', () => { + assert.equal(parseCost('Стоимость кампании от 201,6₽'), '201.60'); +}); + +test('точка-разделитель дробной части тоже принимается', () => { + assert.equal(parseCost('от 201.6 ₽'), '201.60'); +}); + +test('лишние пробелы по краям игнорируются', () => { + assert.equal(parseCost(' от 200 ₽ '), '200.00'); +}); + +test('нет цифр → null', () => { + assert.equal(parseCost('Стоимость кампании'), null); +}); + +test('пустая строка → null', () => { + assert.equal(parseCost(''), null); +}); + +test('null → null', () => { + assert.equal(parseCost(null), null); +}); + +test('undefined → null', () => { + assert.equal(parseCost(undefined), null); +}); + +test('дробь длиннее 2 знаков обрезается без некорректного округления', () => { + assert.equal(parseCost('от 201,609 ₽'), '201.60'); +}); diff --git a/docs/superpowers/plans/2026-07-27-telegram-module-hardening.md b/docs/superpowers/plans/2026-07-27-telegram-module-hardening.md index 823cb293..d28ff618 100644 --- a/docs/superpowers/plans/2026-07-27-telegram-module-hardening.md +++ b/docs/superpowers/plans/2026-07-27-telegram-module-hardening.md @@ -173,6 +173,11 @@ it('повторный запуск не создаёт вторую кампа > - Накопительно → на старте НЕ списывать; держать бронь, списывать/добирать по завершении откруток (пост-фактум опрос стоимости кабинета); `actualCostRub` берётся из финального отчёта, а не на старте. > Задачи 2.2/2.4 финализируются ПОСЛЕ ответа; если модель накопительная — скорректировать точку и источник списания. +> **✅ РЕШЕНО (28.07.2026): модель НАКОПИТЕЛЬНАЯ** (резерв → списание по факту показов → возврат остатка; экран `/payment`, см. `bots/mts-telegram-ads/FLOW-FINDINGS.md`). Следствия: +> - **Задача 2.2 — СДЕЛАНА** (`RobotResult.actualCostRub` + чистая `parseCost()` в `cabinet.js`, покрыты тестами; к DOM-потоку пока НЕ подключено). +> - **Задача 2.4 (списание по факту) ПЕРЕНЕСЕНА в Этап 3.** На успехе отправки показов ещё нет — фактическую стоимость можно списать только по завершении откруток, а это опрос кабинета (Этап 3, рядом с опросом модерации/статуса). Точка списания: при закрытии кампании `charge(actualCostRub, external_key="telegram:campaign:{id}")` + `release` остатка брони. +> - **Этап 2 закрыт составом 2.1 + 2.3 + 2.2.** Бронь при запуске (`freeze` в `launch`) + возврат при отказе/сбое/отмене (`release`, задача 2.3) уже зеркалят «резерв → возврат остатка» МТС. + ### Задача 2.0 — Подтвердить билинг-модель МТС (разведка) - [ ] В кабинете/справке МТС Маркетолог выяснить, как списываются деньги за «показы своей базе»: разовая сумма за кампанию или накопление по показам. Записать в `bots/mts-telegram-ads/FLOW-FINDINGS.md`. От ответа зависят задачи 2.2/2.4.