Python-мапінг у шаблонах M.E.Doc: довідник рецептів
Ця стаття — для тих, хто налаштовує вивантаження документів з Odoo у M.E.Doc і дійшов до полів, які не мапляться ані прямим полем Odoo, ані сталим значенням. Тут зібрані робочі рецепти, типові помилки та спосіб упізнати їх за виглядом результату.
Читати підряд не обов'язково: «Швидкі рецепти» працюють як шпаргалка, а «Три правила» пояснюють, чому рецепти саме такі.
Контракт
Код у полі Python-код виконується окремо для кожного документа (або рядка табличної частини). Результат треба покласти у змінну result — саме її значення потрапляє в XML-тег.
result = record.name
Доступні змінні:
| Ім'я | Що це |
|---|---|
| record | запис, для якого формується тег |
| env | середовище Odoo: доступ до будь-якої моделі |
| result | сюди покласти значення; початково — порожній рядок |
| datetime | робота з датами: datetime.date.today(), datetime.timedelta(days=5) |
| relativedelta | арифметика по місяцях і роках |
Плюс звичні вбудовані функції: str, int, float, round, len, sum, min, max, sorted, filter, any, all та інші.
import недоступний — усе потрібне вже покладено в контекст.
Чим є record. Це залежить від розділу, у якому стоїть поле:
- Шапка (TAB 0) — record є самим документом, тобто записом моделі, вказаної в «Джерело в Odoo».
- Таблична частина (TAB ≥ 1) — record є одним рядком зі зв'язку, вказаного в «Поле рядків (для TAB ≥ 1)». Код виконується окремо для кожного рядка документа.
Наприклад, для джерела account.move з полем рядків invoice_line_ids у шапці record — це account.move, а в табличній частині — account.move.line.
Перевіряти нічого не треба: помічник вибору в полі «Поле Odoo» пропонує поля саме тієї моделі, у контексті якої виконується цей рядок. Якщо у списку видно поля рядка — код працює з рядком.
Код може займати кілька рядків, проміжні змінні дозволені:
parts = (record.partner_id.parent_id.ref, record.partner_id.ref)
result = ', '.join(p for p in parts if p)
Про порожнечу. Якщо result лишиться False або None, модуль сам підставить порожній рядок — дописувати or '' не потрібно.
Про складені типи. Список, кортеж, recordset виводяться «як у Python», разом із дужками й службовим форматом. Це найчастіше джерело дивних значень у вивантаженні.
Три правила
1. Зріз замість індексу
Звертання за індексом до порожнього набору — це помилка виконання. Зріз [:1] на порожньому наборі повертає порожній набір і працює далі.
# зламається, якщо в партнера немає жодного рахунку
result = record.partner_id.bank_ids[0].acc_number
# віддасть порожньо
result = record.partner_id.bank_ids[:1].acc_number
Ланцюжок працює до кінця: звертання до поля на порожньому наборі дає False, який модуль виведе як порожній тег. Перевірка «якщо є хоч одне значення» вбудована в саму дію — окремий if не потрібен.
Те саме стосується будь-якого зв'язку, який може бути порожнім: record.partner_id.parent_id.ref у партнера-компанії не зламається, бо parent_id там — порожній набір.
2. Ріж recordset, а не список
Найпідступніша помилка, бо код виглядає правильним і не падає. mapped() повертає список, і зріз від списку — теж список, просто з одного елемента.
# у результат поїде: [datetime.date(2026, 8, 14)]
result = record._get_reconciled_payments().mapped('date')[:1]
# у результат поїде дата
result = record._get_reconciled_payments()[:1].date
Правило: спершу звузити набір записів, потім брати поле. Якщо в результаті видно квадратні дужки — зріз стоїть не на тому рівні.
3. Порядок не буває «очевидним»
Recordset, отриманий з поля моделі, впорядкований за _order цієї моделі. Наприклад, partner_id.bank_ids — за sequence, id, тобто «перший» збігається з тим, що користувач перетягнув угору на вкладці партнера. Це передбачувано.
Набір, зібраний методом, такої гарантії не дає. _get_reconciled_payments() складається з рядків звірки, і «перший» там може виявитися будь-яким. Якщо потрібна саме остання оплата — питай про це явно:
result = max(record._get_reconciled_payments().mapped('date'), default='')
Тут mapped() доречний: max() працює зі списком дат, а default='' закриває випадок неоплаченого документа.
Швидкі рецепти
З рядка табличної частини — до документа
У полях ТЧ record — це рядок, тож реквізити самого документа беруться через зворотний зв'язок:
result = record.move_id.name
Для invoice_line_ids зворотне поле називається move_id; в іншому джерелі воно матиме іншу назву — її підкаже той самий помічник вибору поля.
Кілька реквізитів через роздільник
result = ','.join(p for p in (record.ref, record.payment_reference) if p)
Роздільник з'являється рівно тоді, коли є що розділяти. Умова if p відсіює і False, і порожній рядок. Щоб додати третій реквізит, достатньо дописати його в дужки.
Порівняй із варіантом «у лоб», який робить те саме:
result = (record.ref or '') + (',' if record.ref and record.payment_reference else '') + (record.payment_reference or '')
Він працює, але кожен новий реквізит додає ще один тернарник, і їхня кількість росте швидше за кількість самих полів.
Номер договору через референс партнера
Окремої сутності «договір» в Odoo немає. Якщо номер договору ведеться в референсі партнера, його можна зібрати так:
result = ', '.join(p for p in (record.partner_id.parent_id.ref, record.partner_id.ref) if p)
Порядок навмисний: спершу референс материнської компанії, потім самого контрагента. Для партнера верхнього рівня parent_id порожній, і if p просто прибирає цю частину — жодних перевірок писати не треба.
Перший банківський рахунок партнера
result = record.partner_id.bank_ids[:1].acc_number
Рахунок із документа, з відкатом на партнера
result = (record.partner_bank_id or record.partner_id.bank_ids[:1]).acc_number
Порожній recordset у булевому контексті хибний, тож or між наборами працює як звичайний відкат. Такий варіант надійніший за просто «перший рахунок партнера»: якщо в самому документі вже обрано рахунок, у M.E.Doc піде саме він, і роздруківка не розійдеться з вивантаженням.
Дата останньої оплати
result = max(record._get_reconciled_payments().mapped('date'), default='')
Термін оплати (не плутати з фактом)
result = record.invoice_date_due
invoice_date_due — це коли треба заплатити за умовами платежу, а не коли заплатили. Для частково оплаченого документа «дата оплати» взагалі не має єдиного значення: рецепт вище віддасть дату останньої часткової оплати, а не дату закриття боргу. Стан можна перевірити через record.payment_state.
Зсув дати
result = record.invoice_date + relativedelta(day=31)
day=31 у relativedelta означає «останній день місяця», а не «31 число»: для лютого вийде 28 або 29. Так само працюють months=1, years=-1, days=10.
Про формат дат
Дату можна повертати як є — модуль виводить її згідно з регіональними налаштуваннями Odoo:
result = record.invoice_date
strftime() потрібен лише тоді, коли конкретний тег вимагає формат, відмінний від того, що дає Odoo. Тоді — явно і з перевіркою на порожнє:
d = record.invoice_date
result = d.strftime('%d%m%Y') if d else ''
Кнопка Тест мапінгу показує значення вже обробленим, тож для нового тега варто один раз звірити результат із фінальним XML — і далі покладатися на тест.
Якщо код упав
Помилка в коді не зупиняє вивантаження. Замість значення в тег потрапляє текст ПОМИЛКА У PYTHON-КОДІ: ... з описом причини.
Що з цього випливає:
- документ усе одно сформується, і M.E.Doc його прийме — порожнього місця, яке впало б в око, не буде;
- натомість у файлі лишається видимий маркер: якщо щось пішло не так, у ньому знайдеться підрядок ПОМИЛКА;
- raise у власному коді нічого не зупинить — його перехопить той самий обробник і теж перетворить на текст.
Тому єдиний надійний момент перевірити код — Тест мапінгу перед першим бойовим вивантаженням, а не після нього.
Діагностика
| Значення в результаті | Причина | Ліки |
|---|---|---|
| ПОМИЛКА У PYTHON-КОДІ: ... | код упав; текст після двокрапки — власне причина | читати повідомлення: воно назве і рядок, і тип помилки |
| [datetime.date(2026, 8, 14)] | у result потрапив список — зріз стоїть після mapped() | різати recordset: ...[:1].date |
| account.move.line(42,) | у result потрапив сам recordset, а не поле | дописати потрібне поле в кінці ланцюжка |
| ('12345', False) | у result потрапив кортеж — забуто join | ', '.join(p for p in (...) if p) |
| порожньо там, де мали бути дані | ланцюжок обірвався на порожньому зв'язку | перевірити проміжні поля через Тест мапінгу |
| значення є, але «не те» | покладання на порядок набору, який його не гарантує | max() / min() або явне сортування |
Безпека
Змінна env дає коду доступ до всіх моделей бази — на читання і на запис, з правами того користувача, який виконує вивантаження. Імпорт сторонніх модулів заблоковано, але цього достатньо, щоб вважати поле з кодом рівносильним серверній дії Odoo.
Практичні наслідки:
- право редагувати шаблони M.E.Doc варто давати лише тим, кому ви довіряєте налаштування системи загалом;
- не вставляйте в це поле фрагменти з незнайомих джерел, не розібравшись, що саме вони роблять.
Мінімум, який варто пам'ятати
- [:1] замість [0].
- У ТЧ record — це рядок; до документа йти через зворотне поле.
- Зріз — до mapped(), а не після.
- Складені типи (список, кортеж, recordset) у result не кладуть — тільки значення поля.
- or '' наприкінці не потрібен: порожнечу модуль обробляє сам.
- «Перший» має сенс лише там, де порядок гарантований.
- Помилка не зупиняє вивантаження, а лишається текстом усередині тега.