Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
(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);
|
||||
Reference in New Issue
Block a user