Skip to Content

Архітектура рушія масового імпорту: як влаштований kw_generic_import

Задача: зробити імпорт даних в Odoo, який не доведеться переписувати під кожен новий формат файлу й кожну нову модель. Класичний варіант — окремий скрипт чи візард під кожен кейс клієнта: сьогодні product.template з Excel, завтра account.move з вивантаження 1С, і щоразу наново пишеться парсинг, пошук дублів і create/write. Ми вже писали про ручне перенесення товарів з іншої бази — тут та сама задача, тільки на масштабі: 17 модулів kw_generic_import* замінюють цілий клас разових скриптів однією конфігурованою моделлю.

Конфігурація без коду

Розглянемо дві моделі, які і є конфігурацією. kw.generic.import.template — профіль імпорту: цільова модель, формат джерела, стратегія збігу.

model_id = fields.Many2one('ir.model', required=True)
format_id = fields.Many2one('kw.generic.import.source.format', required=True)
match_strategy = fields.Selection([
    ('create_only', 'Create only'),
    ('update_only', 'Update only'),
    ('upsert', 'Create or update')], default='upsert')
match_field_ids = fields.Many2many('ir.model.fields')
on_error = fields.Selection([
    ('skip', 'Skip row and continue'),
    ('stop', 'Stop on first error'),
    ('rollback_all', 'Roll back the whole import')], default='skip')
batch_size = fields.Integer(default=200)

kw.generic.import.mapping.line — один рядок маппінгу, «колонка файлу → поле моделі»:

source = fields.Char(required=True)            # заголовок колонки / JSON-ключ
field_id = fields.Many2one('ir.model.fields', required=True)
converter_id = fields.Many2one('kw.generic.import.converter')
relation_search_field = fields.Char()           # поле пошуку для m2o/m2m
on_missing = fields.Selection([
    ('error', 'Raise error'),
    ('create', 'Create related record'),
    ('skip', 'Leave empty')], default='error')
is_required = fields.Boolean()

Адміністратор налаштовує обидва записи через UI — python при цьому не пишеться взагалі. Це і є межа «імпорту без коду»: доки задача — мапінг колонок і стандартний пошук дублів, коду не буде.

Ядро проти доменних розширень

Головна архітектурна ідея репозиторію: ядро kw_generic_import не залежить від жодного функціонального модуля (sale, crm, account…) і ніколи не додає поле чи import-прапорець у бізнес-модель. Специфіка моделі живе в handler-адаптері — AbstractModel, який рушій диспетчерить за конвенцією імені:

class KwGenericImportHandler(models.AbstractModel):
    _name = 'kw.generic.import.handler'

    def _dispatch(self, base, model_name, *args):
        specific = '%s_%s' % (base, model_name.replace('.', '_'))
        method = specific if hasattr(self, specific) else '%s_default' % base
        return getattr(self, method)(*args)

    def match_domain(self, model_name, values, template):
        return self._dispatch(
            '_match_domain', model_name, model_name, values, template)

Неправильно — додати import_source_id чи прапорець на crm.lead, щоб позначати імпортовані записи. Правильно_inherit того самого kw.generic.import.handler в окремому модулі kw_generic_import_crm і реалізувати лише _match_domain_crm_lead: пошук іде спершу за email_from, і лише якщо там порожньо — за name. Це пріоритетний вибір по черзі, перше непорожнє поле й вирішує домен, а не поєднання обох умов через AND:

def _match_domain_crm_lead(self, model_name, values, template):
    for field in ('email_from', 'name'):
        if values.get(field):
            return [(field, '=', values[field])]
    return self._match_domain_default(model_name, values, template)

crm.lead при цьому не змінюється жодним рядком. Той самий метод для account.move в kw_generic_import_account уже розрізняє рахунок/накладну/проведення за move_type і додатково валідує баланс дебету й кредиту перед записом — уся специфіка бухгалтерії лишається в одному модулі, ядро про account.move нічого не знає.

Зв'язки: пошук, створення, неоднозначність

Реляційні поля (m2o/m2m) резолвляться конвертером, не handler'ом — _convert_m2o_by_name шукає запис за relation_search_field (за замовчуванням name), а on_missing мапінг-рядка вирішує, що робити з промахом:

def _resolve_relation(self, value, ctx, name_field):
    comodel = ctx.get('comodel')
    text = str(value).strip() if value else ''
    if not text or not comodel:
        return False                # немає значення чи не задано comodel — просто пусто
    records = self.env[comodel].search(
        [(name_field, '=', text)], order='id')
    if records:
        return records[0]           # найстарший запис — детермінований вибір при дублях
    on_missing = ctx.get('on_missing') or 'error'
    if on_missing == 'skip':
        return False
    if on_missing == 'create':
        return self.env[comodel].create({name_field: text})
    raise UserError(...)

