Перейти до змісту

Demo Seed Methodology

Призначення: єдиний підхід до наповнення Postgres demo-знімком, який пакується в Docker-образ і відсилається колегам / клієнтам для оцінки. Цей документ — specification, з якої виростає код у backend/core/management/commands/seed_demo.py і похідних. Якщо ми додаємо новий tenant, нову Organization, новий бізнес-кейс або новий модуль — спочатку оновлюємо цей документ, потім код.


Quick start — як зайти в кожен tenant

Демо-знімок створює три ізольовані tenants з повноцінною бізнес-моделлю — і по одному superuser-логіну на tenant. У DOP немає in-app tenant switcher: request.tenant резолвиться напряму з User.tenant (див. core/middleware/tenant.py), тому щоб побачити інший бізнес — треба вийти і увійти під іншим логіном.

Tenant Логін Пароль Що побачиш
Agro Holding "АгроМрія" admin admin 4 organizations (Зерноплюс / ОлійПром / АгроТранс / ФОП Пекар), повний цикл «зерно → переробка → випічка», ConsolidationGroup з intercompany elimination, 17 employees, 38 invoices
Sukhyi Port Kyiv port admin Контейнерний термінал, ContainerHub-довідники (12 типів, 10 shipping lines, 182 контейнери), 12 export-сервіс інвойсів агрохолдингу, 8 employees
ESWF Software eswf admin Розробник DOP, 7 модулів-ліцензій, 16 SalesInvoices зовнішнім клієнтам (4 Agro orgs + DryPort), 4 employees

Як перемкнутись: топ-меню → Logout → Login з іншою парою.

Чи ізольовані модулі/плагіни між tenants? Так. У DOP три шари: код Django apps глобальний (плагіни завантажені в процес для всіх), каталог shop.Product глобальний (App Store показує однаковий перелік), а активація через shop.License — per-tenant (FK на Tenant). Frontend useActivePlugins фільтрує навігацію саме по License поточного tenant'а — деактивуєш модуль в одному → у другого все лишається. Phase 0.5 у seed_demo викликає seed_shop і створює 24 active License × 3 tenants = 72 рядки. Деталі — §3-bis.

Один запуск:

cd backend && python manage.py seed_demo --reset

Виведе:

=== seed_demo: 3 tenants ready. Logins: admin/admin (agro_holding), eswf/admin (eswf), port/admin (dry_port) ===
[shop] catalogue + default Licenses synced for all tenants

Далі → відкрити http://localhost:5173/ → залогінитись.


1. Базові принципи

