Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
@@ -0,0 +1,676 @@
(function (global) {
"use strict";
const CREATE_NEW = "__create_new__";
const ACCEPT_EXT = [".xml", ".pdf", ".xlsx", ".xls"];
let parseResult = null;
let components = [];
let selectedFile = null;
const WARN_LABELS = {
omd_from_tdn: "ВРХ через TDN — не lab-grade",
omd_missing: "нет ВРХ — подставим дефолт",
dm_missing: "нет СВ — chemistry on hold",
};
function $(id) {
return document.getElementById(id);
}
function showEl(el) {
if (!el) return;
el.hidden = false;
el.classList.remove("lab-agrostar-reveal");
requestAnimationFrame(() => el.classList.add("lab-agrostar-reveal"));
}
function hideEl(el) {
if (!el) return;
el.hidden = true;
el.classList.remove("lab-agrostar-reveal");
}
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function importKind() {
return parseResult?.kind || "lab_samples";
}
function isLabSamples() {
return importKind() === "lab_samples";
}
async function api(path, opts) {
const resp = await fetch(path, opts);
const data = await resp.json().catch(() => ({}));
if (!resp.ok || data.error) {
throw new Error(data.message || `Наука, чёрт возьми — ошибка ${resp.status}`);
}
return data;
}
async function loadComponents(force) {
if (components.length && !force) return components;
const rows = await api("/api/components?limit=2000");
components = Array.isArray(rows) ? rows : [];
return components;
}
function getSampleByNo(sampleNo) {
return (parseResult?.samples || []).find((s) => s.sampleNo === sampleNo);
}
function buildComponentOptions(matchSource, selectedId) {
const suggested = matchSource?.suggestedComponents || [];
const seen = new Set();
const opts = [
'<option value="">— какой реагент? —</option>',
`<option value="${CREATE_NEW}">+ добавить новый реагент</option>`,
];
for (const m of suggested) {
if (!m.componentId || seen.has(m.componentId)) continue;
seen.add(m.componentId);
const sel = m.componentId === selectedId ? " selected" : "";
opts.push(
`<option value="${escapeHtml(m.componentId)}"${sel}>${escapeHtml(m.name)} (${Math.round(m.score * 100)}%)</option>`,
);
}
for (const c of components) {
if (!c.id || seen.has(c.id)) continue;
const sel = c.id === selectedId ? " selected" : "";
opts.push(`<option value="${escapeHtml(c.id)}"${sel}>${escapeHtml(c.name)}</option>`);
}
return opts.join("");
}
function selectComponentForSample(sampleNo, componentId) {
const sel = document.querySelector(
`.lab-agrostar-component[data-sample-no="${CSS.escape(sampleNo)}"]`,
);
const sample = getSampleByNo(sampleNo);
if (!sel || !sample) return;
sel.innerHTML = buildComponentOptions(sample, componentId);
sel.value = componentId;
}
function formatUnsupportedNotice(storage) {
if (!storage?.unsupportedCount) return "";
return storage.unsupportedMessage || `${storage.unsupportedCount} показат. AgroStar без полей в WESP`;
}
function notifyCatalogChanged() {
try {
global.dispatchEvent(new CustomEvent("wesp:components-catalog-changed"));
} catch {
/* ignore */
}
}
async function createComponentFromSample(sampleNo, sel) {
const sample = getSampleByNo(sampleNo);
if (!sample || !sel) return;
const preview = sample.preview || {};
const name = (preview.suggestedName || sample.label || "").trim();
const type = preview.suggestedType || "Сочные корма";
if (!name) {
sel.value = "";
setResult("Нет названия для нового реагента", true);
return;
}
sel.disabled = true;
setResult(`Создаём «${name}»…`);
try {
const data = await api("/api/components", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
type,
dry_matter: sample.dryMatterPct || 0,
nutrients: sample.nutrients || {},
}),
});
await loadComponents(true);
selectComponentForSample(sampleNo, data.id);
const storage = preview.storage || {};
const skipNote = formatUnsupportedNotice(storage);
setResult(
`Реагент «${name}» создан — СВ и ${storage.nutrientCount || Object.keys(sample.nutrients || {}).length} показат.` +
(skipNote ? ` ${skipNote}` : ""),
false,
);
notifyCatalogChanged();
} catch (err) {
sel.value = "";
setResult(err.message || "Не удалось создать реагент", true);
} finally {
sel.disabled = false;
}
}
function onComponentSelectChange(e) {
const sel = e.target;
if (!sel.classList?.contains("lab-agrostar-component")) return;
if (sel.value !== CREATE_NEW) return;
const sampleNo = sel.getAttribute("data-sample-no") || "";
createComponentFromSample(sampleNo, sel);
}
function prime(reason) {
global.WespLabHeisenbergPrime?.error?.(reason, "agrostar");
}
function setStatus(text, isError) {
const el = $("labAgrostarStatus");
if (!el) return;
el.textContent = text || "";
el.className = "small mt-2 mb-0" + (isError ? " lab-agrostar-result--err" : " text-muted");
if (isError && text) prime(text);
}
function setResult(text, isError) {
const el = $("labAgrostarResult");
if (!el) return;
el.textContent = text || "";
el.className = "small mt-2 " + (isError ? "lab-agrostar-result--err" : "lab-agrostar-result--ok");
if (isError && text) prime(text);
}
function formatWarnings(warnings) {
if (!warnings?.length) return "—";
return warnings
.map((w) => {
const key = String(w).split("=")[0];
const label = WARN_LABELS[key] || w;
return `<span class="lab-agrostar-warn">${escapeHtml(label)}</span>`;
})
.join("");
}
function getSelectedComponentLabel(sampleNo) {
const sel = document.querySelector(`.lab-agrostar-component[data-sample-no="${CSS.escape(sampleNo)}"]`);
if (!sel?.value || sel.value === CREATE_NEW) return null;
const opt = sel.options[sel.selectedIndex];
return opt ? opt.text.replace(/\s*\(\d+%\)\s*$/, "").trim() : null;
}
function renderMetaLine(extra) {
const meta = $("labAgrostarMeta");
if (!meta || !parseResult) return;
showEl(meta);
const label = parseResult.sourceLabel || parseResult.labName || "Импорт";
meta.innerHTML =
`<strong>${escapeHtml(label)}</strong>${extra || ""}` +
(parseResult.warnings?.length ? ` · ${escapeHtml(parseResult.warnings.join("; "))}` : "");
}
function setActionButtons() {
const previewBtn = $("labAgrostarPreview");
const applyBtn = $("labAgrostarApply");
const clearBtn = $("labAgrostarClear");
if (previewBtn) previewBtn.disabled = false;
if (clearBtn) clearBtn.disabled = false;
if (applyBtn) {
applyBtn.disabled = !isLabSamples();
applyBtn.title = isLabSamples() ? "" : "Запись в каталог только для лабораторных проб AgroStar";
}
}
function renderLabSamplesTable() {
const wrap = $("labAgrostarTableWrap");
const body = $("labAgrostarTableBody");
const head = $("labImportTableHead");
const title = $("labImportTableTitle");
if (!wrap || !body || !parseResult) return;
const samples = parseResult.samples || [];
if (!samples.length) {
hideEl(wrap);
return;
}
if (title) title.textContent = "Кому в каталог";
if (head) {
head.innerHTML = `<tr>
<th>Образец</th><th>СВ, %</th><th>Реагент</th><th>Косяки</th>
</tr>`;
}
renderMetaLine(` · образцов: ${samples.length}`);
body.innerHTML = samples
.map((sample) => {
const sid = escapeHtml(sample.sampleNo);
const defaultId = sample.suggestedComponents?.[0]?.componentId || "";
return `<tr data-sample-no="${sid}">
<td>
<div class="fw-semibold">${escapeHtml(sample.label || sample.desc1 || sample.sampleNo)}</div>
<div class="text-muted small">${sid} · ${escapeHtml(sample.datePrinted || "")}</div>
</td>
<td>${sample.dryMatterPct != null ? escapeHtml(sample.dryMatterPct) : "—"}</td>
<td>
<select class="form-select form-select-sm lab-agrostar-component" data-sample-no="${sid}">
${buildComponentOptions(sample, defaultId)}
</select>
</td>
<td>${formatWarnings(sample.warnings)}</td>
</tr>`;
})
.join("");
showEl(wrap);
setActionButtons();
}
function renderRationCompositionTable() {
const wrap = $("labAgrostarTableWrap");
const body = $("labAgrostarTableBody");
const head = $("labImportTableHead");
const title = $("labImportTableTitle");
const lines = parseResult?.feedLines || [];
if (!wrap || !body || !lines.length) return;
if (title) title.textContent = "Состав рациона";
if (head) {
head.innerHTML = `<tr>
<th>Корм</th><th>кг/сут</th><th>руб.</th><th>Реагент WESP</th>
</tr>`;
}
const meta = parseResult.meta || {};
const extra = [
meta.group ? escapeHtml(meta.group) : "",
meta.rationDate ? escapeHtml(meta.rationDate) : "",
meta.totalCostRub ? `Σ ${escapeHtml(meta.totalCostRub)} руб.` : "",
]
.filter(Boolean)
.join(" · ");
renderMetaLine(` · ${lines.length} корм(ов)${extra ? " · " + extra : ""}`);
body.innerHTML = lines
.map((line, idx) => {
const defaultId = line.suggestedComponents?.[0]?.componentId || "";
return `<tr>
<td>${escapeHtml(line.feedName)}</td>
<td>${escapeHtml(line.dailyKg)}</td>
<td>${escapeHtml(line.costRub)}</td>
<td>
<select class="form-select form-select-sm lab-agrostar-component lab-agrostar-component--readonly" data-feed-idx="${idx}" disabled>
${buildComponentOptions(line, defaultId)}
</select>
</td>
</tr>`;
})
.join("");
showEl(wrap);
setActionButtons();
}
function renderRationIndicatorsTable() {
const wrap = $("labAgrostarTableWrap");
const body = $("labAgrostarTableBody");
const head = $("labImportTableHead");
const title = $("labImportTableTitle");
const rows = parseResult?.indicators || [];
if (!wrap || !body || !rows.length) return;
if (title) title.textContent = "Показатели рациона";
if (head) {
head.innerHTML = `<tr>
<th>Показатель</th><th>Норма</th><th>Текущий</th><th>Δ</th>
</tr>`;
}
const meta = parseResult.meta || {};
renderMetaLine(
` · ${rows.length} показат.${meta.group ? " · " + escapeHtml(meta.group) : ""}${meta.calcDate ? " · " + escapeHtml(meta.calcDate) : ""}`,
);
body.innerHTML = rows
.map((row) => {
const delta =
row.norm != null && row.current != null ? (row.current - row.norm).toFixed(2) : "—";
return `<tr>
<td>${escapeHtml(row.name)}</td>
<td>${row.norm != null ? escapeHtml(row.norm) : "—"}</td>
<td>${row.current != null ? escapeHtml(row.current) : "—"}</td>
<td>${escapeHtml(delta)}</td>
</tr>`;
})
.join("");
showEl(wrap);
setActionButtons();
}
function renderAfterParse() {
hideSniffPreview();
hideEl($("labAgrostarTableWrap"));
const kind = importKind();
if (kind === "lab_samples") renderLabSamplesTable();
else if (kind === "ration_composition") renderRationCompositionTable();
else if (kind === "ration_indicators") renderRationIndicatorsTable();
}
function renderSniffPreview() {
const panel = $("labAgrostarSniff");
const body = $("labAgrostarSniffBody");
if (!panel || !body || !parseResult) {
setResult("Нечего нюхать — сначала кинь файл", true);
return;
}
const kind = importKind();
if (kind === "ration_composition") {
renderSniffRationComposition(panel, body);
return;
}
if (kind === "ration_indicators") {
renderSniffRationIndicators(panel, body);
return;
}
renderSniffLabSamples(panel, body);
}
function renderSniffRationComposition(panel, body) {
const lines = parseResult.feedLines || [];
const meta = parseResult.meta || {};
const metaRows = Object.entries(meta)
.map(([k, v]) => `<tr><th>${escapeHtml(k)}</th><td>${escapeHtml(v)}</td></tr>`)
.join("");
body.innerHTML = `<article class="lab-agrostar-sniff-card">
<header class="lab-agrostar-sniff-card__head">
<h3 class="h6 mb-0">ПЛИНОР — состав рациона</h3>
<span class="badge text-bg-secondary">${lines.length} корм(ов)</span>
</header>
<p class="small text-muted">Превью · запись рациона в WESP — отдельным шагом</p>
<table class="table table-sm lab-agrostar-sniff-nutrients mb-2">
<thead><tr><th>Корм</th><th class="text-end">кг</th><th class="text-end">руб.</th></tr></thead>
<tbody>${lines
.map(
(l) =>
`<tr><td>${escapeHtml(l.feedName)}</td><td class="text-end">${escapeHtml(l.dailyKg)}</td><td class="text-end">${escapeHtml(l.costRub)}</td></tr>`,
)
.join("")}</tbody>
</table>
${metaRows ? `<table class="table table-sm lab-agrostar-sniff-meta mb-0"><tbody>${metaRows}</tbody></table>` : ""}
</article>`;
showEl(panel);
setResult(`Понюхали · ${parseResult.sourceLabel || "ПЛИНОР"} · ${lines.length} строк`, false);
}
function renderSniffRationIndicators(panel, body) {
const rows = parseResult.indicators || [];
body.innerHTML = `<article class="lab-agrostar-sniff-card">
<header class="lab-agrostar-sniff-card__head">
<h3 class="h6 mb-0">ПЛИНОР — зоопоказатели</h3>
<span class="badge text-bg-secondary">${rows.length} показат.</span>
</header>
<table class="table table-sm lab-agrostar-sniff-nutrients mb-0">
<thead><tr><th>Показатель</th><th class="text-end">Норма</th><th class="text-end">Текущий</th></tr></thead>
<tbody>${rows
.map(
(r) =>
`<tr><td>${escapeHtml(r.name)}</td><td class="text-end">${escapeHtml(r.norm)}</td><td class="text-end fw-semibold">${escapeHtml(r.current)}</td></tr>`,
)
.join("")}</tbody>
</table>
</article>`;
showEl(panel);
setResult(`Понюхали · ${parseResult.sourceLabel || "ПЛИНОР"} · ${rows.length} показат.`, false);
}
function renderSniffLabSamples(panel, body) {
if (!parseResult?.samples?.length) {
setResult("Нечего нюхать — нет проб", true);
return;
}
const labName = parseResult.labName || parseResult.sourceLabel || "AgroStar";
const cards = parseResult.samples.map((sample) => {
const preview = sample.preview;
if (!preview) return "";
const selectedName = getSelectedComponentLabel(sample.sampleNo);
const targetLine = selectedName
? `<p class="lab-agrostar-sniff__target mb-2"><span class="text-muted">Куда запишем:</span> <strong>${escapeHtml(selectedName)}</strong></p>`
: `<p class="lab-agrostar-sniff__target lab-agrostar-sniff__target--warn mb-2">Реагент не выбран — «+ добавить новый реагент»</p>`;
const metaRows = (preview.meta || [])
.map((row) => `<tr><th scope="row">${escapeHtml(row.label)}</th><td>${escapeHtml(row.value)}</td></tr>`)
.join("");
const nutrientRows = (preview.willWrite || [])
.map((row) => {
const sourceCell =
row.sourceValue != null
? `<td class="text-end">${escapeHtml(row.sourceValue)} <span class="text-muted small">${escapeHtml(row.sourceUnit || "")}</span></td>`
: `<td class="text-end text-muted">—</td>`;
return `<tr><td>${escapeHtml(row.label)}</td>${sourceCell}<td class="text-end fw-semibold">${escapeHtml(row.value)}</td><td class="text-muted small">${escapeHtml(row.unit)}</td></tr>`;
})
.join("");
const notes = (preview.notes || []).map((note) => `<li>${escapeHtml(note)}</li>`).join("");
const notesBlock = notes
? `<div class="lab-agrostar-sniff__notes mt-3"><div class="small text-muted mb-1">Замечания</div><ul class="small mb-0">${notes}</ul></div>`
: "";
const storage = preview.storage || {};
const skipNote = formatUnsupportedNotice(storage);
const storageBlock = skipNote
? `<p class="small lab-agrostar-sniff__skip mt-3 mb-0">${escapeHtml(skipNote)}</p>`
: `<p class="small text-muted mt-3 mb-0">В каталог: СВ + ${storage.nutrientCount || 0} показат.</p>`;
return `<article class="lab-agrostar-sniff-card">
<header class="lab-agrostar-sniff-card__head">
<h3 class="h6 mb-0">${escapeHtml(preview.title || sample.label)}</h3>
<span class="badge text-bg-secondary">${escapeHtml(preview.feedTypeRu || "—")}</span>
</header>
${targetLine}
<div class="row g-3">
<div class="col-md-6"><div class="small text-muted mb-1">Из файла</div>
<table class="table table-sm lab-agrostar-sniff-meta mb-0"><tbody>${metaRows}</tbody></table>
</div>
<div class="col-md-6"><div class="small text-muted mb-1">WESP · ${preview.recognizedCount || 0} показат. · % из документа → г/кг СВ (×10)</div>
<table class="table table-sm lab-agrostar-sniff-nutrients mb-0">
<thead><tr><th>Показатель</th><th class="text-end">Документ</th><th class="text-end">WESP</th><th></th></tr></thead>
<tbody>${nutrientRows}</tbody>
</table>
</div>
</div>
${notesBlock}${storageBlock}
</article>`;
});
body.innerHTML = cards.join("");
showEl(panel);
setResult(`Понюхали · ${labName} · ${parseResult.samples.length} образц(ов)`, false);
}
function hideSniffPreview() {
const panel = $("labAgrostarSniff");
const body = $("labAgrostarSniffBody");
hideEl(panel);
if (body) body.innerHTML = "";
}
function collectAssignments() {
if (!parseResult?.samples?.length) return [];
const byNo = new Map(parseResult.samples.map((s) => [s.sampleNo, s]));
const assignments = [];
document.querySelectorAll(".lab-agrostar-component[data-sample-no]").forEach((sel) => {
const sampleNo = sel.getAttribute("data-sample-no") || "";
const sample = byNo.get(sampleNo);
if (!sample) return;
const componentId = sel.value && sel.value !== CREATE_NEW ? sel.value : null;
assignments.push({
sampleNo,
componentId,
dryMatterPct: sample.dryMatterPct,
nutrients: sample.nutrients || {},
warnings: sample.warnings || [],
});
});
return assignments;
}
function fileAllowed(name) {
const lower = (name || "").toLowerCase();
return ACCEPT_EXT.some((ext) => lower.endsWith(ext));
}
async function uploadFile(file) {
if (!file) return;
if (!fileAllowed(file.name)) {
setStatus("Формат не тот — xml, pdf или xlsx", true);
return;
}
selectedFile = file;
const fileNameEl = $("labAgrostarFileName");
if (fileNameEl) {
showEl(fileNameEl);
fileNameEl.textContent = file.name;
}
setStatus("Разбираем поставку…");
setResult("");
hideSniffPreview();
const fd = new FormData();
fd.append("file", file);
try {
parseResult = await api("/api/lab/import/parse", { method: "POST", body: fd });
await loadComponents();
renderAfterParse();
const kind = importKind();
if (kind === "lab_samples") {
setStatus(`На столе: ${parseResult.sampleCount || 0} образц(ов) · ${parseResult.sourceLabel || ""}`);
} else if (kind === "ration_composition") {
setStatus(`Рацион: ${parseResult.feedCount || 0} корм(ов) · ${parseResult.sourceLabel || ""}`);
} else {
setStatus(`Показатели: ${parseResult.indicatorCount || 0} · ${parseResult.sourceLabel || ""}`);
}
} catch (err) {
parseResult = null;
hideEl($("labAgrostarTableWrap"));
hideEl($("labAgrostarMeta"));
setStatus(err.message || "Файл не разобрался", true);
}
}
async function runApply() {
if (!isLabSamples()) {
setResult("Запись в каталог — только для лабораторных проб AgroStar", true);
return;
}
const assignments = collectAssignments();
if (!assignments.length) {
setResult("Нечего готовить — сначала кинь файл", true);
return;
}
const missing = assignments.filter((a) => !a.componentId);
if (missing.length) {
setResult(`Укажи реагент для ${missing.length} образц(ов) — Jesse бы не забыл`, true);
return;
}
setResult("Пишем в каталог…");
try {
const data = await api("/api/lab/import/agrostar-xml/apply", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assignments, dryRun: false }),
});
const lines = (data.results || []).map(
(r) => `${r.sampleNo}: ${r.skipped ? "мимо" : r.componentName || "—"} (${r.nutrientCount} показат.)`,
);
setResult(
`Запомнили · в деле ${data.applied}, мимо ${data.skipped}` +
(lines.length ? "\n" + lines.join("\n") : ""),
false,
);
notifyCatalogChanged();
} catch (err) {
setResult(err.message || "Наука не сработала — импорт сгорел", true);
}
}
function clearAll() {
parseResult = null;
selectedFile = null;
const input = $("labAgrostarFile");
if (input) input.value = "";
const fileNameEl = $("labAgrostarFileName");
if (fileNameEl) {
hideEl(fileNameEl);
fileNameEl.textContent = "";
}
hideEl($("labAgrostarTableWrap"));
hideEl($("labAgrostarMeta"));
hideSniffPreview();
$("labAgrostarPreview").disabled = true;
$("labAgrostarApply").disabled = true;
$("labAgrostarClear").disabled = true;
setStatus("");
setResult("");
}
function bindDropZone() {
const drop = $("labAgrostarDrop");
const input = $("labAgrostarFile");
if (!drop || !input) return;
drop.addEventListener("click", () => input.click());
drop.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
input.click();
}
});
input.addEventListener("change", () => {
const file = input.files?.[0];
if (file) uploadFile(file);
});
["dragenter", "dragover"].forEach((ev) => {
drop.addEventListener(ev, (e) => {
e.preventDefault();
e.stopPropagation();
drop.classList.add("lab-agrostar-drop--active");
});
});
["dragleave", "drop"].forEach((ev) => {
drop.addEventListener(ev, (e) => {
e.preventDefault();
e.stopPropagation();
drop.classList.remove("lab-agrostar-drop--active");
});
});
drop.addEventListener("drop", (e) => {
const file = e.dataTransfer?.files?.[0];
if (file) uploadFile(file);
});
}
function init() {
if (!$("labAgrostarDrop")) return;
bindDropZone();
$("labAgrostarTableWrap")?.addEventListener("change", onComponentSelectChange);
$("labAgrostarPreview")?.addEventListener("click", renderSniffPreview);
$("labAgrostarApply")?.addEventListener("click", runApply);
$("labAgrostarClear")?.addEventListener("click", clearAll);
loadComponents().catch(() => {});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(window);