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