// Extended data: 300 fictional items for противотуманки across Russian auto brands
// Core BOM remains from real PDF; we expand the nomenclature with fictional items

const BOM_GRANTA_L = [
  { n: 1, name: 'Барашек', qty: 1, material: 'POM', weight: 2.5, type: 'internal' },
  { n: 2, name: 'Бленда', qty: 2, material: 'ABS', weight: 0.55, type: 'internal' },
  { n: 3, name: 'Винт 2,9×9,5 крепл. зацепа', qty: 1, material: null, weight: null, type: 'purchased', origin: 'CN' },
  { n: 4, name: 'Винт 3×8 крепл. фиксатора', qty: 1, material: null, weight: null, type: 'purchased', origin: 'CN' },
  { n: 5, name: 'Винт 3×8 п/ш крепл. бленды', qty: 3, material: null, weight: null, type: 'purchased', origin: 'CN' },
  { n: 6, name: 'Винт 4×12 крепл. опор корр.', qty: 3, material: null, weight: null, type: 'purchased', origin: 'CN' },
  { n: 7, name: 'Винт 4×12 п/ш крепл. очков', qty: 4, material: null, weight: null, type: 'purchased', origin: 'CN' },
  { n: 8, name: 'Винт регулировочный (гвоздь)', qty: 2, material: null, weight: null, type: 'purchased', origin: 'CN' },
  { n: 9, name: 'Заглушка корректора', qty: 1, material: 'POM', weight: 7.2, type: 'internal' },
  { n: 10, name: 'Зацеп фиксатора', qty: 1, material: null, weight: 1.2, type: 'purchased', origin: 'CN' },
  { n: 11, name: 'Корпус фары', qty: 1, material: 'PP', weight: 570, type: 'internal' },
  { n: 12, name: 'Крышка пластмассовая', qty: 1, material: 'PP', weight: 18, type: 'internal' },
  { n: 13, name: 'Крышка резиновая (пыльник)', qty: 1, material: 'PVC', weight: 29, type: 'internal' },
  { n: 14, name: 'Ламподержатель серый (S25)', qty: 1, material: null, weight: 18, type: 'purchased', origin: 'CN' },
  { n: 15, name: 'Ламподержатель черный (T20)', qty: 1, material: null, weight: 11, type: 'purchased', origin: 'CN' },
  { n: 16, name: 'Лампа H4 12V', qty: 1, material: null, weight: null, type: 'purchased', origin: 'CN' },
  { n: 17, name: 'Лампа S25 12V Amber', qty: 1, material: null, weight: null, type: 'purchased', origin: 'CN' },
  { n: 18, name: 'Лампа T20', qty: 1, material: null, weight: null, type: 'purchased', origin: 'CN' },
  { n: 19, name: 'Направляющая винта (бабочка)', qty: 1, material: 'POM', weight: 5.8, type: 'internal' },
  { n: 20, name: 'Опора заглушки корректора', qty: 1, material: 'POM', weight: 3.7, type: 'internal' },
  { n: 21, name: 'Опора направляющей винта', qty: 2, material: 'POM', weight: 4.2, type: 'internal' },
  { n: 22, name: 'Отражатель', qty: 1, material: 'PPS', weight: 360, type: 'internal' },
  { n: 23, name: 'Очки напыленные', qty: 1, material: 'PC', weight: 360, type: 'internal' },
  { n: 24, name: 'Пакеты полиэтилен. 50×80', qty: 1, material: null, weight: null, type: 'purchased', origin: 'RU' },
  { n: 25, name: 'Пароотвод (резиновый)', qty: 1, material: null, weight: 2.1, type: 'purchased', origin: 'CN' },
  { n: 26, name: 'Прокладка резиновая', qty: 2, material: null, weight: 0.14, type: 'purchased', origin: 'CN' },
  { n: 27, name: 'Пружинка', qty: 1, material: null, weight: 1.2, type: 'purchased', origin: 'CN' },
  { n: 28, name: 'Рассеиватель лаченый', qty: 1, material: 'PC', weight: 425, type: 'internal' },
  { n: 29, name: 'Стикер самоклеящийся', qty: 1, material: null, weight: null, type: 'purchased', origin: 'RU' },
  { n: 30, name: 'Стикер самокл. С 230', qty: 1, material: null, weight: null, type: 'purchased', origin: 'RU' },
  { n: 31, name: 'Скобы', qty: 5, material: null, weight: null, type: 'purchased', origin: 'CN' },
  { n: 32, name: 'Тешка', qty: 1, material: 'PP', weight: 1, type: 'internal' },
  { n: 33, name: 'Упаковка', qty: 1, material: null, weight: null, type: 'purchased', origin: 'RU' },
  { n: 34, name: 'Фиксатор', qty: 1, material: null, weight: 2.4, type: 'purchased', origin: 'CN' },
  { n: 35, name: 'Шестерня большая', qty: 1, material: 'POM', weight: 1.9, type: 'internal' },
  { n: 36, name: 'Шестерня малая', qty: 1, material: 'POM', weight: 1, type: 'internal' },
];

// SPECIFICATIONS — реестр спецификаций готовых фар по FG SKU.
//
// Форма каждой записи (унифицированная, 2026-06-03):
//   {
//     sku,              // строковой SKU (дубль ключа объекта; нужно для UI)
//     name,             // человекочитаемое название («Блок-фара Гранта L (H4)»)
//     model,            // марка/модель авто («ВАЗ Lada Granta»)
//     pathLabel,        // маршрут по Цеху строкой («ТПА → Лак → Сборка»)
//     bom,              // массив BomRow[] — то, что раньше лежало прямо в SPECIFICATIONS[sku]
//     photos,           // массив { url, filename, originalName, size } (как у BomRow.photos)
//     pdfUrl,           // путь к PDF-спеке (например, эталонный PDF Гранты)
//   }
//
// До 2026-06-03 SPECIFICATIONS[sku] хранил просто BomRow[] — без метаданных.
// Сейчас все консьюмеры обращаются через helper'ы getSpecBom / getSpecMeta /
// upsertSpec, которые принимают и старый, и новый shape. Это позволяет
// безболезненно мигрировать (см. _migrateSpecToObject ниже).
//
// Что НЕ персистится на backend пока что (агент D добавит API в Phase 3):
//   name, model, pathLabel, photos[уровень спецификации, не BomRow], pdfUrl.
// Backend через /api/taxonomy/specification/:sku хранит ТОЛЬКО tree (bom).
// До появления endpoint'ов — метаданные живут только на этом устройстве.
//
// TODO P3 (agent D backend):
//   - PUT /api/taxonomy/specification/:sku принять { tree, meta: { name, model, pathLabel, photos, pdfUrl } }
//   - Snapshot tax.specifications[i] вернуть { productSku, tree, name, model, pathLabel, photos, pdfUrl }
//   - Migration: добавить колонки в Specification table (или JSON-поле metadata)
const SPECIFICATIONS = {};

