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

Purchase Invoice Module — Рахунок від постачальника

Документація модуля Purchase Invoice (рахунок від постачальника) у складі essentials — третьої сторони 3-way matching разом із PurchaseOrder та GoodsReceipt.


1. Призначення

Рахунок постачальника (PurchaseInvoice) — документ, з якого починається процес оплати вендору. Об’єднує три сторони закупівельного циклу:

PurchaseOrder ─┐
               ├─► PurchaseInvoice ─► OutgoingPayment
GoodsReceipt ──┘

Ключові інваріанти:

  1. 3-way match — при проведенні запускається matching_service.three_way_match(), який порівнює PO ↔ GR ↔ Invoice за кількістю, ціною та сумою.
  2. Payment block — при розбіжностях, що перевищують толеранси, виставляється payment_block=True, який блокує створення OutgoingPayment.
  3. Override — користувач із відповідними правами може зняти блокування з обов’язковою вказівкою причини; дія фіксується в аудиті.
  4. Payment status — агрегується з проведених OutgoingPayment через _recalc_invoice_payment_status() (у views/transactions.py).

Реалізує процеси: - Реєстрація вендор-рахунку (з прив’язкою до PO/GR або без); - Автоматична звірка 3-way match; - Kanban-воркфлоу платежу (draft → awaiting → blocked → approved → partially/paid); - Звіт розбіжностей за постачальниками.


2. Архітектура

2.1 Backend (Django)

Модель — backend/essentials/models/purchase_invoice.py

PurchaseInvoice(TransactionModel)
├── supplier, organization, contract, currency         # контрагенти
├── supplier_invoice_number, invoice_date, due_date    # дані вендора
├── purchase_order (FK → PurchaseOrder, optional)      # one-to-many
├── goods_receipts (M2M → GoodsReceipt)                # 0..n
├── total_amount, vat_total, paid_amount
├── matching_status {unmatched|matched|tolerance_warning|blocked|override_approved}
├── payment_block (bool)
├── payment_status  {unpaid|partially_paid|paid|cancelled}
├── override_reason, override_by, override_at          # аудит overriding'у
└── business_operation
    └── lines → PurchaseInvoiceLine
        ├── item, unit, quantity, price, amount
        ├── vat_rate, vat_amount, price_gross          # авторозрахунок у save()
        ├── source_po_line    → PurchaseOrderLine      # трасування до PO
        └── source_receipt_line → GoodsReceiptLine     # трасування до GR

save() у PurchaseInvoiceLine: - Перераховує amount, vat_amount, price_gross; - Викликає _recalc_invoice_totals() (агрегація на рівні PurchaseInvoice).

Міграції

  • 0059 — PurchaseOrder, GoodsReceipt.source_purchase_order, PurchaseOrderLine;
  • 0060PurchaseInvoice + PurchaseInvoiceLine, толеранси в EssentialsModuleSettings (qty/price %, absolute amount), OutgoingPayment.purchase_invoice, PlannedPaymentLine.purchase_invoice.

Сервіс — backend/essentials/services/matching_service.py

three_way_match(invoice, *, persist=True) -> MatchResult
├── _get_tolerances(tenant_id)           # EssentialsModuleSettings
├── _aggregate_po, _aggregate_grs, _aggregate_invoice   # по item_id
└── для кожного item_id:
    • Quantity check: Inv vs. GR (qty_tolerance_pct)
    • Price check:    Inv vs. PO (price_tolerance_pct)
    • Amount check:   Inv vs. PO (amount_tolerance abs)
    → Discrepancy(kind, severity, item, values, message)

override_payment_block(invoice, user, reason)
├── Валідація: тільки якщо payment_block=True
├── matching_status ← 'override_approved', payment_block=False
└── Заповнює override_by/at/reason

discrepancy_report(tenant_id, *, date_from, date_to, supplier_id)
└── Агрегація за постачальниками: {total_count, matched, warning, blocked, override, amounts}

ViewSet — backend/essentials/views/transactions.py:1459

PurchaseInvoiceViewSet (ModelViewSet + TenantFilterMixin)
├── GET  /essentials/purchase-invoices/                     # list
├── POST /essentials/purchase-invoices/                     # create
├── GET  /essentials/purchase-invoices/{id}/                # retrieve
├── PATCH ...                                                # update
├── DELETE ...                                               # destroy
├── @action GET  /form-refs/                      # довідники + next_number
├── @action GET  /{id}/form-data/                 # record + refs
├── @action POST /{id}/post_document/             # draft → posted + three_way_match()
├── @action POST /{id}/unpost_document/           # posted → draft (блок якщо є сплачені OutPay)
├── @action POST /{id}/run-matching/              # re-run match без зміни state
├── @action POST /{id}/override-block/            # зняти payment_block (reason required)
├── @action GET  /payment-kanban/                 # лейни для Kanban UI
└── @action GET  /discrepancy-report/             # агрегат для звіту розбіжностей

