// ============================================================================ // FX-RESERVE: сырьё на Складе №1, формулы отхода по материалу, резерв заявок. // // Сейчас все 36 BOM-internal позиций «живут» на Складе №2 как полуфабрикаты // (корпус, отражатель, бленда и т.д.). Полимерное сырьё, из которого они // отливаются, раньше было только в агрегированном INVENTORY.polymers (6 чисел) // без собственных карточек и без резерва. Этот файл добавляет: // // 1) MATERIAL_WASTE — одна «ставка отхода» на материал (PP, PPS, PC, POM, // ABS, PVC). Можно уточнить после тестов у заказчика. // 2) 6 первичных позиций сырья в w1-plastics (INV-RAW-PP, INV-RAW-PPS, ...) — // с полем reserved, в которое пишется резерв при переходе заявки в MRP. // 3) calcBomReserve(bom, units) — возвращает, сколько единиц каждого ресурса // нужно зарезервировать (W1 сырьё + закупные BOM). // 4) reserveForRequest / releaseForRequest — двигают reserved на позициях // INVENTORY_FULL; ведут RESERVATIONS (кто что зарезервировал). // ============================================================================ // --------------------------------------------------------------------------- // 1) MATERIAL_WASTE — коэффициенты отхода по материалу. // Значения взяты из PROCESSES как stand-in: PP ↔ P-CORPUS-PP и т.д. // PVC не имеет процесса, но есть в BOM («пыльник» — Крышка резиновая) — // задан аналогично POM/ABS. // --------------------------------------------------------------------------- const MATERIAL_WASTE = { PP: { wasteRate: 0.05, recyclableBack: 0.03 }, // из P-CORPUS-PP PC: { wasteRate: 0.08, recyclableBack: 0.04 }, // из P-GLASS-PC PPS: { wasteRate: 0.06, recyclableBack: 0.02 }, // из P-REFLECTOR-PPS POM: { wasteRate: 0.04, recyclableBack: 0.03 }, // из P-PLUG-POM / P-BUTTERFLY-POM ABS: { wasteRate: 0.05, recyclableBack: 0.03 }, // из P-BRACKET-ABS PVC: { wasteRate: 0.05, recyclableBack: 0.02 }, // нет процесса — демо-оценка // AL — литьё алюминия (АК12): литники/прибыли переплавляются (высокий // recyclableBack), безвозвратные потери малы. Без этой записи wasteFactor('AL') // возвращал 1, а главное — не было позиции сырья INV-RAW-AL, и резерв // алюминия для C-161 (и любой фары с AL-литьём) НЕ записывался. (2026-06-18) AL: { wasteRate: 0.10, recyclableBack: 0.08 }, }; // wasteFactor(m, overrideTable) — множитель «gross / net» с учётом возврата: // чтобы получить 1 кг годной массы, нужно подать на вход // 1 / (1 - wasteRate + recyclableBack) кг сырья. Безвозвратные потери = // (wasteRate - recyclableBack), остальное вернётся в следующий прогон. // overrideTable — опционально {: {wasteRate, recyclableBack}} для // per-launch override (Step1 launch_flow позволяет одноразово переписать // без изменения глобального MATERIAL_WASTE). function wasteFactor(material, overrideTable) { const source = (overrideTable && overrideTable[material]) ? overrideTable : MATERIAL_WASTE; const w = source[material]; if (!w) return 1; const netLoss = (w.wasteRate || 0) - (w.recyclableBack || 0); return netLoss >= 1 ? 1 : 1 / (1 - netLoss); } // --------------------------------------------------------------------------- // 1b) Алюминий — ДВА разных расходника, не сводить в один (Александр 2026-06-22): // • Литьевой АК12 — материал ТЕЛА литой детали (корпус/отражатель AL); расход // по массе. Реальная позиция каталога INV-PUR-MISC-603 (группа «Алюминий»). // • Магнетрон — расходник процесса МЕТАЛЛИЗАЦИИ (зеркальное напыление); // тратится на ЛЮБУЮ деталь с этапом «Металлизация»/«ВАК» в маршруте (в т.ч. // ПЛАСТИКОВЫЕ корпуса/отражатели!), по норме 7 г/деталь. Позиция // INV-PUR-MISC-605. // Раньше литьевой AL резолвился в ПРИЗРАК INV-RAW-AL (его НЕТ в каталоге — // дорисовывался фронтом в «Пластики», резерв висел на фантоме), а магнетрон // не считался вовсе. _rawInvIdFor — единый резолвер «материал → invId сырья»; // используется в резерве, расходе и лукапах дашборда/мобайла, иначе резерв // уйдёт на одну позицию, а лукап останется на мёртвом INV-RAW-AL → рассинхрон. const RAW_INVID_OVERRIDE = { AL: 'INV-PUR-MISC-603', // литьевой алюминий — реальная позиция каталога }; function _rawInvIdFor(material) { if (material && RAW_INVID_OVERRIDE[material]) return RAW_INVID_OVERRIDE[material]; return 'INV-RAW-' + material; } const MAGNETRON_INV_ID = 'INV-PUR-MISC-605'; const MAGNETRON_G_PER_PART = 7; // норма AL-магнетрона на 1 металлизируемую деталь function _isMetallizedRoute(route) { return /металлиз|\bвак\b/i.test(String(route || '')); } // Единицы-«меры»: расход считается ПО ВЕЛИЧИНЕ (кг/г/мл/л/т), а не штуками. const MEASURE_RESERVE_UNITS = new Set(['кг', 'г', 'мл', 'л', 'т']); // _classifyPurchasedLine(line) — ЕДИНЫЙ источник истины, как трактовать ЗАКУПНУЮ // строку спеки для резерва И расхода (calcBomReserve / _consumeOneLevel / // shipFinishedToW3). Возвращает { invId, perUnit, unit, kind } или null (пропуск). // • кг/г + weight → ПО МАССЕ: weight(г)×qty/1000 = кг (лак, химия с weight); // • кг/г/мл/л/т → ПО МЕРЕ: qty в своей единице (герметик мл; кг/г/л без weight); // • иначе → ШТУЧНО: qty шт (метизы, лампы, стикеры). // Магнетрон (INV-PUR-MISC-605) — обычная закупная строка (РЕДАКТИРУЕМАЯ в спеке). // Авто-надбавка _addMagnetron срабатывает FALLBACK'ом ТОЛЬКО на металлизируемых // узлах БЕЗ явной строки-605 (см. _hasExplicitMagnetron) — двойного счёта нет // (2026-06-24: магнетрон вынесен в явные строки спек по просьбе Александра). function _classifyPurchasedLine(line) { if (!line) return null; const invId = line.invId || (line.n != null ? ('INV-BOM-' + String(line.n).padStart(3, '0')) : null); if (!invId) return null; const qty = Number(line.qty) || 0; if (qty <= 0) return null; if ((line.unit === 'кг' || line.unit === 'г') && line.weight) { return { invId, perUnit: (line.weight * qty) / 1000, unit: 'кг', kind: 'mass' }; } if (MEASURE_RESERVE_UNITS.has(line.unit)) { return { invId, perUnit: qty, unit: line.unit, kind: 'measure' }; } return { invId, perUnit: qty, unit: 'шт', kind: 'piece' }; } // --------------------------------------------------------------------------- // 2) Первичные позиции сырья в w1-plastics. // Добавляются в INVENTORY_FULL в самое начало группы. id/sku стабильные — // на них ссылается calcBomReserve. Остатки подобраны так, чтобы демо-заявка // «1000 фар Гранта L» наглядно нагружала PC (~90%) и PPS (~60%), а PP/POM/ // ABS оставались со значительным запасом. // --------------------------------------------------------------------------- const RAW_MATERIAL_SPECS = [ { material: 'PP', name: 'PP-гранулят (корпус, крышка, тешка)', stock: 0, minStock: 0, supplier: 'СИБУР', origin: 'RU' }, { material: 'PPS', name: 'PPS GF40 (отражатели)', stock: 0, minStock: 0, supplier: 'Toray JP', origin: 'JP' }, { material: 'PC', name: 'PC Makrolon (рассеиватель, очки)', stock: 0, minStock: 0, supplier: 'Bayer RU', origin: 'RU' }, { material: 'POM', name: 'POM-C (мелкая моторика)', stock: 0, minStock: 0, supplier: 'Полипласт', origin: 'RU' }, { material: 'ABS', name: 'ABS G-25 (бленды)', stock: 0, minStock: 0, supplier: 'СИБУР', origin: 'RU' }, { material: 'PVC', name: 'PVC M-50 (пыльник резиновый)', stock: 0, minStock: 0, supplier: 'РусВинил', origin: 'RU' }, // AL УБРАН из первичного сырья «Пластиков» (2026-06-22, Александр): алюминий — // не сырьё в Пластиках, а ДВЕ реальные позиции каталога в группе «Алюминий» // (литьевой INV-PUR-MISC-603 / магнетрон INV-PUR-MISC-605). Литьевой // резолвится через _rawInvIdFor('AL'); призрак INV-RAW-AL больше НЕ // дорисовывается в «Пластики» (там было 13 плиток вместо 12, резерв висел на // фантоме). MATERIAL_WASTE.AL остаётся — нужен для wasteFactor литья. ]; function buildRawMaterials() { return RAW_MATERIAL_SPECS.map(spec => ({ id: 'INV-RAW-' + spec.material, sku: 'RAW-' + spec.material, name: spec.name, warehouseId: 1, groupId: 'w1-plastics', unit: 'кг', stock: spec.stock, minStock: spec.minStock, reserved: 0, material: spec.material, supplier: spec.supplier, origin: spec.origin, weight: null, isStub: false, isHot: false, isPrimaryRaw: true, // маркер: «первичное сырьё под BOM» — для UI })); } // Расширяем INVENTORY_FULL: первичные позиции идут в начало, чтобы они // оказались первыми в сетке w1-plastics. На всех остальных позициях тоже // заводим reserved:0 — иначе закупные BOM не смогут накапливать резерв. (function installRawMaterials() { const full = window.INVENTORY_FULL; if (!Array.isArray(full)) return; // Идемпотентность: не дублируем, если файл уже подключался. if (full.some(it => it.isPrimaryRaw)) return; const raws = buildRawMaterials(); full.unshift(...raws); for (const it of full) { if (typeof it.reserved !== 'number') it.reserved = 0; } })(); // --------------------------------------------------------------------------- // 3) calcBomReserve(bom, units) // Возвращает массив строк резерва, по одной на каждую позицию INVENTORY_FULL, // которую затрагивает заявка. Агрегирует internal-детали по материалу // (все PP-детали → один ряд PP-сырья). // // Ряды: // { invId, material?, bomN?, name, unit, needNet, needGross, wasteKg } // — needNet: чистая масса годной продукции (кг) — для internal // needGross: ввод сырья с учётом отхода (кг) — для internal // wasteKg: безвозвратные потери (кг) — needGross - needNet // — для purchased (закупные винты/лампы) needGross в штуках, needNet=needGross. // --------------------------------------------------------------------------- function calcBomReserve(bom, units, bufferFactorOrOpts) { if (!Array.isArray(bom) || !units) return []; const n = Number(units) || 0; if (n <= 0) return []; // Совместимость: третий аргумент — число (bufferFactor) либо объект // { bufferFactor, targetSku }. Старые вызовы продолжают работать. const opts = (typeof bufferFactorOrOpts === 'object' && bufferFactorOrOpts !== null) ? bufferFactorOrOpts : { bufferFactor: bufferFactorOrOpts }; // bufferFactor — дополнительный множитель «на брак/резерв заявки»: // 0.10 = +10% сверх needGross/needNet. Применяется симметрично к сырью // и закупным. Для закупных итоговая величина округляется вверх — они // в штуках. Для сырья остаётся дробной и округляется через round3. const buf = Math.max(0, Number(opts.bufferFactor) || 0); const bufMul = 1 + buf; // targetSku — FG, под который идёт заявка. Используется для расчёта // потерь на смену шаблона / холодный пуск (data_equipment.jsx). // Прибавляется к needGross абсолютной величиной — это не процент, // а фиксированный расход на «пробы» при запуске оборудования. const targetSku = opts.targetSku || null; const tmplWaste = (targetSku && typeof window.computeTemplateChangeWaste === 'function') ? window.computeTemplateChangeWaste(targetSku) : {}; // Сторона фары (L/R) из targetSku — чтобы сторона-специфичные ПФ (корпус, // бленда, отражатель) резолвились на Складе №2 ТОЙ ЖЕ стороны (живая спека // линкует ПФ по имени, а у L/R-вариантов оно одинаковое). opts.side имеет // приоритет; иначе берём из targetSku (GRN-R-H4 → 'R'). (2026-06-22) const sideHint = opts.side || (typeof window._skuSide === 'function' ? window._skuSide(targetSku) : null) || null; // stockAware (FIX 4): когда true — НЕ резервировать сырьё W1 под те детали, // ПФ которых уже лежат готовыми на Складе №2 (его не тратим, тратим ПФ). // reserveForRequest/reserveFullSpec зовут со stockAware:true. РАСХОДНЫЙ вызов // в writeoffPfBatch — БЕЗ флага (расход всегда по полному BOM). Default false // → обратная совместимость со всеми прочими вызовами (окно предзаказа и т.п.). const stockAware = !!opts.stockAware; // externalSubs (2026-06-23) — набор sku ПФ-ПОДСБОРОК (корпус), которые при // дефиците производит ОТДЕЛЬНЫЙ наряд (см. launch_flow: «заявка на корпус»). // Такие строки фара ПРОПУСКАЕТ ПОЛНОСТЬЮ (ни claim корпуса на W2, ни сырьё): // резерв входов корпуса (сырьё тела-PP + комплектующие) держит сам наряд- // корпус, иначе на несуществующем корпусе висел бы фантомный reserved>0. // Default null → поведение прежнее (опционально, не ломает вызовы). const externalSubs = (opts.externalSubs instanceof Set) ? opts.externalSubs : (Array.isArray(opts.externalSubs) ? new Set(opts.externalSubs) : null); // pfBatchQty (Н1, спринт 2026-06-17): overproduction по компонентам. // { [bomN]: producedQty } — на 3-м окне запуска можно налить 2000 корпусов // под наряд в 1500. Резерв сырья W1 для КАЖДОГО internal-ПФ считается от его // producedQty (если задан для этого bomN), а не от units заказа. Закупные/ // расходники по-прежнему считаются от units (комплектуются под заказ, не под // задел ПФ). Без pfBatchQty — поведение прежнее (need = qty × units). const pfBatchQty = (opts.pfBatchQty && typeof opts.pfBatchQty === 'object') ? opts.pfBatchQty : null; // perBomUnits(line) — сколько ИЗДЕЛИЙ-эквивалентов резервировать под эту // internal-строку. С overproduction по bomN: producedQty / qtyPerUnit // (producedQty — это ШТУКИ ПФ; делим на расход-на-фару, чтобы остаться в той // же размерности units, что и остальной расчёт). const _bomUnitsFor = (line) => { if (!pfBatchQty || line.n == null) return n; const produced = Number(pfBatchQty[line.n]); if (!Number.isFinite(produced) || produced <= 0) return n; const per = Number(line.qty) || 1; return produced / per; // эффективные «units» под этот ПФ }; const rows = []; const w2PfRows = []; // готовые ПФ со Склада №2 (взяты целиком, НЕ производим) const byMat = {}; // сырьё internal-листьев, агрегировано по материалу const byConsum = {}; // закупные-по-мере (лак=кг, герметик=мл, химия) — { amount, unit }, агрег. по invId const pieceRows = []; // закупные-штучные (метизы/лампы), по строке на позицию let _magnetronG = 0; // AL-магнетрон (металлизация): суммарно грамм на партию const _magnetronParts = []; // что именно металлизируется (для tooltip резерва) // ── РЕКУРСИВНАЯ РАЗУЗЛОВКА ПОДСБОРОК (2026-06-22) ───────────────────────── // Спека фары содержит ПФ-подсборку (корпус G/GFL): в живой базе это узел с // ВЛОЖЕННЫМИ children (корпус n:11 «Корпус фары G» PP-570 + барашек/шестерни/ // винты), либо строка-ссылка по sku на отдельную спеку. На таком узле: // • есть готовый корпус на Складе №2 → берём min(avail,need) ЦЕЛИКОМ (_w2), // его внутренности (барашек/шестерни/винты) НЕ резервируем — они внутри; // • дефицит → разузловываем РЕКУРСИВНО (тело-PP узла в сырьё W1, барашек/ // шестерни — снова «есть на W2 целиком / нет → сырьё», и т.д.); закупные // внутри корпуса (винты) — только на ПРОИЗВОДИМЫЕ корпуса. // ВАЖНО про дубли: flattenBomTree (getBomForSku) НЕ снимает children с узла — // он и оставляет их на узле-корпусе, И дублирует как соседние строки. Поэтому // childSet ниже собирает ССЫЛКИ всех потомков, и верхний проход их ПРОПУСКАЕТ // (они обработаются через рекурсию родителя). Так фикс корректен и на плоском // (getBomForSku), и на вложенном (getSpecBom) входе — вызывающих менять не надо. // Плоская спека без вложенности (не-Гранта) → childSet пуст, рекурсия дремлет // (поведение-сохраняюще). Без stockAware (writeoffPfBatch) всё уходит в сырьё. const _subSpecOf = (line) => { if (!line || line.type !== 'internal') return null; if (Array.isArray(line.children) && line.children.length > 0) return line.children; if (line.sku && typeof window.getSpecBom === 'function') { const sub = window.getSpecBom(line.sku); if (Array.isArray(sub) && sub.length > 0) return sub; } return null; }; // childSet — ссылки всех вложенных потомков во ВХОДНОМ дереве/плоском списке. // Узлы из него на верхнем уровне пропускаются (это дубли, оставленные flatten). const childSet = new Set(); (function collectChildren(rows) { if (!Array.isArray(rows)) return; for (const r of rows) { if (r && Array.isArray(r.children) && r.children.length > 0) { for (const c of r.children) childSet.add(c); collectChildren(r.children); } } })(bom); const _subKey = (line) => line.sku || line.invId || line.name || ('n' + line.n); const _w2Free = (line) => { if (!stockAware || typeof window._findW2Stock !== 'function') return 0; const w2 = window._findW2Stock(line, sideHint); return w2 ? Math.max(0, (Number(w2.stock) || 0) - (Number(w2.reserved) || 0)) : 0; }; const _pushW2 = (line, whole) => { const pfInv = (typeof window._findW2Inv === 'function') ? window._findW2Inv(line, sideHint) : null; w2PfRows.push({ invId: (pfInv && pfInv.id) || line.invId || line.sku || ('INV-WIP-' + (line.n != null ? line.n : 'x')), material: null, bomN: line.n, name: line.name, unit: 'шт', needNet: whole, needGross: whole, wasteKg: 0, bufferFactor: buf, parts: null, _w2: true, // резерв готового ПФ (Склад №2) }); }; const _addRaw = (line, deficit) => { if (!(deficit > 0) || !line.material || !line.weight) return; const netKg = (line.weight * deficit) / 1000; // вес в граммах → кг, на дефицит if (!byMat[line.material]) byMat[line.material] = { netKg: 0, parts: [] }; byMat[line.material].netKg += netKg; byMat[line.material].parts.push({ n: line.n, name: line.name, kg: netKg }); }; // Металлизируется ли деталь: маршрут содержит «Металлизация»/«ВАК». Маршрут // ищем на самом узле (line.route), затем — в спеке узла по sku (resolveItemRoute). const _nodeMetallized = (line) => { if (_isMetallizedRoute(line.route)) return true; if (line.sku && typeof window.resolveItemRoute === 'function') { return _isMetallizedRoute(window.resolveItemRoute({ sku: line.sku, item: line })); } return false; }; // Есть ли у узла ЯВНАЯ строка-магнетрон среди детей/под-спеки? Тогда авто- // надбавку НЕ делаем (её учтёт walk через _classifyPurchasedLine) — без двойного счёта. const _hasExplicitMagnetron = (line) => { const sub = _subSpecOf(line); return Array.isArray(sub) && sub.some(c => c && c.invId === MAGNETRON_INV_ID); }; // Магнетрон-расходник на ПРОИЗВОДИМЫЕ (deficit) металлизируемые детали: // 7 г/деталь. FALLBACK: срабатывает ТОЛЬКО если у узла нет явной строки-605 // (магнетрон теперь редактируется явной строкой в спеке, авто — лишь подстраховка). const _addMagnetron = (line, deficit) => { if (!(deficit > 0) || !_nodeMetallized(line)) return; if (_hasExplicitMagnetron(line)) return; _magnetronG += MAGNETRON_G_PER_PART * deficit; _magnetronParts.push({ n: line.n, name: line.name, kg: (MAGNETRON_G_PER_PART * deficit) / 1000 }); }; // effUnits — изделий-эквивалентов ЭТОГО уровня (корпусов вверху = units фары; // на дефиците корпуса = столько корпусов производим). isTop=true только на // верхнем уровне спеки фары: там действует pfBatchQty (overproduction по bomN) // и закупные комплектуются от ЗАКАЗА n, а не от задела ПФ. seen — против циклов // sku-ссылок. const walk = (bomRows, effUnits, isTop, seen) => { for (const line of bomRows) { if (!line) continue; // Дубль-потомок, оставленный flatten на верхнем уровне → пропускаем // (он обработается рекурсией своего родителя-подсборки). if (isTop && childSet.has(line)) continue; if (line.type === 'internal') { const lvlUnits = isTop ? _bomUnitsFor(line) : effUnits; const need = (Number(line.qty) || 0) * lvlUnits; if (need <= 0) continue; // Вынесенная подсборка (корпус → отдельный наряд): фара claim'ит ТОЛЬКО // уже имеющийся на Складе №2 корпус (реальный сток, не фантом), а ДЕФИЦИТ // производит наряд-корпус — его внутренности (тело-PP + комплектующие) // резервирует ТОТ наряд. min(avail, need): без фантома при stock=0 // (требование Александра), но учитываем частичное наличие корпуса на W2 // (ревью 2026-06-23, #13). Корпус-дефицит приедет от наряда-корпуса. if (externalSubs && line.sku && externalSubs.has(line.sku)) { const _availSub = _w2Free(line); const _claim = Math.min(_availSub, need); if (_claim > 0) _pushW2(line, _claim); continue; } const fromStock = Math.min(_w2Free(line), need); if (fromStock > 0) _pushW2(line, fromStock); const deficit = need - fromStock; // столько ПРОИЗВЕСТИ (разузловать) if (deficit <= 0) continue; // тело самого узла: нест-форма корпуса → PP-570; чистая ссылка → no-op. _addRaw(line, deficit); _addMagnetron(line, deficit); // + AL-магнетрон, если узел металлизируется const sub = _subSpecOf(line); if (sub) { const key = _subKey(line); if (!seen.has(key)) walk(sub, deficit, false, new Set(seen).add(key)); } } else if (line.type === 'purchased') { // Закупные на верхнем уровне комплектуются под ЗАКАЗ (n), внутри // подсборки — под число ПРОИЗВОДИМЫХ подсборок (effUnits=дефицит). const lvlUnits = isTop ? n : effUnits; const cls = _classifyPurchasedLine(line); if (!cls) continue; // магнетрон (учтён через _addMagnetron) или qty<=0 if (cls.kind === 'mass' || cls.kind === 'measure') { // Расходник по мере (лак=кг через weight, герметик=мл, химия) — агрегируем // по invId в СВОЕЙ единице; отхода нет (net=gross). Буфер — в эмиссии. const amount = cls.perUnit * lvlUnits; if (amount > 0) { if (!byConsum[cls.invId]) byConsum[cls.invId] = { amount: 0, unit: cls.unit, name: line.name, bomN: line.n }; byConsum[cls.invId].amount += amount; } } else { const baseQty = cls.perUnit * lvlUnits; if (baseQty > 0) { const qty = Math.ceil(baseQty * bufMul); // штуки округляются вверх pieceRows.push({ invId: cls.invId, material: null, bomN: line.n, name: line.name, unit: 'шт', needNet: qty, needGross: qty, wasteKg: 0, bufferFactor: buf, parts: null, }); } } } } }; walk(bom, n, true, new Set()); // ── Эмиссия: сырьё W1 (с отходом) → закупные-по-массе → закупные-штучные → ПФ W2 ── for (const [material, agg] of Object.entries(byMat)) { const factor = wasteFactor(material); const netWithBuf = agg.netKg * bufMul; const grossBase = netWithBuf * factor; // Накатка / смена шаблона: абсолютные кг поверх gross. const templateExtraKg = (tmplWaste[material] && tmplWaste[material].extraKg) || 0; const grossWithBuf = grossBase + templateExtraKg; rows.push({ invId: _rawInvIdFor(material), material, bomN: null, name: (window.MATERIAL_WASTE && material) || material, unit: 'кг', needNet: round3(netWithBuf), needGross: round3(grossWithBuf), wasteKg: round3(grossWithBuf - netWithBuf), templateExtraKg: round3(templateExtraKg), bufferFactor: buf, parts: agg.parts, }); } // AL-магнетрон (металлизация): один ряд на партию, по норме 7 г/деталь × // число производимых металлизируемых деталей. Отхода нет (net=gross). Буфер // применяется как у сырья (больше деталей под брак → больше напыления). if (_magnetronG > 0) { const netKg = round3((_magnetronG / 1000) * bufMul); rows.push({ invId: MAGNETRON_INV_ID, material: 'AL_M', bomN: null, name: 'AL (для магнетрона)', unit: 'кг', needNet: netKg, needGross: netKg, wasteKg: 0, bufferFactor: buf, parts: _magnetronParts, }); } for (const [invId, agg] of Object.entries(byConsum)) { const withBuf = agg.amount * bufMul; rows.push({ invId, material: null, bomN: agg.bomN, name: agg.name, unit: agg.unit, // кг (лак/химия) либо мл (герметик) — своя мера needNet: round3(withBuf), needGross: round3(withBuf), // у расходников отход не считаем (net=gross) wasteKg: 0, bufferFactor: buf, parts: null, }); } // Закупные штучные — в порядке обхода BOM (как было: по строке на позицию). for (const r of pieceRows) rows.push(r); // Готовые ПФ со Склада №2 (взятые целиком) — отдельной группой, в конце. for (const r of w2PfRows) rows.push(r); return rows; } function round3(x) { return Math.round(x * 1000) / 1000; } // --------------------------------------------------------------------------- // 4) RESERVATIONS + reserveForRequest / releaseForRequest. // Храним по каждой заявке копию того, что она забронировала. Это позволяет // безошибочно отпустить резерв при отмене/выполнении без пересчёта. // Ключ — requestId; значение — массив строк из calcBomReserve плюс ссылки // на найденные позиции INVENTORY_FULL. // --------------------------------------------------------------------------- const RESERVATIONS = {}; function _findInv(invId) { const full = window.INVENTORY_FULL || []; return full.find(it => it.id === invId); } // Сигнал «данные резерва изменились» — чтобы компоненты (ReservedItemsPanel, // WarehouseStripe, ItemPopover) могли подписаться через useReserveTick и // перерисоваться мгновенно, без setInterval-polling. function _signalReserveChange() { try { window.dispatchEvent(new Event('reserve-changed')); } catch (_) {} } // A4: reserveForRequest / releaseForRequest — async wrappers поверх // shared-state API (window.API.*). Pattern: // 1) считаем rows через calcBomReserve (как раньше); // 2) применяем локально (optimistic) → UI мгновенно показывает резерв; // 3) шлём в backend через window.API.reserveForRequest(id, payload); // на успех — SSE-broadcast прилетит и _applyServerSnapshot // перепишет INVENTORY_FULL.reserved абсолютными значениями // с сервера (без двойного учёта, потому что _applyServerSnapshot // сначала обнуляет и потом ставит абсолют); // на ошибку/оффлайн — API.* уже положил op в _offlineQueue, // локальное применение остаётся источником истины до возврата сети. // Идемпотентность: повторный вызов с тем же requestId возвращает Promise. // // reserveForRequest(requestId, bom, units, opts) — opts может быть числом // (старый сигнатура bufferFactor) или объектом { bufferFactor, targetSku } // (calcBomReserve поддерживает оба). function _applyReserveLocally(requestId, rows, units) { const applied = []; for (const row of rows) { // _w2 (готовый ПФ Склада №2) РЕЗЕРВИРУЕМ так же, как W1 — через per-invId // канал (inv.reserved + /reserve). Это надёжно персистит (в отличие от // assembly-reserve, который для кастом-спек по invId не сохранялся). (2026-06-16) const inv = _findInv(row.invId); if (!inv) continue; const amount = row.material ? row.needGross : row.needNet; inv.reserved = (inv.reserved || 0) + amount; applied.push({ invId: inv.id, amount, unit: row.unit }); } RESERVATIONS[requestId] = { rows, applied, units }; _signalReserveChange(); return applied; } function _releaseReserveLocally(requestId) { const rec = RESERVATIONS[requestId]; if (!rec) return false; for (const a of rec.applied) { const inv = _findInv(a.invId); if (!inv) continue; const next = (inv.reserved || 0) - a.amount; inv.reserved = next > 0 ? next : 0; } delete RESERVATIONS[requestId]; _signalReserveChange(); return true; } // payload: преобразование rows из calcBomReserve в формат backend'а. // row.material === null → закупная (шт): amount = needNet // row.material → сырьё (кг): amount = needGross function _rowsToInventoryPayload(rows) { const out = []; for (const row of rows || []) { if (!row || !row.invId) continue; // _w2 (Склад№2 ПФ) ВКЛЮЧАЕМ — резерв per-invId персистит const amount = row.material ? row.needGross : row.needNet; if (!(amount > 0)) continue; out.push({ invId: row.invId, amount, unit: row.unit || 'шт' }); } return out; } async function reserveForRequest(requestId, bom, units, opts) { if (!requestId || RESERVATIONS[requestId]) return false; // FIX 4: резерв заявки учитывает остаток ПФ на W2 (stockAware). Нормализуем // opts (число bufferFactor ИЛИ объект) к объекту и подмешиваем stockAware:true, // не ломая старую number-сигнатуру. Сам W2-резерв ставит reserveForAssembly // (вызывается из reserveFullSpec) — двойного резерва нет, т.к. calcBomReserve // теперь пропускает сырьё под in-stock ПФ. // // ⚠ stockAware ПО УМОЛЧАНИЮ true (сборка ФГ / заявка: готовые ПФ берём со // Склада №2). НО запуск ПФ НА ПРОИЗВОДСТВО обязан передать stockAware:false: // изготавливаемый ПФ нельзя «зарезервировать готовым» со своего же W2 — резерв // должен встать на СЫРЬЁ, из которого он делается (иначе сырья ноль, а Цех всё // равно его производит → рассинхрон). Поэтому base-default НЕ перетирает явный // stockAware из opts — Object.assign кладёт дефолт ПЕРВЫМ. (2026-06-25) const baseOpts = (typeof opts === 'object' && opts !== null) ? opts : { bufferFactor: opts }; const reserveOpts = Object.assign({ stockAware: true }, baseOpts); const rows = calcBomReserve(bom, units, reserveOpts); if (!rows.length) return false; // 1) Optimistic local apply. _applyReserveLocally(requestId, rows, units); // 2) Best-effort server sync. const api = window.API; if (api && typeof api.reserveForRequest === 'function') { const payload = _rowsToInventoryPayload(rows); try { await api.reserveForRequest(requestId, payload); } catch (_) { // API уже положил op в _offlineQueue, локальный стейт уже применён. } } return true; } async function releaseForRequest(requestId) { if (!requestId) return false; const rec = RESERVATIONS[requestId]; // 1) Optimistic local release — ТОЛЬКО если есть локальный rec. if (rec) _releaseReserveLocally(requestId); // 2) Backend wholesale-release (deleteMany по requestId) — ВСЕГДА, даже если // локального rec нет. Корень reserve-leak (ревью-HIGH 2026-06-17, reserve- // аудит интеграции): раньше `if(!rec) return false` обрывал ДО backend-вызова, // и после reload/на другом устройстве (RESERVATIONS пуст, из снапшота он не // восстанавливается) last-ship true-up и closeRequest, идущие через // releaseFullSpec→releaseForRequest, НЕ слали /release → остаток резерва // (последняя треть + лампа + waste) зависал на сервере навсегда. deleteMany // идемпотентен (no-op если по requestId ничего не зарезервировано). const api = window.API; if (api && typeof api.releaseForRequest === 'function') { try { await api.releaseForRequest(requestId); } catch (_) {} } return true; } // Для UI: список заявок, резервирующих конкретную позицию INVENTORY_FULL. function reservationsForInventory(invId) { const out = []; for (const [reqId, rec] of Object.entries(RESERVATIONS)) { const hit = rec.applied.find(a => a.invId === invId); if (!hit) continue; const req = (window.REQUESTS || []).find(r => r.id === reqId); out.push({ requestId: reqId, productName: req ? req.productName : '—', productQty: req ? req.qty : null, amount: hit.amount, unit: hit.unit, status: req ? req.status : '—', }); } return out; } // --- 5) Чтение резерва: срезы для UI. // reservedItemsInGroup(groupId) — позиции подгруппы склада (w1-plastics и т.п.) // с ненулевым резервом, отсортированы по доле резерва (критичные первыми). function reservedItemsInGroup(groupId) { if (!groupId) return []; return (window.INVENTORY_FULL || []) .filter(i => i.groupId === groupId && Number(i.reserved) > 0) .sort((a, b) => { const ra = a.stock > 0 ? (a.reserved / a.stock) : 0; const rb = b.stock > 0 ? (b.reserved / b.stock) : 0; return rb - ra; }); } // shippedFgForSku(sku) — сколько единиц готовой фары отгружено по ОТКРЫТЫМ // нарядам (status != done/cancelled) этой позиции. Источник — getRequestProgress // (Shipment-записи бэкенда). Гаснет при закрытии наряда. Для синего бейджа «↑N» // на готовой фаре Склада №3 + каскада на папки. (Александр 2026-06-18) function shippedFgForSku(sku) { if (!sku || typeof window.getRequestProgress !== 'function') return 0; let s = 0; for (const r of (window.REQUESTS || [])) { if (!r || r.status === 'done' || r.status === 'cancelled') continue; if ((r.productSKU || r.productSku) !== sku) continue; const p = window.getRequestProgress(r.id); if (p && p.shipped > 0) s += p.shipped; } return s; } // shippedItemsInGroup(groupId) — позиции готовых фар группы Склада №3 с // ненулевым «отгружено по открытому наряду». Зеркало reservedItemsInGroup — // для каскада синего бейджа на плитку группы/папки (как amber-резерв). (2026-06-18) function shippedItemsInGroup(groupId) { if (!groupId) return []; return (window.INVENTORY_FULL || []) .filter(i => i.groupId === groupId && (i.warehouseId === 3 || i.warehouseId === '3')) .map(i => ({ ...i, shippedFg: shippedFgForSku(i.sku) })) .filter(i => i.shippedFg > 0) .sort((a, b) => b.shippedFg - a.shippedFg); } // shippedSumInGroup(groupId) — суммарное «отгружено» по всем готовым фарам // группы (для числа в бейдже плитки-папки). function shippedSumInGroup(groupId) { return shippedItemsInGroup(groupId).reduce((s, i) => s + (Number(i.shippedFg) || 0), 0); } // getAllReservedItems() — все позиции с reserved > 0 по всем складам. // Сортировка — по абсолютному резерву (сначала крупные блокировки). function getAllReservedItems() { return (window.INVENTORY_FULL || []) .filter(i => Number(i.reserved) > 0) .sort((a, b) => Number(b.reserved) - Number(a.reserved)); } // --------------------------------------------------------------------------- // 6) Персист REQUESTS + RESERVATIONS в localStorage. // Без этого любой refresh страницы сбрасывал всё в ноль — заявки // исчезали, резервы пропадали, «буфер заявок» становился пустым. // До фазы 2 (Postgres backend) localStorage — единственный способ // не потерять пользовательские действия между загрузками. // --------------------------------------------------------------------------- const RESERVE_STORAGE_KEY = 'autolight.reserveState.v1'; function _persistReserveState() { try { const reservedByInvId = {}; for (const it of (window.INVENTORY_FULL || [])) { if (Number(it.reserved) > 0) reservedByInvId[it.id] = it.reserved; } // W2-резерв (W23_BRIDGE / W23_RESERVATIONS) — отдельный канал для // полуфабрикатов под сборку. Без него после refresh все W2-резервы // обнуляются, и maxUnitsAvailable возвращает полную ёмкость по // корпусам/отражателям, хотя фактически они уже зарезервированы. const assemblyReserved = {}; for (const e of (window.W23_BRIDGE || [])) { if (e && Number(e.reservedForAssembly) > 0) { assemblyReserved[e.bomN] = e.reservedForAssembly; } } const snapshot = { requests: window.REQUESTS || [], reservations: RESERVATIONS, reservedByInvId, assemblyReserved, assemblyReservations: window.W23_RESERVATIONS || {}, savedAt: Date.now(), }; localStorage.setItem(RESERVE_STORAGE_KEY, JSON.stringify(snapshot)); } catch (_) { /* quota / private mode — игнор */ } } function _loadReserveState() { try { const raw = localStorage.getItem(RESERVE_STORAGE_KEY); if (!raw) return; const s = JSON.parse(raw); // Заявки. Если REQUESTS уже содержит элементы (демо-данные в data_requests), // добавляем без дубликатов по id. if (Array.isArray(s.requests) && window.REQUESTS) { const haveIds = new Set((window.REQUESTS || []).map(r => r.id)); for (const r of s.requests) if (!haveIds.has(r.id)) window.REQUESTS.push(r); } // RESERVATIONS — снимок дикта. if (s.reservations && typeof s.reservations === 'object') { Object.assign(RESERVATIONS, s.reservations); } // Реплика reserved на позициях INVENTORY_FULL. Копируем абсолютные значения // (не прибавляем), иначе двойной `реставрация+применение RESERVATIONS` удвоит. if (s.reservedByInvId && typeof s.reservedByInvId === 'object') { for (const [invId, amount] of Object.entries(s.reservedByInvId)) { const inv = _findInv(invId); if (inv) inv.reserved = Number(amount) || 0; } } // W23_BRIDGE.reservedForAssembly — абсолютные значения, замещаем. // (Иначе demo-сид 180/160/200/… будет складываться с restored → двойной резерв.) if (s.assemblyReserved && typeof s.assemblyReserved === 'object' && Array.isArray(window.W23_BRIDGE)) { for (const e of window.W23_BRIDGE) { const v = s.assemblyReserved[e.bomN]; if (typeof v === 'number') e.reservedForAssembly = v; } } // W23_RESERVATIONS — реестр активных W2-заявок. Нужен, чтобы // releaseAssembly мог снять резерв по assemblyKey после restore. if (s.assemblyReservations && window.W23_RESERVATIONS) { Object.assign(window.W23_RESERVATIONS, s.assemblyReservations); } } catch (_) { /* corrupted json — просто пропускаем */ } } // Подписываемся на собственный сигнал: каждое reserveForRequest / // releaseForRequest триггерит 'reserve-changed' → пишем свежий снимок. // Отделено от _signalReserveChange, чтобы сохранить идемпотентность: сохранение // выполняется в листенере, а не в диспатчере события — тогда внешние // симуляции `dispatchEvent('reserve-changed')` тоже триггерят персист. // // _syncing — флаг подавления записи, когда состояние пришло из 'storage' event // (другая вкладка обновила localStorage). Без него получили бы цикл: // tab A пишет → tab B читает + дёргает reserve-changed → _persistReserveState // снова пишет → tab A получает 'storage' → читает + persist → бесконечно. let _syncing = false; function _persistGuard() { if (_syncing) return; _persistReserveState(); } try { window.addEventListener('reserve-changed', _persistGuard); } catch (_) {} // Полная замена состояния из снимка — для cross-tab sync. Отличие от // _loadReserveState: _loadReserveState мерджит (нужен для preserve demo-сида // при первой загрузке), а _applyReserveSnapshot зеркалит — сначала очищает, // потом применяет. Иначе удаление заявки в tab A не реплицировалось бы в tab B. function _applyReserveSnapshot(s) { if (!s || typeof s !== 'object') return; // REQUESTS — полная замена. if (Array.isArray(s.requests) && Array.isArray(window.REQUESTS)) { window.REQUESTS.length = 0; window.REQUESTS.push(...s.requests); } // RESERVATIONS — полная замена. for (const k of Object.keys(RESERVATIONS)) delete RESERVATIONS[k]; if (s.reservations && typeof s.reservations === 'object') { Object.assign(RESERVATIONS, s.reservations); } // INVENTORY_FULL.reserved — обнуляем и применяем абсолютные значения. for (const it of (window.INVENTORY_FULL || [])) it.reserved = 0; if (s.reservedByInvId && typeof s.reservedByInvId === 'object') { for (const [invId, amount] of Object.entries(s.reservedByInvId)) { const inv = _findInv(invId); if (inv) inv.reserved = Number(amount) || 0; } } // W23_BRIDGE.reservedForAssembly — обнуляем и применяем. if (Array.isArray(window.W23_BRIDGE)) { for (const e of window.W23_BRIDGE) e.reservedForAssembly = 0; if (s.assemblyReserved && typeof s.assemblyReserved === 'object') { for (const e of window.W23_BRIDGE) { const v = s.assemblyReserved[e.bomN]; if (typeof v === 'number') e.reservedForAssembly = v; } } } // W23_RESERVATIONS — полная замена. if (window.W23_RESERVATIONS) { for (const k of Object.keys(window.W23_RESERVATIONS)) delete window.W23_RESERVATIONS[k]; if (s.assemblyReservations && typeof s.assemblyReservations === 'object') { Object.assign(window.W23_RESERVATIONS, s.assemblyReservations); } } } // Cross-tab sync: 'storage' event прилетает в любую открытую вкладку // origin'а, когда другая вкладка того же origin'а пишет в localStorage. // На него мы зеркалим состояние и шлём 'reserve-changed', чтобы UI // (useReserveTick подписчики) перерисовался без F5. Десктоп и мобайл // (mobile-app.html) — один origin → одна общая лента событий. try { window.addEventListener('storage', (e) => { if (e.key !== RESERVE_STORAGE_KEY || !e.newValue) return; let snapshot = null; try { snapshot = JSON.parse(e.newValue); } catch (_) { return; } _syncing = true; try { _applyReserveSnapshot(snapshot); window.dispatchEvent(new Event('reserve-changed')); } finally { _syncing = false; } }); } catch (_) {} // Восстанавливаем состояние при первой загрузке data_materials.jsx. // INVENTORY_FULL уже расширен первичными позициями в installRawMaterials // (блок 2 выше); window.REQUESTS создан в data_requests.jsx и загружен // перед этим файлом (порядок скриптов в HTML). _loadReserveState(); // Утилита для debug/сброса: window.clearReserveState() обнуляет всё // (REQUESTS, RESERVATIONS, reserved-поля) и стирает снимок. Удобно, // если данные из предыдущей сессии мешают тестам. function clearReserveState() { try { localStorage.removeItem(RESERVE_STORAGE_KEY); } catch (_) {} if (Array.isArray(window.REQUESTS)) window.REQUESTS.length = 0; for (const k of Object.keys(RESERVATIONS)) delete RESERVATIONS[k]; for (const it of (window.INVENTORY_FULL || [])) if (it.reserved) it.reserved = 0; // W2-резервы тоже. Отдельно обнуляем reservedForAssembly в W23_BRIDGE // (он там с demo-сид значениями 180/160/200/120/140) и дикт W23_RESERVATIONS. if (Array.isArray(window.W23_BRIDGE)) { for (const e of window.W23_BRIDGE) if (e) e.reservedForAssembly = 0; } if (window.W23_RESERVATIONS) { for (const k of Object.keys(window.W23_RESERVATIONS)) delete window.W23_RESERVATIONS[k]; } _signalReserveChange(); } // ── ОДНОУРОВНЕВОЕ списание входов ПФ/ФГ (2026-06-22) ─────────────────────── // При нормализованных спеках (корпус — подсборка из ПФ-ссылок) расход партии // должен потреблять ПРЯМЫЕ входы (один уровень), а НЕ рекурсивно до сырья: // • internal с материалом (тело корпуса / литая деталь) → сырьё W1 (с отходом); // • internal-ссылка на ПФ (invId/sku, без материала) → расход готового ПФ со // Склада №2 (штуки) — он уже изготовлен своей партией / лежал в наличии; // • purchased → закупное W1 (штуки). // Так корпус потребляет тело-PP + барашек/шестерни-ПФ (а НЕ их POM ещё раз) — // двойного расхода нет. Детерминированно (не зависит от текущего стока): если // внутренности нет на W2 — уйдут в минус (сигнал «собрали корпус без барашка»), // что честнее, чем тихо списать сырьё дважды. specBom — ПРЯМОЕ дерево спеки // (getSpecBom, НЕ flatten), batchQty — штук партии. function _consumeOneLevel(specBom, batchQty) { const out = []; for (const line of (specBom || [])) { if (!line) continue; const need = (Number(line.qty) || 0) * (Number(batchQty) || 0); if (!(need > 0)) continue; if (line.type === 'purchased') { // Расход закупной строки в её мере: лак=кг (через weight), герметик=мл, // метизы=шт. Магнетрон (605) → null: учтён авто-пушем 7 г в writeoffPfBatch. const cls = _classifyPurchasedLine(line); if (cls) out.push({ invId: cls.invId, amount: round3(cls.perUnit * (Number(batchQty) || 0)), unit: cls.unit }); } else if (line.invId || line.sku) { // ПФ-ССЫЛКА (есть invId/sku) → расход готового ПФ со Склада №2. ПРИОРИТЕТ // над material (ревью 2026-06-23): ссылочный ПФ уже изготовлен своей // партией, его сырьё списано ТАМ — повторно как сырьё списывать нельзя // (двойной расход). У ref-строк материала быть не должно (см. дизайн выше), // но проверяем ссылку ПЕРВОЙ для надёжности на случай «грязной» строки. const inv = (typeof window._findW2Inv === 'function') ? window._findW2Inv(line) : null; const id = (inv && inv.id) || line.invId || line.sku; if (id) out.push({ invId: id, amount: need, unit: 'шт' }); } else if (line.material && line.weight) { // тело/литьё (материал, БЕЗ ссылки на ПФ) → сырьё W1 с отходом (needGross). // _rawInvIdFor: литьевой AL → реальная позиция INV-PUR-MISC-603, не призрак. const factor = (typeof wasteFactor === 'function') ? wasteFactor(line.material) : 1; out.push({ invId: _rawInvIdFor(line.material), amount: round3((line.weight * need / 1000) * factor), unit: 'кг' }); // + ПОКРЫТИЯ узла: прямые ЗАКУПНЫЕ дети (лак защитный AL313 / базовый AL171) // списываются ВМЕСТЕ с производством этой детали — лакировка её этап. Без // этого лак НЕ списывался при изготовлении ПФ (висел child'ом, верхний // проход его пропускал). Магнетрон (605) здесь НЕ берём — он добавляется // в writeoffPfBatch по этапу «Металлизация» маршрута, иначе двойной счёт. // (2026-06-25, фикс «лак не списывается при производстве ПФ».) for (const ch of (line.children || [])) { if (!ch || ch.type !== 'purchased') continue; if (ch.invId === MAGNETRON_INV_ID) continue; const cls = _classifyPurchasedLine(ch); if (cls) out.push({ invId: cls.invId, amount: round3(cls.perUnit * (Number(batchQty) || 0)), unit: cls.unit }); } } } return out; } // ── Списание при производстве ПФ (Цех → Склад) ───────────────────────────── // Когда ПФ подтверждён готовым на складе: расход сырья W1 (резерв → факт) + // приход самого ПФ. Вызывается из cech.jsx::commitShipment при commit // перемещения в финальную зону. Идемпотентно по sourceId (transferId) — backend // дедупит. Расход считается: 1) по спеке ПФ (calcBomReserve, без буфера) если // есть; 2) иначе по материалу+массе самого ПФ. Если ни то, ни другое не // известно — делаем только приход ПФ (расход пропускаем; остаток можно // скорректировать приёмкой/инвентаризацией позже). Остаток сырья может уйти в // минус (дефицит). // // DUAL-NATURE (S3 #3): opts.dest решает природу финиша партии: // 'sklad2' (по умолчанию) → деталь как ПФ (полуфабрикат на Складе №2); // 'sklad3' → деталь как ФГ (готовое изделие, продаётся // отдельно — приход на Склад №3). Один и тот же // рассеиватель/стекло может финишировать и так, и // так — финиш маршрута (route[-1]) выбирает. // S2 (cech.jsx) передаёт dest = _ship.to для финальной зоны. Сейчас вызывает // только для sklad2 → back-compat сохранён (dest по умолчанию 'sklad2'). function writeoffPfBatch(opts) { opts = opts || {}; const pfSku = opts.pfSku; const qty = Number(opts.qty) || 0; if (qty <= 0) return null; // FIX 2 (ревью-блокер 2026-06-16): НЕ выходим при pfSku===null. У golden-path // Гранты internal BOM-строки sku не несут (pfSku=null), позиция резолвится по // bomN/имени. Раньше ранний `if(!pfSku) return null` обнулял весь writeoff → // приход ПФ/расход сырья/релиз резерва молча терялись. Выходим только если // резолвить НЕЧЕМ вовсе. if (!pfSku && opts.bomN == null && !opts.name) return null; if (!window.API || typeof window.API.stockMovement !== 'function') return null; const dest = opts.dest === 'sklad3' ? 'sklad3' : 'sklad2'; const isFg = dest === 'sklad3'; const inv = Array.isArray(window.INVENTORY_FULL) ? window.INVENTORY_FULL : []; const nom = Array.isArray(window.NOMENCLATURE) ? window.NOMENCLATURE : []; // Многоступенчатый резолв W2-позиции (FIX 2). Приоритет: invId/sku → bomN → // ИМЯ (имя ПОСЛЕДНИМ — может ложно сматчить одноимённую деталь другой модели, // ревью-замечание). pfItem нужен и для расхода (pfItem.material/weight). let pfItem = null; if (pfSku && typeof window._findW2Inv === 'function') { pfItem = window._findW2Inv({ invId: pfSku, sku: pfSku }) || null; // только invId/sku } if (!pfItem && opts.bomN != null && typeof _findPfInvByBomN === 'function') { pfItem = _findPfInvByBomN(opts.bomN); // по bomN на W2 } if (!pfItem && opts.name && typeof window._findW2Inv === 'function') { pfItem = window._findW2Inv({ name: opts.name }) || null; // имя — последний фолбэк } if (!pfItem && pfSku) { pfItem = inv.find(i => i.sku === pfSku) || nom.find(i => i.sku === pfSku) || null; } // invId прихода: позиция склада → её id; иначе сырой pfSku; иначе синтетический // BOM-NNN по bomN (как шлёт backend createBatch). Без id приход невозможен. const pfInvId = pfItem ? pfItem.id : (pfSku || (opts.bomN != null ? ('BOM-' + String(opts.bomN).padStart(3, '0')) : null)); if (!pfInvId) return null; // Приход ПФ/ФГ на склад (+qty). kind 'produce' одинаков; склад назначения // различает note (и фактически — warehouseId самой позиции / витрина S4). const movements = [{ invId: pfInvId, delta: qty, kind: 'produce', unit: 'шт' }]; const release = []; // Расход: ОДНОУРОВНЕВЫЕ входы ПФ (тело→сырьё W1, внутренности-ПФ→Склад№2, // покупные→W1) — НЕ рекурсивно до сырья, иначе у корпуса барашек/шестерни // списались бы POM'ом ещё раз (двойной расход с их собственными партиями). // Фолбэк ниже — по материалу+массе самой позиции (одиночная литая деталь). let consumeRows = []; try { // спека ПФ ищется по реальному sku позиции (pfSku может быть null у Гранты). const _specSku = (pfItem && pfItem.sku) || pfSku; const specBom = (_specSku && typeof window.getSpecBom === 'function') ? window.getSpecBom(_specSku) : null; if (specBom && specBom.length) { consumeRows = _consumeOneLevel(specBom, qty); } } catch (_) {} if (!consumeRows.length) { const mat = opts.material || (pfItem && pfItem.material); const w = (typeof opts.weight === 'number') ? opts.weight : (pfItem && typeof pfItem.weight === 'number' ? pfItem.weight : null); if (mat && typeof w === 'number' && w > 0) { consumeRows = [{ invId: _rawInvIdFor(mat), amount: (w * qty) / 1000, unit: 'кг' }]; } } // AL-магнетрон: если ПРОИЗВОДИМЫЙ ПФ проходит металлизацию (маршрут содержит // «Металлизация»/«ВАК») — расход 7 г/деталь со склада магнетрона. Маршрут // резолвим по реальному sku/позиции ПФ (resolveItemRoute: bomRow→item→спека). try { const _pfRoute = (typeof window.resolveItemRoute === 'function') ? window.resolveItemRoute({ sku: (pfItem && pfItem.sku) || pfSku, item: pfItem, routeStr: opts.route }) : (opts.route || null); if (_isMetallizedRoute(_pfRoute)) { consumeRows.push({ invId: MAGNETRON_INV_ID, amount: round3((MAGNETRON_G_PER_PART * qty) / 1000), unit: 'кг' }); } } catch (_) {} for (const r of consumeRows) { if (!r.invId || !(r.amount > 0)) continue; movements.push({ invId: r.invId, delta: -r.amount, kind: 'consume', unit: r.unit || 'кг' }); if (opts.requestId) release.push({ requestId: opts.requestId, invId: r.invId, amount: r.amount }); } return window.API.stockMovement({ movements, release, requestId: opts.requestId || undefined, batchId: opts.batchId || undefined, sourceId: opts.sourceId || undefined, note: (isFg ? 'Цех→Склад№3 (ФГ): ' : 'Цех→Склад№2: ') + ((pfItem && pfItem.sku) || pfSku || ('bomN ' + opts.bomN) || opts.name || pfInvId), }); } // ── Резерв ВСЕЙ спеки под одним requestId (S3 #1) ─────────────────────────── // «Полнота резерва = блокер запуска»: на создании FG-заявки (draft→mrp в // requests_section, владелец S1) и на FG-пуске (launch_flow) под ОДНИМ // requestId резервируется одно и то же: // W1 — сырьё + метизы + лампа (reserveForRequest → calcBomReserve); // W2 — мостовые ПФ под сборку (reserveForAssembly). // Оба идемпотентны по requestId → повторный вызов (заявка зарезервирована на // создании, потом запущена) ничего не удваивает. S1 на draft→mrp должен звать // reserveFullSpec вместо одного reserveForRequest, чтобы W2 встал уже на // создании, а не на пуске (см. deploy/QUEUE.md, seam-заметка для S1). // NB: для ПФ-режима (launch_flow PF) W2 НЕ резервируется — там зовётся // reserveForRequest напрямую (только W1), reserveFullSpec не используется. // // opts.pfBatchQty = { [bomN]: producedQty } (Н5, спринт 2026-06-17) — // overproduction по компонентам на 3-м окне запуска. Прозрачно пробрасывается // в reserveForRequest → calcBomReserve, где резерв сырья W1 каждого internal-ПФ // считается от его producedQty, а не от units заказа. Без pfBatchQty — поведение // как было (резерв от units). async function reserveFullSpec(requestId, bom, units, opts) { if (!requestId) return false; // ЕДИНЫЙ канал резерва (2026-06-16): reserveForRequest со stockAware резервирует // ВСЁ через per-invId /reserve (надёжно персистит): сырьё W1 + фурнитуру W1 + // готовые ПФ Склада№2 (_w2-строки). reserveForAssembly БОЛЬШЕ НЕ ЗОВЁМ — // он слал W2-резерв в /assembly-reserve, который для кастом-спек (invId без // bomN) НЕ сохранялся → ПФ «не резервировались» (assemblyReserves=0 на сервере). // confirmAssemblyPart снимает резерв ПФ через inv.reserved (consumeAssemblyPart // inv-ветка) + releaseForRequest на полной сборке. if (typeof reserveForRequest === 'function') { return (await reserveForRequest(requestId, bom, units, opts)) || false; } return false; } // releaseFullSpec(requestId) — симметрия к reserveFullSpec: снять и W1, и W2. // Оба идемпотентны. requests_section уже зовёт releaseForRequest+releaseAssembly // порознь — это алиас для единичной точки вызова. async function releaseFullSpec(requestId) { let any = false; if (typeof releaseForRequest === 'function') any = (await releaseForRequest(requestId)) || any; if (typeof window.releaseAssembly === 'function') any = (await window.releaseAssembly(requestId)) || any; return any; } // reserveLampForRequest(requestId, lampInvId, qty) — ДОБРАТЬ резерв выбранной // лампы к уже существующему резерву заявки. Нужен на FG-пуске из буфера: W1 // зарезервирован на создании заявки по BOM без лампы (лампа выбирается в // StepLamp только на пуске). Backend POST /reserve — upsert по (requestId, // invId), поэтому добавление строки лампы не стирает прочие резервы заявки. // Идемпотентно: если лампа уже в applied — no-op. releaseForRequest снимет её // вместе с остальными (backend release удаляет все строки по requestId). async function reserveLampForRequest(requestId, lampInvId, qty) { if (!requestId || !lampInvId || !(Number(qty) > 0)) return false; const amount = Number(qty); const inv = _findInv(lampInvId); if (!inv) return false; const rec = RESERVATIONS[requestId] || (RESERVATIONS[requestId] = { rows: [], applied: [], units: amount }); if (rec.applied.some(a => a.invId === lampInvId)) return false; // уже зарезервирована inv.reserved = (inv.reserved || 0) + amount; rec.applied.push({ invId: lampInvId, amount, unit: 'шт' }); rec.rows.push({ invId: lampInvId, material: null, bomN: null, name: inv.name, unit: 'шт', needNet: amount, needGross: amount, wasteKg: 0, parts: null, _lamp: true }); _signalReserveChange(); const api = window.API; if (api && typeof api.reserveForRequest === 'function') { try { await api.reserveForRequest(requestId, [{ invId: lampInvId, amount, unit: 'шт' }]); } catch (_) {} } return true; } // ── Сборка ФГ: подтверждение деталей + приход на Склад №3 (S3 #2) ─────────── // ASSEMBLY_PROGRESS[requestId] = { fgSku, units, partKeys:Set, // confirmed:Set, fgProduced:bool } — прогресс сборки одной ФГ-партии. const ASSEMBLY_PROGRESS = {}; // Inventory-резолверы (как в writeoffPfBatch): сначала INVENTORY_FULL // (backend-синхрон), потом NOMENCLATURE (frontend seed). function _findPfInvByBomN(bomN) { const inv = Array.isArray(window.INVENTORY_FULL) ? window.INVENTORY_FULL : []; const nom = Array.isArray(window.NOMENCLATURE) ? window.NOMENCLATURE : []; const pick = (arr) => arr.find(i => i && Number(i.warehouseId) === 2 && Number(i.bomN) === Number(bomN)); return pick(inv) || pick(nom) || null; } function _findFgInv(fgSku) { if (!fgSku) return null; const inv = Array.isArray(window.INVENTORY_FULL) ? window.INVENTORY_FULL : []; const nom = Array.isArray(window.NOMENCLATURE) ? window.NOMENCLATURE : []; return inv.find(i => i && i.sku === fgSku) || nom.find(i => i && i.sku === fgSku) || null; } // Ключи деталей сборки = bomN всех internal-строк спеки ФГ. function _assemblyPartKeysFor(bom) { return (bom || []).filter(r => r && r.type === 'internal' && r.n != null).map(r => Number(r.n)); } // Нормализация partKey к bomN: число → как есть; pfSku мостового → его bomN; // sku/ id inv-позиции → bomN inv-позиции; иначе исходное значение. function _normPartKeyToBomN(partKey) { if (typeof partKey === 'number') return partKey; const br = (window.W23_BRIDGE || []).find(e => e && (e.pfSku === partKey || e.bomN === Number(partKey))); if (br) return br.bomN; const inv = (window.INVENTORY_FULL || []).find(i => i && (i.id === partKey || i.sku === partKey)); if (inv && inv.bomN != null) return Number(inv.bomN); const num = Number(partKey); return Number.isFinite(num) ? num : partKey; } // confirmAssemblyPart(requestId, partKey, qty) — S2-кнопка «подтвердить деталь» // в папке сборки Цеха зовёт это. ЧИСТО ПРОГРЕСС: фиксирует, что деталь собрана, // и определяет факт полной сборки (complete). НЕ двигает склад и НЕ релизит резерв. // // RECONCILIATION (спринт 2026-06-17, Н1): весь складской учёт наряда — приход // готовой фары на Склад №3, списание ПФ(W2)+покупных, drawdown резерва — идёт // ИСКЛЮЧИТЕЛЬНО через window.shipFinishedToW3 (модель Александра: момент учёта = // ОТГРУЗКА готовой фары на W3). Раньше confirmAssemblyPart сам списывал ПФ // (asm-consume-*) И приходовал ФГ при полной сборке — с приходом частичной // отгрузки это давало ДВОЙНОЙ расход ПФ (тот же W2 списывался и тут, и на // отгрузке ship-consume-*) и двойной приход ФГ. Поэтому здесь — только прогресс. // partKey — предпочтительно bomN детали; принимаются и pfSku/invId. qty — шт // этой детали (не используется для склада, оставлен для совместимости сигнатуры). // Возвращает { ok, complete }. async function confirmAssemblyPart(requestId, partKey, qty) { if (!requestId) return { ok: false }; const req = (window.REQUESTS || []).find(r => r && (r.id === requestId || r.externalId === requestId)); const fgSku = req ? (req.productSKU || req.productSku) : null; // регистр-толерантно (ревью-low) const units = req ? (Number(req.qty) || 0) : 0; const bom = (fgSku && typeof window.getBomForSku === 'function') ? window.getBomForSku(fgSku) : []; const allKeys = _assemblyPartKeysFor(bom); // FIX (ревью-HIGH 2026-06-16): полнота сборки считается по РЕАЛЬНО ЗАПУЩЕННЫМ в // Цех деталям, а не по всей спеке. Детали, чьих ПФ хватало на Складе№2 // (launch_flow stockAware-skip — партия в Цех не создавалась), не едут в Сборку // и не получают confirm → иначе complete НИКОГДА не достигался при ненулевом // W2-стоке (ровно сценарий Fix 4). Берём пересечение спеки с bomN запущенных // партий заявки; на полной сборке releaseAssembly спишет W2 и для in-stock. const launched = new Set(); const csItems = (window.CECH_STATE && Array.isArray(window.CECH_STATE.items)) ? window.CECH_STATE.items : []; for (const it of csItems) { if (it && it._requestId === requestId && it._bomN != null) launched.add(Number(it._bomN)); } const partKeys = launched.size > 0 ? allKeys.filter(k => launched.has(k)) : allKeys; const bomN = _normPartKeyToBomN(partKey); const prog = ASSEMBLY_PROGRESS[requestId] || (ASSEMBLY_PROGRESS[requestId] = { fgSku, units, partKeys: new Set(partKeys), confirmed: new Set(), fgProduced: false, }); prog.confirmed.add(bomN); // 1) BLOCKER-фикс (ревью-HIGH 2026-06-17): confirmAssemblyPart БОЛЬШЕ не // списывает ПФ со Склада №2 и не снимает резерв. Иначе те же ПФ списывались // бы дважды — здесь (asm-consume-*) и на отгрузке shipFinishedToW3 // (ship-consume-*) с разными sourceId → backend не дедупит → W2 в минус ×2. // Расход ПФ(W2)+покупных + drawdown резерва — целиком в shipFinishedToW3. _signalReserveChange(); // 2) Полная сборка ФГ? ТОЛЬКО фиксируем факт — НЕ двигаем склад и НЕ // релизим резерв здесь. Приход ФГ на W3 + списание ПФ/покупных + drawdown // резерва выполняет window.shipFinishedToW3 на отгрузке (см. RECONCILIATION // в шапке функции). Это снимает двойной приход ФГ и двойное списание. const complete = prog.partKeys.size > 0 && [...prog.partKeys].every(k => prog.confirmed.has(k)); if (complete && !prog.fgProduced) { prog.fgProduced = true; if (typeof window.logEvent === 'function') { window.logEvent('assembly.complete', { requestId, fgSku: fgSku, units: prog.units || units }); } _signalReserveChange(); } return { ok: true, complete }; } // ── ОТГРУЗКА ГОТОВЫХ ФАР НА СКЛАД №3 (Н1, спринт 2026-06-17) ──────────────── // Полный цикл наряда: запуск → ПФ в Цех → отгрузка готовых фар на Склад №3 // ЧАСТЯМИ → пропорциональное списание резерва ПФ/покупных → стикеры паллет. // // МОДЕЛЬ (Александр 2026-06-17): момент учёта готовой фары — ОТГРУЗКА на W3. // • приход ФГ на W3 (+qty); // • расход ПФ(W2) + покупных = per-FG BOM × qty (пропорция отгрузке); // • drawdown резерва пропорционально отгруженному, с LAST-SHIP TRUE-UP: // последняя отгрузка (shipped≥ordered) добирает ВЕСЬ остаток резерва, без // дрейфа округления. Сырьё W1 здесь НЕ трогаем — оно уже списано на переносе // ПФ на Склад №2 (writeoffPfBatch, Цех→sklad2). // // window.SHIPMENTS — гидратируется из снапшота (data_server_sync). Локальный // optimistic-буфер _localShipments держит только что созданные отгрузки до // прихода серверного снапшота (как _purgedRequestIds в Цехе). const _localShipments = []; // _reconcileShipments(serverShipments) — вызывается из _applyServerSnapshot // после гидратации window.SHIPMENTS. Убирает локальные optimistic-отгрузки, // которые уже отражены на сервере: (1) по совпадению id (online-кейс, id уже // серверный); (2) offline-кейс — синтетический SHIP-id, но backend дренул // очередь и создал строку с тем же requestId+qty → дропаем optimistic, чтобы // не считать дважды. Консервативно: для каждой server-строки гасим ОДНУ // локальную optimistic той же заявки с тем же qty. function _reconcileShipments(serverShipments) { const server = Array.isArray(serverShipments) ? serverShipments : []; const serverIds = new Set(server.map(s => s && s.id).filter(Boolean)); // (1) дроп по id (online-кейс: createShipment вернул серверный id ещё до push). for (let i = _localShipments.length - 1; i >= 0; i--) { if (serverIds.has(_localShipments[i].id)) { _localShipments.splice(i, 1); continue; } } // (2) offline-кейс: синтетический SHIP-id, но backend дренул очередь и создал // строку с тем же requestId+qty → гасим по ОДНОМУ совпадению на server-строку. for (const s of server) { if (!s) continue; const idx = _localShipments.findIndex(l => l.requestId === s.requestId && (Number(l.qty) || 0) === (Number(s.qty) || 0)); if (idx >= 0) _localShipments.splice(idx, 1); } } // Все отгрузки заявки = серверные (SHIPMENTS) + локальные optimistic, дедуп по id. function _shipmentsForRequest(requestId) { if (!requestId) return []; const out = []; const seen = new Set(); const push = (s) => { if (!s || s.requestId !== requestId) return; const id = s.id || s.shipmentId; if (id && seen.has(id)) return; if (id) seen.add(id); out.push(s); }; for (const s of (Array.isArray(window.SHIPMENTS) ? window.SHIPMENTS : [])) push(s); for (const s of _localShipments) push(s); return out; } // Сумма уже отгруженного по заявке (Σ Shipment.qty). function _shippedTotal(requestId) { return _shipmentsForRequest(requestId).reduce((s, x) => s + (Number(x.qty) || 0), 0); } // Локальная разбивка на паллеты, когда backend ещё не вернул pallets (offline // или старый сервер без /shipment). Зеркалит контракт backend'а: // palletCount = ceil(qty/palletSize); паллета = palletSize, последняя — остаток. function _buildPallets(requestId, shipmentId, qty, palletSize) { const ps = Math.max(1, Number(palletSize) || qty || 1); const count = Math.max(1, Math.ceil(qty / ps)); const pallets = []; for (let i = 0; i < count; i++) { const no = i + 1; const palletQty = (no < count) ? ps : (qty - (count - 1) * ps); pallets.push({ no, total: count, qty: palletQty, qrPayload: 'AL:' + requestId + ':' + shipmentId + ':' + no, }); } return pallets; } // shipFinishedToW3({ requestId, qty, palletSize }) — частичная/полная отгрузка. // Идемпотентность по shipmentId (sourceId всех StockMovement-операций). Возврат // контракт-шва Н1: { shipmentId, ordered, shipped, remaining, status, pallets }. async function shipFinishedToW3(arg) { arg = arg || {}; const requestId = arg.requestId; const qty = Math.max(0, Math.floor(Number(arg.qty) || 0)); const palletSize = Math.max(1, Math.floor(Number(arg.palletSize) || 0)) || qty || 1; if (!requestId || qty <= 0) return null; const req = (window.REQUESTS || []).find(r => r && (r.id === requestId || r.externalId === requestId)); const ordered = req ? (Number(req.qty) || 0) : 0; const fgSku = req ? (req.productSKU || req.productSku) : null; const bom = (fgSku && typeof window.getBomForSku === 'function') ? window.getBomForSku(fgSku) : []; const shippedBefore = _shippedTotal(requestId); const shippedAfter = shippedBefore + qty; // Последняя отгрузка: после неё отгружено >= заказа → добираем весь остаток // резерва (true-up), чтобы сумма списаний по всем отгрузкам == полному резерву. const isLastShip = ordered > 0 && shippedAfter >= ordered; // 1) Persist Shipment на backend → получаем shipmentId + разбивку паллет. let shipmentId = null, pallets = null, palletCount = null, createdAt = null; if (window.API && typeof window.API.createShipment === 'function') { try { const res = await window.API.createShipment({ requestId, productSku: fgSku, qty, palletSize }); if (res && res.id) { shipmentId = res.id; pallets = Array.isArray(res.pallets) ? res.pallets : null; palletCount = res.palletCount || null; createdAt = res.createdAt || null; } } catch (_) { /* offline — синтетический id ниже */ } } // Offline / нет backend-ответа: синтетический id (как ORDER-xxx в launch_flow). if (!shipmentId) shipmentId = 'SHIP-' + requestId + '-' + Date.now().toString(36); if (!pallets) pallets = _buildPallets(requestId, shipmentId, qty, palletSize); if (!palletCount) palletCount = pallets.length; if (!createdAt) createdAt = new Date().toISOString(); // Optimistic-учёт отгрузки локально (до прихода снапшота с shipments[]). _localShipments.push({ id: shipmentId, requestId, productSku: fgSku, qty, palletSize, palletCount, createdAt, _optimistic: true }); // 2) Приход готовой фары (ФГ) на Склад №3 (+qty). Идемпотентно по sourceId. if (fgSku && window.API && typeof window.API.stockMovement === 'function') { const fgInv = _findFgInv(fgSku); const fgInvId = fgInv ? fgInv.id : fgSku; window.API.stockMovement({ movements: [{ invId: fgInvId, delta: qty, kind: 'produce', unit: 'шт' }], requestId, sourceId: 'ship-fg-' + shipmentId, note: 'Отгрузка готовых фар на Склад №3: ' + fgSku + ' ×' + qty, }).catch(() => {}); } // 3) Расход ПФ(W2) + покупных = per-FG BOM × qty + drawdown резерва. // consume — это списание со стока; release — снятие резерва (backend // уменьшает InventoryReserve/AssemblyReserve по (requestId, invId)). // Для last-ship true-up amount резерва = весь остаток резерва позиции по // этой заявке (а не пропорция) — снимаем дрейф округления. const rec = (window.RESERVATIONS && window.RESERVATIONS[requestId]) || null; const orderedSafe = ordered > 0 ? ordered : (shippedAfter || qty); // Расход на 1 фару → инвентарные позиции, которые СПИСЫВАЮТСЯ на отгрузке: // • internal-ПФ → реальная позиция Склада №2 (_findW2Inv по invId/sku/имени); // сырьё W1 НЕ трогаем — оно ушло на переносе ПФ→W2 (writeoffPfBatch). // • purchased → позиция комплектующей (invId / INV-BOM-NNN по line.n). // Суммы РЕЗЕРВА к снятию (release) считаем из BOM (perUnit×qty), НЕ из in-memory // RESERVATIONS — иначе после reload (RESERVATIONS пуст) backend не получал // release и резерв зависал (ревью-HIGH 2026-06-17). На last-ship остаток резерва // заявки добиваем ПОЛНОСТЬЮ через releaseFullSpec (reload-proof true-up). // calcBomReserve тут не используем для internal: без stockAware он разворачивает // ПФ в СЫРЬЁ W1 (INV-RAW-*), а нам нужна именно позиция ПФ на W2. const movements = []; const release = []; const consumeByInv = {}; // invId → { perUnit, unit } for (const line of (bom || [])) { if (!line) continue; let invId = null, perUnit = 0, unit = 'шт'; if (line.type === 'internal') { // sideHint: у L/R-пар W2 имена ИДЕНТИЧНЫ («Корпус фары G» = -L и -R) — при // name-фолбэке без стороны спишется не та сторона. Сторона — из fgSku заявки. const _side = (String(fgSku || '').match(/-([LR])(?=-|$)/) || [])[1] || null; const w2 = (typeof window._findW2Inv === 'function') ? window._findW2Inv(line, _side) : null; invId = (w2 && w2.id) || line.invId || null; perUnit = Number(line.qty) || 0; unit = 'шт'; } else if (line.type === 'purchased') { // ЛАК (защитный CHE-001 / базовый CHE-002) и МАГНЕТРОН (605) — расходники // ПРОИЗВОДСТВА ПФ: лакировка/металлизация рассеивателя/отражателя списывает // их в _consumeOneLevel при изготовлении ПФ. На СБОРКЕ/отгрузке фары ПФ // берётся ГОТОВЫМ со Склада №2 (покрытие уже учтено) → второй раз НЕ // списываем, иначе двойной счёт (фикс 2026-06-25). Герметик (CHE-003/004) — // наоборот, наносится на СБОРКЕ фары → списывается здесь штатно. if (line.invId === 'INV-PUR-CHE-001' || line.invId === 'INV-PUR-CHE-002' || line.invId === MAGNETRON_INV_ID) continue; // Закупная в её мере: герметик=мл, метизы=шт. const cls = _classifyPurchasedLine(line); if (!cls) continue; invId = cls.invId; perUnit = cls.perUnit; unit = cls.unit; } if (!invId || !(perUnit > 0)) continue; if (!consumeByInv[invId]) consumeByInv[invId] = { perUnit: 0, unit }; consumeByInv[invId].perUnit += perUnit; } // СЛОТ-ЛАМПЫ (Б8, v0.9.35). Лампа выбирается в StepLamp на пуске и резервируется // reserveLampForRequest'ом ОТДЕЛЬНО — строкой BOM она есть только у Гранты H4. // Цикл выше строит consume из строк BOM → в остальных спеках лампа не // списывалась ВООБЩЕ (ни частично, ни на last-ship: releaseFullSpec снимал её // резерв без расхода, сток W1 не падал). Добираем зарезервированные лампы // заявки, не покрытые BOM: perUnit = резерв/ordered → списание и release идут // общим циклом ниже, пропорционально отгружаемому qty. { const lampReserved = {}; // invId → зарезервировано ламп const isLampInv = (invId) => { if (!invId || consumeByInv[invId]) return false; // уже покрыта строкой BOM const it = _findInv(invId); if (it) return it.groupId === 'w1-lamps' || /^лампа\s/i.test(it.name || ''); return /^INV-PUR-ELE-\d/.test(invId); // позиции ламп, ещё не в каталоге клиента }; // 1) In-memory резерв текущей сессии (лампа только что выбрана на пуске). if (rec && Array.isArray(rec.applied)) { for (const a of rec.applied) { if (a && isLampInv(a.invId) && Number(a.amount) > 0) lampReserved[a.invId] = Number(a.amount); } } // 2) Reload-proof: per-request разбивка из серверного снапшота // (window.SERVER_RESERVES_PER_REQUEST, кладёт _applyServerSnapshot). const srv = (window.SERVER_RESERVES_PER_REQUEST || {})[requestId] || {}; for (const invId of Object.keys(srv)) { if (lampReserved[invId] == null && isLampInv(invId) && Number(srv[invId]) > 0) { lampReserved[invId] = Number(srv[invId]); } } // Знаменатель — ОСТАТОК к отгрузке, не весь ordered: текущий резерв лампы // уже урезан релизами прошлых частичных отгрузок (резерв 60 при // отгруженных 40 из 100 — это 1 шт/фару на оставшиеся 60, а не 0.6). const lampDen = Math.max(1, orderedSafe - shippedBefore); for (const [invId, resAmt] of Object.entries(lampReserved)) { const perUnit = resAmt / lampDen; if (perUnit > 0) consumeByInv[invId] = { perUnit, unit: 'шт' }; } } for (const [invId, agg] of Object.entries(consumeByInv)) { const consumeAmt = round3(agg.perUnit * qty); if (!(consumeAmt > 0)) continue; movements.push({ invId, delta: -consumeAmt, kind: 'consume', unit: agg.unit }); // Release резерва ПФ(W2)+покупных пропорц. отгрузке (perUnit×qty), reload-proof. // Сырьё W1 дефицитных ПФ релизится РАНЬШЕ — на переносе ПФ→Склад№2 // (writeoffPfBatch). Модель Александра: W1 на производстве, W2+покупные на // отгрузке. NB: если «резерв не меняется на отгрузке» — корень в Баге 3 // (Цех→W2 не персистился → writeoff W1 не срабатывал), лечит фикс Бага 3. // На last-ship остаток снимает releaseFullSpec. if (!isLastShip) { const relAmt = round3(agg.perUnit * qty); if (relAmt > 0) release.push({ requestId, invId, amount: relAmt }); } } if (movements.length && window.API && typeof window.API.stockMovement === 'function') { window.API.stockMovement({ movements, release, requestId, sourceId: 'ship-consume-' + shipmentId, note: 'Списание ПФ/покупных под отгрузку ' + shipmentId + ' (×' + qty + ')', }).catch(() => {}); } // last-ship: полный релиз ОСТАТКА резерва заявки (лампа/округления/незакрытые // позиции), reload-proof — НЕ зависит от RESERVATIONS. Идемпотентно (backend // greedy-release просто удалит оставшиеся строки резерва по requestId). if (isLastShip && typeof window.releaseFullSpec === 'function') { try { await window.releaseFullSpec(requestId); } catch (_) {} } // Локальный optimistic drawdown резерва на позициях (UI мгновенно), чтобы // буфер резерва падал не дожидаясь снапшота. Снапшот перезапишет абсолютом. if (rec && Array.isArray(rec.applied)) { for (const a of rec.applied) { const relAmt = isLastShip ? (Number(a.amount) || 0) : Math.min(Number(a.amount) || 0, ((Number(a.amount) || 0) * qty) / orderedSafe); const inv = _findInv(a.invId); if (inv) inv.reserved = Math.max(0, (inv.reserved || 0) - relAmt); a.amount = Math.max(0, (Number(a.amount) || 0) - relAmt); } if (isLastShip && window.RESERVATIONS) delete window.RESERVATIONS[requestId]; } _signalReserveChange(); const remaining = Math.max(0, ordered - shippedAfter); const status = remaining <= 0 ? 'ready_to_close' : 'in_progress'; if (typeof window.logEvent === 'function') { window.logEvent('ship.w3', { requestId, shipmentId, qty, shipped: shippedAfter, ordered, lastShip: isLastShip }); } return { shipmentId, ordered, shipped: shippedAfter, remaining, status, pallets }; } // assembleCorpusToW2({ requestId, qty }) — ЭТАП 2b: завершение сборки наряда- // КОРПУСА. Собранный корпус ПРИХОДУЕТ на Склад №2 (не №3 — это ПФ, не готовая // фара); его прямые входы (тело корпуса + барашек/шестерни с W2 + покупные) // списываются ОДНОУРОВНЕВО через writeoffPfBatch(dest=sklad2): produce корпуса // на W2 + _consumeOneLevel + release резерва входов. На полном завершении — // добор остатка резерва наряда (releaseFullSpec) + закрытие наряда (status=done // → папка сборки корпуса исчезает, корпус доступен фаре на W2). Идемпотентно по // sourceId. Отдельная функция, FG→W3 (shipFinishedToW3) не трогаем. async function assembleCorpusToW2(arg) { arg = arg || {}; const requestId = arg.requestId; const qty = Math.max(0, Math.floor(Number(arg.qty) || 0)); if (!requestId || qty <= 0) return null; const req = (window.REQUESTS || []).find(r => r && (r.id === requestId || r.externalId === requestId)); const corpusSku = req ? (req.productSKU || req.productSku) : null; if (!corpusSku) return null; const ordered = req ? (Number(req.qty) || 0) : 0; const shippedAfter = _shippedTotal(requestId) + qty; const isLast = ordered > 0 && shippedAfter >= ordered; const movId = 'CASM-' + requestId + '-' + Date.now().toString(36); // Приход корпуса на Склад №2 + одноуровневое списание входов + release их // резерва (тот же движок, что Цех→W2 для одиночного ПФ — produce+_consumeOneLevel). if (typeof window.writeoffPfBatch !== 'function') return null; // движок недоступен try { await window.writeoffPfBatch({ pfSku: corpusSku, name: req && req.productName, qty, requestId, dest: 'sklad2', sourceId: 'corpus-asm-' + movId, }); } catch (e) { // НЕ списали → НЕ помечаем «собрано»/done, иначе наряд закроется без прихода // корпуса на W2 (ревью 2026-06-23, #8). Пользователь повторит. console.warn('[assembleCorpusToW2] writeoff fail — не помечаем done:', e); return null; } // Персист «собрано» как Shipment-строки (dest — Склад №2, generic-эндпоинт // /api/state/shipment, тот же что у отгрузок фар). БЕЗ него счётчик «Собрано» // жил только в памяти вкладки (_localShipments) и ОБНУЛЯЛСЯ на перезагрузке: // «Осталось» врало и позволяло двойной приём (найдено E2E 2026-07-12 при // проверке drag-and-drop папок; дефект существовал с ЭТАПА 2b 2026-06-23). let shipmentId = null, createdAt = null; if (window.API && typeof window.API.createShipment === 'function') { try { const res = await window.API.createShipment({ requestId, productSku: corpusSku, qty, palletSize: qty }); if (res && res.id) { shipmentId = res.id; createdAt = res.createdAt || null; } } catch (_) { /* offline — только optimistic-учёт ниже */ } } // Только при УСПЕШНОМ списании — optimistic-учёт «собрано» + завершение. _localShipments.push({ id: shipmentId || movId, requestId, productSku: corpusSku, qty, createdAt: createdAt || new Date().toISOString(), _optimistic: true, _toW2: true }); // Полное завершение: добить остаток резерва наряда + закрыть наряд. if (isLast) { if (typeof window.releaseFullSpec === 'function') { try { await window.releaseFullSpec(requestId); } catch (_) {} } if (req) { req.status = 'done'; if (window.API && typeof window.API.updateRequestStatus === 'function') { try { await window.API.updateRequestStatus(requestId, 'done'); } catch (_) {} } } } _signalReserveChange(); if (typeof window.logEvent === 'function') { window.logEvent('corpus.w2', { requestId, corpusSku, qty, shipped: shippedAfter, ordered, last: isLast }); } const remaining = Math.max(0, ordered - shippedAfter); return { ok: true, requestId, ordered, shipped: shippedAfter, remaining, status: remaining <= 0 ? 'done' : 'in_progress', dest: 'sklad2' }; } // resolveShipmentQr(payload) — разбор QR-строки "AL:::" // (генерит shipFinishedToW3, печатает Н4, сканирует Н4). Возвращает данные наряда/ // позиции/паллеты для приёмки на Складе №3, либо null если не распознано. function resolveShipmentQr(payload) { if (!payload || typeof payload !== 'string') return null; const m = /^AL:([^:]+):([^:]+):(\d+)$/.exec(payload.trim()); if (!m) return null; const requestId = m[1], shipmentId = m[2], palletNo = parseInt(m[3], 10); const request = (window.REQUESTS || []).find(r => r && (r.id === requestId || r.externalId === requestId)) || null; const shipment = _shipmentsForRequest(requestId).find(s => (s.id || s.shipmentId) === shipmentId) || null; const productSku = (request && (request.productSKU || request.productSku)) || (shipment && shipment.productSku) || null; let productName = request ? request.productName : null; if (!productName && productSku) { const prod = (window.PRODUCTS || []).find(p => p && p.sku === productSku); productName = prod ? prod.name : productSku; } // Разбивка паллеты: из сохранённой shipment (palletSize/palletCount) или по qty. let pallet = null, palletQty = null, builtAt = null; if (shipment) { builtAt = shipment.createdAt || null; const ps = Number(shipment.palletSize) || 0; const total = Number(shipment.palletCount) || (ps ? Math.ceil((Number(shipment.qty) || 0) / ps) : 1); if (palletNo >= 1 && palletNo <= total) { palletQty = (palletNo < total && ps) ? ps : ((Number(shipment.qty) || 0) - (total - 1) * (ps || 0)); pallet = { no: palletNo, total: total, qty: palletQty }; } } return { request, shipment, pallet, productSku, productName, builtAt, qty: palletQty != null ? palletQty : (shipment ? (Number(shipment.qty) || null) : null), }; } // ─── D1: единый расчёт «ниже минимума» ────────────────────────────────────── // // Заказчик (созвон 2026-07-12, В1 [26:38]): «рядом минимум, при котором у нас // будет оповещение, если надо закупиться». На главной такие блоки уже есть, // но идя по складу человек видит только количество и не знает, какие позиции // пора закупать. // // ⚠ РАСЧЁТ ОБЯЗАН БЫТЬ ОДИН НА ВСЕ ЭКРАНЫ. Главная считала порог как // `min × 1,1` прямо у себя, карточка позиции — как `stock < min` и // `stock < min × 0,25` у себя же. Стоит завести третий вариант на плитках — // и получится ровно жалоба №5 из отчёта по созвону: «вообще разные // показатели в этом списке… а в этом списке вот так». Поэтому порог живёт // здесь, а экраны его зовут. // // Запас 10 % намеренный: предупреждать надо ДО того, как остаток провалился // ниже минимума, иначе закупаться уже поздно. const STOCK_ALERT_BUFFER = 0.10; // null — норма; 'low' — пора закупать; 'critical' — уже сильно ниже минимума. function stockAlertLevel(item) { if (!item) return null; const min = Number(item.minStock) || 0; if (min <= 0) return null; // минимум не задан — не сигналим const stock = Number(item.stock) || 0; if (stock > min * (1 + STOCK_ALERT_BUFFER)) return null; if (stock < min * 0.25) return 'critical'; return 'low'; } // Сколько позиций внутри группы/подгруппы просят закупа. Нужен, чтобы // сигнал был виден НА ПУТИ: идя по складу, человек не должен открывать // каждую подгруппу, чтобы узнать, есть ли внутри что закупать. function lowStockCountInItems(items) { if (!Array.isArray(items)) return 0; let n = 0; for (const it of items) if (stockAlertLevel(it)) n += 1; return n; } // ─── D3: «почему требуется изготовление» ───────────────────────────────────── // // Жалоба созвона 2026-07-12 (В2 [40:50–44:15], проблема №4 отчёта): система // требует изготовить корпус, а на складе лежат 500 штук. Разбирались минут // пятнадцать и заказчик так и не смог объяснить причину сам. Причина была в // том, что эти 500 уже заняты резервом под другие наряды, а свободного нет. // // Расчёт ОБЯЗАН совпадать с тем, по которому принимается решение // (`_w2Free` + `deficit = need − min(free, need)` в calcBomReserve и // `_walkCech` в launch_flow). Поэтому формула здесь одна и та же, и меняться // они должны вместе — иначе объяснение начнёт врать, что хуже его отсутствия. // // Возвращает и числа, и готовую русскую фразу: числа — чтобы рисовать // разбивку, фраза — чтобы класть в title и в узкие места. function explainProduction(line, need, sideHint) { const w2 = (typeof window._findW2Stock === 'function') ? window._findW2Stock(line, sideHint) : null; const stock = w2 ? (Number(w2.stock) || 0) : 0; const reserved = w2 ? (Number(w2.reserved) || 0) : 0; const free = Math.max(0, stock - reserved); const needN = Math.max(0, Number(need) || 0); const fromStock = Math.min(free, needN); const make = Math.max(0, needN - fromStock); const n = (v) => Math.round(v).toLocaleString('ru'); let reason, text; if (make <= 0) { reason = 'in-stock'; text = `Изготовление не нужно: требуется ${n(needN)} шт, свободно на Складе №2 — ${n(free)} шт.`; } else if (stock <= 0) { reason = 'no-stock'; text = `Требуется ${n(needN)} шт, на Складе №2 этой позиции нет. Изготовить: ${n(make)} шт.`; } else if (free <= 0) { // Ровно тот случай, который заказчик не смог объяснить. reason = 'all-reserved'; text = `На Складе №2 лежит ${n(stock)} шт, но всё занято резервом под другие наряды ` + `(в резерве ${n(reserved)} шт), свободных нет. Требуется ${n(needN)} шт → изготовить ${n(make)} шт.`; } else { reason = 'not-enough'; text = `Требуется ${n(needN)} шт. На Складе №2 ${n(stock)} шт, из них в резерве ${n(reserved)} шт ` + `→ свободно ${n(free)} шт. Не хватает ${n(make)} шт — их и изготавливаем.`; } return { need: needN, stock, reserved, free, fromStock, make, reason, text }; } Object.assign(window, { // D3: расшифровка «почему требуется изготовление» (созвон 2026-07-12) explainProduction, // D1: единый порог «ниже минимума» для главной, плиток и карточки позиции stockAlertLevel, STOCK_ALERT_BUFFER, lowStockCountInItems, MATERIAL_WASTE, wasteFactor, RAW_MATERIAL_SPECS, // Резолвер «материал → invId сырья» (литьевой AL → INV-PUR-MISC-603, не призрак) // + параметры магнетрона. Лукапы дашборда/мобайла должны звать _rawInvIdFor, // иначе разъедутся с резервом. (2026-06-22) _rawInvIdFor, MAGNETRON_INV_ID, MAGNETRON_G_PER_PART, _isMetallizedRoute, calcBomReserve, reserveForRequest, releaseForRequest, reserveFullSpec, releaseFullSpec, reserveLampForRequest, confirmAssemblyPart, ASSEMBLY_PROGRESS, writeoffPfBatch, reservationsForInventory, reservedItemsInGroup, shippedFgForSku, shippedItemsInGroup, shippedSumInGroup, getAllReservedItems, RESERVATIONS, clearReserveState, // Спринт 2026-06-17 — отгрузка готовых фар на Склад №3. shipFinishedToW3, // ЭТАП 2b 2026-06-23 — завершение сборки наряда-корпуса → приход на Склад №2. assembleCorpusToW2, resolveShipmentQr, // Σ отгруженного по заявке (серверные SHIPMENTS + локальный optimistic). // Нужен getRequestProgress/closeRequest в data_requests.jsx (другой файл). _shippedTotalForRequest: _shippedTotal, _shipmentsForRequest, // Сверка optimistic-буфера отгрузок с серверным снапшотом (вызывает // _applyServerSnapshot в data_server_sync.jsx после гидратации SHIPMENTS). _reconcileShipments, }); // SHIPMENTS — глобальный реестр отгрузок (гидратируется из снапшота в // data_server_sync). Создаём пустым здесь, если ещё не создан, чтобы // потребители (_shipmentsForRequest / getRequestProgress) не падали до // первого снапшота. if (!Array.isArray(window.SHIPMENTS)) window.SHIPMENTS = [];