406 lines
17 KiB
JavaScript
406 lines
17 KiB
JavaScript
const http = require("http");
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
|
||
const PORT = process.env.PORT || 3000;
|
||
const DATA = path.join(__dirname, "data.json");
|
||
|
||
const SEED_QUESTIONS = [
|
||
{
|
||
id: "q1",
|
||
category: "Тарифы",
|
||
text: "При каком условии тариф «Комфорт» считается активным для абонента?",
|
||
options: [
|
||
"С момента подачи заявки на подключение",
|
||
"С момента подписания договора",
|
||
"С даты, указанной в договоре как дата вступления в силу"
|
||
],
|
||
correct: 2,
|
||
explanation: "Тариф активен с даты вступления договора в силу, указанной в самом договоре. Заявка и подписание — этапы до активации."
|
||
},
|
||
{
|
||
id: "q2",
|
||
category: "Контракты",
|
||
text: "Кто несёт ответственность за надлежащее исполнение обязательств по договору с абонентом?",
|
||
options: [
|
||
"Абонент со стороны подключения",
|
||
"Оператор связи",
|
||
"Дилер, продавший услугу"
|
||
],
|
||
correct: 1,
|
||
explanation: "Ответственность перед абонентом несёт оператор, заключивший договор. Дилер — лишь канал продажи, обязательства остаются за оператором."
|
||
},
|
||
{
|
||
id: "q3",
|
||
category: "Правила",
|
||
text: "В течение какого срока с момента обращения абонент должен получить ответ на жалобу?",
|
||
options: ["3 рабочих дня", "10 рабочих дней", "30 календарных дней"],
|
||
correct: 1,
|
||
explanation: "Согласно правилам обслуживания, ответ на жалобу готовится не позднее 10 рабочих дней."
|
||
},
|
||
{
|
||
id: "q4",
|
||
category: "Услуги",
|
||
text: "Какой документ подтверждает факт заключения дополнительных услуг, оказанных на дому у абонента?",
|
||
options: [
|
||
"Электронное письмо отдела сбыта",
|
||
"Акт оказания услуг с подписью абонента",
|
||
"Скриншот переписки в мессенджере"
|
||
],
|
||
correct: 1,
|
||
explanation: "Факт предоставления услуги подтверждается актом с подписью абонента. Бумажный или электронный след без подписи не является подтверждением."
|
||
},
|
||
{
|
||
id: "q5",
|
||
category: "Тарифы",
|
||
text: "Что происходит со сверхлимитным трафиком в тарифном плане с фиксированным пакетом?",
|
||
options: [
|
||
"Услуга блокируется до конца месяца",
|
||
"Трафик оплачивается по тарифу сверхлимитной услуги",
|
||
"Трафик просто не передаётся, абонент ничего не платит"
|
||
],
|
||
correct: 1,
|
||
explanation: "Сверх пакета трафик тарифицируется по ставке сверхлимитной услуги, если абонент не подключил блокировку после исчерпания."
|
||
},
|
||
{
|
||
id: "q6",
|
||
category: "Контракты",
|
||
text: "Какое действие не требуется для перехода абонента на новый договор при продлении?",
|
||
options: [
|
||
"Новая заявка на продление",
|
||
"Подписание нового договора",
|
||
"Присвоение нового номера абонента"
|
||
],
|
||
correct: 2,
|
||
explanation: "При продлении договора номер абонента сохраняется. Новые заявка и договор — да, новый номер — не требуется."
|
||
},
|
||
{
|
||
id: "q7",
|
||
category: "Правила",
|
||
text: "Кто вправе требовать от абонента документы, подтверждающие личность, при личном визите в офис?",
|
||
options: [
|
||
"Только бухгалтерия",
|
||
"Любой сотрудник, принявший обращение",
|
||
"Только руководитель подразделения"
|
||
],
|
||
correct: 1,
|
||
explanation: "Документ о личности запрашивает сотрудник, непосредственно принимающий обращение, чтобы идентифицировать заявителя."
|
||
},
|
||
{
|
||
id: "q8",
|
||
category: "Услуги",
|
||
text: "Какая минимальная запись должна быть в акте при передаче оборудования абоненту?",
|
||
options: [
|
||
"Серийные номера переданного оборудования",
|
||
"Табель о прибытии",
|
||
"Фото оборудования"
|
||
],
|
||
correct: 0,
|
||
explanation: "Акт должен содержать серийные номера переданного оборудования — это то, что привязывает технику именно к абоненту."
|
||
}
|
||
];
|
||
|
||
function defaultData() {
|
||
return {
|
||
questions: SEED_QUESTIONS,
|
||
attempts: [],
|
||
curators: []
|
||
};
|
||
}
|
||
|
||
function load() {
|
||
try {
|
||
const d = JSON.parse(fs.readFileSync(DATA, "utf8"));
|
||
if (!Array.isArray(d.questions)) d.questions = SEED_QUESTIONS;
|
||
if (!Array.isArray(d.attempts)) d.attempts = [];
|
||
if (!Array.isArray(d.curators)) d.curators = [];
|
||
return d;
|
||
} catch (_) {
|
||
return defaultData();
|
||
}
|
||
}
|
||
|
||
function save(d) {
|
||
fs.writeFileSync(DATA, JSON.stringify(d, null, 2));
|
||
}
|
||
|
||
function send(res, code, obj) {
|
||
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
|
||
res.end(JSON.stringify(obj));
|
||
}
|
||
|
||
function readBody(req) {
|
||
return new Promise((resolve) => {
|
||
let s = "";
|
||
req.on("data", (c) => (s += c));
|
||
req.on("end", () => {
|
||
try {
|
||
resolve(JSON.parse(s || "{}"));
|
||
} catch (_) {
|
||
resolve({});
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function normalizePersonName(v) {
|
||
return String(v || "").replace(/\s+/g, " ").trim();
|
||
}
|
||
|
||
const server = http.createServer(async (req, res) => {
|
||
const url = req.url.split("?")[0];
|
||
|
||
// ── API ──────────────────────────────────────────────────────────
|
||
|
||
if (req.method === "GET" && url === "/api/questions") {
|
||
const d = load();
|
||
const safe = d.questions.map((q) => ({
|
||
id: q.id,
|
||
category: q.category,
|
||
text: q.text,
|
||
options: q.options
|
||
}));
|
||
return send(res, 200, { questions: safe });
|
||
}
|
||
|
||
if (req.method === "GET" && url === "/api/categories") {
|
||
const d = load();
|
||
const cats = {};
|
||
d.questions.forEach((q) => {
|
||
if (q.category && q.text && q.options && q.options.length >= 2) cats[q.category] = (cats[q.category] || 0) + 1;
|
||
});
|
||
return send(res, 200, { categories: Object.keys(cats).sort() });
|
||
}
|
||
|
||
if (req.method === "POST" && url === "/api/attempts") {
|
||
const b = await readBody(req);
|
||
const fio = normalizePersonName(b.fio);
|
||
const dept = String(b.dept || "").replace(/\s+/g, " ").trim();
|
||
const choices = b.choices && typeof b.choices === "object" ? b.choices : {}; // { qid: idx }
|
||
const tryNo = Math.max(1, parseInt(b.tryNo, 10) || 1);
|
||
const d = load();
|
||
|
||
if (!fio || !dept) return send(res, 400, { ok: false, error: "Укажите ФИО и подразделение" });
|
||
if (!d.questions.length) return send(res, 400, { ok: false, error: "Вопросы ещё не добавлены" });
|
||
|
||
const wrong = [];
|
||
const correctCount = { ok: 0, total: 0 };
|
||
d.questions.forEach((q) => {
|
||
const chosen = typeof choices[q.id] === "number" ? choices[q.id] : -1;
|
||
if (typeof q.correct === "number" && q.correct >= 0) {
|
||
correctCount.total += 1;
|
||
if (chosen === q.correct) correctCount.ok += 1;
|
||
else wrong.push({ qid: q.id, question: q.text, options: q.options, correct: q.correct, chosen, explanation: q.explanation || "" });
|
||
}
|
||
});
|
||
const percent = correctCount.total ? Math.round((correctCount.ok / correctCount.total) * 100) : 0;
|
||
const passed = percent >= 70;
|
||
|
||
// Попытки: 1-я всегда доступна. 2-я — только если 1-я не пройдена (70%).
|
||
const prior = d.attempts.filter((a) => a.fio === fio && a.dept === dept);
|
||
if (tryNo === 2 && prior.some((a) => a.passed)) {
|
||
return send(res, 400, { ok: false, error: "У вас уже есть сданный результат — повторное прохождение не требуется" });
|
||
}
|
||
|
||
// Если эту попытку уже заполняли — перезаписываем её (лучший результат остаётся).
|
||
const prevSame = d.attempts.find((a) => a.fio === fio && a.dept === dept && a.tryNo === tryNo);
|
||
if (prevSame) d.attempts = d.attempts.filter((a) => a !== prevSame);
|
||
|
||
const rec = {
|
||
id: "at" + Date.now(),
|
||
fio,
|
||
dept,
|
||
tryNo,
|
||
percent,
|
||
correct: correctCount.ok,
|
||
total: correctCount.total,
|
||
passed,
|
||
date: new Date().toISOString(),
|
||
wrong
|
||
};
|
||
d.attempts.push(rec);
|
||
save(d);
|
||
return send(res, 201, { ok: true, percent, passed, tryNo, wrongCount: wrong.length, id: rec.id });
|
||
}
|
||
|
||
if (req.method === "GET" && url.startsWith("/api/stat")) {
|
||
const d = load();
|
||
const byPerson = {};
|
||
d.attempts.forEach((a) => {
|
||
const key = a.fio + "‖" + a.dept;
|
||
if (!byPerson[key]) byPerson[key] = { fio: a.fio, dept: a.dept, count: 0, best: -1, lastPct: 0, lastDate: "", lastTry: 1, passed: 0 };
|
||
const p = byPerson[key];
|
||
p.count += 1;
|
||
if (a.percent > p.best) p.best = a.percent;
|
||
if (a.percent >= 70) p.passed += 1;
|
||
if (new Date(a.date) > new Date(p.lastDate)) {
|
||
p.lastPct = a.percent;
|
||
p.lastDate = a.date;
|
||
p.lastTry = a.tryNo;
|
||
}
|
||
});
|
||
const people = Object.keys(byPerson).map((k) => byPerson[k]).sort((a, b) => b.best - a.best);
|
||
|
||
const byCat = {};
|
||
d.attempts.forEach((a) => {
|
||
(a.wrong || []).forEach((w) => {
|
||
const q = d.questions.find((x) => x.id === w.qid);
|
||
if (!q) return;
|
||
if (!byCat[q.category]) byCat[q.category] = { misses: 0, attempts: 0 };
|
||
byCat[q.category].misses += 1;
|
||
byCat[q.category].attempts += 1;
|
||
});
|
||
});
|
||
d.questions.forEach((q) => {
|
||
if (!byCat[q.category]) byCat[q.category] = { misses: 0, attempts: 0 };
|
||
});
|
||
|
||
return send(res, 200, {
|
||
people,
|
||
totalAttempts: d.attempts.length,
|
||
totalPassed: d.attempts.filter((a) => a.passed).length,
|
||
byCategory: byCat,
|
||
questionCount: d.questions.length
|
||
});
|
||
}
|
||
|
||
if (req.method === "GET" && url.startsWith("/api/person/")) {
|
||
const key = url.replace("/api/person/", "").split("‖");
|
||
const fio = key[0] || "";
|
||
const dept = key[1] || "";
|
||
const d = load();
|
||
const attempts = d.attempts
|
||
.filter((a) => a.fio === fio && a.dept === dept)
|
||
.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||
return send(res, 200, { attempts, questions: d.questions });
|
||
}
|
||
|
||
// Рецензенты (curators)
|
||
if (req.method === "GET" && url === "/api/curators") {
|
||
const d = load();
|
||
return send(res, 200, { curators: d.curators });
|
||
}
|
||
if (req.method === "POST" && url === "/api/curators") {
|
||
const b = await readBody(req);
|
||
const fio = normalizePersonName(b.fio);
|
||
const code = String(b.code || "").trim().toLowerCase();
|
||
if (!fio || !code) return send(res, 400, { ok: false, error: "Дайте ФИО и код" });
|
||
const d = load();
|
||
d.curators = d.curators.filter((c) => c.code !== code);
|
||
d.curators.push({ fio, code });
|
||
save(d);
|
||
return send(res, 200, { ok: true });
|
||
}
|
||
if (req.method === "POST" && url === "/api/curators/check") {
|
||
const b = await readBody(req);
|
||
const fio = normalizePersonName(b.fio);
|
||
const code = String(b.code || "").trim().toLowerCase();
|
||
const d = load();
|
||
const ok = d.curators.some((c) => c.fio === fio && c.code === code);
|
||
return send(res, 200, { ok });
|
||
}
|
||
if (req.method === "DELETE" && url === "/api/curators") {
|
||
const b = await readBody(req);
|
||
const fio = normalizePersonName(b.fio);
|
||
const code = String(b.code || "").trim().toLowerCase();
|
||
const d = load();
|
||
d.curators = d.curators.filter((c) => !(c.fio === fio && c.code === code));
|
||
save(d);
|
||
return send(res, 200, { ok: true });
|
||
}
|
||
|
||
// Управление вопросами (куратор)
|
||
if (req.method === "GET" && url === "/api/admin/questions") {
|
||
const d = load();
|
||
return send(res, 200, { questions: d.questions });
|
||
}
|
||
if (req.method === "POST" && url === "/api/admin/questions") {
|
||
const b = await readBody(req);
|
||
const text = String(b.text || "").trim();
|
||
const category = String(b.category || "").trim() || "Тарифы";
|
||
const options = Array.isArray(b.options) ? b.options.map((o) => String(o).trim()).filter(Boolean) : [];
|
||
const correct = parseInt(b.correct, 10) || 0;
|
||
const explanation = String(b.explanation || "").trim();
|
||
if (!text || options.length < 2 || correct < 0 || correct >= options.length) {
|
||
return send(res, 400, { ok: false, error: "Нужен вопрос, не менее 2 вариантов и выбранный правильный ответ" });
|
||
}
|
||
const d = load();
|
||
const q = {
|
||
id: "q" + Date.now(),
|
||
category,
|
||
text,
|
||
options,
|
||
correct,
|
||
explanation
|
||
};
|
||
d.questions.push(q);
|
||
save(d);
|
||
return send(res, 201, { ok: true, q });
|
||
}
|
||
if (req.method === "POST" && url.match(/^\/api\/admin\/questions\/.+$/)) {
|
||
const id = url.split("/").pop();
|
||
const b = await readBody(req);
|
||
const d = load();
|
||
const q = d.questions.find((x) => x.id === id);
|
||
if (!q) return send(res, 404, { ok: false, error: "Вопрос не найден" });
|
||
if (b.text) q.text = String(b.text).trim();
|
||
if (b.category) q.category = String(b.category).trim();
|
||
if (Array.isArray(b.options) && b.options.length >= 2) {
|
||
q.options = b.options.map((o) => String(o).trim()).filter(Boolean);
|
||
if (typeof b.correct === "number") q.correct = Math.min(b.correct, q.options.length - 1);
|
||
}
|
||
if (b.explanation) q.explanation = String(b.explanation).trim();
|
||
save(d);
|
||
return send(res, 200, { ok: true, q });
|
||
}
|
||
if (req.method === "DELETE" && url.match(/^\/api\/admin\/questions\/.+$/)) {
|
||
const id = url.split("/").pop();
|
||
const d = load();
|
||
const before = d.questions.length;
|
||
d.questions = d.questions.filter((q) => q.id !== id);
|
||
save(d);
|
||
return send(res, 200, { ok: true, deleted: before - d.questions.length });
|
||
}
|
||
|
||
// ── Статика ──────────────────────────────────────────────────────
|
||
if (url === "/" || url === "/index.html") {
|
||
return serveFile(path.join(__dirname, "index.html"), "text/html; charset=utf-8");
|
||
}
|
||
return serveStatic(req, res);
|
||
|
||
function serveFile(p, type) {
|
||
if (fs.existsSync(p) && fs.statSync(p).isFile()) {
|
||
res.writeHead(200, { "Content-Type": type });
|
||
return res.end(fs.readFileSync(p));
|
||
}
|
||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||
res.end("Not found: " + p);
|
||
}
|
||
function serveStatic(req, res) {
|
||
const p = req.url.split("?")[0];
|
||
const full = path.join(__dirname, p);
|
||
if (p.startsWith("/api/")) {
|
||
res.writeHead(404, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ ok: false, error: "No route" }));
|
||
}
|
||
if (!full.startsWith(__dirname)) {
|
||
res.writeHead(403);
|
||
return res.end("Forbidden");
|
||
}
|
||
if (fs.existsSync(full) && fs.statSync(full).isFile()) {
|
||
const ext = path.extname(full);
|
||
const type =
|
||
ext === ".css" ? "text/css" :
|
||
ext === ".js" ? "application/javascript" :
|
||
ext === ".json" ? "application/json" :
|
||
ext === ".png" ? "image/png" :
|
||
ext === ".svg" ? "image/svg+xml" : "text/html";
|
||
return serveFile(full, type + "; charset=utf-8");
|
||
}
|
||
// SPA-fallback
|
||
return serveFile(path.join(__dirname, "index.html"), "text/html; charset=utf-8");
|
||
}
|
||
});
|
||
|
||
server.listen(PORT, () => console.log("Сервер на порту " + PORT));
|