mirror of
https://github.com/bringout/oca-ocb-accounting.git
synced 2026-04-22 09:22:07 +02:00
19.0 vanilla
This commit is contained in:
parent
ba20ce7443
commit
768b70e05e
2357 changed files with 1057103 additions and 712486 deletions
|
|
@ -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
|
|
@ -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(''),
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
File diff suppressed because it is too large
Load diff
|
|
@ -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
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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."))
|
||||
|
|
|
|||
39
odoo-bringout-oca-ocb-account/account/models/account_root.py
Normal file
39
odoo-bringout-oca-ocb-account/account/models/account_root.py
Normal 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
|
|
@ -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'):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
11
odoo-bringout-oca-ocb-account/account/models/ir_http.py
Normal file
11
odoo-bringout-oca-ocb-account/account/models/ir_http.py
Normal 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
|
||||
115
odoo-bringout-oca-ocb-account/account/models/ir_module.py
Normal file
115
odoo-bringout-oca-ocb-account/account/models/ir_module.py
Normal 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()
|
||||
41
odoo-bringout-oca-ocb-account/account/models/kpi_provider.py
Normal file
41
odoo-bringout-oca-ocb-account/account/models/kpi_provider.py
Normal 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
|
||||
199
odoo-bringout-oca-ocb-account/account/models/mail_message.py
Normal file
199
odoo-bringout-oca-ocb-account/account/models/mail_message.py
Normal 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)
|
||||
|
|
@ -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."))
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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')
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
}
|
||||
59
odoo-bringout-oca-ocb-account/account/models/uom_uom.py
Normal file
59
odoo-bringout-oca-ocb-account/account/models/uom_uom.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue