// 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, });