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 = пропуск (не обов'язково)
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 Групи проводок (вкладені)¶
Повертає 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: Створити та застосувати міграцію¶
Крок 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():
Крок 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 окремі рядки, зіставлені евристикою (дата + опис + сума). Старі дані залишаються доступними для читання:
Модель, серіалізатор, ViewSet та URL будуть збережені, доки всі історичні дані не будуть мігровані або більше не будуть потрібні.