526 lines
19 KiB
JavaScript
526 lines
19 KiB
JavaScript
(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, ">")
|
||
.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 = ['<option value="">— реагент —</option>'];
|
||
components.forEach((c) => {
|
||
const sel = String(c.id) === String(selectedId) ? " selected" : "";
|
||
opts.push(`<option value="${escapeHtml(c.id)}"${sel}>${escapeHtml(c.name)}</option>`);
|
||
});
|
||
return opts.join("");
|
||
}
|
||
|
||
function profileOptions(selectedId) {
|
||
const opts = ['<option value="">— стандарт чистоты —</option>'];
|
||
profiles.forEach((p) => {
|
||
const sel = String(p.id) === String(selectedId) ? " selected" : "";
|
||
const key = p.profileKey ? `[${p.profileKey}] ` : "";
|
||
opts.push(
|
||
`<option value="${escapeHtml(p.id)}"${sel}>${escapeHtml(key)}${escapeHtml(p.label)} (${escapeHtml(p.rationType)})</option>`
|
||
);
|
||
});
|
||
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 '<span class="lab-sandbox-norm--empty" title="Для этого стандарта нет границы">вне стандарта</span>';
|
||
}
|
||
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 = '<div class="lab-sandbox-empty">Выбери стандарт чистоты</div>';
|
||
return;
|
||
}
|
||
let note = "";
|
||
el.innerHTML =
|
||
note +
|
||
`<dl class="lab-sandbox-norms">` +
|
||
keys
|
||
.map((key) => {
|
||
const b = norms[key] || {};
|
||
const label = INDICATOR_LABELS[key] || key;
|
||
return `<dt>${escapeHtml(label)}</dt><dd>${formatNormBounds(b)}</dd>`;
|
||
})
|
||
.join("") +
|
||
`</dl>`;
|
||
}
|
||
|
||
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 = '<div class="lab-sandbox-empty">Нажми «Примени науку»</div>';
|
||
return;
|
||
}
|
||
let html = "";
|
||
if (!Object.keys(norms).length && calcRows.length) {
|
||
html +=
|
||
'<p class="small text-warning mb-2">Для полной матрицы показателей выберите «Стандарт чистоты» и снова нажмите «Примени науку».</p>';
|
||
}
|
||
if (totals.length) {
|
||
html +=
|
||
`<p class="small text-muted mb-1">Синий — значит чистый</p><ul class="small mb-2">` +
|
||
totals.map((t) => `<li>${escapeHtml(t.label)}: ${formatNum(t.value)}</li>`).join("") +
|
||
`</ul>`;
|
||
}
|
||
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 +=
|
||
`<p class="small text-muted mb-1">Строк: ${sorted.length} (со стандартом: ${withNorm}). ≤/≥ — границы чистоты.</p>` +
|
||
`<table><thead><tr><th>Показатель</th><th>Факт</th><th>Стандарт</th><th>Δ</th></tr></thead><tbody>` +
|
||
sorted
|
||
.map((ind) => {
|
||
const dc = hasNormBounds(ind) ? diffClass(ind.diff) : "lab-sandbox-diff--na";
|
||
return (
|
||
`<tr>` +
|
||
`<td>${escapeHtml(ind.label)}</td>` +
|
||
`<td>${formatNum(ind.content)} ${escapeHtml(ind.unit || "")}</td>` +
|
||
`<td>${formatIndicatorNorm(ind)}</td>` +
|
||
`<td class="${dc}">${formatIndicatorDiff(ind)}</td>` +
|
||
`</tr>`
|
||
);
|
||
})
|
||
.join("") +
|
||
`</tbody></table>`;
|
||
}
|
||
if (state?.calculatedAt) {
|
||
html += `<p class="small text-muted mt-2 mb-0">Готово: ${escapeHtml(state.calculatedAt)}</p>`;
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function renderLines() {
|
||
const tbody = document.getElementById("labSandboxLinesBody");
|
||
if (!tbody) return;
|
||
const lines = state?.lines || [];
|
||
if (!lines.length) {
|
||
tbody.innerHTML =
|
||
`<tr><td colspan="6" class="lab-sandbox-empty">Партия пуста — добавь реагент или возьми из WESP</td></tr>`;
|
||
return;
|
||
}
|
||
tbody.innerHTML = lines
|
||
.map(
|
||
(line, i) =>
|
||
`<tr data-line-idx="${i}">` +
|
||
`<td><select class="form-select form-select-sm" data-field="componentId">${componentOptions(line.componentId)}</select></td>` +
|
||
`<td><input class="form-control form-control-sm" data-field="dailyKg" type="number" step="0.01" value="${formatKgInput(line.dailyKg)}"></td>` +
|
||
`<td class="text-center"><input type="checkbox" data-field="inRation" ${line.inRation ? "checked" : ""}></td>` +
|
||
`<td class="text-center"><input type="checkbox" data-field="inCompound" ${line.inCompound ? "checked" : ""}></td>` +
|
||
`<td class="small text-muted">${line.pricePerKg != null ? formatNum(line.pricePerKg) : "—"}</td>` +
|
||
`<td><button type="button" class="btn btn-sm btn-link text-danger p-0" data-action="remove-line" data-idx="${i}">×</button></td>` +
|
||
`</tr>`
|
||
)
|
||
.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 =
|
||
'<option value="">— партия —</option>' +
|
||
recipes.map((r) => `<option value="${escapeHtml(r.id)}">${escapeHtml(r.name)}</option>`).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);
|