PurchaseInvoiceLineViewSet
└── Підтаблиця рядків (filterset: invoice)

URL-и — backend/essentials/urls.py:90

router.register('purchase-invoices', PurchaseInvoiceViewSet, basename='purchase-invoices')
router.register('purchase-invoice-lines', PurchaseInvoiceLineViewSet, basename='purchase-invoice-lines')

Серіалізатори — backend/essentials/serializers/transactions.py:150

  • PurchaseInvoiceLineSerializeramount/vat_amount/price_gross read-only (обчислюються в save()), VAT-валідація через _resolve_vat_rate + _get_organization_from_parent (для неплатників ПДВ).
  • PurchaseInvoiceSerializer — nested lines (read-only), display-поля: supplier_name, organization_name, contract_name, currency_code, purchase_order_number, goods_receipts_numbers, override_by_name.

2.2 Frontend (React + Mantine 8)

Розташування: frontend/erp/src/components/Essentials/PurchaseInvoice/

PurchaseInvoice/
├── PurchaseInvoicePage.tsx          # роутер: list | form | print
├── PurchaseInvoiceList.tsx          # таблиця з фільтрами (матчинг, state, period, search)
├── PurchaseInvoiceListToolbar.tsx   # тулбар списку + matchFilter menu
├── PurchaseInvoiceForm.tsx          # форма на DocumentFormShell
├── PurchaseInvoiceFormToolbar.tsx   # тулбар форми + меню «Звірка»
├── PurchaseInvoiceFields.tsx        # головні поля (Parties / Vendor Document)
├── PurchaseInvoiceSubtables.tsx     # рядки: inline-редагування + модалка
├── PurchasePaymentKanban.tsx        # Kanban-воркфлоу платежу (6 лейнів)
└── MatchingDiscrepancyReport.tsx    # звіт розбіжностей звірки

Ключові компоненти

Компонент Призначення
PurchaseInvoicePage Вхідна точка (роутер list/form/print).
PurchaseInvoiceList Список з колонками: state-icon, №, date, vendor №, supplier, PO, due, amount, currency, matching-badge, payment-badge; сортування, фільтри, context-меню документа.
PurchaseInvoiceListToolbar DocumentListToolbar + кастомний extraButtons з Menu фільтру matching_status.
PurchaseInvoiceForm Повна форма: бейджі (state, matching, payment, block), supplier-label, підсумки; PO-autofill при виборі; модалка override-reason.
PurchaseInvoiceFormToolbar Стандартні дії документа + меню «Звірка» (re-run, override).
PurchaseInvoiceFields Два fieldset-и: Parties (org/supplier/contract/currency), Vendor Document (№/дата постачальника, due_date, PO, business op., опис).
PurchaseInvoiceSubtables Рядки рахунку з inline-редагуванням (SubtablePanel) + модалка редагування рядка.
PurchasePaymentKanban 6 колонок: draft → awaiting_match → blocked → approved → partially_paid → paid. Картки клікабельні → форма рахунку.
MatchingDiscrepancyReport Таблиця агрегатів за постачальниками (matched/warning/blocked/override/amounts) + фільтри period/supplier.

Інтеграція з DocumentFormShell

PurchaseInvoiceForm використовує хук useDocumentForm з параметрами: - saveFields — список полів для PATCH (без computed: total_amount, vat_total, paid_amount, matching_status, payment_block, payment_status); - entityMap: { lines: 'purchaseInvoiceLines' } — маршрутизація підтаблиці на /purchase-invoice-lines/; - parentFkField: 'invoice' — FK від рядка до рахунку; - draftSubtables: ['lines'] — рядки зберігаються локально до першого save (then bulk-create); - preflightCheck — блокує post без жодного рядка; - allowEditWhenPosted: false — posted-документ read-only.

Patterns та reuse

  • Shell-компоненти: DocumentFormShell, DocumentFormToolbar, DocumentListToolbar, SubtablePanel, AttachmentsPanel, AuditPanel — загальний UX.
  • Хуки даних: useDocumentForm (CRUD + draft flow), useListAutoSelect (авто-виділення рядка), useDocumentContextMenu (context-menu для списку).
  • Refs: form-refs/ endpoint bundled (postачальники, контракти, PO, GR, items, units, taxRates) → один HTTP-запит на відкриття форми.
  • Filter: контракти фільтруються за supplier + organization; PO — за supplier. При виборі PO — auto-fill supplier/organization/currency/contract (якщо ще порожні).

3. Використання

3.1 Навігація (UI)

Конфіг — frontend/erp/src/config/essentials.ts:

Essentials → Transactions → Purchasing → Purchase Invoices

