diff --git a/android/black/GetCardInfoScript.cpp b/android/black/GetCardInfoScript.cpp index f493f87..dec27db 100644 --- a/android/black/GetCardInfoScript.cpp +++ b/android/black/GetCardInfoScript.cpp @@ -110,7 +110,7 @@ void GetCardInfoScript::doStart() { account.lastNumbers = lastNumbers; account.cardNumber = cvuNumber; account.amount = balance; - account.currency = "USD"; + account.currency = "ARS"; account.description = "CVU"; account.status = MaterialStatus::Active; account.updateTime = QDateTime::currentDateTimeUtc(); diff --git a/android/black/PayByCardScript.cpp b/android/black/PayByCardScript.cpp index bba8758..93ace96 100644 --- a/android/black/PayByCardScript.cpp +++ b/android/black/PayByCardScript.cpp @@ -81,6 +81,8 @@ void PayByCardScript::makePayment( if (enviarBtn.isEmpty()) { m_error = "Button 'Enviar a CBU o alias' not found: " + deviceId; qWarning() << m_error; + AppLogger::log("black/PayByCard", deviceId, m_error, + AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION"); return; } @@ -103,6 +105,8 @@ void PayByCardScript::makePayment( if (!onInputScreen) { m_error = "Input card number screen not loaded: " + deviceId; qWarning() << m_error; + AppLogger::log("black/PayByCard", deviceId, m_error, + AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION"); return; } @@ -153,6 +157,8 @@ void PayByCardScript::makePayment( if (!onTransferConfirm) { m_error = "Transfer confirm screen not loaded: " + deviceId; qWarning() << m_error; + AppLogger::log("black/PayByCard", deviceId, m_error, + AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION"); return; } @@ -186,6 +192,8 @@ void PayByCardScript::makePayment( if (!onInputSum) { m_error = "Input sum screen not loaded: " + deviceId; qWarning() << m_error; + AppLogger::log("black/PayByCard", deviceId, m_error, + AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION"); return; } @@ -278,6 +286,8 @@ void PayByCardScript::makePayment( if (!onPayConfirm) { m_error = "Pay confirm screen not loaded: " + deviceId; qWarning() << m_error; + AppLogger::log("black/PayByCard", deviceId, m_error, + AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION"); return; } @@ -330,6 +340,8 @@ void PayByCardScript::makePayment( if (!onSuccess) { m_error = "Pay success screen not loaded: " + deviceId; qWarning() << m_error; + AppLogger::log("black/PayByCard", deviceId, m_error, + AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION"); return; } diff --git a/android/ozon/PayByCardScript.cpp b/android/ozon/PayByCardScript.cpp index a544c3a..6f69070 100644 --- a/android/ozon/PayByCardScript.cpp +++ b/android/ozon/PayByCardScript.cpp @@ -281,10 +281,28 @@ void PayByCardScript::makePayment( AdbUtils::makeTap(deviceId, transferBtn2.x(), transferBtn2.y()); QThread::msleep(3000); + // 8.5. Проверяем экран ввода PIN-кода + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + if (const QList pinNodes = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН"); + !pinNodes.isEmpty()) { + qDebug() << "[PayByCard] PIN code screen detected after confirm, entering PIN..."; + inputPinCode(deviceId, pinNodes); + QThread::msleep(3000); + } + // 9. Ждём результат: "Успешно" или "Не удалось" for (int attempt = 0; attempt < 15; ++attempt) { xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + // Если PIN-код появился позже — вводим + if (const QList pinNodes = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН"); + !pinNodes.isEmpty()) { + qDebug() << "[PayByCard] PIN code screen detected (retry), entering PIN..."; + inputPinCode(deviceId, pinNodes); + QThread::msleep(3000); + continue; + } + if (!xmlScreenParser.findTextNode("Успешно").isEmpty()) { qDebug() << "[PayByCard] Payment successful!"; m_error.clear(); diff --git a/android/ozon/PayByPhoneScript.cpp b/android/ozon/PayByPhoneScript.cpp index 0d63afc..9456ec5 100644 --- a/android/ozon/PayByPhoneScript.cpp +++ b/android/ozon/PayByPhoneScript.cpp @@ -386,10 +386,28 @@ void PayByPhoneScript::makePayment( AdbUtils::makeTap(deviceId, transferConfirmBtn.x(), transferConfirmBtn.y()); QThread::msleep(3000); + // 8.5. Проверяем экран ввода PIN-кода + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + if (const QList pinNodes = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН"); + !pinNodes.isEmpty()) { + qDebug() << "[PayByPhone] PIN code screen detected after confirm, entering PIN..."; + inputPinCode(deviceId, pinNodes); + QThread::msleep(3000); + } + // 9. Ждём результат: "Успешно" или "Не удалось" for (int attempt = 0; attempt < 15; ++attempt) { xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + // Если PIN-код появился позже — вводим + if (const QList pinNodes = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН"); + !pinNodes.isEmpty()) { + qDebug() << "[PayByPhone] PIN code screen detected (retry), entering PIN..."; + inputPinCode(deviceId, pinNodes); + QThread::msleep(3000); + continue; + } + if (!xmlScreenParser.findTextNode("Успешно").isEmpty()) { qDebug() << "[PayByPhone] Payment successful!"; m_error.clear(); diff --git a/database/dao/MaterialDAO.cpp b/database/dao/MaterialDAO.cpp index 237fa62..982a789 100644 --- a/database/dao/MaterialDAO.cpp +++ b/database/dao/MaterialDAO.cpp @@ -1,5 +1,6 @@ #include #include +#include #include "MaterialDAO.h" #include "db/MaterialInfo.h" @@ -7,6 +8,14 @@ #include "time/DateUtils.h" bool MaterialDAO::upsertAccount(const MaterialInfo &info) { + // Валидация: lastNumbers должен содержать только цифры + static const QRegularExpression digitsOnly(R"(^\d+$)"); + if (!info.lastNumbers.isEmpty() && !digitsOnly.match(info.lastNumbers).hasMatch()) { + qWarning() << "[MaterialDAO] upsertAccount rejected: lastNumbers is not numeric:" + << info.lastNumbers << "appCode:" << info.appCode; + return false; + } + QSqlQuery query(DatabaseManager::instance().database()); query.prepare(R"( diff --git a/docs/black_tasks.md b/docs/black_tasks.md new file mode 100644 index 0000000..2f8aedd --- /dev/null +++ b/docs/black_tasks.md @@ -0,0 +1,368 @@ +# Black - Логика задач + +[← Главная](index.md) | [События](events.md) + +Описание логики всех типов задач для банка **Black** (appCode: `black`). + +> Black — аргентинское банковское приложение. Интерфейс на испанском. Валюта: ARS (код 32). Таймзона: America/Argentina/Buenos_Aires (UTC-3). + +--- + +## Общие механизмы (CommonScript) + +### Запуск приложения (`runAppAndGoToHomeScreen`) + +1. Убивает приложение если запущено +2. Запускает приложение +3. Ждёт 4.5 секунды +4. Цикл до 10 попыток: + - Получает XML-дамп экрана + - Проверяет `isHomeScreen()` (наличие "Saldo disponible" + content-desc "Inicio") + - Если экран с PIN-кодом (`isPinCodeScreen()` — "¿Olvidaste tu PIN?" или "Ingresá tu PIN"): + - Находит EditText с password=true + - Тапает для фокуса → ожидание 500мс + - Вводит PIN через `AdbUtils::inputText()` + - Ожидание 3 сек + - Ожидание 1 сек между попытками +5. При неудаче — скриншот + +### Прокрутка к кнопке (`swipeToButton`) + +- До 10 свайпов вверх +- После каждого свайпа парсит XML и ищет кнопку +- Аналогично Ozon + +### Создание банковского профиля (`createBankProfileIfNeeded`) + +- Аналогично Ozon (общий код в CommonScript) +- Валюта по умолчанию: ARS→32 + +--- + +## FETCH_PROFILE + +**Скрипты:** `Black::LoginAndCheckAccountsScript` → `Black::GetProfileInfoScript` → `Black::GetCardInfoScript` + +### Цель + +Авторизация, сбор профиля (ФИО, телефон, email), получение CVU и баланса. + +### Шаги LoginAndCheckAccountsScript + +1. **Валидация** — получение DeviceInfo и BankProfileInfo из БД +2. **Запуск приложения** — `runAppAndGoToHomeScreen()` с PIN-кодом +3. **Запуск GetProfileInfoScript** — синхронно, для сбора профиля +4. При ошибке: скриншот + лог + обновление комментария в `BankProfileDAO` + +### Шаги GetProfileInfoScript + +1. **Домашний экран** — проверка/перезапуск + +2. **Парсинг с домашнего экрана**: + - fullName из `parseUserNameFromHomeScreen()` — формат "¡Hola!\nИМЯ\ninitials" + - Баланс из `parseBalanceFromHomeScreen()` — формат "$1.857,00" → 1857.00 + - Обновление баланса в `BankProfileDAO` + +3. **Навигация в боковое меню**: + - Поиск кнопки "¡Hola!" (аватар) + - Тап → ожидание 2 сек + - Ожидание `isSideMenu()` (5 × 1 сек) — маркеры: "Tu Información" + "Cerrar sesión" + +4. **Навигация к профилю**: + - Тап "Tu Información" (content-desc) + - Ожидание 2.5 сек + - Ожидание `isProfilePage()` (5 × 1 сек) — маркер: content-desc "Tu información" + +5. **Парсинг профиля**: + - Nombre, Apellido, Email — по hint-атрибутам EditText + - Свайп вниз → ожидание 1.5 сек + - Телефон — из hint-атрибута + - fullName = "nombre apellido" (или fallback на имя с домашнего экрана) + +6. **Сохранение**: `BankProfileDAO::updateProfileInfo(id, fullName, phone, email)` + +7. **Синхронизация с сервером**: `createBankProfileIfNeeded()` + +8. **Отправка аккаунтов**: `postAccountsData()` для аккаунтов без materialId + +9. **Завершение** — убийство приложения + +### Шаги GetCardInfoScript (CVU) + +1. **Домашний экран** — проверка/перезапуск + +2. **Парсинг баланса** → обновление в БД + +3. **Навигация к CVU**: + - Поиск кнопки "Tu CVU" (exact match) + - Тап → ожидание 2 сек + - Ожидание `isCVUScreen()` (5 × 1 сек) + +4. **Парсинг CVU**: + - `parseCVUNumber()` — 22-значный номер после маркера "CVU" + - lastNumbers = последние 4 цифры CVU + +5. **Сохранение в БД**: + - `MaterialDAO::upsertAccount()` с: + - cardNumber = полный CVU + - lastNumbers = CVU[-4:] + - currency = "USD" (хардкод) + - description = "CVU" + +6. **Отправка материала на сервер**: `ns.postMaterial()` (если есть bankProfileId и нет materialId) + +7. **Завершение** — убийство приложения + +### Ответ серверу (при успехе) + +```json +{ + "type": "FETCH_PROFILE", + "material_id": "...", + "bank_name": "black", + "data": { + "bank_account_id": "", + "amount": 185700, + "currency": 32 + } +} +``` + +--- + +## FETCH_TRANSACTIONS + +**Скрипт:** `Black::GetLastTransactionsScript` + +### Цель + +Получение списка последних транзакций из истории операций. + +### Шаги + +1. **Домашний экран** — проверка/перезапуск + +2. **Навигация к истории транзакций**: + - Поиск кнопки "Ver más" после секции "Tu actividad" через `findVerMasAfterTuActividad()` + - Если не найдена — до 5 свайпов вниз (1 сек между свайпами) + - Тап "Ver más" → ожидание 2 сек + +3. **Ожидание загрузки** (15 × 1.5 сек): + - `isTransactionLoadingScreen()` = "Tu Actividad" есть, но транзакций нет (загрузка) + - `isTransactionListScreen()` = "Tu Actividad" + видимые транзакции + - Если после 15 попыток всё ещё loading → пустой аккаунт (нормально), возврат без ошибки + +4. **Парсинг транзакций** (`parseTransactionItems()`): + - Источник: ImageView ноды с текстом формата: + ``` + Transferencia + DD de MES de YYYY - HH:MM + $amount,cents + ``` + или (входящие): + ``` + Transferencia externa de ИМЯ + DD de MES de YYYY - HH:MM + $amount,cents + ``` + - Отрицательные суммы: `$-amount` (исходящие переводы) + - Парсинг испанских дат (`parseSpanishDate()`): enero, febrero, ..., diciembre + +5. **Сохранение в БД** (для каждой транзакции): + - bankName = "black" + - status = Complete + - timestamp = currentDateTimeUtc() + - Конвертация времени: парсинг испанской даты → таймзона Buenos Aires + - `TransactionDAO::insertTransactionIfNotExists()` + +6. **Возврат** — `goBack()` → ожидание 1 сек + +### Retry-логика (EventHandler) + +Аналогична Ozon: +``` +"Transaction not found" + < 10 мин → повтор через 15 сек +"Transaction not found" + ≥ 10 мин → ошибка +``` + +### Ответ серверу + +```json +{ + "type": "FETCH_TRANSACTIONS", + "material_id": "...", + "bank_name": "black", + "data": [ + { + "bank_transaction_id": null, + "amount": 185700, + "currency": 32, + "transaction_type": "bank", + "bank_participator_id": "+5491112345678", + "status": "completed", + "extra": { "fee": 0 }, + "created_at": "2025-01-15T14:30:00.000-03:00" + } + ] +} +``` + +--- + +## CREATE_TRANSACTION (bank — по CBU/CVU/Alias) + +**Скрипт:** `Black::PayByCardScript` + +### Цель + +Перевод на CBU, CVU или Alias получателя. + +### Входные параметры + +- `event.phone` — CBU/CVU/Alias получателя (поле `m_cardNumber`) +- `event.amount` — сумма перевода +- `m_account` — аккаунт-отправитель + +### Шаги + +1. **Домашний экран** — проверка/перезапуск + +2. **Поиск кнопки оплаты**: + - `findNodeByContentDesc("Enviar a\nCBU o alias", contains=true)` + - Если не найдена — до 5 свайпов вниз + - Тап → ожидание 2 сек + +3. **Ввод реквизитов** (10 × 1.5 сек ожидание экрана): + - Экран `isPayInputCardNumberScreen()` — hint "Ingresar CBU, CVU o Alias" + - Тап на EditText → ожидание 500мс + - Ввод CBU/CVU/Alias + - Тап "Continuar" → ожидание 2 сек + +4. **Подтверждение получателя** (10 × 1.5 сек): + - Экран `isPayTransferConfirmScreen()` — кнопка "Transferir" + поле "Alias:" + - Тап "Transferir" → ожидание 2 сек + +5. **Ввод суммы** (10 × 1.5 сек): + - Экран `isPayInputSumScreen()` — "Cuánto vas a transferír" + - Тап на EditText → ожидание 500мс + - **Очистка поля**: 20 × keyevent 67 (backspace) + - **Ввод суммы в сентаво**: `int(amount * 100)` → строка + - Пример: 500.00 → "50000" + - Проверка: перечитывает поле, если не совпадает — повторяет ввод + - Тап "Continuar" → ожидание 2 сек + +6. **Финальное подтверждение** (10 × 1.5 сек): + - Экран `isPayConfirmScreen()` — "Vas a transferir \na ИМЯ_ПОЛУЧАТЕЛЯ" + - Парсинг имени получателя (`parseRecipientNameFromConfirm()`) + - Тап "Transferir" → ожидание 3 сек + +7. **Ввод PIN** (если запрошен): + - `isPinCodeScreen()` → `inputPinCode()` + - Ожидание 3 сек + +8. **Проверка результата** (15 × 2 сек): + - **"Dinero enviado!"** → успех: + - Парсинг bankTime: `parsePaySuccessDateTime()` → "DD/MM/YY HH:MM" + - Парсинг ID: `parsePaySuccessCoelsaId()` → Coelsa transaction ID + - Скриншот как чек + - Если снова PIN-экран → повторный ввод PIN + - Таймаут → ошибка + +9. **Сохранение транзакции**: + - amount = -m_amount (отрицательная) + - fee = 0 + - type = Card, status = Complete + - name = имя получателя + - description = "Transferencia a ИМЯ" + - phone = CBU/CVU/Alias + - bankTrExternalId = Coelsa ID + - receiptImage = скриншот + - `TransactionDAO::insertTransaction()` + +### Ответ серверу + +```json +{ + "type": "CREATE_TRANSACTION", + "material_id": "...", + "bank_name": "black", + "data": { + "amount": -50000, + "currency": 32, + "transaction_type": "bank", + "bank_participator_id": "0000003100012345678901", + "status": "completed", + "bank_transaction_id": "COELSA-12345", + "extra": { "fee": 0 }, + "created_at": "2025-01-15T14:30:00.000-03:00" + } +} +``` + +--- + +## ScreenXmlParser (Black) + +### Определение экранов + +| Метод | Маркеры | +|-------|---------| +| `isHomeScreen()` | "Saldo disponible" + content-desc "Inicio" | +| `isPinCodeScreen()` | "¿Olvidaste tu PIN?" или "Ingresá tu PIN" | +| `isSideMenu()` | "Tu Información" + "Cerrar sesión" | +| `isProfilePage()` | content-desc "Tu información" | +| `isCVUScreen()` | маркер "CVU" | +| `isTransactionLoadingScreen()` | "Tu Actividad" без "Transferencia" | +| `isTransactionListScreen()` | "Tu Actividad" + транзакции | +| `isPayInputCardNumberScreen()` | EditText с hint "Ingresar CBU, CVU o Alias" | +| `isPayTransferConfirmScreen()` | "Transferir" + "Alias:" без "Vas a transferir" | +| `isPayInputSumScreen()` | "Cuánto vas a transferír" | +| `isPayConfirmScreen()` | "Vas a transferir" | +| `isPaySuccessScreen()` | "Dinero enviado!" | + +### Парсинг данных + +| Метод | Формат / Логика | +|-------|-----------------| +| `parseUserNameFromHomeScreen()` | "¡Hola!\nИМЯ\ninitials" → ИМЯ | +| `parseBalanceFromHomeScreen()` | "$1.857,00" → 1857.00 (аргентинский формат) | +| `parseProfileNombre/Apellido/Email/Phone()` | По hint-атрибутам EditText | +| `parseCVUNumber()` | 22-значный номер после маркера "CVU" | +| `parseTransactionItems()` | ImageView ноды с "Transferencia" | +| `parseSpanishDate()` | "DD de MES de YYYY - HH:MM" → QDateTime | +| `parseRecipientNameFromConfirm()` | "Vas a transferir \na ИМЯ" → ИМЯ | +| `parsePaySuccessDateTime()` | "DD/MM/YY HH:MM" | +| `parsePaySuccessCoelsaId()` | Текст после "Id Coelsa" | + +### Парсинг транзакций + +Формат элемента списка (ImageView text): +``` +Transferencia // тип +15 de enero de 2025 - 14:30 // дата (испанский) +$1.500,00 // сумма (положительная = входящая) +``` + +или: +``` +Transferencia externa de Juan Perez // тип + имя отправителя +15 de enero de 2025 - 14:30 +$-1.500,00 // отрицательная = исходящая +``` + +### Испанские месяцы + +| Месяц | Перевод | +|-------|---------| +| enero | январь | +| febrero | февраль | +| marzo | март | +| abril | апрель | +| mayo | май | +| junio | июнь | +| julio | июль | +| agosto | август | +| septiembre | сентябрь | +| octubre | октябрь | +| noviembre | ноябрь | +| diciembre | декабрь | diff --git a/docs/ozon_tasks.md b/docs/ozon_tasks.md new file mode 100644 index 0000000..6ceeb69 --- /dev/null +++ b/docs/ozon_tasks.md @@ -0,0 +1,384 @@ +# Ozon - Логика задач + +[← Главная](index.md) | [События](events.md) + +Описание логики всех типов задач для банка **Ozon** (appCode: `ozon`). + +--- + +## Общие механизмы (CommonScript) + +### Запуск приложения (`runAppAndGoToHomeScreen`) + +1. Убивает приложение если запущено (`AdbUtils::tryToKillApplication`) +2. Запускает приложение (`AdbUtils::tryToStartApplication`) +3. Ждёт 4.5 секунды на старт +4. Цикл до 10 попыток: + - Получает XML-дамп экрана + - Проверяет `isHomeScreen()` (наличие кнопок "Перевести" + "Пополнить") + - Закрывает баннеры: диалоги, баннер безопасности, Google Play, рекламный bottom-sheet + - Если экран с PIN-кодом — вводит PIN (100–900мс между цифрами) + - Ожидание 1 сек между попытками +5. При неудаче — скриншот + лог ошибки + +### Навигация к продуктам (`goToAllProducts`) + +1. Закрывает баннеры на домашнем экране +2. Ищет вкладку "Финансы" +3. Тапает до 3 раз с ожиданием 1 сек + +### Прокрутка к кнопке (`swipeToButton`) + +- До 10 свайпов вверх (от 3/4 высоты к 1/4 высоты экрана) +- После каждого свайпа парсит XML и ищет кнопку +- Ожидание 1 сек между свайпами + +### Создание банковского профиля (`createBankProfileIfNeeded`) + +- Читает fullName и phone из `BankProfileDAO` +- Нормализует телефон: убирает нецифровые символы, добавляет код страны из `config.ini` +- Маппинг валют: RUB→643, USD→840, KRW→410, AZN→944, ARS→32 +- POST (новый) или PATCH (существующий) на сервер + +--- + +## FETCH_PROFILE + +**Скрипт:** `Ozon::LoginAndCheckAccountsScript` + `Ozon::GetCardInfoScript` + +### Цель + +Авторизация в приложении, получение баланса, полных номеров карт и синхронизация с сервером. + +### Шаги LoginAndCheckAccountsScript + +1. **Валидация** — получение DeviceInfo и BankProfileInfo из БД +2. **Запуск приложения** — `runAppAndGoToHomeScreen()` с PIN-кодом +3. **Создание профиля** — `createBankProfileIfNeeded()` (синхронизация fullName/phone) +4. **Сбор аккаунтов** (если не режим fetchProfileOnly): + - Навигация к "Финансы" через `goToAllProducts()` + - Парсинг видимых аккаунтов из XML + - Upsert в `MaterialDAO` + - Отправка на сервер +5. **Запуск GetCardInfoScript** — синхронно, для получения полных номеров карт + +### Шаги GetCardInfoScript + +1. **Навигация на домашний экран** +2. **Парсинг профиля** (если нет в БД): + - Тап на имя пользователя вверху экрана + - Парсинг fullName и phone + - Обновление `BankProfileDAO` +3. **Загрузка карт** — `parseCardsOnHomeScreen()` (до 5 попыток, 1.5 сек) +4. **Upsert всех карт** в `MaterialDAO` +5. **Синхронизация с сервером**: + - Создание устройства (POST) если нет `apiId` + - POST/PATCH банковского профиля с балансом, ФИО, телефоном +6. **Получение полных номеров карт** (для каждой карты): + - Тап на карту → ожидание экрана карты (10 × 2 сек) + - Тап "Показать" → ожидание появления номера (10 × 2 сек) + - Regex: `\d{4}[\s]\d{4}[\s]\d{4}[\s]\d{4}` (с unicode-пробелами) + - Обработка баннера "Понятно" + - Сохранение в `MaterialDAO::updateCardNumber()` + - При неудаче: статус карты → Off, лог + скриншот +7. **Отправка материалов** на сервер (`ns.postMaterial()`) +8. **Завершение** — убийство приложения + +### Ответ серверу (при успехе) + +```json +{ + "type": "FETCH_PROFILE", + "material_id": "...", + "bank_name": "ozon", + "data": { + "bank_account_id": "", + "amount": 1500000, + "currency": 643 + } +} +``` + +> `amount` — в копейках (баланс × 100) + +--- + +## FETCH_TRANSACTIONS + +**Скрипт:** `Ozon::GetLastTransactionsScript` + +### Цель + +Поиск конкретной транзакции по сумме и времени в истории операций банка. + +### Входные параметры (из JSON задачи) + +- `body.amount` — сумма в копейках (делится на 100) +- `body.created_at` — время транзакции (ISO формат) + +### Два режима работы + +| Режим | Условие | Поведение | +|-------|---------|-----------| +| **Поиск** | `searchAmount > 0` | Ищет конкретную транзакцию | +| **Сканирование** | `searchAmount == 0` | Собирает все транзакции за 24 часа | + +### Шаги (режим поиска) + +1. **Навигация к списку операций** (`navigateToTransactionList`): + - Закрытие рекламного баннера + - Проверка домашнего экрана (перезапуск если нужно) + - Свайп до кнопки "Все" (до 10 свайпов) + - Ожидание экрана "Операции" (10 × 1.5 сек) + +2. **Поиск транзакции** (`searchTransaction`): + - Цикл до 15 прокруток + - Для каждой видимой кнопки-транзакции: + - Парсинг суммы из текста кнопки + - Проверка совпадения: `|amount - searchAmount| ≤ 0.01` + - Парсинг даты из текста, проверка: `|time - searchTime| ≤ 1 час` + - При совпадении: + - Тап на транзакцию → парсинг деталей (телефон, имя, время) + - Вставка в `TransactionDAO` + - Возврат + +3. **Если не найдена** — возвращает `"Transaction not found"` + +### Шаги (режим сканирования) + +1. Навигация к списку операций (аналогично) +2. **Сканирование** (`scanRecentTransactions`): + - Для каждой транзакции: + - Тап → ожидание деталей (10 × 1.5 сек) + - Парсинг кнопки и деталей + - Фильтр по времени: пропускает транзакции старше 24 часов + - **Получение чека:** + - Тап "Документы" + - Ожидание "Поделиться" (20 × 1.5 сек) + - Скриншот → OCR (Tesseract, rus+eng) + - Извлечение ID операции через regex: + - `ID операции = ...` (буквенно-цифровой) + - `№ Ф-YYYY-XXXXXXX` + - Вставка в `TransactionDAO` + - Возврат назад → ожидание списка + +### Retry-логика (EventHandler) + +``` +Результат "Transaction not found" + └─ Прошло < 10 мин? → статус Wait, повтор через 15 сек + └─ Прошло ≥ 10 мин? → статус Error +``` + +> Между повторами другие задачи могут выполниться (см. приоритеты в events.md) + +### Ответ серверу (при успехе) + +```json +{ + "type": "FETCH_TRANSACTIONS", + "material_id": "...", + "bank_name": "ozon", + "data": [ + { + "bank_transaction_id": "Ф-2025-1234567", + "amount": 50000, + "currency": 643, + "transaction_type": "top-up", + "bank_participator_id": "+79001234567", + "status": "completed", + "extra": { "fee": 0 }, + "created_at": "2025-01-15T14:30:00.000+03:00" + } + ] +} +``` + +> Возвращаются **все** транзакции аккаунта за последние 24 часа (не только найденная). + +--- + +## CREATE_TRANSACTION (bank — по номеру карты) + +**Скрипт:** `Ozon::PayByCardScript` + +### Цель + +Перевод по номеру карты получателя. + +### Входные параметры + +- `event.phone` — номер карты получателя +- `event.amount` — сумма перевода +- `event.comment` — имя банка-получателя + +### Шаги + +1. **Подготовка**: + - Закрытие рекламных баннеров + - Проверка домашнего экрана (перезапуск если нужно) + - Закрытие: баннеры, Google Play, "Заказать карту" + +2. **Тап "Перевести"** → ожидание 2 сек + +3. **Ожидание "Платежи и переводы"** (10 × 1.5 сек) + +4. **Тап "По номеру карты"** (секция OTHER_WAYS) → ожидание 2 сек + +5. **Ожидание "Перевод по номеру карты"** (10 × 1.5 сек) + +6. **Ввод номера карты**: + - Поиск EditText с hint "Номер карты получателя" + - `AdbUtils::inputText(cardNumber)` + - Ожидание 2 сек + +7. **Ввод суммы**: + - Поиск EditText с hint "Сумма от" + - Ввод суммы (целое число) + - Ожидание 1 сек + +8. **Ожидание расчёта комиссии** (15 × 1.5 сек): + - Поиск текста "Комиссия" + парсинг суммы после него + - Извлечение цифр из формата "45 ₽" + - Fallback: комиссия = 0 + +9. **Тап "Перевести"** (contains-match) → ожидание 3 сек + +10. **Проверка результата** (15 × 2 сек): + - **"Успешно"** → сохранение транзакции: + - amount = -сумма (отрицательная) + - fee = рассчитанная комиссия + - type = Card, status = Complete + - timestamp = Moscow timezone + - **"Не удалось"** → ошибка + - **Таймаут** → "Не дождались результата" + +11. **Получение чека** (при успехе): + - Тап "Документы" + - Ожидание "Поделиться" (20 × 1.5 сек) + - Скриншот → OCR → извлечение ID операции + - Обновление БД: receipt image + external ID + +### Ответ серверу + +```json +{ + "type": "CREATE_TRANSACTION", + "material_id": "...", + "bank_name": "ozon", + "data": { + "amount": -50000, + "currency": 643, + "transaction_type": "bank", + "bank_participator_id": "2200123456789012", + "status": "completed", + "bank_transaction_id": "Ф-2025-1234567", + "extra": { "fee": 4500 }, + "created_at": "2025-01-15T14:30:00.000+03:00" + } +} +``` + +--- + +## CREATE_TRANSACTION (top-up — по номеру телефона) + +**Скрипт:** `Ozon::PayByPhoneScript` + +### Цель + +Перевод по номеру телефона с выбором банка-получателя. + +### Входные параметры + +- `event.phone` — номер телефона получателя +- `event.amount` — сумма перевода +- `event.comment` — имя банка-получателя (для СБП) + +### Шаги + +1. **Подготовка** — аналогично PayByCard + +2. **Тап "Перевести"** → ожидание 2 сек + +3. **Ожидание страницы платежей** → поиск EditText (10 × 1.5 сек) + +4. **Навигация к "Кому перевести"**: + - Тап на первый EditText → ожидание 2 сек + - Ожидание экрана "Кому перевести" (10 × 1.5 сек) + +5. **Ввод номера телефона**: + - Поиск первого EditText + - `AdbUtils::inputText(phone)` + - Ожидание 2 сек + +6. **Выбор получателя** (цикл 20 попыток): + - **Вариант A**: Найдена кнопка с номером телефона → тап + - **Вариант B**: Выбор банка: + - Тап "Другой банк" (content-desc="other-bank-item") + - Включение ADB IME клавиатуры + - Ввод названия банка через `AdbUtils::inputAdbIMEText()` + - Отключение ADB IME + - Поиск банка в списке (10 × 1.5 сек) + - Если банк не найден → ошибка "Банк '...' не найден" + - **Условие выхода**: текст "По номеру телефона" вверху экрана (y < height/7) + +7. **Ввод суммы**: + - Первый EditText → ввод суммы (целое число) + - Ожидание 1 сек + +8. **Тап "Продолжить"** → ожидание 2 сек + +9. **Ожидание "Подтвердите перевод"** (10 × 1.5 сек) + +10. **Тап "Перевести ..."** (contains-match) → ожидание 3 сек + +11. **Проверка результата** — аналогично PayByCard: + - "Успешно" → транзакция (type = Phone) + - "Не удалось" → ошибка + - Получение чека через OCR + +### Ответ серверу + +Аналогичен PayByCard, но: +- `transaction_type`: `"top-up"` +- `bank_participator_id`: номер телефона + +--- + +## ScreenXmlParser (Ozon) + +### Определение экранов + +| Метод | Маркеры | +|-------|---------| +| `isHomeScreen()` | Кнопки "Перевести" + "Пополнить" | +| `isProfileScreen()` | Заголовок "Аккаунт" + кнопка "Выйти" | +| `isTransactionListScreen()` | WebView с text="Операции" | + +### Баннеры и диалоги + +| Тип | Обнаружение | Действие | +|-----|-------------|----------| +| Диалог/пуш | Кнопки закрытия, "Позже" | Тап на кнопку закрытия | +| Безопасность | "Мы заботимся о вашей безопасности" | Тап "Отложить" | +| Биометрия | "Быстрый вход" | Тап "Не сейчас" | +| Реклама | resource-id "design_bottom_sheet" | Тап "touch_outside" или back | +| Google Play | "Закрыть диалоговое окно" / "Доступно обновление" | Тап закрытия | + +### PIN-код (`tryToFindPinCode`) + +**Метод 1**: Поиск кликабельных нод с текстом `^(?:key\s+)?(\d)$` → нужны все 10 цифр + +**Метод 2** (fallback): Поиск сетки 4×3 из кликабельных элементов одного размера (±10px) + +### Парсинг карт (`parseCardsOnHomeScreen`) + +- Ищет "Основной счёт X ₽" для общего баланса +- Парсит кнопки с текстом, оканчивающимся на 4 цифры +- Regex для суммы: `([\d\s]+(?:[.,]\d+)?)\s*₽` + +### Парсинг транзакций + +- `parseTransactionButtonText()` — извлекает сумму и описание из текста кнопки в списке +- `parseTransactionDetail()` — парсит детали: bankTime (DD.MM.YYYY, HH:mm), phone (+...), name diff --git a/services/net/NetworkService.cpp b/services/net/NetworkService.cpp index edfb094..45d2522 100644 --- a/services/net/NetworkService.cpp +++ b/services/net/NetworkService.cpp @@ -929,6 +929,26 @@ void NetworkService::patchBankProfile() { emit finished(); } +void NetworkService::toggleMaterial() { + if (!ensureValidToken()) { emit finished(); return; } + + const QString materialId = m_extraData.value("material_id").toString(); + const bool enable = m_extraData.value("enable").toBool(); + + if (materialId.isEmpty()) { + qWarning() << "[NetworkService] toggleMaterial: material_id is empty"; + emit finished(); + return; + } + + const QString enableStr = enable ? "true" : "false"; + const QString path = m_apiBase + m_pathMaterial + "/" + materialId + "/" + enableStr; + patchJson(path, QJsonObject(), true); + qDebug() << "[NetworkService] toggleMaterial done:" << materialId << "enable=" << enable; + + emit finished(); +} + void NetworkService::toggleBankProfile() { if (!ensureValidToken()) { emit finished(); return; } diff --git a/services/net/NetworkService.h b/services/net/NetworkService.h index fed0cad..74f7da2 100644 --- a/services/net/NetworkService.h +++ b/services/net/NetworkService.h @@ -32,6 +32,8 @@ public slots: void postMaterial(); + void toggleMaterial(); + void postDeviceScreenshot(); void getPayments(); diff --git a/views/bank/AccountSettingsWindow.cpp b/views/bank/AccountSettingsWindow.cpp index 12250bc..23054c0 100644 --- a/views/bank/AccountSettingsWindow.cpp +++ b/views/bank/AccountSettingsWindow.cpp @@ -495,19 +495,45 @@ void AccountSettingsWindow::updateAccountData(const MaterialInfo &account) { paymentThread->start(); } +void AccountSettingsWindow::toggleMaterialStatus(const MaterialInfo &account, QTableWidget *tableWidget) { + const bool isActive = account.status == MaterialStatus::Active; + const QString newStatus = isActive ? "off" : "active"; + + if (!MaterialDAO::updateAccountById(account.id, newStatus, account.description, account.currency)) { + qWarning() << "[AccountSettings] Failed to update material status:" << account.id; + return; + } + + // Отправляем на сервер PATCH /api/v1/profile/material/{id}/{true|false} + if (!account.materialId.isEmpty()) { + auto *thread = new QThread; + auto *net = new NetworkService; + net->moveToThread(thread); + net->addExtra("material_id", account.materialId); + net->addExtra("enable", !isActive); + connect(thread, &QThread::started, net, &NetworkService::toggleMaterial); + connect(net, &NetworkService::finished, thread, &QThread::quit); + connect(net, &NetworkService::finished, net, &QObject::deleteLater); + connect(thread, &QThread::finished, thread, &QThread::deleteLater); + thread->start(); + } + + reloadContent(tableWidget); + tableWidget->resizeColumnsToContents(); +} + void AccountSettingsWindow::reloadContent(QTableWidget *tableWidget) { QList accounts = MaterialDAO::getAccounts(m_deviceId, m_applicationInfo.code); tableWidget->clearContents(); tableWidget->setRowCount(accounts.length()); - tableWidget->setColumnCount(9); + tableWidget->setColumnCount(8); tableWidget->setHorizontalHeaderLabels({ "Время обновления", "Номер", - "", "Сумма", "Валюта", - "Статус", + "", "Сервер", "", "" @@ -516,7 +542,6 @@ void AccountSettingsWindow::reloadContent(QTableWidget *tableWidget) { tableWidget->setWordWrap(true); tableWidget->setTextElideMode(Qt::ElideNone); tableWidget->horizontalHeader()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter); - tableWidget->horizontalHeader()->setSectionResizeMode(5, QHeaderView::ResizeToContents); tableWidget->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); @@ -534,44 +559,42 @@ void AccountSettingsWindow::reloadContent(QTableWidget *tableWidget) { desc->setFlags(desc->flags() & ~Qt::ItemIsEditable); tableWidget->setItem(k, 1, desc); - QTableWidgetItem *lastNumbers = new QTableWidgetItem(account.lastNumbers); - lastNumbers->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter); - lastNumbers->setFlags(lastNumbers->flags() & ~Qt::ItemIsEditable); - tableWidget->setItem(k, 2, lastNumbers); - QTableWidgetItem *amount = new QTableWidgetItem(QString::number(account.amount)); amount->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter); amount->setFlags(amount->flags() & ~Qt::ItemIsEditable); - tableWidget->setItem(k, 3, amount); + tableWidget->setItem(k, 2, amount); QTableWidgetItem *currency = new QTableWidgetItem(account.currency); currency->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter); currency->setFlags(currency->flags() & ~Qt::ItemIsEditable); - tableWidget->setItem(k, 4, currency); + tableWidget->setItem(k, 3, currency); - QTableWidgetItem *status = new QTableWidgetItem(materialStatusToUiString(account.status)); - status->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter); - status->setFlags(status->flags() & ~Qt::ItemIsEditable); - if (account.status != MaterialStatus::Active) { - status->setForeground(QBrush(Qt::red)); - } - tableWidget->setItem(k, 5, status); + // Кнопка вкл/выкл карты + const bool isActive = account.status == MaterialStatus::Active; + auto *toggleBtn = new QPushButton(isActive ? "Выкл" : "Вкл", tableWidget); + toggleBtn->setStyleSheet(isActive + ? "background-color: orange; color: white;" + : "background-color: green; color: white;"); + tableWidget->setCellWidget(k, 4, toggleBtn); + + connect(toggleBtn, &QPushButton::clicked, this, [this, account, tableWidget]() { + toggleMaterialStatus(account, tableWidget); + }); auto *syncItem = new QTableWidgetItem(account.materialId.isEmpty() ? "Не синхр." : "Синхр."); syncItem->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter); syncItem->setForeground(account.materialId.isEmpty() ? QBrush(Qt::red) : QBrush(Qt::darkGreen)); syncItem->setFlags(syncItem->flags() & ~Qt::ItemIsEditable); - tableWidget->setItem(k, 6, syncItem); - + tableWidget->setItem(k, 5, syncItem); auto *deleteBankAccountBtn = new QPushButton("Удалить", tableWidget); deleteBankAccountBtn->setStyleSheet("background-color: red; color: white;"); - tableWidget->setCellWidget(k, 7, deleteBankAccountBtn); + tableWidget->setCellWidget(k, 6, deleteBankAccountBtn); connect(deleteBankAccountBtn, &QPushButton::clicked, this, [this, tableWidget, account]() { QMessageBox msgBox(this); msgBox.setWindowTitle("Удалить аккаунт?"); - msgBox.setText("Вы уверены, что хотите удалить аккаунт " + account.lastNumbers + "?"); + msgBox.setText("Вы уверены, что хотите удалить аккаунт?"); QPushButton *deleteBtn = msgBox.addButton("Удалить", QMessageBox::AcceptRole); QPushButton *cancelBtn = msgBox.addButton("Отмена", QMessageBox::RejectRole); msgBox.setDefaultButton(cancelBtn); @@ -588,7 +611,7 @@ void AccountSettingsWindow::reloadContent(QTableWidget *tableWidget) { auto *transactionsBtn = new QPushButton("Транзакции", tableWidget); transactionsBtn->setStyleSheet("background-color: #1565C0; color: white;"); - tableWidget->setCellWidget(k, 8, transactionsBtn); + tableWidget->setCellWidget(k, 7, transactionsBtn); connect(transactionsBtn, &QPushButton::clicked, this, [this, account]() { auto *txWindow = new TransactionWindow(account.id, this); diff --git a/views/bank/AccountSettingsWindow.h b/views/bank/AccountSettingsWindow.h index a0f36dd..53e25ab 100644 --- a/views/bank/AccountSettingsWindow.h +++ b/views/bank/AccountSettingsWindow.h @@ -33,5 +33,7 @@ private: void sendAppStatus(const QString &deviceId, const QString &code, const QString &status); + void toggleMaterialStatus(const MaterialInfo &account, QTableWidget *tableWidget); + void updateAccountData(const MaterialInfo &account); };