#include "GetLastTransactionsScript.h" #include #include #include #include #include #include #include #include "adb/AdbUtils.h" #include "dump/TaskDumpRecorder.h" #include "dao/MaterialDAO.h" #include "dao/BankProfileDAO.h" #include "dao/DeviceDAO.h" #include "AppLogger.h" #include "BankConfigUtils.h" #include #include // Душанбе показывает суммы как "100,50", "100.50 TJS", "1 000,50", // "1 000.50 TJS" и т.п. Голый QString::toDouble на таком вернёт 0, // потому что требует, чтобы вся строка была валидным числом. static double parseDushanbeAmount(const QString &raw) { QString s = raw; s.remove(QRegularExpression(R"([^\d,.\-])")); s.replace(',', '.'); const int firstDot = s.indexOf('.'); if (firstDot >= 0) { QString head = s.left(firstDot); QString tail = s.mid(firstDot + 1); tail.remove('.'); s = head + '.' + tail; } return s.toDouble(); } static QString recognizeTextFromImage(const QByteArray &pngData) { auto *api = new tesseract::TessBaseAPI(); if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) { qWarning() << "[Dushanbe::OCR] Failed to init Tesseract"; delete api; return {}; } Pix *pix = pixReadMem(reinterpret_cast(pngData.constData()), static_cast(pngData.size())); if (!pix) { api->End(); delete api; return {}; } api->SetImage(pix); char *text = api->GetUTF8Text(); QString result = QString::fromUtf8(text).trimmed(); delete[] text; pixDestroy(&pix); api->End(); delete api; return result; } // Точечный OCR: находит "Номер операции" через общий OCR, затем кропает строку правее // и распознаёт только цифры. Работает с любым разрешением экрана. static QString recognizeOperationId(const QByteArray &pngData) { Pix *pix = pixReadMem(reinterpret_cast(pngData.constData()), static_cast(pngData.size())); if (!pix) return {}; const int w = pixGetWidth(pix); const int h = pixGetHeight(pix); // 1. Полный OCR с координатами слов — ищем "операции" auto *api = new tesseract::TessBaseAPI(); if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) { pixDestroy(&pix); delete api; return {}; } api->SetImage(pix); api->Recognize(nullptr); int opLineY = -1; int opLineH = 0; bool prevWasNomer = false; tesseract::ResultIterator *ri = api->GetIterator(); if (ri) { do { const char *word = ri->GetUTF8Text(tesseract::RIL_WORD); if (word) { const QString w_str = QString::fromUtf8(word); delete[] word; if (w_str.contains(QString::fromUtf8("омер")) || w_str.contains(QString::fromUtf8("omep"))) { prevWasNomer = true; continue; } if (prevWasNomer && (w_str.contains(QString::fromUtf8("операции")) || w_str.contains(QString::fromUtf8("onepaции")))) { int x1, y1, x2, y2; ri->BoundingBox(tesseract::RIL_WORD, &x1, &y1, &x2, &y2); opLineY = y1; opLineH = y2 - y1; break; } prevWasNomer = false; } } while (ri->Next(tesseract::RIL_WORD)); } api->End(); delete api; if (opLineY < 0) { pixDestroy(&pix); return {}; } // 2. Кропаем правую половину этой строки и OCR только цифры const int cropY = qMax(0, opLineY - opLineH / 2); const int cropH = opLineH * 2; BOX *box = boxCreate(w / 2, cropY, w / 2, qMin(cropH, h - cropY)); Pix *cropped = pixClipRectangle(pix, box, nullptr); boxDestroy(&box); pixDestroy(&pix); if (!cropped) return {}; auto *api2 = new tesseract::TessBaseAPI(); if (api2->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "eng")) { pixDestroy(&cropped); delete api2; return {}; } api2->SetVariable("tessedit_char_whitelist", "0123456789"); api2->SetPageSegMode(tesseract::PSM_SINGLE_LINE); api2->SetImage(cropped); char *text = api2->GetUTF8Text(); QString result = QString::fromUtf8(text).trimmed(); delete[] text; pixDestroy(&cropped); api2->End(); delete api2; result.remove(QRegularExpression("[^0-9]")); if (result.length() >= 8) { qDebug() << "[Dushanbe::OCR] Precise operation ID:" << result; return result; } return {}; } struct OcrTransactionDetail { QString date; QString time; QString operationId; QString provider; QString senderAccount; QString receiverAccount; double amount = 0.0; double fee = 0.0; QString status; }; static OcrTransactionDetail parseDetailFromOcr(const QString &ocrText) { OcrTransactionDetail d; const QStringList lines = ocrText.split('\n'); for (const QString &rawLine : lines) { const QString line = rawLine.trimmed(); // "Дата операции: 05.04.2026" или "Дата операции:" на отдельной строке if (line.contains(QString::fromUtf8("Дата операции"))) { QRegularExpression re(R"((\d{2}\.\d{2}\.\d{4}))"); if (auto m = re.match(line); m.hasMatch()) d.date = m.captured(1); } // "Время операции: 18:19:54" else if (line.contains(QString::fromUtf8("Время операции"))) { QRegularExpression re(R"((\d{2}:\d{2}:\d{2}))"); if (auto m = re.match(line); m.hasMatch()) d.time = m.captured(1); } // "Номер операции: 1598279763" else if (line.contains(QString::fromUtf8("омер операции"))) { QRegularExpression re(R"((\d{5,}))"); if (auto m = re.match(line); m.hasMatch()) d.operationId = m.captured(1); } // "Поставщик: DC (по номеру карты)" else if (line.contains(QString::fromUtf8("Поставщик"))) { QRegularExpression re(R"(Поставщик[:\s]*(.+))"); if (auto m = re.match(line); m.hasMatch()) d.provider = m.captured(1).trimmed(); } // "Счет отправителя: 9762***3258" else if (line.contains(QString::fromUtf8("Счет отправителя")) || line.contains(QString::fromUtf8("чет отправителя"))) { QRegularExpression re(R"((\d[\d*]+))"); if (auto m = re.match(line); m.hasMatch()) d.senderAccount = m.captured(1); } // "Счет получателя: 9762000186263942" или "992111100664" else if (line.contains(QString::fromUtf8("Счет получателя")) || line.contains(QString::fromUtf8("чет получателя"))) { QRegularExpression re(R"((\d{6,}))"); if (auto m = re.match(line); m.hasMatch()) d.receiverAccount = m.captured(1); } // "Сумма операции: 1.00" else if (line.contains(QString::fromUtf8("Сумма операции")) || line.contains(QString::fromUtf8("умма операции"))) { QRegularExpression re(R"((\d+[\.,]\d{2}))"); if (auto m = re.match(line); m.hasMatch()) { QString val = m.captured(1); val.replace(',', '.'); d.amount = val.toDouble(); } } // "Комиссия: 0.00" else if (line.contains(QString::fromUtf8("Комиссия")) || line.contains(QString::fromUtf8("омиссия"))) { QRegularExpression re(R"((\d+[\.,]\d{2}))"); if (auto m = re.match(line); m.hasMatch()) { QString val = m.captured(1); val.replace(',', '.'); d.fee = val.toDouble(); } } // "Статус: Успешный" else if (line.contains(QString::fromUtf8("Статус"))) { if (line.contains(QString::fromUtf8("Успешн"))) { d.status = QString::fromUtf8("Успешный"); } } } // Fallback: если дата/время не найдены в "ключ: значение", ищем паттерном if (d.date.isEmpty()) { QRegularExpression re(R"((\d{2}\.\d{2}\.\d{4}))"); if (auto m = re.match(ocrText); m.hasMatch()) d.date = m.captured(1); } if (d.time.isEmpty()) { QRegularExpression re(R"((\d{2}:\d{2}:\d{2}))"); auto it = re.globalMatch(ocrText); QStringList times; while (it.hasNext()) times.append(it.next().captured(1)); if (times.size() >= 2) d.time = times[1]; else if (times.size() == 1) d.time = times[0]; } if (ocrText.contains(QString::fromUtf8("Успешн"))) d.status = QString::fromUtf8("Успешный"); return d; } namespace Dushanbe { GetLastTransactionsScript::GetLastTransactionsScript( QString deviceId, QString appCode, QList> targets, QString materialId, QObject *parent ) : CommonScript(parent), m_deviceId(std::move(deviceId)), m_appCode(std::move(appCode)), m_targets(std::move(targets)), m_materialId(std::move(materialId)) { } GetLastTransactionsScript::~GetLastTransactionsScript() = default; void GetLastTransactionsScript::doStart() { DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial; const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode); qDebug() << "[Dushanbe::GetLastTransactions] device.id=" << device.id << "adbSerial=" << device.adbSerial << "appCode=" << m_appCode << "app.id=" << app.id << "pinCode.length=" << app.pinCode.length() << "package=" << app.package; if (device.id.isEmpty() || app.id == -1) { m_error = "Device or app not found: " + m_deviceId + " " + m_appCode; qCritical() << m_error; emit finishedWithResult(m_error); return; } const double firstAmount = m_targets.isEmpty() ? 0.0 : m_targets.first().first; const TaskDumpMeta dumpMeta{m_materialId, firstAmount, {}, {}, app.bankProfileId, app.phone, device.id, app.appVersion}; TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error); // Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h). xmlScreenParser.setVersionTrack(Dushanbe::trackForVersion(app.appVersion)); qDebug() << "[Dushanbe::GetLastTransactions] appVersion=" << app.appVersion << "→ track" << Dushanbe::trackName(xmlScreenParser.versionTrack()); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); if (!xmlScreenParser.isHomeScreen()) { qDebug() << "[Dushanbe::GetLastTransactions] Not on home screen, restarting app..."; if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) { m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ")"; qWarning() << m_error; AppLogger::log("dushanbe/GetLastTransactions", m_deviceId, m_error, {}, "FETCH_TRANSACTIONS"); AdbUtils::tryToKillApplication(m_deviceId, app.package); emit finishedWithResult(m_error); return; } xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); } // Тапаем "История" в нижнем меню Node historyBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("История")); if (historyBtn.x() == 0 && historyBtn.y() == 0) { m_error = "Cannot find History button: " + m_deviceId; qWarning() << m_error; AdbUtils::tryToKillApplication(m_deviceId, app.package); emit finishedWithResult(m_error); return; } AdbUtils::makeTap(m_deviceId, historyBtn.x(), historyBtn.y()); QThread::msleep(3000); // Ждём экран истории bool historyLoaded = false; for (int attempt = 0; attempt < 10; ++attempt) { xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight); // Проверяем диалог "Ошибка" — нажимаем "ОК" и обновляем свайпом вниз if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка")).isEmpty()) { Node okBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("ОК")); if (!okBtn.isEmpty()) { qDebug() << "[Dushanbe::GetLastTransactions] Error dialog detected, tapping OK"; AdbUtils::makeTap(m_deviceId, okBtn.x(), okBtn.y()); QThread::msleep(1000); } continue; } if (xmlScreenParser.isHistoryScreen()) { historyLoaded = true; break; } QThread::msleep(1500); } if (!historyLoaded) { m_error = "History screen not loaded: " + m_deviceId; qWarning() << m_error; AdbUtils::tryToKillApplication(m_deviceId, app.package); emit finishedWithResult(m_error); return; } // Переключаемся на вкладку "Выписка" (по умолчанию открыта "Операции") Node vypiskaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Выписка")); if (vypiskaBtn.isEmpty()) { m_error = "Cannot find 'Выписка' tab: " + m_deviceId; qWarning() << m_error; AdbUtils::tryToKillApplication(m_deviceId, app.package); emit finishedWithResult(m_error); return; } qDebug() << "[Dushanbe::GetLastTransactions] Switching to 'Выписка' tab"; AdbUtils::makeTap(m_deviceId, vypiskaBtn.x(), vypiskaBtn.y()); QThread::msleep(3000); // Pull-to-refresh: свайп сверху вниз для обновления списка { const int x = device.screenWidth / 2; const int fromY = device.screenHeight / 3; const int toY = device.screenHeight * 2 / 3; AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500); QThread::msleep(3000); qDebug() << "[Dushanbe::GetLastTransactions] Pull-to-refresh on Выписка"; } // Сохраняем дамп Выписки для анализа формата xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); // Закрываем возможный диалог "Ошибка" на Выписке if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка")).isEmpty()) { Node okBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("ОК")); if (!okBtn.isEmpty()) { qDebug() << "[Dushanbe::GetLastTransactions] Error dialog on Выписка, tapping OK"; AdbUtils::makeTap(m_deviceId, okBtn.x(), okBtn.y()); QThread::msleep(1000); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); } } // Находим accountId int accountId = -1; const QList accounts = MaterialDAO::getAccounts(device.id, m_appCode); if (!accounts.isEmpty()) { accountId = accounts.first().id; } else { qWarning() << "[Dushanbe::GetLastTransactions] No account found"; } // История отсортирована сверху-вниз: новые операции выше старых. Ищем цели // в том же порядке (по времени, от новых к старым) — тогда один непрерывный // проход вниз находит все, и возвращаться к началу между целями не нужно. QList> targets = m_targets; std::sort(targets.begin(), targets.end(), [](const QPair &a, const QPair &b) { return a.second > b.second; // по убыванию времени (новые первыми) }); // Ищем каждую цель из списка. Найденные отдаём сразу через transactionParsed; // ненайденные копим и репортим одной агрегированной ошибкой в конце. QStringList notFound; for (int i = 0; i < targets.size(); ++i) { if (QThread::currentThread()->isInterruptionRequested()) { m_error = "Interrupted"; break; } const double amount = targets[i].first; const QDateTime time = targets[i].second; // Проход идёт вниз от текущей позиции. Если цель не нашлась — возможно, // предыдущая (более новая) цель отсутствовала и увела список вниз, а эта // лежит выше. Тогда надёжно возвращаемся к началу Выписки и пробуем ещё раз. SearchResult res = searchOne(device, accountId, amount, time); if (res == SearchResult::NotFound && m_error != "Interrupted") { scrollHistoryToTop(device); res = searchOne(device, accountId, amount, time); } if (res != SearchResult::Found) { 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) { // Свайпы сверху-вниз поднимают список Выписки к началу. Поиск ОДНОЙ цели // (searchOne) мог проскроллить вниз до maxScrolls(=10) раз, поэтому // фиксированного числа свайпов вверх не хватает — крутим, пока верх списка // не перестанет меняться (значит достигли начала), с запасом по итерациям. static const QRegularExpression reTime(R"(\d{2}:\d{2}:\d{2})"); // Сигнатура верха списка: content-desc самой верхней View-строки операции // (last line — время HH:MM:SS). Пустая строка — список не распознан/пуст. auto topSignature = [&]() -> QString { xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); QString best; int bestY = device.screenHeight + 1; for (const Node &n : xmlScreenParser.findAllNodes()) { if (n.className != "android.view.View") continue; const QStringList lines = n.contentDesc.split('\n'); if (lines.size() < 5) continue; if (!reTime.match(lines.last().trimmed()).hasMatch()) continue; if (n.y1() < bestY) { bestY = n.y1(); best = n.contentDesc; } } return best; }; const int x = device.screenWidth / 2; const int fromY = device.screenHeight / 3; // ниже шапки — без pull-to-refresh const int toY = device.screenHeight * 3 / 4; // длинный бросок вниз → контент вверх const int maxUpSwipes = 14; // > maxScrolls(10) с запасом QString prevTop = topSignature(); int stable = 0; for (int i = 0; i < maxUpSwipes; ++i) { if (QThread::currentThread()->isInterruptionRequested()) return; AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500); QThread::msleep(700); const QString curTop = topSignature(); if (!curTop.isEmpty() && curTop == prevTop) { if (++stable >= 2) break; // верх не меняется два свайпа подряд → начало списка } else { stable = 0; prevTop = curTop; } } QThread::msleep(500); } GetLastTransactionsScript::SearchResult 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"); const int maxScrolls = 10; // Окно поиска по датам: сегодня + (N-1) предыдущих дней. Настраивается в // config.ini → []/transaction_search_window_days (дефолт 2 = сегодня+вчера). const int searchWindowDays = BankConfigUtils::transactionSearchWindowDays(m_appCode); auto interrupted = []() { return QThread::currentThread()->isInterruptionRequested(); }; struct VypiskaItem { double amount = 0.0; QString time; // "21:31:21" QString date; // "05.04.2026" bool dateReliable = false; // true если дата из content-desc (P2P), false если из groupDates Node node; }; // Guard от «залипшей» прокрутки: если свайп не двигает список (банковское // приложение перестало скроллиться — частый случай при сбоях UI/жестов), // видимые транзакции не меняются. Тогда листать до maxScrolls бессмысленно, // а при десятках целей это упирается в 10-минутный watchdog → "Interrupted". // Детектируем по сигнатуре видимого набора: 2 одинаковых подряд = прогресса нет. QString prevViewSig; int stallScrolls = 0; for (int scroll = 0; scroll <= maxScrolls; ++scroll) { if (interrupted()) { m_error = "Interrupted"; return SearchResult::NotFound; } xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight); // Верх нижнего меню: минимальный y1() среди табов и FAB "Сканировать". // Тап по строке с y >= navTopY - navMargin промахивается и попадает в FAB/таб. int navTopY = device.screenHeight; for (const QString &lbl : { QString::fromUtf8("Главная"), QString::fromUtf8("История"), QString::fromUtf8("Переводы"), QString::fromUtf8("Сканировать")}) { Node nav = xmlScreenParser.findNodeByContentDesc(lbl); if (!nav.isEmpty() && nav.y1() > 0 && nav.y1() < navTopY) navTopY = nav.y1(); } const int navMargin = 20; qDebug() << "[Dushanbe::GetLastTransactions] navTopY=" << navTopY; // 1. Собираем список дат-групп + извлекаем "встроенную" верхнюю транзакцию, // которая впечатана в content-desc родительского ImageView "Обновлено: ..." // вместо отдельного android.view.View (самая свежая запись в Выписке). const QDate todayLocal = QDateTime::currentDateTimeUtc().toTimeZone(tjTz).date(); QStringList groupDates; static const QRegularExpression reDate(R"(^\d{2}\.\d{2}\.\d{4}$)"); static const QRegularExpression reEmbTime(R"(^\s*(\d{2}:\d{2}:\d{2})\s*$)"); Node updatedImgNode; VypiskaItem embedded; bool hasEmbedded = false; for (const Node &n : xmlScreenParser.findAllNodes()) { if (n.className != "android.widget.ImageView") continue; if (!n.contentDesc.startsWith(QString::fromUtf8("Обновлено"))) continue; updatedImgNode = n; const QStringList descLines = n.contentDesc.split('\n'); for (const QString &line : descLines) { const QString t = line.trimmed(); if (reDate.match(t).hasMatch()) { groupDates.append(t); } else if (t.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0) { groupDates.append(todayLocal.toString("dd.MM.yyyy")); } else if (t.compare(QString::fromUtf8("Вчера"), Qt::CaseInsensitive) == 0) { groupDates.append(todayLocal.addDays(-1).toString("dd.MM.yyyy")); } } // Паттерн встроенной: ...\ncard\ncard\namount\nописание\n HH:MM:SS\n... for (int i = 4; i < descLines.size(); ++i) { const auto tm = reEmbTime.match(descLines[i]); if (!tm.hasMatch()) continue; bool ok = false; const double amt = descLines[i - 2].trimmed().toDouble(&ok); if (!ok) continue; embedded.amount = amt; embedded.time = tm.captured(1); if (!groupDates.isEmpty()) embedded.date = groupDates.first(); hasEmbedded = true; break; } break; } // 2. Собираем View-транзакции (формат Выписки) // Известные форматы content-desc (всё через '\n'): // a) DCWallet (старый, 5 строк): DCWallet*WDC...\ncard\namount\nописание\n HH:MM:SS // b) DCWallet (новый, 6 строк): DCWallet*WDC...\nphone-card-\ncard_owner\namount\nописание\n HH:MM:SS // c) P2P: Сегодня|dd.MM.yyyy\ncard\ncard\namount\nописание\n HH:MM:SS // Позиция amount уехала между версиями приложения, поэтому ищем по // паттерну "[-]?digits.dd" среди всех строк — устойчиво к перестановке полей. static const QRegularExpression reTime(R"(\d{2}:\d{2}:\d{2})"); static const QRegularExpression reDateOrToday( QString("^(?:\\d{2}\\.\\d{2}\\.\\d{4}|%1|%2)$") .arg(QString::fromUtf8("Сегодня"), QString::fromUtf8("Вчера"))); // Сумма: либо с десятичной частью ("-5577.95", "109,50"), либо целая // ("109" — P2P-зачисления показываются без копеек). Целую часть // ограничиваем 7 цифрами, чтобы не спутать с 12–16-значным номером // карты, если он окажется на отдельной строке без пробелов/дефисов. static const QRegularExpression reAmountLine(R"(^-?(?:\d+[.,]\d{1,2}|\d{1,7})$)"); QList items; for (const Node &n : xmlScreenParser.findAllNodes()) { if (n.className != "android.view.View") continue; const QStringList lines = n.contentDesc.split('\n'); if (lines.size() < 5) continue; const QString last = lines.last().trimmed(); if (!reTime.match(last).hasMatch()) continue; // Определяем формат: если первая строка — дата/Сегодня/Вчера, это P2P формат const QString firstLine = lines.first().trimmed(); const bool isP2P = reDateOrToday.match(firstLine).hasMatch(); if (!isP2P && !n.contentDesc.contains("DCWallet") && !n.contentDesc.contains("***")) continue; VypiskaItem it; bool ok = false; // Ищем строку-сумму — устойчиво и к 5-, и к 6-строчному DCWallet, // и к P2P. Карты с пробелами ("9762 0002 ...") и дефисами, а также // текстовые поля не подпадают под паттерн. for (const QString &line : lines) { const QString t = line.trimmed(); if (!reAmountLine.match(t).hasMatch()) continue; QString v = t; v.replace(',', '.'); it.amount = v.toDouble(&ok); if (ok) break; } if (!ok) continue; it.time = last; it.node = n; // В P2P формате дата встроена в первую строку — надёжная if (isP2P) { if (firstLine.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0) it.date = todayLocal.toString("dd.MM.yyyy"); else if (firstLine.compare(QString::fromUtf8("Вчера"), Qt::CaseInsensitive) == 0) it.date = todayLocal.addDays(-1).toString("dd.MM.yyyy"); else it.date = firstLine; it.dateReliable = true; } items.append(it); } // 2a. Если нашли встроенную транзакцию — создаём синтетический Node с // координатами тапа над первой "реальной" View-записью и добавляем // как первый элемент списка. if (hasEmbedded && !updatedImgNode.isEmpty()) { int firstViewY1 = -1; int firstViewRowH = 0; for (const VypiskaItem &v : items) { if (firstViewY1 < 0 || v.node.y1() < firstViewY1) { firstViewY1 = v.node.y1(); firstViewRowH = v.node.height(); } } const int topY = updatedImgNode.y1(); // Если первая реальная View-запись уже находится у верха ImageView // (значит скролл поднял контент и встроенная запись ушла за экран), // пропускаем embedded — её визуально нет. const int minEmbeddedSpace = 120; const bool embeddedVisible = firstViewY1 < 0 || (firstViewY1 - topY) >= minEmbeddedSpace; if (embeddedVisible) { int tapY; if (firstViewY1 > 0 && firstViewRowH > 0) { // Между встроенной записью и первой "реальной" View лежит // заголовок даты (~0.4 * rowH). Ставим тап по центру // строки: firstViewY1 - headerH - rowH/2 ≈ firstViewY1 - rowH * 0.9. // Дополнительно ограничиваем диапазоном [topY + rowH/2 .. firstViewY1 - 40]. tapY = firstViewY1 - (firstViewRowH * 9 / 10); const int minY = topY + firstViewRowH / 2; const int maxY = firstViewY1 - 40; if (tapY < minY) tapY = minY; if (tapY > maxY) tapY = maxY; } else { tapY = topY + 200; } Node synth; synth.setBounds(updatedImgNode.x1(), tapY - 10, updatedImgNode.x2(), tapY + 10); synth.className = "android.view.View"; synth.contentDesc = updatedImgNode.contentDesc; synth.clickable = true; embedded.node = synth; items.prepend(embedded); qDebug() << "[Dushanbe::GetLastTransactions] Embedded tx added:" << "amount=" << embedded.amount << "time=" << embedded.time << "date=" << embedded.date << "tapY=" << tapY; } else { qDebug() << "[Dushanbe::GetLastTransactions] Embedded tx skipped " "(scrolled off): firstViewY1=" << firstViewY1 << "topY=" << topY; } } // 3. Сортируем по Y и группируем по разрывам (> 30px → новая группа-день) std::sort(items.begin(), items.end(), [](const VypiskaItem &a, const VypiskaItem &b) { return a.node.y1() < b.node.y1(); }); int groupIdx = 0; int prevY2 = -1; for (VypiskaItem &it : items) { if (prevY2 >= 0 && it.node.y1() - prevY2 > 30) ++groupIdx; // Не перезаписываем дату если она уже установлена (P2P формат) if (it.date.isEmpty() && groupIdx < groupDates.size()) it.date = groupDates[groupIdx]; prevY2 = it.node.y2(); } qDebug() << "[Dushanbe::GetLastTransactions] scroll=" << scroll << "items:" << items.size() << "groups:" << groupDates << "hasEmbedded:" << hasEmbedded; // Логируем все items для отладки for (int i = 0; i < items.size(); ++i) { qDebug() << "[Dushanbe::GetLastTransactions] item[" << i << "] amount=" << items[i].amount << "date=" << items[i].date << "time=" << items[i].time << "y=" << items[i].node.y(); } const double searchAbs = qAbs(searchAmount); for (const auto &tx : items) { const double absListAmt = qAbs(tx.amount); const bool amountDirect = qAbs(absListAmt - searchAbs) < 0.01; const bool amountX100 = qAbs(absListAmt * 100.0 - searchAbs) < 0.01; if (!amountDirect && !amountX100) continue; const bool matchedX100 = amountX100 && !amountDirect; qDebug() << "[Dushanbe::GetLastTransactions] Amount match:" << tx.amount << (matchedX100 ? "(scaled x100)" : "(direct)") << "date:" << tx.date << "time:" << tx.time; // Pre-filter по ДАТЕ: только если дата надёжная (из content-desc P2P формата). // DCWallet транзакции получают дату из groupDates ImageView, которая // неправильно присваивается при скролле — не фильтруем по ней. if (tx.dateReliable && searchTime.isValid() && !tx.date.isEmpty()) { const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy"); const QDate searchLocalDate = searchTime.toTimeZone(tjTz).date(); if (listDate.isValid() && searchLocalDate.isValid()) { const qint64 dayDiff = qAbs(listDate.daysTo(searchLocalDate)); if (dayDiff > 3) { qDebug() << "[Dushanbe::GetLastTransactions] Date pre-filter skip:" << tx.date << "vs" << searchLocalDate.toString("dd.MM.yyyy") << "diff=" << dayDiff << "days"; continue; } } } // Проверка времени по HH:MM:SS (±10 минут, с учётом wraparound полуночи) if (searchTime.isValid() && !tx.time.isEmpty()) { const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss"); 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 qDebug() << "[Dushanbe::GetLastTransactions] Time pre-filter skip:" << tx.time << "vs" << searchLocalTime.toString("HH:mm:ss") << "diff=" << diffSecs << "sec"; continue; } } } Node txNode = tx.node; if (txNode.x() == 0 && txNode.y() == 0) { qWarning() << "[Dushanbe::GetLastTransactions] Cannot find node for transaction"; continue; } // Строка под нижним меню — тап попадёт в FAB "Сканировать" или таб. // Пропускаем кандидата, ищем совпадение выше; если таких нет — скролл. if (txNode.y() >= navTopY - navMargin) { qDebug() << "[Dushanbe::GetLastTransactions] Candidate y=" << txNode.y() << "behind nav (navTopY=" << navTopY << "), skipping, will scroll"; continue; } // Тап по левому краю ряда — уходим от центра экрана, где FAB "Сканировать". const int tapX = txNode.x1() + 80; AdbUtils::makeTap(m_deviceId, tapX, txNode.y()); QThread::msleep(3000); if (interrupted()) { m_error = "Interrupted"; return SearchResult::NotFound; } // Ждём экран деталей Выписки — ImageView с content-desc начинающимся с "Успешный платеж" OcrTransactionDetail detail; bool detailLoaded = false; for (int attempt = 0; attempt < 10; ++attempt) { if (interrupted()) { m_error = "Interrupted"; return SearchResult::NotFound; } 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): // одна нода со всем content-desc через \n. Лейблы без двоеточий, сумма — // отдельной строкой со знаком сразу после даты+времени. // "Успешный платёж\n21.05.2026 16:58:32\n-10\nКарта\n...\nПолучатель\n...\nЗакрыть" for (const Node &n : xmlScreenParser.findAllNodes()) { const QString cd = n.contentDesc; if (!cd.contains(QString::fromUtf8("Карта"))) continue; if (!cd.contains(QString::fromUtf8("Получатель"))) continue; if (!cd.contains(QString::fromUtf8("Операция"))) continue; const QStringList lines = cd.split('\n', Qt::SkipEmptyParts); if (lines.size() < 6) continue; // lines[0] — заголовок: "Успешный платёж" / "Поступление" / ... if (lines[0].contains(QString::fromUtf8("Успешн")) || lines[0].contains(QString::fromUtf8("Поступление")) || lines[0].contains(QString::fromUtf8("выполнен"))) { detail.status = QString::fromUtf8("Успешный"); } // lines[1] — "dd.MM.yyyy HH:mm:ss" { static const QRegularExpression reDt( R"((\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2}:\d{2}))"); const auto m = reDt.match(lines[1]); if (m.hasMatch()) { detail.date = m.captured(1); detail.time = m.captured(2); } } // lines[2] — сумма со знаком: "-10" / "30" / "30.50" / "1 000,50". detail.amount = parseDushanbeAmount(lines[2]); // Дальше — пары label/value; значение может быть multi-line (Получатель). // На сервер шлём первую строку получателя — таков сложившийся контракт. static const QSet labels{ QString::fromUtf8("Карта"), QString::fromUtf8("Получатель"), QString::fromUtf8("Операция"), QString::fromUtf8("Дата"), QString::fromUtf8("Время"), QString::fromUtf8("Закрыть"), QString::fromUtf8("Назад"), }; for (int i = 3; i < lines.size(); ++i) { const QString lbl = lines[i].trimmed(); if (!labels.contains(lbl)) continue; QStringList vals; for (int j = i + 1; j < lines.size(); ++j) { const QString cand = lines[j].trimmed(); if (labels.contains(cand)) break; vals << cand; } if (vals.isEmpty()) continue; if (lbl == QString::fromUtf8("Получатель")) { detail.receiverAccount = vals.first(); } else if (lbl == QString::fromUtf8("Дата") && detail.date.isEmpty()) { detail.date = vals.first(); } else if (lbl == QString::fromUtf8("Время") && detail.time.isEmpty()) { detail.time = vals.first(); } } detailLoaded = true; break; } // 2) Старый blob-формат (до 3.2.33): "Успешный платеж" / "Поступление" — // одна нода с ключами и значениями через \n. if (!detailLoaded) for (const Node &n : xmlScreenParser.findAllNodes()) { if (!n.contentDesc.contains(QString::fromUtf8("Дата"))) continue; if (!n.contentDesc.contains(QString::fromUtf8("Время"))) continue; const QStringList lines = n.contentDesc.split('\n'); for (int i = 0; i < lines.size(); ++i) { QString key = lines[i].trimmed(); if (key.endsWith(':')) key.chop(1); // нормализуем "Дата:" → "Дата" if (i + 1 >= lines.size()) continue; const QString val = lines[i + 1].trimmed(); if (key == QString::fromUtf8("Получатель")) { detail.receiverAccount = val; } else if (key == QString::fromUtf8("Сумма")) { detail.amount = parseDushanbeAmount(val); } else if (key == QString::fromUtf8("Дата")) { detail.date = val; } else if (key == QString::fromUtf8("Время")) { detail.time = val; } } detailLoaded = true; break; } // 3) Split-формат: лейблы (с ':') и значения в отдельных View-нодах // на одной Y-полосе. Промежуточная версия между blob и bottom-sheet. if (!detailLoaded) { const QMap pairs = xmlScreenParser.parseLabelValuePairs(); const QString d = pairs.value(QString::fromUtf8("Дата")); const QString t = pairs.value(QString::fromUtf8("Время")); if (!d.isEmpty() && !t.isEmpty()) { detail.date = d; detail.time = t; detail.receiverAccount = pairs.value(QString::fromUtf8("Получатель")); detail.amount = parseDushanbeAmount(pairs.value(QString::fromUtf8("Сумма"))); if (detail.amount == 0.0) { detail.amount = parseDushanbeAmount(pairs.value(QString::fromUtf8("К зачислению"))); } detail.fee = parseDushanbeAmount(pairs.value(QString::fromUtf8("Комиссия"))); detail.operationId = pairs.value(QString::fromUtf8("Номер операции")); detail.status = pairs.value(QString::fromUtf8("Статус")); detailLoaded = true; } } if (detailLoaded) break; QThread::msleep(1500); } if (!detailLoaded) { qWarning() << "[Dushanbe::GetLastTransactions] Detail screen not loaded " "(tap missed or detail format unknown); staying on Выписка"; // Не вызываем goBack — после промаха тапа мы всё ещё на Выписке. // goBack тут закрывает Выписку и ломает дальнейший скролл. // Если же тап сработал и открылся НЕ-распознанный экран — следующая // итерация parse обнаружит это, и мы обработаем штатно. QThread::msleep(500); continue; } qDebug() << "[Dushanbe::GetLastTransactions] Detail parsed:" << "date=" << detail.date << "time=" << detail.time << "amount=" << detail.amount << "receiver=" << detail.receiverAccount; // Проверяем время ±10 минут 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()) { txTime = QDateTime::fromString(detail.date + " " + detail.time, "dd.MM.yyyy HH:mm"); } if (txTime.isValid()) { txTime.setTimeZone(tjTz); 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); QThread::msleep(1000); continue; } } } // Нашли! Сохраняем qDebug() << "[Dushanbe::GetLastTransactions] FOUND:" << "amount=" << detail.amount << "date=" << detail.date << detail.time << "opId=" << detail.operationId << "receiver=" << detail.receiverAccount << "status=" << detail.status; // Формируем task_result напрямую из распарсенных данных и отдаём // EventHandler через сигнал — без записи/чтения БД. { const double finalAmount = matchedX100 ? detail.amount * 100.0 : detail.amount; const double finalFee = matchedX100 ? detail.fee * 100.0 : detail.fee; QDateTime txTime = QDateTime::fromString( detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss"); if (!txTime.isValid()) { txTime = QDateTime::fromString(detail.date + " " + detail.time, "dd.MM.yyyy HH:mm"); } if (txTime.isValid()) txTime.setTimeZone(tjTz); QJsonObject txJson; txJson["bank_transaction_id"] = detail.operationId.isEmpty() ? QString("dushanbe_") + detail.date + "_" + detail.time : detail.operationId; // Списание хранится с отрицательным знаком, пополнение — положительным. // detail.amount всегда положителен (OCR), знак берём из списка Выписки. const double signedAmount = (tx.amount < 0 ? -1.0 : 1.0) * qAbs(finalAmount); txJson["amount"] = static_cast(signedAmount * 100); txJson["currency"] = 972; // TJS txJson["transaction_type"] = "bank"; txJson["bank_participator_id"] = detail.receiverAccount; txJson["status"] = detail.status.contains(QString::fromUtf8("Успешн")) ? QStringLiteral("completed") : QStringLiteral("progress"); QJsonObject extraObj; extraObj["fee"] = static_cast(finalFee * 100); txJson["extra"] = extraObj; txJson["created_at"] = txTime.isValid() ? txTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null); qDebug() << "[Dushanbe::GetLastTransactions] Emitting parsed tx:" << QJsonDocument(txJson).toJson(QJsonDocument::Compact); emit transactionParsed(txJson); } // Скриншот экрана деталей транзакции — отправляем в мониторинг m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId); if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot); // Назад к истории Node nazadBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Назад")); if (nazadBtn.x() > 0 && nazadBtn.y() > 0) { AdbUtils::makeTap(m_deviceId, nazadBtn.x(), nazadBtn.y()); } else { AdbUtils::goBack(m_deviceId); } QThread::msleep(1000); return SearchResult::Found; } // Окно поиска — "сегодня + (searchWindowDays-1) предыдущих дней". Список // Выписки идёт сверху-вниз от новых к старым; группы-даты на текущем экране // собраны в groupDates. Как только самая свежая видимая дата уже старше // нижней границы окна — значит окно полностью проскроллено, дальше только // старая история. Прекращаем скролл (целевой транзакции в окне нет), вместо // того чтобы листать до упора и упереться в watchdog-таймаут. { const QDate cutoff = todayLocal.addDays(-(searchWindowDays - 1)); // нижняя граница окна QDate newestVisible; for (const QString &gd : groupDates) { const QDate d = QDate::fromString(gd, "dd.MM.yyyy"); if (d.isValid() && (newestVisible.isNull() || d > newestVisible)) newestVisible = d; } if (newestVisible.isValid() && newestVisible < cutoff) { qDebug() << "[Dushanbe::GetLastTransactions] Past today+yesterday window" " (newest visible=" << newestVisible.toString("dd.MM.yyyy") << "< cutoff=" << cutoff.toString("dd.MM.yyyy") << ") — stopping scroll"; return SearchResult::PastWindow; } } // Детект отсутствия прогресса прокрутки: сравниваем сигнатуру текущего // видимого набора с предыдущей. Совпала — свайп ничего не сдвинул. { QStringList sigParts; for (const VypiskaItem &it : items) { sigParts << QString::number(it.amount, 'f', 2) + "|" + it.date + "|" + it.time; } const QString viewSig = sigParts.join(';') + "##" + groupDates.join(','); if (!prevViewSig.isEmpty() && viewSig == prevViewSig) { if (++stallScrolls >= 2) { qWarning() << "[Dushanbe::GetLastTransactions] No scroll progress for" << stallScrolls << "swipes (list stuck) — aborting search for amount=" << QString::number(searchAmount, 'f', 2); break; } } else { stallScrolls = 0; } prevViewSig = viewSig; } // Скроллим вниз — свайп внутри области транзакций if (scroll < maxScrolls) { if (interrupted()) { m_error = "Interrupted"; return SearchResult::NotFound; } const int x = device.screenWidth / 2; // Определяем область контента из реальных Y позиций items int contentBottom = device.screenHeight * 2 / 3; int contentTop = device.screenHeight / 3; if (!items.isEmpty()) { contentBottom = items.last().node.y(); contentTop = items.first().node.y(); } const int fromY = qMin(contentBottom, device.screenHeight * 2 / 3); const int toY = qMax(contentTop, device.screenHeight / 4); qDebug() << "[Dushanbe::GetLastTransactions] Swipe from" << fromY << "to" << toY; AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500); QThread::msleep(2000); } } // Эту цель не нашли. 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); return SearchResult::NotFound; } } // namespace Dushanbe