Складской учёт · CRM учебного центра · внутренняя панель с Telegram
Разбор структур БД трёх систем, которые я спроектировал и довёл до продакшена. Здесь нет исходного кода — только схемы, принятые решения и причины, по которым они приняты. Исходники в приватных репозиториях, доступ по запросу.
|
Складской учёт и продажи 27 таблиц MySQL · мультиарендная |
CRM учебного центра 47 таблиц SQLite · бот + панель |
Внутренняя панель 15 таблиц SQLite · MTProto |
Главное решение всей системы — и оно определяет остальные.
%%{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
}
%%{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
| Наивная схема | Движения + остаток | |
|---|---|---|
| Ответ «почему стало 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
UNIQUE KEY ux_inventory_documents_org_no (organization_id, document_no) -- ✅
UNIQUE KEY ux_inventory_documents_no (document_no) -- ❌ навсегда один клиентРазница при проектировании — ноль. Разница при добавлении потом — переписать каждую таблицу, каждый запрос и каждую проверку прав.
Тот же продукт, другая половина: работа с клиентом до отгрузки.
%%{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
}
Три решения, которые видно в схеме:
- 🔀
pipeline_code+stage_codeвместо одногоstage_id— воронок может быть несколько (продажа, возврат, сервис), и этап имеет смысл только внутри своей. - 🏷️
external_codeотдельно отid— код контрагента из чужой системы при импорте не должен подменять первичный ключ. - 💱
currency_codeрядом с каждой суммой, а не одна валюта на всю систему. Добавить валюту потом — переписать все денежные расчёты.
Центральный вопрос системы: кто, за что и сколько должен.
%%{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
}
%%{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
Один платёж может закрыть два месяца, один месяц — оплачен двумя переводами.
Флаг paid этого не выражает, а задолженность — главный отчёт, ради которого
систему покупают.
У провайдеров разные жизненные циклы платежа: у одного двухфазная схема с раздельными «создать» и «подтвердить» и свои коды отмены, у другого другой набор состояний и своя схема подписи.
Общая таблица с полем provider очень быстро превращается в набор колонок,
половина которых всегда NULL. Общее вынесено в интерфейс на уровне кода —
см. payment-integration-notes.
%%{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
}
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
next_action_at — не удобство, а защита: сделка без назначенного следующего шага
исчезает из поля зрения и умирает молча.
accounting_now — ответ на вопрос «как клиент ведёт учёт сейчас». Переход дальше
этапа «Ответил» запрещён на уровне API, пока поле пустое.
-- ❌ 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 | ИКПУ, фискализация, ЭСФ |