491 lines
17 KiB
JavaScript
491 lines
17 KiB
JavaScript
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);
|
||
});
|