This commit is contained in:
2026-06-19 11:24:17 +05:00
commit b9251ed056
11 changed files with 11284 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
data/
uploads/
server.log
*.pdf

1559
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "test",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"better-sqlite3": "^12.11.1",
"express": "^4.22.2",
"multer": "^2.2.0",
"pdf-parse": "^2.4.5"
}
}

7365
pdf_text.txt Normal file

File diff suppressed because it is too large Load Diff

495
public/app.js Normal file
View File

@@ -0,0 +1,495 @@
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');
const uploadStatus = document.getElementById('uploadStatus');
const statusEl = document.getElementById('status');
let selectedFile = null;
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
if (e.dataTransfer.files.length) {
selectedFile = e.dataTransfer.files[0];
uploadBtn.disabled = false;
dropZone.querySelector('.drop-text').textContent = 'Выбран: ' + selectedFile.name;
}
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length) {
selectedFile = fileInput.files[0];
uploadBtn.disabled = false;
dropZone.querySelector('.drop-text').textContent = 'Выбран: ' + selectedFile.name;
}
});
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
e.preventDefault();
if (!selectedFile) return;
uploadBtn.disabled = true;
uploadStatus.textContent = 'Загрузка...';
uploadStatus.className = '';
const formData = new FormData();
formData.append('pdf', selectedFile);
try {
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await res.json();
if (data.error) {
uploadStatus.textContent = 'Ошибка: ' + data.error;
uploadStatus.className = 'error';
} else {
uploadStatus.textContent = data.message;
uploadStatus.className = 'success';
statusEl.textContent = 'Данные загружены';
statusEl.classList.add('loaded');
loadFilters();
loadAll();
}
} catch (err) {
uploadStatus.textContent = 'Ошибка: ' + err.message;
uploadStatus.className = 'error';
}
selectedFile = null;
fileInput.value = '';
uploadBtn.disabled = true;
dropZone.querySelector('.drop-text').textContent = 'Перетащите PDF сюда или кликните для выбора';
});
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.getElementById(tab.dataset.tab).classList.add('active');
});
});
function getFilterParams() {
const params = {};
const student = document.getElementById('gfStudent').value.trim();
const subject = document.getElementById('gfSubject').value.trim();
const teacher = document.getElementById('gfTeacher').value.trim();
const from = document.getElementById('gfDateFrom').value;
const to = document.getElementById('gfDateTo').value;
if (student) params.student = student;
if (subject) params.subject = subject;
if (teacher) params.teacher = teacher;
if (from) params.date_from = from.slice(5);
if (to) params.date_to = to.slice(5);
return params;
}
function buildQuery(params) {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(params)) q.set(k, v);
return q.toString();
}
async function loadAll() {
const params = getFilterParams();
const qs = buildQuery(params);
const [statsRes, dailyRes] = await Promise.all([
fetch('/api/stats?' + qs),
fetch('/api/daily?' + qs + '&limit=150&offset=0')
]);
const stats = await statsRes.json();
const daily = await dailyRes.json();
renderSummary(stats.summary);
renderAlerts(stats);
renderSubjectsTable(stats);
renderTeachersTable(stats);
renderClassesTable(stats);
renderDailyTable(daily);
loadPivot();
}
async function loadFilters() {
try {
const res = await fetch('/api/filters');
const data = await res.json();
if (data.error) return;
const fill = (id, items) => {
const dl = document.getElementById(id);
if (dl) dl.innerHTML = (items || []).map(v => `<option value="${v}">`).join('');
};
fill('dlStudents', data.students);
fill('dlSubjects', data.subjects);
fill('dlTeachers', data.teachers);
} catch (e) {
console.error(e);
}
}
function renderSummary(summary) {
const cards = document.getElementById('summaryCards');
if (!summary || !summary.totalStudents) {
cards.innerHTML = '<div class="empty-state">Нет данных. Загрузите PDF журнала.</div>';
return;
}
cards.innerHTML = `
<div class="card"><div class="value">${summary.totalClasses}</div><div class="label">Классов</div></div>
<div class="card"><div class="value">${summary.totalStudents}</div><div class="label">Учеников</div></div>
<div class="card"><div class="value">${summary.totalTeachers}</div><div class="label">Учителей</div></div>
<div class="card"><div class="value">${summary.totalSubjects}</div><div class="label">Предметов</div></div>
<div class="card"><div class="value">${summary.totalDaily}</div><div class="label">Подневных оценок</div></div>
`;
}
function renderAlerts(stats) {
const alerts = document.getElementById('alerts');
let html = '';
if (stats.subjectsWithFewGrades && stats.subjectsWithFewGrades.length > 0) {
for (const s of stats.subjectsWithFewGrades) {
html += `<div class="alert warning">⚠️ ${s.warning}</div>`;
}
}
if (stats.teachersWithNoGrades && stats.teachersWithNoGrades.length > 0) {
for (const t of stats.teachersWithNoGrades) {
html += `<div class="alert danger">❌ Учитель «${t.name}» (${t.subject_name}) — нет отметок</div>`;
}
}
if (!html) {
const total = stats.summary ? stats.summary.totalDaily : 0;
if (total > 0) html = '<div class="alert info">Все предметы имеют достаточное количество оценок. Проблем не обнаружено.</div>';
else html = '<div class="alert info">Загрузите PDF журнала для отображения предупреждений.</div>';
}
alerts.innerHTML = html;
}
function renderSubjectsTable(stats) {
const tbody = document.querySelector('#subjectsTable tbody');
const arr = stats.gradeCountBySubject || [];
if (!arr.length) {
tbody.innerHTML = '<tr><td colspan="11" class="empty-state">Нет данных</td></tr>';
return;
}
const threshold = 10;
tbody.innerHTML = arr.map(s => {
const cls = s.student_count < threshold ? 'low-grades' : '';
return `<tr>
<td class="${cls}">${s.subject}</td>
<td class="num ${cls}">${s.total}</td>
<td class="num">${s.fives}</td>
<td class="num">${s.fours}</td>
<td class="num">${s.threes}</td>
<td class="num">${s.twos}</td>
<td class="num">${s.n_count || 0}</td>
<td class="num">${s.b_count || 0}</td>
<td class="num">${s.u_count || 0}</td>
<td class="num">${s.avg_grade !== null ? s.avg_grade : '—'}</td>
<td class="num">${s.student_count}</td>
</tr>`;
}).join('');
}
function renderTeachersTable(stats) {
const tbody = document.querySelector('#teachersTable tbody');
const arr = stats.gradeCountByTeacher || [];
const noGrades = new Set((stats.teachersWithNoGrades || []).map(t => t.name));
let html = '';
for (const t of arr) {
const cls = noGrades.has(t.teacher) ? 'no-grades' : '';
html += `<tr>
<td class="${cls}">${t.teacher}</td>
<td>${t.subject_name}</td>
<td class="num ${cls}">${t.total}</td>
<td class="num">${t.fives}</td>
<td class="num">${t.fours}</td>
<td class="num">${t.threes}</td>
<td class="num">${t.twos}</td>
<td class="num">${t.n_count || 0}</td>
<td class="num">${t.b_count || 0}</td>
<td class="num">${t.u_count || 0}</td>
<td class="num">${t.student_count || '—'}</td>
</tr>`;
}
if ((stats.teachersWithNoGrades || []).length > 0 && !arr.length) {
for (const t of stats.teachersWithNoGrades) {
html += `<tr>
<td class="no-grades">${t.name}</td>
<td>${t.subject_name}</td>
<td class="num no-grades">0</td>
<td class="num">0</td><td class="num">0</td><td class="num">0</td><td class="num">0</td><td class="num">0</td><td class="num">0</td><td class="num">0</td><td class="num">0</td>
</tr>`;
}
}
if (!html) html = '<tr><td colspan="11" class="empty-state">Нет данных</td></tr>';
tbody.innerHTML = html;
}
function renderClassesTable(stats) {
const tbody = document.querySelector('#classesTable tbody');
const arr = stats.gradeCountByClass || [];
if (!arr.length) {
tbody.innerHTML = '<tr><td colspan="10" class="empty-state">Нет данных</td></tr>';
return;
}
tbody.innerHTML = arr.map(c => `<tr>
<td>${c.class}</td>
<td class="num">${c.student_count}</td>
<td class="num">${c.total}</td>
<td class="num">${c.fives}</td>
<td class="num">${c.fours}</td>
<td class="num">${c.threes}</td>
<td class="num">${c.twos}</td>
<td class="num">${c.n_count || 0}</td>
<td class="num">${c.b_count || 0}</td>
<td class="num">${c.u_count || 0}</td>
</tr>`).join('');
}
function renderDailyTable(data) {
const tbody = document.querySelector('#dailyTable tbody');
if (!data.rows || !data.rows.length) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">Нет данных</td></tr>';
document.getElementById('dailyPagination').innerHTML = '';
return;
}
tbody.innerHTML = data.rows.map(r => {
const g = r.grade || r.symbol || '—';
return `<tr>
<td>${r.grade_date}</td>
<td>${r.student}</td>
<td>${r.class}</td>
<td>${r.subject}</td>
<td>${r.teacher}</td>
<td class="num">${g}</td>
</tr>`;
}).join('');
const totalPages = Math.ceil(data.total / 150);
document.getElementById('dailyPagination').innerHTML = totalPages > 1 ? `
<button ${true ? 'disabled' : ''} onclick="dailyNav(0)">←</button>
<span>стр. 1 / ${totalPages}</span>
<button ${totalPages > 1 ? '' : 'disabled'} onclick="dailyNav(1)">→</button>
` : '';
}
window.dailyPage = 0;
window.dailyNav = async function(page) {
window.dailyPage = page;
const params = getFilterParams();
const qs = buildQuery(params) + '&limit=150&offset=' + (page * 150);
const res = await fetch('/api/daily?' + qs);
const data = await res.json();
renderDailyTable(data);
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.querySelector('[data-tab="daily"]').classList.add('active');
document.getElementById('daily').classList.add('active');
};
document.getElementById('gfApply').addEventListener('click', () => loadAll());
document.getElementById('gfReset').addEventListener('click', () => {
document.getElementById('gfStudent').value = '';
document.getElementById('gfSubject').value = '';
document.getElementById('gfTeacher').value = '';
document.getElementById('gfDateFrom').value = '';
document.getElementById('gfDateTo').value = '';
loadAll();
});
const modal = document.getElementById('clearModal');
document.getElementById('clearBtn').addEventListener('click', () => {
modal.classList.add('open');
loadClassList();
});
document.getElementById('modalClose').addEventListener('click', () => modal.classList.remove('open'));
modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('open'); });
async function loadClassList() {
try {
const res = await fetch('/api/stats');
const stats = await res.json();
const dl = document.getElementById('dlClasses');
if (dl && stats.classes) {
dl.innerHTML = stats.classes.map(c => `<option value="${c.name}">`).join('');
}
} catch (e) {}
}
document.getElementById('clearClassBtn').addEventListener('click', async () => {
const name = document.getElementById('clearClass').value.trim();
if (!name) return;
const msg = document.getElementById('clearClassMsg');
if (!confirm(`Удалить класс ${name} и все его данные?`)) return;
msg.className = '';
msg.textContent = '...';
try {
const res = await fetch('/api/classes/' + encodeURIComponent(name), { method: 'DELETE' });
const data = await res.json();
msg.className = data.error ? 'error' : 'success';
msg.textContent = data.message || data.error;
if (data.ok) { loadFilters(); loadAll(); }
} catch (e) {
msg.className = 'error';
msg.textContent = e.message;
}
});
document.getElementById('clearParallelBtn').addEventListener('click', async () => {
const grade = document.getElementById('clearParallel').value.trim();
if (!grade) return;
const msg = document.getElementById('clearParallelMsg');
if (!confirm(`Удалить ВСЕ классы параллели ${grade}?`)) return;
msg.className = '';
msg.textContent = '...';
try {
const res = await fetch('/api/parallel/' + encodeURIComponent(grade), { method: 'DELETE' });
const data = await res.json();
msg.className = data.error ? 'error' : 'success';
msg.textContent = data.message || data.error;
if (data.ok) { loadFilters(); loadAll(); }
} catch (e) {
msg.className = 'error';
msg.textContent = e.message;
}
});
document.getElementById('clearAllBtn').addEventListener('click', async () => {
const msg = document.getElementById('clearAllMsg');
if (!confirm('Удалить ВСЕ данные без возможности восстановления?')) return;
if (!confirm('Точно?')) return;
msg.className = '';
msg.textContent = '...';
try {
const res = await fetch('/api/data', { method: 'DELETE' });
const data = await res.json();
msg.className = data.error ? 'error' : 'success';
msg.textContent = data.message || data.error || 'Всё удалено';
if (data.ok) { loadFilters(); loadAll(); }
} catch (e) {
msg.className = 'error';
msg.textContent = e.message;
}
});
let pivotMode = 'month';
document.querySelectorAll('.per-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.per-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
pivotMode = btn.dataset.per;
loadPivot();
});
});
async function loadPivot() {
const params = getFilterParams();
params.period = pivotMode;
const qs = buildQuery(params);
document.getElementById('pivotInfo').textContent = 'Загрузка...';
try {
const res = await fetch('/api/pivot?' + qs);
const data = await res.json();
if (data.error) {
document.getElementById('pivotTable').innerHTML = '<tr><td class="empty-state">' + data.error + '</td></tr>';
return;
}
renderPivot(data);
} catch (e) {
console.error(e);
}
}
function renderPivot(data) {
const thead = document.querySelector('#pivotTable thead');
const tbody = document.querySelector('#pivotTable tbody');
document.getElementById('pivotInfo').textContent = data.rows.length + ' строк, ' + data.periods.length + ' периодов';
let headHtml = '<tr><th>Предмет / Учитель / Ученик</th>';
for (const lbl of data.periodLabels) {
headHtml += '<th>' + lbl + '</th>';
}
headHtml += '</tr>';
thead.innerHTML = headHtml;
if (!data.rows.length) {
tbody.innerHTML = '<tr><td colspan="' + (data.periods.length + 1) + '" class="empty-state">Нет данных</td></tr>';
return;
}
const subjectGroups = {};
for (const row of data.rows) {
if (!subjectGroups[row.subject]) subjectGroups[row.subject] = [];
subjectGroups[row.subject].push(row);
}
let html = '';
for (const [subject, rows] of Object.entries(subjectGroups).sort()) {
const subjId = 's_' + subject.replace(/[^a-zа-яё0-9]/gi, '_');
html += '<tr class="pivot-subject-row" data-group="' + subjId + '">';
html += '<td class="pivot-subject" onclick="toggleGroup(\'' + subjId + '\')"><span class="toggle" id="tog_' + subjId + '">▶</span> ' + subject + '</td>';
for (let i = 0; i < data.periods.length; i++) html += '<td></td>';
html += '</tr>';
const teacherGroups = {};
for (const row of rows) {
if (!teacherGroups[row.teacher]) teacherGroups[row.teacher] = [];
teacherGroups[row.teacher].push(row);
}
for (const [teacher, trows] of Object.entries(teacherGroups)) {
const tchId = subjId + '_t_' + teacher.replace(/[^a-zа-яё0-9]/gi, '_');
html += '<tr class="pivot-hidden ' + subjId + ' pivot-teacher-row" data-group="' + tchId + '">';
html += '<td class="pivot-teacher" onclick="toggleGroup(\'' + tchId + '\')"><span class="toggle" id="tog_' + tchId + '">▶</span> ' + teacher + '</td>';
for (let i = 0; i < data.periods.length; i++) html += '<td></td>';
html += '</tr>';
for (const row of trows) {
html += '<tr class="pivot-hidden ' + subjId + ' ' + tchId + '">';
html += '<td class="pivot-student">' + row.student + '</td>';
for (let i = 0; i < data.periods.length; i++) {
const val = row.cells[i] || '';
html += '<td class="pivot-cell">' + val + '</td>';
}
html += '</tr>';
}
}
}
tbody.innerHTML = html;
}
window.toggleGroup = function(groupId) {
const rows = document.querySelectorAll('.' + groupId);
const tog = document.getElementById('tog_' + groupId);
if (!rows.length) return;
const firstRow = rows[0];
if (firstRow.classList.contains('pivot-hidden')) {
rows.forEach(r => r.classList.remove('pivot-hidden'));
if (tog) tog.classList.add('open');
} else {
rows.forEach(r => r.classList.add('pivot-hidden'));
if (tog) tog.classList.remove('open');
}
};
loadFilters();
loadAll();

