RUEN
Обсудить проект

Инженерный кейс

Раздача заказов с учётом долгов

Заказы наёмным исполнителям: выдача, статусы, спорные случаи и расчёт выплат. Один файл, стандартная библиотека и одна внешняя зависимость.

колонок с балансом
0
внешняя зависимость
1
строк в одном файле
4 266

Управляющий собирает заказ по шагам — исполнитель, товар, количество, данные для выдачи, — исполнитель получает его в личку и отмечает выполнение. Бот начисляет ему сумму, ведёт долг перед каждым, замораживает деньги по спорным заказам и считает выплаты. Четыре роли доступа, два языка интерфейса, при желании — HTTP-контракт для внешней панели.

Долга нет ни в одной колонке — он каждый раз считается заново

В схеме нет поля «баланс» или «долг». Есть одна таблица проводок с четырьмя видами записи: начисление, заморозка, возврат, выплата. Долг — это сумма по ней.

Очевидное решение — держать число у исполнителя и править его на каждом событии — ломается здесь быстрее обычного: у одного заказа сумма меняет знак до трёх раз, плюс выплаты частями. Любой сбой между «поправили число» и «записали историю» даёт две разные правды, и спор с исполнителем разрешать нечем — он видит одну цифру, управляющий другую. При пересчёте расхождение невозможно: история и есть баланс.

Цена размена честная: экраны балансов вызывают пересчёт в цикле по каждому исполнителю, а индексов в схеме нет ни одного. На сотнях заказов незаметно, на десятках тысяч — нет.

Смена статуса и деньги — одна операция, а не две подряд

Любой переход заказа выполняется запросом с условием «изменить, только если статус всё ещё прежний», и проводка вставляется лишь тогда, когда переход действительно состоялся, в той же транзакции.

Кнопку в мессенджере нажимают дважды всегда: палец дрогнул, сеть подвисла, обновление пришло повторно. Код вида «прочитать статус → проверить → записать» оставляет между чтением и записью окно, в которое второй поток успевает начислить деньги ещё раз. Здесь второй клик просто получает «ноль изменённых строк» и уходит в ветку «статус уже изменился».

Сетевая ошибка при отправке — это не «не доставлено»

Отправка возвращает не «да/нет», а причину: не нажимал старт, заблокировал бота, текст слишком длинный, ошибка интерфейса, сеть. Последний случай обрабатывается отдельно: заказ остаётся активным, управляющий видит «доставка не подтверждена».

Если мессенджер не ответил, это не значит, что сообщение не ушло — оно могло уже лежать у исполнителя. Пометить такой заказ недоставленным значит подтолкнуть управляющего к кнопке «Повторить» — и выдать один и тот же логин с паролем двум людям. Поэтому неопределённость показывается как неопределённость, а не приводится к ошибке.

Причина недоставки решает, какие кнопки показать

Под сообщением о неудаче собирается клавиатура, и при причине «текст слишком длинный» кнопки «Повторить» в ней нет — остаются «Другой исполнитель» и «Отмена».

Одна кнопка «Повторить» на все случаи выглядит проще. Но длина текста — свойство самого сообщения, а не сбой канала: повтор даст ту же ошибку, и так бесконечно. Кнопка, которая заведомо не сработает, хуже отсутствующей — человек будет жать её, пока не решит, что бот сломан.

Длина текста считается так, как её считает мессенджер

Лимит проверяется не по числу символов Python, а по числу единиц UTF-16 — так, как меряет сам Telegram. Любая эмодзи у него считается за два знака, у Python за один.

Иначе сообщение проходит проверку при вводе и отвергается при отправке — то есть ошибка всплывает не там, где её можно исправить, а на шаг позже и без понятного объяснения.

Приглашение по имени не выдаёт доступ

Добавить человека можно пересланным сообщением, числовым идентификатором или именем @…. Первые два срабатывают сразу. Третий только создаёт заявку: когда человек с таким именем пишет боту, владелец получает запрос «это точно он?» с числовым идентификатором.

Имя в мессенджере не идентификатор — его можно освободить и занять. Приглашение, выписанное на @ivan_manager, через месяц активирует совсем другой человек и получит доступ к кассе и к чужим логинам. Числовой идентификатор сменить нельзя, поэтому окончательное решение всегда принимается по нему и всегда человеком.

Права проверяются на сервере, а не скрытием кнопок

У модератора урезанное меню, но дело не в нём: на входе стоит белый список разрешённых действий, и неизвестное запрещено по умолчанию. Отдельно проверяются и текстовые режимы ввода.

В мессенджере данные кнопки отправляет клиент, а не сервер. Достаточно один раз увидеть у коллеги сообщение с кнопкой «Выплатить всё» — а он его увидит, уведомления идут всему персоналу — и повторить её содержимое. Скрытие кнопок здесь косметика; решение принимает сервер.

Владельцев из настроек бот не может лишить прав

Роли живут в базе, но владельцы из файла настроек восстанавливаются при каждом запуске, и их роль вообще не читается из базы. Любое действие над ними отбивается — как и попытка сменить роль самому себе.

Без этого получается классическая ловушка: два владельца разжаловали друг друга, и раздел управления доступом недоступен никому — восстановить права можно только правкой базы руками.

Одно действие внешней панели запрещено намеренно

HTTP-контракт переиспользует те же функции, что и бот, но принимает лишь часть переходов. Отметки «выполнено» среди них нет: это действие исполнителя в его мессенджере.

Соблазн выставить наружу все переходы понятен — они уже написаны. Но «выполнил» — единственное событие во всей системе, которое подтверждает исполнитель, и именно оно начисляет деньги. Дай его панели, и подтверждение перестаёт что-либо значить: спор «я этого не делал» станет неразрешимым.

Python 3.12 · pyTelegramBotAPI · SQLite (WAL) · http.server · systemd