diff --git a/public/style.css b/public/style.css index 1a4e3d2..e0c8117 100644 --- a/public/style.css +++ b/public/style.css @@ -132,3 +132,19 @@ ol li { margin: 4px 0; } font-size: 13px; color: #666; } + +.decl-input { + max-width: 180px; + font-size: 13px; + padding: 4px 6px; +} +.decl-input:disabled { + background: #f0f0f0; + color: #999; + cursor: not-allowed; +} +.decl-cb { + width: 18px; + height: 18px; + cursor: pointer; +} diff --git a/src/db.js b/src/db.js index d810462..e0676ed 100644 --- a/src/db.js +++ b/src/db.js @@ -109,6 +109,9 @@ async function initDb() { // Миграция: перенос Русского/Математики в обязательные слоты 01/02, если они пусты migrateMandatorySubjects(); + // Миграция: таблица склонений ФИО + migrateDeclensions(); + console.log('БД инициализирована'); return db; } @@ -164,6 +167,27 @@ function migrateMandatorySubjects() { } } +function migrateDeclensions() { + if (!db) return; + db.run(` + CREATE TABLE IF NOT EXISTS name_declensions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + last_name TEXT NOT NULL, + first_name TEXT NOT NULL, + patronymic TEXT DEFAULT '', + last_name_declined TEXT DEFAULT '', + first_name_declined TEXT DEFAULT '', + patronymic_declined TEXT DEFAULT '', + no_decline_last INTEGER DEFAULT 0, + no_decline_first INTEGER DEFAULT 0, + no_decline_patr INTEGER DEFAULT 0, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(last_name, first_name, patronymic) + ) + `); + saveDb(); +} + function getDb() { if (!db) throw new Error('БД не инициализирована.'); return db; diff --git a/src/routes/api.js b/src/routes/api.js index 6b0d0ac..7be0c78 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -246,6 +246,82 @@ router.get('/classes', (req, res) => { 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'); diff --git a/src/routes/pages.js b/src/routes/pages.js index fc4dbe7..77c51e1 100644 --- a/src/routes/pages.js +++ b/src/routes/pages.js @@ -1,5 +1,5 @@ const express = require('express'); -const { dbGet, dbAll } = require('../db'); +const { dbGet, dbAll, dbRun } = require('../db'); const router = express.Router(); const requireAuth = (req, res, next) => { @@ -119,7 +119,7 @@ router.get('/certificate/:id', requireAuth, (req, res) => { } const gender = detectGender(student.patronymic); - const fioDative = formatNameDative(student, gender); + const fioDative = formatNameDativeWithOverrides(student, gender); const verbEnding = gender === 'm' ? 'обучался' : 'обучалась'; const verbEnding2 = gender === 'm' ? 'получил' : 'получила'; @@ -203,6 +203,53 @@ function declineMalePatr(name) { return name + 'у'; } +function getDeclensionOverrides(last_name, first_name, patronymic) { + return dbGet( + 'SELECT * FROM name_declensions WHERE last_name = ? AND first_name = ? AND patronymic = ?', + [last_name || '', first_name || '', patronymic || ''] + ); +} + +function formatNameDativeWithOverrides(student, gender) { + const l = student.last_name || ''; + const f = student.first_name || ''; + const p = student.patronymic || ''; + + let declinedL, declinedF, declinedP; + if (gender === 'f') { + declinedL = declineFemaleLast(l); + declinedF = declineFemaleFirst(f); + declinedP = declineFemalePatr(p); + } else { + declinedL = declineMaleLast(l); + declinedF = declineMaleFirst(f); + declinedP = declineMalePatr(p); + } + + const override = getDeclensionOverrides(l, f, p); + if (override) { + if (override.no_decline_last) { + declinedL = l; + } else if (override.last_name_declined) { + declinedL = override.last_name_declined; + } + + if (override.no_decline_first) { + declinedF = f; + } else if (override.first_name_declined) { + declinedF = override.first_name_declined; + } + + if (override.no_decline_patr) { + declinedP = p; + } else if (override.patronymic_declined) { + declinedP = override.patronymic_declined; + } + } + + return `${declinedL} ${declinedF} ${declinedP}`; +} + router.get('/batch-print', requireAuth, (req, res) => { const { class: cls, parallel, type } = req.query; @@ -245,7 +292,7 @@ router.get('/batch-print', requireAuth, (req, res) => { const finalType = (type === 'gia' || type === 'gve') ? type : autoType; const gender = detectGender(st.patronymic); - const fioDative = formatNameDative(st, gender); + const fioDative = formatNameDativeWithOverrides(st, gender); let allSubjects; if (finalType === 'gia') { @@ -289,4 +336,8 @@ router.get('/history', requireAuth, (req, res) => { res.render('history', { rows, user: req.session.user }); }); +router.get('/declension', requireAuth, (req, res) => { + res.render('declension', { user: req.session.user }); +}); + module.exports = router; diff --git a/views/declension.ejs b/views/declension.ejs new file mode 100644 index 0000000..23b5d70 --- /dev/null +++ b/views/declension.ejs @@ -0,0 +1,246 @@ + + + + + + Склонение ФИО — Справки ГИА/ГВЭ + + + +
+

Настройки склонения ФИО

+ + + +
+

+ Настройте склонение фамилии, имени и отчества для справок. + Если отмечено «Не склоняется» — ФИО будет использоваться в именительном падеже. + Если введено своё склонение — оно будет использовано вместо автоматического. +

+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + + + + + + + + + + + + + +
ФамилияИмяОтчествоСклонённая фамилияНе скл.Склонённое имяНе скл.Склонённое отчествоНе скл.Действия
+
+
+ + + + diff --git a/views/index.ejs b/views/index.ejs index d00ef14..023fc13 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -13,6 +13,7 @@