1
0
forked from BRT/arc

Add support for mock server state persistence, error tracking, and screenshot handling. Extend bootstrapping logic and monitoring endpoints for enhanced debugging and task management.

This commit is contained in:
slava 2026-04-14 18:46:34 +07:00
parent 628323184f
commit 899f4ff6d6
20 changed files with 933 additions and 492 deletions

4
.gitignore vendored
View File

@ -6,6 +6,10 @@
.DS_Store
.idea/
# Mock server state
tests/mock_server/.mock_state.json
tests/mock_server/.mock_screenshots/
cmake_install.cmake
CMakeCache.txt
Makefile

View File

@ -4,6 +4,7 @@
#include <QString>
#include <QCoreApplication>
#include <QFileInfo>
#include <QRegularExpression>
#include <QThread>
AdbUtils::AdbUtils(QObject *parent) : QObject(parent) {
@ -197,8 +198,8 @@ bool AdbUtils::tryToKillApplication(const QString &deviceId, const QString &pack
// adb -s 6edc4a47 shell 'uiautomator dump /sdcard/uidump.xml' && adb -s 6edc4a47 pull /sdcard/uidump.xml step_1.xml && adb -s 6edc4a47 shell 'rm /sdcard/uidump.xml'
QString AdbUtils::getScreenDumpAsXml(const QString &deviceId, int idx, bool screenshot) {
const QString adb = adbPath();
QString output;
for (int retry = 0; retry < 3; ++retry) {
// 1. Dump UI hierarchy (retry up to 3 times — uiautomator may be killed by Android)
bool dumpOk = false;
for (int attempt = 0; attempt < 3; ++attempt) {
@ -229,9 +230,20 @@ QString AdbUtils::getScreenDumpAsXml(const QString &deviceId, int idx, bool scre
}
// 4. Remove temp XML file
startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/uidump.xml"}); // неважно, успешна ли команда
startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/uidump.xml"});
// 5. Screenshot (if needed)
// 5. Проверяем что XML не пустой, достаточного размера и содержит непустые UI-элементы
static const QRegularExpression reNonEmpty(R"((text|content-desc)="[^"]+")");
const bool hasContent = reNonEmpty.match(xmlOut).hasMatch();
if (xmlOut.trimmed().isEmpty() || !xmlOut.contains("<?xml")
|| xmlOut.size() < 500 || !hasContent) {
qWarning() << "[AdbUtils] getScreenDumpAsXml: bad dump (size=" << xmlOut.size()
<< "), retry" << (retry + 1) << "of 3";
QThread::msleep(1500);
continue;
}
// 6. Screenshot (if needed)
if (screenshot) {
startProcess(adb, {"-s", deviceId, "shell", "screencap", "-p", "/sdcard/screen.png"});
startProcess(adb, {"-s", deviceId, "pull", "/sdcard/screen.png",
@ -239,7 +251,7 @@ QString AdbUtils::getScreenDumpAsXml(const QString &deviceId, int idx, bool scre
startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/screen.png"});
}
// 6. Обрезка к началу XML
// 7. Обрезка к началу XML
QByteArray xmlBytes = xmlOut.toUtf8();
if (const int start = xmlBytes.indexOf("<?xml"); start != -1) {
xmlBytes = xmlBytes.mid(start);
@ -247,6 +259,10 @@ QString AdbUtils::getScreenDumpAsXml(const QString &deviceId, int idx, bool scre
return QString::fromUtf8(xmlBytes);
}
qWarning() << "[AdbUtils] getScreenDumpAsXml: empty dump after 3 retries";
return {};
}
bool AdbUtils::takeScreenshot(const QString &deviceId, const QString &name) {
const QString adb = adbPath();
struct Step { QStringList args; };

View File

@ -331,9 +331,9 @@ void GetLastTransactionsScript::doStart() {
// 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);
const int fromY = device.screenHeight / 3;
const int toY = device.screenHeight * 2 / 3;
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500);
QThread::msleep(3000);
qDebug() << "[Dushanbe::GetLastTransactions] Pull-to-refresh on Выписка";
}
@ -369,7 +369,7 @@ void GetLastTransactionsScript::doStart() {
// Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства
const QTimeZone tjTz("Asia/Dushanbe");
const int maxScrolls = 4;
const int maxScrolls = 10;
auto interrupted = []() {
return QThread::currentThread()->isInterruptionRequested();
@ -379,6 +379,7 @@ void GetLastTransactionsScript::doStart() {
double amount = 0.0;
QString time; // "21:31:21"
QString date; // "05.04.2026"
bool dateReliable = false; // true если дата из content-desc (P2P), false если из groupDates
Node node;
};
@ -462,7 +463,7 @@ void GetLastTransactionsScript::doStart() {
it.time = last;
it.node = n;
// В P2P формате дата встроена в первую строку
// В P2P формате дата встроена в первую строку — надёжная
if (isP2P) {
if (firstLine.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0)
it.date = todayLocal.toString("dd.MM.yyyy");
@ -470,6 +471,7 @@ void GetLastTransactionsScript::doStart() {
it.date = todayLocal.addDays(-1).toString("dd.MM.yyyy");
else
it.date = firstLine;
it.dateReliable = true;
}
items.append(it);
}
@ -537,8 +539,15 @@ void GetLastTransactionsScript::doStart() {
prevY2 = it.node.y2();
}
qDebug() << "[Dushanbe::GetLastTransactions] Vypiska items:" << items.size()
<< "groups:" << groupDates;
qDebug() << "[Dushanbe::GetLastTransactions] scroll=" << scroll
<< "items:" << items.size() << "groups:" << groupDates
<< "hasEmbedded:" << hasEmbedded;
// Логируем все items для отладки
for (int i = 0; i < items.size(); ++i) {
qDebug() << "[Dushanbe::GetLastTransactions] item[" << i << "] amount=" << items[i].amount
<< "date=" << items[i].date << "time=" << items[i].time
<< "y=" << items[i].node.y();
}
const double searchAbs = qAbs(m_searchAmount);
for (const auto &tx : items) {
@ -552,10 +561,10 @@ void GetLastTransactionsScript::doStart() {
<< (matchedX100 ? "(scaled x100)" : "(direct)")
<< "date:" << tx.date << "time:" << tx.time;
// Pre-filter по ДАТЕ: если дата ряда не совпадает с датой поиска (±1 день),
// пропускаем без тапа-в-детали. Это отсекает большинство "нематчащих" строк
// быстрее, чем 10-секундный цикл tap→parse→goBack.
if (m_searchTime.isValid() && !tx.date.isEmpty()) {
// Pre-filter по ДАТЕ: только если дата надёжная (из content-desc P2P формата).
// DCWallet транзакции получают дату из groupDates ImageView, которая
// неправильно присваивается при скролле — не фильтруем по ней.
if (tx.dateReliable && m_searchTime.isValid() && !tx.date.isEmpty()) {
const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy");
const QDate searchLocalDate = m_searchTime.toTimeZone(tjTz).date();
if (listDate.isValid() && searchLocalDate.isValid()) {
@ -720,13 +729,21 @@ void GetLastTransactionsScript::doStart() {
return;
}
// Скроллим вниз
// Скроллим вниз — свайп внутри области транзакций
if (scroll < maxScrolls) {
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
const int x = device.screenWidth / 2;
const int fromY = device.screenHeight * 3 / 4;
const int toY = device.screenHeight / 4;
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY);
// Определяем область контента из реальных Y позиций items
int contentBottom = device.screenHeight * 2 / 3;
int contentTop = device.screenHeight / 3;
if (!items.isEmpty()) {
contentBottom = items.last().node.y();
contentTop = items.first().node.y();
}
const int fromY = qMin(contentBottom, device.screenHeight * 2 / 3);
const int toY = qMax(contentTop, device.screenHeight / 4);
qDebug() << "[Dushanbe::GetLastTransactions] Swipe from" << fromY << "to" << toY;
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500);
QThread::msleep(2000);
}
}

View File

@ -88,6 +88,15 @@ bool CommonScript::runAppAndGoToHomeScreen(
qDebug() << "[Ozon] Fast path: Home reached after PIN";
return true;
}
// Ошибка загрузки — тапаем "Обновить"
{
Node refreshBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Обновить"));
if (refreshBtn.x() > 0 && refreshBtn.y() > 0) {
qDebug() << "[Ozon] Fast path: page error after PIN, tapping 'Обновить'";
AdbUtils::makeTap(deviceId, refreshBtn.x(), refreshBtn.y());
QThread::msleep(5000);
}
}
QThread::msleep(1000);
}
}
@ -128,6 +137,17 @@ bool CommonScript::runAppAndGoToHomeScreen(
continue;
}
// Проверяем на ошибку загрузки WebView ("Обновить")
{
Node refreshBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Обновить"));
if (refreshBtn.x() > 0 && refreshBtn.y() > 0) {
qDebug() << "[runAppAndGoToHomeScreen] Page load error, tapping 'Обновить'";
AdbUtils::makeTap(deviceId, refreshBtn.x(), refreshBtn.y());
QThread::msleep(5000);
continue;
}
}
// Проверяем на банеры перед пин-кодом
Node closeBannerNode = xmlScreenParser.tryToFindSecurityBanner();
if (!closeBannerNode.isEmpty()) {

View File

@ -132,7 +132,21 @@ void GetLastTransactionsScript::doStart() {
}
bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &device, const BankProfileInfo &app) {
// Хелпер: если на экране ошибка загрузки ("Обновить"), тапаем один раз
auto tryRefreshIfError = [&]() {
// Кнопка "Обновить" — Button с content-desc="Обновить" (text пустой)
Node refreshBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Обновить"));
if (refreshBtn.x() > 0 && refreshBtn.y() > 0) {
qDebug() << "[GetLastTransactions] Page load error, tapping 'Обновить' at"
<< refreshBtn.x() << refreshBtn.y();
AdbUtils::makeTap(m_deviceId, refreshBtn.x(), refreshBtn.y());
QThread::msleep(5000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
};
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
tryRefreshIfError();
// Закрываем рекламный bottom sheet
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
@ -147,6 +161,21 @@ bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &devi
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Если уже на экране операций — скроллим наверх и готово
if (xmlScreenParser.isTransactionListScreen()) {
qDebug() << "[GetLastTransactions] Already on transaction list screen, scrolling to top";
// Несколько быстрых свайпов вверх чтобы вернуться к началу списка
for (int i = 0; i < 5; ++i) {
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight / 3,
device.screenWidth / 2,
device.screenHeight * 2 / 3, 200);
QThread::msleep(500);
}
QThread::msleep(1000);
return true;
}
// Если не на домашнем экране — перезапускаем
if (!xmlScreenParser.isHomeScreen()) {
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
@ -160,6 +189,7 @@ bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &devi
return false;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
tryRefreshIfError();
}
// Закрываем баннеры на главной
@ -212,17 +242,19 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId);
QTimeZone deviceTz(tzName.toUtf8());
if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow");
const int maxScrolls = 30;
const int maxScrolls = 10;
const int navBarTop = device.screenHeight - (device.screenHeight / 10);
// Целевая дата в таймзоне устройства для поиска заголовка дня в списке
const QDateTime searchLocal = m_searchTime.toTimeZone(deviceTz);
const QDate searchDate = searchLocal.date();
qDebug() << "[FetchTransactions] Searching: amount=" << m_searchAmount
qDebug() << "[FetchTransactions] Searching: device=" << m_deviceId
<< "amount=" << m_searchAmount
<< "time=" << m_searchTime.toString(Qt::ISODate)
<< "tz=" << tzName
<< "date(local)=" << searchDate.toString("dd.MM.yyyy");
<< "date(local)=" << searchDate.toString("dd.MM.yyyy")
<< "screen=" << device.screenWidth << "x" << device.screenHeight;
// ── Шаг 1: скроллим до нужной даты ──────────────────────────────
static const QMap<QString, int> monthMap = {
@ -290,7 +322,8 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
break;
}
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight - 300, device.screenWidth / 2, 300, 300);
device.screenHeight * 2 / 3, device.screenWidth / 2,
device.screenHeight / 3, 600);
QThread::msleep(1500);
}
@ -305,26 +338,45 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
// Кнопки транзакций между этими Y принадлежат нужному дню.
// Если заголовок нужной даты ушёл за верх экрана, но следующая дата ещё
// видна ниже (или не видна вовсе), dateY = 0 — все кнопки выше nextDateY
// принадлежат нашему дню.
auto findDateYRange = [&]() -> QPair<int, int> {
int dateY = -1;
int nextDateY = device.screenHeight;
bool foundOlderDate = false;
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) {
// Первый заголовок другой даты после нашей
} else if (d < searchDate) {
// Заголовок более старой даты — граница снизу
if (dateY >= 0 && node.y() > dateY) {
nextDateY = node.y();
} else if (dateY < 0) {
// Заголовок нужной даты ушёл вверх, но видна более старая дата
nextDateY = node.y();
dateY = 0; // всё выше nextDateY принадлежит нашему дню
}
foundOlderDate = true;
break;
}
}
// Заголовок ушёл вверх и нет более старых дат на экране —
// весь экран всё ещё внутри нашей даты
if (dateY < 0 && !foundOlderDate && dateFound) {
dateY = 0;
}
qDebug() << "[FetchTransactions] findDateYRange: dateY=" << dateY
<< "nextDateY=" << nextDateY << "foundOlderDate=" << foundOlderDate;
return {dateY, nextDateY};
};
// ── Шаг 2: тапаем только по кнопкам под заголовком нужной даты ───
QSet<QString> checkedDetails;
int noProgressCount = 0; // Счётчик итераций без прогресса (новых unchecked кнопок)
for (int attempt = 0; attempt < 15; ++attempt) {
if (QThread::currentThread()->isInterruptionRequested()) {
@ -333,21 +385,38 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Логируем все заголовки дат на экране
{
QStringList datesOnScreen;
for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue;
const QDate d = parseDateHeader(node.text.trimmed());
if (d.isValid()) {
datesOnScreen << QString("%1@y=%2").arg(d.toString("dd.MM")).arg(node.y());
}
}
qDebug() << "[FetchTransactions] attempt=" << attempt << "dates on screen:" << datesOnScreen;
}
// Находим Y-диапазон заголовка нужной даты
const auto [dateHeaderY, nextDateHeaderY] = findDateYRange();
if (dateHeaderY < 0) {
qDebug() << "[FetchTransactions] Date header not on screen, scrolling";
qDebug() << "[FetchTransactions] Date completely off screen, scrolling up";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight * 3 / 4,
device.screenHeight / 3,
device.screenWidth / 2,
device.screenHeight / 4, 300);
QThread::msleep(1000);
device.screenHeight * 2 / 3, 600);
QThread::msleep(1500);
continue;
}
QList<Node> txButtons = xmlScreenParser.findTransactionButtons(20);
qDebug() << "[FetchTransactions] Found" << txButtons.size() << "buttons, dateY range:"
<< dateHeaderY << "-" << nextDateHeaderY;
bool tapped = false;
bool hasNewButton = false; // Есть ли новая (ещё не проверенная) кнопка с нужной суммой
int buttonsInRange = 0; // Кнопки внутри Y-диапазона нужной даты
for (int bi = 0; bi < txButtons.size(); ++bi) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
@ -358,8 +427,12 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
const int btnY = txButtons[bi].y();
if (btnY <= dateHeaderY || btnY >= nextDateHeaderY) continue;
if (btnY <= 0 || btnY >= navBarTop) continue;
++buttonsInRange;
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text);
qDebug() << "[FetchTransactions] Button" << bi << "y=" << btnY
<< "parsed amount=" << parsed.amount << "search=" << m_searchAmount
<< "text=" << txButtons[bi].text.left(60);
if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue;
const Node &visibleBtn = txButtons[bi];
@ -412,6 +485,20 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
}
checkedDetails.insert(dtStr);
tapped = true;
hasNewButton = true;
// Проверяем что транзакция принадлежит нужной дате (защита от широкого Y-диапазона)
if (txTime.isValid()) {
const QDate txLocalDate = txTime.toTimeZone(deviceTz).date();
if (txLocalDate != searchDate) {
qDebug() << "[FetchTransactions] Wrong date in detail:" << dtStr
<< "expected date" << searchDate.toString("dd.MM.yyyy")
<< "- button belongs to different date, skipping";
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
continue;
}
}
// Проверяем время (±10 минут)
if (txTime.isValid() && m_searchTime.isValid()) {
@ -427,7 +514,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
}
// Нашли!
qDebug() << "[FetchTransactions] FOUND transaction:"
qDebug() << "[FetchTransactions] FOUND transaction: device=" << m_deviceId
<< "amount=" << parsed.amount << "time=" << dtStr
<< "name=" << detail.name;
@ -443,7 +530,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
QThread::msleep(2000);
bool docLoaded = false;
for (int da = 0; da < 15; ++da) {
for (int da = 0; da < 5; ++da) {
if (QThread::currentThread()->isInterruptionRequested()) break;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) {
@ -535,17 +622,50 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
return; // успех
}
// Скроллим вниз если ни одна кнопка не была нажата или все проверенные не подошли
if (!tapped) {
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight * 3 / 4,
device.screenWidth / 2,
device.screenHeight / 4, 300);
QThread::msleep(1000);
// Отслеживаем прогресс: "без прогресса" только если были кнопки с СОВПАДАЮЩЕЙ
// суммой (tapped), но все уже checked. Если суммы не совпали — скроллим дальше,
// нужная кнопка может быть ниже.
if (!hasNewButton && tapped) {
++noProgressCount;
if (noProgressCount >= 5) {
qDebug() << "[FetchTransactions] No progress after" << noProgressCount
<< "attempts with matching amount — giving up";
break;
}
} else if (hasNewButton) {
noProgressCount = 0;
}
// Скроллим вниз если не нашли нужную транзакцию
if (!tapped || !hasNewButton) {
qDebug() << "[FetchTransactions] Scrolling down (tapped=" << tapped
<< "hasNew=" << hasNewButton << "inRange=" << buttonsInRange
<< "noProgress=" << noProgressCount << ")";
if (dateHeaderY > 0 && dateHeaderY < device.screenHeight / 2) {
// Заголовок в верхней половине — маленький скролл чтобы показать
// больше кнопок под этой датой
const int fromY = device.screenHeight * 2 / 3;
const int toY = device.screenHeight / 3;
qDebug() << "[FetchTransactions] Small scroll: from" << fromY << "to" << toY
<< "(dateHeaderY=" << dateHeaderY << ")";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, fromY,
device.screenWidth / 2, toY, 500);
} else {
// Заголовок в нижней половине или за экраном — полный скролл
const int fromY = device.screenHeight * 3 / 4;
const int toY = device.screenHeight / 4;
qDebug() << "[FetchTransactions] Full scroll down: from" << fromY << "to" << toY
<< "(dateHeaderY=" << dateHeaderY << ")";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, fromY,
device.screenWidth / 2, toY, 400);
}
QThread::msleep(1500);
}
}
m_error = "Transaction not found: amount=" + QString::number(m_searchAmount, 'f', 2)
m_error = "Transaction not found: device=" + m_deviceId
+ " amount=" + QString::number(m_searchAmount, 'f', 2)
+ " date=" + searchDate.toString("dd.MM.yyyy");
qWarning() << "[FetchTransactions]" << m_error;
// Отправляем скриншот списка транзакций, сделанный до начала поиска

View File

@ -316,7 +316,8 @@ int main(int argc, char *argv[]) {
QApplication::setPalette(lightPalette);
logFile.setFileName("application.log");
if (!logFile.open(QIODevice::Append | QIODevice::Text)) {
// Очищаем лог-файл при каждом запуске
if (!logFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
qWarning() << "Не удалось открыть лог-файл для записи";
}
// Устанавливаем свой обработчик
@ -334,7 +335,7 @@ int main(int argc, char *argv[]) {
// Миграции БД
DatabaseManager::instance().initSchema();
DatabaseManager::instance().cleanupOldData(5);
DatabaseManager::instance().cleanupOldData(0);
dbReady.store(true);
// 1. Если есть api_key — логинимся и проверяем/создаём desktop

View File

@ -32,8 +32,8 @@
#include "dushanbe/PayByPhoneScript.h"
static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonValue &data, int eventId = -1);
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot = {});
static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {});
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot = {}, int eventId = -1);
static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}, int eventId = -1);
EventHandler::EventHandler(QObject *parent) : QObject(parent) {}
@ -176,6 +176,8 @@ static void sendTaskResult(const QString &type, const QString &materialId, const
obj["material_id"] = materialId; // UUID string
obj["bank_name"] = bankName;
obj["data"] = data;
if (eventId > 0)
obj["internal_task_id"] = eventId;
auto *thread = new QThread;
auto *networkService = new NetworkService;
@ -200,24 +202,26 @@ static void sendTaskResult(const QString &type, const QString &materialId, const
}
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName,
const QString &description, const QByteArray &screenshot) {
const QString &description, const QByteArray &screenshot, int eventId) {
QJsonObject errorData;
errorData["status"] = QStringLiteral("error");
errorData["description"] = description;
sendTaskResult(type, materialId, bankName, errorData);
sendTaskResult(type, materialId, bankName, errorData, eventId);
if (!screenshot.isEmpty()) {
const QString message = QStringLiteral("[%1] bank=%2 material=%3: %4")
.arg(type, bankName, materialId, description);
sendMonitoringLog("error", "task_error", message, screenshot);
sendMonitoringLog("error", "task_error", message, screenshot, eventId);
}
}
static void sendMonitoringLog(const QString &type, const QString &event, const QString &message,
const QByteArray &image) {
const QByteArray &image, int eventId) {
auto *thread = new QThread;
auto *networkService = new NetworkService;
networkService->moveToThread(thread);
if (eventId > 0)
networkService->addExtra("internal_task_id", eventId);
QObject::connect(thread, &QThread::started, networkService, [networkService, type, event, message, image]() {
networkService->postMonitoringLog(type, event, message, image);
emit networkService->finished();
@ -273,7 +277,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
.arg(eventTypeToString(event.type), deviceLabel, event.bankName, result);
GeneralLogDAO::insert("CRITICAL", "EventHandler", logMsg, screenshot);
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, result, screenshot);
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, result, screenshot, event.id);
EventDAO::markSentToServer(event.id);
}
@ -375,21 +379,21 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
}
f.remove();
}
sendMonitoringLog("info", "task_success", msg, screenshot);
sendMonitoringLog("info", "task_success", msg, screenshot, event.id);
}
if (event.type == EventType::FetchTransactions) {
const QString msg = QStringLiteral("[FETCH_TRANSACTIONS] bank=%1 material=%2")
.arg(event.bankName, event.externalId);
const QByteArray scr = m_screenshots.take(key);
sendMonitoringLog("info", "task_success", msg, scr);
sendMonitoringLog("info", "task_success", msg, scr, event.id);
}
if (event.type == EventType::FetchProfile) {
const QString msg = QStringLiteral("[FETCH_PROFILE] bank=%1 material=%2")
.arg(event.bankName, event.externalId);
const QByteArray scr = m_screenshots.take(key);
sendMonitoringLog("info", "task_success", msg, scr);
sendMonitoringLog("info", "task_success", msg, scr, event.id);
}
}
}

