Архитектура системы управления школой: уроки проектирования ERP для локального рынка
За последние несколько лет я написал M Smart School — систему управления школой — и сейчас внедряю её в реальных условиях: одна пилотная школа, больше 300 учеников. До старта мне казалось, что это «обычный CRUD»: список учеников, журнал оценок, посещаемость. На практике это оказался полноценный ERP — со своим доменом, измерением времени и, главное, пользователями, далёкими от техники. Ниже — архитектурные решения, которые я принял на этом пути, и выводы из них. Без внутренних секретов — только общие инженерные уроки.
Доменная модель: не таблицы, а связи и время
На первый взгляд домен прост: ученик, класс, предмет, расписание, посещаемость, оценка — шесть таблиц. Но настоящая сложность живёт не в самих сущностях, а в связях между ними и в измерении времени:
- Ученик не просто «принадлежит» классу — он учится в этом классе в конкретном учебном году. В следующем году «5-А» становится «6-А», а ученик может перевестись в другую школу.
- Оценка привязана не к паре «ученик + предмет», а к тройке «ученик + предмет + четверть». Оценка без четверти бессмысленна — непонятно, за какой период она выставлена.
- Посещаемость записывается на конкретный урок, а урок — это проекция расписания на конкретную дату. Если расписание меняют, история посещаемости не должна ломаться.
Поэтому таблица оценок с самого начала содержит временной контекст:
CREATE TABLE grades (
id BIGSERIAL PRIMARY KEY,
school_id BIGINT NOT NULL, -- tenant
student_id BIGINT NOT NULL,
subject_id BIGINT NOT NULL,
term_id BIGINT NOT NULL, -- четверть: оценка не живёт без контекста времени
lesson_id BIGINT, -- на каком уроке выставлена (опционально)
value SMALLINT NOT NULL CHECK (value BETWEEN 1 AND 5),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_grades_lookup ON grades (school_id, student_id, term_id);Мой главный урок: не делайте связь «ученик — класс» прямым foreign key. Вместо этого нужна таблица enrollment (зачисление): ученик, класс, учебный год и интервал дат. Тогда ученик, пришедший или ушедший посреди года, перевод из класса в класс — всё выражается без разрушения истории.
Multi-tenant: отдельная база на школу или общая база
В системе, обслуживающей несколько школ, это первое большое решение. У обоих путей своя цена:
- Отдельная база: сильная изоляция, легко делать backup/restore одной школы, минимальный риск утечки данных. Но N школ — это N миграций, N точек мониторинга, растущие расходы на connection'ы и инфраструктуру.
- Общая база + tenantid: одна миграция, дешёвая инфраструктура, статистика по школам получается обычным запросом. Главный риск — один-единственный забытый WHERE schoolid может показать данные чужой школы.
Я выбрал общую базу + tenantid. Логика простая: команда маленькая, школ пока немного, и операционная нагрузка (содержать N баз) на этом этапе дороже, чем выгода от изоляции. Чтобы снизить риск, фильтр по tenant'у превратился из доброй привычки в обязательный слой: все запросы проходят через repository-слой, который вообще не позволяет построить запрос без schoolid. В PostgreSQL как дополнительный барьер можно включить Row Level Security. Важный момент: раз схема одинаковая, то если в будущем крупный клиент потребует отдельную базу, миграция останется реальным и недорогим путём — это решение обратимо.
Роли и права: сохранить RBAC простым
Основных ролей четыре: директор, учитель, родитель, ученик. Главный соблазн здесь — написать универсальный permission engine «на будущее». На практике же хватило словаря роль → права и одной проверки scope:
const PERMISSIONS: Record<Role, string[]> = {
director: ["reports:read", "schedule:write", "users:manage"],
teacher: ["attendance:write", "grades:write", "schedule:read"],
parent: ["grades:read", "attendance:read"],
student: ["grades:read", "schedule:read"],
};
function can(user: User, permission: string): boolean {
return PERMISSIONS[user.role].includes(permission);
}
// Scope проверяется отдельно: родитель видит только своего ребёнка
function inScope(user: User, studentId: number): boolean {
if (user.role === "parent") return user.childIds.includes(studentId);
if (user.role === "student") return user.studentId === studentId;
return true; // директор и учитель видят в рамках своей школы
}Разделение права («что может делать») и scope («над кем») держит код плоским. Новая роль — например, завуч — решается добавлением одной строки в словарь. Per-object ACL, динамические роли и прочие сложности добавляются, когда понадобятся; за время пилота не понадобились.
Локальные условия: нестабильный интернет и низкая техническая грамотность
Эти два факта повлияли на архитектуру сильнее любого технологического выбора:
- Страницы лёгкие: server-side render, минимум JavaScript. Каждая страница обязана открываться даже на медленном мобильном интернете.
- Формы маленькие: отметить посещаемость можно за 2-3 нажатия, длинные формы разбиты на шаги.
- Сохранение подтверждается явно: вместо оптимистичного UI — чёткий сигнал «Сохранено». Это важно, чтобы пользователь доверял системе.
- Если интернет оборвался, введённые данные не теряются: состояние формы сохраняется локально и отправляется повторно при восстановлении связи.
Я много думал о полноценном offline-first PWA, но сознательно не стал его делать: управление конфликтами синхронизации — слишком дорогая сложность для маленькой команды. Простой retry и сохранение черновиков закрыли большую часть проблемы.
Общий принцип: простота важнее количества функций. Каждая новая кнопка — это стоимость обучения учителей и дополнительный поток вопросов в поддержку. Когда просят фичу, мой первый вопрос: «можно ли без неё жить?»
Отчёты: считать в реальном времени или готовить вечером
Руководству нужны агрегаты: процент посещаемости в разрезе классов, динамика успеваемости по четвертям, разрез по предметам. На 300 учениках запросы в реальном времени поверх сырых таблиц работают без проблем — но я всё равно выбрал ежедневные агрегатные таблицы.
Причин две. Первая техническая: отчётные запросы не должны конкурировать в течение дня с основным потоком записи (посещаемость, оценки). Вторая продуктовая: директору совершенно достаточно «состояния на вчера» — в школьной отчётности никто не ждёт секундной точности.
-- Вечерний cron: дневной агрегат посещаемости по классам
INSERT INTO attendance_daily (school_id, class_id, date, present, absent)
SELECT school_id, class_id, date,
COUNT(*) FILTER (WHERE status = 'present'),
COUNT(*) FILTER (WHERE status = 'absent')
FROM attendance
WHERE date = CURRENT_DATE
GROUP BY school_id, class_id, date
ON CONFLICT (school_id, class_id, date) DO UPDATE
SET present = EXCLUDED.present,
absent = EXCLUDED.absent;Бонусный эффект: страницы отчётов, читающие из агрегатных таблиц, открываются очень быстро — на слабом интернете это ценится особенно.
Уроки пилотного внедрения
Мы выкатили систему не сразу в десять школ, а в одну пилотную — и это оказалось самым правильным решением:
- Реальные данные испытывают схему. Ученик, пришедший посреди года, дети с одинаковыми именами и фамилиями, сменившие класс — всё это всплыло в первые недели, и модель enrollment себя оправдала.
- Обучение важнее фич. Даже лучшая функция не существует, если пользователь не может её найти. Живые демонстрации и короткие инструкции заняли не меньше времени, чем написание кода.
- Цикл обратной связи должен быть коротким. Модуль посещаемости мы переделали уже в первую неделю — учителя представляли процесс иначе, чем мы ожидали. Без пилота мы узнали бы это сразу в десяти школах.
- Выделяйте время на внутренние админ-инструменты. Импорт данных, исправление ошибок, перевод ученика — без этого каждая мелкая проблема падает в руки разработчика.
Вывод
Сложная часть школьного ERP — не технология. Сложное — это домен (связи с измерением времени), люди (пользователи, далёкие от техники) и условия (нестабильный интернет). Если архитектура уважает эти три вещи, надёжную систему можно построить и на простом монолите, общей базе, RBAC с четырьмя ролями и вечерних агрегатах. Добавляйте сложность, когда её потребует проблема — а не заранее.