Стек:
- Python 3.11+
- aiogram 3.x
- SQLAlchemy 2 (async) + asyncpg
- APScheduler (напоминания, jobstore в Postgres)
- ReportLab (PDF, генерация на лету)
- pydantic-settings (конфиг из
.env) - structlog (логи)
Структура каталогов:
inventory-bot/
├── docker-compose.yml
├── Dockerfile
├── .env.example
├── requirements.txt
├── assets/
│ └── fonts/
│ ├── DejaVuSans.ttf
│ └── DejaVuSans-Bold.ttf
└── app/
├── __init__.py
├── main.py # точка входа: инициализация, polling
├── config.py # настройки из .env
├── db/
│ ├── __init__.py
│ ├── base.py # engine, sessionmaker, Base
│ └── models.py # все SQLAlchemy-модели
├── repositories/ # доступ к БД (один файл на сущность)
│ ├── users.py
│ ├── question_sets.py
│ ├── inventories.py
│ └── reminders.py
├── services/ # бизнес-логика
│ ├── questions_service.py
│ ├── inventory_service.py
│ ├── pdf_service.py
│ └── reminder_service.py
├── handlers/ # хендлеры aiogram, по сценариям
│ ├── __init__.py
│ ├── common.py # /start, /menu, /cancel, /help
│ ├── timezone.py # выбор TZ при первом запуске
│ ├── questions.py # «Мои вопросы» + добавить/удалить/заменить
│ ├── inventory.py # прохождение инвентаризации
│ ├── history.py # «Мои инвентаризации»
│ └── reminder.py # настройка напоминаний
├── keyboards/
│ ├── main_menu.py # reply-клавиатура главного меню
│ └── inline.py # все inline-наборы
├── states/
│ └── fsm.py # все FSM-состояния
├── scheduler/
│ ├── __init__.py
│ └── jobs.py # функции, которые запускает APScheduler
└── utils/
├── parsers.py # парсинг списка вопросов
├── timezones.py # список RU-зон, валидация
└── formatting.py # форматирование текстов
Docker:
docker-compose.yml поднимает только сервис bot (Postgres уже есть на сервере). Подключение — по сети хоста (extra_hosts: host.docker.internal или прямой адрес из .env). Volume для шрифтов не нужен — они в образе.
Все таблицы — в отдельной схеме Postgres, например inventory_bot. Это требование из твоего ответа по п.2.
users (1) ──< (N) question_sets (1) ──< (N) questions
│
└──< (N) inventories ──< (N) answers
│
users (1) ──< (1) reminder ───────┘ (логически связан через user_id)
| поле | тип | описание |
|---|---|---|
id |
BIGINT, PK | = telegram user_id |
username |
TEXT, NULL | для логов |
first_name |
TEXT, NULL | для приветствия |
timezone |
TEXT, NOT NULL | IANA-имя, например Europe/Moscow |
created_at |
TIMESTAMPTZ | |
updated_at |
TIMESTAMPTZ |
| поле | тип | описание |
|---|---|---|
id |
BIGSERIAL, PK | |
user_id |
BIGINT, FK→users.id, ON DELETE CASCADE | |
is_active |
BOOLEAN, NOT NULL, default true |
у пользователя только один активный набор |
created_at |
TIMESTAMPTZ |
Индекс: уникальный частичный UNIQUE (user_id) WHERE is_active = true — гарантирует, что активный набор только один.
| поле | тип | описание |
|---|---|---|
id |
BIGSERIAL, PK | |
question_set_id |
BIGINT, FK→question_sets.id, ON DELETE CASCADE | |
position |
INT, NOT NULL | порядковый номер в наборе (1..N) |
text |
TEXT, NOT NULL |
Индекс: UNIQUE (question_set_id, position).
| поле | тип | описание |
|---|---|---|
id |
BIGSERIAL, PK | |
user_id |
BIGINT, FK→users.id, ON DELETE CASCADE | |
question_set_id |
BIGINT, FK→question_sets.id | версия вопросов, по которой проходили |
status |
TEXT, NOT NULL | in_progress / completed / abandoned |
current_position |
INT, NOT NULL, default 1 |
следующий вопрос для отображения |
started_at |
TIMESTAMPTZ, NOT NULL | дата/время первого показа первого вопроса; обновляется при «Начать заново» |
completed_at |
TIMESTAMPTZ, NULL | момент нажатия «Завершить» |
last_activity_at |
TIMESTAMPTZ, NOT NULL | для аналитики/восстановления |
Индексы:
(user_id, status)— быстро находить незавершённую;(user_id, started_at DESC)— для экрана истории.
| поле | тип | описание |
|---|---|---|
id |
BIGSERIAL, PK | |
inventory_id |
BIGINT, FK→inventories.id, ON DELETE CASCADE | |
question_id |
BIGINT, FK→questions.id | |
position |
INT, NOT NULL | дубль questions.position — для быстрой сортировки и устойчивости к гипотетическому изменению порядка |
text |
TEXT, NULL | сам ответ; NULL = ещё не отвечено; пустая строка не используем |
is_skipped |
BOOLEAN, NOT NULL, default false |
пропуск (в PDF будет «—») |
answered_at |
TIMESTAMPTZ, NULL |
Индекс: UNIQUE (inventory_id, question_id).
Важно по логике:
- При старте инвентаризации сразу создаём все строки
answersсо снимком позиций — это упрощает «ответить на пропуски» (выборкаWHERE is_skipped = true AND text IS NULL). - «Пропустить» →
is_skipped = true,text = NULL. - При «Ответить на пропуски» и фактическом ответе →
is_skipped = false,text = '...'.
| поле | тип | описание |
|---|---|---|
user_id |
BIGINT, PK, FK→users.id, ON DELETE CASCADE | один-к-одному |
is_enabled |
BOOLEAN, NOT NULL, default false |
|
time_local |
TIME, NOT NULL | локальное время пользователя, например 21:00 |
updated_at |
TIMESTAMPTZ |
Часовой пояс берётся из users.timezone — не дублируем.
Создаётся библиотекой в той же схеме (SQLAlchemyJobStore). Хранит cron-задачи напоминаний, переживает рестарт бота.
Использую aiogram.fsm с MemoryStorage (для простоты) либо RedisStorage, если в будущем будет multi-instance. Пока — MemoryStorage, состояния короткоживущие.
TimezoneSetup:
choosing_tz # выбор из пресетов
entering_custom_tz # ручной ввод IANA-имени
Questions:
entering_list # «Заменить весь список» — ждём сообщение со списком
confirming_list # показали предпросмотр, ждём «Сохранить / Отменить»
adding_one # «Добавить вопрос» — ждём текст(ы)
deleting_one # «Удалить вопрос» — ждём выбора номера (inline)
Inventory:
answering # идёт прохождение; в data: inventory_id, current_position
finalizing # дошли до конца, показан экран «Завершить / Ответить на пропуски / Начать заново»
answering_skipped # режим повторного прохода по пропускам
Reminder:
choosing_time # выбор пресета времени или «своё»
entering_custom_time # ручной ввод HH:MM
Сценарий D. «Добавить вопрос»
Questions.adding_one— ждём текст (можно несколько строк = несколько вопросов).- Создаём новую версию
question_set: копируем старыеquestions+ добавляем новые в конец. Старая версия →is_active=false, новая →is_active=true. - Показываем обновлённый список, возвращаем в экран «Мои вопросы».
Сценарий E. «Удалить вопрос»
Questions.deleting_one— показываем inline-список номеров (по 5 в ряд, например):[1][2][3][4][5]+[⬅️ Назад].- По нажатию: создаём новую версию набора без выбранного вопроса (с пересчётом
position). Старая →is_active=false. - Возврат в экран «Мои вопросы» с обновлённым списком.
⚠️ Незавершённые инвентаризации, ссылающиеся на старые версии набора, не ломаются — у них свойquestion_set_id. Они доживут до своего «Завершить / Начать заново».
Сценарий F. «Начать инвентаризацию»
- Проверка: есть ли активный
question_set? Если нет — отправляем в «Мои вопросы». - Проверка: есть ли у пользователя
inventoryсоstatus='in_progress'?- Да → показываем экран:
У вас есть незавершённая инвентаризация от {started_at}. [▶️ Продолжить] [🆕 Начать новую]- «Продолжить» → переход в
Inventory.answeringсcurrent_positionиз БД. - «Начать новую» → старую помечаем
abandoned, создаём новую.
- «Продолжить» → переход в
- Да → показываем экран:
- Создание новой инвентаризации:
- вставляем
inventoryсоstarted_at = now(),status='in_progress',current_position=1; - сразу создаём пустые строки
answersдля всех вопросов активного набора; - переходим в
Inventory.answering, показываем первый вопрос.
- вставляем
Сценарий G. Прохождение (Inventory.answering)
Каждый вопрос — одно сообщение, которое редактируется (edit_message_text) при переходе к следующему. Это ключевое UX-решение: чат не засоряется, виден прогресс.
Формат сообщения:
Вопрос 3 из 40
Чистая ли я сегодня?
[ ⏭ Пропустить ]
[ 🔄 Начать заново ]
Поведение:
- Пользователь пишет текстом → сохраняем
answers.text,is_skipped=false,answered_at=now(). Удаляем сообщение пользователя (опционально, по согласованию — см. ниже вопрос). Редактируем «карточку вопроса» на следующий вопрос.current_position += 1. - Кнопка «Пропустить» →
is_skipped=true,text=NULL. То же самое: следующий вопрос. - Кнопка «Начать заново» → подтверждение
[Да, начать заново] [Отмена]. При «Да»:- все
answersтекущей инвентаризации обнуляются (text=NULL,is_skipped=false,answered_at=NULL); started_at = now(),current_position = 1;- редактируем сообщение на первый вопрос.
- все
- Не-текст (фото/стикер/гс) → отвечаем коротким сообщением «Нужен текст», вопрос не считается отвеченным. (Голос — на будущее.)
Когда current_position > N (ответили/пропустили последний вопрос) → переход в Inventory.finalizing.
Сценарий H. Финализация (Inventory.finalizing)
Редактируем то же сообщение-карточку:
Все вопросы пройдены.
Отвечено: 35 • Пропущено: 5
[ ✅ Завершить ]
[ ↩️ Ответить на пропуски ] ← только если skipped > 0
[ 🔄 Начать заново ]
- «Завершить» →
status='completed',completed_at=now(). Генерируем PDF на лету и отправляем файлом. Возврат в главное меню. - «Ответить на пропуски» → переход в
Inventory.answering_skipped: бот по очереди показывает только вопросы сis_skipped=true AND text IS NULL. Под каждым те же кнопки[⏭ Пропустить] [🔄 Начать заново]. Когда пропуски кончились — сноваInventory.finalizing. - «Начать заново» → как в сценарии G.
Сценарий I. «Мои инвентаризации»
- Запрос: список всех
completedинвентаризаций пользователя, отсортированных поstarted_at DESC, постранично (по 10). - Каждая строка — inline-кнопка с датой и временем:
📄 07.11.2025, 21:14. - Внизу —
[⬅️ Назад] [Стр. 1/3 ▶️]. - По нажатию на конкретную дату — генерируем PDF на лету и отправляем файлом.
Сценарий J. Настройка напоминаний (доступ из главного меню или /settings — опционально, можно вынести в подменю)
- Показываем текущее состояние: «Напоминания: вкл/выкл, время 21:00 (Europe/Moscow)».
- Кнопки:
[🔔 Включить/Выключить] [🕐 Изменить время] [⬅️ Назад]. - «Изменить время» → пресеты
08:00 / 12:00 / 18:00 / 21:00 / ✏️ Своё. - «Своё» →
Reminder.entering_custom_time, валидация форматаHH:MM. - После сохранения — пересоздаём cron-задачу в APScheduler.
┌───────────────────────────────┐
│ 📝 Начать инвентаризацию │
├───────────────────────────────┤
│ 📋 Мои вопросы │
├───────────────────────────────┤
│ 📚 Мои инвентаризации │
├───────────────────────────────┤
│ 🔔 Напоминания │
└───────────────────────────────┘
4 кнопки, в один столбец. Reply-клавиатура не сворачивается, не теряется в истории.
| Экран | Кнопки |
|---|---|
| Выбор TZ | пресеты RU-зон (по 2 в ряд) + 🌍 Другое |
| Мои вопросы (есть список) | ➕ Добавить ❌ Удалить / ✏️ Заменить всё / ⬅️ Назад |
| Предпросмотр списка | 💾 Сохранить ✖ Отменить |
| Удаление вопроса | номера 1..N (по 5 в ряд) + ⬅️ Назад |
| Прохождение вопроса | ⏭ Пропустить / 🔄 Начать заново |
| Подтверждение «Начать заново» | Да, начать заново / Отмена |
| Финализация | ✅ Завершить / ↩️ Ответить на пропуски / 🔄 Начать заново |
| Возобновление | ▶️ Продолжить / 🆕 Начать новую |
| История (список дат) | 📄 {дата, время} × 10 + пагинация |
| Напоминания | 🔔 Вкл/Выкл / 🕐 Время / ⬅️ Назад |
| Выбор времени | 08:00 12:00 18:00 21:00 / ✏️ Своё |
- Карточка вопроса во время прохождения = одно сообщение, редактируется через
edit_message_textпри переходе к следующему. История чата чистая. - Сообщения пользователя с ответами — оставляем как есть (не удаляем). Это твой «дневник по ходу», и пользователь видит, что он писал. Можно добавить удаление позже, если попросишь.
- Меню «Мои вопросы», «Мои инвентаризации», «Напоминания» — тоже одно сообщение с inline-кнопками, редактируется при переходах внутри раздела.
- Главное меню (Reply) — отдельный слой, не пересекается с inline.
- APScheduler с
AsyncIOSchedulerиSQLAlchemyJobStoreв нашей же схемеinventory_bot. - Один job на пользователя,
id = f"reminder_{user_id}", типcron, с указаниемtimezoneпользователя. - При старте бота шедулер сам подхватывает все jobs из БД — переживает рестарты.
- Включил напоминание → создаём/пересоздаём job (
scheduler.add_job(..., replace_existing=True)). - Изменил время или TZ → то же самое.
- Выключил →
scheduler.remove_job(...). - Удалил аккаунт /
/startзаново — на всякий случай: при/startсинхронизируем job с настройками из БД.
Функция send_daily_reminder(user_id) в scheduler/jobs.py:
- Проверяет: есть ли у пользователя сегодня (по его TZ)
inventoryсоstatus='completed'иcompleted_atв пределах текущих суток?- Да → молча выходим, ничего не отправляем.
- Иначе → отправляем сообщение:
(inline-кнопка дублирует пункт меню, чтобы открыть прямо из уведомления.)
Время для ежедневной инвентаризации 🙏 [📝 Начать инвентаризацию]
- Список пресетов:
Europe/Kaliningrad,Europe/Moscow,Europe/Samara,Asia/Yekaterinburg,Asia/Omsk,Asia/Krasnoyarsk,Asia/Irkutsk,Asia/Yakutsk,Asia/Vladivostok,Asia/Magadan,Asia/Kamchatka+🌍 Другое. - «Другое» — ручной ввод IANA-имени, валидация через
zoneinfo.ZoneInfo(...)(try/except).
Не храним. Генерируем на лету при каждом запросе (после «Завершить» и при выборе из истории). Источник истины — inventories + answers + questions той версии набора, на которую ссылается инвентаризация.
Плюсы: нет проблем с дисковым местом, бэкап только БД, нет рассинхрона.
- ReportLab + платформонезависимый шрифт
DejaVuSans(есть кириллица), регистрируем при старте бота черезpdfmetrics.registerFont. - Шрифты кладём в
assets/fonts/, копируем в образ черезDockerfile. - Генерация — синхронная, но запускаем через
asyncio.to_thread(...), чтобы не блокировать loop.
┌──────────────────────────────────────────┐
│ Ежедневная инвентаризация │ ← заголовок, жирный, 16pt
│ │
│ Дата: 7 ноября 2025 │ ← started_at в TZ пользователя
│ │
│ ────────────────────────────────── │
│ │
│ Вопрос 1: Счастлив ли я сегодня? │ ← bold
│ Ответ: Да, вполне. │
│ │
│ Вопрос 2: … │
│ Ответ: — │ ← для пропусков
│ │
│ … │
│ │
│ ────────────────────────────────── │
│ Инвентаризация завершена. │
└──────────────────────────────────────────┘
- Имя файла:
inventarizatsiya_{YYYY-MM-DD}_{HH-MM}.pdf(на основеstarted_atв TZ пользователя). - Поля A4, 2 см со всех сторон, шрифт 11pt для текста, 16pt для заголовка.
pdf_service.build_inventory_pdf(inventory_id) -> bytes — возвращает байты, хендлер оборачивает в BufferedInputFile aiogram и отправляет.
- Все запросы к БД фильтруются по
user_idна уровне репозиториев. Нет ни одного метода, который бы возвращал данные «по id без проверки владельца». - Нет админ-команд, дающих доступ к чужим данным. Логи — только метаданные (user_id, действие), без содержимого ответов.
- PDF не сохраняется на диск → не утечёт через volume.
- Telegram user_id — единственный идентификатор. Не запрашиваем телефон/email.
- В логи не пишем тексты ответов ни при каких условиях (только длину/факт).
Каждый этап — отдельный коммит/блок кода, который я пришлю тебе после согласования архитектуры. Не двигаюсь дальше, пока не подтвердишь предыдущий.
- Каркас проекта — структура папок,
Dockerfile,docker-compose.yml,requirements.txt,.env.example,config.py,main.pyс пустым роутером. - БД и миграции —
models.py(все 6 таблиц) - Базовые хендлеры —
/start,/menu,/cancel,/help, главное меню (Reply), регистрация пользователя. - Часовой пояс при первом запуске —
timezone.py, FSM, пресеты + ручной ввод. - Мои вопросы —
questions.py, парсер списка, версионирование набора, добавить/удалить/заменить. - Инвентаризация — прохождение —
inventory.py, FSM, карточка-сообщение сedit_message_text, пропуски, «Начать заново», возобновление. - Финализация + ответ на пропуски — экран финализации, режим повторного прохода.
- Генерация PDF —
pdf_service.py, шрифт, шаблон, отправка после «Завершить». - История инвентаризаций —
history.py, пагинация, повторная генерация PDF из истории. - Напоминания — APScheduler, jobstore, настройка в подменю, проверка «уже прошёл сегодня».
- Полировка — обработка ошибок, лимиты (100 вопросов, 4000 символов), не-текст во время прохождения, тексты сообщений.