function _specFromBom_GRANTA_L(sku, side) {
  return {
    sku,
    name: 'Блок-фара Гранта ' + side + ' (с лампой H4)',
    model: 'ВАЗ Lada Granta',
    pathLabel: 'ТПА → Лак → Сборка → ОТК → Упаковка',
    bom: BOM_GRANTA_L.map(r => ({ ...r })),
    photos: [],
    pdfUrl: 'Блок_фара_Гранта_с_лампой_L1.pdf',
  };
}

SPECIFICATIONS['GRN-L-H4'] = _specFromBom_GRANTA_L('GRN-L-H4', 'L');
SPECIFICATIONS['GRN-R-H4'] = _specFromBom_GRANTA_L('GRN-R-H4', 'R');

// ─── Корпус как подсборка (ПФ со своей спекой) ─────────────────────────────
// «Корпус» — это не одна литая деталь, а ПОДСБОРКА (как «фара в сборе»):
// корпус-PP (тело) + механика (шестерни, направляющие, барашек/ручка) +
// закупные метизы/уплотнители. У каждого варианта (G / GFL) — своя спека в
// SPECIFICATIONS, поэтому при запуске корпус РАЗУЗЛОВЫВАЕТСЯ рекурсивно
// (та же логика, что FG-фара: буфер W2 готовых ПФ → резерв W1 сырья →
// нехватка → производство).
//
// Тип строки:
//   internal — изготавливается своими силами (тело-PP, шестерни POM,
//              направляющие POM, барашек/ручка) → разворачивается в Цех + резерв W1;
//   purchased — закупное (винты-«гвозди», прокладки/втулки резиновые) → резерв W1.
// Веса проставлены ТОЛЬКО там, где заданы заказчиком (тело GFL — 680 г,
// отход 5%). Остальные — null (не выдумываем).
//
// SKU держим в пространстве W2-ПФ (WIP-CORPUS-*), чтобы корпус попадал в
// список ПФ (_pfList → settings «Полуфабрикаты» / launch PF-режим).
// Веса (граммы) — из эталонных spec.json фары (узел «Корпус …» + его children:
// «Автосвет Спеки/Блок-фара Гранта Правая (G)» и «…FL Правая (GFL)»). Источник —
// docx/папка заказчика; не выдуманы. Состав (и qty бабочки=2) сверены с
// «Корпус БФ Гранта (спец-я) G.txt» / «Корпус в сборе - Гранта FL GFL.txt».
// (Корпус G/GFL подсборки УДАЛЕНЫ 2026-06-23 — это были ДУБЛИ каноничных
// серверных спек WIP-KRP-GRN-L «Корпус фары G» / WIP-KRP-GFL-L «Корпус в сборе
// GFL», на которые и ссылаются спеки фар. Легаси WIP-CORPUS-G/GFL висели в
// списке ПФ дублями «(подсборка)». Серверную orphan-спеку WIP-CORPUS-G сносим
// через API отдельно. _CORPUS_G/GFL_BOM использовались только тут.)

// ─── Spec helpers ────────────────────────────────────────────────────────
// SPECIFICATIONS[sku] исторически бывает либо BomRow[], либо новый
// объект-спец {sku, name, model, pathLabel, bom, photos, pdfUrl}. Эти
// helper'ы разворачивают любой shape в одно представление, чтобы
// потребители не зависели от формата хранения.
function _isSpecObject(v) {
  return v && typeof v === 'object' && !Array.isArray(v);
}
function getSpecBom(sku) {
  const specs = (typeof window !== 'undefined') ? (window.SPECIFICATIONS || {}) : {};
  const v = specs[sku];
  if (!v) return [];
  if (Array.isArray(v)) return v;
  if (_isSpecObject(v) && Array.isArray(v.bom)) return v.bom;
  return [];
}
// Имя ПФ-спеки: backend хранит спеку как голый tree (без display-имени), и при
// загрузке snapshot'а setSpecBom фолбэчит name=sku для ПФ (их sku нет в PRODUCTS)
// → в списках/редакторе виден артикул (WIP-BAR-GRN-L) вместо «Барашек G». Имя
// есть на складской позиции того же sku (INVENTORY_FULL/NOMENCLATURE — туда оно
// пришло при заведении позиции). Резолвим: stored-имя приоритетно, но если оно
// пустое ИЛИ равно sku — берём имя со склада. (2026-06-22)
function _resolveSpecName(sku, storedName) {
  if (storedName && storedName !== sku) return storedName;
  if (typeof window !== 'undefined' && sku) {
    const pools = [window.INVENTORY_FULL, window.NOMENCLATURE];
    for (const pool of pools) {
      if (!Array.isArray(pool)) continue;
      const hit = pool.find(it => it && it.sku === sku && it.name && it.name !== sku);
      if (hit) return hit.name;
    }
  }
  return storedName || sku;
}
function getSpecMeta(sku) {
  const specs = (typeof window !== 'undefined') ? (window.SPECIFICATIONS || {}) : {};
  const v = specs[sku];
  if (!v) return null;
  if (Array.isArray(v)) return { sku, name: null, model: null, pathLabel: null, photos: [], pdfUrl: null };
  if (_isSpecObject(v)) {
    return {
      sku: v.sku || sku,
      name: _resolveSpecName(v.sku || sku, v.name),
      model: v.model || null,
      pathLabel: v.pathLabel || null,
      photos: Array.isArray(v.photos) ? v.photos : [],
      pdfUrl: v.pdfUrl || null,
    };
  }
  return null;
}
function hasSpec(sku) {
  const specs = (typeof window !== 'undefined') ? (window.SPECIFICATIONS || {}) : {};
  const v = specs[sku];
  if (!v) return false;
  if (Array.isArray(v)) return v.length > 0;
  if (_isSpecObject(v)) return Array.isArray(v.bom);  // объект-спец «есть», даже если BOM пустой
  return false;
}
// Конвертирует старую запись SPECIFICATIONS[sku] (массив) в новый shape
// (объект-спец), сохраняя весь bom. Idempotent — если уже объект, возвращает
// его. Используется в data_server_sync при загрузке snapshot'а от backend
// (который пока шлёт только tree).
function _migrateSpecToObject(sku, fallbackName) {
  const specs = (typeof window !== 'undefined') ? (window.SPECIFICATIONS || (window.SPECIFICATIONS = {})) : {};
  const v = specs[sku];
  if (_isSpecObject(v)) return v;
  const product = (typeof window !== 'undefined' && Array.isArray(window.PRODUCTS))
    ? window.PRODUCTS.find(p => p.sku === sku)
    : null;
  const obj = {
    sku,
    name: fallbackName || (product && product.name) || sku,
    model: product ? (product.family || null) : null,
    pathLabel: null,
    bom: Array.isArray(v) ? v : [],
    photos: [],
    pdfUrl: null,
  };
  specs[sku] = obj;
  return obj;
}
// Заменяет bom-дерево в SPECIFICATIONS[sku], сохраняя метаданные. Создаёт
// объект-спек если его нет. Возвращает финальный объект-спек.
function setSpecBom(sku, tree) {
  const specs = (typeof window !== 'undefined') ? (window.SPECIFICATIONS || (window.SPECIFICATIONS = {})) : {};
  const v = specs[sku];
  if (_isSpecObject(v)) {
    v.bom = Array.isArray(tree) ? tree : [];
    return v;
  }
  // При создании НОВОГО spec-объекта (например при первом приходе snapshot
  // от backend, где tree есть а meta-поля не возвращаются) — берём name из
  // PRODUCTS если SKU там зарегистрирован. Иначе sku как placeholder.
  // Это исправляет ситуацию когда у юзера в Settings → Спецификации
  // название превращалось в артикул после reload страницы.
  const product = (typeof window !== 'undefined' && Array.isArray(window.PRODUCTS))
    ? window.PRODUCTS.find(p => p.sku === sku)
    : null;
  const obj = {
    sku,
    name: product ? product.name : sku,
    model: product ? (product.family || null) : null,
    pathLabel: null,
    bom: Array.isArray(tree) ? tree : [],
    photos: [], pdfUrl: null,
  };
  specs[sku] = obj;
  return obj;
}
// Обновить метаданные спеки (всё кроме bom). Возвращает обновлённый объект.
function setSpecMeta(sku, patch) {
  const specs = (typeof window !== 'undefined') ? (window.SPECIFICATIONS || (window.SPECIFICATIONS = {})) : {};
  let v = specs[sku];
  if (!_isSpecObject(v)) v = _migrateSpecToObject(sku);
  if (!patch || typeof patch !== 'object') return v;
  const ALLOWED = ['name', 'model', 'pathLabel', 'photos', 'pdfUrl'];
  for (const k of ALLOWED) {
    if (k in patch) v[k] = patch[k];
  }
  return v;
}
// Создаёт пустой spec-объект (если ещё не было), либо upsert'ит meta-поля.
function upsertSpec(sku, body) {
  const specs = (typeof window !== 'undefined') ? (window.SPECIFICATIONS || (window.SPECIFICATIONS = {})) : {};
  let v = specs[sku];
  if (!_isSpecObject(v)) {
    v = _migrateSpecToObject(sku, body && body.name);
  }
  if (body) {
    if ('bom' in body && Array.isArray(body.bom)) v.bom = body.bom;
    setSpecMeta(sku, body);
  }
  return v;
}