Принцип Що означає на практиці
Ідемпотентність python manage.py seed_demo можна викликати повторно — дані не дублюються. Кожен seeder використовує get_or_create за стабільним code / number у межах свого tenant.
Композиція з малих частин Жоден seeder не «знає всю картину». Є низькорівневі seeders (EssentialsSeeder, FleetSeeder, CrmSeeder, seed_containerhub, seed_shop, init_accounting_data) — і orchestrator seed_demo викликає їх у правильному порядку per tenant + додає cross-organization і cross-tenant транзакції.
Реалістичний знімок, не випадковий шум Дані мають утворювати сюжет: «Зерноплюс вирощує пшеницю → ОлійПром меле борошно → ФОП-пекарня випікає → роздріб купує», «АгроТранс возить між організаціями холдингу», «Сухий Порт переваленовує контейнери на експорт», «ESWF продає DOP-модулі холдингу і порту». Кожна цифра має історію.
Три tenants, multi-organization Замість одного Tenant: ESWF Demo з 6 Organizationтри окремі tenants з власними бізнес-моделями:
1. agro_holding — 4 organizations (виробник зерна, переробник/масляний термінал, перевізник, ФОП-пекарня) під спільним керівництвом холдингу.
2. dry_port — 1 organization (контейнерний термінал з Z/Z-під'їзною колією).
3. eswf — 1 organization (розробник DOP).
Контрагентом у документах є Client-mirror, що відтворює іншу Organization у тому ж tenant (внутрішньотенантний потік) або Organization з іншого tenant (cross-tenant потік). Між-tenant документи дублюються в обох tenants з протилежних сторін (sale у одного = purchase у іншого).
Detuning через прапорці Хочеш мінімум? seed_demo --skip=extras. Хочеш тільки один tenant? seed_demo --only-tenant=agro_holding. Хочеш скинути? seed_demo --reset. Хочеш конкретні фази? seed_demo --only=case_processor,case_carrier.
Версіонування знімку Кожен запуск пише Tenant.name = "<Tenant Name> (snapshot YYYY-MM-DD)". У Docker-образ зашиваємо дату білду, щоб по логам розрізняти версії.
Завжди через класифікатори Довідники з готовим класифікатором (валюти, банки, одиниці виміру, УКТЗЕД), наповнюються виключно через load_*_classifier команди — окремо в кожен tenant — щоб структура (numeric_code, name_ua, ієрархія) збереглась. Див. §2-bis.
Стандартизована нумерація Усі довідники: code формату 000000001 (9 цифр з ведучими нулями), послідовно у межах сутності у межах одного tenant. Усі документи: <XX-prefix><sequence> де XXOrganization.code_prefix (2 символи), всього 9 символів. Див. §2-ter. Реалізовано в core/services/numbering.py.
Локалізація: name vs name_ua Майже всюди є парні поля name (латиниця) + name_ua (українська). Загальні номенклатурні значення (товари, послуги, ролі, статуси) — перекладаємо (Sunflower oil / Олія соняшникова). Власні назви (юр. назви компаній, ПІБ, торгові марки) — транслітеруємо в name, оригінал у name_ua. Див. §2-quater.
Один superuser на tenant DOP не має in-app tenant switcher: request.tenant резолвиться напряму з User.tenant (див. core/middleware/tenant.py). Тому seed_demo створює по одному superuser на кожен tenantadmin/admin (agro_holding), eswf/admin (eswf), port/admin (dry_port). Перемикання між бізнесами — через logout/login. Це свідомий компроміс: зробити tenant switcher у UI можна, але для демо простіші окремі логіни наочніше показують ізоляцію.

2. Архітектурна ієрархія команд

python manage.py seed_demo                                  ← top-level orchestrator
├── Phase 0:   створити 3 Tenants + 3 superuser-логіни
│              └── tenant_eswf, tenant_dry_port, tenant_agro_holding
│              └── admin/admin (agro), eswf/admin (eswf), port/admin (dry_port)
├── Phase 0.5: seed_shop — App Store catalogue + License per tenant
│              └── 24 Products × 3 tenants = 72 active Licenses
│              └── гарантує що нав-меню в усіх 3 tenants має модулі без runtime-магії
├── tenant_eswf:                                            [Tenant code='eswf']
│   ├── base_eswf:        EswfBaseSeeder
│   │   ├── load_currency_classifier --codes UAH,EUR,USD
│   │   ├── load_unit_classifier
│   │   ├── load_bank_classifier
│   │   ├── exchange_rates UAH↔EUR, UAH↔USD                 ← НБУ за останні 4 міс
│   │   ├── tax_rates (ПДВ 20/7/0)
│   │   ├── chart_of_accounts                                ← делегується в init_accounting_data
│   │   ├── multi_ledger (pcg_eur + ua_nsbo, активні з 2026-01-01) ← див. §2-quinquies
│   │   ├── 1 Organization: TOV ESWF Software (prefix='ES')
│   │   ├── Clients (mirror усіх 5 organizations з інших 2 tenants — як зовнішніх покупців модулів)
│   │   ├── persons + departments (R&D, Sales, Support, Admin)
│   │   ├── settlement_account + cashbox
│   │   ├── warehouses (license_stock)
│   │   ├── price_types + items (модулі ESWF — perpetual/subscription/community)
│   │   └── essentials_module_settings (UAH primary, EUR presentation)
│   │
│   ├── case_es_modules:  EswfModulesCaseSeeder              ← StoreManager + cross-tenant продажі
│   │   ├── delegates → seed_shop (22 Products, всі pricing='community' у dev, див. §3-bis)
│   │   ├── _activate_all_available()                        ← safety-net License активація
│   │   ├── ActivationCodes (3× appFleetTrack, 2× appPortal)
│   │   ├── 6 SalesInvoices модулів зовнішнім клієнтам:
│   │   │   ├── ES → Zernoplyus (Essentials + CRM)            [cross-tenant: agro_holding]
│   │   │   ├── ES → OliyProm (Essentials + Production + Quality) [cross-tenant: agro_holding]
│   │   │   ├── ES → AhroTrans (Essentials + Fleet + Driver Mobile) [cross-tenant: agro_holding]
│   │   │   ├── ES → FOP Pekar (Essentials community)         [cross-tenant: agro_holding]
│   │   │   ├── ES → DryPort (Essentials + ContainerHub)       [cross-tenant: dry_port]
│   │   │   └── 1 Subscription billing recurring (12 рахунків × 5 клієнтів = annual)
│   │   ├── IncomingPayments (повна / часткова оплата по 4 з 5)
│   │   ├── 1 CustomerProfile (admin ↔ ES org)
│   │   ├── 1 EmailSmtpSettings (фіктивна smtp.eswf.dev)
│   │   ├── EmailLog (~5 записів — activation code emails до клієнтів)
│   │   └── 1 ShopOrder + activated License для appPortal (flow redeem → license)
│   │
│   └── extras_eswf:     EswfExtrasSeeder
│       ├── _seed_crm                 → 12 Leads з агро/порт-ринку, 8 Deals, 5 Contacts, 20 Activities
│       ├── _seed_hr_payroll          → 8 Positions, 8 Employees (devs/sales/support), 1 PayrollPeriod
│       ├── _seed_budgeting           → R&D budget 2026 (12 міс × 6 рахунків)
│       ├── _seed_fixed_assets        → 5 ПК + 2 сервери + 1 ноут
│       ├── _seed_chat                → #general, #dev, #support
│       ├── _seed_baf_sync            → BAFSyncSettings (dry_run)
│       └── _seed_manual_journal_entry → 1 збалансована JE (50 000 UAH cash ↔ bank)
├── tenant_dry_port:                                        [Tenant code='dry_port']
│   ├── base_dry_port:    DryPortBaseSeeder
│   │   ├── load_currency_classifier --codes UAH,EUR,USD
│   │   ├── load_unit_classifier (+ TEU, FEU контейнерні)
│   │   ├── load_bank_classifier
│   │   ├── exchange_rates
│   │   ├── tax_rates + chart_of_accounts
│   │   ├── multi_ledger (pcg_eur + ua_nsbo, активні з 2026-01-01)
│   │   ├── 1 Organization: TOV Sukhyi Port Kyiv (prefix='SP')
│   │   ├── Clients:
│   │   │   ├── mirror Zernoplyus, OliyProm, AhroTrans (як замовники транзиту/перевалки)
│   │   │   ├── mirror ESWF (як постачальник DOP-модулів)
│   │   │   └── 5 external (shipping lines: MSC, Maersk, COSCO, ZIM, ONE)
│   │   ├── persons + departments (operations, gate, RW, customs, accounting)
│   │   ├── settlement_account UAH + USD + cashbox
│   │   ├── warehouses (yard_main, customs_zone, RW_siding, transit_grain)
│   │   └── essentials_module_settings (UAH primary, USD presentation — експортна валюта)
│   │
│   ├── case_terminal_ops: DryPortTerminalCaseSeeder         ← Базові операції терміналу
│   │   ├── delegates → seed_containerhub (12 types, 10 shipping lines, 2 terminals,
│   │   │                                   20 zones, 30 tariffs, 6 integrations,
│   │   │                                   182 Containers)
│   │   ├── 30 GateTransactions (in/out за 4 міс)
│   │   ├── 12 Bookings
│   │   ├── 8 RailwayWaybills (ВПС/ВВС → морські порти)
│   │   ├── 15 StorageCharges
│   │   └── 5 DemurrageCalculations
│   │
│   ├── case_grain_export: DryPortGrainExportCaseSeeder      ← Cross-tenant: експорт зерна для agro
│   │   ├── 5 Bookings зерна Зерноплюс → морський порт через DryPort
│   │   ├── 5 ServiceInvoices → Zernoplyus (RW switching, storage, customs clearance)
│   │   ├── 5 ServiceInvoices → OliyProm (експорт олії наливом — flexitank)
│   │   ├── 8 GateTransactions з зерновозами AhroTrans (вантажі агрохолдингу)
│   │   └── 3 IncomingPayments від agro_holding клієнтів
│   │
│   └── extras_dry_port: DryPortExtrasSeeder
│       ├── _seed_hr_payroll          → 12 Employees: gate operators, RW dispatchers, customs, crane
│       ├── _seed_quality             → 5 DefectReason (зерно: вологість, домішки) + 4 StorageLocation
│       ├── _seed_fixed_assets        → 2 крана-перевантажувача, 3 yard tractor, 1 ваги
│       ├── _seed_chat                → #operations, #gate, #rw-dispatch
│       ├── _seed_baf_sync            → BAFSyncSettings (dry_run)
│       └── _seed_gps                 → yard tractors GPS snapshots
└── tenant_agro_holding:                                    [Tenant code='agro_holding']
    ├── base_agro:      AgroBaseSeeder
    │   ├── load_currency_classifier --codes UAH,EUR,USD
    │   ├── load_unit_classifier (+ ц/га, т/год, кг/буханка)
    │   ├── load_bank_classifier
    │   ├── exchange_rates
    │   ├── tax_rates + chart_of_accounts (consolidated CoA для 4 organizations холдингу)
    │   ├── multi_ledger (pcg_eur + ua_nsbo, активні з 2026-01-01)
    │   ├── 4 Organizations:
    │   │   ├── TOV Zernoplyus (prefix='ZP') — grower (ПДВ 20)
    │   │   ├── TOV OliyProm (prefix='OP') — processor / масляний термінал (ПДВ 20)
    │   │   ├── TOV AhroTrans (prefix='AT') — внутрішній перевізник (ПДВ 20)
    │   │   └── FOP Pekar (prefix='PK') — пекарня (ФОП, single-tax, без ПДВ)
    │   ├── 12 Clients:
    │   │   ├── 6 internal mirrors (всі 4 internal orgs дзеркалять одне одного попарно для
    │   │   │   внутрішніх sale/purchase — Zernoplyus↔OliyProm, OliyProm↔FOP, AhroTrans↔інші 3)
    │   │   ├── 2 cross-tenant mirrors (DryPort, ESWF як external suppliers/clients)
    │   │   └── 4 external (роздрібні мережі — Сільпо, АТБ, Novus, Varus як покупці пекарні
    │   │       та експортери зерна як покупці Zernoplyus)
    │   ├── persons + departments per organization
    │   ├── settlement_accounts (4 — один на org) + cashboxes (4)
    │   ├── warehouses (зерносховище ZP, маслоналив OP-tank, склад готової продукції OP-store,
    │   │              автодепо AT, склад сировини PK-raw, склад готової випічки PK-bread)
    │   ├── price_types + items:
    │   │   ├── Сировина: пшениця 2 кл, пшениця 3 кл, соняшник, кукурудза
    │   │   ├── Готова продукція переробки: олія соняшникова рафінована, шрот соняшниковий,
    │   │   │                                борошно пшеничне в/с, висівки пшеничні
    │   │   ├── Хлібобулочні вироби: хліб «Український» 700г, батон «Нарізний» 500г,
    │   │   │                         булочки з маком 80г, рулет з маком 400г
    │   │   ├── Послуги: транспортування за кілометр, оренда зерносховища,
    │   │   │             логістика-послуги (АТ → внутрішні + зовнішні)
    │   │   └── Модулі ESWF (з view-only license records, sourced from tenant_eswf invoices)
    │   └── essentials_module_settings (UAH primary, EUR presentation, FIFO, per-org defaults)
    ├── case_grower:    AgroGrowerCaseSeeder                   ← Зерноплюс: вирощування і продаж зерна
    │   ├── 8 SalesInvoices → OliyProm (4× пшениця, 4× соняшник на переробку)
    │   ├── 3 SalesInvoices → External (експорт пшениці через DryPort + посередник)
    │   ├── 2 SalesInvoices → FOP Pekar (пшениця 2 кл напряму без переробки — невелика партія)
    │   ├── 5 PurchaseInvoices ← External (насіння, добрива, ЗЗР, паливо)
    │   ├── Production: 3 «WorkOrder»-аналоги для збору врожаю (умовний BOM поле→бункер)
    │   └── 8 IncomingPayments (mix повна/часткова, по 4 покупцях)
    ├── case_processor: AgroProcessorCaseSeeder                ← ОлійПром: масляний термінал
    │   ├── 8 PurchaseInvoices ← Zernoplyus (вхід сировини)
    │   ├── BOM "Sunflower → Oil + Meal":  1 т соняшник → 0.40 т олія + 0.55 т шрот + 0.05 т втрати
    │   ├── BOM "Wheat → Flour + Bran":     1 т пшениця → 0.75 т борошно + 0.20 т висівки + 0.05 т втрати
    │   ├── 12 WorkOrders (8 олії + 4 борошна, з GoodsReceipt продукції на склад)
    │   ├── 6 SalesInvoices → FOP Pekar (борошно + олія для випічки)
    │   ├── 4 SalesInvoices → External (олія наливом експорт через DryPort flexitank,
    │   │                                 шрот як корм — комбікормові заводи)
    │   ├── 2 SalesInvoices → External (відходи: висівки на корм)
    │   └── 6 OutgoingPayments + 6 IncomingPayments
    ├── case_carrier:   AgroCarrierCaseSeeder                  ← АгроТранс: внутрішня логістика
    │   ├── delegates → FleetSeeder (8 vehicles, 6 drivers, fuel types, routes)
    │   ├── 30 Waybills (внутрішньохолдингові рейси: ZP→OP, OP→PK, ZP→DryPort, OP→DryPort)
    │   ├── 30 WaybillTasks + WaybillWorkResults
    │   ├── 30 WaybillFuelRecords + 5 VehicleRefueling
    │   ├── 12 TransportInvoices → Zernoplyus / OliyProm / FOP Pekar (внутрішні замовники)
    │   ├── 4 TransportInvoices → DryPort (зовнішні рейси з зерном/олією на термінал)
    │   ├── 8 MaintenanceRecords
    │   ├── 4 DriverSalaryAccruals (нарахування ЗП водіям за місяць)
    │   └── 6 IncomingPayments (від internal та DryPort)
    ├── case_bakery:    AgroBakeryCaseSeeder                   ← ФОП Пекар: випікання і ритейл
    │   ├── 6 PurchaseInvoices ← OliyProm (борошно + олія — основні інгредієнти)
    │   ├── 4 PurchaseInvoices ← External (дріжджі, сіль, цукор, дрібна сировина)
    │   ├── BOM "Хліб Український": 0.45 кг борошно + 0.005 кг дріжджі + сіль/цукор/вода → 1 буханка 700г
    │   ├── BOM "Батон Нарізний":   0.30 кг борошно + 0.02 л олія + дріжджі/сіль/цукор → 1 батон 500г
    │   ├── BOM "Булочки з маком":  0.06 кг борошно + 0.005 л олія + мак/цукор → 1 булочка 80г
    │   ├── 30 WorkOrders (щоденні зміни випікання, по 3 типи виробів × 10 робочих днів)
    │   ├── 16 SalesInvoices → External (4 ритейл-мережі × 4 щотижневі поставки)
    │   ├── 60 cash IncomingPayments (щоденна виручка з фірмового магазину при пекарні)
    │   └── 4 OutgoingPayments → OliyProm (оплата за борошно/олію)
    ├── case_external:  AgroExternalCaseSeeder                 ← Cross-tenant покупки агрохолдингу
    │   ├── 5 PurchaseInvoices ← ESWF (модулі DOP — мирорять SalesInvoices у tenant_eswf)
    │   ├── 5 OutgoingPayments → ESWF
    │   ├── 5 PurchaseInvoices ← DryPort (експорт-послуги — мирорять у tenant_dry_port)
    │   ├── 3 OutgoingPayments → DryPort (часткові, з відстроченням 30 днів)
    │   └── 12 monthly subscription PurchaseInvoices ← ESWF (recurring billing)
    ├── case_agro_crm:  AgroCrmSeeder                          ← Наскрізна CRM-воронка по холдингу
    │   ├── 4 додаткові LeadSources (AgroExpo / NIBULON-referral / Retail outreach / DryPort-referral)
    │   ├── 12 Leads — потенційні агро-клієнти: Kernel/Glencore/Cargill/Bunge (експорт),
    │   │              ЕКО-Маркет/Фора/Метро/Ашан (ритейл), 2 transport partners
    │   ├── 7 Contacts — primary contacts на Сільпо/АТБ/Novus/Varus/NIBULON/Lallemand
    │   ├── 24 Deals у всіх 6 стадіях (3 New + 4 Qual + 5 Prop + 3 Neg + 6 Won + 3 Lost)
    │   │   з assigned_to 4 demo-менеджерам (admin/bilous/oliynyk/bulkina)
    │   ├── 50 PipelineHistoryJournal entries — replay переходів через `record_stage_change`
    │   │   (drives Stage Duration list widget)
    │   ├── 95 Activities × 4 managers через `record_activity` → ActivityLedger
    │   │   (drives Manager Activity widget)
    │   ├── 6 overdue activities (due_date<now, completed_at IS NULL) → Sales Actions counter
    │   ├── 4 stuck deals (updated_at backdated >35д через .update()) → Deals at Risk top-5
    │   ├── 5 Quotations + 3 SalesOrders для advanced/won deals
    │   └── self-bootstrap: викликає `seed_roles_agro_holding` ідемпотентно — фаза runnable
    │       standalone через `--only=case_agro_crm` навіть на свіжому tenant'і.
    └── extras_agro: AgroExtrasSeeder
        ├── _seed_hr_payroll          → ~50 Employees розподілено по 4 orgs:
        │                                  ZP (15 — agronomists, combine operators),
        │                                  OP (20 — production line, lab, accounting),
        │                                  AT (10 — drivers, mechanics, dispatcher),
        │                                  PK (5 — bakers, sales)
        ├── _seed_budgeting            → consolidated 2026 budget (cross-org, по холдингу)
        ├── _seed_consolidation        → 1 ConsolidationGroup "AhroMriya Holding" з 4 org members
        │                                  + IntercompanyMap (ZP-OP, OP-PK, AT-всі) для elimination
        ├── _seed_production           → BOMs з case_processor + case_bakery vivified як reference
        ├── _seed_fixed_assets         → комбайни (3 ZP), преси і олійні лінії (4 OP),
        │                                  ваги і сепаратори (2 OP), вантажівки і причепи (8 AT),
        │                                  пекарські печі і тістозмішувачі (3 PK)
        ├── _seed_quality              → стандарти приймання зерна (вологість, засміченість, протеїн)
        ├── _seed_chat                 → #agro-ops, #bakery, #drivers, #processing
        ├── _seed_sales_field          → мобільні агенти ритейл (FOP Pekar): 1 DayPlan + 5 visits
        ├── _seed_baf_sync             → BAFSyncSettings (dry_run)
        ├── _seed_manual_journal_entry → 1 збалансована JE в OP (амортизація лінії)
        └── _seed_gps                  → комбайни ZP + Fleet vehicles AT (last 7 days)

Ключове: seed_demo — це тонкий шар оркестрації. Уся «розумна» бізнес-логіка лишається в існуючих seeders / management commands. Якщо хочеш «50 рандомних waybills» — викликаєш python manage.py seed waybills --tenant=agro_holding --count=50, а не торкаєшся seed_demo.

Фаза extras_<tenant> як сміттєзбирач: усі модулі, у яких нема окремого бізнес-кейсу (HR, Budgeting, Production extras, Quality, Chat тощо), але які треба «показати» в UI з мінімумом даних — сідаються в extras_<tenant>. Нові модулі з простою моделлю даних додаються туди ж новим _seed_<module> методом, щоб не плодити phases. Для модуля з окремим сюжетом (як case_grain_export для DryPort) — окрема фаза в межах того tenant.

Cross-tenant зв'язки реалізуються парою фаз: case_es_modules у tenant_eswf створює SalesInvoices → 5 client-mirrors; case_external у tenant_agro_holding (і відповідна логіка в tenant_dry_port) створює дзеркальні PurchaseInvoices з тими ж номерами/датами/сумами. Ідентифікатор зв'язку — поле Document.external_reference = number документа з іншого tenant.


2-bis. Класифікатори — джерело правди для довідників

DOP має чотири готові класифікатори (JSON-файли) і чотири команди, що з них наповнюють довідники. Seed-методика забороняє створювати ці довідники в обхід класифікаторів — інакше втрачається структура (numeric_code, ієрархія, локалізовані назви). Важливо: класифікатори завантажуються окремо в кожен tenant — це isolation-by-design (RLS-готовність).

Довідник Класифікатор Команда завантаження Default scope у seed_demo
Currency backend/essentials/data/currency_classifier.json python manage.py load_currency_classifier --codes UAH,EUR,USD --tenant <id> UAH + EUR + USD для всіх трьох tenants (USD потрібен Dry Port для shipping lines і Agro Holding для зернового експорту).
Unit backend/essentials/data/unit_classifier.json python manage.py load_unit_classifier --tenant <id> Усі (pcs, kg, t, l, m, m², m³, hr, day, TEU, FEU…)
Bank backend/essentials/data/bank_classifier.json python manage.py load_bank_classifier --tenant <id> Усі (МФО код + name + name_ua)
UKTZEDDirectory backend/uktzed.json python manage.py load_uktzed --tenant <id> Усі (для майбутньої M.E.Doc інтеграції). Особливо важливо для tenant_agro_holding — там реальний рух зерна по УКТЗЕД-кодах 1001/1206/1512.

base_<tenant> фази викликають ці команди в правильному порядку перед створенням Organizations / Items.

Правило: якщо ми додаємо новий довідник, який має зовнішнє джерело істини (КОАТУУ, ДКВЕД, ISO-3166 країни, …) — спочатку готуємо JSON-класифікатор у backend/<app>/data/, потім команду load_<name>_classifier, і тільки тоді додаємо це у відповідну base_<tenant> фазу.


2-ter. Нумерація: довідники vs документи

Довідники (master data)

Поле MasterDataModel.code (CharField(max_length=50)) заповнюється завжди у форматі 9 цифр з ведучими нулями (константа MASTERDATA_CODE_LENGTH = 9 у core/services/numbering.py), послідовно в межах сутності в межах одного tenant:

Item.code            → '000000001', '000000002', '000000003', …
Client.code          → '000000001', '000000002', …
Vehicle.code         → '000000001', '000000002', …
Driver.code          → '000000001', '000000002', …
Organization.code    → '000000001', '000000002', … (плюс окремий code_prefix — див. нижче)

Унікалізація — в межах (tenant, entity_type). Послідовність рестартує з 000000001 для кожного tenant: у tenant_eswf Organization.code='000000001' (ESWF Software), у tenant_dry_port — теж '000000001' (Sukhyi Port Kyiv), у tenant_agro_holding — '000000001'..'000000004' (Zernoplyus, OliyProm, AhroTrans, FOP Pekar). Колізій нема, бо ключ (tenant_id, code).

Хелпер get_next_masterdata_code(model_class, tenant) повертає наступний доступний номер у межах tenant.

Виняток — довідники з зовнішнього класифікатора (Currency, Bank, Unit, UKTZEDDirectory): їхній code приходить з класифікатора (numeric_code для валют — '980' для UAH, '978' для EUR; МФО для банків — '305299'; …). Це особливе джерело істини.

Документи (transactions)

Поле <DocumentModel>.number заповнюється у форматі 9 символів (константа TRANSACTION_NUMBER_LENGTH = 9):

<XX-prefix><7-digit-sequence>

де: - XXOrganization.code_prefix (2 символи, ASCII літери), задається при створенні Organization; - <sequence> — послідовний номер у межах (tenant, organization, document_type) з ведучими нулями, 7 цифр (= 9 - 2). Якщо префікс порожній — лічильник 9 цифр.

Хелпер get_next_transaction_number(model_class, tenant, prefix) повертає наступний доступний номер у форматі XX1234567.

Приклади (всі в межах 9 символів):

Tenant 'eswf':
  Organization TOV ESWF Software (prefix='ES'):
    Invoice.number   → 'ES0000001', 'ES0000002', …
    ShopOrder.number → 'ES0000001', 'ES0000002', …          (своя послідовність на тип документа)

Tenant 'dry_port':
  Organization TOV Sukhyi Port Kyiv (prefix='SP'):
    GateTransaction.number → 'SP0000001', 'SP0000002', …
    RailwayWaybill.number  → 'SP0000001', 'SP0000002', …
    ServiceInvoice.number  → 'SP0000001', 'SP0000002', …

Tenant 'agro_holding':
  Organization TOV Zernoplyus (prefix='ZP'):
    Invoice.number  → 'ZP0000001', 'ZP0000002', …
  Organization TOV OliyProm (prefix='OP'):
    Invoice.number       → 'OP0000001', …
    PurchaseInvoice.number → 'OP0000001', …                  (інша послідовність)
    WorkOrder.number     → 'OP0000001', …
  Organization TOV AhroTrans (prefix='AT'):
    Waybill.number             → 'AT0000001', 'AT0000002', …
    TransportInvoice.number    → 'AT0000001', 'AT0000002', …
    DriverSalaryAccrual.number → 'AT0000001', 'AT0000002', …
  Organization FOP Pekar (prefix='PK'):
    Invoice.number   → 'PK0000001', 'PK0000002', …
    WorkOrder.number → 'PK0000001', 'PK0000002', …

Лічильник окремий на кожен тип документа в межах кожної organization — тому Invoice.number = 'ZP0000001' і Waybill.number = 'AT0000001' мирно співіснують. Природний ключ — (tenant, model_class, number).

Префікси для 6 demo-organizations across 3 tenants

Tenant Code code_prefix name (latin) name_ua (ua)
eswf org_es ES TOV ESWF Software ТОВ "ESWF Software"
dry_port org_sp SP TOV Sukhyi Port Kyiv ТОВ "Сухий Порт Київ"
agro_holding org_zp ZP TOV Zernoplyus ТОВ "Зерноплюс"
agro_holding org_op OP TOV OliyProm ТОВ "ОлійПром"
agro_holding org_at AT TOV AhroTrans ТОВ "АгроТранс"
agro_holding org_pk PK FOP Pekar P.P. ФОП Пекар П.П.

Налаштування валют обліку

Після створення Currency-довідника в кожному tenant, відповідний base_<tenant> встановлює в EssentialsModuleSettings:

Tenant accounting_currency presentation_currency Пояснення
eswf UAH EUR Розробник з європейським презентаційним поглядом
dry_port UAH USD Експорт через міжнародні shipping lines, USD домінантна валюта
agro_holding UAH EUR Холдинг з європейським expansion-планом

Інші settings (per tenant): - inventory_method = 'fifo' (всюди) - default_warehouse → перший Warehouse що створюється у відповідному tenant (license_stock / yard_main / зерносховище ZP) - default_sales_operation, default_incoming_payment_operation, default_purchase_operation, default_outgoing_payment_operation → відповідні BusinessOperation з init_accounting_data (для синхронізації PartyLedger ↔ chart-of-accounts, див. audit §15-bis)

В tenant_agro_holding settings — per organization, бо 4 organization в одному tenant мають різні бізнес-моделі: у FOP Pekar ПДВ вимкнений (vat_default = 0), у решти — 20%.

Multi-ledger: pcg_eur + ua_nsbo з 2026-01-01

Кожен demo-tenant — українська компанія, яка одночасно веде два паралельні облікові контури (Ledger):

Code Назва Призначення Стандарт COA
pcg_eur European PCG (by Nature) Управлінський / групповий звіт у європейському шаблоні "By Nature" 4–7 (1000–7900)
ua_nsbo Ukrainian NSBO Юридична відповідність — наказ Мінфіну №291 UA-NSBO 1–9 (10, 20, 30, 40, 6411…)

Обидва контури активуються з 2026-01-01 — це початок demo-знімка, від якого народжуються всі PostingGroup. Кожен пост документа породжує одну PostingGroup на кожен активний Ledger через відповідний BusinessOperationTemplate. Деталі архітектури — у essentials/models/ledger.py і тестах essentials/tests/test_multi_ledger.py.

Що робить базовий seeder для кожного tenant'а (метод seed_chart_and_operations у _demo_shared.py):

  1. init_accounting_data → ~120 рахунків PCG + ~55 BusinessOperation з парами Dt/Ct (legacy поля).
  2. init_multi_ledger --activated-from 2026-01-01 → створює Ledger(code='pcg_eur', is_active=True, activated_from=2026-01-01) + BusinessOperationTemplate(standard='pcg_eur') для кожного BO.
  3. seed_ua_nsbo --activate --activated-from 2026-01-01 → створює ~70 UA-NSBO рахунків (ChartOfAccounts(standard='ua_nsbo')), мапить кожен BO у BusinessOperationTemplate(standard='ua_nsbo') за таблицею PCG_TO_UA_MAP + special-case overrides для writeoff/inventory-adjust, і активує Ledger(code='ua_nsbo', is_active=True, activated_from=2026-01-01).

Усі три кроки ідемпотентні. Опція --activated-from YYYY-MM-DD в обох командах перепиняє існуючу дату активації, щоб ребіл бази або повторний seed повертав до канонічної 2026-01-01.

IFRS (Ledger(code='ifrs')) — scaffolding-only: команда seed_ifrs створює caption-style COA (A.1, L.1, R.1…), але не активує контур і не створює BusinessOperationTemplate'и. Реальний IFRS-маппінг — окрема задача методолога.


2-quater. Локалізація назв: коли перекладати, коли транслітерувати

У більшості наших моделей (MasterDataModel і похідних) є парні поля:

name      → латиниця (англ. або транслітерація)
name_ua   → кирилиця (українська)

Це дві проєкції одного й того самого об'єкта, а не різні переклади під різні аудиторії. Тому правило вибору між перекладом і транслітерацією залежить від того, що саме записано в полі.

Правило 1 — загальні номенклатурні значення → перекладаємо

Для смислових сутностей, де name фактично є описом / категорією / типом — використовуємо семантичний переклад:

name (en) name_ua (ua) Контекст
Sunflower oil refined Олія соняшникова рафінована Item.name — товар (OP)
Wheat flour, premium grade Борошно пшеничне в/с Item.name — товар (OP)
Sunflower meal Шрот соняшниковий Item.name — товар (OP)
Wheat 2nd class Пшениця 2 класу Item.name — товар (ZP)
Bread "Ukrainian" 700g Хліб «Український» 700г Item.name — готова продукція (PK)
Transportation services per km Транспортні послуги за км Item.name — послуга (AT)
Container handling, TEU Перевалка контейнера, TEU Item.name — послуга (SP)
Cash desk Kyiv Каса Київ Cashbox.name
Grain silo Зерносховище Warehouse.name (ZP)
Flexitank loading station Станція наливу олії Warehouse.name (OP)
Sales Manager Менеджер з продажу Person.position
Posted Проведено BusinessOperation.name (стани)
Sales Invoice Видаткова накладна BusinessOperation.name

Загальне правило: якщо хтось англомовний відкриє довідник з name-колонкою — він має зрозуміти, що це таке.

Правило 2 — власні назви → транслітеруємо в name, оригінал у name_ua

Для юр. назв організацій, ПІБ, торгових марок, географічних власних назв — не перекладаємо, а транслітеруємо латиницею (≈ ISO 9 / паспортна транслітерація):

name (en, транслітерація) name_ua (ua, оригінал) Контекст
TOV ESWF Software ТОВ "ESWF Software" Organization.name
TOV Sukhyi Port Kyiv ТОВ "Сухий Порт Київ" Organization.name
TOV Zernoplyus ТОВ "Зерноплюс" Organization.name
TOV OliyProm ТОВ "ОлійПром" Organization.name
TOV AhroTrans ТОВ "АгроТранс" Organization.name
FOP Pekar P.P. ФОП Пекар П.П. Organization.name
Pekar Petro Petrovych Пекар Петро Петрович Driver.full_name / Person.full_name
Kyiv Київ LocationPoint.name
Odesa Одеса LocationPoint.name

❌ Заборонено: LLC "Grain Plus", LLC ESWF Software (переклад організаційно-правової форми), Sunflower Industries Ltd (вигаданий англ. еквівалент). ✅ Правильно: TOV Zernoplyus, TOV ESWF Software, TOV OliyProm.

Правило 3 — англомовні бренди / технічні коди → дублюємо as-is

Якщо вихідне значення — англомовний бренд або технічний код (модулі ESWF, торгові марки), пишемо однаково в обох полях:

name name_ua Контекст
ESWF Essentials ESWF Essentials Item.name (модуль)
ESWF Fleet ESWF Fleet Item.name (модуль)
ESWF ContainerHub ESWF ContainerHub Item.name (модуль)
Volvo FH16 Volvo FH16 Vehicle.brand_model
Bühler MDDK Bühler MDDK FixedAsset.brand (млин в OP)

Як перевіряти у seed-коді

Зручно зробити thin helper у core/seeders/_naming.py:

def names(en: str, ua: str) -> dict:
    """Парний name + name_ua. Завжди обидва поля одночасно."""
    return {'name': en, 'name_ua': ua}

def proper(latin: str, original_ua: str) -> dict:
    """Власна назва — транслітерація + оригінал. Окремий helper щоб у diff було видно намір."""
    return {'name': latin, 'name_ua': original_ua}

Тоді в seed-коді намір читається з виклику:

Item.objects.get_or_create(tenant=t, code='000000001',
    defaults={**names('Sunflower oil refined', 'Олія соняшникова рафінована'),
              'unit': liter, ...})

Organization.objects.get_or_create(tenant=t_agro, code='000000002',
    defaults={**proper('TOV OliyProm', 'ТОВ "ОлійПром"'),
              'code_prefix': 'OP', 'edrpou': '23456784', ...})

2-quinquies. Описи об'єктів: бізнес-контекст як частина seed

У кожної сутності, яку ми сіємо, є поле description (або аналогічне — note, comment, legal_address тощо). Ми заповнюємо його осмислено, а не залишаємо порожнім — щоб демо-база була самодокументованою.

Що писати в description

Опис кожного об'єкта має відповідати на 3 питання:

  1. Що це — коротка характеристика сутності (тип, роль, категорія)
  2. Навіщо створено — яку demo-історію / бізнес-кейс обслуговує
  3. Як взаємодіє з іншими — які сусідні сутності очікують цей об'єкт, у яких документах / регістрах він з'явиться
"OliyProm — масляний термінал агрохолдингу: купує сировину у Зерноплюс, переробляє в олію + борошно, продає ФОП-пекарні і на експорт через DryPort"
"Grain owner — entry point для зерна холдингу; продає сировину OliyProm (внутрішньо) і експортерам (через DryPort)"
"Internal carrier — обслуговує перевезення між ZP/OP/PK + рейси на DryPort. Не продає послуги назовні."

Приклади за типами сутностей

Тип description
Organization (tenant agro_holding) "OliyProm — переробник агрохолдингу. Купує пшеницю/соняшник у ZP, продає борошно/олію FOP Pekar (внутрішньо) + експорт через DryPort. ПДВ 20%, accounting UAH, presentation EUR."
Client (cross-tenant mirror) "Mirror of TOV Sukhyi Port Kyiv (tenant=dry_port) — used when agro_holding orgs pay for grain export services. ServiceInvoices з порту з'являються тут як PurchaseInvoices."
Item (raw material) "Sunflower seeds, 8% moisture max. Sold by Zernoplyus to OliyProm; appears as input in BOM 'Sunflower → Oil + Meal' (case_processor)."
Item (finished product) "Refined sunflower oil, food grade. OliyProm output of pressing+refining BOM. Sold to FOP Pekar (internal) і експорт через DryPort flexitank."
Item (module license) "ESWF Essentials perpetual license. Sold by tenant_eswf to all 5 external orgs in case_es_modules; mirrored as PurchaseInvoice у tenant_agro_holding (case_external) і tenant_dry_port."
Warehouse "Grain silo Zernoplyus — capacity 10000 t. Default for case_grower harvest receipts. Linked from EssentialsModuleSettings.default_warehouse for org_zp."
Cashbox "Cash desk of FOP Pekar (org_pk) — UAH only. Receives daily retail bread sales (60+ cash IncomingPayments in case_bakery)."
BusinessOperation "Customer Sale — Revenue: Dr 36 (AR) / Cr 70 (Revenue). Default for Invoice posting (set in EssentialsModuleSettings.default_invoice_operation)."
Invoice (demo doc) "case_processor: OliyProm sells flour + oil to FOP Pekar — internal cross-org transaction. Full payment via IncomingPayment same period — PartyLedger balance must settle to 0."
Waybill "AhroTrans rейс ZP→OP — Driver Pekarchuk + DAF XF105 hauls 25t wheat. Internal carrier flow part of case_carrier."

Правило двомовності: опис завжди EN + UA

Описи завжди пишемо двома мовами. Технічно — два режими, залежно від моделі:

Режим A — модель має парні поля description + description_ua (shop.Product, medoc_exchange.UKTZEDDirectory, core.ScheduledTask).

Режим B — модель має лише description (TextField) (усі MasterDataModel / TransactionModel нащадки): UA-параграф, порожній рядок, EN: <англ>:

ОлійПром — масляний термінал агрохолдингу. Купує сировину у Зерноплюс, переробляє в олію та борошно, продає ФОП-пекарні і на експорт через Сухий Порт.

EN: OliyProm — agroholding's processing terminal. Buys raw materials from Zernoplyus, processes into oil and flour, sells to FOP-bakery and exports via Dry Port.

Код: використовуй хелпер describe()

У core/seeders/_naming.py є функція describe(en, ua, has_ua_field=False) + шорткат self.describe(...) на BaseSeeder. Вона сама вибирає потрібний режим. Деталі — як раніше (self.describe(...) йде після self.master_fields(...) у розпакуванні **).

Як застосовувати

  • У довідниках (MasterDataModel): описи живуть у description — постійні, редагуються як частина довідника. Правило двомовності обов'язкове.
  • У документах (TransactionModel): description — короткий ярлик документа. Для demo-документів використовуємо label сценарію українською + EN-переклад ("OP→PK продаж борошна+олії, повна оплата\n\nEN: OP→PK flour+oil sale, full payment").
  • У cross-tenant дзеркалах: явно прописуємо source: "Mirror of ES0000001 (tenant=eswf) — see source for original terms".

Паттерн для нових seed-ерів (чек-ліст)

  1. Модель має description_ua? Грепни: grep -n "description_ua" backend/<app>/models.py.
  2. Обидва рядки непорожні?
  3. Описи унікальні?
  4. Опис читається з БД, а не лише з коду?

❌ Що НЕ писати

  • Технічні ID і FK ("tenant_id=7")
  • Переклади назв (опис — про роль, не про name_ua)
  • Порожні рядки чи "-" / "TBD"
  • Однією мовою

3. Розподіл 6 Organizations across 3 Tenants

Демо-знімок — це три ізольовані бізнеси під одним adminом, з реалістичними між-tenant зв'язками. Усі організації — Organization-записи у відповідному tenant. Internal mirrors через Client — для виставлення документів між організаціями того ж tenant. Cross-tenant mirrors через Client — для документів від однієї tenant-organization до іншої tenant-organization (узгоджені пари document_pairs у seed-логу).

Іменування — Правило 2 (§2-quater): name транслітерація, name_ua оригінал.

Tenant 1: agro_holding — Агрохолдинг "АгроМрія"

Бізнес-модель: вертикально-інтегрований холдинг з повним циклом «зерно → переробка → випічка», з власним перевізником.

Code code_prefix name (latin) name_ua (ua) Role EDRPOU (фейк) Tax VAT
org_zp ZP TOV Zernoplyus ТОВ "Зерноплюс" Виробник зерна — вирощує пшеницю, соняшник, кукурудзу 23456784 corporate_tax
org_op OP TOV OliyProm ТОВ "ОлійПром" Переробник / масляний термінал — купує зерно, переробляє в олію + борошно, продає 11223344 corporate_tax
org_at AT TOV AhroTrans ТОВ "АгроТранс" Внутрішній перевізник — обслуговує лише організації холдингу + рейси на DryPort 55667788 corporate_tax
org_pk PK FOP Pekar P.P. ФОП Пекар П.П. Пекарня — купує сировину у OP, випікає хліб, продає в роздріб 1234567890 single_tax

Документи кожної організації:

  • org_zp Zernoplyus:
  • Inbound: PurchaseInvoices (насіння, добрива, ЗЗР, паливо)
  • Outbound: SalesInvoices → OliyProm (внутрішньо), External (експорт)
  • Special: harvest WorkOrder-аналоги, Combine GPS snapshots
  • org_op OliyProm:
  • Inbound: PurchaseInvoices ← Zernoplyus
  • Production: WorkOrder з BOM (sunflower→oil+meal, wheat→flour+bran), GoodsReceipt готової продукції
  • Outbound: SalesInvoices → FOP Pekar, External (експорт олії, шрот на корм)
  • org_at AhroTrans:
  • Operational: Waybill, WaybillTask, WaybillFuelRecord, VehicleRefueling
  • Outbound: TransportInvoice → ZP/OP/PK/DryPort
  • HR: DriverSalaryAccrual
  • Maintenance: MaintenanceRecord
  • org_pk FOP Pekar:
  • Inbound: PurchaseInvoices ← OliyProm + External (дріжджі/сіль/цукор)
  • Production: WorkOrder з BOM (хліб, батон, булочки)
  • Outbound: SalesInvoices → ритейл-мережі + кешеві IncomingPayments з фірмового магазину

Внутрішньо-холдингові потоки (для analytics і consolidation): - ZP → OP: пшениця/соняшник (велика частина обороту) - OP → PK: борошно + олія (внутрішня поставка) - ZP → PK: пшениця напряму (мала партія) - AT → ZP/OP/PK: транспортні послуги (всі три як замовники) - AT → DryPort: вивіз на експорт (зовнішній клієнт)

ConsolidationGroup "AhroMriya Holding"_seed_consolidation) включає всі 4 organizations + IntercompanyMap для elimination → consolidated P&L.

