Skip to content

About

Разбор схем БД трёх продакшен-систем: неизменяемые движения склада, начисления и оплаты, составные ключи. ER-диаграммы и принятые решения.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 

Repository files navigation

🗄️ Схемы баз данных продакшен-систем

Складской учёт · CRM учебного центра · внутренняя панель с Telegram

PHP MySQL SQLite Node.js

Таблиц Систем Диаграмм


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

📦 StoreCRM

Складской учёт и продажи 27 таблиц MySQL · мультиарендная

🎓 PayTutor

CRM учебного центра 47 таблиц SQLite · бот + панель

⚙️ Admin

Внутренняя панель 15 таблиц SQLite · MTProto


📦 1. Складской учёт: движения отдельно от остатков

Главное решение всей системы — и оно определяет остальные.

%%{init: {'theme':'base','themeVariables':{'fontFamily':'ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif','fontSize':'15px','textColor':'#1e1b4b','lineColor':'#4338ca','primaryColor':'#e0e7ff','primaryTextColor':'#1e1b4b','primaryBorderColor':'#4338ca','secondaryColor':'#ede9fe','tertiaryColor':'#f5f3ff','mainBkg':'#c7d2fe','nodeBorder':'#4338ca','nodeTextColor':'#1e1b4b','edgeLabelBackground':'#e0e7ff','attributeBackgroundColorOdd':'#eef2ff','attributeBackgroundColorEven':'#e0e7ff','noteBkgColor':'#fef3c7','noteTextColor':'#1a1a1a','noteBorderColor':'#b45309','clusterBkg':'#f5f3ff','clusterBorder':'#4338ca','labelBoxBkgColor':'#e0e7ff','labelBoxBorderColor':'#4338ca','labelTextColor':'#1e1b4b','actorBkg':'#c7d2fe','actorBorder':'#4338ca','actorTextColor':'#1e1b4b','actorLineColor':'#4338ca','signalColor':'#4338ca','signalTextColor':'#1e1b4b','sequenceNumberColor':'#ffffff','activationBkgColor':'#ddd6fe','activationBorderColor':'#4338ca','transitionColor':'#4338ca','transitionLabelColor':'#1e1b4b','stateBkg':'#c7d2fe','stateLabelColor':'#1e1b4b','altBackground':'#f5f3ff','compositeBackground':'#f5f3ff','compositeBorder':'#4338ca','compositeTitleBackground':'#e0e7ff','specialStateColor':'#4338ca','innerEndBackground':'#4338ca'}}}%%
erDiagram
    organizations ||--o{ inventory_documents : "владеет"
    inventory_documents ||--|{ inventory_document_items : "строки"
    inventory_document_items ||--o{ inventory_movements : "порождает"
    inventory_movements }o--|| products : "товар"
    inventory_movements }o--|| warehouses : "склад"
    inventory_balances }o--|| products : "товар"
    inventory_balances }o--|| warehouses : "склад"
    products }o--|| units : "базовая единица"
    products }o--o| product_categories : "категория"
    inventory_documents }o--o| customers : "контрагент"
    inventory_documents }o--o| deals : "сделка"

    inventory_documents {
        bigint id PK
        bigint organization_id FK "мультиарендность"
        bigint warehouse_id FK
        bigint store_id FK
        bigint customer_id FK
        bigint deal_id FK
        bigint created_by_user_id FK
        bigint approved_by_user_id FK "кто провёл"
        varchar document_type "приход, отгрузка, списание, перемещение"
        varchar status "draft, posted"
        varchar document_no "UNIQUE с organization_id"
        date document_date
        varchar reference_no
        timestamp posted_at "момент проведения"
        json metadata_json
        timestamp deleted_at "мягкое удаление"
    }

    inventory_movements {
        bigint id PK
        bigint organization_id FK
        bigint warehouse_id FK
        bigint product_id FK
        bigint document_id FK
        bigint document_item_id FK
        varchar movement_type
        decimal quantity_delta "со знаком"
        decimal quantity_before "остаток ДО"
        decimal quantity_after "остаток ПОСЛЕ"
        decimal unit_cost
        timestamp occurred_at
    }

    inventory_balances {
        bigint id PK
        bigint organization_id FK
        bigint warehouse_id FK
        bigint product_id FK
        decimal qty_on_hand "физически на складе"
        decimal qty_reserved "отложено под заказы"
        decimal avg_cost "средневзвешенная"
        timestamp last_movement_at
    }

    products {
        bigint id PK
        bigint organization_id FK
        bigint category_id FK
        bigint base_unit_id FK
        varchar sku
        varchar barcode
        varchar name
        varchar product_type "stocked, service"
        varchar status
        varchar brand
        boolean track_stock
        boolean allow_negative_stock
        varchar cost_method "weighted_average, fifo"
        json metadata_json
    }
