init
This commit is contained in:
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
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
Reference in New Issue
Block a user