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

Accounting & Tax — архітектура обліку

Огляд

ESWF використовує підхід Фінансових аналітик (Financial Dimensions) до облікової аналітики, натхненний Microsoft Dynamics 365 Finance & Operations. Це замінює застарілу модель "субконто" у стилі 1С, де кожен рахунок диктував, які аналітики обов'язкові.

Ключові принципи: - Подвійний запис через PostingGroup + PostingEntry - Аналітики глобальні — усі 8 аналітик доступні на кожному записі - Документ вирішує, які аналітики заповнювати, а не рахунок - Без валідації на рівні рахунку щодо обов'язкових аналітик (можна додати пізніше через Account Structures)


1. План рахунків

Модель: essentials.ChartOfAccounts
Наслідує: HierarchicalMasterDataModel (code, name, parent, is_group)

ChartOfAccounts
├── code             CharField     — номер рахунку (напр. "281", "631", "361")
├── name             CharField     — назва рахунку
├── parent           FK(self)      — ієрархія (групові рахунки)
├── is_group         Boolean       — група чи аналітичний
├── account_type     Choices       — активний / пасивний / активно-пасивний
├── allow_currency   Boolean       — мультивалютний облік
└── allow_quantity   Boolean       — кількісний облік (для складських рахунків)

Проєктні рішення

План рахунків є суто структурним — визначає ієрархію рахунків, тип та прапорці валюти/кількості. Він НЕ визначає, які аналітики обов'язкові для проводок. Це свідомий відхід від 1С, де прапорці requires_partner, requires_product, requires_project на рахунку диктували, які дані потрібно надати при проведенні.

Чому без прапорців обов'язкових аналітик?

Система Аналітики прив'язані до Обмеження
1С Рахунку (3 фіксовані субконто) Максимум 3 аналітики, рахунок мусить знати всі сценарії
SAP Типу документа / модуля Різні модулі = різні аналітики
ESWF Контексту документа Документ найкраще знає свої дані
D365 F&O Глобальні + Account Structures Найгнучкіший, валідація — окремий шар

На практиці, Прибуткова накладна знає що має склад, номенклатуру, контрагента, договір — і передає все. Видатковий платіж має лише контрагента та статтю витрат — передає їх. Рахунку не потрібно декларувати, що він очікує.


2. Господарська операція

Модель: essentials.BusinessOperation
Наслідує: MasterDataModel (code, name)

