180 lines
5.9 KiB
JavaScript
180 lines
5.9 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");
|
||
|
||
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));
|