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', async (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 { let user = authService.authenticateLocal(login, password); if (!user) { user = await authService.authenticateWithLDAP(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); });