715 lines
33 KiB
C++
715 lines
33 KiB
C++
#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
|