1010 lines
50 KiB
C++
1010 lines
50 KiB
C++
#include "GetLastTransactionsScript.h"
|
||
|
||
#include <QJsonDocument>
|
||
#include <QJsonObject>
|
||
#include <QProcess>
|
||
#include <QRegularExpression>
|
||
#include <QThread>
|
||
#include <QTimeZone>
|
||
|
||
#include <tesseract/baseapi.h>
|
||
#include <leptonica/allheaders.h>
|
||
|
||
#include "adb/AdbUtils.h"
|
||
#include "dump/TaskDumpRecorder.h"
|
||
#include "dao/MaterialDAO.h"
|
||
#include "dao/BankProfileDAO.h"
|
||
#include "dao/DeviceDAO.h"
|
||
#include "dao/TransactionDAO.h"
|
||
#include "time/DateUtils.h"
|
||
#include "AppLogger.h"
|
||
|
||
#include <QCoreApplication>
|
||
static QString recognizeTextFromImage(const QByteArray &pngData) {
|
||
auto *api = new tesseract::TessBaseAPI();
|
||
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
|
||
qWarning() << "[OCR] Failed to init Tesseract";
|
||
delete api;
|
||
return {};
|
||
}
|
||
|
||
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
|
||
static_cast<size_t>(pngData.size()));
|
||
if (!pix) {
|
||
qWarning() << "[OCR] Failed to read image";
|
||
api->End();
|
||
delete api;
|
||
return {};
|
||
}
|
||
|
||
Pix *gray = pixConvertRGBToGray(pix, 0.0f, 0.0f, 0.0f);
|
||
pixDestroy(&pix);
|
||
if (!gray) {
|
||
qWarning() << "[OCR] Failed to convert to grayscale";
|
||
api->End();
|
||
delete api;
|
||
return {};
|
||
}
|
||
|
||
api->SetImage(gray);
|
||
api->SetSourceResolution(300);
|
||
char *text = api->GetUTF8Text();
|
||
QString result = QString::fromUtf8(text).trimmed();
|
||
delete[] text;
|
||
pixDestroy(&gray);
|
||
api->End();
|
||
delete api;
|
||
|
||
return result;
|
||
}
|
||
|
||
static QString extractTransactionId(const QString &ocrText) {
|
||
// ID может быть на одной строке: "ID операции B606513042211200000120011700501"
|
||
// или с дефисами: "ID операции po-3140eb01-002c-5000-8000-033396b637e1"
|
||
// или разбит на 2 строки: "ID ... po-3140eb01-002c-5000-8000-033396b\n ... 637e1"
|
||
QRegularExpression reId(R"(ID\s+операции\s*=?\s*([A-Za-z0-9\-]+(?:[^\S\n]+[A-Za-z0-9\-]+)?))");
|
||
if (auto m = reId.match(ocrText); m.hasMatch()) {
|
||
QString id = m.captured(1);
|
||
id.remove(' ');
|
||
id.remove(QChar(0x00A0));
|
||
return id;
|
||
}
|
||
|
||
QRegularExpression reRef(R"(№\s*(Ф-[\d-]+))");
|
||
if (auto m = reRef.match(ocrText); m.hasMatch()) return m.captured(1);
|
||
|
||
return {};
|
||
}
|
||
|
||
namespace Ozon {
|
||
|
||
GetLastTransactionsScript::GetLastTransactionsScript(
|
||
QString deviceId,
|
||
QString appCode,
|
||
int maxCount,
|
||
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_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);
|
||
|
||
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("ozon", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error);
|
||
|
||
if (!navigateToTransactionList(device, app)) {
|
||
emit finishedWithResult(m_error);
|
||
return;
|
||
}
|
||
|
||
// Находим accountId
|
||
int accountId = -1;
|
||
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
|
||
if (!accounts.isEmpty()) {
|
||
accountId = accounts.first().id;
|
||
} else {
|
||
qWarning() << "[GetLastTransactions] No account found for" << device.id << m_appCode;
|
||
}
|
||
|
||
// Выбор режима: поиск списка конкретных транзакций или сканирование последних.
|
||
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);
|
||
}
|
||
|
||
emit finishedWithResult(m_error);
|
||
}
|
||
|
||
bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &device, const BankProfileInfo &app) {
|
||
// Хелпер: если на экране ошибка загрузки ("Обновить"), тапаем один раз
|
||
auto tryRefreshIfError = [&]() {
|
||
// Кнопка "Обновить" — Button с content-desc="Обновить" (text пустой)
|
||
Node refreshBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Обновить"));
|
||
if (refreshBtn.x() > 0 && refreshBtn.y() > 0) {
|
||
qDebug() << "[GetLastTransactions] Page load error, tapping 'Обновить' at"
|
||
<< refreshBtn.x() << refreshBtn.y();
|
||
AdbUtils::makeTap(m_deviceId, refreshBtn.x(), refreshBtn.y());
|
||
QThread::msleep(5000);
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
}
|
||
};
|
||
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
tryRefreshIfError();
|
||
|
||
// Закрываем рекламный bottom sheet
|
||
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
|
||
AdbUtils::makeTap(m_deviceId, adNode.x(), adNode.y());
|
||
QThread::msleep(1000);
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
if (Node stillAd = xmlScreenParser.tryToFindAdBanner(); !stillAd.isEmpty()) {
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1000);
|
||
}
|
||
}
|
||
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
|
||
// Если уже на экране операций — скроллим наверх и готово
|
||
if (xmlScreenParser.isTransactionListScreen()) {
|
||
qDebug() << "[GetLastTransactions] Already on transaction list screen, scrolling to top";
|
||
// Несколько быстрых свайпов вверх чтобы вернуться к началу списка
|
||
for (int i = 0; i < 5; ++i) {
|
||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||
device.screenHeight / 3,
|
||
device.screenWidth / 2,
|
||
device.screenHeight * 2 / 3, 200);
|
||
QThread::msleep(500);
|
||
}
|
||
QThread::msleep(1000);
|
||
return true;
|
||
}
|
||
|
||
// Если не на домашнем экране — перезапускаем
|
||
if (!xmlScreenParser.isHomeScreen()) {
|
||
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;
|
||
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
|
||
AppLogger::log("ozon/GetLastTransactions", m_deviceId, m_error, {}, "FETCH_TRANSACTIONS");
|
||
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||
return false;
|
||
}
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
tryRefreshIfError();
|
||
}
|
||
|
||
// Закрываем баннеры на главной
|
||
tryToCloseAllBannersOnHomePage(m_deviceId, device.screenWidth, device.screenHeight);
|
||
QThread::msleep(500);
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
|
||
// Скроллим до "Все" и тапаем (быстрый fling чтобы пропустить баннеры)
|
||
const int navBarTop = device.screenHeight - (device.screenHeight / 10);
|
||
Node allBtn;
|
||
for (int i = 0; i < 10; ++i) {
|
||
if (QThread::currentThread()->isInterruptionRequested()) return false;
|
||
const int x = device.screenWidth / 2;
|
||
AdbUtils::makeSwipe(m_deviceId, x, device.screenHeight - 200, x, 200, 100);
|
||
QThread::msleep(1500);
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||
|
||
allBtn = xmlScreenParser.findButtonNode("Все", false);
|
||
if (!allBtn.isEmpty() && allBtn.y() > 0 && allBtn.y() < navBarTop) break;
|
||
allBtn = {};
|
||
}
|
||
|
||
if (allBtn.isEmpty()) {
|
||
m_error = "Button 'Все' not found: " + m_deviceId;
|
||
qWarning() << m_error;
|
||
return false;
|
||
}
|
||
|
||
AdbUtils::makeTap(m_deviceId, allBtn.x(), allBtn.y());
|
||
QThread::msleep(2000);
|
||
|
||
// Ждём экран "Операции"
|
||
for (int attempt = 0; attempt < 10; ++attempt) {
|
||
if (QThread::currentThread()->isInterruptionRequested()) return false;
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||
if (xmlScreenParser.isTransactionListScreen()) return true;
|
||
QThread::msleep(1500);
|
||
}
|
||
|
||
m_error = "Transaction list screen not loaded: " + m_deviceId;
|
||
qWarning() << m_error;
|
||
return false;
|
||
}
|
||
|
||
bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountId,
|
||
double searchAmount, const QDateTime &searchTime) {
|
||
// Скриншот списка транзакций — отправим при неудаче, удалим при успехе
|
||
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||
|
||
// Время в Ozon показывается в таймзоне устройства, а не в MSK
|
||
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId);
|
||
QTimeZone deviceTz(tzName.toUtf8());
|
||
if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow");
|
||
const int maxScrolls = 30;
|
||
// Реальный y верхней границы нижнего нав-холдера. Если в XML есть
|
||
// ru.ozon.fintech.finance:id/main_activity_bottom_navigation_holder,
|
||
// используем его y1; иначе грубая оценка ~10% снизу.
|
||
auto detectNavBarTop = [&]() -> int {
|
||
for (const Node &n : xmlScreenParser.getNodes()) {
|
||
if (n.resourceId == "ru.ozon.fintech.finance:id/main_activity_bottom_navigation_holder"
|
||
&& n.height() > 0) {
|
||
return n.y1();
|
||
}
|
||
}
|
||
return device.screenHeight - (device.screenHeight / 10);
|
||
};
|
||
int navBarTop = detectNavBarTop();
|
||
|
||
// Целевая дата в таймзоне устройства для поиска заголовка дня в списке
|
||
const QDateTime searchLocal = searchTime.toTimeZone(deviceTz);
|
||
const QDate searchDate = searchLocal.date();
|
||
|
||
qDebug() << "[FetchTransactions] Searching: device=" << m_deviceId
|
||
<< "amount=" << searchAmount
|
||
<< "time=" << searchTime.toString(Qt::ISODate)
|
||
<< "tz=" << tzName
|
||
<< "date(local)=" << searchDate.toString("dd.MM.yyyy")
|
||
<< "screen=" << device.screenWidth << "x" << device.screenHeight;
|
||
|
||
// ── Шаг 1: скроллим до нужной даты ──────────────────────────────
|
||
static const QMap<QString, int> monthMap = {
|
||
{"января", 1}, {"февраля", 2}, {"марта", 3}, {"апреля", 4},
|
||
{"мая", 5}, {"июня", 6}, {"июля", 7}, {"августа", 8},
|
||
{"сентября", 9}, {"октября", 10}, {"ноября", 11}, {"декабря", 12}
|
||
};
|
||
static const QRegularExpression dateRx(R"((\d{1,2})\s+(\S+),\s+\S+)");
|
||
|
||
auto parseDateHeader = [&](const QString &t) -> QDate {
|
||
const QDate today = QDateTime::currentDateTimeUtc().toTimeZone(deviceTz).date();
|
||
if (t.compare("Сегодня", Qt::CaseInsensitive) == 0) return today;
|
||
if (t.compare("Вчера", Qt::CaseInsensitive) == 0) return today.addDays(-1);
|
||
const auto match = dateRx.match(t);
|
||
if (!match.hasMatch()) return {};
|
||
const int day = match.captured(1).toInt();
|
||
const int month = monthMap.value(match.captured(2), 0);
|
||
if (month == 0) return {};
|
||
return QDate(searchDate.year(), month, day);
|
||
};
|
||
|
||
// Ozon WebView сворачивает заголовки дат вне вьюпорта в один y (0 или общий
|
||
// верхний y). Несколько разных дат на одной y-позиции — pile-up скрытых
|
||
// заголовков, их надо считать невидимыми.
|
||
auto computeDateYCount = [&]() -> QMap<int, int> {
|
||
QMap<int, int> cnt;
|
||
for (const Node &node : xmlScreenParser.getNodes()) {
|
||
if (node.className != "android.widget.TextView") continue;
|
||
if (node.y() <= 0) continue;
|
||
if (parseDateHeader(node.text.trimmed()).isValid()) cnt[node.y()]++;
|
||
}
|
||
return cnt;
|
||
};
|
||
|
||
auto hasDateOnScreen = [&]() -> bool {
|
||
const QMap<int, int> yCount = computeDateYCount();
|
||
for (const Node &node : xmlScreenParser.getNodes()) {
|
||
if (node.className != "android.widget.TextView") continue;
|
||
if (node.y() <= 0) continue;
|
||
if (yCount.value(node.y(), 0) != 1) continue;
|
||
if (parseDateHeader(node.text.trimmed()) == searchDate) return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
auto hasOlderDateOnScreen = [&]() -> bool {
|
||
const QMap<int, int> yCount = computeDateYCount();
|
||
for (const Node &node : xmlScreenParser.getNodes()) {
|
||
if (node.className != "android.widget.TextView") continue;
|
||
if (node.y() <= 0) continue;
|
||
if (yCount.value(node.y(), 0) != 1) continue;
|
||
const QDate d = parseDateHeader(node.text.trimmed());
|
||
if (d.isValid() && d < searchDate) return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// Ждём пока WebView раскроет accessibility tree (иначе dump пустой)
|
||
for (int wait = 0; wait < 5; ++wait) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||
if (xmlScreenParser.findTransactionButtons(1).size() > 0) break;
|
||
if (hasDateOnScreen()) break;
|
||
qDebug() << "[FetchTransactions] Waiting for WebView content..." << (wait + 1);
|
||
QThread::msleep(2000);
|
||
}
|
||
|
||
// Скроллим к нужной дате, запоминаем количество скроллов
|
||
int scrollsToDate = 0;
|
||
bool dateFound = false;
|
||
for (int scroll = 0; scroll < maxScrolls; ++scroll) {
|
||
if (QThread::currentThread()->isInterruptionRequested()) {
|
||
m_error = "Interrupted";
|
||
return false;
|
||
}
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||
if (hasDateOnScreen()) {
|
||
dateFound = true;
|
||
scrollsToDate = scroll;
|
||
qDebug() << "[FetchTransactions] Found target date on screen after" << scroll << "scrolls";
|
||
break;
|
||
}
|
||
if (hasOlderDateOnScreen()) {
|
||
qDebug() << "[FetchTransactions] Passed target date, not found in list";
|
||
break;
|
||
}
|
||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||
device.screenHeight * 2 / 3, device.screenWidth / 2,
|
||
device.screenHeight / 3, 600);
|
||
QThread::msleep(1500);
|
||
}
|
||
|
||
if (!dateFound) {
|
||
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 false;
|
||
}
|
||
|
||
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
|
||
// Кнопки транзакций между этими Y принадлежат нужному дню.
|
||
// Возвращает специальные коды через dateY:
|
||
// dateY >= 0 : header виден на этом Y (нормальный случай)
|
||
// dateY == 0 : header выше viewport, есть более старая видимая дата
|
||
// dateY == -1 : header не виден / лежит в нижней sticky-пачке
|
||
// (скролл вниз, чтобы target выехал в видимую часть)
|
||
// dateY == -2 : header в верхней sticky-пачке (несколько дат свёрнуто
|
||
// над viewport) — скролл вверх, чтобы пачка распалась
|
||
auto findDateYRange = [&]() -> QPair<int, int> {
|
||
const QMap<int, int> yCount = computeDateYCount();
|
||
auto isAnchored = [&](int y) { return y > 0 && yCount.value(y, 0) == 1; };
|
||
auto isPiled = [&](int y) { return y > 0 && yCount.value(y, 0) > 1; };
|
||
const int viewportMid = device.screenHeight / 2;
|
||
|
||
int dateY = -1;
|
||
int nextDateY = device.screenHeight;
|
||
int targetPileY = -1;
|
||
bool foundOlderDate = false;
|
||
for (const Node &node : xmlScreenParser.getNodes()) {
|
||
if (node.className != "android.widget.TextView") continue;
|
||
const QDate d = parseDateHeader(node.text.trimmed());
|
||
if (!d.isValid()) continue;
|
||
if (d == searchDate) {
|
||
if (isAnchored(node.y())) dateY = node.y();
|
||
else if (isPiled(node.y())) targetPileY = node.y();
|
||
} else if (d < searchDate) {
|
||
foundOlderDate = true;
|
||
if (isAnchored(node.y())
|
||
&& (dateY < 0 || node.y() > dateY)
|
||
&& node.y() < nextDateY) {
|
||
nextDateY = node.y();
|
||
}
|
||
}
|
||
}
|
||
// Target в sticky-пачке: пачка над viewport (малый Y) → секция уже
|
||
// прокручена выше, надо вернуться (dateY = -2). Пачка снизу viewport
|
||
// (большой Y) → target ещё не доехал до экрана, скролл вниз (dateY = -1).
|
||
if (dateY < 0 && targetPileY >= 0) {
|
||
dateY = (targetPileY < viewportMid) ? -2 : -1;
|
||
}
|
||
// Target-header не виден, но видна более старая дата → target ушёл вверх:
|
||
// всё выше nextDateY принадлежит нашему дню.
|
||
if (dateY < 0 && nextDateY < device.screenHeight && targetPileY < 0) {
|
||
dateY = 0;
|
||
}
|
||
qDebug() << "[FetchTransactions] findDateYRange: dateY=" << dateY
|
||
<< "nextDateY=" << nextDateY << "foundOlderDate=" << foundOlderDate
|
||
<< "targetPileY=" << targetPileY;
|
||
return {dateY, nextDateY};
|
||
};
|
||
|
||
// ── Шаг 2: тапаем только по кнопкам под заголовком нужной даты ───
|
||
QSet<QString> checkedDetails;
|
||
int noProgressCount = 0; // Счётчик итераций без прогресса (новых unchecked кнопок)
|
||
|
||
for (int attempt = 0; attempt < 15; ++attempt) {
|
||
if (QThread::currentThread()->isInterruptionRequested()) {
|
||
m_error = "Interrupted";
|
||
return false;
|
||
}
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||
|
||
// Логируем все заголовки дат на экране
|
||
{
|
||
QStringList datesOnScreen;
|
||
for (const Node &node : xmlScreenParser.getNodes()) {
|
||
if (node.className != "android.widget.TextView") continue;
|
||
const QDate d = parseDateHeader(node.text.trimmed());
|
||
if (d.isValid()) {
|
||
datesOnScreen << QString("%1@y=%2").arg(d.toString("dd.MM")).arg(node.y());
|
||
}
|
||
}
|
||
qDebug() << "[FetchTransactions] attempt=" << attempt << "dates on screen:" << datesOnScreen;
|
||
}
|
||
|
||
// Находим Y-диапазон заголовка нужной даты
|
||
const auto [dateHeaderY, nextDateHeaderY] = findDateYRange();
|
||
if (dateHeaderY == -2) {
|
||
// Target застрял в sticky-пачке (Ozon стекает заголовки выше viewport
|
||
// в одну Y-позицию). Ниже пачки есть anchored более старая дата →
|
||
// секцию target уже проскроллили мимо. Скроллим обратно вверх,
|
||
// чтобы target распался из пачки и занял реальный Y.
|
||
qDebug() << "[FetchTransactions] Target piled in sticky band — scrolling up to detach";
|
||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||
device.screenHeight / 3,
|
||
device.screenWidth / 2,
|
||
device.screenHeight * 2 / 3, 600);
|
||
QThread::msleep(1500);
|
||
continue;
|
||
}
|
||
if (dateHeaderY < 0) {
|
||
// Target-header не виден и ни одной более старой даты не видно →
|
||
// target скорее всего ниже вьюпорта. Скроллим вниз (swipe вверх).
|
||
qDebug() << "[FetchTransactions] Target not visible, no older date — scrolling down";
|
||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||
device.screenHeight * 2 / 3,
|
||
device.screenWidth / 2,
|
||
device.screenHeight / 3, 500);
|
||
QThread::msleep(1500);
|
||
continue;
|
||
}
|
||
if (dateHeaderY == 0 && nextDateHeaderY < device.screenHeight) {
|
||
// Target-header ушёл вверх, видна только более старая дата.
|
||
// Кнопки выше nextDateHeaderY могут принадлежать промежуточным
|
||
// (пропущенным) датам — матчить их опасно. Скроллим обратно вверх.
|
||
qDebug() << "[FetchTransactions] Target scrolled above — scrolling up to recover";
|
||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||
device.screenHeight / 3,
|
||
device.screenWidth / 2,
|
||
device.screenHeight * 2 / 3, 600);
|
||
QThread::msleep(1500);
|
||
continue;
|
||
}
|
||
|
||
QList<Node> txButtons = xmlScreenParser.findTransactionButtons(20);
|
||
qDebug() << "[FetchTransactions] Found" << txButtons.size() << "buttons, dateY range:"
|
||
<< dateHeaderY << "-" << nextDateHeaderY;
|
||
|
||
bool tapped = false;
|
||
bool hasNewButton = false; // Есть ли новая (ещё не проверенная) кнопка с нужной суммой
|
||
int buttonsInRange = 0; // Кнопки внутри Y-диапазона нужной даты
|
||
for (int bi = 0; bi < txButtons.size(); ++bi) {
|
||
if (QThread::currentThread()->isInterruptionRequested()) {
|
||
m_error = "Interrupted";
|
||
return false;
|
||
}
|
||
|
||
// Пропускаем кнопки вне Y-диапазона нужной даты.
|
||
// Используем y1 (верх кнопки), а не центр — длинные кнопки могут
|
||
// заходить под нав-бар, но всё равно tappable (тапнем по верхней части).
|
||
const int btnY1 = txButtons[bi].y1();
|
||
const int btnY = txButtons[bi].y();
|
||
if (btnY1 <= dateHeaderY || btnY1 >= nextDateHeaderY) continue;
|
||
if (btnY1 <= 0 || btnY1 >= navBarTop) continue;
|
||
++buttonsInRange;
|
||
|
||
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text);
|
||
qDebug() << "[FetchTransactions] Button" << bi << "y=" << btnY
|
||
<< "parsed amount=" << parsed.amount << "search=" << searchAmount
|
||
<< "text=" << txButtons[bi].text.left(60);
|
||
if (qAbs(parsed.amount - searchAmount) > 0.01) continue;
|
||
|
||
const Node &visibleBtn = txButtons[bi];
|
||
// Зажимаем тап-Y на видимую часть кнопки: если кнопка заходит под
|
||
// нав-бар, центр окажется на нав-баре и тап улетит мимо.
|
||
const int safeMaxY = navBarTop - 20;
|
||
int tapY = visibleBtn.y();
|
||
if (tapY > safeMaxY) {
|
||
tapY = qMax(visibleBtn.y1() + 30, safeMaxY);
|
||
}
|
||
qDebug() << "[FetchTransactions] Amount match:" << parsed.amount
|
||
<< "at" << visibleBtn.x() << tapY
|
||
<< "(btn y1=" << visibleBtn.y1() << "y2=" << visibleBtn.y2()
|
||
<< "navBarTop=" << navBarTop
|
||
<< "dateY:" << dateHeaderY << "-" << nextDateHeaderY << ")";
|
||
|
||
// Тапаем и ждём деталей
|
||
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), tapY);
|
||
QThread::msleep(2000);
|
||
|
||
TransactionInfo detail;
|
||
for (int da = 0; da < 10; ++da) {
|
||
if (QThread::currentThread()->isInterruptionRequested()) {
|
||
m_error = "Interrupted";
|
||
return false;
|
||
}
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||
detail = xmlScreenParser.parseTransactionDetail();
|
||
if (!detail.bankTime.isEmpty()) break;
|
||
if (xmlScreenParser.isTransactionListScreen()) {
|
||
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
|
||
}
|
||
QThread::msleep(1500);
|
||
}
|
||
|
||
if (detail.bankTime.isEmpty()) {
|
||
qWarning() << "[FetchTransactions] Could not load detail, going back";
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1000);
|
||
continue;
|
||
}
|
||
|
||
// Парсим дату из деталей
|
||
QString dtStr = detail.bankTime;
|
||
for (int ci = 0; ci < dtStr.size(); ++ci) {
|
||
const QChar ch = dtStr.at(ci);
|
||
if (ch != ' ' && ch.category() == QChar::Separator_Space) dtStr[ci] = ' ';
|
||
}
|
||
QDateTime txTime = QDateTime::fromString(dtStr, "dd.MM.yyyy, HH:mm");
|
||
if (!txTime.isValid()) txTime = QDateTime::fromString(dtStr, "dd.MM.yyyy,HH:mm");
|
||
if (txTime.isValid()) txTime.setTimeZone(deviceTz);
|
||
|
||
// Пропускаем если уже проверяли эту транзакцию
|
||
if (checkedDetails.contains(dtStr)) {
|
||
qDebug() << "[FetchTransactions] Already checked:" << dtStr << "- skipping";
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(500);
|
||
continue;
|
||
}
|
||
checkedDetails.insert(dtStr);
|
||
tapped = true;
|
||
hasNewButton = true;
|
||
|
||
// Проверяем что транзакция принадлежит нужной дате (защита от широкого Y-диапазона)
|
||
if (txTime.isValid()) {
|
||
const QDate txLocalDate = txTime.toTimeZone(deviceTz).date();
|
||
if (txLocalDate != searchDate) {
|
||
qDebug() << "[FetchTransactions] Wrong date in detail:" << dtStr
|
||
<< "expected date" << searchDate.toString("dd.MM.yyyy")
|
||
<< "- button belongs to different date, skipping";
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1000);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Проверяем время: txTime не раньше цели и не позже неё на 10 минут (+10м, одностороннее окно)
|
||
if (txTime.isValid() && searchTime.isValid()) {
|
||
const qint64 diffSecs = searchTime.toUTC().secsTo(txTime.toUTC()); // >0 = txTime позже цели
|
||
if (diffSecs < 0 || diffSecs > 600) {
|
||
qDebug() << "[FetchTransactions] Time mismatch:" << dtStr
|
||
<< "expected" << searchLocal.toString("dd.MM.yyyy, HH:mm")
|
||
<< "diff=" << diffSecs << "sec";
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1000);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Нашли!
|
||
qDebug() << "[FetchTransactions] FOUND transaction: device=" << m_deviceId
|
||
<< "amount=" << parsed.amount << "time=" << dtStr
|
||
<< "name=" << detail.name;
|
||
|
||
// Скриншот экрана деталей транзакции — отдаём в EventHandler.
|
||
// Раньше дополнительно тапали "Документы" + OCR ради ID операции,
|
||
// но это медленно и нестабильно. Теперь полагаемся на дату+время+
|
||
// сумму как идентификатор.
|
||
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||
|
||
QString externalId = detail.bankTrExternalId;
|
||
|
||
// Фолбэк-id (когда реальный ID банка не распарсился) строим из
|
||
// created_at задачи — он детерминирован и стабилен между запусками,
|
||
// что важно для дедупликации на сервере. Раньше брали распарсенное
|
||
// с экрана время (dtStr), которое могло «плавать» из-за OCR.
|
||
const QString finalExternalId = externalId.isEmpty()
|
||
? QStringLiteral("ozon_") + (searchTime.isValid()
|
||
? searchTime.toUTC().toString(Qt::ISODate)
|
||
: QString(dtStr).remove(',').trimmed().replace(' ', '_'))
|
||
: externalId;
|
||
|
||
// Сохраняем
|
||
if (accountId != -1) {
|
||
TransactionInfo tx;
|
||
tx.accountId = accountId;
|
||
tx.amount = parsed.amount;
|
||
tx.bankName = m_appCode;
|
||
tx.bankTime = dtStr;
|
||
tx.bankTrExternalId = finalExternalId;
|
||
tx.status = TransactionStatus::Complete;
|
||
tx.timestamp = DateUtils::getUtcNow();
|
||
if (txTime.isValid()) tx.completeTime = txTime;
|
||
if (!detail.name.isEmpty()) tx.name = detail.name;
|
||
if (!detail.description.isEmpty()) tx.description = detail.description;
|
||
if (!detail.phone.isEmpty()) {
|
||
QString cleanPhone;
|
||
for (const QChar &ch : detail.phone)
|
||
if (ch.isDigit()) cleanPhone.append(ch);
|
||
tx.phone = cleanPhone;
|
||
tx.type = TransactionType::Phone;
|
||
}
|
||
TransactionDAO::insertTransactionIfNotExists(tx);
|
||
}
|
||
|
||
// Отдаём результат в EventHandler через сигнал (как Dushanbe)
|
||
{
|
||
QJsonObject txJson;
|
||
txJson["bank_transaction_id"] = finalExternalId;
|
||
txJson["amount"] = static_cast<int>(parsed.amount * 100);
|
||
txJson["currency"] = 643; // RUB
|
||
txJson["transaction_type"] = "bank";
|
||
QString participator = detail.phone;
|
||
if (participator.isEmpty()) participator = detail.name;
|
||
txJson["bank_participator_id"] = participator;
|
||
txJson["status"] = "completed";
|
||
QJsonObject extraObj;
|
||
extraObj["fee"] = 0;
|
||
txJson["extra"] = extraObj;
|
||
// created_at — время из задачи (поисковый target), real_created_at —
|
||
// фактическое время транзакции, спарсенное с экрана приложения.
|
||
txJson["created_at"] = searchTime.isValid()
|
||
? searchTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null);
|
||
txJson["real_created_at"] = txTime.isValid()
|
||
? txTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null);
|
||
|
||
qDebug() << "[FetchTransactions] Emitting parsed tx:"
|
||
<< QJsonDocument(txJson).toJson(QJsonDocument::Compact);
|
||
emit transactionParsed(txJson);
|
||
}
|
||
|
||
if (!m_resultScreenshot.isEmpty())
|
||
emit screenshotReady(m_resultScreenshot);
|
||
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1000);
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1000);
|
||
return true; // успех
|
||
}
|
||
|
||
// Отслеживаем прогресс: "без прогресса" только если были кнопки с СОВПАДАЮЩЕЙ
|
||
// суммой (tapped), но все уже checked. Если суммы не совпали — скроллим дальше,
|
||
// нужная кнопка может быть ниже.
|
||
if (!hasNewButton && tapped) {
|
||
++noProgressCount;
|
||
if (noProgressCount >= 5) {
|
||
qDebug() << "[FetchTransactions] No progress after" << noProgressCount
|
||
<< "attempts with matching amount — giving up";
|
||
break;
|
||
}
|
||
} else if (hasNewButton) {
|
||
noProgressCount = 0;
|
||
}
|
||
|
||
// Скроллим если не нашли нужную транзакцию
|
||
if (!tapped || !hasNewButton) {
|
||
// Признак того, что под фолдом ещё есть не раскрытые транзакции
|
||
// (Ozon хранит off-screen-row как android.view.View с height=0).
|
||
const bool moreBelow = xmlScreenParser.hasCollapsedTransactionsBelow(device.screenHeight);
|
||
qDebug() << "[FetchTransactions] Scrolling (tapped=" << tapped
|
||
<< "hasNew=" << hasNewButton << "inRange=" << buttonsInRange
|
||
<< "noProgress=" << noProgressCount
|
||
<< "dateHeaderY=" << dateHeaderY
|
||
<< "nextDateHeaderY=" << nextDateHeaderY
|
||
<< "moreBelow=" << moreBelow << ")";
|
||
|
||
const int cx = device.screenWidth / 2;
|
||
if (dateHeaderY == 0 && nextDateHeaderY < device.screenHeight) {
|
||
// Target-header ушёл вверх — свайпим вниз чтоб вернуть его.
|
||
AdbUtils::makeSwipe(m_deviceId, cx,
|
||
device.screenHeight * 2 / 5, cx,
|
||
device.screenHeight * 3 / 4, 600);
|
||
QThread::msleep(1500);
|
||
} else {
|
||
// Скролл вниз. Ozon WebView ведёт себя странно: ОДИН медленный
|
||
// свайп иногда игнорируется (видимо, расценивается как hold).
|
||
// Стратегия: серия из 3 коротких быстрых свайпов внутри
|
||
// безопасной зоны (выше нав-бара). Это надёжно триггерит
|
||
// нативный fling в WebView.
|
||
// safeBottom — верхняя граница нав-бара минус запас.
|
||
// Когда target-секция уже у нижнего края (dateHeaderY >= safeBottom),
|
||
// зона "внутри секции" схлопывается — свайпаем фиксированным
|
||
// диапазоном относительно safeBottom, чтобы вытащить контент
|
||
// секции из-под нав-бара.
|
||
const int safeBottom = navBarTop - 30;
|
||
int amplitude;
|
||
if (dateHeaderY > 0 && dateHeaderY < safeBottom - 100) {
|
||
// Header виден достаточно высоко — скроллим внутри его секции.
|
||
const int safeTop = qMax(dateHeaderY + 80, device.screenHeight / 4);
|
||
amplitude = qMin(safeBottom - safeTop, 700);
|
||
} else {
|
||
// Header у самого низа или ниже нав-бара — большой свайп.
|
||
amplitude = qMin(safeBottom - device.screenHeight / 4, 700);
|
||
}
|
||
amplitude = qMax(amplitude, 300); // минимум 300px чтобы было ощутимо
|
||
qDebug() << "[FetchTransactions] Triple-swipe scroll:"
|
||
<< "safeBottom=" << safeBottom << "amplitude=" << amplitude
|
||
<< "(dateHeaderY=" << dateHeaderY << " moreBelow=" << moreBelow << ")";
|
||
for (int i = 0; i < 3; ++i) {
|
||
AdbUtils::makeSwipe(m_deviceId, cx, safeBottom,
|
||
cx, safeBottom - amplitude, 250);
|
||
QThread::msleep(350);
|
||
}
|
||
QThread::msleep(800);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Эту цель не нашли. 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) {
|
||
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId);
|
||
QTimeZone deviceTz(tzName.toUtf8());
|
||
if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow");
|
||
const int navBarTop = device.screenHeight - (device.screenHeight / 10);
|
||
|
||
// Ждём появления транзакций
|
||
QList<Node> txButtons;
|
||
for (int attempt = 0; attempt < 5; ++attempt) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||
txButtons = xmlScreenParser.findTransactionButtons(8);
|
||
if (!txButtons.isEmpty()) break;
|
||
QThread::msleep(1500);
|
||
}
|
||
|
||
qDebug() << "[GetLastTransactions] Found" << txButtons.size() << "transaction buttons";
|
||
|
||
QList<TransactionInfo> transactions;
|
||
for (int i = 0; i < txButtons.size(); ++i) {
|
||
const Node &btn = txButtons[i];
|
||
|
||
// Скроллим до кнопки если за экраном
|
||
if (btn.y() <= 0 || btn.y() >= navBarTop) {
|
||
for (int s = 0; s < 5; ++s) {
|
||
const int x = device.screenWidth / 2;
|
||
const int offset = device.screenHeight / 4;
|
||
AdbUtils::makeSwipe(m_deviceId, x, device.screenHeight - offset, x, offset);
|
||
QThread::msleep(1000);
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||
txButtons = xmlScreenParser.findTransactionButtons(8);
|
||
if (i < txButtons.size() && txButtons[i].y() > 0 && txButtons[i].y() < navBarTop) break;
|
||
}
|
||
if (i >= txButtons.size()) break;
|
||
}
|
||
|
||
const Node &visibleBtn = txButtons[i];
|
||
TransactionInfo tx = xmlScreenParser.parseTransactionButtonText(visibleBtn.text);
|
||
|
||
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
|
||
QThread::msleep(2000);
|
||
|
||
// Ждём загрузки деталей
|
||
TransactionInfo detail;
|
||
for (int attempt = 0; attempt < 10; ++attempt) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||
detail = xmlScreenParser.parseTransactionDetail();
|
||
if (!detail.bankTime.isEmpty()) break;
|
||
if (xmlScreenParser.isTransactionListScreen()) {
|
||
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
|
||
}
|
||
QThread::msleep(1500);
|
||
}
|
||
|
||
tx.bankName = m_appCode;
|
||
tx.bankTime = detail.bankTime;
|
||
tx.bankTrExternalId = detail.bankTrExternalId;
|
||
|
||
// Если ID не найден в XML — пробуем через "Документы" + OCR
|
||
if (tx.bankTrExternalId.isEmpty()) {
|
||
Node docBtn = xmlScreenParser.findButtonNode("Документы", false);
|
||
if (!docBtn.isEmpty()) {
|
||
AdbUtils::makeTap(m_deviceId, docBtn.x(), docBtn.y());
|
||
QThread::msleep(2000);
|
||
bool docLoaded = false;
|
||
for (int da = 0; da < 15; ++da) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||
if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) {
|
||
docLoaded = true;
|
||
break;
|
||
}
|
||
QThread::msleep(1500);
|
||
}
|
||
if (docLoaded) {
|
||
QThread::msleep(500);
|
||
const QByteArray screenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||
if (!screenshot.isEmpty()) {
|
||
const QString ocrText = recognizeTextFromImage(screenshot);
|
||
if (!ocrText.isEmpty()) {
|
||
tx.bankTrExternalId = extractTransactionId(ocrText);
|
||
qDebug() << "[GetLastTransactions] OCR extracted ID:" << tx.bankTrExternalId;
|
||
}
|
||
}
|
||
}
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1000);
|
||
}
|
||
}
|
||
|
||
tx.status = TransactionStatus::Complete;
|
||
tx.timestamp = DateUtils::getUtcNow();
|
||
if (!detail.name.isEmpty()) tx.name = detail.name;
|
||
if (!detail.phone.isEmpty()) {
|
||
QString cleanPhone;
|
||
for (const QChar &ch : detail.phone)
|
||
if (ch.isDigit()) cleanPhone.append(ch);
|
||
tx.phone = cleanPhone;
|
||
tx.type = TransactionType::Phone;
|
||
}
|
||
|
||
// Парсим время
|
||
if (!tx.bankTime.isEmpty()) {
|
||
QString dtStr = tx.bankTime;
|
||
for (int ci = 0; ci < dtStr.size(); ++ci) {
|
||
const QChar ch = dtStr.at(ci);
|
||
if (ch != ' ' && ch.category() == QChar::Separator_Space) dtStr[ci] = ' ';
|
||
}
|
||
tx.bankTime = dtStr;
|
||
QDateTime parsed = QDateTime::fromString(dtStr, "dd.MM.yyyy, HH:mm");
|
||
if (!parsed.isValid()) parsed = QDateTime::fromString(dtStr, "dd.MM.yyyy,HH:mm");
|
||
if (parsed.isValid()) {
|
||
parsed.setTimeZone(deviceTz);
|
||
tx.completeTime = parsed;
|
||
}
|
||
}
|
||
|
||
// Фильтр по времени (24ч) если нет maxCount
|
||
if (m_maxCount <= 0 && tx.completeTime.isValid()) {
|
||
const qint64 ageSecs = tx.completeTime.toUTC().secsTo(QDateTime::currentDateTimeUtc());
|
||
if (ageSecs > 24 * 3600) {
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1000);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Сохраняем
|
||
if (accountId != -1) {
|
||
tx.accountId = accountId;
|
||
if (TransactionDAO::insertTransactionIfNotExists(tx)) {
|
||
// Скриншот чека
|
||
Node docBtn = xmlScreenParser.findButtonNode("Документы", false);
|
||
if (!docBtn.isEmpty()) {
|
||
AdbUtils::makeTap(m_deviceId, docBtn.x(), docBtn.y());
|
||
QThread::msleep(2000);
|
||
|
||
bool docLoaded = false;
|
||
for (int da = 0; da < 20; ++da) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||
if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) {
|
||
docLoaded = true; break;
|
||
}
|
||
QThread::msleep(1500);
|
||
}
|
||
|
||
if (docLoaded) {
|
||
QThread::msleep(500);
|
||
QProcess screencapProc;
|
||
screencapProc.start("adb", QStringList() << "-s" << m_deviceId << "exec-out" << "screencap" << "-p");
|
||
screencapProc.waitForFinished(5000);
|
||
const QByteArray screenshot = screencapProc.readAllStandardOutput();
|
||
if (!screenshot.isEmpty()) {
|
||
TransactionInfo saved = TransactionDAO::findByBankTimeAndAmount(
|
||
accountId, tx.bankName, tx.bankTime, tx.amount);
|
||
if (saved.id != -1) {
|
||
TransactionDAO::updateReceiptImage(saved.id, screenshot);
|
||
const QString ocrText = recognizeTextFromImage(screenshot);
|
||
if (!ocrText.isEmpty()) {
|
||
const QString txId = extractTransactionId(ocrText);
|
||
if (!txId.isEmpty()) {
|
||
TransactionDAO::updateBankTrExternalId(saved.id, txId);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1000);
|
||
}
|
||
}
|
||
}
|
||
|
||
transactions.append(tx);
|
||
|
||
if (m_maxCount > 0 && transactions.size() >= m_maxCount) {
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1000);
|
||
break;
|
||
}
|
||
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1500);
|
||
|
||
for (int attempt = 0; attempt < 5; ++attempt) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||
if (xmlScreenParser.isTransactionListScreen()) break;
|
||
QThread::msleep(1000);
|
||
}
|
||
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
txButtons = xmlScreenParser.findTransactionButtons(8);
|
||
}
|
||
}
|
||
|
||
} // namespace Ozon
|