@@ -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 ? "
" + esc(flags) + "
" : "");
}
function stHtml(row) {
- if (!row || !row.status) return "
нет данных ";
- var m = { done: ["kfu-st-done", "сдан"], processing: ["kfu-st-processing", "расчёт"], error: ["kfu-st-error", "ошибка"] };
+ if (!row || !row.status || row.status === "not_submitted") return "
не сдан ";
+ 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 "
" + it[1] + " ";
}
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) + " компаний:
" + done + " сдано, " + proc + " на расчёте, " + err + " ошибок";
+ esc(rows.length) + " компаний:
" + submitted + " сдано (" + 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("
пересчитать ");
}
+ 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 "
" +
- "" + esc(r.company) + " " +
+ "" + esc(r.company) +
+ (hasDetail ? "подробнее " : "") +
+ (r.error ? "" + esc(r.error) + "
" : "") +
+ " " +
"" + stHtml(r) + " " +
"" + esc(fmtDate(r.uploadedAt)) + " " +
- "" + esc(r.comment) + " " +
"" + fmtNum(r.debt) + " " +
"" + fmtNum(r.ebitda_ltm) + " " +
"" + fmtNum(r.interest_ltm) + " " +
@@ -211,9 +241,13 @@
"" + fmtNum(r.k2) + " " +
"" + fmtNum(r.k3) + " " +
"" + zoneHtml(r) + " " +
- "" + esc(r.agent_comment) + " " +
- "" + acts.join(" ") + " " +
- " ";
+ "
" + actsStr + " " +
+ "" +
+ (hasDetail ? "
" +
+ (r.comment ? "Комментарий дочки: " + esc(r.comment) + "
" : "") +
+ (r.agent_comment ? "Замечания агента: " + esc(r.agent_comment) + "
" : "") +
+ ((r.flags && r.flags.length) ? "Признаки: " + r.flags.map(esc).join(" ") + "
" : "") +
+ " " : "");
}).join("");
tbody.innerHTML = html;
document.getElementById("exportBtn").setAttribute("href", "api/export?t=" + Date.now());
@@ -226,7 +260,7 @@
"
" +
"
" +
"
" +
- "
" + esc(c.login) + " " +
+ "
" + esc(c.login) + " " +
"
" +
"сохранить " +
"пароль " +
@@ -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();
diff --git a/lib/alem.js b/lib/alem.js
index 4ca4e7b..8fab7a1 100644
--- a/lib/alem.js
+++ b/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": "" } }
+ *
+ * Шаг 2. Отправить сообщение в сессию (ответ идёт потоком SSE):
+ * POST $ALEM_BASE_URL/api/v1/open-api/conversation//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: ".
+ * Итог — в последнем событии 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;
});
});
diff --git a/server.js b/server.js
index 9dbb1f0..6665728 100644
--- a/server.js
+++ b/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("404 ");