Skip to content

chernykhmark/inventory_bot

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

10 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🏛 Архитектура бота «Ежедневная инвентаризация»

1. Стек и структура проекта

Стек:

  • 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 для шрифтов не нужен — они в образе.


2. Сущности и таблицы БД

Все таблицы — в отдельной схеме Postgres, например inventory_bot. Это требование из твоего ответа по п.2.

2.1. ER-схема (текстом)

users (1) ──< (N) question_sets (1) ──< (N) questions
                       │
                       └──< (N) inventories ──< (N) answers
                                  │
users (1) ──< (1) reminder ───────┘ (логически связан через user_id)

2.2. Таблицы

users — Telegram-пользователи

поле тип описание
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

question_sets — версии наборов вопросов пользователя

поле тип описание
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 — гарантирует, что активный набор только один.

questions — вопросы внутри версии набора

поле тип описание
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).

inventories — конкретные прохождения

поле тип описание
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) — для экрана истории.

answers — ответы на конкретные вопросы конкретной инвентаризации

поле тип описание
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 = '...'.

reminders — настройки ежедневных напоминаний

поле тип описание
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 — не дублируем.

apscheduler_jobs — служебная таблица APScheduler

Создаётся библиотекой в той же схеме (SQLAlchemyJobStore). Хранит cron-задачи напоминаний, переживает рестарт бота.


3. FSM и сценарии диалогов

Использую aiogram.fsm с MemoryStorage (для простоты) либо RedisStorage, если в будущем будет multi-instance. Пока — MemoryStorage, состояния короткоживущие.

3.1. Состояния (states/fsm.py)

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

3.2. Ключевые сценарии (продолжение)

Сценарий D. «Добавить вопрос»

  1. Questions.adding_one — ждём текст (можно несколько строк = несколько вопросов).
  2. Создаём новую версию question_set: копируем старые questions + добавляем новые в конец. Старая версия → is_active=false, новая → is_active=true.
  3. Показываем обновлённый список, возвращаем в экран «Мои вопросы».

Сценарий E. «Удалить вопрос»

  1. Questions.deleting_one — показываем inline-список номеров (по 5 в ряд, например): [1][2][3][4][5] + [⬅️ Назад].
  2. По нажатию: создаём новую версию набора без выбранного вопроса (с пересчётом position). Старая → is_active=false.
  3. Возврат в экран «Мои вопросы» с обновлённым списком.

⚠️ Незавершённые инвентаризации, ссылающиеся на старые версии набора, не ломаются — у них свой question_set_id. Они доживут до своего «Завершить / Начать заново».

Сценарий F. «Начать инвентаризацию»

  1. Проверка: есть ли активный question_set? Если нет — отправляем в «Мои вопросы».
  2. Проверка: есть ли у пользователя inventory со status='in_progress'?
    • Да → показываем экран:
      У вас есть незавершённая инвентаризация от {started_at}.
      [▶️ Продолжить] [🆕 Начать новую]
      
      • «Продолжить» → переход в Inventory.answering с current_position из БД.
      • «Начать новую» → старую помечаем abandoned, создаём новую.
  3. Создание новой инвентаризации:
    • вставляем 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. «Мои инвентаризации»

  1. Запрос: список всех completed инвентаризаций пользователя, отсортированных по started_at DESC, постранично (по 10).
  2. Каждая строка — inline-кнопка с датой и временем: 📄 07.11.2025, 21:14.
  3. Внизу — [⬅️ Назад] [Стр. 1/3 ▶️].
  4. По нажатию на конкретную дату — генерируем PDF на лету и отправляем файлом.

Сценарий J. Настройка напоминаний (доступ из главного меню или /settings — опционально, можно вынести в подменю)

  1. Показываем текущее состояние: «Напоминания: вкл/выкл, время 21:00 (Europe/Moscow)».
  2. Кнопки: [🔔 Включить/Выключить] [🕐 Изменить время] [⬅️ Назад].
  3. «Изменить время» → пресеты 08:00 / 12:00 / 18:00 / 21:00 / ✏️ Своё.
  4. «Своё» → Reminder.entering_custom_time, валидация формата HH:MM.
  5. После сохранения — пересоздаём cron-задачу в APScheduler.

4. Интерфейс и кнопки

4.1. Главное меню (Reply-клавиатура, всегда под рукой)

┌───────────────────────────────┐
│ 📝 Начать инвентаризацию      │
├───────────────────────────────┤
│ 📋 Мои вопросы                │
├───────────────────────────────┤
│ 📚 Мои инвентаризации         │
├───────────────────────────────┤
│ 🔔 Напоминания                │
└───────────────────────────────┘

4 кнопки, в один столбец. Reply-клавиатура не сворачивается, не теряется в истории.

4.2. Inline-наборы (контекстные)

Экран Кнопки
Выбор TZ пресеты RU-зон (по 2 в ряд) + 🌍 Другое
Мои вопросы (есть список) ➕ Добавить ❌ Удалить / ✏️ Заменить всё / ⬅️ Назад
Предпросмотр списка 💾 Сохранить ✖ Отменить
Удаление вопроса номера 1..N (по 5 в ряд) + ⬅️ Назад
Прохождение вопроса ⏭ Пропустить / 🔄 Начать заново
Подтверждение «Начать заново» Да, начать заново / Отмена
Финализация ✅ Завершить / ↩️ Ответить на пропуски / 🔄 Начать заново
Возобновление ▶️ Продолжить / 🆕 Начать новую
История (список дат) 📄 {дата, время} × 10 + пагинация
Напоминания 🔔 Вкл/Выкл / 🕐 Время / ⬅️ Назад
Выбор времени 08:00 12:00 18:00 21:00 / ✏️ Своё

