26
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
data/
|
||||
uploads/
|
||||
server.log
|
||||
*.pdf
|
||||
1559
package-lock.json
generated
Normal file
1559
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal 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
7365
pdf_text.txt
Normal file
File diff suppressed because it is too large
Load Diff
495
public/app.js
Normal file
495
public/app.js
Normal 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
148
public/index.html
Normal 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">×</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
414
public/style.css
Normal 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
490
server.js
Normal 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
143
src/db.js
Normal 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
421
src/parser.js
Normal 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
224
src/stats.js
Normal 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 };
|
||||
Reference in New Issue
Block a user