334 lines
11 KiB
JavaScript
334 lines
11 KiB
JavaScript
const http = require("http");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const XLSX = require("xlsx");
|
|
const formidable = require("formidable");
|
|
|
|
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: [], monthlyData: [] }; }
|
|
}
|
|
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 = {};
|
|
|
|
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)
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Парсинг Excel файла
|
|
function parseExcelFile(filePath) {
|
|
const wb = XLSX.readFile(filePath);
|
|
const sheet = wb.Sheets[wb.SheetNames[0]];
|
|
const data = XLSX.utils.sheet_to_json(sheet, { header: 1 });
|
|
|
|
const months = [];
|
|
const globalParams = {
|
|
ahtIncoming: 4.25,
|
|
serviceTimeIncoming: 40,
|
|
patienceIncoming: 40,
|
|
targetSL: 80,
|
|
ahtOutgoing: 4.5,
|
|
ahtTickets: 12,
|
|
ahtChats: 90,
|
|
concurrency: 4,
|
|
fteHours: 189,
|
|
utilization: 0.86,
|
|
plannedAbsence: 0.1,
|
|
unplannedAbsence: 0.05
|
|
};
|
|
|
|
for (let row of data) {
|
|
if (!row || row.length === 0) continue;
|
|
const firstCell = String(row[0] || '').toLowerCase();
|
|
|
|
if (firstCell.includes('aht') && firstCell.includes('вход')) {
|
|
const val = parseFloat(row[1]);
|
|
if (!isNaN(val)) globalParams.ahtIncoming = val;
|
|
}
|
|
if (firstCell.includes('service') && firstCell.includes('вход')) {
|
|
const val = parseFloat(row[1]);
|
|
if (!isNaN(val)) globalParams.serviceTimeIncoming = val;
|
|
}
|
|
if (firstCell.includes('patience')) {
|
|
const val = parseFloat(row[1]);
|
|
if (!isNaN(val)) globalParams.patienceIncoming = val;
|
|
}
|
|
if (firstCell.includes('target') || firstCell.includes('service level')) {
|
|
const val = parseFloat(row[1]);
|
|
if (!isNaN(val)) globalParams.targetSL = val;
|
|
}
|
|
if (firstCell.includes('aht') && firstCell.includes('исход')) {
|
|
const val = parseFloat(row[1]);
|
|
if (!isNaN(val)) globalParams.ahtOutgoing = val;
|
|
}
|
|
if (firstCell.includes('aht') && firstCell.includes('тикет')) {
|
|
const val = parseFloat(row[1]);
|
|
if (!isNaN(val)) globalParams.ahtTickets = val;
|
|
}
|
|
if (firstCell.includes('aht') && firstCell.includes('чат')) {
|
|
const val = parseFloat(row[1]);
|
|
if (!isNaN(val)) globalParams.ahtChats = val;
|
|
}
|
|
if (firstCell.includes('concurrency')) {
|
|
const val = parseFloat(row[1]);
|
|
if (!isNaN(val)) globalParams.concurrency = val;
|
|
}
|
|
if (firstCell.includes('fte') && firstCell.includes('час')) {
|
|
const val = parseFloat(row[1]);
|
|
if (!isNaN(val) && val > 0) globalParams.fteHours = val;
|
|
}
|
|
if (firstCell.includes('utilization')) {
|
|
const val = parseFloat(row[1]);
|
|
if (!isNaN(val)) globalParams.utilization = val;
|
|
}
|
|
if (firstCell.includes('планов') && firstCell.includes('отсутств')) {
|
|
const val = parseFloat(row[1]);
|
|
if (!isNaN(val)) globalParams.plannedAbsence = val;
|
|
}
|
|
if (firstCell.includes('неплан') && firstCell.includes('отсутств')) {
|
|
const val = parseFloat(row[1]);
|
|
if (!isNaN(val)) globalParams.unplannedAbsence = val;
|
|
}
|
|
|
|
if (firstCell.match(/^\d+$/) || firstCell.match(/^\d{1,2}[.\-]\d{1,2}$/) ||
|
|
firstCell.includes('янв') || firstCell.includes('фев') || firstCell.includes('мар') ||
|
|
firstCell.includes('апр') || firstCell.includes('май') || firstCell.includes('июн') ||
|
|
firstCell.includes('июл') || firstCell.includes('авг') || firstCell.includes('сен') ||
|
|
firstCell.includes('окт') || firstCell.includes('ноя') || firstCell.includes('дек') ||
|
|
firstCell.includes('month')) {
|
|
|
|
let monthName = row[0];
|
|
let incomingCalls = null, outgoingCalls = null, tickets = null, chats = null;
|
|
|
|
for (let i = 1; i < row.length; i++) {
|
|
const cell = String(row[i] || '');
|
|
const val = parseFloat(cell.replace(',', '.').replace(/\s/g, ''));
|
|
if (!isNaN(val)) {
|
|
if (incomingCalls === null) incomingCalls = val;
|
|
else if (outgoingCalls === null) outgoingCalls = val;
|
|
else if (tickets === null) tickets = val;
|
|
else if (chats === null) chats = val;
|
|
}
|
|
}
|
|
|
|
if (incomingCalls !== null && incomingCalls > 0) {
|
|
months.push({
|
|
month: monthName,
|
|
incomingCalls,
|
|
outgoingCalls: outgoingCalls || 0,
|
|
tickets: tickets || 0,
|
|
chats: chats || 0
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return { months, globalParams };
|
|
}
|
|
|
|
http.createServer(async (req, res) => {
|
|
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();
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
if (req.method === "POST" && req.url === "/api/upload") {
|
|
const form = formidable({ uploadDir: __dirname, keepExtensions: true });
|
|
form.parse(req, async (err, fields, files) => {
|
|
if (err) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
return res.end(JSON.stringify({ error: err.message }));
|
|
}
|
|
|
|
const file = files.file && files.file[0];
|
|
if (!file) {
|
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
return res.end(JSON.stringify({ error: "Файл не найден" }));
|
|
}
|
|
|
|
try {
|
|
const parsed = parseExcelFile(file.filepath);
|
|
|
|
const monthlyResults = parsed.months.map(m => ({
|
|
...m,
|
|
results: calculateFTE({
|
|
...m,
|
|
...parsed.globalParams,
|
|
...fields
|
|
})
|
|
}));
|
|
|
|
const data = readData();
|
|
data.monthlyData = monthlyResults;
|
|
writeData(data);
|
|
|
|
fs.unlinkSync(file.filepath);
|
|
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
return res.end(JSON.stringify({
|
|
ok: true,
|
|
months: monthlyResults,
|
|
globalParams: parsed.globalParams
|
|
}));
|
|
} catch (e) {
|
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
return res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
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()));
|
|
}
|
|
|
|
if (req.method === "GET" && req.url === "/api/monthly") {
|
|
const data = readData();
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
return res.end(JSON.stringify(data.monthlyData));
|
|
}
|
|
|
|
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));
|