View File

@ -1318,6 +1318,11 @@ void NetworkService::postMonitoringLog(const QString &type, const QString &event
addFormField(multiPart, "event", event);
addFormField(multiPart, "message", message);
const int internalTaskId = m_extraData.value("internal_task_id").toInt();
if (internalTaskId > 0) {
addFormField(multiPart, "internal_task_id", QString::number(internalTaskId));
}
if (!image.isEmpty()) {
QHttpPart imagePart;
imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/png");

View File

@ -13,7 +13,7 @@ ARC запускается как обычно (реальные Android-уст
| Python | `brew install python` или встроенный | [python.org](https://python.org), галочка «Add to PATH» |
| Flask | `pip install flask` | `pip install flask` |
| ADB | `brew install android-platform-tools` | Используется из `tools\adb\windows\` |
| ARC | `cmake --build build` | `cmake --build build --config Release` |
| ARC | `cmake --build build_test` | `cmake --build build --config Release` |
| Устройства | `adb devices``device` | `tools\adb\windows\adb.exe devices` |
---
@ -35,24 +35,26 @@ python tests\mock_server\gen_mock_data.py
Скрипт:
- Находит `config.ini` в текущей директории (или `build/Release/config.ini`)
- Открывает БД, читает активные `devices → bank_profiles → materials`
- Открывает БД, читает `bank_profiles` с проверенным PIN-кодом (`pin_code_checked = 'yes'`) и активные `materials`
- Генерирует:
- `tests/mock_server/default_profile.json` — профиль для мока
- `tests/mock_server/scenarios/*_fetch_profile.json` — по одному на материал
- `tests/mock_server/scenarios/*_fetch_transactions.json` — для ручных прогонов
- `tests/mock_server/scenarios/*_<card>_fetch_profile.json` — по одному на материал (с номером карты в имени)
- Очищает таблицу `events` в БД
Пример вывода:
```
config: config.ini
database: database/app_data.db
deleted 5 old events
wrote default_profile.json (3 bank_profiles)
wrote scenarios/pixel_7_ozon_fetch_profile.json
wrote scenarios/pixel_7_dushanbe_city_bank_fetch_profile.json
wrote scenarios/tecno_km4_ozon_fetch_profile.json
wrote scenarios/pixel_7_ozon_7919_fetch_profile.json
wrote scenarios/pixel_7_ozon_9865_fetch_profile.json
wrote scenarios/pixel_7_dushanbe_city_bank_3258_fetch_profile.json
wrote scenarios/tecno_km4_ozon_0982_fetch_profile.json
Devices (2):
pixel_7: ozon (30bc106c…), dushanbe_city_bank (61d967d1…)
tecno_km4: ozon (49001bb5…)
pixel_7: ozon/7919 (50393cc1…), ozon/9865 (06a1ae15…), dushanbe_city_bank/3258 (36002ac2…)
tecno_km4: ozon/0982 (7e3a7e4e…)
```
Флаги: `--config C:\path\to\config.ini` — если config не в текущей директории.
@ -70,19 +72,12 @@ python tests\mock_server\gen_fetch_transactions.py
Скрипт:
- Читает БД → находит активные материалы
- Находит подключённые ADB-устройства → матчит по `adb_serial`
- Показывает меню:
```
Available materials (3):
1. Pixel 7 / ozon / Галина Терехина (material 30bc106c…)
2. Pixel 7 / dushanbe_city_bank / Мехрубон (material 61d967d1…)
3. TECNO KM4 / ozon / Владислав Касаткин (material 49001bb5…)
Pick [1-3] or 'all':
```
- После выбора просит открыть список транзакций на устройстве → Enter
- Проходит по каждой транзакции: тап → дамп деталей → назад
(перед каждым тапом проверяет, что мы на списке)
- Сохраняет `scenarios/pool/*_ft_*.json` с реальными `amount` (в копейках)
и `created_at` (UTC, из таймзоны устройства)
- Показывает меню → выбор материала или `all`
- Проходит по транзакциям на экране:
- **Ozon**: парсит заголовки дат (Сегодня/Вчера/dd месяца), определяет дату каждой транзакции по Y-позиции, тапает для получения времени из деталей, скроллит вниз для следующих
- **Dushanbe**: парсит Выписку, тап в детали, время Dushanbe (UTC+5)
- Дедупликация по `(amount, created_at)` — одинаковые суммы в один день с разным временем сохраняются как отдельные задачи
- Сохраняет `scenarios/pool/*_<card>_ft_*.json` с реальными `amount` (в копейках) и `created_at` (UTC)
- Если мок запущен — сразу инжектит задачи
Флаги:
@ -113,57 +108,48 @@ apiBase=http://127.0.0.1:8888
### Терминал 1 — mock-сервер
```bash
# macOS
# macOS — полный цикл (генерация данных + запуск)
cd tests/mock_server && ./run.sh
# macOS — только определённые банки
cd tests/mock_server && ./run.sh --banks ozon
# macOS — только FETCH_TRANSACTIONS (без FETCH_PROFILE)
cd tests/mock_server && ./run.sh --types FETCH_TRANSACTIONS --banks ozon
# macOS — debug режим (auto-reload при изменении кода)
cd tests/mock_server && ./run.sh --banks ozon --debug
# Windows
cd tests\mock_server
python mock_server.py --port 8888
```
Логи в консоли:
```
[mock] state cleared — starting fresh
[mock] loaded default_profile.json: 3 bank_profiles, 3 materials
[mock] loaded pool: 19 FETCH_TRANSACTIONS across 3 materials
* Running on http://127.0.0.1:8888
```
Флаги:
- `--banks ozon` — генерировать задачи только для указанных банков
- `--banks ozon dushanbe_city_bank` — несколько банков через пробел
Флаги `mock_server.py`:
- `--banks ozon dushanbe_city_bank` — только указанные банки
- `--types FETCH_TRANSACTIONS` — только указанные типы задач
- `--ft-per-material 3` — сколько FT на материал при бутстрапе (default 3)
- `--seed 42` — фиксированный RNG для воспроизводимости
- `--debug` — Flask debug режим (auto-reload, состояние сохраняется между перезагрузками)
Без `--banks` задачи генерируются для всех активных банков.
Примеры:
```bash
# Только Ozon
./run.sh --banks ozon
# Только Dushanbe
./run.sh --banks dushanbe_city_bank
# Ozon + Dushanbe, по 2 FT на материал
./run.sh --banks ozon dushanbe_city_bank --ft-per-material 2
# Все банки (по умолчанию)
./run.sh
```
При `--debug`:
- Изменения в `.py` файлах применяются автоматически без перезапуска
- Состояние задач сохраняется в `.mock_state.json` и восстанавливается при reload
- Скриншоты сохраняются в `.mock_screenshots/` (переживают reload)
- При **первом** запуске — чистый старт (events в БД очищаются, state сбрасывается)
### Терминал 2 — ARC
```bash
# macOS
./build/ARC
# macOS (из корня проекта!)
./build_test/ARC
# Windows
build\Release\ARC.exe
```
При логине мок автоматически ставит задачи:
- 1 × FETCH_PROFILE на каждый материал
- 1 × FETCH_PROFILE на каждый материал (если `--types` не ограничивает)
- N × FETCH_TRANSACTIONS (случайные из pool)
Задачи раздаются **по 1 на material за поллинг** — следующая только после
@ -172,15 +158,9 @@ build\Release\ARC.exe
### Терминал 3 — отчёт
```bash
# Браузер с авто-обновлением (задачи группируются по устройствам)
# Браузер с авто-обновлением
open http://127.0.0.1:8888/_admin/report/html # macOS
start http://127.0.0.1:8888/_admin/report/html # Windows
# Счётчики в реальном времени
watch -n 2 'curl -s http://127.0.0.1:8888/_admin/state' # macOS
# Windows (PowerShell)
while ($true) { cls; (iwr http://127.0.0.1:8888/_admin/state).Content; sleep 2 }
```
---
@ -188,86 +168,89 @@ while ($true) { cls; (iwr http://127.0.0.1:8888/_admin/state).Content; sleep 2 }
## 5. HTML-отчёт
`http://127.0.0.1:8888/_admin/report/html` — веб-страница с:
- Сводкой: всего / выполнено / в очереди
- Группировкой по устройствам (Pixel 7, TECNO KM4, …)
- Для каждой задачи: Task JSON слева ↔ Result JSON справа
- Цветовой индикацией: зелёный = completed, жёлтый = pending, красный = error
- Авто-обновлением каждые 2 секунды (без сброса скролла)
Также доступен JSON:
```bash
curl -s http://127.0.0.1:8888/_admin/report | jq # полный
curl -s http://127.0.0.1:8888/_admin/report/pending | jq # только pending
```
- Сводкой: всего / выполнено / ошибки / в очереди
- Группировкой по **устройствам****банкам** (Pixel 7 → ozon, dushanbe_city_bank)
- Для каждой задачи:
- Task JSON и Result JSON (для успешных — свёрнуты, кнопка "Показать JSON")
- Ошибки — развёрнуты сразу с красным бейджем
- Скриншот (если есть) — третья колонка, клик открывает полноэкранный просмотр
- Кнопка "Копировать" на каждом JSON блоке
- Цветовой индикацией: зелёный = completed, красный = error, жёлтый = pending
- Авто-обновлением каждые 2 секунды (состояние свёрнутых/развёрнутых блоков сохраняется)
---
## 6. Ручной инжект задач
## 6. Скриншоты
Приложение отправляет скриншоты через `POST /monitoring/log` с полем `internal_task_id`.
Мок-сервер:
- Сохраняет PNG на диск в `.mock_screenshots/`
- Привязывает к задаче по `internal_task_id` (точное совпадение с `task_result`)
- Отображает в отчёте как третью колонку рядом с Task/Result JSON
- Клик по скриншоту — полноэкранный просмотр (Escape для закрытия)
---
## 7. Ручной инжект задач
```bash
# Одна задача
./inject.sh scenarios/pixel_7_ozon_fetch_profile.json # macOS
curl -X POST http://127.0.0.1:8888/_admin/inject ^ # Windows
-H "Content-Type: application/json" ^
--data-binary @scenarios\pixel_7_ozon_fetch_profile.json
# N случайных из пула
python inject_random.py --count 5
curl -X POST http://127.0.0.1:8888/_admin/inject \
-H "Content-Type: application/json" \
--data-binary @scenarios/pixel_7_ozon_7919_fetch_profile.json
# Повторный бутстрап (без перезапуска мока)
curl -X POST http://127.0.0.1:8888/_admin/bootstrap
# Сброс (очистка очереди + результатов + in-flight)
# Сброс (очистка очереди + результатов + скриншотов)
curl -X POST http://127.0.0.1:8888/_admin/reset
```
---
## 7. Админ-эндпойнты мока
## 8. Админ-эндпойнты мока
| Метод | Путь | Назначение |
|---|---|---|
| POST | `/_admin/inject` | Положить задачу (или массив) в очередь |
| POST | `/_admin/bootstrap` | Повторный бутстрап (1 FP + N FT на material) |
| POST | `/_admin/bootstrap` | Повторный бутстрап |
| POST | `/_admin/seed_profile` | Подменить ответ `current_profile` |
| POST | `/_admin/seed_devices` | Подменить список устройств |
| GET | `/_admin/report` | JSON: полный отчёт задачи ↔ результаты |
| GET | `/_admin/report` | JSON: полный отчёт |
| GET | `/_admin/report/html` | HTML-отчёт с авто-обновлением |
| GET | `/_admin/report/pending` | Только незавершённые задачи |
| GET | `/_admin/report/fragment` | JSON-фрагмент для JS-обновления |
| GET | `/_admin/screenshot/<id>` | PNG скриншот по ID |
| GET | `/_admin/state` | Счётчики |
| POST | `/_admin/reset` | Очистить всё, сбросить бутстрап |
---
## 8. Полный цикл с нуля (новая машина / новые устройства)
## 9. Полный цикл с нуля
```bash
# 1. Установка
pip install flask
# 2. Собрать ARC, запустить с боевым сервером → залогиниться → устройства,
# банковские профили и материалы появятся в БД
# 2. Собрать ARC, запустить с боевым сервером → залогиниться
# Устройства, профили и материалы появятся в БД
# 3. Переключить config.ini на мок
# [net]
# apiBase=http://127.0.0.1:8888
# 4. Сгенерировать стартовые данные из БД
python tests/mock_server/gen_mock_data.py
python3 tests/mock_server/gen_mock_data.py
# 5. Открыть на устройстве банковское приложение → список транзакций
# Сгенерировать пул задач
python tests/mock_server/gen_fetch_transactions.py
python3 tests/mock_server/gen_fetch_transactions.py
# 6. Запустить мок (все банки)
cd tests/mock_server && python mock_server.py --port 8888
# или только определённые:
# cd tests/mock_server && python mock_server.py --port 8888 --banks ozon
# 6. Запустить мок
cd tests/mock_server && ./run.sh --banks ozon --debug
# 7. Запустить ARC (в отдельном терминале)
./build/ARC # macOS
build\Release\ARC.exe # Windows
# 7. Запустить ARC (из корня проекта, в отдельном терминале)
./build_test/ARC
# 8. Открыть отчёт
open http://127.0.0.1:8888/_admin/report/html
@ -275,63 +258,65 @@ open http://127.0.0.1:8888/_admin/report/html
# 9. Дождаться completed = всего в отчёте
# 10. Вернуть config.ini на прод
# [net]
# apiBase=http://93.183.75.134:8005
```
---
## 9. Структура файлов
## 10. Структура файлов
```
tests/mock_server/
├── mock_server.py # Flask mock-сервер
├── run.sh # запуск (macOS/Linux)
├── run.sh # запуск (macOS/Linux): gen_mock_data + mock_server
├── gen_mock_data.py # генерация default_profile + scenarios из БД
├── gen_fetch_transactions.py # генерация pool из живого устройства
├── inject.sh # curl-обёртка для инжекта
├── inject_random.py # рандомный инжект из pool
├── default_profile.json # авто-генерированный профиль для мока
├── seed_profile.example.json # пример ручного засева
├── README.md # эта инструкция
├── WINDOWS.md # Windows-специфичные нюансы
├── scenarios/ # одиночные задачи (FETCH_PROFILE / FT с пустым body)
│ ├── pixel_7_ozon_fetch_profile.json
│ ├── pixel_7_ozon_fetch_transactions.json
│ ├── tecno_km4_ozon_fetch_profile.json
├── .mock_state.json # состояние задач (debug режим, .gitignore)
├── .mock_screenshots/ # скриншоты с устройств (.gitignore)
├── scenarios/ # FETCH_PROFILE сценарии
│ ├── pixel_7_ozon_7919_fetch_profile.json
│ ├── pixel_7_ozon_9865_fetch_profile.json
│ └── ...
├── scenarios/pool/ # пул FETCH_TRANSACTIONS с реальными amount + created_at
│ ├── pixel7_ozon_ft_01_evgeny_plus_100.json
│ ├── tecno_km4_ozon_ft_01_galina_plus_100.json
│ └── ...
└── fixtures/ # XML/PNG дампы экранов (справочно)
├── tecno_ozon/
├── pixel7_ozon/
└── pixel7_dushanbe/
└── scenarios/pool/ # пул FETCH_TRANSACTIONS
├── pixel_7_ozon_7919_01_minus_108_ft_01.json
├── pixel_7_ozon_9865_01_minus_108_ft_01.json
├── pixel_7_dushanbe_city_bank_3258_01_plus_5_ft_01.json
└── ...
```
---
## 10. Типовые проблемы
## 11. Типовые проблемы
| Симптом | Причина | Решение |
|---|---|---|
| `config.ini not found` | Скрипт запущен не из корня проекта | `cd` в корень или `--config path` |
| `No active materials` | БД пустая или профили не active | Запустить ARC с боевым сервером, залогиниться |
| `No ADB devices match` | Устройства не подключены или serial не совпадает | `adb devices`, проверить USB |
| Задача `pending` навсегда | material в in-flight (результат не пришёл) | `/_admin/reset` и перезапустить ARC |
| `Task expired: waited Ns` | Предыдущая задача выполнялась >5 мин | Мок отдаёт по 1 задаче — это уже фикс; убедиться, что `maxScrolls` разумный |
| `Transaction not found` | Транзакции на устройстве не совпадают с pool | Перегенерировать: `gen_fetch_transactions.py` |
| `Cancelled: replaced` | Норма для FETCH_PROFILE — дедупликация по device+bank | Не баг |
| Мок не получает результат | `sendTaskError` шёл в monitoring вместо task_result | Обновить ARC (фикс в EventHandler.cpp) |
| Кракозябры в консоли (Win) | Кодировка | `chcp 65001` перед запуском |
| `No active materials` | Нет профилей с проверенным PIN | Запустить ARC с боевым сервером, добавить PIN |
| `No ADB devices match` | Устройства не подключены | `adb devices`, проверить USB |
| `App restarted` ошибки в отчёте | Events остались от прошлого запуска | Мок-сервер чистит events при старте; используйте `run.sh` или запускайте сервер **до** ARC |
| Задача `pending` навсегда | material в `_in_flight` | `/_admin/reset` и перезапустить ARC |
| `Transaction not found` | Баг поиска — скролл, видимость кнопок | Анализировать логи (device= в ошибке) |
| `Page load error` | Ozon WebView не загрузился | Проблема интернета/устройства, таймаут 5 мин |
| Скриншот не привязан | Нет `internal_task_id` | Обновить ARC (фикс в EventHandler.cpp) |
| Pool material_id не совпадает | БД обновилась, pool устарел | Перегенерировать pool или обновить material_id в файлах |
| Порт занят | Другой процесс на 8888 | `--port 9999` + поменять `apiBase` |
---
## 11. Что мок НЕ эмулирует
## 12. Что мок эмулирует и что нет
- Валидацию тел запросов (всё `{success: true}`)
- Хранение скриншотов
**Эмулирует:**
- Авторизацию (login/refresh)
- Синхронизацию профилей (current_profile, bank_profile CRUD)
- Выдачу задач (get_tasks) с in-flight контролем
- Приём результатов (task_result) с `internal_task_id`
- Приём скриншотов (monitoring/log) с привязкой к задачам
- Управление устройствами и материалами (toggle)
**Не эмулирует:**
- Валидацию тел запросов
- Webhook-уведомления банков
- Сетевые задержки/таймауты

View File

@ -28,14 +28,11 @@ powershell -ExecutionPolicy Bypass -File setup.ps1
4. Находит `config.ini` и проверяет **базу данных**
5. Удаляет старые сгенерированные данные (профиль + scenarios, пул не трогает)
6. Генерирует `default_profile.json` и FETCH_PROFILE scenarios из БД
7. Если порт занят — убивает старый процесс
8. Переключает `config.ini` на mock (`apiBase=http://127.0.0.1:8888`)
9. Показывает сводку: устройства, профили, материалы, пул транзакций
10. Запускает mock-сервер и открывает HTML-отчёт в браузере
При первом логине ARC мок автоматически ставит **FETCH_PROFILE** задачи
на каждый материал. **FETCH_TRANSACTIONS** задачи нужно генерировать отдельно
(см. [раздел 3](#3-генерация-fetch_transactions-задач)).
7. Очищает таблицу `events` в БД
8. Если порт занят — убивает старый процесс
9. Переключает `config.ini` на mock (`apiBase=http://127.0.0.1:8888`)
10. Показывает сводку: устройства, профили, материалы, пул транзакций
11. Запускает mock-сервер и открывает HTML-отчёт в браузере
### Параметры
@ -44,33 +41,23 @@ powershell -ExecutionPolicy Bypass -File setup.ps1
| `-Port` | 8888 | Порт mock-сервера |
| `-FtPerMaterial` | 3 | FETCH_TRANSACTIONS на материал при бутстрапе (из пула) |
| `-Banks` | все | Банки через запятую: `ozon`, `dushanbe_city_bank`, `black` |
| `-Types` | все | Типы задач: `FETCH_PROFILE`, `FETCH_TRANSACTIONS` |
| `-SkipInstall` | — | Пропустить установку Python/Flask |
| `-GenerateOnly` | — | Только сгенерировать данные, не запускать сервер |
| `-Clean` | — | Также очистить пул транзакций (`scenarios/pool/`) |
| `-Debug` | — | Flask debug режим (auto-reload при изменении кода) |
### Примеры
```powershell
# Всё автоматически (установка + генерация + запуск)
# Всё автоматически
.\setup.ps1
# На другом порту с 5 транзакциями на материал
.\setup.ps1 -Port 9999 -FtPerMaterial 5
# Только Ozon, только FETCH_TRANSACTIONS
.\setup.ps1 -Banks ozon -Types FETCH_TRANSACTIONS
# Только Ozon
.\setup.ps1 -Banks ozon
# Только Dushanbe, по 2 транзакции
.\setup.ps1 -Banks dushanbe_city_bank -FtPerMaterial 2
# Ozon + Dushanbe
.\setup.ps1 -Banks ozon,dushanbe_city_bank
# Пропустить установку (Python/Flask уже есть)
.\setup.ps1 -SkipInstall
# Только подготовить данные, сервер не запускать
.\setup.ps1 -GenerateOnly
# Debug режим с 5 транзакциями на материал
.\setup.ps1 -Banks ozon -FtPerMaterial 5 -Debug
# Полная очистка (включая пул транзакций)
.\setup.ps1 -Clean
@ -81,7 +68,7 @@ powershell -ExecutionPolicy Bypass -File setup.ps1
## 2. Вспомогательные скрипты
После первого запуска `setup.ps1` в папке `tests\mock_server\` появляются
скрипты с зашитым путём до Python — искать `python.exe` вручную не нужно:
скрипты с зашитым путём до Python:
| Скрипт | Назначение | Запуск |
|---|---|---|
@ -89,14 +76,14 @@ powershell -ExecutionPolicy Bypass -File setup.ps1
| `run_server.ps1` | Запуск mock-сервера | `powershell -ExecutionPolicy Bypass -File run_server.ps1` |
| `setup.ps1` | Полная установка + генерация + запуск | `powershell -ExecutionPolicy Bypass -File setup.ps1` |
Скрипты принимают дополнительные аргументы, которые передаются в Python-скрипт:
Скрипты принимают дополнительные аргументы:
```powershell
# Запуск сервера только для Ozon, только FETCH_TRANSACTIONS
powershell -ExecutionPolicy Bypass -File run_server.ps1 --banks ozon --types FETCH_TRANSACTIONS
# Генерация с явным серийником
powershell -ExecutionPolicy Bypass -File gen_transactions.ps1 --serial 28291FDH300712
# Запуск сервера на другом порту
powershell -ExecutionPolicy Bypass -File run_server.ps1 --port 9999
```
---
@ -104,8 +91,6 @@ powershell -ExecutionPolicy Bypass -File run_server.ps1 --port 9999
## 3. Генерация FETCH_TRANSACTIONS задач
FETCH_TRANSACTIONS задачи генерируются **только вручную** с живого устройства.
Скрипт подключается по ADB, проходит по списку транзакций на экране
(тап -> снятие суммы/даты -> назад) и сохраняет JSON-файлы в `scenarios/pool/`.
### Подготовка
@ -113,158 +98,74 @@ FETCH_TRANSACTIONS задачи генерируются **только вруч
2. Убедитесь что ADB видит устройство:
```powershell
tools\adb\windows\adb.exe devices
# Должно показать серийник со статусом "device"
```
3. Откройте на устройстве банковское приложение на экране **списка транзакций**
- Ozon: раздел "Операции" -> список переводов/пополнений
- Dushanbe City Bank: раздел "Операции" -> список P2P операций
### Запуск
```powershell
# Через вспомогательный скрипт (рекомендуется)
cd tests\mock_server
powershell -ExecutionPolicy Bypass -File gen_transactions.ps1
# Или напрямую (если Python в PATH)
python tests\mock_server\gen_fetch_transactions.py
```
Скрипт:
1. Покажет меню с активными материалами:
1. Покажет меню с активными материалами
2. Выберите нужный или `all`
3. Подтвердите что список транзакций открыт → **Enter**
4. Автоматически пройдёт по транзакциям (**не трогайте телефон!**)
5. Для **Ozon**: парсит заголовки дат, определяет дату каждой транзакции по позиции на экране, из деталей берёт только время
6. Для **Dushanbe**: время конвертируется из Asia/Dushanbe (UTC+5)
7. Сохраняет в `scenarios/pool/` с номером карты в имени файла:
```
Available materials (3):
1. Pixel 7 / ozon / Галина Терехина (material 30bc106c... serial=28291FDH30xxxx)
2. Pixel 7 / dushanbe_city_bank / Мехрубон (material 61d967d1...)
3. TECNO KM4 / ozon / Владислав Касаткин (material 49001bb5...)
Pick [1-3] or 'all':
```
2. Выберите нужный материал (номер) или `all` для всех
3. Скрипт попросит подтвердить что список транзакций открыт -> нажмите **Enter**
4. Автоматически пройдёт по каждой транзакции на экране (**не трогайте телефон!**)
5. Сохранит задачи в `scenarios/pool/`
6. Если mock-сервер запущен — сразу инжектнёт задачи в него
### Флаги
| Флаг | Описание |
|---|---|
| `--serial 28291FDH30xxxx` | Явный ADB-серийник (если несколько устройств) |
| `--kopecks 100` | Множитель суммы (по умолчанию 100: рубли -> копейки) |
| `--config path` | Путь к config.ini |
### Примеры
```powershell
# Интерактивный выбор материала
powershell -ExecutionPolicy Bypass -File gen_transactions.ps1
# Для конкретного устройства
powershell -ExecutionPolicy Bypass -File gen_transactions.ps1 --serial 28291FDH300712
# С другим config
powershell -ExecutionPolicy Bypass -File gen_transactions.ps1 --config cmake-build-debug\config.ini
pixel_7_ozon_7919_01_minus_108_ft_01.json
pixel_7_ozon_9865_01_minus_108_ft_01.json
pixel_7_dushanbe_city_bank_3258_01_plus_5_ft_01.json
```
### Результат
### Важно
После генерации в `scenarios/pool/` появятся файлы вида:
```
pixel_7_ozon_ft_01_plus_15000_143022.json
pixel_7_ozon_ft_02_minus_500_150133.json
tecno_km4_ozon_ft_01_plus_3000_091500.json
```
Каждый файл содержит задачу с реальной суммой (в копейках) и временем (UTC):
```json
{
"bank_name": "ozon",
"material_id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13",
"type": "FETCH_TRANSACTIONS",
"body": {
"amount": 1500000,
"created_at": "2026-04-10T11:30:22Z"
}
}
```
При следующем запуске `setup.ps1` пул сохраняется (если не указать `-Clean`),
и мок будет раздавать задачи из него при бутстрапе.
- Если у банка **два материала** (две карты), генерируются **отдельные файлы** для каждого
- При смене устройств/аккаунтов нужно перегенерировать pool — `material_id` в файлах должны совпадать с `default_profile.json`
---
## 4. Ручной запуск (пошагово)
Если не хотите использовать `setup.ps1`:
### 4.1. Установка зависимостей
```powershell
# Python 3.9+ (если нет — скачать с python.org, включить «Add to PATH»)
python --version
# Flask
python --version # Python 3.9+
pip install flask
```
### 4.2. Подготовка config.ini
В `config.ini` (рядом с `ARC.exe`) переключить `apiBase`:
```ini
[net]
apiBase=http://127.0.0.1:8888
```
> Совет: держите копию `config.ini.mock`, а перед тестом
> `copy config.ini.mock config.ini`.
### 4.3. Генерация профиля из БД
### 4.3. Генерация данных + запуск
```powershell
# Из корня проекта
# Генерация профиля
python tests\mock_server\gen_mock_data.py
```
Создаёт `default_profile.json` и FETCH_PROFILE scenarios.
### 4.4. Генерация пула транзакций (опционально)
См. [раздел 3](#3-генерация-fetch_transactions-задач).
### 4.5. Запуск mock-сервера
```powershell
# Запуск сервера
cd tests\mock_server
python mock_server.py --port 8888
python mock_server.py --port 8888 --banks ozon --types FETCH_TRANSACTIONS
# Debug режим (auto-reload)
python mock_server.py --port 8888 --banks ozon --debug
```
Должны увидеть:
```
[mock] state cleared — starting fresh
[mock] loaded default_profile.json: 3 bank_profiles, 3 materials
[mock] loaded pool: 19 FETCH_TRANSACTIONS across 3 materials
* Running on http://127.0.0.1:8888
```
### 4.6. Запуск ARC
В отдельном терминале:
### 4.4. Запуск ARC
```powershell
cd build\Release
cd C:\ARC\Ver.18 # или build\Release
ARC.exe
```
Или через CLion: Run -> ARC.
При логине мок автоматически ставит задачи:
- 1 x FETCH_PROFILE на каждый материал (всегда)
- N x FETCH_TRANSACTIONS из пула (если пул не пуст)
Задачи раздаются по 1 на material за поллинг — следующая только
после результата предыдущей.
---
## 5. Мониторинг и отчёт
@ -272,46 +173,37 @@ ARC.exe
```powershell
# Открыть отчёт в браузере (авто-обновление каждые 2 сек)
start http://127.0.0.1:8888/_admin/report/html
# Счётчики
curl -s http://127.0.0.1:8888/_admin/state
# Полный JSON-отчёт
curl -s http://127.0.0.1:8888/_admin/report
```
Если `curl` нет, используйте PowerShell:
Отчёт показывает:
- **Сводку**: всего / выполнено / ошибки / в очереди
- **Группировку**: устройство → банк → задачи
- **Для каждой задачи**:
- Статус (completed / error / pending)
- Task JSON + Result JSON (успешные свёрнуты, ошибки развёрнуты)
- Скриншот (если есть) — клик для полноэкранного просмотра
- Кнопка "Копировать" на каждом JSON блоке
- Авто-обновление без сброса состояния развёрнутых блоков
```powershell
# Счётчики
(Invoke-WebRequest http://127.0.0.1:8888/_admin/state).Content | ConvertFrom-Json
# Полный отчёт в файл
Invoke-WebRequest http://127.0.0.1:8888/_admin/report -OutFile report.json
```
На главной странице отчёта (`/_admin/report/html`) отображаются:
- Счётчики задач (всего / выполнено / в очереди)
- **Загруженные данные** — устройства, профили, материалы с количеством задач в пуле (группировка по устройствам)
- Задачи с результатами, сгруппированные по устройствам
---
## 6. Ручной инжект задач
```powershell
# Одна задача из файла
# Одна задача
curl -X POST http://127.0.0.1:8888/_admin/inject ^
-H "Content-Type: application/json" ^
--data-binary @scenarios\pixel_7_ozon_fetch_profile.json
--data-binary @scenarios\pixel_7_ozon_7919_fetch_profile.json
# N случайных из пула
python inject_random.py --count 5
# Повторный бутстрап (1 FP + N FT на material)
# Повторный бутстрап
curl -X POST http://127.0.0.1:8888/_admin/bootstrap
# Сброс (очистка очереди + результатов)
# Сброс (очистка всего + скриншоты)
curl -X POST http://127.0.0.1:8888/_admin/reset
```
@ -319,19 +211,16 @@ curl -X POST http://127.0.0.1:8888/_admin/reset
## 7. ADB на Windows
ARC использует ADB из `tools\adb\windows\`. Проверка:
ARC использует ADB из `tools\adb\windows\`:
```powershell
tools\adb\windows\adb.exe devices
```
Должно показать устройства в статусе `device`.
Если ADB занят другим процессом (Android Studio, scrcpy):
Если ADB занят:
```powershell
tools\adb\windows\adb.exe kill-server
tools\adb\windows\adb.exe start-server
tools\adb\windows\adb.exe devices
```
---
@ -340,26 +229,25 @@ tools\adb\windows\adb.exe devices
| Проблема | Решение |
|---|---|
| `python` не найден | Запустите `setup.ps1` — установит автоматически. Или используйте скрипты `gen_transactions.ps1` / `run_server.ps1` — в них путь уже зашит |
| Порт 8888 занят | `setup.ps1` убивает старый процесс автоматически. Вручную: `netstat -ano \| findstr :8888` -> `taskkill /PID <pid> /F` |
| `curl` нет | `Invoke-WebRequest` (PowerShell) или `winget install curl` |
| Firewall блокирует | Разрешить `python.exe` в Windows Firewall. Мок слушает только `127.0.0.1` |
| ARC не видит устройства | Проверить USB-драйверы, включить USB debugging, подтвердить RSA-ключ |
| Кракозябры в консоли | `chcp 65001` перед запуском (UTF-8). Скрипты `.ps1` делают это автоматически |
| BOM в config.ini | `setup.ps1` пишет без BOM. Если правили вручную — пересохраните в UTF-8 без BOM (VS Code) |
| No active materials | БД пустая. Запустите ARC с боевым сервером, залогиньтесь, добавьте устройства |
| Пул транзакций пуст | Сгенерируйте: [раздел 3](#3-генерация-fetch_transactions-задач) |
| `No ADB devices match` | Устройства не подключены или serial не совпадает с БД. Проверьте `adb devices` |
| SSL ошибки при сборке | OpenSSL DLL копируются автоматически через CMake post-build |
| `python` не найден | Запустите `setup.ps1` — установит автоматически |
| Порт 8888 занят | `setup.ps1` убивает автоматически. Вручную: `netstat -ano \| findstr :8888``taskkill /PID <pid> /F` |
| `App restarted` ошибки | Мок-сервер чистит events в БД при старте. Запускайте сервер **до** ARC |
| Pool material_id не совпадает | `gen_mock_data.py` обновил material_id. Перегенерируйте pool |
| `Transaction not found` | Баг поиска — анализировать логи (device= в ошибке) |
| `Page load error` | Ozon WebView не загрузился — проблема интернета/устройства |
| Скриншот не привязался | Нет `internal_task_id` — обновить ARC |
| Кракозябры в консоли | `chcp 65001` (UTF-8). Скрипты `.ps1` делают автоматически |
| No active materials | Нет профилей с проверенным PIN. Запустить ARC с боевым сервером |
| Firewall | Разрешить `python.exe`. Мок слушает только `127.0.0.1` |
---
## 9. Быстрый чеклист
```
[ ] powershell -ExecutionPolicy Bypass -File setup.ps1
[ ] Устройства в adb devices -> device
[ ] (опционально) powershell -ExecutionPolicy Bypass -File gen_transactions.ps1
[ ] powershell -ExecutionPolicy Bypass -File setup.ps1 -Banks ozon
[ ] Устройства в adb devices device
[ ] (опционально) gen_transactions.ps1 для пула транзакций
[ ] ARC.exe запущен (отдельный терминал)
[ ] Браузер: http://127.0.0.1:8888/_admin/report/html
[ ] Дождаться completed = всего в отчёте

View File

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,27 @@
{
"bank_profiles": [
{
"id": "85f577bc-20be-40fd-ab47-315af3aadfb9",
"id": "b8763ad9-50bd-4d8c-b6f1-cc122119e2df",
"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": "36002ac2-6f4d-49da-8892-18db723a31c7",
"state": "enabled",
"card_number": "9762000207113258",
"currency": 972
}
]
},
{
"id": "ec6b5712-067c-4354-8641-a3f19a9fdef1",
"bank_name": "ozon",
"state": "enabled",
"phone_number": "+79538038318",
@ -9,22 +29,42 @@
"middle_name": null,
"last_name": "Терехина",
"currency": 643,
"device_id": "c9d3ad7e-659d-4adf-815e-9f234e743078",
"device_id": "986cb259-dfec-4421-8735-4fb126cd1917",
"device_label": "Pixel 7",
"materials": [
{
"id": "e6c6e12a-3e4e-444c-9e11-62543fb5f4f9",
"id": "50393cc1-57e4-4293-9a59-6cdd1d9ce6cb",
"state": "enabled",
"card_number": "2204321035207919",
"currency": 643
},
{
"id": "39592a22-3e79-4438-89d7-778aea72a5e9",
"id": "06a1ae15-abd2-4d90-aac0-77f4a4fd32e8",
"state": "enabled",
"card_number": "2204321086109865",
"currency": 643
}
]
},
{
"id": "a3fa08d3-58d7-4744-886c-c81afa707dcd",
"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": "7e3a7e4e-0d56-4d66-ac44-2b240ec33620",
"state": "enabled",
"card_number": "2204320439130982",
"currency": 643
}
]
}
]
}

View File

@ -479,7 +479,9 @@ def collect_tasks(serial, bank, mid, device_tz, kopecks, prefix):
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)
# Душанбе показывает время в Asia/Dushanbe (UTC+5), не в таймзоне устройства
dushanbe_tz = timezone(timedelta(hours=5))
dt = datetime.strptime(f"{date_s} {time_s}", fmt).replace(tzinfo=dushanbe_tz)
cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
dedup_key = (amount, cat)
if dedup_key in processed_keys:

View File

@ -89,10 +89,11 @@ def query_active_materials(db_path: Path):
FROM devices d
JOIN bank_profiles bp ON bp.device_id = d.id
JOIN materials m ON m.device_id = d.id AND m.app_code = bp.code
WHERE bp.status = 'active'
WHERE bp.pin_code IS NOT NULL AND bp.pin_code != ''
AND bp.pin_code_checked = 'yes'
AND m.status = 'active'
AND m.material_id != ''
AND bp.bank_profile_id != ''
AND m.material_id IS NOT NULL AND m.material_id != ''
AND bp.bank_profile_id IS NOT NULL AND bp.bank_profile_id != ''
ORDER BY d.name, bp.code
""").fetchall()
conn.close()

View File

@ -40,7 +40,10 @@ Usage:
import argparse
import copy
import json
import os
import random
import re
import shutil
import threading
import time
import uuid
@ -75,6 +78,51 @@ _in_flight: set = set() # material_ids with a delivered-but-not-yet-resulted ta
_bootstrap_done = False
_bootstrap_ft_count = 3
_bootstrap_banks: list | None = None # None = all banks
_bootstrap_types: list | None = None # None = all types (FETCH_PROFILE + FETCH_TRANSACTIONS)
_STATE_FILE = HERE / ".mock_state.json"
def _save_state():
"""Persist task state to disk so debug reloader doesn't lose it."""
try:
state = {
"injected": _injected,
"task_queue": list(_task_queue),
"in_flight": list(_in_flight),
"bootstrap_done": _bootstrap_done,
"pending_by_key": {f"{k[0]}|{k[1]}": v for k, v in _pending_by_key.items()},
}
_STATE_FILE.write_text(json.dumps(state, default=str), encoding="utf-8")
except Exception as e:
print(f"[mock] failed to save state: {e}")
def _load_state() -> bool:
"""Restore state from disk. Returns True if loaded."""
global _bootstrap_done
if not _STATE_FILE.exists():
return False
try:
state = json.loads(_STATE_FILE.read_text(encoding="utf-8"))
_injected.clear()
_injected.extend(state.get("injected", []))
_task_queue.clear()
_task_queue.extend(state.get("task_queue", []))
_in_flight.clear()
_in_flight.update(state.get("in_flight", []))
_bootstrap_done = state.get("bootstrap_done", False)
_pending_by_key.clear()
for k, v in state.get("pending_by_key", {}).items():
parts = k.split("|", 1)
if len(parts) == 2:
_pending_by_key[(parts[0], parts[1])] = v
print(f"[mock] restored state: {len(_injected)} injected, "
f"{len(_task_queue)} queued, bootstrap={'done' if _bootstrap_done else 'pending'}")
return True
except Exception as e:
print(f"[mock] failed to load state: {e}")
return False
def ok(data=None):
@ -173,6 +221,7 @@ def bootstrap_tasks():
if not mid:
continue
# 1 × FETCH_PROFILE
if not _bootstrap_types or "FETCH_PROFILE" in _bootstrap_types:
enqueue_task({
"bank_name": bank,
"material_id": mid,
@ -183,6 +232,7 @@ def bootstrap_tasks():
queued += 1
# N × random FETCH_TRANSACTIONS from pool
if not _bootstrap_types or "FETCH_TRANSACTIONS" in _bootstrap_types:
pool = _pool_by_material.get(mid, [])
if not pool:
print(f"[mock] bootstrap: no pool tasks for {mid} ({bank})")
@ -192,6 +242,7 @@ def bootstrap_tasks():
enqueue_task(t)
queued += 1
_bootstrap_done = True
_save_state()
print(f"[mock] bootstrap queued {queued} tasks")
@ -359,6 +410,7 @@ def get_tasks():
_in_flight.add(mid)
_task_queue.extend(kept)
if delivered:
_save_state()
log("GET /task/get_tasks -> delivered",
[{"type": t.get("type"), "material_id": t.get("material_id")}
for t in delivered])
@ -393,15 +445,72 @@ def task_result():
"result_at": time.time(),
"status": "unsolicited",
})
_save_state()
return ok({})
# ---------------------------------------------------------------- monitoring
_monitoring_logs: list = [] # {type, event, message, has_image, image_id}
_SCREENSHOTS_DIR = HERE / ".mock_screenshots"
@app.post("/monitoring/log")
def monitoring_log():
mtype = request.form.get("type", "")
mevent = request.form.get("event", "")
message = request.form.get("message", "")
image_file = request.files.get("image")
image_id = None
if image_file:
image_id = str(uuid.uuid4())
_SCREENSHOTS_DIR.mkdir(exist_ok=True)
(_SCREENSHOTS_DIR / f"{image_id}.png").write_bytes(image_file.read())
entry = {
"type": mtype,
"event": mevent,
"message": message,
"has_image": image_id is not None,
"image_id": image_id,
"timestamp": time.time(),
}
with _lock:
_monitoring_logs.append(entry)
# Привязываем скриншот к задаче по internal_task_id
internal_tid = request.form.get("internal_task_id", "")
if image_id:
if not internal_tid:
print(f"[mock] WARNING: monitoring_log without internal_task_id, screenshot not linked")
else:
matched = False
with _lock:
for e in reversed(_injected):
r = e.get("result")
if r and str(r.get("internal_task_id", "")) == internal_tid:
e["screenshot_id"] = image_id
matched = True
break
if matched:
_save_state()
else:
print(f"[mock] WARNING: no task found for internal_task_id={internal_tid}")
log("POST /monitoring/log", {"type": mtype, "event": mevent,
"has_image": image_id is not None,
"message": message[:100]})
return ok({})
@app.get("/_admin/screenshot/<image_id>")
def get_screenshot(image_id):
path = _SCREENSHOTS_DIR / f"{image_id}.png"
if not path.exists():
return "not found", 404
return Response(path.read_bytes(), mimetype="image/png")
# ---------------------------------------------------------------- admin
@app.post("/_admin/inject")
def admin_inject():
@ -445,10 +554,12 @@ def admin_bootstrap():
def admin_report():
"""Full task ↔ result pairing report."""
with _lock:
errors = sum(1 for e in _injected if e["status"] == "completed" and _is_error_entry(e))
summary = {
"total": len(_injected),
"pending": sum(1 for e in _injected if e["status"] == "pending"),
"completed": sum(1 for e in _injected if e["status"] == "completed"),
"completed": sum(1 for e in _injected if e["status"] == "completed") - errors,
"errors": errors,
"unsolicited": sum(1 for e in _injected if e["status"] == "unsolicited"),
}
return jsonify({
@ -463,12 +574,25 @@ def admin_report_pending():
return jsonify([e for e in _injected if e["status"] == "pending"])
def _is_error_entry(e) -> bool:
"""Check if a completed entry has an error in result data."""
result = e.get("result")
if not result:
return False
data = result.get("data")
if isinstance(data, dict) and data.get("status") == "error":
return True
return False
def _snapshot_entries_and_summary():
entries = list(_injected)
errors = sum(1 for e in entries if e["status"] == "completed" and _is_error_entry(e))
summary = {
"total": len(entries),
"pending": sum(1 for e in entries if e["status"] == "pending"),
"completed": sum(1 for e in entries if e["status"] == "completed"),
"completed": sum(1 for e in entries if e["status"] == "completed") - errors,
"errors": errors,
"unsolicited": sum(1 for e in entries if e["status"] == "unsolicited"),
}
return entries, summary
@ -487,33 +611,7 @@ def admin_report_fragment():
"""JSON fragment for JS auto-refresh: rendered body HTML + summary counts."""
with _lock:
entries, summary = _snapshot_entries_and_summary()
# Render just the grouped body (without the full page shell)
from collections import defaultdict as _dd
import html as _html
groups = _dd(list)
for e in entries:
mid = (e.get("task") or {}).get("material_id")
label = _device_label_by_material.get(mid) or (
"(без устройства)" if mid is None else f"{mid[:8]}"
)
groups[label].append(e)
blocks = []
for label in sorted(groups.keys()):
gentries = groups[label]
g_done = sum(1 for e in gentries if e["status"] == "completed")
g_pend = sum(1 for e in gentries if e["status"] == "pending")
rows_html = "\n".join(render_entry_html(e) for e in gentries)
blocks.append(
f'<section class="group">'
f'<h2><span class="dev">{_html.escape(label)}</span>'
f'<span class="g-stats">'
f'<span class="g-done">{g_done}</span>'
f' / <span class="g-total">{len(gentries)}</span>'
f' <span class="muted">(ожидают: {g_pend})</span>'
f'</span></h2>{rows_html}</section>'
)
body = "\n".join(blocks) or '<div class="empty">Отчёт пуст</div>'
body = _render_grouped_blocks(entries) or '<div class="empty">Отчёт пуст</div>'
return jsonify({"html": body, "summary": summary})
@ -531,6 +629,8 @@ def _esc(s: str) -> str:
def render_entry_html(e) -> str:
import html as _html
status = e["status"]
is_error = _is_error_entry(e)
display_status = "error" if is_error else status
task = e.get("task") or {}
result = e.get("result")
ttype = _html.escape(task.get("type", "?"))
@ -540,41 +640,70 @@ def render_entry_html(e) -> str:
injected_at = _fmt_ts(e.get("injected_at"))
result_at = _fmt_ts(e.get("result_at"))
tid = _html.escape(e.get("id", ""))
def json_block(obj):
if obj is None:
return '<span class="muted">—</span>'
text = json.dumps(obj, ensure_ascii=False, indent=2)
return f'<pre>{_html.escape(text)}</pre>'
escaped = _html.escape(text)
# data-json с escaped JSON для копирования
raw = _html.escape(text, quote=True)
return (f'<div class="json-wrap">'
f'<button class="copy-btn" onclick="copyJson(this)" data-json="{raw}">Копировать</button>'
f'<pre>{escaped}</pre></div>')
result_summary = ""
if result and isinstance(result.get("data"), dict):
d = result["data"]
if d.get("status") == "error":
if is_error:
d = result.get("data", {}) if result else {}
result_summary = f'<div class="err">ERROR: {_html.escape(str(d.get("description", "")))}</div>'
elif result and isinstance(result.get("data"), list):
result_summary = f'<div class="muted">{len(result["data"])} items</div>'
scr_id = e.get("screenshot_id")
scr_pane = ""
if scr_id:
scr_pane = f"""
<div class="pane screenshot-pane">
<div class="label">Screenshot</div>
<img src="/_admin/screenshot/{scr_id}" class="screenshot" loading="lazy">
</div>"""
has_screenshot = bool(scr_id)
panes_cls = "panes panes-3" if has_screenshot else "panes"
# Успешные — вся задача свёрнута, ошибки — развёрнуты
if display_status == "completed":
panes_html = f"""
<div class="toggle-wrap" data-tid="{tid}">
<button class="toggle-btn" onclick="this.parentElement.classList.toggle('open')">Показать JSON</button>
<div class="toggle-content">
<div class="{panes_cls}">
<div class="pane task"><div class="label">Task JSON</div>{json_block(task)}</div>
<div class="pane result"><div class="label">Result JSON</div>{result_summary}{json_block(result)}</div>
{scr_pane}
</div>
</div>
</div>"""
else:
panes_html = f"""
<div class="{panes_cls}">
<div class="pane task"><div class="label">Task JSON</div>{json_block(task)}</div>
<div class="pane result"><div class="label">Result JSON</div>{result_summary}{json_block(result)}</div>
{scr_pane}
</div>"""
return f"""
<div class="entry {status}">
<div class="entry {display_status}">
<div class="head">
<span class="badge {status}">{status}</span>
<span class="badge {display_status}">{display_status}</span>
<span class="type">{ttype}</span>
<span class="bank">{bank}</span>
<span class="mid">{mid}</span>
<span class="src">{src}</span>
<span class="times">inj {injected_at} res {result_at}</span>
</div>
<div class="panes">
<div class="pane task">
<div class="label">Task JSON</div>
{json_block(task)}
</div>
<div class="pane result">
<div class="label">Result JSON</div>
{result_summary}
{json_block(result)}
</div>
</div>
{panes_html}
</div>
"""
@ -670,25 +799,52 @@ def render_profile_section() -> str:
)
def render_report_html(entries, summary) -> str:
def _render_grouped_blocks(entries) -> str:
import html as _html
# Group by device label
groups: dict = defaultdict(list)
# Group by device label → bank_name
devices: dict = defaultdict(lambda: defaultdict(list))
for e in entries:
mid = (e.get("task") or {}).get("material_id")
task = e.get("task") or {}
mid = task.get("material_id")
label = _device_label_by_material.get(mid) or (
"(без устройства)" if mid is None else f"{mid[:8]}"
)
groups[label].append(e)
bank = task.get("bank_name", "unknown")
devices[label][bank].append(e)
group_blocks = []
for label in sorted(groups.keys()):
gentries = groups[label]
g_total = len(gentries)
g_done = sum(1 for e in gentries if e["status"] == "completed")
g_pend = sum(1 for e in gentries if e["status"] == "pending")
rows_html = "\n".join(render_entry_html(e) for e in gentries)
for label in sorted(devices.keys()):
banks = devices[label]
# Device-level stats
all_entries = [e for bl in banks.values() for e in bl]
g_total = len(all_entries)
g_err = sum(1 for e in all_entries if _is_error_entry(e))
g_done = sum(1 for e in all_entries if e["status"] == "completed") - g_err
g_pend = sum(1 for e in all_entries if e["status"] == "pending")
err_badge = f' <span style="color:var(--unsolicited)">ошибки: {g_err}</span>' if g_err else ''
bank_blocks = []
for bank in sorted(banks.keys()):
bentries = banks[bank]
b_total = len(bentries)
b_err = sum(1 for e in bentries if _is_error_entry(e))
b_done = sum(1 for e in bentries if e["status"] == "completed") - b_err
b_pend = sum(1 for e in bentries if e["status"] == "pending")
b_err_badge = f' <span style="color:var(--unsolicited)">{b_err}</span>' if b_err else ''
rows_html = "\n".join(render_entry_html(e) for e in bentries)
bank_blocks.append(
f'<div class="bank-group">'
f'<h3><span class="bank">{_html.escape(bank)}</span>'
f'<span class="g-stats">'
f'<span class="g-done">{b_done}</span>'
f' / <span class="g-total">{b_total}</span>'
f'{b_err_badge}'
f' <span class="muted">(ожидают: {b_pend})</span>'
f'</span></h3>'
f'{rows_html}</div>'
)
group_blocks.append(f"""
<section class="group">
<h2>
@ -696,14 +852,19 @@ def render_report_html(entries, summary) -> str:
<span class="g-stats">
<span class="g-done">{g_done}</span>
/ <span class="g-total">{g_total}</span>
{err_badge}
<span class="muted">(ожидают: {g_pend})</span>
</span>
</h2>
{rows_html}
{"".join(bank_blocks)}
</section>
""")
body_rows = "\n".join(group_blocks) or '<div class="empty">Отчёт пуст — бутстрап ещё не сработал или результаты ещё не пришли.</div>'
return "\n".join(group_blocks)
def render_report_html(entries, summary) -> str:
body_rows = _render_grouped_blocks(entries) or '<div class="empty">Отчёт пуст — бутстрап ещё не сработал или результаты ещё не пришли.</div>'
return f"""<!doctype html>
<html lang="ru">
@ -751,6 +912,7 @@ def render_report_html(entries, summary) -> str:
overflow: hidden;
}}
.entry.completed {{ border-left: 4px solid var(--completed); }}
.entry.error {{ border-left: 4px solid var(--unsolicited); }}
.entry.pending {{ border-left: 4px solid var(--pending); }}
.entry.unsolicited {{ border-left: 4px solid var(--unsolicited); }}
.head {{
@ -764,6 +926,7 @@ def render_report_html(entries, summary) -> str:
}}
.badge.pending {{ background: var(--pending); color: #1a1600; }}
.badge.completed {{ background: var(--completed); color: #0a1a0a; }}
.badge.error {{ background: var(--unsolicited); color: #200; }}
.badge.unsolicited {{ background: var(--unsolicited); color: #200; }}
.type {{ font-weight: 600; color: #9cdcfe; }}
.bank {{ color: #ffb86c; }}
@ -771,6 +934,7 @@ def render_report_html(entries, summary) -> str:
.src {{ color: var(--muted); font-style: italic; }}
.times {{ color: var(--muted); margin-left: auto; font-family: monospace; }}
.panes {{ display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border); }}
.panes-3 {{ grid-template-columns: 1fr 1fr auto; }}
.pane {{ padding: 10px 12px; }}
.pane.task {{ background: var(--task); }}
.pane.result {{ background: var(--result); }}
@ -785,6 +949,37 @@ def render_report_html(entries, summary) -> str:
}}
.muted {{ color: var(--muted); font-style: italic; }}
.err {{ color: #ff6b6b; font-weight: 600; margin-bottom: 4px; }}
.toggle-wrap .toggle-content {{ display: none; }}
.toggle-wrap.open .toggle-content {{ display: block; }}
.toggle-btn {{
background: var(--panel); color: var(--muted);
border: 1px solid var(--border); border-radius: 4px;
padding: 4px 12px; margin: 8px 12px;
cursor: pointer; font-size: 11px;
transition: background 0.15s, color 0.15s;
}}
.toggle-btn:hover {{ background: var(--border); color: var(--text); }}
.json-wrap {{ position: relative; }}
.copy-btn {{
position: absolute; top: 6px; right: 6px;
background: var(--border); color: var(--muted);
border: none; border-radius: 4px;
padding: 5px 12px; font-size: 12px;
cursor: pointer; opacity: 0; transition: opacity 0.15s;
}}
.json-wrap:hover .copy-btn {{ opacity: 1; }}
.copy-btn:hover {{ color: var(--text); background: #3a3f4a; }}
.copy-btn.copied {{ color: var(--completed); }}
.screenshot-pane {{ background: var(--panel); max-width: 280px; }}
.screenshot {{ max-height: 200px; width: auto; border-radius: 4px; margin-top: 4px; cursor: pointer; transition: opacity 0.15s; }}
.screenshot:hover {{ opacity: 0.8; }}
.lightbox {{
display: none; position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,0.85); align-items: center; justify-content: center;
cursor: zoom-out;
}}
.lightbox.open {{ display: flex; }}
.lightbox img {{ max-width: 90vw; max-height: 90vh; border-radius: 6px; }}
.empty {{ color: var(--muted); text-align: center; padding: 40px; }}
a {{ color: #9cdcfe; }}
.group {{
@ -807,6 +1002,22 @@ def render_report_html(entries, summary) -> str:
.group h2 .g-stats {{ margin-left: auto; font-family: monospace; font-size: 13px; }}
.group h2 .g-done {{ color: var(--completed); font-weight: 700; }}
.group h2 .g-total {{ color: var(--muted); }}
.bank-group {{
margin: 8px 0 16px;
padding-left: 12px;
border-left: 2px solid var(--border);
}}
.bank-group h3 {{
margin: 0 0 8px;
font-size: 13px;
display: flex;
align-items: baseline;
gap: 10px;
}}
.bank-group h3 .bank {{ color: #9cdcfe; font-weight: 600; }}
.bank-group h3 .g-stats {{ margin-left: auto; font-family: monospace; font-size: 12px; }}
.bank-group h3 .g-done {{ color: var(--completed); font-weight: 700; }}
.bank-group h3 .g-total {{ color: var(--muted); }}
.updating {{ opacity: 0.6; }}
/* Profile section */
.profile-section {{
@ -891,8 +1102,8 @@ def render_report_html(entries, summary) -> str:
<div class="summary">
<div class="cell"><div class="n">{summary['total']}</div>всего</div>
<div class="cell completed"><div class="n">{summary['completed']}</div>выполнено</div>
<div class="cell unsolicited"><div class="n">{summary['errors']}</div>ошибки</div>
<div class="cell pending"><div class="n">{summary['pending']}</div>в очереди</div>
<div class="cell unsolicited"><div class="n">{summary['unsolicited']}</div>незапрошенные</div>
</div>
{render_profile_section()}
<div id="report-body">
@ -906,20 +1117,48 @@ def render_report_html(entries, summary) -> str:
const summaryEls = {{
total: document.querySelector('.summary .cell:nth-child(1) .n'),
completed: document.querySelector('.summary .cell.completed .n'),
errors: document.querySelector('.summary .cell.unsolicited .n'),
pending: document.querySelector('.summary .cell.pending .n'),
unsolicited: document.querySelector('.summary .cell.unsolicited .n'),
}};
function saveToggleState() {{
const state = new Set();
bodyEl.querySelectorAll('.toggle-wrap[data-tid]').forEach(el => {{
if (el.classList.contains('open')) state.add(el.dataset.tid);
}});
return state;
}}
function restoreToggleState(state) {{
bodyEl.querySelectorAll('.toggle-wrap[data-tid]').forEach(el => {{
if (state.has(el.dataset.tid)) el.classList.add('open');
}});
updateToggleLabels();
}}
function updateToggleLabels() {{
bodyEl.querySelectorAll('.toggle-wrap').forEach(el => {{
const btn = el.querySelector('.toggle-btn');
if (btn) btn.textContent = el.classList.contains('open') ? 'Свернуть JSON' : 'Показать JSON';
}});
}}
document.addEventListener('click', e => {{
if (e.target.classList.contains('toggle-btn')) updateToggleLabels();
}});
async function tick() {{
try {{
const resp = await fetch('/_admin/report/fragment', {{cache: 'no-store'}});
if (!resp.ok) throw new Error(resp.status);
const data = await resp.json();
const saved = saveToggleState();
bodyEl.innerHTML = data.html;
restoreToggleState(saved);
summaryEls.total.textContent = data.summary.total;
summaryEls.completed.textContent = data.summary.completed;
summaryEls.errors.textContent = data.summary.errors;
summaryEls.pending.textContent = data.summary.pending;
summaryEls.unsolicited.textContent = data.summary.unsolicited;
statusEl.textContent = '(обновлено ' + new Date().toLocaleTimeString() + ')';
}} catch (e) {{
statusEl.textContent = '(ошибка обновления: ' + e + ')';
@ -929,6 +1168,31 @@ def render_report_html(entries, summary) -> str:
setInterval(tick, REFRESH_MS);
}})();
</script>
<script>
function copyJson(btn) {{
const text = btn.dataset.json;
navigator.clipboard.writeText(text).then(() => {{
btn.textContent = 'Скопировано';
btn.classList.add('copied');
setTimeout(() => {{ btn.textContent = 'Копировать'; btn.classList.remove('copied'); }}, 1500);
}});
}}
</script>
<div class="lightbox" id="lightbox" onclick="this.classList.remove('open')">
<img id="lightbox-img" src="" alt="">
</div>
<script>
document.addEventListener('click', function(e) {{
if (e.target.classList.contains('screenshot')) {{
const lb = document.getElementById('lightbox');
document.getElementById('lightbox-img').src = e.target.src;
lb.classList.add('open');
}}
}});
document.addEventListener('keydown', function(e) {{
if (e.key === 'Escape') document.getElementById('lightbox').classList.remove('open');
}});
</script>
</body>
</html>
"""
@ -942,7 +1206,12 @@ def admin_reset():
_injected.clear()
_pending_by_key.clear()
_in_flight.clear()
_monitoring_logs.clear()
_bootstrap_done = False
if _SCREENSHOTS_DIR.exists():
shutil.rmtree(_SCREENSHOTS_DIR)
if _STATE_FILE.exists():
_STATE_FILE.unlink()
return ok({})
@ -970,24 +1239,68 @@ if __name__ == "__main__":
help="Only bootstrap tasks for these banks (e.g. --banks ozon dushanbe_city_bank)")
parser.add_argument("--seed", type=int, default=None,
help="RNG seed for reproducible bootstrap picks")
parser.add_argument("--types", nargs="+", default=None,
help="Only bootstrap these task types (e.g. --types FETCH_TRANSACTIONS)")
parser.add_argument("--debug", action="store_true",
help="Run Flask in debug mode (auto-reload on code changes)")
args = parser.parse_args()
_bootstrap_ft_count = args.ft_per_material
_bootstrap_banks = args.banks
_bootstrap_types = args.types
if _bootstrap_banks:
print(f"[mock] bootstrap limited to banks: {_bootstrap_banks}")
if _bootstrap_types:
print(f"[mock] bootstrap limited to types: {_bootstrap_types}")
if args.seed is not None:
random.seed(args.seed)
# Fresh start: drop any leftover state from a previous run in this process.
_task_queue.clear()
_injected.clear()
_pending_by_key.clear()
_in_flight.clear()
_bootstrap_done = False
print("[mock] state cleared — starting fresh")
load_default_profile()
load_pool()
app.run(host=args.host, port=args.port, threaded=True)
is_reload = os.environ.get("WERKZEUG_RUN_MAIN") == "true"
if args.debug and is_reload and _load_state():
# Auto-reload в debug — восстанавливаем состояние
print("[mock] auto-reload: state restored from disk")
else:
# Первый запуск (или без --debug) — чистим всё
_task_queue.clear()
_injected.clear()
_pending_by_key.clear()
_in_flight.clear()
_monitoring_logs.clear()
_bootstrap_done = False
if _STATE_FILE.exists():
_STATE_FILE.unlink()
import shutil
if _SCREENSHOTS_DIR.exists():
shutil.rmtree(_SCREENSHOTS_DIR)
# Чистим events в БД приложения чтобы не было "App restarted" при старте
try:
import configparser
import sqlite3
project_root = (HERE / ".." / "..").resolve()
for cfg in [project_root / "config.ini", project_root / "build_test" / "config.ini"]:
if cfg.exists():
cp = configparser.ConfigParser()
cp.read(str(cfg), encoding="utf-8")
raw = cp.get("db", "dbPath", fallback="")
if raw:
db = Path(os.path.expanduser(raw))
if not db.is_absolute():
db = cfg.parent / db
if db.exists():
conn = sqlite3.connect(str(db))
n = conn.execute("SELECT count(*) FROM events").fetchone()[0]
if n > 0:
conn.execute("DELETE FROM events")
conn.commit()
print(f"[mock] cleaned {n} events from {db.name}")
conn.close()
break
except Exception as e:
print(f"[mock] warning: could not clean events DB: {e}")
print("[mock] starting fresh (clean slate)")
app.run(host=args.host, port=args.port, threaded=True, debug=args.debug)

View File

@ -0,0 +1,6 @@
{
"bank_name": "dushanbe_city_bank",
"material_id": "36002ac2-6f4d-49da-8892-18db723a31c7",
"type": "FETCH_PROFILE",
"body": {}
}

View File

@ -0,0 +1,6 @@
{
"bank_name": "ozon",
"material_id": "50393cc1-57e4-4293-9a59-6cdd1d9ce6cb",
"type": "FETCH_PROFILE",
"body": {}
}

View File

@ -0,0 +1,6 @@
{
"bank_name": "ozon",
"material_id": "06a1ae15-abd2-4d90-aac0-77f4a4fd32e8",
"type": "FETCH_PROFILE",
"body": {}
}

View File

@ -0,0 +1,6 @@
{
"bank_name": "ozon",
"material_id": "7e3a7e4e-0d56-4d66-ac44-2b240ec33620",
"type": "FETCH_PROFILE",
"body": {}
}