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:
parent
628323184f
commit
899f4ff6d6
4
.gitignore
vendored
4
.gitignore
vendored
@ -6,6 +6,10 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# Mock server state
|
||||||
|
tests/mock_server/.mock_state.json
|
||||||
|
tests/mock_server/.mock_screenshots/
|
||||||
|
|
||||||
cmake_install.cmake
|
cmake_install.cmake
|
||||||
CMakeCache.txt
|
CMakeCache.txt
|
||||||
Makefile
|
Makefile
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QCoreApplication>
|
#include <QCoreApplication>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
#include <QRegularExpression>
|
||||||
#include <QThread>
|
#include <QThread>
|
||||||
|
|
||||||
AdbUtils::AdbUtils(QObject *parent) : QObject(parent) {
|
AdbUtils::AdbUtils(QObject *parent) : QObject(parent) {
|
||||||
@ -197,54 +198,69 @@ 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'
|
// 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) {
|
QString AdbUtils::getScreenDumpAsXml(const QString &deviceId, int idx, bool screenshot) {
|
||||||
const QString adb = adbPath();
|
const QString adb = adbPath();
|
||||||
QString output;
|
|
||||||
|
|
||||||
// 1. Dump UI hierarchy (retry up to 3 times — uiautomator may be killed by Android)
|
for (int retry = 0; retry < 3; ++retry) {
|
||||||
bool dumpOk = false;
|
// 1. Dump UI hierarchy (retry up to 3 times — uiautomator may be killed by Android)
|
||||||
for (int attempt = 0; attempt < 3; ++attempt) {
|
bool dumpOk = false;
|
||||||
if (startProcess(adb, {"-s", deviceId, "shell", "uiautomator", "dump", "/sdcard/uidump.xml"})) {
|
for (int attempt = 0; attempt < 3; ++attempt) {
|
||||||
dumpOk = true;
|
if (startProcess(adb, {"-s", deviceId, "shell", "uiautomator", "dump", "/sdcard/uidump.xml"})) {
|
||||||
break;
|
dumpOk = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
qWarning() << "[AdbUtils] uiautomator dump failed, attempt" << (attempt + 1) << "of 3";
|
||||||
|
QThread::msleep(1000);
|
||||||
}
|
}
|
||||||
qWarning() << "[AdbUtils] uiautomator dump failed, attempt" << (attempt + 1) << "of 3";
|
if (!dumpOk) {
|
||||||
QThread::msleep(1000);
|
qWarning() << "Failed to dump UI hierarchy after 3 attempts.";
|
||||||
}
|
|
||||||
if (!dumpOk) {
|
|
||||||
qWarning() << "Failed to dump UI hierarchy after 3 attempts.";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
// 2. Optionally pull and save the XML file
|
|
||||||
if (screenshot) {
|
|
||||||
if (!startProcess(adb, {"-s", deviceId, "pull", "/sdcard/uidump.xml",
|
|
||||||
QString("step_%1.xml").arg(idx)})) {
|
|
||||||
qWarning() << "Failed to pull UIDump XML.";
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
// 2. Optionally pull and save the XML file
|
||||||
// 3. Read the XML content
|
if (screenshot) {
|
||||||
QString xmlOut;
|
if (!startProcess(adb, {"-s", deviceId, "pull", "/sdcard/uidump.xml",
|
||||||
if (!startProcess(adb, {"-s", deviceId, "shell", "cat", "/sdcard/uidump.xml"}, &xmlOut)) {
|
QString("step_%1.xml").arg(idx)})) {
|
||||||
qWarning() << "Failed to read UIDump XML.";
|
qWarning() << "Failed to pull UIDump XML.";
|
||||||
return {};
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. Read the XML content
|
||||||
|
QString xmlOut;
|
||||||
|
if (!startProcess(adb, {"-s", deviceId, "shell", "cat", "/sdcard/uidump.xml"}, &xmlOut)) {
|
||||||
|
qWarning() << "Failed to read UIDump XML.";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Remove temp XML file
|
||||||
|
startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/uidump.xml"});
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
QString("screen_step_%1.png").arg(idx)});
|
||||||
|
startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/screen.png"});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Обрезка к началу XML
|
||||||
|
QByteArray xmlBytes = xmlOut.toUtf8();
|
||||||
|
if (const int start = xmlBytes.indexOf("<?xml"); start != -1) {
|
||||||
|
xmlBytes = xmlBytes.mid(start);
|
||||||
|
}
|
||||||
|
return QString::fromUtf8(xmlBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Remove temp XML file
|
qWarning() << "[AdbUtils] getScreenDumpAsXml: empty dump after 3 retries";
|
||||||
startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/uidump.xml"}); // неважно, успешна ли команда
|
return {};
|
||||||
|
|
||||||
// 5. Screenshot (if needed)
|
|
||||||
if (screenshot) {
|
|
||||||
startProcess(adb, {"-s", deviceId, "shell", "screencap", "-p", "/sdcard/screen.png"});
|
|
||||||
startProcess(adb, {"-s", deviceId, "pull", "/sdcard/screen.png",
|
|
||||||
QString("screen_step_%1.png").arg(idx)});
|
|
||||||
startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/screen.png"});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Обрезка к началу XML
|
|
||||||
QByteArray xmlBytes = xmlOut.toUtf8();
|
|
||||||
if (const int start = xmlBytes.indexOf("<?xml"); start != -1) {
|
|
||||||
xmlBytes = xmlBytes.mid(start);
|
|
||||||
}
|
|
||||||
return QString::fromUtf8(xmlBytes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool AdbUtils::takeScreenshot(const QString &deviceId, const QString &name) {
|
bool AdbUtils::takeScreenshot(const QString &deviceId, const QString &name) {
|
||||||
|
|||||||
@ -331,9 +331,9 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
// Pull-to-refresh: свайп сверху вниз для обновления списка
|
// Pull-to-refresh: свайп сверху вниз для обновления списка
|
||||||
{
|
{
|
||||||
const int x = device.screenWidth / 2;
|
const int x = device.screenWidth / 2;
|
||||||
const int fromY = device.screenHeight / 4;
|
const int fromY = device.screenHeight / 3;
|
||||||
const int toY = device.screenHeight * 3 / 4;
|
const int toY = device.screenHeight * 2 / 3;
|
||||||
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY);
|
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500);
|
||||||
QThread::msleep(3000);
|
QThread::msleep(3000);
|
||||||
qDebug() << "[Dushanbe::GetLastTransactions] Pull-to-refresh on Выписка";
|
qDebug() << "[Dushanbe::GetLastTransactions] Pull-to-refresh on Выписка";
|
||||||
}
|
}
|
||||||
@ -369,7 +369,7 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
|
|
||||||
// Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства
|
// Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства
|
||||||
const QTimeZone tjTz("Asia/Dushanbe");
|
const QTimeZone tjTz("Asia/Dushanbe");
|
||||||
const int maxScrolls = 4;
|
const int maxScrolls = 10;
|
||||||
|
|
||||||
auto interrupted = []() {
|
auto interrupted = []() {
|
||||||
return QThread::currentThread()->isInterruptionRequested();
|
return QThread::currentThread()->isInterruptionRequested();
|
||||||
@ -379,6 +379,7 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
double amount = 0.0;
|
double amount = 0.0;
|
||||||
QString time; // "21:31:21"
|
QString time; // "21:31:21"
|
||||||
QString date; // "05.04.2026"
|
QString date; // "05.04.2026"
|
||||||
|
bool dateReliable = false; // true если дата из content-desc (P2P), false если из groupDates
|
||||||
Node node;
|
Node node;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -462,7 +463,7 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
it.time = last;
|
it.time = last;
|
||||||
it.node = n;
|
it.node = n;
|
||||||
|
|
||||||
// В P2P формате дата встроена в первую строку
|
// В P2P формате дата встроена в первую строку — надёжная
|
||||||
if (isP2P) {
|
if (isP2P) {
|
||||||
if (firstLine.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0)
|
if (firstLine.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0)
|
||||||
it.date = todayLocal.toString("dd.MM.yyyy");
|
it.date = todayLocal.toString("dd.MM.yyyy");
|
||||||
@ -470,6 +471,7 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
it.date = todayLocal.addDays(-1).toString("dd.MM.yyyy");
|
it.date = todayLocal.addDays(-1).toString("dd.MM.yyyy");
|
||||||
else
|
else
|
||||||
it.date = firstLine;
|
it.date = firstLine;
|
||||||
|
it.dateReliable = true;
|
||||||
}
|
}
|
||||||
items.append(it);
|
items.append(it);
|
||||||
}
|
}
|
||||||
@ -537,8 +539,15 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
prevY2 = it.node.y2();
|
prevY2 = it.node.y2();
|
||||||
}
|
}
|
||||||
|
|
||||||
qDebug() << "[Dushanbe::GetLastTransactions] Vypiska items:" << items.size()
|
qDebug() << "[Dushanbe::GetLastTransactions] scroll=" << scroll
|
||||||
<< "groups:" << groupDates;
|
<< "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);
|
const double searchAbs = qAbs(m_searchAmount);
|
||||||
for (const auto &tx : items) {
|
for (const auto &tx : items) {
|
||||||
@ -552,10 +561,10 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
<< (matchedX100 ? "(scaled x100)" : "(direct)")
|
<< (matchedX100 ? "(scaled x100)" : "(direct)")
|
||||||
<< "date:" << tx.date << "time:" << tx.time;
|
<< "date:" << tx.date << "time:" << tx.time;
|
||||||
|
|
||||||
// Pre-filter по ДАТЕ: если дата ряда не совпадает с датой поиска (±1 день),
|
// Pre-filter по ДАТЕ: только если дата надёжная (из content-desc P2P формата).
|
||||||
// пропускаем без тапа-в-детали. Это отсекает большинство "нематчащих" строк
|
// DCWallet транзакции получают дату из groupDates ImageView, которая
|
||||||
// быстрее, чем 10-секундный цикл tap→parse→goBack.
|
// неправильно присваивается при скролле — не фильтруем по ней.
|
||||||
if (m_searchTime.isValid() && !tx.date.isEmpty()) {
|
if (tx.dateReliable && m_searchTime.isValid() && !tx.date.isEmpty()) {
|
||||||
const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy");
|
const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy");
|
||||||
const QDate searchLocalDate = m_searchTime.toTimeZone(tjTz).date();
|
const QDate searchLocalDate = m_searchTime.toTimeZone(tjTz).date();
|
||||||
if (listDate.isValid() && searchLocalDate.isValid()) {
|
if (listDate.isValid() && searchLocalDate.isValid()) {
|
||||||
@ -720,13 +729,21 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Скроллим вниз
|
// Скроллим вниз — свайп внутри области транзакций
|
||||||
if (scroll < maxScrolls) {
|
if (scroll < maxScrolls) {
|
||||||
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
|
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
|
||||||
const int x = device.screenWidth / 2;
|
const int x = device.screenWidth / 2;
|
||||||
const int fromY = device.screenHeight * 3 / 4;
|
// Определяем область контента из реальных Y позиций items
|
||||||
const int toY = device.screenHeight / 4;
|
int contentBottom = device.screenHeight * 2 / 3;
|
||||||
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY);
|
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);
|
QThread::msleep(2000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -88,6 +88,15 @@ bool CommonScript::runAppAndGoToHomeScreen(
|
|||||||
qDebug() << "[Ozon] Fast path: Home reached after PIN";
|
qDebug() << "[Ozon] Fast path: Home reached after PIN";
|
||||||
return true;
|
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);
|
QThread::msleep(1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -128,6 +137,17 @@ bool CommonScript::runAppAndGoToHomeScreen(
|
|||||||
continue;
|
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();
|
Node closeBannerNode = xmlScreenParser.tryToFindSecurityBanner();
|
||||||
if (!closeBannerNode.isEmpty()) {
|
if (!closeBannerNode.isEmpty()) {
|
||||||
|
|||||||
@ -132,7 +132,21 @@ void GetLastTransactionsScript::doStart() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &device, const BankProfileInfo &app) {
|
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));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
|
tryRefreshIfError();
|
||||||
|
|
||||||
// Закрываем рекламный bottom sheet
|
// Закрываем рекламный bottom sheet
|
||||||
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
|
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));
|
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 (!xmlScreenParser.isHomeScreen()) {
|
||||||
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
|
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
|
||||||
@ -160,6 +189,7 @@ bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &devi
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
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);
|
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId);
|
||||||
QTimeZone deviceTz(tzName.toUtf8());
|
QTimeZone deviceTz(tzName.toUtf8());
|
||||||
if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow");
|
if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow");
|
||||||
const int maxScrolls = 30;
|
const int maxScrolls = 10;
|
||||||
const int navBarTop = device.screenHeight - (device.screenHeight / 10);
|
const int navBarTop = device.screenHeight - (device.screenHeight / 10);
|
||||||
|
|
||||||
// Целевая дата в таймзоне устройства для поиска заголовка дня в списке
|
// Целевая дата в таймзоне устройства для поиска заголовка дня в списке
|
||||||
const QDateTime searchLocal = m_searchTime.toTimeZone(deviceTz);
|
const QDateTime searchLocal = m_searchTime.toTimeZone(deviceTz);
|
||||||
const QDate searchDate = searchLocal.date();
|
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)
|
<< "time=" << m_searchTime.toString(Qt::ISODate)
|
||||||
<< "tz=" << tzName
|
<< "tz=" << tzName
|
||||||
<< "date(local)=" << searchDate.toString("dd.MM.yyyy");
|
<< "date(local)=" << searchDate.toString("dd.MM.yyyy")
|
||||||
|
<< "screen=" << device.screenWidth << "x" << device.screenHeight;
|
||||||
|
|
||||||
// ── Шаг 1: скроллим до нужной даты ──────────────────────────────
|
// ── Шаг 1: скроллим до нужной даты ──────────────────────────────
|
||||||
static const QMap<QString, int> monthMap = {
|
static const QMap<QString, int> monthMap = {
|
||||||
@ -290,7 +322,8 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
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);
|
QThread::msleep(1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -305,26 +338,45 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
|
|
||||||
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
|
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
|
||||||
// Кнопки транзакций между этими Y принадлежат нужному дню.
|
// Кнопки транзакций между этими Y принадлежат нужному дню.
|
||||||
|
// Если заголовок нужной даты ушёл за верх экрана, но следующая дата ещё
|
||||||
|
// видна ниже (или не видна вовсе), dateY = 0 — все кнопки выше nextDateY
|
||||||
|
// принадлежат нашему дню.
|
||||||
auto findDateYRange = [&]() -> QPair<int, int> {
|
auto findDateYRange = [&]() -> QPair<int, int> {
|
||||||
int dateY = -1;
|
int dateY = -1;
|
||||||
int nextDateY = device.screenHeight;
|
int nextDateY = device.screenHeight;
|
||||||
|
bool foundOlderDate = false;
|
||||||
for (const Node &node : xmlScreenParser.getNodes()) {
|
for (const Node &node : xmlScreenParser.getNodes()) {
|
||||||
if (node.className != "android.widget.TextView") continue;
|
if (node.className != "android.widget.TextView") continue;
|
||||||
const QDate d = parseDateHeader(node.text.trimmed());
|
const QDate d = parseDateHeader(node.text.trimmed());
|
||||||
if (!d.isValid()) continue;
|
if (!d.isValid()) continue;
|
||||||
if (d == searchDate) {
|
if (d == searchDate) {
|
||||||
dateY = node.y();
|
dateY = node.y();
|
||||||
} else if (dateY >= 0 && node.y() > dateY) {
|
} else if (d < searchDate) {
|
||||||
// Первый заголовок другой даты после нашей
|
// Заголовок более старой даты — граница снизу
|
||||||
nextDateY = node.y();
|
if (dateY >= 0 && node.y() > dateY) {
|
||||||
|
nextDateY = node.y();
|
||||||
|
} else if (dateY < 0) {
|
||||||
|
// Заголовок нужной даты ушёл вверх, но видна более старая дата
|
||||||
|
nextDateY = node.y();
|
||||||
|
dateY = 0; // всё выше nextDateY принадлежит нашему дню
|
||||||
|
}
|
||||||
|
foundOlderDate = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Заголовок ушёл вверх и нет более старых дат на экране —
|
||||||
|
// весь экран всё ещё внутри нашей даты
|
||||||
|
if (dateY < 0 && !foundOlderDate && dateFound) {
|
||||||
|
dateY = 0;
|
||||||
|
}
|
||||||
|
qDebug() << "[FetchTransactions] findDateYRange: dateY=" << dateY
|
||||||
|
<< "nextDateY=" << nextDateY << "foundOlderDate=" << foundOlderDate;
|
||||||
return {dateY, nextDateY};
|
return {dateY, nextDateY};
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Шаг 2: тапаем только по кнопкам под заголовком нужной даты ───
|
// ── Шаг 2: тапаем только по кнопкам под заголовком нужной даты ───
|
||||||
QSet<QString> checkedDetails;
|
QSet<QString> checkedDetails;
|
||||||
|
int noProgressCount = 0; // Счётчик итераций без прогресса (новых unchecked кнопок)
|
||||||
|
|
||||||
for (int attempt = 0; attempt < 15; ++attempt) {
|
for (int attempt = 0; attempt < 15; ++attempt) {
|
||||||
if (QThread::currentThread()->isInterruptionRequested()) {
|
if (QThread::currentThread()->isInterruptionRequested()) {
|
||||||
@ -333,21 +385,38 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
}
|
}
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
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-диапазон заголовка нужной даты
|
// Находим Y-диапазон заголовка нужной даты
|
||||||
const auto [dateHeaderY, nextDateHeaderY] = findDateYRange();
|
const auto [dateHeaderY, nextDateHeaderY] = findDateYRange();
|
||||||
if (dateHeaderY < 0) {
|
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,
|
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||||||
device.screenHeight * 3 / 4,
|
device.screenHeight / 3,
|
||||||
device.screenWidth / 2,
|
device.screenWidth / 2,
|
||||||
device.screenHeight / 4, 300);
|
device.screenHeight * 2 / 3, 600);
|
||||||
QThread::msleep(1000);
|
QThread::msleep(1500);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
QList<Node> txButtons = xmlScreenParser.findTransactionButtons(20);
|
QList<Node> txButtons = xmlScreenParser.findTransactionButtons(20);
|
||||||
|
qDebug() << "[FetchTransactions] Found" << txButtons.size() << "buttons, dateY range:"
|
||||||
|
<< dateHeaderY << "-" << nextDateHeaderY;
|
||||||
|
|
||||||
bool tapped = false;
|
bool tapped = false;
|
||||||
|
bool hasNewButton = false; // Есть ли новая (ещё не проверенная) кнопка с нужной суммой
|
||||||
|
int buttonsInRange = 0; // Кнопки внутри Y-диапазона нужной даты
|
||||||
for (int bi = 0; bi < txButtons.size(); ++bi) {
|
for (int bi = 0; bi < txButtons.size(); ++bi) {
|
||||||
if (QThread::currentThread()->isInterruptionRequested()) {
|
if (QThread::currentThread()->isInterruptionRequested()) {
|
||||||
m_error = "Interrupted";
|
m_error = "Interrupted";
|
||||||
@ -358,8 +427,12 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
const int btnY = txButtons[bi].y();
|
const int btnY = txButtons[bi].y();
|
||||||
if (btnY <= dateHeaderY || btnY >= nextDateHeaderY) continue;
|
if (btnY <= dateHeaderY || btnY >= nextDateHeaderY) continue;
|
||||||
if (btnY <= 0 || btnY >= navBarTop) continue;
|
if (btnY <= 0 || btnY >= navBarTop) continue;
|
||||||
|
++buttonsInRange;
|
||||||
|
|
||||||
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text);
|
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;
|
if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue;
|
||||||
|
|
||||||
const Node &visibleBtn = txButtons[bi];
|
const Node &visibleBtn = txButtons[bi];
|
||||||
@ -412,6 +485,20 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
}
|
}
|
||||||
checkedDetails.insert(dtStr);
|
checkedDetails.insert(dtStr);
|
||||||
tapped = true;
|
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 минут)
|
// Проверяем время (±10 минут)
|
||||||
if (txTime.isValid() && m_searchTime.isValid()) {
|
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
|
<< "amount=" << parsed.amount << "time=" << dtStr
|
||||||
<< "name=" << detail.name;
|
<< "name=" << detail.name;
|
||||||
|
|
||||||
@ -443,7 +530,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
QThread::msleep(2000);
|
QThread::msleep(2000);
|
||||||
|
|
||||||
bool docLoaded = false;
|
bool docLoaded = false;
|
||||||
for (int da = 0; da < 15; ++da) {
|
for (int da = 0; da < 5; ++da) {
|
||||||
if (QThread::currentThread()->isInterruptionRequested()) break;
|
if (QThread::currentThread()->isInterruptionRequested()) break;
|
||||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||||
if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) {
|
if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) {
|
||||||
@ -535,17 +622,50 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
|||||||
return; // успех
|
return; // успех
|
||||||
}
|
}
|
||||||
|
|
||||||
// Скроллим вниз если ни одна кнопка не была нажата или все проверенные не подошли
|
// Отслеживаем прогресс: "без прогресса" только если были кнопки с СОВПАДАЮЩЕЙ
|
||||||
if (!tapped) {
|
// суммой (tapped), но все уже checked. Если суммы не совпали — скроллим дальше,
|
||||||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
// нужная кнопка может быть ниже.
|
||||||
device.screenHeight * 3 / 4,
|
if (!hasNewButton && tapped) {
|
||||||
device.screenWidth / 2,
|
++noProgressCount;
|
||||||
device.screenHeight / 4, 300);
|
if (noProgressCount >= 5) {
|
||||||
QThread::msleep(1000);
|
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");
|
+ " date=" + searchDate.toString("dd.MM.yyyy");
|
||||||
qWarning() << "[FetchTransactions]" << m_error;
|
qWarning() << "[FetchTransactions]" << m_error;
|
||||||
// Отправляем скриншот списка транзакций, сделанный до начала поиска
|
// Отправляем скриншот списка транзакций, сделанный до начала поиска
|
||||||
|
|||||||
5
main.cpp
5
main.cpp
@ -316,7 +316,8 @@ int main(int argc, char *argv[]) {
|
|||||||
QApplication::setPalette(lightPalette);
|
QApplication::setPalette(lightPalette);
|
||||||
|
|
||||||
logFile.setFileName("application.log");
|
logFile.setFileName("application.log");
|
||||||
if (!logFile.open(QIODevice::Append | QIODevice::Text)) {
|
// Очищаем лог-файл при каждом запуске
|
||||||
|
if (!logFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
|
||||||
qWarning() << "Не удалось открыть лог-файл для записи";
|
qWarning() << "Не удалось открыть лог-файл для записи";
|
||||||
}
|
}
|
||||||
// Устанавливаем свой обработчик
|
// Устанавливаем свой обработчик
|
||||||
@ -334,7 +335,7 @@ int main(int argc, char *argv[]) {
|
|||||||
|
|
||||||
// Миграции БД
|
// Миграции БД
|
||||||
DatabaseManager::instance().initSchema();
|
DatabaseManager::instance().initSchema();
|
||||||
DatabaseManager::instance().cleanupOldData(5);
|
DatabaseManager::instance().cleanupOldData(0);
|
||||||
dbReady.store(true);
|
dbReady.store(true);
|
||||||
|
|
||||||
// 1. Если есть api_key — логинимся и проверяем/создаём desktop
|
// 1. Если есть api_key — логинимся и проверяем/создаём desktop
|
||||||
|
|||||||
@ -32,8 +32,8 @@
|
|||||||
#include "dushanbe/PayByPhoneScript.h"
|
#include "dushanbe/PayByPhoneScript.h"
|
||||||
|
|
||||||
static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonValue &data, int eventId = -1);
|
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 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 = {});
|
static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}, int eventId = -1);
|
||||||
|
|
||||||
EventHandler::EventHandler(QObject *parent) : QObject(parent) {}
|
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["material_id"] = materialId; // UUID string
|
||||||
obj["bank_name"] = bankName;
|
obj["bank_name"] = bankName;
|
||||||
obj["data"] = data;
|
obj["data"] = data;
|
||||||
|
if (eventId > 0)
|
||||||
|
obj["internal_task_id"] = eventId;
|
||||||
|
|
||||||
auto *thread = new QThread;
|
auto *thread = new QThread;
|
||||||
auto *networkService = new NetworkService;
|
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,
|
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;
|
QJsonObject errorData;
|
||||||
errorData["status"] = QStringLiteral("error");
|
errorData["status"] = QStringLiteral("error");
|
||||||
errorData["description"] = description;
|
errorData["description"] = description;
|
||||||
sendTaskResult(type, materialId, bankName, errorData);
|
sendTaskResult(type, materialId, bankName, errorData, eventId);
|
||||||
|
|
||||||
if (!screenshot.isEmpty()) {
|
if (!screenshot.isEmpty()) {
|
||||||
const QString message = QStringLiteral("[%1] bank=%2 material=%3: %4")
|
const QString message = QStringLiteral("[%1] bank=%2 material=%3: %4")
|
||||||
.arg(type, bankName, materialId, description);
|
.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,
|
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 *thread = new QThread;
|
||||||
auto *networkService = new NetworkService;
|
auto *networkService = new NetworkService;
|
||||||
networkService->moveToThread(thread);
|
networkService->moveToThread(thread);
|
||||||
|
if (eventId > 0)
|
||||||
|
networkService->addExtra("internal_task_id", eventId);
|
||||||
QObject::connect(thread, &QThread::started, networkService, [networkService, type, event, message, image]() {
|
QObject::connect(thread, &QThread::started, networkService, [networkService, type, event, message, image]() {
|
||||||
networkService->postMonitoringLog(type, event, message, image);
|
networkService->postMonitoringLog(type, event, message, image);
|
||||||
emit networkService->finished();
|
emit networkService->finished();
|
||||||
@ -273,7 +277,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
|
|||||||
.arg(eventTypeToString(event.type), deviceLabel, event.bankName, result);
|
.arg(eventTypeToString(event.type), deviceLabel, event.bankName, result);
|
||||||
GeneralLogDAO::insert("CRITICAL", "EventHandler", logMsg, screenshot);
|
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);
|
EventDAO::markSentToServer(event.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -375,21 +379,21 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
|
|||||||
}
|
}
|
||||||
f.remove();
|
f.remove();
|
||||||
}
|
}
|
||||||
sendMonitoringLog("info", "task_success", msg, screenshot);
|
sendMonitoringLog("info", "task_success", msg, screenshot, event.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type == EventType::FetchTransactions) {
|
if (event.type == EventType::FetchTransactions) {
|
||||||
const QString msg = QStringLiteral("[FETCH_TRANSACTIONS] bank=%1 material=%2")
|
const QString msg = QStringLiteral("[FETCH_TRANSACTIONS] bank=%1 material=%2")
|
||||||
.arg(event.bankName, event.externalId);
|
.arg(event.bankName, event.externalId);
|
||||||
const QByteArray scr = m_screenshots.take(key);
|
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) {
|
if (event.type == EventType::FetchProfile) {
|
||||||
const QString msg = QStringLiteral("[FETCH_PROFILE] bank=%1 material=%2")
|
const QString msg = QStringLiteral("[FETCH_PROFILE] bank=%1 material=%2")
|
||||||
.arg(event.bankName, event.externalId);
|
.arg(event.bankName, event.externalId);
|
||||||
const QByteArray scr = m_screenshots.take(key);
|
const QByteArray scr = m_screenshots.take(key);
|
||||||
sendMonitoringLog("info", "task_success", msg, scr);
|
sendMonitoringLog("info", "task_success", msg, scr, event.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1318,6 +1318,11 @@ void NetworkService::postMonitoringLog(const QString &type, const QString &event
|
|||||||
addFormField(multiPart, "event", event);
|
addFormField(multiPart, "event", event);
|
||||||
addFormField(multiPart, "message", message);
|
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()) {
|
if (!image.isEmpty()) {
|
||||||
QHttpPart imagePart;
|
QHttpPart imagePart;
|
||||||
imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/png");
|
imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/png");
|
||||||
|
|||||||
@ -13,7 +13,7 @@ ARC запускается как обычно (реальные Android-уст
|
|||||||
| Python | `brew install python` или встроенный | [python.org](https://python.org), галочка «Add to PATH» |
|
| Python | `brew install python` или встроенный | [python.org](https://python.org), галочка «Add to PATH» |
|
||||||
| Flask | `pip install flask` | `pip install flask` |
|
| Flask | `pip install flask` | `pip install flask` |
|
||||||
| ADB | `brew install android-platform-tools` | Используется из `tools\adb\windows\` |
|
| 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` |
|
| Устройства | `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`)
|
- Находит `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/default_profile.json` — профиль для мока
|
||||||
- `tests/mock_server/scenarios/*_fetch_profile.json` — по одному на материал
|
- `tests/mock_server/scenarios/*_<card>_fetch_profile.json` — по одному на материал (с номером карты в имени)
|
||||||
- `tests/mock_server/scenarios/*_fetch_transactions.json` — для ручных прогонов
|
- Очищает таблицу `events` в БД
|
||||||
|
|
||||||
Пример вывода:
|
Пример вывода:
|
||||||
```
|
```
|
||||||
config: config.ini
|
config: config.ini
|
||||||
database: database/app_data.db
|
database: database/app_data.db
|
||||||
|
deleted 5 old events
|
||||||
wrote default_profile.json (3 bank_profiles)
|
wrote default_profile.json (3 bank_profiles)
|
||||||
wrote scenarios/pixel_7_ozon_fetch_profile.json
|
wrote scenarios/pixel_7_ozon_7919_fetch_profile.json
|
||||||
wrote scenarios/pixel_7_dushanbe_city_bank_fetch_profile.json
|
wrote scenarios/pixel_7_ozon_9865_fetch_profile.json
|
||||||
wrote scenarios/tecno_km4_ozon_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):
|
Devices (2):
|
||||||
pixel_7: ozon (30bc106c…), dushanbe_city_bank (61d967d1…)
|
pixel_7: ozon/7919 (50393cc1…), ozon/9865 (06a1ae15…), dushanbe_city_bank/3258 (36002ac2…)
|
||||||
tecno_km4: ozon (49001bb5…)
|
tecno_km4: ozon/0982 (7e3a7e4e…)
|
||||||
```
|
```
|
||||||
|
|
||||||
Флаги: `--config C:\path\to\config.ini` — если config не в текущей директории.
|
Флаги: `--config C:\path\to\config.ini` — если config не в текущей директории.
|
||||||
@ -70,19 +72,12 @@ python tests\mock_server\gen_fetch_transactions.py
|
|||||||
Скрипт:
|
Скрипт:
|
||||||
- Читает БД → находит активные материалы
|
- Читает БД → находит активные материалы
|
||||||
- Находит подключённые ADB-устройства → матчит по `adb_serial`
|
- Находит подключённые ADB-устройства → матчит по `adb_serial`
|
||||||
- Показывает меню:
|
- Показывает меню → выбор материала или `all`
|
||||||
```
|
- Проходит по транзакциям на экране:
|
||||||
Available materials (3):
|
- **Ozon**: парсит заголовки дат (Сегодня/Вчера/dd месяца), определяет дату каждой транзакции по Y-позиции, тапает для получения времени из деталей, скроллит вниз для следующих
|
||||||
1. Pixel 7 / ozon / Галина Терехина (material 30bc106c…)
|
- **Dushanbe**: парсит Выписку, тап в детали, время Dushanbe (UTC+5)
|
||||||
2. Pixel 7 / dushanbe_city_bank / Мехрубон (material 61d967d1…)
|
- Дедупликация по `(amount, created_at)` — одинаковые суммы в один день с разным временем сохраняются как отдельные задачи
|
||||||
3. TECNO KM4 / ozon / Владислав Касаткин (material 49001bb5…)
|
- Сохраняет `scenarios/pool/*_<card>_ft_*.json` с реальными `amount` (в копейках) и `created_at` (UTC)
|
||||||
Pick [1-3] or 'all':
|
|
||||||
```
|
|
||||||
- После выбора просит открыть список транзакций на устройстве → Enter
|
|
||||||
- Проходит по каждой транзакции: тап → дамп деталей → назад
|
|
||||||
(перед каждым тапом проверяет, что мы на списке)
|
|
||||||
- Сохраняет `scenarios/pool/*_ft_*.json` с реальными `amount` (в копейках)
|
|
||||||
и `created_at` (UTC, из таймзоны устройства)
|
|
||||||
- Если мок запущен — сразу инжектит задачи
|
- Если мок запущен — сразу инжектит задачи
|
||||||
|
|
||||||
Флаги:
|
Флаги:
|
||||||
@ -113,57 +108,48 @@ apiBase=http://127.0.0.1:8888
|
|||||||
### Терминал 1 — mock-сервер
|
### Терминал 1 — mock-сервер
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# macOS
|
# macOS — полный цикл (генерация данных + запуск)
|
||||||
cd tests/mock_server && ./run.sh
|
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
|
# Windows
|
||||||
cd tests\mock_server
|
cd tests\mock_server
|
||||||
python mock_server.py --port 8888
|
python mock_server.py --port 8888
|
||||||
```
|
```
|
||||||
|
|
||||||
Логи в консоли:
|
Флаги `mock_server.py`:
|
||||||
```
|
- `--banks ozon dushanbe_city_bank` — только указанные банки
|
||||||
[mock] state cleared — starting fresh
|
- `--types FETCH_TRANSACTIONS` — только указанные типы задач
|
||||||
[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` — несколько банков через пробел
|
|
||||||
- `--ft-per-material 3` — сколько FT на материал при бутстрапе (default 3)
|
- `--ft-per-material 3` — сколько FT на материал при бутстрапе (default 3)
|
||||||
- `--seed 42` — фиксированный RNG для воспроизводимости
|
- `--seed 42` — фиксированный RNG для воспроизводимости
|
||||||
|
- `--debug` — Flask debug режим (auto-reload, состояние сохраняется между перезагрузками)
|
||||||
|
|
||||||
Без `--banks` задачи генерируются для всех активных банков.
|
При `--debug`:
|
||||||
|
- Изменения в `.py` файлах применяются автоматически без перезапуска
|
||||||
Примеры:
|
- Состояние задач сохраняется в `.mock_state.json` и восстанавливается при reload
|
||||||
```bash
|
- Скриншоты сохраняются в `.mock_screenshots/` (переживают reload)
|
||||||
# Только Ozon
|
- При **первом** запуске — чистый старт (events в БД очищаются, state сбрасывается)
|
||||||
./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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Терминал 2 — ARC
|
### Терминал 2 — ARC
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# macOS
|
# macOS (из корня проекта!)
|
||||||
./build/ARC
|
./build_test/ARC
|
||||||
|
|
||||||
# Windows
|
# Windows
|
||||||
build\Release\ARC.exe
|
build\Release\ARC.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
При логине мок автоматически ставит задачи:
|
При логине мок автоматически ставит задачи:
|
||||||
- 1 × FETCH_PROFILE на каждый материал
|
- 1 × FETCH_PROFILE на каждый материал (если `--types` не ограничивает)
|
||||||
- N × FETCH_TRANSACTIONS (случайные из pool)
|
- N × FETCH_TRANSACTIONS (случайные из pool)
|
||||||
|
|
||||||
Задачи раздаются **по 1 на material за поллинг** — следующая только после
|
Задачи раздаются **по 1 на material за поллинг** — следующая только после
|
||||||
@ -172,15 +158,9 @@ build\Release\ARC.exe
|
|||||||
### Терминал 3 — отчёт
|
### Терминал 3 — отчёт
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Браузер с авто-обновлением (задачи группируются по устройствам)
|
# Браузер с авто-обновлением
|
||||||
open http://127.0.0.1:8888/_admin/report/html # macOS
|
open http://127.0.0.1:8888/_admin/report/html # macOS
|
||||||
start http://127.0.0.1:8888/_admin/report/html # Windows
|
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-отчёт
|
## 5. HTML-отчёт
|
||||||
|
|
||||||
`http://127.0.0.1:8888/_admin/report/html` — веб-страница с:
|
`http://127.0.0.1:8888/_admin/report/html` — веб-страница с:
|
||||||
- Сводкой: всего / выполнено / в очереди
|
- Сводкой: всего / выполнено / ошибки / в очереди
|
||||||
- Группировкой по устройствам (Pixel 7, TECNO KM4, …)
|
- Группировкой по **устройствам** → **банкам** (Pixel 7 → ozon, dushanbe_city_bank)
|
||||||
- Для каждой задачи: Task JSON слева ↔ Result JSON справа
|
- Для каждой задачи:
|
||||||
- Цветовой индикацией: зелёный = completed, жёлтый = pending, красный = error
|
- Task JSON и Result JSON (для успешных — свёрнуты, кнопка "Показать JSON")
|
||||||
- Авто-обновлением каждые 2 секунды (без сброса скролла)
|
- Ошибки — развёрнуты сразу с красным бейджем
|
||||||
|
- Скриншот (если есть) — третья колонка, клик открывает полноэкранный просмотр
|
||||||
Также доступен JSON:
|
- Кнопка "Копировать" на каждом JSON блоке
|
||||||
```bash
|
- Цветовой индикацией: зелёный = completed, красный = error, жёлтый = pending
|
||||||
curl -s http://127.0.0.1:8888/_admin/report | jq # полный
|
- Авто-обновлением каждые 2 секунды (состояние свёрнутых/развёрнутых блоков сохраняется)
|
||||||
curl -s http://127.0.0.1:8888/_admin/report/pending | jq # только pending
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Ручной инжект задач
|
## 6. Скриншоты
|
||||||
|
|
||||||
|
Приложение отправляет скриншоты через `POST /monitoring/log` с полем `internal_task_id`.
|
||||||
|
Мок-сервер:
|
||||||
|
- Сохраняет PNG на диск в `.mock_screenshots/`
|
||||||
|
- Привязывает к задаче по `internal_task_id` (точное совпадение с `task_result`)
|
||||||
|
- Отображает в отчёте как третью колонку рядом с Task/Result JSON
|
||||||
|
- Клик по скриншоту — полноэкранный просмотр (Escape для закрытия)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Ручной инжект задач
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Одна задача
|
# Одна задача
|
||||||
./inject.sh scenarios/pixel_7_ozon_fetch_profile.json # macOS
|
curl -X POST http://127.0.0.1:8888/_admin/inject \
|
||||||
curl -X POST http://127.0.0.1:8888/_admin/inject ^ # Windows
|
-H "Content-Type: application/json" \
|
||||||
-H "Content-Type: application/json" ^
|
--data-binary @scenarios/pixel_7_ozon_7919_fetch_profile.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/bootstrap
|
curl -X POST http://127.0.0.1:8888/_admin/bootstrap
|
||||||
|
|
||||||
# Сброс (очистка очереди + результатов + in-flight)
|
# Сброс (очистка очереди + результатов + скриншотов)
|
||||||
curl -X POST http://127.0.0.1:8888/_admin/reset
|
curl -X POST http://127.0.0.1:8888/_admin/reset
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Админ-эндпойнты мока
|
## 8. Админ-эндпойнты мока
|
||||||
|
|
||||||
| Метод | Путь | Назначение |
|
| Метод | Путь | Назначение |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| POST | `/_admin/inject` | Положить задачу (или массив) в очередь |
|
| POST | `/_admin/inject` | Положить задачу (или массив) в очередь |
|
||||||
| POST | `/_admin/bootstrap` | Повторный бутстрап (1 FP + N FT на material) |
|
| POST | `/_admin/bootstrap` | Повторный бутстрап |
|
||||||
| POST | `/_admin/seed_profile` | Подменить ответ `current_profile` |
|
| POST | `/_admin/seed_profile` | Подменить ответ `current_profile` |
|
||||||
| POST | `/_admin/seed_devices` | Подменить список устройств |
|
| POST | `/_admin/seed_devices` | Подменить список устройств |
|
||||||
| GET | `/_admin/report` | JSON: полный отчёт задачи ↔ результаты |
|
| GET | `/_admin/report` | JSON: полный отчёт |
|
||||||
| GET | `/_admin/report/html` | HTML-отчёт с авто-обновлением |
|
| GET | `/_admin/report/html` | HTML-отчёт с авто-обновлением |
|
||||||
| GET | `/_admin/report/pending` | Только незавершённые задачи |
|
| GET | `/_admin/report/pending` | Только незавершённые задачи |
|
||||||
| GET | `/_admin/report/fragment` | JSON-фрагмент для JS-обновления |
|
| GET | `/_admin/report/fragment` | JSON-фрагмент для JS-обновления |
|
||||||
|
| GET | `/_admin/screenshot/<id>` | PNG скриншот по ID |
|
||||||
| GET | `/_admin/state` | Счётчики |
|
| GET | `/_admin/state` | Счётчики |
|
||||||
| POST | `/_admin/reset` | Очистить всё, сбросить бутстрап |
|
| POST | `/_admin/reset` | Очистить всё, сбросить бутстрап |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Полный цикл с нуля (новая машина / новые устройства)
|
## 9. Полный цикл с нуля
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Установка
|
# 1. Установка
|
||||||
pip install flask
|
pip install flask
|
||||||
|
|
||||||
# 2. Собрать ARC, запустить с боевым сервером → залогиниться → устройства,
|
# 2. Собрать ARC, запустить с боевым сервером → залогиниться
|
||||||
# банковские профили и материалы появятся в БД
|
# Устройства, профили и материалы появятся в БД
|
||||||
|
|
||||||
# 3. Переключить config.ini на мок
|
# 3. Переключить config.ini на мок
|
||||||
# [net]
|
# [net]
|
||||||
# apiBase=http://127.0.0.1:8888
|
# apiBase=http://127.0.0.1:8888
|
||||||
|
|
||||||
# 4. Сгенерировать стартовые данные из БД
|
# 4. Сгенерировать стартовые данные из БД
|
||||||
python tests/mock_server/gen_mock_data.py
|
python3 tests/mock_server/gen_mock_data.py
|
||||||
|
|
||||||
# 5. Открыть на устройстве банковское приложение → список транзакций
|
# 5. Открыть на устройстве банковское приложение → список транзакций
|
||||||
# Сгенерировать пул задач
|
# Сгенерировать пул задач
|
||||||
python tests/mock_server/gen_fetch_transactions.py
|
python3 tests/mock_server/gen_fetch_transactions.py
|
||||||
|
|
||||||
# 6. Запустить мок (все банки)
|
# 6. Запустить мок
|
||||||
cd tests/mock_server && python mock_server.py --port 8888
|
cd tests/mock_server && ./run.sh --banks ozon --debug
|
||||||
# или только определённые:
|
|
||||||
# cd tests/mock_server && python mock_server.py --port 8888 --banks ozon
|
|
||||||
|
|
||||||
# 7. Запустить ARC (в отдельном терминале)
|
# 7. Запустить ARC (из корня проекта, в отдельном терминале)
|
||||||
./build/ARC # macOS
|
./build_test/ARC
|
||||||
build\Release\ARC.exe # Windows
|
|
||||||
|
|
||||||
# 8. Открыть отчёт
|
# 8. Открыть отчёт
|
||||||
open http://127.0.0.1:8888/_admin/report/html
|
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 = всего в отчёте
|
# 9. Дождаться completed = всего в отчёте
|
||||||
|
|
||||||
# 10. Вернуть config.ini на прод
|
# 10. Вернуть config.ini на прод
|
||||||
# [net]
|
|
||||||
# apiBase=http://93.183.75.134:8005
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Структура файлов
|
## 10. Структура файлов
|
||||||
|
|
||||||
```
|
```
|
||||||
tests/mock_server/
|
tests/mock_server/
|
||||||
├── mock_server.py # Flask mock-сервер
|
├── 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_mock_data.py # генерация default_profile + scenarios из БД
|
||||||
├── gen_fetch_transactions.py # генерация pool из живого устройства
|
├── gen_fetch_transactions.py # генерация pool из живого устройства
|
||||||
├── inject.sh # curl-обёртка для инжекта
|
├── inject.sh # curl-обёртка для инжекта
|
||||||
├── inject_random.py # рандомный инжект из pool
|
|
||||||
├── default_profile.json # авто-генерированный профиль для мока
|
├── default_profile.json # авто-генерированный профиль для мока
|
||||||
├── seed_profile.example.json # пример ручного засева
|
|
||||||
├── README.md # эта инструкция
|
├── README.md # эта инструкция
|
||||||
├── WINDOWS.md # Windows-специфичные нюансы
|
├── WINDOWS.md # Windows-специфичные нюансы
|
||||||
├── scenarios/ # одиночные задачи (FETCH_PROFILE / FT с пустым body)
|
├── .mock_state.json # состояние задач (debug режим, .gitignore)
|
||||||
│ ├── pixel_7_ozon_fetch_profile.json
|
├── .mock_screenshots/ # скриншоты с устройств (.gitignore)
|
||||||
│ ├── pixel_7_ozon_fetch_transactions.json
|
├── scenarios/ # FETCH_PROFILE сценарии
|
||||||
│ ├── tecno_km4_ozon_fetch_profile.json
|
│ ├── pixel_7_ozon_7919_fetch_profile.json
|
||||||
|
│ ├── pixel_7_ozon_9865_fetch_profile.json
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── scenarios/pool/ # пул FETCH_TRANSACTIONS с реальными amount + created_at
|
└── scenarios/pool/ # пул FETCH_TRANSACTIONS
|
||||||
│ ├── pixel7_ozon_ft_01_evgeny_plus_100.json
|
├── pixel_7_ozon_7919_01_minus_108_ft_01.json
|
||||||
│ ├── tecno_km4_ozon_ft_01_galina_plus_100.json
|
├── pixel_7_ozon_9865_01_minus_108_ft_01.json
|
||||||
│ └── ...
|
├── pixel_7_dushanbe_city_bank_3258_01_plus_5_ft_01.json
|
||||||
└── fixtures/ # XML/PNG дампы экранов (справочно)
|
└── ...
|
||||||
├── tecno_ozon/
|
|
||||||
├── pixel7_ozon/
|
|
||||||
└── pixel7_dushanbe/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Типовые проблемы
|
## 11. Типовые проблемы
|
||||||
|
|
||||||
| Симптом | Причина | Решение |
|
| Симптом | Причина | Решение |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `config.ini not found` | Скрипт запущен не из корня проекта | `cd` в корень или `--config path` |
|
| `config.ini not found` | Скрипт запущен не из корня проекта | `cd` в корень или `--config path` |
|
||||||
| `No active materials` | БД пустая или профили не active | Запустить ARC с боевым сервером, залогиниться |
|
| `No active materials` | Нет профилей с проверенным PIN | Запустить ARC с боевым сервером, добавить PIN |
|
||||||
| `No ADB devices match` | Устройства не подключены или serial не совпадает | `adb devices`, проверить USB |
|
| `No ADB devices match` | Устройства не подключены | `adb devices`, проверить USB |
|
||||||
| Задача `pending` навсегда | material в in-flight (результат не пришёл) | `/_admin/reset` и перезапустить ARC |
|
| `App restarted` ошибки в отчёте | Events остались от прошлого запуска | Мок-сервер чистит events при старте; используйте `run.sh` или запускайте сервер **до** ARC |
|
||||||
| `Task expired: waited Ns` | Предыдущая задача выполнялась >5 мин | Мок отдаёт по 1 задаче — это уже фикс; убедиться, что `maxScrolls` разумный |
|
| Задача `pending` навсегда | material в `_in_flight` | `/_admin/reset` и перезапустить ARC |
|
||||||
| `Transaction not found` | Транзакции на устройстве не совпадают с pool | Перегенерировать: `gen_fetch_transactions.py` |
|
| `Transaction not found` | Баг поиска — скролл, видимость кнопок | Анализировать логи (device= в ошибке) |
|
||||||
| `Cancelled: replaced` | Норма для FETCH_PROFILE — дедупликация по device+bank | Не баг |
|
| `Page load error` | Ozon WebView не загрузился | Проблема интернета/устройства, таймаут 5 мин |
|
||||||
| Мок не получает результат | `sendTaskError` шёл в monitoring вместо task_result | Обновить ARC (фикс в EventHandler.cpp) |
|
| Скриншот не привязан | Нет `internal_task_id` | Обновить ARC (фикс в EventHandler.cpp) |
|
||||||
| Кракозябры в консоли (Win) | Кодировка | `chcp 65001` перед запуском |
|
| Pool material_id не совпадает | БД обновилась, pool устарел | Перегенерировать pool или обновить material_id в файлах |
|
||||||
| Порт занят | Другой процесс на 8888 | `--port 9999` + поменять `apiBase` |
|
| Порт занят | Другой процесс на 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-уведомления банков
|
- Webhook-уведомления банков
|
||||||
- Сетевые задержки/таймауты
|
- Сетевые задержки/таймауты
|
||||||
|
|||||||
@ -28,14 +28,11 @@ powershell -ExecutionPolicy Bypass -File setup.ps1
|
|||||||
4. Находит `config.ini` и проверяет **базу данных**
|
4. Находит `config.ini` и проверяет **базу данных**
|
||||||
5. Удаляет старые сгенерированные данные (профиль + scenarios, пул не трогает)
|
5. Удаляет старые сгенерированные данные (профиль + scenarios, пул не трогает)
|
||||||
6. Генерирует `default_profile.json` и FETCH_PROFILE scenarios из БД
|
6. Генерирует `default_profile.json` и FETCH_PROFILE scenarios из БД
|
||||||
7. Если порт занят — убивает старый процесс
|
7. Очищает таблицу `events` в БД
|
||||||
8. Переключает `config.ini` на mock (`apiBase=http://127.0.0.1:8888`)
|
8. Если порт занят — убивает старый процесс
|
||||||
9. Показывает сводку: устройства, профили, материалы, пул транзакций
|
9. Переключает `config.ini` на mock (`apiBase=http://127.0.0.1:8888`)
|
||||||
10. Запускает mock-сервер и открывает HTML-отчёт в браузере
|
10. Показывает сводку: устройства, профили, материалы, пул транзакций
|
||||||
|
11. Запускает mock-сервер и открывает HTML-отчёт в браузере
|
||||||
При первом логине ARC мок автоматически ставит **FETCH_PROFILE** задачи
|
|
||||||
на каждый материал. **FETCH_TRANSACTIONS** задачи нужно генерировать отдельно
|
|
||||||
(см. [раздел 3](#3-генерация-fetch_transactions-задач)).
|
|
||||||
|
|
||||||
### Параметры
|
### Параметры
|
||||||
|
|
||||||
@ -44,33 +41,23 @@ powershell -ExecutionPolicy Bypass -File setup.ps1
|
|||||||
| `-Port` | 8888 | Порт mock-сервера |
|
| `-Port` | 8888 | Порт mock-сервера |
|
||||||
| `-FtPerMaterial` | 3 | FETCH_TRANSACTIONS на материал при бутстрапе (из пула) |
|
| `-FtPerMaterial` | 3 | FETCH_TRANSACTIONS на материал при бутстрапе (из пула) |
|
||||||
| `-Banks` | все | Банки через запятую: `ozon`, `dushanbe_city_bank`, `black` |
|
| `-Banks` | все | Банки через запятую: `ozon`, `dushanbe_city_bank`, `black` |
|
||||||
|
| `-Types` | все | Типы задач: `FETCH_PROFILE`, `FETCH_TRANSACTIONS` |
|
||||||
| `-SkipInstall` | — | Пропустить установку Python/Flask |
|
| `-SkipInstall` | — | Пропустить установку Python/Flask |
|
||||||
| `-GenerateOnly` | — | Только сгенерировать данные, не запускать сервер |
|
| `-GenerateOnly` | — | Только сгенерировать данные, не запускать сервер |
|
||||||
| `-Clean` | — | Также очистить пул транзакций (`scenarios/pool/`) |
|
| `-Clean` | — | Также очистить пул транзакций (`scenarios/pool/`) |
|
||||||
|
| `-Debug` | — | Flask debug режим (auto-reload при изменении кода) |
|
||||||
|
|
||||||
### Примеры
|
### Примеры
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Всё автоматически (установка + генерация + запуск)
|
# Всё автоматически
|
||||||
.\setup.ps1
|
.\setup.ps1
|
||||||
|
|
||||||
# На другом порту с 5 транзакциями на материал
|
# Только Ozon, только FETCH_TRANSACTIONS
|
||||||
.\setup.ps1 -Port 9999 -FtPerMaterial 5
|
.\setup.ps1 -Banks ozon -Types FETCH_TRANSACTIONS
|
||||||
|
|
||||||
# Только Ozon
|
# Debug режим с 5 транзакциями на материал
|
||||||
.\setup.ps1 -Banks ozon
|
.\setup.ps1 -Banks ozon -FtPerMaterial 5 -Debug
|
||||||
|
|
||||||
# Только 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
|
|
||||||
|
|
||||||
# Полная очистка (включая пул транзакций)
|
# Полная очистка (включая пул транзакций)
|
||||||
.\setup.ps1 -Clean
|
.\setup.ps1 -Clean
|
||||||
@ -81,7 +68,7 @@ powershell -ExecutionPolicy Bypass -File setup.ps1
|
|||||||
## 2. Вспомогательные скрипты
|
## 2. Вспомогательные скрипты
|
||||||
|
|
||||||
После первого запуска `setup.ps1` в папке `tests\mock_server\` появляются
|
После первого запуска `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` |
|
| `run_server.ps1` | Запуск mock-сервера | `powershell -ExecutionPolicy Bypass -File run_server.ps1` |
|
||||||
| `setup.ps1` | Полная установка + генерация + запуск | `powershell -ExecutionPolicy Bypass -File setup.ps1` |
|
| `setup.ps1` | Полная установка + генерация + запуск | `powershell -ExecutionPolicy Bypass -File setup.ps1` |
|
||||||
|
|
||||||
Скрипты принимают дополнительные аргументы, которые передаются в Python-скрипт:
|
Скрипты принимают дополнительные аргументы:
|
||||||
|
|
||||||
```powershell
|
```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 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 задач
|
## 3. Генерация FETCH_TRANSACTIONS задач
|
||||||
|
|
||||||
FETCH_TRANSACTIONS задачи генерируются **только вручную** с живого устройства.
|
FETCH_TRANSACTIONS задачи генерируются **только вручную** с живого устройства.
|
||||||
Скрипт подключается по ADB, проходит по списку транзакций на экране
|
|
||||||
(тап -> снятие суммы/даты -> назад) и сохраняет JSON-файлы в `scenarios/pool/`.
|
|
||||||
|
|
||||||
### Подготовка
|
### Подготовка
|
||||||
|
|
||||||
@ -113,158 +98,74 @@ FETCH_TRANSACTIONS задачи генерируются **только вруч
|
|||||||
2. Убедитесь что ADB видит устройство:
|
2. Убедитесь что ADB видит устройство:
|
||||||
```powershell
|
```powershell
|
||||||
tools\adb\windows\adb.exe devices
|
tools\adb\windows\adb.exe devices
|
||||||
# Должно показать серийник со статусом "device"
|
|
||||||
```
|
```
|
||||||
3. Откройте на устройстве банковское приложение на экране **списка транзакций**
|
3. Откройте на устройстве банковское приложение на экране **списка транзакций**
|
||||||
- Ozon: раздел "Операции" -> список переводов/пополнений
|
|
||||||
- Dushanbe City Bank: раздел "Операции" -> список P2P операций
|
|
||||||
|
|
||||||
### Запуск
|
### Запуск
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Через вспомогательный скрипт (рекомендуется)
|
|
||||||
cd tests\mock_server
|
cd tests\mock_server
|
||||||
powershell -ExecutionPolicy Bypass -File gen_transactions.ps1
|
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):
|
pixel_7_ozon_7919_01_minus_108_ft_01.json
|
||||||
1. Pixel 7 / ozon / Галина Терехина (material 30bc106c... serial=28291FDH30xxxx)
|
pixel_7_ozon_9865_01_minus_108_ft_01.json
|
||||||
2. Pixel 7 / dushanbe_city_bank / Мехрубон (material 61d967d1...)
|
pixel_7_dushanbe_city_bank_3258_01_plus_5_ft_01.json
|
||||||
3. TECNO KM4 / ozon / Владислав Касаткин (material 49001bb5...)
|
|
||||||
Pick [1-3] or 'all':
|
|
||||||
```
|
```
|
||||||
2. Выберите нужный материал (номер) или `all` для всех
|
|
||||||
3. Скрипт попросит подтвердить что список транзакций открыт -> нажмите **Enter**
|
|
||||||
4. Автоматически пройдёт по каждой транзакции на экране (**не трогайте телефон!**)
|
|
||||||
5. Сохранит задачи в `scenarios/pool/`
|
|
||||||
6. Если mock-сервер запущен — сразу инжектнёт задачи в него
|
|
||||||
|
|
||||||
### Флаги
|
### Важно
|
||||||
|
|
||||||
| Флаг | Описание |
|
- Если у банка **два материала** (две карты), генерируются **отдельные файлы** для каждого
|
||||||
|---|---|
|
- При смене устройств/аккаунтов нужно перегенерировать pool — `material_id` в файлах должны совпадать с `default_profile.json`
|
||||||
| `--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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Результат
|
|
||||||
|
|
||||||
После генерации в `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`),
|
|
||||||
и мок будет раздавать задачи из него при бутстрапе.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Ручной запуск (пошагово)
|
## 4. Ручной запуск (пошагово)
|
||||||
|
|
||||||
Если не хотите использовать `setup.ps1`:
|
|
||||||
|
|
||||||
### 4.1. Установка зависимостей
|
### 4.1. Установка зависимостей
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Python 3.9+ (если нет — скачать с python.org, включить «Add to PATH»)
|
python --version # Python 3.9+
|
||||||
python --version
|
|
||||||
|
|
||||||
# Flask
|
|
||||||
pip install flask
|
pip install flask
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.2. Подготовка config.ini
|
### 4.2. Подготовка config.ini
|
||||||
|
|
||||||
В `config.ini` (рядом с `ARC.exe`) переключить `apiBase`:
|
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[net]
|
[net]
|
||||||
apiBase=http://127.0.0.1:8888
|
apiBase=http://127.0.0.1:8888
|
||||||
```
|
```
|
||||||
|
|
||||||
> Совет: держите копию `config.ini.mock`, а перед тестом
|
### 4.3. Генерация данных + запуск
|
||||||
> `copy config.ini.mock config.ini`.
|
|
||||||
|
|
||||||
### 4.3. Генерация профиля из БД
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Из корня проекта
|
# Генерация профиля
|
||||||
python tests\mock_server\gen_mock_data.py
|
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
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
Должны увидеть:
|
### 4.4. Запуск ARC
|
||||||
```
|
|
||||||
[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
|
|
||||||
|
|
||||||
В отдельном терминале:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd build\Release
|
cd C:\ARC\Ver.18 # или build\Release
|
||||||
ARC.exe
|
ARC.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
Или через CLion: Run -> ARC.
|
|
||||||
|
|
||||||
При логине мок автоматически ставит задачи:
|
|
||||||
- 1 x FETCH_PROFILE на каждый материал (всегда)
|
|
||||||
- N x FETCH_TRANSACTIONS из пула (если пул не пуст)
|
|
||||||
|
|
||||||
Задачи раздаются по 1 на material за поллинг — следующая только
|
|
||||||
после результата предыдущей.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Мониторинг и отчёт
|
## 5. Мониторинг и отчёт
|
||||||
@ -272,46 +173,37 @@ ARC.exe
|
|||||||
```powershell
|
```powershell
|
||||||
# Открыть отчёт в браузере (авто-обновление каждые 2 сек)
|
# Открыть отчёт в браузере (авто-обновление каждые 2 сек)
|
||||||
start http://127.0.0.1:8888/_admin/report/html
|
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
|
```powershell
|
||||||
# Счётчики
|
# Счётчики
|
||||||
(Invoke-WebRequest http://127.0.0.1:8888/_admin/state).Content | ConvertFrom-Json
|
(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. Ручной инжект задач
|
## 6. Ручной инжект задач
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Одна задача из файла
|
# Одна задача
|
||||||
curl -X POST http://127.0.0.1:8888/_admin/inject ^
|
curl -X POST http://127.0.0.1:8888/_admin/inject ^
|
||||||
-H "Content-Type: application/json" ^
|
-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/bootstrap
|
||||||
|
|
||||||
# Сброс (очистка очереди + результатов)
|
# Сброс (очистка всего + скриншоты)
|
||||||
curl -X POST http://127.0.0.1:8888/_admin/reset
|
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
|
## 7. ADB на Windows
|
||||||
|
|
||||||
ARC использует ADB из `tools\adb\windows\`. Проверка:
|
ARC использует ADB из `tools\adb\windows\`:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
tools\adb\windows\adb.exe devices
|
tools\adb\windows\adb.exe devices
|
||||||
```
|
```
|
||||||
|
|
||||||
Должно показать устройства в статусе `device`.
|
Если ADB занят:
|
||||||
|
|
||||||
Если ADB занят другим процессом (Android Studio, scrcpy):
|
|
||||||
```powershell
|
```powershell
|
||||||
tools\adb\windows\adb.exe kill-server
|
tools\adb\windows\adb.exe kill-server
|
||||||
tools\adb\windows\adb.exe start-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` — в них путь уже зашит |
|
| `python` не найден | Запустите `setup.ps1` — установит автоматически |
|
||||||
| Порт 8888 занят | `setup.ps1` убивает старый процесс автоматически. Вручную: `netstat -ano \| findstr :8888` -> `taskkill /PID <pid> /F` |
|
| Порт 8888 занят | `setup.ps1` убивает автоматически. Вручную: `netstat -ano \| findstr :8888` → `taskkill /PID <pid> /F` |
|
||||||
| `curl` нет | `Invoke-WebRequest` (PowerShell) или `winget install curl` |
|
| `App restarted` ошибки | Мок-сервер чистит events в БД при старте. Запускайте сервер **до** ARC |
|
||||||
| Firewall блокирует | Разрешить `python.exe` в Windows Firewall. Мок слушает только `127.0.0.1` |
|
| Pool material_id не совпадает | `gen_mock_data.py` обновил material_id. Перегенерируйте pool |
|
||||||
| ARC не видит устройства | Проверить USB-драйверы, включить USB debugging, подтвердить RSA-ключ |
|
| `Transaction not found` | Баг поиска — анализировать логи (device= в ошибке) |
|
||||||
| Кракозябры в консоли | `chcp 65001` перед запуском (UTF-8). Скрипты `.ps1` делают это автоматически |
|
| `Page load error` | Ozon WebView не загрузился — проблема интернета/устройства |
|
||||||
| BOM в config.ini | `setup.ps1` пишет без BOM. Если правили вручную — пересохраните в UTF-8 без BOM (VS Code) |
|
| Скриншот не привязался | Нет `internal_task_id` — обновить ARC |
|
||||||
| No active materials | БД пустая. Запустите ARC с боевым сервером, залогиньтесь, добавьте устройства |
|
| Кракозябры в консоли | `chcp 65001` (UTF-8). Скрипты `.ps1` делают автоматически |
|
||||||
| Пул транзакций пуст | Сгенерируйте: [раздел 3](#3-генерация-fetch_transactions-задач) |
|
| No active materials | Нет профилей с проверенным PIN. Запустить ARC с боевым сервером |
|
||||||
| `No ADB devices match` | Устройства не подключены или serial не совпадает с БД. Проверьте `adb devices` |
|
| Firewall | Разрешить `python.exe`. Мок слушает только `127.0.0.1` |
|
||||||
| SSL ошибки при сборке | OpenSSL DLL копируются автоматически через CMake post-build |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Быстрый чеклист
|
## 9. Быстрый чеклист
|
||||||
|
|
||||||
```
|
```
|
||||||
[ ] powershell -ExecutionPolicy Bypass -File setup.ps1
|
[ ] powershell -ExecutionPolicy Bypass -File setup.ps1 -Banks ozon
|
||||||
[ ] Устройства в adb devices -> device
|
[ ] Устройства в adb devices → device
|
||||||
[ ] (опционально) powershell -ExecutionPolicy Bypass -File gen_transactions.ps1
|
[ ] (опционально) gen_transactions.ps1 для пула транзакций
|
||||||
[ ] ARC.exe запущен (отдельный терминал)
|
[ ] ARC.exe запущен (отдельный терминал)
|
||||||
[ ] Браузер: http://127.0.0.1:8888/_admin/report/html
|
[ ] Браузер: http://127.0.0.1:8888/_admin/report/html
|
||||||
[ ] Дождаться completed = всего в отчёте
|
[ ] Дождаться completed = всего в отчёте
|
||||||
|
|||||||
0
tests/mock_server/application.log
Normal file
0
tests/mock_server/application.log
Normal file
1
tests/mock_server/debug_detail.xml
Normal file
1
tests/mock_server/debug_detail.xml
Normal file
File diff suppressed because one or more lines are too long
@ -1,7 +1,27 @@
|
|||||||
{
|
{
|
||||||
"bank_profiles": [
|
"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",
|
"bank_name": "ozon",
|
||||||
"state": "enabled",
|
"state": "enabled",
|
||||||
"phone_number": "+79538038318",
|
"phone_number": "+79538038318",
|
||||||
@ -9,22 +29,42 @@
|
|||||||
"middle_name": null,
|
"middle_name": null,
|
||||||
"last_name": "Терехина",
|
"last_name": "Терехина",
|
||||||
"currency": 643,
|
"currency": 643,
|
||||||
"device_id": "c9d3ad7e-659d-4adf-815e-9f234e743078",
|
"device_id": "986cb259-dfec-4421-8735-4fb126cd1917",
|
||||||
"device_label": "Pixel 7",
|
"device_label": "Pixel 7",
|
||||||
"materials": [
|
"materials": [
|
||||||
{
|
{
|
||||||
"id": "e6c6e12a-3e4e-444c-9e11-62543fb5f4f9",
|
"id": "50393cc1-57e4-4293-9a59-6cdd1d9ce6cb",
|
||||||
"state": "enabled",
|
"state": "enabled",
|
||||||
"card_number": "2204321035207919",
|
"card_number": "2204321035207919",
|
||||||
"currency": 643
|
"currency": 643
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "39592a22-3e79-4438-89d7-778aea72a5e9",
|
"id": "06a1ae15-abd2-4d90-aac0-77f4a4fd32e8",
|
||||||
"state": "enabled",
|
"state": "enabled",
|
||||||
"card_number": "2204321086109865",
|
"card_number": "2204321086109865",
|
||||||
"currency": 643
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
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"
|
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")
|
cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
dedup_key = (amount, cat)
|
dedup_key = (amount, cat)
|
||||||
if dedup_key in processed_keys:
|
if dedup_key in processed_keys:
|
||||||
|
|||||||
@ -89,10 +89,11 @@ def query_active_materials(db_path: Path):
|
|||||||
FROM devices d
|
FROM devices d
|
||||||
JOIN bank_profiles bp ON bp.device_id = d.id
|
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
|
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 m.status = 'active'
|
AND bp.pin_code_checked = 'yes'
|
||||||
AND m.material_id != ''
|
AND m.status = 'active'
|
||||||
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
|
ORDER BY d.name, bp.code
|
||||||
""").fetchall()
|
""").fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@ -40,7 +40,10 @@ Usage:
|
|||||||
import argparse
|
import argparse
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@ -75,6 +78,51 @@ _in_flight: set = set() # material_ids with a delivered-but-not-yet-resulted ta
|
|||||||
_bootstrap_done = False
|
_bootstrap_done = False
|
||||||
_bootstrap_ft_count = 3
|
_bootstrap_ft_count = 3
|
||||||
_bootstrap_banks: list | None = None # None = all banks
|
_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):
|
def ok(data=None):
|
||||||
@ -173,25 +221,28 @@ def bootstrap_tasks():
|
|||||||
if not mid:
|
if not mid:
|
||||||
continue
|
continue
|
||||||
# 1 × FETCH_PROFILE
|
# 1 × FETCH_PROFILE
|
||||||
enqueue_task({
|
if not _bootstrap_types or "FETCH_PROFILE" in _bootstrap_types:
|
||||||
"bank_name": bank,
|
enqueue_task({
|
||||||
"material_id": mid,
|
"bank_name": bank,
|
||||||
"type": "FETCH_PROFILE",
|
"material_id": mid,
|
||||||
"body": {},
|
"type": "FETCH_PROFILE",
|
||||||
"_source": "bootstrap",
|
"body": {},
|
||||||
})
|
"_source": "bootstrap",
|
||||||
queued += 1
|
})
|
||||||
|
queued += 1
|
||||||
|
|
||||||
# N × random FETCH_TRANSACTIONS from pool
|
# N × random FETCH_TRANSACTIONS from pool
|
||||||
pool = _pool_by_material.get(mid, [])
|
if not _bootstrap_types or "FETCH_TRANSACTIONS" in _bootstrap_types:
|
||||||
if not pool:
|
pool = _pool_by_material.get(mid, [])
|
||||||
print(f"[mock] bootstrap: no pool tasks for {mid} ({bank})")
|
if not pool:
|
||||||
continue
|
print(f"[mock] bootstrap: no pool tasks for {mid} ({bank})")
|
||||||
pick = random.sample(pool, min(_bootstrap_ft_count, len(pool)))
|
continue
|
||||||
for t in pick:
|
pick = random.sample(pool, min(_bootstrap_ft_count, len(pool)))
|
||||||
enqueue_task(t)
|
for t in pick:
|
||||||
queued += 1
|
enqueue_task(t)
|
||||||
|
queued += 1
|
||||||
_bootstrap_done = True
|
_bootstrap_done = True
|
||||||
|
_save_state()
|
||||||
print(f"[mock] bootstrap queued {queued} tasks")
|
print(f"[mock] bootstrap queued {queued} tasks")
|
||||||
|
|
||||||
|
|
||||||
@ -359,6 +410,7 @@ def get_tasks():
|
|||||||
_in_flight.add(mid)
|
_in_flight.add(mid)
|
||||||
_task_queue.extend(kept)
|
_task_queue.extend(kept)
|
||||||
if delivered:
|
if delivered:
|
||||||
|
_save_state()
|
||||||
log("GET /task/get_tasks -> delivered",
|
log("GET /task/get_tasks -> delivered",
|
||||||
[{"type": t.get("type"), "material_id": t.get("material_id")}
|
[{"type": t.get("type"), "material_id": t.get("material_id")}
|
||||||
for t in delivered])
|
for t in delivered])
|
||||||
@ -393,15 +445,72 @@ def task_result():
|
|||||||
"result_at": time.time(),
|
"result_at": time.time(),
|
||||||
"status": "unsolicited",
|
"status": "unsolicited",
|
||||||
})
|
})
|
||||||
|
_save_state()
|
||||||
return ok({})
|
return ok({})
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- monitoring
|
# ---------------------------------------------------------------- monitoring
|
||||||
|
_monitoring_logs: list = [] # {type, event, message, has_image, image_id}
|
||||||
|
_SCREENSHOTS_DIR = HERE / ".mock_screenshots"
|
||||||
|
|
||||||
|
|
||||||
@app.post("/monitoring/log")
|
@app.post("/monitoring/log")
|
||||||
def 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({})
|
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
|
# ---------------------------------------------------------------- admin
|
||||||
@app.post("/_admin/inject")
|
@app.post("/_admin/inject")
|
||||||
def admin_inject():
|
def admin_inject():
|
||||||
@ -445,10 +554,12 @@ def admin_bootstrap():
|
|||||||
def admin_report():
|
def admin_report():
|
||||||
"""Full task ↔ result pairing report."""
|
"""Full task ↔ result pairing report."""
|
||||||
with _lock:
|
with _lock:
|
||||||
|
errors = sum(1 for e in _injected if e["status"] == "completed" and _is_error_entry(e))
|
||||||
summary = {
|
summary = {
|
||||||
"total": len(_injected),
|
"total": len(_injected),
|
||||||
"pending": sum(1 for e in _injected if e["status"] == "pending"),
|
"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"),
|
"unsolicited": sum(1 for e in _injected if e["status"] == "unsolicited"),
|
||||||
}
|
}
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@ -463,12 +574,25 @@ def admin_report_pending():
|
|||||||
return jsonify([e for e in _injected if e["status"] == "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():
|
def _snapshot_entries_and_summary():
|
||||||
entries = list(_injected)
|
entries = list(_injected)
|
||||||
|
errors = sum(1 for e in entries if e["status"] == "completed" and _is_error_entry(e))
|
||||||
summary = {
|
summary = {
|
||||||
"total": len(entries),
|
"total": len(entries),
|
||||||
"pending": sum(1 for e in entries if e["status"] == "pending"),
|
"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"),
|
"unsolicited": sum(1 for e in entries if e["status"] == "unsolicited"),
|
||||||
}
|
}
|
||||||
return entries, summary
|
return entries, summary
|
||||||
@ -487,33 +611,7 @@ def admin_report_fragment():
|
|||||||
"""JSON fragment for JS auto-refresh: rendered body HTML + summary counts."""
|
"""JSON fragment for JS auto-refresh: rendered body HTML + summary counts."""
|
||||||
with _lock:
|
with _lock:
|
||||||
entries, summary = _snapshot_entries_and_summary()
|
entries, summary = _snapshot_entries_and_summary()
|
||||||
# Render just the grouped body (without the full page shell)
|
body = _render_grouped_blocks(entries) or '<div class="empty">Отчёт пуст</div>'
|
||||||
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>'
|
|
||||||
return jsonify({"html": body, "summary": summary})
|
return jsonify({"html": body, "summary": summary})
|
||||||
|
|
||||||
|
|
||||||
@ -531,6 +629,8 @@ def _esc(s: str) -> str:
|
|||||||
def render_entry_html(e) -> str:
|
def render_entry_html(e) -> str:
|
||||||
import html as _html
|
import html as _html
|
||||||
status = e["status"]
|
status = e["status"]
|
||||||
|
is_error = _is_error_entry(e)
|
||||||
|
display_status = "error" if is_error else status
|
||||||
task = e.get("task") or {}
|
task = e.get("task") or {}
|
||||||
result = e.get("result")
|
result = e.get("result")
|
||||||
ttype = _html.escape(task.get("type", "?"))
|
ttype = _html.escape(task.get("type", "?"))
|
||||||
@ -540,41 +640,70 @@ def render_entry_html(e) -> str:
|
|||||||
injected_at = _fmt_ts(e.get("injected_at"))
|
injected_at = _fmt_ts(e.get("injected_at"))
|
||||||
result_at = _fmt_ts(e.get("result_at"))
|
result_at = _fmt_ts(e.get("result_at"))
|
||||||
|
|
||||||
|
tid = _html.escape(e.get("id", ""))
|
||||||
|
|
||||||
def json_block(obj):
|
def json_block(obj):
|
||||||
if obj is None:
|
if obj is None:
|
||||||
return '<span class="muted">—</span>'
|
return '<span class="muted">—</span>'
|
||||||
text = json.dumps(obj, ensure_ascii=False, indent=2)
|
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 = ""
|
result_summary = ""
|
||||||
if result and isinstance(result.get("data"), dict):
|
if is_error:
|
||||||
d = result["data"]
|
d = result.get("data", {}) if result else {}
|
||||||
if d.get("status") == "error":
|
result_summary = f'<div class="err">ERROR: {_html.escape(str(d.get("description", "")))}</div>'
|
||||||
result_summary = f'<div class="err">ERROR: {_html.escape(str(d.get("description", "")))}</div>'
|
|
||||||
elif result and isinstance(result.get("data"), list):
|
elif result and isinstance(result.get("data"), list):
|
||||||
result_summary = f'<div class="muted">{len(result["data"])} items</div>'
|
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"""
|
return f"""
|
||||||
<div class="entry {status}">
|
<div class="entry {display_status}">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<span class="badge {status}">{status}</span>
|
<span class="badge {display_status}">{display_status}</span>
|
||||||
<span class="type">{ttype}</span>
|
<span class="type">{ttype}</span>
|
||||||
<span class="bank">{bank}</span>
|
<span class="bank">{bank}</span>
|
||||||
<span class="mid">{mid}…</span>
|
<span class="mid">{mid}…</span>
|
||||||
<span class="src">{src}</span>
|
<span class="src">{src}</span>
|
||||||
<span class="times">inj {injected_at} → res {result_at}</span>
|
<span class="times">inj {injected_at} → res {result_at}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="panes">
|
{panes_html}
|
||||||
<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>
|
|
||||||
</div>
|
</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
|
import html as _html
|
||||||
|
|
||||||
# Group by device label
|
# Group by device label → bank_name
|
||||||
groups: dict = defaultdict(list)
|
devices: dict = defaultdict(lambda: defaultdict(list))
|
||||||
for e in entries:
|
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 (
|
label = _device_label_by_material.get(mid) or (
|
||||||
"(без устройства)" if mid is None else f"{mid[:8]}…"
|
"(без устройства)" 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 = []
|
group_blocks = []
|
||||||
for label in sorted(groups.keys()):
|
for label in sorted(devices.keys()):
|
||||||
gentries = groups[label]
|
banks = devices[label]
|
||||||
g_total = len(gentries)
|
# Device-level stats
|
||||||
g_done = sum(1 for e in gentries if e["status"] == "completed")
|
all_entries = [e for bl in banks.values() for e in bl]
|
||||||
g_pend = sum(1 for e in gentries if e["status"] == "pending")
|
g_total = len(all_entries)
|
||||||
rows_html = "\n".join(render_entry_html(e) for e in gentries)
|
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"""
|
group_blocks.append(f"""
|
||||||
<section class="group">
|
<section class="group">
|
||||||
<h2>
|
<h2>
|
||||||
@ -696,14 +852,19 @@ def render_report_html(entries, summary) -> str:
|
|||||||
<span class="g-stats">
|
<span class="g-stats">
|
||||||
<span class="g-done">{g_done}</span>
|
<span class="g-done">{g_done}</span>
|
||||||
/ <span class="g-total">{g_total}</span>
|
/ <span class="g-total">{g_total}</span>
|
||||||
|
{err_badge}
|
||||||
<span class="muted">(ожидают: {g_pend})</span>
|
<span class="muted">(ожидают: {g_pend})</span>
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
{rows_html}
|
{"".join(bank_blocks)}
|
||||||
</section>
|
</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>
|
return f"""<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
@ -751,6 +912,7 @@ def render_report_html(entries, summary) -> str:
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}}
|
}}
|
||||||
.entry.completed {{ border-left: 4px solid var(--completed); }}
|
.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.pending {{ border-left: 4px solid var(--pending); }}
|
||||||
.entry.unsolicited {{ border-left: 4px solid var(--unsolicited); }}
|
.entry.unsolicited {{ border-left: 4px solid var(--unsolicited); }}
|
||||||
.head {{
|
.head {{
|
||||||
@ -764,6 +926,7 @@ def render_report_html(entries, summary) -> str:
|
|||||||
}}
|
}}
|
||||||
.badge.pending {{ background: var(--pending); color: #1a1600; }}
|
.badge.pending {{ background: var(--pending); color: #1a1600; }}
|
||||||
.badge.completed {{ background: var(--completed); color: #0a1a0a; }}
|
.badge.completed {{ background: var(--completed); color: #0a1a0a; }}
|
||||||
|
.badge.error {{ background: var(--unsolicited); color: #200; }}
|
||||||
.badge.unsolicited {{ background: var(--unsolicited); color: #200; }}
|
.badge.unsolicited {{ background: var(--unsolicited); color: #200; }}
|
||||||
.type {{ font-weight: 600; color: #9cdcfe; }}
|
.type {{ font-weight: 600; color: #9cdcfe; }}
|
||||||
.bank {{ color: #ffb86c; }}
|
.bank {{ color: #ffb86c; }}
|
||||||
@ -771,6 +934,7 @@ def render_report_html(entries, summary) -> str:
|
|||||||
.src {{ color: var(--muted); font-style: italic; }}
|
.src {{ color: var(--muted); font-style: italic; }}
|
||||||
.times {{ color: var(--muted); margin-left: auto; font-family: monospace; }}
|
.times {{ color: var(--muted); margin-left: auto; font-family: monospace; }}
|
||||||
.panes {{ display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border); }}
|
.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 {{ padding: 10px 12px; }}
|
||||||
.pane.task {{ background: var(--task); }}
|
.pane.task {{ background: var(--task); }}
|
||||||
.pane.result {{ background: var(--result); }}
|
.pane.result {{ background: var(--result); }}
|
||||||
@ -785,6 +949,37 @@ def render_report_html(entries, summary) -> str:
|
|||||||
}}
|
}}
|
||||||
.muted {{ color: var(--muted); font-style: italic; }}
|
.muted {{ color: var(--muted); font-style: italic; }}
|
||||||
.err {{ color: #ff6b6b; font-weight: 600; margin-bottom: 4px; }}
|
.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; }}
|
.empty {{ color: var(--muted); text-align: center; padding: 40px; }}
|
||||||
a {{ color: #9cdcfe; }}
|
a {{ color: #9cdcfe; }}
|
||||||
.group {{
|
.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-stats {{ margin-left: auto; font-family: monospace; font-size: 13px; }}
|
||||||
.group h2 .g-done {{ color: var(--completed); font-weight: 700; }}
|
.group h2 .g-done {{ color: var(--completed); font-weight: 700; }}
|
||||||
.group h2 .g-total {{ color: var(--muted); }}
|
.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; }}
|
.updating {{ opacity: 0.6; }}
|
||||||
/* ─── Profile section ─── */
|
/* ─── Profile section ─── */
|
||||||
.profile-section {{
|
.profile-section {{
|
||||||
@ -891,8 +1102,8 @@ def render_report_html(entries, summary) -> str:
|
|||||||
<div class="summary">
|
<div class="summary">
|
||||||
<div class="cell"><div class="n">{summary['total']}</div>всего</div>
|
<div class="cell"><div class="n">{summary['total']}</div>всего</div>
|
||||||
<div class="cell completed"><div class="n">{summary['completed']}</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 pending"><div class="n">{summary['pending']}</div>в очереди</div>
|
||||||
<div class="cell unsolicited"><div class="n">{summary['unsolicited']}</div>незапрошенные</div>
|
|
||||||
</div>
|
</div>
|
||||||
{render_profile_section()}
|
{render_profile_section()}
|
||||||
<div id="report-body">
|
<div id="report-body">
|
||||||
@ -906,20 +1117,48 @@ def render_report_html(entries, summary) -> str:
|
|||||||
const summaryEls = {{
|
const summaryEls = {{
|
||||||
total: document.querySelector('.summary .cell:nth-child(1) .n'),
|
total: document.querySelector('.summary .cell:nth-child(1) .n'),
|
||||||
completed: document.querySelector('.summary .cell.completed .n'),
|
completed: document.querySelector('.summary .cell.completed .n'),
|
||||||
|
errors: document.querySelector('.summary .cell.unsolicited .n'),
|
||||||
pending: document.querySelector('.summary .cell.pending .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() {{
|
async function tick() {{
|
||||||
try {{
|
try {{
|
||||||
const resp = await fetch('/_admin/report/fragment', {{cache: 'no-store'}});
|
const resp = await fetch('/_admin/report/fragment', {{cache: 'no-store'}});
|
||||||
if (!resp.ok) throw new Error(resp.status);
|
if (!resp.ok) throw new Error(resp.status);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
const saved = saveToggleState();
|
||||||
bodyEl.innerHTML = data.html;
|
bodyEl.innerHTML = data.html;
|
||||||
|
restoreToggleState(saved);
|
||||||
summaryEls.total.textContent = data.summary.total;
|
summaryEls.total.textContent = data.summary.total;
|
||||||
summaryEls.completed.textContent = data.summary.completed;
|
summaryEls.completed.textContent = data.summary.completed;
|
||||||
|
summaryEls.errors.textContent = data.summary.errors;
|
||||||
summaryEls.pending.textContent = data.summary.pending;
|
summaryEls.pending.textContent = data.summary.pending;
|
||||||
summaryEls.unsolicited.textContent = data.summary.unsolicited;
|
|
||||||
statusEl.textContent = '(обновлено ' + new Date().toLocaleTimeString() + ')';
|
statusEl.textContent = '(обновлено ' + new Date().toLocaleTimeString() + ')';
|
||||||
}} catch (e) {{
|
}} catch (e) {{
|
||||||
statusEl.textContent = '(ошибка обновления: ' + e + ')';
|
statusEl.textContent = '(ошибка обновления: ' + e + ')';
|
||||||
@ -929,6 +1168,31 @@ def render_report_html(entries, summary) -> str:
|
|||||||
setInterval(tick, REFRESH_MS);
|
setInterval(tick, REFRESH_MS);
|
||||||
}})();
|
}})();
|
||||||
</script>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
@ -942,7 +1206,12 @@ def admin_reset():
|
|||||||
_injected.clear()
|
_injected.clear()
|
||||||
_pending_by_key.clear()
|
_pending_by_key.clear()
|
||||||
_in_flight.clear()
|
_in_flight.clear()
|
||||||
|
_monitoring_logs.clear()
|
||||||
_bootstrap_done = False
|
_bootstrap_done = False
|
||||||
|
if _SCREENSHOTS_DIR.exists():
|
||||||
|
shutil.rmtree(_SCREENSHOTS_DIR)
|
||||||
|
if _STATE_FILE.exists():
|
||||||
|
_STATE_FILE.unlink()
|
||||||
return ok({})
|
return ok({})
|
||||||
|
|
||||||
|
|
||||||
@ -970,24 +1239,68 @@ if __name__ == "__main__":
|
|||||||
help="Only bootstrap tasks for these banks (e.g. --banks ozon dushanbe_city_bank)")
|
help="Only bootstrap tasks for these banks (e.g. --banks ozon dushanbe_city_bank)")
|
||||||
parser.add_argument("--seed", type=int, default=None,
|
parser.add_argument("--seed", type=int, default=None,
|
||||||
help="RNG seed for reproducible bootstrap picks")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
_bootstrap_ft_count = args.ft_per_material
|
_bootstrap_ft_count = args.ft_per_material
|
||||||
_bootstrap_banks = args.banks
|
_bootstrap_banks = args.banks
|
||||||
|
_bootstrap_types = args.types
|
||||||
if _bootstrap_banks:
|
if _bootstrap_banks:
|
||||||
print(f"[mock] bootstrap limited to banks: {_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:
|
if args.seed is not None:
|
||||||
random.seed(args.seed)
|
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_default_profile()
|
||||||
load_pool()
|
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)
|
||||||
|
|||||||
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"bank_name": "dushanbe_city_bank",
|
||||||
|
"material_id": "36002ac2-6f4d-49da-8892-18db723a31c7",
|
||||||
|
"type": "FETCH_PROFILE",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"bank_name": "ozon",
|
||||||
|
"material_id": "50393cc1-57e4-4293-9a59-6cdd1d9ce6cb",
|
||||||
|
"type": "FETCH_PROFILE",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"bank_name": "ozon",
|
||||||
|
"material_id": "06a1ae15-abd2-4d90-aac0-77f4a4fd32e8",
|
||||||
|
"type": "FETCH_PROFILE",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"bank_name": "ozon",
|
||||||
|
"material_id": "7e3a7e4e-0d56-4d66-ac44-2b240ec33620",
|
||||||
|
"type": "FETCH_PROFILE",
|
||||||
|
"body": {}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user