Пункт purchaseInvoices (ItemType.TRANSACTIONDATA). Додатково в конфіг підключаються (через lazy import): - PurchasePaymentKanban → пункт меню PROCESS; - MatchingDiscrepancyReport → пункт меню REPORT.

Примітка: наразі ці два компоненти імпортуються в essentials.ts, але секції меню для них потрібно додати окремо (якщо ще не додано).

3.2 Типовий сценарій

1. Reg-user створює PurchaseInvoice (draft)
   └─ Вибирає postачальника → автопідстановка контракту/валюти
   └─ (Опційно) Вибирає PO → autofill supplier/org/currency
   └─ Додає рядки: Item → lookup ціни → quantity → price
   └─ Вказує supplier_invoice_number, invoice_date, due_date

2. Натискає Post
   └─ preflight: ≥ 1 рядок
   └─ Backend: state=posted, three_way_match() → matching_status
   └─ При розбіжностях понад толеранс → payment_block=True

3a. Якщо matching_status ∈ {matched, tolerance_warning, override_approved}
    └─ Можна створити OutgoingPayment → списання → paid_amount/payment_status

3b. Якщо matching_status = blocked
    └─ Меню «Звірка» → Override block → reason → audit → matching_status=override_approved
    └─ Або: виправити invoice/PO/GR → Re-run matching

4. OutgoingPayment.purchase_invoice → paid_amount перераховується
   └─ paid_amount ≥ total_amount → payment_status=paid

3.3 API — приклади викликів

Створити рахунок:

POST /api/v1/essentials/purchase-invoices/
{
  "date": "2026-04-16",
  "supplier": 42,
  "organization": 1,
  "currency": 1,
  "supplier_invoice_number": "VN-123456",
  "invoice_date": "2026-04-15",
  "due_date": "2026-05-15",
  "purchase_order": 17,
  "goods_receipts": [33, 34]
}

Провести + звірити:

POST /api/v1/essentials/purchase-invoices/{id}/post_document/
→ { ..., "match_result": { "status": "blocked", "should_block_payment": true,
                            "summary": "...", "discrepancies": [...] } }

Re-run звірки без зміни state:

POST /api/v1/essentials/purchase-invoices/{id}/run-matching/
→ { "status": "matched", "should_block_payment": false, "summary": "...", "discrepancies": [] }

Зняти блокування:

POST /api/v1/essentials/purchase-invoices/{id}/override-block/
{ "reason": "Погоджено з фінансовим відділом (quote #A-42)" }

Kanban:

GET /api/v1/essentials/purchase-invoices/payment-kanban/
→ { "draft": [...], "awaiting_match": [...], "blocked": [...], ... }

Звіт розбіжностей:

GET /api/v1/essentials/purchase-invoices/discrepancy-report/
    ?date_from=2026-01-01&date_to=2026-04-01&supplier_id=42
→ [{ "supplier": "ACME LLC", "total_count": 12, "blocked": 2,
     "total_amount": "245000.00", "blocked_amount": "18500.00", ... }]

3.4 Налаштування толерансів

EssentialsModuleSettings (tenant-wide, singleton — endpoint /settings/):

Поле Дефолт Опис
match_qty_tolerance_pct 2 % дельта кількості Invoice vs. GR без блокування
match_price_tolerance_pct 1 % дельта ціни Invoice vs. PO без блокування
match_amount_tolerance 10 Абсолютна дельта суми (рівень документа, округлення)
block_payments_on_mismatch true Якщо false — payment_block не виставляється (для режиму soft-warn)

UI — Settings → Accounting.


4. Залежності й інтеграції

  • OutgoingPayment — при створенні перевіряє payment_block; при проведенні викликає _recalc_invoice_payment_status() → оновлює paid_amount + payment_status.
  • PlannedPaymentLine.purchase_invoice — календар платежів може посилатися на рахунок (для драфт-платіжного календаря).
  • AuditLog — усі зміни state, matching_status, payment_block, override — через audit_service.log().
  • TenantFilterMixin — усі queryset-и фільтруються по tenant. Важливо: для list-action використовується auto_list_serializer_factory, тож якщо треба кастомний серіалізатор на list — перевизначати get_serializer_class.

5. Обмеження та майбутні покращення

  • Unpost заборонений, якщо є проведені OutgoingPayment (повертає 400). Треба спочатку відмінити платежі.
  • Наразі немає UI-вкладки, що показує discrepancies з останнього three_way_match() — сирі дані повертаються в match_result, але UI їх не рендерить. Додати можна як окрему таб у DocumentFormShell.
  • Pop-up з детальною матрицею (PO ↔ GR ↔ Invoice per item) — у планах.
  • VAT-breakdown у футері рахується з рядків; при збереженні БД перераховує через lines.aggregate.