Files
site/apps/web/public/wesp/js/pages/lab-agrostar-import.js

677 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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);