Files
SchoolVue/server.js
2026-07-07 10:37:51 +05:00

960 lines
35 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.
require('dotenv').config();
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const session = require('express-session');
const SQLiteStore = require('connect-sqlite3')(session);
const { parsePdfBuffer } = require('./src/parser');
const { upsertClass, upsertStudent, upsertSubject, upsertTeacher, upsertGradeValue, insertDailyGradeValue, insertLessonPlanValue, db } = require('./src/db');
const { getFullStats, getSubjectDetails, getTeacherDetails, getClassDetails } = require('./src/stats');
const AdmZip = require('adm-zip');
const authService = require('./auth');
authService.setDatabase(db);
const app = express();
const PORT = process.env.PORT || 3000;
function buildClassWhere(query) {
const parts = [];
const params = [];
if (query.class) {
parts.push('c.name LIKE ?');
params.push('%' + query.class + '%');
}
if (query.parallel) {
parts.push('c.name LIKE ?');
params.push(query.parallel + '%');
}
return { sql: parts.length ? 'AND ' + parts.join(' AND ') : '', params };
}
function CYRILLIC_MIN(text, min) {
if (!text) return false;
const m = text.match(/[а-яё]/gi);
return (m && m.length >= min);
}
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: 500 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const ext = file.originalname.toLowerCase();
if (file.mimetype === 'application/pdf' || ext.endsWith('.pdf') ||
file.mimetype === 'application/zip' || ext.endsWith('.zip') ||
file.mimetype === 'application/x-zip-compressed') {
cb(null, true);
} else {
cb(new Error('Только PDF или ZIP файлы'));
}
}
});
const tasks = new Map();
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
app.use(session({
store: new SQLiteStore({
db: 'sessions.db',
dir: path.join(__dirname, 'data'),
table: 'sessions'
}),
secret: process.env.SESSION_SECRET || 'fallback_secret',
resave: false,
saveUninitialized: false,
cookie: {
secure: false,
maxAge: 7 * 24 * 60 * 60 * 1000,
httpOnly: true,
sameSite: 'lax'
}
}));
app.use((req, res, next) => {
const mutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method);
const isLoginPath = req.path === '/api/login';
const isStatic = req.path.startsWith('/');
if (!isLoginPath && req.session && req.session.user && !req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
}
if (mutating) {
if (isLoginPath) return next();
if (isStatic && !req.path.startsWith('/api/')) return next();
if (!req.session || !req.session.user) {
return res.status(401).json({ error: 'Требуется аутентификация' });
}
const clientToken = req.headers['x-csrf-token'];
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
}
if (!clientToken || clientToken !== req.session.csrfToken) {
return res.status(403).json({ error: 'CSRF token missing or invalid' });
}
}
next();
});
const requireAuth = (req, res, next) => {
if (!req.session.user) {
return res.status(401).json({ error: 'Требуется аутентификация' });
}
next();
};
const loginAttempts = new Map();
app.post('/api/login', (req, res) => {
const ip = req.ip || req.connection.remoteAddress;
const now = Date.now();
if (loginAttempts.has(ip)) {
const data = loginAttempts.get(ip);
if (now < data.resetTime && data.count > 10) {
return res.status(429).json({ error: 'Слишком много попыток входа. Попробуйте через минуту.' });
}
if (now > data.resetTime) {
loginAttempts.set(ip, { count: 1, resetTime: now + 60000 });
} else {
data.count++;
}
} else {
loginAttempts.set(ip, { count: 1, resetTime: now + 60000 });
}
const { login, password } = req.body;
if (!login || !password) {
return res.status(400).json({ error: 'Логин и пароль обязательны' });
}
try {
const user = authService.authenticate(login, password);
if (user) {
const sessionUser = {
id: user.id,
login: user.login,
name: user.name,
email: user.email,
role: user.role,
auth_type: user.auth_type
};
if (sessionUser.login === process.env.USER_1_LOGIN) {
sessionUser.role = 'admin';
}
req.session.user = sessionUser;
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
req.session.save((err) => {
res.json({ success: true, user: sessionUser, csrfToken: req.session.csrfToken });
});
} else {
res.status(401).json({ error: 'Неверный логин или пароль' });
}
} catch (error) {
res.status(500).json({ error: 'Ошибка сервера при авторизации' });
}
});
app.post('/api/logout', (req, res) => {
req.session.destroy((err) => {
if (err) return res.status(500).json({ error: 'Ошибка при выходе' });
res.json({ success: true });
});
});
app.get('/api/user', (req, res) => {
if (req.session && req.session.user) {
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
}
res.json({ user: req.session.user, csrfToken: req.session.csrfToken });
} else {
res.status(401).json({ error: 'Не аутентифицирован' });
}
});
function processParsedPdf(parsed) {
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 lesson_plans WHERE subject_id IN (SELECT DISTINCT d.subject_id FROM daily_grades d JOIN students s ON d.student_id = s.id WHERE s.class_id = ?)`).run(classId);
db.prepare(`DELETE FROM daily_grades WHERE student_id IN (SELECT id FROM students WHERE class_id = ?)`).run(classId);
db.prepare(`DELETE FROM lesson_plans WHERE subject_id NOT IN (SELECT DISTINCT subject_id FROM daily_grades)`).run();
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++;
}
}
let lessonCount = 0;
for (const lp of parsed.lessonPlans) {
const subjectId = subjectIds[lp.subject];
if (!subjectId) continue;
let tId = null;
if (lp.teacher && teacherIds[lp.teacher]) {
tId = teacherIds[lp.teacher];
} else if (teacherBySubject[lp.subject]) {
tId = teacherIds[teacherBySubject[lp.subject]] || null;
}
insertLessonPlanValue(subjectId, tId, lp.date, lp.topic, lp.homework);
lessonCount++;
}
return {
className: parsed.className,
students: parsed.students.length,
subjects: parsed.subjects.length,
teachers: parsed.teachers.length,
quarterlyGrades: quarterlyCount,
dailyGrades: dailyCount,
lessons: lessonCount
};
}
app.post('/api/upload', requireAuth, upload.single('pdf'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ error: 'Файл не загружен' });
const ext = req.file.originalname.toLowerCase();
if (ext.endsWith('.zip')) {
const taskId = 'task_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
const filePath = req.file.path;
tasks.set(taskId, { status: 'processing', done: 0, total: 0, elapsed: 0, startTime: Date.now(), filePath });
setImmediate(async () => {
const task = tasks.get(taskId);
if (!task) return;
try {
const zip = new AdmZip(filePath);
const entries = zip.getEntries();
const pdfEntries = entries.filter(e => !e.isDirectory && e.entryName.toLowerCase().endsWith('.pdf'));
task.total = pdfEntries.length;
const results = [];
const errors = [];
for (let i = 0; i < pdfEntries.length; i++) {
const entry = pdfEntries[i];
try {
const buf = entry.getData();
const name = entry.entryName.replace(/^.*[\\/]/, '');
const parsed = await parsePdfBuffer(buf, name);
const r = processParsedPdf(parsed);
results.push(r);
task.lastFile = name;
task.lastResult = r.className + ': ' + r.students + ' уч.';
console.log('[OK] ' + name + ' → ' + r.className + ': ' + r.students + ' уч.');
await new Promise(r => setImmediate(r));
} catch (e) {
errors.push(entry.entryName + ': ' + e.message);
task.lastFile = entry.entryName;
task.lastResult = 'ОШИБКА: ' + e.message;
console.log('[ERR] ' + entry.entryName + ': ' + e.message);
}
task.done = i + 1;
task.elapsed = Math.floor((Date.now() - task.startTime) / 1000);
}
const clsSet = new Set(results.map(r => r.className));
console.log('=== Готово: ' + results.length + '/' + task.total + ' файлов, ' + clsSet.size + ' классов, ' + results.reduce((s,r)=>s+r.students,0) + ' уч., ' + Math.floor((Date.now() - task.startTime)/1000) + 'с, ошибок: ' + errors.length + ' ===');
if (errors.length) errors.forEach(e => console.log(' • ' + e));
task.status = errors.length > 0 ? 'partial' : 'done';
task.elapsed = Math.floor((Date.now() - task.startTime) / 1000);
task.result = {
classes: [...clsSet],
students: results.reduce((s, r) => s + r.students, 0),
files: results.length,
totalFiles: pdfEntries.length,
errors: errors.length,
errorList: errors.slice(0, 10),
elapsed: task.elapsed,
message: `Загружено ${results.length} из ${pdfEntries.length} файлов, ${clsSet.size} классов, ${results.reduce((s, r) => s + r.students, 0)} учеников за ${task.elapsed}с` +
(errors.length ? '\nОшибки:\n' + errors.map(e => ' • ' + e).join('\n') : '')
};
} catch (e) {
task.status = 'error';
task.error = e.message;
}
if (fs.existsSync(filePath)) try { fs.unlinkSync(filePath); } catch (_) {}
});
res.json({ taskId, status: 'processing' });
} else {
const buffer = fs.readFileSync(req.file.path);
const parsed = await parsePdfBuffer(buffer, req.file.originalname);
const r = processParsedPdf(parsed);
fs.unlinkSync(req.file.path);
res.json({
ok: true,
classes: [r.className],
students: r.students,
dailyGrades: r.dailyGrades,
quarterlyGrades: r.quarterlyGrades,
lessons: r.lessons,
message: `Загружен класс ${r.className}: ${r.students} учеников`
});
}
} 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/task/:taskId', requireAuth, (req, res) => {
const task = tasks.get(req.params.taskId);
if (!task) return res.status(404).json({ error: 'Задача не найдена' });
const resp = {
taskId: req.params.taskId,
status: task.status,
done: task.done,
total: task.total,
elapsed: Math.floor((Date.now() - task.startTime) / 1000)
};
if (task.status === 'done' || task.status === 'partial') {
resp.result = task.result;
}
if (task.status === 'error') {
resp.error = task.error;
}
res.json(resp);
if (task.status === 'done' || task.status === 'error') {
setTimeout(() => tasks.delete(req.params.taskId), 60000);
}
});
app.get('/api/stats', requireAuth, (req, res) => {
try {
res.json(getFullStats(req.query));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/subjects/:name', requireAuth, (req, res) => {
try {
res.json(getSubjectDetails(req.params.name));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/teachers/:name', requireAuth, (req, res) => {
try {
res.json(getTeacherDetails(req.params.name));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/classes/:name', requireAuth, (req, res) => {
try {
res.json(getClassDetails(req.params.name));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/filters', requireAuth, (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);
const classes = db.prepare(`SELECT DISTINCT c.name FROM daily_grades d JOIN students s ON d.student_id = s.id JOIN classes c ON s.class_id = c.id ORDER BY c.name`).all().map(r => r.name);
const parallels = db.prepare(`SELECT DISTINCT substr(c.name, 1, length(c.name) - 1) as p FROM classes c WHERE c.name GLOB '[0-9]*' ORDER BY CAST(p AS INTEGER)`).all().map(r => r.p);
res.json({ students, subjects, teachers, classes, parallels });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/daily', requireAuth, (req, res) => {
try {
const { student, subject, teacher, date_from, date_to, limit, offset } = req.query;
const cls = buildClassWhere(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 ${cls.sql}
`;
const params = [...cls.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 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 ${cls.sql}
`;
let countParams = [...cls.params];
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', requireAuth, (req, res) => {
try {
const { period, student, subject, teacher, date_from, date_to } = req.query;
const cls = buildClassWhere(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
JOIN classes c ON s.class_id = c.id
LEFT JOIN teachers t ON d.teacher_id = t.id
WHERE 1=1 ${cls.sql}
`;
const params = [...cls.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.get('/api/lessons', requireAuth, (req, res) => {
try {
const { subject, teacher, date_from, date_to, no_topic, no_homework, low_grades, min_grades } = req.query;
const cls = buildClassWhere(req.query);
const minNumeric = parseInt(min_grades) || 3;
let sql = `
SELECT lp.lesson_date,
sj.name as subject,
COALESCE(t.name, '—') as teacher,
lp.topic,
lp.homework,
COUNT(d.id) as grades_total,
SUM(CASE WHEN d.grade IS NOT NULL THEN 1 ELSE 0 END) as grades_numeric,
SUM(CASE WHEN d.symbol IS NOT NULL THEN 1 ELSE 0 END) as grades_symbol,
GROUP_CONCAT(COALESCE(CAST(d.grade AS TEXT), d.symbol), ',') as grades_list
FROM lesson_plans lp
JOIN subjects sj ON lp.subject_id = sj.id
LEFT JOIN teachers t ON lp.teacher_id = t.id
LEFT JOIN daily_grades d ON d.subject_id = lp.subject_id AND d.grade_date = lp.lesson_date
WHERE 1=1
`;
const params = [];
if (cls.params.length > 0) {
sql += ` AND lp.id IN (
SELECT DISTINCT lp2.id FROM lesson_plans lp2
JOIN daily_grades d2 ON d2.subject_id = lp2.subject_id AND d2.grade_date = lp2.lesson_date
JOIN students s2 ON d2.student_id = s2.id
JOIN classes c ON s2.class_id = c.id
WHERE 1=1 ${cls.sql}
)`;
params.push(...cls.params);
}
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 lp.lesson_date >= ? AND lp.lesson_date <= ?';
params.push(date_from, date_to);
} else {
sql += ' AND (lp.lesson_date >= ? OR lp.lesson_date <= ?)';
params.push(date_from, date_to);
}
} else if (date_from) {
sql += ' AND lp.lesson_date >= ?';
params.push(date_from);
} else if (date_to) {
sql += ' AND lp.lesson_date <= ?';
params.push(date_to);
}
if (no_topic === 'true') sql += ' AND lp.topic IS NULL';
if (no_homework === 'true') sql += ' AND lp.homework IS NULL';
sql += ' GROUP BY lp.lesson_date, lp.subject_id';
if (low_grades === 'true') {
sql += ' HAVING SUM(CASE WHEN d.grade IS NOT NULL THEN 1 ELSE 0 END) < ?';
params.push(minNumeric);
}
sql += ' ORDER BY lp.lesson_date, sj.name';
const rows = db.prepare(sql).all(...params);
for (const r of rows) {
r.lesson_date = r.lesson_date.substring(3, 5) + '.' + r.lesson_date.substring(0, 2);
r.grades_numeric = r.grades_numeric || 0;
r.grades_symbol = r.grades_symbol || 0;
r.grades_total = r.grades_total || 0;
r.topic_invalid = !!(r.topic && !CYRILLIC_MIN(r.topic, 3));
r.homework_invalid = !!(r.homework && !CYRILLIC_MIN(r.homework, 3));
if (r.topic_invalid) r.topic = null;
if (r.homework_invalid) r.homework = null;
}
res.json({ rows, min_grades: minNumeric });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/dvoiki', requireAuth, (req, res) => {
try {
const { student, subject, teacher, date_from, date_to } = req.query;
const cls = buildClassWhere(req.query);
let sql = `
SELECT d.grade_date, d.grade, d.symbol,
sj.name as subject,
COALESCE(t.name, '—') as teacher,
s.full_name as student,
c.name as class
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 ${cls.sql}
`;
const params = [...cls.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 s.full_name, sj.name, d.grade_date';
const rows = db.prepare(sql).all(...params);
const chains = [];
const byPair = {};
for (const r of rows) {
const key = r.student + '|' + r.subject;
if (!byPair[key]) byPair[key] = [];
byPair[key].push(r);
}
for (const [key, grades] of Object.entries(byPair)) {
let chain = null;
for (const g of grades) {
if (g.grade === 2) {
if (!chain) {
chain = { student: g.student, class: g.class, subject: g.subject, teacher: g.teacher, started: g.grade_date, chain: [], resolved: false, reason: '' };
}
chain.chain.push({ date: g.grade_date, grade: String(g.grade) });
} else if (chain && g.grade != null && g.grade >= 3) {
chain.chain.push({ date: g.grade_date, grade: String(g.grade) });
chain.resolved = true;
chain.reason = 'оценка ' + g.grade;
chains.push(chain);
chain = null;
} else if (chain && g.grade === null && g.symbol === null) {
chain.chain.push({ date: g.grade_date, grade: 'X' });
chain.reason = 'пустая клетка';
chains.push(chain);
chain = null;
} else if (chain) {
chain.chain.push({ date: g.grade_date, grade: g.grade !== null ? String(g.grade) : (g.symbol || 'X') });
if (g.grade === null && g.symbol === null) {
chain.reason = 'пустая клетка';
chains.push(chain);
chain = null;
}
}
}
if (chain && !chain.resolved) {
chain.reason = chain.reason || 'нет уроков после';
chain.objective = (chain.reason === 'нет уроков после' && chain.chain.length === 1);
chains.push(chain);
}
}
chains.sort((a, b) => b.started.localeCompare(a.started) || a.student.localeCompare(b.student));
for (const c of chains) {
c.started = c.started.substring(3, 5) + '.' + c.started.substring(0, 2);
for (const g of c.chain) {
g.date = g.date.substring(3, 5) + '.' + g.date.substring(0, 2);
}
}
res.json({ rows: chains });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.delete('/api/classes/:name', requireAuth, (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 lesson_plans WHERE subject_id IN (SELECT DISTINCT d.subject_id FROM daily_grades d JOIN students s ON d.student_id = s.id WHERE s.class_id = ?)`).run(cls.id);
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', requireAuth, (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 lesson_plans WHERE subject_id IN (SELECT DISTINCT d.subject_id FROM daily_grades d JOIN students s ON d.student_id = s.id WHERE s.class_id = ?)`).run(cls.id);
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);
}
db.prepare(`DELETE FROM lesson_plans WHERE subject_id NOT IN (SELECT DISTINCT subject_id FROM daily_grades)`).run();
res.json({ ok: true, message: `Параллель ${req.params.grade} удалена (${classes.length} классов)` });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.delete('/api/data', requireAuth, (req, res) => {
try {
db.exec('DELETE FROM lesson_plans; 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 или ZIP файлы') {
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.timeout = 600000;
server.keepAliveTimeout = 65000;
server.headersTimeout = 66000;
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);
});