From 132d02c03b8b2ec289486adec61e4288e2b1a560 Mon Sep 17 00:00:00 2001 From: Rashid Date: Wed, 16 Sep 2026 10:22:21 +0000 Subject: [PATCH] =?UTF-8?q?WFM:=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7?= =?UTF-8?q?=D0=BA=D0=B0=20Excel=20+=20=D1=80=D0=B0=D1=81=D1=87=D1=91=D1=82?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D0=BC=D0=B5=D1=81=D1=8F=D1=86=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 213 +++++++++++++++++++++++++++------------------- package-lock.json | 178 +++++++++++++++++++++++++++++++++++++- package.json | 4 + script.js | 165 +++++++++++++++++++++++++++++++++++ server.js | 180 ++++++++++++++++++++++++++++++++++++--- 5 files changed, 639 insertions(+), 101 deletions(-) diff --git a/index.html b/index.html index 870a296..e80cb4d 100644 --- a/index.html +++ b/index.html @@ -23,6 +23,18 @@ .total-block { background: var(--kt-ai-primary); color: white; padding: var(--kt-ai-space-6); border-radius: var(--kt-ai-radius-3xl); margin-top: var(--kt-ai-space-6); } .total-block .result-value { color: white; } .total-block .result-label { color: rgba(255,255,255,0.8); } + .upload-area { border: 2px dashed var(--kt-ai-border); border-radius: var(--kt-ai-radius-3xl); padding: var(--kt-ai-space-8); text-align: center; cursor: pointer; transition: all 0.2s; } + .upload-area:hover { border-color: var(--kt-ai-primary); background: var(--kt-ai-bg-soft); } + .upload-area.dragover { border-color: var(--kt-ai-primary); background: var(--kt-ai-primary); color: white; } + .monthly-table { width: 100%; border-collapse: collapse; margin-top: var(--kt-ai-space-4); } + .monthly-table th, .monthly-table td { padding: var(--kt-ai-space-3); text-align: right; border-bottom: 1px solid var(--kt-ai-border); } + .monthly-table th { text-align: left; color: var(--kt-ai-fg-muted); font-weight: var(--kt-ai-weight-medium); } + .monthly-table tr:hover { background: var(--kt-ai-bg-soft); } + .tabs { display: flex; gap: var(--kt-ai-space-2); margin-bottom: var(--kt-ai-space-4); } + .tab { padding: var(--kt-ai-space-2) var(--kt-ai-space-4); border-radius: var(--kt-ai-radius-lg); cursor: pointer; color: var(--kt-ai-fg-muted); } + .tab.active { background: var(--kt-ai-primary); color: white; } + .mode-switch { display: none; } + .mode-switch.active { display: block; } @@ -41,113 +53,140 @@

Расчёт численности операторов Контакт-центра

-

Профессиональный инструмент WFM на основе формул Эрланга. Рассчитайте требуемое количество агентов для входящих звонков, исходящих, чатов и тикетов.

+

Профессиональный инструмент WFM на основе формул Эрланга. Загрузите Excel с прогнозом или введите данные вручную.

-

Параметры расчёта

-

Введите данные по каждому каналу связи

+

Режим расчёта

+

Выберите способ ввода данных

-
-
- -
-

Входящие звонки

-
- - +
+
Вручную
+
Загрузить Excel
+
По месяцам
+
+ + +
+ +
+
+

Входящие звонки

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
- - + +
+

Исходящие звонки

+
+ + +
+
+ + +
-
- - + +
+

Неголосовые тикеты

+
+ + +
+
+ + +
-
- - + +
+

Чаты

+
+ + +
+
+ + +
+
+ + +
-
- - + +
+

Общие параметры

+
+ + +
+
+ + +
+
+ + +
+
+ + +
- -
-

Исходящие звонки

-
- - -
-
- - -
+
+
- - -
-

Неголосовые тикеты

-
- - -
-
- - -
+ +
+ + +
+
+
+ +

Загрузите Excel файл

+

Перетащите файл или нажмите для выбора

+

Формат: как в примере "Расчет численности ЦОО 2026 v1.xlsx"

+
- - +
+
+
+ + +
+
-

Чаты

-
- - -
-
- - -
-
- - -
-
- - -
-

Общие параметры

-
- - -
-
- - -
-
- - -
-
- - -
+

