19.0 vanilla

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

View file

@ -1,16 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account_automatic_entry_wizard
from . import account_unreconcile
from . import account_autopost_bills_wizard
from . import account_validate_account_move
from . import account_move_reversal
from . import account_resequence
from . import account_secure_entries_wizard
from . import setup_wizards
from . import account_invoice_send
from . import account_move_send_wizard
from . import account_move_send_batch_wizard
from . import base_document_layout
from . import account_payment_register
from . import account_tour_upload_bill
from . import accrued_orders
from . import base_partner_merge
from . import account_merge_wizard

View file

@ -2,14 +2,19 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from odoo.tools.misc import format_date, formatLang
from odoo.tools.float_utils import float_repr
from odoo.tools import groupby
from collections import defaultdict
from odoo.tools import groupby, frozendict
from markupsafe import Markup, escape
from odoo.tools import frozendict
import json
class AutomaticEntryWizard(models.TransientModel):
class AccountAutomaticEntryWizard(models.TransientModel):
_name = 'account.automatic.entry.wizard'
_description = 'Create Automatic Entries'
_check_company_auto = True
# General
action = fields.Selection([('change_period', 'Change Period'), ('change_account', 'Change Account')], required=True)
@ -22,7 +27,8 @@ class AutomaticEntryWizard(models.TransientModel):
percentage = fields.Float("Percentage", compute='_compute_percentage', readonly=False, store=True, help="Percentage of each line to execute the action on.")
total_amount = fields.Monetary(compute='_compute_total_amount', store=True, readonly=False, currency_field='company_currency_id', help="Total amount impacted by the automatic entry.")
journal_id = fields.Many2one('account.journal', required=True, readonly=False, string="Journal",
domain="[('company_id', '=', company_id), ('type', '=', 'general')]",
check_company=True,
domain="[('type', '=', 'general')]",
compute="_compute_journal_id",
inverse="_inverse_journal_id",
help="Journal where to create the entry.")
@ -30,23 +36,21 @@ class AutomaticEntryWizard(models.TransientModel):
# change period
account_type = fields.Selection([('income', 'Revenue'), ('expense', 'Expense')], compute='_compute_account_type', store=True)
expense_accrual_account = fields.Many2one('account.account', readonly=False,
domain="[('company_id', '=', company_id),"
"('account_type', 'not in', ('asset_receivable', 'liability_payable')),"
"('is_off_balance', '=', False)]",
check_company=True,
domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'off_balance'))]",
compute="_compute_expense_accrual_account",
inverse="_inverse_expense_accrual_account",
)
revenue_accrual_account = fields.Many2one('account.account', readonly=False,
domain="[('company_id', '=', company_id),"
"('account_type', 'not in', ('asset_receivable', 'liability_payable')),"
"('is_off_balance', '=', False)]",
check_company=True,
domain="[('account_type', 'not in', ('asset_receivable', 'liability_payable', 'off_balance'))]",
compute="_compute_revenue_accrual_account",
inverse="_inverse_revenue_accrual_account",
)
lock_date_message = fields.Char(string="Lock Date Message", compute="_compute_lock_date_message")
# change account
destination_account_id = fields.Many2one(string="To", comodel_name='account.account', help="Account to transfer to.")
destination_account_id = fields.Many2one(string="To", comodel_name='account.account', help="Account to transfer to.", check_company=True)
display_currency_helper = fields.Boolean(string="Currency Conversion Helper", compute='_compute_display_currency_helper')
# Technical field. Used to indicate whether or not to display the currency conversion tooltip. The tooltip informs a currency conversion will be performed with the transfer.
@ -121,8 +125,13 @@ class AutomaticEntryWizard(models.TransientModel):
@api.constrains('date', 'move_line_ids')
def _check_date(self):
for wizard in self:
if wizard.move_line_ids.move_id._get_violated_lock_dates(wizard.date, False):
raise ValidationError(_("The date selected is protected by a lock date"))
for move in wizard.move_line_ids.move_id:
violated_lock_dates = move._get_violated_lock_dates(wizard.date, False)
if violated_lock_dates:
raise ValidationError(_(
"The date selected is protected by: %(lock_date_info)s.",
lock_date_info=self.env['res.company']._format_lock_dates(violated_lock_dates)
))
@api.model
def default_get(self, fields):
@ -136,12 +145,12 @@ class AutomaticEntryWizard(models.TransientModel):
res['move_line_ids'] = [(6, 0, move_line_ids.ids)]
if any(move.state != 'posted' for move in move_line_ids.mapped('move_id')):
raise UserError(_('You can only change the period/account for posted journal items.'))
raise UserError(_("Oops! You can only change the period or account for posted entries! Other ones aren't up for an adventure like that!"))
if any(move_line.reconciled for move_line in move_line_ids):
raise UserError(_('You can only change the period/account for items that are not yet reconciled.'))
if any(line.company_id != move_line_ids[0].company_id for line in move_line_ids):
raise UserError(_("Oops! You can only change the period or account for items that are not yet reconciled! Other ones aren't up for an adventure like that!"))
if any(line.company_id.root_id != move_line_ids[0].company_id.root_id for line in move_line_ids):
raise UserError(_('You cannot use this wizard on journal entries belonging to different companies.'))
res['company_id'] = move_line_ids[0].company_id.id
res['company_id'] = move_line_ids[0].company_id.root_id.id
allowed_actions = set(dict(self._fields['action'].selection))
if self.env.context.get('default_action'):
@ -153,6 +162,11 @@ class AutomaticEntryWizard(models.TransientModel):
res['action'] = allowed_actions.pop()
return res
def _get_cut_off_label_format(self):
""" Get the translated format string used in cut-off labels """
self.ensure_one()
return _("Cut-off {label}") if self.percentage == 100 else _("Cut-off {label} {percent}%")
def _get_move_dict_vals_change_account(self):
line_vals = []
@ -230,10 +244,17 @@ class AutomaticEntryWizard(models.TransientModel):
'analytic_distribution': analytic_distribution,
})
# Get the lowest child company based on accounts used to avoid access error
accounts = self.env['account.account'].browse([line['account_id'] for line in line_vals])
companies = accounts.company_ids.filtered(lambda c: self.env.company in c.parent_ids) | self.env.company
lowest_child_company = max(companies, key=lambda company: len(company.parent_ids))
return [{
'currency_id': self.journal_id.currency_id.id or self.journal_id.company_id.currency_id.id,
'move_type': 'entry',
'name': '/',
'journal_id': self.journal_id.id,
'company_id': lowest_child_company.id,
'date': fields.Date.to_string(self.date),
'ref': self.destination_account_id.display_name and _("Transfer entry to %s", self.destination_account_id.display_name or ''),
'line_ids': [(0, 0, line) for line in line_vals],
@ -245,11 +266,12 @@ class AutomaticEntryWizard(models.TransientModel):
reported_debit = aml.company_id.currency_id.round((self.percentage / 100) * aml.debit)
reported_credit = aml.company_id.currency_id.round((self.percentage / 100) * aml.credit)
reported_amount_currency = aml.currency_id.round((self.percentage / 100) * aml.amount_currency)
name = self._format_strings(self._get_cut_off_label_format(), aml.move_id)
if date == 'new_date':
return [
(0, 0, {
'name': aml.name or '',
'name': name,
'debit': reported_debit,
'credit': reported_credit,
'amount_currency': reported_amount_currency,
@ -259,7 +281,7 @@ class AutomaticEntryWizard(models.TransientModel):
'analytic_distribution': aml.analytic_distribution,
}),
(0, 0, {
'name': self._format_strings(_('{percent:0.2f}% to recognize on {new_date}'), aml.move_id),
'name': name,
'debit': reported_credit,
'credit': reported_debit,
'amount_currency': -reported_amount_currency,
@ -271,7 +293,7 @@ class AutomaticEntryWizard(models.TransientModel):
]
return [
(0, 0, {
'name': aml.name or '',
'name': name,
'debit': reported_credit,
'credit': reported_debit,
'amount_currency': -reported_amount_currency,
@ -281,7 +303,7 @@ class AutomaticEntryWizard(models.TransientModel):
'analytic_distribution': aml.analytic_distribution,
}),
(0, 0, {
'name': self._format_strings(_('{percent:0.2f}% to recognize on {new_date}'), aml.move_id),
'name': name,
'debit': reported_debit,
'credit': reported_credit,
'amount_currency': reported_amount_currency,
@ -304,13 +326,15 @@ class AutomaticEntryWizard(models.TransientModel):
# set the change_period account on the selected journal items
ref_format = self._get_cut_off_label_format()
move_data = {'new_date': {
'currency_id': self.journal_id.currency_id.id or self.journal_id.company_id.currency_id.id,
'move_type': 'entry',
'line_ids': [],
'ref': self._format_strings(_('{label}: Adjusting Entry of {new_date}'), self.move_line_ids[0].move_id),
'ref': self._format_strings(ref_format, self.move_line_ids[0].move_id),
'date': fields.Date.to_string(self.date),
'journal_id': self.journal_id.id,
'adjusting_entry_origin_move_ids': self.move_line_ids.move_id.ids,
}}
# complete the account.move data
for date, grouped_lines in groupby(self.move_line_ids, get_lock_safe_date):
@ -320,14 +344,15 @@ class AutomaticEntryWizard(models.TransientModel):
'currency_id': self.journal_id.currency_id.id or self.journal_id.company_id.currency_id.id,
'move_type': 'entry',
'line_ids': [],
'ref': self._format_strings(_('{label}: Adjusting Entry of {date}'), grouped_lines[0].move_id, amount),
'ref': self._format_strings(ref_format, grouped_lines[0].move_id, amount),
'date': fields.Date.to_string(date),
'journal_id': self.journal_id.id,
'adjusting_entry_origin_move_ids': self.move_line_ids.move_id.ids,
}
# compute the account.move.lines and the total amount per move
for aml in self.move_line_ids:
for date in (get_lock_safe_date(aml), 'new_date'):
for date in ('new_date', get_lock_safe_date(aml)):
move_data[date]['line_ids'] += self._get_move_line_dict_vals_change_period(aml, date)
move_vals = [m for m in move_data.values()]
@ -399,26 +424,39 @@ class AutomaticEntryWizard(models.TransientModel):
accrual_move_lines = accrual_move.mapped('line_ids').filtered(lambda line: line.account_id == accrual_account)[accrual_move_offsets[accrual_move]:accrual_move_offsets[accrual_move]+2]
accrual_move_offsets[accrual_move] += 2
(accrual_move_lines + destination_move_lines).filtered(lambda line: not line.currency_id.is_zero(line.balance)).reconcile()
move.message_post(body=self._format_strings(_('Adjusting Entries have been created for this invoice:<ul><li>%(link1)s cancelling '
'{percent:.2f}%% of {amount}</li><li>%(link0)s postponing it to {new_date}</li></ul>',
link0=self._format_move_link(destination_move),
link1=self._format_move_link(accrual_move),
), move, amount))
destination_messages += [self._format_strings(_('Adjusting Entry {link}: {percent:.2f}% of {amount} recognized from {date}'), move, amount)]
accrual_move_messages[accrual_move] += [self._format_strings(_('Adjusting Entry for {link}: {percent:.2f}% of {amount} recognized on {new_date}'), move, amount)]
body = Markup("%(title)s<ul><li>%(link1)s %(second)s</li><li>%(link2)s %(third)s</li></ul>") % {
'title': _("Adjusting Entries have been created for this invoice:"),
'link1': self._format_move_link(accrual_move),
'second': self._format_strings(_("cancelling {percent}%% of {amount}"), move, amount),
'link2': self._format_move_link(destination_move),
'third': self._format_strings(_("postponing it to {new_date}"), move, amount),
}
move.message_post(body=body)
destination_messages += [
self._format_strings(
escape(_("Adjusting Entry {link} {percent}%% of {amount} recognized from {date}")),
move, amount,
)
]
accrual_move_messages[accrual_move] += [
self._format_strings(
escape(_("Adjusting Entry {link} {percent}%% of {amount} recognized on {new_date}")),
move, amount,
)
]
destination_move.message_post(body='<br/>\n'.join(destination_messages))
destination_move.message_post(body=Markup('<br/>\n').join(destination_messages))
for accrual_move, messages in accrual_move_messages.items():
accrual_move.message_post(body='<br/>\n'.join(messages))
accrual_move.message_post(body=Markup('<br/>\n').join(messages))
# open the generated entries
action = {
'name': _('Generated Entries'),
'domain': [('id', 'in', created_moves.ids)],
'res_model': 'account.move',
'view_mode': 'tree,form',
'view_mode': 'list,form',
'type': 'ir.actions.act_window',
'views': [(self.env.ref('account.view_move_tree').id, 'tree'), (False, 'form')],
'views': [(self.env.ref('account.view_move_tree').id, 'list'), (False, 'form')],
}
if len(created_moves) == 1:
action.update({'view_mode': 'form', 'res_id': created_moves.id})
@ -460,7 +498,6 @@ class AutomaticEntryWizard(models.TransientModel):
return {
'name': _("Transfer"),
'type': 'ir.actions.act_window',
'view_type': 'form',
'view_mode': 'form',
'res_model': 'account.move',
'res_id': new_move.id,
@ -468,31 +505,45 @@ class AutomaticEntryWizard(models.TransientModel):
# Transfer utils
def _format_new_transfer_move_log(self, acc_transfer_per_move):
format = _("<li>{amount} ({debit_credit}) from {link}, <strong>%(account_source_name)s</strong></li>")
rslt = _("This entry transfers the following amounts to <strong>%(destination)s</strong> <ul>", destination=self.destination_account_id.display_name)
for move, balances_per_account in acc_transfer_per_move.items():
for account, balance in balances_per_account.items():
if account != self.destination_account_id: # Otherwise, logging it here is confusing for the user
rslt += self._format_strings(format % {'account_source_name': account.display_name}, move, balance)
rslt += '</ul>'
transfer_format = Markup("<li>%s, <strong>%%(account_source_name)s</strong></li>") % \
_("{amount} ({debit_credit}) from {link}")
rslt = _(
"This entry transfers the following amounts to %(destination)s",
destination=Markup("<strong>%s</strong>") % self.destination_account_id.display_name,
) + Markup("<ul>%(transfer_logs)s</ul>") % {
"transfer_logs": Markup().join(
[
self._format_strings(transfer_format % {"account_source_name": account.display_name}, move, balance)
for move, balances_per_account in acc_transfer_per_move.items()
for account, balance in balances_per_account.items()
if account != self.destination_account_id # Otherwise, logging it here is confusing for the user
],
),
}
return rslt
def _format_transfer_source_log(self, balances_per_account, transfer_move):
transfer_format = _("<li>{amount} ({debit_credit}) from <strong>%s</strong> were transferred to <strong>{account_target_name}</strong> by {link}</li>")
content = ''
for account, balance in balances_per_account.items():
if account != self.destination_account_id:
content += self._format_strings(transfer_format % account.display_name, transfer_move, balance)
return content and '<ul>' + content + '</ul>' or None
if not balances_per_account:
return None
transfer_format = Markup(
_("{amount} ({debit_credit}) from <strong>{account_source_name}</strong> were transferred to <strong>{account_target_name}</strong> by {link}")
)
return Markup("<ul>%s</ul>") % Markup().join([
Markup("<li>%s</li>") % \
self._format_strings(transfer_format, transfer_move, balance, account.display_name)
for account, balance in balances_per_account.items()
if account != self.destination_account_id
])
def _format_move_link(self, move):
return move._get_html_link()
def _format_strings(self, string, move, amount=None):
def _format_strings(self, string, move, amount=None, account_source_name=''):
return string.format(
label=move.name or 'Adjusting Entry',
percent=self.percentage,
label=move.name or _('Adjusting Entry'),
percent=float_repr(self.percentage, 2),
name=move.name,
id=move.id,
amount=formatLang(self.env, abs(amount), currency_obj=self.company_id.currency_id) if amount else '',
@ -500,5 +551,6 @@ class AutomaticEntryWizard(models.TransientModel):
link=self._format_move_link(move),
date=format_date(self.env, move.date),
new_date=self.date and format_date(self.env, self.date) or _('[Not set]'),
account_source_name=account_source_name,
account_target_name=self.destination_account_id.display_name,
)

View file

@ -10,32 +10,34 @@
<field name="company_id" invisible="1"/>
<field name="move_line_ids" invisible="1"/>
<field name="display_currency_helper" invisible="1"/>
<div attrs="{'invisible': [('display_currency_helper', '=', False)]}" class="alert alert-info text-center" role="status">
<field name="action" invisible="1"/>
<div invisible="not display_currency_helper" class="alert alert-info text-center" role="status">
The selected destination account is set to use a specific currency. Every entry transferred to it will be converted into this currency, causing
the loss of any pre-existing foreign currency amount.
</div>
<div attrs="{'invisible': [('lock_date_message', '=', False)]}" class="alert alert-warning text-center" role="status">
<div invisible="not lock_date_message" class="alert alert-warning text-center" role="status">
<field name="lock_date_message" nolabel="1"/>
</div>
<field name="action" invisible="context.get('hide_automatic_options')" widget="radio" options="{'horizontal': true}"/>
<group>
<group attrs="{'invisible': [('action', '!=', 'change_period')]}">
<group invisible="action != 'change_period'">
<field name="date" string="Recognition Date"/>
<field name="expense_accrual_account" string="Accrued Account"
attrs="{'invisible': [('account_type', '!=', 'expense')], 'required': [('account_type', '=', 'expense'), ('action', '=', 'change_period')]}"/>
invisible="account_type != 'expense'"
required="account_type == 'expense' and action == 'change_period'"/>
<field name="revenue_accrual_account" string="Accrued Account"
attrs="{'invisible': [('account_type', '!=', 'income')], 'required': [('account_type', '=', 'income'), ('action', '=', 'change_period')]}"/>
invisible="account_type != 'income'"
required="account_type == 'income' and action == 'change_period'"/>
</group>
<group attrs="{'invisible': [('action', '!=', 'change_account')]}">
<group invisible="action != 'change_account'">
<field name="date" string="Transfer Date"/>
<field name="destination_account_id" attrs="{'required': [('action', '=', 'change_account')]}" domain="[('company_id', '=', company_id)]"/>
<field name="destination_account_id" required="action == 'change_account'"/>
</group>
<group>
<label for="total_amount" string="Adjusting Amount" attrs="{'invisible': [('action', '!=', 'change_period')]}"/>
<div attrs="{'invisible': [('action', '!=', 'change_period')]}">
<field name="percentage" style="width:40% !important" attrs="{'readonly': [('action', '!=', 'change_period')]}"/>%<span class="px-3"></span>(<field name="total_amount" class="oe_inline"/>)
<label for="total_amount" string="Adjusting Amount" invisible="action != 'change_period'"/>
<div invisible="action != 'change_period'">
<field name="percentage" style="width:40% !important" readonly="action != 'change_period'"/>%<span class="px-3"></span>(<field name="total_amount" class="oe_inline"/>)
</div>
<field name="total_amount" readonly="1" attrs="{'invisible': [('action', '=', 'change_period')]}"/>
<field name="total_amount" readonly="1" invisible="action == 'change_period'"/>
<field name="journal_id"/>
</group>
</group>
@ -43,14 +45,14 @@
<field name="preview_move_data" widget="grouped_view_widget" class="d-block"/>
<footer>
<button string="Create Journal Entries" name="do_action" type="object" class="oe_highlight" data-hotkey="q"/>
<button string="Cancel" class="btn btn-secondary" special="cancel" data-hotkey="z"/>
<button string="Cancel" class="btn btn-secondary" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>
</record>
<record id="account_automatic_entry_wizard_action" model="ir.actions.act_window">
<field name="name">Create Automatic Entries for selected Journal Items</field>
<field name="name">Transfer Journal Items</field>
<field name="res_model">account.automatic.entry.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>

View file

@ -0,0 +1,22 @@
from odoo import models, fields
class AccountAutopostBillsWizard(models.TransientModel):
_name = 'account.autopost.bills.wizard'
_description = "Autopost Bills Wizard"
partner_id = fields.Many2one("res.partner")
partner_name = fields.Char(related="partner_id.name")
nb_unmodified_bills = fields.Integer("Number of bills previously unmodified from this partner")
def action_automate_partner(self):
for wizard in self:
wizard.partner_id.autopost_bills = 'always'
def action_ask_later(self):
for wizard in self:
wizard.partner_id.autopost_bills = 'ask'
def action_never_automate_partner(self):
for wizard in self:
wizard.partner_id.autopost_bills = 'never'

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="autopost_bills_wizard" model="ir.ui.view">
<field name="name">Autopost Bills</field>
<field name="model">account.autopost.bills.wizard</field>
<field name="arch" type="xml">
<form string="Autopost Bills">
<p>Hey there !</p>
<p>
It looks like you've successfully validated the last
<b>
<field name="nb_unmodified_bills" readonly="True" class="oe_inline" invisible="nb_unmodified_bills &gt; 9"/>
<span invisible="nb_unmodified_bills &lt; 10">10+</span>
</b>
bills for <b><field name="partner_name"/></b> without making any corrections.
</p>
<p>Want to make your life even easier and automate bill validation from this vendor ?</p>
<p class="text-muted">
<i class="fa fa-lightbulb-o" role="img"/>
Don't worry, you can always change this setting later on the vendor's form.
You also have the option to disable the feature for all vendors in the accounting settings.
</p>
<footer>
<button class="btn-primary" type="object" name="action_automate_partner">Activate auto-validation</button>
<button class="btn-secondary" type="object" name="action_ask_later">Ask me later</button>
<button class="btn-secondary" type="object" name="action_never_automate_partner">Never for this vendor</button>
</footer>
</form>
</field>
</record>
</data>
</odoo>

View file

@ -1,175 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.addons.mail.wizard.mail_compose_message import _reopen
from odoo.exceptions import UserError
from odoo.tools.misc import get_lang
class AccountInvoiceSend(models.TransientModel):
_name = 'account.invoice.send'
_inherits = {'mail.compose.message':'composer_id'}
_description = 'Account Invoice Send'
is_email = fields.Boolean('Email', default=lambda self: self.env.company.invoice_is_email)
invoice_without_email = fields.Text(compute='_compute_invoice_without_email', string='invoice(s) that will not be sent')
is_print = fields.Boolean('Print', default=lambda self: self.env.company.invoice_is_print)
printed = fields.Boolean('Is Printed', default=False)
invoice_ids = fields.Many2many('account.move', 'account_move_account_invoice_send_rel', string='Invoices')
composer_id = fields.Many2one('mail.compose.message', string='Composer', required=True, ondelete='cascade')
template_id = fields.Many2one(
'mail.template', 'Use template',
domain="[('model', '=', 'account.move')]"
)
# Technical field containing a textual representation of the selected move types,
# if multiple. It is used to inform the user in the window in such case.
move_types = fields.Char(
string='Move types',
compute='_compute_move_types',
readonly=True)
@api.model
def default_get(self, fields):
res = super(AccountInvoiceSend, self).default_get(fields)
res_ids = self._context.get('active_ids')
invoices = self.env['account.move'].browse(res_ids).filtered(lambda move: move.is_invoice(include_receipts=True))
if not invoices:
raise UserError(_("You can only send invoices."))
composer = self.env['mail.compose.message'].create({
'composition_mode': 'comment' if len(res_ids) == 1 else 'mass_mail',
})
res.update({
'invoice_ids': res_ids,
'composer_id': composer.id,
})
return res
@api.onchange('invoice_ids')
def _compute_composition_mode(self):
for wizard in self:
wizard.composer_id.composition_mode = 'comment' if len(wizard.invoice_ids) == 1 else 'mass_mail'
@api.onchange('invoice_ids')
def _compute_move_types(self):
for wizard in self:
move_types = False
if len(wizard.invoice_ids) > 1:
moves = self.env['account.move'].browse(self.env.context.get('active_ids'))
# Get the move types of all selected moves and see if there is more than one of them.
# If so, we'll display a warning on the next window about it.
move_types_set = set(m.type_name for m in moves)
if len(move_types_set) > 1:
move_types = ', '.join(move_types_set)
wizard.move_types = move_types
@api.onchange('template_id')
def onchange_template_id(self):
for wizard in self:
if wizard.composer_id:
wizard.composer_id.template_id = wizard.template_id.id
wizard._compute_composition_mode()
wizard.composer_id._onchange_template_id_wrapper()
@api.onchange('is_email')
def onchange_is_email(self):
if self.is_email:
res_ids = self._context.get('active_ids')
if not self.composer_id:
self.composer_id = self.env['mail.compose.message'].create({
'composition_mode': 'comment' if len(res_ids) == 1 else 'mass_mail',
'template_id': self.template_id.id
})
else:
self.composer_id.composition_mode = 'comment' if len(res_ids) == 1 else 'mass_mail'
self.composer_id.template_id = self.template_id.id
self._compute_composition_mode()
self.composer_id._onchange_template_id_wrapper()
@api.onchange('is_email')
def _compute_invoice_without_email(self):
for wizard in self:
if wizard.is_email and len(wizard.invoice_ids) > 1:
invoices = self.env['account.move'].search([
('id', 'in', self.env.context.get('active_ids')),
('partner_id.email', '=', False)
])
if invoices:
wizard.invoice_without_email = "%s\n%s" % (
_("The following invoice(s) will not be sent by email, because the customers don't have email address."),
"\n".join([i.name for i in invoices])
)
else:
wizard.invoice_without_email = False
else:
wizard.invoice_without_email = False
def _send_email(self):
if self.is_email:
# with_context : we don't want to reimport the file we just exported.
self.composer_id.with_context(no_new_invoice=True,
mail_notify_author=self.env.user.partner_id in self.composer_id.partner_ids,
mailing_document_based=True,
)._action_send_mail()
if self.env.context.get('mark_invoice_as_sent'):
#Salesman send posted invoice, without the right to write
#but they should have the right to change this flag
self.mapped('invoice_ids').sudo().write({'is_move_sent': True})
for invoice in self.invoice_ids:
prioritary_attachments = False
if self.composition_mode == 'comment':
# With a single invoice we take the attachment directly from the composer
prioritary_attachments = self.attachment_ids.filtered(lambda x: x.mimetype.endswith('pdf')).sorted('id')
elif self.composition_mode == 'mass_mail':
# In mass mail mode we need to look for attachment in the invoice record
prioritary_attachments = invoice.attachment_ids.filtered(lambda x: x.mimetype.endswith('pdf'))
if prioritary_attachments:
main_attachment = prioritary_attachments[0]
invoice.with_context(tracking_disable=True).sudo().write({'message_main_attachment_id': main_attachment.id})
def _print_document(self):
""" to override for each type of models that will use this composer."""
self.ensure_one()
action = self.invoice_ids.action_invoice_print()
action.update({'close_on_report_download': True})
return action
def send_and_print_action(self):
self.ensure_one()
# Send the mails in the correct language by splitting the ids per lang.
# This should ideally be fixed in mail_compose_message, so when a fix is made there this whole commit should be reverted.
# basically self.body (which could be manually edited) extracts self.template_id,
# which is then not translated for each customer.
if self.composition_mode == 'mass_mail' and self.template_id:
active_ids = self.env.context.get('active_ids', self.res_id)
active_records = self.env[self.model].browse(active_ids)
langs = set(active_records.mapped('partner_id.lang'))
for lang in langs:
active_ids_lang = active_records.filtered(lambda r: r.partner_id.lang == lang).ids
self_lang = self.with_context(active_ids=active_ids_lang, lang=get_lang(self.env, lang).code)
self_lang.onchange_template_id()
self_lang._send_email()
else:
active_record = self.env[self.model].browse(self.res_id)
lang = get_lang(self.env, active_record.partner_id.lang).code
self.with_context(lang=lang)._send_email()
if self.is_print:
return self._print_document()
return {'type': 'ir.actions.act_window_close'}
def save_as_template(self):
self.ensure_one()
self.composer_id.action_save_as_template()
self.template_id = self.composer_id.template_id.id
action = _reopen(self, self.id, self.model, context=self._context)
action.update({'name': _('Send Invoice')})
return action

View file

@ -1,100 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="account_invoice_send_wizard_form" model="ir.ui.view">
<field name="name">account.invoice.send.form</field>
<field name="model">account.invoice.send</field>
<field name="groups_id" eval="[(4,ref('base.group_user'))]"/>
<field name="arch" type="xml">
<form string="Invoice send &amp; Print">
<div class="alert alert-warning" role="alert"
attrs="{'invisible': [('move_types', '=', False)]}">
You have selected the following document types at the same time:
<field name="move_types"/>
</div>
<!-- truly invisible fields for control and options -->
<field name="composition_mode" invisible="1"/>
<field name="invoice_ids" invisible="1"/>
<field name="email_from" invisible="1" />
<field name="mail_server_id" invisible="1"/>
<div name="option_print">
<field name="is_print" />
<b><label for="is_print"/></b>
<div name="info_form"
attrs="{'invisible': ['|', ('is_print', '=', False), ('composition_mode', '=', 'mass_mail')]}"
class="text-center text-muted d-inline-block ms-2">
Preview as a PDF
</div>
</div>
<div name="option_email">
<field name="is_email" />
<b><label for="is_email"/></b>
</div>
<div class="text-start d-inline-block mr8" attrs="{'invisible': ['|', ('is_email','=', False), ('invoice_without_email', '=', False)]}">
<field name="invoice_without_email" class="mr4"/>
</div>
<div name="mail_form" attrs="{'invisible': [('is_email', '=', False)]}">
<!-- visible wizard -->
<div attrs="{'invisible': [('composition_mode', '=', 'mass_mail')]}">
<group>
<label for="partner_ids" string="Recipients" groups="base.group_user"/>
<div groups="base.group_user">
<span attrs="{'invisible': [('composition_mode', '!=', 'mass_mail')]}">
<strong>Email mass mailing</strong> on
<span>the selected records</span>
</span>
<span>Followers of the document and</span>
<field name="partner_ids" widget="many2many_tags_email" placeholder="Add contacts to notify..."
context="{'force_email':True, 'show_email':True}" attrs="{'invisible': [('composition_mode', '=', 'mass_mail')]}"/>
</div>
<field name="subject" placeholder="Subject..." attrs="{'required': [('is_email', '=', True), ('composition_mode', '=', 'comment')]}"/>
</group>
<field name="body" class="oe-bordered-editor" options="{'style-inline': true}"/>
</div>
<group>
<group attrs="{'invisible': [('composition_mode', '=', 'mass_mail')]}">
<field name="attachment_ids" widget="many2many_binary" string="Attach a file" nolabel="1" colspan="2" attrs="{'invisible': [('composition_mode', '=', 'mass_mail')]}"/>
</group>
<group>
<field name="template_id" options="{'no_create': True, 'no_edit': True}"
context="{'default_model': 'account.move'}"/>
</group>
</group>
</div>
<footer>
<button string="Send &amp; Print"
attrs="{'invisible': ['|', ('is_email', '=', False), ('is_print', '=', False)]}" data-hotkey="q"
name="send_and_print_action" type="object" class="send_and_print btn-primary o_mail_send"/>
<button string="Send" data-hotkey="q"
attrs="{'invisible': ['|', ('is_print', '=', True), ('is_email', '=', False)]}"
name="send_and_print_action" type="object" class="send btn-primary o_mail_send"/>
<button string="Print" data-hotkey="q"
attrs="{'invisible': ['|', ('is_print', '=', False), ('is_email', '=', True)]}"
name="send_and_print_action" type="object" class="print btn-primary o_mail_send"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z" />
<button icon="fa-lg fa-save" type="object" name="save_as_template" string="Save as new template"
attrs="{'invisible': ['|', ('composition_mode', '=', 'mass_mail'), ('is_email', '=', False)]}"
class="float-end btn-secondary" help="Save as a new template" data-hotkey="w" />
</footer>
</form>
</field>
</record>
<record id="invoice_send" model="ir.actions.server">
<field name="name">Send &amp; print</field>
<field name="state">code</field>
<field name="type">ir.actions.server</field>
<field name="model_id" ref="model_account_move"/>
<field name="binding_model_id" ref="model_account_move"/>
<field name="binding_view_types">list</field>
<field name="code">
if records:
action = records.action_send_and_print()
</field>
</record>
</data>
</odoo>

View file

@ -0,0 +1,361 @@
import json
from odoo import _, api, fields, models, Command
from odoo.exceptions import UserError
from odoo.tools import SQL
class AccountMergeWizard(models.TransientModel):
_name = 'account.merge.wizard'
_description = "Account merge wizard"
account_ids = fields.Many2many('account.account')
is_group_by_name = fields.Boolean(
string="Group by name?",
default=False,
help="Tick this checkbox if you want accounts to be grouped by name for merging."
)
wizard_line_ids = fields.One2many(
comodel_name='account.merge.wizard.line',
inverse_name='wizard_id',
compute='_compute_wizard_line_ids',
store=True,
readonly=False,
)
disable_merge_button = fields.Boolean(compute='_compute_disable_merge_button')
@api.model
def default_get(self, fields):
res = super().default_get(fields)
if not set(fields) & {'account_ids', 'wizard_line_ids'} or set(res.keys()) & {'account_ids', 'wizard_line_ids'}:
return res
if self.env.context.get('active_model') != 'account.account':
raise UserError(_("This can only be used on accounts."))
if len(self.env.context.get('active_ids') or []) < 2:
raise UserError(_("You must select at least 2 accounts."))
res['account_ids'] = [Command.set(self.env.context.get('active_ids'))]
return res
def _get_grouping_key(self, account):
""" Return a grouping key for the given account. """
self.ensure_one()
grouping_fields = ['account_type', 'non_trade', 'currency_id', 'reconcile', 'active']
if self.is_group_by_name:
grouping_fields.append('name')
return tuple(account[field] for field in grouping_fields)
@api.depends('is_group_by_name', 'account_ids')
def _compute_wizard_line_ids(self):
""" Determine which accounts to merge together. """
for wizard in self:
# Filter out Bank / Cash accounts
accounts = wizard.account_ids._origin.filtered(lambda a: a.account_type not in ('asset_bank', 'asset_cash'))
wizard_lines_vals_list = []
sequence = 0
for grouping_key, group_accounts in accounts.grouped(wizard._get_grouping_key).items():
grouping_key_str = str(grouping_key)
wizard_lines_vals_list.append({
'display_type': 'line_section',
'grouping_key': grouping_key_str,
'sequence': (sequence := sequence + 1),
'account_id': group_accounts[0].id # Used to compute the group name
})
for account in group_accounts:
wizard_lines_vals_list.append({
'display_type': 'account',
'account_id': account.id,
'grouping_key': grouping_key_str,
'is_selected': True,
'sequence': (sequence := sequence + 1),
})
wizard.wizard_line_ids = [Command.clear()] + [
Command.create(vals)
for vals in wizard_lines_vals_list
]
@api.depends('wizard_line_ids.is_selected', 'wizard_line_ids.info')
def _compute_disable_merge_button(self):
for wizard in self:
wizard_lines_to_merge = wizard.wizard_line_ids.filtered(lambda l: l.display_type == 'account' and l.is_selected and not l.info)
wizard.disable_merge_button = all(
len(wizard_line_group) < 2
for wizard_line_group in wizard_lines_to_merge.grouped('grouping_key').values()
)
def _get_window_action(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _("Merge Accounts"),
'view_id': self.env.ref('account.account_merge_wizard_form').id,
'context': self.env.context,
'res_model': 'account.merge.wizard',
'res_id': self.id,
'target': 'new',
'view_mode': 'form',
}
def action_merge(self):
""" Merge each group of accounts in `self.wizard_line_ids`. """
self._check_access_rights(self.account_ids)
for wizard in self:
wizard_lines_selected = wizard.wizard_line_ids.filtered(lambda l: l.display_type == 'account' and l.is_selected and not l.info)
for wizard_lines_group in wizard_lines_selected.grouped('grouping_key').values():
if len(wizard_lines_group) > 1:
# This ensures that if one account in the group has hashed entries, it appears first, ensuring
# that its ID doesn't get changed by the merge.
self._action_merge(wizard_lines_group.sorted('account_has_hashed_entries', reverse=True).account_id)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'success',
'sticky': False,
'message': _("Accounts successfully merged!"),
'next': {'type': 'ir.actions.act_window_close'},
}
}
@api.model
def _check_access_rights(self, accounts):
accounts.check_access('write')
if forbidden_companies := (accounts.sudo().company_ids - self.env.user.company_ids):
raise UserError(_(
"You do not have the right to perform this operation as you do not have access to the following companies: %s.",
", ".join(c.name for c in forbidden_companies)
))
@api.model
def _action_merge(self, accounts):
""" Merge `accounts`:
- the first account is extended to each company of the others, keeping their codes and names;
- the others are deleted; and
- journal items and other references are retargeted to the first account.
"""
# Step 1: Keep track of the company_ids and codes we should write on the account.
# We will do so only at the end, to avoid triggering the constraint that prevents duplicate codes.
company_ids_to_write = accounts.sudo().company_ids
code_by_company = self.env.execute_query(SQL(
"""
SELECT jsonb_object_agg(key, value)
FROM account_account, jsonb_each_text(account_account.code_store)
WHERE account_account.id IN %(account_ids)s
""",
account_ids=tuple(accounts.ids),
to_flush=accounts._fields['code_store'],
))[0][0]
account_to_merge_into = accounts[0]
accounts_to_remove = accounts[1:]
# Step 2: Check that we have write access to all the accounts and access to all the companies
# of these accounts.
self._check_access_rights(accounts)
# Step 3: Update records in DB.
# 3.1: Update foreign keys in DB
wiz = self.env['base.partner.merge.automatic.wizard'].new()
wiz._update_foreign_keys_generic('account.account', accounts_to_remove, account_to_merge_into)
# 3.2: Update Reference and Many2OneReference fields that reference account.account
wiz._update_reference_fields_generic('account.account', accounts_to_remove, account_to_merge_into)
# 3.3: Merge translations
account_names = self.env.execute_query(SQL(
"""
SELECT id, name
FROM account_account
WHERE id IN %(account_ids)s
""",
account_ids=tuple(accounts.ids),
))
account_name_by_id = dict(account_names)
# Construct JSON of name translations, with first account taking precedence.
merged_account_name = {}
for account_id in accounts.ids[::-1]:
merged_account_name.update(account_name_by_id[account_id])
self.env.cr.execute(SQL(
"""
UPDATE account_account
SET name = %(account_name_json)s
WHERE id = %(account_to_merge_into_id)s
""",
account_name_json=json.dumps(merged_account_name),
account_to_merge_into_id=account_to_merge_into.id,
))
# Step 4: Remove merged accounts
self.env.invalidate_all()
self.env.cr.execute(SQL(
"""
DELETE FROM account_account
WHERE id IN %(account_ids_to_delete)s
""",
account_ids_to_delete=tuple(accounts_to_remove.ids),
))
# Clear ir.model.data ormcache
self.env.registry.clear_cache()
# Step 5: Write company_ids and codes on the account
self.env.cr.execute(SQL(
"""
UPDATE account_account
SET code_store = %(code_by_company_json)s
WHERE id = %(account_to_merge_into_id)s
""",
code_by_company_json=json.dumps(code_by_company),
account_to_merge_into_id=account_to_merge_into.id,
))
account_to_merge_into.sudo().company_ids = company_ids_to_write
self.env.add_to_compute(self.env['account.account']._fields['tag_ids'], account_to_merge_into)
class AccountMergeWizardLine(models.TransientModel):
_name = 'account.merge.wizard.line'
_description = "Account merge wizard line"
_order = 'sequence, id'
wizard_id = fields.Many2one(
comodel_name='account.merge.wizard',
required=True,
ondelete='cascade',
)
grouping_key = fields.Char()
sequence = fields.Integer()
display_type = fields.Selection(
selection=[
('line_section', "Section"),
('line_subsection', "Subsection"),
('account', "Account"),
],
required=True,
)
is_selected = fields.Boolean()
account_id = fields.Many2one(
string="Account",
comodel_name='account.account',
ondelete='cascade',
readonly=True,
)
company_ids = fields.Many2many(
string="Companies",
related='account_id.company_ids',
)
info = fields.Char(
string='Info',
compute='_compute_info',
help="Contains either the section name or error message, depending on the line type."
)
account_has_hashed_entries = fields.Boolean(compute='_compute_account_has_hashed_entries')
@api.depends('account_id')
def _compute_account_has_hashed_entries(self):
# optimization to avoid having to re-check which accounts have hashed entries
query = self.env['account.move.line']._search([
('account_id', 'in', self.account_id.ids),
('move_id.inalterable_hash', '!=', False),
], bypass_access=True)
query_result = self.env.execute_query(query.select(SQL('DISTINCT account_move_line.account_id')))
accounts_with_hashed_entries_ids = {r[0] for r in query_result}
wizard_lines_with_hashed_entries = self.filtered(lambda l: l.account_id.id in accounts_with_hashed_entries_ids)
wizard_lines_with_hashed_entries.account_has_hashed_entries = True
(self - wizard_lines_with_hashed_entries).account_has_hashed_entries = False
@api.depends('account_id', 'wizard_id.wizard_line_ids.is_selected', 'display_type')
def _compute_info(self):
""" This re-computes the error message for each wizard line every time the user selects or deselects a wizard line.
In reality accounts will only affect the mergeability of other accounts in the same merge group.
Therefore this method delegates the logic of determining whether an account can be merged to
`_apply_different_companies_constraint` and `_apply_hashed_moves_constraint` which work on a merge group basis.
"""
for wizard_line in self.filtered(lambda l: l.display_type == 'line_section'):
wizard_line.info = wizard_line._get_group_name()
for wizard_line_group in self.filtered(lambda l: l.display_type == 'account').grouped(lambda l: (l.wizard_id, l.grouping_key)).values():
# Reset the error messages for the wizard lines in the group to False, then
# re-compute them for the whole group.
wizard_line_group.info = False
wizard_line_group._apply_different_companies_constraint()
wizard_line_group._apply_hashed_moves_constraint()
def _get_group_name(self):
""" Return a human-readable name for a wizard line's group, based on its `account_id`, in the format:
'{Trade/Non-trade} Receivable {USD} {Reconcilable} {Deprecated}'
"""
self.ensure_one()
account_type_label = dict(self.pool['account.account'].account_type._description_selection(self.env))[self.account_id.account_type]
if self.account_id.account_type in ['asset_receivable', 'liability_payable']:
account_type_label = _("Non-trade %s", account_type_label) if self.account_id.non_trade else _("Trade %s", account_type_label)
other_name_elements = []
if self.account_id.currency_id:
other_name_elements.append(self.account_id.currency_id.name)
if self.account_id.reconcile:
other_name_elements.append(_("Reconcilable"))
if not self.account_id.active:
other_name_elements.append(_("Deprecated"))
if not self.wizard_id.is_group_by_name:
grouping_key_name = account_type_label
if other_name_elements:
grouping_key_name = f'{grouping_key_name} ({", ".join(other_name_elements)})'
else:
grouping_key_name = f'{self.account_id.name} ({", ".join([account_type_label] + other_name_elements)})'
return grouping_key_name
def _apply_different_companies_constraint(self):
""" Set `info` on wizard lines if an account cannot be merged
because it belongs to the same company as another account.
If users want to do that, they should mass-edit the account on the journal items.
The wizard lines in `self` should have the same `grouping_key`.
"""
companies_seen = self.env['res.company']
account_belonging_to_company = {}
for wizard_line in self:
if wizard_line.is_selected and not wizard_line.info:
if shared_companies := (wizard_line.company_ids & companies_seen):
wizard_line.info = _(
"Belongs to the same company as %s.",
account_belonging_to_company[shared_companies[0]].display_name
)
else:
companies_seen |= wizard_line.company_ids
for company in wizard_line.company_ids:
if company not in account_belonging_to_company:
account_belonging_to_company[company] = wizard_line.account_id
def _apply_hashed_moves_constraint(self):
""" Set `info` on wizard lines if an account cannot be merged because it
has hashed entries.
If there are hashed entries in an account, then the merge must preserve that account's ID.
So we cannot merge two accounts that contain hashed entries.
The wizard lines in `self` should have the same `grouping_key`.
"""
account_to_merge_into = None
for wizard_line in self:
if wizard_line.is_selected and not wizard_line.info and wizard_line.account_has_hashed_entries:
if not account_to_merge_into:
account_to_merge_into = wizard_line.account_id
else:
wizard_line.info = _(
"Contains hashed entries, but %s also has hashed entries.",
account_to_merge_into.display_name
)

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="account_merge_wizard_form" model="ir.ui.view">
<field name="name">account.merge.wizard.form</field>
<field name="model">account.merge.wizard</field>
<field name="arch" type="xml">
<form>
<field name="disable_merge_button" invisible="1"/>
<group>
<field name="is_group_by_name"/>
<field name="wizard_line_ids"
colspan="4"
nolabel="1"
widget="account_merge_wizard_lines_one2many">
<list editable="bottom" create="0" delete="0">
<field name="is_selected" nolabel="1" invisible="display_type == 'line_section' or display_type == 'line_subsection'"/>
<field name="account_id" force_save="1"/>
<field name="company_ids" widget="many2many_tags"/>
<field name="info" decoration-danger="display_type == 'account'"/>
<!-- needed to make onchange work -->
<field name="sequence" column_invisible="1"/>
<field name="grouping_key" column_invisible="1"/>
<field name="display_type" column_invisible="1"/>
<field name="account_has_hashed_entries" column_invisible="1"/>
</list>
</field>
</group>
<footer>
<button string="Merge" name="action_merge" type="object" class="oe_highlight" data-hotkey="q" invisible="disable_merge_button"/>
<button string="Merge" name="action_merge" type="object" class="oe_highlight disabled" data-hotkey="q" invisible="not disable_merge_button"/>
<button string="Cancel" class="btn btn-secondary" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>
</record>
<record id="account_merge_wizard_action" model="ir.actions.act_window">
<field name="name">Merge accounts</field>
<field name="group_ids" eval="[(6, 0, [ref('account.group_account_manager')])]"/>
<field name="res_model">account.merge.wizard</field>
<field name="binding_model_id" ref="account.model_account_account"/>
<field name="binding_type">action</field>
<field name="binding_view_types">list,kanban</field>
<field name="view_mode">form</field>
<field name="view_id" eval="ref('account.account_merge_wizard_form')"/>
<field name="target">new</field>
</record>
</data>
</odoo>

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
from odoo import Command, models, fields, api
from odoo.tools.translate import _
from odoo.exceptions import UserError
@ -14,21 +14,11 @@ class AccountMoveReversal(models.TransientModel):
move_ids = fields.Many2many('account.move', 'account_move_reversal_move', 'reversal_id', 'move_id', domain=[('state', '=', 'posted')])
new_move_ids = fields.Many2many('account.move', 'account_move_reversal_new_move', 'reversal_id', 'new_move_id')
date_mode = fields.Selection(selection=[
('custom', 'Specific'),
('entry', 'Journal Entry Date')
], required=True, default='custom')
date = fields.Date(string='Reversal date', default=fields.Date.context_today)
reason = fields.Char(string='Reason')
refund_method = fields.Selection(selection=[
('refund', 'Partial Refund'),
('cancel', 'Full Refund'),
('modify', 'Full refund and new draft invoice')
], string='Credit Method', required=True,
help='Choose how you want to credit this invoice. You cannot "modify" nor "cancel" if the invoice is already reconciled.')
reason = fields.Char(string='Reason displayed on Credit Note')
journal_id = fields.Many2one(
comodel_name='account.journal',
string='Use Specific Journal',
string='Journal',
required=True,
compute='_compute_journal_id',
readonly=False,
@ -59,11 +49,13 @@ class AccountMoveReversal(models.TransientModel):
for record in self:
if record.move_ids:
record.available_journal_ids = self.env['account.journal'].search([
('company_id', '=', record.company_id.id),
*self.env['account.journal']._check_company_domain(record.company_id),
('type', 'in', record.move_ids.journal_id.mapped('type')),
])
else:
record.available_journal_ids = self.env['account.journal'].search([('company_id', '=', record.company_id.id)])
record.available_journal_ids = self.env['account.journal'].search([
*self.env['account.journal']._check_company_domain(record.company_id),
])
@api.constrains('journal_id', 'move_ids')
def _check_journal_type(self):
@ -76,14 +68,17 @@ class AccountMoveReversal(models.TransientModel):
res = super(AccountMoveReversal, self).default_get(fields)
move_ids = self.env['account.move'].browse(self.env.context['active_ids']) if self.env.context.get('active_model') == 'account.move' else self.env['account.move']
if len(move_ids.company_id) > 1:
raise UserError(_("All selected moves for reversal must belong to the same company."))
if any(move.state != "posted" for move in move_ids):
raise UserError(_('You can only reverse posted moves.'))
raise UserError(_(
'To reverse a journal entry, it has to be posted first.'
))
if 'company_id' in fields:
res['company_id'] = move_ids.company_id.id or self.env.company.id
if 'move_ids' in fields:
res['move_ids'] = [(6, 0, move_ids.ids)]
if 'refund_method' in fields:
res['refund_method'] = (len(move_ids) > 1 or move_ids.move_type == 'entry') and 'cancel' or 'refund'
return res
@api.depends('move_ids')
@ -95,41 +90,32 @@ class AccountMoveReversal(models.TransientModel):
record.move_type = move_ids.move_type if len(move_ids) == 1 else (any(move.move_type in ('in_invoice', 'out_invoice') for move in move_ids) and 'some_invoice' or False)
def _prepare_default_reversal(self, move):
reverse_date = self.date if self.date_mode == 'custom' else move.date
mixed_payment_term = move.invoice_payment_term_id.id if move.invoice_payment_term_id and move.company_id.early_pay_discount_computation == 'mixed' else None
reverse_date = self.date
mixed_payment_term = move.invoice_payment_term_id.id if move.invoice_payment_term_id.early_pay_discount_computation == 'mixed' else None
lang = move.partner_id.lang or self.env.lang
return {
'ref': _('Reversal of: %(move_name)s, %(reason)s', move_name=move.name, reason=self.reason)
'ref': self.with_context(lang=lang).env._('Reversal of: %(move_name)s, %(reason)s', move_name=move.name, reason=self.reason)
if self.reason
else _('Reversal of: %s', move.name),
else self.with_context(lang=lang).env._('Reversal of: %s', move.name),
'date': reverse_date,
'invoice_date_due': reverse_date,
'invoice_date': move.is_invoice(include_receipts=True) and reverse_date or False,
'invoice_date': move.is_invoice(include_receipts=True) and (self.date or move.date) or False,
'journal_id': self.journal_id.id,
'invoice_payment_term_id': mixed_payment_term,
'invoice_user_id': move.invoice_user_id.id,
'auto_post': 'at_date' if reverse_date > fields.Date.context_today(self) else 'no',
'invoice_origin': move.invoice_origin,
}
def reverse_moves(self):
def reverse_moves(self, is_modify=False):
self.ensure_one()
moves = self.move_ids
# Create default values.
partners = moves.company_id.partner_id + moves.commercial_partner_id
bank_ids = self.env['res.partner.bank'].search([
('partner_id', 'in', partners.ids),
('company_id', 'in', moves.company_id.ids + [False]),
], order='sequence DESC')
partner_to_bank = {bank.partner_id: bank for bank in bank_ids}
default_values_list = []
for move in moves:
if move.is_outbound():
partner = move.company_id.partner_id
else:
partner = move.commercial_partner_id
default_values_list.append({
'partner_bank_id': partner_to_bank.get(partner, self.env['res.partner.bank']).id,
'partner_bank_id': False, # Resets the partner_bank_id as we'll force its recomputation
**self._prepare_default_reversal(move),
})
@ -139,7 +125,7 @@ class AccountMoveReversal(models.TransientModel):
]
for move, default_vals in zip(moves, default_values_list):
is_auto_post = default_vals.get('auto_post') != 'no'
is_cancel_needed = not is_auto_post and self.refund_method in ('cancel', 'modify')
is_cancel_needed = not is_auto_post and (is_modify or self.move_type == 'entry')
batch_index = 0 if is_cancel_needed else 1
batches[batch_index][0] |= move
batches[batch_index][1].append(default_vals)
@ -148,12 +134,19 @@ class AccountMoveReversal(models.TransientModel):
moves_to_redirect = self.env['account.move']
for moves, default_values_list, is_cancel_needed in batches:
new_moves = moves._reverse_moves(default_values_list, cancel=is_cancel_needed)
new_moves._compute_partner_bank_id()
moves._message_log_batch(
bodies={move.id: move.env._('This entry has been %s', reverse._get_html_link(title=move.env._("reversed"))) for move, reverse in zip(moves, new_moves)}
)
if self.refund_method == 'modify':
if is_modify:
moves_vals_list = []
for move in moves.with_context(include_business_fields=True):
moves_vals_list.append(move.copy_data({'date': self.date if self.date_mode == 'custom' else move.date})[0])
data = move.copy_data(self._modify_default_reverse_values(move))[0]
data['line_ids'] = [line for line in data['line_ids'] if line[2]['display_type'] in ('product', 'line_section', 'line_subsection', 'line_note')]
moves_vals_list.append(data)
new_moves = self.env['account.move'].create(moves_vals_list)
new_moves._compute_partner_bank_id()
moves_to_redirect |= new_moves
@ -173,9 +166,30 @@ class AccountMoveReversal(models.TransientModel):
})
else:
action.update({
'view_mode': 'tree,form',
'view_mode': 'list,form',
'domain': [('id', 'in', moves_to_redirect.ids)],
})
if len(set(moves_to_redirect.mapped('move_type'))) == 1:
action['context'] = {'default_move_type': moves_to_redirect.mapped('move_type').pop()}
return action
def refund_moves(self):
return self.reverse_moves(is_modify=False)
def modify_moves(self):
return self.reverse_moves(is_modify=True)
def _modify_default_reverse_values(self, origin_move):
data = {
'date': self.date
}
# if has vendor attachment, keep it
if origin_move.move_type.startswith('in_') and origin_move.message_main_attachment_id:
new_main_attachment_id = origin_move.message_main_attachment_id.copy({'res_id': False}).id
data.update({
'message_main_attachment_id': new_main_attachment_id,
'attachment_ids': [Command.link(new_main_attachment_id)],
})
return data

View file

@ -12,36 +12,14 @@
<field name="move_type" invisible="1"/>
<field name="available_journal_ids" invisible="1"/>
<group>
<group attrs="{'invisible': ['|',('move_type', 'not in', ('out_invoice', 'in_invoice')),('residual', '=', 0)]}">
<field name="refund_method" widget="radio" attrs="{'readonly': [('residual', '=', 0)]}"/>
</group>
<group attrs="{'invisible': ['|', ('move_type', 'not in', ('out_invoice', 'in_invoice', 'some_invoice')), ('residual', '=', 0)]}">
<div attrs="{'invisible':[('refund_method', '!=', 'refund')]}" class="oe_grey" colspan="2">
The credit note is created in draft and can be edited before being issued.
</div>
<div attrs="{'invisible':[('refund_method', '!=', 'cancel')]}" class="oe_grey" colspan="2">
The credit note is auto-validated and reconciled with the invoice.
</div>
<div attrs="{'invisible':[('refund_method', '!=', 'modify')]}" class="oe_grey" colspan="2">
The credit note is auto-validated and reconciled with the invoice.
The original invoice is duplicated as a new draft.
</div>
</group>
</group>
<group>
<group>
<field name="reason" attrs="{'invisible': [('move_type', '=', 'entry')]}"/>
<field name="date_mode" string="Reversal Date" widget="radio"/>
</group>
<group>
<field name="reason" invisible="move_type == 'entry'"/>
<field name="journal_id" domain="[('id', 'in', available_journal_ids)]"/>
<field name="date" string="Refund Date" attrs="{'invisible': ['|', ('move_type', 'not in', ('out_invoice', 'in_invoice')), ('date_mode', '!=', 'custom')], 'required':[('date_mode', '=', 'custom')]}"/>
<field name="date" attrs="{'invisible': ['|', ('move_type', 'in', ('out_invoice', 'in_invoice')), ('date_mode', '!=', 'custom')], 'required':[('date_mode', '=', 'custom')]}"/>
</group>
<field name="date" required="1"/>
</group>
<footer>
<button string='Reverse' name="reverse_moves" type="object" class="btn-primary" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
<button string='Reverse' name="refund_moves" type="object" class="btn-primary" data-hotkey="q"/>
<button string="Reverse and Create Invoice" name="modify_moves" type="object" class="btn-secondary" invisible="move_type == 'entry'"/>
<button string="Discard" class="btn-secondary" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>
@ -50,12 +28,12 @@
<record id="action_view_account_move_reversal" model="ir.actions.act_window">
<field name="name">Reverse</field>
<field name="res_model">account.move.reversal</field>
<field name="view_mode">tree,form</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="view_account_move_reversal"/>
<field name="target">new</field>
<field name="groups_id" eval="[(4, ref('account.group_account_invoice'))]"/>
<field name="group_ids" eval="[(4, ref('account.group_account_invoice'))]"/>
<field name="binding_model_id" ref="account.model_account_move" />
<field name="binding_view_types">list</field>
<field name="binding_view_types">list,kanban</field>
</record>
</data>
</odoo>

View file

@ -0,0 +1,119 @@
from collections import Counter
from odoo import _, api, Command, fields, models
from odoo.exceptions import RedirectWarning, UserError
class AccountMoveSendBatchWizard(models.TransientModel):
"""Wizard that handles the sending of multiple invoices."""
_name = 'account.move.send.batch.wizard'
_inherit = ['account.move.send']
_description = "Account Move Send Batch Wizard"
move_ids = fields.Many2many(comodel_name='account.move', required=True)
summary_data = fields.Json(compute='_compute_summary_data')
alerts = fields.Json(compute='_compute_alerts')
# -------------------------------------------------------------------------
# DEFAULTS
# -------------------------------------------------------------------------
@api.model
def default_get(self, fields):
# EXTENDS 'base'
results = super().default_get(fields)
if 'move_ids' in fields and 'move_ids' not in results:
move_ids = self.env.context.get('active_ids', [])
results['move_ids'] = [Command.set(move_ids)]
return results
# -------------------------------------------------------------------------
# COMPUTES
# -------------------------------------------------------------------------
@api.depends('move_ids')
def _compute_summary_data(self):
extra_edis = self._get_all_extra_edis()
sending_methods = dict(self.env['res.partner']._fields['invoice_sending_method'].selection)
sending_methods['manual'] = _('Manually') # in batch sending, everything is done asynchronously, we never "Download"
for wizard in self:
edi_counter = Counter()
sending_method_counter = Counter()
for move in wizard.move_ids._origin:
edi_counter += Counter([edi for edi in self._get_default_extra_edis(move)])
sending_settings = self._get_default_sending_settings(move)
sending_method_counter += Counter([
sending_method
for sending_method in self._get_default_sending_methods(move)
if self._is_applicable_to_move(sending_method, move, **sending_settings)
])
summary_data = dict()
for edi, edi_count in edi_counter.items():
summary_data[edi] = {'count': edi_count, 'label': _("by %s", extra_edis[edi]['label'])}
for sending_method, sending_method_count in sending_method_counter.items():
summary_data[sending_method] = {'count': sending_method_count, 'label': sending_methods[sending_method]}
wizard.summary_data = summary_data
@api.depends('summary_data')
def _compute_alerts(self):
for wizard in self:
moves_data = {move: self._get_default_sending_settings(move) for move in wizard.move_ids._origin}
wizard.alerts = self._get_alerts(wizard.move_ids._origin, moves_data)
# -------------------------------------------------------------------------
# CONSTRAINS
# -------------------------------------------------------------------------
@api.constrains('move_ids')
def _check_move_ids_constraints(self):
for wizard in self:
self._check_move_constraints(wizard.move_ids)
# -------------------------------------------------------------------------
# ACTIONS
# -------------------------------------------------------------------------
def action_send_and_print(self, force_synchronous=False, allow_fallback_pdf=False):
""" Launch asynchronously the generation and sending of invoices."""
self.ensure_one()
if self.alerts:
self._raise_danger_alerts(self.alerts)
if force_synchronous:
self.env['account.move.send']._generate_and_send_invoices(self.move_ids, allow_fallback_pdf=allow_fallback_pdf)
return
account_move_send_cron = self.env.ref('account.ir_cron_account_move_send')
if not account_move_send_cron.sudo().active:
if self.env.user.has_group('base.group_system'):
raise RedirectWarning(
_("Batch invoice sending is unavailable. Please, activate the cron to enable batch sending of invoices."),
{
'views': [(False, 'form')],
'res_model': 'ir.cron',
'type': 'ir.actions.act_window',
'res_id': account_move_send_cron.id,
'target': 'current',
},
_("Go to cron configuration"),
)
raise UserError(_("Batch invoice sending is unavailable. Please, contact your system administrator to activate the cron to enable batch sending of invoices."))
self.move_ids.sending_data = {
'author_user_id': self.env.user.id,
'author_partner_id': self.env.user.partner_id.id,
}
account_move_send_cron._trigger()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'info',
'title': _('Sending invoices'),
'message': _('Invoices are being sent in the background.'),
'next': {'type': 'ir.actions.act_window_close'},
},
}

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="account_move_send_batch_wizard_form" model="ir.ui.view">
<field name="name">account.move.send.batch.wizard.form</field>
<field name="model">account.move.send.batch.wizard</field>
<field name="arch" type="xml">
<form>
<field name="move_ids" invisible="1"/>
<!-- Warnings -->
<div class="m-0" id="alerts" invisible="not alerts">
<field name="alerts" class="o_field_html" widget="actionable_errors"/>
</div>
<!-- Summary -->
<field name="summary_data" invisible="not summary_data" widget="account_batch_sending_summary" nolabel="1"/>
<footer>
<button string="Send"
data-hotkey="q"
name="action_send_and_print"
type="object"
class="print btn-primary o_mail_send">
</button>
<button string="Cancel"
data-hotkey="x"
special="cancel"
class="btn-secondary"/>
</footer>
</form>
</field>
</record>
</odoo>

View file

@ -0,0 +1,405 @@
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools.misc import get_lang
from odoo.addons.mail.tools.parser import parse_res_ids
from odoo.addons.mail.wizard.mail_compose_message import _reopen
class AccountMoveSendWizard(models.TransientModel):
"""Wizard that handles the sending a single invoice."""
_name = 'account.move.send.wizard'
_inherit = ['account.move.send', 'mail.composer.mixin']
_description = "Account Move Send Wizard"
move_id = fields.Many2one(comodel_name='account.move', required=True)
company_id = fields.Many2one(comodel_name='res.company', related='move_id.company_id')
alerts = fields.Json(compute='_compute_alerts')
sending_methods = fields.Json(
compute='_compute_sending_methods',
inverse='_inverse_sending_methods',
)
sending_method_checkboxes = fields.Json(
compute='_compute_sending_method_checkboxes',
precompute=True,
readonly=False,
store=True,
)
# Technical field to display the attachments widget
display_attachments_widget = fields.Boolean(
compute='_compute_display_attachments_widget',
)
extra_edis = fields.Json(
compute='_compute_extra_edis',
inverse='_inverse_extra_edis',
)
extra_edi_checkboxes = fields.Json(
compute='_compute_extra_edi_checkboxes',
precompute=True,
readonly=False,
store=True,
)
invoice_edi_format = fields.Selection(
selection=lambda self: self.env['res.partner']._fields['invoice_edi_format'].selection,
compute='_compute_invoice_edi_format',
)
pdf_report_id = fields.Many2one(
comodel_name='ir.actions.report',
string="Invoice report",
domain="[('id', 'in', available_pdf_report_ids)]",
compute='_compute_pdf_report_id',
readonly=False,
store=True,
)
available_pdf_report_ids = fields.One2many(
comodel_name='ir.actions.report',
compute="_compute_available_pdf_report_ids",
)
display_pdf_report_id = fields.Boolean(compute='_compute_display_pdf_report_id')
# MAIL
# Template: override mail.composer.mixin field
template_id = fields.Many2one(
domain="[('model', '=', 'account.move')]",
compute='_compute_template_id',
compute_sudo=True,
readonly=False,
store=True,
)
# Language: override mail.composer.mixin field
lang = fields.Char(compute='_compute_lang', precompute=False, compute_sudo=True)
mail_partner_ids = fields.Many2many(
comodel_name='res.partner',
string="To",
compute='_compute_mail_partners',
store=True,
readonly=False,
)
mail_attachments_widget = fields.Json(
compute='_compute_mail_attachments_widget',
store=True,
readonly=False,
)
attachments_not_supported = fields.Json(compute='_compute_attachments_not_supported')
model = fields.Char('Related Document Model', compute='_compute_model', readonly=False, store=True)
res_ids = fields.Text('Related Document IDs', compute='_compute_res_ids', readonly=False, store=True)
template_name = fields.Char('Template Name') # used when saving a new mail template
# -------------------------------------------------------------------------
# DEFAULTS
# -------------------------------------------------------------------------
@api.model
def default_get(self, fields):
# EXTENDS 'base'
results = super().default_get(fields)
if 'move_id' in fields and 'move_id' not in results:
move_id = self.env.context.get('active_ids', [])[0]
results['move_id'] = move_id
return results
# -------------------------------------------------------------------------
# COMPUTE METHODS
# -------------------------------------------------------------------------
@api.depends('sending_methods', 'extra_edis', 'mail_partner_ids')
def _compute_alerts(self):
for wizard in self:
move_data = {
wizard.move_id: {
'sending_methods': wizard.sending_methods or {},
'invoice_edi_format': wizard.invoice_edi_format,
'extra_edis': wizard.extra_edis or {},
'mail_partner_ids': wizard.mail_partner_ids
}
}
wizard.alerts = self._get_alerts(wizard.move_id, move_data)
@api.depends('sending_method_checkboxes')
def _compute_sending_methods(self):
for wizard in self:
wizard.sending_methods = self._get_selected_checkboxes(wizard.sending_method_checkboxes)
def _inverse_sending_methods(self):
for wizard in self:
wizard.sending_method_checkboxes = {method_key: {'checked': True} for method_key in wizard.sending_methods or {}}
@api.depends('move_id')
def _compute_sending_method_checkboxes(self):
""" Select one applicable sending method given the following priority
1. preferred method set on partner,
2. email,
"""
methods = self.env['ir.model.fields'].get_field_selection('res.partner', 'invoice_sending_method')
# We never want to display the manual method.
methods = [method for method in methods if method[0] != 'manual']
for wizard in self:
preferred_methods = self._get_default_sending_methods(wizard.move_id)
wizard.sending_method_checkboxes = {
method_key: {
'checked': (
method_key in preferred_methods and (
method_key == 'email' or self._is_applicable_to_move(method_key, wizard.move_id, **self._get_default_sending_settings(wizard.move_id))
)), # email method is always ok in single mode since the email can be added if it's missing
'label': method_label,
}
for method_key, method_label in methods
if self._is_applicable_to_company(method_key, wizard.company_id)
}
@api.depends('invoice_edi_format')
def _compute_display_attachments_widget(self):
for wizard in self:
wizard.display_attachments_widget = wizard._display_attachments_widget(
edi_format=wizard.invoice_edi_format,
sending_methods=wizard.sending_methods or [],
)
@api.depends('extra_edi_checkboxes')
def _compute_extra_edis(self):
for wizard in self:
wizard.extra_edis = self._get_selected_checkboxes(wizard.extra_edi_checkboxes)
def _inverse_extra_edis(self):
for wizard in self:
wizard.extra_edi_checkboxes = {method_key: {'checked': True} for method_key in wizard.extra_edis or {}}
@api.depends('move_id')
def _compute_extra_edi_checkboxes(self):
all_extra_edis = self._get_all_extra_edis()
for wizard in self:
wizard.extra_edi_checkboxes = {
edi_key: {'checked': True, 'label': all_extra_edis[edi_key]['label'], 'help': all_extra_edis[edi_key].get('help')}
for edi_key in self._get_default_extra_edis(wizard.move_id)
}
@api.depends('move_id', 'sending_methods')
def _compute_invoice_edi_format(self):
for wizard in self:
wizard.invoice_edi_format = self._get_default_invoice_edi_format(wizard.move_id, sending_methods=wizard.sending_methods or {})
@api.depends('move_id')
def _compute_pdf_report_id(self):
for wizard in self:
wizard.pdf_report_id = self._get_default_pdf_report_id(wizard.move_id)
@api.depends('move_id')
def _compute_available_pdf_report_ids(self):
available_reports = self.move_id._get_available_action_reports()
for wizard in self:
wizard.available_pdf_report_ids = available_reports
@api.depends('move_id')
def _compute_display_pdf_report_id(self):
""" Show PDF template selection if there are more than 1 template available for invoices. """
for wizard in self:
wizard.display_pdf_report_id = len(wizard.available_pdf_report_ids) > 1 and not wizard.move_id.invoice_pdf_report_id
@api.depends('move_id')
def _compute_template_id(self):
for wizard in self:
wizard.template_id = self._get_default_mail_template_id(wizard.move_id)
@api.depends('template_id')
def _compute_lang(self):
# OVERRIDE 'mail.composer.mixin'
for wizard in self:
wizard.lang = self._get_default_mail_lang(wizard.move_id, wizard.template_id) if wizard.template_id else get_lang(self.env).code
@api.depends('template_id', 'lang')
def _compute_mail_partners(self):
for wizard in self:
wizard.mail_partner_ids = commercial_partner if (commercial_partner := wizard.move_id.commercial_partner_id).email else None
if wizard.template_id:
wizard.mail_partner_ids = self._get_default_mail_partner_ids(wizard.move_id, wizard.template_id, wizard.lang)
@api.depends('template_id', 'lang')
def _compute_subject(self):
# OVERRIDE 'mail.composer.mixin'
for wizard in self:
wizard.subject = None
if wizard.template_id:
wizard.subject = self._get_default_mail_subject(wizard.move_id, wizard.template_id, wizard.lang)
@api.depends('template_id', 'lang')
def _compute_body(self):
# OVERRIDE 'mail.composer.mixin'
for wizard in self:
wizard.body = None
if wizard.template_id:
wizard.body = self._get_default_mail_body(wizard.move_id, wizard.template_id, wizard.lang)
@api.depends('template_id', 'invoice_edi_format', 'extra_edis', 'pdf_report_id')
def _compute_mail_attachments_widget(self):
for wizard in self:
manual_attachments_data = [x for x in wizard.mail_attachments_widget or [] if x.get('manual')]
wizard.mail_attachments_widget = (
self._get_default_mail_attachments_widget(
wizard.move_id,
wizard.template_id,
invoice_edi_format=wizard.invoice_edi_format,
extra_edis=wizard.extra_edis or {},
pdf_report=wizard.pdf_report_id,
)
+ manual_attachments_data
)
# Similar of mail.compose.message
@api.depends('template_id')
def _compute_res_ids(self):
for wizard in self:
wizard.res_ids = wizard.move_id.ids
# Similar of mail.compose.message
@api.depends('template_id')
def _compute_model(self):
for wizard in self:
if wizard.model:
continue
wizard.model = self.env.context.get('active_model')
# Similar of mail.compose.message
@api.depends('sending_methods')
def _compute_can_edit_body(self):
for record in self:
record.can_edit_body = record.sending_methods and 'email' in record.sending_methods
@api.depends('model') # Fake trigger otherwise not computed in new mode
def _compute_render_model(self):
# OVERRIDE 'mail.composer.mixin'
self.render_model = 'account.move'
# Similar of mail.compose.message
def open_template_creation_wizard(self):
""" Hit save as template button: opens a wizard that prompts for the template's subject.
`create_mail_template` is called when saving the new wizard. """
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'view_mode': 'form',
'view_id': self.env.ref('mail.mail_compose_message_view_form_template_save').id,
'name': _('Create a Mail Template'),
'res_model': 'account.move.send.wizard',
'context': {'dialog_size': 'medium'},
'target': 'new',
'res_id': self.id,
}
# Similar of mail.compose.message
def create_mail_template(self):
""" Creates a mail template with the current mail composer's fields. """
self.ensure_one()
if not self.model or not self.model in self.env:
raise UserError(_('Template creation from composer requires a valid model.'))
model_id = self.env['ir.model']._get_id(self.model)
values = {
'name': self.template_name or self.subject,
'subject': self.subject,
'body_html': self.body,
'model_id': model_id,
'use_default_to': True,
'user_id': self.env.uid,
}
template = self.env['mail.template'].create(values)
# generate the saved template
self.write({'template_id': template.id})
return _reopen(self, self.id, self.model, context={**self.env.context, 'dialog_size': 'large'})
# Similar of mail.compose.message
def cancel_save_template(self):
""" Restore old subject when canceling the 'save as template' action
as it was erased to let user give a more custom input. """
self.ensure_one()
return _reopen(self, self.id, self.model, context={**self.env.context, 'dialog_size': 'large'})
@api.depends('invoice_edi_format', 'mail_attachments_widget')
def _compute_attachments_not_supported(self):
for wizard in self:
wizard.attachments_not_supported = {}
# -------------------------------------------------------------------------
# CONSTRAINS
# -------------------------------------------------------------------------
@api.constrains('move_id')
def _check_move_id_constraints(self):
for wizard in self:
self._check_move_constraints(wizard.move_id)
# -------------------------------------------------------------------------
# HELPERS
# -------------------------------------------------------------------------
@api.model
def _get_selected_checkboxes(self, json_checkboxes):
if not json_checkboxes:
return {}
return [checkbox_key for checkbox_key, checkbox_vals in json_checkboxes.items() if checkbox_vals['checked']]
# -------------------------------------------------------------------------
# BUSINESS METHODS
# -------------------------------------------------------------------------
def _get_sending_settings(self):
self.ensure_one()
send_settings = {
'sending_methods': self.sending_methods or [],
'invoice_edi_format': self.invoice_edi_format,
'extra_edis': self.extra_edis or [],
'pdf_report': self.pdf_report_id,
'author_user_id': self.env.user.id,
'author_partner_id': self.env.user.partner_id.id,
}
if self.sending_methods and 'email' in self.sending_methods:
send_settings.update({
'mail_template': self.template_id,
'mail_lang': self.lang,
'mail_body': self.body,
'mail_subject': self.subject,
'mail_partner_ids': self.mail_partner_ids.ids,
})
if self.display_attachments_widget:
send_settings['mail_attachments_widget'] = self.mail_attachments_widget
return send_settings
def _update_preferred_settings(self):
"""If the partner's settings are not set, we use them as partner's default."""
self.ensure_one()
if not self.move_id.partner_id.invoice_template_pdf_report_id and self.pdf_report_id != self._get_default_pdf_report_id(self.move_id):
self.move_id.partner_id.sudo().invoice_template_pdf_report_id = self.pdf_report_id
# -------------------------------------------------------------------------
# BUSINESS ACTIONS
# -------------------------------------------------------------------------
@api.model
def _action_download(self, attachments):
""" Download the PDF attachment, or a zip of attachments if there are more than one. """
return {
'type': 'ir.actions.act_url',
'url': f'/account/download_invoice_attachments/{",".join(map(str, attachments.ids))}',
'close': True,
}
def action_send_and_print(self, allow_fallback_pdf=False):
""" Create invoice documents and send them."""
self.ensure_one()
if self.alerts:
self._raise_danger_alerts(self.alerts)
self._update_preferred_settings()
attachments = self._generate_and_send_invoices(
self.move_id,
**self._get_sending_settings(),
allow_fallback_pdf=allow_fallback_pdf,
)
if attachments and self.sending_methods and 'manual' in self.sending_methods:
return self._action_download(attachments)
else:
return {'type': 'ir.actions.act_window_close'}