Tenant 2: dry_port — Сухий Порт Київ

Бізнес-модель: контейнерний термінал з залізничним під'їздом, обслуговує контейнерний транзит і експорт зерна/олії від агрохолдингу.

Code code_prefix name (latin) name_ua (ua) Role EDRPOU (фейк) Tax VAT
org_sp SP TOV Sukhyi Port Kyiv ТОВ "Сухий Порт Київ" Контейнерний термінал — перевалка, транзит, RW, customs 45678901 corporate_tax

Документи: - Operations: GateTransaction, Booking, ContainerMovement, ContainerInspection, Seal - Rail: RailwayWaybill, TrainArrival, TrainManifest - Storage: StorageCharge, ContainerLedger, DemurrageCalculation - Sales (services): ServiceInvoice → Zernoplyus / OliyProm / AhroTrans / shipping lines (international) - Inbound: PurchaseInvoice ← External vendors (паливо для cranes), ESWF (модулі), AhroTrans (transportation)

Cross-tenant потоки: - DryPort → Zernoplyus: експорт-послуги для зерна (case_grain_export, mirror у case_external агро) - DryPort → OliyProm: експорт-послуги для олії наливом (flexitank) - AhroTrans → DryPort: вивіз вантажів агрохолдингу на термінал (mirror у case_carrier) - ESWF → DryPort: модулі DOP (case_es_modules, mirror тут як PurchaseInvoice)