// ─── PF-спеки (полуфабрикаты-подсборки) как виртуальные W2-позиции ─────────
// SPECIFICATIONS содержит и FG-фары (GRN-*), и ПФ-подсборки (корпус G/GFL,
// и любые WIP-* / WIP-CORPUS-*, заведённые в Settings → Спецификации →
// Полуфабрикаты). Чтобы корпус-подсборка попадала в список ПФ запуска
// (_pfList) и в склад-номенклатуру, не плодя seed в data_nomenclature.jsx,
// строим «виртуальные» W2-позиции из самих PF-спек.
//
// PF-спека = спека, чей sku НЕ зарегистрирован в PRODUCTS (FG). Берём из неё
// материал/вес первой internal-строки (тело подсборки) — для расчёта расхода
// сырья по полю самой позиции, когда у потребителя нет дерева.
function _isFgSku(sku) {
  const prods = (typeof window !== 'undefined' && Array.isArray(window.PRODUCTS)) ? window.PRODUCTS : [];
  return prods.some(p => p && p.sku === sku);
}
function pfSpecVirtualItems() {
  const specs = (typeof window !== 'undefined') ? (window.SPECIFICATIONS || {}) : {};
  const out = [];
  for (const sku of Object.keys(specs)) {
    if (_isFgSku(sku)) continue;          // FG-фара — не ПФ
    const meta = getSpecMeta(sku) || {};
    const bom = getSpecBom(sku) || [];
    // Материал/вес берём из «тела» подсборки — первой internal-строки.
    const body = bom.find(r => r && r.type === 'internal' && r.material) || bom[0] || null;
    out.push({
      id: 'SPEC-' + sku,
      sku,
      name: _resolveSpecName(sku, meta && meta.name),
      warehouseId: 2,
      tier: 'W2',
      groupId: 'w2-corpus',
      unit: 'шт',
      material: body ? (body.material || null) : null,
      weight: body ? (body.weight ?? null) : null,
      _fromSpec: true,
      isSubassembly: true,
    });
  }
  return out;
}

// === FICTIONAL EXPANDED NOMENCLATURE (300 items) ===
// Groups: polymer raw, purchased metal, electronics, packaging, rubber, chemistry, optics, ready parts
const FICTIONAL_NOTE = 'Вымышленные данные (демо)';

const GROUPS = [
  { id: 'polymers',    name: 'Полимеры и сырьё',   icon: 'flask',    color: '#8b5cf6' },
  { id: 'optics',      name: 'Оптика и стёкла',    icon: 'lens',     color: '#f59e0b' },
  { id: 'electronics', name: 'Электроника',        icon: 'chip',     color: '#0ea5e9' },
  { id: 'metal',       name: 'Метизы',             icon: 'screw',    color: '#64748b' },
  { id: 'rubber',      name: 'Резина / уплотн.',   icon: 'oring',    color: '#ec4899' },
  { id: 'chemistry',   name: 'Химия / клеи',       icon: 'drop',     color: '#22c55e' },
  { id: 'packaging',   name: 'Упаковка',           icon: 'package',  color: '#a3a3a3' },
  { id: 'ready',       name: 'Готовые сборки',     icon: 'box',      color: '#1d4ed8' },
];

// Russian brands we supply fog lights (противотуманки) for
const PLATFORMS = ['Гранта', 'Веста', 'Калина', 'Приора', 'Ларгус', 'XRAY', 'UAZ Patriot', 'Газель NN', 'Aurus', 'Спец.'];

// Фаза 2.5 (v1.3, 2026-04-24): makeFictionalCatalog → pass-through.
// Раньше возвращал ~300 вымышленных позиций (300 полимеров/оптики/электроники/
// упаковки/химии/готовых сборок), сгенерированных с Math.random(). При
// переходе на production-режим удалён: единый реестр — NOMENCLATURE из
// data_nomenclature.jsx, реальные позиции заливает пользователь через
// OCR / импорт / редактор. Оставлена пустая функция для обратной
// совместимости вызова в const CATALOG ниже.
function makeFictionalCatalog() { return []; }

// (оригинальное тело makeFictionalCatalog на ~180 строк удалено в
// Фазу 2.5; восстановить можно из git: `git show v1.2:v1/data.jsx`).

