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

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 на рахунку диктували, які дані потрібно надати при проведенні.

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

Система Аналітики прив'язані до Обмеження
Рахунку (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 будуть збережені, доки всі історичні дані не будуть мігровані або більше не будуть потрібні.