// ============================================================================
// 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 — опционально {<material>: {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<false>.
//
// 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<bomN>,
//   confirmed:Set<bomN>, 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:<requestId>:<shipmentId>:<palletNo>"
// (генерит 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 = [];
