fix: Alem error reasons, real file link + recalc button, error counts as submitted, Alem call docs
This commit is contained in:
parent
7c25b3f280
commit
09e179107c
3
.gitignore
vendored
3
.gitignore
vendored
@ -4,5 +4,6 @@ uploads/
|
||||
.vibe42-run.log
|
||||
.vibe42-run.pid
|
||||
.env
|
||||
|
||||
package-lock.json
|
||||
.kfu-svod-pw.txt
|
||||
.kfu-data/
|
||||
|
||||
104
dashboard.html
104
dashboard.html
@ -25,12 +25,23 @@
|
||||
.kfu-dot.yellow { background: var(--kt-ai-warning, var(--kt-ai-warn-token)); }
|
||||
.kfu-dot.red { background: var(--kt-ai-danger); }
|
||||
.kfu-dot.none { background: var(--kt-ai-fg-faint, var(--kt-ai-fg-faint)); }
|
||||
.kfu-table-wrap { overflow-x: auto; border: 1px solid var(--kt-ai-card-border); border-radius: 12px; background: var(--kt-ai-card-bg); }
|
||||
table.kfu-t { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 1180px; }
|
||||
.kfu-t th { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--kt-ai-card-border); font-weight: 600; font-size: 12px; color: var(--kt-ai-fg-muted); white-space: nowrap; background: var(--kt-ai-bg); position: sticky; top: 0; }
|
||||
.kfu-t td { padding: 10px 12px; border-bottom: 1px solid var(--kt-ai-card-border); vertical-align: top; }
|
||||
.kfu-table-wrap { border: 1px solid var(--kt-ai-card-border); border-radius: 12px; background: var(--kt-ai-card-bg); }
|
||||
table.kfu-t { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 13px; }
|
||||
.kfu-t th { text-align: left; padding: 8px 8px; border-bottom: 1px solid var(--kt-ai-card-border); font-weight: 600; font-size: 11.5px; color: var(--kt-ai-fg-muted); white-space: nowrap; background: var(--kt-ai-bg); overflow: hidden; text-overflow: ellipsis; }
|
||||
.kfu-t td { padding: 8px 8px; border-bottom: 1px solid var(--kt-ai-card-border); vertical-align: top; overflow: hidden; text-overflow: clip; }
|
||||
.kfu-t td.company-cell { white-space: normal; word-break: break-word; }
|
||||
.kfu-sub { font-size: 12px; color: var(--kt-ai-fg-muted); margin-top: 2px; }
|
||||
.kfu-toggle { background: none; border: 0; color: var(--kt-ai-primary); cursor: pointer; font-size: 12px; padding: 2px 0; margin-top: 4px; }
|
||||
.kfu-toggle:hover { text-decoration: underline; }
|
||||
.kfu-rowdetail { padding: 10px 14px 12px; background: var(--kt-ai-bg); }
|
||||
.kfu-err { margin-top: 4px; font-size: 11.5px; color: var(--kt-ai-danger); max-width: 320px; }
|
||||
.kfu-alem-note { font-size: 11px; color: var(--kt-ai-fg-faint, var(--kt-ai-fg-muted)); line-height: 1.55; margin-top: 10px; padding: 8px 12px; background: var(--kt-ai-card-bg); border: 1px dashed var(--kt-ai-card-border); border-radius: 10px; }
|
||||
.kfu-alem-note code { font-size: 10.5px; color: var(--kt-ai-fg); }
|
||||
.kfu-rowdetail .kfu-cell { margin-bottom: 6px; font-size: 12.5px; color: var(--kt-ai-fg); }
|
||||
.kfu-rowdetail .lbl { font-size: 11.5px; color: var(--kt-ai-fg-muted); }
|
||||
.kfu-t tr:last-child td { border-bottom: 0; }
|
||||
.kfu-t td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.kfu-t td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12.5px; }
|
||||
.kfu-t th.num { text-align: right; }
|
||||
.kfu-t td.company { font-weight: 600; white-space: nowrap; max-width: 230px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.kfu-zone { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; white-space: nowrap; }
|
||||
.kfu-flags { font-size: 12px; color: var(--kt-ai-fg-muted); max-width: 260px; }
|
||||
@ -81,12 +92,15 @@
|
||||
</div>
|
||||
<div class="kfu-table-wrap">
|
||||
<table class="kfu-t">
|
||||
<colgroup>
|
||||
<col style="width:19%"><col style="width:11%"><col style="width:9%"><col style="width:8%"><col style="width:9%">
|
||||
<col style="width:10%"><col style="width:9%"><col style="width:6.5%"><col style="width:6.5%"><col style="width:6.5%"><col style="width:6%">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Компания</th>
|
||||
<th class="th-company">Компания</th>
|
||||
<th>Статус</th>
|
||||
<th>Когда загружен</th>
|
||||
<th>Комментарий дочки</th>
|
||||
<th>Загружен</th>
|
||||
<th class="num">Долг</th>
|
||||
<th class="num">EBITDA 12М</th>
|
||||
<th class="num">Проц. расходы 12М</th>
|
||||
@ -94,21 +108,21 @@
|
||||
<th class="num">К1</th>
|
||||
<th class="num">К2</th>
|
||||
<th class="num">К3</th>
|
||||
<th>Зона компании</th>
|
||||
<th>Замечания агента</th>
|
||||
<th>Зона</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="kfu-alem-note">Как считается: дочка загружает файл → приложение создаёт сессию в Alem: <code>POST /api/v1/open-api/conversation</code> (заголовок <code>Authorization: Bearer …</code>, body <code>{template_id, title, user_id}</code>) → затем текст отчёта уходит в <code>POST /api/v1/open-api/conversation/{session_id}/send</code> (поле <code>content</code>) → ответ приходит SSE-потоком, итог — в последнем событии <code>message_result.detail.full_content</code>, которое приложение разбирает как JSON. Ключи и <code>template_id</code> задаются в настройках приложения, в код не попадают.</p>
|
||||
<section class="kfu-section">
|
||||
<h2>Настройки</h2>
|
||||
<div class="kfu-setcard">
|
||||
<div class="kfu-setgrid">
|
||||
<label class="kt-ai-field"><span>Период отчётности</span>
|
||||
<input class="kt-ai-input" id="setPeriod" placeholder="Q2-2026"></label>
|
||||
<label class="kt-ai-field"><span>Срок предоставления (день.месяц)</span>
|
||||
<label class="kt-ai-field"><span>Срок предоставления (ДД.ММ.ГГГГ)</span>
|
||||
<input class="kt-ai-input" id="setDeadline" placeholder="30.06.2026"></label>
|
||||
<label class="kt-ai-field"><span>Телефон ведущего</span>
|
||||
<input class="kt-ai-input" id="setPhone" placeholder="+7 (___) ___-__-__"></label>
|
||||
@ -117,7 +131,7 @@
|
||||
|
||||
<div>
|
||||
<h3 style="font-size:14px;margin:0 0 4px">Дочерние компании</h3>
|
||||
<p style="font-size:12.5px;color:var(--kt-ai-fg-muted);margin:0 0 8px">Логин дочерней компании — её адрес в Аит (например, <code>jandamir_z</code>). Пароль генерируется системой.</p>
|
||||
<p style="font-size:12.5px;color:var(--kt-ai-fg-muted);margin:0 0 8px">Логин дочерней компании — её адрес в Aitu (например, <code>jandamir_z</code>). Пароль генерируется системой.</p>
|
||||
<div id="compList"></div>
|
||||
<div class="kfu-addrow">
|
||||
<input class="kt-ai-input" id="addName" placeholder="Название компании">
|
||||
@ -146,10 +160,14 @@
|
||||
}
|
||||
function fmtDateFull(iso) {
|
||||
if (!iso) return "";
|
||||
var d = new Date(iso);
|
||||
var s = String(iso).trim();
|
||||
var p2 = function (x) { return (x < 10 ? "0" : "") + x; };
|
||||
var parts = String(iso).split("-");
|
||||
var m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||
if (m) return p2(+m[1]) + "." + p2(+m[2]) + "." + m[3];
|
||||
var parts = s.split("-");
|
||||
if (parts.length === 3) return parts[2] + "." + parts[1] + "." + parts[0];
|
||||
var d = new Date(s);
|
||||
if (isNaN(d.getTime())) return s;
|
||||
return p2(d.getDate()) + "." + p2(d.getMonth() + 1) + "." + d.getFullYear();
|
||||
}
|
||||
function fmtNum(v) {
|
||||
@ -172,14 +190,17 @@
|
||||
(flags ? "<div class='kfu-flags'>" + esc(flags) + "</div>" : "");
|
||||
}
|
||||
function stHtml(row) {
|
||||
if (!row || !row.status) return "<span class='kfu-status-pill kfu-st-no'>нет данных</span>";
|
||||
var m = { done: ["kfu-st-done", "сдан"], processing: ["kfu-st-processing", "расчёт"], error: ["kfu-st-error", "ошибка"] };
|
||||
if (!row || !row.status || row.status === "not_submitted") return "<span class='kfu-status-pill kfu-st-no'>не сдан</span>";
|
||||
var m = { done: ["kfu-st-done", "сдан"], processing: ["kfu-st-processing", "обрабатывается"], error: ["kfu-st-error", "ошибка расчёта"] };
|
||||
var it = m[row.status] || ["kfu-st-no", esc(row.status)];
|
||||
return "<span class='kfu-status-pill " + it[0] + "'>" + it[1] + "</span>";
|
||||
}
|
||||
|
||||
var tbody = document.getElementById("tbody");
|
||||
|
||||
var ROW_DATA = [];
|
||||
var ROW_IDX = 0;
|
||||
|
||||
function render(rows, b) {
|
||||
var done = 0, proc = 0, err = 0;
|
||||
rows.forEach(function (r) {
|
||||
@ -187,9 +208,12 @@
|
||||
else if (r.status === "processing") proc++;
|
||||
else if (r.status === "error") err++;
|
||||
});
|
||||
var submitted = rows.filter(function (r) { return r.status === "done" || r.status === "error"; }).length;
|
||||
document.getElementById("cnt").innerHTML =
|
||||
esc(rows.length) + " компаний: <b>" + done + "</b> сдано, " + proc + " на расчёте, " + err + " ошибок";
|
||||
esc(rows.length) + " компаний: <b>" + submitted + "</b> сдано (" + err + " с ошибкой расчёта), " + proc + " на расчёте";
|
||||
document.getElementById("periodMeta").textContent = "период " + (b.period || "") + " · срок до " + fmtDateFull(b.deadline);
|
||||
ROW_DATA = [];
|
||||
ROW_IDX = 0;
|
||||
var html = rows.map(function (r) {
|
||||
var acts = [];
|
||||
if (r.fileId) {
|
||||
@ -198,11 +222,17 @@
|
||||
if (r.status === "processing" || r.status === "error") {
|
||||
acts.push("<button onclick='kfRecalc(\"" + r.fileId + "\")'>пересчитать</button>");
|
||||
}
|
||||
var idx = ROW_IDX++;
|
||||
ROW_DATA[idx] = r;
|
||||
var hasDetail = !!(r.comment || (r.flags && r.flags.length) || r.agent_comment);
|
||||
var actsStr = acts.length ? acts.join(" ") : "";
|
||||
return "<tr>" +
|
||||
"<td class='company' title='" + esc(r.company) + "'>" + esc(r.company) + "</td>" +
|
||||
"<td class='company company-cell' title='" + esc(r.company) + "'>" + esc(r.company) +
|
||||
(hasDetail ? "<br><button class='kfu-toggle' onclick='kfToggle(" + idx + ")' id='tg" + idx + "'>подробнее</button>" : "") +
|
||||
(r.error ? "<div class='kfu-err'>" + esc(r.error) + "</div>" : "") +
|
||||
"</td>" +
|
||||
"<td>" + stHtml(r) + "</td>" +
|
||||
"<td>" + esc(fmtDate(r.uploadedAt)) + "</td>" +
|
||||
"<td class='kfu-note'>" + esc(r.comment) + "</td>" +
|
||||
"<td class='num'>" + fmtNum(r.debt) + "</td>" +
|
||||
"<td class='num'>" + fmtNum(r.ebitda_ltm) + "</td>" +
|
||||
"<td class='num'>" + fmtNum(r.interest_ltm) + "</td>" +
|
||||
@ -211,9 +241,13 @@
|
||||
"<td class='num'>" + fmtNum(r.k2) + "</td>" +
|
||||
"<td class='num'>" + fmtNum(r.k3) + "</td>" +
|
||||
"<td>" + zoneHtml(r) + "</td>" +
|
||||
"<td class='kfu-note'>" + esc(r.agent_comment) + "</td>" +
|
||||
"<td class='kfu-act'>" + acts.join(" ") + "</td>" +
|
||||
"</tr>";
|
||||
"<td class='kfu-act'>" + actsStr + "</td>" +
|
||||
"</tr>" +
|
||||
(hasDetail ? "<tr class='kfu-detail-row' id='dt" + idx + "' hidden><td colspan='12' class='kfu-rowdetail'>" +
|
||||
(r.comment ? "<div class='kfu-cell'><span class='lbl'>Комментарий дочки:</span> " + esc(r.comment) + "</div>" : "") +
|
||||
(r.agent_comment ? "<div class='kfu-cell'><span class='lbl'>Замечания агента:</span> " + esc(r.agent_comment) + "</div>" : "") +
|
||||
((r.flags && r.flags.length) ? "<div class='kfu-cell'><span class='lbl'>Признаки:</span><br>" + r.flags.map(esc).join("<br>") + "</div>" : "") +
|
||||
"</td></tr>" : "");
|
||||
}).join("");
|
||||
tbody.innerHTML = html;
|
||||
document.getElementById("exportBtn").setAttribute("href", "api/export?t=" + Date.now());
|
||||
@ -226,7 +260,7 @@
|
||||
"<input class='kt-ai-input in name' value='" + esc(c.name) + "' placeholder='Название'>" +
|
||||
"<input class='kt-ai-input in person' value='" + esc(c.person) + "' placeholder='Ответственный'>" +
|
||||
"<input class='kt-ai-input in phone' value='" + esc(c.phone) + "' placeholder='+7 ___'>" +
|
||||
"<span style='font-size:12.5px' class='lg' title='Логин для входа — передайте компании в Аит'>" + esc(c.login) + "</span>" +
|
||||
"<span style='font-size:12.5px' class='lg' title='Логин для входа — передайте компании в Aitu'>" + esc(c.login) + "</span>" +
|
||||
"<div class='kfu-actions'>" +
|
||||
"<button class='act-save'>сохранить</button>" +
|
||||
"<button class='act-pw'>пароль</button>" +
|
||||
@ -256,8 +290,7 @@
|
||||
var data = collect();
|
||||
var body = { companies: window.__companies.map(function (c) {
|
||||
if (c.id !== id) return c;
|
||||
var m = { id: c.id, name: data.name, person: data.person, phone: data.phone, login: data.login, active: c.active };
|
||||
return m;
|
||||
return { id: c.id, name: data.name, person: data.person, phone: data.phone, active: c.active };
|
||||
}) };
|
||||
fetch("api/settings", {
|
||||
method: "POST",
|
||||
@ -275,7 +308,7 @@
|
||||
body: JSON.stringify({ action: "set_password" })
|
||||
}).then(function (r) { return r.json(); }).then(function (b) {
|
||||
if (!b.ok) { alert(b.error || "Ошибка"); return; }
|
||||
flash("Новый пароль: " + b.tempPassword + " — передайте компании через Аит.");
|
||||
flash("Новый пароль: " + b.tempPassword + " — передайте компании через Aitu.");
|
||||
load();
|
||||
});
|
||||
});
|
||||
@ -300,12 +333,21 @@
|
||||
window.__companies = s.companies || [];
|
||||
render(b.rows, b);
|
||||
document.getElementById("setPeriod").value = s.period || "";
|
||||
document.getElementById("setDeadline").value = s.deadline || "";
|
||||
document.getElementById("setDeadline").value = fmtDateFull(s.deadline);
|
||||
document.getElementById("setPhone").value = s.consolidatorPhone || "";
|
||||
renderCompanies(s.companies || []);
|
||||
});
|
||||
}
|
||||
|
||||
window.kfToggle = function (i) {
|
||||
var row = document.getElementById("dt" + i);
|
||||
var btn = document.getElementById("tg" + i);
|
||||
if (!row) return;
|
||||
var open = row.hidden;
|
||||
row.hidden = !open;
|
||||
if (btn) btn.textContent = open ? "скрыть" : "подробнее";
|
||||
};
|
||||
|
||||
window.kfRecalc = function (id) {
|
||||
fetch("api/recalc?id=" + encodeURIComponent(id), { method: "POST" })
|
||||
.then(function (r) { return r.json(); })
|
||||
@ -321,7 +363,11 @@
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
period: document.getElementById("setPeriod").value,
|
||||
deadline: document.getElementById("setDeadline").value,
|
||||
deadline: function (v) {
|
||||
var m = String(v || "").trim().match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||
if (!m) { var m2 = String(v || "").trim().match(/^(\d{4})-(\d{2})-(\d{2})/); if (m2) return m2[0]; return String(v || ""); }
|
||||
return m[3] + "-" + m[2] + "-" + m[1];
|
||||
}(document.getElementById("setDeadline").value),
|
||||
consolidatorPhone: document.getElementById("setPhone").value
|
||||
})
|
||||
}).then(function (r) { return r.json(); }).then(function (b) {
|
||||
@ -343,7 +389,7 @@
|
||||
document.getElementById("addPhone").value = "";
|
||||
var el = document.createElement("p");
|
||||
el.className = "kfu-pass";
|
||||
el.textContent = "Компания добавлена. Логин: " + b.login + ", пароль: " + b.tempPassword + " — передайте через Аит.";
|
||||
el.textContent = "Компания добавлена. Логин: " + b.login + ", пароль: " + b.tempPassword + " — передайте через Aitu.";
|
||||
document.getElementById("compList").after(el);
|
||||
setTimeout(function () { el.remove(); }, 30000);
|
||||
load();
|
||||
|
||||
57
lib/alem.js
57
lib/alem.js
@ -1,15 +1,53 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Вызов агента Alem «ИИ-агент КФУ» (открытый API).
|
||||
*
|
||||
* Базовый адрес: https://alem.ai.telecom.kz/app/api/v1/open-api
|
||||
* (переменная окружения ALEM_BASE_URL уже содержит путь до /app,
|
||||
* то есть ALEM_BASE_URL = https://alem.ai.telecom.kz/app —
|
||||
* скрипт дописывает /api/v1/open-api/...).
|
||||
*
|
||||
* Шаг 1. Создать сессию:
|
||||
* POST $ALEM_BASE_URL/api/v1/open-api/conversation
|
||||
* Headers: Content-Type: application/json
|
||||
* Authorization: Bearer $ALEM_API_KEY
|
||||
* Body: { "template_id": "$ALEM_TEMPLATE_ID", "title": "...", "user_id": "kfu_app" }
|
||||
* Ответ: { "data": { "session_id": "<uuid>" } }
|
||||
*
|
||||
* Шаг 2. Отправить сообщение в сессию (ответ идёт потоком SSE):
|
||||
* POST $ALEM_BASE_URL/api/v1/open-api/conversation/<session_id>/send
|
||||
* Headers: Content-Type: application/json
|
||||
* Authorization: Bearer $ALEM_API_KEY
|
||||
* Body: { "content": "<текст запроса>", "user_id": "kfu_app", "model_api_key": "$ALEM_API_KEY" }
|
||||
*
|
||||
* Ответ — поток Server-Sent Events, каждая строка вида "data: <json>".
|
||||
* Итог — в последнем событии message_result:
|
||||
* { "detail": { "full_content": "<полный текст ответа агента>" } }
|
||||
* (см. extractSseText). Дальше текст разбирается на JSON (extractJsonFromText).
|
||||
*
|
||||
* Таймаут — 10 минут (ALEM_TIMEOUT_MS). Ключи приходят из окружения,
|
||||
* в код и в git не пишутся.
|
||||
*/
|
||||
|
||||
|
||||
const ALEM_TIMEOUT_MS = 600000;
|
||||
|
||||
|
||||
const ALEM_TIMEOUT_TEXT = "Alem timeout: no response within " + Math.round(ALEM_TIMEOUT_MS / 60000) + " min.";
|
||||
|
||||
function fetchWithTimeout(url, options, timeoutMs) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(function () { controller.abort(); }, timeoutMs);
|
||||
return fetch(url, Object.assign({ signal: controller.signal }, options)).finally(function () {
|
||||
return fetch(url, Object.assign({ signal: controller.signal }, options)).catch(function (err) {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
if (controller.signal.aborted) {
|
||||
const e = new Error(ALEM_TIMEOUT_TEXT);
|
||||
e.code = "ALEM_TIMEOUT";
|
||||
throw e;
|
||||
}
|
||||
throw err;
|
||||
}).finally(function () { clearTimeout(timer); });
|
||||
}
|
||||
|
||||
|
||||
@ -27,7 +65,7 @@ function createAlemSession() {
|
||||
user_id: "kfu_app"
|
||||
})
|
||||
}).then(function (resp) {
|
||||
if (!resp.ok) throw new Error("Alem create " + resp.status + ": " + resp.statusText);
|
||||
if (!resp.ok) { const e = new Error("Alem create HTTP " + resp.status + (resp.statusText ? " " + resp.statusText : "")); e.code = "ALEM_HTTP_" + resp.status; throw e; }
|
||||
return resp.json().then(function (body) {
|
||||
const sid = body && body.data && body.data.session_id;
|
||||
if (!sid) throw new Error("Alem no session_id: " + JSON.stringify(body).slice(0, 200));
|
||||
@ -64,7 +102,7 @@ function askAlem(sessionId, message) {
|
||||
},
|
||||
body: JSON.stringify({ content: message, user_id: 'kfu_app', model_api_key: process.env.ALEM_API_KEY })
|
||||
}, ALEM_TIMEOUT_MS).then(function (resp) {
|
||||
if (!resp.ok) throw new Error('Alem send ' + resp.status + ': ' + resp.statusText);
|
||||
if (!resp.ok) { const e = new Error("Alem send HTTP " + resp.status + (resp.statusText ? " " + resp.statusText : "")); e.code = "ALEM_HTTP_" + resp.status; throw e; }
|
||||
return resp.text().then(function (raw) {
|
||||
return extractSseText(raw);
|
||||
});
|
||||
@ -108,10 +146,19 @@ function callKfuAgent(company, period, fileText) {
|
||||
'Числа — числом (без слов и валютного знака), zone_* — одно из: зелёная, жёлтая, красная, без оценки.',
|
||||
'flags — массив строк (пустой, если замечаний нет), sources — объект-источник цифр, comment — краткое замечание (или пустая строка).'
|
||||
].join("\n");
|
||||
if (!process.env.ALEM_BASE_URL || !process.env.ALEM_API_KEY || !process.env.ALEM_TEMPLATE_ID) {
|
||||
const e = new Error("Агент Alem не настроен: отсутствуют ALEM_BASE_URL / ALEM_API_KEY / ALEM_TEMPLATE_ID.");
|
||||
e.code = "ALEM_NOT_CONFIGURED";
|
||||
return Promise.reject(e);
|
||||
}
|
||||
return createAlemSession().then(function (sid) {
|
||||
return askAlem(sid, prompt).then(function (text) {
|
||||
var data = extractJsonFromText(text);
|
||||
if (!data) throw new Error('Alem не вернул JSON. Ответ: ' + String(text).slice(0, 300));
|
||||
if (!data) {
|
||||
const e = new Error('Ответ агента не JSON. Первые 300 символов: ' + String(text || '').replace(/\s+/g, ' ').trim().slice(0, 300));
|
||||
e.code = "ALEM_NOT_JSON";
|
||||
throw e;
|
||||
}
|
||||
return data;
|
||||
});
|
||||
});
|
||||
|
||||
32
server.js
32
server.js
@ -7,8 +7,9 @@ const crypto = require("crypto");
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const ROOT = __dirname;
|
||||
const DATA_FILE = path.join(ROOT, "data.json");
|
||||
const UPLOADS_DIR = path.join(ROOT, "uploads");
|
||||
let DATA_DIR = path.join(ROOT, "..", ".kfu-data");
|
||||
let DATA_FILE = path.join(DATA_DIR, "data.json");
|
||||
let UPLOADS_DIR = path.join(DATA_DIR, "uploads");
|
||||
|
||||
const store = require("./lib/store");
|
||||
const alem = require("./lib/alem");
|
||||
@ -18,7 +19,8 @@ const PUBLIC_BASE = process.env.KFU_PUBLIC_BASE || "";
|
||||
const SESSION_TTL_MS = 1000 * 60 * 60 * 12;
|
||||
const MAX_UPLOAD = 50 * 1024 * 1024;
|
||||
|
||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
try { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(UPLOADS_DIR, { recursive: true }); }
|
||||
catch (e) { DATA_DIR = ROOT; UPLOADS_DIR = path.join(ROOT, "uploads"); fs.mkdirSync(UPLOADS_DIR, { recursive: true }); }
|
||||
|
||||
const DB = store.load();
|
||||
let GENERATED_PASSWORD = DB._consolidatorTempPassword || null;
|
||||
@ -205,13 +207,25 @@ function computeFromText(user, report, fileText) {
|
||||
sendNotification("report_uploaded", user.companyName, DB.period, "done");
|
||||
}).catch(function (err) {
|
||||
report.status = "error";
|
||||
report.error = String((err && err.message) || err).slice(0, 500);
|
||||
report.error = describeAlemError(err);
|
||||
report.computedAt = iso();
|
||||
store.save(DB);
|
||||
sendNotification("report_uploaded", user.companyName, DB.period, "error");
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
function describeAlemError(err) {
|
||||
const msg = String((err && err.message) || err);
|
||||
const code = err && err.code;
|
||||
if (code === "ALEM_TIMEOUT") return "Агент не ответил за 10 минут. " + msg;
|
||||
if (code === "ALEM_NOT_CONFIGURED") return "Агент Alem не настроен.";
|
||||
if (code === "ALEM_NOT_JSON") return msg;
|
||||
var m = msg.match(/^.*?HTTP (\d\d\d)/);
|
||||
if (m) return "Ошибка HTTP " + m[1] + " от сервера Alem.";
|
||||
return "Ошибка агента: " + msg;
|
||||
}
|
||||
|
||||
// ---------- API handlers ----------
|
||||
function handleMe(req, res) {
|
||||
const user = currentUser(req);
|
||||
@ -325,7 +339,7 @@ function dashboardRows() {
|
||||
uploadedAt: r ? r.uploadedAt : null,
|
||||
person: u.person || "",
|
||||
phone: u.phone || "",
|
||||
submitted: st === "done",
|
||||
submitted: st === "done" || st === "error",
|
||||
};
|
||||
if (r) {
|
||||
row.comment = r.comment || "";
|
||||
@ -646,8 +660,12 @@ function serveStatic(req, res, urlPath) {
|
||||
if (fs.existsSync(path.join(ROOT, file + ".html"))) file = file + ".html";
|
||||
else file = "/index.html";
|
||||
}
|
||||
const full = path.join(ROOT, file.replace(/\//g, path.sep));
|
||||
if (!full.startsWith(ROOT)) { res.writeHead(403); return res.end(); }
|
||||
const norm = path.normalize(file);
|
||||
const full = path.join(ROOT, norm);
|
||||
if (norm.indexOf("..") !== -1 || (full !== ROOT && !full.startsWith(ROOT + path.sep))) {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
return res.end("{}");
|
||||
}
|
||||
if (!fs.existsSync(full) || !fs.statSync(full).isFile()) {
|
||||
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
||||
return res.end("<h1>404</h1>");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user