📄 Python-мапінг у шаблонах M.E.Doc: довідник рецептів

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 '' наприкінці не потрібен: порожнечу модуль обробляє сам.
  • «Перший» має сенс лише там, де порядок гарантований.
  • Помилка не зупиняє вивантаження, а лишається текстом усередині тега.