Загрузите Excel файл во вкладке "Загрузить Excel" для расчёта по месяцам

- -
- -
- +
diff --git a/package-lock.json b/package-lock.json index efe9f2c..82b09fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,7 +4,183 @@ "requires": true, "packages": { "": { - "name": "wfm-calculator" + "name": "wfm-calculator", + "dependencies": { + "formidable": "^3.5.1", + "xlsx": "^0.18.5" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } } } } diff --git a/package.json b/package.json index 42bc1c6..5ae06b6 100644 --- a/package.json +++ b/package.json @@ -4,5 +4,9 @@ "type": "commonjs", "scripts": { "start": "node server.js" + }, + "dependencies": { + "xlsx": "^0.18.5", + "formidable": "^3.5.1" } } diff --git a/script.js b/script.js index f77431b..fcbd13d 100644 --- a/script.js +++ b/script.js @@ -2,6 +2,29 @@ document.addEventListener('DOMContentLoaded', function() { const form = document.getElementById('wfmForm'); const resultsSection = document.getElementById('results'); const historyList = document.getElementById('historyList'); + const uploadArea = document.getElementById('uploadArea'); + const fileInput = document.getElementById('fileInput'); + const uploadStatus = document.getElementById('uploadStatus'); + const monthlyResults = document.getElementById('monthlyResults'); + const tabs = document.querySelectorAll('.tab'); + const modes = { + manual: document.getElementById('manualMode'), + upload: document.getElementById('uploadMode'), + monthly: document.getElementById('monthlyMode') + }; + + tabs.forEach(tab => { + tab.addEventListener('click', function() { + const mode = this.dataset.mode; + tabs.forEach(t => t.classList.remove('active')); + this.classList.add('active'); + Object.keys(modes).forEach(k => modes[k].classList.remove('active')); + modes[mode].classList.add('active'); + if (mode === 'monthly') { + loadMonthlyResults(); + } + }); + }); form.addEventListener('submit', async function(e) { e.preventDefault(); @@ -32,6 +55,61 @@ document.addEventListener('DOMContentLoaded', function() { } }); + uploadArea.addEventListener('click', () => fileInput.click()); + + uploadArea.addEventListener('dragover', (e) => { + e.preventDefault(); + uploadArea.classList.add('dragover'); + }); + + uploadArea.addEventListener('dragleave', () => { + uploadArea.classList.remove('dragover'); + }); + + uploadArea.addEventListener('drop', (e) => { + e.preventDefault(); + uploadArea.classList.remove('dragover'); + const files = e.dataTransfer.files; + if (files.length > 0) { + handleFile(files[0]); + } + }); + + fileInput.addEventListener('change', (e) => { + if (e.target.files.length > 0) { + handleFile(e.target.files[0]); + } + }); + + async function handleFile(file) { + uploadStatus.innerHTML = '
Загрузка файла...
'; + + const formData = new FormData(); + formData.append('file', file); + + try { + const response = await fetch('/api/upload', { + method: 'POST', + body: formData + }); + + const result = await response.json(); + + if (result.ok) { + uploadStatus.innerHTML = '
✅ Файл загружен! Найдено месяцев: ' + result.months.length + '
'; + + if (result.months.length > 0) { + displayMonthlyResults(result.months, result.globalParams); + tabs[2].click(); + } + } else { + uploadStatus.innerHTML = '
Ошибка: ' + result.error + '
'; + } + } catch (error) { + uploadStatus.innerHTML = '
Ошибка загрузки: ' + error.message + '
'; + } + } + function displayResults(results) { if (results.incoming) { document.getElementById('resIncoming').textContent = results.incoming.fte; @@ -63,6 +141,71 @@ document.addEventListener('DOMContentLoaded', function() { document.getElementById('resTotalWithAbsence').textContent = results.total.fteWithAbsence.toFixed(1); } + function displayMonthlyResults(months, params) { + const totalFTE = months.reduce((sum, m) => sum + m.results.total.fteWithAbsence, 0); + const avgFTE = totalFTE / months.length; + + let html = ` +
+

Итого за период

+
+
+
${avgFTE.toFixed(1)}
+
Среднее FTE в штате
+
+
+
${totalFTE.toFixed(1)}
+
Суммарно FTE (все месяцы)
+
+
+
${months.length}
+
Месяцев в расчёте
+
+
+
+ +
+

Детализация по месяцам

+
+ + + + + + + + + + + + + + `; + + months.forEach(m => { + html += ` + + + + + + + + + + `; + }); + + html += ` + +
МесяцВходящиеИсходящиеТикетыЧатыFTE в графикеFTE в штате
${m.month}${m.incomingCalls.toLocaleString('ru-RU')}${m.outgoingCalls ? m.outgoingCalls.toLocaleString('ru-RU') : '-'}${m.tickets ? m.tickets.toLocaleString('ru-RU') : '-'}${m.chats ? m.chats.toLocaleString('ru-RU') : '-'}${m.results.total.fteInSchedule.toFixed(1)}${m.results.total.fteWithAbsence.toFixed(1)}
+
+
+ `; + + monthlyResults.innerHTML = html; + } + async function loadHistory() { try { const response = await fetch('/api/history'); @@ -97,5 +240,27 @@ document.addEventListener('DOMContentLoaded', function() { } } + async function loadMonthlyResults() { + try { + const response = await fetch('/api/monthly'); + const data = await response.json(); + + if (!data || data.length === 0) { + monthlyResults.innerHTML = ` +
+

Нет данных

+

Загрузите Excel файл во вкладке "Загрузить Excel" для расчёта по месяцам

+
+ `; + return; + } + + displayMonthlyResults(data, {}); + } catch (error) { + console.error('Ошибка:', error); + monthlyResults.innerHTML = '
Ошибка загрузки данных
'; + } + } + loadHistory(); }); diff --git a/server.js b/server.js index e2d7949..e9122ab 100644 --- a/server.js +++ b/server.js @@ -1,12 +1,14 @@ 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: [] }; } + try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return { calculations: [], monthlyData: [] }; } } function writeData(data) { fs.writeFileSync(DATA, JSON.stringify(data, null, 2)); @@ -41,7 +43,7 @@ function factorial(n) { // Расчёт требуемого количества агентов (Erlang C) function calculateAgents(contacts, ahtMin, serviceTimeSec, patienceSec, targetSL) { const ahtSec = ahtMin * 60; - const trafficIntensity = (contacts * ahtSec) / 3600; // в Эрлангах за час + const trafficIntensity = (contacts * ahtSec) / 3600; let agents = Math.ceil(trafficIntensity); let sl = 0; @@ -65,7 +67,6 @@ function calculateAgents(contacts, ahtMin, serviceTimeSec, patienceSec, targetSL function calculateFTE(params) { const results = {}; - // Входящие звонки (Erlang C) if (params.incomingCalls && params.incomingCalls > 0) { const r = calculateAgents( params.incomingCalls, @@ -81,7 +82,6 @@ function calculateFTE(params) { }; } - // Исходящие звонки (простой расчёт по времени) if (params.outgoingCalls && params.outgoingCalls > 0) { const totalMinutes = params.outgoingCalls * (params.ahtOutgoing || 4.5); const workingMinutes = (params.fteHours || 189) * 60 * (params.utilization || 0.86); @@ -91,7 +91,6 @@ function calculateFTE(params) { }; } - // Неголосовые тикеты if (params.tickets && params.tickets > 0) { const totalMinutes = params.tickets * (params.ahtTickets || 12); const workingMinutes = (params.fteHours || 189) * 60 * (params.utilization || 0.86); @@ -101,7 +100,6 @@ function calculateFTE(params) { }; } - // Чаты (с учётом concurrency) if (params.chats && params.chats > 0) { const concurrency = params.concurrency || 4; const totalMinutes = (params.chats * (params.ahtChats || 90)) / concurrency; @@ -113,7 +111,6 @@ function calculateFTE(params) { }; } - // Итого results.total = { fteInSchedule: Object.values(results).reduce((sum, r) => sum + (r.fte || 0), 0), fteWithAbsence: 0, @@ -121,15 +118,124 @@ function calculateFTE(params) { 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) => { - // CORS для предпросмотра res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); @@ -139,12 +245,10 @@ http.createServer(async (req, res) => { 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(), @@ -158,14 +262,64 @@ http.createServer(async (req, res) => { return res.end(JSON.stringify(results)); } - // API: история расчётов + 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()) {