mne-nuzhno-sdelat-sayt-ili-p/server.js

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));