963 lines
35 KiB
JavaScript
963 lines
35 KiB
JavaScript
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);
|
||
});
|