Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,777 @@
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
const GFE_DYNAMIC_KEYS = ["usp", "nel"];
|
||||
|
||||
let catalog = [];
|
||||
let profiles = [];
|
||||
let selectedId = null;
|
||||
let draftNorms = {};
|
||||
let dynamicNorms = {};
|
||||
let normCoverage = null;
|
||||
let normSourceMap = {};
|
||||
let resolvedNormsPreview = {};
|
||||
let seedCatalog = [];
|
||||
let seedCatalogRation = null;
|
||||
let normsCoverageTimer = null;
|
||||
|
||||
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 escapeHtml(s) {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function notify() {
|
||||
return global.WespZootechNotify?.createAdapter?.();
|
||||
}
|
||||
|
||||
function readMass() {
|
||||
const raw = document.getElementById("labProfileMass")?.value?.trim();
|
||||
if (raw === "") return null;
|
||||
const n = Number(raw);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
function readMilk() {
|
||||
const raw = document.getElementById("labProfileMilk")?.value?.trim();
|
||||
if (raw === "") return null;
|
||||
const n = Number(raw);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
function readNumInput(id) {
|
||||
const raw = document.getElementById(id)?.value?.trim();
|
||||
if (raw === "") return null;
|
||||
const n = Number(raw);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
function readNormsMethod() {
|
||||
return document.getElementById("labProfileNormsMethod")?.value || "wesp";
|
||||
}
|
||||
|
||||
function readNormsParams() {
|
||||
const method = readNormsMethod();
|
||||
if (method === "wesp") return {};
|
||||
const params = {
|
||||
milkFatPct: readNumInput("labProfileFat") ?? 4,
|
||||
lactationNo: readNumInput("labProfileLactation") ?? 2,
|
||||
};
|
||||
if (method === "racion_piter") {
|
||||
params.koncOeSv = readNumInput("labProfileKonc") ?? 10.3;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function syncNormsMethodUi() {
|
||||
const method = readNormsMethod();
|
||||
const racionBox = document.getElementById("labProfileRacionFields");
|
||||
const piterOnly = document.querySelectorAll(".lab-profile-piter-only");
|
||||
if (racionBox) racionBox.classList.toggle("d-none", method === "wesp");
|
||||
piterOnly.forEach((el) => el.classList.toggle("d-none", method !== "racion_piter"));
|
||||
syncSourceFilterOptions(method);
|
||||
renderNormsSummary();
|
||||
scheduleNormsCoverageRefresh();
|
||||
}
|
||||
|
||||
function syncSourceFilterOptions(method) {
|
||||
const sel = document.getElementById("labProfilesSourceFilter");
|
||||
if (!sel) return;
|
||||
sel.querySelectorAll("option[data-racion-only]").forEach((opt) => {
|
||||
opt.hidden = method === "wesp";
|
||||
opt.disabled = method === "wesp";
|
||||
});
|
||||
if (method === "wesp" && ["racion", "racion_only", "derived", "new_racion"].includes(sel.value)) {
|
||||
sel.value = "all";
|
||||
}
|
||||
}
|
||||
|
||||
function buildNormSourceMap(coverage, storedNorms) {
|
||||
const map = {};
|
||||
if (coverage) {
|
||||
(coverage.racion || []).forEach((k) => {
|
||||
map[k] = "racion";
|
||||
});
|
||||
(coverage.derived || []).forEach((k) => {
|
||||
map[k] = "derived";
|
||||
});
|
||||
(coverage.fallback || []).forEach((k) => {
|
||||
if (!map[k]) map[k] = "fallback";
|
||||
});
|
||||
(coverage.missing || []).forEach((k) => {
|
||||
if (!map[k]) map[k] = "missing";
|
||||
});
|
||||
}
|
||||
Object.keys(storedNorms || {}).forEach((k) => {
|
||||
if (!map[k]) map[k] = "stored";
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
function normSourceLabel(src) {
|
||||
const labels = {
|
||||
racion: "RACION",
|
||||
derived: "расч.",
|
||||
fallback: "WESP",
|
||||
stored: "БД",
|
||||
missing: "—",
|
||||
gfe: "GfE",
|
||||
};
|
||||
return labels[src] || "—";
|
||||
}
|
||||
|
||||
function normSourceBadge(src) {
|
||||
if (!src || src === "missing") return '<span class="norm-source-badge norm-source-badge--empty">—</span>';
|
||||
const cls = `norm-source-badge norm-source-badge--${src}`;
|
||||
return `<span class="${cls}" title="${escapeHtml(normSourceLabel(src))}">${escapeHtml(normSourceLabel(src))}</span>`;
|
||||
}
|
||||
|
||||
function isInDb(key) {
|
||||
const b = draftNorms[key] || {};
|
||||
return b.min != null || b.max != null;
|
||||
}
|
||||
|
||||
function matchesSourceFilter(key) {
|
||||
const filter = document.getElementById("labProfilesSourceFilter")?.value || "all";
|
||||
if (filter === "all") return true;
|
||||
const src = normSourceMap[key];
|
||||
if (filter === "in_db") return isInDb(key);
|
||||
if (filter === "seed") return src === "fallback" || src === "stored";
|
||||
if (filter === "racion") return src === "racion" || src === "derived";
|
||||
if (filter === "racion_only") return src === "racion";
|
||||
if (filter === "derived") return src === "derived";
|
||||
if (filter === "new_racion") return src === "racion" || src === "derived";
|
||||
return true;
|
||||
}
|
||||
|
||||
function countNormSources() {
|
||||
const counts = { inDb: 0, racion: 0, derived: 0, fallback: 0, stored: 0, missing: 0 };
|
||||
const seen = new Set();
|
||||
catalog.forEach((c) => seen.add(c.key));
|
||||
Object.keys(draftNorms).forEach((k) => seen.add(k));
|
||||
if (normCoverage) {
|
||||
(normCoverage.racion || []).forEach((k) => seen.add(k));
|
||||
(normCoverage.derived || []).forEach((k) => seen.add(k));
|
||||
(normCoverage.fallback || []).forEach((k) => seen.add(k));
|
||||
(normCoverage.missing || []).forEach((k) => seen.add(k));
|
||||
}
|
||||
seen.forEach((key) => {
|
||||
if (isInDb(key)) counts.inDb += 1;
|
||||
const src = normSourceMap[key];
|
||||
if (src === "racion") counts.racion += 1;
|
||||
else if (src === "derived") counts.derived += 1;
|
||||
else if (src === "fallback") counts.fallback += 1;
|
||||
else if (src === "stored") counts.stored += 1;
|
||||
else if (src === "missing") counts.missing += 1;
|
||||
});
|
||||
return counts;
|
||||
}
|
||||
|
||||
function renderNormsSummary() {
|
||||
const panel = document.getElementById("labProfilesNormsSummary");
|
||||
const text = document.getElementById("labProfilesNormsSummaryText");
|
||||
const form = document.getElementById("labProfilesForm");
|
||||
if (!panel || !text || !form || form.hidden) {
|
||||
if (panel) panel.hidden = true;
|
||||
return;
|
||||
}
|
||||
const method = readNormsMethod();
|
||||
const counts = countNormSources();
|
||||
if (method === "wesp") {
|
||||
panel.hidden = false;
|
||||
text.textContent = `В БД ${counts.inDb} показ. · справочник/ручные ${counts.stored + counts.fallback}`;
|
||||
return;
|
||||
}
|
||||
if (!normCoverage) {
|
||||
panel.hidden = false;
|
||||
text.textContent = "Укажи массу и удой — разберём источники RACION";
|
||||
return;
|
||||
}
|
||||
panel.hidden = false;
|
||||
const racTotal = counts.racion + counts.derived;
|
||||
const withMin = normCoverage.withMin != null ? normCoverage.withMin : racTotal + counts.fallback;
|
||||
text.textContent =
|
||||
`В БД ${counts.inDb} · RACION ${racTotal} (NPitV ${counts.racion} + расч. ${counts.derived}) · ` +
|
||||
`WESP fallback ${counts.fallback} · с min ${withMin}/${normCoverage.total ?? "—"}`;
|
||||
}
|
||||
|
||||
function applyNormsCoveragePayload(payload) {
|
||||
normCoverage = payload.coverage || null;
|
||||
resolvedNormsPreview = payload.resolvedIndicators || {};
|
||||
if (payload.dynamicNorms) {
|
||||
dynamicNorms = { ...dynamicNorms, ...payload.dynamicNorms };
|
||||
}
|
||||
normSourceMap = buildNormSourceMap(normCoverage, draftNorms);
|
||||
renderNormsSummary();
|
||||
renderNormsTable();
|
||||
}
|
||||
|
||||
async function refreshNormsCoverage() {
|
||||
const method = readNormsMethod();
|
||||
const mass = readMass();
|
||||
const milk = readMilk();
|
||||
if (method === "wesp") {
|
||||
normCoverage = null;
|
||||
resolvedNormsPreview = {};
|
||||
normSourceMap = buildNormSourceMap(null, draftNorms);
|
||||
GFE_DYNAMIC_KEYS.forEach((key) => {
|
||||
if (dynamicNorms[key]?.min != null && !isInDb(key)) normSourceMap[key] = "gfe";
|
||||
});
|
||||
renderNormsSummary();
|
||||
renderNormsTable();
|
||||
return;
|
||||
}
|
||||
if (mass == null || milk == null) {
|
||||
normCoverage = null;
|
||||
normSourceMap = buildNormSourceMap(null, draftNorms);
|
||||
renderNormsSummary();
|
||||
renderNormsTable();
|
||||
return;
|
||||
}
|
||||
const q = new URLSearchParams({ method, mass_kg: String(mass), milk_yield_kg: String(milk) });
|
||||
if (selectedId) q.set("profile_id", selectedId);
|
||||
const np = readNormsParams();
|
||||
if (np.milkFatPct != null) q.set("milk_fat_pct", String(np.milkFatPct));
|
||||
if (np.lactationNo != null) q.set("lactation_no", String(np.lactationNo));
|
||||
if (np.koncOeSv != null) q.set("konc_oe_sv", String(np.koncOeSv));
|
||||
try {
|
||||
const preview = await api(`/api/lab/norms-preview?${q.toString()}`);
|
||||
applyNormsCoveragePayload(preview);
|
||||
} catch (err) {
|
||||
notify()?.error?.(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNormsCoverageRefresh() {
|
||||
if (normsCoverageTimer) clearTimeout(normsCoverageTimer);
|
||||
normsCoverageTimer = setTimeout(() => {
|
||||
refreshNormsCoverage().catch((e) => notify()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" }));
|
||||
}, 350);
|
||||
}
|
||||
|
||||
function fillNormsParamsFromProfile(p) {
|
||||
const method = p.normsMethod || "wesp";
|
||||
const sel = document.getElementById("labProfileNormsMethod");
|
||||
if (sel) sel.value = method;
|
||||
const np = p.normsParams || {};
|
||||
const fat = document.getElementById("labProfileFat");
|
||||
const lact = document.getElementById("labProfileLactation");
|
||||
const konc = document.getElementById("labProfileKonc");
|
||||
if (fat) fat.value = np.milkFatPct != null ? np.milkFatPct : 4;
|
||||
if (lact) lact.value = np.lactationNo != null ? np.lactationNo : 2;
|
||||
if (konc) konc.value = np.koncOeSv != null ? np.koncOeSv : 10.3;
|
||||
syncNormsMethodUi();
|
||||
}
|
||||
|
||||
/** GfE 2001 — parity app/lab/calc/gfe_norms.py */
|
||||
function gfeUspMinG(massKg, milkKg) {
|
||||
if (!massKg || massKg <= 0) return null;
|
||||
return 0.09 * massKg ** 0.75 * 6.25 + Math.max(milkKg || 0, 0) * 85;
|
||||
}
|
||||
|
||||
function gfeNelMinMj(massKg, milkKg) {
|
||||
if (!massKg || massKg <= 0) return null;
|
||||
return 0.293 * massKg ** 0.75 + Math.max(milkKg || 0, 0) * 3.3;
|
||||
}
|
||||
|
||||
function computeDynamicNormsLocal() {
|
||||
const mass = readMass();
|
||||
const milk = readMilk();
|
||||
const out = {};
|
||||
const usp = gfeUspMinG(mass, milk);
|
||||
if (usp != null) {
|
||||
out.usp = { min: usp, formula: "0.09×масса^0.75×6.25 + удой×85" };
|
||||
}
|
||||
const nel = gfeNelMinMj(mass, milk);
|
||||
if (nel != null) {
|
||||
out.nel = { min: nel, formula: "0.293×масса^0.75 + удой×3.3" };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatGfe(v) {
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return "—";
|
||||
return Math.abs(n) >= 100 ? n.toFixed(1) : n.toFixed(2);
|
||||
}
|
||||
|
||||
function refreshDynamicNorms() {
|
||||
dynamicNorms = computeDynamicNormsLocal();
|
||||
renderGfePanel();
|
||||
renderNormsTable();
|
||||
}
|
||||
|
||||
function renderGfePanel() {
|
||||
const panel = document.getElementById("labProfilesGfePanel");
|
||||
const vals = document.getElementById("labProfilesGfeValues");
|
||||
const form = document.getElementById("labProfilesForm");
|
||||
if (!panel || !form || form.hidden) {
|
||||
if (panel) panel.hidden = true;
|
||||
return;
|
||||
}
|
||||
const mass = readMass();
|
||||
if (!mass || mass <= 0) {
|
||||
panel.hidden = true;
|
||||
return;
|
||||
}
|
||||
panel.hidden = false;
|
||||
const parts = [];
|
||||
if (dynamicNorms.usp?.min != null) {
|
||||
parts.push(`min уСП ≈ ${formatGfe(dynamicNorms.usp.min)} г`);
|
||||
}
|
||||
if (dynamicNorms.nel?.min != null) {
|
||||
parts.push(`min ЧЭЛ ≈ ${formatGfe(dynamicNorms.nel.min)} МДж`);
|
||||
}
|
||||
if (vals) vals.textContent = parts.join(" · ") || "—";
|
||||
const enabled = parts.length > 0;
|
||||
["labProfilesGfeApplyUsp", "labProfilesGfeApplyNel", "labProfilesGfeApplyAll"].forEach((id) => {
|
||||
const btn = document.getElementById(id);
|
||||
if (btn) btn.disabled = !enabled;
|
||||
});
|
||||
}
|
||||
|
||||
function applyGfeMin(keys) {
|
||||
collectNormsFromTable();
|
||||
let applied = 0;
|
||||
keys.forEach((key) => {
|
||||
const v = dynamicNorms[key]?.min;
|
||||
if (v == null) return;
|
||||
const cur = draftNorms[key] || {};
|
||||
if (cur.min != null) return;
|
||||
draftNorms[key] = { ...cur, min: v };
|
||||
applied += 1;
|
||||
});
|
||||
renderNormsTable();
|
||||
if (applied) {
|
||||
notify()?.success?.("Подставлено по GfE — нажми «Запомни это»");
|
||||
} else {
|
||||
notify()?.error?.("Нижние границы уже заданы или нет массы");
|
||||
}
|
||||
}
|
||||
|
||||
function syncDeleteButton() {
|
||||
const btn = document.getElementById("labProfilesDelete");
|
||||
if (btn) btn.hidden = !selectedId;
|
||||
}
|
||||
|
||||
function readRationType() {
|
||||
return document.getElementById("labProfileType")?.value || "DAIRY";
|
||||
}
|
||||
|
||||
function syncSeedPanelVisibility() {
|
||||
const panel = document.getElementById("labProfilesSeedPanel");
|
||||
const form = document.getElementById("labProfilesForm");
|
||||
if (panel) panel.hidden = !form || form.hidden;
|
||||
}
|
||||
|
||||
function filteredSeedCatalog() {
|
||||
const q = (document.getElementById("labProfilesSeedSearch")?.value || "").trim().toLowerCase();
|
||||
if (!q) return seedCatalog;
|
||||
return seedCatalog.filter((e) => {
|
||||
const hay = `${e.externalNo} ${e.label} ${e.massKg ?? ""}`.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSeedSelect() {
|
||||
const sel = document.getElementById("labProfilesSeedSelect");
|
||||
const applyBtn = document.getElementById("labProfilesSeedApply");
|
||||
if (!sel) return;
|
||||
const prev = sel.value;
|
||||
const rows = filteredSeedCatalog();
|
||||
const opts = ['<option value="">— выбери строку —</option>'].concat(
|
||||
rows.map((e) => {
|
||||
const mass = e.massKg != null ? ` · ${e.massKg} кг` : "";
|
||||
const cnt = e.indicatorCount != null ? ` · ${e.indicatorCount} показ.` : "";
|
||||
return `<option value="${escapeHtml(String(e.externalNo))}">№${escapeHtml(String(e.externalNo))} — ${escapeHtml(e.label)}${escapeHtml(mass)}${escapeHtml(cnt)}</option>`;
|
||||
})
|
||||
);
|
||||
sel.innerHTML = opts.join("");
|
||||
if (prev && rows.some((e) => String(e.externalNo) === prev)) {
|
||||
sel.value = prev;
|
||||
}
|
||||
if (applyBtn) applyBtn.disabled = !sel.value;
|
||||
}
|
||||
|
||||
async function loadSeedCatalog(rationType) {
|
||||
const ration = rationType || readRationType();
|
||||
if (seedCatalogRation === ration && seedCatalog.length) {
|
||||
renderSeedSelect();
|
||||
return;
|
||||
}
|
||||
const data = await api(`/api/lab/seed-norms-catalog?ration_type=${encodeURIComponent(ration)}`);
|
||||
seedCatalog = data.entries || [];
|
||||
seedCatalogRation = ration;
|
||||
renderSeedSelect();
|
||||
}
|
||||
|
||||
async function applySeedNorms() {
|
||||
const sel = document.getElementById("labProfilesSeedSelect");
|
||||
const externalNo = sel?.value;
|
||||
if (!externalNo) {
|
||||
notify()?.error?.("Выбери строку справочника");
|
||||
return;
|
||||
}
|
||||
const filled = Object.values(draftNorms).filter((b) => b.min != null || b.max != null).length;
|
||||
if (filled > 0) {
|
||||
const ok = global.confirm("Заменить текущие границы нормами из справочника?");
|
||||
if (!ok) return;
|
||||
}
|
||||
const ration = readRationType();
|
||||
const entry = await api(
|
||||
`/api/lab/seed-norms-catalog/${encodeURIComponent(externalNo)}?ration_type=${encodeURIComponent(ration)}`
|
||||
);
|
||||
draftNorms = { ...(entry.indicators || {}) };
|
||||
if (entry.massKg != null) {
|
||||
document.getElementById("labProfileMass").value = entry.massKg;
|
||||
}
|
||||
document.getElementById("labProfileExternal").value = entry.externalNo;
|
||||
const labelEl = document.getElementById("labProfileLabel");
|
||||
if (labelEl && !labelEl.value.trim()) {
|
||||
labelEl.value = entry.label || "";
|
||||
}
|
||||
refreshDynamicNorms();
|
||||
notify()?.success?.(`Загружено ${Object.keys(draftNorms).length} показателей — нажми «Запомни это»`);
|
||||
}
|
||||
|
||||
function filteredProfiles() {
|
||||
const type = document.getElementById("labProfilesType")?.value || "";
|
||||
const q = (document.getElementById("labProfilesSearch")?.value || "").trim().toLowerCase();
|
||||
return profiles.filter((p) => {
|
||||
if (type && p.rationType !== type) return false;
|
||||
if (!q) return true;
|
||||
const hay = `${p.profileKey} ${p.label}`.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const el = document.getElementById("labProfilesList");
|
||||
if (!el) return;
|
||||
const rows = filteredProfiles();
|
||||
if (!rows.length) {
|
||||
el.innerHTML = '<div class="lab-sandbox-empty">Стандартов нет — создай новый</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = rows
|
||||
.map(
|
||||
(p) =>
|
||||
`<button type="button" class="${p.id === selectedId ? "is-active" : ""}" data-id="${escapeHtml(p.id)}">` +
|
||||
`${escapeHtml(p.label)}` +
|
||||
`<span class="profile-key">${escapeHtml(p.profileKey)} · ${escapeHtml(p.rationType)}</span>` +
|
||||
`</button>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function indicatorRows() {
|
||||
const onlyCalc = document.getElementById("labProfilesOnlyCalc")?.checked;
|
||||
const onlyFilled = document.getElementById("labProfilesOnlyFilled")?.checked;
|
||||
const q = (document.getElementById("labProfilesNormSearch")?.value || "").trim().toLowerCase();
|
||||
const extraKeys = Object.keys(draftNorms).filter((k) => !catalog.some((c) => c.key === k));
|
||||
const rows = [
|
||||
...catalog.map((c) => ({ ...c })),
|
||||
...extraKeys.map((k) => ({ key: k, label: k, unit: "", inCalc: false })),
|
||||
];
|
||||
return rows.filter((row) => {
|
||||
if (onlyCalc && !row.inCalc) return false;
|
||||
const b = draftNorms[row.key] || {};
|
||||
const has = b.min != null || b.max != null;
|
||||
if (onlyFilled && !has) return false;
|
||||
if (!matchesSourceFilter(row.key)) return false;
|
||||
if (q) {
|
||||
const hay = `${row.key} ${row.label}`.toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function renderNormsTable() {
|
||||
const tbody = document.getElementById("labProfilesNormsBody");
|
||||
const countEl = document.getElementById("labProfilesNormCount");
|
||||
if (!tbody) return;
|
||||
const rows = indicatorRows();
|
||||
tbody.innerHTML = rows
|
||||
.map((row) => {
|
||||
const b = draftNorms[row.key] || {};
|
||||
const min = b.min != null ? b.min : "";
|
||||
const max = b.max != null ? b.max : "";
|
||||
const badge = row.inCalc ? '<span class="norm-calc-badge ms-1">в деле</span>' : "";
|
||||
const unit = row.unit ? ` <span class="text-muted">${escapeHtml(row.unit)}</span>` : "";
|
||||
const dyn = dynamicNorms[row.key];
|
||||
const gfeHint =
|
||||
dyn?.min != null && min === ""
|
||||
? `<small class="text-muted d-block">GfE ≈ ${formatGfe(dyn.min)}${dyn.formula ? ` (${escapeHtml(dyn.formula)})` : ""}</small>`
|
||||
: "";
|
||||
const gfeBadge = GFE_DYNAMIC_KEYS.includes(row.key)
|
||||
? '<span class="norm-calc-badge ms-1" title="min по GfE 2001, если нижняя пуста">GfE</span>'
|
||||
: "";
|
||||
let src = normSourceMap[row.key];
|
||||
if (!src && isInDb(row.key)) src = "stored";
|
||||
const dbBadge = isInDb(row.key) ? '<span class="norm-source-badge norm-source-badge--db" title="Сохранено в БД">БД</span>' : "";
|
||||
const srcBadge = normSourceBadge(src);
|
||||
const resolved = resolvedNormsPreview[row.key];
|
||||
const resolvedHint =
|
||||
resolved?.min != null && min !== "" && Number(min) !== Number(resolved.min)
|
||||
? `<small class="text-muted d-block">RACION ≈ ${formatGfe(resolved.min)}</small>`
|
||||
: resolved?.min != null && min === ""
|
||||
? `<small class="text-muted d-block">RACION ≈ ${formatGfe(resolved.min)} (не в БД)</small>`
|
||||
: "";
|
||||
return (
|
||||
`<tr data-key="${escapeHtml(row.key)}">` +
|
||||
`<td>${escapeHtml(row.label)}${badge}${gfeBadge}${unit}${gfeHint}${resolvedHint}</td>` +
|
||||
`<td class="lab-profiles-source-cell">${dbBadge}${srcBadge}</td>` +
|
||||
`<td><input type="number" class="form-control form-control-sm" data-bound="min" step="any" value="${min}"` +
|
||||
`${dyn?.min != null && min === "" ? ` placeholder="${formatGfe(dyn.min)}"` : ""}></td>` +
|
||||
`<td><input type="number" class="form-control form-control-sm" data-bound="max" step="any" value="${max}"></td>` +
|
||||
`</tr>`
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
if (countEl) {
|
||||
const filled = Object.values(draftNorms).filter((b) => b.min != null || b.max != null).length;
|
||||
const counts = countNormSources();
|
||||
const method = readNormsMethod();
|
||||
const srcPart =
|
||||
method === "wesp"
|
||||
? ` · в БД ${counts.inDb}`
|
||||
: ` · БД ${counts.inDb} · RACION ${counts.racion + counts.derived} · WESP ${counts.fallback}`;
|
||||
countEl.textContent = `В таблице ${rows.length} · с границами ${filled}${srcPart}`;
|
||||
}
|
||||
renderNormsSummary();
|
||||
}
|
||||
|
||||
function collectNormsFromTable() {
|
||||
const out = { ...draftNorms };
|
||||
document.querySelectorAll("#labProfilesNormsBody tr[data-key]").forEach((tr) => {
|
||||
const key = tr.getAttribute("data-key");
|
||||
if (!key) return;
|
||||
const minIn = tr.querySelector('[data-bound="min"]');
|
||||
const maxIn = tr.querySelector('[data-bound="max"]');
|
||||
const minRaw = minIn?.value?.trim();
|
||||
const maxRaw = maxIn?.value?.trim();
|
||||
const min = minRaw === "" ? null : Number(minRaw);
|
||||
const max = maxRaw === "" ? null : Number(maxRaw);
|
||||
if (min == null && max == null) {
|
||||
delete out[key];
|
||||
} else {
|
||||
out[key] = {
|
||||
min: Number.isNaN(min) ? null : min,
|
||||
max: Number.isNaN(max) ? null : max,
|
||||
};
|
||||
}
|
||||
});
|
||||
draftNorms = out;
|
||||
}
|
||||
|
||||
async function selectProfile(id) {
|
||||
selectedId = id;
|
||||
syncDeleteButton();
|
||||
renderList();
|
||||
const empty = document.getElementById("labProfilesEmpty");
|
||||
const form = document.getElementById("labProfilesForm");
|
||||
if (!id) {
|
||||
if (empty) empty.hidden = false;
|
||||
if (form) form.hidden = true;
|
||||
syncSeedPanelVisibility();
|
||||
return;
|
||||
}
|
||||
const p = await api(`/api/lab/animal-profiles/${encodeURIComponent(id)}`);
|
||||
if (empty) empty.hidden = true;
|
||||
if (form) form.hidden = false;
|
||||
document.getElementById("labProfileKey").value = p.profileKey || "";
|
||||
document.getElementById("labProfileLabel").value = p.label || "";
|
||||
document.getElementById("labProfileType").value = p.rationType || "BEEF";
|
||||
document.getElementById("labProfileMass").value = p.massKg != null ? p.massKg : "";
|
||||
document.getElementById("labProfileMilk").value = p.milkYieldKg != null ? p.milkYieldKg : "";
|
||||
document.getElementById("labProfileExternal").value = p.externalNo != null ? p.externalNo : "";
|
||||
fillNormsParamsFromProfile(p);
|
||||
draftNorms = { ...(p.norms || p.normsData?.indicators || {}) };
|
||||
normCoverage = p.coverage || p.normsData?.coverage || null;
|
||||
resolvedNormsPreview = p.resolvedNorms || p.normsData?.resolvedIndicators || {};
|
||||
normSourceMap = buildNormSourceMap(normCoverage, draftNorms);
|
||||
refreshDynamicNorms();
|
||||
syncSourceFilterOptions(readNormsMethod());
|
||||
renderNormsSummary();
|
||||
scheduleNormsCoverageRefresh();
|
||||
syncSeedPanelVisibility();
|
||||
loadSeedCatalog(p.rationType || "DAIRY").catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
|
||||
}
|
||||
|
||||
function newProfile() {
|
||||
selectedId = null;
|
||||
syncDeleteButton();
|
||||
renderList();
|
||||
document.getElementById("labProfilesEmpty").hidden = true;
|
||||
document.getElementById("labProfilesForm").hidden = false;
|
||||
document.getElementById("labProfileKey").value = "";
|
||||
document.getElementById("labProfileLabel").value = "";
|
||||
document.getElementById("labProfileType").value = "DAIRY";
|
||||
document.getElementById("labProfileMass").value = "";
|
||||
document.getElementById("labProfileMilk").value = "";
|
||||
document.getElementById("labProfileExternal").value = "";
|
||||
fillNormsParamsFromProfile({ normsMethod: "wesp", normsParams: {} });
|
||||
draftNorms = {};
|
||||
normCoverage = null;
|
||||
resolvedNormsPreview = {};
|
||||
normSourceMap = {};
|
||||
refreshDynamicNorms();
|
||||
syncSourceFilterOptions("wesp");
|
||||
renderNormsSummary();
|
||||
syncSeedPanelVisibility();
|
||||
loadSeedCatalog("DAIRY").catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
collectNormsFromTable();
|
||||
const key = document.getElementById("labProfileKey")?.value?.trim();
|
||||
const label = document.getElementById("labProfileLabel")?.value?.trim();
|
||||
const rationType = document.getElementById("labProfileType")?.value || "BEEF";
|
||||
const massRaw = document.getElementById("labProfileMass")?.value?.trim();
|
||||
const milkRaw = document.getElementById("labProfileMilk")?.value?.trim();
|
||||
const extRaw = document.getElementById("labProfileExternal")?.value?.trim();
|
||||
if (!key || !label) {
|
||||
notify()?.error?.("Укажи код и название — я предупреждал");
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
profileKey: key,
|
||||
label,
|
||||
rationType,
|
||||
indicators: draftNorms,
|
||||
normsMethod: readNormsMethod(),
|
||||
normsParams: readNormsParams(),
|
||||
};
|
||||
if (massRaw !== "") payload.massKg = Number(massRaw);
|
||||
payload.milkYieldKg = milkRaw === "" ? null : Number(milkRaw);
|
||||
if (extRaw !== "") payload.externalNo = Number(extRaw);
|
||||
const method = selectedId ? "PUT" : "POST";
|
||||
const url = selectedId
|
||||
? `/api/lab/animal-profiles/${encodeURIComponent(selectedId)}`
|
||||
: "/api/lab/animal-profiles";
|
||||
await api(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
notify()?.success?.("Стандарт сохранён. Ты чертовски прав");
|
||||
const typeFilter = document.getElementById("labProfilesType")?.value || "";
|
||||
const data = await api(
|
||||
`/api/lab/animal-profiles${typeFilter ? `?ration_type=${encodeURIComponent(typeFilter)}` : ""}`
|
||||
);
|
||||
profiles = data.profiles || [];
|
||||
if (!selectedId && profiles.length) {
|
||||
const hit = profiles.find((p) => p.profileKey === key);
|
||||
if (hit) selectedId = hit.id;
|
||||
}
|
||||
renderList();
|
||||
if (selectedId) await selectProfile(selectedId);
|
||||
}
|
||||
|
||||
async function deleteProfile() {
|
||||
if (!selectedId) return;
|
||||
const label = document.getElementById("labProfileLabel")?.value?.trim() || selectedId;
|
||||
if (!global.confirm(`Удалить «${label}»? Никаких полумер.`)) return;
|
||||
await api(`/api/lab/animal-profiles/${encodeURIComponent(selectedId)}`, { method: "DELETE" });
|
||||
notify()?.success?.("Стандарт снесён. Say my name.");
|
||||
selectedId = null;
|
||||
syncDeleteButton();
|
||||
const typeFilter = document.getElementById("labProfilesType")?.value || "";
|
||||
const data = await api(
|
||||
`/api/lab/animal-profiles${typeFilter ? `?ration_type=${encodeURIComponent(typeFilter)}` : ""}`
|
||||
);
|
||||
profiles = data.profiles || [];
|
||||
document.getElementById("labProfilesEmpty").hidden = false;
|
||||
document.getElementById("labProfilesForm").hidden = true;
|
||||
renderList();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const [cat, prof] = await Promise.all([
|
||||
api("/api/lab/norm-indicators"),
|
||||
api("/api/lab/animal-profiles"),
|
||||
]);
|
||||
catalog = cat.indicators || [];
|
||||
profiles = prof.profiles || [];
|
||||
renderList();
|
||||
}
|
||||
|
||||
function bind() {
|
||||
document.getElementById("labProfilesList")?.addEventListener("click", (ev) => {
|
||||
const btn = ev.target.closest("button[data-id]");
|
||||
if (!btn) return;
|
||||
selectProfile(btn.getAttribute("data-id")).catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
|
||||
});
|
||||
document.getElementById("labProfilesType")?.addEventListener("change", async () => {
|
||||
const type = document.getElementById("labProfilesType")?.value || "";
|
||||
const data = await api(
|
||||
`/api/lab/animal-profiles${type ? `?ration_type=${encodeURIComponent(type)}` : ""}`
|
||||
);
|
||||
profiles = data.profiles || [];
|
||||
renderList();
|
||||
});
|
||||
document.getElementById("labProfilesSearch")?.addEventListener("input", renderList);
|
||||
document.getElementById("labProfilesOnlyFilled")?.addEventListener("change", renderNormsTable);
|
||||
document.getElementById("labProfilesOnlyCalc")?.addEventListener("change", renderNormsTable);
|
||||
document.getElementById("labProfilesSourceFilter")?.addEventListener("change", renderNormsTable);
|
||||
document.getElementById("labProfilesNormSearch")?.addEventListener("input", renderNormsTable);
|
||||
document.getElementById("labProfilesNormsRefresh")?.addEventListener("click", () => {
|
||||
refreshNormsCoverage().catch((e) => notify()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" }));
|
||||
});
|
||||
document.getElementById("labProfilesNew")?.addEventListener("click", newProfile);
|
||||
document.getElementById("labProfilesSave")?.addEventListener("click", () => {
|
||||
saveProfile().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
|
||||
});
|
||||
document.getElementById("labProfilesDelete")?.addEventListener("click", () => {
|
||||
deleteProfile().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
|
||||
});
|
||||
document.getElementById("labProfilesNormsBody")?.addEventListener("change", (ev) => {
|
||||
if (ev.target.matches("input[data-bound]")) collectNormsFromTable();
|
||||
});
|
||||
document.getElementById("labProfileMass")?.addEventListener("input", () => {
|
||||
refreshDynamicNorms();
|
||||
scheduleNormsCoverageRefresh();
|
||||
});
|
||||
document.getElementById("labProfileMilk")?.addEventListener("input", () => {
|
||||
refreshDynamicNorms();
|
||||
scheduleNormsCoverageRefresh();
|
||||
});
|
||||
document.getElementById("labProfileNormsMethod")?.addEventListener("change", syncNormsMethodUi);
|
||||
["labProfileFat", "labProfileLactation", "labProfileKonc"].forEach((id) => {
|
||||
document.getElementById(id)?.addEventListener("input", scheduleNormsCoverageRefresh);
|
||||
});
|
||||
document.getElementById("labProfileType")?.addEventListener("change", () => {
|
||||
seedCatalogRation = null;
|
||||
loadSeedCatalog(readRationType()).catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
|
||||
});
|
||||
document.getElementById("labProfilesSeedSearch")?.addEventListener("input", renderSeedSelect);
|
||||
document.getElementById("labProfilesSeedSelect")?.addEventListener("change", () => {
|
||||
const applyBtn = document.getElementById("labProfilesSeedApply");
|
||||
const sel = document.getElementById("labProfilesSeedSelect");
|
||||
if (applyBtn && sel) applyBtn.disabled = !sel.value;
|
||||
});
|
||||
document.getElementById("labProfilesSeedApply")?.addEventListener("click", () => {
|
||||
applySeedNorms().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
|
||||
});
|
||||
document.getElementById("labProfilesGfeApplyUsp")?.addEventListener("click", () => applyGfeMin(["usp"]));
|
||||
document.getElementById("labProfilesGfeApplyNel")?.addEventListener("click", () => applyGfeMin(["nel"]));
|
||||
document.getElementById("labProfilesGfeApplyAll")?.addEventListener("click", () =>
|
||||
applyGfeMin(GFE_DYNAMIC_KEYS)
|
||||
);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
bind();
|
||||
init().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
|
||||
});
|
||||
})(window);
|
||||
Reference in New Issue
Block a user