4.3. Принципы работы с сообщениями

  • Карточка вопроса во время прохождения = одно сообщение, редактируется через edit_message_text при переходе к следующему. История чата чистая.
  • Сообщения пользователя с ответами — оставляем как есть (не удаляем). Это твой «дневник по ходу», и пользователь видит, что он писал. Можно добавить удаление позже, если попросишь.
  • Меню «Мои вопросы», «Мои инвентаризации», «Напоминания» — тоже одно сообщение с inline-кнопками, редактируется при переходах внутри раздела.
  • Главное меню (Reply) — отдельный слой, не пересекается с inline.

5. Логика напоминаний

5.1. Технология

  • APScheduler с AsyncIOScheduler и SQLAlchemyJobStore в нашей же схеме inventory_bot.
  • Один job на пользователя, id = f"reminder_{user_id}", тип cron, с указанием timezone пользователя.
  • При старте бота шедулер сам подхватывает все jobs из БД — переживает рестарты.

5.2. Жизненный цикл задачи

  • Включил напоминание → создаём/пересоздаём job (scheduler.add_job(..., replace_existing=True)).
  • Изменил время или TZ → то же самое.
  • Выключилscheduler.remove_job(...).
  • Удалил аккаунт / /start заново — на всякий случай: при /start синхронизируем job с настройками из БД.

5.3. Что делает job

Функция send_daily_reminder(user_id) в scheduler/jobs.py:

  1. Проверяет: есть ли у пользователя сегодня (по его TZ) inventory со status='completed' и completed_at в пределах текущих суток?
    • Да → молча выходим, ничего не отправляем.
  2. Иначе → отправляем сообщение:
    Время для ежедневной инвентаризации 🙏
    [📝 Начать инвентаризацию]
    
    (inline-кнопка дублирует пункт меню, чтобы открыть прямо из уведомления.)

5.4. Часовые пояса

  • Список пресетов: 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).

6. Генерация и хранение PDF

6.1. Хранение

Не храним. Генерируем на лету при каждом запросе (после «Завершить» и при выборе из истории). Источник истины — inventories + answers + questions той версии набора, на которую ссылается инвентаризация.

Плюсы: нет проблем с дисковым местом, бэкап только БД, нет рассинхрона.

6.2. Технология

  • ReportLab + платформонезависимый шрифт DejaVuSans (есть кириллица), регистрируем при старте бота через pdfmetrics.registerFont.
  • Шрифты кладём в assets/fonts/, копируем в образ через Dockerfile.
  • Генерация — синхронная, но запускаем через asyncio.to_thread(...), чтобы не блокировать loop.

6.3. Шаблон документа

┌──────────────────────────────────────────┐
│   Ежедневная инвентаризация              │  ← заголовок, жирный, 16pt
│                                          │
│   Дата: 7 ноября 2025                    │  ← started_at в TZ пользователя
│                                          │
│   ──────────────────────────────────     │
│                                          │
│   Вопрос 1: Счастлив ли я сегодня?       │  ← bold
│   Ответ: Да, вполне.                     │
│                                          │
│   Вопрос 2: …                            │
│   Ответ: —                               │  ← для пропусков
│                                          │
│   …                                      │
│                                          │
│   ──────────────────────────────────     │
│   Инвентаризация завершена.              │
└──────────────────────────────────────────┘
  • Имя файла: inventarizatsiya_{YYYY-MM-DD}_{HH-MM}.pdf (на основе started_at в TZ пользователя).
  • Поля A4, 2 см со всех сторон, шрифт 11pt для текста, 16pt для заголовка.

6.4. Сервис

pdf_service.build_inventory_pdf(inventory_id) -> bytes — возвращает байты, хендлер оборачивает в BufferedInputFile aiogram и отправляет.


7. Приватность и безопасность

  • Все запросы к БД фильтруются по user_id на уровне репозиториев. Нет ни одного метода, который бы возвращал данные «по id без проверки владельца».
  • Нет админ-команд, дающих доступ к чужим данным. Логи — только метаданные (user_id, действие), без содержимого ответов.
  • PDF не сохраняется на диск → не утечёт через volume.
  • Telegram user_id — единственный идентификатор. Не запрашиваем телефон/email.
  • В логи не пишем тексты ответов ни при каких условиях (только длину/факт).

8. План реализации по этапам

Каждый этап — отдельный коммит/блок кода, который я пришлю тебе после согласования архитектуры. Не двигаюсь дальше, пока не подтвердишь предыдущий.

  1. Каркас проекта — структура папок, Dockerfile, docker-compose.yml, requirements.txt, .env.example, config.py, main.py с пустым роутером.
  2. БД и миграцииmodels.py (все 6 таблиц)
  3. Базовые хендлеры/start, /menu, /cancel, /help, главное меню (Reply), регистрация пользователя.
  4. Часовой пояс при первом запускеtimezone.py, FSM, пресеты + ручной ввод.
  5. Мои вопросыquestions.py, парсер списка, версионирование набора, добавить/удалить/заменить.
  6. Инвентаризация — прохождениеinventory.py, FSM, карточка-сообщение с edit_message_text, пропуски, «Начать заново», возобновление.
  7. Финализация + ответ на пропуски — экран финализации, режим повторного прохода.
  8. Генерация PDFpdf_service.py, шрифт, шаблон, отправка после «Завершить».
  9. История инвентаризацийhistory.py, пагинация, повторная генерация PDF из истории.
  10. Напоминания — APScheduler, jobstore, настройка в подменю, проверка «уже прошёл сегодня».
  11. Полировка — обработка ошибок, лимиты (100 вопросов, 4000 символов), не-текст во время прохождения, тексты сообщений.

About

daily inventory

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages