1
0
forked from BRT/arc

- Add toggleMaterial logic to NetworkService for enabling/disabling materials via PATCH request.

- Implement `toggleMaterialStatus` in `AccountSettingsWindow` for UI control and threading integration.
- Enhance `MaterialDAO` with numeric validation for `lastNumbers` field to ensure data integrity.
- Update `PayByCardScript` and `PayByPhoneScript` to handle PIN entry if required after payment confirmation.
- Introduce dynamic UI adjustments for material toggling, including a new toggle button and layout refinements.
- Add documentation for task logic in `ozon_tasks.md` and `black_tasks.md`.
- Switch default currency in `GetCardInfoScript` (Black app) from USD to ARS.
This commit is contained in:
slava 2026-03-31 11:40:34 +07:00
parent bceceb516b
commit de8c2e84d0
11 changed files with 880 additions and 24 deletions

View File

@ -110,7 +110,7 @@ void GetCardInfoScript::doStart() {
account.lastNumbers = lastNumbers; account.lastNumbers = lastNumbers;
account.cardNumber = cvuNumber; account.cardNumber = cvuNumber;
account.amount = balance; account.amount = balance;
account.currency = "USD"; account.currency = "ARS";
account.description = "CVU"; account.description = "CVU";
account.status = MaterialStatus::Active; account.status = MaterialStatus::Active;
account.updateTime = QDateTime::currentDateTimeUtc(); account.updateTime = QDateTime::currentDateTimeUtc();

View File

@ -81,6 +81,8 @@ void PayByCardScript::makePayment(
if (enviarBtn.isEmpty()) { if (enviarBtn.isEmpty()) {
m_error = "Button 'Enviar a CBU o alias' not found: " + deviceId; m_error = "Button 'Enviar a CBU o alias' not found: " + deviceId;
qWarning() << m_error; qWarning() << m_error;
AppLogger::log("black/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return; return;
} }
@ -103,6 +105,8 @@ void PayByCardScript::makePayment(
if (!onInputScreen) { if (!onInputScreen) {
m_error = "Input card number screen not loaded: " + deviceId; m_error = "Input card number screen not loaded: " + deviceId;
qWarning() << m_error; qWarning() << m_error;
AppLogger::log("black/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return; return;
} }
@ -153,6 +157,8 @@ void PayByCardScript::makePayment(
if (!onTransferConfirm) { if (!onTransferConfirm) {
m_error = "Transfer confirm screen not loaded: " + deviceId; m_error = "Transfer confirm screen not loaded: " + deviceId;
qWarning() << m_error; qWarning() << m_error;
AppLogger::log("black/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return; return;
} }
@ -186,6 +192,8 @@ void PayByCardScript::makePayment(
if (!onInputSum) { if (!onInputSum) {
m_error = "Input sum screen not loaded: " + deviceId; m_error = "Input sum screen not loaded: " + deviceId;
qWarning() << m_error; qWarning() << m_error;
AppLogger::log("black/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return; return;
} }
@ -278,6 +286,8 @@ void PayByCardScript::makePayment(
if (!onPayConfirm) { if (!onPayConfirm) {
m_error = "Pay confirm screen not loaded: " + deviceId; m_error = "Pay confirm screen not loaded: " + deviceId;
qWarning() << m_error; qWarning() << m_error;
AppLogger::log("black/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return; return;
} }
@ -330,6 +340,8 @@ void PayByCardScript::makePayment(
if (!onSuccess) { if (!onSuccess) {
m_error = "Pay success screen not loaded: " + deviceId; m_error = "Pay success screen not loaded: " + deviceId;
qWarning() << m_error; qWarning() << m_error;
AppLogger::log("black/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return; return;
} }

View File

@ -281,10 +281,28 @@ void PayByCardScript::makePayment(
AdbUtils::makeTap(deviceId, transferBtn2.x(), transferBtn2.y()); AdbUtils::makeTap(deviceId, transferBtn2.x(), transferBtn2.y());
QThread::msleep(3000); QThread::msleep(3000);
// 8.5. Проверяем экран ввода PIN-кода
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (const QList<Node> pinNodes = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
!pinNodes.isEmpty()) {
qDebug() << "[PayByCard] PIN code screen detected after confirm, entering PIN...";
inputPinCode(deviceId, pinNodes);
QThread::msleep(3000);
}
// 9. Ждём результат: "Успешно" или "Не удалось" // 9. Ждём результат: "Успешно" или "Не удалось"
for (int attempt = 0; attempt < 15; ++attempt) { for (int attempt = 0; attempt < 15; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
// Если PIN-код появился позже — вводим
if (const QList<Node> 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()) { if (!xmlScreenParser.findTextNode("Успешно").isEmpty()) {
qDebug() << "[PayByCard] Payment successful!"; qDebug() << "[PayByCard] Payment successful!";
m_error.clear(); m_error.clear();

View File

@ -386,10 +386,28 @@ void PayByPhoneScript::makePayment(
AdbUtils::makeTap(deviceId, transferConfirmBtn.x(), transferConfirmBtn.y()); AdbUtils::makeTap(deviceId, transferConfirmBtn.x(), transferConfirmBtn.y());
QThread::msleep(3000); QThread::msleep(3000);
// 8.5. Проверяем экран ввода PIN-кода
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (const QList<Node> pinNodes = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
!pinNodes.isEmpty()) {
qDebug() << "[PayByPhone] PIN code screen detected after confirm, entering PIN...";
inputPinCode(deviceId, pinNodes);
QThread::msleep(3000);
}
// 9. Ждём результат: "Успешно" или "Не удалось" // 9. Ждём результат: "Успешно" или "Не удалось"
for (int attempt = 0; attempt < 15; ++attempt) { for (int attempt = 0; attempt < 15; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
// Если PIN-код появился позже — вводим
if (const QList<Node> 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()) { if (!xmlScreenParser.findTextNode("Успешно").isEmpty()) {
qDebug() << "[PayByPhone] Payment successful!"; qDebug() << "[PayByPhone] Payment successful!";
m_error.clear(); m_error.clear();

View File

@ -1,5 +1,6 @@
#include <QSqlQuery> #include <QSqlQuery>
#include <QSqlError> #include <QSqlError>
#include <QRegularExpression>
#include "MaterialDAO.h" #include "MaterialDAO.h"
#include "db/MaterialInfo.h" #include "db/MaterialInfo.h"
@ -7,6 +8,14 @@
#include "time/DateUtils.h" #include "time/DateUtils.h"
bool MaterialDAO::upsertAccount(const MaterialInfo &info) { 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()); QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"( query.prepare(R"(

368
docs/black_tasks.md Normal file
View File

@ -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 | декабрь |

384
docs/ozon_tasks.md Normal file
View File

@ -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 (100900мс между цифрами)
- Ожидание 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

View File

@ -929,6 +929,26 @@ void NetworkService::patchBankProfile() {
emit finished(); 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() { void NetworkService::toggleBankProfile() {
if (!ensureValidToken()) { emit finished(); return; } if (!ensureValidToken()) { emit finished(); return; }

View File

@ -32,6 +32,8 @@ public slots:
void postMaterial(); void postMaterial();
void toggleMaterial();
void postDeviceScreenshot(); void postDeviceScreenshot();
void getPayments(); void getPayments();

View File

@ -495,19 +495,45 @@ void AccountSettingsWindow::updateAccountData(const MaterialInfo &account) {
paymentThread->start(); 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) { void AccountSettingsWindow::reloadContent(QTableWidget *tableWidget) {
QList<MaterialInfo> accounts = MaterialDAO::getAccounts(m_deviceId, m_applicationInfo.code); QList<MaterialInfo> accounts = MaterialDAO::getAccounts(m_deviceId, m_applicationInfo.code);
tableWidget->clearContents(); tableWidget->clearContents();
tableWidget->setRowCount(accounts.length()); tableWidget->setRowCount(accounts.length());
tableWidget->setColumnCount(9); tableWidget->setColumnCount(8);
tableWidget->setHorizontalHeaderLabels({ tableWidget->setHorizontalHeaderLabels({
"Время обновления", "Время обновления",
"Номер", "Номер",
"",
"Сумма", "Сумма",
"Валюта", "Валюта",
"Статус", "",
"Сервер", "Сервер",
"", "",
"" ""
@ -516,7 +542,6 @@ void AccountSettingsWindow::reloadContent(QTableWidget *tableWidget) {
tableWidget->setWordWrap(true); tableWidget->setWordWrap(true);
tableWidget->setTextElideMode(Qt::ElideNone); tableWidget->setTextElideMode(Qt::ElideNone);
tableWidget->horizontalHeader()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter); tableWidget->horizontalHeader()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter);
tableWidget->horizontalHeader()->setSectionResizeMode(5, QHeaderView::ResizeToContents);
tableWidget->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); tableWidget->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch);
@ -534,44 +559,42 @@ void AccountSettingsWindow::reloadContent(QTableWidget *tableWidget) {
desc->setFlags(desc->flags() & ~Qt::ItemIsEditable); desc->setFlags(desc->flags() & ~Qt::ItemIsEditable);
tableWidget->setItem(k, 1, desc); 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)); QTableWidgetItem *amount = new QTableWidgetItem(QString::number(account.amount));
amount->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter); amount->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter);
amount->setFlags(amount->flags() & ~Qt::ItemIsEditable); amount->setFlags(amount->flags() & ~Qt::ItemIsEditable);
tableWidget->setItem(k, 3, amount); tableWidget->setItem(k, 2, amount);
QTableWidgetItem *currency = new QTableWidgetItem(account.currency); QTableWidgetItem *currency = new QTableWidgetItem(account.currency);
currency->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter); currency->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter);
currency->setFlags(currency->flags() & ~Qt::ItemIsEditable); 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); const bool isActive = account.status == MaterialStatus::Active;
status->setFlags(status->flags() & ~Qt::ItemIsEditable); auto *toggleBtn = new QPushButton(isActive ? "Выкл" : "Вкл", tableWidget);
if (account.status != MaterialStatus::Active) { toggleBtn->setStyleSheet(isActive
status->setForeground(QBrush(Qt::red)); ? "background-color: orange; color: white;"
} : "background-color: green; color: white;");
tableWidget->setItem(k, 5, status); tableWidget->setCellWidget(k, 4, toggleBtn);
connect(toggleBtn, &QPushButton::clicked, this, [this, account, tableWidget]() {
toggleMaterialStatus(account, tableWidget);
});
auto *syncItem = new QTableWidgetItem(account.materialId.isEmpty() ? "Не синхр." : "Синхр."); auto *syncItem = new QTableWidgetItem(account.materialId.isEmpty() ? "Не синхр." : "Синхр.");
syncItem->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter); syncItem->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter);
syncItem->setForeground(account.materialId.isEmpty() ? QBrush(Qt::red) : QBrush(Qt::darkGreen)); syncItem->setForeground(account.materialId.isEmpty() ? QBrush(Qt::red) : QBrush(Qt::darkGreen));
syncItem->setFlags(syncItem->flags() & ~Qt::ItemIsEditable); syncItem->setFlags(syncItem->flags() & ~Qt::ItemIsEditable);
tableWidget->setItem(k, 6, syncItem); tableWidget->setItem(k, 5, syncItem);
auto *deleteBankAccountBtn = new QPushButton("Удалить", tableWidget); auto *deleteBankAccountBtn = new QPushButton("Удалить", tableWidget);
deleteBankAccountBtn->setStyleSheet("background-color: red; color: white;"); 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]() { connect(deleteBankAccountBtn, &QPushButton::clicked, this, [this, tableWidget, account]() {
QMessageBox msgBox(this); QMessageBox msgBox(this);
msgBox.setWindowTitle("Удалить аккаунт?"); msgBox.setWindowTitle("Удалить аккаунт?");
msgBox.setText("Вы уверены, что хотите удалить аккаунт " + account.lastNumbers + "?"); msgBox.setText("Вы уверены, что хотите удалить аккаунт?");
QPushButton *deleteBtn = msgBox.addButton("Удалить", QMessageBox::AcceptRole); QPushButton *deleteBtn = msgBox.addButton("Удалить", QMessageBox::AcceptRole);
QPushButton *cancelBtn = msgBox.addButton("Отмена", QMessageBox::RejectRole); QPushButton *cancelBtn = msgBox.addButton("Отмена", QMessageBox::RejectRole);
msgBox.setDefaultButton(cancelBtn); msgBox.setDefaultButton(cancelBtn);
@ -588,7 +611,7 @@ void AccountSettingsWindow::reloadContent(QTableWidget *tableWidget) {
auto *transactionsBtn = new QPushButton("Транзакции", tableWidget); auto *transactionsBtn = new QPushButton("Транзакции", tableWidget);
transactionsBtn->setStyleSheet("background-color: #1565C0; color: white;"); transactionsBtn->setStyleSheet("background-color: #1565C0; color: white;");
tableWidget->setCellWidget(k, 8, transactionsBtn); tableWidget->setCellWidget(k, 7, transactionsBtn);
connect(transactionsBtn, &QPushButton::clicked, this, [this, account]() { connect(transactionsBtn, &QPushButton::clicked, this, [this, account]() {
auto *txWindow = new TransactionWindow(account.id, this); auto *txWindow = new TransactionWindow(account.id, this);

View File

@ -33,5 +33,7 @@ private:
void sendAppStatus(const QString &deviceId, const QString &code, const QString &status); void sendAppStatus(const QString &deviceId, const QString &code, const QString &status);
void toggleMaterialStatus(const MaterialInfo &account, QTableWidget *tableWidget);
void updateAccountData(const MaterialInfo &account); void updateAccountData(const MaterialInfo &account);
}; };