const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const { getDb, saveDb, dbGet, dbAll, dbRun, dbLastInsertId, dbBegin, dbCommit, dbRollback } = require('../db'); const { parseXls } = require('../parser'); const router = express.Router(); const UPLOAD_DIR = path.join(__dirname, '..', '..', 'data', 'uploads'); if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true }); const upload = multer({ dest: UPLOAD_DIR, fileFilter: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); if (ext === '.xls' || ext === '.xlsx') cb(null, true); else cb(new Error('Только .xls и .xlsx файлы')); } }); router.post('/import', upload.single('xlsfile'), (req, res) => { try { if (!req.file) return res.status(400).json({ error: 'Файл не загружен' }); const parsed = parseXls(req.file.path, req.file.originalname); const studentIdByKey = {}; dbBegin(); for (const subj of parsed.subjects) { dbRun('INSERT OR IGNORE INTO subjects (code, name, exam_date) VALUES (?, ?, ?)', [subj.code, subj.name, subj.date || '']); } for (const st of parsed.students) { const key = `${st.last_name}|${st.first_name}|${st.patronymic}|${st.class}`.toLowerCase(); const before = dbRun('INSERT OR IGNORE INTO students (last_name, first_name, patronymic, class, school_code, passport_series, passport_number) VALUES (?, ?, ?, ?, ?, ?, ?)', [st.last_name, st.first_name, st.patronymic, st.class, st.school_code, st.passport_series, st.passport_number]); if (before.changes > 0) { const newId = dbLastInsertId(); studentIdByKey[key] = newId; // Авто-присвоить номер справки const certNumber = String(new Date().getFullYear()) + String(newId).padStart(4, '0'); dbRun('UPDATE students SET cert_number = ? WHERE id = ? AND cert_number IS NULL', [certNumber, newId]); } else { const row = dbGet('SELECT id FROM students WHERE last_name=? AND first_name=? AND patronymic=? AND class=?', [st.last_name, st.first_name, st.patronymic, st.class]); if (row) { studentIdByKey[key] = row.id; dbRun('UPDATE students SET school_code=?, passport_series=?, passport_number=? WHERE id=?', [st.school_code, st.passport_series, st.passport_number, row.id]); } } } for (const r of parsed.results) { const sid = studentIdByKey[r.student_key.toLowerCase()]; if (sid) { let code = r.subject_code; const name = (r.subject_name || '').toLowerCase(); if (name === 'русский язык') { const existing = dbGet("SELECT grade FROM exam_results WHERE student_id = ? AND subject_code = '01'", [sid]); if (!existing || existing.grade === null) code = '01'; } else if (name === 'математика') { const existing = dbGet("SELECT grade FROM exam_results WHERE student_id = ? AND subject_code = '02'", [sid]); if (!existing || existing.grade === null) code = '02'; } const oldRow = dbGet("SELECT primary_score, grade FROM exam_results WHERE student_id = ? AND subject_code = ?", [sid, code]); const changedBy = (req.session && req.session.user) ? req.session.user.login : 'import'; if (!oldRow) { if (r.primary_score !== null) logHistory(sid, code, 'primary_score', null, String(r.primary_score), changedBy, 'import'); if (r.grade !== null) logHistory(sid, code, 'grade', null, String(r.grade), changedBy, 'import'); } else { if (String(oldRow.primary_score) !== String(r.primary_score)) logHistory(sid, code, 'primary_score', oldRow.primary_score, r.primary_score, changedBy, 'import'); if (String(oldRow.grade) !== String(r.grade)) logHistory(sid, code, 'grade', oldRow.grade, r.grade, changedBy, 'import'); } dbRun('INSERT OR REPLACE INTO exam_results (student_id, subject_code, primary_score, grade) VALUES (?, ?, ?, ?)', [sid, code, r.primary_score, r.grade]); } } dbCommit(); saveDb(); fs.unlink(req.file.path, () => {}); res.json({ ok: true, students: parsed.students.length, subjects: parsed.subjects.length, results: parsed.results.length }); } catch (err) { dbRollback(); console.error(err); if (req.file) fs.unlink(req.file.path, () => {}); res.status(500).json({ error: err.message }); } }); router.get('/subjects', (req, res) => { const rows = dbAll('SELECT code, name FROM subjects ORDER BY is_mandatory DESC, name'); res.json(rows); }); router.get('/students', (req, res) => { const search = req.query.search || ''; const filter = req.query.filter || ''; const subjectCode = req.query.subject_code || ''; const scoreMin = req.query.score_min; const scoreMax = req.query.score_max; const gradeMin = req.query.grade_min; const gradeMax = req.query.grade_max; const baseQuery = ` SELECT s.*, COUNT(er.id) as exam_count, SUM(CASE WHEN s2.is_mandatory = 0 THEN 1 ELSE 0 END) as elective_count, SUM(CASE WHEN s2.is_mandatory = 1 AND er.grade IS NOT NULL THEN 1 ELSE 0 END) as mandatory_grades, SUM(CASE WHEN s2.is_mandatory = 1 AND er.grade IS NOT NULL AND er.grade < 3 THEN 1 ELSE 0 END) as low_grades, GROUP_CONCAT(s2.name || ' ' || CAST(er.grade AS TEXT), ' / ') as subjects_list FROM students s LEFT JOIN exam_results er ON er.student_id = s.id LEFT JOIN subjects s2 ON s2.code = er.subject_code `; const groupClause = 'GROUP BY s.id'; const orderClause = 'ORDER BY s.class, s.last_name, s.first_name'; let whereClauses = []; let params = []; if (search) { const like = `%${search}%`; whereClauses.push('(s.last_name LIKE ? OR s.first_name LIKE ? OR s.patronymic LIKE ? OR s.class LIKE ?)'); params.push(like, like, like, like); } if (subjectCode) { whereClauses.push('er.subject_code = ?'); params.push(subjectCode); } if (scoreMin !== undefined && scoreMin !== '') { whereClauses.push('er.primary_score >= ?'); params.push(parseInt(scoreMin, 10)); } if (scoreMax !== undefined && scoreMax !== '') { whereClauses.push('er.primary_score <= ?'); params.push(parseInt(scoreMax, 10)); } if (gradeMin !== undefined && gradeMin !== '') { whereClauses.push('er.grade >= ?'); params.push(parseInt(gradeMin, 10)); } if (gradeMax !== undefined && gradeMax !== '') { whereClauses.push('er.grade <= ?'); params.push(parseInt(gradeMax, 10)); } let havingClause = ''; if (filter === 'gia' || filter === 'oge') { havingClause = 'HAVING elective_count > 0'; } else if (filter === 'gve') { havingClause = 'HAVING elective_count = 0'; } else if (filter === 'nodata') { havingClause = 'HAVING mandatory_grades < 2'; } else if (filter === 'conflict') { whereClauses.push(`s.id IN ( SELECT DISTINCT er1.student_id FROM exam_results er1 JOIN subjects s1 ON s1.code = er1.subject_code WHERE s1.name = 'Русский язык' AND er1.subject_code != '01' AND EXISTS (SELECT 1 FROM exam_results er2 WHERE er2.student_id = er1.student_id AND er2.subject_code = '01') UNION SELECT DISTINCT er1.student_id FROM exam_results er1 JOIN subjects s1 ON s1.code = er1.subject_code WHERE s1.name = 'Математика' AND er1.subject_code != '02' AND EXISTS (SELECT 1 FROM exam_results er2 WHERE er2.student_id = er1.student_id AND er2.subject_code = '02') )`); } else if (filter === 'secondyear') { havingClause = 'HAVING elective_count = 0 AND COUNT(er.id) = 1'; } else if (filter === 'lowgrade') { havingClause = 'HAVING low_grades > 0'; } const wherePart = whereClauses.length ? 'WHERE ' + whereClauses.join(' AND ') : ''; const sql = `${baseQuery} ${wherePart} ${groupClause} ${havingClause} ${orderClause}`; const rows = dbAll(sql, params); res.json(rows); }); router.get('/students/:id', (req, res) => { const student = dbGet('SELECT * FROM students WHERE id = ?', [req.params.id]); if (!student) return res.status(404).json({ error: 'Ученик не найден' }); const results = dbAll(` SELECT er.*, s.name as subject_name, s.is_mandatory FROM exam_results er JOIN subjects s ON s.code = er.subject_code WHERE er.student_id = ? ORDER BY s.is_mandatory DESC, s.name `, [req.params.id]); const history = dbAll(` SELECT h.*, s.name as subject_name FROM exam_history h LEFT JOIN subjects s ON s.code = h.subject_code WHERE h.student_id = ? ORDER BY h.changed_at DESC LIMIT 50 `, [req.params.id]); res.json({ student, results, history }); }); router.put('/students/:id/mandatory', (req, res) => { const { id } = req.params; const { rus_score, rus_grade, math_score, math_grade } = req.body; const changedBy = (req.session && req.session.user) ? req.session.user.login : 'unknown'; dbBegin(); const updateSubject = (code, score, grade) => { const oldRow = dbGet("SELECT primary_score, grade FROM exam_results WHERE student_id = ? AND subject_code = ?", [id, code]); if (!oldRow) { if (score !== undefined && score !== null && score !== '') logHistory(id, code, 'primary_score', null, String(score), changedBy, 'manual'); if (grade !== undefined && grade !== null && grade !== '') logHistory(id, code, 'grade', null, String(grade), changedBy, 'manual'); } else { const newScore = (score !== undefined && score !== '' && score !== null) ? score : null; const newGrade = (grade !== undefined && grade !== '' && grade !== null) ? grade : null; if (String(oldRow.primary_score) !== String(newScore)) logHistory(id, code, 'primary_score', oldRow.primary_score, newScore, changedBy, 'manual'); if (String(oldRow.grade) !== String(newGrade)) logHistory(id, code, 'grade', oldRow.grade, newGrade, changedBy, 'manual'); } dbRun('INSERT OR REPLACE INTO exam_results (student_id, subject_code, primary_score, grade) VALUES (?, ?, ?, ?)', [id, code, score || null, grade || null]); }; if (rus_score !== undefined || rus_grade !== undefined) { updateSubject('01', rus_score, rus_grade); } if (math_score !== undefined || math_grade !== undefined) { updateSubject('02', math_score, math_grade); } dbCommit(); saveDb(); res.json({ ok: true }); }); router.put('/students/:id/cert-number', (req, res) => { const { id } = req.params; const { cert_number } = req.body; if (!cert_number) return res.status(400).json({ error: 'Номер справки обязателен' }); // Проверить уникальность const existing = dbGet('SELECT id, last_name, first_name, class FROM students WHERE cert_number = ? AND id != ?', [cert_number, id]); if (existing) { return res.status(409).json({ error: `Номер ${cert_number} уже занят: ${existing.last_name} ${existing.first_name}, ${existing.class}` }); } const oldRow = dbGet('SELECT cert_number FROM students WHERE id = ?', [id]); const oldValue = oldRow ? oldRow.cert_number : null; dbRun('UPDATE students SET cert_number = ? WHERE id = ?', [cert_number, id]); // История const changedBy = req.session.user ? req.session.user.login : 'unknown'; if (String(oldValue) !== String(cert_number)) { logHistory(id, 'CERT', 'cert_number', oldValue, cert_number, changedBy, 'manual'); } saveDb(); res.json({ ok: true }); }); router.get('/classes', (req, res) => { const rows = dbAll('SELECT DISTINCT class FROM students ORDER BY class'); res.json(rows.map(r => r.class)); }); router.get('/declensions', (req, res) => { const { last_name, first_name, patronymic } = req.query; const whereClauses = []; const params = []; if (last_name) { whereClauses.push('s.last_name LIKE ?'); params.push(`%${last_name}%`); } if (first_name) { whereClauses.push('s.first_name LIKE ?'); params.push(`%${first_name}%`); } if (patronymic) { whereClauses.push('s.patronymic LIKE ?'); params.push(`%${patronymic}%`); } const wherePart = whereClauses.length ? 'WHERE ' + whereClauses.join(' AND ') : ''; const rows = dbAll(` SELECT DISTINCT s.last_name, s.first_name, s.patronymic, nd.id as declension_id, nd.last_name_declined, nd.first_name_declined, nd.patronymic_declined, nd.no_decline_last, nd.no_decline_first, nd.no_decline_patr, nd.updated_at FROM students s LEFT JOIN name_declensions nd ON nd.last_name = s.last_name AND nd.first_name = s.first_name AND nd.patronymic = s.patronymic ${wherePart} ORDER BY s.last_name, s.first_name, s.patronymic `, params); res.json(rows); }); router.put('/declensions', (req, res) => { const { last_name, first_name, patronymic, last_name_declined, first_name_declined, patronymic_declined, no_decline_last, no_decline_first, no_decline_patr } = req.body; if (!last_name || !first_name) { return res.status(400).json({ error: 'Фамилия и имя обязательны' }); } dbRun(` INSERT INTO name_declensions (last_name, first_name, patronymic, last_name_declined, first_name_declined, patronymic_declined, no_decline_last, no_decline_first, no_decline_patr, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(last_name, first_name, patronymic) DO UPDATE SET last_name_declined = excluded.last_name_declined, first_name_declined = excluded.first_name_declined, patronymic_declined = excluded.patronymic_declined, no_decline_last = excluded.no_decline_last, no_decline_first = excluded.no_decline_first, no_decline_patr = excluded.no_decline_patr, updated_at = excluded.updated_at `, [ last_name, first_name, patronymic || '', last_name_declined || '', first_name_declined || '', patronymic_declined || '', no_decline_last ? 1 : 0, no_decline_first ? 1 : 0, no_decline_patr ? 1 : 0 ]); saveDb(); res.json({ ok: true }); }); router.delete('/data', (req, res) => { dbRun('DELETE FROM exam_results'); dbRun('DELETE FROM students'); dbRun("DELETE FROM subjects WHERE is_mandatory = 0"); saveDb(); res.json({ ok: true }); }); function logHistory(studentId, subjectCode, fieldName, oldValue, newValue, changedBy, source) { if (String(oldValue) === String(newValue)) return; dbRun( 'INSERT INTO exam_history (student_id, subject_code, field_name, old_value, new_value, changed_by, source, changed_at) VALUES (?, ?, ?, ?, ?, ?, ?, datetime(\'now\'))', [studentId, subjectCode, fieldName, oldValue !== null && oldValue !== undefined ? String(oldValue) : null, newValue !== null && newValue !== undefined ? String(newValue) : null, changedBy || 'unknown', source || 'manual'] ); } module.exports = router;