Tenant 3: eswf — ESWF Software

Бізнес-модель: розробник DOP, продає модулі/ліцензії/підписки агрохолдингу і сухому порту.

Code code_prefix name (latin) name_ua (ua) Role EDRPOU (фейк) Tax VAT
org_es ES TOV ESWF Software ТОВ "ESWF Software" Розробник DOP 12345672 corporate_tax

Документи: - Products: Product (модулі), ActivationCode, License (через seed_shop) - Sales: SalesInvoice → 5 external clients (4× agro_holding + 1× dry_port), ShopOrder, IncomingPayments - Recurring: Monthly subscription billing (12 рахунків × N клієнтів) - Support: EmailLog, CustomerProfile, SmtpSettings - Inbound: PurchaseInvoice ← External (cloud, hosting), AhroTrans (occasional courier)

Cross-tenant потоки (виходять): - ES → 4× agro_holding orgs: DOP-модулі (case_es_modules → mirror у case_external агро) - ES → dry_port: DOP-модулі + ContainerHub plugin (case_es_modules → mirror у tenant dry_port)

Зведена матриця між-tenant зв'язків

Source Target Документ у source Mirror у target Phase у source Phase у target
ESWF Zernoplyus SalesInvoice ES000000X PurchaseInvoice ZP-mirror case_es_modules case_external
ESWF OliyProm SalesInvoice ES000000X PurchaseInvoice OP-mirror case_es_modules case_external
ESWF AhroTrans SalesInvoice ES000000X PurchaseInvoice AT-mirror case_es_modules case_external
ESWF FOP Pekar SalesInvoice ES000000X PurchaseInvoice PK-mirror case_es_modules case_external
ESWF DryPort SalesInvoice ES000000X PurchaseInvoice SP-mirror case_es_modules case_terminal_ops
DryPort Zernoplyus ServiceInvoice SP000000X PurchaseInvoice ZP-mirror case_grain_export case_external
DryPort OliyProm ServiceInvoice SP000000X PurchaseInvoice OP-mirror case_grain_export case_external
AhroTrans DryPort TransportInvoice AT000000X PurchaseInvoice SP-mirror case_carrier case_terminal_ops

