§ 01О чём проектWhat this is
Контейнерный оператор имеет три терминала в Ташкенте и Сергелях. На каждом сидит диспетчер с ноутбуком, на котором — расшаренный по Dropbox контейнеры.xlsx. Когда заезжает контейнер, диспетчер открывает файл, вписывает строку. Когда выезжает — вписывает дату выезда. Раз в месяц бухгалтерия считает биллинг ручкой.
The container operator runs three terminals in Tashkent and Sergeli. Each one has a dispatcher with a laptop sharing containers.xlsx via Dropbox. When a container arrives, the dispatcher opens the file and adds a row. When it leaves — enters the departure date. Once a month, accounting calculates billing by hand.
Проблемы Excel-файла: два диспетчера правят одновременно — потеря строк. Кто-то опечатывается в номере контейнера — потеря денег. Биллинг считается раз в месяц — клиенты спорят с цифрами через полтора месяца. Excel file problems: two dispatchers editing simultaneously — lost rows. Someone mistypes a container number — lost revenue. Billing is calculated once a month — clients dispute figures six weeks later.
Задача: заменить картину на что-то, что диспетчер сможет использовать с телефона, без установки приложений и без обучения. Telegram — очевидный выбор, потому что все уже в нём сидят. Goal: replace this setup with something a dispatcher can use from a phone, no app installs, no training needed. Telegram was the obvious choice — everyone already uses it.
§ 02Как устроеноHow it's built
FSM-первый дизайн. Каждая операция (приём, выдача, перемещение, корректировка) — короткий стейт-машинный сценарий из 3 – 5 шагов. Aiogram 3 даёт FSM из коробки, и это идеально ложится на Telegram-UX, где каждое сообщение — шаг. FSM-first design. Each operation (check-in, release, transfer, correction) is a short state-machine scenario of 3–5 steps. Aiogram 3 provides FSM out of the box, and it maps perfectly to Telegram UX where each message is a step.
┌──────────────────────────────────────────────────────────┐ │ Telegram Update │ │ │ │ │ ▼ │ │ ┌─────────┐ invalid ┌──────────────────────────┐ │ │ │ Router │ ─────────────▶│ Helpful error + retry │ │ │ └─────────┘ └──────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ FSM │─▶│ Use- │─▶│ Domain │ │ │ │ scene │ │ case │ │ + Rules │ │ │ └─────────┘ └─────────┘ └────┬────┘ │ │ ▲ │ │ │ │ ▼ │ │ │ ┌──────────────────────────────────────┐ │ │ └──────│ Postgres · Outbox · Audit log │ │ │ └──────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────┘
Доменный слой ничего не знает про Telegram. Это дало возможность через полгода добавить web-админку для бухгалтерии без переписи логики — та же FastAPI поверх тех же use-cases, другой UI. The domain layer knows nothing about Telegram. This made it possible to add a web admin panel for accounting six months later without rewriting any logic — the same FastAPI on top of the same use-cases, different UI.
ISO 6346 валидация на этапе вводаISO 6346 validation at input
Номер контейнера — это 4 буквы + 7 цифр, где последняя — check digit по стандарту ISO 6346. Считается через умножение позиций на степени двойки. Самая частая ошибка диспетчера — перепутать B и 8 или 0 и O. Валидация на входе ловит 100% таких опечаток.
A container number is 4 letters + 7 digits, where the last one is a check digit per ISO 6346. It's calculated by multiplying positions by powers of two. The most common dispatcher mistake is confusing B with 8 or 0 with O. Input validation catches 100% of such typos.
def validate_iso6346(number: str) -> ContainerNumber: # MSKU 304221 5 — 4 letters + 6 digits + check norm = number.upper().replace(" ", "") if not _PATTERN.match(norm): raise InvalidContainerNumber(norm, reason="format") owner, serial, check = norm[:4], norm[4:10], norm[10] expected = _check_digit(owner + serial) if int(check) != expected: raise InvalidContainerNumber( norm, reason="check_digit", expected=expected, got=int(check), ) return ContainerNumber(owner=owner, serial=serial)
§ 03Биллинг как доменBilling as a domain
Биллинг — это всегда соблазн посчитать в Excel. Соблазн нужно задушить рано, потому что «один клиент с нестандартным тарифом» превращается в «пятьдесят клиентов с разными правилами», и Excel становится бомбой замедленного действия. Billing is always tempting to calculate in Excel. That temptation must be killed early, because "one client with a custom tariff" turns into "fifty clients with different rules," and Excel becomes a ticking time bomb.
В доменном слое: Tariff, TariffPeriod, Discount, PartialDay-логика. Любая сумма пересчитывается на лету — я нигде не храню готовое число. Хранится Movement с точными timestamp'ами входа и выхода, а сумма — функция от состояния тарифной сетки на момент движения.
In the domain layer: Tariff, TariffPeriod, Discount, PartialDay logic. Every amount is recalculated on the fly — I never store a pre-computed number. What's stored is a Movement with exact entry/exit timestamps, and the amount is a function of the tariff schedule at the time of the movement.
Что это даёт бизнесу: счёт за хранение всегда сходится с реальным движением контейнеров — даже если тариф пересмотрели задним числом. Никаких «в Excel посчитали одно, клиент увидел другое» и споров по оплате. What this means for the business: the storage invoice always matches the real movement of containers — even if a tariff is revised retroactively. No more "Excel said one thing, the client saw another" and billing disputes.
§ 04ЦифрыNumbers
§ 05Что я вынесLessons learned
(а) Telegram — нормальная замена админке для небольших ops-команд. Если все уже в нём сидят, бот окупается в первые две недели. Главное — не строить из него «настоящее приложение» с inline-клавиатурами на десять рядов. (a) Telegram is a viable admin panel replacement for small ops teams. If everyone is already using it, the bot pays for itself in the first two weeks. The key is not to build a "real application" with ten rows of inline keyboards.
(б) FSM в aiogram 3 — идеальное соответствие тому, как человек ведёт диалог в мессенджере. Шаг — сообщение, отмена — /cancel, валидация — сразу.
(b) FSM in aiogram 3 is a perfect match for how people converse in a messenger. Step — message, cancel — /cancel, validation — instant.
(в) Биллинг считать как функцию от состояния, а не как поле в БД. Это спасло от двух историй вида «тариф поменялся неделю назад, давайте пересчитаем». (c) Calculate billing as a function of state, not as a field in the DB. This saved us from two incidents of the "tariff changed a week ago, let's recalculate" variety.