// CATALOG — плоский список позиций для dashboard/roles. Собран из
// BOM_GRANTA_L (36 реальных позиций), остатки/цены/поставщики тянутся
// из NOMENCLATURE по bomN. Если в NOMENCLATURE позиции ещё нет
// (например, дополнительная платформа), поля — разумные нули/null.
// Math.random() убран (Фаза 2.5) — каждое перезагружение страницы
// давало разные числа, из-за чего ломался детерминизм демо.
// data_nomenclature.jsx грузится ПОСЛЕ data.jsx (BOM_GRANTA_L → NOMENCLATURE).
// Поэтому onHand/supplier на CATALOG-позиции — геттеры, которые читают
// NOMENCLATURE_BY_BOMN на момент обращения. Math.random() убран
// (Фаза 2.5) — он давал разные числа на каждую перезагрузку, ломая
// детерминизм UI-тестов.
const CATALOG = BOM_GRANTA_L.map(b => {
  const entry = {
    ...b,
    group: b.type === 'internal' ? 'polymers' : (b.origin === 'CN' && b.name.toLowerCase().includes('винт') ? 'metal' : b.origin === 'CN' && /ламп(а|очк)/i.test(b.name) ? 'electronics' : b.origin === 'RU' ? 'packaging' : 'rubber'),
    minStock: 0,
    unit:     'шт',
    price:    null,
    desc:     'Блок-фара Гранта L (реальная BOM)',
    base:     true,
  };
  Object.defineProperty(entry, 'onHand', {
    enumerable: true,
    get() {
      const nom = (window.NOMENCLATURE_BY_BOMN || {})[b.n];
      return nom ? (nom.stock || 0) : 0;
    },
  });
  Object.defineProperty(entry, 'supplier', {
    enumerable: true,
    get() {
      const nom = (window.NOMENCLATURE_BY_BOMN || {})[b.n];
      if (nom && nom.supplier) return nom.supplier;
      return b.origin === 'CN' ? 'Ningbo Lighting' : b.origin === 'RU' ? 'Автосвет' : 'Цех';
    },
  });
  return entry;
});

// Counts per group (for nav)
const GROUP_COUNTS = GROUPS.reduce((acc, g) => {
  acc[g.id] = CATALOG.filter(i => i.group === g.id).length;
  return acc;
}, {});

// Polymers (raw material inventory).
//
// onHand и reserved — геттеры, читающие window.INVENTORY_FULL. Единый
// источник истины — INVENTORY_FULL (пополняется installRawMaterials в
// data_materials.jsx и двигается reserveForRequest). Когда
// INVENTORY_FULL недоступен (ранняя инициализация, тесты) — fallback на
// статические значения ниже.
const POLYMER_SPECS = {
  POM: { fallbackOnHand: 0, unit: 'кг', supplier: null, lead: 21 },
  ABS: { fallbackOnHand: 0, unit: 'кг', supplier: null, lead: 14 },
  PP:  { fallbackOnHand: 0, unit: 'кг', supplier: null, lead: 10 },
  PC:  { fallbackOnHand: 0, unit: 'кг', supplier: null, lead: 30 },
  PPS: { fallbackOnHand: 0, unit: 'кг', supplier: null, lead: 45 },
  PVC: { fallbackOnHand: 0, unit: 'кг', supplier: null, lead: 14 },
};

function _polymerInv(material) {
  const full = (typeof window !== 'undefined') && window.INVENTORY_FULL;
  if (!Array.isArray(full)) return null;
  return full.find(it => it && it.isPrimaryRaw && it.material === material) || null;
}

const INVENTORY = {
  polymers: Object.fromEntries(Object.entries(POLYMER_SPECS).map(([mat, spec]) => {
    const shell = { unit: spec.unit, supplier: spec.supplier, lead: spec.lead };
    Object.defineProperty(shell, 'onHand', {
      enumerable: true,
      get() {
        const inv = _polymerInv(mat);
        return inv && typeof inv.stock === 'number' ? inv.stock : spec.fallbackOnHand;
      },
    });
    Object.defineProperty(shell, 'reserved', {
      enumerable: true,
      get() {
        const inv = _polymerInv(mat);
        return inv && typeof inv.reserved === 'number' ? inv.reserved : 0;
      },
    });
    return [mat, shell];
  })),
  parts: {}, internalParts: {},
};
// Fill parts/internalParts from catalog for BOM check
CATALOG.filter(c => c.base).forEach(c => {
  if (c.type === 'purchased') INVENTORY.parts[c.n] = c.onHand;
  else INVENTORY.internalParts[c.n] = c.onHand;
});

function maxUnitsAvailable(bom, inventory) {
  // Учитываем активные резервы (reserveForRequest пишет в INVENTORY_FULL.reserved;
  // reserveForAssembly — в W23_BRIDGE.reservedForAssembly). Без этого dashboard
  // показывал «можно сделать 500» даже после того, как одна заявка уже
  // зарезервировала сырьё под 500 штук — и пользователь мог создать дубликат.
  const full   = (typeof window !== 'undefined') ? window.INVENTORY_FULL : null;
  const bridge = (typeof window !== 'undefined') ? window.W23_BRIDGE      : null;
  let min = Infinity;
  for (const line of bom) {
    let available;
    if (line.type === 'purchased') {
      const invId = 'INV-BOM-' + String(line.n).padStart(3, '0');
      const inv = Array.isArray(full) ? full.find(it => it && it.id === invId) : null;
      available = inv
        ? Math.max(0, (inv.stock || 0) - (inv.reserved || 0))
        : (inventory.parts[line.n] ?? 0);
    } else {
      // internal: stock с W2 минус то, что уже зарезервировано под сборку.
      // W23_BRIDGE — массив, не дикт; резерв per-entry. Не все 15 internal
      // есть в bridge (только 5: bomN 11,12,22,23,28). Для остальных резерв
      // не отслеживается этим каналом → reserved=0 (верно, пока механики нет).
      const stock = inventory.internalParts[line.n] ?? 0;
      const entry = Array.isArray(bridge) ? bridge.find(e => e && e.bomN === line.n) : null;
      const reserved = (entry && entry.reservedForAssembly) || 0;
      available = Math.max(0, stock - reserved);
    }
    const p = Math.floor(available / line.qty);
    if (p < min) min = p;
  }
  return { min };
}

// Нормализация имени детали для матчинга BOM-строки ↔ позиции склада:
// lower-case, ё→е, всё кроме букв/цифр → пробел, схлопнуть. «Барашек G» и
// «барашек  g» дают один ключ; «Барашек» и «Барашек G» — разные.
function _normPartName(s) {
  return String(s == null ? '' : s)
    .toLowerCase().replace(/ё/g, 'е')
    .replace(/[^a-zа-я0-9]+/gi, ' ').trim();
}

