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

PartyLedger — розрахунки з контрагентами

Статус: ✅ реалізовано (2026-04-22) — заміна для декларованих ClientLedger / SupplierLedger

Коротко: один регістр замість двох. Відповідає на питання «скільки нам винен клієнт» і «скільки ми винні постачальнику» через фільтр direction.


1. Навіщо цей регістр

Без окремого регістра розрахунків бухгалтер змушений щоразу агрегувати Invoice + IncomingPayment (для клієнтів) або PurchaseInvoice + OutgoingPayment (для постачальників) — це дорогі SQL-запити з JOIN-ами, які не масштабуються на тисячі контрагентів.

Типові запити, на які відповідає PartyLedger:

  • 💰 «Скільки нам винен ТОВ "Альфа" станом на 30.04?» — один рядок з running_balance.
  • 📊 «Загальна дебіторка по всіх клієнтах» — SUM по остатнім рядкам кожного client_id.
  • 📈 «Aging 0-30 / 31-60 / 61-90 / 90+ днів» — BUCKETSUM за датою документа.
  • 🧾 «Які документи формують борг клієнта Х?» — drill-down по PartyLedger WHERE client=X.
  • 🕒 Графік зміни балансу в часі — running_balance відсортований по даті.

2. Модель (backend)

Файл: backend/registers/models.py

class PartyLedger(TenantAwareModel):
    DIRECTION_CHOICES = [
        ('receivable', 'Receivable (client debt to us)'),
        ('payable',    'Payable (our debt to supplier)'),
    ]

    direction        = models.CharField(max_length=20, choices=DIRECTION_CHOICES)
    client           = models.ForeignKey('essentials.Client', ...)   # той самий Client — є is_buyer / is_supplier
    organization     = models.ForeignKey('essentials.Organization', ...)
    currency         = models.ForeignKey('essentials.Currency', ...)
    date             = models.DateField(db_index=True)
    document_type    = models.CharField(max_length=50)   # 'Invoice' | 'IncomingPayment' | 'PurchaseInvoice' | 'OutgoingPayment'
    document_id      = models.PositiveIntegerField()
    debit            = models.DecimalField(max_digits=18, decimal_places=2, default=0)
    credit           = models.DecimalField(max_digits=18, decimal_places=2, default=0)
    running_balance  = models.DecimalField(max_digits=18, decimal_places=2, default=0)
    comment          = models.CharField(max_length=500, blank=True)

    class Meta:
        indexes = [
            models.Index(fields=['tenant', 'direction', 'client', 'date']),
            models.Index(fields=['tenant', 'document_type', 'document_id']),
        ]

Семантика дебету/кредиту:

Напрямок Документ Debit Credit Логіка
receivable Invoice (продаж) total_gross 0 клієнт тепер винен нам
receivable IncomingPayment 0 amount клієнт заплатив — борг зменшився
payable PurchaseInvoice (купівля) 0 total_amount ми тепер винні постачальнику
payable OutgoingPayment amount 0 ми заплатили — борг зменшився

running_balance завжди перераховується хронологічно (по date, id) після будь-якого post/unpost — функція recompute_running_balance(tenant, direction, client).


3. Автопостинг (signals)

Файл: backend/essentials/services/posting.py

Функції post_* / unpost_* викликаються з Django signals при збереженні / видаленні транзакційних документів:

Документ Signal post Signal unpost
Invoice (sales) post_invoice_party_ledger(invoice) unpost_invoice_party_ledger(invoice)
IncomingPayment post_incoming_payment_party_ledger(payment) unpost_incoming_payment_party_ledger(payment)
PurchaseInvoice post_purchase_invoice_party_ledger(pi) unpost_purchase_invoice_party_ledger(pi)
OutgoingPayment post_outgoing_payment_party_ledger(payment) unpost_outgoing_payment_party_ledger(payment)

Після кожного post/unpost викликається recompute_running_balance(...) для відповідного клієнта.

