Odoo Shell Scripting to Accomplish tasks

Odoo Shell Scripting to Accomplish tasks
Photo by Irvan Smith / Unsplash

Today I found a client had not done their closing entries for the past 5 years. To be honest, nothing bad is going to happen, but it looks untidy.

This is in a large multicompany environment, with consistent accounts and journal structures. Some of the companies had been around for less than 6 years, but all in all there was 66 journal entries required. This is where Odoo scripting is really helpful as a repeatable way to test and then deploy complex, repeating transactions.

So lets take a look at the script.

from datetime import date
from dateutil.relativedelta import relativedelta

cye_code = '39000'
re_code = '38000'

for company in env['res.company'].search([]):
    moves_to_create = []
    journal = env['account.journal'].search([('name', '=', 'Miscellaneous Operations'), ('company_id', '=', company.id)], limit=1)
    cye_account = env['account.account'].search([('company_id', '=', company.id), ('code', '=', cye_code)])
    re_account = env['account.account'].search([('company_id', '=', company.id), ('code', '=', re_code)])
    for year in range(2019, 2025):
        end = date(year, 3, 31)
        start = end + relativedelta(years=-1, days=1)
        domain = [('account_id.account_type', 'in', ('income', 'income_other', 'expense_direct_cost', 'expense', 'expense_depreciation')), ('parent_state', '=', 'posted'), ('date', '>=', start), ('date', '<=', end), ('company_id', '=', company.id)]
        try:
            amount = env["account.move.line"].with_context(active_test=False).read_group(domain=domain, fields=["company_id", "balance"], groupby=["company_id"])[0]['balance']
        except IndexError:
            continue
        if not amount:
            continue
        # amount < 0 is a profit
        dr, cr =  (cye_account, re_account) if amount < 0 else (re_account, cye_account)
        ref = f'FY{year} transfer {-amount} from unallocated to retained earnings'
        print(f"Processing {ref}")
        moves_to_create.append(
            {
                'date': end,
                'journal_id': journal.id,
                'ref': ref,
                'company_id': company.id,               
                'line_ids': [
                    (
                        0,
                        0,
                        {
                            'name': ref,
                            'account_id': dr.id,
                            'debit': abs(amount),
                            'credit': 0.0,
                        }
                    ),
                    (
                        0,
                        0,
                        {
                            'name': ref,
                            'account_id': cr.id,
                            'credit': abs(amount),
                            'debit': 0.0,
                        }
                    )
                ]

            }
        )
    orig_lockdate = company.fiscalyear_lock_date
    company.fiscalyear_lock_date = False
    env['account.move'].with_company(company).create(moves_to_create).action_post()
    company.fiscalyear_lock_date = orig_lockdate
    env.cr.commit()

As scripts go, for multicompany environments this is pretty standard.

  • Imports
  • Set Global Variables
  • Iterate over the companies
  • Set company variables
  • Do Something
  • Commit

Imports

These are fairly self explanatory. We need to set a start and end date for our search of the relevant transactions and date and relativedelta are the 2 libraries to do that.

Global Variables

A closing entry involves transferring from the Current Year Earnings account to the Retained earnings account. In this case the codes are the same in all companies.

Iterate Over Companies

for company in env['res.company'].search([]):

In general, we always want to iterate over companies in multicompany, or in the case of a single company in multicompany be very specific. We need with_company, company specific journals, accounts and moves here.

In the search we could be very specific with tightly coupled data, this isn't for general use, so for example if we only wanted to do companies A,B and C then search([('id', 'in', (1,2,3))]) is fine. If our script needs to be more general and reusable, we may break out into functions with arguments.

Set Company Variables

    moves_to_create = []
    journal = env['account.journal'].search([('name', '=', 'Miscellaneous Operations'), ('company_id', '=', company.id)], limit=1)
    cye_account = env['account.account'].search([('company_id', '=', company.id), ('code', '=', cye_code)])
    re_account = env['account.account'].search([('company_id', '=', company.id), ('code', '=', re_code)])

moves_to_create isn't strictly necessary, we could create one by one, or we could declare as a global variable and create all at once. My preference is the middle ground, one create call per company.

journal, cye_account, and re_account will be common to each move we create.

Do Something

In this case we need to create closing entries over a period from 2019-2024.

for year in range(2019, 2025):

Sometimes it can be hard to know what your start or stop is. This is where having an interactive shell to test is very useful, e.g. list(range(2019, 2025) will tell you if your loop is correct.

From there we set dates for our domain, in this case for a 31st March financial year end and go about setting our domain. To find our domain, we find similar code, in this case the formula for Current Year Unallocated earnings in account reports.

Again the shell is our friend. A quick test and comparing the balance shows we needed to check parent_state as well to not include draft and cancelled entries. The start, stop dates and company_id requirements are obvious, but if not, it would have been the same process.

Getting the amount

Now there are a few ways this could be done. Search and sum, sql or read_group. read_group should always be preferred. It has the safety of the ORM, but at a speed approaching pure SQL.

How do we work out what to put in the function. Again the existing code and the shell are our friends. Copy over an existing function that finds account balances, then just change account_id to company_id as we don't need individual account balances, just the sum of all per company.

Here we use a try/except structure. If a company had no moves, we would get no return from read_group and a subsequent IndexError. There are less convenient ways to do this without a try/except, but the general principle is, if I get an error at this point, I just want to keep going as there is nothing to do.

Feedback on progress

Next we have a simple print statement. When developing scripts that have material consequences, slow and steady, check twice, cut once is the mantra. We can now run the script below, without any of the move creation in a test system and check our results without making changes.

from datetime import date
from dateutil.relativedelta import relativedelta

cye_code = '39000'
re_code = '38000'

for company in env['res.company'].search([]):
    moves_to_create = []
    journal = env['account.journal'].search([('name', '=', 'Miscellaneous Operations'), ('company_id', '=', company.id)], limit=1)
    cye_account = env['account.account'].search([('company_id', '=', company.id), ('code', '=', cye_code)])
    re_account = env['account.account'].search([('company_id', '=', company.id), ('code', '=', re_code)])
    for year in range(2019, 2025):
        end = date(year, 3, 31)
        start = end + relativedelta(years=-1, days=1)
        domain = [('account_id.account_type', 'in', ('income', 'income_other', 'expense_direct_cost', 'expense', 'expense_depreciation')), ('parent_state', '=', 'posted'), ('date', '>=', start), ('date', '<=', end), ('company_id', '=', company.id)]
        try:
            amount = env["account.move.line"].with_context(active_test=False).read_group(domain=domain, fields=["company_id", "balance"], groupby=["company_id"])[0]['balance']
        except IndexError:
            continue
        if not amount:
            continue
        # amount < 0 is a profit
        dr, cr =  (cye_account, re_account) if amount < 0 else (re_account, cye_account)
        ref = f'FY{year} transfer {-amount} from unallocated to retained earnings'
        print(f"Processing {ref}")

Preparing the moves

Now I've done this a long time, I generally know the fields I need, but again the code and also the UI are your friends. In debug mode you can simply have over the Reference field or Label field to know their names are ref and name respectively.

We want to create in bulk so we append to moves_to_create.

Creating the Moves

We already know these are for past locked fiscal years. When working with scripts one of the nice things is we can unlock the year, post our moves and lock it again all in the same transaction. This makes it both faster and safer than doing 1 by 1.

Finally we commit the transaction.

What if things go wrong

Firstly, that is why you do in a test database and verify. In this case, in the initial version I actually got the 2 account codes back to front, so we were debiting and crediting the opposite accounts as intended. Again, strong scripting knowledge makes this easy to reset and try again.

We follow the same idiom

  • Imports
  • Globals
  • Iterate Companies
  • Do Something
for company in env['res.company'].search([]):
    entries = env['account.move'].search([('ref', '=like', '%from unallocated to retained earnings'), ('company_id', '=', company.id)])
    orig_lockdate = company.fiscalyear_lock_date
    company.fiscalyear_lock_date = False
    entries.button_draft()
    entries.button_cancel()
    company.fiscalyear_lock_date = orig_lockdate
    env.cr.commit()

And we are back to where we started, adjust and run again.

Final Words

All in all, a script like this, with experience, is 20-30 minutes of work. Sometimes it won't be worth it, but when it must be correct, repeatable, testable and transferrable they are hard to go past.

My preferred way to run scripts is to simply open a shell session and cut and paste them in. In fact often I will type it line by line, pasting in each line as I go in test. After however, that script will be saved and commented with its purpose, when it was run, and filed in a github scripts repo to refer back to.