// _findW2Stock(line): остаток РЕАЛЬНОЙ позиции Склада №2 для internal-строки
// BOM. Связь по invId → sku → имени (NOMENCLATURE + INVENTORY_FULL, фильтр W2).
// Возвращает {stock, reserved} или null, если позиция склада не нашлась.
// Why: internalParts[] ключуется по bomN, который УНИКАЛЕН лишь внутри одного
// BOM; у вложенных подсборок n коллизятся («Барашек G» n=2 ↔ «Бленда» n=2),
// поэтому остаток брался не у той позиции. Связь по складу — надёжный ключ и
// заодно даёт реальную синхронизацию «склад → комплектность запуска». (2026-06-16)
// _skuSide(sku): сторона детали из sku — 'L' | 'R' | null. Сторона-специфичные
// ПФ Гранты имеют sku вида WIP-KRP-GRN-R / WIP-BLN-GRN-L, фары — GRN-R-H4 /
// GRN-FL-L. Берём сегмент L/R, окружённый дефисом/концом (буква L в «FL» не
// ловится — перед ней нет дефиса).
function _skuSide(sku) {
  const m = String(sku || '').match(/-([LR])(?=-|$)/);
  return m ? m[1] : null;
}
function _findW2Inv(line, sideHint) {
  if (typeof window === 'undefined' || !line) return null;
  // INVENTORY_FULL первым: у серверных позиций реальный персистный id (нужен
  // backend-резерву); NOMENCLATURE — фолбэк (localhost / каталог).
  const pools = [window.INVENTORY_FULL, window.NOMENCLATURE];
  const isW2 = (it) => Number(it.warehouseId) === 2 || it.tier === 'W2';
  const find = (pred) => {
    for (const pool of pools) {
      if (!Array.isArray(pool)) continue;
      const hit = pool.find(it => it && isW2(it) && pred(it));
      if (hit) return hit;
    }
    return null;
  };
  let hit = null;
  if (line.invId) hit = find(it => it.id === line.invId);
  if (!hit && line.sku) hit = find(it => it.sku === line.sku);
  if (!hit && line.name) {
    const nn = _normPartName(line.name);
    if (nn) {
      // Сторона-aware (2026-06-22): живая спека Гранты резолвит ПФ по ИМЕНИ, а у
      // L/R-вариантов имя одинаковое («Корпус фары G» = WIP-KRP-GRN-L и -R).
      // Когда известна сторона фары (sideHint из targetSku) — берём ПФ той же
      // стороны; иначе/если не нашли — любой одноимённый (back-compat).
      const side = (sideHint === 'L' || sideHint === 'R') ? sideHint : null;
      if (side) hit = find(it => _normPartName(it.name) === nn && _skuSide(it.sku) === side);
      if (!hit) hit = find(it => _normPartName(it.name) === nn);
    }
  }
  return hit;
}
function _findW2Stock(line, sideHint) {
  const hit = _findW2Inv(line, sideHint);
  if (!hit) return null;
  return { stock: Number(hit.stock) || 0, reserved: Number(hit.reserved) || 0 };
}

function checkBOM(bom, inventory, targetUnits) {
  // Закупные позиции (винты, лампы, уплотнители) живут на W1 в INVENTORY_FULL.
  // Резолв инвентаря: сначала по line.invId (custom-спеки из Settings → линкуют
  // реальный inventory), потом fallback на сидовый 'INV-BOM-NNN' по line.n
  // (BOM_GRANTA_L). Внутренние ПФ — счётчик reservedForAssembly в W23_BRIDGE
  // (тоже сидовый под Гранту, для custom-спек его нет → reserved=0).
  const full   = (typeof window !== 'undefined') ? window.INVENTORY_FULL : null;
  const bridge = (typeof window !== 'undefined') ? window.W23_BRIDGE      : null;
  return bom.map(line => {
    // need в ЕДИНИЦЕ строки (синхронно с calcBomReserve):
    //   • закупной расходник по массе (лак, кг/г + weight) → weight(г)×qty×units/1000 кг;
    //   • прочие (шт, мера-расходник «герметик» в мл) → qty×units в своей единице.
    // _dispUnit/_dispPerUnit — для отображения (× <расход> <ед>, нехв. N <ед>).
    const _massConsum = line.type === 'purchased'
      && (line.unit === 'кг' || line.unit === 'г') && line.weight;
    let _dispPerUnit, _dispUnit, need;
    if (_massConsum) {
      _dispPerUnit = (line.weight * (Number(line.qty) || 1)) / 1000;
      _dispUnit = 'кг';
      need = _dispPerUnit * targetUnits;
    } else {
      _dispPerUnit = Number(line.qty) || 0;
      _dispUnit = line.unit || 'шт';
      need = _dispPerUnit * targetUnits;
    }
    let available;
    if (line.type === 'purchased') {
      let inv = null;
      if (line.invId && Array.isArray(full)) {
        inv = full.find(it => it && it.id === line.invId);
      }
      if (!inv && Array.isArray(full)) {
        const fallbackId = 'INV-BOM-' + String(line.n).padStart(3, '0');
        inv = full.find(it => it && it.id === fallbackId);
      }
      if (inv) {
        available = Math.max(0, (inv.stock || 0) - (inv.reserved || 0));
      } else {
        available = inventory.parts[line.n] ?? 0;
      }
    } else {
      // СНАЧАЛА — реальный остаток позиции Склада №2 (синхронизация склад↔запуск).
      const w2 = _findW2Stock(line);
      if (w2) {
        available = Math.max(0, w2.stock - w2.reserved);
      } else {
        // Фолбэк: сидовый счётчик по n (когда позиции склада нет — старое поведение).
        const total = inventory.internalParts[line.n] ?? 0;
        const br = Array.isArray(bridge) ? bridge.find(e => e.bomN === line.n) : null;
        const reserved = br ? (br.reservedForAssembly || 0) : 0;
        available = Math.max(0, total - reserved);
      }
    }
    const ratio = available / need;
    const status = ratio >= 1 ? 'green' : ratio >= 0.5 ? 'yellow' : 'red';
    return { ...line, need, available, status, shortage: Math.max(0, need - available), _dispUnit, _dispPerUnit };
  });
}

// Разворачивает дерево BOM в плоский массив: каждый internal-узел + все его
// children рекурсивно. Используется потребителями (checkBOM, calcBomReserve,
// reserveForAssembly), которые ходят только по верхнему уровню — без flatten
// они бы не увидели позиции, лежащие внутри узлов-ПФ.
function flattenBomTree(tree) {
  if (!Array.isArray(tree)) return [];
  const out = [];
  const walk = (rows) => {
    for (const r of rows) {
      if (!r) continue;
      out.push(r);
      if (Array.isArray(r.children) && r.children.length > 0) walk(r.children);
    }
  };
  walk(tree);
  return out;
}

