342 lines
14 KiB
JavaScript
342 lines
14 KiB
JavaScript
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('/students', (req, res) => {
|
|
const search = req.query.search || '';
|
|
const filter = req.query.filter || '';
|
|
|
|
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);
|
|
}
|
|
|
|
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;
|