This commit is contained in:
Songb
2024-04-28 11:43:49 +08:00
commit 953c76ce8a
4108 changed files with 2312605 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
from . import wizard
import logging
_logger = logging.getLogger(__name__)
def _account_accountant_post_init(env):
country_code = env.company.country_id.code
if country_code:
module_list = []
# SEPA zone countries will be using SEPA
sepa_zone = env.ref('base.sepa_zone', raise_if_not_found=False)
sepa_zone_country_codes = sepa_zone and sepa_zone.mapped('country_ids.code') or []
if country_code in sepa_zone_country_codes:
module_list.append('account_sepa')
module_list.append('account_bank_statement_import_camt')
if country_code in ('AU', 'CA', 'US'):
module_list.append('account_reports_cash_basis')
# The customer statement is customary in Australia and New Zealand.
if country_code in ('AU', 'NZ'):
module_list.append('l10n_account_customer_statements')
module_ids = env['ir.module.module'].search([('name', 'in', module_list), ('state', '=', 'uninstalled')])
if module_ids:
module_ids.sudo().button_install()
for company in env['res.company'].search([('chart_template', '!=', False)]):
ChartTemplate = env['account.chart.template'].with_company(company)
ChartTemplate._load_data({
'res.company': ChartTemplate._get_account_accountant_res_company(company.chart_template),
})
def uninstall_hook(env):
try:
group_user = env.ref("account.group_account_user")
group_user.write({
'name': "Show Full Accounting Features",
'implied_ids': [(3, env.ref('account.group_account_invoice').id)],
'category_id': env.ref("base.module_category_hidden").id,
})
group_readonly = env.ref("account.group_account_readonly")
group_readonly.write({
'name': "Show Full Accounting Features - Readonly",
'category_id': env.ref("base.module_category_hidden").id,
})
except ValueError as e:
_logger.warning(e)
try:
group_manager = env.ref("account.group_account_manager")
group_manager.write({'name': "Billing Manager",
'implied_ids': [(4, env.ref("account.group_account_invoice").id),
(3, env.ref("account.group_account_readonly").id),
(3, env.ref("account.group_account_user").id)]})
except ValueError as e:
_logger.warning(e)
# make the account_accountant features disappear (magic)
env.ref("account.group_account_user").write({'users': [(5, False, False)]})
env.ref("account.group_account_readonly").write({'users': [(5, False, False)]})
# this menu should always be there, as the module depends on account.
# if it's not, there is something wrong with the db that should be investigated.
invoicing_menu = env.ref("account.menu_finance")
menus_to_move = [
"account.menu_finance_receivables",
"account.menu_finance_payables",
"account.menu_finance_entries",
"account.menu_finance_reports",
"account.menu_finance_configuration",
"account.menu_board_journal_1",
]
for menu_xmlids in menus_to_move:
try:
env.ref(menu_xmlids).parent_id = invoicing_menu
except ValueError as e:
_logger.warning(e)
+64
View File
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Accounting',
'version': '1.1',
'category': 'Accounting/Accounting',
'sequence': 30,
'summary': 'Manage financial and analytic accounting',
'description': """
Accounting Access Rights
========================
It gives the Administrator user access to all accounting features such as journal items and the chart of accounts.
It assigns manager and user access rights to the Administrator for the accounting application and only user rights to the Demo user.
""",
'website': 'https://www.odoo.com/app/accounting',
'depends': ['account', 'mail_enterprise', 'web_tour'],
'data': [
'data/account_accountant_data.xml',
'data/ir_cron.xml',
'data/digest_data.xml',
'security/ir.model.access.csv',
'security/account_accountant_security.xml',
'views/account_account_views.xml',
'views/account_fiscal_year_view.xml',
'views/account_journal_dashboard_views.xml',
'views/account_move_views.xml',
'views/account_payment_views.xml',
'views/account_reconcile_views.xml',
'views/account_reconcile_model_views.xml',
'views/account_accountant_menuitems.xml',
'views/digest_views.xml',
'views/res_config_settings_views.xml',
'views/product_views.xml',
'views/bank_rec_widget_views.xml',
'wizard/account_change_lock_date.xml',
'wizard/account_auto_reconcile_wizard.xml',
'wizard/account_reconcile_wizard.xml',
'wizard/reconcile_model_wizard.xml',
],
'demo': ['data/account_accountant_demo.xml'],
'installable': True,
'application': True,
'post_init_hook': '_account_accountant_post_init',
'uninstall_hook': "uninstall_hook",
'license': 'OEEL-1',
'assets': {
'web.assets_backend': [
'account_accountant/static/src/js/tours/account_accountant.js',
'account_accountant/static/src/components/**/*',
'account_accountant/static/src/**/*.xml',
],
'web.assets_tests': [
'account_accountant/static/tests/tours/**/*',
],
'web.qunit_suite_tests': [
'account_accountant/static/tests/*.js',
'account_accountant/static/tests/helpers/*.js',
],
}
}
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Switch root menu "Invoicing" to "Accounting" -->
<!-- Top menu item -->
<menuitem name="Accounting"
id="menu_accounting"
groups="account.group_account_readonly,account.group_account_invoice"
web_icon="account_accountant,static/description/icon.png"
sequence="60"/>
<!-- move existing submenus to point to the new parent -->
<record id="account.menu_finance_receivables" model="ir.ui.menu">
<field name="parent_id" ref="menu_accounting"/>
</record>
<record id="account.menu_finance_payables" model="ir.ui.menu">
<field name="parent_id" ref="menu_accounting"/>
</record>
<record id="account.menu_finance_entries" model="ir.ui.menu">
<field name="parent_id" ref="menu_accounting"/>
</record>
<record id="account.menu_finance_reports" model="ir.ui.menu">
<field name="parent_id" ref="menu_accounting"/>
</record>
<record id="account.menu_finance_configuration" model="ir.ui.menu">
<field name="parent_id" ref="menu_accounting"/>
</record>
<record id="account.menu_board_journal_1" model="ir.ui.menu">
<field name="parent_id" ref="menu_accounting"/>
</record>
<menuitem id="account.menu_account_config" name="Settings" parent="account.menu_finance_configuration" sequence="0" groups="base.group_system"/>
</odoo>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="base.user_demo" model="res.users">
<field name="groups_id" eval="[(4, ref('account.group_account_user'))]"/>
</record>
</odoo>
+37
View File
@@ -0,0 +1,37 @@
<?xml version='1.0' encoding='utf-8'?>
<odoo>
<data noupdate="1">
<record id="digest.digest_digest_default" model="digest.digest">
<field name="kpi_account_bank_cash">True</field>
</record>
</data>
<data noupdate="0">
<record id="digest_tip_account_accountant_0" model="digest.tip">
<field name="name">Tip: Bulk update journal items</field>
<field name="sequence">900</field>
<field name="group_id" ref="account.group_account_user" />
<field name="tip_description" type="html">
<div>
<b class="tip_title">Tip: Bulk update journal items</b>
<p class="tip_content">From any list view, select multiple records and the list becomes editable. If you update a cell, selected records are updated all at once. Use this feature to update multiple journal entries from the General Ledger, or any Journal view.</p>
<img src="https://download.odoocdn.com/digests/account_accountant/static/src/img/accounting-bulk.gif" width="540" class="illustration_border" />
</div>
</field>
</record>
<record id="digest_tip_account_accountant_1" model="digest.tip">
<field name="name">Tip: Find an Accountant or register your Accounting Firm</field>
<field name="sequence">1000</field>
<field name="group_id" ref="account.group_account_user" />
<field name="tip_description" type="html">
<div>
<b class="tip_title">Tip: Find an Accountant or register your Accounting Firm</b>
<p class="tip_content">Click here to find an accountant or if you want to list out your accounting services on Odoo</p>
<p class="mt-3">
<a class="tip_button" href="https://odoo.com/accounting-firms" target="_blank"><span class="tip_button_text">Find an Accountant</span></a>
<a class="tip_button" href="https://odoo.com/accounting-firms/register" target="_blank"><span class="tip_button_text">Register your Accounting Firm</span></a>
</p>
</div>
</field>
</record>
</data>
</odoo>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="auto_reconcile_bank_statement_line" model="ir.cron">
<field name="name">Try to reconcile automatically your statement lines</field>
<field name="model_id" ref="model_account_bank_statement_line"/>
<field name="state">code</field>
<field name="code">model._cron_try_auto_reconcile_statement_lines(batch_size=100)</field>
<field name='interval_number'>1</field>
<field name='interval_type'>days</field>
<field name="numbercall">-1</field>
</record>
</odoo>
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
+17
View File
@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account_account
from . import account_bank_statement
from . import account_chart_template
from . import account_fiscal_year
from . import account_journal_dashboard
from . import account_move
from . import account_payment
from . import account_reconcile_model
from . import account_reconcile_model_line
from . import digest
from . import res_config_settings
from . import res_company
from . import bank_rec_widget
from . import bank_rec_widget_line
@@ -0,0 +1,15 @@
import ast
from odoo import models
class AccountAccount(models.Model):
_inherit = "account.account"
def action_open_reconcile(self):
self.ensure_one()
# Open reconciliation view for this account
action_values = self.env['ir.actions.act_window']._for_xml_id('account_accountant.action_move_line_posted_unreconciled')
domain = ast.literal_eval(action_values['domain'])
domain.append(('account_id', '=', self.id))
action_values['domain'] = domain
return action_values
@@ -0,0 +1,242 @@
from odoo import _, api, fields, models
from odoo.addons.base.models.res_bank import sanitize_account_number
from odoo.exceptions import UserError
from odoo.tools import html2plaintext
from dateutil.relativedelta import relativedelta
from itertools import product
from lxml import etree
from markupsafe import Markup
class AccountBankStatement(models.Model):
_inherit = 'account.bank.statement'
def action_open_bank_reconcile_widget(self):
self.ensure_one()
return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
name=self.name,
default_context={
'default_statement_id': self.id,
'default_journal_id': self.journal_id.id,
},
extra_domain=[('statement_id', '=', self.id)]
)
def action_generate_attachment(self):
ir_actions_report_sudo = self.env['ir.actions.report'].sudo()
statement_report_action = self.env.ref('account.action_report_account_statement')
for statement in self:
statement_report = statement_report_action.sudo()
content, _content_type = ir_actions_report_sudo._render_qweb_pdf(statement_report, res_ids=statement.ids)
statement.attachment_ids |= self.env['ir.attachment'].create({
'name': _("Bank Statement %s.pdf", statement.name) if statement.name else _("Bank Statement.pdf"),
'type': 'binary',
'mimetype': 'application/pdf',
'raw': content,
'res_model': statement._name,
'res_id': statement.id,
})
return statement_report_action.report_action(docids=self)
class AccountBankStatementLine(models.Model):
_inherit = 'account.bank.statement.line'
# Technical field holding the date of the last time the cron tried to auto-reconcile the statement line. Used to
# optimize the bank matching process"
cron_last_check = fields.Datetime()
def action_save_close(self):
return {'type': 'ir.actions.act_window_close'}
def action_save_new(self):
action = self.env['ir.actions.act_window']._for_xml_id('account_accountant.action_bank_statement_line_form_bank_rec_widget')
action['context'] = {'default_journal_id': self._context['default_journal_id']}
return action
####################################################
# RECONCILIATION PROCESS
####################################################
@api.model
def _action_open_bank_reconciliation_widget(self, extra_domain=None, default_context=None, name=None, kanban_first=True):
context = default_context or {}
views = [
(self.env.ref('account_accountant.view_bank_statement_line_kanban_bank_rec_widget').id, 'kanban'),
(self.env.ref('account_accountant.view_bank_statement_line_tree_bank_rec_widget').id, 'list'),
]
helper = Markup("<p class='o_view_nocontent_smiling_face'>{}</p><p>{}</p>").format(
_("Nothing to do here!"),
_("No transactions matching your filters were found."),
)
return {
'name': name or _("Bank Reconciliation"),
'type': 'ir.actions.act_window',
'res_model': 'account.bank.statement.line',
'context': context,
'search_view_id': [self.env.ref('account_accountant.view_bank_statement_line_search_bank_rec_widget').id, 'search'],
'view_mode': 'kanban,list' if kanban_first else 'list,kanban',
'views': views if kanban_first else views[::-1],
'domain': [('state', '!=', 'cancel')] + (extra_domain or []),
'help': helper,
}
def action_open_recon_st_line(self):
self.ensure_one()
return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
name=self.name,
default_context={
'default_statement_id': self.statement_id.id,
'default_journal_id': self.journal_id.id,
'default_st_line_id': self.id,
'search_default_id': self.id,
},
)
def _cron_try_auto_reconcile_statement_lines(self, batch_size=None, limit_time=0):
""" Method called by the CRON to reconcile the statement lines automatically.
:param batch_size: The maximum number of statement lines that could be processed at once by the CRON to avoid
a timeout. If specified, the CRON will be trigger again asap using a CRON trigger in case
there is still some statement lines to process.
limit_time: Maximum time allowed to run in seconds. 0 if the Cron is allowed to run without time limit.
"""
def _compute_st_lines_to_reconcile(configured_company):
# Find the bank statement lines that are not reconciled and try to reconcile them automatically.
# The ones that are never be processed by the CRON before are processed first.
remaining_line_id = None
limit = batch_size + 1 if batch_size else None
domain = [
('is_reconciled', '=', False),
('create_date', '>', start_time.date() - relativedelta(months=3)),
('company_id', 'in', configured_company.ids),
]
query_obj = self._search(domain, limit=limit)
query_obj.order = '"account_bank_statement_line"."cron_last_check" ASC NULLS FIRST,"account_bank_statement_line"."id"'
query_str, query_params = query_obj.select('account_bank_statement_line.id')
self._cr.execute(query_str, query_params)
st_line_ids = [r[0] for r in self._cr.fetchall()]
if batch_size and len(st_line_ids) > batch_size:
remaining_line_id = st_line_ids[batch_size]
st_line_ids = st_line_ids[:batch_size]
st_lines = self.env['account.bank.statement.line'].browse(st_line_ids)
return st_lines, remaining_line_id
start_time = fields.Datetime.now()
self.env['account.reconcile.model'].flush_model()
# Check the companies having at least one reconcile model using the 'auto_reconcile' feature.
query_obj = self.env['account.reconcile.model']._search([
('auto_reconcile', '=', True),
('rule_type', 'in', ('writeoff_suggestion', 'invoice_matching')),
])
query_obj.order = 'company_id'
query_str, query_params = query_obj.select('DISTINCT company_id')
self._cr.execute(query_str, query_params)
configured_company = children_company = self.env['res.company'].browse([r[0] for r in self._cr.fetchall()])
if not configured_company:
return
while children_company := children_company.child_ids:
configured_company += children_company
self.env['account.bank.statement.line'].flush_model()
# we either already have statement lines to reconcile or compute them
st_lines, remaining_line_id = (self, None) if self else _compute_st_lines_to_reconcile(configured_company)
nb_auto_reconciled_lines = 0
for index, st_line in enumerate(st_lines):
# we want the cron to run only for limit_time seconds
if limit_time and fields.Datetime.now().timestamp() - start_time.timestamp() > limit_time:
remaining_line_id = st_line.id
st_lines = st_lines[:index]
break
wizard = self.env['bank.rec.widget'].with_context(default_st_line_id=st_line.id).new({})
wizard._action_trigger_matching_rules()
if wizard.state == 'valid' and wizard.matching_rules_allow_auto_reconcile:
try:
wizard._action_validate()
if st_line.is_reconciled:
st_line.move_id.message_post(body=_(
"This bank transaction has been automatically validated using the reconciliation model '%s'.",
', '.join(st_line.move_id.line_ids.reconcile_model_id.mapped('name')),
))
nb_auto_reconciled_lines += 1
except UserError:
continue
st_lines.write({'cron_last_check': start_time})
# If the next statement line has never been auto reconciled yet, force the trigger.
if remaining_line_id:
remaining_st_line = self.env['account.bank.statement.line'].browse(remaining_line_id)
if nb_auto_reconciled_lines or not remaining_st_line.cron_last_check:
self.env.ref('account_accountant.auto_reconcile_bank_statement_line')._trigger()
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', 'parent_of', 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:
# using 'complete_name' instead of 'name',
# as 'complete_name' is the first search criteria in _rec_names_search,
# and trigram indexed accordingly.
domains = product(
[
('complete_name', '=ilike', self.partner_name),
('complete_name', 'ilike', self.partner_name),
],
[
('company_id', 'parent_of', self.company_id.id),
('company_id', '=', False),
],
)
for domain in domains:
partner = self.env['res.partner'].search(list(domain) + [('parent_id', '=', False)], limit=1)
if partner:
return partner
# Retrieve the partner from the 'reconcile models'.
rec_models = self.env['account.reconcile.model'].search([
*self.env['account.reconcile.model']._check_company_domain(self.company_id),
('rule_type', '!=', 'writeoff_button'),
])
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 _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
@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
from odoo.addons.account.models.chart_template import template
from odoo import models
class AccountChartTemplate(models.AbstractModel):
_inherit = 'account.chart.template'
def _post_load_data(self, template_code, company, template_data):
# Called when installing a Chart of Accounts template in the settings
super()._post_load_data(template_code, company, template_data)
company = company or self.env.company
values = self._get_default_deferred_values(company)
company.deferred_journal_id = values['deferred_journal']
company.deferred_expense_account_id = values['deferred_expense_account']
company.deferred_revenue_account_id = values['deferred_revenue_account']
@template(model='res.company')
def _get_account_accountant_res_company(self, template_code):
# Called when installing the Accountant module
values = self._get_default_deferred_values(self.env.company)
return {
self.env.company.id: {
'deferred_journal_id': values['deferred_journal'].id,
'deferred_expense_account_id': values['deferred_expense_account'].id,
'deferred_revenue_account_id': values['deferred_revenue_account'].id,
}
}
def _get_default_deferred_values(self, company):
journal = company.deferred_journal_id or self.env['account.journal'].search([
*self.env['account.journal']._check_company_domain(company),
('type', '=', 'general')
], limit=1)
expense_account = company.deferred_expense_account_id or self.env['account.account'].search([
*self.env['account.account']._check_company_domain(company),
('account_type', '=', 'asset_current')
], limit=1)
revenue_account = company.deferred_revenue_account_id or self.env['account.account'].search([
*self.env['account.account']._check_company_domain(company),
('account_type', '=', 'liability_current')
], limit=1)
return {
'deferred_journal': journal,
'deferred_expense_account': expense_account,
'deferred_revenue_account': revenue_account,
}
@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
from odoo.exceptions import ValidationError
from odoo import api, fields, models, _
from datetime import datetime
class AccountFiscalYear(models.Model):
_name = 'account.fiscal.year'
_description = 'Fiscal Year'
name = fields.Char(string='Name', required=True)
date_from = fields.Date(string='Start Date', required=True,
help='Start Date, included in the fiscal year.')
date_to = fields.Date(string='End Date', required=True,
help='Ending Date, included in the fiscal year.')
company_id = fields.Many2one('res.company', string='Company', required=True,
default=lambda self: self.env.company)
@api.constrains('date_from', 'date_to', 'company_id')
def _check_dates(self):
'''
Check interleaving between fiscal years.
There are 3 cases to consider:
s1 s2 e1 e2
( [----)----]
s2 s1 e2 e1
[----(----] )
s1 s2 e2 e1
( [----] )
'''
for fy in self:
# Starting date must be prior to the ending date
date_from = fy.date_from
date_to = fy.date_to
if date_to < date_from:
raise ValidationError(_('The ending date must not be prior to the starting date.'))
domain = [
('id', '!=', fy.id),
('company_id', '=', fy.company_id.id),
'|', '|',
'&', ('date_from', '<=', fy.date_from), ('date_to', '>=', fy.date_from),
'&', ('date_from', '<=', fy.date_to), ('date_to', '>=', fy.date_to),
'&', ('date_from', '<=', fy.date_from), ('date_to', '>=', fy.date_to),
]
if self.search_count(domain) > 0:
raise ValidationError(_('You can not have an overlap between two fiscal years, please correct the start and/or end dates of your fiscal years.'))
@@ -0,0 +1,69 @@
from odoo import models
class account_journal(models.Model):
_inherit = "account.journal"
def action_open_reconcile(self):
self.ensure_one()
if self.type in ('bank', 'cash'):
return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
default_context={
'default_journal_id': self.id,
'search_default_journal_id': self.id,
'search_default_not_matched': True,
},
)
else:
# Open reconciliation view for customers/suppliers
return self.env['ir.actions.act_window']._for_xml_id('account_accountant.action_move_line_posted_unreconciled')
def action_open_to_check(self):
self.ensure_one()
return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
default_context={
'search_default_to_check': True,
'search_default_journal_id': self.id,
'default_journal_id': self.id,
},
)
def action_open_bank_transactions(self):
self.ensure_one()
return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
default_context={
'search_default_journal_id': self.id,
'default_journal_id': self.id
},
kanban_first=False,
)
def action_open_reconcile_statement(self):
return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
default_context={
'search_default_statement_id': self.env.context.get('statement_id'),
},
)
def action_new_transaction(self):
self.ensure_one()
action = self.env['ir.actions.act_window']._for_xml_id('account_accountant.action_bank_statement_line_form_bank_rec_widget')
action['context'] = {'default_journal_id': self.id}
return action
def open_action(self):
# EXTENDS account
# set default action for liquidity journals in dashboard
if self.type in ('bank', 'cash') and not self._context.get('action_name'):
self.ensure_one()
return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
extra_domain=[('line_ids.account_id', '=', self.default_account_id.id)],
default_context={
'default_journal_id': self.id,
'search_default_journal_id': self.id,
},
)
return super().open_action()
+759
View File
@@ -0,0 +1,759 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import calendar
from contextlib import contextmanager
from dateutil.relativedelta import relativedelta
import logging
import re
from odoo import fields, models, api, _, Command
from odoo.exceptions import UserError
from odoo.osv import expression
from odoo.tools import frozendict, SQL
_logger = logging.getLogger(__name__)
DEFERRED_DATE_MIN = '1900-01-01'
DEFERRED_DATE_MAX = '9999-12-31'
class AccountMove(models.Model):
_inherit = "account.move"
# Technical field to keep the value of payment_state when switching from invoicing to accounting
# (using invoicing_switch_threshold setting field). It allows keeping the former payment state, so that
# we can restore it if the user misconfigured the switch date and wants to change it.
payment_state_before_switch = fields.Char(string="Payment State Before Switch", copy=False)
# Deferred management fields
deferred_move_ids = fields.Many2many(
string="Deferred Entries",
comodel_name='account.move',
relation='account_move_deferred_rel',
column1='original_move_id',
column2='deferred_move_id',
help="The deferred entries created by this invoice",
copy=False,
)
deferred_original_move_ids = fields.Many2many(
string="Original Invoices",
comodel_name='account.move',
relation='account_move_deferred_rel',
column1='deferred_move_id',
column2='original_move_id',
help="The original invoices that created the deferred entries",
copy=False,
)
deferred_entry_type = fields.Selection(
string="Deferred Entry Type",
selection=[
('expense', 'Deferred Expense'),
('revenue', 'Deferred Revenue'),
],
compute='_compute_deferred_entry_type',
copy=False,
)
@api.model
def _get_invoice_in_payment_state(self):
# OVERRIDE to enable the 'in_payment' state on invoices.
return 'in_payment'
def _post(self, soft=True):
# Deferred management
posted = super()._post(soft)
for move in self:
if move._get_deferred_entries_method() == 'on_validation' and any(move.line_ids.mapped('deferred_start_date')):
move._generate_deferred_entries()
return posted
def action_post(self):
# EXTENDS 'account' to trigger the CRON auto-reconciling the statement lines.
res = super().action_post()
if self.statement_line_id and not self._context.get('skip_statement_line_cron_trigger'):
self.env.ref('account_accountant.auto_reconcile_bank_statement_line')._trigger()
return res
def button_draft(self):
if any(len(deferral_move.deferred_original_move_ids) > 1 for deferral_move in self.deferred_move_ids):
raise UserError(_("You cannot reset to draft an invoice that is grouped in deferral entry. You can create a credit note instead."))
self.deferred_move_ids._unlink_or_reverse()
return super().button_draft()
# ============================= START - Deferred Management ====================================
def _get_deferred_entries_method(self):
self.ensure_one()
if self.is_outbound():
return self.company_id.generate_deferred_expense_entries_method
return self.company_id.generate_deferred_revenue_entries_method
@api.depends('deferred_original_move_ids')
def _compute_deferred_entry_type(self):
for move in self:
if move.deferred_original_move_ids:
move.deferred_entry_type = 'expense' if move.deferred_original_move_ids[0].is_outbound() else 'revenue'
else:
move.deferred_entry_type = False
@api.model
def _get_deferred_diff_dates(self, start, end):
"""
Returns the number of months between two dates [start, end[
The computation is done by using months of 30 days so that the deferred amount for february
(28-29 days), march (31 days) and april (30 days) are all the same (in case of monthly computation).
See test_deferred_management_get_diff_dates for examples.
"""
if start > end:
start, end = end, start
nb_months = end.month - start.month + 12 * (end.year - start.year)
start_day, end_day = start.day, end.day
if start_day == calendar.monthrange(start.year, start.month)[1]:
start_day = 30
if end_day == calendar.monthrange(end.year, end.month)[1]:
end_day = 30
nb_days = end_day - start_day
return (nb_months * 30 + nb_days) / 30
@api.model
def _get_deferred_period_amount(self, method, period_start, period_end, line_start, line_end, balance):
"""
Returns the amount to defer for the given period taking into account the deferred method (day/month).
"""
if method == 'day':
amount_per_day = balance / (line_end - line_start).days
return (period_end - period_start).days * amount_per_day if period_end > line_start else 0
else:
amount_per_month = balance / self._get_deferred_diff_dates(line_end, line_start)
nb_months_period = self._get_deferred_diff_dates(period_end, period_start)
return nb_months_period * amount_per_month if period_end > line_start and period_end > period_start else 0
@api.model
def _get_deferred_amounts_by_line(self, lines, periods):
"""
:return: a list of dictionaries containing the deferred amounts for each line and each period
E.g. (where period1 = (date1, date2), period2 = (date2, date3), ...)
[
{'account_id': 1, period_1: 100, period_2: 200},
{'account_id': 1, period_1: 100, period_2: 200},
{'account_id': 2, period_1: 300, period_2: 400},
]
"""
values = []
for line in lines:
line_start = fields.Date.to_date(line['deferred_start_date'])
line_end = fields.Date.to_date(line['deferred_end_date'])
later_date = fields.Date.to_date(DEFERRED_DATE_MAX)
if line_end < line_start:
# This normally shouldn't happen, but if it does, would cause calculation errors later on.
# To not make the reports crash, we just set both dates to the same day.
# The user should fix the dates manually.
line_end = line_start
columns = {}
for i, period in enumerate(periods):
# periods = [Total, Before, ..., Current, ..., Later]
# The dates to calculate the amount for the current period
period_start = max(period[0], line_start)
period_end = min(period[1], line_end)
if (
period[1] == later_date and period[0] < line_start
or len(periods) <= 1
or i not in (1, len(periods) - 1)
):
# We are subtracting 1 day to `period_start` because the start date should be included when:
# - in the 'Later' period if the deferral has not started yet (line_start, line_end)
# - we only have one period
# - not in the 'Before' or 'Later' period
period_start -= relativedelta(days=1)
columns[period] = self._get_deferred_period_amount(
self.env.company.deferred_amount_computation_method,
period_start, period_end,
line_start - relativedelta(days=1), line_end, # -1 because we want to include the start date
line['balance']
)
values.append({
**self.env['account.move.line']._get_deferred_amounts_by_line_values(line),
**columns,
})
return values
@api.model
def _get_deferred_lines(self, line, deferred_account, period, ref, force_balance=None):
"""
:return: a list of Command objects to create the deferred lines of a single given period
"""
deferred_amounts = self._get_deferred_amounts_by_line(line, [period])[0]
balance = deferred_amounts[period] if force_balance is None else force_balance
return [
Command.create(
self.env['account.move.line']._get_deferred_lines_values(account.id, coeff * balance, ref, line.analytic_distribution, line)
)
for (account, coeff) in [(deferred_amounts['account_id'], 1), (deferred_account, -1)]
]
def _generate_deferred_entries(self):
"""
Generates the deferred entries for the invoice.
"""
self.ensure_one()
if self.is_entry():
raise UserError(_("You cannot generate deferred entries for a miscellaneous journal entry."))
assert not self.deferred_move_ids, "The deferred entries have already been generated for this document."
is_deferred_expense = self.is_purchase_document()
deferred_account = self.company_id.deferred_expense_account_id if is_deferred_expense else self.company_id.deferred_revenue_account_id
deferred_journal = self.company_id.deferred_journal_id
if not deferred_journal:
raise UserError(_("Please set the deferred journal in the accounting settings."))
if not deferred_account:
raise UserError(_("Please set the deferred accounts in the accounting settings."))
for line in self.line_ids.filtered(lambda l: l.deferred_start_date and l.deferred_end_date):
periods = line._get_deferred_periods()
if not periods:
continue
ref = _("Deferral of %s", line.move_id.name or '')
# Defer the current invoice
move_fully_deferred = self.create({
'move_type': 'entry',
'deferred_original_move_ids': [Command.set(line.move_id.ids)],
'journal_id': deferred_journal.id,
'company_id': self.company_id.id,
'date': line.move_id.invoice_date + relativedelta(day=31),
'auto_post': 'at_date',
'ref': ref,
})
# We write the lines after creation, to make sure the `deferred_original_move_ids` is set.
# This way we can avoid adding taxes for deferred moves.
move_fully_deferred.write({
'line_ids': [
Command.create(
self.env['account.move.line']._get_deferred_lines_values(account.id, coeff * line.balance, ref, line.analytic_distribution, line)
) for (account, coeff) in [(line.account_id, -1), (deferred_account, 1)]
],
})
line.move_id.deferred_move_ids |= move_fully_deferred
move_fully_deferred._post(soft=True)
# Create the deferred entries for the periods [deferred_start_date, deferred_end_date]
remaining_balance = line.balance
for period_index, period in enumerate(periods):
deferred_move = self.create({
'move_type': 'entry',
'deferred_original_move_ids': [Command.set(line.move_id.ids)],
'journal_id': deferred_journal.id,
'company_id': self.company_id.id,
'date': period[1],
'auto_post': 'at_date',
'ref': ref,
})
# For the last deferral move the balance is forced to remaining balance to avoid rounding errors
force_balance = remaining_balance if period_index == len(periods) - 1 else None
# Same as before, to avoid adding taxes for deferred moves.
deferred_move.write({
'line_ids': self._get_deferred_lines(line, deferred_account, period, ref, force_balance=force_balance),
})
remaining_balance -= deferred_move.line_ids[0].balance
line.move_id.deferred_move_ids |= deferred_move
deferred_move._post(soft=True)
def open_deferred_entries(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _("Deferred Entries"),
'res_model': 'account.move.line',
'domain': [('id', 'in', self.deferred_move_ids.line_ids.ids)],
'views': [(False, 'tree'), (False, 'form')],
'context': {
'search_default_group_by_move': True,
'expand': True,
}
}
def open_deferred_original_entry(self):
self.ensure_one()
action = {
'type': 'ir.actions.act_window',
'name': _("Original Deferred Entries"),
'res_model': 'account.move.line',
'domain': [('id', 'in', self.deferred_original_move_ids.line_ids.ids)],
'views': [(False, 'tree'), (False, 'form')],
'context': {
'search_default_group_by_move': True,
'expand': True,
}
}
if len(self.deferred_original_move_ids) == 1:
action.update({
'res_model': 'account.move',
'res_id': self.deferred_original_move_ids[0].id,
'views': [(False, 'form')],
})
return action
# ============================= END - Deferred management ======================================
def action_open_bank_reconciliation_widget(self):
return self.statement_line_id._action_open_bank_reconciliation_widget(
default_context={
'search_default_journal_id': self.statement_line_id.journal_id.id,
'search_default_statement_line_id': self.statement_line_id.id,
'default_st_line_id': self.statement_line_id.id,
}
)
def action_open_bank_reconciliation_widget_statement(self):
return self.statement_line_id._action_open_bank_reconciliation_widget(
extra_domain=[('statement_id', 'in', self.statement_id.ids)],
)
def action_open_business_doc(self):
if self.statement_line_id:
return self.action_open_bank_reconciliation_widget()
else:
action = super().action_open_business_doc()
# prevent propagation of the following keys
action['context'] = action.get('context', {}) | {
'preferred_aml_value': None,
'preferred_aml_currency_id': None,
}
return action
def _get_mail_thread_data_attachments(self):
res = super()._get_mail_thread_data_attachments()
res += self.statement_line_id.statement_id.attachment_ids
return res
@contextmanager
def _get_edi_creation(self):
with super()._get_edi_creation() as move:
previous_lines = move.invoice_line_ids
yield move
for line in move.invoice_line_ids - previous_lines:
line._onchange_name_predictive()
class AccountMoveLine(models.Model):
_name = "account.move.line"
_inherit = "account.move.line"
move_attachment_ids = fields.One2many('ir.attachment', compute='_compute_attachment')
# Deferred management fields
deferred_start_date = fields.Date(
string="Start Date",
compute='_compute_deferred_start_date', store=True, readonly=False,
index='btree_not_null',
copy=False,
help="Date at which the deferred expense/revenue starts"
)
deferred_end_date = fields.Date(
string="End Date",
index='btree_not_null',
copy=False,
help="Date at which the deferred expense/revenue ends"
)
has_deferred_moves = fields.Boolean(compute='_compute_has_deferred_moves')
def _order_to_sql(self, order, query, alias=None, reverse=False):
sql_order = super()._order_to_sql(order, query, alias, reverse)
preferred_aml_residual_value = self._context.get('preferred_aml_value')
preferred_aml_currency_id = self._context.get('preferred_aml_currency_id')
if preferred_aml_residual_value and preferred_aml_currency_id and order == self._order:
currency = self.env['res.currency'].browse(preferred_aml_currency_id)
# using round since currency.round(55.55) = 55.550000000000004
preferred_aml_residual_value = round(preferred_aml_residual_value, currency.decimal_places)
sql_residual_currency = self._field_to_sql(alias or self._table, 'amount_residual_currency', query)
sql_currency = self._field_to_sql(alias or self._table, 'currency_id', query)
return SQL(
"ROUND(%(residual_currency)s, %(decimal_places)s) = %(value)s "
"AND %(currency)s = %(currency_id)s DESC, %(order)s",
residual_currency=sql_residual_currency,
decimal_places=currency.decimal_places,
value=preferred_aml_residual_value,
currency=sql_currency,
currency_id=currency.id,
order=sql_order,
)
return sql_order
def copy_data(self, default=None):
data_list = super().copy_data(default=default)
for line, values in zip(self, data_list):
if 'move_reverse_cancel' in self._context:
values['deferred_start_date'] = line.deferred_start_date
values['deferred_end_date'] = line.deferred_end_date
return data_list
def write(self, vals):
""" Prevent changing the account of a move line when there are already deferral entries.
"""
if 'account_id' in vals:
for line in self:
if (
line.has_deferred_moves
and line.deferred_start_date
and line.deferred_end_date
and vals['account_id'] != line.account_id.id
):
raise UserError(_(
"You cannot change the account for a deferred line in %(move_name)s if it has already been deferred.",
move_name=line.move_id.display_name
))
return super().write(vals)
# ============================= START - Deferred management ====================================
def _compute_has_deferred_moves(self):
for line in self:
line.has_deferred_moves = line.move_id.deferred_move_ids
def _is_compatible_account(self):
self.ensure_one()
return (
self.move_id.is_purchase_document()
and
self.account_id.account_type in ('expense', 'expense_depreciation', 'expense_direct_cost')
) or (
self.move_id.is_sale_document()
and
self.account_id.account_type in ('income', 'income_other')
)
@api.onchange('deferred_start_date')
def _onchange_deferred_start_date(self):
if not self._is_compatible_account():
self.deferred_start_date = False
@api.onchange('deferred_end_date')
def _onchange_deferred_end_date(self):
if not self._is_compatible_account():
self.deferred_end_date = False
@api.depends('deferred_end_date', 'move_id.invoice_date')
def _compute_deferred_start_date(self):
for line in self:
if not line.deferred_start_date and line.move_id.invoice_date and line.deferred_end_date:
line.deferred_start_date = line.move_id.invoice_date
@api.constrains('deferred_start_date', 'deferred_end_date', 'account_id')
def _check_deferred_dates(self):
for line in self:
if line.deferred_start_date and not line.deferred_end_date:
raise UserError(_("You cannot create a deferred entry with a start date but no end date."))
elif line.deferred_start_date and line.deferred_end_date and line.deferred_start_date > line.deferred_end_date:
raise UserError(_("You cannot create a deferred entry with a start date later than the end date."))
@api.model
def _get_deferred_tax_key(self, line, tax_key, tax_repartition_line_id):
if (
line.deferred_start_date
and line.deferred_end_date
and line._is_compatible_account()
and tax_repartition_line_id
and not tax_repartition_line_id.use_in_tax_closing
):
return frozendict(
**tax_key,
deferred_start_date=line.deferred_start_date,
deferred_end_date=line.deferred_end_date,
)
return tax_key
@api.depends('deferred_start_date', 'deferred_end_date')
def _compute_tax_key(self):
super()._compute_tax_key()
for line in self:
line.tax_key = self._get_deferred_tax_key(line, line.tax_key, line.tax_repartition_line_id)
@api.depends('deferred_start_date', 'deferred_end_date')
def _compute_all_tax(self):
super()._compute_all_tax()
for line in self:
for key in list(line.compute_all_tax.keys()):
tax_repartition_line_id = self.env['account.tax.repartition.line'].browse(key.get('tax_repartition_line_id'))
new_key = self._get_deferred_tax_key(line, key, tax_repartition_line_id)
line.compute_all_tax[new_key] = line.compute_all_tax.pop(key)
@api.model
def _get_deferred_ends_of_month(self, start_date, end_date):
"""
:return: a list of dates corresponding to the end of each month between start_date and end_date.
See test_get_ends_of_month for examples.
"""
dates = []
while start_date <= end_date:
start_date = start_date + relativedelta(day=31) # Go to end of month
dates.append(start_date)
start_date = start_date + relativedelta(days=1) # Go to first day of next month
return dates
def _get_deferred_periods(self):
"""
:return: a list of tuples (start_date, end_date) during which the deferred expense/revenue is spread.
If there is only one period containing the move date, it means that we don't need to defer the
expense/revenue since the invoice deferral and its deferred entry will be created on the same day and will
thus cancel each other.
"""
self.ensure_one()
periods = [
(max(self.deferred_start_date, date.replace(day=1)), min(date, self.deferred_end_date))
for date in self._get_deferred_ends_of_month(self.deferred_start_date, self.deferred_end_date)
]
if not periods or len(periods) == 1 and periods[0][0].replace(day=1) == self.date.replace(day=1):
return []
else:
return periods
@api.model
def _get_deferred_amounts_by_line_values(self, line):
return {
'account_id': line['account_id'],
'balance': line['balance'],
'move_id': line['move_id'],
}
@api.model
def _get_deferred_lines_values(self, account_id, balance, ref, analytic_distribution, line=None):
return {
'account_id': account_id,
'balance': balance,
'name': ref,
'analytic_distribution': analytic_distribution,
}
# ============================= END - Deferred management ====================================
def _get_computed_taxes(self):
if self.move_id.deferred_original_move_ids:
# If this line is part of a deferral move, do not (re)calculate its taxes automatically.
# Doing so might unvoluntarily impact the tax report in deferral moves (if a default tax is set on the account).
return self.tax_ids
return super()._get_computed_taxes()
def _compute_attachment(self):
for record in self:
record.move_attachment_ids = self.env['ir.attachment'].search(expression.OR(record._get_attachment_domains()))
def action_reconcile(self):
""" This function is called by the 'Reconcile' button of account.move.line's
tree view. It performs reconciliation between the selected lines.
- If the reconciliation can be done directly we do it silently
- Else, if a write-off is required we open the wizard to let the client enter required information
"""
wizard = self.env['account.reconcile.wizard'].with_context(
active_model='account.move.line',
active_ids=self.ids,
).new({})
return wizard._action_open_wizard() if wizard.is_write_off_required else wizard.reconcile()
def _get_predict_postgres_dictionary(self):
lang = self._context.get('lang') and self._context.get('lang')[:2]
return {'fr': 'french'}.get(lang, 'english')
def _build_predictive_query(self, additional_domain=None):
move_query = self.env['account.move']._where_calc([
('move_type', '=', self.move_id.move_type),
('state', '=', 'posted'),
('partner_id', '=', self.move_id.partner_id.id),
('company_id', '=', self.move_id.journal_id.company_id.id or self.env.company.id),
])
move_query.order = 'account_move.invoice_date'
move_query.limit = int(self.env["ir.config_parameter"].sudo().get_param(
"account.bill.predict.history.limit",
'100',
))
return self.env['account.move.line']._where_calc([
('move_id', 'in', move_query),
('display_type', '=', 'product'),
] + (additional_domain or []))
def _predicted_field(self, field, query=None, additional_queries=None):
r"""Predict the most likely value based on the previous history.
This method uses postgres tsvector in order to try to deduce a field of
an invoice line based on the text entered into the name (description)
field and the partner linked.
We only limit the search on the previous 100 entries, which according
to our tests bore the best results. However this limit parameter is
configurable by creating a config parameter with the key:
account.bill.predict.history.limit
For information, the tests were executed with a dataset of 40 000 bills
from a live database, We split the dataset in 2, removing the 5000 most
recent entries and we tried to use this method to guess the account of
this validation set based on the previous entries.
The result is roughly 90% of success.
:param field (str): the sql column that has to be predicted.
/!\ it is injected in the query without any checks.
:param query (osv.Query): the query object on account.move.line that is
used to do the ranking, containing the right domain, limit, etc. If
it is omitted, a default query is used.
:param additional_queries (list<str>): can be used in addition to the
default query on account.move.line to fetch data coming from other
tables, to have starting values for instance.
/!\ it is injected in the query without any checks.
"""
if not self.name or not self.partner_id:
return False
psql_lang = self._get_predict_postgres_dictionary()
description = self.name + ' account_move_line' # give more priority to main query than additional queries
parsed_description = re.sub(r"[*&()|!':<>=%/~@,.;$\[\]]+", " ", description)
parsed_description = ' | '.join(parsed_description.split())
from_clause, where_clause, params = (query if query is not None else self._build_predictive_query()).get_sql()
mask_from_clause, mask_where_clause, mask_params = self._build_predictive_query().get_sql()
try:
account_move_line = self.env.cr.mogrify(
f"SELECT account_move_line.* FROM {mask_from_clause} WHERE {mask_where_clause}",
mask_params,
).decode()
group_by_clause = ""
if "(" in field: # aggregate function
group_by_clause = "GROUP BY account_move_line.id, account_move_line.name, account_move_line.partner_id"
self.env.cr.execute(f"""
WITH account_move_line AS MATERIALIZED ({account_move_line}),
source AS ({'(' + ') UNION ALL ('.join([self.env.cr.mogrify(f'''
SELECT {field} AS prediction,
setweight(to_tsvector(%%(lang)s, account_move_line.name), 'B')
|| setweight(to_tsvector('simple', 'account_move_line'), 'A') AS document
FROM {from_clause}
WHERE {where_clause}
{group_by_clause}
''', params).decode()] + (additional_queries or [])) + ')'}
),
ranking AS (
SELECT prediction, ts_rank(source.document, query_plain) AS rank
FROM source, to_tsquery(%(lang)s, %(description)s) query_plain
WHERE source.document @@ query_plain
)
SELECT prediction, MAX(rank) AS ranking, COUNT(*)
FROM ranking
GROUP BY prediction
ORDER BY ranking DESC, count DESC
""", {
'lang': psql_lang,
'description': parsed_description,
})
result = self.env.cr.dictfetchone()
if result:
return result['prediction']
except Exception:
# In case there is an error while parsing the to_tsquery (wrong character for example)
# We don't want to have a blocking traceback, instead return False
_logger.exception('Error while predicting invoice line fields')
return False
def _predict_taxes(self):
field = 'array_agg(account_move_line__tax_rel__tax_ids.id ORDER BY account_move_line__tax_rel__tax_ids.id)'
query = self._build_predictive_query()
query.left_join('account_move_line', 'id', 'account_move_line_account_tax_rel', 'account_move_line_id', 'tax_rel')
query.left_join('account_move_line__tax_rel', 'account_tax_id', 'account_tax', 'id', 'tax_ids')
query.add_where('account_move_line__tax_rel__tax_ids.active IS NOT FALSE')
predicted_tax_ids = self._predicted_field(field, query)
if predicted_tax_ids == [None]:
return False
if predicted_tax_ids is not False and set(predicted_tax_ids) != set(self.tax_ids.ids):
return predicted_tax_ids
return False
def _predict_product(self):
predict_product = int(self.env['ir.config_parameter'].sudo().get_param('account_predictive_bills.predict_product', '1'))
if predict_product and self.company_id.predict_bill_product:
query = self._build_predictive_query(['|', ('product_id', '=', False), ('product_id.active', '=', True)])
predicted_product_id = self._predicted_field('account_move_line.product_id', query)
if predicted_product_id and predicted_product_id != self.product_id.id:
return predicted_product_id
return False
def _predict_account(self):
field = 'account_move_line.account_id'
if self.move_id.is_purchase_document(True):
excluded_group = 'income'
else:
excluded_group = 'expense'
account_query = self.env['account.account']._where_calc([
*self.env['account.account']._check_company_domain(self.move_id.company_id or self.env.company),
('deprecated', '=', False),
('internal_group', '!=', excluded_group),
])
psql_lang = self._get_predict_postgres_dictionary()
additional_queries = [self.env.cr.mogrify(*account_query.select(
"account_account.id AS account_id",
SQL("setweight(to_tsvector(%s, name), 'B') AS document", psql_lang),
)).decode()]
query = self._build_predictive_query([('account_id', 'in', account_query)])
predicted_account_id = self._predicted_field(field, query, additional_queries)
if predicted_account_id and predicted_account_id != self.account_id.id:
return predicted_account_id
return False
@api.onchange('name')
def _onchange_name_predictive(self):
if ((self.move_id.quick_edit_mode or self.move_id.move_type == 'in_invoice') and self.name and self.display_type == 'product'
and not self.env.context.get('disable_onchange_name_predictive', False)):
if not self.product_id:
predicted_product_id = self._predict_product()
if predicted_product_id:
name = self.name
self.product_id = predicted_product_id
self.name = name
# In case no product has been set, the account and taxes
# will not depend on any product and can thus be predicted
if not self.product_id:
# Predict account.
predicted_account_id = self._predict_account()
if predicted_account_id:
self.account_id = predicted_account_id
# Predict taxes
predicted_tax_ids = self._predict_taxes()
if predicted_tax_ids:
self.tax_ids = [Command.set(predicted_tax_ids)]
def _read_group_groupby(self, groupby_spec, query):
# enable grouping by :abs_rounded on fields, which is useful when trying
# to match positive and negative amounts
if ':' in groupby_spec:
fname, method = groupby_spec.split(':')
if fname in self and method == 'abs_rounded': # field in self avoids possible injections
# rounds with the used currency settings
sql_field = self._field_to_sql(self._table, fname, query)
currency_alias = query.left_join(self._table, 'currency_id', 'res_currency', 'id', 'currency_id')
sql_decimal = self.env['res.currency']._field_to_sql(currency_alias, 'decimal_places', query)
sql_group = SQL('ROUND(ABS(%s), %s)', sql_field, sql_decimal)
return sql_group, [fname, 'currency_id']
return super()._read_group_groupby(groupby_spec, query)
def _read_group_having(self, having_domain, query):
# Enable to use HAVING clause that sum rounded values depending on the
# currency precision settings. Limitation: we only handle a having
# clause of one element with that specific method :sum_rounded.
if len(having_domain) == 1:
left, operator, right = having_domain[0]
fname, *funcs = left.split(':')
if fname in self and funcs == ['sum_rounded']: # fname in self avoids possible injections
sql_field = self._field_to_sql(self._table, fname, query)
currency_alias = query.left_join(self._table, 'currency_id', 'res_currency', 'id', 'currency_id')
sql_decimal = self.env['res.currency']._field_to_sql(currency_alias, 'decimal_places', query)
sql_operator = expression.SQL_OPERATORS[operator]
sql_expr = SQL(
'SUM(ROUND(%s, %s)) %s %s',
sql_field, sql_decimal, sql_operator, right,
)
return sql_expr, [fname]
return super()._read_group_having(having_domain, query)
@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
import ast
from odoo import models, _
class AccountPayment(models.Model):
_inherit = "account.payment"
def action_open_manual_reconciliation_widget(self):
''' Open the manual reconciliation widget for the current payment.
:return: A dictionary representing an action.
'''
self.ensure_one()
action_values = self.env['ir.actions.act_window']._for_xml_id('account_accountant.action_move_line_posted_unreconciled')
if self.partner_id:
context = ast.literal_eval(action_values['context'])
context.update({'search_default_partner_id': self.partner_id.id})
if self.partner_type == 'customer':
context.update({'search_default_trade_receivable': 1})
elif self.partner_type == 'supplier':
context.update({'search_default_trade_payable': 1})
action_values['context'] = context
return action_values
def button_open_statement_lines(self):
# OVERRIDE
""" Redirect the user to the statement line(s) reconciled to this payment.
:return: An action to open the view of the payment in the reconciliation widget.
"""
self.ensure_one()
return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
extra_domain=[('id', 'in', self.reconciled_statement_line_ids.ids)],
default_context={
'create': False,
'default_st_line_id': self.reconciled_statement_line_ids.ids[-1],
},
name=_("Matched Transactions")
)
@@ -0,0 +1,625 @@
from odoo import fields, models, Command, tools
import re
from collections import defaultdict
from dateutil.relativedelta import relativedelta
class AccountReconcileModel(models.Model):
_inherit = 'account.reconcile.model'
####################################################
# RECONCILIATION PROCESS
####################################################
def _apply_lines_for_bank_widget(self, residual_amount_currency, partner, st_line):
""" Apply the reconciliation model lines to the statement line passed as parameter.
:param residual_amount_currency: The open balance of the statement line in the bank reconciliation widget
expressed in the statement line currency.
:param partner: The partner set on the wizard.
:param st_line: The statement line processed by the bank reconciliation widget.
:return: A list of python dictionaries (one per reconcile model line) representing
the journal items to be created by the current reconcile model.
"""
self.ensure_one()
currency = st_line.foreign_currency_id or st_line.journal_id.currency_id or st_line.company_currency_id
if currency.is_zero(residual_amount_currency):
return []
vals_list = []
for line in self.line_ids:
vals = line._apply_in_bank_widget(residual_amount_currency, partner, st_line)
amount_currency = vals['amount_currency']
if currency.is_zero(amount_currency):
continue
vals_list.append(vals)
residual_amount_currency -= amount_currency
return vals_list
def _get_taxes_move_lines_dict(self, tax, base_line_dict):
''' Get move.lines dict (to be passed to the create()) corresponding to a tax.
:param tax: An account.tax record.
:param base_line_dict: A dict representing the move.line containing the base amount.
:return: A list of dict representing move.lines to be created corresponding to the tax.
'''
self.ensure_one()
balance = base_line_dict['balance']
tax_type = tax.type_tax_use
is_refund = (tax_type == 'sale' and balance < 0) or (tax_type == 'purchase' and balance > 0)
res = tax.compute_all(balance, is_refund=is_refund)
new_aml_dicts = []
for tax_res in res['taxes']:
tax = self.env['account.tax'].browse(tax_res['id'])
balance = tax_res['amount']
name = ' '.join([x for x in [base_line_dict.get('name', ''), tax_res['name']] if x])
new_aml_dicts.append({
'account_id': tax_res['account_id'] or base_line_dict['account_id'],
'journal_id': base_line_dict.get('journal_id', False),
'name': name,
'partner_id': base_line_dict.get('partner_id'),
'balance': balance,
'debit': balance > 0 and balance or 0,
'credit': balance < 0 and -balance or 0,
'analytic_distribution': tax.analytic and base_line_dict['analytic_distribution'],
'tax_repartition_line_id': tax_res['tax_repartition_line_id'],
'tax_ids': [(6, 0, tax_res['tax_ids'])],
'tax_tag_ids': [(6, 0, tax_res['tag_ids'])],
'group_tax_id': tax_res['group'].id if tax_res['group'] else False,
'currency_id': False,
'reconcile_model_id': self.id,
})
# Handle price included taxes.
base_balance = tax_res['base']
base_line_dict.update({
'balance': base_balance,
'debit': base_balance > 0 and base_balance or 0,
'credit': base_balance < 0 and -base_balance or 0,
})
base_line_dict['tax_tag_ids'] = [(6, 0, res['base_tags'])]
return new_aml_dicts
def _get_write_off_move_lines_dict(self, residual_balance, partner_id):
''' Get move.lines dict corresponding to the reconciliation model's write-off lines.
:param residual_balance: The residual balance of the account on the manual reconciliation widget.
:return: A list of dict representing move.lines to be created corresponding to the write-off lines.
'''
self.ensure_one()
if self.rule_type == 'invoice_matching' and (not self.allow_payment_tolerance or self.payment_tolerance_param == 0):
return []
currency = self.company_id.currency_id
lines_vals_list = []
for line in self.line_ids:
if line.amount_type == 'percentage':
balance = currency.round(residual_balance * (line.amount / 100.0))
elif line.amount_type == 'fixed':
balance = currency.round(line.amount * (1 if residual_balance > 0.0 else -1))
if currency.is_zero(balance):
continue
writeoff_line = {
'name': line.label,
'balance': balance,
'debit': balance > 0 and balance or 0,
'credit': balance < 0 and -balance or 0,
'account_id': line.account_id.id,
'currency_id': currency.id,
'analytic_distribution': line.analytic_distribution,
'reconcile_model_id': self.id,
'journal_id': line.journal_id.id,
'tax_ids': [],
}
lines_vals_list.append(writeoff_line)
residual_balance -= balance
if line.tax_ids:
taxes = line.tax_ids
detected_fiscal_position = self.env['account.fiscal.position']._get_fiscal_position(self.env['res.partner'].browse(partner_id))
if detected_fiscal_position:
taxes = detected_fiscal_position.map_tax(taxes)
writeoff_line['tax_ids'] += [Command.set(taxes.ids)]
# Multiple taxes with force_tax_included results in wrong computation, so we
# only allow to set the force_tax_included field if we have one tax selected
if line.force_tax_included:
taxes = taxes[0].with_context(force_price_include=True)
tax_vals_list = self._get_taxes_move_lines_dict(taxes, writeoff_line)
lines_vals_list += tax_vals_list
if not line.force_tax_included:
for tax_line in tax_vals_list:
residual_balance -= tax_line['balance']
return lines_vals_list
####################################################
# RECONCILIATION CRITERIA
####################################################
def _apply_rules(self, st_line, partner):
''' Apply criteria to get candidates for all reconciliation models.
This function is called in enterprise by the reconciliation widget to match
the statement line with the available candidates (using the reconciliation models).
:param st_line: The statement line to match.
:param partner: The partner to consider.
:return: A dict mapping each statement line id with:
* aml_ids: A list of account.move.line ids.
* model: An account.reconcile.model record (optional).
* status: 'reconciled' if the lines has been already reconciled, 'write_off' if the write-off
must be applied on the statement line.
* auto_reconcile: A flag indicating if the match is enough significant to auto reconcile the candidates.
'''
available_models = self.filtered(lambda m: m.rule_type != 'writeoff_button').sorted()
for rec_model in available_models:
if not rec_model._is_applicable_for(st_line, partner):
continue
if rec_model.rule_type == 'invoice_matching':
rules_map = rec_model._get_invoice_matching_rules_map()
for rule_index in sorted(rules_map.keys()):
for rule_method in rules_map[rule_index]:
candidate_vals = rule_method(st_line, partner)
if not candidate_vals:
continue
if candidate_vals.get('amls'):
res = rec_model._get_invoice_matching_amls_result(st_line, partner, candidate_vals)
if res:
return {
**res,
'model': rec_model,
}
else:
return {
**candidate_vals,
'model': rec_model,
}
elif rec_model.rule_type == 'writeoff_suggestion':
return {
'model': rec_model,
'status': 'write_off',
'auto_reconcile': rec_model.auto_reconcile,
}
return {}
def _is_applicable_for(self, st_line, partner):
""" Returns true iff this reconciliation model can be used to search for matches
for the provided statement line and partner.
"""
self.ensure_one()
# Filter on journals, amount nature, amount and partners
# All the conditions defined in this block are non-match conditions.
if ((self.match_journal_ids and st_line.move_id.journal_id not in self.match_journal_ids)
or (self.match_nature == 'amount_received' and st_line.amount < 0)
or (self.match_nature == 'amount_paid' and st_line.amount > 0)
or (self.match_amount == 'lower' and abs(st_line.amount) >= self.match_amount_max)
or (self.match_amount == 'greater' and abs(st_line.amount) <= self.match_amount_min)
or (self.match_amount == 'between' and (abs(st_line.amount) > self.match_amount_max or abs(st_line.amount) < self.match_amount_min))
or (self.match_partner and not partner)
or (self.match_partner and self.match_partner_ids and partner not in self.match_partner_ids)
or (self.match_partner and self.match_partner_category_ids and not (partner.category_id & self.match_partner_category_ids))
):
return False
# Filter on label, note and transaction_type
for record, rule_field, record_field in [(st_line, 'label', 'payment_ref'), (st_line.move_id, 'note', 'narration'), (st_line, 'transaction_type', 'transaction_type')]:
rule_term = (self['match_' + rule_field + '_param'] or '').lower()
record_term = (record[record_field] or '').lower()
# This defines non-match conditions
if ((self['match_' + rule_field] == 'contains' and rule_term not in record_term)
or (self['match_' + rule_field] == 'not_contains' and rule_term in record_term)
or (self['match_' + rule_field] == 'match_regex' and not re.match(rule_term, record_term))
):
return False
return True
def _get_invoice_matching_amls_domain(self, st_line, partner):
aml_domain = st_line._get_default_amls_matching_domain()
if st_line.amount > 0.0:
aml_domain.append(('balance', '>', 0.0))
else:
aml_domain.append(('balance', '<', 0.0))
currency = st_line.foreign_currency_id or st_line.currency_id
if self.match_same_currency:
aml_domain.append(('currency_id', '=', currency.id))
if partner:
aml_domain.append(('partner_id', '=', partner.id))
if self.past_months_limit:
date_limit = fields.Date.context_today(self) - relativedelta(months=self.past_months_limit)
aml_domain.append(('date', '>=', fields.Date.to_string(date_limit)))
return aml_domain
def _get_st_line_text_values_for_matching(self, st_line):
""" Collect the strings that could be used on the statement line to perform some matching.
:param st_line: The current statement line.
:return: A list of strings.
"""
self.ensure_one()
allowed_fields = []
if self.match_text_location_label:
allowed_fields.append('payment_ref')
if self.match_text_location_note:
allowed_fields.append('narration')
if self.match_text_location_reference:
allowed_fields.append('ref')
return st_line._get_st_line_strings_for_matching(allowed_fields=allowed_fields)
def _get_invoice_matching_st_line_tokens(self, st_line):
""" Parse the textual information from the statement line passed as parameter
in order to extract from it the meaningful information in order to perform the matching.
:param st_line: A statement line.
:return: A tuple of list of tokens, each one being a string.
The first element is a list of tokens you may match on numerical information.
The second element is a list of tokens you may match exactly.
"""
st_line_text_values = self._get_st_line_text_values_for_matching(st_line)
significant_token_size = 4
numerical_tokens = []
exact_tokens = []
text_tokens = []
for text_value in st_line_text_values:
tokens = [
''.join(x for x in token if re.match(r'[0-9a-zA-Z\s]', x))
for token in (text_value or '').split()
]
# Numerical tokens
for token in tokens:
# The token is too short to be significant.
if len(token) < significant_token_size:
continue
text_tokens.append(token)
formatted_token = ''.join(x for x in token if x.isdecimal())
# The token is too short after formatting to be significant.
if len(formatted_token) < significant_token_size:
continue
numerical_tokens.append(formatted_token)
# Exact tokens.
if len(tokens) == 1:
exact_tokens.append(text_value)
return numerical_tokens, exact_tokens, text_tokens
def _get_invoice_matching_amls_candidates(self, st_line, partner):
""" Returns the match candidates for the 'invoice_matching' rule, with respect to the provided parameters.
:param st_line: A statement line.
:param partner: The partner associated to the statement line.
"""
assert self.rule_type == 'invoice_matching'
self.env['account.move'].flush_model()
self.env['account.move.line'].flush_model()
if self.matching_order == 'new_first':
order_by = 'sub.date_maturity DESC, sub.date DESC, sub.id DESC'
else:
order_by = 'sub.date_maturity ASC, sub.date ASC, sub.id ASC'
aml_domain = self._get_invoice_matching_amls_domain(st_line, partner)
query = self.env['account.move.line']._where_calc(aml_domain)
tables, where_clause, where_params = query.get_sql()
sub_queries = []
all_params = []
numerical_tokens, exact_tokens, _text_tokens = self._get_invoice_matching_st_line_tokens(st_line)
if numerical_tokens:
for table_alias, field in (
('account_move_line', 'name'),
('account_move_line__move_id', 'name'),
('account_move_line__move_id', 'ref'),
):
sub_queries.append(rf'''
SELECT
account_move_line.id,
account_move_line.date,
account_move_line.date_maturity,
UNNEST(
REGEXP_SPLIT_TO_ARRAY(
SUBSTRING(
REGEXP_REPLACE({table_alias}.{field}, '[^0-9\s]', '', 'g'),
'\S(?:.*\S)*'
),
'\s+'
)
) AS token
FROM {tables}
JOIN account_move account_move_line__move_id ON account_move_line__move_id.id = account_move_line.move_id
WHERE {where_clause} AND {table_alias}.{field} IS NOT NULL
''')
all_params += where_params
if exact_tokens:
for table_alias, field in (
('account_move_line', 'name'),
('account_move_line__move_id', 'name'),
('account_move_line__move_id', 'ref'),
):
sub_queries.append(rf'''
SELECT
account_move_line.id,
account_move_line.date,
account_move_line.date_maturity,
{table_alias}.{field} AS token
FROM {tables}
JOIN account_move account_move_line__move_id ON account_move_line__move_id.id = account_move_line.move_id
WHERE {where_clause} AND COALESCE({table_alias}.{field}, '') != ''
''')
all_params += where_params
if sub_queries:
self._cr.execute(
'''
SELECT
sub.id,
COUNT(*) AS nb_match
FROM (''' + ' UNION ALL '.join(sub_queries) + ''') AS sub
WHERE sub.token IN %s
GROUP BY sub.date_maturity, sub.date, sub.id
HAVING COUNT(*) > 0
ORDER BY nb_match DESC, ''' + order_by + '''
''',
all_params + [tuple(numerical_tokens + exact_tokens)],
)
candidate_ids = [r[0] for r in self._cr.fetchall()]
if candidate_ids:
return {
'allow_auto_reconcile': True,
'amls': self.env['account.move.line'].browse(candidate_ids),
}
# Search without any matching based on textual information.
if partner:
if self.matching_order == 'new_first':
order = 'date_maturity DESC, date DESC, id DESC'
else:
order = 'date_maturity ASC, date ASC, id ASC'
amls = self.env['account.move.line'].search(aml_domain, order=order)
if amls:
return {
'allow_auto_reconcile': False,
'amls': amls,
}
def _get_invoice_matching_rules_map(self):
""" Get a mapping <priority_order, rule> that could be overridden in others modules.
:return: a mapping <priority_order, rule> where:
* priority_order: Defines in which order the rules will be evaluated, the lowest comes first.
This is extremely important since the algorithm stops when a rule returns some candidates.
* rule: Method taking <st_line, partner> as parameters and returning the candidates journal items found.
"""
rules_map = defaultdict(list)
rules_map[10].append(self._get_invoice_matching_amls_candidates)
return rules_map
def _get_partner_from_mapping(self, st_line):
"""Find partner with mapping defined on model.
For invoice matching rules, matches the statement line against each
regex defined in partner mapping, and returns the partner corresponding
to the first one matching.
:param st_line (Model<account.bank.statement.line>):
The statement line that needs a partner to be found
:return Model<res.partner>:
The partner found from the mapping. Can be empty an empty recordset
if there was nothing found from the mapping or if the function is
not applicable.
"""
self.ensure_one()
if self.rule_type not in ('invoice_matching', 'writeoff_suggestion'):
return self.env['res.partner']
for partner_mapping in self.partner_mapping_line_ids:
match_payment_ref = True
if partner_mapping.payment_ref_regex:
match_payment_ref = re.match(partner_mapping.payment_ref_regex, st_line.payment_ref) if st_line.payment_ref else False
match_narration = True
if partner_mapping.narration_regex:
match_narration = re.match(
partner_mapping.narration_regex,
tools.html2plaintext(st_line.narration or '').rstrip(),
flags=re.DOTALL, # Ignore '/n' set by online sync.
)
if match_payment_ref and match_narration:
return partner_mapping.partner_id
return self.env['res.partner']
def _get_invoice_matching_amls_result(self, st_line, partner, candidate_vals):
def _create_result_dict(amls_values_list, status):
if 'rejected' in status:
return
result = {'amls': self.env['account.move.line']}
for aml_values in amls_values_list:
result['amls'] |= aml_values['aml']
if 'allow_write_off' in status and self.line_ids:
result['status'] = 'write_off'
if 'allow_auto_reconcile' in status and candidate_vals['allow_auto_reconcile'] and self.auto_reconcile:
result['auto_reconcile'] = True
return result
st_line_currency = st_line.foreign_currency_id or st_line.currency_id
st_line_amount = st_line._prepare_move_line_default_vals()[1]['amount_currency']
sign = 1 if st_line_amount > 0.0 else -1
amls = candidate_vals['amls']
amls_values_list = []
amls_with_epd_values_list = []
same_currency_mode = amls.currency_id == st_line_currency
for aml in amls:
aml_values = {
'aml': aml,
'amount_residual': aml.amount_residual,
'amount_residual_currency': aml.amount_residual_currency,
}
amls_values_list.append(aml_values)
# Manage the early payment discount.
if aml.move_id.invoice_payment_term_id:
last_discount_date = aml.move_id.invoice_payment_term_id._get_last_discount_date(aml.move_id.date)
else:
last_discount_date = False
if same_currency_mode \
and aml.move_id.move_type in ('out_invoice', 'out_receipt', 'in_invoice', 'in_receipt') \
and not aml.matched_debit_ids \
and not aml.matched_credit_ids \
and last_discount_date \
and st_line.date <= last_discount_date:
rate = abs(aml.amount_currency) / abs(aml.balance) if aml.balance else 1.0
amls_with_epd_values_list.append({
**aml_values,
'amount_residual': st_line.company_currency_id.round(aml.discount_amount_currency / rate),
'amount_residual_currency': aml.discount_amount_currency,
})
else:
amls_with_epd_values_list.append(aml_values)
def match_batch_amls(amls_values_list):
if not same_currency_mode:
return None, []
kepts_amls_values_list = []
sum_amount_residual_currency = 0.0
for aml_values in amls_values_list:
if st_line_currency.compare_amounts(st_line_amount, -aml_values['amount_residual_currency']) == 0:
# Special case: the amounts are the same, submit the line directly.
return 'perfect', [aml_values]
if st_line_currency.compare_amounts(sign * (st_line_amount + sum_amount_residual_currency), 0.0) > 0:
# Here, we still have room for other candidates ; so we add the current one to the list we keep.
# Then, we continue iterating, even if there is no room anymore, just in case one of the following candidates
# is an exact match, which would then be preferred on the current candidates.
kepts_amls_values_list.append(aml_values)
sum_amount_residual_currency += aml_values['amount_residual_currency']
if st_line_currency.is_zero(sign * (st_line_amount + sum_amount_residual_currency)):
return 'perfect', kepts_amls_values_list
elif kepts_amls_values_list:
return 'partial', kepts_amls_values_list
else:
return None, []
# Try to match a batch with the early payment feature. Only a perfect match is allowed.
match_type, kepts_amls_values_list = match_batch_amls(amls_with_epd_values_list)
if match_type != 'perfect':
kepts_amls_values_list = []
# Try to match the amls having the same currency as the statement line.
if not kepts_amls_values_list:
_match_type, kepts_amls_values_list = match_batch_amls(amls_values_list)
# Try to match the whole candidates.
if not kepts_amls_values_list:
kepts_amls_values_list = amls_values_list
# Try to match the amls having the same currency as the statement line.
if kepts_amls_values_list:
status = self._check_rule_propositions(st_line, kepts_amls_values_list)
result = _create_result_dict(kepts_amls_values_list, status)
if result:
return result
def _check_rule_propositions(self, st_line, amls_values_list):
""" Check restrictions that can't be handled for each move.line separately.
Note: Only used by models having a type equals to 'invoice_matching'.
:param st_line: The statement line.
:param amls_values_list: The candidates account.move.line as a list of dict:
* aml: The record.
* amount_residual: The amount residual to consider.
* amount_residual_currency: The amount residual in foreign currency to consider.
:return: A string representing what to do with the candidates:
* rejected: Reject candidates.
* allow_write_off: Allow to generate the write-off from the reconcile model lines if specified.
* allow_auto_reconcile: Allow to automatically reconcile entries if 'auto_validate' is enabled.
"""
self.ensure_one()
if not self.allow_payment_tolerance:
return {'allow_write_off', 'allow_auto_reconcile'}
st_line_currency = st_line.foreign_currency_id or st_line.currency_id
st_line_amount_curr = st_line._prepare_move_line_default_vals()[1]['amount_currency']
amls_amount_curr = sum(
st_line._prepare_counterpart_amounts_using_st_line_rate(
aml_values['aml'].currency_id,
aml_values['amount_residual'],
aml_values['amount_residual_currency'],
)['amount_currency']
for aml_values in amls_values_list
)
sign = 1 if st_line_amount_curr > 0.0 else -1
amount_curr_after_rec = sign * (amls_amount_curr + st_line_amount_curr)
# The statement line will be fully reconciled.
if st_line_currency.is_zero(amount_curr_after_rec):
return {'allow_auto_reconcile'}
# The payment amount is higher than the sum of invoices.
# In that case, don't check the tolerance and don't try to generate any write-off.
if amount_curr_after_rec > 0.0:
return {'allow_auto_reconcile'}
# No tolerance, reject the candidates.
if self.payment_tolerance_param == 0:
return {'rejected'}
# If the tolerance is expressed as a fixed amount, check the residual payment amount doesn't exceed the
# tolerance.
if self.payment_tolerance_type == 'fixed_amount' and -amount_curr_after_rec <= self.payment_tolerance_param:
return {'allow_write_off', 'allow_auto_reconcile'}
# The tolerance is expressed as a percentage between 0 and 100.0.
reconciled_percentage_left = (abs(amount_curr_after_rec / amls_amount_curr)) * 100.0
if self.payment_tolerance_type == 'percentage' and reconciled_percentage_left <= self.payment_tolerance_param:
return {'allow_write_off', 'allow_auto_reconcile'}
return {'rejected'}
def run_auto_reconciliation(self):
""" Tries to auto-reconcile as many statements as possible within time limit
arbitrary set to 3 minutes (the rest will be reconciled asynchronously with the regular cron).
"""
cron_limit_time = tools.config['limit_time_real_cron'] # default is -1
limit_time = cron_limit_time if 0 < cron_limit_time < 180 else 180
self.env['account.bank.statement.line']._cron_try_auto_reconcile_statement_lines(limit_time=limit_time)
@@ -0,0 +1,108 @@
from odoo import models, Command, _
from odoo.exceptions import UserError
import re
from math import copysign
class AccountReconcileModelLine(models.Model):
_inherit = 'account.reconcile.model.line'
def _prepare_aml_vals(self, partner):
""" Prepare a dictionary that will be used later to create a new journal item (account.move.line) for the
given reconcile model line.
:param partner: The partner to be linked to the journal item.
:return: A python dictionary.
"""
self.ensure_one()
taxes = self.tax_ids
if taxes and partner:
fiscal_position = self.env['account.fiscal.position']._get_fiscal_position(partner)
if fiscal_position:
taxes = fiscal_position.map_tax(taxes)
return {
'name': self.label,
'account_id': self.account_id.id,
'partner_id': partner.id,
'analytic_distribution': self.analytic_distribution,
'tax_ids': [Command.set(taxes.ids)],
'reconcile_model_id': self.model_id.id,
}
def _apply_in_manual_widget(self, residual_amount_currency, partner, currency):
""" Prepare a dictionary that will be used later to create a new journal item (account.move.line) for the
given reconcile model line used by the manual reconciliation widget.
Note: 'journal_id' is added to the returned dictionary even if it is a related readonly field.
It's a hack for the manual reconciliation widget. Indeed, a single journal entry will be created for each
journal.
:param residual_amount_currency: The current balance expressed in the account's currency.
:param partner: The partner to be linked to the journal item.
:param currency: The currency set on the account in the manual reconciliation widget.
:return: A python dictionary.
"""
self.ensure_one()
if self.amount_type == 'percentage':
amount_currency = currency.round(residual_amount_currency * (self.amount / 100.0))
elif self.amount_type == 'fixed':
sign = 1 if residual_amount_currency > 0.0 else -1
amount_currency = currency.round(self.amount * sign)
else:
raise UserError(_("This reconciliation model can't be used in the manual reconciliation widget because its "
"configuration is not adapted"))
return {
**self._prepare_aml_vals(partner),
'currency_id': currency.id,
'amount_currency': amount_currency,
'journal_id': self.journal_id.id,
}
def _apply_in_bank_widget(self, residual_amount_currency, partner, st_line):
""" Prepare a dictionary that will be used later to create a new journal item (account.move.line) for the
given reconcile model line used by the bank reconciliation widget.
:param residual_amount_currency: The current balance expressed in the statement line's currency.
:param partner: The partner to be linked to the journal item.
:param st_line: The statement line mounted inside the bank reconciliation widget.
:return: A python dictionary.
"""
self.ensure_one()
currency = st_line.foreign_currency_id or st_line.journal_id.currency_id or st_line.company_currency_id
amount_currency = None
if self.amount_type == 'percentage_st_line':
amount_currency = currency.round(residual_amount_currency * (self.amount / 100.0))
elif self.amount_type == 'regex':
match = re.search(self.amount_string, st_line.payment_ref)
if match:
sign = 1 if residual_amount_currency > 0.0 else -1
decimal_separator = self.model_id.decimal_separator
try:
extracted_match_group = re.sub(r'[^\d' + decimal_separator + ']', '', match.group(1))
extracted_balance = float(extracted_match_group.replace(decimal_separator, '.'))
amount_currency = copysign(extracted_balance * sign, residual_amount_currency)
except ValueError:
amount_currency = 0.0
else:
amount_currency = 0.0
if amount_currency is None:
aml_vals = self._apply_in_manual_widget(residual_amount_currency, partner, currency)
else:
aml_vals = {
**self._prepare_aml_vals(partner),
'currency_id': currency.id,
'amount_currency': amount_currency,
}
if not aml_vals['name']:
aml_vals['name'] = st_line.payment_ref
return aml_vals
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,448 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models, Command
from odoo.osv import expression
from odoo.tools.misc import formatLang
import markupsafe
import uuid
class BankRecWidgetLine(models.Model):
_name = "bank.rec.widget.line"
_inherit = "analytic.mixin"
_description = "Line of the bank reconciliation widget"
# This model is never saved inside the database.
# _auto=False' & _table_query = "0" prevent the ORM to create the corresponding postgresql table.
_auto = False
_table_query = "0"
wizard_id = fields.Many2one(comodel_name='bank.rec.widget')
index = fields.Char(compute='_compute_index')
flag = fields.Selection(
selection=[
('liquidity', 'liquidity'),
('new_aml', 'new_aml'),
('aml', 'aml'),
('exchange_diff', 'exchange_diff'),
('tax_line', 'tax_line'),
('manual', 'manual'),
('early_payment', 'early_payment'),
('auto_balance', 'auto_balance'),
],
)
account_id = fields.Many2one(
comodel_name='account.account',
compute='_compute_account_id',
store=True,
readonly=False,
)
date = fields.Date(
compute='_compute_date',
store=True,
readonly=False,
)
name = fields.Char(
compute='_compute_name',
store=True,
readonly=False,
)
partner_id = fields.Many2one(
comodel_name='res.partner',
compute='_compute_partner_id',
store=True,
readonly=False,
)
currency_id = fields.Many2one(
comodel_name='res.currency',
compute='_compute_currency_id',
store=True,
readonly=False,
)
company_id = fields.Many2one(related='wizard_id.company_id')
company_currency_id = fields.Many2one(related='wizard_id.company_currency_id')
amount_currency = fields.Monetary(
currency_field='currency_id',
compute='_compute_amount_currency',
store=True,
readonly=False,
)
balance = fields.Monetary(
currency_field='company_currency_id',
compute='_compute_balance',
store=True,
readonly=False,
)
debit = fields.Monetary(
currency_field='company_currency_id',
compute='_compute_from_balance',
)
credit = fields.Monetary(
currency_field='company_currency_id',
compute='_compute_from_balance',
)
force_price_included_taxes = fields.Boolean()
tax_base_amount_currency = fields.Monetary(
currency_field='currency_id',
)
source_aml_id = fields.Many2one(comodel_name='account.move.line')
source_aml_move_id = fields.Many2one(
comodel_name='account.move',
compute='_compute_source_aml_fields',
store=True,
readonly=False,
)
source_aml_move_name = fields.Char(
compute='_compute_source_aml_fields',
store=True,
readonly=False,
)
tax_repartition_line_id = fields.Many2one(
comodel_name='account.tax.repartition.line',
compute='_compute_tax_repartition_line_id',
store=True,
readonly=False,
)
tax_ids = fields.Many2many(
comodel_name='account.tax',
compute='_compute_tax_ids',
store=True,
readonly=False,
check_company=True,
)
tax_tag_ids = fields.Many2many(
comodel_name='account.account.tag',
compute='_compute_tax_tag_ids',
store=True,
readonly=False,
)
group_tax_id = fields.Many2one(
comodel_name='account.tax',
compute='_compute_group_tax_id',
store=True,
readonly=False,
)
reconcile_model_id = fields.Many2one(comodel_name='account.reconcile.model')
source_amount_currency = fields.Monetary(currency_field='currency_id')
source_balance = fields.Monetary(currency_field='company_currency_id')
source_debit = fields.Monetary(
currency_field='company_currency_id',
compute='_compute_from_source_balance',
)
source_credit = fields.Monetary(
currency_field='company_currency_id',
compute='_compute_from_source_balance',
)
display_stroked_amount_currency = fields.Boolean(compute='_compute_display_stroked_amount_currency')
display_stroked_balance = fields.Boolean(compute='_compute_display_stroked_balance')
partner_currency_id = fields.Many2one(
comodel_name='res.currency',
compute='_compute_partner_info',
)
partner_receivable_account_id = fields.Many2one(
comodel_name='account.account',
compute='_compute_partner_info',
)
partner_payable_account_id = fields.Many2one(
comodel_name='account.account',
compute='_compute_partner_info',
)
partner_receivable_amount = fields.Monetary(
currency_field='partner_currency_id',
compute='_compute_partner_info',
)
partner_payable_amount = fields.Monetary(
currency_field='partner_currency_id',
compute='_compute_partner_info',
)
bank_account = fields.Char(
compute='_compute_bank_account',
)
suggestion_html = fields.Html(
compute='_compute_suggestion',
sanitize=False,
)
suggestion_amount_currency = fields.Monetary(
currency_field='currency_id',
compute='_compute_suggestion',
)
suggestion_balance = fields.Monetary(
currency_field='company_currency_id',
compute='_compute_suggestion',
)
manually_modified = fields.Boolean()
def _compute_index(self):
for line in self:
line.index = uuid.uuid4()
@api.depends('source_aml_id')
def _compute_account_id(self):
for line in self:
if line.flag in ('aml', 'new_aml', 'liquidity', 'exchange_diff'):
line.account_id = line.source_aml_id.account_id
else:
line.account_id = line.account_id
@api.depends('source_aml_id')
def _compute_date(self):
for line in self:
if line.flag in ('aml', 'new_aml', 'exchange_diff'):
line.date = line.source_aml_id.date
elif line.flag in ('liquidity', 'auto_balance', 'manual', 'early_payment', 'tax_line'):
line.date = line.wizard_id.st_line_id.date
else:
line.date = line.date
@api.depends('source_aml_id')
def _compute_name(self):
for line in self:
if line.flag in ('aml', 'new_aml', 'liquidity'):
line.name = line.source_aml_id.name
else:
line.name = line.name
@api.depends('source_aml_id')
def _compute_partner_id(self):
for line in self:
if line.flag in ('aml', 'new_aml'):
line.partner_id = line.source_aml_id.partner_id
elif line.flag in ('liquidity', 'auto_balance', 'manual', 'early_payment', 'tax_line'):
line.partner_id = line.wizard_id.partner_id
else:
line.partner_id = line.partner_id
@api.depends('source_aml_id')
def _compute_currency_id(self):
for line in self:
if line.flag in ('aml', 'new_aml', 'liquidity', 'exchange_diff'):
line.currency_id = line.source_aml_id.currency_id
elif line.flag in ('auto_balance', 'manual', 'early_payment'):
line.currency_id = line.wizard_id.transaction_currency_id
else:
line.currency_id = line.currency_id
@api.depends('source_aml_id')
def _compute_balance(self):
for line in self:
if line.flag in ('aml', 'liquidity'):
line.balance = line.source_aml_id.balance
else:
line.balance = line.balance
@api.depends('source_aml_id')
def _compute_amount_currency(self):
for line in self:
if line.flag in ('aml', 'liquidity'):
line.amount_currency = line.source_aml_id.amount_currency
else:
line.amount_currency = line.amount_currency
@api.depends('balance')
def _compute_from_balance(self):
for line in self:
line.debit = line.balance if line.balance > 0.0 else 0.0
line.credit = -line.balance if line.balance < 0.0 else 0.0
@api.depends('source_balance')
def _compute_from_source_balance(self):
for line in self:
line.source_debit = line.source_balance if line.source_balance > 0.0 else 0.0
line.source_credit = -line.source_balance if line.source_balance < 0.0 else 0.0
@api.depends('source_aml_id')
def _compute_analytic_distribution(self):
for line in self:
if line.flag in ('aml', 'new_aml'):
line.analytic_distribution = line.source_aml_id.analytic_distribution
else:
line.analytic_distribution = line.analytic_distribution
@api.depends('source_aml_id')
def _compute_tax_repartition_line_id(self):
for line in self:
if line.flag == 'aml':
line.tax_repartition_line_id = line.source_aml_id.tax_repartition_line_id
else:
line.tax_repartition_line_id = line.tax_repartition_line_id
@api.depends('source_aml_id')
def _compute_tax_ids(self):
for line in self:
if line.flag == 'aml':
line.tax_ids = [Command.set(line.source_aml_id.tax_ids.ids)]
else:
line.tax_ids = line.tax_ids
@api.depends('source_aml_id')
def _compute_tax_tag_ids(self):
for line in self:
if line.flag == 'aml':
line.tax_tag_ids = [Command.set(line.source_aml_id.tax_tag_ids.ids)]
else:
line.tax_tag_ids = line.tax_tag_ids
@api.depends('source_aml_id')
def _compute_group_tax_id(self):
for line in self:
if line.flag == 'aml':
line.group_tax_id = line.source_aml_id.group_tax_id
else:
line.group_tax_id = line.group_tax_id
@api.depends('currency_id', 'amount_currency', 'source_amount_currency')
def _compute_display_stroked_amount_currency(self):
for line in self:
line.display_stroked_amount_currency = \
line.flag == 'new_aml' \
and line.currency_id.compare_amounts(line.amount_currency, line.source_amount_currency) != 0
@api.depends('currency_id', 'balance', 'source_balance')
def _compute_display_stroked_balance(self):
for line in self:
line.display_stroked_balance = \
line.flag == 'new_aml' \
and line.currency_id.compare_amounts(line.balance, line.source_balance) != 0
@api.depends('flag')
def _compute_source_aml_fields(self):
for line in self:
line.source_aml_move_id = None
line.source_aml_move_name = None
if line.flag in ('new_aml', 'liquidity'):
line.source_aml_move_id = line.source_aml_id.move_id
line.source_aml_move_name = line.source_aml_id.move_id.name
elif line.flag == 'aml':
partials = line.source_aml_id.matched_debit_ids + line.source_aml_id.matched_credit_ids
all_counterpart_lines = partials.debit_move_id + partials.credit_move_id
counterpart_lines = all_counterpart_lines - line.source_aml_id - partials.exchange_move_id.line_ids
if len(counterpart_lines) == 1:
line.source_aml_move_id = counterpart_lines.move_id
line.source_aml_move_name = counterpart_lines.move_id.name
@api.depends('wizard_id.form_index', 'partner_id')
def _compute_partner_info(self):
for line in self:
line.partner_receivable_amount = 0.0
line.partner_payable_amount = 0.0
line.partner_currency_id = None
line.partner_receivable_account_id = None
line.partner_payable_account_id = None
if not line.partner_id or line.index != line.wizard_id.form_index:
continue
line.partner_currency_id = line.company_currency_id
partner = line.partner_id.with_company(line.wizard_id.company_id)
common_domain = [('parent_state', '=', 'posted'), ('partner_id', '=', partner.id)]
line.partner_receivable_account_id = partner.property_account_receivable_id
if line.partner_receivable_account_id:
results = self.env['account.move.line']._read_group(
domain=expression.AND([common_domain, [('account_id', '=', line.partner_receivable_account_id.id)]]),
aggregates=['amount_residual:sum'],
)
line.partner_receivable_amount = results[0][0]
line.partner_payable_account_id = partner.property_account_payable_id
if line.partner_payable_account_id:
results = self.env['account.move.line']._read_group(
domain=expression.AND([common_domain, [('account_id', '=', line.partner_payable_account_id.id)]]),
aggregates=['amount_residual:sum'],
)
line.partner_payable_amount = results[0][0]
@api.depends('flag', 'wizard_id.st_line_id')
def _compute_bank_account(self):
for line in self:
bank_account = line.wizard_id.st_line_id.partner_bank_id.display_name or line.wizard_id.st_line_id.account_number
if line.flag == 'liquidity' and bank_account:
line.bank_account = bank_account
else:
line.bank_account = None
@api.depends('wizard_id.form_index', 'amount_currency', 'balance')
def _compute_suggestion(self):
for line in self:
line.suggestion_html = None
line.suggestion_amount_currency = None
line.suggestion_balance = None
if line.flag != 'new_aml' or line.index != line.wizard_id.form_index:
continue
aml = line.source_aml_id
wizard = line.wizard_id
residual_amount_before_reco = abs(aml.amount_residual_currency)
residual_amount_after_reco = abs(aml.amount_residual_currency + line.amount_currency)
reconciled_amount = residual_amount_before_reco - residual_amount_after_reco
is_fully_reconciled = aml.currency_id.is_zero(residual_amount_after_reco)
is_invoice = aml.move_id.is_invoice(include_receipts=True)
if is_fully_reconciled:
lines = [
_("The invoice %(display_name_html)s with an open amount of %(open_amount)s will be entirely paid by the transaction.")
if is_invoice else
_("%(display_name_html)s with an open amount of %(open_amount)s will be fully reconciled by the transaction.")
]
partial_amounts = wizard._lines_check_partial_amount(line)
if partial_amounts:
lines.append(
_("You might want to record a %(btn_start)spartial payment%(btn_end)s.")
if is_invoice else
_("You might want to make a %(btn_start)spartial reconciliation%(btn_end)s instead.")
)
line.suggestion_amount_currency = partial_amounts['amount_currency']
line.suggestion_balance = partial_amounts['balance']
else:
if is_invoice:
lines = [
_("The invoice %(display_name_html)s with an open amount of %(open_amount)s will be reduced by %(amount)s."),
_("You might want to set the invoice as %(btn_start)sfully paid%(btn_end)s."),
]
else:
lines = [
_("%(display_name_html)s with an open amount of %(open_amount)s will be reduced by %(amount)s."),
_("You might want to %(btn_start)sfully reconcile%(btn_end)s the document."),
]
line.suggestion_amount_currency = line.source_amount_currency
line.suggestion_balance = line.source_balance
display_name_html = markupsafe.Markup("""
<button name="action_redirect_to_move"
class="btn btn-link p-0 align-baseline fst-italic">%(display_name)s</button>
""") % {
'display_name': aml.move_id.display_name,
}
extra_text = markupsafe.Markup('<br/>').join(lines) % {
'amount': formatLang(self.env, reconciled_amount, currency_obj=aml.currency_id),
'open_amount': formatLang(self.env, residual_amount_before_reco, currency_obj=aml.currency_id),
'display_name_html': display_name_html,
'btn_start': markupsafe.Markup(
'<button name="action_apply_line_suggestion" class="btn btn-link p-0 align-baseline fst-italic">'),
'btn_end': markupsafe.Markup('</button>'),
}
line.suggestion_html = markupsafe.Markup("""<div class="text-muted">%s</div>""") % extra_text
def _get_aml_values(self, **kwargs):
self.ensure_one()
return {
'name': self.name,
'account_id': self.account_id.id,
'currency_id': self.currency_id.id,
'amount_currency': self.amount_currency,
'balance': self.debit - self.credit,
'reconcile_model_id': self.reconcile_model_id.id,
'analytic_distribution': self.analytic_distribution,
'tax_repartition_line_id': self.tax_repartition_line_id.id,
'tax_ids': [Command.set(self.tax_ids.ids)],
'tax_tag_ids': [Command.set(self.tax_tag_ids.ids)],
'group_tax_id': self.group_tax_id.id,
**kwargs,
}
+34
View File
@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
from odoo.exceptions import AccessError
class Digest(models.Model):
_inherit = 'digest.digest'
kpi_account_bank_cash = fields.Boolean('Bank & Cash Moves')
kpi_account_bank_cash_value = fields.Monetary(compute='_compute_kpi_account_total_bank_cash_value')
def _compute_kpi_account_total_bank_cash_value(self):
if not self.env.user.has_group('account.group_account_user'):
raise AccessError(_("Do not have access, skip this data for user's digest email"))
start, end, companies = self._get_kpi_compute_parameters()
data = self.env['account.move']._read_group([
('date', '>=', start),
('date', '<', end),
('journal_id.type', 'in', ('cash', 'bank')),
('company_id', 'in', companies.ids),
], ['company_id'], ['amount_total:sum'])
data = dict(data)
for record in self:
company = record.company_id or self.env.company
record.kpi_account_bank_cash_value = data.get(company)
def _compute_kpis_actions(self, company, user):
res = super(Digest, self)._compute_kpis_actions(company, user)
res.update({'kpi_account_bank_cash': 'account.open_account_journal_dashboard_kanban&menu_id=%s' % (self.env.ref('account.menu_finance').id)})
return res
+191
View File
@@ -0,0 +1,191 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields, _
from odoo.tools.misc import DEFAULT_SERVER_DATE_FORMAT
from datetime import timedelta
from odoo.tools import date_utils
class ResCompany(models.Model):
_inherit = 'res.company'
invoicing_switch_threshold = fields.Date(string="Invoicing Switch Threshold", help="Every payment and invoice before this date will receive the 'From Invoicing' status, hiding all the accounting entries related to it. Use this option after installing Accounting if you were using only Invoicing before, before importing all your actual accounting data in to Odoo.")
predict_bill_product = fields.Boolean(string="Predict Bill Product")
# Deferred management
deferred_journal_id = fields.Many2one(
comodel_name='account.journal',
string="Deferred Journal",
)
deferred_expense_account_id = fields.Many2one(
comodel_name='account.account',
string="Deferred Expense",
)
deferred_revenue_account_id = fields.Many2one(
comodel_name='account.account',
string="Deferred Revenue",
)
generate_deferred_expense_entries_method = fields.Selection(
string="Generate Deferred Expense Entries Method",
selection=[
('on_validation', 'On bill validation'),
('manual', 'Manually & Grouped'),
],
default='on_validation',
required=True,
)
generate_deferred_revenue_entries_method = fields.Selection(
string="Generate Deferred Revenue Entries Method",
selection=[
('on_validation', 'On invoice validation'),
('manual', 'Manually & Grouped'),
],
default='on_validation',
required=True,
)
deferred_amount_computation_method = fields.Selection(
string="Deferred Amount Computation Method",
selection=[
('day', 'Based on days'),
('month', 'Equal per month'),
],
default='month',
required=True,
)
def write(self, vals):
old_threshold_vals = {}
for record in self:
old_threshold_vals[record] = record.invoicing_switch_threshold
rslt = super(ResCompany, self).write(vals)
for record in self:
if 'invoicing_switch_threshold' in vals and old_threshold_vals[record] != vals['invoicing_switch_threshold']:
self.env['account.move.line'].flush_model(['move_id', 'parent_state'])
self.env['account.move'].flush_model(['company_id', 'date', 'state', 'payment_state', 'payment_state_before_switch'])
if record.invoicing_switch_threshold:
# If a new date was set as threshold, we switch all the
# posted moves and payments before it to 'invoicing_legacy'.
# We also reset to posted all the moves and payments that
# were 'invoicing_legacy' and were posterior to the threshold
self.env.cr.execute("""
update account_move_line aml
set parent_state = 'posted'
from account_move move
where aml.move_id = move.id
and move.payment_state = 'invoicing_legacy'
and move.date >= %(switch_threshold)s
and move.company_id = %(company_id)s;
update account_move
set state = 'posted',
payment_state = payment_state_before_switch,
payment_state_before_switch = null
where payment_state = 'invoicing_legacy'
and date >= %(switch_threshold)s
and company_id = %(company_id)s;
update account_move_line aml
set parent_state = 'cancel'
from account_move move
where aml.move_id = move.id
and move.state = 'posted'
and move.date < %(switch_threshold)s
and move.company_id = %(company_id)s;
update account_move
set state = 'cancel',
payment_state_before_switch = payment_state,
payment_state = 'invoicing_legacy'
where state = 'posted'
and date < %(switch_threshold)s
and company_id = %(company_id)s;
""", {'company_id': record.id, 'switch_threshold': record.invoicing_switch_threshold})
else:
# If the threshold date has been emptied, we re-post all the
# invoicing_legacy entries.
self.env.cr.execute("""
update account_move_line aml
set parent_state = 'posted'
from account_move move
where aml.move_id = move.id
and move.payment_state = 'invoicing_legacy'
and move.company_id = %(company_id)s;
update account_move
set state = 'posted',
payment_state = payment_state_before_switch,
payment_state_before_switch = null
where payment_state = 'invoicing_legacy'
and company_id = %(company_id)s;
""", {'company_id': record.id})
self.env['account.move.line'].invalidate_model(['parent_state'])
self.env['account.move'].invalidate_model(['state', 'payment_state', 'payment_state_before_switch'])
return rslt
def compute_fiscalyear_dates(self, current_date):
"""Compute the start and end dates of the fiscal year where the given 'date' belongs to.
:param current_date: A datetime.date/datetime.datetime object.
:return: A dictionary containing:
* date_from
* date_to
* [Optionally] record: The fiscal year record.
"""
self.ensure_one()
date_str = current_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
# Search a fiscal year record containing the date.
# If a record is found, then no need further computation, we get the dates range directly.
fiscalyear = self.env['account.fiscal.year'].search([
('company_id', '=', self.id),
('date_from', '<=', date_str),
('date_to', '>=', date_str),
], limit=1)
if fiscalyear:
return {
'date_from': fiscalyear.date_from,
'date_to': fiscalyear.date_to,
'record': fiscalyear,
}
date_from, date_to = date_utils.get_fiscal_year(
current_date, day=self.fiscalyear_last_day, month=int(self.fiscalyear_last_month))
date_from_str = date_from.strftime(DEFAULT_SERVER_DATE_FORMAT)
date_to_str = date_to.strftime(DEFAULT_SERVER_DATE_FORMAT)
# Search for fiscal year records reducing the delta between the date_from/date_to.
# This case could happen if there is a gap between two fiscal year records.
# E.g. two fiscal year records: 2017-01-01 -> 2017-02-01 and 2017-03-01 -> 2017-12-31.
# => The period 2017-02-02 - 2017-02-30 is not covered by a fiscal year record.
fiscalyear_from = self.env['account.fiscal.year'].search([
('company_id', '=', self.id),
('date_from', '<=', date_from_str),
('date_to', '>=', date_from_str),
], limit=1)
if fiscalyear_from:
date_from = fiscalyear_from.date_to + timedelta(days=1)
fiscalyear_to = self.env['account.fiscal.year'].search([
('company_id', '=', self.id),
('date_from', '<=', date_to_str),
('date_to', '>=', date_to_str),
], limit=1)
if fiscalyear_to:
date_to = fiscalyear_to.date_from - timedelta(days=1)
return {'date_from': date_from, 'date_to': date_to}
def _get_fiscalyear_lock_statement_lines_redirect_action(self, unreconciled_statement_lines):
# OVERRIDE account
return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
extra_domain=[('id', 'in', unreconciled_statement_lines.ids)],
name=_('Unreconciled statements lines'),
)
@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import date
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
fiscalyear_last_day = fields.Integer(related='company_id.fiscalyear_last_day', required=True, readonly=False)
fiscalyear_last_month = fields.Selection(related='company_id.fiscalyear_last_month', required=True, readonly=False)
period_lock_date = fields.Date(string='Lock Date for Non-Advisers',
related='company_id.period_lock_date', readonly=False)
fiscalyear_lock_date = fields.Date(string='Lock Date for All Users',
related='company_id.fiscalyear_lock_date', readonly=False)
tax_lock_date = fields.Date("Tax Lock Date", related='company_id.tax_lock_date', readonly=False)
use_anglo_saxon = fields.Boolean(string='Anglo-Saxon Accounting', related='company_id.anglo_saxon_accounting', readonly=False)
invoicing_switch_threshold = fields.Date(string="Invoicing Switch Threshold", related='company_id.invoicing_switch_threshold', readonly=False)
group_fiscal_year = fields.Boolean(string='Fiscal Years', implied_group='account_accountant.group_fiscal_year')
predict_bill_product = fields.Boolean(string="Predict Bill Product", related='company_id.predict_bill_product', readonly=False)
# Deferred management
deferred_journal_id = fields.Many2one(
comodel_name='account.journal',
string='Deferred Entries Journal',
help='Journal used for deferred entries',
readonly=False,
related='company_id.deferred_journal_id',
)
deferred_expense_account_id = fields.Many2one(
comodel_name='account.account',
string='Deferred Expense',
help='Account used for deferred expenses',
readonly=False,
related='company_id.deferred_expense_account_id',
)
deferred_revenue_account_id = fields.Many2one(
comodel_name='account.account',
string='Deferred Revenue',
help='Account used for deferred revenues',
readonly=False,
related='company_id.deferred_revenue_account_id',
)
generate_deferred_expense_entries_method = fields.Selection(
related='company_id.generate_deferred_expense_entries_method',
readonly=False, required=True,
help='Method used to generate deferred expense entries',
)
generate_deferred_revenue_entries_method = fields.Selection(
related='company_id.generate_deferred_revenue_entries_method',
readonly=False, required=True,
help='Method used to generate deferred revenue entries',
)
deferred_amount_computation_method = fields.Selection(
related='company_id.deferred_amount_computation_method',
readonly=False, required=True,
help='Method used to compute the amount of deferred entries',
)
@api.constrains('fiscalyear_last_day', 'fiscalyear_last_month')
def _check_fiscalyear(self):
# We try if the date exists in 2020, which is a leap year.
# We do not define the constrain on res.company, since the recomputation of the related
# fields is done one field at a time.
for wiz in self:
try:
date(2020, int(wiz.fiscalyear_last_month), wiz.fiscalyear_last_day)
except ValueError:
raise ValidationError(
_('Incorrect fiscal year date: day is out of range for month. Month: %s; Day: %s',
wiz.fiscalyear_last_month, wiz.fiscalyear_last_day)
)
@api.model_create_multi
def create(self, vals_list):
# Amazing workaround: non-stored related fields on company are a BAD idea since the 2 fields
# must follow the constraint '_check_fiscalyear_last_day'. The thing is, in case of related
# fields, the inverse write is done one value at a time, and thus the constraint is verified
# one value at a time... so it is likely to fail.
for vals in vals_list:
fiscalyear_last_day = vals.pop('fiscalyear_last_day', False) or self.env.company.fiscalyear_last_day
fiscalyear_last_month = vals.pop('fiscalyear_last_month', False) or self.env.company.fiscalyear_last_month
vals = {}
if fiscalyear_last_day != self.env.company.fiscalyear_last_day:
vals['fiscalyear_last_day'] = fiscalyear_last_day
if fiscalyear_last_month != self.env.company.fiscalyear_last_month:
vals['fiscalyear_last_month'] = fiscalyear_last_month
if vals:
self.env.company.write(vals)
return super().create(vals_list)
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="base.user_root" model="res.users">
<field name="groups_id" eval="[(4, ref('account.group_account_user'))]"/>
</record>
<record id="base.user_admin" model="res.users">
<field name="groups_id" eval="[(4, ref('account.group_account_user'))]"/>
</record>
<!-- To change the categoy name from Invoicing -> Accounting-->
<record model="ir.module.category" id="base.module_category_accounting_accounting">
<field name="name">Accounting</field>
</record>
<record id="account.group_account_readonly" model="res.groups">
<field name="name">Read-only</field>
<field name="category_id" ref="base.module_category_accounting_accounting"/>
</record>
<record id="account.group_account_user" model="res.groups">
<field name="name">Bookkeeper</field>
<field name="implied_ids" eval="[(4, ref('account.group_account_invoice')), (4, ref('account.group_account_readonly'))]"/>
<field name="category_id" ref="base.module_category_accounting_accounting"/>
</record>
<record id="account.group_account_manager" model="res.groups">
<field name="name">Accountant</field>
<field name="implied_ids" eval="[(3, ref('account.group_account_invoice')), (4, ref('account.group_account_user'))]"/>
<field name="category_id" ref="base.module_category_accounting_accounting"/>
</record>
<record id="account.group_account_invoice" model="res.groups">
<field name="category_id" ref="base.module_category_accounting_accounting"/>
</record>
<record id="group_fiscal_year" model="res.groups">
<field name="name">Allow to define fiscal years of more or less than a year</field>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
</odoo>
@@ -0,0 +1,10 @@
"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
"access_account_change_lock_date","access.account.change.lock.date","model_account_change_lock_date","account.group_account_manager",1,1,1,0
"access_account_auto_reconcile_wizard","access.account.auto.reconcile.wizard","model_account_auto_reconcile_wizard","account.group_account_user",1,1,1,0
"access_account_reconcile_wizard","access.account.reconcile.wizard","model_account_reconcile_wizard","account.group_account_user",1,1,1,0
access_account_fiscal_year_readonly,account.fiscal.year.user,model_account_fiscal_year,account.group_account_readonly,1,0,0,0
access_account_fiscal_year_manager,account.fiscal.year.manager,model_account_fiscal_year,account.group_account_manager,1,1,1,1
access_bank_rec_widget,access.bank.rec.widget,model_bank_rec_widget,account.group_account_user,1,1,1,1
access_bank_rec_widget_line,access.bank.rec.widget.line,model_bank_rec_widget_line,account.group_account_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_account_change_lock_date access.account.change.lock.date model_account_change_lock_date account.group_account_manager 1 1 1 0
3 access_account_auto_reconcile_wizard access.account.auto.reconcile.wizard model_account_auto_reconcile_wizard account.group_account_user 1 1 1 0
4 access_account_reconcile_wizard access.account.reconcile.wizard model_account_reconcile_wizard account.group_account_user 1 1 1 0
5 access_account_fiscal_year_readonly account.fiscal.year.user model_account_fiscal_year account.group_account_readonly 1 0 0 0
6 access_account_fiscal_year_manager account.fiscal.year.manager model_account_fiscal_year account.group_account_manager 1 1 1 1
7 access_bank_rec_widget access.bank.rec.widget model_bank_rec_widget account.group_account_user 1 1 1 1
8 access_bank_rec_widget_line access.bank.rec.widget.line model_bank_rec_widget_line account.group_account_user 1 1 1 1
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1 @@
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M40.682 41.263c-3.514 4.188-9.758 4.734-13.946 1.22-4.189-3.514-4.735-9.758-1.22-13.947 3.514-4.188 9.758-4.734 13.946-1.22 4.188 3.515 4.734 9.759 1.22 13.947Z" fill="#1AD3BB"/><path d="M25.383 21.464c-3.514 4.188-9.758 4.734-13.946 1.22-4.188-3.514-4.735-9.758-1.22-13.946 3.514-4.189 9.758-4.735 13.946-1.22 4.188 3.514 4.734 9.758 1.22 13.946Z" fill="#FBB945"/><path d="M37.09 6.545c1.407-1.406 3.36-1.732 4.364-.728L46 10.363l-33.09 33.09c-1.407 1.406-3.36 1.732-4.365.728L4 39.636 37.09 6.544Z" fill="#985184"/><path d="m31.174 25.188-7.79 7.789a9.853 9.853 0 0 1 2.13-4.442 9.858 9.858 0 0 1 5.66-3.348Z" fill="#005E7A"/><path d="M18.676 24.962a9.868 9.868 0 0 0 6.707-3.498 9.854 9.854 0 0 0 2.278-5.487l-8.985 8.985Z" fill="#953B24"/></svg>

