1111 lines
60 KiB
C++
1111 lines
60 KiB
C++
#include "GetLastTransactionsScript.h"
|
||
|
||
#include <QJsonDocument>
|
||
#include <QRegularExpression>
|
||
#include <QSet>
|
||
#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 "AppLogger.h"
|
||
#include "BankConfigUtils.h"
|
||
|
||
#include <QCoreApplication>
|
||
|
||
#include <algorithm>
|
||
|
||
// Душанбе показывает суммы как "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<const l_uint8 *>(pngData.constData()),
|
||
static_cast<size_t>(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<const l_uint8 *>(pngData.constData()),
|
||
static_cast<size_t>(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<QPair<double, QDateTime>> 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));
|
||
}
|
||
|
||
// Тапаем "История" в нижнем меню (с геометрическим fallback при [0,0][0,0]).
|
||
const QPoint historyPt = bottomNavPoint(QString::fromUtf8("История"), device);
|
||
if (historyPt.x() < 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, historyPt.x(), historyPt.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<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
|
||
if (!accounts.isEmpty()) {
|
||
accountId = accounts.first().id;
|
||
} else {
|
||
qWarning() << "[Dushanbe::GetLastTransactions] No account found";
|
||
}
|
||
|
||
// История отсортирована сверху-вниз: новые операции выше старых. Ищем цели
|
||
// в том же порядке (по времени, от новых к старым) — тогда один непрерывный
|
||
// проход вниз находит все, и возвращаться к началу между целями не нужно.
|
||
// Single-pass: один проход вниз обслуживает ВСЕ цели сразу. Список Выписки
|
||
// идёт сверху-вниз (новые→старые), сортируем цели так же и отдаём в searchAll.
|
||
// Найденные он эмитит через transactionParsed и убирает из pending; в остатке —
|
||
// ненайденные, по которым собираем агрегированную ошибку.
|
||
QList<QPair<double, QDateTime>> pending = m_targets;
|
||
std::sort(pending.begin(), pending.end(),
|
||
[](const QPair<double, QDateTime> &a, const QPair<double, QDateTime> &b) {
|
||
return a.second > b.second; // по убыванию времени (новые первыми)
|
||
});
|
||
|
||
searchAll(device, accountId, pending);
|
||
|
||
if (m_error != "Interrupted") {
|
||
QStringList notFound;
|
||
for (const auto &t : pending) {
|
||
notFound << QString("amount=%1 time=%2")
|
||
.arg(QString::number(t.first, 'f', 2), t.second.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("; "));
|
||
}
|
||
|
||
// Возвращаемся на главную (с геометрическим fallback при [0,0][0,0]).
|
||
{
|
||
const QPoint homePt = bottomNavPoint(QString::fromUtf8("Главная"), device);
|
||
if (homePt.x() >= 0) {
|
||
AdbUtils::makeTap(m_deviceId, homePt.x(), homePt.y());
|
||
} else {
|
||
AdbUtils::goBack(m_deviceId);
|
||
}
|
||
QThread::msleep(1000);
|
||
}
|
||
|
||
emit finishedWithResult(m_error);
|
||
}
|
||
|
||
QPoint GetLastTransactionsScript::bottomNavPoint(const QString &label, const DeviceInfo &device) {
|
||
// 1) Норма: у таба есть реальные bounds в нижней полосе экрана. Проверка
|
||
// «центр в нижних 80% высоты» отсекает и вырожденные [0,0][0,0], и
|
||
// НЕнулевые-но-негодные координаты (одноимённый элемент не в нав-баре).
|
||
const Node lbl = xmlScreenParser.findNodeByContentDesc(label);
|
||
if (!lbl.isEmpty() && lbl.x() > 0
|
||
&& lbl.y() >= static_cast<int>(device.screenHeight * 0.80))
|
||
return {lbl.x(), lbl.y()};
|
||
|
||
// 2) Лейбл нав-таба вырожден ([0,0][0,0] — гибридный UI, напр. Infinix XOS не
|
||
// отдаёт bounds нижнего меню). Но ИКОНКИ табов имеют реальные координаты.
|
||
// Берём самый нижний ряд небольших нод и в нём крайнюю по X: История —
|
||
// самая правая иконка, Главная — самая левая (FAB по центру никогда не
|
||
// крайний; контентные плитки выше нижнего ряда и отсекаются по y2). Это
|
||
// фактические координаты → не зависит от размера экрана, плотности и
|
||
// высоты системной навигации Android (её фикс-высота в dp ломала бы доли).
|
||
const bool rightmost = (label == QString::fromUtf8("История"));
|
||
const bool leftmost = (label == QString::fromUtf8("Главная"));
|
||
if (rightmost || leftmost) {
|
||
const int H = device.screenHeight, W = device.screenWidth;
|
||
const auto nodes = xmlScreenParser.findAllNodes();
|
||
auto isNavIcon = [&](const Node &n) {
|
||
return n.x1() >= 0 && n.width() > 0 && n.height() >= 12
|
||
&& n.width() <= W / 3 && n.y() >= H / 2; // не контейнер, нижняя половина
|
||
};
|
||
int rowBottom = -1;
|
||
for (const Node &n : nodes)
|
||
if (isNavIcon(n)) rowBottom = qMax(rowBottom, n.y2());
|
||
if (rowBottom > 0) {
|
||
const int tol = qMax(8, H / 100); // «тот же ряд» — у самой нижней кромки
|
||
Node pick;
|
||
for (const Node &n : nodes) {
|
||
if (!isNavIcon(n) || n.y2() < rowBottom - tol) continue;
|
||
if (pick.isEmpty()
|
||
|| (rightmost && n.x() > pick.x())
|
||
|| (leftmost && n.x() < pick.x()))
|
||
pick = n;
|
||
}
|
||
if (!pick.isEmpty()) {
|
||
qWarning() << "[Dushanbe::GetLastTransactions] nav tab" << label
|
||
<< "— icon-row fallback tap at" << pick.x() << pick.y();
|
||
return {pick.x(), pick.y()};
|
||
}
|
||
}
|
||
}
|
||
return {-1, -1};
|
||
}
|
||
|
||
void GetLastTransactionsScript::searchAll(
|
||
const DeviceInfo &device, int accountId,
|
||
QList<QPair<double, QDateTime>> &pending) {
|
||
// Скриншот списка транзакций — отправим при неудаче
|
||
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||
|
||
qDebug() << "[Dushanbe::GetLastTransactions] searchAll: targets=" << pending.size();
|
||
|
||
// Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства
|
||
const QTimeZone tjTz("Asia/Dushanbe");
|
||
const int maxScrolls = 10;
|
||
|
||
// Окно поиска по датам: сегодня + (N-1) предыдущих дней. Настраивается в
|
||
// config.ini → [<bankCode>]/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 && !pending.isEmpty(); ) {
|
||
if (interrupted()) {
|
||
m_error = "Interrupted";
|
||
return;
|
||
}
|
||
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<VypiskaItem> 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();
|
||
}
|
||
|
||
// Single-pass: сверяем каждую видимую транзакцию со ВСЕМ набором
|
||
// оставшихся целей. Найденную убираем из pending и пере-сканируем тот же
|
||
// экран (matchedThisScreen), скроллим только когда matchей на экране нет.
|
||
bool matchedThisScreen = false;
|
||
for (const auto &tx : items) {
|
||
const double absListAmt = qAbs(tx.amount);
|
||
|
||
// Выбираем подходящую цель: совпадение по сумме + пред-фильтры даты/времени.
|
||
// Список Выписки, экран деталей и поисковый таргет — все в мажорных
|
||
// единицах (TJS, 2 знака), поэтому сверяем сумму напрямую. Эвристик
|
||
// "x100" убран: он ложно матчил реальную транзакцию на 1.00 к поиску
|
||
// на 100.00 (1.00*100==100.00) и затем домножал результат до 10000.
|
||
int matchIdx = -1;
|
||
for (int ti = 0; ti < pending.size(); ++ti) {
|
||
const double searchAbs = qAbs(pending[ti].first);
|
||
const QDateTime &cand = pending[ti].second;
|
||
const bool amountDirect = qAbs(absListAmt - searchAbs) < 0.01;
|
||
if (!amountDirect) continue;
|
||
|
||
// Pre-filter по ДАТЕ: только если дата надёжная (из content-desc P2P формата).
|
||
// DCWallet транзакции получают дату из groupDates ImageView, которая
|
||
// неправильно присваивается при скролле — не фильтруем по ней.
|
||
if (tx.dateReliable && cand.isValid() && !tx.date.isEmpty()) {
|
||
const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy");
|
||
const QDate searchLocalDate = cand.toTimeZone(tjTz).date();
|
||
if (listDate.isValid() && searchLocalDate.isValid()) {
|
||
const qint64 dayDiff = qAbs(listDate.daysTo(searchLocalDate));
|
||
if (dayDiff > 3) continue;
|
||
}
|
||
}
|
||
|
||
// Проверка времени по HH:MM:SS (+10 минут, одностороннее окно, с учётом wraparound полуночи)
|
||
if (cand.isValid() && !tx.time.isEmpty()) {
|
||
const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss");
|
||
const QTime searchLocalTime = cand.toTimeZone(tjTz).time();
|
||
if (listTime.isValid() && searchLocalTime.isValid()) {
|
||
int diffSecs = searchLocalTime.secsTo(listTime); // >0 = listTime (tx) позже цели
|
||
if (diffSecs < -43200) diffSecs += 86400; // wraparound через полночь вперёд
|
||
else if (diffSecs > 43200) diffSecs -= 86400; // wraparound назад
|
||
if (diffSecs < 0 || diffSecs > 600) continue; // tx раньше цели или позже неё на >10 мин
|
||
}
|
||
}
|
||
|
||
matchIdx = ti;
|
||
break;
|
||
}
|
||
if (matchIdx < 0) continue;
|
||
const QDateTime searchTime = pending[matchIdx].second;
|
||
|
||
qDebug() << "[Dushanbe::GetLastTransactions] Amount match:" << tx.amount
|
||
<< "date:" << tx.date << "time:" << tx.time
|
||
<< "→ target idx" << matchIdx;
|
||
|
||
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; }
|
||
|
||
// Ждём экран деталей Выписки — ImageView с content-desc начинающимся с "Успешный платеж"
|
||
OcrTransactionDetail detail;
|
||
bool detailLoaded = false;
|
||
for (int attempt = 0; attempt < 10; ++attempt) {
|
||
if (interrupted()) { m_error = "Interrupted"; return; }
|
||
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<QString> 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<QString, QString> 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;
|
||
|
||
// Проверяем время: txTime не раньше цели и не позже неё на 10 минут (+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 = searchTime.toUTC().secsTo(txTime.toUTC()); // >0 = txTime позже цели
|
||
if (diffSecs < 0 || diffSecs > 600) {
|
||
qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec (out of +10min window), 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 = detail.amount;
|
||
const double finalFee = 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;
|
||
// Фолбэк-id (когда «Номер операции» не распарсился) строим из
|
||
// created_at задачи — он детерминирован и стабилен между запусками,
|
||
// что важно для дедупликации на сервере. Раньше брали распарсенные
|
||
// с экрана дату+время, которые могли «плавать» из-за OCR.
|
||
txJson["bank_transaction_id"] = !detail.operationId.isEmpty()
|
||
? detail.operationId
|
||
: (searchTime.isValid()
|
||
? QStringLiteral("dushanbe_") + searchTime.toUTC().toString(Qt::ISODate)
|
||
: QStringLiteral("dushanbe_") + detail.date + "_" + detail.time);
|
||
// Списание хранится с отрицательным знаком, пополнение — положительным.
|
||
// detail.amount всегда положителен (OCR), знак берём из списка Выписки.
|
||
const double signedAmount = (tx.amount < 0 ? -1.0 : 1.0) * qAbs(finalAmount);
|
||
txJson["amount"] = static_cast<int>(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<int>(finalFee * 100);
|
||
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);
|
||
|
||
// Проверяем глобальный кэш: если транзакция уже была найдена
|
||
// в рамках другой задачи этого материала — пропускаем её.
|
||
const QString realAt = txJson["real_created_at"].toString();
|
||
if (!realAt.isEmpty()) {
|
||
const QString ck = realAt + "|" + QString::number(qAbs(txJson["amount"].toInt()));
|
||
if (m_foundCache.contains(ck)) {
|
||
qDebug() << "[Dushanbe::GetLastTransactions] TX already found in another task, skip:" << ck;
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(500);
|
||
continue;
|
||
}
|
||
m_foundCache.insert(ck);
|
||
}
|
||
|
||
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);
|
||
|
||
// Цель найдена и отдана — убираем из набора и пере-сканируем этот же
|
||
// экран (вдруг на нём есть ещё цели), прежде чем скроллить дальше.
|
||
pending.removeAt(matchIdx);
|
||
matchedThisScreen = true;
|
||
break;
|
||
}
|
||
|
||
if (pending.isEmpty()) break;
|
||
// На экране что-то нашли — пере-сканируем его же (без скролла): другие
|
||
// цели могут быть на тех же позициях, а скролл их бы пропустил.
|
||
if (matchedThisScreen) continue;
|
||
|
||
// Окно поиска — "сегодня + (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; // дальше только старая история — оставшиеся цели не найдены
|
||
}
|
||
}
|
||
|
||
// Детект отсутствия прогресса прокрутки: сравниваем сигнатуру текущего
|
||
// видимого набора с предыдущей. Совпала — свайп ничего не сдвинул.
|
||
{
|
||
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,"
|
||
<< pending.size() << "target(s) still not found";
|
||
break;
|
||
}
|
||
} else {
|
||
stallScrolls = 0;
|
||
}
|
||
prevViewSig = viewSig;
|
||
}
|
||
|
||
// Скроллим вниз — свайп внутри области транзакций
|
||
if (scroll < maxScrolls) {
|
||
if (interrupted()) { m_error = "Interrupted"; return; }
|
||
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);
|
||
}
|
||
++scroll; // успешный match делает continue выше и сюда не доходит
|
||
}
|
||
|
||
// Прошли весь доступный диапазон/окно. В pending остались ненайденные цели —
|
||
// агрегированную ошибку по ним соберёт doStart (он же вернёт на главную).
|
||
if (!pending.isEmpty()) {
|
||
qWarning() << "[Dushanbe::GetLastTransactions] searchAll finished,"
|
||
<< pending.size() << "target(s) not found";
|
||
// Отправляем скриншот списка транзакций, сделанный до начала поиска
|
||
m_resultScreenshot = listScreenshot.isEmpty()
|
||
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
|
||
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
|
||
}
|
||
}
|
||
|
||
} // namespace Dushanbe
|