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:
parent
cd1828e2b5
commit
44e60fc09f
@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
261
android/dump/TaskDumpRecorder.cpp
Normal file
261
android/dump/TaskDumpRecorder.cpp
Normal 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());
|
||||||
|
}
|
||||||
54
android/dump/TaskDumpRecorder.h
Normal file
54
android/dump/TaskDumpRecorder.h
Normal 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;
|
||||||
|
};
|
||||||
@ -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()) {
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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()) {
|
||||||
|
|||||||
@ -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()) {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
QThread::msleep(1500);
|
||||||
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 {
|
} else {
|
||||||
// Заголовок в нижней половине или не найден — скролл вниз
|
// Скролл вниз. Ozon WebView ведёт себя странно: ОДИН медленный
|
||||||
const int fromY = device.screenHeight * 3 / 4;
|
// свайп иногда игнорируется (видимо, расценивается как hold).
|
||||||
const int toY = device.screenHeight / 4;
|
// Стратегия: серия из 3 коротких быстрых свайпов внутри
|
||||||
qDebug() << "[FetchTransactions] Full scroll down: from" << fromY << "to" << toY
|
// безопасной зоны (выше нав-бара). Это надёжно триггерит
|
||||||
<< "(dateHeaderY=" << dateHeaderY << ")";
|
// нативный fling в WebView.
|
||||||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, fromY,
|
// safeBottom — верхняя граница нав-бара минус запас.
|
||||||
device.screenWidth / 2, toY, 400);
|
// Когда 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);
|
||||||
}
|
}
|
||||||
QThread::msleep(1500);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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,19 +176,27 @@ void PayByCardScript::makePayment(
|
|||||||
Node cardBtn;
|
Node cardBtn;
|
||||||
bool paymentsPageLoaded = false;
|
bool paymentsPageLoaded = false;
|
||||||
bool titleSeen = false;
|
bool titleSeen = false;
|
||||||
for (int attempt = 0; attempt < 10; ++attempt) {
|
{
|
||||||
if (!checkApp()) return;
|
FreezeDetector freezeCheck;
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
if (!checkApp()) return;
|
||||||
Node title = xmlScreenParser.findTextNode("Платежи и переводы");
|
const QString xml = AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter);
|
||||||
if (!title.isEmpty()) titleSeen = true;
|
xmlScreenParser.parseAndSaveXml(xml);
|
||||||
cardBtn = xmlScreenParser.findButtonNode("По номеру карты", false);
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
if (!title.isEmpty() && !cardBtn.isEmpty()) {
|
if (freezeCheck.noticeAndCheckFrozen(xml)) {
|
||||||
paymentsPageLoaded = true;
|
qWarning() << "[PayByCard] screen frozen waiting Платежи и переводы";
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
Node title = xmlScreenParser.findTextNode("Платежи и переводы");
|
||||||
|
if (!title.isEmpty()) titleSeen = true;
|
||||||
|
cardBtn = xmlScreenParser.findButtonNode("По номеру карты", false);
|
||||||
|
if (!title.isEmpty() && !cardBtn.isEmpty()) {
|
||||||
|
paymentsPageLoaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
qDebug() << "[PayByCard] Waiting for 'Платежи и переводы' + 'По номеру карты', attempt" << attempt + 1 << "/ 10";
|
||||||
|
QThread::msleep(1500);
|
||||||
}
|
}
|
||||||
qDebug() << "[PayByCard] Waiting for 'Платежи и переводы' + 'По номеру карты', attempt" << attempt + 1 << "/ 10";
|
|
||||||
QThread::msleep(1500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!paymentsPageLoaded) {
|
if (!paymentsPageLoaded) {
|
||||||
@ -203,17 +214,25 @@ void PayByCardScript::makePayment(
|
|||||||
|
|
||||||
// 4. Ждём экран "Перевод по номеру карты"
|
// 4. Ждём экран "Перевод по номеру карты"
|
||||||
bool cardScreenLoaded = false;
|
bool cardScreenLoaded = false;
|
||||||
for (int attempt = 0; attempt < 10; ++attempt) {
|
{
|
||||||
if (!checkApp()) return;
|
FreezeDetector freezeCheck;
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||||
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
if (!checkApp()) return;
|
||||||
Node title = xmlScreenParser.findTextNode("Перевод по номеру карты");
|
const QString xml = AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter);
|
||||||
if (!title.isEmpty()) {
|
xmlScreenParser.parseAndSaveXml(xml);
|
||||||
cardScreenLoaded = true;
|
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||||
break;
|
if (freezeCheck.noticeAndCheckFrozen(xml)) {
|
||||||
|
qWarning() << "[PayByCard] screen frozen waiting 'Перевод по номеру карты'";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Node title = xmlScreenParser.findTextNode("Перевод по номеру карты");
|
||||||
|
if (!title.isEmpty()) {
|
||||||
|
cardScreenLoaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
qDebug() << "[PayByCard] Waiting for 'Перевод по номеру карты', attempt" << attempt + 1 << "/ 10";
|
||||||
|
QThread::msleep(1500);
|
||||||
}
|
}
|
||||||
qDebug() << "[PayByCard] Waiting for 'Перевод по номеру карты', attempt" << attempt + 1 << "/ 10";
|
|
||||||
QThread::msleep(1500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cardScreenLoaded) {
|
if (!cardScreenLoaded) {
|
||||||
@ -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,48 +441,19 @@ 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) {
|
const QByteArray screenshot = AdbUtils::captureScreenshotBytes(deviceId);
|
||||||
qDebug() << "[PayByCard] Tapping 'Документы'...";
|
if (!screenshot.isEmpty()) {
|
||||||
AdbUtils::makeTap(deviceId, docBtn.x(), docBtn.y());
|
m_resultScreenshot = screenshot;
|
||||||
QThread::msleep(2000);
|
emit screenshotReady(m_resultScreenshot);
|
||||||
|
TransactionDAO::updateReceiptImage(txId, screenshot);
|
||||||
// Ждём появления кнопки "Поделиться"
|
qDebug() << "[PayByCard] Receipt saved to DB, txId:" << txId;
|
||||||
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);
|
|
||||||
if (!screenshot.isEmpty()) {
|
|
||||||
m_resultScreenshot = screenshot;
|
|
||||||
emit screenshotReady(m_resultScreenshot);
|
|
||||||
TransactionDAO::updateReceiptImage(txId, screenshot);
|
|
||||||
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) {
|
||||||
|
|||||||
@ -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,48 +562,19 @@ 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) {
|
const QByteArray screenshot = AdbUtils::captureScreenshotBytes(deviceId);
|
||||||
qDebug() << "[PayByPhone] Tapping 'Документы'...";
|
if (!screenshot.isEmpty()) {
|
||||||
AdbUtils::makeTap(deviceId, docBtn.x(), docBtn.y());
|
m_resultScreenshot = screenshot;
|
||||||
QThread::msleep(2000);
|
emit screenshotReady(m_resultScreenshot);
|
||||||
|
TransactionDAO::updateReceiptImage(txId, screenshot);
|
||||||
// Ждём появления кнопки "Поделиться"
|
qDebug() << "[PayByPhone] Receipt saved to DB, txId:" << txId;
|
||||||
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);
|
|
||||||
if (!screenshot.isEmpty()) {
|
|
||||||
m_resultScreenshot = screenshot;
|
|
||||||
emit screenshotReady(m_resultScreenshot);
|
|
||||||
TransactionDAO::updateReceiptImage(txId, screenshot);
|
|
||||||
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) {
|
||||||
|
|||||||
@ -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.contains(rubleSign)) continue;
|
if (text.isEmpty()) {
|
||||||
// Пропускаем сводки "Расходы", "Доходы"
|
// Ищем View с тем же bounds (или внутри Button-bounds), содержащую ₽.
|
||||||
if (text.startsWith("Расходы") || text.startsWith("Доходы")) continue;
|
// У транзакционной строки высота ~170px — отсекаем мелкие декоры.
|
||||||
buttons.append(node);
|
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 (isSummary(text)) continue;
|
||||||
|
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); // −
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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; },
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"bank_name": "dushanbe_city_bank",
|
|
||||||
"material_id": "36002ac2-6f4d-49da-8892-18db723a31c7",
|
|
||||||
"type": "FETCH_PROFILE",
|
|
||||||
"body": {}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"bank_name": "ozon",
|
|
||||||
"material_id": "50393cc1-57e4-4293-9a59-6cdd1d9ce6cb",
|
|
||||||
"type": "FETCH_PROFILE",
|
|
||||||
"body": {}
|
|
||||||
}
|
|
||||||
@ -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": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": {}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user