Loading

🔑 Почему движения неизменяемы

%%{init: {'theme':'base','themeVariables':{'fontFamily':'ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif','fontSize':'15px','textColor':'#1e1b4b','lineColor':'#4338ca','primaryColor':'#e0e7ff','primaryTextColor':'#1e1b4b','primaryBorderColor':'#4338ca','secondaryColor':'#ede9fe','tertiaryColor':'#f5f3ff','mainBkg':'#c7d2fe','nodeBorder':'#4338ca','nodeTextColor':'#1e1b4b','edgeLabelBackground':'#e0e7ff','attributeBackgroundColorOdd':'#eef2ff','attributeBackgroundColorEven':'#e0e7ff','noteBkgColor':'#fef3c7','noteTextColor':'#1a1a1a','noteBorderColor':'#b45309','clusterBkg':'#f5f3ff','clusterBorder':'#4338ca','labelBoxBkgColor':'#e0e7ff','labelBoxBorderColor':'#4338ca','labelTextColor':'#1e1b4b','actorBkg':'#c7d2fe','actorBorder':'#4338ca','actorTextColor':'#1e1b4b','actorLineColor':'#4338ca','signalColor':'#4338ca','signalTextColor':'#1e1b4b','sequenceNumberColor':'#ffffff','activationBkgColor':'#ddd6fe','activationBorderColor':'#4338ca','transitionColor':'#4338ca','transitionLabelColor':'#1e1b4b','stateBkg':'#c7d2fe','stateLabelColor':'#1e1b4b','altBackground':'#f5f3ff','compositeBackground':'#f5f3ff','compositeBorder':'#4338ca','compositeTitleBackground':'#e0e7ff','specialStateColor':'#4338ca','innerEndBackground':'#4338ca'}}}%%
flowchart LR
    N["❌ Наивно<br/>одно поле quantity"] --> Q{"Вопрос владельца:<br/>почему было 40,<br/>а стало 12?"}
    Q -->|нечем ответить| F["🔥 прошлое затёрто"]

    D(["📄 Документ<br/>черновик"]) -->|проведение| M[["📝 Движения<br/>только INSERT"]]
    M --> B[("📊 Остаток<br/>материализован")]
    M --> H["🔍 Полная история<br/>кто, когда, откуда"]

    classDef bad fill:#ef4444,stroke:#b91c1c,stroke-width:2px,color:#ffffff
    classDef ask fill:#f59e0b,stroke:#b45309,stroke-width:2px,color:#1a1a1a
    classDef good fill:#6366f1,stroke:#4338ca,stroke-width:2px,color:#ffffff
    classDef data fill:#22c55e,stroke:#15803d,stroke-width:2px,color:#052e16

    class N,F bad
    class Q ask
    class D,M good
    class B,H data
Loading
Наивная схема Движения + остаток
Ответ «почему стало 12» ❌ невозможен ✅ строка движения
Остаток на прошлый месяц ❌ потерян ✅ восстанавливается
Резерв под заказ ❌ не выражается ✅ отдельное поле
Себестоимость ❌ нет данных ✅ unit_cost в движении
Скорость чтения остатка ✅ мгновенно ✅ мгновенно, из balances
Риск рассинхрона — ⚠️ есть, лечится транзакцией и сверкой

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

🧾 Статус документа — граница ответственности