View file

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="account_move_send_wizard_form" model="ir.ui.view">
<field name="name">account.move.send.wizard.form</field>
<field name="model">account.move.send.wizard</field>
<field name="arch" type="xml">
<form class="o_account_move_send_wizard">
<field name="move_id" invisible="1"/>
<field name="attachments_not_supported" invisible="1"/>
<!-- Warnings -->
<div class="m-0" id="alerts" invisible="not alerts">
<field name="alerts" class="o_field_html" widget="actionable_errors"/>
</div>
<!-- EDIs and Sending method -->
<div class="m-0" id="extra_edis">
<field name="extra_edi_checkboxes" widget="account_json_checkboxes" invisible="not extra_edi_checkboxes"/>
<field name="sending_method_checkboxes" widget="account_json_checkboxes"/>
</div>
<!-- PDF template -->
<field name="pdf_report_id" invisible="1"/>
<group invisible="not display_pdf_report_id or 'email' in sending_methods">
<field name="pdf_report_id" options="{'no_create': True, 'no_edit': True}"/>
</group>
<!-- Mail composer -->
<div invisible="'email' not in sending_methods">
<field name="model" invisible="1"/>
<field name="res_ids" invisible="1"/>
<field name="render_model" invisible="1"/>
<group>
<field name="pdf_report_id" options="{'no_create': True, 'no_edit': True}" invisible="not display_pdf_report_id"/>
<label for="mail_partner_ids" string="To"/>
<div>
<field name="mail_partner_ids"
widget="many2many_tags_email"
placeholder="Add contacts to notify..."
options="{'no_quick_create': True}"
required="'email' in sending_methods"
context="{'show_email': True, 'form_view_ref': 'base.view_partner_simple_form'}"/>
</div>
<field name="subject"
placeholder="Subject..."
required="'email' in sending_methods"/>
</group>
<field name="body"
class="oe-bordered-editor"
widget="html_mail"/>
</div>
<field name="mail_attachments_widget"
widget="mail_attachments"
string="Attach a file"
nolabel="1"
colspan="2"
invisible="not display_attachments_widget"/>
<footer>
<button string="Send"
data-hotkey="q"
name="action_send_and_print"
type="object"
class="btn-primary"
invisible="not sending_methods"/>
<button string="Generate"
data-hotkey="q"
name="action_send_and_print"
type="object"
class="btn-primary"
invisible="sending_methods"/>
<button string="Discard"
data-hotkey="x"
special="cancel"
class="btn-secondary"/>
<field name="mail_attachments_widget" widget="mail_attachments_selector" invisible="not display_attachments_widget"/>
<field name="template_id" widget="mail_composer_template_selector"/>
</footer>
</form>
</field>
</record>
</odoo>