// Возвращает плоский BOM для FG-SKU. Источники по приоритету:
//   1) getSpecBom(sku) — из window.SPECIFICATIONS (новый объект-спек или старый
//      массив-tree). Спецификация может быть редактирована в Settings.
//   2) BOM_GRANTA_L — эталонный сидовый BOM (PDF заказчика, fallback).
// Без явной спеки для не-Гранты launch_flow раньше использовал BOM_GRANTA_L
// для любой модели — вычисления по неправильному BOM, ложно «всё хватает».
function getBomForSku(sku) {
  const tree = getSpecBom(sku);
  if (Array.isArray(tree) && tree.length > 0) {
    return flattenBomTree(tree);
  }
  // Fallback на хардкод BOM_GRANTA_L УБРАН (2026-06-09): спеки всех моделей уже
  // залиты в SPECIFICATIONS. Раньше fallback подставлял BOM Гранты ЛЮБОЙ модели
  // без спеки → ложные данные («всё хватает» по чужому BOM). Нет спеки → пустой
  // BOM: запуск ничего не зарезервирует и не создаст партий — честнее, чем
  // считать по чужому. (BOM_GRANTA_L остаётся сидом авто-миграции спеки Гранты.)
  return [];
}

// Потребность в полимерах на партию с разделением net/gross.
//   net   — чистая масса BOM (кг): сумма weight × qty × targetUnits / 1000.
//   gross — масса сырья на входе с учётом отхода: net × wasteFactor(material).
// wasteFactor живёт в data_materials.jsx (загружается после data.jsx),
// поэтому резолвим его через window на момент вызова — не на момент
// определения. UI должен использовать .gross как главную цифру «сколько
// нужно», .net — справочно (в подписи или tooltip).
function polymerDemand(bom, targetUnits, overrideMaterialWaste) {
  const wf = (typeof window !== 'undefined' && typeof window.wasteFactor === 'function')
    ? window.wasteFactor
    : () => 1;
  const d = {
    POM: { net: 0, gross: 0 }, ABS: { net: 0, gross: 0 },
    PP:  { net: 0, gross: 0 }, PC:  { net: 0, gross: 0 },
    PPS: { net: 0, gross: 0 }, PVC: { net: 0, gross: 0 },
  };
  for (const line of bom) {
    if (line.type === 'internal' && line.material && line.weight) {
      // Толерантность к ЛЮБОМУ материалу: спеки не-Гранты (C-161, Калина…)
      // имеют PA/PBT/PMMA/TPV/TPS и др., которых нет среди 6 предзаданных
      // полимеров. Без ленивой инициализации d[material] === undefined →
      // `undefined.net` → TypeError → пустой экран при открытии запуска
      // (Гранта не падала: её материалы все в словаре). Лениво заводим запись.
      if (!d[line.material]) d[line.material] = { net: 0, gross: 0 };
      d[line.material].net += (line.weight * line.qty * targetUnits) / 1000;
    }
  }
  for (const m of Object.keys(d)) {
    d[m].gross = d[m].net * wf(m, overrideMaterialWaste);
  }
  return d;
}

// fmtKg(grams) — перевод веса из ГРАММОВ (как хранится в BOM/позициях) в
// КИЛОГРАММЫ для ОТОБРАЖЕНИЯ. Решение 2026-06-09: хранить граммы (расчёт
// расхода делит /1000 сам), показывать кг. До тысячной килограмма, без
// хвостовых нулей: 190 → «0.19», 10 → «0.01», 5 → «0.005», 98 → «0.098».
// Парная функция для ввода — kgToG (умножает на 1000 при сохранении).
function fmtKg(grams) {
  if (grams == null || grams === '') return '';
  const kg = Number(grams) / 1000;
  if (!isFinite(kg)) return '';
  return String(parseFloat(kg.toFixed(3)));
}
// kgToG(kg) — обратно: введённые килограммы → граммы для хранения.
function kgToG(kg) {
  if (kg == null || kg === '') return null;
  const g = Number(kg) * 1000;
  if (!isFinite(g)) return null;
  return Math.round(g * 1000) / 1000;  // до тысячной грамма, без float-хвостов
}

// PRODUCTS — базовый список FG. До production-запуска заказчик подтвердил:
// реальный ассортимент = только Гранта L/R. Раньше здесь жили 7 placeholder'ов
// (Веста L/R, Ларгус L, Калина L, UAZ Patriot L, Газель NN L, Спец. Aurus) —
// убраны из base. C-161-B-00 («161-я фара») приходит с backend через
// productsOverlay.added и появляется в PRODUCTS после _applyProductsOverlay.
const PRODUCTS = [
  { sku: 'GRN-L-H4', name: 'ПТФ Гранта L (H4)', family: 'Гранта', side: 'Лев.', withBulb: true },
  { sku: 'GRN-R-H4', name: 'ПТФ Гранта R (H4)', family: 'Гранта', side: 'Прав.', withBulb: true },
];

// FINISHED, PROCUREMENT, OPERATIONS, QUEUE — раньше держали демо-данные
// для dashboard (произведено по семьям, алерты низкого склада, лента истории,
// очередь нарядов). Очищены при переходе на production-режим — реальные
// значения теперь рассчитываются из state.json (REQUESTS, inventoryReserves,
// audit log на backend) или дозаполняются заказчиком через UI.
const FINISHED = { lenses: [], headlights: [] };

const PROCUREMENT_RAW = [];
const PROCUREMENT = [];

const OPERATIONS = [];

const QUEUE = [];

// === EMPLOYEES / USERS (role hierarchy) ===
// Two owners = SUPERUSER, rest по иерархии
const ROLES_HIERARCHY = [
  { id: 'superuser', name: 'Собственник', level: 100, color: '#7c3aed' },
  { id: 'director',  name: 'Директор',    level: 80,  color: '#dc2626' },
  { id: 'manager',   name: 'Руководитель',level: 60,  color: '#2563eb' },
  { id: 'procurement', name: 'Снабженец', level: 40,  color: '#059669' },
  { id: 'storeman',  name: 'Кладовщик',   level: 30,  color: '#d97706' },
  { id: 'assembler', name: 'Сборщик',     level: 20,  color: '#0891b2' },
  { id: 'viewer',    name: 'Наблюдатель', level: 10,  color: '#64748b' },
];

// EMPLOYEES — список сотрудников. До полноценного онбординга (Settings →
// Сотрудники с persist в backend) держим минимальный seed: два совладельца
// (Александр + Сергей, оба superuser) и кладовщик Андрей. Эти id —
// стабильные, на них завязан Phase 3 chat (Conversation.memberIds).
// id второго совладельца оставлен `u-vladimir` для совместимости с уже
// существующими записями в БД и JWT-сессиями — это лишь внутренний ключ.
//
// 2026-06-02: добавлен seed на 3 человек для теста чата (Phase 3).
// До этого был пустой [], UI чата падал в fallback «добавьте сотрудников».
const EMPLOYEES = [
  {
    id: 'u-aleksandr', name: 'Александр', email: 'aleksandr@autosvet.local',
    phone: '', role: 'superuser', shift: '1', active: true, initials: 'АЛ',
  },
  {
    id: 'u-vladimir', name: 'Сергей', email: 'sergey@autosvet.local',
    phone: '', role: 'superuser', shift: '1', active: true, initials: 'СЕ',
  },
  {
    id: 'u-andrey', name: 'Андрей', email: 'andrey@autosvet.local',
    phone: '', role: 'storeman', shift: '1', active: true, initials: 'АН',
  },
];

