1
0
forked from BRT/arc

Add transactionId support to EventHandler, EventDAO, and related components for improved event tracking and error handling. Update database queries, error reporting, and UI logic accordingly.

(cherry picked from commit 067b16f700)
This commit is contained in:
slava 2026-05-26 19:34:16 +05:00
parent d3b109d87f
commit 50a7b20f0a
12 changed files with 253 additions and 120 deletions

View File

@ -18,6 +18,7 @@ pathDesktopCreate=/api/v1/desktop/
pathDevice=/api/v1/device/
pathDeviceScreenshot=/api/v1/device/add_screenshot/
pathMaterial=/api/v1/profile/material
pathProfilePing=/api/v1/profile/ping
pathMonitoringLog=/monitoring/log
pathMonitoringDump=/monitoring/dump
monitoringBase=http://142.93.102.40:8080

View File

@ -176,6 +176,7 @@ void DatabaseManager::initSchema() {
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER,
external_id TEXT,
transaction_id TEXT,
device_id TEXT,
amount REAL,
phone TEXT,
@ -192,6 +193,7 @@ void DatabaseManager::initSchema() {
FOREIGN KEY (account_id) REFERENCES materials (id) ON DELETE CASCADE,
FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE
))");
q.exec("ALTER TABLE events ADD COLUMN transaction_id TEXT");
q.exec(R"(CREATE TABLE IF NOT EXISTS banks (
id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@ -13,6 +13,7 @@ EventInfo parseEvent(const QSqlQuery &query) {
event.status = eventStatusFromString(query.value("status").toString());
event.type = eventTypeFromString(query.value("type").toString());
event.externalId = query.value("external_id").toString();
event.transactionId = query.value("transaction_id").toString();
event.accountId = query.value("account_id").toInt();
event.deviceId = query.value("device_id").toString();
event.amount = query.value("amount").toDouble();
@ -36,15 +37,16 @@ int EventDAO::insertEvent(const EventInfo &event) {
query.prepare(R"(
INSERT INTO events (
status, external_id, account_id, amount, phone, bank_name, comment, json, timestamp, device_id, type
status, external_id, transaction_id, account_id, amount, phone, bank_name, comment, json, timestamp, device_id, type
) VALUES (
:status, :external_id, :account_id, :amount, :phone, :bank_name, :comment, :json, :timestamp, :device_id, :type
:status, :external_id, :transaction_id, :account_id, :amount, :phone, :bank_name, :comment, :json, :timestamp, :device_id, :type
)
)");
query.bindValue(":status", eventStatusToString(event.status));
query.bindValue(":type", eventTypeToString(event.type));
query.bindValue(":external_id", event.externalId);
query.bindValue(":transaction_id", event.transactionId);
query.bindValue(":account_id", event.accountId);
query.bindValue(":device_id", event.deviceId);
query.bindValue(":amount", event.amount);
@ -139,7 +141,7 @@ QList<EventInfo> EventDAO::getAllEvents(const EventStatus &status) {
QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"(
SELECT id, account_id, device_id, external_id, amount, phone, bank_name,
SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name,
status, type, comment, json, sent_to_server, update_time, complete_time, timestamp
FROM events
WHERE status = :status
@ -161,7 +163,7 @@ QList<EventInfo> EventDAO::getAllEvents(const EventStatus &status) {
EventInfo EventDAO::getFirstWaitEventByDeviceId(const QString &key) {
QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"(
SELECT id, account_id, device_id, external_id, amount, phone, bank_name,
SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name,
status, type, comment, json, sent_to_server, update_time, complete_time, timestamp
FROM events
WHERE status = :status AND device_id = :device_id
@ -193,7 +195,7 @@ QList<EventInfo> EventDAO::getEvents(int page, int pageSize) {
QList<EventInfo> events;
QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"(
SELECT id, account_id, device_id, external_id, amount, phone, bank_name,
SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name,
status, type, comment, json, sent_to_server, update_time, complete_time, timestamp,
(LENGTH(screenshot) > 0) as has_screenshot
FROM events
@ -276,7 +278,7 @@ QList<EventInfo> EventDAO::cancelWaitingByMaterial(const QString &materialId, Ev
QList<EventInfo> cancelled;
QSqlQuery selectQuery(DatabaseManager::instance().database());
selectQuery.prepare(R"(
SELECT id, external_id, bank_name, type FROM events
SELECT id, external_id, transaction_id, bank_name, type FROM events
WHERE external_id = :material_id AND type = :type AND status = :wait
)");
selectQuery.bindValue(":material_id", materialId);
@ -288,8 +290,9 @@ QList<EventInfo> EventDAO::cancelWaitingByMaterial(const QString &materialId, Ev
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());
e.transactionId = selectQuery.value(2).toString();
e.bankName = selectQuery.value(3).toString();
e.type = eventTypeFromString(selectQuery.value(4).toString());
cancelled.append(e);
}
}
@ -315,12 +318,12 @@ QList<EventInfo> EventDAO::cancelWaitingByType(const QString &deviceId, EventTyp
QSqlQuery selectQuery(DatabaseManager::instance().database());
if (bankName.isEmpty()) {
selectQuery.prepare(R"(
SELECT id, external_id, bank_name, type FROM events
SELECT id, external_id, transaction_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
SELECT id, external_id, transaction_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);
@ -334,8 +337,9 @@ QList<EventInfo> EventDAO::cancelWaitingByType(const QString &deviceId, EventTyp
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());
e.transactionId = selectQuery.value(2).toString();
e.bankName = selectQuery.value(3).toString();
e.type = eventTypeFromString(selectQuery.value(4).toString());
cancelled.append(e);
}
}
@ -369,7 +373,7 @@ QList<EventInfo> EventDAO::cancelWaitingByBank(const QString &deviceId, const QS
QList<EventInfo> cancelled;
QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"(
SELECT id, account_id, device_id, external_id, amount, phone, bank_name,
SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name,
status, type, comment, json, sent_to_server, update_time, complete_time, timestamp
FROM events
WHERE device_id = :device_id AND bank_name = :bank_name AND status = :wait
@ -425,7 +429,7 @@ QList<EventInfo> EventDAO::getRecentEvents(int hours) {
QList<EventInfo> events;
QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"(
SELECT id, account_id, device_id, external_id, amount, phone, bank_name,
SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name,
status, type, comment, json, sent_to_server, update_time, complete_time, timestamp
FROM events
WHERE timestamp >= datetime('now', :hours)

View File

@ -91,6 +91,7 @@ CREATE TABLE IF NOT EXISTS events
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER,
external_id TEXT,
transaction_id TEXT,
device_id TEXT,
amount REAL,
phone TEXT,

View File

@ -56,6 +56,7 @@ inline EventType eventTypeFromString(const QString &str) {
struct EventInfo {
int id = -1;
QString externalId;
QString transactionId;
int accountId = -1;
QString deviceId;

View File

@ -33,7 +33,7 @@
#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 = {}, int eventId = -1);
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot = {}, int eventId = -1, const QString &transactionId = {});
static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}, int eventId = -1, bool toApiBase = false);
EventHandler::EventHandler(QObject *parent) : QObject(parent) {}
@ -74,7 +74,7 @@ void EventHandler::start() {
if (e.sentToServer) continue;
EventDAO::updateEvent(e.id, EventStatus::Error, "App restarted");
EventDAO::markSentToServer(e.id);
sendTaskError(eventTypeToString(e.type), e.externalId, e.bankName, "App restarted, event was pending");
sendTaskError(eventTypeToString(e.type), e.externalId, e.bankName, "App restarted, event was pending", {}, -1, e.transactionId);
qDebug() << "[EventHandler] Closed stale event id:" << e.id;
}
}
@ -108,11 +108,14 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
continue;
}
// transaction_id присутствует только в CREATE_TRANSACTION; для других типов это пусто.
const QString incomingTransactionId = task["body"].toObject()["transaction_id"].toString();
// Ищем аккаунт по material_id напрямую
const MaterialInfo account = MaterialDAO::findByMaterialId(materialId);
if (account.id == -1) {
qWarning() << "[EventHandler] Account not found for bank:" << bankName << "material_id:" << materialId;
sendTaskError(typeStr, materialId, bankName, "Account not found for material_id: " + materialId);
sendTaskError(typeStr, materialId, bankName, "Account not found for material_id: " + materialId, {}, -1, incomingTransactionId);
continue;
}
@ -120,7 +123,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
if (eventType == EventType::FetchProfile) {
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");
sendTaskError(eventTypeToString(c.type), c.externalId, c.bankName, "Cancelled: replaced by new task", {}, -1, c.transactionId);
EventDAO::markSentToServer(c.id);
}
}
@ -142,6 +145,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
if (eventType == EventType::CreateTransaction) {
event.amount = body["amount"].toDouble() / 100.0;
event.phone = body["bank_participator_id"].toString();
event.transactionId = body["transaction_id"].toString();
// Название банка-получателя из extra
const QString bankNameRu = body["extra"].toObject()["bank_participator_name_ru"].toString();
@ -160,7 +164,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
if (!onlineDevices.contains(account.deviceId)) {
qWarning() << "[EventHandler] Device offline for material_id:" << materialId << "device:" << account.deviceId;
EventDAO::updateEvent(eventId, EventStatus::Error, "Device is offline");
sendTaskError(typeStr, materialId, bankName, "Device is offline");
sendTaskError(typeStr, materialId, bankName, "Device is offline", {}, -1, incomingTransactionId);
}
}
@ -203,11 +207,14 @@ 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, int eventId) {
const QString &description, const QByteArray &screenshot, int eventId,
const QString &transactionId) {
QJsonObject errorData;
errorData["status"] = QStringLiteral("error");
errorData["event_type"] = type;
errorData["description"] = description;
if (!transactionId.isEmpty())
errorData["transaction_id"] = transactionId;
sendTaskResult(QStringLiteral("ERROR"), materialId, bankName, errorData, eventId);
if (!screenshot.isEmpty()) {
@ -289,7 +296,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, event.id);
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, result, screenshot, event.id, event.transactionId);
if (event.type == EventType::CreateTransaction) {
const MaterialInfo declAcc = MaterialDAO::getAccountById(event.accountId);
@ -318,6 +325,8 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
}
QJsonObject declined;
if (!event.transactionId.isEmpty())
declined["transaction_id"] = event.transactionId;
declined["bank_transaction_id"] = declTxId;
declined["amount"] = -static_cast<int>(event.amount * 100);
declined["currency"] = declCurrencyCode;
@ -360,7 +369,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
if (parsedTx.isEmpty()) {
qWarning() << "[EventHandler] FETCH_TRANSACTIONS: script did not emit parsed tx";
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName,
"Transaction not parsed from screen");
"Transaction not parsed from screen", {}, -1, event.transactionId);
EventDAO::updateEvent(event.id, EventStatus::Error,
"Transaction not parsed from screen");
EventDAO::markSentToServer(event.id);
@ -376,6 +385,8 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
const QString txType = taskJson["body"].toObject()["type"].toString();
QJsonObject obj;
if (!event.transactionId.isEmpty())
obj["transaction_id"] = event.transactionId;
obj["amount"] = -static_cast<int>(event.amount * 100);
obj["currency"] = currencyCode;
obj["transaction_type"] = txType.isEmpty() ? "top-up" : txType;
@ -653,7 +664,7 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
const QString logMsg = QString("[%1] bank=%2 material=%3: Script timeout")
.arg(eventTypeToString(event.type), event.bankName, event.externalId);
GeneralLogDAO::insert("CRITICAL", "EventHandler", logMsg);
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Script timeout (5 min)");
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Script timeout (5 min)", {}, -1, event.transactionId);
EventDAO::markSentToServer(event.id);
// Убиваем банковское приложение на устройстве после таймаута
@ -690,7 +701,7 @@ void EventHandler::processNextTask(const QString &deviceId) {
if (waitSecs > 5 * 60) {
qWarning() << "[EventHandler] Task expired after" << waitSecs << "s, event id:" << event.id;
EventDAO::updateEvent(event.id, EventStatus::Error, "Task expired: waited " + QString::number(waitSecs) + "s");
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Task expired: not started within 5 minutes");
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Task expired: not started within 5 minutes", {}, -1, event.transactionId);
continue;
}
@ -700,7 +711,7 @@ void EventHandler::processNextTask(const QString &deviceId) {
qWarning() << "[EventHandler] Bank profile disabled for" << account.appCode << "device:" << deviceId;
EventDAO::updateEvent(event.id, EventStatus::Error, "Bank profile is disabled");
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName,
"Bank profile is disabled: " + account.appCode);
"Bank profile is disabled: " + account.appCode, {}, -1, event.transactionId);
continue;
}

View File

@ -81,6 +81,7 @@ NetworkService::NetworkService(QObject *parent) : QObject(parent) {
m_monitoringToken = settings.value("net/monitoringToken").toString();
m_pathCurrentProfile = settings.value("net/pathCurrentProfile", "/api/v1/profile/current_profile").toString();
m_pathProfileGetInfo = settings.value("net/pathProfileGetInfo", "/api/v1/profile/get_info").toString();
m_pathProfilePing = settings.value("net/pathProfilePing", "/api/v1/profile/ping").toString();
m_manager = new QNetworkAccessManager(this);
m_token = settings.value("common/token").toString();
m_accessToken = SettingsDAO::get("access_token");
@ -279,7 +280,14 @@ QJsonValue NetworkService::postJson(const QString &path, const QJsonObject &body
}
QJsonValue result;
m_lastErrorSummary.clear();
m_lastResponseBody.clear();
m_lastHttpStatus = timedOut
? 0
: reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (timedOut) {
m_lastErrorSummary = "Timeout after " + QString::number(kMaxRequestAttempts)
+ " attempts: " + lastTimeoutDetail;
GeneralLogDAO::insert("CRITICAL", "NetworkService",
"Timeout (POST) after " + QString::number(kMaxRequestAttempts)
+ " attempts: " + lastTimeoutDetail, {}, reqBodyStr, {});
@ -287,6 +295,8 @@ QJsonValue NetworkService::postJson(const QString &path, const QJsonObject &body
} else if (reply->error() != QNetworkReply::NoError) {
const QByteArray responseBody = reply->readAll();
const QString errSummary = describeNetError(reply, path);
m_lastErrorSummary = errSummary;
m_lastResponseBody = QString::fromUtf8(responseBody);
qCritical().noquote() << "Network error (POST):" << errSummary
<< (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId)
<< "\n response body:" << responseBody;
@ -303,6 +313,8 @@ QJsonValue NetworkService::postJson(const QString &path, const QJsonObject &body
if (root["success"].toBool()) {
result = root["data"];
} else {
m_lastErrorSummary = "API error (success=false)";
m_lastResponseBody = QString::fromUtf8(raw);
qCritical() << "API error:" << root;
GeneralLogDAO::insert("WARNING", "NetworkService",
"API error (POST): " + shortPath, {},
@ -1034,6 +1046,38 @@ bool NetworkService::syncCurrentProfile() {
return true;
}
bool NetworkService::doPingProfile() {
if (!ensureValidToken()) return false;
const QString desktopId = SettingsDAO::get("desktop_id");
if (desktopId.isEmpty()) {
qWarning() << "[NetworkService] pingProfile: desktop_id is empty, skip";
return false;
}
const QSettings settings("config.ini", QSettings::IniFormat);
QJsonObject body;
body["desktop_id"] = desktopId;
body["version"] = settings.value("common/version").toString();
const QJsonValue result = postJson(m_apiBase + m_pathProfilePing, body, true);
Q_UNUSED(result);
// Критерий успеха для пинга — HTTP 200, тело не важно.
if (m_lastHttpStatus != 200) {
qWarning().noquote() << "[NetworkService] pingProfile failed: HTTP"
<< m_lastHttpStatus
<< m_lastErrorSummary
<< "| response body:" << m_lastResponseBody;
return false;
}
return true;
}
void NetworkService::pingProfile() {
doPingProfile();
emit finished();
}
void NetworkService::postBankProfile() {
if (!ensureValidToken()) { emit finished(); return; }
@ -1619,6 +1663,10 @@ void NetworkService::start() {
// быть вызван явно после firstPassDone от DeviceScreener (см. правило #6
// в docs/status-sync.md). Без firstPassDone мы не знаем, какие устройства
// действительно онлайн, и можем ошибочно сработать как «device→offline».
// Heartbeat не зависит от firstPassDone — стартуем сразу. Если desktop_id
// ещё не получен (до loginAndSetup), первые тики просто скипнутся.
startPeriodicPing();
}
void NetworkService::startPeriodicSync() {
@ -1628,6 +1676,33 @@ void NetworkService::startPeriodicSync() {
scheduleNextStatusSync();
}
void NetworkService::startPeriodicPing() {
if (m_pingEnabled) return;
m_pingEnabled = true;
qDebug() << "[NetworkService] periodic ping enabled (every" << (kPingProfileMs / 1000) << "s)";
scheduleNextPing();
}
void NetworkService::scheduleNextPing() {
if (!m_running || !m_pingEnabled) return;
QTimer::singleShot(kPingProfileMs, this, [this]() {
if (!m_running) return;
// Если предыдущий ping ещё в пути — пропускаем тик, чтобы не наслаивать.
if (m_pingInProgress.exchange(true)) {
qDebug() << "[NetworkService] ping tick skipped — previous still in progress";
scheduleNextPing();
return;
}
doPingProfile();
m_pingInProgress.store(false);
scheduleNextPing();
});
}
void NetworkService::scheduleNextStatusSync() {
if (!m_running || !m_periodicSyncEnabled) return;

View File

@ -63,6 +63,13 @@ public slots:
// health-счётчиком в runPeriodicSyncTick (правило #9).
bool syncCurrentProfile();
// POST /api/v1/profile/ping — heartbeat: даёт серверу понять, что десктоп
// онлайн. В теле уходят desktop_id и версия клиента. Вызывать периодически.
void pingProfile();
// Включает периодический ping (kPingProfileMs). Идемпотентен.
void startPeriodicPing();
// Включает периодическую синхронизацию статусов (60s). Должно быть вызвано
// ОДИН раз для долгоживущего инстанса после firstPassDone от DeviceScreener.
void startPeriodicSync();
@ -142,8 +149,15 @@ private:
QString m_monitoringToken;
QString m_pathCurrentProfile;
QString m_pathProfileGetInfo;
QString m_pathProfilePing;
bool m_fetchingTasks = false;
// Последняя ошибка от postJson/patchJson/getJson — чтобы вызывающий код
// мог логировать тело ответа сервера, не дублируя парсинг ошибки.
QString m_lastErrorSummary;
QString m_lastResponseBody;
int m_lastHttpStatus = 0; // 0 если ответа не было (timeout); иначе HTTP-код
// Периодическая синхронизация
bool m_periodicSyncEnabled = false; // включается только после firstPassDone
std::atomic<bool> m_syncInProgress{false}; // защита от наложения тиков
@ -151,6 +165,11 @@ private:
bool m_unhealthyEmitted = false; // чтобы не спамить syncHealthChanged
static constexpr int kSyncFailureThreshold = 3;
static constexpr int kPeriodicSyncMs = 60 * 1000;
static constexpr int kPingProfileMs = 5 * 1000;
// Heartbeat (ping)
bool m_pingEnabled = false;
std::atomic<bool> m_pingInProgress{false};
// Реестр мьютексов на пару (deviceId, bankProfileId) — общий для всех
// инстансов NetworkService. Используется для сериализации PATCH-операций.
@ -171,6 +190,8 @@ private:
void scheduleNextRefresh();
void scheduleNextStatusSync();
bool runPeriodicSyncTick(); // делает один тик, возвращает true при успехе
bool doPingProfile(); // тело ping без emit finished — для периодических тиков
void scheduleNextPing();
void syncDevicesFromServer(const QString &desktopId);
void syncBankProfilesFromServer();
void syncBanksIfEmpty();

View File

@ -247,11 +247,17 @@ void AccountSettingsWindow::toggleProfileStatus() {
m_app.status = newStatus;
showOverlay(isActive ? "Выключение профиля..." : "Включение профиля...");
sendAppStatus(newStatus);
// При включении профиля — включаем на сервере все локально активные материалы
// При включении профиля — материалы включаем ТОЛЬКО после успешного
// включения bank_profile на сервере; иначе пользователь получит подряд
// несколько одинаковых ошибок ("Invalid or disabled connection") от
// независимых PATCH'ей.
std::function<void()> onSuccess;
if (!isActive) {
const QList<MaterialInfo> materials = MaterialDAO::getAccounts(m_deviceId, m_app.code);
const QString deviceId = m_deviceId;
const QString appCode = m_app.code;
onSuccess = [this, deviceId, appCode]() {
const QList<MaterialInfo> materials = MaterialDAO::getAccounts(deviceId, appCode);
for (const auto &mat : materials) {
if (mat.status == MaterialStatus::Active && !mat.materialId.isEmpty()) {
auto *thread = new QThread;
@ -268,7 +274,9 @@ void AccountSettingsWindow::toggleProfileStatus() {
qDebug() << "[AccountSettings] Enabling material on server:" << mat.materialId;
}
}
};
}
sendAppStatus(newStatus, std::move(onSuccess));
}
// ── Edit PIN ───────────────────────────────────────────────────────────────
@ -463,7 +471,7 @@ void AccountSettingsWindow::startGetCardInfo() {
// ── Send status to server ──────────────────────────────────────────────────
void AccountSettingsWindow::sendAppStatus(const QString &status) {
void AccountSettingsWindow::sendAppStatus(const QString &status, std::function<void()> onSuccess) {
if (m_app.bankProfileId.isEmpty()) {
qWarning() << "[AccountSettings] sendAppStatus: no bankProfileId for" << m_app.code;
hideOverlay(true, "Сохранено локально", [this]() { updateStatusUI(); });
@ -478,9 +486,10 @@ void AccountSettingsWindow::sendAppStatus(const QString &status) {
net->addExtra("enable", enable);
NetworkService::connectErrorDialog(this, net);
connect(thread, &QThread::started, net, &NetworkService::toggleBankProfile);
connect(net, &NetworkService::finishedWithResult, this, [this, enable](int result) {
connect(net, &NetworkService::finishedWithResult, this, [this, enable, onSuccess = std::move(onSuccess)](int result) {
if (result == 0) {
hideOverlay(true, "Успешно", [this]() { updateStatusUI(); });
if (onSuccess) onSuccess();
return;
}
// Правило #3: при неудачном включении — откатываем локаль в off и

View File

@ -44,7 +44,7 @@ private:
void startCheckPinCode();
void startGetProfileInfo();
void startGetCardInfo();
void sendAppStatus(const QString &status);
void sendAppStatus(const QString &status, std::function<void()> onSuccess = {});
void toggleMaterialStatus(const MaterialInfo &account);
void reloadCards();

View File

@ -92,11 +92,14 @@ void BankSettingsWindow::startCheckPinCode(const QString &appCode) {
thread->start();
}
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName) {
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName,
const QString &transactionId = {}) {
QJsonObject errData;
errData["status"] = "error";
errData["event_type"] = type;
errData["description"] = "Bank profile disabled";
if (!transactionId.isEmpty())
errData["transaction_id"] = transactionId;
QJsonObject obj;
obj["type"] = "ERROR";
@ -126,7 +129,7 @@ void sendAppStatus(const QString &deviceId, const QString &code, const QString &
if (status != "active") {
const QList<EventInfo> cancelled = EventDAO::cancelWaitingByBank(deviceId, code);
for (const EventInfo &e : cancelled) {
sendTaskError(eventTypeToString(e.type), e.externalId, e.bankName);
sendTaskError(eventTypeToString(e.type), e.externalId, e.bankName, e.transactionId);
EventDAO::markSentToServer(e.id);
qDebug() << "[BankSettings] cancelled task" << e.id << eventTypeToString(e.type);
}

View File

@ -58,11 +58,14 @@ QString getColorByStatus(const BankProfileInfo &app) {
}
}
static void sendTaskErrorToServer(const QString &type, const QString &materialId, const QString &bankName) {
static void sendTaskErrorToServer(const QString &type, const QString &materialId, const QString &bankName,
const QString &transactionId = {}) {
QJsonObject errData;
errData["status"] = "error";
errData["event_type"] = type;
errData["description"] = "Bank profile disabled";
if (!transactionId.isEmpty())
errData["transaction_id"] = transactionId;
QJsonObject obj;
obj["type"] = "ERROR";
@ -94,7 +97,7 @@ void sendBankStatus(const QString &deviceId, const QString &code, const QString
if (status != "active") {
const QList<EventInfo> cancelled = EventDAO::cancelWaitingByBank(deviceId, code);
for (const EventInfo &e : cancelled) {
sendTaskErrorToServer(eventTypeToString(e.type), e.externalId, e.bankName);
sendTaskErrorToServer(eventTypeToString(e.type), e.externalId, e.bankName, e.transactionId);
EventDAO::markSentToServer(e.id);
}
}
@ -107,7 +110,9 @@ void sendBankStatus(const QString &deviceId, const QString &code, const QString
net->addExtra("enable", enable);
QObject::connect(thread, &QThread::started, net, &NetworkService::toggleBankProfile);
if (onResult) {
QObject::connect(net, &NetworkService::finishedWithResult, net,
// Контекст — qApp (main thread), иначе onResult выполнится в worker-треде
// и QMessageBox внутри колбэка упадёт на NSAlert init (см. crashlog 2026-05-26).
QObject::connect(net, &NetworkService::finishedWithResult, qApp,
[onResult](int result) { onResult(result); },
Qt::QueuedConnection);
}
@ -368,44 +373,44 @@ DeviceWidget::DeviceWidget(const DeviceInfo &device, QWidget *parent): QWidget(p
window->activateWindow();
});
auto *deleteBtn = new QPushButton(this);
deleteBtn->setIcon(QIcon(QCoreApplication::applicationDirPath() + "/images/delete.png"));
deleteBtn->setIconSize(QSize(20, 20));
deleteBtn->setFixedSize(28, 28);
deleteBtn->setFlat(true);
deleteBtn->setStyleSheet(
"background-color: rgba(0, 0, 0, 0.5);"
"border: none;"
"border-radius: 4px;"
);
connect(deleteBtn, &QPushButton::clicked, this, [this]() {
const QString deviceId = m_device.id;
const QString deviceName = m_device.name;
QPointer<DeviceWidget> guard(this);
QMessageBox msgBox(nullptr);
msgBox.setWindowTitle(tr("Удалить устройство?"));
msgBox.setText(
"Будет удалено устройство " + deviceName +
" и все связанные данные: банки, аккаунты, транзакции и события.");
QPushButton *confirmBtn = msgBox.addButton(tr("Удалить"), QMessageBox::AcceptRole);
QPushButton *cancelBtn = msgBox.addButton(tr("Отмена"), QMessageBox::RejectRole);
msgBox.setDefaultButton(cancelBtn);
msgBox.setModal(true);
msgBox.exec();
if (msgBox.clickedButton() == confirmBtn) {
auto *thread = new QThread;
auto *net = new NetworkService;
net->setDeviceId(deviceId);
net->moveToThread(thread);
QObject::connect(thread, &QThread::started, net, &NetworkService::deleteDeviceFromServer);
QObject::connect(net, &NetworkService::finished, thread, &QThread::quit);
QObject::connect(net, &NetworkService::finished, net, &QObject::deleteLater);
QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater);
thread->start();
}
});
// auto *deleteBtn = new QPushButton(this);
// deleteBtn->setIcon(QIcon(QCoreApplication::applicationDirPath() + "/images/delete.png"));
// deleteBtn->setIconSize(QSize(20, 20));
// deleteBtn->setFixedSize(28, 28);
// deleteBtn->setFlat(true);
// deleteBtn->setStyleSheet(
// "background-color: rgba(0, 0, 0, 0.5);"
// "border: none;"
// "border-radius: 4px;"
// );
// connect(deleteBtn, &QPushButton::clicked, this, [this]() {
// const QString deviceId = m_device.id;
// const QString deviceName = m_device.name;
// QPointer<DeviceWidget> guard(this);
//
// QMessageBox msgBox(nullptr);
// msgBox.setWindowTitle(tr("Удалить устройство?"));
// msgBox.setText(
// "Будет удалено устройство " + deviceName +
// " и все связанные данные: банки, аккаунты, транзакции и события.");
// QPushButton *confirmBtn = msgBox.addButton(tr("Удалить"), QMessageBox::AcceptRole);
// QPushButton *cancelBtn = msgBox.addButton(tr("Отмена"), QMessageBox::RejectRole);
// msgBox.setDefaultButton(cancelBtn);
// msgBox.setModal(true);
// msgBox.exec();
//
// if (msgBox.clickedButton() == confirmBtn) {
// auto *thread = new QThread;
// auto *net = new NetworkService;
// net->setDeviceId(deviceId);
// net->moveToThread(thread);
// QObject::connect(thread, &QThread::started, net, &NetworkService::deleteDeviceFromServer);
// QObject::connect(net, &NetworkService::finished, thread, &QThread::quit);
// QObject::connect(net, &NetworkService::finished, net, &QObject::deleteLater);
// QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater);
// thread->start();
// }
// });
auto *codeBtn = new QPushButton(this);
codeBtn->setIcon(QIcon(QCoreApplication::applicationDirPath() + "/images/code.png"));
@ -620,7 +625,7 @@ DeviceWidget::DeviceWidget(const DeviceInfo &device, QWidget *parent): QWidget(p
});
topRightLayout->addWidget(settingsBtn);
topRightLayout->addWidget(deleteBtn);
// topRightLayout->addWidget(deleteBtn);
topRightLayout->addWidget(codeBtn);
layout->addWidget(topRightWidget, 0, 0, Qt::AlignTop | Qt::AlignRight);