Files
gia-gve-certificates/src/routes/api.js
2026-07-03 12:02:46 +05:00

373 lines
15 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('/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;