// Bootstrap-пользователь: пока EMPLOYEES пуст (заводская установка до первого
// заведённого сотрудника), UI должен с кем-то рендериться — иначе Settings и
// шапка падают на `currentUser.role`. Это синхронный fallback; реальный юзер
// приходит из /api/whoami через window.API.currentUser().
const BOOTSTRAP_USER = {
  id: '_bootstrap', name: 'Администратор', email: '', phone: '',
  role: 'superuser', shift: '1', active: true,
};

// Чат — пусто до подключения company-chat виджета (см. ~/.claude/plans/
// buzzing-tinkering-hoare.md). До 2026-05-02 заводского запуска заказчик
// общается через Telegram / голосовое — внутренний чат подключится позже.
const CHAT_MESSAGES = [];

// ========================================================================
// === FX-FOUNDATION (П1): WAREHOUSES · GROUPS_W1–W3 · INVENTORY_FULL ===
// Базовая модель трёх складов + ~400 SKU (детерминированно, xorshift 42).
// Старые экраны продолжают работать на INVENTORY/GROUPS/CATALOG — эти
// новые экспорты подключаются экранами из промта П2 (FX-1 UI).
// ========================================================================

const WAREHOUSES = [
  { id: 1, name: 'Склад №1 — сырьё' },
  { id: 2, name: 'Склад №2 — полуфабрикаты' },
  { id: 3, name: 'Склад №3 — готовая продукция' },
];

// Склад №1 — 6 подгрупп по фотосхеме заказчика (2026-04-20):
// Пластики, Метизы, Лампы, Электрика, Химия, Упаковка.
// Сленговое «Прочее» более не отображается отдельной подгруппой —
// позиции из него складируются в ближайшую по смыслу (переведены в BOM_GROUP_MAP).
const GROUPS_W1 = [
  { id: 'w1-plastics',  name: 'Пластики',   abbr: 'PLS', icon: 'flask',   color: '#7c3aed' },
  { id: 'w1-metal',     name: 'Метизы',     abbr: 'MTZ', icon: 'screw',   color: '#475569' },
  { id: 'w1-lamps',     name: 'Лампы',      abbr: 'LMP', icon: 'lamp',    color: '#eab308' },
  { id: 'w1-g6',        name: 'Электрика',  abbr: 'ELC', icon: 'chip',    color: '#10b981' },
  { id: 'w1-chemistry', name: 'Химия',      abbr: 'CHM', icon: 'drop',    color: '#0891b2' },
  { id: 'w1-g5',        name: 'Упаковка',   abbr: 'PKG', icon: 'package', color: '#b45309' },
];

// Склад №2 — 4 подгруппы полуфабрикатов (все внутреннего производства):
// Корпуса, Отражатели, Стёкла + Механизмы (барашек, шестерни, направляющие,
// опоры — мелкая моторика на POM).
const GROUPS_W2 = [
  { id: 'w2-corpus',    name: 'Корпуса',     abbr: 'CRP', icon: 'box',       color: '#334155' },
  { id: 'w2-reflector', name: 'Отражатели',  abbr: 'RFL', icon: 'headlight', color: '#94a3b8' },
  { id: 'w2-glass',     name: 'Стёкла',      abbr: 'GLS', icon: 'lens',      color: '#38bdf8' },
  { id: 'w2-mech',      name: 'Механизмы',   abbr: 'MCH', icon: 'gear',      color: '#64748b' },
];

// Склад №3 — готовая продукция: только «Готовые фары». Подгруппа-дубль
// «Стёкла/Рассеиватели как запчасть» (w3-spare-glass/SPG) убрана 2026-06-24
// (Александр): рассеиватель как запчасть показывается в блоке «ПФ как запчасть»
// (Запчасти со Склада №2), а не отдельной пустой подгруппой готовой продукции.
const GROUPS_W3 = [
  { id: 'w3-headlights', name: 'Готовые фары',          abbr: 'HDL', icon: 'headlight', color: '#f59e0b' },
];

// --- Плейсхолдеры для автономных data_*.jsx из волн 2/3.
// Параллельные промты их ТОЛЬКО читают/дополняют из своих файлов,
// сам data.jsx больше не редактируется (матрица конфликтов в плане).
// PROCESSES         — заполняется в v1/data_processes.jsx     (FX-5, П4)
// REQUESTS          — заполняется в v1/data_requests.jsx      (FX-6, П5)
// MARKETPLACE_ORDERS — заполняется в v1/data_marketplace.jsx  (FX-2, П7)
// W23_BRIDGE        — заполняется в v1/data_w23.jsx           (FX-7, П6)

// --- INVENTORY_FULL: реальные позиции W1 (6 сырьевых + 21 закупная).
// Стабы, xorshift32-генератор и STUB_SPEC удалены в Фазу 2.5 (v1.3):
// единый реестр позиций — NOMENCLATURE (data_nomenclature.jsx), новые
// позиции заливает пользователь через UI/OCR/импорт.

// Распределение 21 закупной BOM-позиции по подгруппам Склада №1.
// Склад №1 = ТОЛЬКО закупаемое. Внутренние ПФ (type='internal') живут
// на Складе №2 (см. W2_SUBGROUP_BY_BOM в warehouse_pages.jsx).
//   винт/скоба/фиксатор/пружина → Метизы;
//   лампочка/ламподержатель → Лампы;
//   пакет/стикер/упаковка → Упаковка;
//   пароотвод/прокладка резиновая → Химия (резиновая группа).
const BOM_GROUP_MAP = {
   3:'w1-metal',     4:'w1-metal',     5:'w1-metal',     6:'w1-metal',
   7:'w1-metal',     8:'w1-metal',    10:'w1-metal',
  14:'w1-lamps',    15:'w1-lamps',    16:'w1-lamps',    17:'w1-lamps',
  18:'w1-lamps',
  24:'w1-g5',       29:'w1-g5',       30:'w1-g5',       33:'w1-g5',
  25:'w1-chemistry',26:'w1-chemistry',
  27:'w1-metal',    31:'w1-metal',    34:'w1-metal',
};