Важливо! Пошук завжди order='id', а не дефолтний _order моделі: сортування за sequence (типовий дефолт багатьох моделей) ставить NULL — тобто щойно створений запис — у кінець вибірки в PostgreSQL, і старий запис із заповненим sequence міг би непомітно перехопити збіг замість запису, щойно створеного попереднім рядком імпорту. При кількох збігах рушій пише warning у лог і бере найстаріший запис — мовчки нічого не вирішує.

Валідація рядка і що з ним відбувається далі

Обов'язковість перевіряється двічі: мапінг-рядком (is_required) і самою моделлю (required=True без ORM default). Друга перевірка врятувала від реального бага: порожня колонка «DATE», змаплена на необов'язкове stock.picking.scheduled_date, тихо писала False в існуючий запис — і тільки NOT NULL на stock.move.date (інше поле, через inverse) це ловив, причому не на dry run, а лише на реальному прогоні. Тепер рядок з колонкою, якої взагалі немає у файлі, для цього поля просто пропускається — на відміну від порожньої комірки, яка й далі йде через конвертер і перевірку.

Транзакційність і on_error

Стратегія on_error на шаблоні визначає, чи один поганий рядок валить увесь імпорт.

  • skip/stop — кожен рядок обробляється у власній незалежній транзакції (_in_new_transaction); попередні успішні рядки лишаються закомічені незалежно від того, що станеться далі. Різниця лише в тому, чи продовжує рушій після помилки.
  • rollback_all — увесь батч обробляється в одній SAVEPOINT; будь-який виняток відкочує назад геть усе, включно з уже «успішними» рядками цього ж виклику.

Ну ок, тобто skip і rollback_all — це буквально протилежні гарантії, і обирає їх адміністратор на шаблоні, а не розробник у коді.

Dry run працює тією ж механікою: увесь файл проганяється у вкладеній транзакції з SAVEPOINT на кожен рядок (щоб зловити навіть DB-рівні обмеження), а потім уся вкладена транзакція відкочується — прев'ю рахує created/updated/error, а в базі не змінюється нічого. Я завжди раджу перший прогін нового шаблону робити саме через dry run: практика показує, що там ловляться не помилки конвертерів, а якраз required-поля з ORM-дефолтом, які легко проґавити на етапі маппінгу.

Асинхронний режим

Cron сам ділить великий файл на батчі batch_size рядків за тік — сесія переходить у queued, і _run_cron_chunk дожовує її шматками. kw_generic_import_generic_task_queue замінює ir.cron на воркер generic_task_queue, коли треба обробляти без пауз між тіками:

class KwGenericImportSession(models.Model):
    _inherit = 'kw.generic.import.session'

    background_backend = fields.Selection(
        selection_add=[('gtq', 'Generic Task Queue')],
        ondelete={'gtq': 'set default'})

    def _enqueue_background(self):
        self._check_background_user_not_superuser()
        if 'generic.task.queue.task' not in self.env:
            return super()._enqueue_background()
        self.write({'state': 'queued', 'background_backend': 'gtq'})
        self.env['generic.task.queue.task'].create_task(
            'kw_generic_import.session.run',
            params={'session_id': self.id},
            name='Generic Import: %s' % self.name,
            channel='import',
            unique_key='kw_generic_import.session.%d' % self.id,
            on_conflict='reuse-running')

Перевірка _check_background_user_not_superuser() не декоративна: асинхронний запуск під суперюзером тихо вимикає access rules і частину валідацій, і бага, який на dry run під звичайним користувачем вискочив би одразу, у фоні просто не станеться — рядок мовчки запишеться. А якщо модуль generic_task_queue в базі не встановлений, _enqueue_background не падає — фолбек return super()._enqueue_background() відкочує сесію на нативний ir.cron: асинхронність нікуди не зникає, просто повертається до обробки батчами по тіку, без черги.

Нативний cron забирає в обробку лише сесії з background_backend = 'cron', тому обидва воркери ніколи не чіпають одну й ту саму сесію одночасно — координації між ними не потрібно.

Межа конфігурованості проходить рівно там, де закінчується мапінг «колонка → поле» і стандартний create/write. Новий формат файлу (_parse_<code>), нестандартна умова пошуку дубля (_match_domain_*) і пост-обробка на кшталт реконсиляції платежу (_post_process_account_payment у kw_generic_import_account) — усе це вже python-метод в окремому модулі, не запис у шаблоні. А ось документ із заголовком і рядками, що групуються за кольором комірки, коду якраз не вимагає: document_row_style_fill_argb/document_row_style_bold на самому шаблоні — це штатна UI-конфігурація, не приклад межі.

Архітектура рушія масового імпорту: як влаштований kw_generic_import
KitWorks, Volodymyr Karabanov 8 серпня 2026 р.
Поділитися цією публікацією
Теги
Архів