// 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 != 3001, // ходим на http://: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 из . // Возвращает { 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 для скачивания (для ). 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-' → bomN → имя из 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, }); })();