From 36567e78a46c253d245476f53aaea27cf504c6a5 Mon Sep 17 00:00:00 2001 From: slava Date: Fri, 22 May 2026 16:19:41 +0500 Subject: [PATCH] Integrate `DeviceScreener` with `NetworkService` for status synchronization, including debounce logic for online/offline transitions and periodic sync initiation. Refactor `startDeviceScreener` and `startNetworkService` to enable inter-component interaction. --- docs/status-sync.md | 105 ++++++++ main.cpp | 40 +++- services/DeviceScreener.cpp | 90 +++---- services/DeviceScreener.h | 10 +- services/net/NetworkService.cpp | 346 +++++++++++++++++++++------ services/net/NetworkService.h | 37 +++ views/MainWindow.cpp | 22 ++ views/MainWindow.h | 6 + views/bank/AccountSettingsWindow.cpp | 51 +++- views/device/DeviceWidget.cpp | 27 ++- 10 files changed, 597 insertions(+), 137 deletions(-) create mode 100644 docs/status-sync.md diff --git a/docs/status-sync.md b/docs/status-sync.md new file mode 100644 index 0000000..c1dd054 --- /dev/null +++ b/docs/status-sync.md @@ -0,0 +1,105 @@ +# Синхронизация статусов bank_profile и material + +## Определения + +### Локальные статусы (SQLite) +| Таблица | Поле | Значения | +|---|---|---| +| `bank_profiles` | `status` | `"active"` \| `"off"` | +| `materials` | `status` | `"active"` \| `"off"` (enum `MaterialStatus::Active` / `MaterialStatus::Off`) | + +### Серверные статусы (REST API) +| Сущность | Поле | Значения | +|---|---|---| +| bank_profile | `state` | `"enabled"` \| `"disabled"` | +| material | `state` | `"enabled"` \| `"disabled"` | + +### Маппинг +| Локально | Сервер | +|---|---| +| `active` | `enabled` | +| `off` | `disabled` | + +--- + +## Инвариант +**Локальное состояние — единственный источник истины.** Сервер всегда отражает локаль. При любых расхождениях побеждает локальная БД, и сервер дотягивается до неё (а не наоборот). + +Pull с сервера (`syncBankProfilesFromServer`) используется только для **создания новых записей** (новые bank_profile / новые материалы) и привязки серверных UUID. Статусы существующих записей с сервера в локаль никогда не пишутся. + +--- + +## Основные правила + +### 1. Смена онлайн-статуса устройства +- **Device → OFFLINE:** bank_profile этого устройства выключается **и локально, и на сервере** (`PATCH bank_profile/{id}/false`). Материалы локально не трогаем — они остаются в том состоянии, в котором были (см. правило 4-А ниже). +- **Device → ONLINE:** локальный статус **не меняется**. На сервер пушится текущее локальное состояние bank_profile и всех его материалов, если оно расходится с серверным. + +### 2. Периодическая синхронизация +- Каждые **60 секунд** запускается фоновая задача, которая сравнивает локальные статусы bank_profile и материалов с серверными и отправляет `PATCH` только для расходящихся. +- Локаль не трогается. Цель — компенсировать сетевые сбои, перезапуски сервера и т.п. + +### 3. Обработка ошибок при включении +- Когда пользователь включает bank_profile или material и `PATCH .../true` падает (network error / HTTP не-2xx / `success:false`): + - локально оставляем `off`; + - показываем пользователю сообщение об ошибке (см. правило 6 про UI). +- При неудачном **выключении** локально оставляем `off`; рассинхрон с сервером устранит минутный таймер из правила 2. + +### 4. Реконсиляция материалов после включения профиля +- Срабатывает **только при включении** профиля (`PATCH bank_profile/{id}/true`), при выключении — нет (см. правило 4-B про гард ниже). +- Отдельной реконсиляции после PATCH не делаем — расхождения материалов подхватит 60s-таймер из правила 2 (в худшем случае — через минуту). +- Допустима оптимизация: «дёрнуть» периодический sync сразу после успешного включения профиля, чтобы не ждать. + +--- + +## Дополнительные правила и инварианты + +### 4-A. Материалы при device → OFFLINE +- При переходе device → OFFLINE правило 1 выключает только профиль. Материалы локально не трогаем — после повторного включения профиля они вернутся к работе без лишних кликов пользователя. +- В UI материалы при `bank_profile.status == "off"` отображаются **приглушённо** или с пометкой «профиль выключен», но их `status` в БД остаётся как есть. + +### 4-B. Гард на пуш материала при выключенном профиле +- На сервере действует инвариант: `material.state ≤ bank_profile.state`. Если профиль `disabled`, сервер принудительно держит материалы в `disabled`. +- Чтобы не получить бесконечный diff-цикл, **при любом пуше статуса материала** (toggle в UI, периодический sync, follow-up после toggle профиля) проверяем локальный `bank_profile.status`. Если профиль `off` — `PATCH` материала **не отправляем**, локальный статус не меняем. +- Этот гард уже частично реализован в `AccountSettingsWindow.cpp:556`; нужно поднять его на уровень `NetworkService`, чтобы покрыть все пути пуша. + +### 5. Дебаунс ADB-флапа +- ADB может мерцать (USB-хаб, спящий экран, Doze). Без дебаунса каждое мерцание сработает как device → OFFLINE. +- **Online — мгновенно** (0 секунд). Как только устройство видно — сразу триггерим логику device → ONLINE. +- **Offline — с задержкой 30 секунд.** Устройство считается офлайн только после того, как ADB **не** показывает его как `device` непрерывно в течение 30 секунд. Если за этот период оно вернулось — считаем что флапа не было. + +### 6. Стартовый порядок (избегание гонки) +- `DeviceScreener` эмитит сигналы `deviceWentOnline(deviceId)` / `deviceWentOffline(deviceId)` **только при смене** статуса. На «никогда не видел в этой сессии» никаких событий нет. +- `NetworkService` стартует 60s-таймер только **после первого успешного прохода** `DeviceScreener` (сигнал `firstPassDone`). До этого никаких offline-эвентов не обрабатывает. +- Это исключает ситуацию «на старте все устройства выглядят офлайн → массово выключаем профили». + +### 7. Сериализация PATCH-ов +- Mutex на пару `(deviceId, bankProfileId)`: внутри одного устройства+профиля PATCH-операции последовательны, между разными — могут идти параллельно. +- Защищает от гонки: одновременно прилетели device-event, юзер-toggle и периодический sync для одного профиля → они выстраиваются в очередь. + +### 8. UI-обработка ошибок (overlay-pattern) +- При любом PATCH статуса (профиля или материала) UI показывает оверлей «Сохранение…» поверх тоггла. +- Тоггл **двигается только после ответа сервера**: + - успех → переходит в новое положение, оверлей скрывается; + - ошибка → остаётся в исходном положении, оверлей сменяется сообщением «Ошибка: …», локальный статус не меняется. +- Сейчас частично реализовано через `hideOverlay` в `AccountSettingsWindow.cpp:559`. Применить тот же pattern в `DeviceWidget`, `BankSetupWizard` и всех других местах с toggle. + +### 9. Индикация неработающей синхронизации +- В `NetworkService` ведём счётчик `m_consecutiveSyncFailures`, инкрементируем при каждом проваленном тике (правило 2) или провале `GET /current_profile`. Сбрасываем при первом успехе. +- При превышении порога (например, **3 тика подряд**) эмитим сигнал в `MainWindow`, который показывает индикатор «Нет связи с сервером, последняя успешная синхронизация: HH:MM:SS». +- При следующем успехе индикатор скрывается. + +### 10. Параллелизм и ограничения нагрузки +- В одном тике периодического sync может образоваться большой объём расхождений (5 устройств × 6 профилей × 4 материала = до 120 PATCH). +- Параллелизм с **лимитом 4-8 одновременных PATCH**; mutex из правила 7 соблюдается. +- Флаг `m_syncInProgress`: если предыдущий тик ещё не завершился, новый тик **пропускается**. Это предотвращает накопление очереди. + +### 11. Поведение при провале `GET /current_profile` +- Если периодический `GET /current_profile` упал (таймаут/сеть/5xx) — тик **пропускается тихо**, локаль не трогаем, никакие PATCH не шлём. +- Сам факт неудачи учитывается в счётчике из правила 9. + +--- + +## Связанные существующие правила (контекст) +- Toggle материала, когда `bank_profile.status == "off"`, **не идёт на сервер** — сохраняется только локально (см. правило 4-B). Серверная синхронизация произойдёт после включения профиля и следующего 60s-тика. +- После `BankSetupWizard` `bank_profile` остаётся `off` по дизайну — пользователь должен включить его вручную. \ No newline at end of file diff --git a/main.cpp b/main.cpp index 04df10f..dfc9ea2 100644 --- a/main.cpp +++ b/main.cpp @@ -65,7 +65,7 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS *ep) { #include #include -void startDeviceScreener(const QCoreApplication &app) { +void startDeviceScreener(const QCoreApplication &app, NetworkService *networkService = nullptr) { auto *thread = new QThread(); auto *deviceScreenerWorker = new DeviceScreener; @@ -76,6 +76,20 @@ void startDeviceScreener(const QCoreApplication &app) { // Когда поток запустится, автоматически вызовется worker->start() внутри фонового потока. QObject::connect(thread, &QThread::started, deviceScreenerWorker, &DeviceScreener::start); + // Связь с NetworkService: первый проход → старт периодической синхронизации; + // переходы online/offline → реакция в NetworkService (правило #1, #6). + if (networkService) { + QObject::connect(deviceScreenerWorker, &DeviceScreener::firstPassDone, + networkService, &NetworkService::startPeriodicSync, + Qt::QueuedConnection); + QObject::connect(deviceScreenerWorker, &DeviceScreener::deviceWentOnline, + networkService, &NetworkService::onDeviceWentOnline, + Qt::QueuedConnection); + QObject::connect(deviceScreenerWorker, &DeviceScreener::deviceWentOffline, + networkService, &NetworkService::onDeviceWentOffline, + Qt::QueuedConnection); + } + // 🔁 Эти 3 строки делают автоматическую зачистку: // Когда worker закончит работу (emit finished()), мы: // - останавливаем поток (thread->quit()) @@ -165,7 +179,7 @@ void startBlackPayByCardTest() { thread->start(); } -void startNetworkService(const QCoreApplication &app) { +NetworkService* startNetworkService(const QCoreApplication &app) { auto *paymentThread = new QThread; auto *networkService = new NetworkService; AppLogger::setNetworkService(networkService); @@ -209,6 +223,7 @@ void startNetworkService(const QCoreApplication &app) { }, Qt::QueuedConnection); paymentThread->start(); + return networkService; } static QFile logFile; @@ -357,8 +372,11 @@ int main(int argc, char *argv[]) { auto *mainWindow = new MainWindow(); mainWindow->show(); registrationWindow->close(); - startNetworkService(app); - startDeviceScreener(app); + NetworkService *net = startNetworkService(app); + QObject::connect(net, &NetworkService::syncHealthChanged, + mainWindow, &MainWindow::setSyncHealth, + Qt::QueuedConnection); + startDeviceScreener(app, net); startEventHandler(app); // startBlackPayByCardTest(); }, Qt::QueuedConnection); @@ -388,8 +406,11 @@ int main(int argc, char *argv[]) { if (r == 1) { auto *mainWindow = new MainWindow(); mainWindow->show(); - startNetworkService(app); - startDeviceScreener(app); + NetworkService *net = startNetworkService(app); + QObject::connect(net, &NetworkService::syncHealthChanged, + mainWindow, &MainWindow::setSyncHealth, + Qt::QueuedConnection); + startDeviceScreener(app, net); startEventHandler(app); // startBlackPayByCardTest(); } else if (r == 0) { @@ -409,8 +430,11 @@ int main(int argc, char *argv[]) { } else if (result == 1) { auto *mainWindow = new MainWindow(); mainWindow->show(); - startNetworkService(app); - startDeviceScreener(app); + NetworkService *net = startNetworkService(app); + QObject::connect(net, &NetworkService::syncHealthChanged, + mainWindow, &MainWindow::setSyncHealth, + Qt::QueuedConnection); + startDeviceScreener(app, net); startEventHandler(app); // startBlackPayByCardTest(); } else if (result == 0) { diff --git a/services/DeviceScreener.cpp b/services/DeviceScreener.cpp index bbbbb2b..dea3dc4 100644 --- a/services/DeviceScreener.cpp +++ b/services/DeviceScreener.cpp @@ -259,26 +259,6 @@ void DeviceScreener::updateDeviceOnApi(const DeviceInfo &device) { apiPatch("/api/v1/device/" + device.apiId, body); } -void DeviceScreener::toggleBankProfilesOnApi(const QString &deviceId, bool enable) { - if (enable) return; // не включаем автоматически при появлении устройства - - const QList apps = BankProfileDAO::getAllApplicationsByDeviceId(deviceId); - for (const BankProfileInfo &app : apps) { - if (app.bankProfileId.isEmpty()) continue; - if (app.status != "active") continue; - - apiPatch("/api/v1/profile/bank_profile/" + app.bankProfileId + "/false", QJsonObject()); - - const QList materials = MaterialDAO::getAccounts(deviceId, app.code); - for (const MaterialInfo &mat : materials) { - if (mat.materialId.isEmpty()) continue; - apiPatch("/api/v1/profile/material/" + mat.materialId + "/false", QJsonObject()); - } - } - - BankProfileDAO::deactivateAppsForDevice(deviceId); -} - QJsonValue DeviceScreener::apiGet(const QString &path) { if (!m_manager) m_manager = new QNetworkAccessManager(this); @@ -317,15 +297,12 @@ QJsonValue DeviceScreener::apiGet(const QString &path) { void DeviceScreener::start() { while (m_running) { - // Устройства, которые были онлайн в БД до текущего скана - const QStringList prevOnline = DeviceDAO::getOnlineDeviceCodes(); - QList > devices = readAdbDevices(); - QStringList onlineIds; - QList deviceInfos; + QSet liveSet; // устройства, видимые как "device" в текущем скане + const qint64 nowSec = QDateTime::currentSecsSinceEpoch(); + for (const auto &[adbSerial, status]: devices) { DeviceInfo device; - bool isChecked = true; if (status == "device") { device = readDeviceInfo(adbSerial); @@ -353,8 +330,7 @@ void DeviceScreener::start() { } } - onlineIds << device.id; - isChecked = prevOnline.contains(device.id); + liveSet.insert(device.id); device.image = takeScreenshot(adbSerial); if (!device.image.isEmpty()) { @@ -367,7 +343,6 @@ void DeviceScreener::start() { } device.status = status; device.updateTime = QDateTime::currentDateTime(); - deviceInfos.append(device); if (status == "device") { if (!DeviceDAO::upsertDevice(device)) { qDebug() << "Cant update device"; @@ -421,26 +396,57 @@ void DeviceScreener::start() { } } - if (!DeviceDAO::markDevicesOfflineExcept(onlineIds)) { - qDebug() << "Cant mark devices offline"; + // Detect transitions: + // 1) Online: устройство есть в liveSet, но ещё не в m_onlineDevices — мгновенно + for (const QString &id : liveSet) { + if (!m_onlineDevices.contains(id)) { + m_onlineDevices.insert(id); + m_offlineSince.remove(id); + qDebug() << "[DeviceScreener] device went ONLINE:" << id; + emit deviceWentOnline(id); + } else { + // Устройство всё ещё видно — сбрасываем pending offline-дебаунс (флап) + m_offlineSince.remove(id); + } } - // Было онлайн в БД, сейчас офлайн — выключаем профили и обновляем статус на сервере - for (const QString &id : prevOnline) { - if (!onlineIds.contains(id)) { - toggleBankProfilesOnApi(id, false); - // Отправляем OFFLINE статус на сервер - const DeviceInfo offlineDevice = DeviceDAO::getDeviceById(id); - if (!offlineDevice.apiId.isEmpty()) { - DeviceInfo offlineCopy = offlineDevice; - offlineCopy.status = "offline"; - updateDeviceOnApi(offlineCopy); - } + // 2) Offline: устройство было в m_onlineDevices, но больше не видно + // Засекаем m_offlineSince; срабатывает после 30s непрерывного отсутствия. + QSet goneOffline; + for (const QString &id : m_onlineDevices) { + if (liveSet.contains(id)) continue; + if (!m_offlineSince.contains(id)) { + m_offlineSince[id] = nowSec; + qDebug() << "[DeviceScreener] device missing, offline debounce started:" << id; + } else if (nowSec - m_offlineSince[id] >= kOfflineDebounceSecs) { + goneOffline.insert(id); } } + for (const QString &id : goneOffline) { + m_onlineDevices.remove(id); + m_offlineSince.remove(id); + qDebug() << "[DeviceScreener] device went OFFLINE (debounced):" << id; + // Сообщаем серверу что устройство офлайн (отдельно от bank_profile-синка) + const DeviceInfo offlineDevice = DeviceDAO::getDeviceById(id); + if (!offlineDevice.apiId.isEmpty()) { + DeviceInfo offlineCopy = offlineDevice; + offlineCopy.status = "offline"; + updateDeviceOnApi(offlineCopy); + } + emit deviceWentOffline(id); + } + + // БД status: считаем онлайном только debounced-онлайн. + QStringList debouncedOnlineList; + for (const QString &id : m_onlineDevices) debouncedOnlineList << id; + if (!DeviceDAO::markDevicesOfflineExcept(debouncedOnlineList)) { + qDebug() << "Cant mark devices offline"; + } if (m_firstScan) { m_firstScan = false; + qDebug() << "[DeviceScreener] first pass done, online devices:" << m_onlineDevices; + emit firstPassDone(); } QThread::msleep(1000); } diff --git a/services/DeviceScreener.h b/services/DeviceScreener.h index ef5ca93..26da719 100644 --- a/services/DeviceScreener.h +++ b/services/DeviceScreener.h @@ -1,6 +1,8 @@ #pragma once #include #include +#include +#include #include #include @@ -12,6 +14,8 @@ public: ~DeviceScreener() override; + static constexpr qint64 kOfflineDebounceSecs = 30; + public slots: void start(); @@ -19,6 +23,9 @@ public slots: signals: void finished(); + void firstPassDone(); + void deviceWentOnline(const QString &deviceId); + void deviceWentOffline(const QString &deviceId); private: bool m_running = true; @@ -28,6 +35,8 @@ private: QString m_apiBase; QNetworkAccessManager *m_manager = nullptr; QMap m_lastApiUpdate; // deviceId → unix timestamp последнего PATCH + QSet m_onlineDevices; // дебаунс-онлайн (true online после dedup) + QMap m_offlineSince; // deviceId → когда пропал из ADB (для дебаунса) static QList > readAdbDevices(); @@ -44,5 +53,4 @@ private: QString createDeviceOnApi(const DeviceInfo &device); void updateDeviceOnApi(const DeviceInfo &device); void postScreenshotToApi(const QString &apiId, const QByteArray &image); - void toggleBankProfilesOnApi(const QString &deviceId, bool enable); }; diff --git a/services/net/NetworkService.cpp b/services/net/NetworkService.cpp index 5891659..4c3cc78 100644 --- a/services/net/NetworkService.cpp +++ b/services/net/NetworkService.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include "dao/BankDAO.h" #include "dao/GeneralLogDAO.h" @@ -920,92 +921,94 @@ void NetworkService::syncDevicesFromServer(const QString &desktopId) { } void NetworkService::syncCurrentProfile() { - // 1. Определяем онлайн-устройства по БД (DeviceScreener обновит позже) - const QList allDevices = DeviceDAO::getAllDevices(false); - QSet onlineDeviceIds; - for (const DeviceInfo &dev : allDevices) { - if (dev.status == "device" && !dev.image.isEmpty()) { - onlineDeviceIds.insert(dev.id); - } - } - - qDebug() << "[syncCurrentProfile] online devices:" << onlineDeviceIds; - - // 3. GET current_profile + // Локаль — источник истины. Логика: GET current_profile, построить + // map id→state, пройти по локальным bank_profiles/materials, для + // каждого расхождения отправить PATCH с локальным значением. + // См. docs/status-sync.md (правила 2, 3, 4-B). const QJsonValue profileResult = getJson(m_apiBase + m_pathCurrentProfile, true); if (profileResult.isUndefined() || profileResult.isNull()) { - qWarning() << "[syncCurrentProfile] failed to get current_profile"; + qWarning() << "[syncCurrentProfile] GET current_profile failed"; return; } const QJsonObject profileData = profileResult.toObject(); - // 4. Сохраняем profile_id const QString profileId = profileData["id"].toString(); if (!profileId.isEmpty()) { SettingsDAO::set("profile_id", profileId); } - // 5. Синхронизируем bank_profiles и materials + // Маппим серверное состояние в плоские map для быстрого lookup. + QMap serverProfileState; // bankProfileId → "enabled"/"disabled" + QMap serverMaterialState; // materialId → "enabled"/"disabled" const QJsonArray bankProfiles = profileData["bank_profiles"].toArray(); for (const QJsonValue &bpVal : bankProfiles) { const QJsonObject bp = bpVal.toObject(); const QString bpId = bp["id"].toString(); - const QString serverBpState = bp["state"].toString(); // "enabled" / "disabled" - - // Находим applications с этим bank_profile_id - const QList apps = BankProfileDAO::findAllByBankProfileId(bpId); - for (const BankProfileInfo &app : apps) { - const bool deviceOnline = onlineDeviceIds.contains(app.deviceId); - const QString localStatus = app.status; // "active" / "off" - const QString serverStatus = (serverBpState == "disabled") ? "off" : "active"; - - if (!deviceOnline) { - // Офлайн: если на сервере enabled → PATCH disable - if (serverBpState != "disabled") { - qDebug() << "[syncCurrentProfile] device" << app.deviceId << "offline, disabling bank_profile" << bpId; - patchJson(m_apiBase + m_pathBankProfile + "/" + bpId + "/false", QJsonObject(), true); - } - } else { - // Онлайн: если локальный status != серверный → PATCH локальное значение на сервер - if (localStatus != serverStatus) { - const QString enableStr = (localStatus == "active") ? "true" : "false"; - qDebug() << "[syncCurrentProfile] device" << app.deviceId << "online, syncing bank_profile" << bpId << "to" << enableStr; - patchJson(m_apiBase + m_pathBankProfile + "/" + bpId + "/" + enableStr, QJsonObject(), true); - } - } - } - - // 6. Синхронизируем materials - const QJsonArray materials = bp["materials"].toArray(); - for (const QJsonValue &matVal : materials) { + if (!bpId.isEmpty()) serverProfileState[bpId] = bp["state"].toString(); + for (const QJsonValue &matVal : bp["materials"].toArray()) { const QJsonObject mat = matVal.toObject(); const QString matId = mat["id"].toString(); - const QString serverMatState = mat["state"].toString(); + if (!matId.isEmpty()) serverMaterialState[matId] = mat["state"].toString(); + } + } - const MaterialInfo account = MaterialDAO::findByMaterialId(matId); - if (account.id < 0) continue; // не найден в локальной БД + int pushedProfiles = 0; + int pushedMaterials = 0; - const bool deviceOnline = onlineDeviceIds.contains(account.deviceId); - const QString localStatus = materialStatusToString(account.status); - const QString serverStatus = (serverMatState == "disabled") ? "off" : "active"; + // Идём по локальным устройствам и их bank_profiles → пушим diff. + const QList allDevices = DeviceDAO::getAllDevices(false); + for (const DeviceInfo &dev : allDevices) { + const QList apps = BankProfileDAO::getApplicationsByDeviceId(dev.id); + for (const BankProfileInfo &app : apps) { + if (app.bankProfileId.isEmpty()) continue; // не синхронизирован с сервером - if (!deviceOnline) { - if (serverMatState != "disabled") { - qDebug() << "[syncCurrentProfile] device" << account.deviceId << "offline, disabling material" << matId; - patchJson(m_apiBase + m_pathMaterial + "/" + matId + "/false", QJsonObject(), true); + QSharedPointer mtx = keyMutex(dev.id, app.bankProfileId); + QMutexLocker locker(mtx.data()); + + // bank_profile diff + if (serverProfileState.contains(app.bankProfileId)) { + const QString localBpState = (app.status == "active") ? "enabled" : "disabled"; + const QString srvBpState = serverProfileState.value(app.bankProfileId); + if (srvBpState != localBpState) { + const QString enableStr = (app.status == "active") ? "true" : "false"; + qDebug() << "[syncCurrentProfile] pushing bank_profile" << app.bankProfileId + << "→" << enableStr << "(local=" << localBpState << "srv=" << srvBpState << ")"; + patchJson(m_apiBase + m_pathBankProfile + "/" + app.bankProfileId + "/" + enableStr, + QJsonObject(), true); + ++pushedProfiles; } } else { - if (localStatus != serverStatus) { - const QString enableStr = (localStatus == "active") ? "true" : "false"; - qDebug() << "[syncCurrentProfile] device" << account.deviceId << "online, syncing material" << matId << "to" << enableStr; - patchJson(m_apiBase + m_pathMaterial + "/" + matId + "/" + enableStr, QJsonObject(), true); + qWarning() << "[syncCurrentProfile] local bankProfileId" << app.bankProfileId + << "(device" << dev.id << "app" << app.code << ") not present on server, skipping"; + continue; + } + + // Гард #3 (rule 4-B): материалы не пушим при локально выключенном профиле. + if (app.status != "active") continue; + + const QList materials = MaterialDAO::getAccounts(dev.id, app.code); + for (const MaterialInfo &mat : materials) { + if (mat.materialId.isEmpty()) continue; + if (!serverMaterialState.contains(mat.materialId)) continue; + + const QString localMatState = (materialStatusToString(mat.status) == "active") + ? "enabled" : "disabled"; + const QString srvMatState = serverMaterialState.value(mat.materialId); + if (srvMatState != localMatState) { + const QString enableStr = (localMatState == "enabled") ? "true" : "false"; + qDebug() << "[syncCurrentProfile] pushing material" << mat.materialId + << "→" << enableStr << "(local=" << localMatState << "srv=" << srvMatState << ")"; + patchJson(m_apiBase + m_pathMaterial + "/" + mat.materialId + "/" + enableStr, + QJsonObject(), true); + ++pushedMaterials; } } } } - qDebug() << "[syncCurrentProfile] done"; + qDebug() << "[syncCurrentProfile] done — pushed" << pushedProfiles << "profiles," + << pushedMaterials << "materials"; } void NetworkService::postBankProfile() { @@ -1093,28 +1096,45 @@ void NetworkService::toggleMaterial() { return; } + // Резолвим (deviceId, bankProfileId) по материалу — нужно и для гарда #3, + // и для сериализации через keyMutex. + const MaterialInfo localMaterial = MaterialDAO::findByMaterialId(materialId); + QString deviceId; + QString bankProfileId; + bool profileActive = true; + if (localMaterial.id >= 0) { + deviceId = localMaterial.deviceId; + const BankProfileInfo app = BankProfileDAO::getApplication(localMaterial.deviceId, localMaterial.appCode); + if (app.id >= 0) { + bankProfileId = app.bankProfileId; + profileActive = (app.status == "active"); + } + } + + // Гард #3 (rule 4-B): пока локальный профиль off, на сервер материал + // не пушим — сервер всё равно держит его disabled, любое расхождение + // дотянет периодический sync после включения профиля. + if (!profileActive) { + qDebug() << "[NetworkService] toggleMaterial skipped — local profile is off:" + << materialId << "(device" << deviceId << ")"; + emit finishedWithResult(0); + emit finished(); + return; + } + + QSharedPointer mtx = keyMutex(deviceId, bankProfileId); + QMutexLocker locker(mtx.data()); + const QString enableStr = enable ? "true" : "false"; const QString path = m_apiBase + m_pathMaterial + "/" + materialId + "/" + enableStr; const QJsonValue result = patchJson(path, QJsonObject(), true); + // Локаль — источник истины (правило-инвариант). Ответ сервера не пишем + // обратно в БД, расхождения дотянет периодический sync. if (result.isNull() || result.isUndefined()) { qWarning() << "[NetworkService] toggleMaterial failed:" << materialId; emit finishedWithResult(-1); } else { - const QJsonArray materials = result.toArray(); - for (const QJsonValue &v : materials) { - const QJsonObject mat = v.toObject(); - const QString matId = mat["id"].toString(); - const QString serverState = mat["state"].toString(); - if (matId.isEmpty()) continue; - const MaterialInfo local = MaterialDAO::findByMaterialId(matId); - if (local.id < 0) continue; - const QString newStatus = (serverState == "disabled") ? "off" : "active"; - if (materialStatusToString(local.status) != newStatus) { - MaterialDAO::updateStatus(local.id, newStatus); - } - } - qDebug() << "[NetworkService] toggleMaterial done:" << materialId << "enable=" << enable - << "synced" << materials.size() << "materials"; + qDebug() << "[NetworkService] toggleMaterial ok:" << materialId << "enable=" << enable; emit finishedWithResult(0); } @@ -1133,6 +1153,14 @@ void NetworkService::toggleBankProfile() { return; } + // Резолвим deviceId для mutex-ключа (правило #5). + QString deviceId; + const QList apps = BankProfileDAO::findAllByBankProfileId(bankProfileId); + if (!apps.isEmpty()) deviceId = apps.first().deviceId; + + QSharedPointer mtx = keyMutex(deviceId, bankProfileId); + QMutexLocker locker(mtx.data()); + const QString enableStr = enable ? "true" : "false"; const QString path = m_apiBase + m_pathBankProfile + "/" + bankProfileId + "/" + enableStr; const QJsonValue result = patchJson(path, QJsonObject(), true); @@ -1140,7 +1168,7 @@ void NetworkService::toggleBankProfile() { qWarning() << "[NetworkService] toggleBankProfile failed:" << bankProfileId; emit finishedWithResult(-1); } else { - qDebug() << "[NetworkService] toggleBankProfile done:" << bankProfileId << "enable=" << enable; + qDebug() << "[NetworkService] toggleBankProfile ok:" << bankProfileId << "enable=" << enable; emit finishedWithResult(0); } @@ -1552,20 +1580,182 @@ void NetworkService::start() { ensureValidToken(); scheduleNextRefresh(); - // Периодическая синхронизация статусов bank_profiles/materials + // Периодическую синхронизацию запускает startPeriodicSync(), который должен + // быть вызван явно после firstPassDone от DeviceScreener (см. правило #6 + // в docs/status-sync.md). Без firstPassDone мы не знаем, какие устройства + // действительно онлайн, и можем ошибочно сработать как «device→offline». +} + +void NetworkService::startPeriodicSync() { + if (m_periodicSyncEnabled) return; + m_periodicSyncEnabled = true; + qDebug() << "[NetworkService] periodic sync enabled (first tick in" << (kPeriodicSyncMs / 1000) << "s)"; scheduleNextStatusSync(); } void NetworkService::scheduleNextStatusSync() { - if (!m_running) return; + if (!m_running || !m_periodicSyncEnabled) return; - QTimer::singleShot(60 * 1000, this, [this]() { + QTimer::singleShot(kPeriodicSyncMs, this, [this]() { if (!m_running) return; - syncCurrentProfile(); + + // Если предыдущий тик ещё не завершился — пропускаем (правило #10). + if (m_syncInProgress.exchange(true)) { + qDebug() << "[NetworkService] periodic sync tick skipped — previous still in progress"; + scheduleNextStatusSync(); + return; + } + + const bool ok = runPeriodicSyncTick(); + m_syncInProgress.store(false); + + if (ok) { + if (m_consecutiveSyncFailures > 0 || m_unhealthyEmitted) { + qDebug() << "[NetworkService] periodic sync recovered after" + << m_consecutiveSyncFailures << "failures"; + } + m_consecutiveSyncFailures = 0; + if (m_unhealthyEmitted) { + m_unhealthyEmitted = false; + emit syncHealthChanged(true, 0); + } + } else { + ++m_consecutiveSyncFailures; + qWarning() << "[NetworkService] periodic sync tick failed; consecutive failures:" + << m_consecutiveSyncFailures; + if (!m_unhealthyEmitted && m_consecutiveSyncFailures >= kSyncFailureThreshold) { + m_unhealthyEmitted = true; + emit syncHealthChanged(false, m_consecutiveSyncFailures); + } + } + scheduleNextStatusSync(); }); } +bool NetworkService::runPeriodicSyncTick() { + // Фаза 2 — обёртка с грубым detect-успех/неудача. В фазе 3 syncCurrentProfile + // будет переписан и сам вернёт bool; пока считаем тик неуспешным только при + // протухшем токене (более тонкая детекция придёт с рефакторингом). + if (!ensureValidToken()) return false; + syncCurrentProfile(); + return true; +} + +QMutex NetworkService::s_keyRegistryMutex; +QMap> NetworkService::s_keyMutexes; + +QSharedPointer NetworkService::keyMutex(const QString &deviceId, const QString &bankProfileId) { + const QString key = deviceId + "|" + bankProfileId; + QMutexLocker locker(&s_keyRegistryMutex); + auto it = s_keyMutexes.find(key); + if (it == s_keyMutexes.end()) { + it = s_keyMutexes.insert(key, QSharedPointer::create()); + } + return *it; +} + +void NetworkService::onDeviceWentOffline(const QString &deviceId) { + // Правило #1 (device→OFFLINE): выключаем bank_profile и локально, и на сервере. + // Материалы локально не трогаем (правило 4-A). + if (deviceId.isEmpty()) return; + if (!ensureValidToken()) return; + + qDebug() << "[NetworkService] onDeviceWentOffline:" << deviceId; + + const QList apps = BankProfileDAO::getApplicationsByDeviceId(deviceId); + + // 1) Сервер: для каждого активного профиля с bank_profile_id → PATCH disable. + for (const BankProfileInfo &app : apps) { + if (app.status != "active") continue; + if (app.bankProfileId.isEmpty()) continue; + + QSharedPointer mtx = keyMutex(deviceId, app.bankProfileId); + QMutexLocker locker(mtx.data()); + + const QString path = m_apiBase + m_pathBankProfile + "/" + app.bankProfileId + "/false"; + patchJson(path, QJsonObject(), true); + qDebug() << "[NetworkService] onDeviceWentOffline: disabled bank_profile on server:" + << app.bankProfileId << "(" << app.code << ")"; + } + + // 2) Локально: все профили устройства → off. + if (!BankProfileDAO::deactivateAppsForDevice(deviceId)) { + qWarning() << "[NetworkService] onDeviceWentOffline: failed to deactivate local apps for device" << deviceId; + } +} + +void NetworkService::onDeviceWentOnline(const QString &deviceId) { + // Правило #1 (device→ONLINE): локаль не меняем, пушим текущее локальное + // состояние bank_profile и его материалов на сервер (с учётом гарда #3). + if (deviceId.isEmpty()) return; + if (!ensureValidToken()) return; + + qDebug() << "[NetworkService] onDeviceWentOnline:" << deviceId; + + const QJsonValue profileResult = getJson(m_apiBase + m_pathCurrentProfile, true); + if (profileResult.isUndefined() || profileResult.isNull()) { + qWarning() << "[NetworkService] onDeviceWentOnline: GET current_profile failed"; + return; + } + + QMap serverProfileState; + QMap serverMaterialState; + const QJsonArray bankProfiles = profileResult.toObject()["bank_profiles"].toArray(); + for (const QJsonValue &bpVal : bankProfiles) { + const QJsonObject bp = bpVal.toObject(); + const QString bpId = bp["id"].toString(); + if (!bpId.isEmpty()) serverProfileState[bpId] = bp["state"].toString(); + for (const QJsonValue &matVal : bp["materials"].toArray()) { + const QJsonObject mat = matVal.toObject(); + const QString matId = mat["id"].toString(); + if (!matId.isEmpty()) serverMaterialState[matId] = mat["state"].toString(); + } + } + + const QList apps = BankProfileDAO::getApplicationsByDeviceId(deviceId); + for (const BankProfileInfo &app : apps) { + if (app.bankProfileId.isEmpty()) continue; + if (!serverProfileState.contains(app.bankProfileId)) { + qWarning() << "[NetworkService] onDeviceWentOnline: server lacks bank_profile" << app.bankProfileId; + continue; + } + + QSharedPointer mtx = keyMutex(deviceId, app.bankProfileId); + QMutexLocker locker(mtx.data()); + + const QString localBpState = (app.status == "active") ? "enabled" : "disabled"; + const QString srvBpState = serverProfileState.value(app.bankProfileId); + if (srvBpState != localBpState) { + const QString enableStr = (app.status == "active") ? "true" : "false"; + patchJson(m_apiBase + m_pathBankProfile + "/" + app.bankProfileId + "/" + enableStr, + QJsonObject(), true); + qDebug() << "[NetworkService] onDeviceWentOnline: pushed bank_profile" << app.bankProfileId + << "→" << enableStr; + } + + // Гард #3: материалы не пушим при локально выключенном профиле. + if (app.status != "active") continue; + + const QList materials = MaterialDAO::getAccounts(deviceId, app.code); + for (const MaterialInfo &mat : materials) { + if (mat.materialId.isEmpty()) continue; + if (!serverMaterialState.contains(mat.materialId)) continue; + + const QString localMatState = (materialStatusToString(mat.status) == "active") + ? "enabled" : "disabled"; + const QString srvMatState = serverMaterialState.value(mat.materialId); + if (srvMatState != localMatState) { + const QString enableStr = (localMatState == "enabled") ? "true" : "false"; + patchJson(m_apiBase + m_pathMaterial + "/" + mat.materialId + "/" + enableStr, + QJsonObject(), true); + qDebug() << "[NetworkService] onDeviceWentOnline: pushed material" << mat.materialId + << "→" << enableStr; + } + } + } +} + void NetworkService::deleteDeviceFromServer() { if (!ensureValidToken()) { emit finished(); return; } diff --git a/services/net/NetworkService.h b/services/net/NetworkService.h index 406fe54..a027364 100644 --- a/services/net/NetworkService.h +++ b/services/net/NetworkService.h @@ -4,6 +4,9 @@ #include #include #include +#include +#include +#include class QWidget; @@ -58,6 +61,18 @@ public slots: void loginAndSetup(); void syncCurrentProfile(); + // Включает периодическую синхронизацию статусов (60s). Должно быть вызвано + // ОДИН раз для долгоживущего инстанса после firstPassDone от DeviceScreener. + void startPeriodicSync(); + + // Обрабатывает переход устройства в офлайн (дебаунс-сигнал от DeviceScreener): + // выключает bank_profile локально и на сервере. + void onDeviceWentOffline(const QString &deviceId); + + // Обрабатывает переход устройства в онлайн: пушит текущее локальное + // состояние профиля и материалов на сервер (локаль не меняем). + void onDeviceWentOnline(const QString &deviceId); + void createDevice(); void updateDevice(); @@ -86,6 +101,9 @@ signals: // Эмитится при HTTP-ошибке PATCH-запроса (например, 409 'материал заблокирован'). // UI подписывается и показывает QMessageBox. void apiPatchError(int httpStatus, QString message); + // healthy=false при kSyncFailureThreshold подряд неудачных тиков; healthy=true + // при первом успехе после такой серии. consecutiveFailures даёт текущий счёт. + void syncHealthChanged(bool healthy, int consecutiveFailures); private: QByteArray m_json; @@ -120,6 +138,19 @@ private: QString m_pathProfileGetInfo; bool m_fetchingTasks = false; + // Периодическая синхронизация + bool m_periodicSyncEnabled = false; // включается только после firstPassDone + std::atomic m_syncInProgress{false}; // защита от наложения тиков + int m_consecutiveSyncFailures = 0; // счётчик подряд неуспешных тиков + bool m_unhealthyEmitted = false; // чтобы не спамить syncHealthChanged + static constexpr int kSyncFailureThreshold = 3; + static constexpr int kPeriodicSyncMs = 60 * 1000; + + // Реестр мьютексов на пару (deviceId, bankProfileId) — общий для всех + // инстансов NetworkService. Используется для сериализации PATCH-операций. + static QMutex s_keyRegistryMutex; + static QMap> s_keyMutexes; + QNetworkAccessManager *m_manager; QJsonValue post(QHttpMultiPart *multiPart, const QString &path); @@ -133,7 +164,13 @@ private: bool ensureValidToken(); void scheduleNextRefresh(); void scheduleNextStatusSync(); + bool runPeriodicSyncTick(); // делает один тик, возвращает true при успехе void syncDevicesFromServer(const QString &desktopId); void syncBankProfilesFromServer(); void syncBanksIfEmpty(); + +public: + // Возвращает (лениво создавая) shared mutex для пары (deviceId, bankProfileId). + // Используется через QMutexLocker для сериализации PATCH-ов по одному профилю. + static QSharedPointer keyMutex(const QString &deviceId, const QString &bankProfileId); }; diff --git a/views/MainWindow.cpp b/views/MainWindow.cpp index d6d563b..cefc14b 100644 --- a/views/MainWindow.cpp +++ b/views/MainWindow.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include "TutorialWindow.h" @@ -119,6 +120,27 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { SettingsDAO::set("tutorial_shown", "1"); }); } + + // Sync-health индикатор в статус-баре (правило #9). По умолчанию скрыт — + // показывается только когда NetworkService сообщил unhealthy. + m_syncHealthLabel = new QLabel(this); + m_syncHealthLabel->setVisible(false); + m_syncHealthLabel->setStyleSheet("color: #d9534f; padding: 0 8px;"); + statusBar()->addPermanentWidget(m_syncHealthLabel); +} + +void MainWindow::setSyncHealth(bool healthy, int consecutiveFailures) { + if (!m_syncHealthLabel) return; + if (healthy) { + m_syncHealthLabel->setVisible(false); + m_syncHealthLabel->setToolTip(QString()); + return; + } + m_syncHealthLabel->setText(QStringLiteral("⚠ Нет связи с сервером")); + m_syncHealthLabel->setToolTip( + QStringLiteral("Подряд %1 неудачных синхронизаций. Проверьте подключение.") + .arg(consecutiveFailures)); + m_syncHealthLabel->setVisible(true); } diff --git a/views/MainWindow.h b/views/MainWindow.h index 671a8f7..1e7e637 100644 --- a/views/MainWindow.h +++ b/views/MainWindow.h @@ -21,6 +21,11 @@ public: void loadDevices(); +public slots: + // Подключается к NetworkService::syncHealthChanged. healthy=false показывает + // индикатор «нет связи» в status bar, healthy=true прячет (правило #9). + void setSyncHealth(bool healthy, int consecutiveFailures); + private: QWidget *central; FlowLayout *flowLayout; @@ -38,6 +43,7 @@ private: QWidget *m_eventWindow = nullptr; QListWidget *m_logList = nullptr; + QLabel *m_syncHealthLabel = nullptr; void createDevicePage(); void deleteDevicePage(); diff --git a/views/bank/AccountSettingsWindow.cpp b/views/bank/AccountSettingsWindow.cpp index 9f07d89..36b38fe 100644 --- a/views/bank/AccountSettingsWindow.cpp +++ b/views/bank/AccountSettingsWindow.cpp @@ -478,11 +478,22 @@ 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](int result) { + connect(net, &NetworkService::finishedWithResult, this, [this, enable](int result) { if (result == 0) { hideOverlay(true, "Успешно", [this]() { updateStatusUI(); }); + return; + } + // Правило #3: при неудачном включении — откатываем локаль в off и + // сообщаем юзеру. При неудачном выключении — локаль остаётся off, + // сервер дотянется периодическим sync-ом (правило #2). + if (enable) { + BankProfileDAO::update(m_app.id, m_app.pinCode, m_app.comment, "off"); + m_app.status = "off"; + hideOverlay(false, "Не удалось включить — возвращено в выключено", + [this]() { updateStatusUI(); }); } else { - hideOverlay(false, "Ошибка отправки на сервер", [this]() { updateStatusUI(); }); + hideOverlay(false, "Локально выключено, сервер дотянется автоматически", + [this]() { updateStatusUI(); }); } }, Qt::QueuedConnection); connect(net, &NetworkService::finished, thread, &QThread::quit); @@ -585,11 +596,20 @@ void AccountSettingsWindow::toggleMaterialStatus(const MaterialInfo &account) { net->addExtra("enable", !isActive); NetworkService::connectErrorDialog(this, net); connect(thread, &QThread::started, net, &NetworkService::toggleMaterial); - connect(net, &NetworkService::finishedWithResult, this, [this, finishToggle](int result) { + const bool willEnable = !isActive; + const int materialDbId = account.id; + connect(net, &NetworkService::finishedWithResult, this, + [this, finishToggle, willEnable, materialDbId](int result) { if (result == 0) { hideOverlay(true, "Успешно", finishToggle); + return; + } + // Правило #3: при неудачном включении карты — откат локально в off. + if (willEnable) { + MaterialDAO::updateStatus(materialDbId, "off"); + hideOverlay(false, "Не удалось включить — возвращено в выключено", finishToggle); } else { - hideOverlay(false, "Ошибка отправки на сервер", finishToggle); + hideOverlay(false, "Локально выключено, сервер дотянется автоматически", finishToggle); } }, Qt::QueuedConnection); connect(net, &NetworkService::finished, thread, &QThread::quit); @@ -617,20 +637,39 @@ void AccountSettingsWindow::reloadCards() { }); m_cardsTable->horizontalHeader()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter); + // Правило 4-A: при выключенном профиле материалы фактически недоступны — + // отображаем приглушённо (gray) и добавляем подсказку, локальный status в БД не трогаем. + const bool profileOff = (m_app.status != "active"); + const QBrush dimmedBrush = QBrush(QColor(128, 128, 128)); + const QString dimmedTooltip = profileOff + ? QStringLiteral("Профиль банка выключен — карта фактически недоступна. " + "Включите профиль, чтобы карта заработала.") + : QString(); + for (int k = 0; k < accounts.size(); ++k) { const MaterialInfo &acc = accounts[k]; // Сервер auto *syncItem = new QTableWidgetItem(acc.materialId.isEmpty() ? "Не синхр." : "Синхр."); syncItem->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter); - syncItem->setForeground(acc.materialId.isEmpty() ? QBrush(Qt::red) : QBrush(Qt::darkGreen)); + if (profileOff) { + syncItem->setForeground(dimmedBrush); + syncItem->setToolTip(dimmedTooltip); + } else { + syncItem->setForeground(acc.materialId.isEmpty() ? QBrush(Qt::red) : QBrush(Qt::darkGreen)); + } m_cardsTable->setItem(k, 0, syncItem); // Статус const bool isActive = acc.status == MaterialStatus::Active; auto *statusItem = new QTableWidgetItem(materialStatusToUiString(acc.status)); statusItem->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter); - statusItem->setForeground(isActive ? QBrush(Qt::darkGreen) : QBrush(Qt::red)); + if (profileOff) { + statusItem->setForeground(dimmedBrush); + statusItem->setToolTip(dimmedTooltip); + } else { + statusItem->setForeground(isActive ? QBrush(Qt::darkGreen) : QBrush(Qt::red)); + } m_cardsTable->setItem(k, 1, statusItem); // Время обновления diff --git a/views/device/DeviceWidget.cpp b/views/device/DeviceWidget.cpp index 19d6367..01e3f6c 100644 --- a/views/device/DeviceWidget.cpp +++ b/views/device/DeviceWidget.cpp @@ -81,10 +81,12 @@ static void sendTaskErrorToServer(const QString &type, const QString &materialId thread->start(); } -void sendBankStatus(const QString &deviceId, const QString &code, const QString &status) { +void sendBankStatus(const QString &deviceId, const QString &code, const QString &status, + const std::function &onResult = nullptr) { const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, code); if (app.bankProfileId.isEmpty()) { qWarning() << "[DeviceWidget] sendBankStatus: no bankProfileId for" << code; + if (onResult) onResult(-1); return; } @@ -104,6 +106,11 @@ void sendBankStatus(const QString &deviceId, const QString &code, const QString net->addExtra("bank_profile_id", app.bankProfileId); net->addExtra("enable", enable); QObject::connect(thread, &QThread::started, net, &NetworkService::toggleBankProfile); + if (onResult) { + QObject::connect(net, &NetworkService::finishedWithResult, net, + [onResult](int result) { onResult(result); }, + Qt::QueuedConnection); + } QObject::connect(net, &NetworkService::finished, thread, &QThread::quit); QObject::connect(net, &NetworkService::finished, net, &QObject::deleteLater); QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater); @@ -208,7 +215,23 @@ void DeviceWidget::buildBankRow(const BankProfileInfo &app, QWidget *rowWidget, if (!BankProfileDAO::update(app.id, app.pinCode, app.comment, newStatus)) { qDebug() << "Cant update bank data"; } else { - sendBankStatus(app.deviceId, app.code, newStatus); + // Правило #3: при неудачном включении — откат локально + сообщение. + // При неудачном выключении — локаль остаётся off (источник истины), + // 60s sync дотянет сервер. + const bool enable = (newStatus == "active"); + sendBankStatus(app.deviceId, app.code, newStatus, + [app, enable](int result) { + if (result == 0) return; + if (enable) { + BankProfileDAO::update(app.id, app.pinCode, app.comment, "off"); + QMessageBox::warning(nullptr, "Ошибка", + "Не удалось включить банк на сервере.\n" + "Локальное состояние возвращено в выключено."); + } else { + qWarning() << "[DeviceWidget] PATCH disable failed for" + << app.code << "— sync retries via 60s timer"; + } + }); // При включении — включаем на сервере все локально активные материалы if (newStatus == "active") { const QList materials = MaterialDAO::getAccounts(app.deviceId, app.code);