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

View File

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

View File

@ -179,6 +179,14 @@ bool CommonScript::runAppAndGoToHomeScreen(
continue; 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) { if (!findAndInputPincode) {
const QList<Node> pincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН"); const QList<Node> pincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
@ -225,6 +233,12 @@ bool CommonScript::tryToCloseAllBannersOnHomePage(const QString &deviceId, const
closedAny = true; closedAny = true;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
continue; 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); } else if (Node closeBannerOrDialogNode = xmlScreenParser.tryToFindBannerOrDialog(width, height);
closeBannerOrDialogNode.x() != -1 && closeBannerOrDialogNode.y() != -1) { closeBannerOrDialogNode.x() != -1 && closeBannerOrDialogNode.y() != -1) {
AdbUtils::makeTap(deviceId, closeBannerOrDialogNode.x(), closeBannerOrDialogNode.y()); AdbUtils::makeTap(deviceId, closeBannerOrDialogNode.x(), closeBannerOrDialogNode.y());

View File

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

View File

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

View File

@ -323,6 +323,28 @@ Node ScreenXmlParser::tryToFindGooglePlayBanner(const int screenWidth, const int
return {}; 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() { bool ScreenXmlParser::isProfileScreen() {
// Страница профиля Ozon: есть заголовок "Аккаунт" и кнопка "Выйти" // Страница профиля Ozon: есть заголовок "Аккаунт" и кнопка "Выйти"
bool hasTitle = false; bool hasTitle = false;

View File

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

View File

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

View File

@ -34,7 +34,7 @@ private:
QMap<QString, QThread *> m_threads; QMap<QString, QThread *> m_threads;
QMap<QString, QTimer *> m_timers; QMap<QString, QTimer *> m_timers;
QMap<QString, QByteArray> m_screenshots; // deviceId → скриншот от скрипта 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) QMap<int, int> m_retryCount; // event.id → число уже сделанных ретраев (только FETCH_TRANSACTIONS)
QTimer *m_pollTimer = nullptr; QTimer *m_pollTimer = nullptr;
NetworkService *m_network = nullptr; NetworkService *m_network = nullptr;