feat(телеграм-реклама): Этап 2 закрыт — робот несёт фактическую стоимость (2.2), билинг МТС подтверждён

Задача 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 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-28 07:52:56 +03:00
parent 2e5db6737b
commit 5211048bce
6 changed files with 160 additions and 1 deletions
+6 -1
View File
@@ -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,
);
}
@@ -0,0 +1,51 @@
<?php
use App\Services\ClientTg\RobotResult;
it('пробрасывает строковый actualCostRub из JSON робота', function () {
$r = RobotResult::fromRobotJson([
'ok' => 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();
});
+23
View File
@@ -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): снять точный селектор чекбокса оферты,
+24
View File
@@ -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;
@@ -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');
});
@@ -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.