148
public/index.html Normal file
View File

@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Школьный журнал — статистика</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>📊 Школьный журнал</h1>
<span id="status" class="status">Нет данных</span>
</header>
<main>
<section class="upload-section">
<form id="uploadForm">
<label class="drop-zone" id="dropZone">
<input type="file" id="fileInput" accept=".pdf" hidden>
<span class="drop-text">Перетащите PDF сюда или кликните для выбора</span>
<span class="drop-hint">(журнал класса)</span>
</label>
<button type="submit" id="uploadBtn" disabled>Загрузить</button>
<button type="button" id="clearBtn" class="btn-danger">Очистить данные</button>
<div id="uploadStatus"></div>
</form>
</section>
<section class="global-filters">
<input type="text" id="gfStudent" placeholder="Ученик..." list="dlStudents">
<input type="text" id="gfSubject" placeholder="Предмет..." list="dlSubjects">
<input type="text" id="gfTeacher" placeholder="Учитель..." list="dlTeachers">
<input type="date" id="gfDateFrom" title="Дата с">
<input type="date" id="gfDateTo" title="Дата по">
<button id="gfApply">Применить</button>
<button id="gfReset" class="btn-reset">Сбросить</button>
<datalist id="dlStudents"></datalist>
<datalist id="dlSubjects"></datalist>
<datalist id="dlTeachers"></datalist>
</section>
<section class="tabs">
<button class="tab active" data-tab="dashboard">Дашборд</button>
<button class="tab" data-tab="subjects">Предметы</button>
<button class="tab" data-tab="teachers">Учителя</button>
<button class="tab" data-tab="classes">Классы</button>
<button class="tab" data-tab="daily">Подневно</button>
<button class="tab" data-tab="pivot">Сводка</button>
</section>
<section id="dashboard" class="tab-content active">
<div class="summary-cards" id="summaryCards"></div>
<div class="alerts" id="alerts"></div>
</section>
<section id="subjects" class="tab-content">
<h2>Статистика по предметам</h2>
<div class="table-wrapper">
<table id="subjectsTable">
<thead><tr><th>Предмет</th><th>Всего</th><th>5</th><th>4</th><th>3</th><th>2</th><th>Н</th><th>Б</th><th>У</th><th>Ср. балл</th><th>Учеников</th></tr></thead>
<tbody></tbody>
</table>
</div>
</section>
<section id="teachers" class="tab-content">
<h2>Статистика по учителям</h2>
<div class="table-wrapper">
<table id="teachersTable">
<thead><tr><th>Учитель</th><th>Предмет</th><th>Всего</th><th>5</th><th>4</th><th>3</th><th>2</th><th>Н</th><th>Б</th><th>У</th><th>Учеников</th></tr></thead>
<tbody></tbody>
</table>
</div>
</section>
<section id="classes" class="tab-content">
<h2>Статистика по классам</h2>
<div class="table-wrapper">
<table id="classesTable">
<thead><tr><th>Класс</th><th>Учеников</th><th>Всего</th><th>5</th><th>4</th><th>3</th><th>2</th><th>Н</th><th>Б</th><th>У</th></tr></thead>
<tbody></tbody>
</table>
</div>
</section>
<section id="daily" class="tab-content">
<h2>Подневные оценки</h2>
<div class="table-wrapper">
<table id="dailyTable">
<thead><tr><th>Дата</th><th>Ученик</th><th>Класс</th><th>Предмет</th><th>Учитель</th><th>Оценка</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="pagination" id="dailyPagination"></div>
</section>
<section id="pivot" class="tab-content">
<h2>Сводка по периодам</h2>
<div class="period-selector">
<button class="per-btn active" data-per="month">Месяц</button>
<button class="per-btn" data-per="week">Неделя</button>
<button class="per-btn" data-per="quarter">Четверть</button>
<span class="per-info" id="pivotInfo"></span>
</div>
<div class="pivot-scroll">
<table id="pivotTable">
<thead></thead>
<tbody></tbody>
</table>
</div>
</section>
</main>
<div class="modal-overlay" id="clearModal">
<div class="modal">
<div class="modal-header">
<h3>Очистить данные</h3>
<button class="modal-close" id="modalClose">&times;</button>
</div>
<div class="modal-body">
<div class="modal-option" data-action="class">
<strong>Определённый класс</strong>
<input type="text" id="clearClass" placeholder="Например: 5Е" list="dlClasses">
<datalist id="dlClasses"></datalist>
<button id="clearClassBtn" class="btn-danger">Удалить класс</button>
<span id="clearClassMsg"></span>
</div>
<hr>
<div class="modal-option" data-action="parallel">
<strong>Целую параллель</strong>
<input type="text" id="clearParallel" placeholder="Например: 5">
<button id="clearParallelBtn" class="btn-danger">Удалить параллель</button>
<span id="clearParallelMsg"></span>
</div>
<hr>
<div class="modal-option danger-zone" data-action="all">
<strong>Всю базу данных</strong>
<p>Удаляет все классы, учеников, оценки без возможности восстановления.</p>
<button id="clearAllBtn" class="btn-danger">Удалить всё</button>
<span id="clearAllMsg"></span>
</div>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

