510 lines
21 KiB
C++
510 lines
21 KiB
C++
#include "GetLastTransactionsScript.h"
|
||
|
||
#include <QProcess>
|
||
#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 "dao/TransactionDAO.h"
|
||
#include "time/DateUtils.h"
|
||
#include "AppLogger.h"
|
||
|
||
#include <QCoreApplication>
|
||
static QString recognizeTextFromImage(const QByteArray &pngData) {
|
||
auto *api = new tesseract::TessBaseAPI();
|
||
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
|
||
qWarning() << "[OCR] Failed to init Tesseract";
|
||
delete api;
|
||
return {};
|
||
}
|
||
|
||
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
|
||
static_cast<size_t>(pngData.size()));
|
||
if (!pix) {
|
||
qWarning() << "[OCR] Failed to read image";
|
||
api->End();
|
||
delete api;
|
||
return {};
|
||
}
|
||
|
||
Pix *gray = pixConvertRGBToGray(pix, 0.0f, 0.0f, 0.0f);
|
||
pixDestroy(&pix);
|
||
if (!gray) {
|
||
qWarning() << "[OCR] Failed to convert to grayscale";
|
||
api->End();
|
||
delete api;
|
||
return {};
|
||
}
|
||
|
||
api->SetImage(gray);
|
||
api->SetSourceResolution(300);
|
||
char *text = api->GetUTF8Text();
|
||
QString result = QString::fromUtf8(text).trimmed();
|
||
delete[] text;
|
||
pixDestroy(&gray);
|
||
api->End();
|
||
delete api;
|
||
|
||
return result;
|
||
}
|
||
|
||
static QString extractTransactionId(const QString &ocrText) {
|
||
// ID может быть на одной строке: "ID операции B606513042211200000120011700501"
|
||
// или с дефисами: "ID операции po-3140eb01-002c-5000-8000-033396b637e1"
|
||
// или разбит на 2 строки: "ID ... po-3140eb01-002c-5000-8000-033396b\n ... 637e1"
|
||
QRegularExpression reId(R"(ID\s+операции\s*=?\s*([A-Za-zА-Яа-яЁё0-9\-]+(?:\s+[A-Za-zА-Яа-яЁё0-9\-]+)?))");
|
||
if (auto m = reId.match(ocrText); m.hasMatch()) {
|
||
QString id = m.captured(1);
|
||
id.remove(QRegularExpression(R"(\s+)"));
|
||
return id;
|
||
}
|
||
|
||
QRegularExpression reRef(R"(№\s*(Ф-[\d-]+))");
|
||
if (auto m = reRef.match(ocrText); m.hasMatch()) return m.captured(1);
|
||
|
||
return {};
|
||
}
|
||
|
||
namespace Ozon {
|
||
|
||
GetLastTransactionsScript::GetLastTransactionsScript(
|
||
QString deviceId,
|
||
QString appCode,
|
||
int maxCount,
|
||
double searchAmount,
|
||
QDateTime searchTime,
|
||
QObject *parent
|
||
) : CommonScript(parent),
|
||
m_deviceId(std::move(deviceId)),
|
||
m_appCode(std::move(appCode)),
|
||
m_maxCount(maxCount),
|
||
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);
|
||
|
||
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;
|
||
}
|
||
|
||
if (!navigateToTransactionList(device, app)) {
|
||
emit finishedWithResult(m_error);
|
||
return;
|
||
}
|
||
|
||
// Находим accountId
|
||
int accountId = -1;
|
||
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
|
||
if (!accounts.isEmpty()) {
|
||
accountId = accounts.first().id;
|
||
} else {
|
||
qWarning() << "[GetLastTransactions] No account found for" << device.id << m_appCode;
|
||
}
|
||
|
||
// Выбор режима: поиск конкретной транзакции или сканирование последних
|
||
if (m_searchAmount > 0) {
|
||
searchTransaction(device, accountId);
|
||
} else {
|
||
scanRecentTransactions(device, accountId);
|
||
}
|
||
|
||
emit finishedWithResult(m_error);
|
||
}
|
||
|
||
bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &device, const BankProfileInfo &app) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
|
||
// Закрываем рекламный bottom sheet
|
||
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
|
||
AdbUtils::makeTap(m_deviceId, adNode.x(), adNode.y());
|
||
QThread::msleep(1000);
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
if (Node stillAd = xmlScreenParser.tryToFindAdBanner(); !stillAd.isEmpty()) {
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1000);
|
||
}
|
||
}
|
||
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
|
||
// Если не на домашнем экране — перезапускаем
|
||
if (!xmlScreenParser.isHomeScreen()) {
|
||
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
|
||
device.screenWidth, device.screenHeight)) {
|
||
m_error = "Cannot reach home screen: " + m_deviceId;
|
||
qWarning() << m_error;
|
||
AppLogger::log("ozon/GetLastTransactions", m_deviceId, m_error,
|
||
AdbUtils::captureScreenshotBytes(m_deviceId),
|
||
"FETCH_TRANSACTIONS");
|
||
AdbUtils::tryToKillApplication(m_deviceId, app.package);
|
||
return false;
|
||
}
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
}
|
||
|
||
// Закрываем баннеры на главной
|
||
tryToCloseAllBannersOnHomePage(m_deviceId, device.screenWidth, device.screenHeight);
|
||
QThread::msleep(500);
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
|
||
// Скроллим до "Все" и тапаем
|
||
const int navBarTop = device.screenHeight - (device.screenHeight / 10);
|
||
Node allBtn;
|
||
for (int i = 0; i < 10; ++i) {
|
||
const int x = device.screenWidth / 2;
|
||
const int offset = device.screenHeight / 4;
|
||
AdbUtils::makeSwipe(m_deviceId, x, device.screenHeight - offset, x, offset);
|
||
QThread::msleep(1000);
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
|
||
allBtn = xmlScreenParser.findButtonNode("Все", false);
|
||
if (!allBtn.isEmpty() && allBtn.y() > 0 && allBtn.y() < navBarTop) break;
|
||
allBtn = {};
|
||
}
|
||
|
||
if (allBtn.isEmpty()) {
|
||
m_error = "Button 'Все' not found: " + m_deviceId;
|
||
qWarning() << m_error;
|
||
return false;
|
||
}
|
||
|
||
AdbUtils::makeTap(m_deviceId, allBtn.x(), allBtn.y());
|
||
QThread::msleep(2000);
|
||
|
||
// Ждём экран "Операции"
|
||
for (int attempt = 0; attempt < 10; ++attempt) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
if (xmlScreenParser.isTransactionListScreen()) return true;
|
||
QThread::msleep(1500);
|
||
}
|
||
|
||
m_error = "Transaction list screen not loaded: " + m_deviceId;
|
||
qWarning() << m_error;
|
||
return false;
|
||
}
|
||
|
||
void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int accountId) {
|
||
const QTimeZone msk("Europe/Moscow");
|
||
const int maxScrolls = 15;
|
||
|
||
qDebug() << "[FetchTransactions] Searching: amount=" << m_searchAmount
|
||
<< "time=" << m_searchTime.toString(Qt::ISODate);
|
||
|
||
for (int scroll = 0; scroll < maxScrolls; ++scroll) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
const QList<Node> txButtons = xmlScreenParser.findTransactionButtons(20);
|
||
|
||
for (const Node &btn : txButtons) {
|
||
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(btn.text);
|
||
if (qAbs(qAbs(parsed.amount) - qAbs(m_searchAmount)) > 0.01) continue;
|
||
|
||
qDebug() << "[FetchTransactions] Amount match:" << parsed.amount
|
||
<< "at" << btn.x() << btn.y();
|
||
|
||
AdbUtils::makeTap(m_deviceId, btn.x(), btn.y());
|
||
QThread::msleep(2000);
|
||
|
||
// Ждём загрузки деталей
|
||
TransactionInfo detail;
|
||
for (int attempt = 0; attempt < 10; ++attempt) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
detail = xmlScreenParser.parseTransactionDetail();
|
||
if (!detail.bankTime.isEmpty()) break;
|
||
if (xmlScreenParser.isTransactionListScreen()) {
|
||
AdbUtils::makeTap(m_deviceId, btn.x(), btn.y());
|
||
}
|
||
QThread::msleep(1500);
|
||
}
|
||
|
||
if (detail.bankTime.isEmpty()) {
|
||
qWarning() << "[FetchTransactions] Could not load detail, going back";
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1000);
|
||
continue;
|
||
}
|
||
|
||
// Парсим время
|
||
QString dtStr = detail.bankTime;
|
||
for (int ci = 0; ci < dtStr.size(); ++ci) {
|
||
const QChar ch = dtStr.at(ci);
|
||
if (ch != ' ' && ch.category() == QChar::Separator_Space) dtStr[ci] = ' ';
|
||
}
|
||
|
||
QDateTime txTime = QDateTime::fromString(dtStr, "dd.MM.yyyy, HH:mm");
|
||
if (!txTime.isValid()) txTime = QDateTime::fromString(dtStr, "dd.MM.yyyy,HH:mm");
|
||
if (txTime.isValid()) txTime.setTimeZone(msk);
|
||
|
||
// Проверяем ±1 час
|
||
if (m_searchTime.isValid() && txTime.isValid()) {
|
||
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
|
||
if (diffSecs > 3600) {
|
||
qDebug() << "[FetchTransactions] Time mismatch: tx=" << dtStr
|
||
<< "diff=" << diffSecs << "sec, skipping";
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1000);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Нашли!
|
||
qDebug() << "[FetchTransactions] FOUND transaction:"
|
||
<< "amount=" << parsed.amount << "time=" << dtStr
|
||
<< "name=" << detail.name;
|
||
|
||
// Пробуем получить ID через "Документы" → OCR
|
||
QString externalId = detail.bankTrExternalId;
|
||
if (externalId.isEmpty()) {
|
||
Node docBtn = xmlScreenParser.findButtonNode("Документы", false);
|
||
if (!docBtn.isEmpty()) {
|
||
qDebug() << "[FetchTransactions] Tapping 'Документы'...";
|
||
AdbUtils::makeTap(m_deviceId, docBtn.x(), docBtn.y());
|
||
QThread::msleep(2000);
|
||
|
||
bool docLoaded = false;
|
||
for (int da = 0; da < 15; ++da) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) {
|
||
docLoaded = true;
|
||
break;
|
||
}
|
||
QThread::msleep(1500);
|
||
}
|
||
|
||
if (docLoaded) {
|
||
QThread::msleep(500);
|
||
const QByteArray screenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||
if (!screenshot.isEmpty()) {
|
||
const QString ocrText = recognizeTextFromImage(screenshot);
|
||
if (!ocrText.isEmpty()) {
|
||
qDebug() << "[FetchTransactions] OCR result:" << ocrText;
|
||
externalId = extractTransactionId(ocrText);
|
||
if (!externalId.isEmpty()) {
|
||
qDebug() << "[FetchTransactions] Extracted ID:" << externalId;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1000);
|
||
}
|
||
}
|
||
|
||
// Сохраняем
|
||
if (accountId != -1) {
|
||
TransactionInfo tx;
|
||
tx.accountId = accountId;
|
||
tx.amount = parsed.amount;
|
||
tx.bankName = m_appCode;
|
||
tx.bankTime = dtStr;
|
||
tx.bankTrExternalId = externalId;
|
||
tx.status = TransactionStatus::Complete;
|
||
tx.timestamp = DateUtils::getUtcNow();
|
||
if (txTime.isValid()) tx.completeTime = txTime;
|
||
if (!detail.name.isEmpty()) tx.name = detail.name;
|
||
if (!detail.description.isEmpty()) tx.description = detail.description;
|
||
if (!detail.phone.isEmpty()) {
|
||
QString cleanPhone;
|
||
for (const QChar &ch : detail.phone)
|
||
if (ch.isDigit()) cleanPhone.append(ch);
|
||
tx.phone = cleanPhone;
|
||
tx.type = TransactionType::Phone;
|
||
}
|
||
TransactionDAO::insertTransactionIfNotExists(tx);
|
||
}
|
||
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1000);
|
||
return; // успех — m_error пустой
|
||
}
|
||
|
||
// Скроллим вниз
|
||
const int x = device.screenWidth / 2;
|
||
const int offset = device.screenHeight / 4;
|
||
AdbUtils::makeSwipe(m_deviceId, x, device.screenHeight - offset, x, offset);
|
||
QThread::msleep(1000);
|
||
}
|
||
|
||
m_error = "Transaction not found: amount=" + QString::number(m_searchAmount, 'f', 2)
|
||
+ " time=" + m_searchTime.toString(Qt::ISODate);
|
||
qWarning() << "[FetchTransactions]" << m_error;
|
||
}
|
||
|
||
void GetLastTransactionsScript::scanRecentTransactions(const DeviceInfo &device, int accountId) {
|
||
const QTimeZone msk("Europe/Moscow");
|
||
const int navBarTop = device.screenHeight - (device.screenHeight / 10);
|
||
|
||
// Ждём появления транзакций
|
||
QList<Node> txButtons;
|
||
for (int attempt = 0; attempt < 5; ++attempt) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
txButtons = xmlScreenParser.findTransactionButtons(8);
|
||
if (!txButtons.isEmpty()) break;
|
||
QThread::msleep(1500);
|
||
}
|
||
|
||
qDebug() << "[GetLastTransactions] Found" << txButtons.size() << "transaction buttons";
|
||
|
||
QList<TransactionInfo> transactions;
|
||
for (int i = 0; i < txButtons.size(); ++i) {
|
||
const Node &btn = txButtons[i];
|
||
|
||
// Скроллим до кнопки если за экраном
|
||
if (btn.y() <= 0 || btn.y() >= navBarTop) {
|
||
for (int s = 0; s < 5; ++s) {
|
||
const int x = device.screenWidth / 2;
|
||
const int offset = device.screenHeight / 4;
|
||
AdbUtils::makeSwipe(m_deviceId, x, device.screenHeight - offset, x, offset);
|
||
QThread::msleep(1000);
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
txButtons = xmlScreenParser.findTransactionButtons(8);
|
||
if (i < txButtons.size() && txButtons[i].y() > 0 && txButtons[i].y() < navBarTop) break;
|
||
}
|
||
if (i >= txButtons.size()) break;
|
||
}
|
||
|
||
const Node &visibleBtn = txButtons[i];
|
||
TransactionInfo tx = xmlScreenParser.parseTransactionButtonText(visibleBtn.text);
|
||
|
||
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
|
||
QThread::msleep(2000);
|
||
|
||
// Ждём загрузки деталей
|
||
TransactionInfo detail;
|
||
for (int attempt = 0; attempt < 10; ++attempt) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
detail = xmlScreenParser.parseTransactionDetail();
|
||
if (!detail.bankTime.isEmpty()) break;
|
||
if (xmlScreenParser.isTransactionListScreen()) {
|
||
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
|
||
}
|
||
QThread::msleep(1500);
|
||
}
|
||
|
||
tx.bankName = m_appCode;
|
||
tx.bankTime = detail.bankTime;
|
||
tx.bankTrExternalId = detail.bankTrExternalId;
|
||
tx.status = TransactionStatus::Complete;
|
||
tx.timestamp = DateUtils::getUtcNow();
|
||
if (!detail.name.isEmpty()) tx.name = detail.name;
|
||
if (!detail.phone.isEmpty()) {
|
||
QString cleanPhone;
|
||
for (const QChar &ch : detail.phone)
|
||
if (ch.isDigit()) cleanPhone.append(ch);
|
||
tx.phone = cleanPhone;
|
||
tx.type = TransactionType::Phone;
|
||
}
|
||
|
||
// Парсим время
|
||
if (!tx.bankTime.isEmpty()) {
|
||
QString dtStr = tx.bankTime;
|
||
for (int ci = 0; ci < dtStr.size(); ++ci) {
|
||
const QChar ch = dtStr.at(ci);
|
||
if (ch != ' ' && ch.category() == QChar::Separator_Space) dtStr[ci] = ' ';
|
||
}
|
||
tx.bankTime = dtStr;
|
||
QDateTime parsed = QDateTime::fromString(dtStr, "dd.MM.yyyy, HH:mm");
|
||
if (!parsed.isValid()) parsed = QDateTime::fromString(dtStr, "dd.MM.yyyy,HH:mm");
|
||
if (parsed.isValid()) {
|
||
parsed.setTimeZone(msk);
|
||
tx.completeTime = parsed;
|
||
}
|
||
}
|
||
|
||
// Фильтр по времени (24ч) если нет maxCount
|
||
if (m_maxCount <= 0 && tx.completeTime.isValid()) {
|
||
const qint64 ageSecs = tx.completeTime.toUTC().secsTo(QDateTime::currentDateTimeUtc());
|
||
if (ageSecs > 24 * 3600) {
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1000);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Сохраняем
|
||
if (accountId != -1) {
|
||
tx.accountId = accountId;
|
||
if (TransactionDAO::insertTransactionIfNotExists(tx)) {
|
||
// Скриншот чека
|
||
Node docBtn = xmlScreenParser.findButtonNode("Документы", false);
|
||
if (!docBtn.isEmpty()) {
|
||
AdbUtils::makeTap(m_deviceId, docBtn.x(), docBtn.y());
|
||
QThread::msleep(2000);
|
||
|
||
bool docLoaded = false;
|
||
for (int da = 0; da < 20; ++da) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) {
|
||
docLoaded = true; break;
|
||
}
|
||
QThread::msleep(1500);
|
||
}
|
||
|
||
if (docLoaded) {
|
||
QThread::msleep(500);
|
||
QProcess screencapProc;
|
||
screencapProc.start("adb", QStringList() << "-s" << m_deviceId << "exec-out" << "screencap" << "-p");
|
||
screencapProc.waitForFinished(5000);
|
||
const QByteArray screenshot = screencapProc.readAllStandardOutput();
|
||
if (!screenshot.isEmpty()) {
|
||
TransactionInfo saved = TransactionDAO::findByBankTimeAndAmount(
|
||
accountId, tx.bankName, tx.bankTime, tx.amount);
|
||
if (saved.id != -1) {
|
||
TransactionDAO::updateReceiptImage(saved.id, screenshot);
|
||
const QString ocrText = recognizeTextFromImage(screenshot);
|
||
if (!ocrText.isEmpty()) {
|
||
const QString txId = extractTransactionId(ocrText);
|
||
if (!txId.isEmpty()) {
|
||
TransactionDAO::updateBankTrExternalId(saved.id, txId);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1000);
|
||
}
|
||
}
|
||
}
|
||
|
||
transactions.append(tx);
|
||
|
||
if (m_maxCount > 0 && transactions.size() >= m_maxCount) {
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1000);
|
||
break;
|
||
}
|
||
|
||
AdbUtils::goBack(m_deviceId);
|
||
QThread::msleep(1500);
|
||
|
||
for (int attempt = 0; attempt < 5; ++attempt) {
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
if (xmlScreenParser.isTransactionListScreen()) break;
|
||
QThread::msleep(1000);
|
||
}
|
||
|
||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||
txButtons = xmlScreenParser.findTransactionButtons(8);
|
||
}
|
||
}
|
||
|
||
} // namespace Ozon
|