Initial commit: Accounting packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:47 +02:00
commit 4ef34c2317
2661 changed files with 1709616 additions and 0 deletions

View file

@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from . import account_bank_statement
from . import account_journal
from . import account_move
from . import account_payment
from . import account_reconcile_model
from . import res_company

View file

@ -0,0 +1,145 @@
# -*- coding: utf-8 -*-
"""Classes defining the populate factory for Bank Statements and related models."""
from odoo import models, Command
from odoo.tools import populate
from dateutil.relativedelta import relativedelta
from functools import lru_cache
import logging
_logger = logging.getLogger(__name__)
class AccountBankStatement(models.Model):
"""Populate factory part for account.bank.statements."""
_inherit = "account.bank.statement"
_populate_dependencies = ['account.bank.statement.line']
def _populate(self, size):
"""
Populate the bank statements with random lines.
:param size:
:return:
"""
rand = populate.Random('account_bank_statement+Populate')
read_group_res = self.env['account.bank.statement.line'].read_group(
[('statement_id', '=', False)],
['ids:array_agg(id)'],
['journal_id'],
)
bank_statement_vals_list = []
for res in read_group_res:
available_ids = res['ids']
nb_ids = len(available_ids)
while nb_ids > 0:
batch_size = min(rand.randint(1, 19), nb_ids)
nb_ids -= batch_size
# 50% to create a statement.
statement_needed = bool(rand.randint(0, 1))
if not statement_needed:
continue
bank_statement_vals_list.append({
'name': f"statement_{len(bank_statement_vals_list) + 1}",
'journal_id': res['journal_id'][0],
'line_ids': [Command.set(res['ids'])],
})
return self.env['account.bank.statement'].create(bank_statement_vals_list)
class AccountBankStatementLine(models.Model):
"""Populate factory part for account.bank.statements.line."""
_inherit = "account.bank.statement.line"
_populate_sizes = {
'small': 100,
'medium': 10000,
'large': 200000,
}
_populate_dependencies = ['account.journal', 'res.company', 'res.partner']
def _populate_factories(self):
@lru_cache()
def search_partner_ids(company_id):
"""Search all the partners that a company has access to.
This method is cached, only one search is done per company_id.
:param company_id (int): the company to search partners for.
:return (list<int>): the ids of partner the company has access to.
"""
return self.env['res.partner'].search([
'|', ('company_id', '=', company_id), ('company_id', '=', False),
('id', 'in', self.env.registry.populated_models['res.partner']),
]).ids
def get_partner(random, values, **kwargs):
"""Get a partner by selecting inside the list of partner a company has access to.
There is also a chance of having no partner set.
:param random: seeded random number generator.
:param values (dict): the values already selected for the record.
:return (int): an id of a partner accessible by the company of the statement.
"""
company_id = self.env['account.journal'].browse(values['journal_id']).company_id.id
partner = search_partner_ids(company_id)
return random.choices(partner + [False], [1/len(partner)] * len(partner) + [1])[0]
def get_amount(random, **kwargs):
"""Get a random amount between -1000 and 1000.
It is impossible to get a null amount. Because it would not be a valid statement line.
:param random: seeded random number generator.
:return (float): a number between -1000 and 1000.
"""
return random.uniform(-1000, 1000) or 1
def get_amount_currency(random, values, **kwargs):
"""
Get a random amount currency between one tenth of amount and 10 times amount with the same sign
if foreign_currency_id is set
:param random: seeded random number generator.
:return (float): a number between amount / 10 and amount * 10.
"""
return random.uniform(0.1 * values['amount'], 10 * values['amount']) if values['foreign_currency_id'] else 0
def get_currency(random, values, **kwargs):
"""Get a random currency.
The currency has to be empty if it is the same as the currency of the line's journal's.
:param random: seeded random number generator.
:param values (dict): the values already selected for the record.
:return (int, bool): the id of an active currency or False if it is the same currency as
the lines's journal's currency.
"""
journal = self.env['account.journal'].browse(values['journal_id'])
currency = random.choice(self.env['res.currency'].search([('active', '=', True)]).ids)
return currency if currency != (journal.currency_id or journal.company_id.currency_id).id else False
company_ids = self.env['res.company'].search([
('chart_template_id', '!=', False),
('id', 'in', self.env.registry.populated_models['res.company']),
])
if not company_ids:
return []
journal_ids = self.env['account.journal'].search([
('company_id', 'in', company_ids.ids),
('type', 'in', ('cash', 'bank')),
]).ids
return [
('journal_id', populate.iterate(journal_ids)),
('partner_id', populate.compute(get_partner)),
('date', populate.randdatetime(relative_before=relativedelta(years=-4))),
('payment_ref', populate.constant('transaction_{values[date]}_{counter}')),
('amount', populate.compute(get_amount)),
('foreign_currency_id', populate.compute(get_currency)),
('amount_currency', populate.compute(get_amount_currency)),
]

