// v1/data_server_sync.jsx — Frontend API-клиент для shared-state backend (Phase 2).
//
// Загружается ПОСЛЕ data_materials.jsx и ПЕРЕД styles.jsx. К моменту вызова
// async-методов window.REQUESTS / INVENTORY_FULL / W23_BRIDGE / W23_RESERVATIONS
// уже инициализированы соседними data_*.jsx.
//
// В этой сессии (A2) backend ещё не существует — все fetch будут падать и
// уходить в fallback (запись в _offlineQueue + persist в localStorage + 'reserve-changed').
// Сессия A4 (Wave 2) переключит мутации фронта на await window.API.* и
// _applyServerSnapshot станет основным каналом синхронизации.

(function () {
  // BASE — origin backend'а. Резолвится в порядке:
  //   1) window.__APP_CONFIG__.apiBase  (Capacitor APK подставляет в build-time).
  //   2) localStorage 'autolight.apiBase' (override для dev/QA).
  //   3) local-dev детект: если фронт на http://localhost:<X>/ и X != 3001,
  //      ходим на http://<hostname>:3001 напрямую (бэкенд CORS разрешает).
  //   4) Иначе — same-origin '' (production через Caddy reverse_proxy).
  function resolveBase() {
    try {
      if (window.__APP_CONFIG__ && window.__APP_CONFIG__.apiBase) {
        return String(window.__APP_CONFIG__.apiBase).replace(/\/$/, '');
      }
      const stored = localStorage.getItem('autolight.apiBase');
      if (stored) return stored.replace(/\/$/, '');
    } catch (_) {}
    const loc = window.location;
    if ((loc.hostname === 'localhost' || loc.hostname === '127.0.0.1') && loc.port && loc.port !== '3001') {
      return 'http://' + loc.hostname + ':3001';
    }
    return '';
  }
  const BASE = resolveBase();

  // API_KEY (X-API-Key) — для прямого фетча в backend без basic-auth.
  // Используется Capacitor APK (значение из build-time config) и для
  // локальной разработки (фронт на 127.0.0.1:8080, backend на 127.0.0.1:3001
  // без Caddy → нет X-Forwarded-User). Можно положить в localStorage
  // 'autolight.apiKey'. В production через Caddy ключ не нужен —
  // там работает X-Forwarded-User.
  function resolveApiKey() {
    try {
      if (window.__APP_CONFIG__ && window.__APP_CONFIG__.apiKey) return String(window.__APP_CONFIG__.apiKey);
      const stored = localStorage.getItem('autolight.apiKey');
      if (stored) return stored;
    } catch (_) {}
    return '';
  }
  const API_KEY = resolveApiKey();

  const TIMEOUT_MS = 5000;
  const ONLINE_WINDOW_MS = 10000; // API.isOnline = true если успех < 10с назад
  const OFFLINE_QUEUE_KEY = 'autolight.offlineQueue.v1';
  const RESYNC_MIN_GAP_MS = 5000; // дедуп _resyncNow (visibilitychange/online/watchdog)

  function fetchTimeout(url, opts, timeoutMs) {
    const ctrl = new AbortController();
    const t = setTimeout(() => ctrl.abort(), timeoutMs);
    return fetch(url, Object.assign({}, opts, { signal: ctrl.signal }))
      .finally(() => clearTimeout(t));
  }

  function loadQueue() {
    try {
      const raw = localStorage.getItem(OFFLINE_QUEUE_KEY);
      const v = raw ? JSON.parse(raw) : [];
      return Array.isArray(v) ? v : [];
    } catch (_) { return []; }
  }
  function saveQueue(q) {
    try { localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(q)); } catch (_) {}
  }

  function signalStatus() {
    try { window.dispatchEvent(new Event('api-status-changed')); } catch (_) {}
  }
  function signalReserve() {
    try { window.dispatchEvent(new Event('reserve-changed')); } catch (_) {}
  }

  // ── Архив нарядов: локальная персистентность флага archived ────────────────
  // backend-колонка archived появилась не сразу, а наряды-подсборки (корпус)
  // backend вовсе не персистит как archived. Без локального якоря optimistic
  // флаг стирался при каждой замене REQUESTS снапшотом → заархивированная фара
  // «исчезала» (done скрыт из буфера + archived потерян → не видна и в архиве).
  // Решение: множество archived-id в localStorage, переналагается на REQUESTS
  // при каждой гидрации (_applyServerSnapshot). Идемпотентно и безвредно после
  // backend-деплоя (backend archived + локальный якорь дают одно и то же).
  const _ARCHIVED_IDS_KEY = 'autolight.archived.ids';
  let _archivedIds;
  try { _archivedIds = new Set(JSON.parse(localStorage.getItem(_ARCHIVED_IDS_KEY) || '[]')); }
  catch (_) { _archivedIds = new Set(); }
  function _persistArchivedIds() {
    try { localStorage.setItem(_ARCHIVED_IDS_KEY, JSON.stringify(Array.from(_archivedIds))); } catch (_) {}
  }
  function _setArchivedLocal(id, archived) {
    if (!id) return;
    if (archived) _archivedIds.add(id); else _archivedIds.delete(id);
    _persistArchivedIds();
  }
  function _reapplyArchived() {
    if (!Array.isArray(window.REQUESTS) || _archivedIds.size === 0) return;
    for (const r of window.REQUESTS) {
      if (r && (_archivedIds.has(r.id) || _archivedIds.has(r.externalId))) r.archived = true;
    }
  }

  // Нормализация роли в УРОВЕНЬ ПРАВА. AUTH.user.role (из /auth/login и
  // /auth/me) хранит ROLEKEY бэкенда — 'DIRECTOR' / 'MASTER' / 'KEEPER' /
  // 'VIEWER' (или 'OWNER' в demo), а CURRENT_USER.role / CanWrite оперируют
  // УРОВНЯМИ 'admin' / 'manager' / 'viewer'. Маппинг тот же, что в app.jsx
  // (DIRECTOR→admin, KEEPER→manager). Без этой нормализации sticky-проверки
  // сравнивали roleKey с уровнем напрямую → никогда не совпадало → роль
  // владельца падала в viewer на просадке whoami и не восстанавливалась.
  function _normRoleKey(r) {
    r = String(r || '').toUpperCase();
    if (r === 'DIRECTOR' || r === 'ADMIN' || r === 'OWNER') return 'admin';
    if (r === 'MASTER' || r === 'KEEPER' || r === 'MANAGER') return 'manager';
    if (r === 'VIEWER') return 'viewer';
    return (r || '').toLowerCase();
  }
  window._normRoleKey = _normRoleKey;

  // ───────── Диагностический лог («почему случилось X») ────────────────────
  // Кольцевой буфер последних 500 событий: auth/роль/запись-fail/удаление/
  // каскад. Смотреть в консоли:  alLog()  ·  alLog('auth')  ·  alLog('write')
  //   ·  alLog('request')  ·  alLog('role'). Каждая запись пишется и в
  // console.debug, и в window.__AL_LOG (выживает между перерисовками).
  const _LOG = [];
  function _log(evt, data) {
    const e = { t: new Date().toISOString().slice(11, 23), evt: evt };
    if (data && typeof data === 'object') Object.assign(e, data);
    else if (data != null) e.v = data;
    _LOG.push(e);
    if (_LOG.length > 500) _LOG.shift();
    try { console.debug('[AL]', e.t, evt, data != null ? data : ''); } catch (_) {}
  }
  window.__AL_LOG = _LOG;
  window.alLog = function (filter) {
    const rows = filter ? _LOG.filter(e => (e.evt || '').indexOf(filter) >= 0) : _LOG.slice();
    try { console.table(rows); } catch (_) { console.log(rows); }
    return rows;
  };
  // Внешний писатель для других файлов (requests_section/cech и т.д.).
  window.logEvent = function (evt, data) { _log(evt, data); };

  const API = {
    _baseUrl: BASE,
    _lastSuccessAt: 0,
    _pending: 0,
    _offlineQueue: loadQueue(),
    _es: null,
    // SSE-watchdog: время последнего ВХОДЯЩЕГО SSE-события (open/snapshot/chat/
    // cech). Half-open туннель (Funnel «полужив», инцидент 2026-06-16) не даёт
    // EventSource error → авто-reconnect не срабатывает → данные молча тухнут
    // (симптом «иногда нужен ручной reload»). Сторож ниже принудительно
    // пересоздаёт мёртвый es, если событий нет дольше порога.
    _lastSseAt: 0,
    _sseWatchdog: null,
    // Дедуп ручного ресинка (visibilitychange/online): не чаще раза в N мс,
    // чтобы серия событий (alt-tab туда-сюда) не спамила getState.
    _lastResyncAt: 0,

    get isOnline() {
      return (Date.now() - this._lastSuccessAt) < ONLINE_WINDOW_MS;
    },

    _markOnline() {
      this._lastSuccessAt = Date.now();
      signalStatus();
    },

    // Резолв requestId → backend-cuid. Резерв/релиз могут прийти с локальным
    // ключом заявки (ORDER-XXX = externalId) — особенно из offline-очереди,
    // запущенной до реконсиляции local→cuid. InventoryReserve.requestId FK
    // ссылается на Request.id (cuid), поэтому externalId даёт FK-400 и резерв
    // не пишется (баг «Гранта не резервируется», 2026-06-18). Ищем заявку по
    // id ИЛИ externalId, возвращаем её настоящий cuid; если не нашли (удалена)
    // — оставляем как есть (4xx-drop в вызывающем методе уберёт мёртвую op).
    _resolveReqId(id) {
      if (id == null) return id;
      const reqs = (typeof window !== 'undefined' && Array.isArray(window.REQUESTS)) ? window.REQUESTS : [];
      const r = reqs.find(x => x && (x.id === id || x.externalId === id));
      return (r && r.id) || id;
    },

    // Дропать ли резерв-операцию из очереди ПЕРМАНЕНТНО (vs ретраить). Дропаем
    // ТОЛЬКО при реально-безнадёжном отказе:
    //   • 404 — endpoint/ресурс не найден;
    //   • FK-400 для заявки, которой УЖЕ НЕТ в REQUESTS (реально удалена).
    // РЕТРАИМ (re-enqueue): 403 (транзитный viewer при резолве роли — сам
    // восстановится) и FK-400 для ЖИВОЙ заявки (гонка с коммитом createRequest /
    // снапшот ещё не убрал призрак). Раньше дропали ВСЕ 4xx → резерв «мигал и
    // исчезал» навсегда (Александр 2026-06-18). 5xx/сеть — обычный offline-ретрай.
    _permanentReserveFail(err, rid) {
      if (!err || !(err.status >= 400 && err.status < 500)) return false;
      if (err.status === 404) return true;
      if (err.status === 400) {
        const reqs = (typeof window !== 'undefined' && Array.isArray(window.REQUESTS)) ? window.REQUESTS : [];
        const live = reqs.some(r => r && (r.id === rid || r.externalId === rid));
        return !live;   // FK на удалённую заявку — дроп; на живую — ретрай (гонка)
      }
      return false;     // 403 и прочее — ретраить
    },

    _enqueue(op) {
      this._offlineQueue.push(Object.assign({ at: Date.now() }, op));
      saveQueue(this._offlineQueue);
      signalStatus();
      // Throttled toast: один раз в 8с, даже если ops сыпятся пачкой.
      const now = Date.now();
      if (now - (this._lastOfflineToastAt || 0) > 8000) {
        this._lastOfflineToastAt = now;
        if (typeof window.toast === 'function') {
          window.toast('Сохранено локально, ждём связи с сервером…', 'warning');
        }
      }
    },

    // Обработчик неудачной мутации. Разделяет два сценария, которые раньше
    // оба молча уходили в offline-очередь (из-за чего viewer видел «Сохранено
    // локально», а правка потом исчезала после snapshot):
    //   • 401/403 — недостаточно прав. НЕ кладём в очередь (ретрай всё равно
    //     будет 403 вечно), показываем явную ошибку и откатываем оптимистичную
    //     локальную правку через ре-pull серверного состояния.
    //   • остальное (сеть, таймаут, 5xx) — законный offline: в очередь + ретрай.
    // op — то, что положить в очередь; ret — поля для merge в возвращаемый объект.
    _writeFail(err, op, ret) {
      ret = ret || {};
      const status = err && err.status;
      if (status === 401 || status === 403) {
        _log('write.forbidden', { status: status, op: op && op.type, role: (window.CURRENT_USER && window.CURRENT_USER.role) || null });
        const now = Date.now();
        if (now - (this._lastForbiddenToastAt || 0) > 6000) {
          this._lastForbiddenToastAt = now;
          if (typeof window.toast === 'function') {
            window.toast('Недостаточно прав — изменение не сохранено', 'error');
          }
        }
        this._revertFromServer();
        return Object.assign({ _forbidden: true }, ret);
      }
      this._enqueue(op);
      signalReserve();
      return Object.assign({ _offline: true }, ret);
    },

    // Откат оптимистичной локальной правки: тянем авторитетное состояние с
    // сервера и переприменяем. Дебаунс 150мс — пачка отказов схлопывается в
    // один re-pull. GET'ы не гейтятся ролью, поэтому работают и для viewer.
    _revertFromServer() {
      if (this._revertScheduled) return;
      this._revertScheduled = true;
      setTimeout(() => {
        this._revertScheduled = false;
        this.getState().then(s => { if (s) _applyServerSnapshot(s); }).catch(() => {});
        this.getTaxonomy().then(t => { if (t) _applyTaxonomy(t); }).catch(() => {});
      }, 150);
    },

    // ВСЕ партии Цеха, связанные с заявкой (1 заявка → N PF-партий) — массив.
    // Заявка приходит объектом (id + externalId) или строкой-id. Питается из
    // window._batchByRequestId (строится в _applyServerSnapshot).
    batchesForRequest(reqOrId) {
      const m = window._batchByRequestId || {};
      if (!reqOrId) return [];
      if (typeof reqOrId === 'string') return m[reqOrId] || [];
      const byId = m[reqOrId.id] || [];
      const byExt = (reqOrId.externalId && m[reqOrId.externalId]) || [];
      return byId.length ? byId : byExt;
    },
    // Совместимость: первая партия заявки (для confirm-сообщения), либо null.
    batchForRequest(reqOrId) {
      const arr = this.batchesForRequest(reqOrId);
      return (arr && arr.length) ? arr[0] : null;
    },
    // Каскад удаления заявки в Цех. Метит requestId как удалённый (переживает
    // race с входящим SSE-снапшотом — см. merge в _applyServerSnapshot) и
    // вычищает из window.CECH_STATE связанные items: и backend-партии, и
    // локальные split'ы (по _requestId, который split наследует от родителя),
    // затем будит cech.jsx через cech:state-changed (ре-гидратация из глобала).
    purgeRequestFromCech(reqOrId) {
      const ids = [];
      if (typeof reqOrId === 'string') { if (reqOrId) ids.push(reqOrId); }
      else if (reqOrId) { if (reqOrId.id) ids.push(reqOrId.id); if (reqOrId.externalId) ids.push(reqOrId.externalId); }
      if (!ids.length) return;
      if (!(window._purgedRequestIds instanceof Set)) window._purgedRequestIds = new Set();
      ids.forEach(id => window._purgedRequestIds.add(id));
      const cs = window.CECH_STATE;
      if (cs && Array.isArray(cs.items)) {
        const before = cs.items.length;
        cs.items = cs.items.filter(it => !(it._requestId && ids.indexOf(it._requestId) >= 0));
        _log('cech.purge', { ids: ids.join(','), removed: before - cs.items.length });
        // Чистим связанное состояние сборки (S2 asmConfirm + S3 progress), иначе
        // удаление заявки в разгар сборки оставит висеть подтверждения деталей.
        if (cs.assembly) ids.forEach(id => { delete cs.assembly[id]; });
        if (window.ASSEMBLY_PROGRESS) ids.forEach(id => { delete window.ASSEMBLY_PROGRESS[id]; });
        // Будим S2-листенер (cech:purge-request) — он чистит React-state asmConfirm,
        // — и общий ре-гидрат (cech:state-changed) для перерисовки items.
        try { window.dispatchEvent(new CustomEvent('cech:purge-request', { detail: { requestId: ids[0], requestIds: ids } })); } catch (_) {}
        if (cs.items.length !== before) {
          try { window.dispatchEvent(new CustomEvent('cech:state-changed', { detail: { source: 'purge', requestId: ids[0] } })); } catch (_) {}
        }
      }
    },

    // Принудительный ресинк состояния с сервера: тянем авторитетный снимок и
    // переприменяем + дренируем очередь. Вызывается из watchdog'а (после
    // реконнекта SSE) и из visibilitychange/online-хендлеров — каналов, где
    // пропущенный broadcast иначе оставил бы фронт с устаревшим состоянием
    // (раньше ресинк шёл ТОЛЬКО на приход snapshot-события). Дедуп по
    // _lastResyncAt: не чаще раза в RESYNC_MIN_GAP_MS, чтобы серия событий
    // (alt-tab, флап сети) не спамила getState. reason — для диаг-лога.
    _resyncNow(reason) {
      const now = Date.now();
      if (now - (this._lastResyncAt || 0) < RESYNC_MIN_GAP_MS) return;
      this._lastResyncAt = now;
      _log('resync', { reason: reason || null });
      this.getState()
        .then(s => { if (s) _applyServerSnapshot(s); })
        .catch(() => {})
        .then(() => { try { this._drainQueue(); } catch (_) {} });
    },

    async _drainQueue() {
      if (!this._offlineQueue.length) return;
      const pending = this._offlineQueue.slice();
      this._offlineQueue.length = 0;
      saveQueue(this._offlineQueue);
      let drained = 0;
      for (let i = 0; i < pending.length; i++) {
        const op = pending[i];
        try {
          const res = await this._dispatch(op);
          // Оффлайн-заявка создавалась с синтетическим id; backend вернул cuid.
          // Пробрасываем его в локальную REQUESTS (по externalId), иначе
          // удаление/каскад искали бы партии по неверному id (партии в Цеху
          // не удалялись бы in-session). Партии бэкенда уже несут cuid.
          if (op.type === 'createRequest' && res && res.id && Array.isArray(window.REQUESTS)) {
            const ext = op.body && op.body.externalId;
            const lr = window.REQUESTS.find(r => r && (r.externalId === ext || r.id === ext));
            // Снимаем локальный id ДО переназначения на cuid — это один из
            // возможных _linkKey'ев (launch_flow мог залинковать партию по нему).
            const prevLocalId = lr && lr.id;
            if (lr && lr.id !== res.id) { _log('request.idsync', { from: lr.id, to: res.id }); lr.id = res.id; lr.externalId = res.externalId || lr.externalId; }
            // SEAM с launch_flow.jsx: оффлайн-партии Цеха (cech.createBatch)
            // несут _linkKey — локальный ORDER-ключ их заявки. Заявка только что
            // получила backend-cuid (res.id); пропатчим requestId в ещё-не-слитых
            // createBatch-операциях этой заявки, иначе они уйдут на сервер с
            // requestId=null навсегда (партии-сироты в Цеху, не каскадятся при
            // удалении заявки). _linkKey совпадает с локальным ключом заявки —
            // тем же, по которому REQUESTS только что синканулся (externalId/id).
            const reqKeys = [];
            if (ext != null) reqKeys.push(ext);
            if (prevLocalId != null) reqKeys.push(prevLocalId);
            if (op.body && op.body.id != null) reqKeys.push(op.body.id);
            for (let j = i + 1; j < pending.length; j++) {
              const dep = pending[j];
              if (!dep || dep.type !== 'cech.createBatch' || !dep.payload) continue;
              const link = dep.payload._linkKey;
              if (link != null && reqKeys.indexOf(link) >= 0) {
                if (!dep.payload.requestId) {
                  dep.payload.requestId = res.id;
                  _log('cech.batch.linksync', { linkKey: link, requestId: res.id });
                }
                // _linkKey — клиентский маркер, backend его не ждёт; убираем
                // из тела перед отправкой, чтобы не словить strict-schema reject.
                delete dep.payload._linkKey;
              }
            }
          }
          drained++;
        } catch (err) {
          // снова не дошло — оставшийся хвост возвращаем в очередь и выходим
          this._offlineQueue.push.apply(this._offlineQueue, pending.slice(i));
          saveQueue(this._offlineQueue);
          signalStatus();
          return;
        }
      }
      signalStatus();
      if (drained > 0 && typeof window.toast === 'function') {
        window.toast(`Синхронизировано ${drained} ${drained === 1 ? 'операция' : 'операций'} с сервером`, 'info');
      }
    },

    _dispatch(op) {
      switch (op && op.type) {
        case 'createRequest':          return this.createRequest(op.body);
        case 'updateRequestStatus':    return this.updateRequestStatus(op.id, op.status);
        case 'deleteRequest':          return this.deleteRequest(op.id);
        case 'archiveRequest':         return this.archiveRequest(op.id, op.archived);
        // A4 contract: payload-shape matches A1 backend (rows array).
        case 'reserveForRequest':      return this.reserveForRequest(op.id, op.rows);
        case 'releaseForRequest':      return this.releaseForRequest(op.id);
        case 'stockMovement':          return this.stockMovement(op.payload);
        case 'reserveForAssembly':     return this.reserveForAssembly(op.id, op.rows);
        case 'releaseAssembly':        return this.releaseAssembly(op.id);
        // Спринт 2026-06-17 — отгрузка / закрытие наряда.
        case 'createShipment':         return this.createShipment(op.body);
        case 'closeRequest':           return this.closeRequest(op.id, op.body);
        case 'applyLaunchToEquipment': return this.applyLaunchToEquipment(op.items);
        // Phase 2.5 — taxonomy/items/specifications/calibration.
        case 'updateWarehouse':        return this.updateWarehouse(op.id, op.patch);
        case 'updateGroup':            return this.updateGroup(op.id, op.patch);
        case 'createGroup':            return this.createGroup(op.body);
        case 'deleteGroup':            return this.deleteGroup(op.id);
        case 'updateItem':             return this.updateItem(op.id, op.patch);
        case 'createItem':             return this.createItem(op.body);
        case 'deleteItem':             return this.deleteItem(op.id);
        case 'setSpecification':       return this.setSpecification(op.sku, op.tree);
        case 'deleteSpecification':    return this.deleteSpecification(op.sku);
        case 'setProductsOverlay':     return this.setProductsOverlay(op.overlay);
        case 'updateCalibration':      return this.updateCalibration(op.patch);
        case 'subsub.create':          return this.subsubgroup.create(op.subgroupId, op.name);
        case 'subsub.rename':          return this.subsubgroup.rename(op.id, op.name);
        case 'subsub.remove':          return this.subsubgroup.remove(op.id);
        // F014: Цех-мутации — раньше enqueue'ились, но не имели case → падали в
        // default(null), и _drainQueue считал их «слитыми» и молча выбрасывал.
        // Теперь дренятся как все остальные.
        case 'cech.createBatch':       return this.cech.createBatch(op.payload);
        case 'cech.updateBatch':       return this.cech.updateBatch(op.id, op.patch);
        case 'cech.deleteBatch':       return this.cech.deleteBatch(op.id);
        case 'cech.createTransfer':    return this.cech.createTransfer(op.payload);
        case 'cech.commitTransfer':    return this.cech.commitTransfer(op.transferId);
        case 'cech.cancelTransfer':    return this.cech.cancelTransfer(op.transferId);
        // Неизвестный тип (повреждённая/legacy-запись из старого localStorage):
        // не throw'аем, чтобы не заклинить очередь, но логируем — раньше это
        // молча проглатывалось как «успех».
        default:
          console.warn('[sync] неизвестная операция в очереди, отброшена:', op && op.type);
          return Promise.resolve(null);
      }
    },

    async _doFetch(path, init, _retried) {
      this._pending++;
      signalStatus();
      const opts = Object.assign({}, init || {});
      const headers = Object.assign({}, opts.headers || {});
      // X-API-Key подмешиваем для всех запросов, если ключ задан
      // (Capacitor / dev). Backend выдаст role:manager.
      if (API_KEY) headers['X-API-Key'] = API_KEY;
      // Phase 3 — если есть accessToken (после /auth/login), шлём Bearer.
      // Backend chat-routes и auth-protected endpoints извлекут user.sub.
      const token = (window.AUTH && window.AUTH.accessToken) || null;
      if (token) headers['Authorization'] = 'Bearer ' + token;
      opts.headers = headers;
      try {
        const res = await fetchTimeout(BASE + path, opts, TIMEOUT_MS);
        // Access-token TTL = 15 мин. Когда он истекает, backend на Bearer
        // отвечает 401 (jwt.verify падает → anonymous → write 403 / whoami
        // anonymous). Раньше _doFetch это не лечил: токен не обновлялся в
        // течение сессии, и через 15 минут без reload владелец «терял права»
        // (серые кнопки + 403), пока не сделает hard-reset (тот дёргал
        // AUTH.me→_refresh). Теперь на 401 с живым токеном — один тихий
        // refresh + повтор запроса. Не вышло — сбрасываем auth, app.jsx
        // покажет LoginGate (явный перелогин лучше тихого anonymous).
        // Рефреш пробуем по COOKIE — НЕ требуем in-memory accessToken (после reload
        // он мог не загрузиться/истечь, а refresh-cookie жив). Это закрывало дыру:
        // 401 без accessToken → refresh не пытались → роль/запись не восстанавливались.
        if (res.status === 401 && !_retried && window.AUTH
            && typeof window.AUTH._refresh === 'function') {
          const ok = await window.AUTH._refresh();
          _log('auth.refresh', { ok: !!ok, path: path });
          // Успешный refresh → ДОЖИДАЕМСЯ пере-резолва роли (иначе retry уйдёт
          // со старой ролью), затем повторяем исходный запрос.
          if (ok) {
            await this.currentUser(true).catch(() => {});
            return await this._doFetch(path, init, true);
          }
          // Refresh не вышел → жёсткий сброс auth + роли + LoginGate. Лучше
          // явный перелогин, чем тихий viewer и 403-revert у каждой записи
          // («заявка удалилась → вернулась»).
          window.AUTH.accessToken = null;
          window.AUTH.user = null;
          if (typeof window.AUTH._save === 'function') window.AUTH._save();
          window.CURRENT_USER = { user: 'anonymous', role: 'viewer', authenticated: false, _loadedAt: 0 };
          try { window.dispatchEvent(new Event('current-user-changed')); } catch (_) {}
          if (typeof window.AUTH._signal === 'function') window.AUTH._signal();
          _log('auth.lost', { path: path });
        }
        if (!res.ok) {
          const e = new Error('HTTP ' + res.status);
          e.status = res.status;        // 401/403 → права, 4xx/5xx → бэкенд; сетевая ошибка статуса не имеет
          if (init && init.method && init.method !== 'GET') _log('http.fail', { path: path, status: res.status, method: init.method });
          throw e;
        }
        let data = {};
        try { data = await res.json(); } catch (_) { /* пустой ответ */ }
        this._markOnline();
        return data;
      } finally {
        this._pending = Math.max(0, this._pending - 1);
        signalStatus();
      }
    },

    async getState() {
      try {
        return await this._doFetch('/api/state', { method: 'GET' });
      } catch (err) {
        return null;
      }
    },

    async createRequest(req) {
      try {
        return await this._doFetch('/api/state/request', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(req || {}),
        });
      } catch (err) {
        this._enqueue({ type: 'createRequest', body: req });
        signalReserve();
        return Object.assign({ _offline: true }, req || {});
      }
    },

    async updateRequestStatus(id, status) {
      try {
        return await this._doFetch('/api/state/request/' + encodeURIComponent(id), {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ status }),
        });
      } catch (err) {
        this._enqueue({ type: 'updateRequestStatus', id, status });
        signalReserve();
        return { _offline: true, id, status };
      }
    },

    async deleteRequest(id) {
      try {
        return await this._doFetch('/api/state/request/' + encodeURIComponent(id), {
          method: 'DELETE',
        });
      } catch (err) {
        this._enqueue({ type: 'deleteRequest', id });
        signalReserve();
        return { _offline: true, id };
      }
    },

    // «Сохранить в архив» / «Восстановить» — PATCH archived (не удаляет наряд).
    async archiveRequest(id, archived = true) {
      _setArchivedLocal(id, !!archived);   // локальный якорь — переживает замену REQUESTS снапшотом
      try {
        return await this._doFetch('/api/state/request/' + encodeURIComponent(id), {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ archived: !!archived }),
        });
      } catch (err) {
        this._enqueue({ type: 'archiveRequest', id, archived: !!archived });
        signalReserve();
        return { _offline: true, id, archived: !!archived };
      }
    },

    // A4: payload-shape совпадает с A1 backend.
    //   rows: [{ invId, amount, unit }]
    // Локальное вычисление (calcBomReserve) делает data_materials.jsx,
    // сюда приходят уже подготовленные строки. Это удобно для drain
    // _offlineQueue — не нужно дёргать calc заново при ретрае.
    //
    // resolveReqId (2026-06-18, Александр — «Гранта не резервируется»): резолвим
    // requestId в backend-cuid. КОРЕНЬ: резерв уходил с локальным/внешним ключом
    // (ORDER-XXX), а InventoryReserve.requestId FK ссылается на Request.id (cuid)
    // → ORDER-XXX ≠ cuid → FK-400, резерв не писался. Ищем заявку по id ИЛИ
    // externalId, берём её настоящий cuid. Если не нашли (заявка удалена) —
    // оставляем как есть, и 4xx-drop ниже выкинет мёртвую операцию из очереди.
    async reserveForRequest(id, rows) {
      const rid = API._resolveReqId(id);
      try {
        return await this._doFetch('/api/state/reserve', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ requestId: rid, rows: rows || [] }),
        });
      } catch (err) {
        // 4xx (FK-400 на несуществующий/удалённый requestId) — ПЕРМАНЕНТНО:
        // не зацикливаем (был вечный флуд 400 от мёртвой ORDER-XXX). Дропаем.
        if (err && API._permanentReserveFail(err, rid)) {
          _log('reserve.drop', { op: 'reserve', id: rid, status: err.status });
          return { _dropped: true, status: err.status };
        }
        this._enqueue({ type: 'reserveForRequest', id: rid, rows });
        signalReserve();
        return { _offline: true, requestId: rid };
      }
    },

    async releaseForRequest(id) {
      const rid = API._resolveReqId(id);
      try {
        return await this._doFetch('/api/state/release', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ requestId: rid }),
        });
      } catch (err) {
        if (err && API._permanentReserveFail(err, rid)) {
          _log('reserve.drop', { op: 'release', id: rid, status: err.status });
          return { _dropped: true, status: err.status };
        }
        this._enqueue({ type: 'releaseForRequest', id: rid });
        signalReserve();
        return { _offline: true, requestId: rid };
      }
    },

    // Движение склада (расход/приход). payload:
    //   { movements:[{invId,delta,kind,unit}], release?:[{requestId,invId,amount}],
    //     requestId?, batchId?, sourceId?, note? }
    // sourceId (напр. transferId) даёт идемпотентность — offline-retry / повторный
    // commit не спишут дважды (backend дедупит по sourceId).
    async stockMovement(payload) {
      try {
        return await this._doFetch('/api/state/stock-movement', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(payload || {}),
        });
      } catch (err) {
        this._enqueue({ type: 'stockMovement', payload });
        signalReserve();
        return { _offline: true };
      }
    },

    // rows: [{ bomN, pfSku, amount }]
    async reserveForAssembly(id, rows) {
      const rid = API._resolveReqId(id);
      try {
        return await this._doFetch('/api/state/assembly-reserve', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ requestId: rid, rows: rows || [] }),
        });
      } catch (err) {
        if (err && API._permanentReserveFail(err, rid)) {
          _log('reserve.drop', { op: 'assembly-reserve', id: rid, status: err.status });
          return { _dropped: true, status: err.status };
        }
        this._enqueue({ type: 'reserveForAssembly', id: rid, rows });
        signalReserve();
        return { _offline: true, requestId: rid };
      }
    },

    async releaseAssembly(id) {
      const rid = API._resolveReqId(id);
      try {
        return await this._doFetch('/api/state/assembly-release', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ requestId: rid }),
        });
      } catch (err) {
        if (err && API._permanentReserveFail(err, rid)) {
          _log('reserve.drop', { op: 'assembly-release', id: rid, status: err.status });
          return { _dropped: true, status: err.status };
        }
        this._enqueue({ type: 'releaseAssembly', id: rid });
        signalReserve();
        return { _offline: true, requestId: rid };
      }
    },

    // Отгрузка готовых фар на Склад №3 (спринт 2026-06-17). body =
    //   { requestId, productSku, qty, palletSize }.
    // Backend persist'ит Shipment-строку, считает palletCount=ceil(qty/palletSize)
    // и возвращает { id, requestId, productSku, qty, palletSize, palletCount,
    // createdAt, pallets:[{no,total,qty,qrPayload}] }. Оркестрацию мультидвижений
    // (produce ФГ / consume ПФ / release резерва) делает window.shipFinishedToW3
    // через stockMovement — здесь только persist самой отгрузки. На офлайне —
    // очередь (drainQueue ретраит), а pallets фронт генерит локально сам.
    async createShipment(body) {
      try {
        return await this._doFetch('/api/state/shipment', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(body || {}),
        });
      } catch (err) {
        this._enqueue({ type: 'createShipment', body });
        signalReserve();
        return { _offline: true };
      }
    },

    // Закрытие наряда (спринт 2026-06-17). id — requestId; body = { reason?, early? }.
    // Backend проставляет статус заявки (done | closed_early) + пишет AuditLog.
    // reason обязателен на сервере при early-закрытии (фронт уже это проверил).
    async closeRequest(id, body) {
      try {
        return await this._doFetch('/api/state/request/' + encodeURIComponent(id) + '/close', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(body || {}),
        });
      } catch (err) {
        this._enqueue({ type: 'closeRequest', id, body });
        signalReserve();
        return { _offline: true, requestId: id };
      }
    },

    // items: [{ group, lastSku, coldStart }]
    // Backend POST /api/state/equipment upsert-ит по group по одному за раз.
    // Шлём последовательно: на первой неудаче — остаток уходит в очередь,
    // чтобы все процессы атомарно дойдут до сервера при возврате сети.
    async applyLaunchToEquipment(items) {
      const list = Array.isArray(items) ? items : [];
      const applied = [];
      for (let i = 0; i < list.length; i++) {
        const it = list[i];
        try {
          await this._doFetch('/api/state/equipment', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(it || {}),
          });
          applied.push(it && it.group);
        } catch (err) {
          this._enqueue({ type: 'applyLaunchToEquipment', items: list.slice(i) });
          signalReserve();
          return { _offline: true, applied };
        }
      }
      return { ok: true, applied };
    },

    // ───────── Phase 2.5 — taxonomy / specifications / calibration ────────
    // Все мутации идут через manager-guard на backend, audit-log + broadcast.
    // Optimistic-локалка делается в settings.jsx до вызова — сюда уже приходит
    // patch/body для отправки на сервер. На офлайне — _offlineQueue,
    // дренится на следующем onsnapshot.

    async getTaxonomy() {
      try {
        return await this._doFetch('/api/taxonomy', { method: 'GET' });
      } catch (err) { return null; }
    },

    async updateWarehouse(id, patch) {
      try {
        return await this._doFetch('/api/taxonomy/warehouse/' + encodeURIComponent(id), {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(patch || {}),
        });
      } catch (err) {
        return this._writeFail(err, { type: 'updateWarehouse', id, patch }, { id });
      }
    },

    async updateGroup(id, patch) {
      try {
        return await this._doFetch('/api/taxonomy/group/' + encodeURIComponent(id), {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(patch || {}),
        });
      } catch (err) {
        return this._writeFail(err, { type: 'updateGroup', id, patch }, { id });
      }
    },

    async createGroup(body) {
      try {
        return await this._doFetch('/api/taxonomy/group', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(body || {}),
        });
      } catch (err) {
        return this._writeFail(err, { type: 'createGroup', body }, body || {});
      }
    },

    async deleteGroup(id) {
      try {
        return await this._doFetch('/api/taxonomy/group/' + encodeURIComponent(id), {
          method: 'DELETE',
        });
      } catch (err) {
        return this._writeFail(err, { type: 'deleteGroup', id }, { id });
      }
    },

    async updateItem(id, patch) {
      try {
        return await this._doFetch('/api/taxonomy/item/' + encodeURIComponent(id), {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(patch || {}),
        });
      } catch (err) {
        return this._writeFail(err, { type: 'updateItem', id, patch }, { id });
      }
    },

    async createItem(body) {
      try {
        return await this._doFetch('/api/taxonomy/item', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(body || {}),
        });
      } catch (err) {
        return this._writeFail(err, { type: 'createItem', body }, body || {});
      }
    },

    async deleteItem(id) {
      try {
        return await this._doFetch('/api/taxonomy/item/' + encodeURIComponent(id), {
          method: 'DELETE',
        });
      } catch (err) {
        return this._writeFail(err, { type: 'deleteItem', id }, { id });
      }
    },

    // PUT — full replace. Используется SpecificationsTab после каждой
    // мутации дерева (throttle на 800ms в settings.jsx).
    async setSpecification(sku, tree) {
      try {
        return await this._doFetch('/api/taxonomy/specification/' + encodeURIComponent(sku), {
          method: 'PUT',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ tree: tree || [] }),
        });
      } catch (err) {
        return this._writeFail(err, { type: 'setSpecification', sku, tree }, { sku });
      }
    },

    // DELETE — полностью удаляет спецификацию (строку Specification) по sku.
    // Используется кнопкой «Удалить спецификацию» в SpecificationsTab. Backend
    // (taxonomy.ts) возвращает 404, если спеки нет — для нас это успех (нечего
    // удалять). Идемпотентно при дренаже offline-очереди.
    async deleteSpecification(sku) {
      try {
        return await this._doFetch('/api/taxonomy/specification/' + encodeURIComponent(sku), {
          method: 'DELETE',
        });
      } catch (err) {
        return this._writeFail(err, { type: 'deleteSpecification', sku }, { sku });
      }
    },

    // (F046) setSpecificationMeta удалён — мёртвый код: эндпоинт
    // /api/taxonomy/specification/:sku/meta на backend не существует, метод
    // никто не вызывал, а его enqueue-тип не имел case в _dispatch (отбрасывался).
    // Meta спеки держится клиентски через setSpecMeta (data.jsx).

    // Phase 3: подподгруппы (3-й уровень). Backend CRUD — taxonomy.ts; снапшот
    // теперь отдаёт tax.subsubgroups → гидратируется в window.SUBSUBGROUP_OVERRIDES,
    // поэтому созданные папки переживают reload (раньше жили только в памяти).
    subsubgroup: {
      async create(subgroupId, name) {
        try {
          return await API._doFetch('/api/taxonomy/subsubgroup', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ subgroupId, name }),
          });
        } catch (err) {
          return API._writeFail(err, { type: 'subsub.create', subgroupId, name }, { subgroupId, name });
        }
      },
      async rename(id, name) {
        try {
          return await API._doFetch('/api/taxonomy/subsubgroup/' + encodeURIComponent(id), {
            method: 'PATCH',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ name }),
          });
        } catch (err) {
          return API._writeFail(err, { type: 'subsub.rename', id, name }, { id });
        }
      },
      async remove(id) {
        try {
          return await API._doFetch('/api/taxonomy/subsubgroup/' + encodeURIComponent(id), { method: 'DELETE' });
        } catch (err) {
          return API._writeFail(err, { type: 'subsub.remove', id }, { id });
        }
      },
    },

    // Phase 3: Цех — производственные партии и перемещения.
    // Backend в `backend/src/routes/cech.ts` (агент D).
    cech: {
      async listBatches() {
        try {
          return await API._doFetch('/api/cech/batches', { method: 'GET' });
        } catch (err) { return { items: [], _offline: true }; }
      },
      async createBatch(payload) {
        try {
          // _linkKey — клиентский SEAM-маркер (launch_flow.jsx) для реконсиляции
          // requestId при дренаже offline-очереди; backend его не ждёт, поэтому
          // в исходящее тело не кладём. Сам payload в очереди (offline-ветка
          // ниже) сохраняет _linkKey — _drainQueue по нему проставит requestId.
          let outBody = payload || {};
          if (outBody && outBody._linkKey != null) {
            outBody = Object.assign({}, outBody);
            delete outBody._linkKey;
          }
          return await API._doFetch('/api/cech/batches', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(outBody),
          });
        } catch (err) {
          API._enqueue({ type: 'cech.createBatch', payload });
          return { _offline: true };
        }
      },
      async updateBatch(id, patch) {
        try {
          return await API._doFetch('/api/cech/batches/' + encodeURIComponent(id), {
            method: 'PATCH',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(patch || {}),
          });
        } catch (err) {
          // F015: enqueue вместо тихой потери — иначе смена статуса партии при
          // блипе сети не доходит до сервера и не ретраится.
          API._enqueue({ type: 'cech.updateBatch', id, patch });
          return { _offline: true, id };
        }
      },
      async deleteBatch(id) {
        try {
          return await API._doFetch('/api/cech/batches/' + encodeURIComponent(id), {
            method: 'DELETE',
          });
        } catch (err) {
          API._enqueue({ type: 'cech.deleteBatch', id });
          return { _offline: true, id };
        }
      },
      async createTransfer(payload) {
        // rid (фикс ReferenceError, 2026-06-25): cech-transfer'ы НЕ резолвятся
        // через REQUESTS — ключ операции это requestId/batchId самого payload,
        // а не cuid заявки. Раньше код звал _permanentReserveFail(err, rid) с
        // НЕобъявленной rid → на любом сетевом сбое здесь падал ReferenceError
        // ДО _enqueue → перенос партии Цеха молча терялся (не попадал в очередь;
        // один из триггеров «возврат на ТПА при блипе сети»). Берём requestId
        // payload'а (для live-проверки в _permanentReserveFail), иначе batchId.
        const rid = (payload && (payload.requestId != null ? payload.requestId : payload.batchId)) || null;
        try {
          return await API._doFetch('/api/cech/transfers', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(payload || {}),
          });
        } catch (err) {
          // 4xx (400 bad body / 404 batch_not_found / 409 insufficient) — ПЕРМАНЕНТНО:
          // ретрай не поможет, не кладём в очередь (иначе вечный флуд). Только
          // сетевые/5xx ошибки (err.status undefined или >=500) — оффлайн-ретрай.
          if (err && API._permanentReserveFail(err, rid)) {
            _log('cech.transfer.drop', { op: 'create', status: err.status });
            return { _dropped: true, status: err.status };
          }
          API._enqueue({ type: 'cech.createTransfer', payload });
          return { _offline: true };
        }
      },
      async commitTransfer(transferId) {
        // rid — у commit/cancel нет заявки в контексте, только transferId.
        // Объявляем явно (раньше rid была undeclared → ReferenceError на сбое
        // сети ронял метод ДО _enqueue, commit терялся). _permanentReserveFail
        // на transferId: 404 → дроп (transfer схлопнут/локальный), 400/409 →
        // дроп (live-заявки с таким id нет → live=false), 5xx/сеть → ретрай.
        const rid = transferId != null ? transferId : null;
        try {
          return await API._doFetch('/api/cech/transfers/' + encodeURIComponent(transferId) + '/commit', {
            method: 'POST',
          });
        } catch (err) {
          // 404 = transfer не существует (локальный числовой id / уже схлопнут) —
          // ПЕРМАНЕНТНО. Раньше re-enqueue → вечный флуд 404 (наблюдалось: 22
          // зависших commit'а долбили backend бесконечно). Дропаем на 4xx.
          if (err && API._permanentReserveFail(err, rid)) {
            _log('cech.transfer.drop', { op: 'commit', transferId, status: err.status });
            return { _dropped: true, status: err.status };
          }
          API._enqueue({ type: 'cech.commitTransfer', transferId });
          return { _offline: true };
        }
      },
      async cancelTransfer(transferId) {
        // rid — см. commitTransfer (только transferId, заявки в контексте нет).
        const rid = transferId != null ? transferId : null;
        try {
          return await API._doFetch('/api/cech/transfers/' + encodeURIComponent(transferId), {
            method: 'DELETE',
          });
        } catch (err) {
          if (err && API._permanentReserveFail(err, rid)) {
            _log('cech.transfer.drop', { op: 'cancel', transferId, status: err.status });
            return { _dropped: true, status: err.status };
          }
          API._enqueue({ type: 'cech.cancelTransfer', transferId });
          return { _offline: true, transferId };
        }
      },
    },

    // Phase 2.5+: загрузка фото BOM-строки. file — File object из <input type=file>.
    // Возвращает { url, filename, size, originalName, mimetype } | null.
    // Offline-fallback нет — фото имеют смысл только когда сервер доступен.
    async uploadSpecPhoto(file) {
      if (!file) return null;
      const form = new FormData();
      form.append('file', file, file.name);
      const opts = { method: 'POST', body: form };
      if (API_KEY) opts.headers = { 'X-API-Key': API_KEY };
      try {
        // FormData не задаёт Content-Length заранее — отказываемся от
        // AbortController-таймаута, чтобы большие фото на медленной сети
        // не отваливались на 5с. Лимит размера контролирует backend (10МБ).
        const res = await fetch(BASE + '/api/taxonomy/spec-photo', opts);
        if (!res.ok) throw new Error('HTTP ' + res.status);
        const data = await res.json();
        this._markOnline();
        return data;
      } catch (err) {
        if (typeof window.toast === 'function') {
          window.toast('Не удалось загрузить фото: ' + (err.message || err), 'error');
        }
        return null;
      }
    },

    // Удаление фото. filename — то что вернул uploadSpecPhoto.
    async deleteSpecPhoto(filename) {
      if (!filename) return null;
      try {
        return await this._doFetch('/api/taxonomy/spec-photo/' + encodeURIComponent(filename), {
          method: 'DELETE',
        });
      } catch (err) {
        // Не критично: даже если файл остался на диске, он orphan и не отражён
        // в snapshot'е. Сборщик мусора можно дописать позже.
        return null;
      }
    },

    // PUT — full replace. body = { renamed, added, removed }.
    // Используется saveProductsOverlay в data.jsx после любой мутации PRODUCTS
    // (rename/add/remove/duplicate во вкладке Specifications). Offline-очередь
    // схлопывает множественные пуши до одного финального (всегда полный snapshot).
    async setProductsOverlay(overlay) {
      const body = {
        renamed: (overlay && overlay.renamed) || {},
        added: (overlay && overlay.added) || [],
        removed: (overlay && overlay.removed) || [],
      };
      try {
        return await this._doFetch('/api/taxonomy/products-overlay', {
          method: 'PUT',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(body),
        });
      } catch (err) {
        return this._writeFail(err, { type: 'setProductsOverlay', overlay: body }, {});
      }
    },

    // PATCH — merge. body = { templateChangeWaste?, minBatchSizePF? }.
    async updateCalibration(patch) {
      try {
        return await this._doFetch('/api/taxonomy/calibration', {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(patch || {}),
        });
      } catch (err) {
        return this._writeFail(err, { type: 'updateCalibration', patch }, {});
      }
    },

    async getAuditLog(limit, opts) {
      try {
        const params = new URLSearchParams();
        params.set('limit', String(limit || 50));
        if (opts && opts.user)   params.set('user', opts.user);
        if (opts && opts.action) params.set('action', opts.action);
        if (opts && opts.since)  params.set('since', opts.since);
        return await this._doFetch('/api/audit?' + params.toString(), { method: 'GET' });
      } catch (err) {
        return [];
      }
    },

    // Phase 2.6: список pg_dump-архивов. Backend читает host-папку
    // ~/apps/auto_light_rostov/backups/ (bind-mount /app/backups, read-only).
    // Cron на сервере пишет туда ежедневно в 03:00 UTC. Возвращает
    // { latest, files[], totalBytes, count, mounted, dir }.
    async getBackups() {
      try {
        return await this._doFetch('/api/admin/backups', { method: 'GET' });
      } catch (err) {
        return null;
      }
    },

    // Возвращает абсолютный URL для скачивания (для <a href>). API_KEY/Bearer
    // если есть — добавляем в query, иначе ссылка работает только в same-origin
    // через Basic-auth Caddy. Backend требует manager role на скачивание.
    getBackupDownloadUrl(filename) {
      const safe = String(filename || '').replace(/[^a-zA-Z0-9._-]/g, '');
      let url = BASE + '/api/admin/backups/file/' + safe;
      const q = [];
      if (API_KEY) q.push('api_key=' + encodeURIComponent(API_KEY));
      if (q.length) url += '?' + q.join('&');
      return url;
    },

    // Phase 2 / A5: текущий пользователь и его роль. Источник истины — backend
    // /api/whoami (X-Forwarded-User от Caddy basic-auth → manager/viewer; либо
    // X-API-Key → capacitor:manager; либо anonymous). Результат кладётся в
    // window.CURRENT_USER, рассылается 'current-user-changed' для CanWrite-ребуков.
    // При сбое (backend offline или 404) — fallback viewer (безопаснее всего,
    // никаких case-кнопок UI не открываем).
    async currentUser(force) {
      if (!force && window.CURRENT_USER && window.CURRENT_USER._loadedAt
          && (Date.now() - window.CURRENT_USER._loadedAt) < 60_000) {
        return window.CURRENT_USER;
      }
      let result;
      try {
        result = await this._doFetch('/api/whoami', { method: 'GET' });
      } catch (err) {
        // Транзитный сбой whoami (сеть/таймаут). НЕ роняем роль до viewer, если
        // есть живой токен и раньше была реальная роль — держим last-known-good,
        // чтобы кнопки не сереели и записи не 403'или из-за моргания роли.
        // Жёсткий сброс прав делает только _doFetch при неудачном refresh.
        const prev = window.CURRENT_USER;
        const hadRole = prev && (_normRoleKey(prev.role) === 'manager' || _normRoleKey(prev.role) === 'admin');
        const liveToken = window.AUTH && window.AUTH.accessToken;
        if (hadRole && liveToken) {
          prev._loadedAt = Date.now();
          _log('role.keep', { role: prev.role, reason: 'whoami transient fail' });
          return prev;
        }
        result = { user: 'anonymous', role: 'viewer', authenticated: false, _offline: true };
      }
      // Dev-mode на localhost: единственный пользователь — владелец, имеет
      // полный доступ. Если backend ответил anonymous/anonymous (нет auth)
      // или нет связи — поднимаем до manager, чтобы UI был полнофункционален
      // для разработки/демо. На staging роль приходит реальная (aleksandr →
      // manager через Authorization Basic decode), и эта ветка не сработает.
      try {
        const h = window.location && window.location.hostname;
        const isLocal = h === 'localhost' || h === '127.0.0.1' || h === '::1';
        if (isLocal && (!result.role || result.role === 'anonymous' || result.role === 'viewer')) {
          result = { user: 'dev', role: 'manager', authenticated: false, _devmode: true };
        }
      } catch (_) {}
      // STICKY-РОЛЬ (2026-06-16, фикс «опять слетели права»): если whoami вернул
      // ПОНИЖЕНИЕ (viewer/anonymous), но в AUTH.user (из /auth/login, переживает
      // reload в localStorage) — аутентифицированный ВЛАДЕЛЕЦ/менеджер, держим его
      // роль. Роль пользователя не меняется; протух лишь access-token (его лечит
      // refresh-on-401). На LAN-HTTP Secure-cookie рефреша может не работать → без
      // этого роль падала до viewer и кнопки сереели. Backend всё равно гейтит
      // записи, так что оптимизм UI безопасен. (НЕ применяем в dev-mode.)
      try {
        const au = window.AUTH && window.AUTH.user;
        // au.role — это ROLEKEY ('DIRECTOR'/'KEEPER'/…), нормализуем в уровень
        // права ('admin'/'manager'/'viewer'), иначе сравнение никогда не
        // совпадает и sticky-удержание роли мертво (роль падает в viewer).
        const auLevel = au ? _normRoleKey(au.role) : null;
        if (au && (auLevel === 'admin' || auLevel === 'manager') && !result._devmode
            && (!result.role || result.role === 'viewer' || result.role === 'anonymous')) {
          result = { user: au.email || au.name || 'user', role: auLevel, authenticated: true, _sticky: true };
          _log('role.sticky', { role: auLevel, roleKey: au.role, reason: 'whoami downgrade, keep AUTH.user role' });
        }
      } catch (_) {}
      const user = {
        user: result.user || 'anonymous',
        role: result.role || 'viewer',
        authenticated: !!result.authenticated,
        _offline: !!result._offline,
        _devmode: !!result._devmode,
        _loadedAt: Date.now(),
      };
      const prevRole = window.CURRENT_USER && window.CURRENT_USER.role;
      window.CURRENT_USER = user;
      if (prevRole !== user.role) _log('role.resolved', { role: user.role, user: user.user, was: prevRole || null });
      try { window.dispatchEvent(new Event('current-user-changed')); } catch (_) {}
      return user;
    },

    // ───────── Phase 3 — Chat ────────────────────────────────────────────
    // Тонкая обёртка над /api/chat/*. Без offline-очереди: чат — соединение
    // realtime, при отсутствии сети UI просто показывает "не доставлено".
    // Apply-логика (push/merge в локальный store) — в v1/chat.jsx, который
    // слушает 'chat-message' / 'chat-conversation' / 'chat-read' window-events.

    async chatListConversations(userId) {
      try {
        return await this._doFetch('/api/chat/conversations?userId=' + encodeURIComponent(userId || ''), {
          method: 'GET',
        });
      } catch (err) { return { items: [] }; }
    },

    async chatCreateConversation(body) {
      try {
        return await this._doFetch('/api/chat/conversations', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(body || {}),
        });
      } catch (err) { return null; }
    },

    async chatListMessages(conversationId, opts) {
      const params = new URLSearchParams();
      if (opts && opts.before) params.set('before', opts.before);
      if (opts && opts.limit)  params.set('limit', String(opts.limit));
      const qs = params.toString();
      try {
        return await this._doFetch(
          '/api/chat/conversations/' + encodeURIComponent(conversationId) + '/messages'
            + (qs ? '?' + qs : ''),
          { method: 'GET' },
        );
      } catch (err) { return { items: [] }; }
    },

    async chatSendMessage(conversationId, body) {
      try {
        return await this._doFetch(
          '/api/chat/conversations/' + encodeURIComponent(conversationId) + '/messages',
          {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(body || {}),
          },
        );
      } catch (err) {
        return { _offline: true };
      }
    },

    async chatMarkRead(conversationId, userId) {
      try {
        return await this._doFetch(
          '/api/chat/conversations/' + encodeURIComponent(conversationId) + '/read',
          {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ userId }),
          },
        );
      } catch (err) { return { _offline: true }; }
    },

    // A8: one-shot миграция старого localStorage-снапшота в backend.
    // Запускается из connectStateStream после первого 'snapshot' event
    // (когда backend гарантированно жив). Идемпотентно через флаг
    // 'autolight.migrated.v2' в localStorage. Если миграции нечего
    // переносить (нет snapshot или нет заявок) — сразу ставим флаг.
    async _migrateLegacySnapshot() {
      try {
        if (localStorage.getItem('autolight.migrated.v2') === 'true') return null;
        const raw = localStorage.getItem('autolight.reserveState.v1');
        if (!raw) {
          localStorage.setItem('autolight.migrated.v2', 'true');
          return null;
        }
        let snapshot = null;
        try { snapshot = JSON.parse(raw); } catch (_) { return null; }
        if (!snapshot || !Array.isArray(snapshot.requests) || snapshot.requests.length === 0) {
          localStorage.setItem('autolight.migrated.v2', 'true');
          return null;
        }
        const res = await this._doFetch('/api/state/import', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(snapshot),
        });
        if (res && res.ok) {
          localStorage.setItem('autolight.migrated.v2', 'true');
          if (typeof window.toast === 'function') {
            window.toast(
              'Локальные данные перенесены на сервер (' + (res.importedRequests || 0) + ' заявок)',
              'info'
            );
          }
          return res;
        }
      } catch (err) {
        // 403 (viewer без X-API-Key) или сетевая ошибка — не trip flag,
        // повторим при следующем заходе manager'а.
        console.warn('[sync] миграция localStorage не удалась:', err);
      }
      return null;
    },
  };

  // Default CURRENT_USER: viewer до первого ответа /api/whoami. Если фронт
  // отрендерится раньше чем приедет whoami — write-кнопки будут заблокированы.
  // Это безопаснее, чем optimistic manager. Override через DevTools или ?role=
  // query-param поможет в локальной разработке без Caddy.
  (function bootstrapCurrentUser() {
    let initialRole = 'viewer';
    let initialUser = 'anonymous';
    // Dev-mode на localhost: единственный пользователь — владелец, у него
    // полный доступ. Без backend whoami в локальной разработке роль остаётся
    // viewer, и write-кнопки заблокированы — на localhost это лишь мешает
    // тестировать UI. На staging роль приходит из /api/whoami (X-Forwarded-User
    // → manager для aleksandr) и переписывает этот bootstrap.
    try {
      const h = window.location && window.location.hostname;
      if (h === 'localhost' || h === '127.0.0.1' || h === '::1') {
        initialRole = 'manager';
        initialUser = 'dev';
      }
    } catch (_) {}
    try {
      const u = new URL(window.location.href);
      const r = u.searchParams.get('role');
      const usr = u.searchParams.get('user');
      if (r === 'manager' || r === 'admin' || r === 'viewer') initialRole = r;
      if (usr) initialUser = usr;
    } catch (_) {}
    window.CURRENT_USER = {
      user: initialUser,
      role: initialRole,
      authenticated: false,
      _bootstrapped: true,
      _loadedAt: 0,
    };
  })();

  // Когда браузер замечает возврат сети — тянем свежий снимок + сливаем очередь.
  // Раньше был только _drainQueue: пропущенный за время оффлайна broadcast
  // оставлял фронт с устаревшим состоянием до следующего случайного snapshot'а.
  // _resyncNow делает getState→_applyServerSnapshot и сам зовёт _drainQueue.
  try {
    window.addEventListener('online', () => API._resyncNow('online'));
  } catch (_) {}

  // Возврат на вкладку (visible) — ресинк. В фоне браузер троттлит SSE/таймеры,
  // и за время «свернул и забыл» могли прийти broadcast'ы, которые EventSource
  // пропустил/задержал (особенно при half-open туннеле). При показе вкладки
  // подтягиваем авторитетное состояние + дренируем очередь. Дедуп в _resyncNow.
  try {
    document.addEventListener('visibilitychange', () => {
      if (!document.hidden) API._resyncNow('visibilitychange');
    });
  } catch (_) {}

  // ───────── _applyServerSnapshot ──────────────────────────────────────────
  // Зеркало логики _applyReserveSnapshot из v1/data_materials.jsx. Принимает
  // snapshot из A1-сервера ИЛИ локальный shape, нормализует и зеркалит в
  // INVENTORY_FULL.reserved / RESERVATIONS / REQUESTS / W23_BRIDGE.reservedForAssembly
  // / W23_RESERVATIONS. В конце шлёт 'reserve-changed' чтобы UI перерисовался.

  function normalizeInventoryReserves(s) {
    if (!s) return {};
    // Локальный формат: dict {invId → reserved}.
    if (s.inventoryReserved && typeof s.inventoryReserved === 'object' && !Array.isArray(s.inventoryReserved)) {
      return s.inventoryReserved;
    }
    // Сервер: array of {invId, totalReserved} либо {invId, amount}.
    const arr = s.inventoryReserves || s.inventoryReserved || [];
    if (!Array.isArray(arr)) return {};
    const out = {};
    for (const row of arr) {
      if (!row || !row.invId) continue;
      const amt = Number(row.totalReserved != null ? row.totalReserved : row.amount);
      if (!Number.isFinite(amt)) continue;
      out[row.invId] = (out[row.invId] || 0) + amt;
    }
    return out;
  }
  function normalizeAssemblyReserves(s) {
    if (!s) return {};
    if (s.assemblyReserved && typeof s.assemblyReserved === 'object' && !Array.isArray(s.assemblyReserved)) {
      return s.assemblyReserved;
    }
    const arr = s.assemblyReserves || [];
    if (!Array.isArray(arr)) return {};
    const out = {};
    for (const row of arr) {
      if (!row || row.bomN == null) continue;
      const amt = Number(row.total != null ? row.total : row.amount);
      if (!Number.isFinite(amt)) continue;
      out[row.bomN] = (out[row.bomN] || 0) + amt;
    }
    return out;
  }

  // Phase 2.5: применить taxonomy-секцию snapshot'а. Источник истины:
  // window.WAREHOUSES, GROUPS_W1/W2/W3, INVENTORY_FULL (метаданные),
  // SPECIFICATIONS, TEMPLATE_CHANGE_WASTE, MIN_BATCH_SIZE_PF — заменяются
  // на серверные данные. stock + reserved у items сохраняем из локального
  // snapshot'а (operational state Phase 2 их уже отслеживает).
  function _applyTaxonomy(tax) {
    if (!tax || typeof tax !== 'object') return false;

    // Warehouses — мутируем элементы по месту, чтобы getter'ы на массиве
    // (например INVENTORY_FULL[].warehouseId резолверы) не сломались.
    if (Array.isArray(tax.warehouses) && tax.warehouses.length > 0
        && Array.isArray(window.WAREHOUSES)) {
      window.WAREHOUSES.length = 0;
      for (const w of tax.warehouses) {
        window.WAREHOUSES.push({ id: w.id, name: w.name, role: w.role || '' });
      }
    }

    // Groups — split на W1/W2/W3 по warehouseId.
    // Сохраняем локально созданные подгруппы, которые сервер ещё не знает
    // (createGroup в полёте или enqueued offline). Без этого SSE-snapshot
    // wipe'нет свежие пользовательские подгруппы на UI до следующего sync'а.
    if (Array.isArray(tax.groups) && tax.groups.length > 0) {
      const w1 = [], w2 = [], w3 = [];
      const seenIds = new Set();
      for (const g of tax.groups) {
        const dst = g.warehouseId === 1 ? w1 : g.warehouseId === 2 ? w2 : g.warehouseId === 3 ? w3 : null;
        if (!dst) continue;
        dst.push({ id: g.id, name: g.name, abbr: g.abbr, icon: g.icon, color: g.color });
        seenIds.add(g.id);
      }
      const mergeKeepLocal = (target, fresh, wId) => {
        if (!Array.isArray(target)) return;
        // Keep local groups not in server snapshot (только тех warehouse).
        const localExtras = target.filter(g => g && !seenIds.has(g.id));
        target.length = 0;
        target.push.apply(target, fresh);
        target.push.apply(target, localExtras);
      };
      mergeKeepLocal(window.GROUPS_W1, w1, 1);
      mergeKeepLocal(window.GROUPS_W2, w2, 2);
      mergeKeepLocal(window.GROUPS_W3, w3, 3);
    }

    // Subsubgroups (Phase 3) — гидратируем window.SUBSUBGROUP_OVERRIDES из сервера,
    // чтобы созданные через edit-mode папки 3-го уровня переживали reload (раньше
    // жили только в window-памяти). Серверные помечаем _backend + _id (для
    // rename/delete). Локальные ещё-не-синканные (без _id, имя сервер не знает)
    // сохраняем — race с create в полёте, чтобы snapshot их не стёр.
    if (Array.isArray(tax.subsubgroups)) {
      const ov = (window.SUBSUBGROUP_OVERRIDES = window.SUBSUBGROUP_OVERRIDES || {});
      const byGroup = {};
      for (const s of tax.subsubgroups) {
        if (!s || !s.subgroupId || !s.name) continue;
        (byGroup[s.subgroupId] = byGroup[s.subgroupId] || []).push({
          key: s.name, name: s.name, _id: s.id, _slug: s.slug, _backend: true, order: s.order,
        });
      }
      const gids = new Set(Object.keys(byGroup).concat(Object.keys(ov)));
      gids.forEach(gid => {
        const server = byGroup[gid] || [];
        const names = new Set(server.map(x => x.name));
        const pending = (ov[gid] || []).filter(o => o && !o._backend && !names.has(o.key));
        const merged = server.concat(pending);
        if (merged.length) ov[gid] = merged;
        else delete ov[gid];
      });
    }

    // Items — сохраняем stock + reserved из существующих, метаданные
    // заменяем серверными. Если позиция была локальной (не в DB) и сервер
    // про неё не знает — оставляем как есть (фронт сам её залил позже).
    if (Array.isArray(tax.items) && tax.items.length > 0 && Array.isArray(window.INVENTORY_FULL)) {
      const prevById = Object.create(null);
      for (const old of window.INVENTORY_FULL) {
        if (old && old.id) prevById[old.id] = old;
      }
      const next = [];
      const seen = new Set();
      const seenSku = new Set();
      for (const srv of tax.items) {
        if (!srv || !srv.id) continue;
        const prev = prevById[srv.id];
        const stock = prev && typeof prev.stock === 'number' ? prev.stock : 0;
        const reserved = prev && typeof prev.reserved === 'number' ? prev.reserved : 0;
        // КРИТИЧНО (фикс каскада 2026-06-16): переносим _seedStock в новый объект.
        // Без этого каждый снапшот пере-захватывал _seedStock из уже посчитанного
        // stock (= seed+Σdelta) и добавлял delta СНОВА → остаток рос на delta
        // каждый снапшот (10k→20k→…→110k, первая позиция растёт быстрее всех).
        const seedStock = prev && typeof prev._seedStock === 'number' ? prev._seedStock : undefined;
        // Лампы (w1-lamps): серверная позиция НЕ хранит voltClass/bulbType, а
        // «Склад» делит «Лампы» на папки 12В/24В/LED/накаливания именно по
        // voltClass (и выбор лампы при запуске фильтрует по нему). Поэтому при
        // гидрации деривируем их из имени (_deriveLampMeta), сохраняя seed-
        // значение, если оно было. Без этого на сервере папки/выбор пустые. (2026-06-22)
        let _voltClass = srv.voltClass || (prev && prev.voltClass) || null;
        let _bulbType = srv.bulbType || (prev && prev.bulbType) || null;
        if (srv.groupId === 'w1-lamps' && (!_voltClass || !_bulbType)
            && typeof window._deriveLampMeta === 'function') {
          const _lm = window._deriveLampMeta(srv.name);
          _voltClass = _voltClass || _lm.voltClass;
          _bulbType = _bulbType || _lm.bulbType;
        }
        next.push({
          id: srv.id, sku: srv.sku, name: srv.name,
          warehouseId: srv.warehouseId, groupId: srv.groupId,
          unit: srv.unit,
          stock,
          _seedStock: seedStock,
          minStock: srv.minStock,
          reserved,
          material: srv.material || null,
          weight: srv.weight ?? null,
          voltClass: _voltClass,
          bulbType: _bulbType,
          supplier: srv.supplier || null,
          origin: srv.origin || null,
          bomN: srv.bomN ?? null,
          isPrimaryRaw: !!srv.isPrimaryRaw,
          isStub: false,
          isHot: !!(prev && prev.isHot),
          // Phase 2.5+: photos. server null → пусто; иначе массив.
          photos: Array.isArray(srv.photos) ? srv.photos.slice() : null,
          _source: srv.source || null,
          _relatedFG: srv.relatedFG || null,
        });
        seen.add(srv.id);
        if (srv.sku) seenSku.add(srv.sku);
      }
      // Сохраняем те локальные позиции, что сервер пока не знает — фронт
      // только что их создал, серверный snapshot пришёл до обновления.
      // НО: дропаем seed-призраки (_initialDemo с числовым id), чей sku уже
      // есть на сервере под другим id (seed 1701 vs server INV-PUR-CHE-001).
      // Дедуп по sku, не только по id — иначе позиция двоится в списке, а
      // «удаление» призрака бьёт в несуществующий серверный id → 404 → «ничего
      // не происходит». Серверная версия с этим sku уже лежит в next.
      for (const old of window.INVENTORY_FULL) {
        if (!old || !old.id) continue;
        if (seen.has(old.id)) continue;
        if (old.sku && seenSku.has(old.sku)) continue;
        next.push(old);
      }
      window.INVENTORY_FULL.length = 0;
      window.INVENTORY_FULL.push.apply(window.INVENTORY_FULL, next);
    }

    // Products overlay (Phase 2.5++) — пересобираем PRODUCTS из base+overlay
    // ДО применения specifications, чтобы SpecificationsTab сразу увидел
    // новые/переименованные/удалённые модели. Применяем только если в
    // snapshot'е действительно есть запись (updatedAt не null) — иначе
    // (пустая база до первого save) уважаем локальный localStorage-hydration.
    if (tax.productsOverlay && tax.productsOverlay.updatedAt
        && typeof window._applyProductsOverlay === 'function') {
      window._applyProductsOverlay({
        renamed: tax.productsOverlay.renamed || {},
        added: tax.productsOverlay.added || [],
        removed: tax.productsOverlay.removed || [],
      });
      // Локальный cache держим в синхроне со server'ом, чтобы offline-старт
      // на этом устройстве сразу показал актуальные модели.
      try {
        localStorage.setItem('autolight_products_overlay_v1', JSON.stringify({
          renamed: tax.productsOverlay.renamed || {},
          added: tax.productsOverlay.added || [],
          removed: tax.productsOverlay.removed || [],
        }));
      } catch (_) {}
    }

    // Specifications — полная замена tree-части window.SPECIFICATIONS, с
    // сохранением метаданных (name, model, pathLabel, photos, pdfUrl), которые
    // живут только клиентски до тех пор пока агент D не добавит API под них.
    // НЕ затираем ключи, которых нет в snapshot — могут быть локально
    // созданные FG, которые ещё не сохранены. Серверные — заменяем (только tree).
    if (Array.isArray(tax.specifications)) {
      const target = window.SPECIFICATIONS || (window.SPECIFICATIONS = {});
      for (const s of tax.specifications) {
        if (!s || !s.productSku) continue;
        const sku = s.productSku;
        const tree = Array.isArray(s.tree) ? s.tree : [];
        // ГАРД: пустой/битый серверный tree НЕ должен затирать рабочую сид-спеку.
        // Blank-снапшот (tree=[] или не массив) при уже непустом локальном bom
        // (напр. сид Гранты 36 строк) разрушал бы getBomForSku→[]→резерв ноль.
        // Непустой серверный tree по-прежнему заменяет локальный (легитимная
        // правка спеки, Phase 2.5 persist).
        const serverEmpty = !Array.isArray(s.tree) || tree.length === 0;
        const localHasBom = (typeof window.getSpecBom === 'function')
          ? ((window.getSpecBom(sku) || []).length > 0)
          : false;
        if (serverEmpty && localHasBom) continue;
        if (typeof window.setSpecBom === 'function') {
          // setSpecBom сохраняет meta (см. data.jsx). Если snapshot принёс
          // meta-поля (s.name, s.model, ...) — их тоже подхватываем.
          window.setSpecBom(sku, tree);
          if (typeof window.setSpecMeta === 'function') {
            const meta = {};
            if (typeof s.name === 'string')      meta.name = s.name;
            if (typeof s.model === 'string')     meta.model = s.model;
            if (typeof s.pathLabel === 'string') meta.pathLabel = s.pathLabel;
            if (Array.isArray(s.photos))         meta.photos = s.photos;
            if (typeof s.pdfUrl === 'string')    meta.pdfUrl = s.pdfUrl;
            if (Object.keys(meta).length > 0) window.setSpecMeta(sku, meta);
          }
        } else {
          // Fallback на случай, если data.jsx ещё не загружен (порядок script).
          target[sku] = tree;
        }
      }
    }

    // Calibration — мутируем по месту, чтобы getter'ы в computeTemplateChangeWaste
    // (читает напрямую window.TEMPLATE_CHANGE_WASTE) подхватили правки.
    if (tax.calibration && typeof tax.calibration === 'object') {
      const tcw = tax.calibration.templateChangeWaste;
      if (tcw && typeof tcw === 'object' && window.TEMPLATE_CHANGE_WASTE) {
        for (const k of Object.keys(window.TEMPLATE_CHANGE_WASTE)) {
          if (!(k in tcw)) delete window.TEMPLATE_CHANGE_WASTE[k];
        }
        Object.assign(window.TEMPLATE_CHANGE_WASTE, tcw);
      }
      const mb = tax.calibration.minBatchSizePF;
      if (mb && typeof mb === 'object' && window.MIN_BATCH_SIZE_PF) {
        for (const k of Object.keys(window.MIN_BATCH_SIZE_PF)) {
          if (!(k in mb)) delete window.MIN_BATCH_SIZE_PF[k];
        }
        Object.assign(window.MIN_BATCH_SIZE_PF, mb);
      }
      // MaterialWaste — глобальный процент брака/возврата на материал.
      // wasteFactor() в data_materials.jsx читает window.MATERIAL_WASTE по
      // material, getter'ы PROCESSES → wasteRate/recyclableBack тоже отсюда.
      // Мутируем по месту чтобы потребители подхватили без перерегистрации.
      const mw = tax.calibration.materialWaste;
      if (mw && typeof mw === 'object' && window.MATERIAL_WASTE) {
        for (const k of Object.keys(mw)) {
          if (window.MATERIAL_WASTE[k] && typeof mw[k] === 'object') {
            const next = mw[k];
            if (typeof next.wasteRate === 'number') {
              window.MATERIAL_WASTE[k].wasteRate = next.wasteRate;
            }
            if (typeof next.recyclableBack === 'number') {
              window.MATERIAL_WASTE[k].recyclableBack = next.recyclableBack;
            }
          }
        }
      }
      // cechRouting — маршруты ПФ по цеху (+ позже граф правил/бейджи).
      // Редактор «Маршруты цеха» (cech_route_editor.jsx) читает window.CECH_ROUTING.
      // Полная замена объектом из снапшота; localStorage-кэш обновляем тоже.
      const cr = tax.calibration.cechRouting;
      if (cr && typeof cr === 'object') {
        window.CECH_ROUTING = cr;
        try { localStorage.setItem('cech_routing_blob', JSON.stringify(cr)); } catch (_) {}
      }
    }

    // Шлём событие чтобы UI-таблицы перерисовались.
    try { window.dispatchEvent(new Event('taxonomy-changed')); } catch (_) {}
    return true;
  }

  // Резолв человекочитаемого имени cech-партии по pfSku. Нужно для старых
  // партий (созданных до миграции batch_name, name=null). Стратегии:
  //   'BOM-<n>' → bomN <n> → имя из BOM_GRANTA_L
  //   реальный sku → NOMENCLATURE_BY_SKU
  function _resolveCechName(pfSku) {
    if (!pfSku) return null;
    const m = /^BOM-(\d+)$/i.exec(pfSku);
    if (m) {
      const n = parseInt(m[1], 10);
      const bom = window.BOM_GRANTA_L || [];
      const row = bom.find(r => r && r.n === n);
      if (row && row.name) return row.name;
    }
    const bySku = window.NOMENCLATURE_BY_SKU;
    if (bySku && bySku[pfSku] && bySku[pfSku].name) return bySku[pfSku].name;
    const inv = window.INVENTORY_FULL || [];
    const hit = inv.find(i => i && i.sku === pfSku);
    if (hit && hit.name) return hit.name;
    return null;
  }

  function _applyServerSnapshot(s) {
    if (!s || typeof s !== 'object') return;

    // Phase 2.5: применяем taxonomy первой. Settings вкладки слушают
    // taxonomy-changed и reserve-changed (последнее эмитится ниже).
    if (s.taxonomy) _applyTaxonomy(s.taxonomy);

    // REQUESTS — полная замена.
    if (Array.isArray(s.requests) && Array.isArray(window.REQUESTS)) {
      window.REQUESTS.length = 0;
      window.REQUESTS.push.apply(window.REQUESTS, s.requests);
      // Восстанавливаем клиентский флаг наряда-КОРПУСА: backend его не хранит, и он
      // теряется при замене REQUESTS. Наряд с productSKU из пространства ПФ (WIP/PF)
      // = наряд-подсборка. Так детект корпуса (Цех/отгрузка→W2) стабилен после
      // снимка, без зависимости от живого флага (ревью 2026-06-23, #4/#10).
      for (const r of window.REQUESTS) {
        if (r && !r._isSubassemblyOrder && /^(WIP|PF)/i.test(r.productSKU || r.productSku || '')) {
          r._isSubassemblyOrder = true;
        }
      }
      // Переналожить локальный флаг archived (backend мог его не вернуть — до
      // деплоя колонки / для наряда-подсборки) — иначе заархивированная фара
      // «исчезает» при первом же снимке. Якорь — localStorage (_archivedIds).
      _reapplyArchived();
    }

    // RESERVATIONS — полная замена, если сервер прислал детальный дикт.
    if (window.RESERVATIONS && s.reservations && typeof s.reservations === 'object') {
      for (const k of Object.keys(window.RESERVATIONS)) delete window.RESERVATIONS[k];
      Object.assign(window.RESERVATIONS, s.reservations);
    }

    // INVENTORY_FULL.reserved — обнуляем и применяем абсолютные значения.
    const invMap = normalizeInventoryReserves(s);
    if (Array.isArray(window.INVENTORY_FULL)) {
      for (const it of window.INVENTORY_FULL) it.reserved = 0;
      for (const it of window.INVENTORY_FULL) {
        const v = invMap[it.id];
        if (typeof v === 'number' && Number.isFinite(v)) it.reserved = v;
      }
    }

    // Движения склада (StockMovement) → эффективный остаток = посевной + Σ(netDelta).
    // _seedStock фиксируем ОДИН раз (исходный посевной), чтобы переприменение
    // netDelta на каждый snapshot не накапливалось. Все экраны читают it.stock.
    // ЗАКОН «остаток ≥ 0»: эффективный остаток клампится Math.max(0,…) ниже — ниже
    // нуля уйти физически нельзя (из нуля ничего не изготовить; требование Александра).
    const stockArr = Array.isArray(s.stockMovements) ? s.stockMovements : [];
    const deltaMap = {};
    for (const m of stockArr) {
      if (m && m.invId) deltaMap[m.invId] = (deltaMap[m.invId] || 0) + (Number(m.netDelta) || 0);
    }
    window.STOCK_DELTA = deltaMap;
    if (Array.isArray(window.INVENTORY_FULL)) {
      for (const it of window.INVENTORY_FULL) {
        if (typeof it._seedStock !== 'number') {
          it._seedStock = (typeof it.stock === 'number') ? it.stock : 0;
        }
        it.stock = Math.max(0, it._seedStock + (deltaMap[it.id] || 0));
      }
    }

    // W23_BRIDGE.reservedForAssembly — обнуляем и применяем.
    const asmMap = normalizeAssemblyReserves(s);
    if (Array.isArray(window.W23_BRIDGE)) {
      for (const e of window.W23_BRIDGE) if (e) e.reservedForAssembly = 0;
      for (const e of window.W23_BRIDGE) {
        if (!e) continue;
        const v = asmMap[e.bomN];
        if (typeof v === 'number' && Number.isFinite(v)) e.reservedForAssembly = v;
      }
    }

    // W23_RESERVATIONS — полная замена, если есть в snapshot.
    if (window.W23_RESERVATIONS && s.assemblyReservations
        && typeof s.assemblyReservations === 'object') {
      for (const k of Object.keys(window.W23_RESERVATIONS)) delete window.W23_RESERVATIONS[k];
      Object.assign(window.W23_RESERVATIONS, s.assemblyReservations);
    }

    // Phase 3 — Цех. Backend возвращает manufacturingBatches[] и cechTransfers[]
    // (агент D). Свинчиваем их в window.CECH_STATE shape, который потребляет
    // cech.jsx: { items: [{id, name, model, origin, routes, stages, shipments,
    // history, pathLabel}], itemSeq }. Транзитные transfer'ы (status='shipped')
    // идут в item.shipments; committed'ы — в item.history как commit-event.
    if (Array.isArray(s.manufacturingBatches)) {
      // Карта заявка→активная партия Цеха — для каскадного удаления заявки
      // (удалил заявку → спросить и отменить связанную партию, иначе иконки
      // партии остаются висеть). requestId дропается при маппинге в
      // CECH_STATE.items, поэтому строим карту из raw-снапшота.
      // 1 заявка → N PF-партий: карта requestId → МАССИВ партий (раньше
      // перезаписывалась последней, из-за чего каскад удаления заявки сносил
      // только одну партию из N).
      const _bbr = {};
      for (const b of s.manufacturingBatches) {
        // pfSku в записи → cech.jsx::_resolveBackendBatchId матчит локальную
        // партию с backend-cuid по pfSku (стабильнее имени). (Баг «возврат на ТПА»)
        if (b && b.requestId) (_bbr[b.requestId] = _bbr[b.requestId] || []).push({ id: b.id, name: b.name || b.pfSku || null, pfSku: b.pfSku || null, qty: b.qty });
      }
      window._batchByRequestId = _bbr;

      const transfers = Array.isArray(s.cechTransfers) ? s.cechTransfers : [];
      const byBatch = {};
      for (const t of transfers) {
        (byBatch[t.batchId] = byBatch[t.batchId] || []).push(t);
      }
      let maxSeq = 0;
      const items = s.manufacturingBatches.map(b => {
        const ts = byBatch[b.id] || [];
        const shipments = [];
        const history = [];
        let localSeq = 0;
        for (const t of ts) {
          // Backend transfer.id — cuid (строка). Number(cuid)=NaN, поэтому для
          // React-key / локального сравнения синтезируем числовой sid, а РЕАЛЬНЫЙ
          // backend cuid кладём в shipment._serverId — по нему агент CECH шлёт
          // commitTransfer/cancelTransfer (иначе commit/cancel не находят transfer).
          const sid = Number(t.id) || ++localSeq;
          if (sid > maxSeq) maxSeq = sid;
          const serverId = (typeof t.id === 'string' && t.id) ? t.id : null;
          if (t.status === 'shipped') {
            shipments.push({ id: sid, _serverId: serverId, from: t.fromZone, to: t.toZone, qty: t.qty });
            history.push({ kind: 'ship', shipmentId: sid, _serverId: serverId, from: t.fromZone, to: t.toZone, qty: t.qty });
          } else if (t.status === 'committed') {
            history.push({ kind: 'ship', shipmentId: sid, _serverId: serverId, from: t.fromZone, to: t.toZone, qty: t.qty });
            history.push({ kind: 'commit', shipmentId: sid, _serverId: serverId, from: t.fromZone, to: t.toZone, qty: t.qty });
          }
        }
        return {
          id: b.id,
          // Имя: backend name → резолв по pfSku (для старых партий без name,
          // созданных до миграции batch_name). 'BOM-11' → bomN 11 → имя из
          // BOM_GRANTA_L; реальный sku → NOMENCLATURE. Иначе сам pfSku.
          name: b.name || _resolveCechName(b.pfSku) || b.pfSku,
          model: b.model || '',
          origin: b.originZone,
          routes: b.routes || {},
          stages: b.stages || { [b.originZone]: b.qty },
          shipments,
          history,
          pathLabel: b.pathLabel || '',
          // План этапов из спеки (поле route партии). Backend может пока
          // не возвращать — тогда восстанавливаем из routeStr/pathLabel через
          // routeToStages, чтобы Цех не терял подсветку маршрута после reload.
          route: Array.isArray(b.route)
            ? b.route
            : (typeof window.routeToStages === 'function'
                ? window.routeToStages(b.routeStr || b.pathLabel)
                : []),
          routeStr: b.routeStr || null,
          // Для списания при Цех→Склад№2: pfSku/requestId/productKind несём на item.
          pfSku: b.pfSku || null,
          productKind: b.productKind || null,
          _requestId: b.requestId || null,
          _backendBatch: true,
        };
      });
      window.CECH_STATE = window.CECH_STATE || { items: [], itemSeq: 200 };
      // MERGE — НЕ перезатираем локальные items. Race condition: ЛФ только что
      // запушил локально (без _backendBatch), параллельно дёрнул
      // API.cech.createBatch (async). Если SSE snapshot пришёл раньше backend-
      // write — items с сервера будут пустыми, и локальные исчезнут → в Цехе
      // ничего. Поэтому объединяем backend-items + локальные не-_backendBatch.
      // Dedup по id: если id совпал с backend — он уже на сервере, не дублируем.
      // Дедуп по requestId НЕ делаем — одна заявка может породить N PF-партий,
      // и не все могли успеть на backend.
      const serverIds = new Set(items.map(it => it.id));
      const prevItems = Array.isArray(window.CECH_STATE.items) ? window.CECH_STATE.items : [];
      // Удалённая заявка: каскад снёс её backend-партии, но merge сохранял бы
      // локальные split-партии (_backendBatch:false) вечно. _purgedRequestIds
      // помечен в purgeRequestFromCech и переживает race с этим снапшотом.
      const purged = (window._purgedRequestIds instanceof Set) ? window._purgedRequestIds : null;
      const isPurged = (it) => purged && it._requestId && purged.has(it._requestId);
      // Видимость партий Цеха (ИСПРАВЛЕНО 2026-06-16 по ground-truth: предыдущая
      // версия v0.7.4 ошибочно ПОКАЗЫВАЛА null-req → удаление заявки оставляло
      // партии в Цеху). ПРАВИЛЬНО: показываем ТОЛЬКО партии живой заявки.
      //   Запуск (даже самостоятельный с дашборда) СОЗДАЁТ заявку: launch_flow
      //   делает synthReq + API.createRequest и линкует партии к её cuid
      //   (launchReqId). Значит легитимная партия ВСЕГДА имеет requestId живой
      //   заявки → видна. requestId === null бывает ТОЛЬКО когда createRequest
      //   не прошёл (offline/Funnel лежал) — это сирота/брак, её и прячем (как и
      //   сирот удалённых заявок). Так «удалил заявку → Цех очистился» работает.
      const _reqIds = new Set();
      const _extToId = {};
      (Array.isArray(s.requests) ? s.requests : []).forEach(r => {
        if (r && r.id) _reqIds.add(r.id);
        if (r && r.externalId) _reqIds.add(r.externalId);
        // Локальный ORDER-ключ (externalId) → backend-cuid заявки. Локальные
        // партии Цеха несут _requestId = ORDER-ключ (launch_flow), backend —
        // cuid. Без маппинга дедуп local↔backend по заявке не сходился.
        if (r && r.externalId && r.id) _extToId[r.externalId] = r.id;
      });
      const hasLiveReq = (it) => it._requestId != null && _reqIds.has(it._requestId);
      const serverKept = items.filter(it => !isPurged(it) && hasLiveReq(it));

      // ── Дедуп local↔backend (Баг «возврат на ТПА», 2026-06-17) ──────────────
      // КОРЕНЬ: launch_flow пушит ЛОКАЛЬНУЮ оптимистичную партию (_backendBatch
      // =false, локальный id, _requestId=ORDER-ключ) и async создаёт её на
      // backend (cuid, requestId=cuid). Реконсиляция local→cuid терялась
      // (cech.jsx mirror-эффект заменял CECH_STATE.items копиями, осиротив
      // ссылку launch_flow; FG-PF-путь к тому же не диспатчил cech:state-changed).
      // Этот merge тогда СОХРАНЯЛ локальную партию (id≠cuid → !serverIds.has),
      // создавая ДУБЛЬ: backend-тайл (cuid, _bb=true) + локальный (localId,
      // _bb=false). Пользователь тащил локальный → performTransfer не резолвил
      // cuid → createTransfer НЕ звался → перенос не персистил → откат на ТПА.
      // ЛЕЧЕНИЕ: если у локальной партии есть backend-близнец той же заявки
      // (по pfSku/имени), ДРОПАЕМ локальный дубль — backend-тайл авторитетен,
      // и его перетаскивание всегда резолвит cuid → createTransfer. Split-партии
      // финальных зон (есть _parentId) НИКОГДА не дропаем — это легитимные
      // клиентские сущности без backend-близнеца.
      const _mapReq = (rid) => (rid != null && _extToId[rid] != null) ? _extToId[rid] : rid;
      const _twinKeys = new Set();
      items.forEach(it => {
        const rq = _mapReq(it._requestId);
        if (rq == null) return;
        if (it.pfSku) _twinKeys.add(rq + '|sku|' + it.pfSku);
        if (it.name) _twinKeys.add(rq + '|nm|' + it.name);
      });
      const _hasBackendTwin = (it) => {
        if (it._parentId) return false;           // split-партия — не дубль
        if (it._backendBatch) return false;        // уже backend — обрабатывается выше
        const rq = _mapReq(it._requestId);
        if (rq == null) return false;
        if (it.pfSku && _twinKeys.has(rq + '|sku|' + it.pfSku)) return true;
        if (it.name && _twinKeys.has(rq + '|nm|' + it.name)) return true;
        return false;
      };
      const localOnly = prevItems.filter(it =>
        !it._backendBatch && !serverIds.has(it.id) && !isPurged(it) && !_hasBackendTwin(it));
      const _dedupCount = prevItems.filter(it =>
        !it._backendBatch && !serverIds.has(it.id) && !isPurged(it) && _hasBackendTwin(it)).length;
      if (_dedupCount > 0) console.log('[cech] дедуп local-дублей (есть backend-близнец): ' + _dedupCount);
      const _orphanCount = items.length - serverKept.length;
      if (_orphanCount > 0) console.log('[cech] скрыто партий-сирот (без заявки): ' + _orphanCount);
      window.CECH_STATE.items = [...serverKept, ...localOnly];
      window.CECH_STATE.itemSeq = Math.max(window.CECH_STATE.itemSeq || 200, maxSeq + 1);
      try { window.dispatchEvent(new CustomEvent('cech:state-changed', { detail: { source: 'snapshot', server: items.length, local: localOnly.length } })); }
      catch (_) {}
      console.log('[cech] snapshot merge: ' + items.length + ' from server, ' + localOnly.length + ' local-only');
    }

    // Отгрузки на Склад №3 (спринт 2026-06-17). Backend отдаёт s.shipments:[{ id,
    // requestId, productSku, qty, palletSize, palletCount, reason, createdAt }].
    // Полная замена window.SHIPMENTS (источник истины — сервер), затем сверка
    // локального optimistic-буфера (_reconcileShipments в data_materials) —
    // гасим только что созданные отгрузки, уже отражённые на сервере, чтобы
    // getRequestProgress не считал дважды. Если s.shipments нет (старый backend
    // без Shipment-модели) — НЕ трогаем SHIPMENTS (локальный optimistic выживает,
    // чтобы накопитель отгрузки не обнулялся до апгрейда backend).
    if (Array.isArray(s.shipments)) {
      const list = s.shipments.map(sh => ({
        id: sh.id,
        requestId: sh.requestId,
        productSku: sh.productSku || sh.productSKU || null,
        qty: Number(sh.qty) || 0,
        palletSize: Number(sh.palletSize) || 0,
        palletCount: Number(sh.palletCount) || 0,
        reason: sh.reason || null,
        createdAt: sh.createdAt || null,
      }));
      window.SHIPMENTS = list;
      if (typeof window._reconcileShipments === 'function') {
        try { window._reconcileShipments(list); } catch (_) {}
      }
    }

    signalReserve();
  }

  // ───────── EventSource (SSE) ─────────────────────────────────────────────
  // Backend GET /api/state/events. EventSource сам ретраит на disconnect;
  // дополнительно логируем + апдейтим api-status-changed.
  function connectStateStream() {
    if (API._es) return API._es;
    if (typeof EventSource !== 'function') {
      console.warn('[sync] EventSource не поддерживается в этом окружении');
      return null;
    }
    let es;
    try {
      // EventSource не позволяет задать custom-headers (нет X-Api-Key),
      // а cross-origin Capacitor не шлёт cookies. Поэтому в Capacitor /
      // X-API-Key-режиме передаём ключ через query-параметр; backend
      // state-auth plugin признаёт ?api_key= как валидный (см. resolve (B)).
      const sseUrl = API_KEY
        ? BASE + '/api/state/events?api_key=' + encodeURIComponent(API_KEY)
        : BASE + '/api/state/events';
      es = new EventSource(sseUrl);
    } catch (err) {
      console.warn('[sync] не удалось открыть EventSource:', err);
      return null;
    }
    es.addEventListener('open', () => {
      API._lastSseAt = Date.now();
      API._markOnline();
    });
    es.addEventListener('snapshot', (e) => {
      try {
        const data = JSON.parse(e.data);
        API._lastSseAt = Date.now();
        API._markOnline();
        _applyServerSnapshot(data);
        // успешный snapshot — самое время слить накопленную очередь
        API._drainQueue();
        // A8: одноразовая миграция legacy-localStorage. После того как backend
        // гарантированно отвечает (мы получили snapshot), пушим старое
        // состояние одним POST'ом и ставим флаг 'autolight.migrated.v2'.
        if (!API._migrationStarted) {
          API._migrationStarted = true;
          API._migrateLegacySnapshot();
        }
      } catch (err) {
        console.warn('[sync] плохой snapshot-event:', err);
      }
    });
    // Phase 3 — chat-events поверх того же SSE-канала. Backend публикует
    // event: chat.message / chat.read / chat.conversation. Здесь только
    // rebroadcast в window-events; merge в локальный store делает chat.jsx.
    es.addEventListener('chat.message', (e) => {
      try {
        const data = JSON.parse(e.data);
        API._lastSseAt = Date.now();
        API._markOnline();
        window.dispatchEvent(new CustomEvent('chat-message', { detail: data }));
      } catch (err) { console.warn('[sync] плохой chat.message:', err); }
    });
    es.addEventListener('chat.read', (e) => {
      try {
        const data = JSON.parse(e.data);
        API._lastSseAt = Date.now();
        window.dispatchEvent(new CustomEvent('chat-read', { detail: data }));
      } catch (err) { console.warn('[sync] плохой chat.read:', err); }
    });
    es.addEventListener('chat.conversation', (e) => {
      try {
        const data = JSON.parse(e.data);
        API._lastSseAt = Date.now();
        window.dispatchEvent(new CustomEvent('chat-conversation', { detail: data }));
      } catch (err) { console.warn('[sync] плохой chat.conversation:', err); }
    });

    // Phase 3 — Цех. Backend публикует cech:changed на каждую мутацию батча/
    // transfer'а. Здесь только rebroadcast — merge в локальный state делает
    // cech.jsx через подписку на window-event 'cech:state-changed'.
    es.addEventListener('cech:changed', (e) => {
      try {
        const data = JSON.parse(e.data);
        API._lastSseAt = Date.now();
        API._markOnline();
        window.dispatchEvent(new CustomEvent('cech:state-changed', { detail: { source: 'sse', payload: data } }));
      } catch (err) { console.warn('[sync] плохой cech:changed:', err); }
    });

    es.addEventListener('error', () => {
      // EventSource сам реконнектится — мы только статус обновляем
      signalStatus();
      console.warn('[sync] SSE disconnected, EventSource auto-retry…');
    });
    API._es = es;
    API._lastSseAt = Date.now();   // отсчёт сторожа от момента подключения
    _startSseWatchdog();
    return es;
  }

  // ───────── SSE reconnect-watchdog ─────────────────────────────────────────
  // Half-open туннель (Funnel «полужив», инцидент 2026-06-16): TCP открыт, но
  // данные не идут; браузер НЕ получает error → EventSource не делает авто-
  // reconnect → snapshot'ы перестают приходить, состояние молча устаревает
  // (документированный симптом «иногда нужен ручной reload»). Сторож раз в
  // ~15с проверяет, давно ли было последнее SSE-событие; если тишина дольше
  // SSE_STALE_MS — принудительно убивает мёртвый es (даже если он CLOSED —
  // обнуляем _es, чтобы обойти гард `if(API._es)return` в connectStateStream),
  // переподключается и подтягивает свежий снимок через getState().
  const SSE_WATCHDOG_INTERVAL_MS = 15000;
  const SSE_STALE_MS = 40000;
  function _startSseWatchdog() {
    if (API._sseWatchdog) return;
    API._sseWatchdog = setInterval(() => {
      try {
        // Вкладка скрыта — браузер троттлит таймеры/сеть, ложная «тишина».
        // Не реконнектим в фоне; ресинк сделает visibilitychange-хендлер.
        if (typeof document !== 'undefined' && document.hidden) return;
        const last = API._lastSseAt || 0;
        const silent = Date.now() - last;
        const dead = !API._es || API._es.readyState === 2; // 2 = CLOSED
        if (silent <= SSE_STALE_MS && !dead) return;
        _log('sse.watchdog.reconnect', { silentMs: silent, readyState: API._es ? API._es.readyState : null });
        // Закрываем и ОБНУЛЯЕМ _es — иначе гард `if(API._es)return` в
        // connectStateStream не даст пересоздать поток.
        try { if (API._es) API._es.close(); } catch (_) {}
        API._es = null;
        const es = connectStateStream();
        // После реконнекта тянем авторитетный снимок (snapshot мог быть
        // пропущен, пока поток висел half-open).
        if (es) API._resyncNow('sse-watchdog');
      } catch (_) {}
    }, SSE_WATCHDOG_INTERVAL_MS);
  }

  // ───────── Phase 3 — AUTH (login/logout/me) ─────────────────────────────
  // Тонкий клиент поверх /auth/login | /auth/refresh | /auth/logout | /auth/me.
  // accessToken хранится в памяти + localStorage (для быстрого старта; refresh-
  // cookie httpOnly — браузер сам отправит). При 401 на любом chat-fetch — фронт
  // дёрнет AUTH.refresh; если refresh не сработает — AUTH.user сбросится и
  // app.jsx покажет login-модалку.

  const ACCESS_KEY = 'autolight.accessToken';
  const USER_KEY   = 'autolight.authUser';

  const AUTH = {
    accessToken: (function loadToken() {
      try { return localStorage.getItem(ACCESS_KEY) || null; } catch (_) { return null; }
    })(),
    user: (function loadUser() {
      try { const raw = localStorage.getItem(USER_KEY); return raw ? JSON.parse(raw) : null; }
      catch (_) { return null; }
    })(),

    _save() {
      try {
        if (this.accessToken) localStorage.setItem(ACCESS_KEY, this.accessToken);
        else localStorage.removeItem(ACCESS_KEY);
        if (this.user) localStorage.setItem(USER_KEY, JSON.stringify(this.user));
        else localStorage.removeItem(USER_KEY);
      } catch (_) {}
    },

    _signal() {
      try { window.dispatchEvent(new Event('auth-changed')); } catch (_) {}
    },

    async login(email, password) {
      const res = await fetch(BASE + '/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',
        body: JSON.stringify({ email, password }),
      });
      if (!res.ok) {
        let msg = 'HTTP ' + res.status;
        try { const j = await res.json(); if (j && j.error) msg = j.error; } catch (_) {}
        throw new Error(msg);
      }
      const data = await res.json();
      this.accessToken = data.accessToken || null;
      this.user = data.user || null;
      this._save();
      this._signal();
      _log('auth.login', { email: email, role: (this.user && this.user.role) || null });
      return this.user;
    },

    async logout() {
      try {
        await fetch(BASE + '/auth/logout', {
          method: 'POST', credentials: 'include',
        });
      } catch (_) { /* network не критичен — refresh ревокнем в следующий заход */ }
      this.accessToken = null;
      this.user = null;
      this._save();
      this._signal();
    },

    async me() {
      if (!this.accessToken) return null;
      try {
        const res = await fetch(BASE + '/auth/me', {
          headers: { 'Authorization': 'Bearer ' + this.accessToken },
          credentials: 'include',
        });
        if (res.status === 401) {
          // Попробуем refresh, прежде чем сбрасывать
          const refreshed = await this._refresh();
          if (!refreshed) {
            this.accessToken = null;
            this.user = null;
            this._save();
            this._signal();
            return null;
          }
          return await this.me();
        }
        if (!res.ok) return null;
        const data = await res.json();
        // /auth/me возвращает {id, email, name, role, permissions}; мапим
        // в тот же shape что и user в /auth/login.
        this.user = { id: data.id, email: data.email, name: data.name, role: data.role };
        this._save();
        this._signal();
        return this.user;
      } catch (_) { return null; }
    },

    async _refresh() {
      try {
        const res = await fetch(BASE + '/auth/refresh', {
          method: 'POST', credentials: 'include',
        });
        if (!res.ok) return false;
        const data = await res.json();
        if (data && data.accessToken) {
          this.accessToken = data.accessToken;
          this._save();
          return true;
        }
        return false;
      } catch (_) { return false; }
    },
  };

  // ───────── Auth-стабильность: проактивный refresh + re-sync роли ─────────
  // Access-token живёт 15 мин. Раньше при истечении владелец/кладовщик «терял
  // права» — кнопки «Запуск»/«Удалить заявку» переставали работать (CanWrite
  // читает CURRENT_USER.role, а whoami звался только на mount) до перезагрузки
  // страницы. Теперь делаем то же, что reload, но периодически:
  //   • каждые 10 мин (< 15-мин TTL) — тихий refresh токена + re-whoami;
  //   • на auth-changed (login/refresh/logout) — сразу пере-резолвим роль.
  function _startAuthHeartbeat() {
    if (window._authHeartbeat) return;
    window._authHeartbeat = setInterval(async () => {
      try {
        // Гейт по AUTH.user (был ли вход), а не по accessToken: refresh идёт по
        // cookie и работает даже когда in-memory токен истёк/потерян после reload.
        if (!window.AUTH || !window.AUTH.user) return;
        const ok = await window.AUTH._refresh();
        if (ok) { try { await API.currentUser(true); } catch (_) {} }
      } catch (_) {}
    }, 10 * 60 * 1000);
  }
  try {
    window.addEventListener('auth-changed', () => { API.currentUser(true).catch(() => {}); });
  } catch (_) {}
  _startAuthHeartbeat();

  // Экспорт в window — никаких import/export, всё через global (Babel Standalone).
  Object.assign(window, {
    API,
    AUTH,
    connectStateStream,
    _applyServerSnapshot,
    _applyTaxonomy,
  });
})();