Wow-ефект для звітів: - P&L кожного tenant — повноцінні дві сторони обороту, реальні марджі - Cash Flow з реальним рухом грошей (кешові продажі пекарні дають ритм) - Trial Balance як знімок per organization + consolidated для агрохолдингу - Payment Calendar з відстроченими платежами між tenants - PartyLedger running balance для всіх внутрішньо- і між-tenant клієнтів - ConsolidationGroup elimination — реальні intercompany продажі ZP↔OP↔PK затиркаються


3-bis. App Store seed: політика ціноутворення та автоактивації

case_es_modules (через seed_shop + _activate_all_available) наповнює App Store у tenant_eswf — це ката́лог модулів. License-записи створюються:

  1. У tenant_eswf — як «продані ліцензії», прив'язані до Order
  2. У tenant_dry_port і tenant_agro_holding — як «активовані ліцензії», прив'язані до тих модулів, які customer купив (matching через License.product_code)

Ціноутворення: всі модулі — community у dev

Поле Product Значення в dev seed Пояснення
pricing 'community' для всіх модулів Активація без коду. Платні тарифи (perpetual / subscription) — тільки на проді.
price, price_monthly, price_annual 0 Каталог у ERP показує "безкоштовно".
status 'available' / 'coming_soon' coming_soon = у розробці — не активується автоматично.
installed_by_default True для 21 модуля, False для appConsolidator Визначає, чи створювати License одразу при першому GET /licenses/ від нового tenant.