After

Width:  |  Height:  |  Size: 842 B

@@ -0,0 +1,25 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { Component } from "@odoo/owl";
class OpenBankRecWidget extends Component {
setup() {
this.action = useService("action");
}
async openBankRec(ev) {
this.action.doActionButton({
type: "object",
resId: this.props.record.resId,
name: "action_open_bank_reconcile_widget",
resModel: "account.bank.statement",
});
}
}
OpenBankRecWidget.template = "account.OpenBankRecWidget";
registry.category("fields").add("bank_rec_widget_open", {
component: OpenBankRecWidget,
});
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-name="account.OpenBankRecWidget">
<a href="#" t-out="props.record.data[props.name]" t-on-click.prevent.stop="(ev) => this.openBankRec()"/>
</t>
</templates>
@@ -0,0 +1,49 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { EmbeddedListView } from "./embedded_list_view";
import { ListRenderer } from "@web/views/list/list_renderer";
import { useState, onWillUnmount } from "@odoo/owl";
export class BankRecAmlsRenderer extends ListRenderer {
setup() {
super.setup();
this.globalState = useState(this.env.methods.getState());
onWillUnmount(this.saveSearchState);
}
/** @override **/
getRowClass(record) {
const classes = super.getRowClass(record);
const amlId = this.globalState.bankRecRecordData.selected_aml_ids.currentIds.find((x) => x === record.resId);
if (amlId){
return `${classes} o_rec_widget_list_selected_item table-info`;
}
return classes;
}
/** @override **/
async onCellClicked(record, column, ev) {
const amlId = this.globalState.bankRecRecordData.selected_aml_ids.currentIds.find((x) => x === record.resId);
if (amlId) {
this.env.config.actionRemoveNewAml(record.resId);
} else {
this.env.config.actionAddNewAml(record.resId);
}
}
/** Backup the search facets in order to restore them when the user comes back on this view. **/
saveSearchState() {
const initParams = this.globalState.bankRecEmbeddedViewsData.amls;
const searchModel = this.env.searchModel;
initParams.exportState = {searchModel: JSON.stringify(searchModel.exportState())};
}
}
export const BankRecAmls = {
...EmbeddedListView,
Renderer: BankRecAmlsRenderer,
};
registry.category("views").add("bank_rec_amls_list_view", BankRecAmls);

Some files were not shown because too many files have changed in this diff Show More