mirror of
https://github.com/bringout/oca-ocb-accounting.git
synced 2026-04-22 13:22:06 +02:00
19.0 vanilla
This commit is contained in:
parent
ba20ce7443
commit
768b70e05e
2357 changed files with 1057103 additions and 712486 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -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 > 9"/>
|
||||
<span invisible="nb_unmodified_bills < 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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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 & 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 & 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 & 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>
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'},
|
||||
},
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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'}
|
||||
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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), '&', ('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), '&', ('can_group_payments', '=', True), ('group_payment', '=', False)]}"/>
|
||||
<div name="amount_div" class="o_row"
|
||||
attrs="{'invisible': ['|', ('can_edit_wizard', '=', False), '&', ('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), '&', ('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), '&', ('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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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'}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue