Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit c298f5a830
247 changed files with 23200 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
"use strict";
// ---------- Анализ надёжности (инженеры и руководители) ----------
// Методика: «Надёжность и безопасность» (ИАТЭ, 2005), ГОСТ 27.002. Отказы — пуассоновский поток, λ ≈ const на участке
// нормальной эксплуатации; оценки по малоотказному опыту консервативные (доверие 90 %). Расчёты — на сервере (app/reliability.py).
const REL_KEY = "reliability_filters";
async function reliabilityPage(tab) {
tab = tab === "drift" ? "drift" : tab === "method" ? "method" : "summary";
const deps = await ref("departments");
let st;
try { st = JSON.parse(localStorage.getItem(REL_KEY) || "null"); } catch (e) { st = null; }
const today = new Date();
const ago = m => { const d = new Date(today); d.setMonth(d.getMonth() - m); return d; };
st = Object.assign({ from: dtIn(ago(12)), to: dtIn(today), department_id: "", cranes: "", confidence: "0.9", horizon: "30", days: "180" }, st || {});
const save = () => { try { localStorage.setItem(REL_KEY, JSON.stringify(st)); } catch (e) {} };
const form = h("div", { class: "card" }), result = h("div");
const params = extra => {
const q = new URLSearchParams(extra || {});
if (st.department_id) q.set("department_id", st.department_id);
if (st.cranes) q.set("cranes", st.cranes);
return q;
};
const periodQ = () => params({ from: st.from, to: st.to });
async function download(kind) {
const q = periodQ(); q.set("kind", kind);
const r = await authFetch(`${API}/reliability/export?${q}`);
if (!r.ok) { let d = r.statusText; try { d = (await r.json()).detail; } catch (_) {} throw new Error(d); }
const a = document.createElement("a"); a.href = URL.createObjectURL(await r.blob());
a.download = `${kind === "failures" ? "otkazy" : "obsluzhivanie_uzlov"}_${st.from.slice(0, 10)}_${st.to.slice(0, 10)}.csv`;
document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}
function drawForm() {
const dt = (key, label) => field(label, h("input", { type: "datetime-local", value: st[key], onchange: e => { st[key] = e.target.value; save(); } }));
const quick = (label, m) => h("button", { class: "sec sm", onclick: () => { st.from = dtIn(ago(m)); st.to = dtIn(new Date()); save(); drawForm(); guard(show)(); } }, label);
form.replaceChildren(
tab === "drift" ? h("div", { class: "grid" },
field("За сколько дней смотреть значения", h("select", { onchange: e => { st.days = e.target.value; save(); } },
...[["90", "3 месяца"], ["180", "6 месяцев"], ["365", "год"], ["730", "2 года"]].map(([v, t]) => h("option", { value: v, selected: st.days === v }, t))))) :
h("div", { class: "grid" }, dt("from", "С"), dt("to", "По"),
field("Доверие оценок", h("select", { onchange: e => { st.confidence = e.target.value; save(); } },
...[["0.9", "90 % (консервативно)"], ["0.8", "80 %"], ["0.5", "50 % («средневероятно»)"]].map(([v, t]) => h("option", { value: v, selected: st.confidence === v }, t)))),
field("Прогноз на", h("select", { onchange: e => { st.horizon = e.target.value; save(); } },
...[["7", "неделю"], ["30", "месяц"], ["90", "квартал"], ["365", "год"]].map(([v, t]) => h("option", { value: v, selected: st.horizon === v }, t))))),
h("div", { class: "grid", style: "margin-top:8px" },
field("Отдел (с вложенными)", h("select", { onchange: e => { st.department_id = e.target.value; save(); } }, h("option", { value: "" }, "Все"),
...deps.map(d => h("option", { value: d.id, selected: String(d.id) === String(st.department_id) }, d.name)))),
me.crane_map ? field("Оборудование", h("select", { onchange: e => { st.cranes = e.target.value; save(); } },
...[["", "Всё"], ["false", "Без кранов"], ["true", "Только краны"]].map(([v, t]) => h("option", { value: v, selected: st.cranes === v }, t)))) : null),
tab === "drift" ? null : h("div", { class: "row", style: "margin-top:10px" }, h("span", { class: "small muted" }, "Период:"),
quick("3 месяца", 3), quick("6 месяцев", 6), quick("год", 12), quick("2 года", 24)),
h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(show) }, "Показать"),
tab === "drift" ? null : h("button", { class: "sec", title: "Одна строка на отказ или неисправность: времена, наработки, причины", onclick: guard(() => download("failures")) }, "⬇ CSV: отказы и неисправности"),
tab === "drift" ? null : h("button", { class: "sec", title: "Плановые и внеплановые замены узлов — «цензурированные» наблюдения для прогноза ресурса", onclick: guard(() => download("service")) }, "⬇ CSV: обслуживание узлов")));
}
const n1 = v => v == null ? "—" : (Math.round(v * 10) / 10).toLocaleString("ru-RU");
const n0 = v => v == null ? "—" : Math.round(v).toLocaleString("ru-RU");
const pct = v => v == null ? "—" : (Math.round(v * 1000) / 10).toLocaleString("ru-RU") + " %";
const per1000 = v => v == null ? "—" : (Math.round(v * 1000 * 100) / 100).toLocaleString("ru-RU");
const bars = (title, list, hint) => h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, title), hint ? h("p", { class: "small muted" }, hint) : null,
list.length ? h("div", { class: "relbars" }, ...list.map(x => h("div", { class: "relbar" }, h("span", { class: "rl" }, x.name),
h("span", { class: "rb" }, h("i", { style: `width:${Math.max(3, x.pct ?? 0)}%` })), h("b", {}, x.n, x.pct != null ? h("span", { class: "small muted" }, ` · ${x.pct}%`) : null))))
: h("p", { class: "small muted" }, "Нет данных за период"));
async function show() {
if (tab === "method") return;
if (tab === "drift") return showDrift();
const q = periodQ(); q.set("confidence", st.confidence); q.set("horizon_days", st.horizon);
const j = await api("GET", "/reliability?" + q);
const F = j.fleet, conf = Math.round(j.confidence * 100);
const tile = (n, label, cls, title) => h("div", { class: "stat " + (cls || ""), title }, h("b", {}, n), h("span", {}, label));
const stats = h("div", { class: "stats" },
tile(F.machines, "машин в анализе"), tile(F.failures, "отказов", F.failures ? "relbad" : ""), tile(F.faults, "сообщений о неисправности"),
tile(F.escalated, "неисправность → отказ", F.escalated ? "relwarn" : "", "Сколько сообщений о неисправности переросли в отказ"),
tile(n0(F.theta), "наработка на отказ, ч (оценка)"), tile(n0(F.theta_lower), `не менее, ч (доверие ${conf} %)`),
tile(n1(F.mttr), "среднее восстановление, ч"), tile(n1(F.downtime), "простой всего, ч"), tile(F.without_failures, "без отказов"));
const help = h("p", { class: "small muted" }, `θ — средняя наработка на отказ (наработка ÷ число отказов). «θ не менее» — консервативная нижняя граница при доверии ${conf} %: `,
"даже без отказов даёт оценку по накопленной наработке (в 2,3 раза меньше её при 90 %). Kг — коэффициент готовности θ / (θ + Tв). ",
`Прогноз — вероятность хотя бы одного отказа за ${j.horizon_days} дн. при текущей интенсивности использования (поток Пуассона). `,
"«*» — нет показаний счётчика, взято календарное время.");
const tableRows = j.rows.map(r => h("tr", { class: "click", onclick: () => location.hash = "#/equipment/" + r.equipment_id },
h("td", {}, h("b", {}, r.equipment), h("div", { class: "small muted" }, [r.inventory_no, r.location, r.department].filter(Boolean).join(" · "))),
h("td", {}, n0(r.hours), r.basis === "calendar" ? h("span", { class: "muted", title: "Нет показаний счётчика — календарное время" }, "*") : null),
h("td", {}, r.failures ? h("b", { class: "s-due" }, r.failures) : "0"),
h("td", {}, r.faults || "—", r.escalated ? h("div", { class: "small", style: "color:var(--bad)" }, `→ отказ: ${r.escalated}`) : null),
h("td", {}, n0(r.theta)), h("td", {}, n0(r.theta_lower)), h("td", { title: "отказов на 1000 ч (верхняя граница)" }, per1000(r.lambda), h("div", { class: "small muted" }, "≤ " + per1000(r.lambda_upper))),
h("td", {}, n1(r.mttr), r.wait_mean ? h("div", { class: "small muted", title: "из них ожидание запчастей" }, "запч. " + n1(r.wait_mean)) : null),
h("td", {}, pct(r.availability ?? r.availability_period)), h("td", {}, n1(r.downtime)),
h("td", {}, h("b", { class: r.forecast_p >= 0.5 ? "s-due" : r.forecast_p >= 0.2 ? "s-soon" : "s-ok" }, pct(r.forecast_p)))));
const table = h("div", { class: "card tablewrap" }, h("h2", { style: "margin-top:0" }, "По машинам"), help,
j.rows.length ? h("table", { class: "reltable" }, h("thead", {}, h("tr", {}, ...["Машина", "Наработка, ч", "Отказы", "Неиспр.", "θ, ч", `θ ≥ (${conf} %)`, "λ на 1000 ч",
"Tв, ч", "Kг", "Простой, ч", `Прогноз ${j.horizon_days} дн.`].map(t => h("th", {}, t)))), h("tbody", {}, ...tableRows))
: h("p", { class: "muted" }, "Нет оборудования по фильтру"));
const maxM = Math.max(1, ...j.months.map(m => m.n));
const months = h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Отказы по месяцам"),
j.months.length ? h("div", { class: "relmonths" }, ...j.months.map(m => h("div", { class: "rm", title: `${m.month}: ${m.n}` },
h("i", { style: `height:${Math.round(100 * m.n / maxM)}%` }), h("b", {}, m.n), h("span", {}, m.month.slice(5) + "." + m.month.slice(2, 4)))))
: h("p", { class: "small muted" }, "Отказов за период не было"));
const topMax = Math.max(1, ...j.cause_units.map(y => y.n));
fill(result, stats, table,
h("div", { class: "relgrid" },
bars("Причины отказов", j.causes, "Если много «не установлена» — уточняйте причину при закрытии отказа"),
bars("Что сделано", j.actions), bars("Характер", j.natures),
bars("Были ли признаки заранее", j.precursors, "«Было сообщение о неисправности» и «замечено на обходе» — отказы, которые можно было предотвратить"),
bars("Узлы-причины (топ)", j.cause_units.map(x => ({ ...x, pct: Math.round(100 * x.n / topMax) }))),
months));
}
async function showDrift() {
const q = params({ days: st.days });
const list = await api("GET", "/reliability/drift?" + q);
const risky = list.filter(r => r.risk);
const fmtB = r => r.bound === "max" ? `↑ к максимуму ${r.max}` : r.bound === "min" ? `↓ к минимуму ${r.min}` : "—";
fill(result, h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, `Параметры под наблюдением: ${risky.length} из ${list.length}`),
h("p", { class: "small muted" }, "Модель «параметр — граница работоспособности»: по числовым пунктам чек-листов с нормой строится линейный тренд, ",
"считается, через сколько дней значение дойдёт до границы, и сколько раз оно уже выходило за неё (ν — выходов за 30 дней). ",
"Так можно поймать постепенный отказ до того, как он случится. Нужно хотя бы 3 значения."),
list.length ? h("div", { class: "tablewrap" }, h("table", { class: "reltable" },
h("thead", {}, h("tr", {}, ...["Машина", "Пункт", "Норма", "Последнее", "Тренд за 30 дн.", "Куда", "До границы, дн.", "Выходы за норму", "Замеров"].map(t => h("th", {}, t)))),
h("tbody", {}, ...list.map(r => h("tr", { class: r.risk ? "relrisk" : "" },
h("td", {}, h("a", { href: "#/equipment/" + r.equipment_id }, r.equipment)), h("td", {}, r.item),
h("td", {}, `${r.min ?? "…"} – ${r.max ?? "…"} ${r.unit}`), h("td", {}, `${r.last} ${r.unit}`, h("div", { class: "small muted" }, fmt(r.last_at))),
h("td", {}, (r.trend_per_30d > 0 ? "+" : "") + r.trend_per_30d), h("td", {}, fmtB(r)),
h("td", {}, r.days_to_bound == null ? "—" : h("b", { class: r.days_to_bound <= 30 ? "s-due" : r.days_to_bound <= 90 ? "s-soon" : "" }, n0(r.days_to_bound))),
h("td", {}, r.out_of_range ? h("b", { class: "s-due" }, r.out_of_range, h("span", { class: "small muted" }, ` · ν=${r.nu_per_30d}`)) : "—"),
h("td", {}, r.n))))))
: h("p", { class: "muted" }, "Нет числовых пунктов с нормой и достаточным числом замеров")));
}
function method() {
fill(result, h("div", { class: "card relmethod" }, h("h2", { style: "margin-top:0" }, "Как считается"),
h("ul", {},
h("li", {}, h("b", {}, "Отказ"), " — оборудование остановлено (запись «Отказ»). «Неисправность» — проблема есть, но машина работает; в показатели надёжности не входит, но учитывается, если перешла в отказ."),
h("li", {}, h("b", {}, "Наработка"), " — по показаниям счётчика моточасов за период; если их нет, берётся календарное время (машина считается работавшей круглосуточно — оценка консервативная)."),
h("li", {}, h("b", {}, "Интенсивность отказов"), " λ = n / T и ", h("b", {}, "наработка на отказ"), " θ = T / n (поток отказов — пуассоновский, участок нормальной эксплуатации, λ ≈ const)."),
h("li", {}, h("b", {}, "Оценка по малоотказному опыту:"), " верхняя граница λ при доверии Pα — χ²(Pα; 2n+2) / 2T. Без отказов при 90 %: λ ≤ 2,3 / T, то есть θ ≥ T / 2,3. При 50 % — «средневероятная» оценка θ ≈ T / 0,69."),
h("li", {}, h("b", {}, "Время восстановления"), " Tв — от отказа (или перевода неисправности в отказ) до ввода в эксплуатацию; отдельно видно ожидание запчастей. ",
h("b", {}, "Коэффициент готовности"), " Kг = θ / (θ + Tв)."),
h("li", {}, h("b", {}, "Прогноз:"), " ожидаемое число отказов μ = λ × наработка за горизонт (по средней загрузке машины), вероятность хотя бы одного отказа 1 − e^(−μ). Если отказов не было, берётся «средневероятная» λ."),
h("li", {}, h("b", {}, "Дрейф параметров:"), " модель «параметр — граница работоспособности»: линейный тренд числового пункта чек-листа, прогноз дня выхода за норму и частота выходов ν."),
h("li", {}, h("b", {}, "Выгрузки CSV"), " — для собственных расчётов: строка на каждый отказ и неисправность и история обслуживания узлов (плановые замены — «цензурированные» наблюдения: узел заменили, не дождавшись отказа)."),
h("li", {}, "Абсолютные цифры приблизительны (мало данных, допущения в запас). Главная польза — ", h("b", {}, "сравнение машин"), " и поиск слабых мест на одной методике."))));
}
fill($app, h("h1", {}, "Анализ надёжности"),
tabs([["#/reliability", "Показатели"], ["#/reliability/drift", "Дрейф параметров"], ["#/reliability/method", "Как считается"]], "#/reliability" + (tab === "summary" ? "" : "/" + tab)),
tab === "method" ? null : form, result);
if (tab === "method") return method();
drawForm();
await show();
}