View file

@ -6,12 +6,15 @@
<field name="name">account.payment.register.form</field>
<field name="model">account.payment.register</field>
<field name="arch" type="xml">
<form string="Register Payment">
<form string="Pay">
<!-- Invisible fields -->
<field name="line_ids" invisible="1"/>
<field name="can_edit_wizard" invisible="1" force_save="1"/>
<field name="can_group_payments" invisible="1" force_save="1"/>
<field name="early_payment_discount_mode" invisible="1" force_save="1"/>
<field name="installments_mode" invisible="1"/>
<field name="installments_switch_amount" invisible="1"/>
<field name="installments_switch_html" invisible="1"/>
<field name="payment_type" invisible="1" force_save="1"/>
<field name="partner_type" invisible="1" force_save="1"/>
<field name="source_amount" invisible="1" force_save="1"/>
@ -20,6 +23,9 @@
<field name="company_id" invisible="1" force_save="1"/>
<field name="partner_id" invisible="1" force_save="1"/>
<field name="country_code" invisible="1" force_save="1"/>
<field name="currency_id" invisible="1" />
<field name="custom_user_amount" invisible="1" />
<field name="custom_user_currency_id" invisible="1" />
<field name="show_partner_bank_account" invisible="1"/>
<field name="require_partner_bank_account" invisible="1"/>
@ -28,58 +34,106 @@
<field name="available_partner_bank_ids" invisible="1"/>
<field name="company_currency_id" invisible="1"/>
<field name="hide_writeoff_section" invisible="1"/>
<field name="writeoff_is_exchange_account" invisible="1"/>
<field name="untrusted_bank_ids" invisible="1"/>
<field name="missing_account_partners" invisible="1"/>
<div role="alert" class="alert alert-info" attrs="{'invisible': [('hide_writeoff_section', '=', False)]}">
<p><b>Early Payment Discount of <field name="payment_difference"/> has been applied.</b></p>
<div role="alert" class="alert alert-info" invisible="not hide_writeoff_section">
<p class="m-0"><b>Early Payment Discount of <field name="payment_difference"/> has been applied.</b></p>
</div>
<div role="alert" class="alert alert-warning" invisible="untrusted_payments_count == 0">
<span class="fw-bold"><field name="untrusted_payments_count" class="oe_inline"/> out of <field name="total_payments_amount" class="oe_inline"/> payments will be skipped due to <button class="oe_link p-0 align-baseline" type="object" name="action_open_untrusted_bank_accounts">untrusted bank accounts</button>.</span>
</div>
<div role="alert" class="alert alert-warning" invisible="not missing_account_partners">
<span class="fw-bold">Payments related to partners with no bank account specified will be skipped. <button class="oe_link p-0 align-baseline" type="object" name="action_open_missing_account_partners">View Partner(s)</button></span>
</div>
<div role="alert" class="alert alert-warning" invisible="not duplicate_payment_ids">
<span>This payment has the same partner, amount and date as </span>
<field name="duplicate_payment_ids" widget="x2many_buttons" string="Duplicated Payments"/>
</div>
<div role="alert" invisible="not actionable_errors">
<field name="actionable_errors" widget="actionable_errors" class="w-100"/>
</div>
<group>
<group name="group1">
<field name="journal_id" options="{'no_open': True, 'no_create': True}" required="1"/>
<field name="payment_method_line_id"
context="{'hide_payment_journal_id': 1}"
required="1" options="{'no_create': True, 'no_open': True}"/>
<field name="partner_bank_id"
attrs="{'invisible': ['|', ('show_partner_bank_account', '=', False), '|', ('can_edit_wizard', '=', False), '&amp;', ('can_group_payments', '=', True), ('group_payment', '=', False)],
'required': [('require_partner_bank_account', '=', True), ('can_edit_wizard', '=', True), '|', ('can_group_payments', '=', False), ('group_payment', '=', False)], 'readonly': [('payment_type', '=', 'inbound')]}"
context="{'default_allow_out_payment': True}"/>
invisible="not show_partner_bank_account or not can_edit_wizard or (can_group_payments and not group_payment)"
readonly="payment_type == 'inbound'"
required="require_partner_bank_account and can_edit_wizard and (not can_group_payments or not group_payment)"
placeholder="Account Number"
context="{'display_account_trust': True, 'default_partner_id': partner_id}"/>
<field name="group_payment"
attrs="{'invisible': [('can_group_payments', '=', False)]}"/>
</group>
<group name="group2">
<label for="amount"
attrs="{'invisible': ['|', ('can_edit_wizard', '=', False), '&amp;', ('can_group_payments', '=', True), ('group_payment', '=', False)]}"/>
<div name="amount_div" class="o_row"
attrs="{'invisible': ['|', ('can_edit_wizard', '=', False), '&amp;', ('can_group_payments', '=', True), ('group_payment', '=', False)]}">
<field name="amount"/>
<field name="currency_id"
required="1"
options="{'no_create': True, 'no_open': True}"
groups="base.group_multi_currency"/>
</div>
<field name="payment_date"/>
<field name="communication"
attrs="{'invisible': ['|', ('can_edit_wizard', '=', False), '&amp;', ('can_group_payments', '=', True), ('group_payment', '=', False)]}"/>
</group>
<group name="group3"
attrs="{'invisible': ['|', ('payment_difference', '=', 0.0), '|', ('early_payment_discount_mode', '=', True), '|', ('can_edit_wizard', '=', False), '&amp;', ('can_group_payments', '=', True), ('group_payment', '=', False)]}">
<label for="payment_difference"/>
<div>
invisible="not can_group_payments"/>
<label for="payment_difference" invisible="not show_payment_difference"/>
<div invisible="not show_payment_difference">
<field name="payment_difference"/>
<field name="payment_difference_handling" widget="radio" nolabel="1"/>
<div attrs="{'invisible': ['|', ('hide_writeoff_section', '=', True), ('payment_difference_handling','=','open')]}">
<label for="writeoff_account_id" string="Post Difference In" class="oe_edit_only"/>
<field name="writeoff_account_id"
string="Post Difference In"
options="{'no_create': True}"
attrs="{'required': [('payment_difference_handling', '=', 'reconcile'), ('early_payment_discount_mode', '=', False)]}"/>
<label for="writeoff_label" class="oe_edit_only" string="Label"/>
<field name="writeoff_label" attrs="{'required': [('payment_difference_handling', '=', 'reconcile')]}"/>
<field name="payment_difference_handling" widget="radio" nolabel="1" invisible="is_register_payment_on_draft"/>
<div class="o_inner_group grid col-lg-12 p-0"
invisible="hide_writeoff_section or payment_difference_handling == 'open'">
<div class="o_wrap_field d-flex d-sm-contents flex-column mb-3 mb-sm-0">
<div class="o_cell o_wrap_label text-break text-900">
<label for="writeoff_account_id"
string="Post Difference In"
class="oe_edit_only"/>
</div>
<div class="o_cell o_wrap_input text-break">
<field name="writeoff_account_id"
string="Post Difference In"
options="{'no_create': True}"
required="payment_difference_handling == 'reconcile' and not early_payment_discount_mode"/>
</div>
</div>
<div class="o_wrap_field d-flex d-sm-contents flex-column mb-3 mb-sm-0">
<div class="o_cell o_wrap_label text-break text-900">
<label for="writeoff_label"
class="oe_edit_only"
string="Label"
invisible="writeoff_is_exchange_account"/>
</div>
<div class="o_cell o_wrap_input text-break">
<field name="writeoff_label"
required="payment_difference_handling == 'reconcile'"
invisible="writeoff_is_exchange_account"/>
</div>
</div>
</div>
</div>
</group>
<group name="group2">
<label for="amount"/>
<div name="amount_div" class="o_row">
<field name="amount"
force_save="1"
readonly="not can_edit_wizard or can_group_payments and not group_payment"/>
<field name="currency_id"
required="1"
options="{'no_create': True, 'no_open': True}"
invisible="not can_edit_wizard or can_group_payments and not group_payment"
groups="base.group_multi_currency"/>
</div>
<field
name="installments_switch_html"
string=""
widget="account_payment_register_html"
invisible="not installments_switch_html"
/>
<field name="payment_date"/>
<field name="communication"
invisible="not can_edit_wizard or (can_group_payments and not group_payment)"/>
</group>
<field name="qr_code" invisible="1"/>
<div invisible="not qr_code" colspan="2" class="text-center">
<field name="qr_code"/>
</div>
</group>
<footer>
<button string="Create Payment" name="action_create_payments" type="object" class="oe_highlight" data-hotkey="q"/>
<button string="Cancel" class="btn btn-secondary" special="cancel" data-hotkey="z"/>
<button string="Create Payments" name="action_create_payments" type="object" class="oe_highlight" data-hotkey="q" invisible="total_payments_amount == 1"/>
<button string="Create Payment" name="action_create_payments" type="object" class="oe_highlight" data-hotkey="q" invisible="total_payments_amount != 1"/>
<button string="Discard" class="btn btn-secondary" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>

