Initial commit: site monorepo with API, web, and infra.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
влад
2026-07-16 10:11:54 +03:00
co-authored by Cursor
commit 016910ffb7
447 changed files with 73972 additions and 0 deletions
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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}">&times;</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);