diff --git a/android/dushanbe/CommonScript.cpp b/android/dushanbe/CommonScript.cpp index f03feac..1ac0778 100644 --- a/android/dushanbe/CommonScript.cpp +++ b/android/dushanbe/CommonScript.cpp @@ -42,6 +42,11 @@ Node CommonScript::swipeToButton( } bool CommonScript::inputPinCode(const QString &deviceId, const QString &pinCode) { + if (pinCode.isEmpty()) { + qWarning() << "[Dushanbe::inputPinCode] pinCode is EMPTY — nothing to enter!"; + return false; + } + qDebug() << "[Dushanbe::inputPinCode] entering" << pinCode.length() << "digits"; // DC Next использует кнопочную сетку для ввода PIN (content-desc = "0"-"9") for (const QChar &digit : pinCode) { Node button = xmlScreenParser.findNodeByContentDesc(QString(digit)); @@ -64,9 +69,32 @@ bool CommonScript::runAppAndGoToHomeScreen( ) { AdbUtils::unlockScreen(deviceId, width, height); + // Быстрый path: если приложение уже запущено — не перезапускаем сразу, + // а смотрим текущий экран. Если это Home — готово. Если PIN — вводим + // и ждём Home. Только если не смогли — убиваем и рестартим. if (const QString pid = AdbUtils::tryToGetRunningAppPid(deviceId, packageName); !pid.isEmpty()) { qDebug() << "[Dushanbe] Process already running, pid:" << pid; - AdbUtils::getScreenDumpAsXml(deviceId, 1000); + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + if (xmlScreenParser.isHomeScreen()) { + qDebug() << "[Dushanbe] Fast path: already on Home screen"; + return true; + } + if (xmlScreenParser.isPinCodeScreen()) { + qDebug() << "[Dushanbe] Fast path: PIN screen, entering PIN"; + if (inputPinCode(deviceId, pinCode)) { + QThread::msleep(3000); + for (int i = 0; i < 10; ++i) { + xmlScreenParser.parseAndSaveXml( + AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + if (xmlScreenParser.isHomeScreen()) { + qDebug() << "[Dushanbe] Fast path: Home reached after PIN"; + return true; + } + QThread::msleep(1000); + } + } + } + qDebug() << "[Dushanbe] Fast path failed, restarting app"; AdbUtils::tryToKillApplication(deviceId, packageName); } if (!AdbUtils::tryToStartApplication(deviceId, packageName)) { @@ -82,23 +110,39 @@ bool CommonScript::runAppAndGoToHomeScreen( while (true) { counter++; - if (counter > 10) { + if (counter > 20) { + qWarning() << "[Dushanbe] runAppAndGoToHomeScreen: too many attempts, giving up"; AdbUtils::takeScreenshot(deviceId, "too_many_attempts"); return false; } xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); if (xmlScreenParser.isHomeScreen()) { + qDebug() << "[Dushanbe] Home reached on attempt" << counter; return true; } if (!findAndInputPincode && xmlScreenParser.isPinCodeScreen()) { - inputPinCode(deviceId, pinCode); + qDebug() << "[Dushanbe] PIN screen detected on attempt" << counter << "entering PIN"; + if (!inputPinCode(deviceId, pinCode)) { + qWarning() << "[Dushanbe] inputPinCode failed on attempt" << counter; + } findAndInputPincode = true; QThread::msleep(3000); continue; } + // Диагностика: что реально на экране? + QStringList sample; + for (const Node &n : xmlScreenParser.findAllNodes()) { + if (n.contentDesc.isEmpty() && n.text.isEmpty()) continue; + QString s = !n.contentDesc.isEmpty() ? n.contentDesc : n.text; + s.replace('\n', ' '); + if (s.size() > 60) s = s.left(60) + "..."; + sample.append(s); + if (sample.size() >= 8) break; + } + qDebug() << "[Dushanbe] attempt" << counter << "not home, not PIN. Sample:" << sample; QThread::msleep(1000); } } diff --git a/android/dushanbe/GetLastTransactionsScript.cpp b/android/dushanbe/GetLastTransactionsScript.cpp index cb0d2a2..b1f004d 100644 --- a/android/dushanbe/GetLastTransactionsScript.cpp +++ b/android/dushanbe/GetLastTransactionsScript.cpp @@ -1,5 +1,6 @@ #include "GetLastTransactionsScript.h" +#include #include #include #include @@ -11,7 +12,6 @@ #include "dao/MaterialDAO.h" #include "dao/BankProfileDAO.h" #include "dao/DeviceDAO.h" -#include "dao/TransactionDAO.h" #include "AppLogger.h" #include @@ -244,6 +244,9 @@ 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; @@ -367,15 +370,22 @@ void GetLastTransactionsScript::doStart() { for (int scroll = 0; scroll <= maxScrolls; ++scroll) { xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); - // 1. Собираем список дат-групп из родительского ImageView "Обновлено: ...\n05.04.2026\n..." - // Поддерживаем "DD.MM.YYYY", "Сегодня", "Вчера" + // 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; - for (const QString &line : n.contentDesc.split('\n')) { + 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); @@ -385,6 +395,19 @@ void GetLastTransactionsScript::doStart() { 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; } @@ -408,6 +431,55 @@ void GetLastTransactionsScript::doStart() { 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(); @@ -423,10 +495,16 @@ void GetLastTransactionsScript::doStart() { qDebug() << "[Dushanbe::GetLastTransactions] Vypiska items:" << items.size() << "groups:" << groupDates; + const double searchAbs = qAbs(m_searchAmount); for (const auto &tx : items) { - if (qAbs(tx.amount - m_searchAmount) > 0.01) continue; + 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; // Проверка времени по HH:MM:SS (±1 час, с учётом wraparound полуночи) @@ -488,9 +566,13 @@ void GetLastTransactionsScript::doStart() { } if (!detailLoaded) { - qWarning() << "[Dushanbe::GetLastTransactions] Detail screen not loaded"; - AdbUtils::goBack(m_deviceId); - QThread::msleep(1000); + 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:" @@ -524,30 +606,41 @@ void GetLastTransactionsScript::doStart() { << "receiver=" << detail.receiverAccount << "status=" << detail.status; - if (accountId != -1) { - TransactionInfo saveTx; - saveTx.accountId = accountId; - saveTx.amount = detail.amount; - saveTx.fee = detail.fee; - saveTx.bankName = m_appCode; - saveTx.bankTime = detail.date + " " + detail.time; - saveTx.bankTrExternalId = "dushanbe_" + detail.date + "_" + detail.time; - saveTx.description = detail.provider; - saveTx.phone = ""; - saveTx.name = detail.receiverAccount; - saveTx.status = detail.status.contains(QString::fromUtf8("Успешн")) - ? TransactionStatus::Complete : TransactionStatus::Unknown; - saveTx.type = TransactionType::Phone; - saveTx.timestamp = QDateTime::currentDateTimeUtc(); + // Формируем 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.setTimeZone(tjTz); - saveTx.completeTime = txTime; + if (!txTime.isValid()) { + txTime = QDateTime::fromString(detail.date + " " + detail.time, "dd.MM.yyyy HH:mm"); } + if (txTime.isValid()) txTime.setTimeZone(tjTz); - TransactionDAO::insertTransactionIfNotExists(saveTx); + 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(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(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); } // Назад к истории diff --git a/android/dushanbe/GetLastTransactionsScript.h b/android/dushanbe/GetLastTransactionsScript.h index 52327ad..5d9a900 100644 --- a/android/dushanbe/GetLastTransactionsScript.h +++ b/android/dushanbe/GetLastTransactionsScript.h @@ -1,6 +1,7 @@ #pragma once #include "CommonScript.h" #include +#include #include "db/DeviceInfo.h" #include "db/BankProfileInfo.h" @@ -20,6 +21,12 @@ public: ~GetLastTransactionsScript() override; +signals: + // Испускается, когда транзакция найдена и распарсена с экрана деталей. + // EventHandler использует эти данные напрямую для task_result вместо + // повторного чтения из БД. + void transactionParsed(const QJsonObject &tx); + protected: void doStart() override; diff --git a/android/dushanbe/LoginAndCheckAccountsScript.cpp b/android/dushanbe/LoginAndCheckAccountsScript.cpp index 42923f0..c5323c0 100644 --- a/android/dushanbe/LoginAndCheckAccountsScript.cpp +++ b/android/dushanbe/LoginAndCheckAccountsScript.cpp @@ -24,8 +24,14 @@ LoginAndCheckAccountsScript::LoginAndCheckAccountsScript( LoginAndCheckAccountsScript::~LoginAndCheckAccountsScript() = default; void LoginAndCheckAccountsScript::doStart() { + // Канонический ключ материала (device.id в БД) — для лукапов BankProfile; + // m_deviceId же перезаписываем ADB-серийником для команд. + const QString canonicalDeviceId = m_deviceId; 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::Login] 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; @@ -53,7 +59,9 @@ void LoginAndCheckAccountsScript::doStart() { return; } - GetProfileInfoScript profileScript(m_deviceId, m_appCode); + // В GetProfileInfoScript отдаём КАНОНИЧЕСКИЙ ключ материала, а не ADB-серийник, + // чтобы BankProfileDAO::getApplication нашла правильную запись с bank_profile_id. + GetProfileInfoScript profileScript(canonicalDeviceId, m_appCode); profileScript.start(); emit finishedWithResult(m_error); diff --git a/android/dushanbe/ScreenXmlParser.cpp b/android/dushanbe/ScreenXmlParser.cpp index bd4f2c4..d6bdc3b 100644 --- a/android/dushanbe/ScreenXmlParser.cpp +++ b/android/dushanbe/ScreenXmlParser.cpp @@ -129,12 +129,29 @@ Node ScreenXmlParser::findFirstEditText() { } bool ScreenXmlParser::isPinCodeScreen() { + // 1. Прямое совпадение по тексту заголовка for (const Node &node : m_nodes) { - if (node.contentDesc.contains(QString::fromUtf8("Введите код доступа"))) { + if (node.contentDesc.contains(QString::fromUtf8("Введите код доступа")) || + node.contentDesc.contains(QString::fromUtf8("Введите ПИН")) || + node.contentDesc.contains(QString::fromUtf8("код доступа")) || + node.text.contains(QString::fromUtf8("код доступа"))) { return true; } } - return false; + // 2. Fallback: если на экране есть полная цифровая клавиатура 0-9 + // (content-desc каждой кнопки = одна цифра) — это PIN-экран. + int digitButtons = 0; + bool seen[10] = {false}; + for (const Node &node : m_nodes) { + if (node.contentDesc.size() != 1) continue; + const QChar c = node.contentDesc.at(0); + if (!c.isDigit()) continue; + const int d = c.digitValue(); + if (d < 0 || d > 9 || seen[d]) continue; + seen[d] = true; + ++digitButtons; + } + return digitButtons >= 10; } Node ScreenXmlParser::findPinCodeEditText() { @@ -148,15 +165,19 @@ Node ScreenXmlParser::findPinCodeEditText() { bool ScreenXmlParser::isHomeScreen() { bool hasGlavnaya = false; - bool hasTJS = false; + bool hasBottomNav = false; for (const Node &node : m_nodes) { - if (node.contentDesc == QString::fromUtf8("Главная")) { + if (node.contentDesc.contains(QString::fromUtf8("Главная"))) { hasGlavnaya = true; } - if (node.contentDesc.contains("TJS")) { - hasTJS = true; + // Нижняя навигация — любая из соседних вкладок указывает, что мы на + // основной странице приложения, а не на splash/PIN/диалоге. + if (node.contentDesc == QString::fromUtf8("Переводы") || + node.contentDesc == QString::fromUtf8("Сервисы") || + node.contentDesc == QString::fromUtf8("История")) { + hasBottomNav = true; } - if (hasGlavnaya && hasTJS) { + if (hasGlavnaya && hasBottomNav) { return true; } } diff --git a/android/ozon/CommonScript.cpp b/android/ozon/CommonScript.cpp index 097b6ae..fcf4c0e 100644 --- a/android/ozon/CommonScript.cpp +++ b/android/ozon/CommonScript.cpp @@ -62,10 +62,37 @@ bool CommonScript::runAppAndGoToHomeScreen( // Разблокируем экран перед запуском AdbUtils::unlockScreen(deviceId, width, height); - // Запустить устройство + // Быстрый path: если приложение уже запущено — не перезапускаем сразу, + // а смотрим текущий экран. Home → готово. PIN → вводим и ждём Home. + // Только если не смогли — убиваем и рестартим. if (const QString pid = AdbUtils::tryToGetRunningAppPid(deviceId, packageName); !pid.isEmpty()) { qDebug() << "Process already running, pid: " << pid; - AdbUtils::getScreenDumpAsXml(deviceId, 1000); + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + if (xmlScreenParser.isHomeScreen()) { + qDebug() << "[Ozon] Fast path: already on Home screen"; + return true; + } + { + const QList fastPincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН"); + if (fastPincode.size() == pinCode.length() && fastPincode.size() >= 4) { + qDebug() << "[Ozon] Fast path: PIN screen, entering PIN"; + if (Node wake = xmlScreenParser.findTextNode("Введите ПИН"); !wake.isEmpty()) { + AdbUtils::makeTap(deviceId, wake.x(), wake.y()); + } + inputPinCode(deviceId, fastPincode); + QThread::msleep(3000); + for (int i = 0; i < 10; ++i) { + xmlScreenParser.parseAndSaveXml( + AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + if (xmlScreenParser.isHomeScreen()) { + qDebug() << "[Ozon] Fast path: Home reached after PIN"; + return true; + } + QThread::msleep(1000); + } + } + } + qDebug() << "[Ozon] Fast path failed, restarting app"; AdbUtils::tryToKillApplication(deviceId, packageName); } if (!AdbUtils::tryToStartApplication(deviceId, packageName)) { diff --git a/services/EventHandler.cpp b/services/EventHandler.cpp index 42620c1..835b778 100644 --- a/services/EventHandler.cpp +++ b/services/EventHandler.cpp @@ -281,121 +281,38 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event, QJsonValue taskData; if (event.type == EventType::FetchProfile) { - if (event.bankName == "ozon") { - EventDAO::updateEventAmount(event.id, account.amount); - QJsonObject obj; - obj["bank_account_id"] = account.materialId; - obj["amount"] = static_cast(account.amount * 100); - obj["currency"] = currencyCode; - taskData = obj; - } else { - // Отправляем суммы всех материалов устройства для этого банка - const QList allAccounts = MaterialDAO::getAccounts(event.deviceId, event.bankName); - if (!allAccounts.isEmpty()) { - QJsonArray accountsArr; - for (const auto &acc : allAccounts) { - const int accCurrency = currencyCodes.value(acc.currency.toUpper(), 643); - QJsonObject accObj; - accObj["bank_account_id"] = acc.materialId; - accObj["amount"] = static_cast(acc.amount * 100); - accObj["currency"] = accCurrency; - accountsArr.append(accObj); - } - taskData = accountsArr; - } else { - EventDAO::updateEventAmount(event.id, account.amount); - QJsonObject obj; - obj["bank_account_id"] = account.materialId; - obj["amount"] = static_cast(account.amount * 100); - obj["currency"] = currencyCode; - taskData = obj; - } + // FETCH_PROFILE всегда один объект. Amount читаем актуальный: + // - для Ozon скрипт обновляет MaterialDAO → берём из account; + // - для остальных (Dushanbe и т.п.) скрипт обновляет BankProfileDAO + // через updateAmount → берём из bank_profiles. + double freshAmount = account.amount; + if (event.bankName != "ozon") { + const BankProfileInfo freshApp = BankProfileDAO::getApplication( + account.deviceId, event.bankName); + if (freshApp.id != -1) freshAmount = freshApp.amount; } + EventDAO::updateEventAmount(event.id, freshAmount); + QJsonObject obj; + obj["bank_account_id"] = account.materialId; + obj["amount"] = static_cast(freshAmount * 100); + obj["currency"] = currencyCode; + taskData = obj; } else if (event.type == EventType::FetchTransactions) { - QList txList = TransactionDAO::getTransactionsWithinHours(event.accountId, 24); - // Оставляем только совпадения по сумме - txList.erase(std::remove_if(txList.begin(), txList.end(), - [&](const TransactionInfo &tx) { - return qAbs(tx.amount - event.amount) > 0.01; - }), txList.end()); - // Если больше одной — берём ближайшую по времени к запросу сервера - if (txList.size() > 1) { - const QJsonObject taskJson = QJsonDocument::fromJson(event.json.toUtf8()).object(); - const QDateTime target = QDateTime::fromString( - taskJson["body"].toObject()["created_at"].toString(), Qt::ISODate); - if (target.isValid()) { - auto best = std::min_element(txList.begin(), txList.end(), - [&](const TransactionInfo &a, const TransactionInfo &b) { - const QDateTime ta = a.completeTime.isValid() ? a.completeTime : a.timestamp; - const QDateTime tb = b.completeTime.isValid() ? b.completeTime : b.timestamp; - return qAbs(ta.toUTC().secsTo(target)) < qAbs(tb.toUTC().secsTo(target)); - }); - const TransactionInfo chosen = *best; - txList.clear(); - txList.append(chosen); - } - } - // Фильтруем: отправляем только транзакции с bank_transaction_id - bool hasWithoutId = false; - QJsonArray arr; - for (const auto &tx : txList) { - if (tx.bankTrExternalId.isEmpty()) { - hasWithoutId = true; - continue; - } - QJsonObject txObj; - txObj["bank_transaction_id"] = tx.bankTrExternalId; - txObj["amount"] = static_cast(tx.amount * 100); - txObj["currency"] = currencyCode; - txObj["transaction_type"] = (tx.type == TransactionType::Phone) ? "top-up" : "bank"; - txObj["bank_participator_id"]= tx.phone; - txObj["status"] = tx.status == TransactionStatus::Complete ? "completed" : (tx.status == TransactionStatus::Wait ? "progress" : "completed"); - QJsonObject extraObj; - extraObj["fee"] = static_cast(tx.fee * 100); - txObj["extra"] = extraObj; - // Банковские приложения показывают время в таймзоне устройства - const DeviceInfo bankDevice = DeviceDAO::getDeviceById(event.deviceId); - const QString bankAdbSerial = bankDevice.adbSerial.isEmpty() - ? event.deviceId : bankDevice.adbSerial; - const QString bankTzName = AdbUtils::getDeviceTimezone(bankAdbSerial); - QTimeZone bankTz(bankTzName.toUtf8()); - if (!bankTz.isValid()) { - bankTz = QTimeZone(event.bankName == "dushanbe_city_bank" - ? "Asia/Dushanbe" : "Europe/Moscow"); - } - // Приоритет: bankTime (время операции) > timestamp (время сохранения) - // Сервер ожидает UTC ("Z") - bool createdAtSet = false; - if (!tx.bankTime.isEmpty()) { - QDateTime dt = QDateTime::fromString(tx.bankTime, "dd.MM.yyyy, HH:mm"); - if (!dt.isValid()) dt = QDateTime::fromString(tx.bankTime, "dd.MM.yyyy HH:mm"); - if (!dt.isValid()) dt = QDateTime::fromString(tx.bankTime, "dd.MM.yyyy HH:mm:ss"); - if (dt.isValid()) { - dt.setTimeZone(bankTz); - txObj["created_at"] = dt.toUTC().toString(Qt::ISODate); - createdAtSet = true; - } - } - if (!createdAtSet && tx.timestamp.isValid()) { - txObj["created_at"] = tx.timestamp.toUTC().toString(Qt::ISODate); - createdAtSet = true; - } - if (!createdAtSet) { - txObj["created_at"] = QJsonValue(QJsonValue::Null); - } - arr.append(txObj); - } - if (arr.isEmpty() && hasWithoutId) { - // Транзакция найдена но без bank_transaction_id — ошибка - qWarning() << "[EventHandler] FETCH_TRANSACTIONS: tx found but no bank_transaction_id"; + // FETCH_TRANSACTIONS больше НЕ читает из БД — берём то, что + // скрипт распарсил вживую и передал через сигнал transactionParsed. + const QJsonObject parsedTx = m_parsedTxs.take(key); + if (parsedTx.isEmpty()) { + qWarning() << "[EventHandler] FETCH_TRANSACTIONS: script did not emit parsed tx"; sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, - "Transaction found but bank_transaction_id is empty"); + "Transaction not parsed from screen"); EventDAO::updateEvent(event.id, EventStatus::Error, - "Transaction found but bank_transaction_id is empty (OCR failed)"); + "Transaction not parsed from screen"); EventDAO::markSentToServer(event.id); m_threads.remove(key); return; } + QJsonArray arr; + arr.append(parsedTx); taskData = arr; } else if (event.type == EventType::CreateTransaction) { // Определяем тип транзакции из JSON задачи @@ -506,9 +423,10 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn }; const QString &appCode = account.appCode; - // Скриптам нужен ADB serial для команд, не android_id - const DeviceInfo device = DeviceDAO::getDeviceById(account.deviceId); - const QString adbSerial = device.adbSerial.isEmpty() ? account.deviceId : device.adbSerial; + // Передаём в скрипт канонический device_id из материала — скрипт сам + // разрезолвит его в adb serial через DeviceDAO и использует ТОТ ЖЕ ключ + // для поиска BankProfileInfo (чтобы PIN поднимался из правильной записи). + const QString adbSerial = account.deviceId; auto connectScreenshot = [this, key](Ozon::CommonScript *w) { connect(w, &Ozon::CommonScript::screenshotReady, this, @@ -539,8 +457,13 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn } else if (appCode == "black") setup(new Black::GetLastTransactionsScript(adbSerial, appCode)); - else if (appCode == "dushanbe_city_bank") - setup(new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, searchAmount, searchTime)); + else if (appCode == "dushanbe_city_bank") { + auto *w = new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, searchAmount, searchTime); + setup(w); + connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this, + [this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; }, + Qt::DirectConnection); + } else qWarning() << "[EventHandler] FETCH_TRANSACTIONS: unknown appCode:" << appCode; } @@ -658,6 +581,8 @@ void EventHandler::clearAll() { timer->deleteLater(); } m_timers.clear(); + m_screenshots.clear(); + m_parsedTxs.clear(); } void EventHandler::stop() { diff --git a/services/EventHandler.h b/services/EventHandler.h index 20422ff..2df4ba9 100644 --- a/services/EventHandler.h +++ b/services/EventHandler.h @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include #include @@ -33,6 +34,7 @@ private: QMap m_threads; QMap m_timers; QMap m_screenshots; // deviceId → скриншот от скрипта + QMap m_parsedTxs; // key → распарсенная транзакция для FETCH_TRANSACTIONS QTimer *m_pollTimer = nullptr; NetworkService *m_network = nullptr; diff --git a/services/net/NetworkService.cpp b/services/net/NetworkService.cpp index 708c7e9..fef2103 100644 --- a/services/net/NetworkService.cpp +++ b/services/net/NetworkService.cpp @@ -21,6 +21,26 @@ #include "dao/TransactionDAO.h" #include "db/EventInfo.h" +// Убирает полный URL (хост:порт) из сообщений об ошибках, оставляя только +// путь эндпоинта и HTTP-код. Нужно, чтобы адрес сервера не попадал в +// логи/UI/general_logs/application.log. +static QString sanitizePath(const QString &fullUrl) { + const int schemeEnd = fullUrl.indexOf("://"); + if (schemeEnd < 0) return fullUrl; + const int pathStart = fullUrl.indexOf('/', schemeEnd + 3); + return pathStart >= 0 ? fullUrl.mid(pathStart) : QStringLiteral("/"); +} + +static QString sanitizeNetError(QNetworkReply *reply, const QString &path) { + const int httpCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QString endpoint = sanitizePath(path); + if (httpCode > 0) { + return QStringLiteral("HTTP %1 %2").arg(httpCode).arg(endpoint); + } + // Сетевой уровень (не HTTP) — выводим enum-код вместо строки с URL. + return QStringLiteral("net_err=%1 %2").arg(static_cast(reply->error())).arg(endpoint); +} + NetworkService::NetworkService(QObject *parent) : QObject(parent) { const QSettings settings("config.ini", QSettings::IniFormat); m_urlAccountUpdate = settings.value("net/accountUpdate").toString(); @@ -90,7 +110,7 @@ QJsonValue NetworkService::post(QHttpMultiPart *multiPart, const QString &path) QJsonValue result; if (!timeout.isActive()) { - qCritical() << "Таймаут запроса:" << path; + qCritical() << "Таймаут запроса:" << sanitizePath(path); reply->abort(); } else if (reply->error() == QNetworkReply::NoError) { const QByteArray raw = reply->readAll(); @@ -105,7 +125,7 @@ QJsonValue NetworkService::post(QHttpMultiPart *multiPart, const QString &path) } } } else { - qCritical() << "Ошибка:" << reply->errorString() << path; + qCritical() << "Ошибка:" << sanitizeNetError(reply, path); } reply->deleteLater(); return result; @@ -136,7 +156,7 @@ QJsonArray NetworkService::get(const QString &path, const QMap QJsonArray result; if (!timeout.isActive()) { - qCritical() << "Таймаут запроса (GET):" << path; + qCritical() << "Таймаут запроса (GET):" << sanitizePath(path); reply->abort(); } else if (reply->error() == QNetworkReply::NoError) { const QByteArray raw = reply->readAll(); @@ -235,19 +255,21 @@ QJsonValue NetworkService::postJson(const QString &path, const QJsonObject &body const QString reqBodyStr = QJsonDocument(body).toJson(QJsonDocument::Compact); QJsonValue result; + const QString shortPath = sanitizePath(path); if (!timeout.isActive()) { - qCritical() << "Таймаут запроса (postJson):" << path; + qCritical() << "Таймаут запроса (postJson):" << shortPath; reply->abort(); GeneralLogDAO::insert("CRITICAL", "NetworkService", - "Timeout (POST): " + path, {}, reqBodyStr, {}); + "Timeout (POST): " + shortPath, {}, reqBodyStr, {}); result = QJsonValue::Undefined; } else if (reply->error() != QNetworkReply::NoError) { const QByteArray responseBody = reply->readAll(); - qCritical().noquote() << "Network error (POST):" << reply->errorString() << path + const QString errSummary = sanitizeNetError(reply, path); + qCritical().noquote() << "Network error (POST):" << errSummary << (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId) << "\n response body:" << responseBody; GeneralLogDAO::insert("CRITICAL", "NetworkService", - "Network error (POST): " + reply->errorString() + " " + path, {}, + "Network error (POST): " + errSummary, {}, reqBodyStr, QString::fromUtf8(responseBody)); result = QJsonValue::Undefined; } else { @@ -261,7 +283,7 @@ QJsonValue NetworkService::postJson(const QString &path, const QJsonObject &body } else { qCritical() << "API error:" << root; GeneralLogDAO::insert("WARNING", "NetworkService", - "API error (POST): " + path, {}, + "API error (POST): " + shortPath, {}, reqBodyStr, QString::fromUtf8(raw)); result = QJsonValue::Null; } @@ -453,17 +475,19 @@ QJsonValue NetworkService::patchJson(const QString &path, const QJsonObject &bod const QString reqBodyStr = QJsonDocument(body).toJson(QJsonDocument::Compact); QJsonValue result; if (!timeout.isActive()) { - qCritical() << "Таймаут запроса (PATCH):" << path; + const QString shortPath = sanitizePath(path); + qCritical() << "Таймаут запроса (PATCH):" << shortPath; reply->abort(); GeneralLogDAO::insert("CRITICAL", "NetworkService", - "Timeout (PATCH): " + path, {}, reqBodyStr, {}); + "Timeout (PATCH): " + shortPath, {}, reqBodyStr, {}); } else if (reply->error() != QNetworkReply::NoError) { const QByteArray responseBody = reply->readAll(); - qCritical().noquote() << "Network error (PATCH):" << reply->errorString() << path + const QString errSummary = sanitizeNetError(reply, path); + qCritical().noquote() << "Network error (PATCH):" << errSummary << (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId) << "\n response body:" << responseBody; GeneralLogDAO::insert("CRITICAL", "NetworkService", - "Network error (PATCH): " + reply->errorString() + " " + path, {}, + "Network error (PATCH): " + errSummary, {}, reqBodyStr, QString::fromUtf8(responseBody)); } else { const QByteArray raw = reply->readAll(); @@ -476,7 +500,7 @@ QJsonValue NetworkService::patchJson(const QString &path, const QJsonObject &bod } else { qCritical() << "API error (PATCH):" << root; GeneralLogDAO::insert("WARNING", "NetworkService", - "API error (PATCH): " + path, {}, + "API error (PATCH): " + sanitizePath(path), {}, reqBodyStr, QString::fromUtf8(raw)); } } @@ -504,11 +528,11 @@ QJsonValue NetworkService::deleteJson(const QString &path, bool withAuth) { QJsonValue result; if (!timeout.isActive()) { - qCritical() << "Таймаут запроса (DELETE):" << path; + qCritical() << "Таймаут запроса (DELETE):" << sanitizePath(path); reply->abort(); result = QJsonValue::Undefined; } else if (reply->error() != QNetworkReply::NoError) { - qCritical().noquote() << "Network error (DELETE):" << reply->errorString() << path + qCritical().noquote() << "Network error (DELETE):" << sanitizeNetError(reply, path) << (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId); result = QJsonValue::Undefined; } else { @@ -550,11 +574,11 @@ QJsonValue NetworkService::getJson(const QString &path, bool withAuth) { QJsonValue result; if (!timeout.isActive()) { - qCritical() << "Таймаут запроса (getJson):" << path; + qCritical() << "Таймаут запроса (getJson):" << sanitizePath(path); reply->abort(); result = QJsonValue::Undefined; } else if (reply->error() != QNetworkReply::NoError) { - qCritical().noquote() << "Network error (GET):" << reply->errorString() << path + qCritical().noquote() << "Network error (GET):" << sanitizeNetError(reply, path) << (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId); result = QJsonValue::Undefined; } else {