View file

@ -1,15 +1,14 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.tools.date_utils import get_month, get_fiscal_year
from odoo.tools.date_utils import get_fiscal_year
from odoo.tools.misc import format_date
import re
from collections import defaultdict
import json
class ReSequenceWizard(models.TransientModel):
class AccountResequenceWizard(models.TransientModel):
_name = 'account.resequence.wizard'
_description = 'Remake the sequence of Journal Entries.'
@ -23,9 +22,9 @@ class ReSequenceWizard(models.TransientModel):
preview_moves = fields.Text(compute='_compute_preview_moves')
@api.model
def default_get(self, fields_list):
values = super(ReSequenceWizard, self).default_get(fields_list)
if 'move_ids' not in fields_list:
def default_get(self, fields):
values = super().default_get(fields)
if 'move_ids' not in fields:
return values
active_move_ids = self.env['account.move']
if self.env.context['active_model'] == 'account.move' and 'active_ids' in self.env.context:
@ -39,7 +38,7 @@ class ReSequenceWizard(models.TransientModel):
and len(move_types) > 1
):
raise UserError(_('The sequences of this journal are different for Invoices and Refunds but you selected some of both types.'))
is_payment = set(active_move_ids.mapped(lambda x: bool(x.payment_id)))
is_payment = set(active_move_ids.mapped(lambda x: bool(x.origin_payment_id)))
if (
active_move_ids.journal_id.payment_sequence
and len(is_payment) > 1
@ -74,7 +73,13 @@ class ReSequenceWizard(models.TransientModel):
or (self.sequence_number_reset == 'year_range' and line['server-year-start-date'][0:4] != previous_line['server-year-start-date'][0:4])\
or (self.sequence_number_reset == 'month' and line['server-date'][0:7] != previous_line['server-date'][0:7]):
if in_elipsis:
changeLines.append({'id': 'other_' + str(line['id']), 'current_name': _('... (%s other)', in_elipsis), 'new_by_name': '...', 'new_by_date': '...', 'date': '...'})
changeLines.append({
'id': 'other_' + str(line['id']),
'current_name': _('... (%(nb_of_values)s other)', nb_of_values=in_elipsis),
'new_by_name': '...',
'new_by_date': '...',
'date': '...',
})
in_elipsis = 0
changeLines.append(line)
else:
@ -91,16 +96,18 @@ class ReSequenceWizard(models.TransientModel):
"""Compute the proposed new values.
Sets a json string on new_values representing a dictionary thats maps account.move
ids to a disctionay containing the name if we execute the action, and information
ids to a dictionary containing the name if we execute the action, and information
relative to the preview widget.
"""
def _get_move_key(move_id):
company = move_id.company_id
year_start, year_end = get_fiscal_year(move_id.date, day=company.fiscalyear_last_day, month=int(company.fiscalyear_last_month))
date_start, date_end = get_fiscal_year(move_id.date, day=company.fiscalyear_last_day, month=int(company.fiscalyear_last_month))
if self.sequence_number_reset == 'year':
return move_id.date.year
elif self.sequence_number_reset == 'year_range':
return "%s-%s"%(year_start.year, year_end.year)
return "%s-%s" % (date_start.year, date_end.year)
elif self.sequence_number_reset == 'year_range_month':
return "%s-%s/%s" % (date_start.year, date_end.year, move_id.date.month)
elif self.sequence_number_reset == 'month':
return (move_id.date.year, move_id.date.month)
return 'default'
@ -117,7 +124,7 @@ class ReSequenceWizard(models.TransientModel):
new_values = {}
for j, period_recs in enumerate(moves_by_period.values()):
# compute the new values period by period
year_start, year_end = period_recs[0]._get_sequence_date_range(sequence_number_reset)
date_start, date_end, forced_year_start, forced_year_end = period_recs[0]._get_sequence_date_range(sequence_number_reset)
for move in period_recs:
new_values[move.id] = {
'id': move.id,
@ -125,15 +132,15 @@ class ReSequenceWizard(models.TransientModel):
'state': move.state,
'date': format_date(self.env, move.date),
'server-date': str(move.date),
'server-year-start-date': str(year_start),
'server-year-start-date': str(date_start),
}
new_name_list = [seq_format.format(**{
**format_values,
'month': year_start.month,
'year_end': year_end.year % (10 ** format_values['year_end_length']),
'year': year_start.year % (10 ** format_values['year_length']),
'seq': i + (format_values['seq'] if j == (len(moves_by_period)-1) else 1),
'month': date_start.month,
'year_end': (forced_year_end or date_end.year) % (10 ** format_values['year_end_length']),
'year': (forced_year_start or date_start.year) % (10 ** format_values['year_length']),
'seq': i + (format_values['seq'] if j == (len(moves_by_period) - 1) else 1),
}) for i in range(len(period_recs))]
# For all the moves of this period, assign the name by increasing initial name
@ -151,7 +158,7 @@ class ReSequenceWizard(models.TransientModel):
if self.ordering == 'date':
raise UserError(_('You can not reorder sequence by date when the journal is locked with a hash.'))
moves_to_rename = self.env['account.move'].browse(int(k) for k in new_values.keys())
moves_to_rename.name = '/'
moves_to_rename.name = False
moves_to_rename.flush_recordset(["name"])
# If the db is not forcibly updated, the temporary renaming could only happen in cache and still trigger the constraint

View file

@ -23,7 +23,7 @@
</group>
<footer>
<button string="Confirm" name="resequence" type="object" default_focus="1" class="btn-primary" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>
@ -36,9 +36,9 @@
<field name="view_mode">form</field>
<field name="view_id" ref="account_resequence_view"/>
<field name="target">new</field>
<field name="groups_id" eval="[(6, 0, [ref('base.group_no_one')])]"/>
<field name="group_ids" eval="[(6, 0, [ref('base.group_no_one')])]"/>
<field name="binding_model_id" ref="account.model_account_move" />
<field name="binding_view_types">list</field>
<field name="binding_view_types">list,kanban</field>
</record>
</data>
</odoo>

View file

@ -0,0 +1,269 @@
from datetime import timedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.fields import Command, Domain
class AccountSecureEntriesWizard(models.TransientModel):
"""
This wizard is used to secure journal entries (with a hash)
"""
_name = 'account.secure.entries.wizard'
_description = 'Secure Journal Entries'
company_id = fields.Many2one(
comodel_name='res.company',
required=True,
readonly=True,
default=lambda self: self.env.company,
)
country_code = fields.Char(
related="company_id.account_fiscal_country_id.code",
)
hash_date = fields.Date(
string='Hash All Entries',
required=True,
compute="_compute_hash_date",
store=True,
readonly=False,
help="The selected Date",
)
chains_to_hash_with_gaps = fields.Json(
compute='_compute_data',
)
max_hash_date = fields.Date(
string='Max Hash Date',
compute='_compute_max_hash_date',
help="Highest Date such that all posted journal entries prior to (including) the date are secured. Only journal entries after the hard lock date are considered.",
)
unreconciled_bank_statement_line_ids = fields.Many2many(
compute='_compute_data',
comodel_name='account.bank.statement.line',
help="All unreconciled bank statement lines before the selected date.",
)
not_hashable_unlocked_move_ids = fields.Many2many(
compute='_compute_data',
comodel_name='account.move',
help="All unhashable moves before the selected date that are not protected by the Hard Lock Date",
)
move_to_hash_ids = fields.Many2many(
compute='_compute_data',
comodel_name='account.move',
help="All moves that will be hashed",
)
warnings = fields.Json(
compute='_compute_warnings',
)
@api.depends('max_hash_date')
def _compute_hash_date(self):
for wizard in self:
if not wizard.hash_date:
wizard.hash_date = wizard.max_hash_date or fields.Date.context_today(self)
@api.depends('company_id', 'company_id.user_hard_lock_date')
def _compute_max_hash_date(self):
today = fields.Date.context_today(self)
for wizard in self:
chains_to_hash = wizard.with_context(chain_info_warnings=False)._get_chains_to_hash(wizard.company_id, today)
moves = self.env['account.move'].concat(
*[chain['moves'] for chain in chains_to_hash],
*[chain['not_hashable_unlocked_moves'] for chain in chains_to_hash],
)
if moves:
min_date = self.env.execute_query(
self.env['account.move']
._search([('id', 'in', moves.ids)])
.select('MIN(date)')
)[0][0]
wizard.max_hash_date = min_date - timedelta(days=1)
else:
wizard.max_hash_date = False
@api.model
def _get_chains_to_hash(self, company_id, hash_date):
self.ensure_one()
res = []
for *__, chain_moves in self.env['account.move'].sudo()._read_group(
domain=self._get_unhashed_moves_in_hashed_period_domain(company_id, hash_date, [('state', '=', 'posted')]),
groupby=['journal_id', 'sequence_prefix'],
aggregates=['id:recordset']
):
chain_info = chain_moves._get_chain_info(force_hash=True)
if not chain_info:
continue
last_move_hashed = chain_info['last_move_hashed']
# It is possible that some moves cannot be hashed (i.e. after upgrade).
# We show a warning ('account_not_hashable_unlocked_moves') if that is the case.
# These moves are ignored for the warning and max_hash_date in case they are protected by the Hard Lock Date
if last_move_hashed:
# remaining_moves either have a hash already or have a higher sequence_number than the last_move_hashed
not_hashable_unlocked_moves = chain_info['remaining_moves'].filtered(
lambda move: (not move.inalterable_hash
and move.sequence_number < last_move_hashed.sequence_number
and move.date > self.company_id.user_hard_lock_date)
)
else:
not_hashable_unlocked_moves = self.env['account.move']
chain_info['not_hashable_unlocked_moves'] = not_hashable_unlocked_moves
res.append(chain_info)
return res
@api.depends('company_id', 'company_id.user_hard_lock_date', 'hash_date')
def _compute_data(self):
for wizard in self:
unreconciled_bank_statement_line_ids = []
chains_to_hash = []
if wizard.hash_date:
for chain_info in wizard._get_chains_to_hash(wizard.company_id, wizard.hash_date):
if 'unreconciled' in chain_info['warnings']:
unreconciled_bank_statement_line_ids.extend(
chain_info['moves'].statement_line_ids.filtered(lambda l: not l.is_reconciled).ids
)
else:
chains_to_hash.append(chain_info)
wizard.unreconciled_bank_statement_line_ids = [Command.set(unreconciled_bank_statement_line_ids)]
wizard.chains_to_hash_with_gaps = [
{
'first_move_id': chain['moves'][0].id,
'last_move_id': chain['moves'][-1].id,
} for chain in chains_to_hash if 'gap' in chain['warnings']
]
not_hashable_unlocked_moves = []
move_to_hash_ids = []
for chain in chains_to_hash:
not_hashable_unlocked_moves.extend(chain['not_hashable_unlocked_moves'].ids)
move_to_hash_ids.extend(chain['moves'].ids)
wizard.not_hashable_unlocked_move_ids = [Command.set(not_hashable_unlocked_moves)]
wizard.move_to_hash_ids = [Command.set(move_to_hash_ids)]
@api.depends('company_id', 'chains_to_hash_with_gaps', 'hash_date', 'not_hashable_unlocked_move_ids', 'max_hash_date', 'unreconciled_bank_statement_line_ids')
def _compute_warnings(self):
for wizard in self:
warnings = {}
if not wizard.hash_date:
wizard.warnings = warnings
continue
if wizard.unreconciled_bank_statement_line_ids:
ignored_sequence_prefixes = list(set(wizard.unreconciled_bank_statement_line_ids.move_id.mapped('sequence_prefix')))
warnings['account_unreconciled_bank_statement_line_ids'] = {
'message': _("There are still unreconciled bank statement lines before the selected date. "
"The entries from journal prefixes containing them will not be secured: %(prefix_info)s",
prefix_info=ignored_sequence_prefixes),
'level': 'danger',
'action_text': _("Review Statements"),
'action': wizard.company_id._get_unreconciled_statement_lines_redirect_action(wizard.unreconciled_bank_statement_line_ids),
}
draft_entries = self.env['account.move'].search_count(
wizard._get_draft_moves_in_hashed_period_domain(),
limit=1
)
if draft_entries:
warnings['account_unhashed_draft_entries'] = {
'message': _("There are still draft entries before the selected date."),
'action_text': _("Review Entries"),
'action': wizard.action_show_draft_moves_in_hashed_period(),
}
not_hashable_unlocked_moves = wizard.not_hashable_unlocked_move_ids
if not_hashable_unlocked_moves:
warnings['account_not_hashable_unlocked_moves'] = {
'message': _("There are entries that cannot be hashed. They can be protected by the Hard Lock Date."),
'action_text': _("Review Entries"),
'action': wizard.action_show_moves(not_hashable_unlocked_moves),
}
if wizard.chains_to_hash_with_gaps:
OR_domains = []
for chain in wizard.chains_to_hash_with_gaps:
first_move = self.env['account.move'].browse(chain['first_move_id'])
last_move = self.env['account.move'].browse(chain['last_move_id'])
OR_domains.append([
*self.env['account.move']._check_company_domain(wizard.company_id),
('journal_id', '=', last_move.journal_id.id),
('sequence_prefix', '=', last_move.sequence_prefix),
('sequence_number', '<=', last_move.sequence_number),
('sequence_number', '>=', first_move.sequence_number),
])
domain = Domain.OR(OR_domains)
warnings['account_sequence_gap'] = {
'message': _("Securing these entries will create at least one gap in the sequence."),
'action_text': _("Review Entries"),
'action': {
**self.env['account.journal']._show_sequence_holes(list(domain)),
'views': [[self.env.ref('account.view_move_tree_multi_edit').id, 'list'], [self.env.ref('account.view_move_form').id, 'form']],
}
}
moves_to_hash_after_selected_date = wizard.move_to_hash_ids.filtered(lambda move: move.date > wizard.hash_date)
if moves_to_hash_after_selected_date:
warnings['account_move_to_secure_after_selected_date'] = {
'message': _("Securing these entries will also secure entries after the selected date."),
'action_text': _("Review Entries"),
'action': wizard.action_show_moves(moves_to_hash_after_selected_date),
}
wizard.warnings = warnings
@api.model
def _get_unhashed_moves_in_hashed_period_domain(self, company_id, hash_date, domain=False):
"""
Return the domain to find all moves before `self.hash_date` that have not been hashed yet.
We ignore whether hashing is activated for the journal or not.
:return a search domain
"""
if not (company_id and hash_date):
return Domain.FALSE
return Domain.AND([
[
('date', '<=', fields.Date.to_string(hash_date)),
('company_id', 'child_of', company_id.id),
('inalterable_hash', '=', False),
],
domain or Domain.TRUE,
])
def _get_draft_moves_in_hashed_period_domain(self):
self.ensure_one()
return self._get_unhashed_moves_in_hashed_period_domain(self.company_id, self.hash_date, [('state', '=', 'draft')])
def action_show_moves(self, moves):
self.ensure_one()
return {
'view_mode': 'list',
'name': _('Journal Entries'),
'res_model': 'account.move',
'type': 'ir.actions.act_window',
'domain': [('id', 'in', moves.ids)],
'search_view_id': [self.env.ref('account.view_account_move_filter').id, 'search'],
'views': [[self.env.ref('account.view_move_tree_multi_edit').id, 'list'], [self.env.ref('account.view_move_form').id, 'form']],
}
def action_show_draft_moves_in_hashed_period(self):
self.ensure_one()
return {
'view_mode': 'list',
'name': _('Draft Entries'),
'res_model': 'account.move',
'type': 'ir.actions.act_window',
'domain': list(self._get_draft_moves_in_hashed_period_domain()),
'search_view_id': [self.env.ref('account.view_account_move_filter').id, 'search'],
'views': [[self.env.ref('account.view_move_tree_multi_edit').id, 'list'], [self.env.ref('account.view_move_form').id, 'form']],
}
def action_secure_entries(self):
self.ensure_one()
if not self.hash_date:
raise UserError(_("Set a date. The moves will be secured up to including this date."))
if not self.move_to_hash_ids:
return
self.move_to_hash_ids._hash_moves(force_hash=True, raise_if_gap=False)

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_account_secure_entries_wizard" model="ir.ui.view">
<field name="name">account.secure.entries.wizard.form</field>
<field name="model">account.secure.entries.wizard</field>
<field name="arch" type="xml">
<form string="Secure Journal Entries">
<sheet>
<div class="m-0" name="warnings" invisible="not warnings">
<field name="warnings" class="o_field_html" widget="actionable_errors"/>
</div>
<field name="company_id" invisible="1"/>
<div class="d-flex" name="hash_date">
<span class="text-muted">
<i>Secure entries up to</i>
</span>
<field name="hash_date" placeholder="Not hashed"
class="oe_inline ms-2"
options="{'warn_future': true}"/>
<span class="text-muted ms-2">
<i>inclusive, to make them immutable</i>
</span>
</div>
</sheet>
<footer>
<button string="Secure Entries" class="btn-primary" name="action_secure_entries" type="object" data-hotkey="v"/>
<button string="Discard" special="cancel" data-hotkey="z"/>
</footer>
</form>
</field>
</record>
<record id="action_view_account_secure_entries_wizard" model="ir.actions.act_window">
<field name="name">Secure Journal Entries</field>
<field name="res_model">account.secure.entries.wizard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_account_secure_entries_wizard"/>
<field name="target">new</field>
</record>
<menuitem
id="menu_action_secure_entries"
name="Secure Entries"
action="action_view_account_secure_entries_wizard"
parent="account.account_closing_menu"
sequence="80"
groups="base.group_no_one,account.group_account_secured"/>
</data>
</odoo>

View file

@ -1,147 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _, Command, tools
import base64
from datetime import timedelta
class AccountTourUploadBill(models.TransientModel):
_name = 'account.tour.upload.bill'
_description = 'Account tour upload bill'
attachment_ids = fields.Many2many(
comodel_name='ir.attachment',
relation='account_tour_upload_bill_ir_attachments_rel',
string='Attachments')
selection = fields.Selection(
selection=lambda self: self._selection_values(),
default="sample"
)
preview_invoice = fields.Html(
compute="_compute_preview_invoice",
string="Invoice Preview",
translate=True,
)
def _compute_preview_invoice(self):
invoice_date = fields.Date.today() - timedelta(days=12)
addr = [x for x in [
self.env.company.street,
self.env.company.street2,
' '.join([x for x in [self.env.company.state_id.name, self.env.company.zip] if x]),
self.env.company.country_id.name,
] if x]
ref = 'INV/%s/0001' % invoice_date.strftime('%Y/%m')
html = self.env['ir.qweb']._render('account.bill_preview', {
'company_name': self.env.company.name,
'company_street_address': addr,
'invoice_name': 'Invoice ' + ref,
'invoice_ref': ref,
'invoice_date': invoice_date,
'invoice_due_date': invoice_date + timedelta(days=30),
})
for record in self:
record.preview_invoice = html
def _selection_values(self):
journal_alias = self.env['account.journal'] \
.search([('type', '=', 'purchase'), ('company_id', '=', self.env.company.id)], limit=1)
values = [('sample', _('Try a sample vendor bill')), ('upload', _('Upload your own bill'))]
if journal_alias.alias_name and journal_alias.alias_domain:
values.append(('email', _('Or send a bill to %s@%s', journal_alias.alias_name, journal_alias.alias_domain)))
return values
def _action_list_view_bill(self, bill_ids=[]):
context = dict(self._context)
context['default_move_type'] = 'in_invoice'
return {
'name': _('Generated Documents'),
'domain': [('id', 'in', bill_ids)],
'view_mode': 'tree,form',
'res_model': 'account.move',
'views': [[False, "tree"], [False, "form"]],
'type': 'ir.actions.act_window',
'context': context
}
def apply(self):
if self._context.get('active_model') == 'account.journal' and self._context.get('active_ids'):
purchase_journal = self.env['account.journal'].browse(self._context['active_ids'])
else:
purchase_journal = self.env['account.journal'].search([('type', '=', 'purchase')], limit=1)
if self.selection == 'upload':
return purchase_journal.with_context(default_journal_id=purchase_journal.id, default_move_type='in_invoice').create_document_from_attachment(attachment_ids=self.attachment_ids.ids)
elif self.selection == 'sample':
invoice_date = fields.Date.today() - timedelta(days=12)
partner = self.env['res.partner'].search([('name', '=', 'Deco Addict')], limit=1)
if not partner:
partner = self.env['res.partner'].create({
'name': 'Deco Addict',
'is_company': True,
})
bill = self.env['account.move'].create({
'move_type': 'in_invoice',
'partner_id': partner.id,
'ref': 'INV/%s/0001' % invoice_date.strftime('%Y/%m'),
'invoice_date': invoice_date,
'invoice_date_due': invoice_date + timedelta(days=30),
'journal_id': purchase_journal.id,
'invoice_line_ids': [
Command.create({
'name': "[FURN_8999] Three-Seat Sofa",
'quantity': 5,
'price_unit': 1500,
}),
Command.create({
'name': "[FURN_8220] Four Person Desk",
'quantity': 5,
'price_unit': 2350,
})
],
})
# In case of test environment, don't create the pdf
if tools.config['test_enable'] or tools.config['test_file']:
bill.with_context(no_new_invoice=True).message_post()
else:
bodies = self.env['ir.actions.report']._prepare_html(self.preview_invoice)[0]
content = self.env['ir.actions.report']._run_wkhtmltopdf(bodies)
attachment = self.env['ir.attachment'].create({
'type': 'binary',
'name': 'INV-%s-0001.pdf' % invoice_date.strftime('%Y-%m'),
'res_model': 'mail.compose.message',
'datas': base64.encodebytes(content),
})
bill.with_context(no_new_invoice=True).message_post(attachment_ids=[attachment.id])
return self._action_list_view_bill(bill.ids)
else:
email_alias = '%s@%s' % (purchase_journal.alias_name, purchase_journal.alias_domain)
new_wizard = self.env['account.tour.upload.bill.email.confirm'].create({'email_alias': email_alias})
view_id = self.env.ref('account.account_tour_upload_bill_email_confirm').id
return {
'type': 'ir.actions.act_window',
'name': _('Confirm'),
'view_mode': 'form',
'res_model': 'account.tour.upload.bill.email.confirm',
'target': 'new',
'res_id': new_wizard.id,
'views': [[view_id, 'form']],
}
class AccountTourUploadBillEmailConfirm(models.TransientModel):
_name = 'account.tour.upload.bill.email.confirm'
_description = 'Account tour upload bill email confirm'
email_alias = fields.Char(readonly=True)
def apply(self):
purchase_journal = self.env['account.journal'].search([('type', '=', 'purchase')], limit=1)
bill_ids = self.env['account.move'].search([('journal_id', '=', purchase_journal.id)]).ids
return self.env['account.tour.upload.bill']._action_list_view_bill(bill_ids)

View file

@ -1,49 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="account_tour_upload_bill" model="ir.ui.view">
<field name="name">account.tour.upload.bill</field>
<field name="model">account.tour.upload.bill</field>
<field name="arch" type="xml">
<form>
<sheet>
<h2>With Odoo, you won't have to record bills manually</h2>
<p>We process bills automatically so that you only have to validate them. Choose how you want to test our artificial intelligence engine:</p>
<group col="6">
<group colspan="2">
<field name="selection" widget="radio" nolabel="1" colspan="2"/>
</group>
<group colspan="4" attrs="{'invisible': [('selection', '!=', 'sample')]}">
<field name="preview_invoice" widget="html" nolabel="1" colspan="2"/>
</group>
<group colspan="4" attrs="{'invisible': [('selection', '!=', 'upload')]}">
<field name="attachment_ids" widget="many2many_binary" string="Attach a file" nolabel="1" colspan="2"/>
</group>
</group>
</sheet>
<footer>
<button string="Continue" type="object" name="apply" class="btn-primary" data-hotkey="q"/>
<button string="Discard" class="btn-secondary" special="cancel" data-hotkey="z" />
</footer>
</form>
</field>
</record>
<record id="account_tour_upload_bill_email_confirm" model="ir.ui.view">
<field name="name">account.tour.upload.bill.email.confirm</field>
<field name="model">account.tour.upload.bill.email.confirm</field>
<field name="arch" type="xml">
<form>
<sheet>
<p>Send your email to <field name="email_alias" class="oe_inline"/> with a pdf of an invoice as attachment.</p>
<p>Once done, press continue.</p>
</sheet>
<footer>
<button string="Continue" type="object" name="apply" class="btn-primary" data-hotkey="q"/>
<button string="Discard" class="btn-secondary" special="cancel" data-hotkey="z" />
</footer>
</form>
</field>
</record>
</data>
</odoo>

View file

@ -1,12 +0,0 @@
from odoo import models, api
class AccountUnreconcile(models.TransientModel):
_name = "account.unreconcile"
_description = "Account Unreconcile"
def trans_unrec(self):
context = dict(self._context or {})
if context.get('active_ids', False):
self.env['account.move.line'].browse(context.get('active_ids')).remove_move_reconcile()
return {'type': 'ir.actions.act_window_close'}

View file

@ -2,30 +2,13 @@
<odoo>
<data>
<record id="account_unreconcile_view" model="ir.ui.view">
<field name="name">Unreconcile Entries</field>
<field name="model">account.unreconcile</field>
<field name="arch" type="xml">
<form string="Unreconcile">
<separator string="Unreconcile Transactions"/>
<form class="o_form_label">If you unreconcile transactions, you must also verify all the actions that are linked to those transactions because they will not be disabled</form>
<footer>
<button string="Unreconcile" name="trans_unrec" type="object" default_focus="1" class="btn-primary" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
</footer>
</form>
</field>
</record>
<record id="action_account_unreconcile" model="ir.actions.act_window">
<record id="action_account_unreconcile" model="ir.actions.server">
<field name="name">Unreconcile</field>
<field name="groups_id" eval="[(4, ref('account.group_account_user'))]"/>
<field name="res_model">account.unreconcile</field>
<field name="view_mode">form</field>
<field name="view_id" ref="account_unreconcile_view"/>
<field name="target">new</field>
<field name="binding_model_id" ref="account.model_account_move_line" />
<field name="binding_view_types">list</field>
<field name="group_ids" eval="[(4, ref('account.group_account_user'))]"/>
<field name="model_id" ref="account.model_account_move_line"/>
<field name="binding_model_id" ref="account.model_account_move_line"/>
<field name="state">code</field>
<field name="code">model.action_unreconcile_match_entries()</field>
</record>
</data>

View file

@ -1,25 +1,79 @@
from odoo import models, fields, _
from odoo import Command, models, fields, api, _
from odoo.exceptions import UserError
class ValidateAccountMove(models.TransientModel):
_name = "validate.account.move"
_name = 'validate.account.move'
_description = "Validate Account Move"
move_ids = fields.Many2many('account.move')
force_post = fields.Boolean(string="Force", help="Entries in the future are set to be auto-posted by default. Check this checkbox to post them now.")
display_force_post = fields.Boolean(compute='_compute_display_force_post')
force_hash = fields.Boolean(string="Force Hash")
display_force_hash = fields.Boolean(compute='_compute_display_force_hash')
is_entries = fields.Boolean(compute='_compute_is_entries')
abnormal_date_partner_ids = fields.One2many('res.partner', compute='_compute_abnormal_date_partner_ids')
ignore_abnormal_date = fields.Boolean()
abnormal_amount_partner_ids = fields.One2many('res.partner', compute='_compute_abnormal_amount_partner_ids')
ignore_abnormal_amount = fields.Boolean()
@api.depends('move_ids')
def _compute_display_force_post(self):
today = fields.Date.context_today(self)
for wizard in self:
wizard.display_force_post = wizard.move_ids.filtered(lambda m: (m.date or m.invoice_date or today) > today)
@api.depends('move_ids')
def _compute_display_force_hash(self):
for wizard in self:
wizard.display_force_hash = wizard.move_ids.filtered('restrict_mode_hash_table')
@api.depends('move_ids')
def _compute_is_entries(self):
for wizard in self:
wizard.is_entries = any(move_type == 'entry' for move_type in wizard.move_ids.mapped('move_type'))
@api.depends('move_ids')
def _compute_abnormal_date_partner_ids(self):
for wizard in self:
wizard.abnormal_date_partner_ids = wizard.move_ids.filtered('abnormal_date_warning').partner_id
@api.depends('move_ids')
def _compute_abnormal_amount_partner_ids(self):
for wizard in self:
wizard.abnormal_amount_partner_ids = wizard.move_ids.filtered('abnormal_amount_warning').partner_id
@api.model
def default_get(self, fields):
result = super().default_get(fields)
if 'move_ids' in fields and not result.get('move_ids'):
if self.env.context.get('active_model') == 'account.move':
domain = [('id', 'in', self.env.context.get('active_ids', [])), ('state', '=', 'draft')]
elif self.env.context.get('active_model') == 'account.journal':
domain = [('journal_id', '=', self.env.context.get('active_id')), ('state', '=', 'draft')]
else:
raise UserError(_("Missing 'active_model' in context."))
moves = self.env['account.move'].search(domain).filtered('line_ids')
if not moves:
raise UserError(_('There are no journal items in the draft state to post.'))
result['move_ids'] = [Command.set(moves.ids)]
return result
def validate_move(self):
if self._context.get('active_model') == 'account.move':
domain = [('id', 'in', self._context.get('active_ids', [])), ('state', '=', 'draft')]
elif self._context.get('active_model') == 'account.journal':
domain = [('journal_id', '=', self._context.get('active_id')), ('state', '=', 'draft')]
else:
raise UserError(_("Missing 'active_model' in context."))
moves = self.env['account.move'].search(domain).filtered('line_ids')
if not moves:
raise UserError(_('There are no journal items in the draft state to post.'))
if self.ignore_abnormal_amount:
self.abnormal_amount_partner_ids.ignore_abnormal_invoice_amount = True
if self.ignore_abnormal_date:
self.abnormal_date_partner_ids.ignore_abnormal_invoice_date = True
if self.force_post:
moves.auto_post = 'no'
moves._post(not self.force_post)
self.move_ids.auto_post = 'no'
if self.force_hash:
moves_to_post = self.move_ids
else:
moves_to_post = self.move_ids.filtered(lambda m: not m.restrict_mode_hash_table)
moves_to_post._post(not self.force_post)
if autopost_bills_wizard := moves_to_post._show_autopost_bills_wizard():
return autopost_bills_wizard
return {'type': 'ir.actions.act_window_close'}

View file

@ -4,40 +4,77 @@
<!--Account Move lines-->
<record id="validate_account_move_view" model="ir.ui.view">
<field name="name">Post Journal Entries</field>
<field name="name">Confirm Entries</field>
<field name="model">validate.account.move</field>
<field name="arch" type="xml">
<form string="Post Journal Entries">
<group>
<field name="force_post"/>
<form string="Confirm Entries">
<field name="move_ids" invisible="1"/>
<h4 class="mb-4">
The selected
<t invisible="not is_entries"> entries </t>
<t invisible="is_entries"> invoices </t>
will be posted. Some of them may be future-dated or require hashing,</h4>
<h4>and will not be posted automatically.</h4>
<group invisible="not display_force_post">
<field name="display_force_post" invisible="1"/>
<div colspan="2">
Future-dated
<t invisible="not is_entries"> entries </t>
<t invisible="is_entries"> invoices </t>
will auto-confirm on their respective dates.
<field name="force_post" class="oe_inline"/><label for="force_post" string="Confirm them now"/>
</div>
</group>
<group invisible="not display_force_hash">
<div colspan="2"> Some
<t invisible="not is_entries"> entries </t>
<t invisible="is_entries"> invoices </t>
will be secured with hash.
<field name="force_hash" class="oe_inline"/><label for="force_hash" string="Yes, confirm and secure them now."/>
</div>
</group>
<group invisible="not abnormal_date_partner_ids">
<div colspan="2">
<t invisible="not is_entries"> Entries </t>
<t invisible="is_entries"> Invoices </t>
dates for
<field name="abnormal_date_partner_ids" widget="many2many_tags" class="oe_inline"/>
fall outside the typical range.
<field name="ignore_abnormal_date" class="oe_inline"/><label for="ignore_abnormal_date" string="Ignore future alerts"/>
</div>
</group>
<group invisible="not abnormal_amount_partner_ids">
<div colspan="2">
<t invisible="not is_entries"> Entries </t>
<t invisible="is_entries"> Invoices </t>
amounts for
<field name="abnormal_amount_partner_ids" widget="many2many_tags" class="oe_inline"/>
fall outside the typical range.
<field name="ignore_abnormal_amount" class="oe_inline"/><label for="ignore_abnormal_amount" string="Ignore future alerts"/>
</div>
</group>
<span class="o_form_label">All selected journal entries will be validated and posted. You won't be able to modify them afterwards.</span>
<footer>
<button string="Post Journal Entries"
<button string="Confirm"
name="validate_move"
type="object"
default_focus="1"
class="btn-primary"
data-hotkey="q"
context="{'validate_analytic': True}"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>
</record>
<record id="action_validate_account_move" model="ir.actions.act_window">
<field name="name">Post entries</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">validate.account.move</field>
<field name="view_mode">form</field>
<field name="view_id" ref="validate_account_move_view"/>
<field name="context">{}</field>
<field name="target">new</field>
<field name="help">This wizard will validate all journal entries selected. Once journal entries are validated, you can not update them anymore.</field>
<field name="groups_id" eval="[(4, ref('account.group_account_invoice'))]"/>
<record id="action_validate_account_moves" model="ir.actions.server">
<field name="name">Confirm Entries</field>
<field name="model_id" ref="account.model_account_move" />
<field name="group_ids" eval="[(4, ref('account.group_account_invoice'))]" />
<field name="binding_model_id" ref="account.model_account_move" />
<field name="binding_view_types">list</field>
<field name="binding_view_types">list,kanban</field>
<field name="state">code</field>
<field name="code">action = records.action_validate_moves_with_confirmation()</field>
</record>
</data>

View file

@ -1,45 +1,35 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from dateutil.relativedelta import relativedelta
import json
from odoo import models, fields, api, _, Command
from odoo.tools import format_date
from odoo.tools import float_is_zero, format_date
from odoo.exceptions import UserError
from odoo.tools import date_utils
from odoo.tools.misc import formatLang
class AccruedExpenseRevenue(models.TransientModel):
class AccountAccruedOrdersWizard(models.TransientModel):
_name = 'account.accrued.orders.wizard'
_description = 'Accrued Orders Wizard'
def _get_account_domain(self):
if self.env.context.get('active_model') == 'purchase.order':
return [('account_type', '=', 'liability_current'), ('company_id', '=', self._get_default_company())]
else:
return [('account_type', '=', 'asset_current'), ('company_id', '=', self._get_default_company())]
_check_company_auto = True
def _get_default_company(self):
if not self._context.get('active_model'):
if not self.env.context.get('active_model'):
return
orders = self.env[self._context['active_model']].browse(self._context['active_ids'])
orders = self.env[self.env.context['active_model']].browse(self.env.context['active_ids'])
return orders and orders[0].company_id.id
def _get_default_journal(self):
return self.env['account.journal'].search([('company_id', '=', self.env.company.id), ('type', '=', 'general')], limit=1)
def _get_default_date(self):
return date_utils.get_month(fields.Date.context_today(self))[0] - relativedelta(days=1)
company_id = fields.Many2one('res.company', default=_get_default_company)
journal_id = fields.Many2one(
comodel_name='account.journal',
compute='_compute_journal_id',
domain="[('type', '=', 'general'), ('company_id', '=', company_id)]",
readonly=False,
compute='_compute_journal_id', store=True, readonly=False, precompute=True,
domain="[('type', '=', 'general')]",
required=True,
default=_get_default_journal,
check_company=True,
company_dependent=True,
string='Journal',
)
date = fields.Date(default=_get_default_date, required=True)
@ -60,14 +50,14 @@ class AccruedExpenseRevenue(models.TransientModel):
required=True,
string='Accrual Account',
check_company=True,
domain=_get_account_domain,
domain="[('account_type', '=', 'liability_current')] if context.get('active_model') in ['purchase.order', 'purchase.order.line'] else [('account_type', '=', 'asset_current')]",
)
preview_data = fields.Text(compute='_compute_preview_data')
display_amount = fields.Boolean(compute='_compute_display_amount')
@api.depends('date', 'amount')
def _compute_display_amount(self):
single_order = len(self._context['active_ids']) == 1
single_order = len(self.env.context['active_ids']) == 1
for record in self:
preview_data = json.loads(self.preview_data)
lines = preview_data.get('groups_vals', [])[0].get('items_vals', [])
@ -83,11 +73,11 @@ class AccruedExpenseRevenue(models.TransientModel):
@api.depends('company_id')
def _compute_journal_id(self):
journal = self.env['account.journal'].search(
[('type', '=', 'general'), ('company_id', '=', self.company_id.id)], limit=1
)
for record in self:
record.journal_id = journal
record.journal_id = self.env['account.journal'].search([
*self.env['account.journal']._check_company_domain(record.company_id),
('type', '=', 'general')
], limit=1)
@api.depends('date', 'journal_id', 'account_id', 'amount')
def _compute_preview_data(self):
@ -145,82 +135,173 @@ class AccruedExpenseRevenue(models.TransientModel):
self.ensure_one()
move_lines = []
is_purchase = self.env.context.get('active_model') == 'purchase.order'
orders = self.env[self._context['active_model']].with_company(self.company_id).browse(self._context['active_ids'])
active_model = self.env.context.get('active_model')
if active_model in ['purchase.order.line', 'sale.order.line']:
lines = self.env[active_model].with_company(self.company_id).browse(self.env.context['active_ids'])
orders = lines.order_id
else:
orders = self.env[active_model].with_company(self.company_id).browse(self.env.context['active_ids'])
lines = orders.order_line.filtered(lambda x: x.product_id)
is_purchase = orders._name == 'purchase.order'
if orders.filtered(lambda o: o.company_id != self.company_id):
raise UserError(_('Entries can only be created for a single company at a time.'))
if orders.currency_id and len(orders.currency_id) > 1:
raise UserError(_('Cannot create an accrual entry with orders in different currencies.'))
orders_with_entries = []
fnames = []
total_balance = 0.0
for order in orders:
product_lines = order.order_line.filtered(lambda x: x.product_id)
amounts_by_perpetual_account = defaultdict(float)
price_diff_values = []
for order, product_lines in lines.grouped('order_id').items():
if len(orders) == 1 and product_lines and self.amount and order.order_line:
total_balance = self.amount
order_line = product_lines[0]
account = self._get_computed_account(order, order_line.product_id, is_purchase)
distribution = order_line.analytic_distribution if order_line.analytic_distribution else {}
if not is_purchase and order.analytic_account_id:
analytic_account_id = str(order.analytic_account_id.id)
distribution[analytic_account_id] = distribution.get(analytic_account_id, 0) + 100.0
values = _get_aml_vals(order, self.amount, 0, account.id, label=_('Manual entry'), analytic_distribution=distribution)
move_lines.append(Command.create(values))
else:
other_currency = self.company_id.currency_id != order.currency_id
rate = order.currency_id._get_rates(self.company_id, self.date).get(order.currency_id.id) if other_currency else 1.0
# create a virtual order that will allow to recompute the qty delivered/received (and dependancies)
# without actually writing anything on the real record (field is computed and stored)
o = order.new(origin=order)
if is_purchase:
o.order_line.with_context(accrual_entry_date=self.date)._compute_qty_received()
o.order_line.with_context(accrual_entry_date=self.date)._compute_qty_invoiced()
else:
o.order_line.with_context(accrual_entry_date=self.date)._compute_qty_delivered()
o.order_line.with_context(accrual_entry_date=self.date)._compute_qty_invoiced()
o.order_line.with_context(accrual_entry_date=self.date)._compute_untaxed_amount_invoiced()
o.order_line.with_context(accrual_entry_date=self.date)._compute_qty_to_invoice()
lines = o.order_line.filtered(
lambda l: l.display_type not in ['line_section', 'line_note'] and
accrual_entry_date = self.env.context.get('accrual_entry_date', self.date)
order_lines = lines.with_context(accrual_entry_date=accrual_entry_date).filtered(
# We only want non-comment lines (no sections, notes, ...) and include all lines
# for purchase orders but exclude downpayment lines for sales orders.
lambda l: not l.display_type and not l.is_downpayment and
l.id in order.order_line.ids and
fields.Float.compare(
l.qty_to_invoice,
l.amount_to_invoice_at_date,
0,
precision_rounding=l.product_uom.rounding,
precision_rounding=l.product_uom_id.rounding,
) != 0
)
for order_line in lines:
for order_line in order_lines:
product = order_line.product_id
if is_purchase:
account = self._get_computed_account(order, order_line.product_id, is_purchase)
if any(tax.price_include for tax in order_line.taxes_id):
# Compute the price unit from the amount to invoice if there is one,
# otherwise use the PO line price unit.
price_unit = order_line.price_unit
quantity_to_invoice = order_line.qty_invoiced_at_date - order_line.qty_received_at_date
if quantity_to_invoice >= 1:
posted_invoice_lines = order_line.invoice_lines.filtered(lambda ivl:
ivl.move_id.state == 'posted' and ivl.date <= accrual_entry_date
)
invoiced_values = sum(ivl.price_subtotal for ivl in posted_invoice_lines)
received_values = order_line.qty_received_at_date * order_line.price_unit
value_to_invoice = invoiced_values - received_values
price_unit = value_to_invoice / quantity_to_invoice
expense_account, stock_variation_account = self._get_product_expense_and_stock_var_accounts(product)
account = stock_variation_account if stock_variation_account else self._get_computed_account(order, order_line.product_id, is_purchase)
if any(tax.price_include for tax in order_line.tax_ids):
# As included taxes are not taken into account in the price_unit, we need to compute the price_subtotal
price_subtotal = order_line.taxes_id.compute_all(
order_line.price_unit,
qty_to_invoice = order_line.qty_received_at_date - order_line.qty_invoiced_at_date
price_subtotal = order_line.tax_ids.compute_all(
price_unit,
currency=order_line.order_id.currency_id,
quantity=order_line.qty_to_invoice,
quantity=qty_to_invoice,
product=order_line.product_id,
partner=order_line.order_id.partner_id)['total_excluded']
else:
price_subtotal = order_line.qty_to_invoice * order_line.price_unit
amount = self.company_id.currency_id.round(price_subtotal / rate)
price_subtotal = (order_line.qty_received_at_date - order_line.qty_invoiced_at_date) * price_unit
amount_currency = order_line.currency_id.round(price_subtotal)
fnames = ['qty_to_invoice', 'qty_received', 'qty_invoiced', 'invoice_lines']
label = _('%s - %s; %s Billed, %s Received at %s each', order.name, _ellipsis(order_line.name, 20), order_line.qty_invoiced, order_line.qty_received, formatLang(self.env, order_line.price_unit, currency_obj=order.currency_id))
amount = order.currency_id._convert(amount_currency, self.company_id.currency_id, self.company_id)
label = _(
'%(order)s - %(order_line)s; %(quantity_billed)s Billed, %(quantity_received)s Received at %(unit_price)s each',
order=order.name,
order_line=_ellipsis(order_line.name, 20),
quantity_billed=order_line.qty_invoiced_at_date,
quantity_received=order_line.qty_received_at_date,
unit_price=formatLang(self.env, price_unit, currency_obj=order.currency_id),
)
# Generate price diff account move lines if needed.
price_diff_account = False
if product.cost_method == 'standard':
price_diff_account = product.categ_id.property_price_difference_account_id
if price_diff_account:
qty_to_invoice = order_line.qty_received_at_date - order_line.qty_invoiced_at_date
diff_label = _('%(order)s - %(order_line)s; price difference for %(product)s',
order=order.name,
order_line=_ellipsis(order_line.name, 20),
product=product.display_name
)
unit_price_diff = order_line.product_id.standard_price - price_unit
price_diff = qty_to_invoice * unit_price_diff
if not float_is_zero(price_diff, precision_rounding=order_line.currency_id.rounding):
price_diff_values.append(_get_aml_vals(
order,
-price_diff,
price_diff,
price_diff_account.id,
label=diff_label,
analytic_distribution=False
))
price_diff_values.append(_get_aml_vals(
order,
price_diff,
price_diff,
product.categ_id.account_stock_variation_id.id,
label=diff_label,
analytic_distribution=False
))
else:
account = self._get_computed_account(order, order_line.product_id, is_purchase)
amount = self.company_id.currency_id.round(order_line.untaxed_amount_to_invoice / rate)
amount_currency = order_line.untaxed_amount_to_invoice
fnames = ['qty_to_invoice', 'untaxed_amount_to_invoice', 'qty_invoiced', 'qty_delivered', 'invoice_lines']
label = _('%s - %s; %s Invoiced, %s Delivered at %s each', order.name, _ellipsis(order_line.name, 20), order_line.qty_invoiced, order_line.qty_delivered, formatLang(self.env, order_line.price_unit, currency_obj=order.currency_id))
qty_to_invoice = order_line.qty_delivered_at_date - order_line.qty_invoiced_at_date
expense_account, stock_variation_account = self._get_product_expense_and_stock_var_accounts(product)
account = self._get_computed_account(order, product, is_purchase)
price_unit = order_line.price_unit
if qty_to_invoice > 0:
# Invoices to be issued.
amount_currency = order_line.amount_to_invoice_at_date
amount = order.currency_id._convert(amount_currency, self.company_id.currency_id, self.company_id)
elif qty_to_invoice < 0:
# Invoiced not delivered.
posted_invoice_line = order_line.invoice_lines.filtered(lambda ivl: ivl.move_id.state == 'posted')[0]
price_unit = posted_invoice_line.price_unit
amount_currency = qty_to_invoice * price_unit
amount = order.currency_id._convert(amount_currency, self.company_id.currency_id, self.company_id)
label = _(
'%(order)s - %(order_line)s; %(quantity_invoiced)s Invoiced, %(quantity_delivered)s Delivered at %(unit_price)s each',
order=order.name,
order_line=_ellipsis(order_line.name, 20),
quantity_invoiced=order_line.qty_invoiced_at_date,
quantity_delivered=order_line.qty_delivered_at_date,
unit_price=formatLang(self.env, price_unit, currency_obj=order.currency_id),
)
if expense_account and stock_variation_account:
# Evaluate if there are more invoiced or more delivered.
if qty_to_invoice > 0:
# Invoices to be issued.
# First, compute the delivered value.
stock_moves = order_line.move_ids.filtered(lambda m: m.state == 'done')
delivered_value = sum(m.value for m in stock_moves)
# Then, compute the already invoiced value.
posted_invoice_lines = order_line.invoice_lines.filtered(lambda ivl: ivl.move_id.state == 'posted')
expense_invoice_lines = posted_invoice_lines.move_id.line_ids.filtered(lambda ivl:
ivl.move_id.state == 'posted' and
ivl.account_id == expense_account
)
invoiced_value = sum(l.balance for l in expense_invoice_lines)
# The amount to invoice is equal to the delivered value minus the already invoiced value.
perpetual_amount = delivered_value - invoiced_value
amounts_by_perpetual_account[expense_account, stock_variation_account] += perpetual_amount
elif qty_to_invoice < 0:
# Invoiced not delivered.
label += " (*)"
posted_invoice_lines = order_line.invoice_lines.filtered(lambda ivl: ivl.move_id.state == 'posted')
expense_invoice_lines = posted_invoice_lines.move_id.line_ids.filtered(lambda ivl:
ivl.move_id.state == 'posted' and
ivl.account_id == expense_account
)
invoiced_quantity = sum(posted_invoice_lines.mapped('quantity'))
sum_amount = sum(expense_invoice_lines.mapped('debit'))
invoiced_unit_price = sum_amount / invoiced_quantity
perpetual_amount = invoiced_unit_price * qty_to_invoice
amounts_by_perpetual_account[expense_account, stock_variation_account] += perpetual_amount
distribution = order_line.analytic_distribution if order_line.analytic_distribution else {}
if not is_purchase and order.analytic_account_id:
analytic_account_id = str(order.analytic_account_id.id)
distribution[analytic_account_id] = distribution.get(analytic_account_id, 0) + 100.0
values = _get_aml_vals(order, amount, amount_currency, account.id, label=label, analytic_distribution=distribution)
move_lines.append(Command.create(values))
total_balance += amount
# must invalidate cache or o can mess when _create_invoices().action_post() of original order after this
order.order_line.invalidate_model(fnames)
if not self.company_id.currency_id.is_zero(total_balance):
# globalized counterpart for the whole orders selection
@ -228,9 +309,6 @@ class AccruedExpenseRevenue(models.TransientModel):
total = sum(order.amount_total for order in orders)
for line in orders.order_line:
ratio = line.price_total / total
if not is_purchase and line.order_id.analytic_account_id:
account_id = str(line.order_id.analytic_account_id.id)
analytic_distribution.update({account_id: analytic_distribution.get(account_id, 0) +100.0*ratio})
if not line.analytic_distribution:
continue
for account_id, distribution in line.analytic_distribution.items():
@ -238,9 +316,25 @@ class AccruedExpenseRevenue(models.TransientModel):
values = _get_aml_vals(orders, -total_balance, 0.0, self.account_id.id, label=_('Accrued total'), analytic_distribution=analytic_distribution)
move_lines.append(Command.create(values))
for (expense_account, stock_variation_account), amount in amounts_by_perpetual_account.items():
if amount == 0:
continue
if amount > 0:
label = _('(*) Goods Delivered not Invoiced (perpetual valuation)')
else:
label = _('(*) Goods Invoiced not Delivered (perpetual valuation)')
values = _get_aml_vals(orders, amount, 0.0, stock_variation_account.id, label=label)
move_lines.append(Command.create(values))
values = _get_aml_vals(orders, -amount, 0.0, expense_account.id, label=label)
move_lines.append(Command.create(values))
for values in price_diff_values:
move_lines.append(Command.create(values))
move_type = _('Expense') if is_purchase else _('Revenue')
move_vals = {
'ref': _('Accrued %s entry as of %s', move_type, format_date(self.env, self.date)),
'ref': _('Accrued %(entry_type)s entry as of %(date)s', entry_type=move_type, date=format_date(self.env, self.date)),
'name': '/',
'journal_id': self.journal_id.id,
'date': self.date,
'line_ids': move_lines,
@ -248,6 +342,16 @@ class AccruedExpenseRevenue(models.TransientModel):
}
return move_vals, orders_with_entries
def _get_accrual_message_body(self, move, reverse_move):
self.ensure_one()
return _(
'Accrual entry created on %(date)s: %(accrual_entry)s.\
And its reverse entry: %(reverse_entry)s.',
date=self.date,
accrual_entry=move._get_html_link(),
reverse_entry=reverse_move._get_html_link(),
)
def create_entries(self):
self.ensure_one()
@ -258,22 +362,20 @@ class AccruedExpenseRevenue(models.TransientModel):
move._post()
reverse_move = move._reverse_moves(default_values_list=[{
'ref': _('Reversal of: %s', move.ref),
'name': '/',
'date': self.reversal_date,
}])
reverse_move._post()
for order in orders_with_entries:
body = _(
'Accrual entry created on %(date)s: %(accrual_entry)s.\
And its reverse entry: %(reverse_entry)s.',
date=self.date,
accrual_entry=move._get_html_link(),
reverse_entry=reverse_move._get_html_link(),
)
order.message_post(body=body)
order.message_post(body=self._get_accrual_message_body(move, reverse_move))
return {
'name': _('Accrual Moves'),
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'view_mode': 'tree,form',
'domain': [('id', 'in', (move.id, reverse_move.id))],
'view_mode': 'list,form',
'domain': [('id', 'in', (move | reverse_move).ids)],
}
@api.model
def _get_product_expense_and_stock_var_accounts(self, product):
return (False, False)

View file

@ -7,13 +7,13 @@
<form string="Make Accrual Entries">
<field name="company_id" invisible="1"/>
<group>
<div class="alert alert-info" colspan="4" role="alert" attrs="{'invisible': [('display_amount', '!=', True)]}">
<div class="alert alert-info" colspan="4" role="alert" invisible="not display_amount">
There doesn't appear to be anything to invoice for the selected order. However, you can use the amount field to force an accrual entry.
</div>
<group>
<field name="journal_id"/>
<field name="account_id"/>
<field name="amount" attrs="{'invisible': [('display_amount', '!=', True)]}"/>
<field name="amount" invisible="not display_amount"/>
<field name="display_amount" invisible="1"/>
</group>
<group>

View file

@ -1,11 +1,78 @@
from odoo import models
from odoo import api, Command, fields, models
class BaseDocumentLayout(models.TransientModel):
_inherit = 'base.document.layout'
from_invoice = fields.Boolean()
qr_code = fields.Boolean(related='company_id.qr_code', readonly=False)
vat = fields.Char(related='company_id.vat', readonly=False,)
account_number = fields.Char(compute='_compute_account_number', inverse='_inverse_account_number',)
def document_layout_save(self):
"""Save layout and onboarding step progress, return super() result"""
res = super(BaseDocumentLayout, self).document_layout_save()
for wizard in self:
wizard.company_id.action_save_onboarding_invoice_layout()
if step := self.env.ref('account.onboarding_onboarding_step_base_document_layout', raise_if_not_found=False):
for company_id in self.company_id:
step.with_company(company_id).action_set_just_done()
# When we finish the configuration of the layout, we want the dialog size to be reset to large
# which is the default behaviour.
if res.get('context'):
res['context']['dialog_size'] = 'large'
return res
def _get_preview_template(self):
if (
self.env.context.get('active_model') == 'account.move'
and self.env.context.get('active_id')
):
return 'account.report_invoice_wizard_iframe'
return super()._get_preview_template()
def _get_render_information(self, styles):
res = super()._get_render_information(styles)
if (
self.env.context.get('active_model') == 'account.move'
and (active_id := self.env.context.get('active_id'))
):
res['o'] = self.env['account.move'].browse(active_id)
if self._get_preview_template() in [
'web.report_invoice_wizard_preview',
'account.report_invoice_wizard_iframe'
]:
res.update({
'qr_code': self.qr_code,
'account_number': self.account_number,
})
return res
@api.depends('partner_id', 'account_number')
def _compute_account_number(self):
for record in self:
if record.partner_id.bank_ids:
record.account_number = record.partner_id.bank_ids[0].acc_number or ''
else:
record.account_number = ''
@api.depends('qr_code', 'account_number')
def _compute_preview(self):
# EXTENDS 'web' to add dependencies
super()._compute_preview()
def _inverse_account_number(self):
for record in self:
if record.partner_id.bank_ids and record.account_number:
bank = record.partner_id.bank_ids[0]
if bank.acc_number != record.account_number:
bank.allow_out_payment = False
bank.acc_number = record.account_number
bank.allow_out_payment = True
elif record.account_number:
record.partner_id.bank_ids += self.env['res.partner.bank']._find_or_create_bank_account(
account_number=record.account_number,
partner=record.partner_id, allow_company_account_creation=True,
company=record.company_id,
)

View file

@ -1,7 +1,7 @@
from odoo import models
class MergePartnerAutomatic(models.TransientModel):
class BasePartnerMergeAutomaticWizard(models.TransientModel):
_inherit = 'base.partner.merge.automatic.wizard'
def _get_summable_fields(self):

View file

@ -7,7 +7,7 @@ from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class FinancialYearOpeningWizard(models.TransientModel):
class AccountFinancialYearOp(models.TransientModel):
_name = 'account.financial.year.op'
_description = 'Opening Balance of Financial Year'
@ -35,37 +35,65 @@ class FinancialYearOpeningWizard(models.TransientModel):
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)
_('Incorrect fiscal year date: day is out of range for month. Month: %(month)s; Day: %(day)s',
month=wiz.fiscalyear_last_month, day=wiz.fiscalyear_last_day)
)
def write(self, vals):
@api.model
def _company_fields_to_update(self):
return {'fiscalyear_last_day', 'fiscalyear_last_month', 'opening_date'}
@api.model
def _update_company(self, company_id, vals):
# Amazing workaround: non-stored related fields on company are a BAD idea since the 3 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 wiz in self:
wiz.company_id.write({
'fiscalyear_last_day': vals.get('fiscalyear_last_day') or wiz.company_id.fiscalyear_last_day,
'fiscalyear_last_month': vals.get('fiscalyear_last_month') or wiz.company_id.fiscalyear_last_month,
'account_opening_date': vals.get('opening_date') or wiz.company_id.account_opening_date,
})
wiz.company_id.account_opening_move_id.write({
'date': fields.Date.from_string(vals.get('opening_date') or wiz.company_id.account_opening_date) - timedelta(days=1),
company_fields_to_update = {k: k for k in self._company_fields_to_update()}
company_fields_to_update['opening_date'] = 'account_opening_date'
company_id.write({
company_field: vals[wizard_field] for wizard_field, company_field in company_fields_to_update.items() if wizard_field in vals
})
opening_date = vals.get('opening_date', company_id.account_opening_date)
opening_move = company_id.account_opening_move_id
if opening_date and opening_move.state == 'draft':
opening_move.write({
'date': fields.Date.from_string(opening_date) - timedelta(days=1),
})
vals.pop('opening_date', None)
vals.pop('fiscalyear_last_day', None)
vals.pop('fiscalyear_last_month', None)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if 'company_id' in vals:
company = self.env['res.company'].browse(vals['company_id'])
self._update_company(company, vals)
# we need to keep opening_date in vals since it's a required field otherwise the wizard fails to be created
for key in self._company_fields_to_update() - {'opening_date'}:
vals.pop(key, None)
return super().create(vals_list)
def write(self, vals):
for wiz in self:
wiz._update_company(wiz.company_id, vals)
for key in self._company_fields_to_update():
vals.pop(key, None)
return super().write(vals)
def action_save_onboarding_fiscal_year(self):
self.env.company.sudo().set_onboarding_step_done('account_setup_fy_data_state')
step_state = self.env['onboarding.onboarding.step'].with_company(self.company_id).action_validate_step('account.onboarding_onboarding_step_fiscal_year')
# move the state to DONE to avoid an update in the web_read
if step_state == 'JUST_DONE':
self.env.ref('account.onboarding_onboarding_account_dashboard')._prepare_rendering_values()
return {'type': 'ir.actions.client', 'tag': 'soft_reload'}
class SetupBarBankConfigWizard(models.TransientModel):
_inherits = {'res.partner.bank': 'res_partner_bank_id'}
class AccountSetupBankManualConfig(models.TransientModel):
_name = 'account.setup.bank.manual.config'
_inherits = {'res.partner.bank': 'res_partner_bank_id'}
_description = 'Bank setup manual config'
_check_company_auto = True
@ -74,13 +102,19 @@ class SetupBarBankConfigWizard(models.TransientModel):
linked_journal_id = fields.Many2one(string="Journal",
comodel_name='account.journal', inverse='set_linked_journal_id',
compute="_compute_linked_journal_id",
domain=lambda self: [('type', '=', 'bank'), ('bank_account_id', '=', False), ('company_id', '=', self.env.company.id)])
check_company=True,
)
bank_bic = fields.Char(related='bank_id.bic', readonly=False, string="Bic")
num_journals_without_account = fields.Integer(default=lambda self: self._number_unlinked_journal())
num_journals_without_account_bank = fields.Integer(default=lambda self: self._number_unlinked_journal('bank'))
num_journals_without_account_credit = fields.Integer(default=lambda self: self._number_unlinked_journal('credit'))
company_id = fields.Many2one('res.company', required=True, compute='_compute_company_id')
def _number_unlinked_journal(self):
return self.env['account.journal'].search([('type', '=', 'bank'), ('bank_account_id', '=', False),
('id', '!=', self.default_linked_journal_id())], count=True)
def _number_unlinked_journal(self, journal_type):
return self.env['account.journal'].search_count([
('type', '=', journal_type),
('bank_account_id', '=', False),
('id', '!=', self.default_linked_journal_id(journal_type)),
])
@api.onchange('acc_number')
def _onchange_acc_number(self):
@ -98,7 +132,7 @@ class SetupBarBankConfigWizard(models.TransientModel):
vals['new_journal_name'] = vals['acc_number']
# If no bank has been selected, but we have a bic, we are using it to find or create the bank
if not vals['bank_id'] and vals['bank_bic']:
if not vals.get('bank_id') and vals.get('bank_bic'):
vals['bank_id'] = self.env['res.bank'].search([('bic', '=', vals['bank_bic'])], limit=1).id \
or self.env['res.bank'].create({'name': vals['bank_bic'], 'bic': vals['bank_bic']}).id
@ -112,38 +146,56 @@ class SetupBarBankConfigWizard(models.TransientModel):
@api.depends('journal_id') # Despite its name, journal_id is actually a One2many field
def _compute_linked_journal_id(self):
journal_type = self.env.context.get('journal_type', 'bank')
for record in self:
record.linked_journal_id = record.journal_id and record.journal_id[0] or record.default_linked_journal_id()
record.linked_journal_id = record.journal_id and record.journal_id[0] or record.default_linked_journal_id(journal_type)
def default_linked_journal_id(self):
for journal_id in self.env['account.journal'].search([('type', '=', 'bank'), ('bank_account_id', '=', False)]):
empty_journal_count = self.env['account.move'].search_count([('journal_id', '=', journal_id.id)])
if empty_journal_count == 0:
return journal_id.id
return False
def default_linked_journal_id(self, journal_type):
journals_with_moves = self.env['account.move'].search_fetch(
[
('journal_id', '!=', False),
('journal_id.type', '=', journal_type),
],
['journal_id'],
).journal_id
return self.env['account.journal'].search(
[
('type', '=', journal_type),
('bank_account_id', '=', False),
('id', 'not in', journals_with_moves.ids),
],
limit=1,
).id
def set_linked_journal_id(self):
""" Called when saving the wizard.
"""
journal_type = self.env.context.get('journal_type', 'bank')
for record in self:
selected_journal = record.linked_journal_id
if not selected_journal:
new_journal_code = self.env['account.journal'].get_next_bank_cash_default_code('bank', self.env.company)
new_journal_code = self.env['account.journal']._get_next_journal_default_code(journal_type, self.env.company)
company = self.env.company
selected_journal = self.env['account.journal'].create({
record.linked_journal_id = self.env['account.journal'].create({
'name': record.new_journal_name,
'code': new_journal_code,
'type': 'bank',
'type': journal_type,
'company_id': company.id,
'bank_account_id': record.res_partner_bank_id.id,
'bank_statements_source': 'undefined',
})
else:
selected_journal.bank_account_id = record.res_partner_bank_id.id
selected_journal.name = record.new_journal_name
def validate(self):
""" Called by the validation button of this wizard. Serves as an
"""Called by the validation button of this wizard. Serves as an
extension hook in account_bank_statement_import.
"""
self.linked_journal_id.mark_bank_setup_as_done_action()
return {'type': 'ir.actions.client', 'tag': 'soft_reload'}
def _compute_company_id(self):
for wizard in self:
if not wizard.company_id:
wizard.company_id = self.env.company

View file

@ -7,24 +7,25 @@
<field name="model">account.financial.year.op</field>
<field name="arch" type="xml">
<form>
<div>
<span class="figure-caption">Never miss a deadline, with automated statements and alerts.</span>
</div>
<sheet>
<group>
<group string="Fiscal Years">
<field name="opening_move_posted" invisible="1"/>
<field name="opening_date" attrs="{'readonly': [('opening_move_posted', '=', True)]}"/>
<field name="opening_move_posted" invisible="1"/>
<field name="opening_date"/>
<label for="fiscalyear_last_month" string="Fiscal Year End"/>
<div>
<field name="fiscalyear_last_day" class="text-center me-2" style="width: 20% !important;"/>
<field name="fiscalyear_last_month" class="w-75"/>
</div>
</group>
<label for="fiscalyear_last_month" string="Fiscal Year End"/>
<div>
<field name="fiscalyear_last_day" class="text-center me-2" style="width: 20% !important;"/>
<field name="fiscalyear_last_month" class="w-75"/>
</div>
</group>
</sheet>
<footer>
<button name="action_save_onboarding_fiscal_year" string="Apply"
class="oe_highlight" type="object" data-hotkey="q" />
<button special="cancel" data-hotkey="z" string="Cancel" />
<button special="cancel" data-hotkey="x" string="Cancel" />
</footer>
</form>
</field>
@ -35,55 +36,69 @@
<field name="model">account.setup.bank.manual.config</field>
<field name="arch" type="xml">
<form>
<field name="num_journals_without_account" invisible="1"/>
<field name="journal_id" invisible="1"/>
<field name="company_id" invisible="1"/>
<field name="linked_journal_id" invisible="1"/>
<sheet>
<group>
<group>
<field name="acc_number" placeholder="e.g BE15001559627230"/>
<field name="bank_id" placeholder="e.g Bank of America"/>
<field name="bank_bic" placeholder="e.g GEBABEBB" string="Bank Identifier Code"/>
</group>
</group>
<group attrs="{'invisible': [('num_journals_without_account', '=', 0)]}">
<group>
<field name="linked_journal_id" options="{'no_create': True}"/>
</group>
<group>
<span class="text-muted" colspan="2">
Leave empty to create a new journal for this bank account, or select a journal to link it with the bank account.
</span>
</group>
<field name="acc_number" placeholder="e.g BE15001559627230"/>
<field name="bank_id" placeholder="e.g Bank of America"/>
<field name="bank_bic" placeholder="e.g GEBABEBB" string="Bank Identifier Code"/>
<field name="linked_journal_id"
options="{'no_create': True}"
placeholder="Leave empty to create new"
invisible="num_journals_without_account_bank == 0"
domain="[('type', '=', 'bank'), ('bank_account_id', '=', False)]"
/>
</group>
</sheet>
<footer>
<button string="Create" class="oe_highlight" type="object" name="validate" data-hotkey="q"/>
<button string="Cancel" special="cancel" data-hotkey="z"/>
<button string="Cancel" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>
</record>
<record id="setup_credit_card_account_wizard" model="ir.ui.view">
<field name="name">account.online.sync.res.partner.credit.card.setup.form</field>
<field name="model">account.setup.bank.manual.config</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="acc_number" placeholder="e.g BE15001559627230"/>
<field name="bank_id" placeholder="e.g Bank of America"/>
<field name="linked_journal_id"
options="{'no_create': True}"
placeholder="Leave empty to create new"
invisible="num_journals_without_account_credit == 0"
domain="[('type', '=', 'credit'), ('bank_account_id', '=', False)]"
/>
</group>
</sheet>
<footer>
<button string="Create" class="oe_highlight" type="object" name="validate" data-hotkey="q"/>
<button string="Cancel" special="cancel" data-hotkey="x"/>
</footer>
</form>
</field>
</record>
<record id="init_accounts_tree" model="ir.ui.view">
<field name="name">account.setup.opening.move.line.tree</field>
<field name="name">account.setup.opening.move.line.list</field>
<field name="model">account.account</field>
<field name="arch" type="xml">
<tree editable="top" create="1" delete="1" decoration-muted="opening_debit == 0 and opening_credit == 0">
<list editable="top" create="1" delete="1" decoration-muted="opening_debit == 0 and opening_credit == 0" open_form_view="True">
<field name="code"/>
<field name="name"/>
<field name="company_id" invisible="1"/>
<field name="company_ids" column_invisible="True"/>
<field name="account_type" widget="account_type_selection"/>
<field name="reconcile" widget="boolean_toggle"/>
<field name="reconcile" optional="hide" widget="boolean_toggle"/>
<field name="active" optional="show" widget="boolean_toggle"/>
<field name="opening_debit" options="{'no_symbol': True}"/>
<field name="opening_credit" options="{'no_symbol': True}"/>
<field name="opening_balance" optional="hide" options="{'no_symbol': True}"/>
<field name="tax_ids" optional="hide" widget="many2many_tags"/>
<field name="tax_ids" optional="hide" widget="many2many_tax_tags"/>
<field name="tag_ids" optional="hide" widget="many2many_tags"/>
<field name="allowed_journal_ids" optional="hide" widget="many2many_tags"/>
<button name="action_read_account" type="object" string="Setup" class="float-end btn-secondary"/>
</tree>
</list>
</field>
</record>