Purchase Invoice Module — Рахунок від постачальника¶
Документація модуля Purchase Invoice (рахунок від постачальника) у складі essentials — третьої сторони 3-way matching разом із PurchaseOrder та GoodsReceipt.
1. Призначення¶
Рахунок постачальника (PurchaseInvoice) — документ, з якого починається процес оплати вендору. Об’єднує три сторони закупівельного циклу:
Ключові інваріанти:
- 3-way match — при проведенні запускається
matching_service.three_way_match(), який порівнює PO ↔ GR ↔ Invoice за кількістю, ціною та сумою. - Payment block — при розбіжностях, що перевищують толеранси, виставляється
payment_block=True, який блокує створенняOutgoingPayment. - Override — користувач із відповідними правами може зняти блокування з обов’язковою вказівкою причини; дія фіксується в аудиті.
- 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;0060—PurchaseInvoice+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¶
PurchaseInvoiceLineSerializer—amount/vat_amount/price_grossread-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:
Пункт 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.