// INVENTORY_FULL: реальные позиции W1. Фаза 2.5 (v1.3): убраны 364
// синтетических стуба из STUB_SPEC (теперь служебный массив, но в
// INVENTORY_FULL не попадают) и случайный stock для закупных BOM.
// Остатки тянутся из NOMENCLATURE (data_nomenclature.jsx) — либо через
// геттер при обращении, либо через installRawMaterials (data_materials.jsx),
// который добавляет 6 первичных полимеров. isHot-флаг проставляется
// ровно тем BOM-позициям, которые чаще всего фигурируют в заявках —
// теперь это детерминированный список на основе BOM-номеров (не random).
const HOT_BOMS_W1 = new Set([3, 5, 7, 16, 18, 25, 29, 31, 33]);  // частые позиции
const INVENTORY_FULL = (function buildInventoryFull() {
  const items = [];
  BOM_GRANTA_L.forEach(b => {
    const groupId = BOM_GROUP_MAP[b.n];
    if (!groupId) return;
    const entry = {
      id:        'INV-BOM-' + String(b.n).padStart(3, '0'),
      name:      b.name,
      sku:       'BOM-' + String(b.n).padStart(3, '0'),
      warehouseId: 1,
      groupId:   groupId,
      unit:      'шт',
      minStock:  0,
      weight:    b.weight,
      material:  b.material,
      origin:    b.origin || null,
      bomN:      b.n,
      isStub:    false,
      isHot:     HOT_BOMS_W1.has(b.n),
      reserved:  0,  // двигается reserveForRequest
    };
    // stock — геттер через NOMENCLATURE, чтобы правка остатка в UI
    // редактора «Группы номенклатуры» сразу же отражалась в checkBOM
    // и warehouse-панелях без ручного пересинка.
    Object.defineProperty(entry, 'stock', {
      enumerable: true,
      get() {
        const nom = (window.NOMENCLATURE_BY_BOMN || {})[b.n];
        return nom ? (nom.stock || 0) : 0;
      },
      set(v) {
        const nom = (window.NOMENCLATURE_BY_BOMN || {})[b.n];
        if (nom) nom.stock = v;
      },
    });
    items.push(entry);
  });
  return items;
})();

// PRODUCTS overlay: переименования, пользовательские модели и удаления
// базовых SKU. С Phase 2.5++ (2026-05-26) персистится одновременно в
// backend (ProductsOverlay table, синк через taxonomy snapshot/SSE) и
// localStorage (offline-cache, читается до того как придёт первый snapshot).
// До этого жило ТОЛЬКО в localStorage — из-за чего новые/переименованные
// FG-модели не появлялись на втором устройстве под тем же логином.
const PRODUCTS_OVERLAY_KEY = 'autolight_products_overlay_v1';
const PRODUCTS_BASE_SKUS = ['GRN-L-H4','GRN-R-H4'];
const PRODUCTS_BASE_NAMES = { 'GRN-L-H4':'ПТФ Гранта L (H4)', 'GRN-R-H4':'ПТФ Гранта R (H4)' };
// Замороженная база — нужна, чтобы пересобирать PRODUCTS из {base + overlay}
// при каждом приходе snapshot'а (server wins, локальные удаления базовых
// возвращаются, если их нет в серверном `removed`).
const PRODUCTS_BASE = PRODUCTS.map(p => ({ ...p }));

// Применяет overlay {renamed, added, removed} к PRODUCTS in-place. Все
// потребители держат PRODUCTS по ссылке (через window.PRODUCTS), поэтому
// мутируем длину + push, а не назначаем новый массив.
function _applyProductsOverlay(overlay) {
  const renamed = (overlay && overlay.renamed) || {};
  const removed = new Set((overlay && overlay.removed) || []);
  const added = (overlay && overlay.added) || [];
  const next = [];
  PRODUCTS_BASE.forEach(b => {
    if (removed.has(b.sku)) return;
    const copy = { ...b };
    if (renamed[copy.sku]) copy.name = renamed[copy.sku];
    next.push(copy);
  });
  added.forEach(a => {
    if (a && a.sku && !next.find(p => p.sku === a.sku)) next.push({ ...a });
  });
  PRODUCTS.length = 0;
  PRODUCTS.push.apply(PRODUCTS, next);
}

// Cold-start hydration из localStorage (offline-кэш). Server snapshot,
// когда придёт, перезатрёт через тот же _applyProductsOverlay.
try {
  const raw = localStorage.getItem(PRODUCTS_OVERLAY_KEY);
  if (raw) _applyProductsOverlay(JSON.parse(raw) || {});
} catch (_) { /* corrupt overlay — игнорируем */ }

function _deriveProductsOverlay() {
  const baseSet = new Set(PRODUCTS_BASE_SKUS);
  const renamed = {};
  const added = [];
  const presentBaseSkus = new Set();
  PRODUCTS.forEach(p => {
    if (baseSet.has(p.sku)) {
      presentBaseSkus.add(p.sku);
      if (p.name !== PRODUCTS_BASE_NAMES[p.sku]) renamed[p.sku] = p.name;
    } else {
      added.push(p);
    }
  });
  const removed = PRODUCTS_BASE_SKUS.filter(s => !presentBaseSkus.has(s));
  return { renamed, added, removed };
}

function saveProductsOverlay() {
  const overlay = _deriveProductsOverlay();
  try { localStorage.setItem(PRODUCTS_OVERLAY_KEY, JSON.stringify(overlay)); } catch (_) {}
  // Phase 2.5++: пушим в backend, чтобы второе устройство (тот же логин)
  // увидело новые/переименованные/удалённые модели. API.setProductsOverlay
  // сам имеет offline-fallback (очередь).
  if (window.API && typeof window.API.setProductsOverlay === 'function') {
    window.API.setProductsOverlay(overlay);
  }
}

Object.assign(window, {
  BOM_GRANTA_L, INVENTORY, PRODUCTS, FINISHED, PROCUREMENT, OPERATIONS, QUEUE,
  CATALOG, GROUPS, GROUP_COUNTS, PLATFORMS,
  ROLES_HIERARCHY, EMPLOYEES, BOOTSTRAP_USER, CHAT_MESSAGES, FICTIONAL_NOTE,
  SPECIFICATIONS,
  // Spec helpers (2026-06-03 unified shape; см. шапку SPECIFICATIONS).
  getSpecBom, getSpecMeta, hasSpec, setSpecBom, setSpecMeta, upsertSpec, _resolveSpecName,
  _migrateSpecToObject, _specFromBom_GRANTA_L,
  // PF-спеки как виртуальные W2-позиции (корпус-подсборка G/GFL и пр.).
  pfSpecVirtualItems, _isFgSku,
  maxUnitsAvailable, checkBOM, polymerDemand, saveProductsOverlay,
  _applyProductsOverlay, flattenBomTree, getBomForSku,
  _findW2Inv, _findW2Stock, _skuSide,
  fmtKg, kgToG,
  // --- П1: фундамент трёх складов + 400 SKU ---
  WAREHOUSES, GROUPS_W1, GROUPS_W2, GROUPS_W3, INVENTORY_FULL,
  BOM_GROUP_MAP,
});
