This commit is contained in:
2026-07-01 13:48:25 +05:00
parent 96230759b7
commit 537d95f295
8 changed files with 148 additions and 35 deletions

View File

@@ -92,6 +92,18 @@ async function initDb() {
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', 'Математика']);
// Миграция: добавить cert_number
try { db.run('ALTER TABLE students ADD COLUMN cert_number TEXT'); } catch (e) {}
try { db.run('CREATE UNIQUE INDEX IF NOT EXISTS idx_students_cert_number ON students(cert_number)'); } catch (e) {}
// Заполнить пустые номера
const year = new Date().getFullYear();
const rows = dbAll("SELECT id, cert_number FROM students WHERE cert_number IS NULL OR cert_number = ''");
for (const r of rows) {
const num = String(year) + String(r.id).padStart(4, '0');
dbRun("UPDATE students SET cert_number = ? WHERE id = ?", [num, r.id]);
}
if (rows.length > 0) console.log(`Миграция cert_number: заполнено ${rows.length} учеников`);
saveDb();
// Миграция: перенос Русского/Математики в обязательные слоты 01/02, если они пусты

View File

@@ -36,7 +36,11 @@ router.post('/import', upload.single('xlsfile'), (req, res) => {
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();
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) {
@@ -212,6 +216,31 @@ router.put('/students/:id/mandatory', (req, res) => {
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));

View File

@@ -52,8 +52,8 @@ router.get('/student/:id', requireAuth, (req, res) => {
groupedHistory.push(currentGroup);
}
currentGroup.changes.push({
subject_name: row.subject_name || row.subject_code,
field_name: row.field_name === 'primary_score' ? 'первичный балл' : 'оценка',
subject_name: row.subject_code === 'CERT' ? 'Номер справки' : (row.subject_name || row.subject_code),
field_name: row.field_name === 'primary_score' ? 'первичный балл' : row.field_name === 'grade' ? 'оценка' : 'номер',
old_value: row.old_value !== null ? row.old_value : '—',
new_value: row.new_value !== null ? row.new_value : '—'
});
@@ -104,10 +104,17 @@ router.get('/certificate/:id', requireAuth, (req, res) => {
}
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 certNumber = student.cert_number || `${today.getFullYear()}${String(id).padStart(4, '0')}`;
const reqDate = req.query.date;
let dateStr;
if (reqDate) {
const [y, m, d] = reqDate.split('-');
dateStr = `«${d.replace(/^0/, '')}» ${months[parseInt(m) - 1]} ${y} года`;
} else {
const today = new Date();
dateStr = `«${String(today.getDate()).padStart(2, '0')}» ${months[today.getMonth()]} ${today.getFullYear()} года`;
}
const gender = detectGender(student.patronymic);
const fioDative = formatNameDative(student, gender);
@@ -209,7 +216,15 @@ router.get('/batch-print', requireAuth, (req, res) => {
const today = new Date();
const months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
const dateStr = `«${String(today.getDate()).padStart(2, '0')}» ${months[today.getMonth()]} ${today.getFullYear()} года`;
const reqDate = req.query.date;
let dateStr;
if (reqDate) {
const [y, m, d] = reqDate.split('-');
dateStr = `«${d.replace(/^0/, '')}» ${months[parseInt(m) - 1]} ${y} года`;
} else {
dateStr = `«${String(today.getDate()).padStart(2, '0')}» ${months[today.getMonth()]} ${today.getFullYear()} года`;
}
const studentData = students.map(st => {
const results = dbAll(`
@@ -246,7 +261,7 @@ router.get('/batch-print', requireAuth, (req, res) => {
return {
student: { ...st, fioDative, gender },
certNumber: `${today.getFullYear()}${String(st.id).padStart(4, '0')}`,
certNumber: st.cert_number || `${today.getFullYear()}${String(st.id).padStart(4, '0')}`,
dateStr,
verbEnding: gender === 'm' ? 'обучался' : 'обучалась',
verbEnding2: gender === 'm' ? 'получил' : 'получила',

View File

@@ -21,14 +21,15 @@
}
.cert-page:last-child { page-break-after: auto; }
.header { text-align: center; margin-bottom: 6mm; }
.header .dept { font-size: 10pt; font-weight: bold; }
.header .dept { font-size: 9pt; font-weight: bold; }
.header .school {
font-size: 11pt;
font-size: 10pt;
font-weight: bold;
text-transform: uppercase;
margin-top: 3mm;
margin-top: 0;
}
.header .address { font-size: 10pt; margin-top: 1mm; }
.header .school-first { white-space: nowrap; }
.header .address { font-size: 9pt; margin-top: 1mm; }
.title {
text-align: center;
font-weight: bold;
@@ -54,11 +55,13 @@
table td:first-child { width: 8mm; }
table td:nth-child(2) { text-align: left; }
.signature {
display: flex;
align-items: flex-end;
margin-top: 10mm;
text-align: right;
}
.signature .dir { margin-bottom: 6mm; }
.signature .name { font-weight: bold; }
.signature .dir { white-space: nowrap; }
.signature .line { flex: 1; border-bottom: 1px solid #000; margin: 0 12px; }
.signature .name { font-weight: bold; white-space: nowrap; }
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
@@ -94,8 +97,10 @@
<div class="header">
<div class="dept">ДЕПАРТАМЕНТ ОБРАЗОВАНИЯ АДМИНИСТРАЦИИ ГОРОДА ЕКАТЕРИНБУРГА</div>
<div class="dept">УПРАВЛЕНИЕ ОБРАЗОВАНИЯ АКАДЕМИЧЕСКОГО РАЙОНА</div>
<div class="school school-first">
МУНИЦИПАЛЬНОЕ АВТОНОМНОЕ ОБЩЕОБРАЗОВАТЕЛЬНОЕ УЧРЕЖДЕНИЕ
</div>
<div class="school">
МУНИЦИПАЛЬНОЕ АВТОНОМНОЕ ОБЩЕОБРАЗОВАТЕЛЬНОЕ УЧРЕЖДЕНИЕ —<br>
СРЕДНЯЯ ОБЩЕОБРАЗОВАТЕЛЬНАЯ ШКОЛА № 25 им. В.Г. ФЕОФАНОВА
</div>
<div class="address">ул. Цветоносная, 2, тел. 334-32-26</div>
@@ -152,8 +157,9 @@
</table>
<div class="signature">
<div class="dir">Директор</div>
<div class="name">О.А. Добычина</div>
<span class="dir">Директор</span>
<span class="line"></span>
<span class="name">О.А. Добычина</span>
</div>
</div>
<% }) %>

View File

@@ -15,14 +15,15 @@
padding: 15mm 20mm;
}
.header { text-align: center; margin-bottom: 10mm; }
.header .dept { font-size: 11pt; font-weight: bold; }
.header .dept { font-size: 10pt; font-weight: bold; }
.header .school {
font-size: 12pt;
font-size: 11pt;
font-weight: bold;
text-transform: uppercase;
margin-top: 4mm;
margin-top: 0;
}
.header .address { font-size: 11pt; margin-top: 2mm; }
.header .school-first { white-space: nowrap; }
.header .address { font-size: 10pt; margin-top: 2mm; }
.title {
text-align: center;
font-weight: bold;
@@ -48,11 +49,13 @@
table td:first-child { width: 10mm; }
table td:nth-child(2) { text-align: left; }
.signature {
display: flex;
align-items: flex-end;
margin-top: 15mm;
text-align: right;
}
.signature .dir { margin-bottom: 8mm; }
.signature .name { font-weight: bold; }
.signature .dir { white-space: nowrap; }
.signature .line { flex: 1; border-bottom: 1px solid #000; margin: 0 15px; }
.signature .name { font-weight: bold; white-space: nowrap; }
@media print {
body { padding: 10mm 15mm; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
@@ -84,8 +87,10 @@
<div class="header">
<div class="dept">ДЕПАРТАМЕНТ ОБРАЗОВАНИЯ АДМИНИСТРАЦИИ ГОРОДА ЕКАТЕРИНБУРГА</div>
<div class="dept">УПРАВЛЕНИЕ ОБРАЗОВАНИЯ АКАДЕМИЧЕСКОГО РАЙОНА</div>
<div class="school school-first">
МУНИЦИПАЛЬНОЕ АВТОНОМНОЕ ОБЩЕОБРАЗОВАТЕЛЬНОЕ УЧРЕЖДЕНИЕ
</div>
<div class="school">
МУНИЦИПАЛЬНОЕ АВТОНОМНОЕ ОБЩЕОБРАЗОВАТЕЛЬНОЕ УЧРЕЖДЕНИЕ —<br>
СРЕДНЯЯ ОБЩЕОБРАЗОВАТЕЛЬНАЯ ШКОЛА № 25 им. В.Г. ФЕОФАНОВА
</div>
<div class="address">ул. Цветоносная, 2, тел. 334-32-26</div>
@@ -142,8 +147,9 @@
</table>
<div class="signature">
<div class="dir">Директор</div>
<div class="name">О.А. Добычина</div>
<span class="dir">Директор</span>
<span class="line"></span>
<span class="name">О.А. Добычина</span>
</div>
<%

View File

@@ -36,8 +36,8 @@
<td style="font-size:12px;white-space:nowrap"><%= r.changed_at %></td>
<td><a href="/student/<%= r.student_id %>"><%= r.last_name %> <%= r.first_name %> <%= r.patronymic %></a></td>
<td><%= r.class %></td>
<td><%= r.subject_name || r.subject_code %></td>
<td><%= r.field_name === 'primary_score' ? 'балл' : 'оценка' %></td>
<td><%= r.subject_code === 'CERT' ? 'Номер справки' : (r.subject_name || r.subject_code) %></td>
<td><%= r.field_name === 'primary_score' ? 'балл' : r.field_name === 'grade' ? 'оценка' : r.field_name === 'cert_number' ? 'номер' : r.field_name %></td>
<td style="color:#999"><%= r.old_value !== null ? r.old_value : '—' %></td>
<td><strong><%= r.new_value !== null ? r.new_value : '—' %></strong></td>
<td style="font-size:12px"><%= r.changed_by || '—' %></td>

View File

@@ -40,6 +40,8 @@
</label>
</div>
<div class="form-row" style="margin-top:8px">
<label>Дата выдачи:</label>
<input type="date" id="batch-date" class="input input-sm">
<button id="batch-print-btn" class="btn btn-blue" style="margin-left:15px">Печать справок</button>
<span style="font-size:12px; color:#888; margin-left:10px">тип определяется автоматически</span>
</div>
@@ -206,14 +208,20 @@
document.getElementById('batch-print-btn').addEventListener('click', () => {
const scope = document.querySelector('input[name="batch-scope"]:checked').value;
let url = '/batch-print';
const sep = url.includes('?') ? '&' : '?';
if (scope === 'class') {
const cls = document.getElementById('class-select').value;
if (!cls) return alert('Выберите класс');
url += `?class=${encodeURIComponent(cls)}`;
url += `${sep}class=${encodeURIComponent(cls)}`;
} else if (scope === 'parallel') {
const par = document.getElementById('parallel-select').value;
if (!par) return alert('Выберите параллель');
url += `?parallel=${encodeURIComponent(par)}`;
url += `${sep}parallel=${encodeURIComponent(par)}`;
}
const date = document.getElementById('batch-date').value;
if (date) {
const sep2 = url.includes('?') ? '&' : '?';
url += `${sep2}date=${date}`;
}
window.open(url, '_blank');
});
@@ -223,6 +231,8 @@
window.location.href = '/login';
});
document.getElementById('batch-date').value = new Date().toISOString().split('T')[0];
loadStudents();
loadClasses();
</script>

View File

@@ -18,6 +18,15 @@
<h1><%= student.last_name %> <%= student.first_name %> <%= student.patronymic %></h1>
<p>Класс: <strong><%= student.class %></strong></p>
<div class="card" style="padding:10px 20px">
<div class="form-row">
<label>Номер справки:</label>
<input type="text" id="cert-number" class="input input-sm" value="<%= student.cert_number || '' %>">
<button id="save-cert-number" class="btn btn-sm">Сохранить номер</button>
<span id="cert-msg" class="msg"></span>
</div>
</div>
<% const hasRus = rusResult && rusResult.grade !== null; const hasMath = mathResult && mathResult.grade !== null; %>
<% if (!hasRus || !hasMath) { %>
<div class="banner-warning">
@@ -98,6 +107,11 @@
<span id="elective-msg" class="msg"></span>
</div>
<div class="form-row" style="margin-top:10px">
<label>Дата выдачи:</label>
<input type="date" id="cert-date" class="input input-sm">
</div>
<button type="submit" class="btn btn-blue" style="margin-top:15px">Сформировать и распечатать</button>
</form>
</div>
@@ -126,7 +140,9 @@
</td>
<td style="font-size:12px">
<% g.changes.forEach((c, ci) => { %>
<div><%= c.subject_name %>: <%= c.field_name %> <span style="color:#999"><%= c.old_value %></span> → <strong><%= c.new_value %></strong></div>
<div>
<% const subjName = c.subject_name === 'CERT' ? 'Номер справки' : c.subject_name; %>
<%= subjName %>: <%= c.field_name %> <span style="color:#999"><%= c.old_value %></span> → <strong><%= c.new_value %></strong></div>
<% }) %>
</td>
</tr>
@@ -156,6 +172,24 @@
window.location.href = '/login';
});
// Set default date to today
document.getElementById('cert-date').value = new Date().toISOString().split('T')[0];
// Save cert number
document.getElementById('save-cert-number').addEventListener('click', async () => {
const num = document.getElementById('cert-number').value.trim();
const msg = document.getElementById('cert-msg');
if (!num) { msg.textContent = 'Введите номер'; msg.className = 'msg msg-err'; return; }
const res = await fetch('/api/students/<%= student.id %>/cert-number', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cert_number: num })
});
const data = await res.json();
if (res.ok) { msg.textContent = 'Сохранено'; msg.className = 'msg msg-ok'; }
else { msg.textContent = data.error || 'Ошибка'; msg.className = 'msg msg-err'; }
});
document.getElementById('mandatory-form').addEventListener('submit', async (e) => {
e.preventDefault();
const score = v => { const n = parseInt(v,10); return isNaN(n)?null:n; };
@@ -198,7 +232,8 @@
return;
}
msg.textContent = '';
const url = `/certificate/<%= student.id %>?type=${type}&subjects=${electives.join(',')}`;
const date = document.getElementById('cert-date').value;
const url = `/certificate/<%= student.id %>?type=${type}&subjects=${electives.join(',')}&date=${date}`;
window.open(url, '_blank');
});
</script>