Idempotent: повторний post_* не створює дублікат — сигнал спершу видаляє існуючий запис по (document_type, document_id).


3-bis. Синхронізація з планом рахунків (PartyLedger ↔ PostingEntry)

✅ Оновлення 2026-04-22 — PartyLedger і проводки на рахунки плану рахунків (наприклад, 4100 — Customers, 4000/4010 — Suppliers, 5300/5310 — Cash у європейському PCG) завжди в синхроні.

Проблема до цього фіксу: post_*_party_ledger спрацьовував безумовно (навіть без business_operation на документі), а post_accounting_entry — тільки якщо документ мав явну BusinessOperation. Наслідок: баланс PartyLedger міг показувати 1200 грн дебіторки, а рахунок 4100 у Balance Sheet / Trial Balance / ПСБО Формі 1 — 0 грн.

Як вирішено: додано fallback-ланцюжок вибору BusinessOperation:

  1. document.business_operation — якщо явно встановлено на документі;
  2. EssentialsModuleSettings.default_*_operation — дефолт для тенанту.

Поля в EssentialsModuleSettings (налаштовуються адміністратором один раз):

Поле settings Для документа Очікувані рахунки (PCG)
default_sales_operation Invoice (продаж) Dt 4100 / Ct 7000
default_incoming_payment_operation IncomingPayment Dt 5310 / Ct 4100
default_purchase_operation PurchaseInvoice Dt 6xx / Ct 4000
default_outgoing_payment_operation OutgoingPayment Dt 4000 / Ct 5310

Якщо жодного BO не знайдено — PartyLedger все одно створюється (для операційного обліку), а проводка по плану рахунків просто пропускається з log-повідомленням. Це навмисний graceful degradation для історичних / seed-даних, де BO не встановлено.

Гарантія: коли default_sales_operation сконфігурованe, для Invoice на 1200 грн:

  • створюється PartyLedger(direction='receivable', debit=1200);
  • створюється PostingGroup + 2 PostingEntry: Dt 4100 1200 / Ct 7000 1200;
  • обидва сальдо по рахунку 4100 та PartyLedger — ідентичні.

Тест консистентності: TestPartyLedgerMatchesChartOfAccounts у test_party_ledger.py.

Чому це важливо: Balance Sheet / ПСБО Форма 1 / Trial Balance рахують з PostingEntry. Якщо PartyLedger і PostingEntry розсинхронізовані — офіційний звіт брехатиме. Тепер — одне джерело правди.


4. API

4.1. Звіт залишків (/report/)

GET /api/v1/registers/ledgers/party/report/?direction=receivable&as_of=2026-04-30

Повертає сумарний баланс по кожному контрагенту на дату as_of.

Параметри: | Параметр | Обов'язково | Опис | |----------|-------------|------| | direction | ✅ | receivable / payable | | as_of | ⏳ (default: today) | дата зрізу | | client | ⏳ | фільтр по одному клієнту | | organization | ⏳ | фільтр по організації |

Відповідь:

{
  "direction": "receivable",
  "as_of": "2026-04-30",
  "count": 12,
  "total_balance": "45823.00",
  "rows": [
    {
      "client_id": 42,
      "client_name": "ТОВ Альфа",
      "organization_id": 1,
      "balance": "1200.00",
      "last_operation_date": "2026-04-15"
    },
    ...
  ]
}

4.2. Drill-down по документах (/by-document/)

GET /api/v1/registers/ledgers/party/by-document/?direction=receivable&client=42

Повертає хронологічний список усіх документів конкретного клієнта з дебет/кредит/running_balance.

Відповідь:

{
  "client_id": 42,
  "client_name": "ТОВ Альфа",
  "direction": "receivable",
  "entries": [
    {
      "date": "2026-04-10",
      "document_type": "Invoice",
      "document_id": 123,
      "document_number": "INV-123",
      "debit": "1000.00",
      "credit": "0.00",
      "running_balance": "1000.00"
    },
    {
      "date": "2026-04-15",
      "document_type": "IncomingPayment",
      "document_id": 456,
      "document_number": "IP-456",
      "debit": "0.00",
      "credit": "300.00",
      "running_balance": "700.00"
    }
  ]
}