%%{init: {'theme':'base','themeVariables':{'fontFamily':'ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif','fontSize':'15px','textColor':'#1e1b4b','lineColor':'#4338ca','primaryColor':'#e0e7ff','primaryTextColor':'#1e1b4b','primaryBorderColor':'#4338ca','secondaryColor':'#ede9fe','tertiaryColor':'#f5f3ff','mainBkg':'#c7d2fe','nodeBorder':'#4338ca','nodeTextColor':'#1e1b4b','edgeLabelBackground':'#e0e7ff','attributeBackgroundColorOdd':'#eef2ff','attributeBackgroundColorEven':'#e0e7ff','noteBkgColor':'#fef3c7','noteTextColor':'#1a1a1a','noteBorderColor':'#b45309','clusterBkg':'#f5f3ff','clusterBorder':'#4338ca','labelBoxBkgColor':'#e0e7ff','labelBoxBorderColor':'#4338ca','labelTextColor':'#1e1b4b','actorBkg':'#c7d2fe','actorBorder':'#4338ca','actorTextColor':'#1e1b4b','actorLineColor':'#4338ca','signalColor':'#4338ca','signalTextColor':'#1e1b4b','sequenceNumberColor':'#ffffff','activationBkgColor':'#ddd6fe','activationBorderColor':'#4338ca','transitionColor':'#4338ca','transitionLabelColor':'#1e1b4b','stateBkg':'#c7d2fe','stateLabelColor':'#1e1b4b','altBackground':'#f5f3ff','compositeBackground':'#f5f3ff','compositeBorder':'#4338ca','compositeTitleBackground':'#e0e7ff','specialStateColor':'#4338ca','innerEndBackground':'#4338ca'}}}%%
stateDiagram-v2
    direction LR
    [*] --> draft
    draft --> posted: проведение
    posted --> [*]

    note right of draft
        Редактируется свободно.
        Движений нет.
        На остаток не влияет.
    end note

    note right of posted
        Движения созданы.
        Документ неизменяем.
        Исправление — сторно.
    end note
Loading

🏢 Мультиарендность закладывается сразу

UNIQUE KEY ux_inventory_documents_org_no (organization_id, document_no)  -- ✅
UNIQUE KEY ux_inventory_documents_no     (document_no)                  -- ❌ навсегда один клиент

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


🤝 2. CRM-контур: сделки, задачи, контрагенты

Тот же продукт, другая половина: работа с клиентом до отгрузки.

