1
0
forked from BRT/arc
arc/android/dushanbe/GetLastTransactionsScript.cpp

715 lines
33 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "GetLastTransactionsScript.h"
#include <QJsonDocument>
#include <QRegularExpression>
#include <QThread>
#include <QTimeZone>
#include <tesseract/baseapi.h>
#include <leptonica/allheaders.h>
#include "adb/AdbUtils.h"
#include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.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() << "[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,
double searchAmount,
QDateTime searchTime,
QObject *parent
) : CommonScript(parent),
m_deviceId(std::move(deviceId)),
m_appCode(std::move(appCode)),
m_searchAmount(searchAmount),
m_searchTime(std::move(searchTime)) {
}
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;
}
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));
// Проверяем диалог "Ошибка" — нажимаем "ОК" и обновляем свайпом вниз
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);
// Сохраняем дамп Выписки для анализа формата
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";
}
qDebug() << "[Dushanbe::GetLastTransactions] Searching: amount=" << m_searchAmount
<< "time=" << m_searchTime.toString(Qt::ISODate);
// Время в банковском приложении показывается в таймзоне устройства
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId);
QTimeZone tjTz(tzName.toUtf8());
if (!tjTz.isValid()) tjTz = QTimeZone("Asia/Dushanbe");
const int maxScrolls = 4;
auto interrupted = []() {
return QThread::currentThread()->isInterruptionRequested();
};
struct VypiskaItem {
double amount = 0.0;
QString time; // "21:31:21"
QString date; // "05.04.2026"
Node node;
};
for (int scroll = 0; scroll <= maxScrolls; ++scroll) {
if (interrupted()) {
m_error = "Interrupted";
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// 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-транзакции (формат Выписки)
static const QRegularExpression reTime(R"(\d{2}:\d{2}:\d{2})");
QList<VypiskaItem> items;
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (n.className != "android.view.View") continue;
if (!n.contentDesc.contains("DCWallet") && !n.contentDesc.contains("***")) 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;
VypiskaItem it;
bool ok = false;
it.amount = lines[2].trimmed().toDouble(&ok);
if (!ok) continue;
it.time = last;
it.node = n;
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 и группируем по разрывам (> 50px → новая группа-день)
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 > 50) ++groupIdx;
if (groupIdx < groupDates.size()) it.date = groupDates[groupIdx];
prevY2 = it.node.y2();
}
qDebug() << "[Dushanbe::GetLastTransactions] Vypiska items:" << items.size()
<< "groups:" << groupDates;
const double searchAbs = qAbs(m_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 по ДАТЕ: если дата ряда не совпадает с датой поиска (±1 день),
// пропускаем без тапа-в-детали. Это отсекает большинство "нематчащих" строк
// быстрее, чем 10-секундный цикл tap→parse→goBack.
if (m_searchTime.isValid() && !tx.date.isEmpty()) {
const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy");
const QDate searchLocalDate = m_searchTime.toTimeZone(tjTz).date();
if (listDate.isValid() && searchLocalDate.isValid()) {
const qint64 dayDiff = qAbs(listDate.daysTo(searchLocalDate));
if (dayDiff > 1) {
qDebug() << "[Dushanbe::GetLastTransactions] Date pre-filter skip:"
<< tx.date << "vs" << searchLocalDate.toString("dd.MM.yyyy")
<< "diff=" << dayDiff << "days";
continue;
}
}
}
// Проверка времени по HH:MM:SS (±1 час, с учётом wraparound полуночи)
if (m_searchTime.isValid() && !tx.time.isEmpty()) {
const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss");
const QTime searchLocalTime = m_searchTime.toTimeZone(tjTz).time();
if (listTime.isValid() && searchLocalTime.isValid()) {
const int diffSecs = qAbs(listTime.secsTo(searchLocalTime));
if (diffSecs > 3600 && diffSecs < 82800) { // 82800 = 23h
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;
}
AdbUtils::makeTap(m_deviceId, txNode.x(), txNode.y());
QThread::msleep(3000);
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
// Ждём экран деталей Выписки — ImageView с content-desc начинающимся с "Успешный платеж"
OcrTransactionDetail detail;
bool detailLoaded = false;
for (int attempt = 0; attempt < 10; ++attempt) {
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
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) {
const QString key = lines[i].trimmed();
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 = val.toDouble();
} else if (key == QString::fromUtf8("Дата:")) {
detail.date = val;
} else if (key == QString::fromUtf8("Время:")) {
detail.time = val;
}
}
if (n.contentDesc.contains(QString::fromUtf8("Успешный"))) {
detail.status = QString::fromUtf8("Успешный");
}
detailLoaded = true;
break;
}
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;
// Проверяем время ±1 час
if (m_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(m_searchTime.toUTC()));
if (diffSecs > 3600) {
qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec, 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<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;
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);
}
// Назад к истории
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);
emit finishedWithResult(m_error); // m_error пустой — успех
return;
}
// Скроллим вниз
if (scroll < maxScrolls) {
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
const int x = device.screenWidth / 2;
const int fromY = device.screenHeight * 3 / 4;
const int toY = device.screenHeight / 4;
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY);
QThread::msleep(2000);
}
}
m_error = "Transaction not found: amount=" + QString::number(m_searchAmount, 'f', 2)
+ " time=" + m_searchTime.toString(Qt::ISODate);
qWarning() << "[Dushanbe::GetLastTransactions]" << m_error;
// Возвращаемся на главную
Node glavnayaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Главная"));
if (glavnayaBtn.x() > 0 && glavnayaBtn.y() > 0) {
AdbUtils::makeTap(m_deviceId, glavnayaBtn.x(), glavnayaBtn.y());
} else {
AdbUtils::goBack(m_deviceId);
}
QThread::msleep(1000);
emit finishedWithResult(m_error);
}
} // namespace Dushanbe