From 0fc7be87b55020f74f5fb9dac2156f076d8cc920 Mon Sep 17 00:00:00 2001 From: kalugin66 Date: Wed, 1 Jul 2026 09:18:06 +0500 Subject: [PATCH] v --- .env.example | 8 ++ .gitignore | 140 ++++++++++++++++++++++ package.json | 22 ++++ public/style.css | 88 ++++++++++++++ server.js | 151 +++++++++++++++++++++++ src/auth.js | 121 +++++++++++++++++++ src/db.js | 139 ++++++++++++++++++++++ src/parser.js | 170 ++++++++++++++++++++++++++ src/routes/api.js | 145 +++++++++++++++++++++++ src/routes/pages.js | 230 ++++++++++++++++++++++++++++++++++++ views/batch-certificate.ejs | 172 +++++++++++++++++++++++++++ views/certificate.ejs | 159 +++++++++++++++++++++++++ views/import.ejs | 85 +++++++++++++ views/index.ejs | 173 +++++++++++++++++++++++++++ views/login.ejs | 145 +++++++++++++++++++++++ views/student.ejs | 160 +++++++++++++++++++++++++ 16 files changed, 2108 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 package.json create mode 100644 public/style.css create mode 100644 server.js create mode 100644 src/auth.js create mode 100644 src/db.js create mode 100644 src/parser.js create mode 100644 src/routes/api.js create mode 100644 src/routes/pages.js create mode 100644 views/batch-certificate.ejs create mode 100644 views/certificate.ejs create mode 100644 views/import.ejs create mode 100644 views/index.ejs create mode 100644 views/login.ejs create mode 100644 views/student.ejs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..317dc43 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +SESSION_SECRET=change_me_to_a_random_string +LDAP_AUTH_URL=https://ldap.itschool25.ru/api/auth +PORT=3000 +USER_1_LOGIN=admin +USER_1_PASSWORD=changeme +USER_1_NAME=Administrator +USER_1_EMAIL=admin@example.com +USER_1_ROLE=admin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..13005dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,140 @@ +INSTRUCTIONS-hourwork-s1.md +mcp-memory.json +report.md +# ---> Node +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* +opencode.json +package-lock.json + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* +data +promt +public/test-results.json + diff --git a/package.json b/package.json new file mode 100644 index 0000000..11b6d27 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "diplom-certificates", + "version": "1.1.0", + "description": "Сервис для формирования справок ГИА и ГВЭ", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "bcryptjs": "~2.4.3", + "connect-sqlite3": "^0.9.16", + "dotenv": "~16.3.1", + "ejs": "^3.1.10", + "express": "^4.21.0", + "express-session": "^1.18.2", + "multer": "^1.4.5-lts.1", + "node-fetch": "~2.6.7", + "sql.js": "^1.10.0", + "xlsx": "^0.18.5" + } +} diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..54c12d9 --- /dev/null +++ b/public/style.css @@ -0,0 +1,88 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 15px; + background: #f5f5f5; + color: #333; + line-height: 1.5; +} +.container { max-width: 960px; margin: 0 auto; padding: 20px; } +h1 { margin-bottom: 15px; } +h2 { margin: 15px 0 8px; font-size: 16px; } + +.nav { margin-bottom: 15px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } + +.btn { + display: inline-block; + padding: 7px 14px; + background: #555; + color: #fff; + text-decoration: none; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; +} +.btn:hover { opacity: 0.85; } +.btn-green { background: #2a7d2a; } +.btn-blue { background: #0066cc; } +.btn-danger { background: #c0392b; } +.btn-sm { padding: 3px 10px; font-size: 13px; } +.btn-logout { background: #666; } + +.input { + padding: 8px 10px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; + width: 100%; + max-width: 300px; +} +.input-sm { max-width: 120px; } + +.search-bar { margin-bottom: 15px; } + +table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 4px; overflow: hidden; } +table th, table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid #eee; } +table th { background: #eee; font-weight: 600; } +table tr:hover { background: #fafafa; } + +.card { + background: #fff; + padding: 15px 20px; + border-radius: 6px; + margin-bottom: 15px; + box-shadow: 0 1px 3px rgba(0,0,0,0.08); +} + +.form-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; + flex-wrap: wrap; +} +.form-row label { min-width: 180px; font-weight: 500; } + +.checkbox-label { + display: block; + margin: 3px 0; + cursor: pointer; +} + +.msg { font-size: 13px; margin-left: 10px; } +.msg-ok { color: #2a7d2a; } +.msg-err { color: #c0392b; } + +.toolbar { margin-top: 15px; } + +ol { padding-left: 20px; } +ol li { margin: 4px 0; } + +.user-bar { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: #666; +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..867d4f7 --- /dev/null +++ b/server.js @@ -0,0 +1,151 @@ +require('dotenv').config(); + +const express = require('express'); +const crypto = require('crypto'); +const path = require('path'); +const session = require('express-session'); +const SQLiteStore = require('connect-sqlite3')(session); + +const { initDb, getDb } = require('./src/db'); +const authService = require('./src/auth'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +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_change_in_production', + resave: false, + saveUninitialized: false, + cookie: { + secure: false, + maxAge: 7 * 24 * 60 * 60 * 1000, + httpOnly: true, + sameSite: 'lax' + } +})); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use((req, res, next) => { + res.locals.csrfToken = req.session ? req.session.csrfToken || '' : ''; + res.locals.user = req.session ? req.session.user || null : null; + next(); +}); + +app.use((req, res, next) => { + const mutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method); + const isLoginPath = req.path === '/api/login'; + 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 (!req.session || !req.session.user) { + return res.status(401).json({ error: 'Требуется аутентификация' }); + } + const clientToken = req.headers['x-csrf-token'] || req.headers['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) { + if (req.headers.accept && req.headers.accept.includes('application/json')) { + return res.status(401).json({ error: 'Требуется аутентификация' }); + } + return res.redirect('/login'); + } + next(); +}; + +app.post('/api/login', async (req, res) => { + const { login, password } = req.body; + if (!login || !password) { + return res.status(400).json({ error: 'Логин и пароль обязательны' }); + } + try { + const user = await authService.authenticate(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, + groups: user.groups ? (typeof user.groups === 'string' ? JSON.parse(user.groups) : user.groups) : [] + }; + 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) => { + if (err) return res.status(500).json({ error: 'Ошибка сохранения сессии' }); + console.log(`Вход: ${user.name} (${user.login})`); + res.json({ success: true, user: sessionUser, csrfToken: req.session.csrfToken }); + }); + } else { + res.status(401).json({ error: 'Неверный логин или пароль' }); + } + } catch (err) { + console.error('Ошибка аутентификации:', err.message); + 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.user) { + if (!req.session.csrfToken) { + req.session.csrfToken = crypto.randomBytes(32).toString('hex'); + } + return res.json({ success: true, user: req.session.user, csrfToken: req.session.csrfToken }); + } + res.status(401).json({ error: 'Не авторизован' }); +}); + +app.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +async function start() { + await initDb(); + authService.setDatabase(getDb()); + + const apiRoutes = require('./src/routes/api'); + const pageRoutes = require('./src/routes/pages'); + app.use('/api', apiRoutes); + app.use('/', pageRoutes); + + app.listen(PORT, () => { + console.log(`Сервер запущен: http://localhost:${PORT}`); + }); +} + +start().catch(err => { + console.error('Ошибка запуска:', err); + process.exit(1); +}); diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..ac1a678 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,121 @@ +const bcrypt = require('bcryptjs'); +const { dbGet, dbRun, dbLastInsertId, saveDb } = require('./db'); + +class AuthService { + constructor() { + this.db = null; + } + + setDatabase(db) { + this.db = db; + this.initUsers(); + } + + initUsers() { + if (!this.db) return; + try { + const login = process.env.USER_1_LOGIN; + const pass = process.env.USER_1_PASSWORD; + if (login && pass) { + this.createUserIfNotExists({ + login, password: pass, + name: process.env.USER_1_NAME, + email: process.env.USER_1_EMAIL, + role: process.env.USER_1_ROLE || 'admin' + }); + } + } catch (err) { + console.error('Ошибка инициализации пользователей:', err.message); + } + } + + createUserIfNotExists(userData) { + const row = dbGet('SELECT id FROM users WHERE login = ?', [userData.login]); + if (row) return row.id; + + const hash = bcrypt.hashSync(userData.password, 10); + dbRun( + "INSERT INTO users (login, password, name, email, role, auth_type, created_at) VALUES (?, ?, ?, ?, ?, 'local', datetime('now'))", + [userData.login, hash, userData.name, userData.email, userData.role || 'teacher'] + ); + const id = dbLastInsertId(); + console.log(`Создан пользователь: ${userData.name}`); + saveDb(); + return id; + } + + authenticateLocal(login, password) { + const user = dbGet("SELECT * FROM users WHERE login = ? AND auth_type = 'local'", [login]); + if (!user) return null; + if (!user.password) return null; + if (!bcrypt.compareSync(password, user.password)) return null; + + dbRun("UPDATE users SET last_login = datetime('now') WHERE id = ?", [user.id]); + saveDb(); + + const { password: _, ...rest } = user; + return rest; + } + + async authenticateLDAP(username, password) { + if (!process.env.LDAP_AUTH_URL) return null; + try { + const fetch = require('node-fetch'); + const response = await fetch(process.env.LDAP_AUTH_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + if (!response.ok) return null; + const data = await response.json(); + if (!data.success) return null; + return this.processLDAPUser(data); + } catch (err) { + console.error('Ошибка LDAP:', err.message); + return null; + } + } + + processLDAPUser(ldapData) { + const { username, full_name, groups } = ldapData; + const groupsArr = groups || []; + + const existing = dbGet("SELECT * FROM users WHERE login = ? AND auth_type = 'ldap'", [username]); + const userData = { + login: username, + name: full_name || username, + email: `${username}@school25.ru`, + groups: JSON.stringify(groupsArr) + }; + + if (existing) { + dbRun( + "UPDATE users SET name=?, email=?, groups=?, last_login=datetime('now') WHERE id=?", + [userData.name, userData.email, userData.groups, existing.id] + ); + saveDb(); + const { password: _, ...rest } = existing; + return { ...rest, name: userData.name, email: userData.email, groups: userData.groups }; + } + + dbRun( + "INSERT INTO users (login, name, email, auth_type, groups, created_at, last_login) VALUES (?, ?, ?, 'ldap', ?, datetime('now'), datetime('now'))", + [username, userData.name, userData.email, userData.groups] + ); + const id = dbLastInsertId(); + console.log(`Создан LDAP пользователь: ${username}`); + saveDb(); + return { id, login: username, name: userData.name, email: userData.email, auth_type: 'ldap', groups: userData.groups }; + } + + async authenticate(login, password) { + const localUser = this.authenticateLocal(login, password); + if (localUser) return localUser; + return this.authenticateLDAP(login, password); + } + + isReady() { return this.db !== null; } +} + +const authService = new AuthService(); +module.exports = authService; diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..490fc9f --- /dev/null +++ b/src/db.js @@ -0,0 +1,139 @@ +const initSqlJs = require('sql.js'); +const path = require('path'); +const fs = require('fs'); + +const DATA_DIR = path.join(__dirname, '..', 'data'); +const DB_PATH = path.join(DATA_DIR, 'exams.db'); + +let db = null; + +async function initDb() { + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + + const SQL = await initSqlJs(); + + if (fs.existsSync(DB_PATH)) { + const buffer = fs.readFileSync(DB_PATH); + db = new SQL.Database(buffer); + } else { + db = new SQL.Database(); + } + + db.run('PRAGMA foreign_keys = ON'); + + db.run(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + login TEXT UNIQUE NOT NULL, + password TEXT, + name TEXT NOT NULL, + email TEXT, + role TEXT DEFAULT 'teacher', + auth_type TEXT DEFAULT 'local', + groups TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_login DATETIME + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS students ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + last_name TEXT NOT NULL, + first_name TEXT NOT NULL, + patronymic TEXT DEFAULT '', + class TEXT DEFAULT '', + school_code TEXT DEFAULT '', + passport_series TEXT DEFAULT '', + passport_number TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(last_name, first_name, patronymic, class) + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS subjects ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, + exam_date TEXT DEFAULT '', + is_mandatory INTEGER DEFAULT 0 + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS exam_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + student_id INTEGER NOT NULL, + subject_code TEXT NOT NULL, + primary_score INTEGER, + grade INTEGER, + FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE, + FOREIGN KEY (subject_code) REFERENCES subjects(code), + UNIQUE(student_id, subject_code) + ) + `); + + db.run('INSERT OR IGNORE INTO subjects (code, name, is_mandatory) VALUES (?, ?, 1)', ['01', 'Русский язык']); + db.run('INSERT OR IGNORE INTO subjects (code, name, is_mandatory) VALUES (?, ?, 1)', ['02', 'Математика']); + + saveDb(); + console.log('БД инициализирована'); + return db; +} + +function saveDb() { + if (!db) return; + const data = db.export(); + const buffer = Buffer.from(data); + fs.writeFileSync(DB_PATH, buffer); +} + +function getDb() { + if (!db) throw new Error('БД не инициализирована.'); + return db; +} + +// Wrappers emulating better-sqlite3 API +function dbGet(sql, params = []) { + const st = db.prepare(sql); + st.bind(params); + const hasRow = st.step(); + const row = hasRow ? st.getAsObject() : null; + st.free(); + return row; +} + +function dbAll(sql, params = []) { + const st = db.prepare(sql); + st.bind(params); + const rows = []; + while (st.step()) { + rows.push(st.getAsObject()); + } + st.free(); + return rows; +} + +function dbRun(sql, params = []) { + const before = db.getRowsModified(); + db.run(sql, params); + const after = db.getRowsModified(); + return { changes: after - before }; +} + +function dbLastInsertId() { + const r = db.exec('SELECT last_insert_rowid() as id'); + return r.length ? r[0].values[0][0] : null; +} + +function dbExec(sql) { + db.run(sql); +} + +function dbBegin() { db.run('BEGIN'); } +function dbCommit() { db.run('COMMIT'); } +function dbRollback() { try { db.run('ROLLBACK'); } catch (e) {} } + +module.exports = { initDb, getDb, saveDb, dbGet, dbAll, dbRun, dbLastInsertId, dbExec, dbBegin, dbCommit, dbRollback }; diff --git a/src/parser.js b/src/parser.js new file mode 100644 index 0000000..515cc5d --- /dev/null +++ b/src/parser.js @@ -0,0 +1,170 @@ +const XLSX = require('xlsx'); + +const SUBJECT_NAMES = [ + 'Русский язык', 'Математика', 'Обществознание', 'Физика', + 'Химия', 'Информатика', 'Биология', 'Английский язык', + 'География', 'Литература', 'История', 'Немецкий язык', + 'Французский язык', 'Испанский язык' +]; + +const HEADER_MAP = { + 'фамилия': 'last_name', + 'имя': 'first_name', + 'отчество': 'patronymic', + 'класс': 'class', + 'код оо': 'school_code', + 'серия': 'passport_series', + 'номер': 'passport_number', + 'первичный балл': 'primary_score', + 'оценка': 'grade' +}; + +function normalize(s) { + return String(s || '').trim().toLowerCase(); +} + +function parseXls(filePath) { + const workbook = XLSX.readFile(filePath); + const sheetName = workbook.SheetNames[0]; + const sheet = workbook.Sheets[sheetName]; + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' }); + + const parsedSubjects = []; + const studentsMap = new Map(); + const results = []; + + let currentSubject = null; + let colIdx = null; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (!row || row.every(c => c === '' || c === null || c === undefined)) continue; + + const firstCell = String(row[0] || '').trim(); + + if (isSummaryRowText(firstCell)) { + currentSubject = null; + colIdx = null; + continue; + } + + const subjectInfo = detectSubject(firstCell); + if (subjectInfo) { + currentSubject = subjectInfo; + parsedSubjects.push(subjectInfo); + colIdx = null; + continue; + } + + if (currentSubject && !colIdx) { + colIdx = findColumnHeaders(row); + if (colIdx) continue; + } + + if (currentSubject && colIdx) { + const sd = extractStudentData(row, colIdx); + if (sd && sd.last_name) { + const key = `${normalize(sd.last_name)}|${normalize(sd.first_name)}|${normalize(sd.patronymic)}|${normalize(sd.class)}`; + if (!studentsMap.has(key)) { + studentsMap.set(key, { + last_name: sd.last_name, + first_name: sd.first_name, + patronymic: sd.patronymic, + class: sd.class, + school_code: sd.school_code || '', + passport_series: sd.passport_series || '', + passport_number: sd.passport_number || '' + }); + } + results.push({ + student_key: key, + subject_code: currentSubject.code, + subject_name: currentSubject.name, + exam_date: currentSubject.date || '', + primary_score: sd.primary_score, + grade: sd.grade + }); + } + } + } + + return { + subjects: uniqSubjects(parsedSubjects), + students: [...studentsMap.values()], + results + }; +} + +function uniqSubjects(subjects) { + const seen = new Set(); + return subjects.filter(s => { + if (seen.has(s.code)) return false; + seen.add(s.code); + return true; + }); +} + +function detectSubject(firstCell) { + if (!firstCell) return null; + const match = firstCell.match(/^(\d{2})\s*-\s*(.+?)\s+(\d{4}\.\d{2}\.\d{2})$/); + if (!match) return null; + const code = match[1]; + const name = match[2]; + const date = match[3]; + if (SUBJECT_NAMES.some(sn => sn.toLowerCase() === name.toLowerCase())) { + return { code, name, date }; + } + return null; +} + +function isSummaryRowText(firstCell) { + return /^(средни|всего|итого)/i.test(firstCell); +} + +function findColumnHeaders(row) { + const map = {}; + for (let i = 0; i < row.length; i++) { + const s = normalize(String(row[i])); + for (const [pattern, field] of Object.entries(HEADER_MAP)) { + if (s === pattern || s.includes(pattern)) { + map[field] = i; + break; + } + } + } + if (map.last_name && (map.primary_score || map.grade)) return map; + return null; +} + +function extractStudentData(row, colIdx) { + const get = (field) => { + const idx = colIdx[field]; + if (idx === undefined || idx >= row.length) return ''; + return String(row[idx]).trim(); + }; + + const firstCol = String(row[0] || '').trim(); + if (!firstCol || !/^\d+$/.test(firstCol)) return null; + + const last_name = get('last_name'); + if (!last_name || last_name.length < 2) return null; + + const toInt = (v) => { + const n = parseInt(v, 10); + return isNaN(n) ? null : n; + }; + + return { + last_name, + first_name: get('first_name'), + patronymic: get('patronymic'), + class: get('class'), + school_code: get('school_code'), + passport_series: get('passport_series'), + passport_number: get('passport_number'), + primary_score: toInt(get('primary_score')), + grade: toInt(get('grade')) + }; +} + +module.exports = { parseXls }; diff --git a/src/routes/api.js b/src/routes/api.js new file mode 100644 index 0000000..7f3bb4d --- /dev/null +++ b/src/routes/api.js @@ -0,0 +1,145 @@ +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); + 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) { + studentIdByKey[key] = dbLastInsertId(); + } 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) { + dbRun('INSERT OR REPLACE INTO exam_results (student_id, subject_code, primary_score, grade) VALUES (?, ?, ?, ?)', [sid, r.subject_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 || ''; + let rows; + if (search) { + const like = `%${search}%`; + rows = dbAll(` + SELECT s.*, COUNT(er.id) as exam_count + FROM students s + LEFT JOIN exam_results er ON er.student_id = s.id + WHERE s.last_name LIKE ? OR s.first_name LIKE ? OR s.patronymic LIKE ? OR s.class LIKE ? + GROUP BY s.id + ORDER BY s.class, s.last_name, s.first_name + `, [like, like, like, like]); + } else { + rows = dbAll(` + SELECT s.*, COUNT(er.id) as exam_count + FROM students s + LEFT JOIN exam_results er ON er.student_id = s.id + GROUP BY s.id + ORDER BY s.class, s.last_name, s.first_name + `); + } + 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]); + + res.json({ student, results }); +}); + +router.put('/students/:id/mandatory', (req, res) => { + const { id } = req.params; + const { rus_score, rus_grade, math_score, math_grade } = req.body; + + dbBegin(); + if (rus_score !== undefined || rus_grade !== undefined) { + dbRun('INSERT OR REPLACE INTO exam_results (student_id, subject_code, primary_score, grade) VALUES (?, ?, ?, ?)', [id, '01', rus_score || null, rus_grade || null]); + } + if (math_score !== undefined || math_grade !== undefined) { + dbRun('INSERT OR REPLACE INTO exam_results (student_id, subject_code, primary_score, grade) VALUES (?, ?, ?, ?)', [id, '02', math_score || null, math_grade || null]); + } + dbCommit(); + 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.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 }); +}); + +module.exports = router; diff --git a/src/routes/pages.js b/src/routes/pages.js new file mode 100644 index 0000000..ab4e13f --- /dev/null +++ b/src/routes/pages.js @@ -0,0 +1,230 @@ +const express = require('express'); +const { dbGet, dbAll } = require('../db'); + +const router = express.Router(); + +const requireAuth = (req, res, next) => { + if (!req.session.user) return res.redirect('/login'); + next(); +}; + +router.get('/login', (req, res) => { + if (req.session.user) return res.redirect('/'); + res.render('login'); +}); + +router.get('/', requireAuth, (req, res) => { + res.render('index', { user: req.session.user }); +}); + +router.get('/student/:id', requireAuth, (req, res) => { + const student = dbGet('SELECT * FROM students WHERE id = ?', [req.params.id]); + if (!student) return res.status(404).send('Ученик не найден'); + + const results = dbAll(` + SELECT er.*, s.name as subject_name, s.is_mandatory, s.code as subject_code + 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 mandatoryResults = results.filter(r => r.is_mandatory); + const electiveResults = results.filter(r => !r.is_mandatory); + const rusResult = mandatoryResults.find(r => r.subject_code === '01') || null; + const mathResult = mandatoryResults.find(r => r.subject_code === '02') || null; + + res.render('student', { + student, + electiveResults, + rusResult, + mathResult, + user: req.session.user + }); +}); + +router.get('/import', requireAuth, (req, res) => { + res.render('import', { user: req.session.user }); +}); + +router.get('/certificate/:id', requireAuth, (req, res) => { + const { id } = req.params; + const type = req.query.type || 'gia'; + const subjectCodes = req.query.subjects ? req.query.subjects.split(',') : []; + + const student = dbGet('SELECT * FROM students WHERE id = ?', [id]); + if (!student) return res.status(404).send('Ученик не найден'); + + const rusResult = dbGet(` + SELECT er.*, s.name as subject_name + FROM exam_results er JOIN subjects s ON s.code = er.subject_code + WHERE er.student_id = ? AND er.subject_code = '01' + `, [id]); + + const mathResult = dbGet(` + SELECT er.*, s.name as subject_name + FROM exam_results er JOIN subjects s ON s.code = er.subject_code + WHERE er.student_id = ? AND er.subject_code = '02' + `, [id]); + + let electives = []; + if (type === 'gia' && subjectCodes.length > 0) { + const placeholders = subjectCodes.map(() => '?').join(','); + electives = dbAll(` + SELECT er.*, s.name as subject_name + FROM exam_results er JOIN subjects s ON s.code = er.subject_code + WHERE er.student_id = ? AND er.subject_code IN (${placeholders}) + `, [id, ...subjectCodes]); + } + + const today = new Date(); + const certNumber = `${today.getFullYear()}${String(id).padStart(4, '0')}`; + const months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', + 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']; + const dateStr = `«${String(today.getDate()).padStart(2, '0')}» ${months[today.getMonth()]} ${today.getFullYear()} года`; + + const gender = detectGender(student.patronymic); + const fioDative = formatNameDative(student, gender); + const verbEnding = gender === 'm' ? 'обучался' : 'обучалась'; + const verbEnding2 = gender === 'm' ? 'получил' : 'получила'; + + res.render('certificate', { + type, + student: { ...student, fioDative, gender }, + certNumber, + dateStr, + verbEnding, + verbEnding2, + rusResult: rusResult || null, + mathResult: mathResult || null, + electives, + allSubjects: type === 'gia' + ? [ + rusResult || { subject_name: 'Русский язык', primary_score: null, grade: null }, + mathResult || { subject_name: 'Математика', primary_score: null, grade: null }, + ...electives + ] + : [ + rusResult || { subject_name: 'Русский язык', grade: null }, + mathResult || { subject_name: 'Математика', grade: null } + ] + }); +}); + +function detectGender(patronymic) { + if (!patronymic) return 'm'; + const last = patronymic.toLowerCase(); + if (/вна|чна|нична$/.test(last)) return 'f'; + return 'm'; +} + +function formatNameDative(student, gender) { + const l = student.last_name || ''; + const f = student.first_name || ''; + const p = student.patronymic || ''; + if (gender === 'f') { + return `${declineFemaleLast(l)} ${declineFemaleFirst(f)} ${declineFemalePatr(p)}`; + } + return `${declineMaleLast(l)} ${declineMaleFirst(f)} ${declineMalePatr(p)}`; +} + +function declineFemaleLast(name) { + if (!name) return ''; + if (name.endsWith('ая')) return name.slice(0, -2) + 'ой'; + if (name.endsWith('яя')) return name.slice(0, -2) + 'ей'; + if (name.endsWith('а')) return name.slice(0, -1) + 'ой'; + if (name.endsWith('я')) return name.slice(0, -1) + 'е'; + if (name.endsWith('ова') || name.endsWith('ева') || name.endsWith('ина')) return name.slice(0, -1) + 'ой'; + return name + 'ой'; +} +function declineFemaleFirst(name) { + if (!name) return ''; + if (name.endsWith('а') || name.endsWith('я')) return name.slice(0, -1) + 'е'; + return name + 'е'; +} +function declineFemalePatr(name) { + if (!name) return ''; + if (name.endsWith('на')) return name.slice(0, -1) + 'е'; + return name + 'е'; +} + +function declineMaleLast(name) { + if (!name) return ''; + if (name.endsWith('ов') || name.endsWith('ев') || name.endsWith('ин') || name.endsWith('ёв')) return name + 'у'; + if (name.endsWith('ий') || name.endsWith('ый')) return name.slice(0, -2) + 'ому'; + if (name.endsWith('ой')) return name.slice(0, -2) + 'ому'; + return name + 'у'; +} +function declineMaleFirst(name) { + if (!name) return ''; + if (name.endsWith('й') || name.endsWith('ь')) return name.slice(0, -1) + 'ю'; + if (name.endsWith('а') || name.endsWith('я')) return name.slice(0, -1) + 'е'; + return name + 'у'; +} +function declineMalePatr(name) { + if (!name) return ''; + if (name.endsWith('ич')) return name + 'у'; + if (name.endsWith('на')) return name.slice(0, -1) + 'е'; + return name + 'у'; +} + +router.get('/batch-print', requireAuth, (req, res) => { + const { class: cls, parallel, type } = req.query; + if (!type || !['gia', 'gve'].includes(type)) return res.status(400).send('Укажите type=gia или type=gve'); + + let students; + if (cls) { + students = dbAll('SELECT * FROM students WHERE class = ? ORDER BY last_name, first_name', [cls]); + } else if (parallel) { + students = dbAll('SELECT * FROM students WHERE class LIKE ? ORDER BY class, last_name, first_name', [parallel + '%']); + } else { + students = dbAll('SELECT * FROM students ORDER BY class, last_name, first_name'); + } + + const today = new Date(); + const months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', + 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']; + const dateStr = `«${String(today.getDate()).padStart(2, '0')}» ${months[today.getMonth()]} ${today.getFullYear()} года`; + + const studentData = students.map(st => { + 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 = ? + `, [st.id]); + + const rusResult = results.find(r => r.subject_code === '01') || null; + const mathResult = results.find(r => r.subject_code === '02') || null; + const electives = results.filter(r => !r.is_mandatory); + + const gender = detectGender(st.patronymic); + const fioDative = formatNameDative(st, gender); + + let allSubjects; + if (type === 'gia') { + allSubjects = [ + rusResult || { subject_name: 'Русский язык', primary_score: null, grade: null }, + mathResult || { subject_name: 'Математика', primary_score: null, grade: null }, + ...electives + ]; + } else { + allSubjects = [ + rusResult || { subject_name: 'Русский язык', grade: null }, + mathResult || { subject_name: 'Математика', grade: null } + ]; + } + + return { + student: { ...st, fioDative, gender }, + certNumber: `${today.getFullYear()}${String(st.id).padStart(4, '0')}`, + dateStr, + verbEnding: gender === 'm' ? 'обучался' : 'обучалась', + verbEnding2: gender === 'm' ? 'получил' : 'получила', + allSubjects + }; + }); + + res.render('batch-certificate', { type, students: studentData }); +}); + +module.exports = router; diff --git a/views/batch-certificate.ejs b/views/batch-certificate.ejs new file mode 100644 index 0000000..7638b6a --- /dev/null +++ b/views/batch-certificate.ejs @@ -0,0 +1,172 @@ + + + + + Справки — <%= type === 'gia' ? 'ГИА' : 'ГВЭ' %> (пакет) + + + +
+ + <%= students.length %> справок +
+ + <% students.forEach((data) => { %> +
+
+
ДЕПАРТАМЕНТ ОБРАЗОВАНИЯ АДМИНИСТРАЦИИ ГОРОДА ЕКАТЕРИНБУРГА
+
УПРАВЛЕНИЕ ОБРАЗОВАНИЯ АКАДЕМИЧЕСКОГО РАЙОНА
+
+ МУНИЦИПАЛЬНОЕ АВТОНОМНОЕ ОБЩЕОБРАЗОВАТЕЛЬНОЕ УЧРЕЖДЕНИЕ —
+ СРЕДНЯЯ ОБЩЕОБРАЗОВАТЕЛЬНАЯ ШКОЛА № 25 им. В.Г. ФЕОФАНОВА +
+
ул. Цветоносная, 2, тел. 334-32-26
+
+ +
+ СПРАВКА № <%= data.certNumber %>
+ о результатах государственной итоговой аттестации +
+ +
+ <% if (type === 'gia') { %> + в форме основного государственного экзамена + <% } else { %> + в форме государственного выпускного экзамена + <% } %> +
+ +
Дата выдачи <%= data.dateStr %>
+ +
+ Настоящая справка выдана <%= data.student.fioDative %> + о том, что он(а) <%= data.verbEnding %> + в Муниципальном автономном общеобразовательном учреждении — средней общеобразовательной + школе № 25 имени В.Г. Феофанова по основной образовательной программе основного общего + образования и по результатам государственной итоговой аттестации + <%= data.verbEnding2 %> в 2025/2026 учебном году по учебным предметам следующие отметки + <% if (type === 'gia') { %>(количество первичных баллов)<% } %>: +
+ + + + + + + <% if (type === 'gia') { %> + + <% } %> + + + + + <% data.allSubjects.forEach((s, i) => { %> + + + + <% if (type === 'gia') { %> + + <% } %> + + + <% }) %> + +
Наименование учебных предметовКоличество первичных балловОтметка по пятибалльной шкале
<%= i + 1 %><%= s.subject_name %><%= s.primary_score !== null ? s.primary_score : '—' %><%= s.grade !== null ? s.grade + ' (' + gradeText(s.grade) + ')' : '—' %>
+ +
+
Директор
+
О.А. Добычина
+
+
+ <% }) %> + + <% + function gradeText(g) { + const map = { 5: 'отлично', 4: 'хорошо', 3: 'удовлетворительно', 2: 'неудовлетворительно' }; + return map[g] || ''; + } + %> + + + + diff --git a/views/certificate.ejs b/views/certificate.ejs new file mode 100644 index 0000000..ac078ec --- /dev/null +++ b/views/certificate.ejs @@ -0,0 +1,159 @@ + + + + + Справка №<%= certNumber %> + + + +
+ +
+ +
+
ДЕПАРТАМЕНТ ОБРАЗОВАНИЯ АДМИНИСТРАЦИИ ГОРОДА ЕКАТЕРИНБУРГА
+
УПРАВЛЕНИЕ ОБРАЗОВАНИЯ АКАДЕМИЧЕСКОГО РАЙОНА
+
+ МУНИЦИПАЛЬНОЕ АВТОНОМНОЕ ОБЩЕОБРАЗОВАТЕЛЬНОЕ УЧРЕЖДЕНИЕ —
+ СРЕДНЯЯ ОБЩЕОБРАЗОВАТЕЛЬНАЯ ШКОЛА № 25 им. В.Г. ФЕОФАНОВА +
+
ул. Цветоносная, 2, тел. 334-32-26
+
+ +
+ СПРАВКА № <%= certNumber %>
+ о результатах государственной итоговой аттестации +
+ +
+ <% if (type === 'gia') { %> + в форме основного государственного экзамена + <% } else { %> + в форме государственного выпускного экзамена + <% } %> +
+ +
Дата выдачи <%= dateStr %>
+ +
+ Настоящая справка выдана <%= student.fioDative %> + о том, что он(а) <%= verbEnding %> + в Муниципальном автономном общеобразовательном учреждении — средней общеобразовательной + школе № 25 имени В.Г. Феофанова по основной образовательной программе основного общего + образования и по результатам государственной итоговой аттестации + <%= verbEnding2 %> в 2025/2026 учебном году по учебным предметам следующие отметки + <% if (type === 'gia') { %>(количество первичных баллов)<% } %>: +
+ + + + + + + <% if (type === 'gia') { %> + + <% } %> + + + + + <% allSubjects.forEach((s, i) => { %> + + + + <% if (type === 'gia') { %> + + <% } %> + + + <% }) %> + +
Наименование учебных предметовКоличество первичных балловОтметка по пятибалльной шкале
<%= i + 1 %><%= s.subject_name %><%= s.primary_score !== null ? s.primary_score : '—' %><%= s.grade !== null ? s.grade + ' (' + gradeText(s.grade) + ')' : '—' %>
+ +
+
Директор
+
О.А. Добычина
+
+ + <% + function gradeText(g) { + const map = { 5: 'отлично', 4: 'хорошо', 3: 'удовлетворительно', 2: 'неудовлетворительно' }; + return map[g] || ''; + } + %> + + + diff --git a/views/import.ejs b/views/import.ejs new file mode 100644 index 0000000..5fb560d --- /dev/null +++ b/views/import.ejs @@ -0,0 +1,85 @@ + + + + + + Импорт XLS — Справки + + + +
+ + +