414
public/style.css Normal file
View File

@@ -0,0 +1,414 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f0f2f5;
color: #1a1a2e;
min-height: 100vh;
}
header {
background: #1a1a2e;
color: #fff;
padding: 16px 32px;
display: flex;
align-items: center;
justify-content: space-between;
}
header h1 { font-size: 22px; font-weight: 600; }
.status { font-size: 13px; opacity: 0.7; }
.status.loaded { color: #4ade80; }
main { max-width: 1200px; margin: 0 auto; padding: 24px; }
.upload-section {
background: #fff;
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.upload-section form {
display: flex;
gap: 16px;
align-items: center;
}
.drop-zone {
flex: 1;
border: 2px dashed #c0c0d0;
border-radius: 10px;
padding: 28px 20px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
display: block;
}
.drop-zone:hover, .drop-zone.drag-over {
border-color: #4f46e5;
background: #f5f3ff;
}
.drop-text { display: block; font-size: 15px; color: #555; margin-bottom: 4px; }
.drop-hint { font-size: 12px; color: #999; }
button {
background: #4f46e5;
color: #fff;
border: none;
padding: 12px 28px;
border-radius: 8px;
font-size: 15px;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
button:hover:not(:disabled) { background: #4338ca; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
#uploadStatus {
font-size: 13px;
color: #666;
min-width: 200px;
}
#uploadStatus.success { color: #16a34a; }
#uploadStatus.error { color: #dc2626; }
.tabs {
display: flex;
gap: 0;
background: #fff;
border-radius: 12px 12px 0 0;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.tab {
padding: 12px 24px;
border: none;
background: #fff;
color: #666;
font-size: 14px;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.15s;
border-radius: 0;
}
.tab:hover { color: #4f46e5; background: #f9f9fb; }
.tab.active { color: #4f46e5; border-bottom-color: #4f46e5; font-weight: 600; }
.tab-content {
display: none;
background: #fff;
padding: 24px;
border-radius: 0 0 12px 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.tab-content.active { display: block; }
.summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.card {
background: #f8f9fc;
border-radius: 10px;
padding: 20px;
text-align: center;
border: 1px solid #e5e7eb;
}
.card .value { font-size: 28px; font-weight: 700; color: #4f46e5; }
.card .label { font-size: 13px; color: #666; margin-top: 4px; }
.alerts { display: flex; flex-direction: column; gap: 8px; }
.alert {
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.alert.warning { background: #fef3c7; color: #92400e; border: 1px solid #fcd34d; }
.alert.danger { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
.alert.info { background: #e0f2fe; color: #075985; border: 1px solid #7dd3fc; }
.table-wrapper { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #f0f0f0; }
th { background: #f8f9fc; font-weight: 600; color: #555; font-size: 13px; white-space: nowrap; }
tr:hover td { background: #f9f9fb; }
td.num { text-align: center; font-variant-numeric: tabular-nums; }
.low-grades { color: #dc2626; font-weight: 600; }
.no-grades { color: #d97706; font-weight: 600; }
h2 { font-size: 18px; margin-bottom: 16px; color: #1a1a2e; }
.empty-state {
text-align: center;
padding: 40px;
color: #999;
}
.filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
align-items: center;
}
.filters input {
padding: 6px 10px;
border: 1px solid #d0d0d0;
border-radius: 6px;
font-size: 13px;
width: 140px;
}
.filters input:focus {
outline: none;
border-color: #4f46e5;
}
.global-filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
background: #fff;
border-radius: 12px;
padding: 14px 20px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.global-filters input {
padding: 7px 10px;
border: 1px solid #d0d0d0;
border-radius: 6px;
font-size: 13px;
width: 140px;
}
.global-filters input[type="date"] {
width: 145px;
}
.global-filters .btn-reset {
background: transparent;
color: #666;
border: 1px solid #d0d0d0;
margin-left: auto;
}
.global-filters .btn-reset:hover {
background: #f5f5f5;
color: #333;
}
.btn-danger {
background: #dc2626;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.open {
display: flex;
}
.modal {
background: #fff;
border-radius: 14px;
width: 480px;
max-width: 95vw;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 8px 30px rgba(0,0,0,0.18);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 22px 12px;
}
.modal-header h3 { font-size: 17px; }
.modal-close {
background: none;
border: none;
font-size: 22px;
cursor: pointer;
color: #999;
padding: 0 4px;
}
.modal-close:hover { color: #333; }
.modal-body {
padding: 0 22px 20px;
}
.modal-option {
padding: 12px 0;
}
.modal-option strong {
display: block;
margin-bottom: 8px;
font-size: 14px;
}
.modal-option input {
padding: 7px 10px;
border: 1px solid #d0d0d0;
border-radius: 6px;
font-size: 13px;
width: 160px;
margin-right: 8px;
}
.modal-option input:focus {
outline: none;
border-color: #4f46e5;
}
.modal-option hr {
border: none;
border-top: 1px solid #eee;
margin: 12px 0;
}
.modal-option p {
font-size: 12px;
color: #999;
margin: 6px 0 10px;
}
.danger-zone {
background: #fef2f2;
border-radius: 8px;
padding: 14px;
margin-top: 4px;
}
.modal-option span {
font-size: 12px;
margin-left: 8px;
}
.modal-option span.success { color: #16a34a; }
.modal-option span.error { color: #dc2626; }
.period-selector {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 14px;
}
.per-btn {
padding: 6px 16px;
border: 1px solid #d0d0d0;
background: #fff;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
color: #555;
}
.per-btn.active {
background: #4f46e5;
color: #fff;
border-color: #4f46e5;
}
.per-info {
margin-left: auto;
font-size: 13px;
color: #888;
}
.pivot-scroll {
overflow-x: auto;
max-width: 100%;
}
#pivotTable {
border-collapse: collapse;
font-size: 13px;
min-width: 100%;
}
#pivotTable th, #pivotTable td {
padding: 6px 8px;
border: 1px solid #e5e5e5;
white-space: nowrap;
vertical-align: middle;
}
#pivotTable th {
background: #f0f2f5;
font-weight: 600;
color: #444;
position: sticky;
top: 0;
z-index: 1;
}
#pivotTable thead th:first-child {
position: sticky;
left: 0;
z-index: 2;
background: #f0f2f5;
}
#pivotTable td:first-child {
position: sticky;
left: 0;
background: #fff;
font-weight: 500;
z-index: 0;
min-width: 160px;
border-right: 2px solid #d0d0d0;
}
#pivotTable tr:hover td {
background: #f8f9fc;
}
#pivotTable tr:hover td:first-child {
background: #f0f2f5;
}
.pivot-subject {
cursor: pointer;
font-weight: 700;
color: #1a1a2e;
}
.pivot-subject .toggle {
display: inline-block;
width: 16px;
font-size: 11px;
transition: transform 0.15s;
}
.pivot-subject .toggle.open {
transform: rotate(90deg);
}
.pivot-teacher {
padding-left: 20px !important;
color: #555;
}
.pivot-student {
padding-left: 36px !important;
font-size: 12px;
color: #666;
}
.pivot-cell {
text-align: center;
font-variant-numeric: tabular-nums;
color: #333;
}
.pivot-hidden { display: none; }
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
margin-top: 16px;
font-size: 14px;
}
.pagination button {
padding: 6px 14px;
font-size: 13px;
}
@media (max-width: 640px) {
.upload-section form { flex-direction: column; }
button { width: 100%; }
.tabs { overflow-x: auto; }
.tab { padding: 10px 14px; font-size: 13px; }
}

490
server.js Normal file
View File

@@ -0,0 +1,490 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { parsePdfBuffer } = require('./src/parser');
const { upsertClass, upsertStudent, upsertSubject, upsertTeacher, upsertGradeValue, insertDailyGradeValue, db } = require('./src/db');
const { getFullStats, getSubjectDetails, getTeacherDetails, getClassDetails } = require('./src/stats');
const app = express();
const PORT = process.env.PORT || 3000;
const UPLOADS_DIR = path.join(__dirname, 'uploads');
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true });
const upload = multer({
dest: UPLOADS_DIR,
limits: { fileSize: 50 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype === 'application/pdf' || file.originalname.toLowerCase().endsWith('.pdf')) {
cb(null, true);
} else {
cb(new Error('Только PDF файлы'));
}
}
});
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
app.post('/api/upload', upload.single('pdf'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
const buffer = fs.readFileSync(req.file.path);
const parsed = await parsePdfBuffer(buffer, req.file.originalname);
if (!parsed.className) throw new Error('Не удалось определить класс');
if (!parsed.subjects || parsed.subjects.length === 0) throw new Error('Не найдены предметы');
if (!parsed.students || parsed.students.length === 0) throw new Error('Не найдены ученики');
const classId = upsertClass(parsed.className);
if (!classId) throw new Error('Ошибка сохранения класса');
db.prepare(`DELETE FROM daily_grades WHERE student_id IN (SELECT id FROM students WHERE class_id = ?)`).run(classId);
db.prepare(`DELETE FROM grades WHERE student_id IN (SELECT id FROM students WHERE class_id = ?)`).run(classId);
const subjectIds = {};
for (const subj of parsed.subjects) {
const id = upsertSubject(subj);
if (!id) throw new Error('Ошибка сохранения предмета: ' + subj);
subjectIds[subj] = id;
}
const studentIds = {};
for (const name of parsed.students) {
const id = upsertStudent(name, classId);
if (!id) throw new Error('Ошибка сохранения ученика: ' + name);
studentIds[name] = id;
}
const teacherIds = {};
for (const teacher of parsed.teachers) {
const id = upsertTeacher(teacher, null);
if (!id) throw new Error('Ошибка сохранения учителя: ' + teacher);
teacherIds[teacher] = id;
}
const teacherBySubject = {};
const subjectList = parsed.subjects;
const teacherList = parsed.teachers;
for (let i = 0; i < Math.min(subjectList.length, teacherList.length); i++) {
teacherBySubject[subjectList[i]] = teacherList[i];
}
let quarterlyCount = 0;
for (const g of parsed.allGrades) {
const studentId = studentIds[g.studentName];
const subjectId = subjectIds[g.subject];
if (studentId && subjectId) {
const tName = teacherBySubject[g.subject];
const tId = tName ? (teacherIds[tName] || null) : null;
upsertGradeValue(studentId, subjectId, tId, g.quarter, g.grade);
quarterlyCount++;
}
}
let dailyCount = 0;
for (const d of parsed.dailyGrades) {
const studentId = studentIds[d.studentName];
const subjectId = subjectIds[d.subject];
if (studentId && subjectId) {
let tId = null;
if (d.teacher && teacherIds[d.teacher]) {
tId = teacherIds[d.teacher];
} else if (teacherBySubject[d.subject]) {
tId = teacherIds[teacherBySubject[d.subject]] || null;
}
const gradeVal = d.grade !== null ? String(d.grade) : (d.symbol || '');
insertDailyGradeValue(studentId, subjectId, tId, d.date, gradeVal);
dailyCount++;
}
}
fs.unlinkSync(req.file.path);
res.json({
ok: true,
className: parsed.className,
students: parsed.students.length,
subjects: parsed.subjects.length,
teachers: parsed.teachers.length,
quarterlyGrades: quarterlyCount,
dailyGrades: dailyCount,
message: `Загружен класс ${parsed.className}: ${parsed.students.length} учеников, ${parsed.subjects.length} предметов, ${quarterlyCount} четвертных, ${dailyCount} подневных оценок`
});
} catch (e) {
if (req.file && fs.existsSync(req.file.path)) {
try { fs.unlinkSync(req.file.path); } catch (_) {}
}
console.error('Upload error:', e);
res.status(500).json({ error: e.message });
}
});
app.get('/api/stats', (req, res) => {
try {
res.json(getFullStats(req.query));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/subjects/:name', (req, res) => {
try {
res.json(getSubjectDetails(req.params.name));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/teachers/:name', (req, res) => {
try {
res.json(getTeacherDetails(req.params.name));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/classes/:name', (req, res) => {
try {
res.json(getClassDetails(req.params.name));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/filters', (req, res) => {
try {
const students = db.prepare(`SELECT DISTINCT s.full_name FROM daily_grades d JOIN students s ON d.student_id = s.id ORDER BY s.full_name`).all().map(r => r.full_name);
const subjects = db.prepare(`SELECT DISTINCT sj.name FROM daily_grades d JOIN subjects sj ON d.subject_id = sj.id ORDER BY sj.name`).all().map(r => r.name);
const teachers = db.prepare(`SELECT DISTINCT t.name FROM daily_grades d JOIN teachers t ON d.teacher_id = t.id WHERE t.name IS NOT NULL ORDER BY t.name`).all().map(r => r.name);
res.json({ students, subjects, teachers });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/daily', (req, res) => {
try {
const { student, subject, teacher, date_from, date_to, limit, offset } = req.query;
let sql = `
SELECT d.id, s.full_name as student, c.name as class, sj.name as subject,
COALESCE(t.name, '—') as teacher,
(substr(d.grade_date, 4, 2) || '.' || substr(d.grade_date, 1, 2)) as grade_date,
d.grade, d.symbol
FROM daily_grades d
JOIN students s ON d.student_id = s.id
JOIN classes c ON s.class_id = c.id
JOIN subjects sj ON d.subject_id = sj.id
LEFT JOIN teachers t ON d.teacher_id = t.id
WHERE 1=1
`;
const params = [];
if (student) { sql += ' AND s.full_name LIKE ?'; params.push('%' + student + '%'); }
if (subject) { sql += ' AND sj.name LIKE ?'; params.push('%' + subject + '%'); }
if (teacher) { sql += ' AND t.name LIKE ?'; params.push('%' + teacher + '%'); }
if (date_from && date_to) {
if (date_from <= date_to) {
sql += ' AND d.grade_date >= ? AND d.grade_date <= ?';
params.push(date_from, date_to);
} else {
sql += ' AND (d.grade_date >= ? OR d.grade_date <= ?)';
params.push(date_from, date_to);
}
} else if (date_from) {
sql += ' AND d.grade_date >= ?';
params.push(date_from);
} else if (date_to) {
sql += ' AND d.grade_date <= ?';
params.push(date_to);
}
sql += ' ORDER BY d.grade_date DESC, s.full_name, sj.name';
sql += ' LIMIT ? OFFSET ?';
params.push(parseInt(limit) || 200, parseInt(offset) || 0);
const rows = db.prepare(sql).all(...params);
let countSql = `
SELECT COUNT(*) as c FROM daily_grades d
JOIN students s ON d.student_id = s.id
JOIN subjects sj ON d.subject_id = sj.id
LEFT JOIN teachers t ON d.teacher_id = t.id
WHERE 1=1
`;
let countParams = [];
if (student) { countSql += ' AND s.full_name LIKE ?'; countParams.push('%' + student + '%'); }
if (subject) { countSql += ' AND sj.name LIKE ?'; countParams.push('%' + subject + '%'); }
if (teacher) { countSql += ' AND t.name LIKE ?'; countParams.push('%' + teacher + '%'); }
if (date_from && date_to) {
if (date_from <= date_to) {
countSql += ' AND d.grade_date >= ? AND d.grade_date <= ?';
countParams.push(date_from, date_to);
} else {
countSql += ' AND (d.grade_date >= ? OR d.grade_date <= ?)';
countParams.push(date_from, date_to);
}
} else if (date_from) {
countSql += ' AND d.grade_date >= ?';
countParams.push(date_from);
} else if (date_to) {
countSql += ' AND d.grade_date <= ?';
countParams.push(date_to);
}
const total = db.prepare(countSql).get(...countParams).c;
res.json({ total, rows });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/pivot', (req, res) => {
try {
const { period, student, subject, teacher, date_from, date_to } = req.query;
const mode = period === 'week' ? 'week' : period === 'quarter' ? 'quarter' : 'month';
let sql = `
SELECT sj.name as subject,
COALESCE(t.name, '—') as teacher,
s.full_name as student,
d.grade_date,
d.grade,
d.symbol
FROM daily_grades d
JOIN subjects sj ON d.subject_id = sj.id
JOIN students s ON d.student_id = s.id
LEFT JOIN teachers t ON d.teacher_id = t.id
WHERE 1=1
`;
const params = [];
if (student) { sql += ' AND s.full_name LIKE ?'; params.push('%' + student + '%'); }
if (subject) { sql += ' AND sj.name LIKE ?'; params.push('%' + subject + '%'); }
if (teacher) { sql += ' AND t.name LIKE ?'; params.push('%' + teacher + '%'); }
if (date_from && date_to) {
if (date_from <= date_to) {
sql += ' AND d.grade_date >= ? AND d.grade_date <= ?';
params.push(date_from, date_to);
} else {
sql += ' AND (d.grade_date >= ? OR d.grade_date <= ?)';
params.push(date_from, date_to);
}
} else if (date_from) {
sql += ' AND d.grade_date >= ?';
params.push(date_from);
} else if (date_to) {
sql += ' AND d.grade_date <= ?';
params.push(date_to);
}
sql += ' ORDER BY sj.name, s.full_name, d.grade_date';
const rows = db.prepare(sql).all(...params);
const MONTHS = {
'09': 'Сентябрь', '10': 'Октябрь', '11': 'Ноябрь', '12': 'Декабрь',
'01': 'Январь', '02': 'Февраль', '03': 'Март', '04': 'Апрель',
'05': 'Май', '06': 'Июнь', '07': 'Июль', '08': 'Август'
};
const TRIMESTERS = [
{ key: 'I', months: ['09', '10', '11'], label: 'I триместр' },
{ key: 'II', months: ['12', '01', '02'], label: 'II триместр' },
{ key: 'III', months: ['03', '04', '05'], label: 'III триместр' }
];
function getPeriodKey(row) {
const m = row.grade_date.substring(0, 2);
if (mode === 'quarter') {
for (const t of TRIMESTERS) {
if (t.months.includes(m)) return t.key;
}
return m;
}
if (mode === 'week') return m + '-' + row.grade_date.substring(3, 5);
return m;
}
const grouped = {};
const periodSet = new Set();
for (const row of rows) {
let periodKey;
if (mode === 'week') {
periodKey = row.grade_date;
} else if (mode === 'quarter') {
periodKey = getPeriodKey(row);
} else {
periodKey = row.grade_date.substring(0, 2);
}
const key = `${row.subject}|${row.teacher}|${row.student}`;
if (!grouped[key]) {
grouped[key] = { subject: row.subject, teacher: row.teacher, student: row.student, cells: {} };
}
if (!grouped[key].cells[periodKey]) {
grouped[key].cells[periodKey] = [];
}
grouped[key].cells[periodKey].push(row.grade !== null ? String(row.grade) : (row.symbol || ''));
periodSet.add(periodKey);
}
let periods, periodLabels;
if (mode === 'week') {
const allDates = [...periodSet].sort();
const weekMap = {};
const weekLabels = {};
if (allDates.length > 0) {
const first = allDates[0];
const firstDate = new Date(2025, parseInt(first.substring(0, 2)) - 1, parseInt(first.substring(3, 5)));
let weekNum = 0;
let weekStart = null;
const sorted = [...periodSet].sort();
for (const d of sorted) {
const dt = new Date(2025, parseInt(d.substring(0, 2)) - 1, parseInt(d.substring(3, 5)));
if (weekStart === null || (dt - weekStart) / 86400000 >= 7) {
weekNum++;
weekStart = dt;
}
weekMap[d] = 'w' + weekNum;
if (!weekLabels['w' + weekNum]) {
const endDt = new Date(weekStart.getTime() + 6 * 86400000);
weekLabels['w' + weekNum] = d.substring(0, 5) + '' +
(endDt.getMonth() + 1).toString().padStart(2, '0') + '.' + endDt.getDate().toString().padStart(2, '0');
}
}
}
for (const d of allDates) {
const wk = weekMap[d];
for (const key of Object.keys(grouped)) {
const cell = grouped[key].cells;
if (cell[d]) {
if (!cell[wk]) cell[wk] = [];
cell[wk].push(...cell[d]);
delete cell[d];
}
}
}
periods = Object.keys(weekLabels).sort();
periodLabels = periods.map(p => weekLabels[p]);
} else if (mode === 'quarter') {
periods = TRIMESTERS.map(t => t.key);
periodLabels = TRIMESTERS.map(t => t.label);
} else {
periods = [...periodSet].sort();
periodLabels = periods.map(p => MONTHS[p] || p);
}
const resultRows = Object.values(grouped).map(r => ({
subject: r.subject,
teacher: r.teacher,
student: r.student,
cells: periods.map(p => (r.cells[p] || []).join(', '))
}));
resultRows.sort((a, b) =>
a.subject.localeCompare(b.subject) || a.student.localeCompare(b.student)
);
res.json({ mode, periods, periodLabels, rows: resultRows });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.delete('/api/classes/:name', (req, res) => {
try {
const cls = db.prepare(`SELECT id FROM classes WHERE name = ?`).get(req.params.name);
if (!cls) return res.status(404).json({ error: 'Класс не найден' });
db.prepare(`DELETE FROM daily_grades WHERE student_id IN (SELECT id FROM students WHERE class_id = ?)`).run(cls.id);
db.prepare(`DELETE FROM grades WHERE student_id IN (SELECT id FROM students WHERE class_id = ?)`).run(cls.id);
db.prepare(`DELETE FROM students WHERE class_id = ?`).run(cls.id);
db.prepare(`DELETE FROM classes WHERE id = ?`).run(cls.id);
res.json({ ok: true, message: `Класс ${req.params.name} удалён` });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.delete('/api/parallel/:grade', (req, res) => {
try {
const classes = db.prepare(`SELECT id, name FROM classes WHERE name LIKE ?`).all(req.params.grade + '%');
if (!classes.length) return res.status(404).json({ error: 'Классы не найдены' });
for (const cls of classes) {
db.prepare(`DELETE FROM daily_grades WHERE student_id IN (SELECT id FROM students WHERE class_id = ?)`).run(cls.id);
db.prepare(`DELETE FROM grades WHERE student_id IN (SELECT id FROM students WHERE class_id = ?)`).run(cls.id);
db.prepare(`DELETE FROM students WHERE class_id = ?`).run(cls.id);
db.prepare(`DELETE FROM classes WHERE id = ?`).run(cls.id);
}
res.json({ ok: true, message: `Параллель ${req.params.grade} удалена (${classes.length} классов)` });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.delete('/api/data', (req, res) => {
try {
db.exec('DELETE FROM daily_grades; DELETE FROM grades; DELETE FROM teachers; DELETE FROM students; DELETE FROM subjects; DELETE FROM classes;');
res.json({ ok: true, message: 'Все данные удалены' });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.use((err, req, res, next) => {
if (err.message === 'Только PDF файлы') {
return res.status(400).json({ error: err.message });
}
res.status(500).json({ error: err.message });
});
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: true
});
const server = app.listen(PORT, () => {
console.log(`Сервер запущен: http://localhost:${PORT}`);
console.log('Нажмите Ctrl+C для остановки');
});
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`Порт ${PORT} занят. Остановите другой процесс или укажите PORT=3001`);
} else {
console.error('Ошибка сервера:', err.message);
}
process.exit(1);
});
function shutdown() {
console.log('\nСервер остановлен');
rl.close();
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 2000);
}
rl.on('SIGINT', shutdown);
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('uncaughtException', (err) => {
console.error('Критическая ошибка:', err.message);
shutdown();
});
process.on('unhandledRejection', (reason) => {
console.error('Необработанный Promise:', reason);
});

143
src/db.js Normal file
View File

@@ -0,0 +1,143 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const DATA_DIR = path.join(__dirname, '..', 'data');
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
const DB_PATH = path.join(DATA_DIR, 'journal.db');
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS classes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS students (
id INTEGER PRIMARY KEY AUTOINCREMENT,
full_name TEXT NOT NULL,
class_id INTEGER NOT NULL,
UNIQUE(full_name, class_id),
FOREIGN KEY (class_id) REFERENCES classes(id)
);
CREATE TABLE IF NOT EXISTS subjects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS teachers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
subject_id INTEGER,
FOREIGN KEY (subject_id) REFERENCES subjects(id)
);
CREATE TABLE IF NOT EXISTS grades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_id INTEGER NOT NULL,
subject_id INTEGER NOT NULL,
teacher_id INTEGER,
quarter TEXT NOT NULL,
grade INTEGER,
UNIQUE(student_id, subject_id, quarter),
FOREIGN KEY (student_id) REFERENCES students(id),
FOREIGN KEY (subject_id) REFERENCES subjects(id),
FOREIGN KEY (teacher_id) REFERENCES teachers(id)
);
CREATE TABLE IF NOT EXISTS daily_grades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_id INTEGER NOT NULL,
subject_id INTEGER NOT NULL,
teacher_id INTEGER,
grade_date TEXT NOT NULL,
grade INTEGER,
symbol TEXT,
FOREIGN KEY (student_id) REFERENCES students(id),
FOREIGN KEY (subject_id) REFERENCES subjects(id),
FOREIGN KEY (teacher_id) REFERENCES teachers(id)
);
CREATE INDEX IF NOT EXISTS idx_daily_student ON daily_grades(student_id);
CREATE INDEX IF NOT EXISTS idx_daily_subject ON daily_grades(subject_id);
CREATE INDEX IF NOT EXISTS idx_daily_teacher ON daily_grades(teacher_id);
CREATE INDEX IF NOT EXISTS idx_daily_date ON daily_grades(grade_date);
`);
const getClass = db.prepare(`SELECT id FROM classes WHERE name = ?`);
const insertClass = db.prepare(`INSERT OR IGNORE INTO classes (name) VALUES (?)`);
const getStudent = db.prepare(`SELECT id FROM students WHERE full_name = ? AND class_id = ?`);
const insertStudent = db.prepare(`INSERT OR IGNORE INTO students (full_name, class_id) VALUES (?, ?)`);
const getSubject = db.prepare(`SELECT id FROM subjects WHERE name = ?`);
const insertSubject = db.prepare(`INSERT OR IGNORE INTO subjects (name) VALUES (?)`);
const getTeacher = db.prepare(`SELECT id FROM teachers WHERE name = ? AND subject_id = ?`);
const getTeacherNull = db.prepare(`SELECT id FROM teachers WHERE name = ? AND subject_id IS NULL`);
const insertTeacher = db.prepare(`INSERT OR IGNORE INTO teachers (name, subject_id) VALUES (?, ?)`);
const upsertGrade = db.prepare(`
INSERT INTO grades (student_id, subject_id, teacher_id, quarter, grade)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(student_id, subject_id, quarter) DO UPDATE SET
teacher_id = excluded.teacher_id,
grade = excluded.grade
`);
function upsertClass(name) {
insertClass.run(name);
return getClass.get(name).id;
}
function upsertStudent(fullName, classId) {
insertStudent.run(fullName, classId);
return getStudent.get(fullName, classId).id;
}
function upsertSubject(name) {
insertSubject.run(name);
return getSubject.get(name).id;
}
function upsertTeacher(name, subjectId) {
insertTeacher.run(name, subjectId || null);
const row = subjectId ? getTeacher.get(name, subjectId) : getTeacherNull.get(name);
return row ? row.id : null;
}
function upsertGradeValue(studentId, subjectId, teacherId, quarter, grade) {
upsertGrade.run(studentId, subjectId, teacherId || null, quarter, grade === 'н/а' || grade === 'н' ? null : Number(grade) || null);
}
const insertDailyGrade = db.prepare(`
INSERT INTO daily_grades (student_id, subject_id, teacher_id, grade_date, grade, symbol)
VALUES (?, ?, ?, ?, ?, ?)
`);
function insertDailyGradeValue(studentId, subjectId, teacherId, gradeDate, gradeVal) {
const num = /^[2-5]$/.test(gradeVal) ? parseInt(gradeVal) : null;
const sym = /^[НБУнбу]$/.test(gradeVal) ? gradeVal.toUpperCase() : null;
insertDailyGrade.run(studentId, subjectId, teacherId || null, gradeDate, num, sym);
}
function clearAll() {
db.exec('DELETE FROM daily_grades; DELETE FROM grades; DELETE FROM teachers; DELETE FROM students; DELETE FROM subjects; DELETE FROM classes;');
}
module.exports = {
db,
upsertClass,
upsertStudent,
upsertSubject,
upsertTeacher,
upsertGradeValue,
insertDailyGradeValue,
clearAll,
DATA_DIR
};

421
src/parser.js Normal file
View File

@@ -0,0 +1,421 @@
const { PDFParse } = require('pdf-parse');
const fs = require('fs');
async function extractText(buffer) {
const parser = new PDFParse({ data: buffer });
const result = await parser.getText();
return result.text;
}
function extractClassName(text) {
const m = text.match(/(\d{1,2}[А-ЯЁA-Z])\s*(?:класса|класс)/i);
return m ? m[1] : null;
}
function extractClassNameFromFilename(filename) {
const m = filename.match(/(\d{1,2}[А-Яа-яЁёA-Za-z])/);
return m ? m[1].toUpperCase() : 'Unknown';
}
function extractSubjects(text) {
const subjects = [];
const re = /Наименование предмета\s+(.+)/g;
let m;
while ((m = re.exec(text)) !== null) {
const name = m[1].trim();
if (name && !name.includes('Наименование') && name.length < 80) {
subjects.push(normalizeSubjectName(name));
}
}
return [...new Set(subjects)];
}
function normalizeSubjectName(name) {
return name
.replace(/\s+/g, ' ')
.replace(/\b(\w)\s+(\w)/g, (_, a, b) => a + ' ' + b)
.trim();
}
function extractTeachers(text) {
const teachers = [];
const re = /Фамилия,\s*имя,\s*отчество\s+учителя\s+([А-ЯЁ][\s\S]{0,60}?)\n\s*Число\s+и/g;
let m;
while ((m = re.exec(text)) !== null) {
const raw = m[1].trim();
const names = splitTeacherNames(raw);
for (const name of names) {
if (name.length > 4 && /[А-ЯЁ][а-яё]+\s+[А-ЯЁ]\./.test(name)) {
teachers.push(name);
}
}
}
return [...new Set(teachers)];
}
function splitTeacherNames(raw) {
const parts = raw.split(/\s{2,}/);
const names = [];
for (const part of parts) {
const singleRe = /[А-ЯЁ][а-яё]+\s+[А-ЯЁ]\.[А-ЯЁ]?(?:\s+[А-ЯЁ]\.[А-ЯЁ]?)?/g;
let m;
while ((m = singleRe.exec(part)) !== null) {
names.push(m[0]);
}
}
return names.length > 0 ? names : [raw];
}
function extractStudents(text) {
const countMap = new Map();
const re = /^\s*(\d{1,2})\s+([А-ЯЁ][А-Яа-яё]+(?:\s+[А-ЯЁ][А-Яа-яё]+){1,2})/gm;
let m;
while ((m = re.exec(text)) !== null) {
const num = parseInt(m[1]);
const candidate = m[2].trim();
const parts = candidate.split(/\s+/);
if (num < 1 || num > 50) continue;
if (parts.length < 2 || parts.length > 3) continue;
const nonName = ['Зач', 'Число', 'Месяц', 'Дано', 'Урок', 'Триместр', 'Год', 'Переведен'];
if (nonName.includes(parts[parts.length - 1])) continue;
const score = nameQuality(candidate);
if (score < 15) continue;
const key = `${num}:${candidate}`;
countMap.set(key, (countMap.get(key) || 0) + 1);
}
const byNum = new Map();
for (const [key, count] of countMap) {
const [numStr, ...nameParts] = key.split(':');
const num = parseInt(numStr);
const name = nameParts.join(':');
const existing = byNum.get(num);
if (!existing || count > existing.count) {
byNum.set(num, { name, count });
} else if (count === existing.count && name.length > existing.name.length) {
byNum.set(num, { name, count });
}
}
const students = [];
for (const [num, val] of byNum) {
students.push({ num, name: val.name });
}
students.sort((a, b) => a.num - b.num);
return students;
}
function nameQuality(name) {
const parts = name.split(/\s+/);
let score = 0;
for (const p of parts) {
if (/^[А-ЯЁ][а-яё]+$/.test(p)) score += 12;
else if (/^[А-ЯЁ]+$/.test(p)) score += 2;
else score += 0;
}
score += parts.length * 5;
const totalLen = name.replace(/\s/g, '').length;
score += Math.min(totalLen / 4, 4);
return score;
}
function parseSummaryGrades(text, studentNamesByNum) {
const allGrades = [];
const allSubjectsSet = new Set();
const sections = text.split(/(?:Сводная ведомость|Фамилия,\s*имя,\s*отчество\s+классного)/);
const studentRe = /(\d{1,2})\s+([А-ЯЁ][А-Яа-яё]+\s+[А-ЯЁ][А-Яа-яё]+(?:\s+[А-ЯЁ][А-Яа-яё]+)?)\s*\n/g;
const quarterRe = /(?:^|\n)\s*(I{1,3})\s*триместр\s+(.+?)(?:\n|$)/gm;
function parseGradesLine(line, subjectCount) {
const values = [];
const parts = line.trim().split(/\s+/);
for (const p of parts) {
if (/^[2-5]$/.test(p)) values.push(parseInt(p));
else if (p === 'Зач' || p === 'зач') values.push(null);
else if (/^\d+$/.test(p)) values.push(parseInt(p));
}
while (values.length < subjectCount) values.push(null);
return values.slice(0, subjectCount);
}
function parseSmallFormat(sectionText, sectionSubjects) {
const lines = sectionText.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('--'));
const gradeLines = [];
for (const line of lines) {
const parts = line.split(/\s+/);
const numericParts = parts.filter(p => /^[2-5]$/.test(p));
if (numericParts.length === sectionSubjects.length) {
gradeLines.push(numericParts.map(Number));
}
}
const results = [];
let studentIdx = 0;
for (let i = 0; i + 3 < gradeLines.length; i += 4) {
const studentNum = studentIdx + 1;
const studentName = studentNamesByNum[studentNum];
if (!studentName) { studentIdx++; continue; }
const quarters = ['I', 'II', 'III', 'year'];
for (let qi = 0; qi < 4 && i + qi < gradeLines.length; qi++) {
for (let si = 0; si < sectionSubjects.length; si++) {
results.push({
studentName, studentNum,
subject: sectionSubjects[si],
quarter: quarters[qi],
grade: gradeLines[i + qi][si] || null
});
}
}
studentIdx++;
}
return results;
}
for (const section of sections) {
const sectionSubjects = extractSubjectNames(section);
if (sectionSubjects.length === 0) continue;
for (const s of sectionSubjects) allSubjectsSet.add(s);
let sMatch;
let hasNamedStudents = false;
while ((sMatch = studentRe.exec(section)) !== null) {
hasNamedStudents = true;
const studentNum = parseInt(sMatch[1]);
const studentName = sMatch[2].trim();
const blockStart = sMatch.index;
const blockEnd = section.indexOf('\nЭкз', blockStart);
const block = section.substring(blockStart, blockEnd > 0 ? blockEnd : blockStart + 1500);
let qMatch;
while ((qMatch = quarterRe.exec(block)) !== null) {
const quarter = qMatch[1];
const parsed = parseGradesLine(qMatch[2], sectionSubjects.length);
for (let i = 0; i < parsed.length; i++) {
allGrades.push({
studentName, studentNum,
subject: sectionSubjects[i],
quarter: quarter === 'I' ? 'I' : quarter === 'II' ? 'II' : quarter === 'III' ? 'III' : quarter,
grade: parsed[i]
});
}
}
const yMatch = block.match(/Год\.\s*оценка\s+(.+)/);
if (yMatch) {
const parsed = parseGradesLine(yMatch[1], sectionSubjects.length);
for (let i = 0; i < parsed.length; i++) {
allGrades.push({
studentName, studentNum,
subject: sectionSubjects[i],
quarter: 'year',
grade: parsed[i]
});
}
}
}
if (!hasNamedStudents && sectionSubjects.length === 3) {
const smallGrades = parseSmallFormat(section, sectionSubjects);
allGrades.push(...smallGrades);
}
}
return { allSubjects: [...allSubjectsSet], grades: allGrades };
}
function extractSubjectNames(sectionText) {
const subjects = [];
const seen = new Set();
const wholeText = sectionText.replace(/\n/g, ' ').replace(/\s+/g, ' ');
const knownSubjects = [
['Адаптивная', 'Адаптивная физическая культура'],
['Биология', 'Биология'],
['География', 'География'],
['Изобразительн', 'Изобразительное искусство'],
['Иностранн', 'Иностранный язык (английский)'],
['История', 'История'],
['Литература', 'Литература'],
['Математика', 'Математика'],
['Мир', 'Мир-визуально-пространственных-искусств'],
['Музыка', 'Музыка'],
['Русский', 'Русский язык'],
['Труд', 'Труд (технология)'],
['Физическая', 'Физическая культура']
];
for (const [prefix, canonical] of knownSubjects) {
if (wholeText.includes(prefix)) {
if (!seen.has(canonical)) {
seen.add(canonical);
subjects.push(canonical);
}
}
}
return subjects;
}
function parseDailyGrades(text) {
const results = [];
const subjectSections = splitSubjectSections(text);
for (const sec of subjectSections) {
const teacher = extractTeacherFromSection(sec.text);
const dates = extractLessonDates(sec.text);
const studentRe = /^\s*(\d{1,2})\s+([А-ЯЁ][А-Яа-яё]+\s+[А-ЯЁ][А-Яа-яё]+(?:\s+[А-ЯЁ][А-Яа-яё]+)?)\s+([\s\dНБУнбу\/\.]+?)(?:\n|$)/gm;
let sMatch;
while ((sMatch = studentRe.exec(sec.text)) !== null) {
const studentNum = parseInt(sMatch[1]);
const studentName = sMatch[2].trim();
const gradesStr = sMatch[3].trim();
if (studentNum < 1 || studentNum > 50) continue;
if (studentName.split(/\s+/).length < 2) continue;
const tokens = gradesStr.split(/\s+/).filter(t => t.length > 0);
const gradeTokens = tokens.filter(t => /^[2-5НБУнбу]$/.test(t) || /^\d+\/\d+$/.test(t));
for (let i = 0; i < gradeTokens.length; i++) {
const raw = gradeTokens[i];
let grade = null;
let symbol = null;
if (/^[2-5]$/.test(raw)) {
grade = parseInt(raw);
} else if (/^\d+\/\d+$/.test(raw)) {
const parts = raw.split('/');
const lastNum = parts[parts.length - 1];
if (/^[2-5]$/.test(lastNum)) grade = parseInt(lastNum);
}
if (!grade && /^[НБУнбу]$/.test(raw)) {
symbol = raw.toUpperCase();
}
const date = i < dates.length ? dates[i] : null;
if (!date) continue;
const parts = date.split('.');
const sortableDate = parts.length === 2 ? parts[1] + '-' + parts[0] : date;
results.push({
subject: sec.name,
teacher,
studentName,
studentNum,
grade,
symbol,
date: sortableDate
});
}
}
}
return results;
}
function splitSubjectSections(text) {
const sections = [];
const re = /Наименование предмета\s+(.+)/g;
let m;
let lastIndex = 0;
while ((m = re.exec(text)) !== null) {
if (lastIndex > 0) {
sections[sections.length - 1].end = m.index;
}
sections.push({
name: normalizeSubjectName(m[1].trim()),
start: m.index,
end: text.length
});
lastIndex = m.index;
}
if (lastIndex > 0) {
sections[sections.length - 1].end = text.length;
}
for (const sec of sections) {
sec.text = text.substring(sec.start, Math.min(sec.end, sec.start + 30000));
}
return sections;
}
function extractTeacherFromSection(sectionText) {
const m = sectionText.match(/Фамилия,\s*имя,\s*отчество\s+учителя\s+([А-ЯЁ][А-Яа-яёЁ\s\.]+?)\n\s*Число/);
if (!m) return null;
const raw = m[1].trim();
const nameRe = /[А-ЯЁ][а-яё]+\s+[А-ЯЁ]\.[А-ЯЁ]?(?:\s+[А-ЯЁ]\.[А-ЯЁ]?)?/g;
const names = [];
let nm;
while ((nm = nameRe.exec(raw)) !== null) names.push(nm[0]);
return names.length > 0 ? names[0] : (raw.length < 40 ? raw : null);
}
function extractLessonDates(sectionText) {
const dates = [];
const re = /^(\d{2}\.\d{2})\s/gm;
let m;
while ((m = re.exec(sectionText)) !== null) {
const d = m[1];
if (!dates.includes(d)) dates.push(d);
}
return dates;
}
function nextSubjectIndex(text, from) {
const re = /Наименование предмета\s+/g;
re.lastIndex = from;
const m = re.exec(text);
return m ? m.index : text.length;
}
async function parsePdfBuffer(buffer, filename) {
const text = await extractText(buffer);
if (!text || text.length < 100) throw new Error('Не удалось извлечь текст из PDF');
const classNameFromText = extractClassName(text);
const classNameFromFile = extractClassNameFromFilename(filename);
const className = classNameFromText || classNameFromFile || 'Unknown';
const students = extractStudents(text);
if (students.length === 0) throw new Error('Не найдены ученики в PDF');
const studentNamesByNum = {};
for (const s of students) {
studentNamesByNum[s.num] = s.name;
}
const subjects = extractSubjects(text);
const teachers = extractTeachers(text);
const { allSubjects: summarySubjects, grades: summaryGrades } = parseSummaryGrades(text, studentNamesByNum);
const dailyGrades = parseDailyGrades(text);
const finalSubjects = summarySubjects.length > 0 ? summarySubjects : subjects;
const allGrades = summaryGrades.length > 0 ? summaryGrades : [];
return {
className,
students: students.map(s => s.name),
subjects: finalSubjects,
teachers,
allGrades,
dailyGrades,
rawSummary: summaryGrades
};
}
module.exports = { parsePdfBuffer };

224
src/stats.js Normal file
View File

@@ -0,0 +1,224 @@
const { db } = require('./db');
function buildFilter(filters) {
const conditions = [];
const params = [];
if (filters.student) {
conditions.push('s.full_name LIKE ?');
params.push('%' + filters.student + '%');
}
if (filters.subject) {
conditions.push('sj.name LIKE ?');
params.push('%' + filters.subject + '%');
}
if (filters.teacher) {
conditions.push('t.name LIKE ?');
params.push('%' + filters.teacher + '%');
}
if (filters.date_from && filters.date_to) {
if (filters.date_from <= filters.date_to) {
conditions.push('d.grade_date >= ? AND d.grade_date <= ?');
params.push(filters.date_from, filters.date_to);
} else {
conditions.push('(d.grade_date >= ? OR d.grade_date <= ?)');
params.push(filters.date_from, filters.date_to);
}
} else if (filters.date_from) {
conditions.push('d.grade_date >= ?');
params.push(filters.date_from);
} else if (filters.date_to) {
conditions.push('d.grade_date <= ?');
params.push(filters.date_to);
}
return { where: conditions.length ? 'AND ' + conditions.join(' AND ') : '', params };
}
function getFullStats(filters = {}) {
const f = buildFilter(filters);
const classes = db.prepare(`SELECT * FROM classes ORDER BY name`).all();
const subjects = db.prepare(`SELECT * FROM subjects ORDER BY name`).all();
const students = db.prepare(`
SELECT s.*, c.name as class_name
FROM students s JOIN classes c ON s.class_id = c.id
ORDER BY c.name, s.full_name
`).all();
const teachers = db.prepare(`
SELECT t.*, COALESCE(sj.name, '—') as subject_name
FROM teachers t LEFT JOIN subjects sj ON t.subject_id = sj.id
ORDER BY t.name
`).all();
const gradeCountBySubject = db.prepare(`
SELECT sj.name as subject,
COUNT(*) as total,
COUNT(DISTINCT d.student_id) as student_count,
SUM(CASE WHEN d.grade = 5 THEN 1 ELSE 0 END) as fives,
SUM(CASE WHEN d.grade = 4 THEN 1 ELSE 0 END) as fours,
SUM(CASE WHEN d.grade = 3 THEN 1 ELSE 0 END) as threes,
SUM(CASE WHEN d.grade = 2 THEN 1 ELSE 0 END) as twos,
SUM(CASE WHEN d.symbol = 'Н' THEN 1 ELSE 0 END) as n_count,
SUM(CASE WHEN d.symbol = 'Б' THEN 1 ELSE 0 END) as b_count,
SUM(CASE WHEN d.symbol = 'У' THEN 1 ELSE 0 END) as u_count,
ROUND(AVG(CASE WHEN d.grade IS NOT NULL THEN CAST(d.grade AS REAL) END), 2) as avg_grade
FROM daily_grades d
JOIN subjects sj ON d.subject_id = sj.id
JOIN students s ON d.student_id = s.id
LEFT JOIN teachers t ON d.teacher_id = t.id
WHERE 1=1 ${f.where}
GROUP BY sj.name ORDER BY student_count DESC, total DESC
`).all(...f.params);
const gradeCountByTeacher = db.prepare(`
SELECT t.name as teacher,
GROUP_CONCAT(DISTINCT sj.name) as subject_name,
COUNT(*) as total,
COUNT(DISTINCT d.student_id) as student_count,
SUM(CASE WHEN d.grade = 5 THEN 1 ELSE 0 END) as fives,
SUM(CASE WHEN d.grade = 4 THEN 1 ELSE 0 END) as fours,
SUM(CASE WHEN d.grade = 3 THEN 1 ELSE 0 END) as threes,
SUM(CASE WHEN d.grade = 2 THEN 1 ELSE 0 END) as twos,
SUM(CASE WHEN d.symbol = 'Н' THEN 1 ELSE 0 END) as n_count,
SUM(CASE WHEN d.symbol = 'Б' THEN 1 ELSE 0 END) as b_count,
SUM(CASE WHEN d.symbol = 'У' THEN 1 ELSE 0 END) as u_count
FROM daily_grades d
JOIN teachers t ON d.teacher_id = t.id
JOIN subjects sj ON d.subject_id = sj.id
JOIN students s ON d.student_id = s.id
WHERE 1=1 ${f.where}
GROUP BY t.id ORDER BY total ASC
`).all(...f.params);
for (const t of gradeCountByTeacher) {
if (t.subject_name) t.subject_name = t.subject_name.replace(/,/g, ', ');
}
const gradeCountByClass = db.prepare(`
SELECT c.name as class, COUNT(*) as total,
COUNT(DISTINCT d.student_id) as student_count,
SUM(CASE WHEN d.grade = 5 THEN 1 ELSE 0 END) as fives,
SUM(CASE WHEN d.grade = 4 THEN 1 ELSE 0 END) as fours,
SUM(CASE WHEN d.grade = 3 THEN 1 ELSE 0 END) as threes,
SUM(CASE WHEN d.grade = 2 THEN 1 ELSE 0 END) as twos,
SUM(CASE WHEN d.symbol = 'Н' THEN 1 ELSE 0 END) as n_count,
SUM(CASE WHEN d.symbol = 'Б' THEN 1 ELSE 0 END) as b_count,
SUM(CASE WHEN d.symbol = 'У' THEN 1 ELSE 0 END) as u_count
FROM daily_grades d
JOIN students s ON d.student_id = s.id
JOIN classes c ON s.class_id = c.id
JOIN subjects sj ON d.subject_id = sj.id
LEFT JOIN teachers t ON d.teacher_id = t.id
WHERE 1=1 ${f.where}
GROUP BY c.id ORDER BY c.name
`).all(...f.params);
const teachersWithNoGrades = (f.params.length === 0) ? db.prepare(`
SELECT t.name, COALESCE(sj.name, '—') as subject_name
FROM teachers t
LEFT JOIN subjects sj ON t.subject_id = sj.id
WHERE t.id NOT IN (SELECT DISTINCT teacher_id FROM daily_grades WHERE teacher_id IS NOT NULL)
ORDER BY t.name
`).all() : [];
const subjectsWithFewGrades = gradeCountBySubject
.filter(s => s.student_count < 10)
.map(s => ({
subject: s.subject,
students: s.student_count,
warning: `⚠️ Предмет «${s.subject}»: всего ${s.student_count} учеников`
}));
const totalStudents = students.length;
const totalClasses = classes.length;
const totalTeachers = teachers.length;
const totalSubjects = subjects.length;
const totalDaily = db.prepare(`
SELECT COUNT(*) as c FROM daily_grades d
JOIN students s ON d.student_id = s.id
JOIN subjects sj ON d.subject_id = sj.id
LEFT JOIN teachers t ON d.teacher_id = t.id
WHERE 1=1 ${f.where}
`).get(...f.params).c;
return {
summary: { totalClasses, totalStudents, totalTeachers, totalSubjects, totalDaily },
classes,
subjects,
students,
teachers,
gradeCountBySubject,
gradeCountByTeacher,
gradeCountByClass,
subjectsWithFewGrades,
teachersWithNoGrades
};
}
function getSubjectDetails(subjectName) {
const grades = db.prepare(`
SELECT s.full_name as student, c.name as class, g.quarter, g.grade
FROM grades g
JOIN students s ON g.student_id = s.id
JOIN classes c ON s.class_id = c.id
JOIN subjects sj ON g.subject_id = sj.id
WHERE sj.name = ?
ORDER BY c.name, s.full_name, g.quarter
`).all(subjectName);
const daily = db.prepare(`
SELECT s.full_name as student, c.name as class, d.grade_date, d.grade, d.symbol
FROM daily_grades d
JOIN students s ON d.student_id = s.id
JOIN classes c ON s.class_id = c.id
JOIN subjects sj ON d.subject_id = sj.id
WHERE sj.name = ?
ORDER BY d.grade_date DESC, s.full_name
LIMIT 200
`).all(subjectName);
return { subjectName, quarterlyGrades: grades, dailyGrades: daily };
}
function getTeacherDetails(teacherName) {
return db.prepare(`
SELECT s.full_name as student, c.name as class, sj.name as subject, d.grade_date, d.grade, d.symbol
FROM daily_grades d
JOIN students s ON d.student_id = s.id
JOIN classes c ON s.class_id = c.id
JOIN subjects sj ON d.subject_id = sj.id
JOIN teachers t ON d.teacher_id = t.id
WHERE t.name = ?
ORDER BY d.grade_date DESC, s.full_name, sj.name
LIMIT 200
`).all(teacherName);
}
function getClassDetails(className) {
const students = db.prepare(`
SELECT s.* FROM students s JOIN classes c ON s.class_id = c.id WHERE c.name = ?
ORDER BY s.full_name
`).all(className);
const subjects = db.prepare(`
SELECT DISTINCT sj.name FROM daily_grades d
JOIN students s ON d.student_id = s.id
JOIN classes c ON s.class_id = c.id
JOIN subjects sj ON d.subject_id = sj.id
WHERE c.name = ? ORDER BY sj.name
`).all(className);
const daily = db.prepare(`
SELECT s.full_name as student, sj.name as subject, d.grade_date, d.grade, d.symbol
FROM daily_grades d
JOIN students s ON d.student_id = s.id
JOIN classes c ON s.class_id = c.id
JOIN subjects sj ON d.subject_id = sj.id
WHERE c.name = ?
ORDER BY s.full_name, d.grade_date, sj.name
LIMIT 500
`).all(className);
return { className, students, subjects, daily };
}
module.exports = { getFullStats, getSubjectDetails, getTeacherDetails, getClassDetails };