%%{init: {'theme':'base','themeVariables':{'fontFamily':'ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif','fontSize':'15px','textColor':'#1e1b4b','lineColor':'#4338ca','primaryColor':'#e0e7ff','primaryTextColor':'#1e1b4b','primaryBorderColor':'#4338ca','secondaryColor':'#ede9fe','tertiaryColor':'#f5f3ff','mainBkg':'#c7d2fe','nodeBorder':'#4338ca','nodeTextColor':'#1e1b4b','edgeLabelBackground':'#e0e7ff','attributeBackgroundColorOdd':'#eef2ff','attributeBackgroundColorEven':'#e0e7ff','noteBkgColor':'#fef3c7','noteTextColor':'#1a1a1a','noteBorderColor':'#b45309','clusterBkg':'#f5f3ff','clusterBorder':'#4338ca','labelBoxBkgColor':'#e0e7ff','labelBoxBorderColor':'#4338ca','labelTextColor':'#1e1b4b','actorBkg':'#c7d2fe','actorBorder':'#4338ca','actorTextColor':'#1e1b4b','actorLineColor':'#4338ca','signalColor':'#4338ca','signalTextColor':'#1e1b4b','sequenceNumberColor':'#ffffff','activationBkgColor':'#ddd6fe','activationBorderColor':'#4338ca','transitionColor':'#4338ca','transitionLabelColor':'#1e1b4b','stateBkg':'#c7d2fe','stateLabelColor':'#1e1b4b','altBackground':'#f5f3ff','compositeBackground':'#f5f3ff','compositeBorder':'#4338ca','compositeTitleBackground':'#e0e7ff','specialStateColor':'#4338ca','innerEndBackground':'#4338ca'}}}%%
erDiagram
    organizations ||--o{ customers : "владеет"
    customers ||--o{ customer_contacts : "контакты"
    customers ||--o{ deals : "сделки"
    deals ||--o{ tasks : "задачи"
    customers ||--o{ tasks : "задачи"
    users ||--o{ deals : "ответственный"
    users ||--o{ user_roles : "роли"
    roles ||--o{ user_roles : ""
    roles ||--o{ role_permissions : ""
    permissions ||--o{ role_permissions : ""
    stores ||--o{ deals : "точка"

    customers {
        bigint id PK
        bigint organization_id FK
        bigint assigned_to_user_id FK
        bigint store_id FK
        varchar external_code "код из чужой системы"
        varchar customer_type "person, company"
        varchar status
        varchar source "manual, telegram, site"
        varchar first_name
        varchar last_name
        varchar company_name
        varchar phone
        varchar email
        varchar tax_number "ИНН"
        date birth_date
        varchar loyalty_level
    }

    deals {
        bigint id PK
        bigint organization_id FK
        bigint customer_id FK
        bigint store_id FK
        bigint owner_user_id FK
        varchar title
        varchar pipeline_code "воронок может быть несколько"
        varchar stage_code
        varchar status "open, won, lost"
        varchar source
        decimal amount
        char currency_code "UZS"
        date expected_close_at
        timestamp closed_at
        json metadata_json
    }

    tasks {
        bigint id PK
        bigint organization_id FK
        bigint deal_id FK
        bigint customer_id FK
        bigint created_by_user_id FK
        bigint assigned_to_user_id FK
        varchar title
        varchar status
        varchar priority
        timestamp due_at
        timestamp completed_at
    }
Loading

Три решения, которые видно в схеме:

  • 🔀 pipeline_code + stage_code вместо одного stage_id — воронок может быть несколько (продажа, возврат, сервис), и этап имеет смысл только внутри своей.
  • 🏷️ external_code отдельно от id — код контрагента из чужой системы при импорте не должен подменять первичный ключ.
  • 💱 currency_code рядом с каждой суммой, а не одна валюта на всю систему. Добавить валюту потом — переписать все денежные расчёты.

🎓 3. CRM учебного центра: 47 таблиц вокруг одного вопроса

Центральный вопрос системы: кто, за что и сколько должен.

%%{init: {'theme':'base','themeVariables':{'fontFamily':'ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif','fontSize':'15px','textColor':'#1e1b4b','lineColor':'#4338ca','primaryColor':'#e0e7ff','primaryTextColor':'#1e1b4b','primaryBorderColor':'#4338ca','secondaryColor':'#ede9fe','tertiaryColor':'#f5f3ff','mainBkg':'#c7d2fe','nodeBorder':'#4338ca','nodeTextColor':'#1e1b4b','edgeLabelBackground':'#e0e7ff','attributeBackgroundColorOdd':'#eef2ff','attributeBackgroundColorEven':'#e0e7ff','noteBkgColor':'#fef3c7','noteTextColor':'#1a1a1a','noteBorderColor':'#b45309','clusterBkg':'#f5f3ff','clusterBorder':'#4338ca','labelBoxBkgColor':'#e0e7ff','labelBoxBorderColor':'#4338ca','labelTextColor':'#1e1b4b','actorBkg':'#c7d2fe','actorBorder':'#4338ca','actorTextColor':'#1e1b4b','actorLineColor':'#4338ca','signalColor':'#4338ca','signalTextColor':'#1e1b4b','sequenceNumberColor':'#ffffff','activationBkgColor':'#ddd6fe','activationBorderColor':'#4338ca','transitionColor':'#4338ca','transitionLabelColor':'#1e1b4b','stateBkg':'#c7d2fe','stateLabelColor':'#1e1b4b','altBackground':'#f5f3ff','compositeBackground':'#f5f3ff','compositeBorder':'#4338ca','compositeTitleBackground':'#e0e7ff','specialStateColor':'#4338ca','innerEndBackground':'#4338ca'}}}%%
erDiagram
    branches ||--o{ groups : "филиал"
    teachers ||--o{ groups : "ведёт"
    subjects ||--o{ groups : "предмет"
    groups ||--o{ enrollments : "зачисления"
    users ||--o{ enrollments : "ученик"
    groups ||--o{ group_lessons : "занятия"
    group_lessons ||--o{ attendance : "посещаемость"
    group_lessons ||--o{ lesson_materials : "материалы"
    enrollments ||--o{ tuition_invoices : "начисления"
    tuition_subscriptions ||--o{ tuition_invoices : "периодичность"
    tuition_invoices ||--o{ tuition_invoice_bundle_items : "состав"
    users ||--o{ payme_transactions : "оплаты"
    users ||--o{ click_transactions : "оплаты"
    exams ||--o{ exam_results : "результаты"
    exam_results ||--o{ certificates : "сертификат"
    users ||--o{ parent_links : "родитель"
    users ||--o{ trial_requests : "пробный урок"
    users ||--o{ call_logs : "звонки"
    attendance ||--o{ attendance_penalties : "штрафы"

    enrollments {
        int id PK
        int user_id FK
        int group_id FK
        string status "trial, active, paused, finished"
        date started_at
        date finished_at
    }

    tuition_invoices {
        int id PK
        int enrollment_id FK
        int subscription_id FK
        int amount
        string period "месяц обучения"
        string status "unpaid, partial, paid"
        date due_date
        date paid_at
    }

    payme_transactions {
        int id PK
        int user_id FK
        string provider_tx_id "UNIQUE - защита от повтора"
        int amount "в тийинах"
        string state "своя машина состояний"
        int create_time
        int perform_time
        int cancel_time
        int reason
    }

    attendance {
        int id PK
        int lesson_id FK
        int user_id FK
        string status "present, absent, late, excused"
        timestamp marked_at
        int marked_by FK
    }
Loading

💰 Начисление ≠ оплата

%%{init: {'theme':'base','themeVariables':{'fontFamily':'ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif','fontSize':'15px','textColor':'#1e1b4b','lineColor':'#4338ca','primaryColor':'#e0e7ff','primaryTextColor':'#1e1b4b','primaryBorderColor':'#4338ca','secondaryColor':'#ede9fe','tertiaryColor':'#f5f3ff','mainBkg':'#c7d2fe','nodeBorder':'#4338ca','nodeTextColor':'#1e1b4b','edgeLabelBackground':'#e0e7ff','attributeBackgroundColorOdd':'#eef2ff','attributeBackgroundColorEven':'#e0e7ff','noteBkgColor':'#fef3c7','noteTextColor':'#1a1a1a','noteBorderColor':'#b45309','clusterBkg':'#f5f3ff','clusterBorder':'#4338ca','labelBoxBkgColor':'#e0e7ff','labelBoxBorderColor':'#4338ca','labelTextColor':'#1e1b4b','actorBkg':'#c7d2fe','actorBorder':'#4338ca','actorTextColor':'#1e1b4b','actorLineColor':'#4338ca','signalColor':'#4338ca','signalTextColor':'#1e1b4b','sequenceNumberColor':'#ffffff','activationBkgColor':'#ddd6fe','activationBorderColor':'#4338ca','transitionColor':'#4338ca','transitionLabelColor':'#1e1b4b','stateBkg':'#c7d2fe','stateLabelColor':'#1e1b4b','altBackground':'#f5f3ff','compositeBackground':'#f5f3ff','compositeBorder':'#4338ca','compositeTitleBackground':'#e0e7ff','specialStateColor':'#4338ca','innerEndBackground':'#4338ca'}}}%%
flowchart TB
    subgraph WRONG["❌ Одно поле «оплачено»"]
        W1["Начисление<br/>+ флаг paid"] --> W2["🚫 Задолженность<br/>посчитать нельзя"]
    end

    subgraph RIGHT["✅ Две сущности"]
        R1["📋 tuition_invoices<br/>сколько должен"]
        R2["💳 transactions<br/>сколько пришло"]
        R1 -.->|"многие ко многим"| R2
        R2 --> R3["📊 Задолженность<br/>= начислено − оплачено"]
    end

    classDef bad fill:#ef4444,stroke:#b91c1c,stroke-width:2px,color:#ffffff
    classDef ok fill:#22c55e,stroke:#15803d,stroke-width:2px,color:#052e16
    classDef neutral fill:#6366f1,stroke:#4338ca,stroke-width:2px,color:#ffffff

    class W1,W2 bad
    class R3 ok
    class R1,R2 neutral
Loading

Один платёж может закрыть два месяца, один месяц — оплачен двумя переводами. Флаг paid этого не выражает, а задолженность — главный отчёт, ради которого систему покупают.

🔁 Почему транзакции провайдеров в разных таблицах

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

Общая таблица с полем provider очень быстро превращается в набор колонок, половина которых всегда NULL. Общее вынесено в интерфейс на уровне кода — см. payment-integration-notes.


⚙️ 4. Внутренняя панель: CRM поверх архива переписки

%%{init: {'theme':'base','themeVariables':{'fontFamily':'ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif','fontSize':'15px','textColor':'#1e1b4b','lineColor':'#4338ca','primaryColor':'#e0e7ff','primaryTextColor':'#1e1b4b','primaryBorderColor':'#4338ca','secondaryColor':'#ede9fe','tertiaryColor':'#f5f3ff','mainBkg':'#c7d2fe','nodeBorder':'#4338ca','nodeTextColor':'#1e1b4b','edgeLabelBackground':'#e0e7ff','attributeBackgroundColorOdd':'#eef2ff','attributeBackgroundColorEven':'#e0e7ff','noteBkgColor':'#fef3c7','noteTextColor':'#1a1a1a','noteBorderColor':'#b45309','clusterBkg':'#f5f3ff','clusterBorder':'#4338ca','labelBoxBkgColor':'#e0e7ff','labelBoxBorderColor':'#4338ca','labelTextColor':'#1e1b4b','actorBkg':'#c7d2fe','actorBorder':'#4338ca','actorTextColor':'#1e1b4b','actorLineColor':'#4338ca','signalColor':'#4338ca','signalTextColor':'#1e1b4b','sequenceNumberColor':'#ffffff','activationBkgColor':'#ddd6fe','activationBorderColor':'#4338ca','transitionColor':'#4338ca','transitionLabelColor':'#1e1b4b','stateBkg':'#c7d2fe','stateLabelColor':'#1e1b4b','altBackground':'#f5f3ff','compositeBackground':'#f5f3ff','compositeBorder':'#4338ca','compositeTitleBackground':'#e0e7ff','specialStateColor':'#4338ca','innerEndBackground':'#4338ca'}}}%%
erDiagram
    tg_accounts ||--o{ tg_chats : "аккаунт"
    tg_chats ||--o{ tg_messages : "сообщения"
    crm_stages ||--o{ crm_deals : "этап"
    crm_contacts ||--o{ crm_deals : "контакт"
    crm_deals ||--o{ crm_activities : "лента"
    tg_chats }o--o| crm_contacts : "привязка по chat_id"

    tg_chats {
        int account_id PK "составной ключ"
        string id PK "уникален только внутри аккаунта"
        string title
        string type "user, group, channel"
        int unread_count
        string last_message_at
        int synced_fully
        string menu_button
    }

    tg_messages {
        int account_id PK
        string chat_id PK
        int id PK
        string sender_name
        int out "исходящее"
        string text
        string media_type
        string date
    }

    crm_deals {
        int id PK
        int contact_id FK
        int stage_id FK
        string product
        int amount
        string next_action_at "NOT NULL для открытых"
        string next_action_note
        string accounting_now "гейт перехода"
        string status "open, won, lost"
        string lost_reason
    }
Loading

🗝️ Составной ключ вместо суррогатного

tg_chats имеет ключ (account_id, id). Идентификатор чата уникален только внутри аккаунта Telegram, а аккаунтов в системе несколько.

⚠️ Изначально таблица была одноаккаунтной с id в качестве ключа. Миграция на составной ключ переписывала обе таблицы целиком — с созданием новых, переносом данных и переключением.

Вывод, который стоит недёшево: если сущность приходит из внешней системы, её идентификатор уникален в пространстве той системы, а не вашей.

🚧 Обязательное поле как защита процесса

%%{init: {'theme':'base','themeVariables':{'fontFamily':'ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif','fontSize':'15px','textColor':'#1e1b4b','lineColor':'#4338ca','primaryColor':'#e0e7ff','primaryTextColor':'#1e1b4b','primaryBorderColor':'#4338ca','secondaryColor':'#ede9fe','tertiaryColor':'#f5f3ff','mainBkg':'#c7d2fe','nodeBorder':'#4338ca','nodeTextColor':'#1e1b4b','edgeLabelBackground':'#e0e7ff','attributeBackgroundColorOdd':'#eef2ff','attributeBackgroundColorEven':'#e0e7ff','noteBkgColor':'#fef3c7','noteTextColor':'#1a1a1a','noteBorderColor':'#b45309','clusterBkg':'#f5f3ff','clusterBorder':'#4338ca','labelBoxBkgColor':'#e0e7ff','labelBoxBorderColor':'#4338ca','labelTextColor':'#1e1b4b','actorBkg':'#c7d2fe','actorBorder':'#4338ca','actorTextColor':'#1e1b4b','actorLineColor':'#4338ca','signalColor':'#4338ca','signalTextColor':'#1e1b4b','sequenceNumberColor':'#ffffff','activationBkgColor':'#ddd6fe','activationBorderColor':'#4338ca','transitionColor':'#4338ca','transitionLabelColor':'#1e1b4b','stateBkg':'#c7d2fe','stateLabelColor':'#1e1b4b','altBackground':'#f5f3ff','compositeBackground':'#f5f3ff','compositeBorder':'#4338ca','compositeTitleBackground':'#e0e7ff','specialStateColor':'#4338ca','innerEndBackground':'#4338ca'}}}%%
flowchart LR
    S1(["📥 Собран"]) --> S2(["✉️ Написали"])
    S2 --> S3(["💬 Ответил"])
    S3 --> G{"accounting_now<br/>заполнено?"}
    G -->|нет| STOP["🛑 HTTP 422<br/>переход запрещён"]
    G -->|да| S4(["✅ Квалифицирован"])
    S4 --> S5(["🖥️ Демо"])
    S5 --> S6(["🧾 Счёт"])
    S6 --> WON(["💚 Оплачен"])
    S6 --> LOST(["🔴 Отказ"])

    classDef stage fill:#6366f1,stroke:#4338ca,stroke-width:2px,color:#ffffff
    classDef gate fill:#f59e0b,stroke:#b45309,stroke-width:2px,color:#1a1a1a
    classDef stop fill:#ef4444,stroke:#b91c1c,stroke-width:2px,color:#ffffff
    classDef win fill:#22c55e,stroke:#15803d,stroke-width:2px,color:#052e16

    class S1,S2,S3,S4,S5,S6 stage
    class G gate
    class STOP,LOST stop
    class WON win
Loading

next_action_at — не удобство, а защита: сделка без назначенного следующего шага исчезает из поля зрения и умирает молча.

accounting_now — ответ на вопрос «как клиент ведёт учёт сейчас». Переход дальше этапа «Ответил» запрещён на уровне API, пока поле пустое.

🕐 Ловушка сравнения дат в SQLite

-- ❌ ISO-строка с 'T' и результат datetime('now') с пробелом
--    сортируются по-разному: условие молча не находит записи
WHERE next_action_at <= datetime('now')

-- ✅ приводим обе стороны к одному формату
WHERE datetime(next_action_at) <= datetime('now')

Ошибка тихая: запрос отрабатывает без ошибки и возвращает пустой результат.


🎯 Что из этого стоит забрать

1️⃣События отдельно от текущего состояния. Движения неизменяемы, остаток — производная. Иначе на вопрос «почему стало 12» ответа нет.
2️⃣Начисление и оплата — разные сущности со связью многие-ко-многим, а не флаг «оплачено».
3️⃣Мультиарендность закладывается сразу или не закладывается никогда.
4️⃣Идентификатор из чужой системы уникален только в ней. Составной ключ.
5️⃣Обязательное поле как защита процесса, а не как валидация формы.
6️⃣Валюта рядом с каждой суммой. Добавить вторую потом — переписать все расчёты.

🔗 Смежные заметки

Репозиторий О чём
💳 payment-integration-notes Payme, Click, Uzum: состояния и идемпотентность
📦 inventory-accounting-notes Складской учёт подробнее
🤖 telegram-bot-architecture-notes Bot API против MTProto
🏛️ php-layered-architecture-notes Слои и границы
🧾 uz-fiscal-compliance-notes ИКПУ, фискализация, ЭСФ

Шохрух Рузиев · backend-разработчик, Ташкент

Сайт Telegram

Исходный код систем — в приватных репозиториях, доступ по запросу.

About

Разбор схем БД трёх продакшен-систем: неизменяемые движения склада, начисления и оплаты, составные ключи. ER-диаграммы и принятые решения.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors