19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:07 +01:00
parent ba20ce7443
commit 768b70e05e
2357 changed files with 1057103 additions and 712486 deletions

View file

@ -1,17 +1,20 @@
# -*- coding: utf-8 -*-
from . import sequence_mixin
from . import account_document_import_mixin
from . import partner
from . import res_partner_bank
from . import account_account_tag
from . import account_account
from . import account_code_mapping
from . import account_root
from . import account_journal
from . import account_lock_exception
from . import account_tax
from . import account_reconcile_model
from . import account_payment_term
from . import account_move
from . import account_move_line
from . import account_move_line_tax_details
from . import account_move_send
from . import account_partial_reconcile
from . import account_full_reconcile
from . import account_payment
@ -25,15 +28,26 @@ from . import account_analytic_plan
from . import account_analytic_line
from . import account_journal_dashboard
from . import product
from . import product_catalog_mixin
from . import company
from . import res_config_settings
from . import res_country_group
from . import account_cash_rounding
from . import account_incoterms
from . import decimal_precision
from . import digest
from . import kpi_provider
from . import res_users
from . import ir_actions_report
from . import ir_attachment
from . import ir_actions_report
from . import ir_http
from . import ir_module
from . import mail_message
from . import mail_template
from . import mail_tracking_value
from . import merge_partner_automatic
from . import res_currency
from . import mail_thread
from . import account_report
from . import onboarding_onboarding_step
from . import template_generic_coa
from . import uom_uom

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo import osv
from odoo.fields import Domain
from odoo.tools import SQL, Query
from odoo.exceptions import UserError
@ -8,25 +8,71 @@ class AccountAccountTag(models.Model):
_name = 'account.account.tag'
_description = 'Account Tag'
name = fields.Char('Tag Name', required=True)
name = fields.Char('Tag Name', required=True, translate=True)
applicability = fields.Selection([('accounts', 'Accounts'), ('taxes', 'Taxes'), ('products', 'Products')], required=True, default='accounts')
color = fields.Integer('Color Index')
active = fields.Boolean(default=True, help="Set active to false to hide the Account Tag without removing it.")
tax_negate = fields.Boolean(string="Negate Tax Balance", help="Check this box to negate the absolute value of the balance of the lines associated with this tag in tax report computation.")
country_id = fields.Many2one(string="Country", comodel_name='res.country', help="Country for which this tag is available, when applied on taxes.")
# If the tag was generated by a report line for the `tax_tags` engine, these fields allow getting
# the sign of the report line used to display the amount.
# If balance_negate is true then it means we use `-balance` instead of `balance`
report_expression_id = fields.Many2one('account.report.expression', compute='_compute_report_expression_id')
balance_negate = fields.Boolean(compute='_compute_report_expression_id')
def name_get(self):
_name_uniq = models.Constraint(
'unique(name, applicability, country_id)',
'A tag with the same name and applicability already exists in this country.',
)
@api.depends('applicability', 'country_id')
@api.depends_context('company')
def _compute_display_name(self):
if not self.env.company.multi_vat_foreign_country_ids:
return super().name_get()
return super()._compute_display_name()
res = []
for tag in self:
name = tag.name
if tag.applicability == "taxes" and tag.country_id and tag.country_id != self.env.company.account_fiscal_country_id:
name = _("%s (%s)", tag.name, tag.country_id.code)
res.append((tag.id, name,))
name = _("%(tag)s (%(country_code)s)", tag=tag.name, country_code=tag.country_id.code)
tag.display_name = name
return res
@api.depends('name')
def _compute_report_expression_id(self):
query = self._search([('id', 'in', self.ids)])
id2expression = {tag_id: vals for tag_id, *vals in self.env.execute_query(query.select(
SQL.identifier(query.table, 'id'),
self._field_to_sql(query.table, 'report_expression_id', query),
self._field_to_sql(query.table, 'balance_negate', query),
))}
for tag in self:
tag.report_expression_id, tag.balance_negate = id2expression.get(tag._origin.id, (False, False))
def _field_to_sql(self, alias: str, field_expr: str, query: (Query | None) = None) -> SQL:
if field_expr in ('report_expression_id', 'balance_negate'):
rhs_alias = query.make_alias(alias, 'expression')
if rhs_alias not in query._tables:
query.add_join(
kind='LEFT JOIN',
alias=rhs_alias,
table='account_report_expression',
condition=SQL(
"%s->>'en_US' = LTRIM(%s, '-')",
SQL.identifier(alias, 'name'),
SQL.identifier(rhs_alias, 'formula'),
),
)
if field_expr == 'report_expression_id':
return SQL.identifier(rhs_alias, 'id')
if field_expr == 'balance_negate':
return SQL("STARTS_WITH(%s, '-')", SQL.identifier(rhs_alias, 'formula'))
return super()._field_to_sql(alias, field_expr, query)
@api.model_create_multi
def create(self, vals_list):
tags = super().create(vals_list)
if tax_tags := tags.filtered(lambda tag: tag.applicability == 'taxes'):
self._translate_tax_tags(tag_ids=tax_tags.ids)
return tags
@api.model
def _get_tax_tags(self, tag_name, country_id):
@ -34,37 +80,32 @@ class AccountAccountTag(models.Model):
in the specified country.
"""
domain = self._get_tax_tags_domain(tag_name, country_id)
original_lang = self._context.get('lang', 'en_US')
original_lang = self.env.context.get('lang', 'en_US')
rslt_tags = self.env['account.account.tag'].with_context(active_test=False, lang='en_US').search(domain)
return rslt_tags.with_context(lang=original_lang) # Restore original language, in case the name of the tags needs to be shown/modified
@api.model
def _get_tax_tags_domain(self, tag_name, country_id, sign=None):
""" Returns a domain to search for all the tax tags corresponding to the tag name given in parameter
def _get_tax_tags_domain(self, formula, country_id):
""" Returns a domain to search for all the tax tags corresponding to the formula given in parameter
in the specified country.
"""
escaped_tag_name = tag_name.replace('\\', '\\\\').replace('%', r'\%').replace('_', r'\_')
return [
('name', '=like', (sign or '_') + escaped_tag_name),
('name', '=', formula.lstrip('-')),
('country_id', '=', country_id),
('applicability', '=', 'taxes')
('applicability', '=', 'taxes'),
]
def _get_related_tax_report_expressions(self):
if not self:
return self.env['account.report.expression']
or_domains = []
for record in self:
expr_domain = [
'&',
('report_line_id.report_id.country_id', '=', record.country_id.id),
('formula', '=', record.name[1:]),
]
or_domains.append(expr_domain)
domain = osv.expression.AND([[('engine', '=', 'tax_tags')], osv.expression.OR(or_domains)])
return self.env['account.report.expression'].search(domain)
return self.env['account.report.expression'].search(Domain('engine', '=', 'tax_tags') & Domain.OR(
(
Domain('report_line_id.report_id.country_id', '=', record.country_id.id)
& Domain('formula', 'in', (record.name, '-' + record.name))
)
for record in self
))
@api.ondelete(at_uninstall=False)
def _unlink_except_master_tags(self):
@ -77,3 +118,23 @@ class AccountAccountTag(models.Model):
master_tag = self.env.ref(f"account.{master_xmlid}", raise_if_not_found=False)
if master_tag and master_tag in self:
raise UserError(_("You cannot delete this account tag (%s), it is used on the chart of account definition.", master_tag.name))
def _translate_tax_tags(self, langs=None, tag_ids=None):
"""Translate tax tags having the same name as report lines."""
langs = langs or (code for code, _name in self.env['res.lang'].get_installed() if code != 'en_US')
for lang in langs:
self.env.cr.execute(SQL(
"""
UPDATE account_account_tag tag
SET name = tag.name || jsonb_build_object(%(lang)s, substring(tag.name->>'en_US' FOR 1) || (report_line.name->>%(lang)s))
FROM account_report_line report_line
JOIN account_report report ON report.id = report_line.report_id
WHERE tag.applicability = 'taxes'
AND tag.country_id = report.country_id
AND tag.name->>'en_US' = substring(tag.name->>'en_US' FOR 1) || (report_line.name->>'en_US')
AND tag.name->>%(lang)s != substring(tag.name->>'en_US' FOR 1) || (report_line.name->>%(lang)s)
%(and_tag_ids)s
""",
lang=lang,
and_tag_ids=SQL('AND tag.id IN %s', tuple(tag_ids)) if tag_ids else SQL(''),
))

View file

@ -15,91 +15,64 @@ class AccountAnalyticAccount(models.Model):
compute='_compute_vendor_bill_count',
)
debit = fields.Monetary(groups='account.group_account_readonly')
credit = fields.Monetary(groups='account.group_account_readonly')
@api.depends('line_ids')
def _compute_invoice_count(self):
sale_types = self.env['account.move'].get_sale_types(include_receipts=True)
query = self.env['account.move.line']._search([
('parent_state', '=', 'posted'),
('move_id.move_type', 'in', sale_types),
])
query.add_where(
'account_move_line.analytic_distribution ?| %s',
[[str(account_id) for account_id in self.ids]],
data = self.env['account.move.line']._read_group(
[
('parent_state', '=', 'posted'),
('move_id.move_type', 'in', sale_types),
('analytic_distribution', 'in', self.ids),
],
['analytic_distribution'],
['__count'],
)
query.order = None
query_string, query_param = query.select(
'jsonb_object_keys(account_move_line.analytic_distribution) as account_id',
'COUNT(DISTINCT(account_move_line.move_id)) as move_count',
)
query_string = f"{query_string} GROUP BY jsonb_object_keys(account_move_line.analytic_distribution)"
self._cr.execute(query_string, query_param)
data = {int(record.get('account_id')): record.get('move_count') for record in self._cr.dictfetchall()}
data = {int(account_id): move_count for account_id, move_count in data}
for account in self:
account.invoice_count = data.get(account.id, 0)
@api.depends('line_ids')
def _compute_vendor_bill_count(self):
purchase_types = self.env['account.move'].get_purchase_types(include_receipts=True)
query = self.env['account.move.line']._search([
('parent_state', '=', 'posted'),
('move_id.move_type', 'in', purchase_types),
])
query.add_where(
'account_move_line.analytic_distribution ?| %s',
[[str(account_id) for account_id in self.ids]],
data = self.env['account.move.line']._read_group(
[
('parent_state', '=', 'posted'),
('move_id.move_type', 'in', purchase_types),
('analytic_distribution', 'in', self.ids),
],
['analytic_distribution'],
['__count'],
)
query.order = None
query_string, query_param = query.select(
'jsonb_object_keys(account_move_line.analytic_distribution) as account_id',
'COUNT(DISTINCT(account_move_line.move_id)) as move_count',
)
query_string = f"{query_string} GROUP BY jsonb_object_keys(account_move_line.analytic_distribution)"
self._cr.execute(query_string, query_param)
data = {int(record.get('account_id')): record.get('move_count') for record in self._cr.dictfetchall()}
data = {int(account_id): move_count for account_id, move_count in data}
for account in self:
account.vendor_bill_count = data.get(account.id, 0)
def action_view_invoice(self):
self.ensure_one()
query = self.env['account.move.line']._search([('move_id.move_type', 'in', self.env['account.move'].get_sale_types())])
query.order = None
query.add_where('analytic_distribution ? %s', [str(self.id)])
query_string, query_param = query.select('DISTINCT account_move_line.move_id')
self._cr.execute(query_string, query_param)
move_ids = [line.get('move_id') for line in self._cr.dictfetchall()]
result = {
account_move_lines = self.env['account.move.line'].search_fetch([
('move_id.move_type', 'in', self.env['account.move'].get_sale_types()),
('analytic_distribution', 'in', self.ids),
], ['move_id'])
return {
"type": "ir.actions.act_window",
"res_model": "account.move",
"domain": [('id', 'in', move_ids)],
"domain": [('id', 'in', account_move_lines.move_id.ids)],
"context": {"create": False, 'default_move_type': 'out_invoice'},
"name": _("Customer Invoices"),
'view_mode': 'tree,form',
'view_mode': 'list,form',
}
return result
def action_view_vendor_bill(self):
self.ensure_one()
query = self.env['account.move.line']._search([('move_id.move_type', 'in', self.env['account.move'].get_purchase_types())])
query.order = None
query.add_where('analytic_distribution ? %s', [str(self.id)])
query_string, query_param = query.select('DISTINCT account_move_line.move_id')
self._cr.execute(query_string, query_param)
move_ids = [line.get('move_id') for line in self._cr.dictfetchall()]
result = {
account_move_lines = self.env['account.move.line'].search_fetch([
('move_id.move_type', 'in', self.env['account.move'].get_purchase_types(include_receipts=True)),
('analytic_distribution', 'in', self.ids),
], ['move_id'])
return {
"type": "ir.actions.act_window",
"res_model": "account.move",
"domain": [('id', 'in', move_ids)],
"domain": [('id', 'in', account_move_lines.move_id.ids)],
"context": {"create": False, 'default_move_type': 'in_invoice'},
"name": _("Vendor Bills"),
'view_mode': 'tree,form',
'view_mode': 'list,form',
}
return result

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
import re
from odoo import fields, models
from odoo import api, fields, models, _
class AccountAnalyticDistributionModel(models.Model):
@ -8,12 +8,13 @@ class AccountAnalyticDistributionModel(models.Model):
account_prefix = fields.Char(
string='Accounts Prefix',
help="Prefix that defines which accounts from the financial accounting this model should apply on.",
help="This analytic distribution will apply to all financial accounts sharing the prefix specified.",
)
product_id = fields.Many2one(
'product.product',
string='Product',
ondelete='cascade',
check_company=True,
help="Select a product for which the analytic distribution will be used (e.g. create new customer invoice or Sales order if we select this product, it will automatically take this as an analytic account)",
)
product_categ_id = fields.Many2one(
@ -22,7 +23,48 @@ class AccountAnalyticDistributionModel(models.Model):
ondelete='cascade',
help="Select a product category which will use analytic account specified in analytic default (e.g. create new customer invoice or Sales order if we select this product, it will automatically take this as an analytic account)",
)
prefix_placeholder = fields.Char(compute='_compute_prefix_placeholder')
def _get_default_search_domain_vals(self):
return super()._get_default_search_domain_vals() | {
'product_id': False,
'product_categ_id': False,
}
def _get_applicable_models(self, vals):
applicable_models = super()._get_applicable_models(vals)
# Regex pattern to split by either ';' or ','
delimiter_pattern = re.compile(r'[;,]\s*')
return applicable_models.filtered(
lambda model:
not model.account_prefix or
any((vals.get('account_prefix') or '').startswith(prefix) for prefix in delimiter_pattern.split(model.account_prefix))
)
def _create_domain(self, fname, value):
if not fname == 'account_prefix':
return super()._create_domain(fname, value)
if fname == 'account_prefix':
return []
return super()._create_domain(fname, value)
# To be able to see the placeholder when creating a record in the list view, need to depends on a field that has a
# value directly, analytic precision has a default.
@api.depends('analytic_precision')
def _compute_prefix_placeholder(self):
expense_account = self.env['account.account'].search([
*self.env['account.account']._check_company_domain(self.env.company),
('account_type', '=', 'expense'),
], limit=1)
for model in self:
account_prefixes = "60, 61, 62"
if expense_account:
prefix_base = expense_account.code[:2]
try:
# Convert prefix_base to an integer for numerical manipulation
prefix_num = int(prefix_base)
account_prefixes = f"{prefix_num}, {prefix_num + 1}, {prefix_num + 2}"
except ValueError:
pass
model.prefix_placeholder = _("e.g. %(prefix)s", prefix=account_prefixes)

View file

@ -3,6 +3,7 @@
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class AccountAnalyticLine(models.Model):
_inherit = 'account.analytic.line'
_description = 'Analytic Line'
@ -16,7 +17,7 @@ class AccountAnalyticLine(models.Model):
'account.account',
string='Financial Account',
ondelete='restrict',
domain="[('deprecated', '=', False), ('company_id', '=', company_id)]",
check_company=True,
compute='_compute_general_account_id', store=True, readonly=False
)
journal_id = fields.Many2one(
@ -54,7 +55,7 @@ class AccountAnalyticLine(models.Model):
if line.move_line_id and line.general_account_id != line.move_line_id.account_id:
raise ValidationError(_('The journal item is not linked to the correct financial account'))
@api.depends('move_line_id')
@api.depends('move_line_id.partner_id')
def _compute_partner_id(self):
for line in self:
line.partner_id = line.move_line_id.partner_id or line.partner_id
@ -67,11 +68,11 @@ class AccountAnalyticLine(models.Model):
prod_accounts = self.product_id.product_tmpl_id.with_company(self.company_id)._get_product_accounts()
unit = self.product_uom_id
account = prod_accounts['expense']
if not unit or self.product_id.uom_po_id.category_id.id != unit.category_id.id:
unit = self.product_id.uom_po_id
if not unit:
unit = self.product_id.uom_id
# Compute based on pricetype
amount_unit = self.product_id.price_compute('standard_price', uom=unit)[self.product_id.id]
amount_unit = self.product_id._price_compute('standard_price', uom=unit)[self.product_id.id]
amount = amount_unit * self.unit_amount or 0.0
result = (self.currency_id.round(amount) if self.currency_id else round(amount, 2)) * -1
self.amount = result
@ -86,3 +87,24 @@ class AccountAnalyticLine(models.Model):
account=self.env['account.analytic.account'].browse(self.env.context['account_id']).name
)
return super().view_header_get(view_id, view_type)
@api.model_create_multi
def create(self, vals_list):
analytic_lines = super().create(vals_list)
analytic_lines.move_line_id._update_analytic_distribution()
return analytic_lines
def write(self, vals):
affected_move_lines = self.move_line_id
res = super().write(vals)
if any(field in vals for field in ['amount', 'move_line_id'] + self._get_plan_fnames()):
if 'move_line_id' in vals:
affected_move_lines |= self.move_line_id
affected_move_lines._update_analytic_distribution()
return res
def unlink(self):
affected_move_lines = self.move_line_id
res = super().unlink()
affected_move_lines._update_analytic_distribution()
return res

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
import re
from odoo import fields, models
from odoo import _, fields, models, api
class AccountAnalyticApplicability(models.Model):
@ -18,22 +18,53 @@ class AccountAnalyticApplicability(models.Model):
},
)
account_prefix = fields.Char(
string='Financial Accounts Prefix',
string='Financial Accounts Prefixes',
help="Prefix that defines which accounts from the financial accounting this applicability should apply on.",
)
product_categ_id = fields.Many2one(
'product.category',
string='Product Category'
)
display_account_prefix = fields.Boolean(
compute='_compute_display_account_prefix',
help='Defines if the field account prefix should be displayed'
)
account_prefix_placeholder = fields.Char(compute='_compute_prefix_placeholder')
@api.depends('account_prefix', 'business_domain')
def _compute_prefix_placeholder(self):
account_expense = self.env['account.account'].search([('account_type', '=', 'expense')], limit=1)
account_income = self.env['account.account'].search([('account_type', '=', 'income')], limit=1)
for applicability in self:
if applicability.business_domain == 'bill':
account = account_expense
account_prefixes = "60, 61, 62"
else:
account = account_income
account_prefixes = "40, 41, 42"
if account and account.code:
prefix_base = account.code[:2]
try:
# Convert prefix_base to an integer for numerical manipulation
prefix_num = int(prefix_base)
account_prefixes = f"{prefix_num}, {prefix_num + 1}, {prefix_num + 2}"
except ValueError:
pass
applicability.account_prefix_placeholder = _("e.g. %(prefix)s", prefix=account_prefixes)
def _get_score(self, **kwargs):
score = super(AccountAnalyticApplicability, self)._get_score(**kwargs)
if score == -1:
return -1
product = self.env['product.product'].browse(kwargs.get('product', None))
account = self.env['account.account'].browse(kwargs.get('account', None))
product = self.env['product.product'].browse(kwargs.get('product'))
account = self.env['account.account'].browse(kwargs.get('account'))
if self.account_prefix:
if account and account.code.startswith(self.account_prefix):
account_prefixes = tuple(prefix for prefix in re.split("[,;]", self.account_prefix.replace(" ", "")) if prefix)
if account.code and account.code.startswith(account_prefixes):
score += 1
else:
return -1
@ -43,3 +74,8 @@ class AccountAnalyticApplicability(models.Model):
else:
return -1
return score
@api.depends('business_domain')
def _compute_display_account_prefix(self):
for applicability in self:
applicability.display_account_prefix = applicability.business_domain in ('general', 'invoice', 'bill')

View file

@ -3,10 +3,12 @@ from contextlib import contextmanager
from odoo import api, fields, models, _, Command
from odoo.exceptions import UserError
from odoo.fields import Domain
from odoo.tools.misc import formatLang
class AccountBankStatement(models.Model):
_name = "account.bank.statement"
_name = 'account.bank.statement'
_description = "Bank Statement"
_order = "first_line_index desc"
_check_company_auto = True
@ -25,8 +27,8 @@ class AccountBankStatement(models.Model):
)
date = fields.Date(
compute='_compute_date_index', store=True,
index=True,
compute='_compute_date', store=True,
index=True, readonly=False,
)
# The internal index of the first line of a statement, it is used for sorting the statements
@ -34,7 +36,7 @@ class AccountBankStatement(models.Model):
# keeping this order is important because the validity of the statements are based on their order
first_line_index = fields.Char(
comodel_name='account.bank.statement.line',
compute='_compute_date_index', store=True, index=True,
compute='_compute_first_line_index', store=True,
)
balance_start = fields.Monetary(
@ -60,7 +62,7 @@ class AccountBankStatement(models.Model):
currency_id = fields.Many2one(
comodel_name='res.currency',
compute='_compute_currency_id',
compute='_compute_currency_id', store=True,
)
journal_id = fields.Many2one(
@ -73,7 +75,6 @@ class AccountBankStatement(models.Model):
comodel_name='account.bank.statement.line',
inverse_name='statement_id',
string='Statement lines',
required=True,
)
# A statement assumed to be complete when the sum of encoded lines is equal to the difference between start and
@ -93,6 +94,10 @@ class AccountBankStatement(models.Model):
search='_search_is_valid',
)
journal_has_invalid_statements = fields.Boolean(
related='journal_id.has_invalid_statements',
)
problem_description = fields.Text(
compute='_compute_problem_description',
)
@ -100,8 +105,12 @@ class AccountBankStatement(models.Model):
attachment_ids = fields.Many2many(
comodel_name='ir.attachment',
string="Attachments",
bypass_search_access=True,
)
_journal_id_date_desc_id_desc_idx = models.Index("(journal_id, date DESC, id DESC)")
_first_line_index_idx = models.Index("(journal_id, first_line_index)")
# -------------------------------------------------------------------------
# COMPUTE METHODS
# -------------------------------------------------------------------------
@ -109,15 +118,24 @@ class AccountBankStatement(models.Model):
@api.depends('create_date')
def _compute_name(self):
for stmt in self:
stmt.name = _("%s Statement %s", stmt.journal_id.code, stmt.date)
name = ''
if stmt.journal_id:
name = stmt.journal_id.code + ' '
stmt.name = name +_("Statement %(date)s", date=stmt.date or fields.Date.to_date(stmt.create_date))
@api.depends('line_ids.internal_index', 'line_ids.state')
def _compute_date_index(self):
def _compute_first_line_index(self):
for stmt in self:
# When we create lines manually from the form view, they don't have any `internal_index` set yet.
sorted_lines = stmt.line_ids.filtered("internal_index").sorted('internal_index')
stmt.first_line_index = sorted_lines[:1].internal_index
stmt.date = sorted_lines.filtered(lambda l: l.state == 'posted')[-1:].date
@api.depends('line_ids.internal_index', 'line_ids.state')
def _compute_date(self):
for statement in self:
# When we create lines manually from the form view, they don't have any `internal_index` set yet.
sorted_lines = statement.line_ids.filtered('internal_index').sorted('internal_index')
statement.date = sorted_lines.filtered(lambda l: l.state == 'posted')[-1:].date
@api.depends('create_date')
def _compute_balance_start(self):
@ -159,7 +177,7 @@ class AccountBankStatement(models.Model):
for stmt in self:
stmt.balance_end_real = stmt.balance_end
@api.depends('journal_id')
@api.depends('journal_id.currency_id', 'company_id.currency_id')
def _compute_currency_id(self):
for statement in self:
statement.currency_id = statement.journal_id.currency_id or statement.company_id.currency_id
@ -199,11 +217,9 @@ class AccountBankStatement(models.Model):
stmt.problem_description = description
def _search_is_valid(self, operator, value):
if operator not in ('=', '!=', '<>'):
raise UserError(_('Operation not supported'))
if operator != 'in':
return NotImplemented
invalid_ids = self._get_invalid_statement_ids(all_statements=True)
if operator in ('!=', '<>') and value or operator == '=' and not value:
return [('id', 'in', invalid_ids)]
return [('id', 'not in', invalid_ids)]
# -------------------------------------------------------------------------
@ -215,6 +231,7 @@ class AccountBankStatement(models.Model):
previous = self.env['account.bank.statement'].search(
[
('first_line_index', '<', self.first_line_index),
('first_line_index', '!=', False),
('journal_id', '=', self.journal_id.id),
],
limit=1,
@ -229,22 +246,29 @@ class AccountBankStatement(models.Model):
self.env['account.bank.statement'].flush_model(['balance_start', 'balance_end_real', 'first_line_index'])
self.env.cr.execute(f"""
SELECT st.id
FROM account_bank_statement st
LEFT JOIN res_company co ON st.company_id = co.id
LEFT JOIN account_journal j ON st.journal_id = j.id
LEFT JOIN res_currency currency ON COALESCE(j.currency_id, co.currency_id) = currency.id,
LATERAL (
SELECT balance_end_real
FROM account_bank_statement st_lookup
WHERE st_lookup.first_line_index < st.first_line_index
AND st_lookup.journal_id = st.journal_id
ORDER BY st_lookup.first_line_index desc
LIMIT 1
) prev
WHERE ROUND(prev.balance_end_real, currency.decimal_places) != ROUND(st.balance_start, currency.decimal_places)
{"" if all_statements else "AND st.id IN %(ids)s"}
WITH statements AS (
SELECT st.id,
st.balance_start,
st.journal_id,
LAG(st.balance_end_real) OVER (
PARTITION BY st.journal_id
ORDER BY st.first_line_index
) AS prev_balance_end_real,
currency.decimal_places
FROM account_bank_statement st
LEFT JOIN res_company co ON st.company_id = co.id
LEFT JOIN account_journal j ON st.journal_id = j.id
LEFT JOIN res_currency currency ON COALESCE(j.currency_id, co.currency_id) = currency.id
WHERE st.first_line_index IS NOT NULL
{"" if all_statements else "AND st.journal_id IN %(journal_ids)s"}
)
SELECT id
FROM statements
WHERE prev_balance_end_real IS NOT NULL
AND ROUND(prev_balance_end_real, decimal_places) != ROUND(balance_start, decimal_places)
{"" if all_statements else "AND id IN %(ids)s"};
""", {
'journal_ids': tuple(set(self.journal_id.ids)),
'ids': tuple(self.ids)
})
res = self.env.cr.fetchall()
@ -255,16 +279,16 @@ class AccountBankStatement(models.Model):
# -------------------------------------------------------------------------
@api.model
def default_get(self, fields_list):
def default_get(self, fields):
# EXTENDS base
defaults = super().default_get(fields_list)
defaults = super().default_get(fields)
if 'line_ids' not in fields_list:
if 'line_ids' not in fields:
return defaults
active_ids = self._context.get('active_ids')
context_split_line_id = self._context.get('split_line_id')
context_st_line_id = self._context.get('st_line_id')
active_ids = self.env.context.get('active_ids', [])
context_split_line_id = self.env.context.get('split_line_id')
context_st_line_id = self.env.context.get('st_line_id')
lines = None
# creating statements with split button
if context_split_line_id:
@ -339,11 +363,11 @@ class AccountBankStatement(models.Model):
container['records'] = stmts = super().create(vals_list)
return stmts
def write(self, values):
if len(self) != 1 and 'attachment_ids' in values:
values.pop('attachment_ids')
def write(self, vals):
if len(self) != 1 and 'attachment_ids' in vals:
vals.pop('attachment_ids')
container = {'records': self}
with self._check_attachments(container, [values]):
result = super().write(values)
with self._check_attachments(container, [vals]):
result = super().write(vals)
return result

View file

@ -1,16 +1,15 @@
from odoo import api, Command, fields, models, _
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from odoo.tools import html2plaintext
from odoo.tools.misc import str2bool
from odoo.addons.base.models.res_bank import sanitize_account_number
from odoo.fields import Command, Domain
from xmlrpc.client import MAXINT
from itertools import product
from odoo.tools import SQL
from odoo.tools.misc import str2bool
class AccountBankStatementLine(models.Model):
_name = "account.bank.statement.line"
_name = 'account.bank.statement.line'
_inherits = {'account.move': 'move_id'}
_description = "Bank Statement Line"
_order = "internal_index desc"
@ -23,34 +22,30 @@ class AccountBankStatementLine(models.Model):
# - there should be a better way for syncing account_moves with bank transactions, payments, invoices, etc.
# == Business fields ==
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
# copy the date and statement from the latest transaction of the same journal to help the user
# to enter the next transaction, they do not have to enter the date and the statement every time until the
# statement is completed. It is only possible if we know the journal that is used, so it can only be done
# in a view in which the journal is already set and so is single journal view.
if 'journal_id' in defaults and 'date' in fields_list:
last_line = self.search([
('journal_id', '=', defaults.get('journal_id')),
('state', '=', 'posted'),
], limit=1)
statement = last_line.statement_id
if statement:
defaults.setdefault('date', statement.date)
elif last_line:
defaults.setdefault('date', last_line.date)
return defaults
move_id = fields.Many2one(
comodel_name='account.move',
auto_join=True,
bypass_search_access=True,
string='Journal Entry', required=True, readonly=True, ondelete='cascade',
index=True,
check_company=True)
journal_id = fields.Many2one(
comodel_name='account.journal',
inherited=True,
related='move_id.journal_id', store=True, readonly=False, precompute=True,
index=False, # covered by account_bank_statement_line_main_idx
required=True,
)
company_id = fields.Many2one(
comodel_name='res.company',
inherited=True,
related='move_id.company_id', store=True, readonly=False, precompute=True,
index=False, # covered by account_bank_statement_line_main_idx
required=True,
)
statement_id = fields.Many2one(
comodel_name='account.bank.statement',
string='Statement',
index=True,
)
# Payments generated during the reconciliation of this bank statement lines.
@ -73,11 +68,11 @@ class AccountBankStatementLine(models.Model):
# This field is used to record the third party name when importing bank statement in electronic format,
# when the partner doesn't exist yet in the database (or cannot be found).
partner_name = fields.Char()
partner_name = fields.Char(index='btree_not_null')
# Transaction type is used in electronic format, when the type of transaction is available in the imported file.
transaction_type = fields.Char()
payment_ref = fields.Char(string='Label')
payment_ref = fields.Char(string='Label', index='trigram')
currency_id = fields.Many2one(
comodel_name='res.currency',
string='Journal Currency',
@ -129,7 +124,6 @@ class AccountBankStatementLine(models.Model):
internal_index = fields.Char(
string='Internal Reference',
compute='_compute_internal_index', store=True,
index=True,
)
# Technical field indicating if the statement line is already reconciled.
@ -143,6 +137,20 @@ class AccountBankStatementLine(models.Model):
statement_valid = fields.Boolean(
related='statement_id.is_valid',
)
statement_balance_end_real = fields.Monetary(
related='statement_id.balance_end_real',
)
statement_name = fields.Char(
string="Statement Name",
related='statement_id.name',
)
# Technical field to store details about the bank statement line
transaction_details = fields.Json(readonly=True)
_unreconciled_idx = models.Index("(journal_id, company_id, internal_index) WHERE is_reconciled IS NOT TRUE")
_orphan_idx = models.Index("(journal_id, company_id, internal_index) WHERE statement_id IS NULL")
_main_idx = models.Index("(journal_id, company_id, internal_index)")
# -------------------------------------------------------------------------
# COMPUTE METHODS
@ -176,10 +184,11 @@ class AccountBankStatementLine(models.Model):
# the user can split on that lines, but their balance should be the same as previous posted line
# we do the same for the canceled lines, in order to keep using them as anchor points
self.statement_id.flush_model(['balance_start', 'first_line_index'])
self.flush_model(['internal_index', 'date', 'journal_id', 'statement_id', 'amount', 'state'])
record_by_id = {x.id: x for x in self}
company2children = {
company: self.env['res.company'].search([('id', 'child_of', company.id)])
for company in self.journal_id.company_id
}
for journal in self.journal_id:
journal_lines_indexes = self.filtered(lambda line: line.journal_id == journal)\
.sorted('internal_index')\
@ -187,7 +196,8 @@ class AccountBankStatementLine(models.Model):
min_index, max_index = journal_lines_indexes[0], journal_lines_indexes[-1]
# Find the oldest index for each journal.
self._cr.execute(
self.env['account.bank.statement'].flush_model(['first_line_index', 'journal_id', 'balance_start'])
self.env.cr.execute(
"""
SELECT first_line_index, COALESCE(balance_start, 0.0)
FROM account_bank_statement
@ -197,19 +207,20 @@ class AccountBankStatementLine(models.Model):
ORDER BY first_line_index DESC
LIMIT 1
""",
[min_index, journal.id],
[min_index or '', journal.id],
)
current_running_balance = 0.0
extra_clause = ''
extra_params = []
row = self._cr.fetchone()
extra_clause = SQL()
row = self.env.cr.fetchone()
if row:
starting_index, current_running_balance = row
extra_clause = "AND st_line.internal_index >= %s"
extra_params.append(starting_index)
extra_clause = SQL("AND st_line.internal_index >= %s", starting_index)
self._cr.execute(
f"""
self.flush_model(['amount', 'move_id', 'statement_id', 'journal_id', 'internal_index'])
self.env['account.bank.statement'].flush_model(['first_line_index', 'balance_start'])
self.env['account.move'].flush_model(['state'])
self.env.cr.execute(SQL(
"""
SELECT
st_line.id,
st_line.amount,
@ -221,14 +232,18 @@ class AccountBankStatementLine(models.Model):
LEFT JOIN account_bank_statement st ON st.id = st_line.statement_id
WHERE
st_line.internal_index <= %s
AND move.journal_id = %s
{extra_clause}
AND st_line.journal_id = %s
AND st_line.company_id = ANY(%s)
%s
ORDER BY st_line.internal_index
""",
[max_index, journal.id] + extra_params,
)
max_index or '',
journal.id,
company2children[journal.company_id].ids,
extra_clause,
))
pending_items = self
for st_line_id, amount, is_anchor, balance_start, state in self._cr.fetchall():
for st_line_id, amount, is_anchor, balance_start, state in self.env.cr.fetchall():
if is_anchor:
current_running_balance = balance_start
if state == 'posted':
@ -265,7 +280,7 @@ class AccountBankStatementLine(models.Model):
f'{st_line._origin.id:0>10}'
@api.depends('journal_id', 'currency_id', 'amount', 'foreign_currency_id', 'amount_currency',
'move_id.to_check',
'move_id.checked',
'move_id.line_ids.account_id', 'move_id.line_ids.amount_currency',
'move_id.line_ids.amount_residual_currency', 'move_id.line_ids.currency_id',
'move_id.line_ids.matched_debit_ids', 'move_id.line_ids.matched_credit_ids')
@ -278,7 +293,7 @@ class AccountBankStatementLine(models.Model):
_liquidity_lines, suspense_lines, _other_lines = st_line._seek_for_lines()
# Compute residual amount
if st_line.to_check:
if not st_line.checked:
st_line.amount_residual = -st_line.amount_currency if st_line.foreign_currency_id else -st_line.amount
elif suspense_lines.account_id.reconcile:
st_line.amount_residual = sum(suspense_lines.mapped('amount_residual_currency'))
@ -298,7 +313,6 @@ class AccountBankStatementLine(models.Model):
# The journal entry seems reconciled.
st_line.is_reconciled = True
# -------------------------------------------------------------------------
# CONSTRAINT METHODS
# -------------------------------------------------------------------------
@ -322,11 +336,32 @@ class AccountBankStatementLine(models.Model):
# LOW-LEVEL METHODS
# -------------------------------------------------------------------------
@api.model
def default_get(self, fields):
self_ctx = self.with_context(is_statement_line=True)
defaults = super(AccountBankStatementLine, self_ctx).default_get(fields)
if 'journal_id' in fields and not defaults.get('journal_id'):
defaults['journal_id'] = self_ctx.env['account.move']._search_default_journal().id
if 'date' in fields and not defaults.get('date') and 'journal_id' in defaults:
# copy the date and statement from the latest transaction of the same journal to help the user
# to enter the next transaction, they do not have to enter the date and the statement every time until the
# statement is completed. It is only possible if we know the journal that is used, so it can only be done
# in a view in which the journal is already set and so is single journal view.
last_line = self.search([
('journal_id', '=', defaults['journal_id']),
('state', '=', 'posted'),
], limit=1)
statement = last_line.statement_id
if statement:
defaults.setdefault('date', statement.date)
elif last_line:
defaults.setdefault('date', last_line.date)
return defaults
@api.model
def new(self, values=None, origin=None, ref=None):
st_line = super().new(values, origin, ref)
if not st_line.journal_id: # might not be computed because declared by inheritance
st_line.move_id._compute_journal_id()
return st_line
return super(AccountBankStatementLine, self.with_context(is_statement_line=True)).new(values, origin, ref)
@api.model_create_multi
def create(self, vals_list):
@ -361,54 +396,61 @@ class AccountBankStatementLine(models.Model):
if 'amount' not in vals:
vals['amount'] = 0
st_lines = super().create(vals_list)
for i, st_line in enumerate(st_lines):
counterpart_account_id = counterpart_account_ids[i]
to_write = {'statement_line_id': st_line.id, 'narration': st_line.narration}
st_lines = super(AccountBankStatementLine, self.with_context(is_statement_line=True)).create([{
'name': False,
**vals,
} for vals in vals_list])
to_create_lines_vals = []
for i, (st_line, vals) in enumerate(zip(st_lines, vals_list)):
if 'line_ids' not in vals_list[i]:
to_write['line_ids'] = [(0, 0, line_vals) for line_vals in st_line._prepare_move_line_default_vals(
counterpart_account_id=counterpart_account_id)]
to_create_lines_vals.extend(
line_vals
for line_vals in st_line._prepare_move_line_default_vals(counterpart_account_ids[i])
)
to_write = {'statement_line_id': st_line.id, 'narration': st_line.narration, 'name': False}
with self.env.protecting(self.env['account.move']._get_protected_vals(vals, st_line)):
st_line.move_id.write(to_write)
self.env['account.move.line'].create(to_create_lines_vals)
self.env.add_to_compute(self.env['account.move']._fields['name'], st_lines.move_id)
st_line.move_id.write(to_write)
# Otherwise field narration will be recomputed silently (at next flush) when writing on partner_id
self.env.remove_to_compute(st_line.move_id._fields['narration'], st_line.move_id)
# Otherwise field narration will be recomputed silently (at next flush) when writing on partner_id
self.env.remove_to_compute(self.env['account.move']._fields['narration'], st_lines.move_id)
# No need for the user to manage their status (from 'Draft' to 'Posted')
st_lines.move_id.action_post()
return st_lines
return st_lines.with_env(self.env) # clear the context
def write(self, vals):
# OVERRIDE
res = super().write(vals)
res = super(AccountBankStatementLine, self.with_context(skip_readonly_check=True)).write(vals)
self._synchronize_to_moves(set(vals.keys()))
return res
def unlink(self):
# OVERRIDE to unlink the inherited account.move (move_id field) as well.
moves = self.with_context(force_delete=True).mapped('move_id')
tracked_lines = self.filtered(lambda stl: stl.company_id.restrictive_audit_trail)
tracked_lines.move_id.button_cancel()
moves_to_delete = (self - tracked_lines).move_id
res = super().unlink()
moves.unlink()
moves_to_delete.with_context(force_delete=True).unlink()
return res
@api.model
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
# Add latest running_balance in the read_group
result = super(AccountBankStatementLine, self).read_group(
domain, fields, groupby, offset=offset,
limit=limit, orderby=orderby, lazy=lazy)
def formatted_read_group(self, domain, groupby=(), aggregates=(), having=(), offset=0, limit=None, order=None) -> list[dict]:
# Add latest running_balance in the formatted_read_group
result = super().formatted_read_group(
domain, groupby, aggregates, having=having,
offset=offset, limit=limit, order=order)
show_running_balance = False
# We loop over the content of groupby because the groupby date is in the form of "date:granularity"
for el in groupby:
if (el == 'statement_id' or el == 'journal_id' or el.startswith('date')) and 'running_balance' in fields:
if (el == 'statement_id' or el == 'journal_id' or el.startswith('date')) and self.env.context.get('show_running_balance_latest'):
show_running_balance = True
break
if show_running_balance:
for group_line in result:
group_line['running_balance'] = self.search(group_line.get('__domain'), limit=1).running_balance or 0.0
group_line['running_balance'] = self.search(group_line['__extra_domain'] + domain, limit=1).running_balance or 0.0
return result
# -------------------------------------------------------------------------
@ -423,8 +465,8 @@ class AccountBankStatementLine(models.Model):
self.payment_ids.unlink()
for st_line in self:
st_line.with_context(force_delete=True).write({
'to_check': False,
st_line.with_context(force_delete=True, skip_readonly_check=True).write({
'checked': True,
'line_ids': [Command.clear()] + [
Command.create(line_vals) for line_vals in st_line._prepare_move_line_default_vals()],
})
@ -433,61 +475,48 @@ class AccountBankStatementLine(models.Model):
# HELPERS
# -------------------------------------------------------------------------
@api.ondelete(at_uninstall=False)
def _check_allow_unlink(self):
if self.statement_id.filtered(lambda stmt: stmt.is_valid and stmt.is_complete):
raise UserError(_("You can not delete a transaction from a valid statement.\n"
"If you want to delete it, please remove the statement first."))
def _find_or_create_bank_account(self):
self.ensure_one()
if not self.partner_id:
return self.env['res.partner.bank']
if str2bool(self.env['ir.config_parameter'].sudo().get_param("account.skip_create_bank_account_on_reconcile")):
return self.env['res.partner.bank'].search([
('acc_number', '=', self.account_number),
('partner_id', '=', self.partner_id.id),
('company_id', 'in', [False, self.company_id.id]),
], limit=1)
return self.env['res.partner.bank']._find_or_create_bank_account(
account_number=self.account_number,
partner=self.partner_id,
company=self.company_id,
)
# There is a sql constraint on res.partner.bank ensuring an unique pair <partner, account number>.
# Since it's not dependent of the company, we need to search on others company too to avoid the creation
# of an extra res.partner.bank raising an error coming from this constraint.
# However, at the end, we need to filter out the results to not trigger the check_company when trying to
# assign a res.partner.bank owned by another company.
bank_account = self.env['res.partner.bank'].sudo().with_context(active_test=False).search([
('acc_number', '=', self.account_number),
('partner_id', '=', self.partner_id.id),
])
if not bank_account and not str2bool(
self.env['ir.config_parameter'].sudo().get_param("account.skip_create_bank_account_on_reconcile")
):
bank_account = self.env['res.partner.bank'].create({
'acc_number': self.account_number,
'partner_id': self.partner_id.id,
'journal_id': None,
})
return bank_account.filtered(lambda x: x.company_id.id in (False, self.company_id.id))
def _get_amounts_with_currencies(self):
"""
Returns the line amount in company, journal and foreign currencies
"""
def _get_default_amls_matching_domain(self, allow_draft=False):
self.ensure_one()
company_currency = self.journal_id.company_id.currency_id
journal_currency = self.journal_id.currency_id or company_currency
foreign_currency = self.foreign_currency_id or journal_currency or company_currency
journal_amount = self.amount
if foreign_currency == journal_currency:
transaction_amount = journal_amount
else:
transaction_amount = self.amount_currency
if journal_currency == company_currency:
company_amount = journal_amount
elif foreign_currency == company_currency:
company_amount = transaction_amount
else:
company_amount = journal_currency._convert(journal_amount, company_currency,
self.journal_id.company_id, self.date)
return company_amount, company_currency, journal_amount, journal_currency, transaction_amount, foreign_currency
def _get_default_amls_matching_domain(self):
return [
all_reconcilable_account_ids = self.env['account.account'].sudo().search([
("company_ids", "child_of", self.company_id.root_id.id),
('reconcile', '=', True),
]).ids
state_domain = [('parent_state', '=', 'posted')]
if allow_draft:
# Set if bank recon will display draft invoices/bills that have a partner.
# Usually not applied when used by bank recon models (no suggestions & auto matching for draft entries)
partnered_drafts_domain = [('parent_state', '=', 'draft'), ('partner_id', '!=', False)]
state_domain = Domain.OR([state_domain, partnered_drafts_domain])
return state_domain + [
# Base domain.
('display_type', 'not in', ('line_section', 'line_note')),
('parent_state', '=', 'posted'),
('company_id', '=', self.company_id.id),
('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')),
('company_id', 'in', self.env['res.company'].search([('id', 'child_of', self.company_id.id)]).ids), # allow to match invoices from same or children companies to be consistant with what's shown in the interface
# Reconciliation domain.
('reconciled', '=', False),
('account_id.reconcile', '=', True),
# Domain to use the account_move_line__unreconciled_index
('account_id', 'in', all_reconcilable_account_ids),
# Special domain for payments.
'|',
('account_id.account_type', 'not in', ('asset_receivable', 'liability_payable')),
@ -500,30 +529,21 @@ class AccountBankStatementLine(models.Model):
def _get_default_journal(self):
journal_type = self.env.context.get('journal_type', 'bank')
return self.env['account.journal'].search([
*self.env['account.journal']._check_company_domain(self.env.company),
('type', '=', journal_type),
('company_id', '=', self.env.company.id)
], limit=1)
def _get_st_line_strings_for_matching(self, allowed_fields=None):
""" Collect the strings that could be used on the statement line to perform some matching.
:param allowed_fields: A explicit list of fields to consider.
:return: A list of strings.
"""
self.ensure_one()
st_line_text_values = []
if not allowed_fields or 'payment_ref' in allowed_fields:
if self.payment_ref:
st_line_text_values.append(self.payment_ref)
if not allowed_fields or 'narration' in allowed_fields:
value = html2plaintext(self.narration or "")
if value:
st_line_text_values.append(value)
if not allowed_fields or 'ref' in allowed_fields:
if self.ref:
st_line_text_values.append(self.ref)
return st_line_text_values
@api.model
def _get_default_statement(self, journal_id=None, date=None):
statement = self.search(
domain=[
('journal_id', '=', journal_id or self._get_default_journal().id),
('date', '<=', date or fields.Date.today()),
],
limit=1
).statement_id
if not statement.is_complete:
return statement
def _get_accounting_amounts_and_currencies(self):
""" Retrieve the transaction amount, journal amount and the company amount with their corresponding currencies
@ -618,8 +638,22 @@ class AccountBankStatementLine(models.Model):
self.journal_id.display_name,
))
company_amount, _company_currency, journal_amount, journal_currency, transaction_amount, foreign_currency \
= self._get_amounts_with_currencies()
company_currency = self.journal_id.company_id.sudo().currency_id
journal_currency = self.journal_id.currency_id or company_currency
foreign_currency = self.foreign_currency_id or journal_currency or company_currency
journal_amount = self.amount
if foreign_currency == journal_currency:
transaction_amount = journal_amount
else:
transaction_amount = self.amount_currency
if journal_currency == company_currency:
company_amount = journal_amount
elif foreign_currency == company_currency:
company_amount = transaction_amount
else:
company_amount = journal_currency\
._convert(journal_amount, company_currency, self.journal_id.company_id, self.date)
liquidity_line_vals = {
'name': self.payment_ref,
@ -645,53 +679,6 @@ class AccountBankStatementLine(models.Model):
}
return [liquidity_line_vals, counterpart_line_vals]
def _retrieve_partner(self):
self.ensure_one()
# Retrieve the partner from the statement line.
if self.partner_id:
return self.partner_id
# Retrieve the partner from the bank account.
if self.account_number:
account_number_nums = sanitize_account_number(self.account_number)
if account_number_nums:
domain = [('sanitized_acc_number', 'ilike', account_number_nums)]
for extra_domain in ([('company_id', '=', self.company_id.id)], [('company_id', '=', False)]):
bank_accounts = self.env['res.partner.bank'].search(extra_domain + domain)
if len(bank_accounts.partner_id) == 1:
return bank_accounts.partner_id
# Retrieve the partner from the partner name.
if self.partner_name:
domains = product(
[
('name', '=ilike', self.partner_name),
('name', 'ilike', self.partner_name),
],
[
('company_id', '=', self.company_id.id),
('company_id', '=', False),
],
)
for domain in domains:
partner = self.env['res.partner'].search(list(domain) + [('parent_id', '=', False)], limit=2)
# Return the partner if there is only one with this name
if len(partner) == 1:
return partner
# Retrieve the partner from the 'reconcile models'.
rec_models = self.env['account.reconcile.model'].search([
('rule_type', '!=', 'writeoff_button'),
('company_id', '=', self.company_id.id),
])
for rec_model in rec_models:
partner = rec_model._get_partner_from_mapping(self)
if partner and rec_model._is_applicable_for(self, partner):
return partner
return self.env['res.partner']
def _seek_for_lines(self):
""" Helper used to dispatch the journal items between:
- The lines using the liquidity account.
@ -723,7 +710,7 @@ class AccountBankStatementLine(models.Model):
Also, check both models are still consistent.
:param changed_fields: A set containing all modified fields on account.move.
"""
if self._context.get('skip_account_move_synchronization'):
if self.env.context.get('skip_account_move_synchronization'):
return
for st_line in self.with_context(skip_account_move_synchronization=True):
@ -732,7 +719,7 @@ class AccountBankStatementLine(models.Model):
st_line_vals_to_write = {}
if 'line_ids' in changed_fields:
liquidity_lines, suspense_lines, _other_lines = st_line._seek_for_lines()
liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines()
company_currency = st_line.journal_id.company_id.currency_id
journal_currency = st_line.journal_id.currency_id if st_line.journal_id.currency_id != company_currency\
else False
@ -741,8 +728,8 @@ class AccountBankStatementLine(models.Model):
raise UserError(_(
"The journal entry %s reached an invalid state regarding its related statement line.\n"
"To be consistent, the journal entry must always have exactly one journal item involving the "
"bank/cash account."
) % st_line.move_id.display_name)
"bank/cash account.",
st_line.move_id.display_name))
st_line_vals_to_write.update({
'payment_ref': liquidity_lines.name,
@ -762,8 +749,9 @@ class AccountBankStatementLine(models.Model):
if len(suspense_lines) > 1:
raise UserError(_(
"%s reached an invalid state regarding its related statement line.\n"
"To be consistent, the journal entry must always have exactly one suspense line.", st_line.move_id.display_name
"%(move)s reached an invalid state regarding its related statement line.\n"
"To be consistent, the journal entry must always have exactly one suspense line.",
move=st_line.move_id.display_name,
))
elif len(suspense_lines) == 1:
if journal_currency and suspense_lines.currency_id == journal_currency:
@ -785,7 +773,7 @@ class AccountBankStatementLine(models.Model):
'foreign_currency_id': False,
})
else:
elif not other_lines:
# Update the statement line regarding the foreign currency of the suspense line.
@ -799,14 +787,14 @@ class AccountBankStatementLine(models.Model):
'currency_id': (st_line.foreign_currency_id or journal_currency or company_currency).id,
})
move.write(move._cleanup_write_orm_values(move, move_vals_to_write))
move.with_context(skip_readonly_check=True).write(move._cleanup_write_orm_values(move, move_vals_to_write))
st_line.write(move._cleanup_write_orm_values(st_line, st_line_vals_to_write))
def _synchronize_to_moves(self, changed_fields):
""" Update the account.move regarding the modified account.bank.statement.line.
:param changed_fields: A list containing all modified fields on account.bank.statement.line.
"""
if self._context.get('skip_account_move_synchronization'):
if self.env.context.get('skip_account_move_synchronization'):
return
if not any(field_name in changed_fields for field_name in (
@ -818,7 +806,8 @@ class AccountBankStatementLine(models.Model):
for st_line in self.with_context(skip_account_move_synchronization=True):
liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines()
journal = st_line.journal_id
company_currency = journal.company_id.currency_id
# bypassing access rights restrictions for branch-specific users in a branch company environment.
company_currency = journal.company_id.sudo().currency_id
journal_currency = journal.currency_id if journal.currency_id != company_currency else False
line_vals_list = st_line._prepare_move_line_default_vals()
@ -840,13 +829,14 @@ class AccountBankStatementLine(models.Model):
st_line_vals['journal_id'] = journal.id
if st_line.move_id.partner_id != st_line.partner_id:
st_line_vals['partner_id'] = st_line.partner_id.id
st_line.move_id.write(st_line_vals)
st_line.move_id.with_context(skip_readonly_check=True).write(st_line_vals)
# For optimization purpose, creating the reverse relation of m2o in _inherits saves
# a lot of SQL queries
class AccountMove(models.Model):
_name = "account.move"
_inherit = ['account.move']
_inherit = 'account.move'
statement_line_ids = fields.One2many('account.bank.statement.line', 'move_id', string='Statements')

View file

@ -14,6 +14,7 @@ class AccountCashRounding(models.Model):
"""
_name = 'account.cash.rounding'
_description = 'Account Cash Rounding'
_check_company_auto = True
name = fields.Char(string='Name', translate=True, required=True)
rounding = fields.Float(string='Rounding Precision', required=True, default=0.01,
@ -21,12 +22,25 @@ class AccountCashRounding(models.Model):
strategy = fields.Selection([('biggest_tax', 'Modify tax amount'), ('add_invoice_line', 'Add a rounding line')],
string='Rounding Strategy', default='add_invoice_line', required=True,
help='Specify which way will be used to round the invoice amount to the rounding precision')
profit_account_id = fields.Many2one('account.account', string='Profit Account', company_dependent=True, domain="[('deprecated', '=', False), ('company_id', '=', current_company_id)]")
loss_account_id = fields.Many2one('account.account', string='Loss Account', company_dependent=True, domain="[('deprecated', '=', False), ('company_id', '=', current_company_id)]")
profit_account_id = fields.Many2one(
'account.account',
string='Profit Account',
company_dependent=True,
check_company=True,
domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable'))]",
ondelete='restrict',
)
loss_account_id = fields.Many2one(
'account.account',
string='Loss Account',
company_dependent=True,
check_company=True,
domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable'))]",
ondelete='restrict',
)
rounding_method = fields.Selection(string='Rounding Method', required=True,
selection=[('UP', 'Up'), ('DOWN', 'Down'), ('HALF-UP', 'Nearest')],
default='HALF-UP', help='The tie-breaking rule used for float rounding operations')
company_id = fields.Many2one('res.company', related='profit_account_id.company_id')
@api.constrains('rounding')
def validate_rounding(self):

View file

@ -0,0 +1,86 @@
from odoo import fields, models, api, _
from odoo.fields import Domain
from odoo.exceptions import UserError
from odoo.tools import Query
COMPANY_OFFSET = 10000
class AccountCodeMapping(models.Model):
# This model is used purely for UI, to display the account codes for each company.
# It is not stored in DB. Instead, records are only populated in cache by the
# `_search` override when accessing the One2many on `account.account`.
_name = 'account.code.mapping'
_description = "Mapping of account codes per company"
_auto = False
_table_query = '0'
account_id = fields.Many2one(
comodel_name='account.account',
string="Account",
compute='_compute_account_id',
# suppress warning about field not being searchable (due to being used in depends);
# searching is actually implemented in the `_search` override.
search=True,
)
company_id = fields.Many2one(
comodel_name='res.company',
string="Company",
compute='_compute_company_id',
readonly=False, # TODO remove in master (kept in stable because of view change)
)
code = fields.Char(
string="Code",
compute='_compute_code',
inverse='_inverse_code',
)
@api.model_create_multi
def create(self, vals_list):
mappings = self.browse([
vals['account_id'] * COMPANY_OFFSET + vals['company_id']
for vals in vals_list
])
for mapping, vals in zip(mappings, vals_list):
mapping.code = vals['code']
return mappings
def _search(self, domain, offset=0, limit=None, order=None, **kw) -> Query:
account_ids = []
def get_accounts(condition):
if not account_ids and condition.field_expr == 'account_id' and condition.operator == 'in':
account_ids.extend(condition.value)
return Domain(bool(condition.value))
return condition
remaining_domain = Domain(domain).map_conditions(get_accounts)
if not account_ids:
raise UserError(_(
"Account Code Mapping cannot be accessed directly. "
"It is designed to be used only through the Chart of Accounts."
))
return self.browse([
account_id * COMPANY_OFFSET + company.id
for account_id in account_ids
for company in self.env.user.with_context(active_test=True).company_ids.sorted(lambda c: (c.sequence, c.name))
]).filtered_domain(remaining_domain)._as_query()
def _compute_account_id(self):
for record in self:
record.account_id = record._origin.id // COMPANY_OFFSET
def _compute_company_id(self):
for record in self:
record.company_id = record._origin.id % COMPANY_OFFSET
@api.depends('account_id.code')
def _compute_code(self):
for record in self:
account = record.account_id.with_company(record.company_id._origin)
record.code = account.code
def _inverse_code(self):
for record in self:
record.account_id.with_company(record.company_id).write({'code': record.code})

View file

@ -0,0 +1,556 @@
from contextlib import contextmanager
from copy import deepcopy
import difflib
import io
import itertools
import logging
from lxml import etree
from markupsafe import Markup
from struct import error as StructError
from odoo import api, models, modules
from odoo.exceptions import RedirectWarning
from odoo.tools import groupby
from odoo.tools.mimetypes import guess_mimetype
from odoo.tools.pdf import OdooPdfFileReader, PdfReadError
_logger = logging.getLogger(__name__)
def _can_commit():
""" Helper to know if we can commit the current transaction or not.
:returns: True if commit is acceptable, False otherwise.
"""
return not modules.module.current_test
@contextmanager
def rollbackable_transaction(cr):
""" A savepoint-less commit/rollback context manager.
Commits the cursor, then executes the code inside the context manager, then tries to commit again.
Rolls the cursor back if an exception was raised.
Because this method commits the cursor, try to:
(1) do as much work as possible before calling this method, and
(2) avoid triggering a SerializationError later in the request. If a SerializationError happens,
`retrying` will cause the whole request to be retried, which may cause some things
to be duplicated. That may be more or less undesirable, depending on what you're doing.
(This method will gracefully handle SerializationErrors caused within the context manager.)
:raise: an Exception if an error was caught and the transaction was rolled back.
"""
if not _can_commit():
yield
return
# We start by committing so that if we do a rollback in the except block, we don't lose all the progress that
# was done before this method was called. If a SerializationError occurs here, no problem - nothing will be
# committed and the whole request will be restarted by the `retrying` mechanism.
cr.commit()
try:
# This may trigger both database errors (e.g. SQL constraints)
# and Python exceptions (e.g. UserError / ValidationError).
# In both cases, we want to roll back and log an error on the invoice.
yield
# Commit in order to trigger any SerializationError right now, while we can still rollback.
cr.commit()
except Exception:
cr.rollback()
raise
def split_etree_on_tag(tree, tag):
""" Split an etree that has multiple instances of a given tag into multiple trees
that each have a single instance of the tag.
That is,
treeA = etree.fromstring('''
<A>
<B>Some header</B>
<C>First</C>
<C>Second</C>
</A>
''')
gets split by `split_etree_on_tag(etree_A, 'C')` into
<A>
<B>Some header</B>
<C>First</C>
</A>
and
<A>
<B>Some header</B>
<C>Second</C>
</A>
"""
tree = deepcopy(tree)
nodes_to_split = tree.findall(f'.//{tag}')
# Remove all nodes with the tag
parent_node = nodes_to_split[0].getparent()
for node in nodes_to_split:
parent_node.remove(node)
# Create a new tree for each node
trees = []
for node in nodes_to_split:
parent_node.append(node)
trees.append(deepcopy(tree))
parent_node.remove(node)
return trees
def extract_pdf_embedded_files(filename, content):
with io.BytesIO(content) as buffer:
try:
pdf_reader = OdooPdfFileReader(buffer, strict=False)
except Exception as e: # noqa: BLE001
# Malformed pdf
_logger.info('Error when reading the pdf file "%s": %s', filename, e)
return []
try:
return list(pdf_reader.getAttachments())
except (NotImplementedError, StructError, PdfReadError) as e:
_logger.warning("Unable to access the attachments of %s. Tried to decrypt it, but %s.", filename, e)
return []
class AccountDocumentImportMixin(models.AbstractModel):
_name = 'account.document.import.mixin'
_description = "Business document import mixin"
@api.model
def _create_records_from_attachments(self, attachments, grouping_method=None):
""" For each attachment, create a corresponding record, and attempt to decode the
attachment on the record.
Some attachments (e.g. in some EDI formats) may contain multiple business
documents; in that case, we attempt to separate them and create a new record for
each business document.
Because this method commits the cursor, try to:
(1) do as much work as possible before calling this method, and
(2) avoid triggering a SerializationError later in the request. If a SerializationError happens,
`retrying` will cause the whole request to be retried, which may cause some things
to be duplicated. That may be more or less undesirable, depending on what you're doing.
"""
if grouping_method is None:
grouping_method = self._group_files_data_by_origin_attachment
files_data = self._to_files_data(attachments)
# Extract embedded attachments
files_data.extend(self._unwrap_attachments(files_data))
# Perform a grouping to determine how many invoices to create
file_data_groups = grouping_method(files_data)
records = self.create([{}] * len(file_data_groups))
for record, file_data_group in zip(records, file_data_groups):
attachment_records = self._from_files_data(file_data_group)
attachment_records.write({
'res_model': record._name,
'res_id': record.id,
})
record.message_post(
body=self.env._("This document was created from the following attachment(s)."),
attachment_ids=attachment_records.ids
)
# Call _extend_with_attachments at the end, because it commits the transaction.
for record, file_data_group in zip(records, file_data_groups):
record._extend_with_attachments(file_data_group, new=True)
return records
# --------------------------------------------------------
# Methods for grouping attachments
# --------------------------------------------------------
def _group_files_data_by_origin_attachment(self, files_data):
""" A naive grouping method which does the following:
- if a file_data has an 'origin_attachment', it is assigned to the same group as the 'origin_attachment'.
- otherwise, it is assigned to a new group.
"""
return [
file_data_group
for origin_attachment, file_data_group
in groupby(files_data, lambda file_data: file_data['origin_attachment'])
]
def _group_files_data_into_groups_of_mixed_types(self, files_data):
""" A grouping method with a heuristic that enables it to dispatch files of the same type to
different groups, but files of different types to the same group.
This makes it suitable for grouping attachments received through a journal mail alias.
For example, receiving 5 PDFs will dispatch them into 5 groups (one per PDF),
but receiving one PDF, one JPG and one XML will dispatch them all into a single group.
"""
files_data_with_origin_attachment = []
files_data_without_origin_attachment = []
for file_data in files_data:
if 'decoder_info' not in file_data:
file_data['decoder_info'] = self._get_edi_decoder(file_data, new=True)
if file_data['origin_attachment'] == file_data['attachment']:
files_data_without_origin_attachment.append(file_data)
else:
files_data_with_origin_attachment.append(file_data)
groups = []
# First dispatch the files_data that don't have an origin_attachment.
sorted_files_data = sorted(
files_data_without_origin_attachment,
key=lambda file_data: (file_data['decoder_info'] or {}).get('priority', 0),
reverse=True,
)
for file_data in sorted_files_data:
self._assign_attachment_to_group_of_different_type(file_data, groups)
# Then dispatch the files_data that have an origin_attachment.
for file_data in files_data_with_origin_attachment:
self._assign_attachment_to_group_with_same_origin_attachment(file_data, groups)
return groups
def _assign_attachment_to_group_of_different_type(self, incoming_file_data, groups=[]):
""" Add the attachment to the group which doesn't yet have an attachment of the same root type
(however, attachments with no root type don't clash with each other).
If several groups are available, we choose the group which has the highest filename similarity.
"""
incoming_type = incoming_file_data['import_file_type']
# If there are groups with different types, we choose the group which has the highest filename similarity.
if groups_with_different_type := [
group
for group in groups
if not incoming_type or incoming_type not in (file_data['import_file_type'] for file_data in group)
]:
sorted_by_similarity = sorted(
groups_with_different_type,
key=lambda group: max(
self._get_similarity_score(incoming_file_data['name'], file_data['name'])
for file_data in group
),
reverse=True,
)
sorted_by_similarity[0].append(incoming_file_data)
return
# Otherwise, create a new group.
groups.append([incoming_file_data])
def _assign_attachment_to_group_with_same_origin_attachment(self, incoming_file_data, groups=[]):
""" Attachments that come from the same origin attachment are added to the same group. """
for group in groups:
if any(
incoming_file_data['origin_attachment'] == file_data['origin_attachment']
for file_data in group
):
group.append(incoming_file_data)
return
groups.append([incoming_file_data])
def _get_similarity_score(self, filename1, filename2):
""" Compute a similarity score between two filenames.
This is used to group files with similar names together as much as possible
when figuring out how to dispatch attachments received in a mail alias.
Similarity is defined as the length of the largest common substring between
the two filenames.
"""
matcher = difflib.SequenceMatcher(a=filename1, b=filename2, autojunk=False)
return matcher.find_longest_match().size
# --------------------------------------------------------
# Decoder framework
# --------------------------------------------------------
def _extend_with_attachments(self, files_data, new=False):
""" Extend/enhance a business document with one or more attachments.
Only the attachment with the highest priority will be used to extend the business document,
using the appropriate decoder.
The decoder may break Python and SQL constraints in difficult-to-predict ways.
This method calls the decoder in such a way that any exceptions instead roll back the transaction
and log a message on the invoice chatter.
This method will not extract embedded files for you - if you want embedded files to be
considered, you must pass them as part of the `attachments` recordset.
:param self: An invoice on which to apply the attachments.
:param files_data: A list of file_data dicts, each representing an in-DB or extracted attachment.
:param new: If true, indicates that the invoice was newly created, will be passed to the decoder.
:return: True if at least one document is successfully imported.
Because this method commits the cursor, try to:
(1) do as much work as possible before calling this method, and
(2) avoid triggering a SerializationError later in the request. If a SerializationError happens,
`retrying` will cause the whole request to be retried, which may cause some things
to be duplicated. That may be more or less undesirable, depending on what you're doing.
"""
def _get_attachment_name(file_data):
params = {
'filename': file_data['name'],
'root_filename': file_data['origin_attachment'].name,
'type': file_data['import_file_type'],
}
if not file_data['attachment']:
return self.env._("'%(filename)s' (extracted from '%(root_filename)s', type=%(type)s)", **params)
else:
return self.env._("'%(filename)s' (type=%(type)s)", **params)
self.ensure_one()
for file_data in files_data:
if 'decoder_info' not in file_data:
file_data['decoder_info'] = self._get_edi_decoder(file_data, new=new)
# Identify the attachment to decode.
sorted_files_data = sorted(
files_data,
key=lambda file_data: (
file_data['decoder_info'] is not None,
(file_data['decoder_info'] or {}).get('priority', 0),
),
reverse=True,
)
file_data = sorted_files_data[0]
if file_data['decoder_info'] is None or file_data['decoder_info'].get('priority', 0) == 0:
_logger.info(
"Attachment(s) %s not imported: no suitable decoder found.",
[file_data['name'] for file_data in files_data],
)
return
try:
with rollbackable_transaction(self.env.cr):
reason_cannot_decode = file_data['decoder_info']['decoder'](self, file_data, new)
if reason_cannot_decode:
self.message_post(
body=self.env._(
"Attachment %(filename)s not imported: %(reason)s",
filename=file_data['name'],
reason=reason_cannot_decode,
)
)
return
except RedirectWarning:
raise
except Exception as e:
_logger.exception("Error importing attachment %s on record %s", file_data['name'], self)
self.sudo().message_post(body=Markup("%s<br/><br/>%s<br/>%s") % (
self.env._(
"Error importing attachment %(filename)s:",
filename=_get_attachment_name(file_data),
),
self.env._("This specific error occurred during the import:"),
str(e),
))
return
return True
def _get_edi_decoder(self, file_data, new=False):
""" Main method that should be overridden to implement decoders for various file types.
:param file_data: A dict representing an attachment which should be decoded.
:param new: (optional) whether the business document was newly created.
:return: A dict with the following keys:
- decoder: The decoder function to use. This function should return either None
if decoding was successful, or a string explaining why decoding failed.
- priority: The priority of the decoder.
"""
pass
# --------------------------------------------------------------
# Helpers to consistently attach/unattach attachments to records
# --------------------------------------------------------------
def _attachment_fields_to_clear(self):
""" Return a list of fields that should be cleared when an attachment is unattached from the record. """
return []
def _fix_attachments_on_record(self, attachments):
""" Ensure that only attachments of certain types appear in `self`'s attachments.
This is to provide a consistent behaviour where only certain attachment types
appear in the chatter's attachments, to avoid cluttering the attachments view.
"""
self.ensure_one()
attachments_to_attach = attachments.filtered(self._should_attach_to_record)
if attachments_to_attach:
# No need to write to attachments that have the same res_model and res_id
attachments_to_write = attachments_to_attach.filtered(lambda a: a.res_model != self._name or a.res_id != self.id)
attachments_to_write.write({
'res_model': self._name,
'res_id': self.id,
})
attachments_to_unattach = (attachments - attachments_to_attach).filtered(lambda a: a.res_model == self._name and not a.res_field)
if attachments_to_unattach:
for fname in self._attachment_fields_to_clear():
self[fname] -= attachments_to_unattach
attachments_to_unattach.write({
'res_model': False,
'res_id': 0,
})
def _should_attach_to_record(self, attachment):
""" Indicate whether a given attachment should be displayed in the record's attachments. """
return attachment and not attachment.res_field and attachment.mimetype in {
'text/csv',
'application/pdf',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.oasis.opendocument.spreadsheet',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.oasis.opendocument.presentation',
}
# -------------------------------------------------------------------------
# Helpers to convert between ir.attachment and file_data dicts
# -------------------------------------------------------------------------
@api.model
def _to_files_data(self, attachments):
""" Helper method to convert an ir.attachment recordset into an intermediate `files_data` format
used by the import framework.
:return: a list of dicts, each dict representing one of the attachments in `self`.
"""
files_data = []
for attachment in attachments:
file_data = {
'name': attachment.name,
'raw': attachment.raw,
'mimetype': attachment.mimetype,
'origin_attachment': attachment,
'attachment': attachment,
}
file_data['xml_tree'] = self._get_xml_tree(file_data)
file_data['import_file_type'] = self._get_import_file_type(file_data)
file_data['origin_import_file_type'] = file_data['import_file_type']
files_data.append(file_data)
return files_data
@api.model
def _from_files_data(self, files_data):
""" Helper method to convert a `files_data` list-of-dicts back into an ir.attachment recordset.
This only returns those elements in `files_data` which correspond to an ir.attachment
(thus, embedded files that were never turned into ir.attachments are omitted).
"""
return self.env['ir.attachment'].union(*(
file_data['attachment']
for file_data in files_data
if file_data.get('attachment')
))
@api.model
def _get_import_file_type(self, file_data):
""" Method to be overridden to identify a file's format. """
if 'pdf' in file_data['mimetype'] or file_data['name'].endswith('.pdf'):
return 'pdf'
@api.model
def _get_xml_tree(self, file_data):
""" Parse file_data['raw'] into an lxml.etree.ElementTree.
Can be overridden if custom decoding is needed.
"""
if (
# XML attachments received by mail have a 'text/plain' mimetype.
'text/plain' in file_data['mimetype'] and (guess_mimetype(file_data['raw'] or b'').endswith('/xml') or file_data['name'].endswith('.xml'))
or file_data['mimetype'].endswith('/xml')
):
try:
return etree.fromstring(file_data['raw'], parser=etree.XMLParser(remove_comments=True, resolve_entities=False))
except etree.ParseError as e:
_logger.info('Error when reading the xml file "%s": %s', file_data['name'], e)
@api.model
def _unwrap_attachments(self, files_data, recurse=True):
""" Unwrap and return any embedded files.
:param files_data: The files to be unwrapped.
:param recurse: if True, embedded-of-embedded attachments will also be unwrapped and returned.
:return: a `files_data` list representation of the embedded attachments.
"""
return list(itertools.chain(*(self._unwrap_attachment(file_data, recurse=recurse) for file_data in files_data)))
@api.model
def _unwrap_attachment(self, file_data, recurse=True):
""" Unwrap a single attachment and return its embedded attachments.
This method can be overridden to implement custom unwrapping behaviours
(e.g. EDI formats which contain multiple business documents in a single file)
:param file_data: The file to be unwrapped.
:param recurse: if True, should return embedded-of-embedded attachments.
:return: a `files_data` list representation of the embedded attachements.
"""
embedded = []
if file_data['import_file_type'] == 'pdf':
for filename, content in extract_pdf_embedded_files(file_data['name'], file_data['raw']):
embedded_file_data = {
'name': filename,
'raw': content,
'mimetype': guess_mimetype(content),
'attachment': None,
'origin_attachment': file_data['origin_attachment'],
'origin_import_file_type': file_data['origin_import_file_type'],
}
embedded_file_data['xml_tree'] = self._get_xml_tree(embedded_file_data)
embedded_file_data['import_file_type'] = self._get_import_file_type(embedded_file_data)
embedded.append(embedded_file_data)
if embedded and recurse:
embedded.extend(self._unwrap_attachments(embedded))
return embedded
@api.model
def _split_xml_into_new_attachments(self, file_data, tag):
""" Helper method to split an XML file into multiple files on a given tag.
In EDIs, some XMLs contain multiple business documents.
In such cases, we often want any business document beyond the first to have its
own attachment that can be decoded separately.
This helper method looks whether the provided XML tree (given in `file_data`) has multiple
instances of the given `tag`, and creates a new attachment for each tag beyond the first.
The new attachment has the same XML structure as the original file, but only has one instance
of the specified tag.
:param file_data: The XML file to split
:param tag: The tag which the XML file should be split on if there are multiple instances of it
:return: a `files_data` list of files, for each business document beyond the first.
"""
new_files_data = []
if len(file_data['xml_tree'].findall(f'.//{tag}')) > 1:
# Create a new xml tree for each invoice beyond the first
trees = split_etree_on_tag(file_data['xml_tree'], tag)
filename_without_extension, _dummy, extension = file_data['name'].rpartition('.')
attachment_vals = [
{
'name': f'{filename_without_extension}_{filename_index}.{extension}',
'raw': etree.tostring(tree),
}
for filename_index, tree in enumerate(trees[1:], start=2)
]
created_attachments = self.env['ir.attachment'].create(attachment_vals)
new_files_data.extend(self._to_files_data(created_attachments))
return new_files_data

View file

@ -1,37 +1,45 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo import api, fields, models, Command
class AccountFullReconcile(models.Model):
_name = "account.full.reconcile"
_name = 'account.full.reconcile'
_description = "Full Reconcile"
name = fields.Char(string='Number', required=True, copy=False, default=lambda self: self.env['ir.sequence'].next_by_code('account.reconcile'))
partial_reconcile_ids = fields.One2many('account.partial.reconcile', 'full_reconcile_id', string='Reconciliation Parts')
reconciled_line_ids = fields.One2many('account.move.line', 'full_reconcile_id', string='Matched Journal Items')
exchange_move_id = fields.Many2one('account.move', index="btree_not_null")
def unlink(self):
""" When removing a full reconciliation, we need to revert the eventual journal entries we created to book the
fluctuation of the foreign currency's exchange rate.
We need also to reconcile together the origin currency difference line and its reversal in order to completely
cancel the currency difference entry on the partner account (otherwise it will still appear on the aged balance
for example).
"""
# Avoid cyclic unlink calls when removing partials.
if not self:
return True
@api.model_create_multi
def create(self, vals_list):
def get_ids(commands):
for command in commands:
if command[0] == Command.LINK:
yield command[1]
elif command[0] == Command.SET:
yield from command[2]
else:
raise ValueError("Unexpected command: %s" % command)
move_line_ids = [list(get_ids(vals.pop('reconciled_line_ids'))) for vals in vals_list]
partial_ids = [list(get_ids(vals.pop('partial_reconcile_ids'))) for vals in vals_list]
fulls = super(AccountFullReconcile, self.with_context(tracking_disable=True)).create(vals_list)
moves_to_reverse = self.exchange_move_id
self.env.cr.execute_values("""
UPDATE account_move_line line
SET full_reconcile_id = source.full_id
FROM (VALUES %s) AS source(full_id, line_ids)
WHERE line.id = ANY(source.line_ids)
""", [(full.id, line_ids) for full, line_ids in zip(fulls, move_line_ids)], page_size=1000)
fulls.reconciled_line_ids.invalidate_recordset(['full_reconcile_id'], flush=False)
fulls.invalidate_recordset(['reconciled_line_ids'], flush=False)
res = super().unlink()
self.env.cr.execute_values("""
UPDATE account_partial_reconcile partial
SET full_reconcile_id = source.full_id
FROM (VALUES %s) AS source(full_id, partial_ids)
WHERE partial.id = ANY(source.partial_ids)
""", [(full.id, line_ids) for full, line_ids in zip(fulls, partial_ids)], page_size=1000)
fulls.partial_reconcile_ids.invalidate_recordset(['full_reconcile_id'], flush=False)
fulls.invalidate_recordset(['partial_reconcile_ids'], flush=False)
# Reverse all exchange moves at once.
if moves_to_reverse:
default_values_list = [{
'date': move._get_accounting_date(move.date, move._affect_tax_report()),
'ref': _('Reversal of: %s') % move.name,
} for move in moves_to_reverse]
moves_to_reverse._reverse_moves(default_values_list, cancel=True)
return res
self.env['account.partial.reconcile']._update_matching_number(fulls.reconciled_line_ids)
return fulls

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo import api, fields, models
class AccountIncoterms(models.Model):
@ -17,3 +17,8 @@ class AccountIncoterms(models.Model):
active = fields.Boolean(
'Active', default=True,
help="By unchecking the active field, you may hide an INCOTERM you will not use.")
@api.depends('code')
def _compute_display_name(self):
for incoterm in self:
incoterm.display_name = '%s%s' % (incoterm.code and '[%s] ' % incoterm.code or '', incoterm.name)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,306 @@
from odoo import _, api, fields, models
from odoo.fields import Command, Domain
from odoo.tools.misc import format_datetime
from odoo.exceptions import UserError, ValidationError
from odoo.addons.account.models.company import SOFT_LOCK_DATE_FIELDS
from datetime import date
class AccountLock_Exception(models.Model):
_name = 'account.lock_exception'
_description = "Account Lock Exception"
active = fields.Boolean(
string='Active',
default=True,
)
state = fields.Selection(
selection=[
('active', 'Active'),
('revoked', 'Revoked'),
('expired', 'Expired'),
],
string="State",
compute='_compute_state',
search='_search_state'
)
company_id = fields.Many2one(
'res.company',
string='Company',
required=True,
readonly=True,
default=lambda self: self.env.company,
)
# An exception w/o user_id is an exception for everyone
user_id = fields.Many2one(
'res.users',
string='User',
default=lambda self: self.env.user,
)
reason = fields.Char(
string='Reason',
)
# An exception without `end_datetime` is valid forever
end_datetime = fields.Datetime(
string='End Date',
)
# The changed lock date
lock_date_field = fields.Selection(
selection=[
('fiscalyear_lock_date', 'Global Lock Date'),
('tax_lock_date', 'Tax Return Lock Date'),
('sale_lock_date', 'Sales Lock Date'),
('purchase_lock_date', 'Purchase Lock Date'),
],
string="Lock Date Field",
required=True,
help="Technical field identifying the changed lock date",
)
lock_date = fields.Date(
string="Changed Lock Date",
help="Technical field giving the date the lock date was changed to.",
)
company_lock_date = fields.Date(
string="Original Lock Date",
copy=False,
help="Technical field giving the date the company lock date at the time the exception was created.",
)
# (Non-stored) computed lock date fields; c.f. res.company
fiscalyear_lock_date = fields.Date(
string="Global Lock Date",
compute="_compute_lock_dates",
search="_search_fiscalyear_lock_date",
help="The date the Global Lock Date is set to by this exception. If the lock date is not changed it is set to the maximal date.",
)
tax_lock_date = fields.Date(
string="Tax Return Lock Date",
compute="_compute_lock_dates",
search="_search_tax_lock_date",
help="The date the Tax Lock Date is set to by this exception. If the lock date is not changed it is set to the maximal date.",
)
sale_lock_date = fields.Date(
string='Sales Lock Date',
compute="_compute_lock_dates",
search="_search_sale_lock_date",
help="The date the Sale Lock Date is set to by this exception. If the lock date is not changed it is set to the maximal date.",
)
purchase_lock_date = fields.Date(
string='Purchase Lock Date',
compute="_compute_lock_dates",
search="_search_purchase_lock_date",
help="The date the Purchase Lock Date is set to by this exception. If the lock date is not changed it is set to the maximal date.",
)
_company_id_end_datetime_idx = models.Index("(company_id, user_id, end_datetime) WHERE active IS TRUE")
def _compute_display_name(self):
for record in self:
record.display_name = _("Lock Date Exception %s", record.id)
@api.depends('active', 'end_datetime')
def _compute_state(self):
for record in self:
if not record.active:
record.state = 'revoked'
elif record.end_datetime and record.end_datetime < self.env.cr.now():
record.state = 'expired'
else:
record.state = 'active'
@api.depends('lock_date_field', 'lock_date')
def _compute_lock_dates(self):
for exception in self:
for field in SOFT_LOCK_DATE_FIELDS:
if field == exception.lock_date_field:
exception[field] = exception.lock_date
else:
exception[field] = date.max
def _search_state(self, operator, value):
if operator != 'in':
return NotImplemented
domain = Domain.FALSE
if 'revoked' in value:
domain |= Domain('active', '=', False)
if 'expired' in value:
domain |= Domain('active', '=', True) & Domain('end_datetime', '<', self.env.cr.now())
if 'active' in value:
domain |= Domain('active', '=', True) & (Domain('end_datetime', '=', False) | Domain('end_datetime', '>=', self.env.cr.now()))
return domain
def _search_lock_date(self, field, operator, value):
if operator not in ['<', '<='] or not value:
return NotImplemented
return ['&',
('lock_date_field', '=', field),
'|',
('lock_date', '=', False),
('lock_date', operator, value),
]
def _search_fiscalyear_lock_date(self, operator, value):
return self._search_lock_date('fiscalyear_lock_date', operator, value)
def _search_tax_lock_date(self, operator, value):
return self._search_lock_date('tax_lock_date', operator, value)
def _search_sale_lock_date(self, operator, value):
return self._search_lock_date('sale_lock_date', operator, value)
def _search_purchase_lock_date(self, operator, value):
return self._search_lock_date('purchase_lock_date', operator, value)
def _invalidate_affected_user_lock_dates(self):
affected_lock_date_fields = {exception.lock_date_field for exception in self}
self.env['res.company'].invalidate_model(
fnames=[f'user_{field}' for field in list(affected_lock_date_fields)],
)
@api.model_create_multi
def create(self, vals_list):
# Preprocess arguments:
# 1. Parse lock date arguments
# E.g. to create an exception for 'fiscalyear_lock_date' to '2024-01-01' put
# {'fiscalyear_lock_date': '2024-01-01'} in the create vals.
# The same thing works for all other fields in SOFT_LOCK_DATE_FIELDS.
# 2. Fetch company lock date
for vals in vals_list:
if 'lock_date' not in vals or 'lock_date_field' not in vals:
# Use vals[field] (for field in SOFT_LOCK_DATE_FIELDS) to init the data
changed_fields = [field for field in SOFT_LOCK_DATE_FIELDS if field in vals]
if len(changed_fields) != 1:
raise ValidationError(_("A single exception must change exactly one lock date field."))
field = changed_fields[0]
vals['lock_date_field'] = field
vals['lock_date'] = vals.pop(field)
company = self.env['res.company'].browse(vals.get('company_id', self.env.company.id))
if 'company_lock_date' not in vals:
vals['company_lock_date'] = company[vals['lock_date_field']]
exceptions = super().create(vals_list)
# Log the creation of the exception and the changed field on the company chatter
for exception in exceptions:
company = exception.company_id
# Create tracking values to display the lock date change in the chatter
field = exception.lock_date_field
value = exception.lock_date
field_info = exception.fields_get([field])[field]
tracking_values = self.env['mail.tracking.value']._create_tracking_values(
company[field], value, field, field_info, exception,
)
tracking_value_ids = [Command.create(tracking_values)]
# In case there is no explicit end datetime "forever" is implied by not mentioning an end datetime
end_datetime_string = _(" valid until %s", format_datetime(self.env, exception.end_datetime)) if exception.end_datetime else ""
reason_string = _(" for '%s'", exception.reason) if exception.reason else ""
company_chatter_message = _(
"%(exception)s for %(user)s%(end_datetime_string)s%(reason)s.",
exception=exception._get_html_link(title=_("Exception")),
user=exception.user_id.display_name if exception.user_id else _("everyone"),
end_datetime_string=end_datetime_string,
reason=reason_string,
)
company.sudo().message_post(
body=company_chatter_message,
tracking_value_ids=tracking_value_ids,
)
exceptions._invalidate_affected_user_lock_dates()
return exceptions
def copy(self, default=None):
raise UserError(_('You cannot duplicate a Lock Date Exception.'))
def _recreate(self):
"""
1. Copy all exceptions in self but update the company lock date.
2. Revoke all exceptions in self.
3. Return the new records from step 1.
"""
if not self:
return self.env['account.lock_exception']
vals_list = self.with_context(active_test=False).copy_data()
new_records = self.create(vals_list)
self.sudo().action_revoke()
return new_records
def action_revoke(self):
"""Revokes an active exception."""
if not self.env.user.has_group('account.group_account_manager') and not self.env.su:
raise UserError(_("You cannot revoke Lock Date Exceptions. Ask someone with the 'Adviser' role."))
for record in self:
if record.state == 'active':
record_sudo = record.sudo()
record_sudo.active = False
record_sudo.end_datetime = fields.Datetime.now()
record._invalidate_affected_user_lock_dates()
@api.model
def _get_active_exceptions_domain(self, company, soft_lock_date_fields):
return (
Domain.OR(
Domain(field, '<', company[field])
for field in soft_lock_date_fields
if company[field]
)
& Domain('company_id', '=', company.id)
& Domain('state', '=', 'active'), # checks the datetime
)
def _get_audit_trail_during_exception_domain(self):
self.ensure_one()
common_message_domain = [
('date', '>=', self.create_date),
]
if self.user_id:
common_message_domain.append(('create_uid', '=', self.user_id.id))
if self.end_datetime:
common_message_domain.append(('date', '<=', self.end_datetime))
# Add restrictions on the accounting date to avoid unnecessary entries
min_date = self.lock_date
max_date = self.company_lock_date
move_date_domain = []
tracking_old_datetime_domain = []
tracking_new_datetime_domain = []
if min_date:
move_date_domain.append([('date', '>=', min_date)])
tracking_old_datetime_domain.append([('tracking_value_ids.old_value_datetime', '>=', min_date)])
tracking_new_datetime_domain.append([('tracking_value_ids.new_value_datetime', '>=', min_date)])
if max_date:
move_date_domain.append([('date', '<=', max_date)])
tracking_old_datetime_domain.append([('tracking_value_ids.old_value_datetime', '<=', max_date)])
tracking_new_datetime_domain.append([('tracking_value_ids.new_value_datetime', '<=', max_date)])
return [
('company_id', 'child_of', self.company_id.id),
('audit_trail_message_ids', 'any', common_message_domain),
'|',
# The date was changed from or to a value inside the excepted period
('audit_trail_message_ids', 'any', [
('tracking_value_ids.field_id', '=', self.env['ir.model.fields']._get('account.move', 'date').id),
'|',
*Domain.AND(tracking_old_datetime_domain),
*Domain.AND(tracking_new_datetime_domain),
]),
# The date of the move is inside the excepted period and sth. was changed on the move
*Domain.AND(move_date_domain),
]
def action_show_audit_trail_during_exception(self):
self.ensure_one()
return {
'name': _("Journal Items"),
'type': 'ir.actions.act_window',
'res_model': 'account.move.line',
'view_mode': 'list,form',
'domain': [('move_id', 'any', self._get_audit_trail_during_exception_domain())],
}

File diff suppressed because it is too large Load diff

View file

@ -1,65 +1,58 @@
# -*- coding: utf-8 -*-
from odoo import api, models
from odoo.tools import SQL
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
@api.model
def _get_query_tax_details_from_domain(self, domain, fallback=True):
def _get_query_tax_details_from_domain(self, domain, fallback=True) -> SQL:
""" Create the tax details sub-query based on the orm domain passed as parameter.
:param domain: An orm domain on account.move.line.
:param fallback: Fallback on an approximated mapping if the mapping failed.
:return: A tuple <query, params>.
:return: query as SQL object
"""
self.env['account.move.line'].check_access_rights('read')
query = self.env['account.move.line']._search(domain)
query = self.env['account.move.line']._where_calc(domain)
# Wrap the query with 'company_id IN (...)' to avoid bypassing company access rights.
self.env['account.move.line']._apply_ir_rules(query)
tables, where_clause, where_params = query.get_sql()
return self._get_query_tax_details(tables, where_clause, where_params, fallback=fallback)
return self._get_query_tax_details(query.from_clause, query.where_clause, fallback=fallback)
@api.model
def _get_extra_query_base_tax_line_mapping(self):
def _get_extra_query_base_tax_line_mapping(self) -> SQL:
#TO OVERRIDE
return ''
return SQL()
@api.model
def _get_query_tax_details(self, tables, where_clause, where_params, fallback=True):
def _get_query_tax_details(self, table_references, search_condition, fallback=True) -> SQL:
""" Create the tax details sub-query based on the orm domain passed as parameter.
:param tables: The 'tables' query to inject after the FROM.
:param where_clause: The 'where_clause' query computed based on an orm domain.
:param where_params: The params to fill the 'where_clause' query.
:param fallback: Fallback on an approximated mapping if the mapping failed.
:return: A tuple <query, params>.
:param table_references: The query to inject after the FROM, as an SQL object.
:param search_condition: The query to inject in the WHERE clause, as an SQL object.
:param fallback: Fallback on an approximated mapping if the mapping failed.
:return: query as an SQL object
"""
group_taxes = self.env['account.tax'].search([('amount_type', '=', 'group')])
group_taxes_query_list = []
group_taxes_params = []
for group_tax in group_taxes:
children_taxes = group_tax.children_tax_ids
if not children_taxes:
continue
children_taxes_in_query = ','.join('%s' for dummy in children_taxes)
group_taxes_query_list.append(f'WHEN tax.id = %s THEN ARRAY[{children_taxes_in_query}]')
group_taxes_params.append(group_tax.id)
group_taxes_params.extend(children_taxes.ids)
children_taxes_in_query = SQL(','.join('%s' for dummy in children_taxes),
*children_taxes.ids)
group_taxes_query_list.append(SQL('WHEN tax.id = %s THEN ARRAY[%s]', group_tax.id, children_taxes_in_query))
if group_taxes_query_list:
group_taxes_query = f'''UNNEST(CASE {' '.join(group_taxes_query_list)} ELSE ARRAY[tax.id] END)'''
group_taxes_query = SQL('''UNNEST(CASE %s ELSE ARRAY[tax.id] END)''', SQL(' ').join(group_taxes_query_list))
else:
group_taxes_query = 'tax.id'
group_taxes_query = SQL('tax.id')
if fallback:
fallback_query = f'''
fallback_query = SQL(
'''
UNION ALL
SELECT
@ -68,7 +61,7 @@ class AccountMoveLine(models.Model):
base_line.id AS src_line_id,
base_line.balance AS base_amount,
base_line.amount_currency AS base_amount_currency
FROM {tables}
FROM %(table_references)s
LEFT JOIN base_tax_line_mapping ON
base_tax_line_mapping.tax_line_id = account_move_line.id
JOIN account_move_line_account_tax_rel tax_rel ON
@ -79,16 +72,18 @@ class AccountMoveLine(models.Model):
AND base_line.move_id = account_move_line.move_id
AND base_line.currency_id = account_move_line.currency_id
WHERE base_tax_line_mapping.tax_line_id IS NULL
AND {where_clause}
'''
fallback_params = where_params
AND %(search_condition)s
''',
table_references=table_references,
search_condition=search_condition,
)
else:
fallback_query = ''
fallback_params = []
fallback_query = SQL()
extra_query_base_tax_line_mapping = self._get_extra_query_base_tax_line_mapping()
return f'''
return SQL(
'''
/*
As example to explain the different parts of the query, we'll consider a move with the following lines:
Name Tax_line_id Tax_ids Debit Credit Base lines
@ -102,42 +97,7 @@ class AccountMoveLine(models.Model):
tax_line_4 5 275 base_line_2/3
*/
WITH affecting_base_tax_ids AS (
/*
This CTE builds a reference table based on the tax_ids field, with the following changes:
- flatten the group of taxes
- exclude the taxes having 'is_base_affected' set to False.
Those allow to match only base_line_1 when finding the base lines of tax_line_1, as we need to find
base lines having a 'affecting_base_tax_ids' ending with [10_affect_base, 20], not only containing
'10_affect_base'. Otherwise, base_line_2/3 would also be matched.
In our example, as all the taxes are set to be affected by previous ones affecting the base, the
result is similar to the table 'account_move_line_account_tax_rel':
Id Tax_ids
-------------------------------------------
base_line_1 [10_affect_base, 20]
base_line_2 [10_affect_base, 5]
base_line_3 [10_affect_base, 5]
*/
SELECT
sub.line_id AS id,
ARRAY_AGG(sub.tax_id ORDER BY sub.sequence, sub.tax_id) AS tax_ids
FROM (
SELECT
tax_rel.account_move_line_id AS line_id,
{group_taxes_query} AS tax_id,
tax.sequence
FROM {tables}
JOIN account_move_line_account_tax_rel tax_rel ON account_move_line.id = tax_rel.account_move_line_id
JOIN account_tax tax ON tax.id = tax_rel.account_tax_id
WHERE tax.is_base_affected
AND {where_clause}
) AS sub
GROUP BY sub.line_id
),
base_tax_line_mapping AS (
WITH base_tax_line_mapping AS (
/*
Create the mapping of each tax lines with their corresponding base lines.
@ -159,7 +119,7 @@ class AccountMoveLine(models.Model):
base_line.balance AS base_amount,
base_line.amount_currency AS base_amount_currency
FROM {tables}
FROM %(table_references)s
JOIN account_tax_repartition_line tax_rep ON
tax_rep.id = account_move_line.tax_repartition_line_id
JOIN account_tax tax ON
@ -174,8 +134,8 @@ class AccountMoveLine(models.Model):
AND base_line.move_id = account_move_line.move_id
AND (
move.move_type != 'entry'
OR
sign(account_move_line.balance) = sign(base_line.balance * tax.amount * tax_rep.factor_percent)
OR (tax.tax_exigibility = 'on_payment' AND tax.cash_basis_transition_account_id IS NOT NULL)
OR sign(account_move_line.balance) = sign(base_line.balance * tax.amount * tax_rep.factor_percent)
)
AND COALESCE(base_line.partner_id, 0) = COALESCE(account_move_line.partner_id, 0)
AND base_line.currency_id = account_move_line.currency_id
@ -184,21 +144,58 @@ class AccountMoveLine(models.Model):
OR (tax.tax_exigibility = 'on_payment' AND tax.cash_basis_transition_account_id IS NOT NULL)
)
AND (
NOT tax.analytic
(tax.analytic IS NOT TRUE AND tax_rep.use_in_tax_closing IS TRUE)
OR (base_line.analytic_distribution IS NULL AND account_move_line.analytic_distribution IS NULL)
OR base_line.analytic_distribution = account_move_line.analytic_distribution
)
{extra_query_base_tax_line_mapping}
%(extra_query_base_tax_line_mapping)s
JOIN res_currency curr ON
curr.id = account_move_line.currency_id
JOIN res_currency comp_curr ON
comp_curr.id = account_move_line.company_currency_id
LEFT JOIN affecting_base_tax_ids tax_line_tax_ids ON tax_line_tax_ids.id = account_move_line.id
JOIN affecting_base_tax_ids base_line_tax_ids ON base_line_tax_ids.id = base_line.id
LEFT JOIN LATERAL (
/*
This table builds a reference table based on the tax_ids field, with the following changes:
- flatten the group of taxes
- exclude the taxes having 'is_base_affected' set to False.
Those allow to match only base_line_1 when finding the base lines of tax_line_1, as we need to find
base lines having a 'affecting_base_tax_ids' ending with [10_affect_base, 20], not only containing
'10_affect_base'. Otherwise, base_line_2/3 would also be matched.
In our example, as all the taxes are set to be affected by previous ones affecting the base, the
result is similar to the table 'account_move_line_account_tax_rel':
Id Tax_ids
-------------------------------------------
base_line_1 [10_affect_base, 20]
base_line_2 [10_affect_base, 5]
base_line_3 [10_affect_base, 5]
*/
SELECT ARRAY_AGG(sub.tax_id ORDER BY sub.sequence, sub.tax_id) AS tax_ids
FROM (
SELECT
%(group_taxes_query)s AS tax_id,
tax.sequence
FROM account_move_line_account_tax_rel tax_rel
JOIN account_tax tax ON tax.id = tax_rel.account_tax_id
WHERE tax.is_base_affected
AND tax_rel.account_move_line_id = account_move_line.id
) AS sub
) tax_line_tax_ids ON TRUE
LEFT JOIN LATERAL (
SELECT ARRAY_AGG(sub.tax_id ORDER BY sub.sequence, sub.tax_id) AS tax_ids
FROM (
SELECT
%(group_taxes_query)s AS tax_id,
tax.sequence
FROM account_move_line_account_tax_rel tax_rel
JOIN account_tax tax ON tax.id = tax_rel.account_tax_id
WHERE tax.is_base_affected
AND tax_rel.account_move_line_id = base_line.id
) AS sub
) base_line_tax_ids ON TRUE
WHERE account_move_line.tax_repartition_line_id IS NOT NULL
AND {where_clause}
AND %(search_condition)s
AND (
-- keeping only the rows from affecting_base_tax_lines that end with the same taxes applied (see comment in affecting_base_tax_ids)
-- keeping only the rows from affecting_base_tax_lines that end with the same taxes applied (see comment in tax_line_tax_ids)
NOT tax.include_base_amount
OR base_line_tax_ids.tax_ids[ARRAY_LENGTH(base_line_tax_ids.tax_ids, 1) - COALESCE(ARRAY_LENGTH(tax_line_tax_ids.tax_ids, 1), 0):ARRAY_LENGTH(base_line_tax_ids.tax_ids, 1)]
= ARRAY[account_move_line.tax_line_id] || COALESCE(tax_line_tax_ids.tax_ids, ARRAY[]::INTEGER[])
@ -268,7 +265,7 @@ class AccountMoveLine(models.Model):
) OVER (PARTITION BY tax_line.id, account_move_line.id) AS total_base_amount_currency,
account_move_line.amount_currency AS total_tax_amount_currency
FROM {tables}
FROM %(table_references)s
JOIN account_tax tax_include_base_amount ON
tax_include_base_amount.include_base_amount
AND tax_include_base_amount.id = account_move_line.tax_line_id
@ -289,7 +286,7 @@ class AccountMoveLine(models.Model):
comp_curr.id = tax_line.company_currency_id
JOIN account_move_line base_line ON
base_line.id = base_tax_line_mapping.base_line_id
WHERE {where_clause}
WHERE %(search_condition)s
),
@ -368,7 +365,7 @@ class AccountMoveLine(models.Model):
Skipped if the 'fallback' method parameter is False.
*/
{fallback_query}
%(fallback_query)s
),
@ -506,4 +503,10 @@ class AccountMoveLine(models.Model):
0.0
) AS tax_amount_currency
FROM base_tax_matching_all_amounts sub
''', group_taxes_params + where_params + where_params + where_params + fallback_params
''',
extra_query_base_tax_line_mapping=extra_query_base_tax_line_mapping,
group_taxes_query=group_taxes_query,
search_condition=search_condition,
table_references=table_references,
fallback_query=fallback_query,
)

View file

@ -0,0 +1,854 @@
import logging
from collections import defaultdict
from markupsafe import Markup
from odoo import Command, _, api, models, modules, tools
from odoo.exceptions import UserError, ValidationError
_logger = logging.getLogger(__name__)
class AccountMoveSend(models.AbstractModel):
""" Shared class between the two sending wizards.
See 'account.move.send.batch.wizard' for multiple invoices sending wizard (async)
and 'account.move.send.wizard' for single invoice sending wizard (sync).
"""
_name = 'account.move.send'
_description = "Account Move Send"
# -------------------------------------------------------------------------
# DEFAULTS
# -------------------------------------------------------------------------
@api.model
def _get_default_sending_methods(self, move) -> set:
""" By default, we use the sending method set on the partner or email. """
return {move.commercial_partner_id.with_company(move.company_id).invoice_sending_method or 'email'}
@api.model
def _get_all_extra_edis(self) -> dict:
""" Returns a dict representing EDI data such as:
{ 'edi_key': {'label': 'EDI label', 'is_applicable': function, 'help': 'optional help'} }
"""
return {}
@api.model
def _get_default_extra_edis(self, move) -> set:
""" By default, we use all applicable extra EDIs. """
extra_edis = self._get_all_extra_edis()
return {edi_key for edi_key, edi_vals in extra_edis.items() if edi_vals['is_applicable'](move)}
@api.model
def _get_default_invoice_edi_format(self, move, **kwargs) -> str:
""" By default, we generate the EDI format set on partner. """
return move.commercial_partner_id.with_company(move.company_id).invoice_edi_format
@api.model
def _get_default_pdf_report_id(self, move):
if partner_default_template := move.commercial_partner_id.with_company(move.company_id).invoice_template_pdf_report_id:
return partner_default_template
if journal_default_template := move.journal_id.with_company(move.company_id).invoice_template_pdf_report_id:
return journal_default_template
action_report = self.env.ref('account.account_invoices')
if move._is_action_report_available(action_report):
return action_report
raise UserError(_("There is no template that applies to this move type."))
@api.model
def _get_default_mail_template_id(self, move):
return move._get_mail_template()
@api.model
def _get_default_sending_settings(self, move, from_cron=False, **custom_settings):
""" Returns a dict with all the necessary data to generate and send invoices.
Either takes the provided custom_settings, or the default value.
"""
def get_setting(key, from_cron=False, default_value=None):
return custom_settings.get(key) if key in custom_settings else move.sending_data.get(key) if from_cron else default_value
vals = {
'sending_methods': get_setting('sending_methods', default_value=self._get_default_sending_methods(move)) or {},
'extra_edis': get_setting('extra_edis', default_value=self._get_default_extra_edis(move)) or {},
'pdf_report': get_setting('pdf_report') or self._get_default_pdf_report_id(move),
'author_user_id': get_setting('author_user_id', from_cron=from_cron) or self.env.user.id,
'author_partner_id': get_setting('author_partner_id', from_cron=from_cron) or self.env.user.partner_id.id,
}
vals['invoice_edi_format'] = get_setting('invoice_edi_format', default_value=self._get_default_invoice_edi_format(move, sending_methods=vals['sending_methods']))
mail_template = get_setting('mail_template') or self._get_default_mail_template_id(move)
if 'email' in vals['sending_methods']:
mail_lang = get_setting('mail_lang') or self._get_default_mail_lang(move, mail_template)
vals.update({
'mail_template': mail_template,
'mail_lang': mail_lang,
'mail_body': get_setting('mail_body', default_value=self._get_default_mail_body(move, mail_template, mail_lang)),
'mail_subject': get_setting('mail_subject', default_value=self._get_default_mail_subject(move, mail_template, mail_lang)),
'mail_partner_ids': get_setting('mail_partner_ids', default_value=self._get_default_mail_partner_ids(move, mail_template, mail_lang).ids),
})
# Add mail attachments if sending methods support them
if self._display_attachments_widget(vals['invoice_edi_format'], vals['sending_methods']):
mail_attachments_widget = self._get_default_mail_attachments_widget(
move,
mail_template,
invoice_edi_format=vals['invoice_edi_format'],
extra_edis=vals['extra_edis'],
pdf_report=vals['pdf_report'],
)
vals['mail_attachments_widget'] = get_setting('mail_attachments_widget', default_value=mail_attachments_widget)
return vals
# -------------------------------------------------------------------------
# ALERTS
# -------------------------------------------------------------------------
@api.model
def _get_alerts(self, moves, moves_data):
""" Returns a dict of all alerts corresponding to moves with the given context (sending method,
edi format to generate, extra_edi to generate).
An alert can have some information:
- level (danger, info, warning, ...) (! danger alerts are considered blocking and will be raised)
- message to display
- action_text for the text to show on the clickable link
- action the action to run when the link is clicked
"""
alerts = {}
send_cron = self.env.ref('account.ir_cron_account_move_send', raise_if_not_found=False)
if len(moves) > 1 and send_cron and not send_cron.sudo().active:
has_cron_access = send_cron.has_access('write')
has_access_message = _("The scheduled action 'Send Invoices automatically' is archived. You won't be able to send invoices in batch.")
no_access_addendum = _("\nPlease contact your administrator.")
alerts['account_send_cron_archived'] = {
'level': 'warning',
'message': has_access_message if has_cron_access else has_access_message + no_access_addendum,
'action_text': _("Check") if has_cron_access else None,
'action': send_cron._get_records_action() if has_cron_access else None,
}
# Filter moves that are trying to send via email
email_moves = moves.filtered(lambda m: 'email' in moves_data[m]['sending_methods'])
if email_moves:
# Identify partners without email depending on batch/single send
if is_batch := len(moves) > 1:
# Batch sending
partners_without_mail = email_moves.filtered(lambda m: not m.partner_id.email).mapped('partner_id')
else:
# Single sending
partners_without_mail = moves_data[email_moves]['mail_partner_ids'].filtered(lambda p: not p.email)
# If there are partners without email, add an alert
if partners_without_mail:
alerts['account_missing_email'] = {
'level': 'warning' if is_batch else 'danger',
'message': _("Partner(s) should have an email address."),
'action_text': _("View Partner(s)") if is_batch else False,
'action': (
partners_without_mail._get_records_action(name=_("Check Partner(s) Email(s)"))
if is_batch else False
),
}
return alerts
# -------------------------------------------------------------------------
# MAIL
# -------------------------------------------------------------------------
@api.model
def _get_mail_default_field_value_from_template(self, mail_template, lang, move, field, **kwargs):
if not mail_template:
return
return mail_template.sudo()\
.with_context(lang=lang)\
._render_field(field, move.ids, **kwargs)[move._origin.id]
@api.model
def _get_default_mail_lang(self, move, mail_template):
return mail_template._render_lang([move.id]).get(move.id)
@api.model
def _get_default_mail_body(self, move, mail_template, mail_lang):
return self._get_mail_default_field_value_from_template(
mail_template,
mail_lang,
move,
'body_html',
options={'post_process': True},
)
@api.model
def _get_default_mail_subject(self, move, mail_template, mail_lang):
return self._get_mail_default_field_value_from_template(
mail_template,
mail_lang,
move,
'subject',
)
@api.model
def _get_default_mail_partner_ids(self, move, mail_template, mail_lang):
# TDE FIXME: this should use standard composer / template code to be sure
# it is aligned with standard recipients management. Todo later
partners = self.env['res.partner'].with_company(move.company_id)
if mail_template.use_default_to:
defaults = move._message_get_default_recipients()[move.id]
email_cc = defaults['email_to']
email_to = defaults['email_to']
partners |= partners.browse(defaults['partner_ids'])
else:
if mail_template.email_cc:
email_cc = self._get_mail_default_field_value_from_template(mail_template, mail_lang, move, 'email_cc')
else:
email_cc = ''
if mail_template.email_to:
email_to = self._get_mail_default_field_value_from_template(mail_template, mail_lang, move, 'email_to')
else:
email_to = ''
partners |= move._partner_find_from_emails_single(
tools.email_split(email_cc or '') + tools.email_split(email_to or ''),
no_create=False,
)
if not mail_template.use_default_to and mail_template.partner_to:
partner_to = self._get_mail_default_field_value_from_template(mail_template, mail_lang, move, 'partner_to')
partner_ids = mail_template._parse_partner_to(partner_to)
partners |= self.env['res.partner'].sudo().browse(partner_ids).exists()
return partners if self.env.context.get('allow_partners_without_mail') else partners.filtered('email')
# -------------------------------------------------------------------------
# ATTACHMENTS
# -------------------------------------------------------------------------
@api.model
def _get_default_mail_attachments_widget(self, move, mail_template, invoice_edi_format=None, extra_edis=None, pdf_report=None):
return self._get_placeholder_mail_attachments_data(move, invoice_edi_format=invoice_edi_format, extra_edis=extra_edis, pdf_report=pdf_report) \
+ self._get_placeholder_mail_template_dynamic_attachments_data(move, mail_template, pdf_report=pdf_report) \
+ self._get_invoice_extra_attachments_data(move) \
+ self._get_mail_template_attachments_data(mail_template)
@api.model
def _get_placeholder_mail_attachments_data(self, move, invoice_edi_format=None, extra_edis=None, pdf_report=None):
""" Returns all the placeholder data.
Should be extended to add placeholder based on the sending method.
:param: move: The current move.
:returns: A list of dictionary for each placeholder.
* id: str: The (fake) id of the attachment, this is needed in rendering in t-key.
* name: str: The name of the attachment.
* mimetype: str: The mimetype of the attachment.
* placeholder bool: Should be true to prevent download / deletion.
"""
if move.invoice_pdf_report_id:
return []
filename = move._get_invoice_report_filename(report=pdf_report)
return [{
'id': f'placeholder_{filename}',
'name': filename,
'mimetype': 'application/pdf',
'placeholder': True,
}]
@api.model
def _get_placeholder_mail_template_dynamic_attachments_data(self, move, mail_template, pdf_report=None):
"""
This method returns the placeholder data for the dynamic attachments.
:param move: The current move we are generating documents for.
:param mail_template: The mail template used to get dynamic attachments for the move.
:param pdf_report: The 'ir.actions.report' used for the move.
Usually it will be the generic 'account.account_invoices' but the user can customize it
from the Send Wizard interface.
:return: A list of dictionary, one for each placeholder.
"""
# The Send wizard will generate a legal PDF based on a specific ir.actions.report.
# In case the report selected to do so is also added in dynamic attachments of the mail template, we need to
# filter them out to avoid duplicated placeholders, since they are already added in the
# _get_placeholder_mail_attachments_data method.
pdf_report = pdf_report or self._get_default_pdf_report_id(move)
invoice_template = pdf_report | self.env.ref('account.account_invoices')
extra_mail_templates = mail_template.report_template_ids - invoice_template
filename = move._get_invoice_report_filename(report=pdf_report)
return [
{
'id': f'placeholder_{extra_mail_template.name.lower()}_{filename}',
'name': f'{extra_mail_template.name.lower()}_{filename}',
'mimetype': 'application/pdf',
'placeholder': True,
'dynamic_report': extra_mail_template.report_name,
} for extra_mail_template in extra_mail_templates
]
@api.model
def _get_invoice_extra_attachments(self, move):
return move.invoice_pdf_report_id
@api.model
def _get_invoice_extra_attachments_data(self, move):
return [
{
'id': attachment.id,
'name': attachment.name,
'mimetype': attachment.mimetype,
'placeholder': False,
'protect_from_deletion': True,
}
for attachment in self._get_invoice_extra_attachments(move)
]
@api.model
def _get_mail_template_attachments_data(self, mail_template):
""" Returns all mail template data. """
return [
{
'id': attachment.id,
'name': attachment.name,
'mimetype': attachment.mimetype,
'placeholder': False,
'mail_template_id': mail_template.id,
'protect_from_deletion': True,
}
for attachment in mail_template.attachment_ids
]
# -------------------------------------------------------------------------
# HELPERS
# -------------------------------------------------------------------------
@api.model
def _raise_danger_alerts(self, alerts):
danger_alert_messages = [alert['message'] for _key, alert in alerts.items() if alert.get('level') == 'danger']
if danger_alert_messages:
raise UserError('\n'.join(danger_alert_messages))
@api.model
def _check_move_constraints(self, moves):
for move in moves:
if move_constraints := self._get_move_constraints(move):
raise UserError(next(iter(move_constraints.values()), None))
@api.model
def _get_move_constraints(self, move):
constraints = {}
if move.state != 'posted':
constraints['not_posted'] = _("You can't generate invoices that are not posted.")
if not move.is_sale_document(include_receipts=True):
constraints['not_sale_document'] = _("You can only generate sales documents.")
return constraints
@api.model
def _check_invoice_report(self, moves, **custom_settings):
if ((
custom_settings.get('pdf_report')
and any(not move._is_action_report_available(custom_settings['pdf_report']) for move in moves)
)
or any(not self._get_default_pdf_report_id(move).is_invoice_report for move in moves)
):
raise UserError(_("The sending of invoices is not set up properly, make sure the report used is set for invoices."))
@api.model
def _format_error_text(self, error):
""" Format the error that can be a dict (complex format needed)
:param error: the error to format.
:return: a text formatted error.
"""
errors = '\n- '.join(error.get('errors', ''))
return f"{error['error_title']}\n- {errors}" if errors else error['error_title']
@api.model
def _format_error_html(self, error):
""" Format the error that can be a dict (complex format needed)
:param error: the error to format.
:return: a html formatted error.
"""
if 'errors' not in error:
return error['error_title']
errors = Markup().join(Markup("<li>%s</li>") % error for error in error['errors'])
return Markup("%s<ul>%s</ul>") % (error['error_title'], errors)
@api.model
def _display_attachments_widget(self, edi_format, sending_methods):
return 'email' in sending_methods
# -------------------------------------------------------------------------
# SENDING METHODS
# -------------------------------------------------------------------------
@api.model
def _is_applicable_to_company(self, method, company):
""" TO OVERRIDE - used to determine if we should display the sending method in the selection."""
return True
@api.model
def _is_applicable_to_move(self, method, move, **move_data):
""" TO OVERRIDE - """
if method == 'email' and 'mail_partner_ids' in move_data:
return bool(move_data['mail_partner_ids'])
return True
@api.model
def _hook_invoice_document_before_pdf_report_render(self, invoice, invoice_data):
""" Hook allowing to add some extra data for the invoice passed as parameter before the rendering of the pdf
report.
:param invoice: An account.move record.
:param invoice_data: The collected data for the invoice so far.
"""
return
@api.model
def _prepare_invoice_pdf_report(self, invoices_data):
""" Prepare the pdf report for the invoice passed as parameter.
:param invoice: An account.move record.
:param invoice_data: The collected data for the invoice so far.
"""
company_id = next(iter(invoices_data)).company_id
grouped_invoices_by_report = defaultdict(dict)
for invoice, invoice_data in invoices_data.items():
grouped_invoices_by_report[invoice_data['pdf_report']][invoice] = invoice_data
for pdf_report, group_invoices_data in grouped_invoices_by_report.items():
ids = [inv.id for inv in group_invoices_data]
content, report_type = self.env['ir.actions.report'].with_company(company_id)._pre_render_qweb_pdf(pdf_report.report_name, res_ids=ids)
content_by_id = self.env['ir.actions.report']._get_splitted_report(pdf_report.report_name, content, report_type)
if len(content_by_id) == 1 and False in content_by_id:
raise ValidationError(_("Cannot identify the invoices in the generated PDF: %s", ids))
for invoice, invoice_data in group_invoices_data.items():
invoice_data['pdf_attachment_values'] = {
'name': invoice._get_invoice_report_filename(report=pdf_report),
'raw': content_by_id[invoice.id],
'mimetype': 'application/pdf',
'res_model': invoice._name,
'res_id': invoice.id,
'res_field': 'invoice_pdf_report_file', # Binary field
}
@api.model
def _prepare_invoice_proforma_pdf_report(self, invoice, invoice_data):
""" Prepare the proforma pdf report for the invoice passed as parameter.
:param invoice: An account.move record.
:param invoice_data: The collected data for the invoice so far.
"""
pdf_report = invoice_data['pdf_report']
content, report_type = self.env['ir.actions.report'].with_company(invoice.company_id)._pre_render_qweb_pdf(pdf_report.report_name, invoice.ids, data={'proforma': True})
content_by_id = self.env['ir.actions.report']._get_splitted_report(pdf_report.report_name, content, report_type)
invoice_data['proforma_pdf_attachment_values'] = {
'raw': content_by_id[invoice.id],
'name': invoice._get_invoice_proforma_pdf_report_filename(),
'mimetype': 'application/pdf',
'res_model': invoice._name,
'res_id': invoice.id,
}
@api.model
def _hook_invoice_document_after_pdf_report_render(self, invoice, invoice_data):
""" Hook allowing to add some extra data for the invoice passed as parameter after the rendering of the
(proforma) pdf report.
:param invoice: An account.move record.
:param invoice_data: The collected data for the invoice so far.
"""
return
@api.model
def _link_invoice_documents(self, invoices_data):
""" Create the attachments containing the pdf/electronic documents for the invoice passed as parameter.
:param invoice: An account.move record.
:param invoice_data: The collected data for the invoice so far.
"""
# create an attachment that will become 'invoice_pdf_report_file'
# note: Binary is used for security reason
attachment_to_create = [invoice_data['pdf_attachment_values'] for invoice_data in invoices_data.values() if invoice_data.get('pdf_attachment_values')]
if not attachment_to_create:
return
attachments = self.sudo().env['ir.attachment'].create(attachment_to_create)
res_id_to_attachment = {attachment.res_id: attachment for attachment in attachments}
for invoice, invoice_data in invoices_data.items():
if attachment := res_id_to_attachment.get(invoice.id):
invoice.message_main_attachment_id = attachment
invoice.invalidate_recordset(fnames=['invoice_pdf_report_id', 'invoice_pdf_report_file'])
invoice.is_move_sent = True
@api.model
def _hook_if_errors(self, moves_data, allow_raising=True):
""" Process errors found so far when generating the documents. """
group_by_partner = defaultdict(list)
for move, move_data in moves_data.items():
error = move_data['error']
if allow_raising:
raise UserError(self._format_error_text(error))
group_by_partner[move_data['author_partner_id']].append(move.id)
move.message_post(body=self._format_error_html(error))
self._send_notifications_to_partners(group_by_partner, is_success=False)
@api.model
def _hook_if_success(self, moves_data, from_cron=False):
""" Process (typically send) successful documents."""
group_by_partner = defaultdict(list)
to_send_mail = {}
for move, move_data in moves_data.items():
if from_cron:
group_by_partner[move_data['author_partner_id']].append(move.id)
if 'email' in move_data['sending_methods'] and self._is_applicable_to_move('email', move, **move_data):
to_send_mail[move] = move_data
self._send_mails(to_send_mail)
self._send_notifications_to_partners(group_by_partner)
# Notify subscribers.
for move, move_data in moves_data.items():
if not move.is_invoice(include_receipts=True):
continue
try:
move.journal_id._notify_invoice_subscribers(
invoice=move,
mail_params={
'attachment_ids': [
Command.create({'name': attachment.name, 'raw': attachment.raw, 'mimetype': attachment.mimetype})
for attachment in self._get_invoice_extra_attachments(move)
]
},
)
except Exception:
_logger.exception("Failed notifying subscribers for move %s", move.id)
@api.model
def _send_notifications_to_partners(self, moves_grouped_by_author_partner_id, is_success=True):
if not moves_grouped_by_author_partner_id:
return
def get_account_notification(move_ids, is_success: bool):
_ = self.env._
return [
'account_notification',
{
'type': 'success' if is_success else 'warning',
'title': _("Invoices sent") if is_success else _("Invoices in error"),
'message': _("Invoices sent successfully.") if is_success else _(
"One or more invoices couldn't be processed."),
'action_button': {
'name': _('Open'),
'action_name': _("Sent invoices") if is_success else _("Invoices in error"),
'model': 'account.move',
'res_ids': move_ids,
},
},
]
ResPartner = self.env['res.partner']
for partner_id, move_ids in moves_grouped_by_author_partner_id.items():
partner = ResPartner.browse(partner_id)
partner._bus_send(*get_account_notification(move_ids, is_success))
@api.model
def _send_mail(self, move, mail_template, **kwargs):
""" Send the journal entry passed as parameter by mail. """
new_message = move.with_context(
email_notification_allow_footer=True,
disable_attachment_import=True,
no_document=True,
).message_post(
message_type='comment',
**kwargs,
**{ # noqa: PIE804
'email_layout_xmlid': self._get_mail_layout(),
'email_add_signature': not mail_template,
'mail_auto_delete': mail_template.auto_delete,
'mail_server_id': mail_template.mail_server_id.id,
'reply_to_force_new': False,
}
)
# Prevent duplicated attachments linked to the invoice.
new_message.attachment_ids.invalidate_recordset(['res_id', 'res_model'], flush=False)
if new_message.attachment_ids.ids:
self.env.cr.execute("UPDATE ir_attachment SET res_id = NULL WHERE id IN %s", [tuple(new_message.attachment_ids.ids)])
new_message.attachment_ids.write({
'res_model': new_message._name,
'res_id': new_message.id,
})
@api.model
def _get_mail_layout(self):
return 'mail.mail_notification_layout_with_responsible_signature'
@api.model
def _get_mail_params(self, move, move_data):
# We must ensure the newly created PDF are added. At this point, the PDF has been generated but not added
# to 'mail_attachments_widget'.
mail_attachments_widget = move_data.get('mail_attachments_widget')
seen_attachment_ids = set()
to_exclude = {x['name'] for x in mail_attachments_widget if x.get('skip')}
for attachment_data in self._get_invoice_extra_attachments_data(move) + mail_attachments_widget:
if attachment_data['name'] in to_exclude and not attachment_data.get('manual'):
continue
try:
attachment_id = int(attachment_data['id'])
except ValueError:
continue
seen_attachment_ids.add(attachment_id)
mail_attachments = [
(attachment.name, attachment.raw)
for attachment in self.env['ir.attachment'].browse(list(seen_attachment_ids)).exists()
]
return {
'author_id': move_data['author_partner_id'],
'body': move_data['mail_body'],
'subject': move_data['mail_subject'],
'partner_ids': move_data['mail_partner_ids'],
'attachments': mail_attachments,
}
@api.model
def _generate_dynamic_reports(self, moves_data):
for move, move_data in moves_data.items():
mail_attachments_widget = move_data.get('mail_attachments_widget', [])
dynamic_reports = [
attachment_widget
for attachment_widget in mail_attachments_widget
if attachment_widget.get('dynamic_report')
and not attachment_widget.get('skip')
]
attachments_to_create = []
for dynamic_report in dynamic_reports:
content, _report_format = self.env['ir.actions.report']\
.with_company(move.company_id)\
.with_context(from_account_move_send=True)\
._render(dynamic_report['dynamic_report'], move.ids)
attachments_to_create.append({
'raw': content,
'name': dynamic_report['name'],
'mimetype': 'application/pdf',
'res_model': move._name,
'res_id': move.id,
})
attachments = self.env['ir.attachment'].create(attachments_to_create)
mail_attachments_widget += [{
'id': attachment.id,
'name': attachment.name,
'mimetype': 'application/pdf',
'placeholder': False,
'protect_from_deletion': True,
} for attachment in attachments]
@api.model
def _send_mails(self, moves_data):
subtype = self.env.ref('mail.mt_comment')
self._generate_dynamic_reports(moves_data)
for move, move_data in [
(move, move_data)
for move, move_data in moves_data.items()
if move.partner_id.email or move_data.get('mail_partner_ids')
]:
mail_template = move_data['mail_template']
mail_lang = move_data['mail_lang']
mail_params = self._get_mail_params(move, move_data)
if not mail_params:
continue
if move_data.get('proforma_pdf_attachment'):
attachment = move_data['proforma_pdf_attachment']
mail_params['attachments'].append((attachment.name, attachment.raw))
# synchronize author / email_from, as account.move.send wizard computes
# a bit too much stuff
author_id = mail_params.pop('author_id', False)
email_from = self._get_mail_default_field_value_from_template(mail_template, mail_lang, move, 'email_from')
if email_from or not author_id:
author_id, email_from = move._message_compute_author(email_from=email_from)
model_description = move.with_context(lang=mail_lang).type_name
self._send_mail(
move,
mail_template,
author_id=author_id,
subtype_id=subtype.id,
model_description=model_description,
notify_author_mention=True,
email_from=email_from,
**mail_params,
)
@api.model
def _can_commit(self):
""" Helper to know if we can commit the current transaction or not.
:return: True if commit is accepted, False otherwise.
"""
return not (tools.config['test_enable'] or modules.module.current_test)
@api.model
def _call_web_service_before_invoice_pdf_render(self, invoices_data):
# TO OVERRIDE
# call a web service before the pdfs are rendered
return
@api.model
def _call_web_service_after_invoice_pdf_render(self, invoices_data):
# TO OVERRIDE
# call a web service after the pdfs are rendered
return
@api.model
def _generate_invoice_documents(self, invoices_data, allow_fallback_pdf=False):
""" Generate the invoice PDF and electronic documents.
:param invoices_data: The collected data for invoices so far.
:param allow_fallback_pdf: In case of error when generating the documents for invoices, generate a
proforma PDF report instead.
"""
for invoice, invoice_data in invoices_data.items():
self._hook_invoice_document_before_pdf_report_render(invoice, invoice_data)
invoice_data['blocking_error'] = invoice_data.get('error') \
and not (allow_fallback_pdf and invoice_data.get('error_but_continue'))
invoice_data['error_but_continue'] = allow_fallback_pdf and invoice_data.get('error_but_continue')
invoices_data_web_service = {
invoice: invoice_data
for invoice, invoice_data in invoices_data.items()
if not invoice_data.get('error')
}
if invoices_data_web_service:
self._call_web_service_before_invoice_pdf_render(invoices_data_web_service)
invoices_data_pdf = {
invoice: invoice_data
for invoice, invoice_data in invoices_data.items()
if not invoice_data.get('error') or invoice_data.get('error_but_continue')
}
# Use batch to avoid memory error
batch_size = self.env['ir.config_parameter'].sudo().get_param('account.pdf_generation_batch', '80')
batches = []
pdf_to_generate = {}
for invoice, invoice_data in invoices_data_pdf.items():
if not invoice_data.get('error') and not invoice.invoice_pdf_report_id: # we don't regenerate pdf if it already exists
pdf_to_generate[invoice] = invoice_data
if (len(pdf_to_generate) > int(batch_size)):
batches.append(pdf_to_generate)
pdf_to_generate = {}
if pdf_to_generate:
batches.append(pdf_to_generate)
for batch in batches:
self._prepare_invoice_pdf_report(batch)
for invoice, invoice_data in invoices_data_pdf.items():
if not invoice_data.get('error') and not invoice.invoice_pdf_report_id:
self._hook_invoice_document_after_pdf_report_render(invoice, invoice_data)
# Cleanup the error if we don't want to block the regular pdf generation.
if allow_fallback_pdf:
invoices_data_pdf_error = {
invoice: invoice_data
for invoice, invoice_data in invoices_data.items()
if invoice_data.get('pdf_attachment_values') and invoice_data.get('error')
}
if invoices_data_pdf_error:
self._hook_if_errors(invoices_data_pdf_error, allow_raising=not allow_fallback_pdf)
# Web-service after the PDF generation.
invoices_data_web_service = {
invoice: invoice_data
for invoice, invoice_data in invoices_data.items()
if not invoice_data.get('error')
}
if invoices_data_web_service:
self._call_web_service_after_invoice_pdf_render(invoices_data_web_service)
# Create and link the generated documents to the invoice if the web-service didn't failed.
invoices_to_link = {
invoice: invoice_data
for invoice, invoice_data in invoices_data_web_service.items()
if not invoice_data.get('error') or allow_fallback_pdf
}
self._link_invoice_documents(invoices_to_link)
@api.model
def _generate_invoice_fallback_documents(self, invoices_data):
""" Generate the invoice PDF and electronic documents.
:param invoices_data: The collected data for invoices so far.
"""
for invoice, invoice_data in invoices_data.items():
if not invoice.invoice_pdf_report_id and invoice_data.get('error'):
invoice_data.pop('error')
self._prepare_invoice_proforma_pdf_report(invoice, invoice_data)
self._hook_invoice_document_after_pdf_report_render(invoice, invoice_data)
invoice_data['proforma_pdf_attachment'] = self.env['ir.attachment']\
.create(invoice_data.pop('proforma_pdf_attachment_values'))
def _check_sending_data(self, moves, **custom_settings):
"""Assert the data provided to _generate_and_send_invoices are correct.
This is a security in case the method is called directly without going through the wizards.
"""
self._check_move_constraints(moves)
self._check_invoice_report(moves, **custom_settings)
assert all(
sending_method in dict(self.env['res.partner']._fields['invoice_sending_method'].selection)
for sending_method in custom_settings.get('sending_methods', [])
) if 'sending_methods' in custom_settings else True
@api.model
def _generate_and_send_invoices(self, moves, from_cron=False, allow_raising=True, allow_fallback_pdf=False, **custom_settings):
""" Generate and send the moves given custom_settings if provided, else their default configuration set on related partner/company.
:param moves: account.move to process
:param from_cron: whether the processing comes from a cron.
:param allow_raising: whether the process can raise errors, or should log them on the move's chatter.
:param allow_fallback_pdf: In case of error when generating the documents for invoices, generate a proforma PDF report instead.
:param custom_settings: settings to apply instead of related partner's defaults settings.
"""
self._check_sending_data(moves, **custom_settings)
moves_data = {
move.sudo(): {
**self._get_default_sending_settings(move, from_cron=from_cron, **custom_settings),
}
for move in moves
}
# Generate all invoice documents (PDF and electronic documents if relevant).
self._generate_invoice_documents(moves_data, allow_fallback_pdf=allow_fallback_pdf)
# Manage errors.
errors = {move: move_data for move, move_data in moves_data.items() if move_data.get('error')}
if errors:
self._hook_if_errors(errors, allow_raising=not from_cron and not allow_fallback_pdf and allow_raising)
# Fallback in case of error.
errors = {move: move_data for move, move_data in moves_data.items() if move_data.get('error')}
if allow_fallback_pdf and errors:
self._generate_invoice_fallback_documents(errors)
# Successfully generated a PDF - Process sending.
success = {move: move_data for move, move_data in moves_data.items() if not move_data.get('error')}
if success:
self._hook_if_success(success, from_cron=from_cron)
# Update sending data of moves
for move, move_data in moves_data.items():
# We keep the sending_data, so it will be retried
if from_cron and move_data.get('error', {}).get('retry'):
continue
move.sending_data = False
# Return generated attachments.
attachments = self.env['ir.attachment']
for move, move_data in success.items():
attachments += self._get_invoice_extra_attachments(move) or move_data['proforma_pdf_attachment']
return attachments

View file

@ -4,12 +4,11 @@ from odoo.exceptions import UserError, ValidationError
from odoo.tools import frozendict
from datetime import date
import json
class AccountPartialReconcile(models.Model):
_name = "account.partial.reconcile"
_name = 'account.partial.reconcile'
_description = "Partial Reconcile"
_rec_name = "id"
# ==== Reconciliation fields ====
debit_move_id = fields.Many2one(
@ -23,6 +22,10 @@ class AccountPartialReconcile(models.Model):
string="Full Reconcile", copy=False, index='btree_not_null')
exchange_move_id = fields.Many2one(comodel_name='account.move', index='btree_not_null')
# this field will be used upon the posting of the invoice, to know if we can keep the partial or if the
# user has to re-do entirely the reconciliaion (in case fundamental values changed for the cash basis)
draft_caba_move_vals = fields.Json(string="Values that created the draft cash-basis entry")
# ==== Currency fields ====
company_currency_id = fields.Many2one(
comodel_name='res.currency',
@ -55,9 +58,11 @@ class AccountPartialReconcile(models.Model):
company_id = fields.Many2one(
comodel_name='res.company',
string="Company", store=True, readonly=False,
related='debit_move_id.company_id')
precompute=True,
compute='_compute_company_id')
max_date = fields.Date(
string="Max Date of Matched Lines", store=True,
precompute=True,
compute='_compute_max_date')
# used to determine at which date this reconciliation needs to be shown on the aged receivable/payable reports
@ -83,6 +88,15 @@ class AccountPartialReconcile(models.Model):
partial.credit_move_id.date
)
@api.depends('debit_move_id', 'credit_move_id')
def _compute_company_id(self):
for partial in self:
# Potential exchange diff and caba entries should be created on the invoice side if any
if partial.debit_move_id.move_id.is_invoice(True):
partial.company_id = partial.debit_move_id.company_id
else:
partial.company_id = partial.credit_move_id.company_id
# -------------------------------------------------------------------------
# LOW-LEVEL METHODS
# -------------------------------------------------------------------------
@ -96,30 +110,110 @@ class AccountPartialReconcile(models.Model):
if not self:
return True
# Retrieve the matching number to unlink.
full_to_unlink = self.full_reconcile_id
# Get the payments without journal entry to reset once the amount residual is reset
to_update_payments = self._get_to_update_payments(from_state='paid')
# Retrieve the CABA entries to reverse.
moves_to_reverse = self.env['account.move'].search([('tax_cash_basis_rec_id', 'in', self.ids)])
# Same for the exchange difference entries.
moves_to_reverse += self.exchange_move_id
# Retrieve the matching number to unlink
full_to_unlink = self.full_reconcile_id
# if the move is draft and can be removed, there is no need to update the matching number
all_reconciled = self.debit_move_id + self.credit_move_id
# Unlink partials before doing anything else to avoid 'Record has already been deleted' due to the recursion.
res = super().unlink()
# Remove the matching numbers before reversing the moves to avoid trying to remove the full twice.
full_to_unlink.unlink()
# Reverse CABA entries.
# Reverse or unlink CABA/exchange move entries.
if moves_to_reverse:
not_draft_moves = moves_to_reverse.filtered(lambda m: m.state != 'draft')
draft_moves = moves_to_reverse - not_draft_moves
default_values_list = [{
'date': move._get_accounting_date(move.date, move._affect_tax_report()),
'ref': _('Reversal of: %s') % move.name,
} for move in moves_to_reverse]
moves_to_reverse._reverse_moves(default_values_list, cancel=True)
'ref': move.env._('Reversal of: %s', move.name),
} for move in not_draft_moves]
not_draft_moves._reverse_moves(default_values_list, cancel=True)
draft_moves.unlink()
all_reconciled = all_reconciled.exists()
self._update_matching_number(all_reconciled)
to_update_payments.state = 'in_process'
return res
@api.model_create_multi
def create(self, vals_list):
partials = super().create(vals_list)
partials._get_to_update_payments(from_state='in_process').state = 'paid'
self._update_matching_number(partials.debit_move_id + partials.credit_move_id)
return partials
def _get_to_update_payments(self, from_state):
to_update = []
for partial in self:
matched_payments = (partial.credit_move_id | partial.debit_move_id).move_id.matched_payment_ids
to_check_payments = matched_payments.filtered(lambda payment: not payment.outstanding_account_id and payment.state == from_state)
for payment in to_check_payments:
if payment.payment_type == 'inbound':
amount = partial.debit_amount_currency
else:
amount = -partial.credit_amount_currency
if not payment.currency_id.compare_amounts(payment.amount_signed, amount):
to_update.append(payment)
break
return self.env['account.payment'].union(*to_update)
@api.model
def _update_matching_number(self, amls):
amls = amls._all_reconciled_lines()
all_partials = amls.matched_debit_ids | amls.matched_credit_ids
# The matchings form a set of graphs, which can be numbered: this is the matching number.
# We iterate on each edge of the graphs, giving it a number (min of its edge ids).
# By iterating, we either simply add a node (move line) to the graph and asign the number to
# it or we merge the two graphs.
# At the end, we have an index for the number to assign of all lines.
number2lines = {}
line2number = {}
for partial in all_partials.sorted('id'):
debit_min_id = line2number.get(partial.debit_move_id.id)
credit_min_id = line2number.get(partial.credit_move_id.id)
if debit_min_id and credit_min_id: # merging the 2 graph into the one with smalles number
if debit_min_id != credit_min_id:
min_min_id = min(debit_min_id, credit_min_id)
max_min_id = max(debit_min_id, credit_min_id)
for line_id in number2lines[max_min_id]:
line2number[line_id] = min_min_id
number2lines[min_min_id].extend(number2lines.pop(max_min_id))
elif debit_min_id: # adding a new node to a graph
number2lines[debit_min_id].append(partial.credit_move_id.id)
line2number[partial.credit_move_id.id] = debit_min_id
elif credit_min_id: # adding a new node to a graph
number2lines[credit_min_id].append(partial.debit_move_id.id)
line2number[partial.debit_move_id.id] = credit_min_id
else: # creating a new graph
number2lines[partial.id] = [partial.debit_move_id.id, partial.credit_move_id.id]
line2number[partial.debit_move_id.id] = partial.id
line2number[partial.credit_move_id.id] = partial.id
amls.flush_recordset(['full_reconcile_id'])
self.env.cr.execute_values("""
UPDATE account_move_line l
SET matching_number = CASE
WHEN l.full_reconcile_id IS NOT NULL THEN l.full_reconcile_id::text
ELSE 'P' || source.number
END
FROM (VALUES %s) AS source(number, ids)
WHERE l.id = ANY(source.ids)
""", list(number2lines.items()), page_size=1000)
processed_amls = self.env['account.move.line'].browse([_id for ids in number2lines.values() for _id in ids])
processed_amls.invalidate_recordset(['matching_number'])
(amls - processed_amls).matching_number = False
# -------------------------------------------------------------------------
# RECONCILIATION METHODS
# -------------------------------------------------------------------------
@ -155,7 +249,8 @@ class AccountPartialReconcile(models.Model):
if not journal:
raise UserError(_("There is no tax cash basis journal defined for the '%s' company.\n"
"Configure it in Accounting/Configuration/Settings") % partial.company_id.display_name)
"Configure it in Accounting/Configuration/Settings",
partial.company_id.display_name))
partial_amount = 0.0
partial_amount_currency = 0.0
@ -206,12 +301,15 @@ class AccountPartialReconcile(models.Model):
if source_line.currency_id != counterpart_line.currency_id:
# When the invoice and the payment are not sharing the same foreign currency, the rate is computed
# on-the-fly using the payment date.
payment_rate = self.env['res.currency']._get_conversion_rate(
counterpart_line.company_currency_id,
source_line.currency_id,
counterpart_line.company_id,
payment_date,
)
if 'forced_rate_from_register_payment' in self.env.context:
payment_rate = self.env.context['forced_rate_from_register_payment']
else:
payment_rate = self.env['res.currency']._get_conversion_rate(
counterpart_line.company_currency_id,
source_line.currency_id,
counterpart_line.company_id,
payment_date,
)
elif rate_amount:
payment_rate = rate_amount_currency / rate_amount
else:
@ -223,6 +321,7 @@ class AccountPartialReconcile(models.Model):
'partial': partial,
'percentage': percentage,
'payment_rate': payment_rate,
'both_move_posted': partial.debit_move_id.move_id.state == 'posted' and partial.credit_move_id.move_id.state == 'posted',
}
# Add partials.
@ -261,6 +360,7 @@ class AccountPartialReconcile(models.Model):
'tax_ids': [Command.set(tax_ids.ids)],
'tax_tag_ids': [Command.set(all_tags.ids)],
'analytic_distribution': base_line.analytic_distribution,
'display_type': base_line.display_type,
}
@api.model
@ -281,6 +381,7 @@ class AccountPartialReconcile(models.Model):
'currency_id': cb_base_line_vals['currency_id'],
'partner_id': cb_base_line_vals['partner_id'],
'analytic_distribution': cb_base_line_vals['analytic_distribution'],
'display_type': cb_base_line_vals['display_type'],
}
@api.model
@ -294,7 +395,7 @@ class AccountPartialReconcile(models.Model):
account.move.line.
'''
tax_ids = tax_line.tax_ids.filtered(lambda x: x.tax_exigibility == 'on_payment')
base_tags = tax_ids.get_tax_tags(tax_line.tax_repartition_line_id.refund_tax_id, 'base')
base_tags = tax_ids.get_tax_tags(tax_line.tax_repartition_line_id.filtered(lambda rl: rl.document_type == 'refund').tax_id, 'base')
product_tags = tax_line.tax_tag_ids.filtered(lambda x: x.applicability == 'products')
all_tags = base_tags + tax_line.tax_repartition_line_id.tag_ids + product_tags
@ -311,7 +412,7 @@ class AccountPartialReconcile(models.Model):
'currency_id': tax_line.currency_id.id,
'partner_id': tax_line.partner_id.id,
'analytic_distribution': tax_line.analytic_distribution,
# No need to set tax_tag_invert as on the base line; it will be computed from the repartition line
'display_type': tax_line.display_type,
}
@api.model
@ -333,6 +434,7 @@ class AccountPartialReconcile(models.Model):
'currency_id': cb_tax_line_vals['currency_id'],
'partner_id': cb_tax_line_vals['partner_id'],
'analytic_distribution': cb_tax_line_vals['analytic_distribution'],
'display_type': cb_tax_line_vals['display_type'],
}
@api.model
@ -406,23 +508,27 @@ class AccountPartialReconcile(models.Model):
tax_cash_basis_values_per_move = self._collect_tax_cash_basis_values()
today = fields.Date.context_today(self)
moves_to_create = []
moves_to_create_and_post = []
moves_to_create_in_draft = []
to_reconcile_after = []
for move_values in tax_cash_basis_values_per_move.values():
move = move_values['move']
pending_cash_basis_lines = []
amount_residual_per_tax_line = {line.id: line.amount_residual_currency for line_type, line in move_values['to_process_lines'] if line_type == 'tax'}
for partial_values in move_values['partials']:
partial = partial_values['partial']
# Init the journal entry.
lock_date = move.company_id._get_user_fiscal_lock_date()
move_date = partial.max_date if partial.max_date > (lock_date or date.min) else today
journal = partial.company_id.tax_cash_basis_journal_id
lock_date = move.company_id._get_user_fiscal_lock_date(journal)
move_date = partial.max_date if partial.max_date > lock_date else today
move_vals = {
'move_type': 'entry',
'date': move_date,
'ref': move.name,
'journal_id': partial.company_id.tax_cash_basis_journal_id.id,
'journal_id': journal.id,
'company_id': partial.company_id.id,
'line_ids': [],
'tax_cash_basis_rec_id': partial.id,
'tax_cash_basis_origin_move_id': move.id,
@ -434,7 +540,6 @@ class AccountPartialReconcile(models.Model):
partial_lines_to_create = {}
for caba_treatment, line in move_values['to_process_lines']:
# ==========================================================================
# Compute the balance of the current line on the cash basis entry.
# This balance is a percentage representing the part of the journal entry
@ -443,6 +548,19 @@ class AccountPartialReconcile(models.Model):
# Percentage expressed in the foreign currency.
amount_currency = line.currency_id.round(line.amount_currency * partial_values['percentage'])
if (
caba_treatment == 'tax'
and (
move_values['is_fully_paid']
or line.currency_id.compare_amounts(abs(line.amount_residual_currency), abs(amount_currency)) < 0
)
and partial_values == move_values['partials'][-1]
):
# If the move is supposed to be fully paid, and we're on the last partial for it,
# put the remaining amount to avoid rounding issues
amount_currency = amount_residual_per_tax_line[line.id]
if caba_treatment == 'tax':
amount_residual_per_tax_line[line.id] -= amount_currency
balance = partial_values['payment_rate'] and amount_currency / partial_values['payment_rate'] or 0.0
# ==========================================================================
@ -515,7 +633,7 @@ class AccountPartialReconcile(models.Model):
counterpart_line_vals['sequence'] = sequence + 1
if tax_line.account_id.reconcile:
move_index = len(moves_to_create)
move_index = len(moves_to_create_and_post) + len(moves_to_create_in_draft)
to_reconcile_after.append((tax_line, move_index, counterpart_line_vals['sequence']))
else:
@ -528,12 +646,20 @@ class AccountPartialReconcile(models.Model):
move_vals['line_ids'] += [(0, 0, counterpart_line_vals), (0, 0, line_vals)]
moves_to_create.append(move_vals)
if partial_values['both_move_posted']:
moves_to_create_and_post.append(move_vals)
else:
moves_to_create_in_draft.append(move_vals)
moves = self.env['account.move'].create(moves_to_create)
moves._post(soft=False)
moves = self.env['account.move'].with_context(
skip_invoice_sync=True,
skip_invoice_line_sync=True,
skip_account_move_synchronization=True,
).create(moves_to_create_and_post + moves_to_create_in_draft)
moves[:len(moves_to_create_and_post)]._post(soft=False)
# Reconcile the tax lines being on a reconcile tax basis transfer account.
reconciliation_plan = []
for lines, move_index, sequence in to_reconcile_after:
# In expenses, all move lines are created manually without any grouping on tax lines.
@ -548,6 +674,28 @@ class AccountPartialReconcile(models.Model):
if counterpart_line.reconciled:
continue
(lines + counterpart_line).reconcile()
reconciliation_plan.append((counterpart_line + lines))
# passing add_caba_vals in the context to make sure that any exchange diff that would be created for
# this cash basis move would set the field draft_caba_move_vals accordingly on the partial
self.env['account.move.line'].with_context(add_caba_vals=True)._reconcile_plan(reconciliation_plan)
return moves
def _get_draft_caba_move_vals(self):
self.ensure_one()
debit_vals = self.debit_move_id.move_id._collect_tax_cash_basis_values() or {}
credit_vals = self.credit_move_id.move_id._collect_tax_cash_basis_values() or {}
if not debit_vals and not credit_vals:
return False
return json.dumps({
'debit_caba_lines': [(aml_type, aml.id) for aml_type, aml in debit_vals.get('to_process_lines', [])],
'debit_total_balance': debit_vals.get('total_balance'),
'debit_total_amount_currency': debit_vals.get('total_amount_currency'),
'credit_caba_lines': [(aml_type, aml.id) for aml_type, aml in credit_vals.get('to_process_lines', [])],
'credit_total_balance': credit_vals.get('total_balance'),
'credit_total_amount_currency': credit_vals.get('total_amount_currency'),
})
def _set_draft_caba_move_vals(self):
for partial in self:
partial.draft_caba_move_vals = partial._get_draft_caba_move_vals()

File diff suppressed because it is too large Load diff

View file

@ -1,34 +1,36 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.osv import expression
from odoo.fields import Domain
class AccountPaymentMethod(models.Model):
_name = "account.payment.method"
_name = 'account.payment.method'
_description = "Payment Methods"
name = fields.Char(required=True, translate=True)
code = fields.Char(required=True) # For internal identification
payment_type = fields.Selection(selection=[('inbound', 'Inbound'), ('outbound', 'Outbound')], required=True)
_sql_constraints = [
('name_code_unique', 'unique (code, payment_type)', 'The combination code/payment type already exists!'),
]
_name_code_unique = models.Constraint(
'unique (code, payment_type)',
'The combination code/payment type already exists!',
)
@api.model_create_multi
def create(self, vals_list):
payment_methods = super().create(vals_list)
methods_info = self._get_payment_method_information()
return self._auto_link_payment_methods(payment_methods, methods_info)
def _auto_link_payment_methods(self, payment_methods, methods_info):
# This method was extracted from create so it can be overriden in the upgrade script.
# In said script we can then allow for a custom behavior for the payment.method.line on the journals.
for method in payment_methods:
information = methods_info.get(method.code, {})
if information.get('mode') == 'multi':
method_domain = method._get_payment_method_domain(method.code)
journals = self.env['account.journal'].search(method_domain)
self.env['account.payment.method.line'].create([{
'name': method.name,
'payment_method_id': method.id,
@ -37,45 +39,44 @@ class AccountPaymentMethod(models.Model):
return payment_methods
@api.model
def _get_payment_method_domain(self, code):
def _get_payment_method_domain(self, code, with_currency=True, with_country=True):
"""
:return: The domain specyfying which journal can accomodate this payment method.
:param code: string of the payment method line code to check.
:param with_currency: if False (default True), ignore the currency_id domain if it exists.
:return: The domain specifying which journal can accommodate this payment method.
"""
if not code:
return []
return Domain.TRUE
information = self._get_payment_method_information().get(code)
journal_types = information.get('type', ('bank', 'cash', 'credit'))
domain = Domain('type', 'in', journal_types)
currency_ids = information.get('currency_ids')
country_id = information.get('country_id')
default_domain = [('type', 'in', ('bank', 'cash'))]
domains = [information.get('domain', default_domain)]
if with_currency and (currency_ids := information.get('currency_ids')):
domain &= (
Domain('currency_id', '=', False) & Domain('company_id.currency_id', 'in', currency_ids)
) | Domain('currency_id', 'in', currency_ids)
if currency_ids:
domains += [expression.OR([
[('currency_id', '=', False), ('company_id.currency_id', 'in', currency_ids)],
[('currency_id', 'in', currency_ids)]],
)]
if with_country and (country_id := information.get('country_id')):
domain &= Domain('company_id.account_fiscal_country_id', '=', country_id)
if country_id:
domains += [[('company_id.account_fiscal_country_id', '=', country_id)]]
return expression.AND(domains)
return domain
@api.model
def _get_payment_method_information(self):
"""
Contains details about how to initialize a payment method with the code x.
The contained info are:
mode: Either unique if we only want one of them at a single time (payment providers for example)
or multi if we want the method on each journal fitting the domain.
domain: The domain defining the eligible journals.
currency_id: The id of the currency necessary on the journal (or company) for it to be eligible.
country_id: The id of the country needed on the company for it to be eligible.
hidden: If set to true, the method will not be automatically added to the journal,
and will not be selectable by the user.
- ``mode``: One of the following:
"unique" if the method cannot be used twice on the same company,
"electronic" if the method cannot be used twice on the same company for the same 'payment_provider_id',
"multi" if the method can be duplicated on the same journal.
- ``type``: Tuple containing one or both of these items: "bank" and "cash"
- ``currency_ids``: The ids of the currency necessary on the journal (or company) for it to be eligible.
- ``country_id``: The id of the country needed on the company for it to be eligible.
"""
return {
'manual': {'mode': 'multi', 'domain': [('type', 'in', ('bank', 'cash'))]},
'manual': {'mode': 'multi', 'type': ('bank', 'cash', 'credit')},
}
@api.model
@ -86,9 +87,13 @@ class AccountPaymentMethod(models.Model):
"""
return []
def unlink(self):
self.env['account.payment.method.line'].search([('payment_method_id', 'in', self.ids)]).unlink()
return super().unlink()
class AccountPaymentMethodLine(models.Model):
_name = "account.payment.method.line"
_name = 'account.payment.method.line'
_description = "Payment Methods"
_order = 'sequence, id'
@ -100,19 +105,22 @@ class AccountPaymentMethodLine(models.Model):
comodel_name='account.payment.method',
domain="[('payment_type', '=?', payment_type), ('id', 'in', available_payment_method_ids)]",
required=True,
ondelete='cascade'
)
payment_account_id = fields.Many2one(
comodel_name='account.account',
check_company=True,
copy=False,
ondelete='restrict',
domain="[('deprecated', '=', False), "
"('company_id', '=', company_id), "
"('account_type', 'not in', ('asset_receivable', 'liability_payable')), "
"'|', ('account_type', 'in', ('asset_current', 'liability_current')), ('id', '=', parent.default_account_id)]"
domain="['|', ('account_type', 'in', ('asset_current', 'liability_current')), ('id', '=', default_account_id)]"
)
journal_id = fields.Many2one(
comodel_name='account.journal',
check_company=True,
index='btree_not_null',
)
default_account_id = fields.Many2one(
related='journal_id.default_account_id'
)
journal_id = fields.Many2one(comodel_name='account.journal', ondelete="cascade")
# == Display purpose fields ==
code = fields.Char(related='payment_method_id.code')
@ -120,6 +128,14 @@ class AccountPaymentMethodLine(models.Model):
company_id = fields.Many2one(related='journal_id.company_id')
available_payment_method_ids = fields.Many2many(related='journal_id.available_payment_method_ids')
@api.depends('journal_id')
@api.depends_context('hide_payment_journal_id')
def _compute_display_name(self):
for method in self:
if self.env.context.get('hide_payment_journal_id'):
return super()._compute_display_name()
method.display_name = f"{method.name} ({method.journal_id.name})"
@api.depends('payment_method_id.name')
def _compute_name(self):
for method in self:
@ -147,24 +163,11 @@ class AccountPaymentMethodLine(models.Model):
@api.model
def _auto_toggle_account_to_reconcile(self, account_id):
""" Automatically toggle the account to reconcile if allowed.
"""This method is deprecated and will be removed.
Automatically toggle the account to reconcile if allowed.
:param account_id: The id of an account.account.
"""
account = self.env['account.account'].browse(account_id)
if not account.reconcile and account.account_type not in ('asset_cash', 'liability_credit_card') and account.internal_group != 'off_balance':
if not account.reconcile and account.account_type not in ('asset_cash', 'liability_credit_card', 'off_balance'):
account.reconcile = True
@api.model_create_multi
def create(self, vals_list):
# OVERRIDE
for vals in vals_list:
if vals.get('payment_account_id'):
self._auto_toggle_account_to_reconcile(vals['payment_account_id'])
return super().create(vals_list)
def write(self, vals):
# OVERRIDE
if vals.get('payment_account_id'):
self._auto_toggle_account_to_reconcile(vals['payment_account_id'])
return super().write(vals)

View file

@ -2,51 +2,116 @@
from odoo import api, fields, models, _, Command
from odoo.exceptions import UserError, ValidationError
from odoo.tools import format_date, formatLang, frozendict
from odoo.tools import format_date, formatLang, frozendict, date_utils
from odoo.tools.float_utils import float_round
from dateutil.relativedelta import relativedelta
class AccountPaymentTerm(models.Model):
_name = "account.payment.term"
_name = 'account.payment.term'
_description = "Payment Terms"
_order = "sequence, id"
_check_company_domain = models.check_company_domain_parent_of
def _default_line_ids(self):
return [Command.create({'value': 'balance', 'value_amount': 0.0, 'days': 0, 'end_month': False})]
def _default_example_amount(self):
return self._context.get('example_amount') or 100 # Force default value if the context is set to False
return [Command.create({'value': 'percent', 'value_amount': 100.0, 'nb_days': 0})]
def _default_example_date(self):
return self._context.get('example_date') or fields.Date.today()
return self.env.context.get('example_date') or fields.Date.today()
name = fields.Char(string='Payment Terms', translate=True, required=True)
active = fields.Boolean(default=True, help="If the active field is set to False, it will allow you to hide the payment terms without removing it.")
note = fields.Html(string='Description on the Invoice', translate=True)
line_ids = fields.One2many('account.payment.term.line', 'payment_id', string='Terms', copy=True, default=_default_line_ids)
company_id = fields.Many2one('res.company', string='Company')
fiscal_country_codes = fields.Char(compute='_compute_fiscal_country_codes')
sequence = fields.Integer(required=True, default=10)
display_on_invoice = fields.Boolean(string='Display terms on invoice', help="If set, the payment deadlines and respective due amounts will be detailed on invoices.")
example_amount = fields.Float(default=_default_example_amount, store=False)
currency_id = fields.Many2one('res.currency', compute="_compute_currency_id")
display_on_invoice = fields.Boolean(string='Show installment dates', default=True)
example_amount = fields.Monetary(currency_field='currency_id', default=1000, store=False, readonly=True)
example_date = fields.Date(string='Date example', default=_default_example_date, store=False)
example_invalid = fields.Boolean(compute='_compute_example_invalid')
example_preview = fields.Html(compute='_compute_example_preview')
example_preview_discount = fields.Html(compute='_compute_example_preview')
discount_percentage = fields.Float(string='Discount %', help='Early Payment Discount granted for this payment term', default=2.0)
discount_days = fields.Integer(string='Discount Days', help='Number of days before the early payment proposition expires', default=10)
early_pay_discount_computation = fields.Selection([
('included', 'On early payment'),
('excluded', 'Never'),
('mixed', 'Always (upon invoice)'),
], string='Cash Discount Tax Reduction', readonly=False, store=True, compute='_compute_discount_computation')
early_discount = fields.Boolean(string='Early Discount')
@api.depends('company_id')
@api.depends_context('allowed_company_ids')
def _compute_fiscal_country_codes(self):
for record in self:
allowed_companies = record.company_id or self.env.companies
record.fiscal_country_codes = ",".join(allowed_companies.mapped('account_fiscal_country_id.code'))
@api.depends_context('company')
@api.depends('company_id')
def _compute_currency_id(self):
for payment_term in self:
payment_term.currency_id = payment_term.company_id.currency_id or self.env.company.currency_id
def _get_amount_due_after_discount(self, total_amount, untaxed_amount):
self.ensure_one()
if self.early_discount:
percentage = self.discount_percentage / 100.0
if self.early_pay_discount_computation in ('excluded', 'mixed'):
discount_amount_currency = (total_amount - untaxed_amount) * percentage
else:
discount_amount_currency = total_amount * percentage
amount_due = self.currency_id.round(total_amount - discount_amount_currency)
if self.env.context.get('active_model') == 'account.move' and (active_id := self.env.context.get('active_id')):
move = self.env['account.move'].browse(active_id)
cash_rounding = move.invoice_cash_rounding_id
currency = move.currency_id
if cash_rounding:
cash_rounding_difference = cash_rounding.compute_difference(currency, amount_due)
if not currency.is_zero(cash_rounding_difference):
amount_due = self.currency_id.round(amount_due + cash_rounding_difference)
return amount_due
return total_amount
@api.depends('company_id')
def _compute_discount_computation(self):
for pay_term in self:
country_code = pay_term.company_id.country_code or self.env.company.country_code
if country_code == 'BE':
pay_term.early_pay_discount_computation = 'mixed'
elif country_code == 'NL':
pay_term.early_pay_discount_computation = 'excluded'
else:
pay_term.early_pay_discount_computation = 'included'
@api.depends('line_ids')
def _compute_example_invalid(self):
for payment_term in self:
payment_term.example_invalid = len(payment_term.line_ids.filtered(lambda l: l.value == 'balance')) != 1
payment_term.example_invalid = not payment_term.line_ids
@api.depends('example_amount', 'example_date', 'line_ids.value', 'line_ids.value_amount',
'line_ids.months', 'line_ids.days', 'line_ids.end_month', 'line_ids.days_after')
@api.depends('currency_id', 'example_amount', 'example_date', 'line_ids.value', 'line_ids.value_amount', 'line_ids.nb_days', 'early_discount', 'discount_percentage', 'discount_days')
def _compute_example_preview(self):
for record in self:
example_preview = ""
record.example_preview_discount = ""
currency = record.currency_id
if record.early_discount:
date = record._get_last_discount_date_formatted(record.example_date or fields.Date.context_today(record))
discount_amount = record._get_amount_due_after_discount(record.example_amount, 0.0)
record.example_preview_discount = _(
"Early Payment Discount: <b>%(amount)s</b> if paid before <b>%(date)s</b>",
amount=formatLang(self.env, discount_amount, currency_obj=currency),
date=date,
)
if not record.example_invalid:
currency = self.env.company.currency_id
terms = record._compute_terms(
date_ref=record.example_date,
date_ref=record.example_date or fields.Date.context_today(record),
currency=currency,
company=self.env.company,
tax_amount=0,
@ -54,60 +119,54 @@ class AccountPaymentTerm(models.Model):
untaxed_amount=record.example_amount,
untaxed_amount_currency=record.example_amount,
sign=1)
for i, info_by_dates in enumerate(record._get_amount_by_date(terms, currency).values()):
for i, info_by_dates in enumerate(record._get_amount_by_date(terms).values()):
date = info_by_dates['date']
discount_date = info_by_dates['discount_date']
amount = info_by_dates['amount']
discount_amount = info_by_dates['discounted_amount'] or 0.0
example_preview += "<div style='margin-left: 20px;'>"
example_preview += "<div>"
example_preview += _(
"<b>%(count)s#</b> Installment of <b>%(amount)s</b> on <b style='color: #704A66;'>%(date)s</b>",
"<b>%(count)s#</b> Installment of <b>%(amount)s</b> due on <b style='color: #704A66;'>%(date)s</b>",
count=i+1,
amount=formatLang(self.env, amount, monetary=True, currency_obj=currency),
amount=formatLang(self.env, amount, currency_obj=currency),
date=date,
)
if discount_date:
example_preview += _(
" (<b>%(amount)s</b> if paid before <b>%(date)s</b>)",
amount=formatLang(self.env, discount_amount, monetary=True, currency_obj=currency),
date=format_date(self.env, terms[i].get('discount_date')),
)
example_preview += "</div>"
record.example_preview = example_preview
@api.model
def _get_amount_by_date(self, terms, currency):
def _get_amount_by_date(self, terms):
"""
Returns a dictionary with the amount for each date of the payment term
(grouped by date, discounted percentage and discount last date,
sorted by date and ignoring null amounts).
"""
terms = sorted(terms, key=lambda t: t.get('date'))
terms_lines = sorted(terms["line_ids"], key=lambda t: t.get('date'))
amount_by_date = {}
for term in terms:
for term in terms_lines:
key = frozendict({
'date': term['date'],
'discount_date': term['discount_date'],
'discount_percentage': term['discount_percentage'],
})
results = amount_by_date.setdefault(key, {
'date': format_date(self.env, term['date']),
'amount': 0.0,
'discounted_amount': 0.0,
'discount_date': format_date(self.env, term['discount_date']),
})
results['amount'] += term['foreign_amount']
results['discounted_amount'] += term['discount_amount_currency']
return amount_by_date
@api.constrains('line_ids')
@api.constrains('line_ids', 'early_discount')
def _check_lines(self):
round_precision = self.env['decimal.precision'].precision_get('Payment Terms')
for terms in self:
if len(terms.line_ids.filtered(lambda r: r.value == 'balance')) != 1:
raise ValidationError(_('The Payment Term must have one Balance line.'))
if terms.line_ids.filtered(lambda r: r.value == 'fixed' and r.discount_percentage):
raise ValidationError(_("You can't mix fixed amount with early payment percentage"))
total_percent = sum(line.value_amount for line in terms.line_ids if line.value == 'percent')
if float_round(total_percent, precision_digits=round_precision) != 100:
raise ValidationError(_('The Payment Term must have at least one percent line and the sum of the percent must be 100%.'))
if len(terms.line_ids) > 1 and terms.early_discount:
raise ValidationError(
_("The Early Payment Discount functionality can only be used with payment terms using a single 100% line. "))
if terms.early_discount and terms.discount_percentage <= 0.0:
raise ValidationError(_("The Early Payment Discount must be strictly positive."))
if terms.early_discount and terms.discount_days <= 0:
raise ValidationError(_("The Early Payment Discount days must be strictly positive."))
def _compute_terms(self, date_ref, currency, company, tax_amount, tax_amount_currency, sign, untaxed_amount, untaxed_amount_currency, cash_rounding=None):
"""Get the distribution of this payment term.
@ -123,162 +182,186 @@ class AccountPaymentTerm(models.Model):
We assume that the input total in move currency (tax_amount_currency + untaxed_amount_currency) is already cash rounded.
The cash rounding does not change the totals: Consider the sum of all the computed payment term amounts in move / company currency.
It is the same as the input total in move / company currency.
:return (list<tuple<datetime.date,tuple<float,float>>>): the amount in the company's currency and
the document's currency, respectively for each required payment date
"""
self.ensure_one()
company_currency = company.currency_id
tax_amount_left = tax_amount
tax_amount_currency_left = tax_amount_currency
untaxed_amount_left = untaxed_amount
untaxed_amount_currency_left = untaxed_amount_currency
total_amount = tax_amount + untaxed_amount
total_amount_currency = tax_amount_currency + untaxed_amount_currency
foreign_rounding_amount = 0
company_rounding_amount = 0
result = []
rate = abs(total_amount_currency / total_amount) if total_amount else 0.0
for line in self.line_ids.sorted(lambda line: line.value == 'balance'):
pay_term = {
'total_amount': total_amount,
'discount_percentage': self.discount_percentage if self.early_discount else 0.0,
'discount_date': date_ref + relativedelta(days=(self.discount_days or 0)) if self.early_discount else False,
'discount_balance': 0,
'line_ids': [],
}
if self.early_discount:
# Early discount is only available on single line, 100% payment terms.
discount_percentage = self.discount_percentage / 100.0
if self.early_pay_discount_computation in ('excluded', 'mixed'):
pay_term['discount_balance'] = company_currency.round(total_amount - untaxed_amount * discount_percentage)
pay_term['discount_amount_currency'] = currency.round(total_amount_currency - untaxed_amount_currency * discount_percentage)
else:
pay_term['discount_balance'] = company_currency.round(total_amount * (1 - discount_percentage))
pay_term['discount_amount_currency'] = currency.round(total_amount_currency * (1 - discount_percentage))
if cash_rounding:
cash_rounding_difference_currency = cash_rounding.compute_difference(currency, pay_term['discount_amount_currency'])
if not currency.is_zero(cash_rounding_difference_currency):
pay_term['discount_amount_currency'] += cash_rounding_difference_currency
pay_term['discount_balance'] = company_currency.round(pay_term['discount_amount_currency'] / rate) if rate else 0.0
residual_amount = total_amount
residual_amount_currency = total_amount_currency
for i, line in enumerate(self.line_ids):
term_vals = {
'date': line._get_due_date(date_ref),
'has_discount': line.discount_percentage,
'discount_date': None,
'discount_amount_currency': 0.0,
'discount_balance': 0.0,
'discount_percentage': line.discount_percentage,
'company_amount': 0,
'foreign_amount': 0,
}
if line.value == 'fixed':
term_vals['company_amount'] = sign * company_currency.round(line.value_amount)
# The last line is always the balance, no matter the type
on_balance_line = i == len(self.line_ids) - 1
if on_balance_line:
term_vals['company_amount'] = residual_amount
term_vals['foreign_amount'] = residual_amount_currency
elif line.value == 'fixed':
# Fixed amounts
term_vals['company_amount'] = sign * company_currency.round(line.value_amount / rate) if rate else 0.0
term_vals['foreign_amount'] = sign * currency.round(line.value_amount)
company_proportion = tax_amount/untaxed_amount if untaxed_amount else 1
foreign_proportion = tax_amount_currency/untaxed_amount_currency if untaxed_amount_currency else 1
line_tax_amount = company_currency.round(line.value_amount * company_proportion) * sign
line_tax_amount_currency = currency.round(line.value_amount * foreign_proportion) * sign
line_untaxed_amount = term_vals['company_amount'] - line_tax_amount
line_untaxed_amount_currency = term_vals['foreign_amount'] - line_tax_amount_currency
elif line.value == 'percent':
term_vals['company_amount'] = company_currency.round(total_amount * (line.value_amount / 100.0))
term_vals['foreign_amount'] = currency.round(total_amount_currency * (line.value_amount / 100.0))
line_tax_amount = company_currency.round(tax_amount * (line.value_amount / 100.0))
line_tax_amount_currency = currency.round(tax_amount_currency * (line.value_amount / 100.0))
line_untaxed_amount = term_vals['company_amount'] - line_tax_amount
line_untaxed_amount_currency = term_vals['foreign_amount'] - line_tax_amount_currency
else:
line_tax_amount = line_tax_amount_currency = line_untaxed_amount = line_untaxed_amount_currency = 0.0
# Percentage amounts
line_amount = company_currency.round(total_amount * (line.value_amount / 100.0))
line_amount_currency = currency.round(total_amount_currency * (line.value_amount / 100.0))
term_vals['company_amount'] = line_amount
term_vals['foreign_amount'] = line_amount_currency
# The following values do not account for any potential cash rounding
tax_amount_left -= line_tax_amount
tax_amount_currency_left -= line_tax_amount_currency
untaxed_amount_left -= line_untaxed_amount
untaxed_amount_currency_left -= line_untaxed_amount_currency
if cash_rounding and line.value in ['fixed', 'percent']:
if cash_rounding and not on_balance_line:
# The value `residual_amount_currency` is always cash rounded (in case of cash rounding).
# * We assume `total_amount_currency` is cash rounded.
# * We only subtract cash rounded amounts.
# Thus the balance line is cash rounded.
cash_rounding_difference_currency = cash_rounding.compute_difference(currency, term_vals['foreign_amount'])
if not currency.is_zero(cash_rounding_difference_currency):
rate = abs(term_vals['foreign_amount'] / term_vals['company_amount']) if term_vals['company_amount'] else 1.0
foreign_rounding_amount += cash_rounding_difference_currency
term_vals['foreign_amount'] += cash_rounding_difference_currency
term_vals['company_amount'] = company_currency.round(term_vals['foreign_amount'] / rate) if rate else 0.0
company_amount = company_currency.round(term_vals['foreign_amount'] / rate)
cash_rounding_difference = company_amount - term_vals['company_amount']
if not currency.is_zero(cash_rounding_difference):
company_rounding_amount += cash_rounding_difference
term_vals['company_amount'] = company_amount
residual_amount -= term_vals['company_amount']
residual_amount_currency -= term_vals['foreign_amount']
pay_term['line_ids'].append(term_vals)
if line.value == 'balance':
# The `*_amount_left` variables do not account for cash rounding.
# Here we remove the total amount added by the cash rounding from the amount left.
# This way the totals in company and move currency remain unchanged (compared to the input).
# We assume the foreign total (`tax_amount_currency + untaxed_amount_currency`) is cash rounded.
# The following right side is the same as subtracting all the (cash rounded) foreign payment term amounts from the foreign total.
# Thus it is the remaining foreign amount and also cash rounded.
term_vals['foreign_amount'] = tax_amount_currency_left + untaxed_amount_currency_left - foreign_rounding_amount
term_vals['company_amount'] = tax_amount_left + untaxed_amount_left - company_rounding_amount
line_tax_amount = tax_amount_left
line_tax_amount_currency = tax_amount_currency_left
line_untaxed_amount = untaxed_amount_left
line_untaxed_amount_currency = untaxed_amount_currency_left
if line.discount_percentage:
if company.early_pay_discount_computation in ('excluded', 'mixed'):
term_vals['discount_balance'] = company_currency.round(term_vals['company_amount'] - line_untaxed_amount * line.discount_percentage / 100.0)
term_vals['discount_amount_currency'] = currency.round(term_vals['foreign_amount'] - line_untaxed_amount_currency * line.discount_percentage / 100.0)
else:
term_vals['discount_balance'] = company_currency.round(term_vals['company_amount'] * (1 - (line.discount_percentage / 100.0)))
term_vals['discount_amount_currency'] = currency.round(term_vals['foreign_amount'] * (1 - (line.discount_percentage / 100.0)))
term_vals['discount_date'] = date_ref + relativedelta(days=line.discount_days)
if cash_rounding and line.discount_percentage:
cash_rounding_difference_currency = cash_rounding.compute_difference(currency, term_vals['discount_amount_currency'])
if not currency.is_zero(cash_rounding_difference_currency):
rate = abs(term_vals['discount_amount_currency'] / term_vals['discount_balance']) if term_vals['discount_balance'] else 1.0
term_vals['discount_amount_currency'] += cash_rounding_difference_currency
term_vals['discount_balance'] = company_currency.round(term_vals['discount_amount_currency'] / rate)
result.append(term_vals)
return result
return pay_term
@api.ondelete(at_uninstall=False)
def _unlink_except_referenced_terms(self):
if self.env['account.move'].search([('invoice_payment_term_id', 'in', self.ids)]):
raise UserError(_('You can not delete payment terms as other records still reference it. However, you can archive it.'))
if self.env['account.move'].search_count([('invoice_payment_term_id', 'in', self.ids)], limit=1):
raise UserError(_("Uh-oh! Those payment terms are quite popular and can't be deleted since there are still some records referencing them. How about archiving them instead?"))
def unlink(self):
for terms in self:
self.env['ir.property'].sudo().search(
[('value_reference', 'in', ['account.payment.term,%s'%payment_term.id for payment_term in terms])]
).unlink()
return super(AccountPaymentTerm, self).unlink()
def _get_last_discount_date(self, date_ref):
self.ensure_one()
if not date_ref:
return None
return date_ref + relativedelta(days=self.discount_days or 0) if self.early_discount else False
def copy(self, default=None):
def _get_last_discount_date_formatted(self, date_ref):
self.ensure_one()
if not date_ref:
return None
return format_date(self.env, self._get_last_discount_date(date_ref))
def copy_data(self, default=None):
default = dict(default or {})
default['name'] = _('%s (copy)', self.name)
return super().copy(default)
vals_list = super().copy_data(default=default)
return [dict(vals, name=_("%s (copy)", line.name)) for line, vals in zip(self, vals_list)]
class AccountPaymentTermLine(models.Model):
_name = "account.payment.term.line"
_name = 'account.payment.term.line'
_description = "Payment Terms Line"
_order = "id"
value = fields.Selection([
('balance', 'Balance'),
('percent', 'Percent'),
('fixed', 'Fixed Amount')
], string='Type', required=True, default='percent',
('fixed', 'Fixed')
], required=True, default='percent',
help="Select here the kind of valuation related to this payment terms line.")
value_amount = fields.Float(string='Value', digits='Payment Terms', help="For percent enter a ratio between 0-100.")
months = fields.Integer(string='Months', required=True, default=0)
days = fields.Integer(string='Days', required=True, default=0)
end_month = fields.Boolean(string='End of month', help="Switch to end of the month after having added months or days")
days_after = fields.Integer(string='Days after End of month', help="Days to add after the end of the month")
discount_percentage = fields.Float(string='Discount %', help='Early Payment Discount granted for this line')
discount_days = fields.Integer(string='Discount Days', help='Number of days before the early payment proposition expires')
value_amount = fields.Float(string='Due', digits='Payment Terms',
help="For percent enter a ratio between 0-100.",
compute='_compute_value_amount', store=True, readonly=False)
delay_type = fields.Selection([
('days_after', 'Days after invoice date'),
('days_after_end_of_month', 'Days after end of month'),
('days_after_end_of_next_month', 'Days after end of next month'),
('days_end_of_month_on_the', 'Days end of month on the'),
], required=True, default='days_after')
display_days_next_month = fields.Boolean(compute='_compute_display_days_next_month')
days_next_month = fields.Char(
string='Days on the next month',
readonly=False,
default='10',
size=2,
)
nb_days = fields.Integer(string='Days', readonly=False, store=True, compute='_compute_days')
payment_id = fields.Many2one('account.payment.term', string='Payment Terms', required=True, index=True, ondelete='cascade')
def _get_due_date(self, date_ref):
self.ensure_one()
due_date = fields.Date.from_string(date_ref) or fields.Date.today()
due_date += relativedelta(months=self.months)
due_date += relativedelta(days=self.days)
if self.end_month:
due_date += relativedelta(day=31)
due_date += relativedelta(days=self.days_after)
return due_date
if self.delay_type == 'days_after_end_of_month':
return date_utils.end_of(due_date, 'month') + relativedelta(days=self.nb_days)
elif self.delay_type == 'days_after_end_of_next_month':
return date_utils.end_of(due_date + relativedelta(months=1), 'month') + relativedelta(days=self.nb_days)
elif self.delay_type == 'days_end_of_month_on_the':
try:
days_next_month = int(self.days_next_month)
except ValueError:
days_next_month = 1
@api.constrains('value', 'value_amount', 'discount_percentage')
if not days_next_month:
return date_utils.end_of(due_date + relativedelta(days=self.nb_days), 'month')
return due_date + relativedelta(days=self.nb_days) + relativedelta(months=1, day=days_next_month)
return due_date + relativedelta(days=self.nb_days)
@api.constrains('days_next_month')
def _check_valid_char_value(self):
for record in self:
if record.days_next_month and record.days_next_month.isnumeric():
if not (0 <= int(record.days_next_month) <= 31):
raise ValidationError(_('The days added must be between 0 and 31.'))
else:
raise ValidationError(_('The days added must be a number and has to be between 0 and 31.'))
@api.depends('delay_type')
def _compute_display_days_next_month(self):
for record in self:
record.display_days_next_month = record.delay_type == 'days_end_of_month_on_the'
@api.constrains('value', 'value_amount')
def _check_percent(self):
for term_line in self:
if term_line.value == 'percent' and (term_line.value_amount < 0.0 or term_line.value_amount > 100.0):
raise ValidationError(_('Percentages on the Payment Terms lines must be between 0 and 100.'))
if term_line.discount_percentage and (term_line.discount_percentage < 0.0 or term_line.discount_percentage > 100.0):
raise ValidationError(_('Discount percentages on the Payment Terms lines must be between 0 and 100.'))
@api.constrains('discount_days')
def _check_positive(self):
for term_line in self:
if term_line.discount_days < 0:
raise ValidationError(_('The discount days of the Payment Terms lines must be positive.'))
@api.depends('payment_id')
def _compute_days(self):
for line in self:
#Line.payment_id.line_ids[-1] is the new line that has been just added when clicking "add a new line"
if not line.nb_days and len(line.payment_id.line_ids) > 1:
line.nb_days = line.payment_id.line_ids[-2].nb_days + 30
else:
line.nb_days = line.nb_days
@api.depends('payment_id')
def _compute_value_amount(self):
for line in self:
if line.value == 'fixed':
line.value_amount = 0
else:
amount = 0
for i in line.payment_id.line_ids.filtered(lambda r: r.value == 'percent'):
amount += i['value_amount']
line.value_amount = 100 - amount

View file

@ -1,12 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
import re
from collections import defaultdict
from odoo import models, fields, api, _, osv, Command
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError, UserError
from odoo.fields import Command, Domain
FIGURE_TYPE_SELECTION_VALUES = [
('monetary', "Monetary"),
@ -15,27 +15,60 @@ FIGURE_TYPE_SELECTION_VALUES = [
('float', "Float"),
('date', "Date"),
('datetime', "Datetime"),
('none', "No Formatting"),
('boolean', 'Boolean'),
('string', 'String'),
]
DOMAIN_REGEX = re.compile(r'(-?sum)\((.*)\)')
CROSS_REPORT_REGEX = re.compile(r'^cross_report\((.+)\)$')
ACCOUNT_CODES_ENGINE_SPLIT_REGEX = re.compile(r"(?=[+-])")
ACCOUNT_CODES_ENGINE_TERM_REGEX = re.compile(
r"^(?P<sign>[+-]?)"
r"(?P<prefix>([A-Za-z\d.]*|tag\([\w.]+\))((?=\\)|(?<=[^CD])))"
r"(\\\((?P<excluded_prefixes>([A-Za-z\d.]+,)*[A-Za-z\d.]*)\))?"
r"(?P<balance_character>[DC]?)$"
)
number_regex = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
report_line_code_regex = r"[+-]?[\s(]*[^().\s*/+\-]+\.[^().\s*/+\-]+"
operator_regex = r"[\s*/+\-]"
hard_formulas = ['sum_children']
AGGREGATION_ENGINE_FORMULA_REGEX = re.compile(
f'{"|".join(hard_formulas)}|'
rf"[\s(]*(?:{number_regex}|{report_line_code_regex})[\s)]*"
rf"(?:{operator_regex}[\s(]*(?:{number_regex}|{report_line_code_regex})[\s)]*)*"
)
class AccountReport(models.Model):
_name = "account.report"
_name = 'account.report'
_description = "Accounting Report"
_order = 'sequence, id'
# CORE ==========================================================================================================================================
name = fields.Char(string="Name", required=True, translate=True)
sequence = fields.Integer(string="Sequence")
active = fields.Boolean(string="Active", default=True)
line_ids = fields.One2many(string="Lines", comodel_name='account.report.line', inverse_name='report_id')
column_ids = fields.One2many(string="Columns", comodel_name='account.report.column', inverse_name='report_id')
root_report_id = fields.Many2one(string="Root Report", comodel_name='account.report', help="The report this report is a variant of.")
root_report_id = fields.Many2one(string="Root Report", comodel_name='account.report', index='btree_not_null', help="The report this report is a variant of.")
variant_report_ids = fields.One2many(string="Variants", comodel_name='account.report', inverse_name='root_report_id')
chart_template_id = fields.Many2one(string="Chart of Accounts", comodel_name='account.chart.template')
section_report_ids = fields.Many2many(string="Sections", comodel_name='account.report', relation="account_report_section_rel", column1="main_report_id", column2="sub_report_id")
section_main_report_ids = fields.Many2many(string="Section Of", comodel_name='account.report', relation="account_report_section_rel", column1="sub_report_id", column2="main_report_id")
use_sections = fields.Boolean(
string="Composite Report",
compute="_compute_use_sections", store=True, readonly=False,
help="Create a structured report with multiple sections for convenient navigation and simultaneous printing.",
)
chart_template = fields.Selection(string="Chart of Accounts", selection=lambda self: self.env['account.chart.template']._select_chart_template())
country_id = fields.Many2one(string="Country", comodel_name='res.country')
only_tax_exigible = fields.Boolean(
string="Only Tax Exigible Lines",
compute=lambda x: x._compute_report_option_filter('only_tax_exigible'), readonly=False, store=True, depends=['root_report_id'],
compute=lambda x: x._compute_report_option_filter('only_tax_exigible'),
precompute=True,
readonly=False, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
availability_condition = fields.Selection(
string="Availability",
@ -44,6 +77,13 @@ class AccountReport(models.Model):
)
load_more_limit = fields.Integer(string="Load More Limit")
search_bar = fields.Boolean(string="Search Bar")
prefix_groups_threshold = fields.Integer(string="Prefix Groups Threshold", default=4000)
integer_rounding = fields.Selection(string="Integer Rounding", selection=[('HALF-UP', "Nearest"), ('UP', "Up"), ('DOWN', "Down")])
allow_foreign_vat = fields.Boolean(
string="Allow Foreign VAT",
compute=lambda x: x._compute_report_option_filter('allow_foreign_vat'),
precompute=True, readonly=False, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
default_opening_date_filter = fields.Selection(
string="Default Opening",
@ -52,12 +92,26 @@ class AccountReport(models.Model):
('this_quarter', "This Quarter"),
('this_month', "This Month"),
('today', "Today"),
('last_month', "Last Month"),
('last_quarter', "Last Quarter"),
('last_year', "Last Year"),
('previous_month', "Last Month"),
('previous_quarter', "Last Quarter"),
('previous_year', "Last Year"),
('this_return_period', "This Return Period"),
('previous_return_period', "Last Return Period"),
],
compute=lambda x: x._compute_report_option_filter('default_opening_date_filter', 'last_month'),
readonly=False, store=True, depends=['root_report_id'],
compute=lambda x: x._compute_report_option_filter('default_opening_date_filter', 'previous_month'),
precompute=True,
readonly=False, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
currency_translation = fields.Selection(
string="Currency Translation",
selection=[
('current', "Use the most recent rate at the date of the report"),
('cta', "Use CTA"),
],
compute=lambda x: x._compute_report_option_filter('currency_translation', 'cta'),
precompute=True,
readonly=False, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
# FILTERS =======================================================================================================================================
@ -65,65 +119,101 @@ class AccountReport(models.Model):
filter_multi_company = fields.Selection(
string="Multi-Company",
selection=[('disabled', "Disabled"), ('selector', "Use Company Selector"), ('tax_units', "Use Tax Units")],
compute=lambda x: x._compute_report_option_filter('filter_multi_company', 'disabled'), readonly=False, store=True, depends=['root_report_id'],
selection=[('selector', "Use Company Selector"), ('tax_units', "Use Tax Units")],
compute=lambda x: x._compute_report_option_filter('filter_multi_company', 'selector'), readonly=False,
precompute=True, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
filter_date_range = fields.Boolean(
string="Date Range",
compute=lambda x: x._compute_report_option_filter('filter_date_range', True), readonly=False, store=True, depends=['root_report_id'],
compute=lambda x: x._compute_report_option_filter('filter_date_range', True),
precompute=True, readonly=False, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
filter_show_draft = fields.Boolean(
string="Draft Entries",
compute=lambda x: x._compute_report_option_filter('filter_show_draft', True), readonly=False, store=True, depends=['root_report_id'],
compute=lambda x: x._compute_report_option_filter('filter_show_draft', True),
precompute=True, readonly=False, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
filter_unreconciled = fields.Boolean(
string="Unreconciled Entries",
compute=lambda x: x._compute_report_option_filter('filter_unreconciled', False), readonly=False, store=True, depends=['root_report_id'],
compute=lambda x: x._compute_report_option_filter('filter_unreconciled', False),
precompute=True, readonly=False, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
filter_unfold_all = fields.Boolean(
string="Unfold All",
compute=lambda x: x._compute_report_option_filter('filter_unfold_all'), readonly=False, store=True, depends=['root_report_id'],
compute=lambda x: x._compute_report_option_filter('filter_unfold_all'),
precompute=True, readonly=False, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
filter_hide_0_lines = fields.Selection(
string="Hide lines at 0",
selection=[('by_default', "Enabled by Default"), ('optional', "Optional"), ('never', "Never")],
compute=lambda x: x._compute_report_option_filter('filter_hide_0_lines', 'optional'),
precompute=True, readonly=False, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
filter_period_comparison = fields.Boolean(
string="Period Comparison",
compute=lambda x: x._compute_report_option_filter('filter_period_comparison', True), readonly=False, store=True, depends=['root_report_id'],
compute=lambda x: x._compute_report_option_filter('filter_period_comparison', True),
precompute=True, readonly=False, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
filter_growth_comparison = fields.Boolean(
string="Growth Comparison",
compute=lambda x: x._compute_report_option_filter('filter_growth_comparison', True), readonly=False, store=True, depends=['root_report_id'],
compute=lambda x: x._compute_report_option_filter('filter_growth_comparison', True),
precompute=True, readonly=False, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
filter_journals = fields.Boolean(
string="Journals",
compute=lambda x: x._compute_report_option_filter('filter_journals'), readonly=False, store=True, depends=['root_report_id'],
compute=lambda x: x._compute_report_option_filter('filter_journals'), readonly=False,
precompute=True, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
filter_analytic = fields.Boolean(
string="Analytic Filter",
compute=lambda x: x._compute_report_option_filter('filter_analytic'), readonly=False, store=True, depends=['root_report_id'],
compute=lambda x: x._compute_report_option_filter('filter_analytic'), readonly=False,
precompute=True, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
filter_hierarchy = fields.Selection(
string="Account Groups",
selection=[('by_default', "Enabled by Default"), ('optional', "Optional"), ('never', "Never")],
compute=lambda x: x._compute_report_option_filter('filter_hierarchy', 'optional'), readonly=False, store=True, depends=['root_report_id'],
compute=lambda x: x._compute_report_option_filter('filter_hierarchy', 'optional'), readonly=False,
precompute=True, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
filter_account_type = fields.Boolean(
filter_account_type = fields.Selection(
string="Account Types",
compute=lambda x: x._compute_report_option_filter('filter_account_type'), readonly=False, store=True, depends=['root_report_id'],
selection=[('both', "Payable and receivable"), ('payable', "Payable"), ('receivable', "Receivable"), ('disabled', 'Disabled')],
compute=lambda x: x._compute_report_option_filter('filter_account_type', 'disabled'), readonly=False,
precompute=True, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
filter_partner = fields.Boolean(
string="Partners",
compute=lambda x: x._compute_report_option_filter('filter_partner'), readonly=False, store=True, depends=['root_report_id'],
compute=lambda x: x._compute_report_option_filter('filter_partner'), readonly=False,
precompute=True, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
filter_fiscal_position = fields.Boolean(
string="Filter Multivat",
compute=lambda x: x._compute_report_option_filter('filter_fiscal_position'), readonly=False, store=True, depends=['root_report_id'],
filter_aml_ir_filters = fields.Boolean(
string="Favorite Filters", help="If activated, user-defined filters on journal items can be selected on this report",
compute=lambda x: x._compute_report_option_filter('filter_aml_ir_filters'), readonly=False,
precompute=True, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
filter_budgets = fields.Boolean(
string="Budgets",
compute=lambda x: x._compute_report_option_filter('filter_budgets'), readonly=False,
precompute=True, store=True, depends=['root_report_id', 'section_main_report_ids'],
)
def _compute_report_option_filter(self, field_name, default_value=False):
# We don't depend on the different filter fields on the root report, as we don't want a manual change on it to be reflected on all the reports
# using it as their root (would create confusion). The root report filters are only used as some kind of default values.
for report in self:
# When a report is a section, it can also get its default filter values from its parent composite report. This only happens when we're sure
# the report is not used as a section of multiple reports, nor as a standalone report.
for report in self.sorted(lambda x: not x.section_report_ids):
# Reports are sorted in order to first treat the composite reports, in case they need to compute their filters a the same time
# as their sections
is_accessible = self.env['ir.actions.client'].search_count([('context', 'ilike', f"'report_id': {report.id}"), ('tag', '=', 'account_report')])
is_variant = bool(report.root_report_id)
if (is_accessible or is_variant) and report.section_main_report_ids:
continue # prevent updating the filters of a report when being added as a section of a report
if report.root_report_id:
report[field_name] = report.root_report_id[field_name]
elif len(report.section_main_report_ids) == 1 and not is_accessible:
report[field_name] = report.section_main_report_ids[field_name]
else:
report[field_name] = default_value
@ -135,6 +225,11 @@ class AccountReport(models.Model):
elif not report.availability_condition:
report.availability_condition = 'always'
@api.depends('section_report_ids')
def _compute_use_sections(self):
for report in self:
report.use_sections = bool(report.section_report_ids)
@api.constrains('root_report_id')
def _validate_root_report_id(self):
for report in self:
@ -144,13 +239,19 @@ class AccountReport(models.Model):
@api.constrains('line_ids')
def _validate_parent_sequence(self):
previous_lines = self.env['account.report.line']
for line in self.line_ids:
for line in self.line_ids.sorted('sequence'):
if line.parent_id and line.parent_id not in previous_lines:
raise ValidationError(
_('Line "%s" defines line "%s" as its parent, but appears before it in the report. '
'The parent must always come first.', line.name, line.parent_id.name))
_('Line "%(line)s" defines line "%(parent_line)s" as its parent, but appears before it in the report. '
'The parent must always come first.', line=line.name, parent_line=line.parent_id.name))
previous_lines |= line
@api.constrains('section_report_ids')
def _validate_section_report_ids(self):
for record in self:
if any(section.section_report_ids for section in record.section_report_ids):
raise ValidationError(_("The sections defined on a report cannot have sections themselves."))
@api.constrains('availability_condition', 'country_id')
def _validate_availability_condition(self):
for record in self:
@ -186,33 +287,38 @@ class AccountReport(models.Model):
return super().write(vals)
def copy_data(self, default=None):
vals_list = super().copy_data(default=default)
return [dict(vals, name=report._get_copied_name()) for report, vals in zip(self, vals_list)]
def copy(self, default=None):
'''Copy the whole financial report hierarchy by duplicating each line recursively.
:param default: Default values.
:return: The copied account.report record.
'''
self.ensure_one()
if default is None:
default = {}
default['name'] = self._get_copied_name()
copied_report = super().copy(default=default)
code_mapping = {}
for line in self.line_ids.filtered(lambda x: not x.parent_id):
line._copy_hierarchy(copied_report, code_mapping=code_mapping)
new_reports = super().copy(default=default)
for old_report, new_report in zip(self, new_reports):
code_mapping = {}
for line in old_report.line_ids.filtered(lambda x: not x.parent_id):
line._copy_hierarchy(new_report, code_mapping=code_mapping)
# Replace line codes by their copy in aggregation formulas
for expression in copied_report.line_ids.expression_ids:
if expression.engine == 'aggregation':
copied_formula = f" {expression.formula} " # Add spaces so that the lookahead/lookbehind of the regex can work (we can't do a | in those)
for old_code, new_code in code_mapping.items():
copied_formula = re.sub(f"(?<=\\W){old_code}(?=\\W)", new_code, copied_formula)
expression.formula = copied_formula.strip() # Remove the spaces introduced for lookahead/lookbehind
# Replace line codes by their copy in aggregation formulas
for expression in new_report.line_ids.expression_ids:
if expression.engine == 'aggregation':
copied_formula = f" {expression.formula} " # Add spaces so that the lookahead/lookbehind of the regex can work (we can't do a | in those)
for old_code, new_code in code_mapping.items():
copied_formula = re.sub(f"(?<=\\W){old_code}(?=\\W)", new_code, copied_formula)
expression.formula = copied_formula.strip() # Remove the spaces introduced for lookahead/lookbehind
# Repeat the same logic for the subformula, if it is set.
if expression.subformula:
copied_subformula = f" {expression.subformula} "
for old_code, new_code in code_mapping.items():
copied_subformula = re.sub(f"(?<=\\W){old_code}(?=\\W)", new_code, copied_subformula)
expression.subformula = copied_subformula.strip()
for column in self.column_ids:
column.copy({'report_id': copied_report.id})
return copied_report
old_report.column_ids.copy({'report_id': new_report.id})
return new_reports
@api.ondelete(at_uninstall=False)
def _unlink_if_no_variant(self):
@ -232,15 +338,16 @@ class AccountReport(models.Model):
return name
@api.depends('name', 'country_id')
def name_get(self):
result = []
def _compute_display_name(self):
for report in self:
result.append((report.id, report.name + (f' ({report.country_id.code})' if report.country_id else '')))
return result
if report.name:
report.display_name = report.name + (f' ({report.country_id.code})' if report.country_id else '')
else:
report.display_name = False
class AccountReportLine(models.Model):
_name = "account.report.line"
_name = 'account.report.line'
_description = "Accounting Report Line"
_order = 'sequence, id'
@ -255,6 +362,7 @@ class AccountReportLine(models.Model):
required=True,
recursive=True,
precompute=True,
index=True,
ondelete='cascade'
)
hierarchy_level = fields.Integer(
@ -266,9 +374,14 @@ class AccountReportLine(models.Model):
required=True,
precompute=True,
)
parent_id = fields.Many2one(string="Parent Line", comodel_name='account.report.line', ondelete='set null')
parent_id = fields.Many2one(string="Parent Line", comodel_name='account.report.line', ondelete='set null', index='btree_not_null')
children_ids = fields.One2many(string="Child Lines", comodel_name='account.report.line', inverse_name='parent_id')
groupby = fields.Char(string="Group By", help="Comma-separated list of fields from account.move.line (Journal Item). When set, this line will generate sublines grouped by those keys.")
user_groupby = fields.Char(
string="User Group By",
compute='_compute_user_groupby', store=True, readonly=False, precompute=True,
help="Comma-separated list of fields from account.move.line (Journal Item). When set, this line will generate sublines grouped by those keys.",
)
sequence = fields.Integer(string="Sequence")
code = fields.Char(string="Code", help="Unique identifier for this line.")
foldable = fields.Boolean(string="Foldable", help="By default, we always unfold the lines that can be. If this is checked, the line won't be unfolded by default, and a folding button will be displayed.")
@ -278,17 +391,21 @@ class AccountReportLine(models.Model):
domain_formula = fields.Char(string="Domain Formula Shortcut", help="Internal field to shorten expression_ids creation for the domain engine", inverse='_inverse_domain_formula', store=False)
account_codes_formula = fields.Char(string="Account Codes Formula Shortcut", help="Internal field to shorten expression_ids creation for the account_codes engine", inverse='_inverse_account_codes_formula', store=False)
aggregation_formula = fields.Char(string="Aggregation Formula Shortcut", help="Internal field to shorten expression_ids creation for the aggregation engine", inverse='_inverse_aggregation_formula', store=False)
external_formula = fields.Char(string="External Formula Shortcut", help="Internal field to shorten expression_ids creation for the external engine", inverse='_inverse_external_formula', store=False)
horizontal_split_side = fields.Selection(string="Horizontal Split Side", selection=[('left', "Left"), ('right', "Right")], compute='_compute_horizontal_split_side', readonly=False, store=True, recursive=True)
tax_tags_formula = fields.Char(string="Tax Tags Formula Shortcut", help="Internal field to shorten expression_ids creation for the tax_tags engine", inverse='_inverse_aggregation_tax_formula', store=False)
_sql_constraints = [
('code_uniq', 'unique (code)', "A report line with the same code already exists."),
]
_code_uniq = models.Constraint(
'unique (report_id, code)',
'A report line with the same code already exists.',
)
@api.depends('parent_id.hierarchy_level')
def _compute_hierarchy_level(self):
for report_line in self:
if report_line.parent_id:
report_line.hierarchy_level = report_line.parent_id.hierarchy_level + 2
increase_level = 3 if report_line.parent_id.hierarchy_level == 0 else 2
report_line.hierarchy_level = report_line.parent_id.hierarchy_level + increase_level
else:
report_line.hierarchy_level = 1
@ -298,20 +415,31 @@ class AccountReportLine(models.Model):
if report_line.parent_id:
report_line.report_id = report_line.parent_id.report_id
@api.depends('parent_id.horizontal_split_side')
def _compute_horizontal_split_side(self):
for report_line in self:
if report_line.parent_id:
report_line.horizontal_split_side = report_line.parent_id.horizontal_split_side
@api.depends('groupby', 'expression_ids.engine')
def _compute_user_groupby(self):
for report_line in self:
if not report_line.id and not report_line.user_groupby:
report_line.user_groupby = report_line.groupby
try:
report_line._validate_groupby()
except UserError:
report_line.user_groupby = report_line.groupby
@api.constrains('parent_id')
def _validate_groupby_no_child(self):
for report_line in self:
if report_line.parent_id.groupby:
if report_line.parent_id.groupby or report_line.parent_id.user_groupby:
raise ValidationError(_("A line cannot have both children and a groupby value (line '%s').", report_line.parent_id.name))
@api.constrains('expression_ids', 'groupby')
def _validate_formula(self):
for expression in self.expression_ids:
if expression.engine == 'aggregation' and expression.report_line_id.groupby:
raise ValidationError(_(
"Groupby feature isn't supported by aggregation engine. Please remove the groupby value on '%s'",
expression.report_line_id.display_name,
))
@api.constrains('groupby', 'user_groupby')
def _validate_groupby(self):
self.expression_ids._validate_engine()
@api.constrains('parent_id')
def _check_parent_line(self):
@ -374,6 +502,9 @@ class AccountReportLine(models.Model):
def _inverse_account_codes_formula(self):
self._create_report_expression(engine='account_codes')
def _inverse_external_formula(self):
self._create_report_expression(engine='external')
def _create_report_expression(self, engine):
# create account.report.expression for each report line based on the formula provided to each
# engine-related field. This makes xmls a bit shorter
@ -388,6 +519,12 @@ class AccountReportLine(models.Model):
subformula, formula = None, report_line.account_codes_formula
elif engine == 'aggregation' and report_line.aggregation_formula:
subformula, formula = None, report_line.aggregation_formula
elif engine == 'external' and report_line.external_formula:
subformula, formula = 'editable', 'most_recent'
if report_line.external_formula == 'percentage':
subformula = 'editable;rounding=0'
elif report_line.external_formula == 'monetary':
formula = 'sum'
elif engine == 'tax_tags' and report_line.tax_tags_formula:
subformula, formula = None, report_line.tax_tags_formula
else:
@ -404,6 +541,9 @@ class AccountReportLine(models.Model):
'formula': formula.lstrip(' \t\n'), # Avoid IndentationError in evals
'subformula': subformula
}
if engine == 'external' and report_line.external_formula:
vals['figure_type'] = report_line.external_formula
if report_line.expression_ids:
# expressions already exists, update the first expression with the right engine
# since syntactic sugar aren't meant to be used with multiple expressions
@ -437,13 +577,13 @@ class AccountReportLine(models.Model):
class AccountReportExpression(models.Model):
_name = "account.report.expression"
_name = 'account.report.expression'
_description = "Accounting Report Expression"
_rec_name = 'report_line_name'
report_line_id = fields.Many2one(string="Report Line", comodel_name='account.report.line', required=True, ondelete='cascade')
report_line_id = fields.Many2one(string="Report Line", comodel_name='account.report.line', required=True, index=True, ondelete='cascade')
report_line_name = fields.Char(string="Report Line Name", related="report_line_id.name")
label = fields.Char(string="Label", required=True)
label = fields.Char(string="Label", required=True, copy=True)
engine = fields.Selection(
string="Computation Engine",
selection=[
@ -465,9 +605,8 @@ class AccountReportExpression(models.Model):
('from_fiscalyear', 'From the start of the fiscal year'),
('to_beginning_of_fiscalyear', 'At the beginning of the fiscal year'),
('to_beginning_of_period', 'At the beginning of the period'),
('normal', 'According to each type of account'),
('strict_range', 'Strictly on the given dates'),
('previous_tax_period', "From previous tax period")
('previous_return_period', "From previous return period")
],
required=True,
default='strict_range',
@ -481,19 +620,53 @@ class AccountReportExpression(models.Model):
carryover_target = fields.Char(
string="Carry Over To",
help="Formula in the form line_code.expression_label. This allows setting the target of the carryover for this expression "
"(on a _carryover_*-labeled expression), in case it is different from the parent line. 'custom' is also allowed as value"
" in case the carryover destination requires more complex logic."
"(on a _carryover_*-labeled expression), in case it is different from the parent line."
)
_domain_engine_subformula_required = models.Constraint(
"CHECK(engine != 'domain' OR subformula IS NOT NULL)",
"Expressions using 'domain' engine should all have a subformula.",
)
_line_label_uniq = models.Constraint(
'UNIQUE(report_line_id,label)',
'The expression label must be unique per report line.',
)
@api.constrains('carryover_target', 'label')
def _check_carryover_target(self):
for expression in self:
if expression.carryover_target and not expression.label.startswith('_carryover_'):
raise UserError(_("You cannot use the field carryover_target in an expression that does not have the label starting with _carryover_"))
elif expression.carryover_target and not expression.carryover_target.split('.')[1].startswith('_applied_carryover_'):
raise UserError(_("When targeting an expression for carryover, the label of that expression must start with _applied_carryover_"))
@api.constrains('formula')
def _check_domain_formula(self):
for expression in self.filtered(lambda expr: expr.engine == 'domain'):
def _check_formula(self):
def raise_formula_error(expression):
raise ValidationError(self.env._("Invalid formula for expression '%(label)s' of line '%(line)s': %(formula)s",
label=expression.label, line=expression.report_line_name,
formula=expression.formula))
expressions_by_engine = self.grouped('engine')
for expression in expressions_by_engine.get('domain', []):
try:
domain = ast.literal_eval(expression.formula)
self.env['account.move.line']._where_calc(domain)
self.env['account.move.line']._search(domain)
except:
raise UserError(_("Invalid domain for expression '%s' of line '%s': %s",
expression.label, expression.report_line_name, expression.formula))
raise_formula_error(expression)
for expression in expressions_by_engine.get('account_codes', []):
for token in ACCOUNT_CODES_ENGINE_SPLIT_REGEX.split(expression.formula.replace(' ', '')):
if token: # e.g. if the first character of the formula is "-", the first token is ''
token_match = ACCOUNT_CODES_ENGINE_TERM_REGEX.match(token)
prefix = token_match and token_match['prefix']
if not prefix:
raise_formula_error(expression)
for expression in expressions_by_engine.get('aggregation', []):
if not AGGREGATION_ENGINE_FORMULA_REGEX.fullmatch(expression.formula):
raise_formula_error(expression)
@api.depends('engine')
def _compute_auditable(self):
@ -501,6 +674,17 @@ class AccountReportExpression(models.Model):
for expression in self:
expression.auditable = expression.engine in auditable_engines
@api.constrains('engine', 'report_line_id')
def _validate_engine(self):
for expression in self:
if expression.engine in ('aggregation', 'external') and (expression.report_line_id.groupby or expression.report_line_id.user_groupby):
engine_description = dict(expression._fields['engine']._description_selection(self.env))
raise ValidationError(_(
"Groupby feature isn't supported by '%(engine)s' engine. Please remove the groupby value on '%(report_line)s'",
engine=engine_description[expression.engine],
report_line=expression.report_line_id.display_name
))
def _get_auditable_engines(self):
return {'tax_tags', 'domain', 'account_codes', 'external', 'aggregation'}
@ -509,10 +693,9 @@ class AccountReportExpression(models.Model):
vals['formula'] = re.sub(r'\s+', ' ', vals['formula'].strip())
def _create_tax_tags(self, tag_name, country):
existing_tags = self.env['account.account.tag']._get_tax_tags(tag_name, country.id)
if len(existing_tags) < 2:
# We can have only one tag in case we archived it and deleted its unused complement sign
tag_vals = self._get_tags_create_vals(tag_name, country.id, existing_tag=existing_tags)
existing_tag = self.env['account.account.tag']._get_tax_tags(tag_name, country.id)
if not existing_tag:
tag_vals = self._get_tags_create_vals(tag_name, country.id, existing_tag=existing_tag)
self.env['account.account.tag'].create(tag_vals)
@api.model_create_multi
@ -552,7 +735,8 @@ class AccountReportExpression(models.Model):
self.env['account.account.tag'].create(tags_create_vals)
if 'formula' not in vals:
# In case the engine is changed we don't propagate any change to the tags themselves
if 'formula' not in vals or (vals.get('engine') and vals['engine'] != 'tax_tags'):
return super().write(vals)
former_formulas_by_country = defaultdict(lambda: [])
@ -560,7 +744,6 @@ class AccountReportExpression(models.Model):
former_formulas_by_country[expr.report_line_id.report_id.country_id].append(expr.formula)
result = super().write(vals)
for country, former_formulas_list in former_formulas_by_country.items():
for former_formula in former_formulas_list:
new_tax_tags = self.env['account.account.tag']._get_tax_tags(vals['formula'], country.id)
@ -571,13 +754,7 @@ class AccountReportExpression(models.Model):
if former_tax_tags and all(tag_expr in self for tag_expr in former_tax_tags._get_related_tax_report_expressions()):
# If we're changing the formula of all the expressions using that tag, rename the tag
positive_tags, negative_tags = former_tax_tags.sorted(lambda x: x.tax_negate)
if self.pool['account.tax'].name.translate:
positive_tags._update_field_translations('name', {'en_US': f"+{vals['formula']}"})
negative_tags._update_field_translations('name', {'en_US': f"-{vals['formula']}"})
else:
positive_tags.name = f"+{vals['formula']}"
negative_tags.name = f"-{vals['formula']}"
former_tax_tags._update_field_translations('name', {'en_US': vals['formula']})
else:
# Else, create a new tag. Its the compute functions will make sure it is properly linked to the expressions
tag_vals = self.env['account.report.expression']._get_tags_create_vals(vals['formula'], country.id)
@ -597,8 +774,8 @@ class AccountReportExpression(models.Model):
for tag in expressions_tags:
other_expression_using_tag = self.env['account.report.expression'].sudo().search([
('engine', '=', 'tax_tags'),
('formula', '=', tag.with_context(lang='en_US').name[1:]), # we escape the +/- sign
('report_line_id.report_id.country_id.id', '=', tag.country_id.id),
('formula', '=', tag.with_context(lang='en_US').name),
('report_line_id.report_id.country_id', '=', tag.country_id.id),
('id', 'not in', self.ids),
], limit=1)
if not other_expression_using_tag:
@ -614,8 +791,11 @@ class AccountReportExpression(models.Model):
tags_to_archive.active = False
tags_to_unlink.unlink()
def name_get(self):
return [(expr.id, f'{expr.report_line_name} [{expr.label}]') for expr in self]
@api.depends('report_line_name', 'label')
def _compute_display_name(self):
for expr in self:
expr.display_name = f'{expr.report_line_name} [{expr.label}]'
def _expand_aggregations(self):
"""Return self and its full aggregation expression dependency"""
@ -632,8 +812,37 @@ class AccountReportExpression(models.Model):
else:
labels_by_code = candidate_expr._get_aggregation_terms_details()
cross_report_domain = []
if candidate_expr.subformula != 'cross_report':
if candidate_expr.subformula and candidate_expr.subformula.startswith('cross_report'):
subformula_match = CROSS_REPORT_REGEX.match(candidate_expr.subformula)
if not subformula_match:
raise UserError(_(
"In report '%(report_name)s', on line '%(line_name)s', with label '%(label)s',\n"
"The format of the cross report expression is invalid. \n"
"Expected: cross_report(<report_id>|<xml_id>)"
"Example: cross_report(my_module.my_report) or cross_report(123)",
report_name=candidate_expr.report_line_id.report_id.display_name,
line_name=candidate_expr.report_line_name,
label=candidate_expr.label,
))
cross_report_value = subformula_match.groups()[0]
try:
report_id = int(cross_report_value)
except ValueError:
report_id = report.id if (report := self.env.ref(cross_report_value, raise_if_not_found=False)) else None
if not report_id:
raise UserError(_(
"In report '%(report_name)s', on line '%(line_name)s', with label '%(label)s',\n"
"Failed to parse the cross report id or xml_id.\n",
report_name=candidate_expr.report_line_id.report_id.display_name,
line_name=candidate_expr.report_line_name,
label=candidate_expr.label,
))
elif report_id == candidate_expr.report_line_id.report_id.id:
raise UserError(_("You cannot use cross report on itself"))
cross_report_domain = [('report_line_id.report_id', '=', report_id)]
else:
cross_report_domain = [('report_line_id.report_id', '=', candidate_expr.report_line_id.report_id.id)]
for line_code, expr_labels in labels_by_code.items():
@ -641,7 +850,7 @@ class AccountReportExpression(models.Model):
domains.append(dependency_domain)
if domains:
sub_expressions |= self.env['account.report.expression'].search(osv.expression.OR(domains))
sub_expressions |= self.env['account.report.expression'].search(Domain.OR(domains))
to_expand = sub_expressions.filtered(lambda x: x.engine == 'aggregation' and x not in result)
result |= sub_expressions
@ -673,7 +882,7 @@ class AccountReportExpression(models.Model):
return totals_by_code
def _get_matching_tags(self, sign=None):
def _get_matching_tags(self):
""" Returns all the signed account.account.tags records whose name matches any of the formulas of the tax_tags expressions contained in self.
"""
tag_expressions = self.filtered(lambda x: x.engine == 'tax_tags')
@ -683,34 +892,20 @@ class AccountReportExpression(models.Model):
or_domains = []
for tag_expression in tag_expressions:
country = tag_expression.report_line_id.report_id.country_id
or_domains.append(self.env['account.account.tag']._get_tax_tags_domain(tag_expression.formula, country.id, sign))
or_domains.append(self.env['account.account.tag']._get_tax_tags_domain(tag_expression.formula, country.id))
return self.env['account.account.tag'].with_context(active_test=False, lang='en_US').search(osv.expression.OR(or_domains))
return self.env['account.account.tag'].with_context(active_test=False, lang='en_US').search(Domain.OR(or_domains))
@api.model
def _get_tags_create_vals(self, tag_name, country_id, existing_tag=None):
"""
We create the plus and minus tags with tag_name.
In case there is an existing_tag (which can happen if we deleted its unused complement sign)
we only recreate the missing sign.
"""
minus_tag_vals = {
'name': '-' + tag_name,
tag_vals = {
'name': tag_name.lstrip('-'),
'applicability': 'taxes',
'tax_negate': True,
'country_id': country_id,
}
plus_tag_vals = {
'name': '+' + tag_name,
'applicability': 'taxes',
'tax_negate': False,
'country_id': country_id,
}
res = []
if not existing_tag or not existing_tag.tax_negate:
res.append(minus_tag_vals)
if not existing_tag or existing_tag.tax_negate:
res.append(plus_tag_vals)
if not existing_tag:
res.append(tag_vals)
return res
def _get_carryover_target_expression(self, options):
@ -735,28 +930,29 @@ class AccountReportExpression(models.Model):
class AccountReportColumn(models.Model):
_name = "account.report.column"
_name = 'account.report.column'
_description = "Accounting Report Column"
_order = 'sequence, id'
name = fields.Char(string="Name", translate=True, required=True)
expression_label = fields.Char(string="Expression Label", required=True)
sequence = fields.Integer(string="Sequence")
report_id = fields.Many2one(string="Report", comodel_name='account.report')
report_id = fields.Many2one(string="Report", comodel_name='account.report', index='btree_not_null')
sortable = fields.Boolean(string="Sortable")
figure_type = fields.Selection(string="Figure Type", selection=FIGURE_TYPE_SELECTION_VALUES, default="monetary", required=True)
blank_if_zero = fields.Boolean(string="Blank if Zero", default=True, help="When checked, 0 values will not show in this column.")
blank_if_zero = fields.Boolean(string="Blank if Zero", help="When checked, 0 values will not show in this column.")
custom_audit_action_id = fields.Many2one(string="Custom Audit Action", comodel_name="ir.actions.act_window")
class AccountReportExternalValue(models.Model):
_name = "account.report.external.value"
_name = 'account.report.external.value'
_description = 'Accounting Report External Value'
_check_company_auto = True
_order = 'date, id'
name = fields.Char(required=True)
value = fields.Float(required=True)
value = fields.Float(string="Numeric Value")
text_value = fields.Char(string="Text Value")
date = fields.Date(required=True)
target_report_expression_id = fields.Many2one(string="Target Expression", comodel_name="account.report.expression", required=True, ondelete="cascade")
@ -766,20 +962,6 @@ class AccountReportExternalValue(models.Model):
company_id = fields.Many2one(string='Company', comodel_name='res.company', required=True, default=lambda self: self.env.company)
foreign_vat_fiscal_position_id = fields.Many2one(
string="Fiscal position",
comodel_name='account.fiscal.position',
domain="[('company_id', '=', company_id), ('country_id', '=', report_country_id), ('foreign_vat', '!=', False)]",
check_company=True,
help="The foreign fiscal position for which this external value is made.",
)
# Carryover fields
carryover_origin_expression_label = fields.Char(string="Origin Expression Label")
carryover_origin_report_line_id = fields.Many2one(string="Origin Line", comodel_name='account.report.line')
@api.constrains('foreign_vat_fiscal_position_id', 'target_report_expression_id')
def _check_fiscal_position(self):
for record in self:
if record.foreign_vat_fiscal_position_id and record.foreign_vat_fiscal_position_id.country_id != record.report_country_id:
raise ValidationError(_("The country set on the foreign VAT fiscal position must match the one set on the report."))

View file

@ -0,0 +1,39 @@
from itertools import accumulate
from odoo import api, fields, models
from odoo.exceptions import UserError
from odoo.tools import Query
class AccountRoot(models.Model):
_name = 'account.root'
_description = 'Account codes first 2 digits'
_auto = False
_table_query = '0'
name = fields.Char(compute='_compute_root')
parent_id = fields.Many2one('account.root', compute='_compute_root')
@api.private
def browse(self, ids=()):
if isinstance(ids, str):
ids = (ids,)
return super().browse(ids)
def _search(self, domain, offset=0, limit=None, order=None, **kw) -> Query:
match list(domain):
case [('id', 'in', ids)]:
return self.browse(sorted(ids))._as_query()
case [('id', 'parent_of', ids)]:
return self.browse(sorted({s for _id in ids for s in accumulate(_id)}))._as_query()
raise UserError(self.env._("Filter on the Account or its Display Name instead"))
@api.model
def _from_account_code(self, code):
return self.browse(code and code[:2])
def _compute_root(self):
for root in self:
root.name = root.id
root.parent_id = self.browse(root.id[:-1] if len(root.id) > 1 else False)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,10 @@
from odoo import models
from odoo import api, models
class DecimalPrecision(models.Model):
_inherit = 'decimal.precision'
@api.model
def precision_get(self, application):
stackmap = self.env.cr.cache.get('account_disable_recursion_stack', {})
if application == 'Discount' and stackmap.get('ignore_discount_precision'):

View file

@ -5,7 +5,7 @@ from odoo import fields, models, _
from odoo.exceptions import AccessError
class Digest(models.Model):
class DigestDigest(models.Model):
_inherit = 'digest.digest'
kpi_account_total_revenue = fields.Boolean('Revenue')
@ -14,21 +14,26 @@ class Digest(models.Model):
def _compute_kpi_account_total_revenue_value(self):
if not self.env.user.has_group('account.group_account_invoice'):
raise AccessError(_("Do not have access, skip this data for user's digest email"))
start, end, companies = self._get_kpi_compute_parameters()
total_per_companies = dict(self.env['account.move.line'].sudo()._read_group(
groupby=['company_id'],
aggregates=['balance:sum'],
domain=[
('company_id', 'in', companies.ids),
('date', '>', start),
('date', '<=', end),
('account_id.internal_group', '=', 'income'),
('parent_state', '=', 'posted'),
],
))
for record in self:
start, end, company = record._get_kpi_compute_parameters()
self._cr.execute('''
SELECT -SUM(line.balance)
FROM account_move_line line
JOIN account_move move ON move.id = line.move_id
JOIN account_account account ON account.id = line.account_id
WHERE line.company_id = %s AND line.date > %s::DATE AND line.date <= %s::DATE
AND account.internal_group = 'income'
AND move.state = 'posted'
''', [company.id, start, end])
query_res = self._cr.fetchone()
record.kpi_account_total_revenue_value = query_res and query_res[0] or 0.0
company = record.company_id or self.env.company
record.kpi_account_total_revenue_value = -total_per_companies.get(company, 0)
def _compute_kpis_actions(self, company, user):
res = super(Digest, self)._compute_kpis_actions(company, user)
res['kpi_account_total_revenue'] = 'account.action_move_out_invoice_type&menu_id=%s' % self.env.ref('account.menu_finance').id
res = super()._compute_kpis_actions(company, user)
res['kpi_account_total_revenue'] = 'account.action_move_out_invoice_type?menu_id=%s' % self.env.ref('account.menu_finance').id
return res

View file

@ -1,17 +1,8 @@
# -*- coding: utf-8 -*-
from collections import OrderedDict
from zlib import error as zlib_error
try:
from PyPDF2.errors import PdfReadError
except ImportError:
from PyPDF2.utils import PdfReadError
try:
from PyPDF2.errors import DependencyError
except ImportError:
DependencyError = NotImplementedError
from odoo import api, models, _
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.tools import pdf
@ -19,6 +10,11 @@ from odoo.tools import pdf
class IrActionsReport(models.Model):
_inherit = 'ir.actions.report'
is_invoice_report = fields.Boolean(
string="Invoice report",
copy=True,
)
def _render_qweb_pdf_prepare_streams(self, report_ref, data, res_ids=None):
# Custom behavior for 'account.report_original_vendor_bill'.
if self._get_report(report_ref).report_name != 'account.report_original_vendor_bill':
@ -31,14 +27,14 @@ class IrActionsReport(models.Model):
collected_streams = OrderedDict()
for invoice in invoices:
attachment = invoice.message_main_attachment_id
attachment = self._prepare_local_attachments(invoice.message_main_attachment_id)
if attachment:
stream = pdf.to_pdf_stream(attachment)
if stream:
record = self.env[attachment.res_model].browse(attachment.res_id)
try:
stream = pdf.add_banner(stream, record.name, logo=True)
except (ValueError, PdfReadError, TypeError, zlib_error, NotImplementedError, DependencyError, ArithmeticError):
stream = pdf.add_banner(stream, record.name or '', logo=True)
except (ValueError, pdf.PdfReadError, TypeError, zlib_error, NotImplementedError, pdf.DependencyError, ArithmeticError):
record._message_log(body=_(
"There was an error when trying to add the banner to the original PDF.\n"
"Please make sure the source file is valid."
@ -50,9 +46,21 @@ class IrActionsReport(models.Model):
return collected_streams
def _is_invoice_report(self, report_ref):
return self._get_report(report_ref).report_name in ('account.report_invoice_with_payments', 'account.report_invoice')
report = self._get_report(report_ref)
return (report.is_invoice_report and report.model == 'account.move') or report.report_name == 'account.report_invoice'
def _render_qweb_pdf(self, report_ref, res_ids=None, data=None):
def _get_splitted_report(self, report_ref, content, report_type):
if report_type == 'html':
report = self._get_report(report_ref)
bodies, res_ids, *_unused = self._prepare_html(content, report_model=report.model)
return {res_id: str(body).encode() for res_id, body in zip(res_ids, bodies)}
elif report_type == 'pdf':
pdf_dict = {res_id: stream['stream'].getvalue() for res_id, stream in content.items()}
for stream in content.values():
stream['stream'].close()
return pdf_dict
def _pre_render_qweb_pdf(self, report_ref, res_ids=None, data=None):
# Check for reports only available for invoices.
# + append context data with the display_name_in_footer parameter
if self._is_invoice_report(report_ref):
@ -63,7 +71,7 @@ class IrActionsReport(models.Model):
if any(x.move_type == 'entry' for x in invoices):
raise UserError(_("Only invoices could be printed."))
return super()._render_qweb_pdf(report_ref, res_ids=res_ids, data=data)
return super()._pre_render_qweb_pdf(report_ref, res_ids=res_ids, data=data)
@api.ondelete(at_uninstall=False)
def _unlink_except_master_tags(self):
@ -80,3 +88,9 @@ class IrActionsReport(models.Model):
master_report = self.env.ref(f"account.{master_xmlid}", raise_if_not_found=False)
if master_report and master_report in self:
raise UserError(_("You cannot delete this report (%s), it is used by the accounting PDF generation engine.", master_report.name))
def _get_rendering_context(self, report, docids, data):
data = super()._get_rendering_context(report, docids, data)
if self.env.context.get('proforma_invoice'):
data['proforma'] = True
return data

View file

@ -1,15 +1,90 @@
# -*- coding: utf-8 -*-
from odoo import models, api
from odoo import api, models, fields, _
from odoo.exceptions import UserError
from odoo.tools.mimetypes import guess_mimetype
from odoo.tools.misc import format_date
import io
import zipfile
class IrAttachment(models.Model):
_inherit = 'ir.attachment'
# -------------------------------------------------------------------------
# XSD validation
# -------------------------------------------------------------------------
def _build_zip_from_attachments(self):
""" Return the zip bytes content resulting from compressing the attachments in `self`"""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w', compression=zipfile.ZIP_DEFLATED) as zipfile_obj:
for attachment in self:
zipfile_obj.writestr(attachment.display_name, attachment.raw)
return buffer.getvalue()
@api.model
def action_download_xsd_files(self):
# To be extended by localisations, where they can download their necessary XSD files
# Note: they should always return super().action_download_xsd_files()
return
@api.ondelete(at_uninstall=True)
def _except_audit_trail(self):
audit_trail_attachments = self.filtered(lambda attachment:
attachment.res_model == 'account.move'
and attachment.res_id
and attachment.raw
and attachment.company_id.restrictive_audit_trail
and guess_mimetype(attachment.raw) in (
'application/pdf',
'application/xml',
)
)
id2move = self.env['account.move'].browse(set(audit_trail_attachments.mapped('res_id'))).exists().grouped('id')
for attachment in audit_trail_attachments:
move = id2move.get(attachment.res_id)
if move and move.posted_before and move.company_id.restrictive_audit_trail:
ue = UserError(_("You cannot remove parts of a restricted audit trail."))
ue._audit_trail = True
raise ue
def write(self, vals):
if vals.keys() & {'res_id', 'res_model', 'raw', 'datas', 'store_fname', 'db_datas', 'company_id'}:
try:
self._except_audit_trail()
except UserError as e:
if (
not hasattr(e, '_audit_trail')
or vals.get('res_model') != 'documents.document'
or vals.keys() & {'raw', 'datas', 'store_fname', 'db_datas'}
):
raise # do not raise if trying to version the attachment through a document
vals.pop('res_model', None)
vals.pop('res_id', None)
return super().write(vals)
def unlink(self):
invoice_pdf_attachments = self.filtered(lambda attachment:
attachment.res_model == 'account.move'
and attachment.res_id
and attachment.res_field in ('invoice_pdf_report_file', 'ubl_cii_xml_file')
and attachment.company_id.restrictive_audit_trail
)
if invoice_pdf_attachments:
# only detach the document from the field, but keep it in the database for the audit trail
# it shouldn't be an issue as there aren't any security group on the fields as it is the public report
invoice_pdf_attachments.res_field = False
today = format_date(self.env, fields.Date.context_today(self))
for attachment in invoice_pdf_attachments:
attachment_name = attachment.name
attachment_extension = ''
dot_index = attachment_name.rfind('.')
if dot_index > 0:
attachment_name = attachment.name[:dot_index]
attachment_extension = attachment.name[dot_index:]
attachment.name = _(
'%(attachment_name)s (detached by %(user)s on %(date)s)%(attachment_extension)s',
attachment_name=attachment_name,
attachment_extension=attachment_extension,
user=self.env.user.name,
date=today,
)
return super(IrAttachment, self - invoice_pdf_attachments).unlink()
def _post_add_create(self, **kwargs):
for move_id, attachments in self.filtered(lambda attachment: attachment.res_model == 'account.move').grouped('res_id').items():
move = self.env['account.move'].browse(move_id)
files_data = move._to_files_data(attachments)
files_data.extend(move._unwrap_attachments(files_data))
move._extend_with_attachments(files_data)
super()._post_add_create(**kwargs)

View file

@ -0,0 +1,11 @@
from odoo import api, models
class IrHttp(models.AbstractModel):
_inherit = 'ir.http'
@api.model
def lazy_session_info(self):
res = super().lazy_session_info()
res['show_sale_receipts'] = self.env['ir.config_parameter'].sudo().get_param('account.show_sale_receipts')
return res

View file

@ -0,0 +1,115 @@
from importlib import import_module
from inspect import getmembers, ismodule, isclass, isfunction
from odoo import api, models, fields
from odoo.tools.misc import get_flag
def templ(env, code, name=None, country='', **kwargs):
country_code = country or code.split('_')[0] if country is not None else None
country = country_code and env.ref(f"base.{country_code}", raise_if_not_found=False)
country_name = f"{get_flag(country.code)} {country.name}" if country else ''
return {
'name': country_name and (f"{country_name} - {name}" if name else country_name) or name,
'country_id': country and country.id,
'country_code': country and country.code,
**kwargs,
}
template_module = lambda m: ismodule(m) and m.__name__.split('.')[-1].startswith('template_')
template_class = isclass
template_function = lambda f: isfunction(f) and hasattr(f, '_l10n_template') and f._l10n_template[1] == 'template_data'
class IrModuleModule(models.Model):
_inherit = "ir.module.module"
account_templates = fields.Binary(compute='_compute_account_templates', exportable=False)
@api.depends('state')
def _compute_account_templates(self):
chart_category = self.env.ref('base.module_category_accounting_localizations_account_charts')
ChartTemplate = self.env['account.chart.template']
for module in self:
templates = {}
if module.category_id == chart_category or module.name == 'account':
try:
python_module = import_module(f"odoo.addons.{module.name}.models")
except ModuleNotFoundError:
templates = {}
else:
templates = {
fct._l10n_template[0]: {
'name': template_values.get('name'),
'parent': template_values.get('parent'),
'sequence': template_values.get('sequence', 1),
'country': template_values.get('country', ''),
'visible': template_values.get('visible', True),
'installed': module.state == "installed",
'module': module.name,
}
for _name, mdl in getmembers(python_module, template_module)
for _name, cls in getmembers(mdl, template_class)
for _name, fct in getmembers(cls, template_function)
if (template_values := fct(ChartTemplate))
}
module.account_templates = {
code: templ(self.env, code, **vals)
for code, vals in sorted(templates.items(), key=lambda kv: kv[1]['sequence'])
}
def write(self, vals):
# Instanciate the first template of the module on the current company upon installing the module
was_installed = len(self) == 1 and self.state in ('installed', 'to upgrade', 'to remove')
res = super().write(vals)
is_installed = len(self) == 1 and self.state == 'installed'
if (
not was_installed and is_installed
and not self.env.company.chart_template
and self.account_templates
and (guessed := next((
tname
for tname, tvals in self.account_templates.items()
if (self.env.company.country_id.id and tvals['country_id'] == self.env.company.country_id.id)
or tname == 'generic_coa'
), None))
):
def try_loading(env):
env['account.chart.template'].try_loading(
guessed,
env.company,
)
self.env.registry._auto_install_template = try_loading
return res
def _load_module_terms(self, modules, langs, overwrite=False):
super()._load_module_terms(modules, langs, overwrite=overwrite)
if 'account' in modules:
def load_account_translations(env):
env['account.chart.template']._load_translations(langs=langs)
env['account.account.tag']._translate_tax_tags(langs=langs)
if self.env.registry.loaded:
load_account_translations(self.env)
else:
self.env.registry._delayed_account_translator = load_account_translations
def _register_hook(self):
super()._register_hook()
if hasattr(self.env.registry, '_delayed_account_translator'):
self.env.registry._delayed_account_translator(self.env)
del self.env.registry._delayed_account_translator
if hasattr(self.env.registry, '_auto_install_template'):
self.env.registry._auto_install_template(self.env)
del self.env.registry._auto_install_template
def module_uninstall(self):
unlinked_templates = [code for template in self.mapped('account_templates') for code in template]
if unlinked_templates:
companies = self.env['res.company'].search([
('chart_template', 'in', unlinked_templates),
])
companies.chart_template = False
companies.flush_recordset()
return super().module_uninstall()

View file

@ -0,0 +1,41 @@
from odoo import api, fields, models
class KpiProvider(models.AbstractModel):
_inherit = 'kpi.provider'
@api.model
def get_account_kpi_summary(self):
grouped_moves_to_report = self.env['account.move']._read_group(
fields.Domain.OR([
[('state', '=', 'draft')],
[('state', '=', 'posted'), ('checked', '=', False)],
[('state', '=', 'posted'), ('journal_id.type', '=', 'bank'), ('statement_line_id.is_reconciled', '=', False)],
]),
['journal_id'],
['journal_id:count'],
)
FieldsSelection = self.env['ir.model.fields.selection'].with_context(lang=self.env.user.lang)
journal_type_names = {x.value: x.name for x in FieldsSelection.search([
('field_id.model', '=', 'account.journal'),
('field_id.name', '=', 'type'),
])}
count_by_type = {}
for journal_id, count in grouped_moves_to_report:
journal_type = journal_id.type
count_by_type[journal_type] = count_by_type.get(journal_type, 0) + count
return [{
'id': f'account_journal_type.{journal_type}',
'name': journal_type_names[journal_type],
'type': 'integer',
'value': count,
} for journal_type, count in count_by_type.items()]
@api.model
def get_kpi_summary(self):
result = super().get_kpi_summary()
result.extend(self.get_account_kpi_summary())
return result

View file

@ -0,0 +1,199 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.exceptions import UserError
from odoo.fields import Domain
def _subselect_domain(model, field_name, domain):
query = model._search(Domain(field_name, '!=', False) & domain, active_test=False, bypass_access=True)
return Domain('id', 'in', query.subselect(model._field_to_sql(query.table, field_name, query)))
bypass_token = object()
DOMAINS = {
'res.company':
lambda rec, operator, value: _subselect_domain(rec.env['account.move.line'], 'company_id',
Domain('company_id.restrictive_audit_trail', operator, value)
),
'account.move':
lambda rec, operator, value: [('company_id.restrictive_audit_trail', operator, value)],
'account.account':
lambda rec, operator, value: [('used', operator, value), ('company_ids.restrictive_audit_trail', operator, value)],
'account.tax':
lambda rec, operator, value: _subselect_domain(rec.env['account.move.line'], 'tax_line_id',
Domain('company_id.restrictive_audit_trail', operator, value),
),
'res.partner':
lambda rec, operator, value: _subselect_domain(rec.env['account.move.line'], 'partner_id',
Domain('company_id.restrictive_audit_trail', operator, value),
),
}
class MailMessage(models.Model):
_inherit = 'mail.message'
account_audit_log_preview = fields.Text(
string="Description",
compute="_compute_account_audit_log_preview",
search="_search_account_audit_log_preview",
)
account_audit_log_move_id = fields.Many2one(
comodel_name='account.move',
string="Journal Entry",
compute="_compute_account_audit_log_move_id",
search="_search_account_audit_log_move_id",
)
account_audit_log_partner_id = fields.Many2one(
comodel_name='res.partner',
string="Partner",
compute="_compute_account_audit_log_partner_id",
search="_search_account_audit_log_partner_id",
)
account_audit_log_account_id = fields.Many2one(
comodel_name='account.account',
string="Account",
compute="_compute_account_audit_log_account_id",
search="_search_account_audit_log_account_id",
)
account_audit_log_tax_id = fields.Many2one(
comodel_name='account.tax',
string="Tax",
compute="_compute_account_audit_log_tax_id",
search="_search_account_audit_log_tax_id",
)
account_audit_log_company_id = fields.Many2one(
comodel_name='res.company',
string="Company ",
compute="_compute_account_audit_log_company_id",
search="_search_account_audit_log_company_id",
)
account_audit_log_restricted = fields.Boolean(
string="Protected by restricted Audit Logs",
compute="_compute_account_audit_log_restricted",
search="_search_account_audit_log_restricted",
)
@api.depends('tracking_value_ids')
def _compute_account_audit_log_preview(self):
audit_messages = self.filtered(lambda m: m.message_type == 'notification')
(self - audit_messages).account_audit_log_preview = False
for message in audit_messages:
title = message.subject or message.preview
tracking_value_ids = message.sudo().tracking_value_ids._filter_has_field_access(self.env)
if not title and tracking_value_ids:
title = self.env._("Updated")
if not title and message.subtype_id and not message.subtype_id.internal:
title = message.subtype_id.display_name
audit_log_preview = (title or '') + '\n'
audit_log_preview += "\n".join(
"%(old_value)s%(new_value)s (%(field)s)" % {
'old_value': fmt_vals['oldValue'],
'new_value': fmt_vals['newValue'],
'field': fmt_vals['fieldInfo']['changedField'],
}
for fmt_vals in tracking_value_ids._tracking_value_format()
)
message.account_audit_log_preview = audit_log_preview
def _search_account_audit_log_preview(self, operator, value):
if operator not in ['=', 'like', '=like', 'ilike'] or not isinstance(value, str):
return NotImplemented
return Domain('message_type', '=', 'notification') & Domain.OR([
[('tracking_value_ids.old_value_char', operator, value)],
[('tracking_value_ids.new_value_char', operator, value)],
[('tracking_value_ids.old_value_text', operator, value)],
[('tracking_value_ids.new_value_text', operator, value)],
])
def _compute_account_audit_log_move_id(self):
self._compute_audit_log_related_record_id('account.move', 'account_audit_log_move_id')
def _search_account_audit_log_move_id(self, operator, value):
return self._search_audit_log_related_record_id('account.move', operator, value)
def _compute_account_audit_log_account_id(self):
self._compute_audit_log_related_record_id('account.account', 'account_audit_log_account_id')
def _search_account_audit_log_account_id(self, operator, value):
return self._search_audit_log_related_record_id('account.account', operator, value)
def _compute_account_audit_log_tax_id(self):
self._compute_audit_log_related_record_id('account.tax', 'account_audit_log_tax_id')
def _search_account_audit_log_tax_id(self, operator, value):
return self._search_audit_log_related_record_id('account.tax', operator, value)
def _compute_account_audit_log_company_id(self):
self._compute_audit_log_related_record_id('res.company', 'account_audit_log_company_id')
def _search_account_audit_log_company_id(self, operator, value):
return self._search_audit_log_related_record_id('res.company', operator, value)
def _compute_account_audit_log_partner_id(self):
self._compute_audit_log_related_record_id('res.partner', 'account_audit_log_partner_id')
def _search_account_audit_log_partner_id(self, operator, value):
return self._search_audit_log_related_record_id('res.partner', operator, value)
def _compute_account_audit_log_restricted(self):
self.account_audit_log_restricted = False
if potentially_restricted := self.filtered(lambda r: r.model in DOMAINS):
restricted = self.search(Domain('id', 'in', potentially_restricted.ids) + self._search_account_audit_log_restricted('in', [True]))
restricted.account_audit_log_restricted = True
def _search_account_audit_log_restricted(self, operator, value):
if operator not in ('in', 'not in'):
return NotImplemented
return Domain('message_type', '=', 'notification') & Domain.OR(
[('model', '=', model), ('res_id', 'in', self.env[model]._search(domain_factory(self, operator, value)))]
for model, domain_factory in DOMAINS.items()
)
def _compute_audit_log_related_record_id(self, model, fname):
messages_of_related = self.filtered(lambda m: m.model == model and m.res_id)
(self - messages_of_related)[fname] = False
for message in messages_of_related:
message[fname] = message.res_id
def _search_audit_log_related_record_id(self, model, operator, value):
if (
operator in ('like', 'ilike', 'not ilike', 'not like') and isinstance(value, str)
) or (
operator in ('in', 'not in') and any(isinstance(v, str) for v in value)
):
res_id_domain = [('res_id', 'in', self.env[model]._search([('display_name', operator, value)]))]
elif operator in ('any', 'not any', 'any!', 'not any!'):
if isinstance(value, Domain):
query = self.env[model]._search(value)
else:
query = value
res_id_domain = [('res_id', 'in' if operator in ('any', 'any!') else 'not in', query)]
elif operator in ('in', 'not in'):
res_id_domain = [('res_id', operator, value)]
else:
return NotImplemented
return [('model', '=', model)] + res_id_domain
@api.ondelete(at_uninstall=False)
def _except_audit_log(self):
if self.env.context.get('bypass_audit') is bypass_token:
return
for message in self:
if message.account_audit_log_move_id and not message.account_audit_log_move_id.posted_before:
continue
if message.account_audit_log_restricted:
raise UserError(self.env._("You cannot remove parts of a restricted audit trail. Archive the record instead."))
def write(self, vals):
# We allow any whitespace modifications in the subject
normalized_subject = ' '.join(vals['subject'].split()) if vals.get('subject') else None
if (
vals.keys() & {'res_id', 'res_model', 'message_type', 'subtype_id'}
or ('subject' in vals and any(' '.join(s.subject.split()) != normalized_subject for s in self if s.subject))
or ('body' in vals and any(self.mapped('body')))
):
self._except_audit_log()
return super().write(vals)

View file

@ -0,0 +1,16 @@
from odoo import _, api, models
from odoo.exceptions import UserError
class MailTemplate(models.Model):
_inherit = 'mail.template'
@api.ondelete(at_uninstall=False)
def _unlink_except_master_mail_template(self):
master_xmlids = {
"account.email_template_edi_invoice",
"account.email_template_edi_credit_note",
}
removed_xml_ids = set(self.get_external_id().values())
if removed_xml_ids.intersection(master_xmlids):
raise UserError(_("You cannot delete this mail template, it is used in the invoice sending flow."))

View file

@ -1,34 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class MailThread(models.AbstractModel):
_inherit = 'mail.thread'
def _message_post_process_attachments(self, attachments, attachment_ids, message_values):
""" This method extension ensures that, when using the "Send & Print" feature, if the user
adds an attachment, the latter will be linked to the record.
# Task-2792146: will move to model-based method
"""
record = self.env.context.get('attached_to')
# link mail.compose.message attachments to attached_to
if record and record._name in ('account.move', 'account.payment'):
message_values['model'] = record._name
message_values['res_id'] = record.id
res = super()._message_post_process_attachments(attachments, attachment_ids, message_values)
# link account.invoice.send attachments to attached_to
model = message_values['model']
res_id = message_values['res_id']
att_ids = [att[1] for att in res.get('attachment_ids') or []]
if att_ids and model == 'account.move':
filtered_attachment_ids = self.env['ir.attachment'].sudo().browse(att_ids).filtered(
lambda a: a.res_model in ('account.invoice.send',) and a.create_uid.id == self._uid)
if filtered_attachment_ids:
filtered_attachment_ids.write({'res_model': model, 'res_id': res_id})
elif model == 'account.payment':
attachments_to_link = self.env['ir.attachment'].sudo().browse(att_ids).filtered(
lambda a: a.res_model in ('mail.message',) and a.create_uid.id == self._uid)
attachments_to_link.write({'res_model': model, 'res_id': res_id})
return res

View file

@ -0,0 +1,15 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class MailTrackingValue(models.Model):
_inherit = 'mail.tracking.value'
@api.ondelete(at_uninstall=True)
def _except_audit_log(self):
self.mail_message_id._except_audit_log()
def write(self, vals):
self._except_audit_log()
return super().write(vals)

View file

@ -0,0 +1,9 @@
from odoo import models
from .mail_message import bypass_token
class BasePartnerMergeAutomaticWizard(models.TransientModel):
_inherit = 'base.partner.merge.automatic.wizard'
def _update_reference_fields(self, src_partners, dst_partner):
return super(BasePartnerMergeAutomaticWizard, self.with_context(bypass_audit=bypass_token))._update_reference_fields(src_partners, dst_partner)

View file

@ -0,0 +1,29 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class OnboardingOnboarding(models.Model):
_inherit = 'onboarding.onboarding'
# Invoice Onboarding
@api.model
def action_close_panel_account_invoice(self):
self.action_close_panel('account.onboarding_onboarding_account_invoice')
def _prepare_rendering_values(self):
"""Compute existence of invoices for company."""
self.ensure_one()
if self == self.env.ref('account.onboarding_onboarding_account_invoice', raise_if_not_found=False):
step = self.env.ref('account.onboarding_onboarding_step_create_invoice', raise_if_not_found=False)
if step and step.current_step_state == 'not_done':
if self.env['account.move'].search_count(
[('company_id', '=', self.env.company.id), ('move_type', '=', 'out_invoice')], limit=1
):
step.action_set_just_done()
return super()._prepare_rendering_values()
# Dashboard Onboarding
@api.model
def action_close_panel_account_dashboard(self):
self.action_close_panel('account.onboarding_onboarding_account_dashboard')

View file

@ -0,0 +1,120 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, models
class OnboardingOnboardingStep(models.Model):
_inherit = 'onboarding.onboarding.step'
# COMMON STEPS
@api.model
def action_open_step_company_data(self):
"""Set company's basic information."""
company = self.env['account.journal'].browse(self.env.context.get('journal_id', None)).company_id or self.env.company
action = {
'type': 'ir.actions.act_window',
'name': _('Set your company data'),
'res_model': 'res.company',
'res_id': company.id,
'views': [(self.env.ref('account.res_company_form_view_onboarding').id, "form")],
'target': 'new',
}
return action
@api.model
def action_open_step_base_document_layout(self):
view_id = self.env.ref('web.view_base_document_layout').id
return {
'name': _('Configure your document layout'),
'type': 'ir.actions.act_window',
'res_model': 'base.document.layout',
'target': 'new',
'views': [(view_id, 'form')],
'context': {"dialog_size": "extra-large"},
}
@api.model
def action_validate_step_base_document_layout(self):
"""Set the onboarding(s) step as done only if layout is set."""
step = self.env.ref('account.onboarding_onboarding_step_base_document_layout', raise_if_not_found=False)
if not step or not self.env.company.external_report_layout_id:
return False
return self.action_validate_step('account.onboarding_onboarding_step_base_document_layout')
# INVOICE ONBOARDING
@api.model
def action_open_step_bank_account(self):
return self.env.company.setting_init_bank_account_action()
@api.model
def action_open_step_create_invoice(self):
return {
'type': 'ir.actions.act_window',
'name': _('Create first invoice'),
'views': [(self.env.ref("account.view_move_form").id, 'form')],
'res_model': 'account.move',
'context': {'default_move_type': 'out_invoice'},
}
# DASHBOARD ONBOARDING
@api.model
def action_open_step_fiscal_year(self):
company = self.env['account.journal'].browse(self.env.context.get('journal_id', None)).company_id or self.env.company
new_wizard = self.env['account.financial.year.op'].create({'company_id': company.id})
view_id = self.env.ref('account.setup_financial_year_opening_form').id
return {
'type': 'ir.actions.act_window',
'name': _('Accounting Periods'),
'view_mode': 'form',
'res_model': 'account.financial.year.op',
'target': 'new',
'res_id': new_wizard.id,
'views': [[view_id, 'form']],
'context': {
'dialog_size': 'medium',
}
}
@api.model
def action_open_step_chart_of_accounts(self):
""" Called by the 'Chart of Accounts' button of the dashboard onboarding panel."""
company = self.env['account.journal'].browse(self.env.context.get('journal_id', None)).company_id or self.env.company
self.sudo().with_company(company).action_validate_step('account.onboarding_onboarding_step_chart_of_accounts')
# If an opening move has already been posted, we open the list view showing all the accounts
if company.opening_move_posted():
return 'account.action_account_form'
# Then, we open will open a custom list view allowing to edit opening balances of the account
view_id = self.env.ref('account.init_accounts_tree').id
# Hide the current year earnings account as it is automatically computed
domain = [
*self.env['account.account']._check_company_domain(company),
('account_type', '!=', 'equity_unaffected'),
]
return {
'type': 'ir.actions.act_window',
'name': _('Chart of Accounts'),
'res_model': 'account.account',
'view_mode': 'list',
'limit': 99999999,
'search_view_id': [self.env.ref('account.view_account_search').id],
'views': [[view_id, 'list'], [False, 'form']],
'domain': domain,
}
# STEPS WITHOUT PANEL
@api.model
def action_open_step_sales_tax(self):
view_id = self.env.ref('account.res_company_form_view_onboarding_sale_tax').id
return {
'type': 'ir.actions.act_window',
'name': _('Sales tax'),
'res_id': self.env.company.id,
'res_model': 'res.company',
'target': 'new',
'view_mode': 'form',
'views': [[view_id, 'form']],
}

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,12 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo import api, fields, models, _, Command
from odoo.exceptions import ValidationError
from odoo.fields import Domain
from odoo.tools import format_amount
from odoo.tools.misc import split_every
ACCOUNT_DOMAIN = "[('account_type', 'not in', ('asset_receivable','liability_payable','asset_cash','liability_credit_card','off_balance'))]"
ACCOUNT_DOMAIN = "['&', '&', '&', ('deprecated', '=', False), ('account_type', 'not in', ('asset_receivable','liability_payable','asset_cash','liability_credit_card')), ('company_id', '=', current_company_id), ('is_off_balance', '=', False)]"
class ProductCategory(models.Model):
_inherit = "product.category"
@ -12,28 +14,44 @@ class ProductCategory(models.Model):
property_account_income_categ_id = fields.Many2one('account.account', company_dependent=True,
string="Income Account",
domain=ACCOUNT_DOMAIN,
help="This account will be used when validating a customer invoice.")
help="This account will be used when validating a customer invoice.",
tracking=True,
ondelete='restrict',
)
property_account_expense_categ_id = fields.Many2one('account.account', company_dependent=True,
string="Expense Account",
domain=ACCOUNT_DOMAIN,
help="The expense is accounted for when a vendor bill is validated, except in anglo-saxon accounting with perpetual inventory valuation in which case the expense (Cost of Goods Sold account) is recognized at the customer invoice validation.")
help="The expense is accounted for when a vendor bill is validated, except in anglo-saxon accounting with perpetual inventory valuation in which case the expense (Cost of Goods Sold account) is recognized at the customer invoice validation.",
tracking=True,
ondelete='restrict',
)
#----------------------------------------------------------
# Products
#----------------------------------------------------------
class ProductTemplate(models.Model):
_inherit = "product.template"
taxes_id = fields.Many2many('account.tax', 'product_taxes_rel', 'prod_id', 'tax_id', help="Default taxes used when selling the product.", string='Customer Taxes',
domain=[('type_tax_use', '=', 'sale')], default=lambda self: self.env.company.account_sale_tax_id)
taxes_id = fields.Many2many('account.tax', 'product_taxes_rel', 'prod_id', 'tax_id',
string="Sales Taxes",
help="Default taxes used when selling the product",
domain=[('type_tax_use', '=', 'sale')],
default=lambda self: self.env.companies.account_sale_tax_id or self.env.companies.root_id.sudo().account_sale_tax_id,
)
tax_string = fields.Char(compute='_compute_tax_string')
supplier_taxes_id = fields.Many2many('account.tax', 'product_supplier_taxes_rel', 'prod_id', 'tax_id', string='Vendor Taxes', help='Default taxes used when buying the product.',
domain=[('type_tax_use', '=', 'purchase')], default=lambda self: self.env.company.account_purchase_tax_id)
property_account_income_id = fields.Many2one('account.account', company_dependent=True,
supplier_taxes_id = fields.Many2many('account.tax', 'product_supplier_taxes_rel', 'prod_id', 'tax_id',
string="Purchase Taxes",
help="Default taxes used when buying the product",
domain=[('type_tax_use', '=', 'purchase')],
default=lambda self: self.env.companies.account_purchase_tax_id or self.env.companies.root_id.sudo().account_purchase_tax_id,
)
property_account_income_id = fields.Many2one('account.account', company_dependent=True, ondelete='restrict',
string="Income Account",
domain=ACCOUNT_DOMAIN,
help="Keep this field empty to use the default value from the product category.")
property_account_expense_id = fields.Many2one('account.account', company_dependent=True,
property_account_expense_id = fields.Many2one('account.account', company_dependent=True, ondelete='restrict',
string="Expense Account",
domain=ACCOUNT_DOMAIN,
help="Keep this field empty to use the default value from the product category. If anglo-saxon accounting with automated valuation method is configured, the expense account on the product category will be used.")
@ -42,24 +60,46 @@ class ProductTemplate(models.Model):
comodel_name='account.account.tag',
domain="[('applicability', '=', 'products')]",
help="Tags to be set on the base and tax journal items created for this product.")
fiscal_country_codes = fields.Char(compute='_compute_fiscal_country_codes')
def _get_product_accounts(self):
return {
'income': self.property_account_income_id or self.categ_id.property_account_income_categ_id,
'expense': self.property_account_expense_id or self.categ_id.property_account_expense_categ_id
'income': (
self.property_account_income_id
or self._get_category_account('property_account_income_categ_id')
or (self.company_id or self.env.company).income_account_id
), 'expense': (
self.property_account_expense_id
or self._get_category_account('property_account_expense_categ_id')
or (self.company_id or self.env.company).expense_account_id
),
}
def _get_asset_accounts(self):
res = {}
res['stock_input'] = False
res['stock_output'] = False
return res
def _get_category_account(self, field_name):
"""
Return the first account defined on the product category hierarchy
for the given field.
"""
categ = self.categ_id
while categ:
account = categ[field_name]
if account:
return account
categ = categ.parent_id
return self.env['account.account']
def get_product_accounts(self, fiscal_pos=None):
accounts = self._get_product_accounts()
if not fiscal_pos:
fiscal_pos = self.env['account.fiscal.position']
return fiscal_pos.map_accounts(accounts)
return {
key: (fiscal_pos or self.env['account.fiscal.position']).map_account(account)
for key, account in self._get_product_accounts().items()
}
@api.depends('company_id')
@api.depends_context('allowed_company_ids')
def _compute_fiscal_country_codes(self):
for record in self:
allowed_companies = record.company_id or self.env.companies
record.fiscal_country_codes = ",".join(allowed_companies.mapped('account_fiscal_country_id.code'))
@api.depends('taxes_id', 'list_price')
@api.depends_context('company')
@ -69,16 +109,16 @@ class ProductTemplate(models.Model):
def _construct_tax_string(self, price):
currency = self.currency_id
res = self.taxes_id.filtered(lambda t: t.company_id == self.env.company).compute_all(
res = self.taxes_id._filter_taxes_by_company(self.env.company).compute_all(
price, product=self, partner=self.env['res.partner']
)
joined = []
included = res['total_included']
if currency.compare_amounts(included, price):
joined.append(_('%s Incl. Taxes', format_amount(self.env, included, currency)))
joined.append(_('%(amount)s Incl. Taxes', amount=format_amount(self.env, included, currency)))
excluded = res['total_excluded']
if currency.compare_amounts(excluded, price):
joined.append(_('%s Excl. Taxes', format_amount(self.env, excluded, currency)))
joined.append(_('%(amount)s Excl. Taxes', amount=format_amount(self.env, excluded, currency)))
if joined:
tax_string = f"(= {', '.join(joined)})"
else:
@ -88,26 +128,82 @@ class ProductTemplate(models.Model):
@api.constrains('uom_id')
def _check_uom_not_in_invoice(self):
self.env['product.template'].flush_model(['uom_id'])
self._cr.execute("""
self.env.cr.execute("""
SELECT prod_template.id
FROM account_move_line line
JOIN product_product prod_variant ON line.product_id = prod_variant.id
JOIN product_template prod_template ON prod_variant.product_tmpl_id = prod_template.id
JOIN uom_uom template_uom ON prod_template.uom_id = template_uom.id
JOIN uom_category template_uom_cat ON template_uom.category_id = template_uom_cat.id
JOIN uom_uom line_uom ON line.product_uom_id = line_uom.id
JOIN uom_category line_uom_cat ON line_uom.category_id = line_uom_cat.id
WHERE prod_template.id IN %s
AND line.parent_state = 'posted'
AND template_uom_cat.id != line_uom_cat.id
AND template_uom.id != line_uom.id
LIMIT 1
""", [tuple(self.ids)])
if self._cr.fetchall():
if self.env.cr.fetchall():
raise ValidationError(_(
"This product is already being used in posted Journal Entries.\n"
"If you want to change its Unit of Measure, please archive this product and create a new one."
))
@api.onchange('type')
def _onchange_type(self):
if self.type == 'combo':
self.taxes_id = False
self.supplier_taxes_id = False
return super()._onchange_type()
def _force_default_sale_tax(self, companies):
default_customer_taxes = companies.filtered('account_sale_tax_id').account_sale_tax_id
if not default_customer_taxes:
return
links = [Command.link(t.id) for t in default_customer_taxes]
for sub_ids in split_every(self.env.cr.IN_MAX, self.ids):
chunk = self.browse(sub_ids)
chunk.write({'taxes_id': links})
chunk.invalidate_recordset(['taxes_id'])
def _force_default_purchase_tax(self, companies):
default_supplier_taxes = companies.filtered('account_purchase_tax_id').account_purchase_tax_id
if not default_supplier_taxes:
return
links = [Command.link(t.id) for t in default_supplier_taxes]
for sub_ids in split_every(self.env.cr.IN_MAX, self.ids):
chunk = self.browse(sub_ids)
chunk.write({'supplier_taxes_id': links})
chunk.invalidate_recordset(['supplier_taxes_id'])
def _force_default_tax(self, companies):
self._force_default_sale_tax(companies)
self._force_default_purchase_tax(companies)
@api.model_create_multi
def create(self, vals_list):
products = super().create(vals_list)
# If no company was set for the product, the product will be available for all companies and therefore should
# have the default taxes of the other companies as well. sudo() is used since we're going to need to fetch all
# the other companies default taxes which the user may not have access to.
other_companies = self.env['res.company'].sudo().search(['!', ('id', 'child_of', self.env.companies.ids)])
if other_companies and products:
products_without_company = products.filtered(lambda p: not p.company_id).sudo()
products_without_company._force_default_tax(other_companies)
return products
def _get_list_price(self, price):
""" Get the product sales price from a public price based on taxes defined on the product """
self.ensure_one()
if not self.taxes_id:
return super()._get_list_price(price)
computed_price = self.taxes_id.compute_all(price, self.currency_id)
total_included = computed_price["total_included"]
if price == total_included:
# Tax is configured as price included
return total_included
# calculate base from tax
included_computed_price = self.taxes_id.with_context(force_price_include=True).compute_all(price, self.currency_id)
return included_computed_price['total_excluded']
class ProductProduct(models.Model):
_inherit = "product.product"
@ -124,6 +220,8 @@ class ProductProduct(models.Model):
""" Helper to get the price unit from different models.
This is needed to compute the same unit price in different models (sale order, account move, etc.) with same parameters.
"""
self.ensure_one()
company.ensure_one()
product = self
@ -156,10 +254,8 @@ class ProductProduct(models.Model):
if product_taxes and fiscal_position:
product_price_unit = self._get_tax_included_unit_price_from_price(
product_price_unit,
currency,
product_taxes,
fiscal_position=fiscal_position,
is_refund_document=is_refund_document,
)
# Apply currency rate.
@ -168,12 +264,10 @@ class ProductProduct(models.Model):
return product_price_unit
@api.model # the product is optional for `compute_all`
def _get_tax_included_unit_price_from_price(
self, product_price_unit, currency, product_taxes,
self, product_price_unit, product_taxes,
fiscal_position=None,
product_taxes_after_fp=None,
is_refund_document=False,
):
if not product_taxes:
return product_price_unit
@ -184,38 +278,62 @@ class ProductProduct(models.Model):
product_taxes_after_fp = fiscal_position.map_tax(product_taxes)
flattened_taxes_after_fp = product_taxes_after_fp._origin.flatten_taxes_hierarchy()
flattened_taxes_before_fp = product_taxes._origin.flatten_taxes_hierarchy()
taxes_before_included = all(tax.price_include for tax in flattened_taxes_before_fp)
if set(product_taxes.ids) != set(product_taxes_after_fp.ids) and taxes_before_included:
taxes_res = flattened_taxes_before_fp.with_context(round=False, round_base=False).compute_all(
product_price_unit,
quantity=1.0,
currency=currency,
product=self,
is_refund=is_refund_document,
)
product_price_unit = taxes_res['total_excluded']
if any(tax.price_include for tax in flattened_taxes_after_fp):
taxes_res = flattened_taxes_after_fp.with_context(round=False, round_base=False).compute_all(
product_price_unit,
quantity=1.0,
currency=currency,
product=self,
is_refund=is_refund_document,
handle_price_include=False,
)
for tax_res in taxes_res['taxes']:
tax = self.env['account.tax'].browse(tax_res['id'])
if tax.price_include:
product_price_unit += tax_res['amount']
return product_price_unit
return product_taxes._adapt_price_unit_to_another_taxes(
price_unit=product_price_unit,
product=self,
original_taxes=product_taxes,
new_taxes=product_taxes_after_fp,
)
@api.depends('lst_price', 'product_tmpl_id', 'taxes_id')
@api.depends_context('company')
def _compute_tax_string(self):
for record in self:
record.tax_string = record.product_tmpl_id._construct_tax_string(record.lst_price)
# -------------------------------------------------------------------------
# EDI
# -------------------------------------------------------------------------
def _retrieve_product(self, company=None, extra_domain=None, **product_vals):
'''Search all products and find one that matches one of the parameters.
:param company: The company of the product.
:param extra_domain: Any extra domain to add to the search.
:param product_vals: Values the product should match.
:returns: A product or an empty recordset if not found.
'''
domains = self._get_product_domain_search_order(**product_vals)
company = company or self.env.company
for _priority, domain in domains:
for company_domain in (
[*self.env['res.partner']._check_company_domain(company), ('company_id', '!=', False)],
[('company_id', '=', False)],
):
if product := self.env['product.product'].search(
Domain.AND([domain, company_domain, extra_domain or Domain.TRUE]), limit=1,
):
return product
return self.env['product.product']
def _get_product_domain_search_order(self, **vals):
"""Gives the order of search for a product given the parameters.
:param name: The name of the product.
:param default_code: The default_code of the product.
:param barcode: The barcode of the product.
:returns: An ordered list of product domains and their associated priority.
:rtype: list[tuple[int, Domain]]
"""
sorted_domains = []
if barcode := vals.get('barcode'):
sorted_domains.append((5, Domain('barcode', '=', barcode)))
if default_code := vals.get('default_code'):
sorted_domains.append((10, Domain('default_code', '=', default_code)))
if name := vals.get('name'):
name = name.split('\n', 1)[0] # Cut sales description from the name
sorted_domains.append((15, Domain('name', '=', name)))
# avoid matching unrelated products whose names merely contain that short string
if len(name) > 4:
sorted_domains.append((20, Domain('name', 'ilike', name)))
return sorted_domains

View file

@ -0,0 +1,188 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ProductCatalogMixin(models.AbstractModel):
_inherit = 'product.catalog.mixin'
def _create_section(self, child_field, name, position, **kwargs):
"""Create a new section in order.
:param str child_field: Field name of the order's lines (e.g., 'order_line').
:param str name: The name of the section to create.
:param str position: The position of the section where it should be created, either 'top'
or 'bottom'.
:param dict kwargs: Additional values given for inherited models.
:return: A dictionary with newly created section's 'id' and 'sequence'.
:rtype: dict
"""
parent_field = self._get_parent_field_on_child_model()
if not parent_field:
return {}
lines = self[child_field].sorted('sequence')
line_model = lines._name
sequence = 10
if lines:
sequence = (
lines[0].sequence - 1 if position == 'top'
else lines[-1].sequence + 1
)
section = self.env[line_model].create({
parent_field: self.id,
'name': name,
'display_type': 'line_section',
'sequence': sequence,
**self._get_default_create_section_values(),
})
return {
'id': section.id,
'sequence': section.sequence,
}
def _get_new_line_sequence(self, child_field, section_id):
"""Compute the sequence number for inserting a new line into the order.
:param str child_field: Field name of the order's lines (e.g., 'order_line').
:param int section_id: ID of the section line to insert after.
:rtype: int
:return: Computed sequence number.
"""
lines = self[child_field].sorted('sequence')
if section_id:
# Insert after the selected section line
sequence = lines.filtered_domain([
('display_type', '=', 'line_section'),
('id', '=', section_id),
]).sequence + 1
elif (
section_lines := lines.filtered_domain([
('display_type', '=', 'line_section'),
])
):
# Insert before the first section (top of the order)
sequence = section_lines[0].sequence
else:
# No sections exist, insert at the end
sequence = (lines and lines[-1].sequence + 1) or 10
for line in lines.filtered_domain([('sequence', '>=', sequence)]):
line.sequence += 1
return sequence
def _get_sections(self, child_field, **kwargs):
"""Return section data for the product catalog display.
:param str child_field: Field name of the order's lines (e.g., 'order_line').
:param dict kwargs: Additional values given for inherited models.
:rtype: list
:return: List of section dicts with 'id', 'name', 'sequence', and 'line_count'.
"""
sections = {}
no_section_count = 0
lines = self[child_field]
for line in lines.sorted('sequence'):
if line.display_type == 'line_section':
sections[line.id] = {
'id': line.id,
'name': line.name,
'sequence': line.sequence,
'line_count': 0,
}
elif self._is_line_valid_for_section_line_count(line):
sec_id = line.get_parent_section_line().id
if sec_id and sec_id in sections:
sections[sec_id]['line_count'] += 1
else:
no_section_count += 1
if no_section_count > 0 or not sections:
# If there are products outside of a section or no section at all
sections[False] = {
'id': False,
'name': self.env._("No Section"),
'sequence': lines[0].sequence - 1 if lines else 0,
'line_count': no_section_count,
}
return sorted(sections.values(), key=lambda x: x['sequence'])
def _get_default_create_section_values(self):
"""Return default values for creating a new section in order through catalog.
:return: A dictionary with default values for creating a new section.
:rtype: dict
"""
return {}
def _get_parent_field_on_child_model(self):
"""Return the parent field for the order lines.
:return: parent field
:rtype: str
"""
return ''
def _is_line_valid_for_section_line_count(self, line):
"""Check if a line is valid for inclusion in the section's line count.
:param recordset line: A record of an order line.
:return: whether this line should be considered in the section lines count.
:rtype: bool
"""
return (
not line.display_type
and line.product_type != 'combo'
and line.product_uom_qty > 0
)
def _resequence_sections(self, sections, child_field, **kwargs):
"""Resequence the order content based on the new sequence order.
:param list sections: A list of dictionaries containing move and target sections.
:param str child_field: Field name of the order's lines (e.g., 'order_line').
:param dict kwargs: Additional values given for inherited models.
:return: A dictonary containing the new sequences of all the sections of order.
:rtype: dict
"""
lines = self[child_field].sorted('sequence')
move_section, target_section = sections
move_block = lines.filtered(
lambda line: line.id == move_section['id']
or line.parent_id.id == move_section['id'],
)
target_block = lines.filtered(
lambda line: line.id == target_section['id']
or line.parent_id.id == target_section['id'],
)
remaining_lines = lines - move_block
insert_after = move_section['sequence'] < target_section['sequence']
insert_index = len(remaining_lines)
for idx, line in enumerate(remaining_lines):
if line.id == (target_block[-1].id if insert_after else target_section['id']):
insert_index = idx + 1 if insert_after else idx
break
reordered_lines = (
remaining_lines[:insert_index] +
move_block +
remaining_lines[insert_index:]
)
sections = {}
for sequence, line in enumerate(reordered_lines, start=1):
line.sequence = sequence
if line.display_type == 'line_section':
sections[line.id] = sequence
return sections

View file

@ -1,7 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo import _, api, fields, models
from odoo.addons.account.models.company import PEPPOL_LIST
class ResConfigSettings(models.TransientModel):
@ -14,90 +15,70 @@ class ResConfigSettings(models.TransientModel):
comodel_name='account.journal',
related='company_id.currency_exchange_journal_id', readonly=False,
string="Currency Exchange Journal",
domain="[('company_id', '=', company_id), ('type', '=', 'general')]",
check_company=True,
domain="[('type', '=', 'general')]",
help='The accounting journal where automatic exchange differences will be registered')
income_currency_exchange_account_id = fields.Many2one(
comodel_name="account.account",
related="company_id.income_currency_exchange_account_id",
string="Gain Account",
string="Gain Exchange Rate Account",
readonly=False,
domain="[('deprecated', '=', False), ('company_id', '=', company_id),\
('internal_group', '=', 'income')]")
check_company=True,
domain="[('internal_group', '=', 'income')]")
expense_currency_exchange_account_id = fields.Many2one(
comodel_name="account.account",
related="company_id.expense_currency_exchange_account_id",
string="Loss Account",
string="Loss Exchange Rate Account",
readonly=False,
domain="[('deprecated', '=', False), ('company_id', '=', company_id),\
('account_type', '=', 'expense')]")
check_company=True,
domain="[('account_type', '=', 'expense')]")
has_chart_of_accounts = fields.Boolean(compute='_compute_has_chart_of_accounts', string='Company has a chart of accounts')
chart_template_id = fields.Many2one('account.chart.template', string='Template', default=lambda self: self.env.company.chart_template_id,
domain="[('visible','=', True)]")
sale_tax_id = fields.Many2one('account.tax', string="Default Sale Tax", related='company_id.account_sale_tax_id', readonly=False)
purchase_tax_id = fields.Many2one('account.tax', string="Default Purchase Tax", related='company_id.account_purchase_tax_id', readonly=False)
chart_template = fields.Selection(selection=lambda self: self.env.company._chart_template_selection(), default=lambda self: self.env.company.chart_template)
sale_tax_id = fields.Many2one(
'account.tax',
string="Default Sale Tax",
related='company_id.account_sale_tax_id',
readonly=False,
check_company=True,
)
purchase_tax_id = fields.Many2one(
'account.tax',
string="Default Purchase Tax",
related='company_id.account_purchase_tax_id',
readonly=False,
check_company=True,
)
account_price_include = fields.Selection(
string='Default Sales Price Include',
related='company_id.account_price_include',
readonly=False,
required=True,
help="Default on whether the sales price used on the product and invoices with this Company includes its taxes."
)
tax_calculation_rounding_method = fields.Selection(
related='company_id.tax_calculation_rounding_method', string='Tax calculation rounding method', readonly=False)
account_journal_suspense_account_id = fields.Many2one(
comodel_name='account.account',
string='Bank Suspense Account',
string='Bank Suspense',
readonly=False,
related='company_id.account_journal_suspense_account_id',
domain="[('deprecated', '=', False), ('company_id', '=', company_id), ('account_type', 'in', ('asset_current', 'liability_current'))]",
check_company=True,
domain="[('account_type', 'in', ('asset_current', 'liability_current'))]",
help='Bank Transactions are posted immediately after import or synchronization. '
'Their counterparty is the bank suspense account.\n'
'Reconciliation replaces the latter by the definitive account(s).')
account_journal_payment_debit_account_id = fields.Many2one(
comodel_name='account.account',
string='Outstanding Receipts Account',
readonly=False,
related='company_id.account_journal_payment_debit_account_id',
domain="[('deprecated', '=', False), ('company_id', '=', company_id), ('account_type', '=', 'asset_current')]",
help='Incoming payments are posted on an Outstanding Receipts Account. '
'In the bank reconciliation widget, they appear as blue lines.\n'
'Bank transactions are then reconciled on the Outstanding Receipts Accounts rather than the Receivable '
'Account.')
account_journal_payment_credit_account_id = fields.Many2one(
comodel_name='account.account',
string='Outstanding Payments Account',
readonly=False,
related='company_id.account_journal_payment_credit_account_id',
domain="[('deprecated', '=', False), ('company_id', '=', company_id), ('account_type', '=', 'asset_current')]",
help='Outgoing Payments are posted on an Outstanding Payments Account. '
'In the bank reconciliation widget, they appear as blue lines.\n'
'Bank transactions are then reconciled on the Outstanding Payments Account rather the Payable Account.')
transfer_account_id = fields.Many2one('account.account', string="Internal Transfer Account",
transfer_account_id = fields.Many2one('account.account', string="Internal Transfer",
related='company_id.transfer_account_id', readonly=False,
check_company=True,
domain=[
('reconcile', '=', True),
('account_type', '=', 'asset_current'),
('deprecated', '=', False),
],
help="Intermediary account used when moving from a liquidity account to another.")
module_account_accountant = fields.Boolean(string='Accounting')
group_warning_account = fields.Boolean(string="Warnings in Invoices", implied_group='account.group_warning_account')
group_cash_rounding = fields.Boolean(string="Cash Rounding", implied_group='account.group_cash_rounding')
# group_show_line_subtotals_tax_excluded and group_show_line_subtotals_tax_included are opposite,
# so we can assume exactly one of them will be set, and not the other.
# We need both of them to coexist so we can take advantage of automatic group assignation.
group_show_line_subtotals_tax_excluded = fields.Boolean(
"Show line subtotals without taxes (B2B)",
implied_group='account.group_show_line_subtotals_tax_excluded',
group='base.group_portal,base.group_user,base.group_public',
compute='_compute_group_show_line_subtotals', store=True, readonly=False)
group_show_line_subtotals_tax_included = fields.Boolean(
"Show line subtotals with taxes (B2C)",
implied_group='account.group_show_line_subtotals_tax_included',
group='base.group_portal,base.group_user,base.group_public',
compute='_compute_group_show_line_subtotals', store=True, readonly=False)
group_show_sale_receipts = fields.Boolean(string='Sale Receipt',
implied_group='account.group_sale_receipts')
group_show_purchase_receipts = fields.Boolean(string='Purchase Receipt',
implied_group='account.group_purchase_receipts')
show_line_subtotals_tax_selection = fields.Selection([
('tax_excluded', 'Tax Excluded'),
('tax_included', 'Tax Included')], string="Line Subtotals Tax Display",
required=True, default='tax_excluded',
config_parameter='account.show_line_subtotals_tax_selection')
show_sale_receipts = fields.Boolean(string='Sale Receipt', config_parameter='account.show_sale_receipts')
module_account_budget = fields.Boolean(string='Budget Management')
module_account_payment = fields.Boolean(string='Invoice Online Payment')
module_account_reports = fields.Boolean("Dynamic Reports")
@ -105,38 +86,52 @@ class ResConfigSettings(models.TransientModel):
module_account_batch_payment = fields.Boolean(string='Use batch payments',
help='This allows you grouping payments into a single batch and eases the reconciliation process.\n'
'-This installs the account_batch_payment module.')
module_account_sepa = fields.Boolean(string='SEPA Credit Transfer (SCT)')
module_account_iso20022 = fields.Boolean(string='SEPA Credit Transfer / ISO20022')
module_account_sepa_direct_debit = fields.Boolean(string='Use SEPA Direct Debit')
module_account_bank_statement_import_qif = fields.Boolean("Import .qif files")
module_account_bank_statement_import_ofx = fields.Boolean("Import in .ofx format")
module_account_bank_statement_import_csv = fields.Boolean("Import in .csv format")
module_account_bank_statement_import_camt = fields.Boolean("Import in CAMT.053 format")
module_currency_rate_live = fields.Boolean(string="Automatic Currency Rates")
module_account_intrastat = fields.Boolean(string='Intrastat')
module_product_margin = fields.Boolean(string="Allow Product Margin")
module_l10n_eu_oss = fields.Boolean(string="EU Intra-community Distance Selling")
module_account_taxcloud = fields.Boolean(string="Account TaxCloud")
module_account_invoice_extract = fields.Boolean(string="Document Digitization")
module_account_extract = fields.Boolean(string="Document Digitization")
module_account_invoice_extract = fields.Boolean("Invoice Digitization", compute='_compute_module_account_invoice_extract', readonly=False, store=True)
module_account_bank_statement_extract = fields.Boolean("Bank Statement Digitization", compute='_compute_module_account_bank_statement_extract', readonly=False, store=True)
module_snailmail_account = fields.Boolean(string="Snailmail")
module_account_peppol = fields.Boolean(string='PEPPOL Invoicing')
tax_exigibility = fields.Boolean(string='Cash Basis', related='company_id.tax_exigibility', readonly=False)
tax_cash_basis_journal_id = fields.Many2one('account.journal', related='company_id.tax_cash_basis_journal_id', string="Tax Cash Basis Journal", readonly=False)
tax_cash_basis_journal_id = fields.Many2one(
'account.journal',
string="Tax Cash Basis Journal",
related='company_id.tax_cash_basis_journal_id',
readonly=False,
check_company=True,
)
account_cash_basis_base_account_id = fields.Many2one(
comodel_name='account.account',
string="Base Tax Received Account",
readonly=False,
check_company=True,
related='company_id.account_cash_basis_base_account_id',
domain=[('deprecated', '=', False)])
)
account_fiscal_country_id = fields.Many2one(string="Fiscal Country Code", related="company_id.account_fiscal_country_id", readonly=False, store=False)
qr_code = fields.Boolean(string='Display SEPA QR-code', related='company_id.qr_code', readonly=False)
invoice_is_print = fields.Boolean(string='Print', related='company_id.invoice_is_print', readonly=False)
invoice_is_email = fields.Boolean(string='Send Email', related='company_id.invoice_is_email', readonly=False)
link_qr_code = fields.Boolean(string='Display Link QR-code', related='company_id.link_qr_code', readonly=False)
incoterm_id = fields.Many2one('account.incoterms', string='Default incoterm', related='company_id.incoterm_id', help='International Commercial Terms are a series of predefined commercial terms used in international transactions.', readonly=False)
invoice_terms = fields.Html(related='company_id.invoice_terms', string="Terms & Conditions", readonly=False)
invoice_terms_html = fields.Html(related='company_id.invoice_terms_html', string="Terms & Conditions as a Web page",
readonly=False)
terms_type = fields.Selection(
related='company_id.terms_type', readonly=False)
display_invoice_amount_total_words = fields.Boolean(
string="Total amount of invoice in letters",
related='company_id.display_invoice_amount_total_words',
readonly=False
)
display_invoice_tax_company_currency = fields.Boolean(
string="Taxes in company currency",
related='company_id.display_invoice_tax_company_currency',
readonly=False,
)
preview_ready = fields.Boolean(string="Display preview button", compute='_compute_terms_preview')
use_invoice_terms = fields.Boolean(
@ -155,6 +150,7 @@ class ResConfigSettings(models.TransientModel):
# Storno Accounting
account_storno = fields.Boolean(string="Storno accounting", readonly=False, related='company_id.account_storno')
display_account_storno = fields.Boolean(related='company_id.display_account_storno')
# Allows for the use of a different delivery address
group_sale_delivery_address = fields.Boolean("Customer Addresses", implied_group='account.group_delivery_invoice_address')
@ -162,56 +158,103 @@ class ResConfigSettings(models.TransientModel):
# Quick encoding (fiduciary mode)
quick_edit_mode = fields.Selection(string="Quick encoding", readonly=False, related='company_id.quick_edit_mode')
early_pay_discount_computation = fields.Selection(related='company_id.early_pay_discount_computation', string='Tax setting', readonly=False)
account_journal_early_pay_discount_loss_account_id = fields.Many2one(
comodel_name='account.account',
string='Cash Discount Loss account',
string='Early Discount Loss',
help='Account for the difference amount after the expense discount has been granted',
readonly=False,
related='company_id.account_journal_early_pay_discount_loss_account_id',
domain="[('deprecated', '=', False), ('company_id', '=', company_id), ('account_type', 'in', ('expense', 'income', 'income_other'))]",
check_company=True,
domain="[('account_type', 'in', ('expense', 'income', 'income_other'))]",
)
account_journal_early_pay_discount_gain_account_id = fields.Many2one(
comodel_name='account.account',
string='Cash Discount Gain account',
string='Early Discount Gain',
help='Account for the difference amount after the income discount has been granted',
readonly=False,
check_company=True,
related='company_id.account_journal_early_pay_discount_gain_account_id',
domain="[('deprecated', '=', False), ('company_id', '=', company_id), ('account_type', 'in', ('income', 'income_other', 'expense'))]",
domain="[('account_type', 'in', ('income', 'income_other', 'expense'))]",
)
# Accounts for allocation of discounts
account_discount_income_allocation_id = fields.Many2one(
comodel_name='account.account',
string='Vendor Bills Discounts Account',
readonly=False,
related='company_id.account_discount_income_allocation_id',
domain="[('account_type', 'in', ('income', 'income_other', 'expense', 'expense_other'))]",
)
account_discount_expense_allocation_id = fields.Many2one(
comodel_name='account.account',
string='Customer Invoices Discounts Account',
readonly=False,
related='company_id.account_discount_expense_allocation_id',
domain="[('account_type', 'in', ('income', 'income_other', 'expense', 'expense_other'))]",
)
# PEPPOL
is_account_peppol_eligible = fields.Boolean(
string='PEPPOL eligible',
compute='_compute_is_account_peppol_eligible',
) # technical field used for showing the Peppol settings conditionally
# Audit trail
restrictive_audit_trail = fields.Boolean(string='Restricted Audit Trail', related='company_id.restrictive_audit_trail', readonly=False)
force_restrictive_audit_trail = fields.Boolean(string='Forced Audit Trail', related='company_id.force_restrictive_audit_trail', readonly=False)
# Autopost of bills
autopost_bills = fields.Boolean(related='company_id.autopost_bills', readonly=False)
income_account_id = fields.Many2one(related='company_id.income_account_id', readonly=False, check_company=True)
expense_account_id = fields.Many2one(related='company_id.expense_account_id', readonly=False, check_company=True)
@api.depends('country_code')
def _compute_is_account_peppol_eligible(self):
# we want to show Peppol settings only to customers that are eligible for Peppol,
# except countries that are not in Europe
for config in self:
config.is_account_peppol_eligible = config.country_code in PEPPOL_LIST
def set_values(self):
super().set_values()
# install a chart of accounts for the given company (if required)
if self.env.company == self.company_id \
and self.chart_template_id \
and self.chart_template_id != self.company_id.chart_template_id:
self.chart_template_id._load(self.env.company)
if self.env.company == self.company_id and self.chart_template \
and self.chart_template != self.company_id.chart_template:
self.env['account.chart.template'].try_loading(self.chart_template, company=self.company_id)
self.company_id._initiate_account_onboardings()
def reload_template(self):
self.env['account.chart.template'].try_loading(self.company_id.chart_template, company=self.company_id)
@api.depends('company_id')
def _compute_account_default_credit_limit(self):
for setting in self:
setting.account_default_credit_limit = self.env['ir.property']._get('credit_limit', 'res.partner')
ResPartner = self.env['res.partner']
company_limit = ResPartner._fields['credit_limit'].get_company_dependent_fallback(ResPartner)
self.account_default_credit_limit = company_limit
def _inverse_account_default_credit_limit(self):
for setting in self:
self.env['ir.property']._set_default(
'credit_limit',
self.env['ir.default'].set(
'res.partner',
'credit_limit',
setting.account_default_credit_limit,
setting.company_id.id
company_id=setting.company_id.id
)
@api.depends('company_id')
def _compute_has_chart_of_accounts(self):
self.has_chart_of_accounts = bool(self.company_id.chart_template_id)
self.has_accounting_entries = self.env['account.chart.template'].existing_accounting(self.company_id)
self.has_chart_of_accounts = bool(self.company_id.chart_template)
self.has_accounting_entries = self.company_id.root_id._existing_accounting()
@api.depends('show_line_subtotals_tax_selection')
def _compute_group_show_line_subtotals(self):
for wizard in self:
wizard.group_show_line_subtotals_tax_included = wizard.show_line_subtotals_tax_selection == "tax_included"
wizard.group_show_line_subtotals_tax_excluded = wizard.show_line_subtotals_tax_selection == "tax_excluded"
@api.depends('module_account_extract')
def _compute_module_account_invoice_extract(self):
for config in self:
config.module_account_invoice_extract = config.module_account_extract and self.env['ir.module.module']._get('account_invoice_extract').state == 'installed'
@api.depends('module_account_extract')
def _compute_module_account_bank_statement_extract(self):
for config in self:
config.module_account_bank_statement_extract = config.module_account_extract and self.env['ir.module.module']._get('account_invoice_extract').state == 'installed'
@api.onchange('group_analytic_accounting')
def onchange_analytic_accounting(self):
@ -227,7 +270,8 @@ class ResConfigSettings(models.TransientModel):
def _onchange_tax_exigibility(self):
res = {}
tax = self.env['account.tax'].search([
('company_id', '=', self.env.company.id), ('tax_exigibility', '=', 'on_payment')
*self.env['account.tax']._check_company_domain(self.env.company),
('tax_exigibility', '=', 'on_payment'),
], limit=1)
if not self.tax_exigibility and tax:
self.tax_exigibility = True
@ -258,3 +302,10 @@ class ResConfigSettings(models.TransientModel):
'target': 'new',
'res_id': self.company_id.id,
}
def action_eu_oss_tax_mapping(self):
l10n_eu_oss_module = self.env['ir.module.module'].search([('name', '=', 'l10n_eu_oss')], limit=1)
if l10n_eu_oss_module:
if l10n_eu_oss_module.state != 'installed':
l10n_eu_oss_module.button_immediate_install()
self.env.companies._map_eu_taxes()

View file

@ -0,0 +1,12 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResCountryGroup(models.Model):
_inherit = 'res.country.group'
exclude_state_ids = fields.Many2many(
comodel_name='res.country.state',
string="Fiscal Exceptions",
help="Those states are ignored by the fiscal positions")

View file

@ -3,21 +3,25 @@
from odoo import api, models, fields, _
from odoo.exceptions import UserError
from odoo.tools import date_utils, SQL
class ResCurrency(models.Model):
_inherit = 'res.currency'
def _get_fiscal_country_codes(self):
return ','.join(self.env.companies.mapped('account_fiscal_country_id.code'))
display_rounding_warning = fields.Boolean(string="Display Rounding Warning", compute='_compute_display_rounding_warning',
help="The warning informs a rounding factor change might be dangerous on res.currency's form view.")
fiscal_country_codes = fields.Char(store=False, default=_get_fiscal_country_codes)
@api.depends('rounding')
def _compute_display_rounding_warning(self):
for record in self:
record.display_rounding_warning = record.id \
and record._origin.rounding != record.rounding \
and record._origin._has_accounting_entries()
record.display_rounding_warning = (
record._origin.id and record._origin.rounding != record.rounding
)
def write(self, vals):
if 'rounding' in vals:
@ -35,31 +39,247 @@ class ResCurrency(models.Model):
self.ensure_one()
return bool(self.env['account.move.line'].sudo().search_count(['|', ('currency_id', '=', self.id), ('company_currency_id', '=', self.id)]))
@api.model
def _get_query_currency_table(self, options):
''' Construct the currency table as a mapping company -> rate to convert the amount to the user's company
currency in a multi-company/multi-currency environment.
The currency_table is a small postgresql table construct with VALUES.
:param options: The report options.
:return: The query representing the currency table.
'''
def _get_simple_currency_table(self, companies) -> SQL:
""" Helper creating the currency table and returning its definition for basic cases of Odoo reports needing to convert amounts using only the
current rates, in a single period.
"""
if self._check_currency_table_monocurrency(companies):
return self._get_monocurrency_currency_table_sql(companies)
user_company = self.env.company
user_currency = user_company.currency_id
if options.get('multi_company', False):
companies = self.env.companies
conversion_date = options['date']['date_to']
currency_rates = companies.mapped('currency_id')._get_rates(user_company, conversion_date)
else:
companies = user_company
currency_rates = {user_currency.id: 1.0}
self._create_currency_table(companies, [('period', None, fields.Date.today())])
return SQL('account_currency_table')
conversion_rates = []
def _check_currency_table_monocurrency(self, companies):
""" Returns whether displaying the data of the provided companies can be done with a monocurrency currency table.
If it can, calling _get_monocurrency_currency_table_sql is enough to join the currency table (which actually consists of a bunch of VALUES
directly injected in the join).
Else, a full-flegdge temporary table will be needed, that will have to be generated by a call to _create_currency_table.
"""
return len(companies.currency_id) == 1
def _get_monocurrency_currency_table_sql(self, companies, use_cta_rates=False):
""" Returns a simplified currency table, faster to generate, for cases were all the data to convert are expressed in the same currency,
to be use in a JOIN. It actually just consists of a few VALUES ; no temporary table is created in this case.
All the rates in this currency table are equal to 1 (since everything is in the same currency). This is useful so that the queries can
be written exactly in the same way, joining the currency table returned by some function, for both mono and multi currency cases.
"""
unit_rates = [
SQL("(%(company_id)s, CAST(NULL AS VARCHAR), CAST(NULL AS DATE), CAST(NULL AS DATE), %(rate_type)s, 1)", company_id=company.id, rate_type=rate_type)
for company in companies
for rate_type in (('historical', 'current', 'average') if use_cta_rates else ('current',))
]
return SQL('(VALUES %s) AS account_currency_table(company_id, period_key, date_from, date_next, rate_type, rate)', SQL(',').join(unit_rates))
def _create_currency_table(self, companies, date_periods, use_cta_rates=False):
""" Creates a temporary table containing the currency rates to be used in order to aggregate amounts belonging to companies
with different main currencies in a reporting query.
These rates are computed from the res.currrency.rate objects defined for self.env.company.
The currency table consists of the following columns:
- company_id: The id of the company whose amounts can be converted with this rate.
- period_key: The key corresponding to the period this rate is valid for. (see params list)
- date_from: Only set for rate_type 'historical'. The starting date for this rate.
- date_next: Only set for rate_type 'historical'. The date of the next rate. So, the rate applies until one day before date_next.
- rate_type: 'historical', 'current' or 'average'
- 'historical' means the rate is to be used to convert operations at the date they were made; they each
directly correspond to the res.currency.rate objects of the active company
- 'current' means this rate is the most recent rate within the period. This rate is unique per (company_id, period_key).
- 'average' means this rate is the average rate for the period. This rate is unique per (company_id, period_key).
- rate: The rate to apply, as a decimal factor to apply directly to the value to convert, provided it is expressed in the
main currency of the company referred to by company_id.
:param companies: The res.company objects to generate rates for.
:param date_periods: List of tuples in the form (period_key, date_from, date_to), containing each of the periods to generate rates for, where:
- period_key is a unique string identifier used to differentiate the periods
- date_from is the date the period starts at ; it can be None if the period want to consider everything from the beginning
- date_to is the date the periods ends at
:param use_cta_rates: Boolean parameter, enabling the computation of CTA rates. If True, 'current', 'average' and 'historical' rates will be
computed for all companies, for all periods. Else, only 'current' will be computed.
"""
main_company = self.env.company
domestic_currency_companies = companies.filtered(lambda x: x.currency_id == main_company.currency_id)
other_companies = companies - domestic_currency_companies
table_builders = []
if domestic_currency_companies:
table_builders += [self._get_table_builder_domestic_currency(domestic_currency_companies, use_cta_rates)]
last_date_to = None
for period_key, date_from, date_to in date_periods:
main_company_unit_factor = main_company.currency_id._get_rates(main_company, date_to)[main_company.currency_id.id]
table_builders.append(self._get_table_builder_current(period_key, main_company, other_companies, date_to, main_company_unit_factor))
if use_cta_rates:
table_builders += [
self._get_table_builder_historical(main_company, other_companies, date_to, main_company_unit_factor, last_date_to),
self._get_table_builder_average(period_key, main_company, other_companies, date_from, date_to, main_company_unit_factor),
]
last_date_to = date_to
self.env.cr.execute(SQL(
"""
-- Tests may call this function multiple times within the same transaction; we then need to delete an regenerate the currency table
DROP TABLE IF EXISTS account_currency_table;
-- Create a temporary table
CREATE TEMPORARY TABLE
account_currency_table (company_id, period_key, date_from, date_next, rate_type, rate)
ON COMMIT DROP
AS (%(currency_table_build_query)s);
-- Create a supporting index to avoid seq.scans
CREATE INDEX account_currency_table_index ON account_currency_table (company_id, rate_type, date_from, date_next);
-- Update statistics for correct planning
ANALYZE account_currency_table;
""",
currency_table_build_query=SQL(" UNION ALL ").join(SQL('(%s)', builder) for builder in table_builders),
))
def _get_table_builder_domestic_currency(self, companies, use_cta_rates) -> SQL:
""" Returns a query building one rate of each appropriate type equal to 1 for each of the provided companies. Those companies should be
the ones sharing the same currency as self.env.company.
"""
rate_values = []
for company in companies:
conversion_rates.extend((
company.id,
currency_rates[user_company.currency_id.id] / currency_rates[company.currency_id.id],
user_currency.decimal_places,
))
query = '(VALUES %s) AS currency_table(company_id, rate, precision)' % ','.join('(%s, %s, %s)' for i in companies)
return self.env.cr.mogrify(query, conversion_rates).decode(self.env.cr.connection.encoding)
rate_values.append(SQL("(%s, CAST(NULL AS VARCHAR), CAST(NULL AS DATE), CAST(NULL AS DATE), 'current', 1)", company.id))
if use_cta_rates:
rate_values += [
SQL("(%s, CAST(NULL AS VARCHAR), CAST(NULL AS DATE), CAST(NULL AS DATE), 'average', 1)", company.id),
SQL("(%s, CAST(NULL AS VARCHAR), CAST(NULL AS DATE), CAST(NULL AS DATE), 'historical', 1)", company.id),
]
return SQL(
"""
SELECT *
FROM ( VALUES
%(rate_values)s
) values
""",
rate_values=SQL(", ").join(rate_values)
)
def _get_table_builder_current(self, period_key, main_company, other_companies, date_to, main_company_unit_factor) -> SQL:
return SQL(
"""
SELECT DISTINCT ON (other_company.id)
other_company.id,
%(period_key)s,
CAST(NULL AS DATE),
CAST(NULL AS DATE),
'current',
CASE WHEN rate.id IS NOT NULL THEN %(main_company_unit_factor)s / rate.rate ELSE 1 END
FROM res_company other_company
LEFT JOIN res_currency_rate rate
ON rate.currency_id = other_company.currency_id
AND rate.name <= %(date_to)s
AND rate.company_id = %(main_company_id)s
WHERE
other_company.id IN %(other_company_ids)s
ORDER BY other_company.id, rate.name DESC
""",
period_key=period_key,
main_company_id=main_company.root_id.id,
other_company_ids=tuple(other_companies.ids),
date_to=date_to,
main_company_unit_factor=main_company_unit_factor,
)
def _get_table_builder_historical(self, main_company, other_companies, date_to, main_company_unit_factor, date_exclude) -> SQL:
return SQL(
"""
SELECT
other_company.id,
CAST(NULL AS VARCHAR),
rate.name,
LAG(rate.name, 1) OVER (PARTITION BY other_company.id, rate.currency_id ORDER BY rate.name DESC),
'historical',
%(main_company_unit_factor)s / rate.rate
FROM res_company other_company
JOIN res_currency_rate rate
ON rate.currency_id = other_company.currency_id
WHERE
other_company.id IN %(other_company_ids)s
AND rate.company_id = %(main_company_id)s
AND rate.name <= %(date_to)s
%(exclusion_condition)s
""",
main_company_id=main_company.root_id.id,
other_company_ids=tuple(other_companies.ids),
main_company_unit_factor=main_company_unit_factor,
date_to=date_to,
exclusion_condition=SQL("AND rate.name > %(date_exclude)s", date_exclude=date_exclude) if date_exclude else SQL(),
)
def _get_table_builder_average(self, period_key, main_company, other_companies, date_from, date_to, main_company_unit_factor) -> SQL:
if not date_from:
# When there is no start date, we want to compute the average rate on the current year only
date_from = date_utils.start_of(fields.Date.from_string(date_to), 'year')
return SQL(
"""
SELECT
rate_with_days.other_company_id,
%(period_key)s,
CAST(NULL AS DATE),
CAST(NULL AS DATE),
'average',
SUM(%(main_company_unit_factor)s / rate_with_days.rate * rate_with_days.number_of_days) / SUM(rate_with_days.number_of_days)
FROM (
SELECT
other_company.id as other_company_id,
rate.rate AS rate,
EXTRACT (
'Day' FROM COALESCE(
LEAD(rate.name, 1) OVER (PARTITION BY other_company.id, rate.currency_id ORDER BY rate.name ASC)::TIMESTAMP,
%(date_to)s::TIMESTAMP + INTERVAL '1' DAY
) - rate.name::TIMESTAMP
) AS number_of_days
FROM res_company other_company
JOIN res_currency_rate rate
ON rate.currency_id = other_company.currency_id
WHERE
rate.name <= %(date_to)s
AND rate.name >= %(date_from)s
AND other_company.id IN %(other_company_ids)s
AND rate.company_id = %(main_company_id)s
UNION ALL
(
SELECT DISTINCT ON (other_company.id)
other_company.id as other_company_id,
COALESCE(out_period_rate.rate, 1.0) AS rate,
EXTRACT('Day' FROM COALESCE(in_period_rate.name::TIMESTAMP, %(date_to)s::TIMESTAMP + INTERVAL '1' DAY) - %(date_from)s::TIMESTAMP) AS number_of_days
FROM res_company other_company
LEFT JOIN res_currency_rate in_period_rate
ON in_period_rate.currency_id = other_company.currency_id
AND in_period_rate.name <= %(date_to)s
AND in_period_rate.name >= %(date_from)s
AND in_period_rate.company_id = %(main_company_id)s
LEFT JOIN res_currency_rate out_period_rate
ON out_period_rate.currency_id = other_company.currency_id
AND out_period_rate.company_id = %(main_company_id)s
AND out_period_rate.name < %(date_from)s
WHERE
other_company.id IN %(other_company_ids)s
ORDER BY other_company.id, in_period_rate.name ASC, out_period_rate.name DESC
)
) rate_with_days
GROUP BY rate_with_days.other_company_id
""",
period_key=period_key,
main_company_id=main_company.root_id.id,
other_company_ids=tuple(other_companies.ids),
date_from=date_from,
date_to=date_to,
main_company_unit_factor=main_company_unit_factor,
)

View file

@ -4,8 +4,9 @@ from collections import defaultdict
import werkzeug
import werkzeug.exceptions
from odoo import _, api, fields, models
from odoo import _, api, fields, models, SUPERUSER_ID, tools
from odoo.exceptions import UserError, ValidationError
from odoo.tools import SQL
from odoo.tools.image import image_data_uri
@ -15,16 +16,41 @@ class ResPartnerBank(models.Model):
journal_id = fields.One2many(
'account.journal', 'bank_account_id', domain=[('type', '=', 'bank')], string='Account Journal', readonly=True,
check_company=True,
help="The accounting journal corresponding to this bank account.")
has_iban_warning = fields.Boolean(
compute='_compute_display_account_warning',
help='Technical field used to display a warning if the IBAN country is different than the holder country.',
store=True,
)
partner_country_name = fields.Char(related='partner_id.country_id.name')
has_money_transfer_warning = fields.Boolean(
compute='_compute_display_account_warning',
help='Technical field used to display a warning if the account is a transfer service account.',
store=True,
)
money_transfer_service = fields.Char(compute='_compute_money_transfer_service_name')
partner_supplier_rank = fields.Integer(related='partner_id.supplier_rank')
partner_customer_rank = fields.Integer(related='partner_id.customer_rank')
related_moves = fields.One2many('account.move', inverse_name='partner_bank_id')
# Add tracking to the base fields
bank_id = fields.Many2one(tracking=True)
active = fields.Boolean(tracking=True)
acc_number = fields.Char(tracking=True)
acc_holder_name = fields.Char(tracking=True)
clearing_number = fields.Char(tracking=True)
partner_id = fields.Many2one(tracking=True)
allow_out_payment = fields.Boolean(tracking=True)
user_has_group_validate_bank_account = fields.Boolean(compute='_compute_user_has_group_validate_bank_account')
allow_out_payment = fields.Boolean(
tracking=True,
help='Sending fake invoices with a fraudulent account number is a common phishing practice. '
'To protect yourself, always verify new bank account numbers, preferably by calling the vendor, as phishing '
'usually happens when their emails are compromised. Once verified, you can activate the ability to send money.'
)
currency_id = fields.Many2one(tracking=True)
lock_trust_fields = fields.Boolean(compute='_compute_lock_trust_fields')
duplicate_bank_partner_ids = fields.Many2many('res.partner', compute="_compute_duplicate_bank_partner_ids")
@api.constrains('journal_id')
def _check_journal_id(self):
@ -32,6 +58,82 @@ class ResPartnerBank(models.Model):
if len(bank.journal_id) > 1:
raise ValidationError(_('A bank account can belong to only one journal.'))
def _check_allow_out_payment(self):
""" Block enabling the setting, but it can be set to false without the group. (For example, at creation) """
for bank in self:
if bank.allow_out_payment and not bank._user_can_trust():
raise ValidationError(_('You do not have the right to trust or un-trust a bank account.'))
@api.depends('acc_number')
def _compute_duplicate_bank_partner_ids(self):
id2duplicates = dict(self.env.execute_query(SQL(
"""
SELECT this.id,
ARRAY_AGG(other.partner_id)
FROM res_partner_bank this
LEFT JOIN res_partner_bank other ON this.acc_number = other.acc_number
AND this.id != other.id
AND other.active = TRUE
WHERE this.id = ANY(%(ids)s)
AND other.partner_id IS NOT NULL
AND this.active = TRUE
AND (
((this.company_id = other.company_id) OR (this.company_id IS NULL AND other.company_id IS NULL))
OR
other.company_id IS NULL
)
GROUP BY this.id
""",
ids=self.ids,
)))
for bank in self:
duplicate_record = id2duplicates.get(bank._origin.id) or []
duplicate_record = [x for x in duplicate_record if x]
bank.duplicate_bank_partner_ids = self.env['res.partner'].browse(duplicate_record) if duplicate_record else False
@api.depends('partner_id.country_id', 'sanitized_acc_number', 'allow_out_payment', 'acc_type')
def _compute_display_account_warning(self):
for bank in self:
if bank.allow_out_payment or not bank.sanitized_acc_number or bank.acc_type != 'iban':
bank.has_iban_warning = False
bank.has_money_transfer_warning = False
continue
bank_country = bank.sanitized_acc_number[:2]
bank.has_iban_warning = bank.partner_id.country_id and bank_country != bank.partner_id.country_id.code
bank_institution_code = bank.sanitized_acc_number[4:7]
bank.has_money_transfer_warning = bank_institution_code in bank._get_money_transfer_services()
@api.depends('sanitized_acc_number', 'allow_out_payment')
def _compute_money_transfer_service_name(self):
for bank in self:
if bank.sanitized_acc_number:
bank_institution_code = bank.sanitized_acc_number[4:7]
bank.money_transfer_service = bank._get_money_transfer_services().get(bank_institution_code, False)
else:
bank.money_transfer_service = False
def _get_money_transfer_services(self):
return {
'967': 'Wise',
'977': 'Paynovate',
'974': 'PPS EU SA',
}
@api.depends('acc_number')
@api.depends_context('uid')
def _compute_user_has_group_validate_bank_account(self):
for bank in self:
bank.user_has_group_validate_bank_account = bank._user_can_trust()
@api.depends('allow_out_payment')
def _compute_lock_trust_fields(self):
for bank in self:
if not bank._origin or not bank.allow_out_payment:
bank.lock_trust_fields = False
elif bank._origin and bank.allow_out_payment:
bank.lock_trust_fields = True
def _build_qr_code_vals(self, amount, free_communication, structured_communication, currency, debtor_partner, qr_method=None, silent_errors=True):
""" Returns the QR-code vals needed to generate the QR-code report link to pay this account with the given parameters,
or None if no QR-code could be generated.
@ -48,14 +150,14 @@ class ResPartnerBank(models.Model):
return None
self.ensure_one()
if not currency:
raise UserError(_("Currency must always be provided in order to generate a QR-code"))
available_qr_methods = self.get_available_qr_methods_in_sequence()
candidate_methods = qr_method and [(qr_method, dict(available_qr_methods)[qr_method])] or available_qr_methods
for candidate_method, candidate_name in candidate_methods:
if self._eligible_for_qr_code(candidate_method, debtor_partner, currency, not silent_errors):
error_message = self._get_error_messages_for_qr(candidate_method, debtor_partner, currency)
if not error_message:
error_message = self._check_for_qr_code_errors(candidate_method, amount, currency, debtor_partner, free_communication, structured_communication)
if not error_message:
@ -68,9 +170,8 @@ class ResPartnerBank(models.Model):
'structured_communication': structured_communication,
}
elif not silent_errors:
error_header = _("The following error prevented '%s' QR-code to be generated though it was detected as eligible: ", candidate_name)
raise UserError(error_header + error_message)
if not silent_errors:
raise UserError(self.env._("The following error prevented '%(candidate)s' QR-code to be generated though it was detected as eligible: ", candidate=candidate_name) + error_message)
return None
@ -149,32 +250,58 @@ class ResPartnerBank(models.Model):
all_available.sort(key=lambda x: x[2])
return [(code, name) for (code, name, sequence) in all_available]
def _eligible_for_qr_code(self, qr_method, debtor_partner, currency, raises_error=True):
def _get_error_messages_for_qr(self, qr_method, debtor_partner, currency):
""" Tells whether or not the criteria to apply QR-generation
method qr_method are met for a payment on this account, in the
given currency, by debtor_partner. This does not impeach generation errors,
it only checks that this type of QR-code *should be* possible to generate.
If not, returns an adequate error message to be displayed to the user if need be.
Consistency of the required field needs then to be checked by _check_for_qr_code_errors().
:returns: None if the qr method is eligible, or the error message
"""
return False
return None
def _check_for_qr_code_errors(self, qr_method, amount, currency, debtor_partner, free_communication, structured_communication):
""" Checks the data before generating a QR-code for the specified qr_method
(this method must have been checked for eligbility by _eligible_for_qr_code() first).
(this method must have been checked for eligbility by _get_error_messages_for_qr() first).
Returns None if no error was found, or a string describing the first error encountered
so that it can be reported to the user.
"""
return None
def _user_can_trust(self):
return super()._user_can_trust() and (
self.env.su
or self.env.user.has_group('account.group_validate_bank_account')
or self.env.user.has_group('base.group_system')
) and (
# Prevent crons from trusting bank accounts (OdooBot), except when loading demo data
self.env.user.id != SUPERUSER_ID
or self.env.context.get('install_mode')
or tools.config['test_enable']
)
@api.model_create_multi
def create(self, vals_list):
# EXTENDS base res.partner.bank
res = super().create(vals_list)
for account in res:
to_trust = [vals.get('allow_out_payment') for vals in vals_list]
for vals in vals_list:
vals['allow_out_payment'] = False
for vals in vals_list:
if (partner_id := vals.get('partner_id')) and (acc_number := vals.get('acc_number')):
archived_res_partner_bank = self.env['res.partner.bank'].search([('active', '=', False), ('partner_id', '=', partner_id), ('acc_number', '=', acc_number)])
if archived_res_partner_bank:
raise UserError(_("A bank account with Account Number %(number)s already exists for Partner %(partner)s, but is archived. Please unarchive it instead.", number=acc_number, partner=archived_res_partner_bank.partner_id.name))
accounts = super().create(vals_list)
for account, trust in zip(accounts, to_trust):
if trust and account._user_can_trust():
account.allow_out_payment = True
msg = _("Bank Account %s created", account._get_html_link(title=f"#{account.id}"))
account.partner_id._message_log(body=msg)
return res
return accounts
def write(self, vals):
# EXTENDS base res.partner.bank
@ -194,8 +321,37 @@ class ResPartnerBank(models.Model):
# Group initial values by partner_id
account_initial_values[account][field] = account[field]
# Some fields should not be editable based on conditions. It is enforced in the view, but not in python which
# leaves them vulnerable to edits via the shell/... So we need to ensure that the user has the rights to edit
# these fields when writing too.
# While we do lock changes if the account is trusted, we still want to allow to change them if we go from not trusted -> trusted or from trusted -> not trusted.
trusted_accounts = self.filtered(lambda x: x.lock_trust_fields)
if not trusted_accounts:
should_allow_changes = True # If we were on a non-trusted account, we will allow to change (setting/... one last time before trusting)
else:
# If we were on a trusted account, we only allow changes if the account is moving to untrusted.
should_allow_changes = self.env.su or ('allow_out_payment' in vals and vals['allow_out_payment'] is False)
lock_fields = {'acc_number', 'sanitized_acc_number', 'partner_id', 'acc_type'}
if not should_allow_changes and any(
account[fname] != account._fields[fname].convert_to_record(
account._fields[fname].convert_to_cache(vals[fname], account),
account,
)
for fname in lock_fields & set(vals)
for account in trusted_accounts
):
raise UserError(_("You cannot modify the account number or partner of an account that has been trusted."))
if 'allow_out_payment' in vals and any(not bank._user_can_trust() for bank in self):
raise UserError(_("You do not have the rights to trust or un-trust accounts."))
res = super().write(vals)
# Check
if "allow_out_payment" in vals:
self._check_allow_out_payment()
# Log changes to move lines on each move
for account, initial_values in account_initial_values.items():
tracking_value_ids = account._mail_track(fields_definition, initial_values)[1]
@ -209,16 +365,30 @@ class ResPartnerBank(models.Model):
def unlink(self):
# EXTENDS base res.partner.bank
for account in self:
msg = _("Bank Account %s with number %s deleted", account._get_html_link(title=f"#{account.id}"), account.acc_number)
msg = _("Bank Account %(link)s with number %(number)s archived", link=account._get_html_link(title=f"#{account.id}"), number=account.acc_number)
account.partner_id._message_log(body=msg)
return super().unlink()
def default_get(self, fields_list):
if 'acc_number' not in fields_list:
return super().default_get(fields_list)
@api.model
def default_get(self, fields):
if 'acc_number' not in fields:
return super().default_get(fields)
# When create & edit, `name` could be used to pass (in the context) the
# value input by the user. However, we want to set the default value of
# `acc_number` variable instead.
default_acc_number = self._context.get('default_acc_number', False) or self._context.get('default_name', False)
return super(ResPartnerBank, self.with_context(default_acc_number=default_acc_number)).default_get(fields_list)
default_acc_number = self.env.context.get('default_acc_number', False) or self.env.context.get('default_name', False)
return super(ResPartnerBank, self.with_context(default_acc_number=default_acc_number)).default_get(fields)
@api.depends('allow_out_payment', 'acc_number', 'bank_id')
@api.depends_context('display_account_trust')
def _compute_display_name(self):
super()._compute_display_name()
if self.env.context.get('display_account_trust'):
for acc in self:
trusted_label = _('trusted') if acc.allow_out_payment else _('untrusted')
if acc.bank_id:
name = f'{acc.acc_number} - {acc.bank_id.name} ({trusted_label})'
else:
name = f'{acc.acc_number} ({trusted_label})'
acc.display_name = name

View file

@ -5,29 +5,7 @@ from odoo import api, models, _
from odoo.exceptions import ValidationError
class Users(models.Model):
_inherit = "res.users"
@api.constrains('groups_id')
def _check_one_user_type(self):
super(Users, self)._check_one_user_type()
g1 = self.env.ref('account.group_show_line_subtotals_tax_included', False)
g2 = self.env.ref('account.group_show_line_subtotals_tax_excluded', False)
if not g1 or not g2:
# A user cannot be in a non-existant group
return
for user in self:
if user._has_multiple_groups([g1.id, g2.id]):
raise ValidationError(_("A user cannot have both Tax B2B and Tax B2C.\n"
"You should go in General Settings, and choose to display Product Prices\n"
"either in 'Tax-Included' or in 'Tax-Excluded' mode\n"
"(or switch twice the mode if you are already in the desired one)."))
class GroupsView(models.Model):
class ResGroups(models.Model):
_inherit = 'res.groups'
@api.model
@ -35,9 +13,26 @@ class GroupsView(models.Model):
# Overridden in order to remove 'Show Full Accounting Features' and
# 'Show Full Accounting Features - Readonly' in the 'res.users' form view to prevent confusion
group_account_user = self.env.ref('account.group_account_user', raise_if_not_found=False)
if group_account_user and group_account_user.category_id.xml_id == 'base.module_category_hidden':
if group_account_user and not group_account_user.privilege_id:
domain += [('id', '!=', group_account_user.id)]
group_account_readonly = self.env.ref('account.group_account_readonly', raise_if_not_found=False)
if group_account_readonly and group_account_readonly.category_id.xml_id == 'base.module_category_hidden':
if group_account_readonly and not group_account_readonly.privilege_id:
domain += [('id', '!=', group_account_readonly.id)]
group_account_basic = self.env.ref('account.group_account_basic', raise_if_not_found=False)
if group_account_basic and not group_account_basic.privilege_id:
domain += [('id', '!=', group_account_basic.id)]
return super().get_application_groups(domain)
@api.model
def _activate_group_account_secured(self):
group_account_secured = self.env.ref('account.group_account_secured', raise_if_not_found=False)
if not group_account_secured:
return
groups_with_access = [
'account.group_account_readonly',
'account.group_account_invoice',
]
for group_name in groups_with_access:
group = self.env.ref(group_name, raise_if_not_found=False)
if group:
group.sudo()._apply_group(group_account_secured)

View file

@ -4,11 +4,14 @@ from datetime import date
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.tools.misc import format_date
from odoo.tools import frozendict, date_utils
from odoo.tools import frozendict, date_utils, index_exists, SQL
import logging
import re
from collections import defaultdict
from psycopg2 import sql
from psycopg2 import errors as pgerrors
_logger = logging.getLogger(__name__)
class SequenceMixin(models.AbstractModel):
@ -24,10 +27,22 @@ class SequenceMixin(models.AbstractModel):
_sequence_field = "name"
_sequence_date_field = "date"
_sequence_index = False
_sequence_year_range_regex = r'^(?:(?P<prefix1>.*?)(?P<year>((?<=\D)|(?<=^))((19|20|21)\d{2}|(\d{2}(?=\D))))(?P<prefix2>\D)(?P<year_end>((?<=\D)|(?<=^))((19|20|21)\d{2}|(\d{2}(?=\D))))(?P<prefix3>\D+?))?(?P<seq>\d*)(?P<suffix>\D*?)$'
_sequence_monthly_regex = r'^(?P<prefix1>.*?)(?P<year>((?<=\D)|(?<=^))((19|20|21)\d{2}|(\d{2}(?=\D))))(?P<prefix2>\D*?)(?P<month>(0[1-9]|1[0-2]))(?P<prefix3>\D+?)(?P<seq>\d*)(?P<suffix>\D*?)$'
_sequence_yearly_regex = r'^(?P<prefix1>.*?)(?P<year>((?<=\D)|(?<=^))((19|20|21)?\d{2}))(?P<prefix2>\D+?)(?P<seq>\d*)(?P<suffix>\D*?)$'
_sequence_fixed_regex = r'^(?P<prefix1>.*?)(?P<seq>\d{0,9})(?P<suffix>\D*?)$'
prefix = r'(?P<prefix1>.*?)'
prefix2 = r'(?P<prefix2>\D)'
prefix3 = r'(?P<prefix3>\D+?)'
seq = r'(?P<seq>\d*)'
month = r'(?P<month>(0[1-9]|1[0-2]))'
# `(19|20|21)` is for catching 19 20 and 21 century prefixes
year = r'(?P<year>((?<=\D)|(?<=^))((19|20|21)\d{2}|(\d{2}(?=\D))))'
year_end = r'(?P<year_end>((?<=\D)|(?<=^))((19|20|21)\d{2}|(\d{2}(?=\D))))'
suffix = r'(?P<suffix>\D*?)'
_sequence_year_range_monthly_regex = fr'^{prefix}{year}{prefix2}{year_end}(?P<prefix3>\D){month}(?P<prefix4>\D+?){seq}{suffix}$'
_sequence_year_range_regex = fr'^(?:{prefix}{year}{prefix2}{year_end}{prefix3})?{seq}{suffix}$'
_sequence_monthly_regex = fr'^{prefix}{year}(?P<prefix2>\D*?){month}{prefix3}{seq}{suffix}$'
_sequence_yearly_regex = fr'^{prefix}(?P<year>((?<=\D)|(?<=^))((19|20|21)?\d{{2}}))(?P<prefix2>\D+?){seq}{suffix}$'
_sequence_fixed_regex = fr'^{prefix}(?P<seq>\d{{0,9}}){suffix}$'
sequence_prefix = fields.Char(compute='_compute_split_sequence', store=True)
sequence_number = fields.Integer(compute='_compute_split_sequence', store=True)
@ -36,34 +51,84 @@ class SequenceMixin(models.AbstractModel):
# Add an index to optimise the query searching for the highest sequence number
if not self._abstract and self._sequence_index:
index_name = self._table + '_sequence_index'
self.env.cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', (index_name,))
if not self.env.cr.fetchone():
self.env.cr.execute(sql.SQL("""
CREATE INDEX {index_name} ON {table} ({sequence_index}, sequence_prefix desc, sequence_number desc, {field});
CREATE INDEX {index2_name} ON {table} ({sequence_index}, id desc, sequence_prefix);
""").format(
sequence_index=sql.Identifier(self._sequence_index),
index_name=sql.Identifier(index_name),
index2_name=sql.Identifier(index_name + "2"),
table=sql.Identifier(self._table),
field=sql.Identifier(self._sequence_field),
if not index_exists(self.env.cr, index_name):
self.env.cr.execute(SQL("""
CREATE INDEX %(index_name)s ON %(table)s (%(sequence_index)s, sequence_prefix desc, sequence_number desc, %(field)s);
CREATE INDEX %(index2_name)s ON %(table)s (%(sequence_index)s, id desc, sequence_prefix);
""",
sequence_index=SQL.identifier(self._sequence_index),
index_name=SQL.identifier(index_name),
index2_name=SQL.identifier(index_name + "2"),
table=SQL.identifier(self._table),
field=SQL.identifier(self._sequence_field),
))
unique_index = self.env.execute_query(SQL(
"""
SELECT 1
FROM pg_class t
JOIN pg_index ix ON t.oid = ix.indrelid
JOIN pg_attribute a ON a.attrelid = t.oid
AND a.attnum = ANY(ix.indkey)
WHERE t.relkind = 'r'
AND t.relname = %(table)s
AND t.relnamespace = current_schema::regnamespace
AND a.attname = %(column)s
AND ix.indisunique
""",
table=self._table,
column=self._sequence_field,
))
if not unique_index:
_logger.warning(
"A unique index for `sequence.mixin` is missing on %s. "
"This will cause duplicated sequences under heavy load.",
self._table
)
def _get_sequence_cache(self):
# To avoid requiring multiple savepoints when generating successive
# sequence numbers within a single transaction, we cache the sequence value
# for the duration of the in-flight transaction.
# The `precommit.data` container is used instead of `cr.cache` to
# reduce the need for manual invalidation and ensure that the
# cache does not survive a commit or rollback.
#
# Before adding an entry for a sequence to this `sequence.mixin` cache,
# the transaction must have locked the corresponding unique constraint,
# typically by successfully updating or inserting a row governed by the
# constraint (note: be mindful of partial constraint clauses).
#
# Entries in the sequence.mixin cache will look like this:
# {
# (<seq_format> , <seq_index> ) : <seq_number>,
# ('2042/04/000000', account.journal(1,)) : 123,
# }
#
# See also:
# - https://postgres.ai/blog/20210831-postgresql-subtransactions-considered-harmful
# - the documentation in _locked_increment()
return self.env.cr.precommit.data.setdefault('sequence.mixin', {})
def write(self, vals):
if self._sequence_field in vals and self.env.context.get('clear_sequence_mixin_cache', True):
self._get_sequence_cache().clear()
return super().write(vals)
def _get_sequence_date_range(self, reset):
ref_date = fields.Date.to_date(self[self._sequence_date_field])
if reset in ('year', 'year_range'):
return (date(ref_date.year, 1, 1), date(ref_date.year, 12, 31))
if reset in ('year', 'year_range', 'year_range_month'):
return (date(ref_date.year, 1, 1), date(ref_date.year, 12, 31), None, None)
if reset == 'month':
return date_utils.get_month(ref_date)
return date_utils.get_month(ref_date) + (None, None)
if reset == 'never':
return (date(1, 1, 1), date(9999, 1, 1))
return (date(1, 1, 1), date(9999, 12, 31), None, None)
raise NotImplementedError(reset)
def _must_check_constrains_date_sequence(self):
return True
def _year_match(self, format_value, date):
return format_value == self._truncate_year_to_length(date.year, len(str(format_value)))
def _year_match(self, format_value, year):
return format_value == self._truncate_year_to_length(year, len(str(format_value)))
def _truncate_year_to_length(self, year, length):
return year % (10 ** length)
@ -78,10 +143,10 @@ class SequenceMixin(models.AbstractModel):
format_values = self._get_sequence_format_param(sequence)[1]
sequence_number_reset = self._deduce_sequence_number_reset(sequence)
year_start, year_end = self._get_sequence_date_range(sequence_number_reset)
date_start, date_end, forced_year_start, forced_year_end = self._get_sequence_date_range(sequence_number_reset)
year_match = (
(not format_values["year"] or self._year_match(format_values["year"], year_start))
and (not format_values["year_end"] or self._year_match(format_values["year_end"], year_end))
(not format_values["year"] or self._year_match(format_values["year"], forced_year_start or date_start.year))
and (not format_values["year_end"] or self._year_match(format_values["year_end"], forced_year_end or date_end.year))
)
month_match = not format_values['month'] or format_values['month'] == date.month
return year_match and month_match
@ -106,21 +171,19 @@ class SequenceMixin(models.AbstractModel):
and not record._sequence_matches_date()
):
raise ValidationError(_(
"The %(date_field)s (%(date)s) doesn't match the sequence number of the related %(model)s (%(sequence)s)\n"
"You will need to clear the %(model)s's %(sequence_field)s to proceed.\n"
"In doing so, you might want to resequence your entries in order to maintain a continuous date-based sequence.",
"The %(date_field)s (%(date)s) you've entered isn't aligned with the existing sequence number (%(sequence)s). Clear the sequence number to proceed.\n"
"To maintain date-based sequences, select entries and use the resequence option from the actions menu, available in developer mode.",
date_field=record._fields[record._sequence_date_field]._description_string(self.env),
date=format_date(self.env, date),
sequence=sequence,
date_field=record._fields[record._sequence_date_field]._description_string(self.env),
sequence_field=record._fields[record._sequence_field]._description_string(self.env),
model=self.env['ir.model']._get(record._name).display_name,
))
@api.depends(lambda self: [self._sequence_field])
def _compute_split_sequence(self):
for record in self:
sequence = record[record._sequence_field] or ''
regex = re.sub(r"\?P<\w+>", "?:", record._sequence_fixed_regex.replace(r"?P<seq>", "")) # make the seq the only matching group
# make the seq the only matching group
regex = self._make_regex_non_capturing(record._sequence_fixed_regex.replace(r"?P<seq>", ""))
matching = re.match(regex, sequence)
record.sequence_prefix = sequence[:matching.start(1)]
record.sequence_number = int(matching.group(1) or 0)
@ -134,6 +197,7 @@ class SequenceMixin(models.AbstractModel):
sequence.
"""
for regex, ret_val, requirements in [
(self._sequence_year_range_monthly_regex, 'year_range_month', ['seq', 'year', 'year_end', 'month']),
(self._sequence_monthly_regex, 'month', ['seq', 'month', 'year']),
(self._sequence_year_range_regex, 'year_range', ['seq', 'year', 'year_end']),
(self._sequence_yearly_regex, 'year', ['seq', 'year']),
@ -158,6 +222,22 @@ class SequenceMixin(models.AbstractModel):
r'^(?P<prefix1>.*?)(?P<seq>\d*)(?P<suffix>\D*?)$'
))
def _make_regex_non_capturing(self, regex):
r""" Replace the "named capturing group" found in the regex by
"non-capturing group" instead.
Example:
`^(?P<prefix1>.*?)(?P<seq>\d{0,9})(?P<suffix>\D*?)$` will become
`^(?:.*?)(?:\d{0,9})(?:\D*?)$`
- `(?P<name>...)` = Named capturing groups
- `(?:...)` = Non-capturing group
:param regex: the regex to modify
:return: the modified regex
"""
return re.sub(r"\?P<\w+>", "?:", regex)
def _get_last_sequence_domain(self, relaxed=False):
"""Get the sql domain to retreive the previous sequence number.
@ -184,7 +264,7 @@ class SequenceMixin(models.AbstractModel):
self.ensure_one()
return "00000000"
def _get_last_sequence(self, relaxed=False, with_prefix=None, lock=True):
def _get_last_sequence(self, relaxed=False, with_prefix=None):
"""Retrieve the previous sequence.
This is done by taking the number with the greatest alphabetical value within
@ -197,7 +277,6 @@ class SequenceMixin(models.AbstractModel):
would only work when the numbering makes a new start (domain returns by
_get_last_sequence_domain is [], i.e: a new year).
:param field_name: the field that contains the sequence.
:param relaxed: this should be set to True when a previous request didn't find
something without. This allows to find a pattern from a previous period, and
try to adapt it for the new period.
@ -217,21 +296,12 @@ class SequenceMixin(models.AbstractModel):
param['with_prefix'] = with_prefix
query = f"""
SELECT {{field}} FROM {self._table}
SELECT {self._sequence_field} FROM {self._table}
{where_string}
AND sequence_prefix = (SELECT sequence_prefix FROM {self._table} {where_string} ORDER BY id DESC LIMIT 1)
ORDER BY sequence_number DESC
LIMIT 1
"""
if lock:
query = f"""
UPDATE {self._table} SET write_date = write_date WHERE id = (
{query.format(field='id')}
)
RETURNING {self._sequence_field};
"""
else:
query = query.format(field=self._sequence_field)
self.flush_model([self._sequence_field, 'sequence_number', 'sequence_prefix'])
self.env.cr.execute(query, param)
@ -241,10 +311,12 @@ class SequenceMixin(models.AbstractModel):
"""Get the python format and format values for the sequence.
:param previous: the sequence we want to extract the format from
:return tuple(format, format_values):
format is the format string on which we should call .format()
format_values is the dict of values to format the `format` string
``format.format(**format_values)`` should be equal to ``previous``
tuple(format, format_values)
:returns: a 2-elements tuple with:
- format is the format string on which we should call .format()
- format_values is the dict of values to format the `format` string
``format.format(**format_values)`` should be equal to ``previous``
"""
sequence_number_reset = self._deduce_sequence_number_reset(previous)
regex = self._sequence_fixed_regex
@ -254,6 +326,8 @@ class SequenceMixin(models.AbstractModel):
regex = self._sequence_year_range_regex
elif sequence_number_reset == 'month':
regex = self._sequence_monthly_regex
elif sequence_number_reset == 'year_range_month':
regex = self._sequence_year_range_monthly_regex
format_values = re.match(regex, previous).groupdict()
format_values['seq_length'] = len(format_values['seq'])
format_values['year_length'] = len(format_values.get('year') or '')
@ -276,32 +350,125 @@ class SequenceMixin(models.AbstractModel):
)
return format, format_values
def _locked_increment(self, format_string, format_values):
"""Increment the sequence for the given format, returning the new value.
This method will lock the sequence in the database through its unique
constraint, in order to ensure cross-transactional uniqueness of sequence
numbers. If the sequence is already locked by another transaction, it
will wait until the other one finishes, then grab the next available
number.
Once the sequence has been locked by the transaction, further increments
will rely on a cache, to avoid the need for multiple savepoints
(see implementation comments)
At entry, the sequence record must be governed by the unique constraint,
e.g. for an account.move, it must be in state `posted`, otherwise the lock
won't be taken, and sequence numbers may not be unique when returned.
"""
cache = self._get_sequence_cache()
seq = format_values.pop('seq')
# cache key unique to a sequence: its format string + its sequence index
cache_key = (format_string.format(**format_values, seq=0), self._sequence_index and self[self._sequence_index])
if cache_key in cache:
cache[cache_key] += 1
return format_string.format(**format_values, seq=cache[cache_key])
self.flush_recordset()
with self.env.cr.savepoint(flush=False) as sp:
# By updating a row covered by the sequence's UNIQUE constraint,
# the transaction acquires an exclusive lock on the corresponding
# B-tree index entry. This prevents other transactions from inserting
# the same sequence value. See _bt_doinsert() and _bt_check_unique()
# in the PostgreSQL source code.
#
# This guarantee holds only if the sequence row is currently covered
# by a unique index, so any partial index conditions must be satisfied
# beforehand.
#
# This operation requires a savepoint because, after waiting for the lock,
# the transaction may discover that the new number is already taken,
# resulting in a constraint violation. Such violations cannot be
# cleanly recovered from without a savepoint. In that case, we retry
# until a free number is found.
#
# Unfortunately, repeated savepoints can severely impact performance,
# so we minimize their use. Once the lock is acquired, we rely on a
# transactional cache provided by _get_sequence_cache.
# Because the transaction holds the lock on the initially assigned
# sequence number, other transactions must wait for its completion
# before assigning newer numbers. It is therefore safe to continue
# assigning sequential numbers without additional savepoints.
#
# See also:
# - https://postgres.ai/blog/20210831-postgresql-subtransactions-considered-harmful
# - the documentation of _get_sequence_cache()
while True:
seq += 1
sequence = format_string.format(**format_values, seq=seq)
try:
self.env.cr.execute(SQL(
"UPDATE %(table)s SET %(fname)s = %(sequence)s WHERE id = %(id)s",
table=SQL.identifier(self._table),
fname=SQL.identifier(self._sequence_field),
sequence=sequence,
id=self.id,
), log_exceptions=False)
cache[cache_key] = seq
return sequence
except (pgerrors.ExclusionViolation, pgerrors.UniqueViolation):
sp.rollback()
def _set_next_sequence(self):
"""Set the next sequence.
This method ensures that the field is set both in the ORM and in the database.
This is necessary because we use a database query to get the previous sequence,
and we need that query to always be executed on the latest data.
:param field_name: the field that contains the sequence.
"""
self.ensure_one()
format_string, format_values = self._get_next_sequence_format()
sequence = self._locked_increment(format_string, format_values)
self.with_context(clear_sequence_mixin_cache=False)[self._sequence_field] = sequence
registry = self.env.registry
triggers = registry._field_triggers[self._fields[self._sequence_field]]
for inverse_field, triggered_fields in triggers.items():
for triggered_field in triggered_fields:
if not triggered_field.store or not triggered_field.compute:
continue
for field in registry.field_inverses[inverse_field[0]] if inverse_field else [None]:
self.env.add_to_compute(triggered_field, self[field.name] if field else self)
self._compute_split_sequence()
def _get_next_sequence_format(self):
"""Get the next sequence format and its values.
This method retrieves the last used sequence and determines the next sequence format based on it.
If there is no previous sequence, it initializes a new sequence using the starting sequence format.
:returns: a 2-element tuple with:
- format_string (str): the string on which we should call .format()
- format_values (dict): the dict of values to format ``format_string``
"""
last_sequence = self._get_last_sequence()
new = not last_sequence
if new:
last_sequence = self._get_last_sequence(relaxed=True) or self._get_starting_sequence()
format, format_values = self._get_sequence_format_param(last_sequence)
sequence_number_reset = self._deduce_sequence_number_reset(last_sequence)
format_string, format_values = self._get_sequence_format_param(last_sequence)
if new:
date_start, date_end = self._get_sequence_date_range(sequence_number_reset)
sequence_number_reset = self._deduce_sequence_number_reset(last_sequence)
date_start, date_end, forced_year_start, forced_year_end = self._get_sequence_date_range(sequence_number_reset)
format_values['seq'] = 0
format_values['year'] = self._truncate_year_to_length(date_start.year, format_values['year_length'])
format_values['year_end'] = self._truncate_year_to_length(date_end.year, format_values['year_end_length'])
format_values['month'] = date_start.month
format_values['seq'] = format_values['seq'] + 1
self[self._sequence_field] = format.format(**format_values)
self._compute_split_sequence()
format_values['year'] = self._truncate_year_to_length(forced_year_start or date_start.year, format_values['year_length'])
format_values['year_end'] = self._truncate_year_to_length(forced_year_end or date_end.year, format_values['year_end_length'])
format_values['month'] = self[self._sequence_date_field].month
return format_string, format_values
def _is_last_from_seq_chain(self):
"""Tells whether or not this element is the last one of the sequence chain.

View file

@ -0,0 +1,62 @@
from odoo import models, _
from odoo.addons.account.models.chart_template import template
class AccountChartTemplate(models.AbstractModel):
_inherit = "account.chart.template"
@template('generic_coa')
def _get_generic_coa_template_data(self):
"""Return the data necessary for the chart template.
:return: all the values that are not stored but are used to instancieate
the chart of accounts. Common keys are:
* property_*
* code_digits
:rtype: dict
"""
return {
'name': _("Generic Chart of Accounts"),
'country': None,
'property_account_receivable_id': 'receivable',
'property_account_payable_id': 'payable',
}
@template('generic_coa', 'res.company')
def _get_generic_coa_res_company(self):
"""Return the data to be written on the company.
The data is a mapping the XMLID to the create/write values of a record.
:rtype: dict[(str, int), dict]
"""
return {
self.env.company.id: {
'anglo_saxon_accounting': True,
'account_fiscal_country_id': 'base.us',
'bank_account_code_prefix': '1014',
'cash_account_code_prefix': '1015',
'transfer_account_code_prefix': '1017',
'account_default_pos_receivable_account_id': 'pos_receivable',
'income_currency_exchange_account_id': 'income_currency_exchange',
'expense_currency_exchange_account_id': 'expense_currency_exchange',
'default_cash_difference_income_account_id': 'cash_diff_income',
'default_cash_difference_expense_account_id': 'cash_diff_expense',
'account_journal_early_pay_discount_loss_account_id': 'cash_discount_loss',
'account_journal_early_pay_discount_gain_account_id': 'cash_discount_gain',
'expense_account_id': 'expense',
'income_account_id': 'income',
'account_stock_journal_id': 'inventory_valuation',
'account_stock_valuation_id': 'stock_valuation',
'account_production_wip_account_id': 'wip',
'account_production_wip_overhead_account_id': 'cost_of_production',
},
}
@template('generic_coa', 'account.account')
def _get_generic_coa_account_account(self):
return {
'stock_valuation': {
'account_stock_variation_id': 'stock_variation',
},
}

View file

@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api
UOM_TO_UNECE_CODE = {
'uom.product_uom_unit': 'C62',
'uom.product_uom_dozen': 'DZN',
'uom.product_uom_kgm': 'KGM',
'uom.product_uom_gram': 'GRM',
'uom.product_uom_day': 'DAY',
'uom.product_uom_hour': 'HUR',
'uom.product_uom_minute': 'MIN',
'uom.product_uom_ton': 'TNE',
'uom.product_uom_meter': 'MTR',
'uom.product_uom_km': 'KMT',
'uom.product_uom_cm': 'CMT',
'uom.product_uom_litre': 'LTR',
'uom.product_uom_lb': 'LBR',
'uom.product_uom_oz': 'ONZ',
'uom.product_uom_inch': 'INH',
'uom.product_uom_foot': 'FOT',
'uom.product_uom_mile': 'SMI',
'uom.product_uom_floz': 'OZA',
'uom.product_uom_qt': 'QT',
'uom.product_uom_gal': 'GLL',
'uom.product_uom_cubic_meter': 'MTQ',
'uom.product_uom_cubic_inch': 'INQ',
'uom.product_uom_cubic_foot': 'FTQ',
'uom.uom_square_meter': 'MTK',
'uom.uom_square_foot': 'FTK',
'uom.product_uom_yard': 'YRD',
'uom.product_uom_millimeter': 'MMT',
'uom.product_uom_kwh': 'KWH',
}
class UomUom(models.Model):
_inherit = "uom.uom"
fiscal_country_codes = fields.Char(compute="_compute_fiscal_country_codes")
@api.depends_context("allowed_company_ids")
def _compute_fiscal_country_codes(self):
for record in self:
record.fiscal_country_codes = ",".join(self.env.companies.mapped("account_fiscal_country_id.code"))
def _get_unece_code(self):
""" Returns the UNECE code used for international trading for corresponding to the UoM as per
https://unece.org/fileadmin/DAM/cefact/recommendations/rec20/rec20_rev3_Annex2e.pdf"""
xml_ids = self._get_external_ids().get(self.id, [])
matches = list(set(xml_ids) & set(UOM_TO_UNECE_CODE.keys()))
return matches and UOM_TO_UNECE_CODE[matches[0]] or 'C62'
@api.model
def _get_uom_from_unece_code(self, unece_code):
unece_code_to_uom = {v: k for k, v in UOM_TO_UNECE_CODE.items()}
uom_xmlid = unece_code_to_uom.get(unece_code, 'uom.product_uom_unit')
return self.env.ref(uom_xmlid, raise_if_not_found=False)