Чому всі community в dev: demo-образ має показати весь функціонал одразу для всіх трьох tenants, без квестів «введи код активації». Реальна платна логіка (paid/subscription) перевіряється окремо через unit-тести LicenseViewSet.activate, не в seed.

Автоактивація: три шари захисту (спрацьовують в кожному tenant окремо)

  1. Phase 0.5 у seed_demo викликає seed_shop (без --tenant параметра). Він перебирає всіх Tenant у БД і створює License для кожного installed_by_default=True Product у кожному tenant. Поточний стан після seed_demo --reset: 24 active Licenses × 3 tenants = 72 рядка License.
  2. Product.installed_by_default=True — використовується seed_shop як критерій масової активації. Усі продукти з цим прапорцем автоактивуються в кожному tenant.
  3. LicenseViewSet._ensure_default_licenses(tenant) у runtime — при першому GET /licenses/ від tenant автоматично створює відсутні License. Останній рубіж захисту (наприклад, якщо tenant з'явився поза seed_demo).

Чітке логування для всіх tenants одразу:

--- Licenses ---
  [+] License: ESWF Software ← appEssentials
  [+] License: ESWF Software ← appFleet
  ... (24 Products × 3 tenants)
Licenses: 72 created, 0 reactivated, 24 already active.
[shop] catalogue + default Licenses synced for all tenants

Продукти, що потребують ручної активації

Якщо Product має installed_by_default=False, він не активується в Phase 0.5 і потребує вручної логіки в extras_<tenant>. Поточний приклад — appBudgeting (installed_by_default=False за станом 2026-05-06; перевірити в seed_shop.py). У такому випадку:

# extras_<tenant>.py — захищена через _safe(...)
def _activate_<product>_license(self):
    from shop.models import License, Product
    product = Product.objects.filter(code="<appCode>").first()
    if not product:
        self.log("Product not in catalogue — skipped")
        return
    lic, created = License.objects.get_or_create(
        tenant=self.tenant, product=product,
        defaults={"is_active": True},
    )

Реальний приклад — _activate_consolidator_license у agro_extras.py (страховка, бо appConsolidation зараз installed_by_default=True, але цей прапорець може мінятися).

Синхронізація з frontend-конфігом

backend/shop/management/commands/seed_shop.py і frontend/erp/src/config/applications.tsдва джерела істини, які треба тримати в sync.


3-ter. Ролі та demo-юзери з призначеннями

Окрім трьох superuser-логінів (Phase 0), кожна extras_<tenant> фаза підтягує системний каталог ролей у тенант і створює per-role demo-юзерів. Каталог — спільний для всіх тенантів, призначення — специфічні.

Що робить orchestrator (Phase 0)

  • Створює 3 tenants
  • Створює 3 superuser'и (admin/eswf/port) — кожен is_tenant_admin=True
  • Оновлення (2026-05-06): одразу після superuser'ів викликає apply_system_roles(tenant) для кожного тенанта → у БД з'являються 14 системних ролей (drift-free copy of role_catalog.py). Це гарантує що Role є завжди, навіть якщо хтось запустить seed_demo --skip=extras_*.

Що роблять extras_<tenant> фази

Кожна фаза в кінці викликає seed_roles_<tenant>backend/core/seeders/_roles_<tenant>.py):

  1. Re-apply каталогу (idempotent — нічого не змінює якщо Phase 0 уже застосував).
  2. Призначити tenant_admin bound superuser'у явним записом у UserRole. Це дублює auto-allow з is_tenant_admin=True, але робить роль видимою в UI як explicit assignment.
  3. Створити per-role demo-юзерів:
  4. eswf: melnyk, tkachenko, bondarenko (по одному на роль accountant+sales/support/engineer)
  5. dry_port: honchar, lytvyn (Person-linked) + gate, weigher, dispatcher, yard (standalone — не Persons, а демо-логіни для специфічних робочих місць)
  6. agro_holding: 8 логінів по юрособах + 2 standalone (gate_agro, weigher_agro — ворота на сило́сах і пекарні)
  7. Прив'язати кожного юзера до його ролі/-ей через assign_role(user, code).