Импорт данных из XLS

+ +
+
+
+ +
+ +
+
+
+ +
+

Инструкция

+
    +
  1. Выберите XLS-файл с протоколом ГИА-9
  2. +
  3. Нажмите «Загрузить и распарсить»
  4. +
  5. Данные сохранятся в базу
  6. +
  7. Затем перейдите в карточку ученика и введите баллы по русскому и математике
  8. +
  9. Сформируйте справку (ГИА или ГВЭ)
  10. +
+
+
+ + + + diff --git a/views/index.ejs b/views/index.ejs new file mode 100644 index 0000000..b93e04c --- /dev/null +++ b/views/index.ejs @@ -0,0 +1,173 @@ + + + + + + Ученики — Справки ГИА/ГВЭ + + + +
+

Ученики

+ + + +
+

Пакетная печать

+
+ + + + + +
+
+ + + +
+
+ + + + + + + + + + + + + + + + +
ФамилияИмяОтчествоКлассПредметовДействия
+ +
+ +
+
+ + + + + + diff --git a/views/login.ejs b/views/login.ejs new file mode 100644 index 0000000..a381669 --- /dev/null +++ b/views/login.ejs @@ -0,0 +1,145 @@ + + + + + + Вход — Справки ГИА/ГВЭ + + + +
+

