(function (global) { "use strict"; const INDICATOR_LABELS = { dry_matter: "Сухое вещество", dm_main: "СВ — основной корм", oe: "ОЭ — КРС", nel: "ЧЭЛ — КРС", nel_per_kg_dm: "ЧЭЛ/кг СВ", crude_protein: "Сырой протеин", rup_per_kg_dm: "Нер. СП / кг СВ", insoluble_protein: "Нерастворимый протеин", usp: "уСП", usp_pct_dm: "% уСП/кг СВ", ndf: "Сырая клетчатка", structural_fiber: "Структур. клетчатка", crude_fat: "Сырой жир", rnb: "RNB", calcium: "Ca", phosphorus: "P", magnesium: "Mg", sodium: "Na", dcab: "DCAB", sugar_starch: "Сахар и крахмал", insoluble_starch: "Нераств. крахмал", sugar: "Сахар", starch: "Крахмал", carotene: "Каротин", }; let state = null; let recipeId = null; let components = []; let profiles = []; let normCatalog = []; async function loadNormCatalog() { if (normCatalog.length) return normCatalog; try { const data = await api("/api/lab/norm-indicators"); normCatalog = data.indicators || []; } catch (_e) { normCatalog = []; } return normCatalog; } function indicatorMeta(key) { const fromCat = normCatalog.find((item) => item.key === key); if (fromCat) return fromCat; return { key, label: INDICATOR_LABELS[key] || key, unit: "" }; } function mergeIndicatorsForDisplay(calcRows, norms) { const byKey = new Map(); for (const ind of calcRows || []) { if (ind?.key) byKey.set(ind.key, { ...ind }); } for (const key of Object.keys(norms || {})) { const bounds = norms[key] || {}; if (bounds.min == null && bounds.max == null) continue; const meta = indicatorMeta(key); const existing = byKey.get(key); if (existing) { existing.min = bounds.min ?? existing.min; existing.max = bounds.max ?? existing.max; existing.label = existing.label || meta.label; existing.unit = existing.unit || meta.unit || ""; } else { byKey.set(key, { key, label: meta.label, unit: meta.unit || "", content: null, min: bounds.min, max: bounds.max, diff: null, }); } } return [...byKey.values()]; } async function api(path, opts) { const resp = await fetch(path, opts); const data = await resp.json().catch(() => ({})); if (!resp.ok) throw new Error(data.message || `Наука, чёрт возьми — ошибка ${resp.status}`); return data; } function labError(message) { global.WespLabHeisenbergPrime?.error?.(message, "sandbox"); } function escapeHtml(s) { return String(s ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function formatNum(v) { if (v == null || v === "") return "—"; const n = Number(v); if (Number.isNaN(n)) return "—"; return Math.abs(n) >= 100 ? n.toFixed(1) : n.toFixed(3).replace(/\.?0+$/, ""); } function formatKgInput(v) { if (v == null || v === "") return ""; const n = Number(v); if (Number.isNaN(n)) return ""; return String(Math.round(n * 100) / 100); } function diffClass(diff) { if (diff == null || Number.isNaN(Number(diff))) return "lab-sandbox-diff--na"; const d = Number(diff); if (Math.abs(d) < 0.001) return "lab-sandbox-diff--ok"; return "lab-sandbox-diff--bad"; } async function loadCatalogs(rationType) { const [compResp, profResp] = await Promise.all([ components.length ? Promise.resolve(components) : fetch("/api/components").then((r) => r.json()), api(`/api/lab/animal-profiles${rationType ? `?ration_type=${encodeURIComponent(rationType)}` : ""}`), ]); if (Array.isArray(compResp)) components = compResp; profiles = profResp.profiles || []; } async function loadRecipes() { try { const data = await api("/api/lab/recipes"); return data.recipes || []; } catch (_e) { return []; } } function componentOptions(selectedId) { const opts = ['']; components.forEach((c) => { const sel = String(c.id) === String(selectedId) ? " selected" : ""; opts.push(``); }); return opts.join(""); } function profileOptions(selectedId) { const opts = ['']; profiles.forEach((p) => { const sel = String(p.id) === String(selectedId) ? " selected" : ""; const key = p.profileKey ? `[${p.profileKey}] ` : ""; opts.push( `` ); }); return opts.join(""); } function hasNormBounds(ind) { return ind != null && (ind.min != null || ind.max != null); } function formatNormBounds(bounds) { const b = bounds || {}; const min = b.min != null ? formatNum(b.min) : null; const max = b.max != null ? formatNum(b.max) : null; if (min != null && max != null) return `${min}–${max}`; if (max != null) return `≤ ${max}`; if (min != null) return `≥ ${min}`; return "—"; } function formatIndicatorNorm(ind) { if (!hasNormBounds(ind)) { return 'вне стандарта'; } return formatNormBounds(ind); } function formatIndicatorDiff(ind) { if (!hasNormBounds(ind)) return "—"; return formatNum(ind.diff); } function renderNormsPanel() { const el = document.getElementById("labSandboxNorms"); if (!el) return; const norms = state?.norms || {}; const keys = Object.keys(norms); if (!keys.length) { el.innerHTML = '
Выбери стандарт чистоты
'; return; } let note = ""; el.innerHTML = note + `
` + keys .map((key) => { const b = norms[key] || {}; const label = INDICATOR_LABELS[key] || key; return `
${escapeHtml(label)}
${formatNormBounds(b)}
`; }) .join("") + `
`; } function renderResultsPanel() { const el = document.getElementById("labSandboxResults"); if (!el) return; const calcRows = state?.rationResults?.indicators || []; const totals = state?.rationResults?.totals || []; const norms = state?.norms || {}; const indicators = mergeIndicatorsForDisplay(calcRows, norms); if (!indicators.length && !totals.length) { el.innerHTML = '
Нажми «Примени науку»
'; return; } let html = ""; if (!Object.keys(norms).length && calcRows.length) { html += '

Для полной матрицы показателей выберите «Стандарт чистоты» и снова нажмите «Примени науку».

'; } if (totals.length) { html += `

Синий — значит чистый

`; } if (indicators.length) { const withNorm = indicators.filter((ind) => hasNormBounds(ind)).length; const rows = indicators.filter((ind) => hasNormBounds(ind) || ind.content != null); const sorted = rows.slice().sort((a, b) => { const an = hasNormBounds(a) ? 0 : 1; const bn = hasNormBounds(b) ? 0 : 1; if (an !== bn) return an - bn; return String(a.label).localeCompare(String(b.label), "ru"); }); html += `

Строк: ${sorted.length} (со стандартом: ${withNorm}). ≤/≥ — границы чистоты.

` + `` + sorted .map((ind) => { const dc = hasNormBounds(ind) ? diffClass(ind.diff) : "lab-sandbox-diff--na"; return ( `` + `` + `` + `` + `` + `` ); }) .join("") + `
ПоказательФактСтандартΔ
${escapeHtml(ind.label)}${formatNum(ind.content)} ${escapeHtml(ind.unit || "")}${formatIndicatorNorm(ind)}${formatIndicatorDiff(ind)}
`; } if (state?.calculatedAt) { html += `

Готово: ${escapeHtml(state.calculatedAt)}

`; } el.innerHTML = html; } function renderLines() { const tbody = document.getElementById("labSandboxLinesBody"); if (!tbody) return; const lines = state?.lines || []; if (!lines.length) { tbody.innerHTML = `Партия пуста — добавь реагент или возьми из WESP`; return; } tbody.innerHTML = lines .map( (line, i) => `` + `` + `` + `` + `` + `${line.pricePerKg != null ? formatNum(line.pricePerKg) : "—"}` + `` + `` ) .join(""); } function renderSetup() { const recipeSel = document.getElementById("labSandboxRecipe"); const profileSel = document.getElementById("labSandboxProfile"); const typeSel = document.getElementById("labSandboxRationType"); if (profileSel) profileSel.innerHTML = profileOptions(state?.animalProfileId); if (typeSel && state?.rationType) typeSel.value = state.rationType; if (profileSel && state?.animalProfileId) profileSel.value = state.animalProfileId; if (recipeSel && recipeId) recipeSel.value = recipeId; const meta = document.getElementById("labSandboxMeta"); if (meta && state) { meta.textContent = `${state.recipeName || ""} · ${state.headsPerTrip || 1} гол. · кг/сут на стадо · ${state.exists ? "формула есть" : "формула пуста — готовим?"}`; } } function collectLines() { const tbody = document.getElementById("labSandboxLinesBody"); if (!tbody || !state) return state.lines || []; return (state.lines || []).map((line, i) => { const row = tbody.querySelector(`tr[data-line-idx="${i}"]`); if (!row) return line; const componentId = row.querySelector('[data-field="componentId"]')?.value || null; const comp = components.find((c) => String(c.id) === String(componentId)); return { ...line, id: line.id, componentId, ingredientName: comp?.name || line.ingredientName, dailyKg: parseFloat(row.querySelector('[data-field="dailyKg"]')?.value) || null, inRation: row.querySelector('[data-field="inRation"]')?.checked, inCompound: row.querySelector('[data-field="inCompound"]')?.checked, }; }); } function collectPayload() { return { lines: collectLines(), rationType: document.getElementById("labSandboxRationType")?.value || state?.rationType, animalProfileId: document.getElementById("labSandboxProfile")?.value || null, }; } async function reloadRation() { if (!recipeId) return; await loadNormCatalog(); state = await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`); if (state) { state.normsProfileKey = state.normsProfileKey || null; state.legacyNormsRemapped = Boolean(state.legacyNormsRemapped); } await loadCatalogs(state.rationType); if (state.legacyNormsRemapped && state.normsProfileKey) { const fp = profiles.find((p) => p.profileKey === state.normsProfileKey); if (fp) state.animalProfileId = fp.id; } renderSetup(); renderLines(); renderNormsPanel(); renderResultsPanel(); } async function ensureMaster() { if (!recipeId) return; if (state?.exists) return; const ok = await global.WespDialog?.confirm?.("Взять состав из WESP и начать готовку?", { title: "Нам нужно готовить", }); if (ok) { await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/seed-from-execution`, { method: "POST" }); } else { await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/ensure-empty`, { method: "POST" }); } await reloadRation(); } async function initRecipeSelect() { const sel = document.getElementById("labSandboxRecipe"); if (!sel) return; const recipes = await loadRecipes(); sel.innerHTML = '' + recipes.map((r) => ``).join(""); const params = new URLSearchParams(global.location.search); const q = params.get("recipe"); if (q) { sel.value = q; recipeId = q; await reloadRation(); await ensureMaster(); } } function bindEvents() { document.getElementById("labSandboxRecipe")?.addEventListener("change", async (ev) => { recipeId = ev.target.value || null; if (!recipeId) { state = null; renderLines(); renderResultsPanel(); return; } await reloadRation(); await ensureMaster(); }); document.getElementById("labSandboxProfile")?.addEventListener("change", async (ev) => { const pid = ev.target.value; if (!pid) { if (state) state.norms = {}; renderNormsPanel(); return; } try { const profile = await api(`/api/lab/animal-profiles/${encodeURIComponent(pid)}`); if (state) { state.animalProfileId = pid; state.norms = profile.resolvedNorms || profile.normsData?.resolvedIndicators || profile.normsData?.indicators || {}; state.normsProfileKey = profile.normsProfileKey || profile.profileKey; state.legacyNormsRemapped = Boolean(profile.legacyNormsRemapped); if (profile.rationType) state.rationType = profile.rationType; } const typeSel = document.getElementById("labSandboxRationType"); if (typeSel && profile.rationType) typeSel.value = profile.rationType; renderNormsPanel(); } catch (e) { labError(e.message); } }); document.getElementById("labSandboxRationType")?.addEventListener("change", async (ev) => { await loadCatalogs(ev.target.value); renderSetup(); }); document.getElementById("labSandboxLinesBody")?.addEventListener("click", (ev) => { const btn = ev.target.closest("[data-action='remove-line']"); if (!btn || !state) return; const idx = parseInt(btn.getAttribute("data-idx"), 10); if (!Number.isNaN(idx)) { state.lines = (state.lines || []).filter((_, i) => i !== idx); renderLines(); } }); document.querySelector("[data-action='add-line']")?.addEventListener("click", () => { if (!state) state = { lines: [] }; state.lines = [ ...(state.lines || []), { componentId: "", ingredientName: "", dailyKg: 0, inRation: true, inCompound: false }, ]; renderLines(); }); document.querySelector("[data-action='save']")?.addEventListener("click", async () => { if (!recipeId) { labError("Сначала выбери партию — без этого не готовим"); return; } try { await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(collectPayload()), }); await reloadRation(); global.WespZootechNotify?.createAdapter?.()?.success?.("Запомни это — формула сохранена"); } catch (e) { labError(e.message); } }); document.querySelector("[data-action='recalculate']")?.addEventListener("click", async () => { if (!recipeId) { labError("Сначала выбери партию — науку некуда применять"); return; } try { await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(collectPayload()), }); await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/recalculate`, { method: "POST" }); await reloadRation(); global.WespZootechNotify?.createAdapter?.()?.success?.("Применена наука — 99,1% чистоты"); } catch (e) { labError(e.message); } }); document.querySelector("[data-action='seed']")?.addEventListener("click", async () => { if (!recipeId) { labError("Сначала выбери партию — нечего брать с поля"); return; } try { await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/seed-from-execution`, { method: "POST" }); await reloadRation(); } catch (e) { labError(e.message); } }); document.querySelector("[data-action='apply']")?.addEventListener("click", async () => { if (!recipeId) { labError("Сначала выбери партию — некуда отправлять"); return; } const ok = await global.WespDialog?.confirm?.("Отправить кг/сут из лаборатории в WESP?"); if (!ok) return; try { await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/apply-from-master`, { method: "POST" }); global.WespZootechNotify?.createAdapter?.()?.success?.("Отправлено в WESP. Ты чертовски прав"); } catch (e) { labError(e.message); } }); } function init() { bindEvents(); loadNormCatalog(); initRecipeSelect(); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })(window);