diff --git a/android/ozon/GetLastTransactionsScript.cpp b/android/ozon/GetLastTransactionsScript.cpp index 4c5b36d..0870448 100644 --- a/android/ozon/GetLastTransactionsScript.cpp +++ b/android/ozon/GetLastTransactionsScript.cpp @@ -45,8 +45,8 @@ static QString recognizeTextFromImage(const QByteArray &pngData) { } static QString extractTransactionId(const QString &ocrText) { - // "ID операции A60130542076330K0000040011670301" - QRegularExpression reId(R"(ID\s+операции\s+([A-Za-z0-9]+))"); + // "ID операции A60130542076330K0000040011670301" или "ID операции = Аб60650..." + QRegularExpression reId(R"(ID\s+операции\s*=?\s*([A-Za-zА-Яа-я0-9]+))"); if (auto m = reId.match(ocrText); m.hasMatch()) return m.captured(1); // "№ Ф-2026-25235663" @@ -270,6 +270,17 @@ void GetLastTransactionsScript::doStart() { << "phone:" << tx.phone << "amount:" << (tx.amount >= 0 ? "+" : "") + QString::number(tx.amount, 'f', 2); + // Если транзакция старше 24 часов — прекращаем + if (tx.completeTime.isValid()) { + const qint64 ageSecs = tx.completeTime.toUTC().secsTo(QDateTime::currentDateTimeUtc()); + if (ageSecs > 24 * 3600) { + qDebug() << "[GetLastTransactions] Transaction older than 24h, stopping"; + AdbUtils::goBack(m_deviceId); + QThread::msleep(1000); + break; + } + } + // Сохраняем в БД если не существует // Берём первый аккаунт для этого устройства и приложения if (accountId != -1) { @@ -286,14 +297,14 @@ void GetLastTransactionsScript::doStart() { // Ждём появления кнопки "Поделиться" bool docLoaded = false; - for (int da = 0; da < 10; ++da) { + for (int da = 0; da < 20; ++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"; + qDebug() << "[GetLastTransactions] Waiting for 'Поделиться', attempt" << da + 1 << "/ 20"; QThread::msleep(1500); } diff --git a/android/ozon/PayByPhoneScript.cpp b/android/ozon/PayByPhoneScript.cpp index 2824b07..1ff5c21 100644 --- a/android/ozon/PayByPhoneScript.cpp +++ b/android/ozon/PayByPhoneScript.cpp @@ -3,13 +3,58 @@ #include #include #include +#include #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" + +static QString recognizeTextFromImage(const QByteArray &pngData) { + auto *api = new tesseract::TessBaseAPI(); + if (api->Init(nullptr, "rus+eng")) { + qWarning() << "[OCR] Failed to init Tesseract"; + delete api; + return {}; + } + + Pix *pix = pixReadMem(reinterpret_cast(pngData.constData()), + static_cast(pngData.size())); + if (!pix) { + qWarning() << "[OCR] Failed to read image"; + api->End(); + delete api; + return {}; + } + + api->SetImage(pix); + char *text = api->GetUTF8Text(); + QString result = QString::fromUtf8(text).trimmed(); + delete[] text; + pixDestroy(&pix); + api->End(); + delete api; + + return result; +} + +static QString extractTransactionId(const QString &ocrText) { + // "ID операции A60130542076330K0000040011670301" или "ID операции = Аб60650..." + QRegularExpression reId(R"(ID\s+операции\s*=?\s*([A-Za-zА-Яа-я0-9]+))"); + if (auto m = reId.match(ocrText); m.hasMatch()) return m.captured(1); + + // "№ Ф-2026-25235663" + QRegularExpression reRef(R"(№\s*(Ф-[\d-]+))"); + if (auto m = reRef.match(ocrText); m.hasMatch()) return m.captured(1); + + return {}; +} namespace Ozon { @@ -329,7 +374,106 @@ void PayByPhoneScript::makePayment( return; } - qDebug() << "[PayByPhone] Confirm screen loaded, stopping before confirmation"; + // 8. Нажимаем "Перевести ..." (ищем по первому слову) + Node transferConfirmBtn = xmlScreenParser.findButtonNode("Перевести", true); + if (transferConfirmBtn.isEmpty()) { + m_error = "Кнопка 'Перевести ...' не найдена на экране подтверждения"; + qWarning() << "[PayByPhone]" << m_error; + return; + } + + qDebug() << "[PayByPhone] Tapping confirm button:" << transferConfirmBtn.text; + AdbUtils::makeTap(deviceId, transferConfirmBtn.x(), transferConfirmBtn.y()); + QThread::msleep(3000); + + // 9. Ждём результат: "Успешно" или "Не удалось" + for (int attempt = 0; attempt < 15; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + + if (!xmlScreenParser.findTextNode("Успешно").isEmpty()) { + qDebug() << "[PayByPhone] Payment successful!"; + m_error.clear(); + + // Сохраняем транзакцию в БД + TransactionInfo tx; + tx.accountId = m_account.id; + tx.amount = -m_amount; + tx.phone = m_phone; + tx.bankName = "ozon"; + tx.type = TransactionType::Phone; + tx.status = TransactionStatus::Complete; + tx.timestamp = DateUtils::getUtcNow(); + tx.completeTime = QDateTime::currentDateTime().toTimeZone(QTimeZone("Europe/Moscow")); + tx.bankTime = tx.completeTime.toString("dd.MM.yyyy, HH:mm"); + const int txId = TransactionDAO::insertTransaction(tx); + qDebug() << "[PayByPhone] Saved transaction to DB, id:" << txId; + + // Ищем кнопку "Документы" и достаём ID операции + Node docBtn = xmlScreenParser.findButtonNode("Документы", false); + if (!docBtn.isEmpty() && txId != -1) { + qDebug() << "[PayByPhone] Tapping 'Документы'..."; + AdbUtils::makeTap(deviceId, docBtn.x(), docBtn.y()); + QThread::msleep(2000); + + // Ждём появления кнопки "Поделиться" + bool docLoaded = false; + for (int da = 0; da < 20; ++da) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + Node shareBtn = xmlScreenParser.findNodeByContentDesc("Поделиться"); + if (!shareBtn.isEmpty()) { + docLoaded = true; + break; + } + qDebug() << "[PayByPhone] Waiting for 'Поделиться', attempt" << da + 1 << "/ 20"; + QThread::msleep(1500); + } + + if (docLoaded) { + QThread::msleep(500); + const QByteArray screenshot = AdbUtils::captureScreenshotBytes(deviceId); + if (!screenshot.isEmpty()) { + TransactionDAO::updateReceiptImage(txId, screenshot); + qDebug() << "[PayByPhone] Receipt saved to DB, txId:" << txId; + + const QString ocrText = recognizeTextFromImage(screenshot); + if (!ocrText.isEmpty()) { + qDebug() << "[PayByPhone] OCR result:\n" << ocrText; + const QString externalId = extractTransactionId(ocrText); + if (!externalId.isEmpty()) { + TransactionDAO::updateBankTrExternalId(txId, externalId); + qDebug() << "[PayByPhone] Extracted transaction ID:" << externalId; + } else { + qWarning() << "[PayByPhone] Could not extract transaction ID from OCR text"; + } + } else { + qWarning() << "[PayByPhone] OCR returned empty text"; + } + } else { + qWarning() << "[PayByPhone] Screenshot is empty"; + } + } else { + qWarning() << "[PayByPhone] 'Поделиться' not found, skipping receipt"; + } + + AdbUtils::goBack(deviceId); + QThread::msleep(1000); + } + + return; + } + + if (!xmlScreenParser.findTextNode("Не удалось").isEmpty()) { + m_error = "Перевод не удался (экран 'Не удалось')"; + qWarning() << "[PayByPhone]" << m_error; + return; + } + + qDebug() << "[PayByPhone] Waiting for payment result, attempt" << attempt + 1 << "/ 15"; + QThread::msleep(2000); + } + + m_error = "Не дождались результата перевода"; + qWarning() << "[PayByPhone]" << m_error; } } // namespace Ozon diff --git a/android/ozon/ScreenXmlParser.cpp b/android/ozon/ScreenXmlParser.cpp index 619c33b..32f0ba9 100644 --- a/android/ozon/ScreenXmlParser.cpp +++ b/android/ozon/ScreenXmlParser.cpp @@ -388,38 +388,54 @@ QString ScreenXmlParser::parseProfilePhone() { } QList ScreenXmlParser::findPinCodeNodes(const QString &pinCode) { - // Ozon PIN кнопки — анонимные FrameLayout без text/content-desc. - // Алгоритм не зависит от разрешения: ищем строки из ровно 3 кликабельных узлов. - - // 1. Собираем все кликабельные узлы с ненулевым размером - QList clickable; - for (const Node &node: m_nodes) { - if (node.clickable && node.width() > 0 && node.height() > 0) { - clickable.append(node); + // Способ 1: ищем кликабельные ноды с текстом-цифрой (0-9) + QMap digitMap; + for (const Node &node : m_nodes) { + if (!node.clickable || node.width() <= 0 || node.height() <= 0) continue; + const QString t = node.text.trimmed(); + if (t.size() == 1 && t.at(0).isDigit()) { + digitMap[t] = node; } } + + if (digitMap.size() >= 10) { + QList result; + for (const QChar &c : pinCode) { + const QString d = QString(c); + if (digitMap.contains(d)) { + result.append(digitMap[d]); + } else { + qWarning() << "[findPinCodeNodes] digit not found:" << d; + return {}; + } + } + return result; + } + + // Способ 2 (fallback): анонимные FrameLayout — ищем сетку 4x3 + QList clickable; + for (const Node &node : m_nodes) { + if (node.clickable && node.width() > 0 && node.height() > 0) + clickable.append(node); + } if (clickable.isEmpty()) return {}; - // 2. Находим минимальную высоту — это примерный размер одной кнопки. - // Исключаем полноэкранные элементы (WebView и т.п.) фильтром height <= minH * 3. int minH = INT_MAX; - for (const Node &n: clickable) minH = std::min(minH, n.height()); + for (const Node &n : clickable) minH = std::min(minH, n.height()); QList candidates; - for (const Node &n: clickable) { + for (const Node &n : clickable) { if (n.height() <= minH * 3) candidates.append(n); } - // 3. Сортируем по y std::sort(candidates.begin(), candidates.end(), [](const Node &a, const Node &b) { return a.y() < b.y(); }); - // 4. Группируем в строки: узлы с разницей y < height первого узла строки — одна строка QList> rows; - for (const Node &node: candidates) { + for (const Node &node : candidates) { bool placed = false; - for (QList &row: rows) { + for (QList &row : rows) { if (std::abs(node.y() - row.first().y()) < row.first().height()) { row.append(node); placed = true; @@ -429,45 +445,35 @@ QList ScreenXmlParser::findPinCodeNodes(const QString &pinCode) { if (!placed) rows.append({node}); } - // 5. Внутри каждой строки сортируем по x (слева направо) - for (QList &row: rows) { + for (QList &row : rows) { std::sort(row.begin(), row.end(), [](const Node &a, const Node &b) { return a.x() < b.x(); }); } - // 6. PIN-сетка — строки ровно с 3 узлами (3 столбца) - // Строки 1-3: цифры 1-9 - // Строка 4: (биометрия) [0] (backspace) → берём средний элемент - qDebug() << "[findPinCodeNodes] clickable:" << clickable.size() - << "minH:" << minH << "candidates:" << candidates.size() << "rows:" << rows.size(); - for (int i = 0; i < rows.size(); ++i) { - QStringList info; - for (const Node &n : rows[i]) - info << QString("(%1,%2 %3x%4 '%5')").arg(n.x()).arg(n.y()).arg(n.width()).arg(n.height()).arg(n.text.left(15)); - qDebug() << "[findPinCodeNodes] row" << i << "size:" << rows[i].size() << info.join(" | "); - } - QList> gridRows; - for (const QList &row: rows) { + for (const QList &row : rows) { if (row.size() == 3) gridRows.append(row); } - qDebug() << "[findPinCodeNodes] gridRows:" << gridRows.size() << "pinCode len:" << pinCode.size(); - QMap digitMap; + if (gridRows.size() < 4) { + qWarning() << "[findPinCodeNodes] grid not found: gridRows=" << gridRows.size(); + return {}; + } + const QStringList digits19 = {"1", "2", "3", "4", "5", "6", "7", "8", "9"}; int idx = 0; for (int r = 0; r < gridRows.size() - 1 && idx < digits19.size(); ++r) { - for (const Node &n: gridRows[r]) { + for (const Node &n : gridRows[r]) { if (idx < digits19.size()) digitMap[digits19[idx++]] = n; } } - if (!gridRows.isEmpty() && gridRows.last().size() >= 2) { - digitMap["0"] = gridRows.last()[1]; // средний столбец последней строки + if (gridRows.last().size() >= 2) { + digitMap["0"] = gridRows.last()[1]; } QList result; - for (const QChar &c: pinCode) { + for (const QChar &c : pinCode) { if (const QString d = QString(c); digitMap.contains(d)) { result.append(digitMap[d]); } diff --git a/assets/error.png b/assets/error.png new file mode 100644 index 0000000..e10d2ce Binary files /dev/null and b/assets/error.png differ diff --git a/assets/ozon/pay/confirm_payment.png b/assets/ozon/pay/confirm_payment.png new file mode 100644 index 0000000..e8133f9 Binary files /dev/null and b/assets/ozon/pay/confirm_payment.png differ diff --git a/assets/ozon/pay/confirm_payment.xml b/assets/ozon/pay/confirm_payment.xml new file mode 100644 index 0000000..2918a8a --- /dev/null +++ b/assets/ozon/pay/confirm_payment.xml @@ -0,0 +1,539 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/ozon/pay/confirm_payment_error.png b/assets/ozon/pay/confirm_payment_error.png new file mode 100644 index 0000000..f45651c Binary files /dev/null and b/assets/ozon/pay/confirm_payment_error.png differ diff --git a/assets/ozon/pay/confirm_payment_error.xml b/assets/ozon/pay/confirm_payment_error.xml new file mode 100644 index 0000000..c187dca --- /dev/null +++ b/assets/ozon/pay/confirm_payment_error.xml @@ -0,0 +1,698 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/ozon/pay/confirm_payment_success.png b/assets/ozon/pay/confirm_payment_success.png new file mode 100644 index 0000000..8555d5e Binary files /dev/null and b/assets/ozon/pay/confirm_payment_success.png differ diff --git a/assets/ozon/pay/confirm_payment_success.xml b/assets/ozon/pay/confirm_payment_success.xml new file mode 100644 index 0000000..cbbff82 --- /dev/null +++ b/assets/ozon/pay/confirm_payment_success.xml @@ -0,0 +1,780 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/config.ini b/config.ini index c4c5a2f..6bde3c8 100644 --- a/config.ini +++ b/config.ini @@ -2,13 +2,13 @@ list=ozon [common] -access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjNDU1MjE1Ni1kMTc5LTRhNzktOWU5MS01ODcwMzhjOTVjZjAiLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcyNzE0MTQyfQ.-074b8eEWeLgzoxxpq5o54G1npivuC_VWvu4NLEz_gY +access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjNDU1MjE1Ni1kMTc5LTRhNzktOWU5MS01ODcwMzhjOTVjZjAiLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcyODg4MjU2fQ.5WF3EQobCOQpuQr5wcCeYOJW1ER0RJG506WRhEB_Y5E api_key=0eee77b62013253e02e1a69bc6a505bb37757fb7 currencies=USD, KRW, AZN, RUB desktop_id=b86eac8e-dc89-4a9e-a5e8-7f85db011391 profile_id=c4552156-d179-4a79-9e91-587038c95cf0 -refresh_token=def3036b-b62d-4550-8971-94d7ef57eba2 -token_expires_at=1772714142 +refresh_token=4c78f097-d70b-4bdb-b351-c839fa9eb442 +token_expires_at=1772888256 version=0.0.1 [db] diff --git a/database/DatabaseManager.cpp b/database/DatabaseManager.cpp index 327ba4e..dab0bde 100644 --- a/database/DatabaseManager.cpp +++ b/database/DatabaseManager.cpp @@ -361,6 +361,16 @@ void DatabaseManager::initSchema() { q.exec("PRAGMA user_version = 11"); qDebug() << "[DB] Migration 11 applied"; } + + // ── Версия 12: json и sent_to_server в events ──────────────────────────── + if (version < 12) { + db.transaction(); + q.exec("ALTER TABLE events ADD COLUMN json TEXT DEFAULT ''"); + q.exec("ALTER TABLE events ADD COLUMN sent_to_server INTEGER DEFAULT 0"); + db.commit(); + q.exec("PRAGMA user_version = 12"); + qDebug() << "[DB] Migration 12 applied"; + } } void DatabaseManager::closeConnection() { diff --git a/database/app_data.db b/database/app_data.db index eb4db1c..c2ba19f 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 e5a3f6a..e4b3c8b 100644 --- a/database/dao/AccountDAO.cpp +++ b/database/dao/AccountDAO.cpp @@ -224,8 +224,10 @@ QStringList AccountDAO::getActiveMaterialIds() { SELECT a.material_id FROM accounts a JOIN applications app ON a.device_id = app.device_id AND a.app_code = app.code + JOIN devices d ON a.device_id = d.id WHERE app.status = 'active' AND a.material_id IS NOT NULL AND a.material_id != '' + AND d.status = 'device' AND d.image IS NOT NULL )"); if (!query.exec()) { diff --git a/database/dao/BankDAO.cpp b/database/dao/BankDAO.cpp index bb9d5bf..53eb335 100644 --- a/database/dao/BankDAO.cpp +++ b/database/dao/BankDAO.cpp @@ -29,6 +29,19 @@ QList BankDAO::getAll() { return list; } +BankInfo BankDAO::findByAlias(const QString &alias) { + QSqlQuery query(DatabaseManager::instance().database()); + query.prepare("SELECT * FROM banks WHERE alias = :alias LIMIT 1"); + query.bindValue(":alias", alias); + if (!query.exec() || !query.next()) return {}; + 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(); + return bank; +} + void BankDAO::replaceAll(const QList &banks) { QSqlDatabase db = DatabaseManager::instance().database(); QSqlQuery query(db); diff --git a/database/dao/BankDAO.h b/database/dao/BankDAO.h index dbe2f8b..12093cd 100644 --- a/database/dao/BankDAO.h +++ b/database/dao/BankDAO.h @@ -7,5 +7,7 @@ class BankDAO { public: [[nodiscard]] static QList getAll(); + [[nodiscard]] static BankInfo findByAlias(const QString &alias); + static void replaceAll(const QList &banks); }; diff --git a/database/dao/EventDAO.cpp b/database/dao/EventDAO.cpp index 379a9d9..73c0ff2 100644 --- a/database/dao/EventDAO.cpp +++ b/database/dao/EventDAO.cpp @@ -18,6 +18,8 @@ EventInfo parseEvent(const QSqlQuery &query) { event.phone = query.value("phone").toString(); event.bankName = query.value("bank_name").toString(); event.comment = query.value("comment").toString(); + event.json = query.value("json").toString(); + event.sentToServer = query.value("sent_to_server").toBool(); event.timestamp = DateUtils::fromUtcString(query.value("timestamp").toString()); event.updateTime = DateUtils::fromUtcString(query.value("update_time").toString()); event.completeTime = DateUtils::fromUtcString(query.value("complete_time").toString()); @@ -29,9 +31,9 @@ int EventDAO::insertEvent(const EventInfo &event) { query.prepare(R"( INSERT INTO events ( - status, external_id, account_id, amount, phone, bank_name, timestamp, device_id, type + status, external_id, account_id, amount, phone, bank_name, comment, json, timestamp, device_id, type ) VALUES ( - :status, :external_id, :account_id, :amount, :phone, :bank_name, :timestamp, :device_id, :type + :status, :external_id, :account_id, :amount, :phone, :bank_name, :comment, :json, :timestamp, :device_id, :type ) )"); @@ -43,6 +45,8 @@ int EventDAO::insertEvent(const EventInfo &event) { query.bindValue(":amount", event.amount); query.bindValue(":phone", event.phone); query.bindValue(":bank_name", event.bankName); + query.bindValue(":comment", event.comment); + query.bindValue(":json", event.json); query.bindValue(":timestamp", DateUtils::toUtcString(event.timestamp)); if (!query.exec()) { @@ -114,12 +118,24 @@ bool EventDAO::updateEventAmount(int id, double amount) { return true; } +bool EventDAO::markSentToServer(int id) { + QSqlQuery query(DatabaseManager::instance().database()); + query.prepare("UPDATE events SET sent_to_server = 1 WHERE id = :id"); + query.bindValue(":id", id); + if (!query.exec()) { + qWarning() << "Ошибка markSentToServer:" << query.lastError().text(); + return false; + } + return true; +} + QList EventDAO::getAllEvents(const EventStatus &status) { QList events; QSqlQuery query(DatabaseManager::instance().database()); query.prepare(R"( - SELECT id, account_id, device_id, external_id, amount, phone, bank_name, status, update_time, complete_time, timestamp, type + SELECT id, account_id, device_id, external_id, amount, phone, bank_name, + status, type, comment, json, sent_to_server, update_time, complete_time, timestamp FROM events WHERE status = :status )"); @@ -141,7 +157,7 @@ EventInfo EventDAO::getFirstWaitEventByDeviceId(const QString &key) { QSqlQuery query(DatabaseManager::instance().database()); query.prepare(R"( SELECT id, account_id, device_id, external_id, amount, phone, bank_name, - status, type, comment, update_time, complete_time, timestamp + status, type, comment, json, sent_to_server, update_time, complete_time, timestamp FROM events WHERE status = :status AND device_id = :device_id ORDER BY timestamp ASC @@ -166,7 +182,7 @@ QList EventDAO::getEvents(int page, int pageSize) { QSqlQuery query(DatabaseManager::instance().database()); query.prepare(R"( SELECT id, account_id, device_id, external_id, amount, phone, bank_name, - status, type, comment, update_time, complete_time, timestamp + status, type, comment, json, sent_to_server, update_time, complete_time, timestamp FROM events ORDER BY id DESC LIMIT :limit OFFSET :offset diff --git a/database/dao/EventDAO.h b/database/dao/EventDAO.h index c3fe337..45b3fdc 100644 --- a/database/dao/EventDAO.h +++ b/database/dao/EventDAO.h @@ -20,6 +20,8 @@ public: [[nodiscard]] static bool updateEventAmount(int id, double amount); + [[nodiscard]] static bool markSentToServer(int id); + [[nodiscard]] static QList getEvents(int page, int pageSize); [[nodiscard]] static int getTotalCount(); diff --git a/database/dao/TransactionDAO.cpp b/database/dao/TransactionDAO.cpp index 79a163f..d0c89a6 100644 --- a/database/dao/TransactionDAO.cpp +++ b/database/dao/TransactionDAO.cpp @@ -67,6 +67,7 @@ bool TransactionDAO::insertTransactionIfNotExists(const TransactionInfo &info) { query.bindValue(":bank_tr_external_id", info.bankTrExternalId); query.bindValue(":update_time", DateUtils::toUtcString(info.updateTime)); query.bindValue(":complete_time", DateUtils::toUtcString(info.completeTime)); + query.bindValue(":timestamp", DateUtils::toUtcString(info.timestamp)); if (!query.exec()) { qWarning() << "Ошибка при вставке transaction:" << query.lastError().text(); diff --git a/models/db/EventInfo.h b/models/db/EventInfo.h index 65da4da..de8fe5e 100644 --- a/models/db/EventInfo.h +++ b/models/db/EventInfo.h @@ -67,6 +67,9 @@ struct EventInfo { EventStatus status = EventStatus::Unknown; EventType type = EventType::Unknown; + QString json; + bool sentToServer = false; + QByteArray screenshot; QDateTime updateTime; diff --git a/services/DeviceScreener.cpp b/services/DeviceScreener.cpp index d341280..9f99746 100644 --- a/services/DeviceScreener.cpp +++ b/services/DeviceScreener.cpp @@ -203,7 +203,7 @@ void DeviceScreener::postScreenshotToApi(const QString &apiId, const QByteArray } else { const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); if (doc.isObject() && doc.object()["success"].toBool()) { - qDebug() << "postScreenshotToApi done for device" << apiId; + // ok } else { qWarning() << "postScreenshotToApi failed for device" << apiId << ":" << doc.toJson(QJsonDocument::Compact); } @@ -229,7 +229,6 @@ void DeviceScreener::toggleBankProfilesOnApi(const QString &deviceId, bool enabl const QString enableStr = enable ? "true" : "false"; const QString path = "/api/v1/profile/bank_profile/" + app.bankProfileId + "/" + enableStr; - qDebug() << "[DeviceScreener] toggleBankProfile:" << app.code << app.bankProfileId << "enable=" << enable; apiPatch(path, QJsonObject()); } } diff --git a/services/EventHandler.cpp b/services/EventHandler.cpp index e3b83e7..f06324d 100644 --- a/services/EventHandler.cpp +++ b/services/EventHandler.cpp @@ -1,18 +1,23 @@ #include "EventHandler.h" +#include +#include #include #include #include #include #include #include +#include #include "dao/AccountDAO.h" #include "dao/ApplicationDAO.h" +#include "dao/BankDAO.h" #include "dao/DeviceDAO.h" #include "dao/EventDAO.h" #include "dao/TransactionDAO.h" #include "db/ApplicationInfo.h" +#include "adb/AdbUtils.h" #include "net/NetworkService.h" #include "birbank/GetLastDaysHistoryBirbankScript.h" #include "birbank/LoginAndCheckAccountsBirbankScript.h" @@ -24,8 +29,11 @@ #include "toss/LoginAndCheckAccountsTossScript.h" #include "ziraat/GetLastDaysHistoryZiraatScript.h" #include "ziraat/LoginAndCheckAccountsZiraatScript.h" +#include "ozon/PayByPhoneScript.h" +#include "rshb/PayByPhoneScript.h" static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonValue &data); +static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description); EventHandler::EventHandler(QObject *parent) : QObject(parent) {} @@ -41,6 +49,18 @@ void EventHandler::start() { connect(m_pollTimer, &QTimer::timeout, m_network, &NetworkService::getTasks); m_pollTimer->start(); + // Закрываем зависшие события с прошлого запуска (только не отправленные на сервер) + for (const EventStatus st : {EventStatus::Wait, EventStatus::InProgress}) { + const QList stale = EventDAO::getAllEvents(st); + for (const EventInfo &e : stale) { + if (e.sentToServer) continue; + EventDAO::updateEvent(e.id, EventStatus::Error, "App restarted"); + EventDAO::markSentToServer(e.id); + sendTaskError(eventTypeToString(e.type), e.externalId, e.bankName, "App restarted, event was pending"); + qDebug() << "[EventHandler] Closed stale event id:" << e.id; + } + } + // Первый запрос сразу при старте qDebug() << "[EventHandler] starting first getTasks()"; m_network->getTasks(); @@ -74,10 +94,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) { 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, QJsonValue(data)); + sendTaskError(typeStr, materialId, bankName, "Account not found for material_id: " + materialId); continue; } @@ -89,11 +106,19 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) { event.deviceId = account.deviceId; event.bankName = bankName; event.timestamp = QDateTime::currentDateTimeUtc(); + event.json = QJsonDocument(task).toJson(QJsonDocument::Compact); const QJsonObject body = task["body"].toObject(); if (eventType == EventType::CreateTransaction) { - event.amount = body["amount"].toDouble(); + event.amount = body["amount"].toDouble() / 100.0; event.phone = body["bank_participator_id"].toString(); + + // Резолвим название банка-получателя по алиасу + const QString bankAlias = body["extra"].toObject()["bank_participator_name"].toString(); + if (!bankAlias.isEmpty()) { + const BankInfo bank = BankDAO::findByAlias(bankAlias); + event.comment = !bank.nspkName.isEmpty() ? bank.nspkName : bank.name; + } } const int eventId = EventDAO::insertEvent(event); @@ -106,10 +131,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) { if (!onlineDevices.contains(account.deviceId)) { qWarning() << "[EventHandler] Device offline for material_id:" << materialId << "device:" << account.deviceId; EventDAO::updateEvent(eventId, EventStatus::Error, "Device is offline"); - QJsonObject errData; - errData["status"] = "error"; - errData["description"] = "Device is offline"; - sendTaskResult(typeStr, materialId, bankName, QJsonValue(errData)); + sendTaskError(typeStr, materialId, bankName, "Device is offline"); } } @@ -125,10 +147,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) { if (waitSecs > 5 * 60) { qWarning() << "[EventHandler] Task expired after" << waitSecs << "s, event id:" << event.id; EventDAO::updateEvent(event.id, EventStatus::Error, "Task expired: waited " + QString::number(waitSecs) + "s"); - QJsonObject errData; - errData["status"] = "error"; - errData["description"] = "Task expired: not started within 5 minutes"; - sendTaskResult(eventTypeToString(event.type), event.externalId, event.bankName, QJsonValue(errData)); + sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Task expired: not started within 5 minutes"); continue; } @@ -146,7 +165,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) { } static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, - const QJsonValue &data) { + const QJsonValue &data, int eventId = -1) { QJsonObject obj; obj["type"] = type; obj["material_id"] = materialId; // UUID string @@ -157,13 +176,51 @@ static void sendTaskResult(const QString &type, const QString &materialId, const auto *networkService = new NetworkService; networkService->moveToThread(thread); networkService->setPayload(QJsonDocument(obj).toJson()); - QObject::connect(thread, &QThread::started, networkService, &NetworkService::postTaskResult); + QObject::connect(thread, &QThread::started, networkService, [networkService, eventId]() { + networkService->postTaskResult(); + if (eventId != -1) { + EventDAO::markSentToServer(eventId); + } + }); 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 sendTaskError(const QString &type, const QString &materialId, const QString &bankName, + const QString &description) { + const QString message = QStringLiteral("[%1] bank=%2 material=%3: %4") + .arg(type, bankName, materialId, description); + + auto *thread = new QThread; + auto *networkService = new NetworkService; + networkService->moveToThread(thread); + QObject::connect(thread, &QThread::started, networkService, [networkService, message]() { + networkService->postMonitoringLog("error", "task_error", message, {}); + emit networkService->finished(); + }); + 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 sendMonitoringLog(const QString &type, const QString &event, const QString &message, + const QByteArray &image = {}) { + auto *thread = new QThread; + auto *networkService = new NetworkService; + networkService->moveToThread(thread); + QObject::connect(thread, &QThread::started, networkService, [networkService, type, event, message, image]() { + networkService->postMonitoringLog(type, event, message, image); + emit networkService->finished(); + }); + 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 QString &externalId, const int eventId, const QString &status, const QString &error) { QJsonObject obj; @@ -190,58 +247,98 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event, if (!EventDAO::updateEvent(event.id, newStatus, result)) { qCritical() << "[EventHandler] Failed to update event:" << event.id; } else { - QJsonValue taskData; if (newStatus == EventStatus::Error) { - QJsonObject errObj; - errObj["status"] = "error"; - errObj["description"] = result; - taskData = errObj; - } else if (event.type == EventType::FetchProfile) { - const AccountInfo account = AccountDAO::getAccountById(event.accountId); - EventDAO::updateEventAmount(event.id, account.amount); - QJsonObject obj; - obj["bank_account_id"] = QString(); - obj["amount"] = static_cast(account.amount); - obj["currency"] = 643; // RUB - taskData = obj; - } else if (event.type == EventType::FetchTransactions) { - QList txList = TransactionDAO::getTransactionsWithinHours(event.accountId, 24); - if (txList.isEmpty()) { - txList = TransactionDAO::getLastTransactions(event.accountId, 10); - } - QJsonArray arr; - for (const auto &tx : txList) { - QJsonObject txObj; - txObj["bank_transaction_id"] = tx.bankTrExternalId.isEmpty() ? QJsonValue(QJsonValue::Null) : QJsonValue(tx.bankTrExternalId); - txObj["amount"] = static_cast(tx.amount); - txObj["currency"] = 643; // RUB - txObj["transaction_type"] = (tx.type == TransactionType::Phone) ? "top-up" : "bank"; - txObj["bank_participator_id"]= tx.phone; - txObj["status"] = tx.status == TransactionStatus::Complete ? "completed" : (tx.status == TransactionStatus::Wait ? "progress" : "completed"); - txObj["extra"] = QJsonObject(); - if (tx.timestamp.isValid()) { - txObj["created_at"] = tx.timestamp.toString(Qt::ISODate); - } else if (!tx.bankTime.isEmpty()) { - QDateTime dt = QDateTime::fromString(tx.bankTime, "dd.MM.yyyy, HH:mm"); - if (!dt.isValid()) dt = QDateTime::fromString(tx.bankTime, "dd.MM.yyyy HH:mm"); - txObj["created_at"] = dt.isValid() ? dt.toString(Qt::ISODate) : QJsonValue(QJsonValue::Null); - } else { - txObj["created_at"] = QJsonValue(QJsonValue::Null); - } - arr.append(txObj); - } - taskData = arr; - } else { - QJsonObject obj; - obj["status"] = "completed"; - taskData = obj; + sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, result); + EventDAO::markSentToServer(event.id); + } + + if (newStatus != EventStatus::Error) { + QJsonValue taskData; + if (event.type == EventType::FetchProfile) { + const AccountInfo account = AccountDAO::getAccountById(event.accountId); + EventDAO::updateEventAmount(event.id, account.amount); + QJsonObject obj; + obj["bank_account_id"] = QString(); + obj["amount"] = static_cast(account.amount * 100); + obj["currency"] = 643; // RUB + taskData = obj; + } else if (event.type == EventType::FetchTransactions) { + QList txList = TransactionDAO::getTransactionsWithinHours(event.accountId, 24); + QJsonArray arr; + for (const auto &tx : txList) { + QJsonObject txObj; + txObj["bank_transaction_id"] = tx.bankTrExternalId.isEmpty() ? QJsonValue(QJsonValue::Null) : QJsonValue(tx.bankTrExternalId); + txObj["amount"] = static_cast(tx.amount * 100); + txObj["currency"] = 643; // RUB + txObj["transaction_type"] = (tx.type == TransactionType::Phone) ? "top-up" : "bank"; + txObj["bank_participator_id"]= tx.phone; + txObj["status"] = tx.status == TransactionStatus::Complete ? "completed" : (tx.status == TransactionStatus::Wait ? "progress" : "completed"); + txObj["extra"] = QJsonObject(); + if (tx.timestamp.isValid()) { + txObj["created_at"] = tx.timestamp.toTimeZone(QTimeZone("Europe/Moscow")).toString(Qt::ISODateWithMs); + } else if (!tx.bankTime.isEmpty()) { + QDateTime dt = QDateTime::fromString(tx.bankTime, "dd.MM.yyyy, HH:mm"); + if (!dt.isValid()) dt = QDateTime::fromString(tx.bankTime, "dd.MM.yyyy HH:mm"); + if (dt.isValid()) { + dt.setTimeZone(QTimeZone("Europe/Moscow")); + txObj["created_at"] = dt.toString(Qt::ISODateWithMs); + } else { + txObj["created_at"] = QJsonValue(QJsonValue::Null); + } + } else { + txObj["created_at"] = QJsonValue(QJsonValue::Null); + } + arr.append(txObj); + } + taskData = arr; + } else if (event.type == EventType::CreateTransaction) { + QJsonObject obj; + obj["amount"] = -static_cast(event.amount * 100); + obj["currency"] = 643; + obj["transaction_type"] = "top-up"; + obj["bank_participator_id"]= event.phone; + obj["status"] = "completed"; + obj["created_at"] = QDateTime::currentDateTime().toTimeZone(QTimeZone("Europe/Moscow")).toString(Qt::ISODateWithMs); + + // Берём bank_transaction_id из последней сохранённой транзакции + QList txList = TransactionDAO::getTransactionsWithinHours(event.accountId, 1); + for (const auto &tx : txList) { + if (!tx.bankTrExternalId.isEmpty() && qAbs(tx.amount) == event.amount && tx.phone == event.phone) { + obj["bank_transaction_id"] = tx.bankTrExternalId; + break; + } + } + + taskData = obj; + } else { + QJsonObject obj; + obj["status"] = "completed"; + taskData = obj; + } + sendTaskResult( + eventTypeToString(event.type), + event.externalId, + event.bankName, + taskData, + event.id + ); + } + + if (newStatus == EventStatus::Complete && event.type == EventType::CreateTransaction) { + const QString msg = QStringLiteral("[CREATE_TRANSACTION] bank=%1 material=%2 amount=%3 phone=%4") + .arg(event.bankName, event.externalId, QString::number(event.amount, 'f', 2), event.phone); + QByteArray screenshot; + const QString tmpPath = QDir::tempPath() + "/arc_tx_" + QString::number(event.id); + if (AdbUtils::takeScreenshot(event.deviceId, tmpPath)) { + QFile f(tmpPath + ".png"); + if (f.open(QIODevice::ReadOnly)) { + screenshot = f.readAll(); + f.close(); + } + f.remove(); + } + sendMonitoringLog("info", "task_success", msg, screenshot); } - sendTaskResult( - eventTypeToString(event.type), - event.externalId, - event.bankName, - taskData - ); } if (const auto tm = m_timers.take(key)) { @@ -292,32 +389,35 @@ void EventHandler::startThreadForTask(const AccountInfo &account, const EventInf } if (event.type == EventType::CreateTransaction) { - // TODO: реализовать CREATE_TRANSACTION + const QString recipientBank = event.comment; // резолвленное имя банка-получателя + if (appCode == "ozon") setup(new Ozon::PayByPhoneScript(account, event.phone, recipientBank, event.amount)); + else if (appCode == "rshb") setup(new Rshb::PayByPhoneScript(account, event.phone, recipientBank, event.amount)); + else qWarning() << "[EventHandler] CREATE_TRANSACTION: unknown appCode:" << appCode; } - // Watchdog-таймер: 5 минут — убиваем зависший поток - const auto timer = new QTimer(this); - timer->setSingleShot(true); - connect(timer, &QTimer::timeout, this, [this, key]() { - qWarning() << "[EventHandler] Timeout for key" << key << "- killing stuck thread."; - if (QThread *thread = m_threads.value(key); thread && thread->isRunning()) { - thread->requestInterruption(); - if (!thread->wait(500)) { - qWarning() << "[EventHandler] Hard terminate for key" << key; - thread->terminate(); - thread->wait(); - } - } - if (const auto tm = m_timers.take(key)) { - tm->deleteLater(); - } - m_threads.remove(key); - }); + // // Watchdog-таймер: 5 минут — убиваем зависший поток + // const auto timer = new QTimer(this); + // timer->setSingleShot(true); + // connect(timer, &QTimer::timeout, this, [this, key]() { + // qWarning() << "[EventHandler] Timeout for key" << key << "- killing stuck thread."; + // if (QThread *thread = m_threads.value(key); thread && thread->isRunning()) { + // thread->requestInterruption(); + // if (!thread->wait(500)) { + // qWarning() << "[EventHandler] Hard terminate for key" << key; + // thread->terminate(); + // thread->wait(); + // } + // } + // if (const auto tm = m_timers.take(key)) { + // tm->deleteLater(); + // } + // m_threads.remove(key); + // }); m_threads[key] = thr; - m_timers[key] = timer; + // m_timers[key] = timer; - timer->start(5 * 60 * 1000); + // timer->start(5 * 60 * 1000); thr->start(); } diff --git a/services/net/NetworkService.cpp b/services/net/NetworkService.cpp index 9bc7448..ae0c684 100644 --- a/services/net/NetworkService.cpp +++ b/services/net/NetworkService.cpp @@ -1,5 +1,6 @@ #include "NetworkService.h" +#include #include #include #include @@ -626,6 +627,10 @@ void NetworkService::getTasks() { QUrl url(m_apiBase + m_pathGetTasks); QUrlQuery urlQuery; const QStringList materialIds = AccountDAO::getActiveMaterialIds(); + if (materialIds.isEmpty()) { + m_fetchingTasks = false; + return; + } for (const QString &mid : materialIds) { urlQuery.addQueryItem("materials_id", mid); } @@ -665,7 +670,7 @@ void NetworkService::getTasks() { 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()) { + if (result.isUndefined()) { qWarning() << "[NetworkService] postTaskResult failed, body:" << QJsonDocument(body).toJson(QJsonDocument::Compact); } emit finished(); @@ -675,18 +680,20 @@ void NetworkService::postMonitoringLog(const QString &type, const QString &event const QString &message, const QByteArray &image) { auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); - addFormField(multiPart, "type", type); + addFormField(multiPart, "type", type.toUpper()); addFormField(multiPart, "event", event); addFormField(multiPart, "message", message); - QHttpPart imagePart; - imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/png"); - imagePart.setHeader( - QNetworkRequest::ContentDispositionHeader, - QStringLiteral("form-data; name=\"image\"; filename=\"screenshot.png\"") - ); - imagePart.setBody(image.isEmpty() ? QByteArray(1, '\0') : image); - multiPart->append(imagePart); + if (!image.isEmpty()) { + QHttpPart imagePart; + imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/png"); + imagePart.setHeader( + QNetworkRequest::ContentDispositionHeader, + QStringLiteral("form-data; name=\"image\"; filename=\"screenshot.png\"") + ); + imagePart.setBody(image); + multiPart->append(imagePart); + } const QUrl url(m_apiBase + m_pathMonitoringLog); QNetworkRequest request(url); @@ -694,12 +701,15 @@ void NetworkService::postMonitoringLog(const QString &type, const QString &event QNetworkReply *reply = m_manager->post(request, multiPart); multiPart->setParent(reply); - connect(reply, &QNetworkReply::finished, reply, [reply]() { - if (reply->error() != QNetworkReply::NoError) { - qWarning() << "[NetworkService] postMonitoringLog failed:" << reply->errorString(); - } - reply->deleteLater(); - }); + QEventLoop loop; + connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + + if (reply->error() != QNetworkReply::NoError) { + qWarning() << "[NetworkService] postMonitoringLog failed:" << reply->errorString() + << "\n response body:" << reply->readAll(); + } + reply->deleteLater(); } void NetworkService::start() { diff --git a/views/event/EventWindow.cpp b/views/event/EventWindow.cpp index 0eb7636..e4a12f8 100644 --- a/views/event/EventWindow.cpp +++ b/views/event/EventWindow.cpp @@ -1,9 +1,11 @@ #include "EventWindow.h" +#include #include #include #include #include +#include #include #include @@ -18,7 +20,9 @@ static constexpr int COL_DEVICE = 4; static constexpr int COL_STATUS = 5; static constexpr int COL_AMOUNT = 6; static constexpr int COL_COMMENT = 7; -static constexpr int COL_ACTION = 8; +static constexpr int COL_JSON = 8; +static constexpr int COL_SENT = 9; +static constexpr int COL_ACTION = 10; static void sendErrorToServer(const QString &type, const QString &materialId, const QString &bankName) { QJsonObject errData; @@ -63,13 +67,13 @@ static QString typeDisplayName(EventType t) { EventWindow::EventWindow(QWidget *parent) : QDialog(parent) { setWindowTitle("События"); - resize(1100, 600); + resize(1400, 600); // ── Таблица ─────────────────────────────────────────────────────────────── m_table = new QTableWidget(this); - m_table->setColumnCount(9); + m_table->setColumnCount(11); m_table->setHorizontalHeaderLabels({ - "#", "Время", "Тип", "Банк", "Устройство", "Статус", "Сумма", "Комментарий", "" + "#", "Время", "Тип", "Банк", "Устройство", "Статус", "Сумма", "Комментарий", "JSON", "Отпр.", "" }); m_table->setEditTriggers(QAbstractItemView::NoEditTriggers); m_table->setSelectionBehavior(QAbstractItemView::SelectRows); @@ -83,6 +87,8 @@ EventWindow::EventWindow(QWidget *parent) : QDialog(parent) { m_table->setColumnWidth(COL_DEVICE, 120); m_table->setColumnWidth(COL_STATUS, 100); m_table->setColumnWidth(COL_AMOUNT, 80); + m_table->setColumnWidth(COL_JSON, 200); + m_table->setColumnWidth(COL_SENT, 45); m_table->setColumnWidth(COL_ACTION, 80); // ── Пагинация ───────────────────────────────────────────────────────────── @@ -116,6 +122,24 @@ EventWindow::EventWindow(QWidget *parent) : QDialog(parent) { m_page = 0; loadPage(); }); + connect(m_table, &QTableWidget::cellDoubleClicked, this, [this](int row, int col) { + if (col != COL_JSON) return; + auto *item = m_table->item(row, col); + if (!item) return; + const QString json = item->toolTip(); + if (json.isEmpty()) return; + + auto *dlg = new QDialog(this); + dlg->setWindowTitle("JSON"); + dlg->resize(600, 400); + auto *edit = new QPlainTextEdit(dlg); + edit->setReadOnly(true); + edit->setPlainText(QJsonDocument::fromJson(json.toUtf8()).toJson(QJsonDocument::Indented)); + auto *layout = new QVBoxLayout(dlg); + layout->addWidget(edit); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->show(); + }); loadPage(); } @@ -141,6 +165,12 @@ void EventWindow::loadPage() { e.amount > 0 ? QString::number(e.amount, 'f', 2) : "—")); m_table->setItem(i, COL_COMMENT, new QTableWidgetItem(e.comment)); + auto *jsonItem = new QTableWidgetItem(e.json.left(80)); + jsonItem->setToolTip(e.json); + m_table->setItem(i, COL_JSON, jsonItem); + + m_table->setItem(i, COL_SENT, new QTableWidgetItem(e.sentToServer ? "+" : "")); + // Подсветка строки по статусу QColor bg; switch (e.status) {