Справки ГИА / ГВЭ

+
МАОУ СОШ № 25 им. В.Г. Феофанова
+
+
+ + +
+
+ + +
+ +
+
+
Школа № 25 © 2026
+
+ + + + diff --git a/views/student.ejs b/views/student.ejs new file mode 100644 index 0000000..5f5a331 --- /dev/null +++ b/views/student.ejs @@ -0,0 +1,160 @@ + + + + + + <%= student.last_name %> <%= student.first_name %> — Справки + + + +
+ + +

<%= student.last_name %> <%= student.first_name %> <%= student.patronymic %>

+

Класс: <%= student.class %>

+ +
+

Обязательные предметы

+
+
+ + + +
+
+ + + +
+ + +
+
+ + <% if (electiveResults.length > 0) { %> +
+

Предметы по выбору (из протокола)

+ + + + + + + + + + <% electiveResults.forEach(r => { %> + + + + + + <% }) %> + +
ПредметПервичный баллОценка
<%= r.subject_name %><%= r.primary_score !== null ? r.primary_score : '—' %><%= r.grade !== null ? r.grade : '—' %>
+
+ <% } %> + +
+

Сформировать справку

+
+
+ +
+
+ +
+ +
+

Выберите 1–2 предмета для справки ГИА:

+ <% electiveResults.forEach(r => { %> + + <% }) %> + +
+ + +
+
+
+ + + +