5. Frontend

Компонент: PartyLedgerReport.tsx

Розташування у sidebar: Essentials → Фінансові звіти → Розрахунки з контрагентами.

UI: - SegmentedControl — перемикання Дебіторка / Кредиторка (direction). - Поле дати — фільтр as_of. - Таблиця залишків — сума, остання операція. - Клік по рядку → панель drill-down з хронологією документів. - Клік по документу → перехід до його форми (через DOC_ROUTE мапу).


6. Тести

Файл: backend/essentials/tests/test_party_ledger.py — 12 тестів.

Тест Що перевіряє
TestInvoicePartyLedger.test_post_creates_receivable_debit Invoice створює рядок з Debit = total_gross
TestInvoicePartyLedger.test_unpost_removes_row Unpost ідемпотентно видаляє запис
TestIncomingPaymentPartyLedger.test_post_creates_receivable_credit Оплата створює рядок з Credit = amount
TestReceivableRunningBalance.test_running_balance_chronological running_balance перераховується правильно: 1000 → 1500 → 800
TestPurchaseInvoicePartyLedger.test_post_creates_payable_credit Закупівля створює payable з Credit
TestOutgoingPaymentPartyLedger.test_post_creates_payable_debit Оплата постачальнику — Debit
TestPartyLedgerReportEndpoint.test_valid_request_returns_200 Ендпоінт /report/ повертає валідні дані
TestPartyLedgerByDocumentEndpoint.test_returns_entries Ендпоінт /by-document/ повертає список
TestPartyLedgerMatchesChartOfAccounts.test_invoice_posts_to_party_ledger_and_chart_of_accounts sync PartyLedger ↔ PostingEntry на рахунку 4100 через default_sales_operation
TestPartyLedgerMatchesChartOfAccounts.test_unpost_clears_both_party_ledger_and_postings unpost очищає обидва регістри разом

Усі 12 тестів проходять (див. аудит 2026-04-21).


7. Чому один регістр, а не два

Історично (документація 2024): ClientLedger окремо, SupplierLedger окремо.

Реальність: 95% полів однакові, логіка посту однакова (лише знак інший), а клієнт може бути і покупцем, і постачальником одночасно (is_buyer=True, is_supplier=True).

Плюси об'єднання: - Один queryset + один serializer + один endpoint — ~40% менше коду. - Client залишається єдиною сутністю — не треба дублювати в Supplier. - Фільтр direction='receivable' дає той самий результат, що окремий ClientLedger. - Легше робити консолідовані звіти (сальдо клієнт-постачальник, коли одна сторона — обидва одночасно).


🔮 Deferred / Ideas

Aging Report (0-30 / 31-60 / 61-90 / 90+)

Мотивація: стандартний звіт для CFO — «який відсоток дебіторки простроченої». Чому відкладено: потрібен due_date у документі + bucket-агрегація. Trigger: запит клієнта з великою дебіторкою.

Currency-aware ledger

Мотивація: контракт у USD → payment теж USD — running_balance має бути у валюті документа, а не лише UAH. Чому відкладено: поточна реалізація агрегує у functional currency. Trigger: імпортер/експортер.

Автоматичне закриття (matching Invoice ↔ Payment)

Мотивація: бухгалтер хоче бачити, який рахунок яким платежем закритий. Чому відкладено: FIFO-matching потребує окремого регістру Settlement. Trigger: робота з великою кількістю часткових оплат.

Statement of account (акт звірки) PDF/email

Мотивація: юристу/бухгалтеру — документ на підпис клієнту. Чому відкладено: потрібен print template + signing flow. Trigger: B2B-операції з формальною звіркою.


Пов'язане