Files
SchoolVue/server.js
2026-06-19 11:24:17 +05:00

491 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});