diff --git a/android/adb/AdbUtils.cpp b/android/adb/AdbUtils.cpp index 2302e84..3632941 100644 --- a/android/adb/AdbUtils.cpp +++ b/android/adb/AdbUtils.cpp @@ -245,11 +245,16 @@ bool AdbUtils::enableAdbIMEKeyboard(const QString &deviceId) { bool AdbUtils::inputAdbIMEText(const QString &deviceId, const QString &text) { QProcess process; - QString command = QString( - "%1 -s %2 shell am broadcast -a ADB_INPUT_TEXT --es msg '%3'" - ).arg(adbPath(), deviceId, text); - process.start(command); + process.start(adbPath(), { + "-s", deviceId, + "shell", "am", "broadcast", + "-a", "ADB_INPUT_TEXT", + "--es", "msg", text + }); process.waitForFinished(); + qDebug() << "[AdbUtils] inputAdbIMEText result:" << process.exitCode() + << "output:" << process.readAllStandardOutput().trimmed() + << "error:" << process.readAllStandardError().trimmed(); return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0; } diff --git a/android/ozon/CommonScript.cpp b/android/ozon/CommonScript.cpp index 19d32fd..4a23c2f 100644 --- a/android/ozon/CommonScript.cpp +++ b/android/ozon/CommonScript.cpp @@ -102,6 +102,13 @@ bool CommonScript::runAppAndGoToHomeScreen( continue; } + // Проверяем на баннер Google Play "Доступно обновление" + if (Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(); !gpNode.isEmpty()) { + qDebug() << "[runAppAndGoToHomeScreen] Google Play update banner detected, closing..."; + AdbUtils::makeTap(deviceId, gpNode.x(), gpNode.y()); + QThread::msleep(1500); + continue; + } // Проверяем, что это экран ввода пин-кода if (!findAndInputPincode) { diff --git a/android/ozon/GetCardInfoScript.cpp b/android/ozon/GetCardInfoScript.cpp index 7e070c3..874b5ec 100644 --- a/android/ozon/GetCardInfoScript.cpp +++ b/android/ozon/GetCardInfoScript.cpp @@ -256,6 +256,16 @@ void GetCardInfoScript::doStart() { qDebug() << "[GetCardInfo] Card number found on attempt" << attempt + 1; break; } + + // Закрываем баннер "Понятно" если он перекрывает экран + Node bannerBtn = xmlScreenParser.findTextNode("Понятно"); + if (!bannerBtn.isEmpty()) { + qDebug() << "[GetCardInfo] Banner 'Понятно' detected, closing..."; + AdbUtils::makeTap(m_deviceId, bannerBtn.x(), bannerBtn.y()); + QThread::msleep(1000); + continue; + } + qDebug() << "[GetCardInfo] Card number not visible, attempt" << attempt + 1 << "/ 10"; } diff --git a/android/ozon/GetLastTransactionsScript.cpp b/android/ozon/GetLastTransactionsScript.cpp index 398ecee..f1358b5 100644 --- a/android/ozon/GetLastTransactionsScript.cpp +++ b/android/ozon/GetLastTransactionsScript.cpp @@ -1,10 +1,15 @@ #include "GetLastTransactionsScript.h" +#include #include +#include #include "adb/AdbUtils.h" +#include "dao/AccountDAO.h" #include "dao/ApplicationDAO.h" #include "dao/DeviceDAO.h" +#include "dao/TransactionDAO.h" +#include "time/DateUtils.h" namespace Ozon { @@ -61,13 +66,29 @@ void GetLastTransactionsScript::doStart() { xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); // Скроллим вниз до кнопки "Все" и тапаем по ней - Node allBtn = swipeToButton(m_deviceId, "Все", m_counter, - device.screenWidth, device.screenHeight); + // Кнопка "Все" на домашнем экране находится у границы навбара (y ≈ 2190), + // поэтому сначала скроллим вниз, чтобы она поднялась выше навигационной панели + const int navBarTop = device.screenHeight - (device.screenHeight / 10); // ~90% высоты экрана + 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 = {}; + qDebug() << "[GetLastTransactions] 'Все' not found above navbar, attempt" << i + 1 << "/ 10"; + } if (allBtn.isEmpty()) { m_error = "Button 'Все' not found after scrolling: " + m_deviceId; qWarning() << m_error; - AdbUtils::tryToKillApplication(m_deviceId, app.package); emit finishedWithResult(m_error); return; } @@ -76,16 +97,206 @@ void GetLastTransactionsScript::doStart() { AdbUtils::makeTap(m_deviceId, allBtn.x(), allBtn.y()); QThread::msleep(2000); - // Парсим транзакции за сегодня и вчера - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); - QList transactions = xmlScreenParser.parseTodayAndYesterdayHistory(); - - qDebug() << "[GetLastTransactions] Found" << transactions.size() << "transactions"; - for (const TransactionInfo &tx : transactions) { - qDebug() << "[GetLastTransactions]" << tx.description << tx.amount << tx.bankTime; + // Ждём загрузки экрана "Операции" + bool loaded = false; + for (int attempt = 0; attempt < 10; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); + if (xmlScreenParser.isTransactionListScreen()) { + loaded = true; + break; + } + qDebug() << "[GetLastTransactions] Waiting for transaction list screen, attempt" << attempt + 1 << "/ 10"; + QThread::msleep(1500); + } + + if (!loaded) { + m_error = "Transaction list screen not loaded: " + m_deviceId; + qWarning() << m_error; + emit finishedWithResult(m_error); + return; + } + + // Ждём появления транзакций (могут подгружаться) + QList txButtons; + for (int attempt = 0; attempt < 5; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); + txButtons = xmlScreenParser.findTransactionButtons(8); + if (!txButtons.isEmpty()) break; + qDebug() << "[GetLastTransactions] No transactions yet, attempt" << attempt + 1 << "/ 5"; + QThread::msleep(1500); + } + + qDebug() << "[GetLastTransactions] Found" << txButtons.size() << "transaction buttons"; + + // Находим accountId для сохранения транзакций + int accountId = -1; + const QList accounts = AccountDAO::getAccounts(m_deviceId, m_appCode); + if (!accounts.isEmpty()) { + accountId = accounts.first().id; + qDebug() << "[GetLastTransactions] Using accountId:" << accountId; + } else { + qWarning() << "[GetLastTransactions] No account found for" << m_deviceId << m_appCode; + } + + // Для каждой транзакции: клик → берём дату из деталей → назад + QList transactions; + for (int i = 0; i < txButtons.size(); ++i) { + const Node &btn = txButtons[i]; + + // Если кнопка внизу экрана (за навбаром) — скроллим до неё + const int navBarTop = device.screenHeight - (device.screenHeight / 10); + if (btn.y() <= 0 || btn.y() >= navBarTop) { + qDebug() << "[GetLastTransactions] Transaction" << i + 1 << "is off-screen (y=" << btn.y() << "), scrolling..."; + 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()) { + qWarning() << "[GetLastTransactions] Transaction" << i + 1 << "not found after scrolling"; + break; + } + } + const Node &visibleBtn = txButtons[i]; + TransactionInfo tx = xmlScreenParser.parseTransactionButtonText(visibleBtn.text); + + qDebug() << "[GetLastTransactions] Tapping transaction" << i + 1 << ":" << visibleBtn.text.left(50); + 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; + qDebug() << "[GetLastTransactions] Detail not loaded, attempt" << attempt + 1 << "/ 10"; + QThread::msleep(1500); + } + tx.bankName = "ozon"; + tx.bankTime = detail.bankTime; + 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; + } + + // Чистим bankTime от unicode символов: заменяем все не-ASCII пробелы на обычные + 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; + const QTimeZone msk("Europe/Moscow"); + 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; + } + } + + qDebug() << "[GetLastTransactions]" + << "date:" << tx.bankTime + << "name:" << tx.description + << "sender:" << tx.name + << "phone:" << tx.phone + << "amount:" << (tx.amount >= 0 ? "+" : "") + QString::number(tx.amount, 'f', 2); + + // Сохраняем в БД если не существует + // Берём первый аккаунт для этого устройства и приложения + if (accountId != -1) { + tx.accountId = accountId; + if (TransactionDAO::insertTransactionIfNotExists(tx)) { + qDebug() << "[GetLastTransactions] Saved transaction to DB"; + + // Делаем скриншот документа транзакции + Node docBtn = xmlScreenParser.findButtonNode("Документы", false); + if (!docBtn.isEmpty()) { + qDebug() << "[GetLastTransactions] Tapping 'Документы'..."; + AdbUtils::makeTap(m_deviceId, docBtn.x(), docBtn.y()); + QThread::msleep(2000); + + // Ждём появления кнопки "Поделиться" + bool docLoaded = false; + for (int da = 0; da < 10; ++da) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); + Node shareBtn = xmlScreenParser.findNodeByContentDesc("Поделиться"); + if (!shareBtn.isEmpty()) { + docLoaded = true; + break; + } + qDebug() << "[GetLastTransactions] Waiting for 'Поделиться', attempt" << da + 1 << "/ 10"; + 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); + qDebug() << "[GetLastTransactions] Receipt saved to DB, txId:" << saved.id; + } + } else { + qWarning() << "[GetLastTransactions] Screenshot is empty"; + } + } else { + qWarning() << "[GetLastTransactions] 'Поделиться' not found, skipping receipt"; + } + + // Возвращаемся из документов в детали транзакции + AdbUtils::goBack(m_deviceId); + QThread::msleep(1000); + } + } else { + qDebug() << "[GetLastTransactions] Transaction already exists or error"; + } + } + + transactions.append(tx); + + // Возвращаемся в список + 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); } - AdbUtils::tryToKillApplication(m_deviceId, app.package); emit finishedWithResult(m_error); } diff --git a/android/ozon/LoginAndCheckAccountsScript.cpp b/android/ozon/LoginAndCheckAccountsScript.cpp index 5e5563c..c59ecda 100644 --- a/android/ozon/LoginAndCheckAccountsScript.cpp +++ b/android/ozon/LoginAndCheckAccountsScript.cpp @@ -15,7 +15,8 @@ LoginAndCheckAccountsScript::LoginAndCheckAccountsScript( QString appCode, QObject *parent ) : CommonScript(parent), - m_deviceId(std::move(deviceId)), m_appCode(std::move(appCode)) { + m_deviceId(std::move(deviceId)), + m_appCode(std::move(appCode)) { } LoginAndCheckAccountsScript::~LoginAndCheckAccountsScript() = default; diff --git a/android/ozon/PayByPhoneScript.cpp b/android/ozon/PayByPhoneScript.cpp index bde0c69..2824b07 100644 --- a/android/ozon/PayByPhoneScript.cpp +++ b/android/ozon/PayByPhoneScript.cpp @@ -1,17 +1,15 @@ #include "PayByPhoneScript.h" -#include +#include +#include #include #include -#include "DatabaseManager.h" #include "adb/AdbUtils.h" #include "dao/AccountDAO.h" #include "dao/ApplicationDAO.h" #include "dao/DeviceDAO.h" #include "dao/TransactionDAO.h" -#include "db/ApplicationInfo.h" -#include "db/DeviceInfo.h" namespace Ozon { @@ -28,313 +26,310 @@ PayByPhoneScript::PayByPhoneScript( PayByPhoneScript::~PayByPhoneScript() = default; - -void PayByPhoneScript::makePayment( - const QString &deviceId, - const QString &pinCode, - int width, - int height -) { - // Оплата со страницы карты - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - Node payBtn = xmlScreenParser.findButtonNode("Оплатить", false); - if (!payBtn.isEmpty()) { - AdbUtils::makeTap(deviceId, payBtn.x(), payBtn.y()); - QThread::msleep(1000); - } else { - AdbUtils::takeScreenshot(deviceId, "pay_by_phone_pay_btn_not_found"); - m_error = "Кнопка 'Оплатить' не нашлась"; - qDebug() << m_error; - return; - } - - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - // TODO - По номеру карты В РСХБ и другие банки - Node payTitle = xmlScreenParser.findTextNode("Платежи и переводы"); - if (!payTitle.isEmpty() && payTitle.y() < 300) { - Node payByPhoneBtn = xmlScreenParser.findButtonNode("По номеру телефона В РСХБ и через", false); - if (!payByPhoneBtn.isEmpty()) { - AdbUtils::makeTap(deviceId, payByPhoneBtn.x(), payByPhoneBtn.y()); - QThread::msleep(1000); - } else { - AdbUtils::takeScreenshot(deviceId, "pay_by_phone_not_found_pay_by_phone"); - m_error = "Не смогли найти пункт 'По номеру телефона В РСХБ и через'"; - qDebug() << m_error; - return; - } - } else { - AdbUtils::takeScreenshot(deviceId, "pay_by_phone_cant_go_to_payments"); - m_error = "Не смогли перейти на экран 'Платежи и переводы'"; - qDebug() << m_error; - return; - } - - - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - Node payByPhoneTitle = xmlScreenParser.findTextNode("Перевод по телефону"); - Node continueBtn = xmlScreenParser.findButtonNode("Продолжить", false); - if (!payByPhoneTitle.isEmpty() && payByPhoneTitle.y() < 300 && continueBtn.isEmpty()) { - Node inputPhone = xmlScreenParser.findFirstEditText(); - AdbUtils::makeTap(deviceId, inputPhone.x(), inputPhone.y()); - QThread::msleep(500); - AdbUtils::inputText(deviceId, m_phone); - QThread::msleep(1000); - } else { - AdbUtils::takeScreenshot(deviceId, "pay_by_phone_cant_find_pay_by_phone"); - m_error = "Не смогли найти пункт 'Перевод по телефону'"; - qDebug() << m_error; - return; - } - - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - QString payBankName = m_bankName == QStringLiteral("Россельхозбанк") ? "Россельхозбанк" : "Другой банк через СБП"; - Node payOtherBankBtn = xmlScreenParser.findButtonNode(payBankName, false); - if (!payOtherBankBtn.isEmpty()) { - AdbUtils::makeTap(deviceId, payOtherBankBtn.x(), payOtherBankBtn.y()); - QThread::msleep(1000); - } else { - AdbUtils::takeScreenshot(deviceId, "pay_by_phone_cant_tap_on_bank"); - m_error = "Не смогли нажать на первичный выбор банка"; - qDebug() << m_error; - return; - } - - if (m_bankName != QStringLiteral("Россельхозбанк")) { - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - for (int i = 0; i < 5; ++i) { - Node allBanksTitle = xmlScreenParser.findTextNode("Банки участники"); - if (!allBanksTitle.isEmpty() && allBanksTitle.y() < 300) { - Node inputBankName = xmlScreenParser.findFirstEditText(); - AdbUtils::makeTap(deviceId, inputBankName.x(), inputBankName.y()); - QThread::msleep(500); - AdbUtils::enableAdbIMEKeyboard(deviceId); - AdbUtils::inputAdbIMEText(deviceId, m_bankName); - AdbUtils::disableAdbIMEKeyboard(deviceId); - QThread::msleep(1000); - break; - } else { - qDebug() << "--- allBanksTitle not found, i: " << i; - } - QThread::msleep(1000); - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - } - - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - for (int i = 0; i < 5; ++i) { - Node bankNameListItem = xmlScreenParser.findTextNode(m_bankName); - if (!bankNameListItem.isEmpty()) { - AdbUtils::makeTap(deviceId, bankNameListItem.x(), bankNameListItem.y()); - QThread::msleep(1000); - break; - } else { - qDebug() << "--- bankNameListItem not found, i: " << i; - } - QThread::msleep(1000); - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - } - } - - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - - const bool isRshbBank = m_bankName == QStringLiteral("Россельхозбанк"); - QString payByPhoneScreenTitleText = isRshbBank ? "Перевод клиенту РСХБ" : "Перевод по телефону"; - Node payByPhoneScreenTitle = xmlScreenParser.findTextNode(payByPhoneScreenTitleText); - Node continuePayBtn = xmlScreenParser.findButtonNode("Продолжить", false); - - if (isRshbBank || - !payByPhoneScreenTitle.isEmpty() && payByPhoneScreenTitle.y() < 300 && !continuePayBtn.isEmpty()) { - Node sumBtn = xmlScreenParser.findButtonNode("0", false); - if (isRshbBank || !sumBtn.isEmpty()) { - if (!isRshbBank) { - AdbUtils::makeTap(deviceId, sumBtn.x(), sumBtn.y()); - QThread::msleep(500); - } - AdbUtils::inputText(deviceId, QString::number(m_amount)); - QThread::msleep(500); - - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - Node readyBtn = xmlScreenParser.findTextNode("Готово"); - if (!readyBtn.isEmpty()) { - AdbUtils::makeTap(deviceId, readyBtn.x(), readyBtn.y()); - QThread::msleep(500); - if (isRshbBank) { - QThread::msleep(1000); - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - continuePayBtn = xmlScreenParser.findButtonNode("Продолжить", false); - } - AdbUtils::makeTap(deviceId, continuePayBtn.x(), continuePayBtn.y()); - QThread::msleep(1000); - } else { - AdbUtils::takeScreenshot(deviceId, "pay_by_phone_cant_tap_on_ready"); - m_error = "Не смогли нажать 'Готово'"; - qDebug() << m_error; - return; - } - } else { - AdbUtils::takeScreenshot(deviceId, "pay_by_phone_cant_tap_on_continue"); - m_error = "Не смогли нажать 'Продолжить'"; - qDebug() << m_error; - return; - } - } else { - AdbUtils::takeScreenshot(deviceId, "pay_by_phone_cant_find_bank_title"); - m_error = "Не смогли найти заголовок: " + payByPhoneScreenTitleText; - qDebug() << m_error; - return; - } - - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - bool inputedPinCode = false; - for (int i = 0; i < 5; ++i) { - // TODO кнопка содержит "Перевести 10 ₽" если будет от 1000 то скорее всвего будет "Перевести 1 000 ₽", - // TODO не ясно как форматирование будет, поэтому пока на начало зашьем - Node makePayBtn = xmlScreenParser.findButtonNode("Перевести ", true); - if (!makePayBtn.isEmpty()) { - AdbUtils::makeTap(deviceId, makePayBtn.x(), makePayBtn.y()); - QThread::msleep(500); - - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - if (const QList pincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите код доступа"); - pincode.size() == 4) { - inputPinCode(deviceId, pincode); - inputedPinCode = true; - // После ввода пин-кода, ждем 3с - QThread::msleep(3000); - } - break; - } else { - qWarning() << "--- makePayBtn not found, i: " << i; - } - QThread::msleep(1000); - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - } - - if (!isRshbBank && !inputedPinCode) { - AdbUtils::takeScreenshot(deviceId, "pay_by_phone_cant_input_pin_code"); - m_error = "Не смогли ввести пин-код!'"; - qDebug() << m_error; - return; - } - - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - - for (int i = 0; i < 10; ++i) { - Node moreDetailBtn = xmlScreenParser.findButtonNode("Детали операции", false); - if (!moreDetailBtn.isEmpty()) { - AdbUtils::makeTap(deviceId, moreDetailBtn.x(), moreDetailBtn.y()); - QThread::msleep(200); - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - - // TODO считываем все данные по переводу - TransactionInfo transaction = xmlScreenParser.parseTransactionInfo(width, height); - if (isRshbBank) { - transaction.bankName = m_bankName; - } - transaction.accountId = m_account.id; - transaction.amount = -m_amount; - transaction.type = TransactionType::Phone; - transaction.id = TransactionDAO::insertTransaction(transaction); - if (transaction.id == -1) { - // FIXME alert!! - qDebug() << "Transaction has not inserted..."; - } - - // ждем 10 секунд смену статуса - for (int k = 0; k < 10; ++k) { - Node paymentCompleted = xmlScreenParser.findTextNode("Исполнен", 0, 0, width, height / 2); - if (!paymentCompleted.isEmpty()) { - transaction.status = TransactionStatus::Complete; - if (!TransactionDAO::updateTransactionStatus(transaction.id, TransactionStatus::Complete)) { - qDebug() << "Not updated: " << transaction.id; - } - // TODO обновляем данные - break; - } - - QThread::msleep(1000); - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - } - - postNewTransactionData(m_account, transaction); - - AdbUtils::takeScreenshot(deviceId, "pay_by_phone_finish_transactions_" + transaction.id); - AdbUtils::takeXmlDump(deviceId, "pay_by_phone_finish_transactions_" + transaction.id); - break; - } else { - qWarning() << "--- moreDetailBtn not found, i: " << i; - } - QThread::msleep(1000); - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - } - - Node homeBtn = xmlScreenParser.findButtonNode("Вернуться на главную", false); - AdbUtils::makeTap(deviceId, homeBtn.x(), homeBtn.y()); - QThread::msleep(1000); -} - void PayByPhoneScript::doStart() { const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId); const ApplicationInfo app = ApplicationDAO::getApplication(m_account.deviceId, m_account.appCode); if (device.id.isEmpty() || app.id == -1) { - m_error = "Device or app not found...."; - qDebug() << m_error; + m_error = "Device or app not found: " + m_account.deviceId; + qCritical() << m_error; emit finishedWithResult(m_error); return; } - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_account.deviceId, ++m_counter)); + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.id, ++m_counter)); + + // Закрываем рекламный bottom sheet + if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) { + qDebug() << "[PayByPhone] Ad banner detected, closing..."; + AdbUtils::makeTap(device.id, adNode.x(), adNode.y()); + QThread::msleep(1000); + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.id, ++m_counter)); + } + + // Если не на домашнем экране — перезапускаем if (!xmlScreenParser.isHomeScreen()) { - if (!runAppAndGoToHomeScreen(device.id, app.package, app.pinCode, device.screenWidth, device.screenHeight)) { - m_error = "Не смогли дойти до домашней страницы"; - qDebug() << m_error; + qDebug() << "[PayByPhone] Not on home screen, restarting app..."; + if (!runAppAndGoToHomeScreen(device.id, app.package, app.pinCode, + device.screenWidth, device.screenHeight)) { + m_error = "Cannot reach home screen: " + device.id; + qWarning() << m_error; + AdbUtils::tryToKillApplication(device.id, app.package); emit finishedWithResult(m_error); return; } + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.id, ++m_counter)); } - if (goToAllProducts(device.id, device.screenWidth, device.screenHeight)) { - QList > accounts = xmlScreenParser.parseAccountsInfo(); - for (auto &[account, node]: accounts) { - account.deviceId = m_account.deviceId; - account.appCode = m_account.appCode; - account.status = account.amount > 0 ? AccountStatus::Active : AccountStatus::Off; - if (!AccountDAO::upsertAccount(account)) { - qDebug() << "Not updated: " << account.lastNumbers; - } - } + // Закрываем баннеры + tryToCloseAllBannersOnHomePage(device.id, device.screenWidth, device.screenHeight); + QThread::msleep(500); + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.id, ++m_counter)); - postAccountsData(accounts); - - for (auto &[account, node]: accounts) { - // FIXME будем считать что числа последние везде уникальные - if (m_account.lastNumbers == account.lastNumbers) { - if (account.amount >= m_amount) { - AdbUtils::makeTap(device.id, node.x(), node.y()); - QThread::msleep(1000); - } else { - AdbUtils::takeScreenshot(device.id, "pay_by_phone_not_enough_money_" + QString::number(m_account.id)); - m_error = "Not enough money, id: " + QString::number(m_account.id) - + ", amount: " + QString::number(account.amount) - + ", trx amount: " + QString::number(m_amount); - qCritical() << m_error; - emit finishedWithResult(m_error); - return; - } - break; - } - } - - makePayment(device.id, app.pinCode, device.screenWidth, device.screenHeight); - // AdbUtils::tryToKillApplication(device.id, app.package); TODO - надо ли это??? - } else { - AdbUtils::takeScreenshot(device.id, "pay_by_phone_cant_open_all_products_" + QString::number(m_account.id)); - AdbUtils::tryToKillApplication(device.id, app.package); - m_error = "Не смогли открыть 'Все продукты'"; - qDebug() << m_error; + // Закрываем баннер Google Play "Доступно обновление" + if (Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(); !gpNode.isEmpty()) { + qDebug() << "[PayByPhone] Google Play update banner detected, closing..."; + AdbUtils::makeTap(device.id, gpNode.x(), gpNode.y()); + QThread::msleep(1500); + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.id, ++m_counter)); } + // Закрываем баннер "Заказать карту" — тапаем сверху экрана + if (Node orderCardNode = xmlScreenParser.findTextNode("Заказать карту"); !orderCardNode.isEmpty()) { + qDebug() << "[PayByPhone] 'Заказать карту' banner detected, closing..."; + AdbUtils::makeTap(device.id, device.screenWidth / 2, 100); + QThread::msleep(1000); + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.id, ++m_counter)); + } + + makePayment(device.id, app.pinCode, device.screenWidth, device.screenHeight); + emit finishedWithResult(m_error); } -} // namespace Ozon \ No newline at end of file +void PayByPhoneScript::makePayment( + const QString &deviceId, + const QString &pinCode, + const int width, + const int height +) { + // 1. Нажимаем "Перевести" на домашнем экране + Node transferBtn = xmlScreenParser.findButtonNode("Перевести", false); + if (transferBtn.isEmpty()) { + m_error = "Кнопка 'Перевести' не найдена на домашнем экране"; + qWarning() << "[PayByPhone]" << m_error; + return; + } + + qDebug() << "[PayByPhone] Tapping 'Перевести'..."; + AdbUtils::makeTap(deviceId, transferBtn.x(), transferBtn.y()); + QThread::msleep(2000); + + // 2. Ждём экран "Платежи и переводы" с полем "Телефон получателя" + bool paymentsPageLoaded = false; + for (int attempt = 0; attempt < 10; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + Node phoneField = xmlScreenParser.findFirstEditText(); + if (!phoneField.isEmpty()) { + paymentsPageLoaded = true; + break; + } + qDebug() << "[PayByPhone] Waiting for payments page, attempt" << attempt + 1 << "/ 10"; + QThread::msleep(1500); + } + + if (!paymentsPageLoaded) { + m_error = "Экран 'Платежи и переводы' не загрузился"; + qWarning() << "[PayByPhone]" << m_error; + return; + } + + // 3. Тапаем на поле "Телефон получателя" чтобы перейти на экран "Кому перевести" + Node phoneFieldTap = xmlScreenParser.findFirstEditText(); + qDebug() << "[PayByPhone] Tapping 'Телефон получателя'..."; + AdbUtils::makeTap(deviceId, phoneFieldTap.x(), phoneFieldTap.y()); + QThread::msleep(2000); + + // 4. Ждём экран "Кому перевести" + bool phoneScreenLoaded = false; + for (int attempt = 0; attempt < 10; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + Node title = xmlScreenParser.findTextNode("Кому перевести"); + if (!title.isEmpty()) { + phoneScreenLoaded = true; + break; + } + qDebug() << "[PayByPhone] Waiting for 'Кому перевести', attempt" << attempt + 1 << "/ 10"; + QThread::msleep(1500); + } + + if (!phoneScreenLoaded) { + m_error = "Экран 'Кому перевести' не загрузился"; + qWarning() << "[PayByPhone]" << m_error; + return; + } + + // 5. Тапаем на EditText и вводим номер телефона + Node phoneInput; + for (int attempt = 0; attempt < 10; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + phoneInput = xmlScreenParser.findFirstEditText(); + if (!phoneInput.isEmpty()) break; + qDebug() << "[PayByPhone] Waiting for phone input field, attempt" << attempt + 1 << "/ 10"; + QThread::msleep(1500); + } + if (phoneInput.isEmpty()) { + m_error = "Поле ввода телефона не найдено"; + qWarning() << "[PayByPhone]" << m_error; + return; + } + + AdbUtils::makeTap(deviceId, phoneInput.x(), phoneInput.y()); + QThread::msleep(500); + AdbUtils::inputText(deviceId, m_phone); + QThread::msleep(2000); + + // 6. Ждём экран "По номеру телефона", обрабатывая промежуточные экраны + bool sumScreenLoaded = false; + bool bankSelected = false; + bool otherBankClicked = false; + for (int attempt = 0; attempt < 20; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + + // Проверяем: уже на экране суммы? + Node sumTitle = xmlScreenParser.findTextNode("По номеру телефона"); + if (!sumTitle.isEmpty() && sumTitle.y() < 300) { + sumScreenLoaded = true; + break; + } + + // Ищем кнопку с номером телефона (новый перевод / последние переводы) + QString phoneDigits = m_phone; + phoneDigits.remove(QRegularExpression("[^0-9]")); + bool foundPhoneBtn = false; + for (const Node &btn : xmlScreenParser.findAllButtons()) { + QString btnDigits = btn.text; + btnDigits.remove(QRegularExpression("[^0-9]")); + if (btnDigits.contains(phoneDigits) || phoneDigits.contains(btnDigits.right(10))) { + qDebug() << "[PayByPhone] Found button with phone number:" << btn.text; + AdbUtils::makeTap(deviceId, btn.x(), btn.y()); + QThread::msleep(2000); + foundPhoneBtn = true; + break; + } + } + if (foundPhoneBtn) continue; + + // Экран выбора банка: ищем "Другой банк" (текст) или EditText с hint "Название банка" + if (!bankSelected) { + // Вариант 1: кнопка "Другой банк" (content-desc="other-bank-item", text пустой) + bool foundOtherBank = false; + { + Node node; + for (const Node &n : xmlScreenParser.findAllNodes()) { + if (n.contentDesc.contains("other-bank-item")) { + node = n; + break; + } + } + if (!node.isEmpty()) { + qDebug() << "[PayByPhone] Tapping 'Другой банк' (other-bank-item)..."; + AdbUtils::makeTap(deviceId, node.x(), node.y()); + QThread::msleep(2000); + foundOtherBank = true; + otherBankClicked = true; + } + } + if (foundOtherBank) continue; + + // Вариант 2: уже на экране поиска банка (EditText) — только после клика на "Другой банк" + if (otherBankClicked) { + Node bankInput = xmlScreenParser.findFirstEditText(); + if (!bankInput.isEmpty()) { + qDebug() << "[PayByPhone] Bank search screen detected, entering bank name..."; + AdbUtils::makeTap(deviceId, bankInput.x(), bankInput.y()); + QThread::msleep(500); + + // Ввод кириллицы через ADB Keyboard + AdbUtils::enableAdbIMEKeyboard(deviceId); + QThread::msleep(500); + AdbUtils::makeTap(deviceId, bankInput.x(), bankInput.y()); + QThread::msleep(500); + AdbUtils::inputAdbIMEText(deviceId, m_bankName); + QThread::msleep(2000); + AdbUtils::disableAdbIMEKeyboard(deviceId); + qDebug() << "[PayByPhone] Entered bank name via ADB IME:" << m_bankName; + QThread::msleep(2000); + + // Ищем банк в списке и кликаем по нему (кнопка с числовым content-desc) + bool bankFound = false; + for (int ba = 0; ba < 10; ++ba) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + for (const Node &node : xmlScreenParser.findAllNodes()) { + if (node.className == "android.widget.Button" + && !node.contentDesc.isEmpty() + && node.contentDesc != "base-go-back-button" + && node.contentDesc != "native-toolbar-close-button" + && node.contentDesc != "clear-input-icon") { + qDebug() << "[PayByPhone] Found bank in list, tapping:" << node.contentDesc << "bounds:" << node.x() << node.y(); + AdbUtils::makeTap(deviceId, node.x(), node.y()); + bankFound = true; + break; + } + } + if (bankFound) break; + qDebug() << "[PayByPhone] Waiting for bank list, attempt" << ba + 1 << "/ 10"; + QThread::msleep(1500); + } + + if (!bankFound) { + m_error = "Банк '" + m_bankName + "' не найден в списке"; + qWarning() << "[PayByPhone]" << m_error; + return; + } + + bankSelected = true; + QThread::msleep(2000); + continue; + } + } // otherBankClicked + } + + qDebug() << "[PayByPhone] Waiting for 'По номеру телефона', attempt" << attempt + 1 << "/ 20"; + QThread::msleep(1500); + } + + if (!sumScreenLoaded) { + m_error = "Экран ввода суммы не загрузился"; + qWarning() << "[PayByPhone]" << m_error; + return; + } + + // 7. Вводим сумму + Node sumInput = xmlScreenParser.findFirstEditText(); + if (sumInput.isEmpty()) { + m_error = "Поле ввода суммы не найдено"; + qWarning() << "[PayByPhone]" << m_error; + return; + } + + AdbUtils::makeTap(deviceId, sumInput.x(), sumInput.y()); + QThread::msleep(500); + AdbUtils::inputText(deviceId, QString::number(static_cast(m_amount))); + QThread::msleep(1000); + + // 6. Нажимаем "Продолжить" + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + Node continueBtn = xmlScreenParser.findButtonNode("Продолжить", false); + if (continueBtn.isEmpty()) { + m_error = "Кнопка 'Продолжить' не найдена"; + qWarning() << "[PayByPhone]" << m_error; + return; + } + + AdbUtils::makeTap(deviceId, continueBtn.x(), continueBtn.y()); + QThread::msleep(2000); + + // 7. Ждём экран "Подтвердите перевод" с кнопкой "Перевести ..." + bool confirmScreenLoaded = false; + for (int attempt = 0; attempt < 10; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + Node confirmTitle = xmlScreenParser.findTextNode("Подтвердите перевод"); + if (!confirmTitle.isEmpty()) { + confirmScreenLoaded = true; + break; + } + qDebug() << "[PayByPhone] Waiting for 'Подтвердите перевод', attempt" << attempt + 1 << "/ 10"; + QThread::msleep(1500); + } + + if (!confirmScreenLoaded) { + m_error = "Экран подтверждения не загрузился"; + qWarning() << "[PayByPhone]" << m_error; + return; + } + + qDebug() << "[PayByPhone] Confirm screen loaded, stopping before confirmation"; +} + +} // namespace Ozon diff --git a/android/ozon/ScreenXmlParser.cpp b/android/ozon/ScreenXmlParser.cpp index 197373e..faef0c8 100644 --- a/android/ozon/ScreenXmlParser.cpp +++ b/android/ozon/ScreenXmlParser.cpp @@ -157,6 +157,20 @@ Node ScreenXmlParser::findButtonNode(const QString &text, const bool contains) { return {}; } +QList ScreenXmlParser::findAllButtons() { + QList buttons; + for (const Node &node : m_nodes) { + if (node.className == "android.widget.Button" && !node.text.isEmpty()) { + buttons.append(node); + } + } + return buttons; +} + +const QList &ScreenXmlParser::findAllNodes() const { + return m_nodes; +} + Node ScreenXmlParser::findNodeByContentDesc(const QString &contentDesc) { for (const Node &node: m_nodes) { if (node.clickable && node.contentDesc.trimmed() == contentDesc) { @@ -198,6 +212,25 @@ Node ScreenXmlParser::tryToFindAdBanner() { return {}; } +Node ScreenXmlParser::tryToFindGooglePlayBanner() { + // Баннер Google Play "Доступно обновление" — ищем кнопку закрытия по content-desc + for (const Node &node : m_nodes) { + if (node.contentDesc.contains("Закрыть диалоговое окно") && node.clickable) { + return node; + } + } + // Альтернативно — по content-desc "Доступно обновление" + for (const Node &node : m_nodes) { + if (node.contentDesc.contains("Доступно обновление")) { + // Тапаем в верхнюю часть экрана (выше баннера), чтобы закрыть + Node tapNode; + tapNode.setBounds(540, 100, 541, 101); + return tapNode; + } + } + return {}; +} + bool ScreenXmlParser::isProfileScreen() { // Страница профиля Ozon: есть заголовок "Аккаунт" и кнопка "Выйти" bool hasTitle = false; @@ -1223,6 +1256,110 @@ QString ScreenXmlParser::findFullCardNumber() { return {}; } +bool ScreenXmlParser::isTransactionListScreen() { + // Экран "Операции": WebView с text="Операции" + наличие кнопок фильтров + for (const Node &node : m_nodes) { + if (node.className == "android.webkit.WebView" && node.text.trimmed() == "Операции") { + return true; + } + } + return false; +} + +QList ScreenXmlParser::findTransactionButtons(const int maxCount) { + const QChar rubleSign(0x20BD); // ₽ + QList buttons; + for (const Node &node : m_nodes) { + if (node.className != "android.widget.Button") continue; + const QString text = node.text.trimmed(); + if (!text.contains(rubleSign)) continue; + // Пропускаем сводки "Расходы", "Доходы" + if (text.startsWith("Расходы") || text.startsWith("Доходы")) continue; + buttons.append(node); + if (buttons.size() >= maxCount) break; + } + return buttons; +} + +TransactionInfo ScreenXmlParser::parseTransactionButtonText(const QString &text) { + const QChar rubleSign(0x20BD); // ₽ + const QChar unicodeMinus(0x2212); // − + const QChar nbsp(0x00A0); // non-breaking space + + TransactionInfo info; + + const int rubleIdx = text.indexOf(rubleSign); + if (rubleIdx < 0) return info; + + int signIdx = -1; + bool isExpense = false; + for (int i = rubleIdx - 1; i >= 0; --i) { + const QChar ch = text.at(i); + if (ch == '+') { signIdx = i; isExpense = false; break; } + if (ch == '-' || ch == unicodeMinus) { signIdx = i; isExpense = true; break; } + } + if (signIdx < 0) return info; + + info.description = text.left(signIdx).trimmed(); + + QString amountStr = text.mid(signIdx + 1, rubleIdx - signIdx - 1).trimmed(); + amountStr.remove(QChar(' ')); + amountStr.remove(nbsp); + amountStr.replace(',', '.'); + info.amount = amountStr.toDouble(); + if (isExpense) info.amount = -info.amount; + + return info; +} + +TransactionInfo ScreenXmlParser::parseTransactionDetail() { + TransactionInfo info; + + for (const Node &node : m_nodes) { + if (node.className != "android.widget.TextView") continue; + const QString text = node.text.trimmed(); + if (text.isEmpty()) continue; + + // Дата+время: "15.01.2026, 15:55" + if (info.bankTime.isEmpty() && text.contains('.') && text.contains(':') && text.length() <= 25) { + info.bankTime = text; + continue; + } + + // Телефон: "+7 (985) 553-28-96" + if (info.phone.isEmpty() && text.startsWith('+') && text.contains('(')) { + info.phone = text; + continue; + } + } + + // Имя отправителя — TextView прямо перед телефоном (по y-координате чуть выше) + if (!info.phone.isEmpty()) { + // Ищем ноду телефона и берём ближайший TextView выше + int phoneY = -1; + for (const Node &node : m_nodes) { + if (node.className == "android.widget.TextView" && node.text.trimmed() == info.phone) { + phoneY = node.y(); + break; + } + } + if (phoneY > 0) { + int bestY = -1; + for (const Node &node : m_nodes) { + if (node.className != "android.widget.TextView") continue; + const QString text = node.text.trimmed(); + if (text.isEmpty() || text == info.bankTime || text == info.phone) continue; + if (node.y() > 0 && node.y() < phoneY && node.y() > bestY) { + bestY = node.y(); + info.name = text; + } + } + } + } + + return info; +} + void ScreenXmlParser::test() { // TransactionInfo info2 = parseExpandedInfo(m_xml); // qDebug().noquote().nospace() << convertTransactionToString(info2); diff --git a/android/ozon/ScreenXmlParser.h b/android/ozon/ScreenXmlParser.h index b484567..8656493 100644 --- a/android/ozon/ScreenXmlParser.h +++ b/android/ozon/ScreenXmlParser.h @@ -30,12 +30,18 @@ public: Node findButtonNode(const QString &text, bool contains); + QList findAllButtons(); + + const QList &findAllNodes() const; + Node findNodeByContentDesc(const QString &contentDesc); bool isHomeScreen(); Node tryToFindAdBanner(); + Node tryToFindGooglePlayBanner(); + // Returns a Button node whose text is exactly 4 digits (card last numbers) Node findCardButtonOnHomeScreen(); @@ -73,6 +79,17 @@ public: // Finds a TextView with full card number (format "XXXX XXXX XXXX XXXX") QString findFullCardNumber(); + bool isTransactionListScreen(); + + // Возвращает кнопки транзакций на экране "Операции" (до maxCount штук) + QList findTransactionButtons(int maxCount); + + // Парсит имя и сумму из текста кнопки: "NAME + 150 ₽ CATEGORY" + TransactionInfo parseTransactionButtonText(const QString &text); + + // Парсит детали транзакции: дату, имя отправителя, телефон + TransactionInfo parseTransactionDetail(); + void test(); private: diff --git a/assets/ozon/card_banner.png b/assets/ozon/card_banner.png new file mode 100644 index 0000000..78622d9 Binary files /dev/null and b/assets/ozon/card_banner.png differ diff --git a/assets/ozon/card_banner.xml b/assets/ozon/card_banner.xml new file mode 100644 index 0000000..c015962 --- /dev/null +++ b/assets/ozon/card_banner.xml @@ -0,0 +1,746 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/ozon/google_play.png b/assets/ozon/google_play.png new file mode 100644 index 0000000..427dd62 Binary files /dev/null and b/assets/ozon/google_play.png differ diff --git a/assets/ozon/google_play.xml b/assets/ozon/google_play.xml new file mode 100644 index 0000000..2cc0562 --- /dev/null +++ b/assets/ozon/google_play.xml @@ -0,0 +1,283 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/ozon/pay/pay_buy_phone.xml b/assets/ozon/pay/pay_buy_phone.xml index 3c7a8fc..3aff34d 100644 --- a/assets/ozon/pay/pay_buy_phone.xml +++ b/assets/ozon/pay/pay_buy_phone.xml @@ -1 +1,512 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/ozon/pay/pay_buy_phone_enter_sum.xml b/assets/ozon/pay/pay_buy_phone_enter_sum.xml index 6de42d5..9be6d28 100644 --- a/assets/ozon/pay/pay_buy_phone_enter_sum.xml +++ b/assets/ozon/pay/pay_buy_phone_enter_sum.xml @@ -1 +1,641 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/ozon/pay/pay_to_new_number.png b/assets/ozon/pay/pay_to_new_number.png new file mode 100644 index 0000000..a4629b3 Binary files /dev/null and b/assets/ozon/pay/pay_to_new_number.png differ diff --git a/assets/ozon/pay/pay_to_new_number.xml b/assets/ozon/pay/pay_to_new_number.xml new file mode 100644 index 0000000..15ed612 --- /dev/null +++ b/assets/ozon/pay/pay_to_new_number.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ozon/pay/pay_to_old_number.png b/assets/ozon/pay/pay_to_old_number.png new file mode 100644 index 0000000..7d9553a Binary files /dev/null and b/assets/ozon/pay/pay_to_old_number.png differ diff --git a/assets/ozon/pay/pay_to_old_number.xml b/assets/ozon/pay/pay_to_old_number.xml new file mode 100644 index 0000000..7c22ca8 --- /dev/null +++ b/assets/ozon/pay/pay_to_old_number.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ozon/payments_page.xml b/assets/ozon/payments_page.xml index 6157f6a..72c8cb5 100644 --- a/assets/ozon/payments_page.xml +++ b/assets/ozon/payments_page.xml @@ -1 +1,1492 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/ozon/select_bank.png b/assets/ozon/select_bank.png new file mode 100644 index 0000000..7840c19 Binary files /dev/null and b/assets/ozon/select_bank.png differ diff --git a/assets/ozon/select_bank.xml b/assets/ozon/select_bank.xml new file mode 100644 index 0000000..a149a99 --- /dev/null +++ b/assets/ozon/select_bank.xml @@ -0,0 +1,518 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/ozon/transaction_banner.png b/assets/ozon/transaction_banner.png new file mode 100644 index 0000000..af62a55 Binary files /dev/null and b/assets/ozon/transaction_banner.png differ diff --git a/assets/ozon/transaction_banner.xml b/assets/ozon/transaction_banner.xml new file mode 100644 index 0000000..fece037 --- /dev/null +++ b/assets/ozon/transaction_banner.xml @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/ozon/transaction_document.png b/assets/ozon/transaction_document.png new file mode 100644 index 0000000..00281a0 Binary files /dev/null and b/assets/ozon/transaction_document.png differ diff --git a/assets/ozon/transaction_document.xml b/assets/ozon/transaction_document.xml new file mode 100644 index 0000000..9196524 --- /dev/null +++ b/assets/ozon/transaction_document.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/ozon/transaction_info.png b/assets/ozon/transaction_info.png new file mode 100644 index 0000000..5c916f5 Binary files /dev/null and b/assets/ozon/transaction_info.png differ diff --git a/assets/ozon/transaction_info.xml b/assets/ozon/transaction_info.xml new file mode 100644 index 0000000..f0589ac --- /dev/null +++ b/assets/ozon/transaction_info.xml @@ -0,0 +1,502 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/ozon/transaction_info_spb.png b/assets/ozon/transaction_info_spb.png new file mode 100644 index 0000000..7e6f092 Binary files /dev/null and b/assets/ozon/transaction_info_spb.png differ diff --git a/assets/ozon/transaction_info_spb.xml b/assets/ozon/transaction_info_spb.xml new file mode 100644 index 0000000..b1e79e9 --- /dev/null +++ b/assets/ozon/transaction_info_spb.xml @@ -0,0 +1,509 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/config.ini b/config.ini index 1865920..9437b48 100644 --- a/config.ini +++ b/config.ini @@ -2,13 +2,13 @@ list=ozon [common] -access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjNDU1MjE1Ni1kMTc5LTRhNzktOWU5MS01ODcwMzhjOTVjZjAiLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcyMjQ4MzM1fQ.gDA57WCWCCwdrxa84McLJLcIyV4RYLRQ7s9YT3AcFd4 +access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjNDU1MjE1Ni1kMTc5LTRhNzktOWU5MS01ODcwMzhjOTVjZjAiLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcyNDE4MDUwfQ.26k6Z22gMOzNFuS17Mx0d4dt8pnJQh9TIv0rrAk2Xro api_key=0eee77b62013253e02e1a69bc6a505bb37757fb7 currencies=USD, KRW, AZN, RUB desktop_id=b86eac8e-dc89-4a9e-a5e8-7f85db011391 profile_id=c4552156-d179-4a79-9e91-587038c95cf0 -refresh_token=2cdf2bd7-9d5f-463f-98f4-fce79a9ee9a4 -token_expires_at=1772248335 +refresh_token=ff49cde7-4f3a-4efa-ad46-84514fc03bf6 +token_expires_at=1772418050 version=0.0.1 [db] diff --git a/database/DatabaseManager.cpp b/database/DatabaseManager.cpp index 19990cc..2b24146 100644 --- a/database/DatabaseManager.cpp +++ b/database/DatabaseManager.cpp @@ -271,6 +271,35 @@ void DatabaseManager::initSchema() { q.exec("PRAGMA user_version = 7"); qDebug() << "[DB] Migration 7 applied"; } + + // ── Версия 8: receipt_image в transactions ────────────────────────────────── + if (version < 8) { + db.transaction(); + + if (!columnExists(q, "transactions", "receipt_image")) { + q.exec("ALTER TABLE transactions ADD COLUMN receipt_image BLOB"); + } + + db.commit(); + q.exec("PRAGMA user_version = 8"); + qDebug() << "[DB] Migration 8 applied"; + } + + // ── Версия 9: таблица banks ─────────────────────────────────────────────── + if (version < 9) { + db.transaction(); + + q.exec(R"(CREATE TABLE IF NOT EXISTS banks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + alias TEXT UNIQUE, + name TEXT, + nspk_name TEXT + ))"); + + db.commit(); + q.exec("PRAGMA user_version = 9"); + qDebug() << "[DB] Migration 9 applied"; + } } void DatabaseManager::closeConnection() { diff --git a/database/app_data.db b/database/app_data.db index 63fe766..9b787cd 100644 Binary files a/database/app_data.db and b/database/app_data.db differ diff --git a/database/dao/AccountDAO.cpp b/database/dao/AccountDAO.cpp index d8e736b..1e0303e 100644 --- a/database/dao/AccountDAO.cpp +++ b/database/dao/AccountDAO.cpp @@ -167,6 +167,30 @@ AccountInfo AccountDAO::getAccountById(const int accountId) { return {}; } +QList AccountDAO::getAccountsByAppCode(const QString &appCode) { + QList accounts; + QSqlQuery query(DatabaseManager::instance().database()); + + query.prepare(R"( + SELECT id, device_id, app_code, last_numbers, card_number, material_id, amount, update_time, status, description, currency + FROM accounts + WHERE app_code = :app_code + )"); + + query.bindValue(":app_code", appCode); + + if (!query.exec()) { + qWarning() << "Ошибка при получении accounts по app_code:" << query.lastError().text(); + return accounts; + } + + while (query.next()) { + accounts.append(parseAccount(query)); + } + + return accounts; +} + AccountInfo AccountDAO::findAppAccount(const QString &deviceId, const QString &appName, const QString &lastNumber) { QSqlQuery query(DatabaseManager::instance().database()); @@ -192,3 +216,27 @@ AccountInfo AccountDAO::findAppAccount(const QString &deviceId, const QString &a return {}; } + +AccountInfo AccountDAO::findByMaterialId(const QString &materialId) { + QSqlQuery query(DatabaseManager::instance().database()); + + query.prepare(R"( + SELECT id, device_id, app_code, last_numbers, card_number, material_id, amount, update_time, status, description, currency + FROM accounts + WHERE material_id = :material_id + LIMIT 1 + )"); + + query.bindValue(":material_id", materialId); + + if (!query.exec()) { + qWarning() << "Ошибка при поиске account по material_id:" << query.lastError().text(); + return {}; + } + + if (query.next()) { + return parseAccount(query); + } + + return {}; +} diff --git a/database/dao/AccountDAO.h b/database/dao/AccountDAO.h index f869d47..70b1d03 100644 --- a/database/dao/AccountDAO.h +++ b/database/dao/AccountDAO.h @@ -22,9 +22,13 @@ public: [[nodiscard]] static AccountInfo getAccountById(int accountId); + [[nodiscard]] static QList getAccountsByAppCode(const QString &appCode); + [[nodiscard]] static AccountInfo findAppAccount( const QString &deviceId, const QString &appName, const QString &lastNumber ); + + [[nodiscard]] static AccountInfo findByMaterialId(const QString &materialId); }; diff --git a/database/dao/BankDAO.cpp b/database/dao/BankDAO.cpp new file mode 100644 index 0000000..bb9d5bf --- /dev/null +++ b/database/dao/BankDAO.cpp @@ -0,0 +1,55 @@ +#include "BankDAO.h" + +#include +#include +#include + +#include "DatabaseManager.h" + +QList BankDAO::getAll() { + QList list; + QSqlQuery query(DatabaseManager::instance().database()); + + query.prepare("SELECT * FROM banks ORDER BY alias"); + + if (!query.exec()) { + qWarning() << "Ошибка при получении banks:" << query.lastError().text(); + return list; + } + + while (query.next()) { + BankInfo bank; + bank.id = query.value("id").toInt(); + bank.alias = query.value("alias").toString(); + bank.name = query.value("name").toString(); + bank.nspkName = query.value("nspk_name").toString(); + list.append(bank); + } + + return list; +} + +void BankDAO::replaceAll(const QList &banks) { + QSqlDatabase db = DatabaseManager::instance().database(); + QSqlQuery query(db); + + db.transaction(); + + query.exec("DELETE FROM banks"); + + query.prepare(R"( + INSERT INTO banks (alias, name, nspk_name) + VALUES (:alias, :name, :nspk_name) + )"); + + for (const BankInfo &bank : banks) { + query.bindValue(":alias", bank.alias); + query.bindValue(":name", bank.name); + query.bindValue(":nspk_name", bank.nspkName); + if (!query.exec()) { + qWarning() << "Ошибка при вставке bank:" << query.lastError().text(); + } + } + + db.commit(); +} diff --git a/database/dao/BankDAO.h b/database/dao/BankDAO.h new file mode 100644 index 0000000..dbe2f8b --- /dev/null +++ b/database/dao/BankDAO.h @@ -0,0 +1,11 @@ +#pragma once + +#include "db/BankInfo.h" +#include + +class BankDAO { +public: + [[nodiscard]] static QList getAll(); + + static void replaceAll(const QList &banks); +}; diff --git a/database/dao/EventDAO.cpp b/database/dao/EventDAO.cpp index 7484362..e046c15 100644 --- a/database/dao/EventDAO.cpp +++ b/database/dao/EventDAO.cpp @@ -11,7 +11,7 @@ EventInfo parseEvent(const QSqlQuery &query) { event.id = query.value("id").toInt(); event.status = eventStatusFromString(query.value("status").toString()); event.type = eventTypeFromString(query.value("type").toString()); - event.externalId = query.value("external_id").toInt(); + event.externalId = query.value("external_id").toString(); event.accountId = query.value("account_id").toInt(); event.deviceId = query.value("device_id").toString(); event.amount = query.value("amount").toDouble(); @@ -146,3 +146,20 @@ EventInfo EventDAO::getFirstWaitEventByDeviceId(const QString &key) { } return {}; } + +bool EventDAO::existsActiveByExternalId(const QString &externalId) { + QSqlQuery query(DatabaseManager::instance().database()); + query.prepare(R"( + SELECT 1 FROM events + WHERE external_id = :external_id + AND status IN ('wait', 'iPprogress') + LIMIT 1 + )"); + query.bindValue(":external_id", externalId); + + if (!query.exec()) { + qCritical() << "Ошибка при existsActiveByExternalId:" << query.lastError().text(); + return false; + } + return query.next(); +} diff --git a/database/dao/EventDAO.h b/database/dao/EventDAO.h index 2465cb1..d104819 100644 --- a/database/dao/EventDAO.h +++ b/database/dao/EventDAO.h @@ -15,4 +15,6 @@ public: [[nodiscard]] static QList getAllEvents(const EventStatus &status); [[nodiscard]] static EventInfo getFirstWaitEventByDeviceId(const QString &key); + + [[nodiscard]] static bool existsActiveByExternalId(const QString &externalId); }; diff --git a/database/dao/TransactionDAO.cpp b/database/dao/TransactionDAO.cpp index f3d325a..d73898d 100644 --- a/database/dao/TransactionDAO.cpp +++ b/database/dao/TransactionDAO.cpp @@ -16,17 +16,19 @@ bool TransactionDAO::insertTransactionIfNotExists(const TransactionInfo &info) { SELECT COUNT(*) FROM transactions WHERE account_id = :account_id AND amount = :amount AND timestamp = :timestamp AND type = :type )"); + query.bindValue(":timestamp", DateUtils::toUtcString(info.timestamp)); } else { query.prepare(R"( SELECT COUNT(*) FROM transactions - WHERE account_id = :account_id AND amount = :amount AND bankTime = :bankTime AND type = :type + WHERE account_id = :account_id AND amount = :amount AND bank_time = :bank_time AND bank_name = :bank_name )"); + query.bindValue(":bank_time", info.bankTime); + query.bindValue(":bank_name", info.bankName); } query.bindValue(":account_id", info.accountId); query.bindValue(":amount", info.amount); query.bindValue(":type", transactionTypeToString(info.type)); - query.bindValue(":timestamp", DateUtils::toUtcString(info.timestamp)); if (!query.exec() || !query.next()) { qWarning() << "Ошибка при проверке существования transaction:" << query.lastError().text(); @@ -140,6 +142,7 @@ TransactionInfo parseTransaction(const QSqlQuery &query) { tx.updateTime = DateUtils::fromUtcString(query.value("update_time").toString()); tx.completeTime = DateUtils::fromUtcString(query.value("complete_time").toString()); tx.timestamp = DateUtils::fromUtcString(query.value("timestamp").toString()); + tx.receiptImage = query.value("receipt_image").toByteArray(); return tx; } @@ -344,3 +347,37 @@ bool TransactionDAO::updateExternalTransactionId(const int id, const int externa } return true; } + +bool TransactionDAO::updateReceiptImage(const int id, const QByteArray &image) { + QSqlQuery query(DatabaseManager::instance().database()); + query.prepare("UPDATE transactions SET receipt_image = :image WHERE id = :id"); + query.bindValue(":id", id); + query.bindValue(":image", image); + + if (!query.exec()) { + qWarning() << "Ошибка при сохранении receipt_image:" << query.lastError().text(); + return false; + } + return true; +} + +TransactionInfo TransactionDAO::findByBankTimeAndAmount( + const int accountId, const QString &bankName, const QString &bankTime, const double amount +) { + QSqlQuery query(DatabaseManager::instance().database()); + query.prepare(R"( + SELECT * FROM transactions + WHERE account_id = :account_id AND bank_name = :bank_name + AND bank_time = :bank_time AND amount = :amount + LIMIT 1 + )"); + query.bindValue(":account_id", accountId); + query.bindValue(":bank_name", bankName); + query.bindValue(":bank_time", bankTime); + query.bindValue(":amount", amount); + + if (!query.exec() || !query.next()) { + return {}; + } + return parseTransaction(query); +} diff --git a/database/dao/TransactionDAO.h b/database/dao/TransactionDAO.h index 3ac2321..18cacfb 100644 --- a/database/dao/TransactionDAO.h +++ b/database/dao/TransactionDAO.h @@ -26,4 +26,10 @@ public: ); [[nodiscard]] static bool updateExternalTransactionId(int id, int externalId); + + [[nodiscard]] static bool updateReceiptImage(int id, const QByteArray &image); + + // Найти транзакцию по bank_name + bank_time + amount + [[nodiscard]] static TransactionInfo findByBankTimeAndAmount( + int accountId, const QString &bankName, const QString &bankTime, double amount); }; diff --git a/docs/api/auth.md b/docs/api/auth.md new file mode 100644 index 0000000..32b58a3 --- /dev/null +++ b/docs/api/auth.md @@ -0,0 +1,65 @@ +# Auth API + +[← Главная](../index.md) + +Авторизация через API-ключ и управление токенами. + +Все защищённые эндпоинты требуют заголовок: +``` +Authorization: Bearer +``` + +--- + +## POST `/api/v1/auth/login` + +Получение токена по API-ключу. + +**Тело запроса:** +```json +{ + "api_key": "string" +} +``` + +**Ответ:** +```json +{ + "success": true, + "data": { + "id": "uuid", + "access_token": "string", + "refresh_token": "string", + "expires_in": 3600.0 + } +} +``` + +--- + +## POST `/api/v1/auth/refresh_token` + +Обновление access_token по refresh_token. + +**Тело запроса:** +```json +{ + "refresh_token": "string" +} +``` + +**Ответ:** аналогичен `/login`. + +--- + +## Хранение токенов в проекте + +Токены сохраняются в `config.ini`: + +| Ключ | Значение | +|------|----------| +| `common/access_token` | Bearer-токен для запросов | +| `common/refresh_token` | Для обновления | +| `common/token_expires_at` | Unix timestamp истечения | + +Рефреш токена происходит автоматически за 30 минут до истечения — см. `NetworkService::scheduleNextRefresh()`. diff --git a/docs/api/device.md b/docs/api/device.md new file mode 100644 index 0000000..0dd3466 --- /dev/null +++ b/docs/api/device.md @@ -0,0 +1,111 @@ +# Device & Desktop API + +[← Главная](../index.md) + +Управление десктопами (установками приложения) и устройствами (подключёнными Android телефонами). + +--- + +## Desktop + +Desktop — это одна установка ARCDeskProject. Каждый desktop содержит список устройств. + +### GET `/api/v1/desktop/` 🔒 + +Получить все десктопы профиля. + +### POST `/api/v1/desktop/` 🔒 + +Зарегистрировать новый десктоп. + +**Тело запроса:** +```json +{ + "blue_token": "string", + "name": "Мой компьютер", + "contact": "string", + "app_version": "1.0.0" +} +``` + +**Ответ** содержит `id` (UUID) и `unique_name`. + +### PATCH `/api/v1/desktop/{desktop_id}` 🔒 + +Обновить данные десктопа (версию, session_time и т.д.). + +--- + +## Device + +Device — физическое Android-устройство, подключённое к десктопу через ADB. + +### GET `/api/v1/device/{desktop_id}` 🔒 + +Получить все устройства десктопа. + +### POST `/api/v1/device/` 🔒 + +Зарегистрировать новое устройство. + +**Тело запроса:** +```json +{ + "desktop_id": "uuid", + "status": "ONLINE", + "name": "Pixel 6", + "screen_width": 1080, + "screen_height": 2400, + "battery": 85 +} +``` + +**`DeviceStatus`:** `"ONLINE"` | `"OFFLINE"` + +**Ответ** содержит `id` (UUID) — сохраняется как `api_id` в таблице `devices`. + +### PATCH `/api/v1/device/{device_id}` 🔒 + +Обновить статус, battery, update_time устройства. + +```json +{ + "status": "ONLINE", + "battery": 72, + "update_time": 1700000000 +} +``` + +### POST `/api/v1/device/add_screenshot/{device_id}` 🔒 + +Загрузить скриншот экрана устройства. + +**Тип запроса:** `multipart/form-data` + +| Поле | Тип | Описание | +|------|-----|----------| +| `file` | binary | JPEG-скриншот | + +**Ответ:** +```json +{ + "success": true, + "data": { + "id": "uuid", + "device_id": "uuid", + "screenshot_url": "https://...", + "taken_at": "2024-01-01T12:00:00" + } +} +``` + +--- + +## Связь с кодом проекта + +| API | Метод в коде | +|-----|-------------| +| `POST /api/v1/desktop/` | `NetworkService::loginAndSetup()` | +| `POST /api/v1/device/` | `NetworkService::createDevice()` | +| `PATCH /api/v1/device/{id}` | `NetworkService::updateDevice()` | +| `POST /api/v1/device/add_screenshot/{id}` | `NetworkService::postDeviceScreenshot()` | diff --git a/docs/api/profile.md b/docs/api/profile.md new file mode 100644 index 0000000..c544704 --- /dev/null +++ b/docs/api/profile.md @@ -0,0 +1,127 @@ +# Profile API + +[← Главная](../index.md) + +Управление профилем, банковскими профилями и материалами (картами). + +--- + +## GET `/api/v1/profile/current_profile` 🔒 + +Получить текущий профиль со всеми банковскими профилями. + +**Ответ:** +```json +{ + "success": true, + "data": { + "id": "uuid", + "api_key": "string", + "bank_profiles": [ + { + "id": "uuid", + "bank_profile_external_id": 1, + "first_name": "string", + "last_name": "string", + "middle_name": "string", + "bank_name": "ozon", + "phone_number": "+7...", + "balance": "15000", + "state": "active", + "currency": 643, + "profile_id": "uuid", + "materials": [ ... ], + "created_at": "2024-01-01T00:00:00", + "updated_at": "2024-01-01T00:00:00" + } + ] + } +} +``` + +--- + +## POST `/api/v1/profile/fetch_bank_profiles` 🔒 + +Получить список всех банковских профилей. + +--- + +## POST `/api/v1/profile/fetch_materials` 🔒 + +Получить список всех материалов (карт). + +--- + +## Bank Profile + +### POST `/api/v1/profile/bank_profile` 🔒 + +Создать банковский профиль. + +**Тело запроса:** +```json +{ + "first_name": "Иван", + "last_name": "Иванов", + "middle_name": "Иванович", + "bank_name": "ozon", + "phone_number": "+79001234567", + "balance": "15000", + "state": "active", + "currency": 643 +} +``` + +**Ответ** содержит `id` (UUID) — сохраняется как `bank_profile_id` в таблице `applications`. + +### PATCH `/api/v1/profile/bank_profile/{bank_profile_id}` 🔒 + +Обновить данные банковского профиля (любые поля опциональны). + +### PATCH `/api/v1/profile/bank_profile/{bank_profile_id}/{is_enable}` 🔒 + +Включить (`true`) / отключить (`false`) банковский профиль. + +--- + +## Material (карта) + +Material — это карта/счёт внутри банковского профиля. + +### POST `/api/v1/profile/material` 🔒 + +Создать материал. + +**Тело запроса:** +```json +{ + "bank_profile_id": "uuid", + "card_number": "1234567890123456", + "name": "Основная карта" +} +``` + +**Ответ** содержит `id` (UUID) и `external_material_id` (int) — сохраняется как `material_id` в таблице `accounts`. + +### PATCH `/api/v1/profile/material/{material_id}` 🔒 + +Обновить номер карты материала. + +```json +{ "card_number": "1234567890123456" } +``` + +### PATCH `/api/v1/profile/material/{material_id}/{is_enable}` 🔒 + +Включить / отключить материал. + +--- + +## Связь с кодом проекта + +| API | Метод в коде | +|-----|-------------| +| `POST /api/v1/profile/bank_profile` | `NetworkService::postBankProfile()` | +| `PATCH /api/v1/profile/bank_profile/{id}` | `NetworkService::patchBankProfile()` | +| `POST /api/v1/profile/material` | `NetworkService::postMaterial()` | diff --git a/docs/api/task.md b/docs/api/task.md new file mode 100644 index 0000000..5ff6d04 --- /dev/null +++ b/docs/api/task.md @@ -0,0 +1,181 @@ +# Task API + +[← Главная](../index.md) + +Управление задачами: получение очереди, добавление задач, отправка результатов. + +--- + +## GET `/api/v1/task/get_tasks` 🔒 + +Получить список задач в очереди для текущего профиля. + +**Ответ:** +```json +{ + "success": true, + "data": [ + { + "bank_name": "ozon", + "material_id": 42, + "type": "FETCH_PROFILE", + "body": {} + }, + { + "bank_name": "birbank", + "material_id": 7, + "type": "FETCH_TRANSACTIONS", + "body": {} + }, + { + "bank_name": "ozon", + "material_id": 5, + "type": "CREATE_TRANSACTION", + "body": { + "amount": 1000, + "currency": 643, + "type": "bank", + "bank_participator_id": "string", + "extra": {} + } + } + ] +} +``` + +**Типы задач:** + +| `type` | Описание | `body` | +|--------|----------|--------| +| `FETCH_PROFILE` | Получить баланс и данные аккаунта | пустой `{}` | +| `FETCH_TRANSACTIONS` | Получить историю транзакций | пустой `{}` | +| `CREATE_TRANSACTION` | Выполнить перевод | `CreateTransactionBody` | + +**`CreateTransactionBody`:** +```json +{ + "amount": 1000, + "currency": 643, + "type": "bank", + "bank_participator_id": "string", + "extra": {} +} +``` + +- `currency` — ISO 4217 numeric код валюты (643 = RUB, 840 = USD, 944 = AZN, 410 = KRW) +- `type` — `"bank"` или `"top-up"` + +--- + +## POST `/api/v1/task/task_result` 🔒 + +Отправить результат выполненной задачи. + +Тело запроса зависит от типа задачи (discriminator — поле `type`). + +### FETCH_PROFILE + +```json +{ + "type": "FETCH_PROFILE", + "material_id": 42, + "bank_name": "ozon", + "data": { + "bank_account_id": "string", + "currency": 643, + "amount": 15000, + "extra": {} + } +} +``` + +### FETCH_TRANSACTIONS + +```json +{ + "type": "FETCH_TRANSACTIONS", + "material_id": 7, + "bank_name": "birbank", + "data": [ + { + "bank_transaction_id": "string", + "amount": 500, + "currency": 944, + "transaction_type": "bank", + "bank_participator_id": "string", + "status": "completed", + "extra": {}, + "created_at": "2024-01-01T12:00:00" + } + ] +} +``` + +### CREATE_TRANSACTION + +```json +{ + "type": "CREATE_TRANSACTION", + "material_id": 5, + "bank_name": "ozon", + "data": { + "bank_transaction_id": "string", + "amount": 1000, + "currency": 643, + "transaction_type": "bank", + "bank_participator_id": "string", + "status": "completed", + "extra": {} + } +} +``` + +**`TransactionStatusEnum`:** `"progress"` | `"completed"` | `"declined"` + +--- + +## POST `/api/v1/task/add_task` + +Добавить задачу в очередь (вызывается внешней системой, не клиентом). + +--- + +## POST `/api/v1/task/add_bank` + +Зарегистрировать банк с webhook-URL. + +**Query параметры:** + +| Параметр | Тип | Описание | +|----------|-----|----------| +| `bank_name` | string | Название банка | +| `webhook_url` | uri | URL для уведомлений | + +--- + +## Ошибка выполнения задачи + +Если задача не может быть выполнена (аккаунт не найден, скрипт упал), в поле `data` передаётся: + +```json +{ + "type": "FETCH_PROFILE", + "material_id": 42, + "bank_name": "ozon", + "data": { + "status": "error", + "description": "Account not found for material_id: 42" + } +} +``` + +--- + +## Связь с кодом проекта + +| API | Метод в коде | +|-----|-------------| +| `GET /api/v1/task/get_tasks` | `NetworkService::getTasks()` | +| `POST /api/v1/task/task_result` | `NetworkService::postTaskResult()` | + +Хелпер в `EventHandler`: `sendTaskResult(type, materialId, bankName, data)` — создаёт `NetworkService` в отдельном потоке и вызывает `postTaskResult()`. diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 0000000..0e7961f --- /dev/null +++ b/docs/events.md @@ -0,0 +1,237 @@ +# События и задачи + +[← Главная](index.md) + +Описание полного цикла задачи: от получения из API до завершения и отчёта. + +--- + +## Ключевые файлы + +| Файл | Роль | +|------|------| +| `services/EventHandler.h/cpp` | Оркестратор: поллинг, сохранение в БД, запуск потоков | +| `services/net/NetworkService.h/cpp` | HTTP-клиент: `getTasks()`, `postTaskResult()` | +| `database/dao/EventDAO.h/cpp` | CRUD для таблицы `events` | +| `models/db/EventInfo.h` | Модель задачи | + +--- + +## Жизненный цикл задачи + +``` +Wait → InProgress → Complete + ↘ Error +``` + +--- + +## Схема работы + +### 1. Получение задач (каждые 5 секунд) + +``` +QTimer (5s) + └─→ NetworkService::getTasks() + └─→ GET /api/v1/task/get_tasks (Bearer token) + └─→ signal tasksReceived(QJsonArray) + └─→ EventHandler::onTasksReceived() +``` + +Формат ответа API: + +```json +{ + "success": true, + "data": [ + { "bank_name": "ozon", "material_id": "c340cd2e-cefd-48d6-a813-c4be097ae493", "type": "FETCH_PROFILE", "body": {} }, + { "bank_name": "birbank", "material_id": "a1b2c3d4-0000-0000-0000-000000000001", "type": "FETCH_TRANSACTIONS", "body": {} }, + { "bank_name": "ozon", "material_id": "a1b2c3d4-0000-0000-0000-000000000002", "type": "CREATE_TRANSACTION", "body": { "amount": 1000, "currency": 643 } } + ] +} +``` + +> `material_id` — UUID строка. Поддерживается также числовой формат для совместимости (парсится через `task["material_id"].isString() ? toString() : QString::number(toInt())`). + +### 2. Обработка задачи (`onTasksReceived`) + +``` +Для каждой задачи из API: + ├─ existsActiveByExternalId(material_id)? + │ YES → пропустить (дедупликация) + │ NO → продолжить + │ + ├─ AccountDAO::getAccountsByAppCode(bank_name) + │ найти аккаунт где materialId == material_id + │ НЕ НАЙДЕН → sendTaskResult(status=error) и пропустить + │ + └─ EventDAO::insertEvent(status=Wait) +``` + +Маппинг типов задач: + +| Тип в API | `EventType` в коде | Скрипт | +|-----------|--------------------|--------| +| `FETCH_PROFILE` | `FetchProfile` | `Ozon::LoginAndCheckAccountsScript` | +| `FETCH_TRANSACTIONS` | `FetchTransactions` | `GetLastDaysHistoryBirbankScript` | +| `CREATE_TRANSACTION` | `CreateTransaction` | *(не реализован)* | + +### 3. Запуск задач (тот же `onTasksReceived`) + +``` +Для каждого онлайн устройства: + ├─ m_threads[deviceId] запущен? → пропустить + │ (одно устройство = один поток одновременно) + │ + ├─ EventDAO::getFirstWaitEventByDeviceId(deviceId) + │ самая старая Wait-задача (ORDER BY timestamp ASC) + │ + ├─ ApplicationDAO::getApplication() → статус == "active"? + │ + ├─ EventDAO::updateEvent(id, InProgress) + │ + └─ startThreadForTask(account, event) +``` + +### 4. Выполнение скрипта (QThread) + +``` +QThread + └─→ Script::start() + ├─ ADB команды (adb shell, screenshot, xml dump) + ├─ ScreenXmlParser (парсинг UI) + ├─ Tesseract OCR (при необходимости) + └─ emit finishedWithResult(error или "") + └─→ EventHandler::updateEventThread() +``` + +> **Watchdog:** если скрипт не завершился за **5 минут** — поток принудительно завершается. + +### 5. Завершение задачи (`updateEventThread`) + +``` +result == "" → EventDAO::updateEvent(Complete) +result != "" → EventDAO::updateEvent(Error, message) + +└─→ sendEventStatus() (новый QThread) + └─→ POST /api/v1/event/status ⚠️ устаревший эндпоинт, требует замены на task_result +``` + +--- + +## Отправка результата задачи + +Используется `POST /api/v1/task/task_result` через `NetworkService::postTaskResult()`. + +### При ненайденном аккаунте (material_id не в БД) + +```json +{ + "type": "FETCH_PROFILE", + "material_id": 42, + "bank_name": "ozon", + "data": { + "status": "error", + "description": "Account not found for material_id: 42" + } +} +``` + +### При успешном выполнении — FETCH_PROFILE + +```json +{ + "type": "FETCH_PROFILE", + "material_id": 42, + "bank_name": "ozon", + "data": { + "bank_account_id": "string", + "currency": 643, + "amount": 15000, + "extra": {} + } +} +``` + +### При успешном выполнении — FETCH_TRANSACTIONS + +```json +{ + "type": "FETCH_TRANSACTIONS", + "material_id": 7, + "bank_name": "birbank", + "data": [ + { + "bank_transaction_id": "string", + "amount": 500, + "currency": 944, + "transaction_type": "bank", + "bank_participator_id": "string", + "status": "completed", + "extra": {}, + "created_at": "2024-01-01T12:00:00" + } + ] +} +``` + +### При ошибке выполнения скрипта + +```json +{ + "type": "FETCH_PROFILE", + "material_id": 42, + "bank_name": "ozon", + "data": { + "status": "error", + "description": "Cant open the home screen: emulator-5554 ozon" + } +} +``` + +--- + +## Модель `EventInfo` + +```cpp +struct EventInfo { + int id; // локальный ID в SQLite + int externalId; // material_id из API + int accountId; // FK → accounts + QString deviceId; // ADB device id + + double amount; + QString phone; + QString bankName; + QString comment; + + QByteArray screenshot; // последний скриншот экрана + + EventStatus status; // Wait / InProgress / Complete / Error + EventType type; // FetchProfile / FetchTransactions / CreateTransaction + + QDateTime timestamp; + QDateTime updateTime; + QDateTime completeTime; +}; +``` + +--- + +## Дедупликация + +`EventDAO::existsActiveByExternalId(int externalId)` — проверяет наличие записи +с `status IN ('wait', 'iPprogress')` для данного `external_id`. +Гарантирует что одна и та же задача не попадёт в очередь дважды +пока не завершится предыдущее выполнение. + +--- + +## Ограничения + +- **1 поток на устройство** — пока устройство занято, остальные задачи ждут следующего поллинга +- **Очерёдность** — внутри устройства задачи выполняются по `timestamp ASC` +- **Интервал поллинга** — 5 секунд (`m_pollTimer->setInterval`) +- **Пути API** — настраиваются в `config.ini`: + - `net/pathGetTasks` (дефолт `/api/v1/task/get_tasks`) + - `net/pathTaskResult` (дефолт `/api/v1/task/task_result`) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..64f9d85 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,42 @@ +# ARCDeskProject — Документация + +Десктопное Qt6/C++ приложение для мониторинга и автоматизации Android банковских приложений через ADB. + +--- + +## Оглавление + +### Архитектура приложения + +| Раздел | Описание | +|--------|----------| +| [События и задачи](events.md) | Очередь задач: получение из API, хранение в БД, выполнение скриптов | + +### API Backend (`Adb Core Backend v1.0.0`) + +| Раздел | Описание | +|--------|----------| +| [Авторизация](api/auth.md) | Логин, refresh token, хранение токенов | +| [Profile & Material](api/profile.md) | Банковские профили и карты (материалы) | +| [Device & Desktop](api/device.md) | Регистрация десктопов и Android-устройств, скриншоты | +| [Task](api/task.md) | Получение задач, отправка результатов | + +--- + +## Общая архитектура приложения + +``` +MainWindow (Qt6 GUI) + ├── DeviceScreener (QThread) — опрос ADB устройств, скриншоты + ├── EventHandler (QThread) — обработка задач, запуск скриптов + └── NetworkService (QThread) — синхронизация с REST API (localhost:9999/api/v1/) + +Скрипты: android/{rshb,birbank,toss,ozon,ziraat}/ +Database: SQLite — DatabaseManager (singleton) + DAO-слой +``` + +## Технологии + +- **Qt6** — Widgets, Network, Sql, Xml +- **C++17**, SQLite, ADB, Tesseract OCR +- CMake diff --git a/main.cpp b/main.cpp index 16fade6..630959d 100644 --- a/main.cpp +++ b/main.cpp @@ -18,6 +18,7 @@ #include "EventHandler.h" #include "net/NetworkService.h" +#include "ozon/PayByPhoneScript.h" #include "widget/loader/ErrorWindow.h" #include "widget/loader/LoaderWindow.h" #include "widget/loader/RegistrationWindow.h" @@ -67,6 +68,35 @@ void startEventHandler(const QCoreApplication &app) { thread->start(); } +void startPayByPhoneTest() { + // Берём первый девайс с ozon аккаунтом + QList accounts = AccountDAO::getAccountsByAppCode("ozon"); + if (accounts.isEmpty()) { + qWarning() << "[PayByPhoneTest] No ozon accounts found"; + return; + } + AccountInfo account = accounts.first(); + qDebug() << "[PayByPhoneTest] Using account:" << account.id << account.deviceId << account.lastNumbers; + + auto *thread = new QThread(nullptr); + auto *worker = new Ozon::PayByPhoneScript( + account, + "+79855532896", + "Альфа-Банк", + 150, + nullptr + ); + worker->moveToThread(thread); + QObject::connect(thread, &QThread::started, worker, &Ozon::PayByPhoneScript::start); + QObject::connect(worker, &Ozon::PayByPhoneScript::finished, thread, &QThread::quit); + QObject::connect(worker, &Ozon::PayByPhoneScript::finished, worker, &QObject::deleteLater); + QObject::connect(thread, &QThread::finished, thread, &QObject::deleteLater); + QObject::connect(worker, &Ozon::PayByPhoneScript::finishedWithResult, [](const QString &result) { + qDebug() << "[PayByPhoneTest] Result:" << result; + }); + thread->start(); +} + void startNetworkService(const QCoreApplication &app) { auto *paymentThread = new QThread; auto *networkService = new NetworkService; @@ -179,6 +209,7 @@ int main(int argc, char *argv[]) { startNetworkService(app); startDeviceScreener(app); startEventHandler(app); + startPayByPhoneTest(); }, Qt::QueuedConnection); }; @@ -206,6 +237,7 @@ int main(int argc, char *argv[]) { startNetworkService(app); startDeviceScreener(app); startEventHandler(app); + startPayByPhoneTest(); } else if (r == 0) { QSettings s("config.ini", QSettings::IniFormat); s.remove("common/api_key"); @@ -226,6 +258,7 @@ int main(int argc, char *argv[]) { startNetworkService(app); startDeviceScreener(app); startEventHandler(app); + startPayByPhoneTest(); } else if (result == 0) { QSettings s("config.ini", QSettings::IniFormat); s.remove("common/api_key"); diff --git a/models/db/BankInfo.h b/models/db/BankInfo.h new file mode 100644 index 0000000..2f5c885 --- /dev/null +++ b/models/db/BankInfo.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +struct BankInfo { + int id = -1; + QString alias; + QString name; + QString nspkName; +}; diff --git a/models/db/EventInfo.h b/models/db/EventInfo.h index c5f356c..65da4da 100644 --- a/models/db/EventInfo.h +++ b/models/db/EventInfo.h @@ -12,11 +12,9 @@ enum class EventStatus { }; enum class EventType { - Phone, - Card, - Balance, - History, FetchProfile, + FetchTransactions, + CreateTransaction, Unknown }; @@ -41,27 +39,23 @@ inline EventStatus eventStatusFromString(const QString &str) { inline QString eventTypeToString(const EventType type) { switch (type) { - case EventType::Phone: return "phone"; - case EventType::Card: return "card"; - case EventType::Balance: return "balance"; - case EventType::History: return "history"; - case EventType::FetchProfile: return "fetch_profile"; + case EventType::FetchProfile: return "FETCH_PROFILE"; + case EventType::FetchTransactions: return "FETCH_TRANSACTIONS"; + case EventType::CreateTransaction: return "CREATE_TRANSACTION"; default: return "unknown"; } } inline EventType eventTypeFromString(const QString &str) { - if (str == "phone") return EventType::Phone; - if (str == "card") return EventType::Card; - if (str == "balance") return EventType::Balance; - if (str == "history") return EventType::History; - if (str == "fetch_profile") return EventType::FetchProfile; + if (str == "FETCH_PROFILE") return EventType::FetchProfile; + if (str == "FETCH_TRANSACTIONS") return EventType::FetchTransactions; + if (str == "CREATE_TRANSACTION") return EventType::CreateTransaction; return EventType::Unknown; } struct EventInfo { int id = -1; - int externalId = -1; + QString externalId; int accountId = -1; QString deviceId; @@ -73,6 +67,8 @@ struct EventInfo { EventStatus status = EventStatus::Unknown; EventType type = EventType::Unknown; + QByteArray screenshot; + QDateTime updateTime; QDateTime completeTime; QDateTime timestamp; diff --git a/models/db/TransactionInfo.h b/models/db/TransactionInfo.h index e8272f7..d64329a 100644 --- a/models/db/TransactionInfo.h +++ b/models/db/TransactionInfo.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include enum class TransactionType { @@ -69,6 +70,8 @@ struct TransactionInfo { QDateTime updateTime; QDateTime completeTime; QDateTime timestamp; + + QByteArray receiptImage; }; inline QString convertTransactionToString(const TransactionInfo &tx) { diff --git a/services/EventHandler.cpp b/services/EventHandler.cpp index 9a3f76a..dd457d2 100644 --- a/services/EventHandler.cpp +++ b/services/EventHandler.cpp @@ -1,6 +1,8 @@ #include "EventHandler.h" +#include #include +#include #include #include #include @@ -12,91 +14,155 @@ #include "db/ApplicationInfo.h" #include "net/NetworkService.h" #include "birbank/GetLastDaysHistoryBirbankScript.h" +#include "birbank/LoginAndCheckAccountsBirbankScript.h" +#include "ozon/GetLastTransactionsScript.h" #include "ozon/LoginAndCheckAccountsScript.h" -#include "rshb/CommonScript.h" #include "rshb/GetLastDaysHistoryScript.h" #include "rshb/LoginAndCheckAccountsScript.h" -#include "rshb/PayByPhoneScript.h" +#include "toss/GetLastDaysHistoryTossScript.h" +#include "toss/LoginAndCheckAccountsTossScript.h" +#include "ziraat/GetLastDaysHistoryZiraatScript.h" +#include "ziraat/LoginAndCheckAccountsZiraatScript.h" -EventHandler::EventHandler(QObject *parent) : QObject(parent) { - m_checkTimer = new QTimer(this); - m_checkTimer->setInterval(1000); - connect(m_checkTimer, &QTimer::timeout, this, &EventHandler::checkAndRestart); -} +static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonObject &data); + +EventHandler::EventHandler(QObject *parent) : QObject(parent) {} EventHandler::~EventHandler() = default; void EventHandler::start() { - m_devices = DeviceDAO::getOnlineDeviceCodes(); + m_network = new NetworkService(this); + connect(m_network, &NetworkService::tasksReceived, this, &EventHandler::onTasksReceived); - clearAll(); - // Запустим сразу потоки для всех ключей - for (auto &key: m_devices) { - startThreadForKey(key); - } + m_pollTimer = new QTimer(this); + m_pollTimer->setInterval(5000); + connect(m_pollTimer, &QTimer::timeout, m_network, &NetworkService::getTasks); + m_pollTimer->start(); - m_checkTimer->start(); + // Первый запрос сразу при старте + m_network->getTasks(); } -void EventHandler::checkAndRestart() { - if (!m_running) { - return; - } - m_devices = DeviceDAO::getOnlineDeviceCodes(); +void EventHandler::onTasksReceived(const QJsonArray &tasks) { + if (!m_running) return; - for (const auto &key: m_devices) { - const QThread *thr = m_threads.value(key, nullptr); - if (!thr || !thr->isRunning()) { - startThreadForKey(key); + // 1. Сохраняем новые задачи в БД + for (const auto &taskVal : tasks) { + const QJsonObject task = taskVal.toObject(); + const QString bankName = task["bank_name"].toString(); + const QString materialId = task["material_id"].isString() + ? task["material_id"].toString() + : QString::number(task["material_id"].toInt()); + const QString typeStr = task["type"].toString(); + + const EventType eventType = eventTypeFromString(typeStr); + + if (eventType == EventType::Unknown) { + qWarning() << "[EventHandler] Unknown task type:" << typeStr; + continue; + } + + // Пропускаем если уже есть активная запись с таким external_id + if (EventDAO::existsActiveByExternalId(materialId)) continue; + + // Ищем аккаунт по material_id напрямую + const AccountInfo account = AccountDAO::findByMaterialId(materialId); + if (account.id == -1) { + qWarning() << "[EventHandler] Account not found for bank:" << bankName << "material_id:" << materialId; + QJsonObject data; + data["status"] = "error"; + data["description"] = "Account not found for material_id: " + materialId; + sendTaskResult(typeStr, materialId, bankName, data); + continue; + } + + EventInfo event; + event.status = EventStatus::Wait; + event.type = eventType; + event.externalId = materialId; + event.accountId = account.id; + event.deviceId = account.deviceId; + event.bankName = bankName; + event.timestamp = QDateTime::currentDateTimeUtc(); + + if (EventDAO::insertEvent(event) == -1) { + qCritical() << "[EventHandler] Failed to insert event, external_id:" << materialId; } } + + // 2. Для каждого онлайн устройства без активного потока — запускаем следующую задачу + const QStringList onlineDevices = DeviceDAO::getOnlineDeviceCodes(); + for (const QString &deviceId : onlineDevices) { + if (const QThread *thr = m_threads.value(deviceId, nullptr); thr && thr->isRunning()) continue; + + const EventInfo event = EventDAO::getFirstWaitEventByDeviceId(deviceId); + if (event.id == -1) continue; + + const AccountInfo account = AccountDAO::getAccountById(event.accountId); + const ApplicationInfo app = ApplicationDAO::getApplication(deviceId, account.appCode); + if (app.status != "active") continue; + + if (!EventDAO::updateEvent(event.id, EventStatus::InProgress, "")) { + qCritical() << "[EventHandler] Failed to set InProgress, event id:" << event.id; + continue; + } + + startThreadForTask(account, event); + } } -void sendEventStatus(const QString &deviceId, const int externalId, const int eventId, const QString &status, - const QString &error) { +static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, + const QJsonObject &data) { QJsonObject obj; - obj["id"] = externalId; - obj["externalId"] = eventId; - obj["status"] = status; - obj["error"] = error; + obj["type"] = type; + obj["material_id"] = materialId; // UUID string + obj["bank_name"] = bankName; + obj["data"] = data; - auto *paymentThread = new QThread; + auto *thread = new QThread; + auto *networkService = new NetworkService; + networkService->moveToThread(thread); + networkService->setPayload(QJsonDocument(obj).toJson()); + QObject::connect(thread, &QThread::started, networkService, &NetworkService::postTaskResult); + QObject::connect(networkService, &NetworkService::finished, thread, &QThread::quit); + QObject::connect(networkService, &NetworkService::finished, networkService, &QObject::deleteLater); + QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater); + thread->start(); +} + +static void sendEventStatus(const QString &deviceId, const int externalId, const int eventId, + const QString &status, const QString &error) { + QJsonObject obj; + obj["id"] = externalId; + obj["externalId"] = eventId; + obj["status"] = status; + obj["error"] = error; + + auto *paymentThread = new QThread; auto *networkService = new NetworkService; networkService->moveToThread(paymentThread); networkService->setDeviceId(deviceId); networkService->setPayload(QJsonDocument(obj).toJson()); - QObject::connect(paymentThread, &QThread::started, networkService, &NetworkService::postEventStatus); - QObject::connect(networkService, &NetworkService::finished, paymentThread, &QThread::quit); + QObject::connect(paymentThread, &QThread::started, networkService, &NetworkService::postEventStatus); + QObject::connect(networkService, &NetworkService::finished, paymentThread, &QThread::quit); QObject::connect(networkService, &NetworkService::finished, networkService, &QObject::deleteLater); - QObject::connect(paymentThread, &QThread::finished, paymentThread, &QThread::deleteLater); + QObject::connect(paymentThread, &QThread::finished, paymentThread, &QThread::deleteLater); paymentThread->start(); } void EventHandler::updateEventThread(const QString &key, const EventInfo &event, const QString &result) { - if (!result.isEmpty()) { - if (!EventDAO::updateEvent(event.id, EventStatus::Error, result)) { - qCritical() << "Cant update event: " << event.id; - } else { - sendEventStatus( - event.deviceId, - event.externalId, - event.id, - eventStatusToString(EventStatus::Error), - result - ); - } + const EventStatus newStatus = result.isEmpty() ? EventStatus::Complete : EventStatus::Error; + + if (!EventDAO::updateEvent(event.id, newStatus, result)) { + qCritical() << "[EventHandler] Failed to update event:" << event.id; } else { - if (!EventDAO::updateEvent(event.id, EventStatus::Complete, "")) { - qCritical() << "Cant update event: " << event.id; - } else { - sendEventStatus( - event.deviceId, - event.externalId, - event.id, - eventStatusToString(EventStatus::Complete), - "" - ); - } + sendEventStatus( + event.deviceId, + event.externalId, + event.id, + eventStatusToString(newStatus), + result + ); } if (const auto tm = m_timers.take(key)) { @@ -106,99 +172,59 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event, m_threads.remove(key); } -void EventHandler::startThreadForKey(const QString &key) { - const EventInfo event = EventDAO::getFirstWaitEventByDeviceId(key); - if (event.id == -1) { - return; - } +void EventHandler::startThreadForTask(const AccountInfo &account, const EventInfo &event) { + const auto thr = new QThread(this); + const QString key = account.deviceId; - const AccountInfo account = AccountDAO::getAccountById(event.accountId); - const ApplicationInfo app = ApplicationDAO::getApplication(event.deviceId, account.appCode); - if (app.status != "active") { - return; - } + // Generic helper: подключает сигналы и переносит worker в поток + auto setup = [&](auto *worker) { + using W = std::remove_pointer_t; + worker->moveToThread(thr); + connect(thr, &QThread::started, worker, &W::start); + connect(worker, &W::finished, thr, &QThread::quit); + connect(worker, &W::finished, worker, &QObject::deleteLater); + connect(thr, &QThread::finished, thr, &QObject::deleteLater); + connect(worker, &W::finishedWithResult, this, + [this, key, event](const QString &result) { + updateEventThread(key, event, result); + } + ); + }; - if (!EventDAO::updateEvent(event.id, EventStatus::InProgress, "")) { - qCritical() << "Cant update event: " << event.id; - return; - } - - const auto thr = new QThread(this); - // qDebug() << "Start thread for key" << eventTypeToString(event.type); - if (event.type == EventType::History) { - if ("birbank" == account.appCode) { - auto *worker = new GetLastDaysHistoryBirbankScript(account); - worker->moveToThread(thr); - - connect(thr, &QThread::started, worker, &GetLastDaysHistoryBirbankScript::start); - connect(worker, &GetLastDaysHistoryBirbankScript::finished, thr, &QThread::quit); - connect(worker, &GetLastDaysHistoryBirbankScript::finished, worker, &QObject::deleteLater); - connect(thr, &QThread::finished, thr, &QObject::deleteLater); - - connect(worker, &GetLastDaysHistoryBirbankScript::finishedWithResult, this, - [this, key, event](const QString &result) { - updateEventThread(key, event, result); - } - ); - } - // auto *worker = new Rshb::GetLastDaysHistoryScript(account); - // worker->moveToThread(thr); - // - // connect(thr, &QThread::started, worker, &Rshb::GetLastDaysHistoryScript::start); - // connect(worker, &Rshb::GetLastDaysHistoryScript::finished, thr, &QThread::quit); - // connect(worker, &Rshb::GetLastDaysHistoryScript::finished, worker, &QObject::deleteLater); - // connect(thr, &QThread::finished, thr, &QObject::deleteLater); - // - // connect(worker, &Rshb::GetLastDaysHistoryScript::finishedWithResult, this, - // [this, key, event](const QString &result) { - // updateEventThread(key, event, result); - // } - // ); - } + const QString &appCode = account.appCode; + const QString &deviceId = account.deviceId; if (event.type == EventType::FetchProfile) { - if ("ozon" == account.appCode) { - auto *worker = new Ozon::LoginAndCheckAccountsScript(event.deviceId, account.appCode); - worker->moveToThread(thr); - - connect(thr, &QThread::started, worker, &Ozon::LoginAndCheckAccountsScript::start); - connect(worker, &Ozon::LoginAndCheckAccountsScript::finished, thr, &QThread::quit); - connect(worker, &Ozon::LoginAndCheckAccountsScript::finished, worker, &QObject::deleteLater); - connect(thr, &QThread::finished, thr, &QObject::deleteLater); - - connect(worker, &Ozon::LoginAndCheckAccountsScript::finishedWithResult, this, - [this, key, event](const QString &result) { - updateEventThread(key, event, result); - } - ); - } + if (appCode == "ozon") setup(new Ozon::LoginAndCheckAccountsScript(deviceId, appCode)); + else if (appCode == "birbank") setup(new LoginAndCheckAccountsBirbankScript(deviceId, appCode)); + else if (appCode == "rshb") setup(new Rshb::LoginAndCheckAccountsScript(deviceId, appCode)); + else if (appCode == "toss") setup(new LoginAndCheckAccountsTossScript(deviceId, appCode)); + else if (appCode == "ziraat") setup(new LoginAndCheckAccountsZiraatScript(deviceId, appCode)); + else qWarning() << "[EventHandler] FETCH_PROFILE: unknown appCode:" << appCode; } - if (event.type == EventType::Phone) { - // auto *worker = new Rshb::PayByPhoneScript(account, event.phone, event.bankName, event.amount); - // worker->moveToThread(thr); - // - // connect(thr, &QThread::started, worker, &Rshb::PayByPhoneScript::start); - // connect(worker, &Rshb::PayByPhoneScript::finished, thr, &QThread::quit); - // connect(worker, &Rshb::PayByPhoneScript::finished, worker, &QObject::deleteLater); - // connect(thr, &QThread::finished, thr, &QObject::deleteLater); - // - // connect(worker, &Rshb::PayByPhoneScript::finishedWithResult, this, - // [this, key, event](const QString &result) { - // updateEventThread(key, event, result); - // } - // ); + if (event.type == EventType::FetchTransactions) { + if (appCode == "birbank") setup(new GetLastDaysHistoryBirbankScript(account)); + else if (appCode == "ozon") setup(new Ozon::GetLastTransactionsScript(deviceId, appCode)); + else if (appCode == "rshb") setup(new Rshb::GetLastDaysHistoryScript(account)); + else if (appCode == "toss") setup(new GetLastDaysHistoryTossScript(account)); + else if (appCode == "ziraat") setup(new GetLastDaysHistoryZiraatScript(account)); + else qWarning() << "[EventHandler] FETCH_TRANSACTIONS: unknown appCode:" << appCode; } - // Таймер на 5 минут — если не успел finish(), убить и перезапустить + if (event.type == EventType::CreateTransaction) { + // TODO: реализовать CREATE_TRANSACTION + } + + // Watchdog-таймер: 5 минут — убиваем зависший поток const auto timer = new QTimer(this); timer->setSingleShot(true); connect(timer, &QTimer::timeout, this, [this, key]() { - qWarning() << "Timeout for key" << key << "- killing stuck thread."; + qWarning() << "[EventHandler] Timeout for key" << key << "- killing stuck thread."; if (QThread *thread = m_threads.value(key); thread && thread->isRunning()) { - thread->requestInterruption(); // мягкое прерывание + thread->requestInterruption(); if (!thread->wait(500)) { - qWarning() << "Hard terminate for key" << key; + qWarning() << "[EventHandler] Hard terminate for key" << key; thread->terminate(); thread->wait(); } @@ -209,17 +235,15 @@ void EventHandler::startThreadForKey(const QString &key) { m_threads.remove(key); }); - // Сохраняем и запускаем m_threads[key] = thr; - m_timers[key] = timer; + m_timers[key] = timer; timer->start(5 * 60 * 1000); thr->start(); } void EventHandler::clearAll() { - // Останавливаем и удаляем старые - for (QThread *thread: m_threads) { + for (QThread *thread : m_threads) { if (thread->isRunning()) { thread->requestInterruption(); thread->terminate(); @@ -229,7 +253,7 @@ void EventHandler::clearAll() { } m_threads.clear(); - for (QTimer *timer: m_timers) { + for (QTimer *timer : m_timers) { timer->stop(); timer->deleteLater(); } @@ -238,6 +262,6 @@ void EventHandler::clearAll() { void EventHandler::stop() { m_running = false; - m_checkTimer->stop(); + if (m_pollTimer) m_pollTimer->stop(); clearAll(); } diff --git a/services/EventHandler.h b/services/EventHandler.h index 31f8a69..6f8f855 100644 --- a/services/EventHandler.h +++ b/services/EventHandler.h @@ -1,10 +1,14 @@ #pragma once +#include #include #include #include +#include "db/AccountInfo.h" #include "db/EventInfo.h" +class NetworkService; + class EventHandler final : public QObject { Q_OBJECT @@ -22,19 +26,18 @@ signals: void finished(); private slots: - void checkAndRestart(); // вызывается по таймеру + void onTasksReceived(const QJsonArray &tasks); private: bool m_running = true; QMap m_threads; QMap m_timers; - QTimer *m_checkTimer = nullptr; - - QStringList m_devices; + QTimer *m_pollTimer = nullptr; + NetworkService *m_network = nullptr; void clearAll(); void updateEventThread(const QString &key, const EventInfo &event, const QString &result); - void startThreadForKey(const QString &key); + void startThreadForTask(const AccountInfo &account, const EventInfo &event); }; diff --git a/services/net/NetworkService.cpp b/services/net/NetworkService.cpp index 2387da8..3980f54 100644 --- a/services/net/NetworkService.cpp +++ b/services/net/NetworkService.cpp @@ -33,6 +33,8 @@ NetworkService::NetworkService(QObject *parent) : QObject(parent) { m_pathDeviceScreenshot= settings.value("net/pathDeviceScreenshot","/api/v1/device/add_screenshot/").toString(); m_pathBankProfile = settings.value("net/pathBankProfile", "/api/v1/profile/bank_profile").toString(); m_pathMaterial = settings.value("net/pathMaterial", "/api/v1/profile/material").toString(); + m_pathGetTasks = settings.value("net/pathGetTasks", "/api/v1/task/get_tasks").toString(); + m_pathTaskResult = settings.value("net/pathTaskResult", "/api/v1/task/task_result").toString(); m_manager = new QNetworkAccessManager(this); m_token = settings.value("common/token").toString(); m_accessToken = settings.value("common/access_token").toString(); @@ -586,14 +588,60 @@ void NetworkService::scheduleNextRefresh() { }); } +void NetworkService::getTasks() { + if (m_fetchingTasks) return; + m_fetchingTasks = true; + + const QSettings settings("config.ini", QSettings::IniFormat); + const QString accessToken = settings.value("common/access_token").toString(); + + QUrl url(m_apiBase + m_pathGetTasks); + QNetworkRequest request(url); + request.setRawHeader("Authorization", ("Bearer " + accessToken).toUtf8()); + + QNetworkReply *reply = m_manager->get(request); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + m_fetchingTasks = false; + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + qWarning() << "[NetworkService] getTasks error:" << reply->errorString(); + return; + } + + const QByteArray raw = reply->readAll(); + QJsonParseError err; + const QJsonDocument doc = QJsonDocument::fromJson(raw, &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) { + qWarning() << "[NetworkService] getTasks: invalid JSON"; + return; + } + + const QJsonObject root = doc.object(); + if (!root.value("success").toBool()) { + qWarning() << "[NetworkService] getTasks: success=false" << root; + return; + } + + emit tasksReceived(root.value("data").toArray()); + }); +} + +void NetworkService::postTaskResult() { + const QJsonObject body = QJsonDocument::fromJson(m_json).object(); + const QJsonValue result = postJson(m_apiBase + m_pathTaskResult, body, true); + if (result.isNull() || result.isUndefined()) { + qWarning() << "[NetworkService] postTaskResult failed, body:" << QJsonDocument(body).toJson(QJsonDocument::Compact); + } + emit finished(); +} + void NetworkService::start() { m_running = true; // Проверяем токен при старте и планируем рефреш по времени жизни ensureValidToken(); scheduleNextRefresh(); - - // TODO: GET /api/v1/task/get_tasks (polling) } void NetworkService::stop() { diff --git a/services/net/NetworkService.h b/services/net/NetworkService.h index 60312a6..59f6a90 100644 --- a/services/net/NetworkService.h +++ b/services/net/NetworkService.h @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include #include @@ -47,6 +48,10 @@ public slots: void updateDevice(); + void getTasks(); + + void postTaskResult(); + void start(); void stop(); @@ -54,6 +59,7 @@ signals: void finished(); void finishedWithResult(int value); void tokenRefreshFailed(); + void tasksReceived(QJsonArray tasks); private: QByteArray m_json; @@ -78,6 +84,9 @@ private: QString m_pathDeviceScreenshot; QString m_pathBankProfile; QString m_pathMaterial; + QString m_pathGetTasks; + QString m_pathTaskResult; + bool m_fetchingTasks = false; QNetworkAccessManager *m_manager; diff --git a/views/MainWindow.cpp b/views/MainWindow.cpp index a06415e..d642bd5 100644 --- a/views/MainWindow.cpp +++ b/views/MainWindow.cpp @@ -7,6 +7,7 @@ #include #include "TutorialWindow.h" +#include "bank/BankListWindow.h" #include "db/DeviceInfo.h" #include "device/DeviceWidget.h" #include "dao/DeviceDAO.h" @@ -35,9 +36,11 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { QMenu *menu = menuBar()->addMenu("Меню"); auto *devicesPageAction = new QAction("Активные устройства", this); // auto *accountPageAction = new QAction("Аккаунты", this); + auto *bankListAction = new QAction("Банки", this); auto *tutorialPageAction = new QAction("Инструкция", this); menu->addAction(devicesPageAction); // menu->addAction(accountPageAction); + menu->addAction(bankListAction); menu->addAction(tutorialPageAction); connect(devicesPageAction, &QAction::triggered, this, [=]() { @@ -52,6 +55,15 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { // deleteDevicePage(); // освобождаем ресурсы // }); + connect(bankListAction, &QAction::triggered, this, [=]() { + if (!m_bankListWindow) { + m_bankListWindow = new BankListWindow(this); + } + m_bankListWindow->show(); + m_bankListWindow->raise(); + m_bankListWindow->activateWindow(); + }); + connect(tutorialPageAction, &QAction::triggered, this, [=]() { if (!m_tutorialWindow) { m_tutorialWindow = new TutorialWindow(this); diff --git a/views/MainWindow.h b/views/MainWindow.h index b4e7db9..c5bbf99 100644 --- a/views/MainWindow.h +++ b/views/MainWindow.h @@ -26,6 +26,7 @@ private: QWidget *m_devicesWindow = nullptr; QWidget *m_accountsWindow = nullptr; QWidget *m_tutorialWindow = nullptr; + QWidget *m_bankListWindow = nullptr; void createDevicePage(); diff --git a/views/bank/AccountSettingsWindow.cpp b/views/bank/AccountSettingsWindow.cpp index bf6c9ea..3711128 100644 --- a/views/bank/AccountSettingsWindow.cpp +++ b/views/bank/AccountSettingsWindow.cpp @@ -15,9 +15,11 @@ #include "ozon/LoginAndCheckAccountsScript.h" #include "ozon/GetProfileInfoScript.h" #include "ozon/GetCardInfoScript.h" +#include "ozon/GetLastTransactionsScript.h" #include "toss/LoginAndCheckAccountsTossScript.h" #include "dao/AccountDAO.h" #include "dao/ApplicationDAO.h" +#include "bank/TransactionWindow.h" #include "device/EditBankAccountDialog.h" #include "device/EditBankDialog.h" #include "net/NetworkService.h" @@ -205,8 +207,29 @@ AccountSettingsWindow::AccountSettingsWindow( } }); + QPushButton *transactionsButton = new QPushButton("Проверить транзакции"); + transactionsButton->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); + transactionsButton->setStyleSheet("background-color: #1565C0; color: white;"); + QObject::connect(transactionsButton, &QPushButton::clicked, [this]() { + QMessageBox msgBox(this); + msgBox.setWindowTitle(tr("Проверить транзакции?")); + msgBox.setText( + "Запустится скрипт получения последних транзакций из " + m_applicationInfo.name + + ". Окно с настройками закроется."); + QPushButton *runBtn = msgBox.addButton(tr("Запустить"), QMessageBox::AcceptRole); + QPushButton *cancelBtn = msgBox.addButton(tr("Отмена"), QMessageBox::RejectRole); + msgBox.setDefaultButton(cancelBtn); + msgBox.setModal(true); + msgBox.exec(); + if (msgBox.clickedButton() == runBtn) { + startGetLastTransactions(m_applicationInfo.code); + close(); + } + }); + ozonRow->addWidget(profileButton); ozonRow->addWidget(cardButton); + ozonRow->addWidget(transactionsButton); ozonRow->addStretch(); layout->addLayout(ozonRow); } @@ -322,6 +345,19 @@ void AccountSettingsWindow::startGetCardInfo(const QString &appCode) { thread->start(); } +void AccountSettingsWindow::startGetLastTransactions(const QString &appCode) { + if (appCode != "ozon") return; + + QThread *thread = new QThread(nullptr); + auto *worker = new Ozon::GetLastTransactionsScript(m_deviceId, appCode, nullptr); + worker->moveToThread(thread); + connect(thread, &QThread::started, worker, &Ozon::GetLastTransactionsScript::start); + connect(worker, &Ozon::GetLastTransactionsScript::finished, thread, &QThread::quit); + connect(worker, &Ozon::GetLastTransactionsScript::finished, worker, &QObject::deleteLater); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); + thread->start(); +} + void AccountSettingsWindow::sendAppStatus(const QString &deviceId, const QString &code, const QString &status) { Q_UNUSED(deviceId) Q_UNUSED(code) Q_UNUSED(status) // TODO: заменить на новый API @@ -356,7 +392,7 @@ void AccountSettingsWindow::reloadContent(QTableWidget *tableWidget) { tableWidget->clearContents(); tableWidget->setRowCount(accounts.length()); - tableWidget->setColumnCount(7); + tableWidget->setColumnCount(8); tableWidget->setHorizontalHeaderLabels({ "Время обновления", "Номер", @@ -364,6 +400,7 @@ void AccountSettingsWindow::reloadContent(QTableWidget *tableWidget) { "Сумма", "Валюта", "Статус", + "", "" }); @@ -371,7 +408,6 @@ void AccountSettingsWindow::reloadContent(QTableWidget *tableWidget) { tableWidget->setTextElideMode(Qt::ElideNone); tableWidget->horizontalHeader()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter); tableWidget->horizontalHeader()->setSectionResizeMode(5, QHeaderView::ResizeToContents); - tableWidget->verticalHeader()->setSectionResizeMode(6, QHeaderView::ResizeToContents); tableWidget->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); @@ -413,32 +449,35 @@ void AccountSettingsWindow::reloadContent(QTableWidget *tableWidget) { tableWidget->setItem(k, 5, status); - auto *editBankAccountBtn = new QPushButton("Редактировать", tableWidget); - tableWidget->setCellWidget(k, 6, editBankAccountBtn); + auto *deleteBankAccountBtn = new QPushButton("Удалить", tableWidget); + deleteBankAccountBtn->setStyleSheet("background-color: red; color: white;"); + tableWidget->setCellWidget(k, 6, deleteBankAccountBtn); - connect(editBankAccountBtn, &QPushButton::clicked, this, [this, tableWidget, account]() { - EditBankAccountDialog dlg(account, this); - if (dlg.exec() == QDialog::Accepted) { - const QString cardNumber = dlg.cardNumberValue(); - const QString currency = dlg.currencyValue(); - bool on = dlg.toggled(); - - if (!AccountDAO::updateAccountById(account.id, on ? "active" : "off", cardNumber, currency)) { - qDebug() << "Cant update bank account data"; - } else { + connect(deleteBankAccountBtn, &QPushButton::clicked, this, [this, tableWidget, account]() { + QMessageBox msgBox(this); + msgBox.setWindowTitle("Удалить аккаунт?"); + msgBox.setText("Вы уверены, что хотите удалить аккаунт " + account.lastNumbers + "?"); + QPushButton *deleteBtn = msgBox.addButton("Удалить", QMessageBox::AcceptRole); + QPushButton *cancelBtn = msgBox.addButton("Отмена", QMessageBox::RejectRole); + msgBox.setDefaultButton(cancelBtn); + msgBox.exec(); + if (msgBox.clickedButton() == deleteBtn) { + if (AccountDAO::deleteAccount(account.id)) { reloadContent(tableWidget); - AccountInfo accountInfo; - accountInfo.currency = currency; - accountInfo.description = cardNumber; - accountInfo.amount = account.amount; - accountInfo.currency = currency; - accountInfo.appCode = m_applicationInfo.code; - accountInfo.deviceId = m_deviceId; - accountInfo.status = on ? AccountStatus::Active : AccountStatus::Off; - accountInfo.lastNumbers = cardNumber.right(4); - updateAccountData(accountInfo); + tableWidget->resizeColumnsToContents(); + } else { + qWarning() << "Cant delete account" << account.id; } } }); + + auto *transactionsBtn = new QPushButton("Транзакции", tableWidget); + transactionsBtn->setStyleSheet("background-color: #1565C0; color: white;"); + tableWidget->setCellWidget(k, 7, transactionsBtn); + + connect(transactionsBtn, &QPushButton::clicked, this, [this, account]() { + auto *txWindow = new TransactionWindow(account.id, this); + txWindow->show(); + }); } } diff --git a/views/bank/AccountSettingsWindow.h b/views/bank/AccountSettingsWindow.h index b1c2810..e5a77ce 100644 --- a/views/bank/AccountSettingsWindow.h +++ b/views/bank/AccountSettingsWindow.h @@ -27,6 +27,8 @@ private: void startGetCardInfo(const QString &appCode); + void startGetLastTransactions(const QString &appCode); + void reloadContent(QTableWidget *tableWidget); void sendAppStatus(const QString &deviceId, const QString &code, const QString &status); diff --git a/views/bank/BankListWindow.cpp b/views/bank/BankListWindow.cpp new file mode 100644 index 0000000..a3c4553 --- /dev/null +++ b/views/bank/BankListWindow.cpp @@ -0,0 +1,117 @@ +#include "BankListWindow.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "dao/BankDAO.h" + +BankListWindow::BankListWindow(QWidget *parent) + : QWidget(parent, + Qt::Window + | Qt::WindowTitleHint + | Qt::WindowSystemMenuHint + | Qt::WindowCloseButtonHint + | Qt::WindowMinMaxButtonsHint) { + setWindowTitle("Банки"); + setMinimumSize(600, 400); + resize(700, 500); + + auto *layout = new QVBoxLayout(this); + + auto *topRow = new QHBoxLayout(); + auto *syncBtn = new QPushButton("Синхронизировать"); + syncBtn->setStyleSheet("background-color: #1565C0; color: white;"); + syncBtn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); + topRow->addWidget(syncBtn); + topRow->addStretch(); + layout->addLayout(topRow); + + connect(syncBtn, &QPushButton::clicked, this, &BankListWindow::syncBanks); + + m_tableWidget = new QTableWidget(this); + m_tableWidget->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_tableWidget->setSelectionMode(QAbstractItemView::SingleSelection); + m_tableWidget->setSelectionBehavior(QAbstractItemView::SelectRows); + m_tableWidget->setColumnCount(3); + m_tableWidget->setHorizontalHeaderLabels({"Alias", "Название", "NSPK"}); + m_tableWidget->horizontalHeader()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter); + m_tableWidget->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + + layout->addWidget(m_tableWidget); + + reloadTable(); +} + +void BankListWindow::reloadTable() { + QList banks = BankDAO::getAll(); + + m_tableWidget->setRowCount(banks.size()); + for (int i = 0; i < banks.size(); ++i) { + const BankInfo &b = banks[i]; + + auto *alias = new QTableWidgetItem(b.alias); + alias->setFlags(alias->flags() & ~Qt::ItemIsEditable); + m_tableWidget->setItem(i, 0, alias); + + auto *name = new QTableWidgetItem(b.name); + name->setFlags(name->flags() & ~Qt::ItemIsEditable); + m_tableWidget->setItem(i, 1, name); + + auto *nspk = new QTableWidgetItem(b.nspkName); + nspk->setFlags(nspk->flags() & ~Qt::ItemIsEditable); + m_tableWidget->setItem(i, 2, nspk); + } +} + +void BankListWindow::syncBanks() { + auto *manager = new QNetworkAccessManager(this); + + QNetworkRequest request(QUrl("https://api.brtservice.io/api/rest/info/")); + QNetworkReply *reply = manager->get(request); + + connect(reply, &QNetworkReply::finished, this, [this, reply, manager]() { + if (reply->error() != QNetworkReply::NoError) { + QMessageBox::warning(this, "Ошибка", "Не удалось загрузить банки: " + reply->errorString()); + reply->deleteLater(); + manager->deleteLater(); + return; + } + + const QByteArray data = reply->readAll(); + const QJsonDocument doc = QJsonDocument::fromJson(data); + + QJsonArray array; + if (doc.isArray()) { + array = doc.array(); + } else if (doc.isObject() && doc.object().contains("data")) { + array = doc.object()["data"].toArray(); + } + + QList banks; + for (const QJsonValue &val : array) { + QJsonObject obj = val.toObject(); + BankInfo bank; + bank.alias = obj["alias"].toString(); + bank.name = obj["name"].toString(); + bank.nspkName = obj["nspk_name"].toString(); + if (!bank.alias.isEmpty()) { + banks.append(bank); + } + } + + BankDAO::replaceAll(banks); + reloadTable(); + + qDebug() << "[BankListWindow] Synced" << banks.size() << "banks"; + reply->deleteLater(); + manager->deleteLater(); + }); +} diff --git a/views/bank/BankListWindow.h b/views/bank/BankListWindow.h new file mode 100644 index 0000000..2264503 --- /dev/null +++ b/views/bank/BankListWindow.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +class BankListWindow final : public QWidget { + Q_OBJECT + +public: + explicit BankListWindow(QWidget *parent = nullptr); + +private: + QTableWidget *m_tableWidget; + + void reloadTable(); + void syncBanks(); +}; diff --git a/views/bank/TransactionWindow.cpp b/views/bank/TransactionWindow.cpp index d51afe3..6148b49 100644 --- a/views/bank/TransactionWindow.cpp +++ b/views/bank/TransactionWindow.cpp @@ -1,7 +1,10 @@ #include "TransactionWindow.h" #include +#include #include +#include +#include #include #include #include @@ -46,8 +49,7 @@ QTableWidgetItem *createTableWidget(const QString &text) { TransactionWindow::TransactionWindow(const int accountId, QWidget *parent): QDialog(parent) { setWindowTitle("Транзакции"); - setMinimumSize(1000, 600); // минимальный размер - setMaximumSize(1000, 600); // максимальный размер + setMinimumSize(800, 600); auto *mainLayout = new QVBoxLayout(this); @@ -101,27 +103,27 @@ TransactionWindow::TransactionWindow(const int accountId, QWidget *parent): QDia tableWidget->setEditTriggers(QAbstractItemView::NoEditTriggers); tableWidget->setRowCount(transactions.length()); - tableWidget->setColumnCount(6); + tableWidget->setColumnCount(7); tableWidget->setHorizontalHeaderLabels({ "Дата и время", "Сумма", "Банк", "Телефон", "Статус", - "Описание" + "Описание", + "" }); tableWidget->horizontalHeader()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter); for (int i = 0; i < 5; ++i) { tableWidget->horizontalHeader()->setSectionResizeMode(i, QHeaderView::ResizeToContents); } tableWidget->horizontalHeader()->setSectionResizeMode(5, QHeaderView::Stretch); - // tableWidget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); tableWidget->setItemDelegateForColumn(1, new AmountDelegate(tableWidget)); for (int k = 0; k < transactions.size(); ++k) { TransactionInfo tr = transactions[k]; - tableWidget->setItem(k, 0, createTableWidget(tr.timestamp.toString("dd.MM.yyyy HH:mm:ss"))); + tableWidget->setItem(k, 0, createTableWidget(tr.bankTime)); tableWidget->setItem(k, 1, createTableWidget(QString::number(tr.amount))); tableWidget->setItem(k, 2, createTableWidget(tr.bankName)); tableWidget->setItem(k, 3, createTableWidget(tr.phone)); @@ -130,12 +132,42 @@ TransactionWindow::TransactionWindow(const int accountId, QWidget *parent): QDia QTableWidgetItem *item = new QTableWidgetItem(tr.description); item->setTextAlignment(Qt::AlignLeft | Qt::AlignTop); item->setFlags(item->flags() & ~Qt::ItemIsEditable); - item->setSizeHint(QSize(100, tr.description.length() / 2)); // Минимальная высота ячейки для переноса + item->setSizeHint(QSize(100, tr.description.length() / 2)); tableWidget->setItem(k, 5, item); + + if (!tr.receiptImage.isEmpty()) { + auto *docBtn = new QPushButton("Документы", tableWidget); + docBtn->setStyleSheet("background-color: #1565C0; color: white;"); + tableWidget->setCellWidget(k, 6, docBtn); + + connect(docBtn, &QPushButton::clicked, this, [this, tr]() { + QPixmap pixmap; + pixmap.loadFromData(tr.receiptImage); + if (pixmap.isNull()) return; + + auto *imgDialog = new QDialog(this); + imgDialog->setWindowTitle("Документ"); + imgDialog->setAttribute(Qt::WA_DeleteOnClose); + auto *dlgLayout = new QVBoxLayout(imgDialog); + auto *imgLabel = new QLabel(imgDialog); + imgLabel->setPixmap(pixmap.scaled(600, 1000, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + dlgLayout->addWidget(imgLabel); + imgDialog->resize(imgLabel->pixmap().size()); + imgDialog->show(); + }); + } } layout->addWidget(tableWidget); + tableWidget->resizeColumnsToContents(); tableWidget->resizeRowsToContents(); + // Подгоняем ширину окна под содержимое таблицы + int totalWidth = tableWidget->verticalHeader()->width() + tableWidget->frameWidth() * 2 + 20; + for (int i = 0; i < tableWidget->columnCount(); ++i) { + totalWidth += tableWidget->columnWidth(i); + } + resize(qMax(800, totalWidth), 600); + // for (int row = 0; row < tableWidget->rowCount(); ++row) { // for (int col = 0; col < tableWidget->columnCount(); ++col) { // QTableWidgetItem *item = tableWidget->item(row, col);