View file

@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
"""Classes defining the populate factory for Accounting Journals and related models."""
import logging
from odoo import models
from odoo.tools import populate
_logger = logging.getLogger(__name__)
class AccountJournal(models.Model):
"""Populate factory part for account.journal."""
_inherit = "account.journal"
_populate_sizes = {
'small': 10,
'medium': 30,
'large': 100,
}
_populate_dependencies = ['res.company']
def _populate_factories(self):
company_ids = self.env['res.company'].search([
('chart_template_id', '!=', False),
('id', 'in', self.env.registry.populated_models['res.company']),
])
if not company_ids:
return []
return [
('company_id', populate.cartesian(company_ids.ids)),
('type', populate.cartesian(['sale', 'purchase', 'cash', 'bank', 'general'])),
('currency_id', populate.randomize(self.env['res.currency'].search([
('active', '=', True),
]).ids + [False])),
('name', populate.constant("Journal {values[type]} {counter}")),
('code', populate.constant("{values[type]:.2}{counter}")),
]

View file

@ -0,0 +1,243 @@
# -*- coding: utf-8 -*-
"""Classes defining the populate factory for Journal Entries, Invoices and related models."""
from odoo import models, fields, Command
from odoo.tools import populate
import logging
import math
from functools import lru_cache
from dateutil.relativedelta import relativedelta
_logger = logging.getLogger(__name__)
class AccountMove(models.Model):
"""Populate factory part for account.move.
Because of the complicated nature of the interraction of account.move and account.move.line,
both models are actualy generated in the same factory.
"""
_inherit = "account.move"
_populate_sizes = {
'small': 1000,
'medium': 10000,
'large': 500000,
}
_populate_dependencies = ['res.partner', 'account.journal', 'product.product']
def _populate_factories(self):
@lru_cache()
def search_accounts(company_id, types=None):
"""Search all the accounts of a certain type for a company.
This method is cached, only one search is done per tuple(company_id, type).
:param company_id (int): the company to search accounts for.
:param type (str): the type to filter on. If not set, do not filter. Valid values are:
payable, receivable, liquidity, other, False.
:return (Model<account.account>): the recordset of accounts found.
"""
domain = [('company_id', '=', company_id), ('account_type', '!=', 'off_balance')]
if types:
domain += [('account_type', 'in', types)]
return self.env['account.account'].search(domain)
@lru_cache()
def search_journals(company_id, journal_type, currency_id):
"""Search all the journal of a certain type for a company.
This method is cached, only one search is done per tuple(company_id, journal_type).
:param company_id (int): the company to search journals for.
:param journal_type (str): the journal type to filter on.
Valid values are sale, purchase, cash, bank and general.
:param currency_id (int): the currency to search journals for.
:return (list<int>): the ids of the journals of a company and a certain type
"""
return self.env['account.journal'].search([
('company_id', '=', company_id),
('currency_id', 'in', (False, currency_id)),
('type', '=', journal_type),
]).ids
@lru_cache()
def search_products(company_id):
"""Search all the products a company has access to.
This method is cached, only one search is done per company_id.
:param company_id (int): the company to search products for.
:return (Model<product.product>): all the products te company has access to
"""
return self.env['product.product'].search([
('company_id', 'in', (False, company_id)),
('id', 'in', self.env.registry.populated_models['product.product']),
])
@lru_cache()
def search_partner_ids(company_id):
"""Search all the partners that a company has access to.
This method is cached, only one search is done per company_id.
:param company_id (int): the company to search partners for.
:return (list<int>): the ids of partner the company has access to.
"""
return self.env['res.partner'].search([
'|', ('company_id', '=', company_id), ('company_id', '=', False),
('id', 'in', self.env.registry.populated_models['res.partner']),
]).ids
def get_invoice_date(values, **kwargs):
"""Get the invoice date date.
:param values (dict): the values already selected for the record.
:return (datetime.date, bool): the accounting date if it is an invoice (or similar) document
or False otherwise.
"""
if values['move_type'] in self.get_invoice_types(include_receipts=True):
return values['date']
return False
def get_lines(random, values, **kwargs):
"""Build the dictionary of account.move.line.
Generate lines depending on the move_type, company_id and currency_id.
:param random: seeded random number generator.
:param values (dict): the values already selected for the record.
:return list: list of ORM create commands for the field line_ids
"""
def get_entry_line(label, balance=None):
account = random.choice(accounts)
currency = account.currency_id != account.company_id.currency_id and account.currency_id or random.choice(currencies)
if balance is None:
balance = round(random.uniform(-10000, 10000))
return Command.create({
'name': 'label_%s' % label,
'balance': balance,
'account_id': account.id,
'partner_id': partner_id,
'currency_id': currency.id,
'amount_currency': account.company_id.currency_id._convert(balance, currency, account.company_id, date),
})
def get_invoice_line():
return Command.create({
'product_id': random.choice(products).id,
'account_id': random.choice(accounts).id,
'price_unit': round(random.uniform(0, 10000)),
'quantity': round(random.uniform(0, 100)),
})
move_type = values['move_type']
date = values['date']
company_id = values['company_id']
partner_id = values['partner_id']
# Determine the right sets of accounts depending on the move_type
if move_type in self.get_sale_types(include_receipts=True):
accounts = search_accounts(company_id, ('income',))
elif move_type in self.get_purchase_types(include_receipts=True):
accounts = search_accounts(company_id, ('expense',))
else:
accounts = search_accounts(company_id)
products = search_products(company_id)
if move_type == 'entry':
# Add a random number of lines (between 1 and 20)
lines = [get_entry_line(
label=i,
) for i in range(random.randint(1, 20))]
# Add a last line containing the balance.
# For invoices, etc., it will be on the receivable/payable account.
lines += [get_entry_line(
balance=-sum(vals['balance'] for _command, _id, vals in lines),
label='balance',
)]
else:
lines = [get_invoice_line() for _i in range(random.randint(1, 20))]
return lines
def get_journal(random, values, **kwargs):
"""Get a random journal depending on the company and the move_type.
:param random: seeded random number generator.
:param values (dict): the values already selected for the record.
:return (int): the id of the journal randomly selected
"""
move_type = values['move_type']
company_id = values['company_id']
currency_id = values['company_id']
if move_type in self.get_sale_types(include_receipts=True):
journal_type = 'sale'
elif move_type in self.get_purchase_types(include_receipts=True):
journal_type = 'purchase'
else:
journal_type = 'general'
journal = search_journals(company_id, journal_type, currency_id)
return random.choice(journal)
def get_partner(random, values, **kwargs):
"""Get a random partner depending on the company and the move_type.
The first 3/5 of the available partners are used as customer
The last 3/5 of the available partners are used as suppliers
It means 1/5 is both customer/supplier
-> Same proportions as in account.payment
:param random: seeded random number generator.
:param values (dict): the values already selected for the record.
:return (int, bool): the id of the partner randomly selected if it is an invoice document
False if it is a Journal Entry.
"""
move_type = values['move_type']
company_id = values['company_id']
partner_ids = search_partner_ids(company_id)
if move_type in self.get_sale_types(include_receipts=True):
return random.choice(partner_ids[:math.ceil(len(partner_ids)/5*2)])
if move_type in self.get_purchase_types(include_receipts=True):
return random.choice(partner_ids[math.floor(len(partner_ids)/5*2):])
return False
company_ids = self.env['res.company'].search([
('chart_template_id', '!=', False),
('id', 'in', self.env.registry.populated_models['res.company']),
])
currencies = self.env['res.currency'].search([
('active', '=', True),
])
if not company_ids:
return []
return [
('move_type', populate.randomize(
['entry', 'in_invoice', 'out_invoice', 'in_refund', 'out_refund', 'in_receipt', 'out_receipt'],
[0.2, 0.3, 0.3, 0.07, 0.07, 0.03, 0.03],
)),
('company_id', populate.randomize(company_ids.ids)),
('currency_id', populate.randomize(currencies.ids)),
('journal_id', populate.compute(get_journal)),
('date', populate.randdatetime(relative_before=relativedelta(years=-4), relative_after=relativedelta(years=1))),
('invoice_date', populate.compute(get_invoice_date)),
('partner_id', populate.compute(get_partner)),
('line_ids', populate.compute(get_lines)),
]
def _populate(self, size):
records = super()._populate(size)
_logger.info('Posting Journal Entries')
to_post = records.filtered(lambda r: r.date < fields.Date.today())
to_post.action_post()
# TODO add some reconciliations. Not done initially because of perfs.
# _logger.info('Registering Payments for Invoices and Bills')
# random = populate.Random('account.move+register_payment')
# for invoice in to_post:
# if invoice.is_invoice() and random.uniform(0, 1) < 0.9: # 90% of invoices are at least partialy paid
# payment_wizard = self.env['account.payment.register'].with_context(active_model='account.move', active_ids=invoice.ids).create({})
# if random.uniform(0, 1) > 0.9: # 90% of paid invoices have the exact amount, others vary a little
# payment_wizard.amount *= random.uniform(0.5, 1.5)
# payment_wizard._create_payments()
return records

