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:
@@ -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();
|
||||
});
|
||||
@@ -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): снять точный селектор чекбокса оферты,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user