From 628323184ff0f82a8a88a4cf7b5ea8122160e418 Mon Sep 17 00:00:00 2001 From: slava Date: Mon, 13 Apr 2026 22:41:15 +0700 Subject: [PATCH] Refine transaction fetching and parsing: - Add date header parsing and swipe functionality for streamlined `Ozon` transactions list processing. - Improve deduplication logic, date assignment, and scroll behavior in `Dushanbe` and `Ozon` scripts. - Introduce screenshot handling for debugging transaction fetching workflows. - Add cancellation logic by `materialId` in `EventDAO`. - Update mock server for enhanced filename generation and PATCH support for bank profiles. --- .../dushanbe/GetLastTransactionsScript.cpp | 59 +++- android/dushanbe/GetLastTransactionsScript.h | 3 + android/ozon/GetLastTransactionsScript.cpp | 77 +++-- database/dao/EventDAO.cpp | 72 ++++- database/dao/EventDAO.h | 4 +- services/EventHandler.cpp | 35 ++- tests/mock_server/default_profile.json | 46 +-- tests/mock_server/gen_fetch_transactions.py | 282 ++++++++++++++---- tests/mock_server/gen_mock_data.py | 5 +- tests/mock_server/mock_server.py | 17 ++ 10 files changed, 467 insertions(+), 133 deletions(-) diff --git a/android/dushanbe/GetLastTransactionsScript.cpp b/android/dushanbe/GetLastTransactionsScript.cpp index ae38e46..45bef9a 100644 --- a/android/dushanbe/GetLastTransactionsScript.cpp +++ b/android/dushanbe/GetLastTransactionsScript.cpp @@ -328,6 +328,16 @@ void GetLastTransactionsScript::doStart() { AdbUtils::makeTap(m_deviceId, vypiskaBtn.x(), vypiskaBtn.y()); QThread::msleep(3000); + // Pull-to-refresh: свайп сверху вниз для обновления списка + { + const int x = device.screenWidth / 2; + const int fromY = device.screenHeight / 4; + const int toY = device.screenHeight * 3 / 4; + AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY); + QThread::msleep(3000); + qDebug() << "[Dushanbe::GetLastTransactions] Pull-to-refresh on Выписка"; + } + // Сохраняем дамп Выписки для анализа формата xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); @@ -342,6 +352,9 @@ void GetLastTransactionsScript::doStart() { } } + // Скриншот списка транзакций — отправим при ошибке, удалим при успехе + const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId); + // Находим accountId int accountId = -1; const QList accounts = MaterialDAO::getAccounts(device.id, m_appCode); @@ -354,10 +367,8 @@ void GetLastTransactionsScript::doStart() { qDebug() << "[Dushanbe::GetLastTransactions] Searching: amount=" << m_searchAmount << "time=" << m_searchTime.toString(Qt::ISODate); - // Время в банковском приложении показывается в таймзоне устройства - const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId); - QTimeZone tjTz(tzName.toUtf8()); - if (!tjTz.isValid()) tjTz = QTimeZone("Asia/Dushanbe"); + // Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства + const QTimeZone tjTz("Asia/Dushanbe"); const int maxScrolls = 4; auto interrupted = []() { @@ -421,22 +432,45 @@ void GetLastTransactionsScript::doStart() { } // 2. Собираем View-транзакции (формат Выписки) + // Два формата content-desc: + // a) DCWallet/*** — старый: DCWallet*WDC...\ncard\namount\nописание\n HH:MM:SS + // b) P2P — новый: Сегодня|dd.MM.yyyy\ncard\ncard\namount\nописание\n HH:MM:SS static const QRegularExpression reTime(R"(\d{2}:\d{2}:\d{2})"); + static const QRegularExpression reDateOrToday( + QString("^(?:\\d{2}\\.\\d{2}\\.\\d{4}|%1|%2)$") + .arg(QString::fromUtf8("Сегодня"), QString::fromUtf8("Вчера"))); QList items; for (const Node &n : xmlScreenParser.findAllNodes()) { if (n.className != "android.view.View") continue; - if (!n.contentDesc.contains("DCWallet") && !n.contentDesc.contains("***")) continue; const QStringList lines = n.contentDesc.split('\n'); if (lines.size() < 5) continue; const QString last = lines.last().trimmed(); if (!reTime.match(last).hasMatch()) continue; + // Определяем формат: если первая строка — дата/Сегодня/Вчера, это P2P формат + const QString firstLine = lines.first().trimmed(); + const bool isP2P = reDateOrToday.match(firstLine).hasMatch(); + if (!isP2P && !n.contentDesc.contains("DCWallet") && !n.contentDesc.contains("***")) continue; + VypiskaItem it; bool ok = false; - it.amount = lines[2].trimmed().toDouble(&ok); + // В P2P формате amount на позиции [3] (после даты, двух карт), в DCWallet — [2] + const int amtIdx = isP2P ? 3 : 2; + if (amtIdx >= lines.size()) continue; + it.amount = lines[amtIdx].trimmed().toDouble(&ok); if (!ok) continue; it.time = last; it.node = n; + + // В P2P формате дата встроена в первую строку + if (isP2P) { + if (firstLine.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0) + it.date = todayLocal.toString("dd.MM.yyyy"); + else if (firstLine.compare(QString::fromUtf8("Вчера"), Qt::CaseInsensitive) == 0) + it.date = todayLocal.addDays(-1).toString("dd.MM.yyyy"); + else + it.date = firstLine; + } items.append(it); } @@ -497,7 +531,9 @@ void GetLastTransactionsScript::doStart() { int prevY2 = -1; for (VypiskaItem &it : items) { if (prevY2 >= 0 && it.node.y1() - prevY2 > 30) ++groupIdx; - if (groupIdx < groupDates.size()) it.date = groupDates[groupIdx]; + // Не перезаписываем дату если она уже установлена (P2P формат) + if (it.date.isEmpty() && groupIdx < groupDates.size()) + it.date = groupDates[groupIdx]; prevY2 = it.node.y2(); } @@ -539,7 +575,7 @@ void GetLastTransactionsScript::doStart() { const QTime searchLocalTime = m_searchTime.toTimeZone(tjTz).time(); if (listTime.isValid() && searchLocalTime.isValid()) { const int diffSecs = qAbs(listTime.secsTo(searchLocalTime)); - if (diffSecs > 3600 && diffSecs < 82800) { // 82800 = 23h + if (diffSecs > 600 && diffSecs < 85800) { // 85800 = 23h50m qDebug() << "[Dushanbe::GetLastTransactions] Time pre-filter skip:" << tx.time << "vs" << searchLocalTime.toString("HH:mm:ss") << "diff=" << diffSecs << "sec"; @@ -617,8 +653,8 @@ void GetLastTransactionsScript::doStart() { if (txTime.isValid()) { txTime.setTimeZone(tjTz); const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC())); - if (diffSecs > 3600) { - qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec, skipping"; + if (diffSecs > 600) { + qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec (>10min), skipping"; AdbUtils::goBack(m_deviceId); QThread::msleep(1000); continue; @@ -699,6 +735,9 @@ void GetLastTransactionsScript::doStart() { + " time=" + m_searchTime.toString(Qt::ISODate); qWarning() << "[Dushanbe::GetLastTransactions]" << m_error; + // Отправляем скриншот списка транзакций, сделанный до начала поиска + if (!listScreenshot.isEmpty()) emit screenshotReady(listScreenshot); + // Возвращаемся на главную Node glavnayaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Главная")); if (glavnayaBtn.x() > 0 && glavnayaBtn.y() > 0) { diff --git a/android/dushanbe/GetLastTransactionsScript.h b/android/dushanbe/GetLastTransactionsScript.h index 5d9a900..49de28b 100644 --- a/android/dushanbe/GetLastTransactionsScript.h +++ b/android/dushanbe/GetLastTransactionsScript.h @@ -27,6 +27,9 @@ signals: // повторного чтения из БД. void transactionParsed(const QJsonObject &tx); + // Скриншот списка транзакций (при ошибке отправляется в мониторинг) + void screenshotReady(const QByteArray &screenshot); + protected: void doStart() override; diff --git a/android/ozon/GetLastTransactionsScript.cpp b/android/ozon/GetLastTransactionsScript.cpp index c28eb05..94f6511 100644 --- a/android/ozon/GetLastTransactionsScript.cpp +++ b/android/ozon/GetLastTransactionsScript.cpp @@ -205,6 +205,9 @@ bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &devi } void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int accountId) { + // Скриншот списка транзакций — отправим при ошибке, удалим при успехе + const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId); + // Время в Ozon показывается в таймзоне устройства, а не в MSK const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId); QTimeZone deviceTz(tzName.toUtf8()); @@ -267,6 +270,8 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int QThread::msleep(2000); } + // Скроллим к нужной дате, запоминаем количество скроллов + int scrollsToDate = 0; bool dateFound = false; for (int scroll = 0; scroll < maxScrolls; ++scroll) { if (QThread::currentThread()->isInterruptionRequested()) { @@ -276,7 +281,8 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); if (hasDateOnScreen()) { dateFound = true; - qDebug() << "[FetchTransactions] Found target date on screen"; + scrollsToDate = scroll; + qDebug() << "[FetchTransactions] Found target date on screen after" << scroll << "scrolls"; break; } if (hasOlderDateOnScreen()) { @@ -291,13 +297,33 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int if (!dateFound) { m_error = "Date not found in transaction list: " + searchDate.toString("dd.MM.yyyy"); qWarning() << "[FetchTransactions]" << m_error; - m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId); + m_resultScreenshot = listScreenshot.isEmpty() + ? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot; if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot); return; } - // ── Шаг 2: тапаем по кнопкам с нужной суммой, проверяем дату/время в деталях ─ - // Запоминаем уже проверенные (date+time из деталей) чтобы не застревать + // Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране. + // Кнопки транзакций между этими Y принадлежат нужному дню. + auto findDateYRange = [&]() -> QPair { + int dateY = -1; + int nextDateY = device.screenHeight; + for (const Node &node : xmlScreenParser.getNodes()) { + if (node.className != "android.widget.TextView") continue; + const QDate d = parseDateHeader(node.text.trimmed()); + if (!d.isValid()) continue; + if (d == searchDate) { + dateY = node.y(); + } else if (dateY >= 0 && node.y() > dateY) { + // Первый заголовок другой даты после нашей + nextDateY = node.y(); + break; + } + } + return {dateY, nextDateY}; + }; + + // ── Шаг 2: тапаем только по кнопкам под заголовком нужной даты ─── QSet checkedDetails; for (int attempt = 0; attempt < 15; ++attempt) { @@ -306,6 +332,19 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int return; } xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); + + // Находим Y-диапазон заголовка нужной даты + const auto [dateHeaderY, nextDateHeaderY] = findDateYRange(); + if (dateHeaderY < 0) { + qDebug() << "[FetchTransactions] Date header not on screen, scrolling"; + AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, + device.screenHeight * 3 / 4, + device.screenWidth / 2, + device.screenHeight / 4, 300); + QThread::msleep(1000); + continue; + } + QList txButtons = xmlScreenParser.findTransactionButtons(20); bool tapped = false; @@ -315,16 +354,18 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int return; } + // Пропускаем кнопки вне Y-диапазона нужной даты + const int btnY = txButtons[bi].y(); + if (btnY <= dateHeaderY || btnY >= nextDateHeaderY) continue; + if (btnY <= 0 || btnY >= navBarTop) continue; + const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text); if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue; - // Пропускаем кнопки за экраном - const int btnY = txButtons[bi].y(); - if (btnY <= 0 || btnY >= navBarTop) continue; - const Node &visibleBtn = txButtons[bi]; qDebug() << "[FetchTransactions] Amount match:" << parsed.amount - << "at" << visibleBtn.x() << visibleBtn.y(); + << "at" << visibleBtn.x() << visibleBtn.y() + << "(dateY:" << dateHeaderY << "-" << nextDateHeaderY << ")"; // Тапаем и ждём деталей AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y()); @@ -367,23 +408,15 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int qDebug() << "[FetchTransactions] Already checked:" << dtStr << "- skipping"; AdbUtils::goBack(m_deviceId); QThread::msleep(500); - continue; // пробуем следующую кнопку + continue; } checkedDetails.insert(dtStr); tapped = true; - // Проверяем дату - if (txTime.isValid() && txTime.date() != searchDate) { - qDebug() << "[FetchTransactions] Wrong date:" << dtStr << "expected" << searchDate; - AdbUtils::goBack(m_deviceId); - QThread::msleep(1000); - continue; - } - - // Проверяем время (±1 час) + // Проверяем время (±10 минут) if (txTime.isValid() && m_searchTime.isValid()) { const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC())); - if (diffSecs > 3600) { + if (diffSecs > 600) { qDebug() << "[FetchTransactions] Time mismatch:" << dtStr << "expected" << searchLocal.toString("dd.MM.yyyy, HH:mm") << "diff=" << diffSecs << "sec"; @@ -515,7 +548,9 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int m_error = "Transaction not found: amount=" + QString::number(m_searchAmount, 'f', 2) + " date=" + searchDate.toString("dd.MM.yyyy"); qWarning() << "[FetchTransactions]" << m_error; - m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId); + // Отправляем скриншот списка транзакций, сделанный до начала поиска + m_resultScreenshot = listScreenshot.isEmpty() + ? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot; if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot); } diff --git a/database/dao/EventDAO.cpp b/database/dao/EventDAO.cpp index cb5973a..8789ebf 100644 --- a/database/dao/EventDAO.cpp +++ b/database/dao/EventDAO.cpp @@ -272,7 +272,74 @@ bool EventDAO::existsActiveByExternalId(const QString &externalId) { return query.next(); } -bool EventDAO::cancelWaitingByType(const QString &deviceId, EventType type, const QString &bankName) { +QList EventDAO::cancelWaitingByMaterial(const QString &materialId, EventType type) { + QList cancelled; + QSqlQuery selectQuery(DatabaseManager::instance().database()); + selectQuery.prepare(R"( + SELECT id, external_id, bank_name, type FROM events + WHERE external_id = :material_id AND type = :type AND status = :wait + )"); + selectQuery.bindValue(":material_id", materialId); + selectQuery.bindValue(":type", eventTypeToString(type)); + selectQuery.bindValue(":wait", eventStatusToString(EventStatus::Wait)); + + if (selectQuery.exec()) { + while (selectQuery.next()) { + EventInfo e; + e.id = selectQuery.value(0).toInt(); + e.externalId = selectQuery.value(1).toString(); + e.bankName = selectQuery.value(2).toString(); + e.type = eventTypeFromString(selectQuery.value(3).toString()); + cancelled.append(e); + } + } + + QSqlQuery query(DatabaseManager::instance().database()); + query.prepare(R"( + UPDATE events SET status = :cancelled, comment = 'Cancelled: replaced by new task' + WHERE external_id = :material_id AND type = :type AND status = :wait + )"); + query.bindValue(":cancelled", eventStatusToString(EventStatus::Error)); + query.bindValue(":material_id", materialId); + query.bindValue(":type", eventTypeToString(type)); + query.bindValue(":wait", eventStatusToString(EventStatus::Wait)); + + if (!query.exec()) { + qWarning() << "Ошибка при cancelWaitingByMaterial:" << query.lastError().text(); + } + return cancelled; +} + +QList EventDAO::cancelWaitingByType(const QString &deviceId, EventType type, const QString &bankName) { + QList cancelled; + QSqlQuery selectQuery(DatabaseManager::instance().database()); + if (bankName.isEmpty()) { + selectQuery.prepare(R"( + SELECT id, external_id, bank_name, type FROM events + WHERE device_id = :device_id AND type = :type AND status = :wait + )"); + } else { + selectQuery.prepare(R"( + SELECT id, external_id, bank_name, type FROM events + WHERE device_id = :device_id AND type = :type AND status = :wait AND bank_name = :bank_name + )"); + selectQuery.bindValue(":bank_name", bankName); + } + selectQuery.bindValue(":device_id", deviceId); + selectQuery.bindValue(":type", eventTypeToString(type)); + selectQuery.bindValue(":wait", eventStatusToString(EventStatus::Wait)); + + if (selectQuery.exec()) { + while (selectQuery.next()) { + EventInfo e; + e.id = selectQuery.value(0).toInt(); + e.externalId = selectQuery.value(1).toString(); + e.bankName = selectQuery.value(2).toString(); + e.type = eventTypeFromString(selectQuery.value(3).toString()); + cancelled.append(e); + } + } + QSqlQuery query(DatabaseManager::instance().database()); if (bankName.isEmpty()) { query.prepare(R"( @@ -293,9 +360,8 @@ bool EventDAO::cancelWaitingByType(const QString &deviceId, EventType type, cons if (!query.exec()) { qWarning() << "Ошибка при cancelWaitingByType:" << query.lastError().text(); - return false; } - return true; + return cancelled; } QList EventDAO::cancelWaitingByBank(const QString &deviceId, const QString &bankName) { diff --git a/database/dao/EventDAO.h b/database/dao/EventDAO.h index 06f414b..8727d7e 100644 --- a/database/dao/EventDAO.h +++ b/database/dao/EventDAO.h @@ -19,7 +19,9 @@ public: [[nodiscard]] static bool existsActiveByExternalId(const QString &externalId); - [[nodiscard]] static bool cancelWaitingByType(const QString &deviceId, EventType type, const QString &bankName = {}); + [[nodiscard]] static QList cancelWaitingByType(const QString &deviceId, EventType type, const QString &bankName = {}); + + [[nodiscard]] static QList cancelWaitingByMaterial(const QString &materialId, EventType type); [[nodiscard]] static QList cancelWaitingByBank(const QString &deviceId, const QString &bankName); diff --git a/services/EventHandler.cpp b/services/EventHandler.cpp index 2aa68b9..c504907 100644 --- a/services/EventHandler.cpp +++ b/services/EventHandler.cpp @@ -115,9 +115,13 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) { continue; } - // FETCH_PROFILE: отменяем старые ожидающие задачи такого же типа на этом устройстве+банк + // FETCH_PROFILE: отменяем старые ожидающие задачи такого же типа для этого материала if (eventType == EventType::FetchProfile) { - EventDAO::cancelWaitingByType(account.deviceId, EventType::FetchProfile, bankName); + const QList cancelled = EventDAO::cancelWaitingByMaterial(materialId, EventType::FetchProfile); + for (const EventInfo &c : cancelled) { + sendTaskError(eventTypeToString(c.type), c.externalId, c.bankName, "Cancelled: replaced by new task"); + EventDAO::markSentToServer(c.id); + } } EventInfo event; @@ -396,6 +400,18 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event, } m_threads.remove(key); + // Убиваем банковское приложение на устройстве после завершения задачи + { + const MaterialInfo acc = MaterialDAO::getAccountById(event.accountId); + const BankProfileInfo app = BankProfileDAO::getApplication(event.deviceId, acc.appCode); + if (!app.package.isEmpty()) { + const DeviceInfo device = DeviceDAO::getDeviceById(event.deviceId); + const QString adbSerial = device.adbSerial.isEmpty() ? event.deviceId : device.adbSerial; + AdbUtils::tryToKillApplication(adbSerial, app.package); + qDebug() << "[EventHandler] Killed app" << app.package << "on device" << adbSerial; + } + } + // Сразу пробуем запустить следующую задачу для этого устройства processNextTask(key); } @@ -463,6 +479,9 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this, [this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; }, Qt::DirectConnection); + connect(w, &Dushanbe::GetLastTransactionsScript::screenshotReady, this, + [this, key](const QByteArray &s) { m_screenshots[key] = s; }, + Qt::DirectConnection); } else qWarning() << "[EventHandler] FETCH_TRANSACTIONS: unknown appCode:" << appCode; } @@ -519,6 +538,18 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Script timeout (5 min)"); EventDAO::markSentToServer(event.id); + // Убиваем банковское приложение на устройстве после таймаута + { + const MaterialInfo acc = MaterialDAO::getAccountById(event.accountId); + const BankProfileInfo app = BankProfileDAO::getApplication(event.deviceId, acc.appCode); + if (!app.package.isEmpty()) { + const DeviceInfo device = DeviceDAO::getDeviceById(event.deviceId); + const QString adbSerial = device.adbSerial.isEmpty() ? event.deviceId : device.adbSerial; + AdbUtils::tryToKillApplication(adbSerial, app.package); + qDebug() << "[EventHandler] Killed app" << app.package << "on device" << adbSerial << "(timeout)"; + } + } + processNextTask(key); }); diff --git a/tests/mock_server/default_profile.json b/tests/mock_server/default_profile.json index 76c9a8c..f70fe44 100644 --- a/tests/mock_server/default_profile.json +++ b/tests/mock_server/default_profile.json @@ -1,27 +1,7 @@ { "bank_profiles": [ { - "id": "dd0ba55d-0a02-40ab-8b31-cf6a7529269f", - "bank_name": "dushanbe_city_bank", - "state": "enabled", - "phone_number": "+992185555519", - "first_name": "Мехрубон", - "middle_name": null, - "last_name": "Имомкулзода", - "currency": 972, - "device_id": "986cb259-dfec-4421-8735-4fb126cd1917", - "device_label": "Pixel 7", - "materials": [ - { - "id": "61d967d1-8d3e-4d05-ba01-8c8ab327b945", - "state": "enabled", - "card_number": "9762000207113258", - "currency": 972 - } - ] - }, - { - "id": "86bcdee7-1c64-40e3-bc8f-132d153a9d28", + "id": "85f577bc-20be-40fd-ab47-315af3aadfb9", "bank_name": "ozon", "state": "enabled", "phone_number": "+79538038318", @@ -29,33 +9,19 @@ "middle_name": null, "last_name": "Терехина", "currency": 643, - "device_id": "986cb259-dfec-4421-8735-4fb126cd1917", + "device_id": "c9d3ad7e-659d-4adf-815e-9f234e743078", "device_label": "Pixel 7", "materials": [ { - "id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13", + "id": "e6c6e12a-3e4e-444c-9e11-62543fb5f4f9", "state": "enabled", "card_number": "2204321035207919", "currency": 643 - } - ] - }, - { - "id": "e8c5f3b2-c710-421c-b304-93353ccfbfd5", - "bank_name": "ozon", - "state": "enabled", - "phone_number": "+79855532896", - "first_name": "Владислав", - "middle_name": null, - "last_name": "Касаткин", - "currency": 643, - "device_id": "ec6397fb-89b1-41e1-9b6b-303d6f1e943f", - "device_label": "TECNO KM4", - "materials": [ + }, { - "id": "49001bb5-3b24-478d-b913-c2242aea0732", + "id": "39592a22-3e79-4438-89d7-778aea72a5e9", "state": "enabled", - "card_number": "2204320439130982", + "card_number": "2204321086109865", "currency": 643 } ] diff --git a/tests/mock_server/gen_fetch_transactions.py b/tests/mock_server/gen_fetch_transactions.py index 55ddf90..77a2e77 100755 --- a/tests/mock_server/gen_fetch_transactions.py +++ b/tests/mock_server/gen_fetch_transactions.py @@ -160,30 +160,92 @@ def back(serial): adb(serial, "shell", "input", "keyevent", "KEYCODE_BACK") +def swipe_up(serial, duration=500): + """Swipe up to scroll the list down (small step).""" + adb(serial, "shell", "input", "swipe", "540", "1400", "540", "900", str(duration)) + + # ---------------------------------------------------------------- Ozon +MONTH_MAP = { + "января": 1, "февраля": 2, "марта": 3, "апреля": 4, + "мая": 5, "июня": 6, "июля": 7, "августа": 8, + "сентября": 9, "октября": 10, "ноября": 11, "декабря": 12, +} +DATE_HEADER_RE = re.compile(r"(\d{1,2})\s+(\S+),\s+\S+") + + +def parse_ozon_date_headers(xml, device_tz): + """Parse date headers (TextView) and return list of (y, date_str 'dd.MM.yyyy').""" + today = datetime.now(tz=device_tz).date() + headers = [] + for m in re.finditer( + r'text="([^"]*)"[^>]*class="android.widget.TextView"' + r'[^>]*bounds="(\[\d+,\d+\]\[\d+,\d+\])"', xml): + text, bounds = m.group(1).strip(), m.group(2) + bm = BOUNDS_RE.match(bounds) + if not bm: + continue + x1, y1, x2, y2 = map(int, bm.groups()) + h = y2 - y1 + if h < 5 or y1 < 700: + continue # за экраном или в зоне шапки + d = None + if text == "Сегодня": + d = today + elif text == "Вчера": + d = today - timedelta(days=1) + else: + dm = DATE_HEADER_RE.match(text) + if dm: + day = int(dm.group(1)) + month = MONTH_MAP.get(dm.group(2), 0) + if month: + d = today.replace(month=month, day=day) + if d > today: + d = d.replace(year=today.year - 1) + if d: + headers.append({"y": y1, "date": d.strftime("%d.%m.%Y")}) + headers.sort(key=lambda h: h["y"]) + return headers + + def parse_ozon_rows(xml): rows = [] for m in re.finditer( - r']*?text="([^"]*)"' + r'text="([^"]*)"' r'[^>]*?bounds="(\[\d+,\d+\]\[\d+,\d+\])"', xml): text, bounds = m.group(1), m.group(2) bm = BOUNDS_RE.match(bounds) if not bm: continue x1, y1, x2, y2 = map(int, bm.groups()) - if (y2 - y1) < 50 or "Расход" in text or "Доход" in text: + h = y2 - y1 + cy = (y1 + y2) // 2 + if h < 50 or h < 5 or cy < 800: + continue + if "Расход" in text or "Доход" in text: continue am = re.search(r"([+\-−])\s*(\d[\d\s\u202f]*)", text) if not am: continue sign = -1 if am.group(1) in ("-", "−") else 1 amount = sign * int(re.sub(r"[\s\u202f]", "", am.group(2))) - rows.append({"text": text, "cx": (x1+x2)//2, "cy": (y1+y2)//2, "amount": amount}) + rows.append({"text": text, "cx": (x1+x2)//2, "cy": cy, "amount": amount}) rows.sort(key=lambda r: r["cy"]) return rows +def assign_dates_to_rows(rows, headers): + """Assign date from nearest preceding header to each row.""" + for row in rows: + row["date"] = None + for h in reversed(headers): + if h["y"] < row["cy"]: + row["date"] = h["date"] + break + + def is_ozon_list(xml): return "Операции" in xml @@ -266,71 +328,181 @@ def collect_tasks(serial, bank, mid, device_tz, kopecks, prefix): if is_ozon: print("Detected: Ozon") + headers = parse_ozon_date_headers(xml, device_tz) baseline = parse_ozon_rows(xml) - print(f"rows: {len(baseline)}") + assign_dates_to_rows(baseline, headers) + print(f"date headers: {[h['date'] for h in headers]}") + print(f"visible rows: {len(baseline)}") for i, r in enumerate(baseline, 1): - print(f" {i}. ({r['cx']},{r['cy']}) {r['amount']:+d} ₽ | {r['text'][:50]}") + print(f" {i}. ({r['cx']},{r['cy']}) {r['amount']:+d} ₽ date={r.get('date','-')} | {r['text'][:40]}") - for idx in range(1, len(baseline) + 1): + task_num = 0 + processed_keys = set() # (amount, created_at) — финальная дедупликация + completed_dates = set() # даты где все строки уже обработаны + date_row_counts = {} # date -> сколько строк видели для этой даты (макс) + max_scrolls = 20 + scroll_count = 0 + + while True: xml = dump_xml(serial) if not is_ozon_list(xml): - print(f"[{idx}] left list, stop"); break + print("Left list, stop"); break + + headers = parse_ozon_date_headers(xml, device_tz) rows = parse_ozon_rows(xml) - if idx > len(rows): - break - row = rows[idx - 1] - print(f"\n[{idx}] tap ({row['cx']},{row['cy']}) {row['amount']:+d}") - tap(serial, row["cx"], row["cy"]) - time.sleep(2.5) - d_xml = dump_xml(serial) - date_s, time_s = extract_ozon_detail_time(d_xml) - print(f" {date_s} {time_s}") - back(serial); time.sleep(1.3) - if not date_s or not time_s: - print(" SKIP"); continue - dt = datetime.strptime(f"{date_s} {time_s}", "%d.%m.%Y %H:%M:%S").replace(tzinfo=device_tz) - cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - label = short_label(row["amount"], time_s) - while label in seen: label += "_2" - seen.add(label) - tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS", - "body": {"amount": row["amount"] * kopecks, "created_at": cat}, - "_tag": f"{prefix}_{idx:02d}_{label}"}) + assign_dates_to_rows(rows, headers) + + if not rows: + print("No rows found, stop"); break + + # Фильтруем строки от уже завершённых дат + rows = [r for r in rows if r.get("date") and r["date"] not in completed_dates] + if not rows: + print("All visible dates already processed, scrolling...") + scroll_count += 1 + if scroll_count >= max_scrolls: + print(f"\nReached max scrolls ({max_scrolls}), stop"); break + swipe_up(serial) + time.sleep(1.5) + continue + + # Считаем строки по датам на текущем экране + screen_date_counts = {} + for row in rows: + d = row["date"] + screen_date_counts[d] = screen_date_counts.get(d, 0) + 1 + + new_on_screen = 0 + for row in rows: + date_from_header = row["date"] + + print(f"\n [{date_from_header}] tap ({row['cx']},{row['cy']}) {row['amount']:+d}") + tap(serial, row["cx"], row["cy"]) + time.sleep(3) + + # Ждём деталей — нужно только время (дата уже из заголовка) + time_s = None + for attempt in range(3): + d_xml = dump_xml(serial) + _, time_s = extract_ozon_detail_time(d_xml) + if time_s: + break + if is_ozon_list(d_xml): + print(f" retry {attempt+1}: still on list, re-tapping") + tap(serial, row["cx"], row["cy"]) + time.sleep(2) + else: + print(f" retry {attempt+1}: waiting for time...") + time.sleep(2) + + back(serial); time.sleep(1.5) + + if not time_s: + print(f" SKIP (no time from detail)"); continue + + print(f" {date_from_header} {time_s}") + + dt = datetime.strptime(f"{date_from_header} {time_s}", "%d.%m.%Y %H:%M:%S").replace(tzinfo=device_tz) + cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + dedup_key = (row["amount"], cat) + if dedup_key in processed_keys: + print(" DUPLICATE, skip"); continue + processed_keys.add(dedup_key) + new_on_screen += 1 + + task_num += 1 + label = short_label(row["amount"], time_s) + while label in seen: label += "_2" + seen.add(label) + tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS", + "body": {"amount": row["amount"] * kopecks, "created_at": cat}, + "_tag": f"{prefix}_{task_num:02d}_{label}"}) + + # Помечаем даты как завершённые: все даты кроме последней на экране + # (последняя может быть обрезана скроллом) + visible_dates = list(dict.fromkeys(r["date"] for r in rows)) # ordered unique + if len(visible_dates) > 1: + for d in visible_dates[:-1]: + completed_dates.add(d) + + if new_on_screen == 0: + # Последняя дата на экране — тоже завершена + if visible_dates: + completed_dates.add(visible_dates[-1]) + print("\nNo new transactions on screen, scrolling past...") + scroll_count += 1 + if scroll_count >= max_scrolls: + print(f"\nReached max scrolls ({max_scrolls}), stop"); break + swipe_up(serial) + time.sleep(1.5) + continue + + scroll_count += 1 + if scroll_count >= max_scrolls: + print(f"\nReached max scrolls ({max_scrolls}), stop"); break + print(f"\n--- scroll {scroll_count}/{max_scrolls} ---") + swipe_up(serial) + time.sleep(1.5) elif is_dush: print("Detected: Dushanbe") baseline = parse_dushanbe_rows(xml) - print(f"rows: {len(baseline)}") + print(f"visible rows: {len(baseline)}") for i, r in enumerate(baseline, 1): print(f" {i}. ({r['cx']},{r['cy']}) amt={r['amount']} time={r['time']}") - for idx in range(1, len(baseline) + 1): + task_num = 0 + processed_keys = set() + max_scrolls = 30 + scroll_count = 0 + + while True: xml = dump_xml(serial) if not is_dushanbe_list(xml): - print(f"[{idx}] left list, stop"); break + print("Left list, stop"); break rows = parse_dushanbe_rows(xml) - if idx > len(rows): - break - row = rows[idx - 1] - print(f"\n[{idx}] tap ({row['cx']},{row['cy']}) amt={row['amount']}") - tap(serial, row["cx"], row["cy"]) - time.sleep(2.5) - d_xml = dump_xml(serial) - amt_s, date_s, time_s = extract_dushanbe_detail(d_xml) - print(f" amt={amt_s} {date_s} {time_s}") - back(serial); time.sleep(1.3) - if not date_s or not time_s: - print(" SKIP"); continue - amount = int(float(amt_s)) if amt_s else (row["amount"] or 0) - fmt = "%d.%m.%Y %H:%M:%S" if len(time_s) > 5 else "%d.%m.%Y %H:%M" - dt = datetime.strptime(f"{date_s} {time_s}", fmt).replace(tzinfo=device_tz) - cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - label = short_label(amount, time_s) - while label in seen: label += "_2" - seen.add(label) - tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS", - "body": {"amount": amount * kopecks, "created_at": cat}, - "_tag": f"{prefix}_{idx:02d}_{label}"}) + if not rows: + print("No rows found, stop"); break + + tapped_any = False + for row in rows: + print(f"\n tap ({row['cx']},{row['cy']}) amt={row['amount']}") + tap(serial, row["cx"], row["cy"]) + time.sleep(2.5) + d_xml = dump_xml(serial) + amt_s, date_s, time_s = extract_dushanbe_detail(d_xml) + print(f" amt={amt_s} {date_s} {time_s}") + back(serial); time.sleep(1.3) + if not date_s or not time_s: + print(" SKIP"); continue + + amount = int(float(amt_s)) if amt_s else (row["amount"] or 0) + fmt = "%d.%m.%Y %H:%M:%S" if len(time_s) > 5 else "%d.%m.%Y %H:%M" + dt = datetime.strptime(f"{date_s} {time_s}", fmt).replace(tzinfo=device_tz) + cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + dedup_key = (amount, cat) + if dedup_key in processed_keys: + print(" DUPLICATE, skip"); continue + processed_keys.add(dedup_key) + tapped_any = True + + task_num += 1 + label = short_label(amount, time_s) + while label in seen: label += "_2" + seen.add(label) + tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS", + "body": {"amount": amount * kopecks, "created_at": cat}, + "_tag": f"{prefix}_{task_num:02d}_{label}"}) + + scroll_count += 1 + if scroll_count >= max_scrolls: + print(f"\nReached max scrolls ({max_scrolls}), stop"); break + if not tapped_any: + print("\nNo new transactions on screen, stop"); break + print(f"\n--- scrolling down ({scroll_count}/{max_scrolls}) ---") + swipe_up(serial) + time.sleep(1.5) return tasks @@ -409,7 +581,8 @@ def main(): serial = args.serial or o["serial"] bank = o["bank_name"] mid = o["material_id"] - prefix = f"{o['label'].lower().replace(' ', '_')}_{bank}" + card = o.get('last_numbers') or o['material_id'][:8] + prefix = f"{o['label'].lower().replace(' ', '_')}_{bank}_{card}" device_tz = get_device_tz(serial) print(f"\n{'='*60}") print(f"Scanning: {o['label']} / {bank} ({mid[:8]}…)") @@ -429,7 +602,8 @@ def main(): # Remove old files for scanned prefixes scanned_prefixes = set() for o in to_scan: - scanned_prefixes.add(f"{o['label'].lower().replace(' ', '_')}_{o['bank_name']}") + card = o.get('last_numbers') or o['material_id'][:8] + scanned_prefixes.add(f"{o['label'].lower().replace(' ', '_')}_{o['bank_name']}_{card}") for old in POOL_DIR.glob("*.json"): for pfx in scanned_prefixes: if old.name.startswith(pfx + "_ft_"): diff --git a/tests/mock_server/gen_mock_data.py b/tests/mock_server/gen_mock_data.py index ceea5d6..2f7dad9 100755 --- a/tests/mock_server/gen_mock_data.py +++ b/tests/mock_server/gen_mock_data.py @@ -170,7 +170,8 @@ def generate(materials: list, out_dir: Path): for row in materials: dev_short = device_label(row["device_name"]).lower().replace(" ", "_") bank = row["bank_name"] - fname = f"{dev_short}_{bank}_fetch_profile.json" + card = row["last_numbers"] or row["material_id"][:8] + fname = f"{dev_short}_{bank}_{card}_fetch_profile.json" task = { "bank_name": bank, "material_id": row["material_id"], @@ -183,7 +184,7 @@ def generate(materials: list, out_dir: Path): print(f" wrote scenarios/{fname}") devices_seen.setdefault(dev_short, []).append( - f"{bank} ({row['material_id'][:8]}…)" + f"{bank}/{card} ({row['material_id'][:8]}…)" ) # Summary diff --git a/tests/mock_server/mock_server.py b/tests/mock_server/mock_server.py index 44ec15a..7816f8d 100644 --- a/tests/mock_server/mock_server.py +++ b/tests/mock_server/mock_server.py @@ -45,6 +45,7 @@ import threading import time import uuid from collections import defaultdict, deque +from datetime import datetime, timedelta, timezone from pathlib import Path from flask import Flask, Response, jsonify, request @@ -297,6 +298,22 @@ def bank_profile_toggle(bp_id, state): return ok({"id": bp_id, "state": state}) +@app.patch("/api/v1/profile/bank_profile/") +def bank_profile_update(bp_id): + body = request.get_json(silent=True) or {} + log("PATCH /bank_profile", {"id": bp_id, **body}) + for bp in _bank_profiles: + if bp.get("id") == bp_id: + bp.update(body) + return ok(bp) + # Не найден — создаём + bp = dict(body) + bp["id"] = bp_id + bp.setdefault("materials", []) + _bank_profiles.append(bp) + return ok(bp) + + @app.post("/api/v1/profile/material") def material_create(): body = request.get_json(silent=True) or {}