BusinessOperation
├── debit_account      FK(ChartOfAccounts)  — рахунок дебету
├── credit_account     FK(ChartOfAccounts)  — рахунок кредиту
└── vat_debit_account  FK(ChartOfAccounts)  — рахунок дебету ПДВ (необов'язково)

Господарська операція — це шаблон, який зіставляє бізнес-подію з бухгалтерським записом. Визначає ЯКІ рахунки дебетувати і кредитувати, але не аналітику.

Приклади

Господарська операція Дт рахунок Кт рахунок ПДВ Дт
Надходження товарів (закупівля) 281 Товари 631 Постачальники 641 ПДВ
Оплата від покупця 311 Каса 361 Покупці —
Витрати на зарплату 92 Адмін. витрати 661 Зарплата —

Документ (GoodsReceipt, IncomingPayment тощо) посилається на business_operation, яка визначає відповідність рахунків.


3. Моделі проводок — структура БД

3.1 PostingGroup (Група проводок)

Модель: essentials.PostingGroup
Наслідує: RegisterModel (tenant, date, document_type, document_id, manual_edit)

PostingGroup
├── [Поля RegisterModel]
│   ├── tenant           FK(Tenant)
│   ├── date             DateField
│   ├── document_type    CharField    — напр. "GoodsReceipt", "IncomingPayment"
│   ├── document_id      BigInt       — PK документа-реєстратора
│   └── manual_edit      Boolean      — захист від автоматичного видалення
├── description          TextField    — опис операції
├── organization         FK(Organization)
├── currency             FK(Currency)
└── amount               Decimal(15,2) — загальна сума операції

Одна PostingGroup = одна бухгалтерська операція. Документ може створити кілька груп (напр. одна на рядок для прибуткової накладної, або окремі групи для суми та ПДВ).

Індекс: (document_type, document_id) для швидкого пошуку всіх проводок документа.

3.2 PostingEntry (Запис проводки)

Модель: essentials.PostingEntry
Наслідує: TenantAwareModel (tenant, uuid, created_at тощо)

PostingEntry
├── group              FK(PostingGroup)  — CASCADE видалення
├── account            FK(ChartOfAccounts) — PROTECT
├── debit              Decimal(15,2)     — >0 для дебету, 0 для кредиту
├── credit             Decimal(15,2)     — >0 для кредиту, 0 для дебету
│
│  ── Фінансові аналітики ──
├── partner            FK(Client)           — контрагент
├── product            FK(Item)             — номенклатура / товар
├── warehouse          FK(Warehouse)        — склад
├── contract           FK(Contract)         — договір
├── department         FK(Department)       — підрозділ / центр витрат
├── project            FK(BusinessDirection) — проєкт / напрямок діяльності
├── expense_item       FK(ExpenseItem)      — стаття витрат
└── person             FK(Person)           — співробітник / відповідальна особа

Кожна PostingGroup має рівно 2 записи (стандартний подвійний запис) — один з debit > 0 та один з credit > 0. Складні операції (напр. сума + ПДВ) створюють кілька PostingGroup.

3.3 Діаграма зв'язків

Документ (GoodsReceipt, Invoice, ...)
    │
    │ post_document()
    ▼
PostingGroup  ←──────────  1 група на бухгалтерську операцію
    │                      (дата, посилання на документ, організація, валюта, сума)
    │
    ├── PostingEntry (Дт)  ← дебетова сторона  (рахунок, debit=сума, аналітики...)
    └── PostingEntry (Кт)  ← кредитова сторона (рахунок, credit=сума, аналітики...)

3.4 Приклад: проводки прибуткової накладної

Документ: Прибуткова накладна #15, постачальник "Будмат", склад "Основний", 2 рядки.

PostingGroup #42
  date=2026-04-08, doc=GoodsReceipt/15, amount=10000
  description="Надходження #15 — Цемент [Закупівля товарів]"
  ├── PostingEntry: Дт 281 "Товари"        debit=10000
  │     partner=Будмат, product=Цемент, warehouse=Основний, contract=Договір №1
  └── PostingEntry: Кт 631 "Постачальники" credit=10000
        partner=Будмат, contract=Договір №1

PostingGroup #43
  date=2026-04-08, doc=GoodsReceipt/15, amount=2000
  description="ПДВ по надходженню #15 — Цемент"
  ├── PostingEntry: Дт 641 "ПДВ"           debit=2000
  │     partner=Будмат, contract=Договір №1
  └── PostingEntry: Кт 631 "Постачальники" credit=2000
        partner=Будмат, contract=Договір №1

PostingGroup #44
  date=2026-04-08, doc=GoodsReceipt/15, amount=5000
  description="Надходження #15 — Фарба [Закупівля товарів]"
  ├── PostingEntry: Дт 281 "Товари"        debit=5000
  │     partner=Будмат, product=Фарба, warehouse=Основний, contract=Договір №1
  └── PostingEntry: Кт 631 "Постачальники" credit=5000
        partner=Будмат, contract=Договір №1

4. Фінансові аналітики — підхід

4.1 Вісім аналітик

# Аналітика Модель Типове використання
1 partner Client Контрагент (постачальник, покупець)
2 product Item Номенклатура, товари, матеріали
3 warehouse Warehouse Склад, місце зберігання
4 contract Contract Договір з контрагентом
5 department Department Підрозділ, центр витрат
6 project BusinessDirection Проєкт, напрямок діяльності
7 expense_item ExpenseItem Стаття витрат/доходів
8 person Person Співробітник, відповідальна особа

4.2 Як аналітики заповнюються

Документ (а не рахунок) вирішує, які аналітики надати:

Тип документа Надані аналітики
Прибуткова накладна partner, product, warehouse, contract
Видаткова накладна partner, product, warehouse
Прибутковий платіж partner
Видатковий платіж partner, expense_item
Операція (ручна) partner (по рядку)
Нарахування зарплати person, department

4.3 Порівняння з іншими системами

Підхід 1С (замінений):

ПланРахунків:
  requires_partner = True    ← рахунок диктує
  requires_product = True
  requires_project = False

ЗаписПроводки:
  partner = валідація(обов'язково по рахунку)
  product = валідація(обов'язково по рахунку)
  project = пропуск (не обов'язково)
Проблема: Рахунок 631 "Постачальники" має requires_partner=True, а що з contract? Потрібно додати 4-й прапорець. А warehouse? 5-й. Рахунок не може передбачити контекст кожного документа.

Підхід ESWF (поточний):

ПланРахунків:
  (без прапорців аналітики — лише структура)

ЗаписПроводки:
  partner = document.supplier     ← документ надає контекст
  product = line.item
  warehouse = document.warehouse
  contract = document.contract
  department = null               ← не стосується цього документа
  project = null
  expense_item = null
  person = null

Аналітики — це nullable FK-колонки в широкій таблиці. Невикористані аналітики просто NULL. Жодних помилок валідації, жодних пропущених даних, жодної конфігурації на рахунку.

4.4 Аналітики по сторонах (Дт/Кт)

Не всі аналітики однаково стосуються дебетової та кредитової сторони. Наприклад, при надходженні товарів: - Дт 3700 Товари: partner, product, warehouse, contract — товар і склад стосуються саме цього рахунку - Кт 4000 Постачальники: partner, contract — постачальнику не потрібна номенклатура і склад

AccountingService підтримує це через розділення аналітик: - Спільні (partner, contract, department, project, expense_item, person) — записуються на обидві сторони - По стороні (debit_dims, credit_dims) — записуються тільки на відповідну сторону - product та warehouse за замовчуванням НЕ додаються на обидві сторони — їх потрібно явно передати через debit_dims або credit_dims


5. API-ендпоінти

5.1 Групи проводок (вкладені)

GET /api/v1/essentials/journals/postings/

Повертає PostingGroup із вкладеними записами:

{
  "count": 42,
  "results": [
    {
      "id": 1,
      "date": "2026-04-08",
      "document_type": "GoodsReceipt",
      "document_id": 15,
      "description": "Надходження #15 — Цемент",
      "organization_name": "ESWF LLC",
      "currency_code": "UAH",
      "amount": "10000.00",
      "entries": [
        {
          "id": 1,
          "account_display": "281 Товари",
          "debit": "10000.00",
          "credit": "0.00",
          "partner_name": "Будмат",
          "product_name": "Цемент",
          "warehouse_name": "Основний склад",
          "contract_name": "",
          "department_name": "",
          "project_name": "",
          "expense_item_name": "",
          "person_name": ""
        },
        {
          "id": 2,
          "account_display": "631 Постачальники",
          "debit": "0.00",
          "credit": "10000.00",
          "partner_name": "Будмат",
          "product_name": "Цемент",
          "warehouse_name": "Основний склад",
          ...
        }
      ]
    }
  ]
}

5.2 Плоский список записів (для RegisterRecordsPage)

GET /api/v1/essentials/journals/postings/flat/
GET /api/v1/essentials/journals/postings/flat/?document_type=GoodsReceipt&document_id=15

Повертає один рядок на PostingEntry з контекстом групи:

{
  "count": 4,
  "results": [
    {
      "id": 1,
      "group_id": 42,
      "date": "2026-04-08",
      "document_type": "GoodsReceipt",
      "document_id": 15,
      "description": "Надходження #15 — Цемент",
      "account_display": "281 Товари",
      "debit": "10000.00",
      "credit": "0.00",
      "partner_name": "Будмат",
      "product_name": "Цемент",
      "warehouse_name": "Основний склад",
      ...
    }
  ]
}

Фільтри: date__gte, date__lte, document_type, document_id, organization, search.

5.3 Парний (згорнутий) режим на фронтенді

Фронтенд RegisterRecordsPage підтримує перемикач парного режиму, що згортає Дт/Кт записи в один рядок за group_id:

Дата Дт рахунок Кт рахунок Сума Контрагент Номенклатура
2026-04-08 281 Товари 631 Постачальники 10 000,00 Будмат Цемент
2026-04-08 641 ПДВ 631 Постачальники 2 000,00 Будмат Цемент

Парування 100% надійне, бо використовує group_id (FK на PostingGroup), а не евристику.


6. Як документи створюють проводки

6.1 Через AccountingService (рекомендовано)

Для документів із полем business_operation:

from essentials.services.accounting_service import post_accounting_entry

post_accounting_entry(
    tenant=obj.tenant,
    document=obj,
    business_operation=obj.business_operation,
    amount=Decimal(str(line.amount)),
    description=f'Надходження #{obj.number} — {line.item}',
    # Рівень групи
    organization=obj.organization,
    currency=obj.currency,
    # Спільні аналітики — на обидві сторони
    partner=obj.supplier,
    contract=obj.contract,
    # Аналітики тільки для дебету (товар і склад — лише на рахунку товарів)
    debit_dims=dict(product=line.item, warehouse=obj.warehouse),
)

Це створює: - 1 PostingGroup (дата, посилання на документ, організація, валюта, сума, опис) - PostingEntry Дт: partner + contract + product + warehouse - PostingEntry Кт: partner + contract (без product/warehouse)

6.2 Через прямий bulk create (DocumentOperation)

Для багаторядкових документів, що потребують детального контролю:

from essentials.models.posting import PostingGroup, PostingEntry

groups = PostingGroup.objects.bulk_create([...])
entries = []
for group, line in zip(groups, lines):
    dims = dict(partner=line.client)
    entries.append(PostingEntry(
        tenant=tenant, group=group, account=line.debit_account,
        debit=line.amount, credit=0, **dims,
    ))
    entries.append(PostingEntry(
        tenant=tenant, group=group, account=line.credit_account,
        debit=0, credit=line.amount, **dims,
    ))
PostingEntry.objects.bulk_create(entries)

6.3 Скасування проведення (відміна)

from essentials.services.accounting_service import reverse_accounting_entries

reverse_accounting_entries(document)
# Видаляє всі PostingGroup документа (CASCADE видаляє PostingEntry)

Або напряму:

PostingGroup.objects.filter(
    tenant=obj.tenant,
    document_type='GoodsReceipt',
    document_id=obj.id,
).delete()  # CASCADE видаляє записи


7. Масштабування аналітики — додавання нових аналітик

Коли потрібна нова аналітика (напр. vehicle для обліку автопарку):

Крок 1: Додати FK до PostingEntry

# essentials/models/posting.py — клас PostingEntry
vehicle = models.ForeignKey(
    'fleet.Vehicle', on_delete=models.SET_NULL, null=True, blank=True,
    related_name='posting_entries',
)

Крок 2: Створити та застосувати міграцію

cd backend
python manage.py makemigrations essentials
python manage.py migrate

Крок 3: Оновити серіалізатори

# essentials/serializers/posting.py — PostingEntrySerializer
vehicle_name = serializers.CharField(source='vehicle.name', read_only=True, default='')

Також додати vehicle_name до PostingEntryFlatSerializer.

Крок 4: Оновити prefetch у flat view

# essentials/views/posting.py
_ENTRY_PREFETCHES = [
    ...,
    'entries__vehicle',  # додати новий зв'язок
]

Додати поле до словника rows.append(...) в екшені flat():

'vehicle_name': entry.vehicle.name if entry.vehicle else '',

Крок 5: Передати аналітику з коду проведення документа

# У методі post_document() ViewSet
post_accounting_entry(
    ...,
    vehicle=obj.vehicle,  # додати нову аналітику
)

Крок 6: Оновити сигнатуру accounting_service.py

def post_accounting_entry(
    *,
    ...,
    vehicle=None,    # додати новий kwarg
):
    dimensions = dict(
        ...,
        vehicle=vehicle,
    )

Крок 7: Оновити конфіг колонок на фронтенді

// RegisterRecordsPage.tsx — REGISTER_COL_ORDER
postingGroups: [
    ...,
    'vehicle_name',
],

// COL_LABELS
vehicle_name: { en: 'Vehicle', ua: 'ТЗ' },

Підсумок: що потрібно змінити при додаванні аналітики

Шар Файл Зміна
Модель models/posting.py Додати FK до PostingEntry
Міграція makemigrations + migrate Автоматично згенеровано
Серіалізатор serializers/posting.py Додати _name поле до обох серіалізаторів
В'юха views/posting.py Додати до prefetch + словника flat
Сервіс services/accounting_service.py Додати kwarg + додати до dimensions
Документ views/transactions.py Передати аналітику у виклику post_accounting_entry()
Фронтенд RegisterRecordsPage.tsx Додати до REGISTER_COL_ORDER + COL_LABELS

8. Майбутнє: Account Structures (необов'язковий шар валідації)

Поточна архітектура навмисно не має валідації обов'язкових аналітик по рахунках. Це найпростіший коректний підхід — документ надає контекст, а невикористані аналітики = NULL.

Якщо в майбутньому потрібна суворіша валідація, можна додати модель Account Structure:

class AccountStructure(TenantAwareModel):
    """Визначає обов'язкові/необов'язкові аналітики для групи рахунків."""
    name = CharField(max_length=255)
    accounts = ManyToManyField(ChartOfAccounts)

class AccountStructureDimension(TenantAwareModel):
    structure = FK(AccountStructure)
    dimension = CharField(choices=DIMENSION_CHOICES)  # 'partner', 'product', ...
    required = BooleanField(default=False)

Це дозволить правила на кшталт: - Рахунки 28x (запаси): product=обов'язково, warehouse=обов'язково - Рахунки 36x/63x (розрахунки): partner=обов'язково, contract=необов'язково - Рахунки 9x (витрати): expense_item=обов'язково, department=необов'язково

Функція post_accounting_entry() тоді валідуватиме аналітики відповідно до структури перед збереженням. Це підхід D365 F&O, і його можна додати без зміни існуючих моделей чи даних.


9. Примітки щодо міграції

Застаріла модель: IncomeExpenseJournal

Стара модель IncomeExpenseJournal досі існує в БД, але більше не записується. Вона зберігала кожну пару подвійного запису як 2 окремі рядки, зіставлені евристикою (дата + опис + сума). Старі дані залишаються доступними для читання:

GET /api/v1/essentials/journals/income-expense/

Модель, серіалізатор, ViewSet та URL будуть збережені, доки всі історичні дані не будуть мігровані або більше не будуть потрібні.