1
0
forked from BRT/arc

Introduce TaskDumpRecorder for centralized task dump management:

- Add TaskDumpRecorder to save task-related XML dumps and upload failure info to Telegram.
- Integrate TaskDumpRecorder into `PayByPhoneScript` and `PayByCardScript` for Dushanbe and Ozon banks.
- Extend ADB utilities to record XML dumps with TaskDumpRecorder and refine keyboard dismissal logic.
- Enhance freeze detection with consecutive dump checks in CommonScript and payment scripts.
- Update mock server profiles and scenario configurations with new material IDs.
This commit is contained in:
slava 2026-04-26 19:22:40 +07:00
parent cd1828e2b5
commit 44e60fc09f
24 changed files with 740 additions and 249 deletions

View File

@ -60,27 +60,27 @@ find_package(Qt6 REQUIRED COMPONENTS
Xml Xml
) )
file(GLOB_RECURSE DB_SOURCES file(GLOB_RECURSE DB_SOURCES CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/database/*.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/database/*.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/database/*.h" "${CMAKE_CURRENT_SOURCE_DIR}/database/*.h"
) )
file(GLOB_RECURSE VIEW_SOURCES file(GLOB_RECURSE VIEW_SOURCES CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/views/*.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/views/*.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/views/*.h" "${CMAKE_CURRENT_SOURCE_DIR}/views/*.h"
) )
file(GLOB_RECURSE SERVICES_SOURCES file(GLOB_RECURSE SERVICES_SOURCES CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/services/*.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/services/*.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/services/*.h" "${CMAKE_CURRENT_SOURCE_DIR}/services/*.h"
) )
file(GLOB_RECURSE MODELS_SOURCES file(GLOB_RECURSE MODELS_SOURCES CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/models/*.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/models/*.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/models/*.h" "${CMAKE_CURRENT_SOURCE_DIR}/models/*.h"
) )
file(GLOB_RECURSE ANDROID_SOURCES file(GLOB_RECURSE ANDROID_SOURCES CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/android/*.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/android/*.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/android/*.h" "${CMAKE_CURRENT_SOURCE_DIR}/android/*.h"
) )

View File

@ -7,6 +7,8 @@
#include <QRegularExpression> #include <QRegularExpression>
#include <QThread> #include <QThread>
#include "dump/TaskDumpRecorder.h"
AdbUtils::AdbUtils(QObject *parent) : QObject(parent) { AdbUtils::AdbUtils(QObject *parent) : QObject(parent) {
} }
@ -235,9 +237,19 @@ QString AdbUtils::getScreenDumpAsXml(const QString &deviceId, int idx, bool scre
// 5. Проверяем что XML не пустой, достаточного размера и содержит непустые UI-элементы // 5. Проверяем что XML не пустой, достаточного размера и содержит непустые UI-элементы
static const QRegularExpression reNonEmpty(R"((text|content-desc)="[^"]+")"); static const QRegularExpression reNonEmpty(R"((text|content-desc)="[^"]+")");
const bool hasContent = reNonEmpty.match(xmlOut).hasMatch(); const bool hasContent = reNonEmpty.match(xmlOut).hasMatch();
// Дополнительно: ловим "WebView-shell-only" состояние, когда AT отдаёт
// только обёртку WebView и нижнюю нав-панель Ozon (FINANCE_TAB*),
// но Svelte-контент ещё не отрисован. Признак полезного среза —
// хотя бы один Button с непустым text ИЛИ EditText. Нав-табы у Ozon
// имеют пустой text (только content-desc), поэтому регекс их не зачтёт.
static const QRegularExpression reUsefulUi(
R"(class="android\.widget\.Button"[^/]*text="[^"]+"|text="[^"]+"[^/]*class="android\.widget\.Button"|class="android\.widget\.EditText")"
);
const bool hasUsefulUi = reUsefulUi.match(xmlOut).hasMatch();
if (xmlOut.trimmed().isEmpty() || !xmlOut.contains("<?xml") if (xmlOut.trimmed().isEmpty() || !xmlOut.contains("<?xml")
|| xmlOut.size() < 500 || !hasContent) { || xmlOut.size() < 500 || !hasContent || !hasUsefulUi) {
qWarning() << "[AdbUtils] getScreenDumpAsXml: bad dump (size=" << xmlOut.size() qWarning() << "[AdbUtils] getScreenDumpAsXml: bad dump (size=" << xmlOut.size()
<< "useful=" << hasUsefulUi
<< "), retry" << (retry + 1) << "of 3"; << "), retry" << (retry + 1) << "of 3";
QThread::msleep(1500); QThread::msleep(1500);
continue; continue;
@ -256,7 +268,11 @@ QString AdbUtils::getScreenDumpAsXml(const QString &deviceId, int idx, bool scre
if (const int start = xmlBytes.indexOf("<?xml"); start != -1) { if (const int start = xmlBytes.indexOf("<?xml"); start != -1) {
xmlBytes = xmlBytes.mid(start); xmlBytes = xmlBytes.mid(start);
} }
return QString::fromUtf8(xmlBytes); const QString cleanXml = QString::fromUtf8(xmlBytes);
if (auto *rec = TaskDumpRecorder::current()) {
rec->saveXml(idx, cleanXml);
}
return cleanXml;
} }
qWarning() << "[AdbUtils] getScreenDumpAsXml: empty dump after 3 retries"; qWarning() << "[AdbUtils] getScreenDumpAsXml: empty dump after 3 retries";
@ -503,19 +519,40 @@ bool AdbUtils::inputText(const QString &deviceId, const QString &text) {
} }
bool AdbUtils::hideKeyboard(const QString &deviceId) { bool AdbUtils::hideKeyboard(const QString &deviceId) {
QProcess process; // Метод 1: KEYCODE_ESCAPE (111) — работает на большинстве Android-устройств,
process.setWorkingDirectory(adbDir()); // но игнорируется на некоторых прошивках (например, TECNO/HiOS).
process.start(adbPath(), { {
"-s", deviceId, QProcess p;
"shell", "input", p.setWorkingDirectory(adbDir());
"keyevent", "111" p.start(adbPath(), {"-s", deviceId, "shell", "input", "keyevent", "111"});
}); if (p.waitForStarted(5000)) p.waitForFinished(3000);
if (!process.waitForStarted(5000)) {
qWarning() << "[AdbUtils] Failed to start adb keyevent:" << process.errorString();
return false;
} }
process.waitForFinished(); // Метод 2: KEYCODE_BACK (4) — Android посылает BACK сначала в IME; если
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0; // IME (клавиатура) видим — он его потребляет и закрывает, не уходя
// на предыдущий экран. На TECNO/HiOS это часто единственный рабочий
// вариант. Если клавиатура уже скрыта (после метода 1), BACK уйдёт
// в Activity — нежелательно. Поэтому сначала проверим через dumpsys
// input_method, видна ли всё ещё клавиатура.
bool imeVisible = false;
{
QProcess p;
p.setWorkingDirectory(adbDir());
p.start(adbPath(), {"-s", deviceId, "shell", "dumpsys", "input_method"});
if (p.waitForStarted(5000) && p.waitForFinished(5000)) {
const QString out = QString::fromUtf8(p.readAllStandardOutput());
imeVisible = out.contains(QStringLiteral("mInputShown=true"))
|| out.contains(QStringLiteral("mServedInputConnection=")) // fallback heuristic
&& !out.contains(QStringLiteral("mInputShown=false"));
}
}
if (imeVisible) {
qDebug() << "[AdbUtils] hideKeyboard: ESC didn't dismiss IME, trying BACK";
QProcess p;
p.setWorkingDirectory(adbDir());
p.start(adbPath(), {"-s", deviceId, "shell", "input", "keyevent", "4"});
if (p.waitForStarted(5000)) p.waitForFinished(3000);
}
return true;
} }
bool AdbUtils::goBack(const QString &deviceId) { bool AdbUtils::goBack(const QString &deviceId) {

View File

@ -0,0 +1,261 @@
#include "TaskDumpRecorder.h"
#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QProcess>
#include <QRegularExpression>
#include <QTextStream>
#include <QThread>
#include <QThreadStorage>
#include <qDebug>
#include "adb/AdbUtils.h"
namespace {
QThreadStorage<TaskDumpRecorder *> &tlsRecorder() {
static QThreadStorage<TaskDumpRecorder *> tls;
return tls;
}
constexpr const char *kBotToken = "8256716069:AAFgZRB_0Y6KTpqFQmCxIlZiWdY5m2dR2D8";
constexpr const char *kChatId = "-5177086220";
QString dumpsRoot() {
return QCoreApplication::applicationDirPath() + "/dumps";
}
QString sanitize(const QString &raw, int maxLen = 40) {
static const QRegularExpression re(QStringLiteral("[^\\p{L}\\p{N}_-]+"));
QString s = raw;
s.replace(re, "_");
if (s.size() > maxLen) s = s.left(maxLen);
while (s.endsWith('_')) s.chop(1);
while (s.startsWith('_')) s = s.mid(1);
return s;
}
QString extractScreenTag(const QString &xml) {
static const QRegularExpression re(QStringLiteral("text=\"([^\"]{4,40})\""));
static const QRegularExpression noiseRe(QStringLiteral("^[0-9\\s,\\.\\-+₽$€%]+$"));
auto it = re.globalMatch(xml);
while (it.hasNext()) {
QString text = it.next().captured(1).trimmed();
if (text.isEmpty() || noiseRe.match(text).hasMatch()) continue;
QString tag = sanitize(text);
if (!tag.isEmpty()) return tag;
}
return QStringLiteral("screen");
}
bool runSync(const QString &program, const QStringList &args, int timeoutMs = 180000) {
QProcess p;
p.start(program, args);
if (!p.waitForStarted(5000)) {
qWarning() << "[TaskDumpRecorder] failed to start:" << program << args
<< p.errorString();
return false;
}
if (!p.waitForFinished(timeoutMs)) {
qWarning() << "[TaskDumpRecorder] timeout:" << program << args;
p.kill();
return false;
}
if (p.exitCode() != 0) {
qWarning() << "[TaskDumpRecorder]" << program << "exit=" << p.exitCode()
<< "stderr:" << QString::fromUtf8(p.readAllStandardError()).trimmed();
return false;
}
return true;
}
bool zipFolder(const QString &folder, const QString &archivePath) {
#ifdef Q_OS_WIN
const QString cmd = QString(
"Compress-Archive -Path '%1\\*' -DestinationPath '%2' -Force"
).arg(QDir::toNativeSeparators(folder), QDir::toNativeSeparators(archivePath));
return runSync("powershell", {"-NoProfile", "-Command", cmd});
#else
const QFileInfo fi(folder);
QProcess p;
p.setWorkingDirectory(fi.absolutePath());
p.start("zip", {"-rq", archivePath, fi.fileName()});
if (!p.waitForStarted(5000) || !p.waitForFinished(180000)) {
qWarning() << "[TaskDumpRecorder] zip failed:" << p.errorString();
return false;
}
return p.exitCode() == 0;
#endif
}
void uploadToTelegram(const QString &archivePath, const QString &caption) {
const QString url = QString("https://api.telegram.org/bot%1/sendDocument").arg(kBotToken);
const QStringList args = {
"-sS", "--max-time", "120",
"-F", QString("chat_id=%1").arg(kChatId),
"-F", QString("document=@%1").arg(archivePath),
"-F", QString("caption=%1").arg(caption),
url
};
runSync("curl", args, 150000);
}
} // namespace
TaskDumpRecorder *TaskDumpRecorder::current() {
return tlsRecorder().hasLocalData() ? tlsRecorder().localData() : nullptr;
}
void TaskDumpRecorder::begin(const QString &bank, const QString &taskKind,
const QString &adbDeviceId, const TaskDumpMeta &meta) {
if (auto *prev = current()) {
prev->finishInternal(QStringLiteral("interrupted (new task started before previous finished)"));
// Не вызываем `delete prev` — QThreadStorage<T*> сам удалит предыдущий
// указатель при замене через setLocalData.
}
auto *rec = new TaskDumpRecorder(bank, taskKind, adbDeviceId, meta);
tlsRecorder().setLocalData(rec);
}
void TaskDumpRecorder::finish(const QString &errorMessage) {
auto *rec = current();
if (!rec) return;
rec->finishInternal(errorMessage);
// setLocalData(nullptr) сам вызовет delete на текущем указателе.
tlsRecorder().setLocalData(nullptr);
}
TaskDumpRecorder::TaskDumpRecorder(const QString &bank, const QString &taskKind,
const QString &adbDeviceId, const TaskDumpMeta &meta)
: m_bank(sanitize(bank)),
m_taskKind(sanitize(taskKind)),
m_adbDeviceIdRaw(adbDeviceId),
m_adbDeviceIdSafe(sanitize(adbDeviceId)),
m_meta(meta) {
// Короткий суффикс для имени папки — чтобы визуально отличать задачи,
// но не раздувать путь. Берём materialId (первые 8 символов — UUID segment)
// + сумму, если есть.
QString folderTail;
if (!m_meta.materialId.isEmpty()) {
folderTail = sanitize(m_meta.materialId.left(8));
}
if (m_meta.amount != 0) {
const QString amt = QString::number(static_cast<qint64>(m_meta.amount));
folderTail = folderTail.isEmpty() ? amt : folderTail + "_" + amt;
}
const QString ts = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss");
const QString sub = folderTail.isEmpty()
? QString("%1_%2").arg(ts, m_adbDeviceIdSafe)
: QString("%1_%2_%3").arg(ts, m_adbDeviceIdSafe, folderTail);
m_folder = QString("%1/%2_%3/%4")
.arg(dumpsRoot(), m_bank, m_taskKind, sub);
if (!QDir().mkpath(m_folder)) {
qWarning() << "[TaskDumpRecorder] failed to mkpath" << m_folder;
m_folder.clear();
return;
}
qDebug() << "[TaskDumpRecorder] begin:" << m_folder;
}
QString TaskDumpRecorder::buildCaption(const QString &errorMessage) const {
QString s = QString("Bank: %1 | Task: %2 | Device: %3")
.arg(m_bank, m_taskKind, m_adbDeviceIdRaw);
if (!m_meta.materialId.isEmpty()) s += QString("\nMaterial ID: %1").arg(m_meta.materialId);
if (m_meta.amount != 0) s += QString("\nAmount: %1").arg(static_cast<qint64>(m_meta.amount));
if (!m_meta.phone.isEmpty()) s += QString("\nPhone: %1").arg(m_meta.phone);
if (!m_meta.card.isEmpty()) s += QString("\nCard: %1").arg(m_meta.card);
s += QString("\nError: %1").arg(errorMessage.left(800));
return s;
}
void TaskDumpRecorder::saveXml(int counter, const QString &xml) {
if (m_folder.isEmpty() || xml.isEmpty()) return;
const QString tag = extractScreenTag(xml);
const QString fname = QString("%1/%2_%3.xml")
.arg(m_folder,
QString("%1").arg(counter, 4, 10, QChar('0')),
tag);
QFile f(fname);
if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
f.write(xml.toUtf8());
f.close();
} else {
qWarning() << "[TaskDumpRecorder] failed to write" << fname << f.errorString();
}
}
void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
if (m_finished) return;
m_finished = true;
if (m_folder.isEmpty()) return;
if (errorMessage.isEmpty()) {
QDir(m_folder).removeRecursively();
qDebug() << "[TaskDumpRecorder] success — removed" << m_folder;
return;
}
qWarning() << "[TaskDumpRecorder] task failed:" << errorMessage
<< "folder:" << m_folder;
// Финальный скриншот текущего экрана
if (!m_adbDeviceIdRaw.isEmpty()) {
const QByteArray png = AdbUtils::captureScreenshotBytes(m_adbDeviceIdRaw);
if (!png.isEmpty()) {
QFile f(m_folder + "/_final_screen.png");
if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
f.write(png);
f.close();
}
}
}
// Метаданные
{
QFile ef(m_folder + "/_error.txt");
if (ef.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
QTextStream ts(&ef);
ts.setEncoding(QStringConverter::Utf8);
ts << "bank: " << m_bank << "\n"
<< "task: " << m_taskKind << "\n"
<< "device: " << m_adbDeviceIdRaw << "\n";
if (!m_meta.materialId.isEmpty()) ts << "material_id: " << m_meta.materialId << "\n";
if (m_meta.amount != 0) ts << "amount: " << static_cast<qint64>(m_meta.amount) << "\n";
if (!m_meta.phone.isEmpty()) ts << "phone: " << m_meta.phone << "\n";
if (!m_meta.card.isEmpty()) ts << "card: " << m_meta.card << "\n";
ts << "time: " << QDateTime::currentDateTime().toString(Qt::ISODate) << "\n"
<< "error: " << errorMessage << "\n";
ef.close();
}
}
const QString folder = m_folder;
const QString archivePath = m_folder + ".zip";
const QString caption = buildCaption(errorMessage);
QThread *bg = QThread::create([folder, archivePath, caption]() {
if (zipFolder(folder, archivePath)) {
uploadToTelegram(archivePath, caption);
} else {
qWarning() << "[TaskDumpRecorder] zip failed, skipping Telegram upload for" << folder;
}
QDir(folder).removeRecursively();
QFile::remove(archivePath);
});
QObject::connect(bg, &QThread::finished, bg, &QObject::deleteLater);
bg->start();
}
TaskDumpRecorder::Scope::Scope(const QString &bank, const QString &taskKind,
const QString &adbDeviceId, const TaskDumpMeta &meta,
const QString *errorPtr)
: m_errorPtr(errorPtr) {
TaskDumpRecorder::begin(bank, taskKind, adbDeviceId, meta);
}
TaskDumpRecorder::Scope::~Scope() {
TaskDumpRecorder::finish(m_errorPtr ? *m_errorPtr : QString());
}

View File

@ -0,0 +1,54 @@
#pragma once
#include <QString>
// Мета-поля задачи. Пустые/нулевые значения не попадают в caption Telegram
// и в имя папки — выводятся только те, что релевантны конкретному типу задачи.
struct TaskDumpMeta {
QString materialId;
double amount = 0; // 0 = нет
QString phone; // пусто = нет
QString card; // пусто = нет
};
class TaskDumpRecorder {
public:
// Возвращает рекордер текущего потока (nullptr если задача не начата).
static TaskDumpRecorder* current();
// RAII-скоуп: при создании регистрирует рекордер на текущий поток,
// при разрушении — финализирует (успех/ошибка определяются по errorPtr).
class Scope {
public:
Scope(const QString &bank, const QString &taskKind,
const QString &adbDeviceId, const TaskDumpMeta &meta,
const QString *errorPtr);
~Scope();
Scope(const Scope &) = delete;
Scope &operator=(const Scope &) = delete;
private:
const QString *m_errorPtr;
};
// Сохраняет XML-дамп в папку задачи. Имя файла: NNNN_<screenTag>.xml.
void saveXml(int counter, const QString &xml);
private:
TaskDumpRecorder(const QString &bank, const QString &taskKind,
const QString &adbDeviceId, const TaskDumpMeta &meta);
static void begin(const QString &bank, const QString &taskKind,
const QString &adbDeviceId, const TaskDumpMeta &meta);
static void finish(const QString &errorMessage);
void finishInternal(const QString &errorMessage);
QString buildCaption(const QString &errorMessage) const;
QString m_folder;
QString m_bank;
QString m_taskKind;
QString m_adbDeviceIdRaw;
QString m_adbDeviceIdSafe;
TaskDumpMeta m_meta;
bool m_finished = false;
};

View File

@ -9,6 +9,7 @@
#include <leptonica/allheaders.h> #include <leptonica/allheaders.h>
#include "adb/AdbUtils.h" #include "adb/AdbUtils.h"
#include "dump/TaskDumpRecorder.h"
#include "dao/MaterialDAO.h" #include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
@ -231,12 +232,14 @@ GetLastTransactionsScript::GetLastTransactionsScript(
QString appCode, QString appCode,
double searchAmount, double searchAmount,
QDateTime searchTime, QDateTime searchTime,
QString materialId,
QObject *parent QObject *parent
) : CommonScript(parent), ) : CommonScript(parent),
m_deviceId(std::move(deviceId)), m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)), m_appCode(std::move(appCode)),
m_searchAmount(searchAmount), m_searchAmount(searchAmount),
m_searchTime(std::move(searchTime)) { m_searchTime(std::move(searchTime)),
m_materialId(std::move(materialId)) {
} }
GetLastTransactionsScript::~GetLastTransactionsScript() = default; GetLastTransactionsScript::~GetLastTransactionsScript() = default;
@ -255,6 +258,9 @@ void GetLastTransactionsScript::doStart() {
return; return;
} }
const TaskDumpMeta dumpMeta{m_materialId, m_searchAmount, {}, {}};
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (!xmlScreenParser.isHomeScreen()) { if (!xmlScreenParser.isHomeScreen()) {

View File

@ -16,6 +16,7 @@ public:
QString appCode, QString appCode,
double searchAmount = 0, double searchAmount = 0,
QDateTime searchTime = {}, QDateTime searchTime = {},
QString materialId = {},
QObject *parent = nullptr QObject *parent = nullptr
); );
@ -35,6 +36,7 @@ private:
const QString m_appCode; const QString m_appCode;
const double m_searchAmount; // сумма в сомони (0 = все последние) const double m_searchAmount; // сумма в сомони (0 = все последние)
const QDateTime m_searchTime; // примерное время (UTC) const QDateTime m_searchTime; // примерное время (UTC)
const QString m_materialId; // UUID задачи (для сохранения дампов)
QString m_error; QString m_error;
}; };

View File

@ -7,6 +7,7 @@
#include <leptonica/allheaders.h> #include <leptonica/allheaders.h>
#include "adb/AdbUtils.h" #include "adb/AdbUtils.h"
#include "dump/TaskDumpRecorder.h"
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h" #include "dao/TransactionDAO.h"
@ -121,6 +122,9 @@ void PayByCardScript::doStart() {
return; return;
} }
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, {}, m_cardNumber};
TaskDumpRecorder::Scope dumpScope("dushanbe", "CREATE_TRANSACTION_CARD", device.adbSerial, dumpMeta, &m_error);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
if (!xmlScreenParser.isHomeScreen()) { if (!xmlScreenParser.isHomeScreen()) {

View File

@ -7,6 +7,7 @@
#include <leptonica/allheaders.h> #include <leptonica/allheaders.h>
#include "adb/AdbUtils.h" #include "adb/AdbUtils.h"
#include "dump/TaskDumpRecorder.h"
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h" #include "dao/TransactionDAO.h"
@ -118,6 +119,9 @@ void PayByPhoneScript::doStart() {
return; return;
} }
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, m_phone, {}};
TaskDumpRecorder::Scope dumpScope("dushanbe", "CREATE_TRANSACTION_PHONE", device.adbSerial, dumpMeta, &m_error);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
if (!xmlScreenParser.isHomeScreen()) { if (!xmlScreenParser.isHomeScreen()) {

View File

@ -447,6 +447,33 @@ bool CommonScript::isAppRunning(const QString &deviceId, const QString &packageN
return !AdbUtils::tryToGetRunningAppPid(deviceId, packageName).isEmpty(); return !AdbUtils::tryToGetRunningAppPid(deviceId, packageName).isEmpty();
} }
bool CommonScript::FreezeDetector::isUsefulDump(const QString &xml) {
if (xml.isEmpty()) return false;
// Полезный дамп должен содержать хотя бы один Button с непустым text
// ИЛИ EditText. Иначе — это shell-only state (только нав-табы, у которых
// text пуст и есть только content-desc).
if (!xml.contains(QStringLiteral("class=\"android.widget.Button\""))) return false;
static const QRegularExpression usefulRe(
R"(class="android\.widget\.Button"[^/]*text="[^"]+"|text="[^"]+"[^/]*class="android\.widget\.Button"|class="android\.widget\.EditText")"
);
return usefulRe.match(xml).hasMatch();
}
bool CommonScript::FreezeDetector::noticeAndCheckFrozen(const QString &xml) {
if (!isUsefulDump(xml)) {
// Пустой или WebView-shell-only — не учитываем.
return false;
}
if (!m_prev.isEmpty() && xml == m_prev) {
++m_streak;
if (m_streak >= m_maxStreak) return true;
} else {
m_streak = 0;
}
m_prev = xml;
return false;
}
void CommonScript::stop() { void CommonScript::stop() {
m_running = false; m_running = false;
} }

View File

@ -69,6 +69,33 @@ protected:
// Проверяет что приложение ещё запущено на устройстве // Проверяет что приложение ещё запущено на устройстве
static bool isAppRunning(const QString &deviceId, const QString &packageName); static bool isAppRunning(const QString &deviceId, const QString &packageName);
// Детектор зафризившегося экрана: считает подряд идущие байт-идентичные
// дампы с реальным контентом. Бесполезные дампы (пустые из-за adb-сбоя
// или WebView-shell-only без отрисованного Svelte-контента) пропускаются —
// не обнуляют счётчик и не накручивают его.
//
// Использование в wait-loop:
// FreezeDetector freezeCheck;
// for (int attempt = 0; attempt < N; ++attempt) {
// QString xml = AdbUtils::getScreenDumpAsXml(...);
// xmlScreenParser.parseAndSaveXml(xml);
// if (freezeCheck.noticeAndCheckFrozen(xml)) {
// m_error = "Экран зафризился"; return;
// }
// ... existing logic
// }
class FreezeDetector {
public:
explicit FreezeDetector(int maxStreak = 3) : m_maxStreak(maxStreak) {}
// True если получили (m_maxStreak + 1) подряд байт-идентичных полезных дампов.
bool noticeAndCheckFrozen(const QString &xml);
private:
static bool isUsefulDump(const QString &xml);
QString m_prev;
int m_streak = 0;
int m_maxStreak;
};
bool m_running = true; bool m_running = true;
ScreenXmlParser xmlScreenParser; ScreenXmlParser xmlScreenParser;
int m_counter = 0; int m_counter = 0;

View File

@ -11,6 +11,7 @@
#include <leptonica/allheaders.h> #include <leptonica/allheaders.h>
#include "adb/AdbUtils.h" #include "adb/AdbUtils.h"
#include "dump/TaskDumpRecorder.h"
#include "dao/MaterialDAO.h" #include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
@ -83,13 +84,15 @@ GetLastTransactionsScript::GetLastTransactionsScript(
int maxCount, int maxCount,
double searchAmount, double searchAmount,
QDateTime searchTime, QDateTime searchTime,
QString materialId,
QObject *parent QObject *parent
) : CommonScript(parent), ) : CommonScript(parent),
m_deviceId(std::move(deviceId)), m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)), m_appCode(std::move(appCode)),
m_maxCount(maxCount), m_maxCount(maxCount),
m_searchAmount(searchAmount), m_searchAmount(searchAmount),
m_searchTime(std::move(searchTime)) { m_searchTime(std::move(searchTime)),
m_materialId(std::move(materialId)) {
} }
GetLastTransactionsScript::~GetLastTransactionsScript() = default; GetLastTransactionsScript::~GetLastTransactionsScript() = default;
@ -107,6 +110,9 @@ void GetLastTransactionsScript::doStart() {
return; return;
} }
const TaskDumpMeta dumpMeta{m_materialId, m_searchAmount, {}, {}};
TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error);
if (!navigateToTransactionList(device, app)) { if (!navigateToTransactionList(device, app)) {
emit finishedWithResult(m_error); emit finishedWithResult(m_error);
return; return;
@ -245,7 +251,19 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
QTimeZone deviceTz(tzName.toUtf8()); QTimeZone deviceTz(tzName.toUtf8());
if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow"); if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow");
const int maxScrolls = 30; const int maxScrolls = 30;
const int navBarTop = device.screenHeight - (device.screenHeight / 10); // Реальный y верхней границы нижнего нав-холдера. Если в XML есть
// ru.ozon.fintech.finance:id/main_activity_bottom_navigation_holder,
// используем его y1; иначе грубая оценка ~10% снизу.
auto detectNavBarTop = [&]() -> int {
for (const Node &n : xmlScreenParser.getNodes()) {
if (n.resourceId == "ru.ozon.fintech.finance:id/main_activity_bottom_navigation_holder"
&& n.height() > 0) {
return n.y1();
}
}
return device.screenHeight - (device.screenHeight / 10);
};
int navBarTop = detectNavBarTop();
// Целевая дата в таймзоне устройства для поиска заголовка дня в списке // Целевая дата в таймзоне устройства для поиска заголовка дня в списке
const QDateTime searchLocal = m_searchTime.toTimeZone(deviceTz); const QDateTime searchLocal = m_searchTime.toTimeZone(deviceTz);
@ -459,10 +477,13 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
return; return;
} }
// Пропускаем кнопки вне Y-диапазона нужной даты // Пропускаем кнопки вне Y-диапазона нужной даты.
// Используем y1 (верх кнопки), а не центр — длинные кнопки могут
// заходить под нав-бар, но всё равно tappable (тапнем по верхней части).
const int btnY1 = txButtons[bi].y1();
const int btnY = txButtons[bi].y(); const int btnY = txButtons[bi].y();
if (btnY <= dateHeaderY || btnY >= nextDateHeaderY) continue; if (btnY1 <= dateHeaderY || btnY1 >= nextDateHeaderY) continue;
if (btnY <= 0 || btnY >= navBarTop) continue; if (btnY1 <= 0 || btnY1 >= navBarTop) continue;
++buttonsInRange; ++buttonsInRange;
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text); const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text);
@ -472,12 +493,21 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue; if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue;
const Node &visibleBtn = txButtons[bi]; const Node &visibleBtn = txButtons[bi];
// Зажимаем тап-Y на видимую часть кнопки: если кнопка заходит под
// нав-бар, центр окажется на нав-баре и тап улетит мимо.
const int safeMaxY = navBarTop - 20;
int tapY = visibleBtn.y();
if (tapY > safeMaxY) {
tapY = qMax(visibleBtn.y1() + 30, safeMaxY);
}
qDebug() << "[FetchTransactions] Amount match:" << parsed.amount qDebug() << "[FetchTransactions] Amount match:" << parsed.amount
<< "at" << visibleBtn.x() << visibleBtn.y() << "at" << visibleBtn.x() << tapY
<< "(dateY:" << dateHeaderY << "-" << nextDateHeaderY << ")"; << "(btn y1=" << visibleBtn.y1() << "y2=" << visibleBtn.y2()
<< "navBarTop=" << navBarTop
<< "dateY:" << dateHeaderY << "-" << nextDateHeaderY << ")";
// Тапаем и ждём деталей // Тапаем и ждём деталей
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y()); AdbUtils::makeTap(m_deviceId, visibleBtn.x(), tapY);
QThread::msleep(2000); QThread::msleep(2000);
TransactionInfo detail; TransactionInfo detail;
@ -555,49 +585,13 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
<< "amount=" << parsed.amount << "time=" << dtStr << "amount=" << parsed.amount << "time=" << dtStr
<< "name=" << detail.name; << "name=" << detail.name;
// Скриншот экрана деталей транзакции — отдаём в EventHandler.
// Раньше дополнительно тапали "Документы" + OCR ради ID операции,
// но это медленно и нестабильно. Теперь полагаемся на дату+время+
// сумму как идентификатор.
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId); m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
// Пробуем получить ID через "Документы" → OCR
QString externalId = detail.bankTrExternalId; QString externalId = detail.bankTrExternalId;
if (externalId.isEmpty() && !QThread::currentThread()->isInterruptionRequested()) {
Node docBtn = xmlScreenParser.findButtonNode("Документы", false);
if (!docBtn.isEmpty()) {
qDebug() << "[FetchTransactions] Tapping 'Документы'...";
AdbUtils::makeTap(m_deviceId, docBtn.x(), docBtn.y());
QThread::msleep(2000);
bool docLoaded = false;
for (int da = 0; da < 5; ++da) {
if (QThread::currentThread()->isInterruptionRequested()) break;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) {
docLoaded = true;
break;
}
QThread::msleep(1500);
}
if (docLoaded) {
QThread::msleep(500);
const QByteArray screenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
if (!screenshot.isEmpty()) {
m_resultScreenshot = screenshot;
const QString ocrText = recognizeTextFromImage(screenshot);
if (!ocrText.isEmpty()) {
qDebug() << "[FetchTransactions] OCR result:" << ocrText;
externalId = extractTransactionId(ocrText);
if (!externalId.isEmpty()) {
qDebug() << "[FetchTransactions] Extracted ID:" << externalId;
}
}
}
}
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
}
}
// Сохраняем // Сохраняем
if (accountId != -1) { if (accountId != -1) {
@ -676,39 +670,55 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
// Скроллим если не нашли нужную транзакцию // Скроллим если не нашли нужную транзакцию
if (!tapped || !hasNewButton) { if (!tapped || !hasNewButton) {
// Признак того, что под фолдом ещё есть не раскрытые транзакции
// (Ozon хранит off-screen-row как android.view.View с height=0).
const bool moreBelow = xmlScreenParser.hasCollapsedTransactionsBelow(device.screenHeight);
qDebug() << "[FetchTransactions] Scrolling (tapped=" << tapped qDebug() << "[FetchTransactions] Scrolling (tapped=" << tapped
<< "hasNew=" << hasNewButton << "inRange=" << buttonsInRange << "hasNew=" << hasNewButton << "inRange=" << buttonsInRange
<< "noProgress=" << noProgressCount << "noProgress=" << noProgressCount
<< "dateHeaderY=" << dateHeaderY << "dateHeaderY=" << dateHeaderY
<< "nextDateHeaderY=" << nextDateHeaderY << ")"; << "nextDateHeaderY=" << nextDateHeaderY
<< "moreBelow=" << moreBelow << ")";
const int cx = device.screenWidth / 2;
if (dateHeaderY == 0 && nextDateHeaderY < device.screenHeight) { if (dateHeaderY == 0 && nextDateHeaderY < device.screenHeight) {
// Target-header ушёл вверх, под ним видна более старая дата — // Target-header ушёл вверх — свайпим вниз чтоб вернуть его.
// скроллим обратно наверх (swipe вниз) чтобы вернуться к target. AdbUtils::makeSwipe(m_deviceId, cx,
const int fromY = device.screenHeight / 3; device.screenHeight * 2 / 5, cx,
const int toY = device.screenHeight * 2 / 3; device.screenHeight * 3 / 4, 600);
qDebug() << "[FetchTransactions] Scroll UP: from" << fromY << "to" << toY;
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, fromY,
device.screenWidth / 2, toY, 500);
} else if (dateHeaderY > 0 && dateHeaderY < device.screenHeight / 2) {
// Заголовок в верхней половине — маленький скролл чтобы показать
// больше кнопок под этой датой
const int fromY = device.screenHeight * 2 / 3;
const int toY = device.screenHeight / 3;
qDebug() << "[FetchTransactions] Small scroll down: from" << fromY << "to" << toY
<< "(dateHeaderY=" << dateHeaderY << ")";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, fromY,
device.screenWidth / 2, toY, 500);
} else {
// Заголовок в нижней половине или не найден — скролл вниз
const int fromY = device.screenHeight * 3 / 4;
const int toY = device.screenHeight / 4;
qDebug() << "[FetchTransactions] Full scroll down: from" << fromY << "to" << toY
<< "(dateHeaderY=" << dateHeaderY << ")";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, fromY,
device.screenWidth / 2, toY, 400);
}
QThread::msleep(1500); QThread::msleep(1500);
} else {
// Скролл вниз. Ozon WebView ведёт себя странно: ОДИН медленный
// свайп иногда игнорируется (видимо, расценивается как hold).
// Стратегия: серия из 3 коротких быстрых свайпов внутри
// безопасной зоны (выше нав-бара). Это надёжно триггерит
// нативный fling в WebView.
// safeBottom — верхняя граница нав-бара минус запас.
// Когда target-секция уже у нижнего края (dateHeaderY >= safeBottom),
// зона "внутри секции" схлопывается — свайпаем фиксированным
// диапазоном относительно safeBottom, чтобы вытащить контент
// секции из-под нав-бара.
const int safeBottom = navBarTop - 30;
int amplitude;
if (dateHeaderY > 0 && dateHeaderY < safeBottom - 100) {
// Header виден достаточно высоко — скроллим внутри его секции.
const int safeTop = qMax(dateHeaderY + 80, device.screenHeight / 4);
amplitude = qMin(safeBottom - safeTop, 700);
} else {
// Header у самого низа или ниже нав-бара — большой свайп.
amplitude = qMin(safeBottom - device.screenHeight / 4, 700);
}
amplitude = qMax(amplitude, 300); // минимум 300px чтобы было ощутимо
qDebug() << "[FetchTransactions] Triple-swipe scroll:"
<< "safeBottom=" << safeBottom << "amplitude=" << amplitude
<< "(dateHeaderY=" << dateHeaderY << " moreBelow=" << moreBelow << ")";
for (int i = 0; i < 3; ++i) {
AdbUtils::makeSwipe(m_deviceId, cx, safeBottom,
cx, safeBottom - amplitude, 250);
QThread::msleep(350);
}
QThread::msleep(800);
}
} }
} }

View File

@ -20,6 +20,7 @@ public:
int maxCount = 0, int maxCount = 0,
double searchAmount = 0, double searchAmount = 0,
QDateTime searchTime = {}, QDateTime searchTime = {},
QString materialId = {},
QObject *parent = nullptr QObject *parent = nullptr
); );
@ -43,6 +44,7 @@ private:
const int m_maxCount; // 0 = фильтр по 24ч, >0 = N последних const int m_maxCount; // 0 = фильтр по 24ч, >0 = N последних
const double m_searchAmount; // сумма в рублях (0 = не искать конкретную) const double m_searchAmount; // сумма в рублях (0 = не искать конкретную)
const QDateTime m_searchTime; // примерное время (UTC) const QDateTime m_searchTime; // примерное время (UTC)
const QString m_materialId; // UUID задачи (для сохранения дампов)
QString m_error; QString m_error;
}; };

View File

@ -11,6 +11,7 @@
#include <leptonica/allheaders.h> #include <leptonica/allheaders.h>
#include "adb/AdbUtils.h" #include "adb/AdbUtils.h"
#include "dump/TaskDumpRecorder.h"
#include "dao/MaterialDAO.h" #include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
@ -90,6 +91,8 @@ void PayByCardScript::doStart() {
} }
const QString &adbId = device.adbSerial; const QString &adbId = device.adbSerial;
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, {}, m_cardNumber};
TaskDumpRecorder::Scope dumpScope("ozon", "CREATE_TRANSACTION_CARD", adbId, dumpMeta, &m_error);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
@ -173,10 +176,17 @@ void PayByCardScript::makePayment(
Node cardBtn; Node cardBtn;
bool paymentsPageLoaded = false; bool paymentsPageLoaded = false;
bool titleSeen = false; bool titleSeen = false;
{
FreezeDetector freezeCheck;
for (int attempt = 0; attempt < 10; ++attempt) { for (int attempt = 0; attempt < 10; ++attempt) {
if (!checkApp()) return; if (!checkApp()) return;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); const QString xml = AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter);
xmlScreenParser.parseAndSaveXml(xml);
closeGooglePlayBannerIfVisible(deviceId, width, height); closeGooglePlayBannerIfVisible(deviceId, width, height);
if (freezeCheck.noticeAndCheckFrozen(xml)) {
qWarning() << "[PayByCard] screen frozen waiting Платежи и переводы";
break;
}
Node title = xmlScreenParser.findTextNode("Платежи и переводы"); Node title = xmlScreenParser.findTextNode("Платежи и переводы");
if (!title.isEmpty()) titleSeen = true; if (!title.isEmpty()) titleSeen = true;
cardBtn = xmlScreenParser.findButtonNode("По номеру карты", false); cardBtn = xmlScreenParser.findButtonNode("По номеру карты", false);
@ -187,6 +197,7 @@ void PayByCardScript::makePayment(
qDebug() << "[PayByCard] Waiting for 'Платежи и переводы' + 'По номеру карты', attempt" << attempt + 1 << "/ 10"; qDebug() << "[PayByCard] Waiting for 'Платежи и переводы' + 'По номеру карты', attempt" << attempt + 1 << "/ 10";
QThread::msleep(1500); QThread::msleep(1500);
} }
}
if (!paymentsPageLoaded) { if (!paymentsPageLoaded) {
m_error = titleSeen m_error = titleSeen
@ -203,10 +214,17 @@ void PayByCardScript::makePayment(
// 4. Ждём экран "Перевод по номеру карты" // 4. Ждём экран "Перевод по номеру карты"
bool cardScreenLoaded = false; bool cardScreenLoaded = false;
{
FreezeDetector freezeCheck;
for (int attempt = 0; attempt < 10; ++attempt) { for (int attempt = 0; attempt < 10; ++attempt) {
if (!checkApp()) return; if (!checkApp()) return;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); const QString xml = AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter);
xmlScreenParser.parseAndSaveXml(xml);
closeGooglePlayBannerIfVisible(deviceId, width, height); closeGooglePlayBannerIfVisible(deviceId, width, height);
if (freezeCheck.noticeAndCheckFrozen(xml)) {
qWarning() << "[PayByCard] screen frozen waiting 'Перевод по номеру карты'";
break;
}
Node title = xmlScreenParser.findTextNode("Перевод по номеру карты"); Node title = xmlScreenParser.findTextNode("Перевод по номеру карты");
if (!title.isEmpty()) { if (!title.isEmpty()) {
cardScreenLoaded = true; cardScreenLoaded = true;
@ -215,6 +233,7 @@ void PayByCardScript::makePayment(
qDebug() << "[PayByCard] Waiting for 'Перевод по номеру карты', attempt" << attempt + 1 << "/ 10"; qDebug() << "[PayByCard] Waiting for 'Перевод по номеру карты', attempt" << attempt + 1 << "/ 10";
QThread::msleep(1500); QThread::msleep(1500);
} }
}
if (!cardScreenLoaded) { if (!cardScreenLoaded) {
m_error = "Экран 'Перевод по номеру карты' не загрузился"; m_error = "Экран 'Перевод по номеру карты' не загрузился";
@ -247,6 +266,11 @@ void PayByCardScript::makePayment(
AdbUtils::inputText(deviceId, m_cardNumber); AdbUtils::inputText(deviceId, m_cardNumber);
QThread::msleep(2000); QThread::msleep(2000);
// Закрываем системную клавиатуру — она перекрывает поле суммы и кнопку
// "Перевести" внизу, а на TECNO ещё мешает Ozon WebView дорендерить AT.
AdbUtils::hideKeyboard(deviceId);
QThread::msleep(800);
// 6. Ждём распознавания карты и вводим сумму // 6. Ждём распознавания карты и вводим сумму
Node sumInput; Node sumInput;
for (int attempt = 0; attempt < 10; ++attempt) { for (int attempt = 0; attempt < 10; ++attempt) {
@ -255,6 +279,35 @@ void PayByCardScript::makePayment(
closeGooglePlayBannerIfVisible(deviceId, width, height); closeGooglePlayBannerIfVisible(deviceId, width, height);
sumInput = xmlScreenParser.findEditTextByHint("Сумма от"); sumInput = xmlScreenParser.findEditTextByHint("Сумма от");
if (!sumInput.isEmpty()) break; if (!sumInput.isEmpty()) break;
// Fallback для устройств, где Ozon WebView не отдаёт hint (TECNO).
// На экране "Перевод по номеру карты" всегда 2 EditText: карта (сверху)
// и сумма (снизу). Стратегии по приоритету:
// 1) Svelte генерирует resource-id="input___xxxx" ТОЛЬКО для суммы —
// берём этот EditText.
// 2) Если rid нет (старая версия Ozon UI) — берём второй EditText
// по y-порядку (карта y1=меньше, сумма y1=больше).
QList<Node> candidateInputs;
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.className != "android.widget.EditText") continue;
if (n.height() <= 0 || n.height() > height * 4 / 5) continue;
candidateInputs.append(n);
}
// Strategy 1: rid="input___"
for (const Node &n : candidateInputs) {
if (n.resourceId.startsWith("input___")) {
sumInput = n;
break;
}
}
// Strategy 2: bottom-most по y1 (если их 2 — берём второй)
if (sumInput.isEmpty() && candidateInputs.size() >= 2) {
std::sort(candidateInputs.begin(), candidateInputs.end(),
[](const Node &a, const Node &b) { return a.y1() < b.y1(); });
sumInput = candidateInputs.last();
}
if (!sumInput.isEmpty()) break;
qDebug() << "[PayByCard] Waiting for 'Сумма от' input, attempt" << attempt + 1 << "/ 10"; qDebug() << "[PayByCard] Waiting for 'Сумма от' input, attempt" << attempt + 1 << "/ 10";
QThread::msleep(1500); QThread::msleep(1500);
} }
@ -349,10 +402,16 @@ void PayByCardScript::makePayment(
} }
// 9. Ждём результат: "Успешно" или "Не удалось" // 9. Ждём результат: "Успешно" или "Не удалось"
FreezeDetector resultFreeze;
for (int attempt = 0; attempt < 15; ++attempt) { for (int attempt = 0; attempt < 15; ++attempt) {
if (!checkApp()) return; if (!checkApp()) return;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); const QString xml = AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter);
xmlScreenParser.parseAndSaveXml(xml);
closeGooglePlayBannerIfVisible(deviceId, width, height); closeGooglePlayBannerIfVisible(deviceId, width, height);
if (resultFreeze.noticeAndCheckFrozen(xml)) {
qWarning() << "[PayByCard] screen frozen while waiting payment result";
break;
}
// Если PIN-код появился позже — вводим // Если PIN-код появился позже — вводим
if (const QList<Node> pinNodes = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН"); if (const QList<Node> pinNodes = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
@ -382,49 +441,20 @@ void PayByCardScript::makePayment(
const int txId = TransactionDAO::insertTransaction(tx); const int txId = TransactionDAO::insertTransaction(tx);
qDebug() << "[PayByCard] Saved transaction to DB, id:" << txId << "fee:" << fee; qDebug() << "[PayByCard] Saved transaction to DB, id:" << txId << "fee:" << fee;
// Ищем кнопку "Документы" и достаём ID операции // Скриншот экрана "Успешно" — отдаём как receipt-image и в EventHandler.
// Раньше дополнительно тапали "Документы" + ждали "Поделиться" + OCR
// ради bank_transaction_id, но это медленно (до 30 сек ожидания) и
// нестабильно. Теперь полагаемся на fallback-ID.
QString externalId; QString externalId;
Node docBtn = xmlScreenParser.findButtonNode("Документы", false); if (txId != -1) {
if (!docBtn.isEmpty() && txId != -1) {
qDebug() << "[PayByCard] Tapping 'Документы'...";
AdbUtils::makeTap(deviceId, docBtn.x(), docBtn.y());
QThread::msleep(2000);
// Ждём появления кнопки "Поделиться"
bool docLoaded = false;
for (int da = 0; da < 20; ++da) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node shareBtn = xmlScreenParser.findNodeByContentDesc("Поделиться");
if (!shareBtn.isEmpty()) {
docLoaded = true;
break;
}
qDebug() << "[PayByCard] Waiting for 'Поделиться', attempt" << da + 1 << "/ 20";
QThread::msleep(1500);
}
if (docLoaded) {
QThread::msleep(500);
const QByteArray screenshot = AdbUtils::captureScreenshotBytes(deviceId); const QByteArray screenshot = AdbUtils::captureScreenshotBytes(deviceId);
if (!screenshot.isEmpty()) { if (!screenshot.isEmpty()) {
m_resultScreenshot = screenshot; m_resultScreenshot = screenshot;
emit screenshotReady(m_resultScreenshot); emit screenshotReady(m_resultScreenshot);
TransactionDAO::updateReceiptImage(txId, screenshot); TransactionDAO::updateReceiptImage(txId, screenshot);
qDebug() << "[PayByCard] Receipt saved to DB, txId:" << txId; qDebug() << "[PayByCard] Receipt saved to DB, txId:" << txId;
const QString ocrText = recognizeTextFromImage(screenshot);
if (!ocrText.isEmpty()) {
qDebug() << "[PayByCard] OCR result:\n" << ocrText;
externalId = extractTransactionId(ocrText);
} }
} }
} else {
qWarning() << "[PayByCard] 'Поделиться' not found, skipping receipt";
}
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
}
if (externalId.isEmpty() && txId != -1) { if (externalId.isEmpty() && txId != -1) {
externalId = "ozon_" + bankProfilePhone + "_" + QString::number(QDateTime::currentSecsSinceEpoch()); externalId = "ozon_" + bankProfilePhone + "_" + QString::number(QDateTime::currentSecsSinceEpoch());

View File

@ -10,6 +10,7 @@
#include <leptonica/allheaders.h> #include <leptonica/allheaders.h>
#include "adb/AdbUtils.h" #include "adb/AdbUtils.h"
#include "dump/TaskDumpRecorder.h"
#include "dao/MaterialDAO.h" #include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
@ -91,6 +92,8 @@ void PayByPhoneScript::doStart() {
} }
const QString &adbId = device.adbSerial; const QString &adbId = device.adbSerial;
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, m_phone, {}};
TaskDumpRecorder::Scope dumpScope("ozon", "CREATE_TRANSACTION_PHONE", adbId, dumpMeta, &m_error);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter));
@ -255,11 +258,18 @@ void PayByPhoneScript::makePayment(
bool sumScreenLoaded = false; bool sumScreenLoaded = false;
bool bankSelected = false; bool bankSelected = false;
bool otherBankClicked = false; bool otherBankClicked = false;
FreezeDetector freezeCheck;
for (int attempt = 0; attempt < 20; ++attempt) { for (int attempt = 0; attempt < 20; ++attempt) {
if (!checkApp()) return; if (!checkApp()) return;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); const QString currentDumpXml = AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter);
xmlScreenParser.parseAndSaveXml(currentDumpXml);
closeGooglePlayBannerIfVisible(deviceId, width, height); closeGooglePlayBannerIfVisible(deviceId, width, height);
if (freezeCheck.noticeAndCheckFrozen(currentDumpXml)) {
qWarning() << "[PayByPhone] 4 identical dumps in a row — screen frozen, aborting";
break;
}
// Проверяем: уже на экране суммы? // Проверяем: уже на экране суммы?
Node sumTitle = xmlScreenParser.findTextNode("По номеру телефона"); Node sumTitle = xmlScreenParser.findTextNode("По номеру телефона");
if (!sumTitle.isEmpty() && sumTitle.y() < height / 7) { if (!sumTitle.isEmpty() && sumTitle.y() < height / 7) {
@ -404,11 +414,29 @@ void PayByPhoneScript::makePayment(
} }
// 7. Вводим сумму // 7. Вводим сумму
// На экране "По номеру телефона" обычно 2 EditText: сумма (сверху) и
// сообщение получателю (снизу). Оба имеют rid="input___xxxx" в Svelte UI.
// Берём верхний (минимальный y1) — это amount.
Node sumInput; Node sumInput;
for (int attempt = 0; attempt < 10; ++attempt) { for (int attempt = 0; attempt < 10; ++attempt) {
if (!checkApp()) return; if (!checkApp()) return;
QList<Node> candidateInputs;
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.className != "android.widget.EditText") continue;
if (n.height() <= 0 || n.height() > height * 4 / 5) continue;
if (!n.resourceId.startsWith("input___")) continue;
candidateInputs.append(n);
}
if (!candidateInputs.isEmpty()) {
std::sort(candidateInputs.begin(), candidateInputs.end(),
[](const Node &a, const Node &b) { return a.y1() < b.y1(); });
sumInput = candidateInputs.first(); // верхний = amount
break;
}
// Старая логика — если нет ни одного "input___": берём любой EditText.
sumInput = xmlScreenParser.findFirstEditText(); sumInput = xmlScreenParser.findFirstEditText();
if (!sumInput.isEmpty()) break; if (!sumInput.isEmpty()) break;
qDebug() << "[PayByPhone] Waiting for sum input field, attempt" << attempt + 1 << "/ 10"; qDebug() << "[PayByPhone] Waiting for sum input field, attempt" << attempt + 1 << "/ 10";
QThread::msleep(1500); QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
@ -534,49 +562,20 @@ void PayByPhoneScript::makePayment(
const int txId = TransactionDAO::insertTransaction(tx); const int txId = TransactionDAO::insertTransaction(tx);
qDebug() << "[PayByPhone] Saved transaction to DB, id:" << txId; qDebug() << "[PayByPhone] Saved transaction to DB, id:" << txId;
// Ищем кнопку "Документы" и достаём ID операции // Скриншот экрана "Успешно" — отдаём как receipt-image и в EventHandler.
// Раньше дополнительно тапали "Документы" + ждали "Поделиться" + OCR
// ради bank_transaction_id, но это медленно (до 30 сек ожидания) и
// нестабильно. Теперь полагаемся на fallback-ID из phone+timestamp.
QString externalId; QString externalId;
Node docBtn = xmlScreenParser.findButtonNode("Документы", false); if (txId != -1) {
if (!docBtn.isEmpty() && txId != -1) {
qDebug() << "[PayByPhone] Tapping 'Документы'...";
AdbUtils::makeTap(deviceId, docBtn.x(), docBtn.y());
QThread::msleep(2000);
// Ждём появления кнопки "Поделиться"
bool docLoaded = false;
for (int da = 0; da < 20; ++da) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node shareBtn = xmlScreenParser.findNodeByContentDesc("Поделиться");
if (!shareBtn.isEmpty()) {
docLoaded = true;
break;
}
qDebug() << "[PayByPhone] Waiting for 'Поделиться', attempt" << da + 1 << "/ 20";
QThread::msleep(1500);
}
if (docLoaded) {
QThread::msleep(500);
const QByteArray screenshot = AdbUtils::captureScreenshotBytes(deviceId); const QByteArray screenshot = AdbUtils::captureScreenshotBytes(deviceId);
if (!screenshot.isEmpty()) { if (!screenshot.isEmpty()) {
m_resultScreenshot = screenshot; m_resultScreenshot = screenshot;
emit screenshotReady(m_resultScreenshot); emit screenshotReady(m_resultScreenshot);
TransactionDAO::updateReceiptImage(txId, screenshot); TransactionDAO::updateReceiptImage(txId, screenshot);
qDebug() << "[PayByPhone] Receipt saved to DB, txId:" << txId; qDebug() << "[PayByPhone] Receipt saved to DB, txId:" << txId;
const QString ocrText = recognizeTextFromImage(screenshot);
if (!ocrText.isEmpty()) {
qDebug() << "[PayByPhone] OCR result:\n" << ocrText;
externalId = extractTransactionId(ocrText);
} }
} }
} else {
qWarning() << "[PayByPhone] 'Поделиться' not found, skipping receipt";
}
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
}
if (externalId.isEmpty() && txId != -1) { if (externalId.isEmpty() && txId != -1) {
externalId = "ozon_" + bankProfilePhone + "_" + QString::number(QDateTime::currentSecsSinceEpoch()); externalId = "ozon_" + bankProfilePhone + "_" + QString::number(QDateTime::currentSecsSinceEpoch());

View File

@ -1439,18 +1439,69 @@ bool ScreenXmlParser::isTransactionListScreen() {
QList<Node> ScreenXmlParser::findTransactionButtons(const int maxCount) { QList<Node> ScreenXmlParser::findTransactionButtons(const int maxCount) {
const QChar rubleSign(0x20BD); // ₽ const QChar rubleSign(0x20BD); // ₽
QList<Node> buttons; QList<Node> buttons;
// В новом Svelte-UI Ozon ноды устроены так: Button-обёртка с пустым text,
// а сам текст транзакции живёт во вложенной android.view.View с теми же
// bounds. В старом UI текст был прямо в Button. Поддерживаем оба варианта:
// сначала сматчим Button с непустым text, затем пройдёмся по пустым
// Button-ам и подтянем текст из перекрывающейся View с ₽-знаком.
auto isSummary = [](const QString &t) {
return t.startsWith("Расходы") || t.startsWith("Доходы");
};
for (const Node &node : m_nodes) { for (const Node &node : m_nodes) {
if (node.className != "android.widget.Button") continue; if (node.className != "android.widget.Button") continue;
const QString text = node.text.trimmed(); const QString text = node.text.trimmed();
if (text.isEmpty()) {
// Ищем View с тем же bounds (или внутри Button-bounds), содержащую ₽.
// У транзакционной строки высота ~170px — отсекаем мелкие декоры.
if (node.height() < 80 || node.height() > 260) continue;
QString viewText;
for (const Node &v : m_nodes) {
if (v.className != "android.view.View") continue;
if (!v.text.contains(rubleSign)) continue;
if (v.y1() < node.y1() || v.y2() > node.y2()) continue;
if (v.x1() < node.x1() || v.x2() > node.x2()) continue;
viewText = v.text.trimmed();
break;
}
if (viewText.isEmpty() || isSummary(viewText)) continue;
// Возвращаем Button с подставленным текстом, чтобы вызывающий код
// (parseTransactionButtonText / makeTap по координатам) работал
// прозрачно для обоих UI-вариантов.
Node enriched = node;
enriched.text = viewText;
buttons.append(enriched);
} else {
if (!text.contains(rubleSign)) continue; if (!text.contains(rubleSign)) continue;
// Пропускаем сводки "Расходы", "Доходы" if (isSummary(text)) continue;
if (text.startsWith("Расходы") || text.startsWith("Доходы")) continue;
buttons.append(node); buttons.append(node);
}
if (buttons.size() >= maxCount) break; if (buttons.size() >= maxCount) break;
} }
return buttons; return buttons;
} }
bool ScreenXmlParser::hasCollapsedTransactionsBelow(const int screenHeight) {
const QChar rubleSign(0x20BD);
// Off-screen строка в Ozon WebView выглядит так: bounds=[0,Y][W,Y] (height=0)
// на нижнем краю viewport. Текст транзакции лежит во внутреннем
// android.widget.Button (внешний wrapper-View имеет пустой text).
// Ищем именно Button-ы с h=0 и ₽ в тексте — в этом случае и количество
// совпадает с числом скрытых строк.
const int bottomHalf = screenHeight / 2;
for (const Node &node : m_nodes) {
if (node.className != "android.widget.Button") continue;
if (node.height() != 0) continue;
if (node.y1() < bottomHalf) continue;
const QString text = node.text.trimmed();
if (text.isEmpty() || !text.contains(rubleSign)) continue;
if (text.startsWith("Расходы") || text.startsWith("Доходы")) continue;
return true;
}
return false;
}
TransactionInfo ScreenXmlParser::parseTransactionButtonText(const QString &text) { TransactionInfo ScreenXmlParser::parseTransactionButtonText(const QString &text) {
const QChar rubleSign(0x20BD); // ₽ const QChar rubleSign(0x20BD); // ₽
const QChar unicodeMinus(0x2212); // const QChar unicodeMinus(0x2212); //

View File

@ -90,6 +90,13 @@ public:
// Возвращает кнопки транзакций на экране "Операции" (до maxCount штук) // Возвращает кнопки транзакций на экране "Операции" (до maxCount штук)
QList<Node> findTransactionButtons(int maxCount); QList<Node> findTransactionButtons(int maxCount);
// Ozon WebView рендерит tappable-Button только для видимых транзакций.
// Off-screen элементы остаются в accessibility-tree как android.view.View
// с "схлопнутыми" bounds (height == 0). Если такие View с ₽-знаком есть —
// значит в списке осталось что прокручивать. Используется для детекции
// того, что скролл не завершён.
bool hasCollapsedTransactionsBelow(int screenHeight);
// Парсит имя и сумму из текста кнопки: "NAME + 150 ₽ CATEGORY" // Парсит имя и сумму из текста кнопки: "NAME + 150 ₽ CATEGORY"
TransactionInfo parseTransactionButtonText(const QString &text); TransactionInfo parseTransactionButtonText(const QString &text);

View File

@ -478,7 +478,7 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
const QDateTime searchTime = QDateTime::fromString(body["created_at"].toString(), Qt::ISODate); const QDateTime searchTime = QDateTime::fromString(body["created_at"].toString(), Qt::ISODate);
if (appCode == "ozon") { if (appCode == "ozon") {
auto *w = new Ozon::GetLastTransactionsScript(adbSerial, appCode, 0, searchAmount, searchTime); auto *w = new Ozon::GetLastTransactionsScript(adbSerial, appCode, 0, searchAmount, searchTime, account.materialId);
setup(w); connectScreenshot(w); setup(w); connectScreenshot(w);
connect(w, &Ozon::GetLastTransactionsScript::transactionParsed, this, connect(w, &Ozon::GetLastTransactionsScript::transactionParsed, this,
[this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; }, [this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; },
@ -487,7 +487,7 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
else if (appCode == "black") else if (appCode == "black")
setup(new Black::GetLastTransactionsScript(adbSerial, appCode)); setup(new Black::GetLastTransactionsScript(adbSerial, appCode));
else if (appCode == "dushanbe_city_bank") { else if (appCode == "dushanbe_city_bank") {
auto *w = new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, searchAmount, searchTime); auto *w = new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, searchAmount, searchTime, account.materialId);
setup(w); connectScreenshotDushanbe(w); setup(w); connectScreenshotDushanbe(w);
connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this, connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this,
[this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; }, [this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; },

View File

@ -1,27 +1,7 @@
{ {
"bank_profiles": [ "bank_profiles": [
{ {
"id": "b8763ad9-50bd-4d8c-b6f1-cc122119e2df", "id": "21aa26c8-ea89-45ea-a283-ee6d55ead82b",
"bank_name": "dushanbe_city_bank",
"state": "enabled",
"phone_number": "+992185555519",
"first_name": "Мехрубон",
"middle_name": null,
"last_name": "Имомкулзода",
"currency": 972,
"device_id": "986cb259-dfec-4421-8735-4fb126cd1917",
"device_label": "Pixel 7",
"materials": [
{
"id": "36002ac2-6f4d-49da-8892-18db723a31c7",
"state": "enabled",
"card_number": "9762000207113258",
"currency": 972
}
]
},
{
"id": "ec6b5712-067c-4354-8641-a3f19a9fdef1",
"bank_name": "ozon", "bank_name": "ozon",
"state": "enabled", "state": "enabled",
"phone_number": "+79538038318", "phone_number": "+79538038318",
@ -33,13 +13,7 @@
"device_label": "Pixel 7", "device_label": "Pixel 7",
"materials": [ "materials": [
{ {
"id": "50393cc1-57e4-4293-9a59-6cdd1d9ce6cb", "id": "3a2ca76d-f735-4006-b521-66bccaf821a4",
"state": "enabled",
"card_number": "2204321035207919",
"currency": 643
},
{
"id": "06a1ae15-abd2-4d90-aac0-77f4a4fd32e8",
"state": "enabled", "state": "enabled",
"card_number": "2204321086109865", "card_number": "2204321086109865",
"currency": 643 "currency": 643
@ -47,7 +21,7 @@
] ]
}, },
{ {
"id": "a3fa08d3-58d7-4744-886c-c81afa707dcd", "id": "524c4cfe-9522-4434-90d1-9ee80aae92b2",
"bank_name": "ozon", "bank_name": "ozon",
"state": "enabled", "state": "enabled",
"phone_number": "+79855532896", "phone_number": "+79855532896",
@ -59,7 +33,7 @@
"device_label": "TECNO KM4", "device_label": "TECNO KM4",
"materials": [ "materials": [
{ {
"id": "7e3a7e4e-0d56-4d66-ac44-2b240ec33620", "id": "19d1a302-7155-4d68-b596-5c7fb7cf6c2e",
"state": "enabled", "state": "enabled",
"card_number": "2204320439130982", "card_number": "2204320439130982",
"currency": 643 "currency": 643

View File

@ -85,7 +85,7 @@ def query_active_materials(db_path: Path):
FROM devices d FROM devices d
JOIN bank_profiles bp ON bp.device_id = d.id JOIN bank_profiles bp ON bp.device_id = d.id
JOIN materials m ON m.device_id = d.id AND m.app_code = bp.code JOIN materials m ON m.device_id = d.id AND m.app_code = bp.code
WHERE bp.status = 'active' AND m.status = 'active' WHERE bp.pin_code_checked = 'yes' AND m.status = 'active'
AND m.material_id != '' AND bp.bank_profile_id != '' AND m.material_id != '' AND bp.bank_profile_id != ''
ORDER BY d.name, bp.code ORDER BY d.name, bp.code
""").fetchall() """).fetchall()

View File

@ -428,7 +428,15 @@ def task_result():
# Unblock this material for the next task delivery # Unblock this material for the next task delivery
_in_flight.discard(mid) _in_flight.discard(mid)
key = (mid, body.get("type")) # Клиент присылает ошибки с type="ERROR", но оригинальный тип задачи
# лежит в data.event_type — им и матчимся, иначе ошибка повиснет
# как unsolicited без привязки к задаче/устройству.
rtype = body.get("type")
if rtype == "ERROR":
data = body.get("data") or {}
rtype = data.get("event_type") or rtype
key = (mid, rtype)
idx = None idx = None
if _pending_by_key[key]: if _pending_by_key[key]:
idx = _pending_by_key[key].popleft() idx = _pending_by_key[key].popleft()

View File

@ -1,6 +0,0 @@
{
"bank_name": "dushanbe_city_bank",
"material_id": "36002ac2-6f4d-49da-8892-18db723a31c7",
"type": "FETCH_PROFILE",
"body": {}
}

View File

@ -1,6 +0,0 @@
{
"bank_name": "ozon",
"material_id": "50393cc1-57e4-4293-9a59-6cdd1d9ce6cb",
"type": "FETCH_PROFILE",
"body": {}
}

View File

@ -1,6 +1,6 @@
{ {
"bank_name": "ozon", "bank_name": "ozon",
"material_id": "06a1ae15-abd2-4d90-aac0-77f4a4fd32e8", "material_id": "3a2ca76d-f735-4006-b521-66bccaf821a4",
"type": "FETCH_PROFILE", "type": "FETCH_PROFILE",
"body": {} "body": {}
} }

View File

@ -1,6 +1,6 @@
{ {
"bank_name": "ozon", "bank_name": "ozon",
"material_id": "7e3a7e4e-0d56-4d66-ac44-2b240ec33620", "material_id": "19d1a302-7155-4d68-b596-5c7fb7cf6c2e",
"type": "FETCH_PROFILE", "type": "FETCH_PROFILE",
"body": {} "body": {}
} }