Усі demo-юзери мають is_tenant_admin=False (крім тих 3 superuser'ів) — це навмисно, щоб через них тестувати реальну роботу HasRolePermission (короткозамикання адмінських прав не активне).

Структура файлів

backend/core/seeders/
├── _roles_eswf.py          ← seed_roles_eswf(tenant)
├── _roles_dry_port.py      ← seed_roles_dry_port(tenant)
└── _roles_agro_holding.py  ← seed_roles_agro_holding(tenant)

Кожен файл містить: - список PERSON_ROLE_USERS(username, last_name, role_codes, position) для юзерів, прив'язаних до існуючих Person - список STANDALONE_USERS — для логінів-без-Person (наприклад gate operators) - функцію seed_roles_<tenant>(tenant, log=print) — idempotent

Повний список логінів

Усі — пароль admin. Деталі (роль → user → що бачить) — у docs/eswf/permissions/demo-users.md.

Tenant Superuser + Demo-юзери
eswf eswf (Ніколаєнко) melnyk, tkachenko, bondarenko
dry_port port (Бойко) honchar, lytvyn, gate, weigher, dispatcher, yard
agro_holding admin (Петренко) koval, sydorenko, bilous, havryliuk, kravets, oliynyk, bulkina, gate_agro, weigher_agro

Зв'язок із системою прав

  • Каталог ролей у кодіbackend/core/services/role_catalog.py
  • Документація правdocs/eswf/permissions/
  • Як перевірити що залогінений юзер бачить — UI: Admin Tools → Roles Matrix; CLI: python manage.py shell + JWT через RefreshToken.for_user(u)
  • Як змінити права через UI — Admin Tools → Roles Matrix (працює per tenant, не редагує Python-каталог)
  • Як відкотити правки до коду — там же, "Скинути до каталогу" (викликає apply_system_roles(tenant))

Чек-ліст для нової extras_<tenant> фази (наприклад майбутній 4-й тенант)

При додаванні нового тенанта: 1. Створити _roles_<tenant>.py за патерном існуючих 2. У <tenant>_extras.py додати self._safe("roles", self._seed_roles) як остання дія run() (після hr_payroll, fixed_assets, journal_entry) 3. Завести _seed_roles метод що імпортує і викликає seed_roles_<tenant> 4. Тест-залогінитись новим юзером, переконатись що useFilteredSections показує очікуваний sidebar

Якщо не потрібно роздавати ролі (мінімальний кейс)

Якщо новий тенант — суто адмін-демо (тільки superuser, без додаткових юзерів): - В extras_<tenant> пропустити _seed_roles (або викликати з STANDALONE_USERS=[], PERSON_ROLE_USERS=[]) - Phase 0 все одно застосує каталог → 14 ролей будуть у БД, готові до призначення через UI


4. Часовий діапазон даних

  • Opening balances: 2025-12-31 (делеговано в init_accounting_data._seed_document_operations per tenant).
  • Робочі транзакції: 2026-01-01 → 2026-05-06 (поточна дата).
  • GPS snapshots: останні 7 діб до поточної дати (комбайни ZP + Fleet AT + yard tractors SP).
  • Period для майбутніх запусків: seed_demo приймає --end-date YYYY-MM-DD (default = today).

Це дає достатньо даних для: - P&L per tenant за 4+ повних місяці - Consolidated P&L agro_holding за той самий період - Cash Flow з реальним рухом per tenant - Trial Balance як знімок per tenant - Payment Calendar з планованими платежами на травень-червень - PartyLedger running balance для всіх клієнтів (internal + cross-tenant + external)


5. Контракт ідемпотентності

Кожен sub-seeder зобов'язаний:

  1. Шукати по природному ключу перед створенням:
  2. Tenantcode ('eswf', 'dry_port', 'agro_holding')
  3. Organization(tenant, code) (де code — формат 000000001, рестартує per tenant)
  4. Client(tenant, code) (формат 000000001)
  5. Item(tenant, code) (формат 000000001)
  6. Invoice / Waybill / ShopOrder(tenant, model_class, number) у форматі <XX-prefix><7-digits> (див. §2-ter)
  7. ChartOfAccounts(tenant, code) (PCG номер)
  8. BusinessOperation(tenant, name)
  9. Currency / Bank / Unit → не створюються вручну, тільки через load_*_classifier (§2-bis)

  10. Якщо знайдено — оновити поля з patch-логікою (не перетирати ID, FK, історію):

    obj, created = Model.objects.get_or_create(
        tenant=tenant, code=code,
        defaults={...},
    )
    if not created:
        for attr, val in [...]:
            if getattr(obj, attr) != val:
                setattr(obj, attr, val)
        obj.save()
    

  11. Не торкатись transactional state при повторному запуску (статуси документів, проводки в журналі, GPS snapshots) — окрім випадку --reset-transactions, який чистить документи + переграє автопроводки. Reset діє per tenant (не torkає інших).

  12. Логувати кожне створення / пропуск через self.stdout.write з префіксами [tenant=<code>][created], [tenant=<code>][updated], [tenant=<code>][skip].

  13. Cross-tenant зв'язки: при створенні mirror-документа в target tenant обов'язково проставити external_reference = number документа з source tenant. Це дозволяє ідемпотентний replay і readable diagnostics.


6. Прапорці CLI

Поточний реєстр фаз (seed_demo.PHASES у core/management/commands/seed_demo.py):

Tenant Ключ Статус Що робить
eswf base_eswf ✓ implemented 1 org TOV ESWF Software, 7 модулів, 5 cross-tenant mirrors
eswf case_es_modules ✓ implemented 16 SalesInvoices модулів 5 зовнішнім клієнтам + payments
eswf extras_eswf ✓ implemented Chat, BAF Sync, HR/Payroll (4 employees), FixedAssets (4), JE
dry_port base_dry_port ✓ implemented 1 org Sukhyi Port, 6 terminal-services items, 8 clients
dry_port case_terminal_ops ✓ implemented seed_containerhub delegate (12 types, 10 lines, 182 containers)
dry_port case_grain_export ✓ implemented 12 ServiceInvoices до ZP/OP/AT (cross-tenant flow)
dry_port extras_dry_port ✓ implemented Chat, BAF Sync, HR/Payroll (8), FixedAssets (5), Quality, JE
agro_holding base_agro ✓ implemented 4 orgs (ZP/OP/AT/PK), 14 items, 12 clients (6 internal + cross + external)
agro_holding case_grower ✓ implemented Зерноплюс — 9 SalesInvoices зерна + 1 PurchaseInvoice
agro_holding case_processor ✓ implemented ОлійПром — 8 SalesInvoices олії/борошна/шроту/висівок
agro_holding case_carrier ✓ implemented АгроТранс — 13 TransportInvoices до ZP/OP/PK + DryPort
agro_holding case_bakery ✓ implemented ФОП Пекар — 8 sales до ритейл-мереж + 4 purchases
agro_holding case_external ✓ implemented 5 inline external items + 12 ESWF mirrors + 3 DryPort mirrors
agro_holding case_agro_crm ✓ implemented Cross-org CRM funnel — 12 leads, 7 contacts, 24 deals (Won/Lost/Open mix), 50 pipeline-history entries, 95 activities (4 managers), 5 quotations, 3 sales orders. Drives all CRM dashboards (Funnel/Win-Loss KPI/Stage Duration/Manager Activity/Sales Actions/Deals at Risk). Self-bootstraps demo manager users.
agro_holding extras_agro ✓ implemented Chat, HR/Payroll (17), FixedAssets (10), Quality, Budget, Consolidation, JE
# Найчастіші сценарії
python manage.py seed_demo                                       # повний знімок (всі 3 tenants, всі implemented фази)
python manage.py seed_demo --reset                               # знести 3 demo tenants і створити з нуля
python manage.py seed_demo --only-tenant=agro_holding            # тільки агрохолдинг (всі його фази)
python manage.py seed_demo --only-tenant=eswf,dry_port           # без агрохолдингу
python manage.py seed_demo --only=base_agro,case_grower          # тільки конкретні фази
python manage.py seed_demo --skip=extras_agro,extras_eswf        # без extras для двох tenants
python manage.py seed_demo --reset-tenant=dry_port               # перерахувати лише один tenant

# Допоміжні
python manage.py seed_demo --list                                # перелік фаз з маркером ✓/·, груповано по tenant
python manage.py seed_demo --list --tenant=agro_holding          # фази тільки для одного tenant

Default users: три superuser-и, по одному на tenant — admin / admin (agro_holding), eswf / admin (eswf), port / admin (dry_port). Перемикання між tenants — через logout + login з іншою парою (in-app switcher не реалізовано — User.tenant рулить). Логіка у _ensure_demo_users — якщо користувач вже існує, пароль перепризначається; is_superuser=True, is_staff=True, is_tenant_admin=True форсуються щоразу.

Важливо про --reset: чистить усі три demo tenants (eswf, dry_port, agro_holding) через _purge_tenant_data. Інші tenants (наприклад, ваш робочий my_company) не торкаються. Для скидання лише одного — --reset-tenant=<code>.


7. Як додати новий бізнес-кейс (нову компанію / новий модуль / новий tenant)

Чек-ліст:

  1. Оновити цей документ — визначити, до якого tenant додаємо (чи створюємо новий tenant). Додати рядок у §2-ter (нова Organization з префіксом), §3 (новий блок або рядок у блоці tenant), фразу в §2 (новий метод чи Phase у дереві).
  2. Якщо новий tenant — описати в §3 окремий блок з business-model, organizations, document flow, cross-tenant relations.
  3. Вибрати місце для sub-seeder:
  4. Модуль має власний сюжетнова фаза: sub-seeder core/seeders/<tenant>_case_<name>.py з класом <Tenant><Name>CaseSeeder(BaseSeeder), зареєстрована у PHASES в seed_demo.py під відповідним tenant.
  5. Модуль не має окремого сюжету, але потрібен мінімум данихметод у <Tenant>ExtrasSeeder: _seed_<module> у core/seeders/_case_extras.py.
  6. Модуль вже має власну seed_* management-команду (seed_containerhub, seed_gps_data) → call_command("seed_<name>", tenant=tenant.id, verbosity=0) у _seed_<module> методі.
  7. Зареєструвати:
  8. Нову фазу — додати рядок у PHASES у seed_demo.pytenant_key) + гілку в _run_phase().
  9. Новий метод у <Tenant>ExtrasSeeder — додати виклик self._safe("<label>", self._seed_<module>) у run().
  10. Гарантувати ідемпотентність (див. §5). У <Tenant>ExtrasSeeder кожен метод вже обгорнутий у _safe() → власну atomic transaction → якщо щось падає, інші модулі не ламаються.
  11. Дотриматись локалізаційного правила (§2-quater) — для кожної нової сутності з парою name/name_ua явно визначити, чи це загальна номенклатура (перекладаємо), чи власна назва (транслітеруємо). 6-bis. Двомовний description (§2-quinquies) — через self.describe(en, ua). Перевірити що обидва рядки непорожні та унікальні.
  12. Прописати cross-org / cross-tenant зв'язки — в коментарях вказати, як цей кейс взаємодіє з вже існуючими (хто кому виставляє рахунки, хто чий клієнт, mirrors у яких tenants).
  13. Перебудувати Docker образ (див. §8).

