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"); function readData() { try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return { calculations: [] }; } } function writeData(data) { fs.writeFileSync(DATA, JSON.stringify(data, null, 2)); } function body(req) { return new Promise((resolve) => { let s = ""; req.on("data", (c) => (s += c)); req.on("end", () => { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } }); }); } // Формула Эрланга C (вероятность ожидания) function erlangC(A, N) { if (N <= 0 || A <= 0) return 1; let sum = 0; for (let i = 0; i < N; i++) { sum += Math.pow(A, i) / factorial(i); } const numerator = (Math.pow(A, N) / factorial(N)) * (N / (N - A)); const denominator = sum + numerator; return numerator / denominator; } function factorial(n) { if (n <= 1) return 1; let result = 1; for (let i = 2; i <= n; i++) result *= i; return result; } // Расчёт требуемого количества агентов (Erlang C) function calculateAgents(contacts, ahtMin, serviceTimeSec, patienceSec, targetSL) { const ahtSec = ahtMin * 60; const trafficIntensity = (contacts * ahtSec) / 3600; // в Эрлангах за час let agents = Math.ceil(trafficIntensity); let sl = 0; for (let n = agents; n <= agents + 50; n++) { const pWait = erlangC(trafficIntensity, n); const avgSpeed = (pWait * serviceTimeSec) / (n - trafficIntensity); const probAnswered = 1 - pWait * Math.exp(-(n - trafficIntensity) * patienceSec / serviceTimeSec); sl = probAnswered * 100; if (sl >= targetSL) { agents = n; break; } } return { agents, trafficIntensity, sl: Math.min(sl, 99.9) }; } // Расчёт FTE для разных каналов function calculateFTE(params) { const results = {}; // Входящие звонки (Erlang C) if (params.incomingCalls && params.incomingCalls > 0) { const r = calculateAgents( params.incomingCalls, params.ahtIncoming || 4, params.serviceTimeIncoming || 40, params.patienceIncoming || 40, params.targetSL || 80 ); results.incoming = { fte: r.agents, trafficIntensity: r.trafficIntensity.toFixed(2), sl: r.sl.toFixed(1) }; } // Исходящие звонки (простой расчёт по времени) if (params.outgoingCalls && params.outgoingCalls > 0) { const totalMinutes = params.outgoingCalls * (params.ahtOutgoing || 4.5); const workingMinutes = (params.fteHours || 189) * 60 * (params.utilization || 0.86); results.outgoing = { fte: Math.ceil(totalMinutes / workingMinutes * 10) / 10, totalHours: (totalMinutes / 60).toFixed(1) }; } // Неголосовые тикеты if (params.tickets && params.tickets > 0) { const totalMinutes = params.tickets * (params.ahtTickets || 12); const workingMinutes = (params.fteHours || 189) * 60 * (params.utilization || 0.86); results.tickets = { fte: Math.ceil(totalMinutes / workingMinutes * 10) / 10, totalHours: (totalMinutes / 60).toFixed(1) }; } // Чаты (с учётом concurrency) if (params.chats && params.chats > 0) { const concurrency = params.concurrency || 4; const totalMinutes = (params.chats * (params.ahtChats || 90)) / concurrency; const workingMinutes = (params.fteHours || 189) * 60 * (params.utilization || 0.86); results.chats = { fte: Math.ceil(totalMinutes / workingMinutes * 10) / 10, totalHours: (totalMinutes / 60).toFixed(1), concurrency }; } // Итого results.total = { fteInSchedule: Object.values(results).reduce((sum, r) => sum + (r.fte || 0), 0), fteWithAbsence: 0, absence: params.plannedAbsence || 0.1, unplannedAbsence: params.unplannedAbsence || 0.05 }; // Учёт отсутствий const absenceMultiplier = 1 / (1 - (results.total.absence + results.total.unplannedAbsence)); results.total.fteWithAbsence = Math.ceil(results.total.fteInSchedule * absenceMultiplier * 10) / 10; return results; } http.createServer(async (req, res) => { // CORS для предпросмотра res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); if (req.method === "OPTIONS") { res.writeHead(200); return res.end(); } // API: расчёт WFM if (req.method === "POST" && req.url === "/api/calculate") { const params = await body(req); const results = calculateFTE(params); // Сохранение расчёта const data = readData(); data.calculations.push({ id: Date.now(), date: new Date().toISOString(), params, results }); writeData(data); res.writeHead(200, { "Content-Type": "application/json" }); return res.end(JSON.stringify(results)); } // API: история расчётов if (req.method === "GET" && req.url === "/api/history") { const data = readData(); res.writeHead(200, { "Content-Type": "application/json" }); return res.end(JSON.stringify(data.calculations.slice(-10).reverse())); } // Статика let file = req.url === "/" ? "/index.html" : req.url.split("?")[0]; const full = path.join(__dirname, file); if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) { const ext = path.extname(full); const type = ext === ".css" ? "text/css" : ext === ".js" ? "application/javascript" : "text/html"; res.writeHead(200, { "Content-Type": type + "; charset=utf-8" }); return res.end(fs.readFileSync(full)); } res.writeHead(404); res.end("Not found"); }).listen(PORT, () => console.log("WFM Calculator на порту " + PORT));