Refine transaction fetching and parsing:
- Add date header parsing and swipe functionality for streamlined `Ozon` transactions list processing. - Improve deduplication logic, date assignment, and scroll behavior in `Dushanbe` and `Ozon` scripts. - Introduce screenshot handling for debugging transaction fetching workflows. - Add cancellation logic by `materialId` in `EventDAO`. - Update mock server for enhanced filename generation and PATCH support for bank profiles.
This commit is contained in:
parent
a97bdea977
commit
628323184f
@ -328,6 +328,16 @@ void GetLastTransactionsScript::doStart() {
|
||||
AdbUtils::makeTap(m_deviceId, vypiskaBtn.x(), vypiskaBtn.y());
|
||||
QThread::msleep(3000);
|
||||
|
||||
// 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);
|
||||
QThread::msleep(3000);
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] Pull-to-refresh on Выписка";
|
||||
}
|
||||
|
||||
// Сохраняем дамп Выписки для анализа формата
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
|
||||
@ -342,6 +352,9 @@ void GetLastTransactionsScript::doStart() {
|
||||
}
|
||||
}
|
||||
|
||||
// Скриншот списка транзакций — отправим при ошибке, удалим при успехе
|
||||
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||||
|
||||
// Находим accountId
|
||||
int accountId = -1;
|
||||
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
|
||||
@ -354,10 +367,8 @@ void GetLastTransactionsScript::doStart() {
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] Searching: amount=" << m_searchAmount
|
||||
<< "time=" << m_searchTime.toString(Qt::ISODate);
|
||||
|
||||
// Время в банковском приложении показывается в таймзоне устройства
|
||||
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId);
|
||||
QTimeZone tjTz(tzName.toUtf8());
|
||||
if (!tjTz.isValid()) tjTz = QTimeZone("Asia/Dushanbe");
|
||||
// Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства
|
||||
const QTimeZone tjTz("Asia/Dushanbe");
|
||||
const int maxScrolls = 4;
|
||||
|
||||
auto interrupted = []() {
|
||||
@ -421,22 +432,45 @@ void GetLastTransactionsScript::doStart() {
|
||||
}
|
||||
|
||||
// 2. Собираем View-транзакции (формат Выписки)
|
||||
// Два формата content-desc:
|
||||
// a) DCWallet/*** — старый: DCWallet*WDC...\ncard\namount\nописание\n HH:MM:SS
|
||||
// b) P2P — новый: Сегодня|dd.MM.yyyy\ncard\ncard\namount\nописание\n HH:MM:SS
|
||||
static const QRegularExpression reTime(R"(\d{2}:\d{2}:\d{2})");
|
||||
static const QRegularExpression reDateOrToday(
|
||||
QString("^(?:\\d{2}\\.\\d{2}\\.\\d{4}|%1|%2)$")
|
||||
.arg(QString::fromUtf8("Сегодня"), QString::fromUtf8("Вчера")));
|
||||
QList<VypiskaItem> items;
|
||||
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||
if (n.className != "android.view.View") continue;
|
||||
if (!n.contentDesc.contains("DCWallet") && !n.contentDesc.contains("***")) continue;
|
||||
const QStringList lines = n.contentDesc.split('\n');
|
||||
if (lines.size() < 5) continue;
|
||||
const QString last = lines.last().trimmed();
|
||||
if (!reTime.match(last).hasMatch()) continue;
|
||||
|
||||
// Определяем формат: если первая строка — дата/Сегодня/Вчера, это P2P формат
|
||||
const QString firstLine = lines.first().trimmed();
|
||||
const bool isP2P = reDateOrToday.match(firstLine).hasMatch();
|
||||
if (!isP2P && !n.contentDesc.contains("DCWallet") && !n.contentDesc.contains("***")) continue;
|
||||
|
||||
VypiskaItem it;
|
||||
bool ok = false;
|
||||
it.amount = lines[2].trimmed().toDouble(&ok);
|
||||
// В P2P формате amount на позиции [3] (после даты, двух карт), в DCWallet — [2]
|
||||
const int amtIdx = isP2P ? 3 : 2;
|
||||
if (amtIdx >= lines.size()) continue;
|
||||
it.amount = lines[amtIdx].trimmed().toDouble(&ok);
|
||||
if (!ok) continue;
|
||||
it.time = last;
|
||||
it.node = n;
|
||||
|
||||
// В P2P формате дата встроена в первую строку
|
||||
if (isP2P) {
|
||||
if (firstLine.compare(QString::fromUtf8("Сегодня"), Qt::CaseInsensitive) == 0)
|
||||
it.date = todayLocal.toString("dd.MM.yyyy");
|
||||
else if (firstLine.compare(QString::fromUtf8("Вчера"), Qt::CaseInsensitive) == 0)
|
||||
it.date = todayLocal.addDays(-1).toString("dd.MM.yyyy");
|
||||
else
|
||||
it.date = firstLine;
|
||||
}
|
||||
items.append(it);
|
||||
}
|
||||
|
||||
@ -497,7 +531,9 @@ void GetLastTransactionsScript::doStart() {
|
||||
int prevY2 = -1;
|
||||
for (VypiskaItem &it : items) {
|
||||
if (prevY2 >= 0 && it.node.y1() - prevY2 > 30) ++groupIdx;
|
||||
if (groupIdx < groupDates.size()) it.date = groupDates[groupIdx];
|
||||
// Не перезаписываем дату если она уже установлена (P2P формат)
|
||||
if (it.date.isEmpty() && groupIdx < groupDates.size())
|
||||
it.date = groupDates[groupIdx];
|
||||
prevY2 = it.node.y2();
|
||||
}
|
||||
|
||||
@ -539,7 +575,7 @@ void GetLastTransactionsScript::doStart() {
|
||||
const QTime searchLocalTime = m_searchTime.toTimeZone(tjTz).time();
|
||||
if (listTime.isValid() && searchLocalTime.isValid()) {
|
||||
const int diffSecs = qAbs(listTime.secsTo(searchLocalTime));
|
||||
if (diffSecs > 3600 && diffSecs < 82800) { // 82800 = 23h
|
||||
if (diffSecs > 600 && diffSecs < 85800) { // 85800 = 23h50m
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] Time pre-filter skip:"
|
||||
<< tx.time << "vs" << searchLocalTime.toString("HH:mm:ss")
|
||||
<< "diff=" << diffSecs << "sec";
|
||||
@ -617,8 +653,8 @@ void GetLastTransactionsScript::doStart() {
|
||||
if (txTime.isValid()) {
|
||||
txTime.setTimeZone(tjTz);
|
||||
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
|
||||
if (diffSecs > 3600) {
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec, skipping";
|
||||
if (diffSecs > 600) {
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec (>10min), skipping";
|
||||
AdbUtils::goBack(m_deviceId);
|
||||
QThread::msleep(1000);
|
||||
continue;
|
||||
@ -699,6 +735,9 @@ void GetLastTransactionsScript::doStart() {
|
||||
+ " time=" + m_searchTime.toString(Qt::ISODate);
|
||||
qWarning() << "[Dushanbe::GetLastTransactions]" << m_error;
|
||||
|
||||
// Отправляем скриншот списка транзакций, сделанный до начала поиска
|
||||
if (!listScreenshot.isEmpty()) emit screenshotReady(listScreenshot);
|
||||
|
||||
// Возвращаемся на главную
|
||||
Node glavnayaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Главная"));
|
||||
if (glavnayaBtn.x() > 0 && glavnayaBtn.y() > 0) {
|
||||
|
||||
@ -27,6 +27,9 @@ signals:
|
||||
// повторного чтения из БД.
|
||||
void transactionParsed(const QJsonObject &tx);
|
||||
|
||||
// Скриншот списка транзакций (при ошибке отправляется в мониторинг)
|
||||
void screenshotReady(const QByteArray &screenshot);
|
||||
|
||||
protected:
|
||||
void doStart() override;
|
||||
|
||||
|
||||
@ -205,6 +205,9 @@ bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &devi
|
||||
}
|
||||
|
||||
void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int accountId) {
|
||||
// Скриншот списка транзакций — отправим при ошибке, удалим при успехе
|
||||
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||||
|
||||
// Время в Ozon показывается в таймзоне устройства, а не в MSK
|
||||
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId);
|
||||
QTimeZone deviceTz(tzName.toUtf8());
|
||||
@ -267,6 +270,8 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
QThread::msleep(2000);
|
||||
}
|
||||
|
||||
// Скроллим к нужной дате, запоминаем количество скроллов
|
||||
int scrollsToDate = 0;
|
||||
bool dateFound = false;
|
||||
for (int scroll = 0; scroll < maxScrolls; ++scroll) {
|
||||
if (QThread::currentThread()->isInterruptionRequested()) {
|
||||
@ -276,7 +281,8 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
if (hasDateOnScreen()) {
|
||||
dateFound = true;
|
||||
qDebug() << "[FetchTransactions] Found target date on screen";
|
||||
scrollsToDate = scroll;
|
||||
qDebug() << "[FetchTransactions] Found target date on screen after" << scroll << "scrolls";
|
||||
break;
|
||||
}
|
||||
if (hasOlderDateOnScreen()) {
|
||||
@ -291,13 +297,33 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
if (!dateFound) {
|
||||
m_error = "Date not found in transaction list: " + searchDate.toString("dd.MM.yyyy");
|
||||
qWarning() << "[FetchTransactions]" << m_error;
|
||||
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||||
m_resultScreenshot = listScreenshot.isEmpty()
|
||||
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
|
||||
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Шаг 2: тапаем по кнопкам с нужной суммой, проверяем дату/время в деталях ─
|
||||
// Запоминаем уже проверенные (date+time из деталей) чтобы не застревать
|
||||
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
|
||||
// Кнопки транзакций между этими Y принадлежат нужному дню.
|
||||
auto findDateYRange = [&]() -> QPair<int, int> {
|
||||
int dateY = -1;
|
||||
int nextDateY = device.screenHeight;
|
||||
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();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {dateY, nextDateY};
|
||||
};
|
||||
|
||||
// ── Шаг 2: тапаем только по кнопкам под заголовком нужной даты ───
|
||||
QSet<QString> checkedDetails;
|
||||
|
||||
for (int attempt = 0; attempt < 15; ++attempt) {
|
||||
@ -306,6 +332,19 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
return;
|
||||
}
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
|
||||
// Находим Y-диапазон заголовка нужной даты
|
||||
const auto [dateHeaderY, nextDateHeaderY] = findDateYRange();
|
||||
if (dateHeaderY < 0) {
|
||||
qDebug() << "[FetchTransactions] Date header not on screen, scrolling";
|
||||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||||
device.screenHeight * 3 / 4,
|
||||
device.screenWidth / 2,
|
||||
device.screenHeight / 4, 300);
|
||||
QThread::msleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
QList<Node> txButtons = xmlScreenParser.findTransactionButtons(20);
|
||||
|
||||
bool tapped = false;
|
||||
@ -315,16 +354,18 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
return;
|
||||
}
|
||||
|
||||
// Пропускаем кнопки вне Y-диапазона нужной даты
|
||||
const int btnY = txButtons[bi].y();
|
||||
if (btnY <= dateHeaderY || btnY >= nextDateHeaderY) continue;
|
||||
if (btnY <= 0 || btnY >= navBarTop) continue;
|
||||
|
||||
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text);
|
||||
if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue;
|
||||
|
||||
// Пропускаем кнопки за экраном
|
||||
const int btnY = txButtons[bi].y();
|
||||
if (btnY <= 0 || btnY >= navBarTop) continue;
|
||||
|
||||
const Node &visibleBtn = txButtons[bi];
|
||||
qDebug() << "[FetchTransactions] Amount match:" << parsed.amount
|
||||
<< "at" << visibleBtn.x() << visibleBtn.y();
|
||||
<< "at" << visibleBtn.x() << visibleBtn.y()
|
||||
<< "(dateY:" << dateHeaderY << "-" << nextDateHeaderY << ")";
|
||||
|
||||
// Тапаем и ждём деталей
|
||||
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
|
||||
@ -367,23 +408,15 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
qDebug() << "[FetchTransactions] Already checked:" << dtStr << "- skipping";
|
||||
AdbUtils::goBack(m_deviceId);
|
||||
QThread::msleep(500);
|
||||
continue; // пробуем следующую кнопку
|
||||
continue;
|
||||
}
|
||||
checkedDetails.insert(dtStr);
|
||||
tapped = true;
|
||||
|
||||
// Проверяем дату
|
||||
if (txTime.isValid() && txTime.date() != searchDate) {
|
||||
qDebug() << "[FetchTransactions] Wrong date:" << dtStr << "expected" << searchDate;
|
||||
AdbUtils::goBack(m_deviceId);
|
||||
QThread::msleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверяем время (±1 час)
|
||||
// Проверяем время (±10 минут)
|
||||
if (txTime.isValid() && m_searchTime.isValid()) {
|
||||
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
|
||||
if (diffSecs > 3600) {
|
||||
if (diffSecs > 600) {
|
||||
qDebug() << "[FetchTransactions] Time mismatch:" << dtStr
|
||||
<< "expected" << searchLocal.toString("dd.MM.yyyy, HH:mm")
|
||||
<< "diff=" << diffSecs << "sec";
|
||||
@ -515,7 +548,9 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
m_error = "Transaction not found: amount=" + QString::number(m_searchAmount, 'f', 2)
|
||||
+ " date=" + searchDate.toString("dd.MM.yyyy");
|
||||
qWarning() << "[FetchTransactions]" << m_error;
|
||||
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
|
||||
// Отправляем скриншот списка транзакций, сделанный до начала поиска
|
||||
m_resultScreenshot = listScreenshot.isEmpty()
|
||||
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
|
||||
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
|
||||
}
|
||||
|
||||
|
||||
@ -272,7 +272,74 @@ bool EventDAO::existsActiveByExternalId(const QString &externalId) {
|
||||
return query.next();
|
||||
}
|
||||
|
||||
bool EventDAO::cancelWaitingByType(const QString &deviceId, EventType type, const QString &bankName) {
|
||||
QList<EventInfo> EventDAO::cancelWaitingByMaterial(const QString &materialId, EventType type) {
|
||||
QList<EventInfo> cancelled;
|
||||
QSqlQuery selectQuery(DatabaseManager::instance().database());
|
||||
selectQuery.prepare(R"(
|
||||
SELECT id, external_id, bank_name, type FROM events
|
||||
WHERE external_id = :material_id AND type = :type AND status = :wait
|
||||
)");
|
||||
selectQuery.bindValue(":material_id", materialId);
|
||||
selectQuery.bindValue(":type", eventTypeToString(type));
|
||||
selectQuery.bindValue(":wait", eventStatusToString(EventStatus::Wait));
|
||||
|
||||
if (selectQuery.exec()) {
|
||||
while (selectQuery.next()) {
|
||||
EventInfo e;
|
||||
e.id = selectQuery.value(0).toInt();
|
||||
e.externalId = selectQuery.value(1).toString();
|
||||
e.bankName = selectQuery.value(2).toString();
|
||||
e.type = eventTypeFromString(selectQuery.value(3).toString());
|
||||
cancelled.append(e);
|
||||
}
|
||||
}
|
||||
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
query.prepare(R"(
|
||||
UPDATE events SET status = :cancelled, comment = 'Cancelled: replaced by new task'
|
||||
WHERE external_id = :material_id AND type = :type AND status = :wait
|
||||
)");
|
||||
query.bindValue(":cancelled", eventStatusToString(EventStatus::Error));
|
||||
query.bindValue(":material_id", materialId);
|
||||
query.bindValue(":type", eventTypeToString(type));
|
||||
query.bindValue(":wait", eventStatusToString(EventStatus::Wait));
|
||||
|
||||
if (!query.exec()) {
|
||||
qWarning() << "Ошибка при cancelWaitingByMaterial:" << query.lastError().text();
|
||||
}
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
QList<EventInfo> EventDAO::cancelWaitingByType(const QString &deviceId, EventType type, const QString &bankName) {
|
||||
QList<EventInfo> cancelled;
|
||||
QSqlQuery selectQuery(DatabaseManager::instance().database());
|
||||
if (bankName.isEmpty()) {
|
||||
selectQuery.prepare(R"(
|
||||
SELECT id, external_id, bank_name, type FROM events
|
||||
WHERE device_id = :device_id AND type = :type AND status = :wait
|
||||
)");
|
||||
} else {
|
||||
selectQuery.prepare(R"(
|
||||
SELECT id, external_id, bank_name, type FROM events
|
||||
WHERE device_id = :device_id AND type = :type AND status = :wait AND bank_name = :bank_name
|
||||
)");
|
||||
selectQuery.bindValue(":bank_name", bankName);
|
||||
}
|
||||
selectQuery.bindValue(":device_id", deviceId);
|
||||
selectQuery.bindValue(":type", eventTypeToString(type));
|
||||
selectQuery.bindValue(":wait", eventStatusToString(EventStatus::Wait));
|
||||
|
||||
if (selectQuery.exec()) {
|
||||
while (selectQuery.next()) {
|
||||
EventInfo e;
|
||||
e.id = selectQuery.value(0).toInt();
|
||||
e.externalId = selectQuery.value(1).toString();
|
||||
e.bankName = selectQuery.value(2).toString();
|
||||
e.type = eventTypeFromString(selectQuery.value(3).toString());
|
||||
cancelled.append(e);
|
||||
}
|
||||
}
|
||||
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
if (bankName.isEmpty()) {
|
||||
query.prepare(R"(
|
||||
@ -293,9 +360,8 @@ bool EventDAO::cancelWaitingByType(const QString &deviceId, EventType type, cons
|
||||
|
||||
if (!query.exec()) {
|
||||
qWarning() << "Ошибка при cancelWaitingByType:" << query.lastError().text();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
QList<EventInfo> EventDAO::cancelWaitingByBank(const QString &deviceId, const QString &bankName) {
|
||||
|
||||
@ -19,7 +19,9 @@ public:
|
||||
|
||||
[[nodiscard]] static bool existsActiveByExternalId(const QString &externalId);
|
||||
|
||||
[[nodiscard]] static bool cancelWaitingByType(const QString &deviceId, EventType type, const QString &bankName = {});
|
||||
[[nodiscard]] static QList<EventInfo> cancelWaitingByType(const QString &deviceId, EventType type, const QString &bankName = {});
|
||||
|
||||
[[nodiscard]] static QList<EventInfo> cancelWaitingByMaterial(const QString &materialId, EventType type);
|
||||
|
||||
[[nodiscard]] static QList<EventInfo> cancelWaitingByBank(const QString &deviceId, const QString &bankName);
|
||||
|
||||
|
||||
@ -115,9 +115,13 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// FETCH_PROFILE: отменяем старые ожидающие задачи такого же типа на этом устройстве+банк
|
||||
// FETCH_PROFILE: отменяем старые ожидающие задачи такого же типа для этого материала
|
||||
if (eventType == EventType::FetchProfile) {
|
||||
EventDAO::cancelWaitingByType(account.deviceId, EventType::FetchProfile, bankName);
|
||||
const QList<EventInfo> cancelled = EventDAO::cancelWaitingByMaterial(materialId, EventType::FetchProfile);
|
||||
for (const EventInfo &c : cancelled) {
|
||||
sendTaskError(eventTypeToString(c.type), c.externalId, c.bankName, "Cancelled: replaced by new task");
|
||||
EventDAO::markSentToServer(c.id);
|
||||
}
|
||||
}
|
||||
|
||||
EventInfo event;
|
||||
@ -396,6 +400,18 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
|
||||
}
|
||||
m_threads.remove(key);
|
||||
|
||||
// Убиваем банковское приложение на устройстве после завершения задачи
|
||||
{
|
||||
const MaterialInfo acc = MaterialDAO::getAccountById(event.accountId);
|
||||
const BankProfileInfo app = BankProfileDAO::getApplication(event.deviceId, acc.appCode);
|
||||
if (!app.package.isEmpty()) {
|
||||
const DeviceInfo device = DeviceDAO::getDeviceById(event.deviceId);
|
||||
const QString adbSerial = device.adbSerial.isEmpty() ? event.deviceId : device.adbSerial;
|
||||
AdbUtils::tryToKillApplication(adbSerial, app.package);
|
||||
qDebug() << "[EventHandler] Killed app" << app.package << "on device" << adbSerial;
|
||||
}
|
||||
}
|
||||
|
||||
// Сразу пробуем запустить следующую задачу для этого устройства
|
||||
processNextTask(key);
|
||||
}
|
||||
@ -463,6 +479,9 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
|
||||
connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this,
|
||||
[this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; },
|
||||
Qt::DirectConnection);
|
||||
connect(w, &Dushanbe::GetLastTransactionsScript::screenshotReady, this,
|
||||
[this, key](const QByteArray &s) { m_screenshots[key] = s; },
|
||||
Qt::DirectConnection);
|
||||
}
|
||||
else qWarning() << "[EventHandler] FETCH_TRANSACTIONS: unknown appCode:" << appCode;
|
||||
}
|
||||
@ -519,6 +538,18 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
|
||||
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Script timeout (5 min)");
|
||||
EventDAO::markSentToServer(event.id);
|
||||
|
||||
// Убиваем банковское приложение на устройстве после таймаута
|
||||
{
|
||||
const MaterialInfo acc = MaterialDAO::getAccountById(event.accountId);
|
||||
const BankProfileInfo app = BankProfileDAO::getApplication(event.deviceId, acc.appCode);
|
||||
if (!app.package.isEmpty()) {
|
||||
const DeviceInfo device = DeviceDAO::getDeviceById(event.deviceId);
|
||||
const QString adbSerial = device.adbSerial.isEmpty() ? event.deviceId : device.adbSerial;
|
||||
AdbUtils::tryToKillApplication(adbSerial, app.package);
|
||||
qDebug() << "[EventHandler] Killed app" << app.package << "on device" << adbSerial << "(timeout)";
|
||||
}
|
||||
}
|
||||
|
||||
processNextTask(key);
|
||||
});
|
||||
|
||||
|
||||
@ -1,27 +1,7 @@
|
||||
{
|
||||
"bank_profiles": [
|
||||
{
|
||||
"id": "dd0ba55d-0a02-40ab-8b31-cf6a7529269f",
|
||||
"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": "61d967d1-8d3e-4d05-ba01-8c8ab327b945",
|
||||
"state": "enabled",
|
||||
"card_number": "9762000207113258",
|
||||
"currency": 972
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "86bcdee7-1c64-40e3-bc8f-132d153a9d28",
|
||||
"id": "85f577bc-20be-40fd-ab47-315af3aadfb9",
|
||||
"bank_name": "ozon",
|
||||
"state": "enabled",
|
||||
"phone_number": "+79538038318",
|
||||
@ -29,33 +9,19 @@
|
||||
"middle_name": null,
|
||||
"last_name": "Терехина",
|
||||
"currency": 643,
|
||||
"device_id": "986cb259-dfec-4421-8735-4fb126cd1917",
|
||||
"device_id": "c9d3ad7e-659d-4adf-815e-9f234e743078",
|
||||
"device_label": "Pixel 7",
|
||||
"materials": [
|
||||
{
|
||||
"id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13",
|
||||
"id": "e6c6e12a-3e4e-444c-9e11-62543fb5f4f9",
|
||||
"state": "enabled",
|
||||
"card_number": "2204321035207919",
|
||||
"currency": 643
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "e8c5f3b2-c710-421c-b304-93353ccfbfd5",
|
||||
"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": "49001bb5-3b24-478d-b913-c2242aea0732",
|
||||
"id": "39592a22-3e79-4438-89d7-778aea72a5e9",
|
||||
"state": "enabled",
|
||||
"card_number": "2204320439130982",
|
||||
"card_number": "2204321086109865",
|
||||
"currency": 643
|
||||
}
|
||||
]
|
||||
|
||||
@ -160,30 +160,92 @@ def back(serial):
|
||||
adb(serial, "shell", "input", "keyevent", "KEYCODE_BACK")
|
||||
|
||||
|
||||
def swipe_up(serial, duration=500):
|
||||
"""Swipe up to scroll the list down (small step)."""
|
||||
adb(serial, "shell", "input", "swipe", "540", "1400", "540", "900", str(duration))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Ozon
|
||||
|
||||
MONTH_MAP = {
|
||||
"января": 1, "февраля": 2, "марта": 3, "апреля": 4,
|
||||
"мая": 5, "июня": 6, "июля": 7, "августа": 8,
|
||||
"сентября": 9, "октября": 10, "ноября": 11, "декабря": 12,
|
||||
}
|
||||
DATE_HEADER_RE = re.compile(r"(\d{1,2})\s+(\S+),\s+\S+")
|
||||
|
||||
|
||||
def parse_ozon_date_headers(xml, device_tz):
|
||||
"""Parse date headers (TextView) and return list of (y, date_str 'dd.MM.yyyy')."""
|
||||
today = datetime.now(tz=device_tz).date()
|
||||
headers = []
|
||||
for m in re.finditer(
|
||||
r'text="([^"]*)"[^>]*class="android.widget.TextView"'
|
||||
r'[^>]*bounds="(\[\d+,\d+\]\[\d+,\d+\])"', xml):
|
||||
text, bounds = m.group(1).strip(), m.group(2)
|
||||
bm = BOUNDS_RE.match(bounds)
|
||||
if not bm:
|
||||
continue
|
||||
x1, y1, x2, y2 = map(int, bm.groups())
|
||||
h = y2 - y1
|
||||
if h < 5 or y1 < 700:
|
||||
continue # за экраном или в зоне шапки
|
||||
d = None
|
||||
if text == "Сегодня":
|
||||
d = today
|
||||
elif text == "Вчера":
|
||||
d = today - timedelta(days=1)
|
||||
else:
|
||||
dm = DATE_HEADER_RE.match(text)
|
||||
if dm:
|
||||
day = int(dm.group(1))
|
||||
month = MONTH_MAP.get(dm.group(2), 0)
|
||||
if month:
|
||||
d = today.replace(month=month, day=day)
|
||||
if d > today:
|
||||
d = d.replace(year=today.year - 1)
|
||||
if d:
|
||||
headers.append({"y": y1, "date": d.strftime("%d.%m.%Y")})
|
||||
headers.sort(key=lambda h: h["y"])
|
||||
return headers
|
||||
|
||||
|
||||
def parse_ozon_rows(xml):
|
||||
rows = []
|
||||
for m in re.finditer(
|
||||
r'<node\s[^>]*?text="([^"]*)"'
|
||||
r'text="([^"]*)"'
|
||||
r'[^>]*?bounds="(\[\d+,\d+\]\[\d+,\d+\])"', xml):
|
||||
text, bounds = m.group(1), m.group(2)
|
||||
bm = BOUNDS_RE.match(bounds)
|
||||
if not bm:
|
||||
continue
|
||||
x1, y1, x2, y2 = map(int, bm.groups())
|
||||
if (y2 - y1) < 50 or "Расход" in text or "Доход" in text:
|
||||
h = y2 - y1
|
||||
cy = (y1 + y2) // 2
|
||||
if h < 50 or h < 5 or cy < 800:
|
||||
continue
|
||||
if "Расход" in text or "Доход" in text:
|
||||
continue
|
||||
am = re.search(r"([+\-−])\s*(\d[\d\s\u202f]*)", text)
|
||||
if not am:
|
||||
continue
|
||||
sign = -1 if am.group(1) in ("-", "−") else 1
|
||||
amount = sign * int(re.sub(r"[\s\u202f]", "", am.group(2)))
|
||||
rows.append({"text": text, "cx": (x1+x2)//2, "cy": (y1+y2)//2, "amount": amount})
|
||||
rows.append({"text": text, "cx": (x1+x2)//2, "cy": cy, "amount": amount})
|
||||
rows.sort(key=lambda r: r["cy"])
|
||||
return rows
|
||||
|
||||
|
||||
def assign_dates_to_rows(rows, headers):
|
||||
"""Assign date from nearest preceding header to each row."""
|
||||
for row in rows:
|
||||
row["date"] = None
|
||||
for h in reversed(headers):
|
||||
if h["y"] < row["cy"]:
|
||||
row["date"] = h["date"]
|
||||
break
|
||||
|
||||
|
||||
def is_ozon_list(xml):
|
||||
return "Операции" in xml
|
||||
|
||||
@ -266,71 +328,181 @@ def collect_tasks(serial, bank, mid, device_tz, kopecks, prefix):
|
||||
|
||||
if is_ozon:
|
||||
print("Detected: Ozon")
|
||||
headers = parse_ozon_date_headers(xml, device_tz)
|
||||
baseline = parse_ozon_rows(xml)
|
||||
print(f"rows: {len(baseline)}")
|
||||
assign_dates_to_rows(baseline, headers)
|
||||
print(f"date headers: {[h['date'] for h in headers]}")
|
||||
print(f"visible rows: {len(baseline)}")
|
||||
for i, r in enumerate(baseline, 1):
|
||||
print(f" {i}. ({r['cx']},{r['cy']}) {r['amount']:+d} ₽ | {r['text'][:50]}")
|
||||
print(f" {i}. ({r['cx']},{r['cy']}) {r['amount']:+d} ₽ date={r.get('date','-')} | {r['text'][:40]}")
|
||||
|
||||
for idx in range(1, len(baseline) + 1):
|
||||
task_num = 0
|
||||
processed_keys = set() # (amount, created_at) — финальная дедупликация
|
||||
completed_dates = set() # даты где все строки уже обработаны
|
||||
date_row_counts = {} # date -> сколько строк видели для этой даты (макс)
|
||||
max_scrolls = 20
|
||||
scroll_count = 0
|
||||
|
||||
while True:
|
||||
xml = dump_xml(serial)
|
||||
if not is_ozon_list(xml):
|
||||
print(f"[{idx}] left list, stop"); break
|
||||
print("Left list, stop"); break
|
||||
|
||||
headers = parse_ozon_date_headers(xml, device_tz)
|
||||
rows = parse_ozon_rows(xml)
|
||||
if idx > len(rows):
|
||||
break
|
||||
row = rows[idx - 1]
|
||||
print(f"\n[{idx}] tap ({row['cx']},{row['cy']}) {row['amount']:+d}")
|
||||
tap(serial, row["cx"], row["cy"])
|
||||
time.sleep(2.5)
|
||||
d_xml = dump_xml(serial)
|
||||
date_s, time_s = extract_ozon_detail_time(d_xml)
|
||||
print(f" {date_s} {time_s}")
|
||||
back(serial); time.sleep(1.3)
|
||||
if not date_s or not time_s:
|
||||
print(" SKIP"); continue
|
||||
dt = datetime.strptime(f"{date_s} {time_s}", "%d.%m.%Y %H:%M:%S").replace(tzinfo=device_tz)
|
||||
cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
label = short_label(row["amount"], time_s)
|
||||
while label in seen: label += "_2"
|
||||
seen.add(label)
|
||||
tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS",
|
||||
"body": {"amount": row["amount"] * kopecks, "created_at": cat},
|
||||
"_tag": f"{prefix}_{idx:02d}_{label}"})
|
||||
assign_dates_to_rows(rows, headers)
|
||||
|
||||
if not rows:
|
||||
print("No rows found, stop"); break
|
||||
|
||||
# Фильтруем строки от уже завершённых дат
|
||||
rows = [r for r in rows if r.get("date") and r["date"] not in completed_dates]
|
||||
if not rows:
|
||||
print("All visible dates already processed, scrolling...")
|
||||
scroll_count += 1
|
||||
if scroll_count >= max_scrolls:
|
||||
print(f"\nReached max scrolls ({max_scrolls}), stop"); break
|
||||
swipe_up(serial)
|
||||
time.sleep(1.5)
|
||||
continue
|
||||
|
||||
# Считаем строки по датам на текущем экране
|
||||
screen_date_counts = {}
|
||||
for row in rows:
|
||||
d = row["date"]
|
||||
screen_date_counts[d] = screen_date_counts.get(d, 0) + 1
|
||||
|
||||
new_on_screen = 0
|
||||
for row in rows:
|
||||
date_from_header = row["date"]
|
||||
|
||||
print(f"\n [{date_from_header}] tap ({row['cx']},{row['cy']}) {row['amount']:+d}")
|
||||
tap(serial, row["cx"], row["cy"])
|
||||
time.sleep(3)
|
||||
|
||||
# Ждём деталей — нужно только время (дата уже из заголовка)
|
||||
time_s = None
|
||||
for attempt in range(3):
|
||||
d_xml = dump_xml(serial)
|
||||
_, time_s = extract_ozon_detail_time(d_xml)
|
||||
if time_s:
|
||||
break
|
||||
if is_ozon_list(d_xml):
|
||||
print(f" retry {attempt+1}: still on list, re-tapping")
|
||||
tap(serial, row["cx"], row["cy"])
|
||||
time.sleep(2)
|
||||
else:
|
||||
print(f" retry {attempt+1}: waiting for time...")
|
||||
time.sleep(2)
|
||||
|
||||
back(serial); time.sleep(1.5)
|
||||
|
||||
if not time_s:
|
||||
print(f" SKIP (no time from detail)"); continue
|
||||
|
||||
print(f" {date_from_header} {time_s}")
|
||||
|
||||
dt = datetime.strptime(f"{date_from_header} {time_s}", "%d.%m.%Y %H:%M:%S").replace(tzinfo=device_tz)
|
||||
cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
dedup_key = (row["amount"], cat)
|
||||
if dedup_key in processed_keys:
|
||||
print(" DUPLICATE, skip"); continue
|
||||
processed_keys.add(dedup_key)
|
||||
new_on_screen += 1
|
||||
|
||||
task_num += 1
|
||||
label = short_label(row["amount"], time_s)
|
||||
while label in seen: label += "_2"
|
||||
seen.add(label)
|
||||
tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS",
|
||||
"body": {"amount": row["amount"] * kopecks, "created_at": cat},
|
||||
"_tag": f"{prefix}_{task_num:02d}_{label}"})
|
||||
|
||||
# Помечаем даты как завершённые: все даты кроме последней на экране
|
||||
# (последняя может быть обрезана скроллом)
|
||||
visible_dates = list(dict.fromkeys(r["date"] for r in rows)) # ordered unique
|
||||
if len(visible_dates) > 1:
|
||||
for d in visible_dates[:-1]:
|
||||
completed_dates.add(d)
|
||||
|
||||
if new_on_screen == 0:
|
||||
# Последняя дата на экране — тоже завершена
|
||||
if visible_dates:
|
||||
completed_dates.add(visible_dates[-1])
|
||||
print("\nNo new transactions on screen, scrolling past...")
|
||||
scroll_count += 1
|
||||
if scroll_count >= max_scrolls:
|
||||
print(f"\nReached max scrolls ({max_scrolls}), stop"); break
|
||||
swipe_up(serial)
|
||||
time.sleep(1.5)
|
||||
continue
|
||||
|
||||
scroll_count += 1
|
||||
if scroll_count >= max_scrolls:
|
||||
print(f"\nReached max scrolls ({max_scrolls}), stop"); break
|
||||
print(f"\n--- scroll {scroll_count}/{max_scrolls} ---")
|
||||
swipe_up(serial)
|
||||
time.sleep(1.5)
|
||||
|
||||
elif is_dush:
|
||||
print("Detected: Dushanbe")
|
||||
baseline = parse_dushanbe_rows(xml)
|
||||
print(f"rows: {len(baseline)}")
|
||||
print(f"visible rows: {len(baseline)}")
|
||||
for i, r in enumerate(baseline, 1):
|
||||
print(f" {i}. ({r['cx']},{r['cy']}) amt={r['amount']} time={r['time']}")
|
||||
|
||||
for idx in range(1, len(baseline) + 1):
|
||||
task_num = 0
|
||||
processed_keys = set()
|
||||
max_scrolls = 30
|
||||
scroll_count = 0
|
||||
|
||||
while True:
|
||||
xml = dump_xml(serial)
|
||||
if not is_dushanbe_list(xml):
|
||||
print(f"[{idx}] left list, stop"); break
|
||||
print("Left list, stop"); break
|
||||
rows = parse_dushanbe_rows(xml)
|
||||
if idx > len(rows):
|
||||
break
|
||||
row = rows[idx - 1]
|
||||
print(f"\n[{idx}] tap ({row['cx']},{row['cy']}) amt={row['amount']}")
|
||||
tap(serial, row["cx"], row["cy"])
|
||||
time.sleep(2.5)
|
||||
d_xml = dump_xml(serial)
|
||||
amt_s, date_s, time_s = extract_dushanbe_detail(d_xml)
|
||||
print(f" amt={amt_s} {date_s} {time_s}")
|
||||
back(serial); time.sleep(1.3)
|
||||
if not date_s or not time_s:
|
||||
print(" SKIP"); continue
|
||||
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)
|
||||
cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
label = short_label(amount, time_s)
|
||||
while label in seen: label += "_2"
|
||||
seen.add(label)
|
||||
tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS",
|
||||
"body": {"amount": amount * kopecks, "created_at": cat},
|
||||
"_tag": f"{prefix}_{idx:02d}_{label}"})
|
||||
if not rows:
|
||||
print("No rows found, stop"); break
|
||||
|
||||
tapped_any = False
|
||||
for row in rows:
|
||||
print(f"\n tap ({row['cx']},{row['cy']}) amt={row['amount']}")
|
||||
tap(serial, row["cx"], row["cy"])
|
||||
time.sleep(2.5)
|
||||
d_xml = dump_xml(serial)
|
||||
amt_s, date_s, time_s = extract_dushanbe_detail(d_xml)
|
||||
print(f" amt={amt_s} {date_s} {time_s}")
|
||||
back(serial); time.sleep(1.3)
|
||||
if not date_s or not time_s:
|
||||
print(" SKIP"); continue
|
||||
|
||||
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)
|
||||
cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
dedup_key = (amount, cat)
|
||||
if dedup_key in processed_keys:
|
||||
print(" DUPLICATE, skip"); continue
|
||||
processed_keys.add(dedup_key)
|
||||
tapped_any = True
|
||||
|
||||
task_num += 1
|
||||
label = short_label(amount, time_s)
|
||||
while label in seen: label += "_2"
|
||||
seen.add(label)
|
||||
tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS",
|
||||
"body": {"amount": amount * kopecks, "created_at": cat},
|
||||
"_tag": f"{prefix}_{task_num:02d}_{label}"})
|
||||
|
||||
scroll_count += 1
|
||||
if scroll_count >= max_scrolls:
|
||||
print(f"\nReached max scrolls ({max_scrolls}), stop"); break
|
||||
if not tapped_any:
|
||||
print("\nNo new transactions on screen, stop"); break
|
||||
print(f"\n--- scrolling down ({scroll_count}/{max_scrolls}) ---")
|
||||
swipe_up(serial)
|
||||
time.sleep(1.5)
|
||||
|
||||
return tasks
|
||||
|
||||
@ -409,7 +581,8 @@ def main():
|
||||
serial = args.serial or o["serial"]
|
||||
bank = o["bank_name"]
|
||||
mid = o["material_id"]
|
||||
prefix = f"{o['label'].lower().replace(' ', '_')}_{bank}"
|
||||
card = o.get('last_numbers') or o['material_id'][:8]
|
||||
prefix = f"{o['label'].lower().replace(' ', '_')}_{bank}_{card}"
|
||||
device_tz = get_device_tz(serial)
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Scanning: {o['label']} / {bank} ({mid[:8]}…)")
|
||||
@ -429,7 +602,8 @@ def main():
|
||||
# Remove old files for scanned prefixes
|
||||
scanned_prefixes = set()
|
||||
for o in to_scan:
|
||||
scanned_prefixes.add(f"{o['label'].lower().replace(' ', '_')}_{o['bank_name']}")
|
||||
card = o.get('last_numbers') or o['material_id'][:8]
|
||||
scanned_prefixes.add(f"{o['label'].lower().replace(' ', '_')}_{o['bank_name']}_{card}")
|
||||
for old in POOL_DIR.glob("*.json"):
|
||||
for pfx in scanned_prefixes:
|
||||
if old.name.startswith(pfx + "_ft_"):
|
||||
|
||||
@ -170,7 +170,8 @@ def generate(materials: list, out_dir: Path):
|
||||
for row in materials:
|
||||
dev_short = device_label(row["device_name"]).lower().replace(" ", "_")
|
||||
bank = row["bank_name"]
|
||||
fname = f"{dev_short}_{bank}_fetch_profile.json"
|
||||
card = row["last_numbers"] or row["material_id"][:8]
|
||||
fname = f"{dev_short}_{bank}_{card}_fetch_profile.json"
|
||||
task = {
|
||||
"bank_name": bank,
|
||||
"material_id": row["material_id"],
|
||||
@ -183,7 +184,7 @@ def generate(materials: list, out_dir: Path):
|
||||
print(f" wrote scenarios/{fname}")
|
||||
|
||||
devices_seen.setdefault(dev_short, []).append(
|
||||
f"{bank} ({row['material_id'][:8]}…)"
|
||||
f"{bank}/{card} ({row['material_id'][:8]}…)"
|
||||
)
|
||||
|
||||
# Summary
|
||||
|
||||
@ -45,6 +45,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict, deque
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, Response, jsonify, request
|
||||
@ -297,6 +298,22 @@ def bank_profile_toggle(bp_id, state):
|
||||
return ok({"id": bp_id, "state": state})
|
||||
|
||||
|
||||
@app.patch("/api/v1/profile/bank_profile/<bp_id>")
|
||||
def bank_profile_update(bp_id):
|
||||
body = request.get_json(silent=True) or {}
|
||||
log("PATCH /bank_profile", {"id": bp_id, **body})
|
||||
for bp in _bank_profiles:
|
||||
if bp.get("id") == bp_id:
|
||||
bp.update(body)
|
||||
return ok(bp)
|
||||
# Не найден — создаём
|
||||
bp = dict(body)
|
||||
bp["id"] = bp_id
|
||||
bp.setdefault("materials", [])
|
||||
_bank_profiles.append(bp)
|
||||
return ok(bp)
|
||||
|
||||
|
||||
@app.post("/api/v1/profile/material")
|
||||
def material_create():
|
||||
body = request.get_json(silent=True) or {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user