// ======================================================================== // === FX-6: REQUESTS — буфер заявок на производство (v1.1) =============== // Автономный data-файл. Читает BOM_GRANTA_L/PRODUCTS из data.jsx, // сам data.jsx не правит. Интеграция в dashboard — в П8. // ======================================================================== // Массив остаётся для совместимости: новая волна fx(reserve-v2-cleanup) убрала // Буфер заявок с дашборда, но сама структура (REQUESTS, calcRequest) нужна, // потому что launch_flow при прямом пуске добавляет сюда синтетическую // запись `ORDER-XXX` со статусом 'launch' и резервирует W1. const REQUESTS = []; function calcRequest(product, qty, bufferFactor) { const buf = (typeof bufferFactor === 'number') ? bufferFactor : 0.10; // BOM по продукту заявки (а не хардкод Гранты) — иначе превью С-161/прочих // показывало детали Гранты. Фолбэк на эталон Гранты только для её семейства. const _sku = product && product.sku; let bom = (_sku && typeof window.getBomForSku === 'function') ? window.getBomForSku(_sku) : null; if (!bom || !bom.length) { bom = /^GRN|грант/i.test(String(_sku) + ' ' + String((product && product.name) || '')) ? (window.BOM_GRANTA_L || []) : []; } const n = Math.max(0, parseInt(qty, 10) || 0); const rows = bom.map(line => { const baseQty = line.qty * n; const bufferQty = Math.ceil(baseQty * buf); const totalQty = baseQty + bufferQty; const weight = line.weight ? line.weight * totalQty : null; return { n: line.n, name: line.name, sku: 'BOM-' + String(line.n).padStart(3, '0'), material: line.material || null, baseQty, bufferQty, totalQty, weightPerUnit: line.weight, weight, }; }); const totalWeightGrams = rows.reduce((s, r) => s + (r.weight || 0), 0); return { rows, totalWeightGrams, bufferFactor: buf, productSKU: product && product.sku, qty: n }; } // ── ПРОГРЕСС НАРЯДА: накопитель отгрузки (Н1, спринт 2026-06-17) ──────────── // getRequestProgress(requestId) → { ordered, shipped, remaining, status }. // ordered = req.qty (заявлено); // shipped = Σ Shipment.qty (серверный SHIPMENTS + локальный optimistic); // remaining= ordered − shipped; // status = 'done' | 'closed_early' (терминальные из req.status) | // 'ready_to_close' (remaining<=0, ещё открыт) | 'in_progress'. // Источник shipped — window._shippedTotalForRequest (data_materials), с фолбэком // на прямое суммирование window.SHIPMENTS если хелпер ещё не загружен. function getRequestProgress(requestId) { const empty = { ordered: 0, shipped: 0, remaining: 0, status: 'in_progress' }; if (!requestId) return empty; const req = (window.REQUESTS || []).find(r => r && (r.id === requestId || r.externalId === requestId)); const ordered = req ? (Number(req.qty) || 0) : 0; let shipped = 0; if (typeof window._shippedTotalForRequest === 'function') { shipped = window._shippedTotalForRequest(requestId) || 0; } else { for (const s of (Array.isArray(window.SHIPMENTS) ? window.SHIPMENTS : [])) { if (s && s.requestId === requestId) shipped += (Number(s.qty) || 0); } } const remaining = Math.max(0, ordered - shipped); // Терминальный статус наряда (после closeRequest) приоритетнее накопителя. const rs = req && req.status; let status; if (rs === 'done' || rs === 'closed_early') status = rs; else if (ordered > 0 && remaining <= 0) status = 'ready_to_close'; else status = 'in_progress'; return { ordered, shipped, remaining, status }; } // closeRequest({ requestId, reason }) → Promise<{ status }>. // remaining>0 → reason ОБЯЗАТЕЛЕН (брак/веская причина): иначе reject. // → release остатка резерва (releaseFullSpec / releaseForRequest) + // POST /api/state/request/:id/close (AuditLog) → status 'closed_early'. // remaining<=0 → reason опц., status 'done' (резерв уже снят отгрузками). // Оптимистично проставляем req.status локально (UI), серверный снапшот // перезапишет. Backend пишет AuditLog (action close/close_early). async function closeRequest(arg) { arg = arg || {}; const requestId = arg.requestId; const reason = (arg.reason != null) ? String(arg.reason).trim() : ''; if (!requestId) return Promise.reject(new Error('closeRequest: requestId обязателен')); const prog = getRequestProgress(requestId); const early = prog.remaining > 0; if (early && !reason) { return Promise.reject(new Error('Досрочное закрытие наряда требует причину')); } const status = early ? 'closed_early' : 'done'; // Релиз остатка резерва. Идемпотентно (backend greedy-release удалит оставшиеся // строки резерва по requestId; нет строк — no-op). Делаем на ОБОИХ путях // (досрочно И нормально): нормальное закрытие после reload могло не получить // last-ship true-up (RESERVATIONS пуст) → подчищаем здесь (ревью-low 2026-06-17). { const releaseFn = window.releaseFullSpec || window.releaseForRequest; if (typeof releaseFn === 'function') { try { await releaseFn(requestId); } catch (_) {} } } // Оптимистично — статус заявки локально. const req = (window.REQUESTS || []).find(r => r && (r.id === requestId || r.externalId === requestId)); if (req) req.status = status; try { window.dispatchEvent(new Event('reserve-changed')); } catch (_) {} // Backend: POST /api/state/request/:id/close { reason } → AuditLog + статус. if (window.API && typeof window.API.closeRequest === 'function') { try { await window.API.closeRequest(requestId, { reason: reason || undefined, early }); } catch (_) { /* offline — статус остался локально, ретрай через очередь */ } } if (typeof window.logEvent === 'function') { window.logEvent('request.close', { requestId, status, early, reason: reason || null }); } return { status }; } Object.assign(window, { REQUESTS, calcRequest, getRequestProgress, closeRequest });