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
ce14d5eef3
commit
834e56e503
4
.gitignore
vendored
4
.gitignore
vendored
@ -6,6 +6,10 @@
|
||||
.DS_Store
|
||||
.idea/
|
||||
|
||||
# Mock server state
|
||||
tests/mock_server/.mock_state.json
|
||||
tests/mock_server/.mock_screenshots/
|
||||
|
||||
cmake_install.cmake
|
||||
CMakeCache.txt
|
||||
Makefile
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
#include <QString>
|
||||
#include <QCoreApplication>
|
||||
#include <QFileInfo>
|
||||
#include <QRegularExpression>
|
||||
#include <QThread>
|
||||
|
||||
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'
|
||||
QString AdbUtils::getScreenDumpAsXml(const QString &deviceId, int idx, bool screenshot) {
|
||||
const QString adb = adbPath();
|
||||
QString output;
|
||||
|
||||
// 1. Dump UI hierarchy (retry up to 3 times — uiautomator may be killed by Android)
|
||||
bool dumpOk = false;
|
||||
for (int attempt = 0; attempt < 3; ++attempt) {
|
||||
if (startProcess(adb, {"-s", deviceId, "shell", "uiautomator", "dump", "/sdcard/uidump.xml"})) {
|
||||
dumpOk = true;
|
||||
break;
|
||||
for (int retry = 0; retry < 3; ++retry) {
|
||||
// 1. Dump UI hierarchy (retry up to 3 times — uiautomator may be killed by Android)
|
||||
bool dumpOk = false;
|
||||
for (int attempt = 0; attempt < 3; ++attempt) {
|
||||
if (startProcess(adb, {"-s", deviceId, "shell", "uiautomator", "dump", "/sdcard/uidump.xml"})) {
|
||||
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";
|
||||
QThread::msleep(1000);
|
||||
}
|
||||
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.";
|
||||
if (!dumpOk) {
|
||||
qWarning() << "Failed to dump UI hierarchy after 3 attempts.";
|
||||
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 {};
|
||||
// 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 {};
|
||||
}
|
||||
}
|
||||
// 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
|
||||
startProcess(adb, {"-s", deviceId, "shell", "rm", "/sdcard/uidump.xml"}); // неважно, успешна ли команда
|
||||
|
||||
// 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);
|
||||
qWarning() << "[AdbUtils] getScreenDumpAsXml: empty dump after 3 retries";
|
||||
return {};
|
||||
}
|
||||
|
||||
bool AdbUtils::takeScreenshot(const QString &deviceId, const QString &name) {
|
||||
|
||||
@ -331,9 +331,9 @@ void GetLastTransactionsScript::doStart() {
|
||||
// Pull-to-refresh: свайп сверху вниз для обновления списка
|
||||
{
|
||||
const int x = device.screenWidth / 2;
|
||||
const int fromY = device.screenHeight / 4;
|
||||
const int toY = device.screenHeight * 3 / 4;
|
||||
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY);
|
||||
const int fromY = device.screenHeight / 3;
|
||||
const int toY = device.screenHeight * 2 / 3;
|
||||
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500);
|
||||
QThread::msleep(3000);
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] Pull-to-refresh on Выписка";
|
||||
}
|
||||
@ -369,7 +369,7 @@ void GetLastTransactionsScript::doStart() {
|
||||
|
||||
// Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства
|
||||
const QTimeZone tjTz("Asia/Dushanbe");
|
||||
const int maxScrolls = 4;
|
||||
const int maxScrolls = 10;
|
||||
|
||||
auto interrupted = []() {
|
||||
return QThread::currentThread()->isInterruptionRequested();
|
||||
@ -379,6 +379,7 @@ void GetLastTransactionsScript::doStart() {
|
||||
double amount = 0.0;
|
||||
QString time; // "21:31:21"
|
||||
QString date; // "05.04.2026"
|
||||
bool dateReliable = false; // true если дата из content-desc (P2P), false если из groupDates
|
||||
Node node;
|
||||
};
|
||||
|
||||
@ -462,7 +463,7 @@ void GetLastTransactionsScript::doStart() {
|
||||
it.time = last;
|
||||
it.node = n;
|
||||
|
||||
// В P2P формате дата встроена в первую строку
|
||||
// В P2P формате дата встроена в первую строку — надёжная
|
||||
if (isP2P) {
|
||||
if (firstLine.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0)
|
||||
it.date = todayLocal.toString("dd.MM.yyyy");
|
||||
@ -470,6 +471,7 @@ void GetLastTransactionsScript::doStart() {
|
||||
it.date = todayLocal.addDays(-1).toString("dd.MM.yyyy");
|
||||
else
|
||||
it.date = firstLine;
|
||||
it.dateReliable = true;
|
||||
}
|
||||
items.append(it);
|
||||
}
|
||||
@ -537,8 +539,15 @@ void GetLastTransactionsScript::doStart() {
|
||||
prevY2 = it.node.y2();
|
||||
}
|
||||
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] Vypiska items:" << items.size()
|
||||
<< "groups:" << groupDates;
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] scroll=" << scroll
|
||||
<< "items:" << items.size() << "groups:" << groupDates
|
||||
<< "hasEmbedded:" << hasEmbedded;
|
||||
// Логируем все items для отладки
|
||||
for (int i = 0; i < items.size(); ++i) {
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] item[" << i << "] amount=" << items[i].amount
|
||||
<< "date=" << items[i].date << "time=" << items[i].time
|
||||
<< "y=" << items[i].node.y();
|
||||
}
|
||||
|
||||
const double searchAbs = qAbs(m_searchAmount);
|
||||
for (const auto &tx : items) {
|
||||
@ -552,10 +561,10 @@ void GetLastTransactionsScript::doStart() {
|
||||
<< (matchedX100 ? "(scaled x100)" : "(direct)")
|
||||
<< "date:" << tx.date << "time:" << tx.time;
|
||||
|
||||
// Pre-filter по ДАТЕ: если дата ряда не совпадает с датой поиска (±1 день),
|
||||
// пропускаем без тапа-в-детали. Это отсекает большинство "нематчащих" строк
|
||||
// быстрее, чем 10-секундный цикл tap→parse→goBack.
|
||||
if (m_searchTime.isValid() && !tx.date.isEmpty()) {
|
||||
// Pre-filter по ДАТЕ: только если дата надёжная (из content-desc P2P формата).
|
||||
// DCWallet транзакции получают дату из groupDates ImageView, которая
|
||||
// неправильно присваивается при скролле — не фильтруем по ней.
|
||||
if (tx.dateReliable && m_searchTime.isValid() && !tx.date.isEmpty()) {
|
||||
const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy");
|
||||
const QDate searchLocalDate = m_searchTime.toTimeZone(tjTz).date();
|
||||
if (listDate.isValid() && searchLocalDate.isValid()) {
|
||||
@ -720,13 +729,21 @@ void GetLastTransactionsScript::doStart() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Скроллим вниз
|
||||
// Скроллим вниз — свайп внутри области транзакций
|
||||
if (scroll < maxScrolls) {
|
||||
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
|
||||
const int x = device.screenWidth / 2;
|
||||
const int fromY = device.screenHeight * 3 / 4;
|
||||
const int toY = device.screenHeight / 4;
|
||||
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY);
|
||||
// Определяем область контента из реальных Y позиций items
|
||||
int contentBottom = device.screenHeight * 2 / 3;
|
||||
int contentTop = device.screenHeight / 3;
|
||||
if (!items.isEmpty()) {
|
||||
contentBottom = items.last().node.y();
|
||||
contentTop = items.first().node.y();
|
||||
}
|
||||
const int fromY = qMin(contentBottom, device.screenHeight * 2 / 3);
|
||||
const int toY = qMax(contentTop, device.screenHeight / 4);
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] Swipe from" << fromY << "to" << toY;
|
||||
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500);
|
||||
QThread::msleep(2000);
|
||||
}
|
||||
}
|
||||
|
||||
@ -88,6 +88,15 @@ bool CommonScript::runAppAndGoToHomeScreen(
|
||||
qDebug() << "[Ozon] Fast path: Home reached after PIN";
|
||||
return true;
|
||||
}
|
||||
// Ошибка загрузки — тапаем "Обновить"
|
||||
{
|
||||
Node refreshBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Обновить"));
|
||||
if (refreshBtn.x() > 0 && refreshBtn.y() > 0) {
|
||||
qDebug() << "[Ozon] Fast path: page error after PIN, tapping 'Обновить'";
|
||||
AdbUtils::makeTap(deviceId, refreshBtn.x(), refreshBtn.y());
|
||||
QThread::msleep(5000);
|
||||
}
|
||||
}
|
||||
QThread::msleep(1000);
|
||||
}
|
||||
}
|
||||
@ -128,6 +137,17 @@ bool CommonScript::runAppAndGoToHomeScreen(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверяем на ошибку загрузки WebView ("Обновить")
|
||||
{
|
||||
Node refreshBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Обновить"));
|
||||
if (refreshBtn.x() > 0 && refreshBtn.y() > 0) {
|
||||
qDebug() << "[runAppAndGoToHomeScreen] Page load error, tapping 'Обновить'";
|
||||
AdbUtils::makeTap(deviceId, refreshBtn.x(), refreshBtn.y());
|
||||
QThread::msleep(5000);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем на банеры перед пин-кодом
|
||||
Node closeBannerNode = xmlScreenParser.tryToFindSecurityBanner();
|
||||
if (!closeBannerNode.isEmpty()) {
|
||||
|
||||
@ -132,7 +132,21 @@ void GetLastTransactionsScript::doStart() {
|
||||
}
|
||||
|
||||
bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &device, const BankProfileInfo &app) {
|
||||
// Хелпер: если на экране ошибка загрузки ("Обновить"), тапаем один раз
|
||||
auto tryRefreshIfError = [&]() {
|
||||
// Кнопка "Обновить" — Button с content-desc="Обновить" (text пустой)
|
||||
Node refreshBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Обновить"));
|
||||
if (refreshBtn.x() > 0 && refreshBtn.y() > 0) {
|
||||
qDebug() << "[GetLastTransactions] Page load error, tapping 'Обновить' at"
|
||||
<< refreshBtn.x() << refreshBtn.y();
|
||||
AdbUtils::makeTap(m_deviceId, refreshBtn.x(), refreshBtn.y());
|
||||
QThread::msleep(5000);
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
}
|
||||
};
|
||||
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
tryRefreshIfError();
|
||||
|
||||
// Закрываем рекламный bottom sheet
|
||||
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
|
||||
@ -147,6 +161,21 @@ bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &devi
|
||||
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
|
||||
// Если уже на экране операций — скроллим наверх и готово
|
||||
if (xmlScreenParser.isTransactionListScreen()) {
|
||||
qDebug() << "[GetLastTransactions] Already on transaction list screen, scrolling to top";
|
||||
// Несколько быстрых свайпов вверх чтобы вернуться к началу списка
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||||
device.screenHeight / 3,
|
||||
device.screenWidth / 2,
|
||||
device.screenHeight * 2 / 3, 200);
|
||||
QThread::msleep(500);
|
||||
}
|
||||
QThread::msleep(1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Если не на домашнем экране — перезапускаем
|
||||
if (!xmlScreenParser.isHomeScreen()) {
|
||||
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
|
||||
@ -160,6 +189,7 @@ bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &devi
|
||||
return false;
|
||||
}
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
tryRefreshIfError();
|
||||
}
|
||||
|
||||
// Закрываем баннеры на главной
|
||||
@ -212,17 +242,19 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId);
|
||||
QTimeZone deviceTz(tzName.toUtf8());
|
||||
if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow");
|
||||
const int maxScrolls = 30;
|
||||
const int maxScrolls = 10;
|
||||
const int navBarTop = device.screenHeight - (device.screenHeight / 10);
|
||||
|
||||
// Целевая дата в таймзоне устройства для поиска заголовка дня в списке
|
||||
const QDateTime searchLocal = m_searchTime.toTimeZone(deviceTz);
|
||||
const QDate searchDate = searchLocal.date();
|
||||
|
||||
qDebug() << "[FetchTransactions] Searching: amount=" << m_searchAmount
|
||||
qDebug() << "[FetchTransactions] Searching: device=" << m_deviceId
|
||||
<< "amount=" << m_searchAmount
|
||||
<< "time=" << m_searchTime.toString(Qt::ISODate)
|
||||
<< "tz=" << tzName
|
||||
<< "date(local)=" << searchDate.toString("dd.MM.yyyy");
|
||||
<< "date(local)=" << searchDate.toString("dd.MM.yyyy")
|
||||
<< "screen=" << device.screenWidth << "x" << device.screenHeight;
|
||||
|
||||
// ── Шаг 1: скроллим до нужной даты ──────────────────────────────
|
||||
static const QMap<QString, int> monthMap = {
|
||||
@ -290,7 +322,8 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
break;
|
||||
}
|
||||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||||
device.screenHeight - 300, device.screenWidth / 2, 300, 300);
|
||||
device.screenHeight * 2 / 3, device.screenWidth / 2,
|
||||
device.screenHeight / 3, 600);
|
||||
QThread::msleep(1500);
|
||||
}
|
||||
|
||||
@ -305,26 +338,45 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
|
||||
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
|
||||
// Кнопки транзакций между этими Y принадлежат нужному дню.
|
||||
// Если заголовок нужной даты ушёл за верх экрана, но следующая дата ещё
|
||||
// видна ниже (или не видна вовсе), dateY = 0 — все кнопки выше nextDateY
|
||||
// принадлежат нашему дню.
|
||||
auto findDateYRange = [&]() -> QPair<int, int> {
|
||||
int dateY = -1;
|
||||
int nextDateY = device.screenHeight;
|
||||
bool foundOlderDate = false;
|
||||
for (const Node &node : xmlScreenParser.getNodes()) {
|
||||
if (node.className != "android.widget.TextView") continue;
|
||||
const QDate d = parseDateHeader(node.text.trimmed());
|
||||
if (!d.isValid()) continue;
|
||||
if (d == searchDate) {
|
||||
dateY = node.y();
|
||||
} else if (dateY >= 0 && node.y() > dateY) {
|
||||
// Первый заголовок другой даты после нашей
|
||||
nextDateY = node.y();
|
||||
} else if (d < searchDate) {
|
||||
// Заголовок более старой даты — граница снизу
|
||||
if (dateY >= 0 && node.y() > dateY) {
|
||||
nextDateY = node.y();
|
||||
} else if (dateY < 0) {
|
||||
// Заголовок нужной даты ушёл вверх, но видна более старая дата
|
||||
nextDateY = node.y();
|
||||
dateY = 0; // всё выше nextDateY принадлежит нашему дню
|
||||
}
|
||||
foundOlderDate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Заголовок ушёл вверх и нет более старых дат на экране —
|
||||
// весь экран всё ещё внутри нашей даты
|
||||
if (dateY < 0 && !foundOlderDate && dateFound) {
|
||||
dateY = 0;
|
||||
}
|
||||
qDebug() << "[FetchTransactions] findDateYRange: dateY=" << dateY
|
||||
<< "nextDateY=" << nextDateY << "foundOlderDate=" << foundOlderDate;
|
||||
return {dateY, nextDateY};
|
||||
};
|
||||
|
||||
// ── Шаг 2: тапаем только по кнопкам под заголовком нужной даты ───
|
||||
QSet<QString> checkedDetails;
|
||||
int noProgressCount = 0; // Счётчик итераций без прогресса (новых unchecked кнопок)
|
||||
|
||||
for (int attempt = 0; attempt < 15; ++attempt) {
|
||||
if (QThread::currentThread()->isInterruptionRequested()) {
|
||||
@ -333,21 +385,38 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
}
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
|
||||
// Логируем все заголовки дат на экране
|
||||
{
|
||||
QStringList datesOnScreen;
|
||||
for (const Node &node : xmlScreenParser.getNodes()) {
|
||||
if (node.className != "android.widget.TextView") continue;
|
||||
const QDate d = parseDateHeader(node.text.trimmed());
|
||||
if (d.isValid()) {
|
||||
datesOnScreen << QString("%1@y=%2").arg(d.toString("dd.MM")).arg(node.y());
|
||||
}
|
||||
}
|
||||
qDebug() << "[FetchTransactions] attempt=" << attempt << "dates on screen:" << datesOnScreen;
|
||||
}
|
||||
|
||||
// Находим Y-диапазон заголовка нужной даты
|
||||
const auto [dateHeaderY, nextDateHeaderY] = findDateYRange();
|
||||
if (dateHeaderY < 0) {
|
||||
qDebug() << "[FetchTransactions] Date header not on screen, scrolling";
|
||||
qDebug() << "[FetchTransactions] Date completely off screen, scrolling up";
|
||||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||||
device.screenHeight * 3 / 4,
|
||||
device.screenHeight / 3,
|
||||
device.screenWidth / 2,
|
||||
device.screenHeight / 4, 300);
|
||||
QThread::msleep(1000);
|
||||
device.screenHeight * 2 / 3, 600);
|
||||
QThread::msleep(1500);
|
||||
continue;
|
||||
}
|
||||
|
||||
QList<Node> txButtons = xmlScreenParser.findTransactionButtons(20);
|
||||
qDebug() << "[FetchTransactions] Found" << txButtons.size() << "buttons, dateY range:"
|
||||
<< dateHeaderY << "-" << nextDateHeaderY;
|
||||
|
||||
bool tapped = false;
|
||||
bool hasNewButton = false; // Есть ли новая (ещё не проверенная) кнопка с нужной суммой
|
||||
int buttonsInRange = 0; // Кнопки внутри Y-диапазона нужной даты
|
||||
for (int bi = 0; bi < txButtons.size(); ++bi) {
|
||||
if (QThread::currentThread()->isInterruptionRequested()) {
|
||||
m_error = "Interrupted";
|
||||
@ -358,8 +427,12 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
const int btnY = txButtons[bi].y();
|
||||
if (btnY <= dateHeaderY || btnY >= nextDateHeaderY) continue;
|
||||
if (btnY <= 0 || btnY >= navBarTop) continue;
|
||||
++buttonsInRange;
|
||||
|
||||
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text);
|
||||
qDebug() << "[FetchTransactions] Button" << bi << "y=" << btnY
|
||||
<< "parsed amount=" << parsed.amount << "search=" << m_searchAmount
|
||||
<< "text=" << txButtons[bi].text.left(60);
|
||||
if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue;
|
||||
|
||||
const Node &visibleBtn = txButtons[bi];
|
||||
@ -412,6 +485,20 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
}
|
||||
checkedDetails.insert(dtStr);
|
||||
tapped = true;
|
||||
hasNewButton = true;
|
||||
|
||||
// Проверяем что транзакция принадлежит нужной дате (защита от широкого Y-диапазона)
|
||||
if (txTime.isValid()) {
|
||||
const QDate txLocalDate = txTime.toTimeZone(deviceTz).date();
|
||||
if (txLocalDate != searchDate) {
|
||||
qDebug() << "[FetchTransactions] Wrong date in detail:" << dtStr
|
||||
<< "expected date" << searchDate.toString("dd.MM.yyyy")
|
||||
<< "- button belongs to different date, skipping";
|
||||
AdbUtils::goBack(m_deviceId);
|
||||
QThread::msleep(1000);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем время (±10 минут)
|
||||
if (txTime.isValid() && m_searchTime.isValid()) {
|
||||
@ -427,7 +514,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
}
|
||||
|
||||
// Нашли!
|
||||
qDebug() << "[FetchTransactions] FOUND transaction:"
|
||||
qDebug() << "[FetchTransactions] FOUND transaction: device=" << m_deviceId
|
||||
<< "amount=" << parsed.amount << "time=" << dtStr
|
||||
<< "name=" << detail.name;
|
||||
|
||||
@ -443,7 +530,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
QThread::msleep(2000);
|
||||
|
||||
bool docLoaded = false;
|
||||
for (int da = 0; da < 15; ++da) {
|
||||
for (int da = 0; da < 5; ++da) {
|
||||
if (QThread::currentThread()->isInterruptionRequested()) break;
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) {
|
||||
@ -535,17 +622,50 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
return; // успех
|
||||
}
|
||||
|
||||
// Скроллим вниз если ни одна кнопка не была нажата или все проверенные не подошли
|
||||
if (!tapped) {
|
||||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||||
device.screenHeight * 3 / 4,
|
||||
device.screenWidth / 2,
|
||||
device.screenHeight / 4, 300);
|
||||
QThread::msleep(1000);
|
||||
// Отслеживаем прогресс: "без прогресса" только если были кнопки с СОВПАДАЮЩЕЙ
|
||||
// суммой (tapped), но все уже checked. Если суммы не совпали — скроллим дальше,
|
||||
// нужная кнопка может быть ниже.
|
||||
if (!hasNewButton && tapped) {
|
||||
++noProgressCount;
|
||||
if (noProgressCount >= 5) {
|
||||
qDebug() << "[FetchTransactions] No progress after" << noProgressCount
|
||||
<< "attempts with matching amount — giving up";
|
||||
break;
|
||||
}
|
||||
} else if (hasNewButton) {
|
||||
noProgressCount = 0;
|
||||
}
|
||||
|
||||
// Скроллим вниз если не нашли нужную транзакцию
|
||||
if (!tapped || !hasNewButton) {
|
||||
qDebug() << "[FetchTransactions] Scrolling down (tapped=" << tapped
|
||||
<< "hasNew=" << hasNewButton << "inRange=" << buttonsInRange
|
||||
<< "noProgress=" << noProgressCount << ")";
|
||||
|
||||
if (dateHeaderY > 0 && dateHeaderY < device.screenHeight / 2) {
|
||||
// Заголовок в верхней половине — маленький скролл чтобы показать
|
||||
// больше кнопок под этой датой
|
||||
const int fromY = device.screenHeight * 2 / 3;
|
||||
const int toY = device.screenHeight / 3;
|
||||
qDebug() << "[FetchTransactions] Small scroll: from" << fromY << "to" << toY
|
||||
<< "(dateHeaderY=" << dateHeaderY << ")";
|
||||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, fromY,
|
||||
device.screenWidth / 2, toY, 500);
|
||||
} else {
|
||||
// Заголовок в нижней половине или за экраном — полный скролл
|
||||
const int fromY = device.screenHeight * 3 / 4;
|
||||
const int toY = device.screenHeight / 4;
|
||||
qDebug() << "[FetchTransactions] Full scroll down: from" << fromY << "to" << toY
|
||||
<< "(dateHeaderY=" << dateHeaderY << ")";
|
||||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, fromY,
|
||||
device.screenWidth / 2, toY, 400);
|
||||
}
|
||||
QThread::msleep(1500);
|
||||
}
|
||||
}
|
||||
|
||||
m_error = "Transaction not found: amount=" + QString::number(m_searchAmount, 'f', 2)
|
||||
m_error = "Transaction not found: device=" + m_deviceId
|
||||
+ " amount=" + QString::number(m_searchAmount, 'f', 2)
|
||||
+ " date=" + searchDate.toString("dd.MM.yyyy");
|
||||
qWarning() << "[FetchTransactions]" << m_error;
|
||||
// Отправляем скриншот списка транзакций, сделанный до начала поиска
|
||||
|
||||
5
main.cpp
5
main.cpp
@ -316,7 +316,8 @@ int main(int argc, char *argv[]) {
|
||||
QApplication::setPalette(lightPalette);
|
||||
|
||||
logFile.setFileName("application.log");
|
||||
if (!logFile.open(QIODevice::Append | QIODevice::Text)) {
|
||||
// Очищаем лог-файл при каждом запуске
|
||||
if (!logFile.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
|
||||
qWarning() << "Не удалось открыть лог-файл для записи";
|
||||
}
|
||||
// Устанавливаем свой обработчик
|
||||
@ -334,7 +335,7 @@ int main(int argc, char *argv[]) {
|
||||
|
||||
// Миграции БД
|
||||
DatabaseManager::instance().initSchema();
|
||||
DatabaseManager::instance().cleanupOldData(5);
|
||||
DatabaseManager::instance().cleanupOldData(0);
|
||||
dbReady.store(true);
|
||||
|
||||
// 1. Если есть api_key — логинимся и проверяем/создаём desktop
|
||||
|
||||
@ -32,8 +32,8 @@
|
||||
#include "dushanbe/PayByPhoneScript.h"
|
||||
|
||||
static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonValue &data, int eventId = -1);
|
||||
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot = {});
|
||||
static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {});
|
||||
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot = {}, int eventId = -1);
|
||||
static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}, int eventId = -1);
|
||||
|
||||
EventHandler::EventHandler(QObject *parent) : QObject(parent) {}
|
||||
|
||||
@ -176,6 +176,8 @@ static void sendTaskResult(const QString &type, const QString &materialId, const
|
||||
obj["material_id"] = materialId; // UUID string
|
||||
obj["bank_name"] = bankName;
|
||||
obj["data"] = data;
|
||||
if (eventId > 0)
|
||||
obj["internal_task_id"] = eventId;
|
||||
|
||||
auto *thread = new QThread;
|
||||
auto *networkService = new NetworkService;
|
||||
@ -200,24 +202,26 @@ static void sendTaskResult(const QString &type, const QString &materialId, const
|
||||
}
|
||||
|
||||
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName,
|
||||
const QString &description, const QByteArray &screenshot) {
|
||||
const QString &description, const QByteArray &screenshot, int eventId) {
|
||||
QJsonObject errorData;
|
||||
errorData["status"] = QStringLiteral("error");
|
||||
errorData["description"] = description;
|
||||
sendTaskResult(type, materialId, bankName, errorData);
|
||||
sendTaskResult(type, materialId, bankName, errorData, eventId);
|
||||
|
||||
if (!screenshot.isEmpty()) {
|
||||
const QString message = QStringLiteral("[%1] bank=%2 material=%3: %4")
|
||||
.arg(type, bankName, materialId, description);
|
||||
sendMonitoringLog("error", "task_error", message, screenshot);
|
||||
sendMonitoringLog("error", "task_error", message, screenshot, eventId);
|
||||
}
|
||||
}
|
||||
|
||||
static void sendMonitoringLog(const QString &type, const QString &event, const QString &message,
|
||||
const QByteArray &image) {
|
||||
const QByteArray &image, int eventId) {
|
||||
auto *thread = new QThread;
|
||||
auto *networkService = new NetworkService;
|
||||
networkService->moveToThread(thread);
|
||||
if (eventId > 0)
|
||||
networkService->addExtra("internal_task_id", eventId);
|
||||
QObject::connect(thread, &QThread::started, networkService, [networkService, type, event, message, image]() {
|
||||
networkService->postMonitoringLog(type, event, message, image);
|
||||
emit networkService->finished();
|
||||
@ -273,7 +277,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
|
||||
.arg(eventTypeToString(event.type), deviceLabel, event.bankName, result);
|
||||
GeneralLogDAO::insert("CRITICAL", "EventHandler", logMsg, screenshot);
|
||||
|
||||
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, result, screenshot);
|
||||
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, result, screenshot, event.id);
|
||||
EventDAO::markSentToServer(event.id);
|
||||
}
|
||||
|
||||
@ -375,21 +379,21 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
|
||||
}
|
||||
f.remove();
|
||||
}
|
||||
sendMonitoringLog("info", "task_success", msg, screenshot);
|
||||
sendMonitoringLog("info", "task_success", msg, screenshot, event.id);
|
||||
}
|
||||
|
||||
if (event.type == EventType::FetchTransactions) {
|
||||
const QString msg = QStringLiteral("[FETCH_TRANSACTIONS] bank=%1 material=%2")
|
||||
.arg(event.bankName, event.externalId);
|
||||
const QByteArray scr = m_screenshots.take(key);
|
||||
sendMonitoringLog("info", "task_success", msg, scr);
|
||||
sendMonitoringLog("info", "task_success", msg, scr, event.id);
|
||||
}
|
||||
|
||||
if (event.type == EventType::FetchProfile) {
|
||||
const QString msg = QStringLiteral("[FETCH_PROFILE] bank=%1 material=%2")
|
||||
.arg(event.bankName, event.externalId);
|
||||
const QByteArray scr = m_screenshots.take(key);
|
||||
sendMonitoringLog("info", "task_success", msg, scr);
|
||||
sendMonitoringLog("info", "task_success", msg, scr, event.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1318,6 +1318,11 @@ void NetworkService::postMonitoringLog(const QString &type, const QString &event
|
||||
addFormField(multiPart, "event", event);
|
||||
addFormField(multiPart, "message", message);
|
||||
|
||||
const int internalTaskId = m_extraData.value("internal_task_id").toInt();
|
||||
if (internalTaskId > 0) {
|
||||
addFormField(multiPart, "internal_task_id", QString::number(internalTaskId));
|
||||
}
|
||||
|
||||
if (!image.isEmpty()) {
|
||||
QHttpPart imagePart;
|
||||
imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/png");
|
||||
|
||||
@ -13,7 +13,7 @@ ARC запускается как обычно (реальные Android-уст
|
||||
| Python | `brew install python` или встроенный | [python.org](https://python.org), галочка «Add to PATH» |
|
||||
| Flask | `pip install flask` | `pip install flask` |
|
||||
| ADB | `brew install android-platform-tools` | Используется из `tools\adb\windows\` |
|
||||
| ARC | `cmake --build build` | `cmake --build build --config Release` |
|
||||
| ARC | `cmake --build build_test` | `cmake --build build --config Release` |
|
||||
| Устройства | `adb devices` → `device` | `tools\adb\windows\adb.exe devices` |
|
||||
|
||||
---
|
||||
@ -35,24 +35,26 @@ python tests\mock_server\gen_mock_data.py
|
||||
|
||||
Скрипт:
|
||||
- Находит `config.ini` в текущей директории (или `build/Release/config.ini`)
|
||||
- Открывает БД, читает активные `devices → bank_profiles → materials`
|
||||
- Открывает БД, читает `bank_profiles` с проверенным PIN-кодом (`pin_code_checked = 'yes'`) и активные `materials`
|
||||
- Генерирует:
|
||||
- `tests/mock_server/default_profile.json` — профиль для мока
|
||||
- `tests/mock_server/scenarios/*_fetch_profile.json` — по одному на материал
|
||||
- `tests/mock_server/scenarios/*_fetch_transactions.json` — для ручных прогонов
|
||||
- `tests/mock_server/scenarios/*_<card>_fetch_profile.json` — по одному на материал (с номером карты в имени)
|
||||
- Очищает таблицу `events` в БД
|
||||
|
||||
Пример вывода:
|
||||
```
|
||||
config: config.ini
|
||||
database: database/app_data.db
|
||||
deleted 5 old events
|
||||
wrote default_profile.json (3 bank_profiles)
|
||||
wrote scenarios/pixel_7_ozon_fetch_profile.json
|
||||
wrote scenarios/pixel_7_dushanbe_city_bank_fetch_profile.json
|
||||
wrote scenarios/tecno_km4_ozon_fetch_profile.json
|
||||
wrote scenarios/pixel_7_ozon_7919_fetch_profile.json
|
||||
wrote scenarios/pixel_7_ozon_9865_fetch_profile.json
|
||||
wrote scenarios/pixel_7_dushanbe_city_bank_3258_fetch_profile.json
|
||||
wrote scenarios/tecno_km4_ozon_0982_fetch_profile.json
|
||||
|
||||
Devices (2):
|
||||
pixel_7: ozon (30bc106c…), dushanbe_city_bank (61d967d1…)
|
||||
tecno_km4: ozon (49001bb5…)
|
||||
pixel_7: ozon/7919 (50393cc1…), ozon/9865 (06a1ae15…), dushanbe_city_bank/3258 (36002ac2…)
|
||||
tecno_km4: ozon/0982 (7e3a7e4e…)
|
||||
```
|
||||
|
||||
Флаги: `--config C:\path\to\config.ini` — если config не в текущей директории.
|
||||
@ -70,19 +72,12 @@ python tests\mock_server\gen_fetch_transactions.py
|
||||
Скрипт:
|
||||
- Читает БД → находит активные материалы
|
||||
- Находит подключённые ADB-устройства → матчит по `adb_serial`
|
||||
- Показывает меню:
|
||||
```
|
||||
Available materials (3):
|
||||
1. Pixel 7 / ozon / Галина Терехина (material 30bc106c…)
|
||||
2. Pixel 7 / dushanbe_city_bank / Мехрубон (material 61d967d1…)
|
||||
3. TECNO KM4 / ozon / Владислав Касаткин (material 49001bb5…)
|
||||
Pick [1-3] or 'all':
|
||||
```
|
||||
- После выбора просит открыть список транзакций на устройстве → Enter
|
||||
- Проходит по каждой транзакции: тап → дамп деталей → назад
|
||||
(перед каждым тапом проверяет, что мы на списке)
|
||||
- Сохраняет `scenarios/pool/*_ft_*.json` с реальными `amount` (в копейках)
|
||||
и `created_at` (UTC, из таймзоны устройства)
|
||||
- Показывает меню → выбор материала или `all`
|
||||
- Проходит по транзакциям на экране:
|
||||
- **Ozon**: парсит заголовки дат (Сегодня/Вчера/dd месяца), определяет дату каждой транзакции по Y-позиции, тапает для получения времени из деталей, скроллит вниз для следующих
|
||||
- **Dushanbe**: парсит Выписку, тап в детали, время Dushanbe (UTC+5)
|
||||
- Дедупликация по `(amount, created_at)` — одинаковые суммы в один день с разным временем сохраняются как отдельные задачи
|
||||
- Сохраняет `scenarios/pool/*_<card>_ft_*.json` с реальными `amount` (в копейках) и `created_at` (UTC)
|
||||
- Если мок запущен — сразу инжектит задачи
|
||||
|
||||
Флаги:
|
||||
@ -113,57 +108,48 @@ apiBase=http://127.0.0.1:8888
|
||||
### Терминал 1 — mock-сервер
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
# macOS — полный цикл (генерация данных + запуск)
|
||||
cd tests/mock_server && ./run.sh
|
||||
|
||||
# macOS — только определённые банки
|
||||
cd tests/mock_server && ./run.sh --banks ozon
|
||||
|
||||
# macOS — только FETCH_TRANSACTIONS (без FETCH_PROFILE)
|
||||
cd tests/mock_server && ./run.sh --types FETCH_TRANSACTIONS --banks ozon
|
||||
|
||||
# macOS — debug режим (auto-reload при изменении кода)
|
||||
cd tests/mock_server && ./run.sh --banks ozon --debug
|
||||
|
||||
# Windows
|
||||
cd tests\mock_server
|
||||
python mock_server.py --port 8888
|
||||
```
|
||||
|
||||
Логи в консоли:
|
||||
```
|
||||
[mock] state cleared — starting fresh
|
||||
[mock] loaded default_profile.json: 3 bank_profiles, 3 materials
|
||||
[mock] loaded pool: 19 FETCH_TRANSACTIONS across 3 materials
|
||||
* Running on http://127.0.0.1:8888
|
||||
```
|
||||
|
||||
Флаги:
|
||||
- `--banks ozon` — генерировать задачи только для указанных банков
|
||||
- `--banks ozon dushanbe_city_bank` — несколько банков через пробел
|
||||
Флаги `mock_server.py`:
|
||||
- `--banks ozon dushanbe_city_bank` — только указанные банки
|
||||
- `--types FETCH_TRANSACTIONS` — только указанные типы задач
|
||||
- `--ft-per-material 3` — сколько FT на материал при бутстрапе (default 3)
|
||||
- `--seed 42` — фиксированный RNG для воспроизводимости
|
||||
- `--debug` — Flask debug режим (auto-reload, состояние сохраняется между перезагрузками)
|
||||
|
||||
Без `--banks` задачи генерируются для всех активных банков.
|
||||
|
||||
Примеры:
|
||||
```bash
|
||||
# Только Ozon
|
||||
./run.sh --banks ozon
|
||||
|
||||
# Только Dushanbe
|
||||
./run.sh --banks dushanbe_city_bank
|
||||
|
||||
# Ozon + Dushanbe, по 2 FT на материал
|
||||
./run.sh --banks ozon dushanbe_city_bank --ft-per-material 2
|
||||
|
||||
# Все банки (по умолчанию)
|
||||
./run.sh
|
||||
```
|
||||
При `--debug`:
|
||||
- Изменения в `.py` файлах применяются автоматически без перезапуска
|
||||
- Состояние задач сохраняется в `.mock_state.json` и восстанавливается при reload
|
||||
- Скриншоты сохраняются в `.mock_screenshots/` (переживают reload)
|
||||
- При **первом** запуске — чистый старт (events в БД очищаются, state сбрасывается)
|
||||
|
||||
### Терминал 2 — ARC
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
./build/ARC
|
||||
# macOS (из корня проекта!)
|
||||
./build_test/ARC
|
||||
|
||||
# Windows
|
||||
build\Release\ARC.exe
|
||||
```
|
||||
|
||||
При логине мок автоматически ставит задачи:
|
||||
- 1 × FETCH_PROFILE на каждый материал
|
||||
- 1 × FETCH_PROFILE на каждый материал (если `--types` не ограничивает)
|
||||
- N × FETCH_TRANSACTIONS (случайные из pool)
|
||||
|
||||
Задачи раздаются **по 1 на material за поллинг** — следующая только после
|
||||
@ -172,15 +158,9 @@ build\Release\ARC.exe
|
||||
### Терминал 3 — отчёт
|
||||
|
||||
```bash
|
||||
# Браузер с авто-обновлением (задачи группируются по устройствам)
|
||||
# Браузер с авто-обновлением
|
||||
open http://127.0.0.1:8888/_admin/report/html # macOS
|
||||
start http://127.0.0.1:8888/_admin/report/html # Windows
|
||||
|
||||
# Счётчики в реальном времени
|
||||
watch -n 2 'curl -s http://127.0.0.1:8888/_admin/state' # macOS
|
||||
|
||||
# Windows (PowerShell)
|
||||
while ($true) { cls; (iwr http://127.0.0.1:8888/_admin/state).Content; sleep 2 }
|
||||
```
|
||||
|
||||
---
|
||||
@ -188,86 +168,89 @@ while ($true) { cls; (iwr http://127.0.0.1:8888/_admin/state).Content; sleep 2 }
|
||||
## 5. HTML-отчёт
|
||||
|
||||
`http://127.0.0.1:8888/_admin/report/html` — веб-страница с:
|
||||
- Сводкой: всего / выполнено / в очереди
|
||||
- Группировкой по устройствам (Pixel 7, TECNO KM4, …)
|
||||
- Для каждой задачи: Task JSON слева ↔ Result JSON справа
|
||||
- Цветовой индикацией: зелёный = completed, жёлтый = pending, красный = error
|
||||
- Авто-обновлением каждые 2 секунды (без сброса скролла)
|
||||
|
||||
Также доступен JSON:
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8888/_admin/report | jq # полный
|
||||
curl -s http://127.0.0.1:8888/_admin/report/pending | jq # только pending
|
||||
```
|
||||
- Сводкой: всего / выполнено / ошибки / в очереди
|
||||
- Группировкой по **устройствам** → **банкам** (Pixel 7 → ozon, dushanbe_city_bank)
|
||||
- Для каждой задачи:
|
||||
- Task JSON и Result JSON (для успешных — свёрнуты, кнопка "Показать JSON")
|
||||
- Ошибки — развёрнуты сразу с красным бейджем
|
||||
- Скриншот (если есть) — третья колонка, клик открывает полноэкранный просмотр
|
||||
- Кнопка "Копировать" на каждом JSON блоке
|
||||
- Цветовой индикацией: зелёный = completed, красный = error, жёлтый = pending
|
||||
- Авто-обновлением каждые 2 секунды (состояние свёрнутых/развёрнутых блоков сохраняется)
|
||||
|
||||
---
|
||||
|
||||
## 6. Ручной инжект задач
|
||||
## 6. Скриншоты
|
||||
|
||||
Приложение отправляет скриншоты через `POST /monitoring/log` с полем `internal_task_id`.
|
||||
Мок-сервер:
|
||||
- Сохраняет PNG на диск в `.mock_screenshots/`
|
||||
- Привязывает к задаче по `internal_task_id` (точное совпадение с `task_result`)
|
||||
- Отображает в отчёте как третью колонку рядом с Task/Result JSON
|
||||
- Клик по скриншоту — полноэкранный просмотр (Escape для закрытия)
|
||||
|
||||
---
|
||||
|
||||
## 7. Ручной инжект задач
|
||||
|
||||
```bash
|
||||
# Одна задача
|
||||
./inject.sh scenarios/pixel_7_ozon_fetch_profile.json # macOS
|
||||
curl -X POST http://127.0.0.1:8888/_admin/inject ^ # Windows
|
||||
-H "Content-Type: application/json" ^
|
||||
--data-binary @scenarios\pixel_7_ozon_fetch_profile.json
|
||||
|
||||
# N случайных из пула
|
||||
python inject_random.py --count 5
|
||||
curl -X POST http://127.0.0.1:8888/_admin/inject \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @scenarios/pixel_7_ozon_7919_fetch_profile.json
|
||||
|
||||
# Повторный бутстрап (без перезапуска мока)
|
||||
curl -X POST http://127.0.0.1:8888/_admin/bootstrap
|
||||
|
||||
# Сброс (очистка очереди + результатов + in-flight)
|
||||
# Сброс (очистка очереди + результатов + скриншотов)
|
||||
curl -X POST http://127.0.0.1:8888/_admin/reset
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Админ-эндпойнты мока
|
||||
## 8. Админ-эндпойнты мока
|
||||
|
||||
| Метод | Путь | Назначение |
|
||||
|---|---|---|
|
||||
| POST | `/_admin/inject` | Положить задачу (или массив) в очередь |
|
||||
| POST | `/_admin/bootstrap` | Повторный бутстрап (1 FP + N FT на material) |
|
||||
| POST | `/_admin/bootstrap` | Повторный бутстрап |
|
||||
| POST | `/_admin/seed_profile` | Подменить ответ `current_profile` |
|
||||
| POST | `/_admin/seed_devices` | Подменить список устройств |
|
||||
| GET | `/_admin/report` | JSON: полный отчёт задачи ↔ результаты |
|
||||
| GET | `/_admin/report` | JSON: полный отчёт |
|
||||
| GET | `/_admin/report/html` | HTML-отчёт с авто-обновлением |
|
||||
| GET | `/_admin/report/pending` | Только незавершённые задачи |
|
||||
| GET | `/_admin/report/fragment` | JSON-фрагмент для JS-обновления |
|
||||
| GET | `/_admin/screenshot/<id>` | PNG скриншот по ID |
|
||||
| GET | `/_admin/state` | Счётчики |
|
||||
| POST | `/_admin/reset` | Очистить всё, сбросить бутстрап |
|
||||
|
||||
---
|
||||
|
||||
## 8. Полный цикл с нуля (новая машина / новые устройства)
|
||||
## 9. Полный цикл с нуля
|
||||
|
||||
```bash
|
||||
# 1. Установка
|
||||
pip install flask
|
||||
|
||||
# 2. Собрать ARC, запустить с боевым сервером → залогиниться → устройства,
|
||||
# банковские профили и материалы появятся в БД
|
||||
# 2. Собрать ARC, запустить с боевым сервером → залогиниться
|
||||
# Устройства, профили и материалы появятся в БД
|
||||
|
||||
# 3. Переключить config.ini на мок
|
||||
# [net]
|
||||
# apiBase=http://127.0.0.1:8888
|
||||
|
||||
# 4. Сгенерировать стартовые данные из БД
|
||||
python tests/mock_server/gen_mock_data.py
|
||||
python3 tests/mock_server/gen_mock_data.py
|
||||
|
||||
# 5. Открыть на устройстве банковское приложение → список транзакций
|
||||
# Сгенерировать пул задач
|
||||
python tests/mock_server/gen_fetch_transactions.py
|
||||
python3 tests/mock_server/gen_fetch_transactions.py
|
||||
|
||||
# 6. Запустить мок (все банки)
|
||||
cd tests/mock_server && python mock_server.py --port 8888
|
||||
# или только определённые:
|
||||
# cd tests/mock_server && python mock_server.py --port 8888 --banks ozon
|
||||
# 6. Запустить мок
|
||||
cd tests/mock_server && ./run.sh --banks ozon --debug
|
||||
|
||||
# 7. Запустить ARC (в отдельном терминале)
|
||||
./build/ARC # macOS
|
||||
build\Release\ARC.exe # Windows
|
||||
# 7. Запустить ARC (из корня проекта, в отдельном терминале)
|
||||
./build_test/ARC
|
||||
|
||||
# 8. Открыть отчёт
|
||||
open http://127.0.0.1:8888/_admin/report/html
|
||||
@ -275,63 +258,65 @@ open http://127.0.0.1:8888/_admin/report/html
|
||||
# 9. Дождаться completed = всего в отчёте
|
||||
|
||||
# 10. Вернуть config.ini на прод
|
||||
# [net]
|
||||
# apiBase=http://93.183.75.134:8005
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Структура файлов
|
||||
## 10. Структура файлов
|
||||
|
||||
```
|
||||
tests/mock_server/
|
||||
├── mock_server.py # Flask mock-сервер
|
||||
├── run.sh # запуск (macOS/Linux)
|
||||
├── run.sh # запуск (macOS/Linux): gen_mock_data + mock_server
|
||||
├── gen_mock_data.py # генерация default_profile + scenarios из БД
|
||||
├── gen_fetch_transactions.py # генерация pool из живого устройства
|
||||
├── inject.sh # curl-обёртка для инжекта
|
||||
├── inject_random.py # рандомный инжект из pool
|
||||
├── default_profile.json # авто-генерированный профиль для мока
|
||||
├── seed_profile.example.json # пример ручного засева
|
||||
├── README.md # эта инструкция
|
||||
├── WINDOWS.md # Windows-специфичные нюансы
|
||||
├── scenarios/ # одиночные задачи (FETCH_PROFILE / FT с пустым body)
|
||||
│ ├── pixel_7_ozon_fetch_profile.json
|
||||
│ ├── pixel_7_ozon_fetch_transactions.json
|
||||
│ ├── tecno_km4_ozon_fetch_profile.json
|
||||
├── .mock_state.json # состояние задач (debug режим, .gitignore)
|
||||
├── .mock_screenshots/ # скриншоты с устройств (.gitignore)
|
||||
├── scenarios/ # FETCH_PROFILE сценарии
|
||||
│ ├── pixel_7_ozon_7919_fetch_profile.json
|
||||
│ ├── pixel_7_ozon_9865_fetch_profile.json
|
||||
│ └── ...
|
||||
├── scenarios/pool/ # пул FETCH_TRANSACTIONS с реальными amount + created_at
|
||||
│ ├── pixel7_ozon_ft_01_evgeny_plus_100.json
|
||||
│ ├── tecno_km4_ozon_ft_01_galina_plus_100.json
|
||||
│ └── ...
|
||||
└── fixtures/ # XML/PNG дампы экранов (справочно)
|
||||
├── tecno_ozon/
|
||||
├── pixel7_ozon/
|
||||
└── pixel7_dushanbe/
|
||||
└── scenarios/pool/ # пул FETCH_TRANSACTIONS
|
||||
├── pixel_7_ozon_7919_01_minus_108_ft_01.json
|
||||
├── pixel_7_ozon_9865_01_minus_108_ft_01.json
|
||||
├── pixel_7_dushanbe_city_bank_3258_01_plus_5_ft_01.json
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Типовые проблемы
|
||||
## 11. Типовые проблемы
|
||||
|
||||
| Симптом | Причина | Решение |
|
||||
|---|---|---|
|
||||
| `config.ini not found` | Скрипт запущен не из корня проекта | `cd` в корень или `--config path` |
|
||||
| `No active materials` | БД пустая или профили не active | Запустить ARC с боевым сервером, залогиниться |
|
||||
| `No ADB devices match` | Устройства не подключены или serial не совпадает | `adb devices`, проверить USB |
|
||||
| Задача `pending` навсегда | material в in-flight (результат не пришёл) | `/_admin/reset` и перезапустить ARC |
|
||||
| `Task expired: waited Ns` | Предыдущая задача выполнялась >5 мин | Мок отдаёт по 1 задаче — это уже фикс; убедиться, что `maxScrolls` разумный |
|
||||
| `Transaction not found` | Транзакции на устройстве не совпадают с pool | Перегенерировать: `gen_fetch_transactions.py` |
|
||||
| `Cancelled: replaced` | Норма для FETCH_PROFILE — дедупликация по device+bank | Не баг |
|
||||
| Мок не получает результат | `sendTaskError` шёл в monitoring вместо task_result | Обновить ARC (фикс в EventHandler.cpp) |
|
||||
| Кракозябры в консоли (Win) | Кодировка | `chcp 65001` перед запуском |
|
||||
| `No active materials` | Нет профилей с проверенным PIN | Запустить ARC с боевым сервером, добавить PIN |
|
||||
| `No ADB devices match` | Устройства не подключены | `adb devices`, проверить USB |
|
||||
| `App restarted` ошибки в отчёте | Events остались от прошлого запуска | Мок-сервер чистит events при старте; используйте `run.sh` или запускайте сервер **до** ARC |
|
||||
| Задача `pending` навсегда | material в `_in_flight` | `/_admin/reset` и перезапустить ARC |
|
||||
| `Transaction not found` | Баг поиска — скролл, видимость кнопок | Анализировать логи (device= в ошибке) |
|
||||
| `Page load error` | Ozon WebView не загрузился | Проблема интернета/устройства, таймаут 5 мин |
|
||||
| Скриншот не привязан | Нет `internal_task_id` | Обновить ARC (фикс в EventHandler.cpp) |
|
||||
| Pool material_id не совпадает | БД обновилась, pool устарел | Перегенерировать pool или обновить material_id в файлах |
|
||||
| Порт занят | Другой процесс на 8888 | `--port 9999` + поменять `apiBase` |
|
||||
|
||||
---
|
||||
|
||||
## 11. Что мок НЕ эмулирует
|
||||
## 12. Что мок эмулирует и что нет
|
||||
|
||||
- Валидацию тел запросов (всё `{success: true}`)
|
||||
- Хранение скриншотов
|
||||
**Эмулирует:**
|
||||
- Авторизацию (login/refresh)
|
||||
- Синхронизацию профилей (current_profile, bank_profile CRUD)
|
||||
- Выдачу задач (get_tasks) с in-flight контролем
|
||||
- Приём результатов (task_result) с `internal_task_id`
|
||||
- Приём скриншотов (monitoring/log) с привязкой к задачам
|
||||
- Управление устройствами и материалами (toggle)
|
||||
|
||||
**Не эмулирует:**
|
||||
- Валидацию тел запросов
|
||||
- Webhook-уведомления банков
|
||||
- Сетевые задержки/таймауты
|
||||
|
||||
@ -28,14 +28,11 @@ powershell -ExecutionPolicy Bypass -File setup.ps1
|
||||
4. Находит `config.ini` и проверяет **базу данных**
|
||||
5. Удаляет старые сгенерированные данные (профиль + scenarios, пул не трогает)
|
||||
6. Генерирует `default_profile.json` и FETCH_PROFILE scenarios из БД
|
||||
7. Если порт занят — убивает старый процесс
|
||||
8. Переключает `config.ini` на mock (`apiBase=http://127.0.0.1:8888`)
|
||||
9. Показывает сводку: устройства, профили, материалы, пул транзакций
|
||||
10. Запускает mock-сервер и открывает HTML-отчёт в браузере
|
||||
|
||||
При первом логине ARC мок автоматически ставит **FETCH_PROFILE** задачи
|
||||
на каждый материал. **FETCH_TRANSACTIONS** задачи нужно генерировать отдельно
|
||||
(см. [раздел 3](#3-генерация-fetch_transactions-задач)).
|
||||
7. Очищает таблицу `events` в БД
|
||||
8. Если порт занят — убивает старый процесс
|
||||
9. Переключает `config.ini` на mock (`apiBase=http://127.0.0.1:8888`)
|
||||
10. Показывает сводку: устройства, профили, материалы, пул транзакций
|
||||
11. Запускает mock-сервер и открывает HTML-отчёт в браузере
|
||||
|
||||
### Параметры
|
||||
|
||||
@ -44,33 +41,23 @@ powershell -ExecutionPolicy Bypass -File setup.ps1
|
||||
| `-Port` | 8888 | Порт mock-сервера |
|
||||
| `-FtPerMaterial` | 3 | FETCH_TRANSACTIONS на материал при бутстрапе (из пула) |
|
||||
| `-Banks` | все | Банки через запятую: `ozon`, `dushanbe_city_bank`, `black` |
|
||||
| `-Types` | все | Типы задач: `FETCH_PROFILE`, `FETCH_TRANSACTIONS` |
|
||||
| `-SkipInstall` | — | Пропустить установку Python/Flask |
|
||||
| `-GenerateOnly` | — | Только сгенерировать данные, не запускать сервер |
|
||||
| `-Clean` | — | Также очистить пул транзакций (`scenarios/pool/`) |
|
||||
| `-Debug` | — | Flask debug режим (auto-reload при изменении кода) |
|
||||
|
||||
### Примеры
|
||||
|
||||
```powershell
|
||||
# Всё автоматически (установка + генерация + запуск)
|
||||
# Всё автоматически
|
||||
.\setup.ps1
|
||||
|
||||
# На другом порту с 5 транзакциями на материал
|
||||
.\setup.ps1 -Port 9999 -FtPerMaterial 5
|
||||
# Только Ozon, только FETCH_TRANSACTIONS
|
||||
.\setup.ps1 -Banks ozon -Types FETCH_TRANSACTIONS
|
||||
|
||||
# Только Ozon
|
||||
.\setup.ps1 -Banks ozon
|
||||
|
||||
# Только Dushanbe, по 2 транзакции
|
||||
.\setup.ps1 -Banks dushanbe_city_bank -FtPerMaterial 2
|
||||
|
||||
# Ozon + Dushanbe
|
||||
.\setup.ps1 -Banks ozon,dushanbe_city_bank
|
||||
|
||||
# Пропустить установку (Python/Flask уже есть)
|
||||
.\setup.ps1 -SkipInstall
|
||||
|
||||
# Только подготовить данные, сервер не запускать
|
||||
.\setup.ps1 -GenerateOnly
|
||||
# Debug режим с 5 транзакциями на материал
|
||||
.\setup.ps1 -Banks ozon -FtPerMaterial 5 -Debug
|
||||
|
||||
# Полная очистка (включая пул транзакций)
|
||||
.\setup.ps1 -Clean
|
||||
@ -81,7 +68,7 @@ powershell -ExecutionPolicy Bypass -File setup.ps1
|
||||
## 2. Вспомогательные скрипты
|
||||
|
||||
После первого запуска `setup.ps1` в папке `tests\mock_server\` появляются
|
||||
скрипты с зашитым путём до Python — искать `python.exe` вручную не нужно:
|
||||
скрипты с зашитым путём до Python:
|
||||
|
||||
| Скрипт | Назначение | Запуск |
|
||||
|---|---|---|
|
||||
@ -89,14 +76,14 @@ powershell -ExecutionPolicy Bypass -File setup.ps1
|
||||
| `run_server.ps1` | Запуск mock-сервера | `powershell -ExecutionPolicy Bypass -File run_server.ps1` |
|
||||
| `setup.ps1` | Полная установка + генерация + запуск | `powershell -ExecutionPolicy Bypass -File setup.ps1` |
|
||||
|
||||
Скрипты принимают дополнительные аргументы, которые передаются в Python-скрипт:
|
||||
Скрипты принимают дополнительные аргументы:
|
||||
|
||||
```powershell
|
||||
# Запуск сервера только для Ozon, только FETCH_TRANSACTIONS
|
||||
powershell -ExecutionPolicy Bypass -File run_server.ps1 --banks ozon --types FETCH_TRANSACTIONS
|
||||
|
||||
# Генерация с явным серийником
|
||||
powershell -ExecutionPolicy Bypass -File gen_transactions.ps1 --serial 28291FDH300712
|
||||
|
||||
# Запуск сервера на другом порту
|
||||
powershell -ExecutionPolicy Bypass -File run_server.ps1 --port 9999
|
||||
```
|
||||
|
||||
---
|
||||
@ -104,8 +91,6 @@ powershell -ExecutionPolicy Bypass -File run_server.ps1 --port 9999
|
||||
## 3. Генерация FETCH_TRANSACTIONS задач
|
||||
|
||||
FETCH_TRANSACTIONS задачи генерируются **только вручную** с живого устройства.
|
||||
Скрипт подключается по ADB, проходит по списку транзакций на экране
|
||||
(тап -> снятие суммы/даты -> назад) и сохраняет JSON-файлы в `scenarios/pool/`.
|
||||
|
||||
### Подготовка
|
||||
|
||||
@ -113,158 +98,74 @@ FETCH_TRANSACTIONS задачи генерируются **только вруч
|
||||
2. Убедитесь что ADB видит устройство:
|
||||
```powershell
|
||||
tools\adb\windows\adb.exe devices
|
||||
# Должно показать серийник со статусом "device"
|
||||
```
|
||||
3. Откройте на устройстве банковское приложение на экране **списка транзакций**
|
||||
- Ozon: раздел "Операции" -> список переводов/пополнений
|
||||
- Dushanbe City Bank: раздел "Операции" -> список P2P операций
|
||||
|
||||
### Запуск
|
||||
|
||||
```powershell
|
||||
# Через вспомогательный скрипт (рекомендуется)
|
||||
cd tests\mock_server
|
||||
powershell -ExecutionPolicy Bypass -File gen_transactions.ps1
|
||||
|
||||
# Или напрямую (если Python в PATH)
|
||||
python tests\mock_server\gen_fetch_transactions.py
|
||||
```
|
||||
|
||||
Скрипт:
|
||||
1. Покажет меню с активными материалами:
|
||||
1. Покажет меню с активными материалами
|
||||
2. Выберите нужный или `all`
|
||||
3. Подтвердите что список транзакций открыт → **Enter**
|
||||
4. Автоматически пройдёт по транзакциям (**не трогайте телефон!**)
|
||||
5. Для **Ozon**: парсит заголовки дат, определяет дату каждой транзакции по позиции на экране, из деталей берёт только время
|
||||
6. Для **Dushanbe**: время конвертируется из Asia/Dushanbe (UTC+5)
|
||||
7. Сохраняет в `scenarios/pool/` с номером карты в имени файла:
|
||||
```
|
||||
Available materials (3):
|
||||
1. Pixel 7 / ozon / Галина Терехина (material 30bc106c... serial=28291FDH30xxxx)
|
||||
2. Pixel 7 / dushanbe_city_bank / Мехрубон (material 61d967d1...)
|
||||
3. TECNO KM4 / ozon / Владислав Касаткин (material 49001bb5...)
|
||||
Pick [1-3] or 'all':
|
||||
pixel_7_ozon_7919_01_minus_108_ft_01.json
|
||||
pixel_7_ozon_9865_01_minus_108_ft_01.json
|
||||
pixel_7_dushanbe_city_bank_3258_01_plus_5_ft_01.json
|
||||
```
|
||||
2. Выберите нужный материал (номер) или `all` для всех
|
||||
3. Скрипт попросит подтвердить что список транзакций открыт -> нажмите **Enter**
|
||||
4. Автоматически пройдёт по каждой транзакции на экране (**не трогайте телефон!**)
|
||||
5. Сохранит задачи в `scenarios/pool/`
|
||||
6. Если mock-сервер запущен — сразу инжектнёт задачи в него
|
||||
|
||||
### Флаги
|
||||
### Важно
|
||||
|
||||
| Флаг | Описание |
|
||||
|---|---|
|
||||
| `--serial 28291FDH30xxxx` | Явный ADB-серийник (если несколько устройств) |
|
||||
| `--kopecks 100` | Множитель суммы (по умолчанию 100: рубли -> копейки) |
|
||||
| `--config path` | Путь к config.ini |
|
||||
|
||||
### Примеры
|
||||
|
||||
```powershell
|
||||
# Интерактивный выбор материала
|
||||
powershell -ExecutionPolicy Bypass -File gen_transactions.ps1
|
||||
|
||||
# Для конкретного устройства
|
||||
powershell -ExecutionPolicy Bypass -File gen_transactions.ps1 --serial 28291FDH300712
|
||||
|
||||
# С другим config
|
||||
powershell -ExecutionPolicy Bypass -File gen_transactions.ps1 --config cmake-build-debug\config.ini
|
||||
```
|
||||
|
||||
### Результат
|
||||
|
||||
После генерации в `scenarios/pool/` появятся файлы вида:
|
||||
```
|
||||
pixel_7_ozon_ft_01_plus_15000_143022.json
|
||||
pixel_7_ozon_ft_02_minus_500_150133.json
|
||||
tecno_km4_ozon_ft_01_plus_3000_091500.json
|
||||
```
|
||||
|
||||
Каждый файл содержит задачу с реальной суммой (в копейках) и временем (UTC):
|
||||
```json
|
||||
{
|
||||
"bank_name": "ozon",
|
||||
"material_id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13",
|
||||
"type": "FETCH_TRANSACTIONS",
|
||||
"body": {
|
||||
"amount": 1500000,
|
||||
"created_at": "2026-04-10T11:30:22Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
При следующем запуске `setup.ps1` пул сохраняется (если не указать `-Clean`),
|
||||
и мок будет раздавать задачи из него при бутстрапе.
|
||||
- Если у банка **два материала** (две карты), генерируются **отдельные файлы** для каждого
|
||||
- При смене устройств/аккаунтов нужно перегенерировать pool — `material_id` в файлах должны совпадать с `default_profile.json`
|
||||
|
||||
---
|
||||
|
||||
## 4. Ручной запуск (пошагово)
|
||||
|
||||
Если не хотите использовать `setup.ps1`:
|
||||
|
||||
### 4.1. Установка зависимостей
|
||||
|
||||
```powershell
|
||||
# Python 3.9+ (если нет — скачать с python.org, включить «Add to PATH»)
|
||||
python --version
|
||||
|
||||
# Flask
|
||||
python --version # Python 3.9+
|
||||
pip install flask
|
||||
```
|
||||
|
||||
### 4.2. Подготовка config.ini
|
||||
|
||||
В `config.ini` (рядом с `ARC.exe`) переключить `apiBase`:
|
||||
|
||||
```ini
|
||||
[net]
|
||||
apiBase=http://127.0.0.1:8888
|
||||
```
|
||||
|
||||
> Совет: держите копию `config.ini.mock`, а перед тестом
|
||||
> `copy config.ini.mock config.ini`.
|
||||
|
||||
### 4.3. Генерация профиля из БД
|
||||
### 4.3. Генерация данных + запуск
|
||||
|
||||
```powershell
|
||||
# Из корня проекта
|
||||
# Генерация профиля
|
||||
python tests\mock_server\gen_mock_data.py
|
||||
```
|
||||
|
||||
Создаёт `default_profile.json` и FETCH_PROFILE scenarios.
|
||||
|
||||
### 4.4. Генерация пула транзакций (опционально)
|
||||
|
||||
См. [раздел 3](#3-генерация-fetch_transactions-задач).
|
||||
|
||||
### 4.5. Запуск mock-сервера
|
||||
|
||||
```powershell
|
||||
# Запуск сервера
|
||||
cd tests\mock_server
|
||||
python mock_server.py --port 8888
|
||||
python mock_server.py --port 8888 --banks ozon --types FETCH_TRANSACTIONS
|
||||
|
||||
# Debug режим (auto-reload)
|
||||
python mock_server.py --port 8888 --banks ozon --debug
|
||||
```
|
||||
|
||||
Должны увидеть:
|
||||
```
|
||||
[mock] state cleared — starting fresh
|
||||
[mock] loaded default_profile.json: 3 bank_profiles, 3 materials
|
||||
[mock] loaded pool: 19 FETCH_TRANSACTIONS across 3 materials
|
||||
* Running on http://127.0.0.1:8888
|
||||
```
|
||||
|
||||
### 4.6. Запуск ARC
|
||||
|
||||
В отдельном терминале:
|
||||
### 4.4. Запуск ARC
|
||||
|
||||
```powershell
|
||||
cd build\Release
|
||||
cd C:\ARC\Ver.18 # или build\Release
|
||||
ARC.exe
|
||||
```
|
||||
|
||||
Или через CLion: Run -> ARC.
|
||||
|
||||
При логине мок автоматически ставит задачи:
|
||||
- 1 x FETCH_PROFILE на каждый материал (всегда)
|
||||
- N x FETCH_TRANSACTIONS из пула (если пул не пуст)
|
||||
|
||||
Задачи раздаются по 1 на material за поллинг — следующая только
|
||||
после результата предыдущей.
|
||||
|
||||
---
|
||||
|
||||
## 5. Мониторинг и отчёт
|
||||
@ -272,46 +173,37 @@ ARC.exe
|
||||
```powershell
|
||||
# Открыть отчёт в браузере (авто-обновление каждые 2 сек)
|
||||
start http://127.0.0.1:8888/_admin/report/html
|
||||
|
||||
# Счётчики
|
||||
curl -s http://127.0.0.1:8888/_admin/state
|
||||
|
||||
# Полный JSON-отчёт
|
||||
curl -s http://127.0.0.1:8888/_admin/report
|
||||
```
|
||||
|
||||
Если `curl` нет, используйте PowerShell:
|
||||
Отчёт показывает:
|
||||
- **Сводку**: всего / выполнено / ошибки / в очереди
|
||||
- **Группировку**: устройство → банк → задачи
|
||||
- **Для каждой задачи**:
|
||||
- Статус (completed / error / pending)
|
||||
- Task JSON + Result JSON (успешные свёрнуты, ошибки развёрнуты)
|
||||
- Скриншот (если есть) — клик для полноэкранного просмотра
|
||||
- Кнопка "Копировать" на каждом JSON блоке
|
||||
- Авто-обновление без сброса состояния развёрнутых блоков
|
||||
|
||||
```powershell
|
||||
# Счётчики
|
||||
(Invoke-WebRequest http://127.0.0.1:8888/_admin/state).Content | ConvertFrom-Json
|
||||
|
||||
# Полный отчёт в файл
|
||||
Invoke-WebRequest http://127.0.0.1:8888/_admin/report -OutFile report.json
|
||||
```
|
||||
|
||||
На главной странице отчёта (`/_admin/report/html`) отображаются:
|
||||
- Счётчики задач (всего / выполнено / в очереди)
|
||||
- **Загруженные данные** — устройства, профили, материалы с количеством задач в пуле (группировка по устройствам)
|
||||
- Задачи с результатами, сгруппированные по устройствам
|
||||
|
||||
---
|
||||
|
||||
## 6. Ручной инжект задач
|
||||
|
||||
```powershell
|
||||
# Одна задача из файла
|
||||
# Одна задача
|
||||
curl -X POST http://127.0.0.1:8888/_admin/inject ^
|
||||
-H "Content-Type: application/json" ^
|
||||
--data-binary @scenarios\pixel_7_ozon_fetch_profile.json
|
||||
--data-binary @scenarios\pixel_7_ozon_7919_fetch_profile.json
|
||||
|
||||
# N случайных из пула
|
||||
python inject_random.py --count 5
|
||||
|
||||
# Повторный бутстрап (1 FP + N FT на material)
|
||||
# Повторный бутстрап
|
||||
curl -X POST http://127.0.0.1:8888/_admin/bootstrap
|
||||
|
||||
# Сброс (очистка очереди + результатов)
|
||||
# Сброс (очистка всего + скриншоты)
|
||||
curl -X POST http://127.0.0.1:8888/_admin/reset
|
||||
```
|
||||
|
||||
@ -319,19 +211,16 @@ curl -X POST http://127.0.0.1:8888/_admin/reset
|
||||
|
||||
## 7. ADB на Windows
|
||||
|
||||
ARC использует ADB из `tools\adb\windows\`. Проверка:
|
||||
ARC использует ADB из `tools\adb\windows\`:
|
||||
|
||||
```powershell
|
||||
tools\adb\windows\adb.exe devices
|
||||
```
|
||||
|
||||
Должно показать устройства в статусе `device`.
|
||||
|
||||
Если ADB занят другим процессом (Android Studio, scrcpy):
|
||||
Если ADB занят:
|
||||
```powershell
|
||||
tools\adb\windows\adb.exe kill-server
|
||||
tools\adb\windows\adb.exe start-server
|
||||
tools\adb\windows\adb.exe devices
|
||||
```
|
||||
|
||||
---
|
||||
@ -340,26 +229,25 @@ tools\adb\windows\adb.exe devices
|
||||
|
||||
| Проблема | Решение |
|
||||
|---|---|
|
||||
| `python` не найден | Запустите `setup.ps1` — установит автоматически. Или используйте скрипты `gen_transactions.ps1` / `run_server.ps1` — в них путь уже зашит |
|
||||
| Порт 8888 занят | `setup.ps1` убивает старый процесс автоматически. Вручную: `netstat -ano \| findstr :8888` -> `taskkill /PID <pid> /F` |
|
||||
| `curl` нет | `Invoke-WebRequest` (PowerShell) или `winget install curl` |
|
||||
| Firewall блокирует | Разрешить `python.exe` в Windows Firewall. Мок слушает только `127.0.0.1` |
|
||||
| ARC не видит устройства | Проверить USB-драйверы, включить USB debugging, подтвердить RSA-ключ |
|
||||
| Кракозябры в консоли | `chcp 65001` перед запуском (UTF-8). Скрипты `.ps1` делают это автоматически |
|
||||
| BOM в config.ini | `setup.ps1` пишет без BOM. Если правили вручную — пересохраните в UTF-8 без BOM (VS Code) |
|
||||
| No active materials | БД пустая. Запустите ARC с боевым сервером, залогиньтесь, добавьте устройства |
|
||||
| Пул транзакций пуст | Сгенерируйте: [раздел 3](#3-генерация-fetch_transactions-задач) |
|
||||
| `No ADB devices match` | Устройства не подключены или serial не совпадает с БД. Проверьте `adb devices` |
|
||||
| SSL ошибки при сборке | OpenSSL DLL копируются автоматически через CMake post-build |
|
||||
| `python` не найден | Запустите `setup.ps1` — установит автоматически |
|
||||
| Порт 8888 занят | `setup.ps1` убивает автоматически. Вручную: `netstat -ano \| findstr :8888` → `taskkill /PID <pid> /F` |
|
||||
| `App restarted` ошибки | Мок-сервер чистит events в БД при старте. Запускайте сервер **до** ARC |
|
||||
| Pool material_id не совпадает | `gen_mock_data.py` обновил material_id. Перегенерируйте pool |
|
||||
| `Transaction not found` | Баг поиска — анализировать логи (device= в ошибке) |
|
||||
| `Page load error` | Ozon WebView не загрузился — проблема интернета/устройства |
|
||||
| Скриншот не привязался | Нет `internal_task_id` — обновить ARC |
|
||||
| Кракозябры в консоли | `chcp 65001` (UTF-8). Скрипты `.ps1` делают автоматически |
|
||||
| No active materials | Нет профилей с проверенным PIN. Запустить ARC с боевым сервером |
|
||||
| Firewall | Разрешить `python.exe`. Мок слушает только `127.0.0.1` |
|
||||
|
||||
---
|
||||
|
||||
## 9. Быстрый чеклист
|
||||
|
||||
```
|
||||
[ ] powershell -ExecutionPolicy Bypass -File setup.ps1
|
||||
[ ] Устройства в adb devices -> device
|
||||
[ ] (опционально) powershell -ExecutionPolicy Bypass -File gen_transactions.ps1
|
||||
[ ] powershell -ExecutionPolicy Bypass -File setup.ps1 -Banks ozon
|
||||
[ ] Устройства в adb devices → device
|
||||
[ ] (опционально) gen_transactions.ps1 для пула транзакций
|
||||
[ ] ARC.exe запущен (отдельный терминал)
|
||||
[ ] Браузер: http://127.0.0.1:8888/_admin/report/html
|
||||
[ ] Дождаться completed = всего в отчёте
|
||||
|
||||
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": [
|
||||
{
|
||||
"id": "85f577bc-20be-40fd-ab47-315af3aadfb9",
|
||||
"id": "b8763ad9-50bd-4d8c-b6f1-cc122119e2df",
|
||||
"bank_name": "dushanbe_city_bank",
|
||||
"state": "enabled",
|
||||
"phone_number": "+992185555519",
|
||||
"first_name": "Мехрубон",
|
||||
"middle_name": null,
|
||||
"last_name": "Имомкулзода",
|
||||
"currency": 972,
|
||||
"device_id": "986cb259-dfec-4421-8735-4fb126cd1917",
|
||||
"device_label": "Pixel 7",
|
||||
"materials": [
|
||||
{
|
||||
"id": "36002ac2-6f4d-49da-8892-18db723a31c7",
|
||||
"state": "enabled",
|
||||
"card_number": "9762000207113258",
|
||||
"currency": 972
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ec6b5712-067c-4354-8641-a3f19a9fdef1",
|
||||
"bank_name": "ozon",
|
||||
"state": "enabled",
|
||||
"phone_number": "+79538038318",
|
||||
@ -9,22 +29,42 @@
|
||||
"middle_name": null,
|
||||
"last_name": "Терехина",
|
||||
"currency": 643,
|
||||
"device_id": "c9d3ad7e-659d-4adf-815e-9f234e743078",
|
||||
"device_id": "986cb259-dfec-4421-8735-4fb126cd1917",
|
||||
"device_label": "Pixel 7",
|
||||
"materials": [
|
||||
{
|
||||
"id": "e6c6e12a-3e4e-444c-9e11-62543fb5f4f9",
|
||||
"id": "50393cc1-57e4-4293-9a59-6cdd1d9ce6cb",
|
||||
"state": "enabled",
|
||||
"card_number": "2204321035207919",
|
||||
"currency": 643
|
||||
},
|
||||
{
|
||||
"id": "39592a22-3e79-4438-89d7-778aea72a5e9",
|
||||
"id": "06a1ae15-abd2-4d90-aac0-77f4a4fd32e8",
|
||||
"state": "enabled",
|
||||
"card_number": "2204321086109865",
|
||||
"currency": 643
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a3fa08d3-58d7-4744-886c-c81afa707dcd",
|
||||
"bank_name": "ozon",
|
||||
"state": "enabled",
|
||||
"phone_number": "+79855532896",
|
||||
"first_name": "Владислав",
|
||||
"middle_name": null,
|
||||
"last_name": "Касаткин",
|
||||
"currency": 643,
|
||||
"device_id": "ec6397fb-89b1-41e1-9b6b-303d6f1e943f",
|
||||
"device_label": "TECNO KM4",
|
||||
"materials": [
|
||||
{
|
||||
"id": "7e3a7e4e-0d56-4d66-ac44-2b240ec33620",
|
||||
"state": "enabled",
|
||||
"card_number": "2204320439130982",
|
||||
"currency": 643
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -479,7 +479,9 @@ def collect_tasks(serial, bank, mid, device_tz, kopecks, prefix):
|
||||
|
||||
amount = int(float(amt_s)) if amt_s else (row["amount"] or 0)
|
||||
fmt = "%d.%m.%Y %H:%M:%S" if len(time_s) > 5 else "%d.%m.%Y %H:%M"
|
||||
dt = datetime.strptime(f"{date_s} {time_s}", fmt).replace(tzinfo=device_tz)
|
||||
# Душанбе показывает время в Asia/Dushanbe (UTC+5), не в таймзоне устройства
|
||||
dushanbe_tz = timezone(timedelta(hours=5))
|
||||
dt = datetime.strptime(f"{date_s} {time_s}", fmt).replace(tzinfo=dushanbe_tz)
|
||||
cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
dedup_key = (amount, cat)
|
||||
if dedup_key in processed_keys:
|
||||
|
||||
@ -89,10 +89,11 @@ def query_active_materials(db_path: Path):
|
||||
FROM devices d
|
||||
JOIN bank_profiles bp ON bp.device_id = d.id
|
||||
JOIN materials m ON m.device_id = d.id AND m.app_code = bp.code
|
||||
WHERE bp.status = 'active'
|
||||
AND m.status = 'active'
|
||||
AND m.material_id != ''
|
||||
AND bp.bank_profile_id != ''
|
||||
WHERE bp.pin_code IS NOT NULL AND bp.pin_code != ''
|
||||
AND bp.pin_code_checked = 'yes'
|
||||
AND m.status = 'active'
|
||||
AND m.material_id IS NOT NULL AND m.material_id != ''
|
||||
AND bp.bank_profile_id IS NOT NULL AND bp.bank_profile_id != ''
|
||||
ORDER BY d.name, bp.code
|
||||
""").fetchall()
|
||||
conn.close()
|
||||
|
||||
@ -40,7 +40,10 @@ Usage:
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
@ -75,6 +78,51 @@ _in_flight: set = set() # material_ids with a delivered-but-not-yet-resulted ta
|
||||
_bootstrap_done = False
|
||||
_bootstrap_ft_count = 3
|
||||
_bootstrap_banks: list | None = None # None = all banks
|
||||
_bootstrap_types: list | None = None # None = all types (FETCH_PROFILE + FETCH_TRANSACTIONS)
|
||||
|
||||
_STATE_FILE = HERE / ".mock_state.json"
|
||||
|
||||
|
||||
def _save_state():
|
||||
"""Persist task state to disk so debug reloader doesn't lose it."""
|
||||
try:
|
||||
state = {
|
||||
"injected": _injected,
|
||||
"task_queue": list(_task_queue),
|
||||
"in_flight": list(_in_flight),
|
||||
"bootstrap_done": _bootstrap_done,
|
||||
"pending_by_key": {f"{k[0]}|{k[1]}": v for k, v in _pending_by_key.items()},
|
||||
}
|
||||
_STATE_FILE.write_text(json.dumps(state, default=str), encoding="utf-8")
|
||||
except Exception as e:
|
||||
print(f"[mock] failed to save state: {e}")
|
||||
|
||||
|
||||
def _load_state() -> bool:
|
||||
"""Restore state from disk. Returns True if loaded."""
|
||||
global _bootstrap_done
|
||||
if not _STATE_FILE.exists():
|
||||
return False
|
||||
try:
|
||||
state = json.loads(_STATE_FILE.read_text(encoding="utf-8"))
|
||||
_injected.clear()
|
||||
_injected.extend(state.get("injected", []))
|
||||
_task_queue.clear()
|
||||
_task_queue.extend(state.get("task_queue", []))
|
||||
_in_flight.clear()
|
||||
_in_flight.update(state.get("in_flight", []))
|
||||
_bootstrap_done = state.get("bootstrap_done", False)
|
||||
_pending_by_key.clear()
|
||||
for k, v in state.get("pending_by_key", {}).items():
|
||||
parts = k.split("|", 1)
|
||||
if len(parts) == 2:
|
||||
_pending_by_key[(parts[0], parts[1])] = v
|
||||
print(f"[mock] restored state: {len(_injected)} injected, "
|
||||
f"{len(_task_queue)} queued, bootstrap={'done' if _bootstrap_done else 'pending'}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[mock] failed to load state: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def ok(data=None):
|
||||
@ -173,25 +221,28 @@ def bootstrap_tasks():
|
||||
if not mid:
|
||||
continue
|
||||
# 1 × FETCH_PROFILE
|
||||
enqueue_task({
|
||||
"bank_name": bank,
|
||||
"material_id": mid,
|
||||
"type": "FETCH_PROFILE",
|
||||
"body": {},
|
||||
"_source": "bootstrap",
|
||||
})
|
||||
queued += 1
|
||||
if not _bootstrap_types or "FETCH_PROFILE" in _bootstrap_types:
|
||||
enqueue_task({
|
||||
"bank_name": bank,
|
||||
"material_id": mid,
|
||||
"type": "FETCH_PROFILE",
|
||||
"body": {},
|
||||
"_source": "bootstrap",
|
||||
})
|
||||
queued += 1
|
||||
|
||||
# N × random FETCH_TRANSACTIONS from pool
|
||||
pool = _pool_by_material.get(mid, [])
|
||||
if not pool:
|
||||
print(f"[mock] bootstrap: no pool tasks for {mid} ({bank})")
|
||||
continue
|
||||
pick = random.sample(pool, min(_bootstrap_ft_count, len(pool)))
|
||||
for t in pick:
|
||||
enqueue_task(t)
|
||||
queued += 1
|
||||
if not _bootstrap_types or "FETCH_TRANSACTIONS" in _bootstrap_types:
|
||||
pool = _pool_by_material.get(mid, [])
|
||||
if not pool:
|
||||
print(f"[mock] bootstrap: no pool tasks for {mid} ({bank})")
|
||||
continue
|
||||
pick = random.sample(pool, min(_bootstrap_ft_count, len(pool)))
|
||||
for t in pick:
|
||||
enqueue_task(t)
|
||||
queued += 1
|
||||
_bootstrap_done = True
|
||||
_save_state()
|
||||
print(f"[mock] bootstrap queued {queued} tasks")
|
||||
|
||||
|
||||
@ -359,6 +410,7 @@ def get_tasks():
|
||||
_in_flight.add(mid)
|
||||
_task_queue.extend(kept)
|
||||
if delivered:
|
||||
_save_state()
|
||||
log("GET /task/get_tasks -> delivered",
|
||||
[{"type": t.get("type"), "material_id": t.get("material_id")}
|
||||
for t in delivered])
|
||||
@ -393,15 +445,72 @@ def task_result():
|
||||
"result_at": time.time(),
|
||||
"status": "unsolicited",
|
||||
})
|
||||
_save_state()
|
||||
return ok({})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- monitoring
|
||||
_monitoring_logs: list = [] # {type, event, message, has_image, image_id}
|
||||
_SCREENSHOTS_DIR = HERE / ".mock_screenshots"
|
||||
|
||||
|
||||
@app.post("/monitoring/log")
|
||||
def monitoring_log():
|
||||
mtype = request.form.get("type", "")
|
||||
mevent = request.form.get("event", "")
|
||||
message = request.form.get("message", "")
|
||||
image_file = request.files.get("image")
|
||||
|
||||
image_id = None
|
||||
if image_file:
|
||||
image_id = str(uuid.uuid4())
|
||||
_SCREENSHOTS_DIR.mkdir(exist_ok=True)
|
||||
(_SCREENSHOTS_DIR / f"{image_id}.png").write_bytes(image_file.read())
|
||||
|
||||
entry = {
|
||||
"type": mtype,
|
||||
"event": mevent,
|
||||
"message": message,
|
||||
"has_image": image_id is not None,
|
||||
"image_id": image_id,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
with _lock:
|
||||
_monitoring_logs.append(entry)
|
||||
|
||||
# Привязываем скриншот к задаче по internal_task_id
|
||||
internal_tid = request.form.get("internal_task_id", "")
|
||||
if image_id:
|
||||
if not internal_tid:
|
||||
print(f"[mock] WARNING: monitoring_log without internal_task_id, screenshot not linked")
|
||||
else:
|
||||
matched = False
|
||||
with _lock:
|
||||
for e in reversed(_injected):
|
||||
r = e.get("result")
|
||||
if r and str(r.get("internal_task_id", "")) == internal_tid:
|
||||
e["screenshot_id"] = image_id
|
||||
matched = True
|
||||
break
|
||||
if matched:
|
||||
_save_state()
|
||||
else:
|
||||
print(f"[mock] WARNING: no task found for internal_task_id={internal_tid}")
|
||||
|
||||
log("POST /monitoring/log", {"type": mtype, "event": mevent,
|
||||
"has_image": image_id is not None,
|
||||
"message": message[:100]})
|
||||
return ok({})
|
||||
|
||||
|
||||
@app.get("/_admin/screenshot/<image_id>")
|
||||
def get_screenshot(image_id):
|
||||
path = _SCREENSHOTS_DIR / f"{image_id}.png"
|
||||
if not path.exists():
|
||||
return "not found", 404
|
||||
return Response(path.read_bytes(), mimetype="image/png")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- admin
|
||||
@app.post("/_admin/inject")
|
||||
def admin_inject():
|
||||
@ -445,10 +554,12 @@ def admin_bootstrap():
|
||||
def admin_report():
|
||||
"""Full task ↔ result pairing report."""
|
||||
with _lock:
|
||||
errors = sum(1 for e in _injected if e["status"] == "completed" and _is_error_entry(e))
|
||||
summary = {
|
||||
"total": len(_injected),
|
||||
"pending": sum(1 for e in _injected if e["status"] == "pending"),
|
||||
"completed": sum(1 for e in _injected if e["status"] == "completed"),
|
||||
"completed": sum(1 for e in _injected if e["status"] == "completed") - errors,
|
||||
"errors": errors,
|
||||
"unsolicited": sum(1 for e in _injected if e["status"] == "unsolicited"),
|
||||
}
|
||||
return jsonify({
|
||||
@ -463,12 +574,25 @@ def admin_report_pending():
|
||||
return jsonify([e for e in _injected if e["status"] == "pending"])
|
||||
|
||||
|
||||
def _is_error_entry(e) -> bool:
|
||||
"""Check if a completed entry has an error in result data."""
|
||||
result = e.get("result")
|
||||
if not result:
|
||||
return False
|
||||
data = result.get("data")
|
||||
if isinstance(data, dict) and data.get("status") == "error":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _snapshot_entries_and_summary():
|
||||
entries = list(_injected)
|
||||
errors = sum(1 for e in entries if e["status"] == "completed" and _is_error_entry(e))
|
||||
summary = {
|
||||
"total": len(entries),
|
||||
"pending": sum(1 for e in entries if e["status"] == "pending"),
|
||||
"completed": sum(1 for e in entries if e["status"] == "completed"),
|
||||
"completed": sum(1 for e in entries if e["status"] == "completed") - errors,
|
||||
"errors": errors,
|
||||
"unsolicited": sum(1 for e in entries if e["status"] == "unsolicited"),
|
||||
}
|
||||
return entries, summary
|
||||
@ -487,33 +611,7 @@ def admin_report_fragment():
|
||||
"""JSON fragment for JS auto-refresh: rendered body HTML + summary counts."""
|
||||
with _lock:
|
||||
entries, summary = _snapshot_entries_and_summary()
|
||||
# Render just the grouped body (without the full page shell)
|
||||
from collections import defaultdict as _dd
|
||||
import html as _html
|
||||
groups = _dd(list)
|
||||
for e in entries:
|
||||
mid = (e.get("task") or {}).get("material_id")
|
||||
label = _device_label_by_material.get(mid) or (
|
||||
"(без устройства)" if mid is None else f"{mid[:8]}…"
|
||||
)
|
||||
groups[label].append(e)
|
||||
|
||||
blocks = []
|
||||
for label in sorted(groups.keys()):
|
||||
gentries = groups[label]
|
||||
g_done = sum(1 for e in gentries if e["status"] == "completed")
|
||||
g_pend = sum(1 for e in gentries if e["status"] == "pending")
|
||||
rows_html = "\n".join(render_entry_html(e) for e in gentries)
|
||||
blocks.append(
|
||||
f'<section class="group">'
|
||||
f'<h2><span class="dev">{_html.escape(label)}</span>'
|
||||
f'<span class="g-stats">'
|
||||
f'<span class="g-done">{g_done}</span>'
|
||||
f' / <span class="g-total">{len(gentries)}</span>'
|
||||
f' <span class="muted">(ожидают: {g_pend})</span>'
|
||||
f'</span></h2>{rows_html}</section>'
|
||||
)
|
||||
body = "\n".join(blocks) or '<div class="empty">Отчёт пуст</div>'
|
||||
body = _render_grouped_blocks(entries) or '<div class="empty">Отчёт пуст</div>'
|
||||
return jsonify({"html": body, "summary": summary})
|
||||
|
||||
|
||||
@ -531,6 +629,8 @@ def _esc(s: str) -> str:
|
||||
def render_entry_html(e) -> str:
|
||||
import html as _html
|
||||
status = e["status"]
|
||||
is_error = _is_error_entry(e)
|
||||
display_status = "error" if is_error else status
|
||||
task = e.get("task") or {}
|
||||
result = e.get("result")
|
||||
ttype = _html.escape(task.get("type", "?"))
|
||||
@ -540,41 +640,70 @@ def render_entry_html(e) -> str:
|
||||
injected_at = _fmt_ts(e.get("injected_at"))
|
||||
result_at = _fmt_ts(e.get("result_at"))
|
||||
|
||||
tid = _html.escape(e.get("id", ""))
|
||||
|
||||
def json_block(obj):
|
||||
if obj is None:
|
||||
return '<span class="muted">—</span>'
|
||||
text = json.dumps(obj, ensure_ascii=False, indent=2)
|
||||
return f'<pre>{_html.escape(text)}</pre>'
|
||||
escaped = _html.escape(text)
|
||||
# data-json с escaped JSON для копирования
|
||||
raw = _html.escape(text, quote=True)
|
||||
return (f'<div class="json-wrap">'
|
||||
f'<button class="copy-btn" onclick="copyJson(this)" data-json="{raw}">Копировать</button>'
|
||||
f'<pre>{escaped}</pre></div>')
|
||||
|
||||
result_summary = ""
|
||||
if result and isinstance(result.get("data"), dict):
|
||||
d = result["data"]
|
||||
if d.get("status") == "error":
|
||||
result_summary = f'<div class="err">ERROR: {_html.escape(str(d.get("description", "")))}</div>'
|
||||
if is_error:
|
||||
d = result.get("data", {}) if result else {}
|
||||
result_summary = f'<div class="err">ERROR: {_html.escape(str(d.get("description", "")))}</div>'
|
||||
elif result and isinstance(result.get("data"), list):
|
||||
result_summary = f'<div class="muted">{len(result["data"])} items</div>'
|
||||
|
||||
scr_id = e.get("screenshot_id")
|
||||
scr_pane = ""
|
||||
if scr_id:
|
||||
scr_pane = f"""
|
||||
<div class="pane screenshot-pane">
|
||||
<div class="label">Screenshot</div>
|
||||
<img src="/_admin/screenshot/{scr_id}" class="screenshot" loading="lazy">
|
||||
</div>"""
|
||||
|
||||
has_screenshot = bool(scr_id)
|
||||
panes_cls = "panes panes-3" if has_screenshot else "panes"
|
||||
|
||||
# Успешные — вся задача свёрнута, ошибки — развёрнуты
|
||||
if display_status == "completed":
|
||||
panes_html = f"""
|
||||
<div class="toggle-wrap" data-tid="{tid}">
|
||||
<button class="toggle-btn" onclick="this.parentElement.classList.toggle('open')">Показать JSON</button>
|
||||
<div class="toggle-content">
|
||||
<div class="{panes_cls}">
|
||||
<div class="pane task"><div class="label">Task JSON</div>{json_block(task)}</div>
|
||||
<div class="pane result"><div class="label">Result JSON</div>{result_summary}{json_block(result)}</div>
|
||||
{scr_pane}
|
||||
</div>
|
||||
</div>
|
||||
</div>"""
|
||||
else:
|
||||
panes_html = f"""
|
||||
<div class="{panes_cls}">
|
||||
<div class="pane task"><div class="label">Task JSON</div>{json_block(task)}</div>
|
||||
<div class="pane result"><div class="label">Result JSON</div>{result_summary}{json_block(result)}</div>
|
||||
{scr_pane}
|
||||
</div>"""
|
||||
|
||||
return f"""
|
||||
<div class="entry {status}">
|
||||
<div class="entry {display_status}">
|
||||
<div class="head">
|
||||
<span class="badge {status}">{status}</span>
|
||||
<span class="badge {display_status}">{display_status}</span>
|
||||
<span class="type">{ttype}</span>
|
||||
<span class="bank">{bank}</span>
|
||||
<span class="mid">{mid}…</span>
|
||||
<span class="src">{src}</span>
|
||||
<span class="times">inj {injected_at} → res {result_at}</span>
|
||||
</div>
|
||||
<div class="panes">
|
||||
<div class="pane task">
|
||||
<div class="label">Task JSON</div>
|
||||
{json_block(task)}
|
||||
</div>
|
||||
<div class="pane result">
|
||||
<div class="label">Result JSON</div>
|
||||
{result_summary}
|
||||
{json_block(result)}
|
||||
</div>
|
||||
</div>
|
||||
{panes_html}
|
||||
</div>
|
||||
"""
|
||||
|
||||
@ -670,25 +799,52 @@ def render_profile_section() -> str:
|
||||
)
|
||||
|
||||
|
||||
def render_report_html(entries, summary) -> str:
|
||||
def _render_grouped_blocks(entries) -> str:
|
||||
import html as _html
|
||||
|
||||
# Group by device label
|
||||
groups: dict = defaultdict(list)
|
||||
# Group by device label → bank_name
|
||||
devices: dict = defaultdict(lambda: defaultdict(list))
|
||||
for e in entries:
|
||||
mid = (e.get("task") or {}).get("material_id")
|
||||
task = e.get("task") or {}
|
||||
mid = task.get("material_id")
|
||||
label = _device_label_by_material.get(mid) or (
|
||||
"(без устройства)" if mid is None else f"{mid[:8]}…"
|
||||
)
|
||||
groups[label].append(e)
|
||||
bank = task.get("bank_name", "unknown")
|
||||
devices[label][bank].append(e)
|
||||
|
||||
group_blocks = []
|
||||
for label in sorted(groups.keys()):
|
||||
gentries = groups[label]
|
||||
g_total = len(gentries)
|
||||
g_done = sum(1 for e in gentries if e["status"] == "completed")
|
||||
g_pend = sum(1 for e in gentries if e["status"] == "pending")
|
||||
rows_html = "\n".join(render_entry_html(e) for e in gentries)
|
||||
for label in sorted(devices.keys()):
|
||||
banks = devices[label]
|
||||
# Device-level stats
|
||||
all_entries = [e for bl in banks.values() for e in bl]
|
||||
g_total = len(all_entries)
|
||||
g_err = sum(1 for e in all_entries if _is_error_entry(e))
|
||||
g_done = sum(1 for e in all_entries if e["status"] == "completed") - g_err
|
||||
g_pend = sum(1 for e in all_entries if e["status"] == "pending")
|
||||
err_badge = f' <span style="color:var(--unsolicited)">ошибки: {g_err}</span>' if g_err else ''
|
||||
|
||||
bank_blocks = []
|
||||
for bank in sorted(banks.keys()):
|
||||
bentries = banks[bank]
|
||||
b_total = len(bentries)
|
||||
b_err = sum(1 for e in bentries if _is_error_entry(e))
|
||||
b_done = sum(1 for e in bentries if e["status"] == "completed") - b_err
|
||||
b_pend = sum(1 for e in bentries if e["status"] == "pending")
|
||||
b_err_badge = f' <span style="color:var(--unsolicited)">{b_err}</span>' if b_err else ''
|
||||
rows_html = "\n".join(render_entry_html(e) for e in bentries)
|
||||
bank_blocks.append(
|
||||
f'<div class="bank-group">'
|
||||
f'<h3><span class="bank">{_html.escape(bank)}</span>'
|
||||
f'<span class="g-stats">'
|
||||
f'<span class="g-done">{b_done}</span>'
|
||||
f' / <span class="g-total">{b_total}</span>'
|
||||
f'{b_err_badge}'
|
||||
f' <span class="muted">(ожидают: {b_pend})</span>'
|
||||
f'</span></h3>'
|
||||
f'{rows_html}</div>'
|
||||
)
|
||||
|
||||
group_blocks.append(f"""
|
||||
<section class="group">
|
||||
<h2>
|
||||
@ -696,14 +852,19 @@ def render_report_html(entries, summary) -> str:
|
||||
<span class="g-stats">
|
||||
<span class="g-done">{g_done}</span>
|
||||
/ <span class="g-total">{g_total}</span>
|
||||
{err_badge}
|
||||
<span class="muted">(ожидают: {g_pend})</span>
|
||||
</span>
|
||||
</h2>
|
||||
{rows_html}
|
||||
{"".join(bank_blocks)}
|
||||
</section>
|
||||
""")
|
||||
|
||||
body_rows = "\n".join(group_blocks) or '<div class="empty">Отчёт пуст — бутстрап ещё не сработал или результаты ещё не пришли.</div>'
|
||||
return "\n".join(group_blocks)
|
||||
|
||||
|
||||
def render_report_html(entries, summary) -> str:
|
||||
body_rows = _render_grouped_blocks(entries) or '<div class="empty">Отчёт пуст — бутстрап ещё не сработал или результаты ещё не пришли.</div>'
|
||||
|
||||
return f"""<!doctype html>
|
||||
<html lang="ru">
|
||||
@ -751,6 +912,7 @@ def render_report_html(entries, summary) -> str:
|
||||
overflow: hidden;
|
||||
}}
|
||||
.entry.completed {{ border-left: 4px solid var(--completed); }}
|
||||
.entry.error {{ border-left: 4px solid var(--unsolicited); }}
|
||||
.entry.pending {{ border-left: 4px solid var(--pending); }}
|
||||
.entry.unsolicited {{ border-left: 4px solid var(--unsolicited); }}
|
||||
.head {{
|
||||
@ -764,6 +926,7 @@ def render_report_html(entries, summary) -> str:
|
||||
}}
|
||||
.badge.pending {{ background: var(--pending); color: #1a1600; }}
|
||||
.badge.completed {{ background: var(--completed); color: #0a1a0a; }}
|
||||
.badge.error {{ background: var(--unsolicited); color: #200; }}
|
||||
.badge.unsolicited {{ background: var(--unsolicited); color: #200; }}
|
||||
.type {{ font-weight: 600; color: #9cdcfe; }}
|
||||
.bank {{ color: #ffb86c; }}
|
||||
@ -771,6 +934,7 @@ def render_report_html(entries, summary) -> str:
|
||||
.src {{ color: var(--muted); font-style: italic; }}
|
||||
.times {{ color: var(--muted); margin-left: auto; font-family: monospace; }}
|
||||
.panes {{ display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border); }}
|
||||
.panes-3 {{ grid-template-columns: 1fr 1fr auto; }}
|
||||
.pane {{ padding: 10px 12px; }}
|
||||
.pane.task {{ background: var(--task); }}
|
||||
.pane.result {{ background: var(--result); }}
|
||||
@ -785,6 +949,37 @@ def render_report_html(entries, summary) -> str:
|
||||
}}
|
||||
.muted {{ color: var(--muted); font-style: italic; }}
|
||||
.err {{ color: #ff6b6b; font-weight: 600; margin-bottom: 4px; }}
|
||||
.toggle-wrap .toggle-content {{ display: none; }}
|
||||
.toggle-wrap.open .toggle-content {{ display: block; }}
|
||||
.toggle-btn {{
|
||||
background: var(--panel); color: var(--muted);
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
padding: 4px 12px; margin: 8px 12px;
|
||||
cursor: pointer; font-size: 11px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}}
|
||||
.toggle-btn:hover {{ background: var(--border); color: var(--text); }}
|
||||
.json-wrap {{ position: relative; }}
|
||||
.copy-btn {{
|
||||
position: absolute; top: 6px; right: 6px;
|
||||
background: var(--border); color: var(--muted);
|
||||
border: none; border-radius: 4px;
|
||||
padding: 5px 12px; font-size: 12px;
|
||||
cursor: pointer; opacity: 0; transition: opacity 0.15s;
|
||||
}}
|
||||
.json-wrap:hover .copy-btn {{ opacity: 1; }}
|
||||
.copy-btn:hover {{ color: var(--text); background: #3a3f4a; }}
|
||||
.copy-btn.copied {{ color: var(--completed); }}
|
||||
.screenshot-pane {{ background: var(--panel); max-width: 280px; }}
|
||||
.screenshot {{ max-height: 200px; width: auto; border-radius: 4px; margin-top: 4px; cursor: pointer; transition: opacity 0.15s; }}
|
||||
.screenshot:hover {{ opacity: 0.8; }}
|
||||
.lightbox {{
|
||||
display: none; position: fixed; inset: 0; z-index: 1000;
|
||||
background: rgba(0,0,0,0.85); align-items: center; justify-content: center;
|
||||
cursor: zoom-out;
|
||||
}}
|
||||
.lightbox.open {{ display: flex; }}
|
||||
.lightbox img {{ max-width: 90vw; max-height: 90vh; border-radius: 6px; }}
|
||||
.empty {{ color: var(--muted); text-align: center; padding: 40px; }}
|
||||
a {{ color: #9cdcfe; }}
|
||||
.group {{
|
||||
@ -807,6 +1002,22 @@ def render_report_html(entries, summary) -> str:
|
||||
.group h2 .g-stats {{ margin-left: auto; font-family: monospace; font-size: 13px; }}
|
||||
.group h2 .g-done {{ color: var(--completed); font-weight: 700; }}
|
||||
.group h2 .g-total {{ color: var(--muted); }}
|
||||
.bank-group {{
|
||||
margin: 8px 0 16px;
|
||||
padding-left: 12px;
|
||||
border-left: 2px solid var(--border);
|
||||
}}
|
||||
.bank-group h3 {{
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}}
|
||||
.bank-group h3 .bank {{ color: #9cdcfe; font-weight: 600; }}
|
||||
.bank-group h3 .g-stats {{ margin-left: auto; font-family: monospace; font-size: 12px; }}
|
||||
.bank-group h3 .g-done {{ color: var(--completed); font-weight: 700; }}
|
||||
.bank-group h3 .g-total {{ color: var(--muted); }}
|
||||
.updating {{ opacity: 0.6; }}
|
||||
/* ─── Profile section ─── */
|
||||
.profile-section {{
|
||||
@ -891,8 +1102,8 @@ def render_report_html(entries, summary) -> str:
|
||||
<div class="summary">
|
||||
<div class="cell"><div class="n">{summary['total']}</div>всего</div>
|
||||
<div class="cell completed"><div class="n">{summary['completed']}</div>выполнено</div>
|
||||
<div class="cell unsolicited"><div class="n">{summary['errors']}</div>ошибки</div>
|
||||
<div class="cell pending"><div class="n">{summary['pending']}</div>в очереди</div>
|
||||
<div class="cell unsolicited"><div class="n">{summary['unsolicited']}</div>незапрошенные</div>
|
||||
</div>
|
||||
{render_profile_section()}
|
||||
<div id="report-body">
|
||||
@ -906,20 +1117,48 @@ def render_report_html(entries, summary) -> str:
|
||||
const summaryEls = {{
|
||||
total: document.querySelector('.summary .cell:nth-child(1) .n'),
|
||||
completed: document.querySelector('.summary .cell.completed .n'),
|
||||
errors: document.querySelector('.summary .cell.unsolicited .n'),
|
||||
pending: document.querySelector('.summary .cell.pending .n'),
|
||||
unsolicited: document.querySelector('.summary .cell.unsolicited .n'),
|
||||
}};
|
||||
|
||||
function saveToggleState() {{
|
||||
const state = new Set();
|
||||
bodyEl.querySelectorAll('.toggle-wrap[data-tid]').forEach(el => {{
|
||||
if (el.classList.contains('open')) state.add(el.dataset.tid);
|
||||
}});
|
||||
return state;
|
||||
}}
|
||||
|
||||
function restoreToggleState(state) {{
|
||||
bodyEl.querySelectorAll('.toggle-wrap[data-tid]').forEach(el => {{
|
||||
if (state.has(el.dataset.tid)) el.classList.add('open');
|
||||
}});
|
||||
updateToggleLabels();
|
||||
}}
|
||||
|
||||
function updateToggleLabels() {{
|
||||
bodyEl.querySelectorAll('.toggle-wrap').forEach(el => {{
|
||||
const btn = el.querySelector('.toggle-btn');
|
||||
if (btn) btn.textContent = el.classList.contains('open') ? 'Свернуть JSON' : 'Показать JSON';
|
||||
}});
|
||||
}}
|
||||
|
||||
document.addEventListener('click', e => {{
|
||||
if (e.target.classList.contains('toggle-btn')) updateToggleLabels();
|
||||
}});
|
||||
|
||||
async function tick() {{
|
||||
try {{
|
||||
const resp = await fetch('/_admin/report/fragment', {{cache: 'no-store'}});
|
||||
if (!resp.ok) throw new Error(resp.status);
|
||||
const data = await resp.json();
|
||||
const saved = saveToggleState();
|
||||
bodyEl.innerHTML = data.html;
|
||||
restoreToggleState(saved);
|
||||
summaryEls.total.textContent = data.summary.total;
|
||||
summaryEls.completed.textContent = data.summary.completed;
|
||||
summaryEls.errors.textContent = data.summary.errors;
|
||||
summaryEls.pending.textContent = data.summary.pending;
|
||||
summaryEls.unsolicited.textContent = data.summary.unsolicited;
|
||||
statusEl.textContent = '(обновлено ' + new Date().toLocaleTimeString() + ')';
|
||||
}} catch (e) {{
|
||||
statusEl.textContent = '(ошибка обновления: ' + e + ')';
|
||||
@ -929,6 +1168,31 @@ def render_report_html(entries, summary) -> str:
|
||||
setInterval(tick, REFRESH_MS);
|
||||
}})();
|
||||
</script>
|
||||
<script>
|
||||
function copyJson(btn) {{
|
||||
const text = btn.dataset.json;
|
||||
navigator.clipboard.writeText(text).then(() => {{
|
||||
btn.textContent = 'Скопировано';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(() => {{ btn.textContent = 'Копировать'; btn.classList.remove('copied'); }}, 1500);
|
||||
}});
|
||||
}}
|
||||
</script>
|
||||
<div class="lightbox" id="lightbox" onclick="this.classList.remove('open')">
|
||||
<img id="lightbox-img" src="" alt="">
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('click', function(e) {{
|
||||
if (e.target.classList.contains('screenshot')) {{
|
||||
const lb = document.getElementById('lightbox');
|
||||
document.getElementById('lightbox-img').src = e.target.src;
|
||||
lb.classList.add('open');
|
||||
}}
|
||||
}});
|
||||
document.addEventListener('keydown', function(e) {{
|
||||
if (e.key === 'Escape') document.getElementById('lightbox').classList.remove('open');
|
||||
}});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
@ -942,7 +1206,12 @@ def admin_reset():
|
||||
_injected.clear()
|
||||
_pending_by_key.clear()
|
||||
_in_flight.clear()
|
||||
_monitoring_logs.clear()
|
||||
_bootstrap_done = False
|
||||
if _SCREENSHOTS_DIR.exists():
|
||||
shutil.rmtree(_SCREENSHOTS_DIR)
|
||||
if _STATE_FILE.exists():
|
||||
_STATE_FILE.unlink()
|
||||
return ok({})
|
||||
|
||||
|
||||
@ -970,24 +1239,68 @@ if __name__ == "__main__":
|
||||
help="Only bootstrap tasks for these banks (e.g. --banks ozon dushanbe_city_bank)")
|
||||
parser.add_argument("--seed", type=int, default=None,
|
||||
help="RNG seed for reproducible bootstrap picks")
|
||||
parser.add_argument("--types", nargs="+", default=None,
|
||||
help="Only bootstrap these task types (e.g. --types FETCH_TRANSACTIONS)")
|
||||
parser.add_argument("--debug", action="store_true",
|
||||
help="Run Flask in debug mode (auto-reload on code changes)")
|
||||
args = parser.parse_args()
|
||||
|
||||
_bootstrap_ft_count = args.ft_per_material
|
||||
_bootstrap_banks = args.banks
|
||||
_bootstrap_types = args.types
|
||||
if _bootstrap_banks:
|
||||
print(f"[mock] bootstrap limited to banks: {_bootstrap_banks}")
|
||||
if _bootstrap_types:
|
||||
print(f"[mock] bootstrap limited to types: {_bootstrap_types}")
|
||||
if args.seed is not None:
|
||||
random.seed(args.seed)
|
||||
|
||||
# Fresh start: drop any leftover state from a previous run in this process.
|
||||
_task_queue.clear()
|
||||
_injected.clear()
|
||||
_pending_by_key.clear()
|
||||
_in_flight.clear()
|
||||
_bootstrap_done = False
|
||||
print("[mock] state cleared — starting fresh")
|
||||
|
||||
load_default_profile()
|
||||
load_pool()
|
||||
|
||||
app.run(host=args.host, port=args.port, threaded=True)
|
||||
is_reload = os.environ.get("WERKZEUG_RUN_MAIN") == "true"
|
||||
|
||||
if args.debug and is_reload and _load_state():
|
||||
# Auto-reload в debug — восстанавливаем состояние
|
||||
print("[mock] auto-reload: state restored from disk")
|
||||
else:
|
||||
# Первый запуск (или без --debug) — чистим всё
|
||||
_task_queue.clear()
|
||||
_injected.clear()
|
||||
_pending_by_key.clear()
|
||||
_in_flight.clear()
|
||||
_monitoring_logs.clear()
|
||||
_bootstrap_done = False
|
||||
if _STATE_FILE.exists():
|
||||
_STATE_FILE.unlink()
|
||||
import shutil
|
||||
if _SCREENSHOTS_DIR.exists():
|
||||
shutil.rmtree(_SCREENSHOTS_DIR)
|
||||
# Чистим events в БД приложения чтобы не было "App restarted" при старте
|
||||
try:
|
||||
import configparser
|
||||
import sqlite3
|
||||
project_root = (HERE / ".." / "..").resolve()
|
||||
for cfg in [project_root / "config.ini", project_root / "build_test" / "config.ini"]:
|
||||
if cfg.exists():
|
||||
cp = configparser.ConfigParser()
|
||||
cp.read(str(cfg), encoding="utf-8")
|
||||
raw = cp.get("db", "dbPath", fallback="")
|
||||
if raw:
|
||||
db = Path(os.path.expanduser(raw))
|
||||
if not db.is_absolute():
|
||||
db = cfg.parent / db
|
||||
if db.exists():
|
||||
conn = sqlite3.connect(str(db))
|
||||
n = conn.execute("SELECT count(*) FROM events").fetchone()[0]
|
||||
if n > 0:
|
||||
conn.execute("DELETE FROM events")
|
||||
conn.commit()
|
||||
print(f"[mock] cleaned {n} events from {db.name}")
|
||||
conn.close()
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[mock] warning: could not clean events DB: {e}")
|
||||
print("[mock] starting fresh (clean slate)")
|
||||
|
||||
app.run(host=args.host, port=args.port, threaded=True, debug=args.debug)
|
||||
|
||||
@ -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