View file

@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""Classes defining the populate factory for Payments and related models."""
from odoo import models, fields
from odoo.tools import populate
from dateutil.relativedelta import relativedelta
import logging
import math
from functools import lru_cache
_logger = logging.getLogger(__name__)
class AccountPayment(models.Model):
"""Populate factory part for account.payment."""
_inherit = "account.payment"
_populate_sizes = {
'small': 100,
'medium': 5000,
'large': 50000,
}
_populate_dependencies = ['res.company', 'res.partner', 'account.journal']
def _populate_factories(self):
@lru_cache()
def search_partner_ids(company_id):
"""Search all the partners that a company has access to.
This method is cached, only one search is done per company_id.
:param company_id (int): the company to search partners for.
:return (list<int>): the ids of partner the company has access to.
"""
return self.env['res.partner'].search([
'|', ('company_id', '=', company_id), ('company_id', '=', False),
('id', 'in', self.env.registry.populated_models['res.partner']),
]).ids
@lru_cache()
def search_journal_ids(company_id):
"""Search all the journal of a certain type for a company.
This method is cached, only one search is done per company_id.
:param company_id (int): the company to search journals for.
:return (list<int>): the ids of the bank and cash journals of a company
"""
return self.env['account.journal'].search([
('company_id', '=', company_id),
('type', 'in', ('cash', 'bank')),
]).ids
@lru_cache()
def search_payment_method_line_ids(payment_type, journal):
"""Search all the payment methods of a certain type.
This method is cached, only one search is done per type.
:param payment_type (str): the type of payment method. Valid values are customer and supplier.
:param journal (int): the journal of the payment method.
:return list<int>: list of ids of payment methods of the selected type
"""
need_bank_account = self._get_method_codes_needing_bank_account()
other_blacklist = ['sdd', 'bacs_dd']
return self.env['account.payment.method.line'].search([
('journal_id', '=', journal),
('payment_method_id.payment_type', '=', payment_type),
('code', 'not in', need_bank_account + other_blacklist),
]).ids
def get_partner(random, values, **kwargs):
"""Get a random partner depending on the company and the partner_type.
The first 3/5 of the available partners are used as customer
The last 3/5 of the available partners are used as suppliers
It means 1/5 is both customer/supplier
-> Same proportions as in account.move
:param random: seeded random number generator.
:param values (dict): the values already selected for the record.
:return (int): the id of the partner randomly selected.
"""
partner_type = values['partner_type']
company_id = values['company_id']
partner_ids = search_partner_ids(company_id)
if partner_type == 'customer':
return random.choice(partner_ids[:math.ceil(len(partner_ids)/5*2)])
else:
return random.choice(partner_ids[math.floor(len(partner_ids)/5*2):])
def get_journal(random, values, **kwargs):
"""Get a random bank or cash journal depending on the company.
:param random: seeded random number generator.
:param values (dict): the values already selected for the record.
:return (int): the id of the journal randomly selected
"""
return random.choice(search_journal_ids(values['company_id']))
def get_payment_method_line(random, values, **kwargs):
"""Get the payment method depending on the payment type.
:param random: seeded random number generator.
:param values (dict): the values already selected for the record.
"""
return random.choice(search_payment_method_line_ids(values['payment_type'], values['journal_id']))
company_ids = self.env['res.company'].search([
('chart_template_id', '!=', False),
('id', 'in', self.env.registry.populated_models['res.company']),
])
if not company_ids:
return []
return [
('company_id', populate.cartesian(company_ids.ids)),
('payment_type', populate.cartesian(['inbound', 'outbound'])),
('partner_type', populate.cartesian(['customer', 'supplier'])),
('journal_id', populate.compute(get_journal)),
('payment_method_line_id', populate.compute(get_payment_method_line)),
('partner_id', populate.compute(get_partner)),
('amount', populate.randfloat(0, 1000)),
('date', populate.randdatetime(relative_before=relativedelta(years=-4))),
]
def _populate(self, size):
records = super()._populate(size)
_logger.info('Validating Payments')
records.move_id.filtered(lambda r: r.date < fields.Date.today()).action_post()
return records

View file

@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
"""Classes defining the populate factory for Bank Statements and related models."""
from odoo import models
from odoo.tools import populate
import logging
_logger = logging.getLogger(__name__)
class AccountReconcileModel(models.Model):
"""Populate factory part for account.reconcile.model."""
_inherit = "account.reconcile.model"
_populate_sizes = {
'small': 5,
'medium': 100,
'large': 1000,
}
_populate_dependencies = ['res.company']
def _populate_factories(self):
def get_name(counter, **kwargs):
return 'model_%s' % counter
company_ids = self.env['res.company'].search([
('chart_template_id', '!=', False),
('id', 'in', self.env.registry.populated_models['res.company']),
])
if not company_ids:
return []
return [
('company_id', populate.cartesian(company_ids.ids)),
('rule_type', populate.cartesian(['writeoff_button', 'writeoff_suggestion'])),
# ('auto_reconcile', populate.cartesian([True, False], [0.1, 0.9])),
('name', populate.compute(get_name)),
]
class AccountReconcileModelLine(models.Model):
"""Populate factory part for account.reconcile.model.line."""
_inherit = "account.reconcile.model.line"
_populate_sizes = {
'small': 10,
'medium': 200,
'large': 2000,
}
_populate_dependencies = ['account.reconcile.model']
def _populate_factories(self):
def search_account_ids(company_id, type=None, group=None):
"""Search all the accounts of a certain type and group for a company.
This method is cached, only one search is done per tuple(company_id, type, group).
:param company_id (int): the company to search accounts for.
:param type (str): the type to filter on. If not set, do not filter. Valid values are:
payable, receivable, liquidity, other, False.
:param group (str): the group to filter on. If not set, do not filter. Valid values are:
asset, liability, equity, off_balance, False.
:return (Model<account.account>): the recordset of accounts found.
"""
domain = [('company_id', '=', company_id)]
if type:
domain += [('account_type', '=', type)]
if group:
domain += [('internal_group', '=', group)]
return self.env['account.account'].search(domain)
def get_amount(random, values, **kwargs):
"""Get an amount dending on the amount_type.
:param random: seeded random number generator.
:param values (dict): the values already selected for the record.
:return (int, str):
If amount_type is fixed, a random number between 1 and 1000
If amount type is percentage, a random number between 1 and 100
Else, amount_type is regex, a random regex out of 2
"""
if values['amount_type'] == 'fixed':
return '%s' % random.randint(1, 1000)
elif values['amount_type'] == 'percentage':
return '%s' % random.randint(1, 100)
else:
return random.choice([r'^invoice \d+ (\d+)$', r'xd no-(\d+)'])
def get_account(random, values, **kwargs):
"""Get a random account depending on the company.
:param random: seeded random number generator.
:param values (dict): the values already selected for the record.
:return (int): the id of the account randomly selected
"""
company_id = self.env['account.reconcile.model'].browse(values['model_id']).company_id.id
return random.choice(search_account_ids(company_id).ids)
company_ids = self.env['res.company'].search([
('chart_template_id', '!=', False),
('id', 'in', self.env.registry.populated_models['res.company']),
])
if not company_ids:
return []
return [
('model_id', populate.cartesian(self.env.registry.populated_models['account.reconcile.model'])),
('amount_type', populate.randomize(['fixed', 'percentage', 'regex'])),
('amount_string', populate.compute(get_amount)),
('account_id', populate.compute(get_account)),
]

View file

@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
"""Classes extending the populate factory for Companies and related models.
Only adding specificities of basic accounting applications.
"""
from odoo import models, _
from odoo.tools import populate
from odoo.exceptions import UserError
import logging
from functools import lru_cache
_logger = logging.getLogger(__name__)
class ResCompany(models.Model):
"""Populate factory part for the accountings applications of res.company."""
_inherit = "res.company"
def _populate(self, size):
@lru_cache()
def search_coa_ids(currency_id):
return self.env['account.chart.template'].search([('currency_id', '=', currency_id)])
records = super()._populate(size)
_logger.info('Loading Chart Template')
default_chart_templates = self.env['account.chart.template'].search([], limit=1)
if not default_chart_templates:
# TODO install l10n_generic_coa ?
return records
random = populate.Random('res.company+chart_template_selector')
# Load the a chart of accounts matching the currency of the company for the 3 first created companies
# We are loading an existing CoA and not populating it because:
# * it reflects best real use cases.
# * it allows checking reports by localization
# * the config is complete with try_loading(), no need to adapt when the model changes
# * it is way easier :-)
# We are loading only for 3 companies because:
# * It takes a few hundreds of a second to create account.move records in batch.
# Because we want to have a lot of entries for at least one company (in order to test
# reports, functions and widgets performances for instance), we can't afford to do it for
# a lot of companies.
# * it would be useless to have entries for all the companies, we can already do everything with
# entries in only a few (but multiple) companies.
# Note that we can still populate some new records on top of the CoA if it makes sense,
# like account.journal for instance.
for company in records[:3]:
chart_templates_cur = search_coa_ids(company.currency_id.id) or default_chart_templates
random.choice(chart_templates_cur).with_company(company.id).try_loading()
return records