1
0
forked from BRT/arc

Streamline FETCH_TRANSACTIONS to support per-transaction streaming, handle partial successes, and prevent duplicate retries. Update scripts, task lifecycles, and error reporting with multi-target processing.

This commit is contained in:
slava 2026-05-29 21:01:09 +05:00
parent 3832a658ce
commit 709c7d0490
9 changed files with 293 additions and 132 deletions

View File

@ -249,15 +249,13 @@ namespace Dushanbe {
GetLastTransactionsScript::GetLastTransactionsScript(
QString deviceId,
QString appCode,
double searchAmount,
QDateTime searchTime,
QList<QPair<double, QDateTime>> targets,
QString materialId,
QObject *parent
) : CommonScript(parent),
m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)),
m_searchAmount(searchAmount),
m_searchTime(std::move(searchTime)),
m_targets(std::move(targets)),
m_materialId(std::move(materialId)) {
}
@ -277,7 +275,8 @@ void GetLastTransactionsScript::doStart() {
return;
}
const TaskDumpMeta dumpMeta{m_materialId, m_searchAmount, {}, {}};
const double firstAmount = m_targets.isEmpty() ? 0.0 : m_targets.first().first;
const TaskDumpMeta dumpMeta{m_materialId, firstAmount, {}, {}};
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
@ -378,9 +377,6 @@ void GetLastTransactionsScript::doStart() {
}
}
// Скриншот списка транзакций — отправим при ошибке, удалим при успехе
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
// Находим accountId
int accountId = -1;
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
@ -390,8 +386,62 @@ void GetLastTransactionsScript::doStart() {
qWarning() << "[Dushanbe::GetLastTransactions] No account found";
}
qDebug() << "[Dushanbe::GetLastTransactions] Searching: amount=" << m_searchAmount
<< "time=" << m_searchTime.toString(Qt::ISODate);
// Ищем каждую цель из списка. Найденные отдаём сразу через transactionParsed;
// ненайденные копим и репортим одной агрегированной ошибкой в конце.
QStringList notFound;
for (int i = 0; i < m_targets.size(); ++i) {
if (QThread::currentThread()->isInterruptionRequested()) { m_error = "Interrupted"; break; }
const double amount = m_targets[i].first;
const QDateTime time = m_targets[i].second;
// Перед каждой целью (кроме первой) возвращаемся к началу Выписки.
if (i > 0) scrollHistoryToTop(device);
if (!searchOne(device, accountId, amount, time)) {
if (m_error == "Interrupted") break;
notFound << QString("amount=%1 time=%2")
.arg(QString::number(amount, 'f', 2), time.toString(Qt::ISODate));
}
}
if (m_error != "Interrupted") {
m_error = notFound.isEmpty()
? QString()
: QString("Transactions not found (%1/%2): %3")
.arg(notFound.size()).arg(m_targets.size()).arg(notFound.join("; "));
}
// Возвращаемся на главную
{
Node glavnayaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Главная"));
if (glavnayaBtn.x() > 0 && glavnayaBtn.y() > 0) {
AdbUtils::makeTap(m_deviceId, glavnayaBtn.x(), glavnayaBtn.y());
} else {
AdbUtils::goBack(m_deviceId);
}
QThread::msleep(1000);
}
emit finishedWithResult(m_error);
}
void GetLastTransactionsScript::scrollHistoryToTop(const DeviceInfo &device) {
// Свайпы сверху-вниз поднимают список Выписки к началу.
for (int i = 0; i < 4; ++i) {
if (QThread::currentThread()->isInterruptionRequested()) return;
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight / 3,
device.screenWidth / 2,
device.screenHeight * 2 / 3, 300);
QThread::msleep(500);
}
QThread::msleep(800);
}
bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountId,
double searchAmount, const QDateTime &searchTime) {
// Скриншот списка транзакций — отправим при неудаче
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
qDebug() << "[Dushanbe::GetLastTransactions] Searching: amount=" << searchAmount
<< "time=" << searchTime.toString(Qt::ISODate);
// Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства
const QTimeZone tjTz("Asia/Dushanbe");
@ -412,8 +462,7 @@ void GetLastTransactionsScript::doStart() {
for (int scroll = 0; scroll <= maxScrolls; ++scroll) {
if (interrupted()) {
m_error = "Interrupted";
emit finishedWithResult(m_error);
return;
return false;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
@ -602,7 +651,7 @@ void GetLastTransactionsScript::doStart() {
<< "y=" << items[i].node.y();
}
const double searchAbs = qAbs(m_searchAmount);
const double searchAbs = qAbs(searchAmount);
for (const auto &tx : items) {
const double absListAmt = qAbs(tx.amount);
const bool amountDirect = qAbs(absListAmt - searchAbs) < 0.01;
@ -617,9 +666,9 @@ void GetLastTransactionsScript::doStart() {
// Pre-filter по ДАТЕ: только если дата надёжная (из content-desc P2P формата).
// DCWallet транзакции получают дату из groupDates ImageView, которая
// неправильно присваивается при скролле — не фильтруем по ней.
if (tx.dateReliable && m_searchTime.isValid() && !tx.date.isEmpty()) {
if (tx.dateReliable && searchTime.isValid() && !tx.date.isEmpty()) {
const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy");
const QDate searchLocalDate = m_searchTime.toTimeZone(tjTz).date();
const QDate searchLocalDate = searchTime.toTimeZone(tjTz).date();
if (listDate.isValid() && searchLocalDate.isValid()) {
const qint64 dayDiff = qAbs(listDate.daysTo(searchLocalDate));
if (dayDiff > 3) {
@ -632,9 +681,9 @@ void GetLastTransactionsScript::doStart() {
}
// Проверка времени по HH:MM:SS (±10 минут, с учётом wraparound полуночи)
if (m_searchTime.isValid() && !tx.time.isEmpty()) {
if (searchTime.isValid() && !tx.time.isEmpty()) {
const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss");
const QTime searchLocalTime = m_searchTime.toTimeZone(tjTz).time();
const QTime searchLocalTime = searchTime.toTimeZone(tjTz).time();
if (listTime.isValid() && searchLocalTime.isValid()) {
const int diffSecs = qAbs(listTime.secsTo(searchLocalTime));
if (diffSecs > 600 && diffSecs < 85800) { // 85800 = 23h50m
@ -665,13 +714,13 @@ void GetLastTransactionsScript::doStart() {
const int tapX = txNode.x1() + 80;
AdbUtils::makeTap(m_deviceId, tapX, txNode.y());
QThread::msleep(3000);
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
if (interrupted()) { m_error = "Interrupted"; return false; }
// Ждём экран деталей Выписки — ImageView с content-desc начинающимся с "Успешный платеж"
OcrTransactionDetail detail;
bool detailLoaded = false;
for (int attempt = 0; attempt < 10; ++attempt) {
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
if (interrupted()) { m_error = "Interrupted"; return false; }
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
// 1) Bottom-sheet формат (3.2.33, см. assets/dushanbe/3.2.33/tx_detail.xml):
@ -800,7 +849,7 @@ void GetLastTransactionsScript::doStart() {
<< "amount=" << detail.amount << "receiver=" << detail.receiverAccount;
// Проверяем время ±10 минут
if (m_searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) {
if (searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) {
QDateTime txTime = QDateTime::fromString(
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");
if (!txTime.isValid()) {
@ -808,7 +857,7 @@ void GetLastTransactionsScript::doStart() {
}
if (txTime.isValid()) {
txTime.setTimeZone(tjTz);
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(searchTime.toUTC()));
if (diffSecs > 600) {
qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec (>10min), skipping";
AdbUtils::goBack(m_deviceId);
@ -877,13 +926,12 @@ void GetLastTransactionsScript::doStart() {
}
QThread::msleep(1000);
emit finishedWithResult(m_error); // m_error пустой — успех
return;
return true; // успех — транзакция найдена и отдана через transactionParsed
}
// Скроллим вниз — свайп внутри области транзакций
if (scroll < maxScrolls) {
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
if (interrupted()) { m_error = "Interrupted"; return false; }
const int x = device.screenWidth / 2;
// Определяем область контента из реальных Y позиций items
int contentBottom = device.screenHeight * 2 / 3;
@ -900,25 +948,18 @@ void GetLastTransactionsScript::doStart() {
}
}
m_error = "Transaction not found: amount=" + QString::number(m_searchAmount, 'f', 2)
+ " time=" + m_searchTime.toString(Qt::ISODate);
qWarning() << "[Dushanbe::GetLastTransactions]" << m_error;
// Эту цель не нашли. m_error не трогаем — агрегированную ошибку по всем
// ненайденным целям соберёт оркестратор в doStart (он же вернёт на главную).
qWarning() << "[Dushanbe::GetLastTransactions] Transaction not found: amount="
<< QString::number(searchAmount, 'f', 2)
<< "time=" << searchTime.toString(Qt::ISODate);
// Отправляем скриншот списка транзакций, сделанный до начала поиска
m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
// Возвращаемся на главную
Node glavnayaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Главная"));
if (glavnayaBtn.x() > 0 && glavnayaBtn.y() > 0) {
AdbUtils::makeTap(m_deviceId, glavnayaBtn.x(), glavnayaBtn.y());
} else {
AdbUtils::goBack(m_deviceId);
}
QThread::msleep(1000);
emit finishedWithResult(m_error);
return false;
}
} // namespace Dushanbe

View File

@ -2,6 +2,8 @@
#include "CommonScript.h"
#include <QDateTime>
#include <QJsonObject>
#include <QList>
#include <QPair>
#include "db/DeviceInfo.h"
#include "db/BankProfileInfo.h"
@ -14,8 +16,7 @@ public:
explicit GetLastTransactionsScript(
QString deviceId,
QString appCode,
double searchAmount = 0,
QDateTime searchTime = {},
QList<QPair<double, QDateTime>> targets = {},
QString materialId = {},
QObject *parent = nullptr
);
@ -23,7 +24,7 @@ public:
~GetLastTransactionsScript() override;
signals:
// Испускается, когда транзакция найдена и распарсена с экрана деталей.
// Испускается на КАЖДУЮ найденную транзакцию из списка целей.
// EventHandler использует эти данные напрямую для task_result вместо
// повторного чтения из БД.
void transactionParsed(const QJsonObject &tx);
@ -32,10 +33,19 @@ protected:
void doStart() override;
private:
// Поиск одной цели по сумме и времени на экране "Выписка". Возвращает true
// и эмитит transactionParsed, если нашли; false — если нет. Прерывание
// выставляет m_error="Interrupted".
bool searchOne(const DeviceInfo &device, int accountId,
double searchAmount, const QDateTime &searchTime);
// Возврат к началу списка "Выписка" между поисками целей.
void scrollHistoryToTop(const DeviceInfo &device);
QString m_deviceId;
const QString m_appCode;
const double m_searchAmount; // сумма в сомони (0 = все последние)
const QDateTime m_searchTime; // примерное время (UTC)
// Список целей: пары (сумма в сомони со знаком, примерное время UTC).
const QList<QPair<double, QDateTime>> m_targets;
const QString m_materialId; // UUID задачи (для сохранения дампов)
QString m_error;
};

View File

@ -179,6 +179,14 @@ bool CommonScript::runAppAndGoToHomeScreen(
continue;
}
// Проверяем на онбординг-баннер "Обновили главную" поверх главной
if (Node onboardingNode = xmlScreenParser.tryToFindOnboardingBanner(); !onboardingNode.isEmpty()) {
qDebug() << "[runAppAndGoToHomeScreen] Onboarding banner detected, tapping 'Пропустить'...";
AdbUtils::makeTap(deviceId, onboardingNode.x(), onboardingNode.y());
QThread::msleep(1500);
continue;
}
// Проверяем, что это экран ввода пин-кода
if (!findAndInputPincode) {
const QList<Node> pincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
@ -225,6 +233,12 @@ bool CommonScript::tryToCloseAllBannersOnHomePage(const QString &deviceId, const
closedAny = true;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
continue;
} else if (Node onboardingNode = xmlScreenParser.tryToFindOnboardingBanner(); !onboardingNode.isEmpty()) {
// Онбординг "Обновили главную" поверх главной — жмём "Пропустить"
qDebug() << "[tryToCloseAllBannersOnHomePage] Onboarding banner detected, tapping 'Пропустить'...";
AdbUtils::makeTap(deviceId, onboardingNode.x(), onboardingNode.y());
QThread::msleep(1500);
closedThis = true;
} else if (Node closeBannerOrDialogNode = xmlScreenParser.tryToFindBannerOrDialog(width, height);
closeBannerOrDialogNode.x() != -1 && closeBannerOrDialogNode.y() != -1) {
AdbUtils::makeTap(deviceId, closeBannerOrDialogNode.x(), closeBannerOrDialogNode.y());

View File

@ -82,16 +82,14 @@ GetLastTransactionsScript::GetLastTransactionsScript(
QString deviceId,
QString appCode,
int maxCount,
double searchAmount,
QDateTime searchTime,
QList<QPair<double, QDateTime>> targets,
QString materialId,
QObject *parent
) : CommonScript(parent),
m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)),
m_maxCount(maxCount),
m_searchAmount(searchAmount),
m_searchTime(std::move(searchTime)),
m_targets(std::move(targets)),
m_materialId(std::move(materialId)) {
}
@ -110,7 +108,8 @@ void GetLastTransactionsScript::doStart() {
return;
}
const TaskDumpMeta dumpMeta{m_materialId, m_searchAmount, {}, {}};
const double firstAmount = m_targets.isEmpty() ? 0.0 : m_targets.first().first;
const TaskDumpMeta dumpMeta{m_materialId, firstAmount, {}, {}};
TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error);
if (!navigateToTransactionList(device, app)) {
@ -127,9 +126,31 @@ void GetLastTransactionsScript::doStart() {
qWarning() << "[GetLastTransactions] No account found for" << device.id << m_appCode;
}
// Выбор режима: поиск конкретной транзакции или сканирование последних
if (qAbs(m_searchAmount) > 0.01) {
searchTransaction(device, accountId);
// Выбор режима: поиск списка конкретных транзакций или сканирование последних.
if (!m_targets.isEmpty()) {
// Ищем каждую цель из списка. Найденные отдаём сразу через
// transactionParsed; ненайденные копим и репортим одной ошибкой в конце.
QStringList notFound;
for (int i = 0; i < m_targets.size(); ++i) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
emit finishedWithResult(m_error);
return;
}
const double amount = m_targets[i].first;
const QDateTime time = m_targets[i].second;
// Перед каждой целью (кроме первой) возвращаемся к началу списка,
// т.к. предыдущий поиск мог проскроллить вниз к другой дате.
if (i > 0) scrollListToTop(device);
if (!searchOne(device, accountId, amount, time)) {
notFound << QString("amount=%1 time=%2")
.arg(QString::number(amount, 'f', 2), time.toString(Qt::ISODate));
}
}
m_error = notFound.isEmpty()
? QString()
: QString("Transactions not found (%1/%2): %3")
.arg(notFound.size()).arg(m_targets.size()).arg(notFound.join("; "));
} else {
scanRecentTransactions(device, accountId);
}
@ -242,8 +263,9 @@ bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &devi
return false;
}
void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int accountId) {
// Скриншот списка транзакций — отправим при ошибке, удалим при успехе
bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountId,
double searchAmount, const QDateTime &searchTime) {
// Скриншот списка транзакций — отправим при неудаче, удалим при успехе
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
// Время в Ozon показывается в таймзоне устройства, а не в MSK
@ -266,12 +288,12 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
int navBarTop = detectNavBarTop();
// Целевая дата в таймзоне устройства для поиска заголовка дня в списке
const QDateTime searchLocal = m_searchTime.toTimeZone(deviceTz);
const QDateTime searchLocal = searchTime.toTimeZone(deviceTz);
const QDate searchDate = searchLocal.date();
qDebug() << "[FetchTransactions] Searching: device=" << m_deviceId
<< "amount=" << m_searchAmount
<< "time=" << m_searchTime.toString(Qt::ISODate)
<< "amount=" << searchAmount
<< "time=" << searchTime.toString(Qt::ISODate)
<< "tz=" << tzName
<< "date(local)=" << searchDate.toString("dd.MM.yyyy")
<< "screen=" << device.screenWidth << "x" << device.screenHeight;
@ -348,7 +370,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
for (int scroll = 0; scroll < maxScrolls; ++scroll) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
return;
return false;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
@ -369,12 +391,12 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
}
if (!dateFound) {
m_error = "Date not found in transaction list: " + searchDate.toString("dd.MM.yyyy");
qWarning() << "[FetchTransactions]" << m_error;
qWarning() << "[FetchTransactions] Date not found in transaction list: "
<< searchDate.toString("dd.MM.yyyy");
m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
return;
return false;
}
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
@ -436,7 +458,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
for (int attempt = 0; attempt < 15; ++attempt) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
return;
return false;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
@ -503,7 +525,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
for (int bi = 0; bi < txButtons.size(); ++bi) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
return;
return false;
}
// Пропускаем кнопки вне Y-диапазона нужной даты.
@ -517,9 +539,9 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text);
qDebug() << "[FetchTransactions] Button" << bi << "y=" << btnY
<< "parsed amount=" << parsed.amount << "search=" << m_searchAmount
<< "parsed amount=" << parsed.amount << "search=" << searchAmount
<< "text=" << txButtons[bi].text.left(60);
if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue;
if (qAbs(parsed.amount - searchAmount) > 0.01) continue;
const Node &visibleBtn = txButtons[bi];
// Зажимаем тап-Y на видимую часть кнопки: если кнопка заходит под
@ -543,7 +565,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
for (int da = 0; da < 10; ++da) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
return;
return false;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
@ -597,8 +619,8 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
}
// Проверяем время (±10 минут)
if (txTime.isValid() && m_searchTime.isValid()) {
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
if (txTime.isValid() && searchTime.isValid()) {
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(searchTime.toUTC()));
if (diffSecs > 600) {
qDebug() << "[FetchTransactions] Time mismatch:" << dtStr
<< "expected" << searchLocal.toString("dd.MM.yyyy, HH:mm")
@ -680,7 +702,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
QThread::msleep(1000);
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
return; // успех
return true; // успех
}
// Отслеживаем прогресс: "без прогресса" только если были кнопки с СОВПАДАЮЩЕЙ
@ -751,14 +773,30 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
}
}
m_error = "Transaction not found: device=" + m_deviceId
+ " amount=" + QString::number(m_searchAmount, 'f', 2)
+ " date=" + searchDate.toString("dd.MM.yyyy");
qWarning() << "[FetchTransactions]" << m_error;
// Эту цель не нашли. m_error не трогаем — агрегированную ошибку по всем
// ненайденным целям соберёт оркестратор в doStart.
qWarning() << "[FetchTransactions] Transaction not found: device=" << m_deviceId
<< "amount=" << QString::number(searchAmount, 'f', 2)
<< "date=" << searchDate.toString("dd.MM.yyyy");
// Отправляем скриншот списка транзакций, сделанный до начала поиска
m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
return false;
}
void GetLastTransactionsScript::scrollListToTop(const DeviceInfo &device) {
// Несколько быстрых свайпов вверх, чтобы вернуться к началу списка операций.
for (int i = 0; i < 5; ++i) {
if (QThread::currentThread()->isInterruptionRequested()) return;
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight / 3,
device.screenWidth / 2,
device.screenHeight * 2 / 3, 200);
QThread::msleep(400);
}
QThread::msleep(800);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
void GetLastTransactionsScript::scanRecentTransactions(const DeviceInfo &device, int accountId) {

View File

@ -2,6 +2,8 @@
#include "CommonScript.h"
#include <QDateTime>
#include <QJsonObject>
#include <QList>
#include <QPair>
#include "db/DeviceInfo.h"
#include "db/BankProfileInfo.h"
@ -11,6 +13,7 @@ class GetLastTransactionsScript final : public CommonScript {
Q_OBJECT
signals:
// Испускается на КАЖДУЮ найденную транзакцию из списка целей.
void transactionParsed(const QJsonObject &tx);
public:
@ -18,8 +21,7 @@ public:
QString deviceId,
QString appCode,
int maxCount = 0,
double searchAmount = 0,
QDateTime searchTime = {},
QList<QPair<double, QDateTime>> targets = {},
QString materialId = {},
QObject *parent = nullptr
);
@ -36,14 +38,20 @@ private:
// Сканирование последних транзакций (для UI: 24ч или maxCount)
void scanRecentTransactions(const DeviceInfo &device, int accountId);
// Поиск конкретной транзакции по сумме и времени (для задач FETCH_TRANSACTIONS)
void searchTransaction(const DeviceInfo &device, int accountId);
// Поиск одной конкретной транзакции по сумме и времени (FETCH_TRANSACTIONS).
// Возвращает true и эмитит transactionParsed, если нашли; false — если нет.
bool searchOne(const DeviceInfo &device, int accountId,
double searchAmount, const QDateTime &searchTime);
// Быстрый возврат к началу списка операций (между поисками целей)
void scrollListToTop(const DeviceInfo &device);
QString m_deviceId;
const QString m_appCode;
const int m_maxCount; // 0 = фильтр по 24ч, >0 = N последних
const double m_searchAmount; // сумма в рублях (0 = не искать конкретную)
const QDateTime m_searchTime; // примерное время (UTC)
// Список целей поиска: пары (сумма в рублях со знаком, примерное время UTC).
// Пустой список → режим сканирования последних (scanRecentTransactions).
const QList<QPair<double, QDateTime>> m_targets;
const QString m_materialId; // UUID задачи (для сохранения дампов)
QString m_error;
};

View File

@ -323,6 +323,28 @@ Node ScreenXmlParser::tryToFindGooglePlayBanner(const int screenWidth, const int
return {};
}
Node ScreenXmlParser::tryToFindOnboardingBanner() {
// Онбординг "Обновили главную" рендерится во ViewPager с этим resource-id.
// Кнопка "Пропустить" — TextView (text_atom) внутри кликабельного контейнера,
// занимающего всю ширину верхней полоски. Сам TextView некликабельный, но его
// bounds совпадают с кликабельным родителем, поэтому тап по его центру попадает.
bool hasOnboarding = false;
for (const Node &node : m_nodes) {
if (node.resourceId == "ru.ozon.fintech.finance:id/onboarding_viewpager") {
hasOnboarding = true;
break;
}
}
if (!hasOnboarding) return {};
for (const Node &node : m_nodes) {
if (node.text.trimmed() == QString::fromUtf8("Пропустить")) {
return node;
}
}
return {};
}
bool ScreenXmlParser::isProfileScreen() {
// Страница профиля Ozon: есть заголовок "Аккаунт" и кнопка "Выйти"
bool hasTitle = false;

View File

@ -50,6 +50,11 @@ public:
Node tryToFindGooglePlayBanner(int screenWidth, int screenHeight);
// Онбординг-баннер "Обновили главную" (resource-id onboarding_viewpager),
// появляется поверх главного экрана Ozon. Возвращает кнопку "Пропустить"
// для тапа, либо пустой Node если баннер не виден.
Node tryToFindOnboardingBanner();
// Находит кнопку "ЗАПРЕТИТЬ" в системном диалоге запроса разрешения Android
// (com.google.android.permissioncontroller). Возвращает пустой Node если диалог не виден.
Node tryToFindSystemPermissionDenyButton();

View File

@ -32,7 +32,7 @@
#include "dushanbe/PayByCardScript.h"
#include "dushanbe/PayByPhoneScript.h"
static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonValue &data, int eventId = -1);
static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonValue &data, int eventId = -1, bool updateStatus = true);
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot = {}, int eventId = -1, const QString &transactionId = {});
static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}, int eventId = -1, bool toApiBase = false);
@ -175,7 +175,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
}
static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName,
const QJsonValue &data, int eventId) {
const QJsonValue &data, int eventId, bool updateStatus) {
QJsonObject obj;
obj["type"] = type;
obj["material_id"] = materialId; // UUID string
@ -190,8 +190,11 @@ static void sendTaskResult(const QString &type, const QString &materialId, const
networkService->setPayload(QJsonDocument(obj).toJson());
QObject::connect(thread, &QThread::started, networkService, &NetworkService::postTaskResult);
QObject::connect(networkService, &NetworkService::finishedWithResult, networkService,
[eventId](int result) {
if (eventId != -1) {
[eventId, updateStatus](int result) {
// updateStatus=false для потоковых per-tx результатов FETCH_TRANSACTIONS:
// статус события финализируется один раз в updateEventThread, а не на
// каждой отдельной отправке найденной транзакции.
if (eventId != -1 && updateStatus) {
if (result == 0) {
EventDAO::markSentToServer(eventId);
} else {
@ -288,9 +291,11 @@ bool EventHandler::tryRetry(const QString &key, const EventInfo &event, const QS
.arg(QString::number(attempt), QString::number(kMaxRetries),
event.deviceId, event.externalId, reason));
// Чистим артефакты прошлой попытки
// Чистим артефакты прошлой попытки. Ретрай запускается только когда ещё
// ничего не отправлено (см. guard в updateEventThread), так что сбрасываем
// счётчик отправленных в 0.
m_screenshots.remove(key);
m_parsedTxs.remove(key);
m_parsedTxCount.remove(key);
// Снимаем текущий поток/таймер (в timeout-ветке они уже сняты — take() вернёт null)
if (const auto tm = m_timers.take(key)) {
@ -317,8 +322,12 @@ bool EventHandler::tryRetry(const QString &key, const EventInfo &event, const QS
void EventHandler::updateEventThread(const QString &key, const EventInfo &event, const QString &result) {
// Поиск транзакции: при ошибке скрипта перезапускаем (ещё до 2 раз) вместо
// немедленной отправки ошибки на сервер.
// немедленной отправки ошибки на сервер. Но только если НИ ОДНОЙ транзакции
// ещё не отправлено: при частичном успехе (часть списка найдена и уже
// улетела на сервер) ретрай бессмыслен — переотправит дубли, поэтому
// ненайденные просто репортим ошибкой ниже.
if (!result.isEmpty() && event.type == EventType::FetchTransactions
&& m_parsedTxCount.value(key, 0) == 0
&& tryRetry(key, event, result)) {
return;
}
@ -413,24 +422,13 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
obj["currency"] = currencyCode;
taskData = obj;
} else if (event.type == EventType::FetchTransactions) {
// FETCH_TRANSACTIONS больше НЕ читает из БД — берём то, что
// скрипт распарсил вживую и передал через сигнал transactionParsed.
const QJsonObject parsedTx = m_parsedTxs.take(key);
if (parsedTx.isEmpty()) {
qWarning() << "[EventHandler] FETCH_TRANSACTIONS: script did not emit parsed tx";
if (tryRetry(key, event, "Transaction not parsed from screen")) return;
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName,
"Transaction not parsed from screen", {}, -1, event.transactionId);
EventDAO::updateEvent(event.id, EventStatus::Error,
"Transaction not parsed from screen");
// FETCH_TRANSACTIONS шлёт результаты потоково: каждая найденная
// транзакция уже отправлена отдельным task_result по сигналу
// transactionParsed (см. startThreadForTask). Здесь, при полном
// успехе (m_error пуст → найдены все цели списка), отдельный
// финальный task_result НЕ отправляем — только финализируем
// событие, чтобы оно не считалось «зависшим» при перезапуске.
EventDAO::markSentToServer(event.id);
m_threads.remove(key);
m_retryCount.remove(event.id);
return;
}
QJsonArray arr;
arr.append(parsedTx);
taskData = arr;
} else if (event.type == EventType::CreateTransaction) {
// Определяем тип транзакции из JSON задачи
const QJsonObject taskJson = QJsonDocument::fromJson(event.json.toUtf8()).object();
@ -467,6 +465,9 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
obj["status"] = "completed";
taskData = obj;
}
// FETCH_TRANSACTIONS уже отправил данные потоково (per-tx) —
// финальный агрегирующий результат не нужен.
if (event.type != EventType::FetchTransactions) {
sendTaskResult(
eventTypeToString(event.type),
event.externalId,
@ -475,6 +476,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
event.id
);
}
}
if (newStatus == EventStatus::Complete) {
const DeviceInfo device = DeviceDAO::getDeviceById(event.deviceId);
@ -560,6 +562,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
}
m_threads.remove(key);
m_retryCount.remove(event.id);
m_parsedTxCount.remove(key);
// Убиваем банковское приложение на устройстве после завершения задачи
{
@ -628,36 +631,54 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
}
if (event.type == EventType::FetchTransactions) {
// Парсим amount и created_at из body задачи
// body теперь — массив транзакций для поиска: [{amount, created_at}, ...].
// Для обратной совместимости поддерживаем и старый формат — одиночный
// объект {amount, created_at} (оборачиваем в массив из одного элемента).
const QJsonObject taskJson = QJsonDocument::fromJson(event.json.toUtf8()).object();
const QJsonObject body = taskJson["body"].toObject();
const double searchAmount = body["amount"].toDouble() / 100.0; // копейки → рубли (с учётом знака)
const QString createdAtStr = body["created_at"].toString();
QDateTime searchTime = QDateTime::fromString(createdAtStr, Qt::ISODate);
// Backend контракт — UTC ISO с суффиксом 'Z' (как мы сами пишем
// на строках 317 и 373). Если 'Z'/смещение отсутствует — Qt парсит
// как LocalTime, что даёт off-by-one для вечерних транзакций
// (см. task_dump (26): Ozon группировал +100 на 14.05, backend
// прислал 13.05 без TZ). Жёстко переинтерпретируем как UTC.
QJsonArray bodyArr = taskJson["body"].toArray();
if (bodyArr.isEmpty() && taskJson["body"].isObject())
bodyArr.append(taskJson["body"]);
QList<QPair<double, QDateTime>> targets;
for (const auto &v : bodyArr) {
const QJsonObject item = v.toObject();
const double searchAmount = item["amount"].toDouble() / 100.0; // копейки → рубли (с учётом знака)
QDateTime searchTime = QDateTime::fromString(item["created_at"].toString(), Qt::ISODate);
// Backend контракт — UTC ISO с суффиксом 'Z'. Если 'Z'/смещение
// отсутствует — Qt парсит как LocalTime, что даёт off-by-one для
// вечерних транзакций (см. task_dump (26)). Жёстко интерпретируем как UTC.
if (searchTime.isValid() && searchTime.timeSpec() == Qt::LocalTime) {
qWarning() << "[EventHandler] FETCH_TRANSACTIONS: created_at без TZ-маркера"
<< createdAtStr << "— интерпретирую как UTC";
<< item["created_at"].toString() << "— интерпретирую как UTC";
searchTime.setTimeZone(QTimeZone::utc());
}
targets.append({searchAmount, searchTime});
}
qDebug() << "[EventHandler] FETCH_TRANSACTIONS: searching" << targets.size() << "transaction(s)";
// Потоковая отправка: каждую найденную транзакцию шлём на сервер сразу
// отдельным task_result (updateStatus=false — статус события финализирует
// updateEventThread). m_parsedTxCount считает отправленные, чтобы не
// ретраить уже частично отработанную задачу.
auto streamParsedTx = [this, key, event](const QJsonObject &tx) {
m_parsedTxCount[key] = m_parsedTxCount.value(key, 0) + 1;
QJsonArray arr;
arr.append(tx);
sendTaskResult(eventTypeToString(EventType::FetchTransactions),
event.externalId, event.bankName, arr, event.id, /*updateStatus=*/false);
};
if (appCode == "ozon") {
auto *w = new Ozon::GetLastTransactionsScript(adbSerial, appCode, 0, searchAmount, searchTime, account.materialId);
auto *w = new Ozon::GetLastTransactionsScript(adbSerial, appCode, 0, targets, account.materialId);
setup(w); connectScreenshot(w);
connect(w, &Ozon::GetLastTransactionsScript::transactionParsed, this,
[this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; });
connect(w, &Ozon::GetLastTransactionsScript::transactionParsed, this, streamParsedTx);
}
else if (appCode == "black")
setup(new Black::GetLastTransactionsScript(adbSerial, appCode));
else if (appCode == "dushanbe_city_bank") {
auto *w = new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, searchAmount, searchTime, account.materialId);
auto *w = new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, targets, account.materialId);
setup(w); connectScreenshotDushanbe(w);
connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this,
[this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; });
connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this, streamParsedTx);
}
else qWarning() << "[EventHandler] FETCH_TRANSACTIONS: unknown appCode:" << appCode;
}
@ -712,8 +733,10 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
}
m_threads.remove(key);
// Поиск транзакции: таймаут тоже ретраим (ещё до 2 раз)
// Поиск транзакции: таймаут тоже ретраим (ещё до 2 раз), но только если
// ещё ни одной транзакции не отправлено (иначе ретрай переотправит дубли).
if (event.type == EventType::FetchTransactions
&& m_parsedTxCount.value(key, 0) == 0
&& tryRetry(key, event, "Script timeout (5 min)")) {
return;
}
@ -812,7 +835,7 @@ void EventHandler::clearAll() {
}
m_timers.clear();
m_screenshots.clear();
m_parsedTxs.clear();
m_parsedTxCount.clear();
}
void EventHandler::stop() {

View File

@ -34,7 +34,7 @@ private:
QMap<QString, QThread *> m_threads;
QMap<QString, QTimer *> m_timers;
QMap<QString, QByteArray> m_screenshots; // deviceId → скриншот от скрипта
QMap<QString, QJsonObject> m_parsedTxs; // key → распарсенная транзакция для FETCH_TRANSACTIONS
QMap<QString, int> m_parsedTxCount; // key → сколько транзакций уже отправлено на сервер (FETCH_TRANSACTIONS, per-tx streaming)
QMap<int, int> m_retryCount; // event.id → число уже сделанных ретраев (только FETCH_TRANSACTIONS)
QTimer *m_pollTimer = nullptr;
NetworkService *m_network = nullptr;