Правило приналежності: окрема фаза чи метод в extras?

Критерій Окрема фаза (case_X) Метод у <Tenant>ExtrasSeeder
Сюжет/сценарій Так (виробництво → продажі → оплата → consolidation) Ні — просто «заповнити щоб показати»
Обсяг даних 30+ записів з різними статусами 3-10 записів, по 1-2 статуси
Cross-org / cross-tenant зв'язки Задіюються 2+ Organizations або 2+ Tenants Переважно на одній org
Потребує власного orchestration Так (як case_processor — purchase → BOM → workorder → goods → sale) Ні — одна-дві моделі

Правило: окремий tenant чи нова organization у наявному?

Критерій Новий tenant Нова organization у наявному tenant
Контекст бізнесу Інший власник, інший CoA, окремі звіти Той самий власник (як холдинг)
Доступ користувачів Доступ ізольований (різні юзери з різних компаній) Спільний admin/staff
Потреба у consolidation Ні (справді окремі бізнеси) Так (intercompany elimination)
Шкала Цілий ринок/галузь 4-10 organizations максимум на один tenant

Приклад — чому FOP Pekar і Zernoplyus в одному tenant, а ESWF — в окремому: пекарня і виробник зерна належать одному холдингу (consolidated reporting, спільний admin); ESWF — це постачальник, не частина холдингу.


8. Як перебудувати Docker-образ після зміни seed

Один скрипт-команда — scripts/build-demo-image.js:

node scripts/build-demo-image.js
# або з тегом версії:
node scripts/build-demo-image.js --tag=v2026.05.06

Що відбувається всередині:

  1. Чиста БД: drop & create database.
  2. Migrate: python manage.py migrate --noinput.
  3. Seed: python manage.py seed_demo --reset (всі 3 tenants).
  4. Build ERP: cd frontend/erp && npx vite build.
  5. Docker build: docker build -f Dockerfile.demo -t eswf-demo:<tag> ..
  6. Export: docker save eswf-demo:<tag> | gzip > dist/eswf-demo-<tag>.tar.gz.
  7. Прибирання: docker system prune -f для звільнення локального диску.

Розмір очікуваний: 250-400MB у .tar.gz (через 3 tenants з повним seed). Файл готовий до відправки.


9. Як колега запускає образ

Тільки 3 команди (з README.colleague.md що йде разом з .tar):

docker load -i eswf-demo-<tag>.tar.gz
docker run -d -p 8080:80 --name eswf-demo eswf-demo:<tag>
# відкрити http://localhost:8080 і залогінитись:
#   admin / admin → Agro Holding "АгроМрія" (4 organizations)
#   eswf  / admin → ESWF Software (DOP vendor)
#   port  / admin → Sukhyi Port Kyiv (container terminal)
# Перехід між tenants — через logout + login (in-app switcher не реалізовано).

Зупинити: docker stop eswf-demo. Видалити: docker rm eswf-demo && docker rmi eswf-demo:<tag>.

RAM під час роботи: ~300-400MB (Daphne + nginx + supervisord + Postgres з 3 tenants). Вписується у 1GB вільного.


10. Дорожня карта seed-методики

Хвиля Що додаємо Статус
Wave 1 (legacy) Один tenant main з 6 organizations: base, case_a, case_b, case_d, inventory, extras. Legacy code лишається в репо як reference (demo_base.py, demo_case_*.py), але з PHASES виключено. ✓ done (історичний)
Wave 2 — Multi-tenant migration 14 фаз через 3 tenants з повноцінними посталеними документами і Consolidation:
• Phase 0 — _ensure_tenants + _ensure_demo_users (3 superuser логіни)
EswfBaseSeeder / DryPortBaseSeeder / AgroBaseSeeder
• 5 case-фаз внутрішніх потоків (case_es_modules, case_grower/processor/carrier/bakery)
• 3 cross-tenant case-фази (case_external, case_grain_export, case_terminal_ops)
• 3 extras-фази (chat, baf, hr/payroll, fixed_assets, quality, budget, consolidation, JE)
--only-tenant, --reset-tenant, --list --tenant=<code> CLI прапорці
• Перевірено повний seed_demo --reset + ідемпотентний re-run
✓ done (2026-05-06)
Wave 3 case_vbs (eTTN B→B→C тристороння схема — на tenant_agro_holding як натуральна площадка) + case_e (Logistic plugin multimodal forwarding — на tenant_dry_port) + Docker pack автоматизація. ⏳ planned

Майбутнє покращення (backlog): - 🔮 Snapshot-режим: експорт поточного стану БД → reusable fixture per tenant, замість повного re-seed щоразу. - 🔮 N-tenant scale тест: seed_demo --duplicate-tenant=agro_holding --copies=10 → симулювати 10 агрохолдингів для нагрузочного тесту RLS і tenant isolation. - 🔮 Random-noise добавки: опційне --noise=10x --tenant=<code> — генерує додаткові «шумові» транзакції для UI-тестування з великим обсягом. - 🔮 Локалізовані варіанти: seed_demo --locale=ua додає документи з українськими описами, --locale=en — англійськими (поверх двомовного обов'язкового мінімуму з §2-quinquies). - 🔮 Часові ряди для дашбордів: окремий seed_demo_timeseries що генерує погодинні / денні snapshot-и для перевірки real-time графіків. - 🔮 Real-RLS smoke-test: автоматичний pytest що під 3 окремих юзерів (по одному на tenant) перевіряє відсутність витоку даних між tenants.


Пов'язане